Add goal, project, and workspace CRUD

Full-stack: IPC channels, command types, OrganizationService methods,
IPC handlers, preload bridge, desktop-api types, and UI dialogs on the
Work page. Users can now create goals and projects directly instead of
relying on the onboarding bootstrap.
This commit is contained in:
ZacharyZhang-NY committed 2026-06-07 17:36:25 -04:00
1 parent 2ef721774f
commit 5f92cd0d75
9 files changed
+218 -1

No files matched your search

@@ -101,6 +101,9 @@ export function registerIpcHandlers(deps: IpcDeps): void {
return fail("task_run_failed", error instanceof Error ? error.message : String(error));
}
});
ipcMain.handle(IPC_CHANNELS.createGoal, (_event, input) => mutate(() => organization.createGoal(input)));
ipcMain.handle(IPC_CHANNELS.createProject, (_event, input) => mutate(() => organization.createProject(input)));
ipcMain.handle(IPC_CHANNELS.createWorkspace, (_event, input) => mutate(() => organization.createWorkspace(input)));
ipcMain.handle(IPC_CHANNELS.createApproval, (_event, input) => mutate(() => approvals.create(input)));
ipcMain.handle(IPC_CHANNELS.decideApproval, (_event, input) => mutate(() => approvals.decide(input)));
ipcMain.handle(IPC_CHANNELS.syncEmployeeIdentity, (_event, input) => mutate(() => identityAccess.syncEmployeeIdentity(input)));
@@ -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, CreateOrganizationInput, CreateTaskInput, UpdateAgentInput } from "../../shared/commands";
import type { CreateAgentInput, CreateGoalInput, CreateOrganizationInput, CreateProjectInput, CreateTaskInput, CreateWorkspaceInput, UpdateAgentInput } from "../../shared/commands";
import { AuditService } from "./audit-service";
import { PolicyService } from "./policy-service";
import { IdentityAccessService } from "./identity-access-service";
@@ -382,6 +382,67 @@ export class OrganizationService {
}
}
createGoal(input: CreateGoalInput): string {
const id = createId("gol");
const now = new Date().toISOString();
if (input.ownerAgentId) this.verifyOptionalOrgEntity("agents", input.ownerAgentId, input.orgId, "Owner agent");
this.db.prepare(`
insert into goals(id, org_id, title, description, status, owner_agent_id, created_at, updated_at)
values (?, ?, ?, ?, ?, ?, ?, ?)
`).run(id, input.orgId, input.title.trim(), input.description.trim(), "active", input.ownerAgentId, now, now);
this.audit.record({
orgId: input.orgId,
entityType: "goal",
entityId: id,
actorType: "employee",
actorId: null,
action: "created",
payload: { title: input.title.trim() },
});
return id;
}
createProject(input: CreateProjectInput): string {
const id = createId("prj");
const now = new Date().toISOString();
if (input.goalId) this.verifyOptionalOrgEntity("goals", input.goalId, input.orgId, "Goal");
if (input.leadAgentId) this.verifyOptionalOrgEntity("agents", input.leadAgentId, input.orgId, "Lead agent");
this.db.prepare(`
insert into projects(id, org_id, goal_id, name, description, lead_agent_id, status, created_at, updated_at)
values (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(id, input.orgId, input.goalId, input.name.trim(), input.description.trim(), input.leadAgentId, "in_progress", now, now);
this.audit.record({
orgId: input.orgId,
entityType: "project",
entityId: id,
actorType: "employee",
actorId: null,
action: "created",
payload: { name: input.name.trim() },
});
return id;
}
createWorkspace(input: CreateWorkspaceInput): string {
const id = createId("wsp");
const now = new Date().toISOString();
if (input.projectId) this.verifyProject(input.projectId, null, input.orgId);
this.db.prepare(`
insert into workspaces(id, org_id, name, local_path, repo_url, repo_ref, project_id, is_primary, created_at, updated_at)
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(id, input.orgId, input.name.trim(), input.localPath.trim(), "", "", input.projectId, 0, now, now);
this.audit.record({
orgId: input.orgId,
entityType: "workspace",
entityId: id,
actorType: "employee",
actorId: null,
action: "created",
payload: { name: input.name.trim() },
});
return id;
}
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);
+3
View File
@@ -17,6 +17,9 @@ const api: DesktopApi = {
createTask: (input) => invoke(IPC_CHANNELS.createTask, input),
updateTaskStatus: (input) => invoke(IPC_CHANNELS.updateTaskStatus, input),
runTask: (input) => invoke(IPC_CHANNELS.runTask, input),
createGoal: (input) => invoke(IPC_CHANNELS.createGoal, input),
createProject: (input) => invoke(IPC_CHANNELS.createProject, input),
createWorkspace: (input) => invoke(IPC_CHANNELS.createWorkspace, input),
createApproval: (input) => invoke(IPC_CHANNELS.createApproval, input),
decideApproval: (input) => invoke(IPC_CHANNELS.decideApproval, input),
syncEmployeeIdentity: (input) => invoke(IPC_CHANNELS.syncEmployeeIdentity, input),
@@ -16,6 +16,8 @@ import {
type WorkspaceRecord,
} from "../../../shared/domain";
import { NewTaskDialog } from "./NewTaskDialog";
import { GoalDialog } from "./work/GoalDialog";
import { ProjectDialog } from "./work/ProjectDialog";
import { TaskDetailDialog } from "./work/TaskDetailDialog";
import { ProjectOverview } from "./work/ProjectOverview";
import { canRun, latestTaskRun, nextStatus, prettyRunState, prettyStatus, providerFor, runTone } from "./work/task-helpers";
@@ -41,6 +43,8 @@ export function WorkPage({ snapshot, actions }: PageProps) {
const orgId = activeOrgId(snapshot);
const [view, setView] = useState<View>("board");
const [newTaskOpen, setNewTaskOpen] = useState(false);
const [newGoalOpen, setNewGoalOpen] = useState(false);
const [newProjectOpen, setNewProjectOpen] = useState(false);
const [priorityFilter, setPriorityFilter] = useState<TaskPriority | "all">("all");
const [assigneeFilter, setAssigneeFilter] = useState<string>("all");
const [detailTaskId, setDetailTaskId] = useState<string | null>(null);
@@ -88,6 +92,8 @@ export function WorkPage({ snapshot, actions }: PageProps) {
onPriority={setPriorityFilter}
onAssignee={setAssigneeFilter}
/>
<Button tone="ghost" onClick={() => setNewGoalOpen(true)}><Icon name="plus" size={13} /> Goal</Button>
<Button tone="ghost" onClick={() => setNewProjectOpen(true)}><Icon name="plus" size={13} /> Project</Button>
<Button tone="primary" onClick={() => setNewTaskOpen(true)}><Icon name="plus" size={13} /> New task</Button>
</TopbarActions>
<h1 className="page-h1">Work</h1>
@@ -124,6 +130,8 @@ export function WorkPage({ snapshot, actions }: PageProps) {
)}
{view === "timeline" && <TimelineView tasks={tasks} goals={goals} />}
<GoalDialog open={newGoalOpen} orgId={orgId} agents={agents} actions={actions} onClose={() => setNewGoalOpen(false)} />
<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
task={detailTask}
@@ -0,0 +1,51 @@
import { type FormEvent } from "react";
import { Field } from "../../shared/ui";
import { Dialog } from "../../shared/Dialog";
import type { AgentRecord, AppSnapshot } from "../../../../shared/domain";
import type { SnapshotActions } from "../../app/useSnapshot";
function text(form: FormData, key: string): string {
return String(form.get(key) ?? "").trim();
}
function optional(form: FormData, key: string): string | null {
return text(form, key) || null;
}
export function GoalDialog({
open,
orgId,
agents,
actions,
onClose,
}: {
open: boolean;
orgId: string;
agents: AgentRecord[];
actions: SnapshotActions;
onClose: () => void;
}) {
const submit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
void actions.run(() => window.agentCompany.createGoal({
orgId,
title: text(form, "title"),
description: text(form, "description"),
ownerAgentId: optional(form, "ownerAgentId"),
})).then((result) => { if (result) onClose(); });
};
return (
<Dialog title="New goal" open={open} submitLabel="Create" onClose={onClose} onSubmit={submit}>
<Field label="Title"><input className="input" name="title" required /></Field>
<Field label="Description"><textarea className="input" name="description" required /></Field>
<Field label="Owner agent">
<select className="input" name="ownerAgentId" defaultValue="">
<option value="">Unassigned</option>
{agents.map((agent) => <option key={agent.id} value={agent.id}>{agent.name} · {agent.role}</option>)}
</select>
</Field>
</Dialog>
);
}
@@ -0,0 +1,60 @@
import { type FormEvent } from "react";
import { Field } from "../../shared/ui";
import { Dialog } from "../../shared/Dialog";
import type { AgentRecord, GoalRecord } from "../../../../shared/domain";
import type { SnapshotActions } from "../../app/useSnapshot";
function text(form: FormData, key: string): string {
return String(form.get(key) ?? "").trim();
}
function optional(form: FormData, key: string): string | null {
return text(form, key) || null;
}
export function ProjectDialog({
open,
orgId,
goals,
agents,
actions,
onClose,
}: {
open: boolean;
orgId: string;
goals: GoalRecord[];
agents: AgentRecord[];
actions: SnapshotActions;
onClose: () => void;
}) {
const submit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
void actions.run(() => window.agentCompany.createProject({
orgId,
goalId: optional(form, "goalId"),
name: text(form, "name"),
description: text(form, "description"),
leadAgentId: optional(form, "leadAgentId"),
})).then((result) => { if (result) onClose(); });
};
return (
<Dialog title="New project" open={open} submitLabel="Create" onClose={onClose} onSubmit={submit}>
<Field label="Name"><input className="input" name="name" required /></Field>
<Field label="Description"><textarea className="input" name="description" required /></Field>
<Field label="Goal">
<select className="input" name="goalId" defaultValue="">
<option value="">None</option>
{goals.map((goal) => <option key={goal.id} value={goal.id}>{goal.title}</option>)}
</select>
</Field>
<Field label="Lead agent">
<select className="input" name="leadAgentId" defaultValue="">
<option value="">Unassigned</option>
{agents.map((agent) => <option key={agent.id} value={agent.id}>{agent.name} · {agent.role}</option>)}
</select>
</Field>
</Dialog>
);
}
+22
View File
@@ -88,6 +88,28 @@ export interface RunTaskInput {
taskId: string;
}
export interface CreateGoalInput {
orgId: string;
title: string;
description: string;
ownerAgentId: string | null;
}
export interface CreateProjectInput {
orgId: string;
goalId: string | null;
name: string;
description: string;
leadAgentId: string | null;
}
export interface CreateWorkspaceInput {
orgId: string;
name: string;
localPath: string;
projectId: string | null;
}
export interface CreateApprovalInput {
orgId: string;
kind: string;
@@ -9,6 +9,9 @@ import type {
CreateAgentInput,
UpdateAgentInput,
CreateApprovalInput,
CreateGoalInput,
CreateProjectInput,
CreateWorkspaceInput,
CreateAuditReplayInput,
CreateChatSpaceInput,
CreateComplianceExportInput,
@@ -90,6 +93,9 @@ export interface DesktopApi {
createTask(input: CreateTaskInput): Promise<DesktopResult<AppSnapshot>>;
updateTaskStatus(input: UpdateTaskStatusInput): Promise<DesktopResult<AppSnapshot>>;
runTask(input: RunTaskInput): Promise<DesktopResult<AppSnapshot>>;
createGoal(input: CreateGoalInput): Promise<DesktopResult<AppSnapshot>>;
createProject(input: CreateProjectInput): Promise<DesktopResult<AppSnapshot>>;
createWorkspace(input: CreateWorkspaceInput): Promise<DesktopResult<AppSnapshot>>;
createApproval(input: CreateApprovalInput): Promise<DesktopResult<AppSnapshot>>;
decideApproval(input: DecideApprovalInput): Promise<DesktopResult<AppSnapshot>>;
syncEmployeeIdentity(input: SyncEmployeeIdentityInput): Promise<DesktopResult<AppSnapshot>>;
+3
View File
@@ -57,6 +57,9 @@ export const IPC_CHANNELS = {
setLegalHoldState: "agentcompany:set-legal-hold-state",
createRetentionFreeze: "agentcompany:create-retention-freeze",
setRetentionFreezeState: "agentcompany:set-retention-freeze-state",
createGoal: "agentcompany:create-goal",
createProject: "agentcompany:create-project",
createWorkspace: "agentcompany:create-workspace",
search: "agentcompany:search",
rendererEvent: "agentcompany:renderer-event",
windowMinimize: "agentcompany:window-minimize",