diff --git a/flake.nix b/flake.nix index 753b2923a6..546381bec6 100644 --- a/flake.nix +++ b/flake.nix @@ -206,7 +206,7 @@ # Documentation mdbook mdbook-mermaid - mdbook-linkcheck + mdbook-linkcheck2 mdbook-pagetoc # Terminal bench + browser recording diff --git a/fmt.mk b/fmt.mk index 9d5ef44712..b8dc32e09e 100644 --- a/fmt.mk +++ b/fmt.mk @@ -82,13 +82,9 @@ else ifeq ($(wildcard flake.nix),) @echo "flake.nix not found; skipping Nix format check" else @echo "Checking flake.nix formatting..." - @tmp_dir=$$(mktemp -d "$${TMPDIR:-/tmp}/fmt-nix-check.XXXXXX"); \ - trap "rm -rf $$tmp_dir" EXIT; \ - cp flake.nix "$$tmp_dir/flake.nix"; \ - (cd "$$tmp_dir" && nix fmt -- flake.nix >/dev/null 2>&1); \ - if ! cmp -s flake.nix "$$tmp_dir/flake.nix"; then \ + @# Check from the repo flake instead of a temp copy; `nix fmt` evaluates flake metadata. + @if ! nix fmt -- --check flake.nix; then \ echo "flake.nix is not formatted correctly. Run 'make fmt-nix' to fix."; \ - diff -u flake.nix "$$tmp_dir/flake.nix" || true; \ exit 1; \ fi endif diff --git a/scripts/fmt.sh b/scripts/fmt.sh index 1fabd33db7..32394c5222 100755 --- a/scripts/fmt.sh +++ b/scripts/fmt.sh @@ -63,18 +63,9 @@ check_nix_format() ( exit 0 fi - local tmp_dir - tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/fmt-nix-check.XXXXXX")" - trap 'rm -rf "$tmp_dir"' EXIT - cp "$flake_path" "$tmp_dir/flake.nix" - ( - cd "$tmp_dir" - nix fmt -- flake.nix - ) - - if ! cmp -s "$flake_path" "$tmp_dir/flake.nix"; then + # Check from the repo flake instead of a temp copy; `nix fmt` evaluates flake metadata. + if ! nix fmt -- --check flake.nix; then echo "flake.nix is not formatted correctly. Run ./scripts/fmt.sh --nix or make fmt-nix." - diff -u "$flake_path" "$tmp_dir/flake.nix" || true exit 1 fi ) diff --git a/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx b/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx index c914f94558..99664a3b83 100644 --- a/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx +++ b/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx @@ -151,8 +151,16 @@ let archiveWorkspaceActionMock = mock( _options?: { acknowledgedUntrackedPaths?: string[] } ): Promise => resolveArchiveResult() ); +let removeWorkspaceActionMock = mock( + ( + _workspaceId: string, + _options?: { force?: boolean } + ): Promise<{ success: boolean; error?: string }> => Promise.resolve({ success: true }) +); let settingsOpenMock = mock(() => undefined); -let confirmDialogMock = mock(() => Promise.resolve(true)); +let confirmDialogMock = mock((_options: ConfirmDialogContextModule.ConfirmDialogOptions) => + Promise.resolve(true) +); let archivePopoverShowErrorMock = mock( (_workspaceId: string, _error: string, _anchor?: { top: number; left: number }) => undefined ); @@ -210,7 +218,15 @@ function installProjectSidebarTestDoubles() { _options?: { acknowledgedUntrackedPaths?: string[] } ): Promise => resolveArchiveResult() ); - confirmDialogMock = mock(() => Promise.resolve(true)); + removeWorkspaceActionMock = mock( + ( + _workspaceId: string, + _options?: { force?: boolean } + ): Promise<{ success: boolean; error?: string }> => Promise.resolve({ success: true }) + ); + confirmDialogMock = mock((_options: ConfirmDialogContextModule.ConfirmDialogOptions) => + Promise.resolve(true) + ); latestArchiveWorkspaceHandler = null; latestArchiveConfirmationModalProps = null; void mock.module("@/browser/assets/logos/mux-logo-dark.svg?react", () => ({ @@ -423,7 +439,7 @@ function installProjectSidebarTestDoubles() { setSelectedWorkspace: () => undefined, preflightArchiveWorkspace: preflightArchiveWorkspaceMock, archiveWorkspace: archiveWorkspaceActionMock, - removeWorkspace: () => Promise.resolve({ success: true }), + removeWorkspace: removeWorkspaceActionMock, updateWorkspaceTitle: () => Promise.resolve({ success: true }), refreshWorkspaceMetadata: () => Promise.resolve(), pendingNewWorkspaceProject: null, @@ -602,6 +618,62 @@ describe("ProjectSidebar multi-project completed-subagent toggles", () => { mock.restore(); }); + function renderVariantGroupSidebar() { + window.localStorage.setItem(EXPANDED_PROJECTS_KEY, JSON.stringify(["/projects/demo-project"])); + + const singleProjectRefs = [ + { projectPath: "/projects/demo-project", projectName: "demo-project" }, + ]; + const parentWorkspace = { + ...createWorkspace("parent", { title: "Parent workspace" }), + projects: singleProjectRefs, + }; + const taskGroup = { + groupId: "variants-demo", + index: 0, + total: 2, + kind: "variants", + label: "frontend", + } as const; + const childOne = { + ...createWorkspace("child-1", { + parentWorkspaceId: "parent", + taskStatus: "running", + title: "Split review", + bestOf: taskGroup, + }), + projects: singleProjectRefs, + }; + const childTwo = { + ...createWorkspace("child-2", { + parentWorkspaceId: "parent", + taskStatus: "queued", + title: "Split review", + bestOf: { ...taskGroup, index: 1, label: "backend" }, + }), + projects: singleProjectRefs, + }; + + projectContextValue = createProjectContextValue({ + userProjects: new Map([["/projects/demo-project", { workspaces: [] }]]), + hasAnyProject: true, + resolveNewChatProjectPath: () => "/projects/demo-project", + }); + + const view = render( + undefined} + sortedWorkspacesByProject={ + new Map([["/projects/demo-project", [parentWorkspace, childOne, childTwo]]]) + } + workspaceRecency={{ parent: Date.now(), "child-1": Date.now(), "child-2": Date.now() }} + /> + ); + + return { view, childOne, childTwo }; + } + test("filters multi-project rows out entirely when the experiment is disabled", () => { spyOn(ExperimentsModule, "useExperimentValue").mockImplementation(() => false); @@ -856,93 +928,7 @@ describe("ProjectSidebar multi-project completed-subagent toggles", () => { }); test("renders variants groups with a shared row and labeled members when expanded", async () => { - window.localStorage.setItem(EXPANDED_PROJECTS_KEY, JSON.stringify(["/projects/demo-project"])); - - const singleProjectRefs = [ - { projectPath: "/projects/demo-project", projectName: "demo-project" }, - ]; - const parentWorkspace = { - ...createWorkspace("parent", { title: "Parent workspace" }), - projects: singleProjectRefs, - }; - const taskGroup = { - groupId: "variants-demo", - index: 0, - total: 2, - kind: "variants", - label: "frontend", - } as const; - const childOne = { - ...createWorkspace("child-1", { - parentWorkspaceId: "parent", - taskStatus: "running", - title: "Split review", - bestOf: taskGroup, - }), - projects: singleProjectRefs, - }; - const childTwo = { - ...createWorkspace("child-2", { - parentWorkspaceId: "parent", - taskStatus: "queued", - title: "Split review", - bestOf: { ...taskGroup, index: 1, label: "backend" }, - }), - projects: singleProjectRefs, - }; - - const sortedWorkspacesByProject = new Map([ - ["/projects/demo-project", [parentWorkspace, childOne, childTwo]], - ]); - - const projectConfig = { workspaces: [] }; - spyOn(ProjectContextModule, "useProjectContext").mockImplementation(() => ({ - userProjects: new Map([["/projects/demo-project", projectConfig]]), - systemProjectPath: null, - resolveProjectPath: () => null, - getProjectConfig: () => projectConfig, - loading: false, - refreshProjects: () => Promise.resolve(), - addProject: () => undefined, - removeProject: () => Promise.resolve({ success: true }), - isProjectCreateModalOpen: false, - openProjectCreateModal: () => undefined, - closeProjectCreateModal: () => undefined, - workspaceModalState: { - isOpen: false, - projectPath: null, - projectName: "", - branches: [], - defaultTrunkBranch: undefined, - loadErrorMessage: null, - isLoading: false, - }, - openWorkspaceModal: () => Promise.resolve(), - closeWorkspaceModal: () => undefined, - getBranchesForProject: () => Promise.resolve({ branches: [], recommendedTrunk: null }), - getSecrets: () => Promise.resolve([]), - updateSecrets: () => Promise.resolve(), - updateDisplayName: () => resolveVoidResult(), - updateColor: () => resolveVoidResult(), - assignWorkspaceToSubProject: () => resolveVoidResult(), - hasAnyProject: true, - resolveNewChatProjectPath: () => "/projects/demo-project", - })); - - const workspaceRecency = { - parent: Date.now(), - "child-1": Date.now(), - "child-2": Date.now(), - }; - - const view = render( - undefined} - sortedWorkspacesByProject={sortedWorkspacesByProject} - workspaceRecency={workspaceRecency} - /> - ); + const { view } = renderVariantGroupSidebar(); const groupRow = view.getByTestId("task-group-variants-demo"); expect(groupRow.textContent).toContain("Variants · Split review"); @@ -964,6 +950,35 @@ describe("ProjectSidebar multi-project completed-subagent toggles", () => { expect(childTwoRow.dataset.connectorLayout).toBe("task-group-member"); }); + test("deletes every variant from the group action menu", async () => { + const { view, childOne, childTwo } = renderVariantGroupSidebar(); + + fireEvent.click(view.getByTestId("task-group-actions-variants-demo")); + fireEvent.click(view.getByRole("button", { name: "Delete all variants" })); + + await waitFor(() => { + expect(removeWorkspaceActionMock).toHaveBeenCalledTimes(2); + }); + + expect(confirmDialogMock.mock.calls[0]?.[0].confirmVariant).toBe("destructive"); + expect(removeWorkspaceActionMock.mock.calls.map((call) => call[0])).toEqual([ + childOne.id, + childTwo.id, + ]); + expect(removeWorkspaceActionMock.mock.calls.map((call) => call[1])).toEqual([ + { force: true }, + { force: true }, + ]); + }); + + test("opens the variant group action menu on right-click", () => { + const { view } = renderVariantGroupSidebar(); + + fireEvent.contextMenu(view.getByTestId("task-group-variants-demo")); + + expect(view.getByRole("button", { name: "Delete all variants" })).toBeTruthy(); + }); + test("does not coalesce a best-of group when one candidate still has hidden child tasks", () => { window.localStorage.setItem(EXPANDED_PROJECTS_KEY, JSON.stringify(["/projects/demo-project"])); diff --git a/src/browser/components/ProjectSidebar/ProjectSidebar.tsx b/src/browser/components/ProjectSidebar/ProjectSidebar.tsx index b218c8393f..f9c74e544f 100644 --- a/src/browser/components/ProjectSidebar/ProjectSidebar.tsx +++ b/src/browser/components/ProjectSidebar/ProjectSidebar.tsx @@ -109,7 +109,12 @@ import { getErrorMessage } from "@/common/utils/errors"; import { isMultiProject } from "@/common/utils/multiProject"; import { MULTI_PROJECT_SIDEBAR_SECTION_ID } from "@/common/constants/multiProject"; import { getProjectWorkspaceCounts } from "@/common/utils/projectRemoval"; -import { getTaskGroupKindFromMetadata } from "@/common/utils/tools/taskGroups"; +import { + TASK_GROUP_KIND, + formatTaskGroupItemsLabel, + getTaskGroupKindFromMetadata, + type TaskGroupKind, +} from "@/common/utils/tools/taskGroups"; import { hasCompletedAgentReport } from "@/common/utils/agentTaskCompletion"; import { useExperimentValue } from "@/browser/hooks/useExperiments"; import { EXPERIMENT_IDS } from "@/common/constants/experiments"; @@ -896,6 +901,7 @@ const ProjectSidebarInner: React.FC = ({ const [archivingWorkspaceIds, setArchivingWorkspaceIds] = useState>(new Set()); const [removingWorkspaceIds, setRemovingWorkspaceIds] = useState>(new Set()); + const [deletingTaskGroupIds, setDeletingTaskGroupIds] = useState>(new Set()); const [draftVisibilityByProject, setDraftVisibilityByProject] = useState< Record> >({}); @@ -903,6 +909,7 @@ const ProjectSidebarInner: React.FC = ({ const workspaceForkError = usePopoverError(); const workspaceStopRuntimeError = usePopoverError(); const workspaceRemoveError = usePopoverError(); + const workspaceDeleteError = usePopoverError(); const [archiveConfirmation, setArchiveConfirmation] = useState<{ workspaceId: string; displayTitle: string; @@ -1340,6 +1347,82 @@ const ProjectSidebarInner: React.FC = ({ [removeWorkspace, workspaceRemoveError] ); + const handleDeleteTaskGroupWorkspaces = useCallback( + async (params: { + groupId: string; + kind: TaskGroupKind; + title: string; + members: FrontendWorkspaceMetadata[]; + }) => { + const memberIds = params.members.map((member) => member.id); + if (memberIds.length === 0 || deletingTaskGroupIds.has(params.groupId)) { + return; + } + + const itemLabel = formatTaskGroupItemsLabel(params.kind).toLowerCase(); + const hasRunningMembers = params.members.some( + (member) => + member.taskStatus === "running" || + member.taskStatus === "awaiting_report" || + member.isInitializing === true + ); + const ok = await confirmDialog({ + title: `Delete all ${itemLabel}?`, + description: `This will permanently delete ${memberIds.length} ${itemLabel} workspace${memberIds.length === 1 ? "" : "s"} for “${params.title}”.`, + warning: hasRunningMembers + ? `Running ${itemLabel} will be stopped before deletion.` + : undefined, + confirmLabel: `Delete ${itemLabel}`, + confirmVariant: "destructive", + }); + if (!ok) { + return; + } + + setDeletingTaskGroupIds((prev) => new Set(prev).add(params.groupId)); + setRemovingWorkspaceIds((prev) => { + const next = new Set(prev); + for (const memberId of memberIds) { + next.add(memberId); + } + return next; + }); + + let didShowError = false; + try { + for (const member of params.members) { + const result = await removeWorkspace(member.id, { force: true }); + if (result.success) { + continue; + } + + if (!didShowError) { + const displayTitle = member.title ?? member.name ?? member.id; + workspaceDeleteError.showError( + params.groupId, + `${displayTitle}: ${result.error ?? `Failed to delete ${itemLabel}`}` + ); + didShowError = true; + } + } + } finally { + setRemovingWorkspaceIds((prev) => { + const next = new Set(prev); + for (const memberId of memberIds) { + next.delete(memberId); + } + return next; + }); + setDeletingTaskGroupIds((prev) => { + const next = new Set(prev); + next.delete(params.groupId); + return next; + }); + } + }, + [confirmDialog, deletingTaskGroupIds, removeWorkspace, workspaceDeleteError] + ); + const handleRemoveSection = async ( projectPath: string, subProjectPath: string, @@ -2320,6 +2403,9 @@ const ProjectSidebarInner: React.FC = ({ const groupKind = getTaskGroupKindFromMetadata( allMembers[0]?.bestOf ); + // Variant runs are sibling task workspaces; the group row owns bulk cleanup + // so users can delete the whole set without expanding every child. + const canDeleteTaskGroup = groupKind === TASK_GROUP_KIND.VARIANTS; let completedCount = 0; let runningCount = 0; let queuedCount = 0; @@ -2367,9 +2453,22 @@ const ProjectSidebarInner: React.FC = ({ isSelected={allMembers.some( (member) => member.id === selectedWorkspace?.workspaceId )} + isDeleting={deletingTaskGroupIds.has(taskGroupId)} onToggle={() => { toggleTaskGroupExpansion(taskGroupId); }} + onDeleteAll={ + canDeleteTaskGroup + ? () => { + void handleDeleteTaskGroupWorkspaces({ + groupId: taskGroupId, + kind: groupKind, + title: groupTitle, + members: allMembers, + }); + } + : undefined + } /> ); @@ -3073,6 +3172,11 @@ const ProjectSidebarInner: React.FC = ({ prefix="Failed to cancel workspace creation" onDismiss={workspaceRemoveError.clearError} /> + void; + onDeleteAll?: (buttonElement: HTMLElement) => void | Promise; } export function TaskGroupListItem(props: TaskGroupListItemProps) { const paddingLeft = getSidebarItemPaddingLeft(props.depth); + const hasActionMenu = props.onDeleteAll != null; + const actionMenu = useContextMenuPosition({ + longPress: hasActionMenu, + canOpen: () => hasActionMenu && props.isDeleting !== true, + }); + const itemLabel = formatTaskGroupItemsLabel(props.kind).toLowerCase(); + const deleteAllLabel = `Delete all ${itemLabel}`; const statusParts: string[] = []; if (props.runningCount > 0) { statusParts.push(`${props.runningCount} running`); @@ -52,14 +67,19 @@ export function TaskGroupListItem(props: TaskGroupListItemProps) { aria-label={`${props.isExpanded ? "Collapse" : "Expand"} task group ${props.title}`} data-testid={`task-group-${props.groupId}`} className={cn( - "bg-surface-primary relative flex items-start gap-1.5 rounded-l-sm py-2 pr-2 pl-1 select-none transition-all duration-150 hover:bg-surface-secondary", + "bg-surface-primary group/task-group relative flex items-start gap-1.5 rounded-l-sm py-2 pr-2 pl-1 select-none transition-all duration-150 hover:bg-surface-secondary", props.sectionId != null ? "ml-2" : "ml-0", props.isSelected && "bg-surface-secondary" )} style={{ paddingLeft }} onClick={() => { + if (actionMenu.suppressClickIfLongPress()) { + return; + } props.onToggle(); }} + onContextMenu={hasActionMenu ? actionMenu.onContextMenu : undefined} + {...(hasActionMenu ? actionMenu.touchHandlers : {})} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); @@ -84,7 +104,13 @@ export function TaskGroupListItem(props: TaskGroupListItemProps) { {formatTaskGroupHeader(props.kind, props.totalCount, props.title)} - + {props.completedCount}/{props.totalCount} @@ -98,6 +124,45 @@ export function TaskGroupListItem(props: TaskGroupListItemProps) { )} + {hasActionMenu && ( + + )} + + } + label={deleteAllLabel} + variant="destructive" + disabled={props.isDeleting === true} + onClick={(event) => { + actionMenu.close(); + void props.onDeleteAll?.(event.currentTarget); + }} + /> + ); }