fix: preserve inherited workspace delete copy (#3864)

This commit is contained in:
Carlos Florêncio
2026-09-22 23:31:49 +01:00
committed by GitHub
parent 2e600341f4
commit 151c8bb526
22 changed files with 466 additions and 11 deletions
+5
View File
@@ -9,12 +9,14 @@ import { Button } from "@kandev/ui/button";
import { Badge } from "@kandev/ui/badge";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { formatTimeDistance, useDateLocale } from "@/lib/i18n/date-locale";
import { cleanupSharesParentWorkspace } from "@/components/task/task-cleanup-summary";
import { TaskDeleteConfirmDialog } from "@/components/task/task-delete-confirm-dialog";
import { TaskArchiveConfirmation } from "@/components/task/task-archive-confirmation";
import { linkToTask } from "@/lib/links";
import { useTranslation } from "react-i18next";
import { t } from "@/lib/i18n";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { workspaceModeFromMetadata } from "@/lib/kanban/map-task";
type TaskWithResolution = Task & {
workflowName?: string;
@@ -132,6 +134,9 @@ function ActionsCell({ row, ctx }: { row: Row<TaskWithResolution>; ctx: ActionsC
taskTitle={task.title}
taskId={task.id}
executorType={task.primary_executor_type}
sharesParentWorkspace={cleanupSharesParentWorkspace(
workspaceModeFromMetadata(task.metadata),
)}
isDeleting={isDeleting}
onConfirm={({ cascade, discardWorktreeChanges }) =>
ctx.onDelete(task.id, { cascade, discardWorktreeChanges })
+4
View File
@@ -6,6 +6,7 @@ import { Button } from "@kandev/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { IconArchive, IconArchiveOff, IconLoader, IconTrash } from "@tabler/icons-react";
import { TaskArchiveConfirmation } from "@/components/task/task-archive-confirmation";
import { cleanupSharesParentWorkspace } from "@/components/task/task-cleanup-summary";
import { TaskDeleteConfirmDialog } from "@/components/task/task-delete-confirm-dialog";
import { primaryTaskRepository, type Repository, type Task, type Workflow } from "@/lib/types/http";
import { formatTaskStateLabel } from "@/lib/ui/state-labels";
@@ -20,6 +21,7 @@ import { useTranslation } from "react-i18next";
import { t } from "@/lib/i18n";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import type { TaskListFacetValue } from "@/lib/plugins/types";
import { workspaceModeFromMetadata } from "@/lib/kanban/map-task";
export type TasksListViewProps = {
total: number;
@@ -548,6 +550,7 @@ function TaskRowActions({
) => Promise<void>;
}) {
const { t } = useTranslation();
const workspaceMode = workspaceModeFromMetadata(task.metadata);
const archiveAnchorRef = useRef<HTMLButtonElement>(null);
const { isFinePointer } = useResponsiveBreakpoint();
return (
@@ -601,6 +604,7 @@ function TaskRowActions({
taskId={task.id}
isInFlight={isTaskInFlight(task.foreground_activity)}
executorType={task.primary_executor_type}
sharesParentWorkspace={cleanupSharesParentWorkspace(workspaceMode)}
isDeleting={isDeleting}
onConfirm={({ cascade, discardWorktreeChanges }) =>
onDelete(task.id, { cascade, discardWorktreeChanges })
+2
View File
@@ -6,6 +6,7 @@ import {
useKanbanCardMoveTargets,
} from "@/components/kanban-card-menu-items";
import { useTaskPluginLinkActions } from "@/components/task/task-session-sidebar-link-actions";
import { cleanupSharesParentWorkspace } from "@/components/task/task-cleanup-summary";
import { TaskDeleteConfirmDialog } from "@/components/task/task-delete-confirm-dialog";
import {
TaskExternalLinkDialog,
@@ -269,6 +270,7 @@ export function KanbanCardDialogs({
taskTitle={task.title}
taskId={task.id}
executorType={task.primaryExecutorType}
sharesParentWorkspace={cleanupSharesParentWorkspace(task.workspaceMode)}
isDeleting={isDeleting}
onConfirm={(opts) => onDelete?.(task, opts)}
/>
@@ -4,6 +4,7 @@ import { useRef, type RefObject } from "react";
import { useActiveWorkspaceRepositories } from "@/components/kanban-card-repositories";
import { TaskArchiveConfirmation } from "@/components/task/task-archive-confirmation";
import { TaskCreateDialog } from "@/components/task-create-dialog";
import { cleanupSharesParentWorkspace } from "@/components/task/task-cleanup-summary";
import { TaskDeleteConfirmDialog } from "@/components/task/task-delete-confirm-dialog";
import { TaskDetachConfirmationSurface } from "@/components/task/task-detach-confirm-dialog";
import { TaskExternalLinkDialog } from "@/components/task/task-external-link-dialog";
@@ -122,6 +123,20 @@ function useLastResolvedBoardRow(
return ref.current.boardRow;
}
function buildConfirmationTarget(
taskId: string,
taskTitle: string,
triggerRef: RefObject<HTMLElement | null>,
) {
return {
taskId,
taskTitle,
anchorRef: triggerRef,
focusReturnRef: triggerRef,
restoreFocusOnConfirm: true,
};
}
/**
* Mounts every dialog a task actions menu entry can open. Hosted per surface
* so a dialog outlives the menu that opened it.
@@ -140,13 +155,7 @@ export function TaskActionsMenuDialogs({
const editBoardRow = useLastResolvedBoardRow(taskId, boardRow);
if (!taskId) return null;
const linkDialogTask = buildLinkDialogTask(taskId, taskTitle, boardRow);
const confirmationTarget = {
taskId,
taskTitle,
anchorRef: menu.triggerRef,
focusReturnRef: menu.triggerRef,
restoreFocusOnConfirm: true,
};
const confirmationTarget = buildConfirmationTarget(taskId, taskTitle, menu.triggerRef);
return (
<>
@@ -175,6 +184,7 @@ export function TaskActionsMenuDialogs({
taskTitle={taskTitle}
taskId={taskId}
executorType={boardRow?.primaryExecutorType ?? subjectExecutorType}
sharesParentWorkspace={cleanupSharesParentWorkspace(boardRow?.workspaceMode)}
isDeleting={isDeleting}
onConfirm={(opts) => menu.onConfirmDelete(opts)}
focusReturnRef={menu.triggerRef}
@@ -25,6 +25,15 @@ describe("getCleanupSummary (single task)", () => {
expect(notes.join(" ")).toMatch(/not affected/i);
});
it("preserves the parent workspace for inherited-parent tasks", () => {
expect(getCleanupSummary("worktree", { sharesParentWorkspace: true })).toEqual({
effects: [AGENT_STOP_EFFECT],
notes: [
"This task shares its parent's workspace. The parent task's worktree, branch, and files are not touched.",
],
});
});
it("describes local_docker container removal", () => {
const { effects, notes } = getCleanupSummary("local_docker");
expect(effects[0]).toMatch(/Docker container/i);
@@ -1,11 +1,22 @@
// Executor types match models.ExecutorType in apps/backend/internal/task/models/models.go.
import { t } from "@/lib/i18n";
import type { WorkspaceMode } from "@/lib/kanban/map-task";
export type CleanupSummary = {
effects: string[];
notes: string[];
};
export type CleanupSummaryOptions = {
sharesParentWorkspace?: boolean;
};
export function cleanupSharesParentWorkspace(
workspaceMode: WorkspaceMode | null | undefined,
): boolean | undefined {
return workspaceMode == null ? undefined : workspaceMode === "inherit_parent";
}
// mock_remote is test-only and intentionally falls through to the generic effect.
type KnownExecutor =
| "local"
@@ -45,6 +56,7 @@ const SINGLE_COPIES: Record<KnownExecutor, CleanupCopy> = {
};
const GENERIC_EFFECT_KEY = "task:cleanupAgentSessionsStopped";
const INHERITED_PARENT_WORKSPACE_NOTE_KEY = "task:cleanupInheritedParentWorkspaceNote";
function normalize(executorType: string | null | undefined): KnownExecutor | null {
if (!executorType) return null;
@@ -65,7 +77,16 @@ function resolveCopy(copy: CleanupCopy, options?: { count: number }): CleanupSum
}
/** Single-task variant. */
export function getCleanupSummary(executorType: string | null | undefined): CleanupSummary {
export function getCleanupSummary(
executorType: string | null | undefined,
options?: CleanupSummaryOptions,
): CleanupSummary {
if (options?.sharesParentWorkspace) {
return {
effects: [t(GENERIC_EFFECT_KEY)],
notes: [t(INHERITED_PARENT_WORKSPACE_NOTE_KEY)],
};
}
const known = normalize(executorType);
return known
? resolveCopy(SINGLE_COPIES[known])
@@ -14,7 +14,11 @@ vi.mock("@/lib/api", () => ({
import { TaskDeleteConfirmDialog } from "./task-delete-confirm-dialog";
import { expectCompactWarning } from "./task-confirm-dialog.test-helpers";
type SeedTask = { id: string; foregroundActivity?: "generating" | "background" | null };
type SeedTask = {
id: string;
foregroundActivity?: "generating" | "background" | null;
workspaceMode?: "inherit_parent" | "new_workspace" | "shared_group";
};
// The dialog reads live foreground_activity from the store via useTaskInFlight,
// so every render needs a StateProvider. `tasks` seeds the active kanban tasks
@@ -33,6 +37,7 @@ function renderDialog(ui: ReactNode, tasks: SeedTask[] = []) {
title: t.id,
position: 0,
foregroundActivity: t.foregroundActivity ?? undefined,
workspaceMode: t.workspaceMode,
})),
},
}}
@@ -444,6 +449,72 @@ describe("TaskDeleteConfirmDialog executor cleanup copy", () => {
});
});
describe("TaskDeleteConfirmDialog inherited-parent workspace copy", () => {
it("describes inherited-parent workspace preservation from explicit context", () => {
mockGetSubtaskCount.mockResolvedValue({ count: 0 });
renderDialog(
<TaskDeleteConfirmDialog
open
onOpenChange={() => {}}
taskTitle="Child task"
taskId={TASK_ID}
executorType="worktree"
sharesParentWorkspace
onConfirm={() => {}}
/>,
);
expect(screen.getByTestId("task-cleanup-effects").querySelectorAll("li")).toHaveLength(1);
expect(
screen.getByText(
/This task shares its parent's workspace\. The parent task's worktree, branch, and files are not touched\./i,
),
).toBeTruthy();
expect(screen.queryByText(/worktree and its branch will be deleted/i)).toBeNull();
});
it("resolves inherited-parent workspace preservation from the task store", () => {
mockGetSubtaskCount.mockResolvedValue({ count: 0 });
renderDialog(
<TaskDeleteConfirmDialog
open
onOpenChange={() => {}}
taskTitle="Child task"
taskId={TASK_ID}
executorType="worktree"
onConfirm={() => {}}
/>,
[{ id: TASK_ID, workspaceMode: "inherit_parent" }],
);
expect(
screen.getByText(
/This task shares its parent's workspace\. The parent task's worktree, branch, and files are not touched\./i,
),
).toBeTruthy();
expect(screen.queryByText(/worktree and its branch will be deleted/i)).toBeNull();
});
it("prefers the live task-store mode over stale caller context", () => {
mockGetSubtaskCount.mockResolvedValue({ count: 0 });
renderDialog(
<TaskDeleteConfirmDialog
open
onOpenChange={() => {}}
taskTitle="Child task"
taskId={TASK_ID}
executorType="worktree"
sharesParentWorkspace
onConfirm={() => {}}
/>,
[{ id: TASK_ID, workspaceMode: "new_workspace" }],
);
expect(screen.getByText(/worktree and its branch will be deleted/i)).toBeTruthy();
expect(screen.queryByText(/shares its parent's workspace/i)).toBeNull();
});
});
describe("TaskDeleteConfirmDialog still-working guard", () => {
it("keeps the shared in-flight warning visually subordinate", () => {
mockGetSubtaskCount.mockResolvedValue({ count: 0 });
@@ -23,6 +23,8 @@ import { useTaskInFlight } from "@/hooks/use-task-in-flight";
import { getCleanupSummary, getBulkCleanupSummary } from "./task-cleanup-summary";
import { TaskCleanupConsequences } from "./task-cleanup-consequences";
import { StillWorkingWarning } from "./task-still-working-warning";
import { useAppStore } from "@/components/state-provider";
import { findTaskInSnapshots } from "@/lib/kanban/find-task";
import {
TASK_CONFIRM_ACTION_CLASS,
TASK_CONFIRM_BODY_CLASS,
@@ -34,6 +36,14 @@ import {
import { useTranslation } from "react-i18next";
import { createFocusReturnHandler } from "@/lib/dialog-focus-return";
function useStoreSharesParentWorkspace(taskId: string | undefined, isBulkOperation?: boolean) {
return useAppStore((state) => {
if (isBulkOperation || !taskId) return undefined;
const task = findTaskInSnapshots(taskId, state.kanbanMulti.snapshots, state.kanban.tasks);
return task ? task.workspaceMode === "inherit_parent" : undefined;
});
}
type TaskDeleteConfirmDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
@@ -48,6 +58,8 @@ type TaskDeleteConfirmDialogProps = {
executorType?: string | null;
/** Executor types of the tasks being deleted (bulk). */
executorTypes?: Array<string | null | undefined>;
/** Whether the single task borrows its workspace from its parent. */
sharesParentWorkspace?: boolean;
onConfirm: (opts: { cascade: boolean; discardWorktreeChanges: boolean }) => void;
confirmTestId?: string;
/** Overrides default focus restoration when the original trigger may disappear. */
@@ -284,6 +296,7 @@ export function TaskDeleteConfirmDialog({
isInFlight,
executorType,
executorTypes,
sharesParentWorkspace,
onConfirm,
confirmTestId,
onCloseAutoFocus,
@@ -297,9 +310,12 @@ export function TaskDeleteConfirmDialog({
const description = isBulkOperation
? t("task:deleteTasksConfirm", { count: safeCount })
: t("task:deleteTaskConfirm", { taskTitle });
const storeSharesParentWorkspace = useStoreSharesParentWorkspace(taskId, isBulkOperation);
const cleanup = isBulkOperation
? getBulkCleanupSummary(executorTypes ?? [])
: getCleanupSummary(executorType);
: getCleanupSummary(executorType, {
sharesParentWorkspace: storeSharesParentWorkspace ?? sharesParentWorkspace,
});
const {
cascade,
@@ -19,6 +19,7 @@ import type { TaskPriority } from "@/lib/types/http";
import { TaskManagementMenu, type TaskManagementMenuProps } from "./task-management-menu";
import { TaskManagementDrawer, TaskManagementSheet } from "./task-management-drawer";
import { TaskArchiveConfirmation } from "./task-archive-confirmation";
import { cleanupSharesParentWorkspace } from "./task-cleanup-summary";
import { TaskDeleteConfirmDialog } from "./task-delete-confirm-dialog";
import { useSidebarLinkActions } from "./task-session-sidebar-link-actions";
import { useSidebarTaskLinking } from "./task-session-sidebar-task-linking";
@@ -215,6 +216,7 @@ function ManagementConfirmations({
taskId={task.id}
taskTitle={task.title}
executorType={task.remoteExecutorType}
sharesParentWorkspace={cleanupSharesParentWorkspace(task.workspaceMode)}
isDeleting={mutations.disabled}
onCloseAutoFocus={onCloseAutoFocus}
confirmTestId="thread-delete-confirm"
+10 -1
View File
@@ -3,7 +3,10 @@ import { createAppStore } from "@/lib/state/store";
import { resolveTaskMenuTarget } from "./task-menu-target";
const identity = { taskId: "A", workspaceId: "workspace" };
function fixture(loss?: string) {
function fixture(
loss?: string,
workspaceMode?: "inherit_parent" | "new_workspace" | "shared_group",
) {
const store = createAppStore();
store.setState((state) => ({
workspaces: { ...state.workspaces, activeId: loss === "workspace" ? "elsewhere" : "workspace" },
@@ -32,6 +35,7 @@ function fixture(loss?: string) {
priority: "high",
isArchived: loss === "archived",
primaryExecutorType: "worktree",
workspaceMode,
},
],
},
@@ -61,4 +65,9 @@ describe("task menu eligibility", () => {
expect(resolveTaskMenuTarget(state, identity)).toBeNull();
},
);
it("carries the task workspace mode into the management target", () => {
const state = fixture(undefined, "inherit_parent").getState();
expect(resolveTaskMenuTarget(state, identity)?.workspaceMode).toBe("inherit_parent");
});
});
+1
View File
@@ -32,5 +32,6 @@ export function resolveTaskMenuTarget(
remoteExecutorType: task.primaryExecutorType ?? undefined,
primarySessionId: task.primarySessionId,
parentTaskId: task.parentTaskId ?? undefined,
workspaceMode: task.workspaceMode,
};
}
+1
View File
@@ -2237,6 +2237,7 @@
"cleanupSingleSprites": "The Sprites cloud sandbox for this task will be destroyed. Any uncommitted work inside it will be lost.",
"cleanupSingleSsh": "The task directory on the remote host will be removed.",
"cleanupSingleSshNote": "Best-effort removal may not complete. Your local repo is not touched.",
"cleanupInheritedParentWorkspaceNote": "This task shares its parent's workspace. The parent task's worktree, branch, and files are not touched.",
"cleanupSingleWorktree": "The task's git worktree and its branch will be deleted.",
"cleanupSingleWorktreeNote": "Your main repo and other branches are not affected.",
"enhancedPromptCopiedToClipboard": "Enhanced prompt copied to clipboard.",
+1
View File
@@ -2237,6 +2237,7 @@
"cleanupSingleSprites": "このタスクの Sprites クラウドサンドボックスは破棄されます。中の未コミット作業は失われます。",
"cleanupSingleSsh": "リモートホスト上のタスクディレクトリが削除されます。",
"cleanupSingleSshNote": "ベストエフォート削除なので、完了しない場合があります。ローカルリポジトリには触れません。",
"cleanupInheritedParentWorkspaceNote": "このタスクは親タスクのワークスペースを共有します。親タスクの worktree、ブランチ、ファイルには触れません。",
"cleanupSingleWorktree": "タスクの git worktree とそのブランチが削除されます。",
"cleanupSingleWorktreeNote": "メインリポジトリと他のブランチには影響しません。",
"enhancedPromptCopiedToClipboard": "拡張プロンプトをクリップボードにコピーしました。",
+1
View File
@@ -2237,6 +2237,7 @@
"cleanupSingleSprites": "Ţĥē Śƥŕĩţēś ćĺōũď śàńďƀōx ƒōŕ ţĥĩś ţàśķ ŵĩĺĺ ƀē ďēśţŕōŷēď. Àńŷ ũńćōḿḿĩţţēď ŵōŕķ ĩńśĩďē ĩţ ŵĩĺĺ ƀē ĺōśţ.",
"cleanupSingleSsh": "Ţĥē ţàśķ ďĩŕēćţōŕŷ ōń ţĥē ŕēḿōţē ĥōśţ ŵĩĺĺ ƀē ŕēḿōvēď.",
"cleanupSingleSshNote": "Ɓēśţ-ēƒƒōŕţ ŕēḿōvàĺ ḿàŷ ńōţ ćōḿƥĺēţē. Ŷōũŕ ĺōćàĺ ŕēƥō ĩś ńōţ ţōũćĥēď.",
"cleanupInheritedParentWorkspaceNote": "Ţĥĩś ţàśķ śĥàŕēś ĩţś ƥàŕēńţ'ś ŵōŕķśƥàćē. Ţĥē ƥàŕēńţ ţàśķ'ś ŵōŕķţŕēē, ƀŕàńćĥ, àńď ƒĩĺēś àŕē ńōţ ţōũćĥēď.",
"cleanupSingleWorktree": "Ţĥē ţàśķ'ś ĝĩţ ŵōŕķţŕēē àńď ĩţś ƀŕàńćĥ ŵĩĺĺ ƀē ďēĺēţēď.",
"cleanupSingleWorktreeNote": "Ŷōũŕ ḿàĩń ŕēƥō àńď ōţĥēŕ ƀŕàńćĥēś àŕē ńōţ àƒƒēćţēď.",
"enhancedPromptCopiedToClipboard": "Ēńĥàńćēď ƥŕōḿƥţ ćōƥĩēď ţō ćĺĩƥƀōàŕď.",
+1
View File
@@ -2101,6 +2101,7 @@
"cleanupSingleSprites": "A sandbox na cloud Sprites desta tarefa será destruída. Todo o trabalho não commitado lá dentro será perdido.",
"cleanupSingleSsh": "O diretório da tarefa no anfitrião remoto será removido.",
"cleanupSingleSshNote": "A remoção é uma tentativa e pode não ser concluída. O seu repositório local não é tocado.",
"cleanupInheritedParentWorkspaceNote": "Esta tarefa partilha o espaço de trabalho da tarefa principal. O worktree, o ramo e os ficheiros da tarefa principal não serão tocados.",
"cleanupSingleWorktree": "O worktree git da tarefa e o seu ramo serão eliminados.",
"cleanupSingleWorktreeNote": "O seu repositório principal e os outros ramos não são afetados.",
"composerPlaceholderBusy": "Colocar instruções na fila para o agente...",
+1
View File
@@ -2101,6 +2101,7 @@
"cleanupSingleSprites": "该任务的 Sprites 云沙箱将被销毁。其中所有未提交的工作都会丢失。",
"cleanupSingleSsh": "远程主机上的任务目录将被移除。",
"cleanupSingleSshNote": "尽力移除,可能无法完成。你的本地仓库不会被改动。",
"cleanupInheritedParentWorkspaceNote": "此任务与父任务共享工作区。父任务的 worktree、分支和文件不会受到影响。",
"cleanupSingleWorktree": "该任务的 git worktree 及其分支将被删除。",
"cleanupSingleWorktreeNote": "你的主仓库和其他分支不受影响。",
"composerPlaceholderBusy": "为智能体排队指令…",
+1
View File
@@ -2101,6 +2101,7 @@
"cleanupSingleSprites": "該任務的 Sprites 雲沙箱將被銷燬。其中所有未提交的工作都會丟失。",
"cleanupSingleSsh": "遠端主機上的任務目錄將被移除。",
"cleanupSingleSshNote": "盡力移除,可能無法完成。你的本地儲存庫不會被改動。",
"cleanupInheritedParentWorkspaceNote": "此任務與父任務共享工作區。父任務的 worktree、分支和檔案不會受到影響。",
"cleanupSingleWorktree": "該任務的 git worktree 及其分支將被刪除。",
"cleanupSingleWorktreeNote": "你的主儲存庫和其他分支不受影響。",
"composerPlaceholderBusy": "為代理程式排隊指令…",
+1
View File
@@ -2101,6 +2101,7 @@
"cleanupSingleSprites": "該任務的 Sprites 雲沙箱將被銷燬。其中所有未提交的工作都會丟失。",
"cleanupSingleSsh": "遠端主機上的任務目錄將被移除。",
"cleanupSingleSshNote": "盡力移除,可能無法完成。你的本地儲存庫不會被改動。",
"cleanupInheritedParentWorkspaceNote": "此任務與父任務共享工作區。父任務的 worktree、分支和檔案不會受到影響。",
"cleanupSingleWorktree": "該任務的 git worktree 及其分支將被刪除。",
"cleanupSingleWorktreeNote": "你的主儲存庫和其他分支不受影響。",
"composerPlaceholderBusy": "為代理程式排隊指令…",