diff --git a/packages/app/e2e/helpers/launcher.ts b/packages/app/e2e/helpers/launcher.ts index 35d5f9daa..798201137 100644 --- a/packages/app/e2e/helpers/launcher.ts +++ b/packages/app/e2e/helpers/launcher.ts @@ -30,7 +30,9 @@ export async function waitForTabBar(page: Page): Promise { /** Return all tab test IDs currently in the tab bar. */ export async function getTabTestIds(page: Page): Promise { - 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 /** Return the currently active tab's test ID (the one with aria-selected or focus styling). */ export async function getActiveTabTestId(page: Page): Promise { // 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 { /** Click the "Terminal" tile on the launcher panel. */ export async function clickTerminal(page: Page): Promise { - 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 { 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. */ diff --git a/packages/app/e2e/helpers/terminal-perf.ts b/packages/app/e2e/helpers/terminal-perf.ts index 18dd79bd1..8ac45d251 100644 --- a/packages/app/e2e/helpers/terminal-perf.ts +++ b/packages/app/e2e/helpers/terminal-perf.ts @@ -7,6 +7,12 @@ import { buildHostWorkspaceRoute } from "../../src/utils/host-routes"; export type TerminalPerfDaemonClient = { connect(): Promise; close(): Promise; + openProject( + cwd: string, + ): Promise<{ + workspace: { id: number; name: string; projectRootPath: string } | null; + error: string | null; + }>; createTerminal( cwd: string, name?: string, diff --git a/packages/app/e2e/helpers/workspace-lifecycle.ts b/packages/app/e2e/helpers/workspace-lifecycle.ts new file mode 100644 index 000000000..e0aa30dec --- /dev/null +++ b/packages/app/e2e/helpers/workspace-lifecycle.ts @@ -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 { + 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 { + 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 { + 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 { + 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); +} diff --git a/packages/app/e2e/helpers/workspace-setup.ts b/packages/app/e2e/helpers/workspace-setup.ts new file mode 100644 index 000000000..1cdaec060 --- /dev/null +++ b/packages/app/e2e/helpers/workspace-setup.ts @@ -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; + close(): Promise; + 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 { + 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 { + 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 { + 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 { + await page.getByTestId(`sidebar-project-row-${repoPath}`).hover(); +} + +export async function createWorkspaceFromSidebar(page: Page, repoPath: string): Promise { + 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 { + await expect(page.getByText("Workspace setup", { exact: true })).toBeVisible({ timeout: 30_000 }); +} + +export async function expectSetupStatus( + page: Page, + status: "Running" | "Completed" | "Failed", +): Promise { + await expect(page.getByTestId("workspace-setup-status")).toContainText(status, { + timeout: 30_000, + }); +} + +export async function expectSetupLogContains(page: Page, text: string): Promise { + await expect(page.getByTestId("workspace-setup-log")).toContainText(text, { + timeout: 30_000, + }); +} + +export async function expectNoSetupMessage(page: Page): Promise { + 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 { + 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); + }); + }); +} diff --git a/packages/app/e2e/helpers/workspace.ts b/packages/app/e2e/helpers/workspace.ts index d49d67a87..e5305c414 100644 --- a/packages/app/e2e/helpers/workspace.ts +++ b/packages/app/e2e/helpers/workspace.ts @@ -10,7 +10,11 @@ type TempRepo = { export const createTempGitRepo = async ( prefix = "paseo-e2e-", - options?: { withRemote?: boolean }, + options?: { + withRemote?: boolean; + paseoConfig?: Record; + files?: Array<{ path: string; content: string }>; + }, ): Promise => { // 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) { diff --git a/packages/app/e2e/launcher-tab.spec.ts b/packages/app/e2e/launcher-tab.spec.ts index 6aef148b0..343d5e7cd 100644 --- a/packages/app/e2e/launcher-tab.spec.ts +++ b/packages/app/e2e/launcher-tab.spec.ts @@ -28,12 +28,19 @@ import { // ─── Shared state ────────────────────────────────────────────────────────── let tempRepo: { path: string; cleanup: () => Promise }; +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); diff --git a/packages/app/e2e/sidebar-workspace.spec.ts b/packages/app/e2e/sidebar-workspace.spec.ts new file mode 100644 index 000000000..a9cab9c31 --- /dev/null +++ b/packages/app/e2e/sidebar-workspace.spec.ts @@ -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>, + 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(); + } + }); +}); diff --git a/packages/app/e2e/terminal-performance.spec.ts b/packages/app/e2e/terminal-performance.spec.ts index da03858a4..9490343cb 100644 --- a/packages/app/e2e/terminal-performance.spec.ts +++ b/packages/app/e2e/terminal-performance.spec.ts @@ -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 () => { diff --git a/packages/app/e2e/workspace-cwd.spec.ts b/packages/app/e2e/workspace-cwd.spec.ts new file mode 100644 index 000000000..34769ac73 --- /dev/null +++ b/packages/app/e2e/workspace-cwd.spec.ts @@ -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(); + } + }); + +}); diff --git a/packages/app/e2e/workspace-lifecycle.spec.ts b/packages/app/e2e/workspace-lifecycle.spec.ts new file mode 100644 index 000000000..e7c183f6d --- /dev/null +++ b/packages/app/e2e/workspace-lifecycle.spec.ts @@ -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(); + } + }); + }); +}); diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index e41664c4e..9268628a7 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -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 ( - - - - - - - - - - - + + + { + 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}`; + }} + /> + + + + + + ); diff --git a/packages/app/src/app/h/[serverId]/agent/[agentId].tsx b/packages/app/src/app/h/[serverId]/agent/[agentId].tsx index 6ceedf4b7..4ba6a5c8e 100644 --- a/packages/app/src/app/h/[serverId]/agent/[agentId].tsx +++ b/packages/app/src/app/h/[serverId]/agent/[agentId].tsx @@ -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; } diff --git a/packages/app/src/app/h/[serverId]/index.tsx b/packages/app/src/app/h/[serverId]/index.tsx index ab6c8ec96..089dccf21 100644 --- a/packages/app/src/app/h/[serverId]/index.tsx +++ b/packages/app/src/app/h/[serverId]/index.tsx @@ -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()) { diff --git a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx index 2afc57223..399830a1c 100644 --- a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx +++ b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx @@ -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(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(() => { diff --git a/packages/app/src/app/index.tsx b/packages/app/src/app/index.tsx index c65cc841d..33e18d87a 100644 --- a/packages/app/src/app/index.tsx +++ b/packages/app/src/app/index.tsx @@ -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; } diff --git a/packages/app/src/components/agent-list.tsx b/packages/app/src/components/agent-list.tsx index d2471262b..243d0211c 100644 --- a/packages/app/src/components/agent-list.tsx +++ b/packages/app/src/components/agent-list.tsx @@ -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", diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index 6e136bbd5..d6923b244 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -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 { 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({ diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index d3907e739..ad457eeb7 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -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); diff --git a/packages/app/src/components/welcome-screen.tsx b/packages/app/src/components/welcome-screen.tsx index 8ed402b76..3837ba868 100644 --- a/packages/app/src/components/welcome-screen.tsx +++ b/packages/app/src/components/welcome-screen.tsx @@ -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; } diff --git a/packages/app/src/components/workspace-setup-dialog.tsx b/packages/app/src/components/workspace-setup-dialog.tsx index fac0f704d..58417d6c2 100644 --- a/packages/app/src/components/workspace-setup-dialog.tsx +++ b/packages/app/src/components/workspace-setup-dialog.tsx @@ -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"); } diff --git a/packages/app/src/hooks/use-command-center.ts b/packages/app/src/hooks/use-command-center.ts index 6c4857e1b..fa545dbfd 100644 --- a/packages/app/src/hooks/use-command-center.ts +++ b/packages/app/src/hooks/use-command-center.ts @@ -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); diff --git a/packages/app/src/hooks/use-open-project.test.ts b/packages/app/src/hooks/use-open-project.test.ts index cbe42f45d..a39789998 100644 --- a/packages/app/src/hooks/use-open-project.test.ts +++ b/packages/app/src/hooks/use-open-project.test.ts @@ -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({ diff --git a/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts index 1e068fed9..e87f2443d 100644 --- a/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts +++ b/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts @@ -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, diff --git a/packages/app/src/hooks/use-sidebar-workspaces-list.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.ts index 3266d1e2f..0678b78ee 100644 --- a/packages/app/src/hooks/use-sidebar-workspaces-list.ts +++ b/packages/app/src/hooks/use-sidebar-workspaces-list.ts @@ -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; diff --git a/packages/app/src/panels/launcher-panel.tsx b/packages/app/src/panels/launcher-panel.tsx index a7f9e821e..3f6b781a2 100644 --- a/packages/app/src/panels/launcher-panel.tsx +++ b/packages/app/src/panels/launcher-panel.tsx @@ -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(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 ( + + + + + + ); + } + return ( ({ paddingHorizontal: theme.spacing[4], paddingVertical: theme.spacing[8], }, + loadingContent: { + flex: 1, + }, contentUnfocused: { opacity: 0.96, }, diff --git a/packages/app/src/panels/terminal-panel.tsx b/packages/app/src/panels/terminal-panel.tsx index eaac54fda..3d2c65a28 100644 --- a/packages/app/src/panels/terminal-panel.tsx +++ b/packages/app/src/panels/terminal-panel.tsx @@ -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 => { - 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 ; } return ( diff --git a/packages/app/src/screens/agent/draft-agent-screen.tsx b/packages/app/src/screens/agent/draft-agent-screen.tsx index 21b6cd15d..22dccca4b 100644 --- a/packages/app/src/screens/agent/draft-agent-screen.tsx +++ b/packages/app/src/screens/agent/draft-agent-screen.tsx @@ -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({ + } = useDraftAgentCreateFlow({ 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); diff --git a/packages/app/src/screens/workspace/workspace-agent-visibility.test.ts b/packages/app/src/screens/workspace/workspace-agent-visibility.test.ts index 05337f084..e1cafc63d 100644 --- a/packages/app/src/screens/workspace/workspace-agent-visibility.test.ts +++ b/packages/app/src/screens/workspace/workspace-agent-visibility.test.ts @@ -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([ + [ + "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"]) }; diff --git a/packages/app/src/screens/workspace/workspace-agent-visibility.ts b/packages/app/src/screens/workspace/workspace-agent-visibility.ts index 783b23a93..a15a899b3 100644 --- a/packages/app/src/screens/workspace/workspace-agent-visibility.ts +++ b/packages/app/src/screens/workspace/workspace-agent-visibility.ts @@ -12,11 +12,11 @@ export interface WorkspaceAgentVisibility { export function deriveWorkspaceAgentVisibility(input: { sessionAgents: Map | 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(), knownAgentIds: new Set(), @@ -26,7 +26,7 @@ export function deriveWorkspaceAgentVisibility(input: { const activeAgentIds = new Set(); const knownAgentIds = new Set(); for (const agent of sessionAgents.values()) { - if (normalizeWorkspaceId(agent.cwd) !== normalizedWorkspaceId) { + if (normalizeWorkspaceId(agent.cwd) !== normalizedWorkspaceDirectory) { continue; } knownAgentIds.add(agent.id); diff --git a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx index 16cc59f2b..90a4bdbd8 100644 --- a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx +++ b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx @@ -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 } : {}), diff --git a/packages/app/src/screens/workspace/workspace-header-source.ts b/packages/app/src/screens/workspace/workspace-header-source.ts index 4316f103b..3c9029110 100644 --- a/packages/app/src/screens/workspace/workspace-header-source.ts +++ b/packages/app/src/screens/workspace/workspace-header-source.ts @@ -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, }; } diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index f9a5b2f6e..2087cd277 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -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(() => { - 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) } - disabled={!normalizedWorkspaceId.startsWith("/")} + disabled={!workspaceDirectory} onSelect={handleCopyWorkspacePath} > Copy workspace path @@ -1952,7 +1968,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) <> @@ -2135,7 +2151,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) diff --git a/packages/app/src/screens/workspace/workspace-source-of-truth.test.ts b/packages/app/src/screens/workspace/workspace-source-of-truth.test.ts index d11968a63..1ca625126 100644 --- a/packages/app/src/screens/workspace/workspace-source-of-truth.test.ts +++ b/packages/app/src/screens/workspace/workspace-source-of-truth.test.ts @@ -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", diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index 146254e58..ecc252603 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -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, diff --git a/packages/app/src/utils/resolve-hydrated-workspace-id.ts b/packages/app/src/utils/resolve-hydrated-workspace-id.ts new file mode 100644 index 000000000..e20ae332f --- /dev/null +++ b/packages/app/src/utils/resolve-hydrated-workspace-id.ts @@ -0,0 +1,26 @@ +import type { WorkspaceDescriptor } from "@/stores/session-store"; +import { normalizeWorkspaceIdentity } from "@/utils/workspace-identity"; + +export function resolveHydratedWorkspaceId(input: { + workspaces: Iterable | 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; +} diff --git a/packages/app/src/utils/workspace-archive-navigation.test.ts b/packages/app/src/utils/workspace-archive-navigation.test.ts index df729e24f..259ea82d8 100644 --- a/packages/app/src/utils/workspace-archive-navigation.test.ts +++ b/packages/app/src/utils/workspace-archive-navigation.test.ts @@ -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, diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index fa512603d..7c6c012e9 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -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); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 128718ba7..767ff6798 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -385,6 +385,7 @@ export class AgentManager { private readonly clients = new Map(); private readonly agents = new Map(); private readonly timelineStore = new InMemoryAgentTimelineStore(); + private readonly agentsAwaitingInitialSnapshotPersist = new Set(); private readonly sessionEventTails = new Map>(); private readonly pendingForegroundRuns = new Map(); private readonly subscribers = new Set(); @@ -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 }; } diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index f912fbf7b..2ef594562 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -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, { diff --git a/packages/server/src/server/db/db-agent-snapshot-store.ts b/packages/server/src/server/db/db-agent-snapshot-store.ts index ab49b2b16..a89f7ec11 100644 --- a/packages/server/src/server/db/db-agent-snapshot-store.ts +++ b/packages/server/src/server/db/db-agent-snapshot-store.ts @@ -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); } diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 7d2234ee0..ed2102269 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -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, diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 67cabf8c3..8879c7c0f 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -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 }); + } + }); }); diff --git a/packages/server/src/server/workspace-git-metadata.ts b/packages/server/src/server/workspace-git-metadata.ts new file mode 100644 index 000000000..e3c3466c6 --- /dev/null +++ b/packages/server/src/server/workspace-git-metadata.ts @@ -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, + }; +} diff --git a/packages/server/src/server/workspace-reconciliation-service.test.ts b/packages/server/src/server/workspace-reconciliation-service.test.ts new file mode 100644 index 000000000..8dabd051a --- /dev/null +++ b/packages/server/src/server/workspace-reconciliation-service.test.ts @@ -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(); + const workspaces = new Map(); + 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) => { + 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) => { + 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); + }); +}); diff --git a/packages/server/src/server/workspace-reconciliation-service.ts b/packages/server/src/server/workspace-reconciliation-service.ts new file mode 100644 index 000000000..00eddaf4d --- /dev/null +++ b/packages/server/src/server/workspace-reconciliation-service.ts @@ -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>; + } + | { + kind: "workspace_updated"; + workspaceId: number; + directory: string; + fields: Partial>; + }; + +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 | 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 { + return this.reconcile(); + } + + private async runSafe(): Promise { + 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 { + 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(); + 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 + > = {}; + + // 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 }; + } +} diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index d142eae1a..3ca543280 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -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(),