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

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 -- logs <id> # 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 <host:port>` to point the CLI at a different daemon:

View File

@@ -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<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.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);

View File

@@ -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) {

View File

@@ -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<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 },
);
}

View File

@@ -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 ({

View File

@@ -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,
},
]);
});
});

View File

@@ -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<string>();
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;
}

View File

@@ -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
<UpdateCalloutSource />
<WorktreeSetupCalloutSource />
<CommandCenter />
<AddProjectFlowHost />
<HostChooserModal />
<ProviderSettingsHost />
<WorkspaceSetupDialog />

View File

@@ -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} />;
}

View File

@@ -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<TextInput>(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<FlowRowOption[]>(() => {
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") {

View File

@@ -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<Map<number, View>>(new Map());
@@ -493,16 +489,6 @@ export function CommandCenter() {
const snapPoints = useMemo(() => ["60%", "90%"], []);
if (open && addProjectRequest) {
return (
<AddProjectFlow
key={addProjectRequest.id}
request={addProjectRequest}
onClose={handleClose}
/>
);
}
const resultList =
items.length === 0 ? (
<Text style={emptyTextStyle}>{t("shell.commandCenter.noMatches")}</Text>

View File

@@ -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<OpenProjectPayload["errorCode"]>;
type WorkspaceOpenPayload =
| Awaited<ReturnType<DaemonClient["openProject"]>>
| Awaited<ReturnType<DaemonClient["cloneGithubWorkspace"]>>;
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<WorkspaceDescriptor>) => 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<DaemonClient, "cloneGithubWorkspace"> | null;
cloneProtocol?: ProjectGithubCloneProtocol;
client: Pick<DaemonClient, "cloneGithubProject"> | 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<boolean> {
export async function cloneGithubProjectDirectly(
input: CloneGithubProjectDirectlyInput,
): Promise<OpenProjectResult> {
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" };
}

View File

@@ -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<TextInput>(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,

View File

@@ -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);
}

View File

@@ -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<WorkspaceDescriptor>) => {
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<typeof buildWorkspacePayload>) {
function createFakeGithubCloneClient(project: ReturnType<typeof buildProjectPayload> | 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([]);
});
});

View File

@@ -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<boolean> {
cloneProtocol?: ProjectGithubCloneProtocol,
) => Promise<OpenProjectResult> {
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],
);
}

View File

@@ -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<CloneResult> = {
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,
};

View File

@@ -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<WorkspaceGithubClonePayload> {
): Promise<ProjectGithubClonePayload> {
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,
});
}

View File

@@ -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();
});
});

View File

@@ -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<typeof GithubRepositorySchema>;
export type WorkspaceGithubCloneResponse = z.infer<typeof WorkspaceGithubCloneResponseSchema>;
export type ProjectGithubCloneResponse = z.infer<typeof ProjectGithubCloneResponseSchema>;
export type StartWorkspaceScriptResponseMessage = z.infer<
typeof StartWorkspaceScriptResponseMessageSchema
>;
@@ -4878,8 +4878,8 @@ export type ProjectCreateDirectoryErrorCode = z.infer<typeof ProjectCreateDirect
export type WorkspaceGithubSearchRepositoriesRequest = z.infer<
typeof WorkspaceGithubSearchRepositoriesRequestSchema
>;
export type WorkspaceGithubCloneRequest = z.infer<typeof WorkspaceGithubCloneRequestSchema>;
export type WorkspaceGithubCloneProtocol = z.infer<typeof WorkspaceGithubCloneProtocolSchema>;
export type ProjectGithubCloneRequest = z.infer<typeof ProjectGithubCloneRequestSchema>;
export type ProjectGithubCloneProtocol = z.infer<typeof ProjectGithubCloneProtocolSchema>;
export type ArchiveWorkspaceRequest = z.infer<typeof ArchiveWorkspaceRequestSchema>;
export type WorkspaceClearAttentionRequest = z.infer<typeof WorkspaceClearAttentionRequestSchema>;
export type FileExplorerRequest = z.infer<typeof FileExplorerRequestSchema>;

View File

@@ -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",
},
});

View File

@@ -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<SessionInboundMessage, { type: "workspace.github.clone.request" }>,
private async handleProjectGithubCloneRequest(
request: Extract<SessionInboundMessage, { type: "project.github.clone.request" }>,
): Promise<void> {
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,
},
});

View File

@@ -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.