Restore project grouping across hosts (#2565)

* fix(projects): restore cross-host grouping

Random host-local project IDs replaced the remote-derived value that had also served as grouping identity. Persist a separate opaque group key and let normal boot reconciliation backfill older records without a migration.

* fix(projects): keep ambiguous clones separate

A shared remote can identify a project across hosts, but it cannot distinguish two independent clones on one host. Treat those keys as ambiguous so host-local project IDs remain separate.

* fix(projects): preserve host-local project identity

* fix(projects): strengthen grouping identity

* test(app): update project host shape contract

* fix(projects): preserve selected host identity

* fix(projects): close grouped routing gaps

* fix(projects): stabilize legacy subproject grouping

* fix(projects): preserve selected project identity

* fix(projects): isolate legacy host identity

* fix(projects): preserve symlinked bootstrap roots

* fix(projects): reconcile grouped custom names

* fix(projects): stabilize settings and path identity

* fix(projects): normalize default remote ports

* fix(projects): harden host and remote identity

* fix(projects): preserve host-scoped actions

* fix(projects): preserve remote path semantics

* fix(projects): model SCP remote semantics

* fix(projects): keep settings on routed host

* fix(projects): preserve SSH URL path semantics

* fix(projects): separate identity from presentation

* test(projects): use grouped identity in UI specs

* fix(projects): harden grouped project routing

* test(projects): expect framed settings route

* fix(projects): preserve exact placement identity

* fix(projects): harden grouping identity and routing

* fix(projects): restore project key as grouping identity

Random host-local project IDs had replaced the persisted cross-host value carried by projectKey. Keep projectId for routing while reconciliation owns and persists projectKey for grouping.

* fix(projects): prevent project key collisions

Preserve platform path semantics and generic remote transports in cross-host keys, and compare legacy checkout roots using filesystem equivalence before materializing ownership.

* test(projects): assert host-scoped project keys

* fix(projects): preserve project key compatibility

Resolve legacy workspace project references at the daemon boundary while keeping projectKey as the cross-host identity. Complete generic SSH normalization without collapsing distinct repositories.

* fix(projects): harden project reference identity

* fix(projects): retain archived reference errors

* fix(projects): scope legacy paths to hosts

* test(app): model persisted project keys

* fix(projects): retain absolute scp users

* fix(projects): resolve legacy mutation aliases

* fix(projects): preserve conservative identity boundaries

* fix(projects): preserve legacy placement identity

* test(projects): assert legacy placement identity

* test(projects): expect canonical Windows keys

* fix(projects): separate bootstrap identity fields

* fix(projects): scope legacy paths by host

* fix(projects): stabilize opaque project identity

* fix(projects): preserve remaining opaque identities

* fix(projects): classify forward-slash UNC roots

* fix(projects): preserve opaque selection keys

* fix(projects): frame host-local project keys

* fix(projects): canonicalize framed identities

* test(projects): isolate repeated remote fixtures

* fix(projects): keep project key as UI identity

* fix(projects): reconcile hydrated project references

* fix(projects): preserve archived project identity

* refactor(projects): group projects from project records, not workspaces

Cross-host grouping had been built by hanging a projectKey off every
workspace descriptor. That scoped the key to the wrong entity: the app
then had to reconstruct project identity per workspace in six modules,
which forced projectKey fallbacks into RPC arguments and a server-side
resolver that turned grouping keys back into project ids.

A workspace has a projectId. The key belongs to the project, and
grouping is a display concern, so the daemon now publishes projects
over project.list plus the existing project.update channel and the app
groups them in one place. Projects are addressed by host-local
projectId everywhere, including the settings route.

Projects persisted without a key are backfilled by ordinary boot
reconciliation, with no migration. Old daemons advertise no projectList
capability and simply do not group.

* test(projects): seed projects alongside workspaces in fixtures

Sidebar fixtures seeded workspaces without their projects, which the
daemon cannot produce now that projects arrive on their own channel, so
nothing rendered. Adds a seeding helper that supplies both.

Also drops the provisioning test for accepting a project key where a
project id belongs; that resolver was removed deliberately.

* fix(projects): keep derived project identity stable

Four gaps left by shrinking the key derivation:

Local keys used a bare path resolve, so one directory reached by a
different spelling produced a different key and reset grouped identity.
Windows CI caught this with the 8.3 and long-name forms of one temp
path. Canonicalize through the shared realpath-aware normalization.

Subproject display names lost their selected-path input, so every
subproject of a monorepo rendered under the same remote-derived name.

Reconciliation only ever backfilled a missing key, so a project whose
remote changed stayed grouped with hosts on the old remote.

Project settings kept a disconnected host selected and rendered a dead
end with no way back to an editable host.

* fix(projects): read scheme remotes as URLs, not scp form

An `ssh://user@host:22/org/repo` remote matched the scp-form pattern
first, because `[^@]+` happily eats `ssh://git`, so the port landed in
the path and the project keyed as `remote:host/22/org/repo`. Two
spellings of one remote therefore never grouped.

Try scp form only when there is no scheme, and drop protocol-default
ports so an explicitly written `:22` matches the same remote without it.
This commit is contained in:
Mohamed Boudra
2026-07-29 23:36:31 +02:00
committed by GitHub
parent 9e5accee3c
commit 20e46e3e3f
113 changed files with 3073 additions and 2038 deletions
+11 -4
View File
@@ -4,9 +4,15 @@
Projects are allocated for the exact root selected by the caller, normalized lexically with `path.resolve` (never `realpath`). New project IDs are opaque `prj_<16 hex>` values. Existing remote-shaped or path-shaped IDs are retained as readable compatibility records and are never rekeyed. An active exact root is idempotent; archived-only matches do not resurrect an old project. Workspace `projectId` is stable membership: reconciliation may update git-derived kind and branch metadata, but never rehomes a workspace or changes a project's root, ID, or default name.
`kind` is mutable metadata, not identity. Workspace reconciliation watches active project roots and
updates only a project's `kind` and `updatedAt` when `.git` appears or disappears, preserving its
ID, root path, names, and workspace foreign keys. Attached workspaces are independently refreshed
`projectKey` is a persisted, opaque equivalence key used only to group the same logical project
across hosts. It is separate from the host-local `projectId`; today's producer prefers a normalized
Git remote and otherwise uses the local project root. Consumers never derive it from live Git.
Creation persists it with the project, and normal boot reconciliation fills it for
older records where the field is absent—there is no migration.
`kind` and `projectKey` are mutable metadata, not identity. Workspace reconciliation watches active project roots and
updates those fields and `updatedAt` when Git facts change, preserving the project's ID, root path,
names, and workspace foreign keys. Attached workspaces are independently refreshed
from their own cwd, so an explicit project root never implies a workspace checkout. Empty projects
are observed too.
@@ -450,7 +456,8 @@ Array of project records.
| Field | Type | Description |
| ------------- | --------------------------- | -------------------------------------------------------------------------------- |
| `projectId` | `string` | Primary key; new records use opaque `prj_<16 hex>` IDs |
| `projectId` | `string` | Host-local primary key; new records use opaque `prj_<16 hex>` IDs |
| `projectKey` | `string \| null` | Persisted opaque cross-host grouping key; reconciliation backfills absent values |
| `rootPath` | `string` | Exact lexically normalized selected root; never realpathed |
| `kind` | `"git" \| "non_git"` | Mutable Git observation about `rootPath`, never a membership key |
| `displayName` | `string` | Selected-root basename, stable across remote and Git changes |
+2 -2
View File
@@ -2,7 +2,7 @@
Authoritative terminology. UI label wins. Don't invent synonyms; use what's here.
- **Project** — A stable, exact selected-root record. New IDs are opaque `prj_<16 hex>` values; older remote-shaped and path-shaped IDs remain readable compatibility records. Git facts can update mutable kind metadata but never project identity, root, or default display name. UI: "Project" / "Add project". Forbidden: "Repo", "Repository" as UI label.
- **Project** — A stable, exact selected-root record. Its host-local `projectId` is an opaque `prj_<16 hex>` value. Its persisted `projectKey` is an opaque equivalence key that may group the logical project across hosts. A normalized Git remote is the current key producer, but consumers must not parse or rederive it. Git facts can update mutable kind and grouping metadata but never the ID, root, or default display name. UI: "Project" / "Add project". Forbidden: "Repo", "Repository" as UI label.
- **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. Its `id` is opaque workspace identity; its `cwd` is the filesystem directory. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/protocol/src/messages.ts:2178`). Don't confuse with: Branch (one branch can back many workspaces via worktrees). Forbidden: "Folder", "Directory" as UI label.
- **Archive workspace** — Removes one workspace from active use and archives everything it owns. UI, CLI, and MCP always say "Archive workspace", regardless of backing. The daemon leaves ordinary directories intact and removes a Paseo-owned worktree only when no active workspace still references it.
- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. The git-derived, persisted property of a workspace, used across its lifetime (archive safety, sidebar, grouping). Derived from the cwd's git reality by `deriveWorkspaceKind` in `workspace-registry-model.ts`, not stored from a user choice. Don't confuse with **Isolation** (the create-time intent).
@@ -17,7 +17,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Change request** — Forge-neutral term for a proposed branch-to-branch code change. UI normally renders the forge noun instead: GitHub/Gitea/Forgejo "PR", GitLab "MR". Code: `forge_change_request` attachments, `checkoutSource: { kind: "change_request" }`, and PR/MR status payloads.
- **MR** — GitLab merge request. UI label for GitLab change requests only; do not use MR for GitHub/Gitea/Forgejo.
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. User-facing creation treats it as the `worktree` workspace isolation choice. Code and `paseo.json` retain worktree terminology for git lifecycle implementation. Forbidden: "Checkout" as a product synonym.
- **Repository / Remote** — Internal Git observations. They may affect mutable kind/branch metadata but never project identity, root, display name, or workspace membership. No UI label.
- **Repository / Remote** — Internal Git observations. They may produce mutable kind, branch, and project-grouping metadata but never the host-local project ID, root, display name, or workspace membership. No UI label.
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
- **Workspace status bucket** — Aggregate activity signal for a workspace row. Same-`cwd` workspaces intentionally share agent and terminal status buckets, while tab, agent, and terminal visibility remains scoped by `workspaceId`.
+21 -24
View File
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
import { mkdtemp, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { test, expect, type Page } from "./fixtures";
import { test, expect } from "./fixtures";
import {
addProjectFlow,
addProjectFlowBack,
@@ -13,36 +13,21 @@ import {
expectAddProjectPage,
expectNewWorkspaceForAddedProject,
openAddProjectFlow,
waitForConnectedHost,
} from "./helpers/add-project-flow";
import { gotoAppShell } from "./helpers/app";
import { buildSeededHost } from "./helpers/daemon-registry";
import { addOfflineHostAndReload } from "./helpers/hosts";
import {
addConnectedHostAndReload,
addOfflineHostAndReload,
waitForConnectedHost,
} from "./helpers/hosts";
import { type IsolatedHostDaemon, startIsolatedHostDaemon } from "./helpers/isolated-host-daemon";
import { expectOpenedProject } from "./helpers/project-picker-ui";
import { connectSeedClient } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
const EXTRA_HOSTS_KEY = "@paseo:e2e-extra-hosts";
const SECONDARY_HOST_ID = "add-project-flow-secondary";
const SECONDARY_HOST_LABEL = "Secondary Host";
async function addConnectedHostAndReload(page: Page, host: IsolatedHostDaemon): Promise<void> {
const registryEntry = buildSeededHost({
serverId: host.serverId,
label: SECONDARY_HOST_LABEL,
endpoint: `127.0.0.1:${host.port}`,
nowIso: new Date().toISOString(),
});
await page.evaluate(
({ key, entry }) => {
localStorage.setItem(key, JSON.stringify([entry]));
},
{ key: EXTRA_HOSTS_KEY, entry: registryEntry },
);
await page.reload();
}
async function expectProjectDirectory(pathname: string): Promise<void> {
await expect.poll(async () => (await stat(pathname)).isDirectory()).toBe(true);
}
@@ -149,7 +134,11 @@ test.describe("Add Project command-center flow", () => {
test("keyboard selection chooses the second host", async ({ page }) => {
await gotoAppShell(page);
await addConnectedHostAndReload(page, secondaryHost);
await addConnectedHostAndReload(page, {
serverId: secondaryHost.serverId,
label: SECONDARY_HOST_LABEL,
port: secondaryHost.port,
});
await waitForConnectedHost(page, {
serverId: SECONDARY_HOST_ID,
endpoint: `localhost:${secondaryHost.port}`,
@@ -167,7 +156,11 @@ test.describe("Add Project command-center flow", () => {
page,
}) => {
await gotoAppShell(page);
await addConnectedHostAndReload(page, secondaryHost);
await addConnectedHostAndReload(page, {
serverId: secondaryHost.serverId,
label: SECONDARY_HOST_LABEL,
port: secondaryHost.port,
});
await waitForConnectedHost(page, {
serverId: SECONDARY_HOST_ID,
endpoint: `localhost:${secondaryHost.port}`,
@@ -211,7 +204,11 @@ test.describe("Add Project command-center flow", () => {
try {
await gotoAppShell(page);
await addConnectedHostAndReload(page, secondaryHost);
await addConnectedHostAndReload(page, {
serverId: secondaryHost.serverId,
label: SECONDARY_HOST_LABEL,
port: secondaryHost.port,
});
await waitForConnectedHost(page, {
serverId: SECONDARY_HOST_ID,
endpoint: `localhost:${secondaryHost.port}`,
@@ -207,7 +207,7 @@ test.describe("Composer attachments", () => {
});
await openNewWorkspaceComposer(page, {
projectKey: workspace.projectId,
projectKey: workspace.projectKey,
projectDisplayName: workspace.projectDisplayName,
});
@@ -293,7 +293,7 @@ test.describe("Composer attachments", () => {
});
await openNewWorkspaceComposer(page, {
projectKey: workspace.projectId,
projectKey: workspace.projectKey,
projectDisplayName: workspace.projectDisplayName,
});
await fillComposerDraft(page, "lock test prompt");
+19 -15
View File
@@ -32,12 +32,12 @@ async function archiveWorkspaceFromSidebar(page: Page, workspaceId: string): Pro
await archiveItem.click();
}
async function removeProjectFromSidebar(page: Page, projectId: string): Promise<void> {
const projectRow = page.getByTestId(`sidebar-project-row-${projectId}`);
async function removeProjectFromSidebar(page: Page, projectKey: string): Promise<void> {
const projectRow = page.getByTestId(`sidebar-project-row-${projectKey}`);
await expect(projectRow).toBeVisible({ timeout: 30_000 });
await projectRow.hover();
const kebab = page.getByTestId(`sidebar-project-kebab-${projectId}`);
const kebab = page.getByTestId(`sidebar-project-kebab-${projectKey}`);
await expect(kebab).toBeVisible({ timeout: 10_000 });
await kebab.click();
@@ -45,7 +45,7 @@ async function removeProjectFromSidebar(page: Page, projectId: string): Promise<
// user-confirmed removal proceeds deterministically.
page.once("dialog", (dialog) => void dialog.accept());
const removeItem = page.getByTestId(`sidebar-project-menu-remove-${projectId}`);
const removeItem = page.getByTestId(`sidebar-project-menu-remove-${projectKey}`);
await expect(removeItem).toBeVisible({ timeout: 10_000 });
await removeItem.click();
}
@@ -152,9 +152,9 @@ test.describe("Project with no workspaces persists", () => {
const workspace = await seedWorkspace({ repoPrefix: "empty-project-persists-" });
try {
const projectRow = page.getByTestId(`sidebar-project-row-${workspace.projectId}`);
const projectRow = page.getByTestId(`sidebar-project-row-${workspace.projectKey}`);
const newWorkspaceRow = page.getByTestId(
`sidebar-project-new-workspace-row-${workspace.projectId}`,
`sidebar-project-new-workspace-row-${workspace.projectKey}`,
);
const globalNewWorkspace = page.getByTestId("sidebar-global-new-workspace");
@@ -195,9 +195,10 @@ test.describe("Project with no workspaces persists", () => {
test.describe("Project remove", () => {
test("removing a project from project actions removes it from the sidebar", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "project-remove-sidebar-" });
let readdedProjectId: string | null = null;
try {
const projectRow = page.getByTestId(`sidebar-project-row-${workspace.projectId}`);
const projectRow = page.getByTestId(`sidebar-project-row-${workspace.projectKey}`);
await gotoAppShell(page);
await waitForSidebarHydration(page);
@@ -206,7 +207,7 @@ test.describe("Project remove", () => {
timeout: 30_000,
});
await removeProjectFromSidebar(page, workspace.projectId);
await removeProjectFromSidebar(page, workspace.projectKey);
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toHaveCount(0, {
timeout: 30_000,
@@ -220,21 +221,24 @@ test.describe("Project remove", () => {
const readded = await workspace.client.addProject(workspace.repoPath);
expect(readded.error).toBeNull();
expect(readded.project).not.toBeNull();
const readdedProjectId = readded.project?.projectId ?? "";
readdedProjectId = readded.project?.projectId ?? "";
const readdedProjectKey = readded.project?.projectKey ?? "";
expect(readdedProjectId).not.toBe(workspace.projectId);
expect(readdedProjectKey).toBe(workspace.projectKey);
expect(readded.project?.projectDisplayName).toBe(workspace.projectDisplayName);
await page.reload();
await waitForSidebarHydration(page);
await expect(projectRow).toHaveCount(0, { timeout: 30_000 });
const readdedProjectRow = page.getByTestId(`sidebar-project-row-${readdedProjectId}`);
await expect(readdedProjectRow).toBeVisible({ timeout: 30_000 });
await expect(readdedProjectRow).toContainText(workspace.projectDisplayName);
await expect(readdedProjectRow).not.toContainText(workspace.repoPath);
await expect(projectRow).toBeVisible({ timeout: 30_000 });
await expect(projectRow).toContainText(workspace.projectDisplayName);
await expect(projectRow).not.toContainText(workspace.repoPath);
await expect(
page.getByTestId(`sidebar-project-new-workspace-row-${readdedProjectId}`),
page.getByTestId(`sidebar-project-new-workspace-row-${readdedProjectKey}`),
).toBeVisible({ timeout: 30_000 });
} finally {
if (readdedProjectId) {
await workspace.client.removeProject(readdedProjectId).catch(() => undefined);
}
await workspace.cleanup();
}
});
@@ -37,17 +37,6 @@ export function addProjectFlowMethod(page: Page, method: AddProjectMethod): Loca
return page.getByTestId(`add-project-flow-method-${method}`);
}
export async function waitForConnectedHost(
page: Page,
input: { serverId: string; endpoint: string },
): Promise<void> {
await page.getByTestId("sidebar-hosts-trigger").click();
const host = page.getByTestId(`sidebar-host-row-${input.serverId}`);
await expect(host).toContainText(input.endpoint, { timeout: 30_000 });
await page.keyboard.press("Escape");
await expect(host).not.toBeVisible();
}
export async function expectAddProjectPage(page: Page, kind: AddProjectFlowPage): Promise<Locator> {
const currentPage = page.getByTestId(`add-project-flow-page-${kind}`);
await expect(currentPage).toBeVisible({ timeout: 30_000 });
+63
View File
@@ -57,6 +57,69 @@ export async function addOfflineHostAndReload(
await page.reload();
}
export async function addConnectedHostAndReload(
page: Page,
input: { serverId: string; label: string; port: number; primaryLabel?: string },
): Promise<void> {
await addConnectedHostsAndReload(page, [input], { primaryLabel: input.primaryLabel });
}
export async function addConnectedHostsAndReload(
page: Page,
inputs: Array<{ serverId: string; label: string; port: number }>,
options?: { primaryLabel?: string },
): Promise<void> {
const connectedHosts = inputs.map((input) =>
buildSeededHost({
serverId: input.serverId,
label: input.label,
endpoint: `127.0.0.1:${input.port}`,
nowIso: new Date().toISOString(),
}),
);
await page.evaluate(
({ hosts, keys, primaryLabel }) => {
const nonce = localStorage.getItem(keys.nonce);
if (!nonce) {
throw new Error("Expected the e2e seed nonce before overriding the host registry.");
}
const raw = localStorage.getItem(keys.registry);
const registry: Array<{ serverId: string; label?: string }> = raw ? JSON.parse(raw) : [];
if (primaryLabel && registry[0]) registry[0].label = primaryLabel;
for (const host of hosts) {
if (!registry.some((entry) => entry.serverId === host.serverId)) {
registry.push(host);
}
}
localStorage.setItem(keys.registry, JSON.stringify(registry));
localStorage.setItem(keys.disableSeedOnce, nonce);
},
{
hosts: connectedHosts,
keys: {
registry: REGISTRY_KEY,
nonce: SEED_NONCE_KEY,
disableSeedOnce: DISABLE_DEFAULT_SEED_ONCE_KEY,
},
primaryLabel: options?.primaryLabel,
},
);
await page.reload();
}
export async function waitForConnectedHost(
page: Page,
input: { serverId: string; endpoint: string },
): Promise<void> {
await page.getByTestId("sidebar-hosts-trigger").click();
const host = page.getByTestId(`sidebar-host-row-${input.serverId}`);
await expect(host).toContainText(input.endpoint, { timeout: 30_000 });
await page.keyboard.press("Escape");
await expect(host).not.toBeVisible();
}
export async function openSidebarDisplayPreferences(page: Page): Promise<void> {
await page.getByTestId("sidebar-display-preferences-menu").click();
await expect(page.getByTestId("sidebar-display-preferences-content")).toBeVisible({
@@ -9,6 +9,7 @@ import { withDisabledE2ESpeechEnv } from "./speech-env";
export interface IsolatedHostDaemon {
serverId: string;
port: number;
paseoHome: string;
restart(): Promise<void>;
close(): Promise<void>;
}
@@ -133,6 +134,7 @@ export async function startIsolatedHostDaemon(serverId: string): Promise<Isolate
return {
serverId,
port,
paseoHome,
restart: async () => {
if (closed) throw new Error(`Cannot restart closed isolated daemon ${serverId}`);
await stopProcess(child);
+15 -4
View File
@@ -18,6 +18,7 @@ type NewWorkspaceDaemonClient = Pick<
| "getPaseoWorktreeList"
| "getDaemonConfig"
| "inspectWorkspaceRecovery"
| "listProjects"
| "on"
| "patchDaemonConfig"
| "removeProject"
@@ -29,6 +30,7 @@ type WorkspaceDescriptor = NonNullable<CreateWorkspacePayload["workspace"]>;
export interface OpenedProject {
workspaceId: string;
projectId: string;
projectKey: string;
projectDisplayName: string;
workspaceName: string;
@@ -45,10 +47,19 @@ function requireWorkspace(payload: WorkspacePayload) {
return payload.workspace;
}
function openedProjectFromWorkspace(workspace: WorkspaceDescriptor): OpenedProject {
async function openedProjectFromWorkspace(
client: NewWorkspaceDaemonClient,
workspace: WorkspaceDescriptor,
): Promise<OpenedProject> {
const payload = await client.listProjects();
const project = payload.projects.find((candidate) => candidate.projectId === workspace.projectId);
if (!project?.projectKey) {
throw new Error(`Project ${workspace.projectId} has no project key`);
}
return {
workspaceId: workspace.id,
projectKey: workspace.projectId,
projectId: workspace.projectId,
projectKey: project.projectKey,
projectDisplayName: workspace.projectDisplayName,
workspaceName: workspace.name,
workspaceDirectory: workspace.workspaceDirectory,
@@ -107,7 +118,7 @@ export async function openProjectViaDaemon(
source: { kind: "directory", path: repoPath },
}),
);
return openedProjectFromWorkspace(workspace);
return openedProjectFromWorkspace(client, workspace);
}
export async function archiveWorkspaceFromDaemon(
@@ -149,7 +160,7 @@ export async function createWorktreeViaDaemon(
worktreeSlug: input.slug,
});
const workspace = requireWorkspace(payload);
return openedProjectFromWorkspace(workspace);
return openedProjectFromWorkspace(client, workspace);
}
export async function openNewWorkspaceComposer(
@@ -0,0 +1,131 @@
import { expect, type Locator, type Page } from "@playwright/test";
import { gotoAppShell } from "./app";
import { addConnectedHostsAndReload, waitForConnectedHost } from "./hosts";
import { openProjectSettings } from "./project-settings";
import { waitForSidebarHydration } from "./workspace-ui";
const PROJECT_VISIBILITY_TIMEOUT = 30_000;
function projectGroup(page: Page, projectName: string): Locator {
return page.getByRole("group", { name: projectName, exact: true });
}
function projectGroupWithWorkspace(
page: Page,
projectName: string,
workspaceName: string,
): Locator {
return projectGroup(page, projectName).filter({ hasText: workspaceName });
}
export async function openProjectDirectory(page: Page): Promise<void> {
await gotoAppShell(page);
await waitForSidebarHydration(page);
}
export async function openProjectDirectoryWithHosts(
page: Page,
input: {
hosts: Array<{ serverId: string; label: string; port: number }>;
primaryLabel?: string;
},
): Promise<void> {
await gotoAppShell(page);
await addConnectedHostsAndReload(page, input.hosts, { primaryLabel: input.primaryLabel });
for (const host of input.hosts) {
await waitForConnectedHost(page, {
serverId: host.serverId,
endpoint: `localhost:${host.port}`,
});
}
await waitForSidebarHydration(page);
}
export async function expectProjectContainsWorkspaces(
page: Page,
input: { projectName: string; workspaceNames: string[] },
): Promise<void> {
const group = projectGroup(page, input.projectName);
await expect(group).toHaveCount(1, { timeout: PROJECT_VISIBILITY_TIMEOUT });
for (const workspaceName of input.workspaceNames) {
await expect(group.getByRole("button").filter({ hasText: workspaceName })).toBeVisible({
timeout: PROJECT_VISIBILITY_TIMEOUT,
});
}
}
export async function expectSeparateProjects(
page: Page,
projects: Array<{ projectName: string; workspaceName: string }>,
): Promise<void> {
for (const project of projects) {
await expect(
projectGroupWithWorkspace(page, project.projectName, project.workspaceName),
).toHaveCount(1, { timeout: PROJECT_VISIBILITY_TIMEOUT });
}
}
export async function beginWorkspaceFromProject(page: Page, projectName: string): Promise<void> {
const group = projectGroup(page, projectName);
await expect(group).toBeVisible({ timeout: PROJECT_VISIBILITY_TIMEOUT });
await group.hover();
await group.getByLabel(`Create a new workspace for ${projectName}`).click();
await expect(page.getByRole("button", { name: "Workspace project", exact: true })).toContainText(
projectName,
{ timeout: PROJECT_VISIBILITY_TIMEOUT },
);
}
export async function selectWorkspaceHost(page: Page, hostName: string): Promise<void> {
await page.getByRole("button", { name: "Host", exact: true }).click();
await page.getByRole("button", { name: hostName, exact: true }).click();
await expect(page.getByRole("button", { name: "Host", exact: true })).toContainText(hostName);
}
export async function createWorkspaceWithoutAgent(page: Page): Promise<void> {
await page.getByRole("button", { name: "Create", exact: true }).click();
}
export async function expectProjectWorkspaceCountForHost(
page: Page,
input: { projectName: string; hostName: string; count: number },
): Promise<void> {
const group = projectGroup(page, input.projectName);
await expect(group.getByRole("button").filter({ hasText: input.hostName })).toHaveCount(
input.count,
{ timeout: PROJECT_VISIBILITY_TIMEOUT },
);
}
export async function renameProject(page: Page, name: string): Promise<void> {
await page.getByRole("button", { name: "Rename project", exact: true }).click();
await page.getByRole("textbox", { name: "Project name", exact: true }).fill(name);
await page.getByRole("button", { name: "Save project name", exact: true }).click();
await expect(page.getByText("Project renamed", { exact: true })).toBeVisible({
timeout: PROJECT_VISIBILITY_TIMEOUT,
});
}
export async function openGroupedProjectSettings(page: Page, projectName: string): Promise<void> {
await page.getByRole("button", { name: "Settings", exact: true }).click();
await expect(page).toHaveURL(/\/settings\/general$/);
await page.getByRole("button", { name: "Projects", exact: true }).click();
await expect(page).toHaveURL(/\/settings\/projects$/);
await openProjectSettings(page, projectName);
}
export async function switchProjectSettingsHost(page: Page, hostName: string): Promise<void> {
const projectSettings = page.getByRole("main");
await projectSettings.getByRole("button", { name: "Switch host", exact: true }).click();
await page.getByRole("button", { name: hostName, exact: true }).click();
await expect(
projectSettings.getByRole("button", { name: "Switch host", exact: true }),
).toContainText(hostName);
}
export async function expectProjectSettingsName(page: Page, projectName: string): Promise<void> {
await expect(page.getByRole("group", { name: "Project name", exact: true })).toContainText(
projectName,
{ timeout: PROJECT_VISIBILITY_TIMEOUT },
);
}
@@ -7,7 +7,8 @@ export async function expectOpenedProject(page: Page, projectName: string): Prom
.first();
await expect(projectRow).toBeVisible({ timeout: 30_000 });
const testId = await projectRow.getAttribute("data-testid");
expect(testId).not.toBeNull();
return testId!.replace("sidebar-project-row-", "");
await expect(page).toHaveURL(/\/new\?.*projectId=/u, { timeout: 30_000 });
const projectId = new URL(page.url()).searchParams.get("projectId");
expect(projectId).not.toBeNull();
return projectId!;
}
@@ -16,6 +16,7 @@ type SessionRequest = Record<string, unknown> & { type?: string; requestId?: str
export interface FakeScheduleHostWorkspace {
serverId: string;
projectId: string;
projectKey: string;
projectDisplayName: string;
workspace: Record<string, unknown>;
}
@@ -120,6 +121,7 @@ export async function buildFakeScheduleHostWorkspace(
return {
serverId: "schedule-fake-host",
projectId,
projectKey: workspace.projectKey,
projectDisplayName: FAKE_HOST_PROJECT_DISPLAY_NAME,
workspace: {
...baseWorkspace,
@@ -139,6 +141,7 @@ export async function installFakeScheduleHost(input: {
port: string;
serverId: string;
workspace: Record<string, unknown>;
project: Pick<FakeScheduleHostWorkspace, "projectId" | "projectKey" | "projectDisplayName">;
schedules?: FakeScheduleSummary[];
}): Promise<void> {
await input.page.routeWebSocket(wsRoutePatternForPort(input.port), (ws) => {
@@ -154,6 +157,7 @@ export async function installFakeScheduleHost(input: {
features: {
providersSnapshot: true,
workspaceMultiplicity: true,
projectList: true,
projectAdd: true,
projectRemove: true,
workspaceRecovery: true,
@@ -197,6 +201,22 @@ export async function installFakeScheduleHost(input: {
}),
);
return;
case "project.list.request":
ws.send(
buildSessionMessage("project.list.response", {
requestId,
projects: [
{
projectId: input.project.projectId,
projectKey: input.project.projectKey,
projectDisplayName: input.project.projectDisplayName,
projectRootPath: String(input.workspace.projectRootPath),
projectKind: "git",
},
],
}),
);
return;
case "fetch_agents_request":
ws.send(
buildSessionMessage("fetch_agents_response", {
+17 -1
View File
@@ -13,6 +13,11 @@ export interface SeedWorkspaceDescriptor {
workspaceDirectory: string;
}
interface SeedProjectDescriptor {
projectId: string;
projectKey?: string;
}
/**
* The general-purpose E2E daemon client used to seed and drive state out of
* band (workspaces, agents, terminals) while the UI is exercised through the
@@ -25,15 +30,18 @@ export interface SeedDaemonClient {
addProject(cwd: string): Promise<{
project: {
projectId: string;
projectKey?: string;
projectDisplayName: string;
projectRootPath: string;
} | null;
error: string | null;
}>;
removeProject(projectId: string): Promise<{ removedWorkspaceIds: string[] }>;
renameProject(projectId: string, customName: string | null): Promise<void>;
fetchWorkspaces(options?: { filter?: { projectId?: string } }): Promise<{
entries: SeedWorkspaceDescriptor[];
}>;
listProjects(): Promise<{ projects: SeedProjectDescriptor[] }>;
createWorkspace(input: {
source:
| { kind: "directory"; path: string; projectId?: string }
@@ -179,8 +187,10 @@ export interface SeededWorkspace {
workspaceId: string;
workspaceName: string;
workspaceDirectory: string;
/** Stable project identity the daemon groups workspaces under. */
/** Host-local identity used by daemon project operations. */
projectId: string;
/** Opaque cross-host key used by grouped project UI. */
projectKey: string;
/** Project label the UI shows (owner/repo for known remotes, else basename). */
projectDisplayName: string;
cleanup(): Promise<void>;
@@ -208,6 +218,11 @@ export async function seedWorkspace(options: {
throw new Error(created.error ?? `Failed to create workspace ${project.path}`);
}
const workspace = created.workspace;
const listed = await client.listProjects();
const descriptor = listed.projects.find((item) => item.projectId === workspace.projectId);
if (!descriptor?.projectKey) {
throw new Error(`Created project ${workspace.projectId} has no project key`);
}
return {
client,
repoPath: project.path,
@@ -215,6 +230,7 @@ export async function seedWorkspace(options: {
workspaceName: workspace.name,
workspaceDirectory: workspace.workspaceDirectory,
projectId: workspace.projectId,
projectKey: descriptor.projectKey,
projectDisplayName: workspace.projectDisplayName,
cleanup: async () => {
await client.removeProject(workspace.projectId).catch(() => undefined);
@@ -64,6 +64,13 @@ async function seedPaseoWorkspaceWithOpenCodeSession(): Promise<OpenCodeImportSc
if (!createdWorkspace.workspace) {
throw new Error(createdWorkspace.error ?? `Failed to create workspace ${PASEO_REPO_PATH}`);
}
const descriptor = (await client.listProjects()).projects.find(
(project) => project.projectId === createdWorkspace.workspace?.projectId,
);
const projectKey = descriptor?.projectKey;
if (!projectKey) {
throw new Error(`Created workspace ${createdWorkspace.workspace.id} has no project key`);
}
return {
prompt,
promptPreview,
@@ -75,6 +82,7 @@ async function seedPaseoWorkspaceWithOpenCodeSession(): Promise<OpenCodeImportSc
workspaceName: createdWorkspace.workspace.name,
workspaceDirectory: createdWorkspace.workspace.workspaceDirectory,
projectId: createdWorkspace.workspace.projectId,
projectKey,
projectDisplayName: createdWorkspace.workspace.projectDisplayName,
cleanup: async () => {
await client.close().catch(() => undefined);
@@ -165,7 +165,7 @@ test.describe("New workspace Codex mode preferences", () => {
await waitForSidebarHydration(page);
await openGlobalNewWorkspaceComposer(page);
await selectNewWorkspaceProject(page, {
projectKey: seeded.projectId,
projectKey: seeded.projectKey,
projectDisplayName: seeded.projectDisplayName,
});
@@ -221,7 +221,7 @@ test.describe("New workspace Codex mode preferences", () => {
await openGlobalNewWorkspaceComposer(page);
await selectNewWorkspaceProject(page, {
projectKey: seeded.projectId,
projectKey: seeded.projectKey,
projectDisplayName: seeded.projectDisplayName,
});
@@ -34,7 +34,7 @@ test.describe("New workspace composer draft", () => {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openNewWorkspaceComposer(page, {
projectKey: firstProject.projectId,
projectKey: firstProject.projectKey,
projectDisplayName: firstProject.projectDisplayName,
});
await expectNewWorkspaceProjectSelected(page, firstProject.projectDisplayName);
@@ -42,7 +42,7 @@ test.describe("New workspace composer draft", () => {
await fillNewWorkspaceDraft(page, DRAFT);
await selectNewWorkspaceProject(page, {
projectKey: secondProject.projectId,
projectKey: secondProject.projectKey,
projectDisplayName: secondProject.projectDisplayName,
});
+12 -10
View File
@@ -179,13 +179,13 @@ test.describe("New workspace entry points", () => {
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await expect(projectRow(page, projectA.projectId)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, projectB.projectId)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, projectC.projectId)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, projectA.projectKey)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, projectB.projectKey)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, projectC.projectKey)).toBeVisible({ timeout: 30_000 });
// Project A's row icon opens New Workspace with A preselected.
await openNewWorkspaceComposer(page, {
projectKey: projectA.projectId,
projectKey: projectA.projectKey,
projectDisplayName: projectA.projectDisplayName,
});
await expectNewWorkspaceProjectSelected(page, projectA.projectDisplayName);
@@ -194,7 +194,9 @@ test.describe("New workspace entry points", () => {
// manualProjectKey is what the reused 'new' screen must reset when the next
// route-driven navigation targets a different project.
await page.getByTestId("new-workspace-project-picker-trigger").click();
const optionC = page.getByTestId(`new-workspace-project-picker-option-${projectC.projectId}`);
const optionC = page.getByTestId(
`new-workspace-project-picker-option-${projectC.projectKey}`,
);
await expect(optionC).toBeVisible({ timeout: 30_000 });
await optionC.click();
await expectNewWorkspaceProjectSelected(page, projectC.projectDisplayName);
@@ -203,7 +205,7 @@ test.describe("New workspace entry points", () => {
// because the stale manual choice (C) was reset on the route change. If the
// reset were missing, the trigger would still read C.
await openNewWorkspaceComposer(page, {
projectKey: projectB.projectId,
projectKey: projectB.projectKey,
projectDisplayName: projectB.projectDisplayName,
});
await expectNewWorkspaceProjectSelected(page, projectB.projectDisplayName);
@@ -226,8 +228,8 @@ test.describe("New workspace entry points", () => {
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await expect(projectRow(page, gitProject.projectId)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, nonGitProject.projectId)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, gitProject.projectKey)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, nonGitProject.projectKey)).toBeVisible({ timeout: 30_000 });
// Open New Workspace for the non-git project via the global button, then
// select it in the picker (the per-row icon would preselect it too).
@@ -236,7 +238,7 @@ test.describe("New workspace entry points", () => {
await expect(trigger).toBeVisible({ timeout: 30_000 });
await trigger.click();
const nonGitOption = page.getByTestId(
`new-workspace-project-picker-option-${nonGitProject.projectId}`,
`new-workspace-project-picker-option-${nonGitProject.projectKey}`,
);
await expect(nonGitOption).toBeVisible({ timeout: 30_000 });
await nonGitOption.click();
@@ -249,7 +251,7 @@ test.describe("New workspace entry points", () => {
// Switching to the git project on the same screen reveals the Isolation row.
await trigger.click();
const gitOption = page.getByTestId(
`new-workspace-project-picker-option-${gitProject.projectId}`,
`new-workspace-project-picker-option-${gitProject.projectKey}`,
);
await expect(gitOption).toBeVisible({ timeout: 30_000 });
await gitOption.click();
@@ -126,7 +126,7 @@ test.describe("New Workspace mode cycle safety", () => {
// so its handler is still registered when we cycle here.
await openGlobalNewWorkspaceComposer(page);
await selectNewWorkspaceProject(page, {
projectKey: seeded.projectId,
projectKey: seeded.projectKey,
projectDisplayName: seeded.projectDisplayName,
});
+7
View File
@@ -195,6 +195,7 @@ async function submitNewWorkspaceWithoutPrompt(page: import("@playwright/test").
test.describe("New workspace flow", () => {
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
const localWorkspaceIds = new Set<string>();
const localProjectIds = new Set<string>();
const createdWorktreeDirectories = new Set<string>();
const localGithubFixtures = new Set<LocalGhPrFixture>();
@@ -212,9 +213,13 @@ test.describe("New workspace flow", () => {
for (const workspaceId of localWorkspaceIds) {
await archiveLocalWorkspaceFromDaemon(client, workspaceId).catch(() => undefined);
}
for (const projectId of localProjectIds) {
await client.removeProject(projectId).catch(() => undefined);
}
}
createdWorktreeDirectories.clear();
localWorkspaceIds.clear();
localProjectIds.clear();
await client?.close().catch(() => undefined);
});
@@ -830,6 +835,7 @@ test.describe("New workspace flow", () => {
const openedProject = await openProjectViaDaemon(client, mainCheckout.path);
localWorkspaceIds.add(openedProject.workspaceId);
localProjectIds.add(openedProject.projectId);
await gotoAppShell(page);
await waitForSidebarHydration(page);
@@ -881,6 +887,7 @@ test.describe("New workspace flow", () => {
const openedProject = await openProjectViaDaemon(client, mainCheckout.path);
localWorkspaceIds.add(openedProject.workspaceId);
localProjectIds.add(openedProject.projectId);
await gotoAppShell(page);
await waitForSidebarHydration(page);
@@ -1,8 +1,10 @@
import { test, expect } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import { injectDesktopBridge, waitForDirectoryDialog } from "./helpers/desktop-updates";
import { getE2EDaemonPort } from "./helpers/daemon-port";
import { waitForConnectedHost } from "./helpers/hosts";
import { expectOpenedProject } from "./helpers/project-picker-ui";
import { expectNewWorkspaceForAddedProject } from "./helpers/add-project-flow";
import { expectNewWorkspaceForAddedProject, openAddProjectFlow } from "./helpers/add-project-flow";
import { getServerId } from "./helpers/server-id";
import { connectSeedClient } from "./helpers/seed-client";
@@ -18,8 +20,12 @@ test("Browse opens the folder selected by the desktop dialog", async ({
dialogOpenResult: projectPickerFixture.projectPath,
});
await gotoAppShell(page);
await waitForConnectedHost(page, {
serverId: getServerId(),
endpoint: `localhost:${getE2EDaemonPort()}`,
});
await page.getByTestId("sidebar-add-project").click();
await openAddProjectFlow(page);
const browse = page.getByRole("button", { name: /^Browse/ });
await expect(browse).toBeVisible({ timeout: 30_000 });
await browse.click();
@@ -50,8 +56,12 @@ test("canceling Browse returns to the Add Project methods", async ({
dialogOpenResult: null,
});
await gotoAppShell(page);
await waitForConnectedHost(page, {
serverId: getServerId(),
endpoint: `localhost:${getE2EDaemonPort()}`,
});
await page.getByTestId("sidebar-add-project").click();
await openAddProjectFlow(page);
const browse = page.getByRole("button", { name: /^Browse/ });
await expect(browse).toBeVisible({ timeout: 30_000 });
await browse.click();
@@ -163,6 +163,7 @@ test.describe("Schedules", () => {
port: fakePort,
serverId: fakeHost.serverId,
workspace: fakeHost.workspace,
project: fakeHost,
schedules: [
buildFakeHostSchedule({
id: scheduleId,
@@ -205,7 +205,7 @@ test.describe("Schedules project target", () => {
await expect(formSheet.getByText("Cron", { exact: true })).toHaveCount(0);
await page.getByRole("button", { name: /select project/i }).click();
await page.getByTestId(`schedule-project-option-${workspace.projectId}`).click();
await page.getByTestId(`schedule-project-option-${workspace.projectKey}`).click();
const projectTrigger = page.getByTestId("schedule-project-trigger");
await expect(projectTrigger).toContainText(workspace.projectDisplayName);
await expectSettled(projectTrigger);
@@ -245,6 +245,7 @@ test.describe("Schedules project target", () => {
port: fakePort,
serverId: fakeHost.serverId,
workspace: fakeHost.workspace,
project: fakeHost,
});
await addFakeScheduleHostAndReload({
page,
@@ -277,6 +278,7 @@ test.describe("Schedules project target", () => {
port: fakePort,
serverId: fakeHost.serverId,
workspace: fakeHost.workspace,
project: fakeHost,
});
await gotoAppShell(page);
@@ -313,7 +315,7 @@ test.describe("Schedules project target", () => {
await expectSettled(hostTrigger);
await projectTrigger.click();
await page.getByTestId(`schedule-project-option-${workspace.projectId}`).click();
await page.getByTestId(`schedule-project-option-${workspace.projectKey}`).click();
await expect(projectTrigger).toContainText(workspace.projectDisplayName);
await expectSettled(projectTrigger);
@@ -335,8 +337,7 @@ test.describe("Schedules project target", () => {
await expectSettled(projectTrigger);
await projectTrigger.click();
await expect(page.getByTestId(`schedule-project-option-${workspace.projectId}`)).toHaveCount(0);
await page.getByTestId(`schedule-project-option-${fakeHost.projectId}`).click();
await page.getByTestId(`schedule-project-option-${fakeHost.projectKey}`).click();
await expect(projectTrigger).toContainText(fakeHost.projectDisplayName);
await expectSettled(projectTrigger);
await expect(modelTrigger).toContainText(/select model/i);
@@ -362,7 +363,7 @@ test.describe("Schedules project target", () => {
await page.getByTestId("schedules-empty-new").click();
await page.getByRole("button", { name: /select project/i }).click();
await page.getByTestId(`schedule-project-option-${workspace.projectId}`).click();
await page.getByTestId(`schedule-project-option-${workspace.projectKey}`).click();
await selectModelByLabel(page, "Ten second stream");
await expect(
page.getByText("Off keeps each run's workspace in the sidebar for inspection."),
+6 -6
View File
@@ -53,8 +53,8 @@ test.describe("Model B sidebar shape", () => {
// Both projects are expandable parents — the non-git one is NOT flattened
// into a bare workspace link.
await expect(projectRow(page, gitProject.projectId)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, nonGitProject.projectId)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, gitProject.projectKey)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, nonGitProject.projectKey)).toBeVisible({ timeout: 30_000 });
// Each parent shows both of its workspace rows underneath.
await expect(workspaceRow(page, gitProject.workspaceId)).toBeVisible({ timeout: 30_000 });
@@ -65,12 +65,12 @@ test.describe("Model B sidebar shape", () => {
// Both projects show a per-row New workspace icon (revealed on hover): the
// git project can branch off a worktree, and the non-git project can add
// another workspace because the host supports workspaceMultiplicity.
await projectRow(page, gitProject.projectId).hover();
await expect(projectNewWorktreeIcon(page, gitProject.projectId)).toBeVisible({
await projectRow(page, gitProject.projectKey).hover();
await expect(projectNewWorktreeIcon(page, gitProject.projectKey)).toBeVisible({
timeout: 30_000,
});
await projectRow(page, nonGitProject.projectId).hover();
await expect(projectNewWorktreeIcon(page, nonGitProject.projectId)).toBeVisible({
await projectRow(page, nonGitProject.projectKey).hover();
await expect(projectNewWorktreeIcon(page, nonGitProject.projectKey)).toBeVisible({
timeout: 30_000,
});
@@ -0,0 +1,404 @@
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { test as base } from "./fixtures";
import {
beginWorkspaceFromProject,
createWorkspaceWithoutAgent,
expectProjectContainsWorkspaces,
expectProjectSettingsName,
expectProjectWorkspaceCountForHost,
expectSeparateProjects,
openGroupedProjectSettings,
openProjectDirectory,
openProjectDirectoryWithHosts,
renameProject,
selectWorkspaceHost,
switchProjectSettingsHost,
} from "./helpers/project-grouping";
import { type IsolatedHostDaemon, startIsolatedHostDaemon } from "./helpers/isolated-host-daemon";
import { connectSeedClient, type SeedDaemonClient } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
import { createTempGitRepo } from "./helpers/workspace";
const PRIMARY_HOST_LABEL = "Primary Host";
const SECONDARY_HOST_LABEL = "Secondary Host";
const LEGACY_PRIMARY_HOST_LABEL = "Legacy Primary Host";
const LEGACY_SECONDARY_HOST_LABEL = "Legacy Secondary Host";
const GROUPED_PROJECT_NAME = "paseo-e2e/grouped-project";
const SHARED_REMOTE_URL = "https://github.com/paseo-e2e/grouped-project.git";
const SUBDIRECTORY = path.join("packages", "app");
const REPO_FILES = [{ path: path.join(SUBDIRECTORY, "package.json"), content: "{}\n" }];
interface HostConnection {
serverId: string;
label: string;
port: number;
}
interface ProjectDirectoryScenario {
hosts: HostConnection[];
primaryLabel?: string;
}
interface CreatedProject {
projectId: string;
}
async function createProject(
client: SeedDaemonClient,
input: {
projectPath: string;
serverId: string;
workspaceName: string;
projectName?: string;
},
): Promise<CreatedProject> {
const created = await client.createWorkspace({
source: { kind: "directory", path: input.projectPath },
title: input.workspaceName,
});
if (!created.workspace) {
throw new Error(created.error ?? `Failed to create project on ${input.serverId}`);
}
if (input.projectName) {
await client.renameProject(created.workspace.projectId, input.projectName);
}
return { projectId: created.workspace.projectId };
}
async function removePersistedProjectKeys(host: IsolatedHostDaemon): Promise<void> {
const projectsPath = path.join(host.paseoHome, "projects", "projects.json");
const projects = JSON.parse(await readFile(projectsPath, "utf8")) as Array<
Record<string, unknown>
>;
for (const project of projects) delete project.projectKey;
await writeFile(projectsPath, JSON.stringify(projects));
}
const test = base.extend<{
crossHostProject: ProjectDirectoryScenario;
reconciledCrossHostProject: ProjectDirectoryScenario;
rootAndSubdirectoryProjects: ProjectDirectoryScenario;
crossHostSubdirectoryProject: ProjectDirectoryScenario;
sameHostClones: ProjectDirectoryScenario;
}>({
crossHostProject: async ({ page: _page }, provide) => {
const secondaryHost = await startIsolatedHostDaemon("project-grouping-secondary");
const primaryRepo = await createTempGitRepo("grouped-primary-", {
originUrl: SHARED_REMOTE_URL,
});
const secondaryRepo = await createTempGitRepo("grouped-secondary-", {
originUrl: SHARED_REMOTE_URL,
});
const primaryClient = await connectSeedClient();
const secondaryClient = await connectSeedClient({ port: secondaryHost.port });
let primary: CreatedProject | null = null;
let secondary: CreatedProject | null = null;
try {
primary = await createProject(primaryClient, {
projectPath: primaryRepo.path,
serverId: getServerId(),
workspaceName: "Primary workspace",
projectName: GROUPED_PROJECT_NAME,
});
secondary = await createProject(secondaryClient, {
projectPath: secondaryRepo.path,
serverId: secondaryHost.serverId,
workspaceName: "Secondary workspace",
projectName: GROUPED_PROJECT_NAME,
});
await provide({
primaryLabel: PRIMARY_HOST_LABEL,
hosts: [
{
serverId: secondaryHost.serverId,
label: SECONDARY_HOST_LABEL,
port: secondaryHost.port,
},
],
});
} finally {
if (primary) await primaryClient.removeProject(primary.projectId).catch(() => undefined);
if (secondary)
await secondaryClient.removeProject(secondary.projectId).catch(() => undefined);
await primaryClient.close().catch(() => undefined);
await secondaryClient.close().catch(() => undefined);
await primaryRepo.cleanup().catch(() => undefined);
await secondaryRepo.cleanup().catch(() => undefined);
await secondaryHost.close().catch(() => undefined);
}
},
reconciledCrossHostProject: async ({ page: _page }, provide) => {
const primaryHost = await startIsolatedHostDaemon("project-grouping-legacy-primary");
const secondaryHost = await startIsolatedHostDaemon("project-grouping-legacy-secondary");
const primaryRepo = await createTempGitRepo("grouped-legacy-primary-", {
originUrl: SHARED_REMOTE_URL,
});
const secondaryRepo = await createTempGitRepo("grouped-legacy-secondary-", {
originUrl: SHARED_REMOTE_URL,
});
const primaryClient = await connectSeedClient({ port: primaryHost.port });
const secondaryClient = await connectSeedClient({ port: secondaryHost.port });
try {
await createProject(primaryClient, {
projectPath: primaryRepo.path,
serverId: primaryHost.serverId,
workspaceName: "Recovered primary workspace",
projectName: GROUPED_PROJECT_NAME,
});
await createProject(secondaryClient, {
projectPath: secondaryRepo.path,
serverId: secondaryHost.serverId,
workspaceName: "Recovered secondary workspace",
projectName: GROUPED_PROJECT_NAME,
});
await primaryClient.close();
await secondaryClient.close();
await removePersistedProjectKeys(primaryHost);
await removePersistedProjectKeys(secondaryHost);
await Promise.all([primaryHost.restart(), secondaryHost.restart()]);
await provide({
hosts: [
{
serverId: primaryHost.serverId,
label: LEGACY_PRIMARY_HOST_LABEL,
port: primaryHost.port,
},
{
serverId: secondaryHost.serverId,
label: LEGACY_SECONDARY_HOST_LABEL,
port: secondaryHost.port,
},
],
});
} finally {
await primaryClient.close().catch(() => undefined);
await secondaryClient.close().catch(() => undefined);
await primaryHost.close().catch(() => undefined);
await secondaryHost.close().catch(() => undefined);
await primaryRepo.cleanup().catch(() => undefined);
await secondaryRepo.cleanup().catch(() => undefined);
}
},
rootAndSubdirectoryProjects: async ({ page: _page }, provide) => {
const repo = await createTempGitRepo("grouped-root-subdir-", {
originUrl: SHARED_REMOTE_URL,
files: REPO_FILES,
});
const client = await connectSeedClient();
const projects: CreatedProject[] = [];
try {
projects.push(
await createProject(client, {
projectPath: repo.path,
serverId: getServerId(),
workspaceName: "Repository root workspace",
projectName: "Repository root project",
}),
);
projects.push(
await createProject(client, {
projectPath: path.join(repo.path, SUBDIRECTORY),
serverId: getServerId(),
workspaceName: "App package workspace",
projectName: "App package project",
}),
);
await provide({ hosts: [] });
} finally {
for (const project of projects) {
await client.removeProject(project.projectId).catch(() => undefined);
}
await client.close().catch(() => undefined);
await repo.cleanup().catch(() => undefined);
}
},
crossHostSubdirectoryProject: async ({ page: _page }, provide) => {
const secondaryHost = await startIsolatedHostDaemon("project-grouping-subdir-secondary");
const primaryRepo = await createTempGitRepo("grouped-subdir-primary-", {
originUrl: SHARED_REMOTE_URL,
files: REPO_FILES,
});
const secondaryRepo = await createTempGitRepo("grouped-subdir-secondary-", {
originUrl: SHARED_REMOTE_URL,
files: REPO_FILES,
});
const primaryClient = await connectSeedClient();
const secondaryClient = await connectSeedClient({ port: secondaryHost.port });
let primary: CreatedProject | null = null;
let secondary: CreatedProject | null = null;
try {
primary = await createProject(primaryClient, {
projectPath: path.join(primaryRepo.path, SUBDIRECTORY),
serverId: getServerId(),
workspaceName: "Primary app workspace",
projectName: GROUPED_PROJECT_NAME,
});
secondary = await createProject(secondaryClient, {
projectPath: path.join(secondaryRepo.path, SUBDIRECTORY),
serverId: secondaryHost.serverId,
workspaceName: "Secondary app workspace",
projectName: GROUPED_PROJECT_NAME,
});
await provide({
primaryLabel: PRIMARY_HOST_LABEL,
hosts: [
{
serverId: secondaryHost.serverId,
label: SECONDARY_HOST_LABEL,
port: secondaryHost.port,
},
],
});
} finally {
if (primary) await primaryClient.removeProject(primary.projectId).catch(() => undefined);
if (secondary)
await secondaryClient.removeProject(secondary.projectId).catch(() => undefined);
await primaryClient.close().catch(() => undefined);
await secondaryClient.close().catch(() => undefined);
await primaryRepo.cleanup().catch(() => undefined);
await secondaryRepo.cleanup().catch(() => undefined);
await secondaryHost.close().catch(() => undefined);
}
},
sameHostClones: async ({ page: _page }, provide) => {
const firstRepo = await createTempGitRepo("grouped-clone-first-", {
originUrl: SHARED_REMOTE_URL,
});
const secondRepo = await createTempGitRepo("grouped-clone-second-", {
originUrl: SHARED_REMOTE_URL,
});
const client = await connectSeedClient();
const projects: CreatedProject[] = [];
try {
projects.push(
await createProject(client, {
projectPath: firstRepo.path,
serverId: getServerId(),
workspaceName: "First clone workspace",
projectName: "First clone",
}),
);
projects.push(
await createProject(client, {
projectPath: secondRepo.path,
serverId: getServerId(),
workspaceName: "Second clone workspace",
projectName: "Second clone",
}),
);
await provide({ hosts: [] });
} finally {
for (const project of projects) {
await client.removeProject(project.projectId).catch(() => undefined);
}
await client.close().catch(() => undefined);
await firstRepo.cleanup().catch(() => undefined);
await secondRepo.cleanup().catch(() => undefined);
}
},
});
async function openScenario(
page: Parameters<typeof openProjectDirectory>[0],
scenario: ProjectDirectoryScenario,
): Promise<void> {
if (scenario.hosts.length === 0) {
await openProjectDirectory(page);
return;
}
await openProjectDirectoryWithHosts(page, scenario);
}
test.describe("Sidebar project grouping", () => {
test.describe.configure({ timeout: 120_000 });
test("groups projects with the same Git remote across hosts", async ({
page,
crossHostProject,
}) => {
await openScenario(page, crossHostProject);
await expectProjectContainsWorkspaces(page, {
projectName: GROUPED_PROJECT_NAME,
workspaceNames: ["Primary workspace", "Secondary workspace"],
});
});
test("groups persisted projects missing project keys after boot", async ({
page,
reconciledCrossHostProject,
}) => {
await openScenario(page, reconciledCrossHostProject);
await expectProjectContainsWorkspaces(page, {
projectName: GROUPED_PROJECT_NAME,
workspaceNames: ["Recovered primary workspace", "Recovered secondary workspace"],
});
});
test("keeps a repository root and its subdirectory as separate projects", async ({
page,
rootAndSubdirectoryProjects,
}) => {
await openScenario(page, rootAndSubdirectoryProjects);
await expectSeparateProjects(page, [
{ projectName: "Repository root project", workspaceName: "Repository root workspace" },
{ projectName: "App package project", workspaceName: "App package workspace" },
]);
});
test("groups the same repository subdirectory across hosts", async ({
page,
crossHostSubdirectoryProject,
}) => {
await openScenario(page, crossHostSubdirectoryProject);
await expectProjectContainsWorkspaces(page, {
projectName: GROUPED_PROJECT_NAME,
workspaceNames: ["Primary app workspace", "Secondary app workspace"],
});
});
test("keeps two clones of the same repository on one host separate", async ({
page,
sameHostClones,
}) => {
await openScenario(page, sameHostClones);
await expectSeparateProjects(page, [
{ projectName: "First clone", workspaceName: "First clone workspace" },
{ projectName: "Second clone", workspaceName: "Second clone workspace" },
]);
});
test("renames only the selected host's grouped project", async ({ page, crossHostProject }) => {
await openScenario(page, crossHostProject);
await openGroupedProjectSettings(page, GROUPED_PROJECT_NAME);
await renameProject(page, "Primary-only project name");
await expectProjectSettingsName(page, "Primary-only project name");
await switchProjectSettingsHost(page, SECONDARY_HOST_LABEL);
await expectProjectSettingsName(page, GROUPED_PROJECT_NAME);
});
test("creates a workspace on the selected host from a grouped project", async ({
page,
crossHostProject,
}) => {
await openScenario(page, crossHostProject);
await beginWorkspaceFromProject(page, GROUPED_PROJECT_NAME);
await selectWorkspaceHost(page, SECONDARY_HOST_LABEL);
await createWorkspaceWithoutAgent(page);
await expectProjectWorkspaceCountForHost(page, {
projectName: GROUPED_PROJECT_NAME,
hostName: SECONDARY_HOST_LABEL,
count: 2,
});
});
});
+1 -1
View File
@@ -219,7 +219,7 @@ test.describe("Half-screen desktop layout", () => {
}
await gotoAppShell(page);
await page.getByTestId(`sidebar-project-show-more-${workspace.projectId}`).click();
await page.getByTestId(`sidebar-project-show-more-${workspace.projectKey}`).click();
await waitForSidebarWorkspace(page, lastWorkspaceId);
const sidebarScroll = page.getByTestId("sidebar-project-workspace-list-scroll");
@@ -32,8 +32,12 @@ interface RestartDaemonClient {
name: string;
status: string;
workspaceDirectory: string;
projectId: string;
}>;
}>;
listProjects(): Promise<{
projects: Array<{ projectId: string; projectKey?: string }>;
}>;
fetchAgents(options?: { scope?: "active" }): Promise<{
entries: Array<{
agent: {
@@ -461,9 +465,16 @@ test.describe("Workspace model restart regressions", () => {
.poll(() => getVisibleWorkspaceAgentTabIds(page), { timeout: 30_000 })
.toContain(`workspace-tab-agent_${LEGACY_AGENT_ID}`);
const reconciledProjectKey = (await client.listProjects()).projects.find(
(project) => project.projectId === seeded.projectId,
);
if (!reconciledProjectKey?.projectKey) {
throw new Error(`Workspace ${seeded.workspaceA} was not reconciled with a project key`);
}
await openGlobalNewWorkspaceComposer(page);
await selectNewWorkspaceProject(page, {
projectKey: seeded.projectId,
projectKey: reconciledProjectKey.projectKey,
projectDisplayName: seeded.projectDisplayName,
});
await expectNewWorkspaceProjectSelected(page, seeded.projectDisplayName);
@@ -84,7 +84,7 @@ test.describe("Workspace multiplicity creation flow", () => {
try {
const project = {
projectKey: seeded.projectId,
projectKey: seeded.projectKey,
projectDisplayName: seeded.projectDisplayName,
};
@@ -136,7 +136,7 @@ test.describe("Workspace multiplicity creation flow", () => {
try {
const project = {
projectKey: seeded.projectId,
projectKey: seeded.projectKey,
projectDisplayName: seeded.projectDisplayName,
};
@@ -182,7 +182,7 @@ test.describe("Workspace multiplicity creation flow", () => {
try {
const project = {
projectKey: seeded.projectId,
projectKey: seeded.projectKey,
projectDisplayName: seeded.projectDisplayName,
};
@@ -190,7 +190,7 @@ test.describe("Workspace multiplicity creation flow", () => {
await waitForSidebarHydration(page);
// Model B: a non-git project is an expandable parent like any other, with
// its single workspace already rendered as its own row underneath.
await expect(page.getByTestId(`sidebar-project-row-${seeded.projectId}`)).toBeVisible({
await expect(page.getByTestId(`sidebar-project-row-${seeded.projectKey}`)).toBeVisible({
timeout: 30_000,
});
await expect(page.getByTestId(workspaceRowTestId(seeded.workspaceId))).toBeVisible({
@@ -210,7 +210,7 @@ test.describe("Workspace multiplicity creation flow", () => {
// Both the original and the new workspace render as distinct rows under
// the same expandable parent.
await expect(page.getByTestId(`sidebar-project-row-${seeded.projectId}`)).toBeVisible({
await expect(page.getByTestId(`sidebar-project-row-${seeded.projectKey}`)).toBeVisible({
timeout: 30_000,
});
await expect(page.getByTestId(workspaceRowTestId(seeded.workspaceId))).toBeVisible({
+1 -1
View File
@@ -883,7 +883,7 @@ function RootStack() {
<Stack.Screen name="settings/index" />
<Stack.Screen name="settings/[section]" />
<Stack.Screen name="settings/projects/index" />
<Stack.Screen name="settings/projects/[projectKey]" />
<Stack.Screen name="settings/projects/[serverId]/[projectId]" />
<Stack.Screen name="new" />
<Stack.Screen name="open-project" />
<Stack.Screen name="sessions" />
@@ -1,12 +0,0 @@
import { useLocalSearchParams } from "expo-router";
import { useMemo } from "react";
import SettingsScreen from "@/screens/settings-screen";
export default function SettingsProjectDetailRoute() {
const params = useLocalSearchParams<{ projectKey?: string | string[] }>();
const rawProjectKey = Array.isArray(params.projectKey) ? params.projectKey[0] : params.projectKey;
const projectKey = typeof rawProjectKey === "string" ? decodeURIComponent(rawProjectKey) : "";
const view = useMemo(() => ({ kind: "project" as const, projectKey }), [projectKey]);
return <SettingsScreen view={view} />;
}
@@ -0,0 +1,19 @@
import { useLocalSearchParams } from "expo-router";
import { useMemo } from "react";
import SettingsScreen from "@/screens/settings-screen";
import { normalizeProjectSettingsRouteId } from "@/utils/host-routes";
export default function SettingsProjectDetailRoute() {
const params = useLocalSearchParams<{
serverId?: string | string[];
projectId?: string | string[];
}>();
const serverId = normalizeProjectSettingsRouteId(params.serverId);
const projectId = normalizeProjectSettingsRouteId(params.projectId);
const view = useMemo(
() => ({ kind: "project" as const, serverId, projectId }),
[projectId, serverId],
);
return <SettingsScreen view={view} />;
}
@@ -365,7 +365,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
const recommendedPaths = useRecommendedProjectPaths(hostId);
const openProject = useOpenProject(hostId);
const cloneGithubProject = useCloneGithubProject(hostId);
const addEmptyProject = useSessionStore((store) => store.addEmptyProject);
const upsertProject = useSessionStore((store) => store.upsertProject);
const setHasHydratedWorkspaces = useSessionStore((store) => store.setHasHydratedWorkspaces);
const inputRef = useRef<TextInput>(null);
const submissionInFlightRef = useRef(false);
@@ -735,7 +735,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
registerProjectDescriptor({
serverId: page.hostId,
project: payload.project,
addEmptyProject,
upsertProject,
setHasHydratedWorkspaces,
});
openNewWorkspaceForProject(page.hostId, payload.project);
@@ -749,7 +749,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
} finally {
submissionInFlightRef.current = false;
}
}, [addEmptyProject, client, openNewWorkspaceForProject, page, setHasHydratedWorkspaces]);
}, [client, openNewWorkspaceForProject, page, setHasHydratedWorkspaces, upsertProject]);
const submitActive = useCallback(() => {
if (page.kind === "new-directory-name") {
@@ -38,6 +38,7 @@ import {
} from "@/runtime/host-runtime";
import type { HostProfile } from "@/types/host-connection";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import { seedSessionWorkspaces } from "@/test/seed-session";
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
import { useWorkspaceFields } from "@/stores/session-store-hooks";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
@@ -164,9 +165,7 @@ function initializeSidebarState(workspaces: WorkspaceDescriptor[]): void {
act(() => {
setHostProfiles([makeHost()]);
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
useSessionStore
.getState()
.setWorkspaces(SERVER_ID, new Map(workspaces.map((entry) => [entry.id, entry])));
seedSessionWorkspaces(SERVER_ID, new Map(workspaces.map((entry) => [entry.id, entry])));
useSessionStore.getState().setHasHydratedWorkspaces(SERVER_ID, true);
useSidebarOrderStore.setState({
projectOrder: ["project-a", "project-b"],
@@ -115,6 +115,7 @@ import type { PrHint } from "@/git/use-pr-status-query";
import {
buildSidebarProjectRowModel,
resolveSidebarProjectIconTarget,
resolveSidebarProjectLocalPath,
type SidebarProjectHostTarget,
} from "@/utils/sidebar-project-row-model";
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
@@ -132,6 +133,7 @@ import {
} from "@/constants/platform";
import { getDesktopHost } from "@/desktop/host";
import { OpenInFileManagerMenuItem } from "@/workspace/open-in-file-manager/menu-item";
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
const workspaceKeyExtractor = (workspace: SidebarWorkspacePlacement) => workspace.workspaceKey;
@@ -505,6 +507,9 @@ function ProjectRowTrailingActions({
removeProjectStatus: "idle" | "pending" | "success";
}) {
const actionsVisible = isHovered || platformIsNative || isMobileBreakpoint;
const localDaemonServerId = useLocalDaemonServerId();
const localProjectPath = resolveSidebarProjectLocalPath(project, localDaemonServerId);
const settingsTarget = project.hosts[0] ?? null;
return (
<View style={styles.projectTrailingActions}>
{worktreeTarget ? (
@@ -523,7 +528,8 @@ function ProjectRowTrailingActions({
>
<ProjectKebabMenu
projectKey={project.projectKey}
projectPath={project.iconWorkingDir}
settingsTarget={settingsTarget}
projectPath={localProjectPath}
onRemoveProject={onRemoveProject}
removeProjectStatus={removeProjectStatus}
/>
@@ -550,11 +556,13 @@ function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) {
function ProjectKebabMenu({
projectKey,
settingsTarget,
projectPath,
onRemoveProject,
removeProjectStatus,
}: {
projectKey: string;
settingsTarget: { serverId: string; projectId: string } | null;
projectPath: string;
onRemoveProject: () => void;
removeProjectStatus: "idle" | "pending" | "success";
@@ -562,10 +570,10 @@ function ProjectKebabMenu({
const { t } = useTranslation();
const toast = useToast();
const handleOpenProjectSettings = useCallback(() => {
if (projectKey.trim().length === 0) return;
router.navigate(buildProjectSettingsRoute(projectKey));
}, [projectKey]);
const canOpenProjectSettings = projectKey.trim().length > 0;
if (!settingsTarget) return;
router.navigate(buildProjectSettingsRoute(settingsTarget.serverId, settingsTarget.projectId));
}, [settingsTarget]);
const canOpenProjectSettings = settingsTarget !== null;
// Desktop-only: open a second window that lands on this project via the same
// open-project flow as a CLI launch. The project stays visible here too — no
// ownership, no move.
@@ -908,10 +916,10 @@ function NewWorkspaceGhostRow({
serverId: worktreeTarget.serverId,
sourceDirectory: worktreeTarget.iconWorkingDir,
displayName,
projectId: project.projectKey,
projectId: worktreeTarget.projectId,
}) as Href,
);
}, [displayName, onWorkspacePress, project.projectKey, worktreeTarget]);
}, [displayName, onWorkspacePress, worktreeTarget]);
const rowStyle = useCallback(
({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.newWorkspaceGhostRow,
@@ -988,10 +996,10 @@ function ProjectHeaderRow({
serverId: worktreeTarget.serverId,
sourceDirectory: worktreeTarget.iconWorkingDir,
displayName,
projectId: project.projectKey,
projectId: worktreeTarget.projectId,
}) as Href,
);
}, [displayName, onWorkspacePress, project.projectKey, worktreeTarget]);
}, [displayName, onWorkspacePress, worktreeTarget]);
const interaction = useLongPressDragInteraction({
drag,
menuController,
@@ -1755,7 +1763,6 @@ function ProjectBlock({
}
void removeProjectFromHosts({
projectKey: project.projectKey,
targets: readiness.targets,
getClient: (serverId) => getHostRuntimeStore().getClient(serverId),
})
@@ -1824,7 +1831,7 @@ function ProjectBlock({
}
return (
<View style={styles.projectBlock}>
<View role="group" accessibilityLabel={displayName} style={styles.projectBlock}>
<ProjectHeaderRow
project={project}
displayName={displayName}
@@ -43,6 +43,7 @@ function makeProject(workspaces: SidebarWorkspacePlacement[]): SidebarProjectEnt
hosts: [
{
serverId: "srv",
projectId: "project",
iconWorkingDir: "/repo",
canCreateWorktree: true,
},
+8 -1
View File
@@ -299,7 +299,14 @@ export function ComboboxItem({
);
return (
<Pressable testID={testID} disabled={disabled} onPress={onPress} style={itemPressableStyle}>
<Pressable
testID={testID}
accessibilityRole="button"
accessibilityLabel={label}
disabled={disabled}
onPress={onPress}
style={itemPressableStyle}
>
{leadingContent}
<View style={itemContentStyle}>
<Text numberOfLines={1} style={styles.comboboxItemLabel}>
@@ -9,6 +9,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import { seedSessionWorkspaces } from "@/test/seed-session";
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
import { useSidebarViewStore } from "@/stores/sidebar-view-store";
@@ -93,7 +94,7 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
act(() => {
setHostProfiles([hostProfile()]);
useSessionStore.getState().initializeSession("srv", null as unknown as DaemonClient);
useSessionStore.getState().setWorkspaces(
seedSessionWorkspaces(
"srv",
new Map([
["ws-1", workspaceDescriptor({ id: "ws-1", name: "Workspace 1" })],
@@ -139,7 +140,7 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
it("publishes status-mode shortcut targets in visual status order", async () => {
act(() => {
useSidebarViewStore.getState().setGroupMode("status");
useSessionStore.getState().setWorkspaces(
seedSessionWorkspaces(
"srv",
new Map([
[
@@ -211,18 +212,14 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
setHostProfiles([hostProfile("host-a"), hostProfile("host-b")]);
useSessionStore.getState().initializeSession("host-a", null as unknown as DaemonClient);
useSessionStore.getState().initializeSession("host-b", null as unknown as DaemonClient);
useSessionStore
.getState()
.setWorkspaces(
"host-a",
new Map([["a-1", workspaceDescriptor({ id: "a-1", name: "Host A" })]]),
);
useSessionStore
.getState()
.setWorkspaces(
"host-b",
new Map([["b-1", workspaceDescriptor({ id: "b-1", name: "Host B" })]]),
);
seedSessionWorkspaces(
"host-a",
new Map([["a-1", workspaceDescriptor({ id: "a-1", name: "Host A" })]]),
);
seedSessionWorkspaces(
"host-b",
new Map([["b-1", workspaceDescriptor({ id: "b-1", name: "Host B" })]]),
);
useSessionStore.getState().setHasHydratedWorkspaces("host-a", true);
useSessionStore.getState().setHasHydratedWorkspaces("host-b", true);
useSidebarViewStore.getState().toggleHostFilter("host-b");
@@ -3,101 +3,56 @@ import {
buildWorktreeSetupCalloutPolicy,
selectActiveGitWorkspaceProject,
shouldShowWorktreeSetupCallout,
type WorktreeSetupWorkspaceInput,
} from "./worktree-setup-callout-policy";
function gitWorkspace(
overrides: Partial<WorktreeSetupWorkspaceInput> = {},
): WorktreeSetupWorkspaceInput {
return {
projectId: "project-1",
projectKind: "git",
projectRootPath: "/repo/project-1",
project: { checkout: { mainRepoRoot: "/repo/main-project-1" } },
...overrides,
};
}
describe("selectActiveGitWorkspaceProject", () => {
it("selects the active git workspace project from checkout metadata", () => {
expect(selectActiveGitWorkspaceProject("server-1", gitWorkspace())).toEqual({
serverId: "server-1",
projectKey: "project-1",
repoRoot: "/repo/main-project-1",
});
});
it("falls back to the workspace project root when checkout metadata has no main root", () => {
it("selects the host-local project id for a git workspace", () => {
expect(
selectActiveGitWorkspaceProject(
"server-1",
gitWorkspace({ project: { checkout: { mainRepoRoot: null } } }),
),
selectActiveGitWorkspaceProject("server-1", {
projectId: "prj_local",
projectKind: "git",
projectRootPath: "/repo/project",
}),
).toEqual({
serverId: "server-1",
projectKey: "project-1",
repoRoot: "/repo/project-1",
projectId: "prj_local",
repoRoot: "/repo/project",
});
});
it("ignores non-git workspaces and blank project coordinates", () => {
it("ignores non-git workspaces and blank coordinates", () => {
expect(
selectActiveGitWorkspaceProject("server-1", gitWorkspace({ projectKind: "local" })),
).toBe(null);
expect(selectActiveGitWorkspaceProject("server-1", gitWorkspace({ projectId: " " }))).toBe(
null,
);
expect(
selectActiveGitWorkspaceProject(
"server-1",
gitWorkspace({ projectRootPath: " ", project: null }),
),
).toBe(null);
selectActiveGitWorkspaceProject("server-1", {
projectId: "project",
projectKind: "directory",
projectRootPath: "/repo",
}),
).toBeNull();
});
});
describe("shouldShowWorktreeSetupCallout", () => {
it("shows the callout when paseo config was read and setup commands are missing", () => {
it("shows only after a successful config read with no setup command", () => {
expect(shouldShowWorktreeSetupCallout({ ok: true, config: {} })).toBe(true);
expect(shouldShowWorktreeSetupCallout({ ok: true, config: null })).toBe(true);
});
it("does not show the callout when setup commands are present", () => {
expect(
shouldShowWorktreeSetupCallout({ ok: true, config: { worktree: { setup: "npm install" } } }),
shouldShowWorktreeSetupCallout({ ok: true, config: { worktree: { setup: "npm i" } } }),
).toBe(false);
expect(
shouldShowWorktreeSetupCallout({
ok: true,
config: { worktree: { setup: [" ", "npm install"] } },
}),
).toBe(false);
});
it("does not show the callout when reading paseo config fails or has not completed", () => {
expect(shouldShowWorktreeSetupCallout(undefined)).toBe(false);
expect(shouldShowWorktreeSetupCallout({ ok: false })).toBe(false);
});
});
describe("buildWorktreeSetupCalloutPolicy", () => {
it("builds the stable sidebar callout identity and action route", () => {
it("routes by server id and project id", () => {
expect(
buildWorktreeSetupCalloutPolicy({
serverId: "server-1",
projectKey: "project-1",
repoRoot: "/repo/project-1",
projectId: "prj_local",
repoRoot: "/repo/project",
}),
).toEqual({
id: "worktree-setup-missing:project-1",
dismissalKey: "worktree-setup-missing:project-1",
priority: 100,
title: "Set up worktree scripts",
description:
"Add setup commands so new worktrees can install dependencies and prepare themselves automatically.",
actionLabel: "Open project settings",
projectSettingsRoute: "/settings/projects/project-1",
testID: "worktree-setup-callout-project-1",
).toMatchObject({
id: "worktree-setup-missing:server-1:prj_local",
projectSettingsRoute: "/settings/projects/server-1/prj_local",
testID: "worktree-setup-callout-prj_local",
});
});
});
@@ -6,16 +6,11 @@ export interface WorktreeSetupWorkspaceInput {
projectId: string;
projectKind: string;
projectRootPath: string;
project?: {
checkout?: {
mainRepoRoot?: string | null;
} | null;
} | null;
}
export interface ActiveGitWorkspaceProject {
serverId: string;
projectKey: string;
projectId: string;
repoRoot: string;
}
@@ -43,13 +38,13 @@ export function selectActiveGitWorkspaceProject(
return null;
}
const projectKey = workspace.projectId.trim();
const repoRoot = (workspace.project?.checkout?.mainRepoRoot ?? workspace.projectRootPath).trim();
if (!projectKey || !repoRoot) {
const projectId = workspace.projectId;
const repoRoot = workspace.projectRootPath.trim();
if (!projectId.trim() || !repoRoot) {
return null;
}
return { serverId, projectKey, repoRoot };
return { serverId, projectId, repoRoot };
}
export function shouldShowWorktreeSetupCallout(readResult: ReadProjectConfigResult | undefined) {
@@ -59,7 +54,7 @@ export function shouldShowWorktreeSetupCallout(readResult: ReadProjectConfigResu
export function buildWorktreeSetupCalloutPolicy(
project: ActiveGitWorkspaceProject,
): WorktreeSetupCalloutPolicy {
const calloutKey = `worktree-setup-missing:${project.projectKey}`;
const calloutKey = `worktree-setup-missing:${project.serverId}:${project.projectId}`;
return {
id: calloutKey,
@@ -68,8 +63,8 @@ export function buildWorktreeSetupCalloutPolicy(
title: i18n.t("sidebar.worktreeSetup.title"),
description: i18n.t("sidebar.worktreeSetup.description"),
actionLabel: i18n.t("sidebar.worktreeSetup.openProjectSettings"),
projectSettingsRoute: buildProjectSettingsRoute(project.projectKey),
testID: `worktree-setup-callout-${project.projectKey}`,
projectSettingsRoute: buildProjectSettingsRoute(project.serverId, project.projectId),
testID: `worktree-setup-callout-${project.projectId}`,
};
}
@@ -14,11 +14,12 @@ import {
export function WorktreeSetupCalloutSource() {
const selection = useActiveWorkspaceSelection();
const activeProject = useWorkspaceFields(
const selectedWorkspaceProject = useWorkspaceFields(
selection?.serverId ?? null,
selection?.workspaceId ?? null,
(workspace) => selectActiveGitWorkspaceProject(selection?.serverId ?? "", workspace),
);
const activeProject = selectedWorkspaceProject;
const client = useHostRuntimeClient(activeProject?.serverId ?? "");
const callouts = useSidebarCallouts();
const router = useRouter();
+7 -10
View File
@@ -4,10 +4,7 @@ import type {
ProjectAddResponse,
WorkspaceProjectDescriptorPayload,
} from "@getpaseo/protocol/messages";
import {
normalizeEmptyProjectDescriptor as normalizeProjectWithoutWorkspacesDescriptor,
type EmptyProjectDescriptor as ProjectWithoutWorkspacesDescriptor,
} from "@/stores/session-store";
import { normalizeProjectDescriptor, type ProjectDescriptor } from "@/stores/session-store";
type OpenProjectPayload = ProjectAddResponse["payload"];
type OpenProjectErrorCode = NonNullable<OpenProjectPayload["errorCode"]>;
@@ -47,28 +44,28 @@ export interface OpenProjectDirectlyInput {
isConnected: boolean;
canAddProject: boolean;
client: Pick<DaemonClient, "addProject"> | null;
addEmptyProject: (serverId: string, project: ProjectWithoutWorkspacesDescriptor) => void;
upsertProject: (serverId: string, project: ProjectDescriptor) => void;
setHasHydratedWorkspaces: (serverId: string, hydrated: boolean) => void;
}
interface ProjectRegistrationCallbacks {
serverId: string;
isConnected: boolean;
addEmptyProject: (serverId: string, project: ProjectWithoutWorkspacesDescriptor) => void;
upsertProject: (serverId: string, project: ProjectDescriptor) => void;
setHasHydratedWorkspaces: (serverId: string, hydrated: boolean) => void;
}
export interface RegisterProjectDescriptorInput {
serverId: string;
project: WorkspaceProjectDescriptorPayload;
addEmptyProject: (serverId: string, project: ProjectWithoutWorkspacesDescriptor) => void;
upsertProject: (serverId: string, project: ProjectDescriptor) => void;
setHasHydratedWorkspaces: (serverId: string, hydrated: boolean) => void;
}
export function registerProjectDescriptor(input: RegisterProjectDescriptorInput): boolean {
const serverId = input.serverId.trim();
if (!serverId) return false;
input.addEmptyProject(serverId, normalizeProjectWithoutWorkspacesDescriptor(input.project));
input.upsertProject(serverId, normalizeProjectDescriptor(input.project));
input.setHasHydratedWorkspaces(serverId, true);
return true;
}
@@ -109,7 +106,7 @@ export async function openProjectDirectly(
const registered = registerProjectDescriptor({
serverId: normalizedServerId,
project: payload.project,
addEmptyProject: input.addEmptyProject,
upsertProject: input.upsertProject,
setHasHydratedWorkspaces: input.setHasHydratedWorkspaces,
});
return registered
@@ -145,7 +142,7 @@ export async function cloneGithubProjectDirectly(
const registered = registerProjectDescriptor({
serverId: normalizedServerId,
project: payload.project,
addEmptyProject: input.addEmptyProject,
upsertProject: input.upsertProject,
setHasHydratedWorkspaces: input.setHasHydratedWorkspaces,
});
return registered
@@ -78,20 +78,25 @@ function project(input: {
projectKind?: WorkspaceStructureProject["projectKind"];
iconWorkingDir?: string;
workspaceKeys: string[];
hosts?: WorkspaceStructureProject["hosts"];
hosts?: Array<
Omit<WorkspaceStructureProject["hosts"][number], "projectId"> & { projectId?: string }
>;
}): WorkspaceStructureProject {
return {
projectKey: input.projectKey,
projectName: input.projectName ?? input.projectKey,
projectKind: input.projectKind ?? "git",
iconWorkingDir: input.iconWorkingDir ?? input.projectKey,
hosts: input.hosts ?? [
{
serverId: "srv",
iconWorkingDir: input.iconWorkingDir ?? input.projectKey,
canCreateWorktree: true,
},
],
hosts: Array.from(
input.hosts ?? [
{
serverId: "srv",
iconWorkingDir: input.iconWorkingDir ?? input.projectKey,
canCreateWorktree: true,
},
],
(host) => Object.assign({}, host, { projectId: host.projectId ?? input.projectKey }),
),
workspaceKeys: input.workspaceKeys,
};
}
@@ -324,8 +329,18 @@ describe("shared sidebar workspace model", () => {
expect.objectContaining({
projectKey: "getpaseo/paseo",
hosts: [
{ serverId: "host-a", iconWorkingDir: "/repo/getpaseo/paseo", canCreateWorktree: true },
{ serverId: "host-b", iconWorkingDir: "/repo/getpaseo/paseo", canCreateWorktree: true },
{
serverId: "host-a",
projectId: "getpaseo/paseo",
iconWorkingDir: "/repo/getpaseo/paseo",
canCreateWorktree: true,
},
{
serverId: "host-b",
projectId: "getpaseo/paseo",
iconWorkingDir: "/repo/getpaseo/paseo",
canCreateWorktree: true,
},
],
workspaces: [
expect.objectContaining({
@@ -401,6 +416,35 @@ describe("shared sidebar workspace model", () => {
expect(nextEntries.get("srv:one")).toBe(previousEntries.get("srv:one"));
expect(nextEntries.get("srv:two")).not.toBe(previousEntries.get("srv:two"));
});
it("keeps a structurally disambiguated project key in status entries", () => {
const projectKey = "host:srv:project:prj_a";
const model = buildSidebarWorkspacePlacementModel({
projects: [project({ projectKey, projectName: "Clone A", workspaceKeys: ["srv:clone-a"] })],
});
const entries = buildSidebarWorkspaceEntries({
placements: model.workspaces,
sessions: [
{
serverId: "srv",
workspaceAgentActivity: new Map(),
workspaces: new Map([
[
"clone-a",
workspace({
id: "clone-a",
name: "main",
projectId: "prj_a",
projectDisplayName: "acme/app",
}),
],
]),
},
],
});
expect(entries.get("srv:clone-a")?.projectKey).toBe(projectKey);
});
});
describe("shouldShowSidebarHostLabels", () => {
@@ -141,10 +141,11 @@ function normalizeCurrentBranch(currentBranch: string | null | undefined): strin
export function createSidebarWorkspaceEntry(input: {
serverId: string;
workspace: WorkspaceDescriptor;
projectKey?: string;
pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
workspaceAgentActivity?: ReadonlyMap<string, WorkspaceAgentActivity>;
}): SidebarWorkspaceEntry {
const projectKey = input.workspace.project?.projectKey ?? input.workspace.projectId;
const projectKey = input.projectKey ?? input.workspace.projectId;
const effectiveStatus = deriveEffectiveWorkspaceStatus(input);
return {
workspaceKey: `${input.serverId}:${input.workspace.id}`,
@@ -323,6 +324,7 @@ export function buildSidebarWorkspaceEntries(input: {
const entry = createSidebarWorkspaceEntry({
serverId: placement.serverId,
workspace,
projectKey: placement.projectKey,
pendingCreateAttempts: input.pendingCreateAttempts,
workspaceAgentActivity: session.workspaceAgentActivity,
});
@@ -4,7 +4,7 @@ import {
getOpenProjectFailureReason,
openProjectDirectly,
} from "@/hooks/open-project";
import type { EmptyProjectDescriptor as ProjectWithoutWorkspacesDescriptor } from "@/stores/session-store";
import type { ProjectDescriptor } from "@/stores/session-store";
const SERVER_ID = "server-1";
const PROJECT_PATH = "/repo/project";
@@ -20,7 +20,7 @@ function buildProjectPayload() {
interface RecordedProject {
serverId: string;
project: ProjectWithoutWorkspacesDescriptor;
project: ProjectDescriptor;
}
interface RecordedHydrated {
@@ -40,7 +40,7 @@ function createFakeSession() {
return {
projects,
hydrated,
addEmptyProject: (serverId: string, project: ProjectWithoutWorkspacesDescriptor) => {
upsertProject: (serverId: string, project: ProjectDescriptor) => {
projects.push({ serverId, project });
},
setHasHydratedWorkspaces: (serverId: string, value: boolean) => {
@@ -83,7 +83,7 @@ describe("openProjectDirectly", () => {
project: projectPayload,
}),
},
addEmptyProject: session.addEmptyProject,
upsertProject: session.upsertProject,
setHasHydratedWorkspaces: session.setHasHydratedWorkspaces,
});
@@ -93,6 +93,7 @@ describe("openProjectDirectly", () => {
serverId: SERVER_ID,
project: {
projectId: "project-1",
projectKey: null,
projectDisplayName: "project",
projectCustomName: null,
projectKind: "git",
@@ -117,7 +118,7 @@ describe("openProjectDirectly", () => {
project: buildProjectPayload(),
}),
},
addEmptyProject: session.addEmptyProject,
upsertProject: session.upsertProject,
setHasHydratedWorkspaces: session.setHasHydratedWorkspaces,
});
@@ -146,7 +147,7 @@ describe("openProjectDirectly", () => {
project: null,
}),
},
addEmptyProject: session.addEmptyProject,
upsertProject: session.upsertProject,
setHasHydratedWorkspaces: session.setHasHydratedWorkspaces,
});
@@ -173,7 +174,7 @@ describe("cloneGithubProjectDirectly", () => {
cloneProtocol: "https",
isConnected: true,
client: github,
addEmptyProject: session.addEmptyProject,
upsertProject: session.upsertProject,
setHasHydratedWorkspaces: session.setHasHydratedWorkspaces,
});
@@ -191,6 +192,7 @@ describe("cloneGithubProjectDirectly", () => {
project: {
...projectPayload,
projectCustomName: null,
projectKey: null,
},
},
]);
@@ -208,7 +210,7 @@ describe("cloneGithubProjectDirectly", () => {
cloneProtocol: "https",
isConnected: true,
client: github,
addEmptyProject: session.addEmptyProject,
upsertProject: session.upsertProject,
setHasHydratedWorkspaces: session.setHasHydratedWorkspaces,
});
+6 -6
View File
@@ -20,7 +20,7 @@ export function useOpenProject(
state.sessions[normalizedServerId]?.serverInfo?.features?.stableProjectIdentity === true
: false,
);
const addEmptyProject = useSessionStore((state) => state.addEmptyProject);
const upsertProject = useSessionStore((state) => state.upsertProject);
const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces);
return useCallback(
@@ -31,13 +31,13 @@ export function useOpenProject(
isConnected,
canAddProject,
client,
addEmptyProject,
upsertProject,
setHasHydratedWorkspaces,
});
return result;
},
[
addEmptyProject,
upsertProject,
canAddProject,
client,
isConnected,
@@ -57,7 +57,7 @@ export function useCloneGithubProject(
const normalizedServerId = serverId?.trim() ?? "";
const client = useHostRuntimeClient(normalizedServerId);
const isConnected = useHostRuntimeIsConnected(normalizedServerId);
const addEmptyProject = useSessionStore((state) => state.addEmptyProject);
const upsertProject = useSessionStore((state) => state.upsertProject);
const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces);
return useCallback(
@@ -69,10 +69,10 @@ export function useCloneGithubProject(
...(cloneProtocol ? { cloneProtocol } : {}),
isConnected,
client,
addEmptyProject,
upsertProject,
setHasHydratedWorkspaces,
});
},
[addEmptyProject, client, isConnected, normalizedServerId, setHasHydratedWorkspaces],
[client, isConnected, normalizedServerId, setHasHydratedWorkspaces, upsertProject],
);
}
+40 -192
View File
@@ -1,242 +1,90 @@
import { describe, expect, it } from "vitest";
import type { EmptyProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store";
import type { ProjectDescriptor } from "@/stores/session-store";
import {
deriveProjectsFromReplica,
type ProjectHostReplica,
type ProjectHostRuntimeState,
} from "@/hooks/use-projects";
function runtimeState(input: {
serverId: string;
isOnline: boolean;
isLoading?: boolean;
isFetching?: boolean;
error?: string | null;
}): ProjectHostRuntimeState {
function project(id: string, name: string, root: string): ProjectDescriptor {
return {
projectId: id,
projectKey: `remote:github.com/${name}`,
projectDisplayName: name,
projectCustomName: null,
projectRootPath: root,
projectKind: "git",
};
}
function runtimeState(input: Partial<ProjectHostRuntimeState> & { serverId: string }) {
return {
serverId: input.serverId,
isOnline: input.isOnline,
isOnline: input.isOnline ?? true,
isLoading: input.isLoading ?? false,
isFetching: input.isFetching ?? false,
error: input.error ?? null,
};
}
function workspace(input: {
id: string;
projectKey: string;
projectName: string;
cwd: string;
remoteUrl: string | null;
}): WorkspaceDescriptor {
return {
id: input.id,
projectId: input.projectKey,
projectDisplayName: input.projectName,
projectCustomName: null,
projectRootPath: input.cwd,
workspaceDirectory: input.cwd,
projectKind: "git",
workspaceKind: "local_checkout",
name: input.id,
title: null,
archivingAt: null,
status: "done",
statusEnteredAt: null,
diffStat: null,
scripts: [],
gitRuntime: {
currentBranch: "main",
remoteUrl: input.remoteUrl,
isPaseoOwnedWorktree: false,
isDirty: false,
aheadBehind: null,
aheadOfOrigin: null,
behindOfOrigin: null,
},
githubRuntime: null,
project: {
projectKey: input.projectKey,
projectName: input.projectName,
checkout: {
cwd: input.cwd,
isGit: true,
currentBranch: "main",
remoteUrl: input.remoteUrl,
worktreeRoot: input.cwd,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
},
};
}
function emptyProject(input: {
projectKey: string;
projectName: string;
cwd: string;
}): EmptyProjectDescriptor {
return {
projectId: input.projectKey,
projectDisplayName: input.projectName,
projectCustomName: null,
projectRootPath: input.cwd,
projectKind: "git",
};
}
describe("deriveProjectsFromReplica", () => {
it("aggregates store workspaces and empty projects sorted by display name", () => {
it("derives projects from the project descriptor replica", () => {
const replicas: ProjectHostReplica[] = [
{
serverId: "local",
serverName: "Local",
workspaces: [
workspace({
id: "z-main",
projectKey: "remote:github.com/acme/zeta",
projectName: "acme/zeta",
cwd: "/repo/zeta",
remoteUrl: "https://github.com/acme/zeta.git",
}),
],
emptyProjects: [],
workspaces: [],
projects: [project("prj_zeta", "acme/zeta", "/repo/zeta")],
},
{
serverId: "laptop",
serverName: "Laptop",
workspaces: [],
emptyProjects: [
emptyProject({
projectKey: "remote:github.com/acme/alpha",
projectName: "acme/alpha",
cwd: "/repo/alpha",
}),
],
projects: [project("prj_alpha", "acme/alpha", "/repo/alpha")],
},
];
const result = deriveProjectsFromReplica({
replicas,
runtimeStates: [
runtimeState({ serverId: "local", isOnline: true }),
runtimeState({ serverId: "local" }),
runtimeState({ serverId: "laptop", isOnline: false }),
],
});
expect(result.projects.map((project) => project.projectName)).toEqual([
"acme/alpha",
"acme/zeta",
]);
expect(result.projects[0]?.hosts).toEqual([
expect.objectContaining({
serverId: "laptop",
isOnline: false,
repoRoot: "/repo/alpha",
workspaceCount: 0,
}),
]);
expect(result.hostErrors).toEqual([]);
});
it("maps runtime directory status into loading and host errors", () => {
const replicas: ProjectHostReplica[] = [
{
serverId: "local",
serverName: "Local",
workspaces: [
workspace({
id: "main",
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
cwd: "/repo/app",
remoteUrl: "https://github.com/acme/app.git",
}),
],
emptyProjects: [],
},
{
serverId: "laptop",
serverName: "Laptop",
workspaces: [],
emptyProjects: [],
},
];
const result = deriveProjectsFromReplica({
replicas,
runtimeStates: [
runtimeState({ serverId: "local", isOnline: true, isFetching: true }),
runtimeState({
serverId: "laptop",
isOnline: true,
isLoading: true,
error: "laptop unavailable",
}),
],
expect(result.projects.map((item) => item.projectName)).toEqual(["acme/alpha", "acme/zeta"]);
expect(result.projects[0]?.hosts[0]).toMatchObject({
serverId: "laptop",
projectId: "prj_alpha",
repoRoot: "/repo/alpha",
workspaceCount: 0,
});
expect(result.projects).toEqual([
expect.objectContaining({ projectKey: "remote:github.com/acme/app" }),
]);
expect(result.hostErrors).toEqual([
{
serverId: "laptop",
serverName: "Laptop",
message: "laptop unavailable",
},
]);
expect(result.isLoading).toBe(true);
expect(result.isFetching).toBe(true);
});
it("returns only the stable public project and host entry shapes", () => {
it("preserves runtime loading and error state", () => {
const result = deriveProjectsFromReplica({
replicas: [
{
serverId: "local",
serverName: "Local",
workspaces: [
workspace({
id: "main",
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
cwd: "/repo/app",
remoteUrl: "https://github.com/acme/app.git",
}),
],
emptyProjects: [],
workspaces: [],
projects: [project("prj_app", "acme/app", "/repo/app")],
},
],
runtimeStates: [runtimeState({ serverId: "local", isOnline: true })],
runtimeStates: [
runtimeState({
serverId: "local",
isLoading: true,
isFetching: true,
error: "unavailable",
}),
],
});
expect(Object.keys(result.projects[0] ?? {}).sort()).toEqual([
"githubUrl",
"hostCount",
"hosts",
"onlineHostCount",
"projectCustomName",
"projectKey",
"projectName",
"totalWorkspaceCount",
]);
expect(Object.keys(result.projects[0]?.hosts[0] ?? {}).sort()).toEqual([
"gitRuntime",
"githubRuntime",
"isOnline",
"repoRoot",
"serverId",
"serverName",
"workspaceCount",
"workspaces",
]);
expect(Object.keys(result.projects[0]?.hosts[0]?.workspaces[0] ?? {}).sort()).toEqual([
"currentBranch",
"id",
"name",
"status",
"workspaceKind",
expect(result.isLoading).toBe(true);
expect(result.isFetching).toBe(true);
expect(result.hostErrors).toEqual([
{ serverId: "local", serverName: "Local", message: "unavailable" },
]);
});
});
+4 -4
View File
@@ -9,7 +9,7 @@ import {
} from "@/runtime/host-runtime";
import {
useSessionStore,
type EmptyProjectDescriptor,
type ProjectDescriptor,
type WorkspaceDescriptor,
} from "@/stores/session-store";
import { buildProjects, type ProjectHost, type ProjectSummary } from "@/utils/projects";
@@ -24,7 +24,7 @@ export interface ProjectHostReplica {
serverId: string;
serverName: string;
workspaces: WorkspaceDescriptor[];
emptyProjects: EmptyProjectDescriptor[];
projects: ProjectDescriptor[];
}
export interface ProjectHostRuntimeState {
@@ -87,7 +87,7 @@ function selectProjectHostReplicas(
serverId: host.serverId,
serverName: host.label,
workspaces: Array.from(session?.workspaces.values() ?? []),
emptyProjects: Array.from(session?.emptyProjects.values() ?? []),
projects: Array.from(session?.projects.values() ?? []),
};
});
}
@@ -106,7 +106,7 @@ export function deriveProjectsFromReplica(input: {
serverName: replica.serverName,
isOnline: runtimeState?.isOnline ?? false,
workspaces: replica.workspaces,
emptyProjects: replica.emptyProjects,
projects: replica.projects,
};
});
const hostErrors = input.replicas.flatMap((replica) => {
@@ -1,12 +1,9 @@
import { useCallback, useEffect, useMemo } from "react";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import { useHydratedWorkspaceServerIds } from "@/stores/session-store-hooks";
import { useHostProjects } from "@/projects/host-projects";
import { fetchAllWorkspaceDescriptors } from "@/projects/workspace-fetching";
import { getHostRuntimeStore, useHostRegistryLoaded, useHosts } from "@/runtime/host-runtime";
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
import { useSidebarViewStore } from "@/stores/sidebar-view-store";
import { shouldSuppressWorkspaceForLocalArchive } from "@/contexts/session-workspace-upserts";
import {
buildSidebarWorkspacePlacementModel,
computeSidebarOrderUpdates,
@@ -128,35 +125,12 @@ export function useSidebarWorkspacesList(options?: {
for (const serverId of serverIds) {
const snapshot = runtime.getSnapshot(serverId);
if (snapshot?.connectionStatus !== "online") continue;
const client = runtime.getClient(serverId);
if (!client) continue;
void (async () => {
const next = new Map<string, WorkspaceDescriptor>();
try {
const { workspaces, emptyProjects } = await fetchAllWorkspaceDescriptors({
client,
sort: [{ key: "activity_at", direction: "desc" }],
});
for (const workspace of workspaces) {
if (shouldSuppressWorkspaceForLocalArchive({ serverId, workspace })) {
continue;
}
next.set(workspace.id, workspace);
}
const store = useSessionStore.getState();
store.setWorkspaces(serverId, next);
// Keep parents with no workspaces yet, so a manual refresh doesn't drop
// a freshly-added project from the sidebar.
store.setEmptyProjects(serverId, emptyProjects);
store.setHasHydratedWorkspaces(serverId, true);
} catch (error) {
console.error("[WorkspaceFetch][sidebar-refresh] failed", {
serverId,
error,
});
// ignore explicit refresh failures; hook keeps existing data
}
})();
void runtime.refreshDirectories(serverId).catch((error) => {
console.error("[WorkspaceFetch][sidebar-refresh] failed", {
serverId,
error,
});
});
}
}, [isActive, runtime, serverIds]);
+43 -32
View File
@@ -4,14 +4,7 @@ import type {
WorkspaceStructureProject,
} from "@/projects/workspace-structure";
export interface HostProjectListItem {
projectKey: string;
projectName: string;
projectKind: WorkspaceDescriptor["projectKind"];
iconWorkingDir: string;
hosts: WorkspaceStructureHostPlacement[];
workspaceKeys: string[];
}
export type HostProjectListItem = WorkspaceStructureProject;
export interface HostProjectRouteContext {
serverId: string;
@@ -31,33 +24,21 @@ export function canCreateWorktreeForProjectKind(
return projectKind === "git";
}
export function buildHostProjectList(input: {
projects: readonly WorkspaceStructureProject[];
}): HostProjectListItem[] {
return input.projects.map((project) => ({
projectKey: project.projectKey,
projectName: project.projectName,
projectKind: project.projectKind,
iconWorkingDir: project.iconWorkingDir,
hosts: project.hosts,
workspaceKeys: project.workspaceKeys,
}));
}
export function hostProjectFromRoute(route: HostProjectRouteContext): HostProjectListItem | null {
const projectKey = trimOptional(route.projectId);
const projectId = route.projectId?.trim() || undefined;
const iconWorkingDir = trimOptional(route.sourceDirectory);
if (!projectKey || !iconWorkingDir) {
if (!projectId || !iconWorkingDir) {
return null;
}
return {
projectKey,
projectName: trimOptional(route.displayName) ?? projectKey,
projectKey: projectId,
projectName: trimOptional(route.displayName) || projectId,
projectKind: "git",
iconWorkingDir,
hosts: [
{
serverId: route.serverId,
projectId,
iconWorkingDir,
canCreateWorktree: true,
},
@@ -73,20 +54,24 @@ export function hostProjectFromWorkspace(input: {
if (!input.workspace) {
return null;
}
const projectKey = input.workspace.projectId.trim();
const projectId = input.workspace.projectId.trim() || undefined;
if (!projectId) {
return null;
}
const iconWorkingDir = input.workspace.projectRootPath.trim();
if (!projectKey || !iconWorkingDir) {
if (!iconWorkingDir) {
return null;
}
const canCreate = canCreateWorktreeForProjectKind(input.workspace.projectKind);
return {
projectKey,
projectName: input.workspace.projectDisplayName || projectKey,
projectKey: projectId,
projectName: input.workspace.projectDisplayName || projectId,
projectKind: input.workspace.projectKind,
iconWorkingDir,
hosts: [
{
serverId: input.serverId,
projectId: input.workspace.projectId,
iconWorkingDir,
canCreateWorktree: canCreate,
},
@@ -99,11 +84,25 @@ function projectCanCreateWorktree(project: HostProjectListItem): boolean {
return project.hosts.some((h) => h.canCreateWorktree);
}
function getHostProjectPlacement(
project: HostProjectListItem,
serverId: string,
): WorkspaceStructureHostPlacement | null {
for (const host of project.hosts) {
if (host.serverId === serverId) return host;
}
return null;
}
export function getHostProjectSourceDirectory(
project: HostProjectListItem,
serverId: string,
): string | null {
return project.hosts.find((host) => host.serverId === serverId)?.iconWorkingDir ?? null;
return getHostProjectPlacement(project, serverId)?.iconWorkingDir ?? null;
}
export function getHostProjectId(project: HostProjectListItem, serverId: string): string | null {
return getHostProjectPlacement(project, serverId)?.projectId ?? null;
}
export function canCreateWorkspaceForHostProject(input: {
@@ -111,7 +110,7 @@ export function canCreateWorkspaceForHostProject(input: {
serverId: string;
allowAllProjects: boolean;
}): boolean {
const host = input.project.hosts.find((candidate) => candidate.serverId === input.serverId);
const host = getHostProjectPlacement(input.project, input.serverId);
if (!host) {
return false;
}
@@ -144,8 +143,20 @@ export function resolveInitialWorkspaceProject(input: {
if (!candidate) {
continue;
}
const candidatePlacement = candidate.hosts.find(
(host) => host.serverId === input.serverId && host.projectId,
);
const hydratedProject =
input.projects.find((project) => project.projectKey === candidate.projectKey) ?? candidate;
(candidatePlacement
? input.projects.find((project) =>
project.hosts.some(
(host) =>
host.serverId === input.serverId && host.projectId === candidatePlacement.projectId,
),
)
: undefined) ??
input.projects.find((project) => project.projectKey === candidate.projectKey) ??
candidate;
if (
canCreateWorkspaceForHostProject({
project: hydratedProject,
+29 -277
View File
@@ -1,312 +1,64 @@
import { describe, expect, it } from "vitest";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import type { WorkspaceStructureProject } from "@/projects/workspace-structure";
import { describe, expect, test } from "vitest";
import type { HostProjectListItem } from "./host-project-model";
import {
buildHostProjectList,
canCreateWorkspaceForHostProject,
canCreateWorktreeForProjectKind,
filterWorkspaceProjectsForHost,
getHostProjectId,
getHostProjectSourceDirectory,
hostProjectFromRoute,
hostProjectFromWorkspace,
resolveInitialWorkspaceProject,
resolveInitialWorktreeProject,
resolveSelectedHostProject,
type HostProjectListItem,
} from "./host-project-model";
function structureProject(input: Partial<WorkspaceStructureProject>): WorkspaceStructureProject {
function project(): HostProjectListItem {
return {
projectKey: input.projectKey ?? "project-a",
projectName: input.projectName ?? "Project A",
projectKind: input.projectKind ?? "git",
iconWorkingDir: input.iconWorkingDir ?? "/repo/a",
hosts: input.hosts ?? [
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
projectKind: "git",
iconWorkingDir: "/repo/a",
hosts: [
{
serverId: "host-a",
iconWorkingDir: input.iconWorkingDir ?? "/repo/a",
canCreateWorktree: input.projectKind !== "directory",
projectId: "prj_a",
iconWorkingDir: "/repo/a",
canCreateWorktree: true,
},
],
workspaceKeys: input.workspaceKeys ?? ["workspace-a"],
};
}
function hostProject(input: Partial<HostProjectListItem>): HostProjectListItem {
return {
projectKey: input.projectKey ?? "project-a",
projectName: input.projectName ?? "Project A",
projectKind: input.projectKind ?? "git",
iconWorkingDir: input.iconWorkingDir ?? "/repo/a",
hosts: input.hosts ?? [
{
serverId: "host-a",
iconWorkingDir: input.iconWorkingDir ?? "/repo/a",
serverId: "host-b",
projectId: "prj_b",
iconWorkingDir: "/repo/b",
canCreateWorktree: true,
},
],
workspaceKeys: input.workspaceKeys ?? ["workspace-a"],
workspaceKeys: [],
};
}
function workspace(input: Partial<WorkspaceDescriptor>): WorkspaceDescriptor {
return {
id: input.id ?? "workspace-a",
projectId: input.projectId ?? "project-a",
projectDisplayName: input.projectDisplayName ?? "Project A",
projectRootPath: input.projectRootPath ?? "/repo/a",
workspaceDirectory: input.workspaceDirectory ?? "/repo/a",
projectKind: input.projectKind ?? "git",
workspaceKind: input.workspaceKind ?? "local_checkout",
name: input.name ?? "main",
status: input.status ?? "done",
statusEnteredAt: input.statusEnteredAt ?? null,
archivingAt: input.archivingAt ?? null,
diffStat: input.diffStat ?? null,
scripts: input.scripts ?? [],
};
}
const routeProject = hostProject({
projectKey: "route-project",
projectName: "Route Project",
iconWorkingDir: "/repo/route",
});
const lastActiveProject = hostProject({
projectKey: "last-project",
projectName: "Last Project",
iconWorkingDir: "/repo/last",
});
const firstProject = hostProject({
projectKey: "first-project",
projectName: "First Project",
iconWorkingDir: "/repo/first",
});
describe("host project list", () => {
it("preserves workspace-structure order and project metadata", () => {
expect(
buildHostProjectList({
projects: [
structureProject({
projectKey: "project-b",
projectName: "Project B",
projectKind: "directory",
iconWorkingDir: "/repo/b",
workspaceKeys: ["workspace-b"],
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/b", canCreateWorktree: false }],
}),
structureProject({
projectKey: "project-a",
projectName: "Project A",
projectKind: "git",
iconWorkingDir: "/repo/a",
workspaceKeys: ["workspace-a"],
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: true }],
}),
],
}),
).toEqual([
{
projectKey: "project-b",
projectName: "Project B",
projectKind: "directory",
iconWorkingDir: "/repo/b",
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/b", canCreateWorktree: false }],
workspaceKeys: ["workspace-b"],
},
{
projectKey: "project-a",
projectName: "Project A",
projectKind: "git",
iconWorkingDir: "/repo/a",
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: true }],
workspaceKeys: ["workspace-a"],
},
]);
describe("host project lookups", () => {
test("returns host-local ids and roots without falling back to the grouping key", () => {
expect(getHostProjectId(project(), "host-b")).toBe("prj_b");
expect(getHostProjectSourceDirectory(project(), "host-b")).toBe("/repo/b");
expect(getHostProjectId(project(), "missing")).toBeNull();
});
it("keeps worktree capability separate from project listability", () => {
expect(canCreateWorktreeForProjectKind("git")).toBe(true);
expect(canCreateWorktreeForProjectKind("directory")).toBe(false);
});
it("uses route project before last active project when it can create worktrees", () => {
test("checks workspace creation against the selected host placement", () => {
expect(
resolveInitialWorktreeProject({
routeProject,
lastActiveProject,
projects: [firstProject],
}),
).toEqual(routeProject);
});
it("skips non-worktree route and last-active projects", () => {
expect(
resolveInitialWorktreeProject({
routeProject: {
...routeProject,
projectKind: "directory",
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/route", canCreateWorktree: false }],
},
lastActiveProject: {
...lastActiveProject,
projectKind: "directory",
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/last", canCreateWorktree: false }],
},
projects: [
{
...firstProject,
projectKind: "directory",
hosts: [
{ serverId: "host-a", iconWorkingDir: "/repo/first", canCreateWorktree: false },
],
},
hostProject({ projectKey: "git-project", projectName: "Git Project" }),
],
}),
).toMatchObject({ projectKey: "git-project" });
});
it("leaves the project empty when no worktree-capable project is available", () => {
expect(
resolveInitialWorktreeProject({
routeProject: null,
lastActiveProject: null,
projects: [
{
...firstProject,
projectKind: "directory",
hosts: [
{ serverId: "host-a", iconWorkingDir: "/repo/first", canCreateWorktree: false },
],
},
],
}),
).toBeNull();
});
it("filters new-workspace projects to the selected host", () => {
const hostAOnly = hostProject({
projectKey: "host-a-project",
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: true }],
});
const hostBOnly = hostProject({
projectKey: "host-b-project",
hosts: [{ serverId: "host-b", iconWorkingDir: "/repo/b", canCreateWorktree: true }],
});
expect(
filterWorkspaceProjectsForHost({
projects: [hostAOnly, hostBOnly],
canCreateWorkspaceForHostProject({
project: project(),
serverId: "host-b",
allowAllProjects: false,
}).map((project) => project.projectKey),
).toEqual(["host-b-project"]);
});
it("allows directory projects only when workspace multiplicity is supported", () => {
const directoryProject = hostProject({
projectKey: "directory-project",
projectKind: "directory",
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/directory", canCreateWorktree: false }],
});
expect(
canCreateWorkspaceForHostProject({
project: directoryProject,
serverId: "host-a",
allowAllProjects: false,
}),
).toBe(false);
expect(
canCreateWorkspaceForHostProject({
project: directoryProject,
serverId: "host-a",
allowAllProjects: true,
}),
).toBe(true);
});
it("falls back when the route project is not available on the selected host", () => {
const selectedHostProject = hostProject({
projectKey: "selected-host-project",
hosts: [
{ serverId: "host-b", iconWorkingDir: "/repo/selected-host", canCreateWorktree: true },
],
});
expect(
resolveInitialWorkspaceProject({
routeProject,
lastActiveProject: null,
projects: [selectedHostProject],
serverId: "host-b",
allowAllProjects: false,
}),
).toEqual(selectedHostProject);
});
it("resolves the selected host project source directory", () => {
const project = hostProject({
hosts: [
{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: true },
{ serverId: "host-b", iconWorkingDir: "/repo/b", canCreateWorktree: true },
],
});
expect(getHostProjectSourceDirectory(project, "host-b")).toBe("/repo/b");
expect(getHostProjectSourceDirectory(project, "host-c")).toBeNull();
});
it("keeps a selected route project available before project hydration", () => {
expect(
resolveSelectedHostProject({
selectedProjectKey: routeProject.projectKey,
projects: [],
routeProject,
lastActiveProject: null,
}),
).toEqual(routeProject);
});
it("converts route project only when it has a key and source directory", () => {
test("builds an unhydrated route project around the routed project id", () => {
expect(
hostProjectFromRoute({
serverId: "host-a",
projectId: "project-a",
displayName: "Project A",
projectId: "prj_a",
displayName: "App",
sourceDirectory: "/repo/a",
}),
).toEqual({
projectKey: "project-a",
projectName: "Project A",
projectKind: "git",
iconWorkingDir: "/repo/a",
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: true }],
workspaceKeys: [],
});
expect(hostProjectFromRoute({ serverId: "host-a", projectId: "project-a" })).toBeNull();
});
it("converts last active workspaces with matching worktree capability", () => {
expect(hostProjectFromWorkspace({ serverId: "host-a", workspace: workspace({}) })).toEqual({
projectKey: "project-a",
projectName: "Project A",
projectKind: "git",
iconWorkingDir: "/repo/a",
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: true }],
workspaceKeys: ["host-a:workspace-a"],
});
expect(
hostProjectFromWorkspace({
serverId: "host-a",
workspace: workspace({ projectKind: "directory" }),
}),
).toMatchObject({
projectKind: "directory",
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: false }],
projectKey: "prj_a",
hosts: [{ serverId: "host-a", projectId: "prj_a" }],
});
});
});
+3 -9
View File
@@ -1,13 +1,12 @@
import { useMemo } from "react";
import { useWorkspaceStructure } from "@/stores/session-store-hooks";
import { buildHostProjectList, type HostProjectListItem } from "@/projects/host-project-model";
import { type HostProjectListItem } from "@/projects/host-project-model";
export {
buildHostProjectList,
canCreateWorkspaceForHostProject,
canCreateWorktreeForProjectKind,
filterWorkspaceProjectsForHost,
getHostProjectSourceDirectory,
getHostProjectId,
hostProjectFromRoute,
hostProjectFromWorkspace,
resolveInitialWorkspaceProject,
@@ -19,10 +18,5 @@ export {
export function useHostProjects(serverIds: string[]): HostProjectListItem[] {
const workspaceStructure = useWorkspaceStructure(serverIds);
return useMemo(() => {
if (workspaceStructure.projects.length === 0) {
return [];
}
return buildHostProjectList({ projects: workspaceStructure.projects });
}, [workspaceStructure.projects]);
return workspaceStructure.projects;
}
@@ -7,7 +7,10 @@ import {
const project: ProjectRemoveProject = {
projectKey: "remote:github.com/acme/app",
hosts: [{ serverId: "host-a" }, { serverId: "host-b" }],
hosts: [
{ serverId: "host-a", projectId: "prj_host_a" },
{ serverId: "host-b", projectId: "prj_host_b" },
],
};
function createProjectRemoveClient() {
@@ -46,12 +49,14 @@ describe("project remove policy", () => {
expect(readiness).toEqual({
kind: "ready",
targets: [{ serverId: "host-a" }, { serverId: "host-b" }],
targets: [
{ serverId: "host-a", projectId: "prj_host_a" },
{ serverId: "host-b", projectId: "prj_host_b" },
],
});
const outcome = await removeProjectFromHosts({
projectKey: project.projectKey,
targets: [{ serverId: "host-a" }, { serverId: "host-b" }],
targets: readiness.kind === "ready" ? readiness.targets : [],
getClient: (serverId) => {
if (serverId === "host-a") return hostA.client;
if (serverId === "host-b") return hostB.client;
@@ -60,16 +65,18 @@ describe("project remove policy", () => {
});
expect(outcome).toEqual({ kind: "removed", serverIds: ["host-a", "host-b"] });
expect(hostA.removedProjectKeys).toEqual([project.projectKey]);
expect(hostB.removedProjectKeys).toEqual([project.projectKey]);
expect(hostA.removedProjectKeys).toEqual(["prj_host_a"]);
expect(hostB.removedProjectKeys).toEqual(["prj_host_b"]);
});
it("reports disconnected hosts before sending any remove request", async () => {
const hostA = createProjectRemoveClient();
const outcome = await removeProjectFromHosts({
projectKey: project.projectKey,
targets: [{ serverId: "host-a" }, { serverId: "host-b" }],
targets: [
{ serverId: "host-a", projectId: "prj_host_a" },
{ serverId: "host-b", projectId: "prj_host_b" },
],
getClient: (serverId) => (serverId === "host-a" ? hostA.client : null),
});
+10 -6
View File
@@ -4,6 +4,7 @@ import { selectHostFeature } from "@/runtime/host-features";
interface ProjectRemoveHost {
serverId: string;
projectId: string;
}
export interface ProjectRemoveProject {
@@ -13,6 +14,7 @@ export interface ProjectRemoveProject {
export interface ProjectRemoveTarget {
serverId: string;
projectId: string;
}
export type ProjectRemoveReadiness =
@@ -38,7 +40,10 @@ export function getProjectRemoveReadiness(input: {
unsupportedServerIds.push(host.serverId);
continue;
}
targets.push({ serverId: host.serverId });
targets.push({
serverId: host.serverId,
projectId: host.projectId,
});
}
if (unsupportedServerIds.length > 0) {
@@ -59,11 +64,10 @@ export function getCurrentProjectRemoveReadiness(
}
export async function removeProjectFromHosts(input: {
projectKey: string;
targets: readonly ProjectRemoveTarget[];
getClient: (serverId: string) => ProjectRemoveClient | null;
}): Promise<ProjectRemoveOutcome> {
const clients: Array<{ serverId: string; client: ProjectRemoveClient }> = [];
const clients: Array<{ serverId: string; projectId: string; client: ProjectRemoveClient }> = [];
const disconnectedServerIds: string[] = [];
for (const target of input.targets) {
@@ -72,7 +76,7 @@ export async function removeProjectFromHosts(input: {
disconnectedServerIds.push(target.serverId);
continue;
}
clients.push({ serverId: target.serverId, client });
clients.push({ serverId: target.serverId, projectId: target.projectId, client });
}
if (disconnectedServerIds.length > 0) {
@@ -80,8 +84,8 @@ export async function removeProjectFromHosts(input: {
}
const results = await Promise.allSettled(
clients.map(async ({ client }) => {
await client.removeProject(input.projectKey);
clients.map(async ({ client, projectId }) => {
await client.removeProject(projectId);
}),
);
const failedServerIds: string[] = [];
@@ -1,50 +0,0 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import {
type EmptyProjectDescriptor,
normalizeEmptyProjectDescriptor,
normalizeWorkspaceDescriptor,
type WorkspaceDescriptor,
} from "@/stores/session-store";
export type FetchWorkspacesClient = Pick<DaemonClient, "fetchWorkspaces">;
export type FetchWorkspacesSort = NonNullable<
Parameters<DaemonClient["fetchWorkspaces"]>[0]
>["sort"];
export interface FetchWorkspaceDescriptorsResult {
workspaces: WorkspaceDescriptor[];
/**
* Project parents with no active workspaces. The daemon only rides these on
* the first page of `fetchWorkspaces`, so a freshly-added project that has no
* workspace yet shows up here and nowhere else — drop it and the project is
* invisible to anything that derives projects from workspaces alone.
*/
emptyProjects: EmptyProjectDescriptor[];
}
export async function fetchAllWorkspaceDescriptors(input: {
client: FetchWorkspacesClient;
sort: FetchWorkspacesSort;
}): Promise<FetchWorkspaceDescriptorsResult> {
const workspaces: WorkspaceDescriptor[] = [];
const emptyProjects = new Map<string, EmptyProjectDescriptor>();
let cursor: string | null = null;
while (true) {
const payload = await input.client.fetchWorkspaces({
sort: input.sort,
page: cursor ? { limit: 200, cursor } : { limit: 200 },
});
workspaces.push(...payload.entries.map((entry) => normalizeWorkspaceDescriptor(entry)));
for (const project of payload.emptyProjects ?? []) {
const descriptor = normalizeEmptyProjectDescriptor(project);
emptyProjects.set(descriptor.projectId, descriptor);
}
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) {
break;
}
cursor = payload.pageInfo.nextCursor;
}
return { workspaces, emptyProjects: Array.from(emptyProjects.values()) };
}
@@ -0,0 +1,147 @@
import { describe, expect, test } from "vitest";
import type { ProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store";
import { buildWorkspaceStructureProjects } from "./workspace-structure";
function project(input: {
id: string;
key: string | null;
root: string;
name?: string;
}): ProjectDescriptor {
return {
projectId: input.id,
projectKey: input.key,
projectDisplayName: input.name ?? "acme/app",
projectCustomName: null,
projectRootPath: input.root,
projectKind: "git",
};
}
function workspace(id: string, projectId: string, root: string): WorkspaceDescriptor {
return {
id,
projectId,
projectDisplayName: "acme/app",
projectCustomName: null,
projectRootPath: root,
workspaceDirectory: root,
projectKind: "git",
workspaceKind: "local_checkout",
name: "main",
status: "done",
statusEnteredAt: null,
archivingAt: null,
diffStat: null,
scripts: [],
};
}
describe("buildWorkspaceStructureProjects", () => {
test("groups the same project key across hosts and keeps host-local ids", () => {
const key = "remote:github.com/acme/app";
const result = buildWorkspaceStructureProjects({
sessions: [
{
serverId: "host-a",
projects: [project({ id: "prj_a", key, root: "/a/app" })],
workspaces: [workspace("ws-a", "prj_a", "/a/app")],
},
{
serverId: "host-b",
projects: [project({ id: "prj_b", key, root: "/b/app" })],
workspaces: [workspace("ws-b", "prj_b", "/b/app")],
},
],
});
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
projectKey: key,
hosts: [
{ serverId: "host-a", projectId: "prj_a" },
{ serverId: "host-b", projectId: "prj_b" },
],
workspaceKeys: ["host-a:ws-a", "host-b:ws-b"],
});
});
test("keeps two clones with the same key on one host separate", () => {
const key = "remote:github.com/acme/app";
const result = buildWorkspaceStructureProjects({
sessions: [
{
serverId: "host-a",
projects: [
project({ id: "prj_one", key, root: "/repos/one" }),
project({ id: "prj_two", key, root: "/repos/two" }),
],
workspaces: [
workspace("ws-one", "prj_one", "/repos/one"),
workspace("ws-two", "prj_two", "/repos/two"),
],
},
],
});
expect(result).toHaveLength(2);
expect(result.map((item) => item.hosts[0]?.projectId).sort()).toEqual(["prj_one", "prj_two"]);
expect(result.map((item) => item.workspaceKeys[0]).sort()).toEqual([
"host-a:ws-one",
"host-a:ws-two",
]);
});
test("does not let project order choose which same-host clone groups with another host", () => {
const key = "remote:github.com/acme/app";
const hostAProjects = [
project({ id: "prj_one", key, root: "/repos/one" }),
project({ id: "prj_two", key, root: "/repos/two" }),
];
const build = (projects: ProjectDescriptor[]) =>
buildWorkspaceStructureProjects({
sessions: [
{ serverId: "host-a", projects, workspaces: [] },
{
serverId: "host-b",
projects: [project({ id: "prj_remote", key, root: "/repos/remote" })],
workspaces: [],
},
],
});
const identitiesByProjectId = (projects: ReturnType<typeof build>) => {
const identities: Record<string, string> = {};
for (const group of projects) {
for (const host of group.hosts) identities[host.projectId] = group.projectKey;
}
return identities;
};
const forward = build(hostAProjects);
const reversed = build(hostAProjects.toReversed());
expect(forward).toHaveLength(3);
expect(identitiesByProjectId(forward)).toEqual(identitiesByProjectId(reversed));
expect(new Set(Object.values(identitiesByProjectId(forward))).size).toBe(3);
});
test("keeps projects without persisted keys scoped to their host", () => {
const result = buildWorkspaceStructureProjects({
sessions: [
{
serverId: "host-a",
projects: [project({ id: "/workspace/app", key: null, root: "/workspace/app" })],
workspaces: [],
},
{
serverId: "host-b",
projects: [project({ id: "/workspace/app", key: null, root: "/workspace/app" })],
workspaces: [],
},
],
});
expect(result).toHaveLength(2);
expect(new Set(result.map((item) => item.projectKey)).size).toBe(2);
});
});
+112 -119
View File
@@ -1,8 +1,9 @@
import type { EmptyProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store";
import type { ProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store";
import { projectDisplayNameFromProjectId } from "@/utils/project-display-name";
export interface WorkspaceStructureHostPlacement {
serverId: string;
projectId: string;
iconWorkingDir: string;
canCreateWorktree: boolean;
}
@@ -20,126 +21,55 @@ export interface WorkspaceStructure {
projects: WorkspaceStructureProject[];
}
function compareWorkspaceStructureItems(
left: { workspaceId: string; workspaceName: string },
right: { workspaceId: string; workspaceName: string },
): number {
const nameDelta = left.workspaceName.localeCompare(right.workspaceName, undefined, {
numeric: true,
sensitivity: "base",
});
if (nameDelta !== 0) {
return nameDelta;
}
return left.workspaceId.localeCompare(right.workspaceId, undefined, {
sensitivity: "base",
});
}
function compareWorkspaceStructureProjects(
left: WorkspaceStructureProject,
right: WorkspaceStructureProject,
): number {
return left.projectName.localeCompare(right.projectName, undefined, {
numeric: true,
sensitivity: "base",
});
}
function canCreateWorktreeForProjectKind(projectKind: WorkspaceDescriptor["projectKind"]): boolean {
return projectKind === "git";
}
interface WorkspaceStructureSession {
serverId: string;
projects: Iterable<ProjectDescriptor>;
workspaces: Iterable<WorkspaceDescriptor>;
emptyProjects?: Iterable<EmptyProjectDescriptor>;
}
interface ProjectDraft {
projectKey: string;
projectName: string;
hasCustomName: boolean;
projectKind: WorkspaceDescriptor["projectKind"];
iconWorkingDir: string;
hosts: Map<string, WorkspaceStructureHostPlacement>;
workspaces: Array<{ workspaceId: string; workspaceName: string; workspaceKey: string }>;
}
/** The single app boundary that turns host-local projects into grouped display projects. */
export function buildWorkspaceStructureProjects(input: {
sessions: WorkspaceStructureSession[];
}): WorkspaceStructureProject[] {
const byProject = new Map<
string,
{
projectKey: string;
projectName: string;
projectKind: WorkspaceDescriptor["projectKind"];
iconWorkingDir: string;
hosts: Map<string, WorkspaceStructureHostPlacement>;
workspaces: Array<{ workspaceId: string; workspaceName: string; workspaceKey: string }>;
}
>();
const byProject = new Map<string, ProjectDraft>();
const projectEntries: Array<{ serverId: string; project: ProjectDescriptor }> = [];
const keyCountsByServer = new Map<string, Map<string, number>>();
const viewKeyByServerProjectId = new Map<string, Map<string, string>>();
for (const session of input.sessions) {
for (const emptyProject of session.emptyProjects ?? []) {
const projectKey = emptyProject.projectId;
const placement = {
serverId: session.serverId,
iconWorkingDir: emptyProject.projectRootPath,
canCreateWorktree: canCreateWorktreeForProjectKind(emptyProject.projectKind),
};
const existing = byProject.get(projectKey);
if (!existing) {
byProject.set(projectKey, {
projectKey,
projectName:
emptyProject.projectCustomName ??
emptyProject.projectDisplayName ??
projectDisplayNameFromProjectId(projectKey),
projectKind: emptyProject.projectKind,
iconWorkingDir: emptyProject.projectRootPath,
hosts: new Map([[session.serverId, placement]]),
workspaces: [],
});
continue;
for (const project of session.projects) {
projectEntries.push({ serverId: session.serverId, project });
const sharedKey = project.projectKey ?? null;
if (sharedKey) {
const counts = getOrCreate(keyCountsByServer, session.serverId, () => new Map());
counts.set(sharedKey, (counts.get(sharedKey) ?? 0) + 1);
}
existing.hosts.set(session.serverId, placement);
}
}
for (const { serverId, project } of projectEntries) {
const projectKey = addProjectToView({ byProject, keyCountsByServer, serverId, project });
getOrCreate(viewKeyByServerProjectId, serverId, () => new Map()).set(
project.projectId,
projectKey,
);
}
for (const session of input.sessions) {
for (const workspace of session.workspaces) {
const projectKey = workspace.project?.projectKey ?? workspace.projectId;
const existing = byProject.get(projectKey);
if (!existing) {
byProject.set(projectKey, {
projectKey,
projectName:
workspace.projectCustomName ??
workspace.projectDisplayName ??
projectDisplayNameFromProjectId(projectKey),
projectKind: workspace.projectKind,
iconWorkingDir: workspace.projectRootPath,
hosts: new Map([
[
session.serverId,
{
serverId: session.serverId,
iconWorkingDir: workspace.projectRootPath,
canCreateWorktree: canCreateWorktreeForProjectKind(workspace.projectKind),
},
],
]),
workspaces: [
{
workspaceId: workspace.id,
workspaceName: workspace.name,
workspaceKey: `${session.serverId}:${workspace.id}`,
},
],
});
continue;
}
existing.hosts.set(session.serverId, {
serverId: session.serverId,
iconWorkingDir: workspace.projectRootPath,
canCreateWorktree: canCreateWorktreeForProjectKind(workspace.projectKind),
});
existing.workspaces.push({
const projectKey = viewKeyByServerProjectId.get(session.serverId)?.get(workspace.projectId);
if (!projectKey) continue;
byProject.get(projectKey)?.workspaces.push({
workspaceId: workspace.id,
workspaceName: workspace.name,
workspaceKey: `${session.serverId}:${workspace.id}`,
@@ -147,19 +77,82 @@ export function buildWorkspaceStructureProjects(input: {
}
}
const projects: WorkspaceStructureProject[] = [];
for (const raw of byProject.values()) {
const sortedWorkspaces = [...raw.workspaces].sort(compareWorkspaceStructureItems);
projects.push({
projectKey: raw.projectKey,
projectName: raw.projectName,
projectKind: raw.projectKind,
iconWorkingDir: raw.iconWorkingDir,
hosts: Array.from(raw.hosts.values()),
workspaceKeys: sortedWorkspaces.map((w) => w.workspaceKey),
return Array.from(byProject.values())
.map((draft) => ({
projectKey: draft.projectKey,
projectName: draft.projectName,
projectKind: draft.projectKind,
iconWorkingDir: draft.iconWorkingDir,
hosts: Array.from(draft.hosts.values()),
workspaceKeys: draft.workspaces
.sort(compareWorkspaceStructureItems)
.map((workspace) => workspace.workspaceKey),
}))
.sort((left, right) =>
left.projectName.localeCompare(right.projectName, undefined, {
numeric: true,
sensitivity: "base",
}),
);
}
function addProjectToView(input: {
byProject: Map<string, ProjectDraft>;
keyCountsByServer: Map<string, Map<string, number>>;
serverId: string;
project: ProjectDescriptor;
}): string {
const { byProject, keyCountsByServer, serverId, project } = input;
const sharedKey = project.projectKey ?? null;
const canUseSharedKey =
sharedKey !== null && keyCountsByServer.get(serverId)?.get(sharedKey) === 1;
const projectKey = canUseSharedKey ? sharedKey : JSON.stringify([serverId, project.projectId]);
const placement: WorkspaceStructureHostPlacement = {
serverId,
projectId: project.projectId,
iconWorkingDir: project.projectRootPath,
canCreateWorktree: project.projectKind === "git",
};
const draft = byProject.get(projectKey);
if (!draft) {
byProject.set(projectKey, {
projectKey,
projectName:
project.projectCustomName ??
project.projectDisplayName ??
projectDisplayNameFromProjectId(project.projectId),
hasCustomName: Boolean(project.projectCustomName),
projectKind: project.projectKind,
iconWorkingDir: project.projectRootPath,
hosts: new Map([[serverId, placement]]),
workspaces: [],
});
} else {
if (project.projectCustomName && !draft.hasCustomName) {
draft.projectName = project.projectCustomName;
draft.hasCustomName = true;
}
draft.hosts.set(serverId, placement);
}
return projectKey;
}
function getOrCreate<K, V>(map: Map<K, V>, key: K, create: () => V): V {
const existing = map.get(key);
if (existing !== undefined) return existing;
const value = create();
map.set(key, value);
return value;
}
projects.sort(compareWorkspaceStructureProjects);
return projects;
function compareWorkspaceStructureItems(
left: { workspaceId: string; workspaceName: string },
right: { workspaceId: string; workspaceName: string },
): number {
return (
left.workspaceName.localeCompare(right.workspaceName, undefined, {
numeric: true,
sensitivity: "base",
}) || left.workspaceId.localeCompare(right.workspaceId, undefined, { sensitivity: "base" })
);
}
@@ -5,10 +5,12 @@ import { useSessionStore } from "@/stores/session-store";
import { DirectoryRefreshSupersededError, DirectorySync } from "./index";
type WorkspaceFetchResult = Awaited<ReturnType<DaemonClient["fetchWorkspaces"]>>;
type ProjectListResult = Awaited<ReturnType<DaemonClient["listProjects"]>>;
class FakeDirectoryClient {
fetchAgentsCalls = 0;
fetchWorkspacesCalls = 0;
listProjectsCalls = 0;
private pendingWorkspaceFetch: Promise<WorkspaceFetchResult> | null = null;
private readonly handlers = new Map<
SessionOutboundMessage["type"],
@@ -63,6 +65,22 @@ class FakeDirectoryClient {
pageInfo: { hasMore: false, nextCursor: null, prevCursor: null },
};
}
async listProjects(): Promise<ProjectListResult> {
this.listProjectsCalls += 1;
return {
requestId: "projects",
projects: [
{
projectId: "project-1",
projectKey: "remote:github.com/acme/app",
projectDisplayName: "acme/app",
projectRootPath: "/repo/app",
projectKind: "git",
},
],
};
}
}
const serverIds = new Set<string>();
@@ -119,6 +137,28 @@ describe("DirectorySync session readiness", () => {
directory.dispose();
});
it("fetches the project descriptor channel when the daemon advertises it", async () => {
const serverId = "project-list";
const { client, directory } = createDirectory(serverId);
const store = useSessionStore.getState();
store.initializeSession(serverId, client as unknown as DaemonClient, 1);
store.updateSessionServerInfo(serverId, {
serverId,
hostname: null,
version: "test",
features: { workspaceMultiplicity: true, projectList: true },
});
await directory.refreshWorkspaces();
expect(client.listProjectsCalls).toBe(1);
expect(useSessionStore.getState().sessions[serverId]?.projects.get("project-1")).toMatchObject({
projectId: "project-1",
projectKey: "remote:github.com/acme/app",
});
directory.dispose();
});
it("rejects a session wait on disconnect so the reconnect can refresh", async () => {
const serverId = "session-wait-reconnect";
const { client, directory } = createDirectory(serverId);
@@ -200,17 +240,14 @@ describe("DirectorySync session readiness", () => {
});
await refresh;
const emptyProjects = useSessionStore.getState().sessions[serverId]?.emptyProjects;
expect(Array.from(emptyProjects?.keys() ?? [])).toEqual([
"snapshot-project",
"workspace-project",
]);
expect(emptyProjects?.get("snapshot-project")).toMatchObject({
const projects = useSessionStore.getState().sessions[serverId]?.projects;
expect(Array.from(projects?.keys() ?? [])).toEqual(["snapshot-project", "workspace-project"]);
expect(projects?.get("snapshot-project")).toMatchObject({
projectDisplayName: "Renamed during hydration",
projectRootPath: "/moved/snapshot-project",
projectKind: "directory",
});
expect(emptyProjects?.get("workspace-project")).toMatchObject({
expect(projects?.get("workspace-project")).toMatchObject({
projectDisplayName: "Project from workspace update",
});
directory.dispose();
@@ -247,7 +284,7 @@ describe("DirectorySync session readiness", () => {
expect(useSessionStore.getState().sessions[serverId]?.hasHydratedWorkspaces).toBe(true);
expect(
useSessionStore.getState().sessions[serverId]?.emptyProjects.get("early-project"),
useSessionStore.getState().sessions[serverId]?.projects.get("early-project"),
).toMatchObject({
projectDisplayName: "Early project",
projectRootPath: "/repo/early-project",
@@ -5,10 +5,12 @@ import type {
} from "@getpaseo/client/internal/daemon-client";
import { fetchAgentTimelineOnce } from "@/timeline/fetch-agent-timeline-once";
import {
normalizeEmptyProjectDescriptor,
normalizeProjectDescriptor,
normalizeWorkspaceDescriptor,
useSessionStore,
type Agent,
type ProjectDescriptor,
type WorkspaceDescriptor,
} from "@/stores/session-store";
import {
readLegacyDaemonWorkspaceDirectory,
@@ -109,7 +111,7 @@ export class DirectorySync {
const source = connection.source;
this.workspaceTransactions.begin(source, () => ({
workspaces: new Map(),
emptyProjects: new Map(),
projects: new Map(),
}));
const subscriptions = [
client.on("agent_update", (message) => {
@@ -205,8 +207,12 @@ export class DirectorySync {
}
if (completion.snapshot.legacy) {
const store = useSessionStore.getState();
store.setWorkspaces(this.serverId, buildLegacyWorkspaces(completion.snapshot.entries));
store.setEmptyProjects(this.serverId, []);
const workspaces = buildLegacyWorkspaces(completion.snapshot.entries);
store.setWorkspaces(this.serverId, workspaces);
store.setProjects(
this.serverId,
Array.from(workspaces.values(), legacyProjectDescriptorFromWorkspace),
);
store.setHasHydratedWorkspaces(this.serverId, true);
}
const deltas = completion.snapshot.legacy
@@ -233,7 +239,7 @@ export class DirectorySync {
const { client, source } = this.requireOnline();
const transaction = this.workspaceTransactions.begin(source, () => ({
workspaces: new Map(),
emptyProjects: new Map(),
projects: new Map(),
}));
try {
await this.waitForSessionMetadata(client, source);
@@ -243,7 +249,17 @@ export class DirectorySync {
if (deltas) for (const delta of deltas) this.workspaces.applyDelta(delta);
return;
}
const supportsProjectList = serverInfo.features?.projectList === true;
if (supportsProjectList) {
const payload = await client.listProjects();
this.assertWorkspaceTransactionCurrent(client, source, transaction);
for (const entry of payload.projects) {
const project = normalizeProjectDescriptor(entry);
transaction.snapshot.projects.set(project.projectId, project);
}
}
await this.fetchWorkspaceSnapshot(client, source, transaction, input?.subscribe === true);
if (!supportsProjectList) this.buildLegacyProjectSnapshot(transaction.snapshot);
if (!this.isCurrent(client, source) || !this.hasMatchingSession(client, source)) {
throw new DirectoryRefreshSupersededError("workspace completion no longer current");
}
@@ -279,8 +295,8 @@ export class DirectorySync {
transaction.snapshot.workspaces.set(workspace.id, workspace);
}
for (const entry of payload.emptyProjects ?? []) {
const project = normalizeEmptyProjectDescriptor(entry);
transaction.snapshot.emptyProjects.set(project.projectId, project);
const project = normalizeProjectDescriptor(entry);
transaction.snapshot.projects.set(project.projectId, project);
}
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) return;
cursor = payload.pageInfo.nextCursor;
@@ -288,6 +304,15 @@ export class DirectorySync {
}
}
private buildLegacyProjectSnapshot(snapshot: WorkspaceDirectorySnapshot): void {
for (const workspace of snapshot.workspaces.values()) {
if (!snapshot.projects.has(workspace.projectId)) {
const project = legacyProjectDescriptorFromWorkspace(workspace);
snapshot.projects.set(project.projectId, project);
}
}
}
async refreshAll(): Promise<void> {
await Promise.all([this.refreshAgents(), this.refreshWorkspaces({ subscribe: true })]);
}
@@ -421,6 +446,17 @@ export class DirectorySync {
}
}
function legacyProjectDescriptorFromWorkspace(workspace: WorkspaceDescriptor): ProjectDescriptor {
return {
projectId: workspace.projectId,
projectKey: null,
projectDisplayName: workspace.projectDisplayName,
projectCustomName: workspace.projectCustomName ?? null,
projectRootPath: workspace.projectRootPath,
projectKind: workspace.projectKind,
};
}
export class DirectoryRefreshSupersededError extends Error {}
export type { DirectorySourceToken } from "./transaction";
@@ -2,7 +2,7 @@ import { expect, it } from "vitest";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { WorkspaceDescriptorPayload } from "@getpaseo/protocol/messages";
import {
normalizeEmptyProjectDescriptor,
normalizeProjectDescriptor,
normalizeWorkspaceDescriptor,
useSessionStore,
} from "@/stores/session-store";
@@ -33,7 +33,7 @@ it("commits workspace and project-parent state with filtered removals", () => {
const store = useSessionStore.getState();
store.initializeSession(serverId, null as unknown as DaemonClient);
const replica = new WorkspaceDirectoryReplica(serverId);
const empty = normalizeEmptyProjectDescriptor({
const empty = normalizeProjectDescriptor({
projectId: "empty",
projectDisplayName: "Empty",
projectRootPath: "/repo/empty",
@@ -45,14 +45,14 @@ it("commits workspace and project-parent state with filtered removals", () => {
["kept", normalizeWorkspaceDescriptor(workspace("kept"))],
["filtered", normalizeWorkspaceDescriptor(workspace("filtered", "filtered-project"))],
]),
emptyProjects: new Map([[empty.projectId, empty]]),
projects: new Map([[empty.projectId, empty]]),
},
[{ kind: "remove", id: "filtered", removedProjectId: "filtered-project" }],
);
const session = useSessionStore.getState().sessions[serverId];
expect(Array.from(session?.workspaces.keys() ?? [])).toEqual(["kept"]);
expect(Array.from(session?.emptyProjects.keys() ?? [])).toEqual(["empty"]);
expect(Array.from(session?.projects.keys() ?? [])).toEqual(["empty"]);
store.clearSession(serverId);
});
@@ -65,19 +65,19 @@ it("commits the authoritative snapshot before buffered project updates", () => {
const attachedFeature = normalizeWorkspaceDescriptor(workspace("attached-feature", "attached"));
const removed = normalizeWorkspaceDescriptor(workspace("removed", "removed"));
const unrelated = normalizeWorkspaceDescriptor(workspace("unrelated", "unrelated"));
const staleAttachedProject = normalizeEmptyProjectDescriptor({
const staleAttachedProject = normalizeProjectDescriptor({
projectId: "attached",
projectDisplayName: "Stale attached project",
projectRootPath: "/repo/attached",
projectKind: "git",
});
const removedProject = normalizeEmptyProjectDescriptor({
const removedProject = normalizeProjectDescriptor({
projectId: "removed",
projectDisplayName: "Removed project",
projectRootPath: "/repo/removed",
projectKind: "git",
});
const unchangedEmptyProject = normalizeEmptyProjectDescriptor({
const unchangedEmptyProject = normalizeProjectDescriptor({
projectId: "unchanged-empty",
projectDisplayName: "Unchanged empty project",
projectRootPath: "/repo/unchanged-empty",
@@ -92,7 +92,7 @@ it("commits the authoritative snapshot before buffered project updates", () => {
[removed.id, removed],
[unrelated.id, unrelated],
]),
emptyProjects: new Map([
projects: new Map([
[staleAttachedProject.projectId, staleAttachedProject],
[removedProject.projectId, removedProject],
[unchangedEmptyProject.projectId, unchangedEmptyProject],
@@ -103,6 +103,7 @@ it("commits the authoritative snapshot before buffered project updates", () => {
kind: "upsert",
project: {
projectId: "attached",
projectKey: "remote:github.com/acme/attached",
projectDisplayName: "Renamed attached project",
projectCustomName: "Personal name",
projectRootPath: "/moved/attached",
@@ -135,11 +136,29 @@ it("commits the authoritative snapshot before buffered project updates", () => {
});
expect(session?.workspaces.has(removed.id)).toBe(false);
expect(session?.workspaces.get(unrelated.id)).toBe(unrelated);
expect(Array.from(session?.emptyProjects.keys() ?? [])).toEqual(["unchanged-empty", "new-empty"]);
expect(session?.emptyProjects.get("unchanged-empty")).toBe(unchangedEmptyProject);
expect(session?.emptyProjects.get("new-empty")).toMatchObject({
expect(Array.from(session?.projects.keys() ?? [])).toEqual([
"attached",
"unchanged-empty",
"new-empty",
]);
expect(session?.projects.get("unchanged-empty")).toBe(unchangedEmptyProject);
expect(session?.projects.get("new-empty")).toMatchObject({
projectDisplayName: "New empty project",
projectRootPath: "/repo/new-empty",
});
store.clearSession(serverId);
});
it("does not invent a null-key project from a workspace update", () => {
const serverId = "workspace-before-project-update";
const store = useSessionStore.getState();
store.initializeSession(serverId, null as unknown as DaemonClient);
const replica = new WorkspaceDirectoryReplica(serverId);
replica.applyDelta({ kind: "upsert", workspace: workspace("main", "fresh-project") });
const session = useSessionStore.getState().sessions[serverId];
expect(session?.workspaces.has("main")).toBe(true);
expect(session?.projects.has("fresh-project")).toBe(false);
store.clearSession(serverId);
});
@@ -1,9 +1,9 @@
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
import {
normalizeEmptyProjectDescriptor,
normalizeProjectDescriptor,
normalizeWorkspaceDescriptor,
useSessionStore,
type EmptyProjectDescriptor,
type ProjectDescriptor,
type WorkspaceDescriptor,
} from "@/stores/session-store";
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
@@ -20,7 +20,7 @@ type ProjectDirectoryDelta = Extract<SessionOutboundMessage, { type: "project.up
export interface WorkspaceDirectorySnapshot {
workspaces: Map<string, WorkspaceDescriptor>;
emptyProjects: Map<string, EmptyProjectDescriptor>;
projects: Map<string, ProjectDescriptor>;
}
function applyProjectDelta(
@@ -28,18 +28,17 @@ function applyProjectDelta(
delta: ProjectDirectoryDelta,
): void {
if (delta.kind === "remove") {
snapshot.emptyProjects.delete(delta.projectId);
snapshot.projects.delete(delta.projectId);
for (const [workspaceId, workspace] of snapshot.workspaces) {
if (workspace.projectId === delta.projectId) snapshot.workspaces.delete(workspaceId);
}
return;
}
const project = normalizeEmptyProjectDescriptor(delta.project);
let hasAttachedWorkspace = false;
const project = normalizeProjectDescriptor(delta.project);
snapshot.projects.set(project.projectId, project);
for (const [workspaceId, workspace] of snapshot.workspaces) {
if (workspace.projectId !== project.projectId) continue;
hasAttachedWorkspace = true;
snapshot.workspaces.set(workspaceId, {
...workspace,
projectDisplayName: project.projectDisplayName,
@@ -48,8 +47,6 @@ function applyProjectDelta(
projectKind: project.projectKind,
});
}
if (hasAttachedWorkspace) snapshot.emptyProjects.delete(project.projectId);
else snapshot.emptyProjects.set(project.projectId, project);
}
export class WorkspaceDirectoryReplica {
@@ -75,7 +72,7 @@ export class WorkspaceDirectoryReplica {
const session = useSessionStore.getState().sessions[this.serverId];
return {
workspaces: new Map(session?.workspaces),
emptyProjects: new Map(session?.emptyProjects),
projects: new Map(session?.projects),
};
}
@@ -84,7 +81,7 @@ export class WorkspaceDirectoryReplica {
deltas: readonly WorkspaceDirectoryDelta[],
): WorkspaceDirectorySnapshot {
const workspaces = new Map(snapshot.workspaces);
const emptyProjects = new Map(snapshot.emptyProjects);
const projects = new Map(snapshot.projects);
for (const [workspaceId, workspace] of workspaces) {
if (shouldSuppressWorkspaceForLocalArchive({ serverId: this.serverId, workspace })) {
workspaces.delete(workspaceId);
@@ -92,16 +89,18 @@ export class WorkspaceDirectoryReplica {
}
for (const delta of deltas) {
if ("projectId" in delta || "project" in delta) {
applyProjectDelta({ workspaces, emptyProjects }, delta);
applyProjectDelta({ workspaces, projects }, delta);
continue;
}
if (delta.kind === "remove") {
workspaces.delete(delta.id);
if (delta.emptyProject) {
const project = normalizeEmptyProjectDescriptor(delta.emptyProject);
emptyProjects.set(project.projectId, project);
const project = normalizeProjectDescriptor(delta.emptyProject);
projects.set(project.projectId, project);
}
if (delta.removedProjectId) {
projects.delete(delta.removedProjectId);
}
if (delta.removedProjectId) emptyProjects.delete(delta.removedProjectId);
continue;
}
const workspace = normalizeWorkspaceDescriptor(delta.workspace);
@@ -109,16 +108,15 @@ export class WorkspaceDirectoryReplica {
workspaces.delete(workspace.id);
} else {
workspaces.set(workspace.id, workspace);
emptyProjects.delete(workspace.projectId);
}
}
return { workspaces, emptyProjects };
return { workspaces, projects };
}
private commit(snapshot: WorkspaceDirectorySnapshot, removedWorkspaceIds: string[]): void {
const store = useSessionStore.getState();
store.setWorkspaces(this.serverId, snapshot.workspaces);
store.setEmptyProjects(this.serverId, snapshot.emptyProjects.values());
store.setProjects(this.serverId, snapshot.projects.values());
for (const workspaceId of removedWorkspaceIds) {
clearWorkspaceArchivePending({ serverId: this.serverId, workspaceId });
useWorkspaceSetupStore.getState().removeWorkspace({ serverId: this.serverId, workspaceId });
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, it } from "vitest";
import type { WorkspaceDescriptorPayload } from "@getpaseo/protocol/messages";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import {
normalizeEmptyProjectDescriptor,
normalizeProjectDescriptor,
normalizeWorkspaceDescriptor,
useSessionStore,
} from "@/stores/session-store";
@@ -96,8 +96,15 @@ function seedSession(): void {
SERVER_ID,
new Map([["workspace-1", normalizeWorkspaceDescriptor(workspace())]]),
);
store.setEmptyProjects(SERVER_ID, [
normalizeEmptyProjectDescriptor({
store.setProjects(SERVER_ID, [
normalizeProjectDescriptor({
projectId: "project-1",
projectKey: "remote:github.com/getpaseo/paseo",
projectDisplayName: "Paseo",
projectRootPath: "/repo/paseo",
projectKind: "git",
}),
normalizeProjectDescriptor({
projectId: "empty-project",
projectDisplayName: "Empty project",
projectRootPath: "/repo/empty",
@@ -162,7 +169,7 @@ describe("ReplicaCache", () => {
expect(session?.hasHydratedWorkspaces).toBe(false);
expect(Array.from(session?.agents.keys() ?? [])).toEqual(["agent-1"]);
expect(Array.from(session?.workspaces.keys() ?? [])).toEqual(["workspace-1"]);
expect(Array.from(session?.emptyProjects.keys() ?? [])).toEqual([]);
expect(Array.from(session?.projects.keys() ?? [])).toEqual(["project-1"]);
expect(session?.agents.get("agent-1")?.updatedAt).toBeInstanceOf(Date);
expect(session?.workspaces.get("workspace-1")?.statusEnteredAt).toBeInstanceOf(Date);
expect(session?.agentStreamTail.get("agent-1")).toEqual([message("message-1", "Cached")]);
@@ -213,7 +220,7 @@ describe("ReplicaCache", () => {
const timelines = session?.agentStreamTail;
expect(Array.from(session?.agents.keys() ?? [])).toEqual(["agent-2"]);
expect(Array.from(session?.workspaces.keys() ?? [])).toEqual(["workspace-2"]);
expect(Array.from(session?.emptyProjects.keys() ?? [])).toEqual([]);
expect(Array.from(session?.projects.keys() ?? [])).toEqual(["project-2"]);
expect(Array.from(timelines?.keys() ?? [])).toEqual(["agent-2"]);
expect(timelines?.get("agent-2")).toEqual(secondTimeline.slice(-50));
});
@@ -9,12 +9,13 @@ import {
type WorkspaceDescriptorPayload,
} from "@getpaseo/protocol/messages";
import {
normalizeEmptyProjectDescriptor,
normalizeProjectDescriptor,
normalizeWorkspaceDescriptor,
useSessionStore,
type Agent,
type SessionReplica,
type SessionState,
type ProjectDescriptor,
type WorkspaceDescriptor,
} from "@/stores/session-store";
import type { StreamItem } from "@/types/stream";
@@ -50,6 +51,7 @@ const StoredHostSchema = z.object({
serverId: z.string(),
agents: z.array(StoredAgentSchema),
workspaces: z.array(WorkspaceDescriptorPayloadSchema),
projects: z.array(WorkspaceProjectDescriptorPayloadSchema).optional().default([]),
emptyProjects: z.array(WorkspaceProjectDescriptorPayloadSchema),
timeline: StoredTimelineSchema.nullable(),
});
@@ -214,18 +216,47 @@ function serializeWorkspace(workspace: WorkspaceDescriptor): WorkspaceDescriptor
};
}
function serializeProject(project: ProjectDescriptor) {
return {
projectId: project.projectId,
...(project.projectKey ? { projectKey: project.projectKey } : {}),
projectDisplayName: project.projectDisplayName,
projectCustomName: project.projectCustomName,
projectRootPath: project.projectRootPath,
projectKind: project.projectKind,
};
}
function deserializeHost(stored: StoredHost): SessionReplica {
const agents = stored.agents.map((entry) => deserializeAgent(stored.serverId, entry));
const workspaces = stored.workspaces.map(normalizeWorkspaceDescriptor);
const emptyProjects = stored.emptyProjects.map(normalizeEmptyProjectDescriptor);
const listedProjects = stored.projects.map(normalizeProjectDescriptor);
const legacyProjects = [
...stored.emptyProjects.map(normalizeProjectDescriptor),
...workspaces.map(legacyProjectDescriptorFromWorkspace),
];
const projects = new Map(
[...legacyProjects, ...listedProjects].map((project) => [project.projectId, project]),
);
return {
agents: new Map(agents.map((agent) => [agent.id, agent])),
workspaces: new Map(workspaces.map((workspace) => [workspace.id, workspace])),
emptyProjects: new Map(emptyProjects.map((project) => [project.projectId, project])),
projects,
timeline: deserializeTimeline(stored.timeline),
};
}
function legacyProjectDescriptorFromWorkspace(workspace: WorkspaceDescriptor): ProjectDescriptor {
return {
projectId: workspace.projectId,
projectKey: null,
projectDisplayName: workspace.projectDisplayName,
projectCustomName: workspace.projectCustomName ?? null,
projectRootPath: workspace.projectRootPath,
projectKind: workspace.projectKind,
};
}
export class ReplicaCache {
private readonly activeServerIds = new Set<string>();
private readonly storedHosts = new Map<string, StoredHost>();
@@ -384,6 +415,11 @@ export class ReplicaCache {
serverId,
agents: focusedAgent ? [serializeAgent(focusedAgent)] : [],
workspaces: focusedWorkspace ? [serializeWorkspace(focusedWorkspace)] : [],
projects: focusedWorkspace
? [session.projects.get(focusedWorkspace.projectId)].flatMap((project) =>
project ? [serializeProject(project)] : [],
)
: [],
emptyProjects: [],
timeline,
};
@@ -588,6 +588,28 @@ describe("schedule form model", () => {
});
});
it("hydrates an edited schedule's project label when its host targets arrive", () => {
const form = open({
mode: "edit",
schedule: scheduleOnHost({
serverId: "host-b",
serverName: "Host B",
cwd: "/repo/b",
model: "model-b",
}),
defaults: { serverId: null, projectTargets: [], preferences: {} },
});
expect(form.getState().projectDisplay).toEqual({ label: "/repo/b" });
form.applyProjectTargets(PROJECT_TARGETS);
expect(form.getState()).toMatchObject({
projectDisplay: { label: "Project B" },
selectedProjectOptionId: buildProjectOptionId("host-b", "project-b"),
});
});
it("hydrates late create preferences without overwriting user changes or edited schedules", () => {
const savedPreferences: FormPreferences = {
provider: "mock",
@@ -602,6 +602,11 @@ function updateDerivedState(input: {
...input.state,
hosts: [...input.hosts],
projectOptions: buildProjectOptions(input.targets, input.state.selectedServerId),
projectDisplay: resolveProjectDisplay({
targets: input.targets,
serverId: input.state.selectedServerId,
cwd: input.state.workingDir,
}),
selectedProjectOptionId: projectTarget?.optionId ?? input.state.selectedProjectOptionId,
selectedModelDisplay: resolveModelDisplay({
entries: input.providerEntries,
@@ -21,6 +21,9 @@ function makeProject(overrides: Partial<ProjectSummary>): ProjectSummary {
function makeHost(overrides: Partial<ProjectSummary["hosts"][number]>) {
return {
serverId: "host-1",
projectId: "project-1",
projectName: "Project",
projectCustomName: null,
serverName: "Host 1",
isOnline: true,
repoRoot: "/tmp/project",
@@ -35,14 +38,17 @@ describe("buildScheduleProjectTargets", () => {
const targets = buildScheduleProjectTargets([
makeProject({
projectName: "Alpha",
hosts: [makeHost({ repoRoot: "/tmp/alpha" }), makeHost({ serverId: "host-2" })],
hosts: [
makeHost({ projectName: "Alpha on Host 1", repoRoot: "/tmp/alpha" }),
makeHost({ serverId: "host-2", projectName: "Alpha on Host 2" }),
],
}),
]);
expect(targets).toHaveLength(2);
expect(targets[0]).toMatchObject({
serverId: "host-1",
cwd: "/tmp/alpha",
projectName: "Alpha",
projectName: "Alpha on Host 1",
});
});
@@ -60,12 +66,15 @@ describe("describeScheduleCwd", () => {
it("prefers a matched project name and shortens unmatched paths", () => {
const byCwd = buildProjectNameByCwd(
buildScheduleProjectTargets([
makeProject({ projectName: "Alpha", hosts: [makeHost({ repoRoot: "/tmp/alpha" })] }),
makeProject({
projectName: "Grouped Alpha",
hosts: [makeHost({ projectName: "Alpha on Host 1", repoRoot: "/tmp/alpha" })],
}),
]),
);
expect(
describeScheduleCwd({ serverId: "host-1", cwd: "/tmp/alpha", projectNameByCwd: byCwd }),
).toBe("Alpha");
).toBe("Alpha on Host 1");
expect(
describeScheduleCwd({ serverId: "host-1", cwd: "/Users/sam/api", projectNameByCwd: byCwd }),
).toBe("~/api");
@@ -37,7 +37,7 @@ export function buildScheduleProjectTargets(
serverId: host.serverId,
serverName: host.serverName,
projectKey: project.projectKey,
projectName: project.projectName,
projectName: host.projectName,
cwd,
isGit: Boolean(host.gitRuntime),
});
@@ -12,7 +12,7 @@ function projectFor(serverId: string, key = "project"): HostProjectListItem {
projectName: key,
projectKind: "git",
iconWorkingDir: `/work/${key}`,
hosts: [{ serverId, iconWorkingDir: `/work/${key}`, canCreateWorktree: true }],
hosts: [{ serverId, projectId: key, iconWorkingDir: `/work/${key}`, canCreateWorktree: true }],
workspaceKeys: [],
};
}
@@ -66,6 +66,7 @@ import { toErrorMessage } from "@/utils/error-messages";
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
import {
getHostProjectSourceDirectory,
getHostProjectId,
hostProjectFromRoute,
hostProjectFromWorkspace,
useHostProjects,
@@ -823,6 +824,8 @@ async function createMultiplicityWorkspace(input: {
serverId: string;
createFailedMessage: string;
}): Promise<ReturnType<typeof normalizeWorkspaceDescriptor>> {
const projectId = getHostProjectId(input.project, input.serverId);
if (!projectId) throw new Error("Project is not available on the selected host");
const isWorktree = input.isolation === "worktree";
const checkoutRequest = isWorktree
? resolveCheckoutRequest(input.selectedItem, input.currentBranch)
@@ -836,14 +839,14 @@ async function createMultiplicityWorkspace(input: {
? {
kind: "worktree",
cwd: input.sourceDirectory,
projectId: input.project.projectKey,
projectId,
worktreeSlug: createNameId(),
...checkoutRequest,
}
: {
kind: "directory",
path: input.sourceDirectory,
projectId: input.project.projectKey,
projectId,
},
...(firstAgentContext ? { firstAgentContext } : {}),
});
@@ -1411,6 +1414,8 @@ function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactEleme
>
<Pressable
ref={host.anchorRef}
accessibilityRole="button"
accessibilityLabel="Host"
onPress={host.open}
disabled={isPending || host.allHosts.length === 0}
style={badgePressableStyle}
@@ -1889,16 +1894,20 @@ export function NewWorkspaceScreen({
}
const checkoutRequest = resolveCheckoutRequest(selectedItem, currentBranch);
const firstAgentContext = buildFirstAgentContext(input);
const hostProjectId = getHostProjectId(selectedProject, selectedServerId);
if (!hostProjectId) {
throw new Error("Project is not available on the selected host");
}
return {
cwd: selectedSourceDirectory,
projectId: selectedProject.projectKey,
projectId: hostProjectId,
worktreeSlug: createNameId(),
...(firstAgentContext ? { firstAgentContext } : {}),
...checkoutRequest,
};
},
[currentBranch, selectedItem, selectedProject, selectedSourceDirectory],
[currentBranch, selectedItem, selectedProject, selectedServerId, selectedSourceDirectory],
);
const ensureWorkspace = useCallback(
@@ -17,7 +17,14 @@ function project(projectKey: string, serverId = "host"): HostProjectListItem {
projectName: projectKey,
projectKind: "git",
iconWorkingDir: `/work/${projectKey}`,
hosts: [{ serverId, iconWorkingDir: `/work/${projectKey}`, canCreateWorktree: true }],
hosts: [
{
serverId,
projectId: projectKey,
iconWorkingDir: `/work/${projectKey}`,
canCreateWorktree: true,
},
],
workspaceKeys: [],
};
}
@@ -111,6 +118,46 @@ describe("reconcileProjectSelection", () => {
});
});
it("adopts a routed project's hydrated cross-host key", () => {
const routedProject = {
...project("local-project-id"),
hosts: [
{
serverId: "host",
projectId: "local-project-id",
iconWorkingDir: "/work/project",
canCreateWorktree: true,
},
],
};
const hydratedProject = {
...project("remote:github.com/acme/project"),
hosts: [
{
serverId: "host",
projectId: "local-project-id",
iconWorkingDir: "/work/project",
canCreateWorktree: false,
},
],
};
const current = createProjectSelection(
context({ initialProject: routedProject, projects: [], routeProject: routedProject }),
);
const hydratedContext = context({
initialProject: hydratedProject,
projects: [hydratedProject],
routeProject: routedProject,
});
expect(reconcileProjectSelection(current, hydratedContext)).toEqual({
contextKey: "host:",
projectKey: hydratedProject.projectKey,
project: hydratedProject,
source: "initial",
});
});
it("stores hydrated project snapshots before archive gaps", () => {
const routeProject = project("route-project");
const hydratedProject: HostProjectListItem = {
@@ -184,6 +231,17 @@ describe("reconcileProjectSelection", () => {
expect(reconcileProjectSelection(current, afterRememberedHydration)).toEqual(current);
});
it("preserves an opaque project key ending in whitespace", () => {
const selected = project("host:project ");
const current = createProjectSelection(
context({ initialProject: selected, projects: [selected] }),
);
const currentContext = context({ initialProject: selected, projects: [selected] });
expect(resolveProjectSelection(current, currentContext)).toEqual(selected);
expect(reconcileProjectSelection(current, currentContext)).toEqual(current);
});
it("resets fallback selection when host project capability changes", () => {
const fallback = project("git-fallback");
const remembered = project("remembered-directory");
@@ -57,7 +57,17 @@ export function resolveInitialProjectSelectionSource(input: {
if (!input.initialProject) {
return null;
}
if (input.routeProject?.projectKey === input.initialProject.projectKey) {
if (
input.routeProject?.projectKey === input.initialProject.projectKey ||
input.routeProject?.hosts.some((routeHost) =>
input.initialProject?.hosts.some(
(host) =>
host.serverId === routeHost.serverId &&
Boolean(host.projectId) &&
host.projectId === routeHost.projectId,
),
)
) {
return "route";
}
if (input.lastActiveProject?.projectKey === input.initialProject.projectKey) {
@@ -67,8 +77,8 @@ export function resolveInitialProjectSelectionSource(input: {
}
function resolveProjectSelectionKey(selection: ProjectSelection): string | null {
const projectKey = selection.projectKey?.trim() ?? "";
return projectKey || null;
const projectKey = selection.projectKey ?? "";
return projectKey.trim() ? projectKey : null;
}
function resolveSelectedProjectFromInitialInputs(
@@ -95,14 +105,14 @@ function refreshSelectionProject(
};
}
function shouldResetInitialFallbackSelection(
function shouldResetHydratedInitialSelection(
selection: ProjectSelection,
context: ProjectSelectionContext,
): boolean {
if (
selection.source !== "initial" ||
!context.initialProject ||
context.initialProjectSource !== "lastActive"
(context.initialProjectSource !== "route" && context.initialProjectSource !== "lastActive")
) {
return false;
}
@@ -149,7 +159,7 @@ export function reconcileProjectSelection(
return initialSelection;
}
if (shouldResetInitialFallbackSelection(current, context)) {
if (shouldResetHydratedInitialSelection(current, context)) {
return initialSelection;
}
@@ -0,0 +1,30 @@
import { expect, test } from "vitest";
import { resolveProjectSettingsHostTarget } from "./project-settings-host-selection";
test("falls back when the selected host is no longer editable", () => {
const hostA = { serverId: "host-a", projectId: "project-a" };
const hostB = { serverId: "host-b", projectId: "project-b" };
expect(
resolveProjectSettingsHostTarget({
routeKey: "host-a:project-a",
editableHosts: [hostA],
defaultHost: hostA,
selection: { routeKey: "host-a:project-a", ...hostB },
}),
).toEqual(hostA);
});
test("keeps the selected host while it remains editable", () => {
const hostA = { serverId: "host-a", projectId: "project-a" };
const hostB = { serverId: "host-b", projectId: "project-b" };
expect(
resolveProjectSettingsHostTarget({
routeKey: "host-a:project-a",
editableHosts: [hostA, hostB],
defaultHost: hostA,
selection: { routeKey: "host-a:project-a", ...hostB },
}),
).toEqual(hostB);
});
@@ -0,0 +1,40 @@
export interface ProjectSettingsHostTarget {
serverId: string;
projectId: string;
}
export interface StoredProjectSettingsHostSelection extends ProjectSettingsHostTarget {
routeKey: string;
}
interface ResolveProjectSettingsHostTargetInput {
routeKey: string;
editableHosts: readonly ProjectSettingsHostTarget[];
defaultHost: ProjectSettingsHostTarget | undefined;
selection: StoredProjectSettingsHostSelection;
}
export function resolveProjectSettingsHostTarget(
input: ResolveProjectSettingsHostTargetInput,
): ProjectSettingsHostTarget | undefined {
const selectedHost =
input.selection.routeKey === input.routeKey
? getEditableHost(input.editableHosts, input.selection)
: undefined;
return (
selectedHost ??
getEditableHost(input.editableHosts, input.defaultHost) ??
input.editableHosts[0]
);
}
function getEditableHost(
editableHosts: readonly ProjectSettingsHostTarget[],
target: ProjectSettingsHostTarget | undefined,
): ProjectSettingsHostTarget | undefined {
if (!target) return undefined;
for (const host of editableHosts) {
if (host.serverId === target.serverId && host.projectId === target.projectId) return host;
}
return undefined;
}
@@ -46,6 +46,10 @@ import {
} from "@/utils/project-config-form";
import { buildProjectsSettingsRoute } from "@/utils/host-routes";
import type { ProjectHostEntry, ProjectSummary } from "@/utils/projects";
import { getProjectHostEntry, getProjectSummaryForHostProject } from "@/utils/projects";
import { useSessionStore } from "@/stores/session-store";
import { selectProjectIdForServer } from "@/stores/session-store-hooks/selectors";
import { resolveProjectSettingsHostTarget } from "@/screens/project-settings-host-selection";
const SCRIPT_SERVICE_TYPE = "service";
@@ -84,35 +88,54 @@ const WORKTREE_DOCS_URL = "https://paseo.sh/docs/worktrees";
type ReadProjectConfigData = Awaited<ReturnType<DaemonClient["readProjectConfig"]>>;
export interface ProjectSettingsScreenProps {
projectKey: string;
serverId: string;
projectId: string;
}
export default function ProjectSettingsScreen({ projectKey }: ProjectSettingsScreenProps) {
export default function ProjectSettingsScreen({ serverId, projectId }: ProjectSettingsScreenProps) {
const { projects } = useProjects();
const project = useMemo(
() => projects.find((entry) => entry.projectKey === projectKey),
[projects, projectKey],
() => getProjectSummaryForHostProject(projects, serverId, projectId),
[projectId, projects, serverId],
);
const editableHosts = useMemo(() => filterEditableHosts(project), [project]);
const [selectedServerId, setSelectedServerId] = useState<string>(
() => editableHosts[0]?.serverId ?? "",
const routeKey = `${serverId}:${projectId}`;
const routeHost = getProjectHostEntry(project, serverId, projectId);
const defaultHost = routeHost?.isOnline ? routeHost : editableHosts[0];
const [hostSelection, setHostSelection] = useState({
routeKey: "",
serverId: "",
projectId: "",
});
const selectedTarget = resolveProjectSettingsHostTarget({
routeKey,
editableHosts,
defaultHost,
selection: hostSelection,
});
const selectedServerId = selectedTarget?.serverId ?? "";
const selectedProjectId = selectedTarget?.projectId ?? "";
const setSelectedServerId = useCallback(
(targetServerId: string) => {
const targetProjectId = selectProjectIdForServer(useSessionStore.getState(), {
sourceServerId: serverId,
projectId,
targetServerId,
});
if (targetProjectId) {
setHostSelection({ routeKey, serverId: targetServerId, projectId: targetProjectId });
}
},
[projectId, routeKey, serverId],
);
useEffect(() => {
const stillValid = editableHosts.some((host) => host.serverId === selectedServerId);
if (!stillValid) {
setSelectedServerId(editableHosts[0]?.serverId ?? "");
}
}, [editableHosts, selectedServerId]);
const selectedSnapshot = useHostRuntimeSnapshot(selectedServerId);
const isHostGone =
Boolean(selectedServerId) &&
(selectedSnapshot?.connectionStatus === "offline" ||
selectedSnapshot?.connectionStatus === "error");
const selectedHost = editableHosts.find((host) => host.serverId === selectedServerId);
const selectedHost = getProjectHostEntry(project, selectedServerId, selectedProjectId);
const client = useHostRuntimeClient(selectedHost?.serverId ?? "");
if (!project || editableHosts.length === 0 || !selectedHost || !client) {
@@ -231,7 +254,7 @@ function ProjectSettingsBody({
const hasMultipleHosts = hosts.length > 1;
return (
<View style={styles.body}>
<View role="main" style={styles.body}>
<BackToProjectsButton />
<View style={styles.headerBlock}>
@@ -241,7 +264,13 @@ function ProjectSettingsBody({
projectName={project.projectName}
projectKey={project.projectKey}
/>
<ProjectNameEditor project={project} client={client} />
<ProjectNameEditor
key={`${selectedHost.serverId}:${selectedHost.projectId}`}
projectName={selectedHost.projectName}
projectCustomName={selectedHost.projectCustomName}
projectId={selectedHost.projectId}
client={client}
/>
</View>
<HostContext hosts={hosts} selectedHost={selectedHost} onSelectHost={onSelectHost} />
</View>
@@ -789,19 +818,26 @@ function ResolveSpinnerColor(): string {
}
interface ProjectNameEditorProps {
project: ProjectSummary;
projectName: string;
projectCustomName: string | null;
projectId: string;
client: DaemonClient;
}
function ProjectNameEditor({ project, client }: ProjectNameEditorProps) {
function ProjectNameEditor({
projectName,
projectCustomName,
projectId,
client,
}: ProjectNameEditorProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const toast = useToast();
const [isEditing, setIsEditing] = useState(false);
const [value, setValue] = useState(project.projectCustomName ?? "");
const [value, setValue] = useState(projectCustomName ?? "");
const renameMutation = useMutation({
mutationFn: (customName: string | null) => client.renameProject(project.projectKey, customName),
mutationFn: (customName: string | null) => client.renameProject(projectId, customName),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["projects"] });
setIsEditing(false);
@@ -815,24 +851,24 @@ function ProjectNameEditor({ project, client }: ProjectNameEditorProps) {
});
const handleStartEdit = useCallback(() => {
setValue(project.projectCustomName ?? "");
setValue(projectCustomName ?? "");
setIsEditing(true);
}, [project.projectCustomName]);
}, [projectCustomName]);
const handleCancel = useCallback(() => {
setIsEditing(false);
setValue(project.projectCustomName ?? "");
}, [project.projectCustomName]);
setValue(projectCustomName ?? "");
}, [projectCustomName]);
const handleSave = useCallback(() => {
const trimmed = value.trim();
const next = trimmed.length === 0 ? null : trimmed;
if (next === (project.projectCustomName ?? null)) {
if (next === projectCustomName) {
setIsEditing(false);
return;
}
renameMutation.mutate(next);
}, [value, project.projectCustomName, renameMutation]);
}, [value, projectCustomName, renameMutation]);
const handleReset = useCallback(() => {
renameMutation.mutate(null);
@@ -840,12 +876,17 @@ function ProjectNameEditor({ project, client }: ProjectNameEditorProps) {
if (!isEditing) {
return (
<View style={styles.nameEditorRow}>
<View
role="group"
accessibilityLabel={t("settings.project.rename.projectNameLabel")}
style={styles.nameEditorRow}
>
<Text style={styles.projectTitle} numberOfLines={1}>
{project.projectName}
{projectName}
</Text>
<Pressable
testID="project-name-edit-button"
accessibilityRole="button"
accessibilityLabel={t("settings.project.rename.renameLabel")}
onPress={handleStartEdit}
hitSlop={8}
@@ -853,9 +894,10 @@ function ProjectNameEditor({ project, client }: ProjectNameEditorProps) {
>
<Pencil size={ICON_SIZE} color={styles.iconColor.color} />
</Pressable>
{project.projectCustomName ? (
{projectCustomName ? (
<Pressable
testID="project-name-reset-button"
accessibilityRole="button"
accessibilityLabel={t("settings.project.rename.resetLabel")}
onPress={handleReset}
disabled={renameMutation.isPending}
@@ -870,13 +912,17 @@ function ProjectNameEditor({ project, client }: ProjectNameEditorProps) {
}
return (
<View style={styles.nameEditorRow}>
<View
role="group"
accessibilityLabel={t("settings.project.rename.projectNameLabel")}
style={styles.nameEditorRow}
>
<TextInput
testID="project-name-input"
accessibilityLabel={t("settings.project.rename.projectNameLabel")}
value={value}
onChangeText={setValue}
placeholder={project.projectName}
placeholder={projectName}
placeholderTextColor={styles.placeholderColor.color}
autoFocus
style={styles.nameEditorInput}
@@ -886,6 +932,7 @@ function ProjectNameEditor({ project, client }: ProjectNameEditorProps) {
/>
<Pressable
testID="project-name-save-button"
accessibilityRole="button"
accessibilityLabel={t("settings.project.rename.saveLabel")}
onPress={handleSave}
disabled={renameMutation.isPending}
@@ -896,6 +943,7 @@ function ProjectNameEditor({ project, client }: ProjectNameEditorProps) {
</Pressable>
<Pressable
testID="project-name-cancel-button"
accessibilityRole="button"
accessibilityLabel={t("settings.project.rename.cancelLabel")}
onPress={handleCancel}
disabled={renameMutation.isPending}
@@ -982,6 +1030,7 @@ function HostPicker({ hosts, selectedHost, onSelectHost }: HostPickerProps) {
>
<Pressable
ref={triggerRef}
accessibilityRole="button"
accessibilityLabel={t("settings.project.switchHost")}
testID="host-picker"
style={styles.hostIndicator}
@@ -221,6 +221,9 @@ function workspaceSummary(overrides: Partial<WorkspaceSummary> = {}): WorkspaceS
function hostEntry(overrides: Partial<ProjectHostEntry> = {}): ProjectHostEntry {
return {
serverId: "host-a",
projectId: "project-a",
projectName: "Project",
projectCustomName: null,
serverName: "alpha",
isOnline: true,
repoRoot: "/home/me/proj",
@@ -290,7 +293,9 @@ describe("ProjectsScreen", () => {
vi.unstubAllGlobals();
});
function render(view: { kind: "projects" } | { kind: "project"; projectKey: string }) {
function render(
view: { kind: "projects" } | { kind: "project"; serverId: string; projectId: string },
) {
act(() => {
root?.render(<ProjectsScreen view={view} />);
});
@@ -329,7 +334,7 @@ describe("ProjectsScreen", () => {
});
expect(navigate).toHaveBeenCalledTimes(1);
expect(navigate).toHaveBeenCalledWith("/settings/projects/remote%3Agithub.com%2Facme%2Fapp");
expect(navigate).toHaveBeenCalledWith("/settings/projects/host-a/project-a");
});
it("does not render a kebab menu on the row", () => {
@@ -392,7 +397,7 @@ describe("ProjectsScreen", () => {
],
});
render({ kind: "project", projectKey: "remote:github.com/acme/app" });
render({ kind: "project", serverId: "host-a", projectId: "project-a" });
const selected = findRow(container!, "remote:github.com/acme/app");
const other = findRow(container!, "remote:github.com/acme/other");
+10 -6
View File
@@ -10,16 +10,20 @@ import { useProjects, type ProjectHostError } from "@/hooks/use-projects";
import { useProjectIconDataByProjectKey } from "@/projects/project-icons";
import { settingsStyles } from "@/styles/settings";
import { buildProjectSettingsRoute } from "@/utils/host-routes";
import type { ProjectSummary } from "@/utils/projects";
import { getProjectSummaryForHostProject, type ProjectSummary } from "@/utils/projects";
interface ProjectsScreenProps {
view: { kind: "projects" } | { kind: "project"; projectKey: string };
view: { kind: "projects" } | { kind: "project"; serverId: string; projectId: string };
}
export default function ProjectsScreen({ view }: ProjectsScreenProps) {
const { t } = useTranslation();
const { projects, hostErrors, isLoading } = useProjects();
const selectedProjectKey = view.kind === "project" ? view.projectKey : null;
const selectedProjectKey =
view.kind === "project"
? (getProjectSummaryForHostProject(projects, view.serverId, view.projectId)?.projectKey ??
null)
: null;
const iconTargets = useMemo(
() =>
projects.flatMap((project) => {
@@ -100,10 +104,10 @@ function ProjectRow({ project, isFirst, isSelected, iconDataUri }: ProjectRowPro
const { t } = useTranslation();
const { theme } = useUnistyles();
const { projectKey, projectName } = project;
const target = project.hosts[0];
const handleNavigate = useCallback(() => {
router.navigate(buildProjectSettingsRoute(projectKey));
}, [projectKey]);
if (target) router.navigate(buildProjectSettingsRoute(target.serverId, target.projectId));
}, [target]);
const rowStyle = useCallback(
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
+2 -2
View File
@@ -129,7 +129,7 @@ export type SettingsView =
| { kind: "section"; section: SettingsSectionSlug }
| { kind: "host"; serverId: string; section: HostSectionSlug }
| { kind: "projects" }
| { kind: "project"; projectKey: string };
| { kind: "project"; serverId: string; projectId: string };
interface SidebarSectionItem {
id: SettingsSectionSlug;
@@ -1386,7 +1386,7 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
return <ProjectsScreen view={view} />;
}
if (view.kind === "project") {
return <ProjectSettingsScreen projectKey={view.projectKey} />;
return <ProjectSettingsScreen serverId={view.serverId} projectId={view.projectId} />;
}
if (view.kind === "section") {
switch (view.section) {
@@ -19,7 +19,7 @@ import {
} from "./selectors";
import {
useSessionStore,
type EmptyProjectDescriptor,
type ProjectDescriptor,
type WorkspaceDescriptor,
} from "../session-store";
@@ -45,11 +45,33 @@ function createWorkspace(
};
}
function projectDescriptorFromTestWorkspace(workspace: WorkspaceDescriptor): ProjectDescriptor {
return {
projectId: workspace.projectId,
projectKey: workspace.projectId,
projectDisplayName: workspace.projectDisplayName,
projectCustomName: workspace.projectCustomName ?? null,
projectRootPath: workspace.projectRootPath,
projectKind: workspace.projectKind,
};
}
function initializeWorkspaces(workspaces: WorkspaceDescriptor[]): void {
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
useSessionStore
.getState()
.setWorkspaces(SERVER_ID, new Map(workspaces.map((workspace) => [workspace.id, workspace])));
useSessionStore
.getState()
.setProjects(
SERVER_ID,
new Map(
workspaces.map((workspace) => [
workspace.projectId,
projectDescriptorFromTestWorkspace(workspace),
]),
).values(),
);
}
interface Subscribable<S> {
@@ -125,11 +147,26 @@ describe("workspace replica authority", () => {
useSessionStore
.getState()
.setWorkspaces(SERVER_ID, new Map([[authoritativeWorkspace.id, authoritativeWorkspace]]));
useSessionStore
.getState()
.setProjects(
SERVER_ID,
new Map([
[
authoritativeWorkspace.projectId,
projectDescriptorFromTestWorkspace(authoritativeWorkspace),
],
]).values(),
);
useSessionStore.getState().setHasHydratedWorkspaces(SERVER_ID, true);
const hydratedServerIds = selectHydratedWorkspaceServerIds(useSessionStore.getState(), [
SERVER_ID,
]);
expect(hydratedServerIds).toEqual([SERVER_ID]);
expect(
Array.from(useSessionStore.getState().sessions[SERVER_ID]?.projects.keys() ?? []),
).toEqual([authoritativeWorkspace.projectId]);
expect(
selectWorkspaceStructureProjects(useSessionStore.getState(), hydratedServerIds).map(
(project) => project.workspaceKeys,
@@ -149,10 +186,16 @@ describe("workspace replica authority", () => {
sessions: {
[loadingServerId]: {
hasHydratedWorkspaces: false,
projects: new Map([
[loadingWorkspace.projectId, projectDescriptorFromTestWorkspace(loadingWorkspace)],
]),
workspaces: new Map([[loadingWorkspace.id, loadingWorkspace]]),
},
[hydratedServerId]: {
hasHydratedWorkspaces: true,
projects: new Map([
[hydratedWorkspace.projectId, projectDescriptorFromTestWorkspace(hydratedWorkspace)],
]),
workspaces: new Map([[hydratedWorkspace.id, hydratedWorkspace]]),
},
},
@@ -295,8 +338,9 @@ describe("workspace structure composition", () => {
projectRootPath: "/repo/a",
workspaceDirectory: "/repo/a",
});
const emptyProject: EmptyProjectDescriptor = {
const emptyProject: ProjectDescriptor = {
projectId: "project-a",
projectKey: "project-a",
projectDisplayName: "Project A",
projectCustomName: null,
projectRootPath: "/repo/a",
@@ -311,7 +355,7 @@ describe("workspace structure composition", () => {
try {
useSessionStore.getState().removeWorkspace(SERVER_ID, workspace.id);
useSessionStore.getState().addEmptyProject(SERVER_ID, emptyProject);
useSessionStore.getState().upsertProject(SERVER_ID, emptyProject);
} finally {
stop();
}
@@ -348,15 +392,15 @@ describe("workspace structure composition", () => {
it("renders a project parent with zero active workspaces", () => {
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
useSessionStore.getState().setWorkspaces(SERVER_ID, new Map());
useSessionStore.getState().setEmptyProjects(SERVER_ID, [
{
projectId: "empty-project",
projectDisplayName: "Empty Project",
projectCustomName: null,
projectRootPath: "/repo/empty",
projectKind: "git",
},
]);
const emptyProject: ProjectDescriptor = {
projectId: "empty-project",
projectKey: "empty-project",
projectDisplayName: "Empty Project",
projectCustomName: null,
projectRootPath: "/repo/empty",
projectKind: "git",
};
useSessionStore.getState().setProjects(SERVER_ID, [emptyProject]);
const projects = selectWorkspaceStructureProjects(useSessionStore.getState(), [SERVER_ID]);
expect(projects).toEqual([
@@ -368,7 +412,7 @@ describe("workspace structure composition", () => {
]);
});
it("changes when a structure-relevant project identity field changes", () => {
it("changes when a project descriptor display field changes", () => {
const workspace = createWorkspace({
id: "workspace-a",
projectDisplayName: "Project 1",
@@ -382,9 +426,10 @@ describe("workspace structure composition", () => {
);
const before = tracked.current;
useSessionStore
.getState()
.mergeWorkspaces(SERVER_ID, [{ ...workspace, projectDisplayName: "Project Renamed" }]);
useSessionStore.getState().upsertProject(SERVER_ID, {
...projectDescriptorFromTestWorkspace(workspace),
projectDisplayName: "Project Renamed",
});
expect(tracked.current).not.toBe(before);
tracked.stop();
@@ -6,7 +6,7 @@ import {
} from "@/projects/workspace-structure";
import type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state";
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity";
import type { EmptyProjectDescriptor, WorkspaceDescriptor } from "../session-store";
import type { ProjectDescriptor, WorkspaceDescriptor } from "../session-store";
export type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state";
export type { WorkspaceStructure, WorkspaceStructureProject } from "@/projects/workspace-structure";
@@ -17,7 +17,7 @@ export interface SessionsSnapshot {
{
hasHydratedWorkspaces?: boolean;
workspaces: Map<string, WorkspaceDescriptor>;
emptyProjects?: Map<string, EmptyProjectDescriptor>;
projects?: Map<string, ProjectDescriptor>;
}
>;
}
@@ -145,20 +145,20 @@ export function selectWorkspaceStructureProjects(
const sessions: Array<{
serverId: string;
workspaces: Iterable<WorkspaceDescriptor>;
emptyProjects: Iterable<EmptyProjectDescriptor>;
projects: Iterable<ProjectDescriptor>;
}> = [];
for (const serverId of serverIds) {
const session = state.sessions[serverId];
const workspaces = session?.workspaces;
const emptyProjects = session?.emptyProjects;
if ((!workspaces || workspaces.size === 0) && (!emptyProjects || emptyProjects.size === 0)) {
const projects = session?.projects;
if (!projects || projects.size === 0) {
continue;
}
sessions.push({
serverId,
workspaces: workspaces?.values() ?? [],
emptyProjects: emptyProjects?.values() ?? [],
projects: projects.values(),
});
}
@@ -169,6 +169,32 @@ export function selectWorkspaceStructureProjects(
return buildWorkspaceStructureProjects({ sessions });
}
export function selectProject(
state: SessionsSnapshot,
serverId: string | null,
projectId: string | null,
): ProjectDescriptor | null {
if (!serverId || !projectId) return null;
return state.sessions[serverId]?.projects?.get(projectId) ?? null;
}
export function selectProjectIdForServer(
state: SessionsSnapshot,
input: {
sourceServerId: string;
projectId: string;
targetServerId: string;
},
): string | null {
if (input.sourceServerId === input.targetServerId) return input.projectId;
const source = selectProject(state, input.sourceServerId, input.projectId);
if (!source?.projectKey) return null;
for (const project of state.sessions[input.targetServerId]?.projects?.values() ?? []) {
if (project.projectKey === source.projectKey) return project.projectId;
}
return null;
}
export function selectProjectOrder(state: SidebarOrderSnapshot): string[] {
return state.projectOrder ?? EMPTY_WORKSPACE_KEYS;
}
@@ -49,7 +49,6 @@ function getTestSessionReferences() {
sessions: state.sessions,
session,
workspaces: session.workspaces,
emptyProjects: session.emptyProjects,
};
}
@@ -405,48 +404,6 @@ describe("removeWorkspace", () => {
});
});
describe("removeEmptyProject", () => {
it("removes an empty project by project id", () => {
const store = useSessionStore.getState();
initializeTestSession();
store.setEmptyProjects("test-server", [
{
projectId: "project-empty",
projectDisplayName: "Empty",
projectCustomName: null,
projectRootPath: "/repo/empty",
projectKind: "git",
},
]);
store.removeEmptyProject("test-server", "project-empty");
expect(getTestSessionReferences().emptyProjects.has("project-empty")).toBe(false);
});
it("preserves identity when removing a missing empty project", () => {
const store = useSessionStore.getState();
initializeTestSession();
store.setEmptyProjects("test-server", [
{
projectId: "project-empty",
projectDisplayName: "Empty",
projectCustomName: null,
projectRootPath: "/repo/empty",
projectKind: "git",
},
]);
const before = getTestSessionReferences();
store.removeEmptyProject("test-server", "project-missing");
const after = getTestSessionReferences();
expect(after.sessions).toBe(before.sessions);
expect(after.session).toBe(before.session);
expect(after.emptyProjects).toBe(before.emptyProjects);
});
});
describe("patchWorkspaceScripts", () => {
it("preserves workspace entry identity when scripts are content-equal", () => {
const script = {
+36 -98
View File
@@ -182,19 +182,21 @@ export function normalizeWorkspaceDescriptor(
};
}
export interface EmptyProjectDescriptor {
export interface ProjectDescriptor {
projectId: string;
projectKey?: string | null;
projectDisplayName: string;
projectCustomName: string | null;
projectRootPath: string;
projectKind: WorkspaceDescriptorPayload["projectKind"];
}
export function normalizeEmptyProjectDescriptor(
export function normalizeProjectDescriptor(
payload: WorkspaceProjectDescriptorPayload,
): EmptyProjectDescriptor {
): ProjectDescriptor {
return {
projectId: payload.projectId,
projectKey: payload.projectKey ?? null,
projectDisplayName: payload.projectDisplayName,
projectCustomName: payload.projectCustomName ?? null,
projectRootPath: payload.projectRootPath,
@@ -237,28 +239,15 @@ function preserveWorkspaceMapIdentity(
return changed ? next : existing;
}
function emptyProjectDescriptorFromWorkspace(
workspace: WorkspaceDescriptor,
): EmptyProjectDescriptor {
return {
projectId: workspace.projectId,
projectDisplayName: workspace.projectDisplayName,
projectCustomName: workspace.projectCustomName ?? null,
projectRootPath: workspace.projectRootPath,
projectKind: workspace.projectKind,
};
}
function hasWorkspaceInProject(
workspaces: ReadonlyMap<string, WorkspaceDescriptor>,
projectId: string,
function projectMapsEqual(
left: ReadonlyMap<string, ProjectDescriptor>,
right: ReadonlyMap<string, ProjectDescriptor>,
): boolean {
for (const workspace of workspaces.values()) {
if (workspace.projectId === projectId) {
return true;
}
if (left.size !== right.size) return false;
for (const [projectId, project] of right) {
if (!equal(left.get(projectId), project)) return false;
}
return false;
return true;
}
export type ExplorerEntryKind = "file" | "directory";
@@ -332,7 +321,7 @@ export interface SessionReplicaTimeline {
export interface SessionReplica {
agents: Map<string, Agent>;
workspaces: Map<string, WorkspaceDescriptor>;
emptyProjects: Map<string, EmptyProjectDescriptor>;
projects: Map<string, ProjectDescriptor>;
timeline: SessionReplicaTimeline | null;
}
@@ -383,9 +372,8 @@ export interface SessionState {
workspaceAgentActivity: Map<string, WorkspaceAgentActivity>;
agentDetails: Map<string, Agent>;
workspaces: Map<string, WorkspaceDescriptor>;
// Project parents with no active workspaces, keyed by projectId. The
// `emptyProjects` name is the existing protocol/store projection.
emptyProjects: Map<string, EmptyProjectDescriptor>;
// All active project descriptors, keyed by host-local projectId.
projects: Map<string, ProjectDescriptor>;
// Transient restore state for archived workspaces, keyed by normalized
// workspaceId. Cleared in mergeWorkspaces when the descriptor lands.
restoringWorkspaces: Map<string, WorkspaceRestoreStatus>;
@@ -512,9 +500,9 @@ interface SessionStoreActions {
) => void;
mergeWorkspaces: (serverId: string, workspaces: Iterable<WorkspaceDescriptor>) => void;
removeWorkspace: (serverId: string, workspaceId: string) => void;
setEmptyProjects: (serverId: string, emptyProjects: Iterable<EmptyProjectDescriptor>) => void;
addEmptyProject: (serverId: string, emptyProject: EmptyProjectDescriptor) => void;
removeEmptyProject: (serverId: string, projectId: string) => void;
setProjects: (serverId: string, projects: Iterable<ProjectDescriptor>) => void;
upsertProject: (serverId: string, project: ProjectDescriptor) => void;
removeProject: (serverId: string, projectId: string) => void;
setWorkspaceRestoreStatus: (
serverId: string,
workspaceId: string,
@@ -599,7 +587,7 @@ function createInitialSessionState(
workspaceAgentActivity: new Map(),
agentDetails: new Map(),
workspaces: new Map(),
emptyProjects: new Map(),
projects: new Map(),
restoringWorkspaces: new Map(),
pendingPermissions: new Map(),
fileExplorer: new Map(),
@@ -728,7 +716,7 @@ export const useSessionStore = create<SessionStore>()(
agents: replica.agents,
workspaceAgentActivity: buildWorkspaceAgentActivityIndex(replica.agents),
workspaces: replica.workspaces,
emptyProjects: replica.emptyProjects,
projects: replica.projects,
agentStreamTail,
agentTimelineCursor,
agentTimelineHasOlder,
@@ -1363,65 +1351,41 @@ export const useSessionStore = create<SessionStore>()(
});
},
setEmptyProjects: (serverId, emptyProjects) => {
const next = new Map<string, EmptyProjectDescriptor>();
for (const project of emptyProjects) {
next.set(project.projectId, project);
}
setProjects: (serverId, projects) => {
const next = new Map<string, ProjectDescriptor>();
for (const project of projects) next.set(project.projectId, project);
set((prev) => {
const session = prev.sessions[serverId];
if (!session) {
return prev;
}
if (session.emptyProjects.size === 0 && next.size === 0) {
return prev;
}
if (!session || projectMapsEqual(session.projects, next)) return prev;
return {
...prev,
sessions: {
...prev.sessions,
[serverId]: { ...session, emptyProjects: next },
},
sessions: { ...prev.sessions, [serverId]: { ...session, projects: next } },
};
});
},
addEmptyProject: (serverId, emptyProject) => {
upsertProject: (serverId, project) => {
set((prev) => {
const session = prev.sessions[serverId];
if (!session) {
return prev;
}
const existing = session.emptyProjects.get(emptyProject.projectId);
if (existing && equal(existing, emptyProject)) {
return prev;
}
const next = new Map(session.emptyProjects);
next.set(emptyProject.projectId, emptyProject);
if (!session || equal(session.projects.get(project.projectId), project)) return prev;
const projects = new Map(session.projects);
projects.set(project.projectId, project);
return {
...prev,
sessions: {
...prev.sessions,
[serverId]: { ...session, emptyProjects: next },
},
sessions: { ...prev.sessions, [serverId]: { ...session, projects } },
};
});
},
removeEmptyProject: (serverId, projectId) => {
removeProject: (serverId, projectId) => {
set((prev) => {
const session = prev.sessions[serverId];
if (!session?.emptyProjects.has(projectId)) {
return prev;
}
const next = new Map(session.emptyProjects);
next.delete(projectId);
if (!session?.projects.has(projectId)) return prev;
const projects = new Map(session.projects);
projects.delete(projectId);
return {
...prev,
sessions: {
...prev.sessions,
[serverId]: { ...session, emptyProjects: next },
},
sessions: { ...prev.sessions, [serverId]: { ...session, projects } },
};
});
},
@@ -1483,17 +1447,10 @@ export const useSessionStore = create<SessionStore>()(
}
const next = new Map(session.workspaces);
let changed = false;
// A workspace landing in a project means that project is no longer
// empty: prune any stale empty descriptor so it stops governing the
// project's rendered metadata.
const nextEmptyProjects = new Map(session.emptyProjects);
// A descriptor arriving is the success signal for a pending restore:
// clear it at the source so every entry point converges to "ready".
let nextRestoring: Map<string, WorkspaceRestoreStatus> | null = null;
for (const workspace of nextEntries) {
if (nextEmptyProjects.delete(workspace.projectId)) {
changed = true;
}
if (session.restoringWorkspaces.has(workspace.id)) {
nextRestoring ??= new Map(session.restoringWorkspaces);
nextRestoring.delete(workspace.id);
@@ -1517,7 +1474,6 @@ export const useSessionStore = create<SessionStore>()(
[serverId]: {
...session,
workspaces: next,
emptyProjects: nextEmptyProjects,
restoringWorkspaces: nextRestoring ?? session.restoringWorkspaces,
},
},
@@ -1535,31 +1491,13 @@ export const useSessionStore = create<SessionStore>()(
if (!session || !workspaceKey) {
return prev;
}
const removedWorkspace = session.workspaces.get(workspaceKey);
if (!removedWorkspace) {
return prev;
}
const next = new Map(session.workspaces);
next.delete(workspaceKey);
let nextEmptyProjects = session.emptyProjects;
if (hasWorkspaceInProject(next, removedWorkspace.projectId)) {
if (nextEmptyProjects.has(removedWorkspace.projectId)) {
nextEmptyProjects = new Map(nextEmptyProjects);
nextEmptyProjects.delete(removedWorkspace.projectId);
}
} else {
const emptyProject = emptyProjectDescriptorFromWorkspace(removedWorkspace);
const existing = nextEmptyProjects.get(emptyProject.projectId);
if (!existing || !equal(existing, emptyProject)) {
nextEmptyProjects = new Map(nextEmptyProjects);
nextEmptyProjects.set(emptyProject.projectId, emptyProject);
}
}
return {
...prev,
sessions: {
...prev.sessions,
[serverId]: { ...session, workspaces: next, emptyProjects: nextEmptyProjects },
[serverId]: { ...session, workspaces: next },
},
};
});
+41
View File
@@ -0,0 +1,41 @@
import {
useSessionStore,
type ProjectDescriptor,
type WorkspaceDescriptor,
} from "@/stores/session-store";
function projectFromWorkspace(workspace: WorkspaceDescriptor): ProjectDescriptor {
return {
projectId: workspace.projectId,
// A daemon always derives a key for every project, so fixtures carry one too.
// Same key on two hosts means the same project, which is what grouping asserts.
projectKey: workspace.projectId,
projectDisplayName: workspace.projectDisplayName,
projectCustomName: workspace.projectCustomName ?? null,
projectRootPath: workspace.projectRootPath,
projectKind: workspace.projectKind,
};
}
/**
* Seeds a session the way a daemon populates it: every workspace's project is
* published on the project channel, so the store never holds a workspace whose
* project is unknown. Tests that seed workspaces alone model a state the daemon
* cannot produce, and nothing renders.
*/
export function seedSessionWorkspaces(
serverId: string,
workspaces: Map<string, WorkspaceDescriptor>,
projects?: Iterable<ProjectDescriptor>,
): void {
const byProjectId = new Map<string, ProjectDescriptor>();
for (const project of projects ?? []) byProjectId.set(project.projectId, project);
for (const workspace of workspaces.values()) {
if (!byProjectId.has(workspace.projectId)) {
byProjectId.set(workspace.projectId, projectFromWorkspace(workspace));
}
}
const store = useSessionStore.getState();
store.setProjects(serverId, byProjectId.values());
store.setWorkspaces(serverId, workspaces);
}
+6 -14
View File
@@ -17,6 +17,7 @@ import {
encodeWorkspaceIdForPathSegment,
isSettingsSectionSlug,
normalizeHostSectionSlug,
normalizeProjectSettingsRouteId,
parseHostAgentRouteFromPathname,
parseHostWorkspaceOpenIntentFromPathname,
parseHostWorkspaceRouteFromPathname,
@@ -196,23 +197,14 @@ describe("projects settings routes", () => {
expect(buildProjectsSettingsRoute()).toBe("/settings/projects");
});
it("buildProjectSettingsRoute encodes a remote project key as a single segment", () => {
expect(buildProjectSettingsRoute("remote:github.com/acme/app")).toBe(
"/settings/projects/remote%3Agithub.com%2Facme%2Fapp",
it("buildProjectSettingsRoute addresses a host-local project id", () => {
expect(buildProjectSettingsRoute("host a", "project/1")).toBe(
"/settings/projects/host%20a/project%2F1",
);
});
it("buildProjectSettingsRoute encodes a local repo-root key", () => {
expect(buildProjectSettingsRoute("/Users/me/dev/paseo")).toBe(
"/settings/projects/%2FUsers%2Fme%2Fdev%2Fpaseo",
);
});
it("project keys round-trip through decodeURIComponent", () => {
const projectKey = "remote:github.com/acme/app";
const route = buildProjectSettingsRoute(projectKey);
const segment = route.slice("/settings/projects/".length);
expect(decodeURIComponent(segment)).toBe(projectKey);
it("keeps route ids opaque", () => {
expect(normalizeProjectSettingsRouteId("project%2F1")).toBe("project%2F1");
});
});
+9 -5
View File
@@ -566,10 +566,14 @@ export function buildProjectsSettingsRoute() {
return "/settings/projects" as const;
}
export function buildProjectSettingsRoute(projectKey: string) {
const normalized = trimNonEmpty(projectKey);
if (!normalized) {
throw new Error("buildProjectSettingsRoute requires a non-empty projectKey");
export function buildProjectSettingsRoute(serverId: string, projectId: string) {
if (!serverId.trim() || !projectId.trim()) {
throw new Error("buildProjectSettingsRoute requires a serverId and projectId");
}
return `/settings/projects/${encodeSegment(normalized)}` as const;
return `/settings/projects/${encodeSegment(serverId)}/${encodeSegment(projectId)}` as const;
}
export function normalizeProjectSettingsRouteId(value: string | string[] | undefined): string {
const id = Array.isArray(value) ? value[0] : value;
return typeof id === "string" ? id : "";
}
+66 -554
View File
@@ -1,595 +1,107 @@
import { describe, expect, it } from "vitest";
import type { ProjectPlacementPayload } from "@getpaseo/protocol/messages";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { buildProjects } from "./projects";
import { describe, expect, test } from "vitest";
import type { ProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store";
import { buildProjects, getProjectSummaryForHostProject } from "./projects";
function placement(input: {
projectKey: string;
projectName: string;
cwd: string;
remoteUrl: string | null;
mainRepoRoot?: string | null;
}): ProjectPlacementPayload {
function descriptor(id: string, key: string, root: string): ProjectDescriptor {
return {
projectKey: input.projectKey,
projectName: input.projectName,
checkout: {
cwd: input.cwd,
isGit: true,
currentBranch: "main",
remoteUrl: input.remoteUrl,
worktreeRoot: input.cwd,
isPaseoOwnedWorktree: false,
mainRepoRoot: input.mainRepoRoot ?? null,
},
projectId: id,
projectKey: key,
projectDisplayName: "acme/app",
projectCustomName: null,
projectRootPath: root,
projectKind: "git",
};
}
function workspace(input: {
id: string;
repoRoot: string;
project?: ProjectPlacementPayload;
projectId?: string;
projectName?: string;
remoteUrl?: string | null;
currentBranch?: string;
archivingAt?: string | null;
}): WorkspaceDescriptor {
function workspace(id: string, projectId: string, root: string): WorkspaceDescriptor {
return {
id: input.id,
projectId: input.projectId ?? input.project?.projectKey ?? input.repoRoot,
projectDisplayName: input.projectName ?? input.project?.projectName ?? "Project",
projectRootPath: input.repoRoot,
workspaceDirectory: input.repoRoot,
id,
projectId,
projectDisplayName: "acme/app",
projectCustomName: null,
projectRootPath: root,
workspaceDirectory: root,
projectKind: "git",
workspaceKind: "local_checkout",
name: input.id,
name: "main",
status: "done",
archivingAt: input.archivingAt ?? null,
statusEnteredAt: null,
archivingAt: null,
diffStat: null,
scripts: [],
gitRuntime: {
currentBranch: input.currentBranch ?? "main",
remoteUrl: input.remoteUrl ?? input.project?.checkout.remoteUrl ?? null,
isPaseoOwnedWorktree: false,
isDirty: false,
aheadBehind: null,
aheadOfOrigin: null,
behindOfOrigin: null,
},
githubRuntime: null,
project: input.project,
};
}
describe("buildProjects", () => {
it("normalizes detached and blank branches out of workspace summaries", () => {
test("uses the grouped project list while retaining each host project id", () => {
const key = "remote:github.com/acme/app";
const result = buildProjects({
hosts: [
{
serverId: "local",
serverName: "Local",
serverId: "host-a",
serverName: "Host A",
isOnline: true,
workspaces: [
workspace({ id: "detached", repoRoot: "/repo/app", currentBranch: "HEAD" }),
workspace({ id: "blank", repoRoot: "/repo/app", currentBranch: " " }),
],
},
],
});
expect(result.projects[0]?.hosts[0]?.workspaces).toMatchObject([
{ id: "detached", currentBranch: null },
{ id: "blank", currentBranch: null },
]);
});
it("carries active archive state into workspace summaries", () => {
const archivingAt = "2026-07-15T18:00:00.000Z";
const result = buildProjects({
hosts: [
{
serverId: "local",
serverName: "Local",
isOnline: true,
workspaces: [
workspace({ id: "archiving", repoRoot: "/repo/app", archivingAt }),
workspace({ id: "active", repoRoot: "/repo/app" }),
],
},
],
});
expect(result.projects[0]?.hosts[0]?.workspaces).toMatchObject([
{ id: "archiving", archivingAt },
{ id: "active" },
]);
expect(result.projects[0]?.hosts[0]?.workspaces[1]).not.toHaveProperty("archivingAt");
});
it("groups two daemons with the same GitHub project key into one project with one host entry per daemon", () => {
const result = buildProjects({
hosts: [
{
serverId: "local",
serverName: "Local",
isOnline: true,
workspaces: [
workspace({
id: "main",
repoRoot: "/repo/app",
project: placement({
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
cwd: "/repo/app",
remoteUrl: "https://github.com/acme/app.git",
}),
}),
workspace({
id: "feature-a",
repoRoot: "/repo/app",
project: placement({
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
cwd: "/repo/app/feature-a",
remoteUrl: "https://github.com/acme/app.git",
}),
}),
workspace({
id: "feature-b",
repoRoot: "/repo/app",
project: placement({
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
cwd: "/repo/app/feature-b",
remoteUrl: "https://github.com/acme/app.git",
}),
}),
],
projects: [descriptor("prj_a", key, "/a/app")],
workspaces: [workspace("ws-a", "prj_a", "/a/app")],
},
{
serverId: "laptop",
serverName: "Laptop",
isOnline: true,
workspaces: [
workspace({
id: "main",
repoRoot: "/work/app",
project: placement({
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
cwd: "/work/app",
remoteUrl: "git@github.com:acme/app.git",
}),
}),
workspace({
id: "feature",
repoRoot: "/work/app",
project: placement({
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
cwd: "/work/app/feature",
remoteUrl: "git@github.com:acme/app.git",
}),
}),
],
},
],
});
expect(result.projects).toHaveLength(1);
const summary = result.projects[0];
expect(summary?.projectKey).toBe("remote:github.com/acme/app");
expect(summary?.projectName).toBe("acme/app");
expect(summary?.hostCount).toBe(2);
expect(summary?.onlineHostCount).toBe(2);
expect(summary?.totalWorkspaceCount).toBe(5);
expect(summary?.githubUrl).toBe("https://github.com/acme/app");
expect(summary?.hosts).toHaveLength(2);
const local = summary?.hosts.find((host) => host.serverId === "local");
const laptop = summary?.hosts.find((host) => host.serverId === "laptop");
expect(local?.workspaceCount).toBe(3);
expect(laptop?.workspaceCount).toBe(2);
expect(local?.workspaces.map((entry) => entry.id)).toEqual(["main", "feature-a", "feature-b"]);
expect(laptop?.workspaces.map((entry) => entry.id)).toEqual(["main", "feature"]);
});
it("collapses five workspaces on one host into a single host entry whose workspaceCount is five", () => {
const result = buildProjects({
hosts: [
{
serverId: "local",
serverName: "Local",
isOnline: true,
workspaces: Array.from({ length: 5 }, (_, index) =>
workspace({
id: `ws-${index}`,
repoRoot: "/repo/app",
project: placement({
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
cwd: `/repo/app/ws-${index}`,
remoteUrl: "https://github.com/acme/app.git",
}),
}),
),
},
],
});
expect(result.projects).toHaveLength(1);
expect(result.projects[0]?.hosts).toHaveLength(1);
expect(result.projects[0]?.hosts[0]?.workspaceCount).toBe(5);
expect(result.projects[0]?.totalWorkspaceCount).toBe(5);
expect(result.projects[0]?.hostCount).toBe(1);
});
it("prefers placement mainRepoRoot for the host repoRoot and falls back to projectRootPath when placement is absent", () => {
const result = buildProjects({
hosts: [
{
serverId: "local",
serverName: "Local",
isOnline: true,
workspaces: [
workspace({
id: "main",
repoRoot: "/worktrees/app/main",
project: placement({
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
cwd: "/worktrees/app/main",
remoteUrl: "https://github.com/acme/app.git",
mainRepoRoot: "/repo/app",
}),
}),
],
},
{
serverId: "legacy",
serverName: "Legacy",
isOnline: true,
workspaces: [
workspace({
id: "legacy",
repoRoot: "/repo/legacy",
projectId: "legacy-project",
projectName: "Legacy",
}),
],
},
],
});
const acme = result.projects.find(
(project) => project.projectKey === "remote:github.com/acme/app",
);
const legacy = result.projects.find((project) => project.projectKey === "legacy-project");
expect(acme?.hosts[0]?.repoRoot).toBe("/repo/app");
expect(legacy?.hosts[0]?.repoRoot).toBe("/repo/legacy");
});
it("derives githubUrl only when projectKey matches remote:github.com/{owner}/{repo}", () => {
const result = buildProjects({
hosts: [
{
serverId: "local",
serverName: "Local",
isOnline: true,
workspaces: [
workspace({
id: "github",
repoRoot: "/repo/app",
project: placement({
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
cwd: "/repo/app",
remoteUrl: "https://github.com/acme/app.git",
}),
}),
workspace({
id: "local",
repoRoot: "/repo/local",
project: placement({
projectKey: "/repo/local",
projectName: "local",
cwd: "/repo/local",
remoteUrl: null,
}),
}),
],
},
],
});
const github = result.projects.find(
(project) => project.projectKey === "remote:github.com/acme/app",
);
const local = result.projects.find((project) => project.projectKey === "/repo/local");
expect(github?.githubUrl).toBe("https://github.com/acme/app");
expect(local?.githubUrl).toBeUndefined();
});
it("totals hostCount across all hosts and counts only online ones in onlineHostCount", () => {
const result = buildProjects({
hosts: [
{
serverId: "online",
serverName: "Online",
isOnline: true,
workspaces: [
workspace({
id: "ws",
repoRoot: "/repo/app",
project: placement({
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
cwd: "/repo/app",
remoteUrl: "https://github.com/acme/app.git",
}),
}),
],
},
{
serverId: "offline",
serverName: "Offline",
serverId: "host-b",
serverName: "Host B",
isOnline: false,
workspaces: [
workspace({
id: "ws",
repoRoot: "/repo/app",
project: placement({
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
cwd: "/repo/app",
remoteUrl: "https://github.com/acme/app.git",
}),
}),
],
projects: [descriptor("prj_b", key, "/b/app")],
workspaces: [workspace("ws-b", "prj_b", "/b/app")],
},
],
});
expect(result.projects).toHaveLength(1);
expect(result.projects[0]?.hostCount).toBe(2);
expect(result.projects[0]?.onlineHostCount).toBe(1);
expect(result.projects[0]?.hosts.find((host) => host.serverId === "online")?.isOnline).toBe(
true,
);
expect(result.projects[0]?.hosts.find((host) => host.serverId === "offline")?.isOnline).toBe(
false,
);
});
it("does not merge fallback repo-root-keyed projects with different roots", () => {
const result = buildProjects({
hosts: [
{
serverId: "local",
serverName: "Local",
isOnline: true,
workspaces: [
workspace({
id: "one",
repoRoot: "/repo/one",
project: placement({
projectKey: "/repo/one",
projectName: "one",
cwd: "/repo/one",
remoteUrl: null,
}),
}),
workspace({
id: "two",
repoRoot: "/repo/two",
project: placement({
projectKey: "/repo/two",
projectName: "two",
cwd: "/repo/two",
remoteUrl: null,
}),
}),
],
},
],
});
expect(result.projects.map((project) => project.projectKey)).toEqual([
"/repo/one",
"/repo/two",
expect(result.projects).toEqual([
expect.objectContaining({
projectKey: key,
hostCount: 2,
onlineHostCount: 1,
totalWorkspaceCount: 2,
hosts: [
expect.objectContaining({ serverId: "host-a", projectId: "prj_a" }),
expect.objectContaining({ serverId: "host-b", projectId: "prj_b" }),
],
}),
]);
});
it("includes GitHub, GitLab, Bitbucket and local projects together, sorted by name", () => {
test("includes a project with no workspaces", () => {
const result = buildProjects({
hosts: [
{
serverId: "local",
serverName: "Local",
isOnline: true,
workspaces: [
workspace({
id: "github",
repoRoot: "/repo/github",
project: placement({
projectKey: "remote:github.com/acme/web",
projectName: "acme/web",
cwd: "/repo/github",
remoteUrl: "https://github.com/acme/web.git",
}),
}),
workspace({
id: "gitlab",
repoRoot: "/repo/gitlab",
project: placement({
projectKey: "remote:gitlab.com/acme/api",
projectName: "acme/api",
cwd: "/repo/gitlab",
remoteUrl: "https://gitlab.com/acme/api.git",
}),
}),
workspace({
id: "bitbucket",
repoRoot: "/repo/bitbucket",
project: placement({
projectKey: "remote:bitbucket.org/acme/cli",
projectName: "acme/cli",
cwd: "/repo/bitbucket",
remoteUrl: "https://bitbucket.org/acme/cli.git",
}),
}),
workspace({
id: "local",
repoRoot: "/repo/local",
project: placement({
projectKey: "/repo/local",
projectName: "local",
cwd: "/repo/local",
remoteUrl: null,
}),
}),
],
},
],
});
expect(result.projects.map((project) => project.projectKey)).toEqual([
"remote:gitlab.com/acme/api",
"remote:bitbucket.org/acme/cli",
"remote:github.com/acme/web",
"/repo/local",
]);
});
it("renders a non-GitHub remote project on its own when no other projects are present", () => {
const result = buildProjects({
hosts: [
{
serverId: "local",
serverName: "Local",
isOnline: true,
workspaces: [
workspace({
id: "gitlab",
repoRoot: "/repo/gitlab",
project: placement({
projectKey: "remote:gitlab.com/acme/app",
projectName: "acme/app",
cwd: "/repo/gitlab",
remoteUrl: "https://gitlab.com/acme/app.git",
}),
}),
],
},
],
});
expect(result.projects).toHaveLength(1);
expect(result.projects[0]?.projectKey).toBe("remote:gitlab.com/acme/app");
expect(result.projects[0]?.projectName).toBe("acme/app");
expect(result.projects[0]?.githubUrl).toBeUndefined();
});
it("groups two workspaces sharing a non-GitHub remote into one project with workspaceCount two", () => {
const result = buildProjects({
hosts: [
{
serverId: "local",
serverName: "Local",
isOnline: true,
workspaces: [
workspace({
id: "main",
repoRoot: "/repo/gitlab",
project: placement({
projectKey: "remote:gitlab.com/acme/app",
projectName: "acme/app",
cwd: "/repo/gitlab",
remoteUrl: "https://gitlab.com/acme/app.git",
}),
}),
workspace({
id: "feature",
repoRoot: "/repo/gitlab",
project: placement({
projectKey: "remote:gitlab.com/acme/app",
projectName: "acme/app",
cwd: "/repo/gitlab/feature",
remoteUrl: "https://gitlab.com/acme/app.git",
}),
}),
],
},
],
});
expect(result.projects).toHaveLength(1);
expect(result.projects[0]?.hosts).toHaveLength(1);
expect(result.projects[0]?.hosts[0]?.workspaceCount).toBe(2);
});
it("falls back conservatively for mixed-version daemons whose descriptors lack project", () => {
const result = buildProjects({
hosts: [
{
serverId: "old-daemon",
serverName: "Old daemon",
isOnline: true,
workspaces: [
workspace({
id: "legacy",
repoRoot: "/repo/legacy",
projectId: "legacy-project",
projectName: "Legacy",
remoteUrl: "https://gitlab.com/acme/legacy.git",
}),
],
},
],
});
expect(result.projects).toHaveLength(1);
const summary = result.projects[0];
expect(summary?.projectKey).toBe("legacy-project");
expect(summary?.projectName).toBe("Legacy");
expect(summary?.githubUrl).toBeUndefined();
expect(summary?.hosts).toHaveLength(1);
expect(summary?.hosts[0]?.repoRoot).toBe("/repo/legacy");
expect(summary?.hosts[0]?.workspaceCount).toBe(1);
});
it("surfaces a project with no workspaces yet and gives its host an editable repoRoot", () => {
const result = buildProjects({
hosts: [
{
serverId: "local",
serverName: "Local",
serverId: "host-a",
serverName: "Host A",
isOnline: true,
projects: [descriptor("prj_a", "local-a", "/a/app")],
workspaces: [],
emptyProjects: [
{
projectId: "/repo/fresh",
projectDisplayName: "fresh",
projectCustomName: null,
projectRootPath: "/repo/fresh",
projectKind: "git",
},
],
},
],
});
expect(result.projects).toHaveLength(1);
const summary = result.projects[0];
expect(summary?.projectKey).toBe("/repo/fresh");
expect(summary?.totalWorkspaceCount).toBe(0);
expect(summary?.hosts).toHaveLength(1);
// repoRoot must be non-empty or the project settings screen treats the host
// as non-editable and shows its empty state.
expect(summary?.hosts[0]?.repoRoot).toBe("/repo/fresh");
expect(summary?.hosts[0]?.workspaceCount).toBe(0);
expect(result.projects[0]).toMatchObject({
totalWorkspaceCount: 0,
hosts: [{ projectId: "prj_a", repoRoot: "/a/app" }],
});
});
test("looks up a grouped project by host-local identity", () => {
const project = buildProjects({
hosts: [
{
serverId: "host-a",
serverName: "Host A",
isOnline: true,
projects: [descriptor("prj_a", "shared", "/a/app")],
workspaces: [],
},
],
}).projects[0];
expect(getProjectSummaryForHostProject(project ? [project] : [], "host-a", "prj_a")).toBe(
project,
);
});
});
+117 -77
View File
@@ -1,5 +1,5 @@
import type { EmptyProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store";
import { buildHostProjectList, type HostProjectListItem } from "@/projects/host-project-model";
import type { ProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store";
import type { HostProjectListItem } from "@/projects/host-project-model";
import { buildWorkspaceStructureProjects } from "@/projects/workspace-structure";
export interface WorkspaceSummary {
@@ -14,6 +14,9 @@ export interface WorkspaceSummary {
export interface ProjectHostEntry {
serverId: string;
projectId: string;
projectName: string;
projectCustomName: string | null;
serverName: string;
isOnline: boolean;
repoRoot: string;
@@ -39,8 +42,7 @@ export interface ProjectHost {
serverName: string;
isOnline: boolean;
workspaces: WorkspaceDescriptor[];
/** Project parents with no active workspaces, so they still surface as projects. */
emptyProjects?: EmptyProjectDescriptor[];
projects: ProjectDescriptor[];
}
export interface BuildProjectsInput {
@@ -51,10 +53,35 @@ export interface BuildProjectsResult {
projects: ProjectSummary[];
}
const GITHUB_PROJECT_KEY_PATTERN = /^remote:github\.com\/([^/]+)\/([^/]+)$/;
export function getProjectSummaryForHostProject(
projects: readonly ProjectSummary[],
serverId: string,
projectId: string,
): ProjectSummary | undefined {
for (const project of projects) {
for (const host of project.hosts) {
if (host.serverId === serverId && host.projectId === projectId) return project;
}
}
return undefined;
}
export function getProjectHostEntry(
project: ProjectSummary | undefined,
serverId: string,
projectId?: string,
): ProjectHostEntry | undefined {
for (const host of project?.hosts ?? []) {
if (host.serverId === serverId && (!projectId || host.projectId === projectId)) return host;
}
return undefined;
}
interface HostGroup {
serverId: string;
projectId: string;
projectName: string;
projectCustomName: string | null;
serverName: string;
isOnline: boolean;
workspaces: WorkspaceDescriptor[];
@@ -70,50 +97,32 @@ interface ProjectGroup {
hostsByServerId: Map<string, HostGroup>;
}
function findProjectCustomName(
workspaces: WorkspaceDescriptor[],
function findProjectMetadata(
host: ProjectHost,
projectKey: string,
): { customName: string; displayName: string } | null {
for (const workspace of workspaces) {
if (workspace.projectId === projectKey && workspace.projectCustomName) {
): { customName: string | null; displayName: string } | null {
for (const project of host.projects) {
if (project.projectId === projectKey) {
return {
customName: workspace.projectCustomName,
displayName: workspace.projectDisplayName,
customName: project.projectCustomName ?? null,
displayName: project.projectDisplayName,
};
}
}
return null;
}
function buildHostProjectEntries(host: ProjectHost): HostProjectListItem[] {
return buildHostProjectList({
projects: buildWorkspaceStructureProjects({
sessions: [
{
serverId: host.serverId,
workspaces: host.workspaces,
emptyProjects: host.emptyProjects,
},
],
}),
function buildHostProjectEntries(hosts: ProjectHost[]): HostProjectListItem[] {
return buildWorkspaceStructureProjects({
sessions: hosts.map((host) => ({
serverId: host.serverId,
projects: host.projects,
workspaces: host.workspaces,
})),
});
}
function deriveGithubUrl(projectKey: string): string | undefined {
const match = projectKey.match(GITHUB_PROJECT_KEY_PATTERN);
if (!match) {
return undefined;
}
return `https://github.com/${match[1]}/${match[2]}`;
}
function resolveHostRepoRoot(group: HostGroup): string {
for (const workspace of group.workspaces) {
const mainRepoRoot = workspace.project?.checkout.mainRepoRoot;
if (mainRepoRoot) {
return mainRepoRoot;
}
}
return group.workspaces[0]?.projectRootPath ?? group.fallbackRepoRoot;
}
@@ -137,6 +146,9 @@ function toHostEntry(group: HostGroup): ProjectHostEntry {
group.workspaces[0];
return {
serverId: group.serverId,
projectId: group.projectId,
projectName: group.projectName,
projectCustomName: group.projectCustomName,
serverName: group.serverName,
isOnline: group.isOnline,
repoRoot,
@@ -167,53 +179,81 @@ function toProjectSummary(draft: ProjectGroup): ProjectSummary {
totalWorkspaceCount,
hostCount: hosts.length,
onlineHostCount,
githubUrl: deriveGithubUrl(draft.projectKey),
};
}
function addHostProjects(
groups: Map<string, ProjectGroup>,
host: ProjectHost,
hostProjects: HostProjectListItem[],
): void {
const repoRootByProjectId = new Map(
host.projects.map((project) => [project.projectId, project.projectRootPath]),
);
for (const hostProject of hostProjects) {
const placement = hostProject.hosts.find((entry) => entry.serverId === host.serverId);
if (!placement) continue;
const projectId = placement.projectId;
const customName = findProjectMetadata(host, projectId);
let group = groups.get(hostProject.projectKey);
if (!group) {
group = {
projectKey: hostProject.projectKey,
projectName: customName?.displayName ?? hostProject.projectName,
projectCustomName: customName?.customName ?? null,
hostsByServerId: new Map(),
};
groups.set(hostProject.projectKey, group);
} else if (customName?.customName && !group.projectCustomName) {
group.projectCustomName = customName.customName;
group.projectName = customName.displayName;
}
if (!group.hostsByServerId.has(host.serverId)) {
group.hostsByServerId.set(host.serverId, {
serverId: host.serverId,
projectId,
projectName: customName?.displayName ?? hostProject.projectName,
projectCustomName: customName?.customName ?? null,
serverName: host.serverName,
isOnline: host.isOnline,
workspaces: [],
fallbackRepoRoot: repoRootByProjectId.get(projectId) ?? "",
});
}
}
}
function attachHostWorkspaces(
groups: Map<string, ProjectGroup>,
host: ProjectHost,
hostProjects: HostProjectListItem[],
): void {
const projectKeyByProjectId = new Map(
hostProjects.flatMap((project) =>
project.hosts
.filter((placement) => placement.serverId === host.serverId && placement.projectId)
.map((placement) => [placement.projectId!, project.projectKey] as const),
),
);
for (const workspace of host.workspaces) {
const key = projectKeyByProjectId.get(workspace.projectId);
if (key) groups.get(key)?.hostsByServerId.get(host.serverId)?.workspaces.push(workspace);
}
}
export function buildProjects(input: BuildProjectsInput): BuildProjectsResult {
const groups = new Map<string, ProjectGroup>();
const projectEntries = buildHostProjectEntries(input.hosts);
for (const host of input.hosts) {
const emptyRepoRootByProjectKey = new Map<string, string>();
for (const emptyProject of host.emptyProjects ?? []) {
emptyRepoRootByProjectKey.set(emptyProject.projectId, emptyProject.projectRootPath);
}
const hostProjects = buildHostProjectEntries(host);
for (const hostProject of hostProjects) {
const customName = findProjectCustomName(host.workspaces, hostProject.projectKey);
let group = groups.get(hostProject.projectKey);
if (!group) {
group = {
projectKey: hostProject.projectKey,
projectName: customName?.displayName ?? hostProject.projectName,
projectCustomName: customName?.customName ?? null,
hostsByServerId: new Map(),
};
groups.set(hostProject.projectKey, group);
} else if (customName && !group.projectCustomName) {
group.projectCustomName = customName.customName;
group.projectName = customName.displayName;
}
if (!group.hostsByServerId.has(host.serverId)) {
group.hostsByServerId.set(host.serverId, {
serverId: host.serverId,
serverName: host.serverName,
isOnline: host.isOnline,
workspaces: [],
fallbackRepoRoot: emptyRepoRootByProjectKey.get(hostProject.projectKey) ?? "",
});
}
}
for (const workspace of host.workspaces) {
const group = groups.get(workspace.projectId);
const hostGroup = group?.hostsByServerId.get(host.serverId);
if (!hostGroup) continue;
hostGroup.workspaces.push(workspace);
}
const hostProjects = projectEntries.filter((project) =>
project.hosts.some((placement) => placement.serverId === host.serverId),
);
addHostProjects(groups, host, hostProjects);
attachHostWorkspaces(groups, host, hostProjects);
}
const projects = Array.from(groups.values()).map(toProjectSummary);
@@ -6,6 +6,7 @@ import type {
import {
buildSidebarProjectRowModel,
resolveSidebarProjectIconTarget,
resolveSidebarProjectLocalPath,
} from "./sidebar-project-row-model";
function workspace(overrides: Partial<SidebarWorkspaceEntry> = {}): SidebarWorkspaceEntry {
@@ -34,18 +35,26 @@ function workspace(overrides: Partial<SidebarWorkspaceEntry> = {}): SidebarWorks
};
}
function project(overrides: Partial<SidebarProjectEntry> = {}): SidebarProjectEntry {
type ProjectOverrides = Omit<Partial<SidebarProjectEntry>, "hosts"> & {
hosts?: Array<Omit<SidebarProjectEntry["hosts"][number], "projectId"> & { projectId?: string }>;
};
function project(overrides: ProjectOverrides = {}): SidebarProjectEntry {
const projectKind = overrides.projectKind ?? "git";
const hosts = Array.from(
overrides.hosts ?? [
{ serverId: "srv", iconWorkingDir: "/repo", canCreateWorktree: projectKind === "git" },
],
(host) => Object.assign({}, host, { projectId: host.projectId ?? `project-${host.serverId}` }),
);
return {
projectKey: "project-1",
projectName: "paseo",
projectKind,
iconWorkingDir: "/repo",
hosts: overrides.hosts ?? [
{ serverId: "srv", iconWorkingDir: "/repo", canCreateWorktree: projectKind === "git" },
],
workspaces: [workspace()],
...overrides,
hosts,
};
}
@@ -80,7 +89,7 @@ describe("buildSidebarProjectRowModel", () => {
chevron: "expand",
trailingAction: {
kind: "new_workspace",
target: { serverId: "srv", iconWorkingDir: "/repo" },
target: { serverId: "srv", projectId: "project-srv", iconWorkingDir: "/repo" },
},
});
});
@@ -94,7 +103,7 @@ describe("buildSidebarProjectRowModel", () => {
expect(result.trailingAction).toEqual({
kind: "new_workspace",
target: { serverId: "srv", iconWorkingDir: "/repo" },
target: { serverId: "srv", projectId: "project-srv", iconWorkingDir: "/repo" },
});
});
@@ -117,7 +126,7 @@ describe("buildSidebarProjectRowModel", () => {
expect(result.trailingAction).toEqual({
kind: "new_workspace",
target: { serverId: "srv", iconWorkingDir: "/repo" },
target: { serverId: "srv", projectId: "project-srv", iconWorkingDir: "/repo" },
});
});
@@ -178,7 +187,7 @@ describe("buildSidebarProjectRowModel", () => {
chevron: "expand",
trailingAction: {
kind: "new_workspace",
target: { serverId: "srv", iconWorkingDir: "/repo" },
target: { serverId: "srv", projectId: "project-srv", iconWorkingDir: "/repo" },
},
});
});
@@ -193,7 +202,24 @@ describe("buildSidebarProjectRowModel", () => {
}),
);
expect(iconTarget).toEqual({ serverId: "host-b", iconWorkingDir: "/repo/b" });
expect(iconTarget).toEqual({
serverId: "host-b",
projectId: "project-host-b",
iconWorkingDir: "/repo/b",
});
});
it("resolves desktop file actions from the local project placement", () => {
const groupedProject = project({
iconWorkingDir: "/remote/repo",
hosts: [
{ serverId: "remote", iconWorkingDir: "/remote/repo", canCreateWorktree: true },
{ serverId: "local", iconWorkingDir: "/local/repo", canCreateWorktree: true },
],
});
expect(resolveSidebarProjectLocalPath(groupedProject, "local")).toBe("/local/repo");
expect(resolveSidebarProjectLocalPath(groupedProject, "missing")).toBe("");
});
it("renders an empty project as an expandable section", () => {
@@ -207,7 +233,7 @@ describe("buildSidebarProjectRowModel", () => {
chevron: "collapse",
trailingAction: {
kind: "new_workspace",
target: { serverId: "srv", iconWorkingDir: "/repo" },
target: { serverId: "srv", projectId: "project-srv", iconWorkingDir: "/repo" },
},
});
});
@@ -2,6 +2,7 @@ import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list";
export interface SidebarProjectHostTarget {
serverId: string;
projectId: string;
iconWorkingDir: string;
}
@@ -18,16 +19,16 @@ export interface SidebarProjectSectionRowModel {
export type SidebarProjectRowModel = SidebarProjectSectionRowModel;
const EMPTY_MULTIPLICITY_MAP: ReadonlyMap<string, boolean> = new Map();
function hostTarget(input: {
serverId: string;
projectId: string;
iconWorkingDir: string;
}): SidebarProjectHostTarget | null {
const iconWorkingDir = input.iconWorkingDir.trim();
if (!input.serverId || !iconWorkingDir) {
return null;
}
return { serverId: input.serverId, iconWorkingDir };
return { serverId: input.serverId, projectId: input.projectId, iconWorkingDir };
}
export function resolveSidebarProjectIconTarget(
@@ -42,6 +43,14 @@ export function resolveSidebarProjectIconTarget(
return null;
}
export function resolveSidebarProjectLocalPath(
project: SidebarProjectEntry,
localServerId: string | null,
): string {
if (!localServerId) return "";
return project.hosts.find((host) => host.serverId === localServerId)?.iconWorkingDir.trim() ?? "";
}
// A project can host a brand-new workspace on a host when that host can create a
// git worktree (git projects) OR the host supports running multiple independent
// workspaces per directory (`workspaceMultiplicity`), which is what lets non-git
@@ -57,9 +66,7 @@ function resolveNewWorkspaceTarget(
continue;
}
const target = hostTarget(host);
if (target) {
return target;
}
if (target) return target;
}
return null;
}
@@ -53,6 +53,7 @@ function project(projectKey: string, workspaces: SidebarWorkspaceEntry[]): Sideb
hosts: [
{
serverId: workspaces[0]?.serverId ?? "s1",
projectId: projectKey,
iconWorkingDir: workspaces[0]?.workspaceDirectory ?? "",
canCreateWorktree: true,
},
@@ -1,7 +1,8 @@
import { afterEach, describe, expect, it } from "vitest";
import type { DaemonClient, FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client";
import { useSessionStore, type Agent } from "@/stores/session-store";
import { useSessionStore, type Agent, type WorkspaceDescriptor } from "@/stores/session-store";
import { deriveWorkspaceAgentVisibility } from "@/workspace-tabs/agent-visibility";
import { buildWorkspaceStructureProjects } from "@/projects/workspace-structure";
import {
applyLegacyDaemonWorkspaceOwnership,
backfillLegacyDaemonWorkspaceDirectoryIfEmpty,
@@ -10,6 +11,17 @@ import {
const SERVER_ID = "srv_legacy";
function legacyProjectFromWorkspace(workspace: WorkspaceDescriptor) {
return {
projectId: workspace.projectId,
projectKey: null,
projectDisplayName: workspace.projectDisplayName,
projectCustomName: workspace.projectCustomName ?? null,
projectRootPath: workspace.projectRootPath,
projectKind: workspace.projectKind,
};
}
function legacyAgent(input: {
id: string;
cwd: string;
@@ -118,6 +130,38 @@ describe("buildLegacyDaemonWorkspaceSnapshot", () => {
]);
});
it("keeps matching legacy path projects separate across hosts", () => {
const first = buildLegacyDaemonWorkspaceSnapshot({
serverId: "host-a",
entries: [legacyAgent({ id: "agent-a", cwd: "/repo/app" })],
});
const second = buildLegacyDaemonWorkspaceSnapshot({
serverId: "host-b",
entries: [legacyAgent({ id: "agent-b", cwd: "/repo/app" })],
});
const projects = buildWorkspaceStructureProjects({
sessions: [
{
serverId: "host-a",
projects: Array.from(first.workspaces.values(), legacyProjectFromWorkspace),
workspaces: first.workspaces.values(),
},
{
serverId: "host-b",
projects: Array.from(second.workspaces.values(), legacyProjectFromWorkspace),
workspaces: second.workspaces.values(),
},
],
});
expect(projects).toHaveLength(2);
expect(projects.map((project) => project.hosts[0]?.serverId).sort()).toEqual([
"host-a",
"host-b",
]);
});
it("keeps old-daemon agent updates attached to the path-backed workspace", () => {
const snapshot = buildLegacyDaemonWorkspaceSnapshot({
serverId: SERVER_ID,
@@ -185,7 +229,7 @@ describe("buildLegacyDaemonWorkspaceSnapshot", () => {
client,
serverId: SERVER_ID,
workspaces: new Map(),
emptyProjects: new Map(),
projects: new Map(),
isCancelled: () => cancelled,
});
@@ -7,7 +7,12 @@ import {
deriveAgentStateBucket,
getWorkspaceStateBucketPriority,
} from "@getpaseo/protocol/agent-state-bucket";
import type { Agent, DaemonServerInfo, WorkspaceDescriptor } from "@/stores/session-store";
import type {
Agent,
DaemonServerInfo,
ProjectDescriptor,
WorkspaceDescriptor,
} from "@/stores/session-store";
import { useSessionStore } from "@/stores/session-store";
import {
buildAgentDirectoryState,
@@ -33,7 +38,7 @@ interface LegacyDaemonWorkspaceBackfillInput {
client: Pick<DaemonClient, "fetchAgents">;
serverId: string;
workspaces: ReadonlyMap<unknown, unknown>;
emptyProjects: ReadonlyMap<unknown, unknown>;
projects: ReadonlyMap<unknown, unknown>;
isCancelled?: () => boolean;
}
@@ -97,7 +102,7 @@ export async function fetchLegacyDaemonWorkspaceDirectory(input: {
export async function backfillLegacyDaemonWorkspaceDirectoryIfEmpty(
input: LegacyDaemonWorkspaceBackfillInput,
): Promise<boolean> {
if (input.workspaces.size > 0 || input.emptyProjects.size > 0) {
if (input.workspaces.size > 0 || input.projects.size > 0) {
return false;
}
const serverInfo = useSessionStore.getState().sessions[input.serverId]?.serverInfo;
@@ -205,11 +210,25 @@ export function replaceLegacyDaemonWorkspaceDirectory(input: {
const workspaces = buildLegacyWorkspaces(entries);
const store = useSessionStore.getState();
store.setWorkspaces(input.serverId, workspaces);
store.setEmptyProjects(input.serverId, []);
store.setProjects(
input.serverId,
Array.from(workspaces.values(), legacyProjectDescriptorFromWorkspace),
);
store.setHasHydratedWorkspaces(input.serverId, true);
return { agents, workspaces };
}
function legacyProjectDescriptorFromWorkspace(workspace: WorkspaceDescriptor): ProjectDescriptor {
return {
projectId: workspace.projectId,
projectKey: null,
projectDisplayName: workspace.projectDisplayName,
projectCustomName: workspace.projectCustomName ?? null,
projectRootPath: workspace.projectRootPath,
projectKind: workspace.projectKind,
};
}
function readFetchAgentsHasMore(
pageInfo: Awaited<ReturnType<DaemonClient["fetchAgents"]>>["pageInfo"],
): boolean {
+22
View File
@@ -678,6 +678,10 @@ export type FetchWorkspacesOptions = Omit<FetchWorkspacesRequest, "type" | "requ
};
export type FetchWorkspacesEntry = FetchWorkspacesPayload["entries"][number];
export type FetchWorkspacesPageInfo = FetchWorkspacesPayload["pageInfo"];
export type ProjectListPayload = Extract<
SessionOutboundMessage,
{ type: "project.list.response" }
>["payload"];
export interface CreateChatRoomOptions {
name: string;
purpose?: string | null;
@@ -2092,6 +2096,24 @@ export class DaemonClient {
});
}
async listProjects(requestId?: string): Promise<ProjectListPayload> {
const resolvedRequestId = this.createRequestId(requestId);
const message = SessionInboundMessageSchema.parse({
type: "project.list.request",
requestId: resolvedRequestId,
});
return this.sendRequest({
requestId: resolvedRequestId,
message,
options: { skipQueue: true },
select: (msg) => {
if (msg.type !== "project.list.response") return null;
if (msg.payload.requestId !== resolvedRequestId) return null;
return msg.payload;
},
});
}
async openProject(cwd: string, requestId?: string): Promise<OpenProjectPayload> {
return this.sendCorrelatedSessionRequest({
requestId,
+15
View File
@@ -48,4 +48,19 @@ describe("parseGitRemoteLocation port", () => {
it("has no port for an scp-form remote", () => {
expect(parseGitRemoteLocation("git@host.example.com:team/repo.git")?.port).toBeUndefined();
});
it("omits an explicitly written default port", () => {
expect(parseGitRemoteLocation("ssh://git@github.com:22/acme/repo.git")).toEqual(
parseGitRemoteLocation("ssh://git@github.com/acme/repo.git"),
);
});
it("reads a scheme remote as a URL rather than scp form", () => {
expect(parseGitRemoteLocation("ssh://git@example.com:2222/acme/repo.git")).toEqual({
transport: "ssh",
host: "example.com",
port: "2222",
path: "acme/repo",
});
});
});
+13 -2
View File
@@ -8,6 +8,12 @@ const TRANSPORT_BY_PROTOCOL: Record<string, GitRemoteLocation["transport"]> = {
"ssh:": "ssh",
};
const DEFAULT_PORT_BY_PROTOCOL: Record<string, string> = {
"https:": "443",
"http:": "80",
"ssh:": "22",
};
export interface GitRemoteLocation {
transport: "scp" | "ssh" | "http" | "https";
host: string;
@@ -50,7 +56,9 @@ export function parseGitRemoteLocation(remoteUrl: string): GitRemoteLocation | n
const trimmed = remoteUrl.trim();
if (!trimmed) return null;
const scpLike = trimmed.match(/^[^@]+@([^:]+):(.+)$/u);
// scp form has no scheme. Testing it first would match `ssh://git@host:22/x`
// — `[^@]+` happily eats `ssh://git` — and swallow the port into the path.
const scpLike = trimmed.includes("://") ? null : trimmed.match(/^[^@]+@([^:]+):(.+)$/u);
if (scpLike) {
const host = normalizeHost(scpLike[1] ?? "");
const path = normalizeRemotePath(scpLike[2] ?? "");
@@ -78,7 +86,10 @@ export function parseGitRemoteLocation(remoteUrl: string): GitRemoteLocation | n
const normalizedPath = normalizeRemotePath(path);
if (!isValidRemoteHost(host) || !normalizedPath) return null;
return { transport, host, port: parsed.port || undefined, path: normalizedPath };
const protocol = parsed.protocol.toLowerCase();
const port =
parsed.port && parsed.port !== DEFAULT_PORT_BY_PROTOCOL[protocol] ? parsed.port : undefined;
return { transport, host, port, path: normalizedPath };
}
export function parseGitHubRemoteIdentity(path: string): GitHubRemoteIdentity | null {
+21
View File
@@ -1072,6 +1072,11 @@ export const FetchWorkspacesRequestMessageSchema = z.object({
.optional(),
});
export const ProjectListRequestMessageSchema = z.object({
type: z.literal("project.list.request"),
requestId: z.string(),
});
export const FetchAgentHistoryRequestMessageSchema = z.object({
type: z.literal("fetch_agent_history_request"),
requestId: z.string(),
@@ -2445,6 +2450,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
FetchAgentHistoryRequestMessageSchema,
FetchRecentProviderSessionsRequestMessageSchema,
FetchWorkspacesRequestMessageSchema,
ProjectListRequestMessageSchema,
FetchAgentRequestMessageSchema,
DeleteAgentRequestMessageSchema,
ArchiveAgentRequestMessageSchema,
@@ -2814,6 +2820,8 @@ export const ServerInfoStatusPayloadSchema = z
workspaceGithubRepositorySearch: z.boolean().optional(),
// COMPAT(projectCreateDirectory): added in v0.1.108, remove gate after 2027-01-15.
projectCreateDirectory: z.boolean().optional(),
// COMPAT(projectList): added in v0.2.4, drop the gate when floor >= v0.2.4.
projectList: z.boolean().optional(),
// COMPAT(commitsList): added in v0.1.110, remove gate after 2027-01-16.
commitsList: z.boolean().optional(),
// COMPAT(commitBaseClassification): added in v0.2.0, remove gate after 2027-01-23.
@@ -3232,6 +3240,8 @@ export const FetchRecentProviderSessionsResponseMessageSchema = z.object({
// workspace is archived.
export const WorkspaceProjectDescriptorPayloadSchema = z.object({
projectId: z.string(),
// COMPAT(projectKey): added in v0.2.4 on 2026-07-28; remove optional after 2027-01-28.
projectKey: z.string().optional(),
projectDisplayName: z.string(),
projectCustomName: z.string().nullable().optional(),
projectRootPath: z.string(),
@@ -3288,6 +3298,14 @@ export const ProjectUpdateMessageSchema = z.object({
]),
});
export const ProjectListResponseMessageSchema = z.object({
type: z.literal("project.list.response"),
payload: z.object({
requestId: z.string(),
projects: z.array(WorkspaceProjectDescriptorPayloadSchema),
}),
});
export const ScriptStatusUpdateMessageSchema = z.object({
type: z.literal("script_status_update"),
payload: z.object({
@@ -5185,6 +5203,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
AgentUpdateMessageSchema,
WorkspaceUpdateMessageSchema,
ProjectUpdateMessageSchema,
ProjectListResponseMessageSchema,
ScriptStatusUpdateMessageSchema,
WorkspaceSetupProgressMessageSchema,
WorkspaceSetupStatusResponseMessageSchema,
@@ -5363,6 +5382,7 @@ export type WorkspaceDescriptorPayload = z.infer<typeof WorkspaceDescriptorPaylo
export type WorkspaceProjectDescriptorPayload = z.infer<
typeof WorkspaceProjectDescriptorPayloadSchema
>;
export type ProjectListResponseMessage = z.infer<typeof ProjectListResponseMessageSchema>;
export type WorkspaceScriptLifecycle = z.infer<typeof WorkspaceScriptLifecycleSchema>;
export type WorkspaceScriptHealth = z.infer<typeof WorkspaceScriptHealthSchema>;
export type WorkspaceScriptPayload = z.infer<typeof WorkspaceScriptPayloadSchema>;
@@ -5508,6 +5528,7 @@ export type FetchRecentProviderSessionsRequestMessage = z.infer<
typeof FetchRecentProviderSessionsRequestMessageSchema
>;
export type FetchWorkspacesRequestMessage = z.infer<typeof FetchWorkspacesRequestMessageSchema>;
export type ProjectListRequestMessage = z.infer<typeof ProjectListRequestMessageSchema>;
export type FetchAgentRequestMessage = z.infer<typeof FetchAgentRequestMessageSchema>;
export type AgentForkContextRequestMessage = z.infer<typeof AgentForkContextRequestMessageSchema>;
export type SendAgentMessageRequest = z.infer<typeof SendAgentMessageRequestSchema>;
@@ -2564,6 +2564,7 @@ describe("create_agent MCP tool", () => {
providerSnapshotManager: createOpenCodeManager().manager,
projectRegistry: {
get: async (projectId) => (projectId === project.projectId ? project : null),
list: async () => [project],
},
createPaseoWorktree,
logger,
@@ -108,7 +108,7 @@ export interface PaseoToolHostDependencies {
archiveWorkspaceRecord?: ArchiveDependencies["archiveWorkspaceRecord"];
emitWorkspaceUpdatesForWorkspaceIds?: ArchiveDependencies["emitWorkspaceUpdatesForWorkspaceIds"];
workspaceRegistry?: Pick<WorkspaceRegistry, "get" | "list" | "upsert">;
projectRegistry?: Pick<ProjectRegistry, "get">;
projectRegistry?: Pick<ProjectRegistry, "get" | "list">;
createDirectoryWorkspace?: (
cwd: string,
title?: string | null,
+4 -1
View File
@@ -793,6 +793,7 @@ export async function createPaseoDaemon(
},
});
const workspaceProvisioning = createWorkspaceProvisioningService({
serverId,
projectRegistry,
workspaceRegistry,
workspaceGitService,
@@ -829,6 +830,7 @@ export async function createPaseoDaemon(
await agentStorage.initialize();
logger.info({ elapsed: elapsed() }, "Agent storage initialized");
await bootstrapWorkspaceRegistries({
serverId,
paseoHome: config.paseoHome,
agentStorage,
projectRegistry,
@@ -842,6 +844,7 @@ export async function createPaseoDaemon(
releaseWorkspaceServicePortPlan(workspaceId);
};
const workspaceReconciliation = new WorkspaceReconciliationService({
serverId,
projectRegistry,
workspaceRegistry,
logger,
@@ -857,7 +860,7 @@ export async function createPaseoDaemon(
},
});
await workspaceReconciliation.start();
void workspaceReconciliation.runOnce().catch((error) => {
void workspaceReconciliation.reconcileNow().catch((error) => {
logger.warn({ err: error }, "Initial workspace reconciliation failed");
});
await chatService.initialize();
@@ -23,6 +23,7 @@ import { readPaseoWorktreeMetadata } from "../utils/worktree-metadata.js";
import { createWorktree, getPaseoWorktreesRoot } from "../utils/worktree.js";
import { isPlatform } from "../test-utils/platform.js";
import { areEquivalentPaths, createRealpathAwarePathMatcher } from "../utils/path.js";
import { deriveProjectKey } from "./project-key.js";
const cleanupPaths: string[] = [];
@@ -73,7 +74,16 @@ test("creates a worktree and registers it in the source workspace project withou
expect(result.workspace.baseBranch).toBe("main");
expect(result.workspace.title).toBe("Feature One");
expect(deps.workspaceGitService.getSnapshot).not.toHaveBeenCalled();
expect(deps.projects.get(sourceProject.projectId)).toEqual(sourceProject);
expect(deps.projects.get(sourceProject.projectId)).toEqual({
...sourceProject,
projectKey: deriveProjectKey({
rootPath: result.repoRoot,
remoteUrl: null,
worktreeRoot: null,
mainRepoRoot: null,
}),
updatedAt: expect.any(String),
});
expect(events).toEqual([`workspace:${result.workspace.workspaceId}`]);
});
@@ -436,7 +446,16 @@ test("an explicit project FK remains unchanged when its worktree comes from anot
);
expect(result.workspace.projectId).toBe(project.projectId);
expect(deps.projects.get(project.projectId)).toEqual(project);
expect(deps.projects.get(project.projectId)).toEqual({
...project,
projectKey: deriveProjectKey({
rootPath: project.rootPath,
remoteUrl: null,
worktreeRoot: null,
mainRepoRoot: null,
}),
updatedAt: expect.any(String),
});
});
// POSIX-only: Windows git worktree paths need separate canonicalization coverage.
@@ -0,0 +1,141 @@
import { mkdirSync, mkdtempSync, rmSync, symlinkSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, test } from "vitest";
import { deriveProjectGroupingDisplayName, deriveProjectKey } from "./project-key.js";
describe("deriveProjectKey", () => {
const rootPath = path.resolve("repo");
const derive = (remoteUrl: string) =>
deriveProjectKey({ rootPath, remoteUrl, worktreeRoot: rootPath, mainRepoRoot: null });
test.each([
"https://github.com/getpaseo/paseo.git",
"ssh://git@github.com/getpaseo/paseo.git",
"git@github.com:getpaseo/paseo.git",
])("normalizes common remote form %s", (remoteUrl) => {
expect(derive(remoteUrl)).toBe("remote:github.com/getpaseo/paseo");
});
test("normalizes GitHub casing", () => {
expect(derive("git@github.com:GetPaseo/Paseo.git")).toBe("remote:github.com/getpaseo/paseo");
});
test("preserves self-hosted paths and explicit ports", () => {
expect(derive("https://git.example.com:8443/Team/App.git")).toBe(
"remote:git.example.com:8443/Team/App",
);
});
test("groups an SSH remote whether or not its default port is spelled out", () => {
expect(derive("ssh://git@github.com:22/getpaseo/paseo.git")).toBe(
derive("ssh://git@github.com/getpaseo/paseo.git"),
);
});
test("keeps projects without a remote host-local", () => {
const hostA = deriveProjectKey({
rootPath,
remoteUrl: null,
worktreeRoot: null,
mainRepoRoot: null,
serverId: "host-a",
});
const hostB = deriveProjectKey({
rootPath,
remoteUrl: null,
worktreeRoot: null,
mainRepoRoot: null,
serverId: "host-b",
});
expect(hostA).not.toBe(hostB);
});
test("uses one host-local key for two spellings of the same directory", () => {
const tempRoot = mkdtempSync(path.join(tmpdir(), "project-key-identity-"));
const projectRoot = path.join(tempRoot, "project");
const alternateRoot = path.join(tempRoot, "project-alias");
const deriveLocalProject = (localRootPath: string) =>
deriveProjectKey({
rootPath: localRootPath,
remoteUrl: null,
worktreeRoot: null,
mainRepoRoot: null,
serverId: "host-a",
});
try {
mkdirSync(projectRoot);
symlinkSync(projectRoot, alternateRoot, process.platform === "win32" ? "junction" : "dir");
expect(deriveLocalProject(alternateRoot)).toBe(deriveLocalProject(projectRoot));
} finally {
rmSync(tempRoot, { recursive: true, force: true });
}
});
test("keeps a repository root distinct from one of its subprojects", () => {
const worktreeRoot = path.resolve("host-a", "repo");
const remoteUrl = "git@github.com:getpaseo/paseo.git";
expect(
deriveProjectKey({ rootPath: worktreeRoot, remoteUrl, worktreeRoot, mainRepoRoot: null }),
).toBe("remote:github.com/getpaseo/paseo");
expect(
deriveProjectKey({
rootPath: path.join(worktreeRoot, "packages", "app"),
remoteUrl,
worktreeRoot,
mainRepoRoot: null,
}),
).toBe("remote:github.com/getpaseo/paseo#subdir:packages/app");
});
test("groups the same subproject across different absolute checkout roots", () => {
const remoteUrl = "git@github.com:getpaseo/paseo.git";
const deriveSubproject = (worktreeRoot: string) =>
deriveProjectKey({
rootPath: path.join(worktreeRoot, "packages", "app"),
remoteUrl,
worktreeRoot,
mainRepoRoot: null,
});
expect(deriveSubproject(path.resolve("host-a", "repo"))).toBe(
deriveSubproject(path.resolve("host-b", "different", "repo")),
);
});
});
describe("deriveProjectGroupingDisplayName", () => {
test("uses the owner and repository from a remote", () => {
expect(
deriveProjectGroupingDisplayName({
rootPath: path.resolve("repo"),
remoteUrl: "git@github.com:getpaseo/paseo.git",
worktreeRoot: path.resolve("repo"),
}),
).toBe("getpaseo/paseo");
});
test("uses the selected directory name without a remote", () => {
expect(
deriveProjectGroupingDisplayName({
rootPath: path.resolve("acme", "app"),
remoteUrl: null,
worktreeRoot: null,
}),
).toBe("app");
});
test("uses the selected subproject directory name instead of the repository remote", () => {
const worktreeRoot = path.resolve("acme", "repo");
expect(
deriveProjectGroupingDisplayName({
rootPath: path.join(worktreeRoot, "packages", "server"),
remoteUrl: "git@github.com:acme/repo.git",
worktreeRoot,
}),
).toBe("server");
});
});
+57
View File
@@ -0,0 +1,57 @@
import { resolve, win32 } from "node:path";
import { isGitHubHost, parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
import { getRealpathAwareRelativePath, normalizePathForIdentity } from "../utils/path.js";
/** Persisted opaque key used to join the same remote across hosts. */
export function deriveProjectKey(input: {
rootPath: string;
remoteUrl: string | null;
worktreeRoot: string | null;
mainRepoRoot: string | null;
serverId?: string;
}): string {
const remote = input.remoteUrl ? parseGitRemoteLocation(input.remoteUrl) : null;
const selectedPath = input.worktreeRoot
? getRealpathAwareRelativePath(input.worktreeRoot, input.rootPath) || null
: null;
if (remote) {
const host = remote.port ? `${remote.host}:${remote.port}` : remote.host;
const path = isGitHubHost(remote.host) ? remote.path.toLowerCase() : remote.path;
const remoteKey = `remote:${host}/${path}`;
return selectedPath ? `${remoteKey}#subdir:${selectedPath.replaceAll("\\", "/")}` : remoteKey;
}
const localPathParts = [
selectedPath && input.mainRepoRoot ? input.mainRepoRoot : input.rootPath,
selectedPath && input.mainRepoRoot ? selectedPath : "",
];
const resolvedLocalPath = localPathParts.some(looksLikeWindowsPath)
? win32.resolve(...localPathParts)
: resolve(...localPathParts);
const localPath = normalizePathForIdentity(resolvedLocalPath);
return input.serverId ? `host:${input.serverId}:${localPath}` : localPath;
}
export function deriveProjectGroupingDisplayName(input: {
rootPath: string;
remoteUrl: string | null;
worktreeRoot: string | null;
}): string {
const selectedPath = input.worktreeRoot
? getRealpathAwareRelativePath(input.worktreeRoot, input.rootPath)
: null;
if (selectedPath) return lastPathSegment(input.rootPath);
const remote = input.remoteUrl ? parseGitRemoteLocation(input.remoteUrl) : null;
if (!remote) return lastPathSegment(input.rootPath);
return remote.path.split("/").filter(Boolean).slice(-2).join("/") || input.rootPath;
}
function lastPathSegment(inputPath: string): string {
const segments = inputPath.split(/[\\/]/u).filter(Boolean);
return segments[segments.length - 1] ?? inputPath;
}
function looksLikeWindowsPath(value: string): boolean {
return /^[a-zA-Z]:[\\/]/u.test(value) || /^[/\\]{2}[^/\\]+[/\\][^/\\]+/u.test(value);
}
+63 -4
View File
@@ -25,6 +25,7 @@ import type { StoredAgentRecord } from "./agent/agent-storage.js";
import type { AgentManagerEvent } from "./agent/agent-manager.js";
import type { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js";
import { createPersistedProjectRecord } from "./workspace-registry.js";
import { deriveProjectKey } from "./project-key.js";
import type { SessionOptions } from "./session.js";
import type { SessionInboundMessage, SessionOutboundMessage } from "./messages.js";
import {
@@ -649,6 +650,12 @@ describe("project command-center RPCs", () => {
rootPath: directoryPath,
kind: "non_git",
displayName: "new-project",
projectKey: deriveProjectKey({
rootPath: directoryPath,
remoteUrl: null,
worktreeRoot: null,
mainRepoRoot: null,
}),
timestamp: expect.any(String),
});
expect(messages).toEqual([
@@ -3416,7 +3423,7 @@ describe("session workspace descriptors", () => {
const messages: unknown[] = [];
const workspace = {
workspaceId: "ws-gh",
projectId: "remote:github.com/acme/app",
projectId: "prj_app",
cwd: "/repo/app",
kind: "local_checkout" as const,
displayName: "app",
@@ -3424,7 +3431,8 @@ describe("session workspace descriptors", () => {
archivedAt: null,
};
const project = {
projectId: "remote:github.com/acme/app",
projectId: "prj_app",
projectKey: "remote:github.com/acme/app",
rootPath: "/repo/app",
kind: "git" as const,
displayName: "acme/app",
@@ -3435,7 +3443,10 @@ describe("session workspace descriptors", () => {
const session = createSessionForTest({
messages,
workspaceRegistry: { get: vi.fn(), list: vi.fn().mockResolvedValue([workspace]) },
projectRegistry: { list: vi.fn().mockResolvedValue([project]), get: vi.fn() },
projectRegistry: {
list: vi.fn().mockResolvedValue([project]),
get: vi.fn().mockResolvedValue(project),
},
workspaceGitService: {
getSnapshot: vi.fn(),
peekSnapshot: vi.fn(() =>
@@ -3464,8 +3475,9 @@ describe("session workspace descriptors", () => {
entries: [
expect.objectContaining({
id: "ws-gh",
projectId: "prj_app",
project: expect.objectContaining({
projectKey: "remote:github.com/acme/app",
projectKey: "prj_app",
projectName: "acme/app",
workspaceName: "app",
checkout: expect.objectContaining({
@@ -4900,6 +4912,53 @@ test("sends project updates only to capable sockets in a retained session", () =
]);
});
test("project.list returns every active project descriptor", async () => {
const messages: SessionOutboundMessage[] = [];
const active = createPersistedProjectRecord({
projectId: "project-active",
projectKey: "remote:github.com/acme/app",
rootPath: "/tmp/project-active",
kind: "git",
displayName: "acme/app",
createdAt: "2026-07-17T00:00:00.000Z",
updatedAt: "2026-07-17T00:00:00.000Z",
});
const archived = createPersistedProjectRecord({
projectId: "project-archived",
rootPath: "/tmp/project-archived",
kind: "non_git",
displayName: "archived",
createdAt: "2026-07-17T00:00:00.000Z",
updatedAt: "2026-07-17T00:00:00.000Z",
archivedAt: "2026-07-18T00:00:00.000Z",
});
const session = createSessionForTest({
messages,
projectRegistry: { list: vi.fn().mockResolvedValue([active, archived]) },
});
await session.handleMessage({ type: "project.list.request", requestId: "projects-1" });
expect(messages).toEqual([
{
type: "project.list.response",
payload: {
requestId: "projects-1",
projects: [
{
projectId: "project-active",
projectKey: "remote:github.com/acme/app",
projectDisplayName: "acme/app",
projectCustomName: null,
projectRootPath: "/tmp/project-active",
projectKind: "git",
},
],
},
},
]);
});
describe("agent config setters", () => {
function liveAgentManager(overrides: { [K in keyof SessionOptions["agentManager"]]?: unknown }): {
[K in keyof SessionOptions["agentManager"]]?: unknown;

Some files were not shown because too many files have changed in this diff Show More