mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bcd32962f7 | ||
|
|
d00bd666a7 | ||
|
|
4f047832ac | ||
|
|
a3c3d25592 | ||
|
|
b252cd98d2 | ||
|
|
670e6a6fdd | ||
|
|
e9c3753466 | ||
|
|
9c1bea7135 |
@@ -15,6 +15,7 @@ export interface ArchiveTabAgent {
|
||||
id: string;
|
||||
title: string;
|
||||
cwd: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface ArchiveTabDaemonClient {
|
||||
@@ -33,9 +34,9 @@ interface ArchiveTabDaemonClient {
|
||||
waitForFinish(agentId: string, timeout?: number): Promise<{ status: string }>;
|
||||
waitForAgentUpsert(
|
||||
agentId: string,
|
||||
predicate: (snapshot: { status: string }) => boolean,
|
||||
predicate: (snapshot: { status: string; createdAt: string }) => boolean,
|
||||
timeout?: number,
|
||||
): Promise<{ status: string }>;
|
||||
): Promise<{ status: string; createdAt: string }>;
|
||||
fetchAgentHistory(options?: {
|
||||
page?: { limit: number };
|
||||
}): Promise<{ entries: Array<{ id: string }> }>;
|
||||
@@ -132,6 +133,7 @@ export async function createIdleAgent(
|
||||
id: created.id,
|
||||
title: input.title,
|
||||
cwd: input.cwd,
|
||||
createdAt: snapshot.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -257,7 +259,7 @@ export async function reloadWorkspace(page: Page, workspaceId: string): Promise<
|
||||
}
|
||||
|
||||
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 sessionsButton.click();
|
||||
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.
|
||||
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> {
|
||||
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
|
||||
---
|
||||
# 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:
|
||||
when:
|
||||
visible:
|
||||
id: "sidebar-sessions"
|
||||
id: "sidebar-history"
|
||||
commands:
|
||||
- swipe:
|
||||
direction: LEFT
|
||||
@@ -21,7 +21,7 @@ appId: sh.paseo
|
||||
|
||||
# Verify sidebar opened
|
||||
- assertVisible:
|
||||
id: "sidebar-sessions"
|
||||
id: "sidebar-history"
|
||||
- takeScreenshot: 01-sidebar-opened
|
||||
|
||||
# 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 { Stack, useGlobalSearchParams, usePathname, useRouter } from "expo-router";
|
||||
import {
|
||||
default as React,
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
@@ -65,7 +66,6 @@ import { polyfillCrypto } from "@/polyfills/crypto";
|
||||
import { queryClient } from "@/query/query-client";
|
||||
import {
|
||||
getHostRuntimeStore,
|
||||
hasConfiguredLocalDaemonOverride,
|
||||
useHostMutations,
|
||||
useHostRuntimeClient,
|
||||
useHosts,
|
||||
@@ -312,7 +312,7 @@ async function shouldStartBuiltInDaemon(): Promise<boolean> {
|
||||
return settings.daemon.manageBuiltInDaemon;
|
||||
}
|
||||
|
||||
function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
||||
export function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
||||
useEffect(() => {
|
||||
const store = getHostRuntimeStore();
|
||||
const daemonStartService = getDaemonStartService({ store });
|
||||
@@ -327,16 +327,12 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
||||
const anyOnlineHostServerId = useEarliestOnlineHostServerId();
|
||||
const daemonStartError = useDaemonStartLastError();
|
||||
const daemonStartIsRunning = useDaemonStartIsRunning();
|
||||
const waitForConfiguredLocalDaemon =
|
||||
hasConfiguredLocalDaemonOverride() && !shouldUseDesktopDaemon();
|
||||
|
||||
const [hasGivenUpWaitingForHost, setHasGivenUpWaitingForHost] = useState(false);
|
||||
useEffect(() => {
|
||||
if (
|
||||
anyOnlineHostServerId ||
|
||||
daemonStartError ||
|
||||
daemonStartIsRunning ||
|
||||
waitForConfiguredLocalDaemon ||
|
||||
hasGivenUpWaitingForHost
|
||||
) {
|
||||
return;
|
||||
@@ -347,13 +343,7 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
||||
return () => {
|
||||
clearTimeout(handle);
|
||||
};
|
||||
}, [
|
||||
anyOnlineHostServerId,
|
||||
daemonStartError,
|
||||
daemonStartIsRunning,
|
||||
waitForConfiguredLocalDaemon,
|
||||
hasGivenUpWaitingForHost,
|
||||
]);
|
||||
}, [anyOnlineHostServerId, daemonStartError, daemonStartIsRunning, hasGivenUpWaitingForHost]);
|
||||
|
||||
const retry = useCallback(() => {
|
||||
const daemonStartService = getDaemonStartService({ store: getHostRuntimeStore() });
|
||||
|
||||
@@ -2,10 +2,16 @@ import { useLocalSearchParams } from "expo-router";
|
||||
import { NewWorkspaceScreen } from "@/screens/new-workspace-screen";
|
||||
|
||||
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 sourceDirectory = typeof params.dir === "string" ? params.dir : "";
|
||||
const displayName = typeof params.name === "string" ? params.name : undefined;
|
||||
const headerTitle = typeof params.title === "string" ? params.title : undefined;
|
||||
|
||||
if (!sourceDirectory) {
|
||||
return null;
|
||||
@@ -16,6 +22,7 @@ export default function HostNewWorkspaceRoute() {
|
||||
serverId={serverId}
|
||||
sourceDirectory={sourceDirectory}
|
||||
displayName={displayName}
|
||||
headerTitle={headerTitle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { router, usePathname } from "expo-router";
|
||||
import { FolderPlus, MessagesSquare, Settings } from "lucide-react-native";
|
||||
import { FolderPlus, Settings } from "lucide-react-native";
|
||||
import {
|
||||
type Dispatch,
|
||||
memo,
|
||||
@@ -32,8 +32,9 @@ import Animated, {
|
||||
} from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
|
||||
import { SidebarHistoryNavigationRow } from "@/components/sidebar/sidebar-history-navigation-row";
|
||||
import { SidebarShellHeader } from "@/components/sidebar/sidebar-shell-header";
|
||||
import { SidebarWorkspacesView } from "@/components/sidebar/sidebar-workspaces-view";
|
||||
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
@@ -62,9 +63,14 @@ import {
|
||||
buildSettingsRoute,
|
||||
mapPathnameToServer,
|
||||
} from "@/utils/host-routes";
|
||||
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
|
||||
import {
|
||||
SidebarSessionsToggle,
|
||||
SidebarSessionsView,
|
||||
type SidebarSessionFilter,
|
||||
type SidebarSessionViewMode,
|
||||
useSidebarSessionsController,
|
||||
} from "@/sidebar-sessions";
|
||||
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
|
||||
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
|
||||
|
||||
const MIN_CHAT_WIDTH = 400;
|
||||
|
||||
@@ -80,6 +86,14 @@ interface SidebarSharedProps {
|
||||
activeServerId: string | null;
|
||||
activeHostLabel: 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[];
|
||||
hostTriggerRef: RefObject<View | null>;
|
||||
isHostPickerOpen: boolean;
|
||||
@@ -182,6 +196,16 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
);
|
||||
const hostTriggerRef = useRef<View | null>(null);
|
||||
const [isHostPickerOpen, setIsHostPickerOpen] = useState(false);
|
||||
const {
|
||||
sidebarViewMode,
|
||||
setSidebarViewMode,
|
||||
sidebarSessionFilter,
|
||||
setSidebarSessionFilter,
|
||||
groupByProject,
|
||||
previewExpandedProjects,
|
||||
setGroupByProject,
|
||||
toggleProjectPreviewExpanded,
|
||||
} = useSidebarSessionsController({ serverId: activeServerId });
|
||||
|
||||
const { projects, isInitialLoad, isRevalidating, refreshAll } = useSidebarWorkspacesList({
|
||||
serverId: activeServerId,
|
||||
@@ -247,6 +271,14 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
activeServerId,
|
||||
activeHostLabel,
|
||||
activeHostStatusColor,
|
||||
sidebarViewMode,
|
||||
setSidebarViewMode,
|
||||
sidebarSessionFilter,
|
||||
setSidebarSessionFilter,
|
||||
groupByProject,
|
||||
previewExpandedProjects,
|
||||
setGroupByProject,
|
||||
toggleProjectPreviewExpanded,
|
||||
hostOptions,
|
||||
hostTriggerRef,
|
||||
isHostPickerOpen,
|
||||
@@ -485,6 +517,14 @@ function MobileSidebar({
|
||||
activeServerId,
|
||||
activeHostLabel,
|
||||
activeHostStatusColor,
|
||||
sidebarViewMode,
|
||||
setSidebarViewMode,
|
||||
sidebarSessionFilter,
|
||||
setSidebarSessionFilter,
|
||||
groupByProject,
|
||||
previewExpandedProjects,
|
||||
setGroupByProject,
|
||||
toggleProjectPreviewExpanded,
|
||||
hostOptions,
|
||||
hostTriggerRef,
|
||||
isHostPickerOpen,
|
||||
@@ -507,8 +547,6 @@ function MobileSidebar({
|
||||
closeToAgent,
|
||||
handleViewMoreNavigate,
|
||||
}: MobileSidebarProps) {
|
||||
const pathname = usePathname();
|
||||
const isSessionsActive = pathname.includes("/sessions");
|
||||
const {
|
||||
translateX,
|
||||
backdropOpacity,
|
||||
@@ -664,6 +702,30 @@ function MobileSidebar({
|
||||
],
|
||||
[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 (
|
||||
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
|
||||
@@ -672,29 +734,35 @@ function MobileSidebar({
|
||||
<GestureDetector gesture={closeGesture} touchAction="pan-y">
|
||||
<Animated.View style={mobileSidebarStyle} pointerEvents="auto">
|
||||
<View style={styles.sidebarContent} pointerEvents="auto">
|
||||
<SidebarHeaderRow
|
||||
icon={MessagesSquare}
|
||||
label="Sessions"
|
||||
onPress={handleViewMore}
|
||||
isActive={isSessionsActive}
|
||||
testID="sidebar-sessions"
|
||||
/>
|
||||
<SidebarShellHeader toggleSlot={toggleSlot}>
|
||||
<SidebarHistoryNavigationRow onPress={handleViewMore} />
|
||||
</SidebarShellHeader>
|
||||
|
||||
{isInitialLoad ? (
|
||||
<SidebarAgentListSkeleton />
|
||||
) : (
|
||||
<SidebarWorkspaceList
|
||||
{sidebarViewMode === "workspaces" ? (
|
||||
<SidebarWorkspacesView
|
||||
serverId={activeServerId}
|
||||
collapsedProjectKeys={collapsedProjectKeys}
|
||||
onToggleProjectCollapsed={toggleProjectCollapsed}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
projects={projects}
|
||||
isInitialLoad={isInitialLoad}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
onWorkspacePress={handleWorkspacePress}
|
||||
onAddProject={handleOpenProject}
|
||||
parentGestureRef={closeGestureRef}
|
||||
/>
|
||||
) : (
|
||||
<SidebarSessionsView
|
||||
serverId={activeServerId}
|
||||
projects={projects}
|
||||
filter={sidebarSessionFilter}
|
||||
groupByProject={groupByProject}
|
||||
previewExpandedProjects={previewExpandedProjects}
|
||||
collapsedProjectKeys={collapsedProjectKeys}
|
||||
onProjectPreviewExpandedToggle={toggleProjectPreviewExpanded}
|
||||
onProjectCollapsedToggle={toggleProjectCollapsed}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SidebarFooter
|
||||
@@ -723,6 +791,14 @@ function DesktopSidebar({
|
||||
activeServerId,
|
||||
activeHostLabel,
|
||||
activeHostStatusColor,
|
||||
sidebarViewMode,
|
||||
setSidebarViewMode,
|
||||
sidebarSessionFilter,
|
||||
setSidebarSessionFilter,
|
||||
groupByProject,
|
||||
previewExpandedProjects,
|
||||
setGroupByProject,
|
||||
toggleProjectPreviewExpanded,
|
||||
hostOptions,
|
||||
hostTriggerRef,
|
||||
isHostPickerOpen,
|
||||
@@ -743,8 +819,6 @@ function DesktopSidebar({
|
||||
isOpen,
|
||||
handleViewMore,
|
||||
}: DesktopSidebarProps) {
|
||||
const pathname = usePathname();
|
||||
const isSessionsActive = pathname.includes("/sessions");
|
||||
const padding = useWindowControlsPadding("sidebar");
|
||||
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
|
||||
const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth);
|
||||
@@ -799,6 +873,30 @@ function DesktopSidebar({
|
||||
() => [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) {
|
||||
return null;
|
||||
@@ -807,31 +905,37 @@ function DesktopSidebar({
|
||||
return (
|
||||
<Animated.View style={desktopSidebarStyle}>
|
||||
<View style={desktopSidebarBorderStyle}>
|
||||
<View style={styles.sidebarDragArea}>
|
||||
<TitlebarDragRegion />
|
||||
{padding.top > 0 ? <View style={paddingTopSpacerStyle} /> : null}
|
||||
<SidebarHeaderRow
|
||||
icon={MessagesSquare}
|
||||
label="Sessions"
|
||||
onPress={handleViewMore}
|
||||
isActive={isSessionsActive}
|
||||
testID="sidebar-sessions"
|
||||
/>
|
||||
</View>
|
||||
<SidebarShellHeader
|
||||
topSpacerStyle={padding.top > 0 ? paddingTopSpacerStyle : undefined}
|
||||
toggleSlot={toggleSlot}
|
||||
withTitlebarDragRegion
|
||||
>
|
||||
<SidebarHistoryNavigationRow onPress={handleViewMore} />
|
||||
</SidebarShellHeader>
|
||||
|
||||
{isInitialLoad ? (
|
||||
<SidebarAgentListSkeleton />
|
||||
) : (
|
||||
<SidebarWorkspaceList
|
||||
{sidebarViewMode === "workspaces" ? (
|
||||
<SidebarWorkspacesView
|
||||
serverId={activeServerId}
|
||||
collapsedProjectKeys={collapsedProjectKeys}
|
||||
onToggleProjectCollapsed={toggleProjectCollapsed}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
projects={projects}
|
||||
isInitialLoad={isInitialLoad}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
onAddProject={handleOpenProject}
|
||||
/>
|
||||
) : (
|
||||
<SidebarSessionsView
|
||||
serverId={activeServerId}
|
||||
projects={projects}
|
||||
filter={sidebarSessionFilter}
|
||||
groupByProject={groupByProject}
|
||||
previewExpandedProjects={previewExpandedProjects}
|
||||
collapsedProjectKeys={collapsedProjectKeys}
|
||||
onProjectPreviewExpandedToggle={toggleProjectPreviewExpanded}
|
||||
onProjectCollapsedToggle={toggleProjectCollapsed}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SidebarCalloutSlot />
|
||||
@@ -898,9 +1002,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
width: 10,
|
||||
zIndex: 10,
|
||||
},
|
||||
sidebarDragArea: {
|
||||
position: "relative",
|
||||
},
|
||||
hostTrigger: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import { Folder } from "lucide-react-native";
|
||||
import { router } from "expo-router";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
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 { isNative } from "@/constants/platform";
|
||||
import { useActiveServerId } from "@/hooks/use-active-server-id";
|
||||
import { buildHostNewWorkspaceRoute } from "@/utils/host-routes";
|
||||
|
||||
interface PathRowProps {
|
||||
path: string;
|
||||
@@ -63,6 +65,7 @@ export function ProjectPickerModal() {
|
||||
const serverId = useActiveServerId();
|
||||
|
||||
const open = useKeyboardShortcutsStore((s) => s.projectPickerOpen);
|
||||
const intent = useKeyboardShortcutsStore((s) => s.projectPickerIntent);
|
||||
const setOpen = useKeyboardShortcutsStore((s) => s.setProjectPickerOpen);
|
||||
|
||||
const client = useHostRuntimeClient(serverId ?? "");
|
||||
@@ -115,6 +118,16 @@ export function ProjectPickerModal() {
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
if (intent.kind === "new-workspace") {
|
||||
router.navigate(
|
||||
buildHostNewWorkspaceRoute(serverId, trimmed, {
|
||||
headerTitle: intent.headerTitle,
|
||||
}) as never,
|
||||
);
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const didOpenProject = await openProject(trimmed);
|
||||
if (didOpenProject) {
|
||||
setOpen(false);
|
||||
@@ -123,7 +136,7 @@ export function ProjectPickerModal() {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
},
|
||||
[client, openProject, serverId, setOpen],
|
||||
[client, intent, openProject, serverId, setOpen],
|
||||
);
|
||||
|
||||
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",
|
||||
gap: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
},
|
||||
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 { useShallow } from "zustand/shallow";
|
||||
import { shallow, useShallow } from "zustand/shallow";
|
||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import type { AgentDirectoryEntry } from "@/types/agent-directory";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
@@ -18,12 +19,151 @@ export interface AggregatedAgentsResult {
|
||||
refreshAll: () => void;
|
||||
}
|
||||
|
||||
export type AggregatedAgentSortOption = "createdAt-desc-stable" | "running-then-activity";
|
||||
|
||||
export interface AggregatedAgentIdEntry {
|
||||
id: string;
|
||||
serverId: string;
|
||||
status: Agent["status"];
|
||||
lastActivityAt: Date;
|
||||
cwd: string;
|
||||
archivedAt: Date | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
interface AggregatedAgentSortFields {
|
||||
id: string;
|
||||
status: Agent["status"];
|
||||
lastActivityAt: Date;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
function compareAggregatedAgentsByCreatedAtDescStable(
|
||||
left: AggregatedAgentSortFields,
|
||||
right: AggregatedAgentSortFields,
|
||||
): number {
|
||||
const createdAtDiff = right.createdAt.getTime() - left.createdAt.getTime();
|
||||
if (createdAtDiff !== 0) {
|
||||
return createdAtDiff;
|
||||
}
|
||||
return left.id.localeCompare(right.id);
|
||||
}
|
||||
|
||||
function compareAggregatedAgentsByRunningThenActivity(
|
||||
left: AggregatedAgentSortFields,
|
||||
right: AggregatedAgentSortFields,
|
||||
): number {
|
||||
const leftRunning = left.status === "running";
|
||||
const rightRunning = right.status === "running";
|
||||
if (leftRunning && !rightRunning) {
|
||||
return -1;
|
||||
}
|
||||
if (!leftRunning && rightRunning) {
|
||||
return 1;
|
||||
}
|
||||
const leftTime = left.lastActivityAt.getTime();
|
||||
const rightTime = right.lastActivityAt.getTime();
|
||||
return rightTime - leftTime;
|
||||
}
|
||||
|
||||
function sortAggregatedAgents(
|
||||
agents: AggregatedAgent[],
|
||||
sort: AggregatedAgentSortOption,
|
||||
): AggregatedAgent[] {
|
||||
agents.sort(
|
||||
sort === "running-then-activity"
|
||||
? compareAggregatedAgentsByRunningThenActivity
|
||||
: compareAggregatedAgentsByCreatedAtDescStable,
|
||||
);
|
||||
return agents;
|
||||
}
|
||||
|
||||
function sortAggregatedAgentIdEntries(
|
||||
agents: AggregatedAgentIdEntry[],
|
||||
sort: AggregatedAgentSortOption,
|
||||
): AggregatedAgentIdEntry[] {
|
||||
agents.sort(
|
||||
sort === "running-then-activity"
|
||||
? compareAggregatedAgentsByRunningThenActivity
|
||||
: compareAggregatedAgentsByCreatedAtDescStable,
|
||||
);
|
||||
return agents;
|
||||
}
|
||||
|
||||
export function useAggregatedAgentIds(options?: {
|
||||
includeArchived?: boolean;
|
||||
sort?: AggregatedAgentSortOption;
|
||||
filter?: (agent: AggregatedAgentIdEntry) => boolean;
|
||||
}): string[] {
|
||||
const includeArchived = options?.includeArchived ?? false;
|
||||
const sort = options?.sort ?? "createdAt-desc-stable";
|
||||
const filter = options?.filter;
|
||||
|
||||
return useStoreWithEqualityFn(
|
||||
useSessionStore,
|
||||
(state) => {
|
||||
const agents: AggregatedAgentIdEntry[] = [];
|
||||
for (const [serverId, session] of Object.entries(state.sessions)) {
|
||||
for (const agent of session.agents.values()) {
|
||||
if (!includeArchived && agent.archivedAt) {
|
||||
continue;
|
||||
}
|
||||
const entry: AggregatedAgentIdEntry = {
|
||||
id: agent.id,
|
||||
serverId,
|
||||
status: agent.status,
|
||||
lastActivityAt: agent.lastActivityAt,
|
||||
cwd: agent.cwd,
|
||||
archivedAt: agent.archivedAt ?? null,
|
||||
createdAt: agent.createdAt,
|
||||
};
|
||||
if (!filter || filter(entry)) {
|
||||
agents.push(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
return sortAggregatedAgentIdEntries(agents, sort).map((agent) => agent.id);
|
||||
},
|
||||
shallow,
|
||||
);
|
||||
}
|
||||
|
||||
export function useAggregatedAgentsInitialLoad(): boolean {
|
||||
const daemons = useHosts();
|
||||
const runtime = getHostRuntimeStore();
|
||||
const runtimeVersion = useSyncExternalStore(
|
||||
(onStoreChange) => runtime.subscribeAll(onStoreChange),
|
||||
() => runtime.getVersion(),
|
||||
() => runtime.getVersion(),
|
||||
);
|
||||
const agentCount = useSessionStore((state) => {
|
||||
let count = 0;
|
||||
for (const session of Object.values(state.sessions)) {
|
||||
for (const agent of session.agents.values()) {
|
||||
if (!agent.archivedAt) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return count;
|
||||
});
|
||||
|
||||
void runtimeVersion;
|
||||
const isLoading = daemons.some((daemon) => {
|
||||
const status = runtime.getSnapshot(daemon.serverId)?.agentDirectoryStatus ?? "initial_loading";
|
||||
return status === "initial_loading" || status === "revalidating";
|
||||
});
|
||||
return isLoading && agentCount === 0;
|
||||
}
|
||||
|
||||
export function useAggregatedAgents(options?: {
|
||||
includeArchived?: boolean;
|
||||
sort?: AggregatedAgentSortOption;
|
||||
}): AggregatedAgentsResult {
|
||||
const daemons = useHosts();
|
||||
const runtime = getHostRuntimeStore();
|
||||
const includeArchived = options?.includeArchived ?? false;
|
||||
const sort = options?.sort ?? "createdAt-desc-stable";
|
||||
const runtimeVersion = useSyncExternalStore(
|
||||
(onStoreChange) => runtime.subscribeAll(onStoreChange),
|
||||
() => runtime.getVersion(),
|
||||
@@ -83,20 +223,7 @@ export function useAggregatedAgents(options?: {
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by: running agents first, then by most recent activity
|
||||
allAgents.sort((left, right) => {
|
||||
const leftRunning = left.status === "running";
|
||||
const rightRunning = right.status === "running";
|
||||
if (leftRunning && !rightRunning) {
|
||||
return -1;
|
||||
}
|
||||
if (!leftRunning && rightRunning) {
|
||||
return 1;
|
||||
}
|
||||
const leftTime = left.lastActivityAt.getTime();
|
||||
const rightTime = right.lastActivityAt.getTime();
|
||||
return rightTime - leftTime;
|
||||
});
|
||||
sortAggregatedAgents(allAgents, sort);
|
||||
|
||||
// Check if we have any cached data
|
||||
const hasAnyData = allAgents.length > 0;
|
||||
@@ -116,7 +243,7 @@ export function useAggregatedAgents(options?: {
|
||||
isInitialLoad,
|
||||
isRevalidating,
|
||||
};
|
||||
}, [daemons, includeArchived, runtime, runtimeVersion, sessionAgents]);
|
||||
}, [daemons, includeArchived, runtime, runtimeVersion, sessionAgents, sort]);
|
||||
|
||||
return {
|
||||
...result,
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { __private__ } from "./use-all-agents-list";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
|
||||
vi.mock("@/runtime/host-runtime", () => ({
|
||||
getHostRuntimeStore: () => ({
|
||||
refreshAgentDirectory: vi.fn(),
|
||||
}),
|
||||
useHostRuntimeConnectionStatus: () => "online",
|
||||
useHostRuntimeIsDirectoryLoading: () => false,
|
||||
useHosts: () => [{ serverId: "server-1", label: "Local" }],
|
||||
}));
|
||||
|
||||
const AGENT_TIMESTAMP = new Date("2026-03-08T10:00:00.000Z");
|
||||
|
||||
const AGENT_DEFAULTS: Agent = {
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
import { useCallback } from "react";
|
||||
import { router } from "expo-router";
|
||||
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 { 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 isLocalDaemon = useIsLocalDaemon(normalizedServerId);
|
||||
const setProjectPickerOpen = useKeyboardShortcutsStore((state) => state.setProjectPickerOpen);
|
||||
@@ -16,7 +26,7 @@ export function useOpenProjectPicker(serverId: string | null): () => Promise<voi
|
||||
}
|
||||
|
||||
if (!isLocalDaemon) {
|
||||
setProjectPickerOpen(true);
|
||||
setProjectPickerOpen(true, intent);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -25,6 +35,15 @@ export function useOpenProjectPicker(serverId: string | null): () => Promise<voi
|
||||
return;
|
||||
}
|
||||
|
||||
if (intent.kind === "new-workspace") {
|
||||
router.navigate(
|
||||
buildHostNewWorkspaceRoute(normalizedServerId, path, {
|
||||
headerTitle: intent.headerTitle,
|
||||
}) as never,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await openProject(path);
|
||||
}, [isLocalDaemon, normalizedServerId, openProject, setProjectPickerOpen]);
|
||||
}, [intent, isLocalDaemon, normalizedServerId, openProject, setProjectPickerOpen]);
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ interface NewWorkspaceScreenProps {
|
||||
serverId: string;
|
||||
sourceDirectory: string;
|
||||
displayName?: string;
|
||||
headerTitle?: string;
|
||||
}
|
||||
|
||||
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 {
|
||||
const {
|
||||
serverId,
|
||||
@@ -385,6 +391,7 @@ export function NewWorkspaceScreen({
|
||||
serverId,
|
||||
sourceDirectory,
|
||||
displayName: displayNameProp,
|
||||
headerTitle: headerTitleProp,
|
||||
}: NewWorkspaceScreenProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
@@ -412,6 +419,7 @@ export function NewWorkspaceScreen({
|
||||
}, [pickerSearchQuery]);
|
||||
|
||||
const displayName = displayNameProp?.trim() ?? "";
|
||||
const headerTitle = resolveNewWorkspaceHeaderTitle(headerTitleProp);
|
||||
const workspace = createdWorkspace;
|
||||
const selectedItem = pickerSelection?.item ?? null;
|
||||
const isPending = pendingAction !== null;
|
||||
@@ -711,7 +719,7 @@ export function NewWorkspaceScreen({
|
||||
<SidebarMenuToggle />
|
||||
<View style={styles.headerTitleContainer}>
|
||||
<Text style={styles.headerTitle} numberOfLines={1}>
|
||||
New workspace
|
||||
{headerTitle}
|
||||
</Text>
|
||||
<Text style={styles.headerProjectTitle} numberOfLines={1}>
|
||||
{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({
|
||||
commandCenterOpen: false,
|
||||
projectPickerOpen: false,
|
||||
projectPickerIntent: { kind: "open-project" },
|
||||
shortcutsDialogOpen: false,
|
||||
capturingShortcut: false,
|
||||
altDown: false,
|
||||
@@ -25,4 +26,23 @@ describe("keyboard-shortcuts-store", () => {
|
||||
useKeyboardShortcutsStore.getState().setCapturingShortcut(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;
|
||||
|
||||
export type ProjectPickerIntent =
|
||||
| { kind: "open-project" }
|
||||
| { kind: "new-workspace"; headerTitle: string };
|
||||
|
||||
interface KeyboardShortcutsState {
|
||||
commandCenterOpen: boolean;
|
||||
projectPickerOpen: boolean;
|
||||
projectPickerIntent: ProjectPickerIntent;
|
||||
shortcutsDialogOpen: boolean;
|
||||
capturingShortcut: boolean;
|
||||
altDown: boolean;
|
||||
@@ -15,7 +20,7 @@ interface KeyboardShortcutsState {
|
||||
sidebarShortcutWorkspaceTargets: SidebarShortcutWorkspaceTarget[];
|
||||
|
||||
setCommandCenterOpen: (open: boolean) => void;
|
||||
setProjectPickerOpen: (open: boolean) => void;
|
||||
setProjectPickerOpen: (open: boolean, intent?: ProjectPickerIntent) => void;
|
||||
setShortcutsDialogOpen: (open: boolean) => void;
|
||||
setCapturingShortcut: (capturing: boolean) => void;
|
||||
setAltDown: (down: boolean) => void;
|
||||
@@ -50,6 +55,7 @@ function updateBadgeTimer(
|
||||
export const useKeyboardShortcutsStore = create<KeyboardShortcutsState>((set, get) => ({
|
||||
commandCenterOpen: false,
|
||||
projectPickerOpen: false,
|
||||
projectPickerIntent: { kind: "open-project" },
|
||||
shortcutsDialogOpen: false,
|
||||
capturingShortcut: false,
|
||||
altDown: false,
|
||||
@@ -58,7 +64,11 @@ export const useKeyboardShortcutsStore = create<KeyboardShortcutsState>((set, ge
|
||||
sidebarShortcutWorkspaceTargets: [],
|
||||
|
||||
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 }),
|
||||
setCapturingShortcut: (capturing) => set({ capturingShortcut: capturing }),
|
||||
setAltDown: (down) => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildHostAgentDetailRoute,
|
||||
buildHostNewWorkspaceRoute,
|
||||
buildHostRootRoute,
|
||||
buildHostWorkspaceOpenRoute,
|
||||
buildHostWorkspaceRoute,
|
||||
@@ -160,3 +161,14 @@ describe("projects settings routes", () => {
|
||||
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(
|
||||
serverId: string,
|
||||
sourceDirectory: string,
|
||||
options?: { displayName?: string },
|
||||
options?: { displayName?: string; headerTitle?: string },
|
||||
) {
|
||||
const base = buildHostRootRoute(serverId);
|
||||
if (base === "/") {
|
||||
@@ -369,6 +369,9 @@ export function buildHostNewWorkspaceRoute(
|
||||
if (options?.displayName) {
|
||||
params.set("name", options.displayName);
|
||||
}
|
||||
if (options?.headerTitle) {
|
||||
params.set("title", options.headerTitle);
|
||||
}
|
||||
return `${base}/new?${params.toString()}` as const;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user