update
This commit is contained in:
1 parent
41eafc7c44
commit
54bcc77ef7
19 files changed
+11420
-64
No files matched your search
@@ -49,6 +49,7 @@ export class AgentApiServer {
|
||||
private db: AppDatabase;
|
||||
private onDomainChanged: () => 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) {
|
||||
this.db = db;
|
||||
@@ -57,6 +58,10 @@ export class AgentApiServer {
|
||||
this.registerRoutes();
|
||||
}
|
||||
|
||||
setBrowserActionHandler(handler: (actionId: string, socialAccountId: string, actionType: string, payload: Record<string, unknown>) => void) {
|
||||
this.onBrowserAction = handler;
|
||||
}
|
||||
|
||||
getPort(): number {
|
||||
return this.port;
|
||||
}
|
||||
@@ -960,7 +965,430 @@ export class AgentApiServer {
|
||||
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,
|
||||
approvalInputSchema,
|
||||
agentInputSchema,
|
||||
browserActionQuerySchema,
|
||||
cancelRunSchema,
|
||||
commentInputSchema,
|
||||
companyInputSchema,
|
||||
@@ -33,16 +34,29 @@ import {
|
||||
deleteEntitySchema,
|
||||
goalInputSchema,
|
||||
heartbeatSettingsSchema,
|
||||
inboxDismissSchema,
|
||||
listCommentsSchema,
|
||||
metricsQuerySchema,
|
||||
type DesktopResult,
|
||||
projectInputSchema,
|
||||
runLogSchema,
|
||||
secretInputSchema,
|
||||
settingsSchema,
|
||||
socialAccountInputSchema,
|
||||
startRunSchema,
|
||||
taskInputSchema,
|
||||
templateApplySchema,
|
||||
triggerHeartbeatSchema,
|
||||
workspaceInputSchema,
|
||||
meetingInputSchema,
|
||||
documentInputSchema,
|
||||
knowledgeEntryInputSchema,
|
||||
sprintInputSchema,
|
||||
automationRuleInputSchema,
|
||||
agentMessageInputSchema,
|
||||
messageQuerySchema,
|
||||
workflowInputSchema,
|
||||
autoAssignSchema,
|
||||
} from "@shared/contracts";
|
||||
import type { DesktopEvent } from "@shared/types";
|
||||
import { getConnectorDefinition } from "./connectors";
|
||||
@@ -50,6 +64,8 @@ import { AppDatabase } from "./database";
|
||||
import { AgentApiServer } from "./api-server";
|
||||
import { createAgentToken } from "./agent-auth";
|
||||
import { buildTaskPrompt } from "./prompt-builder";
|
||||
import { COMPANY_TEMPLATES, getTemplate } from "./templates";
|
||||
import { BrowserManager } from "./browser-manager";
|
||||
|
||||
app.name = "AgentCompany";
|
||||
|
||||
@@ -105,6 +121,7 @@ let mainWindow: BrowserWindow | null = null;
|
||||
let db: AppDatabase;
|
||||
let worker: ReturnType<typeof utilityProcess.fork> | null = null;
|
||||
let apiServer: AgentApiServer | null = null;
|
||||
let browserManager: BrowserManager | null = null;
|
||||
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let isQuitting = false;
|
||||
let backgroundCloseNoticeShown = false;
|
||||
@@ -304,17 +321,32 @@ function buildMenu() {
|
||||
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() {
|
||||
session.defaultSession.setPermissionCheckHandler(() => false);
|
||||
session.defaultSession.setPermissionRequestHandler((_webContents, _permission, callback) => {
|
||||
callback(false);
|
||||
});
|
||||
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) => {
|
||||
event.preventDefault();
|
||||
});
|
||||
contents.on("will-navigate", (event) => {
|
||||
// Allow navigation in social login browser windows
|
||||
if (isSocialBrowserSession(contents)) return;
|
||||
event.preventDefault();
|
||||
});
|
||||
});
|
||||
@@ -464,13 +496,26 @@ function createWorker() {
|
||||
const recoveryApiUrl = recoveryApiPort ? `http://127.0.0.1:${recoveryApiPort}` : "";
|
||||
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({
|
||||
agent,
|
||||
apiUrl: recoveryApiUrl,
|
||||
apiKey: recoveryApiKey,
|
||||
runId: candidate.id,
|
||||
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),
|
||||
projects: recoverySnapshot.projects.filter((p) => p.companyId === candidate.companyId),
|
||||
directReports: db.getDirectReports(agent.id, candidate.companyId),
|
||||
@@ -484,6 +529,30 @@ function createWorker() {
|
||||
),
|
||||
tasksAwaitingReview: db.listReviewTasksForManager(agent.id, candidate.companyId),
|
||||
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;
|
||||
|
||||
// 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 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({
|
||||
agent,
|
||||
apiUrl,
|
||||
@@ -955,6 +1035,30 @@ function startAgentHeartbeatRun(agentId: string, companyId: string, trigger: str
|
||||
),
|
||||
tasksAwaitingReview: db.listReviewTasksForManager(agent.id, companyId),
|
||||
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;
|
||||
|
||||
// Add API credentials to env
|
||||
@@ -1468,18 +1572,31 @@ function registerHandlers() {
|
||||
const apiUrl = apiPort ? `http://127.0.0.1:${apiPort}` : "";
|
||||
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({
|
||||
agent,
|
||||
apiUrl,
|
||||
apiKey,
|
||||
runId: run.id,
|
||||
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),
|
||||
projects: snapshot.projects.filter((p) => p.companyId === task.companyId),
|
||||
directReports: db.getDirectReports(agent.id, task.companyId),
|
||||
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),
|
||||
pendingApprovals: snapshot.approvals.filter(
|
||||
(a) => a.companyId === task.companyId &&
|
||||
@@ -1488,6 +1605,30 @@ function registerHandlers() {
|
||||
),
|
||||
tasksAwaitingReview: db.listReviewTasksForManager(agent.id, task.companyId),
|
||||
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;
|
||||
|
||||
// Add API credentials to env
|
||||
@@ -1791,6 +1932,366 @@ function registerHandlers() {
|
||||
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() {
|
||||
@@ -1869,6 +2370,43 @@ async function bootstrap() {
|
||||
});
|
||||
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
|
||||
startHeartbeatScheduler();
|
||||
|
||||
@@ -1923,6 +2461,8 @@ app.on("before-quit", () => {
|
||||
heartbeatTimer = null;
|
||||
apiServer?.stop();
|
||||
apiServer = null;
|
||||
browserManager?.shutdown();
|
||||
browserManager = null;
|
||||
worker?.kill();
|
||||
worker = null;
|
||||
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 {
|
||||
agent: AgentRecord;
|
||||
@@ -17,17 +17,121 @@ interface PromptContext {
|
||||
/** Tasks from direct reports that are awaiting this agent's review */
|
||||
tasksAwaitingReview: TaskRecord[];
|
||||
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 {
|
||||
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
|
||||
sections.push(`# Agent Identity
|
||||
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}` : ""}
|
||||
`);
|
||||
|
||||
// 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
|
||||
if (ctx.chainOfCommand.length > 0) {
|
||||
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
|
||||
const activeGoals = ctx.goals.filter((g) => g.status === "active" || g.status === "planned");
|
||||
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
|
||||
- Always @mention the agent name in comments for clarity
|
||||
- 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
|
||||
sections.push(`# AgentCompany API
|
||||
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
|
||||
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
|
||||
const isCEO = ctx.chainOfCommand.length === 0;
|
||||
const isExecutive = isCEO;
|
||||
const isManager = !isCEO && ctx.directReports.length > 0;
|
||||
// Social media capabilities
|
||||
if (ctx.socialAccounts.length > 0) {
|
||||
const accountLines = ctx.socialAccounts.map(a =>
|
||||
`- ${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) {
|
||||
// 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.
|
||||
|
||||
## 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)
|
||||
- **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)
|
||||
- **CPO / VP Product** — owns product design, UX, features (if goals involve product development)
|
||||
- **COO / VP Operations** — owns processes, infrastructure (if goals involve operations)
|
||||
Think about what roles are MOST needed to achieve the stated goals. Start lean (3-5 direct reports max).
|
||||
- **HR Director** — owns hiring pipeline, onboarding, performance management
|
||||
- **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
|
||||
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.
|
||||
`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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) {
|
||||
// Step 2d: Manager with credential escalation instruction
|
||||
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.
|
||||
`);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 {
|
||||
// Step 2d: IC with credential escalation instruction
|
||||
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
|
||||
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
|
||||
- Always test your work before submitting for review
|
||||
- Leave clear comments so your manager can understand what was done
|
||||
- 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.",
|
||||
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.",
|
||||
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}`;
|
||||
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) {
|
||||
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 {
|
||||
|
||||
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),
|
||||
triggerHeartbeat: (input) => ipcRenderer.invoke(IPC_CHANNELS.triggerHeartbeat, input),
|
||||
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) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, payload: DesktopEvent) => callback(payload);
|
||||
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 {
|
||||
ArrowClockwise,
|
||||
Bell,
|
||||
BookOpen,
|
||||
Brain,
|
||||
CalendarBlank,
|
||||
CaretUpDown,
|
||||
ChartBar,
|
||||
ChatCircle,
|
||||
Check,
|
||||
Command,
|
||||
Cpu,
|
||||
CurrencyCircleDollar,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
Gear,
|
||||
Heartbeat,
|
||||
Kanban,
|
||||
Lightning,
|
||||
ListChecks,
|
||||
MagnifyingGlass,
|
||||
Megaphone,
|
||||
Plus,
|
||||
Pulse,
|
||||
Robot,
|
||||
Rows,
|
||||
ShieldCheck,
|
||||
Trash,
|
||||
UserPlus,
|
||||
SquaresFour,
|
||||
Target,
|
||||
TreeStructure,
|
||||
@@ -27,14 +39,23 @@ import {
|
||||
X,
|
||||
} from "@phosphor-icons/react";
|
||||
import type {
|
||||
AgentMessageRecord,
|
||||
AutomationRuleRecord,
|
||||
CompanyRecord,
|
||||
CompanyMetrics,
|
||||
DesktopEvent,
|
||||
InboxItem,
|
||||
ProfileSnapshot,
|
||||
RunRecord,
|
||||
SectionId,
|
||||
SocialAccountRecord,
|
||||
BrowserActionRecord,
|
||||
StandupReport,
|
||||
WorkflowPipelineRecord,
|
||||
} from "@shared/types";
|
||||
import type { SidebarBadges } from "@shared/contracts";
|
||||
import { CompanyOnboarding } from "./components/CompanyOnboarding";
|
||||
import { Inbox, StandupView, PerformanceView } from "./components/inbox";
|
||||
import { ConnectorEditor } from "./components/connectors";
|
||||
import { Dashboard, KanbanBoard } from "./components/dashboard";
|
||||
import { EntityWorkspace } from "./components/EntityWorkspace";
|
||||
@@ -65,6 +86,10 @@ import {
|
||||
ProjectEditor,
|
||||
ProjectList,
|
||||
} 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 { unwrap } from "./lib/desktop";
|
||||
import { formatMoney, formatTime } from "./lib/formatters";
|
||||
@@ -77,6 +102,9 @@ const navGroups: NavGroup[] = [
|
||||
label: "Overview",
|
||||
items: [
|
||||
{ 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: [
|
||||
{ id: "agents", label: "Agents", icon: Robot },
|
||||
{ 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: "projects", label: "Projects", icon: Kanban },
|
||||
{ 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",
|
||||
items: [
|
||||
{ id: "approvals", label: "Approvals", icon: ShieldCheck },
|
||||
{ id: "costs", label: "Costs", icon: CurrencyCircleDollar },
|
||||
{ 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 [commandPaletteSearch, setCommandPaletteSearch] = useState("");
|
||||
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 () => {
|
||||
try {
|
||||
@@ -147,6 +198,7 @@ export function App() {
|
||||
if (next.currentCompanyId) {
|
||||
const badgeResult = await window.agentCompany.getSidebarBadges(next.currentCompanyId);
|
||||
if (badgeResult.ok) setBadges(badgeResult.data);
|
||||
setSocialAccounts(next.socialAccounts?.filter(a => a.companyId === next.currentCompanyId) ?? []);
|
||||
}
|
||||
setError(null);
|
||||
} catch (nextError) {
|
||||
@@ -196,6 +248,36 @@ export function App() {
|
||||
root.style.colorScheme = snapshot.theme === "system" ? "light dark" : snapshot.theme;
|
||||
}, [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
|
||||
useEffect(() => {
|
||||
if (!commandPaletteOpen) return;
|
||||
@@ -233,11 +315,13 @@ export function App() {
|
||||
const selectedProject = companyProjects.find((p) => p.id === selectedProjectId) ?? null;
|
||||
|
||||
// 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 ? {
|
||||
inbox: activeInboxCount > 0 ? { count: activeInboxCount, tone: "warn" } : undefined,
|
||||
tasks: badges.todoTasks > 0 ? { count: badges.todoTasks, tone: "default" } : undefined,
|
||||
approvals: badges.pendingApprovals > 0 ? { count: badges.pendingApprovals, tone: "warn" } : 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 failedRuns = useMemo(() => runs.filter((run) => ["failed", "timed_out", "interrupted"].includes(run.status)), [runs]);
|
||||
@@ -542,6 +626,57 @@ export function App() {
|
||||
activity={filteredActivity}
|
||||
costs={companyCosts}
|
||||
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) => {
|
||||
setSection(targetSection);
|
||||
if (entityId) {
|
||||
@@ -625,6 +760,16 @@ export function App() {
|
||||
<OrgChart agents={companyAgents} onSelectAgent={(id) => { setSelectedAgentId(id); setSection("agents"); }} />
|
||||
) : 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" ? (
|
||||
<>
|
||||
<Panel title="Goal stack" action={<ActionButton label="New goal" icon={Target} tone="accent" onClick={() => setSelectedGoalId("__new__")} />}>
|
||||
@@ -794,6 +939,192 @@ export function App() {
|
||||
</Panel>
|
||||
) : 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" ? (
|
||||
<EntityWorkspace
|
||||
title="Workspaces"
|
||||
|
||||
@@ -1,21 +1,36 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
Buildings,
|
||||
CheckCircle,
|
||||
Code,
|
||||
Cpu,
|
||||
Flask,
|
||||
FolderOpen,
|
||||
Lightning,
|
||||
Megaphone,
|
||||
Robot,
|
||||
Rocket,
|
||||
Stack,
|
||||
Target,
|
||||
TreeStructure,
|
||||
Users,
|
||||
WarningCircle,
|
||||
XCircle,
|
||||
} from "@phosphor-icons/react";
|
||||
import type { ProfileSnapshot } from "@shared/types";
|
||||
import type { CompanyTemplate, ProfileSnapshot } from "@shared/types";
|
||||
import { unwrap } from "../lib/desktop";
|
||||
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({
|
||||
connectors,
|
||||
@@ -24,7 +39,7 @@ export function CompanyOnboarding({
|
||||
connectors: ProfileSnapshot["connectors"];
|
||||
onDone: () => Promise<void>;
|
||||
}) {
|
||||
const [step, setStep] = useState<Step>("company");
|
||||
const [step, setStep] = useState<Step>("choose");
|
||||
const [companyName, setCompanyName] = useState("");
|
||||
const [workspacePath, setWorkspacePath] = useState("");
|
||||
const [autoApproveHires, setAutoApproveHires] = useState(true);
|
||||
@@ -33,10 +48,20 @@ export function CompanyOnboarding({
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = 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 readyConnectors = connectors.filter((c) => c.status === "ready" || c.status === "degraded");
|
||||
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() {
|
||||
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 () => {
|
||||
if (loading) return;
|
||||
if (!companyName.trim()) {
|
||||
@@ -98,7 +171,6 @@ export function CompanyOnboarding({
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
// Create goal
|
||||
if (goalTitle.trim()) {
|
||||
unwrap(
|
||||
await window.agentCompany.saveGoal({
|
||||
@@ -111,8 +183,6 @@ export function CompanyOnboarding({
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Create CEO agent automatically
|
||||
if (bestConnector) {
|
||||
const ceoId = unwrap(
|
||||
await window.agentCompany.saveAgent({
|
||||
@@ -129,22 +199,14 @@ export function CompanyOnboarding({
|
||||
status: "idle",
|
||||
}),
|
||||
);
|
||||
|
||||
// Enable heartbeat on CEO for autonomous operation
|
||||
unwrap(await window.agentCompany.setHeartbeat({ agentId: ceoId, companyId: createdCompanyId, enabled: true, intervalSec: 120 }));
|
||||
|
||||
setStep("launch");
|
||||
|
||||
// Auto-trigger the CEO's first run after a brief delay for UI
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await window.agentCompany.triggerHeartbeat({ agentId: ceoId, companyId: createdCompanyId, trigger: "assignment" });
|
||||
} catch {
|
||||
// Non-fatal — CEO will wake on next heartbeat cycle
|
||||
}
|
||||
} catch { /* Non-fatal */ }
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
await onDone();
|
||||
} catch (submitError) {
|
||||
setError(submitError instanceof Error ? submitError.message : "Unable to set up the company.");
|
||||
@@ -165,10 +227,22 @@ export function CompanyOnboarding({
|
||||
}
|
||||
}, [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 (
|
||||
<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 */}
|
||||
<div className="flex items-center px-8 py-12 md:px-16">
|
||||
<motion.div
|
||||
@@ -187,11 +261,7 @@ export function CompanyOnboarding({
|
||||
|
||||
{/* Step indicators */}
|
||||
<div className="mt-10 flex items-center gap-3">
|
||||
{[
|
||||
{ label: "Create Company", icon: Buildings },
|
||||
{ label: "Set Mission", icon: Target },
|
||||
{ label: "Launch", icon: Rocket },
|
||||
].map((s, i) => (
|
||||
{stepLabels.map((s, i) => (
|
||||
<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}
|
||||
<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 */}
|
||||
<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">
|
||||
{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
|
||||
key="company"
|
||||
initial={{ opacity: 0, y: 18 }}
|
||||
@@ -255,7 +501,7 @@ export function CompanyOnboarding({
|
||||
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)]">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-6 h-px bg-[color:var(--line)]" />
|
||||
<div className="space-y-4">
|
||||
@@ -281,8 +527,6 @@ export function CompanyOnboarding({
|
||||
The directory where your agents will work. You can add more later.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Auto-approve 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
|
||||
@@ -305,7 +549,14 @@ export function CompanyOnboarding({
|
||||
{error}
|
||||
</div>
|
||||
) : 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
|
||||
label={loading ? "Creating..." : "Continue"}
|
||||
onClick={() => void createCompany()}
|
||||
@@ -317,6 +568,7 @@ export function CompanyOnboarding({
|
||||
</div>
|
||||
</motion.div>
|
||||
) : step === "goal" ? (
|
||||
/* ── Step: Goal ───────────────────────────────── */
|
||||
<motion.div
|
||||
key="goal"
|
||||
initial={{ opacity: 0, y: 18 }}
|
||||
@@ -325,7 +577,7 @@ export function CompanyOnboarding({
|
||||
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)]">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-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.
|
||||
@@ -346,8 +598,6 @@ export function CompanyOnboarding({
|
||||
rows={4}
|
||||
/>
|
||||
</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="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)]">
|
||||
@@ -365,7 +615,6 @@ export function CompanyOnboarding({
|
||||
</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}
|
||||
@@ -390,6 +639,7 @@ export function CompanyOnboarding({
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
/* ── Step: Launch Success ─────────────────────── */
|
||||
<motion.div
|
||||
key="launch"
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
@@ -409,7 +659,9 @@ export function CompanyOnboarding({
|
||||
{companyName} is live!
|
||||
</h2>
|
||||
<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>
|
||||
<motion.div
|
||||
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)]">
|
||||
<span className="h-2 w-2 animate-pulse rounded-full bg-[color:var(--success)]" />
|
||||
CEO agent initializing...
|
||||
{selectedTemplate ? "Team initializing..." : "CEO agent initializing..."}
|
||||
</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 {
|
||||
ArrowRight,
|
||||
Briefcase,
|
||||
Buildings,
|
||||
CalendarCheck,
|
||||
ChartLineUp,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Cpu,
|
||||
CurrencyCircleDollar,
|
||||
Heartbeat,
|
||||
Lightning,
|
||||
Robot,
|
||||
Rocket,
|
||||
ShieldCheck,
|
||||
Target,
|
||||
TreeStructure,
|
||||
TrendUp,
|
||||
Users,
|
||||
Warning,
|
||||
XCircle,
|
||||
} from "@phosphor-icons/react";
|
||||
@@ -21,10 +28,13 @@ import type {
|
||||
ApprovalRecord,
|
||||
ConnectorRecord,
|
||||
CostRecord,
|
||||
DepartmentId,
|
||||
GoalRecord,
|
||||
MeetingRecord,
|
||||
ProjectRecord,
|
||||
RunRecord,
|
||||
SectionId,
|
||||
SprintRecord,
|
||||
TaskRecord,
|
||||
} from "@shared/types";
|
||||
import { formatMoney } from "../lib/formatters";
|
||||
@@ -44,6 +54,8 @@ interface DashboardProps {
|
||||
activity: ActivityRecord[];
|
||||
costs: CostRecord[];
|
||||
connectors: ConnectorRecord[];
|
||||
meetings?: MeetingRecord[];
|
||||
sprints?: SprintRecord[];
|
||||
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 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
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(
|
||||
() => 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 activeAgents = useMemo(() => agents.filter((a) => a.status !== "terminated" && a.status !== "pending_approval"), [agents]);
|
||||
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 (
|
||||
<div className="space-y-8">
|
||||
@@ -488,7 +736,55 @@ export function Dashboard(props: DashboardProps) {
|
||||
</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>
|
||||
<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>
|
||||
@@ -505,7 +801,7 @@ export function Dashboard(props: DashboardProps) {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Row 3: Workforce + Activity */}
|
||||
{/* Row 5: Workforce + Activity */}
|
||||
<div className="grid gap-6 lg:grid-cols-5">
|
||||
{/* Workforce */}
|
||||
<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 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 = {
|
||||
loadSnapshot: "agent-company:load-snapshot",
|
||||
@@ -40,6 +40,45 @@ export const IPC_CHANNELS = {
|
||||
triggerHeartbeat: "agent-company:trigger-heartbeat",
|
||||
getApiPort: "agent-company:get-api-port",
|
||||
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;
|
||||
|
||||
const optionalId = z.preprocess(
|
||||
@@ -80,6 +119,7 @@ export const agentInputSchema = z.object({
|
||||
name: z.string().min(2).max(80),
|
||||
role: z.string().min(2).max(80),
|
||||
title: z.string().default(""),
|
||||
department: z.enum(["executive", "engineering", "product", "marketing", "sales", "hr", "finance", "legal", "operations", "customer_support", "research", "design"]).nullable().optional(),
|
||||
reportsTo: optionalId,
|
||||
connectorId: connectorInputSchema.shape.id,
|
||||
workspaceId: optionalId,
|
||||
@@ -129,7 +169,7 @@ export const approvalInputSchema = z.object({
|
||||
companyId: z.string().uuid(),
|
||||
relatedTaskId: 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),
|
||||
impactSummary: z.string().min(2).max(400),
|
||||
});
|
||||
@@ -202,7 +242,178 @@ export const heartbeatSettingsSchema = z.object({
|
||||
export const triggerHeartbeatSchema = z.object({
|
||||
agentId: 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> =
|
||||
@@ -247,5 +458,44 @@ export type DesktopApi = {
|
||||
setHeartbeat: (input: z.infer<typeof heartbeatSettingsSchema>) => Promise<DesktopResult<boolean>>;
|
||||
triggerHeartbeat: (input: z.infer<typeof triggerHeartbeatSchema>) => Promise<DesktopResult<string>>;
|
||||
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;
|
||||
};
|
||||
+483
-3
@@ -1,8 +1,12 @@
|
||||
export type SectionId =
|
||||
| "overview"
|
||||
| "inbox"
|
||||
| "standup"
|
||||
| "performance"
|
||||
| "connectors"
|
||||
| "agents"
|
||||
| "orgchart"
|
||||
| "hiring"
|
||||
| "goals"
|
||||
| "projects"
|
||||
| "tasks"
|
||||
@@ -10,6 +14,13 @@ export type SectionId =
|
||||
| "costs"
|
||||
| "activity"
|
||||
| "workspaces"
|
||||
| "social"
|
||||
| "automation"
|
||||
| "communication"
|
||||
| "documents"
|
||||
| "knowledge"
|
||||
| "meetings"
|
||||
| "sprints"
|
||||
| "settings";
|
||||
|
||||
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 TaskStatus = "backlog" | "todo" | "in_progress" | "in_review" | "done" | "blocked" | "cancelled";
|
||||
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 =
|
||||
| "queued"
|
||||
| "running"
|
||||
@@ -99,12 +122,28 @@ export interface WorkspaceRecord {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// ── Departments ────────────────────────────────────────────────────────
|
||||
export type DepartmentId =
|
||||
| "executive"
|
||||
| "engineering"
|
||||
| "product"
|
||||
| "marketing"
|
||||
| "sales"
|
||||
| "hr"
|
||||
| "finance"
|
||||
| "legal"
|
||||
| "operations"
|
||||
| "customer_support"
|
||||
| "research"
|
||||
| "design";
|
||||
|
||||
export interface AgentRecord {
|
||||
id: string;
|
||||
companyId: string;
|
||||
name: string;
|
||||
role: string;
|
||||
title: string;
|
||||
department: DepartmentId | null;
|
||||
status: AgentStatus;
|
||||
reportsTo: string | null;
|
||||
connectorId: ConnectorId;
|
||||
@@ -172,7 +211,7 @@ export interface ApprovalRecord {
|
||||
relatedTaskId: string | null;
|
||||
relatedAgentId: string | null;
|
||||
requestedByAgentId: string | null;
|
||||
type: "hire_agent" | "approve_ceo_strategy" | "dangerous_command" | "secret_access";
|
||||
type: ApprovalType;
|
||||
payloadSummary: string;
|
||||
impactSummary: string;
|
||||
payloadJson: string | null;
|
||||
@@ -258,6 +297,15 @@ export interface ProfileSnapshot {
|
||||
activity: ActivityRecord[];
|
||||
secrets: SecretRecord[];
|
||||
comments: CommentRecord[];
|
||||
socialAccounts: SocialAccountRecord[];
|
||||
browserActions: BrowserActionRecord[];
|
||||
meetings: MeetingRecord[];
|
||||
documents: DocumentRecord[];
|
||||
knowledgeBase: KnowledgeEntryRecord[];
|
||||
sprints: SprintRecord[];
|
||||
automationRules: AutomationRuleRecord[];
|
||||
agentMessages: AgentMessageRecord[];
|
||||
workflows: WorkflowPipelineRecord[];
|
||||
}
|
||||
|
||||
export interface LogChunkEvent {
|
||||
@@ -283,7 +331,7 @@ export interface RunStatusEvent {
|
||||
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 {
|
||||
id: string;
|
||||
@@ -313,10 +361,442 @@ export interface AgentMessageEvent {
|
||||
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 =
|
||||
| LogChunkEvent
|
||||
| DomainChangedEvent
|
||||
| ShortcutEvent
|
||||
| RunStatusEvent
|
||||
| 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;
|
||||
}
|
||||
Reference in new issue
Block a user