mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 10:12:44 +00:00
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:
@@ -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());
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user