Fix sidebar project removal

Remove projects through a daemon RPC so the sidebar can delete the project
record after archiving its active workspaces. Reopening a removed directory
recreates the missing project record so local projects keep their derived
basename, and History skips orphaned archived workspace rows.

Fixes #1583.
This commit is contained in:
Mohamed Boudra
2026-06-19 00:46:40 +07:00
parent f876b80f7a
commit 9a09ccb3d6
20 changed files with 736 additions and 29 deletions

View File

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

View File

@@ -27,6 +27,24 @@ async function hideWorkspaceFromSidebar(page: Page, workspaceId: string): Promis
await archiveItem.click();
}
async function removeProjectFromSidebar(page: Page, projectId: string): Promise<void> {
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();
}
});
});

View File

@@ -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<string, number>;
@@ -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,

View File

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

View File

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

View File

@@ -787,6 +787,7 @@ export const ar: TranslationResources = {
toasts: {
hostDisconnected: "Host غير متصل",
removeFailed: "فشل في إزالة بعض مساحات العمل",
updateHostToRemove: "قم بتحديث Host لإزالة المشاريع.",
},
empty: {
title: "لا توجد مشاريع حتى الآن",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -806,6 +806,7 @@ export const ru: TranslationResources = {
toasts: {
hostDisconnected: "Host не подключен",
removeFailed: "Не удалось удалить некоторые рабочие области.",
updateHostToRemove: "Обновите host, чтобы удалять проекты.",
},
empty: {
title: "Пока нет проектов",

View File

@@ -779,6 +779,7 @@ export const zhCN: TranslationResources = {
toasts: {
hostDisconnected: "Host 未连接",
removeFailed: "部分 workspace 移除失败",
updateHostToRemove: "更新 host 以移除 projects。",
},
empty: {
title: "还没有 projects",

View File

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

View File

@@ -460,6 +460,7 @@ interface SessionStoreActions {
removeWorkspace: (serverId: string, workspaceId: string) => void;
setEmptyProjects: (serverId: string, emptyProjects: Iterable<EmptyProjectDescriptor>) => 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<SessionStore>()(
});
},
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];

View File

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

View File

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

View File

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

View File

@@ -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<typeof SetAgentFeatureRespo
export type AgentRewindResponseMessage = z.infer<typeof AgentRewindResponseMessageSchema>;
export type UpdateAgentResponseMessage = z.infer<typeof UpdateAgentResponseMessageSchema>;
export type ProjectRenameResponse = z.infer<typeof ProjectRenameResponseSchema>;
export type ProjectRemoveResponse = z.infer<typeof ProjectRemoveResponseSchema>;
export type WorkspaceTitleSetResponse = z.infer<typeof WorkspaceTitleSetResponseSchema>;
export type WorkspaceTitleSetResponsePayload = z.infer<
typeof WorkspaceTitleSetResponsePayloadSchema
@@ -4150,6 +4177,7 @@ export type WorkspaceTitleSetResponsePayload = z.infer<
export type WorkspaceCreateRequest = z.infer<typeof WorkspaceCreateRequestSchema>;
export type WorkspaceCreateResponse = z.infer<typeof WorkspaceCreateResponseSchema>;
export type ProjectRenameResponsePayload = z.infer<typeof ProjectRenameResponsePayloadSchema>;
export type ProjectRemoveResponsePayload = z.infer<typeof ProjectRemoveResponsePayloadSchema>;
export type WaitForFinishResponseMessage = z.infer<typeof WaitForFinishResponseMessageSchema>;
export type AgentPermissionRequestMessage = z.infer<typeof AgentPermissionRequestMessageSchema>;
export type AgentPermissionResolvedMessage = z.infer<typeof AgentPermissionResolvedMessageSchema>;
@@ -4265,6 +4293,7 @@ export type ResumeAgentRequestMessage = z.infer<typeof ResumeAgentRequestMessage
export type DeleteAgentRequestMessage = z.infer<typeof DeleteAgentRequestMessageSchema>;
export type UpdateAgentRequestMessage = z.infer<typeof UpdateAgentRequestMessageSchema>;
export type ProjectRenameRequest = z.infer<typeof ProjectRenameRequestSchema>;
export type ProjectRemoveRequest = z.infer<typeof ProjectRemoveRequestSchema>;
export type WorkspaceTitleSetRequest = z.infer<typeof WorkspaceTitleSetRequestSchema>;
export type SetAgentModeRequestMessage = z.infer<typeof SetAgentModeRequestMessageSchema>;
export type SetAgentModelRequestMessage = z.infer<typeof SetAgentModelRequestMessageSchema>;

View File

@@ -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<ProjectPlacementPayload | null> {
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<void> {
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<SessionInboundMessage, { type: "project.remove.request" }>,
): Promise<void> {
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<string>,
options?: { skipReconcile?: boolean; dedupeGitState?: boolean },
options?: { skipReconcile?: boolean; dedupeGitState?: boolean; removedProjectId?: string },
): Promise<void> {
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<WorkspaceUpdatePayload> {
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.

View File

@@ -84,6 +84,7 @@ interface SessionTestAccess {
archive(projectId: string, archivedAt: string): Promise<void>;
get(id: string): Promise<unknown>;
upsert(record: unknown): Promise<unknown>;
remove(projectId: string): Promise<void>;
};
agentStorage: {
list(...args: unknown[]): Promise<unknown[]>;
@@ -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<string, PersistedProjectRecord>([[project.projectId, project]]);
const workspaces = new Map<string, PersistedWorkspaceRecord>([
[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<string> }) => {
const workspaceIds = Array.from(options.workspaceIds ?? workspaces.keys());
const descriptors = new Map<string, unknown>();
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<string, PersistedProjectRecord>([[project.projectId, project]]);
const workspaces = new Map<string, PersistedWorkspaceRecord>([
[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<string, ReturnType<typeof createPersistedProjectRecord>>();
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
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<typeof createPersistedProjectRecord>,
) => {
projects.set(record.projectId, record);
};
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
workspaces.get(lookupWorkspaceId) ?? null;
session.workspaceRegistry.upsert = async (
record: ReturnType<typeof createPersistedWorkspaceRecord>,
) => {
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({

View File

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