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
+30 -50
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" };
}
+12 -15
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,
+1 -12
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);
}
+30 -82
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([]);
});
});
+10 -12
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],
);
}