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
+15 -5
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. */
@@ -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,
@@ -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);
}
+165
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);
});
});
}
+22 -1
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) {
+18 -11
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);
+178
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();
}
});
});
@@ -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 () => {
+107
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();
}
});
});
@@ -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();
}
});
});
});
+27 -16
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>
);
@@ -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;
}
+23 -5
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()) {
@@ -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(() => {
+9 -1
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;
}
+12 -1
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",
@@ -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;
}
@@ -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({
@@ -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);
@@ -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;
}
@@ -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");
}
+16 -2
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);
@@ -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({
@@ -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,
@@ -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;
+26 -8
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,
},
+19 -7
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}
/>
@@ -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);
@@ -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"]) };
@@ -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);
@@ -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 }
: {}),
@@ -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,
};
}
@@ -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}
/>
@@ -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",
+2
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,
@@ -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;
}
@@ -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,