From 9899686bfa3a21ac3a9bd8a6c5e13a51bceaa5b3 Mon Sep 17 00:00:00 2001 From: Mux Date: Mon, 20 Jul 2026 12:37:29 -0500 Subject: [PATCH 1/4] fix: archive all sidebar variants Co-authored-by: Mux --- .../ProjectSidebar/ProjectSidebar.test.tsx | 19 ++- .../ProjectSidebar/ProjectSidebar.tsx | 146 +++++++++++++++--- .../ProjectSidebar/TaskGroupListItem.tsx | 38 +++++ 3 files changed, 180 insertions(+), 23 deletions(-) diff --git a/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx b/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx index 3cd5d6c8f7..11a1950069 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,22 @@ 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); + + 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..663e6a7ef8 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, @@ -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, @@ -1267,6 +1280,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 +1301,54 @@ 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) { + await performArchiveWorkspace(member.workspaceId, buttonElement); + } + }, + [hasActiveStream, performArchiveWorkspace, preflightArchiveWorkspace, workspaceArchiveError] + ); + const handleArchiveWorkspaceConfirm = useCallback(async () => { if (!archiveConfirmation) { return; @@ -1294,6 +1356,17 @@ const ProjectSidebarInner: React.FC = ({ const confirmation = archiveConfirmation; setArchiveConfirmation(null); + if (confirmation.kind === "variant-group") { + for (const member of confirmation.members) { + await performArchiveWorkspace( + member.workspaceId, + confirmation.buttonElement, + member.untrackedPaths + ); + } + return; + } + await performArchiveWorkspace( confirmation.workspaceId, confirmation.buttonElement, @@ -1890,6 +1963,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 +3334,32 @@ const ProjectSidebarInner: React.FC = ({ void; + /** Variant groups archive as one unit so users do not have to expand and remove each chat. */ + onArchiveAll?: (buttonElement: HTMLElement) => Promise; } /** @@ -43,6 +52,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,10 +96,18 @@ export function TaskGroupListItem(props: TaskGroupListItemProps) { onClick={() => { props.onToggle(); }} + onContextMenu={props.onArchiveAll ? contextMenu.onContextMenu : undefined} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); props.onToggle(); + return; + } + if (props.onArchiveAll && matchesKeybind(event, KEYBINDS.ARCHIVE_WORKSPACE)) { + event.preventDefault(); + props.onArchiveAll(event.currentTarget).catch(() => { + // The sidebar owner surfaces archive failures through its shared error UI. + }); } }} > @@ -153,6 +171,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. + }); + }} + /> + + )} ); } From 817e1e2ad02210d089a9ed27ce922c2821504433 Mon Sep 17 00:00:00 2001 From: Mux Date: Mon, 20 Jul 2026 12:44:03 -0500 Subject: [PATCH 2/4] fix: handle variant archive failures Co-authored-by: Mux --- .../ProjectSidebar/ProjectSidebar.test.tsx | 12 +++++++++++ .../ProjectSidebar/ProjectSidebar.tsx | 16 ++++++++++---- .../ProjectSidebar/TaskGroupListItem.test.tsx | 21 +++++++++++++++++-- .../ProjectSidebar/TaskGroupListItem.tsx | 2 ++ 4 files changed, 45 insertions(+), 6 deletions(-) diff --git a/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx b/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx index 11a1950069..ae7fda18a0 100644 --- a/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx +++ b/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx @@ -1335,6 +1335,18 @@ describe("ProjectSidebar multi-project completed-subagent toggles", () => { 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); } diff --git a/src/browser/components/ProjectSidebar/ProjectSidebar.tsx b/src/browser/components/ProjectSidebar/ProjectSidebar.tsx index 663e6a7ef8..12e10555a2 100644 --- a/src/browser/components/ProjectSidebar/ProjectSidebar.tsx +++ b/src/browser/components/ProjectSidebar/ProjectSidebar.tsx @@ -1156,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) { @@ -1187,7 +1187,7 @@ const ProjectSidebarInner: React.FC = ({ return (hasActiveStreams || isStarting) && !awaitingUserQuestion; })(), }); - return; + return false; } } } @@ -1198,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) => { @@ -1343,7 +1345,10 @@ const ProjectSidebarInner: React.FC = ({ } for (const member of preparedMembers) { - await performArchiveWorkspace(member.workspaceId, buttonElement); + const archived = await performArchiveWorkspace(member.workspaceId, buttonElement); + if (!archived) { + return; + } } }, [hasActiveStream, performArchiveWorkspace, preflightArchiveWorkspace, workspaceArchiveError] @@ -1358,11 +1363,14 @@ const ProjectSidebarInner: React.FC = ({ setArchiveConfirmation(null); if (confirmation.kind === "variant-group") { for (const member of confirmation.members) { - await performArchiveWorkspace( + const archived = await performArchiveWorkspace( member.workspaceId, confirmation.buttonElement, member.untrackedPaths ); + if (!archived) { + return; + } } return; } diff --git a/src/browser/components/ProjectSidebar/TaskGroupListItem.test.tsx b/src/browser/components/ProjectSidebar/TaskGroupListItem.test.tsx index fce6bc4809..6deb35e7e9 100644 --- a/src/browser/components/ProjectSidebar/TaskGroupListItem.test.tsx +++ b/src/browser/components/ProjectSidebar/TaskGroupListItem.test.tsx @@ -1,7 +1,7 @@ import "../../../../tests/ui/dom"; -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { cleanup, render } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { cleanup, fireEvent, render } from "@testing-library/react"; import { installDom } from "../../../../tests/ui/dom"; import { TaskGroupListItem } from "./TaskGroupListItem"; @@ -64,6 +64,23 @@ describe("TaskGroupListItem", () => { expect(groupRow.textContent).toContain("1 queued"); }); + 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 be0c9f4f30..fc792d2a4b 100644 --- a/src/browser/components/ProjectSidebar/TaskGroupListItem.tsx +++ b/src/browser/components/ProjectSidebar/TaskGroupListItem.tsx @@ -8,6 +8,7 @@ import { } 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 { @@ -105,6 +106,7 @@ export function TaskGroupListItem(props: TaskGroupListItemProps) { } 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. }); From 81095d1f3f28c307be6d7961c107af26394d6ad3 Mon Sep 17 00:00:00 2001 From: Mux Date: Mon, 20 Jul 2026 12:50:02 -0500 Subject: [PATCH 3/4] fix: isolate variant group shortcuts Co-authored-by: Mux --- .../ProjectSidebar/TaskGroupListItem.test.tsx | 16 ++++++++++++++++ .../ProjectSidebar/TaskGroupListItem.tsx | 5 +++++ 2 files changed, 21 insertions(+) diff --git a/src/browser/components/ProjectSidebar/TaskGroupListItem.test.tsx b/src/browser/components/ProjectSidebar/TaskGroupListItem.test.tsx index 6deb35e7e9..50ca818961 100644 --- a/src/browser/components/ProjectSidebar/TaskGroupListItem.test.tsx +++ b/src/browser/components/ProjectSidebar/TaskGroupListItem.test.tsx @@ -64,6 +64,22 @@ describe("TaskGroupListItem", () => { expect(groupRow.textContent).toContain("1 queued"); }); + test("ignores keyboard events from nested menu controls", () => { + const onToggle = mock(() => undefined); + const view = renderTaskGroup({ + kind: "variants", + onArchiveAll: () => Promise.resolve(), + onToggle, + }); + fireEvent.contextMenu(view.getByTestId("task-group-best-of-demo")); + + fireEvent.keyDown(view.getByRole("button", { name: /Archive all variants/ }), { + key: "Enter", + }); + + expect(onToggle).not.toHaveBeenCalled(); + }); + test("handles the archive shortcut without triggering native window handlers", () => { const onWindowKeydown = mock(() => undefined); const onArchiveAll = mock(() => Promise.resolve()); diff --git a/src/browser/components/ProjectSidebar/TaskGroupListItem.tsx b/src/browser/components/ProjectSidebar/TaskGroupListItem.tsx index fc792d2a4b..88a14536d4 100644 --- a/src/browser/components/ProjectSidebar/TaskGroupListItem.tsx +++ b/src/browser/components/ProjectSidebar/TaskGroupListItem.tsx @@ -99,6 +99,11 @@ export function TaskGroupListItem(props: TaskGroupListItemProps) { }} onContextMenu={props.onArchiveAll ? contextMenu.onContextMenu : undefined} onKeyDown={(event) => { + // Portaled menu events still bubble through the React tree. Only the row + // itself owns expansion and archive shortcuts. + if (event.target !== event.currentTarget) { + return; + } if (event.key === "Enter" || event.key === " ") { event.preventDefault(); props.onToggle(); From dfd9c9d1071a16211812c35053b28086b67feb9f Mon Sep 17 00:00:00 2001 From: Mux Date: Mon, 20 Jul 2026 12:58:40 -0500 Subject: [PATCH 4/4] fix: handle nested variant shortcuts Co-authored-by: Mux --- .../ProjectSidebar/TaskGroupListItem.test.tsx | 26 ++++++++++++------- .../ProjectSidebar/TaskGroupListItem.tsx | 20 +++++++------- 2 files changed, 27 insertions(+), 19 deletions(-) diff --git a/src/browser/components/ProjectSidebar/TaskGroupListItem.test.tsx b/src/browser/components/ProjectSidebar/TaskGroupListItem.test.tsx index 50ca818961..4da999fbbe 100644 --- a/src/browser/components/ProjectSidebar/TaskGroupListItem.test.tsx +++ b/src/browser/components/ProjectSidebar/TaskGroupListItem.test.tsx @@ -64,20 +64,28 @@ describe("TaskGroupListItem", () => { expect(groupRow.textContent).toContain("1 queued"); }); - test("ignores keyboard events from nested menu controls", () => { + 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); - const view = renderTaskGroup({ - kind: "variants", - onArchiveAll: () => Promise.resolve(), - onToggle, - }); + 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(view.getByRole("button", { name: /Archive all variants/ }), { - key: "Enter", + 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(onToggle).not.toHaveBeenCalled(); + expect(onArchiveAll).toHaveBeenCalledTimes(1); + expect(onWindowKeydown).not.toHaveBeenCalled(); }); test("handles the archive shortcut without triggering native window handlers", () => { diff --git a/src/browser/components/ProjectSidebar/TaskGroupListItem.tsx b/src/browser/components/ProjectSidebar/TaskGroupListItem.tsx index 88a14536d4..bf6fee9162 100644 --- a/src/browser/components/ProjectSidebar/TaskGroupListItem.tsx +++ b/src/browser/components/ProjectSidebar/TaskGroupListItem.tsx @@ -99,22 +99,22 @@ export function TaskGroupListItem(props: TaskGroupListItemProps) { }} onContextMenu={props.onArchiveAll ? contextMenu.onContextMenu : undefined} onKeyDown={(event) => { - // Portaled menu events still bubble through the React tree. Only the row - // itself owns expansion and archive shortcuts. - if (event.target !== event.currentTarget) { - return; - } - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - props.onToggle(); - return; - } + // 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(); } }} >