From 176942bbf1a319b9841d20934228e6dd87c1e607 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 3 Apr 2026 21:33:05 +0700 Subject: [PATCH] fix(sidebar): persist projects/workspaces and add remove project menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stop auto-archiving workspaces when all their agents are archived — only archive when the directory no longer exists on disk. This prevents projects from disappearing when users archive their last agent. Add a kebab dropdown menu to all project rows (desktop) with a "Remove project" action that archives all workspaces in the project. --- .../src/components/sidebar-workspace-list.tsx | 318 +++++++----------- packages/server/src/server/session.ts | 4 - .../server/workspace-registry-model.test.ts | 27 +- .../src/server/workspace-registry-model.ts | 23 -- 4 files changed, 132 insertions(+), 240 deletions(-) diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 9333fbf46..f2bb37431 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -38,6 +38,7 @@ import { Monitor, MoreVertical, Plus, + Trash2, } from "lucide-react-native"; import { NestableScrollContainer } from "react-native-draggable-flatlist"; import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list"; @@ -53,13 +54,7 @@ import { } from "@/hooks/use-sidebar-workspaces-list"; import { useSidebarOrderStore } from "@/stores/sidebar-order-store"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuTrigger, - useContextMenu, -} from "@/components/ui/context-menu"; +import { ContextMenuTrigger, useContextMenu } from "@/components/ui/context-menu"; import { DropdownMenu, DropdownMenuTrigger, @@ -145,6 +140,8 @@ interface ProjectHeaderRowProps { isDragging: boolean; isArchiving?: boolean; menuController: ReturnType | null; + onRemoveProject?: () => void; + removeProjectStatus?: "idle" | "pending"; dragHandleProps?: DraggableListDragHandleProps; } @@ -704,8 +701,11 @@ function ProjectHeaderRow({ isDragging, isArchiving = false, menuController, + onRemoveProject, + removeProjectStatus = "idle", dragHandleProps, }: ProjectHeaderRowProps) { + const { theme } = useUnistyles(); const [isHovered, setIsHovered] = useState(false); const isMobileBreakpoint = isCompactFormFactor(); const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces); @@ -792,16 +792,55 @@ function ProjectHeaderRow({ - {canCreateWorktree ? ( - createWorktreeMutation.mutate()} - visible={isHovered || isMobileBreakpoint} - loading={createWorktreeMutation.isPending} - showShortcutHint={isProjectActive} - testID={`sidebar-project-new-worktree-${project.projectKey}`} - /> - ) : null} + + {canCreateWorktree ? ( + createWorktreeMutation.mutate()} + visible={isHovered || isMobileBreakpoint} + loading={createWorktreeMutation.isPending} + showShortcutHint={isProjectActive} + testID={`sidebar-project-new-worktree-${project.projectKey}`} + /> + ) : null} + {onRemoveProject ? ( + + + [ + styles.projectKebabButton, + hovered && styles.projectKebabButtonHovered, + ]} + accessibilityRole="button" + accessibilityLabel="Project actions" + testID={`sidebar-project-kebab-${project.projectKey}`} + > + {({ hovered }) => ( + + )} + + + } + status={removeProjectStatus} + pendingLabel="Removing..." + onSelect={onRemoveProject} + > + Remove project + + + + + ) : null} + {showShortcutBadge && shortcutNumber !== null ? ( {shortcutNumber} @@ -1209,160 +1248,6 @@ function WorkspaceRowWithMenu({ ); } -function NonGitProjectRowWithMenuContent({ - project, - displayName, - iconDataUri, - workspace, - selected, - onPress, - shortcutNumber, - showShortcutBadge, - drag, - isDragging, - dragHandleProps, -}: { - project: SidebarProjectEntry; - displayName: string; - iconDataUri: string | null; - workspace: SidebarWorkspaceEntry; - selected: boolean; - onPress: () => void; - shortcutNumber: number | null; - showShortcutBadge: boolean; - drag: () => void; - isDragging: boolean; - dragHandleProps?: DraggableListDragHandleProps; -}) { - const toast = useToast(); - const contextMenu = useContextMenu(); - const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection(); - const sessionWorkspaces = useSessionStore( - (state) => state.sessions[workspace.serverId]?.workspaces ?? EMPTY_WORKSPACES, - ); - const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false); - const redirectAfterArchive = useCallback(() => { - if ( - activeWorkspaceSelection?.serverId !== workspace.serverId || - activeWorkspaceSelection.workspaceId !== workspace.workspaceId - ) { - return; - } - - router.replace( - buildWorkspaceArchiveRedirectRoute({ - serverId: workspace.serverId, - archivedWorkspaceId: workspace.workspaceId, - workspaces: sessionWorkspaces.values(), - }) as any, - ); - }, [activeWorkspaceSelection, sessionWorkspaces, workspace.serverId, workspace.workspaceId]); - - const handleArchiveWorkspace = useCallback(() => { - if (isArchivingWorkspace) { - return; - } - - void (async () => { - const confirmed = await confirmDialog({ - title: "Hide workspace?", - message: `Hide "${workspace.name}" from the sidebar?\n\nFiles on disk will not be changed.`, - confirmLabel: "Hide", - cancelLabel: "Cancel", - destructive: true, - }); - if (!confirmed) { - return; - } - - const client = getHostRuntimeStore().getClient(workspace.serverId); - if (!client) { - toast.error("Host is not connected"); - return; - } - - setIsArchivingWorkspace(true); - try { - const payload = await client.archiveWorkspace(workspace.workspaceId); - if (payload.error) { - throw new Error(payload.error); - } - redirectAfterArchive(); - } catch (error) { - toast.error(error instanceof Error ? error.message : "Failed to hide workspace"); - } finally { - setIsArchivingWorkspace(false); - } - })(); - }, [ - isArchivingWorkspace, - redirectAfterArchive, - toast, - workspace.name, - workspace.serverId, - workspace.workspaceId, - ]); - - return ( - <> - - - - Hide from sidebar - - - - ); -} - -function NonGitProjectRowWithMenu(props: { - project: SidebarProjectEntry; - displayName: string; - iconDataUri: string | null; - workspace: SidebarWorkspaceEntry; - selected: boolean; - onPress: () => void; - shortcutNumber: number | null; - showShortcutBadge: boolean; - drag: () => void; - isDragging: boolean; - dragHandleProps?: DraggableListDragHandleProps; -}) { - return ( - - - - ); -} - function FlattenedProjectRow({ project, displayName, @@ -1378,6 +1263,8 @@ function FlattenedProjectRow({ isDragging, dragHandleProps, isProjectActive = false, + onRemoveProject, + removeProjectStatus, }: { project: SidebarProjectEntry; displayName: string; @@ -1393,25 +1280,9 @@ function FlattenedProjectRow({ isDragging: boolean; dragHandleProps?: DraggableListDragHandleProps; isProjectActive?: boolean; + onRemoveProject?: () => void; + removeProjectStatus?: "idle" | "pending"; }) { - if (project.projectKind === "non_git") { - return ( - - ); - } - return ( ); @@ -1601,6 +1474,48 @@ function ProjectBlock({ [onWorkspaceReorder, project.projectKey], ); + const toast = useToast(); + const [isRemovingProject, setIsRemovingProject] = useState(false); + + const handleRemoveProject = useCallback(() => { + if (isRemovingProject || !serverId) { + return; + } + + void (async () => { + const confirmed = await confirmDialog({ + title: "Remove project?", + message: `Remove "${displayName}" from the sidebar?\n\nFiles on disk will not be changed.`, + confirmLabel: "Remove", + cancelLabel: "Cancel", + destructive: true, + }); + if (!confirmed) { + return; + } + + const client = getHostRuntimeStore().getClient(serverId); + if (!client) { + toast.error("Host is not connected"); + return; + } + + setIsRemovingProject(true); + try { + for (const ws of project.workspaces) { + const payload = await client.archiveWorkspace(ws.workspaceId); + if (payload.error) { + throw new Error(payload.error); + } + } + } catch (error) { + toast.error(error instanceof Error ? error.message : "Failed to remove project"); + } finally { + setIsRemovingProject(false); + } + })(); + }, [isRemovingProject, serverId, displayName, toast, project.workspaces]); + return ( {rowModel.kind === "workspace_link" ? ( @@ -1625,6 +1540,8 @@ function ProjectBlock({ isDragging={isDragging} dragHandleProps={dragHandleProps} isProjectActive={isProjectActive} + onRemoveProject={handleRemoveProject} + removeProjectStatus={isRemovingProject ? "pending" : "idle"} /> ) : ( <> @@ -1643,7 +1560,10 @@ function ProjectBlock({ onWorktreeCreated={onWorktreeCreated} drag={drag} isDragging={isDragging} + isArchiving={isRemovingProject} menuController={null} + onRemoveProject={handleRemoveProject} + removeProjectStatus={isRemovingProject ? "pending" : "idle"} dragHandleProps={dragHandleProps} /> @@ -2144,6 +2064,26 @@ const styles = StyleSheet.create((theme) => ({ projectIconActionButtonHidden: { opacity: 0, }, + projectTrailingActions: { + flexDirection: "row", + alignItems: "center", + gap: 2, + flexShrink: 0, + }, + projectKebabButton: { + width: 24, + height: 24, + borderRadius: theme.borderRadius.md, + alignItems: "center", + justifyContent: "center", + flexShrink: 0, + }, + projectKebabButtonHidden: { + opacity: 0, + }, + projectKebabButtonHovered: { + backgroundColor: theme.colors.surface2, + }, projectTrailingControlSlot: { width: 24, height: 24, diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 2e308b0a6..9161bd975 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1363,10 +1363,6 @@ export class Session { ); const staleWorkspaceIds = await detectStaleWorkspaces({ activeWorkspaces, - agentRecords: (await this.agentStorage.list()).map((agent) => ({ - cwd: agent.cwd, - archivedAt: agent.archivedAt ?? null, - })), checkDirectoryExists: async (cwd) => { try { await stat(cwd); diff --git a/packages/server/src/server/workspace-registry-model.test.ts b/packages/server/src/server/workspace-registry-model.test.ts index 4f4f186d3..29610578b 100644 --- a/packages/server/src/server/workspace-registry-model.test.ts +++ b/packages/server/src/server/workspace-registry-model.test.ts @@ -24,7 +24,6 @@ describe("detectStaleWorkspaces", () => { createWorkspaceRecord("/tmp/existing"), createWorkspaceRecord("/tmp/missing"), ], - agentRecords: [], checkDirectoryExists, }); @@ -32,41 +31,21 @@ describe("detectStaleWorkspaces", () => { expect(checkDirectoryExists.mock.calls).toEqual([["/tmp/existing"], ["/tmp/missing"]]); }); - test("returns workspace ids when all matching agents are archived", async () => { + test("keeps workspaces whose directories exist even when all agents are archived", async () => { const staleWorkspaceIds = await detectStaleWorkspaces({ activeWorkspaces: [createWorkspaceRecord("/tmp/repo"), createWorkspaceRecord("/tmp/other")], - agentRecords: [ - { - cwd: "/tmp/repo", - archivedAt: "2026-03-02T00:00:00.000Z", - }, - { - cwd: "/tmp/other", - archivedAt: null, - }, - ], checkDirectoryExists: async () => true, }); - expect(Array.from(staleWorkspaceIds)).toEqual(["/tmp/repo"]); + expect(Array.from(staleWorkspaceIds)).toEqual([]); }); - test("keeps workspaces with no agents or at least one active agent", async () => { + test("keeps workspaces with no agents when directory exists", async () => { const staleWorkspaceIds = await detectStaleWorkspaces({ activeWorkspaces: [ createWorkspaceRecord("/tmp/active"), createWorkspaceRecord("/tmp/no-agents"), ], - agentRecords: [ - { - cwd: "/tmp/active", - archivedAt: "2026-03-02T00:00:00.000Z", - }, - { - cwd: "/tmp/active/../active", - archivedAt: null, - }, - ], checkDirectoryExists: async () => true, }); diff --git a/packages/server/src/server/workspace-registry-model.ts b/packages/server/src/server/workspace-registry-model.ts index 6af4f7bbc..9d1590be0 100644 --- a/packages/server/src/server/workspace-registry-model.ts +++ b/packages/server/src/server/workspace-registry-model.ts @@ -6,14 +6,8 @@ import type { PersistedWorkspaceRecord } from "./workspace-registry.js"; export type PersistedProjectKind = "git" | "non_git"; export type PersistedWorkspaceKind = "local_checkout" | "worktree" | "directory"; -export type StaleWorkspaceAgentRecord = { - cwd: string; - archivedAt: string | null; -}; - export type DetectStaleWorkspacesInput = { activeWorkspaces: PersistedWorkspaceRecord[]; - agentRecords: StaleWorkspaceAgentRecord[]; checkDirectoryExists: (cwd: string) => Promise; }; @@ -143,28 +137,11 @@ export async function detectStaleWorkspaces( input: DetectStaleWorkspacesInput, ): Promise> { const staleWorkspaceIds = new Set(); - const cwdsWithActiveAgents = new Set(); - const cwdsWithAnyAgent = new Set(); - - for (const agent of input.agentRecords) { - const normalizedCwd = normalizeWorkspaceId(agent.cwd); - cwdsWithAnyAgent.add(normalizedCwd); - if (!agent.archivedAt) { - cwdsWithActiveAgents.add(normalizedCwd); - } - } for (const workspace of input.activeWorkspaces) { const dirExists = await input.checkDirectoryExists(workspace.cwd); if (!dirExists) { staleWorkspaceIds.add(workspace.workspaceId); - continue; - } - - const hasAgents = cwdsWithAnyAgent.has(workspace.workspaceId); - const hasActiveAgents = cwdsWithActiveAgents.has(workspace.workspaceId); - if (hasAgents && !hasActiveAgents) { - staleWorkspaceIds.add(workspace.workspaceId); } }