Compare commits

...
Author SHA1 Message Date
Mohamed Boudra bcd32962f7 sidebar history: post-merge fixes (header, kebab, +) and deeper abstractions
Three behavior fixes from user feedback after the parity pass landed.

R1 — Non-git project headers were missing in grouped Sessions while their
child rows still rendered. Sessions had inherited the workspace tree's
isSidebarProjectFlattened branch, but only half of it: the header was
suppressed and the children continued. Sessions doesn't share the
workspace tree's flatten semantics — workspaces flatten because
"1 workspace = the project IS the workspace, no children to expand";
in Sessions, even a single-workspace non-git project can still have
multiple agents worth grouping. Drop the flatten branch from Sessions
entirely. SidebarSessionGroup loses isFlattened. Workspace tree's
FlattenedProjectRow stays untouched.

R2 — The agent kebab had Close + Archive. Sessions agent rows aren't
tabs; there's no open tab to close. Drop Close and the orphan chain
that came with it (workspace-tab menu imports, session store reach,
confirm dialog wiring, resolveSidebarSessionWorkspaceId, etc.). The
menu is now Archive-only; useArchiveAgent is the only handler.

R3 — Each project header in grouped Sessions now renders a "+" that
opens the new-workspace flow scoped to that project's directory. The
shared SidebarProjectTrailingActions is the home — Sessions wires its
own createButton config (Plus icon, "New session", route via
buildHostNewWorkspaceRoute(serverId, sourceDirectory, headerTitle)).

R4 — Meta-audit on the shared sidebar/* abstractions found two depth
issues that let R1 and R3 silently slip through:

1. SidebarProjectHeaderRow was a chrome primitive. Consumers could
   render children without rendering a header (R1 bug). Added
   SidebarProjectSection — an opinionated wrapper that owns
   "header + children/footer when expanded" so the silent failure
   mode no longer compiles. Both consumers refactored.

2. SidebarProjectTrailingActions had showNewWorktreeButton: boolean
   defaulting false. Sessions silently lost the affordance with no
   warning (R3 bug). Replaced the boolean with a required createButton
   prop (literal null to opt out). Each consumer must say what it
   wants — no default suppression.

Surfaced for future iterations (not fixed here): hand-wired hover
state in both consumers, chevron native visibility, missing
project/agent selection state in Sessions, zero-agent-project filter
divergence.

Tests cover the new wrapper, the trailing-actions API change, the
header-always-renders behavior, and the per-project + button
navigation. Workspace tree behavior unchanged.
2026-05-08 23:04:27 +07:00
Mohamed Boudra d00bd666a7 sidebar history: parity with workspace tree (ordering, drag, kebabs, +)
Stops reinventing UX the workspace tree already solved. Five
behavior-preserving extractions plus a few wires.

Project ordering parity:
Sessions consumes the canonical project list from useSidebarWorkspaces
List instead of computing first-occurrence order from agents. The
workspace tree's compareWorkspaceStructureProjects + persisted manual
order via useSidebarOrderStore is the single source of truth for both
views. Single non-git workspace projects flatten (no header, no
chevron, rows render top-level) via the existing isSidebarProjectFlat
tened gate from sidebar-project-row-model. Multi-workspace non-git
projects and git projects collapse normally.

Drag and drop parity:
New SidebarProjectsDraggableList wraps the existing DraggableList,
useDragHandle, useLongPressDragInteraction (extracted to its own file
for sharing), and persists via setProjectOrder + the existing
hasVisibleOrderChanged / mergeWithRemainder helpers. Both the
workspace tree and grouped Sessions consume it. Workspace tree
behavior unchanged; sessions's grouped view is now structured as a
NestableScrollContainer / ScrollView around per-project sections.

Project trailing actions parity:
New SidebarProjectTrailingActions owns the project kebab (settings +
remove), optional new-worktree button, and shortcut hint. Workspace
tree uses it with the new-worktree button enabled; grouped Sessions
uses it with kebab only.

Per-agent kebab:
New SidebarSessionRowKebabMenu reuses useArchiveAgent for archive,
mirrors handleCloseAgentTab semantics for close, pulls the close
menu entry from buildWorkspaceTabMenuEntries, and matches the
workspace row kebab visibility (hover or touch). No fork.

New session button:
Plus button in the Sessions header reuses useOpenProjectPicker and
buildHostNewWorkspaceRoute. Adds a label-only `title` query arg so
the destination screen reads "New session" instead of "New workspace"
when entered from this button.

Tests cover ordering, flatten/collapse, drag-end persistence, kebab
visibility/items, and the new-session route. Render-boundary tests
extended for the section structure.
2026-05-08 22:03:47 +07:00
Mohamed Boudra 4f047832ac sidebar history: collapsible project sections, indented children
The grouped Sessions view now matches the workspace tree's project-row
UX instead of forking it. Both consumers share one composable.

New: SidebarProjectHeaderRow (memoized, slot-driven) — chevron,
hover/press/selected backgrounds, leading visual override, trailing
slot for kebab/shortcut/new-worktree, optional PressableComponent for
ContextMenuTrigger. Plus sidebarProjectChildIndentStyles for indenting
children under the header.

Workspace tree refactored to consume it: drag handle, status icon,
shortcut badge, kebab menu, and ContextMenuTrigger all flow through
slots. Behavior preserved.

Grouped Sessions:
- Header now collapses on press, with the same chevron-on-hover
  affordance the workspace tree has.
- Section collapse uses the same persistent useSidebarCollapsedSections
  store the workspace tree uses, so collapsing Project A in either
  view collapses it in both.
- Children rows get indented: SidebarSessionRow accepts an `indented`
  prop wired to the shared style. Footer matches.
- Show-more vs section-collapse are now distinct concepts:
  controller's expandedProjects renamed to previewExpandedProjects,
  toggleProjectExpanded -> toggleProjectPreviewExpanded. Collapse
  beats preview-expansion.

deriveGroupedSidebarSessions takes both preview-expanded and
collapsed sets. Collapsed groups return empty visibleIds and the
flattener emits header-only for them.

Tests: 41 passing across 7 files. New cases cover collapsed-hides-rows,
collapsed-wins-over-preview-expanded, and the section-only flatten
emission.
2026-05-08 20:45:18 +07:00
Mohamed Boudra a3c3d25592 sidebar history: group by project toggle with show more/less
Adds a group-by-project toggle to the Sessions header. When ON, the
flat session list becomes a grouped list — one section per project,
preview-capped at 6 agents per project with "Show N more" / "Show less"
controls per group. When OFF, the flat list stays as before.

Architecture preserves the Phase 7b perf model:

- Controller (useSidebarSessionsController) owns groupByProject and
  expandedProjects state. Expansion resets on grouping-off, host change,
  and filter change. Group-by persists across hosts as a UI preference.

- A new useOrderedAgentProjectShape selector reactively subscribes to
  per-agent cwd via useStoreWithEqualityFn(..., shallow). When project
  assignments don't change, the array reference stays stable; when an
  agent's project changes, the parent re-derives groups. Shape objects
  are reused across renders when project fields are unchanged.

- deriveGroupedSidebarSessions takes the resolved shape directly (no
  callback indirection) and returns ordered groups with visible/hidden
  ids based on per-project expansion state.

- Header and Footer components are React.memo'd with primitive props
  only, so they don't re-render on irrelevant agent updates. Per-row
  status indicators continue to subscribe via the shared tab descriptor
  path, isolated from parent renders.

- Project ordering: first-occurrence in the createdAt-desc id list,
  matching the user's mental model of "most-recent project first" and
  avoiding a per-render activity-timestamp computation.

Visual primitive shared between the workspace tree project row and the
new Sessions group header (packages/app/src/components/sidebar/
sidebar-project-row-visual.tsx) — no two systems for project visuals.
The workspace tree wraps it with its own chevron/status; the Sessions
header uses it bare.

Tests added: state-machine cases on the controller, pure-helper coverage
on grouping derivation, render-boundary stability for grouped data
references across irrelevant updates and reactivity on project changes,
and a footer click test.
2026-05-08 20:14:38 +07:00
Mohamed Boudra b252cd98d2 sidebar history: status indicator on session rows
Sidebar Session rows now mirror the workspace tab descriptor: same
provider icon, same loader spinner, same status dot, same attention
badge. Reuses WorkspaceTabPresentationResolver + WorkspaceTabIcon
directly — no parallel implementation. Status reactivity stays inside
the tab descriptor's own per-agent selector, so selectSidebarSessionSlice
is unchanged and the parent ids hook keeps its reference stability.

Navigation test mocks tab presentation so it doesn't pull the full
workspace stack just to assert click behavior; production uses the
real shared component.
2026-05-08 19:25:20 +07:00
Mohamed Boudra 670e6a6fdd sidebar history: drop workspace filter, project-only
Removes the Workspace section from the Sessions filter dropdown.
Final dropdown is All + Project. Simplifies the filter discriminated
union (drops the "workspace" variant), the predicate, and the
hook-derived option list. Workspace mapping is still used internally
to map session cwd to project for the Project filter and for hiding
unmapped agents.

E2E journey simplified — the workspace-filter step is gone, including
its brittle accessible-name regex.
2026-05-08 18:31:35 +07:00
Mohamed Boudra e9c3753466 sidebar history: design revamp, branch names in filter, splash timeout
Rolls up three follow-ups on the sidebar Workspaces/Sessions toggle:

Design revamp (Notion-style toggle, History at top):
- Toggle pills both carry icons (Folders/MessagesSquare); only the
  active pill shows its label, with LinearTransition + FadeIn/FadeOut.
- Filter trigger now uses lucide ListFilter; History row uses lucide
  History (clock); History row moved to the top of the sidebar.
- Session row content trimmed to icon + name + timestamp (no cwd
  path); padding/spacing/type ramp matches the existing Workspaces
  tree rows so the icon and text columns align across History row,
  toggle pills, and list rows at the canonical 16px column.
- Filter dropdown uses sentence-case section labels (Workspace/
  Project) and renders project icons (no text-letter fallbacks).
- testID rename: sidebar-sessions -> sidebar-history (the navigation
  row); new toggle pills get sidebar-toggle-workspaces /
  sidebar-toggle-sessions. Updated callers in helpers and maestro.
- No bold or font-weight changes; no uppercase anywhere.

Branch names in workspace filter:
- The dropdown's Workspaces section was rendering the unhydrated
  structural workspace.name (often a path-like id) because the
  selector chain skipped the rehydration step the main sidebar row
  uses. The hook now reuses createSidebarWorkspaceEntry so the label
  comes from the same descriptor path WorkspaceRow uses, matching
  what the user sees in the main sidebar.

Splash screen timeout for env-var local daemon:
- The 5s give-up timer in _layout.tsx had an explicit early-return
  exempting waitForConfiguredLocalDaemon, leaving the splash hung
  forever when EXPO_PUBLIC_LOCAL_DAEMON pointed at an offline
  daemon. Removed the exemption so the timer applies; the runtime
  override loop continues retrying in the background and welcome
  auto-redirects when any host comes online.
2026-05-08 17:58:50 +07:00
Mohamed Boudra 9c1bea7135 feat(app): sidebar Workspaces/Sessions toggle with History rename
Adds a Notion-style pill toggle at the top of the always-visible sidebar
that switches between Workspaces (current tree) and Sessions (flat
live-updating list of agents across the daemon). Sessions are sorted by
createdAt desc (stable, tie-broken by id) and filtered to agents whose
cwd maps to an opened workspace; clicking a row focuses the matching
workspace tab via prepareWorkspaceTab + navigateToPreparedWorkspaceTab.
The existing "Sessions" sidebar row is renamed to "History" (same
destination).

Lives in its own module at packages/app/src/sidebar-sessions/ with the
sidebar shell pieces extracted to packages/app/src/components/sidebar/.
Render performance is treated as first-class: parent subscribes to an
ordered id list with shallow array equality (useAggregatedAgentIds),
each row uses a per-agent shallow Zustand selector, the inactive view
is unmounted on toggle, and the filter dropdown subscription is gated
to Sessions mode. Filter state resets on host change. Agents whose cwd
doesn't map to an opened workspace are hidden from both list and
dropdown options.

Includes a Playwright user-journey spec and unit/integration tests for
the new ids hook, slice selector, render boundaries, navigation,
filtering, and the sidebar-sessions controller.
2026-05-08 17:58:50 +07:00
48 changed files with 6623 additions and 854 deletions
+5 -3
View File
@@ -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())}$`), {
+2 -2
View File
@@ -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
+265
View File
@@ -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);
});
});
+3 -13
View File
@@ -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() });
+8 -1
View File
@@ -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}
/>
);
}
+139 -38
View File
@@ -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"]);
});
});
+143 -16
View File
@@ -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],
);
}
+511
View File
@@ -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");
});
});
+4 -1
View File
@@ -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;
}