mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 19:52:49 +00:00
feat(app): sidebar Workspaces/Sessions toggle with History rename
Adds a Notion-style pill toggle at the top of the always-visible sidebar that switches between Workspaces (current tree) and Sessions (flat live-updating list of agents across the daemon). Sessions are sorted by createdAt desc (stable, tie-broken by id) and filtered to agents whose cwd maps to an opened workspace; clicking a row focuses the matching workspace tab via prepareWorkspaceTab + navigateToPreparedWorkspaceTab. The existing "Sessions" sidebar row is renamed to "History" (same destination). Lives in its own module at packages/app/src/sidebar-sessions/ with the sidebar shell pieces extracted to packages/app/src/components/sidebar/. Render performance is treated as first-class: parent subscribes to an ordered id list with shallow array equality (useAggregatedAgentIds), each row uses a per-agent shallow Zustand selector, the inactive view is unmounted on toggle, and the filter dropdown subscription is gated to Sessions mode. Filter state resets on host change. Agents whose cwd doesn't map to an opened workspace are hidden from both list and dropdown options. Includes a Playwright user-journey spec and unit/integration tests for the new ids hook, slice selector, render boundaries, navigation, filtering, and the sidebar-sessions controller.
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void>;
|
||||
}
|
||||
|
||||
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<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function seedSidebarSessionsJourney(clients: {
|
||||
archiveClient: Awaited<ReturnType<typeof connectArchiveTabDaemonClient>>;
|
||||
workspaceClient: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
}): Promise<SeededSidebarSessions> {
|
||||
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<ReturnType<typeof connectArchiveTabDaemonClient>>;
|
||||
workspaceClient: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
},
|
||||
): Promise<void> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<View | null>;
|
||||
isHostPickerOpen: boolean;
|
||||
@@ -182,6 +192,8 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
);
|
||||
const hostTriggerRef = useRef<View | null>(null);
|
||||
const [isHostPickerOpen, setIsHostPickerOpen] = useState(false);
|
||||
const { sidebarViewMode, setSidebarViewMode, sidebarSessionFilter, setSidebarSessionFilter } =
|
||||
useSidebarSessionsController({ serverId: activeServerId });
|
||||
|
||||
const { 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(
|
||||
() => (
|
||||
<SidebarSessionsToggle
|
||||
serverId={activeServerId}
|
||||
mode={sidebarViewMode}
|
||||
filter={sidebarSessionFilter}
|
||||
projects={projects}
|
||||
onModeChange={setSidebarViewMode}
|
||||
onFilterChange={setSidebarSessionFilter}
|
||||
/>
|
||||
),
|
||||
[
|
||||
activeServerId,
|
||||
projects,
|
||||
sidebarSessionFilter,
|
||||
sidebarViewMode,
|
||||
setSidebarSessionFilter,
|
||||
setSidebarViewMode,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
|
||||
@@ -672,29 +710,30 @@ function MobileSidebar({
|
||||
<GestureDetector gesture={closeGesture} touchAction="pan-y">
|
||||
<Animated.View style={mobileSidebarStyle} pointerEvents="auto">
|
||||
<View style={styles.sidebarContent} pointerEvents="auto">
|
||||
<SidebarHeaderRow
|
||||
icon={MessagesSquare}
|
||||
label="Sessions"
|
||||
onPress={handleViewMore}
|
||||
isActive={isSessionsActive}
|
||||
testID="sidebar-sessions"
|
||||
/>
|
||||
<SidebarShellHeader toggleSlot={toggleSlot}>
|
||||
<SidebarHistoryNavigationRow onPress={handleViewMore} />
|
||||
</SidebarShellHeader>
|
||||
|
||||
{isInitialLoad ? (
|
||||
<SidebarAgentListSkeleton />
|
||||
) : (
|
||||
<SidebarWorkspaceList
|
||||
{sidebarViewMode === "workspaces" ? (
|
||||
<SidebarWorkspacesView
|
||||
serverId={activeServerId}
|
||||
collapsedProjectKeys={collapsedProjectKeys}
|
||||
onToggleProjectCollapsed={toggleProjectCollapsed}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
projects={projects}
|
||||
isInitialLoad={isInitialLoad}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
onWorkspacePress={handleWorkspacePress}
|
||||
onAddProject={handleOpenProject}
|
||||
parentGestureRef={closeGestureRef}
|
||||
/>
|
||||
) : (
|
||||
<SidebarSessionsView
|
||||
serverId={activeServerId}
|
||||
projects={projects}
|
||||
filter={sidebarSessionFilter}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SidebarFooter
|
||||
@@ -723,6 +762,10 @@ function DesktopSidebar({
|
||||
activeServerId,
|
||||
activeHostLabel,
|
||||
activeHostStatusColor,
|
||||
sidebarViewMode,
|
||||
setSidebarViewMode,
|
||||
sidebarSessionFilter,
|
||||
setSidebarSessionFilter,
|
||||
hostOptions,
|
||||
hostTriggerRef,
|
||||
isHostPickerOpen,
|
||||
@@ -743,8 +786,6 @@ function DesktopSidebar({
|
||||
isOpen,
|
||||
handleViewMore,
|
||||
}: DesktopSidebarProps) {
|
||||
const pathname = usePathname();
|
||||
const isSessionsActive = pathname.includes("/sessions");
|
||||
const padding = useWindowControlsPadding("sidebar");
|
||||
const sidebarWidth = usePanelStore((state) => 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(
|
||||
() => (
|
||||
<SidebarSessionsToggle
|
||||
serverId={activeServerId}
|
||||
mode={sidebarViewMode}
|
||||
filter={sidebarSessionFilter}
|
||||
projects={projects}
|
||||
onModeChange={setSidebarViewMode}
|
||||
onFilterChange={setSidebarSessionFilter}
|
||||
/>
|
||||
),
|
||||
[
|
||||
activeServerId,
|
||||
projects,
|
||||
sidebarSessionFilter,
|
||||
sidebarViewMode,
|
||||
setSidebarSessionFilter,
|
||||
setSidebarViewMode,
|
||||
],
|
||||
);
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
@@ -807,31 +868,32 @@ function DesktopSidebar({
|
||||
return (
|
||||
<Animated.View style={desktopSidebarStyle}>
|
||||
<View style={desktopSidebarBorderStyle}>
|
||||
<View style={styles.sidebarDragArea}>
|
||||
<TitlebarDragRegion />
|
||||
{padding.top > 0 ? <View style={paddingTopSpacerStyle} /> : null}
|
||||
<SidebarHeaderRow
|
||||
icon={MessagesSquare}
|
||||
label="Sessions"
|
||||
onPress={handleViewMore}
|
||||
isActive={isSessionsActive}
|
||||
testID="sidebar-sessions"
|
||||
/>
|
||||
</View>
|
||||
<SidebarShellHeader
|
||||
topSpacerStyle={padding.top > 0 ? paddingTopSpacerStyle : undefined}
|
||||
toggleSlot={toggleSlot}
|
||||
withTitlebarDragRegion
|
||||
>
|
||||
<SidebarHistoryNavigationRow onPress={handleViewMore} />
|
||||
</SidebarShellHeader>
|
||||
|
||||
{isInitialLoad ? (
|
||||
<SidebarAgentListSkeleton />
|
||||
) : (
|
||||
<SidebarWorkspaceList
|
||||
{sidebarViewMode === "workspaces" ? (
|
||||
<SidebarWorkspacesView
|
||||
serverId={activeServerId}
|
||||
collapsedProjectKeys={collapsedProjectKeys}
|
||||
onToggleProjectCollapsed={toggleProjectCollapsed}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
projects={projects}
|
||||
isInitialLoad={isInitialLoad}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
onAddProject={handleOpenProject}
|
||||
/>
|
||||
) : (
|
||||
<SidebarSessionsView
|
||||
serverId={activeServerId}
|
||||
projects={projects}
|
||||
filter={sidebarSessionFilter}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SidebarCalloutSlot />
|
||||
@@ -898,9 +960,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
width: 10,
|
||||
zIndex: 10,
|
||||
},
|
||||
sidebarDragArea: {
|
||||
position: "relative",
|
||||
},
|
||||
hostTrigger: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -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 (
|
||||
<SidebarHeaderRow
|
||||
icon={MessagesSquare}
|
||||
label="History"
|
||||
onPress={onPress}
|
||||
isActive={isSessionsActive}
|
||||
testID="sidebar-sessions"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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<ViewStyle>;
|
||||
toggleSlot?: ReactNode;
|
||||
withTitlebarDragRegion?: boolean;
|
||||
}
|
||||
|
||||
export function SidebarShellHeader({
|
||||
children,
|
||||
topSpacerStyle,
|
||||
toggleSlot,
|
||||
withTitlebarDragRegion = false,
|
||||
}: SidebarShellHeaderProps) {
|
||||
const content = (
|
||||
<Fragment>
|
||||
{withTitlebarDragRegion ? <TitlebarDragRegion /> : null}
|
||||
{topSpacerStyle ? <View style={topSpacerStyle} /> : null}
|
||||
{toggleSlot}
|
||||
{children}
|
||||
</Fragment>
|
||||
);
|
||||
|
||||
if (withTitlebarDragRegion || topSpacerStyle) {
|
||||
return <View style={styles.headerDragArea}>{content}</View>;
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create(() => ({
|
||||
headerDragArea: {
|
||||
position: "relative",
|
||||
},
|
||||
}));
|
||||
@@ -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<string>;
|
||||
shortcutIndexByWorkspaceKey: Map<string, number>;
|
||||
onToggleProjectCollapsed: (projectKey: string) => void;
|
||||
onRefresh: () => void;
|
||||
onAddProject: () => void;
|
||||
onWorkspacePress?: () => void;
|
||||
parentGestureRef?: MutableRefObject<GestureType | undefined>;
|
||||
}
|
||||
|
||||
export function SidebarWorkspacesView({
|
||||
serverId,
|
||||
projects,
|
||||
isInitialLoad,
|
||||
isRefreshing,
|
||||
collapsedProjectKeys,
|
||||
shortcutIndexByWorkspaceKey,
|
||||
onToggleProjectCollapsed,
|
||||
onRefresh,
|
||||
onAddProject,
|
||||
onWorkspacePress,
|
||||
parentGestureRef,
|
||||
}: SidebarWorkspacesViewProps): ReactElement {
|
||||
if (isInitialLoad) {
|
||||
return <SidebarAgentListSkeleton />;
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarWorkspaceList
|
||||
serverId={serverId}
|
||||
collapsedProjectKeys={collapsedProjectKeys}
|
||||
onToggleProjectCollapsed={onToggleProjectCollapsed}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
projects={projects}
|
||||
isRefreshing={isRefreshing}
|
||||
onRefresh={onRefresh}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
onAddProject={onAddProject}
|
||||
parentGestureRef={parentGestureRef}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>): 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"]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<SidebarSessionViewMode>("workspaces");
|
||||
const [sidebarSessionFilter, setSidebarSessionFilter] = useState<SidebarSessionFilter>({
|
||||
type: "all",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (previousServerIdRef.current === serverId) {
|
||||
return;
|
||||
}
|
||||
previousServerIdRef.current = serverId;
|
||||
setSidebarSessionFilter({ type: "all" });
|
||||
}, [serverId]);
|
||||
|
||||
return {
|
||||
sidebarViewMode,
|
||||
setSidebarViewMode,
|
||||
sidebarSessionFilter,
|
||||
setSidebarSessionFilter,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { useSidebarSessionsController } from "./controller";
|
||||
export { SidebarSessionsToggle } from "./toggle";
|
||||
export { SidebarSessionsView } from "./view";
|
||||
export type { SidebarSessionFilter, SidebarSessionViewMode } from "./types";
|
||||
@@ -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> = {}): Agent {
|
||||
return { ...AGENT_DEFAULTS, ...input };
|
||||
}
|
||||
|
||||
function seedAgent(agent: Agent) {
|
||||
useSessionStore.getState().initializeSession("server-1", {} as unknown as DaemonClient);
|
||||
useSessionStore.getState().setAgents("server-1", new Map([[agent.id, agent]]));
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
useSessionStore.setState({ sessions: {}, agentLastActivity: new Map() });
|
||||
});
|
||||
|
||||
describe("sidebar session render boundaries", () => {
|
||||
it("changes the row slice when lastActivityAt changes", () => {
|
||||
const beforeAgent = makeAgent();
|
||||
seedAgent(beforeAgent);
|
||||
|
||||
const beforeSlice = selectSidebarSessionSlice(
|
||||
useSessionStore.getState(),
|
||||
"server-1",
|
||||
"agent-1",
|
||||
);
|
||||
|
||||
const afterAgent = makeAgent({
|
||||
lastActivityAt: new Date("2026-05-08T11:00:00.000Z"),
|
||||
});
|
||||
useSessionStore.getState().setAgents("server-1", new Map([["agent-1", afterAgent]]));
|
||||
|
||||
const afterSlice = selectSidebarSessionSlice(useSessionStore.getState(), "server-1", "agent-1");
|
||||
|
||||
expect(shallow(beforeSlice, afterSlice)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not change the row slice for fields the row does not consume", () => {
|
||||
seedAgent(makeAgent());
|
||||
|
||||
const beforeSlice = selectSidebarSessionSlice(
|
||||
useSessionStore.getState(),
|
||||
"server-1",
|
||||
"agent-1",
|
||||
);
|
||||
|
||||
useSessionStore.getState().setAgents(
|
||||
"server-1",
|
||||
new Map([
|
||||
[
|
||||
"agent-1",
|
||||
makeAgent({
|
||||
status: "idle",
|
||||
createdAt: new Date("2026-05-08T11:00:00.000Z"),
|
||||
requiresAttention: true,
|
||||
}),
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
const afterSlice = selectSidebarSessionSlice(useSessionStore.getState(), "server-1", "agent-1");
|
||||
|
||||
expect(shallow(beforeSlice, afterSlice)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,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> = {}): 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"));
|
||||
});
|
||||
});
|
||||
@@ -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<typeof useSessionStore.getState>,
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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"] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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<string, SidebarSessionWorkspace>;
|
||||
}
|
||||
|
||||
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<string, SidebarSessionWorkspace>();
|
||||
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<WorkspaceDescriptor> | 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<string>();
|
||||
const projectKeys = new Set<string>();
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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> = {}): Agent {
|
||||
return { ...AGENT_DEFAULTS, ...input };
|
||||
}
|
||||
|
||||
function toAggregatedAgent(agent: Agent): AggregatedAgent {
|
||||
return {
|
||||
...agent,
|
||||
serverLabel: "Server 1",
|
||||
};
|
||||
}
|
||||
|
||||
function workspace(input: Partial<WorkspaceDescriptor> = {}): 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(
|
||||
<SidebarSessionsView
|
||||
serverId={SERVER_ID}
|
||||
projects={projects()}
|
||||
filter={ALL_SESSIONS_FILTER}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<SidebarSessionsView
|
||||
serverId={SERVER_ID}
|
||||
projects={projects()}
|
||||
filter={ALL_SESSIONS_FILTER}
|
||||
/>,
|
||||
);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.pillGroup}>
|
||||
<TogglePill
|
||||
label="Workspaces"
|
||||
active={mode === "workspaces"}
|
||||
onPress={handleWorkspacesPress}
|
||||
/>
|
||||
<TogglePill label="Sessions" active={sessionsActive} onPress={handleSessionsPress} />
|
||||
</View>
|
||||
{sessionsActive ? (
|
||||
<SidebarSessionsFilterMenu
|
||||
serverId={serverId}
|
||||
filter={filter}
|
||||
projects={projects}
|
||||
onFilterChange={onFilterChange}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
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(
|
||||
() => <Filter size={theme.iconSize.sm} color={filterIconColor} />,
|
||||
[filterIconColor, theme.iconSize.sm],
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
accessibilityLabel="Filter sessions"
|
||||
accessibilityRole="button"
|
||||
style={filterTriggerStyle}
|
||||
testID="sidebar-sessions-filter-trigger"
|
||||
>
|
||||
{filterIcon}
|
||||
{filterActive ? <View style={styles.filterBadge} /> : null}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" width={260} scrollable maxHeight={420}>
|
||||
<DropdownMenuItem selected={filter.type === "all"} onSelect={handleAllSelect}>
|
||||
All
|
||||
</DropdownMenuItem>
|
||||
{visibleFilterProjects.length > 0 ? (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>Workspaces</DropdownMenuLabel>
|
||||
{visibleFilterProjects.flatMap((project) =>
|
||||
project.workspaces.map((workspace) => (
|
||||
<WorkspaceFilterItem
|
||||
key={workspace.workspaceKey}
|
||||
selected={
|
||||
filter.type === "workspace" && filter.workspaceKey === workspace.workspaceKey
|
||||
}
|
||||
label={workspace.name}
|
||||
description={project.projectName}
|
||||
workspaceKey={workspace.workspaceKey}
|
||||
onFilterChange={onFilterChange}
|
||||
/>
|
||||
)),
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>Projects</DropdownMenuLabel>
|
||||
{visibleFilterProjects.map((project) => (
|
||||
<ProjectFilterItem
|
||||
key={project.projectKey}
|
||||
selected={filter.type === "project" && filter.projectKey === project.projectKey}
|
||||
label={project.projectName}
|
||||
projectKey={project.projectKey}
|
||||
onFilterChange={onFilterChange}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Pressable accessibilityRole="button" onPress={onPress} style={pillStyle}>
|
||||
<Text style={textStyle}>{label}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterItem({
|
||||
selected,
|
||||
label,
|
||||
description,
|
||||
onSelect,
|
||||
}: {
|
||||
selected: boolean;
|
||||
label: string;
|
||||
description?: string;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuItem selected={selected} description={description} onSelect={onSelect}>
|
||||
{label}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<FilterItem
|
||||
selected={selected}
|
||||
label={label}
|
||||
description={description}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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 <FilterItem selected={selected} label={label} onSelect={handleSelect} />;
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
}));
|
||||
@@ -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<AggregatedAgent, "id" | "serverId" | "cwd" | "archivedAt">;
|
||||
@@ -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<string, string> = {};
|
||||
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],
|
||||
);
|
||||
}
|
||||
@@ -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 <SidebarAgentListSkeleton />;
|
||||
}
|
||||
|
||||
return <SidebarSessionsList serverId={serverId} sessionIds={sessionIds} />;
|
||||
}
|
||||
|
||||
const SidebarSessionsList = memo(function SidebarSessionsList({
|
||||
serverId,
|
||||
sessionIds,
|
||||
}: {
|
||||
serverId: string | null;
|
||||
sessionIds: readonly string[];
|
||||
}): ReactElement {
|
||||
const renderItem: ListRenderItem<string> = useCallback(
|
||||
({ item }) => (serverId ? <SidebarSessionRow id={item} serverId={serverId} /> : null),
|
||||
[serverId],
|
||||
);
|
||||
const keyExtractor = useCallback((id: string) => `${serverId ?? ""}:${id}`, [serverId]);
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
data={sessionIds}
|
||||
style={styles.list}
|
||||
contentContainerStyle={styles.listContent}
|
||||
keyExtractor={keyExtractor}
|
||||
renderItem={renderItem}
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
ListEmptyComponent={EmptySessions}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
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 (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
onPress={handlePress}
|
||||
style={pressableStyle}
|
||||
testID={`sidebar-session-row-${serverId}-${id}`}
|
||||
>
|
||||
<View style={styles.providerIconWrap}>
|
||||
<ProviderIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
<View style={styles.rowContent}>
|
||||
<View style={styles.titleRow}>
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
{agent.title || "New session"}
|
||||
</Text>
|
||||
<Text style={styles.timeAgo}>{formatTimeAgo(agent.lastActivityAt)}</Text>
|
||||
</View>
|
||||
<Text style={styles.path} numberOfLines={1}>
|
||||
{shortenPath(agent.cwd)}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
});
|
||||
|
||||
function EmptySessions(): ReactElement {
|
||||
return (
|
||||
<View style={styles.emptyState}>
|
||||
<Text style={styles.emptyText}>No sessions</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user