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 { Badge } from "@kandev/ui/badge";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { formatTimeDistance, useDateLocale } from "@/lib/i18n/date-locale"; 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 { TaskDeleteConfirmDialog } from "@/components/task/task-delete-confirm-dialog";
import { TaskArchiveConfirmation } from "@/components/task/task-archive-confirmation"; import { TaskArchiveConfirmation } from "@/components/task/task-archive-confirmation";
import { linkToTask } from "@/lib/links"; import { linkToTask } from "@/lib/links";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { t } from "@/lib/i18n"; import { t } from "@/lib/i18n";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { workspaceModeFromMetadata } from "@/lib/kanban/map-task";
type TaskWithResolution = Task & { type TaskWithResolution = Task & {
workflowName?: string; workflowName?: string;
@@ -132,6 +134,9 @@ function ActionsCell({ row, ctx }: { row: Row<TaskWithResolution>; ctx: ActionsC
taskTitle={task.title} taskTitle={task.title}
taskId={task.id} taskId={task.id}
executorType={task.primary_executor_type} executorType={task.primary_executor_type}
sharesParentWorkspace={cleanupSharesParentWorkspace(
workspaceModeFromMetadata(task.metadata),
)}
isDeleting={isDeleting} isDeleting={isDeleting}
onConfirm={({ cascade, discardWorktreeChanges }) => onConfirm={({ cascade, discardWorktreeChanges }) =>
ctx.onDelete(task.id, { 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 { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { IconArchive, IconArchiveOff, IconLoader, IconTrash } from "@tabler/icons-react"; import { IconArchive, IconArchiveOff, IconLoader, IconTrash } from "@tabler/icons-react";
import { TaskArchiveConfirmation } from "@/components/task/task-archive-confirmation"; 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 { TaskDeleteConfirmDialog } from "@/components/task/task-delete-confirm-dialog";
import { primaryTaskRepository, type Repository, type Task, type Workflow } from "@/lib/types/http"; import { primaryTaskRepository, type Repository, type Task, type Workflow } from "@/lib/types/http";
import { formatTaskStateLabel } from "@/lib/ui/state-labels"; import { formatTaskStateLabel } from "@/lib/ui/state-labels";
@@ -20,6 +21,7 @@ import { useTranslation } from "react-i18next";
import { t } from "@/lib/i18n"; import { t } from "@/lib/i18n";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import type { TaskListFacetValue } from "@/lib/plugins/types"; import type { TaskListFacetValue } from "@/lib/plugins/types";
import { workspaceModeFromMetadata } from "@/lib/kanban/map-task";
export type TasksListViewProps = { export type TasksListViewProps = {
total: number; total: number;
@@ -548,6 +550,7 @@ function TaskRowActions({
) => Promise<void>; ) => Promise<void>;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const workspaceMode = workspaceModeFromMetadata(task.metadata);
const archiveAnchorRef = useRef<HTMLButtonElement>(null); const archiveAnchorRef = useRef<HTMLButtonElement>(null);
const { isFinePointer } = useResponsiveBreakpoint(); const { isFinePointer } = useResponsiveBreakpoint();
return ( return (
@@ -601,6 +604,7 @@ function TaskRowActions({
taskId={task.id} taskId={task.id}
isInFlight={isTaskInFlight(task.foreground_activity)} isInFlight={isTaskInFlight(task.foreground_activity)}
executorType={task.primary_executor_type} executorType={task.primary_executor_type}
sharesParentWorkspace={cleanupSharesParentWorkspace(workspaceMode)}
isDeleting={isDeleting} isDeleting={isDeleting}
onConfirm={({ cascade, discardWorktreeChanges }) => onConfirm={({ cascade, discardWorktreeChanges }) =>
onDelete(task.id, { cascade, discardWorktreeChanges }) onDelete(task.id, { cascade, discardWorktreeChanges })
+2
View File
@@ -6,6 +6,7 @@ import {
useKanbanCardMoveTargets, useKanbanCardMoveTargets,
} from "@/components/kanban-card-menu-items"; } from "@/components/kanban-card-menu-items";
import { useTaskPluginLinkActions } from "@/components/task/task-session-sidebar-link-actions"; 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 { TaskDeleteConfirmDialog } from "@/components/task/task-delete-confirm-dialog";
import { import {
TaskExternalLinkDialog, TaskExternalLinkDialog,
@@ -269,6 +270,7 @@ export function KanbanCardDialogs({
taskTitle={task.title} taskTitle={task.title}
taskId={task.id} taskId={task.id}
executorType={task.primaryExecutorType} executorType={task.primaryExecutorType}
sharesParentWorkspace={cleanupSharesParentWorkspace(task.workspaceMode)}
isDeleting={isDeleting} isDeleting={isDeleting}
onConfirm={(opts) => onDelete?.(task, opts)} onConfirm={(opts) => onDelete?.(task, opts)}
/> />
@@ -4,6 +4,7 @@ import { useRef, type RefObject } from "react";
import { useActiveWorkspaceRepositories } from "@/components/kanban-card-repositories"; import { useActiveWorkspaceRepositories } from "@/components/kanban-card-repositories";
import { TaskArchiveConfirmation } from "@/components/task/task-archive-confirmation"; import { TaskArchiveConfirmation } from "@/components/task/task-archive-confirmation";
import { TaskCreateDialog } from "@/components/task-create-dialog"; 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 { TaskDeleteConfirmDialog } from "@/components/task/task-delete-confirm-dialog";
import { TaskDetachConfirmationSurface } from "@/components/task/task-detach-confirm-dialog"; import { TaskDetachConfirmationSurface } from "@/components/task/task-detach-confirm-dialog";
import { TaskExternalLinkDialog } from "@/components/task/task-external-link-dialog"; import { TaskExternalLinkDialog } from "@/components/task/task-external-link-dialog";
@@ -122,6 +123,20 @@ function useLastResolvedBoardRow(
return ref.current.boardRow; 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 * Mounts every dialog a task actions menu entry can open. Hosted per surface
* so a dialog outlives the menu that opened it. * so a dialog outlives the menu that opened it.
@@ -140,13 +155,7 @@ export function TaskActionsMenuDialogs({
const editBoardRow = useLastResolvedBoardRow(taskId, boardRow); const editBoardRow = useLastResolvedBoardRow(taskId, boardRow);
if (!taskId) return null; if (!taskId) return null;
const linkDialogTask = buildLinkDialogTask(taskId, taskTitle, boardRow); const linkDialogTask = buildLinkDialogTask(taskId, taskTitle, boardRow);
const confirmationTarget = { const confirmationTarget = buildConfirmationTarget(taskId, taskTitle, menu.triggerRef);
taskId,
taskTitle,
anchorRef: menu.triggerRef,
focusReturnRef: menu.triggerRef,
restoreFocusOnConfirm: true,
};
return ( return (
<> <>
@@ -175,6 +184,7 @@ export function TaskActionsMenuDialogs({
taskTitle={taskTitle} taskTitle={taskTitle}
taskId={taskId} taskId={taskId}
executorType={boardRow?.primaryExecutorType ?? subjectExecutorType} executorType={boardRow?.primaryExecutorType ?? subjectExecutorType}
sharesParentWorkspace={cleanupSharesParentWorkspace(boardRow?.workspaceMode)}
isDeleting={isDeleting} isDeleting={isDeleting}
onConfirm={(opts) => menu.onConfirmDelete(opts)} onConfirm={(opts) => menu.onConfirmDelete(opts)}
focusReturnRef={menu.triggerRef} focusReturnRef={menu.triggerRef}
@@ -25,6 +25,15 @@ describe("getCleanupSummary (single task)", () => {
expect(notes.join(" ")).toMatch(/not affected/i); 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", () => { it("describes local_docker container removal", () => {
const { effects, notes } = getCleanupSummary("local_docker"); const { effects, notes } = getCleanupSummary("local_docker");
expect(effects[0]).toMatch(/Docker container/i); expect(effects[0]).toMatch(/Docker container/i);
@@ -1,11 +1,22 @@
// Executor types match models.ExecutorType in apps/backend/internal/task/models/models.go. // Executor types match models.ExecutorType in apps/backend/internal/task/models/models.go.
import { t } from "@/lib/i18n"; import { t } from "@/lib/i18n";
import type { WorkspaceMode } from "@/lib/kanban/map-task";
export type CleanupSummary = { export type CleanupSummary = {
effects: string[]; effects: string[];
notes: 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. // mock_remote is test-only and intentionally falls through to the generic effect.
type KnownExecutor = type KnownExecutor =
| "local" | "local"
@@ -45,6 +56,7 @@ const SINGLE_COPIES: Record<KnownExecutor, CleanupCopy> = {
}; };
const GENERIC_EFFECT_KEY = "task:cleanupAgentSessionsStopped"; const GENERIC_EFFECT_KEY = "task:cleanupAgentSessionsStopped";
const INHERITED_PARENT_WORKSPACE_NOTE_KEY = "task:cleanupInheritedParentWorkspaceNote";
function normalize(executorType: string | null | undefined): KnownExecutor | null { function normalize(executorType: string | null | undefined): KnownExecutor | null {
if (!executorType) return null; if (!executorType) return null;
@@ -65,7 +77,16 @@ function resolveCopy(copy: CleanupCopy, options?: { count: number }): CleanupSum
} }
/** Single-task variant. */ /** 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); const known = normalize(executorType);
return known return known
? resolveCopy(SINGLE_COPIES[known]) ? resolveCopy(SINGLE_COPIES[known])
@@ -14,7 +14,11 @@ vi.mock("@/lib/api", () => ({
import { TaskDeleteConfirmDialog } from "./task-delete-confirm-dialog"; import { TaskDeleteConfirmDialog } from "./task-delete-confirm-dialog";
import { expectCompactWarning } from "./task-confirm-dialog.test-helpers"; 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, // The dialog reads live foreground_activity from the store via useTaskInFlight,
// so every render needs a StateProvider. `tasks` seeds the active kanban tasks // 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, title: t.id,
position: 0, position: 0,
foregroundActivity: t.foregroundActivity ?? undefined, 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", () => { describe("TaskDeleteConfirmDialog still-working guard", () => {
it("keeps the shared in-flight warning visually subordinate", () => { it("keeps the shared in-flight warning visually subordinate", () => {
mockGetSubtaskCount.mockResolvedValue({ count: 0 }); mockGetSubtaskCount.mockResolvedValue({ count: 0 });
@@ -23,6 +23,8 @@ import { useTaskInFlight } from "@/hooks/use-task-in-flight";
import { getCleanupSummary, getBulkCleanupSummary } from "./task-cleanup-summary"; import { getCleanupSummary, getBulkCleanupSummary } from "./task-cleanup-summary";
import { TaskCleanupConsequences } from "./task-cleanup-consequences"; import { TaskCleanupConsequences } from "./task-cleanup-consequences";
import { StillWorkingWarning } from "./task-still-working-warning"; import { StillWorkingWarning } from "./task-still-working-warning";
import { useAppStore } from "@/components/state-provider";
import { findTaskInSnapshots } from "@/lib/kanban/find-task";
import { import {
TASK_CONFIRM_ACTION_CLASS, TASK_CONFIRM_ACTION_CLASS,
TASK_CONFIRM_BODY_CLASS, TASK_CONFIRM_BODY_CLASS,
@@ -34,6 +36,14 @@ import {
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { createFocusReturnHandler } from "@/lib/dialog-focus-return"; 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 = { type TaskDeleteConfirmDialogProps = {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
@@ -48,6 +58,8 @@ type TaskDeleteConfirmDialogProps = {
executorType?: string | null; executorType?: string | null;
/** Executor types of the tasks being deleted (bulk). */ /** Executor types of the tasks being deleted (bulk). */
executorTypes?: Array<string | null | undefined>; executorTypes?: Array<string | null | undefined>;
/** Whether the single task borrows its workspace from its parent. */
sharesParentWorkspace?: boolean;
onConfirm: (opts: { cascade: boolean; discardWorktreeChanges: boolean }) => void; onConfirm: (opts: { cascade: boolean; discardWorktreeChanges: boolean }) => void;
confirmTestId?: string; confirmTestId?: string;
/** Overrides default focus restoration when the original trigger may disappear. */ /** Overrides default focus restoration when the original trigger may disappear. */
@@ -284,6 +296,7 @@ export function TaskDeleteConfirmDialog({
isInFlight, isInFlight,
executorType, executorType,
executorTypes, executorTypes,
sharesParentWorkspace,
onConfirm, onConfirm,
confirmTestId, confirmTestId,
onCloseAutoFocus, onCloseAutoFocus,
@@ -297,9 +310,12 @@ export function TaskDeleteConfirmDialog({
const description = isBulkOperation const description = isBulkOperation
? t("task:deleteTasksConfirm", { count: safeCount }) ? t("task:deleteTasksConfirm", { count: safeCount })
: t("task:deleteTaskConfirm", { taskTitle }); : t("task:deleteTaskConfirm", { taskTitle });
const storeSharesParentWorkspace = useStoreSharesParentWorkspace(taskId, isBulkOperation);
const cleanup = isBulkOperation const cleanup = isBulkOperation
? getBulkCleanupSummary(executorTypes ?? []) ? getBulkCleanupSummary(executorTypes ?? [])
: getCleanupSummary(executorType); : getCleanupSummary(executorType, {
sharesParentWorkspace: storeSharesParentWorkspace ?? sharesParentWorkspace,
});
const { const {
cascade, cascade,
@@ -19,6 +19,7 @@ import type { TaskPriority } from "@/lib/types/http";
import { TaskManagementMenu, type TaskManagementMenuProps } from "./task-management-menu"; import { TaskManagementMenu, type TaskManagementMenuProps } from "./task-management-menu";
import { TaskManagementDrawer, TaskManagementSheet } from "./task-management-drawer"; import { TaskManagementDrawer, TaskManagementSheet } from "./task-management-drawer";
import { TaskArchiveConfirmation } from "./task-archive-confirmation"; import { TaskArchiveConfirmation } from "./task-archive-confirmation";
import { cleanupSharesParentWorkspace } from "./task-cleanup-summary";
import { TaskDeleteConfirmDialog } from "./task-delete-confirm-dialog"; import { TaskDeleteConfirmDialog } from "./task-delete-confirm-dialog";
import { useSidebarLinkActions } from "./task-session-sidebar-link-actions"; import { useSidebarLinkActions } from "./task-session-sidebar-link-actions";
import { useSidebarTaskLinking } from "./task-session-sidebar-task-linking"; import { useSidebarTaskLinking } from "./task-session-sidebar-task-linking";
@@ -215,6 +216,7 @@ function ManagementConfirmations({
taskId={task.id} taskId={task.id}
taskTitle={task.title} taskTitle={task.title}
executorType={task.remoteExecutorType} executorType={task.remoteExecutorType}
sharesParentWorkspace={cleanupSharesParentWorkspace(task.workspaceMode)}
isDeleting={mutations.disabled} isDeleting={mutations.disabled}
onCloseAutoFocus={onCloseAutoFocus} onCloseAutoFocus={onCloseAutoFocus}
confirmTestId="thread-delete-confirm" confirmTestId="thread-delete-confirm"
+10 -1
View File
@@ -3,7 +3,10 @@ import { createAppStore } from "@/lib/state/store";
import { resolveTaskMenuTarget } from "./task-menu-target"; import { resolveTaskMenuTarget } from "./task-menu-target";
const identity = { taskId: "A", workspaceId: "workspace" }; const identity = { taskId: "A", workspaceId: "workspace" };
function fixture(loss?: string) { function fixture(
loss?: string,
workspaceMode?: "inherit_parent" | "new_workspace" | "shared_group",
) {
const store = createAppStore(); const store = createAppStore();
store.setState((state) => ({ store.setState((state) => ({
workspaces: { ...state.workspaces, activeId: loss === "workspace" ? "elsewhere" : "workspace" }, workspaces: { ...state.workspaces, activeId: loss === "workspace" ? "elsewhere" : "workspace" },
@@ -32,6 +35,7 @@ function fixture(loss?: string) {
priority: "high", priority: "high",
isArchived: loss === "archived", isArchived: loss === "archived",
primaryExecutorType: "worktree", primaryExecutorType: "worktree",
workspaceMode,
}, },
], ],
}, },
@@ -61,4 +65,9 @@ describe("task menu eligibility", () => {
expect(resolveTaskMenuTarget(state, identity)).toBeNull(); 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, remoteExecutorType: task.primaryExecutorType ?? undefined,
primarySessionId: task.primarySessionId, primarySessionId: task.primarySessionId,
parentTaskId: task.parentTaskId ?? undefined, 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.", "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.", "cleanupSingleSsh": "The task directory on the remote host will be removed.",
"cleanupSingleSshNote": "Best-effort removal may not complete. Your local repo is not touched.", "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.", "cleanupSingleWorktree": "The task's git worktree and its branch will be deleted.",
"cleanupSingleWorktreeNote": "Your main repo and other branches are not affected.", "cleanupSingleWorktreeNote": "Your main repo and other branches are not affected.",
"enhancedPromptCopiedToClipboard": "Enhanced prompt copied to clipboard.", "enhancedPromptCopiedToClipboard": "Enhanced prompt copied to clipboard.",
+1
View File
@@ -2237,6 +2237,7 @@
"cleanupSingleSprites": "このタスクの Sprites クラウドサンドボックスは破棄されます。中の未コミット作業は失われます。", "cleanupSingleSprites": "このタスクの Sprites クラウドサンドボックスは破棄されます。中の未コミット作業は失われます。",
"cleanupSingleSsh": "リモートホスト上のタスクディレクトリが削除されます。", "cleanupSingleSsh": "リモートホスト上のタスクディレクトリが削除されます。",
"cleanupSingleSshNote": "ベストエフォート削除なので、完了しない場合があります。ローカルリポジトリには触れません。", "cleanupSingleSshNote": "ベストエフォート削除なので、完了しない場合があります。ローカルリポジトリには触れません。",
"cleanupInheritedParentWorkspaceNote": "このタスクは親タスクのワークスペースを共有します。親タスクの worktree、ブランチ、ファイルには触れません。",
"cleanupSingleWorktree": "タスクの git worktree とそのブランチが削除されます。", "cleanupSingleWorktree": "タスクの git worktree とそのブランチが削除されます。",
"cleanupSingleWorktreeNote": "メインリポジトリと他のブランチには影響しません。", "cleanupSingleWorktreeNote": "メインリポジトリと他のブランチには影響しません。",
"enhancedPromptCopiedToClipboard": "拡張プロンプトをクリップボードにコピーしました。", "enhancedPromptCopiedToClipboard": "拡張プロンプトをクリップボードにコピーしました。",
+1
View File
@@ -2237,6 +2237,7 @@
"cleanupSingleSprites": "Ţĥē Śƥŕĩţēś ćĺōũď śàńďƀōx ƒōŕ ţĥĩś ţàśķ ŵĩĺĺ ƀē ďēśţŕōŷēď. Àńŷ ũńćōḿḿĩţţēď ŵōŕķ ĩńśĩďē ĩţ ŵĩĺĺ ƀē ĺōśţ.", "cleanupSingleSprites": "Ţĥē Śƥŕĩţēś ćĺōũď śàńďƀōx ƒōŕ ţĥĩś ţàśķ ŵĩĺĺ ƀē ďēśţŕōŷēď. Àńŷ ũńćōḿḿĩţţēď ŵōŕķ ĩńśĩďē ĩţ ŵĩĺĺ ƀē ĺōśţ.",
"cleanupSingleSsh": "Ţĥē ţàśķ ďĩŕēćţōŕŷ ōń ţĥē ŕēḿōţē ĥōśţ ŵĩĺĺ ƀē ŕēḿōvēď.", "cleanupSingleSsh": "Ţĥē ţàśķ ďĩŕēćţōŕŷ ōń ţĥē ŕēḿōţē ĥōśţ ŵĩĺĺ ƀē ŕēḿōvēď.",
"cleanupSingleSshNote": "Ɓēśţ-ēƒƒōŕţ ŕēḿōvàĺ ḿàŷ ńōţ ćōḿƥĺēţē. Ŷōũŕ ĺōćàĺ ŕēƥō ĩś ńōţ ţōũćĥēď.", "cleanupSingleSshNote": "Ɓēśţ-ēƒƒōŕţ ŕēḿōvàĺ ḿàŷ ńōţ ćōḿƥĺēţē. Ŷōũŕ ĺōćàĺ ŕēƥō ĩś ńōţ ţōũćĥēď.",
"cleanupInheritedParentWorkspaceNote": "Ţĥĩś ţàśķ śĥàŕēś ĩţś ƥàŕēńţ'ś ŵōŕķśƥàćē. Ţĥē ƥàŕēńţ ţàśķ'ś ŵōŕķţŕēē, ƀŕàńćĥ, àńď ƒĩĺēś àŕē ńōţ ţōũćĥēď.",
"cleanupSingleWorktree": "Ţĥē ţàśķ'ś ĝĩţ ŵōŕķţŕēē àńď ĩţś ƀŕàńćĥ ŵĩĺĺ ƀē ďēĺēţēď.", "cleanupSingleWorktree": "Ţĥē ţàśķ'ś ĝĩţ ŵōŕķţŕēē àńď ĩţś ƀŕàńćĥ ŵĩĺĺ ƀē ďēĺēţēď.",
"cleanupSingleWorktreeNote": "Ŷōũŕ ḿàĩń ŕēƥō àńď ōţĥēŕ ƀŕàńćĥēś àŕē ńōţ àƒƒēćţēď.", "cleanupSingleWorktreeNote": "Ŷōũŕ ḿàĩń ŕēƥō àńď ōţĥēŕ ƀŕàńćĥēś àŕē ńōţ àƒƒēćţēď.",
"enhancedPromptCopiedToClipboard": "Ēńĥàńćēď ƥŕōḿƥţ ćōƥĩēď ţō ćĺĩƥƀōàŕď.", "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.", "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.", "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.", "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.", "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.", "cleanupSingleWorktreeNote": "O seu repositório principal e os outros ramos não são afetados.",
"composerPlaceholderBusy": "Colocar instruções na fila para o agente...", "composerPlaceholderBusy": "Colocar instruções na fila para o agente...",
+1
View File
@@ -2101,6 +2101,7 @@
"cleanupSingleSprites": "该任务的 Sprites 云沙箱将被销毁。其中所有未提交的工作都会丢失。", "cleanupSingleSprites": "该任务的 Sprites 云沙箱将被销毁。其中所有未提交的工作都会丢失。",
"cleanupSingleSsh": "远程主机上的任务目录将被移除。", "cleanupSingleSsh": "远程主机上的任务目录将被移除。",
"cleanupSingleSshNote": "尽力移除,可能无法完成。你的本地仓库不会被改动。", "cleanupSingleSshNote": "尽力移除,可能无法完成。你的本地仓库不会被改动。",
"cleanupInheritedParentWorkspaceNote": "此任务与父任务共享工作区。父任务的 worktree、分支和文件不会受到影响。",
"cleanupSingleWorktree": "该任务的 git worktree 及其分支将被删除。", "cleanupSingleWorktree": "该任务的 git worktree 及其分支将被删除。",
"cleanupSingleWorktreeNote": "你的主仓库和其他分支不受影响。", "cleanupSingleWorktreeNote": "你的主仓库和其他分支不受影响。",
"composerPlaceholderBusy": "为智能体排队指令…", "composerPlaceholderBusy": "为智能体排队指令…",
+1
View File
@@ -2101,6 +2101,7 @@
"cleanupSingleSprites": "該任務的 Sprites 雲沙箱將被銷燬。其中所有未提交的工作都會丟失。", "cleanupSingleSprites": "該任務的 Sprites 雲沙箱將被銷燬。其中所有未提交的工作都會丟失。",
"cleanupSingleSsh": "遠端主機上的任務目錄將被移除。", "cleanupSingleSsh": "遠端主機上的任務目錄將被移除。",
"cleanupSingleSshNote": "盡力移除,可能無法完成。你的本地儲存庫不會被改動。", "cleanupSingleSshNote": "盡力移除,可能無法完成。你的本地儲存庫不會被改動。",
"cleanupInheritedParentWorkspaceNote": "此任務與父任務共享工作區。父任務的 worktree、分支和檔案不會受到影響。",
"cleanupSingleWorktree": "該任務的 git worktree 及其分支將被刪除。", "cleanupSingleWorktree": "該任務的 git worktree 及其分支將被刪除。",
"cleanupSingleWorktreeNote": "你的主儲存庫和其他分支不受影響。", "cleanupSingleWorktreeNote": "你的主儲存庫和其他分支不受影響。",
"composerPlaceholderBusy": "為代理程式排隊指令…", "composerPlaceholderBusy": "為代理程式排隊指令…",
+1
View File
@@ -2101,6 +2101,7 @@
"cleanupSingleSprites": "該任務的 Sprites 雲沙箱將被銷燬。其中所有未提交的工作都會丟失。", "cleanupSingleSprites": "該任務的 Sprites 雲沙箱將被銷燬。其中所有未提交的工作都會丟失。",
"cleanupSingleSsh": "遠端主機上的任務目錄將被移除。", "cleanupSingleSsh": "遠端主機上的任務目錄將被移除。",
"cleanupSingleSshNote": "盡力移除,可能無法完成。你的本地儲存庫不會被改動。", "cleanupSingleSshNote": "盡力移除,可能無法完成。你的本地儲存庫不會被改動。",
"cleanupInheritedParentWorkspaceNote": "此任務與父任務共享工作區。父任務的 worktree、分支和檔案不會受到影響。",
"cleanupSingleWorktree": "該任務的 git worktree 及其分支將被刪除。", "cleanupSingleWorktree": "該任務的 git worktree 及其分支將被刪除。",
"cleanupSingleWorktreeNote": "你的主儲存庫和其他分支不受影響。", "cleanupSingleWorktreeNote": "你的主儲存庫和其他分支不受影響。",
"composerPlaceholderBusy": "為代理程式排隊指令…", "composerPlaceholderBusy": "為代理程式排隊指令…",
@@ -0,0 +1,151 @@
---
created: 2026-09-22
status: complete
requirements:
- REQ-UI-TASK-CLEANUP-CONFIRMATION-001
- REQ-TASKS-RUNTIME-CLEANUP-001
system_design:
- ../../specs/ui/system-design/confirmation-warning-hierarchy.md
- ../../specs/tasks/system-design/runtime-cleanup.md
legacy_specs: []
---
# Implementation Plan: Preserve inherited parent workspace copy
## Overview
Correct single-task delete confirmation copy when a subtask uses
`workspaceMode: "inherit_parent"`. The existing cleanup model keys only on the
executor and therefore tells users that the child's worktree and branch will be
deleted, even though task cleanup preserves the borrowed parent environment.
The implementation updates the shared summary model, the delete dialog's
explicit and task-store-derived context, all specified task-aware callers, and
the supported locale catalogs.
The work is one sequential vertical slice. The existing backend cleanup
contract remains unchanged; this package makes the confirmation reflect it.
## Scope
### In scope
- Add the inherited-parent cleanup summary branch and localized note.
- Resolve inherited-parent state explicitly at task-aware call sites and through
the dialog's task-id fallback.
- Preserve the existing bulk cleanup model, deletion callbacks, layout, focus,
and consent behavior.
- Update English, Portuguese, Simplified Chinese, Traditional Chinese, and
Japanese task catalogs.
- Add summary and rendered-dialog regression coverage.
### Out of scope
- Backend cleanup ownership, reference counting, or worktree deletion logic.
- Bulk delete copy or bulk workspace-mode inference.
- A new confirmation surface or responsive layout change.
- Public documentation.
## Technical approach
1. Extend `getCleanupSummary` with an optional `CleanupSummaryOptions` object.
The inherited-parent branch returns the existing session-stop translation
and a new parent-workspace note before the executor-specific branch runs.
2. Add `sharesParentWorkspace` to `TaskDeleteConfirmDialogProps`. For single
deletes, use the prop when present and otherwise resolve the task with
`findTaskInSnapshots(taskId, state.kanbanMulti.snapshots, state.kanban.tasks)`
through `useAppStore`. Bulk deletes continue using grouped executor copy.
3. Pass the task's workspace mode from the five specified callers. Other
callers remain protected by the dialog fallback when they provide a task ID.
4. Add the new key to all six task catalogs. Run the existing Traditional
Chinese generator after updating `zh-cn` and verify catalog parity.
5. Add failing summary and dialog tests, then implement the smallest production
change and rerun the focused tests before the full task checks.
## ASCII UI preview
### UI-01: Inherited-parent workspace delete confirmation
Entry point: delete action for a child task with a worktree executor and
`workspaceMode = inherit_parent`. Desktop and phone use the existing centered
`AlertDialog`; on phones the existing body scrolls while the footer remains
reachable. The copy hierarchy is the same at both sizes.
Current behavior:
```text
+----------------------------------------------+
| Delete task |
| Delete "Child task". This action cannot be |
| undone. |
| |
| Cleanup |
| * The task's git worktree and its branch |
| will be deleted. |
| Your main repo and other branches are not |
| affected. |
| * Any running agent sessions will be stopped.|
| Cancel Delete |
+----------------------------------------------+
```
Corrected behavior:
```text
+----------------------------------------------+
| Delete task |
| Delete "Child task". This action cannot be |
| undone. |
| |
| Cleanup |
| * Any running agent sessions will be stopped.|
| This task shares its parent's workspace. The |
| parent task's worktree, branch, and files |
| are not touched. |
| Cancel Delete |
+----------------------------------------------+
```
Structural requirements: keep the named delete outcome, one effects list, one
supporting note, existing focus and consent behavior, and existing action
reachability. ASCII spacing is illustrative, not a pixel contract. The
corrected view covers `AC-UI-TASK-CLEANUP-CONFIRMATION-001.15`.
## Tests
| Acceptance criterion | Evidence |
| --- | --- |
| `AC-UI-TASK-CLEANUP-CONFIRMATION-001.15` | `apps/web/components/task/task-cleanup-summary.test.ts` verifies the exact structured branch; `apps/web/components/task/task-delete-confirm-dialog.test.tsx` verifies rendered parent-worktree preservation and task-id fallback. |
| `AC-TASKS-RUNTIME-CLEANUP-001.3` | The same rendered copy regression reflects the existing shared-environment cleanup contract. |
## E2E tests
No new Playwright test is required. This is a content and state-normalization
change inside the existing confirmation surface. The mobile composition,
interaction, and geometry remain unchanged; the focused rendered component
test covers the new branch, and the existing mobile confirmation contract
continues to apply.
## Work orders
- [x] [Task 01: Correct inherited workspace delete confirmation](task-01-inherited-workspace-delete-copy.md)
## Verification results
- Focused summary, dialog, and task-menu tests passed: 56 tests across 3 files.
- `pnpm run typecheck`, `pnpm run lint`, `pnpm run build:vite`, and `git diff --check` passed.
- The scoped Traditional Chinese task catalog generator passed.
- `pnpm run i18n:check` remains blocked by 32 pre-existing missing Japanese keys
in `executors` and `task`; the new inherited-workspace key is present in all
six supported catalogs.
- The full Traditional Chinese generator remains blocked by two pre-existing
simplified Chinese values in `zh-hk/workflows` and `zh-tw/workflows`.
## Risks
- A missing task in the store must keep the current executor-based fallback so
unrelated dialogs do not lose cleanup information.
- Bulk delete must continue using executor grouping and must not infer one
workspace mode for several tasks.
- The repository currently reports unrelated missing Japanese catalog keys in
`pnpm run i18n:check`; the final result must distinguish that baseline issue
from any key introduced by this change.
@@ -0,0 +1,130 @@
---
id: "01-inherited-workspace-delete-copy"
title: "Correct inherited workspace delete confirmation"
status: complete
wave: 1
depends_on: []
plan: "plan.md"
requirements:
- REQ-UI-TASK-CLEANUP-CONFIRMATION-001
- REQ-TASKS-RUNTIME-CLEANUP-001
acceptance_criteria:
- AC-UI-TASK-CLEANUP-CONFIRMATION-001.15
- AC-TASKS-RUNTIME-CLEANUP-001.3
system_design:
- ../../specs/ui/system-design/confirmation-warning-hierarchy.md
- ../../specs/tasks/system-design/runtime-cleanup.md
---
# Task 01: Correct inherited workspace delete confirmation
## Summary
Make single-task delete confirmation accurately describe a child task that
shares its parent's materialized workspace. The summary will show only the
agent-session stop effect plus the parent-workspace preservation note, with
explicit caller context and a task-store fallback for callers that provide only
the task ID.
## In scope
- Extend the single-task cleanup summary options and inherited-parent branch.
- Add the dialog prop, store lookup fallback, and five specified call-site
values.
- Add the English and five translated catalog values.
- Add summary and dialog regressions, including explicit prop and fallback
behavior.
## Out of scope
- Backend task cleanup or worktree lifecycle behavior.
- Bulk cleanup summary changes.
- Confirmation layout, touch sizing, focus, or callback changes.
- New Playwright coverage for an unchanged mobile composition.
## Acceptance
- `getCleanupSummary(_, { sharesParentWorkspace: true })` returns exactly one
agent-session effect and exactly one inherited-parent workspace note, with no
worktree or branch deletion copy.
- A rendered delete dialog shows the preserved parent worktree, branch, and
files when the explicit prop is true or when the task ID resolves to
`inherit_parent` in the app store.
- Existing executor-specific single and bulk copy, deletion callbacks, and
consent behavior remain unchanged for all other tasks.
## ASCII UI preview
See [UI-01 in the plan](plan.md#ui-01-inherited-parent-workspace-delete-confirmation).
The dialog keeps the existing desktop and phone composition; only the cleanup
effects and supporting note change for inherited-parent tasks.
```text
Cleanup
* Any running agent sessions will be stopped.
This task shares its parent's workspace. The parent task's worktree,
branch, and files are not touched.
Cancel Delete
```
## Verification
```bash
(cd apps/web && pnpm run i18n:zh-hant)
(cd apps/web && pnpm test components/task/task-cleanup-summary.test.ts components/task/task-delete-confirm-dialog.test.tsx)
(cd apps/web && pnpm run typecheck)
(cd apps/web && pnpm run i18n:check)
(cd apps/web && pnpm run lint)
```
## Files likely touched
- `apps/web/components/task/task-cleanup-summary.ts`
- `apps/web/components/task/task-cleanup-summary.test.ts`
- `apps/web/components/task/task-delete-confirm-dialog.tsx`
- `apps/web/components/task/task-delete-confirm-dialog.test.tsx`
- `apps/web/components/task/task-actions-menu-dialogs.tsx`
- `apps/web/components/kanban-card-menu.tsx`
- `apps/web/components/task/task-management-surface.tsx`
- `apps/web/app/tasks/columns.tsx`
- `apps/web/app/tasks/tasks-list-view.tsx`
- `apps/web/src/locales/{en,pt-pt,zh-cn,zh-hk,zh-tw,ja}/task.json`
## Dependencies
None.
## Risks
- Callers with stale or missing task projections must retain the generic or
executor-based summary through the dialog fallback.
- Traditional Chinese catalogs are generator-managed and must be updated with
`pnpm run i18n:zh-hant`.
- The repository's existing Japanese catalog gaps may keep the full i18n check
red after this task; report the exact pre-existing failures.
## Parallelism
`sequential`
## Inputs
- `docs/specs/ui/requirements/confirmation-warning-hierarchy.md`, especially
`AC-UI-TASK-CLEANUP-CONFIRMATION-001.15`.
- `docs/specs/ui/system-design/confirmation-warning-hierarchy.md`.
- `docs/specs/tasks/requirements/runtime-cleanup.md`,
`AC-TASKS-RUNTIME-CLEANUP-001.3`.
- `docs/specs/tasks/system-design/runtime-cleanup.md`.
- `apps/web/AGENTS.md`.
- `.agents/skills/tdd/SKILL.md` and `.agents/skills/mobile-parity/SKILL.md`.
## Results
Implemented the inherited-parent cleanup branch, explicit task-aware caller
context, task-store fallback, supported locale values, and regression coverage.
The focused test files pass with 56 tests. Typecheck, lint, Vite build, and
diff validation pass. The scoped Traditional Chinese task catalog generation
passes. The full i18n check still reports the 32 pre-existing Japanese missing
keys, and the full Traditional Chinese generator still reports two pre-existing
simplified Chinese values in `zh-hk/workflows` and `zh-tw/workflows`.
@@ -163,6 +163,11 @@ repository resources, so that I can understand the consequence before acting.
- **AC-UI-TASK-CLEANUP-CONFIRMATION-001.14:** At phone widths, the discard - **AC-UI-TASK-CLEANUP-CONFIRMATION-001.14:** At phone widths, the discard
selection shall remain inside the existing centered dialog and its scrolling selection shall remain inside the existing centered dialog and its scrolling
body. Its label shall provide a touch target of at least 44 CSS px. body. Its label shall provide a touch target of at least 44 CSS px.
- **AC-UI-TASK-CLEANUP-CONFIRMATION-001.15:** When a task uses an inherited
parent workspace, its delete confirmation shall state that any running agent
sessions for the task will be stopped and that the parent task's worktree,
branch, and files remain untouched. It shall omit statements that the task's
worktree or branch will be deleted.
Consent inspection, clean-workspace suppression, and failure recovery follow Consent inspection, clean-workspace suppression, and failure recovery follow
[task runtime cleanup](../../tasks/requirements/runtime-cleanup.md), [task runtime cleanup](../../tasks/requirements/runtime-cleanup.md),
@@ -68,6 +68,16 @@ archive popover and inline confirmation use the same ordered model with their
established compact spacing. This removes copy drift without moving cleanup established compact spacing. This removes copy drift without moving cleanup
policy into a visual component. policy into a visual component.
Single-task delete also accepts an inherited-parent workspace context. When
that context is true, `getCleanupSummary` returns only the generic agent-session
effect and the localized note that the parent task's worktree, branch, and files
remain untouched. It does not return the worktree deletion effect or its
single-worktree reassurance. `TaskDeleteConfirmDialog` receives this context
from task-aware callers and, when a caller provides only `taskId`, resolves
`workspaceMode` from `kanbanMulti.snapshots` with `kanban.tasks` as the fallback.
If the task cannot be resolved, the existing executor-based summary remains the
safe fallback.
### Full-dialog composition ### Full-dialog composition
`TaskDeleteConfirmDialog` keeps Radix `AlertDialog` and the existing centered `TaskDeleteConfirmDialog` keeps Radix `AlertDialog` and the existing centered
@@ -253,6 +263,8 @@ disabled actions, single and bulk callback values, cascade behavior, and typed
conflict feedback. Dialog and compact-surface tests assert equivalent structured conflict feedback. Dialog and compact-surface tests assert equivalent structured
content, semantic action variants, unchanged callbacks, and that a deferred content, semantic action variants, unchanged callbacks, and that a deferred
positive descendant result does not expose a provisional fine-pointer popover. positive descendant result does not expose a provisional fine-pointer popover.
The inherited-parent cleanup path is covered at both the summary and rendered
dialog boundaries, including the task-id resolution fallback.
Pending-dismissal component cases cover Escape, trigger focus restoration, and Pending-dismissal component cases cover Escape, trigger focus restoration, and
outside pointer intent. outside pointer intent.
Rendered desktop and phone checks cover viewport bounds, scroll ownership, Rendered desktop and phone checks cover viewport bounds, scroll ownership,