diff --git a/docs/data-model.md b/docs/data-model.md index 71e68c7bd..a9d89ae4f 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -410,6 +410,11 @@ Array of workspace records. A workspace is a specific working directory within a > **Opaque-ID invariant:** `workspaceId` is opaque identity, never a filesystem path. Filesystem and git operations take `cwd`/`workspaceDirectory` only — never the id. Path-derived grouping keys (e.g. `deriveWorkspaceDirectoryKey`, used at bootstrap to group agents into a workspace) are directory keys, not workspace identity, and must not be persisted or compared as ids. +`projectId` is still a real FK: workspace records should have a matching project record. Read-only +history surfaces tolerate transient orphaned workspaces by omitting those rows so one bad FK cannot +blank the whole History screen, but mutation paths should repair or remove the orphaned state rather +than treating it as valid. + --- ## 8. Push Token Store diff --git a/packages/app/e2e/empty-project-persists.spec.ts b/packages/app/e2e/empty-project-persists.spec.ts index 48972483d..2c8443c22 100644 --- a/packages/app/e2e/empty-project-persists.spec.ts +++ b/packages/app/e2e/empty-project-persists.spec.ts @@ -27,6 +27,24 @@ async function hideWorkspaceFromSidebar(page: Page, workspaceId: string): Promis await archiveItem.click(); } +async function removeProjectFromSidebar(page: Page, projectId: string): Promise { + const projectRow = page.getByTestId(`sidebar-project-row-${projectId}`); + await expect(projectRow).toBeVisible({ timeout: 30_000 }); + await projectRow.hover(); + + const kebab = page.getByTestId(`sidebar-project-kebab-${projectId}`); + await expect(kebab).toBeVisible({ timeout: 10_000 }); + await kebab.click(); + + // Removing a project raises a browser confirm; accept it so the + // user-confirmed removal proceeds deterministically. + page.once("dialog", (dialog) => void dialog.accept()); + + const removeItem = page.getByTestId(`sidebar-project-menu-remove-${projectId}`); + await expect(removeItem).toBeVisible({ timeout: 10_000 }); + await removeItem.click(); +} + // Model B makes the project a first-class parent: archiving its last workspace // must not delete the project. The per-project "+ New workspace" row is gone; // the empty project keeps its parent row, and creation stays reachable from the @@ -76,3 +94,43 @@ test.describe("Empty project persists", () => { } }); }); + +test.describe("Project remove", () => { + test("removing a project from project actions removes it from the sidebar", async ({ page }) => { + const workspace = await seedWorkspace({ repoPrefix: "project-remove-sidebar-" }); + + try { + const projectRow = page.getByTestId(`sidebar-project-row-${workspace.projectId}`); + + await gotoAppShell(page); + await waitForSidebarHydration(page); + await expect(projectRow).toBeVisible({ timeout: 30_000 }); + await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toBeVisible({ + timeout: 30_000, + }); + + await removeProjectFromSidebar(page, workspace.projectId); + + await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toHaveCount(0, { + timeout: 30_000, + }); + await expect(projectRow).toHaveCount(0, { timeout: 30_000 }); + + await page.reload(); + await waitForSidebarHydration(page); + await expect(projectRow).toHaveCount(0, { timeout: 30_000 }); + + const reopened = await workspace.client.openProject(workspace.repoPath); + expect(reopened.error).toBeNull(); + expect(reopened.workspace?.projectDisplayName).toBe(workspace.projectDisplayName); + + await page.reload(); + await waitForSidebarHydration(page); + await expect(projectRow).toBeVisible({ timeout: 30_000 }); + await expect(projectRow).toContainText(workspace.projectDisplayName); + await expect(projectRow).not.toContainText(workspace.repoPath); + } finally { + await workspace.cleanup(); + } + }); +}); diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 8467117c4..131b81e57 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -69,6 +69,7 @@ import { type SidebarWorkspaceEntry, } from "@/hooks/use-sidebar-workspaces-list"; import { useSidebarOrderStore } from "@/stores/sidebar-order-store"; +import { useSessionStore } from "@/stores/session-store"; import { useShowShortcutBadges } from "@/hooks/use-show-shortcut-badges"; import { ContextMenuTrigger, useContextMenu } from "@/components/ui/context-menu"; import { @@ -113,8 +114,6 @@ import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model"; import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect"; import { openExternalUrl } from "@/utils/open-external-url"; import { requireWorkspaceDirectory, resolveWorkspaceDirectory } from "@/utils/workspace-directory"; -import { archiveWorkspacesOptimistically } from "@/workspace/workspace-archive"; -import { selectProjectWorkspacesToArchive } from "@/workspace/project-workspace-archive"; import { useWorkspaceArchive } from "@/workspace/use-workspace-archive"; import { isWeb as platformIsWeb, @@ -1785,6 +1784,7 @@ function ProjectBlock({ displayName, iconDataUri, serverId, + canRemoveProject, selectionEnabled, showShortcutBadges, shortcutIndexByWorkspaceKey, @@ -1805,6 +1805,7 @@ function ProjectBlock({ displayName: string; iconDataUri: string | null; serverId: string | null; + canRemoveProject: boolean; selectionEnabled: boolean; showShortcutBadges: boolean; shortcutIndexByWorkspaceKey: Map; @@ -1923,26 +1924,24 @@ function ProjectBlock({ toast.error(t("sidebar.project.toasts.hostDisconnected")); return; } + if (!canRemoveProject) { + toast.error(t("sidebar.project.toasts.updateHostToRemove")); + return; + } setIsRemovingProject(true); - void selectProjectWorkspacesToArchive(project.workspaces) - .then((workspaces) => archiveWorkspacesOptimistically({ client, workspaces })) - .then((failures) => { - if (failures.length > 0) { - toast.error(t("sidebar.project.toasts.removeFailed")); - } - setIsRemovingProject(false); - return; - }) + void client + .removeProject(project.projectKey) .catch((error) => { toast.error( error instanceof Error ? error.message : t("sidebar.project.toasts.removeFailed"), ); + }) + .finally(() => { setIsRemovingProject(false); - return; }); })(); - }, [isRemovingProject, serverId, displayName, t, toast, project.workspaces]); + }, [isRemovingProject, serverId, displayName, t, toast, project.projectKey, canRemoveProject]); const handleToggleCollapsed = useCallback(() => { onToggleCollapsed(project.projectKey); @@ -2000,6 +1999,7 @@ function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlo previous.displayName === next.displayName && previous.iconDataUri === next.iconDataUri && previous.serverId === next.serverId && + previous.canRemoveProject === next.canRemoveProject && previous.selectionEnabled === next.selectionEnabled && previous.showShortcutBadges === next.showShortcutBadges && previous.shortcutIndexByWorkspaceKey === next.shortcutIndexByWorkspaceKey && @@ -2145,6 +2145,9 @@ function ProjectModeList({ const setProjectOrder = useSidebarOrderStore((state) => state.setProjectOrder); const getWorkspaceOrder = useSidebarOrderStore((state) => state.getWorkspaceOrder); const setWorkspaceOrder = useSidebarOrderStore((state) => state.setWorkspaceOrder); + const canRemoveProject = useSessionStore((state) => + serverId ? state.sessions[serverId]?.serverInfo?.features?.projectRemove === true : false, + ); const isWorkspaceRoute = useMemo( () => Boolean(pathname && parseHostWorkspaceRouteFromPathname(pathname)), @@ -2309,6 +2312,7 @@ function ProjectModeList({ displayName={item.projectName} iconDataUri={projectIconByProjectKey.get(item.projectKey) ?? null} serverId={serverId} + canRemoveProject={canRemoveProject} selectionEnabled={selectionEnabled} showShortcutBadges={showShortcutBadges} shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey} @@ -2335,6 +2339,7 @@ function ProjectModeList({ onToggleProjectCollapsed, parentGestureRef, projectIconByProjectKey, + canRemoveProject, selectionEnabled, serverId, shortcutIndexByWorkspaceKey, diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 5d9f0199f..750a1e6bf 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -531,6 +531,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider const setWorkspaces = useSessionStore((state) => state.setWorkspaces); const setEmptyProjects = useSessionStore((state) => state.setEmptyProjects); const addEmptyProject = useSessionStore((state) => state.addEmptyProject); + const removeEmptyProject = useSessionStore((state) => state.removeEmptyProject); const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces); const removeWorkspace = useSessionStore((state) => state.removeWorkspace); const setAgentLastActivity = useSessionStore((state) => state.setAgentLastActivity); @@ -1348,6 +1349,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider if (message.payload.emptyProject) { addEmptyProject(serverId, normalizeEmptyProjectDescriptor(message.payload.emptyProject)); } + if (message.payload.removedProjectId) { + removeEmptyProject(serverId, message.payload.removedProjectId); + } return; } const workspace = normalizeWorkspaceDescriptor(message.payload.workspace); @@ -1771,6 +1775,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider removeWorkspace, removeWorkspaceSetup, addEmptyProject, + removeEmptyProject, setAgentLastActivity, setPendingPermissions, setHasHydratedAgents, diff --git a/packages/app/src/i18n/resources.test.ts b/packages/app/src/i18n/resources.test.ts index 04e194dfb..42a56a4dc 100644 --- a/packages/app/src/i18n/resources.test.ts +++ b/packages/app/src/i18n/resources.test.ts @@ -249,6 +249,9 @@ describe("translation resources", () => { it("includes sidebar and workspace creation keys for the Batch 4C migration", () => { expect(en.sidebar.workspace.actions.copyPath).toBe("Copy path"); expect(en.sidebar.project.confirmations.removeTitle).toBe("Remove project?"); + expect(en.sidebar.project.toasts.updateHostToRemove).toBe( + "Update the host to remove projects.", + ); expect(en.newWorkspace.title).toBe("New workspace"); expect(en.newWorkspace.refPicker.searchPlaceholder).toBe("Search branches and PRs"); expect(en.openProject.tiles.addProject.title).toBe("Add a project"); diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index ac47466a6..d82735eda 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -787,6 +787,7 @@ export const ar: TranslationResources = { toasts: { hostDisconnected: "Host غير متصل", removeFailed: "فشل في إزالة بعض مساحات العمل", + updateHostToRemove: "قم بتحديث Host لإزالة المشاريع.", }, empty: { title: "لا توجد مشاريع حتى الآن", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 7812d65b9..15822fd65 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -794,6 +794,7 @@ export const en = { toasts: { hostDisconnected: "Host is not connected", removeFailed: "Failed to remove some workspaces", + updateHostToRemove: "Update the host to remove projects.", }, empty: { title: "No projects yet", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 53a1094f1..963e8b6fe 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -814,6 +814,7 @@ export const es: TranslationResources = { toasts: { hostDisconnected: "Hostno está conectado", removeFailed: "No se pudieron eliminar algunos espacios de trabajo", + updateHostToRemove: "Actualiza el host para eliminar proyectos.", }, empty: { title: "Aún no hay proyectos", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index a794a8360..fe5fcacd4 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -813,6 +813,7 @@ export const fr: TranslationResources = { toasts: { hostDisconnected: "Hostn'est pas connecté", removeFailed: "Échec de la suppression de certains espaces de travail", + updateHostToRemove: "Mettez à jour le host pour supprimer des projets.", }, empty: { title: "Aucun projet pour l'instant", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index fc6c837a4..a17d1a597 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -806,6 +806,7 @@ export const ru: TranslationResources = { toasts: { hostDisconnected: "Host не подключен", removeFailed: "Не удалось удалить некоторые рабочие области.", + updateHostToRemove: "Обновите host, чтобы удалять проекты.", }, empty: { title: "Пока нет проектов", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index bf28983e8..3a88db254 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -779,6 +779,7 @@ export const zhCN: TranslationResources = { toasts: { hostDisconnected: "Host 未连接", removeFailed: "部分 workspace 移除失败", + updateHostToRemove: "更新 host 以移除 projects。", }, empty: { title: "还没有 projects", diff --git a/packages/app/src/stores/session-store.test.ts b/packages/app/src/stores/session-store.test.ts index 7e7f78d32..9d1491fde 100644 --- a/packages/app/src/stores/session-store.test.ts +++ b/packages/app/src/stores/session-store.test.ts @@ -48,6 +48,7 @@ function getTestSessionReferences() { sessions: state.sessions, session, workspaces: session.workspaces, + emptyProjects: session.emptyProjects, }; } @@ -448,6 +449,48 @@ describe("removeWorkspace", () => { }); }); +describe("removeEmptyProject", () => { + it("removes an empty project by project id", () => { + const store = useSessionStore.getState(); + initializeTestSession(); + store.setEmptyProjects("test-server", [ + { + projectId: "project-empty", + projectDisplayName: "Empty", + projectCustomName: null, + projectRootPath: "/repo/empty", + projectKind: "git", + }, + ]); + + store.removeEmptyProject("test-server", "project-empty"); + + expect(getTestSessionReferences().emptyProjects.has("project-empty")).toBe(false); + }); + + it("preserves identity when removing a missing empty project", () => { + const store = useSessionStore.getState(); + initializeTestSession(); + store.setEmptyProjects("test-server", [ + { + projectId: "project-empty", + projectDisplayName: "Empty", + projectCustomName: null, + projectRootPath: "/repo/empty", + projectKind: "git", + }, + ]); + const before = getTestSessionReferences(); + + store.removeEmptyProject("test-server", "project-missing"); + const after = getTestSessionReferences(); + + expect(after.sessions).toBe(before.sessions); + expect(after.session).toBe(before.session); + expect(after.emptyProjects).toBe(before.emptyProjects); + }); +}); + describe("patchWorkspaceScripts", () => { it("preserves workspace entry identity when scripts are content-equal", () => { const script = { diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index 5e5e00cdb..88f11ae4a 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -460,6 +460,7 @@ interface SessionStoreActions { removeWorkspace: (serverId: string, workspaceId: string) => void; setEmptyProjects: (serverId: string, emptyProjects: Iterable) => void; addEmptyProject: (serverId: string, emptyProject: EmptyProjectDescriptor) => void; + removeEmptyProject: (serverId: string, projectId: string) => void; setWorkspaceRestoreStatus: ( serverId: string, workspaceId: string, @@ -1265,6 +1266,24 @@ export const useSessionStore = create()( }); }, + removeEmptyProject: (serverId, projectId) => { + set((prev) => { + const session = prev.sessions[serverId]; + if (!session?.emptyProjects.has(projectId)) { + return prev; + } + const next = new Map(session.emptyProjects); + next.delete(projectId); + return { + ...prev, + sessions: { + ...prev.sessions, + [serverId]: { ...session, emptyProjects: next }, + }, + }; + }); + }, + setWorkspaceRestoreStatus: (serverId, workspaceId, status) => { set((prev) => { const session = prev.sessions[serverId]; diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index 8bae233f3..36dfdf3d0 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -1521,6 +1521,47 @@ test("sends first-agent prompt context with workspace.create.request", async () }); }); +test("sends project.remove.request", async () => { + const logger = createMockLogger(); + const mock = createMockTransport(); + + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const removePromise = client.removeProject("remote:github.com/acme/app", "req-remove-project"); + + expect(parseSentFrame(mock.sent[0])).toEqual({ + type: "project.remove.request", + requestId: "req-remove-project", + projectId: "remote:github.com/acme/app", + }); + + mock.triggerMessage( + wrapSessionMessage({ + type: "project.remove.response", + payload: { + requestId: "req-remove-project", + projectId: "remote:github.com/acme/app", + accepted: true, + removedWorkspaceIds: ["ws-main"], + error: null, + }, + }), + ); + + await expect(removePromise).resolves.toEqual({ removedWorkspaceIds: ["ws-main"] }); +}); + test("sends worktree base-ref fields in create_paseo_worktree_request", async () => { const logger = createMockLogger(); const mock = createMockTransport(); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index e8507b6c7..776644d1e 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -2153,6 +2153,24 @@ export class DaemonClient { return { customName: payload.customName }; } + async removeProject( + projectId: string, + requestId?: string, + ): Promise<{ removedWorkspaceIds: string[] }> { + const payload = await this.sendNamespacedCorrelatedSessionRequest<"project.remove.response">({ + requestId, + message: { + type: "project.remove.request", + projectId, + }, + timeout: 10000, + }); + if (!payload.accepted) { + throw new Error(payload.error ?? "removeProject rejected"); + } + return { removedWorkspaceIds: payload.removedWorkspaceIds }; + } + async setWorkspaceTitle( workspaceId: string, title: string | null, diff --git a/packages/protocol/src/messages.checkout-pr-schema.test.ts b/packages/protocol/src/messages.checkout-pr-schema.test.ts index 38714d914..1395bb356 100644 --- a/packages/protocol/src/messages.checkout-pr-schema.test.ts +++ b/packages/protocol/src/messages.checkout-pr-schema.test.ts @@ -340,4 +340,18 @@ describe("checkout PR schemas", () => { githubCheckDetails: true, }); }); + + test("accepts the project removal server_info feature flag", () => { + expect( + ServerInfoStatusPayloadSchema.parse({ + status: "server_info", + serverId: "srv_test", + features: { + projectRemove: true, + }, + }).features, + ).toEqual({ + projectRemove: true, + }); + }); }); diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 652d572bb..358a4c055 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -795,6 +795,12 @@ export const ProjectRenameRequestSchema = z.object({ requestId: z.string(), }); +export const ProjectRemoveRequestSchema = z.object({ + type: z.literal("project.remove.request"), + projectId: z.string(), + requestId: z.string(), +}); + export const WorkspaceTitleSetRequestSchema = z.object({ type: z.literal("workspace.title.set.request"), workspaceId: z.string(), @@ -1340,6 +1346,19 @@ export const ProjectRenameResponseSchema = z.object({ payload: ProjectRenameResponsePayloadSchema, }); +export const ProjectRemoveResponsePayloadSchema = z.object({ + requestId: z.string(), + projectId: z.string(), + accepted: z.boolean(), + removedWorkspaceIds: z.array(z.string()).default([]), + error: z.string().nullable(), +}); + +export const ProjectRemoveResponseSchema = z.object({ + type: z.literal("project.remove.response"), + payload: ProjectRemoveResponsePayloadSchema, +}); + export const WorkspaceTitleSetResponsePayloadSchema = z.object({ requestId: z.string(), workspaceId: z.string(), @@ -1968,6 +1987,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ CloseItemsRequestMessageSchema, UpdateAgentRequestMessageSchema, ProjectRenameRequestSchema, + ProjectRemoveRequestSchema, WorkspaceTitleSetRequestSchema, SetVoiceModeMessageSchema, SendAgentMessageRequestSchema, @@ -2256,6 +2276,8 @@ export const ServerInfoStatusPayloadSchema = z checkoutRefresh: z.boolean().optional(), // COMPAT(workspaceMultiplicity): added in v0.1.97, drop the gate when floor >= v0.1.97 workspaceMultiplicity: z.boolean().optional(), + // COMPAT(projectRemove): added in v0.1.97, drop the gate when floor >= v0.1.97. + projectRemove: z.boolean().optional(), // COMPAT(worktreeRestore): added in v0.1.98, drop the gate when floor >= v0.1.98 worktreeRestore: z.boolean().optional(), }) @@ -2692,6 +2714,9 @@ export const WorkspaceUpdateMessageSchema = z.object({ // daemons omit it; old clients ignore it and surface the empty 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. + removedProjectId: z.string().optional(), }), ]), }); @@ -3997,6 +4022,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ AgentRewindResponseMessageSchema, UpdateAgentResponseMessageSchema, ProjectRenameResponseSchema, + ProjectRemoveResponseSchema, WorkspaceTitleSetResponseSchema, WaitForFinishResponseMessageSchema, AgentPermissionRequestMessageSchema, @@ -4143,6 +4169,7 @@ export type SetAgentFeatureResponseMessage = z.infer; export type UpdateAgentResponseMessage = z.infer; export type ProjectRenameResponse = z.infer; +export type ProjectRemoveResponse = z.infer; export type WorkspaceTitleSetResponse = z.infer; export type WorkspaceTitleSetResponsePayload = z.infer< typeof WorkspaceTitleSetResponsePayloadSchema @@ -4150,6 +4177,7 @@ export type WorkspaceTitleSetResponsePayload = z.infer< export type WorkspaceCreateRequest = z.infer; export type WorkspaceCreateResponse = z.infer; export type ProjectRenameResponsePayload = z.infer; +export type ProjectRemoveResponsePayload = z.infer; export type WaitForFinishResponseMessage = z.infer; export type AgentPermissionRequestMessage = z.infer; export type AgentPermissionResolvedMessage = z.infer; @@ -4265,6 +4293,7 @@ export type ResumeAgentRequestMessage = z.infer; export type UpdateAgentRequestMessage = z.infer; export type ProjectRenameRequest = z.infer; +export type ProjectRemoveRequest = z.infer; export type WorkspaceTitleSetRequest = z.infer; export type SetAgentModeRequestMessage = z.infer; export type SetAgentModelRequestMessage = z.infer; diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index ff316a4a2..47427dd67 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -197,7 +197,10 @@ import { type ProjectConfigRpcError, } from "../utils/paseo-config-file.js"; import { buildMetadataPrompt } from "../utils/build-metadata-prompt.js"; -import { archivePersistedWorkspaceRecord } from "./workspace-archive-service.js"; +import { + archivePersistedWorkspaceRecord, + archiveWorkspaceContents, +} from "./workspace-archive-service.js"; import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js"; import type { ServiceProxySubsystem } from "./service-proxy.js"; import { @@ -1703,6 +1706,17 @@ export class Session { return this.buildProjectPlacementForWorkspace(workspace); } + private async buildProjectPlacementForExistingWorkspaceProject( + workspaceId: string, + ): Promise { + const workspace = await this.workspaceRegistry.get(workspaceId); + if (!workspace) return null; + + const project = await this.projectRegistry.get(workspace.projectId); + if (!project) return null; + return this.buildProjectPlacementForWorkspace(workspace, project); + } + private async forwardAgentUpdate(agent: ManagedAgent): Promise { try { const subscription = this.agentUpdatesSubscription; @@ -2184,6 +2198,8 @@ export class Session { return this.handleOpenProjectRequest(msg); case "archive_workspace_request": return this.handleArchiveWorkspaceRequest(msg); + case "project.remove.request": + return this.handleProjectRemoveRequest(msg); case "workspace.create.request": return this.handleWorkspaceCreateRequest(msg); case "workspace.clear_attention.request": @@ -2685,6 +2701,97 @@ export class Session { } } + private async handleProjectRemoveRequest( + request: Extract, + ): Promise { + const { projectId, requestId } = request; + this.sessionLogger.info({ projectId, requestId }, "session: project.remove.request"); + + try { + const projectWorkspaces = (await this.workspaceRegistry.list()).filter( + (workspace) => workspace.projectId === projectId, + ); + const activeWorkspaceIds = projectWorkspaces + .filter((workspace) => !workspace.archivedAt) + .map((workspace) => workspace.workspaceId); + + if (activeWorkspaceIds.length > 0) { + this.markWorkspaceArchiving(activeWorkspaceIds, new Date().toISOString()); + await this.emitWorkspaceUpdatesForWorkspaceIds(activeWorkspaceIds, { + skipReconcile: true, + }); + } + + const removedWorkspaceIds: string[] = []; + try { + for (const workspaceId of activeWorkspaceIds) { + await archiveWorkspaceContents( + { + agentManager: this.agentManager, + agentStorage: this.agentStorage, + killTerminalsForWorkspace: (id) => + this.terminalController.killTerminalsForWorkspace(id), + sessionLogger: this.sessionLogger, + }, + workspaceId, + ); + await this.archiveWorkspaceRecord(workspaceId); + removedWorkspaceIds.push(workspaceId); + } + + await this.projectRegistry.remove(projectId); + } finally { + if (activeWorkspaceIds.length > 0) { + this.clearWorkspaceArchiving(activeWorkspaceIds); + } + } + + const updateIds = + removedWorkspaceIds.length > 0 + ? removedWorkspaceIds + : [projectWorkspaces[0]?.workspaceId ?? projectId]; + await this.emitWorkspaceUpdatesForWorkspaceIds(updateIds, { + skipReconcile: true, + removedProjectId: projectId, + }); + + this.emit({ + type: "project.remove.response", + payload: { + requestId, + projectId, + accepted: true, + removedWorkspaceIds, + error: null, + }, + }); + } catch (error) { + this.sessionLogger.error( + { err: error, projectId, requestId }, + "session: project.remove.request error", + ); + this.emit({ + type: "activity_log", + payload: { + id: uuidv4(), + timestamp: new Date(), + type: "error", + content: `Failed to remove project: ${getErrorMessage(error)}`, + }, + }); + this.emit({ + type: "project.remove.response", + payload: { + requestId, + projectId, + accepted: false, + removedWorkspaceIds: [], + error: getErrorMessageOr(error, "Failed to remove project"), + }, + }); + } + } + private async handleWorkspaceTitleSetRequest( workspaceId: string, title: string | null, @@ -6569,7 +6676,10 @@ export class Session { if (existing) { return existing; } - const placementPromise = this.buildProjectPlacementForWorkspaceId(workspaceId); + const placementPromise = + request.type === "fetch_agent_history_request" + ? this.buildProjectPlacementForExistingWorkspaceProject(workspaceId) + : this.buildProjectPlacementForWorkspaceId(workspaceId); placementByWorkspaceId.set(workspaceId, placementPromise); return placementPromise; }; @@ -7023,6 +7133,9 @@ export class Session { input.workspace.kind === kind && input.workspace.displayName === displayName ) { + if (!input.project) { + await this.projectRegistry.upsert(projectRecord); + } return this.ensureWorkspaceRecordUnarchived(input.workspace); } @@ -7327,7 +7440,7 @@ export class Session { private async emitWorkspaceUpdatesForWorkspaceIds( workspaceIds: Iterable, - options?: { skipReconcile?: boolean; dedupeGitState?: boolean }, + options?: { skipReconcile?: boolean; dedupeGitState?: boolean; removedProjectId?: string }, ): Promise { const subscription = this.workspaceUpdatesSubscription; if (!subscription) { @@ -7356,22 +7469,14 @@ export class Session { ) { continue; } - const watchTarget = this.resolveWorkspaceGitWatchTarget(workspaceId); - if (watchTarget && this.onBranchChanged) { - const newBranchName = nextWorkspace?.name ?? null; - if (newBranchName !== watchTarget.lastBranchName) { - this.onBranchChanged(workspaceId, watchTarget.lastBranchName, newBranchName); - } - } - this.rememberWorkspaceGitDescriptorState(workspaceId, nextWorkspace); + this.recordWorkspaceGitDescriptorState(workspaceId, nextWorkspace); if (!nextWorkspace) { subscription.lastEmittedByWorkspaceId.delete(workspaceId); - this.bufferOrEmitWorkspaceUpdate(subscription, { - kind: "remove", - id: workspaceId, - ...(await this.resolveEmptyProjectForArchivedWorkspace(workspaceId)), - }); + this.bufferOrEmitWorkspaceUpdate( + subscription, + await this.buildWorkspaceRemoveUpdatePayload(workspaceId, options?.removedProjectId), + ); continue; } @@ -7397,6 +7502,34 @@ export class Session { } } + private recordWorkspaceGitDescriptorState( + workspaceId: string, + nextWorkspace: WorkspaceDescriptorPayload | null, + ): void { + const watchTarget = this.resolveWorkspaceGitWatchTarget(workspaceId); + if (watchTarget && this.onBranchChanged) { + const newBranchName = nextWorkspace?.name ?? null; + if (newBranchName !== watchTarget.lastBranchName) { + this.onBranchChanged(workspaceId, watchTarget.lastBranchName, newBranchName); + } + } + this.rememberWorkspaceGitDescriptorState(workspaceId, nextWorkspace); + } + + private async buildWorkspaceRemoveUpdatePayload( + workspaceId: string, + removedProjectId?: string, + ): Promise { + if (removedProjectId) { + return { kind: "remove", id: workspaceId, removedProjectId }; + } + return { + kind: "remove", + id: workspaceId, + ...(await this.resolveEmptyProjectForArchivedWorkspace(workspaceId)), + }; + } + // When a workspace is archived its project may become empty. Resolve the // now-empty project parent so the `remove` update can carry it, keeping the // sidebar's empty project row in sync without a full re-hydration. diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 14be43d2f..876825e5b 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -84,6 +84,7 @@ interface SessionTestAccess { archive(projectId: string, archivedAt: string): Promise; get(id: string): Promise; upsert(record: unknown): Promise; + remove(projectId: string): Promise; }; agentStorage: { list(...args: unknown[]): Promise; @@ -2391,6 +2392,94 @@ test("fetch_agent_history_request pages archived historical rows separately", as expect(session.agentUpdatesSubscription).toBeNull(); }); +test("fetch_agent_history_request skips rows whose workspace project record is missing", async () => { + const emitted: SessionOutboundMessage[] = []; + const session = createSessionForWorkspaceTests(); + const stableCwd = path.resolve("/tmp/stable-history"); + const orphanCwd = path.resolve("/tmp/orphan-history"); + const stableProject = createPersistedProjectRecord({ + projectId: "proj-stable-history", + rootPath: stableCwd, + kind: "non_git", + displayName: "stable history", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const stableWorkspace = createPersistedWorkspaceRecord({ + workspaceId: "ws-stable-history", + projectId: stableProject.projectId, + cwd: stableCwd, + kind: "directory", + displayName: "stable history", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const orphanWorkspace = createPersistedWorkspaceRecord({ + workspaceId: "ws-orphan-history", + projectId: orphanCwd, + cwd: orphanCwd, + kind: "local_checkout", + displayName: "main", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + + session.emit = (message) => { + if (isSessionOutboundMessage(message)) emitted.push(message); + }; + session.projectRegistry.get = async (projectId: string) => + projectId === stableProject.projectId ? stableProject : null; + session.workspaceRegistry.get = async (workspaceId: string) => + [stableWorkspace, orphanWorkspace].find((workspace) => workspace.workspaceId === workspaceId) ?? + null; + session.listAgentPayloads = async () => [ + makeAgent({ + id: "history-orphan", + cwd: orphanCwd, + workspaceId: orphanWorkspace.workspaceId, + status: "idle", + updatedAt: "2026-03-01T12:01:00.000Z", + }), + makeAgent({ + id: "history-stable", + cwd: stableCwd, + workspaceId: stableWorkspace.workspaceId, + status: "closed", + updatedAt: "2026-03-01T12:00:00.000Z", + }), + ]; + + await session.handleMessage({ + type: "fetch_agent_history_request", + requestId: "req-history-orphan", + page: { limit: 25 }, + }); + + expect(emitted).toEqual([ + { + type: "fetch_agent_history_response", + payload: expect.objectContaining({ + requestId: "req-history-orphan", + entries: [ + expect.objectContaining({ + agent: expect.objectContaining({ id: "history-stable" }), + project: expect.objectContaining({ + projectKey: stableProject.projectId, + projectName: "stable history", + workspaceName: "stable history", + }), + }), + ], + pageInfo: { + nextCursor: null, + prevCursor: null, + hasMore: false, + }, + }), + }, + ]); +}); + test("fetch_recent_provider_sessions_request lists importable provider sessions by handle", async () => { const emitted: Array<{ type: string; payload: unknown }> = []; const session = createSessionForWorkspaceTests(); @@ -3035,6 +3124,182 @@ test("archiving the last workspace emits a remove carrying the now-empty project }); }); +test("project.remove.request archives active workspaces and removes the project record", async () => { + const emitted: SessionOutboundMessage[] = []; + const session = createSessionForWorkspaceTests({ + onMessage: (message) => emitted.push(message), + }); + const project = createPersistedProjectRecord({ + projectId: "proj-remove-with-workspace", + rootPath: REPO_CWD, + kind: "git", + displayName: "repo", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const workspace = createPersistedWorkspaceRecord({ + workspaceId: "ws-project-remove", + projectId: project.projectId, + cwd: REPO_CWD, + kind: "local_checkout", + displayName: "main", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const projects = new Map([[project.projectId, project]]); + const workspaces = new Map([ + [workspace.workspaceId, workspace], + ]); + + session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null; + session.projectRegistry.list = async () => Array.from(projects.values()); + session.projectRegistry.remove = async (projectId: string) => { + projects.delete(projectId); + }; + session.workspaceRegistry.get = async (workspaceId: string) => + workspaces.get(workspaceId) ?? null; + session.workspaceRegistry.list = async () => Array.from(workspaces.values()); + session.workspaceRegistry.archive = async (workspaceId: string, archivedAt: string) => { + const existing = workspaces.get(workspaceId); + if (!existing) return; + workspaces.set(workspaceId, { ...existing, updatedAt: archivedAt, archivedAt }); + }; + session.workspaceUpdatesSubscription = { + subscriptionId: "sub-project-remove", + filter: undefined, + isBootstrapping: false, + pendingUpdatesByWorkspaceId: new Map(), + lastEmittedByWorkspaceId: new Map(), + }; + session.reconcileActiveWorkspaceRecords = async () => new Set(); + session.listAgentPayloads = async () => []; + session.buildWorkspaceDescriptorMap = async (options: { workspaceIds?: Iterable }) => { + const workspaceIds = Array.from(options.workspaceIds ?? workspaces.keys()); + const descriptors = new Map(); + for (const workspaceId of workspaceIds) { + const record = workspaces.get(workspaceId); + if (!record || record.archivedAt) continue; + descriptors.set(workspaceId, { + id: record.workspaceId, + projectId: record.projectId, + projectDisplayName: "repo", + projectRootPath: REPO_CWD, + projectKind: "git", + workspaceKind: record.kind, + name: record.displayName, + status: "idle", + activityAt: null, + }); + } + return descriptors; + }; + + await session.handleMessage({ + type: "project.remove.request", + projectId: project.projectId, + requestId: "req-remove-project", + }); + + expect(projects.has(project.projectId)).toBe(false); + expect(workspaces.get(workspace.workspaceId)).toEqual({ + ...workspace, + updatedAt: expect.any(String), + archivedAt: expect.any(String), + }); + expect(findByType(emitted, "project.remove.response")?.payload).toEqual({ + requestId: "req-remove-project", + projectId: project.projectId, + accepted: true, + removedWorkspaceIds: [workspace.workspaceId], + error: null, + }); + const workspaceUpdates = filterByType(emitted, "workspace_update"); + expect(workspaceUpdates.at(-1)?.payload).toEqual({ + kind: "remove", + id: workspace.workspaceId, + removedProjectId: project.projectId, + }); +}); + +test("project.remove.request removes an already-empty project", async () => { + const emitted: SessionOutboundMessage[] = []; + const session = createSessionForWorkspaceTests({ + onMessage: (message) => emitted.push(message), + }); + const project = createPersistedProjectRecord({ + projectId: "proj-remove-empty", + rootPath: REPO_CWD, + kind: "git", + displayName: "repo", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const archivedWorkspace = createPersistedWorkspaceRecord({ + workspaceId: "ws-project-remove-empty", + projectId: project.projectId, + cwd: REPO_CWD, + kind: "local_checkout", + displayName: "main", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + archivedAt: "2026-03-02T12:00:00.000Z", + }); + const projects = new Map([[project.projectId, project]]); + const workspaces = new Map([ + [archivedWorkspace.workspaceId, archivedWorkspace], + ]); + + session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null; + session.projectRegistry.list = async () => Array.from(projects.values()); + session.projectRegistry.remove = async (projectId: string) => { + projects.delete(projectId); + }; + session.workspaceRegistry.get = async (workspaceId: string) => + workspaces.get(workspaceId) ?? null; + session.workspaceRegistry.list = async () => Array.from(workspaces.values()); + session.workspaceRegistry.archive = async (workspaceId: string, archivedAt: string) => { + const existing = workspaces.get(workspaceId); + if (!existing) return; + workspaces.set(workspaceId, { ...existing, updatedAt: archivedAt, archivedAt }); + }; + session.workspaceUpdatesSubscription = { + subscriptionId: "sub-empty-project-remove", + filter: undefined, + isBootstrapping: false, + pendingUpdatesByWorkspaceId: new Map(), + lastEmittedByWorkspaceId: new Map(), + }; + session.reconcileActiveWorkspaceRecords = async () => new Set(); + session.listAgentPayloads = async () => []; + session.buildWorkspaceDescriptorMap = async () => new Map(); + + await session.handleMessage({ + type: "project.remove.request", + projectId: project.projectId, + requestId: "req-remove-empty-project", + }); + + expect(projects.has(project.projectId)).toBe(false); + expect(workspaces.get(archivedWorkspace.workspaceId)).toEqual(archivedWorkspace); + expect(findByType(emitted, "project.remove.response")?.payload).toEqual({ + requestId: "req-remove-empty-project", + projectId: project.projectId, + accepted: true, + removedWorkspaceIds: [], + error: null, + }); + expect(filterByType(emitted, "workspace_update")).toEqual([ + { + type: "workspace_update", + payload: { + kind: "remove", + id: archivedWorkspace.workspaceId, + removedProjectId: project.projectId, + }, + }, + ]); +}); + test("create paseo worktree request returns a registered workspace descriptor", async () => { const emitted: SessionOutboundMessage[] = []; const createdAt = "2026-05-12T12:00:00.000Z"; @@ -4058,6 +4323,67 @@ test("open_project_request unarchives an existing archived workspace and project expect(response?.payload.workspace?.id).toBe(workspaceId); }); +test("open_project_request recreates a missing project record when unarchiving its workspace", async () => { + const emitted: SessionOutboundMessage[] = []; + const session = createSessionForWorkspaceTests(); + const projects = new Map>(); + const workspaces = new Map>(); + + const cwd = REPO_CWD; + const workspaceId = "ws-repo-project-removed"; + workspaces.set( + workspaceId, + createPersistedWorkspaceRecord({ + workspaceId, + projectId: cwd, + cwd, + kind: "directory", + displayName: "repo", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-10T00:00:00.000Z", + archivedAt: "2026-03-10T00:00:00.000Z", + }), + ); + + session.emit = (message) => { + if (isSessionOutboundMessage(message)) emitted.push(message); + }; + session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null; + session.projectRegistry.upsert = async ( + record: ReturnType, + ) => { + projects.set(record.projectId, record); + }; + session.workspaceRegistry.get = async (lookupWorkspaceId: string) => + workspaces.get(lookupWorkspaceId) ?? null; + session.workspaceRegistry.upsert = async ( + record: ReturnType, + ) => { + workspaces.set(record.workspaceId, record); + }; + session.projectRegistry.list = async () => Array.from(projects.values()); + session.workspaceRegistry.list = async () => Array.from(workspaces.values()); + + await session.handleMessage({ + type: "open_project_request", + cwd, + requestId: "req-open-removed-project", + }); + + expect(projects.get(cwd)).toEqual( + expect.objectContaining({ + projectId: cwd, + displayName: "repo", + archivedAt: null, + }), + ); + expect(workspaces.get(workspaceId)?.archivedAt).toBeNull(); + const response = findByType(emitted, "open_project_response"); + expect(response?.payload.error).toBeNull(); + expect(response?.payload.workspace?.id).toBe(workspaceId); + expect(response?.payload.workspace?.projectDisplayName).toBe("repo"); +}); + test("refresh_agent_request unarchives the owning workspace when its directory exists", async () => { const emitted: SessionOutboundMessage[] = []; const session = createSessionForWorkspaceTests({ diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 3e5044157..9bcbb933d 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1157,6 +1157,8 @@ export class VoiceAssistantWebSocketServer { checkoutRefresh: true, // COMPAT(workspaceMultiplicity): added in v0.1.97, drop the gate when floor >= v0.1.97 workspaceMultiplicity: true, + // COMPAT(projectRemove): added in v0.1.97, drop the gate when floor >= v0.1.97. + projectRemove: true, // COMPAT(worktreeRestore): added in v0.1.98, drop the gate when floor >= v0.1.98 worktreeRestore: true, },