sidebar history: group by project toggle with show more/less

Adds a group-by-project toggle to the Sessions header. When ON, the
flat session list becomes a grouped list — one section per project,
preview-capped at 6 agents per project with "Show N more" / "Show less"
controls per group. When OFF, the flat list stays as before.

Architecture preserves the Phase 7b perf model:

- Controller (useSidebarSessionsController) owns groupByProject and
  expandedProjects state. Expansion resets on grouping-off, host change,
  and filter change. Group-by persists across hosts as a UI preference.

- A new useOrderedAgentProjectShape selector reactively subscribes to
  per-agent cwd via useStoreWithEqualityFn(..., shallow). When project
  assignments don't change, the array reference stays stable; when an
  agent's project changes, the parent re-derives groups. Shape objects
  are reused across renders when project fields are unchanged.

- deriveGroupedSidebarSessions takes the resolved shape directly (no
  callback indirection) and returns ordered groups with visible/hidden
  ids based on per-project expansion state.

- Header and Footer components are React.memo'd with primitive props
  only, so they don't re-render on irrelevant agent updates. Per-row
  status indicators continue to subscribe via the shared tab descriptor
  path, isolated from parent renders.

- Project ordering: first-occurrence in the createdAt-desc id list,
  matching the user's mental model of "most-recent project first" and
  avoiding a per-render activity-timestamp computation.

Visual primitive shared between the workspace tree project row and the
new Sessions group header (packages/app/src/components/sidebar/
sidebar-project-row-visual.tsx) — no two systems for project visuals.
The workspace tree wraps it with its own chevron/status; the Sessions
header uses it bare.

Tests added: state-machine cases on the controller, pure-helper coverage
on grouping derivation, render-boundary stability for grouped data
references across irrelevant updates and reactivity on project changes,
and a footer click test.
This commit is contained in:
Mohamed Boudra
2026-05-08 20:14:38 +07:00
parent b252cd98d2
commit a3c3d25592
15 changed files with 1275 additions and 231 deletions
+40 -2
View File
@@ -90,6 +90,10 @@ interface SidebarSharedProps {
setSidebarViewMode: (mode: SidebarSessionViewMode) => void;
sidebarSessionFilter: SidebarSessionFilter;
setSidebarSessionFilter: (filter: SidebarSessionFilter) => void;
groupByProject: boolean;
expandedProjects: ReadonlySet<string>;
setGroupByProject: (next: boolean) => void;
toggleProjectExpanded: (projectKey: string) => void;
hostOptions: ComboboxOption[];
hostTriggerRef: RefObject<View | null>;
isHostPickerOpen: boolean;
@@ -192,8 +196,16 @@ export const LeftSidebar = memo(function LeftSidebar({
);
const hostTriggerRef = useRef<View | null>(null);
const [isHostPickerOpen, setIsHostPickerOpen] = useState(false);
const { sidebarViewMode, setSidebarViewMode, sidebarSessionFilter, setSidebarSessionFilter } =
useSidebarSessionsController({ serverId: activeServerId });
const {
sidebarViewMode,
setSidebarViewMode,
sidebarSessionFilter,
setSidebarSessionFilter,
groupByProject,
expandedProjects,
setGroupByProject,
toggleProjectExpanded,
} = useSidebarSessionsController({ serverId: activeServerId });
const { projects, isInitialLoad, isRevalidating, refreshAll } = useSidebarWorkspacesList({
serverId: activeServerId,
@@ -263,6 +275,10 @@ export const LeftSidebar = memo(function LeftSidebar({
setSidebarViewMode,
sidebarSessionFilter,
setSidebarSessionFilter,
groupByProject,
expandedProjects,
setGroupByProject,
toggleProjectExpanded,
hostOptions,
hostTriggerRef,
isHostPickerOpen,
@@ -505,6 +521,10 @@ function MobileSidebar({
setSidebarViewMode,
sidebarSessionFilter,
setSidebarSessionFilter,
groupByProject,
expandedProjects,
setGroupByProject,
toggleProjectExpanded,
hostOptions,
hostTriggerRef,
isHostPickerOpen,
@@ -689,15 +709,19 @@ function MobileSidebar({
mode={sidebarViewMode}
filter={sidebarSessionFilter}
projects={projects}
groupByProject={groupByProject}
onModeChange={setSidebarViewMode}
onFilterChange={setSidebarSessionFilter}
onGroupByProjectChange={setGroupByProject}
/>
),
[
activeServerId,
groupByProject,
projects,
sidebarSessionFilter,
sidebarViewMode,
setGroupByProject,
setSidebarSessionFilter,
setSidebarViewMode,
],
@@ -733,6 +757,9 @@ function MobileSidebar({
serverId={activeServerId}
projects={projects}
filter={sidebarSessionFilter}
groupByProject={groupByProject}
expandedProjects={expandedProjects}
onProjectExpandedToggle={toggleProjectExpanded}
/>
)}
@@ -766,6 +793,10 @@ function DesktopSidebar({
setSidebarViewMode,
sidebarSessionFilter,
setSidebarSessionFilter,
groupByProject,
expandedProjects,
setGroupByProject,
toggleProjectExpanded,
hostOptions,
hostTriggerRef,
isHostPickerOpen,
@@ -847,15 +878,19 @@ function DesktopSidebar({
mode={sidebarViewMode}
filter={sidebarSessionFilter}
projects={projects}
groupByProject={groupByProject}
onModeChange={setSidebarViewMode}
onFilterChange={setSidebarSessionFilter}
onGroupByProjectChange={setGroupByProject}
/>
),
[
activeServerId,
groupByProject,
projects,
sidebarSessionFilter,
sidebarViewMode,
setGroupByProject,
setSidebarSessionFilter,
setSidebarViewMode,
],
@@ -893,6 +928,9 @@ function DesktopSidebar({
serverId={activeServerId}
projects={projects}
filter={sidebarSessionFilter}
groupByProject={groupByProject}
expandedProjects={expandedProjects}
onProjectExpandedToggle={toggleProjectExpanded}
/>
)}
@@ -2,7 +2,6 @@ import {
View,
Text,
Pressable,
Image,
Platform,
ActivityIndicator,
StatusBar,
@@ -85,7 +84,6 @@ import { useCheckoutGitActionsStore } from "@/stores/checkout-git-actions-store"
import { hasVisibleOrderChanged, mergeWithRemainder } from "@/utils/sidebar-reorder";
import { decideLongPressMove } from "@/utils/sidebar-gesture-arbitration";
import { confirmDialog } from "@/utils/confirm-dialog";
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
import { isEmphasizedStatusDotBucket } from "@/utils/status-dot-color";
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
@@ -114,6 +112,10 @@ 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";
function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null {
if (!icon) {
@@ -505,8 +507,6 @@ function ProjectLeadingVisual({
showChevron?: boolean;
isArchiving?: boolean;
}) {
const placeholderLabel = projectIconPlaceholderLabelFromDisplayName(displayName);
const placeholderInitial = placeholderLabel.charAt(0).toUpperCase();
const activeWorkspace = workspace;
const shouldShowWorkspaceStatus =
activeWorkspace !== null && (isArchiving || activeWorkspace.statusBucket !== "done");
@@ -523,17 +523,13 @@ function ProjectLeadingVisual({
}
if (!shouldShowWorkspaceStatus || !activeWorkspace) {
return (
<View style={styles.projectLeadingVisualSlot}>
<ProjectIcon iconDataUri={iconDataUri} placeholderInitial={placeholderInitial} />
</View>
);
return <SidebarProjectIcon projectName={displayName} iconDataUri={iconDataUri} />;
}
return (
<ProjectLeadingVisualStatus
displayName={displayName}
iconDataUri={iconDataUri}
placeholderInitial={placeholderInitial}
isArchiving={isArchiving}
shouldShowSyncedLoader={shouldShowSyncedLoader}
activeWorkspace={activeWorkspace}
@@ -794,33 +790,15 @@ function WorkspaceKebabMenu({
);
}
function ProjectIcon({
iconDataUri,
placeholderInitial,
}: {
iconDataUri: string | null;
placeholderInitial: string;
}) {
const imageSource = useMemo(() => ({ uri: iconDataUri ?? "" }), [iconDataUri]);
if (iconDataUri) {
return <Image source={imageSource} style={styles.projectIcon} />;
}
return (
<View style={styles.projectIconFallback}>
<Text style={styles.projectIconFallbackText}>{placeholderInitial}</Text>
</View>
);
}
function ProjectLeadingVisualStatus({
displayName,
iconDataUri,
placeholderInitial,
isArchiving,
shouldShowSyncedLoader,
activeWorkspace,
}: {
displayName: string;
iconDataUri: string | null;
placeholderInitial: string;
isArchiving: boolean;
shouldShowSyncedLoader: boolean;
activeWorkspace: SidebarWorkspaceEntry;
@@ -860,7 +838,7 @@ function ProjectLeadingVisualStatus({
return (
<View style={styles.projectLeadingVisualSlot}>
<ProjectIcon iconDataUri={iconDataUri} placeholderInitial={placeholderInitial} />
<SidebarProjectIcon projectName={displayName} iconDataUri={iconDataUri} />
{dotColorStyle ? (
<StatusDotOverlay
dotColorStyle={dotColorStyle}
@@ -1235,25 +1213,27 @@ function ProjectHeaderRow({
],
[isDragging, selected, isHovered],
);
const projectLeadingVisual = useMemo(
() => (
<ProjectLeadingVisual
displayName={displayName}
iconDataUri={iconDataUri}
workspace={workspace}
chevron={chevron}
showChevron={isHovered && chevron !== null}
isArchiving={isArchiving}
/>
),
[chevron, displayName, iconDataUri, isArchiving, isHovered, workspace],
);
const rowChildren = (
<>
<View style={styles.projectRowLeft}>
<ProjectLeadingVisual
displayName={displayName}
iconDataUri={iconDataUri}
workspace={workspace}
chevron={chevron}
showChevron={isHovered && chevron !== null}
isArchiving={isArchiving}
/>
<View style={styles.projectTitleGroup}>
<Text style={styles.projectTitle} numberOfLines={1}>
{displayName}
</Text>
</View>
</View>
<SidebarProjectRowVisual
projectName={displayName}
iconDataUri={iconDataUri}
leadingVisual={projectLeadingVisual}
/>
<ProjectRowTrailingActions
project={project}
displayName={displayName}
@@ -2651,25 +2631,6 @@ const styles = StyleSheet.create((theme) => ({
zIndex: 3,
...theme.shadow.md,
},
projectRowLeft: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
flex: 1,
minWidth: 0,
},
projectTitleGroup: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
flex: 1,
minWidth: 0,
},
projectIcon: {
width: "100%",
height: "100%",
borderRadius: theme.borderRadius.sm,
},
projectLeadingVisualSlot: {
position: "relative",
width: theme.iconSize.md,
@@ -2678,26 +2639,6 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
justifyContent: "center",
},
projectIconFallback: {
width: "100%",
height: "100%",
borderRadius: theme.borderRadius.sm,
borderWidth: 1,
borderColor: theme.colors.border,
alignItems: "center",
justifyContent: "center",
},
projectIconFallbackText: {
color: theme.colors.foregroundMuted,
fontSize: 9,
},
projectTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: "400",
minWidth: 0,
flexShrink: 1,
},
projectActionButton: {
flexDirection: "row",
alignItems: "center",
@@ -0,0 +1,111 @@
import { memo, useMemo, type ReactElement } from "react";
import { Image, Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
export interface SidebarProjectRowVisualProps {
projectName: string;
iconDataUri: string | null;
leadingVisual?: ReactElement | null;
}
export const SidebarProjectRowVisual = memo(function SidebarProjectRowVisual({
projectName,
iconDataUri,
leadingVisual = null,
}: SidebarProjectRowVisualProps): ReactElement {
return (
<View style={styles.projectRowLeft}>
{leadingVisual ?? <SidebarProjectIcon projectName={projectName} iconDataUri={iconDataUri} />}
<SidebarProjectName projectName={projectName} />
</View>
);
});
export const SidebarProjectIcon = memo(function SidebarProjectIcon({
projectName,
iconDataUri,
}: {
projectName: string;
iconDataUri: string | null;
}): ReactElement {
const placeholderLabel = projectIconPlaceholderLabelFromDisplayName(projectName);
const placeholderInitial = placeholderLabel.charAt(0).toUpperCase();
const imageSource = useMemo(() => ({ uri: iconDataUri ?? "" }), [iconDataUri]);
return (
<View style={styles.projectLeadingVisualSlot}>
{iconDataUri ? (
<Image source={imageSource} style={styles.projectIcon} />
) : (
<View style={styles.projectIconFallback}>
<Text style={styles.projectIconFallbackText}>{placeholderInitial}</Text>
</View>
)}
</View>
);
});
const SidebarProjectName = memo(function SidebarProjectName({
projectName,
}: {
projectName: string;
}): ReactElement {
return (
<View style={styles.projectTitleGroup}>
<Text style={styles.projectTitle} numberOfLines={1}>
{projectName}
</Text>
</View>
);
});
const styles = StyleSheet.create((theme) => ({
projectRowLeft: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
flex: 1,
minWidth: 0,
},
projectLeadingVisualSlot: {
position: "relative",
width: theme.iconSize.md,
height: theme.iconSize.md,
flexShrink: 0,
alignItems: "center",
justifyContent: "center",
},
projectIcon: {
width: "100%",
height: "100%",
borderRadius: theme.borderRadius.sm,
},
projectIconFallback: {
width: "100%",
height: "100%",
borderRadius: theme.borderRadius.sm,
borderWidth: 1,
borderColor: theme.colors.border,
alignItems: "center",
justifyContent: "center",
},
projectIconFallbackText: {
color: theme.colors.foregroundMuted,
fontSize: 9,
},
projectTitleGroup: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
flex: 1,
minWidth: 0,
},
projectTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: "400",
minWidth: 0,
flexShrink: 1,
},
}));
@@ -6,6 +6,75 @@ import { describe, expect, it } from "vitest";
import { useSidebarSessionsController } from "./controller";
describe("useSidebarSessionsController", () => {
it("starts with project grouping off and no expanded projects", () => {
const { result } = renderHook(() => useSidebarSessionsController({ serverId: "server-1" }));
expect(result.current.groupByProject).toBe(false);
expect(result.current.expandedProjects.size).toBe(0);
});
it("enables project grouping without expanding projects", () => {
const { result } = renderHook(() => useSidebarSessionsController({ serverId: "server-1" }));
act(() => {
result.current.setGroupByProject(true);
});
expect(result.current.groupByProject).toBe(true);
expect(result.current.expandedProjects.size).toBe(0);
});
it("toggles one expanded project", () => {
const { result } = renderHook(() => useSidebarSessionsController({ serverId: "server-1" }));
act(() => {
result.current.toggleProjectExpanded("p1");
});
expect(result.current.expandedProjects.has("p1")).toBe(true);
act(() => {
result.current.toggleProjectExpanded("p1");
});
expect(result.current.expandedProjects.size).toBe(0);
});
it("toggles expanded projects independently", () => {
const { result } = renderHook(() => useSidebarSessionsController({ serverId: "server-1" }));
act(() => {
result.current.toggleProjectExpanded("p1");
result.current.toggleProjectExpanded("p2");
});
expect([...result.current.expandedProjects].sort()).toEqual(["p1", "p2"]);
act(() => {
result.current.toggleProjectExpanded("p1");
});
expect([...result.current.expandedProjects]).toEqual(["p2"]);
});
it("clears expanded projects when project grouping is disabled", () => {
const { result } = renderHook(() => useSidebarSessionsController({ serverId: "server-1" }));
act(() => {
result.current.setGroupByProject(true);
result.current.toggleProjectExpanded("p1");
});
expect(result.current.expandedProjects.has("p1")).toBe(true);
act(() => {
result.current.setGroupByProject(false);
});
expect(result.current.groupByProject).toBe(false);
expect(result.current.expandedProjects.size).toBe(0);
});
it("resets the session filter when the active host changes", () => {
const { result, rerender } = renderHook(
({ serverId }) => useSidebarSessionsController({ serverId }),
@@ -29,4 +98,82 @@ describe("useSidebarSessionsController", () => {
expect(result.current.sidebarSessionFilter).toEqual({ type: "all" });
expect(result.current.sidebarViewMode).toBe("workspaces");
});
it("clears expanded projects when the active host changes", () => {
const { result, rerender } = renderHook(
({ serverId }) => useSidebarSessionsController({ serverId }),
{ initialProps: { serverId: "server-1" as string | null } },
);
act(() => {
result.current.setGroupByProject(true);
result.current.toggleProjectExpanded("p1");
});
expect(result.current.expandedProjects.has("p1")).toBe(true);
rerender({ serverId: "server-2" });
expect(result.current.expandedProjects.size).toBe(0);
});
it("clears expanded projects when the session filter changes", () => {
const { result } = renderHook(() => useSidebarSessionsController({ serverId: "server-1" }));
act(() => {
result.current.setGroupByProject(true);
result.current.toggleProjectExpanded("p1");
});
expect(result.current.expandedProjects.has("p1")).toBe(true);
act(() => {
result.current.setSidebarSessionFilter({
type: "project",
projectKey: "project-a",
});
});
expect(result.current.sidebarSessionFilter).toEqual({
type: "project",
projectKey: "project-a",
});
expect(result.current.expandedProjects.size).toBe(0);
});
it("does not clear the session filter when project grouping is enabled", () => {
const { result } = renderHook(() => useSidebarSessionsController({ serverId: "server-1" }));
act(() => {
result.current.setSidebarSessionFilter({
type: "project",
projectKey: "project-a",
});
});
act(() => {
result.current.setGroupByProject(true);
});
expect(result.current.groupByProject).toBe(true);
expect(result.current.sidebarSessionFilter).toEqual({
type: "project",
projectKey: "project-a",
});
});
it("preserves project grouping when the active host changes", () => {
const { result, rerender } = renderHook(
({ serverId }) => useSidebarSessionsController({ serverId }),
{ initialProps: { serverId: "server-1" as string | null } },
);
act(() => {
result.current.setGroupByProject(true);
});
rerender({ serverId: "server-2" });
expect(result.current.groupByProject).toBe(true);
});
});
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import type { SidebarSessionFilter, SidebarSessionViewMode } from "./types";
export function useSidebarSessionsController({ serverId }: { serverId: string | null }) {
@@ -7,6 +7,32 @@ export function useSidebarSessionsController({ serverId }: { serverId: string |
const [sidebarSessionFilter, setSidebarSessionFilter] = useState<SidebarSessionFilter>({
type: "all",
});
const [groupByProject, setGroupByProjectState] = useState(false);
const [expandedProjects, setExpandedProjects] = useState<ReadonlySet<string>>(() => new Set());
const setGroupByProject = useCallback((next: boolean) => {
setGroupByProjectState(next);
if (!next) {
setExpandedProjects((previous) => (previous.size === 0 ? previous : new Set()));
}
}, []);
const setSidebarSessionFilterWithReset = useCallback((next: SidebarSessionFilter) => {
setSidebarSessionFilter(next);
setExpandedProjects((previous) => (previous.size === 0 ? previous : new Set()));
}, []);
const toggleProjectExpanded = useCallback((projectKey: string) => {
setExpandedProjects((previous) => {
const next = new Set(previous);
if (next.has(projectKey)) {
next.delete(projectKey);
} else {
next.add(projectKey);
}
return next;
});
}, []);
useEffect(() => {
if (previousServerIdRef.current === serverId) {
@@ -14,12 +40,17 @@ export function useSidebarSessionsController({ serverId }: { serverId: string |
}
previousServerIdRef.current = serverId;
setSidebarSessionFilter({ type: "all" });
setExpandedProjects((previous) => (previous.size === 0 ? previous : new Set()));
}, [serverId]);
return {
sidebarViewMode,
setSidebarViewMode,
sidebarSessionFilter,
setSidebarSessionFilter,
setSidebarSessionFilter: setSidebarSessionFilterWithReset,
groupByProject,
expandedProjects,
setGroupByProject,
toggleProjectExpanded,
};
}
@@ -0,0 +1,222 @@
import React, { memo, useCallback, useMemo, 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 { useProjectIconQuery } from "@/hooks/use-project-icon-query";
import { useSessionStore } from "@/stores/session-store";
import {
deriveGroupedSidebarSessions,
type SidebarSessionAgentProject,
type SidebarSessionGroup,
} from "./session-filtering";
import type { ResolvedSidebarSessionProject } from "./types";
const GROUPED_SESSION_LIMIT = 6;
type SessionStoreState = ReturnType<typeof useSessionStore.getState>;
export type SidebarSessionListItem =
| { kind: "header"; projectKey: string; projectName: string; projectIconKey: string | null }
| { kind: "row"; id: string; serverId: string }
| { kind: "footer"; projectKey: string; hiddenCount: number; isExpanded: boolean };
type ResolveCwdToProject = (cwd: string) => ResolvedSidebarSessionProject | null;
export function useOrderedAgentProjectShape(input: {
orderedIds: readonly string[];
serverId: string;
resolveCwdToProject: ResolveCwdToProject;
}): readonly SidebarSessionAgentProject[] {
const { orderedIds, resolveCwdToProject, serverId } = input;
const selector = useMemo(() => {
let previousById = new Map<string, SidebarSessionAgentProject>();
return (state: SessionStoreState) => {
const agents = state.sessions[serverId]?.agents;
if (!agents) {
previousById = new Map();
return [];
}
const nextById = new Map<string, SidebarSessionAgentProject>();
const agentsWithProjects: SidebarSessionAgentProject[] = [];
for (const id of orderedIds) {
const agent = agents.get(id);
if (!agent) {
continue;
}
const project = resolveCwdToProject(agent.cwd);
if (!project) {
continue;
}
const previous = previousById.get(id);
const next =
previous &&
previous.projectKey === project.projectKey &&
previous.projectIconKey === project.projectIconKey &&
previous.projectName === project.projectName
? previous
: {
id,
projectKey: project.projectKey,
projectIconKey: project.projectIconKey,
projectName: project.projectName,
};
nextById.set(id, next);
agentsWithProjects.push(next);
}
previousById = nextById;
return agentsWithProjects;
};
}, [orderedIds, resolveCwdToProject, serverId]);
return useStoreWithEqualityFn(useSessionStore, selector, shallow);
}
export function useGroupedSidebarSessionListData(input: {
agentsWithProjects: readonly SidebarSessionAgentProject[];
expandedProjects: ReadonlySet<string>;
serverId: string | null;
}): readonly SidebarSessionListItem[] {
const groupedSessions = useMemo(
() =>
deriveGroupedSidebarSessions({
agentsWithProjects: input.agentsWithProjects,
expandedProjects: input.expandedProjects,
limit: GROUPED_SESSION_LIMIT,
}),
[input.agentsWithProjects, input.expandedProjects],
);
return useMemo(
() => flattenGroupedSidebarSessions(groupedSessions, input.serverId),
[groupedSessions, input.serverId],
);
}
function flattenGroupedSidebarSessions(
groupedSessions: readonly SidebarSessionGroup[],
serverId: string | null,
): readonly SidebarSessionListItem[] {
if (!serverId) {
return [];
}
const items: SidebarSessionListItem[] = [];
for (const group of groupedSessions) {
items.push({
kind: "header",
projectKey: group.projectKey,
projectName: group.projectName,
projectIconKey: group.projectIconKey,
});
for (const id of group.visibleIds) {
items.push({ kind: "row", id, serverId });
}
if (group.totalCount > GROUPED_SESSION_LIMIT) {
items.push({
kind: "footer",
projectKey: group.projectKey,
hiddenCount: group.hiddenCount,
isExpanded: group.isExpanded,
});
}
}
return items;
}
export const SidebarSessionGroupHeader = memo(function SidebarSessionGroupHeader({
serverId,
projectName,
projectIconKey,
}: {
serverId: string | null;
projectName: string;
projectIconKey: string | null;
}): ReactElement {
const { icon } = useProjectIconQuery({ serverId: serverId ?? "", cwd: projectIconKey ?? "" });
const dataUri = useMemo(() => {
if (!icon || !icon.mimeType || !icon.data) {
return null;
}
return `data:${icon.mimeType};base64,${icon.data}`;
}, [icon]);
return (
<View style={styles.projectRow}>
<SidebarProjectRowVisual projectName={projectName} iconDataUri={dataUri} />
</View>
);
});
export const SidebarSessionGroupFooter = memo(function SidebarSessionGroupFooter({
projectKey,
hiddenCount,
isExpanded,
onPress,
}: {
projectKey: string;
hiddenCount: number;
isExpanded: boolean;
onPress: (projectKey: string) => void;
}): ReactElement {
const handlePress = useCallback(() => {
onPress(projectKey);
}, [onPress, projectKey]);
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={isExpanded ? "Show less sessions" : `Show ${hiddenCount} more sessions`}
onPress={handlePress}
style={styles.footerRow}
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,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[2],
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,
color: theme.colors.foregroundMuted,
},
}));
@@ -1,106 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import { shallow } from "zustand/shallow";
import type { DaemonClient } from "@server/client/daemon-client";
import { useSessionStore, type Agent } from "@/stores/session-store";
import { selectSidebarSessionSlice } from "./select-sidebar-session-slice";
const TIMESTAMP = new Date("2026-05-08T10:00:00.000Z");
const AGENT_DEFAULTS: Agent = {
serverId: "server-1",
id: "agent-1",
provider: "codex",
status: "running",
createdAt: TIMESTAMP,
updatedAt: TIMESTAMP,
lastUserMessageAt: null,
lastActivityAt: TIMESTAMP,
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: null,
runtimeInfo: undefined,
lastUsage: undefined,
lastError: null,
title: "Agent",
cwd: "/repo/main",
model: null,
thinkingOptionId: undefined,
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
archivedAt: null,
labels: {},
projectPlacement: null,
};
function makeAgent(input: Partial<Agent> = {}): Agent {
return { ...AGENT_DEFAULTS, ...input };
}
function seedAgent(agent: Agent) {
useSessionStore.getState().initializeSession("server-1", {} as unknown as DaemonClient);
useSessionStore.getState().setAgents("server-1", new Map([[agent.id, agent]]));
}
afterEach(() => {
useSessionStore.setState({ sessions: {}, agentLastActivity: new Map() });
});
describe("sidebar session render boundaries", () => {
it("changes the row slice when lastActivityAt changes", () => {
const beforeAgent = makeAgent();
seedAgent(beforeAgent);
const beforeSlice = selectSidebarSessionSlice(
useSessionStore.getState(),
"server-1",
"agent-1",
);
const afterAgent = makeAgent({
lastActivityAt: new Date("2026-05-08T11:00:00.000Z"),
});
useSessionStore.getState().setAgents("server-1", new Map([["agent-1", afterAgent]]));
const afterSlice = selectSidebarSessionSlice(useSessionStore.getState(), "server-1", "agent-1");
expect(shallow(beforeSlice, afterSlice)).toBe(false);
});
it("does not change the row slice for fields the row does not consume", () => {
seedAgent(makeAgent());
const beforeSlice = selectSidebarSessionSlice(
useSessionStore.getState(),
"server-1",
"agent-1",
);
useSessionStore.getState().setAgents(
"server-1",
new Map([
[
"agent-1",
makeAgent({
status: "idle",
createdAt: new Date("2026-05-08T11:00:00.000Z"),
requiresAttention: true,
}),
],
]),
);
const afterSlice = selectSidebarSessionSlice(useSessionStore.getState(), "server-1", "agent-1");
expect(shallow(beforeSlice, afterSlice)).toBe(true);
});
});
@@ -0,0 +1,255 @@
/**
* @vitest-environment jsdom
*/
import { act, fireEvent, render, renderHook } from "@testing-library/react";
import React from "react";
import { useCallback, useMemo } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { shallow } from "zustand/shallow";
import { useStoreWithEqualityFn } from "zustand/traditional";
import type { DaemonClient } from "@server/client/daemon-client";
import { useSessionStore, type Agent } from "@/stores/session-store";
import {
SidebarSessionGroupFooter,
useGroupedSidebarSessionListData,
useOrderedAgentProjectShape,
} from "./grouped-view";
import { selectSidebarSessionSlice } from "./select-sidebar-session-slice";
vi.hoisted(() => {
Object.assign(globalThis, { __DEV__: false });
});
vi.mock("@/hooks/use-project-icon-query", () => ({
useProjectIconQuery: () => ({ icon: null, isLoading: false, isError: false }),
}));
const TIMESTAMP = new Date("2026-05-08T10:00:00.000Z");
const SERVER_ID = "server-1";
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (styles: unknown) =>
typeof styles === "function"
? styles({
borderRadius: { lg: 8, sm: 4 },
colors: {
border: "#dddddd",
foreground: "#111111",
foregroundMuted: "#666666",
},
fontSize: { sm: 14 },
iconSize: { md: 20 },
spacing: { 1: 4, 2: 8 },
})
: styles,
},
}));
const AGENT_DEFAULTS: Agent = {
serverId: "server-1",
id: "agent-1",
provider: "codex",
status: "running",
createdAt: TIMESTAMP,
updatedAt: TIMESTAMP,
lastUserMessageAt: null,
lastActivityAt: TIMESTAMP,
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: null,
runtimeInfo: undefined,
lastUsage: undefined,
lastError: null,
title: "Agent",
cwd: "/repo/main",
model: null,
thinkingOptionId: undefined,
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
archivedAt: null,
labels: {},
projectPlacement: null,
};
function makeAgent(input: Partial<Agent> = {}): Agent {
return { ...AGENT_DEFAULTS, ...input };
}
function seedAgent(agent: Agent) {
useSessionStore.getState().initializeSession(SERVER_ID, {} as unknown as DaemonClient);
useSessionStore.getState().setAgents(SERVER_ID, new Map([[agent.id, agent]]));
}
function seedAgents(agents: Agent[]) {
useSessionStore.getState().initializeSession(SERVER_ID, {} as unknown as DaemonClient);
useSessionStore
.getState()
.setAgents(SERVER_ID, new Map(agents.map((agent) => [agent.id, agent])));
}
function useGroupedBoundaryHarness() {
const orderedIds = useStoreWithEqualityFn(
useSessionStore,
(state) => Array.from(state.sessions[SERVER_ID]?.agents?.keys() ?? []),
shallow,
);
const resolveCwdToProject = useCallback((cwd: string) => {
if (cwd === "/repo/b") {
return { projectKey: "project-b", projectName: "Project B", projectIconKey: "/repo/b" };
}
return { projectKey: "project-a", projectName: "Project A", projectIconKey: "/repo/a" };
}, []);
const agentsWithProjects = useOrderedAgentProjectShape({
orderedIds,
serverId: SERVER_ID,
resolveCwdToProject,
});
const expandedProjects = useMemo(() => new Set<string>(), []);
return useGroupedSidebarSessionListData({
agentsWithProjects,
expandedProjects,
serverId: SERVER_ID,
});
}
afterEach(() => {
useSessionStore.setState({ sessions: {}, agentLastActivity: new Map() });
});
describe("sidebar session render boundaries", () => {
it("changes the row slice when lastActivityAt changes", () => {
const beforeAgent = makeAgent();
seedAgent(beforeAgent);
const beforeSlice = selectSidebarSessionSlice(
useSessionStore.getState(),
"server-1",
"agent-1",
);
const afterAgent = makeAgent({
lastActivityAt: new Date("2026-05-08T11:00:00.000Z"),
});
useSessionStore.getState().setAgents("server-1", new Map([["agent-1", afterAgent]]));
const afterSlice = selectSidebarSessionSlice(useSessionStore.getState(), "server-1", "agent-1");
expect(shallow(beforeSlice, afterSlice)).toBe(false);
});
it("does not change the row slice for fields the row does not consume", () => {
seedAgent(makeAgent());
const beforeSlice = selectSidebarSessionSlice(
useSessionStore.getState(),
"server-1",
"agent-1",
);
useSessionStore.getState().setAgents(
"server-1",
new Map([
[
"agent-1",
makeAgent({
status: "idle",
createdAt: new Date("2026-05-08T11:00:00.000Z"),
requiresAttention: true,
}),
],
]),
);
const afterSlice = selectSidebarSessionSlice(useSessionStore.getState(), "server-1", "agent-1");
expect(shallow(beforeSlice, afterSlice)).toBe(true);
});
it("keeps grouped flattened data stable when activity changes without reordering ids", () => {
seedAgents([makeAgent({ id: "agent-1" }), makeAgent({ id: "agent-2", cwd: "/repo/main-2" })]);
const { result } = renderHook(() => useGroupedBoundaryHarness());
const beforeData = result.current;
act(() => {
useSessionStore.getState().setAgents(
SERVER_ID,
new Map([
[
"agent-1",
makeAgent({
id: "agent-1",
lastActivityAt: new Date("2026-05-08T11:00:00.000Z"),
}),
],
["agent-2", makeAgent({ id: "agent-2", cwd: "/repo/main-2" })],
]),
);
});
expect(result.current).toBe(beforeData);
});
it("changes grouped flattened data when cwd resolves to a different project", () => {
seedAgents([makeAgent({ id: "agent-1", cwd: "/repo/a" })]);
const { result } = renderHook(() => useGroupedBoundaryHarness());
const beforeData = result.current;
act(() => {
useSessionStore
.getState()
.setAgents(SERVER_ID, new Map([["agent-1", makeAgent({ id: "agent-1", cwd: "/repo/b" })]]));
});
expect(result.current).not.toBe(beforeData);
expect(result.current[0]).toMatchObject({ kind: "header", projectKey: "project-b" });
});
it("keeps grouped flattened data stable when cwd changes but resolves to the same project", () => {
seedAgents([makeAgent({ id: "agent-1", cwd: "/repo/a" })]);
const { result } = renderHook(() => useGroupedBoundaryHarness());
const beforeData = result.current;
act(() => {
useSessionStore
.getState()
.setAgents(
SERVER_ID,
new Map([["agent-1", makeAgent({ id: "agent-1", cwd: "/repo/a-worktree" })]]),
);
});
expect(result.current).toBe(beforeData);
});
it("calls the grouped footer press handler with its project key", () => {
const onPress = vi.fn();
const { getByTestId } = render(
<SidebarSessionGroupFooter
projectKey="project-a"
hiddenCount={2}
isExpanded={false}
onPress={onPress}
/>,
);
fireEvent.click(getByTestId("sidebar-session-group-footer-project-a"));
expect(onPress).toHaveBeenCalledWith("project-a");
});
});
@@ -1,12 +1,19 @@
import { describe, expect, it } from "vitest";
import {
createSidebarSessionWorkspaceLookup,
deriveGroupedSidebarSessions,
deriveSidebarSessionFilterAvailability,
deriveSidebarSessionFilterProjects,
shouldIncludeSidebarSessionAgent,
} from "./session-filtering";
import type { SidebarSessionAgent, SidebarSessionWorkspace } from "./types";
interface TestProject {
projectKey: string;
projectName: string;
projectIconKey: string | null;
}
const WORKSPACES: SidebarSessionWorkspace[] = [
{
serverId: "server-1",
@@ -15,6 +22,7 @@ const WORKSPACES: SidebarSessionWorkspace[] = [
workspaceName: "Main",
projectKey: "project-a",
projectName: "Project A",
projectIconKey: "/repo/main",
workspaceDirectory: "/repo/main",
},
{
@@ -24,6 +32,7 @@ const WORKSPACES: SidebarSessionWorkspace[] = [
workspaceName: "Docs",
projectKey: "project-b",
projectName: "Project B",
projectIconKey: "/repo/docs",
workspaceDirectory: "/repo/docs",
},
{
@@ -33,6 +42,7 @@ const WORKSPACES: SidebarSessionWorkspace[] = [
workspaceName: "API",
projectKey: "project-a",
projectName: "Project A",
projectIconKey: "/repo/main",
workspaceDirectory: "/repo/api",
},
];
@@ -61,6 +71,22 @@ function projectWorkspaceKeys(project: {
};
}
function testProject(key: string, name = key): TestProject {
return {
projectKey: key,
projectName: name,
projectIconKey: `/repo/${key}`,
};
}
function agentProject(id: string, project: TestProject) {
return { id, ...project };
}
function agentProjects(ids: readonly string[], project: TestProject) {
return ids.map((id) => agentProject(id, project));
}
describe("sidebar session filtering", () => {
it("hides unmapped agents and includes every mapped active agent for All", () => {
const lookup = createSidebarSessionWorkspaceLookup(WORKSPACES);
@@ -187,3 +213,131 @@ describe("sidebar session filtering", () => {
]);
});
});
describe("deriveGroupedSidebarSessions", () => {
it("returns no groups for empty input", () => {
expect(
deriveGroupedSidebarSessions({
agentsWithProjects: [],
expandedProjects: new Set(),
}),
).toEqual([]);
});
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(),
limit: 6,
});
expect(groups).toEqual([
{
projectKey: "project-a",
projectName: "project-a",
projectIconKey: "/repo/project-a",
visibleIds: ["a1", "a2", "a3"],
hiddenCount: 0,
isExpanded: false,
totalCount: 3,
},
]);
});
it("caps a collapsed project over the limit", () => {
const orderedIds = Array.from({ length: 10 }, (_, index) => `a${index + 1}`);
const projectA = testProject("project-a");
const groups = deriveGroupedSidebarSessions({
agentsWithProjects: agentProjects(orderedIds, projectA),
expandedProjects: new Set(),
limit: 6,
});
expect(groups[0]).toMatchObject({
visibleIds: ["a1", "a2", "a3", "a4", "a5", "a6"],
hiddenCount: 4,
isExpanded: false,
totalCount: 10,
});
});
it("shows every id for an expanded project over the limit", () => {
const orderedIds = Array.from({ length: 10 }, (_, index) => `a${index + 1}`);
const projectA = testProject("project-a");
const groups = deriveGroupedSidebarSessions({
agentsWithProjects: agentProjects(orderedIds, projectA),
expandedProjects: new Set(["project-a"]),
limit: 6,
});
expect(groups[0]).toMatchObject({
visibleIds: orderedIds,
hiddenCount: 0,
isExpanded: true,
totalCount: 10,
});
});
it("orders projects by first occurrence in ordered ids", () => {
const groups = deriveGroupedSidebarSessions({
agentsWithProjects: [
agentProject("a1", testProject("project-a")),
agentProject("b1", testProject("project-b")),
agentProject("a2", testProject("project-a")),
],
expandedProjects: new Set(),
});
expect(groups.map((group) => group.projectKey)).toEqual(["project-a", "project-b"]);
});
it("preserves order within each project", () => {
const groups = deriveGroupedSidebarSessions({
agentsWithProjects: agentProjects(["a1", "a2", "a3"], testProject("project-a")),
expandedProjects: new Set(),
});
expect(groups[0]?.visibleIds).toEqual(["a1", "a2", "a3"]);
});
it("preserves mapped project order after unmapped ids are filtered upstream", () => {
const groups = deriveGroupedSidebarSessions({
agentsWithProjects: [
agentProject("a1", testProject("project-a")),
agentProject("b1", testProject("project-b")),
agentProject("a2", testProject("project-a")),
],
expandedProjects: new Set(),
});
expect(groups.map((group) => ({ key: group.projectKey, ids: group.visibleIds }))).toEqual([
{ key: "project-a", ids: ["a1", "a2"] },
{ key: "project-b", ids: ["b1"] },
]);
});
it("defaults the collapsed limit to 6", () => {
const orderedIds = Array.from({ length: 7 }, (_, index) => `a${index + 1}`);
const projectA = testProject("project-a");
const groups = deriveGroupedSidebarSessions({
agentsWithProjects: agentProjects(orderedIds, projectA),
expandedProjects: new Set(),
});
expect(groups[0]?.visibleIds).toHaveLength(6);
expect(groups[0]?.hiddenCount).toBe(1);
});
it("honors a custom collapsed limit", () => {
const groups = deriveGroupedSidebarSessions({
agentsWithProjects: agentProjects(["a1", "a2", "a3", "a4"], testProject("project-a")),
expandedProjects: new Set(),
limit: 2,
});
expect(groups[0]).toMatchObject({
visibleIds: ["a1", "a2"],
hiddenCount: 2,
});
});
});
@@ -11,6 +11,32 @@ export interface SidebarSessionFilterAvailability {
projectKeys: readonly string[];
}
export interface SidebarSessionGroup {
projectKey: string;
projectName: string;
projectIconKey: string | null;
visibleIds: readonly string[];
hiddenCount: number;
isExpanded: boolean;
totalCount: number;
}
export interface SidebarSessionAgentProject {
id: string;
projectKey: string;
projectName: string;
projectIconKey: string | null;
}
interface SidebarSessionGroupDraft {
projectKey: string;
projectName: string;
projectIconKey: string | null;
ids: string[];
}
const DEFAULT_GROUPED_SESSION_LIMIT = 6;
function executionKey(serverId: string, cwd: string): string {
return `${serverId}:${cwd}`;
}
@@ -119,3 +145,40 @@ export function deriveSidebarSessionFilterProjects(input: {
return projects;
}
export function deriveGroupedSidebarSessions(input: {
agentsWithProjects: readonly SidebarSessionAgentProject[];
expandedProjects: ReadonlySet<string>;
limit?: number;
}): readonly SidebarSessionGroup[] {
const limit = input.limit ?? DEFAULT_GROUPED_SESSION_LIMIT;
const groupsByProject = new Map<string, SidebarSessionGroupDraft>();
for (const agent of input.agentsWithProjects) {
let group = groupsByProject.get(agent.projectKey);
if (!group) {
group = {
projectKey: agent.projectKey,
projectName: agent.projectName,
projectIconKey: agent.projectIconKey,
ids: [],
};
groupsByProject.set(agent.projectKey, group);
}
group.ids.push(agent.id);
}
return Array.from(groupsByProject.values(), (group) => {
const isExpanded = input.expandedProjects.has(group.projectKey);
const visibleIds = isExpanded ? group.ids : group.ids.slice(0, limit);
return {
projectKey: group.projectKey,
projectName: group.projectName,
projectIconKey: group.projectIconKey,
visibleIds,
hiddenCount: isExpanded ? 0 : Math.max(0, group.ids.length - limit),
isExpanded,
totalCount: group.ids.length,
};
});
}
@@ -253,6 +253,9 @@ describe("SidebarSessionsView row navigation", () => {
serverId={SERVER_ID}
projects={projects()}
filter={ALL_SESSIONS_FILTER}
groupByProject={false}
expandedProjects={new Set()}
onProjectExpandedToggle={vi.fn()}
/>,
);
@@ -280,6 +283,9 @@ describe("SidebarSessionsView row navigation", () => {
serverId={SERVER_ID}
projects={projects()}
filter={ALL_SESSIONS_FILTER}
groupByProject={false}
expandedProjects={new Set()}
onProjectExpandedToggle={vi.fn()}
/>,
);
useSessionStore.getState().setWorkspaces(SERVER_ID, new Map());
+84 -32
View File
@@ -1,4 +1,11 @@
import { Folder, Folders, ListFilter, MessagesSquare, type LucideIcon } from "lucide-react-native";
import {
Folder,
Folders,
LayoutList,
ListFilter,
MessagesSquare,
type LucideIcon,
} from "lucide-react-native";
import { memo, useCallback, useMemo, type ReactElement } from "react";
import { Image, Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated";
@@ -20,8 +27,10 @@ interface SidebarSessionsToggleProps {
mode: SidebarSessionViewMode;
filter: SidebarSessionFilter;
projects: readonly SidebarProjectEntry[];
groupByProject: boolean;
onModeChange: (mode: SidebarSessionViewMode) => void;
onFilterChange: (filter: SidebarSessionFilter) => void;
onGroupByProjectChange: (next: boolean) => void;
}
const PILL_TRANSITION = LinearTransition.duration(160);
@@ -32,8 +41,10 @@ export const SidebarSessionsToggle = memo(function SidebarSessionsToggle({
mode,
filter,
projects,
groupByProject,
onModeChange,
onFilterChange,
onGroupByProjectChange,
}: SidebarSessionsToggleProps): ReactElement {
const sessionsActive = mode === "sessions";
@@ -67,7 +78,9 @@ export const SidebarSessionsToggle = memo(function SidebarSessionsToggle({
serverId={serverId}
filter={filter}
projects={projects}
groupByProject={groupByProject}
onFilterChange={onFilterChange}
onGroupByProjectChange={onGroupByProjectChange}
/>
) : null}
</View>
@@ -78,12 +91,16 @@ function SidebarSessionsFilterMenu({
serverId,
filter,
projects,
groupByProject,
onFilterChange,
onGroupByProjectChange,
}: {
serverId: string | null;
filter: SidebarSessionFilter;
projects: readonly SidebarProjectEntry[];
groupByProject: boolean;
onFilterChange: (filter: SidebarSessionFilter) => void;
onGroupByProjectChange: (next: boolean) => void;
}) {
const { theme } = useUnistyles();
const filterActive = filter.type !== "all";
@@ -110,41 +127,71 @@ function SidebarSessionsFilterMenu({
),
[filterActive, theme.colors.foreground, theme.colors.foregroundMuted, theme.iconSize.sm],
);
const handleGroupByProjectPress = useCallback(() => {
onGroupByProjectChange(!groupByProject);
}, [groupByProject, onGroupByProjectChange]);
const groupByProjectStyle = useCallback(
({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.filterButton,
(hovered || groupByProject) && styles.filterButtonHovered,
],
[groupByProject],
);
const groupByProjectIcon = useMemo(
() => (
<LayoutList
size={theme.iconSize.sm}
color={groupByProject ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
),
[groupByProject, theme.colors.foreground, theme.colors.foregroundMuted, theme.iconSize.sm],
);
return (
<DropdownMenu>
<DropdownMenuTrigger
accessibilityLabel="Filter sessions"
<View style={styles.sessionActions}>
<DropdownMenu>
<DropdownMenuTrigger
accessibilityLabel="Filter sessions"
accessibilityRole="button"
style={filterTriggerStyle}
testID="sidebar-sessions-filter-trigger"
>
{filterIcon}
{filterActive ? <View style={styles.filterBadge} /> : null}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={260} scrollable maxHeight={420}>
<DropdownMenuItem selected={filter.type === "all"} onSelect={handleAllSelect}>
All
</DropdownMenuItem>
{visibleFilterProjects.length > 0 ? (
<>
<DropdownMenuSeparator />
<FilterSectionHeading>Project</FilterSectionHeading>
{visibleFilterProjects.map((project) => (
<ProjectFilterItem
key={project.projectKey}
selected={filter.type === "project" && filter.projectKey === project.projectKey}
label={project.projectName}
projectKey={project.projectKey}
serverId={serverId}
iconWorkingDir={project.iconWorkingDir}
onFilterChange={onFilterChange}
/>
))}
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
<Pressable
accessibilityLabel="Group by project"
accessibilityRole="button"
style={filterTriggerStyle}
testID="sidebar-sessions-filter-trigger"
onPress={handleGroupByProjectPress}
style={groupByProjectStyle}
testID="sidebar-toggle-group-by-project"
>
{filterIcon}
{filterActive ? <View style={styles.filterBadge} /> : null}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={260} scrollable maxHeight={420}>
<DropdownMenuItem selected={filter.type === "all"} onSelect={handleAllSelect}>
All
</DropdownMenuItem>
{visibleFilterProjects.length > 0 ? (
<>
<DropdownMenuSeparator />
<FilterSectionHeading>Project</FilterSectionHeading>
{visibleFilterProjects.map((project) => (
<ProjectFilterItem
key={project.projectKey}
selected={filter.type === "project" && filter.projectKey === project.projectKey}
label={project.projectName}
projectKey={project.projectKey}
serverId={serverId}
iconWorkingDir={project.iconWorkingDir}
onFilterChange={onFilterChange}
/>
))}
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
{groupByProjectIcon}
</Pressable>
</View>
);
}
@@ -280,6 +327,11 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[1],
minWidth: 0,
},
sessionActions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
pill: {
flexDirection: "row",
alignItems: "center",
@@ -11,7 +11,13 @@ export interface SidebarSessionWorkspace {
workspaceName: string;
projectKey: string;
projectName: string;
projectIconKey: string | null;
workspaceDirectory: string;
}
export type ResolvedSidebarSessionProject = Pick<
SidebarSessionWorkspace,
"projectKey" | "projectName" | "projectIconKey"
>;
export type SidebarSessionAgent = Pick<AggregatedAgent, "id" | "serverId" | "cwd" | "archivedAt">;
@@ -65,6 +65,7 @@ export function useSidebarSessionWorkspaces(input: {
workspaceName: workspaceFields.name,
projectKey: project.projectKey,
projectName: project.projectName,
projectIconKey: project.iconWorkingDir || null,
workspaceDirectory,
});
}
+126 -3
View File
@@ -27,22 +27,36 @@ import { formatTimeAgo } from "@/utils/time";
import {
createSidebarSessionWorkspaceLookup,
resolveSidebarSessionWorkspaceId,
resolveSidebarSessionWorkspace,
shouldIncludeSidebarSessionAgent,
} from "./session-filtering";
import { selectSidebarSessionSlice } from "./select-sidebar-session-slice";
import type { SidebarSessionFilter } from "./types";
import type { ResolvedSidebarSessionProject, SidebarSessionFilter } from "./types";
import { useSidebarSessionWorkspaces } from "./use-sidebar-session-workspaces";
import {
SidebarSessionGroupFooter,
SidebarSessionGroupHeader,
type SidebarSessionListItem,
useGroupedSidebarSessionListData,
useOrderedAgentProjectShape,
} from "./grouped-view";
interface SidebarSessionsViewProps {
serverId: string | null;
projects: readonly SidebarProjectEntry[];
filter: SidebarSessionFilter;
groupByProject: boolean;
expandedProjects: ReadonlySet<string>;
onProjectExpandedToggle: (projectKey: string) => void;
}
export function SidebarSessionsView({
serverId,
projects,
filter,
groupByProject,
expandedProjects,
onProjectExpandedToggle,
}: SidebarSessionsViewProps): ReactElement {
const isInitialLoad = useAggregatedAgentsInitialLoad();
const workspaces = useSidebarSessionWorkspaces({ serverId, projects });
@@ -60,15 +74,49 @@ export function SidebarSessionsView({
filter: filterAgent,
sort: "createdAt-desc-stable",
});
const resolveCwdToProject = useCallback(
(cwd: string) => {
if (!serverId) {
return null;
}
const workspace = resolveSidebarSessionWorkspace(lookup, {
id: "",
serverId,
cwd,
archivedAt: null,
});
if (!workspace) {
return null;
}
return {
projectKey: workspace.projectKey,
projectName: workspace.projectName,
projectIconKey: workspace.projectIconKey,
};
},
[lookup, serverId],
);
if (isInitialLoad) {
return <SidebarAgentListSkeleton />;
}
return <SidebarSessionsList serverId={serverId} sessionIds={sessionIds} />;
if (groupByProject) {
return (
<GroupedSidebarSessionsList
serverId={serverId}
sessionIds={sessionIds}
resolveCwdToProject={resolveCwdToProject}
expandedProjects={expandedProjects}
onProjectExpandedToggle={onProjectExpandedToggle}
/>
);
}
return <FlatSidebarSessionsList serverId={serverId} sessionIds={sessionIds} />;
}
const SidebarSessionsList = memo(function SidebarSessionsList({
const FlatSidebarSessionsList = memo(function FlatSidebarSessionsList({
serverId,
sessionIds,
}: {
@@ -95,6 +143,81 @@ const SidebarSessionsList = memo(function SidebarSessionsList({
);
});
const GroupedSidebarSessionsList = memo(function GroupedSidebarSessionsList({
serverId,
sessionIds,
resolveCwdToProject,
expandedProjects,
onProjectExpandedToggle,
}: {
serverId: string | null;
sessionIds: readonly string[];
resolveCwdToProject: (cwd: string) => ResolvedSidebarSessionProject | null;
expandedProjects: ReadonlySet<string>;
onProjectExpandedToggle: (projectKey: string) => void;
}): ReactElement {
const agentsWithProjects = useOrderedAgentProjectShape({
orderedIds: sessionIds,
serverId: serverId ?? "",
resolveCwdToProject,
});
const data = useGroupedSidebarSessionListData({
agentsWithProjects,
expandedProjects,
serverId,
});
const renderItem: ListRenderItem<SidebarSessionListItem> = useCallback(
({ item }) => {
switch (item.kind) {
case "header":
return (
<SidebarSessionGroupHeader
serverId={serverId}
projectName={item.projectName}
projectIconKey={item.projectIconKey}
/>
);
case "row":
return <SidebarSessionRow id={item.id} serverId={item.serverId} />;
case "footer":
return (
<SidebarSessionGroupFooter
projectKey={item.projectKey}
hiddenCount={item.hiddenCount}
isExpanded={item.isExpanded}
onPress={onProjectExpandedToggle}
/>
);
}
},
[onProjectExpandedToggle, serverId],
);
const keyExtractor = useCallback((item: SidebarSessionListItem) => {
switch (item.kind) {
case "header":
return `header:${item.projectKey}`;
case "row":
return `row:${item.id}`;
case "footer":
return `footer:${item.projectKey}`;
}
}, []);
return (
<FlatList
data={data}
style={styles.list}
contentContainerStyle={styles.listContent}
keyExtractor={keyExtractor}
renderItem={renderItem}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
ListEmptyComponent={EmptySessions}
/>
);
});
const SidebarSessionRow = memo(function SidebarSessionRow({
id,
serverId,