Skip to content

Commit b08a70e

Browse files
committed
🤖 fix: disable project removal when workspaces exist
- Remove project button is disabled when project has any workspaces (active or archived) - Shows explanatory tooltip based on workspace type: - 'Delete workspace first' (1 active workspace) - 'Delete all N workspaces first' (multiple active) - 'Delete N archived workspaces first' (archived only) - 'Delete N active + M archived workspaces first' (both) - Added archivedCountByProject to WorkspaceContext for efficient archived count tracking - Button uses aria-disabled and cursor-not-allowed styling when disabled --- _Generated with `mux` • Model: `anthropic:claude-opus-4-5` • Thinking: `high`_
1 parent 18a7d72 commit b08a70e

File tree

3 files changed

+94
-44
lines changed

3 files changed

+94
-44
lines changed

src/browser/components/ProjectSidebar.tsx

Lines changed: 66 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,7 @@ const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
200200
archiveWorkspace: onArchiveWorkspace,
201201
renameWorkspace: onRenameWorkspace,
202202
beginWorkspaceCreation: onAddWorkspace,
203+
archivedCountByProject,
203204
} = useWorkspaceContext();
204205

205206
// Get project state and operations from context
@@ -518,34 +519,71 @@ const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
518519
</TooltipTrigger>
519520
<TooltipContent align="end">Manage secrets</TooltipContent>
520521
</Tooltip>
521-
<Tooltip>
522-
<TooltipTrigger asChild>
523-
<button
524-
onClick={(event) => {
525-
event.stopPropagation();
526-
const buttonElement = event.currentTarget;
527-
void (async () => {
528-
const result = await onRemoveProject(projectPath);
529-
if (!result.success) {
530-
const error = result.error ?? "Failed to remove project";
531-
const rect = buttonElement.getBoundingClientRect();
532-
const anchor = {
533-
top: rect.top + window.scrollY,
534-
left: rect.right + 10,
535-
};
536-
projectRemoveError.showError(projectPath, error, anchor);
537-
}
538-
})();
539-
}}
540-
aria-label={`Remove project ${projectName}`}
541-
data-project-path={projectPath}
542-
className="text-muted-dark hover:text-danger-light hover:bg-danger-light/10 mr-1 flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center rounded-[3px] border-none bg-transparent text-base opacity-0 transition-all duration-200"
543-
>
544-
×
545-
</button>
546-
</TooltipTrigger>
547-
<TooltipContent align="end">Remove project</TooltipContent>
548-
</Tooltip>
522+
{(() => {
523+
// Compute workspace counts for removal eligibility
524+
const activeCount =
525+
sortedWorkspacesByProject.get(projectPath)?.length ?? 0;
526+
const archivedCount = archivedCountByProject.get(projectPath) ?? 0;
527+
const canDelete = activeCount === 0 && archivedCount === 0;
528+
529+
// Build tooltip based on what's blocking deletion
530+
let tooltip: string;
531+
if (canDelete) {
532+
tooltip = "Remove project";
533+
} else if (archivedCount === 0) {
534+
// Only active workspaces
535+
tooltip =
536+
activeCount === 1
537+
? "Delete workspace first"
538+
: `Delete all ${activeCount} workspaces first`;
539+
} else if (activeCount === 0) {
540+
// Only archived workspaces
541+
tooltip =
542+
archivedCount === 1
543+
? "Delete archived workspace first"
544+
: `Delete ${archivedCount} archived workspaces first`;
545+
} else {
546+
// Both active and archived
547+
tooltip = `Delete ${activeCount} active + ${archivedCount} archived workspaces first`;
548+
}
549+
550+
return (
551+
<Tooltip>
552+
<TooltipTrigger asChild>
553+
<button
554+
onClick={(event) => {
555+
event.stopPropagation();
556+
if (!canDelete) return;
557+
const buttonElement = event.currentTarget;
558+
void (async () => {
559+
const result = await onRemoveProject(projectPath);
560+
if (!result.success) {
561+
const error = result.error ?? "Failed to remove project";
562+
const rect = buttonElement.getBoundingClientRect();
563+
const anchor = {
564+
top: rect.top + window.scrollY,
565+
left: rect.right + 10,
566+
};
567+
projectRemoveError.showError(projectPath, error, anchor);
568+
}
569+
})();
570+
}}
571+
aria-label={`Remove project ${projectName}`}
572+
aria-disabled={!canDelete}
573+
data-project-path={projectPath}
574+
className={`mr-1 flex h-5 w-5 shrink-0 items-center justify-center rounded-[3px] border-none bg-transparent text-base opacity-0 transition-all duration-200 ${
575+
canDelete
576+
? "text-muted-dark hover:text-danger-light hover:bg-danger-light/10 cursor-pointer"
577+
: "text-muted-dark/50 cursor-not-allowed"
578+
}`}
579+
>
580+
×
581+
</button>
582+
</TooltipTrigger>
583+
<TooltipContent align="end">{tooltip}</TooltipContent>
584+
</Tooltip>
585+
);
586+
})()}
549587
<button
550588
onClick={(event) => {
551589
event.stopPropagation();

src/browser/contexts/WorkspaceContext.tsx

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ function ensureCreatedAt(metadata: FrontendWorkspaceMetadata): void {
8787
export interface WorkspaceContext {
8888
// Workspace data
8989
workspaceMetadata: Map<string, FrontendWorkspaceMetadata>;
90+
archivedCountByProject: Map<string, number>;
9091
loading: boolean;
9192

9293
// Workspace operations
@@ -161,6 +162,9 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) {
161162
);
162163
const [loading, setLoading] = useState(true);
163164
const [pendingNewWorkspaceProject, setPendingNewWorkspaceProject] = useState<string | null>(null);
165+
const [archivedCountByProject, setArchivedCountByProject] = useState<Map<string, number>>(
166+
new Map()
167+
);
164168

165169
// Manage selected workspace internally with localStorage persistence
166170
const [selectedWorkspace, setSelectedWorkspace] = usePersistedState<WorkspaceSelection | null>(
@@ -179,7 +183,11 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) {
179183
if (!api) return false; // Return false to indicate metadata wasn't loaded
180184
try {
181185
const includePostCompaction = isExperimentEnabled(EXPERIMENT_IDS.POST_COMPACTION_CONTEXT);
182-
const metadataList = await api.workspace.list({ includePostCompaction });
186+
// Fetch active and archived workspaces in parallel
187+
const [metadataList, archivedList] = await Promise.all([
188+
api.workspace.list({ includePostCompaction }),
189+
api.workspace.list({ archived: true }),
190+
]);
183191
console.log(
184192
"[WorkspaceContext] Loaded metadata list:",
185193
metadataList.map((m) => ({ id: m.id, name: m.name, title: m.title }))
@@ -194,10 +202,19 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) {
194202
metadataMap.set(metadata.id, metadata);
195203
}
196204
setWorkspaceMetadata(metadataMap);
205+
206+
// Compute archived counts by project
207+
const archivedCounts = new Map<string, number>();
208+
for (const ws of archivedList) {
209+
archivedCounts.set(ws.projectPath, (archivedCounts.get(ws.projectPath) ?? 0) + 1);
210+
}
211+
setArchivedCountByProject(archivedCounts);
212+
197213
return true; // Return true to indicate metadata was loaded
198214
} catch (error) {
199215
console.error("Failed to load workspace metadata:", error);
200216
setWorkspaceMetadata(new Map());
217+
setArchivedCountByProject(new Map());
201218
return true; // Still return true - we tried to load, just got empty result
202219
}
203220
}, [setWorkspaceMetadata, api]);
@@ -607,6 +624,7 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) {
607624
const value = useMemo<WorkspaceContext>(
608625
() => ({
609626
workspaceMetadata,
627+
archivedCountByProject,
610628
loading,
611629
createWorkspace,
612630
removeWorkspace,
@@ -624,6 +642,7 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) {
624642
}),
625643
[
626644
workspaceMetadata,
645+
archivedCountByProject,
627646
loading,
628647
createWorkspace,
629648
removeWorkspace,

src/browser/stories/App.errors.stories.tsx

Lines changed: 8 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -248,12 +248,12 @@ export const LargeDiff: AppStory = {
248248
};
249249

250250
/**
251-
* Project removal error popover.
251+
* Project removal disabled state.
252252
*
253-
* Shows the error popup when attempting to remove a project that has active workspaces.
254-
* The play function hovers the project and clicks the remove button to trigger the error.
253+
* Verifies that the "Remove project" button is disabled when workspaces exist.
254+
* The button is aria-disabled and styled as not-allowed, preventing the backend call.
255255
*/
256-
export const ProjectRemovalError: AppStory = {
256+
export const ProjectRemovalDisabled: AppStory = {
257257
render: () => (
258258
<AppWithMocks
259259
setup={() => {
@@ -268,11 +268,6 @@ export const ProjectRemovalError: AppStory = {
268268
return createMockORPCClient({
269269
projects: groupWorkspacesByProject(workspaces),
270270
workspaces,
271-
onProjectRemove: () => ({
272-
success: false,
273-
error:
274-
"Cannot remove project with active workspaces. Please remove all 2 workspace(s) first.",
275-
}),
276271
});
277272
}}
278273
/>
@@ -297,14 +292,12 @@ export const ProjectRemovalError: AppStory = {
297292
// Small delay for hover state to apply
298293
await new Promise((r) => setTimeout(r, 100));
299294

300-
// Click the remove button
301-
await userEvent.click(removeButton);
302-
303-
// Wait for the error popover to appear
295+
// Verify the button is disabled (aria-disabled="true")
304296
await waitFor(
305297
() => {
306-
const errorPopover = document.querySelector('[role="alert"]');
307-
if (!errorPopover) throw new Error("Error popover not found");
298+
if (removeButton.getAttribute("aria-disabled") !== "true") {
299+
throw new Error("Remove button should be aria-disabled when workspaces exist");
300+
}
308301
},
309302
{ timeout: 2000 }
310303
);

0 commit comments

Comments
 (0)