diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index a7a18dc366..4c8c612712 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -90,6 +90,10 @@ interface SidebarSharedProps { setSidebarViewMode: (mode: SidebarSessionViewMode) => void; sidebarSessionFilter: SidebarSessionFilter; setSidebarSessionFilter: (filter: SidebarSessionFilter) => void; + groupByProject: boolean; + expandedProjects: ReadonlySet; + setGroupByProject: (next: boolean) => void; + toggleProjectExpanded: (projectKey: string) => void; hostOptions: ComboboxOption[]; hostTriggerRef: RefObject; isHostPickerOpen: boolean; @@ -192,8 +196,16 @@ export const LeftSidebar = memo(function LeftSidebar({ ); const hostTriggerRef = useRef(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} /> )} diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 294f86abb2..a45bd51a04 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -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 ( - - - - ); + return ; } return ( ({ uri: iconDataUri ?? "" }), [iconDataUri]); - if (iconDataUri) { - return ; - } - return ( - - {placeholderInitial} - - ); -} - 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 ( - + {dotColorStyle ? ( ( + + ), + [chevron, displayName, iconDataUri, isArchiving, isHovered, workspace], + ); const rowChildren = ( <> - - - - - - {displayName} - - - + ({ 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", diff --git a/packages/app/src/components/sidebar/sidebar-project-row-visual.tsx b/packages/app/src/components/sidebar/sidebar-project-row-visual.tsx new file mode 100644 index 0000000000..0759af32f6 --- /dev/null +++ b/packages/app/src/components/sidebar/sidebar-project-row-visual.tsx @@ -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 ( + + {leadingVisual ?? } + + + ); +}); + +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 ( + + {iconDataUri ? ( + + ) : ( + + {placeholderInitial} + + )} + + ); +}); + +const SidebarProjectName = memo(function SidebarProjectName({ + projectName, +}: { + projectName: string; +}): ReactElement { + return ( + + + {projectName} + + + ); +}); + +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, + }, +})); diff --git a/packages/app/src/sidebar-sessions/controller.test.ts b/packages/app/src/sidebar-sessions/controller.test.ts index 3bc82ea8fd..d1eb12d14a 100644 --- a/packages/app/src/sidebar-sessions/controller.test.ts +++ b/packages/app/src/sidebar-sessions/controller.test.ts @@ -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); + }); }); diff --git a/packages/app/src/sidebar-sessions/controller.ts b/packages/app/src/sidebar-sessions/controller.ts index c4724f981f..53916cc0e2 100644 --- a/packages/app/src/sidebar-sessions/controller.ts +++ b/packages/app/src/sidebar-sessions/controller.ts @@ -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({ type: "all", }); + const [groupByProject, setGroupByProjectState] = useState(false); + const [expandedProjects, setExpandedProjects] = useState>(() => 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, }; } diff --git a/packages/app/src/sidebar-sessions/grouped-view.tsx b/packages/app/src/sidebar-sessions/grouped-view.tsx new file mode 100644 index 0000000000..4a671b1f56 --- /dev/null +++ b/packages/app/src/sidebar-sessions/grouped-view.tsx @@ -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; + +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(); + + return (state: SessionStoreState) => { + const agents = state.sessions[serverId]?.agents; + if (!agents) { + previousById = new Map(); + return []; + } + + const nextById = new Map(); + 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; + 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 ( + + + + ); +}); + +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 ( + + + {isExpanded ? "Show less" : `Show ${hiddenCount} more`} + + ); +}); + +const styles = StyleSheet.create((theme) => ({ + projectRow: { + minHeight: 36, + paddingVertical: theme.spacing[2], + paddingHorizontal: theme.spacing[2], + borderRadius: theme.borderRadius.lg, + marginBottom: theme.spacing[1], + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + gap: theme.spacing[2], + userSelect: "none", + }, + footerRow: { + minHeight: 36, + 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, + }, +})); diff --git a/packages/app/src/sidebar-sessions/render-boundaries.test.ts b/packages/app/src/sidebar-sessions/render-boundaries.test.ts deleted file mode 100644 index da9ca05bc2..0000000000 --- a/packages/app/src/sidebar-sessions/render-boundaries.test.ts +++ /dev/null @@ -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 { - 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); - }); -}); diff --git a/packages/app/src/sidebar-sessions/render-boundaries.test.tsx b/packages/app/src/sidebar-sessions/render-boundaries.test.tsx new file mode 100644 index 0000000000..f892f6845a --- /dev/null +++ b/packages/app/src/sidebar-sessions/render-boundaries.test.tsx @@ -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 { + 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(), []); + + 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( + , + ); + + fireEvent.click(getByTestId("sidebar-session-group-footer-project-a")); + + expect(onPress).toHaveBeenCalledWith("project-a"); + }); +}); diff --git a/packages/app/src/sidebar-sessions/session-filtering.test.ts b/packages/app/src/sidebar-sessions/session-filtering.test.ts index d2cf324647..185c08a4cc 100644 --- a/packages/app/src/sidebar-sessions/session-filtering.test.ts +++ b/packages/app/src/sidebar-sessions/session-filtering.test.ts @@ -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, + }); + }); +}); diff --git a/packages/app/src/sidebar-sessions/session-filtering.ts b/packages/app/src/sidebar-sessions/session-filtering.ts index bb626f7fa9..8b35a7ac21 100644 --- a/packages/app/src/sidebar-sessions/session-filtering.ts +++ b/packages/app/src/sidebar-sessions/session-filtering.ts @@ -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; + limit?: number; +}): readonly SidebarSessionGroup[] { + const limit = input.limit ?? DEFAULT_GROUPED_SESSION_LIMIT; + const groupsByProject = new Map(); + + 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, + }; + }); +} diff --git a/packages/app/src/sidebar-sessions/sidebar-session-row-navigation.test.tsx b/packages/app/src/sidebar-sessions/sidebar-session-row-navigation.test.tsx index 8c97458c75..7940413fcf 100644 --- a/packages/app/src/sidebar-sessions/sidebar-session-row-navigation.test.tsx +++ b/packages/app/src/sidebar-sessions/sidebar-session-row-navigation.test.tsx @@ -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()); diff --git a/packages/app/src/sidebar-sessions/toggle.tsx b/packages/app/src/sidebar-sessions/toggle.tsx index 5b834a4ab3..a46e326b08 100644 --- a/packages/app/src/sidebar-sessions/toggle.tsx +++ b/packages/app/src/sidebar-sessions/toggle.tsx @@ -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} @@ -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( + () => ( + + ), + [groupByProject, theme.colors.foreground, theme.colors.foregroundMuted, theme.iconSize.sm], + ); return ( - - + + + {filterIcon} + {filterActive ? : null} + + + + All + + {visibleFilterProjects.length > 0 ? ( + <> + + Project + {visibleFilterProjects.map((project) => ( + + ))} + + ) : null} + + + - {filterIcon} - {filterActive ? : null} - - - - All - - {visibleFilterProjects.length > 0 ? ( - <> - - Project - {visibleFilterProjects.map((project) => ( - - ))} - - ) : null} - - + {groupByProjectIcon} + + ); } @@ -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", diff --git a/packages/app/src/sidebar-sessions/types.ts b/packages/app/src/sidebar-sessions/types.ts index bfc8b9ef80..a2b22929ca 100644 --- a/packages/app/src/sidebar-sessions/types.ts +++ b/packages/app/src/sidebar-sessions/types.ts @@ -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; diff --git a/packages/app/src/sidebar-sessions/use-sidebar-session-workspaces.ts b/packages/app/src/sidebar-sessions/use-sidebar-session-workspaces.ts index d792e60514..e33c541028 100644 --- a/packages/app/src/sidebar-sessions/use-sidebar-session-workspaces.ts +++ b/packages/app/src/sidebar-sessions/use-sidebar-session-workspaces.ts @@ -65,6 +65,7 @@ export function useSidebarSessionWorkspaces(input: { workspaceName: workspaceFields.name, projectKey: project.projectKey, projectName: project.projectName, + projectIconKey: project.iconWorkingDir || null, workspaceDirectory, }); } diff --git a/packages/app/src/sidebar-sessions/view.tsx b/packages/app/src/sidebar-sessions/view.tsx index 629f04bf63..ad9c9d3728 100644 --- a/packages/app/src/sidebar-sessions/view.tsx +++ b/packages/app/src/sidebar-sessions/view.tsx @@ -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; + 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 ; } - return ; + if (groupByProject) { + return ( + + ); + } + + return ; } -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; + onProjectExpandedToggle: (projectKey: string) => void; +}): ReactElement { + const agentsWithProjects = useOrderedAgentProjectShape({ + orderedIds: sessionIds, + serverId: serverId ?? "", + resolveCwdToProject, + }); + const data = useGroupedSidebarSessionListData({ + agentsWithProjects, + expandedProjects, + serverId, + }); + + const renderItem: ListRenderItem = useCallback( + ({ item }) => { + switch (item.kind) { + case "header": + return ( + + ); + case "row": + return ; + case "footer": + return ( + + ); + } + }, + [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 ( + + ); +}); + const SidebarSessionRow = memo(function SidebarSessionRow({ id, serverId,