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.
This commit is contained in:
Mohamed Boudra
2026-07-15 23:13:55 +02:00
parent 38cfe109c9
commit 943d03ad99
23 changed files with 340 additions and 295 deletions
+1 -1
View File
@@ -380,7 +380,7 @@ npm run cli -- ls -a -g --json # Same, as JSON
npm run cli -- inspect <id> # Show detailed agent info npm run cli -- inspect <id> # Show detailed agent info
npm run cli -- logs <id> # View agent timeline npm run cli -- logs <id> # View agent timeline
npm run cli -- daemon status # Check daemon status 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 <host:port>` to point the CLI at a different daemon: Use `--host <host:port>` to point the CLI at a different daemon:
+38 -4
View File
@@ -11,6 +11,7 @@ import {
addProjectFlowMethod, addProjectFlowMethod,
chooseAddProjectMethod, chooseAddProjectMethod,
expectAddProjectPage, expectAddProjectPage,
expectNewWorkspaceForAddedProject,
openAddProjectFlow, openAddProjectFlow,
waitForConnectedHost, waitForConnectedHost,
} from "./helpers/add-project-flow"; } from "./helpers/add-project-flow";
@@ -20,6 +21,7 @@ import { addOfflineHostAndReload } from "./helpers/hosts";
import { type IsolatedHostDaemon, startIsolatedHostDaemon } from "./helpers/isolated-host-daemon"; import { type IsolatedHostDaemon, startIsolatedHostDaemon } from "./helpers/isolated-host-daemon";
import { expectOpenedProject } from "./helpers/project-picker-ui"; import { expectOpenedProject } from "./helpers/project-picker-ui";
import { connectSeedClient } from "./helpers/seed-client"; import { connectSeedClient } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
const EXTRA_HOSTS_KEY = "@paseo:e2e-extra-hosts"; const EXTRA_HOSTS_KEY = "@paseo:e2e-extra-hosts";
const SECONDARY_HOST_ID = "add-project-flow-secondary"; const SECONDARY_HOST_ID = "add-project-flow-secondary";
@@ -62,6 +64,16 @@ async function removeCreatedProject(
} }
} }
async function expectProjectHasNoWorkspaces(projectId: string): Promise<void> {
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("Add Project command-center flow", () => {
test.describe.configure({ timeout: 180_000 }); 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.type(directoryName);
await page.keyboard.press("Enter"); 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); await expectProjectDirectory(directoryPath);
} finally { } finally {
await rm(parentDirectory, { recursive: true, force: true }); 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); const projectId = await expectOpenedProject(page, projectPickerFixture.projectName);
projectPickerFixture.rememberProjectId(projectId); 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 }) => { 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 page.keyboard.press("Enter");
await expectAddProjectPage(page, "github-location"); await expectAddProjectPage(page, "github-location");
await expect(addProjectFlow(page).getByTestId("add-project-flow-title")).toContainText( const title = addProjectFlow(page).getByTestId("add-project-flow-title");
"manual", 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 addProjectFlowBack(page).click();
await expect(addProjectFlowInput(page)).toHaveValue(remote); await expect(addProjectFlowInput(page)).toHaveValue(remote);
}); });
@@ -307,6 +334,13 @@ test.describe("Add Project command-center flow", () => {
await page.keyboard.press("Enter"); await page.keyboard.press("Enter");
projectId = await expectOpenedProject(page, directoryName); projectId = await expectOpenedProject(page, directoryName);
await expectNewWorkspaceForAddedProject(page, {
serverId: getServerId(),
projectId,
projectName: directoryName,
projectPath: directoryPath,
});
await expectProjectHasNoWorkspaces(projectId);
await expectProjectDirectory(directoryPath); await expectProjectDirectory(directoryPath);
} finally { } finally {
await removeCreatedProject(directoryPath, projectId).catch(() => undefined); await removeCreatedProject(directoryPath, projectId).catch(() => undefined);
@@ -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 { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import { expect, test } from "./fixtures"; import { expect, test } from "./fixtures";
@@ -8,12 +8,14 @@ import {
addProjectFlowInput, addProjectFlowInput,
chooseAddProjectMethod, chooseAddProjectMethod,
expectAddProjectPage, expectAddProjectPage,
expectNewWorkspaceForAddedProject,
openAddProjectFlow, openAddProjectFlow,
} from "./helpers/add-project-flow"; } from "./helpers/add-project-flow";
import { gotoAppShell } from "./helpers/app"; import { gotoAppShell } from "./helpers/app";
import { createTempGithubRepo, hasGithubAuth, type GhRepoFixture } from "./helpers/github-fixtures"; import { createTempGithubRepo, hasGithubAuth, type GhRepoFixture } from "./helpers/github-fixtures";
import { expectOpenedProject } from "./helpers/project-picker-ui"; import { expectOpenedProject } from "./helpers/project-picker-ui";
import { connectSeedClient } from "./helpers/seed-client"; import { connectSeedClient } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
test.describe("Add Project GitHub flow", () => { test.describe("Add Project GitHub flow", () => {
test.describe.configure({ timeout: 300_000 }); test.describe.configure({ timeout: 300_000 });
@@ -56,8 +58,27 @@ test.describe("Add Project GitHub flow", () => {
await expectAddProjectPage(page, "github-location"); await expectAddProjectPage(page, "github-location");
await expect(addProjectFlowInput(page)).toHaveValue(parentDirectory); 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"); await page.keyboard.press("Enter");
projectId = await expectOpenedProject(page, repository.name); 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); await expect.poll(async () => (await stat(checkoutPath)).isDirectory()).toBe(true);
} finally { } finally {
if (projectId) { if (projectId) {
@@ -72,3 +72,24 @@ export async function chooseAddProjectMethod(page: Page, method: AddProjectMetho
await expectAddProjectPage(page, METHOD_DESTINATIONS[method]); await expectAddProjectPage(page, METHOD_DESTINATIONS[method]);
} }
} }
export async function expectNewWorkspaceForAddedProject(
page: Page,
input: {
serverId: string;
projectId: string;
projectName: string;
projectPath: string;
},
): Promise<void> {
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 },
);
}
@@ -2,7 +2,9 @@ import { test, expect } from "./fixtures";
import { gotoAppShell } from "./helpers/app"; import { gotoAppShell } from "./helpers/app";
import { injectDesktopBridge, waitForDirectoryDialog } from "./helpers/desktop-updates"; import { injectDesktopBridge, waitForDirectoryDialog } from "./helpers/desktop-updates";
import { expectOpenedProject } from "./helpers/project-picker-ui"; import { expectOpenedProject } from "./helpers/project-picker-ui";
import { expectNewWorkspaceForAddedProject } from "./helpers/add-project-flow";
import { getServerId } from "./helpers/server-id"; 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"); 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); const projectId = await expectOpenedProject(page, projectPickerFixture.projectName);
projectPickerFixture.rememberProjectId(projectId); 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 ({ test("canceling Browse returns to the Add Project methods", async ({
@@ -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,
},
]);
});
}); });
+24 -9
View File
@@ -3,6 +3,7 @@ import {
parseGitHubRemoteUrl, parseGitHubRemoteUrl,
parseGitRemoteLocation, parseGitRemoteLocation,
} from "@getpaseo/protocol/git-remote"; } from "@getpaseo/protocol/git-remote";
import { shortenPath } from "@/utils/shorten-path";
import type { AddProjectHost, GithubRepositoryChoice } from "./model"; import type { AddProjectHost, GithubRepositoryChoice } from "./model";
export type AddProjectMethodId = "directory-search" | "browse" | "github" | "new-directory"; export type AddProjectMethodId = "directory-search" | "browse" | "github" | "new-directory";
@@ -150,15 +151,29 @@ export function buildCloneLocationOptions(input: {
repositoryName: string; repositoryName: string;
existingPaths: string[]; existingPaths: string[];
}): AddProjectPathOption[] { }): AddProjectPathOption[] {
const existing = new Set(input.existingPaths); const existing = new Set(input.existingPaths.map(pathIdentity));
return input.parents.map((parent) => { const seen = new Set<string>();
return input.parents.flatMap((parent) => {
const path = joinDirectoryPath(parent, input.repositoryName); const path = joinDirectoryPath(parent, input.repositoryName);
return { const identity = pathIdentity(path);
id: parent, if (seen.has(identity)) return [];
path: parent, seen.add(identity);
displayPath: path, const pathExists = existing.has(identity);
secondaryText: existing.has(path) ? "Already exists" : `Parent directory: ${parent}`, return [
disabled: existing.has(path), {
}; 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;
}
+2
View File
@@ -22,6 +22,7 @@ import { KeyboardProvider } from "react-native-keyboard-controller";
import { SafeAreaProvider } from "react-native-safe-area-context"; import { SafeAreaProvider } from "react-native-safe-area-context";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { CommandCenter } from "@/components/command-center"; import { CommandCenter } from "@/components/command-center";
import { AddProjectFlowHost } from "@/components/add-project-flow-host";
import { WorktreeSetupCalloutSource } from "@/components/worktree-setup-callout-source"; import { WorktreeSetupCalloutSource } from "@/components/worktree-setup-callout-source";
import { DownloadToast } from "@/components/download-toast"; import { DownloadToast } from "@/components/download-toast";
import { QuittingOverlay } from "@/components/quitting-overlay"; import { QuittingOverlay } from "@/components/quitting-overlay";
@@ -548,6 +549,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
<UpdateCalloutSource /> <UpdateCalloutSource />
<WorktreeSetupCalloutSource /> <WorktreeSetupCalloutSource />
<CommandCenter /> <CommandCenter />
<AddProjectFlowHost />
<HostChooserModal /> <HostChooserModal />
<ProviderSettingsHost /> <ProviderSettingsHost />
<WorkspaceSetupDialog /> <WorkspaceSetupDialog />
@@ -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 <AddProjectFlow key={request.id} request={request} onClose={close} />;
}
@@ -1,4 +1,5 @@
import { router } from "expo-router"; import { router } from "expo-router";
import type { WorkspaceProjectDescriptorPayload } from "@getpaseo/protocol/messages";
import { import {
ArrowLeft, ArrowLeft,
Folder, Folder,
@@ -63,7 +64,7 @@ import { pickDirectory } from "@/desktop/pick-directory";
import { useFetchQuery } from "@/data/query"; import { useFetchQuery } from "@/data/query";
import { getOpenProjectFailureReason, registerProjectDescriptor } from "@/hooks/open-project"; import { getOpenProjectFailureReason, registerProjectDescriptor } from "@/hooks/open-project";
import { useIsLocalDaemon, useLocalDaemonServerId } from "@/hooks/use-is-local-daemon"; 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 { import {
useHosts, useHosts,
useHostRuntimeClient, useHostRuntimeClient,
@@ -75,7 +76,7 @@ import { useRecommendedProjectPaths } from "@/stores/session-store-hooks";
import type { AddProjectFlowRequest } from "@/stores/add-project-flow-store"; import type { AddProjectFlowRequest } from "@/stores/add-project-flow-store";
import type { Theme } from "@/styles/theme"; import type { Theme } from "@/styles/theme";
import { shortenPath } from "@/utils/shorten-path"; import { shortenPath } from "@/utils/shorten-path";
import { buildSettingsAddHostRoute } from "@/utils/host-routes"; import { buildNewWorkspaceRoute, buildSettingsAddHostRoute } from "@/utils/host-routes";
interface AddProjectFlowProps { interface AddProjectFlowProps {
request: AddProjectFlowRequest; request: AddProjectFlowRequest;
@@ -197,7 +198,7 @@ function pageTitle(page: AddProjectPage): string {
case "github-search": case "github-search":
return "Clone from GitHub"; return "Clone from GitHub";
case "github-location": case "github-location":
return `Where should Paseo create ${pathBaseName(page.repository.nameWithOwner)}?`; return "Choose destination";
case "new-directory-parent": case "new-directory-parent":
return "Choose parent directory"; return "Choose parent directory";
case "new-directory-name": case "new-directory-name":
@@ -296,8 +297,8 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
const hostIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]); const hostIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]);
const connectionStatuses = useHostRuntimeConnectionStatuses(hostIds); const connectionStatuses = useHostRuntimeConnectionStatuses(hostIds);
const projectAddByHost = useHostFeatureMap(hostIds, "projectAdd"); const projectAddByHost = useHostFeatureMap(hostIds, "projectAdd");
// COMPAT(workspaceGithubClone): added in v0.1.108, remove gate after 2027-01-15. // COMPAT(projectGithubClone): added in v0.1.108, remove gate after 2027-01-15.
const githubCloneByHost = useHostFeatureMap(hostIds, "workspaceGithubClone"); const githubCloneByHost = useHostFeatureMap(hostIds, "projectGithubClone");
// COMPAT(workspaceGithubRepositorySearch): added in v0.1.108, remove gate after 2027-01-15. // COMPAT(workspaceGithubRepositorySearch): added in v0.1.108, remove gate after 2027-01-15.
const githubSearchByHost = useHostFeatureMap(hostIds, "workspaceGithubRepositorySearch"); const githubSearchByHost = useHostFeatureMap(hostIds, "workspaceGithubRepositorySearch");
// COMPAT(projectCreateDirectory): added in v0.1.108, remove gate after 2027-01-15. // 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 isLocalDaemon = useIsLocalDaemon(hostId ?? "");
const recommendedPaths = useRecommendedProjectPaths(hostId); const recommendedPaths = useRecommendedProjectPaths(hostId);
const openProject = useOpenProject(hostId); const openProject = useOpenProject(hostId);
const openGithubRepo = useOpenGithubRepo(hostId); const cloneGithubProject = useCloneGithubProject(hostId);
const addEmptyProject = useSessionStore((store) => store.addEmptyProject); const addEmptyProject = useSessionStore((store) => store.addEmptyProject);
const setHasHydratedWorkspaces = useSessionStore((store) => store.setHasHydratedWorkspaces); const setHasHydratedWorkspaces = useSessionStore((store) => store.setHasHydratedWorkspaces);
const inputRef = useRef<TextInput>(null); const inputRef = useRef<TextInput>(null);
@@ -416,9 +417,24 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
}); });
}, [onClose]); }, [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( const openAddedProject = useCallback(
async (path: string, sourceKind: "directory-search" | "method") => { async (path: string, sourceKind: "directory-search" | "method") => {
if (submissionInFlightRef.current) return; if (!hostId || submissionInFlightRef.current) return;
submissionInFlightRef.current = true; submissionInFlightRef.current = true;
setState((current) => setState((current) =>
setPageStatus(current, sourceKind, { isSubmitting: true, error: null }), setPageStatus(current, sourceKind, { isSubmitting: true, error: null }),
@@ -426,7 +442,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
try { try {
const result = await openProject(path); const result = await openProject(path);
if (result.ok) { if (result.ok) {
onClose(); openNewWorkspaceForProject(hostId, result.project);
return; return;
} }
const reason = getOpenProjectFailureReason(result); const reason = getOpenProjectFailureReason(result);
@@ -446,7 +462,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
submissionInFlightRef.current = false; submissionInFlightRef.current = false;
} }
}, },
[onClose, openProject], [hostId, openNewWorkspaceForProject, openProject],
); );
const browse = useCallback(async () => { const browse = useCallback(async () => {
@@ -501,34 +517,34 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
setPageStatus(current, "github-location", { isSubmitting: true, error: null }), setPageStatus(current, "github-location", { isSubmitting: true, error: null }),
); );
try { try {
const opened = await openGithubRepo( const result = await cloneGithubProject(
locationPage.repository.cloneUrl, locationPage.repository.cloneUrl,
parentPath, parentPath,
locationPage.repository.cloneProtocol, locationPage.repository.cloneProtocol,
); );
if (opened) { if (result.ok) {
lastCloneParentByHost.set(locationPage.hostId, parentPath); lastCloneParentByHost.set(locationPage.hostId, parentPath);
onClose(); openNewWorkspaceForProject(locationPage.hostId, result.project);
return; return;
} }
setState((current) => setState((current) =>
setPageStatus(current, "github-location", { setPageStatus(current, "github-location", {
isSubmitting: false, isSubmitting: false,
error: "Unable to clone repository", error: result.error ?? "Unable to clone repository",
}), }),
); );
} catch { } catch (error) {
setState((current) => setState((current) =>
setPageStatus(current, "github-location", { setPageStatus(current, "github-location", {
isSubmitting: false, isSubmitting: false,
error: "Unable to clone repository", error: error instanceof Error ? error.message : "Unable to clone repository",
}), }),
); );
} finally { } finally {
submissionInFlightRef.current = false; submissionInFlightRef.current = false;
} }
}, },
[onClose, openGithubRepo], [cloneGithubProject, openNewWorkspaceForProject],
); );
const rows = useMemo<FlowRowOption[]>(() => { const rows = useMemo<FlowRowOption[]>(() => {
if (page.kind === "host") { if (page.kind === "host") {
@@ -702,7 +718,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
addEmptyProject, addEmptyProject,
setHasHydratedWorkspaces, setHasHydratedWorkspaces,
}); });
onClose(); openNewWorkspaceForProject(page.hostId, payload.project);
} catch { } catch {
setState((current) => setState((current) =>
setPageStatus(current, "new-directory-name", { setPageStatus(current, "new-directory-name", {
@@ -713,7 +729,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
} finally { } finally {
submissionInFlightRef.current = false; submissionInFlightRef.current = false;
} }
}, [addEmptyProject, client, onClose, page, setHasHydratedWorkspaces]); }, [addEmptyProject, client, openNewWorkspaceForProject, page, setHasHydratedWorkspaces]);
const submitActive = useCallback(() => { const submitActive = useCallback(() => {
if (page.kind === "new-directory-name") { if (page.kind === "new-directory-name") {
@@ -21,8 +21,6 @@ import {
import { AgentStatusDot } from "@/components/agent-status-dot"; import { AgentStatusDot } from "@/components/agent-status-dot";
import { Shortcut } from "@/components/ui/shortcut"; import { Shortcut } from "@/components/ui/shortcut";
import { isNative, isWeb } from "@/constants/platform"; 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 { useIsCompactFormFactor } from "@/constants/layout";
import { import {
IsolatedBottomSheetModal, IsolatedBottomSheetModal,
@@ -338,8 +336,6 @@ export function CommandCenter() {
handleSelectItem, handleSelectItem,
handleKeyEvent, handleKeyEvent,
} = useCommandCenter(); } = useCommandCenter();
const addProjectRequest = useAddProjectFlowStore((state) => state.request);
const isCompact = useIsCompactFormFactor(); const isCompact = useIsCompactFormFactor();
const showBottomSheet = isCompact && isNative; const showBottomSheet = isCompact && isNative;
const rowRefs = useRef<Map<number, View>>(new Map()); const rowRefs = useRef<Map<number, View>>(new Map());
@@ -493,16 +489,6 @@ export function CommandCenter() {
const snapPoints = useMemo(() => ["60%", "90%"], []); const snapPoints = useMemo(() => ["60%", "90%"], []);
if (open && addProjectRequest) {
return (
<AddProjectFlow
key={addProjectRequest.id}
request={addProjectRequest}
onClose={handleClose}
/>
);
}
const resultList = const resultList =
items.length === 0 ? ( items.length === 0 ? (
<Text style={emptyTextStyle}>{t("shell.commandCenter.noMatches")}</Text> <Text style={emptyTextStyle}>{t("shell.commandCenter.noMatches")}</Text>
+30 -50
View File
@@ -1,27 +1,20 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { import type {
ProjectGithubCloneProtocol,
ProjectAddResponse, ProjectAddResponse,
WorkspaceGithubCloneProtocol,
WorkspaceProjectDescriptorPayload, WorkspaceProjectDescriptorPayload,
} from "@getpaseo/protocol/messages"; } from "@getpaseo/protocol/messages";
import { import {
normalizeEmptyProjectDescriptor as normalizeProjectWithoutWorkspacesDescriptor, normalizeEmptyProjectDescriptor as normalizeProjectWithoutWorkspacesDescriptor,
normalizeWorkspaceDescriptor,
type EmptyProjectDescriptor as ProjectWithoutWorkspacesDescriptor, type EmptyProjectDescriptor as ProjectWithoutWorkspacesDescriptor,
type WorkspaceDescriptor,
} from "@/stores/session-store"; } 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 OpenProjectPayload = ProjectAddResponse["payload"];
type OpenProjectErrorCode = NonNullable<OpenProjectPayload["errorCode"]>; type OpenProjectErrorCode = NonNullable<OpenProjectPayload["errorCode"]>;
type WorkspaceOpenPayload =
| Awaited<ReturnType<DaemonClient["openProject"]>>
| Awaited<ReturnType<DaemonClient["cloneGithubWorkspace"]>>;
export interface OpenProjectSuccess { export interface OpenProjectSuccess {
ok: true; ok: true;
project: WorkspaceProjectDescriptorPayload;
} }
export interface OpenProjectFailure { export interface OpenProjectFailure {
@@ -32,7 +25,7 @@ export interface OpenProjectFailure {
export type OpenProjectResult = OpenProjectSuccess | OpenProjectFailure; export type OpenProjectResult = OpenProjectSuccess | OpenProjectFailure;
export type OpenProjectFailureReason = "directory_not_found" | "open_failed"; export type OpenProjectFailureReason = "directory_not_found" | "open_failed";
export type { WorkspaceGithubCloneProtocol }; export type { ProjectGithubCloneProtocol };
export function getOpenProjectFailureReason( export function getOpenProjectFailureReason(
result: OpenProjectResult, result: OpenProjectResult,
@@ -58,12 +51,11 @@ export interface OpenProjectDirectlyInput {
setHasHydratedWorkspaces: (serverId: string, hydrated: boolean) => void; setHasHydratedWorkspaces: (serverId: string, hydrated: boolean) => void;
} }
interface WorkspaceOpenCallbacks { interface ProjectRegistrationCallbacks {
serverId: string; serverId: string;
isConnected: boolean; isConnected: boolean;
mergeWorkspaces: (serverId: string, workspaces: Iterable<WorkspaceDescriptor>) => void; addEmptyProject: (serverId: string, project: ProjectWithoutWorkspacesDescriptor) => void;
setHasHydratedWorkspaces: (serverId: string, hydrated: boolean) => void; setHasHydratedWorkspaces: (serverId: string, hydrated: boolean) => void;
navigateToWorkspace: (input: NavigateToWorkspaceInput) => string;
} }
export interface RegisterProjectDescriptorInput { export interface RegisterProjectDescriptorInput {
@@ -81,11 +73,11 @@ export function registerProjectDescriptor(input: RegisterProjectDescriptorInput)
return true; return true;
} }
export interface OpenGithubRepoDirectlyInput extends WorkspaceOpenCallbacks { export interface CloneGithubProjectDirectlyInput extends ProjectRegistrationCallbacks {
repo: string; repo: string;
targetDirectory: string; targetDirectory: string;
cloneProtocol?: WorkspaceGithubCloneProtocol; cloneProtocol?: ProjectGithubCloneProtocol;
client: Pick<DaemonClient, "cloneGithubWorkspace"> | null; client: Pick<DaemonClient, "cloneGithubProject"> | null;
} }
export async function openProjectDirectly( export async function openProjectDirectly(
@@ -114,44 +106,20 @@ export async function openProjectDirectly(
}; };
} }
registerProjectDescriptor({ const registered = registerProjectDescriptor({
serverId: normalizedServerId, serverId: normalizedServerId,
project: payload.project, project: payload.project,
addEmptyProject: input.addEmptyProject, addEmptyProject: input.addEmptyProject,
setHasHydratedWorkspaces: input.setHasHydratedWorkspaces, setHasHydratedWorkspaces: input.setHasHydratedWorkspaces,
}); });
return { ok: true }; return registered
? { ok: true, project: payload.project }
: { ok: false, errorCode: null, error: "Unable to register project" };
} }
function finishWorkspaceOpen( export async function cloneGithubProjectDirectly(
input: WorkspaceOpenCallbacks, input: CloneGithubProjectDirectlyInput,
payload: WorkspaceOpenPayload, ): Promise<OpenProjectResult> {
): 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<boolean> {
const normalizedServerId = input.serverId.trim(); const normalizedServerId = input.serverId.trim();
const trimmedRepo = input.repo.trim(); const trimmedRepo = input.repo.trim();
const trimmedTargetDirectory = input.targetDirectory.trim(); const trimmedTargetDirectory = input.targetDirectory.trim();
@@ -162,13 +130,25 @@ export async function openGithubRepoDirectly(input: OpenGithubRepoDirectlyInput)
!input.client || !input.client ||
!input.isConnected !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, repo: trimmedRepo,
targetDirectory: trimmedTargetDirectory, targetDirectory: trimmedTargetDirectory,
...(input.cloneProtocol ? { cloneProtocol: input.cloneProtocol } : {}), ...(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" };
} }
+12 -15
View File
@@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher"; import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
import { useAggregatedAgents, type AggregatedAgent } from "@/hooks/use-aggregated-agents"; 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 { import {
clearCommandCenterFocusRestoreElement, clearCommandCenterFocusRestoreElement,
takeCommandCenterFocusRestoreElement, takeCommandCenterFocusRestoreElement,
@@ -146,9 +146,7 @@ export function useCommandCenter() {
const { overrides } = useKeyboardShortcutOverrides(); const { overrides } = useKeyboardShortcutOverrides();
const open = useKeyboardShortcutsStore((s) => s.commandCenterOpen); const open = useKeyboardShortcutsStore((s) => s.commandCenterOpen);
const setOpen = useKeyboardShortcutsStore((s) => s.setCommandCenterOpen); const setOpen = useKeyboardShortcutsStore((s) => s.setCommandCenterOpen);
const addProjectRequest = useAddProjectFlowStore((state) => state.request); const openAddProject = useOpenAddProject();
const openAddProjectFlow = useAddProjectFlowStore((state) => state.open);
const closeAddProjectFlow = useAddProjectFlowStore((state) => state.close);
const inputRef = useRef<TextInput>(null); const inputRef = useRef<TextInput>(null);
const didNavigateRef = useRef(false); const didNavigateRef = useRef(false);
const prevOpenRef = useRef(open); const prevOpenRef = useRef(open);
@@ -323,19 +321,19 @@ export function useCommandCenter() {
const handleSelectAction = useCallback( const handleSelectAction = useCallback(
(action: CommandCenterActionItem) => { (action: CommandCenterActionItem) => {
if (action.id === "new-agent") {
openAddProjectFlow();
return;
}
clearCommandCenterFocusRestoreElement(); clearCommandCenterFocusRestoreElement();
setOpen(false); setOpen(false);
if (action.id === "new-agent") {
openAddProject();
return;
}
if (!action.route) { if (!action.route) {
return; return;
} }
didNavigateRef.current = true; didNavigateRef.current = true;
router.push(action.route); router.push(action.route);
}, },
[openAddProjectFlow, setOpen], [openAddProject, setOpen],
); );
const handleSelectItem = useCallback( const handleSelectItem = useCallback(
@@ -374,7 +372,6 @@ export function useCommandCenter() {
prevOpenRef.current = open; prevOpenRef.current = open;
if (!open) { if (!open) {
closeAddProjectFlow();
setQuery(""); setQuery("");
setActiveIndex(0); setActiveIndex(0);
@@ -405,7 +402,7 @@ export function useCommandCenter() {
inputRef.current?.focus(); inputRef.current?.focus();
}, 0); }, 0);
return () => clearTimeout(id); return () => clearTimeout(id);
}, [closeAddProjectFlow, open]); }, [open]);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
@@ -416,7 +413,7 @@ export function useCommandCenter() {
const handleKeyEvent = useCallback( const handleKeyEvent = useCallback(
(key: string): boolean => { (key: string): boolean => {
if (!open || addProjectRequest) return false; if (!open) return false;
const currentItems = itemsRef.current; const currentItems = itemsRef.current;
if (key === "Escape") { if (key === "Escape") {
@@ -445,11 +442,11 @@ export function useCommandCenter() {
return false; return false;
}, },
[addProjectRequest, open], [open],
); );
useEffect(() => { useEffect(() => {
if (!open || addProjectRequest || !isWeb) return; if (!open || !isWeb) return;
const handler = (event: KeyboardEvent) => { const handler = (event: KeyboardEvent) => {
if ( if (
@@ -468,7 +465,7 @@ export function useCommandCenter() {
// react-native-web can stop propagation on key events, so listen in capture phase. // react-native-web can stop propagation on key events, so listen in capture phase.
window.addEventListener("keydown", handler, true); window.addEventListener("keydown", handler, true);
return () => window.removeEventListener("keydown", handler, true); return () => window.removeEventListener("keydown", handler, true);
}, [addProjectRequest, open, handleKeyEvent]); }, [open, handleKeyEvent]);
return { return {
open, open,
+1 -12
View File
@@ -1,16 +1,5 @@
import { useCallback } from "react";
import { useAddProjectFlowStore } from "@/stores/add-project-flow-store"; import { useAddProjectFlowStore } from "@/stores/add-project-flow-store";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
export function useOpenAddProject(): (preferredHostId?: string) => void { export function useOpenAddProject(): (preferredHostId?: string) => void {
const openFlow = useAddProjectFlowStore((state) => state.open); return useAddProjectFlowStore((state) => state.open);
const setCommandCenterOpen = useKeyboardShortcutsStore((state) => state.setCommandCenterOpen);
return useCallback(
(preferredHostId?: string) => {
openFlow(preferredHostId);
setCommandCenterOpen(true);
},
[openFlow, setCommandCenterOpen],
);
} }
+30 -82
View File
@@ -1,14 +1,10 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
cloneGithubProjectDirectly,
getOpenProjectFailureReason, getOpenProjectFailureReason,
openGithubRepoDirectly,
openProjectDirectly, openProjectDirectly,
} from "@/hooks/open-project"; } from "@/hooks/open-project";
import type { import type { EmptyProjectDescriptor as ProjectWithoutWorkspacesDescriptor } from "@/stores/session-store";
EmptyProjectDescriptor as ProjectWithoutWorkspacesDescriptor,
WorkspaceDescriptor,
} from "@/stores/session-store";
import type { NavigateToWorkspaceInput } from "@/stores/navigation-active-workspace-store";
const SERVER_ID = "server-1"; const SERVER_ID = "server-1";
const PROJECT_PATH = "/repo/project"; 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 { interface RecordedProject {
serverId: string; serverId: string;
project: ProjectWithoutWorkspacesDescriptor; project: ProjectWithoutWorkspacesDescriptor;
} }
interface RecordedMerge {
serverId: string;
workspaces: WorkspaceDescriptor[];
}
interface RecordedHydrated { interface RecordedHydrated {
serverId: string; serverId: string;
hydrated: boolean; hydrated: boolean;
@@ -64,47 +36,31 @@ interface RecordedClone {
function createFakeSession() { function createFakeSession() {
const projects: RecordedProject[] = []; const projects: RecordedProject[] = [];
const merges: RecordedMerge[] = [];
const hydrated: RecordedHydrated[] = []; const hydrated: RecordedHydrated[] = [];
return { return {
projects, projects,
merges,
hydrated, hydrated,
addEmptyProject: (serverId: string, project: ProjectWithoutWorkspacesDescriptor) => { addEmptyProject: (serverId: string, project: ProjectWithoutWorkspacesDescriptor) => {
projects.push({ serverId, project }); projects.push({ serverId, project });
}, },
mergeWorkspaces: (serverId: string, workspaces: Iterable<WorkspaceDescriptor>) => {
merges.push({ serverId, workspaces: Array.from(workspaces) });
},
setHasHydratedWorkspaces: (serverId: string, value: boolean) => { setHasHydratedWorkspaces: (serverId: string, value: boolean) => {
hydrated.push({ serverId, hydrated: value }); hydrated.push({ serverId, hydrated: value });
}, },
}; };
} }
function createFakeNavigator() { function createFakeGithubCloneClient(project: ReturnType<typeof buildProjectPayload> | null) {
const navigations: NavigateToWorkspaceInput[] = [];
return {
navigations,
navigateToWorkspace: (input: NavigateToWorkspaceInput) => {
navigations.push(input);
return `/hosts/${input.serverId}/workspaces/${input.workspaceId}`;
},
};
}
function createFakeGithubCloneClient(workspace: ReturnType<typeof buildWorkspacePayload>) {
const clones: RecordedClone[] = []; const clones: RecordedClone[] = [];
return { return {
clones, clones,
cloneGithubWorkspace: async (input: RecordedClone) => { cloneGithubProject: async (input: RecordedClone) => {
clones.push(input); clones.push(input);
return { return {
requestId: "request-3", requestId: "request-3",
repo: "owner/project", repo: "owner/project",
checkoutPath: PROJECT_PATH, checkoutPath: PROJECT_PATH,
error: null, error: project ? null : "Project registration failed",
workspace, project,
}; };
}, },
}; };
@@ -131,7 +87,7 @@ describe("openProjectDirectly", () => {
setHasHydratedWorkspaces: session.setHasHydratedWorkspaces, setHasHydratedWorkspaces: session.setHasHydratedWorkspaces,
}); });
expect(result).toEqual({ ok: true }); expect(result).toEqual({ ok: true, project: projectPayload });
expect(session.projects).toEqual([ expect(session.projects).toEqual([
{ {
serverId: SERVER_ID, serverId: SERVER_ID,
@@ -144,7 +100,6 @@ describe("openProjectDirectly", () => {
}, },
}, },
]); ]);
expect(session.merges).toEqual([]);
expect(session.hydrated).toEqual([{ serverId: SERVER_ID, hydrated: true }]); expect(session.hydrated).toEqual([{ serverId: SERVER_ID, hydrated: true }]);
}); });
@@ -205,26 +160,24 @@ describe("openProjectDirectly", () => {
}); });
}); });
describe("openGithubRepoDirectly", () => { describe("cloneGithubProjectDirectly", () => {
it("opens a cloned GitHub workspace and seeds a draft tab", async () => { it("registers a cloned GitHub project without creating a workspace", async () => {
const session = createFakeSession(); const session = createFakeSession();
const navigator = createFakeNavigator(); const projectPayload = buildProjectPayload();
const workspacePayload = buildWorkspacePayload(); const github = createFakeGithubCloneClient(projectPayload);
const github = createFakeGithubCloneClient(workspacePayload);
const result = await openGithubRepoDirectly({ const result = await cloneGithubProjectDirectly({
serverId: SERVER_ID, serverId: SERVER_ID,
repo: "owner/project", repo: "owner/project",
targetDirectory: "~/workspace", targetDirectory: "~/workspace",
cloneProtocol: "https", cloneProtocol: "https",
isConnected: true, isConnected: true,
client: github, client: github,
mergeWorkspaces: session.mergeWorkspaces, addEmptyProject: session.addEmptyProject,
setHasHydratedWorkspaces: session.setHasHydratedWorkspaces, setHasHydratedWorkspaces: session.setHasHydratedWorkspaces,
navigateToWorkspace: navigator.navigateToWorkspace,
}); });
expect(result).toBe(true); expect(result).toEqual({ ok: true, project: projectPayload });
expect(github.clones).toEqual([ expect(github.clones).toEqual([
{ {
repo: "owner/project", repo: "owner/project",
@@ -232,45 +185,40 @@ describe("openGithubRepoDirectly", () => {
cloneProtocol: "https", cloneProtocol: "https",
}, },
]); ]);
expect(session.merges).toHaveLength(1); expect(session.projects).toEqual([
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([
{ {
serverId: SERVER_ID, serverId: SERVER_ID,
workspaceId: "1", project: {
target: { kind: "draft", draftId: expect.any(String) }, ...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 session = createFakeSession();
const navigator = createFakeNavigator(); const github = createFakeGithubCloneClient(null);
const github = createFakeGithubCloneClient({ ...buildWorkspacePayload(), id: " " });
const result = await openGithubRepoDirectly({ const result = await cloneGithubProjectDirectly({
serverId: SERVER_ID, serverId: SERVER_ID,
repo: "owner/project", repo: "owner/project",
targetDirectory: "~/workspace", targetDirectory: "~/workspace",
cloneProtocol: "https", cloneProtocol: "https",
isConnected: true, isConnected: true,
client: github, client: github,
mergeWorkspaces: session.mergeWorkspaces, addEmptyProject: session.addEmptyProject,
setHasHydratedWorkspaces: session.setHasHydratedWorkspaces, setHasHydratedWorkspaces: session.setHasHydratedWorkspaces,
navigateToWorkspace: navigator.navigateToWorkspace,
}); });
expect(result).toBe(false); expect(result).toEqual({
expect(session.merges).toEqual([]); ok: false,
errorCode: null,
error: "Project registration failed",
});
expect(session.projects).toEqual([]);
expect(session.hydrated).toEqual([]); expect(session.hydrated).toEqual([]);
expect(navigator.navigations).toEqual([]);
}); });
}); });
+10 -12
View File
@@ -1,12 +1,11 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
import { import {
openGithubRepoDirectly, cloneGithubProjectDirectly,
openProjectDirectly, openProjectDirectly,
type OpenProjectResult, type OpenProjectResult,
type WorkspaceGithubCloneProtocol, type ProjectGithubCloneProtocol,
} from "@/hooks/open-project"; } from "@/hooks/open-project";
export function useOpenProject( export function useOpenProject(
@@ -47,33 +46,32 @@ export function useOpenProject(
); );
} }
export function useOpenGithubRepo( export function useCloneGithubProject(
serverId: string | null, serverId: string | null,
): ( ): (
repo: string, repo: string,
targetDirectory: string, targetDirectory: string,
cloneProtocol?: WorkspaceGithubCloneProtocol, cloneProtocol?: ProjectGithubCloneProtocol,
) => Promise<boolean> { ) => Promise<OpenProjectResult> {
const normalizedServerId = serverId?.trim() ?? ""; const normalizedServerId = serverId?.trim() ?? "";
const client = useHostRuntimeClient(normalizedServerId); const client = useHostRuntimeClient(normalizedServerId);
const isConnected = useHostRuntimeIsConnected(normalizedServerId); const isConnected = useHostRuntimeIsConnected(normalizedServerId);
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces); const addEmptyProject = useSessionStore((state) => state.addEmptyProject);
const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces); const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces);
return useCallback( return useCallback(
async (repo: string, targetDirectory: string, cloneProtocol?: WorkspaceGithubCloneProtocol) => { async (repo: string, targetDirectory: string, cloneProtocol?: ProjectGithubCloneProtocol) => {
return openGithubRepoDirectly({ return cloneGithubProjectDirectly({
serverId: normalizedServerId, serverId: normalizedServerId,
repo, repo,
targetDirectory, targetDirectory,
...(cloneProtocol ? { cloneProtocol } : {}), ...(cloneProtocol ? { cloneProtocol } : {}),
isConnected, isConnected,
client, client,
mergeWorkspaces, addEmptyProject,
setHasHydratedWorkspaces, setHasHydratedWorkspaces,
navigateToWorkspace,
}); });
}, },
[client, isConnected, mergeWorkspaces, normalizedServerId, setHasHydratedWorkspaces], [addEmptyProject, client, isConnected, normalizedServerId, setHasHydratedWorkspaces],
); );
} }
+10 -10
View File
@@ -14,15 +14,15 @@ interface CloneCommandOptions extends CommandOptions {
export interface CloneResult { export interface CloneResult {
repo: string; repo: string;
checkoutPath: string; checkoutPath: string;
workspaceId: string; projectId: string;
workspaceName: string; projectName: string;
} }
export const cloneSchema: OutputSchema<CloneResult> = { export const cloneSchema: OutputSchema<CloneResult> = {
idField: "workspaceId", idField: "projectId",
columns: [ columns: [
{ header: "REPO", field: "repo", width: 28 }, { header: "REPO", field: "repo", width: 28 },
{ header: "WORKSPACE", field: "workspaceName", width: 28 }, { header: "PROJECT", field: "projectName", width: 28 },
{ header: "PATH", field: "checkoutPath", width: 56 }, { header: "PATH", field: "checkoutPath", width: 56 },
], ],
}; };
@@ -52,7 +52,7 @@ export async function runCloneCommand(
throw buildDaemonConnectionCommandError({ host: options.host, error: err }); throw buildDaemonConnectionCommandError({ host: options.host, error: err });
} }
if (client.getLastServerInfoMessage()?.features?.workspaceGithubClone !== true) { if (client.getLastServerInfoMessage()?.features?.projectGithubClone !== true) {
await client.close().catch(() => {}); await client.close().catch(() => {});
throw cmdError( throw cmdError(
"UNSUPPORTED_BY_HOST", "UNSUPPORTED_BY_HOST",
@@ -62,15 +62,15 @@ export async function runCloneCommand(
} }
try { try {
const response = await client.cloneGithubWorkspace({ const response = await client.cloneGithubProject({
repo, repo,
targetDirectory, targetDirectory,
...(repoIsCompleteRemote ? {} : { cloneProtocol: options.protocol }), ...(repoIsCompleteRemote ? {} : { cloneProtocol: options.protocol }),
}); });
if (response.error || !response.workspace || !response.checkoutPath) { if (response.error || !response.project || !response.checkoutPath) {
throw cmdError( throw cmdError(
"CLONE_FAILED", "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: { data: {
repo: response.repo, repo: response.repo,
checkoutPath: response.checkoutPath, checkoutPath: response.checkoutPath,
workspaceId: response.workspace.id, projectId: response.project.projectId,
workspaceName: response.workspace.name, projectName: response.project.projectDisplayName,
}, },
schema: cloneSchema, schema: cloneSchema,
}; };
+10 -10
View File
@@ -58,8 +58,8 @@ import type {
ProjectCreateDirectoryResponse, ProjectCreateDirectoryResponse,
OpenProjectResponseMessage, OpenProjectResponseMessage,
WorkspaceGithubSearchRepositoriesResponse, WorkspaceGithubSearchRepositoriesResponse,
WorkspaceGithubCloneProtocol, ProjectGithubCloneProtocol,
WorkspaceGithubCloneResponse, ProjectGithubCloneResponse,
ArchiveWorkspaceResponseMessage, ArchiveWorkspaceResponseMessage,
WorkspaceSetupStatusResponseMessage, WorkspaceSetupStatusResponseMessage,
ListCommandsResponse, ListCommandsResponse,
@@ -152,7 +152,7 @@ const perfNow: () => number =
? () => performance.now() ? () => performance.now()
: () => Date.now(); : () => Date.now();
const WORKSPACE_GITHUB_CLONE_TIMEOUT_MS = 5 * 60 * 1000; const PROJECT_GITHUB_CLONE_TIMEOUT_MS = 5 * 60 * 1000;
interface ImportAgentInputBase { interface ImportAgentInputBase {
cwd?: string; cwd?: string;
@@ -768,7 +768,7 @@ type ProjectAddPayload = ProjectAddResponse["payload"];
export type ProjectCreateDirectoryPayload = ProjectCreateDirectoryResponse["payload"]; export type ProjectCreateDirectoryPayload = ProjectCreateDirectoryResponse["payload"];
export type WorkspaceGithubSearchRepositoriesPayload = export type WorkspaceGithubSearchRepositoriesPayload =
WorkspaceGithubSearchRepositoriesResponse["payload"]; WorkspaceGithubSearchRepositoriesResponse["payload"];
type WorkspaceGithubClonePayload = WorkspaceGithubCloneResponse["payload"]; type ProjectGithubClonePayload = ProjectGithubCloneResponse["payload"];
type ArchiveWorkspacePayload = ArchiveWorkspaceResponseMessage["payload"]; type ArchiveWorkspacePayload = ArchiveWorkspaceResponseMessage["payload"];
type WorkspaceSetupStatusPayload = WorkspaceSetupStatusResponseMessage["payload"]; type WorkspaceSetupStatusPayload = WorkspaceSetupStatusResponseMessage["payload"];
@@ -2029,20 +2029,20 @@ export class DaemonClient {
); );
} }
async cloneGithubWorkspace( async cloneGithubProject(
input: { repo: string; targetDirectory: string; cloneProtocol?: WorkspaceGithubCloneProtocol }, input: { repo: string; targetDirectory: string; cloneProtocol?: ProjectGithubCloneProtocol },
requestId?: string, requestId?: string,
): Promise<WorkspaceGithubClonePayload> { ): Promise<ProjectGithubClonePayload> {
const message = { const message = {
type: "workspace.github.clone.request", type: "project.github.clone.request",
repo: input.repo, repo: input.repo,
targetDirectory: input.targetDirectory, targetDirectory: input.targetDirectory,
...(input.cloneProtocol ? { cloneProtocol: input.cloneProtocol } : {}), ...(input.cloneProtocol ? { cloneProtocol: input.cloneProtocol } : {}),
} as const; } as const;
return this.sendNamespacedCorrelatedSessionRequest<"workspace.github.clone.response">({ return this.sendNamespacedCorrelatedSessionRequest<"project.github.clone.response">({
requestId, requestId,
message, message,
timeout: WORKSPACE_GITHUB_CLONE_TIMEOUT_MS, timeout: PROJECT_GITHUB_CLONE_TIMEOUT_MS,
}); });
} }
@@ -157,7 +157,7 @@ describe("project command-center protocol", () => {
).toBe(" paseo "); ).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({ const parsed = parseServerInfoStatusPayload({
status: "server_info", status: "server_info",
serverId: "server-old", serverId: "server-old",
@@ -165,6 +165,7 @@ describe("project command-center protocol", () => {
}); });
expect(parsed.features?.workspaceGithubRepositorySearch).toBeUndefined(); expect(parsed.features?.workspaceGithubRepositorySearch).toBeUndefined();
expect(parsed.features?.projectGithubClone).toBeUndefined();
expect(parsed.features?.projectCreateDirectory).toBeUndefined(); expect(parsed.features?.projectCreateDirectory).toBeUndefined();
}); });
}); });
+14 -14
View File
@@ -1822,12 +1822,12 @@ export const WorkspaceGithubSearchRepositoriesRequestSchema = z.object({
requestId: z.string(), requestId: z.string(),
}); });
export const WorkspaceGithubCloneProtocolSchema = z.enum(["https", "ssh"]); export const ProjectGithubCloneProtocolSchema = z.enum(["https", "ssh"]);
export const WorkspaceGithubCloneRequestSchema = z.object({ export const ProjectGithubCloneRequestSchema = z.object({
type: z.literal("workspace.github.clone.request"), type: z.literal("project.github.clone.request"),
repo: z.string().trim().min(MIN_REPOSITORY_PATH_LENGTH), repo: z.string().trim().min(MIN_REPOSITORY_PATH_LENGTH),
cloneProtocol: WorkspaceGithubCloneProtocolSchema.optional(), cloneProtocol: ProjectGithubCloneProtocolSchema.optional(),
targetDirectory: z.string().trim().min(1), targetDirectory: z.string().trim().min(1),
requestId: z.string(), requestId: z.string(),
}); });
@@ -2218,7 +2218,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
ProjectAddRequestSchema, ProjectAddRequestSchema,
ProjectCreateDirectoryRequestSchema, ProjectCreateDirectoryRequestSchema,
WorkspaceGithubSearchRepositoriesRequestSchema, WorkspaceGithubSearchRepositoriesRequestSchema,
WorkspaceGithubCloneRequestSchema, ProjectGithubCloneRequestSchema,
ArchiveWorkspaceRequestSchema, ArchiveWorkspaceRequestSchema,
WorkspaceCreateRequestSchema, WorkspaceCreateRequestSchema,
WorkspaceClearAttentionRequestSchema, WorkspaceClearAttentionRequestSchema,
@@ -2463,8 +2463,8 @@ export const ServerInfoStatusPayloadSchema = z
providerSubagents: z.boolean().optional(), providerSubagents: z.boolean().optional(),
// COMPAT(workspacePinning): added in v0.1.107, remove gate after 2027-01-12. // COMPAT(workspacePinning): added in v0.1.107, remove gate after 2027-01-12.
workspacePinning: z.boolean().optional(), workspacePinning: z.boolean().optional(),
// COMPAT(workspaceGithubClone): added in v0.1.108, remove gate after 2027-01-13. // COMPAT(projectGithubClone): added in v0.1.108, remove gate after 2027-01-15.
workspaceGithubClone: z.boolean().optional(), projectGithubClone: z.boolean().optional(),
// COMPAT(workspaceGithubRepositorySearch): added in v0.1.108, remove gate after 2027-01-15. // COMPAT(workspaceGithubRepositorySearch): added in v0.1.108, remove gate after 2027-01-15.
workspaceGithubRepositorySearch: z.boolean().optional(), workspaceGithubRepositorySearch: z.boolean().optional(),
// COMPAT(projectCreateDirectory): added in v0.1.108, remove gate after 2027-01-15. // 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({ export const ProjectGithubCloneResponseSchema = z.object({
type: z.literal("workspace.github.clone.response"), type: z.literal("project.github.clone.response"),
payload: z.object({ payload: z.object({
requestId: z.string(), requestId: z.string(),
repo: z.string().trim().min(MIN_REPOSITORY_PATH_LENGTH), repo: z.string().trim().min(MIN_REPOSITORY_PATH_LENGTH),
checkoutPath: z.string().nullable(), checkoutPath: z.string().nullable(),
workspace: WorkspaceDescriptorPayloadSchema.nullable(), project: WorkspaceProjectDescriptorPayloadSchema.nullable(),
error: z.string().nullable(), error: z.string().nullable(),
}), }),
}); });
@@ -4459,7 +4459,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
ProjectCreateDirectoryResponseSchema, ProjectCreateDirectoryResponseSchema,
OpenProjectResponseMessageSchema, OpenProjectResponseMessageSchema,
WorkspaceGithubSearchRepositoriesResponseSchema, WorkspaceGithubSearchRepositoriesResponseSchema,
WorkspaceGithubCloneResponseSchema, ProjectGithubCloneResponseSchema,
StartWorkspaceScriptResponseMessageSchema, StartWorkspaceScriptResponseMessageSchema,
LegacyListAvailableEditorsResponseMessageSchema, LegacyListAvailableEditorsResponseMessageSchema,
LegacyOpenInEditorResponseMessageSchema, LegacyOpenInEditorResponseMessageSchema,
@@ -4624,7 +4624,7 @@ export type WorkspaceGithubSearchRepositoriesResponse = z.infer<
typeof WorkspaceGithubSearchRepositoriesResponseSchema typeof WorkspaceGithubSearchRepositoriesResponseSchema
>; >;
export type GithubRepository = z.infer<typeof GithubRepositorySchema>; export type GithubRepository = z.infer<typeof GithubRepositorySchema>;
export type WorkspaceGithubCloneResponse = z.infer<typeof WorkspaceGithubCloneResponseSchema>; export type ProjectGithubCloneResponse = z.infer<typeof ProjectGithubCloneResponseSchema>;
export type StartWorkspaceScriptResponseMessage = z.infer< export type StartWorkspaceScriptResponseMessage = z.infer<
typeof StartWorkspaceScriptResponseMessageSchema typeof StartWorkspaceScriptResponseMessageSchema
>; >;
@@ -4878,8 +4878,8 @@ export type ProjectCreateDirectoryErrorCode = z.infer<typeof ProjectCreateDirect
export type WorkspaceGithubSearchRepositoriesRequest = z.infer< export type WorkspaceGithubSearchRepositoriesRequest = z.infer<
typeof WorkspaceGithubSearchRepositoriesRequestSchema typeof WorkspaceGithubSearchRepositoriesRequestSchema
>; >;
export type WorkspaceGithubCloneRequest = z.infer<typeof WorkspaceGithubCloneRequestSchema>; export type ProjectGithubCloneRequest = z.infer<typeof ProjectGithubCloneRequestSchema>;
export type WorkspaceGithubCloneProtocol = z.infer<typeof WorkspaceGithubCloneProtocolSchema>; export type ProjectGithubCloneProtocol = z.infer<typeof ProjectGithubCloneProtocolSchema>;
export type ArchiveWorkspaceRequest = z.infer<typeof ArchiveWorkspaceRequestSchema>; export type ArchiveWorkspaceRequest = z.infer<typeof ArchiveWorkspaceRequestSchema>;
export type WorkspaceClearAttentionRequest = z.infer<typeof WorkspaceClearAttentionRequestSchema>; export type WorkspaceClearAttentionRequest = z.infer<typeof WorkspaceClearAttentionRequestSchema>;
export type FileExplorerRequest = z.infer<typeof FileExplorerRequestSchema>; export type FileExplorerRequest = z.infer<typeof FileExplorerRequestSchema>;
@@ -286,36 +286,45 @@ describe("workspace message schemas", () => {
expect(parsed.type).toBe("open_project_request"); 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({ const request = SessionInboundMessageSchema.parse({
type: "workspace.github.clone.request", type: "project.github.clone.request",
repo: "a/b", repo: "a/b",
cloneProtocol: "https", cloneProtocol: "https",
targetDirectory: "~/workspace", targetDirectory: "~/workspace",
requestId: "req-clone", requestId: "req-clone",
}); });
const response = SessionOutboundMessageSchema.parse({ const response = SessionOutboundMessageSchema.parse({
type: "workspace.github.clone.response", type: "project.github.clone.response",
payload: { payload: {
requestId: "req-clone", requestId: "req-clone",
repo: "a/b", repo: "a/b",
checkoutPath: "/tmp/b", checkoutPath: "/tmp/b",
workspace: null, project: {
projectId: "project-b",
projectDisplayName: "b",
projectRootPath: "/tmp/b",
projectKind: "git",
},
error: null, error: null,
}, },
}); });
expect(request.type).toBe("workspace.github.clone.request"); expect(request.type).toBe("project.github.clone.request");
if (request.type !== "workspace.github.clone.request") { if (request.type !== "project.github.clone.request") {
throw new Error("expected workspace.github.clone.request"); throw new Error("expected project.github.clone.request");
} }
expect(request.cloneProtocol).toBe("https"); 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({ const request = SessionInboundMessageSchema.safeParse({
type: "workspace.github.clone.request", type: "project.github.clone.request",
repo: "a/b", repo: "a/b",
cloneProtocol: "ftp", cloneProtocol: "ftp",
targetDirectory: "~/workspace", targetDirectory: "~/workspace",
@@ -325,20 +334,20 @@ describe("workspace message schemas", () => {
expect(request.success).toBe(false); 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({ const request = SessionInboundMessageSchema.safeParse({
type: "workspace.github.clone.request", type: "project.github.clone.request",
repo: "ab", repo: "ab",
targetDirectory: "~/workspace", targetDirectory: "~/workspace",
requestId: "req-clone", requestId: "req-clone",
}); });
const response = SessionOutboundMessageSchema.safeParse({ const response = SessionOutboundMessageSchema.safeParse({
type: "workspace.github.clone.response", type: "project.github.clone.response",
payload: { payload: {
requestId: "req-clone", requestId: "req-clone",
repo: "ab", repo: "ab",
checkoutPath: null, checkoutPath: null,
workspace: null, project: null,
error: "failed", error: "failed",
}, },
}); });
+11 -26
View File
@@ -1650,8 +1650,8 @@ export class Session {
return this.handleProjectCreateDirectoryRequest(msg); return this.handleProjectCreateDirectoryRequest(msg);
case "workspace.github.search_repositories.request": case "workspace.github.search_repositories.request":
return this.handleWorkspaceGithubSearchRepositoriesRequest(msg); return this.handleWorkspaceGithubSearchRepositoriesRequest(msg);
case "workspace.github.clone.request": case "project.github.clone.request":
return this.handleWorkspaceGithubCloneRequest(msg); return this.handleProjectGithubCloneRequest(msg);
case "archive_workspace_request": case "archive_workspace_request":
return this.handleArchiveWorkspaceRequest(msg); return this.handleArchiveWorkspaceRequest(msg);
case "project.remove.request": case "project.remove.request":
@@ -4910,8 +4910,8 @@ export class Session {
} }
} }
private async handleWorkspaceGithubCloneRequest( private async handleProjectGithubCloneRequest(
request: Extract<SessionInboundMessage, { type: "workspace.github.clone.request" }>, request: Extract<SessionInboundMessage, { type: "project.github.clone.request" }>,
): Promise<void> { ): Promise<void> {
let normalizedRepo = request.repo; let normalizedRepo = request.repo;
let checkoutPath: string | null = null; let checkoutPath: string | null = null;
@@ -4956,31 +4956,16 @@ export class Session {
throw error; throw error;
} }
const workspace = const project =
await this.workspaceProvisioning.findOrCreateWorkspaceForDirectory(checkoutPath); await this.workspaceProvisioning.findOrCreateProjectForDirectory(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",
);
});
this.emit({ this.emit({
type: "workspace.github.clone.response", type: "project.github.clone.response",
payload: { payload: {
requestId: request.requestId, requestId: request.requestId,
repo: repo.displayName, repo: repo.displayName,
checkoutPath, checkoutPath,
workspace: descriptor, project: this.buildProjectDescriptor(project),
error: null, error: null,
}, },
}); });
@@ -4988,15 +4973,15 @@ export class Session {
const message = error instanceof Error ? error.message : "Failed to clone GitHub repo"; const message = error instanceof Error ? error.message : "Failed to clone GitHub repo";
this.sessionLogger.error( this.sessionLogger.error(
{ err: error, repo: request.repo, targetDirectory: request.targetDirectory }, { err: error, repo: request.repo, targetDirectory: request.targetDirectory },
"Failed to clone GitHub workspace", "Failed to clone GitHub project",
); );
this.emit({ this.emit({
type: "workspace.github.clone.response", type: "project.github.clone.response",
payload: { payload: {
requestId: request.requestId, requestId: request.requestId,
repo: normalizedRepo, repo: normalizedRepo,
checkoutPath, checkoutPath,
workspace: null, project: null,
error: message, error: message,
}, },
}); });
@@ -1266,8 +1266,8 @@ export class VoiceAssistantWebSocketServer {
providerSubagents: true, providerSubagents: true,
// COMPAT(workspacePinning): added in v0.1.107, remove gate after 2027-01-12. // COMPAT(workspacePinning): added in v0.1.107, remove gate after 2027-01-12.
workspacePinning: true, workspacePinning: true,
// COMPAT(workspaceGithubClone): added in v0.1.108, remove gate after 2027-01-13. // COMPAT(projectGithubClone): added in v0.1.108, remove gate after 2027-01-15.
workspaceGithubClone: true, projectGithubClone: true,
// COMPAT(workspaceGithubRepositorySearch): added in v0.1.108, remove gate after 2027-01-15. // COMPAT(workspaceGithubRepositorySearch): added in v0.1.108, remove gate after 2027-01-15.
workspaceGithubRepositorySearch: true, workspaceGithubRepositorySearch: true,
// COMPAT(projectCreateDirectory): added in v0.1.108, remove gate after 2027-01-15. // COMPAT(projectCreateDirectory): added in v0.1.108, remove gate after 2027-01-15.