mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-02 22:52:58 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bcd32962f7 | ||
|
|
d00bd666a7 | ||
|
|
4f047832ac | ||
|
|
a3c3d25592 | ||
|
|
b252cd98d2 | ||
|
|
670e6a6fdd | ||
|
|
e9c3753466 | ||
|
|
9c1bea7135 |
@@ -15,6 +15,7 @@ export interface ArchiveTabAgent {
|
|||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ArchiveTabDaemonClient {
|
interface ArchiveTabDaemonClient {
|
||||||
@@ -33,9 +34,9 @@ interface ArchiveTabDaemonClient {
|
|||||||
waitForFinish(agentId: string, timeout?: number): Promise<{ status: string }>;
|
waitForFinish(agentId: string, timeout?: number): Promise<{ status: string }>;
|
||||||
waitForAgentUpsert(
|
waitForAgentUpsert(
|
||||||
agentId: string,
|
agentId: string,
|
||||||
predicate: (snapshot: { status: string }) => boolean,
|
predicate: (snapshot: { status: string; createdAt: string }) => boolean,
|
||||||
timeout?: number,
|
timeout?: number,
|
||||||
): Promise<{ status: string }>;
|
): Promise<{ status: string; createdAt: string }>;
|
||||||
fetchAgentHistory(options?: {
|
fetchAgentHistory(options?: {
|
||||||
page?: { limit: number };
|
page?: { limit: number };
|
||||||
}): Promise<{ entries: Array<{ id: string }> }>;
|
}): Promise<{ entries: Array<{ id: string }> }>;
|
||||||
@@ -132,6 +133,7 @@ export async function createIdleAgent(
|
|||||||
id: created.id,
|
id: created.id,
|
||||||
title: input.title,
|
title: input.title,
|
||||||
cwd: input.cwd,
|
cwd: input.cwd,
|
||||||
|
createdAt: snapshot.createdAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,7 +259,7 @@ export async function reloadWorkspace(page: Page, workspaceId: string): Promise<
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function openSessions(page: Page): Promise<void> {
|
export async function openSessions(page: Page): Promise<void> {
|
||||||
const sessionsButton = page.getByTestId("sidebar-sessions");
|
const sessionsButton = page.getByTestId("sidebar-history");
|
||||||
await expect(sessionsButton).toBeVisible({ timeout: 30_000 });
|
await expect(sessionsButton).toBeVisible({ timeout: 30_000 });
|
||||||
await sessionsButton.click();
|
await sessionsButton.click();
|
||||||
await expect(page).toHaveURL(new RegExp(`${buildHostSessionsRoute(getServerId())}$`), {
|
await expect(page).toHaveURL(new RegExp(`${buildHostSessionsRoute(getServerId())}$`), {
|
||||||
|
|||||||
@@ -31,9 +31,9 @@ export async function closeMobileAgentSidebar(page: Page): Promise<void> {
|
|||||||
|
|
||||||
// The mobile sidebar panel animates via translateX; toBeInViewport reflects the rendered position.
|
// The mobile sidebar panel animates via translateX; toBeInViewport reflects the rendered position.
|
||||||
export async function expectMobileAgentSidebarVisible(page: Page): Promise<void> {
|
export async function expectMobileAgentSidebarVisible(page: Page): Promise<void> {
|
||||||
await expect(page.getByTestId("sidebar-sessions")).toBeInViewport({ timeout: 5_000 });
|
await expect(page.getByTestId("sidebar-history")).toBeInViewport({ timeout: 5_000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function expectMobileAgentSidebarHidden(page: Page): Promise<void> {
|
export async function expectMobileAgentSidebarHidden(page: Page): Promise<void> {
|
||||||
await expect(page.getByTestId("sidebar-sessions")).not.toBeInViewport({ timeout: 5_000 });
|
await expect(page.getByTestId("sidebar-history")).not.toBeInViewport({ timeout: 5_000 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,348 @@
|
|||||||
|
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 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.getByTestId(`workspace-tab-agent_${input.agent.id}`).filter({ visible: true }).first(),
|
||||||
|
).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 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 project, and opens a session tab", async ({ page }) => {
|
||||||
|
await runSidebarHistorySessionsJourney(page);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
appId: sh.paseo
|
appId: sh.paseo
|
||||||
---
|
---
|
||||||
# Ensure sidebar is closed: if sidebar-sessions is visible, close it via swipe left
|
# Ensure sidebar is closed: if sidebar-history is visible, close it via swipe left
|
||||||
- runFlow:
|
- runFlow:
|
||||||
when:
|
when:
|
||||||
visible:
|
visible:
|
||||||
id: "sidebar-sessions"
|
id: "sidebar-history"
|
||||||
commands:
|
commands:
|
||||||
- swipe:
|
- swipe:
|
||||||
direction: LEFT
|
direction: LEFT
|
||||||
@@ -21,7 +21,7 @@ appId: sh.paseo
|
|||||||
|
|
||||||
# Verify sidebar opened
|
# Verify sidebar opened
|
||||||
- assertVisible:
|
- assertVisible:
|
||||||
id: "sidebar-sessions"
|
id: "sidebar-history"
|
||||||
- takeScreenshot: 01-sidebar-opened
|
- takeScreenshot: 01-sidebar-opened
|
||||||
|
|
||||||
# Close sidebar via swipe left
|
# Close sidebar via swipe left
|
||||||
|
|||||||
@@ -0,0 +1,265 @@
|
|||||||
|
/**
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
*/
|
||||||
|
import React from "react";
|
||||||
|
import { act } from "@testing-library/react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { HostRuntimeBootstrapState } from "./_layout";
|
||||||
|
|
||||||
|
const { state, startHostRuntimeBootstrapMock } = vi.hoisted(() => {
|
||||||
|
return {
|
||||||
|
state: {
|
||||||
|
anyOnlineHostServerId: null as string | null,
|
||||||
|
daemonStartError: null as string | null,
|
||||||
|
daemonStartIsRunning: false,
|
||||||
|
latestBootstrapState: null as HostRuntimeBootstrapState | null,
|
||||||
|
},
|
||||||
|
startHostRuntimeBootstrapMock: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@/styles/unistyles", () => ({}));
|
||||||
|
vi.mock("@gorhom/portal", () => ({
|
||||||
|
PortalProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||||
|
}));
|
||||||
|
vi.mock("@tanstack/react-query", () => ({
|
||||||
|
QueryClientProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||||
|
}));
|
||||||
|
vi.mock("expo-linking", () => ({ addEventListener: vi.fn(), getInitialURL: vi.fn() }));
|
||||||
|
vi.mock("expo-notifications", () => ({
|
||||||
|
addNotificationResponseReceivedListener: vi.fn(),
|
||||||
|
getLastNotificationResponseAsync: vi.fn(),
|
||||||
|
setNotificationHandler: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("expo-router", () => ({
|
||||||
|
Stack: Object.assign(() => null, { Protected: () => null, Screen: () => null }),
|
||||||
|
useGlobalSearchParams: () => ({}),
|
||||||
|
usePathname: () => "/",
|
||||||
|
useRouter: () => ({ navigate: vi.fn(), replace: vi.fn() }),
|
||||||
|
}));
|
||||||
|
vi.mock("react-native", () => ({
|
||||||
|
View: ({ children }: { children?: React.ReactNode }) => children,
|
||||||
|
}));
|
||||||
|
vi.mock("react-native-gesture-handler", () => ({
|
||||||
|
Gesture: { Pan: () => ({}) },
|
||||||
|
GestureDetector: ({ children }: { children: React.ReactNode }) => children,
|
||||||
|
GestureHandlerRootView: ({ children }: { children: React.ReactNode }) => children,
|
||||||
|
}));
|
||||||
|
vi.mock("react-native-keyboard-controller", () => ({
|
||||||
|
KeyboardProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||||
|
}));
|
||||||
|
vi.mock("react-native-reanimated", () => ({
|
||||||
|
Extrapolation: { CLAMP: "clamp" },
|
||||||
|
interpolate: vi.fn(),
|
||||||
|
runOnJS: (fn: () => void) => fn,
|
||||||
|
useSharedValue: (value: unknown) => ({ value }),
|
||||||
|
}));
|
||||||
|
vi.mock("react-native-safe-area-context", () => ({
|
||||||
|
SafeAreaProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||||
|
}));
|
||||||
|
vi.mock("react-native-unistyles", () => ({
|
||||||
|
StyleSheet: {
|
||||||
|
create: (factory: unknown) =>
|
||||||
|
typeof factory === "function"
|
||||||
|
? factory({ colors: { surface0: "#000", foreground: "#fff" } })
|
||||||
|
: factory,
|
||||||
|
},
|
||||||
|
UnistylesRuntime: { setAdaptiveThemes: vi.fn(), setTheme: vi.fn() },
|
||||||
|
useUnistyles: () => ({ theme: { colors: { surface0: "#000", foreground: "#fff" } } }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/command-center", () => ({ CommandCenter: () => null }));
|
||||||
|
vi.mock("@/components/worktree-setup-callout-source", () => ({
|
||||||
|
WorktreeSetupCalloutSource: () => null,
|
||||||
|
}));
|
||||||
|
vi.mock("@/components/download-toast", () => ({ DownloadToast: () => null }));
|
||||||
|
vi.mock("@/components/quitting-overlay", () => ({ QuittingOverlay: () => null }));
|
||||||
|
vi.mock("@/components/keyboard-shortcuts-dialog", () => ({ KeyboardShortcutsDialog: () => null }));
|
||||||
|
vi.mock("@/components/left-sidebar", () => ({ LeftSidebar: () => null }));
|
||||||
|
vi.mock("@/components/project-picker-modal", () => ({ ProjectPickerModal: () => null }));
|
||||||
|
vi.mock("@/components/workspace-setup-dialog", () => ({ WorkspaceSetupDialog: () => null }));
|
||||||
|
vi.mock("@/components/workspace-shortcut-targets-subscriber", () => ({
|
||||||
|
WorkspaceShortcutTargetsSubscriber: () => null,
|
||||||
|
}));
|
||||||
|
vi.mock("@/constants/layout", () => ({
|
||||||
|
getIsElectronRuntime: () => false,
|
||||||
|
useIsCompactFormFactor: () => false,
|
||||||
|
}));
|
||||||
|
vi.mock("@/constants/platform", () => ({ isNative: false, isWeb: true }));
|
||||||
|
vi.mock("@/contexts/horizontal-scroll-context", () => ({
|
||||||
|
HorizontalScrollProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||||
|
useHorizontalScrollOptional: () => null,
|
||||||
|
}));
|
||||||
|
vi.mock("@/contexts/session-context", () => ({
|
||||||
|
SessionProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||||
|
}));
|
||||||
|
vi.mock("@/contexts/sidebar-animation-context", () => ({
|
||||||
|
SidebarAnimationProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||||
|
useSidebarAnimation: () => ({
|
||||||
|
animateToClose: vi.fn(),
|
||||||
|
animateToOpen: vi.fn(),
|
||||||
|
backdropOpacity: { value: 0 },
|
||||||
|
gestureAnimatingRef: { current: false },
|
||||||
|
isGesturing: { value: false },
|
||||||
|
openGestureRef: {},
|
||||||
|
translateX: { value: 0 },
|
||||||
|
windowWidth: 320,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
vi.mock("@/contexts/sidebar-callout-context", () => ({
|
||||||
|
SidebarCalloutProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||||
|
}));
|
||||||
|
vi.mock("@/contexts/toast-context", () => ({
|
||||||
|
ToastProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||||
|
}));
|
||||||
|
vi.mock("@/contexts/voice-context", () => ({
|
||||||
|
VoiceProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||||
|
}));
|
||||||
|
vi.mock("@/app/host-runtime-bootstrap", () => ({
|
||||||
|
startDaemonIfGateAllows: vi.fn(),
|
||||||
|
startHostRuntimeBootstrap: startHostRuntimeBootstrapMock,
|
||||||
|
}));
|
||||||
|
vi.mock("@/desktop/daemon/desktop-daemon", () => ({ shouldUseDesktopDaemon: () => false }));
|
||||||
|
vi.mock("@/desktop/electron/events", () => ({ listenToDesktopEvent: vi.fn() }));
|
||||||
|
vi.mock("@/desktop/electron/window", () => ({ updateDesktopWindowControls: vi.fn() }));
|
||||||
|
vi.mock("@/desktop/host", () => ({ getDesktopHost: () => null }));
|
||||||
|
vi.mock("@/desktop/settings/desktop-settings", () => ({ loadDesktopSettings: vi.fn() }));
|
||||||
|
vi.mock("@/desktop/updates/rosetta-callout-source", () => ({ RosettaCalloutSource: () => null }));
|
||||||
|
vi.mock("@/desktop/updates/update-callout-source", () => ({ UpdateCalloutSource: () => null }));
|
||||||
|
vi.mock("@/hooks/use-active-worktree-new-action", () => ({ useActiveWorktreeNewAction: vi.fn() }));
|
||||||
|
vi.mock("@/hooks/use-favicon-status", () => ({ useFaviconStatus: vi.fn() }));
|
||||||
|
vi.mock("@/hooks/use-keyboard-shortcuts", () => ({ useKeyboardShortcuts: vi.fn() }));
|
||||||
|
vi.mock("@/hooks/use-latched-boolean", () => ({ useLatchedBoolean: (value: boolean) => value }));
|
||||||
|
vi.mock("@/hooks/use-open-project", () => ({ useOpenProject: () => vi.fn() }));
|
||||||
|
vi.mock("@/hooks/use-settings", () => ({
|
||||||
|
useAppSettings: () => ({
|
||||||
|
isLoading: false,
|
||||||
|
settings: { theme: "dark" },
|
||||||
|
updateSettings: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
vi.mock("@/hooks/use-stable-event", () => ({ useStableEvent: (fn: unknown) => fn }));
|
||||||
|
vi.mock("@/hooks/use-workspace-navigation", () => ({ navigateToWorkspace: vi.fn() }));
|
||||||
|
vi.mock("@/keyboard/keyboard-action-dispatcher", () => ({
|
||||||
|
keyboardActionDispatcher: { dispatch: vi.fn() },
|
||||||
|
}));
|
||||||
|
vi.mock("@/polyfills/crypto", () => ({ polyfillCrypto: vi.fn() }));
|
||||||
|
vi.mock("@/query/query-client", () => ({ queryClient: {} }));
|
||||||
|
vi.mock("@/runtime/host-runtime", () => ({
|
||||||
|
getHostRuntimeStore: () => ({
|
||||||
|
boot: vi.fn(),
|
||||||
|
getEarliestOnlineHostServerId: () => state.anyOnlineHostServerId,
|
||||||
|
subscribeAll: () => vi.fn(),
|
||||||
|
subscribeHostList: () => vi.fn(),
|
||||||
|
}),
|
||||||
|
useHostMutations: () => ({ upsertConnectionFromOfferUrl: vi.fn() }),
|
||||||
|
useHostRuntimeClient: () => null,
|
||||||
|
useHosts: () => [],
|
||||||
|
}));
|
||||||
|
vi.mock("@/runtime/daemon-start-service", () => ({
|
||||||
|
getDaemonStartService: () => ({
|
||||||
|
getLastError: () => state.daemonStartError,
|
||||||
|
isRunning: () => state.daemonStartIsRunning,
|
||||||
|
recordError: vi.fn(),
|
||||||
|
start: vi.fn(),
|
||||||
|
subscribe: () => vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
vi.mock("@/stores/panel-store", () => ({ usePanelStore: vi.fn(() => vi.fn()) }));
|
||||||
|
vi.mock("@/stores/session-store", () => ({
|
||||||
|
useSessionStore: { getState: () => ({ sessions: {} }) },
|
||||||
|
}));
|
||||||
|
vi.mock("@/styles/theme", () => ({ THEME_TO_UNISTYLES: { dark: "dark" } }));
|
||||||
|
vi.mock("@/utils/active-host", () => ({ resolveActiveHost: () => null }));
|
||||||
|
vi.mock("@/utils/desktop-sidebar-toggle", () => ({
|
||||||
|
toggleDesktopSidebarsWithCheckoutIntent: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/utils/host-routes", () => ({
|
||||||
|
buildHostRootRoute: (serverId: string) => `/h/${serverId}`,
|
||||||
|
mapPathnameToServer: () => null,
|
||||||
|
parseHostAgentRouteFromPathname: () => null,
|
||||||
|
parseServerIdFromPathname: () => null,
|
||||||
|
parseWorkspaceOpenIntent: () => null,
|
||||||
|
}));
|
||||||
|
vi.mock("@/utils/notification-routing", () => ({
|
||||||
|
buildNotificationRoute: () => "/",
|
||||||
|
resolveNotificationTarget: () => ({}),
|
||||||
|
}));
|
||||||
|
vi.mock("@/utils/os-notifications", () => ({
|
||||||
|
WEB_NOTIFICATION_CLICK_EVENT: "notification-click",
|
||||||
|
ensureOsNotificationPermission: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/utils/workspace-execution", () => ({
|
||||||
|
resolveWorkspaceIdByExecutionDirectory: () => null,
|
||||||
|
}));
|
||||||
|
vi.mock("@/utils/workspace-navigation", () => ({ prepareWorkspaceTab: vi.fn() }));
|
||||||
|
|
||||||
|
let useHostRuntimeBootstrapState: typeof import("./_layout").useHostRuntimeBootstrapState;
|
||||||
|
|
||||||
|
function BootstrapStateProbe() {
|
||||||
|
state.latestBootstrapState = useHostRuntimeBootstrapState();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("HostRuntimeBootstrapProvider startup give-up timer", () => {
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
state.anyOnlineHostServerId = null;
|
||||||
|
state.daemonStartError = null;
|
||||||
|
state.daemonStartIsRunning = false;
|
||||||
|
state.latestBootstrapState = null;
|
||||||
|
startHostRuntimeBootstrapMock.mockClear();
|
||||||
|
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
container.remove();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function renderProvider() {
|
||||||
|
const layout = await import("./_layout");
|
||||||
|
useHostRuntimeBootstrapState = layout.useHostRuntimeBootstrapState;
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<layout.HostRuntimeBootstrapProvider>
|
||||||
|
<BootstrapStateProbe />
|
||||||
|
</layout.HostRuntimeBootstrapProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("gives up after 5s when the env override host is not online", async () => {
|
||||||
|
await renderProvider();
|
||||||
|
|
||||||
|
expect(state.latestBootstrapState?.hasGivenUpWaitingForHost).toBe(false);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(5_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(state.latestBootstrapState?.hasGivenUpWaitingForHost).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not give up after 5s when an env override host is online", async () => {
|
||||||
|
state.anyOnlineHostServerId = "server-local";
|
||||||
|
|
||||||
|
await renderProvider();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(5_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(state.latestBootstrapState?.hasGivenUpWaitingForHost).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,6 +5,7 @@ import * as Linking from "expo-linking";
|
|||||||
import * as Notifications from "expo-notifications";
|
import * as Notifications from "expo-notifications";
|
||||||
import { Stack, useGlobalSearchParams, usePathname, useRouter } from "expo-router";
|
import { Stack, useGlobalSearchParams, usePathname, useRouter } from "expo-router";
|
||||||
import {
|
import {
|
||||||
|
default as React,
|
||||||
createContext,
|
createContext,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
useCallback,
|
useCallback,
|
||||||
@@ -65,7 +66,6 @@ import { polyfillCrypto } from "@/polyfills/crypto";
|
|||||||
import { queryClient } from "@/query/query-client";
|
import { queryClient } from "@/query/query-client";
|
||||||
import {
|
import {
|
||||||
getHostRuntimeStore,
|
getHostRuntimeStore,
|
||||||
hasConfiguredLocalDaemonOverride,
|
|
||||||
useHostMutations,
|
useHostMutations,
|
||||||
useHostRuntimeClient,
|
useHostRuntimeClient,
|
||||||
useHosts,
|
useHosts,
|
||||||
@@ -312,7 +312,7 @@ async function shouldStartBuiltInDaemon(): Promise<boolean> {
|
|||||||
return settings.daemon.manageBuiltInDaemon;
|
return settings.daemon.manageBuiltInDaemon;
|
||||||
}
|
}
|
||||||
|
|
||||||
function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
export function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const store = getHostRuntimeStore();
|
const store = getHostRuntimeStore();
|
||||||
const daemonStartService = getDaemonStartService({ store });
|
const daemonStartService = getDaemonStartService({ store });
|
||||||
@@ -327,16 +327,12 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
|||||||
const anyOnlineHostServerId = useEarliestOnlineHostServerId();
|
const anyOnlineHostServerId = useEarliestOnlineHostServerId();
|
||||||
const daemonStartError = useDaemonStartLastError();
|
const daemonStartError = useDaemonStartLastError();
|
||||||
const daemonStartIsRunning = useDaemonStartIsRunning();
|
const daemonStartIsRunning = useDaemonStartIsRunning();
|
||||||
const waitForConfiguredLocalDaemon =
|
|
||||||
hasConfiguredLocalDaemonOverride() && !shouldUseDesktopDaemon();
|
|
||||||
|
|
||||||
const [hasGivenUpWaitingForHost, setHasGivenUpWaitingForHost] = useState(false);
|
const [hasGivenUpWaitingForHost, setHasGivenUpWaitingForHost] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
anyOnlineHostServerId ||
|
anyOnlineHostServerId ||
|
||||||
daemonStartError ||
|
daemonStartError ||
|
||||||
daemonStartIsRunning ||
|
daemonStartIsRunning ||
|
||||||
waitForConfiguredLocalDaemon ||
|
|
||||||
hasGivenUpWaitingForHost
|
hasGivenUpWaitingForHost
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
@@ -347,13 +343,7 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
|||||||
return () => {
|
return () => {
|
||||||
clearTimeout(handle);
|
clearTimeout(handle);
|
||||||
};
|
};
|
||||||
}, [
|
}, [anyOnlineHostServerId, daemonStartError, daemonStartIsRunning, hasGivenUpWaitingForHost]);
|
||||||
anyOnlineHostServerId,
|
|
||||||
daemonStartError,
|
|
||||||
daemonStartIsRunning,
|
|
||||||
waitForConfiguredLocalDaemon,
|
|
||||||
hasGivenUpWaitingForHost,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const retry = useCallback(() => {
|
const retry = useCallback(() => {
|
||||||
const daemonStartService = getDaemonStartService({ store: getHostRuntimeStore() });
|
const daemonStartService = getDaemonStartService({ store: getHostRuntimeStore() });
|
||||||
|
|||||||
@@ -2,10 +2,16 @@ import { useLocalSearchParams } from "expo-router";
|
|||||||
import { NewWorkspaceScreen } from "@/screens/new-workspace-screen";
|
import { NewWorkspaceScreen } from "@/screens/new-workspace-screen";
|
||||||
|
|
||||||
export default function HostNewWorkspaceRoute() {
|
export default function HostNewWorkspaceRoute() {
|
||||||
const params = useLocalSearchParams<{ serverId?: string; dir?: string; name?: string }>();
|
const params = useLocalSearchParams<{
|
||||||
|
serverId?: string;
|
||||||
|
dir?: string;
|
||||||
|
name?: string;
|
||||||
|
title?: string;
|
||||||
|
}>();
|
||||||
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
||||||
const sourceDirectory = typeof params.dir === "string" ? params.dir : "";
|
const sourceDirectory = typeof params.dir === "string" ? params.dir : "";
|
||||||
const displayName = typeof params.name === "string" ? params.name : undefined;
|
const displayName = typeof params.name === "string" ? params.name : undefined;
|
||||||
|
const headerTitle = typeof params.title === "string" ? params.title : undefined;
|
||||||
|
|
||||||
if (!sourceDirectory) {
|
if (!sourceDirectory) {
|
||||||
return null;
|
return null;
|
||||||
@@ -16,6 +22,7 @@ export default function HostNewWorkspaceRoute() {
|
|||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
sourceDirectory={sourceDirectory}
|
sourceDirectory={sourceDirectory}
|
||||||
displayName={displayName}
|
displayName={displayName}
|
||||||
|
headerTitle={headerTitle}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { router, usePathname } from "expo-router";
|
import { router, usePathname } from "expo-router";
|
||||||
import { FolderPlus, MessagesSquare, Settings } from "lucide-react-native";
|
import { FolderPlus, Settings } from "lucide-react-native";
|
||||||
import {
|
import {
|
||||||
type Dispatch,
|
type Dispatch,
|
||||||
memo,
|
memo,
|
||||||
@@ -32,8 +32,9 @@ import Animated, {
|
|||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
import { SidebarHistoryNavigationRow } from "@/components/sidebar/sidebar-history-navigation-row";
|
||||||
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-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 { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||||
import { Shortcut } from "@/components/ui/shortcut";
|
import { Shortcut } from "@/components/ui/shortcut";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
@@ -62,9 +63,14 @@ import {
|
|||||||
buildSettingsRoute,
|
buildSettingsRoute,
|
||||||
mapPathnameToServer,
|
mapPathnameToServer,
|
||||||
} from "@/utils/host-routes";
|
} 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 { SidebarCalloutSlot } from "./sidebar-callout-slot";
|
||||||
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
|
|
||||||
|
|
||||||
const MIN_CHAT_WIDTH = 400;
|
const MIN_CHAT_WIDTH = 400;
|
||||||
|
|
||||||
@@ -80,6 +86,14 @@ interface SidebarSharedProps {
|
|||||||
activeServerId: string | null;
|
activeServerId: string | null;
|
||||||
activeHostLabel: string;
|
activeHostLabel: string;
|
||||||
activeHostStatusColor: string;
|
activeHostStatusColor: string;
|
||||||
|
sidebarViewMode: SidebarSessionViewMode;
|
||||||
|
setSidebarViewMode: (mode: SidebarSessionViewMode) => void;
|
||||||
|
sidebarSessionFilter: SidebarSessionFilter;
|
||||||
|
setSidebarSessionFilter: (filter: SidebarSessionFilter) => void;
|
||||||
|
groupByProject: boolean;
|
||||||
|
previewExpandedProjects: ReadonlySet<string>;
|
||||||
|
setGroupByProject: (next: boolean) => void;
|
||||||
|
toggleProjectPreviewExpanded: (projectKey: string) => void;
|
||||||
hostOptions: ComboboxOption[];
|
hostOptions: ComboboxOption[];
|
||||||
hostTriggerRef: RefObject<View | null>;
|
hostTriggerRef: RefObject<View | null>;
|
||||||
isHostPickerOpen: boolean;
|
isHostPickerOpen: boolean;
|
||||||
@@ -182,6 +196,16 @@ export const LeftSidebar = memo(function LeftSidebar({
|
|||||||
);
|
);
|
||||||
const hostTriggerRef = useRef<View | null>(null);
|
const hostTriggerRef = useRef<View | null>(null);
|
||||||
const [isHostPickerOpen, setIsHostPickerOpen] = useState(false);
|
const [isHostPickerOpen, setIsHostPickerOpen] = useState(false);
|
||||||
|
const {
|
||||||
|
sidebarViewMode,
|
||||||
|
setSidebarViewMode,
|
||||||
|
sidebarSessionFilter,
|
||||||
|
setSidebarSessionFilter,
|
||||||
|
groupByProject,
|
||||||
|
previewExpandedProjects,
|
||||||
|
setGroupByProject,
|
||||||
|
toggleProjectPreviewExpanded,
|
||||||
|
} = useSidebarSessionsController({ serverId: activeServerId });
|
||||||
|
|
||||||
const { projects, isInitialLoad, isRevalidating, refreshAll } = useSidebarWorkspacesList({
|
const { projects, isInitialLoad, isRevalidating, refreshAll } = useSidebarWorkspacesList({
|
||||||
serverId: activeServerId,
|
serverId: activeServerId,
|
||||||
@@ -247,6 +271,14 @@ export const LeftSidebar = memo(function LeftSidebar({
|
|||||||
activeServerId,
|
activeServerId,
|
||||||
activeHostLabel,
|
activeHostLabel,
|
||||||
activeHostStatusColor,
|
activeHostStatusColor,
|
||||||
|
sidebarViewMode,
|
||||||
|
setSidebarViewMode,
|
||||||
|
sidebarSessionFilter,
|
||||||
|
setSidebarSessionFilter,
|
||||||
|
groupByProject,
|
||||||
|
previewExpandedProjects,
|
||||||
|
setGroupByProject,
|
||||||
|
toggleProjectPreviewExpanded,
|
||||||
hostOptions,
|
hostOptions,
|
||||||
hostTriggerRef,
|
hostTriggerRef,
|
||||||
isHostPickerOpen,
|
isHostPickerOpen,
|
||||||
@@ -485,6 +517,14 @@ function MobileSidebar({
|
|||||||
activeServerId,
|
activeServerId,
|
||||||
activeHostLabel,
|
activeHostLabel,
|
||||||
activeHostStatusColor,
|
activeHostStatusColor,
|
||||||
|
sidebarViewMode,
|
||||||
|
setSidebarViewMode,
|
||||||
|
sidebarSessionFilter,
|
||||||
|
setSidebarSessionFilter,
|
||||||
|
groupByProject,
|
||||||
|
previewExpandedProjects,
|
||||||
|
setGroupByProject,
|
||||||
|
toggleProjectPreviewExpanded,
|
||||||
hostOptions,
|
hostOptions,
|
||||||
hostTriggerRef,
|
hostTriggerRef,
|
||||||
isHostPickerOpen,
|
isHostPickerOpen,
|
||||||
@@ -507,8 +547,6 @@ function MobileSidebar({
|
|||||||
closeToAgent,
|
closeToAgent,
|
||||||
handleViewMoreNavigate,
|
handleViewMoreNavigate,
|
||||||
}: MobileSidebarProps) {
|
}: MobileSidebarProps) {
|
||||||
const pathname = usePathname();
|
|
||||||
const isSessionsActive = pathname.includes("/sessions");
|
|
||||||
const {
|
const {
|
||||||
translateX,
|
translateX,
|
||||||
backdropOpacity,
|
backdropOpacity,
|
||||||
@@ -664,6 +702,30 @@ function MobileSidebar({
|
|||||||
],
|
],
|
||||||
[mobileSidebarInsetStyle, sidebarAnimatedStyle, theme.colors.surfaceSidebar],
|
[mobileSidebarInsetStyle, sidebarAnimatedStyle, theme.colors.surfaceSidebar],
|
||||||
);
|
);
|
||||||
|
const toggleSlot = useMemo(
|
||||||
|
() => (
|
||||||
|
<SidebarSessionsToggle
|
||||||
|
serverId={activeServerId}
|
||||||
|
mode={sidebarViewMode}
|
||||||
|
filter={sidebarSessionFilter}
|
||||||
|
projects={projects}
|
||||||
|
groupByProject={groupByProject}
|
||||||
|
onModeChange={setSidebarViewMode}
|
||||||
|
onFilterChange={setSidebarSessionFilter}
|
||||||
|
onGroupByProjectChange={setGroupByProject}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[
|
||||||
|
activeServerId,
|
||||||
|
groupByProject,
|
||||||
|
projects,
|
||||||
|
sidebarSessionFilter,
|
||||||
|
sidebarViewMode,
|
||||||
|
setGroupByProject,
|
||||||
|
setSidebarSessionFilter,
|
||||||
|
setSidebarViewMode,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
|
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
|
||||||
@@ -672,29 +734,35 @@ function MobileSidebar({
|
|||||||
<GestureDetector gesture={closeGesture} touchAction="pan-y">
|
<GestureDetector gesture={closeGesture} touchAction="pan-y">
|
||||||
<Animated.View style={mobileSidebarStyle} pointerEvents="auto">
|
<Animated.View style={mobileSidebarStyle} pointerEvents="auto">
|
||||||
<View style={styles.sidebarContent} pointerEvents="auto">
|
<View style={styles.sidebarContent} pointerEvents="auto">
|
||||||
<SidebarHeaderRow
|
<SidebarShellHeader toggleSlot={toggleSlot}>
|
||||||
icon={MessagesSquare}
|
<SidebarHistoryNavigationRow onPress={handleViewMore} />
|
||||||
label="Sessions"
|
</SidebarShellHeader>
|
||||||
onPress={handleViewMore}
|
|
||||||
isActive={isSessionsActive}
|
|
||||||
testID="sidebar-sessions"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{isInitialLoad ? (
|
{sidebarViewMode === "workspaces" ? (
|
||||||
<SidebarAgentListSkeleton />
|
<SidebarWorkspacesView
|
||||||
) : (
|
|
||||||
<SidebarWorkspaceList
|
|
||||||
serverId={activeServerId}
|
serverId={activeServerId}
|
||||||
collapsedProjectKeys={collapsedProjectKeys}
|
collapsedProjectKeys={collapsedProjectKeys}
|
||||||
onToggleProjectCollapsed={toggleProjectCollapsed}
|
onToggleProjectCollapsed={toggleProjectCollapsed}
|
||||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||||
projects={projects}
|
projects={projects}
|
||||||
|
isInitialLoad={isInitialLoad}
|
||||||
isRefreshing={isManualRefresh && isRevalidating}
|
isRefreshing={isManualRefresh && isRevalidating}
|
||||||
onRefresh={handleRefresh}
|
onRefresh={handleRefresh}
|
||||||
onWorkspacePress={handleWorkspacePress}
|
onWorkspacePress={handleWorkspacePress}
|
||||||
onAddProject={handleOpenProject}
|
onAddProject={handleOpenProject}
|
||||||
parentGestureRef={closeGestureRef}
|
parentGestureRef={closeGestureRef}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<SidebarSessionsView
|
||||||
|
serverId={activeServerId}
|
||||||
|
projects={projects}
|
||||||
|
filter={sidebarSessionFilter}
|
||||||
|
groupByProject={groupByProject}
|
||||||
|
previewExpandedProjects={previewExpandedProjects}
|
||||||
|
collapsedProjectKeys={collapsedProjectKeys}
|
||||||
|
onProjectPreviewExpandedToggle={toggleProjectPreviewExpanded}
|
||||||
|
onProjectCollapsedToggle={toggleProjectCollapsed}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<SidebarFooter
|
<SidebarFooter
|
||||||
@@ -723,6 +791,14 @@ function DesktopSidebar({
|
|||||||
activeServerId,
|
activeServerId,
|
||||||
activeHostLabel,
|
activeHostLabel,
|
||||||
activeHostStatusColor,
|
activeHostStatusColor,
|
||||||
|
sidebarViewMode,
|
||||||
|
setSidebarViewMode,
|
||||||
|
sidebarSessionFilter,
|
||||||
|
setSidebarSessionFilter,
|
||||||
|
groupByProject,
|
||||||
|
previewExpandedProjects,
|
||||||
|
setGroupByProject,
|
||||||
|
toggleProjectPreviewExpanded,
|
||||||
hostOptions,
|
hostOptions,
|
||||||
hostTriggerRef,
|
hostTriggerRef,
|
||||||
isHostPickerOpen,
|
isHostPickerOpen,
|
||||||
@@ -743,8 +819,6 @@ function DesktopSidebar({
|
|||||||
isOpen,
|
isOpen,
|
||||||
handleViewMore,
|
handleViewMore,
|
||||||
}: DesktopSidebarProps) {
|
}: DesktopSidebarProps) {
|
||||||
const pathname = usePathname();
|
|
||||||
const isSessionsActive = pathname.includes("/sessions");
|
|
||||||
const padding = useWindowControlsPadding("sidebar");
|
const padding = useWindowControlsPadding("sidebar");
|
||||||
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
|
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
|
||||||
const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth);
|
const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth);
|
||||||
@@ -799,6 +873,30 @@ function DesktopSidebar({
|
|||||||
() => [styles.resizeHandle, isWeb && ({ cursor: "col-resize" } as object)],
|
() => [styles.resizeHandle, isWeb && ({ cursor: "col-resize" } as object)],
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
const toggleSlot = useMemo(
|
||||||
|
() => (
|
||||||
|
<SidebarSessionsToggle
|
||||||
|
serverId={activeServerId}
|
||||||
|
mode={sidebarViewMode}
|
||||||
|
filter={sidebarSessionFilter}
|
||||||
|
projects={projects}
|
||||||
|
groupByProject={groupByProject}
|
||||||
|
onModeChange={setSidebarViewMode}
|
||||||
|
onFilterChange={setSidebarSessionFilter}
|
||||||
|
onGroupByProjectChange={setGroupByProject}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[
|
||||||
|
activeServerId,
|
||||||
|
groupByProject,
|
||||||
|
projects,
|
||||||
|
sidebarSessionFilter,
|
||||||
|
sidebarViewMode,
|
||||||
|
setGroupByProject,
|
||||||
|
setSidebarSessionFilter,
|
||||||
|
setSidebarViewMode,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
return null;
|
return null;
|
||||||
@@ -807,31 +905,37 @@ function DesktopSidebar({
|
|||||||
return (
|
return (
|
||||||
<Animated.View style={desktopSidebarStyle}>
|
<Animated.View style={desktopSidebarStyle}>
|
||||||
<View style={desktopSidebarBorderStyle}>
|
<View style={desktopSidebarBorderStyle}>
|
||||||
<View style={styles.sidebarDragArea}>
|
<SidebarShellHeader
|
||||||
<TitlebarDragRegion />
|
topSpacerStyle={padding.top > 0 ? paddingTopSpacerStyle : undefined}
|
||||||
{padding.top > 0 ? <View style={paddingTopSpacerStyle} /> : null}
|
toggleSlot={toggleSlot}
|
||||||
<SidebarHeaderRow
|
withTitlebarDragRegion
|
||||||
icon={MessagesSquare}
|
>
|
||||||
label="Sessions"
|
<SidebarHistoryNavigationRow onPress={handleViewMore} />
|
||||||
onPress={handleViewMore}
|
</SidebarShellHeader>
|
||||||
isActive={isSessionsActive}
|
|
||||||
testID="sidebar-sessions"
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{isInitialLoad ? (
|
{sidebarViewMode === "workspaces" ? (
|
||||||
<SidebarAgentListSkeleton />
|
<SidebarWorkspacesView
|
||||||
) : (
|
|
||||||
<SidebarWorkspaceList
|
|
||||||
serverId={activeServerId}
|
serverId={activeServerId}
|
||||||
collapsedProjectKeys={collapsedProjectKeys}
|
collapsedProjectKeys={collapsedProjectKeys}
|
||||||
onToggleProjectCollapsed={toggleProjectCollapsed}
|
onToggleProjectCollapsed={toggleProjectCollapsed}
|
||||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||||
projects={projects}
|
projects={projects}
|
||||||
|
isInitialLoad={isInitialLoad}
|
||||||
isRefreshing={isManualRefresh && isRevalidating}
|
isRefreshing={isManualRefresh && isRevalidating}
|
||||||
onRefresh={handleRefresh}
|
onRefresh={handleRefresh}
|
||||||
onAddProject={handleOpenProject}
|
onAddProject={handleOpenProject}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<SidebarSessionsView
|
||||||
|
serverId={activeServerId}
|
||||||
|
projects={projects}
|
||||||
|
filter={sidebarSessionFilter}
|
||||||
|
groupByProject={groupByProject}
|
||||||
|
previewExpandedProjects={previewExpandedProjects}
|
||||||
|
collapsedProjectKeys={collapsedProjectKeys}
|
||||||
|
onProjectPreviewExpandedToggle={toggleProjectPreviewExpanded}
|
||||||
|
onProjectCollapsedToggle={toggleProjectCollapsed}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<SidebarCalloutSlot />
|
<SidebarCalloutSlot />
|
||||||
@@ -898,9 +1002,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
width: 10,
|
width: 10,
|
||||||
zIndex: 10,
|
zIndex: 10,
|
||||||
},
|
},
|
||||||
sidebarDragArea: {
|
|
||||||
position: "relative",
|
|
||||||
},
|
|
||||||
hostTrigger: {
|
hostTrigger: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
type PressableStateCallbackType,
|
type PressableStateCallbackType,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { Folder } from "lucide-react-native";
|
import { Folder } from "lucide-react-native";
|
||||||
|
import { router } from "expo-router";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||||
@@ -19,6 +20,7 @@ import { useOpenProject } from "@/hooks/use-open-project";
|
|||||||
import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions";
|
import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions";
|
||||||
import { isNative } from "@/constants/platform";
|
import { isNative } from "@/constants/platform";
|
||||||
import { useActiveServerId } from "@/hooks/use-active-server-id";
|
import { useActiveServerId } from "@/hooks/use-active-server-id";
|
||||||
|
import { buildHostNewWorkspaceRoute } from "@/utils/host-routes";
|
||||||
|
|
||||||
interface PathRowProps {
|
interface PathRowProps {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -63,6 +65,7 @@ export function ProjectPickerModal() {
|
|||||||
const serverId = useActiveServerId();
|
const serverId = useActiveServerId();
|
||||||
|
|
||||||
const open = useKeyboardShortcutsStore((s) => s.projectPickerOpen);
|
const open = useKeyboardShortcutsStore((s) => s.projectPickerOpen);
|
||||||
|
const intent = useKeyboardShortcutsStore((s) => s.projectPickerIntent);
|
||||||
const setOpen = useKeyboardShortcutsStore((s) => s.setProjectPickerOpen);
|
const setOpen = useKeyboardShortcutsStore((s) => s.setProjectPickerOpen);
|
||||||
|
|
||||||
const client = useHostRuntimeClient(serverId ?? "");
|
const client = useHostRuntimeClient(serverId ?? "");
|
||||||
@@ -115,6 +118,16 @@ export function ProjectPickerModal() {
|
|||||||
|
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
|
if (intent.kind === "new-workspace") {
|
||||||
|
router.navigate(
|
||||||
|
buildHostNewWorkspaceRoute(serverId, trimmed, {
|
||||||
|
headerTitle: intent.headerTitle,
|
||||||
|
}) as never,
|
||||||
|
);
|
||||||
|
setOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const didOpenProject = await openProject(trimmed);
|
const didOpenProject = await openProject(trimmed);
|
||||||
if (didOpenProject) {
|
if (didOpenProject) {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
@@ -123,7 +136,7 @@ export function ProjectPickerModal() {
|
|||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[client, openProject, serverId, setOpen],
|
[client, intent, openProject, serverId, setOpen],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSubmitCustom = useCallback(() => {
|
const handleSubmitCustom = useCallback(() => {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
*/
|
||||||
|
import { cleanup, render } from "@testing-library/react";
|
||||||
|
import React from "react";
|
||||||
|
import { Text } from "react-native";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { SidebarProjectSection } from "./sidebar-collapsible-project-section";
|
||||||
|
|
||||||
|
vi.mock("lucide-react-native", () => {
|
||||||
|
const createIcon = (name: string) => (props: Record<string, unknown>) =>
|
||||||
|
React.createElement("span", { ...props, "data-icon": name });
|
||||||
|
return {
|
||||||
|
ChevronDown: createIcon("ChevronDown"),
|
||||||
|
ChevronRight: createIcon("ChevronRight"),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("react-native-unistyles", () => ({
|
||||||
|
StyleSheet: {
|
||||||
|
create: (styles: unknown) =>
|
||||||
|
typeof styles === "function"
|
||||||
|
? styles({
|
||||||
|
borderRadius: { lg: 8 },
|
||||||
|
colors: {
|
||||||
|
border: "#dddddd",
|
||||||
|
surface2: "#eeeeee",
|
||||||
|
surfaceSidebarHover: "#f5f5f5",
|
||||||
|
},
|
||||||
|
iconSize: { md: 16 },
|
||||||
|
shadow: { md: {} },
|
||||||
|
spacing: { 1: 4, 2: 8, 3: 12 },
|
||||||
|
})
|
||||||
|
: styles,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/sidebar/sidebar-project-row-visual", () => ({
|
||||||
|
SidebarProjectIcon: ({ projectName }: { projectName: string }) => <Text>{projectName}</Text>,
|
||||||
|
SidebarProjectRowVisual: ({ projectName }: { projectName: string }) => <Text>{projectName}</Text>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
const collapsedFooter = <Text>Footer row</Text>;
|
||||||
|
|
||||||
|
describe("SidebarProjectSection", () => {
|
||||||
|
it("renders the header and expanded children", () => {
|
||||||
|
const { getByTestId, getByText } = render(
|
||||||
|
<SidebarProjectSection
|
||||||
|
projectKey="project-a"
|
||||||
|
projectName="Project A"
|
||||||
|
iconDataUri={null}
|
||||||
|
onPress={vi.fn()}
|
||||||
|
isHovered={false}
|
||||||
|
chevron="collapse"
|
||||||
|
isCollapsed={false}
|
||||||
|
testID="project-a-header"
|
||||||
|
>
|
||||||
|
<Text>Child row</Text>
|
||||||
|
</SidebarProjectSection>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByTestId("project-a-header")).toBeTruthy();
|
||||||
|
expect(getByText("Project A")).toBeTruthy();
|
||||||
|
expect(getByText("Child row")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the header visible and hides children plus footer when collapsed", () => {
|
||||||
|
const { getByTestId, queryByText } = render(
|
||||||
|
<SidebarProjectSection
|
||||||
|
projectKey="project-a"
|
||||||
|
projectName="Project A"
|
||||||
|
iconDataUri={null}
|
||||||
|
onPress={vi.fn()}
|
||||||
|
isHovered={false}
|
||||||
|
chevron="expand"
|
||||||
|
isCollapsed
|
||||||
|
footer={collapsedFooter}
|
||||||
|
testID="project-a-header"
|
||||||
|
>
|
||||||
|
<Text>Child row</Text>
|
||||||
|
</SidebarProjectSection>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByTestId("project-a-header")).toBeTruthy();
|
||||||
|
expect(queryByText("Child row")).toBeNull();
|
||||||
|
expect(queryByText("Footer row")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
import React, {
|
||||||
|
forwardRef,
|
||||||
|
memo,
|
||||||
|
useCallback,
|
||||||
|
useMemo,
|
||||||
|
type ComponentType,
|
||||||
|
type ReactElement,
|
||||||
|
type ReactNode,
|
||||||
|
type Ref,
|
||||||
|
} from "react";
|
||||||
|
import {
|
||||||
|
Pressable,
|
||||||
|
View,
|
||||||
|
type GestureResponderEvent,
|
||||||
|
type PressableProps,
|
||||||
|
type PressableStateCallbackType,
|
||||||
|
type ViewProps,
|
||||||
|
} from "react-native";
|
||||||
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
|
import { ChevronDown, ChevronRight } from "lucide-react-native";
|
||||||
|
import {
|
||||||
|
SidebarProjectIcon,
|
||||||
|
SidebarProjectRowVisual,
|
||||||
|
} from "@/components/sidebar/sidebar-project-row-visual";
|
||||||
|
|
||||||
|
export type SidebarProjectChevronDirection = "expand" | "collapse";
|
||||||
|
|
||||||
|
export interface SidebarProjectHeaderRowProps {
|
||||||
|
projectName: string;
|
||||||
|
iconDataUri: string | null;
|
||||||
|
onPress: () => void;
|
||||||
|
isHovered: boolean;
|
||||||
|
|
||||||
|
/** Visual collapsed state. When set, drives the chevron direction (down=collapse / right=expand). */
|
||||||
|
chevron?: SidebarProjectChevronDirection | null;
|
||||||
|
|
||||||
|
selected?: boolean;
|
||||||
|
isDragging?: boolean;
|
||||||
|
|
||||||
|
/** Replaces the chevron+icon leading visual entirely (e.g. archiving spinner, workspace status icon). */
|
||||||
|
leadingVisualOverride?: ReactElement | null;
|
||||||
|
|
||||||
|
/** Appended after the project name. Kebab, new-worktree button, shortcut badge. */
|
||||||
|
trailingSlot?: ReactNode;
|
||||||
|
|
||||||
|
/** Long-press / drag handlers. */
|
||||||
|
onPressIn?: (event: GestureResponderEvent) => void;
|
||||||
|
onTouchMove?: (event: GestureResponderEvent) => void;
|
||||||
|
onPressOut?: (event: GestureResponderEvent) => void;
|
||||||
|
|
||||||
|
testID?: string;
|
||||||
|
accessibilityLabel?: string;
|
||||||
|
|
||||||
|
/** Substitute for the inner Pressable (e.g. ContextMenuTrigger). Receives the same props. */
|
||||||
|
PressableComponent?: ComponentType<PressableProps & { ref?: Ref<View> }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SidebarProjectSectionProps {
|
||||||
|
projectKey: string;
|
||||||
|
projectName: string;
|
||||||
|
iconDataUri: string | null;
|
||||||
|
onPress: () => void;
|
||||||
|
isHovered: boolean;
|
||||||
|
chevron: "expand" | "collapse" | null;
|
||||||
|
selected?: boolean;
|
||||||
|
isDragging?: boolean;
|
||||||
|
trailingSlot?: ReactNode;
|
||||||
|
testID?: string;
|
||||||
|
children?: ReactNode;
|
||||||
|
footer?: ReactNode;
|
||||||
|
isCollapsed: boolean;
|
||||||
|
headerProps?: Pick<
|
||||||
|
SidebarProjectHeaderRowProps,
|
||||||
|
| "accessibilityLabel"
|
||||||
|
| "leadingVisualOverride"
|
||||||
|
| "onPressIn"
|
||||||
|
| "onTouchMove"
|
||||||
|
| "onPressOut"
|
||||||
|
| "PressableComponent"
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SidebarProjectSection = memo(function SidebarProjectSection({
|
||||||
|
projectName,
|
||||||
|
iconDataUri,
|
||||||
|
onPress,
|
||||||
|
isHovered,
|
||||||
|
chevron,
|
||||||
|
selected = false,
|
||||||
|
isDragging = false,
|
||||||
|
trailingSlot = null,
|
||||||
|
testID,
|
||||||
|
children = null,
|
||||||
|
footer = null,
|
||||||
|
isCollapsed,
|
||||||
|
headerProps,
|
||||||
|
}: SidebarProjectSectionProps): ReactElement {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SidebarProjectHeaderRow
|
||||||
|
projectName={projectName}
|
||||||
|
iconDataUri={iconDataUri}
|
||||||
|
chevron={chevron}
|
||||||
|
isHovered={isHovered}
|
||||||
|
isDragging={isDragging}
|
||||||
|
selected={selected}
|
||||||
|
trailingSlot={trailingSlot}
|
||||||
|
onPress={onPress}
|
||||||
|
testID={testID}
|
||||||
|
{...headerProps}
|
||||||
|
/>
|
||||||
|
{!isCollapsed ? (
|
||||||
|
<>
|
||||||
|
{children}
|
||||||
|
{footer}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared chrome for a project row that names a section: pressable, hover/press/selected
|
||||||
|
* styles, on-hover chevron leading visual, and a trailing actions slot.
|
||||||
|
*
|
||||||
|
* Hover is controlled — callers wrap with their own outer View carrying onPointerEnter/Leave
|
||||||
|
* (workspace tree adds drag-handle attrs to that wrapper; sessions wraps a plain View).
|
||||||
|
*/
|
||||||
|
export const SidebarProjectHeaderRow = memo(
|
||||||
|
forwardRef<View, SidebarProjectHeaderRowProps>(function SidebarProjectHeaderRow(
|
||||||
|
{
|
||||||
|
projectName,
|
||||||
|
iconDataUri,
|
||||||
|
onPress,
|
||||||
|
isHovered,
|
||||||
|
chevron = null,
|
||||||
|
selected = false,
|
||||||
|
isDragging = false,
|
||||||
|
leadingVisualOverride = null,
|
||||||
|
trailingSlot = null,
|
||||||
|
onPressIn,
|
||||||
|
onTouchMove,
|
||||||
|
onPressOut,
|
||||||
|
testID,
|
||||||
|
accessibilityLabel,
|
||||||
|
PressableComponent = Pressable,
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
): ReactElement {
|
||||||
|
const showChevron = isHovered && chevron !== null;
|
||||||
|
const leadingVisual = useMemo<ReactElement | null>(() => {
|
||||||
|
if (showChevron && chevron) {
|
||||||
|
return (
|
||||||
|
<View style={sidebarProjectHeaderRowStyles.leadingSlot}>
|
||||||
|
<SidebarProjectChevron chevron={chevron} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (leadingVisualOverride) {
|
||||||
|
return leadingVisualOverride;
|
||||||
|
}
|
||||||
|
return <SidebarProjectIcon projectName={projectName} iconDataUri={iconDataUri} />;
|
||||||
|
}, [chevron, iconDataUri, leadingVisualOverride, projectName, showChevron]);
|
||||||
|
|
||||||
|
const pressableStyle = useCallback(
|
||||||
|
({ pressed }: PressableStateCallbackType) => [
|
||||||
|
sidebarProjectHeaderRowStyles.row,
|
||||||
|
isDragging && sidebarProjectHeaderRowStyles.dragging,
|
||||||
|
selected && sidebarProjectHeaderRowStyles.selected,
|
||||||
|
isHovered && sidebarProjectHeaderRowStyles.hovered,
|
||||||
|
pressed && sidebarProjectHeaderRowStyles.pressed,
|
||||||
|
],
|
||||||
|
[isDragging, isHovered, selected],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PressableComponent
|
||||||
|
ref={ref}
|
||||||
|
style={pressableStyle}
|
||||||
|
onPress={onPress}
|
||||||
|
onPressIn={onPressIn}
|
||||||
|
onTouchMove={onTouchMove}
|
||||||
|
onPressOut={onPressOut}
|
||||||
|
accessibilityLabel={accessibilityLabel}
|
||||||
|
testID={testID}
|
||||||
|
>
|
||||||
|
<SidebarProjectRowVisual
|
||||||
|
projectName={projectName}
|
||||||
|
iconDataUri={iconDataUri}
|
||||||
|
leadingVisual={leadingVisual}
|
||||||
|
/>
|
||||||
|
{trailingSlot}
|
||||||
|
</PressableComponent>
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
function SidebarProjectChevron({
|
||||||
|
chevron,
|
||||||
|
}: {
|
||||||
|
chevron: SidebarProjectChevronDirection;
|
||||||
|
}): ReactElement {
|
||||||
|
if (chevron === "collapse") {
|
||||||
|
return <ChevronDown size={14} color="#9ca3af" />;
|
||||||
|
}
|
||||||
|
return <ChevronRight size={14} color="#9ca3af" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outer wrapper that carries hover state for a `<SidebarProjectHeaderRow>` plus extra props
|
||||||
|
* (drag handle attrs, ref). Sessions uses the lighter `<SidebarProjectHeaderHoverWrap>`.
|
||||||
|
*/
|
||||||
|
export const SidebarProjectHeaderHoverWrap = forwardRef<
|
||||||
|
View,
|
||||||
|
ViewProps & {
|
||||||
|
onHoverChange: (hovered: boolean) => void;
|
||||||
|
}
|
||||||
|
>(function SidebarProjectHeaderHoverWrap({ onHoverChange, children, ...rest }, ref): ReactElement {
|
||||||
|
const handleEnter = useCallback(() => onHoverChange(true), [onHoverChange]);
|
||||||
|
const handleLeave = useCallback(() => onHoverChange(false), [onHoverChange]);
|
||||||
|
return (
|
||||||
|
<View ref={ref} {...rest} onPointerEnter={handleEnter} onPointerLeave={handleLeave}>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const sidebarProjectHeaderRowStyles = StyleSheet.create((theme) => ({
|
||||||
|
row: {
|
||||||
|
minHeight: 36,
|
||||||
|
paddingVertical: theme.spacing[2],
|
||||||
|
paddingHorizontal: theme.spacing[2],
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
marginBottom: theme.spacing[1],
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
userSelect: "none",
|
||||||
|
},
|
||||||
|
hovered: {
|
||||||
|
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||||
|
},
|
||||||
|
pressed: {
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
},
|
||||||
|
selected: {
|
||||||
|
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||||
|
},
|
||||||
|
dragging: {
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
transform: [{ scale: 1.02 }],
|
||||||
|
zIndex: 3,
|
||||||
|
...theme.shadow.md,
|
||||||
|
},
|
||||||
|
leadingSlot: {
|
||||||
|
position: "relative",
|
||||||
|
width: theme.iconSize.md,
|
||||||
|
height: theme.iconSize.md,
|
||||||
|
flexShrink: 0,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indent style for rows nested under a `<SidebarProjectHeaderRow>`. Workspace rows and
|
||||||
|
* grouped-session rows both apply this so children visually align across surfaces.
|
||||||
|
*/
|
||||||
|
export const sidebarProjectChildIndentStyles = StyleSheet.create((theme) => ({
|
||||||
|
childRow: {
|
||||||
|
paddingLeft: theme.spacing[3] + theme.spacing[3],
|
||||||
|
paddingRight: theme.spacing[3],
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -108,7 +108,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
paddingVertical: theme.spacing[2],
|
paddingVertical: theme.spacing[2],
|
||||||
paddingHorizontal: theme.spacing[3],
|
paddingHorizontal: theme.spacing[2],
|
||||||
borderRadius: theme.borderRadius.lg,
|
borderRadius: theme.borderRadius.lg,
|
||||||
},
|
},
|
||||||
buttonHovered: {
|
buttonHovered: {
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { usePathname } from "expo-router";
|
||||||
|
import { History } 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={History}
|
||||||
|
label="History"
|
||||||
|
onPress={onPress}
|
||||||
|
isActive={isSessionsActive}
|
||||||
|
testID="sidebar-history"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import React, { memo, useMemo, type ReactElement } from "react";
|
||||||
|
import { Image, Text, View } from "react-native";
|
||||||
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
|
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
|
||||||
|
|
||||||
|
export interface SidebarProjectRowVisualProps {
|
||||||
|
projectName: string;
|
||||||
|
iconDataUri: string | null;
|
||||||
|
leadingVisual?: ReactElement | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SidebarProjectRowVisual = memo(function SidebarProjectRowVisual({
|
||||||
|
projectName,
|
||||||
|
iconDataUri,
|
||||||
|
leadingVisual = null,
|
||||||
|
}: SidebarProjectRowVisualProps): ReactElement {
|
||||||
|
return (
|
||||||
|
<View style={styles.projectRowLeft}>
|
||||||
|
{leadingVisual ?? <SidebarProjectIcon projectName={projectName} iconDataUri={iconDataUri} />}
|
||||||
|
<SidebarProjectName projectName={projectName} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SidebarProjectIcon = memo(function SidebarProjectIcon({
|
||||||
|
projectName,
|
||||||
|
iconDataUri,
|
||||||
|
}: {
|
||||||
|
projectName: string;
|
||||||
|
iconDataUri: string | null;
|
||||||
|
}): ReactElement {
|
||||||
|
const placeholderLabel = projectIconPlaceholderLabelFromDisplayName(projectName);
|
||||||
|
const placeholderInitial = placeholderLabel.charAt(0).toUpperCase();
|
||||||
|
const imageSource = useMemo(() => ({ uri: iconDataUri ?? "" }), [iconDataUri]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.projectLeadingVisualSlot}>
|
||||||
|
{iconDataUri ? (
|
||||||
|
<Image source={imageSource} style={styles.projectIcon} />
|
||||||
|
) : (
|
||||||
|
<View style={styles.projectIconFallback}>
|
||||||
|
<Text style={styles.projectIconFallbackText}>{placeholderInitial}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const SidebarProjectName = memo(function SidebarProjectName({
|
||||||
|
projectName,
|
||||||
|
}: {
|
||||||
|
projectName: string;
|
||||||
|
}): ReactElement {
|
||||||
|
return (
|
||||||
|
<View style={styles.projectTitleGroup}>
|
||||||
|
<Text style={styles.projectTitle} numberOfLines={1}>
|
||||||
|
{projectName}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
projectRowLeft: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
},
|
||||||
|
projectLeadingVisualSlot: {
|
||||||
|
position: "relative",
|
||||||
|
width: theme.iconSize.md,
|
||||||
|
height: theme.iconSize.md,
|
||||||
|
flexShrink: 0,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
projectIcon: {
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
borderRadius: theme.borderRadius.sm,
|
||||||
|
},
|
||||||
|
projectIconFallback: {
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
borderRadius: theme.borderRadius.sm,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
projectIconFallbackText: {
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontSize: 9,
|
||||||
|
},
|
||||||
|
projectTitleGroup: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[1],
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
},
|
||||||
|
projectTitle: {
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
fontWeight: "400",
|
||||||
|
minWidth: 0,
|
||||||
|
flexShrink: 1,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
/**
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
*/
|
||||||
|
import { cleanup, fireEvent, render } from "@testing-library/react";
|
||||||
|
import React from "react";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { Pressable, View } from "react-native";
|
||||||
|
import {
|
||||||
|
SidebarProjectTrailingActions,
|
||||||
|
type SidebarProjectCreateButtonConfig,
|
||||||
|
} from "./sidebar-project-trailing-actions";
|
||||||
|
|
||||||
|
vi.hoisted(() => {
|
||||||
|
Object.assign(globalThis, { __DEV__: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
const { navigateMock } = vi.hoisted(() => ({
|
||||||
|
navigateMock: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("expo-router", () => ({
|
||||||
|
router: {
|
||||||
|
navigate: navigateMock,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("lucide-react-native", () => {
|
||||||
|
const createIcon = (name: string) => (props: Record<string, unknown>) =>
|
||||||
|
React.createElement("span", { ...props, "data-icon": name });
|
||||||
|
return {
|
||||||
|
FolderPlus: createIcon("FolderPlus"),
|
||||||
|
MoreVertical: createIcon("MoreVertical"),
|
||||||
|
Plus: createIcon("Plus"),
|
||||||
|
Settings: createIcon("Settings"),
|
||||||
|
Trash2: createIcon("Trash2"),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("react-native-unistyles", () => ({
|
||||||
|
StyleSheet: {
|
||||||
|
create: (styles: unknown) =>
|
||||||
|
typeof styles === "function"
|
||||||
|
? styles({
|
||||||
|
borderRadius: { md: 6 },
|
||||||
|
colors: {
|
||||||
|
foreground: "#111111",
|
||||||
|
foregroundMuted: "#666666",
|
||||||
|
surface2: "#eeeeee",
|
||||||
|
surfaceSidebarHover: "#f5f5f5",
|
||||||
|
},
|
||||||
|
fontSize: { sm: 14 },
|
||||||
|
spacing: { 2: 8 },
|
||||||
|
})
|
||||||
|
: styles,
|
||||||
|
},
|
||||||
|
withUnistyles: (Component: React.ComponentType<Record<string, unknown>>) =>
|
||||||
|
function ThemedComponent(props: Record<string, unknown>) {
|
||||||
|
return <Component {...props} />;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/ui/dropdown-menu", () => ({
|
||||||
|
DropdownMenu: ({ children }: { children: React.ReactNode }) => <View>{children}</View>,
|
||||||
|
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <View>{children}</View>,
|
||||||
|
DropdownMenuItem: ({ children, testID }: { children: React.ReactNode; testID?: string }) => (
|
||||||
|
<View testID={testID}>{children}</View>
|
||||||
|
),
|
||||||
|
DropdownMenuTrigger: ({
|
||||||
|
children,
|
||||||
|
testID,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode | ((state: { hovered: boolean }) => React.ReactNode);
|
||||||
|
testID?: string;
|
||||||
|
}) => (
|
||||||
|
<Pressable testID={testID}>
|
||||||
|
{typeof children === "function" ? children({ hovered: false }) : children}
|
||||||
|
</Pressable>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/ui/tooltip", () => ({
|
||||||
|
Tooltip: ({ children }: { children: React.ReactNode }) => <View>{children}</View>,
|
||||||
|
TooltipContent: ({ children }: { children: React.ReactNode }) => <View>{children}</View>,
|
||||||
|
TooltipTrigger: ({ children }: { children: React.ReactNode }) => children,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/ui/shortcut", () => ({
|
||||||
|
Shortcut: () => <View />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/constants/platform", () => ({
|
||||||
|
isNative: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/constants/layout", () => ({
|
||||||
|
useIsCompactFormFactor: () => false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/contexts/toast-context", () => ({
|
||||||
|
useToast: () => ({ error: vi.fn() }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/hooks/use-shortcut-keys", () => ({
|
||||||
|
useShortcutKeys: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
navigateMock.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
const newWorkspaceCreateButton: SidebarProjectCreateButtonConfig = {
|
||||||
|
onPress: () => {},
|
||||||
|
accessibilityLabel: "New workspace",
|
||||||
|
testID: "sidebar-project-new-worktree-project-a",
|
||||||
|
tooltipLabel: "New workspace",
|
||||||
|
icon: "folder-plus",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("SidebarProjectTrailingActions", () => {
|
||||||
|
it("renders the kebab and create button when configured", () => {
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<SidebarProjectTrailingActions
|
||||||
|
projectKey="project-a"
|
||||||
|
projectName="Project A"
|
||||||
|
serverId="server-1"
|
||||||
|
isHovered
|
||||||
|
createButton={newWorkspaceCreateButton}
|
||||||
|
onRemoveProject={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByTestId("sidebar-project-kebab-project-a")).toBeTruthy();
|
||||||
|
expect(getByTestId("sidebar-project-new-worktree-project-a")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders only the kebab when createButton is null", () => {
|
||||||
|
const { getByTestId, queryByTestId } = render(
|
||||||
|
<SidebarProjectTrailingActions
|
||||||
|
projectKey="project-a"
|
||||||
|
projectName="Project A"
|
||||||
|
serverId="server-1"
|
||||||
|
isHovered
|
||||||
|
createButton={null}
|
||||||
|
onRemoveProject={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByTestId("sidebar-project-kebab-project-a")).toBeTruthy();
|
||||||
|
expect(queryByTestId("sidebar-project-new-worktree-project-a")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the create button override instead of the default route", () => {
|
||||||
|
const createOnPress = vi.fn();
|
||||||
|
|
||||||
|
function Harness() {
|
||||||
|
const createButton = React.useMemo(
|
||||||
|
() => ({
|
||||||
|
onPress: createOnPress,
|
||||||
|
accessibilityLabel: "New session",
|
||||||
|
testID: "sidebar-project-project-a-new-session-button",
|
||||||
|
tooltipLabel: "New session",
|
||||||
|
icon: "plus" as const,
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarProjectTrailingActions
|
||||||
|
projectKey="project-a"
|
||||||
|
projectName="Project A"
|
||||||
|
serverId="server-1"
|
||||||
|
isHovered
|
||||||
|
createButton={createButton}
|
||||||
|
onRemoveProject={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { getByTestId } = render(<Harness />);
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId("sidebar-project-project-a-new-session-button"));
|
||||||
|
|
||||||
|
expect(createOnPress).toHaveBeenCalledOnce();
|
||||||
|
expect(navigateMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,441 @@
|
|||||||
|
import React, { useCallback, useState, type ReactElement } from "react";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Pressable,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
type GestureResponderEvent,
|
||||||
|
type PressableStateCallbackType,
|
||||||
|
} from "react-native";
|
||||||
|
import { router } from "expo-router";
|
||||||
|
import { FolderPlus, MoreVertical, Plus, Settings, Trash2 } from "lucide-react-native";
|
||||||
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { Shortcut } from "@/components/ui/shortcut";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import { isNative as platformIsNative } from "@/constants/platform";
|
||||||
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
|
import { useToast } from "@/contexts/toast-context";
|
||||||
|
import {
|
||||||
|
clearWorkspaceArchivePending,
|
||||||
|
markWorkspaceArchivePending,
|
||||||
|
} from "@/contexts/session-workspace-upserts";
|
||||||
|
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||||
|
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||||
|
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||||
|
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||||
|
import type { Theme } from "@/styles/theme";
|
||||||
|
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||||
|
import { buildProjectSettingsRoute } from "@/utils/host-routes";
|
||||||
|
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-execution";
|
||||||
|
|
||||||
|
export interface SidebarProjectCreateButtonConfig {
|
||||||
|
onPress: () => void;
|
||||||
|
accessibilityLabel: string;
|
||||||
|
testID: string;
|
||||||
|
tooltipLabel: string;
|
||||||
|
icon: "folder-plus" | "plus";
|
||||||
|
showShortcutHint?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SidebarProjectTrailingActionsProps {
|
||||||
|
projectKey: string;
|
||||||
|
serverId: string | null;
|
||||||
|
isHovered: boolean;
|
||||||
|
projectName?: string;
|
||||||
|
onRemoveProject?: () => void;
|
||||||
|
removeProjectStatus?: "idle" | "pending" | "success";
|
||||||
|
workspaces?: readonly SidebarWorkspaceEntry[];
|
||||||
|
createButton: SidebarProjectCreateButtonConfig | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
|
||||||
|
const ThemedFolderPlus = withUnistyles(FolderPlus);
|
||||||
|
const ThemedMoreVertical = withUnistyles(MoreVertical);
|
||||||
|
const ThemedPlus = withUnistyles(Plus);
|
||||||
|
const ThemedSettings = withUnistyles(Settings);
|
||||||
|
const ThemedTrash2 = withUnistyles(Trash2);
|
||||||
|
|
||||||
|
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||||
|
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
});
|
||||||
|
|
||||||
|
const trash2LeadingIcon = <ThemedTrash2 size={14} uniProps={foregroundMutedColorMapping} />;
|
||||||
|
const settingsLeadingIcon = <ThemedSettings size={14} uniProps={foregroundMutedColorMapping} />;
|
||||||
|
|
||||||
|
export function SidebarProjectTrailingActions({
|
||||||
|
projectKey,
|
||||||
|
serverId,
|
||||||
|
isHovered,
|
||||||
|
projectName,
|
||||||
|
onRemoveProject,
|
||||||
|
removeProjectStatus,
|
||||||
|
workspaces = [],
|
||||||
|
createButton,
|
||||||
|
}: SidebarProjectTrailingActionsProps): ReactElement {
|
||||||
|
const isMobileBreakpoint = useIsCompactFormFactor();
|
||||||
|
const actionsVisible = isHovered || platformIsNative || isMobileBreakpoint;
|
||||||
|
const fallbackRemoval = useSidebarProjectRemoval({
|
||||||
|
projectKey,
|
||||||
|
projectName,
|
||||||
|
serverId,
|
||||||
|
workspaces,
|
||||||
|
});
|
||||||
|
const resolvedRemoveProject = onRemoveProject ?? fallbackRemoval.onRemoveProject;
|
||||||
|
const resolvedRemoveProjectStatus = removeProjectStatus ?? fallbackRemoval.removeProjectStatus;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.projectTrailingActions}>
|
||||||
|
{createButton ? (
|
||||||
|
<ProjectCreateButton actionsVisible={actionsVisible} createButton={createButton} />
|
||||||
|
) : null}
|
||||||
|
<View
|
||||||
|
style={!actionsVisible && styles.projectKebabButtonHidden}
|
||||||
|
pointerEvents={actionsVisible ? "auto" : "none"}
|
||||||
|
>
|
||||||
|
<ProjectKebabMenu
|
||||||
|
projectKey={projectKey}
|
||||||
|
onRemoveProject={resolvedRemoveProject}
|
||||||
|
removeProjectStatus={resolvedRemoveProjectStatus}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProjectCreateButton({
|
||||||
|
actionsVisible,
|
||||||
|
createButton,
|
||||||
|
}: {
|
||||||
|
actionsVisible: boolean;
|
||||||
|
createButton: SidebarProjectCreateButtonConfig;
|
||||||
|
}): ReactElement {
|
||||||
|
return (
|
||||||
|
<NewWorktreeButton
|
||||||
|
accessibilityLabel={createButton.accessibilityLabel}
|
||||||
|
icon={createButton.icon}
|
||||||
|
onPress={createButton.onPress}
|
||||||
|
visible={actionsVisible}
|
||||||
|
showShortcutHint={createButton.showShortcutHint ?? false}
|
||||||
|
testID={createButton.testID}
|
||||||
|
tooltipLabel={createButton.tooltipLabel}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function useSidebarProjectRemoval({
|
||||||
|
projectKey,
|
||||||
|
projectName,
|
||||||
|
serverId,
|
||||||
|
workspaces,
|
||||||
|
}: {
|
||||||
|
projectKey: string;
|
||||||
|
projectName?: string;
|
||||||
|
serverId: string | null;
|
||||||
|
workspaces: readonly SidebarWorkspaceEntry[];
|
||||||
|
}): {
|
||||||
|
onRemoveProject: () => void;
|
||||||
|
removeProjectStatus: "idle" | "pending";
|
||||||
|
} {
|
||||||
|
const toast = useToast();
|
||||||
|
const [isRemovingProject, setIsRemovingProject] = useState(false);
|
||||||
|
const displayName = projectName ?? projectKey;
|
||||||
|
|
||||||
|
const handleRemoveProject = useCallback(() => {
|
||||||
|
if (isRemovingProject || !serverId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
const confirmed = await confirmDialog({
|
||||||
|
title: "Remove project?",
|
||||||
|
message: `Remove "${displayName}" from the sidebar?\n\nFiles on disk will not be changed.`,
|
||||||
|
confirmLabel: "Remove",
|
||||||
|
cancelLabel: "Cancel",
|
||||||
|
destructive: true,
|
||||||
|
});
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = getHostRuntimeStore().getClient(serverId);
|
||||||
|
if (!client) {
|
||||||
|
toast.error("Host is not connected");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsRemovingProject(true);
|
||||||
|
const snapshots = new Map(
|
||||||
|
workspaces.map((workspace) => [
|
||||||
|
workspace.workspaceId,
|
||||||
|
hideWorkspaceOptimistically(workspace),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const isRejected = (r: PromiseSettledResult<unknown>) => r.status === "rejected";
|
||||||
|
void Promise.allSettled(
|
||||||
|
workspaces.map(async (ws) => {
|
||||||
|
try {
|
||||||
|
const payload = await client.archiveWorkspace(ws.workspaceId);
|
||||||
|
if (payload.error) {
|
||||||
|
throw new Error(payload.error);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
restoreOptimisticallyHiddenWorkspace({
|
||||||
|
serverId,
|
||||||
|
workspaceId: ws.workspaceId,
|
||||||
|
snapshot: snapshots.get(ws.workspaceId) ?? null,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
).then((results) => {
|
||||||
|
const failed = results.filter(isRejected);
|
||||||
|
if (failed.length > 0) {
|
||||||
|
toast.error("Failed to remove some workspaces");
|
||||||
|
}
|
||||||
|
setIsRemovingProject(false);
|
||||||
|
return;
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
}, [displayName, isRemovingProject, serverId, toast, workspaces]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
onRemoveProject: handleRemoveProject,
|
||||||
|
removeProjectStatus: isRemovingProject ? "pending" : "idle",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideWorkspaceOptimistically(workspace: SidebarWorkspaceEntry): WorkspaceDescriptor | null {
|
||||||
|
const workspaces = useSessionStore.getState().sessions[workspace.serverId]?.workspaces;
|
||||||
|
const workspaceKey = resolveWorkspaceMapKeyByIdentity({
|
||||||
|
workspaces,
|
||||||
|
workspaceId: workspace.workspaceId,
|
||||||
|
});
|
||||||
|
const snapshot = workspaceKey ? (workspaces?.get(workspaceKey) ?? null) : null;
|
||||||
|
markWorkspaceArchivePending({
|
||||||
|
serverId: workspace.serverId,
|
||||||
|
workspaceId: workspace.workspaceId,
|
||||||
|
workspaceDirectory: workspace.workspaceDirectory,
|
||||||
|
});
|
||||||
|
useSessionStore.getState().removeWorkspace(workspace.serverId, workspace.workspaceId);
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreOptimisticallyHiddenWorkspace(input: {
|
||||||
|
serverId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
snapshot: WorkspaceDescriptor | null;
|
||||||
|
}): void {
|
||||||
|
clearWorkspaceArchivePending({
|
||||||
|
serverId: input.serverId,
|
||||||
|
workspaceId: input.workspaceId,
|
||||||
|
});
|
||||||
|
if (input.snapshot) {
|
||||||
|
useSessionStore.getState().mergeWorkspaces(input.serverId, [input.snapshot]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectKebabStyle({
|
||||||
|
hovered = false,
|
||||||
|
}: PressableStateCallbackType & { hovered?: boolean }) {
|
||||||
|
return [styles.projectKebabButton, hovered && styles.projectKebabButtonHovered];
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) {
|
||||||
|
return (
|
||||||
|
<ThemedMoreVertical
|
||||||
|
size={14}
|
||||||
|
uniProps={hovered ? foregroundColorMapping : foregroundMutedColorMapping}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProjectKebabMenu({
|
||||||
|
projectKey,
|
||||||
|
onRemoveProject,
|
||||||
|
removeProjectStatus,
|
||||||
|
}: {
|
||||||
|
projectKey: string;
|
||||||
|
onRemoveProject: () => void;
|
||||||
|
removeProjectStatus: "idle" | "pending" | "success";
|
||||||
|
}) {
|
||||||
|
const handleOpenProjectSettings = useCallback(() => {
|
||||||
|
if (projectKey.trim().length === 0) return;
|
||||||
|
router.navigate(buildProjectSettingsRoute(projectKey));
|
||||||
|
}, [projectKey]);
|
||||||
|
const canOpenProjectSettings = projectKey.trim().length > 0;
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
hitSlop={8}
|
||||||
|
style={projectKebabStyle}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Project actions"
|
||||||
|
testID={`sidebar-project-kebab-${projectKey}`}
|
||||||
|
>
|
||||||
|
{renderKebabTriggerIcon}
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" width={220}>
|
||||||
|
{canOpenProjectSettings ? (
|
||||||
|
<DropdownMenuItem
|
||||||
|
testID={`sidebar-project-menu-open-settings-${projectKey}`}
|
||||||
|
leading={settingsLeadingIcon}
|
||||||
|
onSelect={handleOpenProjectSettings}
|
||||||
|
>
|
||||||
|
Open project settings
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
|
<DropdownMenuItem
|
||||||
|
testID={`sidebar-project-menu-remove-${projectKey}`}
|
||||||
|
leading={trash2LeadingIcon}
|
||||||
|
status={removeProjectStatus}
|
||||||
|
pendingLabel="Removing..."
|
||||||
|
onSelect={onRemoveProject}
|
||||||
|
>
|
||||||
|
Remove project
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NewWorktreeButton({
|
||||||
|
accessibilityLabel,
|
||||||
|
icon,
|
||||||
|
onPress,
|
||||||
|
visible,
|
||||||
|
loading = false,
|
||||||
|
testID,
|
||||||
|
showShortcutHint = false,
|
||||||
|
tooltipLabel,
|
||||||
|
}: {
|
||||||
|
accessibilityLabel: string;
|
||||||
|
icon: "folder-plus" | "plus";
|
||||||
|
onPress: () => void;
|
||||||
|
visible: boolean;
|
||||||
|
loading?: boolean;
|
||||||
|
testID: string;
|
||||||
|
showShortcutHint?: boolean;
|
||||||
|
tooltipLabel: string;
|
||||||
|
}) {
|
||||||
|
const newWorktreeKeys = useShortcutKeys("new-worktree");
|
||||||
|
|
||||||
|
const pressableStyle = useCallback(
|
||||||
|
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||||
|
styles.projectIconActionButton,
|
||||||
|
!visible && styles.projectIconActionButtonHidden,
|
||||||
|
(Boolean(hovered) || pressed) && !loading && styles.projectIconActionButtonHovered,
|
||||||
|
],
|
||||||
|
[visible, loading],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePress = useCallback(
|
||||||
|
(event: GestureResponderEvent) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onPress();
|
||||||
|
},
|
||||||
|
[onPress],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderIcon = useCallback(
|
||||||
|
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => {
|
||||||
|
if (loading) {
|
||||||
|
return <ThemedActivityIndicator size={14} uniProps={foregroundMutedColorMapping} />;
|
||||||
|
}
|
||||||
|
const uniProps = hovered || pressed ? foregroundColorMapping : foregroundMutedColorMapping;
|
||||||
|
if (icon === "plus") {
|
||||||
|
return <ThemedPlus size={15} uniProps={uniProps} />;
|
||||||
|
}
|
||||||
|
return <ThemedFolderPlus size={15} uniProps={uniProps} />;
|
||||||
|
},
|
||||||
|
[icon, loading],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.projectTrailingControlSlot} pointerEvents={visible ? "auto" : "none"}>
|
||||||
|
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||||
|
<TooltipTrigger asChild disabled={!visible}>
|
||||||
|
<Pressable
|
||||||
|
style={pressableStyle}
|
||||||
|
onPress={handlePress}
|
||||||
|
disabled={loading}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={accessibilityLabel}
|
||||||
|
testID={testID}
|
||||||
|
>
|
||||||
|
{renderIcon}
|
||||||
|
</Pressable>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom" align="center" offset={8}>
|
||||||
|
<View style={styles.projectActionTooltipRow}>
|
||||||
|
<Text style={styles.projectActionTooltipText}>{tooltipLabel}</Text>
|
||||||
|
{showShortcutHint && newWorktreeKeys ? (
|
||||||
|
<Shortcut chord={newWorktreeKeys} style={styles.projectActionTooltipShortcut} />
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
projectIconActionButton: {
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
borderRadius: theme.borderRadius.md,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
projectIconActionButtonHovered: {
|
||||||
|
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||||
|
},
|
||||||
|
projectIconActionButtonHidden: {
|
||||||
|
opacity: 0,
|
||||||
|
},
|
||||||
|
projectTrailingActions: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 2,
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
projectKebabButton: {
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
borderRadius: theme.borderRadius.md,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
projectKebabButtonHidden: {
|
||||||
|
opacity: 0,
|
||||||
|
},
|
||||||
|
projectKebabButtonHovered: {
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
},
|
||||||
|
projectTrailingControlSlot: {
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
projectActionTooltipRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
|
projectActionTooltipText: {
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
},
|
||||||
|
projectActionTooltipShortcut: {},
|
||||||
|
}));
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
/**
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
*/
|
||||||
|
import { render } from "@testing-library/react";
|
||||||
|
import React from "react";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||||
|
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||||
|
import {
|
||||||
|
SidebarProjectsDraggableList,
|
||||||
|
type SidebarProjectDragInfo,
|
||||||
|
} from "./sidebar-projects-draggable-list";
|
||||||
|
|
||||||
|
interface CapturedDraggableProps {
|
||||||
|
data: SidebarProjectEntry[];
|
||||||
|
onDragEnd: (next: SidebarProjectEntry[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const captured: { props: CapturedDraggableProps | null } = { props: null };
|
||||||
|
|
||||||
|
vi.mock("@/components/draggable-list", () => ({
|
||||||
|
DraggableList: (props: CapturedDraggableProps) => {
|
||||||
|
captured.props = props;
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
function makeProject(key: string): SidebarProjectEntry {
|
||||||
|
return {
|
||||||
|
projectKey: key,
|
||||||
|
projectName: key,
|
||||||
|
projectKind: "git",
|
||||||
|
iconWorkingDir: `/repo/${key}`,
|
||||||
|
workspaces: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROJECTS_ABC: readonly SidebarProjectEntry[] = [
|
||||||
|
makeProject("a"),
|
||||||
|
makeProject("b"),
|
||||||
|
makeProject("c"),
|
||||||
|
];
|
||||||
|
|
||||||
|
const PROJECTS_AB: readonly SidebarProjectEntry[] = [makeProject("a"), makeProject("b")];
|
||||||
|
|
||||||
|
function renderEmpty(): React.ReactElement {
|
||||||
|
return React.createElement(React.Fragment);
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
captured.props = null;
|
||||||
|
useSidebarOrderStore.setState({
|
||||||
|
projectOrderByServerId: {},
|
||||||
|
workspaceOrderByServerAndProject: {},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const noopRender = (_info: SidebarProjectDragInfo): React.ReactElement => renderEmpty();
|
||||||
|
|
||||||
|
describe("SidebarProjectsDraggableList", () => {
|
||||||
|
it("persists the reordered project keys to the order store on drag end", () => {
|
||||||
|
render(
|
||||||
|
<SidebarProjectsDraggableList
|
||||||
|
projects={PROJECTS_ABC}
|
||||||
|
serverId="server-1"
|
||||||
|
renderProject={noopRender}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(captured.props).not.toBeNull();
|
||||||
|
const reordered = [PROJECTS_ABC[1], PROJECTS_ABC[0], PROJECTS_ABC[2]];
|
||||||
|
captured.props?.onDragEnd(reordered);
|
||||||
|
|
||||||
|
expect(useSidebarOrderStore.getState().getProjectOrder("server-1")).toEqual(["b", "a", "c"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not persist when the reordered visible keys match the current order", () => {
|
||||||
|
useSidebarOrderStore.getState().setProjectOrder("server-1", ["a", "b"]);
|
||||||
|
const setSpy = vi.spyOn(useSidebarOrderStore.getState(), "setProjectOrder");
|
||||||
|
|
||||||
|
render(
|
||||||
|
<SidebarProjectsDraggableList
|
||||||
|
projects={PROJECTS_AB}
|
||||||
|
serverId="server-1"
|
||||||
|
renderProject={noopRender}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
captured.props?.onDragEnd([...PROJECTS_AB]);
|
||||||
|
|
||||||
|
expect(setSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing when serverId is null", () => {
|
||||||
|
render(
|
||||||
|
<SidebarProjectsDraggableList
|
||||||
|
projects={PROJECTS_AB}
|
||||||
|
serverId={null}
|
||||||
|
renderProject={noopRender}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
captured.props?.onDragEnd([PROJECTS_AB[1], PROJECTS_AB[0]]);
|
||||||
|
|
||||||
|
expect(useSidebarOrderStore.getState().projectOrderByServerId).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import React, { useCallback, type MutableRefObject, type ReactElement } from "react";
|
||||||
|
import type { StyleProp, ViewStyle } from "react-native";
|
||||||
|
import type { GestureType } from "react-native-gesture-handler";
|
||||||
|
import { DraggableList, type DraggableRenderItemInfo } from "@/components/draggable-list";
|
||||||
|
import type { DraggableListDragHandleProps } from "@/components/draggable-list.types";
|
||||||
|
import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||||
|
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||||
|
import { hasVisibleOrderChanged, mergeWithRemainder } from "@/utils/sidebar-reorder";
|
||||||
|
|
||||||
|
export interface SidebarProjectDragInfo {
|
||||||
|
project: SidebarProjectEntry;
|
||||||
|
drag: () => void;
|
||||||
|
isDragging: boolean;
|
||||||
|
dragHandleProps?: DraggableListDragHandleProps;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SidebarProjectsDraggableListProps {
|
||||||
|
projects: readonly SidebarProjectEntry[];
|
||||||
|
serverId: string | null;
|
||||||
|
renderProject: (info: SidebarProjectDragInfo) => ReactElement;
|
||||||
|
/** Native: use the nestable variant when wrapped in a NestableScrollContainer. */
|
||||||
|
nestable?: boolean;
|
||||||
|
/** When false, the outer scroll container owns scrolling. */
|
||||||
|
scrollEnabled?: boolean;
|
||||||
|
containerStyle?: StyleProp<ViewStyle>;
|
||||||
|
testID?: string;
|
||||||
|
/** Native: gesture ref to coordinate with parent (e.g. sidebar close swipe). */
|
||||||
|
simultaneousGestureRef?: MutableRefObject<GestureType | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectKeyExtractor = (project: SidebarProjectEntry) => project.projectKey;
|
||||||
|
|
||||||
|
export function SidebarProjectsDraggableList({
|
||||||
|
projects,
|
||||||
|
serverId,
|
||||||
|
renderProject,
|
||||||
|
nestable = false,
|
||||||
|
scrollEnabled = false,
|
||||||
|
containerStyle,
|
||||||
|
testID,
|
||||||
|
simultaneousGestureRef,
|
||||||
|
}: SidebarProjectsDraggableListProps): ReactElement {
|
||||||
|
const getProjectOrder = useSidebarOrderStore((state) => state.getProjectOrder);
|
||||||
|
const setProjectOrder = useSidebarOrderStore((state) => state.setProjectOrder);
|
||||||
|
|
||||||
|
const handleDragEnd = useCallback(
|
||||||
|
(reorderedProjects: SidebarProjectEntry[]) => {
|
||||||
|
if (!serverId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reorderedProjectKeys = reorderedProjects.map((project) => project.projectKey);
|
||||||
|
const currentProjectOrder = getProjectOrder(serverId);
|
||||||
|
if (
|
||||||
|
!hasVisibleOrderChanged({
|
||||||
|
currentOrder: currentProjectOrder,
|
||||||
|
reorderedVisibleKeys: reorderedProjectKeys,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setProjectOrder(
|
||||||
|
serverId,
|
||||||
|
mergeWithRemainder({
|
||||||
|
currentOrder: currentProjectOrder,
|
||||||
|
reorderedVisibleKeys: reorderedProjectKeys,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[getProjectOrder, serverId, setProjectOrder],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderItem = useCallback(
|
||||||
|
({ item, drag, isActive, dragHandleProps }: DraggableRenderItemInfo<SidebarProjectEntry>) =>
|
||||||
|
renderProject({ project: item, drag, isDragging: isActive, dragHandleProps }),
|
||||||
|
[renderProject],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DraggableList
|
||||||
|
testID={testID}
|
||||||
|
data={projects as SidebarProjectEntry[]}
|
||||||
|
keyExtractor={projectKeyExtractor}
|
||||||
|
renderItem={renderItem}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
scrollEnabled={scrollEnabled}
|
||||||
|
useDragHandle
|
||||||
|
nestable={nestable}
|
||||||
|
simultaneousGestureRef={simultaneousGestureRef}
|
||||||
|
containerStyle={containerStyle}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
{children}
|
||||||
|
{toggleSlot}
|
||||||
|
</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,229 @@
|
|||||||
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
|
import { Platform, StatusBar, type GestureResponderEvent } from "react-native";
|
||||||
|
import * as Haptics from "expo-haptics";
|
||||||
|
import { isWeb as platformIsWeb } from "@/constants/platform";
|
||||||
|
import { useContextMenu } from "@/components/ui/context-menu";
|
||||||
|
import { decideLongPressMove } from "@/utils/sidebar-gesture-arbitration";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared mobile drag affordance: long-press arms a draggable-flatlist drag with haptics,
|
||||||
|
* arbitrating against vertical scroll, horizontal swipe, and an optional context-menu open.
|
||||||
|
*
|
||||||
|
* Web does not exercise this — `dragHandleProps` from `DraggableList` covers web DnD.
|
||||||
|
*/
|
||||||
|
export function useLongPressDragInteraction(input: {
|
||||||
|
drag: () => void;
|
||||||
|
menuController: ReturnType<typeof useContextMenu> | null;
|
||||||
|
}) {
|
||||||
|
const didLongPressRef = useRef(false);
|
||||||
|
const dragArmedRef = useRef(false);
|
||||||
|
const dragActivatedRef = useRef(false);
|
||||||
|
const didStartDragRef = useRef(false);
|
||||||
|
const scrollIntentRef = useRef(false);
|
||||||
|
const menuOpenedRef = useRef(false);
|
||||||
|
const touchStartRef = useRef<{ x: number; y: number } | null>(null);
|
||||||
|
const touchCurrentRef = useRef<{ x: number; y: number } | null>(null);
|
||||||
|
const dragArmTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const contextMenuTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const clearTimers = useCallback(() => {
|
||||||
|
if (dragArmTimerRef.current) {
|
||||||
|
clearTimeout(dragArmTimerRef.current);
|
||||||
|
dragArmTimerRef.current = null;
|
||||||
|
}
|
||||||
|
if (contextMenuTimerRef.current) {
|
||||||
|
clearTimeout(contextMenuTimerRef.current);
|
||||||
|
contextMenuTimerRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openContextMenuAtStartPoint = useCallback(() => {
|
||||||
|
if (!input.menuController || !touchStartRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
|
||||||
|
input.menuController.setAnchorRect({
|
||||||
|
x: touchStartRef.current.x,
|
||||||
|
y: touchStartRef.current.y + statusBarHeight,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
});
|
||||||
|
input.menuController.setOpen(true);
|
||||||
|
menuOpenedRef.current = true;
|
||||||
|
didLongPressRef.current = true;
|
||||||
|
}, [input.menuController]);
|
||||||
|
|
||||||
|
const handleLongPress = useCallback(() => {
|
||||||
|
// Manual timers own long-press behavior on mobile.
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
clearTimers();
|
||||||
|
};
|
||||||
|
}, [clearTimers]);
|
||||||
|
|
||||||
|
const armTimers = useCallback(() => {
|
||||||
|
clearTimers();
|
||||||
|
|
||||||
|
const DRAG_ARM_DELAY_MS = 180;
|
||||||
|
const DRAG_ARM_STATIONARY_SLOP_PX = 4;
|
||||||
|
const CONTEXT_MENU_DELAY_MS = 450;
|
||||||
|
const CONTEXT_MENU_STATIONARY_SLOP_PX = 6;
|
||||||
|
|
||||||
|
dragArmTimerRef.current = setTimeout(() => {
|
||||||
|
if (scrollIntentRef.current || didStartDragRef.current || menuOpenedRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const start = touchStartRef.current;
|
||||||
|
const current = touchCurrentRef.current ?? start;
|
||||||
|
if (!start || !current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dx = current.x - start.x;
|
||||||
|
const dy = current.y - start.y;
|
||||||
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
if (distance > DRAG_ARM_STATIONARY_SLOP_PX) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dragArmedRef.current = true;
|
||||||
|
dragActivatedRef.current = true;
|
||||||
|
didLongPressRef.current = true;
|
||||||
|
void Haptics.selectionAsync().catch(() => {});
|
||||||
|
input.drag();
|
||||||
|
}, DRAG_ARM_DELAY_MS);
|
||||||
|
|
||||||
|
if (!input.menuController || platformIsWeb) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
contextMenuTimerRef.current = setTimeout(() => {
|
||||||
|
if (scrollIntentRef.current || didStartDragRef.current || menuOpenedRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const start = touchStartRef.current;
|
||||||
|
const current = touchCurrentRef.current ?? start;
|
||||||
|
if (!start || !current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dx = current.x - start.x;
|
||||||
|
const dy = current.y - start.y;
|
||||||
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
if (distance > CONTEXT_MENU_STATIONARY_SLOP_PX) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void Haptics.selectionAsync().catch(() => {});
|
||||||
|
openContextMenuAtStartPoint();
|
||||||
|
}, CONTEXT_MENU_DELAY_MS);
|
||||||
|
}, [clearTimers, input, openContextMenuAtStartPoint]);
|
||||||
|
|
||||||
|
const handleDragIntent = useCallback(
|
||||||
|
(_details: { dx: number; dy: number; distance: number }) => {
|
||||||
|
if (!dragActivatedRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
didStartDragRef.current = true;
|
||||||
|
didLongPressRef.current = true;
|
||||||
|
clearTimers();
|
||||||
|
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {});
|
||||||
|
},
|
||||||
|
[clearTimers],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleScrollIntent = useCallback(
|
||||||
|
(_details: { dx: number; dy: number; distance: number }) => {
|
||||||
|
scrollIntentRef.current = true;
|
||||||
|
didLongPressRef.current = true;
|
||||||
|
clearTimers();
|
||||||
|
},
|
||||||
|
[clearTimers],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSwipeIntent = useCallback(
|
||||||
|
(_details: { dx: number; dy: number; distance: number }) => {
|
||||||
|
didLongPressRef.current = true;
|
||||||
|
clearTimers();
|
||||||
|
},
|
||||||
|
[clearTimers],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePressIn = useCallback(
|
||||||
|
(event: GestureResponderEvent) => {
|
||||||
|
didLongPressRef.current = false;
|
||||||
|
dragArmedRef.current = false;
|
||||||
|
dragActivatedRef.current = false;
|
||||||
|
didStartDragRef.current = false;
|
||||||
|
scrollIntentRef.current = false;
|
||||||
|
menuOpenedRef.current = false;
|
||||||
|
touchStartRef.current = {
|
||||||
|
x: event.nativeEvent.pageX,
|
||||||
|
y: event.nativeEvent.pageY,
|
||||||
|
};
|
||||||
|
touchCurrentRef.current = {
|
||||||
|
x: event.nativeEvent.pageX,
|
||||||
|
y: event.nativeEvent.pageY,
|
||||||
|
};
|
||||||
|
armTimers();
|
||||||
|
},
|
||||||
|
[armTimers],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTouchMove = useCallback(
|
||||||
|
(event: GestureResponderEvent) => {
|
||||||
|
const start = touchStartRef.current;
|
||||||
|
if (!start || didStartDragRef.current || menuOpenedRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const touch = event?.nativeEvent?.touches?.[0] ?? event?.nativeEvent;
|
||||||
|
const x = touch?.pageX;
|
||||||
|
const y = touch?.pageY;
|
||||||
|
if (typeof x !== "number" || typeof y !== "number") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = { x, y };
|
||||||
|
touchCurrentRef.current = current;
|
||||||
|
const dx = current.x - start.x;
|
||||||
|
const dy = current.y - start.y;
|
||||||
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
const decision = decideLongPressMove({
|
||||||
|
dragArmed: dragArmedRef.current,
|
||||||
|
didStartDrag: didStartDragRef.current,
|
||||||
|
startPoint: start,
|
||||||
|
currentPoint: current,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (decision === "vertical_scroll") {
|
||||||
|
handleScrollIntent({ dx, dy, distance });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (decision === "horizontal_swipe" || decision === "cancel_long_press") {
|
||||||
|
handleSwipeIntent({ dx, dy, distance });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (decision === "start_drag") {
|
||||||
|
handleDragIntent({ dx, dy, distance });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleDragIntent, handleScrollIntent, handleSwipeIntent],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePressOut = useCallback(() => {
|
||||||
|
clearTimers();
|
||||||
|
dragArmedRef.current = false;
|
||||||
|
dragActivatedRef.current = false;
|
||||||
|
touchStartRef.current = null;
|
||||||
|
touchCurrentRef.current = null;
|
||||||
|
}, [clearTimers]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
didLongPressRef,
|
||||||
|
handleLongPress,
|
||||||
|
handlePressIn,
|
||||||
|
handleTouchMove,
|
||||||
|
handlePressOut,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 { 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 { useSessionStore } from "@/stores/session-store";
|
||||||
import type { AgentDirectoryEntry } from "@/types/agent-directory";
|
import type { AgentDirectoryEntry } from "@/types/agent-directory";
|
||||||
import type { Agent } from "@/stores/session-store";
|
import type { Agent } from "@/stores/session-store";
|
||||||
@@ -18,12 +19,151 @@ export interface AggregatedAgentsResult {
|
|||||||
refreshAll: () => void;
|
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?: {
|
export function useAggregatedAgents(options?: {
|
||||||
includeArchived?: boolean;
|
includeArchived?: boolean;
|
||||||
|
sort?: AggregatedAgentSortOption;
|
||||||
}): AggregatedAgentsResult {
|
}): AggregatedAgentsResult {
|
||||||
const daemons = useHosts();
|
const daemons = useHosts();
|
||||||
const runtime = getHostRuntimeStore();
|
const runtime = getHostRuntimeStore();
|
||||||
const includeArchived = options?.includeArchived ?? false;
|
const includeArchived = options?.includeArchived ?? false;
|
||||||
|
const sort = options?.sort ?? "createdAt-desc-stable";
|
||||||
const runtimeVersion = useSyncExternalStore(
|
const runtimeVersion = useSyncExternalStore(
|
||||||
(onStoreChange) => runtime.subscribeAll(onStoreChange),
|
(onStoreChange) => runtime.subscribeAll(onStoreChange),
|
||||||
() => runtime.getVersion(),
|
() => runtime.getVersion(),
|
||||||
@@ -83,20 +223,7 @@ export function useAggregatedAgents(options?: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by: running agents first, then by most recent activity
|
sortAggregatedAgents(allAgents, sort);
|
||||||
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;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check if we have any cached data
|
// Check if we have any cached data
|
||||||
const hasAnyData = allAgents.length > 0;
|
const hasAnyData = allAgents.length > 0;
|
||||||
@@ -116,7 +243,7 @@ export function useAggregatedAgents(options?: {
|
|||||||
isInitialLoad,
|
isInitialLoad,
|
||||||
isRevalidating,
|
isRevalidating,
|
||||||
};
|
};
|
||||||
}, [daemons, includeArchived, runtime, runtimeVersion, sessionAgents]);
|
}, [daemons, includeArchived, runtime, runtimeVersion, sessionAgents, sort]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...result,
|
...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 { __private__ } from "./use-all-agents-list";
|
||||||
import type { Agent } from "@/stores/session-store";
|
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_TIMESTAMP = new Date("2026-03-08T10:00:00.000Z");
|
||||||
|
|
||||||
const AGENT_DEFAULTS: Agent = {
|
const AGENT_DEFAULTS: Agent = {
|
||||||
|
|||||||
@@ -1,10 +1,20 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
|
import { router } from "expo-router";
|
||||||
import { pickDirectory } from "@/desktop/pick-directory";
|
import { pickDirectory } from "@/desktop/pick-directory";
|
||||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
import {
|
||||||
|
useKeyboardShortcutsStore,
|
||||||
|
type ProjectPickerIntent,
|
||||||
|
} from "@/stores/keyboard-shortcuts-store";
|
||||||
|
import { buildHostNewWorkspaceRoute } from "@/utils/host-routes";
|
||||||
import { useIsLocalDaemon } from "./use-is-local-daemon";
|
import { useIsLocalDaemon } from "./use-is-local-daemon";
|
||||||
import { useOpenProject } from "./use-open-project";
|
import { useOpenProject } from "./use-open-project";
|
||||||
|
|
||||||
export function useOpenProjectPicker(serverId: string | null): () => Promise<void> {
|
const OPEN_PROJECT_INTENT: ProjectPickerIntent = { kind: "open-project" };
|
||||||
|
|
||||||
|
export function useOpenProjectPicker(
|
||||||
|
serverId: string | null,
|
||||||
|
intent: ProjectPickerIntent = OPEN_PROJECT_INTENT,
|
||||||
|
): () => Promise<void> {
|
||||||
const normalizedServerId = serverId?.trim() ?? "";
|
const normalizedServerId = serverId?.trim() ?? "";
|
||||||
const isLocalDaemon = useIsLocalDaemon(normalizedServerId);
|
const isLocalDaemon = useIsLocalDaemon(normalizedServerId);
|
||||||
const setProjectPickerOpen = useKeyboardShortcutsStore((state) => state.setProjectPickerOpen);
|
const setProjectPickerOpen = useKeyboardShortcutsStore((state) => state.setProjectPickerOpen);
|
||||||
@@ -16,7 +26,7 @@ export function useOpenProjectPicker(serverId: string | null): () => Promise<voi
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!isLocalDaemon) {
|
if (!isLocalDaemon) {
|
||||||
setProjectPickerOpen(true);
|
setProjectPickerOpen(true, intent);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,6 +35,15 @@ export function useOpenProjectPicker(serverId: string | null): () => Promise<voi
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (intent.kind === "new-workspace") {
|
||||||
|
router.navigate(
|
||||||
|
buildHostNewWorkspaceRoute(normalizedServerId, path, {
|
||||||
|
headerTitle: intent.headerTitle,
|
||||||
|
}) as never,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await openProject(path);
|
await openProject(path);
|
||||||
}, [isLocalDaemon, normalizedServerId, openProject, setProjectPickerOpen]);
|
}, [intent, isLocalDaemon, normalizedServerId, openProject, setProjectPickerOpen]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ interface NewWorkspaceScreenProps {
|
|||||||
serverId: string;
|
serverId: string;
|
||||||
sourceDirectory: string;
|
sourceDirectory: string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
|
headerTitle?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PickerOptionData {
|
interface PickerOptionData {
|
||||||
@@ -329,6 +330,11 @@ function computeWorkspaceTitle(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveNewWorkspaceHeaderTitle(headerTitle: string | undefined): string {
|
||||||
|
const trimmed = headerTitle?.trim();
|
||||||
|
return trimmed ? trimmed : "New workspace";
|
||||||
|
}
|
||||||
|
|
||||||
function submitWorkspaceDraft(input: SubmitDraftInput): void {
|
function submitWorkspaceDraft(input: SubmitDraftInput): void {
|
||||||
const {
|
const {
|
||||||
serverId,
|
serverId,
|
||||||
@@ -385,6 +391,7 @@ export function NewWorkspaceScreen({
|
|||||||
serverId,
|
serverId,
|
||||||
sourceDirectory,
|
sourceDirectory,
|
||||||
displayName: displayNameProp,
|
displayName: displayNameProp,
|
||||||
|
headerTitle: headerTitleProp,
|
||||||
}: NewWorkspaceScreenProps) {
|
}: NewWorkspaceScreenProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
@@ -412,6 +419,7 @@ export function NewWorkspaceScreen({
|
|||||||
}, [pickerSearchQuery]);
|
}, [pickerSearchQuery]);
|
||||||
|
|
||||||
const displayName = displayNameProp?.trim() ?? "";
|
const displayName = displayNameProp?.trim() ?? "";
|
||||||
|
const headerTitle = resolveNewWorkspaceHeaderTitle(headerTitleProp);
|
||||||
const workspace = createdWorkspace;
|
const workspace = createdWorkspace;
|
||||||
const selectedItem = pickerSelection?.item ?? null;
|
const selectedItem = pickerSelection?.item ?? null;
|
||||||
const isPending = pendingAction !== null;
|
const isPending = pendingAction !== null;
|
||||||
@@ -711,7 +719,7 @@ export function NewWorkspaceScreen({
|
|||||||
<SidebarMenuToggle />
|
<SidebarMenuToggle />
|
||||||
<View style={styles.headerTitleContainer}>
|
<View style={styles.headerTitleContainer}>
|
||||||
<Text style={styles.headerTitle} numberOfLines={1}>
|
<Text style={styles.headerTitle} numberOfLines={1}>
|
||||||
New workspace
|
{headerTitle}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.headerProjectTitle} numberOfLines={1}>
|
<Text style={styles.headerProjectTitle} numberOfLines={1}>
|
||||||
{workspaceTitle}
|
{workspaceTitle}
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
/**
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
*/
|
||||||
|
import { act, renderHook } from "@testing-library/react";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { useSidebarSessionsController } from "./controller";
|
||||||
|
|
||||||
|
describe("useSidebarSessionsController", () => {
|
||||||
|
it("starts with project grouping off and no expanded projects", () => {
|
||||||
|
const { result } = renderHook(() => useSidebarSessionsController({ serverId: "server-1" }));
|
||||||
|
|
||||||
|
expect(result.current.groupByProject).toBe(false);
|
||||||
|
expect(result.current.previewExpandedProjects.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enables project grouping without expanding projects", () => {
|
||||||
|
const { result } = renderHook(() => useSidebarSessionsController({ serverId: "server-1" }));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setGroupByProject(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.groupByProject).toBe(true);
|
||||||
|
expect(result.current.previewExpandedProjects.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggles one expanded project", () => {
|
||||||
|
const { result } = renderHook(() => useSidebarSessionsController({ serverId: "server-1" }));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.toggleProjectPreviewExpanded("p1");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.previewExpandedProjects.has("p1")).toBe(true);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.toggleProjectPreviewExpanded("p1");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.previewExpandedProjects.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggles expanded projects independently", () => {
|
||||||
|
const { result } = renderHook(() => useSidebarSessionsController({ serverId: "server-1" }));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.toggleProjectPreviewExpanded("p1");
|
||||||
|
result.current.toggleProjectPreviewExpanded("p2");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect([...result.current.previewExpandedProjects].sort()).toEqual(["p1", "p2"]);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.toggleProjectPreviewExpanded("p1");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect([...result.current.previewExpandedProjects]).toEqual(["p2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears expanded projects when project grouping is disabled", () => {
|
||||||
|
const { result } = renderHook(() => useSidebarSessionsController({ serverId: "server-1" }));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setGroupByProject(true);
|
||||||
|
result.current.toggleProjectPreviewExpanded("p1");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.previewExpandedProjects.has("p1")).toBe(true);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setGroupByProject(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.groupByProject).toBe(false);
|
||||||
|
expect(result.current.previewExpandedProjects.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
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: "project",
|
||||||
|
projectKey: "project-a",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.sidebarSessionFilter).toEqual({
|
||||||
|
type: "project",
|
||||||
|
projectKey: "project-a",
|
||||||
|
});
|
||||||
|
|
||||||
|
rerender({ serverId: "server-2" });
|
||||||
|
|
||||||
|
expect(result.current.sidebarSessionFilter).toEqual({ type: "all" });
|
||||||
|
expect(result.current.sidebarViewMode).toBe("workspaces");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears expanded projects when the active host changes", () => {
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ serverId }) => useSidebarSessionsController({ serverId }),
|
||||||
|
{ initialProps: { serverId: "server-1" as string | null } },
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setGroupByProject(true);
|
||||||
|
result.current.toggleProjectPreviewExpanded("p1");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.previewExpandedProjects.has("p1")).toBe(true);
|
||||||
|
|
||||||
|
rerender({ serverId: "server-2" });
|
||||||
|
|
||||||
|
expect(result.current.previewExpandedProjects.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears expanded projects when the session filter changes", () => {
|
||||||
|
const { result } = renderHook(() => useSidebarSessionsController({ serverId: "server-1" }));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setGroupByProject(true);
|
||||||
|
result.current.toggleProjectPreviewExpanded("p1");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.previewExpandedProjects.has("p1")).toBe(true);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setSidebarSessionFilter({
|
||||||
|
type: "project",
|
||||||
|
projectKey: "project-a",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.sidebarSessionFilter).toEqual({
|
||||||
|
type: "project",
|
||||||
|
projectKey: "project-a",
|
||||||
|
});
|
||||||
|
expect(result.current.previewExpandedProjects.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not clear the session filter when project grouping is enabled", () => {
|
||||||
|
const { result } = renderHook(() => useSidebarSessionsController({ serverId: "server-1" }));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setSidebarSessionFilter({
|
||||||
|
type: "project",
|
||||||
|
projectKey: "project-a",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setGroupByProject(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.groupByProject).toBe(true);
|
||||||
|
expect(result.current.sidebarSessionFilter).toEqual({
|
||||||
|
type: "project",
|
||||||
|
projectKey: "project-a",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves project grouping when the active host changes", () => {
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ serverId }) => useSidebarSessionsController({ serverId }),
|
||||||
|
{ initialProps: { serverId: "server-1" as string | null } },
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setGroupByProject(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
rerender({ serverId: "server-2" });
|
||||||
|
|
||||||
|
expect(result.current.groupByProject).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { useCallback, 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",
|
||||||
|
});
|
||||||
|
const [groupByProject, setGroupByProjectState] = useState(false);
|
||||||
|
const [previewExpandedProjects, setPreviewExpandedProjects] = useState<ReadonlySet<string>>(
|
||||||
|
() => new Set(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const setGroupByProject = useCallback((next: boolean) => {
|
||||||
|
setGroupByProjectState(next);
|
||||||
|
if (!next) {
|
||||||
|
setPreviewExpandedProjects((previous) => (previous.size === 0 ? previous : new Set()));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setSidebarSessionFilterWithReset = useCallback((next: SidebarSessionFilter) => {
|
||||||
|
setSidebarSessionFilter(next);
|
||||||
|
setPreviewExpandedProjects((previous) => (previous.size === 0 ? previous : new Set()));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleProjectPreviewExpanded = useCallback((projectKey: string) => {
|
||||||
|
setPreviewExpandedProjects((previous) => {
|
||||||
|
const next = new Set(previous);
|
||||||
|
if (next.has(projectKey)) {
|
||||||
|
next.delete(projectKey);
|
||||||
|
} else {
|
||||||
|
next.add(projectKey);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (previousServerIdRef.current === serverId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
previousServerIdRef.current = serverId;
|
||||||
|
setSidebarSessionFilter({ type: "all" });
|
||||||
|
setPreviewExpandedProjects((previous) => (previous.size === 0 ? previous : new Set()));
|
||||||
|
}, [serverId]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sidebarViewMode,
|
||||||
|
setSidebarViewMode,
|
||||||
|
sidebarSessionFilter,
|
||||||
|
setSidebarSessionFilter: setSidebarSessionFilterWithReset,
|
||||||
|
groupByProject,
|
||||||
|
previewExpandedProjects,
|
||||||
|
setGroupByProject,
|
||||||
|
toggleProjectPreviewExpanded,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
import React, { memo, useCallback, useMemo, useState, type ReactElement, type Ref } from "react";
|
||||||
|
import { Pressable, Text, View } from "react-native";
|
||||||
|
import { router, type Href } from "expo-router";
|
||||||
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
|
import { shallow } from "zustand/shallow";
|
||||||
|
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||||
|
import { SidebarProjectSection } from "@/components/sidebar/sidebar-collapsible-project-section";
|
||||||
|
import {
|
||||||
|
SidebarProjectTrailingActions,
|
||||||
|
type SidebarProjectCreateButtonConfig,
|
||||||
|
} from "@/components/sidebar/sidebar-project-trailing-actions";
|
||||||
|
import type { DraggableListDragHandleProps } from "@/components/draggable-list.types";
|
||||||
|
import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
|
||||||
|
import { useProjectIconQuery } from "@/hooks/use-project-icon-query";
|
||||||
|
import type {
|
||||||
|
SidebarProjectEntry,
|
||||||
|
SidebarWorkspaceEntry,
|
||||||
|
} from "@/hooks/use-sidebar-workspaces-list";
|
||||||
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
|
import { buildHostNewWorkspaceRoute } from "@/utils/host-routes";
|
||||||
|
import {
|
||||||
|
deriveGroupedSidebarSessions,
|
||||||
|
type SidebarSessionAgentProject,
|
||||||
|
type SidebarSessionGroup,
|
||||||
|
} from "./session-filtering";
|
||||||
|
import type { ResolvedSidebarSessionProject } from "./types";
|
||||||
|
|
||||||
|
export const GROUPED_SESSION_LIMIT = 6;
|
||||||
|
|
||||||
|
type SessionStoreState = ReturnType<typeof useSessionStore.getState>;
|
||||||
|
|
||||||
|
type ResolveCwdToProject = (cwd: string) => ResolvedSidebarSessionProject | null;
|
||||||
|
|
||||||
|
export function useOrderedAgentProjectShape(input: {
|
||||||
|
orderedIds: readonly string[];
|
||||||
|
serverId: string;
|
||||||
|
resolveCwdToProject: ResolveCwdToProject;
|
||||||
|
}): readonly SidebarSessionAgentProject[] {
|
||||||
|
const { orderedIds, resolveCwdToProject, serverId } = input;
|
||||||
|
const selector = useMemo(() => {
|
||||||
|
let previousById = new Map<string, SidebarSessionAgentProject>();
|
||||||
|
|
||||||
|
return (state: SessionStoreState) => {
|
||||||
|
const agents = state.sessions[serverId]?.agents;
|
||||||
|
if (!agents) {
|
||||||
|
previousById = new Map();
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextById = new Map<string, SidebarSessionAgentProject>();
|
||||||
|
const agentsWithProjects: SidebarSessionAgentProject[] = [];
|
||||||
|
|
||||||
|
for (const id of orderedIds) {
|
||||||
|
const agent = agents.get(id);
|
||||||
|
if (!agent) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = resolveCwdToProject(agent.cwd);
|
||||||
|
if (!project) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previous = previousById.get(id);
|
||||||
|
const next =
|
||||||
|
previous && previous.projectKey === project.projectKey
|
||||||
|
? previous
|
||||||
|
: { id, projectKey: project.projectKey };
|
||||||
|
|
||||||
|
nextById.set(id, next);
|
||||||
|
agentsWithProjects.push(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
previousById = nextById;
|
||||||
|
return agentsWithProjects;
|
||||||
|
};
|
||||||
|
}, [orderedIds, resolveCwdToProject, serverId]);
|
||||||
|
|
||||||
|
return useStoreWithEqualityFn(useSessionStore, selector, shallow);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGroupedSidebarSessionGroups(input: {
|
||||||
|
agentsWithProjects: readonly SidebarSessionAgentProject[];
|
||||||
|
projects: readonly SidebarProjectEntry[];
|
||||||
|
previewExpandedProjects: ReadonlySet<string>;
|
||||||
|
collapsedProjectKeys: ReadonlySet<string>;
|
||||||
|
}): readonly SidebarSessionGroup[] {
|
||||||
|
return useMemo(
|
||||||
|
() =>
|
||||||
|
deriveGroupedSidebarSessions({
|
||||||
|
agentsWithProjects: input.agentsWithProjects,
|
||||||
|
projects: input.projects,
|
||||||
|
previewExpandedProjects: input.previewExpandedProjects,
|
||||||
|
collapsedProjectKeys: input.collapsedProjectKeys,
|
||||||
|
limit: GROUPED_SESSION_LIMIT,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
input.agentsWithProjects,
|
||||||
|
input.collapsedProjectKeys,
|
||||||
|
input.previewExpandedProjects,
|
||||||
|
input.projects,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SidebarSessionGroupHeader = memo(function SidebarSessionGroupHeader({
|
||||||
|
serverId,
|
||||||
|
projectKey,
|
||||||
|
projectName,
|
||||||
|
projectIconKey,
|
||||||
|
workspaces,
|
||||||
|
isCollapsed,
|
||||||
|
isDragging = false,
|
||||||
|
drag,
|
||||||
|
dragHandleProps,
|
||||||
|
onToggleCollapsed,
|
||||||
|
children,
|
||||||
|
footer = null,
|
||||||
|
}: {
|
||||||
|
serverId: string | null;
|
||||||
|
projectKey: string;
|
||||||
|
projectName: string;
|
||||||
|
projectIconKey: string | null;
|
||||||
|
workspaces: readonly SidebarWorkspaceEntry[];
|
||||||
|
isCollapsed: boolean;
|
||||||
|
isDragging?: boolean;
|
||||||
|
drag?: () => void;
|
||||||
|
dragHandleProps?: DraggableListDragHandleProps;
|
||||||
|
onToggleCollapsed: (projectKey: string) => void;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
footer?: React.ReactNode;
|
||||||
|
}): ReactElement {
|
||||||
|
const { icon } = useProjectIconQuery({ serverId: serverId ?? "", cwd: projectIconKey ?? "" });
|
||||||
|
const dataUri = useMemo(() => {
|
||||||
|
if (!icon || !icon.mimeType || !icon.data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return `data:${icon.mimeType};base64,${icon.data}`;
|
||||||
|
}, [icon]);
|
||||||
|
|
||||||
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
|
||||||
|
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
|
||||||
|
const sourceDirectory = projectIconKey ?? undefined;
|
||||||
|
const handleCreateSessionPress = useCallback(() => {
|
||||||
|
if (!serverId || !sourceDirectory) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.navigate(
|
||||||
|
buildHostNewWorkspaceRoute(serverId, sourceDirectory, {
|
||||||
|
headerTitle: "New session",
|
||||||
|
}) as Href,
|
||||||
|
);
|
||||||
|
}, [serverId, sourceDirectory]);
|
||||||
|
const createButton = useMemo<SidebarProjectCreateButtonConfig | null>(
|
||||||
|
() =>
|
||||||
|
serverId && sourceDirectory
|
||||||
|
? {
|
||||||
|
onPress: handleCreateSessionPress,
|
||||||
|
accessibilityLabel: "New session",
|
||||||
|
testID: `sidebar-project-${projectKey}-new-session-button`,
|
||||||
|
tooltipLabel: "New session",
|
||||||
|
icon: "plus" as const,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
[handleCreateSessionPress, projectKey, serverId, sourceDirectory],
|
||||||
|
);
|
||||||
|
const interaction = useLongPressDragInteraction({
|
||||||
|
drag: drag ?? noopDrag,
|
||||||
|
menuController: null,
|
||||||
|
});
|
||||||
|
const trailingSlot = useMemo(
|
||||||
|
() => (
|
||||||
|
<SidebarProjectTrailingActions
|
||||||
|
projectKey={projectKey}
|
||||||
|
projectName={projectName}
|
||||||
|
serverId={serverId}
|
||||||
|
isHovered={isHovered}
|
||||||
|
workspaces={workspaces}
|
||||||
|
createButton={createButton}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[createButton, isHovered, projectKey, projectName, serverId, workspaces],
|
||||||
|
);
|
||||||
|
const handlePress = useCallback(() => {
|
||||||
|
if (interaction.didLongPressRef.current) {
|
||||||
|
interaction.didLongPressRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onToggleCollapsed(projectKey);
|
||||||
|
}, [interaction.didLongPressRef, onToggleCollapsed, projectKey]);
|
||||||
|
const headerProps = useMemo(
|
||||||
|
() => ({
|
||||||
|
accessibilityLabel: isCollapsed ? `Expand ${projectName}` : `Collapse ${projectName}`,
|
||||||
|
onPressIn: drag ? interaction.handlePressIn : undefined,
|
||||||
|
onTouchMove: drag ? interaction.handleTouchMove : undefined,
|
||||||
|
onPressOut: drag ? interaction.handlePressOut : undefined,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
drag,
|
||||||
|
interaction.handlePressIn,
|
||||||
|
interaction.handlePressOut,
|
||||||
|
interaction.handleTouchMove,
|
||||||
|
isCollapsed,
|
||||||
|
projectName,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
{...dragHandleProps?.attributes}
|
||||||
|
{...dragHandleProps?.listeners}
|
||||||
|
ref={dragHandleProps?.setActivatorNodeRef as unknown as Ref<View>}
|
||||||
|
onPointerEnter={handlePointerEnter}
|
||||||
|
onPointerLeave={handlePointerLeave}
|
||||||
|
>
|
||||||
|
<SidebarProjectSection
|
||||||
|
projectKey={projectKey}
|
||||||
|
projectName={projectName}
|
||||||
|
iconDataUri={dataUri}
|
||||||
|
chevron={isCollapsed ? "expand" : "collapse"}
|
||||||
|
isHovered={isHovered}
|
||||||
|
isDragging={isDragging}
|
||||||
|
trailingSlot={trailingSlot}
|
||||||
|
onPress={handlePress}
|
||||||
|
testID={`sidebar-session-group-header-${projectKey}`}
|
||||||
|
isCollapsed={isCollapsed}
|
||||||
|
footer={footer}
|
||||||
|
headerProps={headerProps}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SidebarProjectSection>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
function noopDrag() {}
|
||||||
|
|
||||||
|
export const SidebarSessionGroupFooter = memo(function SidebarSessionGroupFooter({
|
||||||
|
projectKey,
|
||||||
|
hiddenCount,
|
||||||
|
isExpanded,
|
||||||
|
onPress,
|
||||||
|
}: {
|
||||||
|
projectKey: string;
|
||||||
|
hiddenCount: number;
|
||||||
|
isExpanded: boolean;
|
||||||
|
onPress: (projectKey: string) => void;
|
||||||
|
}): ReactElement {
|
||||||
|
const handlePress = useCallback(() => {
|
||||||
|
onPress(projectKey);
|
||||||
|
}, [onPress, projectKey]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={isExpanded ? "Show less sessions" : `Show ${hiddenCount} more sessions`}
|
||||||
|
onPress={handlePress}
|
||||||
|
style={footerRowStyle}
|
||||||
|
testID={`sidebar-session-group-footer-${projectKey}`}
|
||||||
|
>
|
||||||
|
<Text style={styles.footerText}>{isExpanded ? "Show less" : `Show ${hiddenCount} more`}</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
footerRow: {
|
||||||
|
minHeight: 32,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingVertical: theme.spacing[1],
|
||||||
|
paddingLeft: theme.spacing[3] + theme.spacing[3],
|
||||||
|
paddingRight: theme.spacing[3],
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
marginBottom: theme.spacing[1],
|
||||||
|
},
|
||||||
|
footerText: {
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const footerRowStyle = styles.footerRow;
|
||||||
@@ -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,432 @@
|
|||||||
|
/**
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
*/
|
||||||
|
import { act, cleanup, fireEvent, render, renderHook } from "@testing-library/react";
|
||||||
|
import React from "react";
|
||||||
|
import { useCallback, useMemo } from "react";
|
||||||
|
import { Pressable, View } from "react-native";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { shallow } from "zustand/shallow";
|
||||||
|
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||||
|
import type { DaemonClient } from "@server/client/daemon-client";
|
||||||
|
import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||||
|
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||||
|
import {
|
||||||
|
SidebarSessionGroupFooter,
|
||||||
|
SidebarSessionGroupHeader,
|
||||||
|
useGroupedSidebarSessionGroups,
|
||||||
|
useOrderedAgentProjectShape,
|
||||||
|
} from "./grouped-view";
|
||||||
|
import { selectSidebarSessionSlice } from "./select-sidebar-session-slice";
|
||||||
|
|
||||||
|
function gitProject(key: string): SidebarProjectEntry {
|
||||||
|
return {
|
||||||
|
projectKey: key,
|
||||||
|
projectName: `Project ${key}`,
|
||||||
|
projectKind: "git",
|
||||||
|
iconWorkingDir: `/repo/${key}`,
|
||||||
|
workspaces: [
|
||||||
|
{
|
||||||
|
workspaceKey: `server-1:${key}-ws`,
|
||||||
|
serverId: "server-1",
|
||||||
|
workspaceId: `${key}-ws`,
|
||||||
|
projectKey: key,
|
||||||
|
projectRootPath: `/repo/${key}`,
|
||||||
|
workspaceDirectory: `/repo/${key}/ws`,
|
||||||
|
projectKind: "git",
|
||||||
|
workspaceKind: "checkout",
|
||||||
|
name: `${key}-ws`,
|
||||||
|
statusBucket: "done",
|
||||||
|
archivingAt: null,
|
||||||
|
diffStat: null,
|
||||||
|
scripts: [],
|
||||||
|
hasRunningScripts: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const HARNESS_PROJECTS: readonly SidebarProjectEntry[] = [
|
||||||
|
gitProject("project-a"),
|
||||||
|
gitProject("project-b"),
|
||||||
|
];
|
||||||
|
|
||||||
|
vi.hoisted(() => {
|
||||||
|
Object.assign(globalThis, { __DEV__: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
const { routerNavigateMock } = vi.hoisted(() => ({
|
||||||
|
routerNavigateMock: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/hooks/use-project-icon-query", () => ({
|
||||||
|
useProjectIconQuery: () => ({ icon: null, isLoading: false, isError: false }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("lucide-react-native", () => {
|
||||||
|
const createIcon = (name: string) => (props: Record<string, unknown>) =>
|
||||||
|
React.createElement("span", { ...props, "data-icon": name });
|
||||||
|
return {
|
||||||
|
ChevronDown: createIcon("ChevronDown"),
|
||||||
|
ChevronRight: createIcon("ChevronRight"),
|
||||||
|
FolderPlus: createIcon("FolderPlus"),
|
||||||
|
MoreVertical: createIcon("MoreVertical"),
|
||||||
|
Plus: createIcon("Plus"),
|
||||||
|
Settings: createIcon("Settings"),
|
||||||
|
Trash2: createIcon("Trash2"),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("expo-router", () => ({
|
||||||
|
router: {
|
||||||
|
navigate: routerNavigateMock,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/ui/dropdown-menu", () => ({
|
||||||
|
DropdownMenu: ({ children }: { children: React.ReactNode }) => <View>{children}</View>,
|
||||||
|
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <View>{children}</View>,
|
||||||
|
DropdownMenuItem: ({ children, testID }: { children: React.ReactNode; testID?: string }) => (
|
||||||
|
<View testID={testID}>{children}</View>
|
||||||
|
),
|
||||||
|
DropdownMenuTrigger: ({
|
||||||
|
children,
|
||||||
|
testID,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode | ((state: { hovered: boolean }) => React.ReactNode);
|
||||||
|
testID?: string;
|
||||||
|
}) => (
|
||||||
|
<Pressable testID={testID}>
|
||||||
|
{typeof children === "function" ? children({ hovered: false }) : children}
|
||||||
|
</Pressable>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/ui/tooltip", () => ({
|
||||||
|
Tooltip: ({ children }: { children: React.ReactNode }) => <View>{children}</View>,
|
||||||
|
TooltipContent: ({ children }: { children: React.ReactNode }) => <View>{children}</View>,
|
||||||
|
TooltipTrigger: ({ children }: { children: React.ReactNode }) => children,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/ui/shortcut", () => ({
|
||||||
|
Shortcut: () => <View />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/constants/platform", () => ({
|
||||||
|
isNative: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/constants/layout", () => ({
|
||||||
|
useIsCompactFormFactor: () => false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/contexts/toast-context", () => ({
|
||||||
|
useToast: () => ({ error: vi.fn() }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/hooks/use-shortcut-keys", () => ({
|
||||||
|
useShortcutKeys: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const TIMESTAMP = new Date("2026-05-08T10:00:00.000Z");
|
||||||
|
const SERVER_ID = "server-1";
|
||||||
|
|
||||||
|
vi.mock("react-native-unistyles", () => ({
|
||||||
|
StyleSheet: {
|
||||||
|
create: (styles: unknown) =>
|
||||||
|
typeof styles === "function"
|
||||||
|
? styles({
|
||||||
|
borderRadius: { lg: 8, md: 6, sm: 4 },
|
||||||
|
colors: {
|
||||||
|
border: "#dddddd",
|
||||||
|
foreground: "#111111",
|
||||||
|
foregroundMuted: "#666666",
|
||||||
|
surface2: "#eeeeee",
|
||||||
|
surfaceSidebarHover: "#f5f5f5",
|
||||||
|
},
|
||||||
|
fontSize: { sm: 14, xs: 12 },
|
||||||
|
iconSize: { md: 20 },
|
||||||
|
shadow: { md: {} },
|
||||||
|
spacing: { 1: 4, 2: 8, 3: 12 },
|
||||||
|
})
|
||||||
|
: styles,
|
||||||
|
},
|
||||||
|
withUnistyles: (Component: React.ComponentType<Record<string, unknown>>) =>
|
||||||
|
function ThemedComponent(props: Record<string, unknown>) {
|
||||||
|
return <Component {...props} />;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const AGENT_DEFAULTS: Agent = {
|
||||||
|
serverId: "server-1",
|
||||||
|
id: "agent-1",
|
||||||
|
provider: "codex",
|
||||||
|
status: "running",
|
||||||
|
createdAt: TIMESTAMP,
|
||||||
|
updatedAt: TIMESTAMP,
|
||||||
|
lastUserMessageAt: null,
|
||||||
|
lastActivityAt: TIMESTAMP,
|
||||||
|
capabilities: {
|
||||||
|
supportsStreaming: true,
|
||||||
|
supportsSessionPersistence: true,
|
||||||
|
supportsDynamicModes: true,
|
||||||
|
supportsMcpServers: true,
|
||||||
|
supportsReasoningStream: true,
|
||||||
|
supportsToolInvocations: true,
|
||||||
|
},
|
||||||
|
currentModeId: null,
|
||||||
|
availableModes: [],
|
||||||
|
pendingPermissions: [],
|
||||||
|
persistence: null,
|
||||||
|
runtimeInfo: undefined,
|
||||||
|
lastUsage: undefined,
|
||||||
|
lastError: null,
|
||||||
|
title: "Agent",
|
||||||
|
cwd: "/repo/main",
|
||||||
|
model: null,
|
||||||
|
thinkingOptionId: undefined,
|
||||||
|
requiresAttention: false,
|
||||||
|
attentionReason: null,
|
||||||
|
attentionTimestamp: null,
|
||||||
|
archivedAt: null,
|
||||||
|
labels: {},
|
||||||
|
projectPlacement: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeAgent(input: Partial<Agent> = {}): Agent {
|
||||||
|
return { ...AGENT_DEFAULTS, ...input };
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedAgent(agent: Agent) {
|
||||||
|
useSessionStore.getState().initializeSession(SERVER_ID, {} as unknown as DaemonClient);
|
||||||
|
useSessionStore.getState().setAgents(SERVER_ID, new Map([[agent.id, agent]]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedAgents(agents: Agent[]) {
|
||||||
|
useSessionStore.getState().initializeSession(SERVER_ID, {} as unknown as DaemonClient);
|
||||||
|
useSessionStore
|
||||||
|
.getState()
|
||||||
|
.setAgents(SERVER_ID, new Map(agents.map((agent) => [agent.id, agent])));
|
||||||
|
}
|
||||||
|
|
||||||
|
function useGroupedBoundaryHarness(input?: { collapsedProjectKeys?: ReadonlySet<string> }) {
|
||||||
|
const orderedIds = useStoreWithEqualityFn(
|
||||||
|
useSessionStore,
|
||||||
|
(state) => Array.from(state.sessions[SERVER_ID]?.agents?.keys() ?? []),
|
||||||
|
shallow,
|
||||||
|
);
|
||||||
|
const resolveCwdToProject = useCallback((cwd: string) => {
|
||||||
|
if (cwd === "/repo/b") {
|
||||||
|
return { projectKey: "project-b", projectName: "Project B", projectIconKey: "/repo/b" };
|
||||||
|
}
|
||||||
|
return { projectKey: "project-a", projectName: "Project A", projectIconKey: "/repo/a" };
|
||||||
|
}, []);
|
||||||
|
const agentsWithProjects = useOrderedAgentProjectShape({
|
||||||
|
orderedIds,
|
||||||
|
serverId: SERVER_ID,
|
||||||
|
resolveCwdToProject,
|
||||||
|
});
|
||||||
|
const previewExpandedProjects = useMemo(() => new Set<string>(), []);
|
||||||
|
const fallbackCollapsed = useMemo(() => new Set<string>(), []);
|
||||||
|
const collapsedProjectKeys = input?.collapsedProjectKeys ?? fallbackCollapsed;
|
||||||
|
|
||||||
|
return useGroupedSidebarSessionGroups({
|
||||||
|
agentsWithProjects,
|
||||||
|
projects: HARNESS_PROJECTS,
|
||||||
|
previewExpandedProjects,
|
||||||
|
collapsedProjectKeys,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
useSessionStore.setState({ sessions: {}, agentLastActivity: new Map() });
|
||||||
|
routerNavigateMock.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sidebar session render boundaries", () => {
|
||||||
|
it("changes the row slice when lastActivityAt changes", () => {
|
||||||
|
const beforeAgent = makeAgent();
|
||||||
|
seedAgent(beforeAgent);
|
||||||
|
|
||||||
|
const beforeSlice = selectSidebarSessionSlice(
|
||||||
|
useSessionStore.getState(),
|
||||||
|
"server-1",
|
||||||
|
"agent-1",
|
||||||
|
);
|
||||||
|
|
||||||
|
const afterAgent = makeAgent({
|
||||||
|
lastActivityAt: new Date("2026-05-08T11:00:00.000Z"),
|
||||||
|
});
|
||||||
|
useSessionStore.getState().setAgents("server-1", new Map([["agent-1", afterAgent]]));
|
||||||
|
|
||||||
|
const afterSlice = selectSidebarSessionSlice(useSessionStore.getState(), "server-1", "agent-1");
|
||||||
|
|
||||||
|
expect(shallow(beforeSlice, afterSlice)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not change the row slice for fields the row does not consume", () => {
|
||||||
|
seedAgent(makeAgent());
|
||||||
|
|
||||||
|
const beforeSlice = selectSidebarSessionSlice(
|
||||||
|
useSessionStore.getState(),
|
||||||
|
"server-1",
|
||||||
|
"agent-1",
|
||||||
|
);
|
||||||
|
|
||||||
|
useSessionStore.getState().setAgents(
|
||||||
|
"server-1",
|
||||||
|
new Map([
|
||||||
|
[
|
||||||
|
"agent-1",
|
||||||
|
makeAgent({
|
||||||
|
status: "idle",
|
||||||
|
createdAt: new Date("2026-05-08T11:00:00.000Z"),
|
||||||
|
requiresAttention: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const afterSlice = selectSidebarSessionSlice(useSessionStore.getState(), "server-1", "agent-1");
|
||||||
|
|
||||||
|
expect(shallow(beforeSlice, afterSlice)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps grouped section data stable when activity changes without reordering ids", () => {
|
||||||
|
seedAgents([makeAgent({ id: "agent-1" }), makeAgent({ id: "agent-2", cwd: "/repo/main-2" })]);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useGroupedBoundaryHarness());
|
||||||
|
const beforeGroups = result.current;
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
useSessionStore.getState().setAgents(
|
||||||
|
SERVER_ID,
|
||||||
|
new Map([
|
||||||
|
[
|
||||||
|
"agent-1",
|
||||||
|
makeAgent({
|
||||||
|
id: "agent-1",
|
||||||
|
lastActivityAt: new Date("2026-05-08T11:00:00.000Z"),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
["agent-2", makeAgent({ id: "agent-2", cwd: "/repo/main-2" })],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current).toBe(beforeGroups);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("changes grouped section data when cwd resolves to a different project", () => {
|
||||||
|
seedAgents([makeAgent({ id: "agent-1", cwd: "/repo/a" })]);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useGroupedBoundaryHarness());
|
||||||
|
const beforeGroups = result.current;
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
useSessionStore
|
||||||
|
.getState()
|
||||||
|
.setAgents(SERVER_ID, new Map([["agent-1", makeAgent({ id: "agent-1", cwd: "/repo/b" })]]));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current).not.toBe(beforeGroups);
|
||||||
|
expect(result.current[0]).toMatchObject({
|
||||||
|
projectKey: "project-b",
|
||||||
|
visibleIds: ["agent-1"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps grouped section data stable when cwd changes but resolves to the same project", () => {
|
||||||
|
seedAgents([makeAgent({ id: "agent-1", cwd: "/repo/a" })]);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useGroupedBoundaryHarness());
|
||||||
|
const beforeGroups = result.current;
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
useSessionStore
|
||||||
|
.getState()
|
||||||
|
.setAgents(
|
||||||
|
SERVER_ID,
|
||||||
|
new Map([["agent-1", makeAgent({ id: "agent-1", cwd: "/repo/a-worktree" })]]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current).toBe(beforeGroups);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits a collapsed group with no visible ids and zero hidden count", () => {
|
||||||
|
const overLimit = Array.from({ length: 10 }, (_, index) =>
|
||||||
|
makeAgent({ id: `agent-${index + 1}`, cwd: "/repo/a" }),
|
||||||
|
);
|
||||||
|
seedAgents(overLimit);
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useGroupedBoundaryHarness({ collapsedProjectKeys: new Set(["project-a"]) }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.current).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
projectKey: "project-a",
|
||||||
|
isCollapsed: true,
|
||||||
|
visibleIds: [],
|
||||||
|
hiddenCount: 0,
|
||||||
|
totalCount: 10,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls the grouped footer press handler with its project key", () => {
|
||||||
|
const onPress = vi.fn();
|
||||||
|
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<SidebarSessionGroupFooter
|
||||||
|
projectKey="project-a"
|
||||||
|
hiddenCount={2}
|
||||||
|
isExpanded={false}
|
||||||
|
onPress={onPress}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId("sidebar-session-group-footer-project-a"));
|
||||||
|
|
||||||
|
expect(onPress).toHaveBeenCalledWith("project-a");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the project kebab and new-session button in the grouped header", () => {
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<SidebarSessionGroupHeader
|
||||||
|
serverId="server-1"
|
||||||
|
projectKey="project-a"
|
||||||
|
projectName="Project A"
|
||||||
|
projectIconKey="/repo/a"
|
||||||
|
workspaces={HARNESS_PROJECTS[0].workspaces}
|
||||||
|
isCollapsed={false}
|
||||||
|
onToggleCollapsed={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByTestId("sidebar-project-kebab-project-a")).toBeTruthy();
|
||||||
|
expect(getByTestId("sidebar-project-project-a-new-session-button")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens a new session scoped to the grouped project root", () => {
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<SidebarSessionGroupHeader
|
||||||
|
serverId="server-1"
|
||||||
|
projectKey="project-a"
|
||||||
|
projectName="Project A"
|
||||||
|
projectIconKey="/repo/a"
|
||||||
|
workspaces={HARNESS_PROJECTS[0].workspaces}
|
||||||
|
isCollapsed={false}
|
||||||
|
onToggleCollapsed={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId("sidebar-project-project-a-new-session-button"));
|
||||||
|
|
||||||
|
expect(routerNavigateMock).toHaveBeenCalledWith(
|
||||||
|
"/h/server-1/new?dir=%2Frepo%2Fa&title=New+session",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,479 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
createSidebarSessionWorkspaceLookup,
|
||||||
|
deriveGroupedSidebarSessions,
|
||||||
|
deriveSidebarSessionFilterAvailability,
|
||||||
|
deriveSidebarSessionFilterProjects,
|
||||||
|
shouldIncludeSidebarSessionAgent,
|
||||||
|
} from "./session-filtering";
|
||||||
|
import type { SidebarSessionAgent, SidebarSessionWorkspace } from "./types";
|
||||||
|
|
||||||
|
import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||||
|
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||||
|
|
||||||
|
type ProjectKind = WorkspaceDescriptor["projectKind"];
|
||||||
|
|
||||||
|
const WORKSPACES: SidebarSessionWorkspace[] = [
|
||||||
|
{
|
||||||
|
serverId: "server-1",
|
||||||
|
workspaceId: "workspace-1",
|
||||||
|
workspaceKey: "server-1:workspace-1",
|
||||||
|
workspaceName: "Main",
|
||||||
|
projectKey: "project-a",
|
||||||
|
projectName: "Project A",
|
||||||
|
projectIconKey: "/repo/main",
|
||||||
|
workspaceDirectory: "/repo/main",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
serverId: "server-1",
|
||||||
|
workspaceId: "workspace-2",
|
||||||
|
workspaceKey: "server-1:workspace-2",
|
||||||
|
workspaceName: "Docs",
|
||||||
|
projectKey: "project-b",
|
||||||
|
projectName: "Project B",
|
||||||
|
projectIconKey: "/repo/docs",
|
||||||
|
workspaceDirectory: "/repo/docs",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
serverId: "server-1",
|
||||||
|
workspaceId: "workspace-3",
|
||||||
|
workspaceKey: "server-1:workspace-3",
|
||||||
|
workspaceName: "API",
|
||||||
|
projectKey: "project-a",
|
||||||
|
projectName: "Project A",
|
||||||
|
projectIconKey: "/repo/main",
|
||||||
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function testProject(
|
||||||
|
key: string,
|
||||||
|
options: { name?: string; projectKind?: ProjectKind; workspaceCount?: number } = {},
|
||||||
|
): SidebarProjectEntry {
|
||||||
|
const projectKind: ProjectKind = options.projectKind ?? "git";
|
||||||
|
const workspaceCount = options.workspaceCount ?? (projectKind === "git" ? 2 : 1);
|
||||||
|
return {
|
||||||
|
projectKey: key,
|
||||||
|
projectName: options.name ?? key,
|
||||||
|
projectKind,
|
||||||
|
iconWorkingDir: `/repo/${key}`,
|
||||||
|
workspaces: Array.from({ length: workspaceCount }, (_, index) => ({
|
||||||
|
workspaceKey: `server-1:${key}-${index}`,
|
||||||
|
serverId: "server-1",
|
||||||
|
workspaceId: `${key}-${index}`,
|
||||||
|
projectKey: key,
|
||||||
|
projectRootPath: `/repo/${key}`,
|
||||||
|
workspaceDirectory: `/repo/${key}/${index}`,
|
||||||
|
projectKind,
|
||||||
|
workspaceKind: "checkout",
|
||||||
|
name: `${key}-${index}`,
|
||||||
|
statusBucket: "done",
|
||||||
|
archivingAt: null,
|
||||||
|
diffStat: null,
|
||||||
|
scripts: [],
|
||||||
|
hasRunningScripts: false,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function agentProject(id: string, project: SidebarProjectEntry) {
|
||||||
|
return { id, projectKey: project.projectKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
function agentProjects(ids: readonly string[], project: SidebarProjectEntry) {
|
||||||
|
return ids.map((id) => agentProject(id, project));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("sidebar session filtering", () => {
|
||||||
|
it("hides unmapped agents and includes every mapped active agent for All", () => {
|
||||||
|
const lookup = createSidebarSessionWorkspaceLookup(WORKSPACES);
|
||||||
|
|
||||||
|
const visibleIds = AGENTS.filter((agent) =>
|
||||||
|
shouldIncludeSidebarSessionAgent({
|
||||||
|
agent,
|
||||||
|
filter: { type: "all" },
|
||||||
|
lookup,
|
||||||
|
}),
|
||||||
|
).map((agent) => agent.id);
|
||||||
|
|
||||||
|
expect(visibleIds).toEqual(["main", "docs", "api"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
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({
|
||||||
|
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", "server-1:workspace-3"],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deriveGroupedSidebarSessions", () => {
|
||||||
|
it("returns no groups for empty input", () => {
|
||||||
|
expect(
|
||||||
|
deriveGroupedSidebarSessions({
|
||||||
|
agentsWithProjects: [],
|
||||||
|
projects: [],
|
||||||
|
previewExpandedProjects: new Set(),
|
||||||
|
}),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a single project under the limit fully visible and collapsed", () => {
|
||||||
|
const projectA = testProject("project-a");
|
||||||
|
const groups = deriveGroupedSidebarSessions({
|
||||||
|
agentsWithProjects: agentProjects(["a1", "a2", "a3"], projectA),
|
||||||
|
projects: [projectA],
|
||||||
|
previewExpandedProjects: new Set(),
|
||||||
|
limit: 6,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(groups).toEqual([
|
||||||
|
{
|
||||||
|
projectKey: "project-a",
|
||||||
|
projectName: "project-a",
|
||||||
|
projectIconKey: "/repo/project-a",
|
||||||
|
visibleIds: ["a1", "a2", "a3"],
|
||||||
|
hiddenCount: 0,
|
||||||
|
isExpanded: false,
|
||||||
|
isCollapsed: false,
|
||||||
|
totalCount: 3,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps a collapsed project over the limit", () => {
|
||||||
|
const orderedIds = Array.from({ length: 10 }, (_, index) => `a${index + 1}`);
|
||||||
|
const projectA = testProject("project-a");
|
||||||
|
const groups = deriveGroupedSidebarSessions({
|
||||||
|
agentsWithProjects: agentProjects(orderedIds, projectA),
|
||||||
|
projects: [projectA],
|
||||||
|
previewExpandedProjects: new Set(),
|
||||||
|
limit: 6,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(groups[0]).toMatchObject({
|
||||||
|
visibleIds: ["a1", "a2", "a3", "a4", "a5", "a6"],
|
||||||
|
hiddenCount: 4,
|
||||||
|
isExpanded: false,
|
||||||
|
isCollapsed: false,
|
||||||
|
totalCount: 10,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows every id for an expanded project over the limit", () => {
|
||||||
|
const orderedIds = Array.from({ length: 10 }, (_, index) => `a${index + 1}`);
|
||||||
|
const projectA = testProject("project-a");
|
||||||
|
const groups = deriveGroupedSidebarSessions({
|
||||||
|
agentsWithProjects: agentProjects(orderedIds, projectA),
|
||||||
|
projects: [projectA],
|
||||||
|
previewExpandedProjects: new Set(["project-a"]),
|
||||||
|
limit: 6,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(groups[0]).toMatchObject({
|
||||||
|
visibleIds: orderedIds,
|
||||||
|
hiddenCount: 0,
|
||||||
|
isExpanded: true,
|
||||||
|
isCollapsed: false,
|
||||||
|
totalCount: 10,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides all rows when a project is collapsed", () => {
|
||||||
|
const orderedIds = Array.from({ length: 10 }, (_, index) => `a${index + 1}`);
|
||||||
|
const projectA = testProject("project-a");
|
||||||
|
const groups = deriveGroupedSidebarSessions({
|
||||||
|
agentsWithProjects: agentProjects(orderedIds, projectA),
|
||||||
|
projects: [projectA],
|
||||||
|
previewExpandedProjects: new Set(),
|
||||||
|
collapsedProjectKeys: new Set(["project-a"]),
|
||||||
|
limit: 6,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(groups[0]).toMatchObject({
|
||||||
|
visibleIds: [],
|
||||||
|
hiddenCount: 0,
|
||||||
|
isExpanded: false,
|
||||||
|
isCollapsed: true,
|
||||||
|
totalCount: 10,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collapsed wins over preview-expanded", () => {
|
||||||
|
const orderedIds = Array.from({ length: 10 }, (_, index) => `a${index + 1}`);
|
||||||
|
const projectA = testProject("project-a");
|
||||||
|
const groups = deriveGroupedSidebarSessions({
|
||||||
|
agentsWithProjects: agentProjects(orderedIds, projectA),
|
||||||
|
projects: [projectA],
|
||||||
|
previewExpandedProjects: new Set(["project-a"]),
|
||||||
|
collapsedProjectKeys: new Set(["project-a"]),
|
||||||
|
limit: 6,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(groups[0]).toMatchObject({
|
||||||
|
visibleIds: [],
|
||||||
|
isExpanded: false,
|
||||||
|
isCollapsed: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("orders projects by the canonical project list, ignoring agent first-occurrence", () => {
|
||||||
|
const projectA = testProject("project-a");
|
||||||
|
const projectB = testProject("project-b");
|
||||||
|
const groups = deriveGroupedSidebarSessions({
|
||||||
|
agentsWithProjects: [
|
||||||
|
agentProject("b1", projectB),
|
||||||
|
agentProject("a1", projectA),
|
||||||
|
agentProject("b2", projectB),
|
||||||
|
agentProject("a2", projectA),
|
||||||
|
],
|
||||||
|
projects: [projectA, projectB],
|
||||||
|
previewExpandedProjects: new Set(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(groups.map((group) => ({ key: group.projectKey, ids: group.visibleIds }))).toEqual([
|
||||||
|
{ key: "project-a", ids: ["a1", "a2"] },
|
||||||
|
{ key: "project-b", ids: ["b1", "b2"] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves order within each project", () => {
|
||||||
|
const projectA = testProject("project-a");
|
||||||
|
const groups = deriveGroupedSidebarSessions({
|
||||||
|
agentsWithProjects: agentProjects(["a1", "a2", "a3"], projectA),
|
||||||
|
projects: [projectA],
|
||||||
|
previewExpandedProjects: new Set(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(groups[0]?.visibleIds).toEqual(["a1", "a2", "a3"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips projects that have no agents", () => {
|
||||||
|
const projectA = testProject("project-a");
|
||||||
|
const projectB = testProject("project-b");
|
||||||
|
const groups = deriveGroupedSidebarSessions({
|
||||||
|
agentsWithProjects: agentProjects(["a1", "a2"], projectA),
|
||||||
|
projects: [projectA, projectB],
|
||||||
|
previewExpandedProjects: new Set(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(groups.map((group) => group.projectKey)).toEqual(["project-a"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a single-workspace non-git project grouped and collapsible", () => {
|
||||||
|
const projectA = testProject("project-a", { projectKind: "directory", workspaceCount: 1 });
|
||||||
|
const groups = deriveGroupedSidebarSessions({
|
||||||
|
agentsWithProjects: agentProjects(["a1", "a2"], projectA),
|
||||||
|
projects: [projectA],
|
||||||
|
previewExpandedProjects: new Set(),
|
||||||
|
collapsedProjectKeys: new Set(["project-a"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(groups[0]).toMatchObject({
|
||||||
|
projectKey: "project-a",
|
||||||
|
isCollapsed: true,
|
||||||
|
visibleIds: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a single-workspace non-git project group when expanded", () => {
|
||||||
|
const projectA = testProject("project-a", { projectKind: "directory", workspaceCount: 1 });
|
||||||
|
const groups = deriveGroupedSidebarSessions({
|
||||||
|
agentsWithProjects: agentProjects(["a1", "a2"], projectA),
|
||||||
|
projects: [projectA],
|
||||||
|
previewExpandedProjects: new Set(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(groups[0]).toMatchObject({
|
||||||
|
projectKey: "project-a",
|
||||||
|
projectName: "project-a",
|
||||||
|
isCollapsed: false,
|
||||||
|
visibleIds: ["a1", "a2"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders multi-workspace non-git project groups too", () => {
|
||||||
|
const projectA = testProject("project-a", { projectKind: "directory", workspaceCount: 2 });
|
||||||
|
const groups = deriveGroupedSidebarSessions({
|
||||||
|
agentsWithProjects: agentProjects(["a1", "a2"], projectA),
|
||||||
|
projects: [projectA],
|
||||||
|
previewExpandedProjects: new Set(),
|
||||||
|
collapsedProjectKeys: new Set(["project-a"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(groups[0]).toMatchObject({
|
||||||
|
isCollapsed: true,
|
||||||
|
visibleIds: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps single-workspace git projects collapsible", () => {
|
||||||
|
const projectA = testProject("project-a", { projectKind: "git", workspaceCount: 1 });
|
||||||
|
const groups = deriveGroupedSidebarSessions({
|
||||||
|
agentsWithProjects: agentProjects(["a1", "a2"], projectA),
|
||||||
|
projects: [projectA],
|
||||||
|
previewExpandedProjects: new Set(),
|
||||||
|
collapsedProjectKeys: new Set(["project-a"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(groups[0]).toMatchObject({
|
||||||
|
isCollapsed: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults the collapsed limit to 6", () => {
|
||||||
|
const orderedIds = Array.from({ length: 7 }, (_, index) => `a${index + 1}`);
|
||||||
|
const projectA = testProject("project-a");
|
||||||
|
const groups = deriveGroupedSidebarSessions({
|
||||||
|
agentsWithProjects: agentProjects(orderedIds, projectA),
|
||||||
|
projects: [projectA],
|
||||||
|
previewExpandedProjects: new Set(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(groups[0]?.visibleIds).toHaveLength(6);
|
||||||
|
expect(groups[0]?.hiddenCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honors a custom collapsed limit", () => {
|
||||||
|
const projectA = testProject("project-a");
|
||||||
|
const groups = deriveGroupedSidebarSessions({
|
||||||
|
agentsWithProjects: agentProjects(["a1", "a2", "a3", "a4"], projectA),
|
||||||
|
projects: [projectA],
|
||||||
|
previewExpandedProjects: new Set(),
|
||||||
|
limit: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(groups[0]).toMatchObject({
|
||||||
|
visibleIds: ["a1", "a2"],
|
||||||
|
hiddenCount: 2,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
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 {
|
||||||
|
projectKeys: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SidebarSessionGroup {
|
||||||
|
projectKey: string;
|
||||||
|
projectName: string;
|
||||||
|
projectIconKey: string | null;
|
||||||
|
visibleIds: readonly string[];
|
||||||
|
hiddenCount: number;
|
||||||
|
isExpanded: boolean;
|
||||||
|
isCollapsed: boolean;
|
||||||
|
totalCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SidebarSessionAgentProject {
|
||||||
|
id: string;
|
||||||
|
projectKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_GROUPED_SESSION_LIMIT = 6;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
return workspace.projectKey === input.filter.projectKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveSidebarSessionFilterAvailability(input: {
|
||||||
|
agents: readonly SidebarSessionAgent[];
|
||||||
|
lookup: SidebarSessionWorkspaceLookup;
|
||||||
|
}): SidebarSessionFilterAvailability {
|
||||||
|
const projectKeys = new Set<string>();
|
||||||
|
|
||||||
|
for (const agent of input.agents) {
|
||||||
|
if (agent.archivedAt) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const workspace = resolveSidebarSessionWorkspace(input.lookup, agent);
|
||||||
|
if (!workspace) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
projectKeys.add(workspace.projectKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
projectKeys: Array.from(projectKeys).sort(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveSidebarSessionFilterProjects(input: {
|
||||||
|
projects: readonly SidebarProjectEntry[];
|
||||||
|
availability: SidebarSessionFilterAvailability;
|
||||||
|
}): SidebarProjectEntry[] {
|
||||||
|
if (input.projects.length === 0 || input.availability.projectKeys.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleProjectKeys = new Set(input.availability.projectKeys);
|
||||||
|
const projects: SidebarProjectEntry[] = [];
|
||||||
|
|
||||||
|
for (const project of input.projects) {
|
||||||
|
if (!visibleProjectKeys.has(project.projectKey)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
projects.push(project);
|
||||||
|
}
|
||||||
|
|
||||||
|
return projects;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveGroupedSidebarSessions(input: {
|
||||||
|
agentsWithProjects: readonly SidebarSessionAgentProject[];
|
||||||
|
projects: readonly SidebarProjectEntry[];
|
||||||
|
previewExpandedProjects?: ReadonlySet<string>;
|
||||||
|
collapsedProjectKeys?: ReadonlySet<string>;
|
||||||
|
limit?: number;
|
||||||
|
}): readonly SidebarSessionGroup[] {
|
||||||
|
const limit = input.limit ?? DEFAULT_GROUPED_SESSION_LIMIT;
|
||||||
|
const previewExpandedProjects = input.previewExpandedProjects ?? EMPTY_KEY_SET;
|
||||||
|
const collapsedProjectKeys = input.collapsedProjectKeys ?? EMPTY_KEY_SET;
|
||||||
|
|
||||||
|
const idsByProject = new Map<string, string[]>();
|
||||||
|
for (const agent of input.agentsWithProjects) {
|
||||||
|
let ids = idsByProject.get(agent.projectKey);
|
||||||
|
if (!ids) {
|
||||||
|
ids = [];
|
||||||
|
idsByProject.set(agent.projectKey, ids);
|
||||||
|
}
|
||||||
|
ids.push(agent.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups: SidebarSessionGroup[] = [];
|
||||||
|
for (const project of input.projects) {
|
||||||
|
const ids = idsByProject.get(project.projectKey);
|
||||||
|
if (!ids || ids.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const isCollapsed = collapsedProjectKeys.has(project.projectKey);
|
||||||
|
const isExpanded = !isCollapsed && previewExpandedProjects.has(project.projectKey);
|
||||||
|
groups.push({
|
||||||
|
projectKey: project.projectKey,
|
||||||
|
projectName: project.projectName,
|
||||||
|
projectIconKey: project.iconWorkingDir || null,
|
||||||
|
visibleIds: visibleIdsFor({ ids, isCollapsed, isExpanded, limit }),
|
||||||
|
hiddenCount: hiddenCountFor({ totalCount: ids.length, isCollapsed, isExpanded, limit }),
|
||||||
|
isExpanded,
|
||||||
|
isCollapsed,
|
||||||
|
totalCount: ids.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
function visibleIdsFor(input: {
|
||||||
|
ids: readonly string[];
|
||||||
|
isCollapsed: boolean;
|
||||||
|
isExpanded: boolean;
|
||||||
|
limit: number;
|
||||||
|
}): readonly string[] {
|
||||||
|
if (input.isCollapsed) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (input.isExpanded) {
|
||||||
|
return input.ids;
|
||||||
|
}
|
||||||
|
return input.ids.slice(0, input.limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hiddenCountFor(input: {
|
||||||
|
totalCount: number;
|
||||||
|
isCollapsed: boolean;
|
||||||
|
isExpanded: boolean;
|
||||||
|
limit: number;
|
||||||
|
}): number {
|
||||||
|
if (input.isCollapsed || input.isExpanded) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return Math.max(0, input.totalCount - input.limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_KEY_SET: ReadonlySet<string> = new Set();
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
/**
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
*/
|
||||||
|
import { cleanup, fireEvent, render } from "@testing-library/react";
|
||||||
|
import React from "react";
|
||||||
|
import { Pressable, Text, View } from "react-native";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { SidebarSessionRowKebabMenu } from "./session-row-actions";
|
||||||
|
|
||||||
|
vi.hoisted(() => {
|
||||||
|
Object.assign(globalThis, { __DEV__: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
const { archiveAgentMock, platformState } = vi.hoisted(() => ({
|
||||||
|
archiveAgentMock: vi.fn(),
|
||||||
|
platformState: { isNative: false },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/hooks/use-archive-agent", () => ({
|
||||||
|
useArchiveAgent: () => ({
|
||||||
|
archiveAgent: archiveAgentMock,
|
||||||
|
isArchivingAgent: () => false,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/constants/platform", () => ({
|
||||||
|
get isNative() {
|
||||||
|
return platformState.isNative;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/ui/dropdown-menu", () => ({
|
||||||
|
DropdownMenu: ({ children }: { children: React.ReactNode }) => <View>{children}</View>,
|
||||||
|
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <View>{children}</View>,
|
||||||
|
DropdownMenuItem: ({
|
||||||
|
children,
|
||||||
|
onSelect,
|
||||||
|
testID,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
onSelect?: () => void;
|
||||||
|
testID?: string;
|
||||||
|
}) => (
|
||||||
|
<Pressable testID={testID} onPress={onSelect}>
|
||||||
|
<Text>{children}</Text>
|
||||||
|
</Pressable>
|
||||||
|
),
|
||||||
|
DropdownMenuTrigger: ({
|
||||||
|
children,
|
||||||
|
testID,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode | ((state: { hovered: boolean }) => React.ReactNode);
|
||||||
|
testID?: string;
|
||||||
|
}) => (
|
||||||
|
<Pressable testID={testID}>
|
||||||
|
{typeof children === "function" ? children({ hovered: false }) : children}
|
||||||
|
</Pressable>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("lucide-react-native", () => {
|
||||||
|
const createIcon = (name: string) =>
|
||||||
|
function Icon({ uniProps: _uniProps, ...props }: Record<string, unknown>) {
|
||||||
|
return React.createElement("span", { ...props, "data-icon": name });
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
Archive: createIcon("Archive"),
|
||||||
|
MoreVertical: createIcon("MoreVertical"),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("react-native-unistyles", () => ({
|
||||||
|
StyleSheet: {
|
||||||
|
create: (styles: unknown) =>
|
||||||
|
typeof styles === "function"
|
||||||
|
? styles({
|
||||||
|
colors: {
|
||||||
|
foreground: "#111111",
|
||||||
|
foregroundMuted: "#666666",
|
||||||
|
surface2: "#eeeeee",
|
||||||
|
},
|
||||||
|
fontSize: { xs: 12 },
|
||||||
|
})
|
||||||
|
: styles,
|
||||||
|
},
|
||||||
|
withUnistyles: (Component: React.ComponentType<Record<string, unknown>>) =>
|
||||||
|
function ThemedComponent(props: Record<string, unknown>) {
|
||||||
|
return <Component {...props} />;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const SERVER_ID = "server-1";
|
||||||
|
const AGENT_ID = "agent-1";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
archiveAgentMock.mockReset();
|
||||||
|
archiveAgentMock.mockResolvedValue(undefined);
|
||||||
|
platformState.isNative = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SidebarSessionRowKebabMenu", () => {
|
||||||
|
it("renders when hovered and hides on non-touch idle rows", () => {
|
||||||
|
const hidden = render(
|
||||||
|
<SidebarSessionRowKebabMenu serverId={SERVER_ID} agentId={AGENT_ID} isHovered={false} />,
|
||||||
|
);
|
||||||
|
expect(hidden.queryByTestId(`sidebar-session-kebab-${SERVER_ID}-${AGENT_ID}`)).toBeNull();
|
||||||
|
hidden.unmount();
|
||||||
|
|
||||||
|
const visible = render(
|
||||||
|
<SidebarSessionRowKebabMenu serverId={SERVER_ID} agentId={AGENT_ID} isHovered />,
|
||||||
|
);
|
||||||
|
expect(visible.getByTestId(`sidebar-session-kebab-${SERVER_ID}-${AGENT_ID}`)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders on touch platforms without hover", () => {
|
||||||
|
platformState.isNative = true;
|
||||||
|
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<SidebarSessionRowKebabMenu serverId={SERVER_ID} agentId={AGENT_ID} isHovered={false} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByTestId(`sidebar-session-kebab-${SERVER_ID}-${AGENT_ID}`)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("archives the agent from the archive item", () => {
|
||||||
|
const { getAllByTestId, getByTestId, queryByTestId } = render(
|
||||||
|
<SidebarSessionRowKebabMenu serverId={SERVER_ID} agentId={AGENT_ID} isHovered />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getAllByTestId(/^sidebar-session-menu-server-1-agent-1-/)).toHaveLength(1);
|
||||||
|
expect(queryByTestId(`sidebar-session-menu-${SERVER_ID}-${AGENT_ID}-close`)).toBeNull();
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId(`sidebar-session-menu-${SERVER_ID}-${AGENT_ID}-archive`));
|
||||||
|
|
||||||
|
expect(archiveAgentMock).toHaveBeenCalledWith({ serverId: SERVER_ID, agentId: AGENT_ID });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import React, { memo, useCallback, type ReactElement } from "react";
|
||||||
|
import { type PressableStateCallbackType } from "react-native";
|
||||||
|
import { Archive, MoreVertical } from "lucide-react-native";
|
||||||
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { isNative as isTouchPlatform } from "@/constants/platform";
|
||||||
|
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
||||||
|
import type { Theme } from "@/styles/theme";
|
||||||
|
|
||||||
|
export interface SidebarSessionRowKebabMenuProps {
|
||||||
|
serverId: string;
|
||||||
|
agentId: string;
|
||||||
|
isHovered: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemedArchive = withUnistyles(Archive);
|
||||||
|
const ThemedMoreVertical = withUnistyles(MoreVertical);
|
||||||
|
|
||||||
|
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||||
|
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
});
|
||||||
|
|
||||||
|
const archiveLeadingIcon = <ThemedArchive size={14} uniProps={foregroundMutedColorMapping} />;
|
||||||
|
|
||||||
|
function kebabButtonStyle({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) {
|
||||||
|
return [styles.kebabButton, hovered && styles.kebabButtonHovered];
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) {
|
||||||
|
return (
|
||||||
|
<ThemedMoreVertical
|
||||||
|
size={14}
|
||||||
|
uniProps={hovered ? foregroundColorMapping : foregroundMutedColorMapping}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SidebarSessionRowKebabMenu = memo(function SidebarSessionRowKebabMenu({
|
||||||
|
serverId,
|
||||||
|
agentId,
|
||||||
|
isHovered,
|
||||||
|
}: SidebarSessionRowKebabMenuProps): ReactElement | null {
|
||||||
|
const visible = isHovered || isTouchPlatform;
|
||||||
|
const { archiveAgent } = useArchiveAgent();
|
||||||
|
|
||||||
|
const handleArchiveAgent = useCallback(() => {
|
||||||
|
void archiveAgent({ serverId, agentId }).catch(() => {});
|
||||||
|
}, [agentId, archiveAgent, serverId]);
|
||||||
|
|
||||||
|
if (!visible) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
hitSlop={8}
|
||||||
|
style={kebabButtonStyle}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Session actions"
|
||||||
|
testID={`sidebar-session-kebab-${serverId}-${agentId}`}
|
||||||
|
>
|
||||||
|
{renderKebabTriggerIcon}
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" width={220}>
|
||||||
|
<DropdownMenuItem
|
||||||
|
testID={`sidebar-session-menu-${serverId}-${agentId}-archive`}
|
||||||
|
leading={archiveLeadingIcon}
|
||||||
|
onSelect={handleArchiveAgent}
|
||||||
|
>
|
||||||
|
Archive
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
kebabButton: {
|
||||||
|
padding: 2,
|
||||||
|
borderRadius: 4,
|
||||||
|
marginLeft: 2,
|
||||||
|
},
|
||||||
|
kebabButtonHovered: {
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
/**
|
||||||
|
* @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: { full: 999, lg: 8 },
|
||||||
|
colors: {
|
||||||
|
foreground: "#111111",
|
||||||
|
foregroundMuted: "#666666",
|
||||||
|
palette: {
|
||||||
|
amber: { 500: "#f59e0b", 700: "#b45309" },
|
||||||
|
blue: { 500: "#3b82f6" },
|
||||||
|
green: { 500: "#22c55e" },
|
||||||
|
red: { 500: "#ef4444" },
|
||||||
|
},
|
||||||
|
surface0: "#ffffff",
|
||||||
|
surface2: "#eeeeee",
|
||||||
|
surfaceSidebarHover: "#f5f5f5",
|
||||||
|
},
|
||||||
|
colorScheme: "light",
|
||||||
|
fontSize: { sm: 14, xs: 12 },
|
||||||
|
fontWeight: { medium: "500" },
|
||||||
|
iconSize: { md: 20, sm: 16 },
|
||||||
|
shadow: { md: {} },
|
||||||
|
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("./session-row-actions", () => ({
|
||||||
|
SidebarSessionRowKebabMenu: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/sidebar/sidebar-project-trailing-actions", () => ({
|
||||||
|
SidebarProjectTrailingActions: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("expo-router", () => ({
|
||||||
|
router: {
|
||||||
|
navigate: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("lucide-react-native", () => {
|
||||||
|
const createIcon = (name: string) =>
|
||||||
|
function Icon({ uniProps: _uniProps, ...props }: Record<string, unknown>) {
|
||||||
|
return React.createElement("span", { ...props, "data-icon": name });
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
Archive: createIcon("Archive"),
|
||||||
|
ChevronDown: createIcon("ChevronDown"),
|
||||||
|
ChevronRight: createIcon("ChevronRight"),
|
||||||
|
FolderPlus: createIcon("FolderPlus"),
|
||||||
|
MoreVertical: createIcon("MoreVertical"),
|
||||||
|
Settings: createIcon("Settings"),
|
||||||
|
Trash2: createIcon("Trash2"),
|
||||||
|
X: createIcon("X"),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@/screens/workspace/workspace-tab-presentation", async () => {
|
||||||
|
const ReactModule = await import("react");
|
||||||
|
return {
|
||||||
|
WorkspaceTabPresentationResolver: ({ children }: { children: (presentation: {}) => unknown }) =>
|
||||||
|
children({}),
|
||||||
|
WorkspaceTabIcon: () =>
|
||||||
|
ReactModule.createElement("span", { "data-testid": "workspace-tab-icon" }),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("react-native-draggable-flatlist", async () => {
|
||||||
|
const ReactModule = await import("react");
|
||||||
|
return {
|
||||||
|
NestableScrollContainer: ({ children }: { children?: React.ReactNode }) =>
|
||||||
|
ReactModule.createElement("div", null, children),
|
||||||
|
default: () => null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("react-native-reanimated", () => ({
|
||||||
|
default: {
|
||||||
|
View: "div",
|
||||||
|
},
|
||||||
|
Easing: {
|
||||||
|
linear: vi.fn(),
|
||||||
|
},
|
||||||
|
makeMutable: (value: unknown) => ({ value }),
|
||||||
|
useAnimatedStyle: (factory: () => unknown) => factory(),
|
||||||
|
withRepeat: (value: unknown) => value,
|
||||||
|
withTiming: (value: unknown) => value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-native-unistyles", () => ({
|
||||||
|
StyleSheet: {
|
||||||
|
create: (styles: (theme: unknown) => unknown) => styles(testTheme),
|
||||||
|
},
|
||||||
|
withUnistyles: (Component: unknown) => Component,
|
||||||
|
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}
|
||||||
|
groupByProject={false}
|
||||||
|
previewExpandedProjects={new Set()}
|
||||||
|
collapsedProjectKeys={new Set()}
|
||||||
|
onProjectPreviewExpandedToggle={vi.fn()}
|
||||||
|
onProjectCollapsedToggle={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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}
|
||||||
|
groupByProject={false}
|
||||||
|
previewExpandedProjects={new Set()}
|
||||||
|
collapsedProjectKeys={new Set()}
|
||||||
|
onProjectPreviewExpandedToggle={vi.fn()}
|
||||||
|
onProjectCollapsedToggle={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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,427 @@
|
|||||||
|
import {
|
||||||
|
Folder,
|
||||||
|
Folders,
|
||||||
|
LayoutList,
|
||||||
|
ListFilter,
|
||||||
|
MessagesSquare,
|
||||||
|
Plus,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react-native";
|
||||||
|
import { memo, useCallback, useMemo, type ReactElement } from "react";
|
||||||
|
import { Image, Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
||||||
|
import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated";
|
||||||
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
||||||
|
import { useProjectIconQuery } from "@/hooks/use-project-icon-query";
|
||||||
|
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[];
|
||||||
|
groupByProject: boolean;
|
||||||
|
onModeChange: (mode: SidebarSessionViewMode) => void;
|
||||||
|
onFilterChange: (filter: SidebarSessionFilter) => void;
|
||||||
|
onGroupByProjectChange: (next: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PILL_TRANSITION = LinearTransition.duration(160);
|
||||||
|
const LABEL_FADE = { in: FadeIn.duration(120), out: FadeOut.duration(80) };
|
||||||
|
|
||||||
|
export const SidebarSessionsToggle = memo(function SidebarSessionsToggle({
|
||||||
|
serverId,
|
||||||
|
mode,
|
||||||
|
filter,
|
||||||
|
projects,
|
||||||
|
groupByProject,
|
||||||
|
onModeChange,
|
||||||
|
onFilterChange,
|
||||||
|
onGroupByProjectChange,
|
||||||
|
}: 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"
|
||||||
|
icon={Folders}
|
||||||
|
active={!sessionsActive}
|
||||||
|
onPress={handleWorkspacesPress}
|
||||||
|
testID="sidebar-toggle-workspaces"
|
||||||
|
/>
|
||||||
|
<TogglePill
|
||||||
|
label="Sessions"
|
||||||
|
icon={MessagesSquare}
|
||||||
|
active={sessionsActive}
|
||||||
|
onPress={handleSessionsPress}
|
||||||
|
testID="sidebar-toggle-sessions"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
{sessionsActive ? (
|
||||||
|
<SidebarSessionsFilterMenu
|
||||||
|
serverId={serverId}
|
||||||
|
filter={filter}
|
||||||
|
projects={projects}
|
||||||
|
groupByProject={groupByProject}
|
||||||
|
onFilterChange={onFilterChange}
|
||||||
|
onGroupByProjectChange={onGroupByProjectChange}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
function SidebarSessionsFilterMenu({
|
||||||
|
serverId,
|
||||||
|
filter,
|
||||||
|
projects,
|
||||||
|
groupByProject,
|
||||||
|
onFilterChange,
|
||||||
|
onGroupByProjectChange,
|
||||||
|
}: {
|
||||||
|
serverId: string | null;
|
||||||
|
filter: SidebarSessionFilter;
|
||||||
|
projects: readonly SidebarProjectEntry[];
|
||||||
|
groupByProject: boolean;
|
||||||
|
onFilterChange: (filter: SidebarSessionFilter) => void;
|
||||||
|
onGroupByProjectChange: (next: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
const filterActive = filter.type !== "all";
|
||||||
|
const visibleFilterProjects = useVisibleSidebarSessionFilterProjects({ serverId, projects });
|
||||||
|
const newSessionIntent = useMemo(
|
||||||
|
() => ({ kind: "new-workspace", headerTitle: "New session" }) as const,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const openNewSessionPicker = useOpenProjectPicker(serverId, newSessionIntent);
|
||||||
|
|
||||||
|
const handleAllSelect = useCallback(() => {
|
||||||
|
onFilterChange({ type: "all" });
|
||||||
|
}, [onFilterChange]);
|
||||||
|
|
||||||
|
const handleNewSessionPress = useCallback(() => {
|
||||||
|
void openNewSessionPicker();
|
||||||
|
}, [openNewSessionPicker]);
|
||||||
|
|
||||||
|
const newSessionButtonStyle = useCallback(
|
||||||
|
({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||||
|
styles.filterButton,
|
||||||
|
(hovered || pressed) && styles.filterButtonHovered,
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const filterTriggerStyle = useCallback(
|
||||||
|
({ hovered = false, open = false }: PressableStateCallbackType & { open?: boolean }) => [
|
||||||
|
styles.filterButton,
|
||||||
|
(hovered || open || filterActive) && styles.filterButtonHovered,
|
||||||
|
],
|
||||||
|
[filterActive],
|
||||||
|
);
|
||||||
|
|
||||||
|
const filterIcon = useMemo(
|
||||||
|
() => (
|
||||||
|
<ListFilter
|
||||||
|
size={theme.iconSize.sm}
|
||||||
|
color={filterActive ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[filterActive, theme.colors.foreground, theme.colors.foregroundMuted, theme.iconSize.sm],
|
||||||
|
);
|
||||||
|
const handleGroupByProjectPress = useCallback(() => {
|
||||||
|
onGroupByProjectChange(!groupByProject);
|
||||||
|
}, [groupByProject, onGroupByProjectChange]);
|
||||||
|
const groupByProjectStyle = useCallback(
|
||||||
|
({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||||
|
styles.filterButton,
|
||||||
|
(hovered || groupByProject) && styles.filterButtonHovered,
|
||||||
|
],
|
||||||
|
[groupByProject],
|
||||||
|
);
|
||||||
|
const groupByProjectIcon = useMemo(
|
||||||
|
() => (
|
||||||
|
<LayoutList
|
||||||
|
size={theme.iconSize.sm}
|
||||||
|
color={groupByProject ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[groupByProject, theme.colors.foreground, theme.colors.foregroundMuted, theme.iconSize.sm],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.sessionActions}>
|
||||||
|
<Pressable
|
||||||
|
accessibilityLabel="New session"
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={handleNewSessionPress}
|
||||||
|
style={newSessionButtonStyle}
|
||||||
|
testID="sidebar-new-session-button"
|
||||||
|
>
|
||||||
|
{({ hovered, pressed }) => (
|
||||||
|
<Plus
|
||||||
|
size={theme.iconSize.sm}
|
||||||
|
color={hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
accessibilityLabel="Filter sessions"
|
||||||
|
accessibilityRole="button"
|
||||||
|
style={filterTriggerStyle}
|
||||||
|
testID="sidebar-sessions-filter-trigger"
|
||||||
|
>
|
||||||
|
{filterIcon}
|
||||||
|
{filterActive ? <View style={styles.filterBadge} /> : null}
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" width={260} scrollable maxHeight={420}>
|
||||||
|
<DropdownMenuItem selected={filter.type === "all"} onSelect={handleAllSelect}>
|
||||||
|
All
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{visibleFilterProjects.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<FilterSectionHeading>Project</FilterSectionHeading>
|
||||||
|
{visibleFilterProjects.map((project) => (
|
||||||
|
<ProjectFilterItem
|
||||||
|
key={project.projectKey}
|
||||||
|
selected={filter.type === "project" && filter.projectKey === project.projectKey}
|
||||||
|
label={project.projectName}
|
||||||
|
projectKey={project.projectKey}
|
||||||
|
serverId={serverId}
|
||||||
|
iconWorkingDir={project.iconWorkingDir}
|
||||||
|
onFilterChange={onFilterChange}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
<Pressable
|
||||||
|
accessibilityLabel="Group by project"
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={handleGroupByProjectPress}
|
||||||
|
style={groupByProjectStyle}
|
||||||
|
testID="sidebar-toggle-group-by-project"
|
||||||
|
>
|
||||||
|
{groupByProjectIcon}
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TogglePill({
|
||||||
|
label,
|
||||||
|
icon: Icon,
|
||||||
|
active,
|
||||||
|
onPress,
|
||||||
|
testID,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
active: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
testID?: string;
|
||||||
|
}) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
const iconColor = active ? theme.colors.foreground : theme.colors.foregroundMuted;
|
||||||
|
const pillStyleActive = useMemo(() => [styles.pill, styles.pillActive], []);
|
||||||
|
const pillStyleHovered = useMemo(() => [styles.pill, styles.pillHovered], []);
|
||||||
|
const renderInner = useCallback(
|
||||||
|
({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => {
|
||||||
|
let pillStyle;
|
||||||
|
if (active) pillStyle = pillStyleActive;
|
||||||
|
else if (hovered) pillStyle = pillStyleHovered;
|
||||||
|
else pillStyle = styles.pill;
|
||||||
|
return (
|
||||||
|
<Animated.View layout={PILL_TRANSITION} style={pillStyle}>
|
||||||
|
<Icon size={theme.iconSize.sm} color={iconColor} />
|
||||||
|
{active ? (
|
||||||
|
<Animated.Text
|
||||||
|
entering={LABEL_FADE.in}
|
||||||
|
exiting={LABEL_FADE.out}
|
||||||
|
style={styles.pillTextActive}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Animated.Text>
|
||||||
|
) : null}
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[Icon, active, iconColor, label, pillStyleActive, pillStyleHovered, theme.iconSize.sm],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={label}
|
||||||
|
onPress={onPress}
|
||||||
|
testID={testID}
|
||||||
|
>
|
||||||
|
{renderInner}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FilterSectionHeading({ children }: { children: string }) {
|
||||||
|
return (
|
||||||
|
<View style={styles.filterSectionHeading}>
|
||||||
|
<Text style={styles.filterSectionHeadingText}>{children}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProjectFilterItem({
|
||||||
|
selected,
|
||||||
|
label,
|
||||||
|
projectKey,
|
||||||
|
serverId,
|
||||||
|
iconWorkingDir,
|
||||||
|
onFilterChange,
|
||||||
|
}: {
|
||||||
|
selected: boolean;
|
||||||
|
label: string;
|
||||||
|
projectKey: string;
|
||||||
|
serverId: string | null;
|
||||||
|
iconWorkingDir: string;
|
||||||
|
onFilterChange: (filter: SidebarSessionFilter) => void;
|
||||||
|
}) {
|
||||||
|
const handleSelect = useCallback(() => {
|
||||||
|
onFilterChange({ type: "project", projectKey });
|
||||||
|
}, [onFilterChange, projectKey]);
|
||||||
|
|
||||||
|
const leading = useMemo(
|
||||||
|
() => <ProjectIconLeading serverId={serverId} cwd={iconWorkingDir} />,
|
||||||
|
[serverId, iconWorkingDir],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem selected={selected} leading={leading} onSelect={handleSelect}>
|
||||||
|
{label}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProjectIconLeading({ serverId, cwd }: { serverId: string | null; cwd: string }) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
const { icon } = useProjectIconQuery({ serverId: serverId ?? "", cwd });
|
||||||
|
const dataUri = useMemo(() => {
|
||||||
|
if (!icon || !icon.mimeType || !icon.data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return `data:${icon.mimeType};base64,${icon.data}`;
|
||||||
|
}, [icon]);
|
||||||
|
const imageSource = useMemo(() => (dataUri ? { uri: dataUri } : null), [dataUri]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View accessibilityElementsHidden importantForAccessibility="no-hide-descendants">
|
||||||
|
{imageSource ? (
|
||||||
|
<Image source={imageSource} style={styles.projectIconImage} />
|
||||||
|
) : (
|
||||||
|
<View style={styles.projectIconFallback}>
|
||||||
|
<Folder size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
container: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
paddingHorizontal: theme.spacing[2],
|
||||||
|
paddingTop: theme.spacing[2],
|
||||||
|
paddingBottom: theme.spacing[2],
|
||||||
|
},
|
||||||
|
pillGroup: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[1],
|
||||||
|
minWidth: 0,
|
||||||
|
},
|
||||||
|
sessionActions: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[1],
|
||||||
|
},
|
||||||
|
pill: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
minHeight: 28,
|
||||||
|
paddingHorizontal: theme.spacing[2],
|
||||||
|
borderRadius: theme.borderRadius.md,
|
||||||
|
},
|
||||||
|
pillActive: {
|
||||||
|
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||||
|
paddingRight: theme.spacing[3],
|
||||||
|
},
|
||||||
|
pillHovered: {
|
||||||
|
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||||
|
},
|
||||||
|
pillTextActive: {
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
},
|
||||||
|
filterButton: {
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
borderRadius: theme.borderRadius.md,
|
||||||
|
position: "relative",
|
||||||
|
},
|
||||||
|
filterButtonHovered: {
|
||||||
|
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||||
|
},
|
||||||
|
filterBadge: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 5,
|
||||||
|
right: 5,
|
||||||
|
width: 5,
|
||||||
|
height: 5,
|
||||||
|
borderRadius: theme.borderRadius.full,
|
||||||
|
backgroundColor: theme.colors.foreground,
|
||||||
|
},
|
||||||
|
filterSectionHeading: {
|
||||||
|
paddingHorizontal: theme.spacing[3],
|
||||||
|
paddingTop: theme.spacing[2],
|
||||||
|
paddingBottom: theme.spacing[1],
|
||||||
|
},
|
||||||
|
filterSectionHeadingText: {
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
},
|
||||||
|
projectIconImage: {
|
||||||
|
width: theme.iconSize.sm,
|
||||||
|
height: theme.iconSize.sm,
|
||||||
|
borderRadius: theme.borderRadius.sm,
|
||||||
|
},
|
||||||
|
projectIconFallback: {
|
||||||
|
width: theme.iconSize.sm,
|
||||||
|
height: theme.iconSize.sm,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||||
|
|
||||||
|
export type SidebarSessionViewMode = "workspaces" | "sessions";
|
||||||
|
|
||||||
|
export type SidebarSessionFilter = { type: "all" } | { type: "project"; projectKey: string };
|
||||||
|
|
||||||
|
export interface SidebarSessionWorkspace {
|
||||||
|
serverId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
workspaceKey: string;
|
||||||
|
workspaceName: string;
|
||||||
|
projectKey: string;
|
||||||
|
projectName: string;
|
||||||
|
projectIconKey: string | null;
|
||||||
|
workspaceDirectory: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ResolvedSidebarSessionProject = Pick<
|
||||||
|
SidebarSessionWorkspace,
|
||||||
|
"projectKey" | "projectName" | "projectIconKey"
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type SidebarSessionAgent = Pick<AggregatedAgent, "id" | "serverId" | "cwd" | "archivedAt">;
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
/**
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
*/
|
||||||
|
import { act, renderHook } from "@testing-library/react";
|
||||||
|
import type { DaemonClient } from "@server/client/daemon-client";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||||
|
import { useSessionStore, type Agent, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||||
|
import { useVisibleSidebarSessionFilterProjects } from "./use-sidebar-session-workspaces";
|
||||||
|
|
||||||
|
vi.hoisted(() => {
|
||||||
|
(globalThis as unknown as { __DEV__: boolean }).__DEV__ = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
const TIMESTAMP = new Date("2026-05-08T10:00:00.000Z");
|
||||||
|
|
||||||
|
const WORKSPACE: WorkspaceDescriptor = {
|
||||||
|
id: "workspace-1",
|
||||||
|
projectId: "project-a",
|
||||||
|
projectDisplayName: "Project A",
|
||||||
|
projectRootPath: "/abs/path/to/repo",
|
||||||
|
workspaceDirectory: "/abs/path/to/repo",
|
||||||
|
projectKind: "git",
|
||||||
|
workspaceKind: "worktree",
|
||||||
|
name: "feat-foo",
|
||||||
|
status: "done",
|
||||||
|
archivingAt: null,
|
||||||
|
diffStat: null,
|
||||||
|
scripts: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const AGENT: 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: "/abs/path/to/repo",
|
||||||
|
model: null,
|
||||||
|
thinkingOptionId: undefined,
|
||||||
|
requiresAttention: false,
|
||||||
|
attentionReason: null,
|
||||||
|
attentionTimestamp: null,
|
||||||
|
archivedAt: null,
|
||||||
|
labels: {},
|
||||||
|
projectPlacement: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const PROJECTS: SidebarProjectEntry[] = [
|
||||||
|
{
|
||||||
|
projectKey: "project-a",
|
||||||
|
projectName: "Project A",
|
||||||
|
projectKind: "git",
|
||||||
|
iconWorkingDir: "/abs/path/to/repo",
|
||||||
|
workspaces: [
|
||||||
|
{
|
||||||
|
workspaceKey: "server-1:workspace-1",
|
||||||
|
serverId: "server-1",
|
||||||
|
workspaceId: "workspace-1",
|
||||||
|
projectKey: "project-a",
|
||||||
|
projectRootPath: "/abs/path/to/repo",
|
||||||
|
workspaceDirectory: "/abs/path/to/repo",
|
||||||
|
projectKind: "git",
|
||||||
|
workspaceKind: "worktree",
|
||||||
|
name: "/abs/path/to/repo",
|
||||||
|
statusBucket: "done",
|
||||||
|
archivingAt: null,
|
||||||
|
diffStat: null,
|
||||||
|
scripts: [],
|
||||||
|
hasRunningScripts: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
useSessionStore.setState({ sessions: {}, agentLastActivity: new Map() });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useVisibleSidebarSessionFilterProjects", () => {
|
||||||
|
it("returns projects with mapped active sessions", () => {
|
||||||
|
act(() => {
|
||||||
|
useSessionStore.getState().initializeSession("server-1", {} as unknown as DaemonClient);
|
||||||
|
useSessionStore.getState().setWorkspaces("server-1", new Map([["workspace-1", WORKSPACE]]));
|
||||||
|
useSessionStore.getState().setAgents("server-1", new Map([["agent-1", AGENT]]));
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useVisibleSidebarSessionFilterProjects({
|
||||||
|
serverId: "server-1",
|
||||||
|
projects: PROJECTS,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.current.map((project) => project.projectKey)).toEqual(["project-a"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import equal from "fast-deep-equal";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||||
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
|
import {
|
||||||
|
createSidebarWorkspaceEntry,
|
||||||
|
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 = { projectKeys: [] } as const;
|
||||||
|
|
||||||
|
export function useSidebarSessionWorkspaces(input: {
|
||||||
|
serverId: string | null;
|
||||||
|
projects: readonly SidebarProjectEntry[];
|
||||||
|
}): SidebarSessionWorkspace[] {
|
||||||
|
const workspaceFieldsById = useStoreWithEqualityFn(
|
||||||
|
useSessionStore,
|
||||||
|
(state) => {
|
||||||
|
if (!input.serverId) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const workspaces = state.sessions[input.serverId]?.workspaces;
|
||||||
|
if (!workspaces || workspaces.size === 0) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldsById: Record<string, { name: string; workspaceDirectory: string | undefined }> =
|
||||||
|
{};
|
||||||
|
for (const workspace of workspaces.values()) {
|
||||||
|
const entry = createSidebarWorkspaceEntry({ serverId: input.serverId, workspace });
|
||||||
|
fieldsById[entry.workspaceId] = {
|
||||||
|
name: entry.name,
|
||||||
|
workspaceDirectory: entry.workspaceDirectory,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return fieldsById;
|
||||||
|
},
|
||||||
|
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 workspaceFields = workspaceFieldsById[workspace.workspaceId];
|
||||||
|
const workspaceDirectory = workspaceFields?.workspaceDirectory;
|
||||||
|
if (!workspaceDirectory) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
result.push({
|
||||||
|
serverId: input.serverId,
|
||||||
|
workspaceId: workspace.workspaceId,
|
||||||
|
workspaceKey: workspace.workspaceKey,
|
||||||
|
workspaceName: workspaceFields.name,
|
||||||
|
projectKey: project.projectKey,
|
||||||
|
projectName: project.projectName,
|
||||||
|
projectIconKey: project.iconWorkingDir || null,
|
||||||
|
workspaceDirectory,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [input.projects, input.serverId, workspaceFieldsById]);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,511 @@
|
|||||||
|
import React, { memo, useCallback, useMemo, useState, type ReactElement } from "react";
|
||||||
|
import {
|
||||||
|
FlatList,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
type ListRenderItem,
|
||||||
|
type PressableStateCallbackType,
|
||||||
|
} from "react-native";
|
||||||
|
import { NestableScrollContainer } from "react-native-draggable-flatlist";
|
||||||
|
import { useShallow } from "zustand/shallow";
|
||||||
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
|
import { isNative as platformIsNative } from "@/constants/platform";
|
||||||
|
import { SidebarAgentListSkeleton } from "@/components/sidebar-agent-list-skeleton";
|
||||||
|
import { sidebarProjectChildIndentStyles } from "@/components/sidebar/sidebar-collapsible-project-section";
|
||||||
|
import {
|
||||||
|
SidebarProjectsDraggableList,
|
||||||
|
type SidebarProjectDragInfo,
|
||||||
|
} from "@/components/sidebar/sidebar-projects-draggable-list";
|
||||||
|
import {
|
||||||
|
type AggregatedAgentIdEntry,
|
||||||
|
useAggregatedAgentIds,
|
||||||
|
useAggregatedAgentsInitialLoad,
|
||||||
|
} from "@/hooks/use-aggregated-agents";
|
||||||
|
import {
|
||||||
|
WorkspaceTabIcon,
|
||||||
|
WorkspaceTabPresentationResolver,
|
||||||
|
} from "@/screens/workspace/workspace-tab-presentation";
|
||||||
|
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||||
|
import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||||
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
|
import { navigateToPreparedWorkspaceTab, prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||||
|
import { formatTimeAgo } from "@/utils/time";
|
||||||
|
import {
|
||||||
|
createSidebarSessionWorkspaceLookup,
|
||||||
|
resolveSidebarSessionWorkspaceId,
|
||||||
|
resolveSidebarSessionWorkspace,
|
||||||
|
shouldIncludeSidebarSessionAgent,
|
||||||
|
type SidebarSessionGroup,
|
||||||
|
} from "./session-filtering";
|
||||||
|
import { selectSidebarSessionSlice } from "./select-sidebar-session-slice";
|
||||||
|
import type { ResolvedSidebarSessionProject, SidebarSessionFilter } from "./types";
|
||||||
|
import { useSidebarSessionWorkspaces } from "./use-sidebar-session-workspaces";
|
||||||
|
import {
|
||||||
|
GROUPED_SESSION_LIMIT,
|
||||||
|
SidebarSessionGroupFooter,
|
||||||
|
SidebarSessionGroupHeader,
|
||||||
|
useGroupedSidebarSessionGroups,
|
||||||
|
useOrderedAgentProjectShape,
|
||||||
|
} from "./grouped-view";
|
||||||
|
import { SidebarSessionRowKebabMenu } from "./session-row-actions";
|
||||||
|
|
||||||
|
interface SidebarSessionsViewProps {
|
||||||
|
serverId: string | null;
|
||||||
|
projects: readonly SidebarProjectEntry[];
|
||||||
|
filter: SidebarSessionFilter;
|
||||||
|
groupByProject: boolean;
|
||||||
|
previewExpandedProjects: ReadonlySet<string>;
|
||||||
|
collapsedProjectKeys: ReadonlySet<string>;
|
||||||
|
onProjectPreviewExpandedToggle: (projectKey: string) => void;
|
||||||
|
onProjectCollapsedToggle: (projectKey: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SidebarSessionsView({
|
||||||
|
serverId,
|
||||||
|
projects,
|
||||||
|
filter,
|
||||||
|
groupByProject,
|
||||||
|
previewExpandedProjects,
|
||||||
|
collapsedProjectKeys,
|
||||||
|
onProjectPreviewExpandedToggle,
|
||||||
|
onProjectCollapsedToggle,
|
||||||
|
}: 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",
|
||||||
|
});
|
||||||
|
const resolveCwdToProject = useCallback(
|
||||||
|
(cwd: string) => {
|
||||||
|
if (!serverId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const workspace = resolveSidebarSessionWorkspace(lookup, {
|
||||||
|
id: "",
|
||||||
|
serverId,
|
||||||
|
cwd,
|
||||||
|
archivedAt: null,
|
||||||
|
});
|
||||||
|
if (!workspace) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
projectKey: workspace.projectKey,
|
||||||
|
projectName: workspace.projectName,
|
||||||
|
projectIconKey: workspace.projectIconKey,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[lookup, serverId],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isInitialLoad) {
|
||||||
|
return <SidebarAgentListSkeleton />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (groupByProject) {
|
||||||
|
return (
|
||||||
|
<GroupedSidebarSessionsList
|
||||||
|
serverId={serverId}
|
||||||
|
sessionIds={sessionIds}
|
||||||
|
projects={projects}
|
||||||
|
resolveCwdToProject={resolveCwdToProject}
|
||||||
|
previewExpandedProjects={previewExpandedProjects}
|
||||||
|
collapsedProjectKeys={collapsedProjectKeys}
|
||||||
|
onProjectPreviewExpandedToggle={onProjectPreviewExpandedToggle}
|
||||||
|
onProjectCollapsedToggle={onProjectCollapsedToggle}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <FlatSidebarSessionsList serverId={serverId} sessionIds={sessionIds} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FlatSidebarSessionsList = memo(function FlatSidebarSessionsList({
|
||||||
|
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 GroupedSidebarSessionsList = memo(function GroupedSidebarSessionsList({
|
||||||
|
serverId,
|
||||||
|
sessionIds,
|
||||||
|
projects,
|
||||||
|
resolveCwdToProject,
|
||||||
|
previewExpandedProjects,
|
||||||
|
collapsedProjectKeys,
|
||||||
|
onProjectPreviewExpandedToggle,
|
||||||
|
onProjectCollapsedToggle,
|
||||||
|
}: {
|
||||||
|
serverId: string | null;
|
||||||
|
sessionIds: readonly string[];
|
||||||
|
projects: readonly SidebarProjectEntry[];
|
||||||
|
resolveCwdToProject: (cwd: string) => ResolvedSidebarSessionProject | null;
|
||||||
|
previewExpandedProjects: ReadonlySet<string>;
|
||||||
|
collapsedProjectKeys: ReadonlySet<string>;
|
||||||
|
onProjectPreviewExpandedToggle: (projectKey: string) => void;
|
||||||
|
onProjectCollapsedToggle: (projectKey: string) => void;
|
||||||
|
}): ReactElement {
|
||||||
|
const agentsWithProjects = useOrderedAgentProjectShape({
|
||||||
|
orderedIds: sessionIds,
|
||||||
|
serverId: serverId ?? "",
|
||||||
|
resolveCwdToProject,
|
||||||
|
});
|
||||||
|
const groups = useGroupedSidebarSessionGroups({
|
||||||
|
agentsWithProjects,
|
||||||
|
projects,
|
||||||
|
previewExpandedProjects,
|
||||||
|
collapsedProjectKeys,
|
||||||
|
});
|
||||||
|
|
||||||
|
const groupsByKey = useMemo(() => {
|
||||||
|
const map = new Map<string, SidebarSessionGroup>();
|
||||||
|
for (const group of groups) {
|
||||||
|
map.set(group.projectKey, group);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [groups]);
|
||||||
|
|
||||||
|
const projectsWithSessions = useMemo(
|
||||||
|
() => projects.filter((project) => groupsByKey.has(project.projectKey)),
|
||||||
|
[groupsByKey, projects],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderProject = useCallback(
|
||||||
|
({ project, drag, isDragging, dragHandleProps }: SidebarProjectDragInfo) => {
|
||||||
|
const group = groupsByKey.get(project.projectKey);
|
||||||
|
if (!group) {
|
||||||
|
return <View />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<SidebarSessionGroupSection
|
||||||
|
serverId={serverId}
|
||||||
|
project={project}
|
||||||
|
group={group}
|
||||||
|
drag={drag}
|
||||||
|
isDragging={isDragging}
|
||||||
|
dragHandleProps={dragHandleProps}
|
||||||
|
onProjectCollapsedToggle={onProjectCollapsedToggle}
|
||||||
|
onProjectPreviewExpandedToggle={onProjectPreviewExpandedToggle}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[groupsByKey, onProjectCollapsedToggle, onProjectPreviewExpandedToggle, serverId],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (projectsWithSessions.length === 0) {
|
||||||
|
return (
|
||||||
|
<View style={styles.list}>
|
||||||
|
<View style={styles.listContent}>
|
||||||
|
<EmptySessions />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const draggableList = (
|
||||||
|
<SidebarProjectsDraggableList
|
||||||
|
testID="sidebar-session-group-list"
|
||||||
|
projects={projectsWithSessions}
|
||||||
|
serverId={serverId}
|
||||||
|
renderProject={renderProject}
|
||||||
|
nestable={platformIsNative}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return platformIsNative ? (
|
||||||
|
<NestableScrollContainer
|
||||||
|
style={styles.list}
|
||||||
|
contentContainerStyle={styles.listContent}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
testID="sidebar-session-group-scroll"
|
||||||
|
>
|
||||||
|
{draggableList}
|
||||||
|
</NestableScrollContainer>
|
||||||
|
) : (
|
||||||
|
<ScrollView
|
||||||
|
style={styles.list}
|
||||||
|
contentContainerStyle={styles.listContent}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
testID="sidebar-session-group-scroll"
|
||||||
|
>
|
||||||
|
{draggableList}
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const SidebarSessionGroupSection = memo(function SidebarSessionGroupSection({
|
||||||
|
serverId,
|
||||||
|
project,
|
||||||
|
group,
|
||||||
|
drag,
|
||||||
|
isDragging,
|
||||||
|
dragHandleProps,
|
||||||
|
onProjectCollapsedToggle,
|
||||||
|
onProjectPreviewExpandedToggle,
|
||||||
|
}: {
|
||||||
|
serverId: string | null;
|
||||||
|
project: SidebarProjectEntry;
|
||||||
|
group: SidebarSessionGroup;
|
||||||
|
drag: () => void;
|
||||||
|
isDragging: boolean;
|
||||||
|
dragHandleProps?: SidebarProjectDragInfo["dragHandleProps"];
|
||||||
|
onProjectCollapsedToggle: (projectKey: string) => void;
|
||||||
|
onProjectPreviewExpandedToggle: (projectKey: string) => void;
|
||||||
|
}): ReactElement {
|
||||||
|
const showFooter = !group.isCollapsed && group.totalCount > GROUPED_SESSION_LIMIT;
|
||||||
|
const footer = useMemo(
|
||||||
|
() =>
|
||||||
|
showFooter ? (
|
||||||
|
<SidebarSessionGroupFooter
|
||||||
|
projectKey={group.projectKey}
|
||||||
|
hiddenCount={group.hiddenCount}
|
||||||
|
isExpanded={group.isExpanded}
|
||||||
|
onPress={onProjectPreviewExpandedToggle}
|
||||||
|
/>
|
||||||
|
) : null,
|
||||||
|
[
|
||||||
|
group.hiddenCount,
|
||||||
|
group.isExpanded,
|
||||||
|
group.projectKey,
|
||||||
|
onProjectPreviewExpandedToggle,
|
||||||
|
showFooter,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<SidebarSessionGroupHeader
|
||||||
|
serverId={serverId}
|
||||||
|
projectKey={group.projectKey}
|
||||||
|
projectName={group.projectName}
|
||||||
|
projectIconKey={group.projectIconKey}
|
||||||
|
workspaces={project.workspaces}
|
||||||
|
isCollapsed={group.isCollapsed}
|
||||||
|
isDragging={isDragging}
|
||||||
|
drag={drag}
|
||||||
|
dragHandleProps={dragHandleProps}
|
||||||
|
onToggleCollapsed={onProjectCollapsedToggle}
|
||||||
|
footer={footer}
|
||||||
|
>
|
||||||
|
{serverId
|
||||||
|
? group.visibleIds.map((id) => (
|
||||||
|
<SidebarSessionRow key={id} id={id} serverId={serverId} indented />
|
||||||
|
))
|
||||||
|
: null}
|
||||||
|
</SidebarSessionGroupHeader>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const SidebarSessionRow = memo(function SidebarSessionRow({
|
||||||
|
id,
|
||||||
|
serverId,
|
||||||
|
indented = false,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
serverId: string;
|
||||||
|
indented?: boolean;
|
||||||
|
}): ReactElement | null {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
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 handlePointerEnter = useCallback(() => setIsHovered(true), []);
|
||||||
|
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
|
||||||
|
|
||||||
|
const pressableStyle = useCallback(
|
||||||
|
({ pressed }: PressableStateCallbackType) => [
|
||||||
|
styles.row,
|
||||||
|
indented && sidebarProjectChildIndentStyles.childRow,
|
||||||
|
isHovered && styles.rowHovered,
|
||||||
|
pressed && styles.rowPressed,
|
||||||
|
],
|
||||||
|
[indented, isHovered],
|
||||||
|
);
|
||||||
|
|
||||||
|
const titleStyle = useMemo(() => [styles.title, isHovered && styles.titleHovered], [isHovered]);
|
||||||
|
const tabDescriptor = useMemo<WorkspaceTabDescriptor>(
|
||||||
|
() => ({
|
||||||
|
key: `agent_${id}`,
|
||||||
|
tabId: `agent_${id}`,
|
||||||
|
kind: "agent",
|
||||||
|
target: { kind: "agent", agentId: id },
|
||||||
|
}),
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!agent || agent.archivedAt) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View onPointerEnter={handlePointerEnter} onPointerLeave={handlePointerLeave}>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={agent.title || "New session"}
|
||||||
|
onPress={handlePress}
|
||||||
|
style={pressableStyle}
|
||||||
|
testID={`sidebar-session-row-${serverId}-${id}`}
|
||||||
|
>
|
||||||
|
<View style={styles.providerIconWrap}>
|
||||||
|
<WorkspaceTabPresentationResolver
|
||||||
|
tab={tabDescriptor}
|
||||||
|
serverId={serverId}
|
||||||
|
workspaceId="sidebar-sessions"
|
||||||
|
>
|
||||||
|
{(presentation) => (
|
||||||
|
<WorkspaceTabIcon
|
||||||
|
presentation={presentation}
|
||||||
|
active={isHovered}
|
||||||
|
size={theme.iconSize.sm}
|
||||||
|
statusDotBorderColor={isHovered ? theme.colors.surfaceSidebarHover : undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</WorkspaceTabPresentationResolver>
|
||||||
|
</View>
|
||||||
|
<Text style={titleStyle} numberOfLines={1}>
|
||||||
|
{agent.title || "New session"}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.timeAgo} numberOfLines={1}>
|
||||||
|
{formatTimeAgo(agent.lastActivityAt)}
|
||||||
|
</Text>
|
||||||
|
<SidebarSessionRowKebabMenu serverId={serverId} agentId={id} isHovered={isHovered} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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: theme.spacing[2],
|
||||||
|
paddingTop: theme.spacing[2],
|
||||||
|
paddingBottom: theme.spacing[4],
|
||||||
|
},
|
||||||
|
row: {
|
||||||
|
minHeight: 36,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
paddingVertical: theme.spacing[2],
|
||||||
|
paddingHorizontal: theme.spacing[2],
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
marginBottom: theme.spacing[1],
|
||||||
|
},
|
||||||
|
rowHovered: {
|
||||||
|
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||||
|
},
|
||||||
|
rowPressed: {
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
},
|
||||||
|
providerIconWrap: {
|
||||||
|
width: theme.iconSize.md,
|
||||||
|
height: theme.iconSize.md,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
fontWeight: "400",
|
||||||
|
lineHeight: 20,
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
opacity: 0.76,
|
||||||
|
},
|
||||||
|
titleHovered: {
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
timeAgo: {
|
||||||
|
flexShrink: 0,
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
},
|
||||||
|
emptyState: {
|
||||||
|
paddingHorizontal: theme.spacing[2],
|
||||||
|
paddingVertical: theme.spacing[6],
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -5,6 +5,7 @@ beforeEach(() => {
|
|||||||
useKeyboardShortcutsStore.setState({
|
useKeyboardShortcutsStore.setState({
|
||||||
commandCenterOpen: false,
|
commandCenterOpen: false,
|
||||||
projectPickerOpen: false,
|
projectPickerOpen: false,
|
||||||
|
projectPickerIntent: { kind: "open-project" },
|
||||||
shortcutsDialogOpen: false,
|
shortcutsDialogOpen: false,
|
||||||
capturingShortcut: false,
|
capturingShortcut: false,
|
||||||
altDown: false,
|
altDown: false,
|
||||||
@@ -25,4 +26,23 @@ describe("keyboard-shortcuts-store", () => {
|
|||||||
useKeyboardShortcutsStore.getState().setCapturingShortcut(true);
|
useKeyboardShortcutsStore.getState().setCapturingShortcut(true);
|
||||||
expect(useKeyboardShortcutsStore.getState().capturingShortcut).toBe(true);
|
expect(useKeyboardShortcutsStore.getState().capturingShortcut).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("stores and resets the project picker intent", () => {
|
||||||
|
useKeyboardShortcutsStore
|
||||||
|
.getState()
|
||||||
|
.setProjectPickerOpen(true, { kind: "new-workspace", headerTitle: "New session" });
|
||||||
|
|
||||||
|
expect(useKeyboardShortcutsStore.getState().projectPickerOpen).toBe(true);
|
||||||
|
expect(useKeyboardShortcutsStore.getState().projectPickerIntent).toEqual({
|
||||||
|
kind: "new-workspace",
|
||||||
|
headerTitle: "New session",
|
||||||
|
});
|
||||||
|
|
||||||
|
useKeyboardShortcutsStore.getState().setProjectPickerOpen(false);
|
||||||
|
|
||||||
|
expect(useKeyboardShortcutsStore.getState().projectPickerOpen).toBe(false);
|
||||||
|
expect(useKeyboardShortcutsStore.getState().projectPickerIntent).toEqual({
|
||||||
|
kind: "open-project",
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,9 +3,14 @@ import type { SidebarShortcutWorkspaceTarget } from "@/utils/sidebar-shortcuts";
|
|||||||
|
|
||||||
const SHORTCUT_BADGE_DELAY_MS = 150;
|
const SHORTCUT_BADGE_DELAY_MS = 150;
|
||||||
|
|
||||||
|
export type ProjectPickerIntent =
|
||||||
|
| { kind: "open-project" }
|
||||||
|
| { kind: "new-workspace"; headerTitle: string };
|
||||||
|
|
||||||
interface KeyboardShortcutsState {
|
interface KeyboardShortcutsState {
|
||||||
commandCenterOpen: boolean;
|
commandCenterOpen: boolean;
|
||||||
projectPickerOpen: boolean;
|
projectPickerOpen: boolean;
|
||||||
|
projectPickerIntent: ProjectPickerIntent;
|
||||||
shortcutsDialogOpen: boolean;
|
shortcutsDialogOpen: boolean;
|
||||||
capturingShortcut: boolean;
|
capturingShortcut: boolean;
|
||||||
altDown: boolean;
|
altDown: boolean;
|
||||||
@@ -15,7 +20,7 @@ interface KeyboardShortcutsState {
|
|||||||
sidebarShortcutWorkspaceTargets: SidebarShortcutWorkspaceTarget[];
|
sidebarShortcutWorkspaceTargets: SidebarShortcutWorkspaceTarget[];
|
||||||
|
|
||||||
setCommandCenterOpen: (open: boolean) => void;
|
setCommandCenterOpen: (open: boolean) => void;
|
||||||
setProjectPickerOpen: (open: boolean) => void;
|
setProjectPickerOpen: (open: boolean, intent?: ProjectPickerIntent) => void;
|
||||||
setShortcutsDialogOpen: (open: boolean) => void;
|
setShortcutsDialogOpen: (open: boolean) => void;
|
||||||
setCapturingShortcut: (capturing: boolean) => void;
|
setCapturingShortcut: (capturing: boolean) => void;
|
||||||
setAltDown: (down: boolean) => void;
|
setAltDown: (down: boolean) => void;
|
||||||
@@ -50,6 +55,7 @@ function updateBadgeTimer(
|
|||||||
export const useKeyboardShortcutsStore = create<KeyboardShortcutsState>((set, get) => ({
|
export const useKeyboardShortcutsStore = create<KeyboardShortcutsState>((set, get) => ({
|
||||||
commandCenterOpen: false,
|
commandCenterOpen: false,
|
||||||
projectPickerOpen: false,
|
projectPickerOpen: false,
|
||||||
|
projectPickerIntent: { kind: "open-project" },
|
||||||
shortcutsDialogOpen: false,
|
shortcutsDialogOpen: false,
|
||||||
capturingShortcut: false,
|
capturingShortcut: false,
|
||||||
altDown: false,
|
altDown: false,
|
||||||
@@ -58,7 +64,11 @@ export const useKeyboardShortcutsStore = create<KeyboardShortcutsState>((set, ge
|
|||||||
sidebarShortcutWorkspaceTargets: [],
|
sidebarShortcutWorkspaceTargets: [],
|
||||||
|
|
||||||
setCommandCenterOpen: (open) => set({ commandCenterOpen: open }),
|
setCommandCenterOpen: (open) => set({ commandCenterOpen: open }),
|
||||||
setProjectPickerOpen: (open) => set({ projectPickerOpen: open }),
|
setProjectPickerOpen: (open, intent) =>
|
||||||
|
set({
|
||||||
|
projectPickerOpen: open,
|
||||||
|
projectPickerIntent: open ? (intent ?? { kind: "open-project" }) : { kind: "open-project" },
|
||||||
|
}),
|
||||||
setShortcutsDialogOpen: (open) => set({ shortcutsDialogOpen: open }),
|
setShortcutsDialogOpen: (open) => set({ shortcutsDialogOpen: open }),
|
||||||
setCapturingShortcut: (capturing) => set({ capturingShortcut: capturing }),
|
setCapturingShortcut: (capturing) => set({ capturingShortcut: capturing }),
|
||||||
setAltDown: (down) => {
|
setAltDown: (down) => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
buildHostAgentDetailRoute,
|
buildHostAgentDetailRoute,
|
||||||
|
buildHostNewWorkspaceRoute,
|
||||||
buildHostRootRoute,
|
buildHostRootRoute,
|
||||||
buildHostWorkspaceOpenRoute,
|
buildHostWorkspaceOpenRoute,
|
||||||
buildHostWorkspaceRoute,
|
buildHostWorkspaceRoute,
|
||||||
@@ -160,3 +161,14 @@ describe("projects settings routes", () => {
|
|||||||
expect(decodeURIComponent(segment)).toBe(projectKey);
|
expect(decodeURIComponent(segment)).toBe(projectKey);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("new workspace route", () => {
|
||||||
|
it("can carry a header title separately from the project display name", () => {
|
||||||
|
expect(
|
||||||
|
buildHostNewWorkspaceRoute("server-1", "/repo", {
|
||||||
|
displayName: "Project",
|
||||||
|
headerTitle: "New session",
|
||||||
|
}),
|
||||||
|
).toBe("/h/server-1/new?dir=%2Frepo&name=Project&title=New+session");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -358,7 +358,7 @@ export function buildHostOpenProjectRoute(serverId: string) {
|
|||||||
export function buildHostNewWorkspaceRoute(
|
export function buildHostNewWorkspaceRoute(
|
||||||
serverId: string,
|
serverId: string,
|
||||||
sourceDirectory: string,
|
sourceDirectory: string,
|
||||||
options?: { displayName?: string },
|
options?: { displayName?: string; headerTitle?: string },
|
||||||
) {
|
) {
|
||||||
const base = buildHostRootRoute(serverId);
|
const base = buildHostRootRoute(serverId);
|
||||||
if (base === "/") {
|
if (base === "/") {
|
||||||
@@ -369,6 +369,9 @@ export function buildHostNewWorkspaceRoute(
|
|||||||
if (options?.displayName) {
|
if (options?.displayName) {
|
||||||
params.set("name", options.displayName);
|
params.set("name", options.displayName);
|
||||||
}
|
}
|
||||||
|
if (options?.headerTitle) {
|
||||||
|
params.set("title", options.headerTitle);
|
||||||
|
}
|
||||||
return `${base}/new?${params.toString()}` as const;
|
return `${base}/new?${params.toString()}` as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user