Add a keyboard-driven project setup flow (#2097)

* feat(projects): add keyboard-driven project setup

* test(app): update add project loading assertion

* fix(projects): harden GitHub project setup
This commit is contained in:
Mohamed Boudra
2026-07-15 21:16:10 +02:00
committed by GitHub
parent 90e0a0e353
commit dfe3330ef8
52 changed files with 3851 additions and 1023 deletions

View File

@@ -0,0 +1,170 @@
import { describe, expect, it } from "vitest";
import {
parseServerInfoStatusPayload,
SessionInboundMessageSchema,
SessionOutboundMessageSchema,
} from "./messages.js";
describe("project command-center protocol", () => {
it("parses the dotted GitHub repository search request and normalized success response", () => {
expect(
SessionInboundMessageSchema.parse({
type: "workspace.github.search_repositories.request",
query: "paseo",
limit: 12,
requestId: "req-search",
}),
).toEqual({
type: "workspace.github.search_repositories.request",
query: "paseo",
limit: 12,
requestId: "req-search",
});
expect(
SessionOutboundMessageSchema.parse({
type: "workspace.github.search_repositories.response",
payload: {
status: "success",
requestId: "req-search",
repositories: [
{
id: "R_paseo",
name: "paseo",
nameWithOwner: "getpaseo/paseo",
description: "Development environment in your pocket",
visibility: "public",
updatedAt: "2026-07-15T10:00:00Z",
cloneUrl: "git@github.com:getpaseo/paseo.git",
},
],
available: true,
error: null,
},
}).payload,
).toEqual({
status: "success",
requestId: "req-search",
repositories: [
{
id: "R_paseo",
name: "paseo",
nameWithOwner: "getpaseo/paseo",
description: "Development environment in your pocket",
visibility: "public",
updatedAt: "2026-07-15T10:00:00Z",
cloneUrl: "git@github.com:getpaseo/paseo.git",
},
],
available: true,
error: null,
});
});
it.each([
{ status: "unavailable", reason: "gh_missing", message: "gh is missing" },
{ status: "unauthenticated", message: "sign in" },
{ status: "error", message: "command failed" },
])("parses the GitHub $status runtime state", (payload) => {
expect(
SessionOutboundMessageSchema.safeParse({
type: "workspace.github.search_repositories.response",
payload: {
requestId: "req-search",
repositories: [],
available: payload.status === "error",
error: payload.message,
...payload,
},
}).success,
).toBe(true);
});
it("parses atomic project directory creation request and response", () => {
expect(
SessionInboundMessageSchema.safeParse({
type: "project.create_directory.request",
parentPath: "/Users/example/dev",
name: "new-project",
requestId: "req-create",
}).success,
).toBe(true);
expect(
SessionOutboundMessageSchema.parse({
type: "project.create_directory.response",
payload: {
requestId: "req-create",
directoryPath: "/Users/example/dev/new-project",
project: {
projectId: "directory:/Users/example/dev/new-project",
projectDisplayName: "new-project",
projectCustomName: null,
projectRootPath: "/Users/example/dev/new-project",
projectKind: "non_git",
},
error: null,
errorCode: null,
},
}).payload.project?.projectDisplayName,
).toBe("new-project");
expect(
SessionInboundMessageSchema.safeParse({
type: "project.create_directory.request",
parentPath: "",
name: "",
requestId: "req-invalid-create",
}).success,
).toBe(true);
});
it("accepts future project directory error codes without transforming wire values", () => {
expect(
SessionOutboundMessageSchema.parse({
type: "project.create_directory.response",
payload: {
requestId: "req-create",
directoryPath: null,
project: null,
error: "A newer failure",
errorCode: "future_failure_reason",
},
}).payload.errorCode,
).toBe("future_failure_reason");
expect(
SessionOutboundMessageSchema.parse({
type: "workspace.github.search_repositories.response",
payload: {
status: "success",
requestId: "req-search",
repositories: [
{
id: "repo",
name: " paseo ",
nameWithOwner: " getpaseo/paseo ",
description: null,
visibility: "public",
updatedAt: "2026-07-15T10:00:00Z",
cloneUrl: " https://github.com/getpaseo/paseo ",
},
],
available: true,
error: null,
},
}).payload.repositories[0]?.name,
).toBe(" paseo ");
});
it("keeps both feature flags optional for older server_info payloads", () => {
const parsed = parseServerInfoStatusPayload({
status: "server_info",
serverId: "server-old",
features: {},
});
expect(parsed.features?.workspaceGithubRepositorySearch).toBeUndefined();
expect(parsed.features?.projectCreateDirectory).toBeUndefined();
});
});

View File

@@ -1789,14 +1789,39 @@ export const OpenProjectRequestSchema = z.object({
requestId: z.string(),
});
// Smallest shorthand repo path is "a/b": owner, slash, repository.
const MIN_REPOSITORY_PATH_LENGTH = 3;
export const ProjectAddRequestSchema = z.object({
type: z.literal("project.add.request"),
cwd: z.string(),
requestId: z.string(),
});
// Smallest shorthand repo path is "a/b": owner, slash, repository.
const MIN_REPOSITORY_PATH_LENGTH = 3;
export const ProjectCreateDirectoryRequestSchema = z.object({
type: z.literal("project.create_directory.request"),
parentPath: z.string(),
name: z.string(),
requestId: z.string(),
});
export const GithubRepositorySchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
nameWithOwner: z.string().min(MIN_REPOSITORY_PATH_LENGTH),
description: z.string().nullable(),
visibility: z.enum(["public", "private", "internal"]),
updatedAt: z.string(),
cloneUrl: z.string().min(MIN_REPOSITORY_PATH_LENGTH),
});
export const WorkspaceGithubSearchRepositoriesRequestSchema = z.object({
type: z.literal("workspace.github.search_repositories.request"),
query: z.string(),
limit: z.number().int().min(1).max(50).optional(),
requestId: z.string(),
});
export const WorkspaceGithubCloneProtocolSchema = z.enum(["https", "ssh"]);
export const WorkspaceGithubCloneRequestSchema = z.object({
@@ -2191,6 +2216,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
LegacyOpenInEditorRequestSchema,
OpenProjectRequestSchema,
ProjectAddRequestSchema,
ProjectCreateDirectoryRequestSchema,
WorkspaceGithubSearchRepositoriesRequestSchema,
WorkspaceGithubCloneRequestSchema,
ArchiveWorkspaceRequestSchema,
WorkspaceCreateRequestSchema,
@@ -2438,6 +2465,10 @@ export const ServerInfoStatusPayloadSchema = z
workspacePinning: z.boolean().optional(),
// COMPAT(workspaceGithubClone): added in v0.1.108, remove gate after 2027-01-13.
workspaceGithubClone: 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.
projectCreateDirectory: z.boolean().optional(),
})
.optional(),
})
@@ -2936,6 +2967,63 @@ export const ProjectAddResponseSchema = z.object({
}),
});
export const ProjectCreateDirectoryErrorCodeSchema = z.enum([
"invalid_name",
"parent_directory_not_found",
"directory_exists",
"permission_denied",
"registration_failed",
"filesystem_error",
]);
export const ProjectCreateDirectoryResponseSchema = z.object({
type: z.literal("project.create_directory.response"),
payload: z.object({
requestId: z.string(),
directoryPath: z.string().nullable(),
project: WorkspaceProjectDescriptorPayloadSchema.nullable(),
error: z.string().nullable(),
// Error codes are open-ended on the wire so older clients can still parse
// responses after a newer daemon learns another failure reason.
errorCode: z.string().nullable(),
}),
});
export const WorkspaceGithubSearchRepositoriesResponseSchema = z.object({
type: z.literal("workspace.github.search_repositories.response"),
payload: z.discriminatedUnion("status", [
z.object({
status: z.literal("success"),
requestId: z.string(),
repositories: z.array(GithubRepositorySchema),
available: z.literal(true),
error: z.null(),
}),
z.object({
status: z.literal("unavailable"),
requestId: z.string(),
repositories: z.array(GithubRepositorySchema),
reason: z.literal("gh_missing"),
available: z.literal(false),
error: z.string(),
}),
z.object({
status: z.literal("unauthenticated"),
requestId: z.string(),
repositories: z.array(GithubRepositorySchema),
available: z.literal(false),
error: z.string(),
}),
z.object({
status: z.literal("error"),
requestId: z.string(),
repositories: z.array(GithubRepositorySchema),
available: z.literal(true),
error: z.string(),
}),
]),
});
export const WorkspaceGithubCloneResponseSchema = z.object({
type: z.literal("workspace.github.clone.response"),
payload: z.object({
@@ -4368,7 +4456,9 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
FetchRecentProviderSessionsResponseMessageSchema,
FetchWorkspacesResponseMessageSchema,
ProjectAddResponseSchema,
ProjectCreateDirectoryResponseSchema,
OpenProjectResponseMessageSchema,
WorkspaceGithubSearchRepositoriesResponseSchema,
WorkspaceGithubCloneResponseSchema,
StartWorkspaceScriptResponseMessageSchema,
LegacyListAvailableEditorsResponseMessageSchema,
@@ -4527,8 +4617,13 @@ export type FetchRecentProviderSessionsResponseMessage = z.infer<
>;
export type FetchWorkspacesResponseMessage = z.infer<typeof FetchWorkspacesResponseMessageSchema>;
export type ProjectAddResponse = z.infer<typeof ProjectAddResponseSchema>;
export type ProjectCreateDirectoryResponse = z.infer<typeof ProjectCreateDirectoryResponseSchema>;
export type ScriptStatusUpdateMessage = z.infer<typeof ScriptStatusUpdateMessageSchema>;
export type OpenProjectResponseMessage = z.infer<typeof OpenProjectResponseMessageSchema>;
export type WorkspaceGithubSearchRepositoriesResponse = z.infer<
typeof WorkspaceGithubSearchRepositoriesResponseSchema
>;
export type GithubRepository = z.infer<typeof GithubRepositorySchema>;
export type WorkspaceGithubCloneResponse = z.infer<typeof WorkspaceGithubCloneResponseSchema>;
export type StartWorkspaceScriptResponseMessage = z.infer<
typeof StartWorkspaceScriptResponseMessageSchema
@@ -4778,6 +4873,11 @@ export type LegacyListAvailableEditorsRequest = z.infer<
export type LegacyOpenInEditorRequest = z.infer<typeof LegacyOpenInEditorRequestSchema>;
export type OpenProjectRequest = z.infer<typeof OpenProjectRequestSchema>;
export type ProjectAddRequest = z.infer<typeof ProjectAddRequestSchema>;
export type ProjectCreateDirectoryRequest = z.infer<typeof ProjectCreateDirectoryRequestSchema>;
export type ProjectCreateDirectoryErrorCode = z.infer<typeof ProjectCreateDirectoryErrorCodeSchema>;
export type WorkspaceGithubSearchRepositoriesRequest = z.infer<
typeof WorkspaceGithubSearchRepositoriesRequestSchema
>;
export type WorkspaceGithubCloneRequest = z.infer<typeof WorkspaceGithubCloneRequestSchema>;
export type WorkspaceGithubCloneProtocol = z.infer<typeof WorkspaceGithubCloneProtocolSchema>;
export type ArchiveWorkspaceRequest = z.infer<typeof ArchiveWorkspaceRequestSchema>;