diff --git a/.storybook/mocks/orpc.ts b/.storybook/mocks/orpc.ts index db46ca56ff..dec1c2a70f 100644 --- a/.storybook/mocks/orpc.ts +++ b/.storybook/mocks/orpc.ts @@ -25,6 +25,8 @@ export interface MockORPCClientOptions { providersConfig?: Record; /** List of available provider names */ providersList?: string[]; + /** Mock for projects.remove - return error string to simulate failure */ + onProjectRemove?: (projectPath: string) => { success: true } | { success: false; error: string }; } /** @@ -52,6 +54,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl executeBash, providersConfig = {}, providersList = [], + onProjectRemove, } = options; const workspaceMap = new Map(workspaces.map((w) => [w.id, w])); @@ -99,7 +102,12 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl branches: ["main", "develop", "feature/new-feature"], recommendedTrunk: "main", }), - remove: async () => ({ success: true, data: undefined }), + remove: async (input: { projectPath: string }) => { + if (onProjectRemove) { + return onProjectRemove(input.projectPath); + } + return { success: true, data: undefined }; + }, secrets: { get: async () => [], update: async () => ({ success: true, data: undefined }), diff --git a/src/browser/App.tsx b/src/browser/App.tsx index 20874b4c3b..280a728961 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -183,11 +183,11 @@ function AppInner() { const openWorkspaceInTerminal = useOpenTerminal(); const handleRemoveProject = useCallback( - async (path: string) => { + async (path: string): Promise<{ success: boolean; error?: string }> => { if (selectedWorkspace?.projectPath === path) { setSelectedWorkspace(null); } - await removeProject(path); + return removeProject(path); }, // eslint-disable-next-line react-hooks/exhaustive-deps [selectedWorkspace, setSelectedWorkspace] diff --git a/src/browser/components/PopoverError.tsx b/src/browser/components/PopoverError.tsx new file mode 100644 index 0000000000..f7cd4e9824 --- /dev/null +++ b/src/browser/components/PopoverError.tsx @@ -0,0 +1,44 @@ +import { createPortal } from "react-dom"; +import type { PopoverErrorState } from "@/browser/hooks/usePopoverError"; + +interface PopoverErrorProps { + error: PopoverErrorState | null; + prefix: string; + onDismiss?: () => void; +} + +/** + * Floating error popover that displays near the trigger element. + * Styled to match the app's toast error design. + */ +export function PopoverError(props: PopoverErrorProps) { + if (!props.error) return null; + + return createPortal( +
+ +
+ {props.prefix} +

{props.error.error}

+
+ {props.onDismiss && ( + + )} +
, + document.body + ); +} diff --git a/src/browser/components/ProjectSidebar.tsx b/src/browser/components/ProjectSidebar.tsx index fc106b9a9f..2ff5e5e870 100644 --- a/src/browser/components/ProjectSidebar.tsx +++ b/src/browser/components/ProjectSidebar.tsx @@ -1,5 +1,4 @@ -import React, { useState, useEffect, useCallback, useRef } from "react"; -import { createPortal } from "react-dom"; +import React, { useState, useEffect, useCallback } from "react"; import { cn } from "@/common/lib/utils"; import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; import { usePersistedState } from "@/browser/hooks/usePersistedState"; @@ -28,6 +27,8 @@ import { RenameProvider } from "@/browser/contexts/WorkspaceRenameContext"; import { useProjectContext } from "@/browser/contexts/ProjectContext"; import { ChevronRight, KeyRound } from "lucide-react"; import { useWorkspaceContext } from "@/browser/contexts/WorkspaceContext"; +import { usePopoverError } from "@/browser/hooks/usePopoverError"; +import { PopoverError } from "./PopoverError"; // Re-export WorkspaceSelection for backwards compatibility export type { WorkspaceSelection } from "./WorkspaceListItem"; @@ -240,12 +241,8 @@ const ProjectSidebarInner: React.FC = ({ Record >("expandedOldWorkspaces", {}); const [deletingWorkspaceIds, setDeletingWorkspaceIds] = useState>(new Set()); - const [removeError, setRemoveError] = useState<{ - workspaceId: string; - error: string; - position: { top: number; left: number }; - } | null>(null); - const removeErrorTimeoutRef = useRef(null); + const workspaceRemoveError = usePopoverError(); + const projectRemoveError = usePopoverError(); const [secretsModalState, setSecretsModalState] = useState<{ isOpen: boolean; projectPath: string; @@ -284,39 +281,6 @@ const ProjectSidebarInner: React.FC = ({ })); }; - const showRemoveError = useCallback( - (workspaceId: string, error: string, anchor?: { top: number; left: number }) => { - if (removeErrorTimeoutRef.current) { - window.clearTimeout(removeErrorTimeoutRef.current); - } - - const position = anchor ?? { - top: window.scrollY + 32, - left: Math.max(window.innerWidth - 420, 16), - }; - - setRemoveError({ - workspaceId, - error, - position, - }); - - removeErrorTimeoutRef.current = window.setTimeout(() => { - setRemoveError(null); - removeErrorTimeoutRef.current = null; - }, 5000); - }, - [] - ); - - useEffect(() => { - return () => { - if (removeErrorTimeoutRef.current) { - window.clearTimeout(removeErrorTimeoutRef.current); - } - }; - }, []); - const handleRemoveWorkspace = useCallback( async (workspaceId: string, buttonElement: HTMLElement) => { // Mark workspace as being deleted for UI feedback @@ -378,7 +342,7 @@ const ProjectSidebarInner: React.FC = ({ const errorMessage = result.error ?? "Failed to remove workspace"; console.error("Force delete failed:", result.error); - showRemoveError(workspaceId, errorMessage, modalState?.anchor ?? undefined); + workspaceRemoveError.showError(workspaceId, errorMessage, modalState?.anchor ?? undefined); } } finally { // Clear deleting state @@ -582,9 +546,20 @@ const ProjectSidebarInner: React.FC = ({