diff --git a/AgentCompany-New/src/main/app/ipc-handlers.ts b/AgentCompany-New/src/main/app/ipc-handlers.ts index 7eda07ca..b5e6700f 100644 --- a/AgentCompany-New/src/main/app/ipc-handlers.ts +++ b/AgentCompany-New/src/main/app/ipc-handlers.ts @@ -72,6 +72,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { ipcMain.handle(IPC_CHANNELS.updateTaskStatus, (_event, input) => mutate(() => organization.updateTaskStatus(input.orgId, input.taskId, input.status))); 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.createChatSpace, (_event, input) => mutate(() => chat.createSpace(input))); ipcMain.handle(IPC_CHANNELS.sendMessage, (_event, input) => mutate(() => chat.sendMessage(input))); ipcMain.handle(IPC_CHANNELS.saveProviderCredential, (_event, input) => mutate(() => providers.saveCredential(input))); ipcMain.handle(IPC_CHANNELS.createProviderResponse, async (_event, input) => { diff --git a/AgentCompany-New/src/main/services/chat-service.ts b/AgentCompany-New/src/main/services/chat-service.ts index d0f78316..276b9d17 100644 --- a/AgentCompany-New/src/main/services/chat-service.ts +++ b/AgentCompany-New/src/main/services/chat-service.ts @@ -1,8 +1,11 @@ import type { DatabaseSync } from "node:sqlite"; -import type { SendMessageInput } from "../../shared/commands"; +import type { CreateChatSpaceInput, SendMessageInput } from "../../shared/commands"; import { createId } from "../../shared/ids"; import { AuditService } from "./audit-service"; +const SPACE_TYPES = new Set(["company", "department", "project", "direct"]); +const SPACE_VISIBILITIES = new Set(["workspace", "restricted"]); + export class ChatService { private readonly audit: AuditService; @@ -10,6 +13,41 @@ export class ChatService { this.audit = new AuditService(db); } + createSpace(input: CreateChatSpaceInput): string { + const name = input.name.trim(); + const topic = input.topic.trim(); + if (!name) throw new Error("Chat space name is required."); + if (!topic) throw new Error("Chat space topic is required."); + if (!SPACE_TYPES.has(input.spaceType)) throw new Error(`Unsupported chat space type: ${input.spaceType}`); + if (!SPACE_VISIBILITIES.has(input.visibility)) throw new Error(`Unsupported chat space visibility: ${input.visibility}`); + + const id = createId("spc"); + const now = new Date().toISOString(); + this.db.prepare(` + insert into chat_spaces(id, org_id, space_type, name, topic, visibility, created_at) + values (?, ?, ?, ?, ?, ?, ?) + `).run(id, input.orgId, input.spaceType, name, topic, input.visibility, now); + + const employees = this.db.prepare("select id from employees where org_id = ?").all(input.orgId) as Array<{ id: string }>; + for (const employee of employees) { + this.db.prepare(` + insert into chat_memberships(space_id, principal_type, principal_id, role, mute_state) + values (?, ?, ?, ?, ?) + `).run(id, "employee", employee.id, input.visibility === "restricted" ? "approver" : "member", "unmuted"); + } + + this.audit.record({ + orgId: input.orgId, + entityType: "chat_space", + entityId: id, + actorType: "system", + actorId: null, + action: "created", + payload: { spaceType: input.spaceType, visibility: input.visibility }, + }); + return id; + } + sendMessage(input: SendMessageInput): string { const body = input.bodyMd.trim(); if (!body) throw new Error("Message body is required."); diff --git a/AgentCompany-New/src/main/services/organization-service.ts b/AgentCompany-New/src/main/services/organization-service.ts index 0be9dc3c..ee131783 100644 --- a/AgentCompany-New/src/main/services/organization-service.ts +++ b/AgentCompany-New/src/main/services/organization-service.ts @@ -129,6 +129,11 @@ export class OrganizationService { createTask(input: CreateTaskInput): string { const id = createId("tsk"); const now = new Date().toISOString(); + this.verifyOptionalOrgEntity("agents", input.assigneeAgentId, input.orgId, "Assignee agent"); + this.verifyOptionalOrgEntity("goals", input.goalId, input.orgId, "Goal"); + this.verifyOptionalOrgEntity("workspaces", input.workspaceId, input.orgId, "Workspace"); + this.verifyProject(input.projectId, input.goalId, input.orgId); + this.db.prepare(` insert into tasks( id, org_id, title, description, status, assignee_agent_id, risk, @@ -143,11 +148,11 @@ export class OrganizationService { input.status, input.assigneeAgentId, input.risk, - null, - null, - null, - "normal", - 0, + input.projectId, + input.goalId, + input.workspaceId, + input.priority, + input.requiresFilesystem ? 1 : 0, now, now, ); @@ -185,6 +190,20 @@ export class OrganizationService { return id; } + private verifyOptionalOrgEntity(table: "agents" | "goals" | "workspaces", id: string | null, orgId: string, label: string): void { + if (!id) return; + const row = this.db.prepare(`select id from ${table} where id = ? and org_id = ?`).get(id, orgId); + if (!row) throw new Error(`${label} was not found.`); + } + + private verifyProject(projectId: string | null, goalId: string | null, orgId: string): void { + if (!projectId) return; + const project = this.db.prepare("select goal_id from projects where id = ? and org_id = ?") + .get(projectId, orgId) as { goal_id: string | null } | undefined; + if (!project) throw new Error("Project was not found."); + if (goalId && project.goal_id !== goalId) throw new Error("Project is linked to a different goal."); + } + 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 c67eadf1..a1b216ce 100644 --- a/AgentCompany-New/src/preload/index.ts +++ b/AgentCompany-New/src/preload/index.ts @@ -15,6 +15,7 @@ const api: DesktopApi = { updateTaskStatus: (input) => invoke(IPC_CHANNELS.updateTaskStatus, input), createApproval: (input) => invoke(IPC_CHANNELS.createApproval, input), decideApproval: (input) => invoke(IPC_CHANNELS.decideApproval, input), + createChatSpace: (input) => invoke(IPC_CHANNELS.createChatSpace, input), sendMessage: (input) => invoke(IPC_CHANNELS.sendMessage, input), saveProviderCredential: (input) => invoke(IPC_CHANNELS.saveProviderCredential, input), createProviderResponse: (input) => invoke(IPC_CHANNELS.createProviderResponse, input), diff --git a/AgentCompany-New/src/renderer/src/pages/ApprovalsPage.tsx b/AgentCompany-New/src/renderer/src/pages/ApprovalsPage.tsx index 33125fa5..5f0d4714 100644 --- a/AgentCompany-New/src/renderer/src/pages/ApprovalsPage.tsx +++ b/AgentCompany-New/src/renderer/src/pages/ApprovalsPage.tsx @@ -1,17 +1,19 @@ import { Avatar, Button, Icon, Pill } from "../shared/ui"; -import type { ApprovalRecord } from "../../../shared/domain"; +import type { AgentRecord, ApprovalRecord, EmployeeRecord } from "../../../shared/domain"; import type { PageProps } from "./types"; import { activeOrgId } from "./types"; export function ApprovalsPage({ snapshot, actions }: PageProps) { const orgId = activeOrgId(snapshot); const employee = snapshot.employees.find((item) => item.orgId === orgId); + const employees = snapshot.employees.filter((item) => item.orgId === orgId); + const agents = snapshot.agents.filter((item) => item.orgId === orgId); const approvals = snapshot.approvals.filter((item) => item.orgId === orgId); const pending = approvals.filter((item) => item.state === "pending"); const decided = approvals.filter((item) => item.state !== "pending"); const decide = (approvalId: string, state: "approved" | "declined") => { - if (!employee) return; + if (!employee) throw new Error("Employee is required for approval decisions."); void actions.run(() => window.agentCompany.decideApproval({ orgId, approvalId, @@ -34,7 +36,13 @@ export function ApprovalsPage({ snapshot, actions }: PageProps) { {pending.length === 0 ? (
No pending approvals
) : pending.map((approval) => ( - decide(approval.id, "approved")} onDecline={() => decide(approval.id, "declined")} /> + decide(approval.id, "approved")} + onDecline={() => decide(approval.id, "declined")} + /> ))}
Decided
@@ -63,10 +71,12 @@ function StatTile({ label, value, tone }: { label: string; value: string; tone?: function ApprovalRow({ approval, + requester, onApprove, onDecline, }: { approval: ApprovalRecord; + requester: string; onApprove: () => void; onDecline: () => void; }) { @@ -82,7 +92,8 @@ function ApprovalRow({
{approval.title}
{approval.details}
- + + {requester} {new Date(approval.createdAt).toLocaleString()}
@@ -93,3 +104,21 @@ function ApprovalRow({ ); } + +function requesterName( + approval: ApprovalRecord, + employees: EmployeeRecord[], + agents: AgentRecord[], +): string { + if (approval.requestedByType === "employee") { + const employee = employees.find((item) => item.id === approval.requestedById); + if (!employee) throw new Error("Approval employee requester is missing."); + return employee.displayName; + } + if (approval.requestedByType === "agent") { + const agent = agents.find((item) => item.id === approval.requestedById); + if (!agent) throw new Error("Approval agent requester is missing."); + return agent.name; + } + return "System"; +} diff --git a/AgentCompany-New/src/renderer/src/pages/ChatPage.tsx b/AgentCompany-New/src/renderer/src/pages/ChatPage.tsx index 75f173cd..b86ac467 100644 --- a/AgentCompany-New/src/renderer/src/pages/ChatPage.tsx +++ b/AgentCompany-New/src/renderer/src/pages/ChatPage.tsx @@ -1,5 +1,6 @@ import { useState, type FormEvent, type ReactNode } from "react"; -import { Avatar, Button, Icon, IconButton } from "../shared/ui"; +import { Avatar, Button, Field, Icon, IconButton } from "../shared/ui"; +import { Dialog } from "../shared/Dialog"; import type { ChatSpaceRecord } from "../../../shared/domain"; import type { PageProps } from "./types"; import { activeOrgId } from "./types"; @@ -8,12 +9,17 @@ function messageBody(form: FormData): string { return String(form.get("message") ?? "").trim(); } +function value(form: FormData, key: string): string { + return String(form.get(key) ?? "").trim(); +} + export function ChatPage({ snapshot, actions }: PageProps) { const orgId = activeOrgId(snapshot); const spaces = snapshot.chatSpaces.filter((item) => item.orgId === orgId); const employees = snapshot.employees.filter((item) => item.orgId === orgId); const agents = snapshot.agents.filter((item) => item.orgId === orgId); const [spaceId, setSpaceId] = useState(spaces[0]?.id ?? ""); + const [spaceOpen, setSpaceOpen] = useState(false); const activeSpace = spaces.find((space) => space.id === spaceId) ?? spaces[0]; const messages = snapshot.chatMessages.filter((item) => item.orgId === orgId && item.spaceId === activeSpace?.id); const sender = employees[0]; @@ -35,15 +41,27 @@ export function ChatPage({ snapshot, actions }: PageProps) { })); }; + const createSpace = (event: FormEvent) => { + event.preventDefault(); + const form = new FormData(event.currentTarget); + void actions.run(() => window.agentCompany.createChatSpace({ + orgId, + spaceType: value(form, "spaceType") as "company" | "department" | "project" | "direct", + name: value(form, "name"), + topic: value(form, "topic"), + visibility: value(form, "visibility") as "workspace" | "restricted", + })).then(() => setSpaceOpen(false)); + }; + return (