Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
20 changes: 20 additions & 0 deletions packages/web/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -4591,6 +4591,26 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
box-shadow: 0 24px 64px rgb(0 0 0 / 18%);
}

.project-settings-modal--confirm {
width: min(480px, calc(100vw - 48px));
max-height: none;
}

.project-settings-modal__confirm-body {
margin: 0;
padding: 0 20px 18px;
color: var(--color-text-secondary);
font-size: 13px;
line-height: 1.5;
}

.project-settings-modal__confirm-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 0 20px 20px;
}

.project-settings-modal__header {
display: flex;
align-items: flex-start;
Expand Down
41 changes: 32 additions & 9 deletions packages/web/src/components/ProjectSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import { projectDashboardPath, projectReviewPath, projectSessionPath } from "@/l
import { ThemeToggle } from "./ThemeToggle";
import { AddProjectModal } from "./AddProjectModal";
import { ProjectSettingsModal } from "./ProjectSettingsModal";
import { RemoveProjectConfirmModal } from "./RemoveProjectConfirmModal";
import { ToastProvider, useToast } from "./Toast";

/** Minimal shape needed to render an orchestrator link in the sidebar. */
export interface ProjectSidebarOrchestrator {
Expand Down Expand Up @@ -110,7 +112,11 @@ export function ProjectSidebar(props: ProjectSidebarProps) {
if (props.projects.length === 0) {
return <ProjectSidebarEmpty collapsed={props.collapsed} />;
}
return <ProjectSidebarInner {...props} />;
return (
<ToastProvider>
<ProjectSidebarInner {...props} />
</ToastProvider>
);
}

interface SessionRowProps {
Expand Down Expand Up @@ -284,6 +290,7 @@ function ProjectSidebarInner({
onMobileClose,
}: ProjectSidebarProps) {
const router = useRouter();
const { showToast } = useToast();
const _isLoading = loading || sessions === null;

const [expandedProjects, setExpandedProjects] = useState<Set<string>>(
Expand All @@ -304,6 +311,7 @@ function ProjectSidebarInner({
const [projectMenuOpenId, setProjectMenuOpenId] = useState<string | null>(null);
const [projectSettingsProjectId, setProjectSettingsProjectId] = useState<string | null>(null);
const [deletingProjectId, setDeletingProjectId] = useState<string | null>(null);
const [projectPendingRemoval, setProjectPendingRemoval] = useState<ProjectInfo | null>(null);
const [removedProjectIds, setRemovedProjectIds] = useState<Set<string>>(new Set());
const [addProjectOpen, setAddProjectOpen] = useState(false);
const settingsRef = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -565,11 +573,19 @@ function ProjectSidebarInner({
});
};

const handleRemoveProject = async (project: ProjectInfo) => {
const confirmed = window.confirm(
`Remove project ${project.name} from AO? This clears its AO sessions/history and removes it from the portfolio, but keeps the repository folder on disk.`,
);
if (!confirmed) return;
const requestRemoveProject = (project: ProjectInfo) => {
setProjectMenuOpenId(null);
setProjectPendingRemoval(project);
};

const cancelRemoveProject = () => {
if (deletingProjectId) return;
setProjectPendingRemoval(null);
};
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated

const confirmRemoveProject = async () => {
const project = projectPendingRemoval;
if (!project) return;

setDeletingProjectId(project.id);
try {
Expand All @@ -591,15 +607,16 @@ function ProjectSidebarInner({
next.delete(project.id);
return next;
});
setProjectMenuOpenId(null);
setProjectPendingRemoval(null);
if (activeProjectId === project.id) {
router.push("/");
} else if ("refresh" in router && typeof router.refresh === "function") {
router.refresh();
}
onMobileClose?.();
} catch (error) {
window.alert(error instanceof Error ? error.message : "Failed to remove project.");
const message = error instanceof Error ? error.message : "Failed to remove project.";
showToast(message, "error");
} finally {
setDeletingProjectId(null);
}
Expand Down Expand Up @@ -972,7 +989,7 @@ function ProjectSidebarInner({
type="button"
className="project-sidebar__proj-menu-item project-sidebar__proj-menu-item--danger"
role="menuitem"
onClick={() => void handleRemoveProject(project)}
onClick={() => requestRemoveProject(project)}
disabled={deletingProjectId === project.id}
>
{deletingProjectId === project.id ? "Removing..." : "Remove project"}
Expand Down Expand Up @@ -1185,6 +1202,12 @@ function ProjectSidebarInner({
projectId={projectSettingsProjectId}
onClose={() => setProjectSettingsProjectId(null)}
/>
<RemoveProjectConfirmModal
project={projectPendingRemoval}
busy={projectPendingRemoval !== null && deletingProjectId === projectPendingRemoval.id}
onCancel={cancelRemoveProject}
onConfirm={() => void confirmRemoveProject()}
/>
</aside>
);
}
95 changes: 95 additions & 0 deletions packages/web/src/components/RemoveProjectConfirmModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
"use client";

import { useEffect, useRef } from "react";
import type { ProjectInfo } from "@/lib/project-name";

export const REMOVE_PROJECT_CONFIRM_MESSAGE =
"This clears its AO sessions/history and removes it from the portfolio, but keeps the repository folder on disk.";

interface RemoveProjectConfirmModalProps {
project: ProjectInfo | null;
busy: boolean;
onCancel: () => void;
onConfirm: () => void;
}

export function RemoveProjectConfirmModal({
project,
busy,
onCancel,
onConfirm,
}: RemoveProjectConfirmModalProps) {
const modalRef = useRef<HTMLDivElement>(null);

useEffect(() => {
if (!project) return;
modalRef.current?.focus();
}, [project]);

useEffect(() => {
if (!project) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape" && !busy) {
event.preventDefault();
onCancel();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [project, busy, onCancel]);
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated

if (!project) return null;

return (
<div className="project-settings-modal-backdrop" onClick={busy ? undefined : onCancel}>
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby="remove-project-title"
className="project-settings-modal project-settings-modal--confirm"
tabIndex={-1}
onClick={(event) => event.stopPropagation()}
>
<div className="project-settings-modal__header">
<div>
<p className="project-settings-modal__eyebrow">Remove project</p>
<h2 id="remove-project-title" className="project-settings-modal__title">
Remove {project.name}?
</h2>
</div>
<button
type="button"
aria-label="Close"
onClick={onCancel}
disabled={busy}
className="project-settings-modal__close"
>
×
</button>
</div>

<p className="project-settings-modal__confirm-body">{REMOVE_PROJECT_CONFIRM_MESSAGE}</p>

<div className="project-settings-modal__confirm-actions">
<button
type="button"
className="bottom-sheet__btn bottom-sheet__btn--cancel"
onClick={onCancel}
disabled={busy}
>
Cancel
</button>
<button
type="button"
className="bottom-sheet__btn bottom-sheet__btn--danger"
onClick={onConfirm}
disabled={busy}
>
{busy ? "Removing…" : "Remove from AO"}
</button>
</div>
</div>
</div>
);
}
59 changes: 54 additions & 5 deletions packages/web/src/components/__tests__/ProjectSidebar.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { ProjectSidebar } from "@/components/ProjectSidebar";
import { makePR, makeSession } from "@/__tests__/helpers";

Expand Down Expand Up @@ -263,10 +263,6 @@ describe("ProjectSidebar", () => {
json: async () => ({ ok: true }),
});
vi.stubGlobal("fetch", fetchMock);
vi.stubGlobal(
"confirm",
vi.fn(() => true),
);

render(
<ProjectSidebar
Expand All @@ -280,13 +276,66 @@ describe("ProjectSidebar", () => {
fireEvent.click(screen.getByRole("button", { name: /Project actions for Project Two/i }));
fireEvent.click(await screen.findByRole("menuitem", { name: "Remove project" }));

const dialog = await screen.findByRole("dialog", { name: /Remove project/i });
fireEvent.click(within(dialog).getByRole("button", { name: "Remove from AO" }));

await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith("/api/projects/project-2", { method: "DELETE" });
expect(mockRefresh).toHaveBeenCalled();
expect(screen.queryByRole("button", { name: /^Project Two 0$/ })).not.toBeInTheDocument();
});
});

it("cancels remove project without calling the API", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);

render(
<ProjectSidebar
projects={projects}
sessions={[]}
activeProjectId="project-1"
activeSessionId={undefined}
/>,
);

fireEvent.click(screen.getByRole("button", { name: /Project actions for Project Two/i }));
fireEvent.click(await screen.findByRole("menuitem", { name: "Remove project" }));

const dialog = await screen.findByRole("dialog", { name: /Remove project/i });
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));

expect(screen.queryByRole("dialog", { name: /Remove project/i })).not.toBeInTheDocument();
expect(fetchMock).not.toHaveBeenCalled();
});

it("shows a toast when remove project fails", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: false,
json: async () => ({ error: "Cannot remove active project." }),
});
vi.stubGlobal("fetch", fetchMock);

render(
<ProjectSidebar
projects={projects}
sessions={[]}
activeProjectId="project-1"
activeSessionId={undefined}
/>,
);

fireEvent.click(screen.getByRole("button", { name: /Project actions for Project Two/i }));
fireEvent.click(await screen.findByRole("menuitem", { name: "Remove project" }));

const dialog = await screen.findByRole("dialog", { name: /Remove project/i });
fireEvent.click(within(dialog).getByRole("button", { name: "Remove from AO" }));

expect(await screen.findByText("Cannot remove active project.")).toBeInTheDocument();
expect(fetchMock).toHaveBeenCalledWith("/api/projects/project-2", { method: "DELETE" });
expect(screen.getByRole("button", { name: /^Project Two 0$/ })).toBeInTheDocument();
});

it("shows non-done worker sessions for the expanded active project", () => {
render(
<ProjectSidebar
Expand Down