This commit is contained in:
ZacharyZhang-NY committed 2026-03-17 12:31:06 -04:00
1 parent 54bcc77ef7
commit 7a3db27ffa
33 files changed
+5906 -3089

No files matched your search

+1
View File
@@ -10,6 +10,7 @@ export default tseslint.config(
"out/**", "out/**",
"build/**", "build/**",
"dist/**", "dist/**",
"oldreference/**",
"node_modules/**", "node_modules/**",
"test-results/**", "test-results/**",
"playwright-report/**", "playwright-report/**",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "agent-company", "name": "agent-company",
"version": "0.3.0", "version": "0.4.16",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "Cross-platform Electron desktop control plane for local-first AI agent companies.", "description": "Cross-platform Electron desktop control plane for local-first AI agent companies.",
+36 -9
View File
@@ -1,8 +1,33 @@
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import { readFile, rm, writeFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const packageJsonPath = join(__dirname, "..", "package.json");
const releaseDir = join(__dirname, "..", "build", "release");
const extraArgs = process.argv.slice(2); const extraArgs = process.argv.slice(2);
const env = { ...process.env }; const env = { ...process.env };
function bumpPatchVersion(currentVersion) {
const match = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(currentVersion);
if (!match) {
throw new Error(`[build-desktop] Unsupported package version format: ${currentVersion}`);
}
const [, major, minor, patch] = match;
return `${major}.${minor}.${Number.parseInt(patch, 10) + 1}`;
}
async function prepareBuildVersion() {
const raw = await readFile(packageJsonPath, "utf8");
const pkg = JSON.parse(raw);
const nextVersion = bumpPatchVersion(pkg.version);
pkg.version = nextVersion;
await writeFile(packageJsonPath, `${JSON.stringify(pkg, null, 2)}\n`, "utf8");
return nextVersion;
}
if (env.CSC_IDENTITY_AUTO_DISCOVERY === undefined) { if (env.CSC_IDENTITY_AUTO_DISCOVERY === undefined) {
env.CSC_IDENTITY_AUTO_DISCOVERY = "false"; env.CSC_IDENTITY_AUTO_DISCOVERY = "false";
} }
@@ -29,15 +54,17 @@ if (!explicitPublishArg) {
} }
builderArgs.push(...extraArgs); builderArgs.push(...extraArgs);
const child = spawn( await rm(releaseDir, { recursive: true, force: true });
"pnpm", const buildVersion = env.AGENTCOMPANY_BUILD_VERSION?.trim() || await prepareBuildVersion();
builderArgs, env.AGENTCOMPANY_BUILD_VERSION = buildVersion;
{ env.npm_package_version = buildVersion;
stdio: "inherit", console.log(`[build-desktop] Cleaned build output and bumped package version to ${buildVersion}.`);
env,
shell: process.platform === "win32", const child = spawn("pnpm", builderArgs, {
}, stdio: "inherit",
); env,
shell: process.platform === "win32",
});
child.on("exit", (code, signal) => { child.on("exit", (code, signal) => {
if (signal) { if (signal) {
+1 -7
View File
@@ -1,13 +1,7 @@
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
/**
* Lightweight HMAC-based agent token system.
* Tokens are short-lived and scoped to a specific agent + company + run.
* No external JWT library needed.
*/
const SIGNING_KEY = randomBytes(32); const SIGNING_KEY = randomBytes(32);
const TOKEN_TTL_MS = 12 * 60 * 60 * 1000; // 12 hours const TOKEN_TTL_MS = 48 * 60 * 60 * 1000; // 48 hours
interface TokenPayload { interface TokenPayload {
agentId: string; agentId: string;
+422 -144
View File
File diff suppressed because it is too large. Load diff
+176 -77
View File
@@ -3,19 +3,10 @@ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import log from "electron-log/main.js"; 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 { export interface BrowserAction {
id: string; id: string;
socialAccountId: string; socialAccountId: string;
platform: string;
actionType: string; actionType: string;
payload: Record<string, unknown>; payload: Record<string, unknown>;
} }
@@ -33,56 +24,116 @@ interface QueuedAction {
reject: (error: Error) => void; reject: (error: Error) => void;
} }
interface PlatformRule {
loginUrl: string;
authCheckUrl: string;
allowedHosts: string[];
cookieOrigins: string[];
unauthenticatedUrlMarkers: string[];
}
const PLATFORM_RULES: Record<string, PlatformRule> = {
twitter: {
loginUrl: "https://x.com/i/flow/login",
authCheckUrl: "https://x.com/home",
allowedHosts: ["x.com", "twitter.com"],
cookieOrigins: ["https://x.com", "https://twitter.com"],
unauthenticatedUrlMarkers: ["/i/flow/login", "/login"],
},
linkedin: {
loginUrl: "https://www.linkedin.com/login",
authCheckUrl: "https://www.linkedin.com/feed/",
allowedHosts: ["linkedin.com"],
cookieOrigins: ["https://www.linkedin.com"],
unauthenticatedUrlMarkers: ["/login", "/checkpoint"],
},
instagram: {
loginUrl: "https://www.instagram.com/accounts/login/",
authCheckUrl: "https://www.instagram.com/",
allowedHosts: ["instagram.com"],
cookieOrigins: ["https://www.instagram.com"],
unauthenticatedUrlMarkers: ["/accounts/login"],
},
facebook: {
loginUrl: "https://www.facebook.com/login",
authCheckUrl: "https://www.facebook.com/",
allowedHosts: ["facebook.com"],
cookieOrigins: ["https://www.facebook.com"],
unauthenticatedUrlMarkers: ["/login"],
},
reddit: {
loginUrl: "https://www.reddit.com/login",
authCheckUrl: "https://www.reddit.com/",
allowedHosts: ["reddit.com"],
cookieOrigins: ["https://www.reddit.com"],
unauthenticatedUrlMarkers: ["/login"],
},
youtube: {
loginUrl: "https://accounts.google.com/signin",
authCheckUrl: "https://www.youtube.com/",
allowedHosts: ["youtube.com", "google.com"],
cookieOrigins: ["https://www.youtube.com", "https://accounts.google.com"],
unauthenticatedUrlMarkers: ["/signin", "/ServiceLogin"],
},
tiktok: {
loginUrl: "https://www.tiktok.com/login",
authCheckUrl: "https://www.tiktok.com/foryou",
allowedHosts: ["tiktok.com"],
cookieOrigins: ["https://www.tiktok.com"],
unauthenticatedUrlMarkers: ["/login"],
},
bluesky: {
loginUrl: "https://bsky.app",
authCheckUrl: "https://bsky.app/",
allowedHosts: ["bsky.app"],
cookieOrigins: ["https://bsky.app"],
unauthenticatedUrlMarkers: ["/login"],
},
other: {
loginUrl: "https://google.com",
authCheckUrl: "https://google.com",
allowedHosts: ["google.com"],
cookieOrigins: ["https://google.com"],
unauthenticatedUrlMarkers: ["/login"],
},
};
export class BrowserManager { export class BrowserManager {
private screenshotDir: string; private screenshotDir: string;
private queues = new Map<string, QueuedAction[]>(); private queues = new Map<string, QueuedAction[]>();
private processing = new Set<string>(); private processing = new Set<string>();
/** Track open login windows so we don't open duplicates */
private loginWindows = new Map<string, BrowserWindow>(); private loginWindows = new Map<string, BrowserWindow>();
private initializedSessions = new Set<string>();
constructor(profileDir: string) { constructor(profileDir: string) {
this.screenshotDir = join(profileDir, "browser-screenshots"); this.screenshotDir = join(profileDir, "browser-screenshots");
if (!existsSync(this.screenshotDir)) mkdirSync(this.screenshotDir, { recursive: true }); 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 { private getSession(socialAccountId: string): Electron.Session {
return session.fromPartition(`persist:social-${socialAccountId}`); const partition = `persist:social-${socialAccountId}`;
const socialSession = session.fromPartition(partition);
if (!this.initializedSessions.has(partition)) {
socialSession.setPermissionCheckHandler(() => false);
socialSession.setPermissionRequestHandler((_webContents, _permission, callback) => callback(false));
this.initializedSessions.add(partition);
}
return socialSession;
} }
/**
* 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( async launchLoginBrowser(
socialAccountId: string, socialAccountId: string,
platform: string, platform: string,
loginUrl?: string, loginUrl?: string,
): Promise<BrowserActionResult> { ): Promise<BrowserActionResult> {
// Don't open duplicate login windows for the same account
const existing = this.loginWindows.get(socialAccountId); const existing = this.loginWindows.get(socialAccountId);
if (existing && !existing.isDestroyed()) { if (existing && !existing.isDestroyed()) {
existing.focus(); existing.focus();
return { success: true, summary: "Login window already open", screenshotPath: null, error: null }; return { success: true, summary: "Login window already open", screenshotPath: null, error: null };
} }
const defaultUrls: Record<string, string> = { const rules = this.getPlatformRules(platform);
twitter: "https://x.com/i/flow/login", const url = loginUrl || rules.loginUrl;
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); const ses = this.getSession(socialAccountId);
log.info(`[browser] Opening login window for ${platform} → ${url}`); log.info(`[browser] Opening login window for ${platform} → ${url}`);
@@ -93,7 +144,6 @@ export class BrowserManager {
title: `Login to ${platform} — AgentCompany`, title: `Login to ${platform} — AgentCompany`,
webPreferences: { webPreferences: {
session: ses, session: ses,
// Standard web page — no node integration, no preload
nodeIntegration: false, nodeIntegration: false,
contextIsolation: true, contextIsolation: true,
sandbox: true, sandbox: true,
@@ -102,43 +152,32 @@ export class BrowserManager {
this.loginWindows.set(socialAccountId, win); this.loginWindows.set(socialAccountId, win);
// Remove menu bar for a cleaner look
win.setMenuBarVisibility(false); win.setMenuBarVisibility(false);
// Load the login page
try { try {
await win.loadURL(url); await win.loadURL(url);
} catch (err) { } catch (err) {
log.error(`[browser] Failed to load ${url}: ${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) => { return new Promise<BrowserActionResult>((resolve) => {
win.on("closed", () => { win.on("closed", () => {
this.loginWindows.delete(socialAccountId); this.loginWindows.delete(socialAccountId);
log.info(`[browser] Login window closed for ${platform} — session saved`); void this.verifyLoginSession(socialAccountId, platform).then((verified) => {
resolve({ log.info(`[browser] Login window closed for ${platform} — verified=${verified}`);
success: true, resolve({
summary: `Login session saved for ${platform}`, success: verified,
screenshotPath: null, summary: verified
error: null, ? `Authenticated session detected for ${platform}`
: `No authenticated ${platform} session detected`,
screenshotPath: null,
error: verified ? null : `Authentication for ${platform} was not completed.`,
});
}); });
}); });
}); });
} }
/**
* 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> { async executeAction(action: BrowserAction): Promise<BrowserActionResult> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const queue = this.queues.get(action.socialAccountId) ?? []; const queue = this.queues.get(action.socialAccountId) ?? [];
@@ -166,17 +205,14 @@ export class BrowserManager {
this.processing.delete(socialAccountId); this.processing.delete(socialAccountId);
} }
/**
* Execute a single browser action using an Electron BrowserWindow.
*/
private async runAction(action: BrowserAction): Promise<BrowserActionResult> { private async runAction(action: BrowserAction): Promise<BrowserActionResult> {
const ses = this.getSession(action.socialAccountId); const ses = this.getSession(action.socialAccountId);
const payload = action.payload; const payload = action.payload;
const screenshotPath = join(this.screenshotDir, `${action.id}.png`); const screenshotPath = join(this.screenshotDir, `${action.id}.png`);
const platform = action.platform;
log.info(`[browser] Executing ${action.actionType} for account ${action.socialAccountId}`); log.info(`[browser] Executing ${action.actionType} for ${platform}/${action.socialAccountId}`);
// Create a visible browser window for the action
const win = new BrowserWindow({ const win = new BrowserWindow({
width: 1280, width: 1280,
height: 860, height: 860,
@@ -197,6 +233,7 @@ export class BrowserManager {
switch (action.actionType) { switch (action.actionType) {
case "navigate": { case "navigate": {
const url = String(payload.url || "https://google.com"); const url = String(payload.url || "https://google.com");
this.assertAllowedUrl(platform, url, "navigate");
await win.loadURL(url); await win.loadURL(url);
await this.waitForLoad(win); await this.waitForLoad(win);
const title = await win.webContents.executeJavaScript("document.title"); const title = await win.webContents.executeJavaScript("document.title");
@@ -205,7 +242,6 @@ export class BrowserManager {
} }
case "post": { case "post": {
const platform = String(payload.platform || "twitter");
const content = String(payload.content || ""); const content = String(payload.content || "");
const postUrl = this.getComposeUrl(platform); const postUrl = this.getComposeUrl(platform);
await win.loadURL(postUrl); await win.loadURL(postUrl);
@@ -213,7 +249,6 @@ export class BrowserManager {
await this.delay(2000); await this.delay(2000);
if (platform === "twitter") { if (platform === "twitter") {
// Type into tweet composer
await win.webContents.executeJavaScript(` await win.webContents.executeJavaScript(`
(async () => { (async () => {
const editor = document.querySelector('[data-testid="tweetTextarea_0"], [role="textbox"]'); const editor = document.querySelector('[data-testid="tweetTextarea_0"], [role="textbox"]');
@@ -224,7 +259,6 @@ export class BrowserManager {
})() })()
`); `);
await this.delay(1000); await this.delay(1000);
// Click post button
await win.webContents.executeJavaScript(` await win.webContents.executeJavaScript(`
(() => { (() => {
const btn = document.querySelector('[data-testid="tweetButton"], [data-testid="tweetButtonInline"]'); const btn = document.querySelector('[data-testid="tweetButton"], [data-testid="tweetButtonInline"]');
@@ -264,6 +298,7 @@ export class BrowserManager {
case "reply": { case "reply": {
const targetUrl = String(payload.targetUrl || ""); const targetUrl = String(payload.targetUrl || "");
this.assertAllowedUrl(platform, targetUrl, "reply");
const replyContent = String(payload.content || ""); const replyContent = String(payload.content || "");
await win.loadURL(targetUrl); await win.loadURL(targetUrl);
await this.waitForLoad(win); await this.waitForLoad(win);
@@ -292,6 +327,7 @@ export class BrowserManager {
case "like": { case "like": {
const targetUrl = String(payload.targetUrl || ""); const targetUrl = String(payload.targetUrl || "");
this.assertAllowedUrl(platform, targetUrl, "like");
await win.loadURL(targetUrl); await win.loadURL(targetUrl);
await this.waitForLoad(win); await this.waitForLoad(win);
await this.delay(2000); await this.delay(2000);
@@ -308,6 +344,7 @@ export class BrowserManager {
case "follow": { case "follow": {
const targetUrl = String(payload.targetUrl || payload.profileUrl || ""); const targetUrl = String(payload.targetUrl || payload.profileUrl || "");
this.assertAllowedUrl(platform, targetUrl, "follow");
await win.loadURL(targetUrl); await win.loadURL(targetUrl);
await this.waitForLoad(win); await this.waitForLoad(win);
await this.delay(2000); await this.delay(2000);
@@ -323,14 +360,13 @@ export class BrowserManager {
} }
case "browse_feed": { case "browse_feed": {
const platform = String(payload.platform || "twitter");
const feedUrl = String(payload.url || this.getFeedUrl(platform)); const feedUrl = String(payload.url || this.getFeedUrl(platform));
this.assertAllowedUrl(platform, feedUrl, "browse_feed");
const maxPosts = Number(payload.maxPosts || 10); const maxPosts = Number(payload.maxPosts || 10);
await win.loadURL(feedUrl); await win.loadURL(feedUrl);
await this.waitForLoad(win); await this.waitForLoad(win);
await this.delay(3000); await this.delay(3000);
// Scroll to load content
for (let i = 0; i < 3; i++) { for (let i = 0; i < 3; i++) {
await win.webContents.executeJavaScript("window.scrollBy(0, window.innerHeight)"); await win.webContents.executeJavaScript("window.scrollBy(0, window.innerHeight)");
await this.delay(1500); await this.delay(1500);
@@ -348,8 +384,8 @@ export class BrowserManager {
case "search": { case "search": {
const query = String(payload.query || ""); const query = String(payload.query || "");
const platform = String(payload.platform || "twitter");
const searchUrl = this.getSearchUrl(platform, query); const searchUrl = this.getSearchUrl(platform, query);
this.assertAllowedUrl(platform, searchUrl, "search");
await win.loadURL(searchUrl); await win.loadURL(searchUrl);
await this.waitForLoad(win); await this.waitForLoad(win);
await this.delay(3000); await this.delay(3000);
@@ -367,6 +403,7 @@ export class BrowserManager {
case "screenshot": { case "screenshot": {
const url = String(payload.url || "about:blank"); const url = String(payload.url || "about:blank");
if (url !== "about:blank") { if (url !== "about:blank") {
this.assertAllowedUrl(platform, url, "screenshot");
await win.loadURL(url); await win.loadURL(url);
await this.waitForLoad(win); await this.waitForLoad(win);
await this.delay(2000); await this.delay(2000);
@@ -377,10 +414,9 @@ export class BrowserManager {
} }
default: default:
summary = `Unknown action type: ${action.actionType}`; throw new Error(`Unsupported browser action type: ${action.actionType}`);
} }
// Capture screenshot
try { try {
const image: NativeImage = await win.webContents.capturePage(); const image: NativeImage = await win.webContents.capturePage();
if (!image.isEmpty()) { if (!image.isEmpty()) {
@@ -390,7 +426,6 @@ export class BrowserManager {
log.warn(`[browser] Screenshot failed: ${err}`); log.warn(`[browser] Screenshot failed: ${err}`);
} }
// Close the action window after a brief pause so user can see result
await this.delay(1000); await this.delay(1000);
if (!win.isDestroyed()) win.close(); if (!win.isDestroyed()) win.close();
@@ -401,7 +436,6 @@ export class BrowserManager {
error: null, error: null,
}; };
} catch (error) { } catch (error) {
// Try to capture error screenshot
try { try {
const image: NativeImage = await win.webContents.capturePage(); const image: NativeImage = await win.webContents.capturePage();
if (!image.isEmpty()) writeFileSync(screenshotPath, image.toPNG()); if (!image.isEmpty()) writeFileSync(screenshotPath, image.toPNG());
@@ -420,8 +454,6 @@ export class BrowserManager {
} }
} }
// ── Helpers ──────────────────────────────────────────────────────
private delay(ms: number): Promise<void> { private delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));
} }
@@ -440,6 +472,69 @@ export class BrowserManager {
}); });
} }
private getPlatformRules(platform: string): PlatformRule {
return PLATFORM_RULES[platform] ?? PLATFORM_RULES.other;
}
private assertAllowedUrl(platform: string, rawUrl: string, actionType: string) {
const allowedHosts = this.getPlatformRules(platform).allowedHosts;
let url: URL;
try {
url = new URL(rawUrl);
} catch {
throw new Error(`Invalid URL for ${actionType}: ${rawUrl}`);
}
const host = url.hostname.toLowerCase();
const isAllowed = allowedHosts.some((allowedHost) => host === allowedHost || host.endsWith(`.${allowedHost}`));
if (!isAllowed) {
throw new Error(`Navigation blocked for ${actionType}. ${host} is outside the allowed ${platform} domains.`);
}
}
private async verifyLoginSession(socialAccountId: string, platform: string): Promise<boolean> {
const ses = this.getSession(socialAccountId);
const rules = this.getPlatformRules(platform);
const cookieSets = await Promise.all(
rules.cookieOrigins.map(async (origin) => {
try {
return await ses.cookies.get({ url: origin });
} catch {
return [];
}
}),
);
const hasCookies = cookieSets.some((cookies) => cookies.length > 0);
if (!hasCookies) {
return false;
}
const verificationWindow = new BrowserWindow({
show: false,
webPreferences: {
session: ses,
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
},
});
try {
await verificationWindow.loadURL(rules.authCheckUrl);
await this.waitForLoad(verificationWindow, 10000);
const finalUrl = verificationWindow.webContents.getURL();
const finalUrlLower = finalUrl.toLowerCase();
const bouncedToLogin = rules.unauthenticatedUrlMarkers.some((marker) => finalUrlLower.includes(marker.toLowerCase()));
return !bouncedToLogin;
} catch (error) {
log.warn(`[browser] Login verification failed for ${platform}: ${error instanceof Error ? error.message : String(error)}`);
return false;
} finally {
if (!verificationWindow.isDestroyed()) {
verificationWindow.close();
}
}
}
private getComposeUrl(platform: string): string { private getComposeUrl(platform: string): string {
const urls: Record<string, string> = { const urls: Record<string, string> = {
twitter: "https://x.com/compose/post", twitter: "https://x.com/compose/post",
@@ -448,8 +543,10 @@ export class BrowserManager {
facebook: "https://www.facebook.com/", facebook: "https://www.facebook.com/",
reddit: "https://www.reddit.com/submit", reddit: "https://www.reddit.com/submit",
bluesky: "https://bsky.app/", bluesky: "https://bsky.app/",
youtube: "https://www.youtube.com/",
tiktok: "https://www.tiktok.com/",
}; };
return urls[platform] || "https://google.com"; return urls[platform] || this.getPlatformRules(platform).loginUrl;
} }
private getFeedUrl(platform: string): string { private getFeedUrl(platform: string): string {
@@ -457,10 +554,13 @@ export class BrowserManager {
twitter: "https://x.com/home", twitter: "https://x.com/home",
linkedin: "https://www.linkedin.com/feed/", linkedin: "https://www.linkedin.com/feed/",
instagram: "https://www.instagram.com/", instagram: "https://www.instagram.com/",
facebook: "https://www.facebook.com/",
reddit: "https://www.reddit.com/", reddit: "https://www.reddit.com/",
bluesky: "https://bsky.app/", bluesky: "https://bsky.app/",
youtube: "https://www.youtube.com/",
tiktok: "https://www.tiktok.com/",
}; };
return urls[platform] || "https://google.com"; return urls[platform] || this.getPlatformRules(platform).loginUrl;
} }
private getSearchUrl(platform: string, query: string): string { private getSearchUrl(platform: string, query: string): string {
@@ -474,7 +574,6 @@ export class BrowserManager {
} }
shutdown() { shutdown() {
// Close any open login windows
for (const [, win] of this.loginWindows) { for (const [, win] of this.loginWindows) {
if (!win.isDestroyed()) win.close(); if (!win.isDestroyed()) win.close();
} }
-4
View File
@@ -263,10 +263,6 @@ const CONNECTORS: ConnectorDefinition[] = [
}, },
]; ];
export function listConnectorDefinitions(): ConnectorDefinition[] {
return CONNECTORS;
}
export function getConnectorDefinition(id: ConnectorId): ConnectorDefinition { export function getConnectorDefinition(id: ConnectorId): ConnectorDefinition {
const definition = CONNECTORS.find((entry) => entry.id === id); const definition = CONNECTORS.find((entry) => entry.id === id);
if (!definition) { if (!definition) {
+674 -268
View File
File diff suppressed because it is too large. Load diff
+1861 -764
View File
File diff suppressed because it is too large. Load diff
+12 -36
View File
@@ -2,37 +2,19 @@ import { runningProcesses } from "@agentcompany/adapter-utils/server-utils";
import { getConnectorDefinition } from "@main/connectors"; import { getConnectorDefinition } from "@main/connectors";
import type { ConnectorId, RunStatus } from "@shared/types"; import type { ConnectorId, RunStatus } from "@shared/types";
/**
* Environment variables allowed to propagate from the host process to connector
* child processes. Any env var NOT in this list is stripped. Secret bindings
* resolved from the vault are always injected regardless of this allowlist.
*/
const ENV_ALLOWLIST: ReadonlySet<string> = new Set([ const ENV_ALLOWLIST: ReadonlySet<string> = new Set([
// PATH is required for command resolution
"PATH", "PATH",
// Basic locale / terminal
"HOME", "USER", "SHELL", "LANG", "LC_ALL", "LC_CTYPE", "TERM", "TMPDIR", "HOME", "USER", "SHELL", "LANG", "LC_ALL", "LC_CTYPE", "TERM", "TMPDIR",
// macOS-specific
"DEVELOPER_DIR", "SDKROOT", "DEVELOPER_DIR", "SDKROOT",
// Node
"NODE_ENV", "NODE_OPTIONS", "NODE_ENV", "NODE_OPTIONS",
// Git
"GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL", "GIT_COMMITTER_NAME", "GIT_COMMITTER_EMAIL", "GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL", "GIT_COMMITTER_NAME", "GIT_COMMITTER_EMAIL",
"GIT_SSH_COMMAND", "GIT_ASKPASS", "GIT_TERMINAL_PROMPT", "GIT_SSH_COMMAND", "GIT_ASKPASS", "GIT_TERMINAL_PROMPT",
// XDG
"XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME", "XDG_RUNTIME_DIR",
// Platform / display (Linux)
"DISPLAY", "WAYLAND_DISPLAY", "DBUS_SESSION_BUS_ADDRESS", "DISPLAY", "WAYLAND_DISPLAY", "DBUS_SESSION_BUS_ADDRESS",
// Windows
"APPDATA", "LOCALAPPDATA", "USERPROFILE", "SYSTEMROOT", "COMSPEC", "PROGRAMFILES", "APPDATA", "LOCALAPPDATA", "USERPROFILE", "SYSTEMROOT", "COMSPEC", "PROGRAMFILES",
// AgentCompany injected
"AGENT_COMPANY_API_URL", "AGENT_COMPANY_API_KEY", "AGENT_COMPANY_RUN_ID", "AGENT_COMPANY_API_URL", "AGENT_COMPANY_API_KEY", "AGENT_COMPANY_RUN_ID",
]); ]);
/**
* Build a sanitized environment for a connector child process.
* Only allowlisted host vars + explicitly resolved secret bindings are included.
*/
function buildSanitizedEnv( function buildSanitizedEnv(
hostEnv: Record<string, string>, hostEnv: Record<string, string>,
secretBindings: Record<string, string>, secretBindings: Record<string, string>,
@@ -43,25 +25,18 @@ function buildSanitizedEnv(
result[key] = hostEnv[key]; result[key] = hostEnv[key];
} }
} }
// Secret bindings always override — they are resolved from the vault
Object.assign(result, secretBindings); Object.assign(result, secretBindings);
return result; return result;
} }
/**
* Build a log redaction function that masks any secret values present in the env.
* Only values that look like secrets (length >= 8, not PATH-like) are redacted.
*/
function buildRedactor(env: Record<string, string>): (text: string) => string { function buildRedactor(env: Record<string, string>): (text: string) => string {
const sensitivePatterns: string[] = []; const sensitivePatterns: string[] = [];
for (const [key, value] of Object.entries(env)) { for (const [key, value] of Object.entries(env)) {
// Skip short values, PATH, HOME, and other system vars unlikely to be secrets
if (value.length < 8) continue; if (value.length < 8) continue;
if (["PATH", "HOME", "USER", "SHELL", "LANG", "TMPDIR", "TERM"].includes(key)) continue; if (["PATH", "HOME", "USER", "SHELL", "LANG", "TMPDIR", "TERM"].includes(key)) continue;
sensitivePatterns.push(value); sensitivePatterns.push(value);
} }
if (sensitivePatterns.length === 0) return (text) => text; if (sensitivePatterns.length === 0) return (text) => text;
// Sort by length descending so longer patterns are replaced first
sensitivePatterns.sort((a, b) => b.length - a.length); sensitivePatterns.sort((a, b) => b.length - a.length);
return (text: string) => { return (text: string) => {
let result = text; let result = text;
@@ -85,14 +60,13 @@ interface RunJob {
connectorId: ConnectorId; connectorId: ConnectorId;
workspaceId: string | null; workspaceId: string | null;
workspacePath: string | null; workspacePath: string | null;
workspaceMode: "exclusive" | "shared" | "none";
command: string; command: string;
model: string | null; model: string | null;
thinkingEffort: string | null; thinkingEffort: string | null;
env: Record<string, string>; env: Record<string, string>;
sessionParams: Record<string, unknown> | null; sessionParams: Record<string, unknown> | null;
/** Rich system prompt with API instructions for autonomous operation */
systemPrompt: string | null; systemPrompt: string | null;
/** Heartbeat trigger that caused this run */
trigger: string | null; trigger: string | null;
} }
@@ -134,6 +108,9 @@ function post(message: ChildMessage) {
} }
function workspaceKey(job: RunJob) { function workspaceKey(job: RunJob) {
if (job.workspaceMode !== "exclusive") {
return null;
}
return job.workspaceId ?? job.workspacePath ?? `task:${job.taskId}`; return job.workspaceId ?? job.workspacePath ?? `task:${job.taskId}`;
} }
@@ -152,7 +129,8 @@ async function maybeStartQueuedRuns() {
const readyJobs = [...queue]; const readyJobs = [...queue];
for (const job of readyJobs) { for (const job of readyJobs) {
if (activeByAgent.has(job.agentId)) continue; if (activeByAgent.has(job.agentId)) continue;
if (activeByWorkspace.has(workspaceKey(job))) continue; const lockKey = workspaceKey(job);
if (lockKey && activeByWorkspace.has(lockKey)) continue;
const index = queue.findIndex((entry) => entry.runId === job.runId); const index = queue.findIndex((entry) => entry.runId === job.runId);
if (index >= 0) queue.splice(index, 1); if (index >= 0) queue.splice(index, 1);
void startRun(job); void startRun(job);
@@ -163,7 +141,9 @@ async function startRun(job: RunJob) {
const definition = getConnectorDefinition(job.connectorId); const definition = getConnectorDefinition(job.connectorId);
const lockKey = workspaceKey(job); const lockKey = workspaceKey(job);
activeByAgent.set(job.agentId, job.runId); activeByAgent.set(job.agentId, job.runId);
activeByWorkspace.set(lockKey, job.runId); if (lockKey) {
activeByWorkspace.set(lockKey, job.runId);
}
post({ post({
type: "run-status", type: "run-status",
runId: job.runId, runId: job.runId,
@@ -171,7 +151,6 @@ async function startRun(job: RunJob) {
message: `Running with ${definition.label}.`, message: `Running with ${definition.label}.`,
}); });
// Build sanitized env: only allowlisted host vars + resolved secret bindings
const sanitizedEnv = buildSanitizedEnv( const sanitizedEnv = buildSanitizedEnv(
Object.fromEntries( Object.fromEntries(
Object.entries(process.env).filter((e): e is [string, string] => typeof e[1] === "string"), Object.entries(process.env).filter((e): e is [string, string] => typeof e[1] === "string"),
@@ -179,7 +158,6 @@ async function startRun(job: RunJob) {
job.env, job.env,
); );
// Build log redactor from secret bindings to prevent accidental secret leakage
const redact = buildRedactor(job.env); const redact = buildRedactor(job.env);
try { try {
@@ -205,13 +183,9 @@ async function startRun(job: RunJob) {
cwd: job.workspacePath ?? process.cwd(), cwd: job.workspacePath ?? process.cwd(),
model: job.model, model: job.model,
env: sanitizedEnv, env: sanitizedEnv,
// Claude: thinking effort
effort: job.connectorId === "claude_local" ? (job.thinkingEffort ?? "") : "", effort: job.connectorId === "claude_local" ? (job.thinkingEffort ?? "") : "",
// Codex: reasoning effort
modelReasoningEffort: job.connectorId === "codex_local" ? (job.thinkingEffort ?? "") : "", modelReasoningEffort: job.connectorId === "codex_local" ? (job.thinkingEffort ?? "") : "",
// Claude: run without permission prompts — required for headless operation
dangerouslySkipPermissions: true, dangerouslySkipPermissions: true,
// Codex: bypass sandbox and approval prompts, skip git trust check
dangerouslyBypassApprovalsAndSandbox: true, dangerouslyBypassApprovalsAndSandbox: true,
extraArgs: job.connectorId === "codex_local" ? ["--skip-git-repo-check"] : [], extraArgs: job.connectorId === "codex_local" ? ["--skip-git-repo-check"] : [],
}, },
@@ -276,7 +250,9 @@ async function startRun(job: RunJob) {
} finally { } finally {
cancelledRuns.delete(job.runId); cancelledRuns.delete(job.runId);
activeByAgent.delete(job.agentId); activeByAgent.delete(job.agentId);
activeByWorkspace.delete(lockKey); if (lockKey) {
activeByWorkspace.delete(lockKey);
}
void maybeStartQueuedRuns(); void maybeStartQueuedRuns();
} }
} }
+573 -317
View File
File diff suppressed because it is too large. Load diff
+1 -175
View File
@@ -1,10 +1,5 @@
import type { CompanyTemplate, DepartmentId } from "@shared/types"; import type { CompanyTemplate } from "@shared/types";
/**
* Pre-built company templates for one-click company setup.
* Each template defines a complete organizational structure with
* agents, goals, and role definitions.
*/
export const COMPANY_TEMPLATES: CompanyTemplate[] = [ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
{ {
id: "software-startup", id: "software-startup",
@@ -313,10 +308,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
], ],
}, },
// ── Fortune 500 Enterprise ────────────────────────────────────────────
// A truly realistic, fully-staffed company with 37 agents across 12
// departments. Modeled after a mature tech company or Fortune 500 org
// with every function needed to operate at scale.
{ {
id: "fortune500-enterprise", id: "fortune500-enterprise",
name: "Fortune 500 Enterprise", name: "Fortune 500 Enterprise",
@@ -337,11 +328,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
{ id: "research", name: "Research & Innovation", description: "Applied research, competitive intelligence, prototyping, and publication of thought-leadership papers." }, { id: "research", name: "Research & Innovation", description: "Applied research, competitive intelligence, prototyping, and publication of thought-leadership papers." },
], ],
agents: [ agents: [
// ────────────────────────────────────────────────────────────────────
// EXECUTIVE SUITE — indices 0-4
// ────────────────────────────────────────────────────────────────────
{ {
// index 0
name: "Victoria Sterling", name: "Victoria Sterling",
role: "CEO", role: "CEO",
title: "Chief Executive Officer", title: "Chief Executive Officer",
@@ -352,7 +339,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 250, budgetMonthlyUsd: 250,
}, },
{ {
// index 1
name: "James Whitfield", name: "James Whitfield",
role: "COO", role: "COO",
title: "Chief Operating Officer", title: "Chief Operating Officer",
@@ -363,7 +349,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 220, budgetMonthlyUsd: 220,
}, },
{ {
// index 2
name: "Marcus Chen", name: "Marcus Chen",
role: "CTO", role: "CTO",
title: "Chief Technology Officer", title: "Chief Technology Officer",
@@ -374,7 +359,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 250, budgetMonthlyUsd: 250,
}, },
{ {
// index 3
name: "Priya Sharma", name: "Priya Sharma",
role: "CFO", role: "CFO",
title: "Chief Financial Officer", title: "Chief Financial Officer",
@@ -385,7 +369,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 200, budgetMonthlyUsd: 200,
}, },
{ {
// index 4
name: "Nadira Osei-Mensah", name: "Nadira Osei-Mensah",
role: "CHRO", role: "CHRO",
title: "Chief Human Resources Officer", title: "Chief Human Resources Officer",
@@ -396,11 +379,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 180, budgetMonthlyUsd: 180,
}, },
// ────────────────────────────────────────────────────────────────────
// ENGINEERING — indices 5-12
// ────────────────────────────────────────────────────────────────────
{ {
// index 5
name: "Elena Rodriguez", name: "Elena Rodriguez",
role: "VP Engineering", role: "VP Engineering",
title: "Vice President of Engineering", title: "Vice President of Engineering",
@@ -411,7 +390,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 220, budgetMonthlyUsd: 220,
}, },
{ {
// index 6
name: "Tomasz Kowalski", name: "Tomasz Kowalski",
role: "Principal Engineer", role: "Principal Engineer",
title: "Principal Software Engineer", title: "Principal Software Engineer",
@@ -422,7 +400,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 280, budgetMonthlyUsd: 280,
}, },
{ {
// index 7
name: "Kai Nakamura", name: "Kai Nakamura",
role: "Senior Backend Engineer", role: "Senior Backend Engineer",
title: "Senior Backend Engineer", title: "Senior Backend Engineer",
@@ -433,7 +410,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 250, budgetMonthlyUsd: 250,
}, },
{ {
// index 8
name: "Sofia Andersson", name: "Sofia Andersson",
role: "Senior Frontend Engineer", role: "Senior Frontend Engineer",
title: "Senior Frontend Engineer", title: "Senior Frontend Engineer",
@@ -444,7 +420,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 250, budgetMonthlyUsd: 250,
}, },
{ {
// index 9
name: "Omar Hassan", name: "Omar Hassan",
role: "Platform / DevOps Engineer", role: "Platform / DevOps Engineer",
title: "Senior Platform Engineer", title: "Senior Platform Engineer",
@@ -455,7 +430,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 230, budgetMonthlyUsd: 230,
}, },
{ {
// index 10
name: "Yuki Tanaka", name: "Yuki Tanaka",
role: "QA Lead", role: "QA Lead",
title: "Quality Assurance Lead", title: "Quality Assurance Lead",
@@ -466,7 +440,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 200, budgetMonthlyUsd: 200,
}, },
{ {
// index 11
name: "Amina Diallo", name: "Amina Diallo",
role: "Security Engineer", role: "Security Engineer",
title: "Application Security Engineer", title: "Application Security Engineer",
@@ -477,7 +450,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 230, budgetMonthlyUsd: 230,
}, },
{ {
// index 12
name: "Ravi Krishnamurthy", name: "Ravi Krishnamurthy",
role: "Data Engineer", role: "Data Engineer",
title: "Senior Data Engineer", title: "Senior Data Engineer",
@@ -488,11 +460,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 230, budgetMonthlyUsd: 230,
}, },
// ────────────────────────────────────────────────────────────────────
// PRODUCT & DESIGN — indices 13-16
// ────────────────────────────────────────────────────────────────────
{ {
// index 13
name: "Amara Okafor", name: "Amara Okafor",
role: "VP Product", role: "VP Product",
title: "Vice President of Product", title: "Vice President of Product",
@@ -503,7 +471,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 200, budgetMonthlyUsd: 200,
}, },
{ {
// index 14
name: "Daniel Okonkwo", name: "Daniel Okonkwo",
role: "Senior Product Manager", role: "Senior Product Manager",
title: "Senior Product Manager", title: "Senior Product Manager",
@@ -514,7 +481,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 180, budgetMonthlyUsd: 180,
}, },
{ {
// index 15
name: "Ingrid Johansson", name: "Ingrid Johansson",
role: "UX Director", role: "UX Director",
title: "Director of User Experience", title: "Director of User Experience",
@@ -525,7 +491,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 180, budgetMonthlyUsd: 180,
}, },
{ {
// index 16
name: "Leo Moretti", name: "Leo Moretti",
role: "UI Designer", role: "UI Designer",
title: "Senior UI Designer", title: "Senior UI Designer",
@@ -536,11 +501,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 160, budgetMonthlyUsd: 160,
}, },
// ────────────────────────────────────────────────────────────────────
// MARKETING — indices 17-20
// ────────────────────────────────────────────────────────────────────
{ {
// index 17
name: "Jasmine Delacroix", name: "Jasmine Delacroix",
role: "CMO", role: "CMO",
title: "Chief Marketing Officer", title: "Chief Marketing Officer",
@@ -551,7 +512,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 200, budgetMonthlyUsd: 200,
}, },
{ {
// index 18
name: "Noah Park", name: "Noah Park",
role: "Content Director", role: "Content Director",
title: "Director of Content", title: "Director of Content",
@@ -562,7 +522,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 180, budgetMonthlyUsd: 180,
}, },
{ {
// index 19
name: "Zara Ahmed", name: "Zara Ahmed",
role: "Growth Marketing Manager", role: "Growth Marketing Manager",
title: "Growth Marketing Manager", title: "Growth Marketing Manager",
@@ -573,7 +532,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 170, budgetMonthlyUsd: 170,
}, },
{ {
// index 20
name: "Clara Dubois", name: "Clara Dubois",
role: "Brand & PR Manager", role: "Brand & PR Manager",
title: "Brand & Public Relations Manager", title: "Brand & Public Relations Manager",
@@ -584,11 +542,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 150, budgetMonthlyUsd: 150,
}, },
// ────────────────────────────────────────────────────────────────────
// SALES — indices 21-23
// ────────────────────────────────────────────────────────────────────
{ {
// index 21
name: "Daniel Brooks", name: "Daniel Brooks",
role: "VP Sales", role: "VP Sales",
title: "Vice President of Sales", title: "Vice President of Sales",
@@ -599,7 +553,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 200, budgetMonthlyUsd: 200,
}, },
{ {
// index 22
name: "Grace Kimura", name: "Grace Kimura",
role: "Enterprise Account Executive", role: "Enterprise Account Executive",
title: "Enterprise Account Executive", title: "Enterprise Account Executive",
@@ -610,7 +563,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 180, budgetMonthlyUsd: 180,
}, },
{ {
// index 23
name: "Marcus Adebayo", name: "Marcus Adebayo",
role: "SDR Manager", role: "SDR Manager",
title: "Sales Development Representative Manager", title: "Sales Development Representative Manager",
@@ -621,11 +573,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 150, budgetMonthlyUsd: 150,
}, },
// ────────────────────────────────────────────────────────────────────
// HR / PEOPLE — indices 24-26
// ────────────────────────────────────────────────────────────────────
{ {
// index 24
name: "Olivia Nguyen", name: "Olivia Nguyen",
role: "VP People", role: "VP People",
title: "Vice President of People", title: "Vice President of People",
@@ -636,7 +584,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 170, budgetMonthlyUsd: 170,
}, },
{ {
// index 25
name: "Farid Al-Rashidi", name: "Farid Al-Rashidi",
role: "Recruiting Lead", role: "Recruiting Lead",
title: "Head of Talent Acquisition", title: "Head of Talent Acquisition",
@@ -647,7 +594,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 140, budgetMonthlyUsd: 140,
}, },
{ {
// index 26
name: "Hannah Johansson", name: "Hannah Johansson",
role: "People Operations Manager", role: "People Operations Manager",
title: "People Operations Manager", title: "People Operations Manager",
@@ -658,11 +604,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 120, budgetMonthlyUsd: 120,
}, },
// ────────────────────────────────────────────────────────────────────
// FINANCE & LEGAL — indices 27-29
// ────────────────────────────────────────────────────────────────────
{ {
// index 27
name: "Richard Okonkwo", name: "Richard Okonkwo",
role: "VP Finance", role: "VP Finance",
title: "Vice President of Finance", title: "Vice President of Finance",
@@ -673,7 +615,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 180, budgetMonthlyUsd: 180,
}, },
{ {
// index 28
name: "Mei-Ling Wu", name: "Mei-Ling Wu",
role: "Financial Controller", role: "Financial Controller",
title: "Financial Controller", title: "Financial Controller",
@@ -684,7 +625,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 140, budgetMonthlyUsd: 140,
}, },
{ {
// index 29
name: "Alejandro Vega", name: "Alejandro Vega",
role: "General Counsel", role: "General Counsel",
title: "General Counsel", title: "General Counsel",
@@ -695,11 +635,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 200, budgetMonthlyUsd: 200,
}, },
// ────────────────────────────────────────────────────────────────────
// CUSTOMER SUCCESS — indices 30-32
// ────────────────────────────────────────────────────────────────────
{ {
// index 30
name: "Aisha Williams", name: "Aisha Williams",
role: "VP Customer Success", role: "VP Customer Success",
title: "Vice President of Customer Success", title: "Vice President of Customer Success",
@@ -710,7 +646,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 180, budgetMonthlyUsd: 180,
}, },
{ {
// index 31
name: "Sergei Volkov", name: "Sergei Volkov",
role: "Senior Support Engineer", role: "Senior Support Engineer",
title: "Senior Technical Support Engineer", title: "Senior Technical Support Engineer",
@@ -721,7 +656,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 160, budgetMonthlyUsd: 160,
}, },
{ {
// index 32
name: "Camila Reyes", name: "Camila Reyes",
role: "Customer Success Manager", role: "Customer Success Manager",
title: "Customer Success Manager", title: "Customer Success Manager",
@@ -732,11 +666,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 140, budgetMonthlyUsd: 140,
}, },
// ────────────────────────────────────────────────────────────────────
// OPERATIONS — indices 33-34
// ────────────────────────────────────────────────────────────────────
{ {
// index 33
name: "Kenji Watanabe", name: "Kenji Watanabe",
role: "Director of Operations", role: "Director of Operations",
title: "Director of Operations", title: "Director of Operations",
@@ -747,7 +677,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 160, budgetMonthlyUsd: 160,
}, },
{ {
// index 34
name: "Laura Petrov", name: "Laura Petrov",
role: "Program Manager", role: "Program Manager",
title: "Senior Program Manager", title: "Senior Program Manager",
@@ -758,11 +687,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 150, budgetMonthlyUsd: 150,
}, },
// ────────────────────────────────────────────────────────────────────
// RESEARCH — indices 35-36
// ────────────────────────────────────────────────────────────────────
{ {
// index 35
name: "Dr. Lena Hoffmann", name: "Dr. Lena Hoffmann",
role: "Director of Research", role: "Director of Research",
title: "Director of Research & Innovation", title: "Director of Research & Innovation",
@@ -773,7 +698,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 200, budgetMonthlyUsd: 200,
}, },
{ {
// index 36
name: "Dr. Aarav Mehta", name: "Dr. Aarav Mehta",
role: "Research Scientist", role: "Research Scientist",
title: "Research Scientist", title: "Research Scientist",
@@ -828,7 +752,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
], ],
}, },
// ── E-Commerce Company ──────────────────────────────────────────────
{ {
id: "ecommerce-company", id: "ecommerce-company",
name: "E-Commerce Business", name: "E-Commerce Business",
@@ -922,7 +845,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
], ],
}, },
// ── Consulting Firm ──────────────────────────────────────────────────
{ {
id: "consulting-firm", id: "consulting-firm",
name: "Consulting Firm", name: "Consulting Firm",
@@ -992,9 +914,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
], ],
}, },
// ── AI Research Company ───────────────────────────────────────────────
// Modeled after organizations like OpenAI, Anthropic, and DeepMind.
// Research-first with strong engineering to ship models into production.
{ {
id: "ai-research-company", id: "ai-research-company",
name: "AI Research Company", name: "AI Research Company",
@@ -1013,9 +932,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
{ id: "legal", name: "Legal & Policy", description: "IP strategy, regulatory affairs, AI governance, data licensing, and compliance." }, { id: "legal", name: "Legal & Policy", description: "IP strategy, regulatory affairs, AI governance, data licensing, and compliance." },
], ],
agents: [ agents: [
// ── Executive (0-1) ──
{ {
// index 0
name: "Dr. Julian Crane", name: "Dr. Julian Crane",
role: "CEO", role: "CEO",
title: "Chief Executive Officer", title: "Chief Executive Officer",
@@ -1026,7 +943,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 250, budgetMonthlyUsd: 250,
}, },
{ {
// index 1
name: "Dr. Mei-Xing Zhou", name: "Dr. Mei-Xing Zhou",
role: "Chief Scientist", role: "Chief Scientist",
title: "Chief Scientist", title: "Chief Scientist",
@@ -1036,10 +952,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
connectorPreference: "claude_local", connectorPreference: "claude_local",
budgetMonthlyUsd: 280, budgetMonthlyUsd: 280,
}, },
// ── Research (2-6) ──
{ {
// index 2
name: "Dr. Sasha Petrov", name: "Dr. Sasha Petrov",
role: "Research Lead — Pre-training", role: "Research Lead — Pre-training",
title: "Senior Research Scientist — Pre-training", title: "Senior Research Scientist — Pre-training",
@@ -1050,7 +963,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 260, budgetMonthlyUsd: 260,
}, },
{ {
// index 3
name: "Dr. Nneka Adeyemi", name: "Dr. Nneka Adeyemi",
role: "Research Lead — Post-training", role: "Research Lead — Post-training",
title: "Senior Research Scientist — RLHF & Fine-tuning", title: "Senior Research Scientist — RLHF & Fine-tuning",
@@ -1061,7 +973,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 260, budgetMonthlyUsd: 260,
}, },
{ {
// index 4
name: "Dr. Hiroshi Takeda", name: "Dr. Hiroshi Takeda",
role: "Research Scientist — Reasoning", role: "Research Scientist — Reasoning",
title: "Research Scientist — Reasoning & Agents", title: "Research Scientist — Reasoning & Agents",
@@ -1072,7 +983,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 240, budgetMonthlyUsd: 240,
}, },
{ {
// index 5
name: "Dr. Amara Osei", name: "Dr. Amara Osei",
role: "Research Scientist — Safety", role: "Research Scientist — Safety",
title: "Research Scientist — AI Safety", title: "Research Scientist — AI Safety",
@@ -1083,7 +993,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 240, budgetMonthlyUsd: 240,
}, },
{ {
// index 6
name: "Lila Fernandez", name: "Lila Fernandez",
role: "Research Engineer", role: "Research Engineer",
title: "Senior Research Engineer", title: "Senior Research Engineer",
@@ -1093,10 +1002,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
connectorPreference: "codex_local", connectorPreference: "codex_local",
budgetMonthlyUsd: 250, budgetMonthlyUsd: 250,
}, },
// ── Engineering (7-10) ──
{ {
// index 7
name: "Rajiv Sundaram", name: "Rajiv Sundaram",
role: "VP Engineering", role: "VP Engineering",
title: "Vice President of Engineering", title: "Vice President of Engineering",
@@ -1107,7 +1013,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 220, budgetMonthlyUsd: 220,
}, },
{ {
// index 8
name: "Christine Bergstrom", name: "Christine Bergstrom",
role: "ML Infrastructure Engineer", role: "ML Infrastructure Engineer",
title: "Senior ML Infrastructure Engineer", title: "Senior ML Infrastructure Engineer",
@@ -1118,7 +1023,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 250, budgetMonthlyUsd: 250,
}, },
{ {
// index 9
name: "Andre Williams", name: "Andre Williams",
role: "Backend Engineer", role: "Backend Engineer",
title: "Senior Backend Engineer", title: "Senior Backend Engineer",
@@ -1129,7 +1033,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 230, budgetMonthlyUsd: 230,
}, },
{ {
// index 10
name: "Yuna Kim", name: "Yuna Kim",
role: "Frontend Engineer", role: "Frontend Engineer",
title: "Senior Frontend Engineer", title: "Senior Frontend Engineer",
@@ -1139,10 +1042,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
connectorPreference: "codex_local", connectorPreference: "codex_local",
budgetMonthlyUsd: 220, budgetMonthlyUsd: 220,
}, },
// ── Product & Design (11-13) ──
{ {
// index 11
name: "Tara Blackwell", name: "Tara Blackwell",
role: "VP Product", role: "VP Product",
title: "Vice President of Product", title: "Vice President of Product",
@@ -1153,7 +1053,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 200, budgetMonthlyUsd: 200,
}, },
{ {
// index 12
name: "Omar Khalil", name: "Omar Khalil",
role: "Product Manager — Developer Platform", role: "Product Manager — Developer Platform",
title: "Product Manager — Developer Platform", title: "Product Manager — Developer Platform",
@@ -1164,7 +1063,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 160, budgetMonthlyUsd: 160,
}, },
{ {
// index 13
name: "Vera Lindqvist", name: "Vera Lindqvist",
role: "Product Designer", role: "Product Designer",
title: "Senior Product Designer", title: "Senior Product Designer",
@@ -1174,10 +1072,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
connectorPreference: "claude_local", connectorPreference: "claude_local",
budgetMonthlyUsd: 160, budgetMonthlyUsd: 160,
}, },
// ── Marketing & DevRel (14-15) ──
{ {
// index 14
name: "Marcus Rhodes", name: "Marcus Rhodes",
role: "Head of Marketing", role: "Head of Marketing",
title: "Head of Marketing & Communications", title: "Head of Marketing & Communications",
@@ -1188,7 +1083,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 170, budgetMonthlyUsd: 170,
}, },
{ {
// index 15
name: "Priya Khatri", name: "Priya Khatri",
role: "Developer Advocate", role: "Developer Advocate",
title: "Senior Developer Advocate", title: "Senior Developer Advocate",
@@ -1198,10 +1092,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
connectorPreference: "codex_local", connectorPreference: "codex_local",
budgetMonthlyUsd: 170, budgetMonthlyUsd: 170,
}, },
// ── People & Finance & Legal (16-17) ──
{ {
// index 16
name: "Danielle Moreau", name: "Danielle Moreau",
role: "Head of People", role: "Head of People",
title: "Head of People", title: "Head of People",
@@ -1212,7 +1103,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 150, budgetMonthlyUsd: 150,
}, },
{ {
// index 17
name: "Gabriel Souza", name: "Gabriel Souza",
role: "Head of Legal & Policy", role: "Head of Legal & Policy",
title: "Head of Legal & Policy", title: "Head of Legal & Policy",
@@ -1251,9 +1141,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
], ],
}, },
// ── Digital Agency ────────────────────────────────────────────────────
// A full-service digital agency that designs, builds, and markets
// digital products for clients.
{ {
id: "digital-agency", id: "digital-agency",
name: "Full-Service Digital Agency", name: "Full-Service Digital Agency",
@@ -1268,9 +1155,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
{ id: "operations", name: "Operations", description: "Resource planning, process management, and quality assurance." }, { id: "operations", name: "Operations", description: "Resource planning, process management, and quality assurance." },
], ],
agents: [ agents: [
// ── Leadership (0-1) ──
{ {
// index 0
name: "Isabelle Fontaine", name: "Isabelle Fontaine",
role: "Managing Director", role: "Managing Director",
title: "Managing Director", title: "Managing Director",
@@ -1281,7 +1166,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 200, budgetMonthlyUsd: 200,
}, },
{ {
// index 1
name: "Victor Nascimento", name: "Victor Nascimento",
role: "Account Director", role: "Account Director",
title: "Account Director", title: "Account Director",
@@ -1291,10 +1175,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
connectorPreference: "claude_local", connectorPreference: "claude_local",
budgetMonthlyUsd: 170, budgetMonthlyUsd: 170,
}, },
// ── Creative & Design (2-4) ──
{ {
// index 2
name: "Saoirse O'Brien", name: "Saoirse O'Brien",
role: "Creative Director", role: "Creative Director",
title: "Creative Director", title: "Creative Director",
@@ -1305,7 +1186,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 200, budgetMonthlyUsd: 200,
}, },
{ {
// index 3
name: "Mateo Cruz", name: "Mateo Cruz",
role: "UX/UI Designer", role: "UX/UI Designer",
title: "Senior UX/UI Designer", title: "Senior UX/UI Designer",
@@ -1316,7 +1196,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 170, budgetMonthlyUsd: 170,
}, },
{ {
// index 4
name: "Yuki Aoyama", name: "Yuki Aoyama",
role: "Motion & Visual Designer", role: "Motion & Visual Designer",
title: "Motion & Visual Designer", title: "Motion & Visual Designer",
@@ -1326,10 +1205,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
connectorPreference: "claude_local", connectorPreference: "claude_local",
budgetMonthlyUsd: 140, budgetMonthlyUsd: 140,
}, },
// ── Development (5-7) ──
{ {
// index 5
name: "Nadia Kowalczyk", name: "Nadia Kowalczyk",
role: "Technical Director", role: "Technical Director",
title: "Technical Director", title: "Technical Director",
@@ -1340,7 +1216,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 220, budgetMonthlyUsd: 220,
}, },
{ {
// index 6
name: "Kwame Asante", name: "Kwame Asante",
role: "Full-Stack Developer", role: "Full-Stack Developer",
title: "Senior Full-Stack Developer", title: "Senior Full-Stack Developer",
@@ -1351,7 +1226,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 230, budgetMonthlyUsd: 230,
}, },
{ {
// index 7
name: "Elina Koskinen", name: "Elina Koskinen",
role: "Frontend Developer", role: "Frontend Developer",
title: "Frontend Developer", title: "Frontend Developer",
@@ -1361,10 +1235,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
connectorPreference: "codex_local", connectorPreference: "codex_local",
budgetMonthlyUsd: 180, budgetMonthlyUsd: 180,
}, },
// ── Digital Marketing (8-10) ──
{ {
// index 8
name: "Tariq Benali", name: "Tariq Benali",
role: "Head of Digital", role: "Head of Digital",
title: "Head of Digital Marketing", title: "Head of Digital Marketing",
@@ -1375,7 +1246,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 180, budgetMonthlyUsd: 180,
}, },
{ {
// index 9
name: "Jessica Torres", name: "Jessica Torres",
role: "SEO & Content Specialist", role: "SEO & Content Specialist",
title: "SEO & Content Specialist", title: "SEO & Content Specialist",
@@ -1386,7 +1256,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 150, budgetMonthlyUsd: 150,
}, },
{ {
// index 10
name: "Ryan Gallagher", name: "Ryan Gallagher",
role: "Paid Media Specialist", role: "Paid Media Specialist",
title: "Paid Media Specialist", title: "Paid Media Specialist",
@@ -1396,10 +1265,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
connectorPreference: "claude_local", connectorPreference: "claude_local",
budgetMonthlyUsd: 150, budgetMonthlyUsd: 150,
}, },
// ── Strategy & Content (11-12) ──
{ {
// index 11
name: "Ananya Chakraborty", name: "Ananya Chakraborty",
role: "Strategy Lead", role: "Strategy Lead",
title: "Digital Strategy Lead", title: "Digital Strategy Lead",
@@ -1410,7 +1276,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 180, budgetMonthlyUsd: 180,
}, },
{ {
// index 12
name: "Liam O'Sullivan", name: "Liam O'Sullivan",
role: "Copywriter", role: "Copywriter",
title: "Senior Copywriter", title: "Senior Copywriter",
@@ -1420,10 +1285,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
connectorPreference: "claude_local", connectorPreference: "claude_local",
budgetMonthlyUsd: 160, budgetMonthlyUsd: 160,
}, },
// ── Operations (13) ──
{ {
// index 13
name: "Sandra Muller", name: "Sandra Muller",
role: "Project Manager", role: "Project Manager",
title: "Senior Project Manager", title: "Senior Project Manager",
@@ -1454,9 +1316,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
], ],
}, },
// ── B2B SaaS Company ──────────────────────────────────────────────────
// A product-led B2B SaaS company with the complete set of functions
// needed to build, sell, and support a subscription software product.
{ {
id: "saas-company", id: "saas-company",
name: "B2B SaaS Company", name: "B2B SaaS Company",
@@ -1474,9 +1333,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
{ id: "hr", name: "People", description: "Recruiting, onboarding, culture, and people operations." }, { id: "hr", name: "People", description: "Recruiting, onboarding, culture, and people operations." },
], ],
agents: [ agents: [
// ── Executive (0-1) ──
{ {
// index 0
name: "Nathan Cross", name: "Nathan Cross",
role: "CEO", role: "CEO",
title: "Chief Executive Officer", title: "Chief Executive Officer",
@@ -1487,7 +1344,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 230, budgetMonthlyUsd: 230,
}, },
{ {
// index 1
name: "Samira Rostami", name: "Samira Rostami",
role: "CTO", role: "CTO",
title: "Chief Technology Officer", title: "Chief Technology Officer",
@@ -1497,10 +1353,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
connectorPreference: "claude_local", connectorPreference: "claude_local",
budgetMonthlyUsd: 240, budgetMonthlyUsd: 240,
}, },
// ── Engineering (2-6) ──
{ {
// index 2
name: "Derek Johansson", name: "Derek Johansson",
role: "VP Engineering", role: "VP Engineering",
title: "Vice President of Engineering", title: "Vice President of Engineering",
@@ -1511,7 +1364,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 210, budgetMonthlyUsd: 210,
}, },
{ {
// index 3
name: "Priya Venkatesh", name: "Priya Venkatesh",
role: "Senior Backend Engineer", role: "Senior Backend Engineer",
title: "Senior Backend Engineer", title: "Senior Backend Engineer",
@@ -1522,7 +1374,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 250, budgetMonthlyUsd: 250,
}, },
{ {
// index 4
name: "Luis Mendoza", name: "Luis Mendoza",
role: "Senior Frontend Engineer", role: "Senior Frontend Engineer",
title: "Senior Frontend Engineer", title: "Senior Frontend Engineer",
@@ -1533,7 +1384,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 240, budgetMonthlyUsd: 240,
}, },
{ {
// index 5
name: "Anika Bergman", name: "Anika Bergman",
role: "Platform Engineer", role: "Platform Engineer",
title: "Senior Platform Engineer", title: "Senior Platform Engineer",
@@ -1544,7 +1394,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 230, budgetMonthlyUsd: 230,
}, },
{ {
// index 6
name: "Tomoko Sato", name: "Tomoko Sato",
role: "QA Engineer", role: "QA Engineer",
title: "Quality Assurance Engineer", title: "Quality Assurance Engineer",
@@ -1554,10 +1403,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
connectorPreference: "codex_local", connectorPreference: "codex_local",
budgetMonthlyUsd: 180, budgetMonthlyUsd: 180,
}, },
// ── Product & Design (7-9) ──
{ {
// index 7
name: "Rachel Abrams", name: "Rachel Abrams",
role: "VP Product", role: "VP Product",
title: "Vice President of Product", title: "Vice President of Product",
@@ -1568,7 +1414,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 200, budgetMonthlyUsd: 200,
}, },
{ {
// index 8
name: "Jake Morrison", name: "Jake Morrison",
role: "Product Manager", role: "Product Manager",
title: "Product Manager", title: "Product Manager",
@@ -1579,7 +1424,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 170, budgetMonthlyUsd: 170,
}, },
{ {
// index 9
name: "Chloe Laurent", name: "Chloe Laurent",
role: "Product Designer", role: "Product Designer",
title: "Senior Product Designer", title: "Senior Product Designer",
@@ -1589,10 +1433,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
connectorPreference: "claude_local", connectorPreference: "claude_local",
budgetMonthlyUsd: 170, budgetMonthlyUsd: 170,
}, },
// ── Marketing (10-12) ──
{ {
// index 10
name: "Ava Richardson", name: "Ava Richardson",
role: "VP Marketing", role: "VP Marketing",
title: "Vice President of Marketing", title: "Vice President of Marketing",
@@ -1603,7 +1444,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 190, budgetMonthlyUsd: 190,
}, },
{ {
// index 11
name: "Benjamin Hart", name: "Benjamin Hart",
role: "Content Marketing Manager", role: "Content Marketing Manager",
title: "Content Marketing Manager", title: "Content Marketing Manager",
@@ -1614,7 +1454,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 160, budgetMonthlyUsd: 160,
}, },
{ {
// index 12
name: "Daria Volkov", name: "Daria Volkov",
role: "Growth & Demand Gen Manager", role: "Growth & Demand Gen Manager",
title: "Growth & Demand Generation Manager", title: "Growth & Demand Generation Manager",
@@ -1624,10 +1463,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
connectorPreference: "claude_local", connectorPreference: "claude_local",
budgetMonthlyUsd: 160, budgetMonthlyUsd: 160,
}, },
// ── Sales (13-15) ──
{ {
// index 13
name: "Carter Ellison", name: "Carter Ellison",
role: "VP Sales", role: "VP Sales",
title: "Vice President of Sales", title: "Vice President of Sales",
@@ -1638,7 +1474,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 200, budgetMonthlyUsd: 200,
}, },
{ {
// index 14
name: "Nina Johansson", name: "Nina Johansson",
role: "Enterprise Account Executive", role: "Enterprise Account Executive",
title: "Enterprise Account Executive", title: "Enterprise Account Executive",
@@ -1649,7 +1484,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 180, budgetMonthlyUsd: 180,
}, },
{ {
// index 15
name: "Travis Chen", name: "Travis Chen",
role: "SDR Lead", role: "SDR Lead",
title: "Sales Development Representative Lead", title: "Sales Development Representative Lead",
@@ -1659,10 +1493,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
connectorPreference: "claude_local", connectorPreference: "claude_local",
budgetMonthlyUsd: 130, budgetMonthlyUsd: 130,
}, },
// ── Customer Success & Support (16-18) ──
{ {
// index 16
name: "Miriam Okafor", name: "Miriam Okafor",
role: "VP Customer Success", role: "VP Customer Success",
title: "Vice President of Customer Success", title: "Vice President of Customer Success",
@@ -1673,7 +1504,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 180, budgetMonthlyUsd: 180,
}, },
{ {
// index 17
name: "Alex Petridis", name: "Alex Petridis",
role: "Customer Success Manager", role: "Customer Success Manager",
title: "Customer Success Manager", title: "Customer Success Manager",
@@ -1684,7 +1514,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 140, budgetMonthlyUsd: 140,
}, },
{ {
// index 18
name: "Rina Tanaka", name: "Rina Tanaka",
role: "Support Engineer", role: "Support Engineer",
title: "Technical Support Engineer", title: "Technical Support Engineer",
@@ -1694,10 +1523,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
connectorPreference: "codex_local", connectorPreference: "codex_local",
budgetMonthlyUsd: 140, budgetMonthlyUsd: 140,
}, },
// ── Finance & People (19) ──
{ {
// index 19
name: "Henrik Larsen", name: "Henrik Larsen",
role: "Head of Finance", role: "Head of Finance",
title: "Head of Finance", title: "Head of Finance",
-9
View File
@@ -44,7 +44,6 @@ const api: DesktopApi = {
triggerHeartbeat: (input) => ipcRenderer.invoke(IPC_CHANNELS.triggerHeartbeat, input), triggerHeartbeat: (input) => ipcRenderer.invoke(IPC_CHANNELS.triggerHeartbeat, input),
getApiPort: () => ipcRenderer.invoke(IPC_CHANNELS.getApiPort), getApiPort: () => ipcRenderer.invoke(IPC_CHANNELS.getApiPort),
getInbox: (companyId) => ipcRenderer.invoke(IPC_CHANNELS.getInbox, companyId), getInbox: (companyId) => ipcRenderer.invoke(IPC_CHANNELS.getInbox, companyId),
dismissInboxItem: (input) => ipcRenderer.invoke(IPC_CHANNELS.dismissInboxItem, input),
getCompanyMetrics: (input) => ipcRenderer.invoke(IPC_CHANNELS.getCompanyMetrics, input), getCompanyMetrics: (input) => ipcRenderer.invoke(IPC_CHANNELS.getCompanyMetrics, input),
getAgentMetrics: (input) => ipcRenderer.invoke(IPC_CHANNELS.getAgentMetrics, input), getAgentMetrics: (input) => ipcRenderer.invoke(IPC_CHANNELS.getAgentMetrics, input),
getStandupReport: (companyId) => ipcRenderer.invoke(IPC_CHANNELS.getStandupReport, companyId), getStandupReport: (companyId) => ipcRenderer.invoke(IPC_CHANNELS.getStandupReport, companyId),
@@ -55,32 +54,24 @@ const api: DesktopApi = {
listBrowserActions: (input) => ipcRenderer.invoke(IPC_CHANNELS.listBrowserActions, input), listBrowserActions: (input) => ipcRenderer.invoke(IPC_CHANNELS.listBrowserActions, input),
cancelBrowserAction: (input) => ipcRenderer.invoke(IPC_CHANNELS.cancelBrowserAction, input), cancelBrowserAction: (input) => ipcRenderer.invoke(IPC_CHANNELS.cancelBrowserAction, input),
triggerBrowserLogin: (id) => ipcRenderer.invoke(IPC_CHANNELS.triggerBrowserLogin, id), triggerBrowserLogin: (id) => ipcRenderer.invoke(IPC_CHANNELS.triggerBrowserLogin, id),
// Meetings
saveMeeting: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveMeeting, input), saveMeeting: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveMeeting, input),
deleteMeeting: (input) => ipcRenderer.invoke(IPC_CHANNELS.deleteMeeting, input), deleteMeeting: (input) => ipcRenderer.invoke(IPC_CHANNELS.deleteMeeting, input),
// Documents
saveDocument: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveDocument, input), saveDocument: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveDocument, input),
deleteDocument: (input) => ipcRenderer.invoke(IPC_CHANNELS.deleteDocument, input), deleteDocument: (input) => ipcRenderer.invoke(IPC_CHANNELS.deleteDocument, input),
// Knowledge Base
saveKnowledgeEntry: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveKnowledgeEntry, input), saveKnowledgeEntry: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveKnowledgeEntry, input),
deleteKnowledgeEntry: (input) => ipcRenderer.invoke(IPC_CHANNELS.deleteKnowledgeEntry, input), deleteKnowledgeEntry: (input) => ipcRenderer.invoke(IPC_CHANNELS.deleteKnowledgeEntry, input),
// Sprints
saveSprint: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveSprint, input), saveSprint: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveSprint, input),
deleteSprint: (input) => ipcRenderer.invoke(IPC_CHANNELS.deleteSprint, input), deleteSprint: (input) => ipcRenderer.invoke(IPC_CHANNELS.deleteSprint, input),
// Automation
saveAutomationRule: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveAutomationRule, input), saveAutomationRule: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveAutomationRule, input),
deleteAutomationRule: (input) => ipcRenderer.invoke(IPC_CHANNELS.deleteAutomationRule, input), deleteAutomationRule: (input) => ipcRenderer.invoke(IPC_CHANNELS.deleteAutomationRule, input),
toggleAutomationRule: (input) => ipcRenderer.invoke(IPC_CHANNELS.toggleAutomationRule, input), toggleAutomationRule: (input) => ipcRenderer.invoke(IPC_CHANNELS.toggleAutomationRule, input),
getAutomationLog: (companyId) => ipcRenderer.invoke(IPC_CHANNELS.getAutomationLog, companyId), getAutomationLog: (companyId) => ipcRenderer.invoke(IPC_CHANNELS.getAutomationLog, companyId),
// Agent Messages
sendAgentMessage: (input) => ipcRenderer.invoke(IPC_CHANNELS.sendAgentMessage, input), sendAgentMessage: (input) => ipcRenderer.invoke(IPC_CHANNELS.sendAgentMessage, input),
listAgentMessages: (input) => ipcRenderer.invoke(IPC_CHANNELS.listAgentMessages, input), listAgentMessages: (input) => ipcRenderer.invoke(IPC_CHANNELS.listAgentMessages, input),
markMessageRead: (input) => ipcRenderer.invoke(IPC_CHANNELS.markMessageRead, input), markMessageRead: (input) => ipcRenderer.invoke(IPC_CHANNELS.markMessageRead, input),
// Workflows
saveWorkflow: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveWorkflow, input), saveWorkflow: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveWorkflow, input),
deleteWorkflow: (input) => ipcRenderer.invoke(IPC_CHANNELS.deleteWorkflow, input), deleteWorkflow: (input) => ipcRenderer.invoke(IPC_CHANNELS.deleteWorkflow, input),
runWorkflow: (input) => ipcRenderer.invoke(IPC_CHANNELS.runWorkflow, input), runWorkflow: (input) => ipcRenderer.invoke(IPC_CHANNELS.runWorkflow, input),
// Auto-assign
autoAssignTask: (input) => ipcRenderer.invoke(IPC_CHANNELS.autoAssignTask, input), autoAssignTask: (input) => ipcRenderer.invoke(IPC_CHANNELS.autoAssignTask, input),
subscribe: (callback: (event: DesktopEvent) => void) => { subscribe: (callback: (event: DesktopEvent) => void) => {
const listener = (_event: Electron.IpcRendererEvent, payload: DesktopEvent) => callback(payload); const listener = (_event: Electron.IpcRendererEvent, payload: DesktopEvent) => callback(payload);
+124 -211
View File
@@ -3,7 +3,6 @@ import { AnimatePresence, motion } from "framer-motion";
import { import {
ArrowClockwise, ArrowClockwise,
Bell, Bell,
BookOpen,
Brain, Brain,
CalendarBlank, CalendarBlank,
CaretUpDown, CaretUpDown,
@@ -39,8 +38,6 @@ import {
X, X,
} from "@phosphor-icons/react"; } from "@phosphor-icons/react";
import type { import type {
AgentMessageRecord,
AutomationRuleRecord,
CompanyRecord, CompanyRecord,
CompanyMetrics, CompanyMetrics,
DesktopEvent, DesktopEvent,
@@ -51,7 +48,6 @@ import type {
SocialAccountRecord, SocialAccountRecord,
BrowserActionRecord, BrowserActionRecord,
StandupReport, StandupReport,
WorkflowPipelineRecord,
} from "@shared/types"; } from "@shared/types";
import type { SidebarBadges } from "@shared/contracts"; import type { SidebarBadges } from "@shared/contracts";
import { CompanyOnboarding } from "./components/CompanyOnboarding"; import { CompanyOnboarding } from "./components/CompanyOnboarding";
@@ -72,6 +68,7 @@ import {
TaskInspector, TaskInspector,
TaskQueue, TaskQueue,
WorkspaceCreator, WorkspaceCreator,
WorkspaceInspector,
WorkspaceList, WorkspaceList,
} from "./components/execution"; } from "./components/execution";
import { import {
@@ -90,6 +87,7 @@ import { SocialAccountsPanel } from "./components/social";
import { AutomationPanel } from "./components/automation"; import { AutomationPanel } from "./components/automation";
import { CommunicationHub } from "./components/communication"; import { CommunicationHub } from "./components/communication";
import { HiringPipeline } from "./components/hiring"; import { HiringPipeline } from "./components/hiring";
import { DocumentCenter, KnowledgeBaseManager, MeetingsManager, SprintManager } from "./components/company-operations";
import { ActionButton, ErrorBoundary, ListFrame, ListRow, Modal, Panel, SectionButton, Select, StatusPill, useFocusTrap, useReducedMotion } from "./components/ui"; import { ActionButton, ErrorBoundary, ListFrame, ListRow, Modal, Panel, SectionButton, Select, StatusPill, useFocusTrap, useReducedMotion } from "./components/ui";
import { unwrap } from "./lib/desktop"; import { unwrap } from "./lib/desktop";
import { formatMoney, formatTime } from "./lib/formatters"; import { formatMoney, formatTime } from "./lib/formatters";
@@ -175,12 +173,15 @@ export function App() {
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null); const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
const [selectedGoalId, setSelectedGoalId] = useState<string | null>(null); const [selectedGoalId, setSelectedGoalId] = useState<string | null>(null);
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null); const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null);
const [selectedWorkspaceId, setSelectedWorkspaceId] = useState<string | null>(null);
const [selectedCommunicationEntityId, setSelectedCommunicationEntityId] = useState<string | null>(null);
const [taskViewMode, setTaskViewMode] = useState<"list" | "kanban">("list"); const [taskViewMode, setTaskViewMode] = useState<"list" | "kanban">("list");
const logBufferRef = useRef<Record<string, string>>({}); const logBufferRef = useRef<Record<string, string>>({});
const [, setLogRevision] = useState(0); const [, setLogRevision] = useState(0);
const [badges, setBadges] = useState<SidebarBadges | null>(null); const [badges, setBadges] = useState<SidebarBadges | null>(null);
const [commandPaletteSearch, setCommandPaletteSearch] = useState(""); const [commandPaletteSearch, setCommandPaletteSearch] = useState("");
const [showOnboarding, setShowOnboarding] = useState(false); const [showOnboarding, setShowOnboarding] = useState(false);
const [initialOnboardingLocked, setInitialOnboardingLocked] = useState(false);
const [inboxItems, setInboxItems] = useState<InboxItem[]>([]); const [inboxItems, setInboxItems] = useState<InboxItem[]>([]);
const [companyMetrics, setCompanyMetrics] = useState<CompanyMetrics | null>(null); const [companyMetrics, setCompanyMetrics] = useState<CompanyMetrics | null>(null);
const [standupReport, setStandupReport] = useState<StandupReport | null>(null); const [standupReport, setStandupReport] = useState<StandupReport | null>(null);
@@ -191,23 +192,46 @@ export function App() {
try { try {
const next = unwrap<ProfileSnapshot>(await window.agentCompany.loadSnapshot()); const next = unwrap<ProfileSnapshot>(await window.agentCompany.loadSnapshot());
setSnapshot(next); setSnapshot(next);
// Tasks are opened explicitly by clicking; don't auto-select. if (next.companies.length === 0) {
setInitialOnboardingLocked(true);
}
if (!selectedConnectorId && next.connectors[0]) setSelectedConnectorId(next.connectors[0].id); if (!selectedConnectorId && next.connectors[0]) setSelectedConnectorId(next.connectors[0].id);
if (!selectedApprovalId && next.approvals[0]) setSelectedApprovalId(next.approvals[0].id);
if (!selectedRunId && next.runs[0]) setSelectedRunId(next.runs[0].id); if (!selectedRunId && next.runs[0]) setSelectedRunId(next.runs[0].id);
if (next.currentCompanyId) { if (next.currentCompanyId) {
const badgeResult = await window.agentCompany.getSidebarBadges(next.currentCompanyId); const badgeResult = await window.agentCompany.getSidebarBadges(next.currentCompanyId);
if (badgeResult.ok) setBadges(badgeResult.data); if (badgeResult.ok) setBadges(badgeResult.data);
setSocialAccounts(next.socialAccounts?.filter(a => a.companyId === next.currentCompanyId) ?? []); setSocialAccounts(next.socialAccounts?.filter(a => a.companyId === next.currentCompanyId) ?? []);
const browserActionResult = await window.agentCompany.listBrowserActions({ companyId: next.currentCompanyId, limit: 50 });
if (browserActionResult.ok) {
setBrowserActions(browserActionResult.data);
}
} }
setError(null); setError(null);
} catch (nextError) { } catch (nextError) {
setError(nextError instanceof Error ? nextError.message : String(nextError)); setError(nextError instanceof Error ? nextError.message : String(nextError));
} }
}, [selectedApprovalId, selectedConnectorId, selectedRunId]); }, [selectedConnectorId, selectedRunId]);
// Stable ref so the subscribe callback always calls the latest refresh
// without tearing down the subscription when refresh identity changes.
const refreshRef = useRef(refresh);
useEffect(() => {
refreshRef.current = refresh;
}, [refresh]);
// Debounce timer ref for coalescing rapid real-time events
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const debouncedRefresh = useCallback(() => {
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = setTimeout(() => {
debounceTimerRef.current = null;
void refreshRef.current();
}, 500);
}, []);
useEffect(() => { useEffect(() => {
void refresh(); void refreshRef.current();
const unsubscribe = window.agentCompany.subscribe((event: DesktopEvent) => { const unsubscribe = window.agentCompany.subscribe((event: DesktopEvent) => {
if (event.type === "run-log") { if (event.type === "run-log") {
logBufferRef.current = { logBufferRef.current = {
@@ -220,13 +244,18 @@ export function App() {
if (event.type === "shortcut") { if (event.type === "shortcut") {
if (event.action === "toggle-command-palette") setCommandPaletteOpen((current) => !current); if (event.action === "toggle-command-palette") setCommandPaletteOpen((current) => !current);
if (event.action === "toggle-console") setConsoleOpen((current) => !current); if (event.action === "toggle-console") setConsoleOpen((current) => !current);
if (event.action === "refresh") void refresh(); if (event.action === "refresh") void refreshRef.current();
return; return;
} }
void refresh(); // All data-mutating events trigger a debounced snapshot reload:
// domain-changed, run-status, new-message, automation-fired, heartbeat-status
debouncedRefresh();
}); });
return unsubscribe; return () => {
}, [refresh]); unsubscribe();
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
};
}, [debouncedRefresh]);
useEffect(() => { useEffect(() => {
if (!selectedRunId) return; if (!selectedRunId) return;
@@ -272,13 +301,9 @@ export function App() {
if (section === "social") { if (section === "social") {
setSocialAccounts(snapshot.socialAccounts?.filter(a => a.companyId === companyId) ?? []); 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]); }, [section, snapshot]);
// Global Escape key handler for command palette
useEffect(() => { useEffect(() => {
if (!commandPaletteOpen) return; if (!commandPaletteOpen) return;
function onKey(event: KeyboardEvent) { function onKey(event: KeyboardEvent) {
@@ -302,11 +327,10 @@ export function App() {
const selectedTask = selectedTaskId ? (tasks.find((task) => task.id === selectedTaskId) ?? null) : null; const selectedTask = selectedTaskId ? (tasks.find((task) => task.id === selectedTaskId) ?? null) : null;
const selectedConnector = snapshot?.connectors.find((connector) => connector.id === selectedConnectorId) ?? snapshot?.connectors[0] ?? null; const selectedConnector = snapshot?.connectors.find((connector) => connector.id === selectedConnectorId) ?? snapshot?.connectors[0] ?? null;
const approvals = useMemo(() => snapshot?.approvals.filter((approval) => !currentCompany || approval.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]); const approvals = useMemo(() => snapshot?.approvals.filter((approval) => !currentCompany || approval.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
const selectedApproval = approvals.find((approval) => approval.id === selectedApprovalId) ?? approvals[0] ?? null; const selectedApproval = selectedApprovalId ? (approvals.find((approval) => approval.id === selectedApprovalId) ?? null) : null;
const runs = useMemo(() => snapshot?.runs.filter((run) => !currentCompany || run.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]); const runs = useMemo(() => snapshot?.runs.filter((run) => !currentCompany || run.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
const selectedRun = runs.find((run) => run.id === selectedRunId) ?? runs[0] ?? null; const selectedRun = runs.find((run) => run.id === selectedRunId) ?? runs[0] ?? null;
// Derived selections for organization entities
const companyAgents = useMemo(() => snapshot?.agents.filter((a) => !currentCompany || a.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]); const companyAgents = useMemo(() => snapshot?.agents.filter((a) => !currentCompany || a.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
const selectedAgent = companyAgents.find((a) => a.id === selectedAgentId) ?? null; const selectedAgent = companyAgents.find((a) => a.id === selectedAgentId) ?? null;
const companyGoals = useMemo(() => snapshot?.goals.filter((g) => !currentCompany || g.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]); const companyGoals = useMemo(() => snapshot?.goals.filter((g) => !currentCompany || g.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
@@ -314,32 +338,24 @@ export function App() {
const companyProjects = useMemo(() => snapshot?.projects.filter((p) => !currentCompany || p.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]); const companyProjects = useMemo(() => snapshot?.projects.filter((p) => !currentCompany || p.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
const selectedProject = companyProjects.find((p) => p.id === selectedProjectId) ?? null; const selectedProject = companyProjects.find((p) => p.id === selectedProjectId) ?? null;
// Sidebar badge mapping const activeInboxCount = inboxItems.length;
const activeInboxCount = useMemo(() => inboxItems.filter((item) => !item.dismissed).length, [inboxItems]);
const navBadges = useMemo<Partial<Record<SectionId, { count: number; tone: "default" | "warn" | "danger" }>>>(() => badges ? { const navBadges = useMemo<Partial<Record<SectionId, { count: number; tone: "default" | "warn" | "danger" }>>>(() => badges ? {
inbox: activeInboxCount > 0 ? { count: activeInboxCount, tone: "warn" } : undefined, inbox: activeInboxCount > 0 ? { count: activeInboxCount, tone: "warn" } : undefined,
tasks: badges.todoTasks > 0 ? { count: badges.todoTasks, tone: "default" } : undefined, tasks: badges.todoTasks > 0 ? { count: badges.todoTasks, tone: "default" } : undefined,
approvals: badges.pendingApprovals > 0 ? { count: badges.pendingApprovals, tone: "warn" } : undefined, approvals: badges.pendingApprovals > 0 ? { count: badges.pendingApprovals, tone: "warn" } : undefined,
costs: badges.budgetWarnings > 0 ? { count: badges.budgetWarnings, tone: "danger" } : undefined, costs: badges.budgetWarnings > 0 ? { count: badges.budgetWarnings, tone: "danger" } : undefined,
communication: badges.unreadMessages > 0 ? { count: badges.unreadMessages, tone: "warn" } : undefined,
} : {}, [badges, activeInboxCount]); } : {}, [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]);
const pendingApprovals = useMemo(() => approvals.filter((approval) => approval.state === "pending"), [approvals]);
const tasksInProgress = useMemo(() => tasks.filter((task) => task.status === "in_progress" || task.status === "in_review"), [tasks]);
const companyWorkspaces = useMemo(() => snapshot?.workspaces.filter((workspace) => !currentCompany || workspace.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]); const companyWorkspaces = useMemo(() => snapshot?.workspaces.filter((workspace) => !currentCompany || workspace.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
const selectedWorkspace = companyWorkspaces.find((workspace) => workspace.id === selectedWorkspaceId) ?? companyWorkspaces[0] ?? null;
const companyCosts = useMemo(() => snapshot?.costs.filter((entry) => !currentCompany || entry.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]); const companyCosts = useMemo(() => snapshot?.costs.filter((entry) => !currentCompany || entry.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
const filteredActivity = useMemo(() => snapshot?.activity.filter((entry) => !currentCompany || entry.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]); const filteredActivity = useMemo(() => snapshot?.activity.filter((entry) => !currentCompany || entry.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
const companySecrets = useMemo(() => snapshot?.secrets.filter((secret) => !currentCompany || secret.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]); const companySecrets = useMemo(() => snapshot?.secrets.filter((secret) => !currentCompany || secret.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
const totalSpend = useMemo(() => companyCosts.reduce((sum, entry) => sum + (entry.amountUsd ?? 0), 0), [companyCosts]); const companyDocuments = useMemo(() => snapshot?.documents?.filter((d) => !currentCompany || d.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
const overviewRows = useMemo(() => [ const companyKnowledge = useMemo(() => snapshot?.knowledgeBase?.filter((k) => !currentCompany || k.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
{ label: "Live runs", value: String(liveRuns.length), tone: liveRuns.length > 0 ? "text-[color:var(--success)]" : "" }, const companyMeetings = useMemo(() => snapshot?.meetings?.filter((m) => !currentCompany || m.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
{ label: "Pending approvals", value: String(pendingApprovals.length), tone: pendingApprovals.length > 0 ? "text-[color:var(--warn)]" : "" }, const companySprints = useMemo(() => snapshot?.sprints?.filter((s) => !currentCompany || s.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
{ label: "Active tasks", value: String(tasksInProgress.length), tone: "" },
{ label: "Known spend", value: formatMoney(totalSpend), tone: "" },
], [liveRuns, pendingApprovals, tasksInProgress, totalSpend]);
// Command palette entity search
const paletteQuery = commandPaletteSearch.trim().toLowerCase(); const paletteQuery = commandPaletteSearch.trim().toLowerCase();
const paletteResults = useMemo(() => { const paletteResults = useMemo(() => {
const filteredNavItems = paletteQuery const filteredNavItems = paletteQuery
@@ -354,6 +370,18 @@ export function App() {
}, [paletteQuery, companyAgents, tasks, companyGoals, companyProjects]); }, [paletteQuery, companyAgents, tasks, companyGoals, companyProjects]);
const { filteredNavItems, matchedAgents, matchedTasks, matchedGoals, matchedProjects, hasEntityResults } = paletteResults; const { filteredNavItems, matchedAgents, matchedTasks, matchedGoals, matchedProjects, hasEntityResults } = paletteResults;
const navigateTo = useCallback((targetSection: SectionId, entityId?: string) => {
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);
else if (targetSection === "communication") setSelectedCommunicationEntityId(entityId);
}
}, []);
const setCompany = useCallback(async (companyId: string) => { const setCompany = useCallback(async (companyId: string) => {
if (companyId === "__new__") { if (companyId === "__new__") {
setShowOnboarding(true); setShowOnboarding(true);
@@ -419,8 +447,12 @@ export function App() {
); );
} }
if (snapshot.companies.length === 0 || showOnboarding) { if (snapshot.companies.length === 0 || showOnboarding || initialOnboardingLocked) {
return <CompanyOnboarding connectors={snapshot.connectors} onDone={async () => { setShowOnboarding(false); await refresh(); }} />; return <CompanyOnboarding connectors={snapshot.connectors} onDone={async () => {
setShowOnboarding(false);
setInitialOnboardingLocked(false);
await refresh();
}} />;
} }
return ( return (
@@ -560,7 +592,7 @@ export function App() {
className={`focus-ring mt-1 flex w-full items-center rounded-[6px] py-[7px] text-[color:var(--muted)] transition duration-150 hover:bg-[color:var(--panel-soft)] hover:text-[color:var(--text)] ${sidebarCollapsed ? "justify-center p-2" : "gap-2.5 px-3"}`} className={`focus-ring mt-1 flex w-full items-center rounded-[6px] py-[7px] text-[color:var(--muted)] transition duration-150 hover:bg-[color:var(--panel-soft)] hover:text-[color:var(--text)] ${sidebarCollapsed ? "justify-center p-2" : "gap-2.5 px-3"}`}
onClick={() => setSidebarCollapsed((c) => !c)} onClick={() => setSidebarCollapsed((c) => !c)}
> >
<SidebarSimple size={sidebarCollapsed ? 18 : 17} className={sidebarCollapsed ? "" : ""} /> <SidebarSimple size={sidebarCollapsed ? 18 : 17} />
{!sidebarCollapsed ? <span className="text-[13px] font-medium tracking-[0.005em]">Collapse</span> : null} {!sidebarCollapsed ? <span className="text-[13px] font-medium tracking-[0.005em]">Collapse</span> : null}
</button> </button>
</div> </div>
@@ -626,67 +658,33 @@ export function App() {
activity={filteredActivity} activity={filteredActivity}
costs={companyCosts} costs={companyCosts}
connectors={snapshot.connectors} connectors={snapshot.connectors}
meetings={snapshot.meetings?.filter((m) => !currentCompany || m.companyId === currentCompany.id)} meetings={companyMeetings}
sprints={snapshot.sprints?.filter((s) => !currentCompany || s.companyId === currentCompany.id)} sprints={companySprints}
onNavigate={(targetSection, entityId) => { messages={snapshot.agentMessages?.filter((message) => !currentCompany || message.companyId === currentCompany.id) ?? []}
setSection(targetSection); browserActions={browserActions}
if (entityId) { workflows={snapshot.workflows?.filter((workflow) => !currentCompany || workflow.companyId === currentCompany.id) ?? []}
if (targetSection === "agents") setSelectedAgentId(entityId); onNavigate={navigateTo}
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} ) : null}
{section === "inbox" ? ( {section === "inbox" ? (
<Inbox <Inbox
items={inboxItems} items={inboxItems}
onNavigate={(targetSection, entityId) => { onNavigate={navigateTo}
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} ) : null}
{section === "standup" ? ( {section === "standup" ? (
<StandupView <StandupView
report={standupReport} report={standupReport}
onNavigate={(targetSection, entityId) => { onNavigate={navigateTo}
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} ) : null}
{section === "performance" ? ( {section === "performance" ? (
<PerformanceView <PerformanceView
metrics={companyMetrics} metrics={companyMetrics}
onNavigate={(targetSection, entityId) => { onNavigate={navigateTo}
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} ) : null}
@@ -717,7 +715,7 @@ export function App() {
</Panel> </Panel>
<Panel <Panel
title={selectedConnector.label} title={selectedConnector.label}
eyebrow={undefined}
action={<ActionButton label="Run health check" onClick={() => void quickConnectorCheck()} icon={Wrench} tone="accent" />} action={<ActionButton label="Run health check" onClick={() => void quickConnectorCheck()} icon={Wrench} tone="accent" />}
> >
<ConnectorEditor connector={selectedConnector} onSaved={refresh} /> <ConnectorEditor connector={selectedConnector} onSaved={refresh} />
@@ -762,7 +760,6 @@ export function App() {
{section === "hiring" && currentCompany ? ( {section === "hiring" && currentCompany ? (
<HiringPipeline <HiringPipeline
companyId={currentCompany.id}
agents={companyAgents} agents={companyAgents}
approvals={(snapshot?.approvals ?? []).filter(a => a.companyId === currentCompany!.id)} approvals={(snapshot?.approvals ?? []).filter(a => a.companyId === currentCompany!.id)}
onRefresh={refresh} onRefresh={refresh}
@@ -906,7 +903,7 @@ export function App() {
tasks={tasks} tasks={tasks}
companyId={currentCompany!.id} companyId={currentCompany!.id}
onDecide={async (state, note) => { onDecide={async (state, note) => {
unwrap(await window.agentCompany.decideApproval({ approvalId: selectedApproval.id, state, decisionNote: note ?? `${state} from desktop control surface.` })); unwrap(await window.agentCompany.decideApproval({ companyId: currentCompany!.id, approvalId: selectedApproval.id, state, decisionNote: note ?? `${state} from desktop control surface.` }));
await refresh(); await refresh();
}} }}
onDeleted={async () => { setSelectedApprovalId(null); await refresh(); }} onDeleted={async () => { setSelectedApprovalId(null); await refresh(); }}
@@ -965,16 +962,19 @@ export function App() {
}} }}
onLogin={async (accountId) => { onLogin={async (accountId) => {
await window.agentCompany.triggerBrowserLogin(accountId); await window.agentCompany.triggerBrowserLogin(accountId);
await refresh();
}} }}
onNavigate={(s, id) => { onOpenPath={async (path) => {
setSection(s); await window.agentCompany.openPath(path);
if (id) { }}
if (s === "agents") setSelectedAgentId(id); onCancelAction={async (actionId) => {
else if (s === "tasks") setSelectedTaskId(id); await window.agentCompany.cancelBrowserAction({ runId: actionId });
else if (s === "approvals") setSelectedApprovalId(id); await refresh();
else if (s === "goals") setSelectedGoalId(id); }}
else if (s === "projects") setSelectedProjectId(id); onNavigate={(sectionName, entityId) => {
} setSection(sectionName);
if (sectionName === "tasks") setSelectedTaskId(entityId);
if (sectionName === "approvals") setSelectedApprovalId(entityId);
}} }}
/> />
)} )}
@@ -983,7 +983,6 @@ export function App() {
<AutomationPanel <AutomationPanel
rules={snapshot.automationRules?.filter(r => r.companyId === currentCompany.id) ?? []} rules={snapshot.automationRules?.filter(r => r.companyId === currentCompany.id) ?? []}
workflows={snapshot.workflows?.filter(w => w.companyId === currentCompany.id) ?? []} workflows={snapshot.workflows?.filter(w => w.companyId === currentCompany.id) ?? []}
agents={companyAgents}
companyId={currentCompany.id} companyId={currentCompany.id}
onRefresh={refresh} onRefresh={refresh}
/> />
@@ -992,137 +991,36 @@ export function App() {
{section === "communication" && currentCompany ? ( {section === "communication" && currentCompany ? (
<CommunicationHub <CommunicationHub
messages={snapshot.agentMessages?.filter(m => m.companyId === currentCompany.id) ?? []} messages={snapshot.agentMessages?.filter(m => m.companyId === currentCompany.id) ?? []}
comments={snapshot.comments?.filter((comment) => comment.companyId === currentCompany.id) ?? []}
tasks={tasks}
agents={companyAgents} agents={companyAgents}
companyId={currentCompany.id} companyId={currentCompany.id}
selectedEntityId={selectedCommunicationEntityId}
onRefresh={refresh} onRefresh={refresh}
/> />
) : null} ) : null}
{section === "documents" && currentCompany ? ( {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>}> <DocumentCenter
{(snapshot.documents?.filter(d => d.companyId === currentCompany.id) ?? []).length === 0 ? ( companyId={currentCompany.id}
<div className="py-10 text-center text-[13px] text-[color:var(--muted)]"> documents={companyDocuments}
<FileText size={32} className="mx-auto mb-3 opacity-40" /> agents={companyAgents}
<p>No documents yet. Agents will create PRDs, specs, and reports as they work.</p> projects={companyProjects}
</div> goals={companyGoals}
) : ( onRefresh={refresh}
<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} ) : null}
{section === "knowledge" && currentCompany ? ( {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>}> <KnowledgeBaseManager companyId={currentCompany.id} entries={companyKnowledge} onRefresh={refresh} />
{(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} ) : null}
{section === "meetings" && currentCompany ? ( {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>}> <MeetingsManager companyId={currentCompany.id} meetings={companyMeetings} agents={companyAgents} onRefresh={refresh} />
{(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} ) : null}
{section === "sprints" && currentCompany ? ( {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>}> <SprintManager companyId={currentCompany.id} sprints={companySprints} onRefresh={refresh} />
{(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} ) : null}
{section === "workspaces" ? ( {section === "workspaces" ? (
@@ -1135,15 +1033,30 @@ export function App() {
workspaces={companyWorkspaces} workspaces={companyWorkspaces}
projects={companyProjects} projects={companyProjects}
companyId={currentCompany!.id} companyId={currentCompany!.id}
selectedWorkspaceId={selectedWorkspace?.id ?? null}
onSelect={setSelectedWorkspaceId}
onOpen={(path) => window.agentCompany.openPath(path).then(() => undefined)} onOpen={(path) => window.agentCompany.openPath(path).then(() => undefined)}
onDeleted={refresh} onDeleted={refresh}
/> />
</Panel> </Panel>
} }
secondary={ secondary={
<Panel title="Add workspace"> <div className="space-y-6">
<WorkspaceCreator companyId={currentCompany!.id} projects={companyProjects} onSaved={refresh} /> <Panel title="Workspace control center">
</Panel> <WorkspaceInspector
workspace={selectedWorkspace}
agents={companyAgents}
tasks={tasks}
runs={runs}
documents={companyDocuments}
project={selectedWorkspace ? (companyProjects.find((project) => project.id === selectedWorkspace.projectId) ?? null) : null}
onOpen={(path) => window.agentCompany.openPath(path).then(() => undefined)}
/>
</Panel>
<Panel title="Add workspace">
<WorkspaceCreator companyId={currentCompany!.id} projects={companyProjects} onSaved={refresh} />
</Panel>
</div>
} }
/> />
) : null} ) : null}
+66 -63
View File
@@ -20,6 +20,7 @@ import {
} from "@phosphor-icons/react"; } from "@phosphor-icons/react";
import type { CompanyTemplate, ProfileSnapshot } from "@shared/types"; import type { CompanyTemplate, ProfileSnapshot } from "@shared/types";
import { unwrap } from "../lib/desktop"; import { unwrap } from "../lib/desktop";
import { getAgentInitials } from "../lib/formatters";
import { ActionButton, Input, TextArea } from "./ui"; import { ActionButton, Input, TextArea } from "./ui";
type Step = "choose" | "template" | "company" | "goal" | "launch"; type Step = "choose" | "template" | "company" | "goal" | "launch";
@@ -56,7 +57,6 @@ export function CompanyOnboarding({
const bestConnector = readyConnectors[0] ?? null; const bestConnector = readyConnectors[0] ?? null;
const selectedTemplate = templates.find((t) => t.id === selectedTemplateId) ?? null; const selectedTemplate = templates.find((t) => t.id === selectedTemplateId) ?? null;
// Load templates
useEffect(() => { useEffect(() => {
window.agentCompany.listCompanyTemplates().then((result) => { window.agentCompany.listCompanyTemplates().then((result) => {
if (result.ok) setTemplates(result.data); if (result.ok) setTemplates(result.data);
@@ -75,7 +75,6 @@ export function CompanyOnboarding({
} }
} }
// Template launch
const launchFromTemplate = useCallback(async () => { const launchFromTemplate = useCallback(async () => {
if (loading || !selectedTemplate) return; if (loading || !selectedTemplate) return;
if (!companyName.trim()) { if (!companyName.trim()) {
@@ -98,7 +97,6 @@ export function CompanyOnboarding({
}), }),
); );
// Create workspace for the company if path provided
if (workspacePath) { if (workspacePath) {
unwrap( unwrap(
await window.agentCompany.saveWorkspace({ await window.agentCompany.saveWorkspace({
@@ -122,7 +120,6 @@ export function CompanyOnboarding({
} }
}, [loading, selectedTemplate, companyName, hasReadyConnector, workspacePath, bestConnector, onDone]); }, [loading, selectedTemplate, companyName, hasReadyConnector, workspacePath, bestConnector, onDone]);
// Custom company creation (existing flow)
const createCompany = useCallback(async () => { const createCompany = useCallback(async () => {
if (loading) return; if (loading) return;
if (!companyName.trim()) { if (!companyName.trim()) {
@@ -166,66 +163,77 @@ export function CompanyOnboarding({
} }
}, [loading, companyName, hasReadyConnector, autoApproveHires, workspacePath]); }, [loading, companyName, hasReadyConnector, autoApproveHires, workspacePath]);
const bootstrapInitialCompany = useCallback(async (companyId: string, seedGoal?: { title?: string; description?: string }) => {
if (seedGoal?.title?.trim()) {
unwrap(
await window.agentCompany.saveGoal({
companyId,
title: seedGoal.title.trim(),
description: seedGoal.description?.trim() || `Achieve: ${seedGoal.title.trim()}`,
status: "active",
parentId: null,
ownerAgentId: null,
}),
);
}
if (bestConnector) {
const ceoId = unwrap(
await window.agentCompany.saveAgent({
companyId,
name: "CEO",
role: "CEO",
title: "Chief Executive Officer",
reportsTo: null,
connectorId: bestConnector.id,
workspaceId: null,
model: null,
capabilities: "Executive leadership, strategic planning, team building, goal decomposition, delegation, and organizational management. Responsible for hiring the leadership team, setting strategy, and coordinating all company operations.",
budgetMonthlyUsd: 250,
status: "idle",
}),
);
unwrap(await window.agentCompany.setHeartbeat({ agentId: ceoId, companyId, enabled: true, intervalSec: 120 }));
setStep("launch");
setTimeout(async () => {
try {
await window.agentCompany.triggerHeartbeat({ agentId: ceoId, companyId, trigger: "assignment" });
} catch {
// Non-fatal. The CEO will wake on the next scheduled heartbeat.
}
}, 1500);
}
await onDone();
}, [bestConnector, onDone]);
const createGoalAndLaunch = useCallback(async () => { const createGoalAndLaunch = useCallback(async () => {
if (loading || !createdCompanyId) return; if (loading || !createdCompanyId) return;
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
if (goalTitle.trim()) { await bootstrapInitialCompany(createdCompanyId, {
unwrap( title: goalTitle,
await window.agentCompany.saveGoal({ description: goalDescription,
companyId: createdCompanyId, });
title: goalTitle.trim(),
description: goalDescription.trim() || `Achieve: ${goalTitle.trim()}`,
status: "active",
parentId: null,
ownerAgentId: null,
}),
);
}
if (bestConnector) {
const ceoId = unwrap(
await window.agentCompany.saveAgent({
companyId: createdCompanyId,
name: "CEO",
role: "CEO",
title: "Chief Executive Officer",
reportsTo: null,
connectorId: bestConnector.id,
workspaceId: null,
model: null,
capabilities: "Executive leadership, strategic planning, team building, goal decomposition, delegation, and organizational management. Responsible for hiring the leadership team, setting strategy, and coordinating all company operations.",
budgetMonthlyUsd: 250,
status: "idle",
}),
);
unwrap(await window.agentCompany.setHeartbeat({ agentId: ceoId, companyId: createdCompanyId, enabled: true, intervalSec: 120 }));
setStep("launch");
setTimeout(async () => {
try {
await window.agentCompany.triggerHeartbeat({ agentId: ceoId, companyId: createdCompanyId, trigger: "assignment" });
} catch { /* Non-fatal */ }
}, 1500);
}
await onDone();
} catch (submitError) { } catch (submitError) {
setError(submitError instanceof Error ? submitError.message : "Unable to set up the company."); setError(submitError instanceof Error ? submitError.message : "Unable to set up the company.");
setLoading(false); setLoading(false);
} }
}, [loading, createdCompanyId, goalTitle, goalDescription, bestConnector, onDone]); }, [loading, createdCompanyId, goalTitle, goalDescription, bootstrapInitialCompany]);
const skipGoalAndFinish = useCallback(async () => { const skipGoalAndFinish = useCallback(async () => {
if (loading) return; if (loading || !createdCompanyId) return;
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
await onDone(); await bootstrapInitialCompany(createdCompanyId);
} catch (submitError) { } catch (submitError) {
setError(submitError instanceof Error ? submitError.message : "Unable to continue."); setError(submitError instanceof Error ? submitError.message : "Unable to continue.");
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [loading, onDone]); }, [loading, createdCompanyId, bootstrapInitialCompany]);
const stepLabels = step === "choose" || step === "template" const stepLabels = step === "choose" || step === "template"
? [ ? [
@@ -242,14 +250,13 @@ export function CompanyOnboarding({
const stepIndex = step === "choose" ? 0 : step === "template" ? 1 : step === "company" ? 0 : step === "goal" ? 1 : 2; const stepIndex = step === "choose" ? 0 : step === "template" ? 1 : step === "company" ? 0 : step === "goal" ? 1 : 2;
return ( return (
<div className="grid min-h-[100dvh] grid-cols-1 bg-[color:var(--bg)] md:grid-cols-[minmax(0,1fr)_520px]"> <div className="grid h-[100dvh] overflow-hidden grid-cols-1 bg-[color:var(--bg)] md:grid-cols-[minmax(0,1fr)_520px]">
{/* Left panel — branding */} <div className="flex min-h-0 items-center justify-center overflow-y-auto px-8 py-12 md:px-16">
<div className="flex items-center px-8 py-12 md:px-16">
<motion.div <motion.div
initial={{ opacity: 0, y: 24 }} initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.45, ease: [0.16, 1, 0.3, 1] }} transition={{ duration: 0.45, ease: [0.16, 1, 0.3, 1] }}
className="max-w-[440px]" className="mx-auto w-full max-w-[440px]"
> >
<div className="mb-3 text-[10px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">AgentCompany Desktop</div> <div className="mb-3 text-[10px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">AgentCompany Desktop</div>
<h1 className="text-[38px] leading-[0.98] tracking-[-0.03em] text-[color:var(--text)] md:text-[48px]"> <h1 className="text-[38px] leading-[0.98] tracking-[-0.03em] text-[color:var(--text)] md:text-[48px]">
@@ -259,7 +266,7 @@ export function CompanyOnboarding({
Set up a fully autonomous AI-powered organization. Your agents will hire, delegate, execute, review, and coordinate — just like a real company. Set up a fully autonomous AI-powered organization. Your agents will hire, delegate, execute, review, and coordinate — just like a real company.
</p> </p>
{/* Step indicators */}
<div className="mt-10 flex items-center gap-3"> <div className="mt-10 flex items-center gap-3">
{stepLabels.map((s, i) => ( {stepLabels.map((s, i) => (
<div key={s.label} className="flex items-center gap-2"> <div key={s.label} className="flex items-center gap-2">
@@ -278,7 +285,7 @@ export function CompanyOnboarding({
))} ))}
</div> </div>
{/* Detected connectors */}
<div className="mt-10"> <div className="mt-10">
<div className="mb-3 text-[10px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Detected agents</div> <div className="mb-3 text-[10px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Detected agents</div>
<div className="space-y-2.5"> <div className="space-y-2.5">
@@ -313,10 +320,9 @@ export function CompanyOnboarding({
</motion.div> </motion.div>
</div> </div>
{/* Right panel — form steps */} <div className="min-h-0 overflow-y-auto border-l border-[color:var(--line)] bg-[color:var(--bg-subtle)]">
<div className="flex items-center border-l border-[color:var(--line)] bg-[color:var(--bg-subtle)] px-6 py-10 md:px-10"> <div className="flex min-h-full items-center px-6 py-10 md:px-10">
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
{/* ── Step: Choose Mode ────────────────────────── */}
{step === "choose" ? ( {step === "choose" ? (
<motion.div <motion.div
key="choose" key="choose"
@@ -331,7 +337,7 @@ export function CompanyOnboarding({
<div className="text-[18px] font-semibold tracking-[-0.01em] text-[color:var(--text)]">Choose your setup</div> <div className="text-[18px] font-semibold tracking-[-0.01em] text-[color:var(--text)]">Choose your setup</div>
</div> </div>
{/* Template cards */}
<div className="space-y-2"> <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="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"> <div className="grid grid-cols-1 gap-2">
@@ -378,7 +384,7 @@ export function CompanyOnboarding({
<div className="h-px flex-1 bg-[color:var(--line)]" /> <div className="h-px flex-1 bg-[color:var(--line)]" />
</div> </div>
{/* Custom option */}
<button <button
type="button" type="button"
onClick={() => setStep("company")} onClick={() => setStep("company")}
@@ -395,7 +401,6 @@ export function CompanyOnboarding({
</button> </button>
</motion.div> </motion.div>
) : step === "template" && selectedTemplate ? ( ) : step === "template" && selectedTemplate ? (
/* ── Step: Template Configuration ─────────────── */
<motion.div <motion.div
key="template" key="template"
initial={{ opacity: 0, y: 18 }} initial={{ opacity: 0, y: 18 }}
@@ -421,7 +426,7 @@ export function CompanyOnboarding({
</div> </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="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="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"> <div className="space-y-1.5">
@@ -430,7 +435,7 @@ export function CompanyOnboarding({
<div className={`flex h-6 w-6 items-center justify-center rounded-full text-[8px] font-bold ${ <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.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()} {getAgentInitials(agent.name)}
</div> </div>
<span className="font-medium text-[color:var(--text)]">{agent.name}</span> <span className="font-medium text-[color:var(--text)]">{agent.name}</span>
<span className="text-[color:var(--muted)]">— {agent.title}</span> <span className="text-[color:var(--muted)]">— {agent.title}</span>
@@ -449,7 +454,7 @@ export function CompanyOnboarding({
</div> </div>
</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="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="mb-2 text-[11px] font-semibold uppercase tracking-[0.06em] text-[color:var(--muted)]">What happens next</div>
<div className="space-y-2 text-[12px] text-[color:var(--muted)]"> <div className="space-y-2 text-[12px] text-[color:var(--muted)]">
@@ -492,7 +497,6 @@ export function CompanyOnboarding({
</div> </div>
</motion.div> </motion.div>
) : step === "company" ? ( ) : step === "company" ? (
/* ── Step: Custom Company ─────────────────────── */
<motion.div <motion.div
key="company" key="company"
initial={{ opacity: 0, y: 18 }} initial={{ opacity: 0, y: 18 }}
@@ -568,7 +572,6 @@ export function CompanyOnboarding({
</div> </div>
</motion.div> </motion.div>
) : step === "goal" ? ( ) : step === "goal" ? (
/* ── Step: Goal ───────────────────────────────── */
<motion.div <motion.div
key="goal" key="goal"
initial={{ opacity: 0, y: 18 }} initial={{ opacity: 0, y: 18 }}
@@ -639,7 +642,6 @@ export function CompanyOnboarding({
</div> </div>
</motion.div> </motion.div>
) : ( ) : (
/* ── Step: Launch Success ─────────────────────── */
<motion.div <motion.div
key="launch" key="launch"
initial={{ opacity: 0, scale: 0.95 }} initial={{ opacity: 0, scale: 0.95 }}
@@ -677,6 +679,7 @@ export function CompanyOnboarding({
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>
</div>
</div> </div>
</div> </div>
); );
+118 -156
View File
@@ -17,7 +17,6 @@ import {
X, X,
} from "@phosphor-icons/react" } from "@phosphor-icons/react"
import type { import type {
AgentRecord,
AutomationAction, AutomationAction,
AutomationRuleRecord, AutomationRuleRecord,
AutomationStatus, AutomationStatus,
@@ -27,14 +26,12 @@ import type {
WorkflowStatus, WorkflowStatus,
WorkflowStep, WorkflowStep,
} from "@shared/types" } from "@shared/types"
import { timeAgo } from "../lib/formatters"
import { DEPARTMENT_OPTIONS } from "@shared/constants"
import { ActionButton, Select } from "./ui" import { ActionButton, Select } from "./ui"
/* ------------------------------------------------------------------ */ const ACCENT = "var(--accent)"
/* Constants & Config */ const LINE = "var(--line)"
/* ------------------------------------------------------------------ */
const ACCENT = "#d97745"
const LINE = "rgba(15,23,42,0.08)"
const triggerConfig: Record<AutomationTrigger, { label: string; description: string; icon: typeof Lightning }> = { const triggerConfig: Record<AutomationTrigger, { label: string; description: string; icon: typeof Lightning }> = {
task_status_changed: { label: "Task Status Changed", description: "When a task transitions to a new status", icon: ArrowsClockwise }, task_status_changed: { label: "Task Status Changed", description: "When a task transitions to a new status", icon: ArrowsClockwise },
@@ -70,21 +67,7 @@ const actionConfig: Record<AutomationAction, { label: string; description: strin
run_task: { label: "Run Task", description: "Immediately execute the task with an agent" }, run_task: { label: "Run Task", description: "Immediately execute the task with an agent" },
} }
const departmentOptions: Array<{ value: string; label: string }> = [ const departmentOptions = DEPARTMENT_OPTIONS
{ value: "", label: "Any department" },
{ value: "executive", label: "Executive" },
{ value: "engineering", label: "Engineering" },
{ value: "product", label: "Product" },
{ value: "marketing", label: "Marketing" },
{ value: "sales", label: "Sales" },
{ value: "hr", label: "Human Resources" },
{ value: "finance", label: "Finance" },
{ value: "legal", label: "Legal" },
{ value: "operations", label: "Operations" },
{ value: "customer_support", label: "Customer Support" },
{ value: "research", label: "Research" },
{ value: "design", label: "Design" },
]
const statusColors: Record<AutomationStatus, { bg: string; text: string; dot: string }> = { const statusColors: Record<AutomationStatus, { bg: string; text: string; dot: string }> = {
active: { bg: "var(--success-soft)", text: "var(--success)", dot: "var(--success)" }, active: { bg: "var(--success-soft)", text: "var(--success)", dot: "var(--success)" },
@@ -109,25 +92,6 @@ const stepStatusConfig: Record<WorkflowStep["status"], { bg: string; border: str
skipped: { bg: "var(--panel-soft)", border: "var(--line)", text: "var(--muted)" }, skipped: { bg: "var(--panel-soft)", border: "var(--line)", text: "var(--muted)" },
} }
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
function timeAgo(dateStr: string | null): string {
if (!dateStr) return "never"
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`
}
function safeJsonParse(json: string): unknown { function safeJsonParse(json: string): unknown {
try { try {
return JSON.parse(json) return JSON.parse(json)
@@ -145,10 +109,6 @@ function safeJsonStringify(value: string): string {
} }
} }
/* ------------------------------------------------------------------ */
/* Automation Log Entry Type */
/* ------------------------------------------------------------------ */
interface AutomationLogEntry { interface AutomationLogEntry {
ruleId: string ruleId: string
ruleName: string ruleName: string
@@ -157,10 +117,6 @@ interface AutomationLogEntry {
executedAt: string executedAt: string
} }
/* ------------------------------------------------------------------ */
/* Automation Rule Card */
/* ------------------------------------------------------------------ */
function RuleCard({ function RuleCard({
rule, rule,
onToggle, onToggle,
@@ -187,7 +143,6 @@ function RuleCard({
style={{ borderColor: LINE }} style={{ borderColor: LINE }}
> >
<div className="flex items-center gap-3 px-4 py-3.5"> <div className="flex items-center gap-3 px-4 py-3.5">
{/* Trigger icon */}
<div <div
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-[10px]" className="flex h-10 w-10 shrink-0 items-center justify-center rounded-[10px]"
style={{ backgroundColor: `${ACCENT}12`, color: ACCENT }} style={{ backgroundColor: `${ACCENT}12`, color: ACCENT }}
@@ -195,7 +150,6 @@ function RuleCard({
<TriggerIcon size={20} weight="fill" /> <TriggerIcon size={20} weight="fill" />
</div> </div>
{/* Main info */}
<button <button
type="button" type="button"
onClick={onEdit} onClick={onEdit}
@@ -217,18 +171,15 @@ function RuleCard({
</span> </span>
</div> </div>
<div className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-[color:var(--muted)]"> <div className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-[color:var(--muted)]">
{/* Trigger label */}
<span className="inline-flex items-center gap-1 rounded-[4px] bg-[color:var(--panel-soft)] px-1.5 py-0.5 font-medium text-[color:var(--muted-strong)]"> <span className="inline-flex items-center gap-1 rounded-[4px] bg-[color:var(--panel-soft)] px-1.5 py-0.5 font-medium text-[color:var(--muted-strong)]">
<Lightning size={10} weight="fill" /> <Lightning size={10} weight="fill" />
{triggerInfo?.label ?? rule.trigger} {triggerInfo?.label ?? rule.trigger}
</span> </span>
<ArrowRight size={10} className="text-[color:var(--muted)]" /> <ArrowRight size={10} className="text-[color:var(--muted)]" />
{/* Action label */}
<span className="inline-flex items-center gap-1 rounded-[4px] bg-[color:var(--panel-soft)] px-1.5 py-0.5 font-medium text-[color:var(--muted-strong)]"> <span className="inline-flex items-center gap-1 rounded-[4px] bg-[color:var(--panel-soft)] px-1.5 py-0.5 font-medium text-[color:var(--muted-strong)]">
<Gear size={10} /> <Gear size={10} />
{actionInfo?.label ?? rule.action} {actionInfo?.label ?? rule.action}
</span> </span>
{/* Department flow */}
{rule.sourceDepartment || rule.targetDepartment ? ( {rule.sourceDepartment || rule.targetDepartment ? (
<> <>
<span className="text-[color:var(--line-strong)]">|</span> <span className="text-[color:var(--line-strong)]">|</span>
@@ -244,7 +195,6 @@ function RuleCard({
</> </>
) : null} ) : null}
</div> </div>
{/* Execution stats */}
<div className="mt-1 flex items-center gap-3 text-[10px] text-[color:var(--muted)]"> <div className="mt-1 flex items-center gap-3 text-[10px] text-[color:var(--muted)]">
<span>{rule.executionCount} execution{rule.executionCount !== 1 ? "s" : ""}</span> <span>{rule.executionCount} execution{rule.executionCount !== 1 ? "s" : ""}</span>
<span className="h-0.5 w-0.5 rounded-full bg-[color:var(--muted)]" /> <span className="h-0.5 w-0.5 rounded-full bg-[color:var(--muted)]" />
@@ -254,7 +204,6 @@ function RuleCard({
</div> </div>
</button> </button>
{/* Actions */}
<div className="flex shrink-0 items-center gap-1"> <div className="flex shrink-0 items-center gap-1">
<button <button
type="button" type="button"
@@ -280,10 +229,6 @@ function RuleCard({
) )
} }
/* ------------------------------------------------------------------ */
/* Rule Creation / Edit Modal */
/* ------------------------------------------------------------------ */
interface RuleFormState { interface RuleFormState {
name: string name: string
description: string description: string
@@ -356,7 +301,6 @@ function RuleFormModal({
setError("Rule name is required") setError("Rule name is required")
return return
} }
// Validate JSON fields
try { JSON.parse(form.conditionsJson) } catch { try { JSON.parse(form.conditionsJson) } catch {
setError("Conditions must be valid JSON") setError("Conditions must be valid JSON")
return return
@@ -428,7 +372,6 @@ function RuleFormModal({
style={{ borderColor: LINE, maxHeight: "85vh" }} style={{ borderColor: LINE, maxHeight: "85vh" }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{/* Header */}
<div className="flex shrink-0 items-center justify-between border-b px-6 py-4" style={{ borderColor: LINE }}> <div className="flex shrink-0 items-center justify-between border-b px-6 py-4" style={{ borderColor: LINE }}>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div <div
@@ -451,17 +394,14 @@ function RuleFormModal({
</button> </button>
</div> </div>
{/* Body */}
<div className="overflow-y-auto px-6 py-5"> <div className="overflow-y-auto px-6 py-5">
<div className="space-y-5"> <div className="space-y-5">
{/* Error */}
{error ? ( {error ? (
<div className="rounded-[8px] bg-[color:var(--danger-soft)] px-3.5 py-2.5 text-[12px] font-medium text-[color:var(--danger)]"> <div className="rounded-[8px] bg-[color:var(--danger-soft)] px-3.5 py-2.5 text-[12px] font-medium text-[color:var(--danger)]">
{error} {error}
</div> </div>
) : null} ) : null}
{/* Name */}
<div> <div>
<label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]"> <label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">
Rule Name Rule Name
@@ -476,7 +416,6 @@ function RuleFormModal({
/> />
</div> </div>
{/* Description */}
<div> <div>
<label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]"> <label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">
Description Description
@@ -491,7 +430,6 @@ function RuleFormModal({
/> />
</div> </div>
{/* Trigger */}
<div> <div>
<label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]"> <label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">
Trigger Trigger
@@ -508,7 +446,6 @@ function RuleFormModal({
) : null} ) : null}
</div> </div>
{/* Conditions */}
<div> <div>
<label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]"> <label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">
Conditions (JSON) Conditions (JSON)
@@ -524,7 +461,6 @@ function RuleFormModal({
/> />
</div> </div>
{/* Action */}
<div> <div>
<label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]"> <label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">
Action Action
@@ -541,7 +477,6 @@ function RuleFormModal({
) : null} ) : null}
</div> </div>
{/* Action Config */}
<div> <div>
<label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]"> <label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">
Action Configuration (JSON) Action Configuration (JSON)
@@ -557,7 +492,6 @@ function RuleFormModal({
/> />
</div> </div>
{/* Department selectors */}
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]"> <label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">
@@ -581,7 +515,6 @@ function RuleFormModal({
</div> </div>
</div> </div>
{/* Priority slider */}
<div> <div>
<div className="mb-1.5 flex items-center justify-between"> <div className="mb-1.5 flex items-center justify-between">
<label className="text-[12px] font-medium text-[color:var(--muted-strong)]"> <label className="text-[12px] font-medium text-[color:var(--muted-strong)]">
@@ -607,7 +540,6 @@ function RuleFormModal({
</div> </div>
</div> </div>
{/* Footer */}
<div className="flex shrink-0 justify-end gap-3 border-t px-6 py-4" style={{ borderColor: LINE }}> <div className="flex shrink-0 justify-end gap-3 border-t px-6 py-4" style={{ borderColor: LINE }}>
<button <button
type="button" type="button"
@@ -630,10 +562,6 @@ function RuleFormModal({
) )
} }
/* ------------------------------------------------------------------ */
/* Delete Confirmation Dialog */
/* ------------------------------------------------------------------ */
function DeleteConfirmDialog({ function DeleteConfirmDialog({
open, open,
entityName, entityName,
@@ -693,10 +621,6 @@ function DeleteConfirmDialog({
) )
} }
/* ------------------------------------------------------------------ */
/* Activity Log */
/* ------------------------------------------------------------------ */
function AutomationActivityLog({ entries }: { entries: AutomationLogEntry[] }) { function AutomationActivityLog({ entries }: { entries: AutomationLogEntry[] }) {
if (entries.length === 0) { if (entries.length === 0) {
return ( return (
@@ -743,10 +667,6 @@ function AutomationActivityLog({ entries }: { entries: AutomationLogEntry[] }) {
) )
} }
/* ------------------------------------------------------------------ */
/* AutomationRulesPanel */
/* ------------------------------------------------------------------ */
function AutomationRulesPanel({ function AutomationRulesPanel({
rules, rules,
companyId, companyId,
@@ -763,7 +683,6 @@ function AutomationRulesPanel({
const [activeTab, setActiveTab] = useState<"rules" | "activity">("rules") const [activeTab, setActiveTab] = useState<"rules" | "activity">("rules")
const [filterStatus, setFilterStatus] = useState<AutomationStatus | "all">("all") const [filterStatus, setFilterStatus] = useState<AutomationStatus | "all">("all")
// Fetch activity log
useEffect(() => { useEffect(() => {
void window.agentCompany.getAutomationLog(companyId).then((result) => { void window.agentCompany.getAutomationLog(companyId).then((result) => {
if (result.ok) setActivityLog(result.data) if (result.ok) setActivityLog(result.data)
@@ -782,29 +701,62 @@ function AutomationRulesPanel({
error: rules.filter((r) => r.status === "error").length, error: rules.filter((r) => r.status === "error").length,
}), [rules]) }), [rules])
const [actionError, setActionError] = useState<string | null>(null)
const handleToggle = useCallback(async (rule: AutomationRuleRecord) => { const handleToggle = useCallback(async (rule: AutomationRuleRecord) => {
setActionError(null)
const newStatus = rule.status === "active" ? "paused" : "active" const newStatus = rule.status === "active" ? "paused" : "active"
await window.agentCompany.toggleAutomationRule({ try {
id: rule.id, const result = await window.agentCompany.toggleAutomationRule({
companyId, id: rule.id,
status: newStatus as "active" | "paused", companyId,
}) status: newStatus as "active" | "paused",
onRefresh() })
if (!result.ok) {
setActionError(result.error.message)
return
}
onRefresh()
} catch (err) {
setActionError(err instanceof Error ? err.message : "Failed to toggle rule")
}
}, [companyId, onRefresh]) }, [companyId, onRefresh])
const handleDelete = useCallback(async () => { const handleDelete = useCallback(async () => {
if (!deleteTarget) return if (!deleteTarget) return
await window.agentCompany.deleteAutomationRule({ setActionError(null)
id: deleteTarget.id, try {
companyId, const result = await window.agentCompany.deleteAutomationRule({
}) id: deleteTarget.id,
setDeleteTarget(null) companyId,
onRefresh() })
if (!result.ok) {
setActionError(result.error.message)
setDeleteTarget(null)
return
}
setDeleteTarget(null)
onRefresh()
} catch (err) {
setActionError(err instanceof Error ? err.message : "Failed to delete rule")
setDeleteTarget(null)
}
}, [deleteTarget, companyId, onRefresh]) }, [deleteTarget, companyId, onRefresh])
return ( return (
<div className="space-y-5"> <div className="space-y-5">
{/* Header */} {actionError ? (
<div className="flex items-center justify-between rounded-[8px] bg-[color:var(--danger-soft)] px-3.5 py-2.5 text-[12px] font-medium text-[color:var(--danger)]">
<span>{actionError}</span>
<button
type="button"
onClick={() => setActionError(null)}
className="ml-2 rounded p-0.5 hover:bg-[color:var(--danger)] hover:text-white"
>
<X size={12} />
</button>
</div>
) : null}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div <div
@@ -828,7 +780,6 @@ function AutomationRulesPanel({
/> />
</div> </div>
{/* Stats bar */}
{rules.length > 0 ? ( {rules.length > 0 ? (
<div className="grid grid-cols-3 gap-2"> <div className="grid grid-cols-3 gap-2">
<div className="rounded-[10px] border bg-[color:var(--panel)] px-4 py-3" style={{ borderColor: LINE }}> <div className="rounded-[10px] border bg-[color:var(--panel)] px-4 py-3" style={{ borderColor: LINE }}>
@@ -850,7 +801,6 @@ function AutomationRulesPanel({
</div> </div>
) : null} ) : null}
{/* Tabs */}
<div className="flex gap-1 rounded-[8px] bg-[color:var(--panel-soft)] p-1"> <div className="flex gap-1 rounded-[8px] bg-[color:var(--panel-soft)] p-1">
<button <button
type="button" type="button"
@@ -876,10 +826,8 @@ function AutomationRulesPanel({
</button> </button>
</div> </div>
{/* Rules content */}
{activeTab === "rules" ? ( {activeTab === "rules" ? (
<> <>
{/* Filter */}
{rules.length > 0 ? ( {rules.length > 0 ? (
<div className="flex flex-wrap items-center gap-1.5"> <div className="flex flex-wrap items-center gap-1.5">
{(["all", "active", "paused", "error"] as const).map((status) => ( {(["all", "active", "paused", "error"] as const).map((status) => (
@@ -900,7 +848,6 @@ function AutomationRulesPanel({
</div> </div>
) : null} ) : null}
{/* Rule list */}
{filteredRules.length === 0 && rules.length === 0 ? ( {filteredRules.length === 0 && rules.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-[10px] border border-dashed py-16 text-center" style={{ borderColor: LINE }}> <div className="flex flex-col items-center justify-center rounded-[10px] border border-dashed py-16 text-center" style={{ borderColor: LINE }}>
<Lightning size={36} className="mb-3 text-[color:var(--muted)]" /> <Lightning size={36} className="mb-3 text-[color:var(--muted)]" />
@@ -941,7 +888,6 @@ function AutomationRulesPanel({
<AutomationActivityLog entries={activityLog} /> <AutomationActivityLog entries={activityLog} />
)} )}
{/* Create / Edit modal */}
<AnimatePresence> <AnimatePresence>
{showCreateModal ? ( {showCreateModal ? (
<RuleFormModal <RuleFormModal
@@ -963,7 +909,6 @@ function AutomationRulesPanel({
) : null} ) : null}
</AnimatePresence> </AnimatePresence>
{/* Delete confirmation */}
<AnimatePresence> <AnimatePresence>
{deleteTarget ? ( {deleteTarget ? (
<DeleteConfirmDialog <DeleteConfirmDialog
@@ -978,10 +923,6 @@ function AutomationRulesPanel({
) )
} }
/* ------------------------------------------------------------------ */
/* Workflow Step Timeline */
/* ------------------------------------------------------------------ */
function StepTimeline({ steps }: { steps: WorkflowStep[] }) { function StepTimeline({ steps }: { steps: WorkflowStep[] }) {
if (steps.length === 0) { if (steps.length === 0) {
return ( return (
@@ -993,7 +934,6 @@ function StepTimeline({ steps }: { steps: WorkflowStep[] }) {
return ( return (
<div className="relative pl-5"> <div className="relative pl-5">
{/* Vertical line */}
<div <div
className="absolute left-[9px] top-2 w-px" className="absolute left-[9px] top-2 w-px"
style={{ style={{
@@ -1009,7 +949,6 @@ function StepTimeline({ steps }: { steps: WorkflowStep[] }) {
return ( return (
<div key={step.id} className="relative flex items-start gap-3"> <div key={step.id} className="relative flex items-start gap-3">
{/* Dot */}
<div <div
className="absolute -left-5 top-1.5 z-10 h-[10px] w-[10px] rounded-full border-2" className="absolute -left-5 top-1.5 z-10 h-[10px] w-[10px] rounded-full border-2"
style={{ style={{
@@ -1018,7 +957,6 @@ function StepTimeline({ steps }: { steps: WorkflowStep[] }) {
}} }}
/> />
{/* Content */}
<div <div
className="flex-1 rounded-[8px] border px-3 py-2.5" className="flex-1 rounded-[8px] border px-3 py-2.5"
style={{ borderColor: step.status === "running" ? ACCENT : LINE }} style={{ borderColor: step.status === "running" ? ACCENT : LINE }}
@@ -1069,18 +1007,18 @@ function StepTimeline({ steps }: { steps: WorkflowStep[] }) {
) )
} }
/* ------------------------------------------------------------------ */
/* Workflow Card */
/* ------------------------------------------------------------------ */
function WorkflowCard({ function WorkflowCard({
workflow, workflow,
isRunning,
onDelete, onDelete,
onEdit, onEdit,
onRun,
}: { }: {
workflow: WorkflowPipelineRecord workflow: WorkflowPipelineRecord
isRunning: boolean
onDelete: () => void onDelete: () => void
onEdit: () => void onEdit: () => void
onRun: () => void
}) { }) {
const [expanded, setExpanded] = useState(false) const [expanded, setExpanded] = useState(false)
const colors = workflowStatusColors[workflow.status] const colors = workflowStatusColors[workflow.status]
@@ -1102,7 +1040,6 @@ function WorkflowCard({
style={{ borderColor: LINE }} style={{ borderColor: LINE }}
> >
<div className="flex items-center gap-3 px-4 py-3.5"> <div className="flex items-center gap-3 px-4 py-3.5">
{/* Icon */}
<div <div
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-[10px]" className="flex h-10 w-10 shrink-0 items-center justify-center rounded-[10px]"
style={{ backgroundColor: `${ACCENT}12`, color: ACCENT }} style={{ backgroundColor: `${ACCENT}12`, color: ACCENT }}
@@ -1110,7 +1047,6 @@ function WorkflowCard({
<TriggerIcon size={20} weight="fill" /> <TriggerIcon size={20} weight="fill" />
</div> </div>
{/* Main info */}
<button <button
type="button" type="button"
onClick={() => setExpanded((prev) => !prev)} onClick={() => setExpanded((prev) => !prev)}
@@ -1149,7 +1085,6 @@ function WorkflowCard({
</div> </div>
</button> </button>
{/* Actions */}
<div className="flex shrink-0 items-center gap-1"> <div className="flex shrink-0 items-center gap-1">
<motion.div <motion.div
animate={{ rotate: expanded ? 90 : 0 }} animate={{ rotate: expanded ? 90 : 0 }}
@@ -1164,6 +1099,16 @@ function WorkflowCard({
<CaretRight size={14} /> <CaretRight size={14} />
</button> </button>
</motion.div> </motion.div>
<button
type="button"
onClick={onRun}
aria-label="Run workflow"
title="Run workflow"
disabled={isRunning}
className="rounded-[6px] p-2 text-[color:var(--muted)] transition hover:bg-[color:var(--success-soft)] hover:text-[color:var(--success)] disabled:pointer-events-none disabled:opacity-50"
>
{isRunning ? <ArrowsClockwise size={15} className="animate-spin" /> : <Play size={15} />}
</button>
<button <button
type="button" type="button"
onClick={onEdit} onClick={onEdit}
@@ -1185,7 +1130,6 @@ function WorkflowCard({
</div> </div>
</div> </div>
{/* Expanded: step timeline */}
<AnimatePresence> <AnimatePresence>
{expanded ? ( {expanded ? (
<motion.div <motion.div
@@ -1210,10 +1154,6 @@ function WorkflowCard({
) )
} }
/* ------------------------------------------------------------------ */
/* Workflow Creation Modal */
/* ------------------------------------------------------------------ */
interface WorkflowFormState { interface WorkflowFormState {
name: string name: string
description: string description: string
@@ -1342,7 +1282,6 @@ function WorkflowFormModal({
style={{ borderColor: LINE, maxHeight: "85vh" }} style={{ borderColor: LINE, maxHeight: "85vh" }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{/* Header */}
<div className="flex shrink-0 items-center justify-between border-b px-6 py-4" style={{ borderColor: LINE }}> <div className="flex shrink-0 items-center justify-between border-b px-6 py-4" style={{ borderColor: LINE }}>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div <div
@@ -1365,7 +1304,6 @@ function WorkflowFormModal({
</button> </button>
</div> </div>
{/* Body */}
<div className="overflow-y-auto px-6 py-5"> <div className="overflow-y-auto px-6 py-5">
<div className="space-y-5"> <div className="space-y-5">
{error ? ( {error ? (
@@ -1374,7 +1312,6 @@ function WorkflowFormModal({
</div> </div>
) : null} ) : null}
{/* Name */}
<div> <div>
<label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]"> <label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">
Workflow Name Workflow Name
@@ -1389,7 +1326,6 @@ function WorkflowFormModal({
/> />
</div> </div>
{/* Description */}
<div> <div>
<label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]"> <label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">
Description Description
@@ -1404,7 +1340,6 @@ function WorkflowFormModal({
/> />
</div> </div>
{/* Trigger + Status */}
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]"> <label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">
@@ -1428,7 +1363,6 @@ function WorkflowFormModal({
</div> </div>
</div> </div>
{/* Trigger Config */}
<div> <div>
<label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]"> <label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">
Trigger Configuration (JSON) Trigger Configuration (JSON)
@@ -1444,7 +1378,6 @@ function WorkflowFormModal({
/> />
</div> </div>
{/* Steps */}
<div> <div>
<label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]"> <label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">
Pipeline Steps (JSON Array) Pipeline Steps (JSON Array)
@@ -1474,7 +1407,6 @@ function WorkflowFormModal({
</div> </div>
</div> </div>
{/* Footer */}
<div className="flex shrink-0 justify-end gap-3 border-t px-6 py-4" style={{ borderColor: LINE }}> <div className="flex shrink-0 justify-end gap-3 border-t px-6 py-4" style={{ borderColor: LINE }}>
<button <button
type="button" type="button"
@@ -1497,10 +1429,6 @@ function WorkflowFormModal({
) )
} }
/* ------------------------------------------------------------------ */
/* WorkflowsPanel */
/* ------------------------------------------------------------------ */
function WorkflowsPanel({ function WorkflowsPanel({
workflows, workflows,
companyId, companyId,
@@ -1513,17 +1441,50 @@ function WorkflowsPanel({
const [showCreateModal, setShowCreateModal] = useState(false) const [showCreateModal, setShowCreateModal] = useState(false)
const [editingWorkflow, setEditingWorkflow] = useState<WorkflowPipelineRecord | null>(null) const [editingWorkflow, setEditingWorkflow] = useState<WorkflowPipelineRecord | null>(null)
const [deleteTarget, setDeleteTarget] = useState<WorkflowPipelineRecord | null>(null) const [deleteTarget, setDeleteTarget] = useState<WorkflowPipelineRecord | null>(null)
const [actionError, setActionError] = useState<string | null>(null)
const [runningId, setRunningId] = useState<string | null>(null)
const handleDelete = useCallback(async () => { const handleDelete = useCallback(async () => {
if (!deleteTarget) return if (!deleteTarget) return
await window.agentCompany.deleteWorkflow({ setActionError(null)
id: deleteTarget.id, try {
companyId, const result = await window.agentCompany.deleteWorkflow({
}) id: deleteTarget.id,
setDeleteTarget(null) companyId,
onRefresh() })
if (!result.ok) {
setActionError(result.error.message)
setDeleteTarget(null)
return
}
setDeleteTarget(null)
onRefresh()
} catch (err) {
setActionError(err instanceof Error ? err.message : "Failed to delete workflow")
setDeleteTarget(null)
}
}, [deleteTarget, companyId, onRefresh]) }, [deleteTarget, companyId, onRefresh])
const handleRun = useCallback(async (workflow: WorkflowPipelineRecord) => {
setActionError(null)
setRunningId(workflow.id)
try {
const result = await window.agentCompany.runWorkflow({
id: workflow.id,
companyId,
})
if (!result.ok) {
setActionError(result.error.message)
return
}
onRefresh()
} catch (err) {
setActionError(err instanceof Error ? err.message : "Failed to run workflow")
} finally {
setRunningId(null)
}
}, [companyId, onRefresh])
const counts = useMemo(() => ({ const counts = useMemo(() => ({
total: workflows.length, total: workflows.length,
active: workflows.filter((w) => w.status === "active").length, active: workflows.filter((w) => w.status === "active").length,
@@ -1532,7 +1493,18 @@ function WorkflowsPanel({
return ( return (
<div className="space-y-5"> <div className="space-y-5">
{/* Header */} {actionError ? (
<div className="flex items-center justify-between rounded-[8px] bg-[color:var(--danger-soft)] px-3.5 py-2.5 text-[12px] font-medium text-[color:var(--danger)]">
<span>{actionError}</span>
<button
type="button"
onClick={() => setActionError(null)}
className="ml-2 rounded p-0.5 hover:bg-[color:var(--danger)] hover:text-white"
>
<X size={12} />
</button>
</div>
) : null}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div <div
@@ -1556,7 +1528,6 @@ function WorkflowsPanel({
/> />
</div> </div>
{/* Workflow list */}
{workflows.length === 0 ? ( {workflows.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-[10px] border border-dashed py-16 text-center" style={{ borderColor: LINE }}> <div className="flex flex-col items-center justify-center rounded-[10px] border border-dashed py-16 text-center" style={{ borderColor: LINE }}>
<ArrowsClockwise size={36} className="mb-3 text-[color:var(--muted)]" /> <ArrowsClockwise size={36} className="mb-3 text-[color:var(--muted)]" />
@@ -1580,15 +1551,16 @@ function WorkflowsPanel({
<WorkflowCard <WorkflowCard
key={wf.id} key={wf.id}
workflow={wf} workflow={wf}
isRunning={runningId === wf.id}
onDelete={() => setDeleteTarget(wf)} onDelete={() => setDeleteTarget(wf)}
onEdit={() => setEditingWorkflow(wf)} onEdit={() => setEditingWorkflow(wf)}
onRun={() => void handleRun(wf)}
/> />
))} ))}
</AnimatePresence> </AnimatePresence>
</div> </div>
)} )}
{/* Create / Edit modal */}
<AnimatePresence> <AnimatePresence>
{showCreateModal ? ( {showCreateModal ? (
<WorkflowFormModal <WorkflowFormModal
@@ -1610,7 +1582,6 @@ function WorkflowsPanel({
) : null} ) : null}
</AnimatePresence> </AnimatePresence>
{/* Delete confirmation */}
<AnimatePresence> <AnimatePresence>
{deleteTarget ? ( {deleteTarget ? (
<DeleteConfirmDialog <DeleteConfirmDialog
@@ -1625,14 +1596,9 @@ function WorkflowsPanel({
) )
} }
/* ------------------------------------------------------------------ */
/* Main Automation Panel (exported) */
/* ------------------------------------------------------------------ */
interface AutomationPanelProps { interface AutomationPanelProps {
rules: AutomationRuleRecord[] rules: AutomationRuleRecord[]
workflows: WorkflowPipelineRecord[] workflows: WorkflowPipelineRecord[]
agents: AgentRecord[]
companyId: string companyId: string
onRefresh: () => void onRefresh: () => void
} }
@@ -1640,7 +1606,6 @@ interface AutomationPanelProps {
export function AutomationPanel({ export function AutomationPanel({
rules, rules,
workflows, workflows,
agents,
companyId, companyId,
onRefresh, onRefresh,
}: AutomationPanelProps) { }: AutomationPanelProps) {
@@ -1648,7 +1613,6 @@ export function AutomationPanel({
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Top banner */}
<div <div
className="rounded-[12px] border px-5 py-4" className="rounded-[12px] border px-5 py-4"
style={{ style={{
@@ -1674,7 +1638,6 @@ export function AutomationPanel({
</div> </div>
</div> </div>
{/* Section tabs */}
<div className="flex gap-1 rounded-[10px] bg-[color:var(--panel-soft)] p-1"> <div className="flex gap-1 rounded-[10px] bg-[color:var(--panel-soft)] p-1">
<button <button
type="button" type="button"
@@ -1702,7 +1665,6 @@ export function AutomationPanel({
</button> </button>
</div> </div>
{/* Content */}
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
<motion.div <motion.div
key={activeSection} key={activeSection}
+316 -204
View File
@@ -5,7 +5,6 @@ import {
EnvelopeSimple, EnvelopeSimple,
At, At,
Hash, Hash,
UserCircle,
PaperPlaneTilt, PaperPlaneTilt,
MagnifyingGlass, MagnifyingGlass,
Funnel, Funnel,
@@ -19,21 +18,20 @@ import {
Plus, Plus,
X, X,
CaretDown, CaretDown,
Robot,
Tray, Tray,
} from "@phosphor-icons/react" } from "@phosphor-icons/react"
import type { import type {
AgentMessageRecord, AgentMessageRecord,
AgentRecord, AgentRecord,
CommentRecord,
DepartmentId, DepartmentId,
MessageChannel, MessageChannel,
MessagePriority, MessagePriority,
TaskRecord,
} from "@shared/types" } from "@shared/types"
import { ActionButton, Modal, Select, Input, TextArea } from "./ui" import { getAgentInitials, formatMessageTime } from "../lib/formatters"
import { DEPARTMENT_COLORS } from "@shared/constants"
/* ------------------------------------------------------------------ */ import { Modal, Select, Input, TextArea } from "./ui"
/* Constants & Config */
/* ------------------------------------------------------------------ */
const TERRACOTTA = "#d97745" const TERRACOTTA = "#d97745"
const TERRACOTTA_SOFT = "#d9774518" const TERRACOTTA_SOFT = "#d9774518"
@@ -52,83 +50,14 @@ const priorityConfig: Record<MessagePriority, { label: string; color: string; bg
low: { label: "Low", color: "var(--muted)", bg: "var(--panel-soft)" }, low: { label: "Low", color: "var(--muted)", bg: "var(--panel-soft)" },
} }
const departmentColors: Record<string, string> = {
executive: "#8b5cf6",
engineering: "#3b82f6",
product: "#f59e0b",
marketing: "#ec4899",
sales: "#10b981",
hr: "#6366f1",
finance: "#14b8a6",
legal: "#a78bfa",
operations: "#f97316",
customer_support: "#06b6d4",
research: "#8b5cf6",
design: "#f43f5e",
}
function getDepartmentColor(department: DepartmentId | null | undefined): string { function getDepartmentColor(department: DepartmentId | null | undefined): string {
if (!department) return "var(--muted)" if (!department) return "var(--muted)"
return departmentColors[department] ?? "var(--muted)" return DEPARTMENT_COLORS[department] ?? "var(--muted)"
} }
function getAgentInitials(name: string): string {
return name
.split(/\s+/)
.slice(0, 2)
.map((w) => w[0])
.join("")
.toUpperCase()
}
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`
}
function formatMessageTime(dateStr: string): string {
const d = new Date(dateStr)
const now = new Date()
const isToday =
d.getDate() === now.getDate() &&
d.getMonth() === now.getMonth() &&
d.getFullYear() === now.getFullYear()
if (isToday) {
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
}
const yesterday = new Date(now)
yesterday.setDate(yesterday.getDate() - 1)
const isYesterday =
d.getDate() === yesterday.getDate() &&
d.getMonth() === yesterday.getMonth() &&
d.getFullYear() === yesterday.getFullYear()
if (isYesterday) {
return `Yesterday ${d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`
}
return d.toLocaleDateString([], { month: "short", day: "numeric" }) +
" " +
d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
}
/* ------------------------------------------------------------------ */
/* Channel Derivation */
/* ------------------------------------------------------------------ */
interface ChannelDescriptor { interface ChannelDescriptor {
key: string key: string
kind: "message"
channel: MessageChannel channel: MessageChannel
label: string label: string
targetId: string | null targetId: string | null
@@ -136,6 +65,13 @@ interface ChannelDescriptor {
agentId: string | null agentId: string | null
} }
interface TaskDiscussionDescriptor {
key: string
kind: "task"
taskId: string
label: string
}
function deriveChannels( function deriveChannels(
messages: AgentMessageRecord[], messages: AgentMessageRecord[],
agents: AgentRecord[], agents: AgentRecord[],
@@ -143,6 +79,37 @@ function deriveChannels(
const channelMap = new Map<string, ChannelDescriptor>() const channelMap = new Map<string, ChannelDescriptor>()
const agentMap = new Map(agents.map((a) => [a.id, a])) const agentMap = new Map(agents.map((a) => [a.id, a]))
// Always create company-wide channel
channelMap.set("company-all", {
key: "company-all",
kind: "message",
channel: "company",
label: "#company-wide",
targetId: null,
agentId: null,
})
// Create permanent department channels for all departments that have agents
const activeDepts = new Set<string>()
for (const agent of agents) {
if (agent.department && agent.status !== "terminated") {
activeDepts.add(agent.department)
}
}
for (const dept of activeDepts) {
const key = `department-${dept}`
const deptLabel = dept.replace(/_/g, "-")
channelMap.set(key, {
key,
kind: "message",
channel: "department",
label: `#${deptLabel}`,
targetId: dept,
agentId: null,
})
}
// Add channels derived from existing messages (DMs, projects, incidents)
for (const msg of messages) { for (const msg of messages) {
let key: string let key: string
let label: string let label: string
@@ -166,7 +133,7 @@ function deriveChannels(
case "department": { case "department": {
const dept = msg.channelTargetId ?? "general" const dept = msg.channelTargetId ?? "general"
key = `department-${dept}` key = `department-${dept}`
label = `#${dept.replace("_", "-")}` label = `#${dept.replace(/_/g, "-")}`
break break
} }
case "company": { case "company": {
@@ -191,6 +158,7 @@ function deriveChannels(
if (!channelMap.has(key)) { if (!channelMap.has(key)) {
channelMap.set(key, { channelMap.set(key, {
key, key,
kind: "message",
channel: msg.channel, channel: msg.channel,
label, label,
targetId: msg.channelTargetId, targetId: msg.channelTargetId,
@@ -199,17 +167,6 @@ function deriveChannels(
} }
} }
// Ensure at least one company-wide channel exists
if (!channelMap.has("company-all")) {
channelMap.set("company-all", {
key: "company-all",
channel: "company",
label: "#company-wide",
targetId: null,
agentId: null,
})
}
const order: MessageChannel[] = ["company", "department", "project", "incident", "direct"] const order: MessageChannel[] = ["company", "department", "project", "incident", "direct"]
return Array.from(channelMap.values()).sort((a, b) => { return Array.from(channelMap.values()).sort((a, b) => {
const ai = order.indexOf(a.channel) const ai = order.indexOf(a.channel)
@@ -219,6 +176,25 @@ function deriveChannels(
}) })
} }
function deriveTaskDiscussionChannels(
comments: CommentRecord[],
tasks: TaskRecord[],
): TaskDiscussionDescriptor[] {
const taskMap = new Map(tasks.map((task) => [task.id, task]))
const discussedTaskIds = new Set(comments.map((comment) => comment.taskId))
return Array.from(discussedTaskIds)
.map((taskId) => {
const task = taskMap.get(taskId)
return {
key: `task-${taskId}`,
kind: "task" as const,
taskId,
label: task?.title ?? `Task ${taskId.slice(0, 8)}`,
}
})
.sort((a, b) => a.label.localeCompare(b.label))
}
function getChannelMessages( function getChannelMessages(
messages: AgentMessageRecord[], messages: AgentMessageRecord[],
channel: ChannelDescriptor, channel: ChannelDescriptor,
@@ -244,10 +220,6 @@ function getChannelMessages(
}) })
} }
/* ------------------------------------------------------------------ */
/* Agent Avatar */
/* ------------------------------------------------------------------ */
function AgentAvatar({ function AgentAvatar({
agent, agent,
size = "md", size = "md",
@@ -269,10 +241,6 @@ function AgentAvatar({
) )
} }
/* ------------------------------------------------------------------ */
/* Channel Sidebar Item */
/* ------------------------------------------------------------------ */
function ChannelItem({ function ChannelItem({
descriptor, descriptor,
active, active,
@@ -346,36 +314,23 @@ function ChannelItem({
) )
} }
/* ------------------------------------------------------------------ */
/* Message Bubble */
/* ------------------------------------------------------------------ */
function MessageBubble({ function MessageBubble({
message, message,
agent, agent,
isThread, isThread,
replyCount, replyCount,
onReply, onReply,
onMarkRead,
}: { }: {
message: AgentMessageRecord message: AgentMessageRecord
agent: AgentRecord | undefined agent: AgentRecord | undefined
isThread: boolean isThread: boolean
replyCount: number replyCount: number
onReply: () => void onReply: () => void
onMarkRead: () => void
}) { }) {
const isUnread = !message.readAt const isUnread = !message.readAt
const isUrgent = message.priority === "urgent" const isUrgent = message.priority === "urgent"
const deptColor = agent ? getDepartmentColor(agent.department) : "var(--muted)" const deptColor = agent ? getDepartmentColor(agent.department) : "var(--muted)"
useEffect(() => {
if (isUnread) {
const timer = setTimeout(onMarkRead, 1500)
return () => clearTimeout(timer)
}
}, [isUnread, onMarkRead])
return ( return (
<motion.div <motion.div
initial={{ opacity: 0, y: 6 }} initial={{ opacity: 0, y: 6 }}
@@ -466,14 +421,12 @@ function MessageBubble({
)} )}
</div> </div>
{/* Subject */}
{message.subject ? ( {message.subject ? (
<div className="mt-0.5 text-[13px] font-medium text-[color:var(--text)]"> <div className="mt-0.5 text-[13px] font-medium text-[color:var(--text)]">
{message.subject} {message.subject}
</div> </div>
) : null} ) : null}
{/* Body */}
<div className="mt-1 whitespace-pre-wrap text-[12px] leading-relaxed text-[color:var(--muted-strong)]"> <div className="mt-1 whitespace-pre-wrap text-[12px] leading-relaxed text-[color:var(--muted-strong)]">
{message.body} {message.body}
</div> </div>
@@ -510,9 +463,102 @@ function MessageBubble({
) )
} }
/* ------------------------------------------------------------------ */ function TaskCommentBubble({
/* Attachment List */ comment,
/* ------------------------------------------------------------------ */ agent,
}: {
comment: CommentRecord
agent: AgentRecord | undefined
}) {
const isBoard = !comment.authorAgentId
const deptColor = agent ? getDepartmentColor(agent.department) : "var(--muted)"
return (
<motion.div
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.18 }}
className="group relative flex gap-3 rounded-[10px] border-l-[3px] bg-[color:var(--panel)] px-4 py-3"
style={{ borderLeftColor: isBoard ? TERRACOTTA : deptColor }}
>
<AgentAvatar agent={agent} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-[13px] font-semibold" style={{ color: isBoard ? TERRACOTTA : deptColor }}>
{comment.authorName}
</span>
{agent?.title ? <span className="text-[10px] text-[color:var(--muted)]">{agent.title}</span> : null}
<span className="text-[10px] text-[color:var(--muted)]">{formatMessageTime(comment.createdAt)}</span>
</div>
<div className="mt-1 whitespace-pre-wrap text-[12px] leading-relaxed text-[color:var(--muted-strong)]">
{comment.body}
</div>
</div>
</motion.div>
)
}
function TaskDiscussionComposer({
companyId,
taskId,
onSent,
}: {
companyId: string
taskId: string
onSent: () => void
}) {
const [body, setBody] = useState("")
const [sending, setSending] = useState(false)
const handleSend = useCallback(async () => {
if (!body.trim()) return
setSending(true)
try {
await window.agentCompany.addComment({
companyId,
taskId,
authorName: "Board",
body: body.trim(),
})
setBody("")
onSent()
} finally {
setSending(false)
}
}, [body, companyId, taskId, onSent])
return (
<div className="border-t border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-3">
<div className="flex items-end gap-2">
<textarea
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder="Reply in task discussion..."
rows={2}
className="min-h-[40px] min-w-0 flex-1 resize-none rounded-[8px] border border-[color:var(--line)] bg-[color:var(--panel-soft)] px-3 py-2 text-[12px] text-[color:var(--text)] placeholder:text-[color:var(--muted)] focus:border-transparent focus:outline-none focus:ring-2"
style={{ focusRingColor: TERRACOTTA } as React.CSSProperties}
/>
<button
type="button"
onClick={() => void handleSend()}
disabled={!body.trim() || sending}
className="flex h-[40px] w-[40px] shrink-0 items-center justify-center rounded-[8px] text-white transition hover:brightness-90 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none"
style={{ backgroundColor: TERRACOTTA }}
aria-label="Send task comment"
>
{sending ? (
<svg className="h-4 w-4 animate-spin" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="2" opacity="0.3" />
<path d="M14 8a6 6 0 0 0-6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
) : (
<PaperPlaneTilt size={18} weight="fill" />
)}
</button>
</div>
</div>
)
}
function AttachmentList({ json }: { json: string }) { function AttachmentList({ json }: { json: string }) {
let items: Array<{ type: string; id: string; title: string }> = [] let items: Array<{ type: string; id: string; title: string }> = []
@@ -538,10 +584,6 @@ function AttachmentList({ json }: { json: string }) {
) )
} }
/* ------------------------------------------------------------------ */
/* Message Composer */
/* ------------------------------------------------------------------ */
interface MessageComposerProps { interface MessageComposerProps {
agents: AgentRecord[] agents: AgentRecord[]
companyId: string companyId: string
@@ -595,7 +637,7 @@ function MessageComposer({
setSubject("") setSubject("")
onSent() onSent()
} catch (err) { } catch (err) {
console.error("[MessageComposer] send failed:", err) void err
} finally { } finally {
setSending(false) setSending(false)
} }
@@ -615,7 +657,6 @@ function MessageComposer({
return ( return (
<div className="border-t border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-3"> <div className="border-t border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-3">
{/* Reply indicator */}
<AnimatePresence> <AnimatePresence>
{replyTo ? ( {replyTo ? (
<motion.div <motion.div
@@ -640,7 +681,6 @@ function MessageComposer({
) : null} ) : null}
</AnimatePresence> </AnimatePresence>
{/* Sender selector row */}
<div className="mb-2 flex items-center gap-2"> <div className="mb-2 flex items-center gap-2">
<AgentAvatar agent={fromAgent} size="sm" /> <AgentAvatar agent={fromAgent} size="sm" />
<select <select
@@ -665,7 +705,6 @@ function MessageComposer({
) : null} ) : null}
</div> </div>
{/* Body input */}
<div className="flex items-end gap-2"> <div className="flex items-end gap-2">
<textarea <textarea
ref={textareaRef} ref={textareaRef}
@@ -702,10 +741,6 @@ function MessageComposer({
) )
} }
/* ------------------------------------------------------------------ */
/* Compose New Message Modal */
/* ------------------------------------------------------------------ */
interface ComposeModalProps { interface ComposeModalProps {
open: boolean open: boolean
onClose: () => void onClose: () => void
@@ -748,14 +783,13 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal
parentMessageId: null, parentMessageId: null,
attachmentsJson: "[]", attachmentsJson: "[]",
}) })
// Reset form
setSubject("") setSubject("")
setBody("") setBody("")
setPriority("normal") setPriority("normal")
onSent() onSent()
onClose() onClose()
} catch (err) { } catch (err) {
console.error("[ComposeModal] send failed:", err) void err
} finally { } finally {
setSending(false) setSending(false)
} }
@@ -764,7 +798,6 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal
return ( return (
<Modal open={open} title="Compose Message" onClose={onClose} width="560px"> <Modal open={open} title="Compose Message" onClose={onClose} width="560px">
<div className="space-y-4"> <div className="space-y-4">
{/* From */}
<Select <Select
label="From Agent" label="From Agent"
value={fromAgentId} value={fromAgentId}
@@ -775,7 +808,6 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal
}))} }))}
/> />
{/* Channel type */}
<Select <Select
label="Channel" label="Channel"
value={channel} value={channel}
@@ -789,7 +821,6 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal
]} ]}
/> />
{/* Channel target */}
{channel === "direct" ? ( {channel === "direct" ? (
<Select <Select
label="To Agent" label="To Agent"
@@ -827,7 +858,6 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal
/> />
) : null} ) : null}
{/* Subject */}
<Input <Input
label="Subject" label="Subject"
value={subject} value={subject}
@@ -835,7 +865,6 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal
placeholder="Message subject" placeholder="Message subject"
/> />
{/* Body */}
<TextArea <TextArea
label="Message" label="Message"
value={body} value={body}
@@ -844,7 +873,6 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal
rows={5} rows={5}
/> />
{/* Priority */}
<Select <Select
label="Priority" label="Priority"
value={priority} value={priority}
@@ -856,7 +884,6 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal
]} ]}
/> />
{/* Actions */}
<div className="flex justify-end gap-3 pt-2"> <div className="flex justify-end gap-3 pt-2">
<button <button
type="button" type="button"
@@ -888,10 +915,6 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal
) )
} }
/* ------------------------------------------------------------------ */
/* Search & Filter Bar */
/* ------------------------------------------------------------------ */
interface FilterState { interface FilterState {
search: string search: string
channelType: MessageChannel | "all" channelType: MessageChannel | "all"
@@ -1030,27 +1053,29 @@ function FilterBar({
) )
} }
/* ------------------------------------------------------------------ */
/* Main: CommunicationHub */
/* ------------------------------------------------------------------ */
interface CommunicationHubProps { interface CommunicationHubProps {
messages: AgentMessageRecord[] messages: AgentMessageRecord[]
comments: CommentRecord[]
tasks: TaskRecord[]
agents: AgentRecord[] agents: AgentRecord[]
companyId: string companyId: string
selectedEntityId?: string | null
onRefresh: () => void onRefresh: () => void
} }
export function CommunicationHub({ export function CommunicationHub({
messages, messages,
comments,
tasks,
agents, agents,
companyId, companyId,
selectedEntityId,
onRefresh, onRefresh,
}: CommunicationHubProps) { }: CommunicationHubProps) {
const [selectedChannelKey, setSelectedChannelKey] = useState<string | null>(null) const [selectedChannelKey, setSelectedChannelKey] = useState<string | null>(null)
const [replyTo, setReplyTo] = useState<AgentMessageRecord | null>(null)
const [showCompose, setShowCompose] = useState(false)
const [showSidebar, setShowSidebar] = useState(true) const [showSidebar, setShowSidebar] = useState(true)
const [showCompose, setShowCompose] = useState(false)
const [replyTo, setReplyTo] = useState<AgentMessageRecord | null>(null)
const [filters, setFilters] = useState<FilterState>({ const [filters, setFilters] = useState<FilterState>({
search: "", search: "",
channelType: "all", channelType: "all",
@@ -1062,7 +1087,9 @@ export function CommunicationHub({
const agentMap = useMemo(() => new Map(agents.map((a) => [a.id, a])), [agents]) const agentMap = useMemo(() => new Map(agents.map((a) => [a.id, a])), [agents])
// Derive channels from messages // Derive channels from messages
const channels = useMemo(() => deriveChannels(messages, agents), [messages, agents]) const messageChannels = useMemo(() => deriveChannels(messages, agents), [messages, agents])
const taskDiscussionChannels = useMemo(() => deriveTaskDiscussionChannels(comments, tasks), [comments, tasks])
const channels = useMemo(() => [...messageChannels, ...taskDiscussionChannels], [messageChannels, taskDiscussionChannels])
// Auto-select first channel // Auto-select first channel
useEffect(() => { useEffect(() => {
@@ -1071,6 +1098,27 @@ export function CommunicationHub({
} }
}, [channels, selectedChannelKey]) }, [channels, selectedChannelKey])
useEffect(() => {
if (!selectedEntityId) return
const matchingMessage = messages.find((message) => message.id === selectedEntityId)
if (matchingMessage) {
const matchingChannel = messageChannels.find((channel) => getChannelMessages(messages, channel).some((message) => message.id === selectedEntityId))
if (matchingChannel) {
setSelectedChannelKey(matchingChannel.key)
}
return
}
const matchingComment = comments.find((comment) => comment.id === selectedEntityId)
if (matchingComment) {
setSelectedChannelKey(`task-${matchingComment.taskId}`)
return
}
const matchingTaskDiscussion = taskDiscussionChannels.find((discussion) => discussion.taskId === selectedEntityId)
if (matchingTaskDiscussion) {
setSelectedChannelKey(matchingTaskDiscussion.key)
}
}, [selectedEntityId, messages, comments, messageChannels, taskDiscussionChannels])
const activeChannel = useMemo( const activeChannel = useMemo(
() => channels.find((c) => c.key === selectedChannelKey) ?? null, () => channels.find((c) => c.key === selectedChannelKey) ?? null,
[channels, selectedChannelKey], [channels, selectedChannelKey],
@@ -1078,16 +1126,32 @@ export function CommunicationHub({
// Messages for the selected channel // Messages for the selected channel
const channelMessages = useMemo(() => { const channelMessages = useMemo(() => {
if (!activeChannel) return [] if (!activeChannel || activeChannel.kind !== "message") return []
return getChannelMessages(messages, activeChannel).sort( return getChannelMessages(messages, activeChannel).sort(
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
) )
}, [messages, activeChannel]) }, [messages, activeChannel])
const taskDiscussionComments = useMemo(() => {
if (!activeChannel || activeChannel.kind !== "task") return []
return comments
.filter((comment) => comment.taskId === activeChannel.taskId)
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
}, [comments, activeChannel])
const activeTask = useMemo(() => {
if (!activeChannel || activeChannel.kind !== "task") return null
return tasks.find((task) => task.id === activeChannel.taskId) ?? null
}, [tasks, activeChannel])
// Apply filters to visible messages // Apply filters to visible messages
const filteredMessages = useMemo(() => { const filteredMessages = useMemo(() => {
let result = channelMessages let result = channelMessages
if (filters.channelType !== "all") {
result = result.filter((m) => m.channel === filters.channelType)
}
if (filters.search) { if (filters.search) {
const q = filters.search.toLowerCase() const q = filters.search.toLowerCase()
result = result.filter( result = result.filter(
@@ -1127,6 +1191,10 @@ export function CommunicationHub({
const unreadByChannel = useMemo(() => { const unreadByChannel = useMemo(() => {
const counts = new Map<string, { unread: number; hasUrgent: boolean }>() const counts = new Map<string, { unread: number; hasUrgent: boolean }>()
for (const ch of channels) { for (const ch of channels) {
if (ch.kind !== "message") {
counts.set(ch.key, { unread: 0, hasUrgent: false })
continue
}
const msgs = getChannelMessages(messages, ch) const msgs = getChannelMessages(messages, ch)
const unread = msgs.filter((m) => !m.readAt).length const unread = msgs.filter((m) => !m.readAt).length
const hasUrgent = msgs.some((m) => !m.readAt && m.priority === "urgent") const hasUrgent = msgs.some((m) => !m.readAt && m.priority === "urgent")
@@ -1139,22 +1207,6 @@ export function CommunicationHub({
return messages.filter((m) => !m.readAt).length return messages.filter((m) => !m.readAt).length
}, [messages]) }, [messages])
// Mark message read
const handleMarkRead = useCallback(
(messageId: string) => {
void window.agentCompany.markMessageRead({ messageId, companyId })
},
[companyId],
)
// After sending, refresh and scroll
const handleSent = useCallback(() => {
onRefresh()
setTimeout(() => {
messageEndRef.current?.scrollIntoView({ behavior: "smooth" })
}, 200)
}, [onRefresh])
// Scroll to bottom when channel changes // Scroll to bottom when channel changes
useEffect(() => { useEffect(() => {
messageEndRef.current?.scrollIntoView({ behavior: "auto" }) messageEndRef.current?.scrollIntoView({ behavior: "auto" })
@@ -1165,7 +1217,7 @@ export function CommunicationHub({
const groups: Array<{ type: MessageChannel; label: string; items: ChannelDescriptor[] }> = [] const groups: Array<{ type: MessageChannel; label: string; items: ChannelDescriptor[] }> = []
const typeOrder: MessageChannel[] = ["company", "department", "project", "incident", "direct"] const typeOrder: MessageChannel[] = ["company", "department", "project", "incident", "direct"]
for (const type of typeOrder) { for (const type of typeOrder) {
const items = channels.filter((c) => c.channel === type) const items = channels.filter((c): c is ChannelDescriptor => c.kind === "message" && c.channel === type)
if (items.length > 0) { if (items.length > 0) {
groups.push({ groups.push({
type, type,
@@ -1200,12 +1252,15 @@ export function CommunicationHub({
</p> </p>
</div> </div>
</div> </div>
<ActionButton <button
label="Compose" type="button"
onClick={() => setShowCompose(true)} onClick={() => setShowCompose(true)}
icon={Plus} className="inline-flex items-center gap-2 rounded-[8px] px-3 py-2 text-[11px] font-semibold text-white transition hover:brightness-90"
tone="accent" style={{ backgroundColor: TERRACOTTA }}
/> >
<Plus size={13} weight="bold" />
New message
</button>
</div> </div>
{/* Main layout: sidebar + messages */} {/* Main layout: sidebar + messages */}
@@ -1213,7 +1268,6 @@ export function CommunicationHub({
className="flex overflow-hidden rounded-[12px] border border-[color:var(--line)]" className="flex overflow-hidden rounded-[12px] border border-[color:var(--line)]"
style={{ height: "calc(100vh - 220px)", minHeight: "480px" }} style={{ height: "calc(100vh - 220px)", minHeight: "480px" }}
> >
{/* ── Sidebar ────────────────────────────────────────── */}
<AnimatePresence initial={false}> <AnimatePresence initial={false}>
{showSidebar ? ( {showSidebar ? (
<motion.div <motion.div
@@ -1258,7 +1312,6 @@ export function CommunicationHub({
agents={agents} agents={agents}
onClick={() => { onClick={() => {
setSelectedChannelKey(ch.key) setSelectedChannelKey(ch.key)
setReplyTo(null)
}} }}
/> />
) )
@@ -1266,9 +1319,34 @@ export function CommunicationHub({
</div> </div>
))} ))}
{taskDiscussionChannels.length > 0 ? (
<div className="mb-3">
<div className="mb-1 px-2 text-[9px] font-bold uppercase tracking-[0.1em] text-[color:var(--muted)]">
Task Discussions
</div>
{taskDiscussionChannels.map((discussion) => (
<button
key={discussion.key}
type="button"
onClick={() => setSelectedChannelKey(discussion.key)}
className={`group flex w-full items-center gap-2.5 rounded-[8px] px-3 py-2 text-left transition duration-150 ${
selectedChannelKey === discussion.key ? "bg-[color:var(--panel-soft)]" : "hover:bg-[color:var(--panel-soft)]/50"
}`}
>
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-[6px]" style={{ backgroundColor: `${TERRACOTTA}18` }}>
<Hash size={14} weight={selectedChannelKey === discussion.key ? "fill" : "regular"} style={{ color: TERRACOTTA }} />
</div>
<span className={`min-w-0 flex-1 truncate text-[12px] ${selectedChannelKey === discussion.key ? "font-semibold text-[color:var(--text)]" : "font-medium text-[color:var(--muted-strong)]"}`}>
{discussion.label}
</span>
</button>
))}
</div>
) : null}
{channels.length === 0 ? ( {channels.length === 0 ? (
<div className="px-3 py-6 text-center text-[11px] text-[color:var(--muted)]"> <div className="px-3 py-6 text-center text-[11px] text-[color:var(--muted)]">
No channels yet. Send a message to create one. No active departments yet.
</div> </div>
) : null} ) : null}
</div> </div>
@@ -1284,7 +1362,6 @@ export function CommunicationHub({
) : null} ) : null}
</AnimatePresence> </AnimatePresence>
{/* ── Main content ─────────────────────────────────── */}
<div className="flex min-w-0 flex-1 flex-col bg-[color:var(--bg)]"> <div className="flex min-w-0 flex-1 flex-col bg-[color:var(--bg)]">
{/* Channel header */} {/* Channel header */}
<div className="flex items-center gap-3 border-b border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-2.5"> <div className="flex items-center gap-3 border-b border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-2.5">
@@ -1301,7 +1378,7 @@ export function CommunicationHub({
{activeChannel ? ( {activeChannel ? (
<> <>
{(() => { {activeChannel.kind === "message" ? (() => {
const Icon = channelConfig[activeChannel.channel].icon const Icon = channelConfig[activeChannel.channel].icon
return ( return (
<Icon <Icon
@@ -1312,19 +1389,20 @@ export function CommunicationHub({
}} }}
/> />
) )
})()} })() : (
<Hash size={16} weight="fill" style={{ color: TERRACOTTA }} />
)}
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="text-[13px] font-semibold text-[color:var(--text)]"> <div className="text-[13px] font-semibold text-[color:var(--text)]">
{activeChannel.label} {activeChannel.label}
</div> </div>
<div className="text-[10px] text-[color:var(--muted)]"> <div className="text-[10px] text-[color:var(--muted)]">
{channelMessages.length} message{channelMessages.length !== 1 ? "s" : ""} {activeChannel.kind === "message"
{(() => { ? `${channelMessages.length} message${channelMessages.length !== 1 ? "s" : ""}${(() => {
const stats = unreadByChannel.get(activeChannel.key) const stats = unreadByChannel.get(activeChannel.key)
return stats?.unread return stats?.unread ? ` (${stats.unread} unread)` : ""
? ` (${stats.unread} unread)` })()}`
: "" : `${taskDiscussionComments.length} comment${taskDiscussionComments.length !== 1 ? "s" : ""}${activeTask ? ` · ${activeTask.status.replaceAll("_", " ")}` : ""}`}
})()}
</div> </div>
</div> </div>
</> </>
@@ -1352,9 +1430,32 @@ export function CommunicationHub({
Select a channel to view messages Select a channel to view messages
</div> </div>
<div className="mt-1 text-[12px] text-[color:var(--muted)]"> <div className="mt-1 text-[12px] text-[color:var(--muted)]">
Or compose a new message to get started Monitor internal agent communications across departments
</div> </div>
</div> </div>
) : activeChannel.kind === "task" ? (
taskDiscussionComments.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center text-center">
<Tray size={36} className="mb-3 text-[color:var(--muted)]" />
<div className="text-[14px] font-medium text-[color:var(--muted-strong)]">
No discussion yet
</div>
<div className="mt-1 text-[12px] text-[color:var(--muted)]">
Comments for this task will appear here as agents and the board coordinate.
</div>
</div>
) : (
<div className="space-y-1">
{taskDiscussionComments.map((comment) => (
<TaskCommentBubble
key={comment.id}
comment={comment}
agent={comment.authorAgentId ? (agentMap.get(comment.authorAgentId) ?? undefined) : undefined}
/>
))}
<div ref={messageEndRef} />
</div>
)
) : filteredMessages.length === 0 ? ( ) : filteredMessages.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center text-center"> <div className="flex h-full flex-col items-center justify-center text-center">
<Tray size={36} className="mb-3 text-[color:var(--muted)]" /> <Tray size={36} className="mb-3 text-[color:var(--muted)]" />
@@ -1366,7 +1467,7 @@ export function CommunicationHub({
<div className="mt-1 text-[12px] text-[color:var(--muted)]"> <div className="mt-1 text-[12px] text-[color:var(--muted)]">
{filters.search || filters.unreadOnly || filters.priority !== "all" {filters.search || filters.unreadOnly || filters.priority !== "all"
? "Try adjusting your search or filters" ? "Try adjusting your search or filters"
: "Be the first to send a message"} : "Messages will appear here as agents work and communicate"}
</div> </div>
</div> </div>
) : ( ) : (
@@ -1381,7 +1482,6 @@ export function CommunicationHub({
isThread={false} isThread={false}
replyCount={replies.length} replyCount={replies.length}
onReply={() => setReplyTo(msg)} onReply={() => setReplyTo(msg)}
onMarkRead={() => handleMarkRead(msg.id)}
/> />
{/* Thread replies */} {/* Thread replies */}
{replies.length > 0 ? ( {replies.length > 0 ? (
@@ -1394,7 +1494,6 @@ export function CommunicationHub({
isThread={true} isThread={true}
replyCount={0} replyCount={0}
onReply={() => setReplyTo(reply)} onReply={() => setReplyTo(reply)}
onMarkRead={() => handleMarkRead(reply.id)}
/> />
))} ))}
</div> </div>
@@ -1407,31 +1506,44 @@ export function CommunicationHub({
)} )}
</div> </div>
{/* Composer */}
{activeChannel ? ( {activeChannel ? (
<MessageComposer activeChannel.kind === "message" ? (
agents={agents} <MessageComposer
companyId={companyId} agents={agents}
channel={activeChannel.channel} companyId={companyId}
channelTargetId={activeChannel.targetId} channel={activeChannel.channel}
replyTo={replyTo} channelTargetId={activeChannel.channel === "direct" ? null : activeChannel.targetId}
onSent={handleSent} replyTo={replyTo}
onCancelReply={() => setReplyTo(null)} onSent={() => {
/> setReplyTo(null)
) : null} onRefresh()
}}
onCancelReply={() => setReplyTo(null)}
/>
) : (
<TaskDiscussionComposer
companyId={companyId}
taskId={activeChannel.taskId}
onSent={onRefresh}
/>
)
) : (
<div className="border-t border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-2.5">
<div className="flex items-center gap-2 text-[11px] text-[color:var(--muted)]">
<ChatCircle size={14} />
<span>Select a channel to read and send messages.</span>
</div>
</div>
)}
</div> </div>
</div> </div>
{/* Compose modal */}
<ComposeModal <ComposeModal
open={showCompose} open={showCompose}
onClose={() => setShowCompose(false)} onClose={() => setShowCompose(false)}
agents={agents} agents={agents}
companyId={companyId} companyId={companyId}
onSent={() => { onSent={onRefresh}
onRefresh()
setShowCompose(false)
}}
/> />
</div> </div>
) )
@@ -0,0 +1,716 @@
import { useMemo, useState } from "react";
import {
CalendarBlank,
FileText,
FloppyDisk,
Lightbulb,
NotePencil,
Plus,
Rows,
Trash,
} from "@phosphor-icons/react";
import type {
AgentRecord,
DocumentRecord,
GoalRecord,
KnowledgeEntryRecord,
MeetingRecord,
ProjectRecord,
SprintRecord,
} from "@shared/types";
import { ActionButton, ConfirmDialog, InlineNotice, Input, ListFrame, ListRow, Modal, Select, StatusPill, TextArea } from "./ui";
const documentTypeOptions = [
"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",
].map((value) => ({ value, label: value.replaceAll("_", " ") }));
const documentStatusOptions = ["draft", "in_review", "approved", "archived", "superseded"].map((value) => ({
value,
label: value.replaceAll("_", " "),
}));
const knowledgeCategoryOptions = [
"lesson_learned",
"best_practice",
"decision",
"process",
"technical",
"business",
"onboarding",
"incident",
].map((value) => ({ value, label: value.replaceAll("_", " ") }));
const importanceOptions = ["critical", "high", "medium", "low"].map((value) => ({ value, label: value }));
const meetingTypeOptions = [
"standup",
"sprint_planning",
"sprint_review",
"retrospective",
"one_on_one",
"all_hands",
"department_sync",
"incident_review",
"hiring_committee",
"budget_review",
"design_review",
"architecture_review",
].map((value) => ({ value, label: value.replaceAll("_", " ") }));
const meetingStatusOptions = ["scheduled", "in_progress", "completed", "cancelled"].map((value) => ({ value, label: value.replaceAll("_", " ") }));
const sprintStatusOptions = ["planning", "active", "review", "completed", "cancelled"].map((value) => ({ value, label: value }));
function splitTags(value: string) {
return JSON.stringify(value.split(",").map((item) => item.trim()).filter(Boolean));
}
function parseTags(value: string) {
try {
const tags = JSON.parse(value) as string[];
return tags.join(", ");
} catch {
return "";
}
}
function parseParticipantIds(value: string) {
return value
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
function formatParticipantIds(raw: string, agents: AgentRecord[]) {
try {
const ids = JSON.parse(raw) as string[];
return ids
.map((id) => agents.find((agent) => agent.id === id)?.name ?? id.slice(0, 8))
.join(", ");
} catch {
return "";
}
}
function DocumentEditor({
companyId,
agents,
projects,
goals,
document,
onSaved,
}: {
companyId: string;
agents: AgentRecord[];
projects: ProjectRecord[];
goals: GoalRecord[];
document: DocumentRecord | null;
onSaved: () => Promise<void>;
}) {
const [title, setTitle] = useState(document?.title ?? "");
const [type, setType] = useState(document?.type ?? "technical_spec");
const [content, setContent] = useState(document?.content ?? "");
const [status, setStatus] = useState(document?.status ?? "draft");
const [reviewerAgentId, setReviewerAgentId] = useState(document?.reviewerAgentId ?? "");
const [projectId, setProjectId] = useState(document?.projectId ?? "");
const [goalId, setGoalId] = useState(document?.goalId ?? "");
const [tags, setTags] = useState(parseTags(document?.tagsJson ?? "[]"));
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
async function save() {
if (!title.trim()) {
setError("Document title is required.");
return;
}
setSaving(true);
setError(null);
try {
const result = await window.agentCompany.saveDocument({
id: document?.id ?? null,
companyId,
type: type as Parameters<typeof window.agentCompany.saveDocument>[0]["type"],
title: title.trim(),
content,
authorAgentId: document?.authorAgentId ?? null,
reviewerAgentId: reviewerAgentId || null,
projectId: projectId || null,
goalId: goalId || null,
parentDocId: document?.parentDocId ?? null,
version: document?.version ?? 1,
status: status as Parameters<typeof window.agentCompany.saveDocument>[0]["status"],
tagsJson: splitTags(tags),
});
if (!result.ok) {
setError(result.error.message);
return;
}
await onSaved();
} finally {
setSaving(false);
}
}
return (
<div className="space-y-4">
{error ? <InlineNotice message={error} /> : null}
<Input label="Title" value={title} onChange={setTitle} placeholder="Architecture review summary" />
<div className="grid gap-4 md:grid-cols-2">
<Select label="Type" value={type} onChange={(value) => setType(value as typeof type)} options={documentTypeOptions} />
<Select label="Status" value={status} onChange={(value) => setStatus(value as typeof status)} options={documentStatusOptions} />
</div>
<div className="grid gap-4 md:grid-cols-2">
<Select
label="Reviewer"
value={reviewerAgentId}
onChange={setReviewerAgentId}
options={[{ value: "", label: "None" }, ...agents.map((agent) => ({ value: agent.id, label: `${agent.name} (${agent.role})` }))]}
/>
<Input label="Tags" value={tags} onChange={setTags} placeholder="architecture, security, release" />
</div>
<div className="grid gap-4 md:grid-cols-2">
<Select label="Project" value={projectId} onChange={setProjectId} options={[{ value: "", label: "None" }, ...projects.map((project) => ({ value: project.id, label: project.name }))]} />
<Select label="Goal" value={goalId} onChange={setGoalId} options={[{ value: "", label: "None" }, ...goals.map((goal) => ({ value: goal.id, label: goal.title }))]} />
</div>
<TextArea label="Content" value={content} onChange={setContent} rows={12} placeholder="Write the document content here." />
<div className="flex justify-end">
<ActionButton label={saving ? "Saving..." : document ? "Save document" : "Create document"} icon={FloppyDisk} tone="accent" onClick={() => void save()} loading={saving} />
</div>
</div>
);
}
function KnowledgeEditor({
companyId,
entry,
onSaved,
}: {
companyId: string;
entry: KnowledgeEntryRecord | null;
onSaved: () => Promise<void>;
}) {
const [topic, setTopic] = useState(entry?.topic ?? "");
const [category, setCategory] = useState(entry?.category ?? "technical");
const [importance, setImportance] = useState(entry?.importance ?? "medium");
const [content, setContent] = useState(entry?.content ?? "");
const [tags, setTags] = useState(parseTags(entry?.tagsJson ?? "[]"));
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
async function save() {
if (!topic.trim() || !content.trim()) {
setError("Topic and content are required.");
return;
}
setSaving(true);
setError(null);
try {
const result = await window.agentCompany.saveKnowledgeEntry({
id: entry?.id ?? null,
companyId,
category: category as Parameters<typeof window.agentCompany.saveKnowledgeEntry>[0]["category"],
topic: topic.trim(),
content: content.trim(),
authorAgentId: entry?.authorAgentId ?? null,
importance: importance as Parameters<typeof window.agentCompany.saveKnowledgeEntry>[0]["importance"],
tagsJson: splitTags(tags),
referencedEntityType: entry?.referencedEntityType ?? null,
referencedEntityId: entry?.referencedEntityId ?? null,
});
if (!result.ok) {
setError(result.error.message);
return;
}
await onSaved();
} finally {
setSaving(false);
}
}
return (
<div className="space-y-4">
{error ? <InlineNotice message={error} /> : null}
<Input label="Topic" value={topic} onChange={setTopic} placeholder="What did the team learn?" />
<div className="grid gap-4 md:grid-cols-2">
<Select label="Category" value={category} onChange={(value) => setCategory(value as typeof category)} options={knowledgeCategoryOptions} />
<Select label="Importance" value={importance} onChange={(value) => setImportance(value as typeof importance)} options={importanceOptions} />
</div>
<Input label="Tags" value={tags} onChange={setTags} placeholder="incident, auth, onboarding" />
<TextArea label="Content" value={content} onChange={setContent} rows={10} placeholder="Capture the lesson, decision, or process in full." />
<div className="flex justify-end">
<ActionButton label={saving ? "Saving..." : entry ? "Save entry" : "Create entry"} icon={FloppyDisk} tone="accent" onClick={() => void save()} loading={saving} />
</div>
</div>
);
}
function MeetingEditor({
companyId,
agents,
meeting,
onSaved,
}: {
companyId: string;
agents: AgentRecord[];
meeting: MeetingRecord | null;
onSaved: () => Promise<void>;
}) {
const [title, setTitle] = useState(meeting?.title ?? "");
const [type, setType] = useState(meeting?.type ?? "department_sync");
const [status, setStatus] = useState(meeting?.status ?? "scheduled");
const [scheduledAt, setScheduledAt] = useState(meeting?.scheduledAt?.slice(0, 16) ?? new Date().toISOString().slice(0, 16));
const [durationMinutes, setDurationMinutes] = useState(String(meeting?.durationMinutes ?? 30));
const [agenda, setAgenda] = useState(parseTags(meeting?.agendaJson ?? "[]"));
const [notes, setNotes] = useState(parseTags(meeting?.notesJson ?? "[]"));
const [decisions, setDecisions] = useState(parseTags(meeting?.decisionsJson ?? "[]"));
const [participants, setParticipants] = useState(formatParticipantIds(meeting?.participantAgentIds ?? "[]", agents));
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
async function save() {
if (!title.trim()) {
setError("Meeting title is required.");
return;
}
setSaving(true);
setError(null);
try {
const participantIds = parseParticipantIds(participants)
.map((value) => agents.find((agent) => agent.name === value || agent.id === value)?.id ?? value)
.filter(Boolean);
const result = await window.agentCompany.saveMeeting({
id: meeting?.id ?? null,
companyId,
type: type as Parameters<typeof window.agentCompany.saveMeeting>[0]["type"],
title: title.trim(),
organizerAgentId: meeting?.organizerAgentId ?? null,
participantAgentIds: JSON.stringify(participantIds),
scheduledAt: new Date(scheduledAt).toISOString(),
durationMinutes: Number.parseInt(durationMinutes, 10) || 30,
agendaJson: splitTags(agenda),
notesJson: splitTags(notes),
decisionsJson: splitTags(decisions),
actionItemsJson: meeting?.actionItemsJson ?? "[]",
status: status as Parameters<typeof window.agentCompany.saveMeeting>[0]["status"],
});
if (!result.ok) {
setError(result.error.message);
return;
}
await onSaved();
} finally {
setSaving(false);
}
}
return (
<div className="space-y-4">
{error ? <InlineNotice message={error} /> : null}
<Input label="Title" value={title} onChange={setTitle} placeholder="Sprint review" />
<div className="grid gap-4 md:grid-cols-2">
<Select label="Type" value={type} onChange={(value) => setType(value as typeof type)} options={meetingTypeOptions} />
<Select label="Status" value={status} onChange={(value) => setStatus(value as typeof status)} options={meetingStatusOptions} />
</div>
<div className="grid gap-4 md:grid-cols-2">
<Input label="Scheduled At" value={scheduledAt} onChange={setScheduledAt} type="datetime-local" />
<Input label="Duration (minutes)" value={durationMinutes} onChange={setDurationMinutes} type="number" />
</div>
<Input label="Participants" value={participants} onChange={setParticipants} placeholder="Comma-separated agent names or ids" />
<Input label="Agenda" value={agenda} onChange={setAgenda} placeholder="Planning, blockers, decisions" />
<Input label="Notes" value={notes} onChange={setNotes} placeholder="Key notes" />
<Input label="Decisions" value={decisions} onChange={setDecisions} placeholder="Decision 1, Decision 2" />
<div className="flex justify-end">
<ActionButton label={saving ? "Saving..." : meeting ? "Save meeting" : "Create meeting"} icon={FloppyDisk} tone="accent" onClick={() => void save()} loading={saving} />
</div>
</div>
);
}
function SprintEditor({
companyId,
sprint,
onSaved,
}: {
companyId: string;
sprint: SprintRecord | null;
onSaved: () => Promise<void>;
}) {
const [name, setName] = useState(sprint?.name ?? "");
const [goal, setGoal] = useState(sprint?.goal ?? "");
const [startDate, setStartDate] = useState(sprint?.startDate ?? new Date().toISOString().slice(0, 10));
const [endDate, setEndDate] = useState(sprint?.endDate ?? new Date(Date.now() + 14 * 86_400_000).toISOString().slice(0, 10));
const [status, setStatus] = useState(sprint?.status ?? "planning");
const [velocityPoints, setVelocityPoints] = useState(String(sprint?.velocityPoints ?? 20));
const [completedPoints, setCompletedPoints] = useState(String(sprint?.completedPoints ?? 0));
const [retrospectiveNotes, setRetrospectiveNotes] = useState(sprint?.retrospectiveNotes ?? "");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
async function save() {
if (!name.trim()) {
setError("Sprint name is required.");
return;
}
setSaving(true);
setError(null);
try {
const result = await window.agentCompany.saveSprint({
id: sprint?.id ?? null,
companyId,
name: name.trim(),
goal: goal.trim(),
startDate,
endDate,
status: status as Parameters<typeof window.agentCompany.saveSprint>[0]["status"],
retrospectiveNotes,
velocityPoints: Number.parseInt(velocityPoints, 10) || 0,
completedPoints: Number.parseInt(completedPoints, 10) || 0,
});
if (!result.ok) {
setError(result.error.message);
return;
}
await onSaved();
} finally {
setSaving(false);
}
}
return (
<div className="space-y-4">
{error ? <InlineNotice message={error} /> : null}
<Input label="Sprint name" value={name} onChange={setName} placeholder="Sprint 12" />
<TextArea label="Goal" value={goal} onChange={setGoal} rows={3} placeholder="What this sprint is meant to achieve." />
<div className="grid gap-4 md:grid-cols-2">
<Input label="Start date" value={startDate} onChange={setStartDate} type="date" />
<Input label="End date" value={endDate} onChange={setEndDate} type="date" />
</div>
<div className="grid gap-4 md:grid-cols-3">
<Select label="Status" value={status} onChange={(value) => setStatus(value as typeof status)} options={sprintStatusOptions} />
<Input label="Velocity points" value={velocityPoints} onChange={setVelocityPoints} type="number" />
<Input label="Completed points" value={completedPoints} onChange={setCompletedPoints} type="number" />
</div>
<TextArea label="Retrospective notes" value={retrospectiveNotes} onChange={setRetrospectiveNotes} rows={5} placeholder="What should the team retain, change, or stop?" />
<div className="flex justify-end">
<ActionButton label={saving ? "Saving..." : sprint ? "Save sprint" : "Create sprint"} icon={FloppyDisk} tone="accent" onClick={() => void save()} loading={saving} />
</div>
</div>
);
}
function SectionHeader({
title,
count,
action,
}: {
title: string;
count: number;
action: () => void;
}) {
return (
<div className="mb-4 flex items-center justify-between">
<div>
<div className="text-[16px] font-semibold text-[color:var(--text)]">{title}</div>
<div className="text-[12px] text-[color:var(--muted)]">{count} item{count === 1 ? "" : "s"}</div>
</div>
<ActionButton label="New" icon={Plus} tone="accent" onClick={action} />
</div>
);
}
export function DocumentCenter({
companyId,
documents,
agents,
projects,
goals,
onRefresh,
}: {
companyId: string;
documents: DocumentRecord[];
agents: AgentRecord[];
projects: ProjectRecord[];
goals: GoalRecord[];
onRefresh: () => Promise<void>;
}) {
const [editingId, setEditingId] = useState<string | null>(null);
const [deleteId, setDeleteId] = useState<string | null>(null);
const activeDocument = useMemo(() => documents.find((document) => document.id === editingId) ?? null, [documents, editingId]);
return (
<div>
<SectionHeader title="Document Center" count={documents.length} action={() => setEditingId("__new__")} />
<ListFrame>
{documents.map((document) => (
<ListRow key={document.id}>
<div className="flex items-start justify-between gap-3">
<button type="button" className="min-w-0 flex-1 text-left" onClick={() => setEditingId(document.id)}>
<div className="flex items-center gap-2 text-[13px] font-medium text-[color:var(--text)]">
<FileText size={15} className="text-[color:var(--accent)]" />
<span className="truncate">{document.title}</span>
</div>
<div className="mt-1 text-[11px] text-[color:var(--muted)]">
{document.type.replaceAll("_", " ")} · v{document.version} · {document.updatedAt.slice(0, 10)}
</div>
</button>
<div className="flex items-center gap-2">
<StatusPill status={document.status} />
<button type="button" className="rounded p-1 text-[color:var(--muted)] hover:text-[color:var(--text)]" onClick={() => setEditingId(document.id)}>
<NotePencil size={14} />
</button>
<button type="button" className="rounded p-1 text-[color:var(--muted)] hover:text-[color:var(--danger)]" onClick={() => setDeleteId(document.id)}>
<Trash size={14} />
</button>
</div>
</div>
</ListRow>
))}
</ListFrame>
<Modal open={editingId !== null} title={activeDocument ? activeDocument.title : "New document"} onClose={() => setEditingId(null)} width="760px">
<DocumentEditor
companyId={companyId}
agents={agents}
projects={projects}
goals={goals}
document={editingId === "__new__" ? null : activeDocument}
onSaved={async () => {
setEditingId(null);
await onRefresh();
}}
/>
</Modal>
<ConfirmDialog
open={deleteId !== null}
title="Delete document"
message="This removes the document from the company knowledge surface."
onCancel={() => setDeleteId(null)}
onConfirm={() => {
if (!deleteId) return;
void window.agentCompany.deleteDocument({ id: deleteId, companyId }).then(() => onRefresh());
setDeleteId(null);
}}
/>
</div>
);
}
export function KnowledgeBaseManager({
companyId,
entries,
onRefresh,
}: {
companyId: string;
entries: KnowledgeEntryRecord[];
onRefresh: () => Promise<void>;
}) {
const [editingId, setEditingId] = useState<string | null>(null);
const [deleteId, setDeleteId] = useState<string | null>(null);
const activeEntry = useMemo(() => entries.find((entry) => entry.id === editingId) ?? null, [entries, editingId]);
return (
<div>
<SectionHeader title="Knowledge Base" count={entries.length} action={() => setEditingId("__new__")} />
<ListFrame>
{entries.map((entry) => (
<ListRow key={entry.id}>
<div className="flex items-start justify-between gap-3">
<button type="button" className="min-w-0 flex-1 text-left" onClick={() => setEditingId(entry.id)}>
<div className="flex items-center gap-2 text-[13px] font-medium text-[color:var(--text)]">
<Lightbulb size={15} className="text-[color:var(--accent)]" />
<span className="truncate">{entry.topic}</span>
</div>
<div className="mt-1 text-[11px] text-[color:var(--muted)]">
{entry.category.replaceAll("_", " ")} · {entry.updatedAt.slice(0, 10)}
</div>
</button>
<div className="flex items-center gap-2">
<StatusPill status={entry.importance} />
<button type="button" className="rounded p-1 text-[color:var(--muted)] hover:text-[color:var(--text)]" onClick={() => setEditingId(entry.id)}>
<NotePencil size={14} />
</button>
<button type="button" className="rounded p-1 text-[color:var(--muted)] hover:text-[color:var(--danger)]" onClick={() => setDeleteId(entry.id)}>
<Trash size={14} />
</button>
</div>
</div>
</ListRow>
))}
</ListFrame>
<Modal open={editingId !== null} title={activeEntry ? activeEntry.topic : "New knowledge entry"} onClose={() => setEditingId(null)} width="760px">
<KnowledgeEditor
companyId={companyId}
entry={editingId === "__new__" ? null : activeEntry}
onSaved={async () => {
setEditingId(null);
await onRefresh();
}}
/>
</Modal>
<ConfirmDialog
open={deleteId !== null}
title="Delete knowledge entry"
message="This removes the entry from the shared company knowledge base."
onCancel={() => setDeleteId(null)}
onConfirm={() => {
if (!deleteId) return;
void window.agentCompany.deleteKnowledgeEntry({ id: deleteId, companyId }).then(() => onRefresh());
setDeleteId(null);
}}
/>
</div>
);
}
export function MeetingsManager({
companyId,
meetings,
agents,
onRefresh,
}: {
companyId: string;
meetings: MeetingRecord[];
agents: AgentRecord[];
onRefresh: () => Promise<void>;
}) {
const [editingId, setEditingId] = useState<string | null>(null);
const [deleteId, setDeleteId] = useState<string | null>(null);
const activeMeeting = useMemo(() => meetings.find((meeting) => meeting.id === editingId) ?? null, [meetings, editingId]);
return (
<div>
<SectionHeader title="Meetings" count={meetings.length} action={() => setEditingId("__new__")} />
<ListFrame>
{meetings.map((meeting) => (
<ListRow key={meeting.id}>
<div className="flex items-start justify-between gap-3">
<button type="button" className="min-w-0 flex-1 text-left" onClick={() => setEditingId(meeting.id)}>
<div className="flex items-center gap-2 text-[13px] font-medium text-[color:var(--text)]">
<CalendarBlank size={15} className="text-[color:var(--accent)]" />
<span className="truncate">{meeting.title}</span>
</div>
<div className="mt-1 text-[11px] text-[color:var(--muted)]">
{meeting.type.replaceAll("_", " ")} · {new Date(meeting.scheduledAt).toLocaleString()} · {meeting.durationMinutes} min
</div>
</button>
<div className="flex items-center gap-2">
<StatusPill status={meeting.status} />
<button type="button" className="rounded p-1 text-[color:var(--muted)] hover:text-[color:var(--text)]" onClick={() => setEditingId(meeting.id)}>
<NotePencil size={14} />
</button>
<button type="button" className="rounded p-1 text-[color:var(--muted)] hover:text-[color:var(--danger)]" onClick={() => setDeleteId(meeting.id)}>
<Trash size={14} />
</button>
</div>
</div>
</ListRow>
))}
</ListFrame>
<Modal open={editingId !== null} title={activeMeeting ? activeMeeting.title : "New meeting"} onClose={() => setEditingId(null)} width="760px">
<MeetingEditor
companyId={companyId}
agents={agents}
meeting={editingId === "__new__" ? null : activeMeeting}
onSaved={async () => {
setEditingId(null);
await onRefresh();
}}
/>
</Modal>
<ConfirmDialog
open={deleteId !== null}
title="Delete meeting"
message="This removes the meeting and its operational context from the company timeline."
onCancel={() => setDeleteId(null)}
onConfirm={() => {
if (!deleteId) return;
void window.agentCompany.deleteMeeting({ id: deleteId, companyId }).then(() => onRefresh());
setDeleteId(null);
}}
/>
</div>
);
}
export function SprintManager({
companyId,
sprints,
onRefresh,
}: {
companyId: string;
sprints: SprintRecord[];
onRefresh: () => Promise<void>;
}) {
const [editingId, setEditingId] = useState<string | null>(null);
const [deleteId, setDeleteId] = useState<string | null>(null);
const activeSprint = useMemo(() => sprints.find((sprint) => sprint.id === editingId) ?? null, [sprints, editingId]);
return (
<div>
<SectionHeader title="Sprint Board" count={sprints.length} action={() => setEditingId("__new__")} />
<ListFrame>
{sprints.map((sprint) => (
<ListRow key={sprint.id}>
<div className="flex items-start justify-between gap-3">
<button type="button" className="min-w-0 flex-1 text-left" onClick={() => setEditingId(sprint.id)}>
<div className="flex items-center gap-2 text-[13px] font-medium text-[color:var(--text)]">
<Rows size={15} className="text-[color:var(--accent)]" />
<span className="truncate">{sprint.name}</span>
</div>
<div className="mt-1 text-[11px] text-[color:var(--muted)]">
{sprint.startDate} → {sprint.endDate} · {sprint.completedPoints}/{sprint.velocityPoints} pts
</div>
</button>
<div className="flex items-center gap-2">
<StatusPill status={sprint.status} />
<button type="button" className="rounded p-1 text-[color:var(--muted)] hover:text-[color:var(--text)]" onClick={() => setEditingId(sprint.id)}>
<NotePencil size={14} />
</button>
<button type="button" className="rounded p-1 text-[color:var(--muted)] hover:text-[color:var(--danger)]" onClick={() => setDeleteId(sprint.id)}>
<Trash size={14} />
</button>
</div>
</div>
</ListRow>
))}
</ListFrame>
<Modal open={editingId !== null} title={activeSprint ? activeSprint.name : "New sprint"} onClose={() => setEditingId(null)} width="720px">
<SprintEditor
companyId={companyId}
sprint={editingId === "__new__" ? null : activeSprint}
onSaved={async () => {
setEditingId(null);
await onRefresh();
}}
/>
</Modal>
<ConfirmDialog
open={deleteId !== null}
title="Delete sprint"
message="This removes the sprint board entry. Historical run and task records remain."
onCancel={() => setDeleteId(null)}
onConfirm={() => {
if (!deleteId) return;
void window.agentCompany.deleteSprint({ id: deleteId, companyId }).then(() => onRefresh());
setDeleteId(null);
}}
/>
</div>
);
}
+117 -103
View File
@@ -1,4 +1,4 @@
import { useMemo } from "react"; import { useEffect, useMemo, useState } from "react";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { import {
ArrowRight, ArrowRight,
@@ -25,10 +25,11 @@ import {
import type { import type {
ActivityRecord, ActivityRecord,
AgentRecord, AgentRecord,
AgentMessageRecord,
ApprovalRecord, ApprovalRecord,
BrowserActionRecord,
ConnectorRecord, ConnectorRecord,
CostRecord, CostRecord,
DepartmentId,
GoalRecord, GoalRecord,
MeetingRecord, MeetingRecord,
ProjectRecord, ProjectRecord,
@@ -36,14 +37,11 @@ import type {
SectionId, SectionId,
SprintRecord, SprintRecord,
TaskRecord, TaskRecord,
WorkflowPipelineRecord,
} from "@shared/types"; } from "@shared/types";
import { formatMoney } from "../lib/formatters"; import { formatMoney, getAgentInitials, timeAgo } from "../lib/formatters";
import { StatusPill } from "./ui"; import { StatusPill } from "./ui";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface DashboardProps { interface DashboardProps {
agents: AgentRecord[]; agents: AgentRecord[];
tasks: TaskRecord[]; tasks: TaskRecord[];
@@ -54,43 +52,45 @@ interface DashboardProps {
activity: ActivityRecord[]; activity: ActivityRecord[];
costs: CostRecord[]; costs: CostRecord[];
connectors: ConnectorRecord[]; connectors: ConnectorRecord[];
messages?: AgentMessageRecord[];
browserActions?: BrowserActionRecord[];
workflows?: WorkflowPipelineRecord[];
meetings?: MeetingRecord[]; meetings?: MeetingRecord[];
sprints?: SprintRecord[]; sprints?: SprintRecord[];
onNavigate: (section: SectionId, entityId?: string) => void; onNavigate: (section: SectionId, entityId?: string) => void;
} }
/* ------------------------------------------------------------------ */ function computeHealthScore(props: Pick<DashboardProps, "agents" | "tasks" | "runs" | "approvals" | "connectors" | "messages" | "browserActions" | "workflows">): number {
/* Health Score Computation */
/* ------------------------------------------------------------------ */
function computeHealthScore(props: Pick<DashboardProps, "agents" | "tasks" | "runs" | "approvals" | "connectors">): number {
let score = 100; let score = 100;
const { agents, tasks, runs, approvals, connectors } = props; const { agents, tasks, runs, approvals, connectors, messages = [], browserActions = [], workflows = [] } = props;
// Penalize for failed runs (recent)
const failedRuns = runs.filter((r) => r.status === "failed" || r.status === "timed_out"); const failedRuns = runs.filter((r) => r.status === "failed" || r.status === "timed_out");
score -= Math.min(failedRuns.length * 5, 25); score -= Math.min(failedRuns.length * 5, 25);
// Penalize for blocked tasks
const blockedTasks = tasks.filter((t) => t.status === "blocked"); const blockedTasks = tasks.filter((t) => t.status === "blocked");
score -= Math.min(blockedTasks.length * 8, 20); score -= Math.min(blockedTasks.length * 8, 20);
// Penalize for pending approvals (bottleneck)
const pendingApprovals = approvals.filter((a) => a.state === "pending"); const pendingApprovals = approvals.filter((a) => a.state === "pending");
score -= Math.min(pendingApprovals.length * 5, 15); score -= Math.min(pendingApprovals.length * 5, 15);
// Penalize for agents in error state
const errorAgents = agents.filter((a) => a.status === "error"); const errorAgents = agents.filter((a) => a.status === "error");
score -= Math.min(errorAgents.length * 10, 20); score -= Math.min(errorAgents.length * 10, 20);
// Penalize for no ready connectors
const readyConnectors = connectors.filter((c) => c.status === "ready"); const readyConnectors = connectors.filter((c) => c.status === "ready");
if (readyConnectors.length === 0 && connectors.length > 0) score -= 15; if (readyConnectors.length === 0 && connectors.length > 0) score -= 15;
// Penalize for overbudget agents
const overBudget = agents.filter((a) => a.budgetMonthlyUsd > 0 && a.spentMonthlyUsd >= a.budgetMonthlyUsd); const overBudget = agents.filter((a) => a.budgetMonthlyUsd > 0 && a.spentMonthlyUsd >= a.budgetMonthlyUsd);
score -= Math.min(overBudget.length * 5, 10); score -= Math.min(overBudget.length * 5, 10);
const urgentMessages = messages.filter((message) => !message.readAt && (message.priority === "urgent" || message.channel === "incident"));
score -= Math.min(urgentMessages.length * 4, 12);
const blockedBrowserActions = browserActions.filter((action) => action.status === "approval_required" || action.status === "failed");
score -= Math.min(blockedBrowserActions.length * 4, 12);
const stalledWorkflows = workflows.filter((workflow) => workflow.status === "paused" || workflow.status === "failed");
score -= Math.min(stalledWorkflows.length * 5, 15);
return Math.max(0, Math.min(100, score)); return Math.max(0, Math.min(100, score));
} }
@@ -102,10 +102,6 @@ function healthLabel(score: number): { label: string; tone: string } {
return { label: "Critical", tone: "text-[color:var(--danger)]" }; return { label: "Critical", tone: "text-[color:var(--danger)]" };
} }
/* ------------------------------------------------------------------ */
/* Health Ring */
/* ------------------------------------------------------------------ */
function HealthRing({ score }: { score: number }) { function HealthRing({ score }: { score: number }) {
const radius = 38; const radius = 38;
const circumference = 2 * Math.PI * radius; const circumference = 2 * Math.PI * radius;
@@ -149,10 +145,6 @@ function HealthRing({ score }: { score: number }) {
); );
} }
/* ------------------------------------------------------------------ */
/* Metric Card */
/* ------------------------------------------------------------------ */
function MetricCard({ function MetricCard({
label, label,
value, value,
@@ -201,10 +193,6 @@ function MetricCard({
); );
} }
/* ------------------------------------------------------------------ */
/* Agent Workforce Grid */
/* ------------------------------------------------------------------ */
const statusGlow: Record<string, string> = { const statusGlow: Record<string, string> = {
running: "ring-2 ring-[color:var(--success)] ring-opacity-50", running: "ring-2 ring-[color:var(--success)] ring-opacity-50",
active: "ring-1 ring-[color:var(--success)] ring-opacity-30", active: "ring-1 ring-[color:var(--success)] ring-opacity-30",
@@ -216,12 +204,7 @@ const statusGlow: Record<string, string> = {
}; };
function AgentCard({ agent, onClick }: { agent: AgentRecord; onClick: () => void }) { function AgentCard({ agent, onClick }: { agent: AgentRecord; onClick: () => void }) {
const initials = agent.name const initials = getAgentInitials(agent.name);
.split(/\s+/)
.slice(0, 2)
.map((w) => w[0])
.join("")
.toUpperCase();
const isActive = agent.status === "running" || agent.status === "active"; const isActive = agent.status === "running" || agent.status === "active";
@@ -287,10 +270,6 @@ function WorkforceGrid({ agents, onSelectAgent }: { agents: AgentRecord[]; onSel
); );
} }
/* ------------------------------------------------------------------ */
/* Activity Stream */
/* ------------------------------------------------------------------ */
const actionIcons: Record<string, React.ElementType> = { const actionIcons: Record<string, React.ElementType> = {
created: CheckCircle, created: CheckCircle,
updated: Lightning, updated: Lightning,
@@ -302,20 +281,6 @@ const actionIcons: Record<string, React.ElementType> = {
rejected: XCircle, rejected: XCircle,
}; };
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`;
}
function ActivityFeed({ activity, agents, limit = 15 }: { activity: ActivityRecord[]; agents: AgentRecord[]; limit?: number }) { function ActivityFeed({ activity, agents, limit = 15 }: { activity: ActivityRecord[]; agents: AgentRecord[]; limit?: number }) {
const recentActivity = useMemo(() => { const recentActivity = useMemo(() => {
return [...activity] return [...activity]
@@ -368,10 +333,6 @@ function ActivityFeed({ activity, agents, limit = 15 }: { activity: ActivityReco
); );
} }
/* ------------------------------------------------------------------ */
/* Workflow Pipeline (visual task flow) */
/* ------------------------------------------------------------------ */
function TaskPipeline({ tasks }: { tasks: TaskRecord[] }) { function TaskPipeline({ tasks }: { tasks: TaskRecord[] }) {
const stages = useMemo(() => { const stages = useMemo(() => {
const counts = { backlog: 0, todo: 0, in_progress: 0, in_review: 0, done: 0, blocked: 0 }; const counts = { backlog: 0, todo: 0, in_progress: 0, in_review: 0, done: 0, blocked: 0 };
@@ -405,10 +366,6 @@ function TaskPipeline({ tasks }: { tasks: TaskRecord[] }) {
); );
} }
/* ------------------------------------------------------------------ */
/* Department Overview */
/* ------------------------------------------------------------------ */
const DEPARTMENT_INFO: Record<string, { label: string; icon: React.ElementType; color: string }> = { const DEPARTMENT_INFO: Record<string, { label: string; icon: React.ElementType; color: string }> = {
executive: { label: "Executive", icon: Buildings, color: "var(--accent)" }, executive: { label: "Executive", icon: Buildings, color: "var(--accent)" },
engineering: { label: "Engineering", icon: Cpu, color: "#6366f1" }, engineering: { label: "Engineering", icon: Cpu, color: "#6366f1" },
@@ -485,7 +442,6 @@ function DepartmentOverview({
onClick={() => onNavigate("agents")} 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" 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="absolute left-0 top-0 h-full w-[3px]" style={{ backgroundColor: info.color }} />
<div className="flex items-center gap-2 pl-1"> <div className="flex items-center gap-2 pl-1">
@@ -524,11 +480,13 @@ function DepartmentOverview({
); );
} }
/* ------------------------------------------------------------------ */
/* Sprint Progress */
/* ------------------------------------------------------------------ */
function SprintProgress({ sprint, tasks }: { sprint: SprintRecord; tasks: TaskRecord[] }) { function SprintProgress({ sprint, tasks }: { sprint: SprintRecord; tasks: TaskRecord[] }) {
const [nowMs, setNowMs] = useState<number | null>(null);
useEffect(() => {
setNowMs(Date.now());
}, []);
const progress = useMemo(() => { const progress = useMemo(() => {
if (sprint.velocityPoints === 0) return 0; if (sprint.velocityPoints === 0) return 0;
return Math.min(100, Math.round((sprint.completedPoints / sprint.velocityPoints) * 100)); return Math.min(100, Math.round((sprint.completedPoints / sprint.velocityPoints) * 100));
@@ -536,9 +494,9 @@ function SprintProgress({ sprint, tasks }: { sprint: SprintRecord; tasks: TaskRe
const daysLeft = useMemo(() => { const daysLeft = useMemo(() => {
const end = new Date(sprint.endDate).getTime(); const end = new Date(sprint.endDate).getTime();
const now = Date.now(); const now = nowMs ?? end;
return Math.max(0, Math.ceil((end - now) / (1000 * 60 * 60 * 24))); return Math.max(0, Math.ceil((end - now) / (1000 * 60 * 60 * 24)));
}, [sprint]); }, [sprint, nowMs]);
const sprintTasks = useMemo(() => { const sprintTasks = useMemo(() => {
const done = tasks.filter((t) => t.status === "done").length; const done = tasks.filter((t) => t.status === "done").length;
@@ -561,7 +519,6 @@ function SprintProgress({ sprint, tasks }: { sprint: SprintRecord; tasks: TaskRe
</div> </div>
</div> </div>
{/* Progress bar */}
<div className="mt-3 h-2 w-full overflow-hidden rounded-full bg-[color:var(--panel-soft)]"> <div className="mt-3 h-2 w-full overflow-hidden rounded-full bg-[color:var(--panel-soft)]">
<motion.div <motion.div
className="h-full rounded-full bg-[color:var(--accent)]" className="h-full rounded-full bg-[color:var(--accent)]"
@@ -587,10 +544,6 @@ function SprintProgress({ sprint, tasks }: { sprint: SprintRecord; tasks: TaskRe
); );
} }
/* ------------------------------------------------------------------ */
/* Goal Progress Bars */
/* ------------------------------------------------------------------ */
function GoalProgress({ goals, tasks, onNavigate }: { goals: GoalRecord[]; tasks: TaskRecord[]; onNavigate: (section: SectionId) => void }) { function GoalProgress({ goals, tasks, onNavigate }: { goals: GoalRecord[]; tasks: TaskRecord[]; onNavigate: (section: SectionId) => void }) {
const activeGoals = useMemo(() => goals.filter((g) => g.status === "active"), [goals]); const activeGoals = useMemo(() => goals.filter((g) => g.status === "active"), [goals]);
@@ -634,16 +587,12 @@ function GoalProgress({ goals, tasks, onNavigate }: { goals: GoalRecord[]; tasks
); );
} }
/* ------------------------------------------------------------------ */
/* Main Dashboard */
/* ------------------------------------------------------------------ */
export function Dashboard(props: DashboardProps) { export function Dashboard(props: DashboardProps) {
const { agents, tasks, runs, approvals, goals, projects, activity, costs, connectors, meetings = [], sprints = [], onNavigate } = props; const { agents, tasks, runs, approvals, goals, projects, activity, costs, connectors, messages = [], browserActions = [], workflows = [], sprints = [], onNavigate } = props;
const healthScore = useMemo( const healthScore = useMemo(
() => computeHealthScore({ agents, tasks, runs, approvals, connectors }), () => computeHealthScore({ agents, tasks, runs, approvals, connectors, messages, browserActions, workflows }),
[agents, tasks, runs, approvals, connectors], [agents, tasks, runs, approvals, connectors, messages, browserActions, workflows],
); );
const liveRuns = useMemo(() => runs.filter((r) => r.status === "running" || r.status === "queued"), [runs]); const liveRuns = useMemo(() => runs.filter((r) => r.status === "running" || r.status === "queued"), [runs]);
@@ -655,16 +604,14 @@ export function Dashboard(props: DashboardProps) {
const activeAgents = useMemo(() => agents.filter((a) => a.status !== "terminated" && a.status !== "pending_approval"), [agents]); const activeAgents = useMemo(() => agents.filter((a) => a.status !== "terminated" && a.status !== "pending_approval"), [agents]);
const readyConnectors = useMemo(() => connectors.filter((c) => c.status === "ready"), [connectors]); const readyConnectors = useMemo(() => connectors.filter((c) => c.status === "ready"), [connectors]);
const activeSprint = useMemo(() => sprints.find((s) => s.status === "active"), [sprints]); const activeSprint = useMemo(() => sprints.find((s) => s.status === "active"), [sprints]);
const completionRate = useMemo(() => { const unreadMessages = useMemo(() => messages.filter((message) => !message.readAt), [messages]);
const total = runs.length; const urgentMessages = useMemo(() => messages.filter((message) => !message.readAt && (message.priority === "urgent" || message.channel === "incident")), [messages]);
if (total === 0) return 0; const blockedBrowserActions = useMemo(() => browserActions.filter((action) => action.status === "approval_required" || action.status === "failed"), [browserActions]);
const succeeded = runs.filter((r) => r.status === "succeeded").length; const stalledWorkflows = useMemo(() => workflows.filter((workflow) => workflow.status === "paused" || workflow.status === "failed"), [workflows]);
return Math.round((succeeded / total) * 100);
}, [runs]);
return ( return (
<div className="space-y-8"> <div className="space-y-8">
{/* Row 1: Health + Metrics */} {/* Health + Metrics */}
<div className="flex flex-col gap-6 lg:flex-row lg:items-start"> <div className="flex flex-col gap-6 lg:flex-row lg:items-start">
{/* Health Ring */} {/* Health Ring */}
<div className="shrink-0 rounded-[12px] border border-[color:var(--line)] bg-[color:var(--panel)] p-5"> <div className="shrink-0 rounded-[12px] border border-[color:var(--line)] bg-[color:var(--panel)] p-5">
@@ -733,10 +680,34 @@ export function Dashboard(props: DashboardProps) {
onClick={() => onNavigate("projects")} onClick={() => onNavigate("projects")}
subtitle={`${projects.length} total`} subtitle={`${projects.length} total`}
/> />
<MetricCard
label="Unread Messages"
value={unreadMessages.length}
icon={Users}
tone={urgentMessages.length > 0 ? "warn" : unreadMessages.length > 0 ? "accent" : "default"}
onClick={() => onNavigate("communication")}
subtitle={urgentMessages.length > 0 ? `${urgentMessages.length} urgent` : unreadMessages.length > 0 ? "Needs triage" : "All read"}
/>
<MetricCard
label="Browser Ops"
value={blockedBrowserActions.length}
icon={Cpu}
tone={blockedBrowserActions.length > 0 ? "warn" : "default"}
onClick={() => onNavigate("social")}
subtitle={blockedBrowserActions.length > 0 ? "Blocked or failed" : "Nominal"}
/>
<MetricCard
label="Workflow Stalls"
value={stalledWorkflows.length}
icon={Lightning}
tone={stalledWorkflows.length > 0 ? "warn" : "default"}
onClick={() => onNavigate("automation")}
subtitle={stalledWorkflows.length > 0 ? "Paused or failed" : "Healthy"}
/>
</div> </div>
</div> </div>
{/* Row 2: Department Overview */} {/* Department Overview */}
<section> <section>
<div className="mb-3 flex items-center justify-between"> <div className="mb-3 flex items-center justify-between">
<div className="text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Departments</div> <div className="text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Departments</div>
@@ -751,7 +722,7 @@ export function Dashboard(props: DashboardProps) {
<DepartmentOverview agents={agents} tasks={tasks} runs={runs} onNavigate={onNavigate} /> <DepartmentOverview agents={agents} tasks={tasks} runs={runs} onNavigate={onNavigate} />
</section> </section>
{/* Row 3: Sprint + Goals side by side */} {/* Sprint + Goals */}
<div className="grid gap-6 lg:grid-cols-2"> <div className="grid gap-6 lg:grid-cols-2">
{activeSprint ? ( {activeSprint ? (
<section> <section>
@@ -784,7 +755,7 @@ export function Dashboard(props: DashboardProps) {
</section> </section>
</div> </div>
{/* Row 4: Task Pipeline */} {/* Task Pipeline */}
<section> <section>
<div className="mb-3 flex items-center justify-between"> <div className="mb-3 flex items-center justify-between">
<div className="text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Task Pipeline</div> <div className="text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Task Pipeline</div>
@@ -801,7 +772,7 @@ export function Dashboard(props: DashboardProps) {
</div> </div>
</section> </section>
{/* Row 5: Workforce + Activity */} {/* Workforce + Activity */}
<div className="grid gap-6 lg:grid-cols-5"> <div className="grid gap-6 lg:grid-cols-5">
{/* Workforce */} {/* Workforce */}
<section className="lg:col-span-3"> <section className="lg:col-span-3">
@@ -836,7 +807,7 @@ export function Dashboard(props: DashboardProps) {
</section> </section>
</div> </div>
{/* Row 4: Connector health (compact) */} {/* Connector Health */}
<section> <section>
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Infrastructure</div> <div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Infrastructure</div>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-5"> <div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-5">
@@ -867,8 +838,8 @@ export function Dashboard(props: DashboardProps) {
</div> </div>
</section> </section>
{/* Row 5: Quick Actions / Needs Attention */} {/* Needs Attention */}
{(pendingApprovals.length > 0 || failedRuns.length > 0) ? ( {(pendingApprovals.length > 0 || failedRuns.length > 0 || urgentMessages.length > 0 || blockedBrowserActions.length > 0 || stalledWorkflows.length > 0) ? (
<section> <section>
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--warn)]">Needs Attention</div> <div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--warn)]">Needs Attention</div>
<div className="space-y-1.5"> <div className="space-y-1.5">
@@ -913,6 +884,56 @@ export function Dashboard(props: DashboardProps) {
</button> </button>
); );
})} })}
{urgentMessages.slice(0, 3).map((message) => {
const sender = agents.find((agent) => agent.id === message.fromAgentId);
return (
<button
key={message.id}
type="button"
onClick={() => onNavigate("communication")}
className="focus-ring flex w-full items-center gap-3 rounded-[8px] border border-[color:var(--warn)] border-opacity-20 bg-[color:var(--warn-soft)] px-4 py-3 text-left transition hover:border-opacity-40"
>
<Users size={18} className="shrink-0 text-[color:var(--warn)]" />
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-medium text-[color:var(--text)]">{message.subject}</div>
<div className="mt-0.5 text-[11px] text-[color:var(--muted)]">
{sender?.name ?? "Unknown"} — {message.channel} {message.priority === "urgent" ? "· urgent" : ""}
</div>
</div>
<ArrowRight size={14} className="shrink-0 text-[color:var(--muted)]" />
</button>
);
})}
{blockedBrowserActions.slice(0, 3).map((action) => (
<button
key={action.id}
type="button"
onClick={() => onNavigate("social")}
className="focus-ring flex w-full items-center gap-3 rounded-[8px] border border-[color:var(--warn)] border-opacity-20 bg-[color:var(--warn-soft)] px-4 py-3 text-left transition hover:border-opacity-40"
>
<Cpu size={18} className="shrink-0 text-[color:var(--warn)]" />
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-medium text-[color:var(--text)]">{action.resultSummary || action.actionType}</div>
<div className="mt-0.5 text-[11px] text-[color:var(--muted)]">Browser action {action.status.replaceAll("_", " ")}</div>
</div>
<ArrowRight size={14} className="shrink-0 text-[color:var(--muted)]" />
</button>
))}
{stalledWorkflows.slice(0, 3).map((workflow) => (
<button
key={workflow.id}
type="button"
onClick={() => onNavigate("automation")}
className="focus-ring flex w-full items-center gap-3 rounded-[8px] border border-[color:var(--warn)] border-opacity-20 bg-[color:var(--warn-soft)] px-4 py-3 text-left transition hover:border-opacity-40"
>
<Lightning size={18} className="shrink-0 text-[color:var(--warn)]" />
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-medium text-[color:var(--text)]">{workflow.name}</div>
<div className="mt-0.5 text-[11px] text-[color:var(--muted)]">Workflow {workflow.status}</div>
</div>
<ArrowRight size={14} className="shrink-0 text-[color:var(--muted)]" />
</button>
))}
</div> </div>
</section> </section>
) : null} ) : null}
@@ -920,10 +941,6 @@ export function Dashboard(props: DashboardProps) {
); );
} }
/* ------------------------------------------------------------------ */
/* Kanban Board */
/* ------------------------------------------------------------------ */
interface KanbanProps { interface KanbanProps {
tasks: TaskRecord[]; tasks: TaskRecord[];
agents: AgentRecord[]; agents: AgentRecord[];
@@ -957,7 +974,6 @@ export function KanbanBoard({ tasks, agents, runs, onSelectTask }: KanbanProps)
if (bucket) bucket.push(task); if (bucket) bucket.push(task);
else grouped.backlog?.push(task); else grouped.backlog?.push(task);
} }
// Sort each column by priority
const priorityOrder = { critical: 0, high: 1, medium: 2, low: 3 }; const priorityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
for (const col of Object.values(grouped)) { for (const col of Object.values(grouped)) {
col.sort((a, b) => (priorityOrder[a.priority] ?? 9) - (priorityOrder[b.priority] ?? 9)); col.sort((a, b) => (priorityOrder[a.priority] ?? 9) - (priorityOrder[b.priority] ?? 9));
@@ -981,7 +997,6 @@ export function KanbanBoard({ tasks, agents, runs, onSelectTask }: KanbanProps)
const colTasks = tasksByStatus[col.status] ?? []; const colTasks = tasksByStatus[col.status] ?? [];
return ( return (
<div key={col.status} className="flex w-[220px] shrink-0 flex-col"> <div key={col.status} className="flex w-[220px] shrink-0 flex-col">
{/* Column header */}
<div className="mb-2 flex items-center gap-2 px-1"> <div className="mb-2 flex items-center gap-2 px-1">
<span className={`h-2 w-2 rounded-full ${col.dotColor}`} /> <span className={`h-2 w-2 rounded-full ${col.dotColor}`} />
<span className={`text-[11px] font-semibold uppercase tracking-[0.08em] ${col.tone}`}>{col.label}</span> <span className={`text-[11px] font-semibold uppercase tracking-[0.08em] ${col.tone}`}>{col.label}</span>
@@ -989,7 +1004,6 @@ export function KanbanBoard({ tasks, agents, runs, onSelectTask }: KanbanProps)
{colTasks.length} {colTasks.length}
</span> </span>
</div> </div>
{/* Cards */}
<div className="flex flex-1 flex-col gap-1.5 rounded-[8px] bg-[color:var(--panel-soft)] p-1.5" style={{ minHeight: 120 }}> <div className="flex flex-1 flex-col gap-1.5 rounded-[8px] bg-[color:var(--panel-soft)] p-1.5" style={{ minHeight: 120 }}>
{colTasks.map((task) => { {colTasks.map((task) => {
const assignee = agents.find((a) => a.id === task.assigneeAgentId); const assignee = agents.find((a) => a.id === task.assigneeAgentId);
+137 -83
View File
@@ -28,6 +28,7 @@ import type {
ActivityRecord, ActivityRecord,
AgentRecord, AgentRecord,
ApprovalRecord, ApprovalRecord,
DocumentRecord,
GoalRecord, GoalRecord,
ProfileSnapshot, ProfileSnapshot,
ProjectRecord, ProjectRecord,
@@ -39,76 +40,12 @@ import type {
} from "@shared/types"; } from "@shared/types";
import { formatMoney, formatTime } from "../lib/formatters"; import { formatMoney, formatTime } from "../lib/formatters";
import { unwrap } from "../lib/desktop"; import { unwrap } from "../lib/desktop";
import { ActionButton, EmptyState, Field, Input, ListFrame, ListRow, Select, StatusPill, TextArea, useFocusTrap } from "./ui"; import { ActionButton, ConfirmDialog, EmptyState, Field, InlineNotice, Input, ListFrame, ListRow, Select, StatusPill, TextArea } from "./ui";
function describeActionError(error: unknown) { function describeActionError(error: unknown) {
return error instanceof Error ? error.message : "The action could not be completed."; return error instanceof Error ? error.message : "The action could not be completed.";
} }
function InlineNotice({
message,
tone = "danger",
}: {
message: string;
tone?: "danger" | "success" | "warn";
}) {
const toneClass = tone === "success"
? "border-[color:var(--success)] bg-[color:var(--success-soft)] text-[color:var(--success)]"
: tone === "warn"
? "border-[color:var(--warn)] bg-[color:var(--warn-soft)] text-[color:var(--warn)]"
: "border-[color:var(--danger)] bg-[color:var(--danger-soft)] text-[color:var(--danger)]";
return (
<div className={`rounded-[10px] border px-4 py-3 text-[13px] ${toneClass}`} role={tone === "danger" ? "alert" : "status"}>
{message}
</div>
);
}
/* ─── Confirm Dialog ─────────────────────────────────────────────────── */
export function ConfirmDialog({ open, title, message, errorMessage, onConfirm, onCancel }: {
open: boolean;
title: string;
message: string;
errorMessage?: string | null;
onConfirm: () => void;
onCancel: () => void;
}) {
const trapRef = useFocusTrap(open);
useEffect(() => {
if (!open) return;
function onKey(event: KeyboardEvent) {
if (event.key === "Escape") onCancel();
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, onCancel]);
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[color:var(--text)]/45" onClick={onCancel}>
<div
ref={trapRef}
role="dialog"
aria-modal="true"
aria-labelledby="confirm-dialog-title"
className="w-full max-w-[400px] rounded-[12px] bg-[color:var(--panel)] border border-[color:var(--line)] p-6 shadow-lg"
onClick={e => e.stopPropagation()}
>
<div id="confirm-dialog-title" className="text-[16px] font-semibold text-[color:var(--text)] mb-2">{title}</div>
<div className="text-[13px] text-[color:var(--muted)] mb-6">{message}</div>
{errorMessage ? <div className="mb-4"><InlineNotice message={errorMessage} /></div> : null}
<div className="flex gap-3 justify-end">
<ActionButton label="Cancel" onClick={onCancel} />
<ActionButton label="Confirm" tone="danger" onClick={onConfirm} />
</div>
</div>
</div>
);
}
/* ─── Entity-type icon lookup ────────────────────────────────────────── */
const ENTITY_ICONS: Record<string, typeof Robot> = { const ENTITY_ICONS: Record<string, typeof Robot> = {
agent: Robot, agent: Robot,
@@ -127,7 +64,6 @@ const ENTITY_TYPE_OPTIONS = [
"all", "agent", "task", "run", "approval", "connector", "workspace", "secret", "goal", "project", "company", "all", "agent", "task", "run", "approval", "connector", "workspace", "secret", "goal", "project", "company",
] as const; ] as const;
/* ─── Workspace state pills ──────────────────────────────────────────── */
function WorkspaceStatePill({ state }: { state: string }) { function WorkspaceStatePill({ state }: { state: string }) {
const colorMap: Record<string, string> = { const colorMap: Record<string, string> = {
@@ -144,7 +80,6 @@ function WorkspaceStatePill({ state }: { state: string }) {
); );
} }
/* ─── Priority indicator ─────────────────────────────────────────────── */
function PriorityDot({ priority }: { priority: string }) { function PriorityDot({ priority }: { priority: string }) {
const color = priority === "critical" const color = priority === "critical"
@@ -157,7 +92,6 @@ function PriorityDot({ priority }: { priority: string }) {
return <span className={`inline-block h-2 w-2 shrink-0 rounded-full ${color}`} aria-label={`${priority} priority`} />; return <span className={`inline-block h-2 w-2 shrink-0 rounded-full ${color}`} aria-label={`${priority} priority`} />;
} }
/* ─── Task Queue ─────────────────────────────────────────────────────── */
export function TaskQueue({ export function TaskQueue({
tasks, tasks,
@@ -204,7 +138,6 @@ export function TaskQueue({
); );
} }
/* ─── Task Inspector ─────────────────────────────────────────────────── */
export function TaskInspector({ export function TaskInspector({
task, task,
@@ -571,7 +504,6 @@ export function TaskInspector({
); );
} }
/* ─── Task Comments ──────────────────────────────────────────────────── */
function TaskComments({ function TaskComments({
taskId, taskId,
@@ -666,7 +598,6 @@ function TaskComments({
); );
} }
/* ─── Task Creator ───────────────────────────────────────────────────── */
export function TaskCreator({ export function TaskCreator({
companyId, companyId,
@@ -741,7 +672,6 @@ export function TaskCreator({
); );
} }
/* ─── Pending Review List ────────────────────────────────────────────── */
export function PendingReviewList({ tasks, agents }: { tasks: TaskRecord[]; agents: AgentRecord[] }) { export function PendingReviewList({ tasks, agents }: { tasks: TaskRecord[]; agents: AgentRecord[] }) {
const reviewTasks = tasks.filter((t) => t.status === "in_review"); const reviewTasks = tasks.filter((t) => t.status === "in_review");
@@ -770,7 +700,6 @@ export function PendingReviewList({ tasks, agents }: { tasks: TaskRecord[]; agen
); );
} }
/* ─── Approval List ──────────────────────────────────────────────────── */
export function ApprovalList({ approvals, selectedApprovalId, onSelect }: { approvals: ApprovalRecord[]; selectedApprovalId: string | null; onSelect: (id: string) => void }) { export function ApprovalList({ approvals, selectedApprovalId, onSelect }: { approvals: ApprovalRecord[]; selectedApprovalId: string | null; onSelect: (id: string) => void }) {
if (approvals.length === 0) { if (approvals.length === 0) {
@@ -801,7 +730,6 @@ export function ApprovalList({ approvals, selectedApprovalId, onSelect }: { appr
); );
} }
/* ─── Approval Inspector ─────────────────────────────────────────────── */
export function ApprovalInspector({ export function ApprovalInspector({
approval, approval,
@@ -915,7 +843,6 @@ export function ApprovalInspector({
{/* Hire agent payload details */} {/* Hire agent payload details */}
{approval.type === "hire_agent" ? (() => { {approval.type === "hire_agent" ? (() => {
// Find the agent that was proposed for hiring (look for pending_approval agents)
const proposedAgent = agents.find( const proposedAgent = agents.find(
(a) => a.status === "pending_approval" && a.reportsTo === approval.requestedByAgentId (a) => a.status === "pending_approval" && a.reportsTo === approval.requestedByAgentId
) ?? agents.find( ) ?? agents.find(
@@ -982,7 +909,6 @@ export function ApprovalInspector({
); );
} }
/* ─── Cost List ──────────────────────────────────────────────────────── */
export function CostList({ costs, agents, runs }: { costs: ProfileSnapshot["costs"]; agents: ProfileSnapshot["agents"]; runs: ProfileSnapshot["runs"] }) { export function CostList({ costs, agents, runs }: { costs: ProfileSnapshot["costs"]; agents: ProfileSnapshot["agents"]; runs: ProfileSnapshot["runs"] }) {
const [connectorFilter, setConnectorFilter] = useState("all"); const [connectorFilter, setConnectorFilter] = useState("all");
@@ -1035,7 +961,6 @@ export function CostList({ costs, agents, runs }: { costs: ProfileSnapshot["cost
); );
} }
/* ─── Budget Summary ─────────────────────────────────────────────────── */
export function BudgetSummary({ agents, costs }: { agents: ProfileSnapshot["agents"]; costs: ProfileSnapshot["costs"] }) { export function BudgetSummary({ agents, costs }: { agents: ProfileSnapshot["agents"]; costs: ProfileSnapshot["costs"] }) {
const knownSpend = costs.reduce((sum, entry) => sum + (entry.amountUsd ?? 0), 0); const knownSpend = costs.reduce((sum, entry) => sum + (entry.amountUsd ?? 0), 0);
@@ -1101,7 +1026,6 @@ export function BudgetSummary({ agents, costs }: { agents: ProfileSnapshot["agen
); );
} }
/* ─── Activity List ──────────────────────────────────────────────────── */
export function ActivityList({ activity }: { activity: ActivityRecord[] }) { export function ActivityList({ activity }: { activity: ActivityRecord[] }) {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
@@ -1187,18 +1111,21 @@ export function ActivityList({ activity }: { activity: ActivityRecord[] }) {
); );
} }
/* ─── Workspace List ─────────────────────────────────────────────────── */
export function WorkspaceList({ export function WorkspaceList({
workspaces, workspaces,
projects, projects,
companyId, companyId,
selectedWorkspaceId,
onSelect,
onOpen, onOpen,
onDeleted, onDeleted,
}: { }: {
workspaces: WorkspaceRecord[]; workspaces: WorkspaceRecord[];
projects: ProjectRecord[]; projects: ProjectRecord[];
companyId: string; companyId: string;
selectedWorkspaceId: string | null;
onSelect: (workspaceId: string) => void;
onOpen: (path: string) => Promise<void>; onOpen: (path: string) => Promise<void>;
onDeleted: () => Promise<void>; onDeleted: () => Promise<void>;
}) { }) {
@@ -1238,7 +1165,8 @@ export function WorkspaceList({
const linkedProject = projects.find((p) => p.id === workspace.projectId) ?? null; const linkedProject = projects.find((p) => p.id === workspace.projectId) ?? null;
return ( return (
<ListRow key={workspace.id}> <ListRow key={workspace.id}>
<div className="mb-2 flex items-center justify-between"> <div className={`mb-2 rounded-[8px] p-1 ${selectedWorkspaceId === workspace.id ? "bg-[color:var(--accent-soft)]" : ""}`}>
<div className="flex items-center justify-between">
<div> <div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="text-[14px] text-[color:var(--text)]">{workspace.name}</div> <div className="text-[14px] text-[color:var(--text)]">{workspace.name}</div>
@@ -1248,6 +1176,7 @@ export function WorkspaceList({
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{workspace.isPrimary ? <StatusPill status="ready" /> : null} {workspace.isPrimary ? <StatusPill status="ready" /> : null}
<ActionButton label="Inspect" onClick={() => onSelect(workspace.id)} icon={FolderOpen} />
<ActionButton label="Reveal" onClick={() => void onOpen(workspace.localPath)} icon={ArrowSquareOut} /> <ActionButton label="Reveal" onClick={() => void onOpen(workspace.localPath)} icon={ArrowSquareOut} />
<ActionButton label="Delete" onClick={() => setConfirmDeleteId(workspace.id)} icon={Trash} tone="danger" /> <ActionButton label="Delete" onClick={() => setConfirmDeleteId(workspace.id)} icon={Trash} tone="danger" />
</div> </div>
@@ -1256,6 +1185,7 @@ export function WorkspaceList({
{workspace.repoUrl || "No remote URL"} {workspace.repoRef ? `· ${workspace.repoRef}` : ""} {workspace.repoUrl || "No remote URL"} {workspace.repoRef ? `· ${workspace.repoRef}` : ""}
{linkedProject ? <span> · Project: <span className="text-[color:var(--text)]">{linkedProject.name}</span></span> : null} {linkedProject ? <span> · Project: <span className="text-[color:var(--text)]">{linkedProject.name}</span></span> : null}
</div> </div>
</div>
</ListRow> </ListRow>
); );
})} })}
@@ -1264,7 +1194,133 @@ export function WorkspaceList({
); );
} }
/* ─── Workspace Creator ──────────────────────────────────────────────── */ export function WorkspaceInspector({
workspace,
agents,
tasks,
runs,
documents,
project,
onOpen,
}: {
workspace: WorkspaceRecord | null;
agents: AgentRecord[];
tasks: TaskRecord[];
runs: RunRecord[];
documents: DocumentRecord[];
project: ProjectRecord | null;
onOpen: (path: string) => Promise<void>;
}) {
if (!workspace) {
return <EmptyState title="Select a workspace" detail="Choose a workspace to inspect the agents, tasks, runs, and documents bound to it." />;
}
const workspaceAgents = agents.filter((agent) => agent.workspaceId === workspace.id);
const workspaceTasks = tasks.filter((task) => task.workspaceId === workspace.id);
const workspaceRuns = runs.filter((run) => run.workspaceId === workspace.id);
const workspaceDocuments = documents.filter((document) => document.projectId && project && document.projectId === project.id);
return (
<div className="space-y-5">
<div className="rounded-[10px] border border-[color:var(--line)] bg-[color:var(--panel)] p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<div className="text-[15px] font-semibold text-[color:var(--text)]">{workspace.name}</div>
<WorkspaceStatePill state={workspace.state} />
</div>
<div className="mono mt-1 text-[11px] text-[color:var(--muted)]">{workspace.localPath}</div>
<div className="mt-2 text-[12px] text-[color:var(--muted)]">
{project ? <>Project: <span className="text-[color:var(--text)]">{project.name}</span></> : "No linked project"}
{workspace.repoUrl ? <span> · Repo: <span className="text-[color:var(--text)]">{workspace.repoUrl}</span></span> : null}
{workspace.repoRef ? <span> · Ref: <span className="text-[color:var(--text)]">{workspace.repoRef}</span></span> : null}
</div>
</div>
<ActionButton label="Reveal in Finder" onClick={() => void onOpen(workspace.localPath)} icon={ArrowSquareOut} />
</div>
</div>
<div className="grid gap-3 md:grid-cols-4">
<div className="rounded-[10px] border border-[color:var(--line)] bg-[color:var(--panel)] p-4">
<div className="text-[11px] uppercase tracking-[0.08em] text-[color:var(--muted)]">Agents</div>
<div className="mt-2 text-[24px] font-semibold text-[color:var(--text)]">{workspaceAgents.length}</div>
</div>
<div className="rounded-[10px] border border-[color:var(--line)] bg-[color:var(--panel)] p-4">
<div className="text-[11px] uppercase tracking-[0.08em] text-[color:var(--muted)]">Tasks</div>
<div className="mt-2 text-[24px] font-semibold text-[color:var(--text)]">{workspaceTasks.length}</div>
</div>
<div className="rounded-[10px] border border-[color:var(--line)] bg-[color:var(--panel)] p-4">
<div className="text-[11px] uppercase tracking-[0.08em] text-[color:var(--muted)]">Runs</div>
<div className="mt-2 text-[24px] font-semibold text-[color:var(--text)]">{workspaceRuns.length}</div>
</div>
<div className="rounded-[10px] border border-[color:var(--line)] bg-[color:var(--panel)] p-4">
<div className="text-[11px] uppercase tracking-[0.08em] text-[color:var(--muted)]">Docs</div>
<div className="mt-2 text-[24px] font-semibold text-[color:var(--text)]">{workspaceDocuments.length}</div>
</div>
</div>
<div className="grid gap-4 xl:grid-cols-3">
<div className="rounded-[10px] border border-[color:var(--line)] bg-[color:var(--panel)] p-4">
<div className="mb-3 text-[12px] font-semibold text-[color:var(--text)]">Assigned Agents</div>
{workspaceAgents.length === 0 ? (
<div className="text-[12px] text-[color:var(--muted)]">No agents are currently bound to this workspace.</div>
) : (
<div className="space-y-2">
{workspaceAgents.map((agent) => (
<div key={agent.id} className="flex items-center justify-between rounded-[8px] bg-[color:var(--panel-soft)] px-3 py-2">
<div>
<div className="text-[12px] font-medium text-[color:var(--text)]">{agent.name}</div>
<div className="text-[11px] text-[color:var(--muted)]">{agent.role}</div>
</div>
<StatusPill status={agent.status} />
</div>
))}
</div>
)}
</div>
<div className="rounded-[10px] border border-[color:var(--line)] bg-[color:var(--panel)] p-4">
<div className="mb-3 text-[12px] font-semibold text-[color:var(--text)]">Open Tasks</div>
{workspaceTasks.length === 0 ? (
<div className="text-[12px] text-[color:var(--muted)]">No tasks target this workspace yet.</div>
) : (
<div className="space-y-2">
{workspaceTasks.slice(0, 6).map((task) => (
<div key={task.id} className="rounded-[8px] bg-[color:var(--panel-soft)] px-3 py-2">
<div className="flex items-center gap-2">
<PriorityDot priority={task.priority} />
<span className="text-[12px] font-medium text-[color:var(--text)]">{task.title}</span>
</div>
<div className="mt-1 text-[11px] text-[color:var(--muted)]">{task.status.replaceAll("_", " ")}</div>
</div>
))}
</div>
)}
</div>
<div className="rounded-[10px] border border-[color:var(--line)] bg-[color:var(--panel)] p-4">
<div className="mb-3 text-[12px] font-semibold text-[color:var(--text)]">Recent Runs</div>
{workspaceRuns.length === 0 ? (
<div className="text-[12px] text-[color:var(--muted)]">No runs have executed in this workspace.</div>
) : (
<div className="space-y-2">
{workspaceRuns.slice(0, 6).map((run) => (
<div key={run.id} className="rounded-[8px] bg-[color:var(--panel-soft)] px-3 py-2">
<div className="flex items-center justify-between gap-2">
<span className="text-[12px] font-medium text-[color:var(--text)]">{run.summary || `Run ${run.id.slice(0, 8)}`}</span>
<StatusPill status={run.status} />
</div>
<div className="mt-1 text-[11px] text-[color:var(--muted)]">{formatTime(run.startedAt)} → {formatTime(run.finishedAt)}</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
);
}
export function WorkspaceCreator({ companyId, projects, onSaved }: { companyId: string; projects: ProjectRecord[]; onSaved: () => Promise<void> }) { export function WorkspaceCreator({ companyId, projects, onSaved }: { companyId: string; projects: ProjectRecord[]; onSaved: () => Promise<void> }) {
const [name, setName] = useState(""); const [name, setName] = useState("");
@@ -1332,7 +1388,6 @@ export function WorkspaceCreator({ companyId, projects, onSaved }: { companyId:
); );
} }
/* ─── Settings Pane ──────────────────────────────────────────────────── */
export function SettingsPane({ export function SettingsPane({
snapshot, snapshot,
@@ -1569,7 +1624,6 @@ export function SettingsPane({
); );
} }
/* ─── Secret Creator ─────────────────────────────────────────────────── */
export function SecretCreator({ companyId, onSaved }: { companyId: string; onSaved: () => Promise<void> }) { export function SecretCreator({ companyId, onSaved }: { companyId: string; onSaved: () => Promise<void> }) {
const [name, setName] = useState(""); const [name, setName] = useState("");
+58 -63
View File
@@ -6,10 +6,10 @@ import {
X, X,
} from "@phosphor-icons/react"; } from "@phosphor-icons/react";
import type { AgentRecord, ApprovalRecord } from "@shared/types"; import type { AgentRecord, ApprovalRecord } from "@shared/types";
import { getAgentInitials } from "../lib/formatters";
import { ActionButton, EmptyState, Panel, StatusPill } from "./ui"; import { ActionButton, EmptyState, Panel, StatusPill } from "./ui";
interface HiringPipelineProps { interface HiringPipelineProps {
companyId: string;
agents: AgentRecord[]; agents: AgentRecord[];
approvals: ApprovalRecord[]; approvals: ApprovalRecord[];
onRefresh: () => Promise<void>; onRefresh: () => Promise<void>;
@@ -19,7 +19,7 @@ interface HiringPipelineProps {
type HiringStage = "requisition" | "pending_approval" | "approved" | "onboarding" | "active" | "rejected"; type HiringStage = "requisition" | "pending_approval" | "approved" | "onboarding" | "active" | "rejected";
interface HiringCandidate { interface HiringCandidate {
agent: AgentRecord; agent: AgentRecord | null;
approval: ApprovalRecord | null; approval: ApprovalRecord | null;
stage: HiringStage; stage: HiringStage;
} }
@@ -33,7 +33,6 @@ function getHiringStage(agent: AgentRecord, approval: ApprovalRecord | null): Hi
return "approved"; return "approved";
} }
if (agent.status === "idle" && approval?.state === "approved") { if (agent.status === "idle" && approval?.state === "approved") {
// Recently approved — check if they have any tasks yet
return "onboarding"; return "onboarding";
} }
return "active"; return "active";
@@ -52,27 +51,28 @@ function stageLabel(stage: HiringStage): string {
function stageColor(stage: HiringStage): string { function stageColor(stage: HiringStage): string {
switch (stage) { switch (stage) {
case "requisition": return "bg-amber-500/20 text-amber-400"; case "requisition": return "bg-[color:var(--warn-soft)] text-[color:var(--warn)]";
case "pending_approval": return "bg-yellow-500/20 text-yellow-400"; case "pending_approval": return "bg-[color:var(--warn-soft)] text-[color:var(--warn)]";
case "approved": return "bg-green-500/20 text-green-400"; case "approved": return "bg-[color:var(--success-soft)] text-[color:var(--success)]";
case "onboarding": return "bg-blue-500/20 text-blue-400"; case "onboarding": return "bg-[color:var(--accent-soft)] text-[color:var(--accent)]";
case "active": return "bg-emerald-500/20 text-emerald-400"; case "active": return "bg-[color:var(--success-soft)] text-[color:var(--success)]";
case "rejected": return "bg-red-500/20 text-red-400"; case "rejected": return "bg-[color:var(--danger-soft)] text-[color:var(--danger)]";
} }
} }
const STAGE_ORDER: HiringStage[] = ["requisition", "pending_approval", "approved", "onboarding", "active", "rejected"]; const STAGE_ORDER: HiringStage[] = ["requisition", "pending_approval", "approved", "onboarding", "active", "rejected"];
export function HiringPipeline({ companyId: _companyId, agents, approvals, onRefresh, onNavigateToAgent }: HiringPipelineProps) { export function HiringPipeline({ agents, approvals, onRefresh, onNavigateToAgent }: HiringPipelineProps) {
const [filterStage, setFilterStage] = useState<HiringStage | "all">("all"); const [filterStage, setFilterStage] = useState<HiringStage | "all">("all");
const hireApprovals = approvals.filter(a => a.type === "hire_agent"); const hireApprovals = approvals.filter(a => a.type === "hire_agent");
const candidates: HiringCandidate[] = agents.map(agent => { const candidates: HiringCandidate[] = agents.map(agent => {
const approval = hireApprovals.find(a => const approval = hireApprovals.find(a => {
a.relatedAgentId === agent.id || if (a.relatedAgentId === agent.id) return true;
(a.payloadJson && (() => { try { const p = JSON.parse(a.payloadJson); return p.agentId === agent.id; } catch { return false; } })()) if (!a.payloadJson) return false;
) ?? null; try { return JSON.parse(a.payloadJson).agentId === agent.id; } catch { return false; }
}) ?? null;
return { return {
agent, agent,
approval, approval,
@@ -80,7 +80,6 @@ export function HiringPipeline({ companyId: _companyId, agents, approvals, onRef
}; };
}); });
// Also include hire approvals that don't yet have an agent (pre-creation stage)
const pendingHireApprovals = hireApprovals.filter(a => { const pendingHireApprovals = hireApprovals.filter(a => {
return a.state === "pending" && !candidates.some(c => c.approval?.id === a.id); return a.state === "pending" && !candidates.some(c => c.approval?.id === a.id);
}); });
@@ -88,7 +87,7 @@ export function HiringPipeline({ companyId: _companyId, agents, approvals, onRef
const allCandidates = [ const allCandidates = [
...candidates, ...candidates,
...pendingHireApprovals.map(a => ({ ...pendingHireApprovals.map(a => ({
agent: null as unknown as AgentRecord, agent: null,
approval: a, approval: a,
stage: "pending_approval" as HiringStage, stage: "pending_approval" as HiringStage,
})), })),
@@ -98,7 +97,6 @@ export function HiringPipeline({ companyId: _companyId, agents, approvals, onRef
? allCandidates ? allCandidates
: allCandidates.filter(c => c.stage === filterStage); : allCandidates.filter(c => c.stage === filterStage);
// Stage counts for the pipeline overview
const stageCounts = STAGE_ORDER.reduce((acc, stage) => { const stageCounts = STAGE_ORDER.reduce((acc, stage) => {
acc[stage] = allCandidates.filter(c => c.stage === stage).length; acc[stage] = allCandidates.filter(c => c.stage === stage).length;
return acc; return acc;
@@ -109,7 +107,6 @@ export function HiringPipeline({ companyId: _companyId, agents, approvals, onRef
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* Pipeline Overview */}
<Panel title="Hiring Pipeline" eyebrow={`${activeHires} active hiring processes`}> <Panel title="Hiring Pipeline" eyebrow={`${activeHires} active hiring processes`}>
<div className="flex items-center gap-1 px-4 py-3 overflow-x-auto"> <div className="flex items-center gap-1 px-4 py-3 overflow-x-auto">
{STAGE_ORDER.filter(s => s !== "rejected").map((stage, idx) => ( {STAGE_ORDER.filter(s => s !== "rejected").map((stage, idx) => (
@@ -117,16 +114,16 @@ export function HiringPipeline({ companyId: _companyId, agents, approvals, onRef
<button <button
onClick={() => setFilterStage(filterStage === stage ? "all" : stage)} onClick={() => setFilterStage(filterStage === stage ? "all" : stage)}
className={`px-3 py-2 rounded-md text-xs font-medium transition-colors whitespace-nowrap ${ 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" filterStage === stage ? "ring-1 ring-[color:var(--accent)]/40 bg-[color:var(--panel-soft)]" : "hover:bg-[color:var(--panel-soft)]"
} ${stageColor(stage)}`} } ${stageColor(stage)}`}
> >
{stageLabel(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]"> <span className="ml-1.5 inline-flex items-center justify-center h-5 w-5 rounded-full bg-[color:var(--line)] text-[10px]">
{stageCounts[stage]} {stageCounts[stage]}
</span> </span>
</button> </button>
{idx < STAGE_ORDER.length - 2 && ( {idx < STAGE_ORDER.length - 2 && (
<ArrowRight size={12} className="text-zinc-600 flex-shrink-0" /> <ArrowRight size={12} className="text-[color:var(--muted)] flex-shrink-0" />
)} )}
</div> </div>
))} ))}
@@ -134,11 +131,11 @@ export function HiringPipeline({ companyId: _companyId, agents, approvals, onRef
<button <button
onClick={() => setFilterStage(filterStage === "rejected" ? "all" : "rejected")} onClick={() => setFilterStage(filterStage === "rejected" ? "all" : "rejected")}
className={`ml-4 px-3 py-2 rounded-md text-xs font-medium transition-colors ${ 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" filterStage === "rejected" ? "ring-1 ring-[color:var(--danger)]/40 bg-[color:var(--panel-soft)]" : "hover:bg-[color:var(--panel-soft)]"
} ${stageColor("rejected")}`} } ${stageColor("rejected")}`}
> >
Rejected Rejected
<span className="ml-1.5 inline-flex items-center justify-center h-5 w-5 rounded-full bg-zinc-800 text-[10px]"> <span className="ml-1.5 inline-flex items-center justify-center h-5 w-5 rounded-full bg-[color:var(--line)] text-[10px]">
{stageCounts.rejected} {stageCounts.rejected}
</span> </span>
</button> </button>
@@ -146,10 +143,9 @@ export function HiringPipeline({ companyId: _companyId, agents, approvals, onRef
</div> </div>
</Panel> </Panel>
{/* Pending Approvals Section */}
{pendingCount > 0 && ( {pendingCount > 0 && (
<Panel title="Awaiting Board Decision" eyebrow={`${pendingCount} hire requests need your review`}> <Panel title="Awaiting Board Decision" eyebrow={`${pendingCount} hire requests need your review`}>
<div className="divide-y divide-zinc-800/60"> <div className="divide-y divide-[color:var(--line)]">
{allCandidates {allCandidates
.filter(c => c.stage === "requisition" || c.stage === "pending_approval") .filter(c => c.stage === "requisition" || c.stage === "pending_approval")
.map((candidate) => { .map((candidate) => {
@@ -157,32 +153,32 @@ export function HiringPipeline({ companyId: _companyId, agents, approvals, onRef
const ag = candidate.agent; const ag = candidate.agent;
return ( return (
<div key={a?.id ?? ag?.id} className="px-4 py-3 flex items-start gap-3"> <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"> <div className="flex-shrink-0 mt-0.5 h-8 w-8 rounded-full bg-[color:var(--warn-soft)] flex items-center justify-center">
<UserPlus size={16} className="text-amber-400" /> <UserPlus size={16} className="text-[color:var(--warn)]" />
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-medium text-sm text-zinc-200">{ag?.name ?? "Proposed Agent"}</span> <span className="font-medium text-sm text-[color:var(--text)]">{ag?.name ?? "Proposed Agent"}</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded ${stageColor(candidate.stage)}`}> <span className={`text-[10px] px-1.5 py-0.5 rounded ${stageColor(candidate.stage)}`}>
{stageLabel(candidate.stage)} {stageLabel(candidate.stage)}
</span> </span>
</div> </div>
<div className="text-xs text-zinc-400 mt-0.5"> <div className="text-xs text-[color:var(--muted)] mt-0.5">
{ag ? `${ag.role} — ${ag.title}` : a?.payloadSummary} {ag ? `${ag.role} — ${ag.title}` : a?.payloadSummary}
</div> </div>
{ag?.department && ( {ag?.department && (
<div className="text-xs text-zinc-500 mt-0.5"> <div className="text-xs text-[color:var(--muted)] mt-0.5">
Dept: {ag.department} | Budget: ${ag.budgetMonthlyUsd}/mo | Connector: {ag.connectorId} Dept: {ag.department} | Budget: ${ag.budgetMonthlyUsd}/mo | Connector: {ag.connectorId}
</div> </div>
)} )}
{a?.payloadSummary && ( {a?.payloadSummary && (
<div className="text-xs text-zinc-500 mt-1">{a.payloadSummary}</div> <div className="text-xs text-[color:var(--muted)] mt-1">{a.payloadSummary}</div>
)} )}
{a?.impactSummary && ( {a?.impactSummary && (
<div className="text-xs text-zinc-500">{a.impactSummary}</div> <div className="text-xs text-[color:var(--muted)]">{a.impactSummary}</div>
)} )}
{ag?.capabilities && ( {ag?.capabilities && (
<div className="text-xs text-zinc-600 mt-1 line-clamp-2">{ag.capabilities}</div> <div className="text-xs text-[color:var(--muted)] mt-1 line-clamp-2">{ag.capabilities}</div>
)} )}
</div> </div>
<div className="flex items-center gap-1.5 flex-shrink-0"> <div className="flex items-center gap-1.5 flex-shrink-0">
@@ -194,6 +190,7 @@ export function HiringPipeline({ companyId: _companyId, agents, approvals, onRef
tone="accent" tone="accent"
onClick={async () => { onClick={async () => {
await window.agentCompany.decideApproval({ await window.agentCompany.decideApproval({
companyId: a.companyId,
approvalId: a.id, approvalId: a.id,
state: "approved", state: "approved",
decisionNote: "Hire approved via hiring pipeline", decisionNote: "Hire approved via hiring pipeline",
@@ -207,6 +204,7 @@ export function HiringPipeline({ companyId: _companyId, agents, approvals, onRef
tone="danger" tone="danger"
onClick={async () => { onClick={async () => {
await window.agentCompany.decideApproval({ await window.agentCompany.decideApproval({
companyId: a.companyId,
approvalId: a.id, approvalId: a.id,
state: "rejected", state: "rejected",
decisionNote: "Hire rejected via hiring pipeline", decisionNote: "Hire rejected via hiring pipeline",
@@ -224,7 +222,6 @@ export function HiringPipeline({ companyId: _companyId, agents, approvals, onRef
</Panel> </Panel>
)} )}
{/* Full Pipeline List */}
<Panel <Panel
title={filterStage === "all" ? "All Agents" : `${stageLabel(filterStage)} (${filteredCandidates.length})`} title={filterStage === "all" ? "All Agents" : `${stageLabel(filterStage)} (${filteredCandidates.length})`}
action={ action={
@@ -239,7 +236,7 @@ export function HiringPipeline({ companyId: _companyId, agents, approvals, onRef
detail="When agents propose new hires via the API, they will appear here for your review." detail="When agents propose new hires via the API, they will appear here for your review."
/> />
) : ( ) : (
<div className="divide-y divide-zinc-800/60"> <div className="divide-y divide-[color:var(--line)]">
{filteredCandidates.map((candidate) => { {filteredCandidates.map((candidate) => {
const ag = candidate.agent; const ag = candidate.agent;
if (!ag) return null; if (!ag) return null;
@@ -250,32 +247,32 @@ export function HiringPipeline({ companyId: _companyId, agents, approvals, onRef
return ( return (
<div <div
key={ag.id} key={ag.id}
className="px-4 py-3 flex items-center gap-3 hover:bg-zinc-800/40 cursor-pointer transition-colors" className="px-4 py-3 flex items-center gap-3 hover:bg-[color:var(--panel-soft)] cursor-pointer transition-colors"
onClick={() => onNavigateToAgent(ag.id)} 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"> <div className="flex-shrink-0 h-8 w-8 rounded-full bg-[color:var(--panel-soft)] flex items-center justify-center text-xs font-bold text-[color:var(--muted-strong)]">
{ag.name.split(" ").map(n => n[0]).join("").slice(0, 2)} {getAgentInitials(ag.name)}
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-medium text-sm text-zinc-200 truncate">{ag.name}</span> <span className="font-medium text-sm text-[color:var(--text)] truncate">{ag.name}</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded ${stageColor(candidate.stage)}`}> <span className={`text-[10px] px-1.5 py-0.5 rounded ${stageColor(candidate.stage)}`}>
{stageLabel(candidate.stage)} {stageLabel(candidate.stage)}
</span> </span>
<StatusPill status={ag.status} /> <StatusPill status={ag.status} />
</div> </div>
<div className="text-xs text-zinc-400 mt-0.5"> <div className="text-xs text-[color:var(--muted)] mt-0.5">
{ag.role} — {ag.title}{ag.department ? ` | ${ag.department}` : ""} {ag.role} — {ag.title}{ag.department ? ` | ${ag.department}` : ""}
</div> </div>
</div> </div>
<div className="flex-shrink-0 text-right"> <div className="flex-shrink-0 text-right">
<div className="text-xs text-zinc-500"> <div className="text-xs text-[color:var(--muted)]">
{manager ? `Reports to ${manager.name}` : "Top level"} {manager ? `Reports to ${manager.name}` : "Top level"}
</div> </div>
{requestedBy && ( {requestedBy && (
<div className="text-[10px] text-zinc-600">Proposed by {requestedBy.name}</div> <div className="text-[10px] text-[color:var(--muted)]">Proposed by {requestedBy.name}</div>
)} )}
<div className="text-xs text-zinc-500">${ag.budgetMonthlyUsd}/mo</div> <div className="text-xs text-[color:var(--muted)]">${ag.budgetMonthlyUsd}/mo</div>
</div> </div>
</div> </div>
); );
@@ -284,31 +281,29 @@ export function HiringPipeline({ companyId: _companyId, agents, approvals, onRef
)} )}
</Panel> </Panel>
{/* Hiring Statistics */}
<div className="grid grid-cols-4 gap-3"> <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="rounded-lg px-4 py-3 border border-[color:var(--line)] bg-[color:var(--panel)]">
<div className="text-[10px] uppercase text-zinc-500 tracking-wider">Total Agents</div> <div className="text-[10px] uppercase text-[color:var(--muted)] 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 className="text-2xl font-bold text-[color:var(--text)] mt-1">{agents.filter(a => a.status !== "terminated").length}</div>
</div> </div>
<div className="bg-zinc-900/60 rounded-lg px-4 py-3 border border-zinc-800/40"> <div className="rounded-lg px-4 py-3 border border-[color:var(--line)] bg-[color:var(--panel)]">
<div className="text-[10px] uppercase text-zinc-500 tracking-wider">Pending Hires</div> <div className="text-[10px] uppercase text-[color:var(--muted)] 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 className="text-2xl font-bold text-[color:var(--warn)] mt-1">{agents.filter(a => a.status === "pending_approval").length}</div>
</div> </div>
<div className="bg-zinc-900/60 rounded-lg px-4 py-3 border border-zinc-800/40"> <div className="rounded-lg px-4 py-3 border border-[color:var(--line)] bg-[color:var(--panel)]">
<div className="text-[10px] uppercase text-zinc-500 tracking-wider">Departments</div> <div className="text-[10px] uppercase text-[color:var(--muted)] tracking-wider">Departments</div>
<div className="text-2xl font-bold text-zinc-200 mt-1"> <div className="text-2xl font-bold text-[color:var(--text)] mt-1">
{new Set(agents.filter(a => a.department && a.status !== "terminated").map(a => a.department)).size} {new Set(agents.filter(a => a.department && a.status !== "terminated").map(a => a.department)).size}
</div> </div>
</div> </div>
<div className="bg-zinc-900/60 rounded-lg px-4 py-3 border border-zinc-800/40"> <div className="rounded-lg px-4 py-3 border border-[color:var(--line)] bg-[color:var(--panel)]">
<div className="text-[10px] uppercase text-zinc-500 tracking-wider">Monthly Payroll</div> <div className="text-[10px] uppercase text-[color:var(--muted)] tracking-wider">Monthly Payroll</div>
<div className="text-2xl font-bold text-zinc-200 mt-1"> <div className="text-2xl font-bold text-[color:var(--text)] mt-1">
${agents.filter(a => a.status !== "terminated" && a.status !== "pending_approval").reduce((s, a) => s + a.budgetMonthlyUsd, 0).toLocaleString()} ${agents.filter(a => a.status !== "terminated" && a.status !== "pending_approval").reduce((s, a) => s + a.budgetMonthlyUsd, 0).toLocaleString()}
</div> </div>
</div> </div>
</div> </div>
{/* Department Headcount */}
<Panel title="Department Headcount"> <Panel title="Department Headcount">
<div className="grid grid-cols-3 gap-2 p-4"> <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 => { {(["executive", "engineering", "product", "design", "marketing", "sales", "hr", "finance", "legal", "customer_support", "operations", "research"] as const).map(dept => {
@@ -317,15 +312,15 @@ export function HiringPipeline({ companyId: _companyId, agents, approvals, onRef
const active = 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; if (active === 0 && pending === 0) return null;
return ( return (
<div key={dept} className="bg-zinc-800/40 rounded-md px-3 py-2"> <div key={dept} className="bg-[color:var(--panel-soft)] rounded-md px-3 py-2">
<div className="text-xs font-medium text-zinc-300 capitalize">{dept.replace("_", " ")}</div> <div className="text-xs font-medium text-[color:var(--text)] capitalize">{dept.replace("_", " ")}</div>
<div className="flex items-center gap-2 mt-1"> <div className="flex items-center gap-2 mt-1">
<span className="text-sm font-bold text-zinc-200">{active}</span> <span className="text-sm font-bold text-[color:var(--text)]">{active}</span>
<span className="text-[10px] text-zinc-500">active</span> <span className="text-[10px] text-[color:var(--muted)]">active</span>
{pending > 0 && ( {pending > 0 && (
<> <>
<span className="text-sm font-bold text-amber-400">+{pending}</span> <span className="text-sm font-bold text-[color:var(--warn)]">+{pending}</span>
<span className="text-[10px] text-amber-400/70">pending</span> <span className="text-[10px] text-[color:var(--warn)]">pending</span>
</> </>
)} )}
</div> </div>
+19 -71
View File
@@ -10,19 +10,14 @@ import {
CheckCircle, CheckCircle,
Clock, Clock,
Eye, Eye,
FunnelSimple,
Robot, Robot,
Heartbeat, Heartbeat,
Target, Target,
ChatCircle, ChatCircle,
Tray, Tray,
} from "@phosphor-icons/react"; } from "@phosphor-icons/react";
import type { InboxItem, InboxItemType, SectionId, StandupReport, AgentMetrics, CompanyMetrics } from "@shared/types"; import type { InboxItem, InboxItemType, SectionId, StandupReport, CompanyMetrics } from "@shared/types";
import { formatMoney } from "../lib/formatters"; import { formatMoney, timeAgo, getAgentInitials, formatDuration } from "../lib/formatters";
/* ------------------------------------------------------------------ */
/* Inbox Panel */
/* ------------------------------------------------------------------ */
const typeConfig: Record<InboxItemType, { icon: React.ElementType; color: string; bg: string; label: string }> = { 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" }, pending_approval: { icon: ShieldCheck, color: "var(--warn)", bg: "var(--warn-soft)", label: "Approval" },
@@ -40,49 +35,28 @@ const severityColors: Record<string, string> = {
low: "border-l-[color:var(--line)]", 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 { interface InboxProps {
items: InboxItem[]; items: InboxItem[];
onNavigate: (section: SectionId, entityId?: string) => void; onNavigate: (section: SectionId, entityId?: string) => void;
onDismiss: (itemId: string) => void;
} }
export function Inbox({ items, onNavigate, onDismiss }: InboxProps) { export function Inbox({ items, onNavigate }: InboxProps) {
const [filter, setFilter] = useState<InboxItemType | "all">("all"); const [filter, setFilter] = useState<InboxItemType | "all">("all");
const [showDismissed, setShowDismissed] = useState(false);
const filteredItems = useMemo(() => { const filteredItems = useMemo(() => {
let result = items; if (filter === "all") return items;
if (!showDismissed) result = result.filter(i => !i.dismissed); return items.filter(i => i.type === filter);
if (filter !== "all") result = result.filter(i => i.type === filter); }, [items, filter]);
return result;
}, [items, filter, showDismissed]);
const counts = useMemo(() => { const counts = useMemo(() => ({
const active = items.filter(i => !i.dismissed); all: items.length,
return { pending_approval: items.filter(i => i.type === "pending_approval").length,
all: active.length, failed_run: items.filter(i => i.type === "failed_run").length,
pending_approval: active.filter(i => i.type === "pending_approval").length, budget_warning: items.filter(i => i.type === "budget_warning").length,
failed_run: active.filter(i => i.type === "failed_run").length, blocked_task: items.filter(i => i.type === "blocked_task").length,
budget_warning: active.filter(i => i.type === "budget_warning").length, review_needed: items.filter(i => i.type === "review_needed").length,
blocked_task: active.filter(i => i.type === "blocked_task").length, agent_message: items.filter(i => i.type === "agent_message").length,
review_needed: active.filter(i => i.type === "review_needed").length, }), [items]);
agent_message: active.filter(i => i.type === "agent_message").length,
};
}, [items]);
const handleItemClick = (item: InboxItem) => { const handleItemClick = (item: InboxItem) => {
const sectionMap: Record<string, SectionId> = { const sectionMap: Record<string, SectionId> = {
@@ -90,6 +64,7 @@ export function Inbox({ items, onNavigate, onDismiss }: InboxProps) {
run: "tasks", run: "tasks",
agent: "agents", agent: "agents",
task: "tasks", task: "tasks",
message: "communication",
}; };
const section = sectionMap[item.entityType] ?? "overview"; const section = sectionMap[item.entityType] ?? "overview";
onNavigate(section, item.entityId); onNavigate(section, item.entityId);
@@ -102,11 +77,11 @@ export function Inbox({ items, onNavigate, onDismiss }: InboxProps) {
{ key: "failed_run", label: "Failed", count: counts.failed_run }, { key: "failed_run", label: "Failed", count: counts.failed_run },
{ key: "blocked_task", label: "Blocked", count: counts.blocked_task }, { key: "blocked_task", label: "Blocked", count: counts.blocked_task },
{ key: "budget_warning", label: "Budget", count: counts.budget_warning }, { key: "budget_warning", label: "Budget", count: counts.budget_warning },
{ key: "agent_message", label: "Messages", count: counts.agent_message },
]; ];
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-3"> <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)]"> <div className="flex h-10 w-10 items-center justify-center rounded-[10px] bg-[color:var(--accent-soft)]">
@@ -121,7 +96,6 @@ export function Inbox({ items, onNavigate, onDismiss }: InboxProps) {
</div> </div>
</div> </div>
{/* Filter Bar */}
<div className="flex flex-wrap items-center gap-1.5"> <div className="flex flex-wrap items-center gap-1.5">
{filterButtons.map(btn => ( {filterButtons.map(btn => (
<button <button
@@ -146,7 +120,6 @@ export function Inbox({ items, onNavigate, onDismiss }: InboxProps) {
))} ))}
</div> </div>
{/* Items */}
{filteredItems.length === 0 ? ( {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"> <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)]" /> <Tray size={32} className="mb-3 text-[color:var(--muted)]" />
@@ -209,9 +182,6 @@ export function Inbox({ items, onNavigate, onDismiss }: InboxProps) {
); );
} }
/* ------------------------------------------------------------------ */
/* Standup Report View */
/* ------------------------------------------------------------------ */
interface StandupProps { interface StandupProps {
report: StandupReport | null; report: StandupReport | null;
@@ -231,7 +201,6 @@ export function StandupView({ report, onNavigate }: StandupProps) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */}
<div className="rounded-[12px] border border-[color:var(--line)] bg-[color:var(--panel)] p-5"> <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 items-center gap-3 mb-4">
<div className="flex h-10 w-10 items-center justify-center rounded-[10px] bg-[color:var(--accent-soft)]"> <div className="flex h-10 w-10 items-center justify-center rounded-[10px] bg-[color:var(--accent-soft)]">
@@ -270,7 +239,6 @@ export function StandupView({ report, onNavigate }: StandupProps) {
</div> </div>
</div> </div>
{/* Company Highlights */}
{report.companyHighlights.length > 0 ? ( {report.companyHighlights.length > 0 ? (
<section> <section>
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Highlights</div> <div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Highlights</div>
@@ -285,7 +253,6 @@ export function StandupView({ report, onNavigate }: StandupProps) {
</section> </section>
) : null} ) : null}
{/* Blockers */}
{report.blockers.length > 0 ? ( {report.blockers.length > 0 ? (
<section> <section>
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--danger)]">Blockers</div> <div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--danger)]">Blockers</div>
@@ -300,7 +267,6 @@ export function StandupView({ report, onNavigate }: StandupProps) {
</section> </section>
) : null} ) : null}
{/* Agent Reports */}
<section> <section>
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Agent Status</div> <div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Agent Status</div>
<div className="space-y-2"> <div className="space-y-2">
@@ -317,7 +283,7 @@ export function StandupView({ report, onNavigate }: StandupProps) {
: ar.status === "error" ? "bg-[color:var(--danger-soft)] text-[color:var(--danger)]" : ar.status === "error" ? "bg-[color:var(--danger-soft)] text-[color:var(--danger)]"
: "bg-[color:var(--panel-soft)] text-[color:var(--muted-strong)]" : "bg-[color:var(--panel-soft)] text-[color:var(--muted-strong)]"
}`}> }`}>
{ar.agentName.split(/\s+/).slice(0, 2).map(w => w[0]).join("").toUpperCase()} {getAgentInitials(ar.agentName)}
</div> </div>
{ar.status === "running" ? ( {ar.status === "running" ? (
<span className="absolute -bottom-0.5 -right-0.5 flex h-3 w-3"> <span className="absolute -bottom-0.5 -right-0.5 flex h-3 w-3">
@@ -350,9 +316,6 @@ export function StandupView({ report, onNavigate }: StandupProps) {
); );
} }
/* ------------------------------------------------------------------ */
/* Performance Metrics View */
/* ------------------------------------------------------------------ */
interface PerformanceProps { interface PerformanceProps {
metrics: CompanyMetrics | null; metrics: CompanyMetrics | null;
@@ -372,7 +335,6 @@ export function PerformanceView({ metrics, onNavigate }: PerformanceProps) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Company Overview Metrics */}
<div className="grid grid-cols-2 gap-2 lg:grid-cols-4"> <div className="grid grid-cols-2 gap-2 lg:grid-cols-4">
<CompactMetric <CompactMetric
label="Success Rate" label="Success Rate"
@@ -398,7 +360,6 @@ export function PerformanceView({ metrics, onNavigate }: PerformanceProps) {
/> />
</div> </div>
{/* Run Stats */}
<div className="rounded-[12px] border border-[color:var(--line)] bg-[color:var(--panel)] p-5"> <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="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 items-center gap-6">
@@ -422,7 +383,6 @@ export function PerformanceView({ metrics, onNavigate }: PerformanceProps) {
</div> </div>
</div> </div>
{/* Agent Performance Table */}
<section> <section>
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Agent Performance</div> <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)]"> <div className="overflow-x-auto rounded-[10px] border border-[color:var(--line)]">
@@ -449,7 +409,7 @@ export function PerformanceView({ metrics, onNavigate }: PerformanceProps) {
<td className="px-4 py-2.5"> <td className="px-4 py-2.5">
<div className="flex items-center gap-2"> <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)]"> <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()} {getAgentInitials(am.agentName)}
</div> </div>
<span className="text-[12px] font-medium text-[color:var(--text)]">{am.agentName}</span> <span className="text-[12px] font-medium text-[color:var(--text)]">{am.agentName}</span>
</div> </div>
@@ -477,9 +437,6 @@ export function PerformanceView({ metrics, onNavigate }: PerformanceProps) {
); );
} }
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
function CompactMetric({ label, value, tone = "default", subtitle }: { function CompactMetric({ label, value, tone = "default", subtitle }: {
label: string; label: string;
@@ -526,12 +483,3 @@ function RunProgressBar({ succeeded, failed, total }: { succeeded: number; faile
</div> </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`;
}
+7 -51
View File
@@ -17,7 +17,7 @@ import type {
ProjectRecord, ProjectRecord,
WorkspaceRecord, WorkspaceRecord,
} from "@shared/types"; } from "@shared/types";
import { formatMoney, formatTime } from "../lib/formatters"; import { formatMoney, formatTime, getAgentInitials } from "../lib/formatters";
import { unwrap } from "../lib/desktop"; import { unwrap } from "../lib/desktop";
import { import {
ActionButton, ActionButton,
@@ -30,10 +30,6 @@ import {
TextArea, TextArea,
} from "./ui"; } from "./ui";
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
function BudgetBar({ spent, budget }: { spent: number; budget: number }) { function BudgetBar({ spent, budget }: { spent: number; budget: number }) {
const pct = budget > 0 ? Math.min((spent / budget) * 100, 100) : 0; const pct = budget > 0 ? Math.min((spent / budget) * 100, 100) : 0;
const overBudget = spent > budget && budget > 0; const overBudget = spent > budget && budget > 0;
@@ -70,10 +66,6 @@ function ConfirmDelete({
); );
} }
/* ------------------------------------------------------------------ */
/* AgentRoster */
/* ------------------------------------------------------------------ */
export function AgentRoster({ export function AgentRoster({
agents, agents,
connectors, connectors,
@@ -132,10 +124,6 @@ export function AgentRoster({
); );
} }
/* ------------------------------------------------------------------ */
/* AgentCreator */
/* ------------------------------------------------------------------ */
export function AgentCreator({ export function AgentCreator({
companyId, companyId,
connectors, connectors,
@@ -261,7 +249,7 @@ export function AgentCreator({
setReportsTo(""); setReportsTo("");
await onSaved(); await onSaved();
} catch (error) { } catch (error) {
console.error("Failed to create agent:", error); void error;
} }
})() })()
} }
@@ -271,10 +259,6 @@ export function AgentCreator({
); );
} }
/* ------------------------------------------------------------------ */
/* AgentEditor */
/* ------------------------------------------------------------------ */
export function AgentEditor({ export function AgentEditor({
agent, agent,
connectors, connectors,
@@ -532,10 +516,6 @@ export function AgentEditor({
); );
} }
/* ------------------------------------------------------------------ */
/* GoalList */
/* ------------------------------------------------------------------ */
export function GoalList({ export function GoalList({
goals, goals,
agents, agents,
@@ -585,10 +565,6 @@ export function GoalList({
); );
} }
/* ------------------------------------------------------------------ */
/* GoalCreator */
/* ------------------------------------------------------------------ */
export function GoalCreator({ export function GoalCreator({
companyId, companyId,
agents, agents,
@@ -649,7 +625,7 @@ export function GoalCreator({
setDescription(""); setDescription("");
await onSaved(); await onSaved();
} catch (error) { } catch (error) {
console.error("Failed to create goal:", error); void error;
} }
})() })()
} }
@@ -659,10 +635,6 @@ export function GoalCreator({
); );
} }
/* ------------------------------------------------------------------ */
/* GoalEditor */
/* ------------------------------------------------------------------ */
export function GoalEditor({ export function GoalEditor({
goal, goal,
goals, goals,
@@ -741,7 +713,7 @@ export function GoalEditor({
}); });
await onSaved(); await onSaved();
} catch (error) { } catch (error) {
console.error("Failed to save goal:", error); void error;
} }
})() })()
} }
@@ -770,10 +742,6 @@ export function GoalEditor({
); );
} }
/* ------------------------------------------------------------------ */
/* ProjectList */
/* ------------------------------------------------------------------ */
export function ProjectList({ export function ProjectList({
projects, projects,
goals, goals,
@@ -834,10 +802,6 @@ export function ProjectList({
); );
} }
/* ------------------------------------------------------------------ */
/* ProjectCreator */
/* ------------------------------------------------------------------ */
export function ProjectCreator({ export function ProjectCreator({
companyId, companyId,
goals, goals,
@@ -902,7 +866,7 @@ export function ProjectCreator({
setTargetDate(""); setTargetDate("");
await onSaved(); await onSaved();
} catch (error) { } catch (error) {
console.error("Failed to create project:", error); void error;
} }
})() })()
} }
@@ -912,10 +876,6 @@ export function ProjectCreator({
); );
} }
/* ------------------------------------------------------------------ */
/* ProjectEditor */
/* ------------------------------------------------------------------ */
export function ProjectEditor({ export function ProjectEditor({
project, project,
goals, goals,
@@ -995,7 +955,7 @@ export function ProjectEditor({
}); });
await onSaved(); await onSaved();
} catch (error) { } catch (error) {
console.error("Failed to save project:", error); void error;
} }
})() })()
} }
@@ -1024,10 +984,6 @@ export function ProjectEditor({
); );
} }
/* ------------------------------------------------------------------ */
/* OrgChart – tree layout with pan & zoom */
/* ------------------------------------------------------------------ */
const CARD_W = 220; const CARD_W = 220;
const CARD_H = 110; const CARD_H = 110;
const GAP_X = 32; const GAP_X = 32;
@@ -1276,7 +1232,7 @@ export function OrgChart({
{allNodes.map((node) => { {allNodes.map((node) => {
const dotColor = statusDotColor[node.agent.status] ?? "#a3a3a3"; const dotColor = statusDotColor[node.agent.status] ?? "#a3a3a3";
const connectorLabel = adapterLabels[node.agent.connectorId] ?? node.agent.connectorId; const connectorLabel = adapterLabels[node.agent.connectorId] ?? node.agent.connectorId;
const initials = node.agent.name.split(/\s+/).slice(0, 2).map((w) => w[0]).join("").toUpperCase(); const initials = getAgentInitials(node.agent.name);
const isRunning = node.agent.status === "running"; const isRunning = node.agent.status === "running";
const isActive = node.agent.status === "active" || isRunning; const isActive = node.agent.status === "active" || isRunning;
const isError = node.agent.status === "error"; const isError = node.agent.status === "error";
+73 -54
View File
@@ -1,9 +1,10 @@
import { useCallback, useMemo, useState } from "react"; import { useCallback, useState } from "react";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import { import {
ArrowRight,
Browser, Browser,
ArrowSquareOut,
Camera, Camera,
ClockCounterClockwise,
ChatCircle, ChatCircle,
Eye, Eye,
Globe, Globe,
@@ -17,16 +18,12 @@ import {
SignIn, SignIn,
Trash, Trash,
UserPlus, UserPlus,
Warning,
X, X,
} from "@phosphor-icons/react"; } from "@phosphor-icons/react";
import type { BrowserActionRecord, SectionId, SocialAccountRecord, SocialPlatform } from "@shared/types"; import type { BrowserActionRecord, SocialAccountRecord, SocialPlatform } from "@shared/types";
import { timeAgo } from "../lib/formatters";
import { ActionButton, Input } from "./ui"; import { ActionButton, Input } from "./ui";
/* ------------------------------------------------------------------ */
/* Platform Config */
/* ------------------------------------------------------------------ */
const platformConfig: Record<SocialPlatform, { label: string; color: string; bgColor: string; icon: string }> = { const platformConfig: Record<SocialPlatform, { label: string; color: string; bgColor: string; icon: string }> = {
twitter: { label: "Twitter / X", color: "#1DA1F2", bgColor: "#1DA1F210", icon: "𝕏" }, twitter: { label: "Twitter / X", color: "#1DA1F2", bgColor: "#1DA1F210", icon: "𝕏" },
linkedin: { label: "LinkedIn", color: "#0A66C2", bgColor: "#0A66C210", icon: "in" }, linkedin: { label: "LinkedIn", color: "#0A66C2", bgColor: "#0A66C210", icon: "in" },
@@ -49,27 +46,9 @@ const actionTypeLabels: Record<string, { label: string; icon: React.ElementType
navigate: { label: "Navigate", icon: Globe }, navigate: { label: "Navigate", icon: Globe },
search: { label: "Search", icon: MagnifyingGlass }, search: { label: "Search", icon: MagnifyingGlass },
dm: { label: "Direct Message", icon: ChatCircle }, dm: { label: "Direct Message", icon: ChatCircle },
custom_script: { label: "Custom Script", icon: Browser }, custom_script: { label: "Legacy Action", 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({ function AccountCard({
account, account,
onLogin, onLogin,
@@ -86,7 +65,6 @@ function AccountCard({
return ( 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"> <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 <div
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-[10px] text-[14px] font-bold" 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 }} style={{ backgroundColor: platform.bgColor, color: platform.color }}
@@ -94,7 +72,6 @@ function AccountCard({
{platform.icon} {platform.icon}
</div> </div>
{/* Info */}
<button type="button" onClick={onClick} className="min-w-0 flex-1 text-left"> <button type="button" onClick={onClick} className="min-w-0 flex-1 text-left">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-[13px] font-semibold text-[color:var(--text)]">{account.displayName || account.accountName}</span> <span className="text-[13px] font-semibold text-[color:var(--text)]">{account.displayName || account.accountName}</span>
@@ -117,7 +94,6 @@ function AccountCard({
</div> </div>
</button> </button>
{/* Status + Actions */}
<div className="flex items-center gap-2"> <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 ${ <span className={`flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[10px] font-semibold ${
isLoggedIn isLoggedIn
@@ -160,10 +136,6 @@ function AccountCard({
); );
} }
/* ------------------------------------------------------------------ */
/* Add Account Dialog */
/* ------------------------------------------------------------------ */
function AddAccountDialog({ function AddAccountDialog({
onAdd, onAdd,
onClose, onClose,
@@ -199,7 +171,6 @@ function AddAccountDialog({
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
{/* Platform selector */}
<div> <div>
<label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">Platform</label> <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"> <div className="grid grid-cols-3 gap-1.5">
@@ -235,7 +206,6 @@ function AddAccountDialog({
placeholder="Your display name" placeholder="Your display name"
/> />
{/* Approval toggle */}
<div className="rounded-[8px] border border-[color:var(--line)] bg-[color:var(--panel-soft)] px-4 py-3"> <div className="rounded-[8px] border border-[color:var(--line)] bg-[color:var(--panel-soft)] px-4 py-3">
<label className="flex items-center gap-3"> <label className="flex items-center gap-3">
<input <input
@@ -279,11 +249,19 @@ function AddAccountDialog({
); );
} }
/* ------------------------------------------------------------------ */ function ActionHistory({
/* Browser Action History */ actions,
/* ------------------------------------------------------------------ */ agents,
onOpenPath,
function ActionHistory({ actions, agents }: { actions: BrowserActionRecord[]; agents: Array<{ id: string; name: string }> }) { onCancelAction,
onNavigate,
}: {
actions: BrowserActionRecord[];
agents: Array<{ id: string; name: string }>;
onOpenPath: (path: string) => Promise<void>;
onCancelAction: (actionId: string) => Promise<void>;
onNavigate: (section: "tasks" | "approvals", entityId: string) => void;
}) {
if (actions.length === 0) { if (actions.length === 0) {
return ( return (
<div className="py-8 text-center text-[13px] text-[color:var(--muted)]"> <div className="py-8 text-center text-[13px] text-[color:var(--muted)]">
@@ -329,6 +307,46 @@ function ActionHistory({ actions, agents }: { actions: BrowserActionRecord[]; ag
{action.errorMessage ? ( {action.errorMessage ? (
<div className="mt-0.5 truncate text-[11px] text-[color:var(--danger)]">{action.errorMessage}</div> <div className="mt-0.5 truncate text-[11px] text-[color:var(--danger)]">{action.errorMessage}</div>
) : null} ) : null}
<div className="mt-1 flex flex-wrap items-center gap-2">
{action.taskId ? (
<button
type="button"
onClick={() => onNavigate("tasks", action.taskId!)}
className="rounded-[5px] bg-[color:var(--panel-soft)] px-2 py-1 text-[10px] font-medium text-[color:var(--muted-strong)] transition hover:text-[color:var(--text)]"
>
Open task
</button>
) : null}
{action.approvalId ? (
<button
type="button"
onClick={() => onNavigate("approvals", action.approvalId!)}
className="rounded-[5px] bg-[color:var(--panel-soft)] px-2 py-1 text-[10px] font-medium text-[color:var(--muted-strong)] transition hover:text-[color:var(--text)]"
>
Open approval
</button>
) : null}
{action.screenshotPath ? (
<button
type="button"
onClick={() => void onOpenPath(action.screenshotPath!)}
className="inline-flex items-center gap-1 rounded-[5px] bg-[color:var(--panel-soft)] px-2 py-1 text-[10px] font-medium text-[color:var(--muted-strong)] transition hover:text-[color:var(--text)]"
>
<ArrowSquareOut size={10} />
Screenshot
</button>
) : null}
{["queued", "running", "approval_required"].includes(action.status) ? (
<button
type="button"
onClick={() => void onCancelAction(action.id)}
className="inline-flex items-center gap-1 rounded-[5px] bg-[color:var(--danger-soft)] px-2 py-1 text-[10px] font-medium text-[color:var(--danger)] transition hover:brightness-95"
>
<ClockCounterClockwise size={10} />
Cancel
</button>
) : null}
</div>
</div> </div>
<div className="shrink-0 text-[10px] text-[color:var(--muted)]"> <div className="shrink-0 text-[10px] text-[color:var(--muted)]">
{timeAgo(action.createdAt)} {timeAgo(action.createdAt)}
@@ -349,10 +367,6 @@ function ActionHistory({ actions, agents }: { actions: BrowserActionRecord[]; ag
); );
} }
/* ------------------------------------------------------------------ */
/* Main Social Accounts Panel */
/* ------------------------------------------------------------------ */
interface SocialPanelProps { interface SocialPanelProps {
accounts: SocialAccountRecord[]; accounts: SocialAccountRecord[];
actions: BrowserActionRecord[]; actions: BrowserActionRecord[];
@@ -367,7 +381,9 @@ interface SocialPanelProps {
}) => Promise<void>; }) => Promise<void>;
onDelete: (id: string) => Promise<void>; onDelete: (id: string) => Promise<void>;
onLogin: (accountId: string) => Promise<void>; onLogin: (accountId: string) => Promise<void>;
onNavigate: (section: SectionId, entityId?: string) => void; onOpenPath: (path: string) => Promise<void>;
onCancelAction: (actionId: string) => Promise<void>;
onNavigate: (section: "tasks" | "approvals", entityId: string) => void;
} }
export function SocialAccountsPanel({ export function SocialAccountsPanel({
@@ -378,6 +394,8 @@ export function SocialAccountsPanel({
onSave, onSave,
onDelete, onDelete,
onLogin, onLogin,
onOpenPath,
onCancelAction,
onNavigate, onNavigate,
}: SocialPanelProps) { }: SocialPanelProps) {
const [showAddDialog, setShowAddDialog] = useState(false); const [showAddDialog, setShowAddDialog] = useState(false);
@@ -390,7 +408,6 @@ export function SocialAccountsPanel({
return ( return (
<div className="space-y-5"> <div className="space-y-5">
{/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-3"> <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)]"> <div className="flex h-10 w-10 items-center justify-center rounded-[10px] bg-[color:var(--accent-soft)]">
@@ -411,7 +428,6 @@ export function SocialAccountsPanel({
/> />
</div> </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="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)]"> <div className="flex items-start gap-2 text-[12px] text-[color:var(--accent)]">
<Browser size={16} className="mt-0.5 shrink-0" /> <Browser size={16} className="mt-0.5 shrink-0" />
@@ -420,13 +436,12 @@ export function SocialAccountsPanel({
<div className="mt-1 text-[11px] opacity-80"> <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. 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 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. using browser automation. All actions are logged and can require Board approval.
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{/* Tabs */}
<div className="flex gap-1 rounded-[8px] bg-[color:var(--panel-soft)] p-1"> <div className="flex gap-1 rounded-[8px] bg-[color:var(--panel-soft)] p-1">
<button <button
type="button" type="button"
@@ -452,7 +467,6 @@ export function SocialAccountsPanel({
</button> </button>
</div> </div>
{/* Content */}
{activeTab === "accounts" ? ( {activeTab === "accounts" ? (
accounts.length === 0 ? ( 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"> <div className="flex flex-col items-center justify-center rounded-[10px] border border-dashed border-[color:var(--line)] py-16 text-center">
@@ -478,16 +492,21 @@ export function SocialAccountsPanel({
account={account} account={account}
onLogin={() => void onLogin(account.id)} onLogin={() => void onLogin(account.id)}
onDelete={() => void onDelete(account.id)} onDelete={() => void onDelete(account.id)}
onClick={() => {/* future: account detail view */}} onClick={() => {}}
/> />
))} ))}
</div> </div>
) )
) : ( ) : (
<ActionHistory actions={actions} agents={agents} /> <ActionHistory
actions={actions}
agents={agents}
onOpenPath={onOpenPath}
onCancelAction={onCancelAction}
onNavigate={onNavigate}
/>
)} )}
{/* Add Account Dialog */}
<AnimatePresence> <AnimatePresence>
{showAddDialog ? ( {showAddDialog ? (
<AddAccountDialog <AddAccountDialog
+61 -5
View File
@@ -353,6 +353,67 @@ export function StatusPill({ status }: { status: string }) {
); );
} }
export function InlineNotice({
message,
tone = "danger",
}: {
message: string;
tone?: "danger" | "success" | "warn";
}) {
const toneClass = tone === "success"
? "border-[color:var(--success)] bg-[color:var(--success-soft)] text-[color:var(--success)]"
: tone === "warn"
? "border-[color:var(--warn)] bg-[color:var(--warn-soft)] text-[color:var(--warn)]"
: "border-[color:var(--danger)] bg-[color:var(--danger-soft)] text-[color:var(--danger)]";
return (
<div className={`rounded-[10px] border px-4 py-3 text-[13px] ${toneClass}`} role={tone === "danger" ? "alert" : "status"}>
{message}
</div>
);
}
export function ConfirmDialog({ open, title, message, errorMessage, onConfirm, onCancel }: {
open: boolean;
title: string;
message: string;
errorMessage?: string | null;
onConfirm: () => void;
onCancel: () => void;
}) {
const trapRef = useFocusTrap(open);
useEffect(() => {
if (!open) return;
function onKey(event: KeyboardEvent) {
if (event.key === "Escape") onCancel();
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, onCancel]);
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[color:var(--text)]/45" onClick={onCancel}>
<div
ref={trapRef}
role="dialog"
aria-modal="true"
aria-labelledby="confirm-dialog-title"
className="w-full max-w-[400px] rounded-[12px] bg-[color:var(--panel)] border border-[color:var(--line)] p-6 shadow-lg"
onClick={e => e.stopPropagation()}
>
<div id="confirm-dialog-title" className="text-[16px] font-semibold text-[color:var(--text)] mb-2">{title}</div>
<div className="text-[13px] text-[color:var(--muted)] mb-6">{message}</div>
{errorMessage ? <div className="mb-4"><InlineNotice message={errorMessage} /></div> : null}
<div className="flex gap-3 justify-end">
<ActionButton label="Cancel" onClick={onCancel} />
<ActionButton label="Confirm" tone="danger" onClick={onConfirm} />
</div>
</div>
</div>
);
}
export function EmptyState({ export function EmptyState({
title, title,
detail, detail,
@@ -404,8 +465,6 @@ export function ListRow({
return <div className={`border-b border-[color:var(--line)] px-5 py-4 last:border-b-0 ${className}`}>{children}</div>; return <div className={`border-b border-[color:var(--line)] px-5 py-4 last:border-b-0 ${className}`}>{children}</div>;
} }
/* ── Focus Trap Hook ────────────────────────────────────────────────── */
export function useFocusTrap(active: boolean) { export function useFocusTrap(active: boolean) {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
@@ -415,7 +474,6 @@ export function useFocusTrap(active: boolean) {
const container = ref.current; const container = ref.current;
const previouslyFocused = document.activeElement as HTMLElement | null; const previouslyFocused = document.activeElement as HTMLElement | null;
// Focus first focusable element
const focusable = container.querySelectorAll<HTMLElement>( const focusable = container.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
); );
@@ -454,8 +512,6 @@ export function useFocusTrap(active: boolean) {
return ref; return ref;
} }
/* ── Reduced Motion Hook ────────────────────────────────────────────── */
export function useReducedMotion() { export function useReducedMotion() {
const query = typeof window !== "undefined" ? window.matchMedia("(prefers-reduced-motion: reduce)") : null; const query = typeof window !== "undefined" ? window.matchMedia("(prefers-reduced-motion: reduce)") : null;
// Return static value — Electron doesn't change this mid-session // Return static value — Electron doesn't change this mid-session
+57
View File
@@ -11,3 +11,60 @@ export function formatTime(value: string | null | undefined) {
if (!value) return "Not yet"; if (!value) return "Not yet";
return new Date(value).toLocaleString(); return new Date(value).toLocaleString();
} }
export function timeAgo(dateStr: string | null): string {
if (!dateStr) return "never";
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`;
}
export function getAgentInitials(name: string): string {
return name
.split(/\s+/)
.slice(0, 2)
.map((w) => w[0])
.join("")
.toUpperCase();
}
export 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`;
}
export function formatMessageTime(dateStr: string): string {
const d = new Date(dateStr);
const now = new Date();
const isToday =
d.getDate() === now.getDate() &&
d.getMonth() === now.getMonth() &&
d.getFullYear() === now.getFullYear();
if (isToday) {
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
const isYesterday =
d.getDate() === yesterday.getDate() &&
d.getMonth() === yesterday.getMonth() &&
d.getFullYear() === yesterday.getFullYear();
if (isYesterday) {
return `Yesterday ${d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
}
return d.toLocaleDateString([], { month: "short", day: "numeric" }) +
" " +
d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
+47
View File
@@ -0,0 +1,47 @@
import type { DepartmentId } from "./types";
export 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 const DEPARTMENT_OPTIONS: Array<{ value: DepartmentId | ""; label: string }> = [
{ value: "", label: "Any department" },
{ value: "executive", label: "Executive" },
{ value: "engineering", label: "Engineering" },
{ value: "product", label: "Product" },
{ value: "marketing", label: "Marketing" },
{ value: "sales", label: "Sales" },
{ value: "hr", label: "Human Resources" },
{ value: "finance", label: "Finance" },
{ value: "legal", label: "Legal" },
{ value: "operations", label: "Operations" },
{ value: "customer_support", label: "Customer Support" },
{ value: "research", label: "Research" },
{ value: "design", label: "Design" },
];
export const DEPARTMENT_COLORS: Record<DepartmentId, string> = {
executive: "#8b5cf6",
engineering: "#3b82f6",
product: "#f59e0b",
marketing: "#ec4899",
sales: "#10b981",
hr: "#6366f1",
finance: "#14b8a6",
legal: "#a78bfa",
operations: "#f97316",
customer_support: "#06b6d4",
research: "#8b5cf6",
design: "#f43f5e",
};
+24 -29
View File
@@ -1,5 +1,5 @@
import { z } from "zod"; import { z } from "zod";
import type { AgentMessageRecord, AgentMetrics, AutomationRuleRecord, BrowserActionRecord, CommentRecord, CompanyMetrics, CompanyTemplate, DesktopEvent, DocumentRecord, InboxItem, KnowledgeEntryRecord, MeetingRecord, ProfileSnapshot, SocialAccountRecord, SprintRecord, StandupReport, WorkflowPipelineRecord } from "./types"; import type { AgentMessageRecord, AgentMetrics, BrowserActionRecord, CommentRecord, CompanyMetrics, CompanyTemplate, DesktopEvent, InboxItem, ProfileSnapshot, StandupReport } from "./types";
export const IPC_CHANNELS = { export const IPC_CHANNELS = {
loadSnapshot: "agent-company:load-snapshot", loadSnapshot: "agent-company:load-snapshot",
@@ -41,7 +41,6 @@ export const IPC_CHANNELS = {
getApiPort: "agent-company:get-api-port", getApiPort: "agent-company:get-api-port",
rendererEvent: "agent-company:event", rendererEvent: "agent-company:event",
getInbox: "agent-company:get-inbox", getInbox: "agent-company:get-inbox",
dismissInboxItem: "agent-company:dismiss-inbox-item",
getCompanyMetrics: "agent-company:get-company-metrics", getCompanyMetrics: "agent-company:get-company-metrics",
getAgentMetrics: "agent-company:get-agent-metrics", getAgentMetrics: "agent-company:get-agent-metrics",
getStandupReport: "agent-company:get-standup-report", getStandupReport: "agent-company:get-standup-report",
@@ -52,32 +51,24 @@ export const IPC_CHANNELS = {
listBrowserActions: "agent-company:list-browser-actions", listBrowserActions: "agent-company:list-browser-actions",
cancelBrowserAction: "agent-company:cancel-browser-action", cancelBrowserAction: "agent-company:cancel-browser-action",
triggerBrowserLogin: "agent-company:trigger-browser-login", triggerBrowserLogin: "agent-company:trigger-browser-login",
// Meetings
saveMeeting: "agent-company:save-meeting", saveMeeting: "agent-company:save-meeting",
deleteMeeting: "agent-company:delete-meeting", deleteMeeting: "agent-company:delete-meeting",
// Documents
saveDocument: "agent-company:save-document", saveDocument: "agent-company:save-document",
deleteDocument: "agent-company:delete-document", deleteDocument: "agent-company:delete-document",
// Knowledge Base
saveKnowledgeEntry: "agent-company:save-knowledge-entry", saveKnowledgeEntry: "agent-company:save-knowledge-entry",
deleteKnowledgeEntry: "agent-company:delete-knowledge-entry", deleteKnowledgeEntry: "agent-company:delete-knowledge-entry",
// Sprints
saveSprint: "agent-company:save-sprint", saveSprint: "agent-company:save-sprint",
deleteSprint: "agent-company:delete-sprint", deleteSprint: "agent-company:delete-sprint",
// Automation
saveAutomationRule: "agent-company:save-automation-rule", saveAutomationRule: "agent-company:save-automation-rule",
deleteAutomationRule: "agent-company:delete-automation-rule", deleteAutomationRule: "agent-company:delete-automation-rule",
toggleAutomationRule: "agent-company:toggle-automation-rule", toggleAutomationRule: "agent-company:toggle-automation-rule",
getAutomationLog: "agent-company:get-automation-log", getAutomationLog: "agent-company:get-automation-log",
// Agent Messages
sendAgentMessage: "agent-company:send-agent-message", sendAgentMessage: "agent-company:send-agent-message",
listAgentMessages: "agent-company:list-agent-messages", listAgentMessages: "agent-company:list-agent-messages",
markMessageRead: "agent-company:mark-message-read", markMessageRead: "agent-company:mark-message-read",
// Workflows
saveWorkflow: "agent-company:save-workflow", saveWorkflow: "agent-company:save-workflow",
deleteWorkflow: "agent-company:delete-workflow", deleteWorkflow: "agent-company:delete-workflow",
runWorkflow: "agent-company:run-workflow", runWorkflow: "agent-company:run-workflow",
// Auto-assign
autoAssignTask: "agent-company:auto-assign-task", autoAssignTask: "agent-company:auto-assign-task",
} as const; } as const;
@@ -175,6 +166,7 @@ export const approvalInputSchema = z.object({
}); });
export const approvalDecisionSchema = z.object({ export const approvalDecisionSchema = z.object({
companyId: z.string().uuid(),
approvalId: z.string().uuid(), approvalId: z.string().uuid(),
state: z.enum(["approved", "rejected", "revision_requested"]), state: z.enum(["approved", "rejected", "revision_requested"]),
decisionNote: z.string().max(400).default(""), decisionNote: z.string().max(400).default(""),
@@ -211,6 +203,7 @@ export const sidebarBadgesSchema = z.object({
failedRuns: z.number(), failedRuns: z.number(),
todoTasks: z.number(), todoTasks: z.number(),
budgetWarnings: z.number(), budgetWarnings: z.number(),
unreadMessages: z.number(),
}); });
export type SidebarBadges = z.infer<typeof sidebarBadgesSchema>; export type SidebarBadges = z.infer<typeof sidebarBadgesSchema>;
@@ -245,11 +238,6 @@ export const triggerHeartbeatSchema = z.object({
trigger: z.enum(["manual", "timer", "assignment", "goal_activated", "approval_resolved", "subtask_completed", "comment", "report_blocked", "report_failed", "recovery", "message", "peer_request"]).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({ export const metricsQuerySchema = z.object({
companyId: z.string().uuid(), companyId: z.string().uuid(),
agentId: z.string().uuid().optional(), agentId: z.string().uuid().optional(),
@@ -342,7 +330,6 @@ export const browserActionQuerySchema = z.object({
limit: z.number().int().min(1).max(100).default(50), limit: z.number().int().min(1).max(100).default(50),
}); });
// ── Automation Schemas ──────────────────────────────────────────────────
export const automationRuleInputSchema = z.object({ export const automationRuleInputSchema = z.object({
id: z.preprocess((v) => (v === "" ? null : v), z.string().uuid().nullable().optional()), id: z.preprocess((v) => (v === "" ? null : v), z.string().uuid().nullable().optional()),
companyId: z.string().uuid(), companyId: z.string().uuid(),
@@ -391,8 +378,9 @@ export const messageQuerySchema = z.object({
companyId: z.string().uuid(), companyId: z.string().uuid(),
agentId: z.string().uuid().optional(), agentId: z.string().uuid().optional(),
channel: z.enum(["direct", "department", "company", "project", "incident"]).optional(), channel: z.enum(["direct", "department", "company", "project", "incident"]).optional(),
channelTargetId: z.string().nullable().optional(),
unreadOnly: z.boolean().optional(), unreadOnly: z.boolean().optional(),
limit: z.number().int().min(1).max(200).default(50), limit: z.number().int().min(1).max(500).default(200),
}); });
export const workflowInputSchema = z.object({ export const workflowInputSchema = z.object({
@@ -411,6 +399,22 @@ export const workflowInputSchema = z.object({
status: z.enum(["draft", "active", "paused", "completed", "failed"]).default("draft"), status: z.enum(["draft", "active", "paused", "completed", "failed"]).default("draft"),
}); });
export const toggleAutomationRuleSchema = z.object({
id: z.string().uuid(),
companyId: z.string().uuid(),
status: z.enum(["active", "paused"]),
});
export const markMessageReadSchema = z.object({
messageId: z.string().uuid(),
companyId: z.string().uuid(),
});
export const runWorkflowSchema = z.object({
id: z.string().uuid(),
companyId: z.string().uuid(),
});
export const autoAssignSchema = z.object({ export const autoAssignSchema = z.object({
taskId: z.string().uuid(), taskId: z.string().uuid(),
companyId: z.string().uuid(), companyId: z.string().uuid(),
@@ -459,7 +463,6 @@ export type DesktopApi = {
triggerHeartbeat: (input: z.infer<typeof triggerHeartbeatSchema>) => Promise<DesktopResult<string>>; triggerHeartbeat: (input: z.infer<typeof triggerHeartbeatSchema>) => Promise<DesktopResult<string>>;
getApiPort: () => Promise<DesktopResult<number>>; getApiPort: () => Promise<DesktopResult<number>>;
getInbox: (companyId: string) => Promise<DesktopResult<InboxItem[]>>; getInbox: (companyId: string) => Promise<DesktopResult<InboxItem[]>>;
dismissInboxItem: (input: z.infer<typeof inboxDismissSchema>) => Promise<DesktopResult<boolean>>;
getCompanyMetrics: (input: z.infer<typeof metricsQuerySchema>) => Promise<DesktopResult<CompanyMetrics>>; getCompanyMetrics: (input: z.infer<typeof metricsQuerySchema>) => Promise<DesktopResult<CompanyMetrics>>;
getAgentMetrics: (input: z.infer<typeof metricsQuerySchema>) => Promise<DesktopResult<AgentMetrics>>; getAgentMetrics: (input: z.infer<typeof metricsQuerySchema>) => Promise<DesktopResult<AgentMetrics>>;
getStandupReport: (companyId: string) => Promise<DesktopResult<StandupReport>>; getStandupReport: (companyId: string) => Promise<DesktopResult<StandupReport>>;
@@ -470,32 +473,24 @@ export type DesktopApi = {
listBrowserActions: (input: z.infer<typeof browserActionQuerySchema>) => Promise<DesktopResult<BrowserActionRecord[]>>; listBrowserActions: (input: z.infer<typeof browserActionQuerySchema>) => Promise<DesktopResult<BrowserActionRecord[]>>;
cancelBrowserAction: (input: z.infer<typeof cancelRunSchema>) => Promise<DesktopResult<boolean>>; cancelBrowserAction: (input: z.infer<typeof cancelRunSchema>) => Promise<DesktopResult<boolean>>;
triggerBrowserLogin: (socialAccountId: string) => Promise<DesktopResult<boolean>>; triggerBrowserLogin: (socialAccountId: string) => Promise<DesktopResult<boolean>>;
// Meetings
saveMeeting: (input: z.infer<typeof meetingInputSchema>) => Promise<DesktopResult<string>>; saveMeeting: (input: z.infer<typeof meetingInputSchema>) => Promise<DesktopResult<string>>;
deleteMeeting: (input: z.infer<typeof deleteEntitySchema>) => Promise<DesktopResult<boolean>>; deleteMeeting: (input: z.infer<typeof deleteEntitySchema>) => Promise<DesktopResult<boolean>>;
// Documents
saveDocument: (input: z.infer<typeof documentInputSchema>) => Promise<DesktopResult<string>>; saveDocument: (input: z.infer<typeof documentInputSchema>) => Promise<DesktopResult<string>>;
deleteDocument: (input: z.infer<typeof deleteEntitySchema>) => Promise<DesktopResult<boolean>>; deleteDocument: (input: z.infer<typeof deleteEntitySchema>) => Promise<DesktopResult<boolean>>;
// Knowledge Base
saveKnowledgeEntry: (input: z.infer<typeof knowledgeEntryInputSchema>) => Promise<DesktopResult<string>>; saveKnowledgeEntry: (input: z.infer<typeof knowledgeEntryInputSchema>) => Promise<DesktopResult<string>>;
deleteKnowledgeEntry: (input: z.infer<typeof deleteEntitySchema>) => Promise<DesktopResult<boolean>>; deleteKnowledgeEntry: (input: z.infer<typeof deleteEntitySchema>) => Promise<DesktopResult<boolean>>;
// Sprints
saveSprint: (input: z.infer<typeof sprintInputSchema>) => Promise<DesktopResult<string>>; saveSprint: (input: z.infer<typeof sprintInputSchema>) => Promise<DesktopResult<string>>;
deleteSprint: (input: z.infer<typeof deleteEntitySchema>) => Promise<DesktopResult<boolean>>; deleteSprint: (input: z.infer<typeof deleteEntitySchema>) => Promise<DesktopResult<boolean>>;
// Automation
saveAutomationRule: (input: z.infer<typeof automationRuleInputSchema>) => Promise<DesktopResult<string>>; saveAutomationRule: (input: z.infer<typeof automationRuleInputSchema>) => Promise<DesktopResult<string>>;
deleteAutomationRule: (input: z.infer<typeof deleteEntitySchema>) => Promise<DesktopResult<boolean>>; deleteAutomationRule: (input: z.infer<typeof deleteEntitySchema>) => Promise<DesktopResult<boolean>>;
toggleAutomationRule: (input: { id: string; companyId: string; status: "active" | "paused" }) => Promise<DesktopResult<boolean>>; toggleAutomationRule: (input: z.infer<typeof toggleAutomationRuleSchema>) => Promise<DesktopResult<boolean>>;
getAutomationLog: (companyId: string) => Promise<DesktopResult<Array<{ ruleId: string; ruleName: string; trigger: string; action: string; executedAt: string }>>>; 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>>; sendAgentMessage: (input: z.infer<typeof agentMessageInputSchema>) => Promise<DesktopResult<string>>;
listAgentMessages: (input: z.infer<typeof messageQuerySchema>) => Promise<DesktopResult<AgentMessageRecord[]>>; listAgentMessages: (input: z.infer<typeof messageQuerySchema>) => Promise<DesktopResult<AgentMessageRecord[]>>;
markMessageRead: (input: { messageId: string; companyId: string }) => Promise<DesktopResult<boolean>>; markMessageRead: (input: z.infer<typeof markMessageReadSchema>) => Promise<DesktopResult<boolean>>;
// Workflows
saveWorkflow: (input: z.infer<typeof workflowInputSchema>) => Promise<DesktopResult<string>>; saveWorkflow: (input: z.infer<typeof workflowInputSchema>) => Promise<DesktopResult<string>>;
deleteWorkflow: (input: z.infer<typeof deleteEntitySchema>) => Promise<DesktopResult<boolean>>; deleteWorkflow: (input: z.infer<typeof deleteEntitySchema>) => Promise<DesktopResult<boolean>>;
runWorkflow: (input: { id: string; companyId: string }) => Promise<DesktopResult<boolean>>; runWorkflow: (input: z.infer<typeof runWorkflowSchema>) => Promise<DesktopResult<boolean>>;
// Auto-assign
autoAssignTask: (input: z.infer<typeof autoAssignSchema>) => Promise<DesktopResult<string>>; autoAssignTask: (input: z.infer<typeof autoAssignSchema>) => Promise<DesktopResult<string>>;
subscribe: (callback: (event: DesktopEvent) => void) => () => void; subscribe: (callback: (event: DesktopEvent) => void) => () => void;
}; };
+4 -75
View File
@@ -122,7 +122,6 @@ export interface WorkspaceRecord {
updatedAt: string; updatedAt: string;
} }
// ── Departments ────────────────────────────────────────────────────────
export type DepartmentId = export type DepartmentId =
| "executive" | "executive"
| "engineering" | "engineering"
@@ -298,7 +297,6 @@ export interface ProfileSnapshot {
secrets: SecretRecord[]; secrets: SecretRecord[];
comments: CommentRecord[]; comments: CommentRecord[];
socialAccounts: SocialAccountRecord[]; socialAccounts: SocialAccountRecord[];
browserActions: BrowserActionRecord[];
meetings: MeetingRecord[]; meetings: MeetingRecord[];
documents: DocumentRecord[]; documents: DocumentRecord[];
knowledgeBase: KnowledgeEntryRecord[]; knowledgeBase: KnowledgeEntryRecord[];
@@ -331,8 +329,6 @@ export interface RunStatusEvent {
message: string; message: string;
} }
export type HeartbeatTrigger = "manual" | "timer" | "assignment" | "goal_activated" | "approval_resolved" | "subtask_completed" | "comment" | "report_blocked" | "report_failed" | "recovery" | "message" | "peer_request";
export interface CommentRecord { export interface CommentRecord {
id: string; id: string;
companyId: string; companyId: string;
@@ -343,11 +339,6 @@ export interface CommentRecord {
createdAt: string; createdAt: string;
} }
export interface OrgChartNode {
agent: AgentRecord;
children: OrgChartNode[];
}
export interface HeartbeatStatusEvent { export interface HeartbeatStatusEvent {
type: "heartbeat-status"; type: "heartbeat-status";
agentId: string; agentId: string;
@@ -384,7 +375,6 @@ export type DesktopEvent =
| AutomationFiredEvent | AutomationFiredEvent
| NewMessageEvent; | NewMessageEvent;
// ── Inbox ──────────────────────────────────────────────────────────────
export type InboxItemType = "pending_approval" | "failed_run" | "budget_warning" | "blocked_task" | "review_needed" | "agent_message"; export type InboxItemType = "pending_approval" | "failed_run" | "budget_warning" | "blocked_task" | "review_needed" | "agent_message";
export interface InboxItem { export interface InboxItem {
@@ -398,23 +388,21 @@ export interface InboxItem {
agentId: string | null; agentId: string | null;
agentName: string | null; agentName: string | null;
createdAt: string; createdAt: string;
dismissed: boolean;
} }
// ── Performance Metrics ────────────────────────────────────────────────
export interface AgentMetrics { export interface AgentMetrics {
agentId: string; agentId: string;
agentName: string; agentName: string;
tasksCompleted: number; tasksCompleted: number;
tasksAssigned: number; tasksAssigned: number;
tasksFailed: number; tasksFailed: number;
successRate: number; // 0-100 successRate: number;
avgRunDurationSec: number; avgRunDurationSec: number;
totalCostUsd: number; totalCostUsd: number;
runsTotal: number; runsTotal: number;
runsSucceeded: number; runsSucceeded: number;
runsFailed: number; runsFailed: number;
reviewCycles: number; // how many times work was sent back for revision reviewCycles: number;
lastActiveAt: string | null; lastActiveAt: string | null;
} }
@@ -434,7 +422,6 @@ export interface CompanyMetrics {
agentMetrics: AgentMetrics[]; agentMetrics: AgentMetrics[];
} }
// ── Company Template ───────────────────────────────────────────────────
export interface CompanyTemplate { export interface CompanyTemplate {
id: string; id: string;
name: string; name: string;
@@ -461,10 +448,9 @@ export interface CompanyTemplate {
}>; }>;
} }
// ── Social Media / Browser Automation ──────────────────────────────
export type SocialPlatform = "twitter" | "linkedin" | "instagram" | "facebook" | "reddit" | "youtube" | "tiktok" | "bluesky" | "other"; export type SocialPlatform = "twitter" | "linkedin" | "instagram" | "facebook" | "reddit" | "youtube" | "tiktok" | "bluesky" | "other";
export type SocialAccountStatus = "active" | "logged_in" | "login_required" | "suspended" | "paused"; 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 BrowserActionType = "post" | "reply" | "like" | "follow" | "browse_feed" | "screenshot" | "navigate" | "search";
export type BrowserActionStatus = "queued" | "running" | "succeeded" | "failed" | "cancelled" | "approval_required"; export type BrowserActionStatus = "queued" | "running" | "succeeded" | "failed" | "cancelled" | "approval_required";
export interface SocialAccountRecord { export interface SocialAccountRecord {
@@ -474,12 +460,9 @@ export interface SocialAccountRecord {
accountName: string; accountName: string;
displayName: string; displayName: string;
profileUrl: string; profileUrl: string;
/** Secret ID for stored credentials (username/password/cookies) */
credentialSecretId: string | null; credentialSecretId: string | null;
status: SocialAccountStatus; status: SocialAccountStatus;
/** Whether posts require Board approval before publishing */
requireApproval: boolean; requireApproval: boolean;
/** JSON metadata: bio, follower count, etc. */
metadataJson: string; metadataJson: string;
lastUsedAt: string | null; lastUsedAt: string | null;
createdAt: string; createdAt: string;
@@ -493,14 +476,10 @@ export interface BrowserActionRecord {
agentId: string; agentId: string;
taskId: string | null; taskId: string | null;
actionType: BrowserActionType; actionType: BrowserActionType;
/** JSON payload: content, targetUrl, selector, script, etc. */
payloadJson: string; payloadJson: string;
status: BrowserActionStatus; status: BrowserActionStatus;
/** Result summary after execution */
resultSummary: string; resultSummary: string;
/** Screenshot path after execution */
screenshotPath: string | null; screenshotPath: string | null;
/** Approval ID if approval was required */
approvalId: string | null; approvalId: string | null;
errorMessage: string | null; errorMessage: string | null;
startedAt: string | null; startedAt: string | null;
@@ -509,7 +488,6 @@ export interface BrowserActionRecord {
updatedAt: string; updatedAt: string;
} }
// ── Meetings ──────────────────────────────────────────────────────────
export type MeetingType = export type MeetingType =
| "standup" | "standup"
| "sprint_planning" | "sprint_planning"
@@ -544,7 +522,6 @@ export interface MeetingRecord {
updatedAt: string; updatedAt: string;
} }
// ── Documents ─────────────────────────────────────────────────────────
export type DocumentType = export type DocumentType =
| "prd" | "prd"
| "technical_spec" | "technical_spec"
@@ -586,7 +563,6 @@ export interface DocumentRecord {
updatedAt: string; updatedAt: string;
} }
// ── Knowledge Base ────────────────────────────────────────────────────
export type KnowledgeCategory = export type KnowledgeCategory =
| "lesson_learned" | "lesson_learned"
| "best_practice" | "best_practice"
@@ -612,7 +588,6 @@ export interface KnowledgeEntryRecord {
updatedAt: string; updatedAt: string;
} }
// ── Sprints ───────────────────────────────────────────────────────────
export type SprintStatus = "planning" | "active" | "review" | "completed" | "cancelled"; export type SprintStatus = "planning" | "active" | "review" | "completed" | "cancelled";
export interface SprintRecord { export interface SprintRecord {
@@ -630,25 +605,6 @@ export interface SprintRecord {
updatedAt: 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 { export interface StandupReport {
companyId: string; companyId: string;
generatedAt: string; generatedAt: string;
@@ -669,10 +625,9 @@ export interface StandupReport {
companyHighlights: string[]; companyHighlights: string[];
blockers: string[]; blockers: string[];
pendingApprovals: number; pendingApprovals: number;
budgetUtilization: number; // 0-100 budgetUtilization: number;
} }
// ── Automation Engine ─────────────────────────────────────────────────
export type AutomationTrigger = export type AutomationTrigger =
| "task_status_changed" | "task_status_changed"
| "task_created" | "task_created"
@@ -713,25 +668,19 @@ export interface AutomationRuleRecord {
name: string; name: string;
description: string; description: string;
trigger: AutomationTrigger; trigger: AutomationTrigger;
/** JSON conditions: e.g., {"status": "blocked", "department": "engineering"} */
conditionsJson: string; conditionsJson: string;
action: AutomationAction; action: AutomationAction;
/** JSON action config: e.g., {"targetAgentId": "...", "message": "..."} */
actionConfigJson: string; actionConfigJson: string;
/** Source department that triggers (null = any) */
sourceDepartment: DepartmentId | null; sourceDepartment: DepartmentId | null;
/** Target department for cross-department rules (null = same) */
targetDepartment: DepartmentId | null; targetDepartment: DepartmentId | null;
priority: number; priority: number;
status: AutomationStatus; status: AutomationStatus;
/** How many times this rule has fired */
executionCount: number; executionCount: number;
lastExecutedAt: string | null; lastExecutedAt: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
// ── Agent Messages (Direct Communication) ─────────────────────────────
export type MessageChannel = "direct" | "department" | "company" | "project" | "incident"; export type MessageChannel = "direct" | "department" | "company" | "project" | "incident";
export type MessagePriority = "urgent" | "normal" | "low"; export type MessagePriority = "urgent" | "normal" | "low";
@@ -739,24 +688,18 @@ export interface AgentMessageRecord {
id: string; id: string;
companyId: string; companyId: string;
fromAgentId: string; fromAgentId: string;
/** null = broadcast to channel */
toAgentId: string | null; toAgentId: string | null;
channel: MessageChannel; channel: MessageChannel;
/** Department/project/incident ID for channel messages */
channelTargetId: string | null; channelTargetId: string | null;
subject: string; subject: string;
body: string; body: string;
priority: MessagePriority; priority: MessagePriority;
/** Whether the recipient has read this message */
readAt: string | null; readAt: string | null;
/** Parent message ID for threads */
parentMessageId: string | null; parentMessageId: string | null;
/** JSON: attached entity references [{type, id, title}] */
attachmentsJson: string; attachmentsJson: string;
createdAt: string; createdAt: string;
} }
// ── Workflow Pipeline ──────────────────────────────────────────────────
export type WorkflowStatus = "draft" | "active" | "paused" | "completed" | "failed"; export type WorkflowStatus = "draft" | "active" | "paused" | "completed" | "failed";
export interface WorkflowPipelineRecord { export interface WorkflowPipelineRecord {
@@ -764,13 +707,11 @@ export interface WorkflowPipelineRecord {
companyId: string; companyId: string;
name: string; name: string;
description: string; description: string;
/** JSON array of pipeline steps */
stepsJson: string; stepsJson: string;
triggerType: AutomationTrigger; triggerType: AutomationTrigger;
triggerConfigJson: string; triggerConfigJson: string;
status: WorkflowStatus; status: WorkflowStatus;
currentStepIndex: number; currentStepIndex: number;
/** How many times this pipeline has run */
runCount: number; runCount: number;
lastRunAt: string | null; lastRunAt: string | null;
createdAt: string; createdAt: string;
@@ -784,19 +725,7 @@ export interface WorkflowStep {
configJson: string; configJson: string;
assigneeDepartment: DepartmentId | null; assigneeDepartment: DepartmentId | null;
assigneeAgentId: string | null; assigneeAgentId: string | null;
/** Wait for approval before proceeding */
requiresApproval: boolean; requiresApproval: boolean;
/** Timeout in seconds (0 = no timeout) */
timeoutSec: number; timeoutSec: number;
/** Status of this step */
status: "pending" | "running" | "completed" | "failed" | "skipped"; status: "pending" | "running" | "completed" | "failed" | "skipped";
} }
// ── Cross-Department Trigger Table ────────────────────────────────────
export interface CrossDeptTrigger {
sourceEvent: string;
sourceDepartment: DepartmentId;
targetDepartment: DepartmentId;
targetAction: string;
description: string;
}
+4 -1
View File
@@ -27,9 +27,12 @@ test("first launch onboarding creates a company and opens the desktop shell", as
try { try {
const page = await app.firstWindow(); const page = await app.firstWindow();
await expect(page.getByText("AgentCompany Desktop")).toBeVisible(); await expect(page.getByText("AgentCompany Desktop")).toBeVisible();
await page.getByRole("button", { name: /Custom Setup/i }).click();
await page.getByLabel("Company name").fill("Orbit Foundry"); await page.getByLabel("Company name").fill("Orbit Foundry");
await page.getByLabel("Workspace folder").fill(workspaceDir); await page.getByLabel("Workspace folder").fill(workspaceDir);
await page.getByRole("button", { name: /Continue to dashboard/i }).click(); await page.getByRole("button", { name: /^Continue$/i }).click();
await page.getByLabel("Goal").fill("Launch the company");
await page.getByRole("button", { name: /Launch your company/i }).click();
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
// Company name is rendered in a custom dropdown trigger button in the sidebar. // Company name is rendered in a custom dropdown trigger button in the sidebar.
+104 -26
View File
@@ -293,7 +293,6 @@ describe("Step 2: CEO phased prompts", () => {
}); });
const prompt = buildSystemPrompt(ctx); const prompt = buildSystemPrompt(ctx);
expect(prompt).toContain("escalate by commenting on YOUR parent task");
expect(prompt).toContain("Break down work and delegate it"); expect(prompt).toContain("Break down work and delegate it");
}); });
@@ -310,7 +309,7 @@ describe("Step 2: CEO phased prompts", () => {
const prompt = buildSystemPrompt(ctx); const prompt = buildSystemPrompt(ctx);
expect(prompt).toContain("Your manager is auto-notified and will help"); expect(prompt).toContain("Your manager is auto-notified and will help");
expect(prompt).toContain("comment explaining what API key/account you need"); expect(prompt).toContain("explain what API key/account you need");
}); });
}); });
@@ -320,15 +319,15 @@ describe("Step 3: Deferred wake queue (database)", () => {
it("enqueue and consume pending wakes", () => { it("enqueue and consume pending wakes", () => {
const agentId = createAgent({ name: "TestAgent" }); const agentId = createAgent({ name: "TestAgent" });
// Enqueue two wakes // Enqueue two wakes for the same busy agent. The queue deduplicates by agent
// so only the freshest trigger is retained.
db.enqueuePendingWake(companyId, agentId, "subtask_completed"); db.enqueuePendingWake(companyId, agentId, "subtask_completed");
db.enqueuePendingWake(companyId, agentId, "comment"); db.enqueuePendingWake(companyId, agentId, "comment");
// Consume returns them in order // Consume returns the latest retained wake
const wakes = db.consumePendingWakes(agentId); const wakes = db.consumePendingWakes(agentId);
expect(wakes).toHaveLength(2); expect(wakes).toHaveLength(1);
expect(wakes[0].trigger).toBe("subtask_completed"); expect(wakes[0].trigger).toBe("comment");
expect(wakes[1].trigger).toBe("comment");
expect(wakes[0].companyId).toBe(companyId); expect(wakes[0].companyId).toBe(companyId);
// Second consume returns empty (they've been deleted) // Second consume returns empty (they've been deleted)
@@ -640,10 +639,10 @@ describe("Step 8: Wake reason injection", () => {
const prompt = buildTaskPrompt(ctx); const prompt = buildTaskPrompt(ctx);
expect(prompt).toContain("Wake Reason"); expect(prompt).toContain("Wake Reason");
expect(prompt).toContain("subtask assigned to one of your reports has been completed"); expect(prompt).toContain("subtask you delegated has been completed");
}); });
it("does not include wake reason for timer triggers", () => { it("includes wake reason for timer triggers", () => {
const agentId = createAgent({ name: "Agent" }); const agentId = createAgent({ name: "Agent" });
const ctx = buildCtx({ const ctx = buildCtx({
@@ -652,10 +651,11 @@ describe("Step 8: Wake reason injection", () => {
}); });
const prompt = buildTaskPrompt(ctx); const prompt = buildTaskPrompt(ctx);
expect(prompt).not.toContain("Wake Reason"); expect(prompt).toContain("Wake Reason");
expect(prompt).toContain("Regular heartbeat check-in");
}); });
it("does not include wake reason for manual triggers", () => { it("includes wake reason for manual triggers", () => {
const agentId = createAgent({ name: "Agent" }); const agentId = createAgent({ name: "Agent" });
const ctx = buildCtx({ const ctx = buildCtx({
@@ -664,17 +664,18 @@ describe("Step 8: Wake reason injection", () => {
}); });
const prompt = buildTaskPrompt(ctx); const prompt = buildTaskPrompt(ctx);
expect(prompt).not.toContain("Wake Reason"); expect(prompt).toContain("Wake Reason");
expect(prompt).toContain("manually triggered by the human operator");
}); });
it("includes correct message for each wake reason", () => { it("includes correct message for each wake reason", () => {
const agentId = createAgent({ name: "Agent" }); const agentId = createAgent({ name: "Agent" });
const reasons: Record<string, string> = { const reasons: Record<string, string> = {
assignment: "assigned new work", assignment: "You have been assigned a new task",
goal_activated: "goal you own has been activated", goal_activated: "goal you own has been activated",
approval_resolved: "approval you requested has been resolved", approval_resolved: "approval you requested has been decided",
subtask_completed: "subtask assigned to one of your reports has been completed", subtask_completed: "subtask you delegated has been completed",
comment: "new comment has been posted", comment: "new comment has been posted",
report_blocked: "reports is blocked", report_blocked: "reports is blocked",
report_failed: "reports has failed", report_failed: "reports has failed",
@@ -887,7 +888,7 @@ describe("E2E: Full autonomous CEO cold start flow", () => {
}); });
const icPrompt = buildTaskPrompt(icPromptCtx); const icPrompt = buildTaskPrompt(icPromptCtx);
expect(icPrompt).toContain("Wake Reason"); expect(icPrompt).toContain("Wake Reason");
expect(icPrompt).toContain("assigned new work"); expect(icPrompt).toContain("You have been assigned a new task");
expect(icPrompt).toContain("Implement auth"); expect(icPrompt).toContain("Implement auth");
}); });
}); });
@@ -898,17 +899,16 @@ describe("E2E: Deferred wake queue with run lifecycle", () => {
it("wake enqueue + run finish + consume cycle", () => { it("wake enqueue + run finish + consume cycle", () => {
const agentId = createAgent({ name: "Agent" }); const agentId = createAgent({ name: "Agent" });
// Simulate: agent is running, wakes come in // Simulate: agent is running, wakes come in. Only the freshest reason is
// retained so the agent gets one consolidated follow-up wake.
db.enqueuePendingWake(companyId, agentId, "subtask_completed"); db.enqueuePendingWake(companyId, agentId, "subtask_completed");
db.enqueuePendingWake(companyId, agentId, "comment"); db.enqueuePendingWake(companyId, agentId, "comment");
db.enqueuePendingWake(companyId, agentId, "report_blocked"); db.enqueuePendingWake(companyId, agentId, "report_blocked");
// Agent finishes run → consume pending wakes // Agent finishes run → consume pending wakes
const wakes = db.consumePendingWakes(agentId); const wakes = db.consumePendingWakes(agentId);
expect(wakes).toHaveLength(3); expect(wakes).toHaveLength(1);
expect(wakes[0].trigger).toBe("subtask_completed"); expect(wakes[0].trigger).toBe("report_blocked");
expect(wakes[1].trigger).toBe("comment");
expect(wakes[2].trigger).toBe("report_blocked");
// All consumed // All consumed
expect(db.consumePendingWakes(agentId)).toHaveLength(0); expect(db.consumePendingWakes(agentId)).toHaveLength(0);
@@ -1192,6 +1192,81 @@ describe("Agent Messages", () => {
expect(messages[0].readAt).toBeTruthy(); expect(messages[0].readAt).toBeTruthy();
}); });
it("tracks read state per agent without marking the sender unread", () => {
const msgId = db.sendAgentMessage({
companyId,
fromAgentId: agentAId,
toAgentId: agentBId,
subject: "Test",
body: "Hello",
});
const senderView = db.listAgentMessages(companyId, { agentId: agentAId });
expect(senderView).toHaveLength(1);
expect(senderView[0].id).toBe(msgId);
expect(senderView[0].readAt).toBeTruthy();
const recipientViewBefore = db.listAgentMessages(companyId, { agentId: agentBId, unreadOnly: true });
expect(recipientViewBefore).toHaveLength(1);
expect(recipientViewBefore[0].id).toBe(msgId);
db.markAgentMessageRead(msgId, companyId, agentBId);
const recipientViewAfter = db.listAgentMessages(companyId, { agentId: agentBId, unreadOnly: true });
expect(recipientViewAfter).toHaveLength(0);
const thirdAgentId = createAgent({ name: "Agent C" });
const thirdView = db.listAgentMessages(companyId, { agentId: thirdAgentId });
expect(thirdView).toHaveLength(0);
});
it("limits department and project messages to relevant members", () => {
const engineeringLeadId = createAgent({ name: "Engineering Lead", role: "Lead", department: "engineering" });
const productLeadId = createAgent({ name: "Product Lead", role: "Lead", department: "product" });
const projectId = db.saveProject({
companyId,
name: "Cross-team Project",
description: "Scope",
leadAgentId: engineeringLeadId,
status: "active",
});
db.saveTask({
companyId,
title: "Project task",
description: "Do work",
assigneeAgentId: engineeringLeadId,
projectId,
status: "todo",
priority: "medium",
});
const deptMessageId = db.sendAgentMessage({
companyId,
fromAgentId: engineeringLeadId,
channel: "department",
channelTargetId: "engineering",
subject: "Engineering update",
body: "Only engineering should see this",
});
const projectMessageId = db.sendAgentMessage({
companyId,
fromAgentId: engineeringLeadId,
channel: "project",
channelTargetId: projectId,
subject: "Project update",
body: "Only project members should see this",
});
const engineeringView = db.listAgentMessages(companyId, { agentId: engineeringLeadId });
expect(engineeringView.some((message) => message.id === deptMessageId)).toBe(true);
expect(engineeringView.some((message) => message.id === projectMessageId)).toBe(true);
const productView = db.listAgentMessages(companyId, { agentId: productLeadId });
expect(productView.some((message) => message.id === deptMessageId)).toBe(false);
expect(productView.some((message) => message.id === projectMessageId)).toBe(false);
});
it("filters messages by channel", () => { it("filters messages by channel", () => {
db.sendAgentMessage({ companyId, fromAgentId: agentAId, subject: "Dept update", body: "All good", channel: "department", channelTargetId: "engineering" }); db.sendAgentMessage({ companyId, fromAgentId: agentAId, subject: "Dept update", body: "All good", channel: "department", channelTargetId: "engineering" });
db.sendAgentMessage({ companyId, fromAgentId: agentAId, toAgentId: agentBId, subject: "DM", body: "Hey", channel: "direct" }); db.sendAgentMessage({ companyId, fromAgentId: agentAId, toAgentId: agentBId, subject: "DM", body: "Hey", channel: "direct" });
@@ -1206,7 +1281,7 @@ describe("Agent Messages", () => {
}); });
it("appears in snapshot", () => { it("appears in snapshot", () => {
db.sendAgentMessage({ companyId, fromAgentId: agentAId, subject: "Snapshot test", body: "Hi" }); db.sendAgentMessage({ companyId, fromAgentId: agentAId, channel: "company", subject: "Snapshot test", body: "Hi" });
const snapshot = db.listSnapshot(); const snapshot = db.listSnapshot();
expect(snapshot.agentMessages.length).toBeGreaterThanOrEqual(1); expect(snapshot.agentMessages.length).toBeGreaterThanOrEqual(1);
expect(snapshot.agentMessages[0].subject).toBe("Snapshot test"); expect(snapshot.agentMessages[0].subject).toBe("Snapshot test");
@@ -1268,10 +1343,13 @@ describe("Auto Task Assignment", () => {
expect(result).toBe(frontendId); expect(result).toBe(frontendId);
}); });
it("returns null if no agents have remaining budget", () => { it("returns null if all agents have exhausted their budget", () => {
// Exhaust both agents' budgets // Set positive budgets that are fully spent
db.saveAgent({ id: backendId, companyId, name: "Backend Dev", role: "Backend", title: "Backend", connectorId: "codex_local", status: "active", capabilities: "backend", budgetMonthlyUsd: 0 }); db.saveAgent({ id: backendId, companyId, name: "Backend Dev", role: "Backend", title: "Backend", connectorId: "codex_local", status: "active", capabilities: "backend", budgetMonthlyUsd: 100 });
db.saveAgent({ id: frontendId, companyId, name: "Frontend Dev", role: "Frontend", title: "Frontend", connectorId: "codex_local", status: "active", capabilities: "frontend", budgetMonthlyUsd: 0 }); db.saveAgent({ id: frontendId, companyId, name: "Frontend Dev", role: "Frontend", title: "Frontend", connectorId: "codex_local", status: "active", capabilities: "frontend", budgetMonthlyUsd: 100 });
// Exhaust budgets by recording spending via direct SQL (since finishRun would need a real run)
(db as any).db.prepare("update agents set spent_monthly_usd = 100 where id = ?").run(backendId);
(db as any).db.prepare("update agents set spent_monthly_usd = 100 where id = ?").run(frontendId);
const taskId = db.saveTask({ companyId, title: "No budget task", description: "Test", priority: "low", status: "todo" }); const taskId = db.saveTask({ companyId, title: "No budget task", description: "Test", priority: "low", status: "todo" });
const result = db.autoAssignTask(taskId, companyId); const result = db.autoAssignTask(taskId, companyId);
+96
View File
@@ -147,4 +147,100 @@ describe("AppDatabase", () => {
db.close(); db.close();
}); });
it("returns a renderer snapshot scoped to the current company while preserving global selectors", async () => {
const profileDir = await createTempDir("agentcompany-renderer-snapshot-");
const db = new AppDatabase(profileDir);
db.init();
const alphaCompanyId = db.saveCompany({
name: "Alpha",
description: "Alpha company",
status: "active",
});
const betaCompanyId = db.saveCompany({
name: "Beta",
description: "Beta company",
status: "active",
});
const alphaWorkspaceId = db.saveWorkspace({
companyId: alphaCompanyId,
name: "Alpha Workspace",
localPath: profileDir,
repoUrl: "",
repoRef: "",
isPrimary: true,
});
const betaWorkspaceId = db.saveWorkspace({
companyId: betaCompanyId,
name: "Beta Workspace",
localPath: profileDir,
repoUrl: "",
repoRef: "",
isPrimary: true,
});
const alphaAgentId = db.saveAgent({
companyId: alphaCompanyId,
name: "Alpha CEO",
role: "CEO",
title: "",
status: "active",
reportsTo: null,
connectorId: "codex_local",
workspaceId: alphaWorkspaceId,
model: null,
capabilities: "Lead alpha",
budgetMonthlyUsd: 100,
});
db.saveAgent({
companyId: betaCompanyId,
name: "Beta CEO",
role: "CEO",
title: "",
status: "active",
reportsTo: null,
connectorId: "codex_local",
workspaceId: betaWorkspaceId,
model: null,
capabilities: "Lead beta",
budgetMonthlyUsd: 100,
});
db.saveTask({
companyId: alphaCompanyId,
title: "Alpha task",
description: "Alpha work",
assigneeAgentId: alphaAgentId,
workspaceId: alphaWorkspaceId,
priority: "medium",
status: "todo",
});
db.saveTask({
companyId: betaCompanyId,
title: "Beta task",
description: "Beta work",
assigneeAgentId: null,
workspaceId: betaWorkspaceId,
priority: "medium",
status: "todo",
});
db.setCurrentCompany(alphaCompanyId);
const rendererSnapshot = db.listRendererSnapshot();
expect(rendererSnapshot.currentCompanyId).toBe(alphaCompanyId);
expect(rendererSnapshot.companies).toHaveLength(2);
expect(rendererSnapshot.connectors.length).toBeGreaterThan(0);
expect(rendererSnapshot.workspaces.map((workspace) => workspace.companyId)).toEqual([alphaCompanyId]);
expect(rendererSnapshot.agents.every((agent) => agent.companyId === alphaCompanyId)).toBe(true);
expect(rendererSnapshot.tasks.every((task) => task.companyId === alphaCompanyId)).toBe(true);
db.close();
});
}); });
-83
View File
@@ -370,63 +370,6 @@ describe("AppDatabase", () => {
expect(db.isAgentPendingApproval(pendingAgentId)).toBe(true); expect(db.isAgentPendingApproval(pendingAgentId)).toBe(true);
}); });
it("detects budget exhaustion via isAgentBudgetExhausted", () => {
const companyId = db.saveCompany({
name: "Budget Gate Co",
description: "Budget gate validation",
status: "active",
});
const workspaceId = db.saveWorkspace({
companyId,
name: "WS",
localPath: "/tmp/budget-gate",
repoUrl: "",
repoRef: "",
isPrimary: true,
});
const agentId = db.saveAgent({
companyId,
name: "Budget Agent",
role: "IC",
title: "Budget",
status: "active",
reportsTo: null,
connectorId: "codex_local",
workspaceId,
model: null,
capabilities: "Test",
budgetMonthlyUsd: 10,
});
expect(db.isAgentBudgetExhausted(agentId)).toBe(false);
// Exhaust the budget via a run
const taskId = db.saveTask({
companyId,
title: "Budget drain",
description: "Test",
assigneeAgentId: agentId,
workspaceId,
priority: "medium",
status: "todo",
});
const run = db.createRun({ companyId, taskId, agentId, workspaceId, connectorId: "codex_local" });
db.finishRun({
runId: run.id,
status: "succeeded",
summary: "Done",
errorMessage: null,
exitCode: 0,
signal: null,
model: null,
sessionDisplayId: null,
costUsd: 15,
unattributedCost: false,
});
expect(db.isAgentBudgetExhausted(agentId)).toBe(true);
});
it("runs forward-only migrations and creates schema_migrations table", () => { it("runs forward-only migrations and creates schema_migrations table", () => {
// The migration system runs during init() — verify the tracking table exists // The migration system runs during init() — verify the tracking table exists
const snapshot = db.listSnapshot(); const snapshot = db.listSnapshot();
@@ -632,30 +575,4 @@ describe("AppDatabase", () => {
expect(approval2.relatedAgentId).toBe(hireId); expect(approval2.relatedAgentId).toBe(hireId);
}); });
it("updates workspace state", () => {
const companyId = db.saveCompany({
name: "WS State Co",
description: "Workspace state test",
status: "active",
});
const wsId = db.saveWorkspace({
companyId,
name: "Test WS",
localPath: "/tmp/ws-state",
repoUrl: "",
repoRef: "",
isPrimary: true,
});
let ws = db.getWorkspace(wsId);
expect(ws?.state).toBe("ready");
db.setWorkspaceState(wsId, "busy");
ws = db.getWorkspace(wsId);
expect(ws?.state).toBe("busy");
db.setWorkspaceState(wsId, "error");
ws = db.getWorkspace(wsId);
expect(ws?.state).toBe("error");
});
}); });