update
This commit is contained in:
1 parent
54bcc77ef7
commit
7a3db27ffa
33 files changed
+5906
-3089
No files matched your search
@@ -10,6 +10,7 @@ export default tseslint.config(
|
||||
"out/**",
|
||||
"build/**",
|
||||
"dist/**",
|
||||
"oldreference/**",
|
||||
"node_modules/**",
|
||||
"test-results/**",
|
||||
"playwright-report/**",
|
||||
|
||||
+1
-1
@@ -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.",
|
||||
|
||||
@@ -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,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
File diff suppressed because it is too large.
Load diff
+176
-77
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
+1861
-764
File diff suppressed because it is too large.
Load diff
@@ -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
File diff suppressed because it is too large.
Load diff
+1
-175
@@ -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",
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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("");
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user