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

+ +