diff --git a/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx b/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx index 3cd5d6c8f7..ae7fda18a0 100644 --- a/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx +++ b/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx @@ -1226,7 +1226,7 @@ describe("ProjectSidebar multi-project completed-subagent toggles", () => { expect(parentRow.getAttribute("aria-describedby")).toBe("workspace-status-description-parent"); }); - test("renders variants groups with a shared row and labeled members when expanded", async () => { + test("archives a variants group from its context menu and renders labeled members when expanded", async () => { window.localStorage.setItem(EXPANDED_PROJECTS_KEY, JSON.stringify(["/projects/demo-project"])); const singleProjectRefs = [ @@ -1322,7 +1322,34 @@ describe("ProjectSidebar multi-project completed-subagent toggles", () => { expect(view.queryByTestId(agentItemTestId("child-1"))).toBeNull(); expect(view.queryByTestId(agentItemTestId("child-2"))).toBeNull(); - fireEvent.click(groupRow); + fireEvent.contextMenu(groupRow, { clientX: 120, clientY: 80 }); + const archiveAllVariants = view.getByRole("button", { name: /Archive all variants/ }); + fireEvent.click(archiveAllVariants); + + await waitFor(() => { + expect(preflightArchiveWorkspaceMock).toHaveBeenCalledTimes(2); + expect(archiveWorkspaceActionMock).toHaveBeenCalledTimes(2); + }); + expect(preflightArchiveWorkspaceMock).toHaveBeenNthCalledWith(1, "child-1"); + expect(preflightArchiveWorkspaceMock).toHaveBeenNthCalledWith(2, "child-2"); + expect(archiveWorkspaceActionMock).toHaveBeenNthCalledWith(1, "child-1", undefined); + expect(archiveWorkspaceActionMock).toHaveBeenNthCalledWith(2, "child-2", undefined); + + archiveWorkspaceActionMock.mockClear(); + archiveWorkspaceActionMock.mockImplementationOnce(() => + Promise.resolve({ success: false as const, error: "archive failed" }) + ); + fireEvent.contextMenu(groupRow, { clientX: 120, clientY: 80 }); + fireEvent.click(view.getByRole("button", { name: /Archive all variants/ })); + + await waitFor(() => { + expect(archiveWorkspaceActionMock).toHaveBeenCalledTimes(1); + }); + expect(archiveWorkspaceActionMock).toHaveBeenCalledWith("child-1", undefined); + + if (groupRow.getAttribute("aria-expanded") !== "true") { + fireEvent.click(groupRow); + } await waitFor(() => { expect(view.getByText("frontend ยท Split review")).toBeTruthy(); diff --git a/src/browser/components/ProjectSidebar/ProjectSidebar.tsx b/src/browser/components/ProjectSidebar/ProjectSidebar.tsx index dfa55838ea..12e10555a2 100644 --- a/src/browser/components/ProjectSidebar/ProjectSidebar.tsx +++ b/src/browser/components/ProjectSidebar/ProjectSidebar.tsx @@ -955,15 +955,26 @@ const ProjectSidebarInner: React.FC = ({ const workspaceForkError = usePopoverError(); const workspaceStopRuntimeError = usePopoverError(); const workspaceRemoveError = usePopoverError(); - const [archiveConfirmation, setArchiveConfirmation] = useState<{ - workspaceId: string; - displayTitle: string; - buttonElement?: HTMLElement; - /** When set, the confirmation warns about permanent deletion of untracked files. */ - untrackedPaths?: string[]; - /** Whether the workspace has an active stream that will be interrupted. */ - isStreaming?: boolean; - } | null>(null); + const [archiveConfirmation, setArchiveConfirmation] = useState< + | { + kind: "workspace"; + workspaceId: string; + displayTitle: string; + buttonElement?: HTMLElement; + /** When set, the confirmation warns about permanent deletion of untracked files. */ + untrackedPaths?: string[]; + /** Whether the workspace has an active stream that will be interrupted. */ + isStreaming?: boolean; + } + | { + kind: "variant-group"; + title: string; + buttonElement: HTMLElement; + members: Array<{ workspaceId: string; untrackedPaths?: string[] }>; + isStreaming: boolean; + } + | null + >(null); const [deleteConfirmation, setDeleteConfirmation] = useState<{ projectPath: string; projectName: string; @@ -1136,6 +1147,7 @@ const ProjectSidebarInner: React.FC = ({ const awaitingUserQuestion = aggregator?.hasAwaitingUserQuestion() ?? false; const isStreaming = (hasActiveStreams || isStarting) && !awaitingUserQuestion; setArchiveConfirmation({ + kind: "workspace", workspaceId, displayTitle, buttonElement, @@ -1144,7 +1156,7 @@ const ProjectSidebarInner: React.FC = ({ // interruption warning again when the archive attempt has not yet been confirmed. isStreaming: acknowledgedUntrackedPaths == null ? isStreaming : false, }); - return; + return false; } if (!result.success) { if (acknowledgedUntrackedPaths != null) { @@ -1160,6 +1172,7 @@ const ProjectSidebarInner: React.FC = ({ const metadata = workspaceStore.getWorkspaceMetadata(workspaceId); const displayTitle = metadata?.title ?? metadata?.name ?? workspaceId; setArchiveConfirmation({ + kind: "workspace", workspaceId, displayTitle, buttonElement, @@ -1174,7 +1187,7 @@ const ProjectSidebarInner: React.FC = ({ return (hasActiveStreams || isStarting) && !awaitingUserQuestion; })(), }); - return; + return false; } } } @@ -1185,7 +1198,9 @@ const ProjectSidebarInner: React.FC = ({ // left-sidebar row that may be far from their current focus. Use the shared toast fallback // position so archive errors match other top-right UI error surfaces. workspaceArchiveError.showError(workspaceId, error); + return false; } + return true; } finally { // Clear archiving state setArchivingWorkspaceIds((prev) => { @@ -1267,6 +1282,7 @@ const ProjectSidebarInner: React.FC = ({ if (isStreaming || untrackedPaths) { // Show a single combined confirmation dialog for streaming + untracked-file warnings. setArchiveConfirmation({ + kind: "workspace", workspaceId, displayTitle, buttonElement, @@ -1287,6 +1303,57 @@ const ProjectSidebarInner: React.FC = ({ ] ); + const handleArchiveVariantGroup = useCallback( + async (title: string, members: FrontendWorkspaceMetadata[], buttonElement: HTMLElement) => { + const preparedMembers: Array<{ workspaceId: string; untrackedPaths?: string[] }> = []; + let isStreaming = false; + + // Preflight the whole group before changing anything. A partial archive would + // leave a variants row in a surprising half-deleted state. + for (const member of members) { + const preflight = await preflightArchiveWorkspace(member.id); + if (!preflight.success) { + workspaceArchiveError.showError( + member.id, + preflight.error ?? "Failed to check archive readiness" + ); + return; + } + + preparedMembers.push({ + workspaceId: member.id, + untrackedPaths: + preflight.data?.kind === "confirm-lossy-untracked-files" + ? preflight.data.paths + : undefined, + }); + isStreaming ||= hasActiveStream(member.id); + } + + const hasUntrackedPaths = preparedMembers.some( + (member) => member.untrackedPaths != null && member.untrackedPaths.length > 0 + ); + if (isStreaming || hasUntrackedPaths) { + setArchiveConfirmation({ + kind: "variant-group", + title, + buttonElement, + members: preparedMembers, + isStreaming, + }); + return; + } + + for (const member of preparedMembers) { + const archived = await performArchiveWorkspace(member.workspaceId, buttonElement); + if (!archived) { + return; + } + } + }, + [hasActiveStream, performArchiveWorkspace, preflightArchiveWorkspace, workspaceArchiveError] + ); + const handleArchiveWorkspaceConfirm = useCallback(async () => { if (!archiveConfirmation) { return; @@ -1294,6 +1361,20 @@ const ProjectSidebarInner: React.FC = ({ const confirmation = archiveConfirmation; setArchiveConfirmation(null); + if (confirmation.kind === "variant-group") { + for (const member of confirmation.members) { + const archived = await performArchiveWorkspace( + member.workspaceId, + confirmation.buttonElement, + member.untrackedPaths + ); + if (!archived) { + return; + } + } + return; + } + await performArchiveWorkspace( confirmation.workspaceId, confirmation.buttonElement, @@ -1890,6 +1971,17 @@ const ProjectSidebarInner: React.FC = ({ workspaceStore, ]); + const archiveConfirmationIsVariantGroup = archiveConfirmation?.kind === "variant-group"; + const variantGroupUntrackedPaths = archiveConfirmationIsVariantGroup + ? archiveConfirmation.members.flatMap((member) => member.untrackedPaths ?? []) + : []; + const archiveConfirmationUntrackedPaths = archiveConfirmationIsVariantGroup + ? variantGroupUntrackedPaths.length > 0 + ? variantGroupUntrackedPaths + : undefined + : archiveConfirmation?.untrackedPaths; + const archiveConfirmationIsStreaming = archiveConfirmation?.isStreaming ?? false; + return ( = ({ onToggle={() => { toggleTaskGroupExpansion(group.storageKey, isExpanded); }} + onArchiveAll={ + group.kind === "variants" + ? (buttonElement) => + handleArchiveVariantGroup( + group.title, + group.allMembers, + buttonElement + ) + : undefined + } /> ); @@ -3240,22 +3342,32 @@ const ProjectSidebarInner: React.FC = ({ { expect(groupRow.textContent).toContain("1 queued"); }); + test("handles menu shortcuts without toggling the group or reaching window handlers", () => { + const onWindowKeydown = mock(() => undefined); + const onArchiveAll = mock(() => Promise.resolve()); + const onToggle = mock(() => undefined); + window.addEventListener("keydown", onWindowKeydown); + const view = renderTaskGroup({ kind: "variants", onArchiveAll, onToggle }); + fireEvent.contextMenu(view.getByTestId("task-group-best-of-demo")); + const menuItem = view.getByRole("button", { name: /Archive all variants/ }); + + fireEvent.keyDown(menuItem, { key: "Enter" }); + expect(onToggle).not.toHaveBeenCalled(); + onWindowKeydown.mockClear(); + + fireEvent.keyDown(menuItem, { + key: "Backspace", + ctrlKey: true, + shiftKey: true, + }); + window.removeEventListener("keydown", onWindowKeydown); + + expect(onArchiveAll).toHaveBeenCalledTimes(1); + expect(onWindowKeydown).not.toHaveBeenCalled(); + }); + + test("handles the archive shortcut without triggering native window handlers", () => { + const onWindowKeydown = mock(() => undefined); + const onArchiveAll = mock(() => Promise.resolve()); + window.addEventListener("keydown", onWindowKeydown); + const view = renderTaskGroup({ kind: "variants", onArchiveAll }); + + fireEvent.keyDown(view.getByTestId("task-group-best-of-demo"), { + key: "Backspace", + ctrlKey: true, + shiftKey: true, + }); + window.removeEventListener("keydown", onWindowKeydown); + + expect(onArchiveAll).toHaveBeenCalledTimes(1); + expect(onWindowKeydown).not.toHaveBeenCalled(); + }); + test("aggregates member state into the shared status-dot language", () => { // Running wins over interrupted: the group is still making progress. const running = renderTaskGroup({ runningCount: 1, interruptedCount: 1 }); diff --git a/src/browser/components/ProjectSidebar/TaskGroupListItem.tsx b/src/browser/components/ProjectSidebar/TaskGroupListItem.tsx index 5c9619d598..bf6fee9162 100644 --- a/src/browser/components/ProjectSidebar/TaskGroupListItem.tsx +++ b/src/browser/components/ProjectSidebar/TaskGroupListItem.tsx @@ -1,7 +1,15 @@ import { ChevronRight, Layers3, Workflow } from "lucide-react"; import { StatusDot, type VisualState } from "@/browser/components/AgentListItem/StatusDot"; +import { ArchiveIcon } from "@/browser/components/icons/ArchiveIcon/ArchiveIcon"; +import { + PositionedMenu, + PositionedMenuItem, +} from "@/browser/components/PositionedMenu/PositionedMenu"; import { getSidebarItemPaddingLeft } from "@/browser/components/sidebarItemLayout"; +import { useContextMenuPosition } from "@/browser/hooks/useContextMenuPosition"; +import { stopKeyboardPropagation } from "@/browser/utils/events"; +import { formatKeybind, KEYBINDS, matchesKeybind } from "@/browser/utils/ui/keybinds"; import { cn } from "@/common/lib/utils"; import { formatSidebarTaskGroupHeader, @@ -24,6 +32,8 @@ interface TaskGroupListItemProps { isExpanded: boolean; isSelected: boolean; onToggle: () => void; + /** Variant groups archive as one unit so users do not have to expand and remove each chat. */ + onArchiveAll?: (buttonElement: HTMLElement) => Promise; } /** @@ -43,6 +53,7 @@ function getAggregateVisualState(props: TaskGroupListItemProps): VisualState { } export function TaskGroupListItem(props: TaskGroupListItemProps) { + const contextMenu = useContextMenuPosition(); const hasRunningWork = props.runningCount > 0; const aggregateState = getAggregateVisualState(props); const statusDescriptionId = `task-group-status-${props.groupId}`; @@ -86,7 +97,21 @@ export function TaskGroupListItem(props: TaskGroupListItemProps) { onClick={() => { props.onToggle(); }} + onContextMenu={props.onArchiveAll ? contextMenu.onContextMenu : undefined} onKeyDown={(event) => { + // Portaled menu events still bubble through the React tree. Handle the + // shared archive shortcut first, then limit row-only keys to the row. + if (props.onArchiveAll && matchesKeybind(event, KEYBINDS.ARCHIVE_WORKSPACE)) { + event.preventDefault(); + stopKeyboardPropagation(event); + props.onArchiveAll(event.currentTarget).catch(() => { + // The sidebar owner surfaces archive failures through its shared error UI. + }); + return; + } + if (event.target !== event.currentTarget) { + return; + } if (event.key === "Enter" || event.key === " ") { event.preventDefault(); props.onToggle(); @@ -153,6 +178,26 @@ export function TaskGroupListItem(props: TaskGroupListItemProps) { )} + {props.onArchiveAll && ( + + } + label="Archive all variants" + shortcut={formatKeybind(KEYBINDS.ARCHIVE_WORKSPACE)} + variant="destructive" + onClick={(event) => { + contextMenu.close(); + props.onArchiveAll?.(event.currentTarget).catch(() => { + // The sidebar owner surfaces archive failures through its shared error UI. + }); + }} + /> + + )} ); }