Skip to content
Merged
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
10 changes: 9 additions & 1 deletion .storybook/mocks/orpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export interface MockORPCClientOptions {
providersConfig?: Record<string, { apiKeySet: boolean; baseUrl?: string; models?: string[] }>;
/** 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 };
}

/**
Expand Down Expand Up @@ -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]));
Expand Down Expand Up @@ -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 }),
Expand Down
4 changes: 2 additions & 2 deletions src/browser/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
44 changes: 44 additions & 0 deletions src/browser/components/PopoverError.tsx
Original file line number Diff line number Diff line change
@@ -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(
<div
role="alert"
aria-live="assertive"
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)]"
style={{
top: `${props.error.position.top}px`,
left: `${props.error.position.left}px`,
}}
>
<span className="text-sm leading-none">⚠</span>
<div className="flex-1 leading-[1.4] break-words whitespace-pre-wrap">
<span className="font-medium">{props.prefix}</span>
<p className="text-light mt-1">{props.error.error}</p>
</div>
{props.onDismiss && (
<button
onClick={props.onDismiss}
aria-label="Dismiss"
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"
>
×
</button>
)}
</div>,
document.body
);
}
86 changes: 29 additions & 57 deletions src/browser/components/ProjectSidebar.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -240,12 +241,8 @@ const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
Record<string, boolean>
>("expandedOldWorkspaces", {});
const [deletingWorkspaceIds, setDeletingWorkspaceIds] = useState<Set<string>>(new Set());
const [removeError, setRemoveError] = useState<{
workspaceId: string;
error: string;
position: { top: number; left: number };
} | null>(null);
const removeErrorTimeoutRef = useRef<number | null>(null);
const workspaceRemoveError = usePopoverError();
const projectRemoveError = usePopoverError();
const [secretsModalState, setSecretsModalState] = useState<{
isOpen: boolean;
projectPath: string;
Expand Down Expand Up @@ -284,39 +281,6 @@ const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
}));
};

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
Expand Down Expand Up @@ -378,7 +342,7 @@ const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
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
Expand Down Expand Up @@ -582,9 +546,20 @@ const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
<button
onClick={(event) => {
event.stopPropagation();
void onRemoveProject(projectPath);
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);
}
})();
}}
title="Remove project"
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"
Expand Down Expand Up @@ -754,19 +729,16 @@ const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
onForceDelete={handleForceDelete}
/>
)}
{removeError &&
createPortal(
<div
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)]"
style={{
top: `${removeError.position.top}px`,
left: `${removeError.position.left}px`,
}}
>
Failed to remove workspace: {removeError.error}
</div>,
document.body
)}
<PopoverError
error={workspaceRemoveError.error}
prefix="Failed to remove workspace"
onDismiss={workspaceRemoveError.clearError}
/>
<PopoverError
error={projectRemoveError.error}
prefix="Failed to remove project"
onDismiss={projectRemoveError.clearError}
/>
</div>
</DndProvider>
</RenameProvider>
Expand Down
12 changes: 8 additions & 4 deletions src/browser/contexts/ProjectContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export interface ProjectContext {
projects: Map<string, ProjectConfig>;
refreshProjects: () => Promise<void>;
addProject: (normalizedPath: string, projectConfig: ProjectConfig) => void;
removeProject: (path: string) => Promise<void>;
removeProject: (path: string) => Promise<{ success: boolean; error?: string }>;

// Project creation modal
isProjectCreateModalOpen: boolean;
Expand Down Expand Up @@ -94,8 +94,8 @@ export function ProjectProvider(props: { children: ReactNode }) {
}, []);

const removeProject = useCallback(
async (path: string) => {
if (!api) return;
async (path: string): Promise<{ success: boolean; error?: string }> => {
if (!api) return { success: false, error: "API not connected" };
try {
const result = await api.projects.remove({ projectPath: path });
if (result.success) {
Expand All @@ -104,11 +104,15 @@ export function ProjectProvider(props: { children: ReactNode }) {
next.delete(path);
return next;
});
return { success: true };
} else {
console.error("Failed to remove project:", result.error);
return { success: false, error: result.error };
}
} catch (error) {
console.error("Failed to remove project:", error);
const errorMessage = error instanceof Error ? error.message : String(error);
console.error("Failed to remove project:", errorMessage);
return { success: false, error: errorMessage };
}
},
[api]
Expand Down
79 changes: 79 additions & 0 deletions src/browser/hooks/usePopoverError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { useCallback, useEffect, useRef, useState } from "react";

export interface PopoverErrorState {
id: string;
error: string;
position: { top: number; left: number };
}

export interface UsePopoverErrorResult {
error: PopoverErrorState | null;
showError: (id: string, error: string, anchor?: { top: number; left: number }) => void;
clearError: () => void;
}

/**
* Hook for managing popover error state with auto-dismiss and click-outside behavior.
* @param autoDismissMs - Time in ms before auto-dismissing (default: 5000)
*/
export function usePopoverError(autoDismissMs = 5000): UsePopoverErrorResult {
const [error, setError] = useState<PopoverErrorState | null>(null);
const timeoutRef = useRef<number | null>(null);

const clearError = useCallback(() => {
setError(null);
if (timeoutRef.current) {
window.clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
}, []);

const showError = useCallback(
(id: string, errorMsg: string, anchor?: { top: number; left: number }) => {
if (timeoutRef.current) {
window.clearTimeout(timeoutRef.current);
}

const position = anchor ?? {
top: window.scrollY + 32,
left: Math.max(window.innerWidth - 420, 16),
};

setError({ id, error: errorMsg, position });

timeoutRef.current = window.setTimeout(() => {
setError(null);
timeoutRef.current = null;
}, autoDismissMs);
},
[autoDismissMs]
);

// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (timeoutRef.current) {
window.clearTimeout(timeoutRef.current);
}
};
}, []);

// Click-outside to dismiss
useEffect(() => {
if (!error) return;

const handleClickOutside = () => clearError();

// Delay to avoid immediate dismissal from the triggering click
const timeoutId = window.setTimeout(() => {
document.addEventListener("click", handleClickOutside, { once: true });
}, 0);

return () => {
window.clearTimeout(timeoutId);
document.removeEventListener("click", handleClickOutside);
};
}, [error, clearError]);

return { error, showError, clearError };
}
Loading