Skip to content

Commit bce0bda

Browse files
authored
🤖 fix: show removeProject errors in UI instead of only console (#931)
When removing a project fails (e.g., when it has active workspaces), the error was only logged to the console and not shown in the UI. ## Changes - Changed `removeProject` to return `{ success, error }` result instead of `void` - Added `projectRemoveError` state and `showProjectRemoveError` helper in `ProjectSidebar` - Display error popup near the remove button when project removal fails - Matches the existing pattern used for workspace removal errors The error message now appears as a floating popup near the remove button, auto-dismissing after 5 seconds (same behavior as workspace removal errors). _Generated with `mux`_
1 parent c65df0d commit bce0bda

File tree

7 files changed

+242
-65
lines changed

7 files changed

+242
-65
lines changed

.storybook/mocks/orpc.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ export interface MockORPCClientOptions {
2525
providersConfig?: Record<string, { apiKeySet: boolean; baseUrl?: string; models?: string[] }>;
2626
/** List of available provider names */
2727
providersList?: string[];
28+
/** Mock for projects.remove - return error string to simulate failure */
29+
onProjectRemove?: (projectPath: string) => { success: true } | { success: false; error: string };
2830
}
2931

3032
/**
@@ -52,6 +54,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
5254
executeBash,
5355
providersConfig = {},
5456
providersList = [],
57+
onProjectRemove,
5558
} = options;
5659

5760
const workspaceMap = new Map(workspaces.map((w) => [w.id, w]));
@@ -99,7 +102,12 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
99102
branches: ["main", "develop", "feature/new-feature"],
100103
recommendedTrunk: "main",
101104
}),
102-
remove: async () => ({ success: true, data: undefined }),
105+
remove: async (input: { projectPath: string }) => {
106+
if (onProjectRemove) {
107+
return onProjectRemove(input.projectPath);
108+
}
109+
return { success: true, data: undefined };
110+
},
103111
secrets: {
104112
get: async () => [],
105113
update: async () => ({ success: true, data: undefined }),

src/browser/App.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -183,11 +183,11 @@ function AppInner() {
183183
const openWorkspaceInTerminal = useOpenTerminal();
184184

185185
const handleRemoveProject = useCallback(
186-
async (path: string) => {
186+
async (path: string): Promise<{ success: boolean; error?: string }> => {
187187
if (selectedWorkspace?.projectPath === path) {
188188
setSelectedWorkspace(null);
189189
}
190-
await removeProject(path);
190+
return removeProject(path);
191191
},
192192
// eslint-disable-next-line react-hooks/exhaustive-deps
193193
[selectedWorkspace, setSelectedWorkspace]
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { createPortal } from "react-dom";
2+
import type { PopoverErrorState } from "@/browser/hooks/usePopoverError";
3+
4+
interface PopoverErrorProps {
5+
error: PopoverErrorState | null;
6+
prefix: string;
7+
onDismiss?: () => void;
8+
}
9+
10+
/**
11+
* Floating error popover that displays near the trigger element.
12+
* Styled to match the app's toast error design.
13+
*/
14+
export function PopoverError(props: PopoverErrorProps) {
15+
if (!props.error) return null;
16+
17+
return createPortal(
18+
<div
19+
role="alert"
20+
aria-live="assertive"
21+
className="bg-dark border-toast-error-border text-toast-error-text pointer-events-auto fixed z-[10000] flex max-w-80 animate-[toastSlideIn_0.2s_ease-out] items-start gap-2 rounded border px-3 py-2 text-xs shadow-[0_4px_12px_rgba(0,0,0,0.3)]"
22+
style={{
23+
top: `${props.error.position.top}px`,
24+
left: `${props.error.position.left}px`,
25+
}}
26+
>
27+
<span className="text-sm leading-none"></span>
28+
<div className="flex-1 leading-[1.4] break-words whitespace-pre-wrap">
29+
<span className="font-medium">{props.prefix}</span>
30+
<p className="text-light mt-1">{props.error.error}</p>
31+
</div>
32+
{props.onDismiss && (
33+
<button
34+
onClick={props.onDismiss}
35+
aria-label="Dismiss"
36+
className="flex h-4 w-4 shrink-0 cursor-pointer items-center justify-center border-0 bg-transparent p-0 text-base leading-none text-inherit opacity-60 transition-opacity hover:opacity-100"
37+
>
38+
×
39+
</button>
40+
)}
41+
</div>,
42+
document.body
43+
);
44+
}

src/browser/components/ProjectSidebar.tsx

Lines changed: 29 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
import React, { useState, useEffect, useCallback, useRef } from "react";
2-
import { createPortal } from "react-dom";
1+
import React, { useState, useEffect, useCallback } from "react";
32
import { cn } from "@/common/lib/utils";
43
import type { FrontendWorkspaceMetadata } from "@/common/types/workspace";
54
import { usePersistedState } from "@/browser/hooks/usePersistedState";
@@ -28,6 +27,8 @@ import { RenameProvider } from "@/browser/contexts/WorkspaceRenameContext";
2827
import { useProjectContext } from "@/browser/contexts/ProjectContext";
2928
import { ChevronRight, KeyRound } from "lucide-react";
3029
import { useWorkspaceContext } from "@/browser/contexts/WorkspaceContext";
30+
import { usePopoverError } from "@/browser/hooks/usePopoverError";
31+
import { PopoverError } from "./PopoverError";
3132

3233
// Re-export WorkspaceSelection for backwards compatibility
3334
export type { WorkspaceSelection } from "./WorkspaceListItem";
@@ -240,12 +241,8 @@ const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
240241
Record<string, boolean>
241242
>("expandedOldWorkspaces", {});
242243
const [deletingWorkspaceIds, setDeletingWorkspaceIds] = useState<Set<string>>(new Set());
243-
const [removeError, setRemoveError] = useState<{
244-
workspaceId: string;
245-
error: string;
246-
position: { top: number; left: number };
247-
} | null>(null);
248-
const removeErrorTimeoutRef = useRef<number | null>(null);
244+
const workspaceRemoveError = usePopoverError();
245+
const projectRemoveError = usePopoverError();
249246
const [secretsModalState, setSecretsModalState] = useState<{
250247
isOpen: boolean;
251248
projectPath: string;
@@ -284,39 +281,6 @@ const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
284281
}));
285282
};
286283

287-
const showRemoveError = useCallback(
288-
(workspaceId: string, error: string, anchor?: { top: number; left: number }) => {
289-
if (removeErrorTimeoutRef.current) {
290-
window.clearTimeout(removeErrorTimeoutRef.current);
291-
}
292-
293-
const position = anchor ?? {
294-
top: window.scrollY + 32,
295-
left: Math.max(window.innerWidth - 420, 16),
296-
};
297-
298-
setRemoveError({
299-
workspaceId,
300-
error,
301-
position,
302-
});
303-
304-
removeErrorTimeoutRef.current = window.setTimeout(() => {
305-
setRemoveError(null);
306-
removeErrorTimeoutRef.current = null;
307-
}, 5000);
308-
},
309-
[]
310-
);
311-
312-
useEffect(() => {
313-
return () => {
314-
if (removeErrorTimeoutRef.current) {
315-
window.clearTimeout(removeErrorTimeoutRef.current);
316-
}
317-
};
318-
}, []);
319-
320284
const handleRemoveWorkspace = useCallback(
321285
async (workspaceId: string, buttonElement: HTMLElement) => {
322286
// Mark workspace as being deleted for UI feedback
@@ -378,7 +342,7 @@ const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
378342
const errorMessage = result.error ?? "Failed to remove workspace";
379343
console.error("Force delete failed:", result.error);
380344

381-
showRemoveError(workspaceId, errorMessage, modalState?.anchor ?? undefined);
345+
workspaceRemoveError.showError(workspaceId, errorMessage, modalState?.anchor ?? undefined);
382346
}
383347
} finally {
384348
// Clear deleting state
@@ -582,9 +546,20 @@ const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
582546
<button
583547
onClick={(event) => {
584548
event.stopPropagation();
585-
void onRemoveProject(projectPath);
549+
const buttonElement = event.currentTarget;
550+
void (async () => {
551+
const result = await onRemoveProject(projectPath);
552+
if (!result.success) {
553+
const error = result.error ?? "Failed to remove project";
554+
const rect = buttonElement.getBoundingClientRect();
555+
const anchor = {
556+
top: rect.top + window.scrollY,
557+
left: rect.right + 10,
558+
};
559+
projectRemoveError.showError(projectPath, error, anchor);
560+
}
561+
})();
586562
}}
587-
title="Remove project"
588563
aria-label={`Remove project ${projectName}`}
589564
data-project-path={projectPath}
590565
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"
@@ -754,19 +729,16 @@ const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
754729
onForceDelete={handleForceDelete}
755730
/>
756731
)}
757-
{removeError &&
758-
createPortal(
759-
<div
760-
className="bg-error-bg border-error text-error font-monospace pointer-events-auto fixed z-[10000] max-w-96 rounded-md border p-3 px-4 text-xs leading-[1.4] break-words whitespace-pre-wrap shadow-[0_4px_16px_rgba(0,0,0,0.5)]"
761-
style={{
762-
top: `${removeError.position.top}px`,
763-
left: `${removeError.position.left}px`,
764-
}}
765-
>
766-
Failed to remove workspace: {removeError.error}
767-
</div>,
768-
document.body
769-
)}
732+
<PopoverError
733+
error={workspaceRemoveError.error}
734+
prefix="Failed to remove workspace"
735+
onDismiss={workspaceRemoveError.clearError}
736+
/>
737+
<PopoverError
738+
error={projectRemoveError.error}
739+
prefix="Failed to remove project"
740+
onDismiss={projectRemoveError.clearError}
741+
/>
770742
</div>
771743
</DndProvider>
772744
</RenameProvider>

src/browser/contexts/ProjectContext.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export interface ProjectContext {
2727
projects: Map<string, ProjectConfig>;
2828
refreshProjects: () => Promise<void>;
2929
addProject: (normalizedPath: string, projectConfig: ProjectConfig) => void;
30-
removeProject: (path: string) => Promise<void>;
30+
removeProject: (path: string) => Promise<{ success: boolean; error?: string }>;
3131

3232
// Project creation modal
3333
isProjectCreateModalOpen: boolean;
@@ -94,8 +94,8 @@ export function ProjectProvider(props: { children: ReactNode }) {
9494
}, []);
9595

9696
const removeProject = useCallback(
97-
async (path: string) => {
98-
if (!api) return;
97+
async (path: string): Promise<{ success: boolean; error?: string }> => {
98+
if (!api) return { success: false, error: "API not connected" };
9999
try {
100100
const result = await api.projects.remove({ projectPath: path });
101101
if (result.success) {
@@ -104,11 +104,15 @@ export function ProjectProvider(props: { children: ReactNode }) {
104104
next.delete(path);
105105
return next;
106106
});
107+
return { success: true };
107108
} else {
108109
console.error("Failed to remove project:", result.error);
110+
return { success: false, error: result.error };
109111
}
110112
} catch (error) {
111-
console.error("Failed to remove project:", error);
113+
const errorMessage = error instanceof Error ? error.message : String(error);
114+
console.error("Failed to remove project:", errorMessage);
115+
return { success: false, error: errorMessage };
112116
}
113117
},
114118
[api]
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { useCallback, useEffect, useRef, useState } from "react";
2+
3+
export interface PopoverErrorState {
4+
id: string;
5+
error: string;
6+
position: { top: number; left: number };
7+
}
8+
9+
export interface UsePopoverErrorResult {
10+
error: PopoverErrorState | null;
11+
showError: (id: string, error: string, anchor?: { top: number; left: number }) => void;
12+
clearError: () => void;
13+
}
14+
15+
/**
16+
* Hook for managing popover error state with auto-dismiss and click-outside behavior.
17+
* @param autoDismissMs - Time in ms before auto-dismissing (default: 5000)
18+
*/
19+
export function usePopoverError(autoDismissMs = 5000): UsePopoverErrorResult {
20+
const [error, setError] = useState<PopoverErrorState | null>(null);
21+
const timeoutRef = useRef<number | null>(null);
22+
23+
const clearError = useCallback(() => {
24+
setError(null);
25+
if (timeoutRef.current) {
26+
window.clearTimeout(timeoutRef.current);
27+
timeoutRef.current = null;
28+
}
29+
}, []);
30+
31+
const showError = useCallback(
32+
(id: string, errorMsg: string, anchor?: { top: number; left: number }) => {
33+
if (timeoutRef.current) {
34+
window.clearTimeout(timeoutRef.current);
35+
}
36+
37+
const position = anchor ?? {
38+
top: window.scrollY + 32,
39+
left: Math.max(window.innerWidth - 420, 16),
40+
};
41+
42+
setError({ id, error: errorMsg, position });
43+
44+
timeoutRef.current = window.setTimeout(() => {
45+
setError(null);
46+
timeoutRef.current = null;
47+
}, autoDismissMs);
48+
},
49+
[autoDismissMs]
50+
);
51+
52+
// Cleanup timeout on unmount
53+
useEffect(() => {
54+
return () => {
55+
if (timeoutRef.current) {
56+
window.clearTimeout(timeoutRef.current);
57+
}
58+
};
59+
}, []);
60+
61+
// Click-outside to dismiss
62+
useEffect(() => {
63+
if (!error) return;
64+
65+
const handleClickOutside = () => clearError();
66+
67+
// Delay to avoid immediate dismissal from the triggering click
68+
const timeoutId = window.setTimeout(() => {
69+
document.addEventListener("click", handleClickOutside, { once: true });
70+
}, 0);
71+
72+
return () => {
73+
window.clearTimeout(timeoutId);
74+
document.removeEventListener("click", handleClickOutside);
75+
};
76+
}, [error, clearError]);
77+
78+
return { error, showError, clearError };
79+
}

0 commit comments

Comments
 (0)