From 943d03ad995a613558752d2863b51e78419fba54 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 15 Jul 2026 23:13:55 +0200 Subject: [PATCH] fix(projects): separate registration from workspace setup Adding or cloning a project now registers it and opens workspace setup instead of creating a workspace implicitly. Keep Add Project independently mounted from Search so closing one cannot control the other. --- docs/development.md | 2 +- packages/app/e2e/add-project-flow.spec.ts | 42 ++++++- .../app/e2e/add-project-github.real.spec.ts | 23 +++- packages/app/e2e/helpers/add-project-flow.ts | 21 ++++ .../app/e2e/project-picker-desktop.spec.ts | 14 +++ .../app/src/add-project-flow/model.test.ts | 18 +++ packages/app/src/add-project-flow/options.ts | 33 ++++-- packages/app/src/app/_layout.tsx | 2 + .../src/components/add-project-flow-host.tsx | 11 ++ .../app/src/components/add-project-flow.tsx | 52 +++++--- .../app/src/components/command-center.tsx | 14 --- packages/app/src/hooks/open-project.ts | 80 +++++-------- packages/app/src/hooks/use-command-center.ts | 27 ++--- .../app/src/hooks/use-open-add-project.ts | 13 +- .../app/src/hooks/use-open-project.test.ts | 112 +++++------------- packages/app/src/hooks/use-open-project.ts | 22 ++-- packages/cli/src/commands/clone.ts | 20 ++-- packages/client/src/daemon-client.ts | 20 ++-- .../messages.project-command-center.test.ts | 3 +- packages/protocol/src/messages.ts | 28 ++--- .../protocol/src/messages.workspaces.test.ts | 37 +++--- packages/server/src/server/session.ts | 37 ++---- .../server/src/server/websocket-server.ts | 4 +- 23 files changed, 340 insertions(+), 295 deletions(-) create mode 100644 packages/app/src/components/add-project-flow-host.tsx diff --git a/docs/development.md b/docs/development.md index 443e8e5db..d303f3c22 100644 --- a/docs/development.md +++ b/docs/development.md @@ -380,7 +380,7 @@ npm run cli -- ls -a -g --json # Same, as JSON npm run cli -- inspect # Show detailed agent info npm run cli -- logs # View agent timeline npm run cli -- daemon status # Check daemon status -npm run cli -- clone owner/repo --dir ~/workspace # Clone GitHub repo and register workspace +npm run cli -- clone owner/repo --dir ~/workspace # Clone GitHub repo and register project ``` Use `--host ` to point the CLI at a different daemon: diff --git a/packages/app/e2e/add-project-flow.spec.ts b/packages/app/e2e/add-project-flow.spec.ts index acf9826b5..4705ac090 100644 --- a/packages/app/e2e/add-project-flow.spec.ts +++ b/packages/app/e2e/add-project-flow.spec.ts @@ -11,6 +11,7 @@ import { addProjectFlowMethod, chooseAddProjectMethod, expectAddProjectPage, + expectNewWorkspaceForAddedProject, openAddProjectFlow, waitForConnectedHost, } from "./helpers/add-project-flow"; @@ -20,6 +21,7 @@ import { addOfflineHostAndReload } from "./helpers/hosts"; import { type IsolatedHostDaemon, startIsolatedHostDaemon } from "./helpers/isolated-host-daemon"; import { expectOpenedProject } from "./helpers/project-picker-ui"; import { connectSeedClient } from "./helpers/seed-client"; +import { getServerId } from "./helpers/server-id"; const EXTRA_HOSTS_KEY = "@paseo:e2e-extra-hosts"; const SECONDARY_HOST_ID = "add-project-flow-secondary"; @@ -62,6 +64,16 @@ async function removeCreatedProject( } } +async function expectProjectHasNoWorkspaces(projectId: string): Promise { + const client = await connectSeedClient(); + try { + const result = await client.fetchWorkspaces({ filter: { projectId } }); + expect(result.entries).toEqual([]); + } finally { + await client.close(); + } +} + test.describe("Add Project command-center flow", () => { test.describe.configure({ timeout: 180_000 }); @@ -218,7 +230,14 @@ test.describe("Add Project command-center flow", () => { await page.keyboard.type(directoryName); await page.keyboard.press("Enter"); - await expectOpenedProject(page, directoryName); + const projectId = await expectOpenedProject(page, directoryName); + await expectNewWorkspaceForAddedProject(page, { + serverId: SECONDARY_HOST_ID, + projectId, + projectName: directoryName, + projectPath: directoryPath, + }); + await expect(page.getByTestId("host-picker-trigger")).toContainText(SECONDARY_HOST_LABEL); await expectProjectDirectory(directoryPath); } finally { await rm(parentDirectory, { recursive: true, force: true }); @@ -243,6 +262,13 @@ test.describe("Add Project command-center flow", () => { const projectId = await expectOpenedProject(page, projectPickerFixture.projectName); projectPickerFixture.rememberProjectId(projectId); + await expectNewWorkspaceForAddedProject(page, { + serverId: getServerId(), + projectId, + projectName: projectPickerFixture.projectName, + projectPath: projectPickerFixture.projectPath, + }); + await expectProjectHasNoWorkspaces(projectId); }); test("the current daemon advertises Clone from GitHub and New directory", async ({ page }) => { @@ -266,9 +292,10 @@ test.describe("Add Project command-center flow", () => { await page.keyboard.press("Enter"); await expectAddProjectPage(page, "github-location"); - await expect(addProjectFlow(page).getByTestId("add-project-flow-title")).toContainText( - "manual", - ); + const title = addProjectFlow(page).getByTestId("add-project-flow-title"); + await expect(title.getByText("Choose destination", { exact: true })).toBeVisible(); + await expect(title.getByText("localhost", { exact: true })).toBeVisible(); + await expect(title).not.toContainText("Where should Paseo create"); await addProjectFlowBack(page).click(); await expect(addProjectFlowInput(page)).toHaveValue(remote); }); @@ -307,6 +334,13 @@ test.describe("Add Project command-center flow", () => { await page.keyboard.press("Enter"); projectId = await expectOpenedProject(page, directoryName); + await expectNewWorkspaceForAddedProject(page, { + serverId: getServerId(), + projectId, + projectName: directoryName, + projectPath: directoryPath, + }); + await expectProjectHasNoWorkspaces(projectId); await expectProjectDirectory(directoryPath); } finally { await removeCreatedProject(directoryPath, projectId).catch(() => undefined); diff --git a/packages/app/e2e/add-project-github.real.spec.ts b/packages/app/e2e/add-project-github.real.spec.ts index 1219d7f74..acb7eaab4 100644 --- a/packages/app/e2e/add-project-github.real.spec.ts +++ b/packages/app/e2e/add-project-github.real.spec.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm, stat } from "node:fs/promises"; +import { mkdir, mkdtemp, rm, stat } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { expect, test } from "./fixtures"; @@ -8,12 +8,14 @@ import { addProjectFlowInput, chooseAddProjectMethod, expectAddProjectPage, + expectNewWorkspaceForAddedProject, openAddProjectFlow, } from "./helpers/add-project-flow"; import { gotoAppShell } from "./helpers/app"; import { createTempGithubRepo, hasGithubAuth, type GhRepoFixture } from "./helpers/github-fixtures"; import { expectOpenedProject } from "./helpers/project-picker-ui"; import { connectSeedClient } from "./helpers/seed-client"; +import { getServerId } from "./helpers/server-id"; test.describe("Add Project GitHub flow", () => { test.describe.configure({ timeout: 300_000 }); @@ -56,8 +58,27 @@ test.describe("Add Project GitHub flow", () => { await expectAddProjectPage(page, "github-location"); await expect(addProjectFlowInput(page)).toHaveValue(parentDirectory); + await mkdir(checkoutPath); + await page.keyboard.press("Enter"); + await expect(page.getByTestId("add-project-flow-error")).toHaveText( + `Checkout path already exists: ${checkoutPath}`, + ); + + await rm(checkoutPath, { recursive: true }); await page.keyboard.press("Enter"); projectId = await expectOpenedProject(page, repository.name); + await expectNewWorkspaceForAddedProject(page, { + serverId: getServerId(), + projectId, + projectName: repository.name, + projectPath: checkoutPath, + }); + const client = await connectSeedClient(); + try { + expect((await client.fetchWorkspaces({ filter: { projectId } })).entries).toEqual([]); + } finally { + await client.close(); + } await expect.poll(async () => (await stat(checkoutPath)).isDirectory()).toBe(true); } finally { if (projectId) { diff --git a/packages/app/e2e/helpers/add-project-flow.ts b/packages/app/e2e/helpers/add-project-flow.ts index 025dca3a9..92cf3ff88 100644 --- a/packages/app/e2e/helpers/add-project-flow.ts +++ b/packages/app/e2e/helpers/add-project-flow.ts @@ -72,3 +72,24 @@ export async function chooseAddProjectMethod(page: Page, method: AddProjectMetho await expectAddProjectPage(page, METHOD_DESTINATIONS[method]); } } + +export async function expectNewWorkspaceForAddedProject( + page: Page, + input: { + serverId: string; + projectId: string; + projectName: string; + projectPath: string; + }, +): Promise { + await expect(page).toHaveURL(/\/new\?.*projectId=/u, { timeout: 30_000 }); + const url = new URL(page.url()); + expect(url.pathname).toBe("/new"); + expect(url.searchParams.get("serverId")).toBe(input.serverId); + expect(url.searchParams.get("projectId")).toBe(input.projectId); + expect(url.searchParams.get("dir")).toBe(input.projectPath); + await expect(page.getByRole("button", { name: "Workspace project" })).toContainText( + input.projectName, + { timeout: 30_000 }, + ); +} diff --git a/packages/app/e2e/project-picker-desktop.spec.ts b/packages/app/e2e/project-picker-desktop.spec.ts index 1339f5a6d..a4a71f056 100644 --- a/packages/app/e2e/project-picker-desktop.spec.ts +++ b/packages/app/e2e/project-picker-desktop.spec.ts @@ -2,7 +2,9 @@ import { test, expect } from "./fixtures"; import { gotoAppShell } from "./helpers/app"; import { injectDesktopBridge, waitForDirectoryDialog } from "./helpers/desktop-updates"; import { expectOpenedProject } from "./helpers/project-picker-ui"; +import { expectNewWorkspaceForAddedProject } from "./helpers/add-project-flow"; import { getServerId } from "./helpers/server-id"; +import { connectSeedClient } from "./helpers/seed-client"; test.skip(process.env.E2E_DESKTOP_RUNTIME !== "1", "requires Metro's Electron platform overlay"); @@ -24,6 +26,18 @@ test("Browse opens the folder selected by the desktop dialog", async ({ const projectId = await expectOpenedProject(page, projectPickerFixture.projectName); projectPickerFixture.rememberProjectId(projectId); + await expectNewWorkspaceForAddedProject(page, { + serverId: getServerId(), + projectId, + projectName: projectPickerFixture.projectName, + projectPath: projectPickerFixture.projectPath, + }); + const client = await connectSeedClient(); + try { + expect((await client.fetchWorkspaces({ filter: { projectId } })).entries).toEqual([]); + } finally { + await client.close(); + } }); test("canceling Browse returns to the Add Project methods", async ({ diff --git a/packages/app/src/add-project-flow/model.test.ts b/packages/app/src/add-project-flow/model.test.ts index 677e00059..8319771c3 100644 --- a/packages/app/src/add-project-flow/model.test.ts +++ b/packages/app/src/add-project-flow/model.test.ts @@ -179,4 +179,22 @@ describe("Add Project options", () => { }, ]); }); + + it("shows equivalent absolute-home and tilde destinations only once", () => { + expect( + buildCloneLocationOptions({ + parents: ["/Users/moboudra/dev", "~/dev"], + repositoryName: "dotfiles", + existingPaths: [], + }), + ).toEqual([ + { + id: "/Users/moboudra/dev", + path: "/Users/moboudra/dev", + displayPath: "/Users/moboudra/dev/dotfiles", + secondaryText: "Parent directory: /Users/moboudra/dev", + disabled: false, + }, + ]); + }); }); diff --git a/packages/app/src/add-project-flow/options.ts b/packages/app/src/add-project-flow/options.ts index fdaf30cfd..0c968cc35 100644 --- a/packages/app/src/add-project-flow/options.ts +++ b/packages/app/src/add-project-flow/options.ts @@ -3,6 +3,7 @@ import { parseGitHubRemoteUrl, parseGitRemoteLocation, } from "@getpaseo/protocol/git-remote"; +import { shortenPath } from "@/utils/shorten-path"; import type { AddProjectHost, GithubRepositoryChoice } from "./model"; export type AddProjectMethodId = "directory-search" | "browse" | "github" | "new-directory"; @@ -150,15 +151,29 @@ export function buildCloneLocationOptions(input: { repositoryName: string; existingPaths: string[]; }): AddProjectPathOption[] { - const existing = new Set(input.existingPaths); - return input.parents.map((parent) => { + const existing = new Set(input.existingPaths.map(pathIdentity)); + const seen = new Set(); + return input.parents.flatMap((parent) => { const path = joinDirectoryPath(parent, input.repositoryName); - return { - id: parent, - path: parent, - displayPath: path, - secondaryText: existing.has(path) ? "Already exists" : `Parent directory: ${parent}`, - disabled: existing.has(path), - }; + const identity = pathIdentity(path); + if (seen.has(identity)) return []; + seen.add(identity); + const pathExists = existing.has(identity); + return [ + { + id: parent, + path: parent, + displayPath: path, + secondaryText: pathExists ? "Already exists" : `Parent directory: ${parent}`, + disabled: pathExists, + }, + ]; }); } + +function pathIdentity(path: string): string { + const normalized = shortenPath(path.trim()).replace(/\\/g, "/").replace(/\/+$/u, ""); + return /^[A-Za-z]:\//u.test(normalized) || normalized.startsWith("//") + ? normalized.toLowerCase() + : normalized; +} diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 58abf2c05..0889c4c4a 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -22,6 +22,7 @@ import { KeyboardProvider } from "react-native-keyboard-controller"; import { SafeAreaProvider } from "react-native-safe-area-context"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import { CommandCenter } from "@/components/command-center"; +import { AddProjectFlowHost } from "@/components/add-project-flow-host"; import { WorktreeSetupCalloutSource } from "@/components/worktree-setup-callout-source"; import { DownloadToast } from "@/components/download-toast"; import { QuittingOverlay } from "@/components/quitting-overlay"; @@ -548,6 +549,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon + diff --git a/packages/app/src/components/add-project-flow-host.tsx b/packages/app/src/components/add-project-flow-host.tsx new file mode 100644 index 000000000..bf8ad055c --- /dev/null +++ b/packages/app/src/components/add-project-flow-host.tsx @@ -0,0 +1,11 @@ +import { AddProjectFlow } from "@/components/add-project-flow"; +import { useAddProjectFlowStore } from "@/stores/add-project-flow-store"; + +export function AddProjectFlowHost() { + const request = useAddProjectFlowStore((state) => state.request); + const close = useAddProjectFlowStore((state) => state.close); + + if (!request) return null; + + return ; +} diff --git a/packages/app/src/components/add-project-flow.tsx b/packages/app/src/components/add-project-flow.tsx index 6fbbd719c..36aa29d97 100644 --- a/packages/app/src/components/add-project-flow.tsx +++ b/packages/app/src/components/add-project-flow.tsx @@ -1,4 +1,5 @@ import { router } from "expo-router"; +import type { WorkspaceProjectDescriptorPayload } from "@getpaseo/protocol/messages"; import { ArrowLeft, Folder, @@ -63,7 +64,7 @@ import { pickDirectory } from "@/desktop/pick-directory"; import { useFetchQuery } from "@/data/query"; import { getOpenProjectFailureReason, registerProjectDescriptor } from "@/hooks/open-project"; import { useIsLocalDaemon, useLocalDaemonServerId } from "@/hooks/use-is-local-daemon"; -import { useOpenGithubRepo, useOpenProject } from "@/hooks/use-open-project"; +import { useCloneGithubProject, useOpenProject } from "@/hooks/use-open-project"; import { useHosts, useHostRuntimeClient, @@ -75,7 +76,7 @@ import { useRecommendedProjectPaths } from "@/stores/session-store-hooks"; import type { AddProjectFlowRequest } from "@/stores/add-project-flow-store"; import type { Theme } from "@/styles/theme"; import { shortenPath } from "@/utils/shorten-path"; -import { buildSettingsAddHostRoute } from "@/utils/host-routes"; +import { buildNewWorkspaceRoute, buildSettingsAddHostRoute } from "@/utils/host-routes"; interface AddProjectFlowProps { request: AddProjectFlowRequest; @@ -197,7 +198,7 @@ function pageTitle(page: AddProjectPage): string { case "github-search": return "Clone from GitHub"; case "github-location": - return `Where should Paseo create ${pathBaseName(page.repository.nameWithOwner)}?`; + return "Choose destination"; case "new-directory-parent": return "Choose parent directory"; case "new-directory-name": @@ -296,8 +297,8 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) { const hostIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]); const connectionStatuses = useHostRuntimeConnectionStatuses(hostIds); const projectAddByHost = useHostFeatureMap(hostIds, "projectAdd"); - // COMPAT(workspaceGithubClone): added in v0.1.108, remove gate after 2027-01-15. - const githubCloneByHost = useHostFeatureMap(hostIds, "workspaceGithubClone"); + // COMPAT(projectGithubClone): added in v0.1.108, remove gate after 2027-01-15. + const githubCloneByHost = useHostFeatureMap(hostIds, "projectGithubClone"); // COMPAT(workspaceGithubRepositorySearch): added in v0.1.108, remove gate after 2027-01-15. const githubSearchByHost = useHostFeatureMap(hostIds, "workspaceGithubRepositorySearch"); // COMPAT(projectCreateDirectory): added in v0.1.108, remove gate after 2027-01-15. @@ -343,7 +344,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) { const isLocalDaemon = useIsLocalDaemon(hostId ?? ""); const recommendedPaths = useRecommendedProjectPaths(hostId); const openProject = useOpenProject(hostId); - const openGithubRepo = useOpenGithubRepo(hostId); + const cloneGithubProject = useCloneGithubProject(hostId); const addEmptyProject = useSessionStore((store) => store.addEmptyProject); const setHasHydratedWorkspaces = useSessionStore((store) => store.setHasHydratedWorkspaces); const inputRef = useRef(null); @@ -416,9 +417,24 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) { }); }, [onClose]); + const openNewWorkspaceForProject = useCallback( + (serverId: string, project: WorkspaceProjectDescriptorPayload) => { + onClose(); + router.push( + buildNewWorkspaceRoute({ + serverId, + projectId: project.projectId, + sourceDirectory: project.projectRootPath, + displayName: project.projectDisplayName, + }), + ); + }, + [onClose], + ); + const openAddedProject = useCallback( async (path: string, sourceKind: "directory-search" | "method") => { - if (submissionInFlightRef.current) return; + if (!hostId || submissionInFlightRef.current) return; submissionInFlightRef.current = true; setState((current) => setPageStatus(current, sourceKind, { isSubmitting: true, error: null }), @@ -426,7 +442,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) { try { const result = await openProject(path); if (result.ok) { - onClose(); + openNewWorkspaceForProject(hostId, result.project); return; } const reason = getOpenProjectFailureReason(result); @@ -446,7 +462,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) { submissionInFlightRef.current = false; } }, - [onClose, openProject], + [hostId, openNewWorkspaceForProject, openProject], ); const browse = useCallback(async () => { @@ -501,34 +517,34 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) { setPageStatus(current, "github-location", { isSubmitting: true, error: null }), ); try { - const opened = await openGithubRepo( + const result = await cloneGithubProject( locationPage.repository.cloneUrl, parentPath, locationPage.repository.cloneProtocol, ); - if (opened) { + if (result.ok) { lastCloneParentByHost.set(locationPage.hostId, parentPath); - onClose(); + openNewWorkspaceForProject(locationPage.hostId, result.project); return; } setState((current) => setPageStatus(current, "github-location", { isSubmitting: false, - error: "Unable to clone repository", + error: result.error ?? "Unable to clone repository", }), ); - } catch { + } catch (error) { setState((current) => setPageStatus(current, "github-location", { isSubmitting: false, - error: "Unable to clone repository", + error: error instanceof Error ? error.message : "Unable to clone repository", }), ); } finally { submissionInFlightRef.current = false; } }, - [onClose, openGithubRepo], + [cloneGithubProject, openNewWorkspaceForProject], ); const rows = useMemo(() => { if (page.kind === "host") { @@ -702,7 +718,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) { addEmptyProject, setHasHydratedWorkspaces, }); - onClose(); + openNewWorkspaceForProject(page.hostId, payload.project); } catch { setState((current) => setPageStatus(current, "new-directory-name", { @@ -713,7 +729,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) { } finally { submissionInFlightRef.current = false; } - }, [addEmptyProject, client, onClose, page, setHasHydratedWorkspaces]); + }, [addEmptyProject, client, openNewWorkspaceForProject, page, setHasHydratedWorkspaces]); const submitActive = useCallback(() => { if (page.kind === "new-directory-name") { diff --git a/packages/app/src/components/command-center.tsx b/packages/app/src/components/command-center.tsx index a3ed82c5f..49cafb624 100644 --- a/packages/app/src/components/command-center.tsx +++ b/packages/app/src/components/command-center.tsx @@ -21,8 +21,6 @@ import { import { AgentStatusDot } from "@/components/agent-status-dot"; import { Shortcut } from "@/components/ui/shortcut"; import { isNative, isWeb } from "@/constants/platform"; -import { AddProjectFlow } from "@/components/add-project-flow"; -import { useAddProjectFlowStore } from "@/stores/add-project-flow-store"; import { useIsCompactFormFactor } from "@/constants/layout"; import { IsolatedBottomSheetModal, @@ -338,8 +336,6 @@ export function CommandCenter() { handleSelectItem, handleKeyEvent, } = useCommandCenter(); - const addProjectRequest = useAddProjectFlowStore((state) => state.request); - const isCompact = useIsCompactFormFactor(); const showBottomSheet = isCompact && isNative; const rowRefs = useRef>(new Map()); @@ -493,16 +489,6 @@ export function CommandCenter() { const snapPoints = useMemo(() => ["60%", "90%"], []); - if (open && addProjectRequest) { - return ( - - ); - } - const resultList = items.length === 0 ? ( {t("shell.commandCenter.noMatches")} diff --git a/packages/app/src/hooks/open-project.ts b/packages/app/src/hooks/open-project.ts index a86f51e68..0c929bab0 100644 --- a/packages/app/src/hooks/open-project.ts +++ b/packages/app/src/hooks/open-project.ts @@ -1,27 +1,20 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { + ProjectGithubCloneProtocol, ProjectAddResponse, - WorkspaceGithubCloneProtocol, WorkspaceProjectDescriptorPayload, } from "@getpaseo/protocol/messages"; import { normalizeEmptyProjectDescriptor as normalizeProjectWithoutWorkspacesDescriptor, - normalizeWorkspaceDescriptor, type EmptyProjectDescriptor as ProjectWithoutWorkspacesDescriptor, - type WorkspaceDescriptor, } from "@/stores/session-store"; -import { generateDraftId } from "@/stores/draft-keys"; -import type { NavigateToWorkspaceInput } from "@/stores/navigation-active-workspace-store"; -import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store"; type OpenProjectPayload = ProjectAddResponse["payload"]; type OpenProjectErrorCode = NonNullable; -type WorkspaceOpenPayload = - | Awaited> - | Awaited>; export interface OpenProjectSuccess { ok: true; + project: WorkspaceProjectDescriptorPayload; } export interface OpenProjectFailure { @@ -32,7 +25,7 @@ export interface OpenProjectFailure { export type OpenProjectResult = OpenProjectSuccess | OpenProjectFailure; export type OpenProjectFailureReason = "directory_not_found" | "open_failed"; -export type { WorkspaceGithubCloneProtocol }; +export type { ProjectGithubCloneProtocol }; export function getOpenProjectFailureReason( result: OpenProjectResult, @@ -58,12 +51,11 @@ export interface OpenProjectDirectlyInput { setHasHydratedWorkspaces: (serverId: string, hydrated: boolean) => void; } -interface WorkspaceOpenCallbacks { +interface ProjectRegistrationCallbacks { serverId: string; isConnected: boolean; - mergeWorkspaces: (serverId: string, workspaces: Iterable) => void; + addEmptyProject: (serverId: string, project: ProjectWithoutWorkspacesDescriptor) => void; setHasHydratedWorkspaces: (serverId: string, hydrated: boolean) => void; - navigateToWorkspace: (input: NavigateToWorkspaceInput) => string; } export interface RegisterProjectDescriptorInput { @@ -81,11 +73,11 @@ export function registerProjectDescriptor(input: RegisterProjectDescriptorInput) return true; } -export interface OpenGithubRepoDirectlyInput extends WorkspaceOpenCallbacks { +export interface CloneGithubProjectDirectlyInput extends ProjectRegistrationCallbacks { repo: string; targetDirectory: string; - cloneProtocol?: WorkspaceGithubCloneProtocol; - client: Pick | null; + cloneProtocol?: ProjectGithubCloneProtocol; + client: Pick | null; } export async function openProjectDirectly( @@ -114,44 +106,20 @@ export async function openProjectDirectly( }; } - registerProjectDescriptor({ + const registered = registerProjectDescriptor({ serverId: normalizedServerId, project: payload.project, addEmptyProject: input.addEmptyProject, setHasHydratedWorkspaces: input.setHasHydratedWorkspaces, }); - return { ok: true }; + return registered + ? { ok: true, project: payload.project } + : { ok: false, errorCode: null, error: "Unable to register project" }; } -function finishWorkspaceOpen( - input: WorkspaceOpenCallbacks, - payload: WorkspaceOpenPayload, -): boolean { - const normalizedServerId = input.serverId.trim(); - if (!normalizedServerId || payload.error || !payload.workspace) { - return false; - } - - const workspace = normalizeWorkspaceDescriptor(payload.workspace); - const workspaceKey = buildWorkspaceTabPersistenceKey({ - serverId: normalizedServerId, - workspaceId: workspace.id, - }); - if (!workspaceKey) { - return false; - } - - input.mergeWorkspaces(normalizedServerId, [workspace]); - input.setHasHydratedWorkspaces(normalizedServerId, true); - input.navigateToWorkspace({ - serverId: normalizedServerId, - workspaceId: workspace.id, - target: { kind: "draft", draftId: generateDraftId() }, - }); - return true; -} - -export async function openGithubRepoDirectly(input: OpenGithubRepoDirectlyInput): Promise { +export async function cloneGithubProjectDirectly( + input: CloneGithubProjectDirectlyInput, +): Promise { const normalizedServerId = input.serverId.trim(); const trimmedRepo = input.repo.trim(); const trimmedTargetDirectory = input.targetDirectory.trim(); @@ -162,13 +130,25 @@ export async function openGithubRepoDirectly(input: OpenGithubRepoDirectlyInput) !input.client || !input.isConnected ) { - return false; + return { ok: false, errorCode: null, error: null }; } - const payload = await input.client.cloneGithubWorkspace({ + const payload = await input.client.cloneGithubProject({ repo: trimmedRepo, targetDirectory: trimmedTargetDirectory, ...(input.cloneProtocol ? { cloneProtocol: input.cloneProtocol } : {}), }); - return finishWorkspaceOpen(input, payload); + if (payload.error || !payload.project) { + return { ok: false, errorCode: null, error: payload.error }; + } + + const registered = registerProjectDescriptor({ + serverId: normalizedServerId, + project: payload.project, + addEmptyProject: input.addEmptyProject, + setHasHydratedWorkspaces: input.setHasHydratedWorkspaces, + }); + return registered + ? { ok: true, project: payload.project } + : { ok: false, errorCode: null, error: "Unable to register project" }; } diff --git a/packages/app/src/hooks/use-command-center.ts b/packages/app/src/hooks/use-command-center.ts index 327f3ac8f..682c254ba 100644 --- a/packages/app/src/hooks/use-command-center.ts +++ b/packages/app/src/hooks/use-command-center.ts @@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher"; import { useAggregatedAgents, type AggregatedAgent } from "@/hooks/use-aggregated-agents"; -import { useAddProjectFlowStore } from "@/stores/add-project-flow-store"; +import { useOpenAddProject } from "@/hooks/use-open-add-project"; import { clearCommandCenterFocusRestoreElement, takeCommandCenterFocusRestoreElement, @@ -146,9 +146,7 @@ export function useCommandCenter() { const { overrides } = useKeyboardShortcutOverrides(); const open = useKeyboardShortcutsStore((s) => s.commandCenterOpen); const setOpen = useKeyboardShortcutsStore((s) => s.setCommandCenterOpen); - const addProjectRequest = useAddProjectFlowStore((state) => state.request); - const openAddProjectFlow = useAddProjectFlowStore((state) => state.open); - const closeAddProjectFlow = useAddProjectFlowStore((state) => state.close); + const openAddProject = useOpenAddProject(); const inputRef = useRef(null); const didNavigateRef = useRef(false); const prevOpenRef = useRef(open); @@ -323,19 +321,19 @@ export function useCommandCenter() { const handleSelectAction = useCallback( (action: CommandCenterActionItem) => { - if (action.id === "new-agent") { - openAddProjectFlow(); - return; - } clearCommandCenterFocusRestoreElement(); setOpen(false); + if (action.id === "new-agent") { + openAddProject(); + return; + } if (!action.route) { return; } didNavigateRef.current = true; router.push(action.route); }, - [openAddProjectFlow, setOpen], + [openAddProject, setOpen], ); const handleSelectItem = useCallback( @@ -374,7 +372,6 @@ export function useCommandCenter() { prevOpenRef.current = open; if (!open) { - closeAddProjectFlow(); setQuery(""); setActiveIndex(0); @@ -405,7 +402,7 @@ export function useCommandCenter() { inputRef.current?.focus(); }, 0); return () => clearTimeout(id); - }, [closeAddProjectFlow, open]); + }, [open]); useEffect(() => { if (!open) return; @@ -416,7 +413,7 @@ export function useCommandCenter() { const handleKeyEvent = useCallback( (key: string): boolean => { - if (!open || addProjectRequest) return false; + if (!open) return false; const currentItems = itemsRef.current; if (key === "Escape") { @@ -445,11 +442,11 @@ export function useCommandCenter() { return false; }, - [addProjectRequest, open], + [open], ); useEffect(() => { - if (!open || addProjectRequest || !isWeb) return; + if (!open || !isWeb) return; const handler = (event: KeyboardEvent) => { if ( @@ -468,7 +465,7 @@ export function useCommandCenter() { // react-native-web can stop propagation on key events, so listen in capture phase. window.addEventListener("keydown", handler, true); return () => window.removeEventListener("keydown", handler, true); - }, [addProjectRequest, open, handleKeyEvent]); + }, [open, handleKeyEvent]); return { open, diff --git a/packages/app/src/hooks/use-open-add-project.ts b/packages/app/src/hooks/use-open-add-project.ts index 5c6bebafa..018494c7f 100644 --- a/packages/app/src/hooks/use-open-add-project.ts +++ b/packages/app/src/hooks/use-open-add-project.ts @@ -1,16 +1,5 @@ -import { useCallback } from "react"; import { useAddProjectFlowStore } from "@/stores/add-project-flow-store"; -import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; export function useOpenAddProject(): (preferredHostId?: string) => void { - const openFlow = useAddProjectFlowStore((state) => state.open); - const setCommandCenterOpen = useKeyboardShortcutsStore((state) => state.setCommandCenterOpen); - - return useCallback( - (preferredHostId?: string) => { - openFlow(preferredHostId); - setCommandCenterOpen(true); - }, - [openFlow, setCommandCenterOpen], - ); + return useAddProjectFlowStore((state) => state.open); } diff --git a/packages/app/src/hooks/use-open-project.test.ts b/packages/app/src/hooks/use-open-project.test.ts index 4acd65d25..55bf4a354 100644 --- a/packages/app/src/hooks/use-open-project.test.ts +++ b/packages/app/src/hooks/use-open-project.test.ts @@ -1,14 +1,10 @@ import { describe, expect, it } from "vitest"; import { + cloneGithubProjectDirectly, getOpenProjectFailureReason, - openGithubRepoDirectly, openProjectDirectly, } from "@/hooks/open-project"; -import type { - EmptyProjectDescriptor as ProjectWithoutWorkspacesDescriptor, - WorkspaceDescriptor, -} from "@/stores/session-store"; -import type { NavigateToWorkspaceInput } from "@/stores/navigation-active-workspace-store"; +import type { EmptyProjectDescriptor as ProjectWithoutWorkspacesDescriptor } from "@/stores/session-store"; const SERVER_ID = "server-1"; const PROJECT_PATH = "/repo/project"; @@ -22,35 +18,11 @@ function buildProjectPayload() { }; } -function buildWorkspacePayload() { - return { - id: "1", - projectId: "1", - projectDisplayName: "project", - projectRootPath: PROJECT_PATH, - workspaceDirectory: PROJECT_PATH, - projectKind: "git" as const, - workspaceKind: "checkout" as const, - name: "project", - archivingAt: null, - status: "done" as const, - statusEnteredAt: null, - activityAt: null, - diffStat: null, - scripts: [], - }; -} - interface RecordedProject { serverId: string; project: ProjectWithoutWorkspacesDescriptor; } -interface RecordedMerge { - serverId: string; - workspaces: WorkspaceDescriptor[]; -} - interface RecordedHydrated { serverId: string; hydrated: boolean; @@ -64,47 +36,31 @@ interface RecordedClone { function createFakeSession() { const projects: RecordedProject[] = []; - const merges: RecordedMerge[] = []; const hydrated: RecordedHydrated[] = []; return { projects, - merges, hydrated, addEmptyProject: (serverId: string, project: ProjectWithoutWorkspacesDescriptor) => { projects.push({ serverId, project }); }, - mergeWorkspaces: (serverId: string, workspaces: Iterable) => { - merges.push({ serverId, workspaces: Array.from(workspaces) }); - }, setHasHydratedWorkspaces: (serverId: string, value: boolean) => { hydrated.push({ serverId, hydrated: value }); }, }; } -function createFakeNavigator() { - const navigations: NavigateToWorkspaceInput[] = []; - return { - navigations, - navigateToWorkspace: (input: NavigateToWorkspaceInput) => { - navigations.push(input); - return `/hosts/${input.serverId}/workspaces/${input.workspaceId}`; - }, - }; -} - -function createFakeGithubCloneClient(workspace: ReturnType) { +function createFakeGithubCloneClient(project: ReturnType | null) { const clones: RecordedClone[] = []; return { clones, - cloneGithubWorkspace: async (input: RecordedClone) => { + cloneGithubProject: async (input: RecordedClone) => { clones.push(input); return { requestId: "request-3", repo: "owner/project", checkoutPath: PROJECT_PATH, - error: null, - workspace, + error: project ? null : "Project registration failed", + project, }; }, }; @@ -131,7 +87,7 @@ describe("openProjectDirectly", () => { setHasHydratedWorkspaces: session.setHasHydratedWorkspaces, }); - expect(result).toEqual({ ok: true }); + expect(result).toEqual({ ok: true, project: projectPayload }); expect(session.projects).toEqual([ { serverId: SERVER_ID, @@ -144,7 +100,6 @@ describe("openProjectDirectly", () => { }, }, ]); - expect(session.merges).toEqual([]); expect(session.hydrated).toEqual([{ serverId: SERVER_ID, hydrated: true }]); }); @@ -205,26 +160,24 @@ describe("openProjectDirectly", () => { }); }); -describe("openGithubRepoDirectly", () => { - it("opens a cloned GitHub workspace and seeds a draft tab", async () => { +describe("cloneGithubProjectDirectly", () => { + it("registers a cloned GitHub project without creating a workspace", async () => { const session = createFakeSession(); - const navigator = createFakeNavigator(); - const workspacePayload = buildWorkspacePayload(); - const github = createFakeGithubCloneClient(workspacePayload); + const projectPayload = buildProjectPayload(); + const github = createFakeGithubCloneClient(projectPayload); - const result = await openGithubRepoDirectly({ + const result = await cloneGithubProjectDirectly({ serverId: SERVER_ID, repo: "owner/project", targetDirectory: "~/workspace", cloneProtocol: "https", isConnected: true, client: github, - mergeWorkspaces: session.mergeWorkspaces, + addEmptyProject: session.addEmptyProject, setHasHydratedWorkspaces: session.setHasHydratedWorkspaces, - navigateToWorkspace: navigator.navigateToWorkspace, }); - expect(result).toBe(true); + expect(result).toEqual({ ok: true, project: projectPayload }); expect(github.clones).toEqual([ { repo: "owner/project", @@ -232,45 +185,40 @@ describe("openGithubRepoDirectly", () => { cloneProtocol: "https", }, ]); - expect(session.merges).toHaveLength(1); - expect(session.merges[0]?.serverId).toBe(SERVER_ID); - expect(session.merges[0]?.workspaces[0]).toMatchObject({ - id: "1", - projectId: "1", - projectRootPath: PROJECT_PATH, - workspaceDirectory: PROJECT_PATH, - }); - expect(session.hydrated).toEqual([{ serverId: SERVER_ID, hydrated: true }]); - expect(navigator.navigations).toEqual([ + expect(session.projects).toEqual([ { serverId: SERVER_ID, - workspaceId: "1", - target: { kind: "draft", draftId: expect.any(String) }, + project: { + ...projectPayload, + projectCustomName: null, + }, }, ]); + expect(session.hydrated).toEqual([{ serverId: SERVER_ID, hydrated: true }]); }); - it("rejects a workspace without an identity before changing app state", async () => { + it("does not register a project when cloning fails", async () => { const session = createFakeSession(); - const navigator = createFakeNavigator(); - const github = createFakeGithubCloneClient({ ...buildWorkspacePayload(), id: " " }); + const github = createFakeGithubCloneClient(null); - const result = await openGithubRepoDirectly({ + const result = await cloneGithubProjectDirectly({ serverId: SERVER_ID, repo: "owner/project", targetDirectory: "~/workspace", cloneProtocol: "https", isConnected: true, client: github, - mergeWorkspaces: session.mergeWorkspaces, + addEmptyProject: session.addEmptyProject, setHasHydratedWorkspaces: session.setHasHydratedWorkspaces, - navigateToWorkspace: navigator.navigateToWorkspace, }); - expect(result).toBe(false); - expect(session.merges).toEqual([]); + expect(result).toEqual({ + ok: false, + errorCode: null, + error: "Project registration failed", + }); + expect(session.projects).toEqual([]); expect(session.hydrated).toEqual([]); - expect(navigator.navigations).toEqual([]); }); }); diff --git a/packages/app/src/hooks/use-open-project.ts b/packages/app/src/hooks/use-open-project.ts index 181610b20..29f571413 100644 --- a/packages/app/src/hooks/use-open-project.ts +++ b/packages/app/src/hooks/use-open-project.ts @@ -1,12 +1,11 @@ import { useCallback } from "react"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useSessionStore } from "@/stores/session-store"; -import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"; import { - openGithubRepoDirectly, + cloneGithubProjectDirectly, openProjectDirectly, type OpenProjectResult, - type WorkspaceGithubCloneProtocol, + type ProjectGithubCloneProtocol, } from "@/hooks/open-project"; export function useOpenProject( @@ -47,33 +46,32 @@ export function useOpenProject( ); } -export function useOpenGithubRepo( +export function useCloneGithubProject( serverId: string | null, ): ( repo: string, targetDirectory: string, - cloneProtocol?: WorkspaceGithubCloneProtocol, -) => Promise { + cloneProtocol?: ProjectGithubCloneProtocol, +) => Promise { const normalizedServerId = serverId?.trim() ?? ""; const client = useHostRuntimeClient(normalizedServerId); const isConnected = useHostRuntimeIsConnected(normalizedServerId); - const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces); + const addEmptyProject = useSessionStore((state) => state.addEmptyProject); const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces); return useCallback( - async (repo: string, targetDirectory: string, cloneProtocol?: WorkspaceGithubCloneProtocol) => { - return openGithubRepoDirectly({ + async (repo: string, targetDirectory: string, cloneProtocol?: ProjectGithubCloneProtocol) => { + return cloneGithubProjectDirectly({ serverId: normalizedServerId, repo, targetDirectory, ...(cloneProtocol ? { cloneProtocol } : {}), isConnected, client, - mergeWorkspaces, + addEmptyProject, setHasHydratedWorkspaces, - navigateToWorkspace, }); }, - [client, isConnected, mergeWorkspaces, normalizedServerId, setHasHydratedWorkspaces], + [addEmptyProject, client, isConnected, normalizedServerId, setHasHydratedWorkspaces], ); } diff --git a/packages/cli/src/commands/clone.ts b/packages/cli/src/commands/clone.ts index 8935c1842..6986934c3 100644 --- a/packages/cli/src/commands/clone.ts +++ b/packages/cli/src/commands/clone.ts @@ -14,15 +14,15 @@ interface CloneCommandOptions extends CommandOptions { export interface CloneResult { repo: string; checkoutPath: string; - workspaceId: string; - workspaceName: string; + projectId: string; + projectName: string; } export const cloneSchema: OutputSchema = { - idField: "workspaceId", + idField: "projectId", columns: [ { header: "REPO", field: "repo", width: 28 }, - { header: "WORKSPACE", field: "workspaceName", width: 28 }, + { header: "PROJECT", field: "projectName", width: 28 }, { header: "PATH", field: "checkoutPath", width: 56 }, ], }; @@ -52,7 +52,7 @@ export async function runCloneCommand( throw buildDaemonConnectionCommandError({ host: options.host, error: err }); } - if (client.getLastServerInfoMessage()?.features?.workspaceGithubClone !== true) { + if (client.getLastServerInfoMessage()?.features?.projectGithubClone !== true) { await client.close().catch(() => {}); throw cmdError( "UNSUPPORTED_BY_HOST", @@ -62,15 +62,15 @@ export async function runCloneCommand( } try { - const response = await client.cloneGithubWorkspace({ + const response = await client.cloneGithubProject({ repo, targetDirectory, ...(repoIsCompleteRemote ? {} : { cloneProtocol: options.protocol }), }); - if (response.error || !response.workspace || !response.checkoutPath) { + if (response.error || !response.project || !response.checkoutPath) { throw cmdError( "CLONE_FAILED", - `Failed to clone GitHub repo: ${response.error ?? "no workspace returned"}`, + `Failed to clone GitHub repo: ${response.error ?? "no project returned"}`, ); } @@ -79,8 +79,8 @@ export async function runCloneCommand( data: { repo: response.repo, checkoutPath: response.checkoutPath, - workspaceId: response.workspace.id, - workspaceName: response.workspace.name, + projectId: response.project.projectId, + projectName: response.project.projectDisplayName, }, schema: cloneSchema, }; diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 22d656e50..daf510e8b 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -58,8 +58,8 @@ import type { ProjectCreateDirectoryResponse, OpenProjectResponseMessage, WorkspaceGithubSearchRepositoriesResponse, - WorkspaceGithubCloneProtocol, - WorkspaceGithubCloneResponse, + ProjectGithubCloneProtocol, + ProjectGithubCloneResponse, ArchiveWorkspaceResponseMessage, WorkspaceSetupStatusResponseMessage, ListCommandsResponse, @@ -152,7 +152,7 @@ const perfNow: () => number = ? () => performance.now() : () => Date.now(); -const WORKSPACE_GITHUB_CLONE_TIMEOUT_MS = 5 * 60 * 1000; +const PROJECT_GITHUB_CLONE_TIMEOUT_MS = 5 * 60 * 1000; interface ImportAgentInputBase { cwd?: string; @@ -768,7 +768,7 @@ type ProjectAddPayload = ProjectAddResponse["payload"]; export type ProjectCreateDirectoryPayload = ProjectCreateDirectoryResponse["payload"]; export type WorkspaceGithubSearchRepositoriesPayload = WorkspaceGithubSearchRepositoriesResponse["payload"]; -type WorkspaceGithubClonePayload = WorkspaceGithubCloneResponse["payload"]; +type ProjectGithubClonePayload = ProjectGithubCloneResponse["payload"]; type ArchiveWorkspacePayload = ArchiveWorkspaceResponseMessage["payload"]; type WorkspaceSetupStatusPayload = WorkspaceSetupStatusResponseMessage["payload"]; @@ -2029,20 +2029,20 @@ export class DaemonClient { ); } - async cloneGithubWorkspace( - input: { repo: string; targetDirectory: string; cloneProtocol?: WorkspaceGithubCloneProtocol }, + async cloneGithubProject( + input: { repo: string; targetDirectory: string; cloneProtocol?: ProjectGithubCloneProtocol }, requestId?: string, - ): Promise { + ): Promise { const message = { - type: "workspace.github.clone.request", + type: "project.github.clone.request", repo: input.repo, targetDirectory: input.targetDirectory, ...(input.cloneProtocol ? { cloneProtocol: input.cloneProtocol } : {}), } as const; - return this.sendNamespacedCorrelatedSessionRequest<"workspace.github.clone.response">({ + return this.sendNamespacedCorrelatedSessionRequest<"project.github.clone.response">({ requestId, message, - timeout: WORKSPACE_GITHUB_CLONE_TIMEOUT_MS, + timeout: PROJECT_GITHUB_CLONE_TIMEOUT_MS, }); } diff --git a/packages/protocol/src/messages.project-command-center.test.ts b/packages/protocol/src/messages.project-command-center.test.ts index 22660fbb5..b603162d9 100644 --- a/packages/protocol/src/messages.project-command-center.test.ts +++ b/packages/protocol/src/messages.project-command-center.test.ts @@ -157,7 +157,7 @@ describe("project command-center protocol", () => { ).toBe(" paseo "); }); - it("keeps both feature flags optional for older server_info payloads", () => { + it("keeps project command feature flags optional for older server_info payloads", () => { const parsed = parseServerInfoStatusPayload({ status: "server_info", serverId: "server-old", @@ -165,6 +165,7 @@ describe("project command-center protocol", () => { }); expect(parsed.features?.workspaceGithubRepositorySearch).toBeUndefined(); + expect(parsed.features?.projectGithubClone).toBeUndefined(); expect(parsed.features?.projectCreateDirectory).toBeUndefined(); }); }); diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 7325c5af8..ad8167c94 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1822,12 +1822,12 @@ export const WorkspaceGithubSearchRepositoriesRequestSchema = z.object({ requestId: z.string(), }); -export const WorkspaceGithubCloneProtocolSchema = z.enum(["https", "ssh"]); +export const ProjectGithubCloneProtocolSchema = z.enum(["https", "ssh"]); -export const WorkspaceGithubCloneRequestSchema = z.object({ - type: z.literal("workspace.github.clone.request"), +export const ProjectGithubCloneRequestSchema = z.object({ + type: z.literal("project.github.clone.request"), repo: z.string().trim().min(MIN_REPOSITORY_PATH_LENGTH), - cloneProtocol: WorkspaceGithubCloneProtocolSchema.optional(), + cloneProtocol: ProjectGithubCloneProtocolSchema.optional(), targetDirectory: z.string().trim().min(1), requestId: z.string(), }); @@ -2218,7 +2218,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ ProjectAddRequestSchema, ProjectCreateDirectoryRequestSchema, WorkspaceGithubSearchRepositoriesRequestSchema, - WorkspaceGithubCloneRequestSchema, + ProjectGithubCloneRequestSchema, ArchiveWorkspaceRequestSchema, WorkspaceCreateRequestSchema, WorkspaceClearAttentionRequestSchema, @@ -2463,8 +2463,8 @@ export const ServerInfoStatusPayloadSchema = z providerSubagents: z.boolean().optional(), // COMPAT(workspacePinning): added in v0.1.107, remove gate after 2027-01-12. workspacePinning: z.boolean().optional(), - // COMPAT(workspaceGithubClone): added in v0.1.108, remove gate after 2027-01-13. - workspaceGithubClone: z.boolean().optional(), + // COMPAT(projectGithubClone): added in v0.1.108, remove gate after 2027-01-15. + projectGithubClone: z.boolean().optional(), // COMPAT(workspaceGithubRepositorySearch): added in v0.1.108, remove gate after 2027-01-15. workspaceGithubRepositorySearch: z.boolean().optional(), // COMPAT(projectCreateDirectory): added in v0.1.108, remove gate after 2027-01-15. @@ -3024,13 +3024,13 @@ export const WorkspaceGithubSearchRepositoriesResponseSchema = z.object({ ]), }); -export const WorkspaceGithubCloneResponseSchema = z.object({ - type: z.literal("workspace.github.clone.response"), +export const ProjectGithubCloneResponseSchema = z.object({ + type: z.literal("project.github.clone.response"), payload: z.object({ requestId: z.string(), repo: z.string().trim().min(MIN_REPOSITORY_PATH_LENGTH), checkoutPath: z.string().nullable(), - workspace: WorkspaceDescriptorPayloadSchema.nullable(), + project: WorkspaceProjectDescriptorPayloadSchema.nullable(), error: z.string().nullable(), }), }); @@ -4459,7 +4459,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ ProjectCreateDirectoryResponseSchema, OpenProjectResponseMessageSchema, WorkspaceGithubSearchRepositoriesResponseSchema, - WorkspaceGithubCloneResponseSchema, + ProjectGithubCloneResponseSchema, StartWorkspaceScriptResponseMessageSchema, LegacyListAvailableEditorsResponseMessageSchema, LegacyOpenInEditorResponseMessageSchema, @@ -4624,7 +4624,7 @@ export type WorkspaceGithubSearchRepositoriesResponse = z.infer< typeof WorkspaceGithubSearchRepositoriesResponseSchema >; export type GithubRepository = z.infer; -export type WorkspaceGithubCloneResponse = z.infer; +export type ProjectGithubCloneResponse = z.infer; export type StartWorkspaceScriptResponseMessage = z.infer< typeof StartWorkspaceScriptResponseMessageSchema >; @@ -4878,8 +4878,8 @@ export type ProjectCreateDirectoryErrorCode = z.infer; -export type WorkspaceGithubCloneRequest = z.infer; -export type WorkspaceGithubCloneProtocol = z.infer; +export type ProjectGithubCloneRequest = z.infer; +export type ProjectGithubCloneProtocol = z.infer; export type ArchiveWorkspaceRequest = z.infer; export type WorkspaceClearAttentionRequest = z.infer; export type FileExplorerRequest = z.infer; diff --git a/packages/protocol/src/messages.workspaces.test.ts b/packages/protocol/src/messages.workspaces.test.ts index d47e46e3c..a446637a4 100644 --- a/packages/protocol/src/messages.workspaces.test.ts +++ b/packages/protocol/src/messages.workspaces.test.ts @@ -286,36 +286,45 @@ describe("workspace message schemas", () => { expect(parsed.type).toBe("open_project_request"); }); - test("parses workspace GitHub clone request and response repo paths", () => { + test("parses a GitHub clone response that registers a project without a workspace", () => { const request = SessionInboundMessageSchema.parse({ - type: "workspace.github.clone.request", + type: "project.github.clone.request", repo: "a/b", cloneProtocol: "https", targetDirectory: "~/workspace", requestId: "req-clone", }); const response = SessionOutboundMessageSchema.parse({ - type: "workspace.github.clone.response", + type: "project.github.clone.response", payload: { requestId: "req-clone", repo: "a/b", checkoutPath: "/tmp/b", - workspace: null, + project: { + projectId: "project-b", + projectDisplayName: "b", + projectRootPath: "/tmp/b", + projectKind: "git", + }, error: null, }, }); - expect(request.type).toBe("workspace.github.clone.request"); - if (request.type !== "workspace.github.clone.request") { - throw new Error("expected workspace.github.clone.request"); + expect(request.type).toBe("project.github.clone.request"); + if (request.type !== "project.github.clone.request") { + throw new Error("expected project.github.clone.request"); } expect(request.cloneProtocol).toBe("https"); - expect(response.type).toBe("workspace.github.clone.response"); + expect(response.type).toBe("project.github.clone.response"); + if (response.type !== "project.github.clone.response") { + throw new Error("expected project.github.clone.response"); + } + expect(response.payload.project?.projectId).toBe("project-b"); }); - test("rejects invalid workspace GitHub clone protocols", () => { + test("rejects invalid project GitHub clone protocols", () => { const request = SessionInboundMessageSchema.safeParse({ - type: "workspace.github.clone.request", + type: "project.github.clone.request", repo: "a/b", cloneProtocol: "ftp", targetDirectory: "~/workspace", @@ -325,20 +334,20 @@ describe("workspace message schemas", () => { expect(request.success).toBe(false); }); - test("rejects workspace GitHub clone repo paths shorter than owner slash repo", () => { + test("rejects project GitHub clone repo paths shorter than owner slash repo", () => { const request = SessionInboundMessageSchema.safeParse({ - type: "workspace.github.clone.request", + type: "project.github.clone.request", repo: "ab", targetDirectory: "~/workspace", requestId: "req-clone", }); const response = SessionOutboundMessageSchema.safeParse({ - type: "workspace.github.clone.response", + type: "project.github.clone.response", payload: { requestId: "req-clone", repo: "ab", checkoutPath: null, - workspace: null, + project: null, error: "failed", }, }); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 87ea2a8e2..899c8a810 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1650,8 +1650,8 @@ export class Session { return this.handleProjectCreateDirectoryRequest(msg); case "workspace.github.search_repositories.request": return this.handleWorkspaceGithubSearchRepositoriesRequest(msg); - case "workspace.github.clone.request": - return this.handleWorkspaceGithubCloneRequest(msg); + case "project.github.clone.request": + return this.handleProjectGithubCloneRequest(msg); case "archive_workspace_request": return this.handleArchiveWorkspaceRequest(msg); case "project.remove.request": @@ -4910,8 +4910,8 @@ export class Session { } } - private async handleWorkspaceGithubCloneRequest( - request: Extract, + private async handleProjectGithubCloneRequest( + request: Extract, ): Promise { let normalizedRepo = request.repo; let checkoutPath: string | null = null; @@ -4956,31 +4956,16 @@ export class Session { throw error; } - const workspace = - await this.workspaceProvisioning.findOrCreateWorkspaceForDirectory(checkoutPath); - await this.syncWorkspaceGitObserverForWorkspace(workspace); - const descriptor = await this.describeWorkspaceRecord(workspace); - await this.emitWorkspaceUpdateForWorkspaceId(workspace.workspaceId); - void this.workspaceGitService - .getSnapshot(workspace.cwd, { - force: true, - includeGitHub: true, - reason: "open_project", - }) - .catch((error) => { - this.sessionLogger.warn( - { err: error, cwd: workspace.cwd }, - "Background snapshot refresh failed after workspace.github.clone", - ); - }); + const project = + await this.workspaceProvisioning.findOrCreateProjectForDirectory(checkoutPath); this.emit({ - type: "workspace.github.clone.response", + type: "project.github.clone.response", payload: { requestId: request.requestId, repo: repo.displayName, checkoutPath, - workspace: descriptor, + project: this.buildProjectDescriptor(project), error: null, }, }); @@ -4988,15 +4973,15 @@ export class Session { const message = error instanceof Error ? error.message : "Failed to clone GitHub repo"; this.sessionLogger.error( { err: error, repo: request.repo, targetDirectory: request.targetDirectory }, - "Failed to clone GitHub workspace", + "Failed to clone GitHub project", ); this.emit({ - type: "workspace.github.clone.response", + type: "project.github.clone.response", payload: { requestId: request.requestId, repo: normalizedRepo, checkoutPath, - workspace: null, + project: null, error: message, }, }); diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 5f655fc51..948d14d3b 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1266,8 +1266,8 @@ export class VoiceAssistantWebSocketServer { providerSubagents: true, // COMPAT(workspacePinning): added in v0.1.107, remove gate after 2027-01-12. workspacePinning: true, - // COMPAT(workspaceGithubClone): added in v0.1.108, remove gate after 2027-01-13. - workspaceGithubClone: true, + // COMPAT(projectGithubClone): added in v0.1.108, remove gate after 2027-01-15. + projectGithubClone: true, // COMPAT(workspaceGithubRepositorySearch): added in v0.1.108, remove gate after 2027-01-15. workspaceGithubRepositorySearch: true, // COMPAT(projectCreateDirectory): added in v0.1.108, remove gate after 2027-01-15.