diff --git a/packages/app/e2e/helpers/archive-tab.ts b/packages/app/e2e/helpers/archive-tab.ts index 3c901b2d19..772566efdc 100644 --- a/packages/app/e2e/helpers/archive-tab.ts +++ b/packages/app/e2e/helpers/archive-tab.ts @@ -15,6 +15,7 @@ export interface ArchiveTabAgent { id: string; title: string; cwd: string; + createdAt: string; } interface ArchiveTabDaemonClient { @@ -33,9 +34,9 @@ interface ArchiveTabDaemonClient { waitForFinish(agentId: string, timeout?: number): Promise<{ status: string }>; waitForAgentUpsert( agentId: string, - predicate: (snapshot: { status: string }) => boolean, + predicate: (snapshot: { status: string; createdAt: string }) => boolean, timeout?: number, - ): Promise<{ status: string }>; + ): Promise<{ status: string; createdAt: string }>; fetchAgentHistory(options?: { page?: { limit: number }; }): Promise<{ entries: Array<{ id: string }> }>; @@ -132,6 +133,7 @@ export async function createIdleAgent( id: created.id, title: input.title, cwd: input.cwd, + createdAt: snapshot.createdAt, }; } diff --git a/packages/app/e2e/sidebar-history-sessions.spec.ts b/packages/app/e2e/sidebar-history-sessions.spec.ts new file mode 100644 index 0000000000..c65a89096d --- /dev/null +++ b/packages/app/e2e/sidebar-history-sessions.spec.ts @@ -0,0 +1,361 @@ +import { randomUUID } from "node:crypto"; +import { execSync } from "node:child_process"; +import path from "node:path"; +import { buildHostSessionsRoute, buildHostWorkspaceRoute } from "@/utils/host-routes"; +import { test, expect, type Page } from "./fixtures"; +import { gotoAppShell } from "./helpers/app"; +import { + archiveAgentFromDaemon, + connectArchiveTabDaemonClient, + createIdleAgent, + expectWorkspaceTabVisible, + type ArchiveTabAgent, +} from "./helpers/archive-tab"; +import { + archiveLocalWorkspaceFromDaemon, + connectNewWorkspaceDaemonClient, + openProjectViaDaemon, + type OpenedProject, +} from "./helpers/new-workspace"; +import { createTempGitRepo } from "./helpers/workspace"; +import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs"; +import { expectSidebarWorkspaceSelected, waitForSidebarHydration } from "./helpers/workspace-ui"; + +interface SeededSidebarSessions { + firstWorkspace: OpenedProject; + secondWorkspace: OpenedProject; + firstProjectName: string; + secondProjectName: string; + unmappedProjectName: string; + agentsByNewest: [ArchiveTabAgent, ArchiveTabAgent, ArchiveTabAgent]; + unmappedAgent: ArchiveTabAgent; + cleanupRepos: () => Promise; +} + +function requireServerId(): string { + const serverId = process.env.E2E_SERVER_ID; + if (!serverId) { + throw new Error("E2E_SERVER_ID is not set."); + } + return serverId; +} + +function uniqueTitle(label: string): string { + return `sidebar-history-${label}-${randomUUID().slice(0, 8)}`; +} + +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function checkoutBranch(repoPath: string, branch: string): void { + execSync(`git checkout ${JSON.stringify(branch)}`, { + cwd: repoPath, + stdio: "ignore", + }); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function seedSidebarSessionsJourney(clients: { + archiveClient: Awaited>; + workspaceClient: Awaited>; +}): Promise { + const firstRepo = await createTempGitRepo("sidebar-history-a-", { branches: ["feature-a"] }); + const secondRepo = await createTempGitRepo("sidebar-history-b-", { branches: ["feature-b"] }); + const unmappedRepo = await createTempGitRepo("sidebar-history-unmapped-"); + const workspaceIds: string[] = []; + const agentIds: string[] = []; + + try { + checkoutBranch(firstRepo.path, "feature-a"); + checkoutBranch(secondRepo.path, "feature-b"); + + const firstWorkspace = await openProjectViaDaemon(clients.workspaceClient, firstRepo.path); + const secondWorkspace = await openProjectViaDaemon(clients.workspaceClient, secondRepo.path); + workspaceIds.push(firstWorkspace.workspaceId, secondWorkspace.workspaceId); + + const oldest = await createIdleAgent(clients.archiveClient, { + cwd: firstRepo.path, + title: uniqueTitle("oldest"), + }); + await sleep(20); + const middle = await createIdleAgent(clients.archiveClient, { + cwd: firstRepo.path, + title: uniqueTitle("middle"), + }); + await sleep(20); + const newest = await createIdleAgent(clients.archiveClient, { + cwd: secondRepo.path, + title: uniqueTitle("newest"), + }); + const unmappedAgent = await createIdleAgent(clients.archiveClient, { + cwd: unmappedRepo.path, + title: uniqueTitle("unmapped"), + }); + await archiveLocalWorkspaceFromDaemon(clients.workspaceClient, unmappedRepo.path); + agentIds.push(oldest.id, middle.id, newest.id, unmappedAgent.id); + + return { + firstWorkspace, + secondWorkspace, + firstProjectName: path.basename(firstRepo.path), + secondProjectName: path.basename(secondRepo.path), + unmappedProjectName: path.basename(unmappedRepo.path), + agentsByNewest: [newest, middle, oldest], + unmappedAgent, + cleanupRepos: async () => { + await unmappedRepo.cleanup(); + await secondRepo.cleanup(); + await firstRepo.cleanup(); + }, + }; + } catch (error) { + for (const agentId of agentIds) { + await archiveAgentFromDaemon(clients.archiveClient, agentId).catch(() => undefined); + } + for (const workspaceId of workspaceIds) { + await archiveLocalWorkspaceFromDaemon(clients.workspaceClient, workspaceId).catch( + () => undefined, + ); + } + await secondRepo.cleanup(); + await firstRepo.cleanup(); + await unmappedRepo.cleanup(); + throw error; + } +} + +async function cleanupSeededSidebarSessions( + seeded: SeededSidebarSessions | null, + clients: { + archiveClient: Awaited>; + workspaceClient: Awaited>; + }, +): Promise { + if (!seeded) { + return; + } + + for (const agent of seeded.agentsByNewest) { + await archiveAgentFromDaemon(clients.archiveClient, agent.id).catch(() => undefined); + } + await archiveAgentFromDaemon(clients.archiveClient, seeded.unmappedAgent.id).catch( + () => undefined, + ); + await archiveLocalWorkspaceFromDaemon(clients.workspaceClient, seeded.unmappedAgent.cwd).catch( + () => undefined, + ); + await archiveLocalWorkspaceFromDaemon( + clients.workspaceClient, + seeded.secondWorkspace.workspaceId, + ).catch(() => undefined); + await archiveLocalWorkspaceFromDaemon( + clients.workspaceClient, + seeded.firstWorkspace.workspaceId, + ).catch(() => undefined); + await seeded.cleanupRepos(); +} + +async function openAppWithSeededWorkspaces(page: Page, seeded: SeededSidebarSessions) { + await gotoAppShell(page); + await waitForSidebarHydration(page); + await expectWorkspaceTreeVisible(page, seeded); +} + +function expectSeededAgentsHaveDeterministicCreatedAtOrder(seeded: SeededSidebarSessions) { + const [newest, middle, oldest] = seeded.agentsByNewest; + expect(new Date(newest.createdAt).getTime()).toBeGreaterThan( + new Date(middle.createdAt).getTime(), + ); + expect(new Date(middle.createdAt).getTime()).toBeGreaterThan( + new Date(oldest.createdAt).getTime(), + ); +} + +async function expectWorkspaceTreeVisible(page: Page, seeded: SeededSidebarSessions) { + await expect(page.getByRole("button", { name: "Workspaces" })).toBeVisible(); + await expect(page.getByText(seeded.firstProjectName, { exact: true })).toBeVisible({ + timeout: 30_000, + }); + await expect(page.getByText(seeded.firstWorkspace.workspaceName, { exact: true })).toBeVisible({ + timeout: 30_000, + }); + await expect(page.getByText(seeded.secondProjectName, { exact: true })).toBeVisible({ + timeout: 30_000, + }); + await expect(page.getByText(seeded.secondWorkspace.workspaceName, { exact: true })).toBeVisible({ + timeout: 30_000, + }); + await expect(page.getByRole("button", { name: "Filter sessions" })).toHaveCount(0); +} + +async function switchSidebarToSessions(page: Page) { + await page.getByRole("button", { name: "Sessions" }).click(); + await expect(page.getByRole("button", { name: "Filter sessions" })).toBeVisible({ + timeout: 30_000, + }); +} + +async function switchSidebarToWorkspaces(page: Page, seeded: SeededSidebarSessions) { + await page.getByRole("button", { name: "Workspaces" }).click(); + await expectWorkspaceTreeVisible(page, seeded); + for (const agent of seeded.agentsByNewest) { + await expect(sessionRow(page, agent)).toHaveCount(0); + } +} + +function sessionRow(page: Page, agent: ArchiveTabAgent) { + return page.getByRole("button", { name: new RegExp(escapeRegex(agent.title)) }); +} + +async function expectSidebarSessionRows(page: Page, agents: readonly ArchiveTabAgent[]) { + for (const agent of agents) { + await expect(sessionRow(page, agent)).toBeVisible({ timeout: 30_000 }); + } +} + +async function expectOnlySidebarSessionRows( + page: Page, + input: { + visible: readonly ArchiveTabAgent[]; + hidden: readonly ArchiveTabAgent[]; + }, +) { + await expectSidebarSessionRows(page, input.visible); + for (const agent of input.hidden) { + await expect(sessionRow(page, agent)).toHaveCount(0); + } +} + +async function expectUnmappedSessionHidden(page: Page, seeded: SeededSidebarSessions) { + await expect(sessionRow(page, seeded.unmappedAgent)).toHaveCount(0); + await page.getByRole("button", { name: "Filter sessions" }).click(); + await expect( + page.getByRole("button", { + name: new RegExp(`^${escapeRegex(seeded.unmappedProjectName)}$`), + }), + ).toHaveCount(0); + await page.keyboard.press("Escape"); +} + +async function expectNewestSessionRowFirst(page: Page, newest: ArchiveTabAgent) { + await expect(sessionRow(page, newest)).toBeVisible({ timeout: 30_000 }); + await expect + .poll(async () => { + const titles = await page.getByRole("button").allTextContents(); + return titles.find((title) => title.includes("sidebar-history-")) ?? ""; + }) + .toContain(newest.title); +} + +async function chooseSessionsFilter(page: Page, name: string | RegExp) { + await page.getByRole("button", { name: "Filter sessions" }).click(); + await page.getByRole("button", { name }).click(); +} + +async function chooseWorkspaceSessionsFilter(page: Page, workspaceId: string) { + await chooseSessionsFilter(page, new RegExp(`^${escapeRegex(workspaceId)}\\s`)); +} + +async function chooseProjectSessionsFilter(page: Page, projectName: string) { + await chooseSessionsFilter(page, new RegExp(`^${escapeRegex(projectName)}$`)); +} + +async function openFirstWorkspace(page: Page, seeded: SeededSidebarSessions) { + const serverId = requireServerId(); + await page.goto(buildHostWorkspaceRoute(serverId, seeded.firstWorkspace.workspaceId)); + await waitForWorkspaceTabsVisible(page); + await expectSidebarWorkspaceSelected({ + page, + serverId, + workspaceId: seeded.firstWorkspace.workspaceId, + }); +} + +async function navigateFromSessionRow(page: Page, agent: ArchiveTabAgent) { + await sessionRow(page, agent).click(); +} + +async function expectWorkspaceFocusedOnSession( + page: Page, + input: { workspaceId: string; agent: ArchiveTabAgent }, +) { + const serverId = requireServerId(); + await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, input.workspaceId), { + timeout: 30_000, + }); + await waitForWorkspaceTabsVisible(page); + await expectWorkspaceTabVisible(page, input.agent.id); + await expect(page.getByRole("button", { name: input.agent.title, exact: true })).toHaveAttribute( + "aria-selected", + "true", + { timeout: 30_000 }, + ); +} + +async function openHistoryFromSidebar(page: Page) { + await page.getByRole("button", { name: "History", exact: true }).click(); + await expect(page).toHaveURL(buildHostSessionsRoute(requireServerId()), { + timeout: 30_000, + }); +} + +async function runSidebarHistorySessionsJourney(page: Page) { + const archiveClient = await connectArchiveTabDaemonClient(); + const workspaceClient = await connectNewWorkspaceDaemonClient(); + let seeded: SeededSidebarSessions | null = null; + + try { + seeded = await seedSidebarSessionsJourney({ archiveClient, workspaceClient }); + expectSeededAgentsHaveDeterministicCreatedAtOrder(seeded); + const [newest, middle, oldest] = seeded.agentsByNewest; + + await openAppWithSeededWorkspaces(page, seeded); + await switchSidebarToSessions(page); + await expectSidebarSessionRows(page, seeded.agentsByNewest); + await expectUnmappedSessionHidden(page, seeded); + await expectNewestSessionRowFirst(page, newest); + await switchSidebarToWorkspaces(page, seeded); + + await switchSidebarToSessions(page); + await chooseWorkspaceSessionsFilter(page, seeded.firstWorkspace.workspaceId); + await expectOnlySidebarSessionRows(page, { + visible: [middle, oldest], + hidden: [newest], + }); + await chooseProjectSessionsFilter(page, seeded.secondProjectName); + await expectOnlySidebarSessionRows(page, { + visible: [newest], + hidden: [middle, oldest], + }); + await chooseSessionsFilter(page, "All"); + await expectSidebarSessionRows(page, seeded.agentsByNewest); + + await openFirstWorkspace(page, seeded); + await switchSidebarToSessions(page); + await navigateFromSessionRow(page, newest); + await expectWorkspaceFocusedOnSession(page, { + workspaceId: seeded.secondWorkspace.workspaceId, + agent: newest, + }); + + await openHistoryFromSidebar(page); + } finally { + await cleanupSeededSidebarSessions(seeded, { archiveClient, workspaceClient }); + await archiveClient.close().catch(() => undefined); + await workspaceClient.close().catch(() => undefined); + } +} + +test.describe("Sidebar History Sessions journey", () => { + test.describe.configure({ timeout: 300_000 }); + + test("toggles sessions, filters by workspace and project, and opens a session tab", async ({ + page, + }) => { + await runSidebarHistorySessionsJourney(page); + }); +}); diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 69952a689d..a7a18dc366 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -1,5 +1,5 @@ import { router, usePathname } from "expo-router"; -import { FolderPlus, MessagesSquare, Settings } from "lucide-react-native"; +import { FolderPlus, Settings } from "lucide-react-native"; import { type Dispatch, memo, @@ -32,8 +32,9 @@ import Animated, { } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; -import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row"; +import { SidebarHistoryNavigationRow } from "@/components/sidebar/sidebar-history-navigation-row"; +import { SidebarShellHeader } from "@/components/sidebar/sidebar-shell-header"; +import { SidebarWorkspacesView } from "@/components/sidebar/sidebar-workspaces-view"; import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox"; import { Shortcut } from "@/components/ui/shortcut"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; @@ -62,9 +63,14 @@ import { buildSettingsRoute, mapPathnameToServer, } from "@/utils/host-routes"; -import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton"; +import { + SidebarSessionsToggle, + SidebarSessionsView, + type SidebarSessionFilter, + type SidebarSessionViewMode, + useSidebarSessionsController, +} from "@/sidebar-sessions"; import { SidebarCalloutSlot } from "./sidebar-callout-slot"; -import { SidebarWorkspaceList } from "./sidebar-workspace-list"; const MIN_CHAT_WIDTH = 400; @@ -80,6 +86,10 @@ interface SidebarSharedProps { activeServerId: string | null; activeHostLabel: string; activeHostStatusColor: string; + sidebarViewMode: SidebarSessionViewMode; + setSidebarViewMode: (mode: SidebarSessionViewMode) => void; + sidebarSessionFilter: SidebarSessionFilter; + setSidebarSessionFilter: (filter: SidebarSessionFilter) => void; hostOptions: ComboboxOption[]; hostTriggerRef: RefObject; isHostPickerOpen: boolean; @@ -182,6 +192,8 @@ export const LeftSidebar = memo(function LeftSidebar({ ); const hostTriggerRef = useRef(null); const [isHostPickerOpen, setIsHostPickerOpen] = useState(false); + const { sidebarViewMode, setSidebarViewMode, sidebarSessionFilter, setSidebarSessionFilter } = + useSidebarSessionsController({ serverId: activeServerId }); const { projects, isInitialLoad, isRevalidating, refreshAll } = useSidebarWorkspacesList({ serverId: activeServerId, @@ -247,6 +259,10 @@ export const LeftSidebar = memo(function LeftSidebar({ activeServerId, activeHostLabel, activeHostStatusColor, + sidebarViewMode, + setSidebarViewMode, + sidebarSessionFilter, + setSidebarSessionFilter, hostOptions, hostTriggerRef, isHostPickerOpen, @@ -485,6 +501,10 @@ function MobileSidebar({ activeServerId, activeHostLabel, activeHostStatusColor, + sidebarViewMode, + setSidebarViewMode, + sidebarSessionFilter, + setSidebarSessionFilter, hostOptions, hostTriggerRef, isHostPickerOpen, @@ -507,8 +527,6 @@ function MobileSidebar({ closeToAgent, handleViewMoreNavigate, }: MobileSidebarProps) { - const pathname = usePathname(); - const isSessionsActive = pathname.includes("/sessions"); const { translateX, backdropOpacity, @@ -664,6 +682,26 @@ function MobileSidebar({ ], [mobileSidebarInsetStyle, sidebarAnimatedStyle, theme.colors.surfaceSidebar], ); + const toggleSlot = useMemo( + () => ( + + ), + [ + activeServerId, + projects, + sidebarSessionFilter, + sidebarViewMode, + setSidebarSessionFilter, + setSidebarViewMode, + ], + ); return ( @@ -672,29 +710,30 @@ function MobileSidebar({ - + + + - {isInitialLoad ? ( - - ) : ( - + ) : ( + )} state.sidebarWidth); const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth); @@ -799,6 +840,26 @@ function DesktopSidebar({ () => [styles.resizeHandle, isWeb && ({ cursor: "col-resize" } as object)], [], ); + const toggleSlot = useMemo( + () => ( + + ), + [ + activeServerId, + projects, + sidebarSessionFilter, + sidebarViewMode, + setSidebarSessionFilter, + setSidebarViewMode, + ], + ); if (!isOpen) { return null; @@ -807,31 +868,32 @@ function DesktopSidebar({ return ( - - - {padding.top > 0 ? : null} - - + 0 ? paddingTopSpacerStyle : undefined} + toggleSlot={toggleSlot} + withTitlebarDragRegion + > + + - {isInitialLoad ? ( - - ) : ( - + ) : ( + )} @@ -898,9 +960,6 @@ const styles = StyleSheet.create((theme) => ({ width: 10, zIndex: 10, }, - sidebarDragArea: { - position: "relative", - }, hostTrigger: { flexDirection: "row", alignItems: "center", diff --git a/packages/app/src/components/sidebar/sidebar-history-navigation-row.tsx b/packages/app/src/components/sidebar/sidebar-history-navigation-row.tsx new file mode 100644 index 0000000000..7748144f74 --- /dev/null +++ b/packages/app/src/components/sidebar/sidebar-history-navigation-row.tsx @@ -0,0 +1,22 @@ +import { usePathname } from "expo-router"; +import { MessagesSquare } from "lucide-react-native"; +import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row"; + +interface SidebarHistoryNavigationRowProps { + onPress: () => void; +} + +export function SidebarHistoryNavigationRow({ onPress }: SidebarHistoryNavigationRowProps) { + const pathname = usePathname(); + const isSessionsActive = pathname.includes("/sessions"); + + return ( + + ); +} diff --git a/packages/app/src/components/sidebar/sidebar-shell-header.tsx b/packages/app/src/components/sidebar/sidebar-shell-header.tsx new file mode 100644 index 0000000000..292d0620e9 --- /dev/null +++ b/packages/app/src/components/sidebar/sidebar-shell-header.tsx @@ -0,0 +1,40 @@ +import type { ReactNode } from "react"; +import { Fragment } from "react"; +import { View, type StyleProp, type ViewStyle } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; + +interface SidebarShellHeaderProps { + children: ReactNode; + topSpacerStyle?: StyleProp; + toggleSlot?: ReactNode; + withTitlebarDragRegion?: boolean; +} + +export function SidebarShellHeader({ + children, + topSpacerStyle, + toggleSlot, + withTitlebarDragRegion = false, +}: SidebarShellHeaderProps) { + const content = ( + + {withTitlebarDragRegion ? : null} + {topSpacerStyle ? : null} + {toggleSlot} + {children} + + ); + + if (withTitlebarDragRegion || topSpacerStyle) { + return {content}; + } + + return content; +} + +const styles = StyleSheet.create(() => ({ + headerDragArea: { + position: "relative", + }, +})); diff --git a/packages/app/src/components/sidebar/sidebar-workspaces-view.tsx b/packages/app/src/components/sidebar/sidebar-workspaces-view.tsx new file mode 100644 index 0000000000..6bc4c6f18f --- /dev/null +++ b/packages/app/src/components/sidebar/sidebar-workspaces-view.tsx @@ -0,0 +1,52 @@ +import type { ReactElement, MutableRefObject } from "react"; +import type { GestureType } from "react-native-gesture-handler"; +import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list"; +import { SidebarAgentListSkeleton } from "../sidebar-agent-list-skeleton"; +import { SidebarWorkspaceList } from "../sidebar-workspace-list"; + +interface SidebarWorkspacesViewProps { + serverId: string | null; + projects: SidebarProjectEntry[]; + isInitialLoad: boolean; + isRefreshing: boolean; + collapsedProjectKeys: ReadonlySet; + shortcutIndexByWorkspaceKey: Map; + onToggleProjectCollapsed: (projectKey: string) => void; + onRefresh: () => void; + onAddProject: () => void; + onWorkspacePress?: () => void; + parentGestureRef?: MutableRefObject; +} + +export function SidebarWorkspacesView({ + serverId, + projects, + isInitialLoad, + isRefreshing, + collapsedProjectKeys, + shortcutIndexByWorkspaceKey, + onToggleProjectCollapsed, + onRefresh, + onAddProject, + onWorkspacePress, + parentGestureRef, +}: SidebarWorkspacesViewProps): ReactElement { + if (isInitialLoad) { + return ; + } + + return ( + + ); +} diff --git a/packages/app/src/hooks/use-aggregated-agents.test.tsx b/packages/app/src/hooks/use-aggregated-agents.test.tsx new file mode 100644 index 0000000000..28d67762f0 --- /dev/null +++ b/packages/app/src/hooks/use-aggregated-agents.test.tsx @@ -0,0 +1,246 @@ +/** + * @vitest-environment jsdom + */ +import { act, renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { DaemonClient } from "@server/client/daemon-client"; +import { useSessionStore, type Agent } from "@/stores/session-store"; +import { useAggregatedAgentIds, useAggregatedAgents } from "./use-aggregated-agents"; + +const { mockRuntimeStore } = vi.hoisted(() => ({ + mockRuntimeStore: { + subscribeAll: vi.fn(() => () => undefined), + getVersion: vi.fn(() => 1), + getSnapshot: vi.fn(() => ({ agentDirectoryStatus: "idle" })), + refreshAllAgentDirectories: vi.fn(), + }, +})); + +vi.mock("@/runtime/host-runtime", () => ({ + getHostRuntimeStore: () => mockRuntimeStore, + useHosts: () => [{ serverId: "server-1", label: "Local" }], +})); + +const TIMESTAMP = new Date("2026-05-08T10:00:00.000Z"); + +const AGENT_DEFAULTS: Agent = { + serverId: "server-1", + id: "agent-1", + provider: "codex", + status: "idle", + 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: "/tmp/project", + 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 seedAgents(agents: Agent[]) { + act(() => { + useSessionStore.getState().initializeSession("server-1", {} as unknown as DaemonClient); + useSessionStore + .getState() + .setAgents("server-1", new Map(agents.map((agent) => [agent.id, agent]))); + }); +} + +afterEach(() => { + mockRuntimeStore.subscribeAll.mockClear(); + mockRuntimeStore.getVersion.mockClear(); + mockRuntimeStore.getSnapshot.mockClear(); + mockRuntimeStore.refreshAllAgentDirectories.mockClear(); + useSessionStore.setState({ sessions: {}, agentLastActivity: new Map() }); +}); + +describe("useAggregatedAgents", () => { + it("sorts by createdAt descending by default and falls back to id for ties", () => { + seedAgents([ + makeAgent({ + id: "beta", + createdAt: new Date("2026-05-08T10:00:00.000Z"), + }), + makeAgent({ + id: "newest", + createdAt: new Date("2026-05-08T11:00:00.000Z"), + }), + makeAgent({ + id: "alpha", + createdAt: new Date("2026-05-08T10:00:00.000Z"), + }), + ]); + + const { result } = renderHook(() => useAggregatedAgents()); + + expect(result.current.agents.map((agent) => agent.id)).toEqual(["newest", "alpha", "beta"]); + }); + + it("can preserve the legacy running-then-activity sort", () => { + seedAgents([ + makeAgent({ + id: "idle-newer", + status: "idle", + lastActivityAt: new Date("2026-05-08T12:00:00.000Z"), + }), + makeAgent({ + id: "running-older", + status: "running", + lastActivityAt: new Date("2026-05-08T09:00:00.000Z"), + }), + makeAgent({ + id: "running-newer", + status: "running", + lastActivityAt: new Date("2026-05-08T11:00:00.000Z"), + }), + ]); + + const { result } = renderHook(() => useAggregatedAgents({ sort: "running-then-activity" })); + + expect(result.current.agents.map((agent) => agent.id)).toEqual([ + "running-newer", + "running-older", + "idle-newer", + ]); + }); +}); + +describe("useAggregatedAgentIds", () => { + it("keeps the same id array reference when membership and order are unchanged", () => { + seedAgents([ + makeAgent({ + id: "alpha", + title: "Alpha", + status: "idle", + createdAt: new Date("2026-05-08T10:00:00.000Z"), + lastActivityAt: new Date("2026-05-08T10:00:00.000Z"), + }), + makeAgent({ + id: "beta", + title: "Beta", + status: "running", + createdAt: new Date("2026-05-08T11:00:00.000Z"), + lastActivityAt: new Date("2026-05-08T11:00:00.000Z"), + }), + ]); + + const { result } = renderHook(() => useAggregatedAgentIds()); + const before = result.current; + + act(() => { + useSessionStore.getState().setAgents( + "server-1", + new Map([ + [ + "alpha", + makeAgent({ + id: "alpha", + title: "Renamed Alpha", + status: "running", + createdAt: new Date("2026-05-08T10:00:00.000Z"), + lastActivityAt: new Date("2026-05-08T12:00:00.000Z"), + }), + ], + [ + "beta", + makeAgent({ + id: "beta", + title: "Renamed Beta", + status: "idle", + createdAt: new Date("2026-05-08T11:00:00.000Z"), + lastActivityAt: new Date("2026-05-08T12:30:00.000Z"), + }), + ], + ]), + ); + }); + + expect(result.current).toBe(before); + expect(result.current).toEqual(["beta", "alpha"]); + }); + + it("changes the id array reference when membership changes", () => { + seedAgents([makeAgent({ id: "alpha" })]); + + const { result } = renderHook(() => useAggregatedAgentIds()); + const before = result.current; + + act(() => { + useSessionStore.getState().setAgents( + "server-1", + new Map([ + ["alpha", makeAgent({ id: "alpha" })], + ["beta", makeAgent({ id: "beta", createdAt: new Date("2026-05-08T11:00:00.000Z") })], + ]), + ); + }); + + expect(result.current).not.toBe(before); + expect(result.current).toEqual(["beta", "alpha"]); + }); + + it("keeps the same id array reference when a local predicate still yields the same ids", () => { + seedAgents([ + makeAgent({ id: "mapped", cwd: "/tmp/project" }), + makeAgent({ + id: "unmapped", + cwd: "/tmp/missing", + createdAt: new Date("2026-05-08T11:00:00.000Z"), + }), + ]); + + const { result } = renderHook(() => + useAggregatedAgentIds({ + filter: (agent) => agent.cwd === "/tmp/project" && !agent.archivedAt, + }), + ); + const before = result.current; + + act(() => { + useSessionStore.getState().setAgents( + "server-1", + new Map([ + ["mapped", makeAgent({ id: "mapped", cwd: "/tmp/project", title: "Renamed" })], + [ + "unmapped", + makeAgent({ + id: "unmapped", + cwd: "/tmp/missing", + title: "Still hidden", + createdAt: new Date("2026-05-08T11:00:00.000Z"), + }), + ], + ]), + ); + }); + + expect(result.current).toBe(before); + expect(result.current).toEqual(["mapped"]); + }); +}); diff --git a/packages/app/src/hooks/use-aggregated-agents.ts b/packages/app/src/hooks/use-aggregated-agents.ts index 3bd73393ad..4d8a10ce3c 100644 --- a/packages/app/src/hooks/use-aggregated-agents.ts +++ b/packages/app/src/hooks/use-aggregated-agents.ts @@ -1,5 +1,6 @@ import { useMemo, useCallback, useSyncExternalStore } from "react"; -import { useShallow } from "zustand/shallow"; +import { shallow, useShallow } from "zustand/shallow"; +import { useStoreWithEqualityFn } from "zustand/traditional"; import { useSessionStore } from "@/stores/session-store"; import type { AgentDirectoryEntry } from "@/types/agent-directory"; import type { Agent } from "@/stores/session-store"; @@ -18,12 +19,151 @@ export interface AggregatedAgentsResult { refreshAll: () => void; } +export type AggregatedAgentSortOption = "createdAt-desc-stable" | "running-then-activity"; + +export interface AggregatedAgentIdEntry { + id: string; + serverId: string; + status: Agent["status"]; + lastActivityAt: Date; + cwd: string; + archivedAt: Date | null; + createdAt: Date; +} + +interface AggregatedAgentSortFields { + id: string; + status: Agent["status"]; + lastActivityAt: Date; + createdAt: Date; +} + +function compareAggregatedAgentsByCreatedAtDescStable( + left: AggregatedAgentSortFields, + right: AggregatedAgentSortFields, +): number { + const createdAtDiff = right.createdAt.getTime() - left.createdAt.getTime(); + if (createdAtDiff !== 0) { + return createdAtDiff; + } + return left.id.localeCompare(right.id); +} + +function compareAggregatedAgentsByRunningThenActivity( + left: AggregatedAgentSortFields, + right: AggregatedAgentSortFields, +): number { + const leftRunning = left.status === "running"; + const rightRunning = right.status === "running"; + if (leftRunning && !rightRunning) { + return -1; + } + if (!leftRunning && rightRunning) { + return 1; + } + const leftTime = left.lastActivityAt.getTime(); + const rightTime = right.lastActivityAt.getTime(); + return rightTime - leftTime; +} + +function sortAggregatedAgents( + agents: AggregatedAgent[], + sort: AggregatedAgentSortOption, +): AggregatedAgent[] { + agents.sort( + sort === "running-then-activity" + ? compareAggregatedAgentsByRunningThenActivity + : compareAggregatedAgentsByCreatedAtDescStable, + ); + return agents; +} + +function sortAggregatedAgentIdEntries( + agents: AggregatedAgentIdEntry[], + sort: AggregatedAgentSortOption, +): AggregatedAgentIdEntry[] { + agents.sort( + sort === "running-then-activity" + ? compareAggregatedAgentsByRunningThenActivity + : compareAggregatedAgentsByCreatedAtDescStable, + ); + return agents; +} + +export function useAggregatedAgentIds(options?: { + includeArchived?: boolean; + sort?: AggregatedAgentSortOption; + filter?: (agent: AggregatedAgentIdEntry) => boolean; +}): string[] { + const includeArchived = options?.includeArchived ?? false; + const sort = options?.sort ?? "createdAt-desc-stable"; + const filter = options?.filter; + + return useStoreWithEqualityFn( + useSessionStore, + (state) => { + const agents: AggregatedAgentIdEntry[] = []; + for (const [serverId, session] of Object.entries(state.sessions)) { + for (const agent of session.agents.values()) { + if (!includeArchived && agent.archivedAt) { + continue; + } + const entry: AggregatedAgentIdEntry = { + id: agent.id, + serverId, + status: agent.status, + lastActivityAt: agent.lastActivityAt, + cwd: agent.cwd, + archivedAt: agent.archivedAt ?? null, + createdAt: agent.createdAt, + }; + if (!filter || filter(entry)) { + agents.push(entry); + } + } + } + return sortAggregatedAgentIdEntries(agents, sort).map((agent) => agent.id); + }, + shallow, + ); +} + +export function useAggregatedAgentsInitialLoad(): boolean { + const daemons = useHosts(); + const runtime = getHostRuntimeStore(); + const runtimeVersion = useSyncExternalStore( + (onStoreChange) => runtime.subscribeAll(onStoreChange), + () => runtime.getVersion(), + () => runtime.getVersion(), + ); + const agentCount = useSessionStore((state) => { + let count = 0; + for (const session of Object.values(state.sessions)) { + for (const agent of session.agents.values()) { + if (!agent.archivedAt) { + count += 1; + } + } + } + return count; + }); + + void runtimeVersion; + const isLoading = daemons.some((daemon) => { + const status = runtime.getSnapshot(daemon.serverId)?.agentDirectoryStatus ?? "initial_loading"; + return status === "initial_loading" || status === "revalidating"; + }); + return isLoading && agentCount === 0; +} + export function useAggregatedAgents(options?: { includeArchived?: boolean; + sort?: AggregatedAgentSortOption; }): AggregatedAgentsResult { const daemons = useHosts(); const runtime = getHostRuntimeStore(); const includeArchived = options?.includeArchived ?? false; + const sort = options?.sort ?? "createdAt-desc-stable"; const runtimeVersion = useSyncExternalStore( (onStoreChange) => runtime.subscribeAll(onStoreChange), () => runtime.getVersion(), @@ -83,20 +223,7 @@ export function useAggregatedAgents(options?: { } } - // Sort by: running agents first, then by most recent activity - allAgents.sort((left, right) => { - const leftRunning = left.status === "running"; - const rightRunning = right.status === "running"; - if (leftRunning && !rightRunning) { - return -1; - } - if (!leftRunning && rightRunning) { - return 1; - } - const leftTime = left.lastActivityAt.getTime(); - const rightTime = right.lastActivityAt.getTime(); - return rightTime - leftTime; - }); + sortAggregatedAgents(allAgents, sort); // Check if we have any cached data const hasAnyData = allAgents.length > 0; @@ -116,7 +243,7 @@ export function useAggregatedAgents(options?: { isInitialLoad, isRevalidating, }; - }, [daemons, includeArchived, runtime, runtimeVersion, sessionAgents]); + }, [daemons, includeArchived, runtime, runtimeVersion, sessionAgents, sort]); return { ...result, diff --git a/packages/app/src/hooks/use-all-agents-list.test.ts b/packages/app/src/hooks/use-all-agents-list.test.ts index 21272dd142..b3ee5d6248 100644 --- a/packages/app/src/hooks/use-all-agents-list.test.ts +++ b/packages/app/src/hooks/use-all-agents-list.test.ts @@ -1,7 +1,16 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { __private__ } from "./use-all-agents-list"; import type { Agent } from "@/stores/session-store"; +vi.mock("@/runtime/host-runtime", () => ({ + getHostRuntimeStore: () => ({ + refreshAgentDirectory: vi.fn(), + }), + useHostRuntimeConnectionStatus: () => "online", + useHostRuntimeIsDirectoryLoading: () => false, + useHosts: () => [{ serverId: "server-1", label: "Local" }], +})); + const AGENT_TIMESTAMP = new Date("2026-03-08T10:00:00.000Z"); const AGENT_DEFAULTS: Agent = { diff --git a/packages/app/src/sidebar-sessions/controller.test.ts b/packages/app/src/sidebar-sessions/controller.test.ts new file mode 100644 index 0000000000..7693ebf435 --- /dev/null +++ b/packages/app/src/sidebar-sessions/controller.test.ts @@ -0,0 +1,32 @@ +/** + * @vitest-environment jsdom + */ +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { useSidebarSessionsController } from "./controller"; + +describe("useSidebarSessionsController", () => { + it("resets the session filter when the active host changes", () => { + const { result, rerender } = renderHook( + ({ serverId }) => useSidebarSessionsController({ serverId }), + { initialProps: { serverId: "server-1" as string | null } }, + ); + + act(() => { + result.current.setSidebarSessionFilter({ + type: "workspace", + workspaceKey: "server-1:workspace-1", + }); + }); + + expect(result.current.sidebarSessionFilter).toEqual({ + type: "workspace", + workspaceKey: "server-1:workspace-1", + }); + + rerender({ serverId: "server-2" }); + + expect(result.current.sidebarSessionFilter).toEqual({ type: "all" }); + expect(result.current.sidebarViewMode).toBe("workspaces"); + }); +}); diff --git a/packages/app/src/sidebar-sessions/controller.ts b/packages/app/src/sidebar-sessions/controller.ts new file mode 100644 index 0000000000..c4724f981f --- /dev/null +++ b/packages/app/src/sidebar-sessions/controller.ts @@ -0,0 +1,25 @@ +import { useEffect, useRef, useState } from "react"; +import type { SidebarSessionFilter, SidebarSessionViewMode } from "./types"; + +export function useSidebarSessionsController({ serverId }: { serverId: string | null }) { + const previousServerIdRef = useRef(serverId); + const [sidebarViewMode, setSidebarViewMode] = useState("workspaces"); + const [sidebarSessionFilter, setSidebarSessionFilter] = useState({ + type: "all", + }); + + useEffect(() => { + if (previousServerIdRef.current === serverId) { + return; + } + previousServerIdRef.current = serverId; + setSidebarSessionFilter({ type: "all" }); + }, [serverId]); + + return { + sidebarViewMode, + setSidebarViewMode, + sidebarSessionFilter, + setSidebarSessionFilter, + }; +} diff --git a/packages/app/src/sidebar-sessions/index.ts b/packages/app/src/sidebar-sessions/index.ts new file mode 100644 index 0000000000..663b4153c5 --- /dev/null +++ b/packages/app/src/sidebar-sessions/index.ts @@ -0,0 +1,4 @@ +export { useSidebarSessionsController } from "./controller"; +export { SidebarSessionsToggle } from "./toggle"; +export { SidebarSessionsView } from "./view"; +export type { SidebarSessionFilter, SidebarSessionViewMode } from "./types"; diff --git a/packages/app/src/sidebar-sessions/render-boundaries.test.ts b/packages/app/src/sidebar-sessions/render-boundaries.test.ts new file mode 100644 index 0000000000..da9ca05bc2 --- /dev/null +++ b/packages/app/src/sidebar-sessions/render-boundaries.test.ts @@ -0,0 +1,106 @@ +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/select-sidebar-session-slice.test.ts b/packages/app/src/sidebar-sessions/select-sidebar-session-slice.test.ts new file mode 100644 index 0000000000..c5b9970933 --- /dev/null +++ b/packages/app/src/sidebar-sessions/select-sidebar-session-slice.test.ts @@ -0,0 +1,96 @@ +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: "/tmp/project", + 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("selectSidebarSessionSlice", () => { + it("returns a shallow-equal slice when the agent state is unchanged", () => { + seedAgent(makeAgent()); + + const first = selectSidebarSessionSlice(useSessionStore.getState(), "server-1", "agent-1"); + const second = selectSidebarSessionSlice(useSessionStore.getState(), "server-1", "agent-1"); + + expect(shallow(first, second)).toBe(true); + expect(first).toEqual({ + title: "Agent", + cwd: "/tmp/project", + provider: "codex", + lastActivityAt: TIMESTAMP, + archivedAt: null, + }); + }); + + it("changes the row slice when lastActivityAt changes", () => { + seedAgent(makeAgent()); + const before = selectSidebarSessionSlice(useSessionStore.getState(), "server-1", "agent-1"); + + useSessionStore.getState().setAgents( + "server-1", + new Map([ + [ + "agent-1", + makeAgent({ + lastActivityAt: new Date("2026-05-08T11:00:00.000Z"), + }), + ], + ]), + ); + + const after = selectSidebarSessionSlice(useSessionStore.getState(), "server-1", "agent-1"); + + expect(shallow(before, after)).toBe(false); + expect(after?.lastActivityAt).toEqual(new Date("2026-05-08T11:00:00.000Z")); + }); +}); diff --git a/packages/app/src/sidebar-sessions/select-sidebar-session-slice.ts b/packages/app/src/sidebar-sessions/select-sidebar-session-slice.ts new file mode 100644 index 0000000000..d812a76339 --- /dev/null +++ b/packages/app/src/sidebar-sessions/select-sidebar-session-slice.ts @@ -0,0 +1,27 @@ +import { useSessionStore, type Agent } from "@/stores/session-store"; + +export type SidebarSessionSlice = { + title: string | null; + cwd: string; + provider: Agent["provider"]; + lastActivityAt: Date; + archivedAt: Date | null; +} | null; + +export function selectSidebarSessionSlice( + state: ReturnType, + serverId: string, + agentId: string, +): SidebarSessionSlice { + const agent = state.sessions[serverId]?.agents?.get(agentId) ?? null; + if (!agent) { + return null; + } + return { + title: agent.title ?? null, + cwd: agent.cwd, + provider: agent.provider, + lastActivityAt: agent.lastActivityAt, + archivedAt: agent.archivedAt ?? null, + }; +} diff --git a/packages/app/src/sidebar-sessions/session-filtering.test.ts b/packages/app/src/sidebar-sessions/session-filtering.test.ts new file mode 100644 index 0000000000..3e1d5b9b51 --- /dev/null +++ b/packages/app/src/sidebar-sessions/session-filtering.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it } from "vitest"; +import { + createSidebarSessionWorkspaceLookup, + deriveSidebarSessionFilterAvailability, + deriveSidebarSessionFilterProjects, + shouldIncludeSidebarSessionAgent, +} from "./session-filtering"; +import type { SidebarSessionAgent, SidebarSessionWorkspace } from "./types"; + +const WORKSPACES: SidebarSessionWorkspace[] = [ + { + serverId: "server-1", + workspaceId: "workspace-1", + workspaceKey: "server-1:workspace-1", + workspaceName: "Main", + projectKey: "project-a", + projectName: "Project A", + workspaceDirectory: "/repo/main", + }, + { + serverId: "server-1", + workspaceId: "workspace-2", + workspaceKey: "server-1:workspace-2", + workspaceName: "Docs", + projectKey: "project-b", + projectName: "Project B", + workspaceDirectory: "/repo/docs", + }, + { + serverId: "server-1", + workspaceId: "workspace-3", + workspaceKey: "server-1:workspace-3", + workspaceName: "API", + projectKey: "project-a", + projectName: "Project A", + workspaceDirectory: "/repo/api", + }, +]; + +const AGENTS: SidebarSessionAgent[] = [ + { id: "main", serverId: "server-1", cwd: "/repo/main/", archivedAt: null }, + { id: "docs", serverId: "server-1", cwd: "/repo/docs", archivedAt: null }, + { id: "api", serverId: "server-1", cwd: "/repo/api", archivedAt: null }, + { id: "unmapped", serverId: "server-1", cwd: "/repo/missing", archivedAt: null }, + { id: "other-server", serverId: "server-2", cwd: "/repo/main", archivedAt: null }, + { + id: "archived", + serverId: "server-1", + cwd: "/repo/main", + archivedAt: new Date("2026-05-08T10:00:00.000Z"), + }, +]; + +function projectWorkspaceKeys(project: { + projectKey: string; + workspaces: { workspaceKey: string }[]; +}) { + return { + projectKey: project.projectKey, + workspaceKeys: project.workspaces.map((workspace) => workspace.workspaceKey), + }; +} + +describe("sidebar session filtering", () => { + it("hides unmapped agents and includes every mapped active agent for All", () => { + const lookup = createSidebarSessionWorkspaceLookup(WORKSPACES); + + const visibleIds = AGENTS.filter((agent) => + shouldIncludeSidebarSessionAgent({ + agent, + filter: { type: "all" }, + lookup, + }), + ).map((agent) => agent.id); + + expect(visibleIds).toEqual(["main", "docs", "api"]); + }); + + it("filters by workspace", () => { + const lookup = createSidebarSessionWorkspaceLookup(WORKSPACES); + + const visibleIds = AGENTS.filter((agent) => + shouldIncludeSidebarSessionAgent({ + agent, + filter: { type: "workspace", workspaceKey: "server-1:workspace-2" }, + lookup, + }), + ).map((agent) => agent.id); + + expect(visibleIds).toEqual(["docs"]); + }); + + it("filters by project", () => { + const lookup = createSidebarSessionWorkspaceLookup(WORKSPACES); + + const visibleIds = AGENTS.filter((agent) => + shouldIncludeSidebarSessionAgent({ + agent, + filter: { type: "project", projectKey: "project-a" }, + lookup, + }), + ).map((agent) => agent.id); + + expect(visibleIds).toEqual(["main", "api"]); + }); + + it("derives filter options only from mapped active agents", () => { + const lookup = createSidebarSessionWorkspaceLookup(WORKSPACES); + const availability = deriveSidebarSessionFilterAvailability({ + agents: [ + AGENTS[0], + AGENTS[3], + { + id: "archived-docs", + serverId: "server-1", + cwd: "/repo/docs", + archivedAt: new Date("2026-05-08T10:00:00.000Z"), + }, + ], + lookup, + }); + + expect(availability).toEqual({ + workspaceKeys: ["server-1:workspace-1"], + projectKeys: ["project-a"], + }); + const filterProjects = deriveSidebarSessionFilterProjects({ + projects: [ + { + projectKey: "project-a", + projectName: "Project A", + projectKind: "git", + iconWorkingDir: "/repo", + workspaces: [ + { + workspaceKey: "server-1:workspace-1", + serverId: "server-1", + workspaceId: "workspace-1", + projectKey: "project-a", + projectRootPath: "/repo", + workspaceDirectory: "/repo/main", + projectKind: "git", + workspaceKind: "worktree", + name: "Main", + statusBucket: "done", + archivingAt: null, + diffStat: null, + scripts: [], + hasRunningScripts: false, + }, + { + workspaceKey: "server-1:workspace-3", + serverId: "server-1", + workspaceId: "workspace-3", + projectKey: "project-a", + projectRootPath: "/repo", + workspaceDirectory: "/repo/api", + projectKind: "git", + workspaceKind: "worktree", + name: "API", + statusBucket: "done", + archivingAt: null, + diffStat: null, + scripts: [], + hasRunningScripts: false, + }, + ], + }, + { + projectKey: "project-b", + projectName: "Project B", + projectKind: "git", + iconWorkingDir: "/repo/docs", + workspaces: [ + { + workspaceKey: "server-1:workspace-2", + serverId: "server-1", + workspaceId: "workspace-2", + projectKey: "project-b", + projectRootPath: "/repo/docs", + workspaceDirectory: "/repo/docs", + projectKind: "git", + workspaceKind: "worktree", + name: "Docs", + statusBucket: "done", + archivingAt: null, + diffStat: null, + scripts: [], + hasRunningScripts: false, + }, + ], + }, + ], + availability, + }); + + expect(filterProjects.map(projectWorkspaceKeys)).toEqual([ + { projectKey: "project-a", workspaceKeys: ["server-1:workspace-1"] }, + ]); + }); +}); diff --git a/packages/app/src/sidebar-sessions/session-filtering.ts b/packages/app/src/sidebar-sessions/session-filtering.ts new file mode 100644 index 0000000000..97d76a14ac --- /dev/null +++ b/packages/app/src/sidebar-sessions/session-filtering.ts @@ -0,0 +1,135 @@ +import type { SidebarSessionAgent, SidebarSessionFilter, SidebarSessionWorkspace } from "./types"; +import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list"; +import type { WorkspaceDescriptor } from "@/stores/session-store"; +import { normalizeWorkspacePath } from "@/utils/workspace-identity"; + +export interface SidebarSessionWorkspaceLookup { + workspaceByExecutionKey: Map; +} + +export interface SidebarSessionFilterAvailability { + workspaceKeys: readonly string[]; + projectKeys: readonly string[]; +} + +function executionKey(serverId: string, cwd: string): string { + return `${serverId}:${cwd}`; +} + +export function createSidebarSessionWorkspaceLookup( + workspaces: readonly SidebarSessionWorkspace[], +): SidebarSessionWorkspaceLookup { + const workspaceByExecutionKey = new Map(); + for (const workspace of workspaces) { + const normalizedDirectory = normalizeWorkspacePath(workspace.workspaceDirectory); + if (!normalizedDirectory) { + continue; + } + workspaceByExecutionKey.set(executionKey(workspace.serverId, normalizedDirectory), workspace); + } + return { workspaceByExecutionKey }; +} + +export function resolveSidebarSessionWorkspace( + lookup: SidebarSessionWorkspaceLookup, + agent: SidebarSessionAgent, +): SidebarSessionWorkspace | null { + const normalizedCwd = normalizeWorkspacePath(agent.cwd); + if (!normalizedCwd) { + return null; + } + return lookup.workspaceByExecutionKey.get(executionKey(agent.serverId, normalizedCwd)) ?? null; +} + +export function resolveSidebarSessionWorkspaceId(input: { + agent: SidebarSessionAgent; + workspaces: Iterable | null | undefined; +}): string | null { + const normalizedCwd = normalizeWorkspacePath(input.agent.cwd); + if (!normalizedCwd) { + return null; + } + + for (const workspace of input.workspaces ?? []) { + if (normalizeWorkspacePath(workspace.workspaceDirectory) === normalizedCwd) { + return workspace.id; + } + } + return null; +} + +export function shouldIncludeSidebarSessionAgent(input: { + agent: SidebarSessionAgent; + filter: SidebarSessionFilter; + lookup: SidebarSessionWorkspaceLookup; +}): boolean { + if (input.agent.archivedAt) { + return false; + } + + const workspace = resolveSidebarSessionWorkspace(input.lookup, input.agent); + if (!workspace) { + return false; + } + + if (input.filter.type === "all") { + return true; + } + if (input.filter.type === "workspace") { + return workspace.workspaceKey === input.filter.workspaceKey; + } + return workspace.projectKey === input.filter.projectKey; +} + +export function deriveSidebarSessionFilterAvailability(input: { + agents: readonly SidebarSessionAgent[]; + lookup: SidebarSessionWorkspaceLookup; +}): SidebarSessionFilterAvailability { + const workspaceKeys = new Set(); + const projectKeys = new Set(); + + for (const agent of input.agents) { + if (agent.archivedAt) { + continue; + } + const workspace = resolveSidebarSessionWorkspace(input.lookup, agent); + if (!workspace) { + continue; + } + workspaceKeys.add(workspace.workspaceKey); + projectKeys.add(workspace.projectKey); + } + + return { + workspaceKeys: Array.from(workspaceKeys).sort(), + projectKeys: Array.from(projectKeys).sort(), + }; +} + +export function deriveSidebarSessionFilterProjects(input: { + projects: readonly SidebarProjectEntry[]; + availability: SidebarSessionFilterAvailability; +}): SidebarProjectEntry[] { + if (input.projects.length === 0 || input.availability.workspaceKeys.length === 0) { + return []; + } + + const visibleWorkspaceKeys = new Set(input.availability.workspaceKeys); + const visibleProjectKeys = new Set(input.availability.projectKeys); + const projects: SidebarProjectEntry[] = []; + + for (const project of input.projects) { + if (!visibleProjectKeys.has(project.projectKey)) { + continue; + } + const workspaces = project.workspaces.filter((workspace) => + visibleWorkspaceKeys.has(workspace.workspaceKey), + ); + if (workspaces.length === 0) { + continue; + } + projects.push({ ...project, workspaces }); + } + + return projects; +} 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 new file mode 100644 index 0000000000..5c16b7cc62 --- /dev/null +++ b/packages/app/src/sidebar-sessions/sidebar-session-row-navigation.test.tsx @@ -0,0 +1,260 @@ +/** + * @vitest-environment jsdom + */ +import { cleanup, fireEvent, render } from "@testing-library/react"; +import type { DaemonClient } from "@server/client/daemon-client"; +import React from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { AggregatedAgent } from "@/hooks/use-aggregated-agents"; +import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list"; +import { useSessionStore, type Agent, type WorkspaceDescriptor } from "@/stores/session-store"; +import { SidebarSessionsView } from "./view"; + +vi.hoisted(() => { + Object.assign(globalThis, { __DEV__: false }); +}); + +const { + aggregatedAgentsState, + navigateToPreparedWorkspaceTabMock, + prepareWorkspaceTabMock, + testTheme, +} = vi.hoisted(() => ({ + aggregatedAgentsState: { + value: { + agents: [] as unknown[], + isInitialLoad: false, + }, + }, + navigateToPreparedWorkspaceTabMock: vi.fn(), + prepareWorkspaceTabMock: vi.fn(), + testTheme: { + borderRadius: { lg: 8 }, + colors: { + foreground: "#111111", + foregroundMuted: "#666666", + surface2: "#eeeeee", + surfaceSidebarHover: "#f5f5f5", + }, + fontSize: { sm: 14, xs: 12 }, + fontWeight: { medium: "500" }, + iconSize: { md: 20, sm: 16 }, + spacing: { 1: 4, 2: 8, 3: 12, 4: 16, 6: 24 }, + }, +})); + +vi.mock("@/hooks/use-aggregated-agents", () => ({ + useAggregatedAgentIds: (options?: { filter?: (agent: AggregatedAgent) => boolean }) => + aggregatedAgentsState.value.agents + .filter((agent) => !options?.filter || options.filter(agent as AggregatedAgent)) + .map((agent) => (agent as AggregatedAgent).id), + useAggregatedAgentsInitialLoad: () => aggregatedAgentsState.value.isInitialLoad, +})); + +vi.mock("@/components/provider-icons", async () => { + const ReactModule = await import("react"); + return { + getProviderIcon: () => + function ProviderIcon() { + return ReactModule.createElement("span", { "data-testid": "provider-icon" }); + }, + }; +}); + +vi.mock("react-native-unistyles", () => ({ + StyleSheet: { + create: (styles: unknown) => (typeof styles === "function" ? styles(testTheme) : styles), + }, + useUnistyles: () => ({ + theme: testTheme, + rt: {}, + breakpoint: undefined, + }), +})); + +vi.mock("@/utils/workspace-navigation", () => ({ + navigateToPreparedWorkspaceTab: navigateToPreparedWorkspaceTabMock, + prepareWorkspaceTab: prepareWorkspaceTabMock, +})); + +vi.mock("@react-native-async-storage/async-storage", () => ({ + default: { + getItem: vi.fn().mockResolvedValue(null), + setItem: vi.fn().mockResolvedValue(undefined), + removeItem: vi.fn().mockResolvedValue(undefined), + }, +})); + +const SERVER_ID = "server-1"; +const WORKSPACE_ID = "workspace-1"; +const AGENT_ID = "agent-1"; +const CWD = "/repo/project/workspace"; +const TIMESTAMP = new Date("2026-05-08T10:00:00.000Z"); +const ALL_SESSIONS_FILTER = { type: "all" } as const; + +const AGENT_DEFAULTS: Agent = { + serverId: SERVER_ID, + id: AGENT_ID, + 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: "Navigation agent", + cwd: CWD, + 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 toAggregatedAgent(agent: Agent): AggregatedAgent { + return { + ...agent, + serverLabel: "Server 1", + }; +} + +function workspace(input: Partial = {}): WorkspaceDescriptor { + return { + id: WORKSPACE_ID, + projectId: "project-1", + projectDisplayName: "Project", + projectRootPath: "/repo/project", + workspaceDirectory: CWD, + projectKind: "git", + workspaceKind: "worktree", + name: "workspace", + status: "done", + archivingAt: null, + diffStat: null, + scripts: [], + ...input, + }; +} + +function projects(): SidebarProjectEntry[] { + return [ + { + projectKey: "project-1", + projectName: "Project", + projectKind: "git", + iconWorkingDir: "/repo/project", + workspaces: [ + { + workspaceKey: `${SERVER_ID}:${WORKSPACE_ID}`, + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + projectKey: "project-1", + projectRootPath: "/repo/project", + workspaceDirectory: CWD, + projectKind: "git", + workspaceKind: "worktree", + name: "workspace", + statusBucket: "done", + archivingAt: null, + diffStat: null, + scripts: [], + hasRunningScripts: false, + }, + ], + }, + ]; +} + +function seedState(input: { agent: Agent; workspaces?: WorkspaceDescriptor[] }) { + const agent = input.agent; + aggregatedAgentsState.value = { + agents: [toAggregatedAgent(agent)], + isInitialLoad: false, + }; + useSessionStore.getState().initializeSession(SERVER_ID, {} as unknown as DaemonClient); + useSessionStore.getState().setAgents(SERVER_ID, new Map([[agent.id, agent]])); + useSessionStore + .getState() + .setWorkspaces( + SERVER_ID, + new Map((input.workspaces ?? [workspace()]).map((entry) => [entry.id, entry])), + ); +} + +beforeEach(() => { + prepareWorkspaceTabMock.mockReset(); + navigateToPreparedWorkspaceTabMock.mockReset(); +}); + +afterEach(() => { + cleanup(); + useSessionStore.setState({ sessions: {}, agentLastActivity: new Map() }); +}); + +describe("SidebarSessionsView row navigation", () => { + it("prepares and navigates to the agent tab in the resolved workspace", () => { + seedState({ agent: makeAgent() }); + + const { getByTestId } = render( + , + ); + + fireEvent.click(getByTestId(`sidebar-session-row-${SERVER_ID}-${AGENT_ID}`)); + + const navigationInput = { + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + target: { kind: "agent", agentId: AGENT_ID }, + }; + expect(prepareWorkspaceTabMock).toHaveBeenCalledTimes(1); + expect(prepareWorkspaceTabMock).toHaveBeenCalledWith(navigationInput); + expect(navigateToPreparedWorkspaceTabMock).toHaveBeenCalledTimes(1); + expect(navigateToPreparedWorkspaceTabMock).toHaveBeenCalledWith(navigationInput); + expect(prepareWorkspaceTabMock.mock.invocationCallOrder[0]).toBeLessThan( + navigateToPreparedWorkspaceTabMock.mock.invocationCallOrder[0] ?? 0, + ); + }); + + it("does nothing when the row agent no longer resolves to a workspace", () => { + seedState({ agent: makeAgent() }); + + const { getByTestId } = render( + , + ); + useSessionStore.getState().setWorkspaces(SERVER_ID, new Map()); + + fireEvent.click(getByTestId(`sidebar-session-row-${SERVER_ID}-${AGENT_ID}`)); + + expect(prepareWorkspaceTabMock).not.toHaveBeenCalled(); + expect(navigateToPreparedWorkspaceTabMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/app/src/sidebar-sessions/toggle.tsx b/packages/app/src/sidebar-sessions/toggle.tsx new file mode 100644 index 0000000000..00a6722154 --- /dev/null +++ b/packages/app/src/sidebar-sessions/toggle.tsx @@ -0,0 +1,304 @@ +import { Filter } from "lucide-react-native"; +import { memo, useCallback, useMemo, type ReactElement } from "react"; +import { Pressable, Text, View, type PressableStateCallbackType } from "react-native"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list"; +import type { SidebarSessionFilter, SidebarSessionViewMode } from "./types"; +import { useVisibleSidebarSessionFilterProjects } from "./use-sidebar-session-workspaces"; + +interface SidebarSessionsToggleProps { + serverId: string | null; + mode: SidebarSessionViewMode; + filter: SidebarSessionFilter; + projects: readonly SidebarProjectEntry[]; + onModeChange: (mode: SidebarSessionViewMode) => void; + onFilterChange: (filter: SidebarSessionFilter) => void; +} + +export const SidebarSessionsToggle = memo(function SidebarSessionsToggle({ + serverId, + mode, + filter, + projects, + onModeChange, + onFilterChange, +}: SidebarSessionsToggleProps): ReactElement { + const sessionsActive = mode === "sessions"; + + const handleWorkspacesPress = useCallback(() => { + onModeChange("workspaces"); + }, [onModeChange]); + const handleSessionsPress = useCallback(() => { + onModeChange("sessions"); + }, [onModeChange]); + + return ( + + + + + + {sessionsActive ? ( + + ) : null} + + ); +}); + +function SidebarSessionsFilterMenu({ + serverId, + filter, + projects, + onFilterChange, +}: { + serverId: string | null; + filter: SidebarSessionFilter; + projects: readonly SidebarProjectEntry[]; + onFilterChange: (filter: SidebarSessionFilter) => void; +}) { + const { theme } = useUnistyles(); + const filterActive = filter.type !== "all"; + const filterIconColor = filterActive ? theme.colors.accentForeground : theme.colors.foreground; + const visibleFilterProjects = useVisibleSidebarSessionFilterProjects({ serverId, projects }); + + const handleAllSelect = useCallback(() => { + onFilterChange({ type: "all" }); + }, [onFilterChange]); + + const filterTriggerStyle = useCallback( + ({ hovered = false, open = false }: PressableStateCallbackType & { open?: boolean }) => [ + styles.filterButton, + filterActive && styles.filterButtonActive, + (hovered || open) && !filterActive && styles.filterButtonHovered, + ], + [filterActive], + ); + + const filterIcon = useMemo( + () => , + [filterIconColor, theme.iconSize.sm], + ); + + return ( + + + {filterIcon} + {filterActive ? : null} + + + + All + + {visibleFilterProjects.length > 0 ? ( + <> + + Workspaces + {visibleFilterProjects.flatMap((project) => + project.workspaces.map((workspace) => ( + + )), + )} + + Projects + {visibleFilterProjects.map((project) => ( + + ))} + + ) : null} + + + ); +} + +function TogglePill({ + label, + active, + onPress, +}: { + label: string; + active: boolean; + onPress: () => void; +}) { + const pillStyle = useCallback( + ({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [ + styles.pill, + active && styles.pillActive, + hovered && !active && styles.pillHovered, + ], + [active], + ); + const textStyle = useMemo(() => [styles.pillText, active && styles.pillTextActive], [active]); + + return ( + + {label} + + ); +} + +function FilterItem({ + selected, + label, + description, + onSelect, +}: { + selected: boolean; + label: string; + description?: string; + onSelect: () => void; +}) { + return ( + + {label} + + ); +} + +function WorkspaceFilterItem({ + selected, + label, + description, + workspaceKey, + onFilterChange, +}: { + selected: boolean; + label: string; + description: string; + workspaceKey: string; + onFilterChange: (filter: SidebarSessionFilter) => void; +}) { + const handleSelect = useCallback(() => { + onFilterChange({ type: "workspace", workspaceKey }); + }, [onFilterChange, workspaceKey]); + + return ( + + ); +} + +function ProjectFilterItem({ + selected, + label, + projectKey, + onFilterChange, +}: { + selected: boolean; + label: string; + projectKey: string; + onFilterChange: (filter: SidebarSessionFilter) => void; +}) { + const handleSelect = useCallback(() => { + onFilterChange({ type: "project", projectKey }); + }, [onFilterChange, projectKey]); + + return ; +} + +const styles = StyleSheet.create((theme) => ({ + container: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + gap: theme.spacing[2], + paddingHorizontal: { + xs: theme.spacing[3], + md: theme.spacing[4], + }, + paddingTop: theme.spacing[3], + paddingBottom: theme.spacing[2], + }, + pillGroup: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + padding: 2, + borderRadius: theme.borderRadius.lg, + backgroundColor: theme.colors.surfaceSidebarHover, + minWidth: 0, + }, + pill: { + minHeight: 28, + justifyContent: "center", + paddingHorizontal: theme.spacing[3], + borderRadius: theme.borderRadius.md, + }, + pillActive: { + backgroundColor: theme.colors.surface1, + }, + pillHovered: { + backgroundColor: theme.colors.surfaceSidebar, + }, + pillText: { + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.medium, + color: theme.colors.foregroundMuted, + }, + pillTextActive: { + color: theme.colors.foreground, + }, + filterButton: { + width: 28, + height: 28, + alignItems: "center", + justifyContent: "center", + borderRadius: theme.borderRadius.md, + position: "relative", + }, + filterButtonHovered: { + backgroundColor: theme.colors.surfaceSidebarHover, + }, + filterButtonActive: { + backgroundColor: theme.colors.accent, + }, + filterBadge: { + position: "absolute", + top: 5, + right: 5, + width: 5, + height: 5, + borderRadius: theme.borderRadius.full, + backgroundColor: theme.colors.accentForeground, + }, +})); diff --git a/packages/app/src/sidebar-sessions/types.ts b/packages/app/src/sidebar-sessions/types.ts new file mode 100644 index 0000000000..5ce26b9076 --- /dev/null +++ b/packages/app/src/sidebar-sessions/types.ts @@ -0,0 +1,20 @@ +import type { AggregatedAgent } from "@/hooks/use-aggregated-agents"; + +export type SidebarSessionViewMode = "workspaces" | "sessions"; + +export type SidebarSessionFilter = + | { type: "all" } + | { type: "workspace"; workspaceKey: string } + | { type: "project"; projectKey: string }; + +export interface SidebarSessionWorkspace { + serverId: string; + workspaceId: string; + workspaceKey: string; + workspaceName: string; + projectKey: string; + projectName: string; + workspaceDirectory: string; +} + +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 new file mode 100644 index 0000000000..3beb87c8f4 --- /dev/null +++ b/packages/app/src/sidebar-sessions/use-sidebar-session-workspaces.ts @@ -0,0 +1,105 @@ +import equal from "fast-deep-equal"; +import { useMemo } from "react"; +import { useStoreWithEqualityFn } from "zustand/traditional"; +import { useSessionStore } from "@/stores/session-store"; +import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list"; +import type { SidebarSessionWorkspace } from "./types"; +import { + createSidebarSessionWorkspaceLookup, + deriveSidebarSessionFilterAvailability, + deriveSidebarSessionFilterProjects, +} from "./session-filtering"; + +const EMPTY_WORKSPACES: SidebarSessionWorkspace[] = []; +const EMPTY_AVAILABILITY = { workspaceKeys: [], projectKeys: [] } as const; + +export function useSidebarSessionWorkspaces(input: { + serverId: string | null; + projects: readonly SidebarProjectEntry[]; +}): SidebarSessionWorkspace[] { + const workspaceDirectoryById = useStoreWithEqualityFn( + useSessionStore, + (state) => { + if (!input.serverId) { + return {}; + } + const workspaces = state.sessions[input.serverId]?.workspaces; + if (!workspaces || workspaces.size === 0) { + return {}; + } + + const directoryById: Record = {}; + for (const workspace of workspaces.values()) { + directoryById[workspace.id] = workspace.workspaceDirectory; + } + return directoryById; + }, + equal, + ); + + return useMemo(() => { + if (!input.serverId || input.projects.length === 0) { + return EMPTY_WORKSPACES; + } + + const result: SidebarSessionWorkspace[] = []; + for (const project of input.projects) { + for (const workspace of project.workspaces) { + const workspaceDirectory = workspaceDirectoryById[workspace.workspaceId]; + if (!workspaceDirectory) { + continue; + } + result.push({ + serverId: input.serverId, + workspaceId: workspace.workspaceId, + workspaceKey: workspace.workspaceKey, + workspaceName: workspace.name, + projectKey: project.projectKey, + projectName: project.projectName, + workspaceDirectory, + }); + } + } + return result; + }, [input.projects, input.serverId, workspaceDirectoryById]); +} + +export function useVisibleSidebarSessionFilterProjects(input: { + serverId: string | null; + projects: readonly SidebarProjectEntry[]; +}): SidebarProjectEntry[] { + const workspaces = useSidebarSessionWorkspaces(input); + const lookup = useMemo(() => createSidebarSessionWorkspaceLookup(workspaces), [workspaces]); + const availability = useStoreWithEqualityFn( + useSessionStore, + (state) => { + if (!input.serverId || lookup.workspaceByExecutionKey.size === 0) { + return EMPTY_AVAILABILITY; + } + const serverId = input.serverId; + const agents = state.sessions[serverId]?.agents; + if (!agents || agents.size === 0) { + return EMPTY_AVAILABILITY; + } + return deriveSidebarSessionFilterAvailability({ + agents: Array.from(agents.values(), (agent) => ({ + id: agent.id, + serverId, + cwd: agent.cwd, + archivedAt: agent.archivedAt ?? null, + })), + lookup, + }); + }, + equal, + ); + + return useMemo( + () => + deriveSidebarSessionFilterProjects({ + projects: input.projects, + availability, + }), + [availability, input.projects], + ); +} diff --git a/packages/app/src/sidebar-sessions/view.tsx b/packages/app/src/sidebar-sessions/view.tsx new file mode 100644 index 0000000000..e0f52cd9b1 --- /dev/null +++ b/packages/app/src/sidebar-sessions/view.tsx @@ -0,0 +1,256 @@ +import React, { memo, useCallback, useMemo, type ReactElement } from "react"; +import { + FlatList, + Pressable, + Text, + View, + type ListRenderItem, + type PressableStateCallbackType, +} from "react-native"; +import { useShallow } from "zustand/shallow"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { SidebarAgentListSkeleton } from "@/components/sidebar-agent-list-skeleton"; +import { getProviderIcon } from "@/components/provider-icons"; +import { + type AggregatedAgentIdEntry, + useAggregatedAgentIds, + useAggregatedAgentsInitialLoad, +} from "@/hooks/use-aggregated-agents"; +import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list"; +import { useSessionStore } from "@/stores/session-store"; +import { navigateToPreparedWorkspaceTab, prepareWorkspaceTab } from "@/utils/workspace-navigation"; +import { shortenPath } from "@/utils/shorten-path"; +import { formatTimeAgo } from "@/utils/time"; +import { + createSidebarSessionWorkspaceLookup, + resolveSidebarSessionWorkspaceId, + shouldIncludeSidebarSessionAgent, +} from "./session-filtering"; +import { selectSidebarSessionSlice } from "./select-sidebar-session-slice"; +import type { SidebarSessionFilter } from "./types"; +import { useSidebarSessionWorkspaces } from "./use-sidebar-session-workspaces"; + +interface SidebarSessionsViewProps { + serverId: string | null; + projects: readonly SidebarProjectEntry[]; + filter: SidebarSessionFilter; +} + +export function SidebarSessionsView({ + serverId, + projects, + filter, +}: SidebarSessionsViewProps): ReactElement { + const isInitialLoad = useAggregatedAgentsInitialLoad(); + const workspaces = useSidebarSessionWorkspaces({ serverId, projects }); + const lookup = useMemo(() => createSidebarSessionWorkspaceLookup(workspaces), [workspaces]); + const filterAgent = useCallback( + (agent: AggregatedAgentIdEntry) => + shouldIncludeSidebarSessionAgent({ + agent, + filter, + lookup, + }), + [filter, lookup], + ); + const sessionIds = useAggregatedAgentIds({ + filter: filterAgent, + sort: "createdAt-desc-stable", + }); + + if (isInitialLoad) { + return ; + } + + return ; +} + +const SidebarSessionsList = memo(function SidebarSessionsList({ + serverId, + sessionIds, +}: { + serverId: string | null; + sessionIds: readonly string[]; +}): ReactElement { + const renderItem: ListRenderItem = useCallback( + ({ item }) => (serverId ? : null), + [serverId], + ); + const keyExtractor = useCallback((id: string) => `${serverId ?? ""}:${id}`, [serverId]); + + return ( + + ); +}); + +const SidebarSessionRow = memo(function SidebarSessionRow({ + id, + serverId, +}: { + id: string; + serverId: string; +}): ReactElement | null { + const { theme } = useUnistyles(); + const agent = useSessionStore( + useShallow((state) => selectSidebarSessionSlice(state, serverId, id)), + ); + const agentCwd = agent?.cwd ?? null; + + const handlePress = useCallback(() => { + if (!agentCwd) { + return; + } + + const workspaceId = resolveSidebarSessionWorkspaceId({ + agent: { + id, + serverId, + cwd: agentCwd, + archivedAt: null, + }, + workspaces: useSessionStore.getState().sessions[serverId]?.workspaces?.values(), + }); + if (!workspaceId) { + return; + } + + const target = { kind: "agent" as const, agentId: id }; + prepareWorkspaceTab({ + serverId, + workspaceId, + target, + }); + navigateToPreparedWorkspaceTab({ + serverId, + workspaceId, + target, + }); + }, [agentCwd, id, serverId]); + const pressableStyle = useCallback( + ({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [ + styles.row, + hovered && styles.rowHovered, + pressed && styles.rowPressed, + ], + [], + ); + + if (!agent || agent.archivedAt) { + return null; + } + + const ProviderIcon = getProviderIcon(agent.provider); + + return ( + + + + + + + + {agent.title || "New session"} + + {formatTimeAgo(agent.lastActivityAt)} + + + {shortenPath(agent.cwd)} + + + + ); +}); + +function EmptySessions(): ReactElement { + return ( + + No sessions + + ); +} + +const styles = StyleSheet.create((theme) => ({ + list: { + flex: 1, + minHeight: 0, + }, + listContent: { + paddingHorizontal: { + xs: theme.spacing[3], + md: theme.spacing[4], + }, + paddingTop: theme.spacing[2], + paddingBottom: theme.spacing[6], + gap: theme.spacing[1], + }, + row: { + minHeight: 52, + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + paddingVertical: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + borderRadius: theme.borderRadius.lg, + }, + rowHovered: { + backgroundColor: theme.colors.surfaceSidebarHover, + }, + rowPressed: { + backgroundColor: theme.colors.surface2, + }, + providerIconWrap: { + width: theme.iconSize.md, + alignItems: "center", + justifyContent: "center", + flexShrink: 0, + }, + rowContent: { + flex: 1, + minWidth: 0, + }, + titleRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + minWidth: 0, + }, + title: { + flex: 1, + minWidth: 0, + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.medium, + color: theme.colors.foreground, + }, + timeAgo: { + flexShrink: 0, + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + }, + path: { + marginTop: 2, + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + }, + emptyState: { + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[6], + }, + emptyText: { + fontSize: theme.fontSize.sm, + color: theme.colors.foregroundMuted, + }, +}));