This commit is contained in:
ZacharyZhang-NY committed 2026-03-16 13:07:14 -04:00
1 parent 41eafc7c44
commit 54bcc77ef7
19 files changed
+11420 -64

No files matched your search

+428
View File
@@ -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}` });
});
}
}
+485
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+544 -4
View File
@@ -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
View File
@@ -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
+39
View File
@@ -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
View File
@@ -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"
+286 -34
View File
@@ -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
+299 -3
View File
@@ -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">
+339
View File
@@ -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>
);
}
+537
View File
@@ -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`;
}
+501
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}