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/**",
"build/**",
"dist/**",
"oldreference/**",
"node_modules/**",
"test-results/**",
"playwright-report/**",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agent-company",
"version": "0.3.0",
"version": "0.4.16",
"private": true,
"type": "module",
"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 { 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 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) {
env.CSC_IDENTITY_AUTO_DISCOVERY = "false";
}
@@ -29,15 +54,17 @@ if (!explicitPublishArg) {
}
builderArgs.push(...extraArgs);
const child = spawn(
"pnpm",
builderArgs,
{
stdio: "inherit",
env,
shell: process.platform === "win32",
},
);
await rm(releaseDir, { recursive: true, force: true });
const buildVersion = env.AGENTCOMPANY_BUILD_VERSION?.trim() || await prepareBuildVersion();
env.AGENTCOMPANY_BUILD_VERSION = buildVersion;
env.npm_package_version = buildVersion;
console.log(`[build-desktop] Cleaned build output and bumped package version to ${buildVersion}.`);
const child = spawn("pnpm", builderArgs, {
stdio: "inherit",
env,
shell: process.platform === "win32",
});
child.on("exit", (code, signal) => {
if (signal) {
+1 -7
View File
@@ -1,13 +1,7 @@
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 TOKEN_TTL_MS = 12 * 60 * 60 * 1000; // 12 hours
const TOKEN_TTL_MS = 48 * 60 * 60 * 1000; // 48 hours
interface TokenPayload {
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 log from "electron-log/main.js";
/**
* Browser Automation Manager — Electron-native edition.
*
* Uses Electron's own BrowserWindow and persistent sessions instead of
* external Playwright, so it works in packaged apps with zero extra deps.
*
* Each social account gets its own Electron session partition so cookies,
* localStorage, and login state are isolated and persisted across app restarts.
*/
export interface BrowserAction {
id: string;
socialAccountId: string;
platform: string;
actionType: string;
payload: Record<string, unknown>;
}
@@ -33,56 +24,116 @@ interface QueuedAction {
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 {
private screenshotDir: string;
private queues = new Map<string, QueuedAction[]>();
private processing = new Set<string>();
/** Track open login windows so we don't open duplicates */
private loginWindows = new Map<string, BrowserWindow>();
private initializedSessions = new Set<string>();
constructor(profileDir: string) {
this.screenshotDir = join(profileDir, "browser-screenshots");
if (!existsSync(this.screenshotDir)) mkdirSync(this.screenshotDir, { recursive: true });
}
/**
* Get a persistent Electron session for a social account.
* Using `persist:` prefix ensures data survives app restarts.
*/
private getSession(socialAccountId: string): Electron.Session {
return session.fromPartition(`persist:social-${socialAccountId}`);
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(
socialAccountId: string,
platform: string,
loginUrl?: string,
): Promise<BrowserActionResult> {
// Don't open duplicate login windows for the same account
const existing = this.loginWindows.get(socialAccountId);
if (existing && !existing.isDestroyed()) {
existing.focus();
return { success: true, summary: "Login window already open", screenshotPath: null, error: null };
}
const defaultUrls: Record<string, string> = {
twitter: "https://x.com/i/flow/login",
linkedin: "https://www.linkedin.com/login",
instagram: "https://www.instagram.com/accounts/login/",
facebook: "https://www.facebook.com/login",
reddit: "https://www.reddit.com/login",
youtube: "https://accounts.google.com/signin",
tiktok: "https://www.tiktok.com/login",
bluesky: "https://bsky.app",
other: "https://google.com",
};
const url = loginUrl || defaultUrls[platform] || defaultUrls.other;
const rules = this.getPlatformRules(platform);
const url = loginUrl || rules.loginUrl;
const ses = this.getSession(socialAccountId);
log.info(`[browser] Opening login window for ${platform} → ${url}`);
@@ -93,7 +144,6 @@ export class BrowserManager {
title: `Login to ${platform} — AgentCompany`,
webPreferences: {
session: ses,
// Standard web page — no node integration, no preload
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
@@ -102,43 +152,32 @@ export class BrowserManager {
this.loginWindows.set(socialAccountId, win);
// Remove menu bar for a cleaner look
win.setMenuBarVisibility(false);
// Load the login page
try {
await win.loadURL(url);
} catch (err) {
log.error(`[browser] Failed to load ${url}: ${err}`);
// Don't fail — the page might redirect or have an expected error
}
// Wait for the user to close the window
return new Promise<BrowserActionResult>((resolve) => {
win.on("closed", () => {
this.loginWindows.delete(socialAccountId);
log.info(`[browser] Login window closed for ${platform} — session saved`);
resolve({
success: true,
summary: `Login session saved for ${platform}`,
screenshotPath: null,
error: null,
void this.verifyLoginSession(socialAccountId, platform).then((verified) => {
log.info(`[browser] Login window closed for ${platform} — verified=${verified}`);
resolve({
success: verified,
summary: verified
? `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> {
return new Promise((resolve, reject) => {
const queue = this.queues.get(action.socialAccountId) ?? [];
@@ -166,17 +205,14 @@ export class BrowserManager {
this.processing.delete(socialAccountId);
}
/**
* Execute a single browser action using an Electron BrowserWindow.
*/
private async runAction(action: BrowserAction): Promise<BrowserActionResult> {
const ses = this.getSession(action.socialAccountId);
const payload = action.payload;
const screenshotPath = join(this.screenshotDir, `${action.id}.png`);
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({
width: 1280,
height: 860,
@@ -197,6 +233,7 @@ export class BrowserManager {
switch (action.actionType) {
case "navigate": {
const url = String(payload.url || "https://google.com");
this.assertAllowedUrl(platform, url, "navigate");
await win.loadURL(url);
await this.waitForLoad(win);
const title = await win.webContents.executeJavaScript("document.title");
@@ -205,7 +242,6 @@ export class BrowserManager {
}
case "post": {
const platform = String(payload.platform || "twitter");
const content = String(payload.content || "");
const postUrl = this.getComposeUrl(platform);
await win.loadURL(postUrl);
@@ -213,7 +249,6 @@ export class BrowserManager {
await this.delay(2000);
if (platform === "twitter") {
// Type into tweet composer
await win.webContents.executeJavaScript(`
(async () => {
const editor = document.querySelector('[data-testid="tweetTextarea_0"], [role="textbox"]');
@@ -224,7 +259,6 @@ export class BrowserManager {
})()
`);
await this.delay(1000);
// Click post button
await win.webContents.executeJavaScript(`
(() => {
const btn = document.querySelector('[data-testid="tweetButton"], [data-testid="tweetButtonInline"]');
@@ -264,6 +298,7 @@ export class BrowserManager {
case "reply": {
const targetUrl = String(payload.targetUrl || "");
this.assertAllowedUrl(platform, targetUrl, "reply");
const replyContent = String(payload.content || "");
await win.loadURL(targetUrl);
await this.waitForLoad(win);
@@ -292,6 +327,7 @@ export class BrowserManager {
case "like": {
const targetUrl = String(payload.targetUrl || "");
this.assertAllowedUrl(platform, targetUrl, "like");
await win.loadURL(targetUrl);
await this.waitForLoad(win);
await this.delay(2000);
@@ -308,6 +344,7 @@ export class BrowserManager {
case "follow": {
const targetUrl = String(payload.targetUrl || payload.profileUrl || "");
this.assertAllowedUrl(platform, targetUrl, "follow");
await win.loadURL(targetUrl);
await this.waitForLoad(win);
await this.delay(2000);
@@ -323,14 +360,13 @@ export class BrowserManager {
}
case "browse_feed": {
const platform = String(payload.platform || "twitter");
const feedUrl = String(payload.url || this.getFeedUrl(platform));
this.assertAllowedUrl(platform, feedUrl, "browse_feed");
const maxPosts = Number(payload.maxPosts || 10);
await win.loadURL(feedUrl);
await this.waitForLoad(win);
await this.delay(3000);
// Scroll to load content
for (let i = 0; i < 3; i++) {
await win.webContents.executeJavaScript("window.scrollBy(0, window.innerHeight)");
await this.delay(1500);
@@ -348,8 +384,8 @@ export class BrowserManager {
case "search": {
const query = String(payload.query || "");
const platform = String(payload.platform || "twitter");
const searchUrl = this.getSearchUrl(platform, query);
this.assertAllowedUrl(platform, searchUrl, "search");
await win.loadURL(searchUrl);
await this.waitForLoad(win);
await this.delay(3000);
@@ -367,6 +403,7 @@ export class BrowserManager {
case "screenshot": {
const url = String(payload.url || "about:blank");
if (url !== "about:blank") {
this.assertAllowedUrl(platform, url, "screenshot");
await win.loadURL(url);
await this.waitForLoad(win);
await this.delay(2000);
@@ -377,10 +414,9 @@ export class BrowserManager {
}
default:
summary = `Unknown action type: ${action.actionType}`;
throw new Error(`Unsupported browser action type: ${action.actionType}`);
}
// Capture screenshot
try {
const image: NativeImage = await win.webContents.capturePage();
if (!image.isEmpty()) {
@@ -390,7 +426,6 @@ export class BrowserManager {
log.warn(`[browser] Screenshot failed: ${err}`);
}
// Close the action window after a brief pause so user can see result
await this.delay(1000);
if (!win.isDestroyed()) win.close();
@@ -401,7 +436,6 @@ export class BrowserManager {
error: null,
};
} catch (error) {
// Try to capture error screenshot
try {
const image: NativeImage = await win.webContents.capturePage();
if (!image.isEmpty()) writeFileSync(screenshotPath, image.toPNG());
@@ -420,8 +454,6 @@ export class BrowserManager {
}
}
// ── Helpers ──────────────────────────────────────────────────────
private delay(ms: number): Promise<void> {
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 {
const urls: Record<string, string> = {
twitter: "https://x.com/compose/post",
@@ -448,8 +543,10 @@ export class BrowserManager {
facebook: "https://www.facebook.com/",
reddit: "https://www.reddit.com/submit",
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 {
@@ -457,10 +554,13 @@ export class BrowserManager {
twitter: "https://x.com/home",
linkedin: "https://www.linkedin.com/feed/",
instagram: "https://www.instagram.com/",
facebook: "https://www.facebook.com/",
reddit: "https://www.reddit.com/",
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 {
@@ -474,7 +574,6 @@ export class BrowserManager {
}
shutdown() {
// Close any open login windows
for (const [, win] of this.loginWindows) {
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 {
const definition = CONNECTORS.find((entry) => entry.id === id);
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 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([
// PATH is required for command resolution
"PATH",
// Basic locale / terminal
"HOME", "USER", "SHELL", "LANG", "LC_ALL", "LC_CTYPE", "TERM", "TMPDIR",
// macOS-specific
"DEVELOPER_DIR", "SDKROOT",
// Node
"NODE_ENV", "NODE_OPTIONS",
// Git
"GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL", "GIT_COMMITTER_NAME", "GIT_COMMITTER_EMAIL",
"GIT_SSH_COMMAND", "GIT_ASKPASS", "GIT_TERMINAL_PROMPT",
// XDG
"XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME", "XDG_RUNTIME_DIR",
// Platform / display (Linux)
"DISPLAY", "WAYLAND_DISPLAY", "DBUS_SESSION_BUS_ADDRESS",
// Windows
"APPDATA", "LOCALAPPDATA", "USERPROFILE", "SYSTEMROOT", "COMSPEC", "PROGRAMFILES",
// AgentCompany injected
"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(
hostEnv: Record<string, string>,
secretBindings: Record<string, string>,
@@ -43,25 +25,18 @@ function buildSanitizedEnv(
result[key] = hostEnv[key];
}
}
// Secret bindings always override — they are resolved from the vault
Object.assign(result, secretBindings);
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 {
const sensitivePatterns: string[] = [];
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 (["PATH", "HOME", "USER", "SHELL", "LANG", "TMPDIR", "TERM"].includes(key)) continue;
sensitivePatterns.push(value);
}
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);
return (text: string) => {
let result = text;
@@ -85,14 +60,13 @@ interface RunJob {
connectorId: ConnectorId;
workspaceId: string | null;
workspacePath: string | null;
workspaceMode: "exclusive" | "shared" | "none";
command: string;
model: string | null;
thinkingEffort: string | null;
env: Record<string, string>;
sessionParams: Record<string, unknown> | null;
/** Rich system prompt with API instructions for autonomous operation */
systemPrompt: string | null;
/** Heartbeat trigger that caused this run */
trigger: string | null;
}
@@ -134,6 +108,9 @@ function post(message: ChildMessage) {
}
function workspaceKey(job: RunJob) {
if (job.workspaceMode !== "exclusive") {
return null;
}
return job.workspaceId ?? job.workspacePath ?? `task:${job.taskId}`;
}
@@ -152,7 +129,8 @@ async function maybeStartQueuedRuns() {
const readyJobs = [...queue];
for (const job of readyJobs) {
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);
if (index >= 0) queue.splice(index, 1);
void startRun(job);
@@ -163,7 +141,9 @@ async function startRun(job: RunJob) {
const definition = getConnectorDefinition(job.connectorId);
const lockKey = workspaceKey(job);
activeByAgent.set(job.agentId, job.runId);
activeByWorkspace.set(lockKey, job.runId);
if (lockKey) {
activeByWorkspace.set(lockKey, job.runId);
}
post({
type: "run-status",
runId: job.runId,
@@ -171,7 +151,6 @@ async function startRun(job: RunJob) {
message: `Running with ${definition.label}.`,
});
// Build sanitized env: only allowlisted host vars + resolved secret bindings
const sanitizedEnv = buildSanitizedEnv(
Object.fromEntries(
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,
);
// Build log redactor from secret bindings to prevent accidental secret leakage
const redact = buildRedactor(job.env);
try {
@@ -205,13 +183,9 @@ async function startRun(job: RunJob) {
cwd: job.workspacePath ?? process.cwd(),
model: job.model,
env: sanitizedEnv,
// Claude: thinking effort
effort: job.connectorId === "claude_local" ? (job.thinkingEffort ?? "") : "",
// Codex: reasoning effort
modelReasoningEffort: job.connectorId === "codex_local" ? (job.thinkingEffort ?? "") : "",
// Claude: run without permission prompts — required for headless operation
dangerouslySkipPermissions: true,
// Codex: bypass sandbox and approval prompts, skip git trust check
dangerouslyBypassApprovalsAndSandbox: true,
extraArgs: job.connectorId === "codex_local" ? ["--skip-git-repo-check"] : [],
},
@@ -276,7 +250,9 @@ async function startRun(job: RunJob) {
} finally {
cancelledRuns.delete(job.runId);
activeByAgent.delete(job.agentId);
activeByWorkspace.delete(lockKey);
if (lockKey) {
activeByWorkspace.delete(lockKey);
}
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[] = [
{
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",
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." },
],
agents: [
// ────────────────────────────────────────────────────────────────────
// EXECUTIVE SUITE — indices 0-4
// ────────────────────────────────────────────────────────────────────
{
// index 0
name: "Victoria Sterling",
role: "CEO",
title: "Chief Executive Officer",
@@ -352,7 +339,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 250,
},
{
// index 1
name: "James Whitfield",
role: "COO",
title: "Chief Operating Officer",
@@ -363,7 +349,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 220,
},
{
// index 2
name: "Marcus Chen",
role: "CTO",
title: "Chief Technology Officer",
@@ -374,7 +359,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 250,
},
{
// index 3
name: "Priya Sharma",
role: "CFO",
title: "Chief Financial Officer",
@@ -385,7 +369,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 200,
},
{
// index 4
name: "Nadira Osei-Mensah",
role: "CHRO",
title: "Chief Human Resources Officer",
@@ -396,11 +379,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 180,
},
// ────────────────────────────────────────────────────────────────────
// ENGINEERING — indices 5-12
// ────────────────────────────────────────────────────────────────────
{
// index 5
name: "Elena Rodriguez",
role: "VP Engineering",
title: "Vice President of Engineering",
@@ -411,7 +390,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 220,
},
{
// index 6
name: "Tomasz Kowalski",
role: "Principal Engineer",
title: "Principal Software Engineer",
@@ -422,7 +400,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 280,
},
{
// index 7
name: "Kai Nakamura",
role: "Senior Backend Engineer",
title: "Senior Backend Engineer",
@@ -433,7 +410,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 250,
},
{
// index 8
name: "Sofia Andersson",
role: "Senior Frontend Engineer",
title: "Senior Frontend Engineer",
@@ -444,7 +420,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 250,
},
{
// index 9
name: "Omar Hassan",
role: "Platform / DevOps Engineer",
title: "Senior Platform Engineer",
@@ -455,7 +430,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 230,
},
{
// index 10
name: "Yuki Tanaka",
role: "QA Lead",
title: "Quality Assurance Lead",
@@ -466,7 +440,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 200,
},
{
// index 11
name: "Amina Diallo",
role: "Security Engineer",
title: "Application Security Engineer",
@@ -477,7 +450,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 230,
},
{
// index 12
name: "Ravi Krishnamurthy",
role: "Data Engineer",
title: "Senior Data Engineer",
@@ -488,11 +460,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 230,
},
// ────────────────────────────────────────────────────────────────────
// PRODUCT & DESIGN — indices 13-16
// ────────────────────────────────────────────────────────────────────
{
// index 13
name: "Amara Okafor",
role: "VP Product",
title: "Vice President of Product",
@@ -503,7 +471,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 200,
},
{
// index 14
name: "Daniel Okonkwo",
role: "Senior Product Manager",
title: "Senior Product Manager",
@@ -514,7 +481,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 180,
},
{
// index 15
name: "Ingrid Johansson",
role: "UX Director",
title: "Director of User Experience",
@@ -525,7 +491,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 180,
},
{
// index 16
name: "Leo Moretti",
role: "UI Designer",
title: "Senior UI Designer",
@@ -536,11 +501,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 160,
},
// ────────────────────────────────────────────────────────────────────
// MARKETING — indices 17-20
// ────────────────────────────────────────────────────────────────────
{
// index 17
name: "Jasmine Delacroix",
role: "CMO",
title: "Chief Marketing Officer",
@@ -551,7 +512,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 200,
},
{
// index 18
name: "Noah Park",
role: "Content Director",
title: "Director of Content",
@@ -562,7 +522,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 180,
},
{
// index 19
name: "Zara Ahmed",
role: "Growth Marketing Manager",
title: "Growth Marketing Manager",
@@ -573,7 +532,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 170,
},
{
// index 20
name: "Clara Dubois",
role: "Brand & PR Manager",
title: "Brand & Public Relations Manager",
@@ -584,11 +542,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 150,
},
// ────────────────────────────────────────────────────────────────────
// SALES — indices 21-23
// ────────────────────────────────────────────────────────────────────
{
// index 21
name: "Daniel Brooks",
role: "VP Sales",
title: "Vice President of Sales",
@@ -599,7 +553,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 200,
},
{
// index 22
name: "Grace Kimura",
role: "Enterprise Account Executive",
title: "Enterprise Account Executive",
@@ -610,7 +563,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 180,
},
{
// index 23
name: "Marcus Adebayo",
role: "SDR Manager",
title: "Sales Development Representative Manager",
@@ -621,11 +573,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 150,
},
// ────────────────────────────────────────────────────────────────────
// HR / PEOPLE — indices 24-26
// ────────────────────────────────────────────────────────────────────
{
// index 24
name: "Olivia Nguyen",
role: "VP People",
title: "Vice President of People",
@@ -636,7 +584,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 170,
},
{
// index 25
name: "Farid Al-Rashidi",
role: "Recruiting Lead",
title: "Head of Talent Acquisition",
@@ -647,7 +594,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 140,
},
{
// index 26
name: "Hannah Johansson",
role: "People Operations Manager",
title: "People Operations Manager",
@@ -658,11 +604,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 120,
},
// ────────────────────────────────────────────────────────────────────
// FINANCE & LEGAL — indices 27-29
// ────────────────────────────────────────────────────────────────────
{
// index 27
name: "Richard Okonkwo",
role: "VP Finance",
title: "Vice President of Finance",
@@ -673,7 +615,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 180,
},
{
// index 28
name: "Mei-Ling Wu",
role: "Financial Controller",
title: "Financial Controller",
@@ -684,7 +625,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 140,
},
{
// index 29
name: "Alejandro Vega",
role: "General Counsel",
title: "General Counsel",
@@ -695,11 +635,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 200,
},
// ────────────────────────────────────────────────────────────────────
// CUSTOMER SUCCESS — indices 30-32
// ────────────────────────────────────────────────────────────────────
{
// index 30
name: "Aisha Williams",
role: "VP Customer Success",
title: "Vice President of Customer Success",
@@ -710,7 +646,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 180,
},
{
// index 31
name: "Sergei Volkov",
role: "Senior Support Engineer",
title: "Senior Technical Support Engineer",
@@ -721,7 +656,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 160,
},
{
// index 32
name: "Camila Reyes",
role: "Customer Success Manager",
title: "Customer Success Manager",
@@ -732,11 +666,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 140,
},
// ────────────────────────────────────────────────────────────────────
// OPERATIONS — indices 33-34
// ────────────────────────────────────────────────────────────────────
{
// index 33
name: "Kenji Watanabe",
role: "Director of Operations",
title: "Director of Operations",
@@ -747,7 +677,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 160,
},
{
// index 34
name: "Laura Petrov",
role: "Program Manager",
title: "Senior Program Manager",
@@ -758,11 +687,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 150,
},
// ────────────────────────────────────────────────────────────────────
// RESEARCH — indices 35-36
// ────────────────────────────────────────────────────────────────────
{
// index 35
name: "Dr. Lena Hoffmann",
role: "Director of Research",
title: "Director of Research & Innovation",
@@ -773,7 +698,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 200,
},
{
// index 36
name: "Dr. Aarav Mehta",
role: "Research Scientist",
title: "Research Scientist",
@@ -828,7 +752,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
],
},
// ── E-Commerce Company ──────────────────────────────────────────────
{
id: "ecommerce-company",
name: "E-Commerce Business",
@@ -922,7 +845,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
],
},
// ── Consulting Firm ──────────────────────────────────────────────────
{
id: "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",
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." },
],
agents: [
// ── Executive (0-1) ──
{
// index 0
name: "Dr. Julian Crane",
role: "CEO",
title: "Chief Executive Officer",
@@ -1026,7 +943,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 250,
},
{
// index 1
name: "Dr. Mei-Xing Zhou",
role: "Chief Scientist",
title: "Chief Scientist",
@@ -1036,10 +952,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
connectorPreference: "claude_local",
budgetMonthlyUsd: 280,
},
// ── Research (2-6) ──
{
// index 2
name: "Dr. Sasha Petrov",
role: "Research Lead — Pre-training",
title: "Senior Research Scientist — Pre-training",
@@ -1050,7 +963,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 260,
},
{
// index 3
name: "Dr. Nneka Adeyemi",
role: "Research Lead — Post-training",
title: "Senior Research Scientist — RLHF & Fine-tuning",
@@ -1061,7 +973,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 260,
},
{
// index 4
name: "Dr. Hiroshi Takeda",
role: "Research Scientist — Reasoning",
title: "Research Scientist — Reasoning & Agents",
@@ -1072,7 +983,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 240,
},
{
// index 5
name: "Dr. Amara Osei",
role: "Research Scientist — Safety",
title: "Research Scientist — AI Safety",
@@ -1083,7 +993,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 240,
},
{
// index 6
name: "Lila Fernandez",
role: "Research Engineer",
title: "Senior Research Engineer",
@@ -1093,10 +1002,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
connectorPreference: "codex_local",
budgetMonthlyUsd: 250,
},
// ── Engineering (7-10) ──
{
// index 7
name: "Rajiv Sundaram",
role: "VP Engineering",
title: "Vice President of Engineering",
@@ -1107,7 +1013,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 220,
},
{
// index 8
name: "Christine Bergstrom",
role: "ML Infrastructure Engineer",
title: "Senior ML Infrastructure Engineer",
@@ -1118,7 +1023,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 250,
},
{
// index 9
name: "Andre Williams",
role: "Backend Engineer",
title: "Senior Backend Engineer",
@@ -1129,7 +1033,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 230,
},
{
// index 10
name: "Yuna Kim",
role: "Frontend Engineer",
title: "Senior Frontend Engineer",
@@ -1139,10 +1042,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
connectorPreference: "codex_local",
budgetMonthlyUsd: 220,
},
// ── Product & Design (11-13) ──
{
// index 11
name: "Tara Blackwell",
role: "VP Product",
title: "Vice President of Product",
@@ -1153,7 +1053,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 200,
},
{
// index 12
name: "Omar Khalil",
role: "Product Manager — Developer Platform",
title: "Product Manager — Developer Platform",
@@ -1164,7 +1063,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 160,
},
{
// index 13
name: "Vera Lindqvist",
role: "Product Designer",
title: "Senior Product Designer",
@@ -1174,10 +1072,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
connectorPreference: "claude_local",
budgetMonthlyUsd: 160,
},
// ── Marketing & DevRel (14-15) ──
{
// index 14
name: "Marcus Rhodes",
role: "Head of Marketing",
title: "Head of Marketing & Communications",
@@ -1188,7 +1083,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 170,
},
{
// index 15
name: "Priya Khatri",
role: "Developer Advocate",
title: "Senior Developer Advocate",
@@ -1198,10 +1092,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
connectorPreference: "codex_local",
budgetMonthlyUsd: 170,
},
// ── People & Finance & Legal (16-17) ──
{
// index 16
name: "Danielle Moreau",
role: "Head of People",
title: "Head of People",
@@ -1212,7 +1103,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 150,
},
{
// index 17
name: "Gabriel Souza",
role: "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",
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." },
],
agents: [
// ── Leadership (0-1) ──
{
// index 0
name: "Isabelle Fontaine",
role: "Managing Director",
title: "Managing Director",
@@ -1281,7 +1166,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 200,
},
{
// index 1
name: "Victor Nascimento",
role: "Account Director",
title: "Account Director",
@@ -1291,10 +1175,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
connectorPreference: "claude_local",
budgetMonthlyUsd: 170,
},
// ── Creative & Design (2-4) ──
{
// index 2
name: "Saoirse O'Brien",
role: "Creative Director",
title: "Creative Director",
@@ -1305,7 +1186,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 200,
},
{
// index 3
name: "Mateo Cruz",
role: "UX/UI Designer",
title: "Senior UX/UI Designer",
@@ -1316,7 +1196,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 170,
},
{
// index 4
name: "Yuki Aoyama",
role: "Motion & Visual Designer",
title: "Motion & Visual Designer",
@@ -1326,10 +1205,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
connectorPreference: "claude_local",
budgetMonthlyUsd: 140,
},
// ── Development (5-7) ──
{
// index 5
name: "Nadia Kowalczyk",
role: "Technical Director",
title: "Technical Director",
@@ -1340,7 +1216,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 220,
},
{
// index 6
name: "Kwame Asante",
role: "Full-Stack Developer",
title: "Senior Full-Stack Developer",
@@ -1351,7 +1226,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 230,
},
{
// index 7
name: "Elina Koskinen",
role: "Frontend Developer",
title: "Frontend Developer",
@@ -1361,10 +1235,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
connectorPreference: "codex_local",
budgetMonthlyUsd: 180,
},
// ── Digital Marketing (8-10) ──
{
// index 8
name: "Tariq Benali",
role: "Head of Digital",
title: "Head of Digital Marketing",
@@ -1375,7 +1246,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 180,
},
{
// index 9
name: "Jessica Torres",
role: "SEO & Content Specialist",
title: "SEO & Content Specialist",
@@ -1386,7 +1256,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 150,
},
{
// index 10
name: "Ryan Gallagher",
role: "Paid Media Specialist",
title: "Paid Media Specialist",
@@ -1396,10 +1265,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
connectorPreference: "claude_local",
budgetMonthlyUsd: 150,
},
// ── Strategy & Content (11-12) ──
{
// index 11
name: "Ananya Chakraborty",
role: "Strategy Lead",
title: "Digital Strategy Lead",
@@ -1410,7 +1276,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 180,
},
{
// index 12
name: "Liam O'Sullivan",
role: "Copywriter",
title: "Senior Copywriter",
@@ -1420,10 +1285,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
connectorPreference: "claude_local",
budgetMonthlyUsd: 160,
},
// ── Operations (13) ──
{
// index 13
name: "Sandra Muller",
role: "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",
name: "B2B SaaS Company",
@@ -1474,9 +1333,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
{ id: "hr", name: "People", description: "Recruiting, onboarding, culture, and people operations." },
],
agents: [
// ── Executive (0-1) ──
{
// index 0
name: "Nathan Cross",
role: "CEO",
title: "Chief Executive Officer",
@@ -1487,7 +1344,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 230,
},
{
// index 1
name: "Samira Rostami",
role: "CTO",
title: "Chief Technology Officer",
@@ -1497,10 +1353,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
connectorPreference: "claude_local",
budgetMonthlyUsd: 240,
},
// ── Engineering (2-6) ──
{
// index 2
name: "Derek Johansson",
role: "VP Engineering",
title: "Vice President of Engineering",
@@ -1511,7 +1364,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 210,
},
{
// index 3
name: "Priya Venkatesh",
role: "Senior Backend Engineer",
title: "Senior Backend Engineer",
@@ -1522,7 +1374,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 250,
},
{
// index 4
name: "Luis Mendoza",
role: "Senior Frontend Engineer",
title: "Senior Frontend Engineer",
@@ -1533,7 +1384,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 240,
},
{
// index 5
name: "Anika Bergman",
role: "Platform Engineer",
title: "Senior Platform Engineer",
@@ -1544,7 +1394,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 230,
},
{
// index 6
name: "Tomoko Sato",
role: "QA Engineer",
title: "Quality Assurance Engineer",
@@ -1554,10 +1403,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
connectorPreference: "codex_local",
budgetMonthlyUsd: 180,
},
// ── Product & Design (7-9) ──
{
// index 7
name: "Rachel Abrams",
role: "VP Product",
title: "Vice President of Product",
@@ -1568,7 +1414,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 200,
},
{
// index 8
name: "Jake Morrison",
role: "Product Manager",
title: "Product Manager",
@@ -1579,7 +1424,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 170,
},
{
// index 9
name: "Chloe Laurent",
role: "Product Designer",
title: "Senior Product Designer",
@@ -1589,10 +1433,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
connectorPreference: "claude_local",
budgetMonthlyUsd: 170,
},
// ── Marketing (10-12) ──
{
// index 10
name: "Ava Richardson",
role: "VP Marketing",
title: "Vice President of Marketing",
@@ -1603,7 +1444,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 190,
},
{
// index 11
name: "Benjamin Hart",
role: "Content Marketing Manager",
title: "Content Marketing Manager",
@@ -1614,7 +1454,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 160,
},
{
// index 12
name: "Daria Volkov",
role: "Growth & Demand Gen Manager",
title: "Growth & Demand Generation Manager",
@@ -1624,10 +1463,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
connectorPreference: "claude_local",
budgetMonthlyUsd: 160,
},
// ── Sales (13-15) ──
{
// index 13
name: "Carter Ellison",
role: "VP Sales",
title: "Vice President of Sales",
@@ -1638,7 +1474,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 200,
},
{
// index 14
name: "Nina Johansson",
role: "Enterprise Account Executive",
title: "Enterprise Account Executive",
@@ -1649,7 +1484,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 180,
},
{
// index 15
name: "Travis Chen",
role: "SDR Lead",
title: "Sales Development Representative Lead",
@@ -1659,10 +1493,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
connectorPreference: "claude_local",
budgetMonthlyUsd: 130,
},
// ── Customer Success & Support (16-18) ──
{
// index 16
name: "Miriam Okafor",
role: "VP Customer Success",
title: "Vice President of Customer Success",
@@ -1673,7 +1504,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 180,
},
{
// index 17
name: "Alex Petridis",
role: "Customer Success Manager",
title: "Customer Success Manager",
@@ -1684,7 +1514,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
budgetMonthlyUsd: 140,
},
{
// index 18
name: "Rina Tanaka",
role: "Support Engineer",
title: "Technical Support Engineer",
@@ -1694,10 +1523,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
connectorPreference: "codex_local",
budgetMonthlyUsd: 140,
},
// ── Finance & People (19) ──
{
// index 19
name: "Henrik Larsen",
role: "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),
getApiPort: () => ipcRenderer.invoke(IPC_CHANNELS.getApiPort),
getInbox: (companyId) => ipcRenderer.invoke(IPC_CHANNELS.getInbox, companyId),
dismissInboxItem: (input) => ipcRenderer.invoke(IPC_CHANNELS.dismissInboxItem, input),
getCompanyMetrics: (input) => ipcRenderer.invoke(IPC_CHANNELS.getCompanyMetrics, input),
getAgentMetrics: (input) => ipcRenderer.invoke(IPC_CHANNELS.getAgentMetrics, input),
getStandupReport: (companyId) => ipcRenderer.invoke(IPC_CHANNELS.getStandupReport, companyId),
@@ -55,32 +54,24 @@ const api: DesktopApi = {
listBrowserActions: (input) => ipcRenderer.invoke(IPC_CHANNELS.listBrowserActions, input),
cancelBrowserAction: (input) => ipcRenderer.invoke(IPC_CHANNELS.cancelBrowserAction, input),
triggerBrowserLogin: (id) => ipcRenderer.invoke(IPC_CHANNELS.triggerBrowserLogin, id),
// Meetings
saveMeeting: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveMeeting, input),
deleteMeeting: (input) => ipcRenderer.invoke(IPC_CHANNELS.deleteMeeting, input),
// Documents
saveDocument: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveDocument, input),
deleteDocument: (input) => ipcRenderer.invoke(IPC_CHANNELS.deleteDocument, input),
// Knowledge Base
saveKnowledgeEntry: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveKnowledgeEntry, input),
deleteKnowledgeEntry: (input) => ipcRenderer.invoke(IPC_CHANNELS.deleteKnowledgeEntry, input),
// Sprints
saveSprint: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveSprint, input),
deleteSprint: (input) => ipcRenderer.invoke(IPC_CHANNELS.deleteSprint, input),
// Automation
saveAutomationRule: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveAutomationRule, input),
deleteAutomationRule: (input) => ipcRenderer.invoke(IPC_CHANNELS.deleteAutomationRule, input),
toggleAutomationRule: (input) => ipcRenderer.invoke(IPC_CHANNELS.toggleAutomationRule, input),
getAutomationLog: (companyId) => ipcRenderer.invoke(IPC_CHANNELS.getAutomationLog, companyId),
// Agent Messages
sendAgentMessage: (input) => ipcRenderer.invoke(IPC_CHANNELS.sendAgentMessage, input),
listAgentMessages: (input) => ipcRenderer.invoke(IPC_CHANNELS.listAgentMessages, input),
markMessageRead: (input) => ipcRenderer.invoke(IPC_CHANNELS.markMessageRead, input),
// Workflows
saveWorkflow: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveWorkflow, input),
deleteWorkflow: (input) => ipcRenderer.invoke(IPC_CHANNELS.deleteWorkflow, input),
runWorkflow: (input) => ipcRenderer.invoke(IPC_CHANNELS.runWorkflow, input),
// Auto-assign
autoAssignTask: (input) => ipcRenderer.invoke(IPC_CHANNELS.autoAssignTask, input),
subscribe: (callback: (event: DesktopEvent) => void) => {
const listener = (_event: Electron.IpcRendererEvent, payload: DesktopEvent) => callback(payload);
+124 -211
View File
@@ -3,7 +3,6 @@ import { AnimatePresence, motion } from "framer-motion";
import {
ArrowClockwise,
Bell,
BookOpen,
Brain,
CalendarBlank,
CaretUpDown,
@@ -39,8 +38,6 @@ import {
X,
} from "@phosphor-icons/react";
import type {
AgentMessageRecord,
AutomationRuleRecord,
CompanyRecord,
CompanyMetrics,
DesktopEvent,
@@ -51,7 +48,6 @@ import type {
SocialAccountRecord,
BrowserActionRecord,
StandupReport,
WorkflowPipelineRecord,
} from "@shared/types";
import type { SidebarBadges } from "@shared/contracts";
import { CompanyOnboarding } from "./components/CompanyOnboarding";
@@ -72,6 +68,7 @@ import {
TaskInspector,
TaskQueue,
WorkspaceCreator,
WorkspaceInspector,
WorkspaceList,
} from "./components/execution";
import {
@@ -90,6 +87,7 @@ import { SocialAccountsPanel } from "./components/social";
import { AutomationPanel } from "./components/automation";
import { CommunicationHub } from "./components/communication";
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 { unwrap } from "./lib/desktop";
import { formatMoney, formatTime } from "./lib/formatters";
@@ -175,12 +173,15 @@ export function App() {
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
const [selectedGoalId, setSelectedGoalId] = 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 logBufferRef = useRef<Record<string, string>>({});
const [, setLogRevision] = useState(0);
const [badges, setBadges] = useState<SidebarBadges | null>(null);
const [commandPaletteSearch, setCommandPaletteSearch] = useState("");
const [showOnboarding, setShowOnboarding] = useState(false);
const [initialOnboardingLocked, setInitialOnboardingLocked] = useState(false);
const [inboxItems, setInboxItems] = useState<InboxItem[]>([]);
const [companyMetrics, setCompanyMetrics] = useState<CompanyMetrics | null>(null);
const [standupReport, setStandupReport] = useState<StandupReport | null>(null);
@@ -191,23 +192,46 @@ export function App() {
try {
const next = unwrap<ProfileSnapshot>(await window.agentCompany.loadSnapshot());
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 (!selectedApprovalId && next.approvals[0]) setSelectedApprovalId(next.approvals[0].id);
if (!selectedRunId && next.runs[0]) setSelectedRunId(next.runs[0].id);
if (next.currentCompanyId) {
const badgeResult = await window.agentCompany.getSidebarBadges(next.currentCompanyId);
if (badgeResult.ok) setBadges(badgeResult.data);
setSocialAccounts(next.socialAccounts?.filter(a => a.companyId === next.currentCompanyId) ?? []);
const browserActionResult = await window.agentCompany.listBrowserActions({ companyId: next.currentCompanyId, limit: 50 });
if (browserActionResult.ok) {
setBrowserActions(browserActionResult.data);
}
}
setError(null);
} catch (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(() => {
void refresh();
void refreshRef.current();
const unsubscribe = window.agentCompany.subscribe((event: DesktopEvent) => {
if (event.type === "run-log") {
logBufferRef.current = {
@@ -220,13 +244,18 @@ export function App() {
if (event.type === "shortcut") {
if (event.action === "toggle-command-palette") setCommandPaletteOpen((current) => !current);
if (event.action === "toggle-console") setConsoleOpen((current) => !current);
if (event.action === "refresh") void refresh();
if (event.action === "refresh") void refreshRef.current();
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;
}, [refresh]);
return () => {
unsubscribe();
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
};
}, [debouncedRefresh]);
useEffect(() => {
if (!selectedRunId) return;
@@ -272,13 +301,9 @@ export function App() {
if (section === "social") {
setSocialAccounts(snapshot.socialAccounts?.filter(a => a.companyId === companyId) ?? []);
void window.agentCompany.listBrowserActions({ companyId, limit: 50 }).then(result => {
if (result.ok) setBrowserActions(result.data);
});
}
}, [section, snapshot?.currentCompanyId]);
}, [section, snapshot]);
// Global Escape key handler for command palette
useEffect(() => {
if (!commandPaletteOpen) return;
function onKey(event: KeyboardEvent) {
@@ -302,11 +327,10 @@ export function App() {
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 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 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 selectedAgent = companyAgents.find((a) => a.id === selectedAgentId) ?? null;
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 selectedProject = companyProjects.find((p) => p.id === selectedProjectId) ?? null;
// Sidebar badge mapping
const activeInboxCount = useMemo(() => inboxItems.filter((item) => !item.dismissed).length, [inboxItems]);
const activeInboxCount = inboxItems.length;
const navBadges = useMemo<Partial<Record<SectionId, { count: number; tone: "default" | "warn" | "danger" }>>>(() => badges ? {
inbox: activeInboxCount > 0 ? { count: activeInboxCount, tone: "warn" } : undefined,
tasks: badges.todoTasks > 0 ? { count: badges.todoTasks, tone: "default" } : undefined,
approvals: badges.pendingApprovals > 0 ? { count: badges.pendingApprovals, tone: "warn" } : undefined,
costs: badges.budgetWarnings > 0 ? { count: badges.budgetWarnings, tone: "danger" } : undefined,
communication: badges.unreadMessages > 0 ? { count: badges.unreadMessages, tone: "warn" } : undefined,
} : {}, [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 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 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 totalSpend = useMemo(() => companyCosts.reduce((sum, entry) => sum + (entry.amountUsd ?? 0), 0), [companyCosts]);
const overviewRows = useMemo(() => [
{ label: "Live runs", value: String(liveRuns.length), tone: liveRuns.length > 0 ? "text-[color:var(--success)]" : "" },
{ label: "Pending approvals", value: String(pendingApprovals.length), tone: pendingApprovals.length > 0 ? "text-[color:var(--warn)]" : "" },
{ label: "Active tasks", value: String(tasksInProgress.length), tone: "" },
{ label: "Known spend", value: formatMoney(totalSpend), tone: "" },
], [liveRuns, pendingApprovals, tasksInProgress, totalSpend]);
// Command palette entity search
const companyDocuments = useMemo(() => snapshot?.documents?.filter((d) => !currentCompany || d.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
const companyKnowledge = useMemo(() => snapshot?.knowledgeBase?.filter((k) => !currentCompany || k.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
const companyMeetings = useMemo(() => snapshot?.meetings?.filter((m) => !currentCompany || m.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
const companySprints = useMemo(() => snapshot?.sprints?.filter((s) => !currentCompany || s.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
const paletteQuery = commandPaletteSearch.trim().toLowerCase();
const paletteResults = useMemo(() => {
const filteredNavItems = paletteQuery
@@ -354,6 +370,18 @@ export function App() {
}, [paletteQuery, companyAgents, tasks, companyGoals, companyProjects]);
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) => {
if (companyId === "__new__") {
setShowOnboarding(true);
@@ -419,8 +447,12 @@ export function App() {
);
}
if (snapshot.companies.length === 0 || showOnboarding) {
return <CompanyOnboarding connectors={snapshot.connectors} onDone={async () => { setShowOnboarding(false); await refresh(); }} />;
if (snapshot.companies.length === 0 || showOnboarding || initialOnboardingLocked) {
return <CompanyOnboarding connectors={snapshot.connectors} onDone={async () => {
setShowOnboarding(false);
setInitialOnboardingLocked(false);
await refresh();
}} />;
}
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"}`}
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}
</button>
</div>
@@ -626,67 +658,33 @@ export function App() {
activity={filteredActivity}
costs={companyCosts}
connectors={snapshot.connectors}
meetings={snapshot.meetings?.filter((m) => !currentCompany || m.companyId === currentCompany.id)}
sprints={snapshot.sprints?.filter((s) => !currentCompany || s.companyId === currentCompany.id)}
onNavigate={(targetSection, entityId) => {
setSection(targetSection);
if (entityId) {
if (targetSection === "agents") setSelectedAgentId(entityId);
else if (targetSection === "tasks") setSelectedTaskId(entityId);
else if (targetSection === "approvals") setSelectedApprovalId(entityId);
else if (targetSection === "goals") setSelectedGoalId(entityId);
else if (targetSection === "projects") setSelectedProjectId(entityId);
}
}}
meetings={companyMeetings}
sprints={companySprints}
messages={snapshot.agentMessages?.filter((message) => !currentCompany || message.companyId === currentCompany.id) ?? []}
browserActions={browserActions}
workflows={snapshot.workflows?.filter((workflow) => !currentCompany || workflow.companyId === currentCompany.id) ?? []}
onNavigate={navigateTo}
/>
) : null}
{section === "inbox" ? (
<Inbox
items={inboxItems}
onNavigate={(targetSection, entityId) => {
setSection(targetSection);
if (entityId) {
if (targetSection === "agents") setSelectedAgentId(entityId);
else if (targetSection === "tasks") setSelectedTaskId(entityId);
else if (targetSection === "approvals") setSelectedApprovalId(entityId);
else if (targetSection === "goals") setSelectedGoalId(entityId);
else if (targetSection === "projects") setSelectedProjectId(entityId);
}
}}
onDismiss={(_itemId) => { /* no-op for now */ }}
onNavigate={navigateTo}
/>
) : null}
{section === "standup" ? (
<StandupView
report={standupReport}
onNavigate={(targetSection, entityId) => {
setSection(targetSection);
if (entityId) {
if (targetSection === "agents") setSelectedAgentId(entityId);
else if (targetSection === "tasks") setSelectedTaskId(entityId);
else if (targetSection === "approvals") setSelectedApprovalId(entityId);
else if (targetSection === "goals") setSelectedGoalId(entityId);
else if (targetSection === "projects") setSelectedProjectId(entityId);
}
}}
onNavigate={navigateTo}
/>
) : null}
{section === "performance" ? (
<PerformanceView
metrics={companyMetrics}
onNavigate={(targetSection, entityId) => {
setSection(targetSection);
if (entityId) {
if (targetSection === "agents") setSelectedAgentId(entityId);
else if (targetSection === "tasks") setSelectedTaskId(entityId);
else if (targetSection === "approvals") setSelectedApprovalId(entityId);
else if (targetSection === "goals") setSelectedGoalId(entityId);
else if (targetSection === "projects") setSelectedProjectId(entityId);
}
}}
onNavigate={navigateTo}
/>
) : null}
@@ -717,7 +715,7 @@ export function App() {
</Panel>
<Panel
title={selectedConnector.label}
eyebrow={undefined}
action={<ActionButton label="Run health check" onClick={() => void quickConnectorCheck()} icon={Wrench} tone="accent" />}
>
<ConnectorEditor connector={selectedConnector} onSaved={refresh} />
@@ -762,7 +760,6 @@ export function App() {
{section === "hiring" && currentCompany ? (
<HiringPipeline
companyId={currentCompany.id}
agents={companyAgents}
approvals={(snapshot?.approvals ?? []).filter(a => a.companyId === currentCompany!.id)}
onRefresh={refresh}
@@ -906,7 +903,7 @@ export function App() {
tasks={tasks}
companyId={currentCompany!.id}
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();
}}
onDeleted={async () => { setSelectedApprovalId(null); await refresh(); }}
@@ -965,16 +962,19 @@ export function App() {
}}
onLogin={async (accountId) => {
await window.agentCompany.triggerBrowserLogin(accountId);
await refresh();
}}
onNavigate={(s, id) => {
setSection(s);
if (id) {
if (s === "agents") setSelectedAgentId(id);
else if (s === "tasks") setSelectedTaskId(id);
else if (s === "approvals") setSelectedApprovalId(id);
else if (s === "goals") setSelectedGoalId(id);
else if (s === "projects") setSelectedProjectId(id);
}
onOpenPath={async (path) => {
await window.agentCompany.openPath(path);
}}
onCancelAction={async (actionId) => {
await window.agentCompany.cancelBrowserAction({ runId: actionId });
await refresh();
}}
onNavigate={(sectionName, entityId) => {
setSection(sectionName);
if (sectionName === "tasks") setSelectedTaskId(entityId);
if (sectionName === "approvals") setSelectedApprovalId(entityId);
}}
/>
)}
@@ -983,7 +983,6 @@ export function App() {
<AutomationPanel
rules={snapshot.automationRules?.filter(r => r.companyId === currentCompany.id) ?? []}
workflows={snapshot.workflows?.filter(w => w.companyId === currentCompany.id) ?? []}
agents={companyAgents}
companyId={currentCompany.id}
onRefresh={refresh}
/>
@@ -992,137 +991,36 @@ export function App() {
{section === "communication" && currentCompany ? (
<CommunicationHub
messages={snapshot.agentMessages?.filter(m => m.companyId === currentCompany.id) ?? []}
comments={snapshot.comments?.filter((comment) => comment.companyId === currentCompany.id) ?? []}
tasks={tasks}
agents={companyAgents}
companyId={currentCompany.id}
selectedEntityId={selectedCommunicationEntityId}
onRefresh={refresh}
/>
) : null}
{section === "documents" && currentCompany ? (
<Panel title="Company Documents" action={<span className="text-[11px] text-[color:var(--muted)]">{(snapshot.documents?.filter(d => d.companyId === currentCompany.id) ?? []).length} documents</span>}>
{(snapshot.documents?.filter(d => d.companyId === currentCompany.id) ?? []).length === 0 ? (
<div className="py-10 text-center text-[13px] text-[color:var(--muted)]">
<FileText size={32} className="mx-auto mb-3 opacity-40" />
<p>No documents yet. Agents will create PRDs, specs, and reports as they work.</p>
</div>
) : (
<ListFrame>
{(snapshot.documents?.filter(d => d.companyId === currentCompany.id) ?? []).map(doc => (
<ListRow key={doc.id}>
<div className="flex items-center justify-between">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<FileText size={14} className="text-[color:var(--accent)]" />
<span className="text-[13px] font-medium text-[color:var(--text)]">{doc.title}</span>
</div>
<div className="mt-1 flex items-center gap-3 text-[11px] text-[color:var(--muted)]">
<span className="uppercase tracking-wider">{doc.type.replace(/_/g, " ")}</span>
<span>v{doc.version}</span>
{doc.authorAgentId && <span>by {companyAgents.find(a => a.id === doc.authorAgentId)?.name ?? "Unknown"}</span>}
</div>
</div>
<StatusPill status={doc.status} />
</div>
</ListRow>
))}
</ListFrame>
)}
</Panel>
<DocumentCenter
companyId={currentCompany.id}
documents={companyDocuments}
agents={companyAgents}
projects={companyProjects}
goals={companyGoals}
onRefresh={refresh}
/>
) : null}
{section === "knowledge" && currentCompany ? (
<Panel title="Knowledge Base" action={<span className="text-[11px] text-[color:var(--muted)]">{(snapshot.knowledgeBase?.filter(k => k.companyId === currentCompany.id) ?? []).length} entries</span>}>
{(snapshot.knowledgeBase?.filter(k => k.companyId === currentCompany.id) ?? []).length === 0 ? (
<div className="py-10 text-center text-[13px] text-[color:var(--muted)]">
<Brain size={32} className="mx-auto mb-3 opacity-40" />
<p>No knowledge entries yet. Agents learn and document lessons as they work.</p>
</div>
) : (
<ListFrame>
{(snapshot.knowledgeBase?.filter(k => k.companyId === currentCompany.id) ?? []).map(entry => (
<ListRow key={entry.id}>
<div className="flex items-center justify-between">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<Brain size={14} className="text-[color:var(--accent)]" />
<span className="text-[13px] font-medium text-[color:var(--text)]">{entry.topic}</span>
</div>
<div className="mt-1 text-[11px] text-[color:var(--muted)]">
<span className="uppercase tracking-wider">{entry.category.replace(/_/g, " ")}</span>
<span className="mx-2">-</span>
<span>{entry.content.slice(0, 100)}{entry.content.length > 100 ? "..." : ""}</span>
</div>
</div>
<StatusPill status={entry.importance} />
</div>
</ListRow>
))}
</ListFrame>
)}
</Panel>
<KnowledgeBaseManager companyId={currentCompany.id} entries={companyKnowledge} onRefresh={refresh} />
) : null}
{section === "meetings" && currentCompany ? (
<Panel title="Meetings" action={<span className="text-[11px] text-[color:var(--muted)]">{(snapshot.meetings?.filter(m => m.companyId === currentCompany.id) ?? []).length} meetings</span>}>
{(snapshot.meetings?.filter(m => m.companyId === currentCompany.id) ?? []).length === 0 ? (
<div className="py-10 text-center text-[13px] text-[color:var(--muted)]">
<CalendarBlank size={32} className="mx-auto mb-3 opacity-40" />
<p>No meetings scheduled. Agents will schedule standups, reviews, and planning sessions.</p>
</div>
) : (
<ListFrame>
{(snapshot.meetings?.filter(m => m.companyId === currentCompany.id) ?? []).map(meeting => (
<ListRow key={meeting.id}>
<div className="flex items-center justify-between">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<CalendarBlank size={14} className="text-[color:var(--accent)]" />
<span className="text-[13px] font-medium text-[color:var(--text)]">{meeting.title}</span>
</div>
<div className="mt-1 flex items-center gap-3 text-[11px] text-[color:var(--muted)]">
<span className="uppercase tracking-wider">{meeting.type.replace(/_/g, " ")}</span>
<span>{new Date(meeting.scheduledAt).toLocaleString()}</span>
<span>{meeting.durationMinutes}min</span>
</div>
</div>
<StatusPill status={meeting.status} />
</div>
</ListRow>
))}
</ListFrame>
)}
</Panel>
<MeetingsManager companyId={currentCompany.id} meetings={companyMeetings} agents={companyAgents} onRefresh={refresh} />
) : null}
{section === "sprints" && currentCompany ? (
<Panel title="Sprint Board" action={<span className="text-[11px] text-[color:var(--muted)]">{(snapshot.sprints?.filter(s => s.companyId === currentCompany.id) ?? []).length} sprints</span>}>
{(snapshot.sprints?.filter(s => s.companyId === currentCompany.id) ?? []).length === 0 ? (
<div className="py-10 text-center text-[13px] text-[color:var(--muted)]">
<Rows size={32} className="mx-auto mb-3 opacity-40" />
<p>No sprints yet. Start a sprint to organize work into focused cycles.</p>
</div>
) : (
<ListFrame>
{(snapshot.sprints?.filter(s => s.companyId === currentCompany.id) ?? []).map(sprint => (
<ListRow key={sprint.id}>
<div className="flex items-center justify-between">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<Rows size={14} className="text-[color:var(--accent)]" />
<span className="text-[13px] font-medium text-[color:var(--text)]">{sprint.name}</span>
</div>
<div className="mt-1 flex items-center gap-3 text-[11px] text-[color:var(--muted)]">
<span>{sprint.startDate} - {sprint.endDate}</span>
<span>{sprint.completedPoints}/{sprint.velocityPoints} pts</span>
</div>
</div>
<StatusPill status={sprint.status} />
</div>
</ListRow>
))}
</ListFrame>
)}
</Panel>
<SprintManager companyId={currentCompany.id} sprints={companySprints} onRefresh={refresh} />
) : null}
{section === "workspaces" ? (
@@ -1135,15 +1033,30 @@ export function App() {
workspaces={companyWorkspaces}
projects={companyProjects}
companyId={currentCompany!.id}
selectedWorkspaceId={selectedWorkspace?.id ?? null}
onSelect={setSelectedWorkspaceId}
onOpen={(path) => window.agentCompany.openPath(path).then(() => undefined)}
onDeleted={refresh}
/>
</Panel>
}
secondary={
<Panel title="Add workspace">
<WorkspaceCreator companyId={currentCompany!.id} projects={companyProjects} onSaved={refresh} />
</Panel>
<div className="space-y-6">
<Panel title="Workspace control center">
<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}
+66 -63
View File
@@ -20,6 +20,7 @@ import {
} from "@phosphor-icons/react";
import type { CompanyTemplate, ProfileSnapshot } from "@shared/types";
import { unwrap } from "../lib/desktop";
import { getAgentInitials } from "../lib/formatters";
import { ActionButton, Input, TextArea } from "./ui";
type Step = "choose" | "template" | "company" | "goal" | "launch";
@@ -56,7 +57,6 @@ export function CompanyOnboarding({
const bestConnector = readyConnectors[0] ?? null;
const selectedTemplate = templates.find((t) => t.id === selectedTemplateId) ?? null;
// Load templates
useEffect(() => {
window.agentCompany.listCompanyTemplates().then((result) => {
if (result.ok) setTemplates(result.data);
@@ -75,7 +75,6 @@ export function CompanyOnboarding({
}
}
// Template launch
const launchFromTemplate = useCallback(async () => {
if (loading || !selectedTemplate) return;
if (!companyName.trim()) {
@@ -98,7 +97,6 @@ export function CompanyOnboarding({
}),
);
// Create workspace for the company if path provided
if (workspacePath) {
unwrap(
await window.agentCompany.saveWorkspace({
@@ -122,7 +120,6 @@ export function CompanyOnboarding({
}
}, [loading, selectedTemplate, companyName, hasReadyConnector, workspacePath, bestConnector, onDone]);
// Custom company creation (existing flow)
const createCompany = useCallback(async () => {
if (loading) return;
if (!companyName.trim()) {
@@ -166,66 +163,77 @@ export function CompanyOnboarding({
}
}, [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 () => {
if (loading || !createdCompanyId) return;
setLoading(true);
setError(null);
try {
if (goalTitle.trim()) {
unwrap(
await window.agentCompany.saveGoal({
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();
await bootstrapInitialCompany(createdCompanyId, {
title: goalTitle,
description: goalDescription,
});
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : "Unable to set up the company.");
setLoading(false);
}
}, [loading, createdCompanyId, goalTitle, goalDescription, bestConnector, onDone]);
}, [loading, createdCompanyId, goalTitle, goalDescription, bootstrapInitialCompany]);
const skipGoalAndFinish = useCallback(async () => {
if (loading) return;
if (loading || !createdCompanyId) return;
setLoading(true);
setError(null);
try {
await onDone();
await bootstrapInitialCompany(createdCompanyId);
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : "Unable to continue.");
} finally {
setLoading(false);
}
}, [loading, onDone]);
}, [loading, createdCompanyId, bootstrapInitialCompany]);
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;
return (
<div className="grid min-h-[100dvh] grid-cols-1 bg-[color:var(--bg)] md:grid-cols-[minmax(0,1fr)_520px]">
{/* Left panel — branding */}
<div className="flex items-center px-8 py-12 md:px-16">
<div className="grid h-[100dvh] overflow-hidden grid-cols-1 bg-[color:var(--bg)] md:grid-cols-[minmax(0,1fr)_520px]">
<div className="flex min-h-0 items-center justify-center overflow-y-auto px-8 py-12 md:px-16">
<motion.div
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
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>
<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.
</p>
{/* Step indicators */}
<div className="mt-10 flex items-center gap-3">
{stepLabels.map((s, i) => (
<div key={s.label} className="flex items-center gap-2">
@@ -278,7 +285,7 @@ export function CompanyOnboarding({
))}
</div>
{/* Detected connectors */}
<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="space-y-2.5">
@@ -313,10 +320,9 @@ export function CompanyOnboarding({
</motion.div>
</div>
{/* Right panel — form steps */}
<div className="flex items-center border-l border-[color:var(--line)] bg-[color:var(--bg-subtle)] px-6 py-10 md:px-10">
<div className="min-h-0 overflow-y-auto border-l border-[color:var(--line)] bg-[color:var(--bg-subtle)]">
<div className="flex min-h-full items-center px-6 py-10 md:px-10">
<AnimatePresence mode="wait">
{/* ── Step: Choose Mode ────────────────────────── */}
{step === "choose" ? (
<motion.div
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>
{/* Template cards */}
<div className="space-y-2">
<div className="text-[11px] font-semibold uppercase tracking-[0.06em] text-[color:var(--accent)]">Quick Start — Choose a template</div>
<div className="grid grid-cols-1 gap-2">
@@ -378,7 +384,7 @@ export function CompanyOnboarding({
<div className="h-px flex-1 bg-[color:var(--line)]" />
</div>
{/* Custom option */}
<button
type="button"
onClick={() => setStep("company")}
@@ -395,7 +401,6 @@ export function CompanyOnboarding({
</button>
</motion.div>
) : step === "template" && selectedTemplate ? (
/* ── Step: Template Configuration ─────────────── */
<motion.div
key="template"
initial={{ opacity: 0, y: 18 }}
@@ -421,7 +426,7 @@ export function CompanyOnboarding({
</div>
</div>
{/* Team preview */}
<div className="mt-5 rounded-[8px] border border-[color:var(--line)] bg-[color:var(--panel-soft)] px-4 py-3">
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.06em] text-[color:var(--muted)]">Team that will be created</div>
<div className="space-y-1.5">
@@ -430,7 +435,7 @@ export function CompanyOnboarding({
<div className={`flex h-6 w-6 items-center justify-center rounded-full text-[8px] font-bold ${
agent.reportsToIndex === null ? "bg-[color:var(--accent-soft)] text-[color:var(--accent)]" : "bg-[color:var(--panel)] text-[color:var(--muted-strong)]"
}`}>
{agent.name.split(/\s+/).slice(0, 2).map(w => w[0]).join("").toUpperCase()}
{getAgentInitials(agent.name)}
</div>
<span className="font-medium text-[color:var(--text)]">{agent.name}</span>
<span className="text-[color:var(--muted)]">— {agent.title}</span>
@@ -449,7 +454,7 @@ export function CompanyOnboarding({
</div>
</div>
{/* What happens */}
<div className="mt-4 rounded-[8px] border border-[color:var(--line)] bg-[color:var(--panel-soft)] px-4 py-3">
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.06em] text-[color:var(--muted)]">What happens next</div>
<div className="space-y-2 text-[12px] text-[color:var(--muted)]">
@@ -492,7 +497,6 @@ export function CompanyOnboarding({
</div>
</motion.div>
) : step === "company" ? (
/* ── Step: Custom Company ─────────────────────── */
<motion.div
key="company"
initial={{ opacity: 0, y: 18 }}
@@ -568,7 +572,6 @@ export function CompanyOnboarding({
</div>
</motion.div>
) : step === "goal" ? (
/* ── Step: Goal ───────────────────────────────── */
<motion.div
key="goal"
initial={{ opacity: 0, y: 18 }}
@@ -639,7 +642,6 @@ export function CompanyOnboarding({
</div>
</motion.div>
) : (
/* ── Step: Launch Success ─────────────────────── */
<motion.div
key="launch"
initial={{ opacity: 0, scale: 0.95 }}
@@ -677,6 +679,7 @@ export function CompanyOnboarding({
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</div>
);
+118 -156
View File
@@ -17,7 +17,6 @@ import {
X,
} from "@phosphor-icons/react"
import type {
AgentRecord,
AutomationAction,
AutomationRuleRecord,
AutomationStatus,
@@ -27,14 +26,12 @@ import type {
WorkflowStatus,
WorkflowStep,
} from "@shared/types"
import { timeAgo } from "../lib/formatters"
import { DEPARTMENT_OPTIONS } from "@shared/constants"
import { ActionButton, Select } from "./ui"
/* ------------------------------------------------------------------ */
/* Constants & Config */
/* ------------------------------------------------------------------ */
const ACCENT = "#d97745"
const LINE = "rgba(15,23,42,0.08)"
const ACCENT = "var(--accent)"
const LINE = "var(--line)"
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 },
@@ -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" },
}
const departmentOptions: Array<{ value: string; 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" },
]
const departmentOptions = DEPARTMENT_OPTIONS
const statusColors: Record<AutomationStatus, { bg: string; text: string; dot: string }> = {
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)" },
}
/* ------------------------------------------------------------------ */
/* 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 {
try {
return JSON.parse(json)
@@ -145,10 +109,6 @@ function safeJsonStringify(value: string): string {
}
}
/* ------------------------------------------------------------------ */
/* Automation Log Entry Type */
/* ------------------------------------------------------------------ */
interface AutomationLogEntry {
ruleId: string
ruleName: string
@@ -157,10 +117,6 @@ interface AutomationLogEntry {
executedAt: string
}
/* ------------------------------------------------------------------ */
/* Automation Rule Card */
/* ------------------------------------------------------------------ */
function RuleCard({
rule,
onToggle,
@@ -187,7 +143,6 @@ function RuleCard({
style={{ borderColor: LINE }}
>
<div className="flex items-center gap-3 px-4 py-3.5">
{/* Trigger icon */}
<div
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-[10px]"
style={{ backgroundColor: `${ACCENT}12`, color: ACCENT }}
@@ -195,7 +150,6 @@ function RuleCard({
<TriggerIcon size={20} weight="fill" />
</div>
{/* Main info */}
<button
type="button"
onClick={onEdit}
@@ -217,18 +171,15 @@ function RuleCard({
</span>
</div>
<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)]">
<Lightning size={10} weight="fill" />
{triggerInfo?.label ?? rule.trigger}
</span>
<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)]">
<Gear size={10} />
{actionInfo?.label ?? rule.action}
</span>
{/* Department flow */}
{rule.sourceDepartment || rule.targetDepartment ? (
<>
<span className="text-[color:var(--line-strong)]">|</span>
@@ -244,7 +195,6 @@ function RuleCard({
</>
) : null}
</div>
{/* Execution stats */}
<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 className="h-0.5 w-0.5 rounded-full bg-[color:var(--muted)]" />
@@ -254,7 +204,6 @@ function RuleCard({
</div>
</button>
{/* Actions */}
<div className="flex shrink-0 items-center gap-1">
<button
type="button"
@@ -280,10 +229,6 @@ function RuleCard({
)
}
/* ------------------------------------------------------------------ */
/* Rule Creation / Edit Modal */
/* ------------------------------------------------------------------ */
interface RuleFormState {
name: string
description: string
@@ -356,7 +301,6 @@ function RuleFormModal({
setError("Rule name is required")
return
}
// Validate JSON fields
try { JSON.parse(form.conditionsJson) } catch {
setError("Conditions must be valid JSON")
return
@@ -428,7 +372,6 @@ function RuleFormModal({
style={{ borderColor: LINE, maxHeight: "85vh" }}
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 items-center gap-3">
<div
@@ -451,17 +394,14 @@ function RuleFormModal({
</button>
</div>
{/* Body */}
<div className="overflow-y-auto px-6 py-5">
<div className="space-y-5">
{/* 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)]">
{error}
</div>
) : null}
{/* Name */}
<div>
<label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">
Rule Name
@@ -476,7 +416,6 @@ function RuleFormModal({
/>
</div>
{/* Description */}
<div>
<label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">
Description
@@ -491,7 +430,6 @@ function RuleFormModal({
/>
</div>
{/* Trigger */}
<div>
<label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">
Trigger
@@ -508,7 +446,6 @@ function RuleFormModal({
) : null}
</div>
{/* Conditions */}
<div>
<label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">
Conditions (JSON)
@@ -524,7 +461,6 @@ function RuleFormModal({
/>
</div>
{/* Action */}
<div>
<label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">
Action
@@ -541,7 +477,6 @@ function RuleFormModal({
) : null}
</div>
{/* Action Config */}
<div>
<label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">
Action Configuration (JSON)
@@ -557,7 +492,6 @@ function RuleFormModal({
/>
</div>
{/* Department selectors */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">
@@ -581,7 +515,6 @@ function RuleFormModal({
</div>
</div>
{/* Priority slider */}
<div>
<div className="mb-1.5 flex items-center justify-between">
<label className="text-[12px] font-medium text-[color:var(--muted-strong)]">
@@ -607,7 +540,6 @@ function RuleFormModal({
</div>
</div>
{/* Footer */}
<div className="flex shrink-0 justify-end gap-3 border-t px-6 py-4" style={{ borderColor: LINE }}>
<button
type="button"
@@ -630,10 +562,6 @@ function RuleFormModal({
)
}
/* ------------------------------------------------------------------ */
/* Delete Confirmation Dialog */
/* ------------------------------------------------------------------ */
function DeleteConfirmDialog({
open,
entityName,
@@ -693,10 +621,6 @@ function DeleteConfirmDialog({
)
}
/* ------------------------------------------------------------------ */
/* Activity Log */
/* ------------------------------------------------------------------ */
function AutomationActivityLog({ entries }: { entries: AutomationLogEntry[] }) {
if (entries.length === 0) {
return (
@@ -743,10 +667,6 @@ function AutomationActivityLog({ entries }: { entries: AutomationLogEntry[] }) {
)
}
/* ------------------------------------------------------------------ */
/* AutomationRulesPanel */
/* ------------------------------------------------------------------ */
function AutomationRulesPanel({
rules,
companyId,
@@ -763,7 +683,6 @@ function AutomationRulesPanel({
const [activeTab, setActiveTab] = useState<"rules" | "activity">("rules")
const [filterStatus, setFilterStatus] = useState<AutomationStatus | "all">("all")
// Fetch activity log
useEffect(() => {
void window.agentCompany.getAutomationLog(companyId).then((result) => {
if (result.ok) setActivityLog(result.data)
@@ -782,29 +701,62 @@ function AutomationRulesPanel({
error: rules.filter((r) => r.status === "error").length,
}), [rules])
const [actionError, setActionError] = useState<string | null>(null)
const handleToggle = useCallback(async (rule: AutomationRuleRecord) => {
setActionError(null)
const newStatus = rule.status === "active" ? "paused" : "active"
await window.agentCompany.toggleAutomationRule({
id: rule.id,
companyId,
status: newStatus as "active" | "paused",
})
onRefresh()
try {
const result = await window.agentCompany.toggleAutomationRule({
id: rule.id,
companyId,
status: newStatus as "active" | "paused",
})
if (!result.ok) {
setActionError(result.error.message)
return
}
onRefresh()
} catch (err) {
setActionError(err instanceof Error ? err.message : "Failed to toggle rule")
}
}, [companyId, onRefresh])
const handleDelete = useCallback(async () => {
if (!deleteTarget) return
await window.agentCompany.deleteAutomationRule({
id: deleteTarget.id,
companyId,
})
setDeleteTarget(null)
onRefresh()
setActionError(null)
try {
const result = await window.agentCompany.deleteAutomationRule({
id: deleteTarget.id,
companyId,
})
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])
return (
<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 gap-3">
<div
@@ -828,7 +780,6 @@ function AutomationRulesPanel({
/>
</div>
{/* Stats bar */}
{rules.length > 0 ? (
<div className="grid grid-cols-3 gap-2">
<div className="rounded-[10px] border bg-[color:var(--panel)] px-4 py-3" style={{ borderColor: LINE }}>
@@ -850,7 +801,6 @@ function AutomationRulesPanel({
</div>
) : null}
{/* Tabs */}
<div className="flex gap-1 rounded-[8px] bg-[color:var(--panel-soft)] p-1">
<button
type="button"
@@ -876,10 +826,8 @@ function AutomationRulesPanel({
</button>
</div>
{/* Rules content */}
{activeTab === "rules" ? (
<>
{/* Filter */}
{rules.length > 0 ? (
<div className="flex flex-wrap items-center gap-1.5">
{(["all", "active", "paused", "error"] as const).map((status) => (
@@ -900,7 +848,6 @@ function AutomationRulesPanel({
</div>
) : null}
{/* Rule list */}
{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 }}>
<Lightning size={36} className="mb-3 text-[color:var(--muted)]" />
@@ -941,7 +888,6 @@ function AutomationRulesPanel({
<AutomationActivityLog entries={activityLog} />
)}
{/* Create / Edit modal */}
<AnimatePresence>
{showCreateModal ? (
<RuleFormModal
@@ -963,7 +909,6 @@ function AutomationRulesPanel({
) : null}
</AnimatePresence>
{/* Delete confirmation */}
<AnimatePresence>
{deleteTarget ? (
<DeleteConfirmDialog
@@ -978,10 +923,6 @@ function AutomationRulesPanel({
)
}
/* ------------------------------------------------------------------ */
/* Workflow Step Timeline */
/* ------------------------------------------------------------------ */
function StepTimeline({ steps }: { steps: WorkflowStep[] }) {
if (steps.length === 0) {
return (
@@ -993,7 +934,6 @@ function StepTimeline({ steps }: { steps: WorkflowStep[] }) {
return (
<div className="relative pl-5">
{/* Vertical line */}
<div
className="absolute left-[9px] top-2 w-px"
style={{
@@ -1009,7 +949,6 @@ function StepTimeline({ steps }: { steps: WorkflowStep[] }) {
return (
<div key={step.id} className="relative flex items-start gap-3">
{/* Dot */}
<div
className="absolute -left-5 top-1.5 z-10 h-[10px] w-[10px] rounded-full border-2"
style={{
@@ -1018,7 +957,6 @@ function StepTimeline({ steps }: { steps: WorkflowStep[] }) {
}}
/>
{/* Content */}
<div
className="flex-1 rounded-[8px] border px-3 py-2.5"
style={{ borderColor: step.status === "running" ? ACCENT : LINE }}
@@ -1069,18 +1007,18 @@ function StepTimeline({ steps }: { steps: WorkflowStep[] }) {
)
}
/* ------------------------------------------------------------------ */
/* Workflow Card */
/* ------------------------------------------------------------------ */
function WorkflowCard({
workflow,
isRunning,
onDelete,
onEdit,
onRun,
}: {
workflow: WorkflowPipelineRecord
isRunning: boolean
onDelete: () => void
onEdit: () => void
onRun: () => void
}) {
const [expanded, setExpanded] = useState(false)
const colors = workflowStatusColors[workflow.status]
@@ -1102,7 +1040,6 @@ function WorkflowCard({
style={{ borderColor: LINE }}
>
<div className="flex items-center gap-3 px-4 py-3.5">
{/* Icon */}
<div
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-[10px]"
style={{ backgroundColor: `${ACCENT}12`, color: ACCENT }}
@@ -1110,7 +1047,6 @@ function WorkflowCard({
<TriggerIcon size={20} weight="fill" />
</div>
{/* Main info */}
<button
type="button"
onClick={() => setExpanded((prev) => !prev)}
@@ -1149,7 +1085,6 @@ function WorkflowCard({
</div>
</button>
{/* Actions */}
<div className="flex shrink-0 items-center gap-1">
<motion.div
animate={{ rotate: expanded ? 90 : 0 }}
@@ -1164,6 +1099,16 @@ function WorkflowCard({
<CaretRight size={14} />
</button>
</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
type="button"
onClick={onEdit}
@@ -1185,7 +1130,6 @@ function WorkflowCard({
</div>
</div>
{/* Expanded: step timeline */}
<AnimatePresence>
{expanded ? (
<motion.div
@@ -1210,10 +1154,6 @@ function WorkflowCard({
)
}
/* ------------------------------------------------------------------ */
/* Workflow Creation Modal */
/* ------------------------------------------------------------------ */
interface WorkflowFormState {
name: string
description: string
@@ -1342,7 +1282,6 @@ function WorkflowFormModal({
style={{ borderColor: LINE, maxHeight: "85vh" }}
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 items-center gap-3">
<div
@@ -1365,7 +1304,6 @@ function WorkflowFormModal({
</button>
</div>
{/* Body */}
<div className="overflow-y-auto px-6 py-5">
<div className="space-y-5">
{error ? (
@@ -1374,7 +1312,6 @@ function WorkflowFormModal({
</div>
) : null}
{/* Name */}
<div>
<label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">
Workflow Name
@@ -1389,7 +1326,6 @@ function WorkflowFormModal({
/>
</div>
{/* Description */}
<div>
<label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">
Description
@@ -1404,7 +1340,6 @@ function WorkflowFormModal({
/>
</div>
{/* Trigger + Status */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">
@@ -1428,7 +1363,6 @@ function WorkflowFormModal({
</div>
</div>
{/* Trigger Config */}
<div>
<label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">
Trigger Configuration (JSON)
@@ -1444,7 +1378,6 @@ function WorkflowFormModal({
/>
</div>
{/* Steps */}
<div>
<label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">
Pipeline Steps (JSON Array)
@@ -1474,7 +1407,6 @@ function WorkflowFormModal({
</div>
</div>
{/* Footer */}
<div className="flex shrink-0 justify-end gap-3 border-t px-6 py-4" style={{ borderColor: LINE }}>
<button
type="button"
@@ -1497,10 +1429,6 @@ function WorkflowFormModal({
)
}
/* ------------------------------------------------------------------ */
/* WorkflowsPanel */
/* ------------------------------------------------------------------ */
function WorkflowsPanel({
workflows,
companyId,
@@ -1513,17 +1441,50 @@ function WorkflowsPanel({
const [showCreateModal, setShowCreateModal] = useState(false)
const [editingWorkflow, setEditingWorkflow] = 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 () => {
if (!deleteTarget) return
await window.agentCompany.deleteWorkflow({
id: deleteTarget.id,
companyId,
})
setDeleteTarget(null)
onRefresh()
setActionError(null)
try {
const result = await window.agentCompany.deleteWorkflow({
id: deleteTarget.id,
companyId,
})
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])
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(() => ({
total: workflows.length,
active: workflows.filter((w) => w.status === "active").length,
@@ -1532,7 +1493,18 @@ function WorkflowsPanel({
return (
<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 gap-3">
<div
@@ -1556,7 +1528,6 @@ function WorkflowsPanel({
/>
</div>
{/* Workflow list */}
{workflows.length === 0 ? (
<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)]" />
@@ -1580,15 +1551,16 @@ function WorkflowsPanel({
<WorkflowCard
key={wf.id}
workflow={wf}
isRunning={runningId === wf.id}
onDelete={() => setDeleteTarget(wf)}
onEdit={() => setEditingWorkflow(wf)}
onRun={() => void handleRun(wf)}
/>
))}
</AnimatePresence>
</div>
)}
{/* Create / Edit modal */}
<AnimatePresence>
{showCreateModal ? (
<WorkflowFormModal
@@ -1610,7 +1582,6 @@ function WorkflowsPanel({
) : null}
</AnimatePresence>
{/* Delete confirmation */}
<AnimatePresence>
{deleteTarget ? (
<DeleteConfirmDialog
@@ -1625,14 +1596,9 @@ function WorkflowsPanel({
)
}
/* ------------------------------------------------------------------ */
/* Main Automation Panel (exported) */
/* ------------------------------------------------------------------ */
interface AutomationPanelProps {
rules: AutomationRuleRecord[]
workflows: WorkflowPipelineRecord[]
agents: AgentRecord[]
companyId: string
onRefresh: () => void
}
@@ -1640,7 +1606,6 @@ interface AutomationPanelProps {
export function AutomationPanel({
rules,
workflows,
agents,
companyId,
onRefresh,
}: AutomationPanelProps) {
@@ -1648,7 +1613,6 @@ export function AutomationPanel({
return (
<div className="space-y-6">
{/* Top banner */}
<div
className="rounded-[12px] border px-5 py-4"
style={{
@@ -1674,7 +1638,6 @@ export function AutomationPanel({
</div>
</div>
{/* Section tabs */}
<div className="flex gap-1 rounded-[10px] bg-[color:var(--panel-soft)] p-1">
<button
type="button"
@@ -1702,7 +1665,6 @@ export function AutomationPanel({
</button>
</div>
{/* Content */}
<AnimatePresence mode="wait">
<motion.div
key={activeSection}
+316 -204
View File
@@ -5,7 +5,6 @@ import {
EnvelopeSimple,
At,
Hash,
UserCircle,
PaperPlaneTilt,
MagnifyingGlass,
Funnel,
@@ -19,21 +18,20 @@ import {
Plus,
X,
CaretDown,
Robot,
Tray,
} from "@phosphor-icons/react"
import type {
AgentMessageRecord,
AgentRecord,
CommentRecord,
DepartmentId,
MessageChannel,
MessagePriority,
TaskRecord,
} from "@shared/types"
import { ActionButton, Modal, Select, Input, TextArea } from "./ui"
/* ------------------------------------------------------------------ */
/* Constants & Config */
/* ------------------------------------------------------------------ */
import { getAgentInitials, formatMessageTime } from "../lib/formatters"
import { DEPARTMENT_COLORS } from "@shared/constants"
import { Modal, Select, Input, TextArea } from "./ui"
const TERRACOTTA = "#d97745"
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)" },
}
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 {
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 {
key: string
kind: "message"
channel: MessageChannel
label: string
targetId: string | null
@@ -136,6 +65,13 @@ interface ChannelDescriptor {
agentId: string | null
}
interface TaskDiscussionDescriptor {
key: string
kind: "task"
taskId: string
label: string
}
function deriveChannels(
messages: AgentMessageRecord[],
agents: AgentRecord[],
@@ -143,6 +79,37 @@ function deriveChannels(
const channelMap = new Map<string, ChannelDescriptor>()
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) {
let key: string
let label: string
@@ -166,7 +133,7 @@ function deriveChannels(
case "department": {
const dept = msg.channelTargetId ?? "general"
key = `department-${dept}`
label = `#${dept.replace("_", "-")}`
label = `#${dept.replace(/_/g, "-")}`
break
}
case "company": {
@@ -191,6 +158,7 @@ function deriveChannels(
if (!channelMap.has(key)) {
channelMap.set(key, {
key,
kind: "message",
channel: msg.channel,
label,
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"]
return Array.from(channelMap.values()).sort((a, b) => {
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(
messages: AgentMessageRecord[],
channel: ChannelDescriptor,
@@ -244,10 +220,6 @@ function getChannelMessages(
})
}
/* ------------------------------------------------------------------ */
/* Agent Avatar */
/* ------------------------------------------------------------------ */
function AgentAvatar({
agent,
size = "md",
@@ -269,10 +241,6 @@ function AgentAvatar({
)
}
/* ------------------------------------------------------------------ */
/* Channel Sidebar Item */
/* ------------------------------------------------------------------ */
function ChannelItem({
descriptor,
active,
@@ -346,36 +314,23 @@ function ChannelItem({
)
}
/* ------------------------------------------------------------------ */
/* Message Bubble */
/* ------------------------------------------------------------------ */
function MessageBubble({
message,
agent,
isThread,
replyCount,
onReply,
onMarkRead,
}: {
message: AgentMessageRecord
agent: AgentRecord | undefined
isThread: boolean
replyCount: number
onReply: () => void
onMarkRead: () => void
}) {
const isUnread = !message.readAt
const isUrgent = message.priority === "urgent"
const deptColor = agent ? getDepartmentColor(agent.department) : "var(--muted)"
useEffect(() => {
if (isUnread) {
const timer = setTimeout(onMarkRead, 1500)
return () => clearTimeout(timer)
}
}, [isUnread, onMarkRead])
return (
<motion.div
initial={{ opacity: 0, y: 6 }}
@@ -466,14 +421,12 @@ function MessageBubble({
)}
</div>
{/* Subject */}
{message.subject ? (
<div className="mt-0.5 text-[13px] font-medium text-[color:var(--text)]">
{message.subject}
</div>
) : null}
{/* Body */}
<div className="mt-1 whitespace-pre-wrap text-[12px] leading-relaxed text-[color:var(--muted-strong)]">
{message.body}
</div>
@@ -510,9 +463,102 @@ function MessageBubble({
)
}
/* ------------------------------------------------------------------ */
/* Attachment List */
/* ------------------------------------------------------------------ */
function TaskCommentBubble({
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 }) {
let items: Array<{ type: string; id: string; title: string }> = []
@@ -538,10 +584,6 @@ function AttachmentList({ json }: { json: string }) {
)
}
/* ------------------------------------------------------------------ */
/* Message Composer */
/* ------------------------------------------------------------------ */
interface MessageComposerProps {
agents: AgentRecord[]
companyId: string
@@ -595,7 +637,7 @@ function MessageComposer({
setSubject("")
onSent()
} catch (err) {
console.error("[MessageComposer] send failed:", err)
void err
} finally {
setSending(false)
}
@@ -615,7 +657,6 @@ function MessageComposer({
return (
<div className="border-t border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-3">
{/* Reply indicator */}
<AnimatePresence>
{replyTo ? (
<motion.div
@@ -640,7 +681,6 @@ function MessageComposer({
) : null}
</AnimatePresence>
{/* Sender selector row */}
<div className="mb-2 flex items-center gap-2">
<AgentAvatar agent={fromAgent} size="sm" />
<select
@@ -665,7 +705,6 @@ function MessageComposer({
) : null}
</div>
{/* Body input */}
<div className="flex items-end gap-2">
<textarea
ref={textareaRef}
@@ -702,10 +741,6 @@ function MessageComposer({
)
}
/* ------------------------------------------------------------------ */
/* Compose New Message Modal */
/* ------------------------------------------------------------------ */
interface ComposeModalProps {
open: boolean
onClose: () => void
@@ -748,14 +783,13 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal
parentMessageId: null,
attachmentsJson: "[]",
})
// Reset form
setSubject("")
setBody("")
setPriority("normal")
onSent()
onClose()
} catch (err) {
console.error("[ComposeModal] send failed:", err)
void err
} finally {
setSending(false)
}
@@ -764,7 +798,6 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal
return (
<Modal open={open} title="Compose Message" onClose={onClose} width="560px">
<div className="space-y-4">
{/* From */}
<Select
label="From Agent"
value={fromAgentId}
@@ -775,7 +808,6 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal
}))}
/>
{/* Channel type */}
<Select
label="Channel"
value={channel}
@@ -789,7 +821,6 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal
]}
/>
{/* Channel target */}
{channel === "direct" ? (
<Select
label="To Agent"
@@ -827,7 +858,6 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal
/>
) : null}
{/* Subject */}
<Input
label="Subject"
value={subject}
@@ -835,7 +865,6 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal
placeholder="Message subject"
/>
{/* Body */}
<TextArea
label="Message"
value={body}
@@ -844,7 +873,6 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal
rows={5}
/>
{/* Priority */}
<Select
label="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">
<button
type="button"
@@ -888,10 +915,6 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal
)
}
/* ------------------------------------------------------------------ */
/* Search & Filter Bar */
/* ------------------------------------------------------------------ */
interface FilterState {
search: string
channelType: MessageChannel | "all"
@@ -1030,27 +1053,29 @@ function FilterBar({
)
}
/* ------------------------------------------------------------------ */
/* Main: CommunicationHub */
/* ------------------------------------------------------------------ */
interface CommunicationHubProps {
messages: AgentMessageRecord[]
comments: CommentRecord[]
tasks: TaskRecord[]
agents: AgentRecord[]
companyId: string
selectedEntityId?: string | null
onRefresh: () => void
}
export function CommunicationHub({
messages,
comments,
tasks,
agents,
companyId,
selectedEntityId,
onRefresh,
}: CommunicationHubProps) {
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 [showCompose, setShowCompose] = useState(false)
const [replyTo, setReplyTo] = useState<AgentMessageRecord | null>(null)
const [filters, setFilters] = useState<FilterState>({
search: "",
channelType: "all",
@@ -1062,7 +1087,9 @@ export function CommunicationHub({
const agentMap = useMemo(() => new Map(agents.map((a) => [a.id, a])), [agents])
// 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
useEffect(() => {
@@ -1071,6 +1098,27 @@ export function CommunicationHub({
}
}, [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(
() => channels.find((c) => c.key === selectedChannelKey) ?? null,
[channels, selectedChannelKey],
@@ -1078,16 +1126,32 @@ export function CommunicationHub({
// Messages for the selected channel
const channelMessages = useMemo(() => {
if (!activeChannel) return []
if (!activeChannel || activeChannel.kind !== "message") return []
return getChannelMessages(messages, activeChannel).sort(
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
)
}, [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
const filteredMessages = useMemo(() => {
let result = channelMessages
if (filters.channelType !== "all") {
result = result.filter((m) => m.channel === filters.channelType)
}
if (filters.search) {
const q = filters.search.toLowerCase()
result = result.filter(
@@ -1127,6 +1191,10 @@ export function CommunicationHub({
const unreadByChannel = useMemo(() => {
const counts = new Map<string, { unread: number; hasUrgent: boolean }>()
for (const ch of channels) {
if (ch.kind !== "message") {
counts.set(ch.key, { unread: 0, hasUrgent: false })
continue
}
const msgs = getChannelMessages(messages, ch)
const unread = msgs.filter((m) => !m.readAt).length
const hasUrgent = msgs.some((m) => !m.readAt && m.priority === "urgent")
@@ -1139,22 +1207,6 @@ export function CommunicationHub({
return messages.filter((m) => !m.readAt).length
}, [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
useEffect(() => {
messageEndRef.current?.scrollIntoView({ behavior: "auto" })
@@ -1165,7 +1217,7 @@ export function CommunicationHub({
const groups: Array<{ type: MessageChannel; label: string; items: ChannelDescriptor[] }> = []
const typeOrder: MessageChannel[] = ["company", "department", "project", "incident", "direct"]
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) {
groups.push({
type,
@@ -1200,12 +1252,15 @@ export function CommunicationHub({
</p>
</div>
</div>
<ActionButton
label="Compose"
<button
type="button"
onClick={() => setShowCompose(true)}
icon={Plus}
tone="accent"
/>
className="inline-flex items-center gap-2 rounded-[8px] px-3 py-2 text-[11px] font-semibold text-white transition hover:brightness-90"
style={{ backgroundColor: TERRACOTTA }}
>
<Plus size={13} weight="bold" />
New message
</button>
</div>
{/* Main layout: sidebar + messages */}
@@ -1213,7 +1268,6 @@ export function CommunicationHub({
className="flex overflow-hidden rounded-[12px] border border-[color:var(--line)]"
style={{ height: "calc(100vh - 220px)", minHeight: "480px" }}
>
{/* ── Sidebar ────────────────────────────────────────── */}
<AnimatePresence initial={false}>
{showSidebar ? (
<motion.div
@@ -1258,7 +1312,6 @@ export function CommunicationHub({
agents={agents}
onClick={() => {
setSelectedChannelKey(ch.key)
setReplyTo(null)
}}
/>
)
@@ -1266,9 +1319,34 @@ export function CommunicationHub({
</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 ? (
<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>
) : null}
</div>
@@ -1284,7 +1362,6 @@ export function CommunicationHub({
) : null}
</AnimatePresence>
{/* ── Main content ─────────────────────────────────── */}
<div className="flex min-w-0 flex-1 flex-col bg-[color:var(--bg)]">
{/* Channel header */}
<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.kind === "message" ? (() => {
const Icon = channelConfig[activeChannel.channel].icon
return (
<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="text-[13px] font-semibold text-[color:var(--text)]">
{activeChannel.label}
</div>
<div className="text-[10px] text-[color:var(--muted)]">
{channelMessages.length} message{channelMessages.length !== 1 ? "s" : ""}
{(() => {
const stats = unreadByChannel.get(activeChannel.key)
return stats?.unread
? ` (${stats.unread} unread)`
: ""
})()}
{activeChannel.kind === "message"
? `${channelMessages.length} message${channelMessages.length !== 1 ? "s" : ""}${(() => {
const stats = unreadByChannel.get(activeChannel.key)
return stats?.unread ? ` (${stats.unread} unread)` : ""
})()}`
: `${taskDiscussionComments.length} comment${taskDiscussionComments.length !== 1 ? "s" : ""}${activeTask ? ` · ${activeTask.status.replaceAll("_", " ")}` : ""}`}
</div>
</div>
</>
@@ -1352,9 +1430,32 @@ export function CommunicationHub({
Select a channel to view messages
</div>
<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>
) : 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 ? (
<div className="flex h-full flex-col items-center justify-center text-center">
<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)]">
{filters.search || filters.unreadOnly || filters.priority !== "all"
? "Try adjusting your search or filters"
: "Be the first to send a message"}
: "Messages will appear here as agents work and communicate"}
</div>
</div>
) : (
@@ -1381,7 +1482,6 @@ export function CommunicationHub({
isThread={false}
replyCount={replies.length}
onReply={() => setReplyTo(msg)}
onMarkRead={() => handleMarkRead(msg.id)}
/>
{/* Thread replies */}
{replies.length > 0 ? (
@@ -1394,7 +1494,6 @@ export function CommunicationHub({
isThread={true}
replyCount={0}
onReply={() => setReplyTo(reply)}
onMarkRead={() => handleMarkRead(reply.id)}
/>
))}
</div>
@@ -1407,31 +1506,44 @@ export function CommunicationHub({
)}
</div>
{/* Composer */}
{activeChannel ? (
<MessageComposer
agents={agents}
companyId={companyId}
channel={activeChannel.channel}
channelTargetId={activeChannel.targetId}
replyTo={replyTo}
onSent={handleSent}
onCancelReply={() => setReplyTo(null)}
/>
) : null}
activeChannel.kind === "message" ? (
<MessageComposer
agents={agents}
companyId={companyId}
channel={activeChannel.channel}
channelTargetId={activeChannel.channel === "direct" ? null : activeChannel.targetId}
replyTo={replyTo}
onSent={() => {
setReplyTo(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>
{/* Compose modal */}
<ComposeModal
open={showCompose}
onClose={() => setShowCompose(false)}
agents={agents}
companyId={companyId}
onSent={() => {
onRefresh()
setShowCompose(false)
}}
onSent={onRefresh}
/>
</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 {
ArrowRight,
@@ -25,10 +25,11 @@ import {
import type {
ActivityRecord,
AgentRecord,
AgentMessageRecord,
ApprovalRecord,
BrowserActionRecord,
ConnectorRecord,
CostRecord,
DepartmentId,
GoalRecord,
MeetingRecord,
ProjectRecord,
@@ -36,14 +37,11 @@ import type {
SectionId,
SprintRecord,
TaskRecord,
WorkflowPipelineRecord,
} from "@shared/types";
import { formatMoney } from "../lib/formatters";
import { formatMoney, getAgentInitials, timeAgo } from "../lib/formatters";
import { StatusPill } from "./ui";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface DashboardProps {
agents: AgentRecord[];
tasks: TaskRecord[];
@@ -54,43 +52,45 @@ interface DashboardProps {
activity: ActivityRecord[];
costs: CostRecord[];
connectors: ConnectorRecord[];
messages?: AgentMessageRecord[];
browserActions?: BrowserActionRecord[];
workflows?: WorkflowPipelineRecord[];
meetings?: MeetingRecord[];
sprints?: SprintRecord[];
onNavigate: (section: SectionId, entityId?: string) => void;
}
/* ------------------------------------------------------------------ */
/* Health Score Computation */
/* ------------------------------------------------------------------ */
function computeHealthScore(props: Pick<DashboardProps, "agents" | "tasks" | "runs" | "approvals" | "connectors">): number {
function computeHealthScore(props: Pick<DashboardProps, "agents" | "tasks" | "runs" | "approvals" | "connectors" | "messages" | "browserActions" | "workflows">): number {
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");
score -= Math.min(failedRuns.length * 5, 25);
// Penalize for blocked tasks
const blockedTasks = tasks.filter((t) => t.status === "blocked");
score -= Math.min(blockedTasks.length * 8, 20);
// Penalize for pending approvals (bottleneck)
const pendingApprovals = approvals.filter((a) => a.state === "pending");
score -= Math.min(pendingApprovals.length * 5, 15);
// Penalize for agents in error state
const errorAgents = agents.filter((a) => a.status === "error");
score -= Math.min(errorAgents.length * 10, 20);
// Penalize for no ready connectors
const readyConnectors = connectors.filter((c) => c.status === "ready");
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);
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));
}
@@ -102,10 +102,6 @@ function healthLabel(score: number): { label: string; tone: string } {
return { label: "Critical", tone: "text-[color:var(--danger)]" };
}
/* ------------------------------------------------------------------ */
/* Health Ring */
/* ------------------------------------------------------------------ */
function HealthRing({ score }: { score: number }) {
const radius = 38;
const circumference = 2 * Math.PI * radius;
@@ -149,10 +145,6 @@ function HealthRing({ score }: { score: number }) {
);
}
/* ------------------------------------------------------------------ */
/* Metric Card */
/* ------------------------------------------------------------------ */
function MetricCard({
label,
value,
@@ -201,10 +193,6 @@ function MetricCard({
);
}
/* ------------------------------------------------------------------ */
/* Agent Workforce Grid */
/* ------------------------------------------------------------------ */
const statusGlow: Record<string, string> = {
running: "ring-2 ring-[color:var(--success)] ring-opacity-50",
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 }) {
const initials = agent.name
.split(/\s+/)
.slice(0, 2)
.map((w) => w[0])
.join("")
.toUpperCase();
const initials = getAgentInitials(agent.name);
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> = {
created: CheckCircle,
updated: Lightning,
@@ -302,20 +281,6 @@ const actionIcons: Record<string, React.ElementType> = {
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 }) {
const recentActivity = useMemo(() => {
return [...activity]
@@ -368,10 +333,6 @@ function ActivityFeed({ activity, agents, limit = 15 }: { activity: ActivityReco
);
}
/* ------------------------------------------------------------------ */
/* Workflow Pipeline (visual task flow) */
/* ------------------------------------------------------------------ */
function TaskPipeline({ tasks }: { tasks: TaskRecord[] }) {
const stages = useMemo(() => {
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 }> = {
executive: { label: "Executive", icon: Buildings, color: "var(--accent)" },
engineering: { label: "Engineering", icon: Cpu, color: "#6366f1" },
@@ -485,7 +442,6 @@ function DepartmentOverview({
onClick={() => onNavigate("agents")}
className="focus-ring group relative overflow-hidden rounded-[10px] border border-[color:var(--line)] bg-[color:var(--panel)] p-3 text-left transition hover:border-[color:var(--line-strong)] hover:shadow-sm"
>
{/* Accent bar */}
<div className="absolute left-0 top-0 h-full w-[3px]" style={{ backgroundColor: info.color }} />
<div className="flex items-center gap-2 pl-1">
@@ -524,11 +480,13 @@ function DepartmentOverview({
);
}
/* ------------------------------------------------------------------ */
/* Sprint Progress */
/* ------------------------------------------------------------------ */
function SprintProgress({ sprint, tasks }: { sprint: SprintRecord; tasks: TaskRecord[] }) {
const [nowMs, setNowMs] = useState<number | null>(null);
useEffect(() => {
setNowMs(Date.now());
}, []);
const progress = useMemo(() => {
if (sprint.velocityPoints === 0) return 0;
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 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)));
}, [sprint]);
}, [sprint, nowMs]);
const sprintTasks = useMemo(() => {
const done = tasks.filter((t) => t.status === "done").length;
@@ -561,7 +519,6 @@ function SprintProgress({ sprint, tasks }: { sprint: SprintRecord; tasks: TaskRe
</div>
</div>
{/* Progress bar */}
<div className="mt-3 h-2 w-full overflow-hidden rounded-full bg-[color:var(--panel-soft)]">
<motion.div
className="h-full rounded-full bg-[color:var(--accent)]"
@@ -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 }) {
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) {
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(
() => computeHealthScore({ agents, tasks, runs, approvals, connectors }),
[agents, tasks, runs, approvals, connectors],
() => computeHealthScore({ agents, tasks, runs, approvals, connectors, messages, browserActions, workflows }),
[agents, tasks, runs, approvals, connectors, messages, browserActions, workflows],
);
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 readyConnectors = useMemo(() => connectors.filter((c) => c.status === "ready"), [connectors]);
const activeSprint = useMemo(() => sprints.find((s) => s.status === "active"), [sprints]);
const completionRate = useMemo(() => {
const total = runs.length;
if (total === 0) return 0;
const succeeded = runs.filter((r) => r.status === "succeeded").length;
return Math.round((succeeded / total) * 100);
}, [runs]);
const unreadMessages = useMemo(() => messages.filter((message) => !message.readAt), [messages]);
const urgentMessages = useMemo(() => messages.filter((message) => !message.readAt && (message.priority === "urgent" || message.channel === "incident")), [messages]);
const blockedBrowserActions = useMemo(() => browserActions.filter((action) => action.status === "approval_required" || action.status === "failed"), [browserActions]);
const stalledWorkflows = useMemo(() => workflows.filter((workflow) => workflow.status === "paused" || workflow.status === "failed"), [workflows]);
return (
<div className="space-y-8">
{/* Row 1: Health + Metrics */}
{/* Health + Metrics */}
<div className="flex flex-col gap-6 lg:flex-row lg:items-start">
{/* Health Ring */}
<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")}
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>
{/* Row 2: Department Overview */}
{/* Department Overview */}
<section>
<div className="mb-3 flex items-center justify-between">
<div className="text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Departments</div>
@@ -751,7 +722,7 @@ export function Dashboard(props: DashboardProps) {
<DepartmentOverview agents={agents} tasks={tasks} runs={runs} onNavigate={onNavigate} />
</section>
{/* Row 3: Sprint + Goals side by side */}
{/* Sprint + Goals */}
<div className="grid gap-6 lg:grid-cols-2">
{activeSprint ? (
<section>
@@ -784,7 +755,7 @@ export function Dashboard(props: DashboardProps) {
</section>
</div>
{/* Row 4: Task Pipeline */}
{/* Task Pipeline */}
<section>
<div className="mb-3 flex items-center justify-between">
<div className="text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Task Pipeline</div>
@@ -801,7 +772,7 @@ export function Dashboard(props: DashboardProps) {
</div>
</section>
{/* Row 5: Workforce + Activity */}
{/* Workforce + Activity */}
<div className="grid gap-6 lg:grid-cols-5">
{/* Workforce */}
<section className="lg:col-span-3">
@@ -836,7 +807,7 @@ export function Dashboard(props: DashboardProps) {
</section>
</div>
{/* Row 4: Connector health (compact) */}
{/* Connector Health */}
<section>
<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">
@@ -867,8 +838,8 @@ export function Dashboard(props: DashboardProps) {
</div>
</section>
{/* Row 5: Quick Actions / Needs Attention */}
{(pendingApprovals.length > 0 || failedRuns.length > 0) ? (
{/* Needs Attention */}
{(pendingApprovals.length > 0 || failedRuns.length > 0 || urgentMessages.length > 0 || blockedBrowserActions.length > 0 || stalledWorkflows.length > 0) ? (
<section>
<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">
@@ -913,6 +884,56 @@ export function Dashboard(props: DashboardProps) {
</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>
</section>
) : null}
@@ -920,10 +941,6 @@ export function Dashboard(props: DashboardProps) {
);
}
/* ------------------------------------------------------------------ */
/* Kanban Board */
/* ------------------------------------------------------------------ */
interface KanbanProps {
tasks: TaskRecord[];
agents: AgentRecord[];
@@ -957,7 +974,6 @@ export function KanbanBoard({ tasks, agents, runs, onSelectTask }: KanbanProps)
if (bucket) bucket.push(task);
else grouped.backlog?.push(task);
}
// Sort each column by priority
const priorityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
for (const col of Object.values(grouped)) {
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] ?? [];
return (
<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">
<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>
@@ -989,7 +1004,6 @@ export function KanbanBoard({ tasks, agents, runs, onSelectTask }: KanbanProps)
{colTasks.length}
</span>
</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 }}>
{colTasks.map((task) => {
const assignee = agents.find((a) => a.id === task.assigneeAgentId);
+137 -83
View File
@@ -28,6 +28,7 @@ import type {
ActivityRecord,
AgentRecord,
ApprovalRecord,
DocumentRecord,
GoalRecord,
ProfileSnapshot,
ProjectRecord,
@@ -39,76 +40,12 @@ import type {
} from "@shared/types";
import { formatMoney, formatTime } from "../lib/formatters";
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) {
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> = {
agent: Robot,
@@ -127,7 +64,6 @@ const ENTITY_TYPE_OPTIONS = [
"all", "agent", "task", "run", "approval", "connector", "workspace", "secret", "goal", "project", "company",
] as const;
/* ─── Workspace state pills ──────────────────────────────────────────── */
function WorkspaceStatePill({ state }: { state: string }) {
const colorMap: Record<string, string> = {
@@ -144,7 +80,6 @@ function WorkspaceStatePill({ state }: { state: string }) {
);
}
/* ─── Priority indicator ─────────────────────────────────────────────── */
function PriorityDot({ priority }: { priority: string }) {
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`} />;
}
/* ─── Task Queue ─────────────────────────────────────────────────────── */
export function TaskQueue({
tasks,
@@ -204,7 +138,6 @@ export function TaskQueue({
);
}
/* ─── Task Inspector ─────────────────────────────────────────────────── */
export function TaskInspector({
task,
@@ -571,7 +504,6 @@ export function TaskInspector({
);
}
/* ─── Task Comments ──────────────────────────────────────────────────── */
function TaskComments({
taskId,
@@ -666,7 +598,6 @@ function TaskComments({
);
}
/* ─── Task Creator ───────────────────────────────────────────────────── */
export function TaskCreator({
companyId,
@@ -741,7 +672,6 @@ export function TaskCreator({
);
}
/* ─── Pending Review List ────────────────────────────────────────────── */
export function PendingReviewList({ tasks, agents }: { tasks: TaskRecord[]; agents: AgentRecord[] }) {
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 }) {
if (approvals.length === 0) {
@@ -801,7 +730,6 @@ export function ApprovalList({ approvals, selectedApprovalId, onSelect }: { appr
);
}
/* ─── Approval Inspector ─────────────────────────────────────────────── */
export function ApprovalInspector({
approval,
@@ -915,7 +843,6 @@ export function ApprovalInspector({
{/* Hire agent payload details */}
{approval.type === "hire_agent" ? (() => {
// Find the agent that was proposed for hiring (look for pending_approval agents)
const proposedAgent = agents.find(
(a) => a.status === "pending_approval" && a.reportsTo === approval.requestedByAgentId
) ?? 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"] }) {
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"] }) {
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[] }) {
const [search, setSearch] = useState("");
@@ -1187,18 +1111,21 @@ export function ActivityList({ activity }: { activity: ActivityRecord[] }) {
);
}
/* ─── Workspace List ─────────────────────────────────────────────────── */
export function WorkspaceList({
workspaces,
projects,
companyId,
selectedWorkspaceId,
onSelect,
onOpen,
onDeleted,
}: {
workspaces: WorkspaceRecord[];
projects: ProjectRecord[];
companyId: string;
selectedWorkspaceId: string | null;
onSelect: (workspaceId: string) => void;
onOpen: (path: string) => Promise<void>;
onDeleted: () => Promise<void>;
}) {
@@ -1238,7 +1165,8 @@ export function WorkspaceList({
const linkedProject = projects.find((p) => p.id === workspace.projectId) ?? null;
return (
<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 className="flex items-center gap-2">
<div className="text-[14px] text-[color:var(--text)]">{workspace.name}</div>
@@ -1248,6 +1176,7 @@ export function WorkspaceList({
</div>
<div className="flex items-center gap-2">
{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="Delete" onClick={() => setConfirmDeleteId(workspace.id)} icon={Trash} tone="danger" />
</div>
@@ -1256,6 +1185,7 @@ export function WorkspaceList({
{workspace.repoUrl || "No remote URL"} {workspace.repoRef ? `· ${workspace.repoRef}` : ""}
{linkedProject ? <span> · Project: <span className="text-[color:var(--text)]">{linkedProject.name}</span></span> : null}
</div>
</div>
</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> }) {
const [name, setName] = useState("");
@@ -1332,7 +1388,6 @@ export function WorkspaceCreator({ companyId, projects, onSaved }: { companyId:
);
}
/* ─── Settings Pane ──────────────────────────────────────────────────── */
export function SettingsPane({
snapshot,
@@ -1569,7 +1624,6 @@ export function SettingsPane({
);
}
/* ─── Secret Creator ─────────────────────────────────────────────────── */
export function SecretCreator({ companyId, onSaved }: { companyId: string; onSaved: () => Promise<void> }) {
const [name, setName] = useState("");
+58 -63
View File
@@ -6,10 +6,10 @@ import {
X,
} from "@phosphor-icons/react";
import type { AgentRecord, ApprovalRecord } from "@shared/types";
import { getAgentInitials } from "../lib/formatters";
import { ActionButton, EmptyState, Panel, StatusPill } from "./ui";
interface HiringPipelineProps {
companyId: string;
agents: AgentRecord[];
approvals: ApprovalRecord[];
onRefresh: () => Promise<void>;
@@ -19,7 +19,7 @@ interface HiringPipelineProps {
type HiringStage = "requisition" | "pending_approval" | "approved" | "onboarding" | "active" | "rejected";
interface HiringCandidate {
agent: AgentRecord;
agent: AgentRecord | null;
approval: ApprovalRecord | null;
stage: HiringStage;
}
@@ -33,7 +33,6 @@ function getHiringStage(agent: AgentRecord, approval: ApprovalRecord | null): Hi
return "approved";
}
if (agent.status === "idle" && approval?.state === "approved") {
// Recently approved — check if they have any tasks yet
return "onboarding";
}
return "active";
@@ -52,27 +51,28 @@ function stageLabel(stage: HiringStage): string {
function stageColor(stage: HiringStage): string {
switch (stage) {
case "requisition": return "bg-amber-500/20 text-amber-400";
case "pending_approval": return "bg-yellow-500/20 text-yellow-400";
case "approved": return "bg-green-500/20 text-green-400";
case "onboarding": return "bg-blue-500/20 text-blue-400";
case "active": return "bg-emerald-500/20 text-emerald-400";
case "rejected": return "bg-red-500/20 text-red-400";
case "requisition": return "bg-[color:var(--warn-soft)] text-[color:var(--warn)]";
case "pending_approval": return "bg-[color:var(--warn-soft)] text-[color:var(--warn)]";
case "approved": return "bg-[color:var(--success-soft)] text-[color:var(--success)]";
case "onboarding": return "bg-[color:var(--accent-soft)] text-[color:var(--accent)]";
case "active": return "bg-[color:var(--success-soft)] text-[color:var(--success)]";
case "rejected": return "bg-[color:var(--danger-soft)] text-[color:var(--danger)]";
}
}
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 hireApprovals = approvals.filter(a => a.type === "hire_agent");
const candidates: HiringCandidate[] = agents.map(agent => {
const approval = hireApprovals.find(a =>
a.relatedAgentId === agent.id ||
(a.payloadJson && (() => { try { const p = JSON.parse(a.payloadJson); return p.agentId === agent.id; } catch { return false; } })())
) ?? null;
const approval = hireApprovals.find(a => {
if (a.relatedAgentId === agent.id) return true;
if (!a.payloadJson) return false;
try { return JSON.parse(a.payloadJson).agentId === agent.id; } catch { return false; }
}) ?? null;
return {
agent,
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 => {
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 = [
...candidates,
...pendingHireApprovals.map(a => ({
agent: null as unknown as AgentRecord,
agent: null,
approval: a,
stage: "pending_approval" as HiringStage,
})),
@@ -98,7 +97,6 @@ export function HiringPipeline({ companyId: _companyId, agents, approvals, onRef
? allCandidates
: allCandidates.filter(c => c.stage === filterStage);
// Stage counts for the pipeline overview
const stageCounts = STAGE_ORDER.reduce((acc, stage) => {
acc[stage] = allCandidates.filter(c => c.stage === stage).length;
return acc;
@@ -109,7 +107,6 @@ export function HiringPipeline({ companyId: _companyId, agents, approvals, onRef
return (
<div className="space-y-4">
{/* Pipeline Overview */}
<Panel title="Hiring Pipeline" eyebrow={`${activeHires} active hiring processes`}>
<div className="flex items-center gap-1 px-4 py-3 overflow-x-auto">
{STAGE_ORDER.filter(s => s !== "rejected").map((stage, idx) => (
@@ -117,16 +114,16 @@ export function HiringPipeline({ companyId: _companyId, agents, approvals, onRef
<button
onClick={() => setFilterStage(filterStage === stage ? "all" : stage)}
className={`px-3 py-2 rounded-md text-xs font-medium transition-colors whitespace-nowrap ${
filterStage === stage ? "ring-1 ring-orange-400/40 bg-zinc-700" : "hover:bg-zinc-700/60"
filterStage === stage ? "ring-1 ring-[color:var(--accent)]/40 bg-[color:var(--panel-soft)]" : "hover:bg-[color:var(--panel-soft)]"
} ${stageColor(stage)}`}
>
{stageLabel(stage)}
<span className="ml-1.5 inline-flex items-center justify-center h-5 w-5 rounded-full bg-zinc-800 text-[10px]">
<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]}
</span>
</button>
{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>
))}
@@ -134,11 +131,11 @@ export function HiringPipeline({ companyId: _companyId, agents, approvals, onRef
<button
onClick={() => setFilterStage(filterStage === "rejected" ? "all" : "rejected")}
className={`ml-4 px-3 py-2 rounded-md text-xs font-medium transition-colors ${
filterStage === "rejected" ? "ring-1 ring-red-400/40 bg-zinc-700" : "hover:bg-zinc-700/60"
filterStage === "rejected" ? "ring-1 ring-[color:var(--danger)]/40 bg-[color:var(--panel-soft)]" : "hover:bg-[color:var(--panel-soft)]"
} ${stageColor("rejected")}`}
>
Rejected
<span className="ml-1.5 inline-flex items-center justify-center h-5 w-5 rounded-full bg-zinc-800 text-[10px]">
<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}
</span>
</button>
@@ -146,10 +143,9 @@ export function HiringPipeline({ companyId: _companyId, agents, approvals, onRef
</div>
</Panel>
{/* Pending Approvals Section */}
{pendingCount > 0 && (
<Panel title="Awaiting Board Decision" eyebrow={`${pendingCount} hire requests need your review`}>
<div className="divide-y divide-zinc-800/60">
<div className="divide-y divide-[color:var(--line)]">
{allCandidates
.filter(c => c.stage === "requisition" || c.stage === "pending_approval")
.map((candidate) => {
@@ -157,32 +153,32 @@ export function HiringPipeline({ companyId: _companyId, agents, approvals, onRef
const ag = candidate.agent;
return (
<div key={a?.id ?? ag?.id} className="px-4 py-3 flex items-start gap-3">
<div className="flex-shrink-0 mt-0.5 h-8 w-8 rounded-full bg-amber-500/15 flex items-center justify-center">
<UserPlus size={16} className="text-amber-400" />
<div 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-[color:var(--warn)]" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm text-zinc-200">{ag?.name ?? "Proposed Agent"}</span>
<span className="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)}`}>
{stageLabel(candidate.stage)}
</span>
</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}
</div>
{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}
</div>
)}
{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 && (
<div className="text-xs text-zinc-500">{a.impactSummary}</div>
<div className="text-xs text-[color:var(--muted)]">{a.impactSummary}</div>
)}
{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 className="flex items-center gap-1.5 flex-shrink-0">
@@ -194,6 +190,7 @@ export function HiringPipeline({ companyId: _companyId, agents, approvals, onRef
tone="accent"
onClick={async () => {
await window.agentCompany.decideApproval({
companyId: a.companyId,
approvalId: a.id,
state: "approved",
decisionNote: "Hire approved via hiring pipeline",
@@ -207,6 +204,7 @@ export function HiringPipeline({ companyId: _companyId, agents, approvals, onRef
tone="danger"
onClick={async () => {
await window.agentCompany.decideApproval({
companyId: a.companyId,
approvalId: a.id,
state: "rejected",
decisionNote: "Hire rejected via hiring pipeline",
@@ -224,7 +222,6 @@ export function HiringPipeline({ companyId: _companyId, agents, approvals, onRef
</Panel>
)}
{/* Full Pipeline List */}
<Panel
title={filterStage === "all" ? "All Agents" : `${stageLabel(filterStage)} (${filteredCandidates.length})`}
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."
/>
) : (
<div className="divide-y divide-zinc-800/60">
<div className="divide-y divide-[color:var(--line)]">
{filteredCandidates.map((candidate) => {
const ag = candidate.agent;
if (!ag) return null;
@@ -250,32 +247,32 @@ export function HiringPipeline({ companyId: _companyId, agents, approvals, onRef
return (
<div
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)}
>
<div className="flex-shrink-0 h-8 w-8 rounded-full bg-zinc-700/60 flex items-center justify-center text-xs font-bold text-zinc-300">
{ag.name.split(" ").map(n => n[0]).join("").slice(0, 2)}
<div 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)]">
{getAgentInitials(ag.name)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm text-zinc-200 truncate">{ag.name}</span>
<span className="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)}`}>
{stageLabel(candidate.stage)}
</span>
<StatusPill status={ag.status} />
</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}` : ""}
</div>
</div>
<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"}
</div>
{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>
);
@@ -284,31 +281,29 @@ export function HiringPipeline({ companyId: _companyId, agents, approvals, onRef
)}
</Panel>
{/* Hiring Statistics */}
<div className="grid grid-cols-4 gap-3">
<div className="bg-zinc-900/60 rounded-lg px-4 py-3 border border-zinc-800/40">
<div className="text-[10px] uppercase text-zinc-500 tracking-wider">Total Agents</div>
<div className="text-2xl font-bold text-zinc-200 mt-1">{agents.filter(a => a.status !== "terminated").length}</div>
<div className="rounded-lg px-4 py-3 border border-[color:var(--line)] bg-[color:var(--panel)]">
<div className="text-[10px] uppercase text-[color:var(--muted)] tracking-wider">Total Agents</div>
<div className="text-2xl font-bold text-[color:var(--text)] mt-1">{agents.filter(a => a.status !== "terminated").length}</div>
</div>
<div className="bg-zinc-900/60 rounded-lg px-4 py-3 border border-zinc-800/40">
<div className="text-[10px] uppercase text-zinc-500 tracking-wider">Pending Hires</div>
<div className="text-2xl font-bold text-amber-400 mt-1">{agents.filter(a => a.status === "pending_approval").length}</div>
<div className="rounded-lg px-4 py-3 border border-[color:var(--line)] bg-[color:var(--panel)]">
<div className="text-[10px] uppercase text-[color:var(--muted)] tracking-wider">Pending Hires</div>
<div className="text-2xl font-bold text-[color:var(--warn)] mt-1">{agents.filter(a => a.status === "pending_approval").length}</div>
</div>
<div className="bg-zinc-900/60 rounded-lg px-4 py-3 border border-zinc-800/40">
<div className="text-[10px] uppercase text-zinc-500 tracking-wider">Departments</div>
<div className="text-2xl font-bold text-zinc-200 mt-1">
<div className="rounded-lg px-4 py-3 border border-[color:var(--line)] bg-[color:var(--panel)]">
<div className="text-[10px] uppercase text-[color:var(--muted)] tracking-wider">Departments</div>
<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}
</div>
</div>
<div className="bg-zinc-900/60 rounded-lg px-4 py-3 border border-zinc-800/40">
<div className="text-[10px] uppercase text-zinc-500 tracking-wider">Monthly Payroll</div>
<div className="text-2xl font-bold text-zinc-200 mt-1">
<div className="rounded-lg px-4 py-3 border border-[color:var(--line)] bg-[color:var(--panel)]">
<div className="text-[10px] uppercase text-[color:var(--muted)] tracking-wider">Monthly Payroll</div>
<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()}
</div>
</div>
</div>
{/* Department Headcount */}
<Panel title="Department Headcount">
<div className="grid grid-cols-3 gap-2 p-4">
{(["executive", "engineering", "product", "design", "marketing", "sales", "hr", "finance", "legal", "customer_support", "operations", "research"] as const).map(dept => {
@@ -317,15 +312,15 @@ export function HiringPipeline({ companyId: _companyId, agents, approvals, onRef
const active = deptAgents.filter(a => a.status !== "pending_approval").length;
if (active === 0 && pending === 0) return null;
return (
<div key={dept} className="bg-zinc-800/40 rounded-md px-3 py-2">
<div className="text-xs font-medium text-zinc-300 capitalize">{dept.replace("_", " ")}</div>
<div key={dept} className="bg-[color:var(--panel-soft)] rounded-md px-3 py-2">
<div className="text-xs font-medium text-[color:var(--text)] capitalize">{dept.replace("_", " ")}</div>
<div className="flex items-center gap-2 mt-1">
<span className="text-sm font-bold text-zinc-200">{active}</span>
<span className="text-[10px] text-zinc-500">active</span>
<span className="text-sm font-bold text-[color:var(--text)]">{active}</span>
<span className="text-[10px] text-[color:var(--muted)]">active</span>
{pending > 0 && (
<>
<span className="text-sm font-bold text-amber-400">+{pending}</span>
<span className="text-[10px] text-amber-400/70">pending</span>
<span className="text-sm font-bold text-[color:var(--warn)]">+{pending}</span>
<span className="text-[10px] text-[color:var(--warn)]">pending</span>
</>
)}
</div>
+19 -71
View File
@@ -10,19 +10,14 @@ import {
CheckCircle,
Clock,
Eye,
FunnelSimple,
Robot,
Heartbeat,
Target,
ChatCircle,
Tray,
} from "@phosphor-icons/react";
import type { InboxItem, InboxItemType, SectionId, StandupReport, AgentMetrics, CompanyMetrics } from "@shared/types";
import { formatMoney } from "../lib/formatters";
/* ------------------------------------------------------------------ */
/* Inbox Panel */
/* ------------------------------------------------------------------ */
import type { InboxItem, InboxItemType, SectionId, StandupReport, CompanyMetrics } from "@shared/types";
import { formatMoney, timeAgo, getAgentInitials, formatDuration } from "../lib/formatters";
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" },
@@ -40,49 +35,28 @@ const severityColors: Record<string, string> = {
low: "border-l-[color:var(--line)]",
};
function timeAgo(dateStr: string): string {
const now = Date.now();
const then = new Date(dateStr).getTime();
const diff = Math.max(0, now - then);
const seconds = Math.floor(diff / 1000);
if (seconds < 60) return "just now";
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
interface InboxProps {
items: InboxItem[];
onNavigate: (section: SectionId, entityId?: string) => void;
onDismiss: (itemId: string) => void;
}
export function Inbox({ items, onNavigate, onDismiss }: InboxProps) {
export function Inbox({ items, onNavigate }: InboxProps) {
const [filter, setFilter] = useState<InboxItemType | "all">("all");
const [showDismissed, setShowDismissed] = useState(false);
const filteredItems = useMemo(() => {
let result = items;
if (!showDismissed) result = result.filter(i => !i.dismissed);
if (filter !== "all") result = result.filter(i => i.type === filter);
return result;
}, [items, filter, showDismissed]);
if (filter === "all") return items;
return items.filter(i => i.type === filter);
}, [items, filter]);
const counts = useMemo(() => {
const active = items.filter(i => !i.dismissed);
return {
all: active.length,
pending_approval: active.filter(i => i.type === "pending_approval").length,
failed_run: active.filter(i => i.type === "failed_run").length,
budget_warning: active.filter(i => i.type === "budget_warning").length,
blocked_task: active.filter(i => i.type === "blocked_task").length,
review_needed: active.filter(i => i.type === "review_needed").length,
agent_message: active.filter(i => i.type === "agent_message").length,
};
}, [items]);
const counts = useMemo(() => ({
all: items.length,
pending_approval: items.filter(i => i.type === "pending_approval").length,
failed_run: items.filter(i => i.type === "failed_run").length,
budget_warning: items.filter(i => i.type === "budget_warning").length,
blocked_task: items.filter(i => i.type === "blocked_task").length,
review_needed: items.filter(i => i.type === "review_needed").length,
agent_message: items.filter(i => i.type === "agent_message").length,
}), [items]);
const handleItemClick = (item: InboxItem) => {
const sectionMap: Record<string, SectionId> = {
@@ -90,6 +64,7 @@ export function Inbox({ items, onNavigate, onDismiss }: InboxProps) {
run: "tasks",
agent: "agents",
task: "tasks",
message: "communication",
};
const section = sectionMap[item.entityType] ?? "overview";
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: "blocked_task", label: "Blocked", count: counts.blocked_task },
{ key: "budget_warning", label: "Budget", count: counts.budget_warning },
{ key: "agent_message", label: "Messages", count: counts.agent_message },
];
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-[10px] bg-[color:var(--accent-soft)]">
@@ -121,7 +96,6 @@ export function Inbox({ items, onNavigate, onDismiss }: InboxProps) {
</div>
</div>
{/* Filter Bar */}
<div className="flex flex-wrap items-center gap-1.5">
{filterButtons.map(btn => (
<button
@@ -146,7 +120,6 @@ export function Inbox({ items, onNavigate, onDismiss }: InboxProps) {
))}
</div>
{/* Items */}
{filteredItems.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-[10px] border border-dashed border-[color:var(--line)] py-16 text-center">
<Tray size={32} className="mb-3 text-[color:var(--muted)]" />
@@ -209,9 +182,6 @@ export function Inbox({ items, onNavigate, onDismiss }: InboxProps) {
);
}
/* ------------------------------------------------------------------ */
/* Standup Report View */
/* ------------------------------------------------------------------ */
interface StandupProps {
report: StandupReport | null;
@@ -231,7 +201,6 @@ export function StandupView({ report, onNavigate }: StandupProps) {
return (
<div className="space-y-6">
{/* Header */}
<div className="rounded-[12px] border border-[color:var(--line)] bg-[color:var(--panel)] p-5">
<div className="flex items-center gap-3 mb-4">
<div className="flex h-10 w-10 items-center justify-center rounded-[10px] bg-[color:var(--accent-soft)]">
@@ -270,7 +239,6 @@ export function StandupView({ report, onNavigate }: StandupProps) {
</div>
</div>
{/* Company Highlights */}
{report.companyHighlights.length > 0 ? (
<section>
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Highlights</div>
@@ -285,7 +253,6 @@ export function StandupView({ report, onNavigate }: StandupProps) {
</section>
) : null}
{/* Blockers */}
{report.blockers.length > 0 ? (
<section>
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--danger)]">Blockers</div>
@@ -300,7 +267,6 @@ export function StandupView({ report, onNavigate }: StandupProps) {
</section>
) : null}
{/* Agent Reports */}
<section>
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Agent Status</div>
<div className="space-y-2">
@@ -317,7 +283,7 @@ export function StandupView({ report, onNavigate }: StandupProps) {
: ar.status === "error" ? "bg-[color:var(--danger-soft)] text-[color:var(--danger)]"
: "bg-[color:var(--panel-soft)] text-[color:var(--muted-strong)]"
}`}>
{ar.agentName.split(/\s+/).slice(0, 2).map(w => w[0]).join("").toUpperCase()}
{getAgentInitials(ar.agentName)}
</div>
{ar.status === "running" ? (
<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 {
metrics: CompanyMetrics | null;
@@ -372,7 +335,6 @@ export function PerformanceView({ metrics, onNavigate }: PerformanceProps) {
return (
<div className="space-y-6">
{/* Company Overview Metrics */}
<div className="grid grid-cols-2 gap-2 lg:grid-cols-4">
<CompactMetric
label="Success Rate"
@@ -398,7 +360,6 @@ export function PerformanceView({ metrics, onNavigate }: PerformanceProps) {
/>
</div>
{/* Run Stats */}
<div className="rounded-[12px] border border-[color:var(--line)] bg-[color:var(--panel)] p-5">
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Run Activity (30d)</div>
<div className="flex items-center gap-6">
@@ -422,7 +383,6 @@ export function PerformanceView({ metrics, onNavigate }: PerformanceProps) {
</div>
</div>
{/* Agent Performance Table */}
<section>
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Agent Performance</div>
<div className="overflow-x-auto rounded-[10px] border border-[color:var(--line)]">
@@ -449,7 +409,7 @@ export function PerformanceView({ metrics, onNavigate }: PerformanceProps) {
<td className="px-4 py-2.5">
<div className="flex items-center gap-2">
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-[color:var(--panel-soft)] text-[10px] font-bold text-[color:var(--muted-strong)]">
{am.agentName.split(/\s+/).slice(0, 2).map(w => w[0]).join("").toUpperCase()}
{getAgentInitials(am.agentName)}
</div>
<span className="text-[12px] font-medium text-[color:var(--text)]">{am.agentName}</span>
</div>
@@ -477,9 +437,6 @@ export function PerformanceView({ metrics, onNavigate }: PerformanceProps) {
);
}
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
function CompactMetric({ label, value, tone = "default", subtitle }: {
label: string;
@@ -526,12 +483,3 @@ function RunProgressBar({ succeeded, failed, total }: { succeeded: number; faile
</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,
WorkspaceRecord,
} from "@shared/types";
import { formatMoney, formatTime } from "../lib/formatters";
import { formatMoney, formatTime, getAgentInitials } from "../lib/formatters";
import { unwrap } from "../lib/desktop";
import {
ActionButton,
@@ -30,10 +30,6 @@ import {
TextArea,
} from "./ui";
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
function BudgetBar({ spent, budget }: { spent: number; budget: number }) {
const pct = budget > 0 ? Math.min((spent / budget) * 100, 100) : 0;
const overBudget = spent > budget && budget > 0;
@@ -70,10 +66,6 @@ function ConfirmDelete({
);
}
/* ------------------------------------------------------------------ */
/* AgentRoster */
/* ------------------------------------------------------------------ */
export function AgentRoster({
agents,
connectors,
@@ -132,10 +124,6 @@ export function AgentRoster({
);
}
/* ------------------------------------------------------------------ */
/* AgentCreator */
/* ------------------------------------------------------------------ */
export function AgentCreator({
companyId,
connectors,
@@ -261,7 +249,7 @@ export function AgentCreator({
setReportsTo("");
await onSaved();
} catch (error) {
console.error("Failed to create agent:", error);
void error;
}
})()
}
@@ -271,10 +259,6 @@ export function AgentCreator({
);
}
/* ------------------------------------------------------------------ */
/* AgentEditor */
/* ------------------------------------------------------------------ */
export function AgentEditor({
agent,
connectors,
@@ -532,10 +516,6 @@ export function AgentEditor({
);
}
/* ------------------------------------------------------------------ */
/* GoalList */
/* ------------------------------------------------------------------ */
export function GoalList({
goals,
agents,
@@ -585,10 +565,6 @@ export function GoalList({
);
}
/* ------------------------------------------------------------------ */
/* GoalCreator */
/* ------------------------------------------------------------------ */
export function GoalCreator({
companyId,
agents,
@@ -649,7 +625,7 @@ export function GoalCreator({
setDescription("");
await onSaved();
} catch (error) {
console.error("Failed to create goal:", error);
void error;
}
})()
}
@@ -659,10 +635,6 @@ export function GoalCreator({
);
}
/* ------------------------------------------------------------------ */
/* GoalEditor */
/* ------------------------------------------------------------------ */
export function GoalEditor({
goal,
goals,
@@ -741,7 +713,7 @@ export function GoalEditor({
});
await onSaved();
} catch (error) {
console.error("Failed to save goal:", error);
void error;
}
})()
}
@@ -770,10 +742,6 @@ export function GoalEditor({
);
}
/* ------------------------------------------------------------------ */
/* ProjectList */
/* ------------------------------------------------------------------ */
export function ProjectList({
projects,
goals,
@@ -834,10 +802,6 @@ export function ProjectList({
);
}
/* ------------------------------------------------------------------ */
/* ProjectCreator */
/* ------------------------------------------------------------------ */
export function ProjectCreator({
companyId,
goals,
@@ -902,7 +866,7 @@ export function ProjectCreator({
setTargetDate("");
await onSaved();
} catch (error) {
console.error("Failed to create project:", error);
void error;
}
})()
}
@@ -912,10 +876,6 @@ export function ProjectCreator({
);
}
/* ------------------------------------------------------------------ */
/* ProjectEditor */
/* ------------------------------------------------------------------ */
export function ProjectEditor({
project,
goals,
@@ -995,7 +955,7 @@ export function ProjectEditor({
});
await onSaved();
} 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_H = 110;
const GAP_X = 32;
@@ -1276,7 +1232,7 @@ export function OrgChart({
{allNodes.map((node) => {
const dotColor = statusDotColor[node.agent.status] ?? "#a3a3a3";
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 isActive = node.agent.status === "active" || isRunning;
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 {
ArrowRight,
Browser,
ArrowSquareOut,
Camera,
ClockCounterClockwise,
ChatCircle,
Eye,
Globe,
@@ -17,16 +18,12 @@ import {
SignIn,
Trash,
UserPlus,
Warning,
X,
} 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";
/* ------------------------------------------------------------------ */
/* Platform Config */
/* ------------------------------------------------------------------ */
const platformConfig: Record<SocialPlatform, { label: string; color: string; bgColor: string; icon: string }> = {
twitter: { label: "Twitter / X", color: "#1DA1F2", bgColor: "#1DA1F210", icon: "𝕏" },
linkedin: { label: "LinkedIn", color: "#0A66C2", bgColor: "#0A66C210", icon: "in" },
@@ -49,27 +46,9 @@ const actionTypeLabels: Record<string, { label: string; icon: React.ElementType
navigate: { label: "Navigate", icon: Globe },
search: { label: "Search", icon: MagnifyingGlass },
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({
account,
onLogin,
@@ -86,7 +65,6 @@ function AccountCard({
return (
<div className="group flex items-center gap-3 rounded-[10px] border border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-3 transition hover:border-[color:var(--line-strong)] hover:shadow-sm">
{/* Platform icon */}
<div
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-[10px] text-[14px] font-bold"
style={{ backgroundColor: platform.bgColor, color: platform.color }}
@@ -94,7 +72,6 @@ function AccountCard({
{platform.icon}
</div>
{/* Info */}
<button type="button" onClick={onClick} className="min-w-0 flex-1 text-left">
<div className="flex items-center gap-2">
<span className="text-[13px] font-semibold text-[color:var(--text)]">{account.displayName || account.accountName}</span>
@@ -117,7 +94,6 @@ function AccountCard({
</div>
</button>
{/* Status + Actions */}
<div className="flex items-center gap-2">
<span className={`flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[10px] font-semibold ${
isLoggedIn
@@ -160,10 +136,6 @@ function AccountCard({
);
}
/* ------------------------------------------------------------------ */
/* Add Account Dialog */
/* ------------------------------------------------------------------ */
function AddAccountDialog({
onAdd,
onClose,
@@ -199,7 +171,6 @@ function AddAccountDialog({
</div>
<div className="space-y-4">
{/* Platform selector */}
<div>
<label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">Platform</label>
<div className="grid grid-cols-3 gap-1.5">
@@ -235,7 +206,6 @@ function AddAccountDialog({
placeholder="Your display name"
/>
{/* Approval toggle */}
<div className="rounded-[8px] border border-[color:var(--line)] bg-[color:var(--panel-soft)] px-4 py-3">
<label className="flex items-center gap-3">
<input
@@ -279,11 +249,19 @@ function AddAccountDialog({
);
}
/* ------------------------------------------------------------------ */
/* Browser Action History */
/* ------------------------------------------------------------------ */
function ActionHistory({ actions, agents }: { actions: BrowserActionRecord[]; agents: Array<{ id: string; name: string }> }) {
function ActionHistory({
actions,
agents,
onOpenPath,
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) {
return (
<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 ? (
<div className="mt-0.5 truncate text-[11px] text-[color:var(--danger)]">{action.errorMessage}</div>
) : 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 className="shrink-0 text-[10px] text-[color:var(--muted)]">
{timeAgo(action.createdAt)}
@@ -349,10 +367,6 @@ function ActionHistory({ actions, agents }: { actions: BrowserActionRecord[]; ag
);
}
/* ------------------------------------------------------------------ */
/* Main Social Accounts Panel */
/* ------------------------------------------------------------------ */
interface SocialPanelProps {
accounts: SocialAccountRecord[];
actions: BrowserActionRecord[];
@@ -367,7 +381,9 @@ interface SocialPanelProps {
}) => Promise<void>;
onDelete: (id: 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({
@@ -378,6 +394,8 @@ export function SocialAccountsPanel({
onSave,
onDelete,
onLogin,
onOpenPath,
onCancelAction,
onNavigate,
}: SocialPanelProps) {
const [showAddDialog, setShowAddDialog] = useState(false);
@@ -390,7 +408,6 @@ export function SocialAccountsPanel({
return (
<div className="space-y-5">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-[10px] bg-[color:var(--accent-soft)]">
@@ -411,7 +428,6 @@ export function SocialAccountsPanel({
/>
</div>
{/* Info banner */}
<div className="rounded-[10px] border border-[color:var(--accent)] border-opacity-20 bg-[color:var(--accent-soft)] px-4 py-3">
<div className="flex items-start gap-2 text-[12px] text-[color:var(--accent)]">
<Browser size={16} className="mt-0.5 shrink-0" />
@@ -420,13 +436,12 @@ export function SocialAccountsPanel({
<div className="mt-1 text-[11px] opacity-80">
Add your social media accounts here. Click "Login" to open a browser where you can sign in.
Your session is saved locally so marketing agents can post content, reply to users, and browse feeds
using Playwright browser automation. All actions are logged and can require Board approval.
using browser automation. All actions are logged and can require Board approval.
</div>
</div>
</div>
</div>
{/* Tabs */}
<div className="flex gap-1 rounded-[8px] bg-[color:var(--panel-soft)] p-1">
<button
type="button"
@@ -452,7 +467,6 @@ export function SocialAccountsPanel({
</button>
</div>
{/* Content */}
{activeTab === "accounts" ? (
accounts.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-[10px] border border-dashed border-[color:var(--line)] py-16 text-center">
@@ -478,16 +492,21 @@ export function SocialAccountsPanel({
account={account}
onLogin={() => void onLogin(account.id)}
onDelete={() => void onDelete(account.id)}
onClick={() => {/* future: account detail view */}}
onClick={() => {}}
/>
))}
</div>
)
) : (
<ActionHistory actions={actions} agents={agents} />
<ActionHistory
actions={actions}
agents={agents}
onOpenPath={onOpenPath}
onCancelAction={onCancelAction}
onNavigate={onNavigate}
/>
)}
{/* Add Account Dialog */}
<AnimatePresence>
{showAddDialog ? (
<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({
title,
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>;
}
/* ── Focus Trap Hook ────────────────────────────────────────────────── */
export function useFocusTrap(active: boolean) {
const ref = useRef<HTMLDivElement>(null);
@@ -415,7 +474,6 @@ export function useFocusTrap(active: boolean) {
const container = ref.current;
const previouslyFocused = document.activeElement as HTMLElement | null;
// Focus first focusable element
const focusable = container.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
);
@@ -454,8 +512,6 @@ export function useFocusTrap(active: boolean) {
return ref;
}
/* ── Reduced Motion Hook ────────────────────────────────────────────── */
export function useReducedMotion() {
const query = typeof window !== "undefined" ? window.matchMedia("(prefers-reduced-motion: reduce)") : null;
// 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";
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 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 = {
loadSnapshot: "agent-company:load-snapshot",
@@ -41,7 +41,6 @@ export const IPC_CHANNELS = {
getApiPort: "agent-company:get-api-port",
rendererEvent: "agent-company:event",
getInbox: "agent-company:get-inbox",
dismissInboxItem: "agent-company:dismiss-inbox-item",
getCompanyMetrics: "agent-company:get-company-metrics",
getAgentMetrics: "agent-company:get-agent-metrics",
getStandupReport: "agent-company:get-standup-report",
@@ -52,32 +51,24 @@ export const IPC_CHANNELS = {
listBrowserActions: "agent-company:list-browser-actions",
cancelBrowserAction: "agent-company:cancel-browser-action",
triggerBrowserLogin: "agent-company:trigger-browser-login",
// Meetings
saveMeeting: "agent-company:save-meeting",
deleteMeeting: "agent-company:delete-meeting",
// Documents
saveDocument: "agent-company:save-document",
deleteDocument: "agent-company:delete-document",
// Knowledge Base
saveKnowledgeEntry: "agent-company:save-knowledge-entry",
deleteKnowledgeEntry: "agent-company:delete-knowledge-entry",
// Sprints
saveSprint: "agent-company:save-sprint",
deleteSprint: "agent-company:delete-sprint",
// Automation
saveAutomationRule: "agent-company:save-automation-rule",
deleteAutomationRule: "agent-company:delete-automation-rule",
toggleAutomationRule: "agent-company:toggle-automation-rule",
getAutomationLog: "agent-company:get-automation-log",
// Agent Messages
sendAgentMessage: "agent-company:send-agent-message",
listAgentMessages: "agent-company:list-agent-messages",
markMessageRead: "agent-company:mark-message-read",
// Workflows
saveWorkflow: "agent-company:save-workflow",
deleteWorkflow: "agent-company:delete-workflow",
runWorkflow: "agent-company:run-workflow",
// Auto-assign
autoAssignTask: "agent-company:auto-assign-task",
} as const;
@@ -175,6 +166,7 @@ export const approvalInputSchema = z.object({
});
export const approvalDecisionSchema = z.object({
companyId: z.string().uuid(),
approvalId: z.string().uuid(),
state: z.enum(["approved", "rejected", "revision_requested"]),
decisionNote: z.string().max(400).default(""),
@@ -211,6 +203,7 @@ export const sidebarBadgesSchema = z.object({
failedRuns: z.number(),
todoTasks: z.number(),
budgetWarnings: z.number(),
unreadMessages: z.number(),
});
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"),
});
export const inboxDismissSchema = z.object({
itemId: z.string(),
companyId: z.string().uuid(),
});
export const metricsQuerySchema = z.object({
companyId: z.string().uuid(),
agentId: z.string().uuid().optional(),
@@ -342,7 +330,6 @@ export const browserActionQuerySchema = z.object({
limit: z.number().int().min(1).max(100).default(50),
});
// ── Automation Schemas ──────────────────────────────────────────────────
export const automationRuleInputSchema = z.object({
id: z.preprocess((v) => (v === "" ? null : v), z.string().uuid().nullable().optional()),
companyId: z.string().uuid(),
@@ -391,8 +378,9 @@ export const messageQuerySchema = z.object({
companyId: z.string().uuid(),
agentId: z.string().uuid().optional(),
channel: z.enum(["direct", "department", "company", "project", "incident"]).optional(),
channelTargetId: z.string().nullable().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({
@@ -411,6 +399,22 @@ export const workflowInputSchema = z.object({
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({
taskId: z.string().uuid(),
companyId: z.string().uuid(),
@@ -459,7 +463,6 @@ export type DesktopApi = {
triggerHeartbeat: (input: z.infer<typeof triggerHeartbeatSchema>) => Promise<DesktopResult<string>>;
getApiPort: () => Promise<DesktopResult<number>>;
getInbox: (companyId: string) => Promise<DesktopResult<InboxItem[]>>;
dismissInboxItem: (input: z.infer<typeof inboxDismissSchema>) => Promise<DesktopResult<boolean>>;
getCompanyMetrics: (input: z.infer<typeof metricsQuerySchema>) => Promise<DesktopResult<CompanyMetrics>>;
getAgentMetrics: (input: z.infer<typeof metricsQuerySchema>) => Promise<DesktopResult<AgentMetrics>>;
getStandupReport: (companyId: string) => Promise<DesktopResult<StandupReport>>;
@@ -470,32 +473,24 @@ export type DesktopApi = {
listBrowserActions: (input: z.infer<typeof browserActionQuerySchema>) => Promise<DesktopResult<BrowserActionRecord[]>>;
cancelBrowserAction: (input: z.infer<typeof cancelRunSchema>) => Promise<DesktopResult<boolean>>;
triggerBrowserLogin: (socialAccountId: string) => Promise<DesktopResult<boolean>>;
// Meetings
saveMeeting: (input: z.infer<typeof meetingInputSchema>) => Promise<DesktopResult<string>>;
deleteMeeting: (input: z.infer<typeof deleteEntitySchema>) => Promise<DesktopResult<boolean>>;
// Documents
saveDocument: (input: z.infer<typeof documentInputSchema>) => Promise<DesktopResult<string>>;
deleteDocument: (input: z.infer<typeof deleteEntitySchema>) => Promise<DesktopResult<boolean>>;
// Knowledge Base
saveKnowledgeEntry: (input: z.infer<typeof knowledgeEntryInputSchema>) => Promise<DesktopResult<string>>;
deleteKnowledgeEntry: (input: z.infer<typeof deleteEntitySchema>) => Promise<DesktopResult<boolean>>;
// Sprints
saveSprint: (input: z.infer<typeof sprintInputSchema>) => Promise<DesktopResult<string>>;
deleteSprint: (input: z.infer<typeof deleteEntitySchema>) => Promise<DesktopResult<boolean>>;
// Automation
saveAutomationRule: (input: z.infer<typeof automationRuleInputSchema>) => Promise<DesktopResult<string>>;
deleteAutomationRule: (input: z.infer<typeof deleteEntitySchema>) => Promise<DesktopResult<boolean>>;
toggleAutomationRule: (input: { id: string; companyId: string; status: "active" | "paused" }) => Promise<DesktopResult<boolean>>;
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 }>>>;
// Agent Messages
sendAgentMessage: (input: z.infer<typeof agentMessageInputSchema>) => Promise<DesktopResult<string>>;
listAgentMessages: (input: z.infer<typeof messageQuerySchema>) => Promise<DesktopResult<AgentMessageRecord[]>>;
markMessageRead: (input: { messageId: string; companyId: string }) => Promise<DesktopResult<boolean>>;
// Workflows
markMessageRead: (input: z.infer<typeof markMessageReadSchema>) => Promise<DesktopResult<boolean>>;
saveWorkflow: (input: z.infer<typeof workflowInputSchema>) => Promise<DesktopResult<string>>;
deleteWorkflow: (input: z.infer<typeof deleteEntitySchema>) => Promise<DesktopResult<boolean>>;
runWorkflow: (input: { id: string; companyId: string }) => Promise<DesktopResult<boolean>>;
// Auto-assign
runWorkflow: (input: z.infer<typeof runWorkflowSchema>) => Promise<DesktopResult<boolean>>;
autoAssignTask: (input: z.infer<typeof autoAssignSchema>) => Promise<DesktopResult<string>>;
subscribe: (callback: (event: DesktopEvent) => void) => () => void;
};
+4 -75
View File
@@ -122,7 +122,6 @@ export interface WorkspaceRecord {
updatedAt: string;
}
// ── Departments ────────────────────────────────────────────────────────
export type DepartmentId =
| "executive"
| "engineering"
@@ -298,7 +297,6 @@ export interface ProfileSnapshot {
secrets: SecretRecord[];
comments: CommentRecord[];
socialAccounts: SocialAccountRecord[];
browserActions: BrowserActionRecord[];
meetings: MeetingRecord[];
documents: DocumentRecord[];
knowledgeBase: KnowledgeEntryRecord[];
@@ -331,8 +329,6 @@ export interface RunStatusEvent {
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 {
id: string;
companyId: string;
@@ -343,11 +339,6 @@ export interface CommentRecord {
createdAt: string;
}
export interface OrgChartNode {
agent: AgentRecord;
children: OrgChartNode[];
}
export interface HeartbeatStatusEvent {
type: "heartbeat-status";
agentId: string;
@@ -384,7 +375,6 @@ export type DesktopEvent =
| AutomationFiredEvent
| NewMessageEvent;
// ── Inbox ──────────────────────────────────────────────────────────────
export type InboxItemType = "pending_approval" | "failed_run" | "budget_warning" | "blocked_task" | "review_needed" | "agent_message";
export interface InboxItem {
@@ -398,23 +388,21 @@ export interface InboxItem {
agentId: string | null;
agentName: string | null;
createdAt: string;
dismissed: boolean;
}
// ── Performance Metrics ────────────────────────────────────────────────
export interface AgentMetrics {
agentId: string;
agentName: string;
tasksCompleted: number;
tasksAssigned: number;
tasksFailed: number;
successRate: number; // 0-100
successRate: number;
avgRunDurationSec: number;
totalCostUsd: number;
runsTotal: number;
runsSucceeded: number;
runsFailed: number;
reviewCycles: number; // how many times work was sent back for revision
reviewCycles: number;
lastActiveAt: string | null;
}
@@ -434,7 +422,6 @@ export interface CompanyMetrics {
agentMetrics: AgentMetrics[];
}
// ── Company Template ───────────────────────────────────────────────────
export interface CompanyTemplate {
id: 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 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 interface SocialAccountRecord {
@@ -474,12 +460,9 @@ export interface SocialAccountRecord {
accountName: string;
displayName: string;
profileUrl: string;
/** Secret ID for stored credentials (username/password/cookies) */
credentialSecretId: string | null;
status: SocialAccountStatus;
/** Whether posts require Board approval before publishing */
requireApproval: boolean;
/** JSON metadata: bio, follower count, etc. */
metadataJson: string;
lastUsedAt: string | null;
createdAt: string;
@@ -493,14 +476,10 @@ export interface BrowserActionRecord {
agentId: string;
taskId: string | null;
actionType: BrowserActionType;
/** JSON payload: content, targetUrl, selector, script, etc. */
payloadJson: string;
status: BrowserActionStatus;
/** Result summary after execution */
resultSummary: string;
/** Screenshot path after execution */
screenshotPath: string | null;
/** Approval ID if approval was required */
approvalId: string | null;
errorMessage: string | null;
startedAt: string | null;
@@ -509,7 +488,6 @@ export interface BrowserActionRecord {
updatedAt: string;
}
// ── Meetings ──────────────────────────────────────────────────────────
export type MeetingType =
| "standup"
| "sprint_planning"
@@ -544,7 +522,6 @@ export interface MeetingRecord {
updatedAt: string;
}
// ── Documents ─────────────────────────────────────────────────────────
export type DocumentType =
| "prd"
| "technical_spec"
@@ -586,7 +563,6 @@ export interface DocumentRecord {
updatedAt: string;
}
// ── Knowledge Base ────────────────────────────────────────────────────
export type KnowledgeCategory =
| "lesson_learned"
| "best_practice"
@@ -612,7 +588,6 @@ export interface KnowledgeEntryRecord {
updatedAt: string;
}
// ── Sprints ───────────────────────────────────────────────────────────
export type SprintStatus = "planning" | "active" | "review" | "completed" | "cancelled";
export interface SprintRecord {
@@ -630,25 +605,6 @@ export interface SprintRecord {
updatedAt: string;
}
// ── Performance Reviews ───────────────────────────────────────────────
export type ReviewStatus = "draft" | "submitted" | "acknowledged";
export interface PerformanceReviewRecord {
id: string;
companyId: string;
agentId: string;
reviewerAgentId: string;
period: string; // e.g., "2026-Q1"
rating: number; // 1-5
strengths: string;
areasForImprovement: string;
goals: string;
status: ReviewStatus;
createdAt: string;
updatedAt: string;
}
// ── Standup Report ─────────────────────────────────────────────────────
export interface StandupReport {
companyId: string;
generatedAt: string;
@@ -669,10 +625,9 @@ export interface StandupReport {
companyHighlights: string[];
blockers: string[];
pendingApprovals: number;
budgetUtilization: number; // 0-100
budgetUtilization: number;
}
// ── Automation Engine ─────────────────────────────────────────────────
export type AutomationTrigger =
| "task_status_changed"
| "task_created"
@@ -713,25 +668,19 @@ export interface AutomationRuleRecord {
name: string;
description: string;
trigger: AutomationTrigger;
/** JSON conditions: e.g., {"status": "blocked", "department": "engineering"} */
conditionsJson: string;
action: AutomationAction;
/** JSON action config: e.g., {"targetAgentId": "...", "message": "..."} */
actionConfigJson: string;
/** Source department that triggers (null = any) */
sourceDepartment: DepartmentId | null;
/** Target department for cross-department rules (null = same) */
targetDepartment: DepartmentId | null;
priority: number;
status: AutomationStatus;
/** How many times this rule has fired */
executionCount: number;
lastExecutedAt: string | null;
createdAt: string;
updatedAt: string;
}
// ── Agent Messages (Direct Communication) ─────────────────────────────
export type MessageChannel = "direct" | "department" | "company" | "project" | "incident";
export type MessagePriority = "urgent" | "normal" | "low";
@@ -739,24 +688,18 @@ export interface AgentMessageRecord {
id: string;
companyId: string;
fromAgentId: string;
/** null = broadcast to channel */
toAgentId: string | null;
channel: MessageChannel;
/** Department/project/incident ID for channel messages */
channelTargetId: string | null;
subject: string;
body: string;
priority: MessagePriority;
/** Whether the recipient has read this message */
readAt: string | null;
/** Parent message ID for threads */
parentMessageId: string | null;
/** JSON: attached entity references [{type, id, title}] */
attachmentsJson: string;
createdAt: string;
}
// ── Workflow Pipeline ──────────────────────────────────────────────────
export type WorkflowStatus = "draft" | "active" | "paused" | "completed" | "failed";
export interface WorkflowPipelineRecord {
@@ -764,13 +707,11 @@ export interface WorkflowPipelineRecord {
companyId: string;
name: string;
description: string;
/** JSON array of pipeline steps */
stepsJson: string;
triggerType: AutomationTrigger;
triggerConfigJson: string;
status: WorkflowStatus;
currentStepIndex: number;
/** How many times this pipeline has run */
runCount: number;
lastRunAt: string | null;
createdAt: string;
@@ -784,19 +725,7 @@ export interface WorkflowStep {
configJson: string;
assigneeDepartment: DepartmentId | null;
assigneeAgentId: string | null;
/** Wait for approval before proceeding */
requiresApproval: boolean;
/** Timeout in seconds (0 = no timeout) */
timeoutSec: number;
/** Status of this step */
status: "pending" | "running" | "completed" | "failed" | "skipped";
}
// ── Cross-Department Trigger Table ────────────────────────────────────
export interface CrossDeptTrigger {
sourceEvent: string;
sourceDepartment: DepartmentId;
targetDepartment: DepartmentId;
targetAction: string;
description: string;
}
+4 -1
View File
@@ -27,9 +27,12 @@ test("first launch onboarding creates a company and opens the desktop shell", as
try {
const page = await app.firstWindow();
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("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();
// 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);
expect(prompt).toContain("escalate by commenting on YOUR parent task");
expect(prompt).toContain("Break down work and delegate it");
});
@@ -310,7 +309,7 @@ describe("Step 2: CEO phased prompts", () => {
const prompt = buildSystemPrompt(ctx);
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", () => {
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, "comment");
// Consume returns them in order
// Consume returns the latest retained wake
const wakes = db.consumePendingWakes(agentId);
expect(wakes).toHaveLength(2);
expect(wakes[0].trigger).toBe("subtask_completed");
expect(wakes[1].trigger).toBe("comment");
expect(wakes).toHaveLength(1);
expect(wakes[0].trigger).toBe("comment");
expect(wakes[0].companyId).toBe(companyId);
// Second consume returns empty (they've been deleted)
@@ -640,10 +639,10 @@ describe("Step 8: Wake reason injection", () => {
const prompt = buildTaskPrompt(ctx);
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 ctx = buildCtx({
@@ -652,10 +651,11 @@ describe("Step 8: Wake reason injection", () => {
});
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 ctx = buildCtx({
@@ -664,17 +664,18 @@ describe("Step 8: Wake reason injection", () => {
});
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", () => {
const agentId = createAgent({ name: "Agent" });
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",
approval_resolved: "approval you requested has been resolved",
subtask_completed: "subtask assigned to one of your reports has been completed",
approval_resolved: "approval you requested has been decided",
subtask_completed: "subtask you delegated has been completed",
comment: "new comment has been posted",
report_blocked: "reports is blocked",
report_failed: "reports has failed",
@@ -887,7 +888,7 @@ describe("E2E: Full autonomous CEO cold start flow", () => {
});
const icPrompt = buildTaskPrompt(icPromptCtx);
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");
});
});
@@ -898,17 +899,16 @@ describe("E2E: Deferred wake queue with run lifecycle", () => {
it("wake enqueue + run finish + consume cycle", () => {
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, "comment");
db.enqueuePendingWake(companyId, agentId, "report_blocked");
// Agent finishes run → consume pending wakes
const wakes = db.consumePendingWakes(agentId);
expect(wakes).toHaveLength(3);
expect(wakes[0].trigger).toBe("subtask_completed");
expect(wakes[1].trigger).toBe("comment");
expect(wakes[2].trigger).toBe("report_blocked");
expect(wakes).toHaveLength(1);
expect(wakes[0].trigger).toBe("report_blocked");
// All consumed
expect(db.consumePendingWakes(agentId)).toHaveLength(0);
@@ -1192,6 +1192,81 @@ describe("Agent Messages", () => {
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", () => {
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" });
@@ -1206,7 +1281,7 @@ describe("Agent Messages", () => {
});
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();
expect(snapshot.agentMessages.length).toBeGreaterThanOrEqual(1);
expect(snapshot.agentMessages[0].subject).toBe("Snapshot test");
@@ -1268,10 +1343,13 @@ describe("Auto Task Assignment", () => {
expect(result).toBe(frontendId);
});
it("returns null if no agents have remaining budget", () => {
// Exhaust both agents' budgets
db.saveAgent({ id: backendId, companyId, name: "Backend Dev", role: "Backend", title: "Backend", connectorId: "codex_local", status: "active", capabilities: "backend", budgetMonthlyUsd: 0 });
db.saveAgent({ id: frontendId, companyId, name: "Frontend Dev", role: "Frontend", title: "Frontend", connectorId: "codex_local", status: "active", capabilities: "frontend", budgetMonthlyUsd: 0 });
it("returns null if all agents have exhausted their budget", () => {
// 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: 100 });
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 result = db.autoAssignTask(taskId, companyId);
+96
View File
@@ -147,4 +147,100 @@ describe("AppDatabase", () => {
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);
});
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", () => {
// The migration system runs during init() — verify the tracking table exists
const snapshot = db.listSnapshot();
@@ -632,30 +575,4 @@ describe("AppDatabase", () => {
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");
});
});