Q151: deleting a task first shows what goes with it, then waits for the result

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-09 01:21:31 -04:00
1 parent 809c67a54a
commit 18f1e1fcc3
13 files changed
+171 -5

No files matched your search

@@ -46,6 +46,7 @@ export const organizationContracts = {
}) satisfies z.ZodType<C.UpdateTaskInput>,
deleteTask: z.strictObject({ orgId: id, taskId: id }) satisfies z.ZodType<C.DeleteTaskInput>,
setTaskBlocked: z.strictObject({ orgId: id, taskId: id, reason: z.string().nullable() }) satisfies z.ZodType<C.SetTaskBlockedInput>,
taskDeleteImpact: z.strictObject({ orgId: id, taskId: id }),
updateTaskStatus: z.strictObject({ orgId: id, taskId: id, status: z.enum(TASK_STATUS) }) satisfies z.ZodType<C.UpdateTaskStatusInput>,
runTask: z.strictObject({ orgId: id, taskId: id }) satisfies z.ZodType<C.RunTaskInput>,
createGoal: z.strictObject({ orgId: id, title: z.string(), description: z.string(), ownerAgentId: nullableId }) satisfies z.ZodType<C.CreateGoalInput>,
+1
View File
@@ -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) => {
@@ -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);
}
+15
View File
@@ -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();
+1
View File
@@ -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),
+9
View File
@@ -39,6 +39,15 @@ export const zh: Record<string, string> = {
"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} 自动恢复。",
+1
View File
@@ -161,6 +161,7 @@ export function WorkPage({ snapshot, actions, navigate, focus }: PageProps) {
<ProjectDialog open={newProjectOpen} orgId={orgId} goals={goals} agents={agents} actions={actions} onClose={() => setNewProjectOpen(false)} />
<NewTaskDialog open={newTaskOpen} orgId={orgId} snapshot={snapshot} actions={actions} onClose={() => setNewTaskOpen(false)} />
<TaskDetailDialog
key={detailTask?.id ?? "none"}
task={detailTask}
agents={agents}
projects={projects}
@@ -12,7 +12,7 @@ import type {
TaskRunRecord,
WorkspaceRecord,
} from "../../../../shared/domain";
import { TASK_STATUS, type TaskStatus, type WritingMode } from "../../../../shared/domain";
import { TASK_STATUS, type TaskDeleteImpact, type TaskStatus, type WritingMode } from "../../../../shared/domain";
import { auditEventLabel, formatRelative } from "../types";
import { WRITING_OPTIONS, descendantIds, nextStatus, prettyRunState, prettyStatus, providerFor, runBlocker, runsForTask, runTone } from "./task-helpers";
import type { SnapshotActions } from "../../app/useSnapshot";
@@ -73,6 +73,8 @@ export function TaskDetailDialog({
}) {
const [tab, setTab] = useState<Tab>("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<string | null>(null);
const act = (operation: () => Promise<void>, 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 (
<div className="modal-backdrop" role="presentation" onMouseDown={onClose}>
@@ -175,9 +184,12 @@ export function TaskDetailDialog({
<Button tone="ghost" size="small" onClick={() => setBlocking(false)}>{t("Cancel")}</Button>
</form>
)}
{deleteImpact && deleteImpact.taskId === task.id && (
<DeleteConfirm impact={deleteImpact.impact} onConfirm={() => confirmDelete(deleteImpact.taskId)} onCancel={() => setDeleteImpact(null)} />
)}
{actionError && <div className="form-error" role="alert" style={{ marginTop: 8 }}>{actionError}</div>}
<div className="modal-actions">
<Button tone="ghost" onClick={deleteTask}><Icon name="x" size={16} /> {t("Delete")}</Button>
<Button tone="ghost" onClick={askDelete} disabled={deleteImpact !== null}><Icon name="x" size={16} /> {t("Delete")}</Button>
{task.blockedReason
? <Button onClick={() => act(() => onBlock(task, null))}>{t("Unblock")}</Button>
: task.status !== TASK_STATUS.DONE && !blocking && <Button onClick={() => setBlocking(true)}>{t("Block")}</Button>}
@@ -382,3 +394,26 @@ function MetaLabel({ children }: { children: string }) {
function LinkButton({ label, onClick, children }: { label: string; onClick: () => void; children: string }) {
return <button type="button" className="link-button" aria-label={label} onClick={onClick} style={{ textAlign: "left" }}>{children}</button>;
}
/** 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 (
<div role="alertdialog" aria-label={t("Delete this task?")} style={{ marginTop: 12, padding: 12, border: "1px solid var(--red)", borderRadius: 10 }}>
<div style={{ fontWeight: 600, marginBottom: 6 }}>{t("Delete this task?")}</div>
{lines.length === 0 && <div style={{ fontSize: 13, color: "var(--text-2)" }}>{t("Nothing else is affected.")}</div>}
{lines.map((line) => <div key={line} style={{ fontSize: 13, color: "var(--text-2)" }}>{line}</div>)}
{impact.running > 0 && <div className="form-error" role="alert">{t("A session is running; stop it or wait for it before deleting.")}</div>}
<div className="row" style={{ gap: 8, marginTop: 10 }}>
<Button tone="primary" size="small" onClick={onConfirm} disabled={impact.running > 0}>{t("Delete task")}</Button>
<Button tone="ghost" size="small" onClick={onCancel}>{t("Cancel")}</Button>
</div>
</div>
);
}
+3
View File
@@ -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<DesktopResult<AppSnapshot>>;
/** What deleting a task would remove, keep and close. */
taskDeleteImpact(input: { orgId: string; taskId: string }): Promise<DesktopResult<TaskDeleteImpact>>;
chatSpaceHistory(input: { orgId: string; spaceId: string; beforeMessageId: string | null } & ({ limit: number } | { fromMessageId: string })): Promise<DesktopResult<SpaceHistoryPage>>;
revokeConnector(input: RevokeConnectorInput): Promise<DesktopResult<AppSnapshot>>;
search(input: SearchInput): Promise<DesktopResult<SearchResultRecord[]>>;
+16
View File
@@ -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;
+1
View File
@@ -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",
+41
View File
@@ -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();
}
+37
View File
@@ -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<string, string> = {};
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();
});
});