update
This commit is contained in:
1 parent
41eafc7c44
commit
54bcc77ef7
19 files changed
+11420
-64
No files matched your search
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "agent-company",
|
"name": "agent-company",
|
||||||
"version": "0.1.0",
|
"version": "0.3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Cross-platform Electron desktop control plane for local-first AI agent companies.",
|
"description": "Cross-platform Electron desktop control plane for local-first AI agent companies.",
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export class AgentApiServer {
|
|||||||
private db: AppDatabase;
|
private db: AppDatabase;
|
||||||
private onDomainChanged: () => void;
|
private onDomainChanged: () => void;
|
||||||
private onWakeAgent: (agentId: string, companyId: string, trigger: string) => void;
|
private onWakeAgent: (agentId: string, companyId: string, trigger: string) => void;
|
||||||
|
private onBrowserAction: ((actionId: string, socialAccountId: string, actionType: string, payload: Record<string, unknown>) => void) | null = null;
|
||||||
|
|
||||||
constructor(db: AppDatabase, onDomainChanged: () => void, onWakeAgent: (agentId: string, companyId: string, trigger: string) => void) {
|
constructor(db: AppDatabase, onDomainChanged: () => void, onWakeAgent: (agentId: string, companyId: string, trigger: string) => void) {
|
||||||
this.db = db;
|
this.db = db;
|
||||||
@@ -57,6 +58,10 @@ export class AgentApiServer {
|
|||||||
this.registerRoutes();
|
this.registerRoutes();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setBrowserActionHandler(handler: (actionId: string, socialAccountId: string, actionType: string, payload: Record<string, unknown>) => void) {
|
||||||
|
this.onBrowserAction = handler;
|
||||||
|
}
|
||||||
|
|
||||||
getPort(): number {
|
getPort(): number {
|
||||||
return this.port;
|
return this.port;
|
||||||
}
|
}
|
||||||
@@ -960,7 +965,430 @@ export class AgentApiServer {
|
|||||||
tasksDone: rTasks.filter((t) => t.status === "done").length,
|
tasksDone: rTasks.filter((t) => t.status === "done").length,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
socialAccounts: snapshot.socialAccounts
|
||||||
|
.filter((a) => a.companyId === token.companyId)
|
||||||
|
.map((a) => ({
|
||||||
|
id: a.id,
|
||||||
|
platform: a.platform,
|
||||||
|
accountName: a.accountName,
|
||||||
|
displayName: a.displayName,
|
||||||
|
status: a.status,
|
||||||
|
requireApproval: a.requireApproval,
|
||||||
|
})),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Browser Automation API (for agents) ────────────────────────────
|
||||||
|
|
||||||
|
// POST /api/companies/:companyId/browser/action — Submit a browser action
|
||||||
|
this.addRoute("POST", "/api/companies/:companyId/browser/action", async (_req, res, params, token, body) => {
|
||||||
|
const companyId = params.companyId!;
|
||||||
|
if (companyId !== token.companyId) {
|
||||||
|
this.json(res, 403, { error: "Company mismatch" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionSchema = z.object({
|
||||||
|
socialAccountId: z.string().uuid(),
|
||||||
|
actionType: z.enum(["post", "reply", "like", "follow", "browse_feed", "screenshot", "navigate", "search", "dm", "custom_script"]),
|
||||||
|
payload: z.record(z.string(), z.unknown()).default({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const parsed = actionSchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
this.json(res, 400, { error: "Invalid browser action payload", details: parsed.error.issues });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = parsed.data;
|
||||||
|
|
||||||
|
// Verify the social account belongs to this company
|
||||||
|
const snapshot = this.db.listSnapshot();
|
||||||
|
const account = snapshot.socialAccounts.find(a => a.id === input.socialAccountId && a.companyId === companyId);
|
||||||
|
if (!account) {
|
||||||
|
this.json(res, 404, { error: "Social account not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if approval is required for posting actions
|
||||||
|
const postActions = ["post", "reply", "dm"];
|
||||||
|
if (account.requireApproval && postActions.includes(input.actionType)) {
|
||||||
|
// Create an approval request
|
||||||
|
const approvalId = this.db.requestApproval({
|
||||||
|
companyId,
|
||||||
|
type: "dangerous_command",
|
||||||
|
relatedTaskId: null,
|
||||||
|
requestedByAgentId: token.agentId,
|
||||||
|
payloadSummary: `${input.actionType} on ${account.platform}/@${account.accountName}: ${String(input.payload.content || "").slice(0, 100)}`,
|
||||||
|
impactSummary: `Will ${input.actionType} on ${account.displayName || account.accountName}'s ${account.platform} account. Content will be publicly visible.`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save the action in approval_required state
|
||||||
|
const actionId = this.db.saveBrowserAction({
|
||||||
|
companyId,
|
||||||
|
socialAccountId: input.socialAccountId,
|
||||||
|
agentId: token.agentId,
|
||||||
|
actionType: input.actionType,
|
||||||
|
payloadJson: JSON.stringify(input.payload),
|
||||||
|
status: "approval_required",
|
||||||
|
approvalId,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.db.addActivity({
|
||||||
|
companyId,
|
||||||
|
actor: token.agentId,
|
||||||
|
action: "browser.action_pending_approval",
|
||||||
|
entityType: "browser_action",
|
||||||
|
entityId: actionId,
|
||||||
|
detail: `${input.actionType} on ${account.platform} requires approval`,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.onDomainChanged();
|
||||||
|
this.json(res, 202, { id: actionId, status: "approval_required", approvalId, message: "Action requires Board approval before execution" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save the action
|
||||||
|
const actionId = this.db.saveBrowserAction({
|
||||||
|
companyId,
|
||||||
|
socialAccountId: input.socialAccountId,
|
||||||
|
agentId: token.agentId,
|
||||||
|
actionType: input.actionType,
|
||||||
|
payloadJson: JSON.stringify(input.payload),
|
||||||
|
status: "queued",
|
||||||
|
});
|
||||||
|
|
||||||
|
this.db.addActivity({
|
||||||
|
companyId,
|
||||||
|
actor: token.agentId,
|
||||||
|
action: "browser.action_queued",
|
||||||
|
entityType: "browser_action",
|
||||||
|
entityId: actionId,
|
||||||
|
detail: `${input.actionType} on ${account.platform}/@${account.accountName}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.onDomainChanged();
|
||||||
|
|
||||||
|
// Execute the action asynchronously via the browser manager callback
|
||||||
|
if (this.onBrowserAction) {
|
||||||
|
this.onBrowserAction(actionId, input.socialAccountId, input.actionType, input.payload as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.json(res, 201, { id: actionId, status: "queued", message: "Browser action queued for execution" });
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/companies/:companyId/social-accounts — List social accounts
|
||||||
|
this.addRoute("GET", "/api/companies/:companyId/social-accounts", (_req, res, params, token) => {
|
||||||
|
if (params.companyId !== token.companyId) {
|
||||||
|
this.json(res, 403, { error: "Company mismatch" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const accounts = this.db.listSocialAccounts(params.companyId!);
|
||||||
|
// Don't expose credential secret IDs to agents
|
||||||
|
const sanitized = accounts.map(a => ({
|
||||||
|
id: a.id,
|
||||||
|
platform: a.platform,
|
||||||
|
accountName: a.accountName,
|
||||||
|
displayName: a.displayName,
|
||||||
|
profileUrl: a.profileUrl,
|
||||||
|
status: a.status,
|
||||||
|
requireApproval: a.requireApproval,
|
||||||
|
}));
|
||||||
|
this.json(res, 200, sanitized);
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/companies/:companyId/browser/actions — List browser action history
|
||||||
|
this.addRoute("GET", "/api/companies/:companyId/browser/actions", (req, res, params, token) => {
|
||||||
|
if (params.companyId !== token.companyId) {
|
||||||
|
this.json(res, 403, { error: "Company mismatch" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = new URL(req.url ?? "/", `http://127.0.0.1:${this.port}`);
|
||||||
|
const socialAccountId = url.searchParams.get("socialAccountId") ?? undefined;
|
||||||
|
const status = url.searchParams.get("status") ?? undefined;
|
||||||
|
const limit = Number(url.searchParams.get("limit") || 50);
|
||||||
|
|
||||||
|
const actions = this.db.listBrowserActions(params.companyId!, { socialAccountId, status, limit });
|
||||||
|
this.json(res, 200, actions);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Documents API ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// POST /api/companies/:companyId/documents — Create a document
|
||||||
|
this.addRoute("POST", "/api/companies/:companyId/documents", (_req, res, params, token, body) => {
|
||||||
|
if (params.companyId !== token.companyId) {
|
||||||
|
this.json(res, 403, { error: "Company mismatch" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = body as Record<string, unknown>;
|
||||||
|
const id = this.db.saveDocument({
|
||||||
|
companyId: token.companyId,
|
||||||
|
type: String(data.type ?? "knowledge_article"),
|
||||||
|
title: String(data.title ?? "Untitled"),
|
||||||
|
content: String(data.content ?? ""),
|
||||||
|
authorAgentId: token.agentId,
|
||||||
|
reviewerAgentId: data.reviewerAgentId ? String(data.reviewerAgentId) : null,
|
||||||
|
projectId: data.projectId ? String(data.projectId) : null,
|
||||||
|
goalId: data.goalId ? String(data.goalId) : null,
|
||||||
|
status: String(data.status ?? "draft"),
|
||||||
|
tagsJson: data.tagsJson ? String(data.tagsJson) : "[]",
|
||||||
|
});
|
||||||
|
this.onDomainChanged();
|
||||||
|
this.json(res, 201, { id });
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/companies/:companyId/documents — List documents
|
||||||
|
this.addRoute("GET", "/api/companies/:companyId/documents", (req, res, params, token) => {
|
||||||
|
if (params.companyId !== token.companyId) {
|
||||||
|
this.json(res, 403, { error: "Company mismatch" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const snapshot = this.db.listSnapshot();
|
||||||
|
const docs = snapshot.documents.filter(d => d.companyId === token.companyId);
|
||||||
|
this.json(res, 200, docs);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Meetings API ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// POST /api/companies/:companyId/meetings — Schedule a meeting
|
||||||
|
this.addRoute("POST", "/api/companies/:companyId/meetings", (_req, res, params, token, body) => {
|
||||||
|
if (params.companyId !== token.companyId) {
|
||||||
|
this.json(res, 403, { error: "Company mismatch" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = body as Record<string, unknown>;
|
||||||
|
const id = this.db.saveMeeting({
|
||||||
|
companyId: token.companyId,
|
||||||
|
type: String(data.type ?? "department_sync"),
|
||||||
|
title: String(data.title ?? "Meeting"),
|
||||||
|
organizerAgentId: token.agentId,
|
||||||
|
participantAgentIds: data.participantAgentIds ? String(data.participantAgentIds) : "[]",
|
||||||
|
scheduledAt: String(data.scheduledAt ?? new Date().toISOString()),
|
||||||
|
durationMinutes: Number(data.durationMinutes ?? 30),
|
||||||
|
agendaJson: data.agendaJson ? String(data.agendaJson) : "[]",
|
||||||
|
status: "scheduled",
|
||||||
|
});
|
||||||
|
this.onDomainChanged();
|
||||||
|
this.json(res, 201, { id });
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/companies/:companyId/meetings — List meetings
|
||||||
|
this.addRoute("GET", "/api/companies/:companyId/meetings", (_req, res, params, token) => {
|
||||||
|
if (params.companyId !== token.companyId) {
|
||||||
|
this.json(res, 403, { error: "Company mismatch" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const snapshot = this.db.listSnapshot();
|
||||||
|
const meetings = snapshot.meetings.filter(m => m.companyId === token.companyId);
|
||||||
|
this.json(res, 200, meetings);
|
||||||
|
});
|
||||||
|
|
||||||
|
// PATCH /api/meetings/:meetingId — Update meeting (add notes, decisions, etc.)
|
||||||
|
this.addRoute("PATCH", "/api/meetings/:meetingId", (_req, res, params, token, body) => {
|
||||||
|
const data = body as Record<string, unknown>;
|
||||||
|
this.db.saveMeeting({
|
||||||
|
id: params.meetingId,
|
||||||
|
companyId: token.companyId,
|
||||||
|
type: data.type ? String(data.type) : "department_sync",
|
||||||
|
title: data.title ? String(data.title) : "Meeting",
|
||||||
|
notesJson: data.notesJson ? String(data.notesJson) : undefined,
|
||||||
|
decisionsJson: data.decisionsJson ? String(data.decisionsJson) : undefined,
|
||||||
|
actionItemsJson: data.actionItemsJson ? String(data.actionItemsJson) : undefined,
|
||||||
|
status: data.status ? String(data.status) : undefined,
|
||||||
|
scheduledAt: data.scheduledAt ? String(data.scheduledAt) : new Date().toISOString(),
|
||||||
|
});
|
||||||
|
this.onDomainChanged();
|
||||||
|
this.json(res, 200, { updated: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Knowledge Base API ────────────────────────────────────────────
|
||||||
|
|
||||||
|
// POST /api/companies/:companyId/knowledge — Add knowledge entry
|
||||||
|
this.addRoute("POST", "/api/companies/:companyId/knowledge", (_req, res, params, token, body) => {
|
||||||
|
if (params.companyId !== token.companyId) {
|
||||||
|
this.json(res, 403, { error: "Company mismatch" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = body as Record<string, unknown>;
|
||||||
|
const id = this.db.saveKnowledgeEntry({
|
||||||
|
companyId: token.companyId,
|
||||||
|
category: String(data.category ?? "lesson_learned"),
|
||||||
|
topic: String(data.topic ?? ""),
|
||||||
|
content: String(data.content ?? ""),
|
||||||
|
authorAgentId: token.agentId,
|
||||||
|
importance: String(data.importance ?? "medium"),
|
||||||
|
tagsJson: data.tagsJson ? String(data.tagsJson) : "[]",
|
||||||
|
referencedEntityType: data.referencedEntityType ? String(data.referencedEntityType) : null,
|
||||||
|
referencedEntityId: data.referencedEntityId ? String(data.referencedEntityId) : null,
|
||||||
|
});
|
||||||
|
this.onDomainChanged();
|
||||||
|
this.json(res, 201, { id });
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/companies/:companyId/knowledge — List knowledge entries
|
||||||
|
this.addRoute("GET", "/api/companies/:companyId/knowledge", (_req, res, params, token) => {
|
||||||
|
if (params.companyId !== token.companyId) {
|
||||||
|
this.json(res, 403, { error: "Company mismatch" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const snapshot = this.db.listSnapshot();
|
||||||
|
const entries = snapshot.knowledgeBase.filter(k => k.companyId === token.companyId);
|
||||||
|
this.json(res, 200, entries);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Agent Messaging API ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
// POST /api/companies/:companyId/messages — Send a message to another agent
|
||||||
|
this.addRoute("POST", "/api/companies/:companyId/messages", (_req, res, params, token, body) => {
|
||||||
|
const companyId = params.companyId!;
|
||||||
|
if (companyId !== token.companyId) {
|
||||||
|
this.json(res, 403, { error: "Company mismatch" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageSchema = z.object({
|
||||||
|
toAgentId: z.string().uuid().nullable().optional(),
|
||||||
|
channel: z.enum(["direct", "department", "company", "project", "incident"]).default("direct"),
|
||||||
|
channelTargetId: z.string().nullable().optional(),
|
||||||
|
subject: z.string().min(1).max(200),
|
||||||
|
body: z.string().min(1).max(8000),
|
||||||
|
priority: z.enum(["urgent", "normal", "low"]).default("normal"),
|
||||||
|
parentMessageId: z.string().uuid().nullable().optional(),
|
||||||
|
attachmentsJson: z.string().default("[]"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const parsed = messageSchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
this.json(res, 400, { error: "Invalid input", details: parsed.error.flatten() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const msg = parsed.data;
|
||||||
|
const messageId = this.db.sendAgentMessage({
|
||||||
|
companyId,
|
||||||
|
fromAgentId: token.agentId,
|
||||||
|
toAgentId: msg.toAgentId,
|
||||||
|
channel: msg.channel,
|
||||||
|
channelTargetId: msg.channelTargetId,
|
||||||
|
subject: msg.subject,
|
||||||
|
body: msg.body,
|
||||||
|
priority: msg.priority,
|
||||||
|
parentMessageId: msg.parentMessageId,
|
||||||
|
attachmentsJson: msg.attachmentsJson,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wake the recipient if it's a direct message
|
||||||
|
if (msg.toAgentId && msg.channel === "direct") {
|
||||||
|
this.onWakeAgent(msg.toAgentId, companyId, "message");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wake all agents in the department if it's a department broadcast
|
||||||
|
if (msg.channel === "department" && msg.channelTargetId) {
|
||||||
|
const snapshot = this.db.listSnapshot();
|
||||||
|
const deptAgents = snapshot.agents.filter(
|
||||||
|
(a) => a.department === msg.channelTargetId && a.companyId === companyId && a.id !== token.agentId && a.status !== "terminated",
|
||||||
|
);
|
||||||
|
for (const a of deptAgents) {
|
||||||
|
this.onWakeAgent(a.id, companyId, "message");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.onDomainChanged();
|
||||||
|
this.json(res, 201, { id: messageId });
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/companies/:companyId/messages — List messages for the calling agent
|
||||||
|
this.addRoute("GET", "/api/companies/:companyId/messages", (req, res, params, token) => {
|
||||||
|
const companyId = params.companyId!;
|
||||||
|
if (companyId !== token.companyId) {
|
||||||
|
this.json(res, 403, { error: "Company mismatch" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(req.url ?? "/", `http://127.0.0.1:${this.port}`);
|
||||||
|
const channel = url.searchParams.get("channel") ?? undefined;
|
||||||
|
const unreadOnly = url.searchParams.get("unreadOnly") === "true";
|
||||||
|
const limitStr = url.searchParams.get("limit");
|
||||||
|
const limit = limitStr ? Math.min(Math.max(parseInt(limitStr, 10) || 50, 1), 200) : 50;
|
||||||
|
|
||||||
|
const messages = this.db.listAgentMessages(companyId, {
|
||||||
|
agentId: token.agentId,
|
||||||
|
channel,
|
||||||
|
unreadOnly,
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
this.json(res, 200, messages);
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/companies/:companyId/messages/:messageId/read — Mark message as read
|
||||||
|
this.addRoute("POST", "/api/companies/:companyId/messages/:messageId/read", (_req, res, params, token) => {
|
||||||
|
const companyId = params.companyId!;
|
||||||
|
if (companyId !== token.companyId) {
|
||||||
|
this.json(res, 403, { error: "Company mismatch" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.db.markMessageRead(params.messageId!, companyId);
|
||||||
|
this.json(res, 200, { ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Sprint API ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// GET /api/companies/:companyId/sprints — List sprints
|
||||||
|
this.addRoute("GET", "/api/companies/:companyId/sprints", (_req, res, params, token) => {
|
||||||
|
const companyId = params.companyId!;
|
||||||
|
if (companyId !== token.companyId) {
|
||||||
|
this.json(res, 403, { error: "Company mismatch" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const snapshot = this.db.listSnapshot();
|
||||||
|
const sprints = snapshot.sprints.filter(s => s.companyId === companyId);
|
||||||
|
this.json(res, 200, sprints);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Performance Metrics API ──────────────────────────────────────────
|
||||||
|
|
||||||
|
// GET /api/agents/me/metrics — Get the calling agent's performance metrics
|
||||||
|
this.addRoute("GET", "/api/agents/me/metrics", (_req, res, _params, token) => {
|
||||||
|
const companyMetrics = this.db.getCompanyMetrics(token.companyId, 30);
|
||||||
|
const agentMetric = companyMetrics.agentMetrics.find(m => m.agentId === token.agentId);
|
||||||
|
if (!agentMetric) {
|
||||||
|
this.json(res, 404, { error: "Metrics not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.json(res, 200, agentMetric);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Org Chart / Agent Details API ────────────────────────────────────
|
||||||
|
|
||||||
|
// GET /api/agents/:agentId — Get a specific agent's details
|
||||||
|
this.addRoute("GET", "/api/agents/:agentId", (_req, res, params, token) => {
|
||||||
|
const snapshot = this.db.listSnapshot();
|
||||||
|
const agent = snapshot.agents.find(a => a.id === params.agentId && a.companyId === token.companyId);
|
||||||
|
if (!agent) {
|
||||||
|
this.json(res, 404, { error: "Agent not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const directReports = snapshot.agents.filter(a => a.reportsTo === agent.id && a.companyId === token.companyId);
|
||||||
|
const manager = agent.reportsTo ? snapshot.agents.find(a => a.id === agent.reportsTo) : null;
|
||||||
|
this.json(res, 200, {
|
||||||
|
...agent,
|
||||||
|
manager: manager ? { id: manager.id, name: manager.name, role: manager.role } : null,
|
||||||
|
directReports: directReports.map(r => ({ id: r.id, name: r.name, role: r.role, status: r.status })),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Wake / Heartbeat API ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
// POST /api/agents/:agentId/wake — Request to wake another agent
|
||||||
|
this.addRoute("POST", "/api/agents/:agentId/wake", (_req, res, params, token, body) => {
|
||||||
|
const targetAgentId = params.agentId!;
|
||||||
|
const wakeSchema = z.object({
|
||||||
|
reason: z.string().min(1).max(200).default("peer_request"),
|
||||||
|
});
|
||||||
|
const parsed = wakeSchema.safeParse(body);
|
||||||
|
const reason = parsed.success ? parsed.data.reason : "peer_request";
|
||||||
|
this.onWakeAgent(targetAgentId, token.companyId, reason);
|
||||||
|
this.json(res, 200, { ok: true, message: `Wake requested for agent ${targetAgentId}` });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,485 @@
|
|||||||
|
import { BrowserWindow, session, type NativeImage } from "electron";
|
||||||
|
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import log from "electron-log/main.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Browser Automation Manager — Electron-native edition.
|
||||||
|
*
|
||||||
|
* Uses Electron's own BrowserWindow and persistent sessions instead of
|
||||||
|
* external Playwright, so it works in packaged apps with zero extra deps.
|
||||||
|
*
|
||||||
|
* Each social account gets its own Electron session partition so cookies,
|
||||||
|
* localStorage, and login state are isolated and persisted across app restarts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface BrowserAction {
|
||||||
|
id: string;
|
||||||
|
socialAccountId: string;
|
||||||
|
actionType: string;
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrowserActionResult {
|
||||||
|
success: boolean;
|
||||||
|
summary: string;
|
||||||
|
screenshotPath: string | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QueuedAction {
|
||||||
|
action: BrowserAction;
|
||||||
|
resolve: (result: BrowserActionResult) => void;
|
||||||
|
reject: (error: Error) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BrowserManager {
|
||||||
|
private screenshotDir: string;
|
||||||
|
private queues = new Map<string, QueuedAction[]>();
|
||||||
|
private processing = new Set<string>();
|
||||||
|
/** Track open login windows so we don't open duplicates */
|
||||||
|
private loginWindows = new Map<string, BrowserWindow>();
|
||||||
|
|
||||||
|
constructor(profileDir: string) {
|
||||||
|
this.screenshotDir = join(profileDir, "browser-screenshots");
|
||||||
|
if (!existsSync(this.screenshotDir)) mkdirSync(this.screenshotDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a persistent Electron session for a social account.
|
||||||
|
* Using `persist:` prefix ensures data survives app restarts.
|
||||||
|
*/
|
||||||
|
private getSession(socialAccountId: string): Electron.Session {
|
||||||
|
return session.fromPartition(`persist:social-${socialAccountId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Launch a real browser window for manual login.
|
||||||
|
* The window uses a persistent session so cookies are saved.
|
||||||
|
* Returns a promise that resolves when the user closes the window.
|
||||||
|
*/
|
||||||
|
async launchLoginBrowser(
|
||||||
|
socialAccountId: string,
|
||||||
|
platform: string,
|
||||||
|
loginUrl?: string,
|
||||||
|
): Promise<BrowserActionResult> {
|
||||||
|
// Don't open duplicate login windows for the same account
|
||||||
|
const existing = this.loginWindows.get(socialAccountId);
|
||||||
|
if (existing && !existing.isDestroyed()) {
|
||||||
|
existing.focus();
|
||||||
|
return { success: true, summary: "Login window already open", screenshotPath: null, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultUrls: Record<string, string> = {
|
||||||
|
twitter: "https://x.com/i/flow/login",
|
||||||
|
linkedin: "https://www.linkedin.com/login",
|
||||||
|
instagram: "https://www.instagram.com/accounts/login/",
|
||||||
|
facebook: "https://www.facebook.com/login",
|
||||||
|
reddit: "https://www.reddit.com/login",
|
||||||
|
youtube: "https://accounts.google.com/signin",
|
||||||
|
tiktok: "https://www.tiktok.com/login",
|
||||||
|
bluesky: "https://bsky.app",
|
||||||
|
other: "https://google.com",
|
||||||
|
};
|
||||||
|
|
||||||
|
const url = loginUrl || defaultUrls[platform] || defaultUrls.other;
|
||||||
|
const ses = this.getSession(socialAccountId);
|
||||||
|
|
||||||
|
log.info(`[browser] Opening login window for ${platform} → ${url}`);
|
||||||
|
|
||||||
|
const win = new BrowserWindow({
|
||||||
|
width: 1280,
|
||||||
|
height: 860,
|
||||||
|
title: `Login to ${platform} — AgentCompany`,
|
||||||
|
webPreferences: {
|
||||||
|
session: ses,
|
||||||
|
// Standard web page — no node integration, no preload
|
||||||
|
nodeIntegration: false,
|
||||||
|
contextIsolation: true,
|
||||||
|
sandbox: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.loginWindows.set(socialAccountId, win);
|
||||||
|
|
||||||
|
// Remove menu bar for a cleaner look
|
||||||
|
win.setMenuBarVisibility(false);
|
||||||
|
|
||||||
|
// Load the login page
|
||||||
|
try {
|
||||||
|
await win.loadURL(url);
|
||||||
|
} catch (err) {
|
||||||
|
log.error(`[browser] Failed to load ${url}: ${err}`);
|
||||||
|
// Don't fail — the page might redirect or have an expected error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for the user to close the window
|
||||||
|
return new Promise<BrowserActionResult>((resolve) => {
|
||||||
|
win.on("closed", () => {
|
||||||
|
this.loginWindows.delete(socialAccountId);
|
||||||
|
log.info(`[browser] Login window closed for ${platform} — session saved`);
|
||||||
|
resolve({
|
||||||
|
success: true,
|
||||||
|
summary: `Login session saved for ${platform}`,
|
||||||
|
screenshotPath: null,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if browser automation is available.
|
||||||
|
* Always true — we use Electron's own Chromium.
|
||||||
|
*/
|
||||||
|
async checkPlaywright(): Promise<boolean> {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queue a browser action for execution.
|
||||||
|
*/
|
||||||
|
async executeAction(action: BrowserAction): Promise<BrowserActionResult> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const queue = this.queues.get(action.socialAccountId) ?? [];
|
||||||
|
queue.push({ action, resolve, reject });
|
||||||
|
this.queues.set(action.socialAccountId, queue);
|
||||||
|
void this.processQueue(action.socialAccountId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async processQueue(socialAccountId: string) {
|
||||||
|
if (this.processing.has(socialAccountId)) return;
|
||||||
|
const queue = this.queues.get(socialAccountId);
|
||||||
|
if (!queue || queue.length === 0) return;
|
||||||
|
|
||||||
|
this.processing.add(socialAccountId);
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const item = queue.shift()!;
|
||||||
|
try {
|
||||||
|
const result = await this.runAction(item.action);
|
||||||
|
item.resolve(result);
|
||||||
|
} catch (error) {
|
||||||
|
item.reject(error instanceof Error ? error : new Error(String(error)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.processing.delete(socialAccountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a single browser action using an Electron BrowserWindow.
|
||||||
|
*/
|
||||||
|
private async runAction(action: BrowserAction): Promise<BrowserActionResult> {
|
||||||
|
const ses = this.getSession(action.socialAccountId);
|
||||||
|
const payload = action.payload;
|
||||||
|
const screenshotPath = join(this.screenshotDir, `${action.id}.png`);
|
||||||
|
|
||||||
|
log.info(`[browser] Executing ${action.actionType} for account ${action.socialAccountId}`);
|
||||||
|
|
||||||
|
// Create a visible browser window for the action
|
||||||
|
const win = new BrowserWindow({
|
||||||
|
width: 1280,
|
||||||
|
height: 860,
|
||||||
|
title: `AgentCompany — ${action.actionType}`,
|
||||||
|
show: true,
|
||||||
|
webPreferences: {
|
||||||
|
session: ses,
|
||||||
|
nodeIntegration: false,
|
||||||
|
contextIsolation: true,
|
||||||
|
sandbox: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
win.setMenuBarVisibility(false);
|
||||||
|
|
||||||
|
try {
|
||||||
|
let summary = "Action completed";
|
||||||
|
|
||||||
|
switch (action.actionType) {
|
||||||
|
case "navigate": {
|
||||||
|
const url = String(payload.url || "https://google.com");
|
||||||
|
await win.loadURL(url);
|
||||||
|
await this.waitForLoad(win);
|
||||||
|
const title = await win.webContents.executeJavaScript("document.title");
|
||||||
|
summary = `Navigated to ${title}`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "post": {
|
||||||
|
const platform = String(payload.platform || "twitter");
|
||||||
|
const content = String(payload.content || "");
|
||||||
|
const postUrl = this.getComposeUrl(platform);
|
||||||
|
await win.loadURL(postUrl);
|
||||||
|
await this.waitForLoad(win);
|
||||||
|
await this.delay(2000);
|
||||||
|
|
||||||
|
if (platform === "twitter") {
|
||||||
|
// Type into tweet composer
|
||||||
|
await win.webContents.executeJavaScript(`
|
||||||
|
(async () => {
|
||||||
|
const editor = document.querySelector('[data-testid="tweetTextarea_0"], [role="textbox"]');
|
||||||
|
if (editor) {
|
||||||
|
editor.focus();
|
||||||
|
document.execCommand('insertText', false, ${JSON.stringify(content)});
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
`);
|
||||||
|
await this.delay(1000);
|
||||||
|
// Click post button
|
||||||
|
await win.webContents.executeJavaScript(`
|
||||||
|
(() => {
|
||||||
|
const btn = document.querySelector('[data-testid="tweetButton"], [data-testid="tweetButtonInline"]');
|
||||||
|
if (btn) btn.click();
|
||||||
|
})()
|
||||||
|
`);
|
||||||
|
await this.delay(3000);
|
||||||
|
} else if (platform === "linkedin") {
|
||||||
|
await win.webContents.executeJavaScript(`
|
||||||
|
(() => {
|
||||||
|
const btn = document.querySelector('button.share-box-feed-entry__trigger');
|
||||||
|
if (btn) btn.click();
|
||||||
|
})()
|
||||||
|
`);
|
||||||
|
await this.delay(2000);
|
||||||
|
await win.webContents.executeJavaScript(`
|
||||||
|
(async () => {
|
||||||
|
const editor = document.querySelector('[role="textbox"][contenteditable="true"]');
|
||||||
|
if (editor) {
|
||||||
|
editor.focus();
|
||||||
|
document.execCommand('insertText', false, ${JSON.stringify(content)});
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
`);
|
||||||
|
await this.delay(1000);
|
||||||
|
await win.webContents.executeJavaScript(`
|
||||||
|
(() => {
|
||||||
|
const btn = document.querySelector('button.share-actions__primary-action');
|
||||||
|
if (btn) btn.click();
|
||||||
|
})()
|
||||||
|
`);
|
||||||
|
await this.delay(3000);
|
||||||
|
}
|
||||||
|
summary = `Posted to ${platform}: "${content.slice(0, 50)}..."`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "reply": {
|
||||||
|
const targetUrl = String(payload.targetUrl || "");
|
||||||
|
const replyContent = String(payload.content || "");
|
||||||
|
await win.loadURL(targetUrl);
|
||||||
|
await this.waitForLoad(win);
|
||||||
|
await this.delay(2000);
|
||||||
|
|
||||||
|
await win.webContents.executeJavaScript(`
|
||||||
|
(async () => {
|
||||||
|
const editor = document.querySelector('[data-testid="tweetTextarea_0"], [role="textbox"]');
|
||||||
|
if (editor) {
|
||||||
|
editor.focus();
|
||||||
|
document.execCommand('insertText', false, ${JSON.stringify(replyContent)});
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
`);
|
||||||
|
await this.delay(1000);
|
||||||
|
await win.webContents.executeJavaScript(`
|
||||||
|
(() => {
|
||||||
|
const btn = document.querySelector('[data-testid="tweetButton"], [data-testid="tweetButtonInline"], button[type="submit"]');
|
||||||
|
if (btn) btn.click();
|
||||||
|
})()
|
||||||
|
`);
|
||||||
|
await this.delay(3000);
|
||||||
|
summary = `Replied to ${targetUrl.slice(0, 50)}`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "like": {
|
||||||
|
const targetUrl = String(payload.targetUrl || "");
|
||||||
|
await win.loadURL(targetUrl);
|
||||||
|
await this.waitForLoad(win);
|
||||||
|
await this.delay(2000);
|
||||||
|
await win.webContents.executeJavaScript(`
|
||||||
|
(() => {
|
||||||
|
const btn = document.querySelector('[data-testid="like"], button[aria-label*="Like"]');
|
||||||
|
if (btn) btn.click();
|
||||||
|
})()
|
||||||
|
`);
|
||||||
|
await this.delay(2000);
|
||||||
|
summary = `Liked post at ${targetUrl.slice(0, 50)}`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "follow": {
|
||||||
|
const targetUrl = String(payload.targetUrl || payload.profileUrl || "");
|
||||||
|
await win.loadURL(targetUrl);
|
||||||
|
await this.waitForLoad(win);
|
||||||
|
await this.delay(2000);
|
||||||
|
await win.webContents.executeJavaScript(`
|
||||||
|
(() => {
|
||||||
|
const btn = document.querySelector('[data-testid*="follow"], button[aria-label*="Follow"]');
|
||||||
|
if (btn && !btn.textContent.includes('Following')) btn.click();
|
||||||
|
})()
|
||||||
|
`);
|
||||||
|
await this.delay(2000);
|
||||||
|
summary = `Followed user at ${targetUrl.slice(0, 50)}`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "browse_feed": {
|
||||||
|
const platform = String(payload.platform || "twitter");
|
||||||
|
const feedUrl = String(payload.url || this.getFeedUrl(platform));
|
||||||
|
const maxPosts = Number(payload.maxPosts || 10);
|
||||||
|
await win.loadURL(feedUrl);
|
||||||
|
await this.waitForLoad(win);
|
||||||
|
await this.delay(3000);
|
||||||
|
|
||||||
|
// Scroll to load content
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
await win.webContents.executeJavaScript("window.scrollBy(0, window.innerHeight)");
|
||||||
|
await this.delay(1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
const posts: string[] = await win.webContents.executeJavaScript(`
|
||||||
|
(() => {
|
||||||
|
const articles = document.querySelectorAll('article, [data-testid="tweet"], .feed-shared-update-v2');
|
||||||
|
return Array.from(articles).slice(0, ${maxPosts}).map(el => el.innerText.slice(0, 300));
|
||||||
|
})()
|
||||||
|
`);
|
||||||
|
summary = `Browsed feed — found ${posts.length} posts`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "search": {
|
||||||
|
const query = String(payload.query || "");
|
||||||
|
const platform = String(payload.platform || "twitter");
|
||||||
|
const searchUrl = this.getSearchUrl(platform, query);
|
||||||
|
await win.loadURL(searchUrl);
|
||||||
|
await this.waitForLoad(win);
|
||||||
|
await this.delay(3000);
|
||||||
|
|
||||||
|
const results: string[] = await win.webContents.executeJavaScript(`
|
||||||
|
(() => {
|
||||||
|
const items = document.querySelectorAll('article, [data-testid="tweet"], .search-result');
|
||||||
|
return Array.from(items).slice(0, 10).map(el => el.innerText.slice(0, 200));
|
||||||
|
})()
|
||||||
|
`);
|
||||||
|
summary = `Search "${query.slice(0, 30)}" — ${results.length} results`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "screenshot": {
|
||||||
|
const url = String(payload.url || "about:blank");
|
||||||
|
if (url !== "about:blank") {
|
||||||
|
await win.loadURL(url);
|
||||||
|
await this.waitForLoad(win);
|
||||||
|
await this.delay(2000);
|
||||||
|
}
|
||||||
|
const title = await win.webContents.executeJavaScript("document.title");
|
||||||
|
summary = `Screenshot captured: ${title}`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
summary = `Unknown action type: ${action.actionType}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture screenshot
|
||||||
|
try {
|
||||||
|
const image: NativeImage = await win.webContents.capturePage();
|
||||||
|
if (!image.isEmpty()) {
|
||||||
|
writeFileSync(screenshotPath, image.toPNG());
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log.warn(`[browser] Screenshot failed: ${err}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close the action window after a brief pause so user can see result
|
||||||
|
await this.delay(1000);
|
||||||
|
if (!win.isDestroyed()) win.close();
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
summary,
|
||||||
|
screenshotPath: existsSync(screenshotPath) ? screenshotPath : null,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
// Try to capture error screenshot
|
||||||
|
try {
|
||||||
|
const image: NativeImage = await win.webContents.capturePage();
|
||||||
|
if (!image.isEmpty()) writeFileSync(screenshotPath, image.toPNG());
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
|
if (!win.isDestroyed()) win.close();
|
||||||
|
|
||||||
|
const msg = error instanceof Error ? error.message : String(error);
|
||||||
|
log.error(`[browser] Action ${action.actionType} failed: ${msg}`);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
summary: "Action failed",
|
||||||
|
screenshotPath: existsSync(screenshotPath) ? screenshotPath : null,
|
||||||
|
error: msg,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private delay(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
private waitForLoad(win: BrowserWindow, timeoutMs = 15000): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (win.webContents.isLoading()) {
|
||||||
|
const timer = setTimeout(resolve, timeoutMs);
|
||||||
|
win.webContents.once("did-finish-load", () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private getComposeUrl(platform: string): string {
|
||||||
|
const urls: Record<string, string> = {
|
||||||
|
twitter: "https://x.com/compose/post",
|
||||||
|
linkedin: "https://www.linkedin.com/feed/",
|
||||||
|
instagram: "https://www.instagram.com/",
|
||||||
|
facebook: "https://www.facebook.com/",
|
||||||
|
reddit: "https://www.reddit.com/submit",
|
||||||
|
bluesky: "https://bsky.app/",
|
||||||
|
};
|
||||||
|
return urls[platform] || "https://google.com";
|
||||||
|
}
|
||||||
|
|
||||||
|
private getFeedUrl(platform: string): string {
|
||||||
|
const urls: Record<string, string> = {
|
||||||
|
twitter: "https://x.com/home",
|
||||||
|
linkedin: "https://www.linkedin.com/feed/",
|
||||||
|
instagram: "https://www.instagram.com/",
|
||||||
|
reddit: "https://www.reddit.com/",
|
||||||
|
bluesky: "https://bsky.app/",
|
||||||
|
};
|
||||||
|
return urls[platform] || "https://google.com";
|
||||||
|
}
|
||||||
|
|
||||||
|
private getSearchUrl(platform: string, query: string): string {
|
||||||
|
const q = encodeURIComponent(query);
|
||||||
|
const urls: Record<string, string> = {
|
||||||
|
twitter: `https://x.com/search?q=${q}`,
|
||||||
|
linkedin: `https://www.linkedin.com/search/results/all/?keywords=${q}`,
|
||||||
|
reddit: `https://www.reddit.com/search/?q=${q}`,
|
||||||
|
};
|
||||||
|
return urls[platform] || `https://google.com/search?q=${q}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
shutdown() {
|
||||||
|
// Close any open login windows
|
||||||
|
for (const [, win] of this.loginWindows) {
|
||||||
|
if (!win.isDestroyed()) win.close();
|
||||||
|
}
|
||||||
|
this.loginWindows.clear();
|
||||||
|
this.queues.clear();
|
||||||
|
this.processing.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
+990
-6
File diff suppressed because it is too large.
Load diff
+544
-4
@@ -26,6 +26,7 @@ import {
|
|||||||
approvalDecisionSchema,
|
approvalDecisionSchema,
|
||||||
approvalInputSchema,
|
approvalInputSchema,
|
||||||
agentInputSchema,
|
agentInputSchema,
|
||||||
|
browserActionQuerySchema,
|
||||||
cancelRunSchema,
|
cancelRunSchema,
|
||||||
commentInputSchema,
|
commentInputSchema,
|
||||||
companyInputSchema,
|
companyInputSchema,
|
||||||
@@ -33,16 +34,29 @@ import {
|
|||||||
deleteEntitySchema,
|
deleteEntitySchema,
|
||||||
goalInputSchema,
|
goalInputSchema,
|
||||||
heartbeatSettingsSchema,
|
heartbeatSettingsSchema,
|
||||||
|
inboxDismissSchema,
|
||||||
listCommentsSchema,
|
listCommentsSchema,
|
||||||
|
metricsQuerySchema,
|
||||||
type DesktopResult,
|
type DesktopResult,
|
||||||
projectInputSchema,
|
projectInputSchema,
|
||||||
runLogSchema,
|
runLogSchema,
|
||||||
secretInputSchema,
|
secretInputSchema,
|
||||||
settingsSchema,
|
settingsSchema,
|
||||||
|
socialAccountInputSchema,
|
||||||
startRunSchema,
|
startRunSchema,
|
||||||
taskInputSchema,
|
taskInputSchema,
|
||||||
|
templateApplySchema,
|
||||||
triggerHeartbeatSchema,
|
triggerHeartbeatSchema,
|
||||||
workspaceInputSchema,
|
workspaceInputSchema,
|
||||||
|
meetingInputSchema,
|
||||||
|
documentInputSchema,
|
||||||
|
knowledgeEntryInputSchema,
|
||||||
|
sprintInputSchema,
|
||||||
|
automationRuleInputSchema,
|
||||||
|
agentMessageInputSchema,
|
||||||
|
messageQuerySchema,
|
||||||
|
workflowInputSchema,
|
||||||
|
autoAssignSchema,
|
||||||
} from "@shared/contracts";
|
} from "@shared/contracts";
|
||||||
import type { DesktopEvent } from "@shared/types";
|
import type { DesktopEvent } from "@shared/types";
|
||||||
import { getConnectorDefinition } from "./connectors";
|
import { getConnectorDefinition } from "./connectors";
|
||||||
@@ -50,6 +64,8 @@ import { AppDatabase } from "./database";
|
|||||||
import { AgentApiServer } from "./api-server";
|
import { AgentApiServer } from "./api-server";
|
||||||
import { createAgentToken } from "./agent-auth";
|
import { createAgentToken } from "./agent-auth";
|
||||||
import { buildTaskPrompt } from "./prompt-builder";
|
import { buildTaskPrompt } from "./prompt-builder";
|
||||||
|
import { COMPANY_TEMPLATES, getTemplate } from "./templates";
|
||||||
|
import { BrowserManager } from "./browser-manager";
|
||||||
|
|
||||||
app.name = "AgentCompany";
|
app.name = "AgentCompany";
|
||||||
|
|
||||||
@@ -105,6 +121,7 @@ let mainWindow: BrowserWindow | null = null;
|
|||||||
let db: AppDatabase;
|
let db: AppDatabase;
|
||||||
let worker: ReturnType<typeof utilityProcess.fork> | null = null;
|
let worker: ReturnType<typeof utilityProcess.fork> | null = null;
|
||||||
let apiServer: AgentApiServer | null = null;
|
let apiServer: AgentApiServer | null = null;
|
||||||
|
let browserManager: BrowserManager | null = null;
|
||||||
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
let isQuitting = false;
|
let isQuitting = false;
|
||||||
let backgroundCloseNoticeShown = false;
|
let backgroundCloseNoticeShown = false;
|
||||||
@@ -304,17 +321,32 @@ function buildMenu() {
|
|||||||
Menu.setApplicationMenu(Menu.buildFromTemplate(template));
|
Menu.setApplicationMenu(Menu.buildFromTemplate(template));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isSocialBrowserSession(contents: Electron.WebContents): boolean {
|
||||||
|
// Social login windows use partitioned sessions named "persist:social-..."
|
||||||
|
const partition = (contents.session as { getStoragePath?: () => string })?.getStoragePath?.() ?? "";
|
||||||
|
// Simpler: check the session object reference — social sessions are never the default session
|
||||||
|
return contents.session !== session.defaultSession;
|
||||||
|
}
|
||||||
|
|
||||||
function installSecurityGuards() {
|
function installSecurityGuards() {
|
||||||
session.defaultSession.setPermissionCheckHandler(() => false);
|
session.defaultSession.setPermissionCheckHandler(() => false);
|
||||||
session.defaultSession.setPermissionRequestHandler((_webContents, _permission, callback) => {
|
session.defaultSession.setPermissionRequestHandler((_webContents, _permission, callback) => {
|
||||||
callback(false);
|
callback(false);
|
||||||
});
|
});
|
||||||
app.on("web-contents-created", (_event, contents) => {
|
app.on("web-contents-created", (_event, contents) => {
|
||||||
contents.setWindowOpenHandler(() => ({ action: "deny" }));
|
contents.setWindowOpenHandler(({ url }) => {
|
||||||
|
// Allow social browser windows to open popups (OAuth flows etc.)
|
||||||
|
if (isSocialBrowserSession(contents)) {
|
||||||
|
return { action: "allow" };
|
||||||
|
}
|
||||||
|
return { action: "deny" };
|
||||||
|
});
|
||||||
contents.on("will-attach-webview", (event) => {
|
contents.on("will-attach-webview", (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
});
|
});
|
||||||
contents.on("will-navigate", (event) => {
|
contents.on("will-navigate", (event) => {
|
||||||
|
// Allow navigation in social login browser windows
|
||||||
|
if (isSocialBrowserSession(contents)) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -464,13 +496,26 @@ function createWorker() {
|
|||||||
const recoveryApiUrl = recoveryApiPort ? `http://127.0.0.1:${recoveryApiPort}` : "";
|
const recoveryApiUrl = recoveryApiPort ? `http://127.0.0.1:${recoveryApiPort}` : "";
|
||||||
const recoveryApiKey = recoveryApiUrl ? createAgentToken(agent.id, candidate.companyId, candidate.id) : "";
|
const recoveryApiKey = recoveryApiUrl ? createAgentToken(agent.id, candidate.companyId, candidate.id) : "";
|
||||||
|
|
||||||
|
const recoveryAgentTasks = recoverySnapshot.tasks.filter((t) => t.companyId === candidate.companyId && t.assigneeAgentId === agent.id);
|
||||||
|
const recoveryCompany = recoverySnapshot.companies.find((c) => c.id === candidate.companyId);
|
||||||
|
const recoveryRecentComments = recoverySnapshot.comments
|
||||||
|
.filter((c) => recoveryAgentTasks.some((t) => t.id === c.taskId))
|
||||||
|
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
|
||||||
|
.slice(0, 20)
|
||||||
|
.map((c) => ({
|
||||||
|
taskId: c.taskId,
|
||||||
|
taskTitle: recoveryAgentTasks.find((t) => t.id === c.taskId)?.title ?? c.taskId,
|
||||||
|
authorName: c.authorName,
|
||||||
|
body: c.body,
|
||||||
|
createdAt: c.createdAt,
|
||||||
|
}));
|
||||||
const recoveryPrompt = recoveryApiUrl ? buildTaskPrompt({
|
const recoveryPrompt = recoveryApiUrl ? buildTaskPrompt({
|
||||||
agent,
|
agent,
|
||||||
apiUrl: recoveryApiUrl,
|
apiUrl: recoveryApiUrl,
|
||||||
apiKey: recoveryApiKey,
|
apiKey: recoveryApiKey,
|
||||||
runId: candidate.id,
|
runId: candidate.id,
|
||||||
task,
|
task,
|
||||||
companyName: recoverySnapshot.companies.find((c) => c.id === candidate.companyId)?.name ?? "Company",
|
companyName: recoveryCompany?.name ?? "Company",
|
||||||
goals: recoverySnapshot.goals.filter((g) => g.companyId === candidate.companyId),
|
goals: recoverySnapshot.goals.filter((g) => g.companyId === candidate.companyId),
|
||||||
projects: recoverySnapshot.projects.filter((p) => p.companyId === candidate.companyId),
|
projects: recoverySnapshot.projects.filter((p) => p.companyId === candidate.companyId),
|
||||||
directReports: db.getDirectReports(agent.id, candidate.companyId),
|
directReports: db.getDirectReports(agent.id, candidate.companyId),
|
||||||
@@ -484,6 +529,30 @@ function createWorker() {
|
|||||||
),
|
),
|
||||||
tasksAwaitingReview: db.listReviewTasksForManager(agent.id, candidate.companyId),
|
tasksAwaitingReview: db.listReviewTasksForManager(agent.id, candidate.companyId),
|
||||||
wakeReason: "recovery",
|
wakeReason: "recovery",
|
||||||
|
allTasks: recoverySnapshot.tasks.filter((t) => t.companyId === candidate.companyId),
|
||||||
|
recentComments: recoveryRecentComments,
|
||||||
|
companyDescription: recoveryCompany?.description ?? "",
|
||||||
|
socialAccounts: db.listSocialAccounts(candidate.companyId).map(a => ({
|
||||||
|
id: a.id,
|
||||||
|
platform: a.platform,
|
||||||
|
accountName: a.accountName,
|
||||||
|
status: a.status,
|
||||||
|
requireApproval: a.requireApproval,
|
||||||
|
})),
|
||||||
|
recentMessages: db.listAgentMessages(candidate.companyId, { agentId: agent.id, limit: 15 }),
|
||||||
|
upcomingMeetings: recoverySnapshot.meetings
|
||||||
|
.filter((m: any) => m.companyId === candidate.companyId && (m.status === "scheduled" || m.status === "in_progress"))
|
||||||
|
.filter((m: any) => {
|
||||||
|
try { const ids = JSON.parse(m.participantAgentIds); return ids.includes(agent.id) || m.organizerAgentId === agent.id; } catch { return false; }
|
||||||
|
})
|
||||||
|
.slice(0, 5),
|
||||||
|
relevantKnowledge: recoverySnapshot.knowledgeBase
|
||||||
|
.filter((k: any) => k.companyId === candidate.companyId && (k.importance === "critical" || k.importance === "high"))
|
||||||
|
.slice(0, 8),
|
||||||
|
activeSprint: recoverySnapshot.sprints.find((s: any) => s.companyId === candidate.companyId && s.status === "active") ?? null,
|
||||||
|
recentDocuments: recoverySnapshot.documents
|
||||||
|
.filter((d: any) => d.companyId === candidate.companyId && (d.authorAgentId === agent.id || d.reviewerAgentId === agent.id || d.status === "in_review"))
|
||||||
|
.slice(0, 5),
|
||||||
}) : null;
|
}) : null;
|
||||||
|
|
||||||
// Inject API credentials exactly like the normal path
|
// Inject API credentials exactly like the normal path
|
||||||
@@ -935,6 +1004,17 @@ function startAgentHeartbeatRun(agentId: string, companyId: string, trigger: str
|
|||||||
const apiUrl = apiPort ? `http://127.0.0.1:${apiPort}` : "";
|
const apiUrl = apiPort ? `http://127.0.0.1:${apiPort}` : "";
|
||||||
const apiKey = apiUrl ? createAgentToken(agent.id, companyId, run.id) : "";
|
const apiKey = apiUrl ? createAgentToken(agent.id, companyId, run.id) : "";
|
||||||
|
|
||||||
|
const heartbeatRecentComments = snapshot.comments
|
||||||
|
.filter((c) => assignedTasks.some((t) => t.id === c.taskId))
|
||||||
|
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
|
||||||
|
.slice(0, 20)
|
||||||
|
.map((c) => ({
|
||||||
|
taskId: c.taskId,
|
||||||
|
taskTitle: assignedTasks.find((t) => t.id === c.taskId)?.title ?? c.taskId,
|
||||||
|
authorName: c.authorName,
|
||||||
|
body: c.body,
|
||||||
|
createdAt: c.createdAt,
|
||||||
|
}));
|
||||||
const systemPrompt = apiUrl ? buildTaskPrompt({
|
const systemPrompt = apiUrl ? buildTaskPrompt({
|
||||||
agent,
|
agent,
|
||||||
apiUrl,
|
apiUrl,
|
||||||
@@ -955,6 +1035,30 @@ function startAgentHeartbeatRun(agentId: string, companyId: string, trigger: str
|
|||||||
),
|
),
|
||||||
tasksAwaitingReview: db.listReviewTasksForManager(agent.id, companyId),
|
tasksAwaitingReview: db.listReviewTasksForManager(agent.id, companyId),
|
||||||
wakeReason: trigger,
|
wakeReason: trigger,
|
||||||
|
allTasks: snapshot.tasks.filter((t) => t.companyId === companyId),
|
||||||
|
recentComments: heartbeatRecentComments,
|
||||||
|
companyDescription: company.description ?? "",
|
||||||
|
socialAccounts: db.listSocialAccounts(companyId).map(a => ({
|
||||||
|
id: a.id,
|
||||||
|
platform: a.platform,
|
||||||
|
accountName: a.accountName,
|
||||||
|
status: a.status,
|
||||||
|
requireApproval: a.requireApproval,
|
||||||
|
})),
|
||||||
|
recentMessages: db.listAgentMessages(companyId, { agentId: agent.id, limit: 15 }),
|
||||||
|
upcomingMeetings: snapshot.meetings
|
||||||
|
.filter((m: any) => m.companyId === companyId && (m.status === "scheduled" || m.status === "in_progress"))
|
||||||
|
.filter((m: any) => {
|
||||||
|
try { const ids = JSON.parse(m.participantAgentIds); return ids.includes(agent.id) || m.organizerAgentId === agent.id; } catch { return false; }
|
||||||
|
})
|
||||||
|
.slice(0, 5),
|
||||||
|
relevantKnowledge: snapshot.knowledgeBase
|
||||||
|
.filter((k: any) => k.companyId === companyId && (k.importance === "critical" || k.importance === "high"))
|
||||||
|
.slice(0, 8),
|
||||||
|
activeSprint: snapshot.sprints.find((s: any) => s.companyId === companyId && s.status === "active") ?? null,
|
||||||
|
recentDocuments: snapshot.documents
|
||||||
|
.filter((d: any) => d.companyId === companyId && (d.authorAgentId === agent.id || d.reviewerAgentId === agent.id || d.status === "in_review"))
|
||||||
|
.slice(0, 5),
|
||||||
}) : null;
|
}) : null;
|
||||||
|
|
||||||
// Add API credentials to env
|
// Add API credentials to env
|
||||||
@@ -1468,18 +1572,31 @@ function registerHandlers() {
|
|||||||
const apiUrl = apiPort ? `http://127.0.0.1:${apiPort}` : "";
|
const apiUrl = apiPort ? `http://127.0.0.1:${apiPort}` : "";
|
||||||
const apiKey = apiUrl ? createAgentToken(agent.id, task.companyId, run.id) : "";
|
const apiKey = apiUrl ? createAgentToken(agent.id, task.companyId, run.id) : "";
|
||||||
|
|
||||||
|
const manualAgentTasks = db.listAgentTasks(agent.id, task.companyId, ["todo", "in_progress", "blocked"]);
|
||||||
|
const manualCompany = snapshot.companies.find((c) => c.id === task.companyId);
|
||||||
|
const manualRecentComments = snapshot.comments
|
||||||
|
.filter((c) => manualAgentTasks.some((t) => t.id === c.taskId))
|
||||||
|
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
|
||||||
|
.slice(0, 20)
|
||||||
|
.map((c) => ({
|
||||||
|
taskId: c.taskId,
|
||||||
|
taskTitle: manualAgentTasks.find((t) => t.id === c.taskId)?.title ?? c.taskId,
|
||||||
|
authorName: c.authorName,
|
||||||
|
body: c.body,
|
||||||
|
createdAt: c.createdAt,
|
||||||
|
}));
|
||||||
const systemPrompt = apiUrl ? buildTaskPrompt({
|
const systemPrompt = apiUrl ? buildTaskPrompt({
|
||||||
agent,
|
agent,
|
||||||
apiUrl,
|
apiUrl,
|
||||||
apiKey,
|
apiKey,
|
||||||
runId: run.id,
|
runId: run.id,
|
||||||
task,
|
task,
|
||||||
companyName: snapshot.companies.find((c) => c.id === task.companyId)?.name ?? "Company",
|
companyName: manualCompany?.name ?? "Company",
|
||||||
goals: snapshot.goals.filter((g) => g.companyId === task.companyId),
|
goals: snapshot.goals.filter((g) => g.companyId === task.companyId),
|
||||||
projects: snapshot.projects.filter((p) => p.companyId === task.companyId),
|
projects: snapshot.projects.filter((p) => p.companyId === task.companyId),
|
||||||
directReports: db.getDirectReports(agent.id, task.companyId),
|
directReports: db.getDirectReports(agent.id, task.companyId),
|
||||||
chainOfCommand: db.getChainOfCommand(agent.id),
|
chainOfCommand: db.getChainOfCommand(agent.id),
|
||||||
assignedTasks: db.listAgentTasks(agent.id, task.companyId, ["todo", "in_progress", "blocked"]),
|
assignedTasks: manualAgentTasks,
|
||||||
allAgents: snapshot.agents.filter((a) => a.companyId === task.companyId),
|
allAgents: snapshot.agents.filter((a) => a.companyId === task.companyId),
|
||||||
pendingApprovals: snapshot.approvals.filter(
|
pendingApprovals: snapshot.approvals.filter(
|
||||||
(a) => a.companyId === task.companyId &&
|
(a) => a.companyId === task.companyId &&
|
||||||
@@ -1488,6 +1605,30 @@ function registerHandlers() {
|
|||||||
),
|
),
|
||||||
tasksAwaitingReview: db.listReviewTasksForManager(agent.id, task.companyId),
|
tasksAwaitingReview: db.listReviewTasksForManager(agent.id, task.companyId),
|
||||||
wakeReason: "manual",
|
wakeReason: "manual",
|
||||||
|
allTasks: snapshot.tasks.filter((t) => t.companyId === task.companyId),
|
||||||
|
recentComments: manualRecentComments,
|
||||||
|
companyDescription: manualCompany?.description ?? "",
|
||||||
|
socialAccounts: db.listSocialAccounts(task.companyId).map(a => ({
|
||||||
|
id: a.id,
|
||||||
|
platform: a.platform,
|
||||||
|
accountName: a.accountName,
|
||||||
|
status: a.status,
|
||||||
|
requireApproval: a.requireApproval,
|
||||||
|
})),
|
||||||
|
recentMessages: db.listAgentMessages(task.companyId, { agentId: agent.id, limit: 15 }),
|
||||||
|
upcomingMeetings: snapshot.meetings
|
||||||
|
.filter((m: any) => m.companyId === task.companyId && (m.status === "scheduled" || m.status === "in_progress"))
|
||||||
|
.filter((m: any) => {
|
||||||
|
try { const ids = JSON.parse(m.participantAgentIds); return ids.includes(agent.id) || m.organizerAgentId === agent.id; } catch { return false; }
|
||||||
|
})
|
||||||
|
.slice(0, 5),
|
||||||
|
relevantKnowledge: snapshot.knowledgeBase
|
||||||
|
.filter((k: any) => k.companyId === task.companyId && (k.importance === "critical" || k.importance === "high"))
|
||||||
|
.slice(0, 8),
|
||||||
|
activeSprint: snapshot.sprints.find((s: any) => s.companyId === task.companyId && s.status === "active") ?? null,
|
||||||
|
recentDocuments: snapshot.documents
|
||||||
|
.filter((d: any) => d.companyId === task.companyId && (d.authorAgentId === agent.id || d.reviewerAgentId === agent.id || d.status === "in_review"))
|
||||||
|
.slice(0, 5),
|
||||||
}) : null;
|
}) : null;
|
||||||
|
|
||||||
// Add API credentials to env
|
// Add API credentials to env
|
||||||
@@ -1791,6 +1932,366 @@ function registerHandlers() {
|
|||||||
return ok([]);
|
return ok([]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Inbox ──────────────────────────────────────────────────────────
|
||||||
|
registerHandle(IPC_CHANNELS.getInbox, (_event, ...args) => {
|
||||||
|
const companyId = companyIdSchema.parse(args[0]);
|
||||||
|
const err = ensureCompanyExists(companyId);
|
||||||
|
if (err) return err;
|
||||||
|
return ok(db.getInbox(companyId));
|
||||||
|
});
|
||||||
|
|
||||||
|
registerHandle(IPC_CHANNELS.dismissInboxItem, (_event, ...args) => {
|
||||||
|
const input = inboxDismissSchema.parse(args[0]);
|
||||||
|
const err = ensureCompanyExists(input.companyId);
|
||||||
|
if (err) return err;
|
||||||
|
// Dismiss is a no-op for now (items are computed on the fly)
|
||||||
|
return ok(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Metrics ────────────────────────────────────────────────────────
|
||||||
|
registerHandle(IPC_CHANNELS.getCompanyMetrics, (_event, ...args) => {
|
||||||
|
const input = metricsQuerySchema.parse(args[0]);
|
||||||
|
const err = ensureCompanyExists(input.companyId);
|
||||||
|
if (err) return err;
|
||||||
|
return ok(db.getCompanyMetrics(input.companyId, input.days));
|
||||||
|
});
|
||||||
|
|
||||||
|
registerHandle(IPC_CHANNELS.getAgentMetrics, (_event, ...args) => {
|
||||||
|
const input = metricsQuerySchema.parse(args[0]);
|
||||||
|
const err = ensureCompanyExists(input.companyId);
|
||||||
|
if (err) return err;
|
||||||
|
if (!input.agentId) return fail("MISSING_AGENT_ID", "agentId is required");
|
||||||
|
const metrics = db.getCompanyMetrics(input.companyId, input.days);
|
||||||
|
const agentMetric = metrics.agentMetrics.find(m => m.agentId === input.agentId);
|
||||||
|
if (!agentMetric) return fail("AGENT_NOT_FOUND", "Agent metrics not found");
|
||||||
|
return ok(agentMetric);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Standup ────────────────────────────────────────────────────────
|
||||||
|
registerHandle(IPC_CHANNELS.getStandupReport, (_event, ...args) => {
|
||||||
|
const companyId = companyIdSchema.parse(args[0]);
|
||||||
|
const err = ensureCompanyExists(companyId);
|
||||||
|
if (err) return err;
|
||||||
|
return ok(db.getStandupReport(companyId));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Templates ──────────────────────────────────────────────────────
|
||||||
|
registerHandle(IPC_CHANNELS.listCompanyTemplates, () => {
|
||||||
|
return ok(COMPANY_TEMPLATES);
|
||||||
|
});
|
||||||
|
|
||||||
|
registerHandle(IPC_CHANNELS.applyCompanyTemplate, (_event, ...args) => {
|
||||||
|
const input = templateApplySchema.parse(args[0]);
|
||||||
|
const template = getTemplate(input.templateId);
|
||||||
|
if (!template) return fail("TEMPLATE_NOT_FOUND", "Company template not found");
|
||||||
|
|
||||||
|
// Create the company
|
||||||
|
const companyId = db.saveCompany({
|
||||||
|
name: input.companyName,
|
||||||
|
description: input.companyDescription || template.description,
|
||||||
|
status: "active",
|
||||||
|
autoApproveHires: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create agents in order (so we can resolve reportsTo by index)
|
||||||
|
const agentIds: string[] = [];
|
||||||
|
const connectors = db.listSnapshot().connectors;
|
||||||
|
const readyConnectors = connectors.filter(c => c.status === "ready");
|
||||||
|
const fallbackConnector = readyConnectors[0]?.id ?? input.connectorId ?? "claude_local";
|
||||||
|
|
||||||
|
for (const agentDef of template.agents) {
|
||||||
|
const connectorToUse = readyConnectors.find(c => c.id === agentDef.connectorPreference)
|
||||||
|
? agentDef.connectorPreference
|
||||||
|
: fallbackConnector;
|
||||||
|
|
||||||
|
const reportsTo = agentDef.reportsToIndex !== null ? agentIds[agentDef.reportsToIndex] ?? null : null;
|
||||||
|
|
||||||
|
const agentId = db.saveAgent({
|
||||||
|
companyId,
|
||||||
|
name: agentDef.name,
|
||||||
|
role: agentDef.role,
|
||||||
|
title: agentDef.title,
|
||||||
|
department: agentDef.department ?? null,
|
||||||
|
capabilities: agentDef.capabilities,
|
||||||
|
reportsTo,
|
||||||
|
connectorId: connectorToUse,
|
||||||
|
workspaceId: input.workspaceId ?? null,
|
||||||
|
model: null,
|
||||||
|
budgetMonthlyUsd: agentDef.budgetMonthlyUsd,
|
||||||
|
status: "active",
|
||||||
|
});
|
||||||
|
agentIds.push(agentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create goals owned by the CEO (first agent)
|
||||||
|
const ceoId = agentIds[0];
|
||||||
|
for (const goalDef of template.goals) {
|
||||||
|
db.saveGoal({
|
||||||
|
companyId,
|
||||||
|
title: goalDef.title,
|
||||||
|
description: goalDef.description,
|
||||||
|
ownerAgentId: ceoId ?? null,
|
||||||
|
parentId: null,
|
||||||
|
status: "active",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set current company
|
||||||
|
db.setCurrentCompany(companyId);
|
||||||
|
|
||||||
|
// Enable heartbeat for CEO to kick off autonomous operation
|
||||||
|
if (ceoId) {
|
||||||
|
db.setHeartbeat(ceoId, true, 120);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Activity log
|
||||||
|
db.addActivity({
|
||||||
|
companyId,
|
||||||
|
actor: "board",
|
||||||
|
action: "company.created_from_template",
|
||||||
|
entityType: "company",
|
||||||
|
entityId: companyId,
|
||||||
|
detail: `Created from template: ${template.name}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
emitEvent({ type: "domain-changed" });
|
||||||
|
|
||||||
|
return ok(companyId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Social Accounts ────────────────────────────────────────────────
|
||||||
|
registerHandle(IPC_CHANNELS.saveSocialAccount, (_event, ...args) => {
|
||||||
|
const input = socialAccountInputSchema.parse(args[0]);
|
||||||
|
const err = ensureCompanyExists(input.companyId);
|
||||||
|
if (err) return err;
|
||||||
|
const id = db.saveSocialAccount(input);
|
||||||
|
emitEvent({ type: "domain-changed" });
|
||||||
|
return ok(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
registerHandle(IPC_CHANNELS.deleteSocialAccount, (_event, ...args) => {
|
||||||
|
const input = deleteEntitySchema.parse(args[0]);
|
||||||
|
const err = ensureCompanyExists(input.companyId);
|
||||||
|
if (err) return err;
|
||||||
|
db.deleteSocialAccount(input.id, input.companyId);
|
||||||
|
emitEvent({ type: "domain-changed" });
|
||||||
|
return ok(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
registerHandle(IPC_CHANNELS.listBrowserActions, (_event, ...args) => {
|
||||||
|
const input = browserActionQuerySchema.parse(args[0]);
|
||||||
|
const err = ensureCompanyExists(input.companyId);
|
||||||
|
if (err) return err;
|
||||||
|
const actions = db.listBrowserActions(input.companyId, {
|
||||||
|
socialAccountId: input.socialAccountId,
|
||||||
|
status: input.status,
|
||||||
|
limit: input.limit,
|
||||||
|
});
|
||||||
|
return ok(actions);
|
||||||
|
});
|
||||||
|
|
||||||
|
registerHandle(IPC_CHANNELS.cancelBrowserAction, (_event, ...args) => {
|
||||||
|
const input = cancelRunSchema.parse(args[0]);
|
||||||
|
db.updateBrowserAction(input.runId, { status: "cancelled" });
|
||||||
|
emitEvent({ type: "domain-changed" });
|
||||||
|
return ok(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
registerHandle(IPC_CHANNELS.triggerBrowserLogin, async (_event, ...args) => {
|
||||||
|
const socialAccountId = z.string().uuid().parse(args[0]);
|
||||||
|
if (!browserManager) {
|
||||||
|
return fail("BROWSER_NOT_READY", "Browser manager not initialized");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the social account to get its platform
|
||||||
|
const snapshot = db.listSnapshot();
|
||||||
|
const account = snapshot.socialAccounts.find(a => a.id === socialAccountId);
|
||||||
|
if (!account) {
|
||||||
|
return fail("ACCOUNT_NOT_FOUND", "Social account not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Launch login browser (this runs async - the browser stays open for user to login)
|
||||||
|
// We run it in background and don't block the IPC response
|
||||||
|
browserManager.launchLoginBrowser(socialAccountId, account.platform).then(result => {
|
||||||
|
if (result.success) {
|
||||||
|
db.saveSocialAccount({
|
||||||
|
id: account.id,
|
||||||
|
companyId: account.companyId,
|
||||||
|
platform: account.platform,
|
||||||
|
accountName: account.accountName,
|
||||||
|
displayName: account.displayName,
|
||||||
|
profileUrl: account.profileUrl,
|
||||||
|
credentialSecretId: account.credentialSecretId,
|
||||||
|
status: "logged_in",
|
||||||
|
requireApproval: account.requireApproval,
|
||||||
|
});
|
||||||
|
emitEvent({ type: "domain-changed" });
|
||||||
|
}
|
||||||
|
}).catch(err => {
|
||||||
|
log.error(`[browser] Login failed: ${err}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
return ok(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Meetings ──────────────────────────────────────────────────────
|
||||||
|
registerHandle(IPC_CHANNELS.saveMeeting, async (_event, payload) => {
|
||||||
|
const parsed = meetingInputSchema.parse(payload);
|
||||||
|
const companyError = ensureCompanyExists(parsed.companyId);
|
||||||
|
if (companyError) return companyError;
|
||||||
|
const id = db.saveMeeting(parsed);
|
||||||
|
emitEvent({ type: "domain-changed" });
|
||||||
|
return ok(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
registerHandle(IPC_CHANNELS.deleteMeeting, async (_event, payload) => {
|
||||||
|
const parsed = deleteEntitySchema.parse(payload);
|
||||||
|
db.deleteMeeting(parsed.id, parsed.companyId);
|
||||||
|
emitEvent({ type: "domain-changed" });
|
||||||
|
return ok(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Documents ─────────────────────────────────────────────────────
|
||||||
|
registerHandle(IPC_CHANNELS.saveDocument, async (_event, payload) => {
|
||||||
|
const parsed = documentInputSchema.parse(payload);
|
||||||
|
const companyError = ensureCompanyExists(parsed.companyId);
|
||||||
|
if (companyError) return companyError;
|
||||||
|
const id = db.saveDocument(parsed);
|
||||||
|
emitEvent({ type: "domain-changed" });
|
||||||
|
return ok(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
registerHandle(IPC_CHANNELS.deleteDocument, async (_event, payload) => {
|
||||||
|
const parsed = deleteEntitySchema.parse(payload);
|
||||||
|
db.deleteDocument(parsed.id, parsed.companyId);
|
||||||
|
emitEvent({ type: "domain-changed" });
|
||||||
|
return ok(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Knowledge Base ────────────────────────────────────────────────
|
||||||
|
registerHandle(IPC_CHANNELS.saveKnowledgeEntry, async (_event, payload) => {
|
||||||
|
const parsed = knowledgeEntryInputSchema.parse(payload);
|
||||||
|
const companyError = ensureCompanyExists(parsed.companyId);
|
||||||
|
if (companyError) return companyError;
|
||||||
|
const id = db.saveKnowledgeEntry(parsed);
|
||||||
|
emitEvent({ type: "domain-changed" });
|
||||||
|
return ok(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
registerHandle(IPC_CHANNELS.deleteKnowledgeEntry, async (_event, payload) => {
|
||||||
|
const parsed = deleteEntitySchema.parse(payload);
|
||||||
|
db.deleteKnowledgeEntry(parsed.id, parsed.companyId);
|
||||||
|
emitEvent({ type: "domain-changed" });
|
||||||
|
return ok(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Sprints ───────────────────────────────────────────────────────
|
||||||
|
registerHandle(IPC_CHANNELS.saveSprint, async (_event, payload) => {
|
||||||
|
const parsed = sprintInputSchema.parse(payload);
|
||||||
|
const companyError = ensureCompanyExists(parsed.companyId);
|
||||||
|
if (companyError) return companyError;
|
||||||
|
const id = db.saveSprint(parsed);
|
||||||
|
emitEvent({ type: "domain-changed" });
|
||||||
|
return ok(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
registerHandle(IPC_CHANNELS.deleteSprint, async (_event, payload) => {
|
||||||
|
const parsed = deleteEntitySchema.parse(payload);
|
||||||
|
db.deleteSprint(parsed.id, parsed.companyId);
|
||||||
|
emitEvent({ type: "domain-changed" });
|
||||||
|
return ok(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Automation Rules ───────────────────────────────────────────────
|
||||||
|
registerHandle(IPC_CHANNELS.saveAutomationRule, async (_event, payload) => {
|
||||||
|
const parsed = automationRuleInputSchema.parse(payload);
|
||||||
|
const companyError = ensureCompanyExists(parsed.companyId);
|
||||||
|
if (companyError) return companyError;
|
||||||
|
const id = db.saveAutomationRule(parsed);
|
||||||
|
emitEvent({ type: "domain-changed" });
|
||||||
|
return ok(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
registerHandle(IPC_CHANNELS.deleteAutomationRule, async (_event, payload) => {
|
||||||
|
const parsed = deleteEntitySchema.parse(payload);
|
||||||
|
db.deleteAutomationRule(parsed.id, parsed.companyId);
|
||||||
|
emitEvent({ type: "domain-changed" });
|
||||||
|
return ok(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
registerHandle(IPC_CHANNELS.toggleAutomationRule, async (_event, payload) => {
|
||||||
|
const parsed = z.object({ id: z.string().uuid(), companyId: z.string().uuid(), status: z.enum(["active", "paused"]) }).parse(payload);
|
||||||
|
db.toggleAutomationRule(parsed.id, parsed.companyId, parsed.status);
|
||||||
|
emitEvent({ type: "domain-changed" });
|
||||||
|
return ok(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
registerHandle(IPC_CHANNELS.getAutomationLog, async (_event, companyId) => {
|
||||||
|
const parsed = companyIdSchema.parse(companyId);
|
||||||
|
return ok(db.getAutomationLog(parsed));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Agent Messages ─────────────────────────────────────────────────
|
||||||
|
registerHandle(IPC_CHANNELS.sendAgentMessage, async (_event, payload) => {
|
||||||
|
const parsed = agentMessageInputSchema.parse(payload);
|
||||||
|
const companyError = ensureCompanyExists(parsed.companyId);
|
||||||
|
if (companyError) return companyError;
|
||||||
|
const id = db.sendAgentMessage(parsed);
|
||||||
|
// Notify recipient via event
|
||||||
|
const msg = db.listAgentMessages(parsed.companyId, { limit: 1 })?.[0];
|
||||||
|
if (msg) emitEvent({ type: "new-message", message: msg });
|
||||||
|
emitEvent({ type: "domain-changed" });
|
||||||
|
return ok(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
registerHandle(IPC_CHANNELS.listAgentMessages, async (_event, payload) => {
|
||||||
|
const parsed = messageQuerySchema.parse(payload);
|
||||||
|
return ok(db.listAgentMessages(parsed.companyId, parsed));
|
||||||
|
});
|
||||||
|
|
||||||
|
registerHandle(IPC_CHANNELS.markMessageRead, async (_event, payload) => {
|
||||||
|
const parsed = z.object({ messageId: z.string().uuid(), companyId: z.string().uuid() }).parse(payload);
|
||||||
|
db.markMessageRead(parsed.messageId, parsed.companyId);
|
||||||
|
return ok(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Workflows ──────────────────────────────────────────────────────
|
||||||
|
registerHandle(IPC_CHANNELS.saveWorkflow, async (_event, payload) => {
|
||||||
|
const parsed = workflowInputSchema.parse(payload);
|
||||||
|
const companyError = ensureCompanyExists(parsed.companyId);
|
||||||
|
if (companyError) return companyError;
|
||||||
|
const id = db.saveWorkflow(parsed);
|
||||||
|
emitEvent({ type: "domain-changed" });
|
||||||
|
return ok(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
registerHandle(IPC_CHANNELS.deleteWorkflow, async (_event, payload) => {
|
||||||
|
const parsed = deleteEntitySchema.parse(payload);
|
||||||
|
db.deleteWorkflow(parsed.id, parsed.companyId);
|
||||||
|
emitEvent({ type: "domain-changed" });
|
||||||
|
return ok(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
registerHandle(IPC_CHANNELS.runWorkflow, async (_event, payload) => {
|
||||||
|
const parsed = z.object({ id: z.string().uuid(), companyId: z.string().uuid() }).parse(payload);
|
||||||
|
const companyError = ensureCompanyExists(parsed.companyId);
|
||||||
|
if (companyError) return companyError;
|
||||||
|
db.activateWorkflow(parsed.id, parsed.companyId);
|
||||||
|
emitEvent({ type: "domain-changed" });
|
||||||
|
return ok(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Auto-Assign ────────────────────────────────────────────────────
|
||||||
|
registerHandle(IPC_CHANNELS.autoAssignTask, async (_event, payload) => {
|
||||||
|
const parsed = autoAssignSchema.parse(payload);
|
||||||
|
const companyError = ensureCompanyExists(parsed.companyId);
|
||||||
|
if (companyError) return companyError;
|
||||||
|
const agentId = db.autoAssignTask(parsed.taskId, parsed.companyId);
|
||||||
|
if (!agentId) return fail("NO_AVAILABLE_AGENT", "No available agent could be found for this task.");
|
||||||
|
emitEvent({ type: "domain-changed" });
|
||||||
|
return ok(agentId);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function checkForUpdates() {
|
async function checkForUpdates() {
|
||||||
@@ -1869,6 +2370,43 @@ async function bootstrap() {
|
|||||||
});
|
});
|
||||||
await apiServer.start();
|
await apiServer.start();
|
||||||
|
|
||||||
|
// Initialize browser manager for social media automation
|
||||||
|
browserManager = new BrowserManager(join(app.getPath("userData"), "browser-data"));
|
||||||
|
|
||||||
|
// Wire browser action handler
|
||||||
|
if (apiServer && browserManager) {
|
||||||
|
const bm = browserManager;
|
||||||
|
apiServer.setBrowserActionHandler(async (actionId, socialAccountId, actionType, payload) => {
|
||||||
|
try {
|
||||||
|
db.updateBrowserAction(actionId, { status: "running", startedAt: new Date().toISOString() });
|
||||||
|
emitEvent({ type: "domain-changed" });
|
||||||
|
|
||||||
|
const result = await bm.executeAction({
|
||||||
|
id: actionId,
|
||||||
|
socialAccountId,
|
||||||
|
actionType,
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
|
||||||
|
db.updateBrowserAction(actionId, {
|
||||||
|
status: result.success ? "succeeded" : "failed",
|
||||||
|
resultSummary: result.summary,
|
||||||
|
screenshotPath: result.screenshotPath,
|
||||||
|
errorMessage: result.error,
|
||||||
|
finishedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
emitEvent({ type: "domain-changed" });
|
||||||
|
} catch (error) {
|
||||||
|
db.updateBrowserAction(actionId, {
|
||||||
|
status: "failed",
|
||||||
|
errorMessage: error instanceof Error ? error.message : String(error),
|
||||||
|
finishedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
emitEvent({ type: "domain-changed" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Start heartbeat scheduler for continuous agent operation
|
// Start heartbeat scheduler for continuous agent operation
|
||||||
startHeartbeatScheduler();
|
startHeartbeatScheduler();
|
||||||
|
|
||||||
@@ -1923,6 +2461,8 @@ app.on("before-quit", () => {
|
|||||||
heartbeatTimer = null;
|
heartbeatTimer = null;
|
||||||
apiServer?.stop();
|
apiServer?.stop();
|
||||||
apiServer = null;
|
apiServer = null;
|
||||||
|
browserManager?.shutdown();
|
||||||
|
browserManager = null;
|
||||||
worker?.kill();
|
worker?.kill();
|
||||||
worker = null;
|
worker = null;
|
||||||
db?.close();
|
db?.close();
|
||||||
|
|||||||
+712
-8
@@ -1,4 +1,4 @@
|
|||||||
import type { AgentRecord, ApprovalRecord, GoalRecord, ProjectRecord, TaskRecord } from "@shared/types";
|
import type { AgentRecord, AgentMessageRecord, ApprovalRecord, DepartmentId, DocumentRecord, GoalRecord, KnowledgeEntryRecord, MeetingRecord, ProjectRecord, SprintRecord, TaskRecord } from "@shared/types";
|
||||||
|
|
||||||
interface PromptContext {
|
interface PromptContext {
|
||||||
agent: AgentRecord;
|
agent: AgentRecord;
|
||||||
@@ -17,17 +17,121 @@ interface PromptContext {
|
|||||||
/** Tasks from direct reports that are awaiting this agent's review */
|
/** Tasks from direct reports that are awaiting this agent's review */
|
||||||
tasksAwaitingReview: TaskRecord[];
|
tasksAwaitingReview: TaskRecord[];
|
||||||
wakeReason: string | null;
|
wakeReason: string | null;
|
||||||
|
/** All company tasks (for cross-team visibility) */
|
||||||
|
allTasks: TaskRecord[];
|
||||||
|
/** Recent comments on the agent's tasks */
|
||||||
|
recentComments: Array<{ taskId: string; taskTitle: string; authorName: string; body: string; createdAt: string }>;
|
||||||
|
/** Company name */
|
||||||
|
companyDescription: string;
|
||||||
|
/** Social media accounts available to the company */
|
||||||
|
socialAccounts: Array<{ id: string; platform: string; accountName: string; status: string; requireApproval: boolean }>;
|
||||||
|
/** Recent messages to/from this agent */
|
||||||
|
recentMessages?: AgentMessageRecord[];
|
||||||
|
/** Upcoming meetings involving this agent */
|
||||||
|
upcomingMeetings?: MeetingRecord[];
|
||||||
|
/** Relevant knowledge base entries */
|
||||||
|
relevantKnowledge?: KnowledgeEntryRecord[];
|
||||||
|
/** Active sprint */
|
||||||
|
activeSprint?: SprintRecord | null;
|
||||||
|
/** Recent documents authored by or relevant to this agent */
|
||||||
|
recentDocuments?: DocumentRecord[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEPARTMENT_LABELS: Record<string, string> = {
|
||||||
|
executive: "Executive Suite",
|
||||||
|
engineering: "Engineering",
|
||||||
|
product: "Product & Design",
|
||||||
|
marketing: "Marketing",
|
||||||
|
sales: "Sales & BD",
|
||||||
|
hr: "People & Culture",
|
||||||
|
finance: "Finance & Legal",
|
||||||
|
legal: "Legal",
|
||||||
|
operations: "Operations",
|
||||||
|
customer_support: "Customer Success",
|
||||||
|
research: "Research",
|
||||||
|
design: "Design",
|
||||||
|
};
|
||||||
|
|
||||||
export function buildSystemPrompt(ctx: PromptContext): string {
|
export function buildSystemPrompt(ctx: PromptContext): string {
|
||||||
const sections: string[] = [];
|
const sections: string[] = [];
|
||||||
|
|
||||||
|
// Compute role flags early so they can be used throughout
|
||||||
|
const isCEO = ctx.chainOfCommand.length === 0;
|
||||||
|
const isExecutive = isCEO;
|
||||||
|
const isManager = !isCEO && ctx.directReports.length > 0;
|
||||||
|
const isIC = !isCEO && ctx.directReports.length === 0;
|
||||||
|
|
||||||
// Identity
|
// Identity
|
||||||
sections.push(`# Agent Identity
|
sections.push(`# Agent Identity
|
||||||
You are ${ctx.agent.name}, a ${ctx.agent.role}${ctx.agent.title ? ` (${ctx.agent.title})` : ""} at ${ctx.companyName}.
|
You are ${ctx.agent.name}, a ${ctx.agent.role}${ctx.agent.title ? ` (${ctx.agent.title})` : ""} at ${ctx.companyName}.
|
||||||
${ctx.agent.capabilities ? `Your capabilities: ${ctx.agent.capabilities}` : ""}
|
${ctx.agent.capabilities ? `Your capabilities: ${ctx.agent.capabilities}` : ""}
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// Company context
|
||||||
|
if (ctx.companyDescription) {
|
||||||
|
sections.push(`# Company Mission
|
||||||
|
${ctx.companyDescription}
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// NEW: Cognitive Framework (after Identity, before Chain of Command)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
sections.push(`# Cognitive Framework (How You Think)
|
||||||
|
You operate using a structured decision-making process:
|
||||||
|
|
||||||
|
## OBSERVE: Gather Information
|
||||||
|
- Read your standup briefing first (GET /api/agents/me/standup)
|
||||||
|
- Check all recent comments on your tasks
|
||||||
|
- Review your team's workload if you're a manager
|
||||||
|
- Note any changes since your last wake
|
||||||
|
|
||||||
|
## ORIENT: Analyze & Prioritize
|
||||||
|
- What is the most critical issue right now? (blockers > reviews > new work)
|
||||||
|
- What is the company-wide priority? (align with active goals)
|
||||||
|
- What are my teammates depending on me for?
|
||||||
|
- What has changed since my last session?
|
||||||
|
|
||||||
|
## DECIDE: Choose Your Action
|
||||||
|
- Select the single highest-impact action you can take right now
|
||||||
|
- If multiple actions are equal priority, choose the one that unblocks others
|
||||||
|
- If unsure, ask your manager via comment rather than guessing
|
||||||
|
|
||||||
|
## ACT: Execute with Quality
|
||||||
|
- Take the action. Document what you did and why.
|
||||||
|
- Leave a trail: comments, status updates, documents
|
||||||
|
- Verify your work before submitting for review
|
||||||
|
`);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// NEW: Agent Personality & Work Style (after Cognitive Framework)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
if (isExecutive) {
|
||||||
|
sections.push(`# Your Leadership Style
|
||||||
|
- You think strategically, not tactically. Your job is vision and coordination.
|
||||||
|
- You communicate through clear, decisive directives.
|
||||||
|
- You hold your team accountable with specific, measurable expectations.
|
||||||
|
- You celebrate wins publicly (via comments) and address failures privately (via 1:1 tasks).
|
||||||
|
- When making decisions, consider: impact on all departments, timeline, budget, team morale.
|
||||||
|
`);
|
||||||
|
} else if (isManager) {
|
||||||
|
sections.push(`# Your Management Style
|
||||||
|
- You are a force multiplier. Your success is measured by your team's output, not your own.
|
||||||
|
- Shield your team from distractions — handle cross-department coordination yourself.
|
||||||
|
- Give specific, actionable feedback — not vague "looks good" or "needs work."
|
||||||
|
- Track your team's velocity and identify bottlenecks proactively.
|
||||||
|
- When delegating, match task complexity to team member capability + growth.
|
||||||
|
`);
|
||||||
|
} else {
|
||||||
|
sections.push(`# Your Work Philosophy
|
||||||
|
- You are a craftsperson. Take pride in quality work that ships.
|
||||||
|
- Ask questions early — a 5-minute clarification saves hours of wrong work.
|
||||||
|
- Leave your code/work better than you found it (but don't gold-plate).
|
||||||
|
- When stuck for more than 10 minutes, escalate. Don't spin your wheels.
|
||||||
|
- Document decisions and tradeoffs as you go, not after.
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
// Organization context
|
// Organization context
|
||||||
if (ctx.chainOfCommand.length > 0) {
|
if (ctx.chainOfCommand.length > 0) {
|
||||||
sections.push(`# Chain of Command
|
sections.push(`# Chain of Command
|
||||||
@@ -45,6 +149,55 @@ ${ctx.directReports.map((a) => `- ${a.name}: ${a.role}${a.title ? ` (${a.title})
|
|||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Team workload overview (for managers)
|
||||||
|
if (ctx.directReports.length > 0) {
|
||||||
|
const reportTaskSummaries = ctx.directReports.map((report) => {
|
||||||
|
const reportTasks = ctx.allTasks.filter((t) => t.assigneeAgentId === report.id);
|
||||||
|
const inProgress = reportTasks.filter((t) => t.status === "in_progress").length;
|
||||||
|
const inReview = reportTasks.filter((t) => t.status === "in_review").length;
|
||||||
|
const blocked = reportTasks.filter((t) => t.status === "blocked").length;
|
||||||
|
const todo = reportTasks.filter((t) => t.status === "todo").length;
|
||||||
|
return ` - ${report.name} (${report.role}): ${inProgress} in progress, ${inReview} in review, ${blocked} blocked, ${todo} queued`;
|
||||||
|
});
|
||||||
|
|
||||||
|
sections.push(`# Team Workload
|
||||||
|
${reportTaskSummaries.join("\n")}
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Department context
|
||||||
|
if (ctx.agent.department) {
|
||||||
|
const deptLabel = DEPARTMENT_LABELS[ctx.agent.department] ?? ctx.agent.department;
|
||||||
|
const deptMembers = ctx.allAgents.filter(
|
||||||
|
(a) => a.department === ctx.agent.department && a.id !== ctx.agent.id && a.status !== "terminated",
|
||||||
|
);
|
||||||
|
sections.push(`# Department: ${deptLabel}
|
||||||
|
You belong to the ${deptLabel} department.${deptMembers.length > 0 ? `
|
||||||
|
Your department colleagues:
|
||||||
|
${deptMembers.map((a) => `- ${a.name} (${a.role}) [${a.status}]`).join("\n")}` : ""}
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cross-department map (for executives and managers)
|
||||||
|
const isCEOCheck = ctx.chainOfCommand.length === 0;
|
||||||
|
if (isCEOCheck || ctx.directReports.length > 0) {
|
||||||
|
const deptMap = new Map<string, AgentRecord[]>();
|
||||||
|
for (const a of ctx.allAgents.filter((a) => a.status !== "terminated")) {
|
||||||
|
const dept = a.department ?? "unassigned";
|
||||||
|
if (!deptMap.has(dept)) deptMap.set(dept, []);
|
||||||
|
deptMap.get(dept)!.push(a);
|
||||||
|
}
|
||||||
|
if (deptMap.size > 1) {
|
||||||
|
const deptLines = Array.from(deptMap.entries()).map(([dept, members]) => {
|
||||||
|
const label = DEPARTMENT_LABELS[dept] ?? dept;
|
||||||
|
return ` ${label} (${members.length}): ${members.map((m) => m.name).join(", ")}`;
|
||||||
|
});
|
||||||
|
sections.push(`# Company Department Map
|
||||||
|
${deptLines.join("\n")}
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Active goals
|
// Active goals
|
||||||
const activeGoals = ctx.goals.filter((g) => g.status === "active" || g.status === "planned");
|
const activeGoals = ctx.goals.filter((g) => g.status === "active" || g.status === "planned");
|
||||||
if (activeGoals.length > 0) {
|
if (activeGoals.length > 0) {
|
||||||
@@ -166,8 +319,109 @@ When the team needs new members:
|
|||||||
- When you need input from another agent, comment on their task or create a new task for them
|
- When you need input from another agent, comment on their task or create a new task for them
|
||||||
- Always @mention the agent name in comments for clarity
|
- Always @mention the agent name in comments for clarity
|
||||||
- Be specific: include what you need, why, and any deadline
|
- Be specific: include what you need, why, and any deadline
|
||||||
|
|
||||||
|
## SOP-008: Cross-Department Collaboration
|
||||||
|
When your work depends on or affects another department:
|
||||||
|
1. Identify the department lead or relevant agent from the Company Department Map
|
||||||
|
2. Create a task assigned to them with clear requirements and deadline
|
||||||
|
3. Set the task priority based on your blocking urgency
|
||||||
|
4. Post a comment explaining the cross-department dependency
|
||||||
|
5. Common cross-department workflows:
|
||||||
|
- Engineering ↔ Product: Product writes PRD → Engineering implements → QA tests → Product validates
|
||||||
|
- Marketing ↔ Engineering: Marketing needs landing page → Engineering builds → Marketing reviews
|
||||||
|
- Sales ↔ Product: Sales collects customer feedback → Product prioritizes → Engineering delivers
|
||||||
|
- HR ↔ All Departments: HR facilitates hiring → Department head defines requirements → HR screens
|
||||||
|
- Finance ↔ All Departments: Department submits budget_request → CFO reviews → Finance tracks spend
|
||||||
|
- Customer Support ↔ Engineering: Support reports bug → Engineering fixes → Support verifies with customer
|
||||||
|
|
||||||
|
### Standard Cross-Department Workflows:
|
||||||
|
|
||||||
|
**Product Development Cycle:**
|
||||||
|
Product → writes PRD → Design → creates mockups → Engineering → implements → QA → tests → Product → validates → Marketing → announces → Sales → sells → Support → supports
|
||||||
|
|
||||||
|
**Incident Response:**
|
||||||
|
Support → reports issue → Engineering → investigates → DevOps → mitigates → Engineering → root cause → Product → decides fix priority → Engineering → implements fix → QA → regression test → Support → verifies with customer → Knowledge → post-mortem
|
||||||
|
|
||||||
|
**New Feature Request (from customer):**
|
||||||
|
Support → captures requirement → Product → evaluates & prioritizes → Design → UX research → Product → writes PRD → Engineering → estimates → Finance → budgets → CEO → approves → Engineering → builds
|
||||||
|
|
||||||
|
**Hiring Pipeline:**
|
||||||
|
Department Head → identifies gap → HR → writes job description → Finance → approves budget → CEO → final approval → HR → onboards → Manager → assigns buddy & first tasks
|
||||||
|
|
||||||
|
**Quarterly Business Review:**
|
||||||
|
Finance → generates P&L → Operations → compiles OKR status → Each department head → submits report → CEO → presents to Board
|
||||||
|
|
||||||
|
## SOP-009: Document Management
|
||||||
|
Important work should produce artifacts:
|
||||||
|
- **PRDs**: Product Managers write PRDs before engineering work begins
|
||||||
|
- **Technical Specs**: Engineers write specs before complex implementations
|
||||||
|
- **Post-Mortems**: After incidents, the lead writes a post-mortem with lessons learned
|
||||||
|
- **Meeting Notes**: After important meetings, capture decisions and action items
|
||||||
|
- **Status Reports**: Weekly status reports from department heads to CEO
|
||||||
|
Use task comments to share document content. Reference document IDs in related tasks.
|
||||||
|
|
||||||
|
## SOP-010: Budget & Financial Protocol
|
||||||
|
- All agents have a monthly budget. Track your spend.
|
||||||
|
- Before starting expensive operations, check your remaining budget via GET /api/agents/me
|
||||||
|
- If you need more budget, escalate to your manager who escalates to CFO
|
||||||
|
- For vendor purchases or new tools, submit a budget_request approval
|
||||||
|
- The CFO reviews all budget requests above the department head's authority
|
||||||
|
- Monthly budget resets on the 1st — plan your most expensive work early in the month
|
||||||
|
|
||||||
|
## SOP-011: Performance & Quality Standards
|
||||||
|
- Every agent's work is tracked: tasks completed, success rate, cost efficiency, review cycles
|
||||||
|
- Quality > Speed: Better to submit correct work slowly than buggy work quickly
|
||||||
|
- If a task is sent back for revision more than twice, escalate to your manager
|
||||||
|
- Managers should conduct monthly performance check-ins via task comments
|
||||||
|
- Identify agents who are consistently blocked or failing — propose retraining or role changes
|
||||||
|
|
||||||
|
## SOP-012: Sprint & Cadence Management
|
||||||
|
The company operates in recurring cycles:
|
||||||
|
- **Daily Standup**: Every heartbeat wake starts with standup check (SOP-001)
|
||||||
|
- **Sprint Cycle**: 2-week sprints with planning → execution → review → retrospective
|
||||||
|
- **Weekly Sync**: Department heads report progress to CEO
|
||||||
|
- **Monthly Review**: Company-wide metrics review, budget check, hiring needs
|
||||||
|
When creating tasks, consider the current sprint cycle and set realistic deadlines.
|
||||||
|
|
||||||
|
## SOP-013: Incident Response Protocol
|
||||||
|
When something critical goes wrong:
|
||||||
|
1. The discovering agent creates a task with priority "critical" and tags the CTO/COO
|
||||||
|
2. CTO assesses technical impact, COO assesses business impact
|
||||||
|
3. Engineering focuses on mitigation first, root cause second
|
||||||
|
4. After resolution, the lead writes a post-mortem document
|
||||||
|
5. Action items from the post-mortem become tracked tasks
|
||||||
|
6. Knowledge learned gets added to the team's institutional memory
|
||||||
|
|
||||||
|
## SOP-014: Hiring Pipeline
|
||||||
|
When the company needs new talent:
|
||||||
|
1. Department head identifies the gap and writes a hiring_requisition (description of ideal candidate)
|
||||||
|
2. HR Director reviews and approves the requisition
|
||||||
|
3. CFO confirms budget availability
|
||||||
|
4. CEO gives final approval via hire_agent approval
|
||||||
|
5. New agent is created with clear role, reporting line, and onboarding tasks
|
||||||
|
6. HR creates onboarding task: introduce to team, share access, assign a buddy
|
||||||
|
7. Manager assigns first tasks appropriate to the new hire's level
|
||||||
|
|
||||||
|
## SOP-015: Knowledge Management
|
||||||
|
The company should get smarter over time:
|
||||||
|
- After solving a hard problem, document the solution as a lesson_learned comment
|
||||||
|
- After making an important decision, document the rationale and alternatives considered
|
||||||
|
- When onboarding new agents, share relevant past decisions and lessons
|
||||||
|
- Periodically review past incidents and ensure fixes are still in place
|
||||||
|
- Capture "best practices" that emerge from repeated successes
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// Recent comments (last messages on agent's tasks)
|
||||||
|
if (ctx.recentComments.length > 0) {
|
||||||
|
const commentLines = ctx.recentComments.slice(0, 10).map((c) => {
|
||||||
|
return `- [${c.taskTitle}] ${c.authorName}: "${c.body.slice(0, 150)}${c.body.length > 150 ? "..." : ""}"`;
|
||||||
|
});
|
||||||
|
sections.push(`# Recent Messages
|
||||||
|
These are the most recent comments on your tasks. Review them before taking action.
|
||||||
|
${commentLines.join("\n")}
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
// API instructions
|
// API instructions
|
||||||
sections.push(`# AgentCompany API
|
sections.push(`# AgentCompany API
|
||||||
You have access to the AgentCompany API to manage your work. Base URL: ${ctx.apiUrl}
|
You have access to the AgentCompany API to manage your work. Base URL: ${ctx.apiUrl}
|
||||||
@@ -280,12 +534,157 @@ GET /api/companies/{companyId}/goals
|
|||||||
|
|
||||||
### List Company Projects
|
### List Company Projects
|
||||||
GET /api/companies/{companyId}/projects
|
GET /api/companies/{companyId}/projects
|
||||||
|
|
||||||
|
### Create a Document (PRD, spec, post-mortem, etc.)
|
||||||
|
POST /api/companies/{companyId}/documents
|
||||||
|
Body: {
|
||||||
|
"type": "prd|technical_spec|design_doc|test_plan|post_mortem|meeting_notes|budget_proposal|hiring_requisition|status_report|incident_report|architecture_decision|knowledge_article",
|
||||||
|
"title": "Document title",
|
||||||
|
"content": "Full document content in markdown",
|
||||||
|
"reviewerAgentId": "{optionalReviewerAgentId}",
|
||||||
|
"projectId": "{optionalProjectId}",
|
||||||
|
"goalId": "{optionalGoalId}",
|
||||||
|
"status": "draft"
|
||||||
|
}
|
||||||
|
|
||||||
|
### List Company Documents
|
||||||
|
GET /api/companies/{companyId}/documents
|
||||||
|
|
||||||
|
### Schedule a Meeting
|
||||||
|
POST /api/companies/{companyId}/meetings
|
||||||
|
Body: {
|
||||||
|
"type": "standup|sprint_planning|sprint_review|retrospective|one_on_one|all_hands|department_sync|incident_review|hiring_committee|budget_review",
|
||||||
|
"title": "Meeting title",
|
||||||
|
"participantAgentIds": "[\"agentId1\", \"agentId2\"]",
|
||||||
|
"scheduledAt": "2026-03-20T10:00:00Z",
|
||||||
|
"durationMinutes": 30,
|
||||||
|
"agendaJson": "[\"Item 1\", \"Item 2\"]"
|
||||||
|
}
|
||||||
|
|
||||||
|
### Update Meeting (add notes, decisions)
|
||||||
|
PATCH /api/meetings/{meetingId}
|
||||||
|
Body: {
|
||||||
|
"notesJson": "[\"Note 1\", \"Note 2\"]",
|
||||||
|
"decisionsJson": "[\"Decision 1\"]",
|
||||||
|
"actionItemsJson": "[\"Action 1\"]",
|
||||||
|
"status": "completed"
|
||||||
|
}
|
||||||
|
|
||||||
|
### Add Knowledge Entry (lessons learned, best practices)
|
||||||
|
POST /api/companies/{companyId}/knowledge
|
||||||
|
Body: {
|
||||||
|
"category": "lesson_learned|best_practice|decision|process|technical|business|onboarding|incident",
|
||||||
|
"topic": "What we learned",
|
||||||
|
"content": "Detailed description of the knowledge",
|
||||||
|
"importance": "high",
|
||||||
|
"referencedEntityType": "task|project|goal",
|
||||||
|
"referencedEntityId": "{optionalEntityId}"
|
||||||
|
}
|
||||||
|
|
||||||
|
### List Knowledge Base
|
||||||
|
GET /api/companies/{companyId}/knowledge
|
||||||
|
|
||||||
|
### Send a Message to Another Agent
|
||||||
|
POST /api/companies/{companyId}/messages
|
||||||
|
Body: {
|
||||||
|
"toAgentId": "{targetAgentId}",
|
||||||
|
"channel": "direct|department|company|project|incident",
|
||||||
|
"channelTargetId": "{departmentId or null for direct}",
|
||||||
|
"subject": "Message subject",
|
||||||
|
"body": "Your message content",
|
||||||
|
"priority": "urgent|normal|low",
|
||||||
|
"parentMessageId": "{optionalReplyToMessageId}"
|
||||||
|
}
|
||||||
|
Direct messages wake the recipient. Department messages wake all dept members.
|
||||||
|
|
||||||
|
### List Your Messages
|
||||||
|
GET /api/companies/{companyId}/messages?unreadOnly=true&limit=20
|
||||||
|
|
||||||
|
### Mark Message as Read
|
||||||
|
POST /api/companies/{companyId}/messages/{messageId}/read
|
||||||
|
|
||||||
|
### Get Agent Details
|
||||||
|
GET /api/agents/{agentId}
|
||||||
|
Returns agent profile, manager info, and direct reports.
|
||||||
|
|
||||||
|
### Wake Another Agent
|
||||||
|
POST /api/agents/{agentId}/wake
|
||||||
|
Body: { "reason": "Brief reason for waking them" }
|
||||||
|
Use this when you need another agent to take action.
|
||||||
|
|
||||||
|
### Get Your Performance Metrics
|
||||||
|
GET /api/agents/me/metrics
|
||||||
|
|
||||||
|
### List Sprints
|
||||||
|
GET /api/companies/{companyId}/sprints
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Step 1: CEO role detection fix — use chainOfCommand, not directReports
|
// Social media capabilities
|
||||||
const isCEO = ctx.chainOfCommand.length === 0;
|
if (ctx.socialAccounts.length > 0) {
|
||||||
const isExecutive = isCEO;
|
const accountLines = ctx.socialAccounts.map(a =>
|
||||||
const isManager = !isCEO && ctx.directReports.length > 0;
|
`- ${a.platform}/@${a.accountName} (status: ${a.status}${a.requireApproval ? ", requires approval" : ""}) — ID: ${a.id}`
|
||||||
|
);
|
||||||
|
sections.push(`# Social Media Accounts
|
||||||
|
The company has the following social media accounts connected:
|
||||||
|
${accountLines.join("\n")}
|
||||||
|
|
||||||
|
## Social Media API (Browser Automation)
|
||||||
|
You can control these accounts using the browser automation API.
|
||||||
|
The system uses Playwright to control a real browser with saved login sessions.
|
||||||
|
|
||||||
|
### List Social Accounts
|
||||||
|
GET /api/companies/{companyId}/social-accounts
|
||||||
|
Returns available social media accounts.
|
||||||
|
|
||||||
|
### Submit Browser Action
|
||||||
|
POST /api/companies/{companyId}/browser/action
|
||||||
|
Body: {
|
||||||
|
"socialAccountId": "{accountId}",
|
||||||
|
"actionType": "post|reply|like|follow|browse_feed|screenshot|navigate|search|custom_script",
|
||||||
|
"payload": {
|
||||||
|
"content": "Your post content here",
|
||||||
|
"platform": "twitter",
|
||||||
|
"targetUrl": "https://twitter.com/user/status/123",
|
||||||
|
"query": "search query",
|
||||||
|
"url": "https://example.com",
|
||||||
|
"maxPosts": 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
### Action Types:
|
||||||
|
- **post**: Publish content to the account's platform. Payload: { content, platform }
|
||||||
|
- **reply**: Reply to a specific post. Payload: { content, targetUrl }
|
||||||
|
- **like**: Like a post. Payload: { targetUrl }
|
||||||
|
- **follow**: Follow a user. Payload: { targetUrl }
|
||||||
|
- **browse_feed**: Browse the account's feed. Payload: { platform, maxPosts }
|
||||||
|
- **search**: Search for content. Payload: { query, platform }
|
||||||
|
- **screenshot**: Capture a screenshot. Payload: { url }
|
||||||
|
- **navigate**: Navigate to a URL. Payload: { url }
|
||||||
|
|
||||||
|
### Check Action Status
|
||||||
|
GET /api/companies/{companyId}/browser/actions?socialAccountId={id}&status=succeeded,failed
|
||||||
|
|
||||||
|
### Important Notes:
|
||||||
|
- Posts on accounts with "requires approval" will be held for Board review before publishing
|
||||||
|
- Always browse the feed first to understand the current conversation before posting
|
||||||
|
- Keep posts professional and on-brand
|
||||||
|
- Check action results for success/failure before proceeding
|
||||||
|
- Rate limit yourself: don't submit more than 5 actions per session
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full company roster (for CEO)
|
||||||
|
if (isCEO && ctx.allAgents.length > 1) {
|
||||||
|
const roster = ctx.allAgents
|
||||||
|
.filter((a) => a.id !== ctx.agent.id && a.status !== "terminated")
|
||||||
|
.map((a) => {
|
||||||
|
const manager = ctx.allAgents.find((m) => m.id === a.reportsTo);
|
||||||
|
return ` - ${a.name} (${a.role}/${a.title}) — reports to ${manager?.name ?? "you"} — status: ${a.status}`;
|
||||||
|
});
|
||||||
|
sections.push(`# Full Company Roster
|
||||||
|
${roster.join("\n")}
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
if (isExecutive) {
|
if (isExecutive) {
|
||||||
// Step 2c: CEO phased prompts
|
// Step 2c: CEO phased prompts
|
||||||
@@ -312,12 +711,19 @@ You are the new CEO. Your company has no active team members yet. Your job is to
|
|||||||
- Identify what departments are needed to achieve these goals.
|
- Identify what departments are needed to achieve these goals.
|
||||||
|
|
||||||
## Step 2: Design the Organization Structure
|
## Step 2: Design the Organization Structure
|
||||||
Based on goals, design a complete org chart. A mature company needs:
|
Based on goals, design a complete org chart. A mature company typically has these departments:
|
||||||
- **CTO / VP Engineering** — owns all technical execution (if goals involve code/product)
|
- **CTO / VP Engineering** — owns all technical execution (if goals involve code/product)
|
||||||
|
- **COO / VP Operations** — owns processes, coordination, company cadence
|
||||||
|
- **CFO / Finance** — owns budget, spend tracking, financial health
|
||||||
- **CMO / VP Marketing** — owns growth, content, brand (if goals involve marketing)
|
- **CMO / VP Marketing** — owns growth, content, brand (if goals involve marketing)
|
||||||
- **CPO / VP Product** — owns product design, UX, features (if goals involve product development)
|
- **CPO / VP Product** — owns product design, UX, features (if goals involve product development)
|
||||||
- **COO / VP Operations** — owns processes, infrastructure (if goals involve operations)
|
- **HR Director** — owns hiring pipeline, onboarding, performance management
|
||||||
Think about what roles are MOST needed to achieve the stated goals. Start lean (3-5 direct reports max).
|
- **Sales Director** — owns revenue, partnerships, customer acquisition (if applicable)
|
||||||
|
- **Customer Support Lead** — owns customer satisfaction, issue triage, knowledge base
|
||||||
|
|
||||||
|
Think about what departments and roles are MOST needed to achieve the stated goals.
|
||||||
|
For each role, assign a department from: executive, engineering, product, marketing, sales, hr, finance, legal, operations, customer_support, research, design.
|
||||||
|
Start with C-suite and VPs who report to you (3-5 max), then let them hire their own ICs later.
|
||||||
|
|
||||||
## Step 3: Submit Hire Requests
|
## Step 3: Submit Hire Requests
|
||||||
For EACH role, use POST /api/companies/{companyId}/agents with:
|
For EACH role, use POST /api/companies/{companyId}/agents with:
|
||||||
@@ -405,6 +811,18 @@ After each run, leave a concise status update on your primary task covering:
|
|||||||
CRITICAL: You are the CEO. DELEGATE work — do NOT try to code, write content, or design yourself. Your job is to ORCHESTRATE.
|
CRITICAL: You are the CEO. DELEGATE work — do NOT try to code, write content, or design yourself. Your job is to ORCHESTRATE.
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// NEW: Proactive Behaviors for Executives (always included for executives)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
sections.push(`## Proactive Behaviors (Things to Do Without Being Asked)
|
||||||
|
- If a department has no active tasks, create strategic tasks for them
|
||||||
|
- If budget utilization is below 50%, identify opportunities to accelerate
|
||||||
|
- If two teams are working on overlapping problems, coordinate
|
||||||
|
- If a goal has been "active" for 2+ weeks with no progress, investigate
|
||||||
|
- Review and approve pending items within 1 heartbeat of their creation
|
||||||
|
`);
|
||||||
|
|
||||||
} else if (isManager) {
|
} else if (isManager) {
|
||||||
// Step 2d: Manager with credential escalation instruction
|
// Step 2d: Manager with credential escalation instruction
|
||||||
sections.push(`# Operating Instructions (Manager)
|
sections.push(`# Operating Instructions (Manager)
|
||||||
@@ -448,6 +866,18 @@ After each run, update your task status and leave a summary for your manager:
|
|||||||
|
|
||||||
CRITICAL: You are a manager. Break down work and delegate it. Do NOT try to do IC work yourself unless you have no reports.
|
CRITICAL: You are a manager. Break down work and delegate it. Do NOT try to do IC work yourself unless you have no reports.
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// NEW: Proactive Behaviors for Managers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
sections.push(`## Proactive Behaviors
|
||||||
|
- If a report hasn't had work for 2+ days, assign them something
|
||||||
|
- If review queue has items older than 24h, prioritize reviews
|
||||||
|
- If team velocity is declining, diagnose and address
|
||||||
|
- Create 1:1 meeting tasks with each report weekly
|
||||||
|
- Identify skill gaps and propose training or hiring
|
||||||
|
`);
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
// Step 2d: IC with credential escalation instruction
|
// Step 2d: IC with credential escalation instruction
|
||||||
sections.push(`# Operating Instructions (Individual Contributor)
|
sections.push(`# Operating Instructions (Individual Contributor)
|
||||||
@@ -489,10 +919,270 @@ When your task is set back to "in_progress" with feedback:
|
|||||||
2. Address each point specifically
|
2. Address each point specifically
|
||||||
3. Re-submit to "in_review" with a comment explaining the changes
|
3. Re-submit to "in_review" with a comment explaining the changes
|
||||||
|
|
||||||
|
## Proactive Communication
|
||||||
|
- When completing a significant milestone, post a progress comment even if not required
|
||||||
|
- If you discover something unexpected, comment immediately — don't wait
|
||||||
|
- When creating subtasks for yourself, comment on the parent explaining your breakdown
|
||||||
|
- If you notice a task could benefit from another agent's input, @mention them in a comment
|
||||||
|
|
||||||
## Quality Standards
|
## Quality Standards
|
||||||
- Always test your work before submitting for review
|
- Always test your work before submitting for review
|
||||||
- Leave clear comments so your manager can understand what was done
|
- Leave clear comments so your manager can understand what was done
|
||||||
- If requirements are ambiguous, ask first rather than guessing
|
- If requirements are ambiguous, ask first rather than guessing
|
||||||
|
`);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// NEW: Proactive Behaviors for ICs
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
sections.push(`## Proactive Behaviors
|
||||||
|
- When you finish a task, immediately check for the next priority task
|
||||||
|
- If you notice a bug while working, create a task for it
|
||||||
|
- If documentation is outdated, update it while you're in the area
|
||||||
|
- If you solve a tricky problem, add it to the knowledge base
|
||||||
|
- Help review teammates' work when your queue is empty
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Department-specific operating instructions
|
||||||
|
const dept = ctx.agent.department;
|
||||||
|
if (dept === "engineering") {
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// NEW: Engineering Quality Checklist (new engineering department section)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
sections.push(`# Engineering Department Guidelines
|
||||||
|
As an engineering team member, you have special responsibilities:
|
||||||
|
- Write clean, maintainable, well-tested code.
|
||||||
|
- Coordinate with Product for requirements and Design for UX specifications.
|
||||||
|
- Participate in code reviews promptly — unblock your teammates.
|
||||||
|
- Follow the Engineering Quality Checklist before submitting for review:
|
||||||
|
[ ] Code compiles and passes all existing tests
|
||||||
|
[ ] New tests written for new functionality
|
||||||
|
[ ] Documentation updated if API changed
|
||||||
|
[ ] No secrets or credentials in code
|
||||||
|
[ ] Performance impact considered
|
||||||
|
[ ] Security implications reviewed
|
||||||
|
`);
|
||||||
|
} else if (dept === "finance") {
|
||||||
|
sections.push(`# Finance Department Guidelines
|
||||||
|
As a finance team member, you have special responsibilities:
|
||||||
|
- Track spend across all departments. Flag any department exceeding 80% of monthly budget.
|
||||||
|
- When reviewing budget_request approvals, check: Is there remaining budget? Is the request justified? Is there a cheaper alternative?
|
||||||
|
- Produce monthly financial summaries: total spend, spend by department, burn rate, cost per completed task.
|
||||||
|
- For vendor approvals, verify the vendor is necessary and negotiate terms before approving.
|
||||||
|
- Work with the CFO to maintain a financial forecast and flag risks early.
|
||||||
|
- Never approve your own budget requests — escalate to CFO or CEO.
|
||||||
|
`);
|
||||||
|
} else if (dept === "hr") {
|
||||||
|
sections.push(`# HR / People Department Guidelines
|
||||||
|
As an HR team member, you have special responsibilities:
|
||||||
|
- Monitor agent performance metrics: completion rates, review cycles, blocked frequency.
|
||||||
|
- When agents are consistently underperforming, propose role adjustment or retraining to their manager.
|
||||||
|
- Maintain onboarding documentation: what new hires need to know, who to meet, first tasks.
|
||||||
|
- Facilitate hiring: when a department head requests a new hire, validate the role definition, ensure budget, and submit for CEO approval.
|
||||||
|
- Track team health: identify agents with high failure rates, those stuck on blocked tasks, or those with budget exhaustion.
|
||||||
|
- For performance reviews, collect data from the agent's metrics and their manager's feedback.
|
||||||
|
- Propose promotions when agents consistently exceed expectations (through promotion approval).
|
||||||
|
`);
|
||||||
|
} else if (dept === "marketing") {
|
||||||
|
sections.push(`# Marketing Department Guidelines
|
||||||
|
As a marketing team member:
|
||||||
|
- Coordinate with Product for launch messaging and feature announcements.
|
||||||
|
- Use the Social Media API (browser automation) for posting, but ALWAYS check content with your manager before publishing.
|
||||||
|
- Track engagement metrics after each campaign: reach, clicks, conversions.
|
||||||
|
- Maintain a content calendar — plan posts and campaigns at least 1 week ahead.
|
||||||
|
- For paid campaigns, submit a budget_request approval with expected ROI.
|
||||||
|
- Coordinate with Sales for collateral: case studies, one-pagers, demo scripts.
|
||||||
|
- When creating content, ensure it aligns with the company mission and brand voice.
|
||||||
|
`);
|
||||||
|
} else if (dept === "sales") {
|
||||||
|
sections.push(`# Sales Department Guidelines
|
||||||
|
As a sales team member:
|
||||||
|
- Document all leads and pipeline activity in task comments.
|
||||||
|
- Coordinate with Marketing for outbound content and landing pages.
|
||||||
|
- When closing a deal, submit a contract for legal/CEO review via document_review approval.
|
||||||
|
- Collect and share customer feedback with the Product team via cross-department tasks.
|
||||||
|
- Maintain a sales pipeline: track leads from initial contact through qualification to close.
|
||||||
|
- Report weekly: new leads, pipeline value, deals closed, deals lost (and why).
|
||||||
|
- Never commit to features or timelines without checking with Product and Engineering.
|
||||||
|
`);
|
||||||
|
} else if (dept === "customer_support") {
|
||||||
|
sections.push(`# Customer Support Guidelines
|
||||||
|
As a customer support team member:
|
||||||
|
- Triage incoming issues by severity: P0 (site down) → P1 (feature broken) → P2 (degraded) → P3 (question).
|
||||||
|
- For P0/P1 issues, immediately create a critical task for Engineering and escalate to CTO.
|
||||||
|
- Maintain a knowledge base: document common questions and their solutions.
|
||||||
|
- Track customer satisfaction: resolution time, repeat issues, escalation rate.
|
||||||
|
- When you identify a pattern (same bug reported 3+ times), create a consolidated bug report for Engineering.
|
||||||
|
- Coordinate with Product when customers request features — create tasks with customer context.
|
||||||
|
- Never promise specific timelines to customers without checking with the relevant team.
|
||||||
|
`);
|
||||||
|
} else if (dept === "operations") {
|
||||||
|
sections.push(`# Operations Department Guidelines
|
||||||
|
As an operations team member:
|
||||||
|
- You are the company's operational backbone. Ensure processes run smoothly.
|
||||||
|
- Facilitate sprint ceremonies: planning, review, retrospective. Capture decisions and action items.
|
||||||
|
- Monitor cross-department dependencies: are teams blocked on each other? Intervene and unblock.
|
||||||
|
- Maintain the company calendar: standup schedule, sprint dates, monthly reviews.
|
||||||
|
- Track OKRs and KPIs across departments. Flag teams falling behind on goals.
|
||||||
|
- Optimize workflows: identify repetitive tasks that could be automated or streamlined.
|
||||||
|
- Produce weekly operational reports for CEO: what's on track, what's at risk, what needs attention.
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// NEW: Company Culture (before Important Rules)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
sections.push(`# Company Culture
|
||||||
|
This company operates on these principles:
|
||||||
|
1. **Ownership** — Every agent owns their work end-to-end. No "that's not my job."
|
||||||
|
2. **Transparency** — All work, decisions, and blockers are visible via the API. No hidden work.
|
||||||
|
3. **Speed with Quality** — Ship fast but don't ship broken. Tests before features.
|
||||||
|
4. **Collaboration over Hierarchy** — Anyone can comment on any task. Ideas win on merit.
|
||||||
|
5. **Data-Driven** — Decisions backed by metrics. "I think" < "the data shows."
|
||||||
|
6. **Continuous Improvement** — After every sprint, capture lessons learned. The company gets smarter.
|
||||||
|
`);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// NEW: Time Awareness (before Important Rules)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
sections.push(`# Time Context
|
||||||
|
Current simulation time context:
|
||||||
|
- You should consider the cadence of company operations when planning work.
|
||||||
|
- If it's the start of a sprint, focus on planning and task breakdown.
|
||||||
|
- If it's mid-sprint, focus on execution and unblocking.
|
||||||
|
- If it's end of sprint, focus on reviews, retrospectives, and wrapping up.
|
||||||
|
- Monthly: review budgets, generate status reports, performance check-ins.
|
||||||
|
- Quarterly: performance reviews, strategy alignment, goal setting.
|
||||||
|
`);
|
||||||
|
|
||||||
|
// ── Decision Authority Matrix ──────────────────────────────────────────
|
||||||
|
{
|
||||||
|
const authorityLevel = isCEO ? "executive" : isManager ? "manager" : "individual_contributor";
|
||||||
|
const budgetDisplay = ctx.agent.budgetMonthlyUsd > 0
|
||||||
|
? `$${ctx.agent.budgetMonthlyUsd}/month (spent: $${ctx.agent.spentMonthlyUsd.toFixed(2)})`
|
||||||
|
: "No budget allocated";
|
||||||
|
|
||||||
|
const authorityRules: Record<string, string[]> = {
|
||||||
|
executive: [
|
||||||
|
"Create tasks and assign to any agent in the company",
|
||||||
|
"Create projects and goals",
|
||||||
|
"Propose hiring new agents (requires Board approval)",
|
||||||
|
"Submit strategic approvals for Board review",
|
||||||
|
"Approve or reject work from direct reports",
|
||||||
|
"Reallocate tasks between reports",
|
||||||
|
"Schedule any type of meeting",
|
||||||
|
"Create and approve documents",
|
||||||
|
"Send messages to any agent or department",
|
||||||
|
"Access company-wide metrics and reports",
|
||||||
|
"Set sprint goals and priorities",
|
||||||
|
],
|
||||||
|
manager: [
|
||||||
|
"Create tasks and assign to your direct reports only",
|
||||||
|
"Create subtasks under your assigned tasks",
|
||||||
|
"Approve or reject work from your direct reports",
|
||||||
|
"Propose hiring to fill gaps on your team (requires CEO + Board approval)",
|
||||||
|
"Schedule meetings within your department",
|
||||||
|
"Create documents relevant to your department",
|
||||||
|
"Send messages to your reports, peers, and manager",
|
||||||
|
"Request budget increases (requires Finance/CEO approval)",
|
||||||
|
"Escalate blockers to your manager",
|
||||||
|
],
|
||||||
|
individual_contributor: [
|
||||||
|
"Update status on your assigned tasks",
|
||||||
|
"Create subtasks under your own tasks (self-decomposition)",
|
||||||
|
"Submit work for review (in_review status)",
|
||||||
|
"Post comments on tasks you're involved with",
|
||||||
|
"Request approvals when blocked (secret_access, etc.)",
|
||||||
|
"Send messages to your manager and teammates",
|
||||||
|
"Create knowledge base entries from your learnings",
|
||||||
|
"Create documents relevant to your work (specs, post-mortems)",
|
||||||
|
"Report blockers and escalate to your manager",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
sections.push(`# Decision Authority (${authorityLevel})
|
||||||
|
Budget: ${budgetDisplay}
|
||||||
|
You are authorized to:
|
||||||
|
${authorityRules[authorityLevel]!.map(r => `- ${r}`).join("\n")}
|
||||||
|
|
||||||
|
Actions requiring approval:
|
||||||
|
- Hiring new agents → requires Board (human operator) approval
|
||||||
|
- Accessing secrets/credentials → requires CEO or Board approval
|
||||||
|
- Dangerous commands → requires Board approval
|
||||||
|
- Budget increases → requires Finance + CEO approval
|
||||||
|
- Production deployments → requires CTO + CEO approval
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Recent Agent Messages ──────────────────────────────────────────────
|
||||||
|
if (ctx.recentMessages && ctx.recentMessages.length > 0) {
|
||||||
|
const msgLines = ctx.recentMessages.slice(0, 15).map((m) => {
|
||||||
|
const from = ctx.allAgents.find(a => a.id === m.fromAgentId)?.name ?? m.fromAgentId;
|
||||||
|
const to = m.toAgentId ? (ctx.allAgents.find(a => a.id === m.toAgentId)?.name ?? "Unknown") : `[${m.channel}]`;
|
||||||
|
const unread = !m.readAt ? " [UNREAD]" : "";
|
||||||
|
return `- ${from} → ${to}${unread}: ${m.subject} — "${m.body.slice(0, 120)}${m.body.length > 120 ? "..." : ""}"`;
|
||||||
|
});
|
||||||
|
sections.push(`# Recent Messages
|
||||||
|
${msgLines.join("\n")}
|
||||||
|
|
||||||
|
To respond, use POST /api/companies/{companyId}/messages.
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Upcoming Meetings ──────────────────────────────────────────────────
|
||||||
|
if (ctx.upcomingMeetings && ctx.upcomingMeetings.length > 0) {
|
||||||
|
const meetingLines = ctx.upcomingMeetings.slice(0, 5).map((m) => {
|
||||||
|
const organizer = m.organizerAgentId ? (ctx.allAgents.find(a => a.id === m.organizerAgentId)?.name ?? "Unknown") : "TBD";
|
||||||
|
let participants: string[];
|
||||||
|
try { participants = JSON.parse(m.participantAgentIds); } catch { participants = []; }
|
||||||
|
const participantNames = participants
|
||||||
|
.map(id => ctx.allAgents.find(a => a.id === id)?.name ?? id)
|
||||||
|
.join(", ");
|
||||||
|
return `- [${m.type}] ${m.title} — Organizer: ${organizer}, Participants: ${participantNames || "TBD"}, At: ${m.scheduledAt}, Duration: ${m.durationMinutes}min, Status: ${m.status}`;
|
||||||
|
});
|
||||||
|
sections.push(`# Upcoming Meetings
|
||||||
|
${meetingLines.join("\n")}
|
||||||
|
|
||||||
|
If you organized a meeting, prepare the agenda. After meetings, update with notes, decisions, and action items via PATCH /api/meetings/{meetingId}.
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Active Sprint ──────────────────────────────────────────────────────
|
||||||
|
if (ctx.activeSprint) {
|
||||||
|
const s = ctx.activeSprint;
|
||||||
|
sections.push(`# Active Sprint
|
||||||
|
- Name: ${s.name}
|
||||||
|
- Goal: ${s.goal || "No sprint goal set"}
|
||||||
|
- Period: ${s.startDate} to ${s.endDate}
|
||||||
|
- Status: ${s.status}
|
||||||
|
- Velocity: ${s.completedPoints}/${s.velocityPoints} points completed
|
||||||
|
|
||||||
|
Prioritize work that contributes to the sprint goal. Flag risks if the sprint goal is at risk.
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Knowledge Base Context ─────────────────────────────────────────────
|
||||||
|
if (ctx.relevantKnowledge && ctx.relevantKnowledge.length > 0) {
|
||||||
|
const knowledgeLines = ctx.relevantKnowledge.slice(0, 8).map((k) => {
|
||||||
|
return `- [${k.category}/${k.importance}] ${k.topic}: ${k.content.slice(0, 200)}${k.content.length > 200 ? "..." : ""}`;
|
||||||
|
});
|
||||||
|
sections.push(`# Relevant Knowledge Base
|
||||||
|
${knowledgeLines.join("\n")}
|
||||||
|
|
||||||
|
Use this knowledge to inform your decisions. Add new lessons via POST /api/companies/{companyId}/knowledge.
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Recent Documents ───────────────────────────────────────────────────
|
||||||
|
if (ctx.recentDocuments && ctx.recentDocuments.length > 0) {
|
||||||
|
const docLines = ctx.recentDocuments.slice(0, 5).map((d) => {
|
||||||
|
const author = d.authorAgentId ? (ctx.allAgents.find(a => a.id === d.authorAgentId)?.name ?? "Unknown") : "System";
|
||||||
|
return `- [${d.type}] ${d.title} — by ${author}, status: ${d.status}, updated: ${d.updatedAt}`;
|
||||||
|
});
|
||||||
|
sections.push(`# Recent Documents
|
||||||
|
${docLines.join("\n")}
|
||||||
|
|
||||||
|
Reference these documents when relevant to your current work. Create new documents for important work artifacts.
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -528,11 +1218,25 @@ export function buildTaskPrompt(ctx: PromptContext): string {
|
|||||||
comment: "A new comment has been posted on one of your tasks. Check comments for updates.",
|
comment: "A new comment has been posted on one of your tasks. Check comments for updates.",
|
||||||
report_blocked: "One of your reports is blocked and needs your help.",
|
report_blocked: "One of your reports is blocked and needs your help.",
|
||||||
report_failed: "A run by one of your reports has failed. Check their task status.",
|
report_failed: "A run by one of your reports has failed. Check their task status.",
|
||||||
|
message: "You received a direct message from another agent. Check your messages via GET /api/companies/{companyId}/messages?unreadOnly=true",
|
||||||
|
peer_request: "Another agent has requested your attention. Check your messages and task assignments.",
|
||||||
};
|
};
|
||||||
const explanation = reasonMap[ctx.wakeReason] ?? `Trigger: ${ctx.wakeReason}`;
|
const explanation = reasonMap[ctx.wakeReason] ?? `Trigger: ${ctx.wakeReason}`;
|
||||||
parts.push(`\n# Wake Reason\n${explanation}\n`);
|
parts.push(`\n# Wake Reason\n${explanation}\n`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// NEW: Time Context appended after Wake Reason in task prompt
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
parts.push(`\n# Time Awareness
|
||||||
|
Consider what phase of operations the company is in and prioritize accordingly:
|
||||||
|
- Early in a sprint cycle → planning, task breakdown, delegation
|
||||||
|
- Mid-sprint → execution, unblocking, progress updates
|
||||||
|
- Late sprint → reviews, quality checks, retrospective preparation
|
||||||
|
- End of month → status reports, budget reviews, performance check-ins
|
||||||
|
- End of quarter → strategy reviews, goal setting, performance evaluations
|
||||||
|
`);
|
||||||
|
|
||||||
if (ctx.task) {
|
if (ctx.task) {
|
||||||
parts.push(`\n---\n\nYour immediate task:\n\nTitle: ${ctx.task.title}\nDescription: ${ctx.task.description || "No additional description."}\nPriority: ${ctx.task.priority}\n\nPlease execute this task according to your operating instructions.`);
|
parts.push(`\n---\n\nYour immediate task:\n\nTitle: ${ctx.task.title}\nDescription: ${ctx.task.description || "No additional description."}\nPriority: ${ctx.task.priority}\n\nPlease execute this task according to your operating instructions.`);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -43,6 +43,45 @@ const api: DesktopApi = {
|
|||||||
setHeartbeat: (input) => ipcRenderer.invoke(IPC_CHANNELS.setHeartbeat, input),
|
setHeartbeat: (input) => ipcRenderer.invoke(IPC_CHANNELS.setHeartbeat, input),
|
||||||
triggerHeartbeat: (input) => ipcRenderer.invoke(IPC_CHANNELS.triggerHeartbeat, input),
|
triggerHeartbeat: (input) => ipcRenderer.invoke(IPC_CHANNELS.triggerHeartbeat, input),
|
||||||
getApiPort: () => ipcRenderer.invoke(IPC_CHANNELS.getApiPort),
|
getApiPort: () => ipcRenderer.invoke(IPC_CHANNELS.getApiPort),
|
||||||
|
getInbox: (companyId) => ipcRenderer.invoke(IPC_CHANNELS.getInbox, companyId),
|
||||||
|
dismissInboxItem: (input) => ipcRenderer.invoke(IPC_CHANNELS.dismissInboxItem, input),
|
||||||
|
getCompanyMetrics: (input) => ipcRenderer.invoke(IPC_CHANNELS.getCompanyMetrics, input),
|
||||||
|
getAgentMetrics: (input) => ipcRenderer.invoke(IPC_CHANNELS.getAgentMetrics, input),
|
||||||
|
getStandupReport: (companyId) => ipcRenderer.invoke(IPC_CHANNELS.getStandupReport, companyId),
|
||||||
|
applyCompanyTemplate: (input) => ipcRenderer.invoke(IPC_CHANNELS.applyCompanyTemplate, input),
|
||||||
|
listCompanyTemplates: () => ipcRenderer.invoke(IPC_CHANNELS.listCompanyTemplates),
|
||||||
|
saveSocialAccount: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveSocialAccount, input),
|
||||||
|
deleteSocialAccount: (input) => ipcRenderer.invoke(IPC_CHANNELS.deleteSocialAccount, input),
|
||||||
|
listBrowserActions: (input) => ipcRenderer.invoke(IPC_CHANNELS.listBrowserActions, input),
|
||||||
|
cancelBrowserAction: (input) => ipcRenderer.invoke(IPC_CHANNELS.cancelBrowserAction, input),
|
||||||
|
triggerBrowserLogin: (id) => ipcRenderer.invoke(IPC_CHANNELS.triggerBrowserLogin, id),
|
||||||
|
// Meetings
|
||||||
|
saveMeeting: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveMeeting, input),
|
||||||
|
deleteMeeting: (input) => ipcRenderer.invoke(IPC_CHANNELS.deleteMeeting, input),
|
||||||
|
// Documents
|
||||||
|
saveDocument: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveDocument, input),
|
||||||
|
deleteDocument: (input) => ipcRenderer.invoke(IPC_CHANNELS.deleteDocument, input),
|
||||||
|
// Knowledge Base
|
||||||
|
saveKnowledgeEntry: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveKnowledgeEntry, input),
|
||||||
|
deleteKnowledgeEntry: (input) => ipcRenderer.invoke(IPC_CHANNELS.deleteKnowledgeEntry, input),
|
||||||
|
// Sprints
|
||||||
|
saveSprint: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveSprint, input),
|
||||||
|
deleteSprint: (input) => ipcRenderer.invoke(IPC_CHANNELS.deleteSprint, input),
|
||||||
|
// Automation
|
||||||
|
saveAutomationRule: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveAutomationRule, input),
|
||||||
|
deleteAutomationRule: (input) => ipcRenderer.invoke(IPC_CHANNELS.deleteAutomationRule, input),
|
||||||
|
toggleAutomationRule: (input) => ipcRenderer.invoke(IPC_CHANNELS.toggleAutomationRule, input),
|
||||||
|
getAutomationLog: (companyId) => ipcRenderer.invoke(IPC_CHANNELS.getAutomationLog, companyId),
|
||||||
|
// Agent Messages
|
||||||
|
sendAgentMessage: (input) => ipcRenderer.invoke(IPC_CHANNELS.sendAgentMessage, input),
|
||||||
|
listAgentMessages: (input) => ipcRenderer.invoke(IPC_CHANNELS.listAgentMessages, input),
|
||||||
|
markMessageRead: (input) => ipcRenderer.invoke(IPC_CHANNELS.markMessageRead, input),
|
||||||
|
// Workflows
|
||||||
|
saveWorkflow: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveWorkflow, input),
|
||||||
|
deleteWorkflow: (input) => ipcRenderer.invoke(IPC_CHANNELS.deleteWorkflow, input),
|
||||||
|
runWorkflow: (input) => ipcRenderer.invoke(IPC_CHANNELS.runWorkflow, input),
|
||||||
|
// Auto-assign
|
||||||
|
autoAssignTask: (input) => ipcRenderer.invoke(IPC_CHANNELS.autoAssignTask, input),
|
||||||
subscribe: (callback: (event: DesktopEvent) => void) => {
|
subscribe: (callback: (event: DesktopEvent) => void) => {
|
||||||
const listener = (_event: Electron.IpcRendererEvent, payload: DesktopEvent) => callback(payload);
|
const listener = (_event: Electron.IpcRendererEvent, payload: DesktopEvent) => callback(payload);
|
||||||
ipcRenderer.on(IPC_CHANNELS.rendererEvent, listener);
|
ipcRenderer.on(IPC_CHANNELS.rendererEvent, listener);
|
||||||
|
|||||||
+333
-2
@@ -2,21 +2,33 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
import {
|
import {
|
||||||
ArrowClockwise,
|
ArrowClockwise,
|
||||||
|
Bell,
|
||||||
|
BookOpen,
|
||||||
|
Brain,
|
||||||
|
CalendarBlank,
|
||||||
CaretUpDown,
|
CaretUpDown,
|
||||||
|
ChartBar,
|
||||||
|
ChatCircle,
|
||||||
Check,
|
Check,
|
||||||
Command,
|
Command,
|
||||||
Cpu,
|
Cpu,
|
||||||
CurrencyCircleDollar,
|
CurrencyCircleDollar,
|
||||||
|
FileText,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
Gear,
|
Gear,
|
||||||
|
Heartbeat,
|
||||||
Kanban,
|
Kanban,
|
||||||
|
Lightning,
|
||||||
ListChecks,
|
ListChecks,
|
||||||
MagnifyingGlass,
|
MagnifyingGlass,
|
||||||
|
Megaphone,
|
||||||
Plus,
|
Plus,
|
||||||
Pulse,
|
Pulse,
|
||||||
Robot,
|
Robot,
|
||||||
|
Rows,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Trash,
|
Trash,
|
||||||
|
UserPlus,
|
||||||
SquaresFour,
|
SquaresFour,
|
||||||
Target,
|
Target,
|
||||||
TreeStructure,
|
TreeStructure,
|
||||||
@@ -27,14 +39,23 @@ import {
|
|||||||
X,
|
X,
|
||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react";
|
||||||
import type {
|
import type {
|
||||||
|
AgentMessageRecord,
|
||||||
|
AutomationRuleRecord,
|
||||||
CompanyRecord,
|
CompanyRecord,
|
||||||
|
CompanyMetrics,
|
||||||
DesktopEvent,
|
DesktopEvent,
|
||||||
|
InboxItem,
|
||||||
ProfileSnapshot,
|
ProfileSnapshot,
|
||||||
RunRecord,
|
RunRecord,
|
||||||
SectionId,
|
SectionId,
|
||||||
|
SocialAccountRecord,
|
||||||
|
BrowserActionRecord,
|
||||||
|
StandupReport,
|
||||||
|
WorkflowPipelineRecord,
|
||||||
} from "@shared/types";
|
} from "@shared/types";
|
||||||
import type { SidebarBadges } from "@shared/contracts";
|
import type { SidebarBadges } from "@shared/contracts";
|
||||||
import { CompanyOnboarding } from "./components/CompanyOnboarding";
|
import { CompanyOnboarding } from "./components/CompanyOnboarding";
|
||||||
|
import { Inbox, StandupView, PerformanceView } from "./components/inbox";
|
||||||
import { ConnectorEditor } from "./components/connectors";
|
import { ConnectorEditor } from "./components/connectors";
|
||||||
import { Dashboard, KanbanBoard } from "./components/dashboard";
|
import { Dashboard, KanbanBoard } from "./components/dashboard";
|
||||||
import { EntityWorkspace } from "./components/EntityWorkspace";
|
import { EntityWorkspace } from "./components/EntityWorkspace";
|
||||||
@@ -65,6 +86,10 @@ import {
|
|||||||
ProjectEditor,
|
ProjectEditor,
|
||||||
ProjectList,
|
ProjectList,
|
||||||
} from "./components/organization";
|
} from "./components/organization";
|
||||||
|
import { SocialAccountsPanel } from "./components/social";
|
||||||
|
import { AutomationPanel } from "./components/automation";
|
||||||
|
import { CommunicationHub } from "./components/communication";
|
||||||
|
import { HiringPipeline } from "./components/hiring";
|
||||||
import { ActionButton, ErrorBoundary, ListFrame, ListRow, Modal, Panel, SectionButton, Select, StatusPill, useFocusTrap, useReducedMotion } from "./components/ui";
|
import { ActionButton, ErrorBoundary, ListFrame, ListRow, Modal, Panel, SectionButton, Select, StatusPill, useFocusTrap, useReducedMotion } from "./components/ui";
|
||||||
import { unwrap } from "./lib/desktop";
|
import { unwrap } from "./lib/desktop";
|
||||||
import { formatMoney, formatTime } from "./lib/formatters";
|
import { formatMoney, formatTime } from "./lib/formatters";
|
||||||
@@ -77,6 +102,9 @@ const navGroups: NavGroup[] = [
|
|||||||
label: "Overview",
|
label: "Overview",
|
||||||
items: [
|
items: [
|
||||||
{ id: "overview", label: "Dashboard", icon: SquaresFour },
|
{ id: "overview", label: "Dashboard", icon: SquaresFour },
|
||||||
|
{ id: "inbox", label: "Inbox", icon: Bell },
|
||||||
|
{ id: "standup", label: "Standup", icon: Heartbeat },
|
||||||
|
{ id: "performance", label: "Performance", icon: ChartBar },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -84,17 +112,35 @@ const navGroups: NavGroup[] = [
|
|||||||
items: [
|
items: [
|
||||||
{ id: "agents", label: "Agents", icon: Robot },
|
{ id: "agents", label: "Agents", icon: Robot },
|
||||||
{ id: "orgchart", label: "Org Chart", icon: TreeStructure },
|
{ id: "orgchart", label: "Org Chart", icon: TreeStructure },
|
||||||
|
{ id: "hiring", label: "Hiring", icon: UserPlus },
|
||||||
|
{ id: "communication", label: "Messages", icon: ChatCircle },
|
||||||
{ id: "goals", label: "Goals", icon: Target },
|
{ id: "goals", label: "Goals", icon: Target },
|
||||||
{ id: "projects", label: "Projects", icon: Kanban },
|
{ id: "projects", label: "Projects", icon: Kanban },
|
||||||
{ id: "tasks", label: "Tasks", icon: ListChecks },
|
{ id: "tasks", label: "Tasks", icon: ListChecks },
|
||||||
|
{ id: "sprints", label: "Sprints", icon: Rows },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Automation",
|
||||||
|
items: [
|
||||||
|
{ id: "automation", label: "Automation", icon: Lightning },
|
||||||
|
{ id: "approvals", label: "Approvals", icon: ShieldCheck },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Knowledge",
|
||||||
|
items: [
|
||||||
|
{ id: "documents", label: "Documents", icon: FileText },
|
||||||
|
{ id: "knowledge", label: "Knowledge", icon: Brain },
|
||||||
|
{ id: "meetings", label: "Meetings", icon: CalendarBlank },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Operations",
|
label: "Operations",
|
||||||
items: [
|
items: [
|
||||||
{ id: "approvals", label: "Approvals", icon: ShieldCheck },
|
|
||||||
{ id: "costs", label: "Costs", icon: CurrencyCircleDollar },
|
{ id: "costs", label: "Costs", icon: CurrencyCircleDollar },
|
||||||
{ id: "activity", label: "Activity", icon: Pulse },
|
{ id: "activity", label: "Activity", icon: Pulse },
|
||||||
|
{ id: "social", label: "Social Media", icon: Megaphone },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -135,6 +181,11 @@ export function App() {
|
|||||||
const [badges, setBadges] = useState<SidebarBadges | null>(null);
|
const [badges, setBadges] = useState<SidebarBadges | null>(null);
|
||||||
const [commandPaletteSearch, setCommandPaletteSearch] = useState("");
|
const [commandPaletteSearch, setCommandPaletteSearch] = useState("");
|
||||||
const [showOnboarding, setShowOnboarding] = useState(false);
|
const [showOnboarding, setShowOnboarding] = useState(false);
|
||||||
|
const [inboxItems, setInboxItems] = useState<InboxItem[]>([]);
|
||||||
|
const [companyMetrics, setCompanyMetrics] = useState<CompanyMetrics | null>(null);
|
||||||
|
const [standupReport, setStandupReport] = useState<StandupReport | null>(null);
|
||||||
|
const [socialAccounts, setSocialAccounts] = useState<SocialAccountRecord[]>([]);
|
||||||
|
const [browserActions, setBrowserActions] = useState<BrowserActionRecord[]>([]);
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -147,6 +198,7 @@ export function App() {
|
|||||||
if (next.currentCompanyId) {
|
if (next.currentCompanyId) {
|
||||||
const badgeResult = await window.agentCompany.getSidebarBadges(next.currentCompanyId);
|
const badgeResult = await window.agentCompany.getSidebarBadges(next.currentCompanyId);
|
||||||
if (badgeResult.ok) setBadges(badgeResult.data);
|
if (badgeResult.ok) setBadges(badgeResult.data);
|
||||||
|
setSocialAccounts(next.socialAccounts?.filter(a => a.companyId === next.currentCompanyId) ?? []);
|
||||||
}
|
}
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (nextError) {
|
} catch (nextError) {
|
||||||
@@ -196,6 +248,36 @@ export function App() {
|
|||||||
root.style.colorScheme = snapshot.theme === "system" ? "light dark" : snapshot.theme;
|
root.style.colorScheme = snapshot.theme === "system" ? "light dark" : snapshot.theme;
|
||||||
}, [snapshot]);
|
}, [snapshot]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!snapshot?.currentCompanyId) return;
|
||||||
|
const companyId = snapshot.currentCompanyId;
|
||||||
|
|
||||||
|
if (section === "inbox") {
|
||||||
|
void window.agentCompany.getInbox(companyId).then((result) => {
|
||||||
|
if (result.ok) setInboxItems(result.data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (section === "performance") {
|
||||||
|
void window.agentCompany.getCompanyMetrics({ companyId, days: 30 }).then((result) => {
|
||||||
|
if (result.ok) setCompanyMetrics(result.data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (section === "standup") {
|
||||||
|
void window.agentCompany.getStandupReport(companyId).then((result) => {
|
||||||
|
if (result.ok) setStandupReport(result.data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (section === "social") {
|
||||||
|
setSocialAccounts(snapshot.socialAccounts?.filter(a => a.companyId === companyId) ?? []);
|
||||||
|
void window.agentCompany.listBrowserActions({ companyId, limit: 50 }).then(result => {
|
||||||
|
if (result.ok) setBrowserActions(result.data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [section, snapshot?.currentCompanyId]);
|
||||||
|
|
||||||
// Global Escape key handler for command palette
|
// Global Escape key handler for command palette
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!commandPaletteOpen) return;
|
if (!commandPaletteOpen) return;
|
||||||
@@ -233,11 +315,13 @@ export function App() {
|
|||||||
const selectedProject = companyProjects.find((p) => p.id === selectedProjectId) ?? null;
|
const selectedProject = companyProjects.find((p) => p.id === selectedProjectId) ?? null;
|
||||||
|
|
||||||
// Sidebar badge mapping
|
// Sidebar badge mapping
|
||||||
|
const activeInboxCount = useMemo(() => inboxItems.filter((item) => !item.dismissed).length, [inboxItems]);
|
||||||
const navBadges = useMemo<Partial<Record<SectionId, { count: number; tone: "default" | "warn" | "danger" }>>>(() => badges ? {
|
const navBadges = useMemo<Partial<Record<SectionId, { count: number; tone: "default" | "warn" | "danger" }>>>(() => badges ? {
|
||||||
|
inbox: activeInboxCount > 0 ? { count: activeInboxCount, tone: "warn" } : undefined,
|
||||||
tasks: badges.todoTasks > 0 ? { count: badges.todoTasks, tone: "default" } : undefined,
|
tasks: badges.todoTasks > 0 ? { count: badges.todoTasks, tone: "default" } : undefined,
|
||||||
approvals: badges.pendingApprovals > 0 ? { count: badges.pendingApprovals, tone: "warn" } : undefined,
|
approvals: badges.pendingApprovals > 0 ? { count: badges.pendingApprovals, tone: "warn" } : undefined,
|
||||||
costs: badges.budgetWarnings > 0 ? { count: badges.budgetWarnings, tone: "danger" } : undefined,
|
costs: badges.budgetWarnings > 0 ? { count: badges.budgetWarnings, tone: "danger" } : undefined,
|
||||||
} : {}, [badges]);
|
} : {}, [badges, activeInboxCount]);
|
||||||
|
|
||||||
const liveRuns = useMemo(() => runs.filter((run) => run.status === "running" || run.status === "queued"), [runs]);
|
const liveRuns = useMemo(() => runs.filter((run) => run.status === "running" || run.status === "queued"), [runs]);
|
||||||
const failedRuns = useMemo(() => runs.filter((run) => ["failed", "timed_out", "interrupted"].includes(run.status)), [runs]);
|
const failedRuns = useMemo(() => runs.filter((run) => ["failed", "timed_out", "interrupted"].includes(run.status)), [runs]);
|
||||||
@@ -542,6 +626,57 @@ export function App() {
|
|||||||
activity={filteredActivity}
|
activity={filteredActivity}
|
||||||
costs={companyCosts}
|
costs={companyCosts}
|
||||||
connectors={snapshot.connectors}
|
connectors={snapshot.connectors}
|
||||||
|
meetings={snapshot.meetings?.filter((m) => !currentCompany || m.companyId === currentCompany.id)}
|
||||||
|
sprints={snapshot.sprints?.filter((s) => !currentCompany || s.companyId === currentCompany.id)}
|
||||||
|
onNavigate={(targetSection, entityId) => {
|
||||||
|
setSection(targetSection);
|
||||||
|
if (entityId) {
|
||||||
|
if (targetSection === "agents") setSelectedAgentId(entityId);
|
||||||
|
else if (targetSection === "tasks") setSelectedTaskId(entityId);
|
||||||
|
else if (targetSection === "approvals") setSelectedApprovalId(entityId);
|
||||||
|
else if (targetSection === "goals") setSelectedGoalId(entityId);
|
||||||
|
else if (targetSection === "projects") setSelectedProjectId(entityId);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{section === "inbox" ? (
|
||||||
|
<Inbox
|
||||||
|
items={inboxItems}
|
||||||
|
onNavigate={(targetSection, entityId) => {
|
||||||
|
setSection(targetSection);
|
||||||
|
if (entityId) {
|
||||||
|
if (targetSection === "agents") setSelectedAgentId(entityId);
|
||||||
|
else if (targetSection === "tasks") setSelectedTaskId(entityId);
|
||||||
|
else if (targetSection === "approvals") setSelectedApprovalId(entityId);
|
||||||
|
else if (targetSection === "goals") setSelectedGoalId(entityId);
|
||||||
|
else if (targetSection === "projects") setSelectedProjectId(entityId);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDismiss={(_itemId) => { /* no-op for now */ }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{section === "standup" ? (
|
||||||
|
<StandupView
|
||||||
|
report={standupReport}
|
||||||
|
onNavigate={(targetSection, entityId) => {
|
||||||
|
setSection(targetSection);
|
||||||
|
if (entityId) {
|
||||||
|
if (targetSection === "agents") setSelectedAgentId(entityId);
|
||||||
|
else if (targetSection === "tasks") setSelectedTaskId(entityId);
|
||||||
|
else if (targetSection === "approvals") setSelectedApprovalId(entityId);
|
||||||
|
else if (targetSection === "goals") setSelectedGoalId(entityId);
|
||||||
|
else if (targetSection === "projects") setSelectedProjectId(entityId);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{section === "performance" ? (
|
||||||
|
<PerformanceView
|
||||||
|
metrics={companyMetrics}
|
||||||
onNavigate={(targetSection, entityId) => {
|
onNavigate={(targetSection, entityId) => {
|
||||||
setSection(targetSection);
|
setSection(targetSection);
|
||||||
if (entityId) {
|
if (entityId) {
|
||||||
@@ -625,6 +760,16 @@ export function App() {
|
|||||||
<OrgChart agents={companyAgents} onSelectAgent={(id) => { setSelectedAgentId(id); setSection("agents"); }} />
|
<OrgChart agents={companyAgents} onSelectAgent={(id) => { setSelectedAgentId(id); setSection("agents"); }} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{section === "hiring" && currentCompany ? (
|
||||||
|
<HiringPipeline
|
||||||
|
companyId={currentCompany.id}
|
||||||
|
agents={companyAgents}
|
||||||
|
approvals={(snapshot?.approvals ?? []).filter(a => a.companyId === currentCompany!.id)}
|
||||||
|
onRefresh={refresh}
|
||||||
|
onNavigateToAgent={(id) => { setSelectedAgentId(id); setSection("agents"); }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{section === "goals" ? (
|
{section === "goals" ? (
|
||||||
<>
|
<>
|
||||||
<Panel title="Goal stack" action={<ActionButton label="New goal" icon={Target} tone="accent" onClick={() => setSelectedGoalId("__new__")} />}>
|
<Panel title="Goal stack" action={<ActionButton label="New goal" icon={Target} tone="accent" onClick={() => setSelectedGoalId("__new__")} />}>
|
||||||
@@ -794,6 +939,192 @@ export function App() {
|
|||||||
</Panel>
|
</Panel>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{section === "social" && snapshot?.currentCompanyId && (
|
||||||
|
<SocialAccountsPanel
|
||||||
|
accounts={socialAccounts}
|
||||||
|
actions={browserActions}
|
||||||
|
agents={companyAgents.map(a => ({ id: a.id, name: a.name }))}
|
||||||
|
companyId={snapshot.currentCompanyId}
|
||||||
|
onSave={async (input) => {
|
||||||
|
await window.agentCompany.saveSocialAccount({
|
||||||
|
companyId: input.companyId,
|
||||||
|
platform: input.platform,
|
||||||
|
accountName: input.accountName,
|
||||||
|
displayName: input.displayName,
|
||||||
|
requireApproval: input.requireApproval,
|
||||||
|
profileUrl: "",
|
||||||
|
status: "login_required",
|
||||||
|
metadataJson: "{}",
|
||||||
|
});
|
||||||
|
await refresh();
|
||||||
|
}}
|
||||||
|
onDelete={async (id) => {
|
||||||
|
if (!snapshot?.currentCompanyId) return;
|
||||||
|
await window.agentCompany.deleteSocialAccount({ id, companyId: snapshot.currentCompanyId });
|
||||||
|
await refresh();
|
||||||
|
}}
|
||||||
|
onLogin={async (accountId) => {
|
||||||
|
await window.agentCompany.triggerBrowserLogin(accountId);
|
||||||
|
}}
|
||||||
|
onNavigate={(s, id) => {
|
||||||
|
setSection(s);
|
||||||
|
if (id) {
|
||||||
|
if (s === "agents") setSelectedAgentId(id);
|
||||||
|
else if (s === "tasks") setSelectedTaskId(id);
|
||||||
|
else if (s === "approvals") setSelectedApprovalId(id);
|
||||||
|
else if (s === "goals") setSelectedGoalId(id);
|
||||||
|
else if (s === "projects") setSelectedProjectId(id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{section === "automation" && currentCompany ? (
|
||||||
|
<AutomationPanel
|
||||||
|
rules={snapshot.automationRules?.filter(r => r.companyId === currentCompany.id) ?? []}
|
||||||
|
workflows={snapshot.workflows?.filter(w => w.companyId === currentCompany.id) ?? []}
|
||||||
|
agents={companyAgents}
|
||||||
|
companyId={currentCompany.id}
|
||||||
|
onRefresh={refresh}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{section === "communication" && currentCompany ? (
|
||||||
|
<CommunicationHub
|
||||||
|
messages={snapshot.agentMessages?.filter(m => m.companyId === currentCompany.id) ?? []}
|
||||||
|
agents={companyAgents}
|
||||||
|
companyId={currentCompany.id}
|
||||||
|
onRefresh={refresh}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{section === "documents" && currentCompany ? (
|
||||||
|
<Panel title="Company Documents" action={<span className="text-[11px] text-[color:var(--muted)]">{(snapshot.documents?.filter(d => d.companyId === currentCompany.id) ?? []).length} documents</span>}>
|
||||||
|
{(snapshot.documents?.filter(d => d.companyId === currentCompany.id) ?? []).length === 0 ? (
|
||||||
|
<div className="py-10 text-center text-[13px] text-[color:var(--muted)]">
|
||||||
|
<FileText size={32} className="mx-auto mb-3 opacity-40" />
|
||||||
|
<p>No documents yet. Agents will create PRDs, specs, and reports as they work.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ListFrame>
|
||||||
|
{(snapshot.documents?.filter(d => d.companyId === currentCompany.id) ?? []).map(doc => (
|
||||||
|
<ListRow key={doc.id}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FileText size={14} className="text-[color:var(--accent)]" />
|
||||||
|
<span className="text-[13px] font-medium text-[color:var(--text)]">{doc.title}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex items-center gap-3 text-[11px] text-[color:var(--muted)]">
|
||||||
|
<span className="uppercase tracking-wider">{doc.type.replace(/_/g, " ")}</span>
|
||||||
|
<span>v{doc.version}</span>
|
||||||
|
{doc.authorAgentId && <span>by {companyAgents.find(a => a.id === doc.authorAgentId)?.name ?? "Unknown"}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<StatusPill status={doc.status} />
|
||||||
|
</div>
|
||||||
|
</ListRow>
|
||||||
|
))}
|
||||||
|
</ListFrame>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{section === "knowledge" && currentCompany ? (
|
||||||
|
<Panel title="Knowledge Base" action={<span className="text-[11px] text-[color:var(--muted)]">{(snapshot.knowledgeBase?.filter(k => k.companyId === currentCompany.id) ?? []).length} entries</span>}>
|
||||||
|
{(snapshot.knowledgeBase?.filter(k => k.companyId === currentCompany.id) ?? []).length === 0 ? (
|
||||||
|
<div className="py-10 text-center text-[13px] text-[color:var(--muted)]">
|
||||||
|
<Brain size={32} className="mx-auto mb-3 opacity-40" />
|
||||||
|
<p>No knowledge entries yet. Agents learn and document lessons as they work.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ListFrame>
|
||||||
|
{(snapshot.knowledgeBase?.filter(k => k.companyId === currentCompany.id) ?? []).map(entry => (
|
||||||
|
<ListRow key={entry.id}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Brain size={14} className="text-[color:var(--accent)]" />
|
||||||
|
<span className="text-[13px] font-medium text-[color:var(--text)]">{entry.topic}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-[11px] text-[color:var(--muted)]">
|
||||||
|
<span className="uppercase tracking-wider">{entry.category.replace(/_/g, " ")}</span>
|
||||||
|
<span className="mx-2">-</span>
|
||||||
|
<span>{entry.content.slice(0, 100)}{entry.content.length > 100 ? "..." : ""}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<StatusPill status={entry.importance} />
|
||||||
|
</div>
|
||||||
|
</ListRow>
|
||||||
|
))}
|
||||||
|
</ListFrame>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{section === "meetings" && currentCompany ? (
|
||||||
|
<Panel title="Meetings" action={<span className="text-[11px] text-[color:var(--muted)]">{(snapshot.meetings?.filter(m => m.companyId === currentCompany.id) ?? []).length} meetings</span>}>
|
||||||
|
{(snapshot.meetings?.filter(m => m.companyId === currentCompany.id) ?? []).length === 0 ? (
|
||||||
|
<div className="py-10 text-center text-[13px] text-[color:var(--muted)]">
|
||||||
|
<CalendarBlank size={32} className="mx-auto mb-3 opacity-40" />
|
||||||
|
<p>No meetings scheduled. Agents will schedule standups, reviews, and planning sessions.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ListFrame>
|
||||||
|
{(snapshot.meetings?.filter(m => m.companyId === currentCompany.id) ?? []).map(meeting => (
|
||||||
|
<ListRow key={meeting.id}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CalendarBlank size={14} className="text-[color:var(--accent)]" />
|
||||||
|
<span className="text-[13px] font-medium text-[color:var(--text)]">{meeting.title}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex items-center gap-3 text-[11px] text-[color:var(--muted)]">
|
||||||
|
<span className="uppercase tracking-wider">{meeting.type.replace(/_/g, " ")}</span>
|
||||||
|
<span>{new Date(meeting.scheduledAt).toLocaleString()}</span>
|
||||||
|
<span>{meeting.durationMinutes}min</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<StatusPill status={meeting.status} />
|
||||||
|
</div>
|
||||||
|
</ListRow>
|
||||||
|
))}
|
||||||
|
</ListFrame>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{section === "sprints" && currentCompany ? (
|
||||||
|
<Panel title="Sprint Board" action={<span className="text-[11px] text-[color:var(--muted)]">{(snapshot.sprints?.filter(s => s.companyId === currentCompany.id) ?? []).length} sprints</span>}>
|
||||||
|
{(snapshot.sprints?.filter(s => s.companyId === currentCompany.id) ?? []).length === 0 ? (
|
||||||
|
<div className="py-10 text-center text-[13px] text-[color:var(--muted)]">
|
||||||
|
<Rows size={32} className="mx-auto mb-3 opacity-40" />
|
||||||
|
<p>No sprints yet. Start a sprint to organize work into focused cycles.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ListFrame>
|
||||||
|
{(snapshot.sprints?.filter(s => s.companyId === currentCompany.id) ?? []).map(sprint => (
|
||||||
|
<ListRow key={sprint.id}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Rows size={14} className="text-[color:var(--accent)]" />
|
||||||
|
<span className="text-[13px] font-medium text-[color:var(--text)]">{sprint.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex items-center gap-3 text-[11px] text-[color:var(--muted)]">
|
||||||
|
<span>{sprint.startDate} - {sprint.endDate}</span>
|
||||||
|
<span>{sprint.completedPoints}/{sprint.velocityPoints} pts</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<StatusPill status={sprint.status} />
|
||||||
|
</div>
|
||||||
|
</ListRow>
|
||||||
|
))}
|
||||||
|
</ListFrame>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{section === "workspaces" ? (
|
{section === "workspaces" ? (
|
||||||
<EntityWorkspace
|
<EntityWorkspace
|
||||||
title="Workspaces"
|
title="Workspaces"
|
||||||
|
|||||||
@@ -1,21 +1,36 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import {
|
import {
|
||||||
Buildings,
|
Buildings,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
|
Code,
|
||||||
|
Cpu,
|
||||||
|
Flask,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
Lightning,
|
Lightning,
|
||||||
|
Megaphone,
|
||||||
Robot,
|
Robot,
|
||||||
Rocket,
|
Rocket,
|
||||||
|
Stack,
|
||||||
Target,
|
Target,
|
||||||
|
TreeStructure,
|
||||||
|
Users,
|
||||||
WarningCircle,
|
WarningCircle,
|
||||||
XCircle,
|
XCircle,
|
||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react";
|
||||||
import type { ProfileSnapshot } from "@shared/types";
|
import type { CompanyTemplate, ProfileSnapshot } from "@shared/types";
|
||||||
import { unwrap } from "../lib/desktop";
|
import { unwrap } from "../lib/desktop";
|
||||||
import { ActionButton, Input, TextArea } from "./ui";
|
import { ActionButton, Input, TextArea } from "./ui";
|
||||||
|
|
||||||
type Step = "company" | "goal" | "launch";
|
type Step = "choose" | "template" | "company" | "goal" | "launch";
|
||||||
|
|
||||||
|
const templateIcons: Record<string, React.ElementType> = {
|
||||||
|
Cpu: Cpu,
|
||||||
|
Megaphone: Megaphone,
|
||||||
|
Flask: Flask,
|
||||||
|
Stack: Stack,
|
||||||
|
Code: Code,
|
||||||
|
};
|
||||||
|
|
||||||
export function CompanyOnboarding({
|
export function CompanyOnboarding({
|
||||||
connectors,
|
connectors,
|
||||||
@@ -24,7 +39,7 @@ export function CompanyOnboarding({
|
|||||||
connectors: ProfileSnapshot["connectors"];
|
connectors: ProfileSnapshot["connectors"];
|
||||||
onDone: () => Promise<void>;
|
onDone: () => Promise<void>;
|
||||||
}) {
|
}) {
|
||||||
const [step, setStep] = useState<Step>("company");
|
const [step, setStep] = useState<Step>("choose");
|
||||||
const [companyName, setCompanyName] = useState("");
|
const [companyName, setCompanyName] = useState("");
|
||||||
const [workspacePath, setWorkspacePath] = useState("");
|
const [workspacePath, setWorkspacePath] = useState("");
|
||||||
const [autoApproveHires, setAutoApproveHires] = useState(true);
|
const [autoApproveHires, setAutoApproveHires] = useState(true);
|
||||||
@@ -33,10 +48,20 @@ export function CompanyOnboarding({
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [createdCompanyId, setCreatedCompanyId] = useState<string | null>(null);
|
const [createdCompanyId, setCreatedCompanyId] = useState<string | null>(null);
|
||||||
|
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null);
|
||||||
|
const [templates, setTemplates] = useState<CompanyTemplate[]>([]);
|
||||||
|
|
||||||
const hasReadyConnector = connectors.some((c) => c.status === "ready" || c.status === "degraded");
|
const hasReadyConnector = connectors.some((c) => c.status === "ready" || c.status === "degraded");
|
||||||
const readyConnectors = connectors.filter((c) => c.status === "ready" || c.status === "degraded");
|
const readyConnectors = connectors.filter((c) => c.status === "ready" || c.status === "degraded");
|
||||||
const bestConnector = readyConnectors[0] ?? null;
|
const bestConnector = readyConnectors[0] ?? null;
|
||||||
|
const selectedTemplate = templates.find((t) => t.id === selectedTemplateId) ?? null;
|
||||||
|
|
||||||
|
// Load templates
|
||||||
|
useEffect(() => {
|
||||||
|
window.agentCompany.listCompanyTemplates().then((result) => {
|
||||||
|
if (result.ok) setTemplates(result.data);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
async function pickDirectory() {
|
async function pickDirectory() {
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -50,6 +75,54 @@ export function CompanyOnboarding({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Template launch
|
||||||
|
const launchFromTemplate = useCallback(async () => {
|
||||||
|
if (loading || !selectedTemplate) return;
|
||||||
|
if (!companyName.trim()) {
|
||||||
|
setError("Enter a company name.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!hasReadyConnector) {
|
||||||
|
setError("At least one connector must be ready.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const companyId = unwrap(
|
||||||
|
await window.agentCompany.applyCompanyTemplate({
|
||||||
|
templateId: selectedTemplate.id,
|
||||||
|
companyName: companyName.trim(),
|
||||||
|
companyDescription: "",
|
||||||
|
connectorId: bestConnector?.id,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create workspace for the company if path provided
|
||||||
|
if (workspacePath) {
|
||||||
|
unwrap(
|
||||||
|
await window.agentCompany.saveWorkspace({
|
||||||
|
companyId,
|
||||||
|
name: "Default Workspace",
|
||||||
|
localPath: workspacePath,
|
||||||
|
repoUrl: "",
|
||||||
|
repoRef: "",
|
||||||
|
isPrimary: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setCreatedCompanyId(companyId);
|
||||||
|
setStep("launch");
|
||||||
|
await onDone();
|
||||||
|
} catch (submitError) {
|
||||||
|
setError(submitError instanceof Error ? submitError.message : "Unable to create the company.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [loading, selectedTemplate, companyName, hasReadyConnector, workspacePath, bestConnector, onDone]);
|
||||||
|
|
||||||
|
// Custom company creation (existing flow)
|
||||||
const createCompany = useCallback(async () => {
|
const createCompany = useCallback(async () => {
|
||||||
if (loading) return;
|
if (loading) return;
|
||||||
if (!companyName.trim()) {
|
if (!companyName.trim()) {
|
||||||
@@ -98,7 +171,6 @@ export function CompanyOnboarding({
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
// Create goal
|
|
||||||
if (goalTitle.trim()) {
|
if (goalTitle.trim()) {
|
||||||
unwrap(
|
unwrap(
|
||||||
await window.agentCompany.saveGoal({
|
await window.agentCompany.saveGoal({
|
||||||
@@ -111,8 +183,6 @@ export function CompanyOnboarding({
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create CEO agent automatically
|
|
||||||
if (bestConnector) {
|
if (bestConnector) {
|
||||||
const ceoId = unwrap(
|
const ceoId = unwrap(
|
||||||
await window.agentCompany.saveAgent({
|
await window.agentCompany.saveAgent({
|
||||||
@@ -129,22 +199,14 @@ export function CompanyOnboarding({
|
|||||||
status: "idle",
|
status: "idle",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Enable heartbeat on CEO for autonomous operation
|
|
||||||
unwrap(await window.agentCompany.setHeartbeat({ agentId: ceoId, companyId: createdCompanyId, enabled: true, intervalSec: 120 }));
|
unwrap(await window.agentCompany.setHeartbeat({ agentId: ceoId, companyId: createdCompanyId, enabled: true, intervalSec: 120 }));
|
||||||
|
|
||||||
setStep("launch");
|
setStep("launch");
|
||||||
|
|
||||||
// Auto-trigger the CEO's first run after a brief delay for UI
|
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
await window.agentCompany.triggerHeartbeat({ agentId: ceoId, companyId: createdCompanyId, trigger: "assignment" });
|
await window.agentCompany.triggerHeartbeat({ agentId: ceoId, companyId: createdCompanyId, trigger: "assignment" });
|
||||||
} catch {
|
} catch { /* Non-fatal */ }
|
||||||
// Non-fatal — CEO will wake on next heartbeat cycle
|
|
||||||
}
|
|
||||||
}, 1500);
|
}, 1500);
|
||||||
}
|
}
|
||||||
|
|
||||||
await onDone();
|
await onDone();
|
||||||
} catch (submitError) {
|
} catch (submitError) {
|
||||||
setError(submitError instanceof Error ? submitError.message : "Unable to set up the company.");
|
setError(submitError instanceof Error ? submitError.message : "Unable to set up the company.");
|
||||||
@@ -165,10 +227,22 @@ export function CompanyOnboarding({
|
|||||||
}
|
}
|
||||||
}, [loading, onDone]);
|
}, [loading, onDone]);
|
||||||
|
|
||||||
const stepIndex = step === "company" ? 0 : step === "goal" ? 1 : 2;
|
const stepLabels = step === "choose" || step === "template"
|
||||||
|
? [
|
||||||
|
{ label: "Choose Setup", icon: Lightning },
|
||||||
|
{ label: "Configure", icon: Buildings },
|
||||||
|
{ label: "Launch", icon: Rocket },
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{ label: "Create Company", icon: Buildings },
|
||||||
|
{ label: "Set Mission", icon: Target },
|
||||||
|
{ label: "Launch", icon: Rocket },
|
||||||
|
];
|
||||||
|
|
||||||
|
const stepIndex = step === "choose" ? 0 : step === "template" ? 1 : step === "company" ? 0 : step === "goal" ? 1 : 2;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid min-h-[100dvh] grid-cols-1 bg-[color:var(--bg)] md:grid-cols-[minmax(0,1fr)_480px]">
|
<div className="grid min-h-[100dvh] grid-cols-1 bg-[color:var(--bg)] md:grid-cols-[minmax(0,1fr)_520px]">
|
||||||
{/* Left panel — branding */}
|
{/* Left panel — branding */}
|
||||||
<div className="flex items-center px-8 py-12 md:px-16">
|
<div className="flex items-center px-8 py-12 md:px-16">
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -187,11 +261,7 @@ export function CompanyOnboarding({
|
|||||||
|
|
||||||
{/* Step indicators */}
|
{/* Step indicators */}
|
||||||
<div className="mt-10 flex items-center gap-3">
|
<div className="mt-10 flex items-center gap-3">
|
||||||
{[
|
{stepLabels.map((s, i) => (
|
||||||
{ label: "Create Company", icon: Buildings },
|
|
||||||
{ label: "Set Mission", icon: Target },
|
|
||||||
{ label: "Launch", icon: Rocket },
|
|
||||||
].map((s, i) => (
|
|
||||||
<div key={s.label} className="flex items-center gap-2">
|
<div key={s.label} className="flex items-center gap-2">
|
||||||
{i > 0 ? <div className={`h-px w-6 ${i <= stepIndex ? "bg-[color:var(--accent)]" : "bg-[color:var(--line)]"}`} /> : null}
|
{i > 0 ? <div className={`h-px w-6 ${i <= stepIndex ? "bg-[color:var(--accent)]" : "bg-[color:var(--line)]"}`} /> : null}
|
||||||
<div className={`flex items-center gap-2 rounded-full px-3 py-1.5 text-[11px] font-medium ${
|
<div className={`flex items-center gap-2 rounded-full px-3 py-1.5 text-[11px] font-medium ${
|
||||||
@@ -246,7 +316,183 @@ export function CompanyOnboarding({
|
|||||||
{/* Right panel — form steps */}
|
{/* Right panel — form steps */}
|
||||||
<div className="flex items-center border-l border-[color:var(--line)] bg-[color:var(--bg-subtle)] px-6 py-10 md:px-10">
|
<div className="flex items-center border-l border-[color:var(--line)] bg-[color:var(--bg-subtle)] px-6 py-10 md:px-10">
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
{step === "company" ? (
|
{/* ── Step: Choose Mode ────────────────────────── */}
|
||||||
|
{step === "choose" ? (
|
||||||
|
<motion.div
|
||||||
|
key="choose"
|
||||||
|
initial={{ opacity: 0, y: 18 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -18 }}
|
||||||
|
transition={{ duration: 0.35, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
className="w-full space-y-5"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 text-[10px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">How would you like to start?</div>
|
||||||
|
<div className="text-[18px] font-semibold tracking-[-0.01em] text-[color:var(--text)]">Choose your setup</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Template cards */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-[11px] font-semibold uppercase tracking-[0.06em] text-[color:var(--accent)]">Quick Start — Choose a template</div>
|
||||||
|
<div className="grid grid-cols-1 gap-2">
|
||||||
|
{templates.map((template) => {
|
||||||
|
const Icon = templateIcons[template.icon] ?? Cpu;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={template.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedTemplateId(template.id);
|
||||||
|
setCompanyName(template.name);
|
||||||
|
setStep("template");
|
||||||
|
}}
|
||||||
|
disabled={!hasReadyConnector}
|
||||||
|
className="focus-ring flex items-start gap-3 rounded-[10px] border border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-3 text-left transition hover:border-[color:var(--accent)] hover:shadow-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-[8px] bg-[color:var(--accent-soft)]">
|
||||||
|
<Icon size={18} className="text-[color:var(--accent)]" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-[13px] font-semibold text-[color:var(--text)]">{template.name}</div>
|
||||||
|
<div className="mt-0.5 text-[11px] leading-relaxed text-[color:var(--muted)]">{template.description}</div>
|
||||||
|
<div className="mt-1.5 flex gap-3 text-[10px] text-[color:var(--muted)]">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Users size={10} />
|
||||||
|
{template.agents.length} agent{template.agents.length !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Target size={10} />
|
||||||
|
{template.goals.length} goal{template.goals.length !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-px flex-1 bg-[color:var(--line)]" />
|
||||||
|
<span className="text-[10px] font-medium text-[color:var(--muted)]">OR</span>
|
||||||
|
<div className="h-px flex-1 bg-[color:var(--line)]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Custom option */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setStep("company")}
|
||||||
|
disabled={!hasReadyConnector}
|
||||||
|
className="focus-ring flex w-full items-center gap-3 rounded-[10px] border border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-4 text-left transition hover:border-[color:var(--line-strong)] hover:shadow-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-[8px] bg-[color:var(--panel-soft)]">
|
||||||
|
<Buildings size={18} className="text-[color:var(--muted-strong)]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[13px] font-semibold text-[color:var(--text)]">Custom Setup</div>
|
||||||
|
<div className="mt-0.5 text-[11px] text-[color:var(--muted)]">Create a company from scratch. Set your own name, goals, and let the CEO build the team.</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
) : step === "template" && selectedTemplate ? (
|
||||||
|
/* ── Step: Template Configuration ─────────────── */
|
||||||
|
<motion.div
|
||||||
|
key="template"
|
||||||
|
initial={{ opacity: 0, y: 18 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -18 }}
|
||||||
|
transition={{ duration: 0.35, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
className="surface w-full rounded-[12px] p-7"
|
||||||
|
>
|
||||||
|
<div className="mb-2 text-[10px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Template: {selectedTemplate.name}</div>
|
||||||
|
<div className="mb-1 text-[18px] font-semibold tracking-[-0.01em] text-[color:var(--text)]">Configure & Launch</div>
|
||||||
|
<div className="mb-6 h-px bg-[color:var(--line)]" />
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Input label="Company name" value={companyName} onChange={setCompanyName} placeholder="e.g. Acme AI Labs" />
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">
|
||||||
|
Workspace folder <span className="font-normal text-[color:var(--muted)]">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Input ariaLabel="Workspace folder" value={workspacePath} onChange={setWorkspacePath} placeholder="/Users/you/project" />
|
||||||
|
<ActionButton label="Choose" onClick={() => void pickDirectory()} icon={FolderOpen} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Team preview */}
|
||||||
|
<div className="mt-5 rounded-[8px] border border-[color:var(--line)] bg-[color:var(--panel-soft)] px-4 py-3">
|
||||||
|
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.06em] text-[color:var(--muted)]">Team that will be created</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{selectedTemplate.agents.map((agent, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2 text-[12px]">
|
||||||
|
<div className={`flex h-6 w-6 items-center justify-center rounded-full text-[8px] font-bold ${
|
||||||
|
agent.reportsToIndex === null ? "bg-[color:var(--accent-soft)] text-[color:var(--accent)]" : "bg-[color:var(--panel)] text-[color:var(--muted-strong)]"
|
||||||
|
}`}>
|
||||||
|
{agent.name.split(/\s+/).slice(0, 2).map(w => w[0]).join("").toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<span className="font-medium text-[color:var(--text)]">{agent.name}</span>
|
||||||
|
<span className="text-[color:var(--muted)]">— {agent.title}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex gap-3 text-[11px] text-[color:var(--muted)]">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<TreeStructure size={12} />
|
||||||
|
{selectedTemplate.agents.length} agents
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Target size={12} />
|
||||||
|
{selectedTemplate.goals.length} goals
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* What happens */}
|
||||||
|
<div className="mt-4 rounded-[8px] border border-[color:var(--line)] bg-[color:var(--panel-soft)] px-4 py-3">
|
||||||
|
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.06em] text-[color:var(--muted)]">What happens next</div>
|
||||||
|
<div className="space-y-2 text-[12px] text-[color:var(--muted)]">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<Users size={14} className="mt-0.5 shrink-0 text-[color:var(--accent)]" />
|
||||||
|
<span>All <strong className="text-[color:var(--text)]">{selectedTemplate.agents.length} agents</strong> are created with roles and budgets</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<Target size={14} className="mt-0.5 shrink-0 text-[color:var(--accent)]" />
|
||||||
|
<span>Goals are set and assigned to the <strong className="text-[color:var(--text)]">{selectedTemplate.agents[0]?.role ?? "CEO"}</strong></span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<Lightning size={14} className="mt-0.5 shrink-0 text-[color:var(--accent)]" />
|
||||||
|
<span>The team leader starts working <strong className="text-[color:var(--text)]">immediately</strong></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<div className="mt-5 rounded-[10px] border border-[color:var(--danger)] bg-[color:var(--danger-soft)] px-4 py-3 text-[13px] text-[color:var(--danger)]" role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="mt-8 flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setStep("choose"); setSelectedTemplateId(null); }}
|
||||||
|
className="text-[12px] font-medium text-[color:var(--muted)] transition hover:text-[color:var(--text)]"
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
<ActionButton
|
||||||
|
label={loading ? "Launching..." : "Launch Company"}
|
||||||
|
onClick={() => void launchFromTemplate()}
|
||||||
|
icon={Rocket}
|
||||||
|
tone="accent"
|
||||||
|
loading={loading}
|
||||||
|
disabled={!companyName.trim() || !hasReadyConnector}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
) : step === "company" ? (
|
||||||
|
/* ── Step: Custom Company ─────────────────────── */
|
||||||
<motion.div
|
<motion.div
|
||||||
key="company"
|
key="company"
|
||||||
initial={{ opacity: 0, y: 18 }}
|
initial={{ opacity: 0, y: 18 }}
|
||||||
@@ -255,7 +501,7 @@ export function CompanyOnboarding({
|
|||||||
transition={{ duration: 0.35, ease: [0.16, 1, 0.3, 1] }}
|
transition={{ duration: 0.35, ease: [0.16, 1, 0.3, 1] }}
|
||||||
className="surface w-full rounded-[12px] p-7"
|
className="surface w-full rounded-[12px] p-7"
|
||||||
>
|
>
|
||||||
<div className="mb-2 text-[10px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Step 1 of 3</div>
|
<div className="mb-2 text-[10px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Custom Setup — Step 1 of 3</div>
|
||||||
<div className="mb-1 text-[18px] font-semibold tracking-[-0.01em] text-[color:var(--text)]">Create your company</div>
|
<div className="mb-1 text-[18px] font-semibold tracking-[-0.01em] text-[color:var(--text)]">Create your company</div>
|
||||||
<div className="mb-6 h-px bg-[color:var(--line)]" />
|
<div className="mb-6 h-px bg-[color:var(--line)]" />
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -281,8 +527,6 @@ export function CompanyOnboarding({
|
|||||||
The directory where your agents will work. You can add more later.
|
The directory where your agents will work. You can add more later.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Auto-approve toggle */}
|
|
||||||
<div className="rounded-[8px] border border-[color:var(--line)] bg-[color:var(--panel-soft)] px-4 py-3">
|
<div className="rounded-[8px] border border-[color:var(--line)] bg-[color:var(--panel-soft)] px-4 py-3">
|
||||||
<label className="flex items-center gap-3">
|
<label className="flex items-center gap-3">
|
||||||
<input
|
<input
|
||||||
@@ -305,7 +549,14 @@ export function CompanyOnboarding({
|
|||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="mt-8 flex items-center justify-end gap-4">
|
<div className="mt-8 flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setStep("choose")}
|
||||||
|
className="text-[12px] font-medium text-[color:var(--muted)] transition hover:text-[color:var(--text)]"
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
<ActionButton
|
<ActionButton
|
||||||
label={loading ? "Creating..." : "Continue"}
|
label={loading ? "Creating..." : "Continue"}
|
||||||
onClick={() => void createCompany()}
|
onClick={() => void createCompany()}
|
||||||
@@ -317,6 +568,7 @@ export function CompanyOnboarding({
|
|||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
) : step === "goal" ? (
|
) : step === "goal" ? (
|
||||||
|
/* ── Step: Goal ───────────────────────────────── */
|
||||||
<motion.div
|
<motion.div
|
||||||
key="goal"
|
key="goal"
|
||||||
initial={{ opacity: 0, y: 18 }}
|
initial={{ opacity: 0, y: 18 }}
|
||||||
@@ -325,7 +577,7 @@ export function CompanyOnboarding({
|
|||||||
transition={{ duration: 0.35, ease: [0.16, 1, 0.3, 1] }}
|
transition={{ duration: 0.35, ease: [0.16, 1, 0.3, 1] }}
|
||||||
className="surface w-full rounded-[12px] p-7"
|
className="surface w-full rounded-[12px] p-7"
|
||||||
>
|
>
|
||||||
<div className="mb-2 text-[10px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Step 2 of 3</div>
|
<div className="mb-2 text-[10px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Custom Setup — Step 2 of 3</div>
|
||||||
<div className="mb-1 text-[18px] font-semibold tracking-[-0.01em] text-[color:var(--text)]">Set your company's mission</div>
|
<div className="mb-1 text-[18px] font-semibold tracking-[-0.01em] text-[color:var(--text)]">Set your company's mission</div>
|
||||||
<div className="mb-2 text-[13px] text-[color:var(--muted)]">
|
<div className="mb-2 text-[13px] text-[color:var(--muted)]">
|
||||||
What should your AI company accomplish? The CEO agent will use this to build a team and start working.
|
What should your AI company accomplish? The CEO agent will use this to build a team and start working.
|
||||||
@@ -346,8 +598,6 @@ export function CompanyOnboarding({
|
|||||||
rows={4}
|
rows={4}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Preview: what will happen */}
|
|
||||||
<div className="mt-5 rounded-[8px] border border-[color:var(--line)] bg-[color:var(--panel-soft)] px-4 py-3">
|
<div className="mt-5 rounded-[8px] border border-[color:var(--line)] bg-[color:var(--panel-soft)] px-4 py-3">
|
||||||
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.06em] text-[color:var(--muted)]">What happens next</div>
|
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.06em] text-[color:var(--muted)]">What happens next</div>
|
||||||
<div className="space-y-2 text-[12px] text-[color:var(--muted)]">
|
<div className="space-y-2 text-[12px] text-[color:var(--muted)]">
|
||||||
@@ -365,7 +615,6 @@ export function CompanyOnboarding({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
<div className="mt-5 rounded-[10px] border border-[color:var(--danger)] bg-[color:var(--danger-soft)] px-4 py-3 text-[13px] text-[color:var(--danger)]" role="alert">
|
<div className="mt-5 rounded-[10px] border border-[color:var(--danger)] bg-[color:var(--danger-soft)] px-4 py-3 text-[13px] text-[color:var(--danger)]" role="alert">
|
||||||
{error}
|
{error}
|
||||||
@@ -390,6 +639,7 @@ export function CompanyOnboarding({
|
|||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
) : (
|
) : (
|
||||||
|
/* ── Step: Launch Success ─────────────────────── */
|
||||||
<motion.div
|
<motion.div
|
||||||
key="launch"
|
key="launch"
|
||||||
initial={{ opacity: 0, scale: 0.95 }}
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
@@ -409,7 +659,9 @@ export function CompanyOnboarding({
|
|||||||
{companyName} is live!
|
{companyName} is live!
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mx-auto mt-3 max-w-[340px] text-[14px] leading-relaxed text-[color:var(--muted)]">
|
<p className="mx-auto mt-3 max-w-[340px] text-[14px] leading-relaxed text-[color:var(--muted)]">
|
||||||
Your CEO agent is starting up. Watch the dashboard as your company comes to life.
|
{selectedTemplate
|
||||||
|
? `Your ${selectedTemplate.agents.length}-agent team is starting up. Watch the dashboard as your company comes to life.`
|
||||||
|
: "Your CEO agent is starting up. Watch the dashboard as your company comes to life."}
|
||||||
</p>
|
</p>
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
@@ -419,7 +671,7 @@ export function CompanyOnboarding({
|
|||||||
>
|
>
|
||||||
<div className="flex items-center justify-center gap-2 text-[12px] text-[color:var(--muted)]">
|
<div className="flex items-center justify-center gap-2 text-[12px] text-[color:var(--muted)]">
|
||||||
<span className="h-2 w-2 animate-pulse rounded-full bg-[color:var(--success)]" />
|
<span className="h-2 w-2 animate-pulse rounded-full bg-[color:var(--success)]" />
|
||||||
CEO agent initializing...
|
{selectedTemplate ? "Team initializing..." : "CEO agent initializing..."}
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -3,15 +3,22 @@ import { motion } from "framer-motion";
|
|||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Briefcase,
|
Briefcase,
|
||||||
|
Buildings,
|
||||||
|
CalendarCheck,
|
||||||
|
ChartLineUp,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
|
Clock,
|
||||||
Cpu,
|
Cpu,
|
||||||
CurrencyCircleDollar,
|
CurrencyCircleDollar,
|
||||||
Heartbeat,
|
Heartbeat,
|
||||||
Lightning,
|
Lightning,
|
||||||
Robot,
|
Robot,
|
||||||
|
Rocket,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Target,
|
Target,
|
||||||
TreeStructure,
|
TreeStructure,
|
||||||
|
TrendUp,
|
||||||
|
Users,
|
||||||
Warning,
|
Warning,
|
||||||
XCircle,
|
XCircle,
|
||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react";
|
||||||
@@ -21,10 +28,13 @@ import type {
|
|||||||
ApprovalRecord,
|
ApprovalRecord,
|
||||||
ConnectorRecord,
|
ConnectorRecord,
|
||||||
CostRecord,
|
CostRecord,
|
||||||
|
DepartmentId,
|
||||||
GoalRecord,
|
GoalRecord,
|
||||||
|
MeetingRecord,
|
||||||
ProjectRecord,
|
ProjectRecord,
|
||||||
RunRecord,
|
RunRecord,
|
||||||
SectionId,
|
SectionId,
|
||||||
|
SprintRecord,
|
||||||
TaskRecord,
|
TaskRecord,
|
||||||
} from "@shared/types";
|
} from "@shared/types";
|
||||||
import { formatMoney } from "../lib/formatters";
|
import { formatMoney } from "../lib/formatters";
|
||||||
@@ -44,6 +54,8 @@ interface DashboardProps {
|
|||||||
activity: ActivityRecord[];
|
activity: ActivityRecord[];
|
||||||
costs: CostRecord[];
|
costs: CostRecord[];
|
||||||
connectors: ConnectorRecord[];
|
connectors: ConnectorRecord[];
|
||||||
|
meetings?: MeetingRecord[];
|
||||||
|
sprints?: SprintRecord[];
|
||||||
onNavigate: (section: SectionId, entityId?: string) => void;
|
onNavigate: (section: SectionId, entityId?: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -393,12 +405,241 @@ function TaskPipeline({ tasks }: { tasks: TaskRecord[] }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Department Overview */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
const DEPARTMENT_INFO: Record<string, { label: string; icon: React.ElementType; color: string }> = {
|
||||||
|
executive: { label: "Executive", icon: Buildings, color: "var(--accent)" },
|
||||||
|
engineering: { label: "Engineering", icon: Cpu, color: "#6366f1" },
|
||||||
|
product: { label: "Product", icon: Rocket, color: "#8b5cf6" },
|
||||||
|
design: { label: "Design", icon: Target, color: "#ec4899" },
|
||||||
|
marketing: { label: "Marketing", icon: TrendUp, color: "#f59e0b" },
|
||||||
|
sales: { label: "Sales", icon: ChartLineUp, color: "#10b981" },
|
||||||
|
hr: { label: "People", icon: Users, color: "#06b6d4" },
|
||||||
|
finance: { label: "Finance", icon: CurrencyCircleDollar, color: "#14b8a6" },
|
||||||
|
legal: { label: "Legal", icon: ShieldCheck, color: "#64748b" },
|
||||||
|
operations: { label: "Operations", icon: Lightning, color: "#f97316" },
|
||||||
|
customer_support: { label: "Support", icon: Heartbeat, color: "#3b82f6" },
|
||||||
|
research: { label: "Research", icon: Target, color: "#a855f7" },
|
||||||
|
};
|
||||||
|
|
||||||
|
function DepartmentOverview({
|
||||||
|
agents,
|
||||||
|
tasks,
|
||||||
|
runs,
|
||||||
|
onNavigate,
|
||||||
|
}: {
|
||||||
|
agents: AgentRecord[];
|
||||||
|
tasks: TaskRecord[];
|
||||||
|
runs: RunRecord[];
|
||||||
|
onNavigate: (section: SectionId) => void;
|
||||||
|
}) {
|
||||||
|
const departments = useMemo(() => {
|
||||||
|
const depts = new Map<string, { agents: AgentRecord[]; tasks: TaskRecord[]; activeRuns: number }>();
|
||||||
|
for (const agent of agents) {
|
||||||
|
if (agent.status === "terminated") continue;
|
||||||
|
const dept = agent.department ?? "unassigned";
|
||||||
|
if (!depts.has(dept)) depts.set(dept, { agents: [], tasks: [], activeRuns: 0 });
|
||||||
|
depts.get(dept)!.agents.push(agent);
|
||||||
|
}
|
||||||
|
for (const task of tasks) {
|
||||||
|
if (task.assigneeAgentId) {
|
||||||
|
const agent = agents.find((a) => a.id === task.assigneeAgentId);
|
||||||
|
if (agent?.department) {
|
||||||
|
const dept = depts.get(agent.department);
|
||||||
|
if (dept) dept.tasks.push(task);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const run of runs) {
|
||||||
|
if (run.status === "running") {
|
||||||
|
const agent = agents.find((a) => a.id === run.agentId);
|
||||||
|
if (agent?.department) {
|
||||||
|
const dept = depts.get(agent.department);
|
||||||
|
if (dept) dept.activeRuns++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(depts.entries())
|
||||||
|
.filter(([key]) => key !== "unassigned")
|
||||||
|
.sort((a, b) => b[1].agents.length - a[1].agents.length);
|
||||||
|
}, [agents, tasks, runs]);
|
||||||
|
|
||||||
|
if (departments.length <= 1) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6">
|
||||||
|
{departments.map(([deptId, data]) => {
|
||||||
|
const info = DEPARTMENT_INFO[deptId] ?? { label: deptId, icon: Buildings, color: "var(--muted)" };
|
||||||
|
const Icon = info.icon;
|
||||||
|
const activeTasks = data.tasks.filter((t) => t.status === "in_progress" || t.status === "in_review").length;
|
||||||
|
const doneTasks = data.tasks.filter((t) => t.status === "done").length;
|
||||||
|
const blockedTasks = data.tasks.filter((t) => t.status === "blocked").length;
|
||||||
|
const runningAgents = data.agents.filter((a) => a.status === "running").length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={deptId}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onNavigate("agents")}
|
||||||
|
className="focus-ring group relative overflow-hidden rounded-[10px] border border-[color:var(--line)] bg-[color:var(--panel)] p-3 text-left transition hover:border-[color:var(--line-strong)] hover:shadow-sm"
|
||||||
|
>
|
||||||
|
{/* Accent bar */}
|
||||||
|
<div className="absolute left-0 top-0 h-full w-[3px]" style={{ backgroundColor: info.color }} />
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 pl-1">
|
||||||
|
<Icon size={14} style={{ color: info.color }} />
|
||||||
|
<span className="text-[11px] font-semibold text-[color:var(--text)]">{info.label}</span>
|
||||||
|
{runningAgents > 0 && (
|
||||||
|
<span className="ml-auto flex items-center gap-1">
|
||||||
|
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-[color:var(--success)]" />
|
||||||
|
<span className="text-[9px] text-[color:var(--success)]">{runningAgents}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 grid grid-cols-3 gap-1 pl-1">
|
||||||
|
<div>
|
||||||
|
<div className="text-[14px] font-bold text-[color:var(--text)]">{data.agents.length}</div>
|
||||||
|
<div className="text-[8px] uppercase tracking-[0.1em] text-[color:var(--muted)]">People</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[14px] font-bold text-[color:var(--accent)]">{activeTasks}</div>
|
||||||
|
<div className="text-[8px] uppercase tracking-[0.1em] text-[color:var(--muted)]">Active</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className={`text-[14px] font-bold ${blockedTasks > 0 ? "text-[color:var(--danger)]" : "text-[color:var(--success)]"}`}>
|
||||||
|
{blockedTasks > 0 ? blockedTasks : doneTasks}
|
||||||
|
</div>
|
||||||
|
<div className="text-[8px] uppercase tracking-[0.1em] text-[color:var(--muted)]">
|
||||||
|
{blockedTasks > 0 ? "Blocked" : "Done"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Sprint Progress */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
function SprintProgress({ sprint, tasks }: { sprint: SprintRecord; tasks: TaskRecord[] }) {
|
||||||
|
const progress = useMemo(() => {
|
||||||
|
if (sprint.velocityPoints === 0) return 0;
|
||||||
|
return Math.min(100, Math.round((sprint.completedPoints / sprint.velocityPoints) * 100));
|
||||||
|
}, [sprint]);
|
||||||
|
|
||||||
|
const daysLeft = useMemo(() => {
|
||||||
|
const end = new Date(sprint.endDate).getTime();
|
||||||
|
const now = Date.now();
|
||||||
|
return Math.max(0, Math.ceil((end - now) / (1000 * 60 * 60 * 24)));
|
||||||
|
}, [sprint]);
|
||||||
|
|
||||||
|
const sprintTasks = useMemo(() => {
|
||||||
|
const done = tasks.filter((t) => t.status === "done").length;
|
||||||
|
const active = tasks.filter((t) => t.status === "in_progress" || t.status === "in_review").length;
|
||||||
|
const total = tasks.length;
|
||||||
|
return { done, active, total };
|
||||||
|
}, [tasks]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-[10px] border border-[color:var(--line)] bg-[color:var(--panel)] p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CalendarCheck size={16} className="text-[color:var(--accent)]" />
|
||||||
|
<span className="text-[12px] font-semibold text-[color:var(--text)]">{sprint.name}</span>
|
||||||
|
<StatusPill status={sprint.status} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 text-[11px] text-[color:var(--muted)]">
|
||||||
|
<Clock size={12} />
|
||||||
|
{daysLeft} days left
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div className="mt-3 h-2 w-full overflow-hidden rounded-full bg-[color:var(--panel-soft)]">
|
||||||
|
<motion.div
|
||||||
|
className="h-full rounded-full bg-[color:var(--accent)]"
|
||||||
|
initial={{ width: 0 }}
|
||||||
|
animate={{ width: `${progress}%` }}
|
||||||
|
transition={{ duration: 0.8, ease: "easeOut" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 flex items-center justify-between text-[10px] text-[color:var(--muted)]">
|
||||||
|
<span>{sprint.completedPoints}/{sprint.velocityPoints} points ({progress}%)</span>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-[color:var(--success)]">{sprintTasks.done} done</span>
|
||||||
|
<span className="text-[color:var(--accent)]">{sprintTasks.active} active</span>
|
||||||
|
<span>{sprintTasks.total} total</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sprint.goal && (
|
||||||
|
<div className="mt-2 text-[11px] italic text-[color:var(--muted)]">"{sprint.goal}"</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Goal Progress Bars */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
function GoalProgress({ goals, tasks, onNavigate }: { goals: GoalRecord[]; tasks: TaskRecord[]; onNavigate: (section: SectionId) => void }) {
|
||||||
|
const activeGoals = useMemo(() => goals.filter((g) => g.status === "active"), [goals]);
|
||||||
|
|
||||||
|
if (activeGoals.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{activeGoals.slice(0, 5).map((goal) => {
|
||||||
|
const goalTasks = tasks.filter((t) => t.goalId === goal.id);
|
||||||
|
const done = goalTasks.filter((t) => t.status === "done").length;
|
||||||
|
const total = goalTasks.length;
|
||||||
|
const pct = total === 0 ? 0 : Math.round((done / total) * 100);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={goal.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onNavigate("goals")}
|
||||||
|
className="focus-ring w-full rounded-[8px] border border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-3 text-left transition hover:border-[color:var(--line-strong)]"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Target size={14} className="text-[color:var(--accent)]" />
|
||||||
|
<span className="text-[12px] font-medium text-[color:var(--text)]">{goal.title}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[11px] font-bold text-[color:var(--accent)]">{pct}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-[color:var(--panel-soft)]">
|
||||||
|
<motion.div
|
||||||
|
className="h-full rounded-full bg-[color:var(--accent)]"
|
||||||
|
initial={{ width: 0 }}
|
||||||
|
animate={{ width: `${pct}%` }}
|
||||||
|
transition={{ duration: 0.8, ease: "easeOut" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-[10px] text-[color:var(--muted)]">{done}/{total} tasks complete</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Main Dashboard */
|
/* Main Dashboard */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
export function Dashboard(props: DashboardProps) {
|
export function Dashboard(props: DashboardProps) {
|
||||||
const { agents, tasks, runs, approvals, goals, projects, activity, costs, connectors, onNavigate } = props;
|
const { agents, tasks, runs, approvals, goals, projects, activity, costs, connectors, meetings = [], sprints = [], onNavigate } = props;
|
||||||
|
|
||||||
const healthScore = useMemo(
|
const healthScore = useMemo(
|
||||||
() => computeHealthScore({ agents, tasks, runs, approvals, connectors }),
|
() => computeHealthScore({ agents, tasks, runs, approvals, connectors }),
|
||||||
@@ -413,6 +654,13 @@ export function Dashboard(props: DashboardProps) {
|
|||||||
const totalSpend = useMemo(() => costs.reduce((sum, c) => sum + (c.amountUsd ?? 0), 0), [costs]);
|
const totalSpend = useMemo(() => costs.reduce((sum, c) => sum + (c.amountUsd ?? 0), 0), [costs]);
|
||||||
const activeAgents = useMemo(() => agents.filter((a) => a.status !== "terminated" && a.status !== "pending_approval"), [agents]);
|
const activeAgents = useMemo(() => agents.filter((a) => a.status !== "terminated" && a.status !== "pending_approval"), [agents]);
|
||||||
const readyConnectors = useMemo(() => connectors.filter((c) => c.status === "ready"), [connectors]);
|
const readyConnectors = useMemo(() => connectors.filter((c) => c.status === "ready"), [connectors]);
|
||||||
|
const activeSprint = useMemo(() => sprints.find((s) => s.status === "active"), [sprints]);
|
||||||
|
const completionRate = useMemo(() => {
|
||||||
|
const total = runs.length;
|
||||||
|
if (total === 0) return 0;
|
||||||
|
const succeeded = runs.filter((r) => r.status === "succeeded").length;
|
||||||
|
return Math.round((succeeded / total) * 100);
|
||||||
|
}, [runs]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
@@ -488,7 +736,55 @@ export function Dashboard(props: DashboardProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Row 2: Task Pipeline */}
|
{/* Row 2: Department Overview */}
|
||||||
|
<section>
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<div className="text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Departments</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onNavigate("orgchart")}
|
||||||
|
className="flex items-center gap-1 text-[11px] font-medium text-[color:var(--accent)] transition hover:underline"
|
||||||
|
>
|
||||||
|
Org chart <TreeStructure size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<DepartmentOverview agents={agents} tasks={tasks} runs={runs} onNavigate={onNavigate} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Row 3: Sprint + Goals side by side */}
|
||||||
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
{activeSprint ? (
|
||||||
|
<section>
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<div className="text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Current Sprint</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onNavigate("sprints")}
|
||||||
|
className="flex items-center gap-1 text-[11px] font-medium text-[color:var(--accent)] transition hover:underline"
|
||||||
|
>
|
||||||
|
Manage <ArrowRight size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<SprintProgress sprint={activeSprint} tasks={tasks} />
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<div className="text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Goal Progress</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onNavigate("goals")}
|
||||||
|
className="flex items-center gap-1 text-[11px] font-medium text-[color:var(--accent)] transition hover:underline"
|
||||||
|
>
|
||||||
|
All goals <ArrowRight size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<GoalProgress goals={goals} tasks={tasks} onNavigate={onNavigate} />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 4: Task Pipeline */}
|
||||||
<section>
|
<section>
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<div className="mb-3 flex items-center justify-between">
|
||||||
<div className="text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Task Pipeline</div>
|
<div className="text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Task Pipeline</div>
|
||||||
@@ -505,7 +801,7 @@ export function Dashboard(props: DashboardProps) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Row 3: Workforce + Activity */}
|
{/* Row 5: Workforce + Activity */}
|
||||||
<div className="grid gap-6 lg:grid-cols-5">
|
<div className="grid gap-6 lg:grid-cols-5">
|
||||||
{/* Workforce */}
|
{/* Workforce */}
|
||||||
<section className="lg:col-span-3">
|
<section className="lg:col-span-3">
|
||||||
|
|||||||
@@ -0,0 +1,339 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
ArrowRight,
|
||||||
|
Check,
|
||||||
|
UserPlus,
|
||||||
|
X,
|
||||||
|
} from "@phosphor-icons/react";
|
||||||
|
import type { AgentRecord, ApprovalRecord } from "@shared/types";
|
||||||
|
import { ActionButton, EmptyState, Panel, StatusPill } from "./ui";
|
||||||
|
|
||||||
|
interface HiringPipelineProps {
|
||||||
|
companyId: string;
|
||||||
|
agents: AgentRecord[];
|
||||||
|
approvals: ApprovalRecord[];
|
||||||
|
onRefresh: () => Promise<void>;
|
||||||
|
onNavigateToAgent: (agentId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type HiringStage = "requisition" | "pending_approval" | "approved" | "onboarding" | "active" | "rejected";
|
||||||
|
|
||||||
|
interface HiringCandidate {
|
||||||
|
agent: AgentRecord;
|
||||||
|
approval: ApprovalRecord | null;
|
||||||
|
stage: HiringStage;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHiringStage(agent: AgentRecord, approval: ApprovalRecord | null): HiringStage {
|
||||||
|
if (agent.status === "pending_approval") {
|
||||||
|
if (!approval) return "requisition";
|
||||||
|
if (approval.state === "pending") return "pending_approval";
|
||||||
|
if (approval.state === "rejected") return "rejected";
|
||||||
|
if (approval.state === "revision_requested") return "requisition";
|
||||||
|
return "approved";
|
||||||
|
}
|
||||||
|
if (agent.status === "idle" && approval?.state === "approved") {
|
||||||
|
// Recently approved — check if they have any tasks yet
|
||||||
|
return "onboarding";
|
||||||
|
}
|
||||||
|
return "active";
|
||||||
|
}
|
||||||
|
|
||||||
|
function stageLabel(stage: HiringStage): string {
|
||||||
|
switch (stage) {
|
||||||
|
case "requisition": return "Requisition";
|
||||||
|
case "pending_approval": return "Pending Approval";
|
||||||
|
case "approved": return "Approved";
|
||||||
|
case "onboarding": return "Onboarding";
|
||||||
|
case "active": return "Active";
|
||||||
|
case "rejected": return "Rejected";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stageColor(stage: HiringStage): string {
|
||||||
|
switch (stage) {
|
||||||
|
case "requisition": return "bg-amber-500/20 text-amber-400";
|
||||||
|
case "pending_approval": return "bg-yellow-500/20 text-yellow-400";
|
||||||
|
case "approved": return "bg-green-500/20 text-green-400";
|
||||||
|
case "onboarding": return "bg-blue-500/20 text-blue-400";
|
||||||
|
case "active": return "bg-emerald-500/20 text-emerald-400";
|
||||||
|
case "rejected": return "bg-red-500/20 text-red-400";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const STAGE_ORDER: HiringStage[] = ["requisition", "pending_approval", "approved", "onboarding", "active", "rejected"];
|
||||||
|
|
||||||
|
export function HiringPipeline({ companyId: _companyId, agents, approvals, onRefresh, onNavigateToAgent }: HiringPipelineProps) {
|
||||||
|
const [filterStage, setFilterStage] = useState<HiringStage | "all">("all");
|
||||||
|
|
||||||
|
const hireApprovals = approvals.filter(a => a.type === "hire_agent");
|
||||||
|
|
||||||
|
const candidates: HiringCandidate[] = agents.map(agent => {
|
||||||
|
const approval = hireApprovals.find(a =>
|
||||||
|
a.relatedAgentId === agent.id ||
|
||||||
|
(a.payloadJson && (() => { try { const p = JSON.parse(a.payloadJson); return p.agentId === agent.id; } catch { return false; } })())
|
||||||
|
) ?? null;
|
||||||
|
return {
|
||||||
|
agent,
|
||||||
|
approval,
|
||||||
|
stage: getHiringStage(agent, approval),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also include hire approvals that don't yet have an agent (pre-creation stage)
|
||||||
|
const pendingHireApprovals = hireApprovals.filter(a => {
|
||||||
|
return a.state === "pending" && !candidates.some(c => c.approval?.id === a.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
const allCandidates = [
|
||||||
|
...candidates,
|
||||||
|
...pendingHireApprovals.map(a => ({
|
||||||
|
agent: null as unknown as AgentRecord,
|
||||||
|
approval: a,
|
||||||
|
stage: "pending_approval" as HiringStage,
|
||||||
|
})),
|
||||||
|
].sort((a, b) => STAGE_ORDER.indexOf(a.stage) - STAGE_ORDER.indexOf(b.stage));
|
||||||
|
|
||||||
|
const filteredCandidates = filterStage === "all"
|
||||||
|
? allCandidates
|
||||||
|
: allCandidates.filter(c => c.stage === filterStage);
|
||||||
|
|
||||||
|
// Stage counts for the pipeline overview
|
||||||
|
const stageCounts = STAGE_ORDER.reduce((acc, stage) => {
|
||||||
|
acc[stage] = allCandidates.filter(c => c.stage === stage).length;
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<HiringStage, number>);
|
||||||
|
|
||||||
|
const pendingCount = stageCounts.requisition + stageCounts.pending_approval;
|
||||||
|
const activeHires = allCandidates.filter(c => c.stage !== "active" && c.stage !== "rejected").length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Pipeline Overview */}
|
||||||
|
<Panel title="Hiring Pipeline" eyebrow={`${activeHires} active hiring processes`}>
|
||||||
|
<div className="flex items-center gap-1 px-4 py-3 overflow-x-auto">
|
||||||
|
{STAGE_ORDER.filter(s => s !== "rejected").map((stage, idx) => (
|
||||||
|
<div key={stage} className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setFilterStage(filterStage === stage ? "all" : stage)}
|
||||||
|
className={`px-3 py-2 rounded-md text-xs font-medium transition-colors whitespace-nowrap ${
|
||||||
|
filterStage === stage ? "ring-1 ring-orange-400/40 bg-zinc-700" : "hover:bg-zinc-700/60"
|
||||||
|
} ${stageColor(stage)}`}
|
||||||
|
>
|
||||||
|
{stageLabel(stage)}
|
||||||
|
<span className="ml-1.5 inline-flex items-center justify-center h-5 w-5 rounded-full bg-zinc-800 text-[10px]">
|
||||||
|
{stageCounts[stage]}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{idx < STAGE_ORDER.length - 2 && (
|
||||||
|
<ArrowRight size={12} className="text-zinc-600 flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{stageCounts.rejected > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setFilterStage(filterStage === "rejected" ? "all" : "rejected")}
|
||||||
|
className={`ml-4 px-3 py-2 rounded-md text-xs font-medium transition-colors ${
|
||||||
|
filterStage === "rejected" ? "ring-1 ring-red-400/40 bg-zinc-700" : "hover:bg-zinc-700/60"
|
||||||
|
} ${stageColor("rejected")}`}
|
||||||
|
>
|
||||||
|
Rejected
|
||||||
|
<span className="ml-1.5 inline-flex items-center justify-center h-5 w-5 rounded-full bg-zinc-800 text-[10px]">
|
||||||
|
{stageCounts.rejected}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
{/* Pending Approvals Section */}
|
||||||
|
{pendingCount > 0 && (
|
||||||
|
<Panel title="Awaiting Board Decision" eyebrow={`${pendingCount} hire requests need your review`}>
|
||||||
|
<div className="divide-y divide-zinc-800/60">
|
||||||
|
{allCandidates
|
||||||
|
.filter(c => c.stage === "requisition" || c.stage === "pending_approval")
|
||||||
|
.map((candidate) => {
|
||||||
|
const a = candidate.approval;
|
||||||
|
const ag = candidate.agent;
|
||||||
|
return (
|
||||||
|
<div key={a?.id ?? ag?.id} className="px-4 py-3 flex items-start gap-3">
|
||||||
|
<div className="flex-shrink-0 mt-0.5 h-8 w-8 rounded-full bg-amber-500/15 flex items-center justify-center">
|
||||||
|
<UserPlus size={16} className="text-amber-400" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium text-sm text-zinc-200">{ag?.name ?? "Proposed Agent"}</span>
|
||||||
|
<span className={`text-[10px] px-1.5 py-0.5 rounded ${stageColor(candidate.stage)}`}>
|
||||||
|
{stageLabel(candidate.stage)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-zinc-400 mt-0.5">
|
||||||
|
{ag ? `${ag.role} — ${ag.title}` : a?.payloadSummary}
|
||||||
|
</div>
|
||||||
|
{ag?.department && (
|
||||||
|
<div className="text-xs text-zinc-500 mt-0.5">
|
||||||
|
Dept: {ag.department} | Budget: ${ag.budgetMonthlyUsd}/mo | Connector: {ag.connectorId}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{a?.payloadSummary && (
|
||||||
|
<div className="text-xs text-zinc-500 mt-1">{a.payloadSummary}</div>
|
||||||
|
)}
|
||||||
|
{a?.impactSummary && (
|
||||||
|
<div className="text-xs text-zinc-500">{a.impactSummary}</div>
|
||||||
|
)}
|
||||||
|
{ag?.capabilities && (
|
||||||
|
<div className="text-xs text-zinc-600 mt-1 line-clamp-2">{ag.capabilities}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||||
|
{a && a.state === "pending" && (
|
||||||
|
<>
|
||||||
|
<ActionButton
|
||||||
|
label="Approve"
|
||||||
|
icon={Check}
|
||||||
|
tone="accent"
|
||||||
|
onClick={async () => {
|
||||||
|
await window.agentCompany.decideApproval({
|
||||||
|
approvalId: a.id,
|
||||||
|
state: "approved",
|
||||||
|
decisionNote: "Hire approved via hiring pipeline",
|
||||||
|
});
|
||||||
|
await onRefresh();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ActionButton
|
||||||
|
label="Reject"
|
||||||
|
icon={X}
|
||||||
|
tone="danger"
|
||||||
|
onClick={async () => {
|
||||||
|
await window.agentCompany.decideApproval({
|
||||||
|
approvalId: a.id,
|
||||||
|
state: "rejected",
|
||||||
|
decisionNote: "Hire rejected via hiring pipeline",
|
||||||
|
});
|
||||||
|
await onRefresh();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Full Pipeline List */}
|
||||||
|
<Panel
|
||||||
|
title={filterStage === "all" ? "All Agents" : `${stageLabel(filterStage)} (${filteredCandidates.length})`}
|
||||||
|
action={
|
||||||
|
filterStage !== "all" ? (
|
||||||
|
<ActionButton label="Show all" icon={X} onClick={() => setFilterStage("all")} />
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{filteredCandidates.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title="No hiring activity"
|
||||||
|
detail="When agents propose new hires via the API, they will appear here for your review."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-zinc-800/60">
|
||||||
|
{filteredCandidates.map((candidate) => {
|
||||||
|
const ag = candidate.agent;
|
||||||
|
if (!ag) return null;
|
||||||
|
const manager = agents.find(a => a.id === ag.reportsTo);
|
||||||
|
const requestedBy = candidate.approval?.requestedByAgentId
|
||||||
|
? agents.find(a => a.id === candidate.approval!.requestedByAgentId)
|
||||||
|
: null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={ag.id}
|
||||||
|
className="px-4 py-3 flex items-center gap-3 hover:bg-zinc-800/40 cursor-pointer transition-colors"
|
||||||
|
onClick={() => onNavigateToAgent(ag.id)}
|
||||||
|
>
|
||||||
|
<div className="flex-shrink-0 h-8 w-8 rounded-full bg-zinc-700/60 flex items-center justify-center text-xs font-bold text-zinc-300">
|
||||||
|
{ag.name.split(" ").map(n => n[0]).join("").slice(0, 2)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium text-sm text-zinc-200 truncate">{ag.name}</span>
|
||||||
|
<span className={`text-[10px] px-1.5 py-0.5 rounded ${stageColor(candidate.stage)}`}>
|
||||||
|
{stageLabel(candidate.stage)}
|
||||||
|
</span>
|
||||||
|
<StatusPill status={ag.status} />
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-zinc-400 mt-0.5">
|
||||||
|
{ag.role} — {ag.title}{ag.department ? ` | ${ag.department}` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-shrink-0 text-right">
|
||||||
|
<div className="text-xs text-zinc-500">
|
||||||
|
{manager ? `Reports to ${manager.name}` : "Top level"}
|
||||||
|
</div>
|
||||||
|
{requestedBy && (
|
||||||
|
<div className="text-[10px] text-zinc-600">Proposed by {requestedBy.name}</div>
|
||||||
|
)}
|
||||||
|
<div className="text-xs text-zinc-500">${ag.budgetMonthlyUsd}/mo</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
{/* Hiring Statistics */}
|
||||||
|
<div className="grid grid-cols-4 gap-3">
|
||||||
|
<div className="bg-zinc-900/60 rounded-lg px-4 py-3 border border-zinc-800/40">
|
||||||
|
<div className="text-[10px] uppercase text-zinc-500 tracking-wider">Total Agents</div>
|
||||||
|
<div className="text-2xl font-bold text-zinc-200 mt-1">{agents.filter(a => a.status !== "terminated").length}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-zinc-900/60 rounded-lg px-4 py-3 border border-zinc-800/40">
|
||||||
|
<div className="text-[10px] uppercase text-zinc-500 tracking-wider">Pending Hires</div>
|
||||||
|
<div className="text-2xl font-bold text-amber-400 mt-1">{agents.filter(a => a.status === "pending_approval").length}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-zinc-900/60 rounded-lg px-4 py-3 border border-zinc-800/40">
|
||||||
|
<div className="text-[10px] uppercase text-zinc-500 tracking-wider">Departments</div>
|
||||||
|
<div className="text-2xl font-bold text-zinc-200 mt-1">
|
||||||
|
{new Set(agents.filter(a => a.department && a.status !== "terminated").map(a => a.department)).size}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-zinc-900/60 rounded-lg px-4 py-3 border border-zinc-800/40">
|
||||||
|
<div className="text-[10px] uppercase text-zinc-500 tracking-wider">Monthly Payroll</div>
|
||||||
|
<div className="text-2xl font-bold text-zinc-200 mt-1">
|
||||||
|
${agents.filter(a => a.status !== "terminated" && a.status !== "pending_approval").reduce((s, a) => s + a.budgetMonthlyUsd, 0).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Department Headcount */}
|
||||||
|
<Panel title="Department Headcount">
|
||||||
|
<div className="grid grid-cols-3 gap-2 p-4">
|
||||||
|
{(["executive", "engineering", "product", "design", "marketing", "sales", "hr", "finance", "legal", "customer_support", "operations", "research"] as const).map(dept => {
|
||||||
|
const deptAgents = agents.filter(a => a.department === dept && a.status !== "terminated");
|
||||||
|
const pending = deptAgents.filter(a => a.status === "pending_approval").length;
|
||||||
|
const active = deptAgents.filter(a => a.status !== "pending_approval").length;
|
||||||
|
if (active === 0 && pending === 0) return null;
|
||||||
|
return (
|
||||||
|
<div key={dept} className="bg-zinc-800/40 rounded-md px-3 py-2">
|
||||||
|
<div className="text-xs font-medium text-zinc-300 capitalize">{dept.replace("_", " ")}</div>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<span className="text-sm font-bold text-zinc-200">{active}</span>
|
||||||
|
<span className="text-[10px] text-zinc-500">active</span>
|
||||||
|
{pending > 0 && (
|
||||||
|
<>
|
||||||
|
<span className="text-sm font-bold text-amber-400">+{pending}</span>
|
||||||
|
<span className="text-[10px] text-amber-400/70">pending</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,537 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import {
|
||||||
|
Bell,
|
||||||
|
ShieldCheck,
|
||||||
|
Warning,
|
||||||
|
XCircle,
|
||||||
|
CurrencyCircleDollar,
|
||||||
|
ArrowRight,
|
||||||
|
CheckCircle,
|
||||||
|
Clock,
|
||||||
|
Eye,
|
||||||
|
FunnelSimple,
|
||||||
|
Robot,
|
||||||
|
Heartbeat,
|
||||||
|
Target,
|
||||||
|
ChatCircle,
|
||||||
|
Tray,
|
||||||
|
} from "@phosphor-icons/react";
|
||||||
|
import type { InboxItem, InboxItemType, SectionId, StandupReport, AgentMetrics, CompanyMetrics } from "@shared/types";
|
||||||
|
import { formatMoney } from "../lib/formatters";
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Inbox Panel */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
const typeConfig: Record<InboxItemType, { icon: React.ElementType; color: string; bg: string; label: string }> = {
|
||||||
|
pending_approval: { icon: ShieldCheck, color: "var(--warn)", bg: "var(--warn-soft)", label: "Approval" },
|
||||||
|
failed_run: { icon: XCircle, color: "var(--danger)", bg: "var(--danger-soft)", label: "Failed Run" },
|
||||||
|
budget_warning: { icon: CurrencyCircleDollar, color: "var(--warn)", bg: "var(--warn-soft)", label: "Budget" },
|
||||||
|
blocked_task: { icon: Warning, color: "var(--danger)", bg: "var(--danger-soft)", label: "Blocked" },
|
||||||
|
review_needed: { icon: Eye, color: "var(--accent)", bg: "var(--accent-soft)", label: "Review" },
|
||||||
|
agent_message: { icon: ChatCircle, color: "var(--accent)", bg: "var(--accent-soft)", label: "Message" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const severityColors: Record<string, string> = {
|
||||||
|
critical: "border-l-[color:var(--danger)]",
|
||||||
|
high: "border-l-[color:var(--warn)]",
|
||||||
|
medium: "border-l-[color:var(--accent)]",
|
||||||
|
low: "border-l-[color:var(--line)]",
|
||||||
|
};
|
||||||
|
|
||||||
|
function timeAgo(dateStr: string): string {
|
||||||
|
const now = Date.now();
|
||||||
|
const then = new Date(dateStr).getTime();
|
||||||
|
const diff = Math.max(0, now - then);
|
||||||
|
const seconds = Math.floor(diff / 1000);
|
||||||
|
if (seconds < 60) return "just now";
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
if (minutes < 60) return `${minutes}m ago`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) return `${hours}h ago`;
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
return `${days}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InboxProps {
|
||||||
|
items: InboxItem[];
|
||||||
|
onNavigate: (section: SectionId, entityId?: string) => void;
|
||||||
|
onDismiss: (itemId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Inbox({ items, onNavigate, onDismiss }: InboxProps) {
|
||||||
|
const [filter, setFilter] = useState<InboxItemType | "all">("all");
|
||||||
|
const [showDismissed, setShowDismissed] = useState(false);
|
||||||
|
|
||||||
|
const filteredItems = useMemo(() => {
|
||||||
|
let result = items;
|
||||||
|
if (!showDismissed) result = result.filter(i => !i.dismissed);
|
||||||
|
if (filter !== "all") result = result.filter(i => i.type === filter);
|
||||||
|
return result;
|
||||||
|
}, [items, filter, showDismissed]);
|
||||||
|
|
||||||
|
const counts = useMemo(() => {
|
||||||
|
const active = items.filter(i => !i.dismissed);
|
||||||
|
return {
|
||||||
|
all: active.length,
|
||||||
|
pending_approval: active.filter(i => i.type === "pending_approval").length,
|
||||||
|
failed_run: active.filter(i => i.type === "failed_run").length,
|
||||||
|
budget_warning: active.filter(i => i.type === "budget_warning").length,
|
||||||
|
blocked_task: active.filter(i => i.type === "blocked_task").length,
|
||||||
|
review_needed: active.filter(i => i.type === "review_needed").length,
|
||||||
|
agent_message: active.filter(i => i.type === "agent_message").length,
|
||||||
|
};
|
||||||
|
}, [items]);
|
||||||
|
|
||||||
|
const handleItemClick = (item: InboxItem) => {
|
||||||
|
const sectionMap: Record<string, SectionId> = {
|
||||||
|
approval: "approvals",
|
||||||
|
run: "tasks",
|
||||||
|
agent: "agents",
|
||||||
|
task: "tasks",
|
||||||
|
};
|
||||||
|
const section = sectionMap[item.entityType] ?? "overview";
|
||||||
|
onNavigate(section, item.entityId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const filterButtons: Array<{ key: InboxItemType | "all"; label: string; count: number }> = [
|
||||||
|
{ key: "all", label: "All", count: counts.all },
|
||||||
|
{ key: "pending_approval", label: "Approvals", count: counts.pending_approval },
|
||||||
|
{ key: "review_needed", label: "Reviews", count: counts.review_needed },
|
||||||
|
{ key: "failed_run", label: "Failed", count: counts.failed_run },
|
||||||
|
{ key: "blocked_task", label: "Blocked", count: counts.blocked_task },
|
||||||
|
{ key: "budget_warning", label: "Budget", count: counts.budget_warning },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-[10px] bg-[color:var(--accent-soft)]">
|
||||||
|
<Bell size={20} weight="fill" className="text-[color:var(--accent)]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-[16px] font-bold text-[color:var(--text)]">Inbox</h2>
|
||||||
|
<p className="text-[12px] text-[color:var(--muted)]">
|
||||||
|
{counts.all} item{counts.all !== 1 ? "s" : ""} need{counts.all === 1 ? "s" : ""} your attention
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter Bar */}
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
{filterButtons.map(btn => (
|
||||||
|
<button
|
||||||
|
key={btn.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFilter(btn.key)}
|
||||||
|
className={`flex items-center gap-1.5 rounded-full px-3 py-1.5 text-[11px] font-medium transition ${
|
||||||
|
filter === btn.key
|
||||||
|
? "bg-[color:var(--accent)] text-white"
|
||||||
|
: "bg-[color:var(--panel-soft)] text-[color:var(--muted-strong)] hover:bg-[color:var(--line)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{btn.label}
|
||||||
|
{btn.count > 0 ? (
|
||||||
|
<span className={`rounded-full px-1.5 py-0.5 text-[9px] font-bold ${
|
||||||
|
filter === btn.key ? "bg-white/20 text-white" : "bg-[color:var(--line)] text-[color:var(--muted)]"
|
||||||
|
}`}>
|
||||||
|
{btn.count}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Items */}
|
||||||
|
{filteredItems.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center rounded-[10px] border border-dashed border-[color:var(--line)] py-16 text-center">
|
||||||
|
<Tray size={32} className="mb-3 text-[color:var(--muted)]" />
|
||||||
|
<div className="text-[14px] font-medium text-[color:var(--muted-strong)]">All clear!</div>
|
||||||
|
<div className="mt-1 text-[12px] text-[color:var(--muted)]">No items need your attention right now</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<AnimatePresence>
|
||||||
|
{filteredItems.map((item) => {
|
||||||
|
const config = typeConfig[item.type];
|
||||||
|
const Icon = config.icon;
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={item.id}
|
||||||
|
initial={{ opacity: 0, y: -8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, x: 20 }}
|
||||||
|
className={`group flex items-center gap-3 rounded-[8px] border border-l-[3px] border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-3 transition hover:border-[color:var(--line-strong)] hover:shadow-sm ${severityColors[item.severity] ?? ""}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-[8px]"
|
||||||
|
style={{ backgroundColor: config.bg }}
|
||||||
|
>
|
||||||
|
<Icon size={18} weight="fill" style={{ color: config.color }} />
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleItemClick(item)}
|
||||||
|
className="min-w-0 flex-1 text-left"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="truncate text-[13px] font-medium text-[color:var(--text)]">{item.title}</span>
|
||||||
|
<span
|
||||||
|
className="shrink-0 rounded-full px-2 py-0.5 text-[9px] font-bold uppercase tracking-[0.1em]"
|
||||||
|
style={{ backgroundColor: config.bg, color: config.color }}
|
||||||
|
>
|
||||||
|
{config.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 flex items-center gap-2 text-[11px] text-[color:var(--muted)]">
|
||||||
|
{item.agentName ? (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Robot size={10} />
|
||||||
|
{item.agentName}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<span>{item.subtitle.slice(0, 80)}</span>
|
||||||
|
<span className="shrink-0">{timeAgo(item.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<ArrowRight size={14} className="shrink-0 text-[color:var(--muted)] opacity-0 transition group-hover:opacity-100" />
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Standup Report View */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
interface StandupProps {
|
||||||
|
report: StandupReport | null;
|
||||||
|
onNavigate: (section: SectionId, entityId?: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StandupView({ report, onNavigate }: StandupProps) {
|
||||||
|
if (!report) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center rounded-[10px] border border-dashed border-[color:var(--line)] py-16 text-center">
|
||||||
|
<Clock size={32} className="mb-3 text-[color:var(--muted)]" />
|
||||||
|
<div className="text-[14px] font-medium text-[color:var(--muted-strong)]">No standup data</div>
|
||||||
|
<div className="mt-1 text-[12px] text-[color:var(--muted)]">Company needs active agents to generate standup reports</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="rounded-[12px] border border-[color:var(--line)] bg-[color:var(--panel)] p-5">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-[10px] bg-[color:var(--accent-soft)]">
|
||||||
|
<Heartbeat size={20} weight="fill" className="text-[color:var(--accent)]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-[16px] font-bold text-[color:var(--text)]">Daily Standup</h2>
|
||||||
|
<p className="text-[12px] text-[color:var(--muted)]">
|
||||||
|
Generated {new Date(report.generatedAt).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-[14px] font-medium text-[color:var(--text)]">{report.summary}</div>
|
||||||
|
<div className="mt-3 flex gap-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-[18px] font-bold text-[color:var(--success)]">{report.agentReports.filter(r => r.tasksCompleted24h > 0).length}</div>
|
||||||
|
<div className="text-[10px] text-[color:var(--muted)]">Productive</div>
|
||||||
|
</div>
|
||||||
|
<div className="h-10 w-px bg-[color:var(--line)]" />
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-[18px] font-bold text-[color:var(--danger)]">{report.blockers.length}</div>
|
||||||
|
<div className="text-[10px] text-[color:var(--muted)]">Blockers</div>
|
||||||
|
</div>
|
||||||
|
<div className="h-10 w-px bg-[color:var(--line)]" />
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-[18px] font-bold text-[color:var(--warn)]">{report.pendingApprovals}</div>
|
||||||
|
<div className="text-[10px] text-[color:var(--muted)]">Approvals</div>
|
||||||
|
</div>
|
||||||
|
<div className="h-10 w-px bg-[color:var(--line)]" />
|
||||||
|
<div className="text-center">
|
||||||
|
<div className={`text-[18px] font-bold ${report.budgetUtilization > 80 ? "text-[color:var(--danger)]" : "text-[color:var(--text)]"}`}>
|
||||||
|
{report.budgetUtilization}%
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-[color:var(--muted)]">Budget Used</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Company Highlights */}
|
||||||
|
{report.companyHighlights.length > 0 ? (
|
||||||
|
<section>
|
||||||
|
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Highlights</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{report.companyHighlights.map((h, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2 rounded-[6px] bg-[color:var(--success-soft)] px-3 py-2 text-[12px] text-[color:var(--success)]">
|
||||||
|
<CheckCircle size={14} weight="fill" />
|
||||||
|
{h}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Blockers */}
|
||||||
|
{report.blockers.length > 0 ? (
|
||||||
|
<section>
|
||||||
|
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--danger)]">Blockers</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{report.blockers.map((b, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2 rounded-[6px] bg-[color:var(--danger-soft)] px-3 py-2 text-[12px] text-[color:var(--danger)]">
|
||||||
|
<Warning size={14} weight="fill" />
|
||||||
|
{b}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Agent Reports */}
|
||||||
|
<section>
|
||||||
|
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Agent Status</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{report.agentReports.map((ar) => (
|
||||||
|
<button
|
||||||
|
key={ar.agentId}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onNavigate("agents", ar.agentId)}
|
||||||
|
className="flex w-full items-center gap-3 rounded-[8px] border border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-3 text-left transition hover:border-[color:var(--line-strong)] hover:shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
<div className={`flex h-10 w-10 items-center justify-center rounded-full text-[12px] font-bold ${
|
||||||
|
ar.status === "running" ? "bg-[color:var(--success-soft)] text-[color:var(--success)]"
|
||||||
|
: ar.status === "error" ? "bg-[color:var(--danger-soft)] text-[color:var(--danger)]"
|
||||||
|
: "bg-[color:var(--panel-soft)] text-[color:var(--muted-strong)]"
|
||||||
|
}`}>
|
||||||
|
{ar.agentName.split(/\s+/).slice(0, 2).map(w => w[0]).join("").toUpperCase()}
|
||||||
|
</div>
|
||||||
|
{ar.status === "running" ? (
|
||||||
|
<span className="absolute -bottom-0.5 -right-0.5 flex h-3 w-3">
|
||||||
|
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-[color:var(--success)] opacity-40" />
|
||||||
|
<span className="relative inline-flex h-2 w-2 rounded-full bg-[color:var(--success)]" />
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[13px] font-semibold text-[color:var(--text)]">{ar.agentName}</span>
|
||||||
|
<span className="text-[10px] text-[color:var(--muted)]">{ar.role}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex flex-wrap gap-3 text-[11px] text-[color:var(--muted)]">
|
||||||
|
{ar.tasksInProgress > 0 ? <span className="text-[color:var(--accent)]">{ar.tasksInProgress} in progress</span> : null}
|
||||||
|
{ar.tasksCompleted24h > 0 ? <span className="text-[color:var(--success)]">{ar.tasksCompleted24h} done</span> : null}
|
||||||
|
{ar.tasksBlocked > 0 ? <span className="text-[color:var(--danger)]">{ar.tasksBlocked} blocked</span> : null}
|
||||||
|
{ar.costLast24h > 0 ? <span>${ar.costLast24h.toFixed(2)}</span> : null}
|
||||||
|
</div>
|
||||||
|
{ar.highlights.length > 0 ? (
|
||||||
|
<div className="mt-1 text-[10px] text-[color:var(--muted)]">{ar.highlights.join(" · ")}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<ArrowRight size={14} className="shrink-0 text-[color:var(--muted)]" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Performance Metrics View */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
interface PerformanceProps {
|
||||||
|
metrics: CompanyMetrics | null;
|
||||||
|
onNavigate: (section: SectionId, entityId?: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PerformanceView({ metrics, onNavigate }: PerformanceProps) {
|
||||||
|
if (!metrics) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center rounded-[10px] border border-dashed border-[color:var(--line)] py-16 text-center">
|
||||||
|
<Target size={32} className="mb-3 text-[color:var(--muted)]" />
|
||||||
|
<div className="text-[14px] font-medium text-[color:var(--muted-strong)]">No metrics yet</div>
|
||||||
|
<div className="mt-1 text-[12px] text-[color:var(--muted)]">Metrics will appear after agents start working</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Company Overview Metrics */}
|
||||||
|
<div className="grid grid-cols-2 gap-2 lg:grid-cols-4">
|
||||||
|
<CompactMetric
|
||||||
|
label="Success Rate"
|
||||||
|
value={`${metrics.overallSuccessRate}%`}
|
||||||
|
tone={metrics.overallSuccessRate >= 80 ? "success" : metrics.overallSuccessRate >= 50 ? "warn" : "danger"}
|
||||||
|
/>
|
||||||
|
<CompactMetric
|
||||||
|
label="Tasks Done"
|
||||||
|
value={metrics.completedTasks}
|
||||||
|
tone="success"
|
||||||
|
subtitle={`of ${metrics.totalTasks} total`}
|
||||||
|
/>
|
||||||
|
<CompactMetric
|
||||||
|
label="Throughput"
|
||||||
|
value={`${metrics.throughputTasksPerDay.toFixed(1)}`}
|
||||||
|
tone="accent"
|
||||||
|
subtitle="tasks/day"
|
||||||
|
/>
|
||||||
|
<CompactMetric
|
||||||
|
label="Avg Cost/Task"
|
||||||
|
value={formatMoney(metrics.avgCostPerTask)}
|
||||||
|
tone="default"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Run Stats */}
|
||||||
|
<div className="rounded-[12px] border border-[color:var(--line)] bg-[color:var(--panel)] p-5">
|
||||||
|
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Run Activity (30d)</div>
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<div className="flex-1">
|
||||||
|
<RunProgressBar
|
||||||
|
succeeded={metrics.successfulRuns}
|
||||||
|
failed={metrics.failedRuns}
|
||||||
|
total={metrics.totalRuns}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4 text-[12px]">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="h-2.5 w-2.5 rounded-full bg-[color:var(--success)]" />
|
||||||
|
<span className="text-[color:var(--muted)]">{metrics.successfulRuns} succeeded</span>
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="h-2.5 w-2.5 rounded-full bg-[color:var(--danger)]" />
|
||||||
|
<span className="text-[color:var(--muted)]">{metrics.failedRuns} failed</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Agent Performance Table */}
|
||||||
|
<section>
|
||||||
|
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Agent Performance</div>
|
||||||
|
<div className="overflow-x-auto rounded-[10px] border border-[color:var(--line)]">
|
||||||
|
<table className="w-full text-left">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-[color:var(--line)] bg-[color:var(--panel-soft)]">
|
||||||
|
<th className="px-4 py-2.5 text-[11px] font-semibold uppercase tracking-[0.06em] text-[color:var(--muted)]">Agent</th>
|
||||||
|
<th className="px-3 py-2.5 text-[11px] font-semibold uppercase tracking-[0.06em] text-[color:var(--muted)] text-right">Tasks Done</th>
|
||||||
|
<th className="px-3 py-2.5 text-[11px] font-semibold uppercase tracking-[0.06em] text-[color:var(--muted)] text-right">Success %</th>
|
||||||
|
<th className="px-3 py-2.5 text-[11px] font-semibold uppercase tracking-[0.06em] text-[color:var(--muted)] text-right">Runs</th>
|
||||||
|
<th className="px-3 py-2.5 text-[11px] font-semibold uppercase tracking-[0.06em] text-[color:var(--muted)] text-right">Avg Duration</th>
|
||||||
|
<th className="px-3 py-2.5 text-[11px] font-semibold uppercase tracking-[0.06em] text-[color:var(--muted)] text-right">Cost</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{metrics.agentMetrics
|
||||||
|
.sort((a, b) => b.tasksCompleted - a.tasksCompleted)
|
||||||
|
.map((am) => (
|
||||||
|
<tr
|
||||||
|
key={am.agentId}
|
||||||
|
className="border-b border-[color:var(--line)] bg-[color:var(--panel)] transition hover:bg-[color:var(--panel-soft)] cursor-pointer"
|
||||||
|
onClick={() => onNavigate("agents", am.agentId)}
|
||||||
|
>
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-[color:var(--panel-soft)] text-[10px] font-bold text-[color:var(--muted-strong)]">
|
||||||
|
{am.agentName.split(/\s+/).slice(0, 2).map(w => w[0]).join("").toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<span className="text-[12px] font-medium text-[color:var(--text)]">{am.agentName}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 text-right text-[12px] font-semibold text-[color:var(--success)]">{am.tasksCompleted}</td>
|
||||||
|
<td className="px-3 py-2.5 text-right">
|
||||||
|
<span className={`text-[12px] font-semibold ${
|
||||||
|
am.successRate >= 80 ? "text-[color:var(--success)]"
|
||||||
|
: am.successRate >= 50 ? "text-[color:var(--warn)]"
|
||||||
|
: "text-[color:var(--danger)]"
|
||||||
|
}`}>
|
||||||
|
{am.successRate}%
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 text-right text-[12px] text-[color:var(--muted)]">{am.runsTotal}</td>
|
||||||
|
<td className="px-3 py-2.5 text-right text-[12px] text-[color:var(--muted)]">{formatDuration(am.avgRunDurationSec)}</td>
|
||||||
|
<td className="px-3 py-2.5 text-right text-[12px] text-[color:var(--muted)]">{formatMoney(am.totalCostUsd)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Helpers */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
function CompactMetric({ label, value, tone = "default", subtitle }: {
|
||||||
|
label: string;
|
||||||
|
value: string | number;
|
||||||
|
tone?: "default" | "success" | "warn" | "danger" | "accent";
|
||||||
|
subtitle?: string;
|
||||||
|
}) {
|
||||||
|
const toneMap = {
|
||||||
|
default: "text-[color:var(--text)]",
|
||||||
|
success: "text-[color:var(--success)]",
|
||||||
|
warn: "text-[color:var(--warn)]",
|
||||||
|
danger: "text-[color:var(--danger)]",
|
||||||
|
accent: "text-[color:var(--accent)]",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-[10px] border border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-3">
|
||||||
|
<div className="text-[10px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">{label}</div>
|
||||||
|
<div className={`text-[22px] font-bold ${toneMap[tone]}`}>{value}</div>
|
||||||
|
{subtitle ? <div className="text-[10px] text-[color:var(--muted)]">{subtitle}</div> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RunProgressBar({ succeeded, failed, total }: { succeeded: number; failed: number; total: number }) {
|
||||||
|
if (total === 0) return <div className="h-3 rounded-full bg-[color:var(--panel-soft)]" />;
|
||||||
|
const successPct = (succeeded / total) * 100;
|
||||||
|
const failPct = (failed / total) * 100;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-3 overflow-hidden rounded-full bg-[color:var(--panel-soft)]">
|
||||||
|
<motion.div
|
||||||
|
className="h-full bg-[color:var(--success)]"
|
||||||
|
initial={{ width: 0 }}
|
||||||
|
animate={{ width: `${successPct}%` }}
|
||||||
|
transition={{ duration: 0.8, ease: "easeOut" }}
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
className="h-full bg-[color:var(--danger)]"
|
||||||
|
initial={{ width: 0 }}
|
||||||
|
animate={{ width: `${failPct}%` }}
|
||||||
|
transition={{ duration: 0.8, ease: "easeOut", delay: 0.2 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(seconds: number): string {
|
||||||
|
if (seconds < 60) return `${seconds}s`;
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const secs = seconds % 60;
|
||||||
|
if (minutes < 60) return `${minutes}m ${secs}s`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
return `${hours}h ${minutes % 60}m`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,501 @@
|
|||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import {
|
||||||
|
ArrowRight,
|
||||||
|
Browser,
|
||||||
|
Camera,
|
||||||
|
ChatCircle,
|
||||||
|
Eye,
|
||||||
|
Globe,
|
||||||
|
Heart,
|
||||||
|
MagnifyingGlass,
|
||||||
|
Megaphone,
|
||||||
|
PaperPlaneTilt,
|
||||||
|
Plus,
|
||||||
|
Robot,
|
||||||
|
ShieldCheck,
|
||||||
|
SignIn,
|
||||||
|
Trash,
|
||||||
|
UserPlus,
|
||||||
|
Warning,
|
||||||
|
X,
|
||||||
|
} from "@phosphor-icons/react";
|
||||||
|
import type { BrowserActionRecord, SectionId, SocialAccountRecord, SocialPlatform } from "@shared/types";
|
||||||
|
import { ActionButton, Input } from "./ui";
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Platform Config */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
const platformConfig: Record<SocialPlatform, { label: string; color: string; bgColor: string; icon: string }> = {
|
||||||
|
twitter: { label: "Twitter / X", color: "#1DA1F2", bgColor: "#1DA1F210", icon: "𝕏" },
|
||||||
|
linkedin: { label: "LinkedIn", color: "#0A66C2", bgColor: "#0A66C210", icon: "in" },
|
||||||
|
instagram: { label: "Instagram", color: "#E4405F", bgColor: "#E4405F10", icon: "IG" },
|
||||||
|
facebook: { label: "Facebook", color: "#1877F2", bgColor: "#1877F210", icon: "fb" },
|
||||||
|
reddit: { label: "Reddit", color: "#FF4500", bgColor: "#FF450010", icon: "r/" },
|
||||||
|
youtube: { label: "YouTube", color: "#FF0000", bgColor: "#FF000010", icon: "YT" },
|
||||||
|
tiktok: { label: "TikTok", color: "#000000", bgColor: "#00000010", icon: "TT" },
|
||||||
|
bluesky: { label: "Bluesky", color: "#0085FF", bgColor: "#0085FF10", icon: "🦋" },
|
||||||
|
other: { label: "Other", color: "#6B7280", bgColor: "#6B728010", icon: "🌐" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const actionTypeLabels: Record<string, { label: string; icon: React.ElementType }> = {
|
||||||
|
post: { label: "Post", icon: PaperPlaneTilt },
|
||||||
|
reply: { label: "Reply", icon: ChatCircle },
|
||||||
|
like: { label: "Like", icon: Heart },
|
||||||
|
follow: { label: "Follow", icon: UserPlus },
|
||||||
|
browse_feed: { label: "Browse Feed", icon: Eye },
|
||||||
|
screenshot: { label: "Screenshot", icon: Camera },
|
||||||
|
navigate: { label: "Navigate", icon: Globe },
|
||||||
|
search: { label: "Search", icon: MagnifyingGlass },
|
||||||
|
dm: { label: "Direct Message", icon: ChatCircle },
|
||||||
|
custom_script: { label: "Custom Script", icon: Browser },
|
||||||
|
};
|
||||||
|
|
||||||
|
function timeAgo(dateStr: string): string {
|
||||||
|
const now = Date.now();
|
||||||
|
const then = new Date(dateStr).getTime();
|
||||||
|
const diff = Math.max(0, now - then);
|
||||||
|
const seconds = Math.floor(diff / 1000);
|
||||||
|
if (seconds < 60) return "just now";
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
if (minutes < 60) return `${minutes}m ago`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) return `${hours}h ago`;
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
return `${days}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Social Account Card */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
function AccountCard({
|
||||||
|
account,
|
||||||
|
onLogin,
|
||||||
|
onDelete,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
account: SocialAccountRecord;
|
||||||
|
onLogin: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
const platform = platformConfig[account.platform] ?? platformConfig.other;
|
||||||
|
const isLoggedIn = account.status === "logged_in" || account.status === "active";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="group flex items-center gap-3 rounded-[10px] border border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-3 transition hover:border-[color:var(--line-strong)] hover:shadow-sm">
|
||||||
|
{/* Platform icon */}
|
||||||
|
<div
|
||||||
|
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-[10px] text-[14px] font-bold"
|
||||||
|
style={{ backgroundColor: platform.bgColor, color: platform.color }}
|
||||||
|
>
|
||||||
|
{platform.icon}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<button type="button" onClick={onClick} className="min-w-0 flex-1 text-left">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[13px] font-semibold text-[color:var(--text)]">{account.displayName || account.accountName}</span>
|
||||||
|
<span
|
||||||
|
className="rounded-full px-2 py-0.5 text-[9px] font-bold uppercase tracking-[0.1em]"
|
||||||
|
style={{ backgroundColor: platform.bgColor, color: platform.color }}
|
||||||
|
>
|
||||||
|
{platform.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 flex items-center gap-2 text-[11px] text-[color:var(--muted)]">
|
||||||
|
<span>@{account.accountName}</span>
|
||||||
|
{account.requireApproval ? (
|
||||||
|
<span className="flex items-center gap-1 text-[color:var(--warn)]">
|
||||||
|
<ShieldCheck size={10} />
|
||||||
|
Approval required
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{account.lastUsedAt ? <span>Last used {timeAgo(account.lastUsedAt)}</span> : null}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Status + Actions */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[10px] font-semibold ${
|
||||||
|
isLoggedIn
|
||||||
|
? "bg-[color:var(--success-soft)] text-[color:var(--success)]"
|
||||||
|
: account.status === "login_required"
|
||||||
|
? "bg-[color:var(--warn-soft)] text-[color:var(--warn)]"
|
||||||
|
: account.status === "suspended"
|
||||||
|
? "bg-[color:var(--danger-soft)] text-[color:var(--danger)]"
|
||||||
|
: "bg-[color:var(--panel-soft)] text-[color:var(--muted)]"
|
||||||
|
}`}>
|
||||||
|
<span className={`h-1.5 w-1.5 rounded-full ${
|
||||||
|
isLoggedIn ? "bg-[color:var(--success)]"
|
||||||
|
: account.status === "login_required" ? "bg-[color:var(--warn)]"
|
||||||
|
: account.status === "suspended" ? "bg-[color:var(--danger)]"
|
||||||
|
: "bg-[color:var(--muted)]"
|
||||||
|
}`} />
|
||||||
|
{account.status === "logged_in" ? "Logged In" : account.status === "active" ? "Active" : account.status.replace("_", " ")}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{account.status === "login_required" ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onLogin}
|
||||||
|
className="flex items-center gap-1 rounded-[6px] bg-[color:var(--accent)] px-2.5 py-1.5 text-[10px] font-semibold text-white transition hover:opacity-90"
|
||||||
|
>
|
||||||
|
<SignIn size={12} />
|
||||||
|
Login
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onDelete}
|
||||||
|
className="rounded-[6px] p-1.5 text-[color:var(--muted)] opacity-0 transition hover:bg-[color:var(--danger-soft)] hover:text-[color:var(--danger)] group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
<Trash size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Add Account Dialog */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
function AddAccountDialog({
|
||||||
|
onAdd,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
onAdd: (platform: SocialPlatform, accountName: string, displayName: string, requireApproval: boolean) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [platform, setPlatform] = useState<SocialPlatform>("twitter");
|
||||||
|
const [accountName, setAccountName] = useState("");
|
||||||
|
const [displayName, setDisplayName] = useState("");
|
||||||
|
const [requireApproval, setRequireApproval] = useState(true);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.95, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
exit={{ scale: 0.95, opacity: 0 }}
|
||||||
|
className="w-full max-w-[420px] rounded-[14px] border border-[color:var(--line)] bg-[color:var(--panel)] p-6 shadow-xl"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="mb-5 flex items-center justify-between">
|
||||||
|
<h3 className="text-[16px] font-bold text-[color:var(--text)]">Add Social Account</h3>
|
||||||
|
<button type="button" onClick={onClose} className="rounded-[6px] p-1 text-[color:var(--muted)] hover:bg-[color:var(--panel-soft)]">
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Platform selector */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">Platform</label>
|
||||||
|
<div className="grid grid-cols-3 gap-1.5">
|
||||||
|
{(Object.entries(platformConfig) as [SocialPlatform, typeof platformConfig.twitter][]).map(([key, config]) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPlatform(key)}
|
||||||
|
className={`flex items-center gap-2 rounded-[8px] border px-3 py-2 text-[11px] font-medium transition ${
|
||||||
|
platform === key
|
||||||
|
? "border-[color:var(--accent)] bg-[color:var(--accent-soft)] text-[color:var(--accent)]"
|
||||||
|
: "border-[color:var(--line)] text-[color:var(--muted)] hover:border-[color:var(--line-strong)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span style={{ color: config.color }}>{config.icon}</span>
|
||||||
|
{config.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Username / Handle"
|
||||||
|
value={accountName}
|
||||||
|
onChange={setAccountName}
|
||||||
|
placeholder={platform === "twitter" ? "elonmusk" : "your-username"}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Display Name (optional)"
|
||||||
|
value={displayName}
|
||||||
|
onChange={setDisplayName}
|
||||||
|
placeholder="Your display name"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Approval toggle */}
|
||||||
|
<div className="rounded-[8px] border border-[color:var(--line)] bg-[color:var(--panel-soft)] px-4 py-3">
|
||||||
|
<label className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={requireApproval}
|
||||||
|
onChange={(e) => setRequireApproval(e.target.checked)}
|
||||||
|
className="h-4 w-4 rounded border-[color:var(--line)] accent-[color:var(--accent)]"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div className="text-[12px] font-medium text-[color:var(--text)]">Require Board Approval for Posts</div>
|
||||||
|
<div className="mt-0.5 text-[10px] text-[color:var(--muted)]">
|
||||||
|
Agents must get approval before publishing content to this account
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-[8px] px-4 py-2 text-[12px] font-medium text-[color:var(--muted)] transition hover:bg-[color:var(--panel-soft)]"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<ActionButton
|
||||||
|
label="Add Account"
|
||||||
|
onClick={() => {
|
||||||
|
if (accountName.trim()) {
|
||||||
|
onAdd(platform, accountName.trim(), displayName.trim(), requireApproval);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
icon={Plus}
|
||||||
|
tone="accent"
|
||||||
|
disabled={!accountName.trim()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Browser Action History */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
function ActionHistory({ actions, agents }: { actions: BrowserActionRecord[]; agents: Array<{ id: string; name: string }> }) {
|
||||||
|
if (actions.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="py-8 text-center text-[13px] text-[color:var(--muted)]">
|
||||||
|
No browser actions yet. Agents will create actions when working on social media tasks.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{actions.slice(0, 30).map((action) => {
|
||||||
|
const config = actionTypeLabels[action.actionType] ?? actionTypeLabels.custom_script;
|
||||||
|
const Icon = config.icon;
|
||||||
|
const agent = agents.find((a) => a.id === action.agentId);
|
||||||
|
const isSuccess = action.status === "succeeded";
|
||||||
|
const isFailed = action.status === "failed";
|
||||||
|
const isRunning = action.status === "running";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={action.id}
|
||||||
|
className="flex items-center gap-3 rounded-[8px] border border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-2.5 text-[12px]"
|
||||||
|
>
|
||||||
|
<Icon size={16} className={
|
||||||
|
isSuccess ? "text-[color:var(--success)]"
|
||||||
|
: isFailed ? "text-[color:var(--danger)]"
|
||||||
|
: isRunning ? "text-[color:var(--accent)]"
|
||||||
|
: "text-[color:var(--muted)]"
|
||||||
|
} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium text-[color:var(--text)]">{config.label}</span>
|
||||||
|
{agent ? (
|
||||||
|
<span className="flex items-center gap-1 text-[color:var(--muted)]">
|
||||||
|
<Robot size={10} />
|
||||||
|
{agent.name}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{action.resultSummary ? (
|
||||||
|
<div className="mt-0.5 truncate text-[11px] text-[color:var(--muted)]">{action.resultSummary}</div>
|
||||||
|
) : null}
|
||||||
|
{action.errorMessage ? (
|
||||||
|
<div className="mt-0.5 truncate text-[11px] text-[color:var(--danger)]">{action.errorMessage}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 text-[10px] text-[color:var(--muted)]">
|
||||||
|
{timeAgo(action.createdAt)}
|
||||||
|
</div>
|
||||||
|
<span className={`shrink-0 rounded-full px-2 py-0.5 text-[9px] font-bold ${
|
||||||
|
isSuccess ? "bg-[color:var(--success-soft)] text-[color:var(--success)]"
|
||||||
|
: isFailed ? "bg-[color:var(--danger-soft)] text-[color:var(--danger)]"
|
||||||
|
: isRunning ? "bg-[color:var(--accent-soft)] text-[color:var(--accent)]"
|
||||||
|
: action.status === "approval_required" ? "bg-[color:var(--warn-soft)] text-[color:var(--warn)]"
|
||||||
|
: "bg-[color:var(--panel-soft)] text-[color:var(--muted)]"
|
||||||
|
}`}>
|
||||||
|
{action.status.replace("_", " ")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Main Social Accounts Panel */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
interface SocialPanelProps {
|
||||||
|
accounts: SocialAccountRecord[];
|
||||||
|
actions: BrowserActionRecord[];
|
||||||
|
agents: Array<{ id: string; name: string }>;
|
||||||
|
companyId: string;
|
||||||
|
onSave: (input: {
|
||||||
|
companyId: string;
|
||||||
|
platform: SocialPlatform;
|
||||||
|
accountName: string;
|
||||||
|
displayName: string;
|
||||||
|
requireApproval: boolean;
|
||||||
|
}) => Promise<void>;
|
||||||
|
onDelete: (id: string) => Promise<void>;
|
||||||
|
onLogin: (accountId: string) => Promise<void>;
|
||||||
|
onNavigate: (section: SectionId, entityId?: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SocialAccountsPanel({
|
||||||
|
accounts,
|
||||||
|
actions,
|
||||||
|
agents,
|
||||||
|
companyId,
|
||||||
|
onSave,
|
||||||
|
onDelete,
|
||||||
|
onLogin,
|
||||||
|
onNavigate,
|
||||||
|
}: SocialPanelProps) {
|
||||||
|
const [showAddDialog, setShowAddDialog] = useState(false);
|
||||||
|
const [activeTab, setActiveTab] = useState<"accounts" | "history">("accounts");
|
||||||
|
|
||||||
|
const handleAdd = useCallback(async (platform: SocialPlatform, accountName: string, displayName: string, requireApproval: boolean) => {
|
||||||
|
await onSave({ companyId, platform, accountName, displayName, requireApproval });
|
||||||
|
setShowAddDialog(false);
|
||||||
|
}, [companyId, onSave]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-[10px] bg-[color:var(--accent-soft)]">
|
||||||
|
<Megaphone size={20} weight="fill" className="text-[color:var(--accent)]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-[16px] font-bold text-[color:var(--text)]">Social Media</h2>
|
||||||
|
<p className="text-[12px] text-[color:var(--muted)]">
|
||||||
|
{accounts.length} account{accounts.length !== 1 ? "s" : ""} connected
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ActionButton
|
||||||
|
label="Add Account"
|
||||||
|
onClick={() => setShowAddDialog(true)}
|
||||||
|
icon={Plus}
|
||||||
|
tone="accent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info banner */}
|
||||||
|
<div className="rounded-[10px] border border-[color:var(--accent)] border-opacity-20 bg-[color:var(--accent-soft)] px-4 py-3">
|
||||||
|
<div className="flex items-start gap-2 text-[12px] text-[color:var(--accent)]">
|
||||||
|
<Browser size={16} className="mt-0.5 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">How it works</div>
|
||||||
|
<div className="mt-1 text-[11px] opacity-80">
|
||||||
|
Add your social media accounts here. Click "Login" to open a browser where you can sign in.
|
||||||
|
Your session is saved locally so marketing agents can post content, reply to users, and browse feeds
|
||||||
|
using Playwright browser automation. All actions are logged and can require Board approval.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex gap-1 rounded-[8px] bg-[color:var(--panel-soft)] p-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab("accounts")}
|
||||||
|
className={`flex-1 rounded-[6px] px-3 py-1.5 text-[12px] font-medium transition ${
|
||||||
|
activeTab === "accounts"
|
||||||
|
? "bg-[color:var(--panel)] text-[color:var(--text)] shadow-sm"
|
||||||
|
: "text-[color:var(--muted)] hover:text-[color:var(--text)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Accounts ({accounts.length})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab("history")}
|
||||||
|
className={`flex-1 rounded-[6px] px-3 py-1.5 text-[12px] font-medium transition ${
|
||||||
|
activeTab === "history"
|
||||||
|
? "bg-[color:var(--panel)] text-[color:var(--text)] shadow-sm"
|
||||||
|
: "text-[color:var(--muted)] hover:text-[color:var(--text)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Action History ({actions.length})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
{activeTab === "accounts" ? (
|
||||||
|
accounts.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center rounded-[10px] border border-dashed border-[color:var(--line)] py-16 text-center">
|
||||||
|
<Megaphone size={32} className="mb-3 text-[color:var(--muted)]" />
|
||||||
|
<div className="text-[14px] font-medium text-[color:var(--muted-strong)]">No social accounts</div>
|
||||||
|
<div className="mt-1 text-[12px] text-[color:var(--muted)]">
|
||||||
|
Add your first social media account to enable marketing automation
|
||||||
|
</div>
|
||||||
|
<div className="mt-4">
|
||||||
|
<ActionButton
|
||||||
|
label="Add Account"
|
||||||
|
onClick={() => setShowAddDialog(true)}
|
||||||
|
icon={Plus}
|
||||||
|
tone="accent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{accounts.map((account) => (
|
||||||
|
<AccountCard
|
||||||
|
key={account.id}
|
||||||
|
account={account}
|
||||||
|
onLogin={() => void onLogin(account.id)}
|
||||||
|
onDelete={() => void onDelete(account.id)}
|
||||||
|
onClick={() => {/* future: account detail view */}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<ActionHistory actions={actions} agents={agents} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add Account Dialog */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{showAddDialog ? (
|
||||||
|
<AddAccountDialog
|
||||||
|
onAdd={(platform, accountName, displayName, requireApproval) => void handleAdd(platform, accountName, displayName, requireApproval)}
|
||||||
|
onClose={() => setShowAddDialog(false)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+253
-3
@@ -1,5 +1,5 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { CommentRecord, DesktopEvent, ProfileSnapshot } from "./types";
|
import type { AgentMessageRecord, AgentMetrics, AutomationRuleRecord, BrowserActionRecord, CommentRecord, CompanyMetrics, CompanyTemplate, DesktopEvent, DocumentRecord, InboxItem, KnowledgeEntryRecord, MeetingRecord, ProfileSnapshot, SocialAccountRecord, SprintRecord, StandupReport, WorkflowPipelineRecord } from "./types";
|
||||||
|
|
||||||
export const IPC_CHANNELS = {
|
export const IPC_CHANNELS = {
|
||||||
loadSnapshot: "agent-company:load-snapshot",
|
loadSnapshot: "agent-company:load-snapshot",
|
||||||
@@ -40,6 +40,45 @@ export const IPC_CHANNELS = {
|
|||||||
triggerHeartbeat: "agent-company:trigger-heartbeat",
|
triggerHeartbeat: "agent-company:trigger-heartbeat",
|
||||||
getApiPort: "agent-company:get-api-port",
|
getApiPort: "agent-company:get-api-port",
|
||||||
rendererEvent: "agent-company:event",
|
rendererEvent: "agent-company:event",
|
||||||
|
getInbox: "agent-company:get-inbox",
|
||||||
|
dismissInboxItem: "agent-company:dismiss-inbox-item",
|
||||||
|
getCompanyMetrics: "agent-company:get-company-metrics",
|
||||||
|
getAgentMetrics: "agent-company:get-agent-metrics",
|
||||||
|
getStandupReport: "agent-company:get-standup-report",
|
||||||
|
applyCompanyTemplate: "agent-company:apply-company-template",
|
||||||
|
listCompanyTemplates: "agent-company:list-company-templates",
|
||||||
|
saveSocialAccount: "agent-company:save-social-account",
|
||||||
|
deleteSocialAccount: "agent-company:delete-social-account",
|
||||||
|
listBrowserActions: "agent-company:list-browser-actions",
|
||||||
|
cancelBrowserAction: "agent-company:cancel-browser-action",
|
||||||
|
triggerBrowserLogin: "agent-company:trigger-browser-login",
|
||||||
|
// Meetings
|
||||||
|
saveMeeting: "agent-company:save-meeting",
|
||||||
|
deleteMeeting: "agent-company:delete-meeting",
|
||||||
|
// Documents
|
||||||
|
saveDocument: "agent-company:save-document",
|
||||||
|
deleteDocument: "agent-company:delete-document",
|
||||||
|
// Knowledge Base
|
||||||
|
saveKnowledgeEntry: "agent-company:save-knowledge-entry",
|
||||||
|
deleteKnowledgeEntry: "agent-company:delete-knowledge-entry",
|
||||||
|
// Sprints
|
||||||
|
saveSprint: "agent-company:save-sprint",
|
||||||
|
deleteSprint: "agent-company:delete-sprint",
|
||||||
|
// Automation
|
||||||
|
saveAutomationRule: "agent-company:save-automation-rule",
|
||||||
|
deleteAutomationRule: "agent-company:delete-automation-rule",
|
||||||
|
toggleAutomationRule: "agent-company:toggle-automation-rule",
|
||||||
|
getAutomationLog: "agent-company:get-automation-log",
|
||||||
|
// Agent Messages
|
||||||
|
sendAgentMessage: "agent-company:send-agent-message",
|
||||||
|
listAgentMessages: "agent-company:list-agent-messages",
|
||||||
|
markMessageRead: "agent-company:mark-message-read",
|
||||||
|
// Workflows
|
||||||
|
saveWorkflow: "agent-company:save-workflow",
|
||||||
|
deleteWorkflow: "agent-company:delete-workflow",
|
||||||
|
runWorkflow: "agent-company:run-workflow",
|
||||||
|
// Auto-assign
|
||||||
|
autoAssignTask: "agent-company:auto-assign-task",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const optionalId = z.preprocess(
|
const optionalId = z.preprocess(
|
||||||
@@ -80,6 +119,7 @@ export const agentInputSchema = z.object({
|
|||||||
name: z.string().min(2).max(80),
|
name: z.string().min(2).max(80),
|
||||||
role: z.string().min(2).max(80),
|
role: z.string().min(2).max(80),
|
||||||
title: z.string().default(""),
|
title: z.string().default(""),
|
||||||
|
department: z.enum(["executive", "engineering", "product", "marketing", "sales", "hr", "finance", "legal", "operations", "customer_support", "research", "design"]).nullable().optional(),
|
||||||
reportsTo: optionalId,
|
reportsTo: optionalId,
|
||||||
connectorId: connectorInputSchema.shape.id,
|
connectorId: connectorInputSchema.shape.id,
|
||||||
workspaceId: optionalId,
|
workspaceId: optionalId,
|
||||||
@@ -129,7 +169,7 @@ export const approvalInputSchema = z.object({
|
|||||||
companyId: z.string().uuid(),
|
companyId: z.string().uuid(),
|
||||||
relatedTaskId: optionalId,
|
relatedTaskId: optionalId,
|
||||||
requestedByAgentId: optionalId,
|
requestedByAgentId: optionalId,
|
||||||
type: z.enum(["hire_agent", "approve_ceo_strategy", "dangerous_command", "secret_access"]),
|
type: z.enum(["hire_agent", "approve_ceo_strategy", "dangerous_command", "secret_access", "budget_request", "promotion", "termination", "vendor_approval", "document_review", "production_deploy", "incident_escalation"]),
|
||||||
payloadSummary: z.string().min(2).max(280),
|
payloadSummary: z.string().min(2).max(280),
|
||||||
impactSummary: z.string().min(2).max(400),
|
impactSummary: z.string().min(2).max(400),
|
||||||
});
|
});
|
||||||
@@ -202,7 +242,178 @@ export const heartbeatSettingsSchema = z.object({
|
|||||||
export const triggerHeartbeatSchema = z.object({
|
export const triggerHeartbeatSchema = z.object({
|
||||||
agentId: z.string().uuid(),
|
agentId: z.string().uuid(),
|
||||||
companyId: z.string().uuid(),
|
companyId: z.string().uuid(),
|
||||||
trigger: z.enum(["manual", "timer", "assignment", "goal_activated", "approval_resolved", "subtask_completed", "comment", "report_blocked", "report_failed", "recovery"]).default("manual"),
|
trigger: z.enum(["manual", "timer", "assignment", "goal_activated", "approval_resolved", "subtask_completed", "comment", "report_blocked", "report_failed", "recovery", "message", "peer_request"]).default("manual"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const inboxDismissSchema = z.object({
|
||||||
|
itemId: z.string(),
|
||||||
|
companyId: z.string().uuid(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metricsQuerySchema = z.object({
|
||||||
|
companyId: z.string().uuid(),
|
||||||
|
agentId: z.string().uuid().optional(),
|
||||||
|
days: z.number().int().min(1).max(90).default(30),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const templateApplySchema = z.object({
|
||||||
|
templateId: z.string(),
|
||||||
|
companyName: z.string().min(2).max(80),
|
||||||
|
companyDescription: z.string().max(500).default(""),
|
||||||
|
workspaceId: z.string().uuid().optional(),
|
||||||
|
connectorId: z.enum(["codex_local", "claude_local", "gemini_local", "opencode_local", "kimi_local"]).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const socialAccountInputSchema = z.object({
|
||||||
|
id: optionalId,
|
||||||
|
companyId: z.string().uuid(),
|
||||||
|
platform: z.enum(["twitter", "linkedin", "instagram", "facebook", "reddit", "youtube", "tiktok", "bluesky", "other"]),
|
||||||
|
accountName: z.string().min(1).max(120),
|
||||||
|
displayName: z.string().max(120).default(""),
|
||||||
|
profileUrl: z.string().max(500).default(""),
|
||||||
|
credentialSecretId: optionalId,
|
||||||
|
status: z.enum(["active", "logged_in", "login_required", "suspended", "paused"]).default("login_required"),
|
||||||
|
requireApproval: z.boolean().default(true),
|
||||||
|
metadataJson: z.string().default("{}"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const meetingInputSchema = z.object({
|
||||||
|
id: optionalId,
|
||||||
|
companyId: z.string().uuid(),
|
||||||
|
type: z.enum(["standup", "sprint_planning", "sprint_review", "retrospective", "one_on_one", "all_hands", "department_sync", "incident_review", "hiring_committee", "budget_review", "design_review", "architecture_review"]),
|
||||||
|
title: z.string().min(2).max(160),
|
||||||
|
organizerAgentId: optionalId,
|
||||||
|
participantAgentIds: z.string().default("[]"),
|
||||||
|
scheduledAt: z.string(),
|
||||||
|
durationMinutes: z.number().int().min(5).max(480).default(30),
|
||||||
|
agendaJson: z.string().default("[]"),
|
||||||
|
notesJson: z.string().default("[]"),
|
||||||
|
decisionsJson: z.string().default("[]"),
|
||||||
|
actionItemsJson: z.string().default("[]"),
|
||||||
|
status: z.enum(["scheduled", "in_progress", "completed", "cancelled"]).default("scheduled"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const documentInputSchema = z.object({
|
||||||
|
id: optionalId,
|
||||||
|
companyId: z.string().uuid(),
|
||||||
|
type: z.enum(["prd", "technical_spec", "design_doc", "test_plan", "post_mortem", "meeting_notes", "budget_proposal", "hiring_requisition", "performance_review_doc", "sop", "knowledge_article", "project_brief", "sprint_report", "incident_report", "onboarding_guide", "architecture_decision", "status_report", "contract", "proposal"]),
|
||||||
|
title: z.string().min(2).max(200),
|
||||||
|
content: z.string().default(""),
|
||||||
|
authorAgentId: optionalId,
|
||||||
|
reviewerAgentId: optionalId,
|
||||||
|
projectId: optionalId,
|
||||||
|
goalId: optionalId,
|
||||||
|
parentDocId: optionalId,
|
||||||
|
version: z.number().int().min(1).default(1),
|
||||||
|
status: z.enum(["draft", "in_review", "approved", "archived", "superseded"]).default("draft"),
|
||||||
|
tagsJson: z.string().default("[]"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const knowledgeEntryInputSchema = z.object({
|
||||||
|
id: optionalId,
|
||||||
|
companyId: z.string().uuid(),
|
||||||
|
category: z.enum(["lesson_learned", "best_practice", "decision", "process", "technical", "business", "onboarding", "incident"]),
|
||||||
|
topic: z.string().min(2).max(200),
|
||||||
|
content: z.string().min(1),
|
||||||
|
authorAgentId: optionalId,
|
||||||
|
importance: z.enum(["critical", "high", "medium", "low"]).default("medium"),
|
||||||
|
tagsJson: z.string().default("[]"),
|
||||||
|
referencedEntityType: z.string().nullable().optional(),
|
||||||
|
referencedEntityId: z.string().nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const sprintInputSchema = z.object({
|
||||||
|
id: optionalId,
|
||||||
|
companyId: z.string().uuid(),
|
||||||
|
name: z.string().min(2).max(120),
|
||||||
|
goal: z.string().default(""),
|
||||||
|
startDate: z.string(),
|
||||||
|
endDate: z.string(),
|
||||||
|
status: z.enum(["planning", "active", "review", "completed", "cancelled"]).default("planning"),
|
||||||
|
retrospectiveNotes: z.string().default(""),
|
||||||
|
velocityPoints: z.number().int().min(0).default(0),
|
||||||
|
completedPoints: z.number().int().min(0).default(0),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const browserActionQuerySchema = z.object({
|
||||||
|
companyId: z.string().uuid(),
|
||||||
|
socialAccountId: z.string().uuid().optional(),
|
||||||
|
status: z.string().optional(),
|
||||||
|
limit: z.number().int().min(1).max(100).default(50),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Automation Schemas ──────────────────────────────────────────────────
|
||||||
|
export const automationRuleInputSchema = z.object({
|
||||||
|
id: z.preprocess((v) => (v === "" ? null : v), z.string().uuid().nullable().optional()),
|
||||||
|
companyId: z.string().uuid(),
|
||||||
|
name: z.string().min(2).max(120),
|
||||||
|
description: z.string().max(500).default(""),
|
||||||
|
trigger: z.enum([
|
||||||
|
"task_status_changed", "task_created", "task_blocked", "run_completed",
|
||||||
|
"run_failed", "approval_resolved", "budget_threshold", "agent_idle",
|
||||||
|
"comment_posted", "document_created", "schedule", "hire_approved",
|
||||||
|
"sprint_started", "sprint_ended",
|
||||||
|
]),
|
||||||
|
conditionsJson: z.string().default("{}"),
|
||||||
|
action: z.enum([
|
||||||
|
"assign_task", "create_task", "notify_agent", "send_message",
|
||||||
|
"trigger_heartbeat", "create_approval", "update_task_status",
|
||||||
|
"escalate_to_manager", "cross_department_notify", "schedule_meeting",
|
||||||
|
"create_document", "auto_approve", "reassign_task", "run_task",
|
||||||
|
]),
|
||||||
|
actionConfigJson: z.string().default("{}"),
|
||||||
|
sourceDepartment: z.enum([
|
||||||
|
"executive", "engineering", "product", "marketing", "sales",
|
||||||
|
"hr", "finance", "legal", "operations", "customer_support", "research", "design",
|
||||||
|
]).nullable().optional(),
|
||||||
|
targetDepartment: z.enum([
|
||||||
|
"executive", "engineering", "product", "marketing", "sales",
|
||||||
|
"hr", "finance", "legal", "operations", "customer_support", "research", "design",
|
||||||
|
]).nullable().optional(),
|
||||||
|
priority: z.number().int().min(0).max(100).default(50),
|
||||||
|
status: z.enum(["active", "paused", "disabled", "error"]).default("active"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const agentMessageInputSchema = z.object({
|
||||||
|
companyId: z.string().uuid(),
|
||||||
|
fromAgentId: z.string().uuid(),
|
||||||
|
toAgentId: z.preprocess((v) => (v === "" ? null : v), z.string().uuid().nullable().optional()),
|
||||||
|
channel: z.enum(["direct", "department", "company", "project", "incident"]).default("direct"),
|
||||||
|
channelTargetId: z.string().nullable().optional(),
|
||||||
|
subject: z.string().min(1).max(200),
|
||||||
|
body: z.string().min(1).max(8000),
|
||||||
|
priority: z.enum(["urgent", "normal", "low"]).default("normal"),
|
||||||
|
parentMessageId: z.preprocess((v) => (v === "" ? null : v), z.string().uuid().nullable().optional()),
|
||||||
|
attachmentsJson: z.string().default("[]"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const messageQuerySchema = z.object({
|
||||||
|
companyId: z.string().uuid(),
|
||||||
|
agentId: z.string().uuid().optional(),
|
||||||
|
channel: z.enum(["direct", "department", "company", "project", "incident"]).optional(),
|
||||||
|
unreadOnly: z.boolean().optional(),
|
||||||
|
limit: z.number().int().min(1).max(200).default(50),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const workflowInputSchema = z.object({
|
||||||
|
id: z.preprocess((v) => (v === "" ? null : v), z.string().uuid().nullable().optional()),
|
||||||
|
companyId: z.string().uuid(),
|
||||||
|
name: z.string().min(2).max(120),
|
||||||
|
description: z.string().max(500).default(""),
|
||||||
|
stepsJson: z.string().default("[]"),
|
||||||
|
triggerType: z.enum([
|
||||||
|
"task_status_changed", "task_created", "task_blocked", "run_completed",
|
||||||
|
"run_failed", "approval_resolved", "budget_threshold", "agent_idle",
|
||||||
|
"comment_posted", "document_created", "schedule", "hire_approved",
|
||||||
|
"sprint_started", "sprint_ended",
|
||||||
|
]),
|
||||||
|
triggerConfigJson: z.string().default("{}"),
|
||||||
|
status: z.enum(["draft", "active", "paused", "completed", "failed"]).default("draft"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const autoAssignSchema = z.object({
|
||||||
|
taskId: z.string().uuid(),
|
||||||
|
companyId: z.string().uuid(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type DesktopResult<T> =
|
export type DesktopResult<T> =
|
||||||
@@ -247,5 +458,44 @@ export type DesktopApi = {
|
|||||||
setHeartbeat: (input: z.infer<typeof heartbeatSettingsSchema>) => Promise<DesktopResult<boolean>>;
|
setHeartbeat: (input: z.infer<typeof heartbeatSettingsSchema>) => Promise<DesktopResult<boolean>>;
|
||||||
triggerHeartbeat: (input: z.infer<typeof triggerHeartbeatSchema>) => Promise<DesktopResult<string>>;
|
triggerHeartbeat: (input: z.infer<typeof triggerHeartbeatSchema>) => Promise<DesktopResult<string>>;
|
||||||
getApiPort: () => Promise<DesktopResult<number>>;
|
getApiPort: () => Promise<DesktopResult<number>>;
|
||||||
|
getInbox: (companyId: string) => Promise<DesktopResult<InboxItem[]>>;
|
||||||
|
dismissInboxItem: (input: z.infer<typeof inboxDismissSchema>) => Promise<DesktopResult<boolean>>;
|
||||||
|
getCompanyMetrics: (input: z.infer<typeof metricsQuerySchema>) => Promise<DesktopResult<CompanyMetrics>>;
|
||||||
|
getAgentMetrics: (input: z.infer<typeof metricsQuerySchema>) => Promise<DesktopResult<AgentMetrics>>;
|
||||||
|
getStandupReport: (companyId: string) => Promise<DesktopResult<StandupReport>>;
|
||||||
|
applyCompanyTemplate: (input: z.infer<typeof templateApplySchema>) => Promise<DesktopResult<string>>;
|
||||||
|
listCompanyTemplates: () => Promise<DesktopResult<CompanyTemplate[]>>;
|
||||||
|
saveSocialAccount: (input: z.infer<typeof socialAccountInputSchema>) => Promise<DesktopResult<string>>;
|
||||||
|
deleteSocialAccount: (input: z.infer<typeof deleteEntitySchema>) => Promise<DesktopResult<boolean>>;
|
||||||
|
listBrowserActions: (input: z.infer<typeof browserActionQuerySchema>) => Promise<DesktopResult<BrowserActionRecord[]>>;
|
||||||
|
cancelBrowserAction: (input: z.infer<typeof cancelRunSchema>) => Promise<DesktopResult<boolean>>;
|
||||||
|
triggerBrowserLogin: (socialAccountId: string) => Promise<DesktopResult<boolean>>;
|
||||||
|
// Meetings
|
||||||
|
saveMeeting: (input: z.infer<typeof meetingInputSchema>) => Promise<DesktopResult<string>>;
|
||||||
|
deleteMeeting: (input: z.infer<typeof deleteEntitySchema>) => Promise<DesktopResult<boolean>>;
|
||||||
|
// Documents
|
||||||
|
saveDocument: (input: z.infer<typeof documentInputSchema>) => Promise<DesktopResult<string>>;
|
||||||
|
deleteDocument: (input: z.infer<typeof deleteEntitySchema>) => Promise<DesktopResult<boolean>>;
|
||||||
|
// Knowledge Base
|
||||||
|
saveKnowledgeEntry: (input: z.infer<typeof knowledgeEntryInputSchema>) => Promise<DesktopResult<string>>;
|
||||||
|
deleteKnowledgeEntry: (input: z.infer<typeof deleteEntitySchema>) => Promise<DesktopResult<boolean>>;
|
||||||
|
// Sprints
|
||||||
|
saveSprint: (input: z.infer<typeof sprintInputSchema>) => Promise<DesktopResult<string>>;
|
||||||
|
deleteSprint: (input: z.infer<typeof deleteEntitySchema>) => Promise<DesktopResult<boolean>>;
|
||||||
|
// Automation
|
||||||
|
saveAutomationRule: (input: z.infer<typeof automationRuleInputSchema>) => Promise<DesktopResult<string>>;
|
||||||
|
deleteAutomationRule: (input: z.infer<typeof deleteEntitySchema>) => Promise<DesktopResult<boolean>>;
|
||||||
|
toggleAutomationRule: (input: { id: string; companyId: string; status: "active" | "paused" }) => Promise<DesktopResult<boolean>>;
|
||||||
|
getAutomationLog: (companyId: string) => Promise<DesktopResult<Array<{ ruleId: string; ruleName: string; trigger: string; action: string; executedAt: string }>>>;
|
||||||
|
// Agent Messages
|
||||||
|
sendAgentMessage: (input: z.infer<typeof agentMessageInputSchema>) => Promise<DesktopResult<string>>;
|
||||||
|
listAgentMessages: (input: z.infer<typeof messageQuerySchema>) => Promise<DesktopResult<AgentMessageRecord[]>>;
|
||||||
|
markMessageRead: (input: { messageId: string; companyId: string }) => Promise<DesktopResult<boolean>>;
|
||||||
|
// Workflows
|
||||||
|
saveWorkflow: (input: z.infer<typeof workflowInputSchema>) => Promise<DesktopResult<string>>;
|
||||||
|
deleteWorkflow: (input: z.infer<typeof deleteEntitySchema>) => Promise<DesktopResult<boolean>>;
|
||||||
|
runWorkflow: (input: { id: string; companyId: string }) => Promise<DesktopResult<boolean>>;
|
||||||
|
// Auto-assign
|
||||||
|
autoAssignTask: (input: z.infer<typeof autoAssignSchema>) => Promise<DesktopResult<string>>;
|
||||||
subscribe: (callback: (event: DesktopEvent) => void) => () => void;
|
subscribe: (callback: (event: DesktopEvent) => void) => () => void;
|
||||||
};
|
};
|
||||||
+483
-3
@@ -1,8 +1,12 @@
|
|||||||
export type SectionId =
|
export type SectionId =
|
||||||
| "overview"
|
| "overview"
|
||||||
|
| "inbox"
|
||||||
|
| "standup"
|
||||||
|
| "performance"
|
||||||
| "connectors"
|
| "connectors"
|
||||||
| "agents"
|
| "agents"
|
||||||
| "orgchart"
|
| "orgchart"
|
||||||
|
| "hiring"
|
||||||
| "goals"
|
| "goals"
|
||||||
| "projects"
|
| "projects"
|
||||||
| "tasks"
|
| "tasks"
|
||||||
@@ -10,6 +14,13 @@ export type SectionId =
|
|||||||
| "costs"
|
| "costs"
|
||||||
| "activity"
|
| "activity"
|
||||||
| "workspaces"
|
| "workspaces"
|
||||||
|
| "social"
|
||||||
|
| "automation"
|
||||||
|
| "communication"
|
||||||
|
| "documents"
|
||||||
|
| "knowledge"
|
||||||
|
| "meetings"
|
||||||
|
| "sprints"
|
||||||
| "settings";
|
| "settings";
|
||||||
|
|
||||||
export type ConnectorId =
|
export type ConnectorId =
|
||||||
@@ -41,6 +52,18 @@ export type GoalStatus = "planned" | "active" | "achieved" | "cancelled";
|
|||||||
export type ProjectStatus = "backlog" | "planned" | "in_progress" | "completed" | "cancelled";
|
export type ProjectStatus = "backlog" | "planned" | "in_progress" | "completed" | "cancelled";
|
||||||
export type TaskStatus = "backlog" | "todo" | "in_progress" | "in_review" | "done" | "blocked" | "cancelled";
|
export type TaskStatus = "backlog" | "todo" | "in_progress" | "in_review" | "done" | "blocked" | "cancelled";
|
||||||
export type ApprovalState = "pending" | "approved" | "rejected" | "revision_requested";
|
export type ApprovalState = "pending" | "approved" | "rejected" | "revision_requested";
|
||||||
|
export type ApprovalType =
|
||||||
|
| "hire_agent"
|
||||||
|
| "approve_ceo_strategy"
|
||||||
|
| "dangerous_command"
|
||||||
|
| "secret_access"
|
||||||
|
| "budget_request"
|
||||||
|
| "promotion"
|
||||||
|
| "termination"
|
||||||
|
| "vendor_approval"
|
||||||
|
| "document_review"
|
||||||
|
| "production_deploy"
|
||||||
|
| "incident_escalation";
|
||||||
export type RunStatus =
|
export type RunStatus =
|
||||||
| "queued"
|
| "queued"
|
||||||
| "running"
|
| "running"
|
||||||
@@ -99,12 +122,28 @@ export interface WorkspaceRecord {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Departments ────────────────────────────────────────────────────────
|
||||||
|
export type DepartmentId =
|
||||||
|
| "executive"
|
||||||
|
| "engineering"
|
||||||
|
| "product"
|
||||||
|
| "marketing"
|
||||||
|
| "sales"
|
||||||
|
| "hr"
|
||||||
|
| "finance"
|
||||||
|
| "legal"
|
||||||
|
| "operations"
|
||||||
|
| "customer_support"
|
||||||
|
| "research"
|
||||||
|
| "design";
|
||||||
|
|
||||||
export interface AgentRecord {
|
export interface AgentRecord {
|
||||||
id: string;
|
id: string;
|
||||||
companyId: string;
|
companyId: string;
|
||||||
name: string;
|
name: string;
|
||||||
role: string;
|
role: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
department: DepartmentId | null;
|
||||||
status: AgentStatus;
|
status: AgentStatus;
|
||||||
reportsTo: string | null;
|
reportsTo: string | null;
|
||||||
connectorId: ConnectorId;
|
connectorId: ConnectorId;
|
||||||
@@ -172,7 +211,7 @@ export interface ApprovalRecord {
|
|||||||
relatedTaskId: string | null;
|
relatedTaskId: string | null;
|
||||||
relatedAgentId: string | null;
|
relatedAgentId: string | null;
|
||||||
requestedByAgentId: string | null;
|
requestedByAgentId: string | null;
|
||||||
type: "hire_agent" | "approve_ceo_strategy" | "dangerous_command" | "secret_access";
|
type: ApprovalType;
|
||||||
payloadSummary: string;
|
payloadSummary: string;
|
||||||
impactSummary: string;
|
impactSummary: string;
|
||||||
payloadJson: string | null;
|
payloadJson: string | null;
|
||||||
@@ -258,6 +297,15 @@ export interface ProfileSnapshot {
|
|||||||
activity: ActivityRecord[];
|
activity: ActivityRecord[];
|
||||||
secrets: SecretRecord[];
|
secrets: SecretRecord[];
|
||||||
comments: CommentRecord[];
|
comments: CommentRecord[];
|
||||||
|
socialAccounts: SocialAccountRecord[];
|
||||||
|
browserActions: BrowserActionRecord[];
|
||||||
|
meetings: MeetingRecord[];
|
||||||
|
documents: DocumentRecord[];
|
||||||
|
knowledgeBase: KnowledgeEntryRecord[];
|
||||||
|
sprints: SprintRecord[];
|
||||||
|
automationRules: AutomationRuleRecord[];
|
||||||
|
agentMessages: AgentMessageRecord[];
|
||||||
|
workflows: WorkflowPipelineRecord[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LogChunkEvent {
|
export interface LogChunkEvent {
|
||||||
@@ -283,7 +331,7 @@ export interface RunStatusEvent {
|
|||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type HeartbeatTrigger = "manual" | "timer" | "assignment" | "goal_activated" | "approval_resolved" | "subtask_completed" | "comment" | "report_blocked" | "report_failed" | "recovery";
|
export type HeartbeatTrigger = "manual" | "timer" | "assignment" | "goal_activated" | "approval_resolved" | "subtask_completed" | "comment" | "report_blocked" | "report_failed" | "recovery" | "message" | "peer_request";
|
||||||
|
|
||||||
export interface CommentRecord {
|
export interface CommentRecord {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -313,10 +361,442 @@ export interface AgentMessageEvent {
|
|||||||
comment: CommentRecord;
|
comment: CommentRecord;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AutomationFiredEvent {
|
||||||
|
type: "automation-fired";
|
||||||
|
ruleId: string;
|
||||||
|
ruleName: string;
|
||||||
|
trigger: AutomationTrigger;
|
||||||
|
action: AutomationAction;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NewMessageEvent {
|
||||||
|
type: "new-message";
|
||||||
|
message: AgentMessageRecord;
|
||||||
|
}
|
||||||
|
|
||||||
export type DesktopEvent =
|
export type DesktopEvent =
|
||||||
| LogChunkEvent
|
| LogChunkEvent
|
||||||
| DomainChangedEvent
|
| DomainChangedEvent
|
||||||
| ShortcutEvent
|
| ShortcutEvent
|
||||||
| RunStatusEvent
|
| RunStatusEvent
|
||||||
| HeartbeatStatusEvent
|
| HeartbeatStatusEvent
|
||||||
| AgentMessageEvent;
|
| AgentMessageEvent
|
||||||
|
| AutomationFiredEvent
|
||||||
|
| NewMessageEvent;
|
||||||
|
|
||||||
|
// ── Inbox ──────────────────────────────────────────────────────────────
|
||||||
|
export type InboxItemType = "pending_approval" | "failed_run" | "budget_warning" | "blocked_task" | "review_needed" | "agent_message";
|
||||||
|
|
||||||
|
export interface InboxItem {
|
||||||
|
id: string;
|
||||||
|
type: InboxItemType;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
severity: "critical" | "high" | "medium" | "low";
|
||||||
|
entityType: string;
|
||||||
|
entityId: string;
|
||||||
|
agentId: string | null;
|
||||||
|
agentName: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
dismissed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Performance Metrics ────────────────────────────────────────────────
|
||||||
|
export interface AgentMetrics {
|
||||||
|
agentId: string;
|
||||||
|
agentName: string;
|
||||||
|
tasksCompleted: number;
|
||||||
|
tasksAssigned: number;
|
||||||
|
tasksFailed: number;
|
||||||
|
successRate: number; // 0-100
|
||||||
|
avgRunDurationSec: number;
|
||||||
|
totalCostUsd: number;
|
||||||
|
runsTotal: number;
|
||||||
|
runsSucceeded: number;
|
||||||
|
runsFailed: number;
|
||||||
|
reviewCycles: number; // how many times work was sent back for revision
|
||||||
|
lastActiveAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompanyMetrics {
|
||||||
|
totalAgents: number;
|
||||||
|
activeAgents: number;
|
||||||
|
totalTasks: number;
|
||||||
|
completedTasks: number;
|
||||||
|
blockedTasks: number;
|
||||||
|
totalRuns: number;
|
||||||
|
successfulRuns: number;
|
||||||
|
failedRuns: number;
|
||||||
|
overallSuccessRate: number;
|
||||||
|
totalCostUsd: number;
|
||||||
|
avgCostPerTask: number;
|
||||||
|
throughputTasksPerDay: number;
|
||||||
|
agentMetrics: AgentMetrics[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Company Template ───────────────────────────────────────────────────
|
||||||
|
export interface CompanyTemplate {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
icon: string;
|
||||||
|
agents: Array<{
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
title: string;
|
||||||
|
department: DepartmentId;
|
||||||
|
capabilities: string;
|
||||||
|
reportsToIndex: number | null; // index in this array, null = CEO
|
||||||
|
connectorPreference: ConnectorId;
|
||||||
|
budgetMonthlyUsd: number;
|
||||||
|
}>;
|
||||||
|
goals: Array<{
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}>;
|
||||||
|
departments?: Array<{
|
||||||
|
id: DepartmentId;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Social Media / Browser Automation ──────────────────────────────
|
||||||
|
export type SocialPlatform = "twitter" | "linkedin" | "instagram" | "facebook" | "reddit" | "youtube" | "tiktok" | "bluesky" | "other";
|
||||||
|
export type SocialAccountStatus = "active" | "logged_in" | "login_required" | "suspended" | "paused";
|
||||||
|
export type BrowserActionType = "post" | "reply" | "like" | "follow" | "browse_feed" | "screenshot" | "navigate" | "search" | "dm" | "custom_script";
|
||||||
|
export type BrowserActionStatus = "queued" | "running" | "succeeded" | "failed" | "cancelled" | "approval_required";
|
||||||
|
|
||||||
|
export interface SocialAccountRecord {
|
||||||
|
id: string;
|
||||||
|
companyId: string;
|
||||||
|
platform: SocialPlatform;
|
||||||
|
accountName: string;
|
||||||
|
displayName: string;
|
||||||
|
profileUrl: string;
|
||||||
|
/** Secret ID for stored credentials (username/password/cookies) */
|
||||||
|
credentialSecretId: string | null;
|
||||||
|
status: SocialAccountStatus;
|
||||||
|
/** Whether posts require Board approval before publishing */
|
||||||
|
requireApproval: boolean;
|
||||||
|
/** JSON metadata: bio, follower count, etc. */
|
||||||
|
metadataJson: string;
|
||||||
|
lastUsedAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrowserActionRecord {
|
||||||
|
id: string;
|
||||||
|
companyId: string;
|
||||||
|
socialAccountId: string;
|
||||||
|
agentId: string;
|
||||||
|
taskId: string | null;
|
||||||
|
actionType: BrowserActionType;
|
||||||
|
/** JSON payload: content, targetUrl, selector, script, etc. */
|
||||||
|
payloadJson: string;
|
||||||
|
status: BrowserActionStatus;
|
||||||
|
/** Result summary after execution */
|
||||||
|
resultSummary: string;
|
||||||
|
/** Screenshot path after execution */
|
||||||
|
screenshotPath: string | null;
|
||||||
|
/** Approval ID if approval was required */
|
||||||
|
approvalId: string | null;
|
||||||
|
errorMessage: string | null;
|
||||||
|
startedAt: string | null;
|
||||||
|
finishedAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Meetings ──────────────────────────────────────────────────────────
|
||||||
|
export type MeetingType =
|
||||||
|
| "standup"
|
||||||
|
| "sprint_planning"
|
||||||
|
| "sprint_review"
|
||||||
|
| "retrospective"
|
||||||
|
| "one_on_one"
|
||||||
|
| "all_hands"
|
||||||
|
| "department_sync"
|
||||||
|
| "incident_review"
|
||||||
|
| "hiring_committee"
|
||||||
|
| "budget_review"
|
||||||
|
| "design_review"
|
||||||
|
| "architecture_review";
|
||||||
|
|
||||||
|
export type MeetingStatus = "scheduled" | "in_progress" | "completed" | "cancelled";
|
||||||
|
|
||||||
|
export interface MeetingRecord {
|
||||||
|
id: string;
|
||||||
|
companyId: string;
|
||||||
|
type: MeetingType;
|
||||||
|
title: string;
|
||||||
|
organizerAgentId: string | null;
|
||||||
|
participantAgentIds: string; // JSON array of agent IDs
|
||||||
|
scheduledAt: string;
|
||||||
|
durationMinutes: number;
|
||||||
|
agendaJson: string;
|
||||||
|
notesJson: string;
|
||||||
|
decisionsJson: string;
|
||||||
|
actionItemsJson: string;
|
||||||
|
status: MeetingStatus;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Documents ─────────────────────────────────────────────────────────
|
||||||
|
export type DocumentType =
|
||||||
|
| "prd"
|
||||||
|
| "technical_spec"
|
||||||
|
| "design_doc"
|
||||||
|
| "test_plan"
|
||||||
|
| "post_mortem"
|
||||||
|
| "meeting_notes"
|
||||||
|
| "budget_proposal"
|
||||||
|
| "hiring_requisition"
|
||||||
|
| "performance_review_doc"
|
||||||
|
| "sop"
|
||||||
|
| "knowledge_article"
|
||||||
|
| "project_brief"
|
||||||
|
| "sprint_report"
|
||||||
|
| "incident_report"
|
||||||
|
| "onboarding_guide"
|
||||||
|
| "architecture_decision"
|
||||||
|
| "status_report"
|
||||||
|
| "contract"
|
||||||
|
| "proposal";
|
||||||
|
|
||||||
|
export type DocumentStatus = "draft" | "in_review" | "approved" | "archived" | "superseded";
|
||||||
|
|
||||||
|
export interface DocumentRecord {
|
||||||
|
id: string;
|
||||||
|
companyId: string;
|
||||||
|
type: DocumentType;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
authorAgentId: string | null;
|
||||||
|
reviewerAgentId: string | null;
|
||||||
|
projectId: string | null;
|
||||||
|
goalId: string | null;
|
||||||
|
parentDocId: string | null;
|
||||||
|
version: number;
|
||||||
|
status: DocumentStatus;
|
||||||
|
tagsJson: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Knowledge Base ────────────────────────────────────────────────────
|
||||||
|
export type KnowledgeCategory =
|
||||||
|
| "lesson_learned"
|
||||||
|
| "best_practice"
|
||||||
|
| "decision"
|
||||||
|
| "process"
|
||||||
|
| "technical"
|
||||||
|
| "business"
|
||||||
|
| "onboarding"
|
||||||
|
| "incident";
|
||||||
|
|
||||||
|
export interface KnowledgeEntryRecord {
|
||||||
|
id: string;
|
||||||
|
companyId: string;
|
||||||
|
category: KnowledgeCategory;
|
||||||
|
topic: string;
|
||||||
|
content: string;
|
||||||
|
authorAgentId: string | null;
|
||||||
|
importance: "critical" | "high" | "medium" | "low";
|
||||||
|
tagsJson: string;
|
||||||
|
referencedEntityType: string | null;
|
||||||
|
referencedEntityId: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sprints ───────────────────────────────────────────────────────────
|
||||||
|
export type SprintStatus = "planning" | "active" | "review" | "completed" | "cancelled";
|
||||||
|
|
||||||
|
export interface SprintRecord {
|
||||||
|
id: string;
|
||||||
|
companyId: string;
|
||||||
|
name: string;
|
||||||
|
goal: string;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
status: SprintStatus;
|
||||||
|
retrospectiveNotes: string;
|
||||||
|
velocityPoints: number;
|
||||||
|
completedPoints: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Performance Reviews ───────────────────────────────────────────────
|
||||||
|
export type ReviewStatus = "draft" | "submitted" | "acknowledged";
|
||||||
|
|
||||||
|
export interface PerformanceReviewRecord {
|
||||||
|
id: string;
|
||||||
|
companyId: string;
|
||||||
|
agentId: string;
|
||||||
|
reviewerAgentId: string;
|
||||||
|
period: string; // e.g., "2026-Q1"
|
||||||
|
rating: number; // 1-5
|
||||||
|
strengths: string;
|
||||||
|
areasForImprovement: string;
|
||||||
|
goals: string;
|
||||||
|
status: ReviewStatus;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Standup Report ─────────────────────────────────────────────────────
|
||||||
|
export interface StandupReport {
|
||||||
|
companyId: string;
|
||||||
|
generatedAt: string;
|
||||||
|
summary: string;
|
||||||
|
agentReports: Array<{
|
||||||
|
agentId: string;
|
||||||
|
agentName: string;
|
||||||
|
role: string;
|
||||||
|
status: AgentStatus;
|
||||||
|
tasksInProgress: number;
|
||||||
|
tasksCompleted24h: number;
|
||||||
|
tasksBlocked: number;
|
||||||
|
lastRunStatus: RunStatus | null;
|
||||||
|
lastRunAt: string | null;
|
||||||
|
costLast24h: number;
|
||||||
|
highlights: string[];
|
||||||
|
}>;
|
||||||
|
companyHighlights: string[];
|
||||||
|
blockers: string[];
|
||||||
|
pendingApprovals: number;
|
||||||
|
budgetUtilization: number; // 0-100
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Automation Engine ─────────────────────────────────────────────────
|
||||||
|
export type AutomationTrigger =
|
||||||
|
| "task_status_changed"
|
||||||
|
| "task_created"
|
||||||
|
| "task_blocked"
|
||||||
|
| "run_completed"
|
||||||
|
| "run_failed"
|
||||||
|
| "approval_resolved"
|
||||||
|
| "budget_threshold"
|
||||||
|
| "agent_idle"
|
||||||
|
| "comment_posted"
|
||||||
|
| "document_created"
|
||||||
|
| "schedule"
|
||||||
|
| "hire_approved"
|
||||||
|
| "sprint_started"
|
||||||
|
| "sprint_ended";
|
||||||
|
|
||||||
|
export type AutomationAction =
|
||||||
|
| "assign_task"
|
||||||
|
| "create_task"
|
||||||
|
| "notify_agent"
|
||||||
|
| "send_message"
|
||||||
|
| "trigger_heartbeat"
|
||||||
|
| "create_approval"
|
||||||
|
| "update_task_status"
|
||||||
|
| "escalate_to_manager"
|
||||||
|
| "cross_department_notify"
|
||||||
|
| "schedule_meeting"
|
||||||
|
| "create_document"
|
||||||
|
| "auto_approve"
|
||||||
|
| "reassign_task"
|
||||||
|
| "run_task";
|
||||||
|
|
||||||
|
export type AutomationStatus = "active" | "paused" | "disabled" | "error";
|
||||||
|
|
||||||
|
export interface AutomationRuleRecord {
|
||||||
|
id: string;
|
||||||
|
companyId: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
trigger: AutomationTrigger;
|
||||||
|
/** JSON conditions: e.g., {"status": "blocked", "department": "engineering"} */
|
||||||
|
conditionsJson: string;
|
||||||
|
action: AutomationAction;
|
||||||
|
/** JSON action config: e.g., {"targetAgentId": "...", "message": "..."} */
|
||||||
|
actionConfigJson: string;
|
||||||
|
/** Source department that triggers (null = any) */
|
||||||
|
sourceDepartment: DepartmentId | null;
|
||||||
|
/** Target department for cross-department rules (null = same) */
|
||||||
|
targetDepartment: DepartmentId | null;
|
||||||
|
priority: number;
|
||||||
|
status: AutomationStatus;
|
||||||
|
/** How many times this rule has fired */
|
||||||
|
executionCount: number;
|
||||||
|
lastExecutedAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Agent Messages (Direct Communication) ─────────────────────────────
|
||||||
|
export type MessageChannel = "direct" | "department" | "company" | "project" | "incident";
|
||||||
|
export type MessagePriority = "urgent" | "normal" | "low";
|
||||||
|
|
||||||
|
export interface AgentMessageRecord {
|
||||||
|
id: string;
|
||||||
|
companyId: string;
|
||||||
|
fromAgentId: string;
|
||||||
|
/** null = broadcast to channel */
|
||||||
|
toAgentId: string | null;
|
||||||
|
channel: MessageChannel;
|
||||||
|
/** Department/project/incident ID for channel messages */
|
||||||
|
channelTargetId: string | null;
|
||||||
|
subject: string;
|
||||||
|
body: string;
|
||||||
|
priority: MessagePriority;
|
||||||
|
/** Whether the recipient has read this message */
|
||||||
|
readAt: string | null;
|
||||||
|
/** Parent message ID for threads */
|
||||||
|
parentMessageId: string | null;
|
||||||
|
/** JSON: attached entity references [{type, id, title}] */
|
||||||
|
attachmentsJson: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Workflow Pipeline ──────────────────────────────────────────────────
|
||||||
|
export type WorkflowStatus = "draft" | "active" | "paused" | "completed" | "failed";
|
||||||
|
|
||||||
|
export interface WorkflowPipelineRecord {
|
||||||
|
id: string;
|
||||||
|
companyId: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
/** JSON array of pipeline steps */
|
||||||
|
stepsJson: string;
|
||||||
|
triggerType: AutomationTrigger;
|
||||||
|
triggerConfigJson: string;
|
||||||
|
status: WorkflowStatus;
|
||||||
|
currentStepIndex: number;
|
||||||
|
/** How many times this pipeline has run */
|
||||||
|
runCount: number;
|
||||||
|
lastRunAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkflowStep {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
action: AutomationAction;
|
||||||
|
configJson: string;
|
||||||
|
assigneeDepartment: DepartmentId | null;
|
||||||
|
assigneeAgentId: string | null;
|
||||||
|
/** Wait for approval before proceeding */
|
||||||
|
requiresApproval: boolean;
|
||||||
|
/** Timeout in seconds (0 = no timeout) */
|
||||||
|
timeoutSec: number;
|
||||||
|
/** Status of this step */
|
||||||
|
status: "pending" | "running" | "completed" | "failed" | "skipped";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cross-Department Trigger Table ────────────────────────────────────
|
||||||
|
export interface CrossDeptTrigger {
|
||||||
|
sourceEvent: string;
|
||||||
|
sourceDepartment: DepartmentId;
|
||||||
|
targetDepartment: DepartmentId;
|
||||||
|
targetAction: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
@@ -93,6 +93,10 @@ function buildCtx(overrides: Record<string, unknown> = {}): Parameters<typeof bu
|
|||||||
pendingApprovals: overrides.pendingApprovals as ApprovalRecord[] ?? [],
|
pendingApprovals: overrides.pendingApprovals as ApprovalRecord[] ?? [],
|
||||||
tasksAwaitingReview: overrides.tasksAwaitingReview as TaskRecord[] ?? [],
|
tasksAwaitingReview: overrides.tasksAwaitingReview as TaskRecord[] ?? [],
|
||||||
wakeReason: overrides.wakeReason as string | null ?? null,
|
wakeReason: overrides.wakeReason as string | null ?? null,
|
||||||
|
allTasks: [],
|
||||||
|
recentComments: [],
|
||||||
|
companyDescription: "",
|
||||||
|
socialAccounts: [],
|
||||||
...overrides,
|
...overrides,
|
||||||
} as Parameters<typeof buildSystemPrompt>[0];
|
} as Parameters<typeof buildSystemPrompt>[0];
|
||||||
}
|
}
|
||||||
@@ -1054,3 +1058,278 @@ describe("Review Fix: Root task completion notification", () => {
|
|||||||
expect(chain[0].id).toBe(ceoId);
|
expect(chain[0].id).toBe(ceoId);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Automation Rules CRUD ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("Automation Rules CRUD", () => {
|
||||||
|
it("creates, reads, toggles, and deletes an automation rule", () => {
|
||||||
|
const ruleId = db.saveAutomationRule({
|
||||||
|
companyId,
|
||||||
|
name: "Auto-escalate blocked tasks",
|
||||||
|
description: "When a task is blocked, notify the manager",
|
||||||
|
trigger: "task_blocked",
|
||||||
|
conditionsJson: JSON.stringify({ department: "engineering" }),
|
||||||
|
action: "escalate_to_manager",
|
||||||
|
actionConfigJson: JSON.stringify({ message: "Task blocked" }),
|
||||||
|
sourceDepartment: "engineering",
|
||||||
|
targetDepartment: "executive",
|
||||||
|
priority: 80,
|
||||||
|
status: "active",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(ruleId).toBeTruthy();
|
||||||
|
|
||||||
|
// Rule appears in snapshot
|
||||||
|
const snapshot = db.listSnapshot();
|
||||||
|
const rule = snapshot.automationRules.find(r => r.id === ruleId);
|
||||||
|
expect(rule).toBeTruthy();
|
||||||
|
expect(rule!.name).toBe("Auto-escalate blocked tasks");
|
||||||
|
expect(rule!.trigger).toBe("task_blocked");
|
||||||
|
expect(rule!.action).toBe("escalate_to_manager");
|
||||||
|
expect(rule!.status).toBe("active");
|
||||||
|
expect(rule!.priority).toBe(80);
|
||||||
|
expect(rule!.executionCount).toBe(0);
|
||||||
|
|
||||||
|
// Toggle to paused
|
||||||
|
db.toggleAutomationRule(ruleId, companyId, "paused");
|
||||||
|
const updated = db.listSnapshot().automationRules.find(r => r.id === ruleId);
|
||||||
|
expect(updated!.status).toBe("paused");
|
||||||
|
|
||||||
|
// Record execution
|
||||||
|
db.recordAutomationExecution(ruleId, companyId, "Auto-escalate blocked tasks", "task_blocked", "escalate_to_manager", "Escalated task-123");
|
||||||
|
const afterExec = db.listSnapshot().automationRules.find(r => r.id === ruleId);
|
||||||
|
expect(afterExec!.executionCount).toBe(1);
|
||||||
|
expect(afterExec!.lastExecutedAt).toBeTruthy();
|
||||||
|
|
||||||
|
// Automation log
|
||||||
|
const log = db.getAutomationLog(companyId);
|
||||||
|
expect(log).toHaveLength(1);
|
||||||
|
expect(log[0].ruleName).toBe("Auto-escalate blocked tasks");
|
||||||
|
|
||||||
|
// Delete
|
||||||
|
db.deleteAutomationRule(ruleId, companyId);
|
||||||
|
const afterDelete = db.listSnapshot().automationRules.find(r => r.id === ruleId);
|
||||||
|
expect(afterDelete).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates an existing rule", () => {
|
||||||
|
const ruleId = db.saveAutomationRule({
|
||||||
|
companyId,
|
||||||
|
name: "Original name",
|
||||||
|
trigger: "task_created",
|
||||||
|
action: "assign_task",
|
||||||
|
});
|
||||||
|
|
||||||
|
db.saveAutomationRule({
|
||||||
|
id: ruleId,
|
||||||
|
companyId,
|
||||||
|
name: "Updated name",
|
||||||
|
trigger: "task_created",
|
||||||
|
action: "notify_agent",
|
||||||
|
priority: 90,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rule = db.listSnapshot().automationRules.find(r => r.id === ruleId);
|
||||||
|
expect(rule!.name).toBe("Updated name");
|
||||||
|
expect(rule!.action).toBe("notify_agent");
|
||||||
|
expect(rule!.priority).toBe(90);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Agent Messages ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("Agent Messages", () => {
|
||||||
|
let agentAId: string;
|
||||||
|
let agentBId: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
agentAId = db.saveAgent({
|
||||||
|
companyId, name: "Alice", role: "Engineer", title: "Senior Engineer",
|
||||||
|
department: "engineering", connectorId: "claude_local", status: "active",
|
||||||
|
capabilities: "Backend development, API design, code review",
|
||||||
|
budgetMonthlyUsd: 100,
|
||||||
|
});
|
||||||
|
agentBId = db.saveAgent({
|
||||||
|
companyId, name: "Bob", role: "Designer", title: "UX Designer",
|
||||||
|
department: "design", connectorId: "claude_local", status: "active",
|
||||||
|
capabilities: "UI design, wireframing, user research",
|
||||||
|
budgetMonthlyUsd: 100,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends and retrieves direct messages", () => {
|
||||||
|
const msgId = db.sendAgentMessage({
|
||||||
|
companyId,
|
||||||
|
fromAgentId: agentAId,
|
||||||
|
toAgentId: agentBId,
|
||||||
|
channel: "direct",
|
||||||
|
subject: "Need design review",
|
||||||
|
body: "Can you review the login page mockup?",
|
||||||
|
priority: "normal",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(msgId).toBeTruthy();
|
||||||
|
|
||||||
|
const messages = db.listAgentMessages(companyId);
|
||||||
|
expect(messages).toHaveLength(1);
|
||||||
|
expect(messages[0].subject).toBe("Need design review");
|
||||||
|
expect(messages[0].fromAgentId).toBe(agentAId);
|
||||||
|
expect(messages[0].toAgentId).toBe(agentBId);
|
||||||
|
expect(messages[0].readAt).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks messages as read", () => {
|
||||||
|
const msgId = db.sendAgentMessage({
|
||||||
|
companyId,
|
||||||
|
fromAgentId: agentAId,
|
||||||
|
toAgentId: agentBId,
|
||||||
|
subject: "Test",
|
||||||
|
body: "Hello",
|
||||||
|
});
|
||||||
|
|
||||||
|
db.markMessageRead(msgId, companyId);
|
||||||
|
const messages = db.listAgentMessages(companyId);
|
||||||
|
expect(messages[0].readAt).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters messages by channel", () => {
|
||||||
|
db.sendAgentMessage({ companyId, fromAgentId: agentAId, subject: "Dept update", body: "All good", channel: "department", channelTargetId: "engineering" });
|
||||||
|
db.sendAgentMessage({ companyId, fromAgentId: agentAId, toAgentId: agentBId, subject: "DM", body: "Hey", channel: "direct" });
|
||||||
|
|
||||||
|
const deptMsgs = db.listAgentMessages(companyId, { channel: "department" });
|
||||||
|
expect(deptMsgs).toHaveLength(1);
|
||||||
|
expect(deptMsgs[0].subject).toBe("Dept update");
|
||||||
|
|
||||||
|
const directMsgs = db.listAgentMessages(companyId, { channel: "direct" });
|
||||||
|
expect(directMsgs).toHaveLength(1);
|
||||||
|
expect(directMsgs[0].subject).toBe("DM");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appears in snapshot", () => {
|
||||||
|
db.sendAgentMessage({ companyId, fromAgentId: agentAId, subject: "Snapshot test", body: "Hi" });
|
||||||
|
const snapshot = db.listSnapshot();
|
||||||
|
expect(snapshot.agentMessages.length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(snapshot.agentMessages[0].subject).toBe("Snapshot test");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Auto Task Assignment ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("Auto Task Assignment", () => {
|
||||||
|
let backendId: string;
|
||||||
|
let frontendId: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
backendId = db.saveAgent({
|
||||||
|
companyId, name: "Backend Dev", role: "Backend Engineer", title: "Senior Backend",
|
||||||
|
department: "engineering", connectorId: "codex_local", status: "active",
|
||||||
|
capabilities: "API development, database design, Node.js, PostgreSQL, backend testing",
|
||||||
|
budgetMonthlyUsd: 200,
|
||||||
|
});
|
||||||
|
frontendId = db.saveAgent({
|
||||||
|
companyId, name: "Frontend Dev", role: "Frontend Engineer", title: "Senior Frontend",
|
||||||
|
department: "engineering", connectorId: "codex_local", status: "active",
|
||||||
|
capabilities: "React, TypeScript, CSS, Tailwind, responsive design, frontend testing, UI components",
|
||||||
|
budgetMonthlyUsd: 200,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("assigns to the best-matching agent by capability keywords", () => {
|
||||||
|
const taskId = db.saveTask({
|
||||||
|
companyId, title: "Build React component for user dashboard",
|
||||||
|
description: "Create a responsive React component with Tailwind CSS styling",
|
||||||
|
priority: "medium", status: "todo",
|
||||||
|
});
|
||||||
|
|
||||||
|
const assignedId = db.autoAssignTask(taskId, companyId);
|
||||||
|
expect(assignedId).toBe(frontendId); // "React", "responsive", "Tailwind" match frontend
|
||||||
|
});
|
||||||
|
|
||||||
|
it("assigns backend task to backend agent", () => {
|
||||||
|
const taskId = db.saveTask({
|
||||||
|
companyId, title: "Design database schema for orders",
|
||||||
|
description: "PostgreSQL database design with API endpoints for order management",
|
||||||
|
priority: "high", status: "todo",
|
||||||
|
});
|
||||||
|
|
||||||
|
const assignedId = db.autoAssignTask(taskId, companyId);
|
||||||
|
expect(assignedId).toBe(backendId); // "database", "PostgreSQL", "API" match backend
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns existing assignee if task already assigned", () => {
|
||||||
|
const taskId = db.saveTask({
|
||||||
|
companyId, title: "Already assigned task",
|
||||||
|
description: "This task has an assignee",
|
||||||
|
assigneeAgentId: frontendId,
|
||||||
|
priority: "medium", status: "todo",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = db.autoAssignTask(taskId, companyId);
|
||||||
|
expect(result).toBe(frontendId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null if no agents have remaining budget", () => {
|
||||||
|
// Exhaust both agents' budgets
|
||||||
|
db.saveAgent({ id: backendId, companyId, name: "Backend Dev", role: "Backend", title: "Backend", connectorId: "codex_local", status: "active", capabilities: "backend", budgetMonthlyUsd: 0 });
|
||||||
|
db.saveAgent({ id: frontendId, companyId, name: "Frontend Dev", role: "Frontend", title: "Frontend", connectorId: "codex_local", status: "active", capabilities: "frontend", budgetMonthlyUsd: 0 });
|
||||||
|
|
||||||
|
const taskId = db.saveTask({ companyId, title: "No budget task", description: "Test", priority: "low", status: "todo" });
|
||||||
|
const result = db.autoAssignTask(taskId, companyId);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("penalizes agents with many active tasks", () => {
|
||||||
|
// Give backend agent 5 active tasks
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
db.saveTask({ companyId, title: `Backend task ${i}`, description: "", assigneeAgentId: backendId, priority: "medium", status: "in_progress" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic task with no strong keyword match — should go to less busy frontend agent
|
||||||
|
const taskId = db.saveTask({ companyId, title: "Implement feature", description: "Build the feature", priority: "medium", status: "todo" });
|
||||||
|
const assignedId = db.autoAssignTask(taskId, companyId);
|
||||||
|
expect(assignedId).toBe(frontendId); // Frontend has 0 active tasks vs backend's 5
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Workflow Pipelines ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("Workflow Pipelines", () => {
|
||||||
|
it("creates, activates, and deletes a workflow", () => {
|
||||||
|
const wfId = db.saveWorkflow({
|
||||||
|
companyId,
|
||||||
|
name: "Code Review Pipeline",
|
||||||
|
description: "Auto-review flow for PRs",
|
||||||
|
triggerType: "task_status_changed",
|
||||||
|
triggerConfigJson: JSON.stringify({ toStatus: "in_review" }),
|
||||||
|
stepsJson: JSON.stringify([
|
||||||
|
{ id: "s1", name: "Run tests", action: "run_task", status: "pending" },
|
||||||
|
{ id: "s2", name: "Notify reviewer", action: "notify_agent", status: "pending" },
|
||||||
|
]),
|
||||||
|
status: "draft",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wfId).toBeTruthy();
|
||||||
|
const snapshot = db.listSnapshot();
|
||||||
|
const wf = snapshot.workflows.find(w => w.id === wfId);
|
||||||
|
expect(wf!.name).toBe("Code Review Pipeline");
|
||||||
|
expect(wf!.status).toBe("draft");
|
||||||
|
expect(wf!.runCount).toBe(0);
|
||||||
|
|
||||||
|
// Activate
|
||||||
|
db.activateWorkflow(wfId, companyId);
|
||||||
|
const activated = db.listSnapshot().workflows.find(w => w.id === wfId);
|
||||||
|
expect(activated!.status).toBe("active");
|
||||||
|
expect(activated!.runCount).toBe(1);
|
||||||
|
expect(activated!.lastRunAt).toBeTruthy();
|
||||||
|
|
||||||
|
// Delete
|
||||||
|
db.deleteWorkflow(wfId, companyId);
|
||||||
|
expect(db.listSnapshot().workflows.find(w => w.id === wfId)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects activating an already-active workflow", () => {
|
||||||
|
const wfId = db.saveWorkflow({ companyId, name: "Test", triggerType: "schedule", status: "draft" });
|
||||||
|
db.activateWorkflow(wfId, companyId);
|
||||||
|
expect(() => db.activateWorkflow(wfId, companyId)).toThrow("already running");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user