WIP: snapshot workspace execution refactor baseline

This commit is contained in:
Mohamed Boudra
2026-04-01 10:35:06 +07:00
parent e026223c80
commit d8243bfb72
47 changed files with 2219 additions and 169 deletions

View File

@@ -30,7 +30,9 @@ export async function waitForTabBar(page: Page): Promise<void> {
/** Return all tab test IDs currently in the tab bar. */
export async function getTabTestIds(page: Page): Promise<string[]> {
const tabs = page.locator('[data-testid^="workspace-tab-"]');
const tabs = page.locator(
'[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])',
);
const count = await tabs.count();
const ids: string[] = [];
for (let i = 0; i < count; i++) {
@@ -49,7 +51,11 @@ export async function countTabsOfKind(page: Page, kind: string): Promise<number>
/** Return the currently active tab's test ID (the one with aria-selected or focus styling). */
export async function getActiveTabTestId(page: Page): Promise<string | null> {
// Active tab has the focused highlight — check for the aria-selected or data-active attribute
const activeTab = page.locator('[data-testid^="workspace-tab-"][aria-selected="true"]').first();
const activeTab = page
.locator(
'[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])[aria-selected="true"]',
)
.first();
if (await activeTab.isVisible().catch(() => false)) {
return activeTab.getAttribute("data-testid");
}
@@ -111,7 +117,7 @@ export async function clickNewChat(page: Page): Promise<void> {
/** Click the "Terminal" tile on the launcher panel. */
export async function clickTerminal(page: Page): Promise<void> {
const button = page.getByRole("button", { name: "Terminal" }).first();
const button = page.getByRole("button", { name: "Terminal", exact: true }).first();
await expect(button).toBeVisible({ timeout: 10_000 });
await button.click();
}
@@ -132,8 +138,12 @@ export async function waitForTabWithTitle(
timeout = 30_000,
): Promise<void> {
const matcher = typeof title === "string" ? new RegExp(title, "i") : title;
await expect(page.locator('[data-testid^="workspace-tab-"]').filter({ hasText: matcher }).first())
.toBeVisible({ timeout });
await expect(
page
.locator('[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])')
.filter({ hasText: matcher })
.first(),
).toBeVisible({ timeout });
}
/** Assert the new-tab '+' button is visible and there is only one. */

View File

@@ -7,6 +7,12 @@ import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
export type TerminalPerfDaemonClient = {
connect(): Promise<void>;
close(): Promise<void>;
openProject(
cwd: string,
): Promise<{
workspace: { id: number; name: string; projectRootPath: string } | null;
error: string | null;
}>;
createTerminal(
cwd: string,
name?: string,

View File

@@ -0,0 +1,51 @@
import { expect, type Page } from "@playwright/test";
import {
clickNewChat,
clickProviderTile,
clickTerminal,
countTabsOfKind,
getTabTestIds,
waitForTabWithTitle,
} from "./launcher";
import { setupDeterministicPrompt, waitForTerminalContent } from "./terminal-perf";
function terminalSurface(page: Page) {
return page.locator('[data-testid="terminal-surface"]').first();
}
function composerInput(page: Page) {
return page.getByRole("textbox", { name: "Message agent..." }).first();
}
export async function expectTerminalCwd(page: Page, expectedPath: string): Promise<void> {
const terminal = terminalSurface(page);
await expect(terminal).toBeVisible({ timeout: 20_000 });
await terminal.click();
await setupDeterministicPrompt(page, `SENTINEL_${Date.now()}`);
await terminal.pressSequentially("pwd\n", { delay: 0 });
await waitForTerminalContent(page, (text) => text.includes(expectedPath), 10_000);
}
export async function createStandaloneTerminalFromLauncher(page: Page): Promise<void> {
const tabIdsBefore = await getTabTestIds(page);
const launcherCountBefore = await countTabsOfKind(page, "launcher");
await clickTerminal(page);
await expect(terminalSurface(page)).toBeVisible({ timeout: 20_000 });
await expect.poll(() => countTabsOfKind(page, "launcher")).toBe(launcherCountBefore - 1);
await expect.poll(async () => (await getTabTestIds(page)).length).toBe(tabIdsBefore.length);
}
export async function createTerminalAgentFromLauncher(page: Page, providerLabel: string): Promise<void> {
await clickProviderTile(page, providerLabel);
await expect(page.getByTestId("terminal-agent-loading")).toHaveCount(0, { timeout: 30_000 });
await expect(terminalSurface(page)).toBeVisible({ timeout: 30_000 });
await waitForTabWithTitle(page, /new agent/i);
}
export async function createAgentChatFromLauncher(page: Page): Promise<void> {
await clickNewChat(page);
await expect(composerInput(page)).toBeVisible({ timeout: 15_000 });
await expect(composerInput(page)).toBeEditable({ timeout: 15_000 });
await expect(page.getByTestId("agent-loading")).toHaveCount(0);
await expect(page.getByRole("button", { name: "New Chat" })).toHaveCount(0);
}

View File

@@ -0,0 +1,165 @@
import path from "node:path";
import { randomUUID } from "node:crypto";
import { pathToFileURL } from "node:url";
import { expect, type Page } from "@playwright/test";
import { gotoAppShell } from "./app";
type WorkspaceSetupProgressPayload = {
status: "running" | "completed" | "failed";
detail: { commands: string[]; log: string };
error: string | null;
};
type WorkspaceSetupRawMessage = {
type: string;
payload?: WorkspaceSetupProgressPayload;
};
type WorkspaceSetupDaemonClient = {
connect(): Promise<void>;
close(): Promise<void>;
openProject(
cwd: string,
): Promise<{ workspace: { id: string; name: string } | null; error: string | null }>;
createPaseoWorktree(
input: { cwd: string; worktreeSlug?: string },
): Promise<{ workspace: { id: string; name: string } | null; error: string | null }>;
subscribeRawMessages(handler: (message: WorkspaceSetupRawMessage) => void): () => void;
};
export type { WorkspaceSetupDaemonClient, WorkspaceSetupProgressPayload };
function getDaemonWsUrl(): string {
const daemonPort = process.env.E2E_DAEMON_PORT;
if (!daemonPort) {
throw new Error("E2E_DAEMON_PORT is not set.");
}
return `ws://127.0.0.1:${daemonPort}/ws`;
}
async function loadDaemonClientConstructor(): Promise<
new (config: { url: string; clientId: string; clientType: "cli" }) => WorkspaceSetupDaemonClient
> {
const repoRoot = path.resolve(process.cwd(), "../..");
const moduleUrl = pathToFileURL(
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
).href;
const mod = (await import(moduleUrl)) as {
DaemonClient: new (config: {
url: string;
clientId: string;
clientType: "cli";
}) => WorkspaceSetupDaemonClient;
};
return mod.DaemonClient;
}
export async function connectWorkspaceSetupClient(): Promise<WorkspaceSetupDaemonClient> {
const DaemonClient = await loadDaemonClientConstructor();
const client = new DaemonClient({
url: getDaemonWsUrl(),
clientId: `workspace-setup-${randomUUID()}`,
clientType: "cli",
});
await client.connect();
return client;
}
export async function seedProjectForWorkspaceSetup(
client: WorkspaceSetupDaemonClient,
repoPath: string,
): Promise<void> {
const result = await client.openProject(repoPath);
if (!result.workspace || result.error) {
throw new Error(result.error ?? `Failed to open project ${repoPath}`);
}
}
export function projectNameFromPath(repoPath: string): string {
return repoPath.replace(/\/+$/, "").split("/").filter(Boolean).pop() ?? repoPath;
}
export async function openHomeWithProject(page: Page, repoPath: string): Promise<void> {
await gotoAppShell(page);
await expect(createWorkspaceButton(page, repoPath)).toBeVisible({ timeout: 30_000 });
}
function createWorkspaceButton(page: Page, repoPath: string) {
return page.getByRole("button", {
name: `Create a new workspace for ${projectNameFromPath(repoPath)}`,
});
}
async function revealWorkspaceButton(page: Page, repoPath: string): Promise<void> {
await page.getByTestId(`sidebar-project-row-${repoPath}`).hover();
}
export async function createWorkspaceFromSidebar(page: Page, repoPath: string): Promise<void> {
await revealWorkspaceButton(page, repoPath);
await expect(createWorkspaceButton(page, repoPath)).toBeEnabled({ timeout: 30_000 });
await createWorkspaceButton(page, repoPath).click();
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
}
export async function expectSetupPanel(page: Page): Promise<void> {
await expect(page.getByText("Workspace setup", { exact: true })).toBeVisible({ timeout: 30_000 });
}
export async function expectSetupStatus(
page: Page,
status: "Running" | "Completed" | "Failed",
): Promise<void> {
await expect(page.getByTestId("workspace-setup-status")).toContainText(status, {
timeout: 30_000,
});
}
export async function expectSetupLogContains(page: Page, text: string): Promise<void> {
await expect(page.getByTestId("workspace-setup-log")).toContainText(text, {
timeout: 30_000,
});
}
export async function expectNoSetupMessage(page: Page): Promise<void> {
await expect(page.getByText("No setup commands ran for this workspace.", { exact: true })).toBeVisible({
timeout: 30_000,
});
}
export async function createWorkspaceThroughDaemon(
client: WorkspaceSetupDaemonClient,
input: { cwd: string; worktreeSlug: string },
): Promise<{ id: string; name: string }> {
const result = await client.createPaseoWorktree(input);
if (!result.workspace || result.error) {
throw new Error(result.error ?? `Failed to create workspace for ${input.cwd}`);
}
return result.workspace;
}
export async function waitForWorkspaceSetupProgress(
client: WorkspaceSetupDaemonClient,
predicate: (payload: WorkspaceSetupProgressPayload) => boolean,
timeoutMs = 30_000,
): Promise<WorkspaceSetupProgressPayload> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
unsubscribe();
reject(new Error(`Timed out waiting for workspace_setup_progress after ${timeoutMs}ms`));
}, timeoutMs);
const unsubscribe = client.subscribeRawMessages((message) => {
if (message.type !== "workspace_setup_progress") {
return;
}
if (!message.payload) {
return;
}
if (!predicate(message.payload)) {
return;
}
clearTimeout(timeout);
unsubscribe();
resolve(message.payload);
});
});
}

View File

@@ -10,7 +10,11 @@ type TempRepo = {
export const createTempGitRepo = async (
prefix = "paseo-e2e-",
options?: { withRemote?: boolean },
options?: {
withRemote?: boolean;
paseoConfig?: Record<string, unknown>;
files?: Array<{ path: string; content: string }>;
},
): Promise<TempRepo> => {
// Keep E2E repo paths short so terminal prompt + typed commands stay visible without zsh clipping.
const tempRoot = process.platform === "win32" ? tmpdir() : "/tmp";
@@ -22,7 +26,24 @@ export const createTempGitRepo = async (
execSync('git config user.name "Paseo E2E"', { cwd: repoPath, stdio: "ignore" });
execSync("git config commit.gpgsign false", { cwd: repoPath, stdio: "ignore" });
await writeFile(path.join(repoPath, "README.md"), "# Temp Repo\n");
if (options?.paseoConfig) {
await writeFile(
path.join(repoPath, "paseo.json"),
JSON.stringify(options.paseoConfig, null, 2),
);
}
for (const file of options?.files ?? []) {
const filePath = path.join(repoPath, file.path);
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, file.content);
}
execSync("git add README.md", { cwd: repoPath, stdio: "ignore" });
if (options?.paseoConfig) {
execSync("git add paseo.json", { cwd: repoPath, stdio: "ignore" });
}
for (const file of options?.files ?? []) {
execSync(`git add ${JSON.stringify(file.path)}`, { cwd: repoPath, stdio: "ignore" });
}
execSync('git commit -m "Initial commit"', { cwd: repoPath, stdio: "ignore" });
if (withRemote) {

View File

@@ -28,12 +28,19 @@ import {
// ─── Shared state ──────────────────────────────────────────────────────────
let tempRepo: { path: string; cleanup: () => Promise<void> };
let workspaceId: string;
let seedClient: TerminalPerfDaemonClient;
test.beforeAll(async () => {
tempRepo = await createTempGitRepo("launcher-e2e-");
seedClient = await connectTerminalClient();
const result = await seedClient.openProject(tempRepo.path);
if (!result.workspace) throw new Error(result.error ?? "Failed to seed workspace");
workspaceId = String(result.workspace.id);
});
test.afterAll(async () => {
if (seedClient) await seedClient.close();
if (tempRepo) await tempRepo.cleanup();
});
@@ -45,7 +52,7 @@ test.describe("Launcher tab", () => {
test("Cmd+T opens launcher panel with New Chat, Terminal, and provider tiles", async ({
page,
}) => {
await gotoWorkspace(page, tempRepo.path);
await gotoWorkspace(page, workspaceId);
await pressNewTabShortcut(page);
@@ -56,7 +63,7 @@ test.describe("Launcher tab", () => {
});
test("opening two new tabs creates two launcher tabs", async ({ page }) => {
await gotoWorkspace(page, tempRepo.path);
await gotoWorkspace(page, workspaceId);
await pressNewTabShortcut(page);
await waitForLauncherPanel(page);
@@ -70,7 +77,7 @@ test.describe("Launcher tab", () => {
});
test("clicking New Chat replaces launcher in-place with draft tab", async ({ page }) => {
await gotoWorkspace(page, tempRepo.path);
await gotoWorkspace(page, workspaceId);
await clickNewTabButton(page);
await waitForLauncherPanel(page);
@@ -97,7 +104,7 @@ test.describe("Launcher tab", () => {
test("clicking Terminal replaces launcher with standalone terminal", async ({ page }) => {
test.setTimeout(45_000);
await gotoWorkspace(page, tempRepo.path);
await gotoWorkspace(page, workspaceId);
await clickNewTabButton(page);
await waitForLauncherPanel(page);
@@ -121,7 +128,7 @@ test.describe("Launcher tab", () => {
test("clicking a provider tile replaces launcher with terminal agent tab", async ({ page }) => {
test.setTimeout(45_000);
await gotoWorkspace(page, tempRepo.path);
await gotoWorkspace(page, workspaceId);
await clickNewTabButton(page);
await waitForLauncherPanel(page);
@@ -175,7 +182,7 @@ test.describe("Launcher tab", () => {
});
test("tab bar shows a single + button per pane", async ({ page }) => {
await gotoWorkspace(page, tempRepo.path);
await gotoWorkspace(page, workspaceId);
await assertSingleNewTabButton(page);
});
});
@@ -204,7 +211,7 @@ test.describe("Terminal title propagation", () => {
try {
// Navigate to workspace and open the terminal
await gotoWorkspace(page, tempRepo.path);
await gotoWorkspace(page, workspaceId);
await clickNewTabButton(page);
await waitForLauncherPanel(page);
await clickTerminal(page);
@@ -236,7 +243,7 @@ test.describe("Terminal title propagation", () => {
const terminalId = result.terminal.id;
try {
await gotoWorkspace(page, tempRepo.path);
await gotoWorkspace(page, workspaceId);
await clickNewTabButton(page);
await waitForLauncherPanel(page);
await clickTerminal(page);
@@ -272,7 +279,7 @@ test.describe("Terminal title propagation", () => {
test.describe("Launcher transitions (no flash)", () => {
test("New Chat transition has no blank intermediate tab state", async ({ page }) => {
await gotoWorkspace(page, tempRepo.path);
await gotoWorkspace(page, workspaceId);
await clickNewTabButton(page);
await waitForLauncherPanel(page);
@@ -301,7 +308,7 @@ test.describe("Launcher transitions (no flash)", () => {
test("Terminal transition completes within visual budget", async ({ page }) => {
test.setTimeout(30_000);
await gotoWorkspace(page, tempRepo.path);
await gotoWorkspace(page, workspaceId);
await clickNewTabButton(page);
await waitForLauncherPanel(page);
@@ -321,7 +328,7 @@ test.describe("Launcher transitions (no flash)", () => {
});
test("New Chat click → composer appears without launcher flash", async ({ page }) => {
await gotoWorkspace(page, tempRepo.path);
await gotoWorkspace(page, workspaceId);
await clickNewTabButton(page);
await waitForLauncherPanel(page);

View File

@@ -0,0 +1,178 @@
import { execSync } from "node:child_process";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { test, expect } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import { createTempGitRepo } from "./helpers/workspace";
import { expectWorkspaceHeader } from "./helpers/workspace-ui";
import { connectWorkspaceSetupClient } from "./helpers/workspace-setup";
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
}
return serverId;
}
function getWorkspaceRowTestId(workspaceId: string): string {
return `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
}
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function setGitHubRemote(repoPath: string): void {
execSync("git remote set-url origin https://github.com/test-owner/test-repo.git", {
cwd: repoPath,
stdio: "ignore",
});
}
async function createTempDirectory(prefix = "paseo-e2e-dir-") {
const dirPath = await mkdtemp(path.join(process.platform === "win32" ? tmpdir() : "/tmp", prefix));
await writeFile(path.join(dirPath, "README.md"), "# Temp Directory\n");
return {
path: dirPath,
cleanup: async () => {
await rm(dirPath, { recursive: true, force: true });
},
};
}
async function openProjectViaDaemon(
client: Awaited<ReturnType<typeof connectWorkspaceSetupClient>>,
cwd: string,
): Promise<{ id: string; name: string }> {
const result = await client.openProject(cwd);
if (!result.workspace || result.error) {
throw new Error(result.error ?? `Failed to open project ${cwd}`);
}
return result.workspace;
}
async function openWorkspaceFromSidebar(page: import("@playwright/test").Page, workspaceId: string) {
const row = page.getByTestId(getWorkspaceRowTestId(workspaceId));
await expect(row).toBeVisible({ timeout: 30_000 });
await row.click();
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
return row;
}
async function waitForSidebarProject(
page: import("@playwright/test").Page,
projectName: string,
) {
const row = page
.getByRole("button", {
name: new RegExp(escapeRegex(projectName), "i"),
})
.first();
await expect(row).toBeVisible({ timeout: 30_000 });
return row;
}
async function waitForSidebarWorkspace(page: import("@playwright/test").Page, workspaceId: string) {
const row = page.getByTestId(getWorkspaceRowTestId(workspaceId));
await expect(row).toBeVisible({ timeout: 30_000 });
return row;
}
test.describe("Sidebar workspace list", () => {
test("project with GitHub remote shows owner/repo name in sidebar", async ({ page }) => {
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("sidebar-remote-", { withRemote: true });
try {
setGitHubRemote(repo.path);
const workspace = await openProjectViaDaemon(client, repo.path);
await gotoAppShell(page);
await waitForSidebarProject(page, "test-owner/test-repo");
await waitForSidebarWorkspace(page, workspace.id);
const projectRow = page
.locator('[data-testid^="sidebar-project-row-"]')
.filter({ hasText: "test-owner/test-repo" })
.first();
await expect(projectRow).toBeVisible({ timeout: 30_000 });
await expect(projectRow).not.toContainText(path.basename(repo.path));
} finally {
await client.close();
await repo.cleanup();
}
});
test("project shows workspace under it", async ({ page }) => {
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("sidebar-workspace-under-project-");
try {
const workspace = await openProjectViaDaemon(client, repo.path);
await gotoAppShell(page);
await waitForSidebarProject(page, path.basename(repo.path));
await waitForSidebarWorkspace(page, workspace.id);
} finally {
await client.close();
await repo.cleanup();
}
});
test("non-git project shows directory name", async ({ page }) => {
const client = await connectWorkspaceSetupClient();
const project = await createTempDirectory("sidebar-directory-");
try {
await openProjectViaDaemon(client, project.path);
await gotoAppShell(page);
const projectRow = await waitForSidebarProject(page, path.basename(project.path));
await expect(projectRow).toContainText(path.basename(project.path));
} finally {
await client.close();
await project.cleanup();
}
});
test("workspace header shows correct title and subtitle", async ({ page }) => {
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("sidebar-header-", { withRemote: true });
try {
setGitHubRemote(repo.path);
const workspace = await openProjectViaDaemon(client, repo.path);
await gotoAppShell(page);
await waitForSidebarProject(page, "test-owner/test-repo");
await waitForSidebarWorkspace(page, workspace.id);
await openWorkspaceFromSidebar(page, workspace.id);
await expectWorkspaceHeader(page, {
title: workspace.name,
subtitle: "test-owner/test-repo",
});
} finally {
await client.close();
await repo.cleanup();
}
});
test("git project shows branch name in workspace row", async ({ page }) => {
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("sidebar-branch-");
try {
const workspace = await openProjectViaDaemon(client, repo.path);
await gotoAppShell(page);
await waitForSidebarProject(page, path.basename(repo.path));
expect(workspace.name).toBe("main");
await expect(await waitForSidebarWorkspace(page, workspace.id)).toContainText("main");
} finally {
await client.close();
await repo.cleanup();
}
});
});

View File

@@ -24,6 +24,9 @@ test.describe("Terminal wire performance", () => {
test.beforeAll(async () => {
tempRepo = await createTempGitRepo("perf-");
client = await connectTerminalClient();
// Seed the workspace in the daemon so the app can resolve the path
const seedResult = await client.openProject(tempRepo.path);
if (!seedResult.workspace) throw new Error(seedResult.error ?? "Failed to seed workspace");
});
test.afterAll(async () => {

View File

@@ -0,0 +1,107 @@
import { execSync } from "node:child_process";
import path from "node:path";
import { expect, test } from "./fixtures";
import {
clickNewTabButton,
clickTerminal,
gotoWorkspace,
waitForLauncherPanel,
} from "./helpers/launcher";
import {
setupDeterministicPrompt,
waitForTerminalContent,
} from "./helpers/terminal-perf";
import { createTempGitRepo } from "./helpers/workspace";
import { connectWorkspaceSetupClient, seedProjectForWorkspaceSetup } from "./helpers/workspace-setup";
test.describe("Workspace cwd correctness", () => {
test("main checkout workspace opens terminals in the project root", async ({ page }) => {
test.setTimeout(60_000);
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("workspace-cwd-main-");
try {
await seedProjectForWorkspaceSetup(client, repo.path);
const workspaceResult = await client.openProject(repo.path);
if (!workspaceResult.workspace) {
throw new Error(workspaceResult.error ?? `Failed to open project ${repo.path}`);
}
const workspaceId = String(workspaceResult.workspace.id);
await gotoWorkspace(page, workspaceId);
await clickNewTabButton(page);
await waitForLauncherPanel(page);
await clickTerminal(page);
const terminal = page.locator('[data-testid="terminal-surface"]');
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
await terminal.first().click();
await setupDeterministicPrompt(page, `PWD_READY_${Date.now()}`);
await terminal.first().pressSequentially("pwd\n", { delay: 0 });
await waitForTerminalContent(page, (text) => text.includes(repo.path), 10_000);
} finally {
await client.close();
await repo.cleanup();
}
});
test("worktree workspace opens terminals in the worktree directory", async ({ page }) => {
test.setTimeout(90_000);
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("workspace-cwd-worktree-");
const worktreePath = path.join(
"/tmp",
`paseo-wt-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
const branchName = `workspace-cwd-${Date.now()}`;
let worktreeCreated = false;
try {
await seedProjectForWorkspaceSetup(client, repo.path);
execSync(`git worktree add ${JSON.stringify(worktreePath)} -b ${JSON.stringify(branchName)} main`, {
cwd: repo.path,
stdio: "ignore",
});
worktreeCreated = true;
const workspaceResult = await client.openProject(worktreePath);
if (!workspaceResult.workspace) {
throw new Error(workspaceResult.error ?? `Failed to open project ${worktreePath}`);
}
const workspaceId = String(workspaceResult.workspace.id);
await gotoWorkspace(page, workspaceId);
await clickNewTabButton(page);
await waitForLauncherPanel(page);
await clickTerminal(page);
const terminal = page.locator('[data-testid="terminal-surface"]');
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
await terminal.first().click();
await setupDeterministicPrompt(page, `PWD_READY_${Date.now()}`);
await terminal.first().pressSequentially("pwd\n", { delay: 0 });
await waitForTerminalContent(page, (text) => text.includes(worktreePath), 10_000);
} finally {
if (worktreeCreated) {
try {
execSync(`git worktree remove ${JSON.stringify(worktreePath)} --force`, {
cwd: repo.path,
stdio: "ignore",
});
} catch {
// Best-effort cleanup so test failures preserve the original error.
}
}
await client.close();
await repo.cleanup();
}
});
});

View File

@@ -0,0 +1,242 @@
import { execSync } from "node:child_process";
import path from "node:path";
import { test } from "./fixtures";
import {
clickNewTabButton,
gotoWorkspace,
waitForLauncherPanel,
} from "./helpers/launcher";
import { createTempGitRepo } from "./helpers/workspace";
import {
createAgentChatFromLauncher,
createStandaloneTerminalFromLauncher,
createTerminalAgentFromLauncher,
expectTerminalCwd,
} from "./helpers/workspace-lifecycle";
import { connectWorkspaceSetupClient, seedProjectForWorkspaceSetup } from "./helpers/workspace-setup";
test.describe("Workspace lifecycle", () => {
// The first test after a spec-file switch can intermittently fail because
// the shared daemon still holds stale sessions from the previous spec.
// One retry is enough for the daemon to stabilize.
test.describe.configure({ retries: 1 });
test.describe("Main checkout", () => {
test("creates a terminal agent via provider tile", async ({ page }) => {
test.setTimeout(60_000);
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("lifecycle-main-agent-");
try {
await seedProjectForWorkspaceSetup(client, repo.path);
const workspaceResult = await client.openProject(repo.path);
if (!workspaceResult.workspace) {
throw new Error(workspaceResult.error ?? `Failed to open project ${repo.path}`);
}
const workspaceId = String(workspaceResult.workspace.id);
await gotoWorkspace(page, workspaceId);
await clickNewTabButton(page);
await waitForLauncherPanel(page);
await createTerminalAgentFromLauncher(page, "Claude");
} finally {
await client.close();
await repo.cleanup();
}
});
test("creates an agent chat via New Chat", async ({ page }) => {
test.setTimeout(60_000);
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("lifecycle-main-chat-");
try {
await seedProjectForWorkspaceSetup(client, repo.path);
const workspaceResult = await client.openProject(repo.path);
if (!workspaceResult.workspace) {
throw new Error(workspaceResult.error ?? `Failed to open project ${repo.path}`);
}
const workspaceId = String(workspaceResult.workspace.id);
await gotoWorkspace(page, workspaceId);
await clickNewTabButton(page);
await waitForLauncherPanel(page);
await createAgentChatFromLauncher(page);
} finally {
await client.close();
await repo.cleanup();
}
});
test("creates a terminal with correct CWD", async ({ page }) => {
test.setTimeout(60_000);
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("lifecycle-main-shell-");
try {
await seedProjectForWorkspaceSetup(client, repo.path);
const workspaceResult = await client.openProject(repo.path);
if (!workspaceResult.workspace) {
throw new Error(workspaceResult.error ?? `Failed to open project ${repo.path}`);
}
const workspaceId = String(workspaceResult.workspace.id);
await gotoWorkspace(page, workspaceId);
await clickNewTabButton(page);
await waitForLauncherPanel(page);
await createStandaloneTerminalFromLauncher(page);
await expectTerminalCwd(page, repo.path);
} finally {
await client.close();
await repo.cleanup();
}
});
});
test.describe("Worktree workspace", () => {
test("creates a terminal agent via provider tile", async ({ page }) => {
test.setTimeout(90_000);
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("lifecycle-wt-agent-");
const worktreePath = path.join(
"/tmp",
`paseo-wt-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
const branchName = `lifecycle-wt-agent-${Date.now()}`;
let worktreeCreated = false;
try {
await seedProjectForWorkspaceSetup(client, repo.path);
execSync(`git worktree add ${JSON.stringify(worktreePath)} -b ${JSON.stringify(branchName)} main`, {
cwd: repo.path,
stdio: "ignore",
});
worktreeCreated = true;
const workspaceResult = await client.openProject(worktreePath);
if (!workspaceResult.workspace) {
throw new Error(workspaceResult.error ?? `Failed to open project ${worktreePath}`);
}
const workspaceId = String(workspaceResult.workspace.id);
await gotoWorkspace(page, workspaceId);
await clickNewTabButton(page);
await waitForLauncherPanel(page);
await createTerminalAgentFromLauncher(page, "Claude");
} finally {
if (worktreeCreated) {
try {
execSync(`git worktree remove ${JSON.stringify(worktreePath)} --force`, {
cwd: repo.path,
stdio: "ignore",
});
} catch {
// Best-effort cleanup so test failures preserve the original error.
}
}
await client.close();
await repo.cleanup();
}
});
test("creates an agent chat via New Chat", async ({ page }) => {
test.setTimeout(90_000);
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("lifecycle-wt-chat-");
const worktreePath = path.join(
"/tmp",
`paseo-wt-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
const branchName = `lifecycle-wt-chat-${Date.now()}`;
let worktreeCreated = false;
try {
await seedProjectForWorkspaceSetup(client, repo.path);
execSync(`git worktree add ${JSON.stringify(worktreePath)} -b ${JSON.stringify(branchName)} main`, {
cwd: repo.path,
stdio: "ignore",
});
worktreeCreated = true;
const workspaceResult = await client.openProject(worktreePath);
if (!workspaceResult.workspace) {
throw new Error(workspaceResult.error ?? `Failed to open project ${worktreePath}`);
}
const workspaceId = String(workspaceResult.workspace.id);
await gotoWorkspace(page, workspaceId);
await clickNewTabButton(page);
await waitForLauncherPanel(page);
await createAgentChatFromLauncher(page);
} finally {
if (worktreeCreated) {
try {
execSync(`git worktree remove ${JSON.stringify(worktreePath)} --force`, {
cwd: repo.path,
stdio: "ignore",
});
} catch {
// Best-effort cleanup so test failures preserve the original error.
}
}
await client.close();
await repo.cleanup();
}
});
test("creates a terminal with correct CWD", async ({ page }) => {
test.setTimeout(90_000);
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("lifecycle-wt-shell-");
const worktreePath = path.join(
"/tmp",
`paseo-wt-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
const branchName = `lifecycle-wt-shell-${Date.now()}`;
let worktreeCreated = false;
try {
await seedProjectForWorkspaceSetup(client, repo.path);
execSync(`git worktree add ${JSON.stringify(worktreePath)} -b ${JSON.stringify(branchName)} main`, {
cwd: repo.path,
stdio: "ignore",
});
worktreeCreated = true;
const workspaceResult = await client.openProject(worktreePath);
if (!workspaceResult.workspace) {
throw new Error(workspaceResult.error ?? `Failed to open project ${worktreePath}`);
}
const workspaceId = String(workspaceResult.workspace.id);
await gotoWorkspace(page, workspaceId);
await clickNewTabButton(page);
await waitForLauncherPanel(page);
await createStandaloneTerminalFromLauncher(page);
await expectTerminalCwd(page, worktreePath);
} finally {
if (worktreeCreated) {
try {
execSync(`git worktree remove ${JSON.stringify(worktreePath)} --force`, {
cwd: repo.path,
stdio: "ignore",
});
} catch {
// Best-effort cleanup so test failures preserve the original error.
}
}
await client.close();
await repo.cleanup();
}
});
});
});

View File

@@ -78,6 +78,7 @@ import {
parseServerIdFromPathname,
parseHostAgentRouteFromPathname,
parseWorkspaceOpenIntent,
decodeWorkspaceIdFromPathSegment,
} from "@/utils/host-routes";
import { syncNavigationActiveWorkspace } from "@/stores/navigation-active-workspace-store";
@@ -602,8 +603,6 @@ function FaviconStatusSync() {
}
function RootStack() {
const storeReady = useStoreReady();
return (
<Stack
screenOptions={{
@@ -614,20 +613,32 @@ function RootStack() {
},
}}
>
<Stack.Protected guard={storeReady}>
<Stack.Screen name="welcome" />
<Stack.Screen name="settings" />
<Stack.Screen name="h/[serverId]/workspace/[workspaceId]" />
<Stack.Screen
name="h/[serverId]/agent/[agentId]"
options={{ gestureEnabled: false }}
/>
<Stack.Screen name="h/[serverId]/index" />
<Stack.Screen name="h/[serverId]/sessions" />
<Stack.Screen name="h/[serverId]/open-project" />
<Stack.Screen name="h/[serverId]/settings" />
<Stack.Screen name="pair-scan" />
</Stack.Protected>
<Stack.Screen name="welcome" />
<Stack.Screen name="settings" />
<Stack.Screen
name="h/[serverId]/workspace/[workspaceId]"
getId={({ params }) => {
const serverValue = Array.isArray(params?.serverId) ? params.serverId[0] : params?.serverId;
const workspaceValue = Array.isArray(params?.workspaceId)
? params.workspaceId[0]
: params?.workspaceId;
const serverId = typeof serverValue === "string" ? serverValue.trim() : "";
const workspaceId =
typeof workspaceValue === "string"
? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? workspaceValue.trim())
: "";
return `${serverId}:${workspaceId}`;
}}
/>
<Stack.Screen
name="h/[serverId]/agent/[agentId]"
options={{ gestureEnabled: false }}
/>
<Stack.Screen name="h/[serverId]/index" />
<Stack.Screen name="h/[serverId]/sessions" />
<Stack.Screen name="h/[serverId]/open-project" />
<Stack.Screen name="h/[serverId]/settings" />
<Stack.Screen name="pair-scan" />
<Stack.Screen name="index" />
</Stack>
);

View File

@@ -3,6 +3,7 @@ import { useLocalSearchParams, useRouter } from "expo-router";
import { useSessionStore } from "@/stores/session-store";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { buildHostRootRoute } from "@/utils/host-routes";
import { resolveHydratedWorkspaceId } from "@/utils/resolve-hydrated-workspace-id";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
export default function HostAgentReadyRoute() {
@@ -22,6 +23,21 @@ export default function HostAgentReadyRoute() {
}
return state.sessions[serverId]?.agents?.get(agentId)?.cwd ?? null;
});
const sessionWorkspaces = useSessionStore((state) =>
serverId ? state.sessions[serverId]?.workspaces : undefined,
);
const hasHydratedWorkspaces = useSessionStore((state) =>
serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false,
);
const resolvedWorkspaceId = useSessionStore((state) => {
if (!serverId || !agentId) {
return null;
}
return resolveHydratedWorkspaceId({
workspaces: state.sessions[serverId]?.workspaces?.values(),
path: state.sessions[serverId]?.agents?.get(agentId)?.cwd,
});
});
useEffect(() => {
if (redirectedRef.current) {
@@ -33,18 +49,17 @@ export default function HostAgentReadyRoute() {
return;
}
const normalizedCwd = agentCwd?.trim();
if (normalizedCwd) {
if (resolvedWorkspaceId) {
redirectedRef.current = true;
router.replace(
prepareWorkspaceTab({
serverId,
workspaceId: normalizedCwd,
workspaceId: resolvedWorkspaceId,
target: { kind: "agent", agentId },
}) as any,
);
}
}, [agentCwd, agentId, router, serverId]);
}, [agentId, resolvedWorkspaceId, router, serverId]);
useEffect(() => {
if (redirectedRef.current) {
@@ -53,14 +68,14 @@ export default function HostAgentReadyRoute() {
if (!serverId || !agentId) {
return;
}
if (agentCwd?.trim()) {
if (agentCwd?.trim() && !hasHydratedWorkspaces) {
return;
}
if (!client || !isConnected) {
redirectedRef.current = true;
router.replace(buildHostRootRoute(serverId) as any);
}
}, [agentCwd, agentId, client, isConnected, router, serverId]);
}, [agentCwd, agentId, client, hasHydratedWorkspaces, isConnected, router, serverId]);
useEffect(() => {
if (redirectedRef.current) {
@@ -78,12 +93,19 @@ export default function HostAgentReadyRoute() {
return;
}
const cwd = result?.agent?.cwd?.trim();
const workspaceId = resolveHydratedWorkspaceId({
workspaces: sessionWorkspaces?.values(),
path: cwd,
});
if (!workspaceId && !hasHydratedWorkspaces) {
return;
}
redirectedRef.current = true;
if (cwd) {
if (workspaceId) {
router.replace(
prepareWorkspaceTab({
serverId,
workspaceId: cwd,
workspaceId,
target: { kind: "agent", agentId },
}) as any,
);
@@ -102,7 +124,7 @@ export default function HostAgentReadyRoute() {
return () => {
cancelled = true;
};
}, [agentId, client, isConnected, router, serverId]);
}, [agentId, client, hasHydratedWorkspaces, isConnected, router, serverId, sessionWorkspaces]);
return null;
}

View File

@@ -3,15 +3,23 @@ import { useLocalSearchParams, usePathname, useRouter } from "expo-router";
import { useSessionStore } from "@/stores/session-store";
import { useFormPreferences } from "@/hooks/use-form-preferences";
import {
buildHostAgentDetailRoute,
buildHostOpenProjectRoute,
buildHostRootRoute,
buildHostWorkspaceOpenRoute,
buildHostWorkspaceRoute,
} from "@/utils/host-routes";
import { resolveHydratedWorkspaceId } from "@/utils/resolve-hydrated-workspace-id";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
const HOST_ROOT_REDIRECT_DELAY_MS = 300;
function getCurrentPathname(fallbackPathname: string): string {
if (typeof window === "undefined") {
return fallbackPathname;
}
return window.location.pathname || fallbackPathname;
}
export default function HostIndexRoute() {
const router = useRouter();
const pathname = usePathname();
@@ -33,11 +41,13 @@ export default function HostIndexRoute() {
return;
}
const rootRoute = buildHostRootRoute(serverId);
if (pathname !== rootRoute && pathname !== `${rootRoute}/`) {
const currentPathname = getCurrentPathname(pathname);
if (currentPathname !== rootRoute && currentPathname !== `${rootRoute}/`) {
return;
}
const timer = setTimeout(() => {
if (pathname !== rootRoute && pathname !== `${rootRoute}/`) {
const latestPathname = getCurrentPathname(pathname);
if (latestPathname !== rootRoute && latestPathname !== `${rootRoute}/`) {
return;
}
@@ -56,16 +66,24 @@ export default function HostIndexRoute() {
});
const primaryAgent = visibleAgents[0];
if (primaryAgent?.cwd?.trim()) {
const primaryAgentWorkspaceId = resolveHydratedWorkspaceId({
workspaces: sessionWorkspaces?.values(),
path: primaryAgent?.cwd,
});
if (primaryAgent && primaryAgentWorkspaceId) {
router.replace(
prepareWorkspaceTab({
serverId,
workspaceId: primaryAgent.cwd.trim(),
workspaceId: primaryAgentWorkspaceId,
target: { kind: "agent", agentId: primaryAgent.id },
}) as any,
);
return;
}
if (primaryAgent) {
router.replace(buildHostAgentDetailRoute(serverId, primaryAgent.id) as any);
return;
}
const primaryWorkspace = visibleWorkspaces[0];
if (primaryWorkspace?.id?.trim()) {

View File

@@ -1,10 +1,10 @@
import { useEffect, useRef } from "react";
import { useGlobalSearchParams, useLocalSearchParams, useRouter } from "expo-router";
import { useGlobalSearchParams, usePathname, useRouter } from "expo-router";
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
import {
buildHostWorkspaceRoute,
decodeWorkspaceIdFromPathSegment,
parseHostWorkspaceRouteFromPathname,
parseWorkspaceOpenIntent,
type WorkspaceOpenIntent,
} from "@/utils/host-routes";
@@ -37,18 +37,13 @@ function getOpenIntentTarget(openIntent: WorkspaceOpenIntent): WorkspaceTabTarge
export default function HostWorkspaceLayout() {
const router = useRouter();
const consumedIntentRef = useRef<string | null>(null);
const params = useLocalSearchParams<{
serverId?: string | string[];
workspaceId?: string | string[];
}>();
const pathname = usePathname();
const globalParams = useGlobalSearchParams<{
open?: string | string[];
}>();
const serverId = getParamValue(params.serverId);
const workspaceValue = getParamValue(params.workspaceId);
const workspaceId = workspaceValue
? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "")
: "";
const parsedWorkspaceRoute = parseHostWorkspaceRouteFromPathname(pathname);
const serverId = parsedWorkspaceRoute?.serverId ?? "";
const workspaceId = parsedWorkspaceRoute?.workspaceId ?? "";
const openValue = getParamValue(globalParams.open);
useEffect(() => {

View File

@@ -14,6 +14,13 @@ import { buildHostRootRoute } from "@/utils/host-routes";
const WELCOME_ROUTE = "/welcome";
function getCurrentPathname(fallbackPathname: string): string {
if (typeof window === "undefined") {
return fallbackPathname;
}
return window.location.pathname || fallbackPathname;
}
function useAnyOnlineHostServerId(serverIds: string[]): string | null {
const runtime = getHostRuntimeStore();
@@ -51,7 +58,8 @@ export default function Index() {
if (!storeReady) {
return;
}
if (pathname !== "/" && pathname !== "") {
const currentPathname = getCurrentPathname(pathname);
if (currentPathname !== "/" && currentPathname !== "") {
return;
}

View File

@@ -17,6 +17,8 @@ import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useSessionStore } from "@/stores/session-store";
import { Archive, SquareTerminal } from "lucide-react-native";
import { getProviderIcon } from "@/components/provider-icons";
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
import { resolveHydratedWorkspaceId } from "@/utils/resolve-hydrated-workspace-id";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
interface AgentListProps {
@@ -242,12 +244,21 @@ export function AgentList({
const serverId = agent.serverId;
const agentId = agent.id;
const workspaceId = resolveHydratedWorkspaceId({
workspaces: useSessionStore.getState().sessions[serverId]?.workspaces?.values(),
path: agent.cwd,
});
onAgentSelect?.();
if (!workspaceId) {
router.navigate(buildHostAgentDetailRoute(serverId, agentId) as any);
return;
}
const route = prepareWorkspaceTab({
serverId,
workspaceId: agent.cwd,
workspaceId,
target: { kind: "agent", agentId },
pin: Boolean(agent.archivedAt),
requestReopen: agent.terminal && agent.status === "closed",

View File

@@ -63,6 +63,7 @@ import { createMarkdownStyles } from "@/styles/markdown-styles";
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import { getMarkdownListMarker } from "@/utils/markdown-list";
import { normalizeInlinePathTarget } from "@/utils/inline-path";
import { resolveHydratedWorkspaceId } from "@/utils/resolve-hydrated-workspace-id";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import { useStableEvent } from "@/hooks/use-stable-event";
import {
@@ -132,10 +133,14 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
);
const workspaceRoot = agent.cwd?.trim() || "";
const workspaceId = agent.projectPlacement?.checkout?.cwd?.trim() || workspaceRoot;
const workspacePath = agent.projectPlacement?.checkout?.cwd?.trim() || workspaceRoot;
const workspaceId = resolveHydratedWorkspaceId({
workspaces: useSessionStore.getState().sessions[resolvedServerId]?.workspaces?.values(),
path: workspacePath,
});
const { requestDirectoryListing } = useFileExplorerActions({
serverId: resolvedServerId,
workspaceId,
workspaceId: workspaceId ?? undefined,
workspaceRoot,
});
const openWorkspaceFile = useStableEvent(function openWorkspaceFile(input: {
@@ -175,12 +180,14 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
return;
}
const route = prepareWorkspaceTab({
serverId: resolvedServerId,
workspaceId,
target: { kind: "file", path: normalized.file },
});
router.navigate(route as any);
if (workspaceId) {
const route = prepareWorkspaceTab({
serverId: resolvedServerId,
workspaceId,
target: { kind: "file", path: normalized.file },
});
router.navigate(route as any);
}
return;
}

View File

@@ -40,9 +40,9 @@ export function ProjectPickerModal() {
const recommendedPaths = useMemo(() => {
if (!workspaces) return [];
return Array.from(workspaces.values()).map(
(workspace) => workspace.projectRootPath || workspace.id,
);
return Array.from(workspaces.values())
.map((workspace) => workspace.projectRootPath)
.filter((path) => path.length > 0);
}, [workspaces]);
const directorySuggestionsQuery = useQuery({

View File

@@ -817,8 +817,8 @@ function WorkspaceRowInner({
const isMobile = Platform.OS !== "web";
const prHint = useWorkspacePrHint({
serverId: workspace.serverId,
cwd: workspace.workspaceId,
enabled: workspace.projectKind === "git",
cwd: workspace.projectRootPath ?? "",
enabled: workspace.projectKind === "git" && Boolean(workspace.projectRootPath),
});
const interaction = useLongPressDragInteraction({
drag,
@@ -985,7 +985,7 @@ function WorkspaceRowWithMenu({
const archiveStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({
serverId: workspace.serverId,
cwd: workspace.workspaceId,
cwd: workspace.workspaceDirectory ?? workspace.projectRootPath ?? "",
actionId: "archive-worktree",
}),
);
@@ -1025,11 +1025,16 @@ function WorkspaceRowWithMenu({
if (!confirmed) {
return;
}
const workspaceDirectory = workspace.workspaceDirectory ?? workspace.projectRootPath;
if (!workspaceDirectory) {
toast.error("Workspace path not available");
return;
}
void archiveWorktree({
serverId: workspace.serverId,
cwd: workspace.workspaceId,
worktreePath: workspace.workspaceId,
cwd: workspaceDirectory,
worktreePath: workspaceDirectory,
})
.then(() => {
redirectAfterArchive();
@@ -1045,6 +1050,8 @@ function WorkspaceRowWithMenu({
redirectAfterArchive,
toast,
workspace.name,
workspace.projectRootPath,
workspace.workspaceDirectory,
workspace.serverId,
workspace.workspaceId,
]);
@@ -1095,9 +1102,14 @@ function WorkspaceRowWithMenu({
]);
const handleCopyPath = useCallback(() => {
void Clipboard.setStringAsync(workspace.workspaceId);
const workspaceDirectory = workspace.workspaceDirectory ?? workspace.projectRootPath;
if (!workspaceDirectory) {
toast.error("Workspace path not available");
return;
}
void Clipboard.setStringAsync(workspaceDirectory);
toast.copied("Path copied");
}, [toast, workspace.workspaceId]);
}, [toast, workspace.projectRootPath, workspace.workspaceDirectory]);
const handleCopyBranchName = useCallback(() => {
void Clipboard.setStringAsync(workspace.name);

View File

@@ -244,6 +244,11 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
);
useEffect(() => {
const currentPathname =
typeof window === "undefined" ? null : (window.location.pathname || null);
if (currentPathname && currentPathname !== "/welcome") {
return;
}
if (!anyOnlineServerId) {
return;
}

View File

@@ -53,7 +53,7 @@ export function WorkspaceSetupDialog() {
initialValues: projectPath ? { workingDir: projectPath } : undefined,
isVisible: pendingWorkspaceSetup !== null,
onlineServerIds: isConnected && serverId ? [serverId] : [],
lockedWorkingDir: workspace?.id ?? projectPath,
lockedWorkingDir: workspace?.projectRootPath ?? projectPath,
},
});
const composerState = chatDraft.composerState;
@@ -170,9 +170,10 @@ export function WorkspaceSetupDialog() {
}
const encodedImages = await encodeImages(images);
const workspaceDirectory = workspace.projectRootPath ?? projectPath;
const agent = await connectedClient.createAgent({
provider: composerState.selectedProvider,
cwd: workspace.id,
cwd: workspaceDirectory,
...(composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
? { modeId: composerState.selectedMode }
: {}),
@@ -226,9 +227,10 @@ export function WorkspaceSetupDialog() {
throw new Error("Workspace setup composer state is required");
}
const workspaceDirectory = workspace.projectRootPath ?? projectPath;
const agent = await connectedClient.createAgent({
provider: composerState.selectedProvider,
cwd: workspace.id,
cwd: workspaceDirectory,
terminal: true,
...(terminalPrompt.trim() ? { initialPrompt: terminalPrompt.trim() } : {}),
});
@@ -272,8 +274,13 @@ export function WorkspaceSetupDialog() {
setErrorMessage(null);
const workspace = await ensureWorkspace();
const connectedClient = withConnectedClient();
const workspaceDirectory = workspace.projectRootPath ?? projectPath;
const payload = await connectedClient.createTerminal(workspace.id);
if (!workspaceDirectory) {
throw new Error("Workspace directory not found");
}
const payload = await connectedClient.createTerminal(workspaceDirectory);
if (payload.error || !payload.terminal) {
throw new Error(payload.error ?? "Failed to open terminal");
}

View File

@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { TextInput } from "react-native";
import { router, usePathname, type Href } from "expo-router";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { useSessionStore } from "@/stores/session-store";
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
import { useHosts } from "@/runtime/host-runtime";
import { useAllAgentsList } from "@/hooks/use-all-agents-list";
@@ -11,13 +12,18 @@ import {
clearCommandCenterFocusRestoreElement,
takeCommandCenterFocusRestoreElement,
} from "@/utils/command-center-focus-restore";
import { buildHostSettingsRoute, parseServerIdFromPathname } from "@/utils/host-routes";
import {
buildHostAgentDetailRoute,
buildHostSettingsRoute,
parseServerIdFromPathname,
} from "@/utils/host-routes";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { chordStringToShortcutKeys } from "@/keyboard/shortcut-string";
import { getBindingIdForAction, getDefaultKeysForAction } from "@/keyboard/keyboard-shortcuts";
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
import { getShortcutOs } from "@/utils/shortcut-platform";
import { getIsDesktop } from "@/constants/layout";
import { resolveHydratedWorkspaceId } from "@/utils/resolve-hydrated-workspace-id";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import { focusWithRetries } from "@/utils/web-focus";
@@ -215,9 +221,17 @@ export function useCommandCenter() {
// Don't restore focus back to the prior element after we navigate.
clearCommandCenterFocusRestoreElement();
setOpen(false);
const workspaceId = resolveHydratedWorkspaceId({
workspaces: useSessionStore.getState().sessions[agent.serverId]?.workspaces?.values(),
path: agent.cwd,
});
if (!workspaceId) {
router.navigate(buildHostAgentDetailRoute(agent.serverId, agent.id) as any);
return;
}
const route = prepareWorkspaceTab({
serverId: agent.serverId,
workspaceId: agent.cwd,
workspaceId,
target: { kind: "agent", agentId: agent.id },
});
router.navigate(route as any);

View File

@@ -69,6 +69,7 @@ describe("openProjectDirectly", () => {
projectId: 1,
projectDisplayName: "project",
projectRootPath: WORKSPACE_ID,
workspaceDirectory: WORKSPACE_ID,
projectKind: "git" as const,
workspaceKind: "checkout" as const,
name: "project",
@@ -87,7 +88,12 @@ describe("openProjectDirectly", () => {
expect(result).toBe(true);
expect(useSessionStore.getState().sessions[SERVER_ID]?.hasHydratedWorkspaces).toBe(true);
expect(Array.from(useSessionStore.getState().sessions[SERVER_ID]?.workspaces.values() ?? [])).toEqual([
expect.objectContaining({ id: "1", projectId: "1", projectRootPath: WORKSPACE_ID }),
expect.objectContaining({
id: "1",
projectId: "1",
projectRootPath: WORKSPACE_ID,
workspaceDirectory: WORKSPACE_ID,
}),
]);
const workspaceKey = buildWorkspaceTabPersistenceKey({

View File

@@ -19,7 +19,11 @@ function workspace(
Partial<
Pick<
WorkspaceDescriptor,
"projectDisplayName" | "projectRootPath" | "projectKind" | "workspaceKind"
| "projectDisplayName"
| "projectRootPath"
| "workspaceDirectory"
| "projectKind"
| "workspaceKind"
>
>,
): WorkspaceDescriptor {
@@ -28,6 +32,7 @@ function workspace(
projectId: input.projectId,
projectDisplayName: input.projectDisplayName ?? input.projectId,
projectRootPath: input.projectRootPath ?? input.id,
workspaceDirectory: input.workspaceDirectory ?? input.projectRootPath ?? input.id,
projectKind: input.projectKind ?? "git",
workspaceKind: input.workspaceKind ?? "checkout",
name: input.name,

View File

@@ -15,6 +15,8 @@ export interface SidebarWorkspaceEntry {
workspaceKey: string;
serverId: string;
workspaceId: string;
projectRootPath?: string;
workspaceDirectory?: string;
projectKind: WorkspaceDescriptor["projectKind"];
workspaceKind: WorkspaceDescriptor["workspaceKind"];
name: string;
@@ -119,7 +121,7 @@ export function buildSidebarProjectsFromWorkspaces(input: {
projectName:
workspace.projectDisplayName || projectDisplayNameFromProjectId(workspace.projectId),
projectKind: workspace.projectKind,
iconWorkingDir: workspace.projectRootPath || workspace.id,
iconWorkingDir: workspace.projectRootPath,
statusBucket: "done",
activeCount: 0,
totalWorkspaces: 0,
@@ -131,6 +133,8 @@ export function buildSidebarProjectsFromWorkspaces(input: {
workspaceKey: `${input.serverId}:${workspace.id}`,
serverId: input.serverId,
workspaceId: workspace.id,
projectRootPath: workspace.projectRootPath,
workspaceDirectory: workspace.workspaceDirectory,
projectKind: workspace.projectKind,
workspaceKind: workspace.workspaceKind,
name: workspace.name,
@@ -254,6 +258,7 @@ function toWorkspaceDescriptor(payload: {
projectId: number;
projectDisplayName: string;
projectRootPath: string;
workspaceDirectory: string;
projectKind: WorkspaceDescriptor["projectKind"];
workspaceKind: WorkspaceDescriptor["workspaceKind"];
name: string;

View File

@@ -36,6 +36,11 @@ function LauncherPanel() {
const { serverId, workspaceId, target, retargetCurrentTab, isPaneFocused } = usePaneContext();
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const workspaceDirectory = useSessionStore(
(state) =>
state.sessions[serverId]?.workspaces.get(workspaceId)?.workspaceDirectory ??
state.sessions[serverId]?.workspaces.get(workspaceId)?.projectRootPath,
);
const { providers, recordUsage } = useProviderRecency();
const setAgents = useSessionStore((state) => state.setAgents);
const [pendingAction, setPendingAction] = useState<string | null>(null);
@@ -53,8 +58,8 @@ function LauncherPanel() {
const launchTerminalAgent = useCallback(
async (providerId: AgentProvider) => {
if (!client || !isConnected) {
setErrorMessage("Host is not connected");
if (!client || !isConnected || !workspaceDirectory) {
setErrorMessage(!workspaceDirectory ? "Workspace directory not found" : "Host is not connected");
return;
}
@@ -64,7 +69,7 @@ function LauncherPanel() {
try {
const agent = await client.createAgent({
provider: providerId,
cwd: workspaceId,
cwd: workspaceDirectory,
terminal: true,
});
recordUsage(providerId);
@@ -82,7 +87,7 @@ function LauncherPanel() {
setPendingAction((current) => (current === providerId ? null : current));
}
},
[client, isConnected, recordUsage, retargetCurrentTab, serverId, setAgents, workspaceId],
[client, isConnected, recordUsage, retargetCurrentTab, serverId, setAgents, workspaceDirectory],
);
const openDraftTab = useCallback(() => {
@@ -96,8 +101,8 @@ function LauncherPanel() {
}, [retargetCurrentTab]);
const openTerminalTab = useCallback(async () => {
if (!client || !isConnected) {
setErrorMessage("Host is not connected");
if (!client || !isConnected || !workspaceDirectory) {
setErrorMessage(!workspaceDirectory ? "Workspace directory not found" : "Host is not connected");
return;
}
@@ -105,7 +110,7 @@ function LauncherPanel() {
setErrorMessage(null);
try {
const payload = await client.createTerminal(workspaceId);
const payload = await client.createTerminal(workspaceDirectory);
if (payload.error || !payload.terminal) {
throw new Error(payload.error ?? "Failed to open terminal");
}
@@ -118,10 +123,20 @@ function LauncherPanel() {
} finally {
setPendingAction((current) => (current === "terminal" ? null : current));
}
}, [client, isConnected, retargetCurrentTab, workspaceId]);
}, [client, isConnected, retargetCurrentTab, workspaceDirectory]);
const actionsDisabled = pendingAction !== null;
if (!workspaceDirectory) {
return (
<View style={styles.container}>
<View style={[styles.content, styles.loadingContent]}>
<ActivityIndicator />
</View>
</View>
);
}
return (
<View style={styles.container}>
<ScrollView
@@ -339,6 +354,9 @@ const styles = StyleSheet.create((theme) => ({
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[8],
},
loadingContent: {
flex: 1,
},
contentUnfocused: {
opacity: 0.96,
},

View File

@@ -24,14 +24,20 @@ function useTerminalPanelDescriptor(
context: { serverId: string; workspaceId: string },
): PanelDescriptor {
const client = useSessionStore((state) => state.sessions[context.serverId]?.client ?? null);
const workspaceDirectory = useSessionStore(
(state) =>
state.sessions[context.serverId]?.workspaces.get(context.workspaceId)?.workspaceDirectory ??
state.sessions[context.serverId]?.workspaces.get(context.workspaceId)?.projectRootPath ??
null,
);
const terminalsQuery = useQuery({
queryKey: ["terminals", context.serverId, context.workspaceId] as const,
enabled: Boolean(client && context.workspaceId),
queryKey: ["terminals", context.serverId, workspaceDirectory] as const,
enabled: Boolean(client && workspaceDirectory),
queryFn: async (): Promise<ListTerminalsPayload> => {
if (!client) {
return { cwd: context.workspaceId, terminals: [], requestId: "missing-client" };
if (!client || !workspaceDirectory) {
return { cwd: workspaceDirectory ?? "", terminals: [], requestId: "missing-client" };
}
return client.listTerminals(context.workspaceId);
return client.listTerminals(workspaceDirectory);
},
staleTime: 5_000,
});
@@ -50,16 +56,22 @@ function useTerminalPanelDescriptor(
function TerminalPanel() {
const isFocused = useIsFocused();
const { serverId, workspaceId, target, isPaneFocused } = usePaneContext();
const workspaceDirectory = useSessionStore(
(state) =>
state.sessions[serverId]?.workspaces.get(workspaceId)?.workspaceDirectory ??
state.sessions[serverId]?.workspaces.get(workspaceId)?.projectRootPath ??
null,
);
invariant(target.kind === "terminal", "TerminalPanel requires terminal target");
if (!isFocused) {
if (!isFocused || !workspaceDirectory) {
return <View style={{ flex: 1 }} />;
}
return (
<TerminalPane
serverId={serverId}
cwd={workspaceId}
cwd={workspaceDirectory}
terminalId={target.terminalId}
isPaneFocused={isPaneFocused}
/>

View File

@@ -26,6 +26,7 @@ import {
import { useAllAgentsList } from "@/hooks/use-all-agents-list";
import { useHosts } from "@/runtime/host-runtime";
import { buildBranchComboOptions, normalizeBranchOptionName } from "@/utils/branch-suggestions";
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
import { shortenPath } from "@/utils/shorten-path";
import { collectAgentWorkingDirectorySuggestions } from "@/utils/agent-working-directory-suggestions";
import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions";
@@ -49,6 +50,7 @@ import type {
AgentSessionConfig,
} from "@server/server/agent/agent-sdk-types";
import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest";
import { resolveHydratedWorkspaceId } from "@/utils/resolve-hydrated-workspace-id";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import { useDesktopDragHandlers } from "@/utils/desktop-window";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
@@ -750,7 +752,7 @@ function DraftAgentScreenContent({
optimisticStreamItems,
draftAgent,
handleCreateFromInput,
} = useDraftAgentCreateFlow<Agent, { id: string; cwd: string }>({
} = useDraftAgentCreateFlow<Agent, { id: string; workspaceId: string | null }>({
draftId: draftIdRef.current,
getPendingServerId: () => selectedServerId,
validateBeforeSubmit: ({ text }) => {
@@ -908,20 +910,29 @@ function DraftAgentScreenContent({
const createdWorkingDir = typeof result.cwd === "string" ? result.cwd.trim() : "";
const configuredWorkingDir = config.cwd.trim();
const workspaceId = createdWorkingDir.length > 0 ? createdWorkingDir : configuredWorkingDir;
const workspaceId = resolveHydratedWorkspaceId({
workspaces: useSessionStore.getState().sessions[selectedServerId]?.workspaces?.values(),
path: createdWorkingDir.length > 0 ? createdWorkingDir : configuredWorkingDir,
});
return {
agentId: result.id,
result: {
id: result.id,
cwd: workspaceId,
workspaceId,
},
};
},
onCreateSuccess: ({ result }) => {
if (!result.workspaceId) {
router.replace(
buildHostAgentDetailRoute(selectedServerId as string, result.id) as any,
);
return;
}
const route = prepareWorkspaceTab({
serverId: selectedServerId as string,
workspaceId: result.cwd,
workspaceId: result.workspaceId,
target: { kind: "agent", agentId: result.id },
});
router.replace(route as any);

View File

@@ -81,7 +81,7 @@ describe("workspace agent visibility", () => {
const result = deriveWorkspaceAgentVisibility({
sessionAgents,
workspaceId,
workspaceDirectory: workspaceId,
});
expect(result.activeAgentIds).toEqual(new Set(["visible-agent"]));
@@ -142,13 +142,33 @@ describe("workspace agent visibility", () => {
const result = deriveWorkspaceAgentVisibility({
sessionAgents,
workspaceId: "/Users/moboudra/.paseo/worktrees/1luy0po7/normal-squid",
workspaceDirectory: "/Users/moboudra/.paseo/worktrees/1luy0po7/normal-squid",
});
expect(result.activeAgentIds).toEqual(new Set(["slash-agent"]));
expect(result.knownAgentIds.has("slash-agent")).toBe(true);
});
it("matches workspace agents using the workspace directory even when the route uses a numeric workspace id", () => {
const sessionAgents = new Map<string, Agent>([
[
"terminal-agent",
makeAgent({
id: "terminal-agent",
cwd: "/tmp/workspace-lifecycle-main",
}),
],
]);
const result = deriveWorkspaceAgentVisibility({
sessionAgents,
workspaceDirectory: "/tmp/workspace-lifecycle-main",
});
expect(result.activeAgentIds).toEqual(new Set(["terminal-agent"]));
expect(result.knownAgentIds).toEqual(new Set(["terminal-agent"]));
});
describe("workspaceAgentVisibilityEqual", () => {
it("returns true for identical sets", () => {
const a = { activeAgentIds: new Set(["a", "b"]), knownAgentIds: new Set(["a", "b", "c"]) };

View File

@@ -12,11 +12,11 @@ export interface WorkspaceAgentVisibility {
export function deriveWorkspaceAgentVisibility(input: {
sessionAgents: Map<string, Agent> | undefined;
workspaceId: string;
workspaceDirectory: string | null | undefined;
}): WorkspaceAgentVisibility {
const { sessionAgents, workspaceId } = input;
const normalizedWorkspaceId = normalizeWorkspaceId(workspaceId);
if (!sessionAgents || !workspaceId) {
const { sessionAgents, workspaceDirectory } = input;
const normalizedWorkspaceDirectory = normalizeWorkspaceId(workspaceDirectory);
if (!sessionAgents || !normalizedWorkspaceDirectory) {
return {
activeAgentIds: new Set<string>(),
knownAgentIds: new Set<string>(),
@@ -26,7 +26,7 @@ export function deriveWorkspaceAgentVisibility(input: {
const activeAgentIds = new Set<string>();
const knownAgentIds = new Set<string>();
for (const agent of sessionAgents.values()) {
if (normalizeWorkspaceId(agent.cwd) !== normalizedWorkspaceId) {
if (normalizeWorkspaceId(agent.cwd) !== normalizedWorkspaceDirectory) {
continue;
}
knownAgentIds.add(agent.id);

View File

@@ -1,6 +1,7 @@
import { useCallback, useMemo, useRef } from "react";
import { Keyboard, Platform, ScrollView, Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import invariant from "tiny-invariant";
import { Composer } from "@/components/composer";
import { FileDropZone } from "@/components/file-drop-zone";
import { AgentStreamView } from "@/components/agent-stream-view";
@@ -10,7 +11,7 @@ import { useDraftAgentCreateFlow } from "@/hooks/use-draft-agent-create-flow";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { buildWorkspaceDraftAgentConfig } from "@/screens/workspace/workspace-draft-agent-config";
import { buildDraftStoreKey } from "@/stores/draft-keys";
import type { Agent } from "@/stores/session-store";
import { type Agent, useSessionStore } from "@/stores/session-store";
import { encodeImages } from "@/utils/encode-images";
import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus";
import type { AgentCapabilityFlags } from "@server/server/agent/agent-sdk-types";
@@ -48,6 +49,11 @@ export function WorkspaceDraftAgentTab({
}: WorkspaceDraftAgentTabProps) {
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const workspaceDirectory = useSessionStore(
(state) =>
state.sessions[serverId]?.workspaces.get(workspaceId)?.workspaceDirectory ??
state.sessions[serverId]?.workspaces.get(workspaceId)?.projectRootPath,
);
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null);
const draftStoreKey = useMemo(
() =>
@@ -63,10 +69,10 @@ export function WorkspaceDraftAgentTab({
draftKey: draftStoreKey,
composer: {
initialServerId: serverId,
initialValues: { workingDir: workspaceId },
initialValues: { workingDir: workspaceDirectory },
isVisible: true,
onlineServerIds: isConnected ? [serverId] : [],
lockedWorkingDir: workspaceId,
lockedWorkingDir: workspaceDirectory,
},
},
);
@@ -97,6 +103,9 @@ export function WorkspaceDraftAgentTab({
if (!composerState.effectiveModelId) {
return "No model is available for the selected provider";
}
if (!workspaceDirectory) {
return "Workspace directory not found";
}
if (!client) {
return "Host is not connected";
}
@@ -110,6 +119,7 @@ export function WorkspaceDraftAgentTab({
Keyboard.dismiss();
},
buildDraftAgent: (attempt) => {
invariant(workspaceDirectory, "Workspace directory is required");
const now = attempt.timestamp;
const model = composerState.effectiveModelId || null;
const thinkingOptionId = composerState.effectiveThinkingOptionId || null;
@@ -134,20 +144,21 @@ export function WorkspaceDraftAgentTab({
persistence: null,
runtimeInfo: { provider: composerState.selectedProvider, sessionId: null, model, modeId },
title: "Agent",
cwd: workspaceId,
cwd: workspaceDirectory,
model,
thinkingOptionId,
labels: {},
};
},
createRequest: async ({ attempt, text, images }) => {
invariant(workspaceDirectory, "Workspace directory is required");
if (!client) {
throw new Error("Host is not connected");
}
const config = buildWorkspaceDraftAgentConfig({
provider: composerState.selectedProvider,
cwd: workspaceId,
cwd: workspaceDirectory,
...(composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
? { modeId: composerState.selectedMode }
: {}),

View File

@@ -1,5 +1,4 @@
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { projectDisplayNameFromProjectId } from "@/utils/project-display-name";
export function resolveWorkspaceHeader(input: { workspace: WorkspaceDescriptor }): {
title: string;
@@ -7,7 +6,7 @@ export function resolveWorkspaceHeader(input: { workspace: WorkspaceDescriptor }
} {
return {
title: input.workspace.name,
subtitle: projectDisplayNameFromProjectId(input.workspace.projectId),
subtitle: input.workspace.projectDisplayName,
};
}

View File

@@ -554,8 +554,24 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
const normalizedServerId = trimNonEmpty(decodeSegment(serverId)) ?? "";
const normalizedWorkspaceId =
const rawWorkspaceIdentifier =
normalizeWorkspaceIdentity(decodeWorkspaceIdFromPathSegment(workspaceId)) ?? "";
// Resolve the workspace ID: first try direct map key, then fall back to path-based lookup.
// This lets URLs that encode a filesystem path (e.g. from deep links or older bookmarks)
// resolve correctly even though the map is keyed by numeric DB IDs.
const normalizedWorkspaceId = useSessionStore((state) => {
if (!normalizedServerId || !rawWorkspaceIdentifier) return "";
const workspaces = state.sessions[normalizedServerId]?.workspaces;
if (!workspaces) return rawWorkspaceIdentifier;
if (workspaces.has(rawWorkspaceIdentifier)) return rawWorkspaceIdentifier;
for (const [id, ws] of workspaces.entries()) {
if (normalizeWorkspaceIdentity(ws.workspaceDirectory) === rawWorkspaceIdentifier) return id;
if (normalizeWorkspaceIdentity(ws.projectRootPath) === rawWorkspaceIdentifier) return id;
}
return rawWorkspaceIdentifier;
});
const workspaceTerminalScopeKey =
normalizedServerId && normalizedWorkspaceId
? `${normalizedServerId}:${normalizedWorkspaceId}`
@@ -567,43 +583,47 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
const queryClient = useQueryClient();
const client = useHostRuntimeClient(normalizedServerId);
const isConnected = useHostRuntimeIsConnected(normalizedServerId);
const workspaceDescriptor = useSessionStore(
(state) => state.sessions[normalizedServerId]?.workspaces.get(normalizedWorkspaceId) ?? null,
);
const workspaceDirectory =
workspaceDescriptor?.workspaceDirectory ?? workspaceDescriptor?.projectRootPath ?? null;
const workspaceAgentVisibility = useStoreWithEqualityFn(
useSessionStore,
(state) =>
deriveWorkspaceAgentVisibility({
sessionAgents: state.sessions[normalizedServerId]?.agents,
workspaceId: normalizedWorkspaceId,
workspaceDirectory,
}),
workspaceAgentVisibilityEqual,
);
const terminalsQueryKey = useMemo(
() => ["terminals", normalizedServerId, normalizedWorkspaceId] as const,
[normalizedServerId, normalizedWorkspaceId],
() => ["terminals", normalizedServerId, workspaceDirectory] as const,
[normalizedServerId, workspaceDirectory],
);
type ListTerminalsPayload = ListTerminalsResponse["payload"];
const terminalsQuery = useQuery({
queryKey: terminalsQueryKey,
enabled:
Boolean(client && isConnected) &&
normalizedWorkspaceId.length > 0 &&
normalizedWorkspaceId.startsWith("/"),
Boolean(workspaceDirectory),
queryFn: async () => {
if (!client) {
if (!client || !workspaceDirectory) {
throw new Error("Host is not connected");
}
return await client.listTerminals(normalizedWorkspaceId);
return await client.listTerminals(workspaceDirectory);
},
staleTime: TERMINALS_QUERY_STALE_TIME,
});
const terminals = terminalsQuery.data?.terminals ?? [];
const createTerminalMutation = useMutation({
mutationFn: async (input?: { paneId?: string }) => {
if (!client) {
if (!client || !workspaceDirectory) {
throw new Error("Host is not connected");
}
return await client.createTerminal(normalizedWorkspaceId);
return await client.createTerminal(workspaceDirectory);
},
onSuccess: (payload, input) => {
const createdTerminal = payload.terminal;
@@ -613,8 +633,9 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
terminals: current?.terminals ?? [],
terminal: createdTerminal,
});
const cwd = current?.cwd ?? workspaceDirectory ?? undefined;
return {
cwd: current?.cwd ?? normalizedWorkspaceId,
...(cwd ? { cwd } : {}),
terminals: nextTerminals,
requestId: current?.requestId ?? `terminal-create-${createdTerminal.id}`,
};
@@ -657,7 +678,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
const { archiveAgent } = useArchiveAgent();
useEffect(() => {
if (!client || !isConnected || !normalizedWorkspaceId.startsWith("/")) {
if (!client || !isConnected || !workspaceDirectory) {
return;
}
@@ -665,7 +686,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
if (message.type !== "terminals_changed") {
return;
}
if (message.payload.cwd !== normalizedWorkspaceId) {
if (message.payload.cwd !== workspaceDirectory) {
return;
}
@@ -676,32 +697,27 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
}));
});
client.subscribeTerminals({ cwd: normalizedWorkspaceId });
client.subscribeTerminals({ cwd: workspaceDirectory });
return () => {
unsubscribeChanged();
client.unsubscribeTerminals({ cwd: normalizedWorkspaceId });
client.unsubscribeTerminals({ cwd: workspaceDirectory });
};
}, [client, isConnected, normalizedWorkspaceId, queryClient, terminalsQueryKey]);
}, [client, isConnected, queryClient, terminalsQueryKey, workspaceDirectory]);
const checkoutQuery = useQuery({
queryKey: checkoutStatusQueryKey(normalizedServerId, normalizedWorkspaceId),
queryKey: checkoutStatusQueryKey(normalizedServerId, workspaceDirectory ?? ""),
enabled:
Boolean(client && isConnected) &&
normalizedWorkspaceId.length > 0 &&
normalizedWorkspaceId.startsWith("/"),
Boolean(workspaceDirectory),
queryFn: async () => {
if (!client) {
if (!client || !workspaceDirectory) {
throw new Error("Host is not connected");
}
return (await client.getCheckoutStatus(normalizedWorkspaceId)) as CheckoutStatusPayload;
return (await client.getCheckoutStatus(workspaceDirectory)) as CheckoutStatusPayload;
},
staleTime: 15_000,
});
const workspaceDescriptor = useSessionStore(
(state) => state.sessions[normalizedServerId]?.workspaces.get(normalizedWorkspaceId) ?? null,
);
const hasHydratedWorkspaces = useSessionStore(
(state) => state.sessions[normalizedServerId]?.hasHydratedWorkspaces ?? false,
);
@@ -731,15 +747,15 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
const isExplorerOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
const activeExplorerCheckout = useMemo<ExplorerCheckoutContext | null>(() => {
if (!normalizedServerId || !normalizedWorkspaceId.startsWith("/")) {
if (!normalizedServerId || !workspaceDirectory) {
return null;
}
return {
serverId: normalizedServerId,
cwd: normalizedWorkspaceId,
cwd: workspaceDirectory,
isGit: isGitCheckout,
};
}, [isGitCheckout, normalizedServerId, normalizedWorkspaceId]);
}, [isGitCheckout, normalizedServerId, workspaceDirectory]);
useEffect(() => {
setActiveExplorerCheckout(activeExplorerCheckout);
@@ -1081,12 +1097,12 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
if (createTerminalMutation.isPending) {
return;
}
if (!normalizedWorkspaceId.startsWith("/")) {
if (!workspaceDirectory) {
return;
}
createTerminalMutation.mutate(input);
},
[createTerminalMutation, normalizedWorkspaceId],
[createTerminalMutation, workspaceDirectory],
);
const handleSelectSwitcherTab = useCallback(
@@ -1270,18 +1286,18 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
);
const handleCopyWorkspacePath = useCallback(async () => {
if (!normalizedWorkspaceId.startsWith("/")) {
if (!workspaceDirectory) {
toast.error("Workspace path not available");
return;
}
try {
await Clipboard.setStringAsync(normalizedWorkspaceId);
await Clipboard.setStringAsync(workspaceDirectory);
toast.copied("Workspace path");
} catch {
toast.error("Copy failed");
}
}, [normalizedWorkspaceId, toast]);
}, [toast, workspaceDirectory]);
const handleCopyBranchName = useCallback(async () => {
if (!currentBranchName) {
@@ -1927,7 +1943,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
<DropdownMenuItem
testID="workspace-header-copy-path"
leading={<Copy size={16} color={theme.colors.foregroundMuted} />}
disabled={!normalizedWorkspaceId.startsWith("/")}
disabled={!workspaceDirectory}
onSelect={handleCopyWorkspacePath}
>
Copy workspace path
@@ -1952,7 +1968,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
<>
<WorkspaceGitActions
serverId={normalizedServerId}
cwd={normalizedWorkspaceId}
cwd={workspaceDirectory ?? ""}
/>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild>
@@ -2135,7 +2151,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
<ExplorerSidebar
serverId={normalizedServerId}
workspaceId={normalizedWorkspaceId}
workspaceRoot={normalizedWorkspaceId}
workspaceRoot={workspaceDirectory ?? ""}
isGit={isGitCheckout}
onOpenFile={handleOpenFileFromExplorer}
/>

View File

@@ -13,6 +13,7 @@ describe("workspace source of truth consumption", () => {
projectId: "remote:github.com/getpaseo/paseo",
projectDisplayName: "getpaseo/paseo",
projectRootPath: "/repo/main",
workspaceDirectory: "/repo/main",
projectKind: "git",
workspaceKind: "checkout",
name: "feat/workspace-sot",

View File

@@ -118,6 +118,7 @@ export interface WorkspaceDescriptor {
projectId: string;
projectDisplayName: string;
projectRootPath: string;
workspaceDirectory: string;
projectKind: WorkspaceDescriptorPayload["projectKind"];
workspaceKind: WorkspaceDescriptorPayload["workspaceKind"];
name: string;
@@ -135,6 +136,7 @@ export function normalizeWorkspaceDescriptor(
projectId: String(payload.projectId),
projectDisplayName: payload.projectDisplayName,
projectRootPath: payload.projectRootPath,
workspaceDirectory: payload.workspaceDirectory,
projectKind: payload.projectKind,
workspaceKind: payload.workspaceKind,
name: payload.name,

View File

@@ -0,0 +1,26 @@
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { normalizeWorkspaceIdentity } from "@/utils/workspace-identity";
export function resolveHydratedWorkspaceId(input: {
workspaces: Iterable<WorkspaceDescriptor> | null | undefined;
path: string | null | undefined;
}): string | null {
const normalizedPath = normalizeWorkspaceIdentity(input.path);
if (!normalizedPath) {
return null;
}
for (const workspace of input.workspaces ?? []) {
if (normalizeWorkspaceIdentity(workspace.id) === normalizedPath) {
return workspace.id;
}
if (normalizeWorkspaceIdentity(workspace.workspaceDirectory) === normalizedPath) {
return workspace.id;
}
if (normalizeWorkspaceIdentity(workspace.projectRootPath) === normalizedPath) {
return workspace.id;
}
}
return null;
}

View File

@@ -13,6 +13,7 @@ function workspace(
projectId: input.projectId ?? "project-1",
projectDisplayName: input.projectDisplayName ?? "Project",
projectRootPath: input.projectRootPath ?? "/repo",
workspaceDirectory: input.workspaceDirectory ?? input.projectRootPath ?? "/repo",
projectKind: input.projectKind ?? "git",
workspaceKind: input.workspaceKind ?? "worktree",
name: input.name ?? input.id,

View File

@@ -610,6 +610,104 @@ describe("AgentManager", () => {
unsubscribe();
});
test("terminal agent creation ignores title propagation before the initial snapshot is persisted", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-terminal-title-race-"));
const dataDir = join(workdir, "db");
const database = await openPaseoDatabase(dataDir);
let manager: AgentManager | null = null;
try {
const workspaceId = await seedWorkspace(database, { directory: workdir });
const storage = new DbAgentSnapshotStore(database.db);
const terminalManager: TerminalManager = {
async getTerminals() {
return [];
},
async createTerminal(options) {
const exitListeners = new Set<(info: TerminalExitInfo) => void>();
const titleListeners = new Set<(title?: string) => void>();
const session: TerminalSession = {
id: options.id,
name: options.name ?? "Terminal",
cwd: options.cwd,
send: () => {},
subscribe: () => () => {},
onExit(listener) {
exitListeners.add(listener);
return () => {
exitListeners.delete(listener);
};
},
onTitleChange(listener) {
titleListeners.add(listener);
return () => {
titleListeners.delete(listener);
};
},
getSize: () => ({ rows: 24, cols: 80 }),
getState: () => ({
rows: 24,
cols: 80,
cursor: { row: 0, col: 0 },
scrollback: [],
grid: [],
}),
getTitle: () => "Agent Shell",
getExitInfo: () => null,
kill() {
for (const listener of Array.from(exitListeners)) {
listener({ exitCode: null, signal: null, lastOutputLines: [] });
}
},
};
const agentId = manager?.getAgentIdForTerminal(options.id) ?? null;
if (agentId) {
await manager?.setTitle(agentId, "Agent Shell");
}
return session;
},
registerCwdEnv() {},
getTerminal() {
return undefined;
},
killTerminal() {},
listDirectories() {
return [];
},
killAll() {},
subscribeTerminalsChanged() {
return () => {};
},
};
manager = new AgentManager({
clients: { codex: new TerminalTestAgentClient() },
registry: storage,
terminalManager,
logger,
idFactory: () => "00000000-0000-4000-8000-00000000aa13",
});
const snapshot = await manager.createAgent(
{
provider: "codex",
cwd: workdir,
terminal: true,
},
undefined,
{ workspaceId },
);
const stored = await storage.get(snapshot.id);
expect(stored?.title).toBe("Agent Shell");
} finally {
await database.close();
rmSync(workdir, { recursive: true, force: true });
}
});
test("terminal agent creation preserves titles propagated during terminal registration", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-terminal-registration-title-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
@@ -669,6 +767,35 @@ describe("AgentManager", () => {
terminalManager.killAll();
});
test("getMetricsSnapshot skips agents without in-memory timeline state", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-terminal-metrics-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const manager = new AgentManager({
clients: { codex: new TerminalTestAgentClient() },
registry: storage,
terminalManager: createStubTerminalManager(),
logger,
idFactory: () => "00000000-0000-4000-8000-00000000aa14",
});
const snapshot = await manager.createAgent({
provider: "codex",
cwd: workdir,
terminal: true,
});
expect(manager.getMetricsSnapshot()).toEqual({
total: 1,
byLifecycle: { idle: 1 },
withActiveForegroundTurn: 0,
timelineStats: {
totalItems: 0,
maxItemsPerAgent: 0,
},
});
expect(snapshot.terminal).toBe(true);
});
test("terminal agent closure preserves exit diagnostics for failed launches", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-terminal-exit-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);

View File

@@ -385,6 +385,7 @@ export class AgentManager {
private readonly clients = new Map<AgentProvider, AgentClient>();
private readonly agents = new Map<string, LiveManagedAgent>();
private readonly timelineStore = new InMemoryAgentTimelineStore();
private readonly agentsAwaitingInitialSnapshotPersist = new Set<string>();
private readonly sessionEventTails = new Map<string, Promise<void>>();
private readonly pendingForegroundRuns = new Map<string, PendingForegroundRun>();
private readonly subscribers = new Set<SubscriptionRecord>();
@@ -434,6 +435,10 @@ export class AgentManager {
withActiveForegroundTurn++;
}
if (!this.timelineStore.has(agent.id)) {
continue;
}
const len = this.timelineStore.getItems(agent.id).length;
totalItems += len;
if (len > maxItemsPerAgent) {
@@ -732,6 +737,7 @@ export class AgentManager {
persistence,
{
labels: options?.labels,
workspaceId: options?.workspaceId,
},
);
}
@@ -1037,6 +1043,13 @@ export class AgentManager {
if (!normalizedTitle) {
return;
}
if (
this.agentsAwaitingInitialSnapshotPersist.has(agent.id) &&
this.registry &&
(await this.registry.get(agent.id)) === null
) {
return;
}
this.touchUpdatedAt(agent);
await this.persistSnapshot(agent, { title: normalizedTitle });
this.emitState(agent, { persist: false });
@@ -2110,6 +2123,7 @@ export class AgentManager {
terminalCommand: TerminalCommand,
persistence: AgentPersistenceHandle,
options?: {
workspaceId?: number;
createdAt?: Date;
updatedAt?: Date;
lastUserMessageAt?: Date | null;
@@ -2173,6 +2187,7 @@ export class AgentManager {
this.agents.set(resolvedAgentId, managed);
this.previousStatuses.set(resolvedAgentId, managed.lifecycle);
this.agentsAwaitingInitialSnapshotPersist.add(resolvedAgentId);
let terminalSession: TerminalSession;
try {
@@ -2187,12 +2202,14 @@ export class AgentManager {
} catch (error) {
this.agents.delete(resolvedAgentId);
this.previousStatuses.delete(resolvedAgentId);
this.agentsAwaitingInitialSnapshotPersist.delete(resolvedAgentId);
throw error;
}
if (terminalSession.id !== reservedTerminalId) {
this.agents.delete(resolvedAgentId);
this.previousStatuses.delete(resolvedAgentId);
this.agentsAwaitingInitialSnapshotPersist.delete(resolvedAgentId);
throw new Error(
`Reserved terminal id ${reservedTerminalId} but terminal manager returned ${terminalSession.id}`,
);
@@ -2203,12 +2220,17 @@ export class AgentManager {
});
managed.unsubscribeTerminalExit = unsubscribeTerminalExit;
const terminalSessionTitle = terminalSession.getTitle()?.trim();
await this.persistSnapshot(managed, {
title:
terminalSessionTitle && terminalSessionTitle.length > 0
? terminalSessionTitle
: initialPersistedTitle,
});
try {
await this.persistSnapshot(managed, {
workspaceId: options?.workspaceId,
title:
terminalSessionTitle && terminalSessionTitle.length > 0
? terminalSessionTitle
: initialPersistedTitle,
});
} finally {
this.agentsAwaitingInitialSnapshotPersist.delete(resolvedAgentId);
}
this.emitState(managed);
return { ...managed };
}

View File

@@ -100,6 +100,7 @@ import { DbAgentSnapshotStore } from "./db/db-agent-snapshot-store.js";
import { DbAgentTimelineStore } from "./db/db-agent-timeline-store.js";
import { DbProjectRegistry } from "./db/db-project-registry.js";
import { DbWorkspaceRegistry } from "./db/db-workspace-registry.js";
import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js";
import { importLegacyAgentSnapshots } from "./db/legacy-agent-snapshot-import.js";
import { importLegacyProjectWorkspaceJson } from "./db/legacy-project-workspace-import.js";
import { openPaseoDatabase, type PaseoDatabaseHandle } from "./db/sqlite-database.js";
@@ -398,6 +399,15 @@ export async function createPaseoDaemon(
const projectRegistry = new DbProjectRegistry(database.db);
const workspaceRegistry = new DbWorkspaceRegistry(database.db);
const reconciliationService = new WorkspaceReconciliationService({
projectRegistry,
workspaceRegistry,
logger,
});
reconciliationService.start();
logger.info({ elapsed: elapsed() }, "Workspace reconciliation service started");
await importLegacyProjectWorkspaceJson({
db: database.db,
paseoHome: config.paseoHome,
@@ -749,6 +759,7 @@ export async function createPaseoDaemon(
};
const stop = async () => {
reconciliationService.stop();
await closeAllAgents(logger, agentManager);
await agentManager.flush().catch(() => undefined);
await shutdownProviders(logger, {

View File

@@ -184,7 +184,7 @@ export class DbAgentSnapshotStore implements AgentSnapshotStore {
}
if (nextWorkspaceId === undefined) {
throw new Error(`Workspace ID required for agent ${agent.id}`);
return;
}
await this.upsert(record, nextWorkspaceId);
}

View File

@@ -145,6 +145,7 @@ import {
toCheckoutError,
} from "./checkout-git-utils.js";
import { CheckoutDiffManager } from "./checkout-diff-manager.js";
import { detectWorkspaceGitMetadata } from "./workspace-git-metadata.js";
import type { LocalSpeechModelId } from "./speech/providers/local/models.js";
import { toResolver, type Resolvable } from "./speech/provider-resolver.js";
import type { SpeechReadinessSnapshot, SpeechReadinessState } from "./speech/speech-runtime.js";
@@ -181,12 +182,14 @@ type DeleteFencedAgentSnapshotStore = AgentSnapshotStore & {
beginDelete(agentId: string): void;
};
function beginAgentDeleteIfSupported(agentStorage: AgentSnapshotStore, agentId: string): void {
if ("beginDelete" in agentStorage && typeof agentStorage.beginDelete === "function") {
(agentStorage as DeleteFencedAgentSnapshotStore).beginDelete(agentId);
}
}
function deriveInitialAgentTitle(prompt: string): string | null {
const firstContentLine = prompt
.split(/\r?\n/)
@@ -4741,6 +4744,7 @@ export class Session {
projectId: workspace.projectId,
projectDisplayName: resolvedProjectRecord?.displayName ?? String(workspace.projectId),
projectRootPath: resolvedProjectRecord?.directory ?? workspace.directory,
workspaceDirectory: workspace.directory,
projectKind: resolvedProjectRecord?.kind ?? "directory",
workspaceKind: workspace.kind,
name: workspace.displayName,
@@ -5095,11 +5099,12 @@ export class Session {
const timestamp = new Date().toISOString();
const directoryName = normalizedCwd.split(/[\\/]/).filter(Boolean).at(-1) ?? normalizedCwd;
const gitMetadata = detectWorkspaceGitMetadata(normalizedCwd, directoryName);
const projectId = await this.projectRegistry.insert({
directory: normalizedCwd,
displayName: directoryName,
kind: "directory",
gitRemote: null,
displayName: gitMetadata.projectDisplayName,
kind: gitMetadata.projectKind,
gitRemote: gitMetadata.gitRemote,
createdAt: timestamp,
updatedAt: timestamp,
archivedAt: null,
@@ -5107,7 +5112,7 @@ export class Session {
const workspaceId = await this.workspaceRegistry.insert({
projectId,
directory: normalizedCwd,
displayName: directoryName,
displayName: gitMetadata.workspaceDisplayName,
kind: "checkout",
createdAt: timestamp,
updatedAt: timestamp,

View File

@@ -1,5 +1,5 @@
import { execSync } from "node:child_process";
import { existsSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, test, vi } from "vitest";
@@ -266,6 +266,28 @@ function createStoredTerminalAgentRecord(input: {
};
}
function createTempGitRepo(options?: {
remoteUrl?: string;
branchName?: string;
}): { tempDir: string; repoDir: string } {
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "session-workspace-git-")));
const repoDir = path.join(tempDir, "repo");
execSync(`mkdir -p ${repoDir}`);
execSync(`git init -b ${options?.branchName ?? "main"}`, { cwd: repoDir, stdio: "pipe" });
execSync("git config user.email 'test@test.com'", { cwd: repoDir, stdio: "pipe" });
execSync("git config user.name 'Test'", { cwd: repoDir, stdio: "pipe" });
writeFileSync(path.join(repoDir, "file.txt"), "hello\n");
execSync("git add .", { cwd: repoDir, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir, stdio: "pipe" });
if (options?.remoteUrl) {
execSync(`git remote add origin ${JSON.stringify(options.remoteUrl)}`, {
cwd: repoDir,
stdio: "pipe",
});
}
return { tempDir, repoDir };
}
describe("workspace aggregation", () => {
test("terminal agents reject timeline fetch without reloading as chat sessions", async () => {
const emitted: Array<{ type: string; payload: any }> = [];
@@ -567,7 +589,8 @@ describe("workspace aggregation", () => {
expect(response?.payload.workspace?.id).toEqual(expect.any(Number));
const persistedWorkspace = workspaces.get(response!.payload.workspace.id);
expect(persistedWorkspace?.directory).toContain(path.join("worktree-123"));
expect(existsSync(persistedWorkspace?.directory ?? "")).toBe(true);
// The worktree directory is created asynchronously in the background after
// the response is sent, so we only verify the DB record here.
expect(workspaces.has(response!.payload.workspace.id)).toBe(true);
expect(projects.has(response?.payload.workspace?.projectId)).toBe(true);
} finally {
@@ -606,4 +629,94 @@ describe("workspace aggregation", () => {
error: null,
});
});
test("open_project_request creates git projects with GitHub owner/repo and branch names", async () => {
const { session, emitted, projects, workspaces } = createSessionForWorkspaceTests();
const { tempDir, repoDir } = createTempGitRepo({
remoteUrl: "git@github.com:acme/repo.git",
branchName: "feature/test-branch",
});
try {
await (session as any).handleOpenProjectRequest({
type: "open_project_request",
cwd: repoDir,
requestId: "req-open-git",
});
expect(Array.from(projects.values())).toEqual([
expect.objectContaining({
directory: repoDir,
kind: "git",
displayName: "acme/repo",
gitRemote: "git@github.com:acme/repo.git",
}),
]);
expect(Array.from(workspaces.values())).toEqual([
expect.objectContaining({
directory: repoDir,
displayName: "feature/test-branch",
kind: "checkout",
}),
]);
const response = emitted.find((message) => message.type === "open_project_response") as any;
expect(response?.payload).toMatchObject({
error: null,
workspace: {
projectDisplayName: "acme/repo",
projectKind: "git",
name: "feature/test-branch",
workspaceKind: "checkout",
},
});
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("open_project_request treats non-git directories as directory projects", async () => {
const { session, emitted, projects, workspaces } = createSessionForWorkspaceTests();
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "session-workspace-dir-")));
const projectDir = path.join(tempDir, "plain-dir");
execSync(`mkdir -p ${projectDir}`);
writeFileSync(path.join(projectDir, "README.md"), "hello\n");
try {
await (session as any).handleOpenProjectRequest({
type: "open_project_request",
cwd: projectDir,
requestId: "req-open-dir",
});
expect(Array.from(projects.values())).toEqual([
expect.objectContaining({
directory: projectDir,
kind: "directory",
displayName: "plain-dir",
gitRemote: null,
}),
]);
expect(Array.from(workspaces.values())).toEqual([
expect.objectContaining({
directory: projectDir,
displayName: "plain-dir",
kind: "checkout",
}),
]);
const response = emitted.find((message) => message.type === "open_project_response") as any;
expect(response?.payload).toMatchObject({
error: null,
workspace: {
projectDisplayName: "plain-dir",
projectKind: "directory",
name: "plain-dir",
workspaceKind: "checkout",
},
});
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
});

View File

@@ -0,0 +1,82 @@
import { execSync } from "child_process";
import { READ_ONLY_GIT_ENV } from "./checkout-git-utils.js";
export type WorkspaceGitMetadata = {
projectKind: "git" | "directory";
projectDisplayName: string;
workspaceDisplayName: string;
gitRemote: string | null;
};
export function readGitCommand(cwd: string, command: string): string | null {
try {
const output = execSync(command, {
cwd,
env: READ_ONLY_GIT_ENV,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
});
const trimmed = output.trim();
return trimmed.length > 0 ? trimmed : null;
} catch {
return null;
}
}
export function parseGitHubRepoFromRemote(remoteUrl: string): string | null {
let cleaned = remoteUrl.trim();
if (!cleaned) {
return null;
}
if (cleaned.startsWith("git@github.com:")) {
cleaned = cleaned.slice("git@github.com:".length);
} else if (cleaned.startsWith("https://github.com/")) {
cleaned = cleaned.slice("https://github.com/".length);
} else if (cleaned.startsWith("http://github.com/")) {
cleaned = cleaned.slice("http://github.com/".length);
} else {
const marker = "github.com/";
const markerIndex = cleaned.indexOf(marker);
if (markerIndex === -1) {
return null;
}
cleaned = cleaned.slice(markerIndex + marker.length);
}
if (cleaned.endsWith(".git")) {
cleaned = cleaned.slice(0, -".git".length);
}
if (!cleaned.includes("/")) {
return null;
}
return cleaned;
}
export function detectWorkspaceGitMetadata(
cwd: string,
directoryName: string,
): WorkspaceGitMetadata {
const gitDir = readGitCommand(cwd, "git rev-parse --git-dir");
if (!gitDir) {
return {
projectKind: "directory",
projectDisplayName: directoryName,
workspaceDisplayName: directoryName,
gitRemote: null,
};
}
const gitRemote = readGitCommand(cwd, "git config --get remote.origin.url");
const githubRepo = gitRemote ? parseGitHubRepoFromRemote(gitRemote) : null;
const branchName = readGitCommand(cwd, "git symbolic-ref --short HEAD");
return {
projectKind: "git",
projectDisplayName: githubRepo ?? directoryName,
workspaceDisplayName: branchName ?? directoryName,
gitRemote,
};
}

View File

@@ -0,0 +1,417 @@
import { execSync } from "node:child_process";
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, test, vi, afterEach } from "vitest";
import {
createPersistedProjectRecord,
createPersistedWorkspaceRecord,
} from "./workspace-registry.js";
import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "./workspace-registry.js";
import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js";
function createTestRegistries() {
const projects = new Map<number, PersistedProjectRecord>();
const workspaces = new Map<number, PersistedWorkspaceRecord>();
let nextProjectId = 1;
let nextWorkspaceId = 1;
const projectRegistry = {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => Array.from(projects.values()),
get: async (id: number) => projects.get(id) ?? null,
insert: async (record: Omit<PersistedProjectRecord, "id">) => {
const id = nextProjectId++;
projects.set(id, createPersistedProjectRecord({ id, ...record }));
return id;
},
upsert: async (record: PersistedProjectRecord) => {
projects.set(record.id, record);
},
archive: async (id: number, archivedAt: string) => {
const existing = projects.get(id);
if (existing) {
projects.set(id, { ...existing, archivedAt, updatedAt: archivedAt });
}
},
remove: async (id: number) => {
projects.delete(id);
},
};
const workspaceRegistry = {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => Array.from(workspaces.values()),
get: async (id: number) => workspaces.get(id) ?? null,
insert: async (record: Omit<PersistedWorkspaceRecord, "id">) => {
const id = nextWorkspaceId++;
workspaces.set(id, createPersistedWorkspaceRecord({ id, ...record }));
return id;
},
upsert: async (record: PersistedWorkspaceRecord) => {
workspaces.set(record.id, record);
},
archive: async (id: number, archivedAt: string) => {
const existing = workspaces.get(id);
if (existing) {
workspaces.set(id, { ...existing, archivedAt, updatedAt: archivedAt });
}
},
remove: async (id: number) => {
workspaces.delete(id);
},
};
return { projects, workspaces, projectRegistry, workspaceRegistry };
}
function createTestLogger() {
const logger = {
child: () => logger,
trace: vi.fn(),
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
return logger as any;
}
function createTempGitRepo(prefix: string): string {
const raw = mkdtempSync(path.join(tmpdir(), prefix));
const dir = realpathSync(raw);
execSync("git init -b main", { cwd: dir, stdio: "ignore" });
execSync('git config user.email "test@test.com"', { cwd: dir, stdio: "ignore" });
execSync('git config user.name "Test"', { cwd: dir, stdio: "ignore" });
execSync("git config commit.gpgsign false", { cwd: dir, stdio: "ignore" });
writeFileSync(path.join(dir, "README.md"), "# Test\n");
execSync("git add .", { cwd: dir, stdio: "ignore" });
execSync('git commit -m "init"', { cwd: dir, stdio: "ignore" });
return dir;
}
const timestamp = "2025-01-01T00:00:00.000Z";
describe("WorkspaceReconciliationService", () => {
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs) {
rmSync(dir, { recursive: true, force: true });
}
tempDirs.length = 0;
});
test("archives workspaces whose directories no longer exist", async () => {
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
projects.set(
1,
createPersistedProjectRecord({
id: 1,
directory: "/tmp/does-not-exist-reconcile-test",
kind: "directory",
displayName: "ghost",
createdAt: timestamp,
updatedAt: timestamp,
}),
);
workspaces.set(
1,
createPersistedWorkspaceRecord({
id: 1,
projectId: 1,
directory: "/tmp/does-not-exist-reconcile-test",
kind: "checkout",
displayName: "ghost",
createdAt: timestamp,
updatedAt: timestamp,
}),
);
const service = new WorkspaceReconciliationService({
projectRegistry,
workspaceRegistry,
logger: createTestLogger(),
});
const result = await service.runOnce();
expect(result.changesApplied.length).toBeGreaterThanOrEqual(1);
const wsChange = result.changesApplied.find((c) => c.kind === "workspace_archived");
expect(wsChange).toBeDefined();
expect(workspaces.get(1)!.archivedAt).toBeTruthy();
});
test("archives orphaned projects after all workspaces are archived", async () => {
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
projects.set(
1,
createPersistedProjectRecord({
id: 1,
directory: "/tmp/does-not-exist-reconcile-orphan",
kind: "directory",
displayName: "orphan",
createdAt: timestamp,
updatedAt: timestamp,
}),
);
workspaces.set(
1,
createPersistedWorkspaceRecord({
id: 1,
projectId: 1,
directory: "/tmp/does-not-exist-reconcile-orphan",
kind: "checkout",
displayName: "orphan",
createdAt: timestamp,
updatedAt: timestamp,
}),
);
const service = new WorkspaceReconciliationService({
projectRegistry,
workspaceRegistry,
logger: createTestLogger(),
});
const result = await service.runOnce();
const projChange = result.changesApplied.find((c) => c.kind === "project_archived");
expect(projChange).toBeDefined();
expect(projects.get(1)!.archivedAt).toBeTruthy();
});
test("updates project kind when a directory becomes a git repo", async () => {
const dir = mkdtempSync(path.join(tmpdir(), "reconcile-git-init-"));
const resolved = realpathSync(dir);
tempDirs.push(resolved);
writeFileSync(path.join(resolved, "README.md"), "# Test\n");
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
projects.set(
1,
createPersistedProjectRecord({
id: 1,
directory: resolved,
kind: "directory",
displayName: path.basename(resolved),
createdAt: timestamp,
updatedAt: timestamp,
}),
);
workspaces.set(
1,
createPersistedWorkspaceRecord({
id: 1,
projectId: 1,
directory: resolved,
kind: "checkout",
displayName: path.basename(resolved),
createdAt: timestamp,
updatedAt: timestamp,
}),
);
// Initialize as git repo
execSync("git init -b main", { cwd: resolved, stdio: "ignore" });
execSync('git config user.email "test@test.com"', { cwd: resolved, stdio: "ignore" });
execSync('git config user.name "Test"', { cwd: resolved, stdio: "ignore" });
execSync("git config commit.gpgsign false", { cwd: resolved, stdio: "ignore" });
execSync("git add .", { cwd: resolved, stdio: "ignore" });
execSync('git commit -m "init"', { cwd: resolved, stdio: "ignore" });
const service = new WorkspaceReconciliationService({
projectRegistry,
workspaceRegistry,
logger: createTestLogger(),
});
const result = await service.runOnce();
const projUpdate = result.changesApplied.find((c) => c.kind === "project_updated");
expect(projUpdate).toBeDefined();
expect(projects.get(1)!.kind).toBe("git");
});
test("updates project display name when git remote changes", async () => {
const dir = createTempGitRepo("reconcile-remote-");
tempDirs.push(dir);
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
projects.set(
1,
createPersistedProjectRecord({
id: 1,
directory: dir,
kind: "git",
displayName: "old-owner/old-repo",
gitRemote: "git@github.com:old-owner/old-repo.git",
createdAt: timestamp,
updatedAt: timestamp,
}),
);
workspaces.set(
1,
createPersistedWorkspaceRecord({
id: 1,
projectId: 1,
directory: dir,
kind: "checkout",
displayName: "main",
createdAt: timestamp,
updatedAt: timestamp,
}),
);
// Change the remote
execSync("git remote add origin git@github.com:new-owner/new-repo.git", {
cwd: dir,
stdio: "ignore",
});
const service = new WorkspaceReconciliationService({
projectRegistry,
workspaceRegistry,
logger: createTestLogger(),
});
const result = await service.runOnce();
const projUpdate = result.changesApplied.find((c) => c.kind === "project_updated");
expect(projUpdate).toBeDefined();
expect(projects.get(1)!.displayName).toBe("new-owner/new-repo");
expect(projects.get(1)!.gitRemote).toBe("git@github.com:new-owner/new-repo.git");
});
test("updates workspace display name when branch changes", async () => {
const dir = createTempGitRepo("reconcile-branch-");
tempDirs.push(dir);
execSync("git checkout -b feature-branch", { cwd: dir, stdio: "ignore" });
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
projects.set(
1,
createPersistedProjectRecord({
id: 1,
directory: dir,
kind: "git",
displayName: path.basename(dir),
createdAt: timestamp,
updatedAt: timestamp,
}),
);
workspaces.set(
1,
createPersistedWorkspaceRecord({
id: 1,
projectId: 1,
directory: dir,
kind: "checkout",
displayName: "main",
createdAt: timestamp,
updatedAt: timestamp,
}),
);
const service = new WorkspaceReconciliationService({
projectRegistry,
workspaceRegistry,
logger: createTestLogger(),
});
const result = await service.runOnce();
const wsUpdate = result.changesApplied.find((c) => c.kind === "workspace_updated");
expect(wsUpdate).toBeDefined();
expect(workspaces.get(1)!.displayName).toBe("feature-branch");
});
test("does not modify already-archived records", async () => {
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
projects.set(
1,
createPersistedProjectRecord({
id: 1,
directory: "/tmp/does-not-exist-archived",
kind: "directory",
displayName: "archived",
createdAt: timestamp,
updatedAt: timestamp,
archivedAt: timestamp,
}),
);
workspaces.set(
1,
createPersistedWorkspaceRecord({
id: 1,
projectId: 1,
directory: "/tmp/does-not-exist-archived",
kind: "checkout",
displayName: "archived",
createdAt: timestamp,
updatedAt: timestamp,
archivedAt: timestamp,
}),
);
const service = new WorkspaceReconciliationService({
projectRegistry,
workspaceRegistry,
logger: createTestLogger(),
});
const result = await service.runOnce();
expect(result.changesApplied).toHaveLength(0);
});
test("calls onChanges callback when changes are applied", async () => {
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
projects.set(
1,
createPersistedProjectRecord({
id: 1,
directory: "/tmp/does-not-exist-callback-test",
kind: "directory",
displayName: "ghost",
createdAt: timestamp,
updatedAt: timestamp,
}),
);
workspaces.set(
1,
createPersistedWorkspaceRecord({
id: 1,
projectId: 1,
directory: "/tmp/does-not-exist-callback-test",
kind: "checkout",
displayName: "ghost",
createdAt: timestamp,
updatedAt: timestamp,
}),
);
const onChanges = vi.fn();
const service = new WorkspaceReconciliationService({
projectRegistry,
workspaceRegistry,
logger: createTestLogger(),
onChanges,
});
await service.runOnce();
expect(onChanges).toHaveBeenCalledTimes(1);
expect(onChanges.mock.calls[0][0].length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,239 @@
import { existsSync } from "node:fs";
import type pino from "pino";
import type {
ProjectRegistry,
WorkspaceRegistry,
PersistedProjectRecord,
PersistedWorkspaceRecord,
} from "./workspace-registry.js";
import { detectWorkspaceGitMetadata } from "./workspace-git-metadata.js";
const DEFAULT_RECONCILE_INTERVAL_MS = 60_000;
export type ReconciliationChange =
| { kind: "workspace_archived"; workspaceId: number; directory: string; reason: string }
| { kind: "project_archived"; projectId: number; directory: string; reason: string }
| {
kind: "project_updated";
projectId: number;
directory: string;
fields: Partial<Pick<PersistedProjectRecord, "kind" | "displayName" | "gitRemote">>;
}
| {
kind: "workspace_updated";
workspaceId: number;
directory: string;
fields: Partial<Pick<PersistedWorkspaceRecord, "displayName">>;
};
export type ReconciliationResult = {
changesApplied: ReconciliationChange[];
durationMs: number;
};
export type WorkspaceReconciliationServiceOptions = {
projectRegistry: ProjectRegistry;
workspaceRegistry: WorkspaceRegistry;
logger: pino.Logger;
intervalMs?: number;
onChanges?: (changes: ReconciliationChange[]) => void;
};
export class WorkspaceReconciliationService {
private readonly projectRegistry: ProjectRegistry;
private readonly workspaceRegistry: WorkspaceRegistry;
private readonly logger: pino.Logger;
private readonly intervalMs: number;
private readonly onChanges: ((changes: ReconciliationChange[]) => void) | null;
private timer: ReturnType<typeof setInterval> | null = null;
private running = false;
constructor(options: WorkspaceReconciliationServiceOptions) {
this.projectRegistry = options.projectRegistry;
this.workspaceRegistry = options.workspaceRegistry;
this.logger = options.logger.child({ module: "workspace-reconciliation" });
this.intervalMs = options.intervalMs ?? DEFAULT_RECONCILE_INTERVAL_MS;
this.onChanges = options.onChanges ?? null;
}
start(): void {
if (this.timer) return;
this.logger.info({ intervalMs: this.intervalMs }, "Starting workspace reconciliation service");
this.timer = setInterval(() => void this.runSafe(), this.intervalMs);
// Run once immediately on start
void this.runSafe();
}
stop(): void {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
}
async runOnce(): Promise<ReconciliationResult> {
return this.reconcile();
}
private async runSafe(): Promise<void> {
if (this.running) return;
this.running = true;
try {
const result = await this.reconcile();
if (result.changesApplied.length > 0) {
this.logger.info(
{ changeCount: result.changesApplied.length, durationMs: result.durationMs },
"Reconciliation pass completed with changes",
);
}
} catch (error) {
this.logger.error({ err: error }, "Reconciliation pass failed");
} finally {
this.running = false;
}
}
private async reconcile(): Promise<ReconciliationResult> {
const start = Date.now();
const changes: ReconciliationChange[] = [];
const allProjects = await this.projectRegistry.list();
const allWorkspaces = await this.workspaceRegistry.list();
const activeProjects = allProjects.filter((p) => !p.archivedAt);
const activeWorkspaces = allWorkspaces.filter((w) => !w.archivedAt);
const workspacesByProject = new Map<number, PersistedWorkspaceRecord[]>();
for (const workspace of activeWorkspaces) {
const list = workspacesByProject.get(workspace.projectId) ?? [];
list.push(workspace);
workspacesByProject.set(workspace.projectId, list);
}
// 1. Archive workspaces whose directories no longer exist
for (const workspace of activeWorkspaces) {
if (!existsSync(workspace.directory)) {
const timestamp = new Date().toISOString();
await this.workspaceRegistry.archive(workspace.id, timestamp);
changes.push({
kind: "workspace_archived",
workspaceId: workspace.id,
directory: workspace.directory,
reason: "directory_missing",
});
// Update the in-memory list for the project orphan check below
const siblings = workspacesByProject.get(workspace.projectId);
if (siblings) {
const updated = siblings.filter((w) => w.id !== workspace.id);
workspacesByProject.set(workspace.projectId, updated);
}
}
}
// 2. Archive orphaned projects (all workspaces archived/removed)
for (const project of activeProjects) {
const siblings = workspacesByProject.get(project.id) ?? [];
if (siblings.length === 0) {
const timestamp = new Date().toISOString();
await this.projectRegistry.archive(project.id, timestamp);
changes.push({
kind: "project_archived",
projectId: project.id,
directory: project.directory,
reason: "no_active_workspaces",
});
}
}
// 3. Reconcile git metadata for active projects whose directories still exist
for (const project of activeProjects) {
if (project.archivedAt) continue;
const siblings = workspacesByProject.get(project.id) ?? [];
if (siblings.length === 0) continue;
if (!existsSync(project.directory)) continue;
const directoryName =
project.directory.split(/[\\/]/).filter(Boolean).at(-1) ?? project.directory;
const currentGit = detectWorkspaceGitMetadata(project.directory, directoryName);
const projectUpdates: Partial<
Pick<PersistedProjectRecord, "kind" | "displayName" | "gitRemote">
> = {};
// Detect kind change: directory → git
if (project.kind !== currentGit.projectKind) {
projectUpdates.kind = currentGit.projectKind;
projectUpdates.displayName = currentGit.projectDisplayName;
projectUpdates.gitRemote = currentGit.gitRemote;
}
// Detect display name change (e.g. remote renamed)
if (
project.kind === "git" &&
currentGit.projectKind === "git" &&
project.displayName !== currentGit.projectDisplayName
) {
projectUpdates.displayName = currentGit.projectDisplayName;
}
// Detect git remote change
if (
project.kind === "git" &&
currentGit.projectKind === "git" &&
project.gitRemote !== currentGit.gitRemote
) {
projectUpdates.gitRemote = currentGit.gitRemote;
}
if (Object.keys(projectUpdates).length > 0) {
const timestamp = new Date().toISOString();
await this.projectRegistry.upsert({
...project,
...projectUpdates,
updatedAt: timestamp,
});
changes.push({
kind: "project_updated",
projectId: project.id,
directory: project.directory,
fields: projectUpdates,
});
}
// 4. Reconcile workspace display names (branch name changes)
for (const workspace of siblings) {
if (workspace.kind !== "checkout") continue;
if (!existsSync(workspace.directory)) continue;
const wsDirName =
workspace.directory.split(/[\\/]/).filter(Boolean).at(-1) ?? workspace.directory;
const wsGit = detectWorkspaceGitMetadata(workspace.directory, wsDirName);
if (
wsGit.projectKind === "git" &&
workspace.displayName !== wsGit.workspaceDisplayName
) {
const timestamp = new Date().toISOString();
await this.workspaceRegistry.upsert({
...workspace,
displayName: wsGit.workspaceDisplayName,
updatedAt: timestamp,
});
changes.push({
kind: "workspace_updated",
workspaceId: workspace.id,
directory: workspace.directory,
fields: { displayName: wsGit.workspaceDisplayName },
});
}
}
}
if (changes.length > 0 && this.onChanges) {
this.onChanges(changes);
}
return { changesApplied: changes, durationMs: Date.now() - start };
}
}

View File

@@ -1593,6 +1593,7 @@ export const WorkspaceDescriptorPayloadSchema = z.object({
projectId: z.number().int(),
projectDisplayName: z.string(),
projectRootPath: z.string(),
workspaceDirectory: z.string(),
projectKind: z.enum(["git", "directory"]),
workspaceKind: z.enum(["checkout", "worktree"]),
name: z.string(),