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
31 changes: 29 additions & 2 deletions src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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();
Expand Down
158 changes: 135 additions & 23 deletions src/browser/components/ProjectSidebar/ProjectSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -955,15 +955,26 @@ const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
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;
Expand Down Expand Up @@ -1136,6 +1147,7 @@ const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
const awaitingUserQuestion = aggregator?.hasAwaitingUserQuestion() ?? false;
const isStreaming = (hasActiveStreams || isStarting) && !awaitingUserQuestion;
setArchiveConfirmation({
kind: "workspace",
workspaceId,
displayTitle,
buttonElement,
Expand All @@ -1144,7 +1156,7 @@ const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
// 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) {
Expand All @@ -1160,6 +1172,7 @@ const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
const metadata = workspaceStore.getWorkspaceMetadata(workspaceId);
const displayTitle = metadata?.title ?? metadata?.name ?? workspaceId;
setArchiveConfirmation({
kind: "workspace",
workspaceId,
displayTitle,
buttonElement,
Expand All @@ -1174,7 +1187,7 @@ const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
return (hasActiveStreams || isStarting) && !awaitingUserQuestion;
})(),
});
return;
return false;
}
}
}
Expand All @@ -1185,7 +1198,9 @@ const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
// 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) => {
Expand Down Expand Up @@ -1267,6 +1282,7 @@ const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
if (isStreaming || untrackedPaths) {
// Show a single combined confirmation dialog for streaming + untracked-file warnings.
setArchiveConfirmation({
kind: "workspace",
workspaceId,
displayTitle,
buttonElement,
Expand All @@ -1287,13 +1303,78 @@ const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
]
);

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;
}

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,
Expand Down Expand Up @@ -1890,6 +1971,17 @@ const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
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 (
<TitleEditProvider onUpdateTitle={onUpdateTitle}>
<SidebarTitleEditKeybinds
Expand Down Expand Up @@ -2588,6 +2680,16 @@ const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
onToggle={() => {
toggleTaskGroupExpansion(group.storageKey, isExpanded);
}}
onArchiveAll={
group.kind === "variants"
? (buttonElement) =>
handleArchiveVariantGroup(
group.title,
group.allMembers,
buttonElement
)
: undefined
}
/>
);

Expand Down Expand Up @@ -3240,22 +3342,32 @@ const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
<ConfirmationModal
isOpen={archiveConfirmation !== null}
title={
archiveConfirmation?.untrackedPaths
? "Archive workspace with untracked files?"
: archiveConfirmation
? `Archive "${archiveConfirmation.displayTitle}" while streaming?`
: "Archive chat?"
archiveConfirmationIsVariantGroup
? `Archive all variants for "${archiveConfirmation.title}"?`
: archiveConfirmationUntrackedPaths
? "Archive workspace with untracked files?"
: archiveConfirmation
? `Archive "${archiveConfirmation.displayTitle}" while streaming?`
: "Archive chat?"
}
description={
archiveConfirmationIsVariantGroup
? `${archiveConfirmation.members.length} variant chats will be archived together.`
: buildArchiveConfirmDescription(
archiveConfirmationIsStreaming,
archiveConfirmationUntrackedPaths
)
}
description={buildArchiveConfirmDescription(
archiveConfirmation?.isStreaming ?? false,
archiveConfirmation?.untrackedPaths
)}
warning={buildArchiveConfirmWarning(
archiveConfirmation?.isStreaming ?? false,
archiveConfirmation?.untrackedPaths
archiveConfirmationIsStreaming,
archiveConfirmationUntrackedPaths
)}
confirmLabel={
archiveConfirmation?.untrackedPaths ? "Archive and delete files" : "Archive"
archiveConfirmationUntrackedPaths
? "Archive and delete files"
: archiveConfirmationIsVariantGroup
? "Archive all"
: "Archive"
}
confirmVariant="destructive"
onConfirm={handleArchiveWorkspaceConfirm}
Expand Down
45 changes: 43 additions & 2 deletions src/browser/components/ProjectSidebar/TaskGroupListItem.test.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -64,6 +64,47 @@ describe("TaskGroupListItem", () => {
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 });
Expand Down
Loading
Loading