Add projects without creating workspaces

Keep the legacy open-project request creating a workspace for old clients; new clients now add projects and create workspaces through separate flows.
This commit is contained in:
Mohamed Boudra
2026-06-20 11:37:05 +07:00
parent d1481833e6
commit 4534754617
36 changed files with 1653 additions and 1723 deletions

View File

@@ -354,4 +354,18 @@ describe("checkout PR schemas", () => {
projectRemove: true,
});
});
test("accepts the project add server_info feature flag", () => {
expect(
ServerInfoStatusPayloadSchema.parse({
status: "server_info",
serverId: "srv_test",
features: {
projectAdd: true,
},
}).features,
).toEqual({
projectAdd: true,
});
});
});

View File

@@ -1714,6 +1714,12 @@ export const OpenProjectRequestSchema = z.object({
requestId: z.string(),
});
export const ProjectAddRequestSchema = z.object({
type: z.literal("project.add.request"),
cwd: z.string(),
requestId: z.string(),
});
export const ArchiveWorkspaceRequestSchema = z.object({
type: z.literal("archive_workspace_request"),
workspaceId: z.string(),
@@ -2080,6 +2086,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
LegacyListAvailableEditorsRequestSchema,
LegacyOpenInEditorRequestSchema,
OpenProjectRequestSchema,
ProjectAddRequestSchema,
ArchiveWorkspaceRequestSchema,
WorkspaceCreateRequestSchema,
WorkspaceClearAttentionRequestSchema,
@@ -2304,6 +2311,8 @@ export const ServerInfoStatusPayloadSchema = z
workspaceMultiplicity: z.boolean().optional(),
// COMPAT(projectRemove): added in v0.1.97, drop the gate when floor >= v0.1.97.
projectRemove: z.boolean().optional(),
// COMPAT(projectAdd): added in v0.1.97, drop the gate when floor >= v0.1.97.
projectAdd: z.boolean().optional(),
// COMPAT(worktreeRestore): added in v0.1.97, drop the gate when floor >= v0.1.97
worktreeRestore: z.boolean().optional(),
// COMPAT(providerUsageList): added in v0.1.98, drop the gate when daemon floor >= v0.1.98.
@@ -2699,8 +2708,9 @@ export const FetchRecentProviderSessionsResponseMessageSchema = z.object({
});
// COMPAT(workspaceProjects): added in v0.1.97, drop the optional gate when floor >= v0.1.97.
// A project parent that has zero active workspaces. The sidebar renders it as an
// empty project row so projects persist after their last workspace is archived.
// A project parent that has zero active workspaces. The sidebar renders the
// project row with a new-workspace child so projects persist after their last
// workspace is archived.
export const WorkspaceProjectDescriptorPayloadSchema = z.object({
projectId: z.string(),
projectDisplayName: z.string(),
@@ -2739,10 +2749,10 @@ export const WorkspaceUpdateMessageSchema = z.object({
id: z.string(),
// COMPAT(workspaceProjects): added in v0.1.97, drop the optional gate when floor >= v0.1.97.
// When archiving this workspace leaves its project with no active
// workspaces, the daemon includes the now-empty project parent so the
// sidebar keeps rendering it without waiting for a full re-hydration. Old
// daemons omit it; old clients ignore it and surface the empty project on
// their next workspace fetch instead.
// workspaces, the daemon includes the project parent so the sidebar keeps
// rendering it without waiting for a full re-hydration. Old daemons omit
// it; old clients ignore it and surface the project on their next
// workspace fetch instead.
emptyProject: WorkspaceProjectDescriptorPayloadSchema.optional(),
// Project removal is represented on the existing workspace update channel
// so old clients can still parse the message and ignore the extra field.
@@ -2795,6 +2805,16 @@ export const OpenProjectResponseMessageSchema = z.object({
}),
});
export const ProjectAddResponseSchema = z.object({
type: z.literal("project.add.response"),
payload: z.object({
requestId: z.string(),
project: WorkspaceProjectDescriptorPayloadSchema.nullable(),
error: z.string().nullable(),
errorCode: z.enum(["directory_not_found"]).nullish().catch(null),
}),
});
export const StartWorkspaceScriptResponseMessageSchema = z.object({
type: z.literal("start_workspace_script_response"),
payload: z.object({
@@ -4081,6 +4101,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
FetchAgentHistoryResponseMessageSchema,
FetchRecentProviderSessionsResponseMessageSchema,
FetchWorkspacesResponseMessageSchema,
ProjectAddResponseSchema,
OpenProjectResponseMessageSchema,
StartWorkspaceScriptResponseMessageSchema,
LegacyListAvailableEditorsResponseMessageSchema,
@@ -4230,6 +4251,7 @@ export type FetchRecentProviderSessionsResponseMessage = z.infer<
typeof FetchRecentProviderSessionsResponseMessageSchema
>;
export type FetchWorkspacesResponseMessage = z.infer<typeof FetchWorkspacesResponseMessageSchema>;
export type ProjectAddResponse = z.infer<typeof ProjectAddResponseSchema>;
export type ScriptStatusUpdateMessage = z.infer<typeof ScriptStatusUpdateMessageSchema>;
export type OpenProjectResponseMessage = z.infer<typeof OpenProjectResponseMessageSchema>;
export type StartWorkspaceScriptResponseMessage = z.infer<
@@ -4473,6 +4495,7 @@ 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 ArchiveWorkspaceRequest = z.infer<typeof ArchiveWorkspaceRequestSchema>;
export type WorkspaceClearAttentionRequest = z.infer<typeof WorkspaceClearAttentionRequestSchema>;
export type FileExplorerRequest = z.infer<typeof FileExplorerRequestSchema>;

View File

@@ -27,6 +27,50 @@ describe("workspace message schemas", () => {
expect(parsed.type).toBe("fetch_workspaces_request");
});
test("parses project.add request and response", () => {
expect(
SessionInboundMessageSchema.parse({
type: "project.add.request",
requestId: "req-add-project",
cwd: "/repo",
}),
).toEqual({
type: "project.add.request",
requestId: "req-add-project",
cwd: "/repo",
});
expect(
SessionOutboundMessageSchema.parse({
type: "project.add.response",
payload: {
requestId: "req-add-project",
project: {
projectId: "/repo",
projectDisplayName: "repo",
projectCustomName: null,
projectRootPath: "/repo",
projectKind: "git",
},
error: null,
},
}),
).toEqual({
type: "project.add.response",
payload: {
requestId: "req-add-project",
project: {
projectId: "/repo",
projectDisplayName: "repo",
projectCustomName: null,
projectRootPath: "/repo",
projectKind: "git",
},
error: null,
},
});
});
test("parses active-scoped fetch_agents_request as an optional extension", () => {
const legacy = SessionInboundMessageSchema.parse({
type: "fetch_agents_request",