Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 66 additions & 28 deletions src/browser/components/ProjectSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@ const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
archiveWorkspace: onArchiveWorkspace,
renameWorkspace: onRenameWorkspace,
beginWorkspaceCreation: onAddWorkspace,
archivedCountByProject,
} = useWorkspaceContext();

// Get project state and operations from context
Expand Down Expand Up @@ -518,34 +519,71 @@ const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
</TooltipTrigger>
<TooltipContent align="end">Manage secrets</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={(event) => {
event.stopPropagation();
const buttonElement = event.currentTarget;
void (async () => {
const result = await onRemoveProject(projectPath);
if (!result.success) {
const error = result.error ?? "Failed to remove project";
const rect = buttonElement.getBoundingClientRect();
const anchor = {
top: rect.top + window.scrollY,
left: rect.right + 10,
};
projectRemoveError.showError(projectPath, error, anchor);
}
})();
}}
aria-label={`Remove project ${projectName}`}
data-project-path={projectPath}
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"
>
×
</button>
</TooltipTrigger>
<TooltipContent align="end">Remove project</TooltipContent>
</Tooltip>
{(() => {
// Compute workspace counts for removal eligibility
const activeCount =
sortedWorkspacesByProject.get(projectPath)?.length ?? 0;
const archivedCount = archivedCountByProject.get(projectPath) ?? 0;
const canDelete = activeCount === 0 && archivedCount === 0;

// Build tooltip based on what's blocking deletion
let tooltip: string;
if (canDelete) {
tooltip = "Remove project";
} else if (archivedCount === 0) {
// Only active workspaces
tooltip =
activeCount === 1
? "Delete workspace first"
: `Delete all ${activeCount} workspaces first`;
} else if (activeCount === 0) {
// Only archived workspaces
tooltip =
archivedCount === 1
? "Delete archived workspace first"
: `Delete ${archivedCount} archived workspaces first`;
} else {
// Both active and archived
tooltip = `Delete ${activeCount} active + ${archivedCount} archived workspaces first`;
}

return (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={(event) => {
event.stopPropagation();
if (!canDelete) return;
const buttonElement = event.currentTarget;
void (async () => {
const result = await onRemoveProject(projectPath);
if (!result.success) {
const error = result.error ?? "Failed to remove project";
const rect = buttonElement.getBoundingClientRect();
const anchor = {
top: rect.top + window.scrollY,
left: rect.right + 10,
};
projectRemoveError.showError(projectPath, error, anchor);
}
})();
}}
aria-label={`Remove project ${projectName}`}
aria-disabled={!canDelete}
data-project-path={projectPath}
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 ${
canDelete
? "text-muted-dark hover:text-danger-light hover:bg-danger-light/10 cursor-pointer"
: "text-muted-dark/50 cursor-not-allowed"
}`}
>
×
</button>
</TooltipTrigger>
<TooltipContent align="end">{tooltip}</TooltipContent>
</Tooltip>
);
})()}
<button
onClick={(event) => {
event.stopPropagation();
Expand Down
21 changes: 20 additions & 1 deletion src/browser/contexts/WorkspaceContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ function ensureCreatedAt(metadata: FrontendWorkspaceMetadata): void {
export interface WorkspaceContext {
// Workspace data
workspaceMetadata: Map<string, FrontendWorkspaceMetadata>;
archivedCountByProject: Map<string, number>;
loading: boolean;

// Workspace operations
Expand Down Expand Up @@ -161,6 +162,9 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) {
);
const [loading, setLoading] = useState(true);
const [pendingNewWorkspaceProject, setPendingNewWorkspaceProject] = useState<string | null>(null);
const [archivedCountByProject, setArchivedCountByProject] = useState<Map<string, number>>(
new Map()
);

// Manage selected workspace internally with localStorage persistence
const [selectedWorkspace, setSelectedWorkspace] = usePersistedState<WorkspaceSelection | null>(
Expand All @@ -179,7 +183,11 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) {
if (!api) return false; // Return false to indicate metadata wasn't loaded
try {
const includePostCompaction = isExperimentEnabled(EXPERIMENT_IDS.POST_COMPACTION_CONTEXT);
const metadataList = await api.workspace.list({ includePostCompaction });
// Fetch active and archived workspaces in parallel
const [metadataList, archivedList] = await Promise.all([
api.workspace.list({ includePostCompaction }),
api.workspace.list({ archived: true }),
]);
console.log(
"[WorkspaceContext] Loaded metadata list:",
metadataList.map((m) => ({ id: m.id, name: m.name, title: m.title }))
Expand All @@ -194,10 +202,19 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) {
metadataMap.set(metadata.id, metadata);
}
setWorkspaceMetadata(metadataMap);

// Compute archived counts by project
const archivedCounts = new Map<string, number>();
for (const ws of archivedList) {
archivedCounts.set(ws.projectPath, (archivedCounts.get(ws.projectPath) ?? 0) + 1);
}
setArchivedCountByProject(archivedCounts);

return true; // Return true to indicate metadata was loaded
} catch (error) {
console.error("Failed to load workspace metadata:", error);
setWorkspaceMetadata(new Map());
setArchivedCountByProject(new Map());
return true; // Still return true - we tried to load, just got empty result
}
}, [setWorkspaceMetadata, api]);
Expand Down Expand Up @@ -607,6 +624,7 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) {
const value = useMemo<WorkspaceContext>(
() => ({
workspaceMetadata,
archivedCountByProject,
loading,
createWorkspace,
removeWorkspace,
Expand All @@ -624,6 +642,7 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) {
}),
[
workspaceMetadata,
archivedCountByProject,
loading,
createWorkspace,
removeWorkspace,
Expand Down
23 changes: 8 additions & 15 deletions src/browser/stories/App.errors.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -248,12 +248,12 @@ export const LargeDiff: AppStory = {
};

/**
* Project removal error popover.
* Project removal disabled state.
*
* Shows the error popup when attempting to remove a project that has active workspaces.
* The play function hovers the project and clicks the remove button to trigger the error.
* Verifies that the "Remove project" button is disabled when workspaces exist.
* The button is aria-disabled and styled as not-allowed, preventing the backend call.
*/
export const ProjectRemovalError: AppStory = {
export const ProjectRemovalDisabled: AppStory = {
render: () => (
<AppWithMocks
setup={() => {
Expand All @@ -268,11 +268,6 @@ export const ProjectRemovalError: AppStory = {
return createMockORPCClient({
projects: groupWorkspacesByProject(workspaces),
workspaces,
onProjectRemove: () => ({
success: false,
error:
"Cannot remove project with active workspaces. Please remove all 2 workspace(s) first.",
}),
});
}}
/>
Expand All @@ -297,14 +292,12 @@ export const ProjectRemovalError: AppStory = {
// Small delay for hover state to apply
await new Promise((r) => setTimeout(r, 100));

// Click the remove button
await userEvent.click(removeButton);

// Wait for the error popover to appear
// Verify the button is disabled (aria-disabled="true")
await waitFor(
() => {
const errorPopover = document.querySelector('[role="alert"]');
if (!errorPopover) throw new Error("Error popover not found");
if (removeButton.getAttribute("aria-disabled") !== "true") {
throw new Error("Remove button should be aria-disabled when workspaces exist");
}
},
{ timeout: 2000 }
);
Expand Down