From a7d5ab24977e7b64e7f4f24b4f691544f0256976 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 1 Apr 2026 21:28:07 +0700 Subject: [PATCH] WIP: workspace execution refactor + notification routing + setup store updates --- packages/app/e2e/helpers/workspace-setup.ts | 197 +++++++++++++++++- packages/app/e2e/sidebar-workspace.spec.ts | 5 +- .../app/e2e/workspace-setup-runtime.spec.ts | 171 +++++++++++++++ .../src/app/h/[serverId]/agent/[agentId].tsx | 10 +- packages/app/src/app/h/[serverId]/index.tsx | 6 +- packages/app/src/components/agent-list.tsx | 6 +- .../app/src/components/agent-stream-view.tsx | 7 +- .../src/components/sidebar-workspace-list.tsx | 56 +++-- .../src/components/workspace-setup-dialog.tsx | 50 +++-- packages/app/src/hooks/use-command-center.ts | 6 +- packages/app/src/panels/file-panel.tsx | 24 ++- packages/app/src/panels/launcher-panel.tsx | 35 +++- packages/app/src/panels/terminal-panel.tsx | 44 ++-- .../src/screens/agent/draft-agent-screen.tsx | 20 +- .../workspace/workspace-draft-agent-tab.tsx | 19 +- .../screens/workspace/workspace-screen.tsx | 82 ++++---- .../src/stores/workspace-setup-store.test.ts | 14 +- .../app/src/stores/workspace-setup-store.ts | 5 +- .../src/utils/notification-routing.test.ts | 14 ++ .../app/src/utils/notification-routing.ts | 2 +- .../utils/resolve-hydrated-workspace-id.ts | 26 --- .../workspace-archive-navigation.test.ts | 9 +- .../src/utils/workspace-archive-navigation.ts | 19 +- .../app/src/utils/workspace-execution.test.ts | 135 ++++++++++++ packages/app/src/utils/workspace-execution.ts | 177 ++++++++++++++++ packages/server/src/client/daemon-client.ts | 3 + packages/server/src/server/session.ts | 2 +- .../src/server/session.workspaces.test.ts | 102 +++++++++ .../server/src/server/worktree-session.ts | 15 +- 29 files changed, 1064 insertions(+), 197 deletions(-) create mode 100644 packages/app/e2e/workspace-setup-runtime.spec.ts delete mode 100644 packages/app/src/utils/resolve-hydrated-workspace-id.ts create mode 100644 packages/app/src/utils/workspace-execution.test.ts create mode 100644 packages/app/src/utils/workspace-execution.ts diff --git a/packages/app/e2e/helpers/workspace-setup.ts b/packages/app/e2e/helpers/workspace-setup.ts index 1cdaec060..0123ab634 100644 --- a/packages/app/e2e/helpers/workspace-setup.ts +++ b/packages/app/e2e/helpers/workspace-setup.ts @@ -2,6 +2,7 @@ import path from "node:path"; import { randomUUID } from "node:crypto"; import { pathToFileURL } from "node:url"; import { expect, type Page } from "@playwright/test"; +import { parseHostWorkspaceRouteFromPathname } from "../../src/utils/host-routes"; import { gotoAppShell } from "./app"; type WorkspaceSetupProgressPayload = { @@ -20,10 +21,52 @@ type WorkspaceSetupDaemonClient = { close(): Promise; openProject( cwd: string, - ): Promise<{ workspace: { id: string; name: string } | null; error: string | null }>; + ): Promise<{ + workspace: { + id: number; + name: string; + workspaceDirectory: string; + projectRootPath: string; + } | null; + error: string | null; + }>; createPaseoWorktree( input: { cwd: string; worktreeSlug?: string }, - ): Promise<{ workspace: { id: string; name: string } | null; error: string | null }>; + ): Promise<{ + workspace: { + id: number; + name: string; + workspaceDirectory: string; + projectRootPath: string; + } | null; + error: string | null; + }>; + fetchWorkspaces(): Promise<{ + entries: Array<{ + id: number; + name: string; + workspaceDirectory: string; + projectRootPath: string; + }>; + }>; + fetchAgents(): Promise<{ + entries: Array<{ + agent: { id: string; cwd: string; workspaceId?: string | null }; + }>; + }>; + fetchAgent( + agentId: string, + ): Promise<{ + agent: { id: string; cwd: string } | null; + project: unknown | null; + } | null>; + listTerminals( + cwd: string, + ): Promise<{ + cwd?: string; + terminals: Array<{ id: string; cwd: string; name: string }>; + error?: string | null; + }>; subscribeRawMessages(handler: (message: WorkspaceSetupRawMessage) => void): () => void; }; export type { WorkspaceSetupDaemonClient, WorkspaceSetupProgressPayload }; @@ -80,7 +123,12 @@ export function projectNameFromPath(repoPath: string): string { export async function openHomeWithProject(page: Page, repoPath: string): Promise { await gotoAppShell(page); - await expect(createWorkspaceButton(page, repoPath)).toBeVisible({ timeout: 30_000 }); + await expect( + page + .locator('[data-testid^="sidebar-project-row-"]') + .filter({ hasText: projectNameFromPath(repoPath) }) + .first(), + ).toBeVisible({ timeout: 30_000 }); } function createWorkspaceButton(page: Page, repoPath: string) { @@ -90,14 +138,98 @@ function createWorkspaceButton(page: Page, repoPath: string) { } async function revealWorkspaceButton(page: Page, repoPath: string): Promise { - await page.getByTestId(`sidebar-project-row-${repoPath}`).hover(); + await page + .locator('[data-testid^="sidebar-project-row-"]') + .filter({ hasText: projectNameFromPath(repoPath) }) + .first() + .hover(); } export async function createWorkspaceFromSidebar(page: Page, repoPath: string): Promise { + const button = createWorkspaceButton(page, repoPath); await revealWorkspaceButton(page, repoPath); - await expect(createWorkspaceButton(page, repoPath)).toBeEnabled({ timeout: 30_000 }); - await createWorkspaceButton(page, repoPath).click(); + await expect(button).toBeVisible({ timeout: 30_000 }); + await expect(button).toBeEnabled({ timeout: 30_000 }); + await button.click(); await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 }); + await expect(page.getByTestId("workspace-setup-dialog")).toBeVisible({ timeout: 30_000 }); +} + +export async function getCurrentWorkspaceIdFromRoute(page: Page): Promise { + await expect + .poll( + () => parseHostWorkspaceRouteFromPathname(new URL(page.url()).pathname)?.workspaceId ?? null, + { timeout: 30_000 }, + ) + .not.toBeNull(); + + const workspaceId = + parseHostWorkspaceRouteFromPathname(new URL(page.url()).pathname)?.workspaceId ?? null; + if (!workspaceId) { + throw new Error(`Expected a workspace route but found ${page.url()}`); + } + + return workspaceId; +} + +function workspaceSetupDialog(page: Page) { + return page.getByTestId("workspace-setup-dialog"); +} + +export async function createChatAgentFromWorkspaceSetup( + page: Page, + input: { message: string }, +): Promise { + const dialog = workspaceSetupDialog(page); + await dialog.getByRole("button", { name: /Chat Agent/i }).click(); + + const messageInput = dialog.getByRole("textbox", { name: "Message agent..." }).first(); + await expect(messageInput).toBeVisible({ timeout: 15_000 }); + await messageInput.fill(input.message); + + await dialog.getByRole("button", { name: "Send message" }).click(); +} + +export async function createTerminalAgentFromWorkspaceSetup( + page: Page, + input: { providerLabel: string; prompt?: string }, +): Promise { + const dialog = workspaceSetupDialog(page); + await dialog.getByRole("button", { name: /Terminal Agent/i }).click(); + + const providerButton = dialog.getByRole("button", { name: new RegExp(`^${input.providerLabel}$`, "i") }).first(); + await expect(providerButton).toBeVisible({ timeout: 15_000 }); + await providerButton.click(); + + if (input.prompt) { + const promptInput = dialog.getByPlaceholder("Optional").first(); + await expect(promptInput).toBeVisible({ timeout: 15_000 }); + await promptInput.fill(input.prompt); + } + + await dialog.getByRole("button", { name: "Launch" }).click(); +} + +export async function createStandaloneTerminalFromWorkspaceSetup(page: Page): Promise { + await workspaceSetupDialog(page) + .getByRole("button", { name: /^Terminal Create the workspace/i }) + .click(); +} + +export async function waitForWorkspaceSetupDialogToClose(page: Page, timeoutMs = 45_000): Promise { + const dialog = workspaceSetupDialog(page); + + try { + await expect(dialog).toHaveCount(0, { timeout: timeoutMs }); + } catch (error) { + const dialogText = (await dialog.textContent().catch(() => null))?.replace(/\s+/g, " ").trim(); + throw new Error( + dialogText + ? `Workspace setup dialog stayed open. Visible text: ${dialogText}` + : `Workspace setup dialog did not close within ${timeoutMs}ms`, + { cause: error }, + ); + } } export async function expectSetupPanel(page: Page): Promise { @@ -133,7 +265,58 @@ export async function createWorkspaceThroughDaemon( if (!result.workspace || result.error) { throw new Error(result.error ?? `Failed to create workspace for ${input.cwd}`); } - return result.workspace; + return { + id: String(result.workspace.id), + name: result.workspace.name, + }; +} + +export async function findWorktreeWorkspaceForProject( + client: WorkspaceSetupDaemonClient, + repoPath: string, +): Promise<{ + id: string; + name: string; + projectRootPath: string; + workspaceDirectory: string; +}> { + const payload = await client.fetchWorkspaces(); + const workspace = + payload.entries.find( + (entry) => + entry.projectRootPath === repoPath && entry.workspaceDirectory !== repoPath, + ) ?? null; + if (!workspace) { + throw new Error(`Failed to find created worktree workspace for ${repoPath}`); + } + return { + id: String(workspace.id), + name: workspace.name, + projectRootPath: workspace.projectRootPath, + workspaceDirectory: workspace.workspaceDirectory, + }; +} + +export async function fetchWorkspaceById( + client: WorkspaceSetupDaemonClient, + workspaceId: string, +): Promise<{ + id: number; + name: string; + workspaceDirectory: string; + projectRootPath: string; +}> { + const parsedWorkspaceId = Number(workspaceId); + if (!Number.isInteger(parsedWorkspaceId)) { + throw new Error(`Workspace id is not numeric: ${workspaceId}`); + } + + const payload = await client.fetchWorkspaces(); + const workspace = payload.entries.find((entry) => entry.id === parsedWorkspaceId) ?? null; + if (!workspace) { + throw new Error(`Workspace not found: ${workspaceId}`); + } + return workspace; } export async function waitForWorkspaceSetupProgress( diff --git a/packages/app/e2e/sidebar-workspace.spec.ts b/packages/app/e2e/sidebar-workspace.spec.ts index a9cab9c31..4d7b285b7 100644 --- a/packages/app/e2e/sidebar-workspace.spec.ts +++ b/packages/app/e2e/sidebar-workspace.spec.ts @@ -50,7 +50,10 @@ async function openProjectViaDaemon( if (!result.workspace || result.error) { throw new Error(result.error ?? `Failed to open project ${cwd}`); } - return result.workspace; + return { + id: String(result.workspace.id), + name: result.workspace.name, + }; } async function openWorkspaceFromSidebar(page: import("@playwright/test").Page, workspaceId: string) { diff --git a/packages/app/e2e/workspace-setup-runtime.spec.ts b/packages/app/e2e/workspace-setup-runtime.spec.ts new file mode 100644 index 000000000..ca5a2ab79 --- /dev/null +++ b/packages/app/e2e/workspace-setup-runtime.spec.ts @@ -0,0 +1,171 @@ +import { existsSync } from "node:fs"; +import { expect, test } from "./fixtures"; +import { createTempGitRepo } from "./helpers/workspace"; +import { + connectWorkspaceSetupClient, + createChatAgentFromWorkspaceSetup, + createStandaloneTerminalFromWorkspaceSetup, + createTerminalAgentFromWorkspaceSetup, + createWorkspaceFromSidebar, + findWorktreeWorkspaceForProject, + openHomeWithProject, + type WorkspaceSetupDaemonClient, +} from "./helpers/workspace-setup"; + +async function openWorkspaceSetupDialogFromSidebar( + page: import("@playwright/test").Page, + repoPath: string, +): Promise { + await openHomeWithProject(page, repoPath); + await createWorkspaceFromSidebar(page, repoPath); +} + +async function expectCreatedWorkspaceRoute( + client: WorkspaceSetupDaemonClient, + originalProjectPath: string, +) { + await expect + .poll( + async () => { + try { + return await findWorktreeWorkspaceForProject(client, originalProjectPath); + } catch { + return null; + } + }, + { timeout: 30_000 }, + ) + .not.toBeNull(); + + const workspace = await findWorktreeWorkspaceForProject(client, originalProjectPath); + + expect(workspace.workspaceDirectory).not.toBe(originalProjectPath); + expect(existsSync(workspace.workspaceDirectory)).toBe(true); + return workspace; +} + +async function waitForNewWorkspaceAgent( + client: WorkspaceSetupDaemonClient, + expectedWorkspaceDirectory: string, + agentIdsBefore: Set, +) { + await expect + .poll( + async () => { + const payload = await client.fetchAgents(); + return ( + payload.entries.find( + (entry) => + !agentIdsBefore.has(entry.agent.id) && + entry.agent.cwd === expectedWorkspaceDirectory, + )?.agent ?? null + ); + }, + { timeout: 30_000 }, + ) + .not.toBeNull(); + + const payload = await client.fetchAgents(); + const agent = + payload.entries.find( + (entry) => + !agentIdsBefore.has(entry.agent.id) && entry.agent.cwd === expectedWorkspaceDirectory, + )?.agent ?? null; + if (!agent) { + throw new Error(`Expected a new agent for workspace ${expectedWorkspaceDirectory}`); + } + return agent; +} + +test.describe("Workspace setup runtime authority", () => { + test.describe.configure({ retries: 1 }); + + test("first chat agent attaches to the created workspace", async ({ page }) => { + test.setTimeout(90_000); + + const client = await connectWorkspaceSetupClient(); + const repo = await createTempGitRepo("workspace-setup-chat-"); + + try { + await client.openProject(repo.path); + await openWorkspaceSetupDialogFromSidebar(page, repo.path); + const agentIdsBefore = new Set((await client.fetchAgents()).entries.map((entry) => entry.agent.id)); + + await createChatAgentFromWorkspaceSetup(page, { + message: `workspace-setup-chat-${Date.now()}`, + }); + + const workspace = await expectCreatedWorkspaceRoute(client, repo.path); + const agent = await waitForNewWorkspaceAgent( + client, + workspace.workspaceDirectory, + agentIdsBefore, + ); + expect(agent.cwd).toBe(workspace.workspaceDirectory); + expect(agent.cwd).not.toBe(repo.path); + } finally { + await client.close(); + await repo.cleanup(); + } + }); + + test("first terminal agent attaches to the created workspace", async ({ page }) => { + test.setTimeout(90_000); + + const client = await connectWorkspaceSetupClient(); + const repo = await createTempGitRepo("workspace-setup-terminal-agent-"); + + try { + await client.openProject(repo.path); + await openWorkspaceSetupDialogFromSidebar(page, repo.path); + const agentIdsBefore = new Set((await client.fetchAgents()).entries.map((entry) => entry.agent.id)); + + await createTerminalAgentFromWorkspaceSetup(page, { + providerLabel: "Claude", + prompt: `workspace-setup-terminal-agent-${Date.now()}`, + }); + + const workspace = await expectCreatedWorkspaceRoute(client, repo.path); + const agent = await waitForNewWorkspaceAgent( + client, + workspace.workspaceDirectory, + agentIdsBefore, + ); + expect(agent.cwd).toBe(workspace.workspaceDirectory); + expect(agent.cwd).not.toBe(repo.path); + } finally { + await client.close(); + await repo.cleanup(); + } + }); + + test("first terminal attaches to the created workspace", async ({ page }) => { + test.setTimeout(90_000); + + const client = await connectWorkspaceSetupClient(); + const repo = await createTempGitRepo("workspace-setup-terminal-"); + + try { + await client.openProject(repo.path); + await openWorkspaceSetupDialogFromSidebar(page, repo.path); + + await createStandaloneTerminalFromWorkspaceSetup(page); + + const workspace = await expectCreatedWorkspaceRoute(client, repo.path); + + await expect + .poll( + async () => + (await client.listTerminals(workspace.workspaceDirectory)).terminals.length > 0, + { timeout: 30_000 }, + ) + .toBe(true); + expect( + (await client.listTerminals(repo.path)).terminals.length, + ).toBe(0); + } finally { + await client.close(); + await repo.cleanup(); + } + }); +}); diff --git a/packages/app/src/app/h/[serverId]/agent/[agentId].tsx b/packages/app/src/app/h/[serverId]/agent/[agentId].tsx index 4ba6a5c8e..3ffa090b6 100644 --- a/packages/app/src/app/h/[serverId]/agent/[agentId].tsx +++ b/packages/app/src/app/h/[serverId]/agent/[agentId].tsx @@ -3,7 +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 { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution"; import { prepareWorkspaceTab } from "@/utils/workspace-navigation"; export default function HostAgentReadyRoute() { @@ -33,9 +33,9 @@ export default function HostAgentReadyRoute() { if (!serverId || !agentId) { return null; } - return resolveHydratedWorkspaceId({ + return resolveWorkspaceIdByExecutionDirectory({ workspaces: state.sessions[serverId]?.workspaces?.values(), - path: state.sessions[serverId]?.agents?.get(agentId)?.cwd, + workspaceDirectory: state.sessions[serverId]?.agents?.get(agentId)?.cwd, }); }); @@ -93,9 +93,9 @@ export default function HostAgentReadyRoute() { return; } const cwd = result?.agent?.cwd?.trim(); - const workspaceId = resolveHydratedWorkspaceId({ + const workspaceId = resolveWorkspaceIdByExecutionDirectory({ workspaces: sessionWorkspaces?.values(), - path: cwd, + workspaceDirectory: cwd, }); if (!workspaceId && !hasHydratedWorkspaces) { return; diff --git a/packages/app/src/app/h/[serverId]/index.tsx b/packages/app/src/app/h/[serverId]/index.tsx index 089dccf21..973570cdf 100644 --- a/packages/app/src/app/h/[serverId]/index.tsx +++ b/packages/app/src/app/h/[serverId]/index.tsx @@ -8,7 +8,7 @@ import { buildHostRootRoute, buildHostWorkspaceOpenRoute, } from "@/utils/host-routes"; -import { resolveHydratedWorkspaceId } from "@/utils/resolve-hydrated-workspace-id"; +import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution"; import { prepareWorkspaceTab } from "@/utils/workspace-navigation"; const HOST_ROOT_REDIRECT_DELAY_MS = 300; @@ -66,9 +66,9 @@ export default function HostIndexRoute() { }); const primaryAgent = visibleAgents[0]; - const primaryAgentWorkspaceId = resolveHydratedWorkspaceId({ + const primaryAgentWorkspaceId = resolveWorkspaceIdByExecutionDirectory({ workspaces: sessionWorkspaces?.values(), - path: primaryAgent?.cwd, + workspaceDirectory: primaryAgent?.cwd, }); if (primaryAgent && primaryAgentWorkspaceId) { router.replace( diff --git a/packages/app/src/components/agent-list.tsx b/packages/app/src/components/agent-list.tsx index 243d0211c..07457105c 100644 --- a/packages/app/src/components/agent-list.tsx +++ b/packages/app/src/components/agent-list.tsx @@ -18,7 +18,7 @@ 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 { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution"; import { prepareWorkspaceTab } from "@/utils/workspace-navigation"; interface AgentListProps { @@ -244,9 +244,9 @@ export function AgentList({ const serverId = agent.serverId; const agentId = agent.id; - const workspaceId = resolveHydratedWorkspaceId({ + const workspaceId = resolveWorkspaceIdByExecutionDirectory({ workspaces: useSessionStore.getState().sessions[serverId]?.workspaces?.values(), - path: agent.cwd, + workspaceDirectory: agent.cwd, }); onAgentSelect?.(); diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index d6923b244..75b8f4606 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -63,7 +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 { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution"; import { prepareWorkspaceTab } from "@/utils/workspace-navigation"; import { useStableEvent } from "@/hooks/use-stable-event"; import { @@ -133,10 +133,9 @@ const AgentStreamViewComponent = forwardRef state.sessions[workspace.serverId]?.workspaces ?? EMPTY_WORKSPACES, ); const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false); + const workspaceDirectory = resolveWorkspaceExecutionDirectory({ + workspaceDirectory: workspace.workspaceDirectory, + }); const archiveStatus = useCheckoutGitActionsStore((state) => - state.getStatus({ - serverId: workspace.serverId, - cwd: workspace.workspaceDirectory ?? workspace.projectRootPath ?? "", - actionId: "archive-worktree", - }), + workspaceDirectory + ? state.getStatus({ + serverId: workspace.serverId, + cwd: workspaceDirectory, + actionId: "archive-worktree", + }) + : "idle", ); const isWorktree = workspace.workspaceKind === "worktree"; const isArchiving = isWorktree ? archiveStatus === "pending" : isArchivingWorkspace; @@ -1025,7 +1037,17 @@ function WorkspaceRowWithMenu({ if (!confirmed) { return; } - const workspaceDirectory = workspace.workspaceDirectory ?? workspace.projectRootPath; + let workspaceDirectory: string; + try { + workspaceDirectory = requireWorkspaceExecutionDirectory({ + workspaceId: workspace.workspaceId, + workspaceDirectory: workspace.workspaceDirectory, + }); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Workspace path not available"); + return; + } + if (!workspaceDirectory) { toast.error("Workspace path not available"); return; @@ -1050,7 +1072,6 @@ function WorkspaceRowWithMenu({ redirectAfterArchive, toast, workspace.name, - workspace.projectRootPath, workspace.workspaceDirectory, workspace.serverId, workspace.workspaceId, @@ -1102,14 +1123,19 @@ function WorkspaceRowWithMenu({ ]); const handleCopyPath = useCallback(() => { - const workspaceDirectory = workspace.workspaceDirectory ?? workspace.projectRootPath; - if (!workspaceDirectory) { - toast.error("Workspace path not available"); + let workspaceDirectory: string; + try { + workspaceDirectory = requireWorkspaceExecutionDirectory({ + workspaceId: workspace.workspaceId, + workspaceDirectory: workspace.workspaceDirectory, + }); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Workspace path not available"); return; } void Clipboard.setStringAsync(workspaceDirectory); toast.copied("Path copied"); - }, [toast, workspace.projectRootPath, workspace.workspaceDirectory]); + }, [toast, workspace.workspaceDirectory, workspace.workspaceId]); const handleCopyBranchName = useCallback(() => { void Clipboard.setStringAsync(workspace.name); diff --git a/packages/app/src/components/workspace-setup-dialog.tsx b/packages/app/src/components/workspace-setup-dialog.tsx index 58417d6c2..00c71d02d 100644 --- a/packages/app/src/components/workspace-setup-dialog.tsx +++ b/packages/app/src/components/workspace-setup-dialog.tsx @@ -17,6 +17,10 @@ import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store"; import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { encodeImages } from "@/utils/encode-images"; import { toErrorMessage } from "@/utils/error-messages"; +import { + requireWorkspaceExecutionAuthority, + requireWorkspaceRecordId, +} from "@/utils/workspace-execution"; import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation"; import type { MessagePayload } from "./message-input"; @@ -41,19 +45,21 @@ export function WorkspaceSetupDialog() { ); const serverId = pendingWorkspaceSetup?.serverId ?? ""; - const projectPath = pendingWorkspaceSetup?.projectPath ?? ""; - const projectName = pendingWorkspaceSetup?.projectName?.trim() ?? ""; + const sourceDirectory = pendingWorkspaceSetup?.sourceDirectory ?? ""; + const displayName = pendingWorkspaceSetup?.displayName?.trim() ?? ""; const workspace = createdWorkspace; const client = useHostRuntimeClient(serverId); const isConnected = useHostRuntimeIsConnected(serverId); const chatDraft = useAgentInputDraft({ - draftKey: `workspace-setup:${serverId}:${projectPath}`, + draftKey: `workspace-setup:${serverId}:${sourceDirectory}`, composer: { initialServerId: serverId || null, - initialValues: projectPath ? { workingDir: projectPath } : undefined, + initialValues: workspace?.workspaceDirectory + ? { workingDir: workspace.workspaceDirectory } + : undefined, isVisible: pendingWorkspaceSetup !== null, onlineServerIds: isConnected && serverId ? [serverId] : [], - lockedWorkingDir: workspace?.projectRootPath ?? projectPath, + lockedWorkingDir: workspace?.workspaceDirectory || undefined, }, }); const composerState = chatDraft.composerState; @@ -70,7 +76,7 @@ export function WorkspaceSetupDialog() { setErrorMessage(null); setCreatedWorkspace(null); setPendingAction(null); - }, [pendingWorkspaceSetup?.creationMethod, projectPath, serverId]); + }, [pendingWorkspaceSetup?.creationMethod, serverId, sourceDirectory]); const handleClose = useCallback(() => { clearWorkspaceSetup(); @@ -116,10 +122,10 @@ export function WorkspaceSetupDialog() { const payload = pendingWorkspaceSetup.creationMethod === "create_worktree" ? await connectedClient.createPaseoWorktree({ - cwd: pendingWorkspaceSetup.projectPath, + cwd: pendingWorkspaceSetup.sourceDirectory, worktreeSlug: createNameId(), }) - : await connectedClient.openProject(pendingWorkspaceSetup.projectPath); + : await connectedClient.openProject(pendingWorkspaceSetup.sourceDirectory); if (payload.error || !payload.workspace) { throw new Error( @@ -149,13 +155,13 @@ export function WorkspaceSetupDialog() { const current = useWorkspaceSetupStore.getState().pendingWorkspaceSetup; return ( current?.serverId === pendingWorkspaceSetup?.serverId && - current?.projectPath === pendingWorkspaceSetup?.projectPath && + current?.sourceDirectory === pendingWorkspaceSetup?.sourceDirectory && current?.creationMethod === pendingWorkspaceSetup?.creationMethod ); }, [ pendingWorkspaceSetup?.creationMethod, - pendingWorkspaceSetup?.projectPath, pendingWorkspaceSetup?.serverId, + pendingWorkspaceSetup?.sourceDirectory, ]); const handleCreateChatAgent = useCallback( @@ -170,10 +176,11 @@ export function WorkspaceSetupDialog() { } const encodedImages = await encodeImages(images); - const workspaceDirectory = workspace.projectRootPath ?? projectPath; + const workspaceDirectory = requireWorkspaceExecutionAuthority({ workspace }).workspaceDirectory; const agent = await connectedClient.createAgent({ provider: composerState.selectedProvider, cwd: workspaceDirectory, + workspaceId: requireWorkspaceRecordId(workspace.id), ...(composerState.modeOptions.length > 0 && composerState.selectedMode !== "" ? { modeId: composerState.selectedMode } : {}), @@ -227,10 +234,11 @@ export function WorkspaceSetupDialog() { throw new Error("Workspace setup composer state is required"); } - const workspaceDirectory = workspace.projectRootPath ?? projectPath; + const workspaceDirectory = requireWorkspaceExecutionAuthority({ workspace }).workspaceDirectory; const agent = await connectedClient.createAgent({ provider: composerState.selectedProvider, cwd: workspaceDirectory, + workspaceId: requireWorkspaceRecordId(workspace.id), terminal: true, ...(terminalPrompt.trim() ? { initialPrompt: terminalPrompt.trim() } : {}), }); @@ -274,11 +282,7 @@ export function WorkspaceSetupDialog() { setErrorMessage(null); const workspace = await ensureWorkspace(); const connectedClient = withConnectedClient(); - const workspaceDirectory = workspace.projectRootPath ?? projectPath; - - if (!workspaceDirectory) { - throw new Error("Workspace directory not found"); - } + const workspaceDirectory = requireWorkspaceExecutionAuthority({ workspace }).workspaceDirectory; const payload = await connectedClient.createTerminal(workspaceDirectory); if (payload.error || !payload.terminal) { @@ -304,12 +308,12 @@ export function WorkspaceSetupDialog() { const workspaceTitle = workspace?.name || workspace?.projectDisplayName || - projectName || - projectPath.split(/[\\/]/).filter(Boolean).pop() || - projectPath; - const workspacePath = workspace?.projectRootPath || projectPath; + displayName || + sourceDirectory.split(/[\\/]/).filter(Boolean).pop() || + sourceDirectory; + const workspacePath = workspace?.workspaceDirectory || "Workspace will be created before launch."; - if (!pendingWorkspaceSetup || !projectPath) { + if (!pendingWorkspaceSetup || !sourceDirectory) { return null; } @@ -378,7 +382,7 @@ export function WorkspaceSetupDialog() { + resolveWorkspaceExecutionAuthority({ + workspaces: state.sessions[serverId]?.workspaces, + workspaceId, + }), + ); invariant(target.kind === "file", "FilePanel requires file target"); - return ; + if (!authority) { + return ( + + Workspace execution directory not found. + + ); + } + return ( + + ); } export const filePanelRegistration: PanelRegistration<"file"> = { diff --git a/packages/app/src/panels/launcher-panel.tsx b/packages/app/src/panels/launcher-panel.tsx index 3f6b781a2..a5dddb937 100644 --- a/packages/app/src/panels/launcher-panel.tsx +++ b/packages/app/src/panels/launcher-panel.tsx @@ -19,6 +19,10 @@ import { useProviderRecency } from "@/stores/provider-recency-store"; import { useSessionStore } from "@/stores/session-store"; import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { toErrorMessage } from "@/utils/error-messages"; +import { + getWorkspaceExecutionAuthority, + requireWorkspaceRecordId, +} from "@/utils/workspace-execution"; const MAX_VISIBLE_PROVIDER_TILES = 4; @@ -36,11 +40,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 workspaces = useSessionStore((state) => state.sessions[serverId]?.workspaces); + const workspaceAuthority = getWorkspaceExecutionAuthority({ workspaces, workspaceId }); + const workspaceDirectory = workspaceAuthority.ok + ? workspaceAuthority.authority.workspaceDirectory + : null; const { providers, recordUsage } = useProviderRecency(); const setAgents = useSessionStore((state) => state.setAgents); const [pendingAction, setPendingAction] = useState(null); @@ -62,6 +66,11 @@ function LauncherPanel() { setErrorMessage(!workspaceDirectory ? "Workspace directory not found" : "Host is not connected"); return; } + if (!workspaceAuthority.ok) { + setErrorMessage(workspaceAuthority.message); + return; + } + const persistedWorkspaceId = requireWorkspaceRecordId(workspaceAuthority.authority.workspaceId); setPendingAction(providerId); setErrorMessage(null); @@ -70,6 +79,7 @@ function LauncherPanel() { const agent = await client.createAgent({ provider: providerId, cwd: workspaceDirectory, + workspaceId: persistedWorkspaceId, terminal: true, }); recordUsage(providerId); @@ -87,7 +97,16 @@ function LauncherPanel() { setPendingAction((current) => (current === providerId ? null : current)); } }, - [client, isConnected, recordUsage, retargetCurrentTab, serverId, setAgents, workspaceDirectory], + [ + client, + isConnected, + recordUsage, + retargetCurrentTab, + serverId, + setAgents, + workspaceAuthority, + workspaceDirectory, + ], ); const openDraftTab = useCallback(() => { @@ -131,7 +150,9 @@ function LauncherPanel() { return ( - + + {workspaceAuthority.ok ? "Workspace execution directory not found." : workspaceAuthority.message} + ); diff --git a/packages/app/src/panels/terminal-panel.tsx b/packages/app/src/panels/terminal-panel.tsx index 3d2c65a28..b6f9a2bf5 100644 --- a/packages/app/src/panels/terminal-panel.tsx +++ b/packages/app/src/panels/terminal-panel.tsx @@ -1,6 +1,6 @@ import { useQuery } from "@tanstack/react-query"; import { Terminal } from "lucide-react-native"; -import { View } from "react-native"; +import { Text, View } from "react-native"; import { useIsFocused } from "@react-navigation/native"; import invariant from "tiny-invariant"; import type { ListTerminalsResponse } from "@server/shared/messages"; @@ -8,6 +8,7 @@ import { TerminalPane } from "@/components/terminal-pane"; import { usePaneContext } from "@/panels/pane-context"; import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry"; import { useSessionStore } from "@/stores/session-store"; +import { getWorkspaceExecutionAuthority } from "@/utils/workspace-execution"; type ListTerminalsPayload = ListTerminalsResponse["payload"]; @@ -24,18 +25,22 @@ 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 workspaces = useSessionStore((state) => state.sessions[context.serverId]?.workspaces); + const workspaceAuthority = getWorkspaceExecutionAuthority({ + workspaces, + workspaceId: context.workspaceId, + }); + const workspaceDirectory = workspaceAuthority.ok + ? workspaceAuthority.authority.workspaceDirectory + : null; const terminalsQuery = useQuery({ queryKey: ["terminals", context.serverId, workspaceDirectory] as const, enabled: Boolean(client && workspaceDirectory), queryFn: async (): Promise => { if (!client || !workspaceDirectory) { - return { cwd: workspaceDirectory ?? "", terminals: [], requestId: "missing-client" }; + throw new Error( + workspaceAuthority.ok ? "Workspace execution directory not found" : workspaceAuthority.message, + ); } return client.listTerminals(workspaceDirectory); }, @@ -56,18 +61,27 @@ 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, - ); + const workspaces = useSessionStore((state) => state.sessions[serverId]?.workspaces); + const workspaceAuthority = getWorkspaceExecutionAuthority({ workspaces, workspaceId }); + const workspaceDirectory = workspaceAuthority.ok + ? workspaceAuthority.authority.workspaceDirectory + : null; invariant(target.kind === "terminal", "TerminalPanel requires terminal target"); - if (!isFocused || !workspaceDirectory) { + if (!isFocused) { return ; } + if (!workspaceDirectory) { + return ( + + + {workspaceAuthority.ok ? "Workspace execution directory not found." : workspaceAuthority.message} + + + ); + } + return ( + resolveWorkspaceIdByExecutionDirectory({ + workspaces: selectedServerId ? state.sessions[selectedServerId]?.workspaces?.values() : null, + workspaceDirectory: explorerCwd, + }), + [explorerCwd, selectedServerId], + ), + ); const canOpenExplorer = draftExplorerCheckout !== null; const openExplorerForDraftCheckout = useCallback(() => { if (!draftExplorerCheckout) { @@ -910,9 +920,9 @@ function DraftAgentScreenContent({ const createdWorkingDir = typeof result.cwd === "string" ? result.cwd.trim() : ""; const configuredWorkingDir = config.cwd.trim(); - const workspaceId = resolveHydratedWorkspaceId({ + const workspaceId = resolveWorkspaceIdByExecutionDirectory({ workspaces: useSessionStore.getState().sessions[selectedServerId]?.workspaces?.values(), - path: createdWorkingDir.length > 0 ? createdWorkingDir : configuredWorkingDir, + workspaceDirectory: createdWorkingDir.length > 0 ? createdWorkingDir : configuredWorkingDir, }); return { @@ -1218,7 +1228,7 @@ function DraftAgentScreenContent({ {!isMobile && isExplorerOpen && explorerServerId && draftExplorerCheckout ? ( @@ -1241,7 +1251,7 @@ function DraftAgentScreenContent({ {isMobile && explorerServerId && draftExplorerCheckout ? ( 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 90a4bdbd8..4647480b9 100644 --- a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx +++ b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx @@ -13,6 +13,10 @@ import { buildWorkspaceDraftAgentConfig } from "@/screens/workspace/workspace-dr import { buildDraftStoreKey } from "@/stores/draft-keys"; import { type Agent, useSessionStore } from "@/stores/session-store"; import { encodeImages } from "@/utils/encode-images"; +import { + getWorkspaceExecutionAuthority, + requireWorkspaceRecordId, +} from "@/utils/workspace-execution"; import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus"; import type { AgentCapabilityFlags } from "@server/server/agent/agent-sdk-types"; import type { AgentSnapshotPayload } from "@server/shared/messages"; @@ -49,11 +53,10 @@ 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 workspaces = useSessionStore((state) => state.sessions[serverId]?.workspaces); + const workspaceAuthority = getWorkspaceExecutionAuthority({ workspaces, workspaceId }); + const workspaceExecutionAuthority = workspaceAuthority.ok ? workspaceAuthority.authority : null; + const workspaceDirectory = workspaceExecutionAuthority?.workspaceDirectory ?? null; const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null); const draftStoreKey = useMemo( () => @@ -69,10 +72,10 @@ export function WorkspaceDraftAgentTab({ draftKey: draftStoreKey, composer: { initialServerId: serverId, - initialValues: { workingDir: workspaceDirectory }, + initialValues: workspaceDirectory ? { workingDir: workspaceDirectory } : undefined, isVisible: true, onlineServerIds: isConnected ? [serverId] : [], - lockedWorkingDir: workspaceDirectory, + lockedWorkingDir: workspaceDirectory ?? undefined, }, }, ); @@ -152,6 +155,7 @@ export function WorkspaceDraftAgentTab({ }, createRequest: async ({ attempt, text, images }) => { invariant(workspaceDirectory, "Workspace directory is required"); + invariant(workspaceExecutionAuthority, "Workspace authority is required"); if (!client) { throw new Error("Host is not connected"); } @@ -169,6 +173,7 @@ export function WorkspaceDraftAgentTab({ const imagesData = await encodeImages(images); const result = await client.createAgent({ config, + workspaceId: requireWorkspaceRecordId(workspaceExecutionAuthority.workspaceId), ...(text ? { initialPrompt: text } : {}), clientMessageId: attempt.clientMessageId, ...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}), diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 2087cd277..1d8de58f0 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -59,7 +59,6 @@ import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher"; import { useCreateFlowStore } from "@/stores/create-flow-store"; import { decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes"; -import { normalizeWorkspaceIdentity } from "@/utils/workspace-identity"; import { normalizeWorkspaceTabTarget, workspaceTabTargetsEqual, @@ -77,6 +76,10 @@ import { useArchiveAgent } from "@/hooks/use-archive-agent"; import { useStableEvent } from "@/hooks/use-stable-event"; import { buildProviderCommand } from "@/utils/provider-command-templates"; import { generateDraftId } from "@/stores/draft-keys"; +import { + resolveWorkspaceExecutionAuthority, + resolveWorkspaceRouteId, +} from "@/utils/workspace-execution"; import { WorkspaceTabPresentationResolver, WorkspaceTabIcon, @@ -554,23 +557,13 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled); const normalizedServerId = trimNonEmpty(decodeSegment(serverId)) ?? ""; - 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 normalizedWorkspaceId = + resolveWorkspaceRouteId({ + routeWorkspaceId: decodeWorkspaceIdFromPathSegment(workspaceId), + }) ?? ""; + const sessionWorkspaces = useSessionStore( + (state) => state.sessions[normalizedServerId]?.workspaces, + ); const workspaceTerminalScopeKey = normalizedServerId && normalizedWorkspaceId @@ -583,11 +576,17 @@ 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 workspaceDescriptor = sessionWorkspaces?.get(normalizedWorkspaceId) ?? null; + const workspaceAuthority = useMemo( + () => + resolveWorkspaceExecutionAuthority({ + workspaces: sessionWorkspaces, + workspaceId: normalizedWorkspaceId, + }), + [normalizedWorkspaceId, sessionWorkspaces], ); - const workspaceDirectory = - workspaceDescriptor?.workspaceDirectory ?? workspaceDescriptor?.projectRootPath ?? null; + const workspaceDirectory = workspaceAuthority?.workspaceDirectory ?? null; + const isMissingWorkspaceExecutionAuthority = Boolean(workspaceDescriptor && !workspaceAuthority); const workspaceAgentVisibility = useStoreWithEqualityFn( useSessionStore, @@ -633,7 +632,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) terminals: current?.terminals ?? [], terminal: createdTerminal, }); - const cwd = current?.cwd ?? workspaceDirectory ?? undefined; + const cwd = current?.cwd ?? workspaceDirectory; return { ...(cwd ? { cwd } : {}), terminals: nextTerminals, @@ -706,7 +705,10 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) }, [client, isConnected, queryClient, terminalsQueryKey, workspaceDirectory]); const checkoutQuery = useQuery({ - queryKey: checkoutStatusQueryKey(normalizedServerId, workspaceDirectory ?? ""), + queryKey: checkoutStatusQueryKey( + normalizedServerId, + workspaceDirectory ?? `missing-workspace-directory:${normalizedWorkspaceId}`, + ), enabled: Boolean(client && isConnected) && Boolean(workspaceDirectory), @@ -1749,6 +1751,12 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) + ) : isMissingWorkspaceExecutionAuthority ? ( + + + Workspace execution directory is missing. Reload workspace data before opening tabs. + + ) : !activeTabDescriptor ? ( !hasHydratedAgents ? ( @@ -1966,10 +1974,12 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) {!isMobile && isGitCheckout ? ( <> - + {workspaceDirectory ? ( + + ) : null} {(!isFocusModeEnabled || isMobile) && ( - + workspaceDirectory ? ( + + ) : null )} diff --git a/packages/app/src/stores/workspace-setup-store.test.ts b/packages/app/src/stores/workspace-setup-store.test.ts index 1271b75cf..59f04350c 100644 --- a/packages/app/src/stores/workspace-setup-store.test.ts +++ b/packages/app/src/stores/workspace-setup-store.test.ts @@ -6,19 +6,21 @@ describe("workspace-setup-store", () => { useWorkspaceSetupStore.setState({ pendingWorkspaceSetup: null }); }); - it("tracks deferred project setup by path instead of a created workspace", () => { + it("tracks deferred workspace setup by source directory and optional workspace id", () => { useWorkspaceSetupStore.getState().beginWorkspaceSetup({ serverId: "server-1", - projectPath: "/Users/test/project", - projectName: "project", + sourceDirectory: "/Users/test/project", + sourceWorkspaceId: "42", + displayName: "project", creationMethod: "open_project", navigationMethod: "replace", }); expect(useWorkspaceSetupStore.getState().pendingWorkspaceSetup).toEqual({ serverId: "server-1", - projectPath: "/Users/test/project", - projectName: "project", + sourceDirectory: "/Users/test/project", + sourceWorkspaceId: "42", + displayName: "project", creationMethod: "open_project", navigationMethod: "replace", }); @@ -27,7 +29,7 @@ describe("workspace-setup-store", () => { it("clears pending setup state", () => { useWorkspaceSetupStore.getState().beginWorkspaceSetup({ serverId: "server-1", - projectPath: "/Users/test/project", + sourceDirectory: "/Users/test/project", creationMethod: "create_worktree", navigationMethod: "navigate", }); diff --git a/packages/app/src/stores/workspace-setup-store.ts b/packages/app/src/stores/workspace-setup-store.ts index f36907619..ba24dde8d 100644 --- a/packages/app/src/stores/workspace-setup-store.ts +++ b/packages/app/src/stores/workspace-setup-store.ts @@ -5,8 +5,9 @@ export type WorkspaceCreationMethod = "open_project" | "create_worktree"; export interface PendingWorkspaceSetup { serverId: string; - projectPath: string; - projectName?: string; + sourceDirectory: string; + sourceWorkspaceId?: string; + displayName?: string; creationMethod: WorkspaceCreationMethod; navigationMethod: WorkspaceSetupNavigationMethod; } diff --git a/packages/app/src/utils/notification-routing.test.ts b/packages/app/src/utils/notification-routing.test.ts index ecd044a06..099b20ef1 100644 --- a/packages/app/src/utils/notification-routing.test.ts +++ b/packages/app/src/utils/notification-routing.test.ts @@ -28,6 +28,20 @@ describe("resolveNotificationTarget", () => { workspaceId: null, }); }); + + it("does not treat cwd as a workspace id alias", () => { + expect( + resolveNotificationTarget({ + serverId: "srv-1", + agentId: "agent-1", + cwd: "/tmp/repo", + }), + ).toEqual({ + serverId: "srv-1", + agentId: "agent-1", + workspaceId: null, + }); + }); }); describe("buildNotificationRoute", () => { diff --git a/packages/app/src/utils/notification-routing.ts b/packages/app/src/utils/notification-routing.ts index 6b0f1b6b0..d1de8e444 100644 --- a/packages/app/src/utils/notification-routing.ts +++ b/packages/app/src/utils/notification-routing.ts @@ -23,7 +23,7 @@ export function resolveNotificationTarget(data: NotificationData): { return { serverId: readNonEmptyString(data, "serverId"), agentId: readNonEmptyString(data, "agentId"), - workspaceId: readNonEmptyString(data, "workspaceId") ?? readNonEmptyString(data, "cwd"), + workspaceId: readNonEmptyString(data, "workspaceId"), }; } diff --git a/packages/app/src/utils/resolve-hydrated-workspace-id.ts b/packages/app/src/utils/resolve-hydrated-workspace-id.ts deleted file mode 100644 index e20ae332f..000000000 --- a/packages/app/src/utils/resolve-hydrated-workspace-id.ts +++ /dev/null @@ -1,26 +0,0 @@ -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 259ea82d8..266a9fd14 100644 --- a/packages/app/src/utils/workspace-archive-navigation.test.ts +++ b/packages/app/src/utils/workspace-archive-navigation.test.ts @@ -17,7 +17,7 @@ function workspace( projectKind: input.projectKind ?? "git", workspaceKind: input.workspaceKind ?? "worktree", name: input.name ?? input.id, - status: input.status ?? "done", + status: input.status ?? "running", activityAt: input.activityAt ?? null, diffStat: input.diffStat ?? null, }; @@ -38,7 +38,7 @@ describe("resolveWorkspaceArchiveRedirectWorkspaceId", () => { ).toBe("/repo"); }); - it("falls back to the project root path when the root checkout is not in the visible workspace list", () => { + it("falls back to the host root route when no sibling workspace target exists", () => { const workspaces = [ workspace({ id: "/repo/.paseo/worktrees/feature", @@ -48,11 +48,12 @@ describe("resolveWorkspaceArchiveRedirectWorkspaceId", () => { ]; expect( - resolveWorkspaceArchiveRedirectWorkspaceId({ + buildWorkspaceArchiveRedirectRoute({ + serverId: "server-1", archivedWorkspaceId: "/repo/.paseo/worktrees/feature", workspaces, }), - ).toBe("/repo"); + ).toBe("/h/server-1"); }); it("falls back to the host root route when no alternate workspace target exists", () => { diff --git a/packages/app/src/utils/workspace-archive-navigation.ts b/packages/app/src/utils/workspace-archive-navigation.ts index 271e8ad14..a1ef64a0f 100644 --- a/packages/app/src/utils/workspace-archive-navigation.ts +++ b/packages/app/src/utils/workspace-archive-navigation.ts @@ -1,20 +1,14 @@ import type { WorkspaceDescriptor } from "@/stores/session-store"; import { buildHostRootRoute, buildHostWorkspaceRoute } from "@/utils/host-routes"; - -function trimNonEmpty(value: string | null | undefined): string | null { - if (typeof value !== "string") { - return null; - } - - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; -} +import { resolveWorkspaceRouteId } from "@/utils/workspace-execution"; export function resolveWorkspaceArchiveRedirectWorkspaceId(input: { archivedWorkspaceId: string; workspaces: Iterable; }): string | null { - const archivedWorkspaceId = trimNonEmpty(input.archivedWorkspaceId); + const archivedWorkspaceId = resolveWorkspaceRouteId({ + routeWorkspaceId: input.archivedWorkspaceId, + }); if (!archivedWorkspaceId) { return null; } @@ -38,11 +32,6 @@ export function resolveWorkspaceArchiveRedirectWorkspaceId(input: { return rootCheckoutWorkspace.id; } - const fallbackProjectRootPath = trimNonEmpty(archivedWorkspace.projectRootPath); - if (fallbackProjectRootPath && fallbackProjectRootPath !== archivedWorkspace.id) { - return fallbackProjectRootPath; - } - const siblingWorkspace = sameProjectWorkspaces.find((workspace) => workspace.id !== archivedWorkspace.id) ?? null; return siblingWorkspace?.id ?? null; diff --git a/packages/app/src/utils/workspace-execution.test.ts b/packages/app/src/utils/workspace-execution.test.ts new file mode 100644 index 000000000..17b9a78cc --- /dev/null +++ b/packages/app/src/utils/workspace-execution.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from "vitest"; +import type { WorkspaceDescriptor } from "@/stores/session-store"; +import { + getWorkspaceExecutionAuthority, + requireWorkspaceExecutionAuthority, + resolveWorkspaceIdByExecutionDirectory, + resolveWorkspaceRouteId, +} from "./workspace-execution"; + +function createWorkspace( + input: Partial & Pick, +): WorkspaceDescriptor { + return { + id: input.id, + projectId: input.projectId ?? "project-1", + projectDisplayName: input.projectDisplayName ?? "Project", + projectRootPath: input.projectRootPath ?? "/repo", + workspaceDirectory: input.workspaceDirectory ?? "/repo", + projectKind: input.projectKind ?? "git", + workspaceKind: input.workspaceKind ?? "checkout", + name: input.name ?? "main", + status: input.status ?? "running", + activityAt: input.activityAt ?? null, + diffStat: input.diffStat ?? null, + }; +} + +describe("resolveWorkspaceRouteId", () => { + it("normalizes route workspace ids", () => { + expect(resolveWorkspaceRouteId({ routeWorkspaceId: " /tmp/repo/ " })).toBe("/tmp/repo"); + }); + + it("returns null for empty values", () => { + expect(resolveWorkspaceRouteId({ routeWorkspaceId: " " })).toBeNull(); + }); +}); + +describe("resolveWorkspaceIdByExecutionDirectory", () => { + it("matches workspace directories", () => { + const workspaces = [ + createWorkspace({ + id: "workspace-1", + projectRootPath: "/repo", + workspaceDirectory: "/repo/.paseo/worktrees/feature", + }), + ]; + + expect( + resolveWorkspaceIdByExecutionDirectory({ + workspaces, + workspaceDirectory: "/repo/.paseo/worktrees/feature", + }), + ).toBe("workspace-1"); + }); + + it("does not match project root metadata", () => { + const workspaces = [ + createWorkspace({ + id: "workspace-1", + projectRootPath: "/repo", + workspaceDirectory: "/repo/.paseo/worktrees/feature", + }), + ]; + + expect( + resolveWorkspaceIdByExecutionDirectory({ + workspaces, + workspaceDirectory: "/repo", + }), + ).toBeNull(); + }); +}); + +describe("workspace execution authority", () => { + it("returns an explicit failure when workspace id is missing", () => { + expect( + getWorkspaceExecutionAuthority({ + workspaces: new Map(), + workspaceId: null, + }), + ).toEqual({ + ok: false, + reason: "workspace_id_missing", + message: "Workspace id is required.", + }); + }); + + it("returns an explicit failure when workspace directory is missing", () => { + const workspaces = new Map([ + [ + "workspace-1", + createWorkspace({ + id: "workspace-1", + workspaceDirectory: " ", + projectRootPath: "/repo", + }), + ], + ]); + + expect( + getWorkspaceExecutionAuthority({ + workspaces, + workspaceId: "workspace-1", + }), + ).toEqual({ + ok: false, + reason: "workspace_directory_missing", + message: "Workspace directory is missing for workspace workspace-1", + }); + }); + + it("never falls back to project root metadata", () => { + const workspaces = new Map([ + [ + "workspace-1", + createWorkspace({ + id: "workspace-1", + projectRootPath: "/repo", + workspaceDirectory: "/repo/.paseo/worktrees/feature", + }), + ], + ]); + + expect( + requireWorkspaceExecutionAuthority({ + workspaces, + workspaceId: "workspace-1", + }), + ).toEqual({ + workspaceId: "workspace-1", + workspaceDirectory: "/repo/.paseo/worktrees/feature", + workspace: workspaces.get("workspace-1"), + }); + }); +}); diff --git a/packages/app/src/utils/workspace-execution.ts b/packages/app/src/utils/workspace-execution.ts new file mode 100644 index 000000000..2d47e02a0 --- /dev/null +++ b/packages/app/src/utils/workspace-execution.ts @@ -0,0 +1,177 @@ +import type { WorkspaceDescriptor } from "@/stores/session-store"; +import { normalizeWorkspaceIdentity } from "@/utils/workspace-identity"; + +export type WorkspaceAuthorityResult = { + workspaceId: string; + workspaceDirectory: string; + workspace: WorkspaceDescriptor; +}; + +export type WorkspaceExecutionAuthorityFailureReason = + | "workspace_id_missing" + | "workspace_missing" + | "workspace_directory_missing"; + +export type WorkspaceExecutionAuthorityResult = + | { ok: true; authority: WorkspaceAuthorityResult } + | { + ok: false; + reason: WorkspaceExecutionAuthorityFailureReason; + message: string; + }; + +export function resolveWorkspaceRouteId(input: { + routeWorkspaceId: string | null | undefined; +}): string | null { + return normalizeWorkspaceIdentity(input.routeWorkspaceId); +} + +export function resolveWorkspaceIdByExecutionDirectory(input: { + workspaces: Iterable | null | undefined; + workspaceDirectory: string | null | undefined; +}): string | null { + const normalizedWorkspaceDirectory = normalizeWorkspaceIdentity(input.workspaceDirectory); + if (!normalizedWorkspaceDirectory) { + return null; + } + + for (const workspace of input.workspaces ?? []) { + if (normalizeWorkspaceIdentity(workspace.workspaceDirectory) === normalizedWorkspaceDirectory) { + return workspace.id; + } + } + + return null; +} + +export function getWorkspaceExecutionAuthority( + input: + | { + workspace: WorkspaceDescriptor | null | undefined; + } + | { + workspaces: Map | undefined; + workspaceId: string | null | undefined; + }, +): WorkspaceExecutionAuthorityResult { + const workspace = + "workspace" in input + ? input.workspace + : (() => { + const normalizedWorkspaceId = normalizeWorkspaceIdentity(input.workspaceId); + if (!normalizedWorkspaceId) { + return null; + } + return input.workspaces?.get(normalizedWorkspaceId) ?? null; + })(); + + if ("workspaces" in input) { + const normalizedWorkspaceId = normalizeWorkspaceIdentity(input.workspaceId); + if (!normalizedWorkspaceId) { + return { + ok: false, + reason: "workspace_id_missing", + message: "Workspace id is required.", + }; + } + } + + if (!workspace) { + return { + ok: false, + reason: "workspace_missing", + message: + "workspaces" in input + ? `Workspace not found: ${String(input.workspaceId ?? "")}` + : "Workspace not found.", + }; + } + + const workspaceDirectory = normalizeWorkspaceIdentity(workspace.workspaceDirectory); + if (!workspaceDirectory) { + return { + ok: false, + reason: "workspace_directory_missing", + message: `Workspace directory is missing for workspace ${workspace.id}`, + }; + } + + return { + ok: true, + authority: { + workspaceId: workspace.id, + workspaceDirectory, + workspace, + }, + }; +} + +export function requireWorkspaceExecutionAuthority( + input: + | { + workspace: WorkspaceDescriptor | null | undefined; + } + | { + workspaces: Map | undefined; + workspaceId: string | null | undefined; + }, +): WorkspaceAuthorityResult { + const result = getWorkspaceExecutionAuthority(input); + if (!result.ok) { + throw new Error(result.message); + } + return result.authority; +} + +export function requireWorkspaceRecordId(workspaceId: string): number { + const normalizedWorkspaceId = normalizeWorkspaceIdentity(workspaceId); + if (!normalizedWorkspaceId) { + throw new Error("Workspace ID is required"); + } + + const parsedWorkspaceId = Number(normalizedWorkspaceId); + if (!Number.isInteger(parsedWorkspaceId)) { + throw new Error(`Workspace ID is not a persisted record ID: ${workspaceId}`); + } + + return parsedWorkspaceId; +} + +export function resolveWorkspaceExecutionDirectory(input: { + workspaceDirectory: string | null | undefined; +}): string | null { + return normalizeWorkspaceIdentity(input.workspaceDirectory); +} + +export function requireWorkspaceExecutionDirectory(input: { + workspaceId?: string; + workspaceDirectory: string | null | undefined; +}): string { + const workspaceDirectory = resolveWorkspaceExecutionDirectory({ + workspaceDirectory: input.workspaceDirectory, + }); + if (!workspaceDirectory) { + throw new Error( + input.workspaceId + ? `Workspace directory is missing for workspace ${input.workspaceId}` + : "Workspace directory is missing.", + ); + } + return workspaceDirectory; +} + +export function resolveWorkspaceExecutionAuthority( + input: + | { + workspace: WorkspaceDescriptor | null | undefined; + } + | { + workspaces: Map | undefined; + workspaceId: string | null | undefined; + }, +): WorkspaceAuthorityResult | null { + const result = getWorkspaceExecutionAuthority(input); + return result.ok ? result.authority : null; +} + +export const parseWorkspaceRecordId = requireWorkspaceRecordId; diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index 0488d1b8f..66a411b12 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -180,6 +180,7 @@ export type CreateAgentRequestOptions = { config?: AgentSessionConfig; provider?: AgentProvider; cwd?: string; + workspaceId?: number; initialPrompt?: string; clientMessageId?: string; outputSchema?: Record; @@ -1383,6 +1384,7 @@ export class DaemonClient { type: "create_agent_request", requestId, config, + ...(typeof options.workspaceId === "number" ? { workspaceId: options.workspaceId } : {}), ...(options.initialPrompt ? { initialPrompt: options.initialPrompt } : {}), ...(options.clientMessageId ? { clientMessageId: options.clientMessageId } : {}), ...(options.outputSchema ? { outputSchema: options.outputSchema } : {}), @@ -3633,6 +3635,7 @@ function resolveAgentConfig(options: CreateAgentRequestOptions): AgentSessionCon config, provider, cwd, + workspaceId: _workspaceId, initialPrompt: _initialPrompt, images: _images, git: _git, diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index ed2102269..f7e7b5ea4 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -2522,7 +2522,7 @@ export class Session { ); await this.forwardAgentUpdate(snapshot); if (sessionConfig.terminal) { - void this.emitInitialTerminalsChangedSnapshot(sessionConfig.cwd); + void this.emitInitialTerminalsChangedSnapshot(resolvedWorkspace.directory); } if (requestId) { diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 8879c7c0f..27aa8f54b 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -630,6 +630,108 @@ describe("workspace aggregation", () => { }); }); + test("create_agent_request uses workspaceId as the execution authority", async () => { + const { session, emitted, projects, workspaces } = createSessionForWorkspaceTests(); + seedProject({ + projects, + id: 5, + directory: "/tmp/repo", + displayName: "repo", + kind: "git", + }); + seedWorkspace({ + workspaces, + id: 50, + projectId: 5, + directory: "/tmp/repo/.paseo/worktrees/feature", + displayName: "feature", + kind: "worktree", + }); + + const createdAgent = makeAgent({ + id: "agent-1", + cwd: "/tmp/repo/.paseo/worktrees/feature", + status: "idle", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const createAgent = vi.fn(async () => createdAgent as any); + + (session as any).agentManager = { + createAgent, + getAgent: vi.fn(() => createdAgent as any), + }; + (session as any).forwardAgentUpdate = vi.fn(async () => undefined); + (session as any).getAgentPayloadById = vi.fn(async () => createdAgent); + (session as any).buildAgentSessionConfig = vi.fn(async (config: any) => ({ + sessionConfig: config, + worktreeConfig: null, + })); + + await (session as any).handleCreateAgentRequest({ + type: "create_agent_request", + requestId: "req-create-agent", + workspaceId: 50, + config: { + provider: "codex", + cwd: "/tmp/repo", + modeId: "default", + }, + labels: {}, + }); + + expect(createAgent).toHaveBeenCalledWith( + expect.objectContaining({ + cwd: "/tmp/repo/.paseo/worktrees/feature", + }), + undefined, + expect.objectContaining({ + workspaceId: 50, + }), + ); + const response = emitted.find((message) => message.type === "status") as any; + expect(response?.payload).toMatchObject({ + status: "agent_created", + requestId: "req-create-agent", + agent: { + cwd: "/tmp/repo/.paseo/worktrees/feature", + }, + }); + }); + + test("create_agent_request fails for an unknown workspaceId", async () => { + const { session, emitted } = createSessionForWorkspaceTests(); + const createAgent = vi.fn(); + + (session as any).agentManager = { + createAgent, + getAgent: vi.fn(() => null), + }; + (session as any).buildAgentSessionConfig = vi.fn(async (config: any) => ({ + sessionConfig: config, + worktreeConfig: null, + })); + + await (session as any).handleCreateAgentRequest({ + type: "create_agent_request", + requestId: "req-create-agent-fail", + workspaceId: 999, + config: { + provider: "codex", + cwd: "/tmp/repo", + modeId: "default", + }, + labels: {}, + }); + + expect(createAgent).not.toHaveBeenCalled(); + const response = emitted.find((message) => message.type === "status") as any; + expect(response?.payload).toMatchObject({ + status: "agent_create_failed", + requestId: "req-create-agent-fail", + }); + expect((response?.payload as any)?.error).toContain("Workspace not found: 999"); + }); + 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({ diff --git a/packages/server/src/server/worktree-session.ts b/packages/server/src/server/worktree-session.ts index 9508a3e31..0df58d1c6 100644 --- a/packages/server/src/server/worktree-session.ts +++ b/packages/server/src/server/worktree-session.ts @@ -573,6 +573,13 @@ export async function handleCreatePaseoWorktreeRequest( worktreePath, branchName: normalizedSlug, }); + await createAgentWorktree({ + cwd: repoRoot, + branchName: normalizedSlug, + baseBranch, + worktreeSlug: normalizedSlug, + paseoHome: dependencies.paseoHome, + }); const descriptor = await dependencies.describeWorkspaceRecord(workspace); dependencies.emit({ type: "create_paseo_worktree_response", @@ -622,14 +629,6 @@ export async function createPaseoWorktreeInBackground( let setupTerminalId: string | null = null; try { - await createAgentWorktree({ - cwd: options.repoRoot, - branchName: options.slug, - baseBranch: options.baseBranch, - worktreeSlug: options.slug, - paseoHome: dependencies.paseoHome, - }); - const setupCommands = getWorktreeSetupCommands(options.worktreePath); if (setupCommands.length > 0 && dependencies.terminalManager) { const runtimeEnv = await resolveWorktreeRuntimeEnv({