From 18f1e1fcc3ca3aaa0ae74e9b8ad1210304575251 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 9 Oct 2026 01:21:31 -0400 Subject: [PATCH] Q151: deleting a task first shows what goes with it, then waits for the result Co-Authored-By: Claude Opus 5.5 --- src/main/app/ipc-contracts/organization.ts | 1 + src/main/app/ipc-handlers.ts | 1 + src/main/services/organization-service.ts | 5 +++ src/main/services/task-mutations.ts | 15 +++++++ src/preload/index.ts | 1 + src/renderer/src/i18n/zh.ts | 9 ++++ src/renderer/src/pages/WorkPage.tsx | 1 + .../src/pages/work/TaskDetailDialog.tsx | 45 ++++++++++++++++--- src/shared/desktop-api.ts | 3 ++ src/shared/domain.ts | 16 +++++++ src/shared/ipc.ts | 1 + tests/e2e/ceo-chat.spec.ts | 41 +++++++++++++++++ tests/unit/task-delete-impact.test.ts | 37 +++++++++++++++ 13 files changed, 171 insertions(+), 5 deletions(-) create mode 100644 tests/unit/task-delete-impact.test.ts diff --git a/src/main/app/ipc-contracts/organization.ts b/src/main/app/ipc-contracts/organization.ts index a5c5bf16..a007876e 100644 --- a/src/main/app/ipc-contracts/organization.ts +++ b/src/main/app/ipc-contracts/organization.ts @@ -46,6 +46,7 @@ export const organizationContracts = { }) satisfies z.ZodType, deleteTask: z.strictObject({ orgId: id, taskId: id }) satisfies z.ZodType, setTaskBlocked: z.strictObject({ orgId: id, taskId: id, reason: z.string().nullable() }) satisfies z.ZodType, + taskDeleteImpact: z.strictObject({ orgId: id, taskId: id }), updateTaskStatus: z.strictObject({ orgId: id, taskId: id, status: z.enum(TASK_STATUS) }) satisfies z.ZodType, runTask: z.strictObject({ orgId: id, taskId: id }) satisfies z.ZodType, createGoal: z.strictObject({ orgId: id, title: z.string(), description: z.string(), ownerAgentId: nullableId }) satisfies z.ZodType, diff --git a/src/main/app/ipc-handlers.ts b/src/main/app/ipc-handlers.ts index 5e5ee2c4..289ba74f 100644 --- a/src/main/app/ipc-handlers.ts +++ b/src/main/app/ipc-handlers.ts @@ -165,6 +165,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { handle("createTask", (input) => mutate(() => organization.createTask(input))); handle("updateTask", (input) => mutate(() => organization.updateTask(input))); handle("setTaskBlocked", (input) => mutate(() => organization.setTaskBlocked(input))); + handle("taskDeleteImpact", (input) => ok(organization.taskDeleteImpact(input.orgId, input.taskId))); handle("deleteTask", (input) => mutate(() => organization.deleteTask(input))); handle("updateTaskStatus", (input) => mutate(() => organization.updateTaskStatus(input.orgId, input.taskId, input.status))); handle("runTask", async (input) => { diff --git a/src/main/services/organization-service.ts b/src/main/services/organization-service.ts index a3cecb5f..30d5df9e 100644 --- a/src/main/services/organization-service.ts +++ b/src/main/services/organization-service.ts @@ -1,3 +1,4 @@ +import type { TaskDeleteImpact } from "../../shared/domain"; import type { DatabaseSync } from "node:sqlite"; import { APPROVAL_STATE, RISK_LEVEL, TASK_STATUS, WRITING_MODE, type RiskLevel } from "../../shared/domain"; import { createId } from "../../shared/ids"; @@ -472,6 +473,10 @@ export class OrganizationService { this.tasks.update(input); } + taskDeleteImpact(orgId: string, taskId: string): TaskDeleteImpact { + return this.tasks.deleteImpact(orgId, taskId); + } + setTaskBlocked(input: SetTaskBlockedInput): void { this.tasks.setBlocked(input); } diff --git a/src/main/services/task-mutations.ts b/src/main/services/task-mutations.ts index 07160bba..455c74d7 100644 --- a/src/main/services/task-mutations.ts +++ b/src/main/services/task-mutations.ts @@ -1,3 +1,4 @@ +import type { TaskDeleteImpact } from "../../shared/domain"; import type { DatabaseSync } from "node:sqlite"; import { RISK_LEVEL, TASK_STATUS, type TaskPriority } from "../../shared/domain"; import type { CreateTaskInput, DeleteTaskInput, SetTaskBlockedInput, UpdateTaskInput } from "../../shared/commands"; @@ -70,6 +71,20 @@ export class TaskMutations { }); } + /** What deleting the task would remove, keep and close, counted from the database. */ + deleteImpact(orgId: string, taskId: string): TaskDeleteImpact { + this.requireTask(orgId, taskId); + const count = (sql: string) => (this.db.prepare(sql).get(taskId, orgId) as { n: number }).n; + return { + runs: count("select count(*) as n from task_runs where task_id = ? and org_id = ?"), + running: count("select count(*) as n from task_runs where task_id = ? and org_id = ? and state = 'running'"), + subtasks: count("select count(*) as n from tasks where parent_task_id = ? and org_id = ?"), + documents: count("select count(*) as n from documents where task_id = ? and org_id = ?"), + openReviews: count("select count(*) as n from tasks where review_of_task_id = ? and org_id = ? and status <> 'done'"), + pendingApprovals: count("select count(*) as n from approvals where source_entity_type = 'task' and source_entity_id = ? and org_id = ? and state = 'pending'"), + }; + } + /** Blocks the task with a reason (no session starts, queued ones are superseded) or unblocks it with null. */ setBlocked(input: SetTaskBlockedInput): void { const reason = input.reason === null ? null : input.reason.trim(); diff --git a/src/preload/index.ts b/src/preload/index.ts index 3713cff9..85bc68b8 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -91,6 +91,7 @@ const api: DesktopApi = { chatThreadHistory: (input) => invoke(IPC_CHANNELS.chatThreadHistory, input), chatSpaceHistory: (input) => invoke(IPC_CHANNELS.chatSpaceHistory, input), setTaskBlocked: (input) => invoke(IPC_CHANNELS.setTaskBlocked, input), + taskDeleteImpact: (input) => invoke(IPC_CHANNELS.taskDeleteImpact, input), revokeConnector: (input) => invoke(IPC_CHANNELS.revokeConnector, input), refreshComposioAccounts: (input) => invoke(IPC_CHANNELS.refreshComposioAccounts, input), search: (input) => invoke(IPC_CHANNELS.search, input), diff --git a/src/renderer/src/i18n/zh.ts b/src/renderer/src/i18n/zh.ts index d508ccda..08bcd3c3 100644 --- a/src/renderer/src/i18n/zh.ts +++ b/src/renderer/src/i18n/zh.ts @@ -39,6 +39,15 @@ export const zh: Record = { "Send back": "退回", "Reopen": "重新打开", "Back to backlog": "退回待办", + "Delete this task?": "删除这个任务?", + "Delete task": "删除任务", + "Nothing else is affected.": "不影响其他内容。", + "{n} sessions and their action records are deleted.": "{n} 次会话及其操作记录将被删除。", + "{n} subtasks stay and move to the top level.": "{n} 个子任务保留,并移到顶层。", + "{n} documents stay, no longer linked to this task.": "{n} 份文档保留,不再关联此任务。", + "{n} open reviews of it are closed.": "{n} 个进行中的审查将被关闭。", + "{n} pending approvals for it stay pending.": "{n} 个待处理审批仍保持待处理。", + "A session is running; stop it or wait for it before deleting.": "有会话正在运行;请先停止或等待其结束再删除。", "needs you": "待你处理", "waiting": "等待中", "Resumes on its own at {time}.": "将于 {time} 自动恢复。", diff --git a/src/renderer/src/pages/WorkPage.tsx b/src/renderer/src/pages/WorkPage.tsx index fa930994..459eebb9 100644 --- a/src/renderer/src/pages/WorkPage.tsx +++ b/src/renderer/src/pages/WorkPage.tsx @@ -161,6 +161,7 @@ export function WorkPage({ snapshot, actions, navigate, focus }: PageProps) { setNewProjectOpen(false)} /> setNewTaskOpen(false)} /> ("overview"); const [blocking, setBlocking] = useState(false); + // The impact is kept with the task it was counted for, and the delete removes exactly that task. + const [deleteImpact, setDeleteImpact] = useState<{ taskId: string; impact: TaskDeleteImpact } | null>(null); const [actionError, setActionError] = useState(null); const act = (operation: () => Promise, after?: () => void) => { setActionError(null); @@ -115,10 +117,17 @@ export function TaskDetailDialog({ })).then((r) => { if (r) setEditing(false); }, (error: unknown) => setEditError(error instanceof Error ? error.message : String(error))); }; - const deleteTask = () => { - void actions.run(() => window.agentCompany.deleteTask({ orgId, taskId: task.id })); - onClose(); + const askDelete = () => { + const taskId = task.id; + setActionError(null); + window.agentCompany.taskDeleteImpact({ orgId, taskId }).then((result) => { + if (!result.ok) throw new Error(result.error.message); + setDeleteImpact({ taskId, impact: result.data }); + }).catch((error: unknown) => setActionError(error instanceof Error ? error.message : String(error))); }; + const confirmDelete = (taskId: string) => act(async () => { + await actions.run(() => window.agentCompany.deleteTask({ orgId, taskId })); + }, onClose); return (
@@ -175,9 +184,12 @@ export function TaskDetailDialog({ )} + {deleteImpact && deleteImpact.taskId === task.id && ( + confirmDelete(deleteImpact.taskId)} onCancel={() => setDeleteImpact(null)} /> + )} {actionError &&
{actionError}
}
- + {task.blockedReason ? : task.status !== TASK_STATUS.DONE && !blocking && } @@ -382,3 +394,26 @@ function MetaLabel({ children }: { children: string }) { function LinkButton({ label, onClick, children }: { label: string; onClick: () => void; children: string }) { return ; } + +/** What a delete will do, said before it happens; a running session stops it. */ +function DeleteConfirm({ impact, onConfirm, onCancel }: { impact: TaskDeleteImpact; onConfirm: () => void; onCancel: () => void }) { + const lines = [ + impact.runs > 0 && t("{n} sessions and their action records are deleted.", { n: impact.runs }), + impact.subtasks > 0 && t("{n} subtasks stay and move to the top level.", { n: impact.subtasks }), + impact.documents > 0 && t("{n} documents stay, no longer linked to this task.", { n: impact.documents }), + impact.openReviews > 0 && t("{n} open reviews of it are closed.", { n: impact.openReviews }), + impact.pendingApprovals > 0 && t("{n} pending approvals for it stay pending.", { n: impact.pendingApprovals }), + ].filter((line): line is string => typeof line === "string"); + return ( +
+
{t("Delete this task?")}
+ {lines.length === 0 &&
{t("Nothing else is affected.")}
} + {lines.map((line) =>
{line}
)} + {impact.running > 0 &&
{t("A session is running; stop it or wait for it before deleting.")}
} +
+ + +
+
+ ); +} diff --git a/src/shared/desktop-api.ts b/src/shared/desktop-api.ts index f11562ff..68d85dd8 100644 --- a/src/shared/desktop-api.ts +++ b/src/shared/desktop-api.ts @@ -1,3 +1,4 @@ +import type { TaskDeleteImpact } from "./domain"; import type { SpaceHistoryPage, ThreadHistory } from "./chat-domain"; import type { RunRequestRecord } from "./provider-runtime-domain"; import type { ApprovalDrift } from "./approval-pin"; @@ -202,6 +203,8 @@ export interface DesktopApi { /** The page of a conversation before a loaded message, for history older than the snapshot holds. */ /** Blocks a task with a reason, or unblocks it with null. */ setTaskBlocked(input: SetTaskBlockedInput): Promise>; + /** What deleting a task would remove, keep and close. */ + taskDeleteImpact(input: { orgId: string; taskId: string }): Promise>; chatSpaceHistory(input: { orgId: string; spaceId: string; beforeMessageId: string | null } & ({ limit: number } | { fromMessageId: string })): Promise>; revokeConnector(input: RevokeConnectorInput): Promise>; search(input: SearchInput): Promise>; diff --git a/src/shared/domain.ts b/src/shared/domain.ts index 774bb5da..11bf8210 100644 --- a/src/shared/domain.ts +++ b/src/shared/domain.ts @@ -89,6 +89,22 @@ export type TaskHold = | { reason: string; waitingOn: "provider"; resumesAt: string } | { reason: string; waitingOn: "operator"; resumesAt: string | null }; +/** What deleting a task removes, keeps and closes, and whether a running session stops it. */ +export interface TaskDeleteImpact { + /** Sessions removed with the task, with their action journals. */ + runs: number; + /** Sessions still running; the delete is refused while any is. */ + running: number; + /** Subtasks kept, moved to the top level. */ + subtasks: number; + /** Documents kept, no longer linked to the task. */ + documents: number; + /** Open reviews of the task, closed by the delete. */ + openReviews: number; + /** Approvals requested for the task that stay pending. */ + pendingApprovals: number; +} + export interface TaskRecord { id: string; orgId: string; diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 9d0d51a5..e8dd3ef5 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -84,6 +84,7 @@ export const IPC_CHANNELS = { chatThreadHistory: "agentcompany:chat-thread-history", chatSpaceHistory: "agentcompany:chat-space-history", setTaskBlocked: "agentcompany:set-task-blocked", + taskDeleteImpact: "agentcompany:task-delete-impact", revokeConnector: "agentcompany:revoke-connector", search: "agentcompany:search", rendererEvent: "agentcompany:renderer-event", diff --git a/tests/e2e/ceo-chat.spec.ts b/tests/e2e/ceo-chat.spec.ts index 398a01d6..ea93cd31 100644 --- a/tests/e2e/ceo-chat.spec.ts +++ b/tests/e2e/ceo-chat.spec.ts @@ -251,6 +251,47 @@ test("the pinned CEO conversation is the Messages front door", async () => { await page.locator(".convo-item").first().click(); await page.getByRole("button", { name: /^Members \(/ }).click(); await expect(page.getByRole("menu")).toContainText("Replies to every message"); + await page.keyboard.press("Escape"); + + // Deleting a task first says what it takes with it, then waits for the result. + await page.evaluate(async () => { + const api = (window as unknown as { agentCompany: { + loadSnapshot: () => Promise<{ ok: true; data: { activeOrgId: string; tasks: Array<{ id: string; title: string }> } }>; + createTask: (input: unknown) => Promise<{ ok: boolean }>; + updateTask: (input: unknown) => Promise<{ ok: boolean }>; + } }).agentCompany; + const before = (await api.loadSnapshot()).data; + const parent = before.tasks.find((task) => task.title === "Operationalize goal: Ship the FlowPilot MVP.")!; + await api.createTask({ + orgId: before.activeOrgId, title: "Child step", description: "d", status: "backlog", assigneeAgentId: null, risk: "low", + projectId: null, goalId: null, workspaceId: null, priority: "normal", requiresFilesystem: false, + }); + const child = (await api.loadSnapshot()).data.tasks.find((task) => task.title === "Child step")!; + await api.updateTask({ + orgId: before.activeOrgId, taskId: child.id, title: "Child step", description: "d", assigneeAgentId: null, risk: "low", priority: "normal", + parentTaskId: parent.id, projectId: null, goalId: null, workspaceId: null, requiresFilesystem: false, writingMode: "conversational", + }); + }); + await page.getByRole("button", { name: "Work" }).click(); + await page.getByRole("button", { name: /Operationalize goal: Ship the FlowPilot MVP\./ }).first().click(); + const parentDialog = page.getByRole("dialog", { name: "Operationalize goal: Ship the FlowPilot MVP." }); + await parentDialog.getByRole("button", { name: "Delete", exact: true }).click(); + await expect(parentDialog.getByRole("alertdialog", { name: "Delete this task?" })).toContainText("1 subtasks stay and move to the top level."); + // Switching to another task drops a pending confirmation, so it can never delete the wrong task. + await parentDialog.getByRole("button", { name: "Subtasks" }).click(); + await parentDialog.getByRole("button", { name: "Open task Child step" }).click(); + const childDialog = page.getByRole("dialog", { name: "Child step" }); + await expect(childDialog).toBeVisible(); + await expect(childDialog.getByRole("alertdialog")).toHaveCount(0); + await childDialog.getByRole("button", { name: "Close" }).click(); + await page.getByRole("button", { name: /Operationalize goal: Ship the FlowPilot MVP\./ }).first().click(); + const doomed = page.getByRole("dialog", { name: "Operationalize goal: Ship the FlowPilot MVP." }); + await doomed.getByRole("button", { name: "Delete", exact: true }).click(); + const confirm = doomed.getByRole("alertdialog", { name: "Delete this task?" }); + await expect(confirm).toContainText("sessions and their action records are deleted."); + await confirm.getByRole("button", { name: "Delete task" }).click(); + await expect(doomed).toHaveCount(0); + await expect(page.getByRole("button", { name: /Operationalize goal: Ship the FlowPilot MVP\./ })).toHaveCount(0); } finally { await app.close(); } diff --git a/tests/unit/task-delete-impact.test.ts b/tests/unit/task-delete-impact.test.ts new file mode 100644 index 00000000..31898eff --- /dev/null +++ b/tests/unit/task-delete-impact.test.ts @@ -0,0 +1,37 @@ +import { randomUUID } from "node:crypto"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, test } from "vitest"; +import { DatabaseConnection } from "../../src/main/database/connection"; +import { OrganizationService } from "../../src/main/services/organization-service"; +import { DocumentService } from "../../src/main/services/document-service"; +import { RISK_LEVEL, TASK_PRIORITY, TASK_STATUS } from "../../src/shared/domain"; + +describe("deleting a task", () => { + test("says beforehand what goes with it, what stays, and what stops it", () => { + const connection = new DatabaseConnection(join(tmpdir(), `ac-delete-impact-${randomUUID()}.db`)); + const organization = new OrganizationService(connection.db); + const ids: Record = {}; + const task = (title: string) => organization.createTask({ + orgId: ids.org, title, description: "d", status: TASK_STATUS.BACKLOG, assigneeAgentId: null, risk: RISK_LEVEL.LOW, + projectId: null, goalId: null, workspaceId: null, priority: TASK_PRIORITY.NORMAL, requiresFilesystem: false, + }); + connection.transaction(() => { + ids.org = organization.createOrganization({ organizationName: "Acme", employeeName: "Yang", mission: "m", goal: "g" }); + ids.agent = organization.createAgent({ orgId: ids.org, name: "Ada", role: "Engineer", department: "Engineering", modelProviderId: null, modelId: null, budgetCents: 1000, managerAgentId: null }); + ids.task = task("Parent"); + ids.sub = task("Child"); + connection.db.prepare("update tasks set parent_task_id = ? where id = ?").run(ids.task, ids.sub); + new DocumentService(connection.db).create({ orgId: ids.org, title: "Spec", docType: "spec", content: "c", authorAgentId: null, reviewerAgentId: null, projectId: null, goalId: null, taskId: ids.task }); + for (const state of ["completed", "failed"]) { + connection.db.prepare("insert into task_runs(id, org_id, task_id, agent_id, trigger, state, created_at) values (?, ?, ?, ?, 'manual', ?, ?)") + .run(`run_${state}`, ids.org, ids.task, ids.agent, state, new Date().toISOString()); + } + }); + expect(organization.taskDeleteImpact(ids.org, ids.task)).toEqual({ runs: 2, running: 0, subtasks: 1, documents: 1, openReviews: 0, pendingApprovals: 0 }); + connection.db.prepare("update task_runs set state = 'running' where id = 'run_failed'").run(); + expect(organization.taskDeleteImpact(ids.org, ids.task).running).toBe(1); + expect(() => organization.taskDeleteImpact(ids.org, "tsk_missing")).toThrow("Task was not found."); + connection.close(); + }); +});