From 151c8bb5269ed33d2f90ef4fae2d9cc1fc60c63b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Tue, 22 Sep 2026 23:31:49 +0100 Subject: [PATCH] fix: preserve inherited workspace delete copy (#3864) --- apps/web/app/tasks/columns.tsx | 5 + apps/web/app/tasks/tasks-list-view.tsx | 4 + apps/web/components/kanban-card-menu.tsx | 2 + .../task/task-actions-menu-dialogs.tsx | 24 ++- .../task/task-cleanup-summary.test.ts | 9 ++ .../components/task/task-cleanup-summary.ts | 23 ++- .../task/task-delete-confirm-dialog.test.tsx | 73 ++++++++- .../task/task-delete-confirm-dialog.tsx | 18 ++- .../task/task-management-surface.tsx | 2 + apps/web/lib/tasks/task-menu-target.test.ts | 11 +- apps/web/lib/tasks/task-menu-target.ts | 1 + apps/web/src/locales/en/task.json | 1 + apps/web/src/locales/ja/task.json | 1 + apps/web/src/locales/pseudo/task.json | 1 + apps/web/src/locales/pt-pt/task.json | 1 + apps/web/src/locales/zh-cn/task.json | 1 + apps/web/src/locales/zh-hk/task.json | 1 + apps/web/src/locales/zh-tw/task.json | 1 + .../plan.md | 151 ++++++++++++++++++ ...task-01-inherited-workspace-delete-copy.md | 130 +++++++++++++++ .../confirmation-warning-hierarchy.md | 5 + .../confirmation-warning-hierarchy.md | 12 ++ 22 files changed, 466 insertions(+), 11 deletions(-) create mode 100644 docs/plans/delete-inherited-workspace-confirmation/plan.md create mode 100644 docs/plans/delete-inherited-workspace-confirmation/task-01-inherited-workspace-delete-copy.md diff --git a/apps/web/app/tasks/columns.tsx b/apps/web/app/tasks/columns.tsx index 9b5194e311..883fc80d7a 100644 --- a/apps/web/app/tasks/columns.tsx +++ b/apps/web/app/tasks/columns.tsx @@ -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; 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 }) diff --git a/apps/web/app/tasks/tasks-list-view.tsx b/apps/web/app/tasks/tasks-list-view.tsx index 8012f889e7..82a48b2819 100644 --- a/apps/web/app/tasks/tasks-list-view.tsx +++ b/apps/web/app/tasks/tasks-list-view.tsx @@ -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; }) { const { t } = useTranslation(); + const workspaceMode = workspaceModeFromMetadata(task.metadata); const archiveAnchorRef = useRef(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 }) diff --git a/apps/web/components/kanban-card-menu.tsx b/apps/web/components/kanban-card-menu.tsx index 8b8ab15e25..da7771ee97 100644 --- a/apps/web/components/kanban-card-menu.tsx +++ b/apps/web/components/kanban-card-menu.tsx @@ -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)} /> diff --git a/apps/web/components/task/task-actions-menu-dialogs.tsx b/apps/web/components/task/task-actions-menu-dialogs.tsx index 69d7374577..1b52eee297 100644 --- a/apps/web/components/task/task-actions-menu-dialogs.tsx +++ b/apps/web/components/task/task-actions-menu-dialogs.tsx @@ -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, +) { + 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} diff --git a/apps/web/components/task/task-cleanup-summary.test.ts b/apps/web/components/task/task-cleanup-summary.test.ts index 86b02fdd0e..267f037c1f 100644 --- a/apps/web/components/task/task-cleanup-summary.test.ts +++ b/apps/web/components/task/task-cleanup-summary.test.ts @@ -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); diff --git a/apps/web/components/task/task-cleanup-summary.ts b/apps/web/components/task/task-cleanup-summary.ts index 039542cc06..5ad4d78868 100644 --- a/apps/web/components/task/task-cleanup-summary.ts +++ b/apps/web/components/task/task-cleanup-summary.ts @@ -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 = { }; 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]) diff --git a/apps/web/components/task/task-delete-confirm-dialog.test.tsx b/apps/web/components/task/task-delete-confirm-dialog.test.tsx index 7ab99ba6e5..8b840d00fa 100644 --- a/apps/web/components/task/task-delete-confirm-dialog.test.tsx +++ b/apps/web/components/task/task-delete-confirm-dialog.test.tsx @@ -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( + {}} + 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( + {}} + 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( + {}} + 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 }); diff --git a/apps/web/components/task/task-delete-confirm-dialog.tsx b/apps/web/components/task/task-delete-confirm-dialog.tsx index bd90e683be..a33317d2a7 100644 --- a/apps/web/components/task/task-delete-confirm-dialog.tsx +++ b/apps/web/components/task/task-delete-confirm-dialog.tsx @@ -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; + /** 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, diff --git a/apps/web/components/task/task-management-surface.tsx b/apps/web/components/task/task-management-surface.tsx index f85c37cca3..3f2d7638c4 100644 --- a/apps/web/components/task/task-management-surface.tsx +++ b/apps/web/components/task/task-management-surface.tsx @@ -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" diff --git a/apps/web/lib/tasks/task-menu-target.test.ts b/apps/web/lib/tasks/task-menu-target.test.ts index d4ff090696..6d64d7f62e 100644 --- a/apps/web/lib/tasks/task-menu-target.test.ts +++ b/apps/web/lib/tasks/task-menu-target.test.ts @@ -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"); + }); }); diff --git a/apps/web/lib/tasks/task-menu-target.ts b/apps/web/lib/tasks/task-menu-target.ts index 12edb4d55c..e4444da688 100644 --- a/apps/web/lib/tasks/task-menu-target.ts +++ b/apps/web/lib/tasks/task-menu-target.ts @@ -32,5 +32,6 @@ export function resolveTaskMenuTarget( remoteExecutorType: task.primaryExecutorType ?? undefined, primarySessionId: task.primarySessionId, parentTaskId: task.parentTaskId ?? undefined, + workspaceMode: task.workspaceMode, }; } diff --git a/apps/web/src/locales/en/task.json b/apps/web/src/locales/en/task.json index b8d80b104d..afcd56ae54 100644 --- a/apps/web/src/locales/en/task.json +++ b/apps/web/src/locales/en/task.json @@ -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.", diff --git a/apps/web/src/locales/ja/task.json b/apps/web/src/locales/ja/task.json index ca893154c1..299b2536bc 100644 --- a/apps/web/src/locales/ja/task.json +++ b/apps/web/src/locales/ja/task.json @@ -2237,6 +2237,7 @@ "cleanupSingleSprites": "このタスクの Sprites クラウドサンドボックスは破棄されます。中の未コミット作業は失われます。", "cleanupSingleSsh": "リモートホスト上のタスクディレクトリが削除されます。", "cleanupSingleSshNote": "ベストエフォート削除なので、完了しない場合があります。ローカルリポジトリには触れません。", + "cleanupInheritedParentWorkspaceNote": "このタスクは親タスクのワークスペースを共有します。親タスクの worktree、ブランチ、ファイルには触れません。", "cleanupSingleWorktree": "タスクの git worktree とそのブランチが削除されます。", "cleanupSingleWorktreeNote": "メインリポジトリと他のブランチには影響しません。", "enhancedPromptCopiedToClipboard": "拡張プロンプトをクリップボードにコピーしました。", diff --git a/apps/web/src/locales/pseudo/task.json b/apps/web/src/locales/pseudo/task.json index 36a260cc1e..99be210e40 100644 --- a/apps/web/src/locales/pseudo/task.json +++ b/apps/web/src/locales/pseudo/task.json @@ -2237,6 +2237,7 @@ "cleanupSingleSprites": "Ţĥē Śƥŕĩţēś ćĺōũď śàńďƀōx ƒōŕ ţĥĩś ţàśķ ŵĩĺĺ ƀē ďēśţŕōŷēď. Àńŷ ũńćōḿḿĩţţēď ŵōŕķ ĩńśĩďē ĩţ ŵĩĺĺ ƀē ĺōśţ.", "cleanupSingleSsh": "Ţĥē ţàśķ ďĩŕēćţōŕŷ ōń ţĥē ŕēḿōţē ĥōśţ ŵĩĺĺ ƀē ŕēḿōvēď.", "cleanupSingleSshNote": "Ɓēśţ-ēƒƒōŕţ ŕēḿōvàĺ ḿàŷ ńōţ ćōḿƥĺēţē. Ŷōũŕ ĺōćàĺ ŕēƥō ĩś ńōţ ţōũćĥēď.", + "cleanupInheritedParentWorkspaceNote": "Ţĥĩś ţàśķ śĥàŕēś ĩţś ƥàŕēńţ'ś ŵōŕķśƥàćē. Ţĥē ƥàŕēńţ ţàśķ'ś ŵōŕķţŕēē, ƀŕàńćĥ, àńď ƒĩĺēś àŕē ńōţ ţōũćĥēď.", "cleanupSingleWorktree": "Ţĥē ţàśķ'ś ĝĩţ ŵōŕķţŕēē àńď ĩţś ƀŕàńćĥ ŵĩĺĺ ƀē ďēĺēţēď.", "cleanupSingleWorktreeNote": "Ŷōũŕ ḿàĩń ŕēƥō àńď ōţĥēŕ ƀŕàńćĥēś àŕē ńōţ àƒƒēćţēď.", "enhancedPromptCopiedToClipboard": "Ēńĥàńćēď ƥŕōḿƥţ ćōƥĩēď ţō ćĺĩƥƀōàŕď.", diff --git a/apps/web/src/locales/pt-pt/task.json b/apps/web/src/locales/pt-pt/task.json index a27de82401..8af7853349 100644 --- a/apps/web/src/locales/pt-pt/task.json +++ b/apps/web/src/locales/pt-pt/task.json @@ -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...", diff --git a/apps/web/src/locales/zh-cn/task.json b/apps/web/src/locales/zh-cn/task.json index 1acf8254e6..b12d983648 100644 --- a/apps/web/src/locales/zh-cn/task.json +++ b/apps/web/src/locales/zh-cn/task.json @@ -2101,6 +2101,7 @@ "cleanupSingleSprites": "该任务的 Sprites 云沙箱将被销毁。其中所有未提交的工作都会丢失。", "cleanupSingleSsh": "远程主机上的任务目录将被移除。", "cleanupSingleSshNote": "尽力移除,可能无法完成。你的本地仓库不会被改动。", + "cleanupInheritedParentWorkspaceNote": "此任务与父任务共享工作区。父任务的 worktree、分支和文件不会受到影响。", "cleanupSingleWorktree": "该任务的 git worktree 及其分支将被删除。", "cleanupSingleWorktreeNote": "你的主仓库和其他分支不受影响。", "composerPlaceholderBusy": "为智能体排队指令…", diff --git a/apps/web/src/locales/zh-hk/task.json b/apps/web/src/locales/zh-hk/task.json index 0e3ac75779..c512563467 100644 --- a/apps/web/src/locales/zh-hk/task.json +++ b/apps/web/src/locales/zh-hk/task.json @@ -2101,6 +2101,7 @@ "cleanupSingleSprites": "該任務的 Sprites 雲沙箱將被銷燬。其中所有未提交的工作都會丟失。", "cleanupSingleSsh": "遠端主機上的任務目錄將被移除。", "cleanupSingleSshNote": "盡力移除,可能無法完成。你的本地儲存庫不會被改動。", + "cleanupInheritedParentWorkspaceNote": "此任務與父任務共享工作區。父任務的 worktree、分支和檔案不會受到影響。", "cleanupSingleWorktree": "該任務的 git worktree 及其分支將被刪除。", "cleanupSingleWorktreeNote": "你的主儲存庫和其他分支不受影響。", "composerPlaceholderBusy": "為代理程式排隊指令…", diff --git a/apps/web/src/locales/zh-tw/task.json b/apps/web/src/locales/zh-tw/task.json index 092d37429e..f131a1863d 100644 --- a/apps/web/src/locales/zh-tw/task.json +++ b/apps/web/src/locales/zh-tw/task.json @@ -2101,6 +2101,7 @@ "cleanupSingleSprites": "該任務的 Sprites 雲沙箱將被銷燬。其中所有未提交的工作都會丟失。", "cleanupSingleSsh": "遠端主機上的任務目錄將被移除。", "cleanupSingleSshNote": "盡力移除,可能無法完成。你的本地儲存庫不會被改動。", + "cleanupInheritedParentWorkspaceNote": "此任務與父任務共享工作區。父任務的 worktree、分支和檔案不會受到影響。", "cleanupSingleWorktree": "該任務的 git worktree 及其分支將被刪除。", "cleanupSingleWorktreeNote": "你的主儲存庫和其他分支不受影響。", "composerPlaceholderBusy": "為代理程式排隊指令…", diff --git a/docs/plans/delete-inherited-workspace-confirmation/plan.md b/docs/plans/delete-inherited-workspace-confirmation/plan.md new file mode 100644 index 0000000000..66fa2e3125 --- /dev/null +++ b/docs/plans/delete-inherited-workspace-confirmation/plan.md @@ -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. diff --git a/docs/plans/delete-inherited-workspace-confirmation/task-01-inherited-workspace-delete-copy.md b/docs/plans/delete-inherited-workspace-confirmation/task-01-inherited-workspace-delete-copy.md new file mode 100644 index 0000000000..7787dde11b --- /dev/null +++ b/docs/plans/delete-inherited-workspace-confirmation/task-01-inherited-workspace-delete-copy.md @@ -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`. diff --git a/docs/specs/ui/requirements/confirmation-warning-hierarchy.md b/docs/specs/ui/requirements/confirmation-warning-hierarchy.md index 5862b336b9..01b9aa8a86 100644 --- a/docs/specs/ui/requirements/confirmation-warning-hierarchy.md +++ b/docs/specs/ui/requirements/confirmation-warning-hierarchy.md @@ -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 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. +- **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 [task runtime cleanup](../../tasks/requirements/runtime-cleanup.md), diff --git a/docs/specs/ui/system-design/confirmation-warning-hierarchy.md b/docs/specs/ui/system-design/confirmation-warning-hierarchy.md index 42bdd36872..e80c6efbf5 100644 --- a/docs/specs/ui/system-design/confirmation-warning-hierarchy.md +++ b/docs/specs/ui/system-design/confirmation-warning-hierarchy.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 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 `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 content, semantic action variants, unchanged callbacks, and that a deferred 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 outside pointer intent. Rendered desktop and phone checks cover viewport bounds, scroll ownership,