Wire real page actions to persisted data

This commit is contained in:
ZacharyZhang-NY committed 2026-05-21 08:54:58 -04:00
1 parent 6992b3e70e
commit 83a51c6881
16 files changed
+411 -94

No files matched your search

@@ -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) => {
@@ -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<CreateChatSpaceInput["spaceType"]>(["company", "department", "project", "direct"]);
const SPACE_VISIBILITIES = new Set<CreateChatSpaceInput["visibility"]>(["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.");
@@ -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);
+1
View File
@@ -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),
@@ -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 ? (
<div className="card" style={{ color: "var(--text-3)" }}>No pending approvals</div>
) : pending.map((approval) => (
<ApprovalRow key={approval.id} approval={approval} onApprove={() => decide(approval.id, "approved")} onDecline={() => decide(approval.id, "declined")} />
<ApprovalRow
key={approval.id}
approval={approval}
requester={requesterName(approval, employees, agents)}
onApprove={() => decide(approval.id, "approved")}
onDecline={() => decide(approval.id, "declined")}
/>
))}
<div className="section-label" style={{ marginTop: 32 }}>Decided</div>
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
@@ -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({
<div style={{ fontSize: 15, fontWeight: 540, marginBottom: 4 }}>{approval.title}</div>
<div style={{ color: "var(--text-2)", lineHeight: 1.5, marginBottom: 12 }}>{approval.details}</div>
<div className="row">
<Avatar name={approval.requestedByType} size="sm" bot={approval.requestedByType !== "employee"} />
<Avatar name={requester} size="sm" bot={approval.requestedByType !== "employee"} />
<span style={{ color: "var(--text-3)", fontSize: 12 }}>{requester}</span>
<span style={{ color: "var(--text-3)", fontSize: 12 }}>{new Date(approval.createdAt).toLocaleString()}</span>
</div>
</div>
@@ -93,3 +104,21 @@ function ApprovalRow({
</div>
);
}
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";
}
@@ -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<HTMLFormElement>) => {
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 (
<div className="split-main" style={{ height: "calc(100vh - 112px)", margin: "-28px -32px -40px" }}>
<aside className="secondary-pane">
<div className="row" style={{ padding: "4px 8px 12px" }}>
<div style={{ fontWeight: 600 }}>Messages</div>
<IconButton icon="plus" label="New space" />
<IconButton icon="plus" label="New space" onClick={() => setSpaceOpen(true)} />
</div>
<NavGroup label="Channels">
{spaces.map((space) => (
{spaces.length === 0 ? <div className="nav-item">No channels</div> : spaces.map((space) => (
<SpaceItem
key={space.id}
space={space}
@@ -86,17 +104,35 @@ export function ChatPage({ snapshot, actions }: PageProps) {
</div>
<form onSubmit={submit} style={{ padding: 14, paddingTop: 4 }}>
<div className="card" style={{ padding: "10px 12px" }}>
<textarea className="input" name="message" rows={2} placeholder={activeSpace ? `Message #${activeSpace.name}` : "Message"} />
<textarea className="input" name="message" rows={2} aria-label="Message" />
<div className="row" style={{ paddingTop: 8, borderTop: "1px solid var(--border-soft)" }}>
<Icon name="attach" />
<Icon name="social" />
<div style={{ marginLeft: "auto" }}>
<Button tone="primary" size="small" type="submit"><Icon name="send" /> Send</Button>
<Button tone="primary" size="small" type="submit" disabled={!activeSpace || !sender}><Icon name="send" /> Send</Button>
</div>
</div>
</div>
</form>
</section>
<Dialog title="New space" open={spaceOpen} submitLabel="Create" onClose={() => setSpaceOpen(false)} onSubmit={createSpace}>
<Field label="Name"><input className="input" name="name" required /></Field>
<Field label="Topic"><input className="input" name="topic" required /></Field>
<Field label="Type">
<select className="input" name="spaceType" defaultValue="project">
<option value="company">Company</option>
<option value="department">Department</option>
<option value="project">Project</option>
<option value="direct">Direct</option>
</select>
</Field>
<Field label="Visibility">
<select className="input" name="visibility" defaultValue="workspace">
<option value="workspace">Workspace</option>
<option value="restricted">Restricted</option>
</select>
</Field>
</Dialog>
</div>
);
}
@@ -151,8 +187,16 @@ function displayName(
employees: Array<{ id: string; displayName: string }>,
agents: Array<{ id: string; name: string }>,
): string {
if (senderType === "employee") return employees.find((item) => item.id === senderId)?.displayName ?? "Employee";
if (senderType === "agent") return agents.find((item) => item.id === senderId)?.name ?? "Agent";
if (senderType === "employee") {
const employee = employees.find((item) => item.id === senderId);
if (!employee) throw new Error("Message employee sender is missing.");
return employee.displayName;
}
if (senderType === "agent") {
const agent = agents.find((item) => item.id === senderId);
if (!agent) throw new Error("Message agent sender is missing.");
return agent.name;
}
if (senderType === "board") return "Board";
return "System";
}
@@ -1,25 +1,37 @@
import { Button, Icon, IconButton } from "../shared/ui";
import { useState } from "react";
import { Button, Icon } from "../shared/ui";
import type { FileObjectRecord } from "../../../shared/domain";
import type { PageProps } from "./types";
import { activeOrgId } from "./types";
export function FilesPage({ snapshot, actions }: PageProps) {
const orgId = activeOrgId(snapshot);
const [selectedKind, setSelectedKind] = useState("all");
const files = snapshot.files.filter((item) => item.orgId === orgId);
const kinds = Array.from(new Set(files.map((file) => file.kind))).sort((a, b) => a.localeCompare(b));
const visibleFiles = selectedKind === "all" ? files : files.filter((file) => file.kind === selectedKind);
return (
<div style={{ display: "grid", gridTemplateColumns: "200px minmax(0, 1fr)", gap: 24 }}>
<aside>
<div className="section-label">Library</div>
<TreeItem icon="folder" label="All files" count={files.length} active />
<TreeItem icon="social" label="Recent" />
<TreeItem icon="check" label="Pinned" />
<TreeItem icon="folder" label="All files" count={files.length} active={selectedKind === "all"} onClick={() => setSelectedKind("all")} />
{kinds.map((kind) => (
<TreeItem
key={kind}
icon="file"
label={formatKind(kind)}
count={files.filter((file) => file.kind === kind).length}
active={selectedKind === kind}
onClick={() => setSelectedKind(kind)}
/>
))}
</aside>
<div className="page">
<div className="row" style={{ marginBottom: 24 }}>
<div>
<h1 className="page-h1">Files</h1>
<p className="page-sub" style={{ marginBottom: 0 }}>{files.length} files · {formatBytes(files.reduce((sum, file) => sum + file.sizeBytes, 0))}</p>
<p className="page-sub" style={{ marginBottom: 0 }}>{visibleFiles.length} files · {formatBytes(visibleFiles.reduce((sum, file) => sum + file.sizeBytes, 0))}</p>
</div>
<div style={{ marginLeft: "auto" }}>
<Button tone="primary" onClick={() => void actions.run(() => window.agentCompany.importFile({ orgId }))}>
@@ -27,14 +39,14 @@ export function FilesPage({ snapshot, actions }: PageProps) {
</Button>
</div>
</div>
<div className="section-label">All files</div>
<div className="section-label">{selectedKind === "all" ? "All files" : formatKind(selectedKind)}</div>
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
<div className="table-row" style={{ gridTemplateColumns: "3fr 1fr 1.4fr 1fr 40px", background: "var(--panel-2)", color: "var(--text-3)", fontSize: 11 }}>
<div>Name</div><div>Size</div><div>Checksum</div><div>Added</div><div />
<div className="table-row" style={{ gridTemplateColumns: "3fr 1fr 1.4fr 1fr", background: "var(--panel-2)", color: "var(--text-3)", fontSize: 11 }}>
<div>Name</div><div>Size</div><div>Checksum</div><div>Added</div>
</div>
{files.length === 0 ? (
{visibleFiles.length === 0 ? (
<div style={{ padding: 18, color: "var(--text-3)" }}>No files</div>
) : files.map((file) => <FileRow key={file.id} file={file} />)}
) : visibleFiles.map((file) => <FileRow key={file.id} file={file} />)}
</div>
</div>
</div>
@@ -45,25 +57,27 @@ function TreeItem({
icon,
label,
count,
onClick,
active = false,
}: {
icon: string;
label: string;
count?: number;
onClick: () => void;
active?: boolean;
}) {
return (
<div className={`nav-item ${active ? "active" : ""}`}>
<button type="button" className={`nav-item ${active ? "active" : ""}`} onClick={onClick}>
<Icon name={icon} />
<span>{label}</span>
{typeof count === "number" && <span className="nav-badge">{count}</span>}
</div>
</button>
);
}
function FileRow({ file }: { file: FileObjectRecord }) {
return (
<div className="table-row" style={{ gridTemplateColumns: "3fr 1fr 1.4fr 1fr 40px" }}>
<div className="table-row" style={{ gridTemplateColumns: "3fr 1fr 1.4fr 1fr" }}>
<div className="row">
<FileIcon kind={file.kind} />
<span style={{ fontWeight: 480 }}>{file.name}</span>
@@ -71,7 +85,6 @@ function FileRow({ file }: { file: FileObjectRecord }) {
<div className="num" style={{ color: "var(--text-3)", fontSize: 12 }}>{formatBytes(file.sizeBytes)}</div>
<div className="num" style={{ color: "var(--text-3)", fontSize: 12 }}>{file.sha256.slice(0, 12)}</div>
<div style={{ color: "var(--text-3)", fontSize: 12 }}>{new Date(file.createdAt).toLocaleDateString()}</div>
<IconButton icon="more" label="More" />
</div>
);
}
@@ -104,6 +117,10 @@ function FileIcon({ kind }: { kind: string }) {
);
}
function formatKind(kind: string): string {
return kind.toUpperCase();
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
@@ -1,7 +1,7 @@
import { useState, type FormEvent } from "react";
import { Button, Field, Icon, Pill } from "../shared/ui";
import { Dialog } from "../shared/Dialog";
import type { ProviderRecord, SkillDefinitionRecord } from "../../../shared/domain";
import type { MigrationReportRecord, PolicyRuleRecord, ProviderRecord, SkillDefinitionRecord } from "../../../shared/domain";
import type { PageProps } from "./types";
import { activeOrgId } from "./types";
@@ -16,10 +16,13 @@ export function SettingsPage({ snapshot, actions }: PageProps) {
const [tab, setTab] = useState<SettingsTab>("providers");
const [providerDialog, setProviderDialog] = useState<ProviderRecord | null>(null);
const [skillOpen, setSkillOpen] = useState(false);
const [migrationOpen, setMigrationOpen] = useState(false);
const providers = snapshot.providers.filter((item) => item.orgId === orgId);
const skills = snapshot.skillDefinitions.filter((item) => item.orgId === orgId);
const agents = snapshot.agents.filter((item) => item.orgId === orgId);
const revisions = snapshot.skillRevisions;
const policies = snapshot.policyRules.filter((item) => item.orgId === orgId);
const reports = snapshot.migrationReports.filter((item) => item.orgId === orgId);
const saveProvider = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
@@ -39,10 +42,19 @@ export function SettingsPage({ snapshot, actions }: PageProps) {
void actions.run(() => window.agentCompany.importSkillDirectory({
orgId,
directoryPath: value(form, "directoryPath"),
versionSemver: value(form, "versionSemver") || "1.0.0",
versionSemver: value(form, "versionSemver"),
})).then(() => setSkillOpen(false));
};
const createMigrationReport = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
void actions.run(() => window.agentCompany.createMigrationReport({
orgId,
sourceDatabasePath: value(form, "sourceDatabasePath"),
})).then(() => setMigrationOpen(false));
};
return (
<div className="split-main" style={{ height: "calc(100vh - 112px)", margin: "-28px -32px -40px" }}>
<aside className="secondary-pane">
@@ -70,8 +82,8 @@ export function SettingsPage({ snapshot, actions }: PageProps) {
}))}
/>
)}
{tab === "policies" && <PolicyPanel />}
{tab === "migration" && <MigrationPanel />}
{tab === "policies" && <PolicyPanel policies={policies} />}
{tab === "migration" && <MigrationPanel reports={reports} onCreate={() => setMigrationOpen(true)} />}
</div>
<Dialog title="Provider credential" open={Boolean(providerDialog)} submitLabel="Save" onClose={() => setProviderDialog(null)} onSubmit={saveProvider}>
<Field label="Provider"><input className="input" value={providerDialog?.label ?? ""} readOnly /></Field>
@@ -80,7 +92,10 @@ export function SettingsPage({ snapshot, actions }: PageProps) {
</Dialog>
<Dialog title="Import skill" open={skillOpen} submitLabel="Import" onClose={() => setSkillOpen(false)} onSubmit={importSkill}>
<Field label="Directory path"><input className="input" name="directoryPath" required /></Field>
<Field label="Version"><input className="input" name="versionSemver" defaultValue="1.0.0" required /></Field>
<Field label="Version"><input className="input" name="versionSemver" required /></Field>
</Dialog>
<Dialog title="Migration report" open={migrationOpen} submitLabel="Create" onClose={() => setMigrationOpen(false)} onSubmit={createMigrationReport}>
<Field label="Source database path"><input className="input" name="sourceDatabasePath" required /></Field>
</Dialog>
</div>
);
@@ -94,7 +109,6 @@ function ProvidersPanel({ providers, onManage }: { providers: ProviderRecord[];
return (
<div className="page">
<h1 className="page-h1">Providers</h1>
<p className="page-sub">Keys, account grants, OAuth profiles.</p>
<div className="section-label">Configured providers</div>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{providers.map((provider) => (
@@ -141,25 +155,29 @@ function SkillsPanel({
{skills.length === 0 ? (
<div style={{ padding: 18, color: "var(--text-3)" }}>No skills</div>
) : skills.map((skill) => {
const revision = revisions.find((item) => item.skillId === skill.id);
const revision = requireRevision(skill, revisions);
return (
<div key={skill.id} className="table-row" style={{ gridTemplateColumns: "1.6fr 1fr 1.6fr" }}>
<div>
<div style={{ fontWeight: 540 }}>{skill.name}</div>
<div style={{ color: "var(--text-3)", fontSize: 11 }}>{skill.slug}</div>
</div>
<Pill>{revision?.versionSemver ?? "No revision"}</Pill>
<select
className="input"
defaultValue=""
onChange={(event) => {
if (event.target.value && revision) onBind(event.target.value, skill, revision.id);
event.target.value = "";
}}
>
<option value="">Bind to agent</option>
{agents.map((agent) => <option key={agent.id} value={agent.id}>{agent.name}</option>)}
</select>
<Pill>{revision.versionSemver}</Pill>
{agents.length === 0 ? (
<Pill>No agents</Pill>
) : (
<select
className="input"
defaultValue=""
onChange={(event) => {
if (event.target.value) onBind(event.target.value, skill, revision.id);
event.target.value = "";
}}
>
<option value="">Agent</option>
{agents.map((agent) => <option key={agent.id} value={agent.id}>{agent.name}</option>)}
</select>
)}
</div>
);
})}
@@ -168,44 +186,80 @@ function SkillsPanel({
);
}
function PolicyPanel() {
function PolicyPanel({ policies }: { policies: PolicyRuleRecord[] }) {
return (
<div className="page">
<h1 className="page-h1">Policies</h1>
<p className="page-sub">Approval, risk, provider, browser boundaries.</p>
<div className="card" style={{ display: "grid", gap: 10 }}>
<PolicyRow label="High-risk task" value="Board approval" />
<PolicyRow label="Social publish" value="Human approval" />
<PolicyRow label="Browser permissions" value="Deny by default" />
<PolicyRow label="Provider secrets" value="OS encrypted" />
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
<div className="table-row" style={{ gridTemplateColumns: "1.2fr 1.2fr 1fr", background: "var(--panel-2)", color: "var(--text-3)", fontSize: 11 }}>
<div>Action</div><div>Effect</div><div>Risk ceiling</div>
</div>
{policies.length === 0 ? (
<div style={{ padding: 18, color: "var(--text-3)" }}>No policies</div>
) : policies.map((policy) => (
<div key={policy.id} className="table-row" style={{ gridTemplateColumns: "1.2fr 1.2fr 1fr" }}>
<span>{policy.action}</span>
<Pill>{policy.effect}</Pill>
<Pill tone={policy.riskCeiling === "high" ? "amber" : undefined}>{policy.riskCeiling}</Pill>
</div>
))}
</div>
</div>
);
}
function MigrationPanel() {
function MigrationPanel({ reports, onCreate }: { reports: MigrationReportRecord[]; onCreate: () => void }) {
return (
<div className="page">
<h1 className="page-h1">Migration</h1>
<p className="page-sub">Legacy import, id maps, checksum validation.</p>
<div className="card" style={{ display: "grid", gap: 10 }}>
<PolicyRow label="Legacy snapshot" value="Immutable" />
<PolicyRow label="Entity checksums" value="Required" />
<PolicyRow label="Delta journal" value="Recorded" />
<div className="row" style={{ marginBottom: 24 }}>
<div>
<h1 className="page-h1">Migration</h1>
<p className="page-sub" style={{ marginBottom: 0 }}>{reports.length} reports</p>
</div>
<div style={{ marginLeft: "auto" }}>
<Button tone="primary" onClick={onCreate}><Icon name="plus" /> Report</Button>
</div>
</div>
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
{reports.length === 0 ? (
<div style={{ padding: 18, color: "var(--text-3)" }}>No reports</div>
) : reports.map((report) => <MigrationReportRow key={report.id} report={report} />)}
</div>
</div>
);
}
function PolicyRow({ label, value }: { label: string; value: string }) {
function MigrationReportRow({ report }: { report: MigrationReportRecord }) {
const body = parseMigrationReport(report.reportJson);
return (
<div className="row" style={{ justifyContent: "space-between" }}>
<span>{label}</span>
<Pill>{value}</Pill>
<div className="table-row" style={{ gridTemplateColumns: "1.4fr 1fr 1fr" }}>
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 540, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} title={report.sourceRef}>{report.sourceRef}</div>
<div style={{ color: "var(--text-3)", fontSize: 11 }}>{new Date(report.createdAt).toLocaleString()}</div>
</div>
<div className="row" style={{ flexWrap: "wrap" }}>
{Object.entries(body.tableCounts).map(([table, count]) => <Pill key={table}>{table}: {count}</Pill>)}
</div>
<Pill>{body.missingTables.length} missing</Pill>
</div>
);
}
function parseMigrationReport(reportJson: string): { tableCounts: Record<string, number>; missingTables: string[] } {
const parsed = JSON.parse(reportJson) as { tableCounts?: Record<string, number>; missingTables?: string[] };
if (!parsed.tableCounts || !Array.isArray(parsed.missingTables)) throw new Error("Migration report is invalid.");
return { tableCounts: parsed.tableCounts, missingTables: parsed.missingTables };
}
function requireRevision(
skill: SkillDefinitionRecord,
revisions: Array<{ id: string; skillId: string; versionSemver: string }>,
): { id: string; versionSemver: string } {
const revision = revisions.find((item) => item.skillId === skill.id);
if (!revision) throw new Error(`Skill revision is missing for ${skill.slug}.`);
return revision;
}
function ProviderLogo({ label }: { label: string }) {
return (
<div style={{ width: 30, height: 30, borderRadius: 7, display: "grid", placeItems: "center", background: "var(--text-1)", color: "#fff", fontWeight: 700 }}>
@@ -1,5 +1,5 @@
import { useState, type FormEvent } from "react";
import { Avatar, Button, Field, Icon, Pill } from "../shared/ui";
import { Avatar, Button, Field, Icon } from "../shared/ui";
import { Dialog } from "../shared/Dialog";
import { SOCIAL_PLATFORM, type SocialAccountRecord, type SocialPlatform } from "../../../shared/domain";
import type { PageProps } from "./types";
@@ -46,17 +46,11 @@ export function SocialPage({ snapshot, actions }: PageProps) {
<AccountCard
key={account.id}
account={account}
employeeName={employees.find((employee) => employee.id === account.authorizedEmployeeId)?.displayName ?? null}
employeeName={authorizedEmployeeName(account.authorizedEmployeeId, employees)}
onLogin={() => void actions.run(() => window.agentCompany.launchSocialLogin({ socialAccountId: account.id }))}
/>
))}
</div>
<div className="section-label">Governance</div>
<div className="card" style={{ display: "grid", gap: 10 }}>
<PolicyRow label="Session isolation" value="Per account partition" />
<PolicyRow label="Permissions" value="Camera, microphone, downloads denied" />
<PolicyRow label="Navigation" value="Platform host allowlist" />
</div>
<Dialog title="Connect social account" open={open} submitLabel="Connect" onClose={() => setOpen(false)} onSubmit={submit}>
<Field label="Platform">
<select className="input" name="platform" defaultValue={SOCIAL_PLATFORM.X}>
@@ -108,15 +102,6 @@ function AccountCard({
);
}
function PolicyRow({ label, value }: { label: string; value: string }) {
return (
<div className="row" style={{ justifyContent: "space-between" }}>
<span>{label}</span>
<Pill>{value}</Pill>
</div>
);
}
function PlatformIcon({ platform }: { platform: SocialPlatform }) {
const labels: Record<SocialPlatform, string> = {
x: "X",
@@ -136,3 +121,13 @@ function PlatformIcon({ platform }: { platform: SocialPlatform }) {
</div>
);
}
function authorizedEmployeeName(
employeeId: string | null,
employees: Array<{ id: string; displayName: string }>,
): string | null {
if (!employeeId) return null;
const employee = employees.find((item) => item.id === employeeId);
if (!employee) throw new Error("Authorized employee is missing.");
return employee.displayName;
}
@@ -1,7 +1,18 @@
import { useState, type FormEvent } from "react";
import { Avatar, Button, Field, Icon, IconButton, Pill } from "../shared/ui";
import { Dialog } from "../shared/Dialog";
import { RISK_LEVEL, TASK_STATUS, type TaskRecord, type TaskStatus } from "../../../shared/domain";
import {
RISK_LEVEL,
TASK_PRIORITY,
TASK_STATUS,
type AgentRecord,
type GoalRecord,
type ProjectRecord,
type TaskPriority,
type TaskRecord,
type TaskStatus,
type WorkspaceRecord,
} from "../../../shared/domain";
import type { PageProps } from "./types";
import { activeOrgId } from "./types";
@@ -16,11 +27,19 @@ function value(form: FormData, key: string): string {
return String(form.get(key) ?? "").trim();
}
function optionalValue(form: FormData, key: string): string | null {
const text = value(form, key);
return text ? text : null;
}
export function WorkPage({ snapshot, actions }: PageProps) {
const orgId = activeOrgId(snapshot);
const [open, setOpen] = useState(false);
const tasks = snapshot.tasks.filter((item) => item.orgId === orgId);
const agents = snapshot.agents.filter((item) => item.orgId === orgId);
const projects = snapshot.projects.filter((item) => item.orgId === orgId);
const goals = snapshot.goals.filter((item) => item.orgId === orgId);
const workspaces = snapshot.workspaces.filter((item) => item.orgId === orgId);
const submit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
@@ -30,8 +49,13 @@ export function WorkPage({ snapshot, actions }: PageProps) {
title: value(form, "title"),
description: value(form, "description"),
status: TASK_STATUS.BACKLOG,
assigneeAgentId: value(form, "assigneeAgentId") || null,
assigneeAgentId: optionalValue(form, "assigneeAgentId"),
risk: value(form, "risk") as "low" | "medium" | "high",
projectId: optionalValue(form, "projectId"),
goalId: optionalValue(form, "goalId"),
workspaceId: optionalValue(form, "workspaceId"),
priority: value(form, "priority") as TaskPriority,
requiresFilesystem: form.get("requiresFilesystem") === "on",
})).then(() => setOpen(false));
};
@@ -46,7 +70,7 @@ export function WorkPage({ snapshot, actions }: PageProps) {
<Button tone="primary" onClick={() => setOpen(true)}><Icon name="plus" /> New task</Button>
</div>
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, minmax(240px, 1fr))", gap: 12 }}>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))", gap: 12 }}>
{columns.map((column) => {
const columnTasks = tasks.filter((task) => task.status === column.id);
return (
@@ -60,7 +84,10 @@ export function WorkPage({ snapshot, actions }: PageProps) {
<TaskCard
key={task.id}
task={task}
assignee={agents.find((agent) => agent.id === task.assigneeAgentId)?.name ?? null}
assignee={optionalRecordName(task.assigneeAgentId, agents, (agent) => agent.name, "Agent")}
project={optionalRecordName(task.projectId, projects, (project) => project.name, "Project")}
goal={optionalRecordName(task.goalId, goals, (goal) => goal.title, "Goal")}
workspace={optionalRecordName(task.workspaceId, workspaces, (workspace) => workspace.name, "Workspace")}
onMove={(status) => void actions.run(() => window.agentCompany.updateTaskStatus({ orgId, taskId: task.id, status }))}
/>
))}
@@ -77,6 +104,24 @@ export function WorkPage({ snapshot, actions }: PageProps) {
{agents.map((agent) => <option key={agent.id} value={agent.id}>{agent.name}</option>)}
</select>
</Field>
<Field label="Project">
<select className="input" name="projectId">
<option value="">Unassigned</option>
{projects.map((project) => <option key={project.id} value={project.id}>{project.name}</option>)}
</select>
</Field>
<Field label="Goal">
<select className="input" name="goalId">
<option value="">Unassigned</option>
{goals.map((goal) => <option key={goal.id} value={goal.id}>{goal.title}</option>)}
</select>
</Field>
<Field label="Workspace">
<select className="input" name="workspaceId">
<option value="">Unassigned</option>
{workspaces.map((workspace) => <option key={workspace.id} value={workspace.id}>{workspace.name}</option>)}
</select>
</Field>
<Field label="Risk">
<select className="input" name="risk" defaultValue={RISK_LEVEL.LOW}>
<option value={RISK_LEVEL.LOW}>Low</option>
@@ -84,6 +129,16 @@ export function WorkPage({ snapshot, actions }: PageProps) {
<option value={RISK_LEVEL.HIGH}>High</option>
</select>
</Field>
<Field label="Priority">
<select className="input" name="priority" defaultValue={TASK_PRIORITY.NORMAL}>
<option value={TASK_PRIORITY.LOW}>Low</option>
<option value={TASK_PRIORITY.NORMAL}>Normal</option>
<option value={TASK_PRIORITY.HIGH}>High</option>
</select>
</Field>
<Field label="Filesystem">
<label className="checkbox-row"><input type="checkbox" name="requiresFilesystem" /> Requires workspace access</label>
</Field>
</Dialog>
</div>
);
@@ -92,10 +147,16 @@ export function WorkPage({ snapshot, actions }: PageProps) {
function TaskCard({
task,
assignee,
project,
goal,
workspace,
onMove,
}: {
task: TaskRecord;
assignee: string | null;
project: string | null;
goal: string | null;
workspace: string | null;
onMove: (status: TaskStatus) => void;
}) {
return (
@@ -104,8 +165,13 @@ function TaskCard({
{task.risk === "high" && <span style={{ width: 3, height: 14, background: "var(--red)", borderRadius: 2, marginTop: 2 }} />}
<div style={{ fontWeight: 480, lineHeight: 1.35 }}>{task.title}</div>
</div>
<div className="row">
<div className="row" style={{ flexWrap: "wrap" }}>
<Pill tone={task.risk === "high" ? "amber" : undefined}>{task.risk}</Pill>
<Pill>{task.priority}</Pill>
{project && <Pill>{project}</Pill>}
{goal && <Pill>{goal}</Pill>}
{workspace && <Pill>{workspace}</Pill>}
{task.requiresFilesystem && <Pill>filesystem</Pill>}
{assignee && <Avatar name={assignee} size="sm" bot />}
<div style={{ marginLeft: "auto" }}>
{nextStatus(task.status) && <IconButton icon="check" label="Move" onClick={() => onMove(nextStatus(task.status)!)} />}
@@ -128,3 +194,15 @@ function nextStatus(status: TaskStatus): TaskStatus | null {
if (status === TASK_STATUS.REVIEW) return TASK_STATUS.DONE;
return null;
}
function optionalRecordName<T extends AgentRecord | ProjectRecord | GoalRecord | WorkspaceRecord>(
id: string | null,
records: T[],
getName: (record: T) => string,
label: string,
): string | null {
if (!id) return null;
const record = records.find((item) => item.id === id);
if (!record) throw new Error(`${label} reference is missing.`);
return getName(record);
}
@@ -220,6 +220,12 @@ button { cursor: pointer; }
outline: none;
}
textarea.input { min-height: 84px; resize: vertical; }
.checkbox-row {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-2);
}
.onboarding-shell {
width: 100%;
min-height: 100%;
+14
View File
@@ -2,6 +2,7 @@ import type {
ApprovalState,
RiskLevel,
SocialPlatform,
TaskPriority,
TaskStatus,
} from "./domain";
@@ -40,6 +41,11 @@ export interface CreateTaskInput {
status: TaskStatus;
assigneeAgentId: string | null;
risk: RiskLevel;
projectId: string | null;
goalId: string | null;
workspaceId: string | null;
priority: TaskPriority;
requiresFilesystem: boolean;
}
export interface UpdateTaskStatusInput {
@@ -75,6 +81,14 @@ export interface SendMessageInput {
attachmentFileIds?: string[];
}
export interface CreateChatSpaceInput {
orgId: string;
spaceType: "company" | "department" | "project" | "direct";
name: string;
topic: string;
visibility: "workspace" | "restricted";
}
export interface SaveProviderCredentialInput {
orgId: string;
providerId: string;
@@ -6,6 +6,7 @@ import type {
BootstrapOnboardingSummary,
CreateAgentInput,
CreateApprovalInput,
CreateChatSpaceInput,
CreateMigrationReportInput,
CreateOrganizationInput,
CreateProviderResponseInput,
@@ -34,6 +35,7 @@ export interface DesktopApi {
updateTaskStatus(input: UpdateTaskStatusInput): Promise<DesktopResult<AppSnapshot>>;
createApproval(input: CreateApprovalInput): Promise<DesktopResult<AppSnapshot>>;
decideApproval(input: DecideApprovalInput): Promise<DesktopResult<AppSnapshot>>;
createChatSpace(input: CreateChatSpaceInput): Promise<DesktopResult<AppSnapshot>>;
sendMessage(input: SendMessageInput): Promise<DesktopResult<AppSnapshot>>;
saveProviderCredential(input: SaveProviderCredentialInput): Promise<DesktopResult<AppSnapshot>>;
createProviderResponse(input: CreateProviderResponseInput): Promise<DesktopResult<AppSnapshot>>;
+8 -1
View File
@@ -17,6 +17,12 @@ export const RISK_LEVEL = {
HIGH: "high",
} as const;
export const TASK_PRIORITY = {
LOW: "low",
NORMAL: "normal",
HIGH: "high",
} as const;
export const PROVIDER_AUTH_TYPE = {
API_KEY: "api_key",
OAUTH_PKCE: "oauth_pkce",
@@ -41,6 +47,7 @@ export type ValueOf<T> = T[keyof T];
export type TaskStatus = ValueOf<typeof TASK_STATUS>;
export type ApprovalState = ValueOf<typeof APPROVAL_STATE>;
export type RiskLevel = ValueOf<typeof RISK_LEVEL>;
export type TaskPriority = ValueOf<typeof TASK_PRIORITY>;
export type ProviderAuthType = ValueOf<typeof PROVIDER_AUTH_TYPE>;
export type SocialPlatform = ValueOf<typeof SOCIAL_PLATFORM>;
export type ProviderRequestState = ValueOf<typeof PROVIDER_REQUEST_STATE>;
@@ -96,7 +103,7 @@ export interface TaskRecord {
projectId: string | null;
goalId: string | null;
workspaceId: string | null;
priority: string;
priority: TaskPriority;
requiresFilesystem: boolean;
createdAt: string;
updatedAt: string;
+1
View File
@@ -7,6 +7,7 @@ export const IPC_CHANNELS = {
updateTaskStatus: "agentcompany:update-task-status",
createApproval: "agentcompany:create-approval",
decideApproval: "agentcompany:decide-approval",
createChatSpace: "agentcompany:create-chat-space",
sendMessage: "agentcompany:send-message",
saveProviderCredential: "agentcompany:save-provider-credential",
createProviderResponse: "agentcompany:create-provider-response",
@@ -61,6 +61,11 @@ describe("domain services", () => {
status: TASK_STATUS.BACKLOG,
assigneeAgentId: null,
risk: RISK_LEVEL.HIGH,
projectId: null,
goalId: null,
workspaceId: null,
priority: "normal",
requiresFilesystem: false,
});
});
@@ -130,8 +135,13 @@ describe("domain services", () => {
mission: "Coordinate agents with auditable approvals.",
goal: "Ship a verified control plane.",
});
const space = connection.db.prepare("select id from chat_spaces where org_id = ? and name = ?")
.get(orgId, "engineering") as { id: string };
const spaceId = chat.createSpace({
orgId,
spaceType: "project",
name: "release",
topic: "Release coordination",
visibility: "workspace",
});
const employee = connection.db.prepare("select id from employees where org_id = ?")
.get(orgId) as { id: string };
const fileId = createId("file");
@@ -142,7 +152,7 @@ describe("domain services", () => {
chat.sendMessage({
orgId,
spaceId: space.id,
spaceId,
threadId: null,
senderType: "employee",
senderId: employee.id,
@@ -152,6 +162,7 @@ describe("domain services", () => {
});
const state = snapshot.load();
expect(state.chatSpaces.map((space) => space.name)).toContain("release");
expect(state.chatMessages).toHaveLength(1);
expect(state.chatThreads).toHaveLength(1);
expect(state.chatAttachments).toHaveLength(1);