Wire real page actions to persisted data
This commit is contained in:
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);
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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>>;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user