From 75e5d17a862a7d409ef5c9b379ad781d4845ab68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 7 Jun 2026 17:55:59 -0400 Subject: [PATCH] Implement full task inspector with subtasks, activity, and inline edit Migration 041 adds parent_task_id to tasks for subtask hierarchy. TaskDetailDialog now has three tabs: Overview (description, assignee, project, goal, workspace, runs with error messages), Subtasks (child tasks with status), and Activity (audit events and approval history). Inline edit overlay covers title, description, assignee, risk, priority, and parent task. Task delete is available from the dialog footer. --- AgentCompany-New/src/main/app/ipc-handlers.ts | 2 + .../migrations/041-task-subtasks-delete.ts | 3 + AgentCompany-New/src/main/database/schema.ts | 2 + .../src/main/services/organization-service.ts | 39 ++- AgentCompany-New/src/preload/index.ts | 2 + .../src/renderer/src/pages/WorkPage.tsx | 5 + .../src/pages/work/TaskDetailDialog.tsx | 280 +++++++++++++----- AgentCompany-New/src/shared/commands.ts | 16 + AgentCompany-New/src/shared/desktop-api.ts | 4 + AgentCompany-New/src/shared/domain.ts | 1 + AgentCompany-New/src/shared/ipc.ts | 2 + .../tests/unit/domain-services.test.ts | 2 +- 12 files changed, 289 insertions(+), 69 deletions(-) create mode 100644 AgentCompany-New/src/main/database/migrations/041-task-subtasks-delete.ts diff --git a/AgentCompany-New/src/main/app/ipc-handlers.ts b/AgentCompany-New/src/main/app/ipc-handlers.ts index a690070e..3a5f725d 100644 --- a/AgentCompany-New/src/main/app/ipc-handlers.ts +++ b/AgentCompany-New/src/main/app/ipc-handlers.ts @@ -96,6 +96,8 @@ export function registerIpcHandlers(deps: IpcDeps): void { ipcMain.handle(IPC_CHANNELS.createAgent, (_event, input) => mutate(() => organization.createAgent(input))); ipcMain.handle(IPC_CHANNELS.updateAgent, (_event, input) => mutate(() => organization.updateAgent(input))); ipcMain.handle(IPC_CHANNELS.createTask, (_event, input) => mutate(() => organization.createTask(input))); + ipcMain.handle(IPC_CHANNELS.updateTask, (_event, input) => mutate(() => organization.updateTask(input))); + ipcMain.handle(IPC_CHANNELS.deleteTask, (_event, input) => mutate(() => organization.deleteTask(input))); ipcMain.handle(IPC_CHANNELS.updateTaskStatus, (_event, input) => mutate(() => organization.updateTaskStatus(input.orgId, input.taskId, input.status))); ipcMain.handle(IPC_CHANNELS.runTask, async (_event, input) => { try { diff --git a/AgentCompany-New/src/main/database/migrations/041-task-subtasks-delete.ts b/AgentCompany-New/src/main/database/migrations/041-task-subtasks-delete.ts new file mode 100644 index 00000000..fbcf211c --- /dev/null +++ b/AgentCompany-New/src/main/database/migrations/041-task-subtasks-delete.ts @@ -0,0 +1,3 @@ +export const taskSubtasksDeleteSchema = ` + alter table tasks add column parent_task_id text references tasks(id) on delete set null; +`; diff --git a/AgentCompany-New/src/main/database/schema.ts b/AgentCompany-New/src/main/database/schema.ts index 370b1ed8..9aea993d 100644 --- a/AgentCompany-New/src/main/database/schema.ts +++ b/AgentCompany-New/src/main/database/schema.ts @@ -39,6 +39,7 @@ import { customProvidersSchema } from "./migrations/037-custom-providers"; import { providerAuthProfileCascadeSchema } from "./migrations/038-provider-auth-profile-cascade"; import { auditImmutabilityOrgTeardownSchema } from "./migrations/039-audit-immutability-org-teardown"; import { documentsKnowledgeAutomationSchema } from "./migrations/040-documents-knowledge-automation"; +import { taskSubtasksDeleteSchema } from "./migrations/041-task-subtasks-delete"; interface Migration { version: number; @@ -87,6 +88,7 @@ const MIGRATIONS: Migration[] = [ { version: 38, name: "provider_auth_profile_cascade", sql: providerAuthProfileCascadeSchema }, { version: 39, name: "audit_immutability_org_teardown", sql: auditImmutabilityOrgTeardownSchema }, { version: 40, name: "documents_knowledge_automation", sql: documentsKnowledgeAutomationSchema }, + { version: 41, name: "task_subtasks_delete", sql: taskSubtasksDeleteSchema }, ]; const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version; diff --git a/AgentCompany-New/src/main/services/organization-service.ts b/AgentCompany-New/src/main/services/organization-service.ts index 912c1748..d2622163 100644 --- a/AgentCompany-New/src/main/services/organization-service.ts +++ b/AgentCompany-New/src/main/services/organization-service.ts @@ -2,7 +2,7 @@ import type { DatabaseSync } from "node:sqlite"; import { APPROVAL_STATE, RISK_LEVEL, TASK_STATUS, type ApprovalState } from "../../shared/domain"; import { PROVIDER_CATALOG } from "../../shared/provider-catalog"; import { createId } from "../../shared/ids"; -import type { CreateAgentInput, CreateGoalInput, CreateOrganizationInput, CreateProjectInput, CreateTaskInput, CreateWorkspaceInput, UpdateAgentInput } from "../../shared/commands"; +import type { CreateAgentInput, CreateGoalInput, CreateOrganizationInput, CreateProjectInput, CreateTaskInput, CreateWorkspaceInput, DeleteTaskInput, UpdateAgentInput, UpdateTaskInput } from "../../shared/commands"; import { AuditService } from "./audit-service"; import { PolicyService } from "./policy-service"; import { IdentityAccessService } from "./identity-access-service"; @@ -445,6 +445,43 @@ export class OrganizationService { return id; } + updateTask(input: UpdateTaskInput): void { + const now = new Date().toISOString(); + if (input.assigneeAgentId) this.verifyOptionalOrgEntity("agents", input.assigneeAgentId, input.orgId, "Assignee agent"); + if (input.parentTaskId) { + const parent = this.db.prepare("select id from tasks where id = ? and org_id = ?").get(input.parentTaskId, input.orgId); + if (!parent) throw new Error("Parent task was not found."); + } + const result = this.db.prepare(` + update tasks set title = ?, description = ?, assignee_agent_id = ?, risk = ?, priority = ?, parent_task_id = ?, updated_at = ? + where id = ? and org_id = ? + `).run(input.title.trim(), input.description.trim(), input.assigneeAgentId, input.risk, input.priority, input.parentTaskId, now, input.taskId, input.orgId); + if (result.changes === 0) throw new Error("Task was not found."); + this.audit.record({ + orgId: input.orgId, + entityType: "task", + entityId: input.taskId, + actorType: "employee", + actorId: null, + action: "updated", + payload: { title: input.title.trim() }, + }); + } + + deleteTask(input: DeleteTaskInput): void { + const result = this.db.prepare("delete from tasks where id = ? and org_id = ?").run(input.taskId, input.orgId); + if (result.changes === 0) throw new Error("Task was not found."); + this.audit.record({ + orgId: input.orgId, + entityType: "task", + entityId: input.taskId, + actorType: "employee", + actorId: null, + action: "deleted", + payload: {}, + }); + } + updateTaskStatus(orgId: string, taskId: string, status: string): void { const result = this.db.prepare("update tasks set status = ?, updated_at = ? where id = ? and org_id = ?") .run(status, new Date().toISOString(), taskId, orgId); diff --git a/AgentCompany-New/src/preload/index.ts b/AgentCompany-New/src/preload/index.ts index a2ba46ff..1cbe9055 100644 --- a/AgentCompany-New/src/preload/index.ts +++ b/AgentCompany-New/src/preload/index.ts @@ -15,6 +15,8 @@ const api: DesktopApi = { createAgent: (input) => invoke(IPC_CHANNELS.createAgent, input), updateAgent: (input) => invoke(IPC_CHANNELS.updateAgent, input), createTask: (input) => invoke(IPC_CHANNELS.createTask, input), + updateTask: (input) => invoke(IPC_CHANNELS.updateTask, input), + deleteTask: (input) => invoke(IPC_CHANNELS.deleteTask, input), updateTaskStatus: (input) => invoke(IPC_CHANNELS.updateTaskStatus, input), runTask: (input) => invoke(IPC_CHANNELS.runTask, input), createGoal: (input) => invoke(IPC_CHANNELS.createGoal, input), diff --git a/AgentCompany-New/src/renderer/src/pages/WorkPage.tsx b/AgentCompany-New/src/renderer/src/pages/WorkPage.tsx index 254cf101..e2313ba4 100644 --- a/AgentCompany-New/src/renderer/src/pages/WorkPage.tsx +++ b/AgentCompany-New/src/renderer/src/pages/WorkPage.tsx @@ -141,6 +141,11 @@ export function WorkPage({ snapshot, actions }: PageProps) { workspaces={workspaces} providers={providers} taskRuns={taskRuns} + allTasks={allTasks} + approvals={snapshot.approvals.filter((a) => a.orgId === orgId)} + auditEvents={snapshot.auditEvents.filter((e) => e.orgId === orgId)} + orgId={orgId} + actions={actions} onClose={() => setDetailTaskId(null)} onAdvance={advance} onRun={runTask} diff --git a/AgentCompany-New/src/renderer/src/pages/work/TaskDetailDialog.tsx b/AgentCompany-New/src/renderer/src/pages/work/TaskDetailDialog.tsx index 25433c1d..42cdffc1 100644 --- a/AgentCompany-New/src/renderer/src/pages/work/TaskDetailDialog.tsx +++ b/AgentCompany-New/src/renderer/src/pages/work/TaskDetailDialog.tsx @@ -1,15 +1,27 @@ -import { Avatar, Button, Icon, IconButton, Pill } from "../../shared/ui"; -import { - type AgentRecord, - type GoalRecord, - type ProjectRecord, - type ProviderRecord, - type TaskRecord, - type TaskRunRecord, - type WorkspaceRecord, +import { useState, type FormEvent } from "react"; +import { Avatar, Button, Field, Icon, IconButton, Pill, Segmented } from "../../shared/ui"; +import type { + AgentRecord, + ApprovalRecord, + AuditEventRecord, + GoalRecord, + ProjectRecord, + ProviderRecord, + TaskRecord, + TaskRunRecord, + WorkspaceRecord, } from "../../../../shared/domain"; -import { formatRelative } from "../types"; +import { TASK_PRIORITY, TASK_STATUS } from "../../../../shared/domain"; +import { auditEventLabel, formatRelative } from "../types"; import { canRun, nextStatus, prettyRunState, prettyStatus, providerFor, runsForTask, runTone } from "./task-helpers"; +import type { SnapshotActions } from "../../app/useSnapshot"; + +type Tab = "overview" | "subtasks" | "activity"; +const TABS: Array<{ id: Tab; label: string }> = [ + { id: "overview", label: "Overview" }, + { id: "subtasks", label: "Subtasks" }, + { id: "activity", label: "Activity" }, +]; export function TaskDetailDialog({ task, @@ -19,6 +31,11 @@ export function TaskDetailDialog({ workspaces, providers, taskRuns, + allTasks, + approvals, + auditEvents, + orgId, + actions, onClose, onAdvance, onRun, @@ -30,94 +47,223 @@ export function TaskDetailDialog({ workspaces: WorkspaceRecord[]; providers: ProviderRecord[]; taskRuns: TaskRunRecord[]; + allTasks: TaskRecord[]; + approvals: ApprovalRecord[]; + auditEvents: AuditEventRecord[]; + orgId: string; + actions: SnapshotActions; onClose: () => void; onAdvance: (task: TaskRecord) => void; onRun: (task: TaskRecord) => void; }) { + const [tab, setTab] = useState("overview"); + const [editing, setEditing] = useState(false); + if (!task) return null; - const assignee = agents.find((agent) => agent.id === task.assigneeAgentId) ?? null; - const project = projects.find((item) => item.id === task.projectId) ?? null; - const goal = goals.find((item) => item.id === task.goalId) ?? null; - const workspace = workspaces.find((item) => item.id === task.workspaceId) ?? null; + const assignee = agents.find((a) => a.id === task.assigneeAgentId) ?? null; + const project = projects.find((p) => p.id === task.projectId) ?? null; + const goal = goals.find((g) => g.id === task.goalId) ?? null; + const workspace = workspaces.find((w) => w.id === task.workspaceId) ?? null; const provider = providerFor(assignee, providers); const runs = runsForTask(task.id, taskRuns); const advanceTo = nextStatus(task.status); + const subtasks = allTasks.filter((t) => t.parentTaskId === task.id); + const taskApprovals = approvals.filter((a) => a.sourceEntityType === "task" && a.sourceEntityId === task.id); + const taskAudit = auditEvents.filter((e) => e.entityType === "task" && e.entityId === task.id); + + const submitEdit = (event: FormEvent) => { + event.preventDefault(); + const form = new FormData(event.currentTarget); + const val = (key: string) => String(form.get(key) ?? "").trim(); + void actions.run(() => window.agentCompany.updateTask({ + orgId, + taskId: task.id, + title: val("title"), + description: val("description"), + assigneeAgentId: val("assigneeAgentId") || null, + risk: (val("risk") || "low") as "low" | "medium" | "high", + priority: (val("priority") || "normal") as "low" | "normal" | "high", + parentTaskId: val("parentTaskId") || null, + })).then((r) => { if (r) setEditing(false); }); + }; + + const deleteTask = () => { + void actions.run(() => window.agentCompany.deleteTask({ orgId, taskId: task.id })); + onClose(); + }; return (
-
event.stopPropagation()}> -
+
e.stopPropagation()}> +
{task.priority === "high" && }

{task.title}

+ setEditing(true)} />
- -
+
{prettyStatus(task.status)} {task.priority} {task.risk === "high" && high risk} {task.requiresFilesystem && workspace}
- {task.description && ( -

- {task.description} -

+
+ + {tab === "overview" && ( + )} - -
- Assignee -
- {assignee ? ( - <> - - {assignee.name} - {provider && ยท {provider.label}} - - ) : ( - Unassigned - )} -
- {project && (<>Project
{project.name}
)} - {goal && (<>Goal
{goal.title}
)} - {workspace && (<>Workspace
{workspace.name}
)} -
- - {runs.length > 0 && ( -
-
Runs
-
- {runs.map((run) => ( -
- {prettyRunState(run.state)} - {run.trigger} - {formatRelative(run.createdAt)} - {run.state === "failed" && run.errorMessage && ( - {run.errorMessage} - )} -
- ))} -
-
+ {tab === "subtasks" && ( + + )} + {tab === "activity" && ( + )}
- {assignee && ( - - )} - {advanceTo && ( - - )} + + {assignee && } + {advanceTo && }
+ + {editing && setEditing(false)} />}
); } +function OverviewTab({ task, assignee, project, goal, workspace, provider, runs }: { + task: TaskRecord; assignee: AgentRecord | null; project: ProjectRecord | null; + goal: GoalRecord | null; workspace: WorkspaceRecord | null; provider: ProviderRecord | null; + runs: TaskRunRecord[]; +}) { + return ( + <> + {task.description && ( +

{task.description}

+ )} +
+ Assignee +
+ {assignee ? (<>{assignee.name}{provider && ({provider.label})}) : Unassigned} +
+ {project && (<>Project
{project.name}
)} + {goal && (<>Goal
{goal.title}
)} + {workspace && (<>Workspace
{workspace.name}
)} +
+ {runs.length > 0 && ( +
+
Runs
+
+ {runs.map((run) => ( +
+
+ {prettyRunState(run.state)} + {run.trigger} + {formatRelative(run.createdAt)} +
+ {run.state === "failed" && run.errorMessage && ( + {run.errorMessage} + )} +
+ ))} +
+
+ )} + + ); +} + +function SubtasksTab({ subtasks, agents }: { subtasks: TaskRecord[]; agents: AgentRecord[] }) { + return ( +
+ {subtasks.length === 0 &&
No subtasks
} + {subtasks.map((st) => { + const owner = agents.find((a) => a.id === st.assigneeAgentId); + return ( +
+ + {st.title} + {prettyStatus(st.status)} + {owner && } +
+ ); + })} +
+ ); +} + +function ActivityTab({ audit, approvals }: { audit: AuditEventRecord[]; approvals: ApprovalRecord[] }) { + return ( +
+ {approvals.length > 0 && ( +
+
Approvals
+ {approvals.map((a) => ( +
+ {a.state} + {a.title} + {formatRelative(a.createdAt)} +
+ ))} +
+ )} +
Events
+ {audit.length === 0 &&
No events
} + {audit.map((e) => ( +
+ {formatRelative(e.createdAt)} + {auditEventLabel(e.entityType, e.action)} +
+ ))} +
+ ); +} + +function EditOverlay({ task, agents, allTasks, onSubmit, onCancel }: { + task: TaskRecord; agents: AgentRecord[]; allTasks: TaskRecord[]; + onSubmit: (e: FormEvent) => void; onCancel: () => void; +}) { + const otherTasks = allTasks.filter((t) => t.id !== task.id && t.orgId === task.orgId); + return ( +
+
+

Edit task

+ +