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/**",
|
"out/**",
|
||||||
"build/**",
|
"build/**",
|
||||||
"dist/**",
|
"dist/**",
|
||||||
|
"oldreference/**",
|
||||||
"node_modules/**",
|
"node_modules/**",
|
||||||
"test-results/**",
|
"test-results/**",
|
||||||
"playwright-report/**",
|
"playwright-report/**",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "agent-company",
|
"name": "agent-company",
|
||||||
"version": "0.3.0",
|
"version": "0.4.16",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Cross-platform Electron desktop control plane for local-first AI agent companies.",
|
"description": "Cross-platform Electron desktop control plane for local-first AI agent companies.",
|
||||||
|
|||||||
@@ -1,8 +1,33 @@
|
|||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
|
import { readFile, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const packageJsonPath = join(__dirname, "..", "package.json");
|
||||||
|
const releaseDir = join(__dirname, "..", "build", "release");
|
||||||
|
|
||||||
const extraArgs = process.argv.slice(2);
|
const extraArgs = process.argv.slice(2);
|
||||||
const env = { ...process.env };
|
const env = { ...process.env };
|
||||||
|
|
||||||
|
function bumpPatchVersion(currentVersion) {
|
||||||
|
const match = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(currentVersion);
|
||||||
|
if (!match) {
|
||||||
|
throw new Error(`[build-desktop] Unsupported package version format: ${currentVersion}`);
|
||||||
|
}
|
||||||
|
const [, major, minor, patch] = match;
|
||||||
|
return `${major}.${minor}.${Number.parseInt(patch, 10) + 1}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prepareBuildVersion() {
|
||||||
|
const raw = await readFile(packageJsonPath, "utf8");
|
||||||
|
const pkg = JSON.parse(raw);
|
||||||
|
const nextVersion = bumpPatchVersion(pkg.version);
|
||||||
|
pkg.version = nextVersion;
|
||||||
|
await writeFile(packageJsonPath, `${JSON.stringify(pkg, null, 2)}\n`, "utf8");
|
||||||
|
return nextVersion;
|
||||||
|
}
|
||||||
|
|
||||||
if (env.CSC_IDENTITY_AUTO_DISCOVERY === undefined) {
|
if (env.CSC_IDENTITY_AUTO_DISCOVERY === undefined) {
|
||||||
env.CSC_IDENTITY_AUTO_DISCOVERY = "false";
|
env.CSC_IDENTITY_AUTO_DISCOVERY = "false";
|
||||||
}
|
}
|
||||||
@@ -29,15 +54,17 @@ if (!explicitPublishArg) {
|
|||||||
}
|
}
|
||||||
builderArgs.push(...extraArgs);
|
builderArgs.push(...extraArgs);
|
||||||
|
|
||||||
const child = spawn(
|
await rm(releaseDir, { recursive: true, force: true });
|
||||||
"pnpm",
|
const buildVersion = env.AGENTCOMPANY_BUILD_VERSION?.trim() || await prepareBuildVersion();
|
||||||
builderArgs,
|
env.AGENTCOMPANY_BUILD_VERSION = buildVersion;
|
||||||
{
|
env.npm_package_version = buildVersion;
|
||||||
stdio: "inherit",
|
console.log(`[build-desktop] Cleaned build output and bumped package version to ${buildVersion}.`);
|
||||||
env,
|
|
||||||
shell: process.platform === "win32",
|
const child = spawn("pnpm", builderArgs, {
|
||||||
},
|
stdio: "inherit",
|
||||||
);
|
env,
|
||||||
|
shell: process.platform === "win32",
|
||||||
|
});
|
||||||
|
|
||||||
child.on("exit", (code, signal) => {
|
child.on("exit", (code, signal) => {
|
||||||
if (signal) {
|
if (signal) {
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
||||||
|
|
||||||
/**
|
|
||||||
* Lightweight HMAC-based agent token system.
|
|
||||||
* Tokens are short-lived and scoped to a specific agent + company + run.
|
|
||||||
* No external JWT library needed.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const SIGNING_KEY = randomBytes(32);
|
const SIGNING_KEY = randomBytes(32);
|
||||||
const TOKEN_TTL_MS = 12 * 60 * 60 * 1000; // 12 hours
|
const TOKEN_TTL_MS = 48 * 60 * 60 * 1000; // 48 hours
|
||||||
|
|
||||||
interface TokenPayload {
|
interface TokenPayload {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
|
|||||||
+422
-144
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 { join } from "node:path";
|
||||||
import log from "electron-log/main.js";
|
import log from "electron-log/main.js";
|
||||||
|
|
||||||
/**
|
|
||||||
* Browser Automation Manager — Electron-native edition.
|
|
||||||
*
|
|
||||||
* Uses Electron's own BrowserWindow and persistent sessions instead of
|
|
||||||
* external Playwright, so it works in packaged apps with zero extra deps.
|
|
||||||
*
|
|
||||||
* Each social account gets its own Electron session partition so cookies,
|
|
||||||
* localStorage, and login state are isolated and persisted across app restarts.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface BrowserAction {
|
export interface BrowserAction {
|
||||||
id: string;
|
id: string;
|
||||||
socialAccountId: string;
|
socialAccountId: string;
|
||||||
|
platform: string;
|
||||||
actionType: string;
|
actionType: string;
|
||||||
payload: Record<string, unknown>;
|
payload: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
@@ -33,56 +24,116 @@ interface QueuedAction {
|
|||||||
reject: (error: Error) => void;
|
reject: (error: Error) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PlatformRule {
|
||||||
|
loginUrl: string;
|
||||||
|
authCheckUrl: string;
|
||||||
|
allowedHosts: string[];
|
||||||
|
cookieOrigins: string[];
|
||||||
|
unauthenticatedUrlMarkers: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLATFORM_RULES: Record<string, PlatformRule> = {
|
||||||
|
twitter: {
|
||||||
|
loginUrl: "https://x.com/i/flow/login",
|
||||||
|
authCheckUrl: "https://x.com/home",
|
||||||
|
allowedHosts: ["x.com", "twitter.com"],
|
||||||
|
cookieOrigins: ["https://x.com", "https://twitter.com"],
|
||||||
|
unauthenticatedUrlMarkers: ["/i/flow/login", "/login"],
|
||||||
|
},
|
||||||
|
linkedin: {
|
||||||
|
loginUrl: "https://www.linkedin.com/login",
|
||||||
|
authCheckUrl: "https://www.linkedin.com/feed/",
|
||||||
|
allowedHosts: ["linkedin.com"],
|
||||||
|
cookieOrigins: ["https://www.linkedin.com"],
|
||||||
|
unauthenticatedUrlMarkers: ["/login", "/checkpoint"],
|
||||||
|
},
|
||||||
|
instagram: {
|
||||||
|
loginUrl: "https://www.instagram.com/accounts/login/",
|
||||||
|
authCheckUrl: "https://www.instagram.com/",
|
||||||
|
allowedHosts: ["instagram.com"],
|
||||||
|
cookieOrigins: ["https://www.instagram.com"],
|
||||||
|
unauthenticatedUrlMarkers: ["/accounts/login"],
|
||||||
|
},
|
||||||
|
facebook: {
|
||||||
|
loginUrl: "https://www.facebook.com/login",
|
||||||
|
authCheckUrl: "https://www.facebook.com/",
|
||||||
|
allowedHosts: ["facebook.com"],
|
||||||
|
cookieOrigins: ["https://www.facebook.com"],
|
||||||
|
unauthenticatedUrlMarkers: ["/login"],
|
||||||
|
},
|
||||||
|
reddit: {
|
||||||
|
loginUrl: "https://www.reddit.com/login",
|
||||||
|
authCheckUrl: "https://www.reddit.com/",
|
||||||
|
allowedHosts: ["reddit.com"],
|
||||||
|
cookieOrigins: ["https://www.reddit.com"],
|
||||||
|
unauthenticatedUrlMarkers: ["/login"],
|
||||||
|
},
|
||||||
|
youtube: {
|
||||||
|
loginUrl: "https://accounts.google.com/signin",
|
||||||
|
authCheckUrl: "https://www.youtube.com/",
|
||||||
|
allowedHosts: ["youtube.com", "google.com"],
|
||||||
|
cookieOrigins: ["https://www.youtube.com", "https://accounts.google.com"],
|
||||||
|
unauthenticatedUrlMarkers: ["/signin", "/ServiceLogin"],
|
||||||
|
},
|
||||||
|
tiktok: {
|
||||||
|
loginUrl: "https://www.tiktok.com/login",
|
||||||
|
authCheckUrl: "https://www.tiktok.com/foryou",
|
||||||
|
allowedHosts: ["tiktok.com"],
|
||||||
|
cookieOrigins: ["https://www.tiktok.com"],
|
||||||
|
unauthenticatedUrlMarkers: ["/login"],
|
||||||
|
},
|
||||||
|
bluesky: {
|
||||||
|
loginUrl: "https://bsky.app",
|
||||||
|
authCheckUrl: "https://bsky.app/",
|
||||||
|
allowedHosts: ["bsky.app"],
|
||||||
|
cookieOrigins: ["https://bsky.app"],
|
||||||
|
unauthenticatedUrlMarkers: ["/login"],
|
||||||
|
},
|
||||||
|
other: {
|
||||||
|
loginUrl: "https://google.com",
|
||||||
|
authCheckUrl: "https://google.com",
|
||||||
|
allowedHosts: ["google.com"],
|
||||||
|
cookieOrigins: ["https://google.com"],
|
||||||
|
unauthenticatedUrlMarkers: ["/login"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export class BrowserManager {
|
export class BrowserManager {
|
||||||
private screenshotDir: string;
|
private screenshotDir: string;
|
||||||
private queues = new Map<string, QueuedAction[]>();
|
private queues = new Map<string, QueuedAction[]>();
|
||||||
private processing = new Set<string>();
|
private processing = new Set<string>();
|
||||||
/** Track open login windows so we don't open duplicates */
|
|
||||||
private loginWindows = new Map<string, BrowserWindow>();
|
private loginWindows = new Map<string, BrowserWindow>();
|
||||||
|
private initializedSessions = new Set<string>();
|
||||||
|
|
||||||
constructor(profileDir: string) {
|
constructor(profileDir: string) {
|
||||||
this.screenshotDir = join(profileDir, "browser-screenshots");
|
this.screenshotDir = join(profileDir, "browser-screenshots");
|
||||||
if (!existsSync(this.screenshotDir)) mkdirSync(this.screenshotDir, { recursive: true });
|
if (!existsSync(this.screenshotDir)) mkdirSync(this.screenshotDir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a persistent Electron session for a social account.
|
|
||||||
* Using `persist:` prefix ensures data survives app restarts.
|
|
||||||
*/
|
|
||||||
private getSession(socialAccountId: string): Electron.Session {
|
private getSession(socialAccountId: string): Electron.Session {
|
||||||
return session.fromPartition(`persist:social-${socialAccountId}`);
|
const partition = `persist:social-${socialAccountId}`;
|
||||||
|
const socialSession = session.fromPartition(partition);
|
||||||
|
if (!this.initializedSessions.has(partition)) {
|
||||||
|
socialSession.setPermissionCheckHandler(() => false);
|
||||||
|
socialSession.setPermissionRequestHandler((_webContents, _permission, callback) => callback(false));
|
||||||
|
this.initializedSessions.add(partition);
|
||||||
|
}
|
||||||
|
return socialSession;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Launch a real browser window for manual login.
|
|
||||||
* The window uses a persistent session so cookies are saved.
|
|
||||||
* Returns a promise that resolves when the user closes the window.
|
|
||||||
*/
|
|
||||||
async launchLoginBrowser(
|
async launchLoginBrowser(
|
||||||
socialAccountId: string,
|
socialAccountId: string,
|
||||||
platform: string,
|
platform: string,
|
||||||
loginUrl?: string,
|
loginUrl?: string,
|
||||||
): Promise<BrowserActionResult> {
|
): Promise<BrowserActionResult> {
|
||||||
// Don't open duplicate login windows for the same account
|
|
||||||
const existing = this.loginWindows.get(socialAccountId);
|
const existing = this.loginWindows.get(socialAccountId);
|
||||||
if (existing && !existing.isDestroyed()) {
|
if (existing && !existing.isDestroyed()) {
|
||||||
existing.focus();
|
existing.focus();
|
||||||
return { success: true, summary: "Login window already open", screenshotPath: null, error: null };
|
return { success: true, summary: "Login window already open", screenshotPath: null, error: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultUrls: Record<string, string> = {
|
const rules = this.getPlatformRules(platform);
|
||||||
twitter: "https://x.com/i/flow/login",
|
const url = loginUrl || rules.loginUrl;
|
||||||
linkedin: "https://www.linkedin.com/login",
|
|
||||||
instagram: "https://www.instagram.com/accounts/login/",
|
|
||||||
facebook: "https://www.facebook.com/login",
|
|
||||||
reddit: "https://www.reddit.com/login",
|
|
||||||
youtube: "https://accounts.google.com/signin",
|
|
||||||
tiktok: "https://www.tiktok.com/login",
|
|
||||||
bluesky: "https://bsky.app",
|
|
||||||
other: "https://google.com",
|
|
||||||
};
|
|
||||||
|
|
||||||
const url = loginUrl || defaultUrls[platform] || defaultUrls.other;
|
|
||||||
const ses = this.getSession(socialAccountId);
|
const ses = this.getSession(socialAccountId);
|
||||||
|
|
||||||
log.info(`[browser] Opening login window for ${platform} → ${url}`);
|
log.info(`[browser] Opening login window for ${platform} → ${url}`);
|
||||||
@@ -93,7 +144,6 @@ export class BrowserManager {
|
|||||||
title: `Login to ${platform} — AgentCompany`,
|
title: `Login to ${platform} — AgentCompany`,
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
session: ses,
|
session: ses,
|
||||||
// Standard web page — no node integration, no preload
|
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
sandbox: true,
|
sandbox: true,
|
||||||
@@ -102,43 +152,32 @@ export class BrowserManager {
|
|||||||
|
|
||||||
this.loginWindows.set(socialAccountId, win);
|
this.loginWindows.set(socialAccountId, win);
|
||||||
|
|
||||||
// Remove menu bar for a cleaner look
|
|
||||||
win.setMenuBarVisibility(false);
|
win.setMenuBarVisibility(false);
|
||||||
|
|
||||||
// Load the login page
|
|
||||||
try {
|
try {
|
||||||
await win.loadURL(url);
|
await win.loadURL(url);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error(`[browser] Failed to load ${url}: ${err}`);
|
log.error(`[browser] Failed to load ${url}: ${err}`);
|
||||||
// Don't fail — the page might redirect or have an expected error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for the user to close the window
|
|
||||||
return new Promise<BrowserActionResult>((resolve) => {
|
return new Promise<BrowserActionResult>((resolve) => {
|
||||||
win.on("closed", () => {
|
win.on("closed", () => {
|
||||||
this.loginWindows.delete(socialAccountId);
|
this.loginWindows.delete(socialAccountId);
|
||||||
log.info(`[browser] Login window closed for ${platform} — session saved`);
|
void this.verifyLoginSession(socialAccountId, platform).then((verified) => {
|
||||||
resolve({
|
log.info(`[browser] Login window closed for ${platform} — verified=${verified}`);
|
||||||
success: true,
|
resolve({
|
||||||
summary: `Login session saved for ${platform}`,
|
success: verified,
|
||||||
screenshotPath: null,
|
summary: verified
|
||||||
error: null,
|
? `Authenticated session detected for ${platform}`
|
||||||
|
: `No authenticated ${platform} session detected`,
|
||||||
|
screenshotPath: null,
|
||||||
|
error: verified ? null : `Authentication for ${platform} was not completed.`,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if browser automation is available.
|
|
||||||
* Always true — we use Electron's own Chromium.
|
|
||||||
*/
|
|
||||||
async checkPlaywright(): Promise<boolean> {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Queue a browser action for execution.
|
|
||||||
*/
|
|
||||||
async executeAction(action: BrowserAction): Promise<BrowserActionResult> {
|
async executeAction(action: BrowserAction): Promise<BrowserActionResult> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const queue = this.queues.get(action.socialAccountId) ?? [];
|
const queue = this.queues.get(action.socialAccountId) ?? [];
|
||||||
@@ -166,17 +205,14 @@ export class BrowserManager {
|
|||||||
this.processing.delete(socialAccountId);
|
this.processing.delete(socialAccountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute a single browser action using an Electron BrowserWindow.
|
|
||||||
*/
|
|
||||||
private async runAction(action: BrowserAction): Promise<BrowserActionResult> {
|
private async runAction(action: BrowserAction): Promise<BrowserActionResult> {
|
||||||
const ses = this.getSession(action.socialAccountId);
|
const ses = this.getSession(action.socialAccountId);
|
||||||
const payload = action.payload;
|
const payload = action.payload;
|
||||||
const screenshotPath = join(this.screenshotDir, `${action.id}.png`);
|
const screenshotPath = join(this.screenshotDir, `${action.id}.png`);
|
||||||
|
const platform = action.platform;
|
||||||
|
|
||||||
log.info(`[browser] Executing ${action.actionType} for account ${action.socialAccountId}`);
|
log.info(`[browser] Executing ${action.actionType} for ${platform}/${action.socialAccountId}`);
|
||||||
|
|
||||||
// Create a visible browser window for the action
|
|
||||||
const win = new BrowserWindow({
|
const win = new BrowserWindow({
|
||||||
width: 1280,
|
width: 1280,
|
||||||
height: 860,
|
height: 860,
|
||||||
@@ -197,6 +233,7 @@ export class BrowserManager {
|
|||||||
switch (action.actionType) {
|
switch (action.actionType) {
|
||||||
case "navigate": {
|
case "navigate": {
|
||||||
const url = String(payload.url || "https://google.com");
|
const url = String(payload.url || "https://google.com");
|
||||||
|
this.assertAllowedUrl(platform, url, "navigate");
|
||||||
await win.loadURL(url);
|
await win.loadURL(url);
|
||||||
await this.waitForLoad(win);
|
await this.waitForLoad(win);
|
||||||
const title = await win.webContents.executeJavaScript("document.title");
|
const title = await win.webContents.executeJavaScript("document.title");
|
||||||
@@ -205,7 +242,6 @@ export class BrowserManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "post": {
|
case "post": {
|
||||||
const platform = String(payload.platform || "twitter");
|
|
||||||
const content = String(payload.content || "");
|
const content = String(payload.content || "");
|
||||||
const postUrl = this.getComposeUrl(platform);
|
const postUrl = this.getComposeUrl(platform);
|
||||||
await win.loadURL(postUrl);
|
await win.loadURL(postUrl);
|
||||||
@@ -213,7 +249,6 @@ export class BrowserManager {
|
|||||||
await this.delay(2000);
|
await this.delay(2000);
|
||||||
|
|
||||||
if (platform === "twitter") {
|
if (platform === "twitter") {
|
||||||
// Type into tweet composer
|
|
||||||
await win.webContents.executeJavaScript(`
|
await win.webContents.executeJavaScript(`
|
||||||
(async () => {
|
(async () => {
|
||||||
const editor = document.querySelector('[data-testid="tweetTextarea_0"], [role="textbox"]');
|
const editor = document.querySelector('[data-testid="tweetTextarea_0"], [role="textbox"]');
|
||||||
@@ -224,7 +259,6 @@ export class BrowserManager {
|
|||||||
})()
|
})()
|
||||||
`);
|
`);
|
||||||
await this.delay(1000);
|
await this.delay(1000);
|
||||||
// Click post button
|
|
||||||
await win.webContents.executeJavaScript(`
|
await win.webContents.executeJavaScript(`
|
||||||
(() => {
|
(() => {
|
||||||
const btn = document.querySelector('[data-testid="tweetButton"], [data-testid="tweetButtonInline"]');
|
const btn = document.querySelector('[data-testid="tweetButton"], [data-testid="tweetButtonInline"]');
|
||||||
@@ -264,6 +298,7 @@ export class BrowserManager {
|
|||||||
|
|
||||||
case "reply": {
|
case "reply": {
|
||||||
const targetUrl = String(payload.targetUrl || "");
|
const targetUrl = String(payload.targetUrl || "");
|
||||||
|
this.assertAllowedUrl(platform, targetUrl, "reply");
|
||||||
const replyContent = String(payload.content || "");
|
const replyContent = String(payload.content || "");
|
||||||
await win.loadURL(targetUrl);
|
await win.loadURL(targetUrl);
|
||||||
await this.waitForLoad(win);
|
await this.waitForLoad(win);
|
||||||
@@ -292,6 +327,7 @@ export class BrowserManager {
|
|||||||
|
|
||||||
case "like": {
|
case "like": {
|
||||||
const targetUrl = String(payload.targetUrl || "");
|
const targetUrl = String(payload.targetUrl || "");
|
||||||
|
this.assertAllowedUrl(platform, targetUrl, "like");
|
||||||
await win.loadURL(targetUrl);
|
await win.loadURL(targetUrl);
|
||||||
await this.waitForLoad(win);
|
await this.waitForLoad(win);
|
||||||
await this.delay(2000);
|
await this.delay(2000);
|
||||||
@@ -308,6 +344,7 @@ export class BrowserManager {
|
|||||||
|
|
||||||
case "follow": {
|
case "follow": {
|
||||||
const targetUrl = String(payload.targetUrl || payload.profileUrl || "");
|
const targetUrl = String(payload.targetUrl || payload.profileUrl || "");
|
||||||
|
this.assertAllowedUrl(platform, targetUrl, "follow");
|
||||||
await win.loadURL(targetUrl);
|
await win.loadURL(targetUrl);
|
||||||
await this.waitForLoad(win);
|
await this.waitForLoad(win);
|
||||||
await this.delay(2000);
|
await this.delay(2000);
|
||||||
@@ -323,14 +360,13 @@ export class BrowserManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "browse_feed": {
|
case "browse_feed": {
|
||||||
const platform = String(payload.platform || "twitter");
|
|
||||||
const feedUrl = String(payload.url || this.getFeedUrl(platform));
|
const feedUrl = String(payload.url || this.getFeedUrl(platform));
|
||||||
|
this.assertAllowedUrl(platform, feedUrl, "browse_feed");
|
||||||
const maxPosts = Number(payload.maxPosts || 10);
|
const maxPosts = Number(payload.maxPosts || 10);
|
||||||
await win.loadURL(feedUrl);
|
await win.loadURL(feedUrl);
|
||||||
await this.waitForLoad(win);
|
await this.waitForLoad(win);
|
||||||
await this.delay(3000);
|
await this.delay(3000);
|
||||||
|
|
||||||
// Scroll to load content
|
|
||||||
for (let i = 0; i < 3; i++) {
|
for (let i = 0; i < 3; i++) {
|
||||||
await win.webContents.executeJavaScript("window.scrollBy(0, window.innerHeight)");
|
await win.webContents.executeJavaScript("window.scrollBy(0, window.innerHeight)");
|
||||||
await this.delay(1500);
|
await this.delay(1500);
|
||||||
@@ -348,8 +384,8 @@ export class BrowserManager {
|
|||||||
|
|
||||||
case "search": {
|
case "search": {
|
||||||
const query = String(payload.query || "");
|
const query = String(payload.query || "");
|
||||||
const platform = String(payload.platform || "twitter");
|
|
||||||
const searchUrl = this.getSearchUrl(platform, query);
|
const searchUrl = this.getSearchUrl(platform, query);
|
||||||
|
this.assertAllowedUrl(platform, searchUrl, "search");
|
||||||
await win.loadURL(searchUrl);
|
await win.loadURL(searchUrl);
|
||||||
await this.waitForLoad(win);
|
await this.waitForLoad(win);
|
||||||
await this.delay(3000);
|
await this.delay(3000);
|
||||||
@@ -367,6 +403,7 @@ export class BrowserManager {
|
|||||||
case "screenshot": {
|
case "screenshot": {
|
||||||
const url = String(payload.url || "about:blank");
|
const url = String(payload.url || "about:blank");
|
||||||
if (url !== "about:blank") {
|
if (url !== "about:blank") {
|
||||||
|
this.assertAllowedUrl(platform, url, "screenshot");
|
||||||
await win.loadURL(url);
|
await win.loadURL(url);
|
||||||
await this.waitForLoad(win);
|
await this.waitForLoad(win);
|
||||||
await this.delay(2000);
|
await this.delay(2000);
|
||||||
@@ -377,10 +414,9 @@ export class BrowserManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
summary = `Unknown action type: ${action.actionType}`;
|
throw new Error(`Unsupported browser action type: ${action.actionType}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Capture screenshot
|
|
||||||
try {
|
try {
|
||||||
const image: NativeImage = await win.webContents.capturePage();
|
const image: NativeImage = await win.webContents.capturePage();
|
||||||
if (!image.isEmpty()) {
|
if (!image.isEmpty()) {
|
||||||
@@ -390,7 +426,6 @@ export class BrowserManager {
|
|||||||
log.warn(`[browser] Screenshot failed: ${err}`);
|
log.warn(`[browser] Screenshot failed: ${err}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close the action window after a brief pause so user can see result
|
|
||||||
await this.delay(1000);
|
await this.delay(1000);
|
||||||
if (!win.isDestroyed()) win.close();
|
if (!win.isDestroyed()) win.close();
|
||||||
|
|
||||||
@@ -401,7 +436,6 @@ export class BrowserManager {
|
|||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Try to capture error screenshot
|
|
||||||
try {
|
try {
|
||||||
const image: NativeImage = await win.webContents.capturePage();
|
const image: NativeImage = await win.webContents.capturePage();
|
||||||
if (!image.isEmpty()) writeFileSync(screenshotPath, image.toPNG());
|
if (!image.isEmpty()) writeFileSync(screenshotPath, image.toPNG());
|
||||||
@@ -420,8 +454,6 @@ export class BrowserManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private delay(ms: number): Promise<void> {
|
private delay(ms: number): Promise<void> {
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
}
|
}
|
||||||
@@ -440,6 +472,69 @@ export class BrowserManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getPlatformRules(platform: string): PlatformRule {
|
||||||
|
return PLATFORM_RULES[platform] ?? PLATFORM_RULES.other;
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertAllowedUrl(platform: string, rawUrl: string, actionType: string) {
|
||||||
|
const allowedHosts = this.getPlatformRules(platform).allowedHosts;
|
||||||
|
let url: URL;
|
||||||
|
try {
|
||||||
|
url = new URL(rawUrl);
|
||||||
|
} catch {
|
||||||
|
throw new Error(`Invalid URL for ${actionType}: ${rawUrl}`);
|
||||||
|
}
|
||||||
|
const host = url.hostname.toLowerCase();
|
||||||
|
const isAllowed = allowedHosts.some((allowedHost) => host === allowedHost || host.endsWith(`.${allowedHost}`));
|
||||||
|
if (!isAllowed) {
|
||||||
|
throw new Error(`Navigation blocked for ${actionType}. ${host} is outside the allowed ${platform} domains.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async verifyLoginSession(socialAccountId: string, platform: string): Promise<boolean> {
|
||||||
|
const ses = this.getSession(socialAccountId);
|
||||||
|
const rules = this.getPlatformRules(platform);
|
||||||
|
const cookieSets = await Promise.all(
|
||||||
|
rules.cookieOrigins.map(async (origin) => {
|
||||||
|
try {
|
||||||
|
return await ses.cookies.get({ url: origin });
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const hasCookies = cookieSets.some((cookies) => cookies.length > 0);
|
||||||
|
if (!hasCookies) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const verificationWindow = new BrowserWindow({
|
||||||
|
show: false,
|
||||||
|
webPreferences: {
|
||||||
|
session: ses,
|
||||||
|
nodeIntegration: false,
|
||||||
|
contextIsolation: true,
|
||||||
|
sandbox: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await verificationWindow.loadURL(rules.authCheckUrl);
|
||||||
|
await this.waitForLoad(verificationWindow, 10000);
|
||||||
|
const finalUrl = verificationWindow.webContents.getURL();
|
||||||
|
const finalUrlLower = finalUrl.toLowerCase();
|
||||||
|
const bouncedToLogin = rules.unauthenticatedUrlMarkers.some((marker) => finalUrlLower.includes(marker.toLowerCase()));
|
||||||
|
return !bouncedToLogin;
|
||||||
|
} catch (error) {
|
||||||
|
log.warn(`[browser] Login verification failed for ${platform}: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
if (!verificationWindow.isDestroyed()) {
|
||||||
|
verificationWindow.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private getComposeUrl(platform: string): string {
|
private getComposeUrl(platform: string): string {
|
||||||
const urls: Record<string, string> = {
|
const urls: Record<string, string> = {
|
||||||
twitter: "https://x.com/compose/post",
|
twitter: "https://x.com/compose/post",
|
||||||
@@ -448,8 +543,10 @@ export class BrowserManager {
|
|||||||
facebook: "https://www.facebook.com/",
|
facebook: "https://www.facebook.com/",
|
||||||
reddit: "https://www.reddit.com/submit",
|
reddit: "https://www.reddit.com/submit",
|
||||||
bluesky: "https://bsky.app/",
|
bluesky: "https://bsky.app/",
|
||||||
|
youtube: "https://www.youtube.com/",
|
||||||
|
tiktok: "https://www.tiktok.com/",
|
||||||
};
|
};
|
||||||
return urls[platform] || "https://google.com";
|
return urls[platform] || this.getPlatformRules(platform).loginUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getFeedUrl(platform: string): string {
|
private getFeedUrl(platform: string): string {
|
||||||
@@ -457,10 +554,13 @@ export class BrowserManager {
|
|||||||
twitter: "https://x.com/home",
|
twitter: "https://x.com/home",
|
||||||
linkedin: "https://www.linkedin.com/feed/",
|
linkedin: "https://www.linkedin.com/feed/",
|
||||||
instagram: "https://www.instagram.com/",
|
instagram: "https://www.instagram.com/",
|
||||||
|
facebook: "https://www.facebook.com/",
|
||||||
reddit: "https://www.reddit.com/",
|
reddit: "https://www.reddit.com/",
|
||||||
bluesky: "https://bsky.app/",
|
bluesky: "https://bsky.app/",
|
||||||
|
youtube: "https://www.youtube.com/",
|
||||||
|
tiktok: "https://www.tiktok.com/",
|
||||||
};
|
};
|
||||||
return urls[platform] || "https://google.com";
|
return urls[platform] || this.getPlatformRules(platform).loginUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getSearchUrl(platform: string, query: string): string {
|
private getSearchUrl(platform: string, query: string): string {
|
||||||
@@ -474,7 +574,6 @@ export class BrowserManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
shutdown() {
|
shutdown() {
|
||||||
// Close any open login windows
|
|
||||||
for (const [, win] of this.loginWindows) {
|
for (const [, win] of this.loginWindows) {
|
||||||
if (!win.isDestroyed()) win.close();
|
if (!win.isDestroyed()) win.close();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -263,10 +263,6 @@ const CONNECTORS: ConnectorDefinition[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function listConnectorDefinitions(): ConnectorDefinition[] {
|
|
||||||
return CONNECTORS;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getConnectorDefinition(id: ConnectorId): ConnectorDefinition {
|
export function getConnectorDefinition(id: ConnectorId): ConnectorDefinition {
|
||||||
const definition = CONNECTORS.find((entry) => entry.id === id);
|
const definition = CONNECTORS.find((entry) => entry.id === id);
|
||||||
if (!definition) {
|
if (!definition) {
|
||||||
|
|||||||
+674
-268
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 { getConnectorDefinition } from "@main/connectors";
|
||||||
import type { ConnectorId, RunStatus } from "@shared/types";
|
import type { ConnectorId, RunStatus } from "@shared/types";
|
||||||
|
|
||||||
/**
|
|
||||||
* Environment variables allowed to propagate from the host process to connector
|
|
||||||
* child processes. Any env var NOT in this list is stripped. Secret bindings
|
|
||||||
* resolved from the vault are always injected regardless of this allowlist.
|
|
||||||
*/
|
|
||||||
const ENV_ALLOWLIST: ReadonlySet<string> = new Set([
|
const ENV_ALLOWLIST: ReadonlySet<string> = new Set([
|
||||||
// PATH is required for command resolution
|
|
||||||
"PATH",
|
"PATH",
|
||||||
// Basic locale / terminal
|
|
||||||
"HOME", "USER", "SHELL", "LANG", "LC_ALL", "LC_CTYPE", "TERM", "TMPDIR",
|
"HOME", "USER", "SHELL", "LANG", "LC_ALL", "LC_CTYPE", "TERM", "TMPDIR",
|
||||||
// macOS-specific
|
|
||||||
"DEVELOPER_DIR", "SDKROOT",
|
"DEVELOPER_DIR", "SDKROOT",
|
||||||
// Node
|
|
||||||
"NODE_ENV", "NODE_OPTIONS",
|
"NODE_ENV", "NODE_OPTIONS",
|
||||||
// Git
|
|
||||||
"GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL", "GIT_COMMITTER_NAME", "GIT_COMMITTER_EMAIL",
|
"GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL", "GIT_COMMITTER_NAME", "GIT_COMMITTER_EMAIL",
|
||||||
"GIT_SSH_COMMAND", "GIT_ASKPASS", "GIT_TERMINAL_PROMPT",
|
"GIT_SSH_COMMAND", "GIT_ASKPASS", "GIT_TERMINAL_PROMPT",
|
||||||
// XDG
|
|
||||||
"XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME", "XDG_RUNTIME_DIR",
|
"XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME", "XDG_RUNTIME_DIR",
|
||||||
// Platform / display (Linux)
|
|
||||||
"DISPLAY", "WAYLAND_DISPLAY", "DBUS_SESSION_BUS_ADDRESS",
|
"DISPLAY", "WAYLAND_DISPLAY", "DBUS_SESSION_BUS_ADDRESS",
|
||||||
// Windows
|
|
||||||
"APPDATA", "LOCALAPPDATA", "USERPROFILE", "SYSTEMROOT", "COMSPEC", "PROGRAMFILES",
|
"APPDATA", "LOCALAPPDATA", "USERPROFILE", "SYSTEMROOT", "COMSPEC", "PROGRAMFILES",
|
||||||
// AgentCompany injected
|
|
||||||
"AGENT_COMPANY_API_URL", "AGENT_COMPANY_API_KEY", "AGENT_COMPANY_RUN_ID",
|
"AGENT_COMPANY_API_URL", "AGENT_COMPANY_API_KEY", "AGENT_COMPANY_RUN_ID",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a sanitized environment for a connector child process.
|
|
||||||
* Only allowlisted host vars + explicitly resolved secret bindings are included.
|
|
||||||
*/
|
|
||||||
function buildSanitizedEnv(
|
function buildSanitizedEnv(
|
||||||
hostEnv: Record<string, string>,
|
hostEnv: Record<string, string>,
|
||||||
secretBindings: Record<string, string>,
|
secretBindings: Record<string, string>,
|
||||||
@@ -43,25 +25,18 @@ function buildSanitizedEnv(
|
|||||||
result[key] = hostEnv[key];
|
result[key] = hostEnv[key];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Secret bindings always override — they are resolved from the vault
|
|
||||||
Object.assign(result, secretBindings);
|
Object.assign(result, secretBindings);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a log redaction function that masks any secret values present in the env.
|
|
||||||
* Only values that look like secrets (length >= 8, not PATH-like) are redacted.
|
|
||||||
*/
|
|
||||||
function buildRedactor(env: Record<string, string>): (text: string) => string {
|
function buildRedactor(env: Record<string, string>): (text: string) => string {
|
||||||
const sensitivePatterns: string[] = [];
|
const sensitivePatterns: string[] = [];
|
||||||
for (const [key, value] of Object.entries(env)) {
|
for (const [key, value] of Object.entries(env)) {
|
||||||
// Skip short values, PATH, HOME, and other system vars unlikely to be secrets
|
|
||||||
if (value.length < 8) continue;
|
if (value.length < 8) continue;
|
||||||
if (["PATH", "HOME", "USER", "SHELL", "LANG", "TMPDIR", "TERM"].includes(key)) continue;
|
if (["PATH", "HOME", "USER", "SHELL", "LANG", "TMPDIR", "TERM"].includes(key)) continue;
|
||||||
sensitivePatterns.push(value);
|
sensitivePatterns.push(value);
|
||||||
}
|
}
|
||||||
if (sensitivePatterns.length === 0) return (text) => text;
|
if (sensitivePatterns.length === 0) return (text) => text;
|
||||||
// Sort by length descending so longer patterns are replaced first
|
|
||||||
sensitivePatterns.sort((a, b) => b.length - a.length);
|
sensitivePatterns.sort((a, b) => b.length - a.length);
|
||||||
return (text: string) => {
|
return (text: string) => {
|
||||||
let result = text;
|
let result = text;
|
||||||
@@ -85,14 +60,13 @@ interface RunJob {
|
|||||||
connectorId: ConnectorId;
|
connectorId: ConnectorId;
|
||||||
workspaceId: string | null;
|
workspaceId: string | null;
|
||||||
workspacePath: string | null;
|
workspacePath: string | null;
|
||||||
|
workspaceMode: "exclusive" | "shared" | "none";
|
||||||
command: string;
|
command: string;
|
||||||
model: string | null;
|
model: string | null;
|
||||||
thinkingEffort: string | null;
|
thinkingEffort: string | null;
|
||||||
env: Record<string, string>;
|
env: Record<string, string>;
|
||||||
sessionParams: Record<string, unknown> | null;
|
sessionParams: Record<string, unknown> | null;
|
||||||
/** Rich system prompt with API instructions for autonomous operation */
|
|
||||||
systemPrompt: string | null;
|
systemPrompt: string | null;
|
||||||
/** Heartbeat trigger that caused this run */
|
|
||||||
trigger: string | null;
|
trigger: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,6 +108,9 @@ function post(message: ChildMessage) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function workspaceKey(job: RunJob) {
|
function workspaceKey(job: RunJob) {
|
||||||
|
if (job.workspaceMode !== "exclusive") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return job.workspaceId ?? job.workspacePath ?? `task:${job.taskId}`;
|
return job.workspaceId ?? job.workspacePath ?? `task:${job.taskId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,7 +129,8 @@ async function maybeStartQueuedRuns() {
|
|||||||
const readyJobs = [...queue];
|
const readyJobs = [...queue];
|
||||||
for (const job of readyJobs) {
|
for (const job of readyJobs) {
|
||||||
if (activeByAgent.has(job.agentId)) continue;
|
if (activeByAgent.has(job.agentId)) continue;
|
||||||
if (activeByWorkspace.has(workspaceKey(job))) continue;
|
const lockKey = workspaceKey(job);
|
||||||
|
if (lockKey && activeByWorkspace.has(lockKey)) continue;
|
||||||
const index = queue.findIndex((entry) => entry.runId === job.runId);
|
const index = queue.findIndex((entry) => entry.runId === job.runId);
|
||||||
if (index >= 0) queue.splice(index, 1);
|
if (index >= 0) queue.splice(index, 1);
|
||||||
void startRun(job);
|
void startRun(job);
|
||||||
@@ -163,7 +141,9 @@ async function startRun(job: RunJob) {
|
|||||||
const definition = getConnectorDefinition(job.connectorId);
|
const definition = getConnectorDefinition(job.connectorId);
|
||||||
const lockKey = workspaceKey(job);
|
const lockKey = workspaceKey(job);
|
||||||
activeByAgent.set(job.agentId, job.runId);
|
activeByAgent.set(job.agentId, job.runId);
|
||||||
activeByWorkspace.set(lockKey, job.runId);
|
if (lockKey) {
|
||||||
|
activeByWorkspace.set(lockKey, job.runId);
|
||||||
|
}
|
||||||
post({
|
post({
|
||||||
type: "run-status",
|
type: "run-status",
|
||||||
runId: job.runId,
|
runId: job.runId,
|
||||||
@@ -171,7 +151,6 @@ async function startRun(job: RunJob) {
|
|||||||
message: `Running with ${definition.label}.`,
|
message: `Running with ${definition.label}.`,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Build sanitized env: only allowlisted host vars + resolved secret bindings
|
|
||||||
const sanitizedEnv = buildSanitizedEnv(
|
const sanitizedEnv = buildSanitizedEnv(
|
||||||
Object.fromEntries(
|
Object.fromEntries(
|
||||||
Object.entries(process.env).filter((e): e is [string, string] => typeof e[1] === "string"),
|
Object.entries(process.env).filter((e): e is [string, string] => typeof e[1] === "string"),
|
||||||
@@ -179,7 +158,6 @@ async function startRun(job: RunJob) {
|
|||||||
job.env,
|
job.env,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Build log redactor from secret bindings to prevent accidental secret leakage
|
|
||||||
const redact = buildRedactor(job.env);
|
const redact = buildRedactor(job.env);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -205,13 +183,9 @@ async function startRun(job: RunJob) {
|
|||||||
cwd: job.workspacePath ?? process.cwd(),
|
cwd: job.workspacePath ?? process.cwd(),
|
||||||
model: job.model,
|
model: job.model,
|
||||||
env: sanitizedEnv,
|
env: sanitizedEnv,
|
||||||
// Claude: thinking effort
|
|
||||||
effort: job.connectorId === "claude_local" ? (job.thinkingEffort ?? "") : "",
|
effort: job.connectorId === "claude_local" ? (job.thinkingEffort ?? "") : "",
|
||||||
// Codex: reasoning effort
|
|
||||||
modelReasoningEffort: job.connectorId === "codex_local" ? (job.thinkingEffort ?? "") : "",
|
modelReasoningEffort: job.connectorId === "codex_local" ? (job.thinkingEffort ?? "") : "",
|
||||||
// Claude: run without permission prompts — required for headless operation
|
|
||||||
dangerouslySkipPermissions: true,
|
dangerouslySkipPermissions: true,
|
||||||
// Codex: bypass sandbox and approval prompts, skip git trust check
|
|
||||||
dangerouslyBypassApprovalsAndSandbox: true,
|
dangerouslyBypassApprovalsAndSandbox: true,
|
||||||
extraArgs: job.connectorId === "codex_local" ? ["--skip-git-repo-check"] : [],
|
extraArgs: job.connectorId === "codex_local" ? ["--skip-git-repo-check"] : [],
|
||||||
},
|
},
|
||||||
@@ -276,7 +250,9 @@ async function startRun(job: RunJob) {
|
|||||||
} finally {
|
} finally {
|
||||||
cancelledRuns.delete(job.runId);
|
cancelledRuns.delete(job.runId);
|
||||||
activeByAgent.delete(job.agentId);
|
activeByAgent.delete(job.agentId);
|
||||||
activeByWorkspace.delete(lockKey);
|
if (lockKey) {
|
||||||
|
activeByWorkspace.delete(lockKey);
|
||||||
|
}
|
||||||
void maybeStartQueuedRuns();
|
void maybeStartQueuedRuns();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+573
-317
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[] = [
|
export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
||||||
{
|
{
|
||||||
id: "software-startup",
|
id: "software-startup",
|
||||||
@@ -313,10 +308,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Fortune 500 Enterprise ────────────────────────────────────────────
|
|
||||||
// A truly realistic, fully-staffed company with 37 agents across 12
|
|
||||||
// departments. Modeled after a mature tech company or Fortune 500 org
|
|
||||||
// with every function needed to operate at scale.
|
|
||||||
{
|
{
|
||||||
id: "fortune500-enterprise",
|
id: "fortune500-enterprise",
|
||||||
name: "Fortune 500 Enterprise",
|
name: "Fortune 500 Enterprise",
|
||||||
@@ -337,11 +328,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
{ id: "research", name: "Research & Innovation", description: "Applied research, competitive intelligence, prototyping, and publication of thought-leadership papers." },
|
{ id: "research", name: "Research & Innovation", description: "Applied research, competitive intelligence, prototyping, and publication of thought-leadership papers." },
|
||||||
],
|
],
|
||||||
agents: [
|
agents: [
|
||||||
// ────────────────────────────────────────────────────────────────────
|
|
||||||
// EXECUTIVE SUITE — indices 0-4
|
|
||||||
// ────────────────────────────────────────────────────────────────────
|
|
||||||
{
|
{
|
||||||
// index 0
|
|
||||||
name: "Victoria Sterling",
|
name: "Victoria Sterling",
|
||||||
role: "CEO",
|
role: "CEO",
|
||||||
title: "Chief Executive Officer",
|
title: "Chief Executive Officer",
|
||||||
@@ -352,7 +339,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
budgetMonthlyUsd: 250,
|
budgetMonthlyUsd: 250,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// index 1
|
|
||||||
name: "James Whitfield",
|
name: "James Whitfield",
|
||||||
role: "COO",
|
role: "COO",
|
||||||
title: "Chief Operating Officer",
|
title: "Chief Operating Officer",
|
||||||
@@ -363,7 +349,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
budgetMonthlyUsd: 220,
|
budgetMonthlyUsd: 220,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// index 2
|
|
||||||
name: "Marcus Chen",
|
name: "Marcus Chen",
|
||||||
role: "CTO",
|
role: "CTO",
|
||||||
title: "Chief Technology Officer",
|
title: "Chief Technology Officer",
|
||||||
@@ -374,7 +359,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
budgetMonthlyUsd: 250,
|
budgetMonthlyUsd: 250,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// index 3
|
|
||||||
name: "Priya Sharma",
|
name: "Priya Sharma",
|
||||||
role: "CFO",
|
role: "CFO",
|
||||||
title: "Chief Financial Officer",
|
title: "Chief Financial Officer",
|
||||||
@@ -385,7 +369,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
budgetMonthlyUsd: 200,
|
budgetMonthlyUsd: 200,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// index 4
|
|
||||||
name: "Nadira Osei-Mensah",
|
name: "Nadira Osei-Mensah",
|
||||||
role: "CHRO",
|
role: "CHRO",
|
||||||
title: "Chief Human Resources Officer",
|
title: "Chief Human Resources Officer",
|
||||||
@@ -396,11 +379,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
budgetMonthlyUsd: 180,
|
budgetMonthlyUsd: 180,
|
||||||
},
|
},
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────────────
|
|
||||||
// ENGINEERING — indices 5-12
|
|
||||||
// ────────────────────────────────────────────────────────────────────
|
|
||||||
{
|
{
|
||||||
// index 5
|
|
||||||
name: "Elena Rodriguez",
|
name: "Elena Rodriguez",
|
||||||
role: "VP Engineering",
|
role: "VP Engineering",
|
||||||
title: "Vice President of Engineering",
|
title: "Vice President of Engineering",
|
||||||
@@ -411,7 +390,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
budgetMonthlyUsd: 220,
|
budgetMonthlyUsd: 220,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// index 6
|
|
||||||
name: "Tomasz Kowalski",
|
name: "Tomasz Kowalski",
|
||||||
role: "Principal Engineer",
|
role: "Principal Engineer",
|
||||||
title: "Principal Software Engineer",
|
title: "Principal Software Engineer",
|
||||||
@@ -422,7 +400,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
budgetMonthlyUsd: 280,
|
budgetMonthlyUsd: 280,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// index 7
|
|
||||||
name: "Kai Nakamura",
|
name: "Kai Nakamura",
|
||||||
role: "Senior Backend Engineer",
|
role: "Senior Backend Engineer",
|
||||||
title: "Senior Backend Engineer",
|
title: "Senior Backend Engineer",
|
||||||
@@ -433,7 +410,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
budgetMonthlyUsd: 250,
|
budgetMonthlyUsd: 250,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// index 8
|
|
||||||
name: "Sofia Andersson",
|
name: "Sofia Andersson",
|
||||||
role: "Senior Frontend Engineer",
|
role: "Senior Frontend Engineer",
|
||||||
title: "Senior Frontend Engineer",
|
title: "Senior Frontend Engineer",
|
||||||
@@ -444,7 +420,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
budgetMonthlyUsd: 250,
|
budgetMonthlyUsd: 250,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// index 9
|
|
||||||
name: "Omar Hassan",
|
name: "Omar Hassan",
|
||||||
role: "Platform / DevOps Engineer",
|
role: "Platform / DevOps Engineer",
|
||||||
title: "Senior Platform Engineer",
|
title: "Senior Platform Engineer",
|
||||||
@@ -455,7 +430,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
budgetMonthlyUsd: 230,
|
budgetMonthlyUsd: 230,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// index 10
|
|
||||||
name: "Yuki Tanaka",
|
name: "Yuki Tanaka",
|
||||||
role: "QA Lead",
|
role: "QA Lead",
|
||||||
title: "Quality Assurance Lead",
|
title: "Quality Assurance Lead",
|
||||||
@@ -466,7 +440,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
budgetMonthlyUsd: 200,
|
budgetMonthlyUsd: 200,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// index 11
|
|
||||||
name: "Amina Diallo",
|
name: "Amina Diallo",
|
||||||
role: "Security Engineer",
|
role: "Security Engineer",
|
||||||
title: "Application Security Engineer",
|
title: "Application Security Engineer",
|
||||||
@@ -477,7 +450,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
budgetMonthlyUsd: 230,
|
budgetMonthlyUsd: 230,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// index 12
|
|
||||||
name: "Ravi Krishnamurthy",
|
name: "Ravi Krishnamurthy",
|
||||||
role: "Data Engineer",
|
role: "Data Engineer",
|
||||||
title: "Senior Data Engineer",
|
title: "Senior Data Engineer",
|
||||||
@@ -488,11 +460,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
budgetMonthlyUsd: 230,
|
budgetMonthlyUsd: 230,
|
||||||
},
|
},
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────────────
|
|
||||||
// PRODUCT & DESIGN — indices 13-16
|
|
||||||
// ────────────────────────────────────────────────────────────────────
|
|
||||||
{
|
{
|
||||||
// index 13
|
|
||||||
name: "Amara Okafor",
|
name: "Amara Okafor",
|
||||||
role: "VP Product",
|
role: "VP Product",
|
||||||
title: "Vice President of Product",
|
title: "Vice President of Product",
|
||||||
@@ -503,7 +471,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
budgetMonthlyUsd: 200,
|
budgetMonthlyUsd: 200,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// index 14
|
|
||||||
name: "Daniel Okonkwo",
|
name: "Daniel Okonkwo",
|
||||||
role: "Senior Product Manager",
|
role: "Senior Product Manager",
|
||||||
title: "Senior Product Manager",
|
title: "Senior Product Manager",
|
||||||
@@ -514,7 +481,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
budgetMonthlyUsd: 180,
|
budgetMonthlyUsd: 180,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// index 15
|
|
||||||
name: "Ingrid Johansson",
|
name: "Ingrid Johansson",
|
||||||
role: "UX Director",
|
role: "UX Director",
|
||||||
title: "Director of User Experience",
|
title: "Director of User Experience",
|
||||||
@@ -525,7 +491,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
budgetMonthlyUsd: 180,
|
budgetMonthlyUsd: 180,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// index 16
|
|
||||||
name: "Leo Moretti",
|
name: "Leo Moretti",
|
||||||
role: "UI Designer",
|
role: "UI Designer",
|
||||||
title: "Senior UI Designer",
|
title: "Senior UI Designer",
|
||||||
@@ -536,11 +501,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
budgetMonthlyUsd: 160,
|
budgetMonthlyUsd: 160,
|
||||||
},
|
},
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────────────
|
|
||||||
// MARKETING — indices 17-20
|
|
||||||
// ────────────────────────────────────────────────────────────────────
|
|
||||||
{
|
{
|
||||||
// index 17
|
|
||||||
name: "Jasmine Delacroix",
|
name: "Jasmine Delacroix",
|
||||||
role: "CMO",
|
role: "CMO",
|
||||||
title: "Chief Marketing Officer",
|
title: "Chief Marketing Officer",
|
||||||
@@ -551,7 +512,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
budgetMonthlyUsd: 200,
|
budgetMonthlyUsd: 200,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// index 18
|
|
||||||
name: "Noah Park",
|
name: "Noah Park",
|
||||||
role: "Content Director",
|
role: "Content Director",
|
||||||
title: "Director of Content",
|
title: "Director of Content",
|
||||||
@@ -562,7 +522,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
budgetMonthlyUsd: 180,
|
budgetMonthlyUsd: 180,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// index 19
|
|
||||||
name: "Zara Ahmed",
|
name: "Zara Ahmed",
|
||||||
role: "Growth Marketing Manager",
|
role: "Growth Marketing Manager",
|
||||||
title: "Growth Marketing Manager",
|
title: "Growth Marketing Manager",
|
||||||
@@ -573,7 +532,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
budgetMonthlyUsd: 170,
|
budgetMonthlyUsd: 170,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// index 20
|
|
||||||
name: "Clara Dubois",
|
name: "Clara Dubois",
|
||||||
role: "Brand & PR Manager",
|
role: "Brand & PR Manager",
|
||||||
title: "Brand & Public Relations Manager",
|
title: "Brand & Public Relations Manager",
|
||||||
@@ -584,11 +542,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
budgetMonthlyUsd: 150,
|
budgetMonthlyUsd: 150,
|
||||||
},
|
},
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────────────
|
|
||||||
// SALES — indices 21-23
|
|
||||||
// ────────────────────────────────────────────────────────────────────
|
|
||||||
{
|
{
|
||||||
// index 21
|
|
||||||
name: "Daniel Brooks",
|
name: "Daniel Brooks",
|
||||||
role: "VP Sales",
|
role: "VP Sales",
|
||||||
title: "Vice President of Sales",
|
title: "Vice President of Sales",
|
||||||
@@ -599,7 +553,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
budgetMonthlyUsd: 200,
|
budgetMonthlyUsd: 200,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// index 22
|
|
||||||
name: "Grace Kimura",
|
name: "Grace Kimura",
|
||||||
role: "Enterprise Account Executive",
|
role: "Enterprise Account Executive",
|
||||||
title: "Enterprise Account Executive",
|
title: "Enterprise Account Executive",
|
||||||
@@ -610,7 +563,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
budgetMonthlyUsd: 180,
|
budgetMonthlyUsd: 180,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// index 23
|
|
||||||
name: "Marcus Adebayo",
|
name: "Marcus Adebayo",
|
||||||
role: "SDR Manager",
|
role: "SDR Manager",
|
||||||
title: "Sales Development Representative Manager",
|
title: "Sales Development Representative Manager",
|
||||||
@@ -621,11 +573,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
budgetMonthlyUsd: 150,
|
budgetMonthlyUsd: 150,
|
||||||
},
|
},
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────────────
|
|
||||||
// HR / PEOPLE — indices 24-26
|
|
||||||
// ────────────────────────────────────────────────────────────────────
|
|
||||||
{
|
{
|
||||||
// index 24
|
|
||||||
name: "Olivia Nguyen",
|
name: "Olivia Nguyen",
|
||||||
role: "VP People",
|
role: "VP People",
|
||||||
title: "Vice President of People",
|
title: "Vice President of People",
|
||||||
@@ -636,7 +584,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
budgetMonthlyUsd: 170,
|
budgetMonthlyUsd: 170,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// index 25
|
|
||||||
name: "Farid Al-Rashidi",
|
name: "Farid Al-Rashidi",
|
||||||
role: "Recruiting Lead",
|
role: "Recruiting Lead",
|
||||||
title: "Head of Talent Acquisition",
|
title: "Head of Talent Acquisition",
|
||||||
@@ -647,7 +594,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
budgetMonthlyUsd: 140,
|
budgetMonthlyUsd: 140,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// index 26
|
|
||||||
name: "Hannah Johansson",
|
name: "Hannah Johansson",
|
||||||
role: "People Operations Manager",
|
role: "People Operations Manager",
|
||||||
title: "People Operations Manager",
|
title: "People Operations Manager",
|
||||||
@@ -658,11 +604,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
budgetMonthlyUsd: 120,
|
budgetMonthlyUsd: 120,
|
||||||
},
|
},
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────────────
|
|
||||||
// FINANCE & LEGAL — indices 27-29
|
|
||||||
// ────────────────────────────────────────────────────────────────────
|
|
||||||
{
|
{
|
||||||
// index 27
|
|
||||||
name: "Richard Okonkwo",
|
name: "Richard Okonkwo",
|
||||||
role: "VP Finance",
|
role: "VP Finance",
|
||||||
title: "Vice President of Finance",
|
title: "Vice President of Finance",
|
||||||
@@ -673,7 +615,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
budgetMonthlyUsd: 180,
|
budgetMonthlyUsd: 180,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// index 28
|
|
||||||
name: "Mei-Ling Wu",
|
name: "Mei-Ling Wu",
|
||||||
role: "Financial Controller",
|
role: "Financial Controller",
|
||||||
title: "Financial Controller",
|
title: "Financial Controller",
|
||||||
@@ -684,7 +625,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
budgetMonthlyUsd: 140,
|
budgetMonthlyUsd: 140,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// index 29
|
|
||||||
name: "Alejandro Vega",
|
name: "Alejandro Vega",
|
||||||
role: "General Counsel",
|
role: "General Counsel",
|
||||||
title: "General Counsel",
|
title: "General Counsel",
|
||||||
@@ -695,11 +635,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
budgetMonthlyUsd: 200,
|
budgetMonthlyUsd: 200,
|
||||||
},
|
},
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────────────
|
|
||||||
// CUSTOMER SUCCESS — indices 30-32
|
|
||||||
// ────────────────────────────────────────────────────────────────────
|
|
||||||
{
|
{
|
||||||
// index 30
|
|
||||||
name: "Aisha Williams",
|
name: "Aisha Williams",
|
||||||
role: "VP Customer Success",
|
role: "VP Customer Success",
|
||||||
title: "Vice President of Customer Success",
|
title: "Vice President of Customer Success",
|
||||||
@@ -710,7 +646,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
budgetMonthlyUsd: 180,
|
budgetMonthlyUsd: 180,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// index 31
|
|
||||||
name: "Sergei Volkov",
|
name: "Sergei Volkov",
|
||||||
role: "Senior Support Engineer",
|
role: "Senior Support Engineer",
|
||||||
title: "Senior Technical Support Engineer",
|
title: "Senior Technical Support Engineer",
|
||||||
@@ -721,7 +656,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
budgetMonthlyUsd: 160,
|
budgetMonthlyUsd: 160,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// index 32
|
|
||||||
name: "Camila Reyes",
|
name: "Camila Reyes",
|
||||||
role: "Customer Success Manager",
|
role: "Customer Success Manager",
|
||||||
title: "Customer Success Manager",
|
title: "Customer Success Manager",
|
||||||
@@ -732,11 +666,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
budgetMonthlyUsd: 140,
|
budgetMonthlyUsd: 140,
|
||||||
},
|
},
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────────────
|
|
||||||
// OPERATIONS — indices 33-34
|
|
||||||
// ────────────────────────────────────────────────────────────────────
|
|
||||||
{
|
{
|
||||||
// index 33
|
|
||||||
name: "Kenji Watanabe",
|
name: "Kenji Watanabe",
|
||||||
role: "Director of Operations",
|
role: "Director of Operations",
|
||||||
title: "Director of Operations",
|
title: "Director of Operations",
|
||||||
@@ -747,7 +677,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
budgetMonthlyUsd: 160,
|
budgetMonthlyUsd: 160,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// index 34
|
|
||||||
name: "Laura Petrov",
|
name: "Laura Petrov",
|
||||||
role: "Program Manager",
|
role: "Program Manager",
|
||||||
title: "Senior Program Manager",
|
title: "Senior Program Manager",
|
||||||
@@ -758,11 +687,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
budgetMonthlyUsd: 150,
|
budgetMonthlyUsd: 150,
|
||||||
},
|
},
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────────────
|
|
||||||
// RESEARCH — indices 35-36
|
|
||||||
// ────────────────────────────────────────────────────────────────────
|
|
||||||
{
|
{
|
||||||
// index 35
|
|
||||||
name: "Dr. Lena Hoffmann",
|
name: "Dr. Lena Hoffmann",
|
||||||
role: "Director of Research",
|
role: "Director of Research",
|
||||||
title: "Director of Research & Innovation",
|
title: "Director of Research & Innovation",
|
||||||
@@ -773,7 +698,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
budgetMonthlyUsd: 200,
|
budgetMonthlyUsd: 200,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// index 36
|
|
||||||
name: "Dr. Aarav Mehta",
|
name: "Dr. Aarav Mehta",
|
||||||
role: "Research Scientist",
|
role: "Research Scientist",
|
||||||
title: "Research Scientist",
|
title: "Research Scientist",
|
||||||
@@ -828,7 +752,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── E-Commerce Company ──────────────────────────────────────────────
|
|
||||||
{
|
{
|
||||||
id: "ecommerce-company",
|
id: "ecommerce-company",
|
||||||
name: "E-Commerce Business",
|
name: "E-Commerce Business",
|
||||||
@@ -922,7 +845,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Consulting Firm ──────────────────────────────────────────────────
|
|
||||||
{
|
{
|
||||||
id: "consulting-firm",
|
id: "consulting-firm",
|
||||||
name: "Consulting Firm",
|
name: "Consulting Firm",
|
||||||
@@ -992,9 +914,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── AI Research Company ───────────────────────────────────────────────
|
|
||||||
// Modeled after organizations like OpenAI, Anthropic, and DeepMind.
|
|
||||||
// Research-first with strong engineering to ship models into production.
|
|
||||||
{
|
{
|
||||||
id: "ai-research-company",
|
id: "ai-research-company",
|
||||||
name: "AI Research Company",
|
name: "AI Research Company",
|
||||||
@@ -1013,9 +932,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
{ id: "legal", name: "Legal & Policy", description: "IP strategy, regulatory affairs, AI governance, data licensing, and compliance." },
|
{ id: "legal", name: "Legal & Policy", description: "IP strategy, regulatory affairs, AI governance, data licensing, and compliance." },
|
||||||
],
|
],
|
||||||
agents: [
|
agents: [
|
||||||
// ── Executive (0-1) ──
|
|
||||||
{
|
{
|
||||||
// index 0
|
|
||||||
name: "Dr. Julian Crane",
|
name: "Dr. Julian Crane",
|
||||||
role: "CEO",
|
role: "CEO",
|
||||||
title: "Chief Executive Officer",
|
title: "Chief Executive Officer",
|
||||||
@@ -1026,7 +943,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
budgetMonthlyUsd: 250,
|
budgetMonthlyUsd: 250,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// index 1
|
|
||||||
name: "Dr. Mei-Xing Zhou",
|
name: "Dr. Mei-Xing Zhou",
|
||||||
role: "Chief Scientist",
|
role: "Chief Scientist",
|
||||||
title: "Chief Scientist",
|
title: "Chief Scientist",
|
||||||
@@ -1036,10 +952,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
connectorPreference: "claude_local",
|
connectorPreference: "claude_local",
|
||||||
budgetMonthlyUsd: 280,
|
budgetMonthlyUsd: 280,
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Research (2-6) ──
|
|
||||||
{
|
{
|
||||||
// index 2
|
|
||||||
name: "Dr. Sasha Petrov",
|
name: "Dr. Sasha Petrov",
|
||||||
role: "Research Lead — Pre-training",
|
role: "Research Lead — Pre-training",
|
||||||
title: "Senior Research Scientist — Pre-training",
|
title: "Senior Research Scientist — Pre-training",
|
||||||
@@ -1050,7 +963,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
budgetMonthlyUsd: 260,
|
budgetMonthlyUsd: 260,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// index 3
|
|
||||||
name: "Dr. Nneka Adeyemi",
|
name: "Dr. Nneka Adeyemi",
|
||||||
role: "Research Lead — Post-training",
|
role: "Research Lead — Post-training",
|
||||||
title: "Senior Research Scientist — RLHF & Fine-tuning",
|
title: "Senior Research Scientist — RLHF & Fine-tuning",
|
||||||
@@ -1061,7 +973,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
budgetMonthlyUsd: 260,
|
budgetMonthlyUsd: 260,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// index 4
|
|
||||||
name: "Dr. Hiroshi Takeda",
|
name: "Dr. Hiroshi Takeda",
|
||||||
role: "Research Scientist — Reasoning",
|
role: "Research Scientist — Reasoning",
|
||||||
title: "Research Scientist — Reasoning & Agents",
|
title: "Research Scientist — Reasoning & Agents",
|
||||||
@@ -1072,7 +983,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
budgetMonthlyUsd: 240,
|
budgetMonthlyUsd: 240,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// index 5
|
|
||||||
name: "Dr. Amara Osei",
|
name: "Dr. Amara Osei",
|
||||||
role: "Research Scientist — Safety",
|
role: "Research Scientist — Safety",
|
||||||
title: "Research Scientist — AI Safety",
|
title: "Research Scientist — AI Safety",
|
||||||
@@ -1083,7 +993,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
budgetMonthlyUsd: 240,
|
budgetMonthlyUsd: 240,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// index 6
|
|
||||||
name: "Lila Fernandez",
|
name: "Lila Fernandez",
|
||||||
role: "Research Engineer",
|
role: "Research Engineer",
|
||||||
title: "Senior Research Engineer",
|
title: "Senior Research Engineer",
|
||||||
@@ -1093,10 +1002,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
connectorPreference: "codex_local",
|
connectorPreference: "codex_local",
|
||||||
budgetMonthlyUsd: 250,
|
budgetMonthlyUsd: 250,
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Engineering (7-10) ──
|
|
||||||
{
|
{
|
||||||
// index 7
|
|
||||||
name: "Rajiv Sundaram",
|
name: "Rajiv Sundaram",
|
||||||
role: "VP Engineering",
|
role: "VP Engineering",
|
||||||
title: "Vice President of Engineering",
|
title: "Vice President of Engineering",
|
||||||
@@ -1107,7 +1013,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
budgetMonthlyUsd: 220,
|
budgetMonthlyUsd: 220,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// index 8
|
|
||||||
name: "Christine Bergstrom",
|
name: "Christine Bergstrom",
|
||||||
role: "ML Infrastructure Engineer",
|
role: "ML Infrastructure Engineer",
|
||||||
title: "Senior ML Infrastructure Engineer",
|
title: "Senior ML Infrastructure Engineer",
|
||||||
@@ -1118,7 +1023,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
budgetMonthlyUsd: 250,
|
budgetMonthlyUsd: 250,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// index 9
|
|
||||||
name: "Andre Williams",
|
name: "Andre Williams",
|
||||||
role: "Backend Engineer",
|
role: "Backend Engineer",
|
||||||
title: "Senior Backend Engineer",
|
title: "Senior Backend Engineer",
|
||||||
@@ -1129,7 +1033,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
budgetMonthlyUsd: 230,
|
budgetMonthlyUsd: 230,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// index 10
|
|
||||||
name: "Yuna Kim",
|
name: "Yuna Kim",
|
||||||
role: "Frontend Engineer",
|
role: "Frontend Engineer",
|
||||||
title: "Senior Frontend Engineer",
|
title: "Senior Frontend Engineer",
|
||||||
@@ -1139,10 +1042,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
connectorPreference: "codex_local",
|
connectorPreference: "codex_local",
|
||||||
budgetMonthlyUsd: 220,
|
budgetMonthlyUsd: 220,
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Product & Design (11-13) ──
|
|
||||||
{
|
{
|
||||||
// index 11
|
|
||||||
name: "Tara Blackwell",
|
name: "Tara Blackwell",
|
||||||
role: "VP Product",
|
role: "VP Product",
|
||||||
title: "Vice President of Product",
|
title: "Vice President of Product",
|
||||||
@@ -1153,7 +1053,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
budgetMonthlyUsd: 200,
|
budgetMonthlyUsd: 200,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// index 12
|
|
||||||
name: "Omar Khalil",
|
name: "Omar Khalil",
|
||||||
role: "Product Manager — Developer Platform",
|
role: "Product Manager — Developer Platform",
|
||||||
title: "Product Manager — Developer Platform",
|
title: "Product Manager — Developer Platform",
|
||||||
@@ -1164,7 +1063,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
budgetMonthlyUsd: 160,
|
budgetMonthlyUsd: 160,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// index 13
|
|
||||||
name: "Vera Lindqvist",
|
name: "Vera Lindqvist",
|
||||||
role: "Product Designer",
|
role: "Product Designer",
|
||||||
title: "Senior Product Designer",
|
title: "Senior Product Designer",
|
||||||
@@ -1174,10 +1072,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
connectorPreference: "claude_local",
|
connectorPreference: "claude_local",
|
||||||
budgetMonthlyUsd: 160,
|
budgetMonthlyUsd: 160,
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Marketing & DevRel (14-15) ──
|
|
||||||
{
|
{
|
||||||
// index 14
|
|
||||||
name: "Marcus Rhodes",
|
name: "Marcus Rhodes",
|
||||||
role: "Head of Marketing",
|
role: "Head of Marketing",
|
||||||
title: "Head of Marketing & Communications",
|
title: "Head of Marketing & Communications",
|
||||||
@@ -1188,7 +1083,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
budgetMonthlyUsd: 170,
|
budgetMonthlyUsd: 170,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// index 15
|
|
||||||
name: "Priya Khatri",
|
name: "Priya Khatri",
|
||||||
role: "Developer Advocate",
|
role: "Developer Advocate",
|
||||||
title: "Senior Developer Advocate",
|
title: "Senior Developer Advocate",
|
||||||
@@ -1198,10 +1092,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
connectorPreference: "codex_local",
|
connectorPreference: "codex_local",
|
||||||
budgetMonthlyUsd: 170,
|
budgetMonthlyUsd: 170,
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── People & Finance & Legal (16-17) ──
|
|
||||||
{
|
{
|
||||||
// index 16
|
|
||||||
name: "Danielle Moreau",
|
name: "Danielle Moreau",
|
||||||
role: "Head of People",
|
role: "Head of People",
|
||||||
title: "Head of People",
|
title: "Head of People",
|
||||||
@@ -1212,7 +1103,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
budgetMonthlyUsd: 150,
|
budgetMonthlyUsd: 150,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// index 17
|
|
||||||
name: "Gabriel Souza",
|
name: "Gabriel Souza",
|
||||||
role: "Head of Legal & Policy",
|
role: "Head of Legal & Policy",
|
||||||
title: "Head of Legal & Policy",
|
title: "Head of Legal & Policy",
|
||||||
@@ -1251,9 +1141,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Digital Agency ────────────────────────────────────────────────────
|
|
||||||
// A full-service digital agency that designs, builds, and markets
|
|
||||||
// digital products for clients.
|
|
||||||
{
|
{
|
||||||
id: "digital-agency",
|
id: "digital-agency",
|
||||||
name: "Full-Service Digital Agency",
|
name: "Full-Service Digital Agency",
|
||||||
@@ -1268,9 +1155,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
{ id: "operations", name: "Operations", description: "Resource planning, process management, and quality assurance." },
|
{ id: "operations", name: "Operations", description: "Resource planning, process management, and quality assurance." },
|
||||||
],
|
],
|
||||||
agents: [
|
agents: [
|
||||||
// ── Leadership (0-1) ──
|
|
||||||
{
|
{
|
||||||
// index 0
|
|
||||||
name: "Isabelle Fontaine",
|
name: "Isabelle Fontaine",
|
||||||
role: "Managing Director",
|
role: "Managing Director",
|
||||||
title: "Managing Director",
|
title: "Managing Director",
|
||||||
@@ -1281,7 +1166,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
budgetMonthlyUsd: 200,
|
budgetMonthlyUsd: 200,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// index 1
|
|
||||||
name: "Victor Nascimento",
|
name: "Victor Nascimento",
|
||||||
role: "Account Director",
|
role: "Account Director",
|
||||||
title: "Account Director",
|
title: "Account Director",
|
||||||
@@ -1291,10 +1175,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
connectorPreference: "claude_local",
|
connectorPreference: "claude_local",
|
||||||
budgetMonthlyUsd: 170,
|
budgetMonthlyUsd: 170,
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Creative & Design (2-4) ──
|
|
||||||
{
|
{
|
||||||
// index 2
|
|
||||||
name: "Saoirse O'Brien",
|
name: "Saoirse O'Brien",
|
||||||
role: "Creative Director",
|
role: "Creative Director",
|
||||||
title: "Creative Director",
|
title: "Creative Director",
|
||||||
@@ -1305,7 +1186,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
budgetMonthlyUsd: 200,
|
budgetMonthlyUsd: 200,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// index 3
|
|
||||||
name: "Mateo Cruz",
|
name: "Mateo Cruz",
|
||||||
role: "UX/UI Designer",
|
role: "UX/UI Designer",
|
||||||
title: "Senior UX/UI Designer",
|
title: "Senior UX/UI Designer",
|
||||||
@@ -1316,7 +1196,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
budgetMonthlyUsd: 170,
|
budgetMonthlyUsd: 170,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// index 4
|
|
||||||
name: "Yuki Aoyama",
|
name: "Yuki Aoyama",
|
||||||
role: "Motion & Visual Designer",
|
role: "Motion & Visual Designer",
|
||||||
title: "Motion & Visual Designer",
|
title: "Motion & Visual Designer",
|
||||||
@@ -1326,10 +1205,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
connectorPreference: "claude_local",
|
connectorPreference: "claude_local",
|
||||||
budgetMonthlyUsd: 140,
|
budgetMonthlyUsd: 140,
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Development (5-7) ──
|
|
||||||
{
|
{
|
||||||
// index 5
|
|
||||||
name: "Nadia Kowalczyk",
|
name: "Nadia Kowalczyk",
|
||||||
role: "Technical Director",
|
role: "Technical Director",
|
||||||
title: "Technical Director",
|
title: "Technical Director",
|
||||||
@@ -1340,7 +1216,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
budgetMonthlyUsd: 220,
|
budgetMonthlyUsd: 220,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// index 6
|
|
||||||
name: "Kwame Asante",
|
name: "Kwame Asante",
|
||||||
role: "Full-Stack Developer",
|
role: "Full-Stack Developer",
|
||||||
title: "Senior Full-Stack Developer",
|
title: "Senior Full-Stack Developer",
|
||||||
@@ -1351,7 +1226,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
budgetMonthlyUsd: 230,
|
budgetMonthlyUsd: 230,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// index 7
|
|
||||||
name: "Elina Koskinen",
|
name: "Elina Koskinen",
|
||||||
role: "Frontend Developer",
|
role: "Frontend Developer",
|
||||||
title: "Frontend Developer",
|
title: "Frontend Developer",
|
||||||
@@ -1361,10 +1235,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
connectorPreference: "codex_local",
|
connectorPreference: "codex_local",
|
||||||
budgetMonthlyUsd: 180,
|
budgetMonthlyUsd: 180,
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Digital Marketing (8-10) ──
|
|
||||||
{
|
{
|
||||||
// index 8
|
|
||||||
name: "Tariq Benali",
|
name: "Tariq Benali",
|
||||||
role: "Head of Digital",
|
role: "Head of Digital",
|
||||||
title: "Head of Digital Marketing",
|
title: "Head of Digital Marketing",
|
||||||
@@ -1375,7 +1246,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
budgetMonthlyUsd: 180,
|
budgetMonthlyUsd: 180,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// index 9
|
|
||||||
name: "Jessica Torres",
|
name: "Jessica Torres",
|
||||||
role: "SEO & Content Specialist",
|
role: "SEO & Content Specialist",
|
||||||
title: "SEO & Content Specialist",
|
title: "SEO & Content Specialist",
|
||||||
@@ -1386,7 +1256,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
budgetMonthlyUsd: 150,
|
budgetMonthlyUsd: 150,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// index 10
|
|
||||||
name: "Ryan Gallagher",
|
name: "Ryan Gallagher",
|
||||||
role: "Paid Media Specialist",
|
role: "Paid Media Specialist",
|
||||||
title: "Paid Media Specialist",
|
title: "Paid Media Specialist",
|
||||||
@@ -1396,10 +1265,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
connectorPreference: "claude_local",
|
connectorPreference: "claude_local",
|
||||||
budgetMonthlyUsd: 150,
|
budgetMonthlyUsd: 150,
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Strategy & Content (11-12) ──
|
|
||||||
{
|
{
|
||||||
// index 11
|
|
||||||
name: "Ananya Chakraborty",
|
name: "Ananya Chakraborty",
|
||||||
role: "Strategy Lead",
|
role: "Strategy Lead",
|
||||||
title: "Digital Strategy Lead",
|
title: "Digital Strategy Lead",
|
||||||
@@ -1410,7 +1276,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
budgetMonthlyUsd: 180,
|
budgetMonthlyUsd: 180,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// index 12
|
|
||||||
name: "Liam O'Sullivan",
|
name: "Liam O'Sullivan",
|
||||||
role: "Copywriter",
|
role: "Copywriter",
|
||||||
title: "Senior Copywriter",
|
title: "Senior Copywriter",
|
||||||
@@ -1420,10 +1285,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
connectorPreference: "claude_local",
|
connectorPreference: "claude_local",
|
||||||
budgetMonthlyUsd: 160,
|
budgetMonthlyUsd: 160,
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Operations (13) ──
|
|
||||||
{
|
{
|
||||||
// index 13
|
|
||||||
name: "Sandra Muller",
|
name: "Sandra Muller",
|
||||||
role: "Project Manager",
|
role: "Project Manager",
|
||||||
title: "Senior Project Manager",
|
title: "Senior Project Manager",
|
||||||
@@ -1454,9 +1316,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── B2B SaaS Company ──────────────────────────────────────────────────
|
|
||||||
// A product-led B2B SaaS company with the complete set of functions
|
|
||||||
// needed to build, sell, and support a subscription software product.
|
|
||||||
{
|
{
|
||||||
id: "saas-company",
|
id: "saas-company",
|
||||||
name: "B2B SaaS Company",
|
name: "B2B SaaS Company",
|
||||||
@@ -1474,9 +1333,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
{ id: "hr", name: "People", description: "Recruiting, onboarding, culture, and people operations." },
|
{ id: "hr", name: "People", description: "Recruiting, onboarding, culture, and people operations." },
|
||||||
],
|
],
|
||||||
agents: [
|
agents: [
|
||||||
// ── Executive (0-1) ──
|
|
||||||
{
|
{
|
||||||
// index 0
|
|
||||||
name: "Nathan Cross",
|
name: "Nathan Cross",
|
||||||
role: "CEO",
|
role: "CEO",
|
||||||
title: "Chief Executive Officer",
|
title: "Chief Executive Officer",
|
||||||
@@ -1487,7 +1344,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
budgetMonthlyUsd: 230,
|
budgetMonthlyUsd: 230,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// index 1
|
|
||||||
name: "Samira Rostami",
|
name: "Samira Rostami",
|
||||||
role: "CTO",
|
role: "CTO",
|
||||||
title: "Chief Technology Officer",
|
title: "Chief Technology Officer",
|
||||||
@@ -1497,10 +1353,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
connectorPreference: "claude_local",
|
connectorPreference: "claude_local",
|
||||||
budgetMonthlyUsd: 240,
|
budgetMonthlyUsd: 240,
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Engineering (2-6) ──
|
|
||||||
{
|
{
|
||||||
// index 2
|
|
||||||
name: "Derek Johansson",
|
name: "Derek Johansson",
|
||||||
role: "VP Engineering",
|
role: "VP Engineering",
|
||||||
title: "Vice President of Engineering",
|
title: "Vice President of Engineering",
|
||||||
@@ -1511,7 +1364,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
budgetMonthlyUsd: 210,
|
budgetMonthlyUsd: 210,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// index 3
|
|
||||||
name: "Priya Venkatesh",
|
name: "Priya Venkatesh",
|
||||||
role: "Senior Backend Engineer",
|
role: "Senior Backend Engineer",
|
||||||
title: "Senior Backend Engineer",
|
title: "Senior Backend Engineer",
|
||||||
@@ -1522,7 +1374,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
budgetMonthlyUsd: 250,
|
budgetMonthlyUsd: 250,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// index 4
|
|
||||||
name: "Luis Mendoza",
|
name: "Luis Mendoza",
|
||||||
role: "Senior Frontend Engineer",
|
role: "Senior Frontend Engineer",
|
||||||
title: "Senior Frontend Engineer",
|
title: "Senior Frontend Engineer",
|
||||||
@@ -1533,7 +1384,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
budgetMonthlyUsd: 240,
|
budgetMonthlyUsd: 240,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// index 5
|
|
||||||
name: "Anika Bergman",
|
name: "Anika Bergman",
|
||||||
role: "Platform Engineer",
|
role: "Platform Engineer",
|
||||||
title: "Senior Platform Engineer",
|
title: "Senior Platform Engineer",
|
||||||
@@ -1544,7 +1394,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
budgetMonthlyUsd: 230,
|
budgetMonthlyUsd: 230,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// index 6
|
|
||||||
name: "Tomoko Sato",
|
name: "Tomoko Sato",
|
||||||
role: "QA Engineer",
|
role: "QA Engineer",
|
||||||
title: "Quality Assurance Engineer",
|
title: "Quality Assurance Engineer",
|
||||||
@@ -1554,10 +1403,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
connectorPreference: "codex_local",
|
connectorPreference: "codex_local",
|
||||||
budgetMonthlyUsd: 180,
|
budgetMonthlyUsd: 180,
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Product & Design (7-9) ──
|
|
||||||
{
|
{
|
||||||
// index 7
|
|
||||||
name: "Rachel Abrams",
|
name: "Rachel Abrams",
|
||||||
role: "VP Product",
|
role: "VP Product",
|
||||||
title: "Vice President of Product",
|
title: "Vice President of Product",
|
||||||
@@ -1568,7 +1414,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
budgetMonthlyUsd: 200,
|
budgetMonthlyUsd: 200,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// index 8
|
|
||||||
name: "Jake Morrison",
|
name: "Jake Morrison",
|
||||||
role: "Product Manager",
|
role: "Product Manager",
|
||||||
title: "Product Manager",
|
title: "Product Manager",
|
||||||
@@ -1579,7 +1424,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
budgetMonthlyUsd: 170,
|
budgetMonthlyUsd: 170,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// index 9
|
|
||||||
name: "Chloe Laurent",
|
name: "Chloe Laurent",
|
||||||
role: "Product Designer",
|
role: "Product Designer",
|
||||||
title: "Senior Product Designer",
|
title: "Senior Product Designer",
|
||||||
@@ -1589,10 +1433,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
connectorPreference: "claude_local",
|
connectorPreference: "claude_local",
|
||||||
budgetMonthlyUsd: 170,
|
budgetMonthlyUsd: 170,
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Marketing (10-12) ──
|
|
||||||
{
|
{
|
||||||
// index 10
|
|
||||||
name: "Ava Richardson",
|
name: "Ava Richardson",
|
||||||
role: "VP Marketing",
|
role: "VP Marketing",
|
||||||
title: "Vice President of Marketing",
|
title: "Vice President of Marketing",
|
||||||
@@ -1603,7 +1444,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
budgetMonthlyUsd: 190,
|
budgetMonthlyUsd: 190,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// index 11
|
|
||||||
name: "Benjamin Hart",
|
name: "Benjamin Hart",
|
||||||
role: "Content Marketing Manager",
|
role: "Content Marketing Manager",
|
||||||
title: "Content Marketing Manager",
|
title: "Content Marketing Manager",
|
||||||
@@ -1614,7 +1454,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
budgetMonthlyUsd: 160,
|
budgetMonthlyUsd: 160,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// index 12
|
|
||||||
name: "Daria Volkov",
|
name: "Daria Volkov",
|
||||||
role: "Growth & Demand Gen Manager",
|
role: "Growth & Demand Gen Manager",
|
||||||
title: "Growth & Demand Generation Manager",
|
title: "Growth & Demand Generation Manager",
|
||||||
@@ -1624,10 +1463,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
connectorPreference: "claude_local",
|
connectorPreference: "claude_local",
|
||||||
budgetMonthlyUsd: 160,
|
budgetMonthlyUsd: 160,
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Sales (13-15) ──
|
|
||||||
{
|
{
|
||||||
// index 13
|
|
||||||
name: "Carter Ellison",
|
name: "Carter Ellison",
|
||||||
role: "VP Sales",
|
role: "VP Sales",
|
||||||
title: "Vice President of Sales",
|
title: "Vice President of Sales",
|
||||||
@@ -1638,7 +1474,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
budgetMonthlyUsd: 200,
|
budgetMonthlyUsd: 200,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// index 14
|
|
||||||
name: "Nina Johansson",
|
name: "Nina Johansson",
|
||||||
role: "Enterprise Account Executive",
|
role: "Enterprise Account Executive",
|
||||||
title: "Enterprise Account Executive",
|
title: "Enterprise Account Executive",
|
||||||
@@ -1649,7 +1484,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
budgetMonthlyUsd: 180,
|
budgetMonthlyUsd: 180,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// index 15
|
|
||||||
name: "Travis Chen",
|
name: "Travis Chen",
|
||||||
role: "SDR Lead",
|
role: "SDR Lead",
|
||||||
title: "Sales Development Representative Lead",
|
title: "Sales Development Representative Lead",
|
||||||
@@ -1659,10 +1493,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
connectorPreference: "claude_local",
|
connectorPreference: "claude_local",
|
||||||
budgetMonthlyUsd: 130,
|
budgetMonthlyUsd: 130,
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Customer Success & Support (16-18) ──
|
|
||||||
{
|
{
|
||||||
// index 16
|
|
||||||
name: "Miriam Okafor",
|
name: "Miriam Okafor",
|
||||||
role: "VP Customer Success",
|
role: "VP Customer Success",
|
||||||
title: "Vice President of Customer Success",
|
title: "Vice President of Customer Success",
|
||||||
@@ -1673,7 +1504,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
budgetMonthlyUsd: 180,
|
budgetMonthlyUsd: 180,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// index 17
|
|
||||||
name: "Alex Petridis",
|
name: "Alex Petridis",
|
||||||
role: "Customer Success Manager",
|
role: "Customer Success Manager",
|
||||||
title: "Customer Success Manager",
|
title: "Customer Success Manager",
|
||||||
@@ -1684,7 +1514,6 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
budgetMonthlyUsd: 140,
|
budgetMonthlyUsd: 140,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// index 18
|
|
||||||
name: "Rina Tanaka",
|
name: "Rina Tanaka",
|
||||||
role: "Support Engineer",
|
role: "Support Engineer",
|
||||||
title: "Technical Support Engineer",
|
title: "Technical Support Engineer",
|
||||||
@@ -1694,10 +1523,7 @@ export const COMPANY_TEMPLATES: CompanyTemplate[] = [
|
|||||||
connectorPreference: "codex_local",
|
connectorPreference: "codex_local",
|
||||||
budgetMonthlyUsd: 140,
|
budgetMonthlyUsd: 140,
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Finance & People (19) ──
|
|
||||||
{
|
{
|
||||||
// index 19
|
|
||||||
name: "Henrik Larsen",
|
name: "Henrik Larsen",
|
||||||
role: "Head of Finance",
|
role: "Head of Finance",
|
||||||
title: "Head of Finance",
|
title: "Head of Finance",
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ const api: DesktopApi = {
|
|||||||
triggerHeartbeat: (input) => ipcRenderer.invoke(IPC_CHANNELS.triggerHeartbeat, input),
|
triggerHeartbeat: (input) => ipcRenderer.invoke(IPC_CHANNELS.triggerHeartbeat, input),
|
||||||
getApiPort: () => ipcRenderer.invoke(IPC_CHANNELS.getApiPort),
|
getApiPort: () => ipcRenderer.invoke(IPC_CHANNELS.getApiPort),
|
||||||
getInbox: (companyId) => ipcRenderer.invoke(IPC_CHANNELS.getInbox, companyId),
|
getInbox: (companyId) => ipcRenderer.invoke(IPC_CHANNELS.getInbox, companyId),
|
||||||
dismissInboxItem: (input) => ipcRenderer.invoke(IPC_CHANNELS.dismissInboxItem, input),
|
|
||||||
getCompanyMetrics: (input) => ipcRenderer.invoke(IPC_CHANNELS.getCompanyMetrics, input),
|
getCompanyMetrics: (input) => ipcRenderer.invoke(IPC_CHANNELS.getCompanyMetrics, input),
|
||||||
getAgentMetrics: (input) => ipcRenderer.invoke(IPC_CHANNELS.getAgentMetrics, input),
|
getAgentMetrics: (input) => ipcRenderer.invoke(IPC_CHANNELS.getAgentMetrics, input),
|
||||||
getStandupReport: (companyId) => ipcRenderer.invoke(IPC_CHANNELS.getStandupReport, companyId),
|
getStandupReport: (companyId) => ipcRenderer.invoke(IPC_CHANNELS.getStandupReport, companyId),
|
||||||
@@ -55,32 +54,24 @@ const api: DesktopApi = {
|
|||||||
listBrowserActions: (input) => ipcRenderer.invoke(IPC_CHANNELS.listBrowserActions, input),
|
listBrowserActions: (input) => ipcRenderer.invoke(IPC_CHANNELS.listBrowserActions, input),
|
||||||
cancelBrowserAction: (input) => ipcRenderer.invoke(IPC_CHANNELS.cancelBrowserAction, input),
|
cancelBrowserAction: (input) => ipcRenderer.invoke(IPC_CHANNELS.cancelBrowserAction, input),
|
||||||
triggerBrowserLogin: (id) => ipcRenderer.invoke(IPC_CHANNELS.triggerBrowserLogin, id),
|
triggerBrowserLogin: (id) => ipcRenderer.invoke(IPC_CHANNELS.triggerBrowserLogin, id),
|
||||||
// Meetings
|
|
||||||
saveMeeting: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveMeeting, input),
|
saveMeeting: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveMeeting, input),
|
||||||
deleteMeeting: (input) => ipcRenderer.invoke(IPC_CHANNELS.deleteMeeting, input),
|
deleteMeeting: (input) => ipcRenderer.invoke(IPC_CHANNELS.deleteMeeting, input),
|
||||||
// Documents
|
|
||||||
saveDocument: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveDocument, input),
|
saveDocument: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveDocument, input),
|
||||||
deleteDocument: (input) => ipcRenderer.invoke(IPC_CHANNELS.deleteDocument, input),
|
deleteDocument: (input) => ipcRenderer.invoke(IPC_CHANNELS.deleteDocument, input),
|
||||||
// Knowledge Base
|
|
||||||
saveKnowledgeEntry: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveKnowledgeEntry, input),
|
saveKnowledgeEntry: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveKnowledgeEntry, input),
|
||||||
deleteKnowledgeEntry: (input) => ipcRenderer.invoke(IPC_CHANNELS.deleteKnowledgeEntry, input),
|
deleteKnowledgeEntry: (input) => ipcRenderer.invoke(IPC_CHANNELS.deleteKnowledgeEntry, input),
|
||||||
// Sprints
|
|
||||||
saveSprint: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveSprint, input),
|
saveSprint: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveSprint, input),
|
||||||
deleteSprint: (input) => ipcRenderer.invoke(IPC_CHANNELS.deleteSprint, input),
|
deleteSprint: (input) => ipcRenderer.invoke(IPC_CHANNELS.deleteSprint, input),
|
||||||
// Automation
|
|
||||||
saveAutomationRule: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveAutomationRule, input),
|
saveAutomationRule: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveAutomationRule, input),
|
||||||
deleteAutomationRule: (input) => ipcRenderer.invoke(IPC_CHANNELS.deleteAutomationRule, input),
|
deleteAutomationRule: (input) => ipcRenderer.invoke(IPC_CHANNELS.deleteAutomationRule, input),
|
||||||
toggleAutomationRule: (input) => ipcRenderer.invoke(IPC_CHANNELS.toggleAutomationRule, input),
|
toggleAutomationRule: (input) => ipcRenderer.invoke(IPC_CHANNELS.toggleAutomationRule, input),
|
||||||
getAutomationLog: (companyId) => ipcRenderer.invoke(IPC_CHANNELS.getAutomationLog, companyId),
|
getAutomationLog: (companyId) => ipcRenderer.invoke(IPC_CHANNELS.getAutomationLog, companyId),
|
||||||
// Agent Messages
|
|
||||||
sendAgentMessage: (input) => ipcRenderer.invoke(IPC_CHANNELS.sendAgentMessage, input),
|
sendAgentMessage: (input) => ipcRenderer.invoke(IPC_CHANNELS.sendAgentMessage, input),
|
||||||
listAgentMessages: (input) => ipcRenderer.invoke(IPC_CHANNELS.listAgentMessages, input),
|
listAgentMessages: (input) => ipcRenderer.invoke(IPC_CHANNELS.listAgentMessages, input),
|
||||||
markMessageRead: (input) => ipcRenderer.invoke(IPC_CHANNELS.markMessageRead, input),
|
markMessageRead: (input) => ipcRenderer.invoke(IPC_CHANNELS.markMessageRead, input),
|
||||||
// Workflows
|
|
||||||
saveWorkflow: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveWorkflow, input),
|
saveWorkflow: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveWorkflow, input),
|
||||||
deleteWorkflow: (input) => ipcRenderer.invoke(IPC_CHANNELS.deleteWorkflow, input),
|
deleteWorkflow: (input) => ipcRenderer.invoke(IPC_CHANNELS.deleteWorkflow, input),
|
||||||
runWorkflow: (input) => ipcRenderer.invoke(IPC_CHANNELS.runWorkflow, input),
|
runWorkflow: (input) => ipcRenderer.invoke(IPC_CHANNELS.runWorkflow, input),
|
||||||
// Auto-assign
|
|
||||||
autoAssignTask: (input) => ipcRenderer.invoke(IPC_CHANNELS.autoAssignTask, input),
|
autoAssignTask: (input) => ipcRenderer.invoke(IPC_CHANNELS.autoAssignTask, input),
|
||||||
subscribe: (callback: (event: DesktopEvent) => void) => {
|
subscribe: (callback: (event: DesktopEvent) => void) => {
|
||||||
const listener = (_event: Electron.IpcRendererEvent, payload: DesktopEvent) => callback(payload);
|
const listener = (_event: Electron.IpcRendererEvent, payload: DesktopEvent) => callback(payload);
|
||||||
|
|||||||
+124
-211
@@ -3,7 +3,6 @@ import { AnimatePresence, motion } from "framer-motion";
|
|||||||
import {
|
import {
|
||||||
ArrowClockwise,
|
ArrowClockwise,
|
||||||
Bell,
|
Bell,
|
||||||
BookOpen,
|
|
||||||
Brain,
|
Brain,
|
||||||
CalendarBlank,
|
CalendarBlank,
|
||||||
CaretUpDown,
|
CaretUpDown,
|
||||||
@@ -39,8 +38,6 @@ import {
|
|||||||
X,
|
X,
|
||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react";
|
||||||
import type {
|
import type {
|
||||||
AgentMessageRecord,
|
|
||||||
AutomationRuleRecord,
|
|
||||||
CompanyRecord,
|
CompanyRecord,
|
||||||
CompanyMetrics,
|
CompanyMetrics,
|
||||||
DesktopEvent,
|
DesktopEvent,
|
||||||
@@ -51,7 +48,6 @@ import type {
|
|||||||
SocialAccountRecord,
|
SocialAccountRecord,
|
||||||
BrowserActionRecord,
|
BrowserActionRecord,
|
||||||
StandupReport,
|
StandupReport,
|
||||||
WorkflowPipelineRecord,
|
|
||||||
} from "@shared/types";
|
} from "@shared/types";
|
||||||
import type { SidebarBadges } from "@shared/contracts";
|
import type { SidebarBadges } from "@shared/contracts";
|
||||||
import { CompanyOnboarding } from "./components/CompanyOnboarding";
|
import { CompanyOnboarding } from "./components/CompanyOnboarding";
|
||||||
@@ -72,6 +68,7 @@ import {
|
|||||||
TaskInspector,
|
TaskInspector,
|
||||||
TaskQueue,
|
TaskQueue,
|
||||||
WorkspaceCreator,
|
WorkspaceCreator,
|
||||||
|
WorkspaceInspector,
|
||||||
WorkspaceList,
|
WorkspaceList,
|
||||||
} from "./components/execution";
|
} from "./components/execution";
|
||||||
import {
|
import {
|
||||||
@@ -90,6 +87,7 @@ import { SocialAccountsPanel } from "./components/social";
|
|||||||
import { AutomationPanel } from "./components/automation";
|
import { AutomationPanel } from "./components/automation";
|
||||||
import { CommunicationHub } from "./components/communication";
|
import { CommunicationHub } from "./components/communication";
|
||||||
import { HiringPipeline } from "./components/hiring";
|
import { HiringPipeline } from "./components/hiring";
|
||||||
|
import { DocumentCenter, KnowledgeBaseManager, MeetingsManager, SprintManager } from "./components/company-operations";
|
||||||
import { ActionButton, ErrorBoundary, ListFrame, ListRow, Modal, Panel, SectionButton, Select, StatusPill, useFocusTrap, useReducedMotion } from "./components/ui";
|
import { ActionButton, ErrorBoundary, ListFrame, ListRow, Modal, Panel, SectionButton, Select, StatusPill, useFocusTrap, useReducedMotion } from "./components/ui";
|
||||||
import { unwrap } from "./lib/desktop";
|
import { unwrap } from "./lib/desktop";
|
||||||
import { formatMoney, formatTime } from "./lib/formatters";
|
import { formatMoney, formatTime } from "./lib/formatters";
|
||||||
@@ -175,12 +173,15 @@ export function App() {
|
|||||||
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
|
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
|
||||||
const [selectedGoalId, setSelectedGoalId] = useState<string | null>(null);
|
const [selectedGoalId, setSelectedGoalId] = useState<string | null>(null);
|
||||||
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null);
|
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null);
|
||||||
|
const [selectedWorkspaceId, setSelectedWorkspaceId] = useState<string | null>(null);
|
||||||
|
const [selectedCommunicationEntityId, setSelectedCommunicationEntityId] = useState<string | null>(null);
|
||||||
const [taskViewMode, setTaskViewMode] = useState<"list" | "kanban">("list");
|
const [taskViewMode, setTaskViewMode] = useState<"list" | "kanban">("list");
|
||||||
const logBufferRef = useRef<Record<string, string>>({});
|
const logBufferRef = useRef<Record<string, string>>({});
|
||||||
const [, setLogRevision] = useState(0);
|
const [, setLogRevision] = useState(0);
|
||||||
const [badges, setBadges] = useState<SidebarBadges | null>(null);
|
const [badges, setBadges] = useState<SidebarBadges | null>(null);
|
||||||
const [commandPaletteSearch, setCommandPaletteSearch] = useState("");
|
const [commandPaletteSearch, setCommandPaletteSearch] = useState("");
|
||||||
const [showOnboarding, setShowOnboarding] = useState(false);
|
const [showOnboarding, setShowOnboarding] = useState(false);
|
||||||
|
const [initialOnboardingLocked, setInitialOnboardingLocked] = useState(false);
|
||||||
const [inboxItems, setInboxItems] = useState<InboxItem[]>([]);
|
const [inboxItems, setInboxItems] = useState<InboxItem[]>([]);
|
||||||
const [companyMetrics, setCompanyMetrics] = useState<CompanyMetrics | null>(null);
|
const [companyMetrics, setCompanyMetrics] = useState<CompanyMetrics | null>(null);
|
||||||
const [standupReport, setStandupReport] = useState<StandupReport | null>(null);
|
const [standupReport, setStandupReport] = useState<StandupReport | null>(null);
|
||||||
@@ -191,23 +192,46 @@ export function App() {
|
|||||||
try {
|
try {
|
||||||
const next = unwrap<ProfileSnapshot>(await window.agentCompany.loadSnapshot());
|
const next = unwrap<ProfileSnapshot>(await window.agentCompany.loadSnapshot());
|
||||||
setSnapshot(next);
|
setSnapshot(next);
|
||||||
// Tasks are opened explicitly by clicking; don't auto-select.
|
if (next.companies.length === 0) {
|
||||||
|
setInitialOnboardingLocked(true);
|
||||||
|
}
|
||||||
if (!selectedConnectorId && next.connectors[0]) setSelectedConnectorId(next.connectors[0].id);
|
if (!selectedConnectorId && next.connectors[0]) setSelectedConnectorId(next.connectors[0].id);
|
||||||
if (!selectedApprovalId && next.approvals[0]) setSelectedApprovalId(next.approvals[0].id);
|
|
||||||
if (!selectedRunId && next.runs[0]) setSelectedRunId(next.runs[0].id);
|
if (!selectedRunId && next.runs[0]) setSelectedRunId(next.runs[0].id);
|
||||||
if (next.currentCompanyId) {
|
if (next.currentCompanyId) {
|
||||||
const badgeResult = await window.agentCompany.getSidebarBadges(next.currentCompanyId);
|
const badgeResult = await window.agentCompany.getSidebarBadges(next.currentCompanyId);
|
||||||
if (badgeResult.ok) setBadges(badgeResult.data);
|
if (badgeResult.ok) setBadges(badgeResult.data);
|
||||||
setSocialAccounts(next.socialAccounts?.filter(a => a.companyId === next.currentCompanyId) ?? []);
|
setSocialAccounts(next.socialAccounts?.filter(a => a.companyId === next.currentCompanyId) ?? []);
|
||||||
|
const browserActionResult = await window.agentCompany.listBrowserActions({ companyId: next.currentCompanyId, limit: 50 });
|
||||||
|
if (browserActionResult.ok) {
|
||||||
|
setBrowserActions(browserActionResult.data);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (nextError) {
|
} catch (nextError) {
|
||||||
setError(nextError instanceof Error ? nextError.message : String(nextError));
|
setError(nextError instanceof Error ? nextError.message : String(nextError));
|
||||||
}
|
}
|
||||||
}, [selectedApprovalId, selectedConnectorId, selectedRunId]);
|
}, [selectedConnectorId, selectedRunId]);
|
||||||
|
|
||||||
|
// Stable ref so the subscribe callback always calls the latest refresh
|
||||||
|
// without tearing down the subscription when refresh identity changes.
|
||||||
|
const refreshRef = useRef(refresh);
|
||||||
|
useEffect(() => {
|
||||||
|
refreshRef.current = refresh;
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
// Debounce timer ref for coalescing rapid real-time events
|
||||||
|
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const debouncedRefresh = useCallback(() => {
|
||||||
|
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
|
||||||
|
debounceTimerRef.current = setTimeout(() => {
|
||||||
|
debounceTimerRef.current = null;
|
||||||
|
void refreshRef.current();
|
||||||
|
}, 500);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void refresh();
|
void refreshRef.current();
|
||||||
const unsubscribe = window.agentCompany.subscribe((event: DesktopEvent) => {
|
const unsubscribe = window.agentCompany.subscribe((event: DesktopEvent) => {
|
||||||
if (event.type === "run-log") {
|
if (event.type === "run-log") {
|
||||||
logBufferRef.current = {
|
logBufferRef.current = {
|
||||||
@@ -220,13 +244,18 @@ export function App() {
|
|||||||
if (event.type === "shortcut") {
|
if (event.type === "shortcut") {
|
||||||
if (event.action === "toggle-command-palette") setCommandPaletteOpen((current) => !current);
|
if (event.action === "toggle-command-palette") setCommandPaletteOpen((current) => !current);
|
||||||
if (event.action === "toggle-console") setConsoleOpen((current) => !current);
|
if (event.action === "toggle-console") setConsoleOpen((current) => !current);
|
||||||
if (event.action === "refresh") void refresh();
|
if (event.action === "refresh") void refreshRef.current();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void refresh();
|
// All data-mutating events trigger a debounced snapshot reload:
|
||||||
|
// domain-changed, run-status, new-message, automation-fired, heartbeat-status
|
||||||
|
debouncedRefresh();
|
||||||
});
|
});
|
||||||
return unsubscribe;
|
return () => {
|
||||||
}, [refresh]);
|
unsubscribe();
|
||||||
|
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
|
||||||
|
};
|
||||||
|
}, [debouncedRefresh]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedRunId) return;
|
if (!selectedRunId) return;
|
||||||
@@ -272,13 +301,9 @@ export function App() {
|
|||||||
|
|
||||||
if (section === "social") {
|
if (section === "social") {
|
||||||
setSocialAccounts(snapshot.socialAccounts?.filter(a => a.companyId === companyId) ?? []);
|
setSocialAccounts(snapshot.socialAccounts?.filter(a => a.companyId === companyId) ?? []);
|
||||||
void window.agentCompany.listBrowserActions({ companyId, limit: 50 }).then(result => {
|
|
||||||
if (result.ok) setBrowserActions(result.data);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}, [section, snapshot?.currentCompanyId]);
|
}, [section, snapshot]);
|
||||||
|
|
||||||
// Global Escape key handler for command palette
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!commandPaletteOpen) return;
|
if (!commandPaletteOpen) return;
|
||||||
function onKey(event: KeyboardEvent) {
|
function onKey(event: KeyboardEvent) {
|
||||||
@@ -302,11 +327,10 @@ export function App() {
|
|||||||
const selectedTask = selectedTaskId ? (tasks.find((task) => task.id === selectedTaskId) ?? null) : null;
|
const selectedTask = selectedTaskId ? (tasks.find((task) => task.id === selectedTaskId) ?? null) : null;
|
||||||
const selectedConnector = snapshot?.connectors.find((connector) => connector.id === selectedConnectorId) ?? snapshot?.connectors[0] ?? null;
|
const selectedConnector = snapshot?.connectors.find((connector) => connector.id === selectedConnectorId) ?? snapshot?.connectors[0] ?? null;
|
||||||
const approvals = useMemo(() => snapshot?.approvals.filter((approval) => !currentCompany || approval.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
|
const approvals = useMemo(() => snapshot?.approvals.filter((approval) => !currentCompany || approval.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
|
||||||
const selectedApproval = approvals.find((approval) => approval.id === selectedApprovalId) ?? approvals[0] ?? null;
|
const selectedApproval = selectedApprovalId ? (approvals.find((approval) => approval.id === selectedApprovalId) ?? null) : null;
|
||||||
const runs = useMemo(() => snapshot?.runs.filter((run) => !currentCompany || run.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
|
const runs = useMemo(() => snapshot?.runs.filter((run) => !currentCompany || run.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
|
||||||
const selectedRun = runs.find((run) => run.id === selectedRunId) ?? runs[0] ?? null;
|
const selectedRun = runs.find((run) => run.id === selectedRunId) ?? runs[0] ?? null;
|
||||||
|
|
||||||
// Derived selections for organization entities
|
|
||||||
const companyAgents = useMemo(() => snapshot?.agents.filter((a) => !currentCompany || a.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
|
const companyAgents = useMemo(() => snapshot?.agents.filter((a) => !currentCompany || a.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
|
||||||
const selectedAgent = companyAgents.find((a) => a.id === selectedAgentId) ?? null;
|
const selectedAgent = companyAgents.find((a) => a.id === selectedAgentId) ?? null;
|
||||||
const companyGoals = useMemo(() => snapshot?.goals.filter((g) => !currentCompany || g.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
|
const companyGoals = useMemo(() => snapshot?.goals.filter((g) => !currentCompany || g.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
|
||||||
@@ -314,32 +338,24 @@ export function App() {
|
|||||||
const companyProjects = useMemo(() => snapshot?.projects.filter((p) => !currentCompany || p.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
|
const companyProjects = useMemo(() => snapshot?.projects.filter((p) => !currentCompany || p.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
|
||||||
const selectedProject = companyProjects.find((p) => p.id === selectedProjectId) ?? null;
|
const selectedProject = companyProjects.find((p) => p.id === selectedProjectId) ?? null;
|
||||||
|
|
||||||
// Sidebar badge mapping
|
const activeInboxCount = inboxItems.length;
|
||||||
const activeInboxCount = useMemo(() => inboxItems.filter((item) => !item.dismissed).length, [inboxItems]);
|
|
||||||
const navBadges = useMemo<Partial<Record<SectionId, { count: number; tone: "default" | "warn" | "danger" }>>>(() => badges ? {
|
const navBadges = useMemo<Partial<Record<SectionId, { count: number; tone: "default" | "warn" | "danger" }>>>(() => badges ? {
|
||||||
inbox: activeInboxCount > 0 ? { count: activeInboxCount, tone: "warn" } : undefined,
|
inbox: activeInboxCount > 0 ? { count: activeInboxCount, tone: "warn" } : undefined,
|
||||||
tasks: badges.todoTasks > 0 ? { count: badges.todoTasks, tone: "default" } : undefined,
|
tasks: badges.todoTasks > 0 ? { count: badges.todoTasks, tone: "default" } : undefined,
|
||||||
approvals: badges.pendingApprovals > 0 ? { count: badges.pendingApprovals, tone: "warn" } : undefined,
|
approvals: badges.pendingApprovals > 0 ? { count: badges.pendingApprovals, tone: "warn" } : undefined,
|
||||||
costs: badges.budgetWarnings > 0 ? { count: badges.budgetWarnings, tone: "danger" } : undefined,
|
costs: badges.budgetWarnings > 0 ? { count: badges.budgetWarnings, tone: "danger" } : undefined,
|
||||||
|
communication: badges.unreadMessages > 0 ? { count: badges.unreadMessages, tone: "warn" } : undefined,
|
||||||
} : {}, [badges, activeInboxCount]);
|
} : {}, [badges, activeInboxCount]);
|
||||||
|
|
||||||
const liveRuns = useMemo(() => runs.filter((run) => run.status === "running" || run.status === "queued"), [runs]);
|
|
||||||
const failedRuns = useMemo(() => runs.filter((run) => ["failed", "timed_out", "interrupted"].includes(run.status)), [runs]);
|
|
||||||
const pendingApprovals = useMemo(() => approvals.filter((approval) => approval.state === "pending"), [approvals]);
|
|
||||||
const tasksInProgress = useMemo(() => tasks.filter((task) => task.status === "in_progress" || task.status === "in_review"), [tasks]);
|
|
||||||
const companyWorkspaces = useMemo(() => snapshot?.workspaces.filter((workspace) => !currentCompany || workspace.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
|
const companyWorkspaces = useMemo(() => snapshot?.workspaces.filter((workspace) => !currentCompany || workspace.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
|
||||||
|
const selectedWorkspace = companyWorkspaces.find((workspace) => workspace.id === selectedWorkspaceId) ?? companyWorkspaces[0] ?? null;
|
||||||
const companyCosts = useMemo(() => snapshot?.costs.filter((entry) => !currentCompany || entry.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
|
const companyCosts = useMemo(() => snapshot?.costs.filter((entry) => !currentCompany || entry.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
|
||||||
const filteredActivity = useMemo(() => snapshot?.activity.filter((entry) => !currentCompany || entry.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
|
const filteredActivity = useMemo(() => snapshot?.activity.filter((entry) => !currentCompany || entry.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
|
||||||
const companySecrets = useMemo(() => snapshot?.secrets.filter((secret) => !currentCompany || secret.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
|
const companySecrets = useMemo(() => snapshot?.secrets.filter((secret) => !currentCompany || secret.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
|
||||||
const totalSpend = useMemo(() => companyCosts.reduce((sum, entry) => sum + (entry.amountUsd ?? 0), 0), [companyCosts]);
|
const companyDocuments = useMemo(() => snapshot?.documents?.filter((d) => !currentCompany || d.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
|
||||||
const overviewRows = useMemo(() => [
|
const companyKnowledge = useMemo(() => snapshot?.knowledgeBase?.filter((k) => !currentCompany || k.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
|
||||||
{ label: "Live runs", value: String(liveRuns.length), tone: liveRuns.length > 0 ? "text-[color:var(--success)]" : "" },
|
const companyMeetings = useMemo(() => snapshot?.meetings?.filter((m) => !currentCompany || m.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
|
||||||
{ label: "Pending approvals", value: String(pendingApprovals.length), tone: pendingApprovals.length > 0 ? "text-[color:var(--warn)]" : "" },
|
const companySprints = useMemo(() => snapshot?.sprints?.filter((s) => !currentCompany || s.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
|
||||||
{ label: "Active tasks", value: String(tasksInProgress.length), tone: "" },
|
|
||||||
{ label: "Known spend", value: formatMoney(totalSpend), tone: "" },
|
|
||||||
], [liveRuns, pendingApprovals, tasksInProgress, totalSpend]);
|
|
||||||
|
|
||||||
// Command palette entity search
|
|
||||||
const paletteQuery = commandPaletteSearch.trim().toLowerCase();
|
const paletteQuery = commandPaletteSearch.trim().toLowerCase();
|
||||||
const paletteResults = useMemo(() => {
|
const paletteResults = useMemo(() => {
|
||||||
const filteredNavItems = paletteQuery
|
const filteredNavItems = paletteQuery
|
||||||
@@ -354,6 +370,18 @@ export function App() {
|
|||||||
}, [paletteQuery, companyAgents, tasks, companyGoals, companyProjects]);
|
}, [paletteQuery, companyAgents, tasks, companyGoals, companyProjects]);
|
||||||
const { filteredNavItems, matchedAgents, matchedTasks, matchedGoals, matchedProjects, hasEntityResults } = paletteResults;
|
const { filteredNavItems, matchedAgents, matchedTasks, matchedGoals, matchedProjects, hasEntityResults } = paletteResults;
|
||||||
|
|
||||||
|
const navigateTo = useCallback((targetSection: SectionId, entityId?: string) => {
|
||||||
|
setSection(targetSection);
|
||||||
|
if (entityId) {
|
||||||
|
if (targetSection === "agents") setSelectedAgentId(entityId);
|
||||||
|
else if (targetSection === "tasks") setSelectedTaskId(entityId);
|
||||||
|
else if (targetSection === "approvals") setSelectedApprovalId(entityId);
|
||||||
|
else if (targetSection === "goals") setSelectedGoalId(entityId);
|
||||||
|
else if (targetSection === "projects") setSelectedProjectId(entityId);
|
||||||
|
else if (targetSection === "communication") setSelectedCommunicationEntityId(entityId);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const setCompany = useCallback(async (companyId: string) => {
|
const setCompany = useCallback(async (companyId: string) => {
|
||||||
if (companyId === "__new__") {
|
if (companyId === "__new__") {
|
||||||
setShowOnboarding(true);
|
setShowOnboarding(true);
|
||||||
@@ -419,8 +447,12 @@ export function App() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (snapshot.companies.length === 0 || showOnboarding) {
|
if (snapshot.companies.length === 0 || showOnboarding || initialOnboardingLocked) {
|
||||||
return <CompanyOnboarding connectors={snapshot.connectors} onDone={async () => { setShowOnboarding(false); await refresh(); }} />;
|
return <CompanyOnboarding connectors={snapshot.connectors} onDone={async () => {
|
||||||
|
setShowOnboarding(false);
|
||||||
|
setInitialOnboardingLocked(false);
|
||||||
|
await refresh();
|
||||||
|
}} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -560,7 +592,7 @@ export function App() {
|
|||||||
className={`focus-ring mt-1 flex w-full items-center rounded-[6px] py-[7px] text-[color:var(--muted)] transition duration-150 hover:bg-[color:var(--panel-soft)] hover:text-[color:var(--text)] ${sidebarCollapsed ? "justify-center p-2" : "gap-2.5 px-3"}`}
|
className={`focus-ring mt-1 flex w-full items-center rounded-[6px] py-[7px] text-[color:var(--muted)] transition duration-150 hover:bg-[color:var(--panel-soft)] hover:text-[color:var(--text)] ${sidebarCollapsed ? "justify-center p-2" : "gap-2.5 px-3"}`}
|
||||||
onClick={() => setSidebarCollapsed((c) => !c)}
|
onClick={() => setSidebarCollapsed((c) => !c)}
|
||||||
>
|
>
|
||||||
<SidebarSimple size={sidebarCollapsed ? 18 : 17} className={sidebarCollapsed ? "" : ""} />
|
<SidebarSimple size={sidebarCollapsed ? 18 : 17} />
|
||||||
{!sidebarCollapsed ? <span className="text-[13px] font-medium tracking-[0.005em]">Collapse</span> : null}
|
{!sidebarCollapsed ? <span className="text-[13px] font-medium tracking-[0.005em]">Collapse</span> : null}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -626,67 +658,33 @@ export function App() {
|
|||||||
activity={filteredActivity}
|
activity={filteredActivity}
|
||||||
costs={companyCosts}
|
costs={companyCosts}
|
||||||
connectors={snapshot.connectors}
|
connectors={snapshot.connectors}
|
||||||
meetings={snapshot.meetings?.filter((m) => !currentCompany || m.companyId === currentCompany.id)}
|
meetings={companyMeetings}
|
||||||
sprints={snapshot.sprints?.filter((s) => !currentCompany || s.companyId === currentCompany.id)}
|
sprints={companySprints}
|
||||||
onNavigate={(targetSection, entityId) => {
|
messages={snapshot.agentMessages?.filter((message) => !currentCompany || message.companyId === currentCompany.id) ?? []}
|
||||||
setSection(targetSection);
|
browserActions={browserActions}
|
||||||
if (entityId) {
|
workflows={snapshot.workflows?.filter((workflow) => !currentCompany || workflow.companyId === currentCompany.id) ?? []}
|
||||||
if (targetSection === "agents") setSelectedAgentId(entityId);
|
onNavigate={navigateTo}
|
||||||
else if (targetSection === "tasks") setSelectedTaskId(entityId);
|
|
||||||
else if (targetSection === "approvals") setSelectedApprovalId(entityId);
|
|
||||||
else if (targetSection === "goals") setSelectedGoalId(entityId);
|
|
||||||
else if (targetSection === "projects") setSelectedProjectId(entityId);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{section === "inbox" ? (
|
{section === "inbox" ? (
|
||||||
<Inbox
|
<Inbox
|
||||||
items={inboxItems}
|
items={inboxItems}
|
||||||
onNavigate={(targetSection, entityId) => {
|
onNavigate={navigateTo}
|
||||||
setSection(targetSection);
|
|
||||||
if (entityId) {
|
|
||||||
if (targetSection === "agents") setSelectedAgentId(entityId);
|
|
||||||
else if (targetSection === "tasks") setSelectedTaskId(entityId);
|
|
||||||
else if (targetSection === "approvals") setSelectedApprovalId(entityId);
|
|
||||||
else if (targetSection === "goals") setSelectedGoalId(entityId);
|
|
||||||
else if (targetSection === "projects") setSelectedProjectId(entityId);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onDismiss={(_itemId) => { /* no-op for now */ }}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{section === "standup" ? (
|
{section === "standup" ? (
|
||||||
<StandupView
|
<StandupView
|
||||||
report={standupReport}
|
report={standupReport}
|
||||||
onNavigate={(targetSection, entityId) => {
|
onNavigate={navigateTo}
|
||||||
setSection(targetSection);
|
|
||||||
if (entityId) {
|
|
||||||
if (targetSection === "agents") setSelectedAgentId(entityId);
|
|
||||||
else if (targetSection === "tasks") setSelectedTaskId(entityId);
|
|
||||||
else if (targetSection === "approvals") setSelectedApprovalId(entityId);
|
|
||||||
else if (targetSection === "goals") setSelectedGoalId(entityId);
|
|
||||||
else if (targetSection === "projects") setSelectedProjectId(entityId);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{section === "performance" ? (
|
{section === "performance" ? (
|
||||||
<PerformanceView
|
<PerformanceView
|
||||||
metrics={companyMetrics}
|
metrics={companyMetrics}
|
||||||
onNavigate={(targetSection, entityId) => {
|
onNavigate={navigateTo}
|
||||||
setSection(targetSection);
|
|
||||||
if (entityId) {
|
|
||||||
if (targetSection === "agents") setSelectedAgentId(entityId);
|
|
||||||
else if (targetSection === "tasks") setSelectedTaskId(entityId);
|
|
||||||
else if (targetSection === "approvals") setSelectedApprovalId(entityId);
|
|
||||||
else if (targetSection === "goals") setSelectedGoalId(entityId);
|
|
||||||
else if (targetSection === "projects") setSelectedProjectId(entityId);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -717,7 +715,7 @@ export function App() {
|
|||||||
</Panel>
|
</Panel>
|
||||||
<Panel
|
<Panel
|
||||||
title={selectedConnector.label}
|
title={selectedConnector.label}
|
||||||
eyebrow={undefined}
|
|
||||||
action={<ActionButton label="Run health check" onClick={() => void quickConnectorCheck()} icon={Wrench} tone="accent" />}
|
action={<ActionButton label="Run health check" onClick={() => void quickConnectorCheck()} icon={Wrench} tone="accent" />}
|
||||||
>
|
>
|
||||||
<ConnectorEditor connector={selectedConnector} onSaved={refresh} />
|
<ConnectorEditor connector={selectedConnector} onSaved={refresh} />
|
||||||
@@ -762,7 +760,6 @@ export function App() {
|
|||||||
|
|
||||||
{section === "hiring" && currentCompany ? (
|
{section === "hiring" && currentCompany ? (
|
||||||
<HiringPipeline
|
<HiringPipeline
|
||||||
companyId={currentCompany.id}
|
|
||||||
agents={companyAgents}
|
agents={companyAgents}
|
||||||
approvals={(snapshot?.approvals ?? []).filter(a => a.companyId === currentCompany!.id)}
|
approvals={(snapshot?.approvals ?? []).filter(a => a.companyId === currentCompany!.id)}
|
||||||
onRefresh={refresh}
|
onRefresh={refresh}
|
||||||
@@ -906,7 +903,7 @@ export function App() {
|
|||||||
tasks={tasks}
|
tasks={tasks}
|
||||||
companyId={currentCompany!.id}
|
companyId={currentCompany!.id}
|
||||||
onDecide={async (state, note) => {
|
onDecide={async (state, note) => {
|
||||||
unwrap(await window.agentCompany.decideApproval({ approvalId: selectedApproval.id, state, decisionNote: note ?? `${state} from desktop control surface.` }));
|
unwrap(await window.agentCompany.decideApproval({ companyId: currentCompany!.id, approvalId: selectedApproval.id, state, decisionNote: note ?? `${state} from desktop control surface.` }));
|
||||||
await refresh();
|
await refresh();
|
||||||
}}
|
}}
|
||||||
onDeleted={async () => { setSelectedApprovalId(null); await refresh(); }}
|
onDeleted={async () => { setSelectedApprovalId(null); await refresh(); }}
|
||||||
@@ -965,16 +962,19 @@ export function App() {
|
|||||||
}}
|
}}
|
||||||
onLogin={async (accountId) => {
|
onLogin={async (accountId) => {
|
||||||
await window.agentCompany.triggerBrowserLogin(accountId);
|
await window.agentCompany.triggerBrowserLogin(accountId);
|
||||||
|
await refresh();
|
||||||
}}
|
}}
|
||||||
onNavigate={(s, id) => {
|
onOpenPath={async (path) => {
|
||||||
setSection(s);
|
await window.agentCompany.openPath(path);
|
||||||
if (id) {
|
}}
|
||||||
if (s === "agents") setSelectedAgentId(id);
|
onCancelAction={async (actionId) => {
|
||||||
else if (s === "tasks") setSelectedTaskId(id);
|
await window.agentCompany.cancelBrowserAction({ runId: actionId });
|
||||||
else if (s === "approvals") setSelectedApprovalId(id);
|
await refresh();
|
||||||
else if (s === "goals") setSelectedGoalId(id);
|
}}
|
||||||
else if (s === "projects") setSelectedProjectId(id);
|
onNavigate={(sectionName, entityId) => {
|
||||||
}
|
setSection(sectionName);
|
||||||
|
if (sectionName === "tasks") setSelectedTaskId(entityId);
|
||||||
|
if (sectionName === "approvals") setSelectedApprovalId(entityId);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -983,7 +983,6 @@ export function App() {
|
|||||||
<AutomationPanel
|
<AutomationPanel
|
||||||
rules={snapshot.automationRules?.filter(r => r.companyId === currentCompany.id) ?? []}
|
rules={snapshot.automationRules?.filter(r => r.companyId === currentCompany.id) ?? []}
|
||||||
workflows={snapshot.workflows?.filter(w => w.companyId === currentCompany.id) ?? []}
|
workflows={snapshot.workflows?.filter(w => w.companyId === currentCompany.id) ?? []}
|
||||||
agents={companyAgents}
|
|
||||||
companyId={currentCompany.id}
|
companyId={currentCompany.id}
|
||||||
onRefresh={refresh}
|
onRefresh={refresh}
|
||||||
/>
|
/>
|
||||||
@@ -992,137 +991,36 @@ export function App() {
|
|||||||
{section === "communication" && currentCompany ? (
|
{section === "communication" && currentCompany ? (
|
||||||
<CommunicationHub
|
<CommunicationHub
|
||||||
messages={snapshot.agentMessages?.filter(m => m.companyId === currentCompany.id) ?? []}
|
messages={snapshot.agentMessages?.filter(m => m.companyId === currentCompany.id) ?? []}
|
||||||
|
comments={snapshot.comments?.filter((comment) => comment.companyId === currentCompany.id) ?? []}
|
||||||
|
tasks={tasks}
|
||||||
agents={companyAgents}
|
agents={companyAgents}
|
||||||
companyId={currentCompany.id}
|
companyId={currentCompany.id}
|
||||||
|
selectedEntityId={selectedCommunicationEntityId}
|
||||||
onRefresh={refresh}
|
onRefresh={refresh}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{section === "documents" && currentCompany ? (
|
{section === "documents" && currentCompany ? (
|
||||||
<Panel title="Company Documents" action={<span className="text-[11px] text-[color:var(--muted)]">{(snapshot.documents?.filter(d => d.companyId === currentCompany.id) ?? []).length} documents</span>}>
|
<DocumentCenter
|
||||||
{(snapshot.documents?.filter(d => d.companyId === currentCompany.id) ?? []).length === 0 ? (
|
companyId={currentCompany.id}
|
||||||
<div className="py-10 text-center text-[13px] text-[color:var(--muted)]">
|
documents={companyDocuments}
|
||||||
<FileText size={32} className="mx-auto mb-3 opacity-40" />
|
agents={companyAgents}
|
||||||
<p>No documents yet. Agents will create PRDs, specs, and reports as they work.</p>
|
projects={companyProjects}
|
||||||
</div>
|
goals={companyGoals}
|
||||||
) : (
|
onRefresh={refresh}
|
||||||
<ListFrame>
|
/>
|
||||||
{(snapshot.documents?.filter(d => d.companyId === currentCompany.id) ?? []).map(doc => (
|
|
||||||
<ListRow key={doc.id}>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<FileText size={14} className="text-[color:var(--accent)]" />
|
|
||||||
<span className="text-[13px] font-medium text-[color:var(--text)]">{doc.title}</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 flex items-center gap-3 text-[11px] text-[color:var(--muted)]">
|
|
||||||
<span className="uppercase tracking-wider">{doc.type.replace(/_/g, " ")}</span>
|
|
||||||
<span>v{doc.version}</span>
|
|
||||||
{doc.authorAgentId && <span>by {companyAgents.find(a => a.id === doc.authorAgentId)?.name ?? "Unknown"}</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<StatusPill status={doc.status} />
|
|
||||||
</div>
|
|
||||||
</ListRow>
|
|
||||||
))}
|
|
||||||
</ListFrame>
|
|
||||||
)}
|
|
||||||
</Panel>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{section === "knowledge" && currentCompany ? (
|
{section === "knowledge" && currentCompany ? (
|
||||||
<Panel title="Knowledge Base" action={<span className="text-[11px] text-[color:var(--muted)]">{(snapshot.knowledgeBase?.filter(k => k.companyId === currentCompany.id) ?? []).length} entries</span>}>
|
<KnowledgeBaseManager companyId={currentCompany.id} entries={companyKnowledge} onRefresh={refresh} />
|
||||||
{(snapshot.knowledgeBase?.filter(k => k.companyId === currentCompany.id) ?? []).length === 0 ? (
|
|
||||||
<div className="py-10 text-center text-[13px] text-[color:var(--muted)]">
|
|
||||||
<Brain size={32} className="mx-auto mb-3 opacity-40" />
|
|
||||||
<p>No knowledge entries yet. Agents learn and document lessons as they work.</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<ListFrame>
|
|
||||||
{(snapshot.knowledgeBase?.filter(k => k.companyId === currentCompany.id) ?? []).map(entry => (
|
|
||||||
<ListRow key={entry.id}>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Brain size={14} className="text-[color:var(--accent)]" />
|
|
||||||
<span className="text-[13px] font-medium text-[color:var(--text)]">{entry.topic}</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 text-[11px] text-[color:var(--muted)]">
|
|
||||||
<span className="uppercase tracking-wider">{entry.category.replace(/_/g, " ")}</span>
|
|
||||||
<span className="mx-2">-</span>
|
|
||||||
<span>{entry.content.slice(0, 100)}{entry.content.length > 100 ? "..." : ""}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<StatusPill status={entry.importance} />
|
|
||||||
</div>
|
|
||||||
</ListRow>
|
|
||||||
))}
|
|
||||||
</ListFrame>
|
|
||||||
)}
|
|
||||||
</Panel>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{section === "meetings" && currentCompany ? (
|
{section === "meetings" && currentCompany ? (
|
||||||
<Panel title="Meetings" action={<span className="text-[11px] text-[color:var(--muted)]">{(snapshot.meetings?.filter(m => m.companyId === currentCompany.id) ?? []).length} meetings</span>}>
|
<MeetingsManager companyId={currentCompany.id} meetings={companyMeetings} agents={companyAgents} onRefresh={refresh} />
|
||||||
{(snapshot.meetings?.filter(m => m.companyId === currentCompany.id) ?? []).length === 0 ? (
|
|
||||||
<div className="py-10 text-center text-[13px] text-[color:var(--muted)]">
|
|
||||||
<CalendarBlank size={32} className="mx-auto mb-3 opacity-40" />
|
|
||||||
<p>No meetings scheduled. Agents will schedule standups, reviews, and planning sessions.</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<ListFrame>
|
|
||||||
{(snapshot.meetings?.filter(m => m.companyId === currentCompany.id) ?? []).map(meeting => (
|
|
||||||
<ListRow key={meeting.id}>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<CalendarBlank size={14} className="text-[color:var(--accent)]" />
|
|
||||||
<span className="text-[13px] font-medium text-[color:var(--text)]">{meeting.title}</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 flex items-center gap-3 text-[11px] text-[color:var(--muted)]">
|
|
||||||
<span className="uppercase tracking-wider">{meeting.type.replace(/_/g, " ")}</span>
|
|
||||||
<span>{new Date(meeting.scheduledAt).toLocaleString()}</span>
|
|
||||||
<span>{meeting.durationMinutes}min</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<StatusPill status={meeting.status} />
|
|
||||||
</div>
|
|
||||||
</ListRow>
|
|
||||||
))}
|
|
||||||
</ListFrame>
|
|
||||||
)}
|
|
||||||
</Panel>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{section === "sprints" && currentCompany ? (
|
{section === "sprints" && currentCompany ? (
|
||||||
<Panel title="Sprint Board" action={<span className="text-[11px] text-[color:var(--muted)]">{(snapshot.sprints?.filter(s => s.companyId === currentCompany.id) ?? []).length} sprints</span>}>
|
<SprintManager companyId={currentCompany.id} sprints={companySprints} onRefresh={refresh} />
|
||||||
{(snapshot.sprints?.filter(s => s.companyId === currentCompany.id) ?? []).length === 0 ? (
|
|
||||||
<div className="py-10 text-center text-[13px] text-[color:var(--muted)]">
|
|
||||||
<Rows size={32} className="mx-auto mb-3 opacity-40" />
|
|
||||||
<p>No sprints yet. Start a sprint to organize work into focused cycles.</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<ListFrame>
|
|
||||||
{(snapshot.sprints?.filter(s => s.companyId === currentCompany.id) ?? []).map(sprint => (
|
|
||||||
<ListRow key={sprint.id}>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Rows size={14} className="text-[color:var(--accent)]" />
|
|
||||||
<span className="text-[13px] font-medium text-[color:var(--text)]">{sprint.name}</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 flex items-center gap-3 text-[11px] text-[color:var(--muted)]">
|
|
||||||
<span>{sprint.startDate} - {sprint.endDate}</span>
|
|
||||||
<span>{sprint.completedPoints}/{sprint.velocityPoints} pts</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<StatusPill status={sprint.status} />
|
|
||||||
</div>
|
|
||||||
</ListRow>
|
|
||||||
))}
|
|
||||||
</ListFrame>
|
|
||||||
)}
|
|
||||||
</Panel>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{section === "workspaces" ? (
|
{section === "workspaces" ? (
|
||||||
@@ -1135,15 +1033,30 @@ export function App() {
|
|||||||
workspaces={companyWorkspaces}
|
workspaces={companyWorkspaces}
|
||||||
projects={companyProjects}
|
projects={companyProjects}
|
||||||
companyId={currentCompany!.id}
|
companyId={currentCompany!.id}
|
||||||
|
selectedWorkspaceId={selectedWorkspace?.id ?? null}
|
||||||
|
onSelect={setSelectedWorkspaceId}
|
||||||
onOpen={(path) => window.agentCompany.openPath(path).then(() => undefined)}
|
onOpen={(path) => window.agentCompany.openPath(path).then(() => undefined)}
|
||||||
onDeleted={refresh}
|
onDeleted={refresh}
|
||||||
/>
|
/>
|
||||||
</Panel>
|
</Panel>
|
||||||
}
|
}
|
||||||
secondary={
|
secondary={
|
||||||
<Panel title="Add workspace">
|
<div className="space-y-6">
|
||||||
<WorkspaceCreator companyId={currentCompany!.id} projects={companyProjects} onSaved={refresh} />
|
<Panel title="Workspace control center">
|
||||||
</Panel>
|
<WorkspaceInspector
|
||||||
|
workspace={selectedWorkspace}
|
||||||
|
agents={companyAgents}
|
||||||
|
tasks={tasks}
|
||||||
|
runs={runs}
|
||||||
|
documents={companyDocuments}
|
||||||
|
project={selectedWorkspace ? (companyProjects.find((project) => project.id === selectedWorkspace.projectId) ?? null) : null}
|
||||||
|
onOpen={(path) => window.agentCompany.openPath(path).then(() => undefined)}
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
<Panel title="Add workspace">
|
||||||
|
<WorkspaceCreator companyId={currentCompany!.id} projects={companyProjects} onSaved={refresh} />
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react";
|
||||||
import type { CompanyTemplate, ProfileSnapshot } from "@shared/types";
|
import type { CompanyTemplate, ProfileSnapshot } from "@shared/types";
|
||||||
import { unwrap } from "../lib/desktop";
|
import { unwrap } from "../lib/desktop";
|
||||||
|
import { getAgentInitials } from "../lib/formatters";
|
||||||
import { ActionButton, Input, TextArea } from "./ui";
|
import { ActionButton, Input, TextArea } from "./ui";
|
||||||
|
|
||||||
type Step = "choose" | "template" | "company" | "goal" | "launch";
|
type Step = "choose" | "template" | "company" | "goal" | "launch";
|
||||||
@@ -56,7 +57,6 @@ export function CompanyOnboarding({
|
|||||||
const bestConnector = readyConnectors[0] ?? null;
|
const bestConnector = readyConnectors[0] ?? null;
|
||||||
const selectedTemplate = templates.find((t) => t.id === selectedTemplateId) ?? null;
|
const selectedTemplate = templates.find((t) => t.id === selectedTemplateId) ?? null;
|
||||||
|
|
||||||
// Load templates
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.agentCompany.listCompanyTemplates().then((result) => {
|
window.agentCompany.listCompanyTemplates().then((result) => {
|
||||||
if (result.ok) setTemplates(result.data);
|
if (result.ok) setTemplates(result.data);
|
||||||
@@ -75,7 +75,6 @@ export function CompanyOnboarding({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Template launch
|
|
||||||
const launchFromTemplate = useCallback(async () => {
|
const launchFromTemplate = useCallback(async () => {
|
||||||
if (loading || !selectedTemplate) return;
|
if (loading || !selectedTemplate) return;
|
||||||
if (!companyName.trim()) {
|
if (!companyName.trim()) {
|
||||||
@@ -98,7 +97,6 @@ export function CompanyOnboarding({
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create workspace for the company if path provided
|
|
||||||
if (workspacePath) {
|
if (workspacePath) {
|
||||||
unwrap(
|
unwrap(
|
||||||
await window.agentCompany.saveWorkspace({
|
await window.agentCompany.saveWorkspace({
|
||||||
@@ -122,7 +120,6 @@ export function CompanyOnboarding({
|
|||||||
}
|
}
|
||||||
}, [loading, selectedTemplate, companyName, hasReadyConnector, workspacePath, bestConnector, onDone]);
|
}, [loading, selectedTemplate, companyName, hasReadyConnector, workspacePath, bestConnector, onDone]);
|
||||||
|
|
||||||
// Custom company creation (existing flow)
|
|
||||||
const createCompany = useCallback(async () => {
|
const createCompany = useCallback(async () => {
|
||||||
if (loading) return;
|
if (loading) return;
|
||||||
if (!companyName.trim()) {
|
if (!companyName.trim()) {
|
||||||
@@ -166,66 +163,77 @@ export function CompanyOnboarding({
|
|||||||
}
|
}
|
||||||
}, [loading, companyName, hasReadyConnector, autoApproveHires, workspacePath]);
|
}, [loading, companyName, hasReadyConnector, autoApproveHires, workspacePath]);
|
||||||
|
|
||||||
|
const bootstrapInitialCompany = useCallback(async (companyId: string, seedGoal?: { title?: string; description?: string }) => {
|
||||||
|
if (seedGoal?.title?.trim()) {
|
||||||
|
unwrap(
|
||||||
|
await window.agentCompany.saveGoal({
|
||||||
|
companyId,
|
||||||
|
title: seedGoal.title.trim(),
|
||||||
|
description: seedGoal.description?.trim() || `Achieve: ${seedGoal.title.trim()}`,
|
||||||
|
status: "active",
|
||||||
|
parentId: null,
|
||||||
|
ownerAgentId: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bestConnector) {
|
||||||
|
const ceoId = unwrap(
|
||||||
|
await window.agentCompany.saveAgent({
|
||||||
|
companyId,
|
||||||
|
name: "CEO",
|
||||||
|
role: "CEO",
|
||||||
|
title: "Chief Executive Officer",
|
||||||
|
reportsTo: null,
|
||||||
|
connectorId: bestConnector.id,
|
||||||
|
workspaceId: null,
|
||||||
|
model: null,
|
||||||
|
capabilities: "Executive leadership, strategic planning, team building, goal decomposition, delegation, and organizational management. Responsible for hiring the leadership team, setting strategy, and coordinating all company operations.",
|
||||||
|
budgetMonthlyUsd: 250,
|
||||||
|
status: "idle",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
unwrap(await window.agentCompany.setHeartbeat({ agentId: ceoId, companyId, enabled: true, intervalSec: 120 }));
|
||||||
|
setStep("launch");
|
||||||
|
setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
await window.agentCompany.triggerHeartbeat({ agentId: ceoId, companyId, trigger: "assignment" });
|
||||||
|
} catch {
|
||||||
|
// Non-fatal. The CEO will wake on the next scheduled heartbeat.
|
||||||
|
}
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
await onDone();
|
||||||
|
}, [bestConnector, onDone]);
|
||||||
|
|
||||||
const createGoalAndLaunch = useCallback(async () => {
|
const createGoalAndLaunch = useCallback(async () => {
|
||||||
if (loading || !createdCompanyId) return;
|
if (loading || !createdCompanyId) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
if (goalTitle.trim()) {
|
await bootstrapInitialCompany(createdCompanyId, {
|
||||||
unwrap(
|
title: goalTitle,
|
||||||
await window.agentCompany.saveGoal({
|
description: goalDescription,
|
||||||
companyId: createdCompanyId,
|
});
|
||||||
title: goalTitle.trim(),
|
|
||||||
description: goalDescription.trim() || `Achieve: ${goalTitle.trim()}`,
|
|
||||||
status: "active",
|
|
||||||
parentId: null,
|
|
||||||
ownerAgentId: null,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (bestConnector) {
|
|
||||||
const ceoId = unwrap(
|
|
||||||
await window.agentCompany.saveAgent({
|
|
||||||
companyId: createdCompanyId,
|
|
||||||
name: "CEO",
|
|
||||||
role: "CEO",
|
|
||||||
title: "Chief Executive Officer",
|
|
||||||
reportsTo: null,
|
|
||||||
connectorId: bestConnector.id,
|
|
||||||
workspaceId: null,
|
|
||||||
model: null,
|
|
||||||
capabilities: "Executive leadership, strategic planning, team building, goal decomposition, delegation, and organizational management. Responsible for hiring the leadership team, setting strategy, and coordinating all company operations.",
|
|
||||||
budgetMonthlyUsd: 250,
|
|
||||||
status: "idle",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
unwrap(await window.agentCompany.setHeartbeat({ agentId: ceoId, companyId: createdCompanyId, enabled: true, intervalSec: 120 }));
|
|
||||||
setStep("launch");
|
|
||||||
setTimeout(async () => {
|
|
||||||
try {
|
|
||||||
await window.agentCompany.triggerHeartbeat({ agentId: ceoId, companyId: createdCompanyId, trigger: "assignment" });
|
|
||||||
} catch { /* Non-fatal */ }
|
|
||||||
}, 1500);
|
|
||||||
}
|
|
||||||
await onDone();
|
|
||||||
} catch (submitError) {
|
} catch (submitError) {
|
||||||
setError(submitError instanceof Error ? submitError.message : "Unable to set up the company.");
|
setError(submitError instanceof Error ? submitError.message : "Unable to set up the company.");
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [loading, createdCompanyId, goalTitle, goalDescription, bestConnector, onDone]);
|
}, [loading, createdCompanyId, goalTitle, goalDescription, bootstrapInitialCompany]);
|
||||||
|
|
||||||
const skipGoalAndFinish = useCallback(async () => {
|
const skipGoalAndFinish = useCallback(async () => {
|
||||||
if (loading) return;
|
if (loading || !createdCompanyId) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await onDone();
|
await bootstrapInitialCompany(createdCompanyId);
|
||||||
} catch (submitError) {
|
} catch (submitError) {
|
||||||
setError(submitError instanceof Error ? submitError.message : "Unable to continue.");
|
setError(submitError instanceof Error ? submitError.message : "Unable to continue.");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [loading, onDone]);
|
}, [loading, createdCompanyId, bootstrapInitialCompany]);
|
||||||
|
|
||||||
const stepLabels = step === "choose" || step === "template"
|
const stepLabels = step === "choose" || step === "template"
|
||||||
? [
|
? [
|
||||||
@@ -242,14 +250,13 @@ export function CompanyOnboarding({
|
|||||||
const stepIndex = step === "choose" ? 0 : step === "template" ? 1 : step === "company" ? 0 : step === "goal" ? 1 : 2;
|
const stepIndex = step === "choose" ? 0 : step === "template" ? 1 : step === "company" ? 0 : step === "goal" ? 1 : 2;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid min-h-[100dvh] grid-cols-1 bg-[color:var(--bg)] md:grid-cols-[minmax(0,1fr)_520px]">
|
<div className="grid h-[100dvh] overflow-hidden grid-cols-1 bg-[color:var(--bg)] md:grid-cols-[minmax(0,1fr)_520px]">
|
||||||
{/* Left panel — branding */}
|
<div className="flex min-h-0 items-center justify-center overflow-y-auto px-8 py-12 md:px-16">
|
||||||
<div className="flex items-center px-8 py-12 md:px-16">
|
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 24 }}
|
initial={{ opacity: 0, y: 24 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.45, ease: [0.16, 1, 0.3, 1] }}
|
transition={{ duration: 0.45, ease: [0.16, 1, 0.3, 1] }}
|
||||||
className="max-w-[440px]"
|
className="mx-auto w-full max-w-[440px]"
|
||||||
>
|
>
|
||||||
<div className="mb-3 text-[10px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">AgentCompany Desktop</div>
|
<div className="mb-3 text-[10px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">AgentCompany Desktop</div>
|
||||||
<h1 className="text-[38px] leading-[0.98] tracking-[-0.03em] text-[color:var(--text)] md:text-[48px]">
|
<h1 className="text-[38px] leading-[0.98] tracking-[-0.03em] text-[color:var(--text)] md:text-[48px]">
|
||||||
@@ -259,7 +266,7 @@ export function CompanyOnboarding({
|
|||||||
Set up a fully autonomous AI-powered organization. Your agents will hire, delegate, execute, review, and coordinate — just like a real company.
|
Set up a fully autonomous AI-powered organization. Your agents will hire, delegate, execute, review, and coordinate — just like a real company.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Step indicators */}
|
|
||||||
<div className="mt-10 flex items-center gap-3">
|
<div className="mt-10 flex items-center gap-3">
|
||||||
{stepLabels.map((s, i) => (
|
{stepLabels.map((s, i) => (
|
||||||
<div key={s.label} className="flex items-center gap-2">
|
<div key={s.label} className="flex items-center gap-2">
|
||||||
@@ -278,7 +285,7 @@ export function CompanyOnboarding({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Detected connectors */}
|
|
||||||
<div className="mt-10">
|
<div className="mt-10">
|
||||||
<div className="mb-3 text-[10px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Detected agents</div>
|
<div className="mb-3 text-[10px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Detected agents</div>
|
||||||
<div className="space-y-2.5">
|
<div className="space-y-2.5">
|
||||||
@@ -313,10 +320,9 @@ export function CompanyOnboarding({
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right panel — form steps */}
|
<div className="min-h-0 overflow-y-auto border-l border-[color:var(--line)] bg-[color:var(--bg-subtle)]">
|
||||||
<div className="flex items-center border-l border-[color:var(--line)] bg-[color:var(--bg-subtle)] px-6 py-10 md:px-10">
|
<div className="flex min-h-full items-center px-6 py-10 md:px-10">
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
{/* ── Step: Choose Mode ────────────────────────── */}
|
|
||||||
{step === "choose" ? (
|
{step === "choose" ? (
|
||||||
<motion.div
|
<motion.div
|
||||||
key="choose"
|
key="choose"
|
||||||
@@ -331,7 +337,7 @@ export function CompanyOnboarding({
|
|||||||
<div className="text-[18px] font-semibold tracking-[-0.01em] text-[color:var(--text)]">Choose your setup</div>
|
<div className="text-[18px] font-semibold tracking-[-0.01em] text-[color:var(--text)]">Choose your setup</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Template cards */}
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="text-[11px] font-semibold uppercase tracking-[0.06em] text-[color:var(--accent)]">Quick Start — Choose a template</div>
|
<div className="text-[11px] font-semibold uppercase tracking-[0.06em] text-[color:var(--accent)]">Quick Start — Choose a template</div>
|
||||||
<div className="grid grid-cols-1 gap-2">
|
<div className="grid grid-cols-1 gap-2">
|
||||||
@@ -378,7 +384,7 @@ export function CompanyOnboarding({
|
|||||||
<div className="h-px flex-1 bg-[color:var(--line)]" />
|
<div className="h-px flex-1 bg-[color:var(--line)]" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Custom option */}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setStep("company")}
|
onClick={() => setStep("company")}
|
||||||
@@ -395,7 +401,6 @@ export function CompanyOnboarding({
|
|||||||
</button>
|
</button>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
) : step === "template" && selectedTemplate ? (
|
) : step === "template" && selectedTemplate ? (
|
||||||
/* ── Step: Template Configuration ─────────────── */
|
|
||||||
<motion.div
|
<motion.div
|
||||||
key="template"
|
key="template"
|
||||||
initial={{ opacity: 0, y: 18 }}
|
initial={{ opacity: 0, y: 18 }}
|
||||||
@@ -421,7 +426,7 @@ export function CompanyOnboarding({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Team preview */}
|
|
||||||
<div className="mt-5 rounded-[8px] border border-[color:var(--line)] bg-[color:var(--panel-soft)] px-4 py-3">
|
<div className="mt-5 rounded-[8px] border border-[color:var(--line)] bg-[color:var(--panel-soft)] px-4 py-3">
|
||||||
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.06em] text-[color:var(--muted)]">Team that will be created</div>
|
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.06em] text-[color:var(--muted)]">Team that will be created</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
@@ -430,7 +435,7 @@ export function CompanyOnboarding({
|
|||||||
<div className={`flex h-6 w-6 items-center justify-center rounded-full text-[8px] font-bold ${
|
<div className={`flex h-6 w-6 items-center justify-center rounded-full text-[8px] font-bold ${
|
||||||
agent.reportsToIndex === null ? "bg-[color:var(--accent-soft)] text-[color:var(--accent)]" : "bg-[color:var(--panel)] text-[color:var(--muted-strong)]"
|
agent.reportsToIndex === null ? "bg-[color:var(--accent-soft)] text-[color:var(--accent)]" : "bg-[color:var(--panel)] text-[color:var(--muted-strong)]"
|
||||||
}`}>
|
}`}>
|
||||||
{agent.name.split(/\s+/).slice(0, 2).map(w => w[0]).join("").toUpperCase()}
|
{getAgentInitials(agent.name)}
|
||||||
</div>
|
</div>
|
||||||
<span className="font-medium text-[color:var(--text)]">{agent.name}</span>
|
<span className="font-medium text-[color:var(--text)]">{agent.name}</span>
|
||||||
<span className="text-[color:var(--muted)]">— {agent.title}</span>
|
<span className="text-[color:var(--muted)]">— {agent.title}</span>
|
||||||
@@ -449,7 +454,7 @@ export function CompanyOnboarding({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* What happens */}
|
|
||||||
<div className="mt-4 rounded-[8px] border border-[color:var(--line)] bg-[color:var(--panel-soft)] px-4 py-3">
|
<div className="mt-4 rounded-[8px] border border-[color:var(--line)] bg-[color:var(--panel-soft)] px-4 py-3">
|
||||||
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.06em] text-[color:var(--muted)]">What happens next</div>
|
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.06em] text-[color:var(--muted)]">What happens next</div>
|
||||||
<div className="space-y-2 text-[12px] text-[color:var(--muted)]">
|
<div className="space-y-2 text-[12px] text-[color:var(--muted)]">
|
||||||
@@ -492,7 +497,6 @@ export function CompanyOnboarding({
|
|||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
) : step === "company" ? (
|
) : step === "company" ? (
|
||||||
/* ── Step: Custom Company ─────────────────────── */
|
|
||||||
<motion.div
|
<motion.div
|
||||||
key="company"
|
key="company"
|
||||||
initial={{ opacity: 0, y: 18 }}
|
initial={{ opacity: 0, y: 18 }}
|
||||||
@@ -568,7 +572,6 @@ export function CompanyOnboarding({
|
|||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
) : step === "goal" ? (
|
) : step === "goal" ? (
|
||||||
/* ── Step: Goal ───────────────────────────────── */
|
|
||||||
<motion.div
|
<motion.div
|
||||||
key="goal"
|
key="goal"
|
||||||
initial={{ opacity: 0, y: 18 }}
|
initial={{ opacity: 0, y: 18 }}
|
||||||
@@ -639,7 +642,6 @@ export function CompanyOnboarding({
|
|||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
) : (
|
) : (
|
||||||
/* ── Step: Launch Success ─────────────────────── */
|
|
||||||
<motion.div
|
<motion.div
|
||||||
key="launch"
|
key="launch"
|
||||||
initial={{ opacity: 0, scale: 0.95 }}
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
@@ -677,6 +679,7 @@ export function CompanyOnboarding({
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import {
|
|||||||
X,
|
X,
|
||||||
} from "@phosphor-icons/react"
|
} from "@phosphor-icons/react"
|
||||||
import type {
|
import type {
|
||||||
AgentRecord,
|
|
||||||
AutomationAction,
|
AutomationAction,
|
||||||
AutomationRuleRecord,
|
AutomationRuleRecord,
|
||||||
AutomationStatus,
|
AutomationStatus,
|
||||||
@@ -27,14 +26,12 @@ import type {
|
|||||||
WorkflowStatus,
|
WorkflowStatus,
|
||||||
WorkflowStep,
|
WorkflowStep,
|
||||||
} from "@shared/types"
|
} from "@shared/types"
|
||||||
|
import { timeAgo } from "../lib/formatters"
|
||||||
|
import { DEPARTMENT_OPTIONS } from "@shared/constants"
|
||||||
import { ActionButton, Select } from "./ui"
|
import { ActionButton, Select } from "./ui"
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
const ACCENT = "var(--accent)"
|
||||||
/* Constants & Config */
|
const LINE = "var(--line)"
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
const ACCENT = "#d97745"
|
|
||||||
const LINE = "rgba(15,23,42,0.08)"
|
|
||||||
|
|
||||||
const triggerConfig: Record<AutomationTrigger, { label: string; description: string; icon: typeof Lightning }> = {
|
const triggerConfig: Record<AutomationTrigger, { label: string; description: string; icon: typeof Lightning }> = {
|
||||||
task_status_changed: { label: "Task Status Changed", description: "When a task transitions to a new status", icon: ArrowsClockwise },
|
task_status_changed: { label: "Task Status Changed", description: "When a task transitions to a new status", icon: ArrowsClockwise },
|
||||||
@@ -70,21 +67,7 @@ const actionConfig: Record<AutomationAction, { label: string; description: strin
|
|||||||
run_task: { label: "Run Task", description: "Immediately execute the task with an agent" },
|
run_task: { label: "Run Task", description: "Immediately execute the task with an agent" },
|
||||||
}
|
}
|
||||||
|
|
||||||
const departmentOptions: Array<{ value: string; label: string }> = [
|
const departmentOptions = DEPARTMENT_OPTIONS
|
||||||
{ value: "", label: "Any department" },
|
|
||||||
{ value: "executive", label: "Executive" },
|
|
||||||
{ value: "engineering", label: "Engineering" },
|
|
||||||
{ value: "product", label: "Product" },
|
|
||||||
{ value: "marketing", label: "Marketing" },
|
|
||||||
{ value: "sales", label: "Sales" },
|
|
||||||
{ value: "hr", label: "Human Resources" },
|
|
||||||
{ value: "finance", label: "Finance" },
|
|
||||||
{ value: "legal", label: "Legal" },
|
|
||||||
{ value: "operations", label: "Operations" },
|
|
||||||
{ value: "customer_support", label: "Customer Support" },
|
|
||||||
{ value: "research", label: "Research" },
|
|
||||||
{ value: "design", label: "Design" },
|
|
||||||
]
|
|
||||||
|
|
||||||
const statusColors: Record<AutomationStatus, { bg: string; text: string; dot: string }> = {
|
const statusColors: Record<AutomationStatus, { bg: string; text: string; dot: string }> = {
|
||||||
active: { bg: "var(--success-soft)", text: "var(--success)", dot: "var(--success)" },
|
active: { bg: "var(--success-soft)", text: "var(--success)", dot: "var(--success)" },
|
||||||
@@ -109,25 +92,6 @@ const stepStatusConfig: Record<WorkflowStep["status"], { bg: string; border: str
|
|||||||
skipped: { bg: "var(--panel-soft)", border: "var(--line)", text: "var(--muted)" },
|
skipped: { bg: "var(--panel-soft)", border: "var(--line)", text: "var(--muted)" },
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* Helpers */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
function timeAgo(dateStr: string | null): string {
|
|
||||||
if (!dateStr) return "never"
|
|
||||||
const now = Date.now()
|
|
||||||
const then = new Date(dateStr).getTime()
|
|
||||||
const diff = Math.max(0, now - then)
|
|
||||||
const seconds = Math.floor(diff / 1000)
|
|
||||||
if (seconds < 60) return "just now"
|
|
||||||
const minutes = Math.floor(seconds / 60)
|
|
||||||
if (minutes < 60) return `${minutes}m ago`
|
|
||||||
const hours = Math.floor(minutes / 60)
|
|
||||||
if (hours < 24) return `${hours}h ago`
|
|
||||||
const days = Math.floor(hours / 24)
|
|
||||||
return `${days}d ago`
|
|
||||||
}
|
|
||||||
|
|
||||||
function safeJsonParse(json: string): unknown {
|
function safeJsonParse(json: string): unknown {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(json)
|
return JSON.parse(json)
|
||||||
@@ -145,10 +109,6 @@ function safeJsonStringify(value: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* Automation Log Entry Type */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
interface AutomationLogEntry {
|
interface AutomationLogEntry {
|
||||||
ruleId: string
|
ruleId: string
|
||||||
ruleName: string
|
ruleName: string
|
||||||
@@ -157,10 +117,6 @@ interface AutomationLogEntry {
|
|||||||
executedAt: string
|
executedAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* Automation Rule Card */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
function RuleCard({
|
function RuleCard({
|
||||||
rule,
|
rule,
|
||||||
onToggle,
|
onToggle,
|
||||||
@@ -187,7 +143,6 @@ function RuleCard({
|
|||||||
style={{ borderColor: LINE }}
|
style={{ borderColor: LINE }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3 px-4 py-3.5">
|
<div className="flex items-center gap-3 px-4 py-3.5">
|
||||||
{/* Trigger icon */}
|
|
||||||
<div
|
<div
|
||||||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-[10px]"
|
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-[10px]"
|
||||||
style={{ backgroundColor: `${ACCENT}12`, color: ACCENT }}
|
style={{ backgroundColor: `${ACCENT}12`, color: ACCENT }}
|
||||||
@@ -195,7 +150,6 @@ function RuleCard({
|
|||||||
<TriggerIcon size={20} weight="fill" />
|
<TriggerIcon size={20} weight="fill" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main info */}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onEdit}
|
onClick={onEdit}
|
||||||
@@ -217,18 +171,15 @@ function RuleCard({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-[color:var(--muted)]">
|
<div className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-[color:var(--muted)]">
|
||||||
{/* Trigger label */}
|
|
||||||
<span className="inline-flex items-center gap-1 rounded-[4px] bg-[color:var(--panel-soft)] px-1.5 py-0.5 font-medium text-[color:var(--muted-strong)]">
|
<span className="inline-flex items-center gap-1 rounded-[4px] bg-[color:var(--panel-soft)] px-1.5 py-0.5 font-medium text-[color:var(--muted-strong)]">
|
||||||
<Lightning size={10} weight="fill" />
|
<Lightning size={10} weight="fill" />
|
||||||
{triggerInfo?.label ?? rule.trigger}
|
{triggerInfo?.label ?? rule.trigger}
|
||||||
</span>
|
</span>
|
||||||
<ArrowRight size={10} className="text-[color:var(--muted)]" />
|
<ArrowRight size={10} className="text-[color:var(--muted)]" />
|
||||||
{/* Action label */}
|
|
||||||
<span className="inline-flex items-center gap-1 rounded-[4px] bg-[color:var(--panel-soft)] px-1.5 py-0.5 font-medium text-[color:var(--muted-strong)]">
|
<span className="inline-flex items-center gap-1 rounded-[4px] bg-[color:var(--panel-soft)] px-1.5 py-0.5 font-medium text-[color:var(--muted-strong)]">
|
||||||
<Gear size={10} />
|
<Gear size={10} />
|
||||||
{actionInfo?.label ?? rule.action}
|
{actionInfo?.label ?? rule.action}
|
||||||
</span>
|
</span>
|
||||||
{/* Department flow */}
|
|
||||||
{rule.sourceDepartment || rule.targetDepartment ? (
|
{rule.sourceDepartment || rule.targetDepartment ? (
|
||||||
<>
|
<>
|
||||||
<span className="text-[color:var(--line-strong)]">|</span>
|
<span className="text-[color:var(--line-strong)]">|</span>
|
||||||
@@ -244,7 +195,6 @@ function RuleCard({
|
|||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{/* Execution stats */}
|
|
||||||
<div className="mt-1 flex items-center gap-3 text-[10px] text-[color:var(--muted)]">
|
<div className="mt-1 flex items-center gap-3 text-[10px] text-[color:var(--muted)]">
|
||||||
<span>{rule.executionCount} execution{rule.executionCount !== 1 ? "s" : ""}</span>
|
<span>{rule.executionCount} execution{rule.executionCount !== 1 ? "s" : ""}</span>
|
||||||
<span className="h-0.5 w-0.5 rounded-full bg-[color:var(--muted)]" />
|
<span className="h-0.5 w-0.5 rounded-full bg-[color:var(--muted)]" />
|
||||||
@@ -254,7 +204,6 @@ function RuleCard({
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -280,10 +229,6 @@ function RuleCard({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* Rule Creation / Edit Modal */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
interface RuleFormState {
|
interface RuleFormState {
|
||||||
name: string
|
name: string
|
||||||
description: string
|
description: string
|
||||||
@@ -356,7 +301,6 @@ function RuleFormModal({
|
|||||||
setError("Rule name is required")
|
setError("Rule name is required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Validate JSON fields
|
|
||||||
try { JSON.parse(form.conditionsJson) } catch {
|
try { JSON.parse(form.conditionsJson) } catch {
|
||||||
setError("Conditions must be valid JSON")
|
setError("Conditions must be valid JSON")
|
||||||
return
|
return
|
||||||
@@ -428,7 +372,6 @@ function RuleFormModal({
|
|||||||
style={{ borderColor: LINE, maxHeight: "85vh" }}
|
style={{ borderColor: LINE, maxHeight: "85vh" }}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
|
||||||
<div className="flex shrink-0 items-center justify-between border-b px-6 py-4" style={{ borderColor: LINE }}>
|
<div className="flex shrink-0 items-center justify-between border-b px-6 py-4" style={{ borderColor: LINE }}>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div
|
<div
|
||||||
@@ -451,17 +394,14 @@ function RuleFormModal({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Body */}
|
|
||||||
<div className="overflow-y-auto px-6 py-5">
|
<div className="overflow-y-auto px-6 py-5">
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
{/* Error */}
|
|
||||||
{error ? (
|
{error ? (
|
||||||
<div className="rounded-[8px] bg-[color:var(--danger-soft)] px-3.5 py-2.5 text-[12px] font-medium text-[color:var(--danger)]">
|
<div className="rounded-[8px] bg-[color:var(--danger-soft)] px-3.5 py-2.5 text-[12px] font-medium text-[color:var(--danger)]">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Name */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">
|
<label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">
|
||||||
Rule Name
|
Rule Name
|
||||||
@@ -476,7 +416,6 @@ function RuleFormModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">
|
<label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">
|
||||||
Description
|
Description
|
||||||
@@ -491,7 +430,6 @@ function RuleFormModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Trigger */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">
|
<label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">
|
||||||
Trigger
|
Trigger
|
||||||
@@ -508,7 +446,6 @@ function RuleFormModal({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Conditions */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">
|
<label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">
|
||||||
Conditions (JSON)
|
Conditions (JSON)
|
||||||
@@ -524,7 +461,6 @@ function RuleFormModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">
|
<label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">
|
||||||
Action
|
Action
|
||||||
@@ -541,7 +477,6 @@ function RuleFormModal({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action Config */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">
|
<label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">
|
||||||
Action Configuration (JSON)
|
Action Configuration (JSON)
|
||||||
@@ -557,7 +492,6 @@ function RuleFormModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Department selectors */}
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">
|
<label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">
|
||||||
@@ -581,7 +515,6 @@ function RuleFormModal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Priority slider */}
|
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-1.5 flex items-center justify-between">
|
<div className="mb-1.5 flex items-center justify-between">
|
||||||
<label className="text-[12px] font-medium text-[color:var(--muted-strong)]">
|
<label className="text-[12px] font-medium text-[color:var(--muted-strong)]">
|
||||||
@@ -607,7 +540,6 @@ function RuleFormModal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<div className="flex shrink-0 justify-end gap-3 border-t px-6 py-4" style={{ borderColor: LINE }}>
|
<div className="flex shrink-0 justify-end gap-3 border-t px-6 py-4" style={{ borderColor: LINE }}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -630,10 +562,6 @@ function RuleFormModal({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* Delete Confirmation Dialog */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
function DeleteConfirmDialog({
|
function DeleteConfirmDialog({
|
||||||
open,
|
open,
|
||||||
entityName,
|
entityName,
|
||||||
@@ -693,10 +621,6 @@ function DeleteConfirmDialog({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* Activity Log */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
function AutomationActivityLog({ entries }: { entries: AutomationLogEntry[] }) {
|
function AutomationActivityLog({ entries }: { entries: AutomationLogEntry[] }) {
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) {
|
||||||
return (
|
return (
|
||||||
@@ -743,10 +667,6 @@ function AutomationActivityLog({ entries }: { entries: AutomationLogEntry[] }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* AutomationRulesPanel */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
function AutomationRulesPanel({
|
function AutomationRulesPanel({
|
||||||
rules,
|
rules,
|
||||||
companyId,
|
companyId,
|
||||||
@@ -763,7 +683,6 @@ function AutomationRulesPanel({
|
|||||||
const [activeTab, setActiveTab] = useState<"rules" | "activity">("rules")
|
const [activeTab, setActiveTab] = useState<"rules" | "activity">("rules")
|
||||||
const [filterStatus, setFilterStatus] = useState<AutomationStatus | "all">("all")
|
const [filterStatus, setFilterStatus] = useState<AutomationStatus | "all">("all")
|
||||||
|
|
||||||
// Fetch activity log
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void window.agentCompany.getAutomationLog(companyId).then((result) => {
|
void window.agentCompany.getAutomationLog(companyId).then((result) => {
|
||||||
if (result.ok) setActivityLog(result.data)
|
if (result.ok) setActivityLog(result.data)
|
||||||
@@ -782,29 +701,62 @@ function AutomationRulesPanel({
|
|||||||
error: rules.filter((r) => r.status === "error").length,
|
error: rules.filter((r) => r.status === "error").length,
|
||||||
}), [rules])
|
}), [rules])
|
||||||
|
|
||||||
|
const [actionError, setActionError] = useState<string | null>(null)
|
||||||
|
|
||||||
const handleToggle = useCallback(async (rule: AutomationRuleRecord) => {
|
const handleToggle = useCallback(async (rule: AutomationRuleRecord) => {
|
||||||
|
setActionError(null)
|
||||||
const newStatus = rule.status === "active" ? "paused" : "active"
|
const newStatus = rule.status === "active" ? "paused" : "active"
|
||||||
await window.agentCompany.toggleAutomationRule({
|
try {
|
||||||
id: rule.id,
|
const result = await window.agentCompany.toggleAutomationRule({
|
||||||
companyId,
|
id: rule.id,
|
||||||
status: newStatus as "active" | "paused",
|
companyId,
|
||||||
})
|
status: newStatus as "active" | "paused",
|
||||||
onRefresh()
|
})
|
||||||
|
if (!result.ok) {
|
||||||
|
setActionError(result.error.message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onRefresh()
|
||||||
|
} catch (err) {
|
||||||
|
setActionError(err instanceof Error ? err.message : "Failed to toggle rule")
|
||||||
|
}
|
||||||
}, [companyId, onRefresh])
|
}, [companyId, onRefresh])
|
||||||
|
|
||||||
const handleDelete = useCallback(async () => {
|
const handleDelete = useCallback(async () => {
|
||||||
if (!deleteTarget) return
|
if (!deleteTarget) return
|
||||||
await window.agentCompany.deleteAutomationRule({
|
setActionError(null)
|
||||||
id: deleteTarget.id,
|
try {
|
||||||
companyId,
|
const result = await window.agentCompany.deleteAutomationRule({
|
||||||
})
|
id: deleteTarget.id,
|
||||||
setDeleteTarget(null)
|
companyId,
|
||||||
onRefresh()
|
})
|
||||||
|
if (!result.ok) {
|
||||||
|
setActionError(result.error.message)
|
||||||
|
setDeleteTarget(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setDeleteTarget(null)
|
||||||
|
onRefresh()
|
||||||
|
} catch (err) {
|
||||||
|
setActionError(err instanceof Error ? err.message : "Failed to delete rule")
|
||||||
|
setDeleteTarget(null)
|
||||||
|
}
|
||||||
}, [deleteTarget, companyId, onRefresh])
|
}, [deleteTarget, companyId, onRefresh])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
{/* Header */}
|
{actionError ? (
|
||||||
|
<div className="flex items-center justify-between rounded-[8px] bg-[color:var(--danger-soft)] px-3.5 py-2.5 text-[12px] font-medium text-[color:var(--danger)]">
|
||||||
|
<span>{actionError}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActionError(null)}
|
||||||
|
className="ml-2 rounded p-0.5 hover:bg-[color:var(--danger)] hover:text-white"
|
||||||
|
>
|
||||||
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div
|
<div
|
||||||
@@ -828,7 +780,6 @@ function AutomationRulesPanel({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats bar */}
|
|
||||||
{rules.length > 0 ? (
|
{rules.length > 0 ? (
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
<div className="rounded-[10px] border bg-[color:var(--panel)] px-4 py-3" style={{ borderColor: LINE }}>
|
<div className="rounded-[10px] border bg-[color:var(--panel)] px-4 py-3" style={{ borderColor: LINE }}>
|
||||||
@@ -850,7 +801,6 @@ function AutomationRulesPanel({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Tabs */}
|
|
||||||
<div className="flex gap-1 rounded-[8px] bg-[color:var(--panel-soft)] p-1">
|
<div className="flex gap-1 rounded-[8px] bg-[color:var(--panel-soft)] p-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -876,10 +826,8 @@ function AutomationRulesPanel({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Rules content */}
|
|
||||||
{activeTab === "rules" ? (
|
{activeTab === "rules" ? (
|
||||||
<>
|
<>
|
||||||
{/* Filter */}
|
|
||||||
{rules.length > 0 ? (
|
{rules.length > 0 ? (
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
{(["all", "active", "paused", "error"] as const).map((status) => (
|
{(["all", "active", "paused", "error"] as const).map((status) => (
|
||||||
@@ -900,7 +848,6 @@ function AutomationRulesPanel({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Rule list */}
|
|
||||||
{filteredRules.length === 0 && rules.length === 0 ? (
|
{filteredRules.length === 0 && rules.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center rounded-[10px] border border-dashed py-16 text-center" style={{ borderColor: LINE }}>
|
<div className="flex flex-col items-center justify-center rounded-[10px] border border-dashed py-16 text-center" style={{ borderColor: LINE }}>
|
||||||
<Lightning size={36} className="mb-3 text-[color:var(--muted)]" />
|
<Lightning size={36} className="mb-3 text-[color:var(--muted)]" />
|
||||||
@@ -941,7 +888,6 @@ function AutomationRulesPanel({
|
|||||||
<AutomationActivityLog entries={activityLog} />
|
<AutomationActivityLog entries={activityLog} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Create / Edit modal */}
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{showCreateModal ? (
|
{showCreateModal ? (
|
||||||
<RuleFormModal
|
<RuleFormModal
|
||||||
@@ -963,7 +909,6 @@ function AutomationRulesPanel({
|
|||||||
) : null}
|
) : null}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
{/* Delete confirmation */}
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{deleteTarget ? (
|
{deleteTarget ? (
|
||||||
<DeleteConfirmDialog
|
<DeleteConfirmDialog
|
||||||
@@ -978,10 +923,6 @@ function AutomationRulesPanel({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* Workflow Step Timeline */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
function StepTimeline({ steps }: { steps: WorkflowStep[] }) {
|
function StepTimeline({ steps }: { steps: WorkflowStep[] }) {
|
||||||
if (steps.length === 0) {
|
if (steps.length === 0) {
|
||||||
return (
|
return (
|
||||||
@@ -993,7 +934,6 @@ function StepTimeline({ steps }: { steps: WorkflowStep[] }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative pl-5">
|
<div className="relative pl-5">
|
||||||
{/* Vertical line */}
|
|
||||||
<div
|
<div
|
||||||
className="absolute left-[9px] top-2 w-px"
|
className="absolute left-[9px] top-2 w-px"
|
||||||
style={{
|
style={{
|
||||||
@@ -1009,7 +949,6 @@ function StepTimeline({ steps }: { steps: WorkflowStep[] }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={step.id} className="relative flex items-start gap-3">
|
<div key={step.id} className="relative flex items-start gap-3">
|
||||||
{/* Dot */}
|
|
||||||
<div
|
<div
|
||||||
className="absolute -left-5 top-1.5 z-10 h-[10px] w-[10px] rounded-full border-2"
|
className="absolute -left-5 top-1.5 z-10 h-[10px] w-[10px] rounded-full border-2"
|
||||||
style={{
|
style={{
|
||||||
@@ -1018,7 +957,6 @@ function StepTimeline({ steps }: { steps: WorkflowStep[] }) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div
|
<div
|
||||||
className="flex-1 rounded-[8px] border px-3 py-2.5"
|
className="flex-1 rounded-[8px] border px-3 py-2.5"
|
||||||
style={{ borderColor: step.status === "running" ? ACCENT : LINE }}
|
style={{ borderColor: step.status === "running" ? ACCENT : LINE }}
|
||||||
@@ -1069,18 +1007,18 @@ function StepTimeline({ steps }: { steps: WorkflowStep[] }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* Workflow Card */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
function WorkflowCard({
|
function WorkflowCard({
|
||||||
workflow,
|
workflow,
|
||||||
|
isRunning,
|
||||||
onDelete,
|
onDelete,
|
||||||
onEdit,
|
onEdit,
|
||||||
|
onRun,
|
||||||
}: {
|
}: {
|
||||||
workflow: WorkflowPipelineRecord
|
workflow: WorkflowPipelineRecord
|
||||||
|
isRunning: boolean
|
||||||
onDelete: () => void
|
onDelete: () => void
|
||||||
onEdit: () => void
|
onEdit: () => void
|
||||||
|
onRun: () => void
|
||||||
}) {
|
}) {
|
||||||
const [expanded, setExpanded] = useState(false)
|
const [expanded, setExpanded] = useState(false)
|
||||||
const colors = workflowStatusColors[workflow.status]
|
const colors = workflowStatusColors[workflow.status]
|
||||||
@@ -1102,7 +1040,6 @@ function WorkflowCard({
|
|||||||
style={{ borderColor: LINE }}
|
style={{ borderColor: LINE }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3 px-4 py-3.5">
|
<div className="flex items-center gap-3 px-4 py-3.5">
|
||||||
{/* Icon */}
|
|
||||||
<div
|
<div
|
||||||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-[10px]"
|
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-[10px]"
|
||||||
style={{ backgroundColor: `${ACCENT}12`, color: ACCENT }}
|
style={{ backgroundColor: `${ACCENT}12`, color: ACCENT }}
|
||||||
@@ -1110,7 +1047,6 @@ function WorkflowCard({
|
|||||||
<TriggerIcon size={20} weight="fill" />
|
<TriggerIcon size={20} weight="fill" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main info */}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setExpanded((prev) => !prev)}
|
onClick={() => setExpanded((prev) => !prev)}
|
||||||
@@ -1149,7 +1085,6 @@ function WorkflowCard({
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
<motion.div
|
<motion.div
|
||||||
animate={{ rotate: expanded ? 90 : 0 }}
|
animate={{ rotate: expanded ? 90 : 0 }}
|
||||||
@@ -1164,6 +1099,16 @@ function WorkflowCard({
|
|||||||
<CaretRight size={14} />
|
<CaretRight size={14} />
|
||||||
</button>
|
</button>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRun}
|
||||||
|
aria-label="Run workflow"
|
||||||
|
title="Run workflow"
|
||||||
|
disabled={isRunning}
|
||||||
|
className="rounded-[6px] p-2 text-[color:var(--muted)] transition hover:bg-[color:var(--success-soft)] hover:text-[color:var(--success)] disabled:pointer-events-none disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isRunning ? <ArrowsClockwise size={15} className="animate-spin" /> : <Play size={15} />}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onEdit}
|
onClick={onEdit}
|
||||||
@@ -1185,7 +1130,6 @@ function WorkflowCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Expanded: step timeline */}
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{expanded ? (
|
{expanded ? (
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -1210,10 +1154,6 @@ function WorkflowCard({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* Workflow Creation Modal */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
interface WorkflowFormState {
|
interface WorkflowFormState {
|
||||||
name: string
|
name: string
|
||||||
description: string
|
description: string
|
||||||
@@ -1342,7 +1282,6 @@ function WorkflowFormModal({
|
|||||||
style={{ borderColor: LINE, maxHeight: "85vh" }}
|
style={{ borderColor: LINE, maxHeight: "85vh" }}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
|
||||||
<div className="flex shrink-0 items-center justify-between border-b px-6 py-4" style={{ borderColor: LINE }}>
|
<div className="flex shrink-0 items-center justify-between border-b px-6 py-4" style={{ borderColor: LINE }}>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div
|
<div
|
||||||
@@ -1365,7 +1304,6 @@ function WorkflowFormModal({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Body */}
|
|
||||||
<div className="overflow-y-auto px-6 py-5">
|
<div className="overflow-y-auto px-6 py-5">
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
{error ? (
|
{error ? (
|
||||||
@@ -1374,7 +1312,6 @@ function WorkflowFormModal({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Name */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">
|
<label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">
|
||||||
Workflow Name
|
Workflow Name
|
||||||
@@ -1389,7 +1326,6 @@ function WorkflowFormModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">
|
<label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">
|
||||||
Description
|
Description
|
||||||
@@ -1404,7 +1340,6 @@ function WorkflowFormModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Trigger + Status */}
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">
|
<label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">
|
||||||
@@ -1428,7 +1363,6 @@ function WorkflowFormModal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Trigger Config */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">
|
<label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">
|
||||||
Trigger Configuration (JSON)
|
Trigger Configuration (JSON)
|
||||||
@@ -1444,7 +1378,6 @@ function WorkflowFormModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Steps */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">
|
<label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">
|
||||||
Pipeline Steps (JSON Array)
|
Pipeline Steps (JSON Array)
|
||||||
@@ -1474,7 +1407,6 @@ function WorkflowFormModal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<div className="flex shrink-0 justify-end gap-3 border-t px-6 py-4" style={{ borderColor: LINE }}>
|
<div className="flex shrink-0 justify-end gap-3 border-t px-6 py-4" style={{ borderColor: LINE }}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1497,10 +1429,6 @@ function WorkflowFormModal({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* WorkflowsPanel */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
function WorkflowsPanel({
|
function WorkflowsPanel({
|
||||||
workflows,
|
workflows,
|
||||||
companyId,
|
companyId,
|
||||||
@@ -1513,17 +1441,50 @@ function WorkflowsPanel({
|
|||||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||||
const [editingWorkflow, setEditingWorkflow] = useState<WorkflowPipelineRecord | null>(null)
|
const [editingWorkflow, setEditingWorkflow] = useState<WorkflowPipelineRecord | null>(null)
|
||||||
const [deleteTarget, setDeleteTarget] = useState<WorkflowPipelineRecord | null>(null)
|
const [deleteTarget, setDeleteTarget] = useState<WorkflowPipelineRecord | null>(null)
|
||||||
|
const [actionError, setActionError] = useState<string | null>(null)
|
||||||
|
const [runningId, setRunningId] = useState<string | null>(null)
|
||||||
|
|
||||||
const handleDelete = useCallback(async () => {
|
const handleDelete = useCallback(async () => {
|
||||||
if (!deleteTarget) return
|
if (!deleteTarget) return
|
||||||
await window.agentCompany.deleteWorkflow({
|
setActionError(null)
|
||||||
id: deleteTarget.id,
|
try {
|
||||||
companyId,
|
const result = await window.agentCompany.deleteWorkflow({
|
||||||
})
|
id: deleteTarget.id,
|
||||||
setDeleteTarget(null)
|
companyId,
|
||||||
onRefresh()
|
})
|
||||||
|
if (!result.ok) {
|
||||||
|
setActionError(result.error.message)
|
||||||
|
setDeleteTarget(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setDeleteTarget(null)
|
||||||
|
onRefresh()
|
||||||
|
} catch (err) {
|
||||||
|
setActionError(err instanceof Error ? err.message : "Failed to delete workflow")
|
||||||
|
setDeleteTarget(null)
|
||||||
|
}
|
||||||
}, [deleteTarget, companyId, onRefresh])
|
}, [deleteTarget, companyId, onRefresh])
|
||||||
|
|
||||||
|
const handleRun = useCallback(async (workflow: WorkflowPipelineRecord) => {
|
||||||
|
setActionError(null)
|
||||||
|
setRunningId(workflow.id)
|
||||||
|
try {
|
||||||
|
const result = await window.agentCompany.runWorkflow({
|
||||||
|
id: workflow.id,
|
||||||
|
companyId,
|
||||||
|
})
|
||||||
|
if (!result.ok) {
|
||||||
|
setActionError(result.error.message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onRefresh()
|
||||||
|
} catch (err) {
|
||||||
|
setActionError(err instanceof Error ? err.message : "Failed to run workflow")
|
||||||
|
} finally {
|
||||||
|
setRunningId(null)
|
||||||
|
}
|
||||||
|
}, [companyId, onRefresh])
|
||||||
|
|
||||||
const counts = useMemo(() => ({
|
const counts = useMemo(() => ({
|
||||||
total: workflows.length,
|
total: workflows.length,
|
||||||
active: workflows.filter((w) => w.status === "active").length,
|
active: workflows.filter((w) => w.status === "active").length,
|
||||||
@@ -1532,7 +1493,18 @@ function WorkflowsPanel({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
{/* Header */}
|
{actionError ? (
|
||||||
|
<div className="flex items-center justify-between rounded-[8px] bg-[color:var(--danger-soft)] px-3.5 py-2.5 text-[12px] font-medium text-[color:var(--danger)]">
|
||||||
|
<span>{actionError}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActionError(null)}
|
||||||
|
className="ml-2 rounded p-0.5 hover:bg-[color:var(--danger)] hover:text-white"
|
||||||
|
>
|
||||||
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div
|
<div
|
||||||
@@ -1556,7 +1528,6 @@ function WorkflowsPanel({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Workflow list */}
|
|
||||||
{workflows.length === 0 ? (
|
{workflows.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center rounded-[10px] border border-dashed py-16 text-center" style={{ borderColor: LINE }}>
|
<div className="flex flex-col items-center justify-center rounded-[10px] border border-dashed py-16 text-center" style={{ borderColor: LINE }}>
|
||||||
<ArrowsClockwise size={36} className="mb-3 text-[color:var(--muted)]" />
|
<ArrowsClockwise size={36} className="mb-3 text-[color:var(--muted)]" />
|
||||||
@@ -1580,15 +1551,16 @@ function WorkflowsPanel({
|
|||||||
<WorkflowCard
|
<WorkflowCard
|
||||||
key={wf.id}
|
key={wf.id}
|
||||||
workflow={wf}
|
workflow={wf}
|
||||||
|
isRunning={runningId === wf.id}
|
||||||
onDelete={() => setDeleteTarget(wf)}
|
onDelete={() => setDeleteTarget(wf)}
|
||||||
onEdit={() => setEditingWorkflow(wf)}
|
onEdit={() => setEditingWorkflow(wf)}
|
||||||
|
onRun={() => void handleRun(wf)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Create / Edit modal */}
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{showCreateModal ? (
|
{showCreateModal ? (
|
||||||
<WorkflowFormModal
|
<WorkflowFormModal
|
||||||
@@ -1610,7 +1582,6 @@ function WorkflowsPanel({
|
|||||||
) : null}
|
) : null}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
{/* Delete confirmation */}
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{deleteTarget ? (
|
{deleteTarget ? (
|
||||||
<DeleteConfirmDialog
|
<DeleteConfirmDialog
|
||||||
@@ -1625,14 +1596,9 @@ function WorkflowsPanel({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* Main Automation Panel (exported) */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
interface AutomationPanelProps {
|
interface AutomationPanelProps {
|
||||||
rules: AutomationRuleRecord[]
|
rules: AutomationRuleRecord[]
|
||||||
workflows: WorkflowPipelineRecord[]
|
workflows: WorkflowPipelineRecord[]
|
||||||
agents: AgentRecord[]
|
|
||||||
companyId: string
|
companyId: string
|
||||||
onRefresh: () => void
|
onRefresh: () => void
|
||||||
}
|
}
|
||||||
@@ -1640,7 +1606,6 @@ interface AutomationPanelProps {
|
|||||||
export function AutomationPanel({
|
export function AutomationPanel({
|
||||||
rules,
|
rules,
|
||||||
workflows,
|
workflows,
|
||||||
agents,
|
|
||||||
companyId,
|
companyId,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
}: AutomationPanelProps) {
|
}: AutomationPanelProps) {
|
||||||
@@ -1648,7 +1613,6 @@ export function AutomationPanel({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Top banner */}
|
|
||||||
<div
|
<div
|
||||||
className="rounded-[12px] border px-5 py-4"
|
className="rounded-[12px] border px-5 py-4"
|
||||||
style={{
|
style={{
|
||||||
@@ -1674,7 +1638,6 @@ export function AutomationPanel({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Section tabs */}
|
|
||||||
<div className="flex gap-1 rounded-[10px] bg-[color:var(--panel-soft)] p-1">
|
<div className="flex gap-1 rounded-[10px] bg-[color:var(--panel-soft)] p-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1702,7 +1665,6 @@ export function AutomationPanel({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
<motion.div
|
<motion.div
|
||||||
key={activeSection}
|
key={activeSection}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
EnvelopeSimple,
|
EnvelopeSimple,
|
||||||
At,
|
At,
|
||||||
Hash,
|
Hash,
|
||||||
UserCircle,
|
|
||||||
PaperPlaneTilt,
|
PaperPlaneTilt,
|
||||||
MagnifyingGlass,
|
MagnifyingGlass,
|
||||||
Funnel,
|
Funnel,
|
||||||
@@ -19,21 +18,20 @@ import {
|
|||||||
Plus,
|
Plus,
|
||||||
X,
|
X,
|
||||||
CaretDown,
|
CaretDown,
|
||||||
Robot,
|
|
||||||
Tray,
|
Tray,
|
||||||
} from "@phosphor-icons/react"
|
} from "@phosphor-icons/react"
|
||||||
import type {
|
import type {
|
||||||
AgentMessageRecord,
|
AgentMessageRecord,
|
||||||
AgentRecord,
|
AgentRecord,
|
||||||
|
CommentRecord,
|
||||||
DepartmentId,
|
DepartmentId,
|
||||||
MessageChannel,
|
MessageChannel,
|
||||||
MessagePriority,
|
MessagePriority,
|
||||||
|
TaskRecord,
|
||||||
} from "@shared/types"
|
} from "@shared/types"
|
||||||
import { ActionButton, Modal, Select, Input, TextArea } from "./ui"
|
import { getAgentInitials, formatMessageTime } from "../lib/formatters"
|
||||||
|
import { DEPARTMENT_COLORS } from "@shared/constants"
|
||||||
/* ------------------------------------------------------------------ */
|
import { Modal, Select, Input, TextArea } from "./ui"
|
||||||
/* Constants & Config */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
const TERRACOTTA = "#d97745"
|
const TERRACOTTA = "#d97745"
|
||||||
const TERRACOTTA_SOFT = "#d9774518"
|
const TERRACOTTA_SOFT = "#d9774518"
|
||||||
@@ -52,83 +50,14 @@ const priorityConfig: Record<MessagePriority, { label: string; color: string; bg
|
|||||||
low: { label: "Low", color: "var(--muted)", bg: "var(--panel-soft)" },
|
low: { label: "Low", color: "var(--muted)", bg: "var(--panel-soft)" },
|
||||||
}
|
}
|
||||||
|
|
||||||
const departmentColors: Record<string, string> = {
|
|
||||||
executive: "#8b5cf6",
|
|
||||||
engineering: "#3b82f6",
|
|
||||||
product: "#f59e0b",
|
|
||||||
marketing: "#ec4899",
|
|
||||||
sales: "#10b981",
|
|
||||||
hr: "#6366f1",
|
|
||||||
finance: "#14b8a6",
|
|
||||||
legal: "#a78bfa",
|
|
||||||
operations: "#f97316",
|
|
||||||
customer_support: "#06b6d4",
|
|
||||||
research: "#8b5cf6",
|
|
||||||
design: "#f43f5e",
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDepartmentColor(department: DepartmentId | null | undefined): string {
|
function getDepartmentColor(department: DepartmentId | null | undefined): string {
|
||||||
if (!department) return "var(--muted)"
|
if (!department) return "var(--muted)"
|
||||||
return departmentColors[department] ?? "var(--muted)"
|
return DEPARTMENT_COLORS[department] ?? "var(--muted)"
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAgentInitials(name: string): string {
|
|
||||||
return name
|
|
||||||
.split(/\s+/)
|
|
||||||
.slice(0, 2)
|
|
||||||
.map((w) => w[0])
|
|
||||||
.join("")
|
|
||||||
.toUpperCase()
|
|
||||||
}
|
|
||||||
|
|
||||||
function timeAgo(dateStr: string): string {
|
|
||||||
const now = Date.now()
|
|
||||||
const then = new Date(dateStr).getTime()
|
|
||||||
const diff = Math.max(0, now - then)
|
|
||||||
const seconds = Math.floor(diff / 1000)
|
|
||||||
if (seconds < 60) return "just now"
|
|
||||||
const minutes = Math.floor(seconds / 60)
|
|
||||||
if (minutes < 60) return `${minutes}m ago`
|
|
||||||
const hours = Math.floor(minutes / 60)
|
|
||||||
if (hours < 24) return `${hours}h ago`
|
|
||||||
const days = Math.floor(hours / 24)
|
|
||||||
return `${days}d ago`
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatMessageTime(dateStr: string): string {
|
|
||||||
const d = new Date(dateStr)
|
|
||||||
const now = new Date()
|
|
||||||
const isToday =
|
|
||||||
d.getDate() === now.getDate() &&
|
|
||||||
d.getMonth() === now.getMonth() &&
|
|
||||||
d.getFullYear() === now.getFullYear()
|
|
||||||
|
|
||||||
if (isToday) {
|
|
||||||
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
|
||||||
}
|
|
||||||
|
|
||||||
const yesterday = new Date(now)
|
|
||||||
yesterday.setDate(yesterday.getDate() - 1)
|
|
||||||
const isYesterday =
|
|
||||||
d.getDate() === yesterday.getDate() &&
|
|
||||||
d.getMonth() === yesterday.getMonth() &&
|
|
||||||
d.getFullYear() === yesterday.getFullYear()
|
|
||||||
|
|
||||||
if (isYesterday) {
|
|
||||||
return `Yesterday ${d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`
|
|
||||||
}
|
|
||||||
|
|
||||||
return d.toLocaleDateString([], { month: "short", day: "numeric" }) +
|
|
||||||
" " +
|
|
||||||
d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* Channel Derivation */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
interface ChannelDescriptor {
|
interface ChannelDescriptor {
|
||||||
key: string
|
key: string
|
||||||
|
kind: "message"
|
||||||
channel: MessageChannel
|
channel: MessageChannel
|
||||||
label: string
|
label: string
|
||||||
targetId: string | null
|
targetId: string | null
|
||||||
@@ -136,6 +65,13 @@ interface ChannelDescriptor {
|
|||||||
agentId: string | null
|
agentId: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TaskDiscussionDescriptor {
|
||||||
|
key: string
|
||||||
|
kind: "task"
|
||||||
|
taskId: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
function deriveChannels(
|
function deriveChannels(
|
||||||
messages: AgentMessageRecord[],
|
messages: AgentMessageRecord[],
|
||||||
agents: AgentRecord[],
|
agents: AgentRecord[],
|
||||||
@@ -143,6 +79,37 @@ function deriveChannels(
|
|||||||
const channelMap = new Map<string, ChannelDescriptor>()
|
const channelMap = new Map<string, ChannelDescriptor>()
|
||||||
const agentMap = new Map(agents.map((a) => [a.id, a]))
|
const agentMap = new Map(agents.map((a) => [a.id, a]))
|
||||||
|
|
||||||
|
// Always create company-wide channel
|
||||||
|
channelMap.set("company-all", {
|
||||||
|
key: "company-all",
|
||||||
|
kind: "message",
|
||||||
|
channel: "company",
|
||||||
|
label: "#company-wide",
|
||||||
|
targetId: null,
|
||||||
|
agentId: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create permanent department channels for all departments that have agents
|
||||||
|
const activeDepts = new Set<string>()
|
||||||
|
for (const agent of agents) {
|
||||||
|
if (agent.department && agent.status !== "terminated") {
|
||||||
|
activeDepts.add(agent.department)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const dept of activeDepts) {
|
||||||
|
const key = `department-${dept}`
|
||||||
|
const deptLabel = dept.replace(/_/g, "-")
|
||||||
|
channelMap.set(key, {
|
||||||
|
key,
|
||||||
|
kind: "message",
|
||||||
|
channel: "department",
|
||||||
|
label: `#${deptLabel}`,
|
||||||
|
targetId: dept,
|
||||||
|
agentId: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add channels derived from existing messages (DMs, projects, incidents)
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
let key: string
|
let key: string
|
||||||
let label: string
|
let label: string
|
||||||
@@ -166,7 +133,7 @@ function deriveChannels(
|
|||||||
case "department": {
|
case "department": {
|
||||||
const dept = msg.channelTargetId ?? "general"
|
const dept = msg.channelTargetId ?? "general"
|
||||||
key = `department-${dept}`
|
key = `department-${dept}`
|
||||||
label = `#${dept.replace("_", "-")}`
|
label = `#${dept.replace(/_/g, "-")}`
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "company": {
|
case "company": {
|
||||||
@@ -191,6 +158,7 @@ function deriveChannels(
|
|||||||
if (!channelMap.has(key)) {
|
if (!channelMap.has(key)) {
|
||||||
channelMap.set(key, {
|
channelMap.set(key, {
|
||||||
key,
|
key,
|
||||||
|
kind: "message",
|
||||||
channel: msg.channel,
|
channel: msg.channel,
|
||||||
label,
|
label,
|
||||||
targetId: msg.channelTargetId,
|
targetId: msg.channelTargetId,
|
||||||
@@ -199,17 +167,6 @@ function deriveChannels(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure at least one company-wide channel exists
|
|
||||||
if (!channelMap.has("company-all")) {
|
|
||||||
channelMap.set("company-all", {
|
|
||||||
key: "company-all",
|
|
||||||
channel: "company",
|
|
||||||
label: "#company-wide",
|
|
||||||
targetId: null,
|
|
||||||
agentId: null,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const order: MessageChannel[] = ["company", "department", "project", "incident", "direct"]
|
const order: MessageChannel[] = ["company", "department", "project", "incident", "direct"]
|
||||||
return Array.from(channelMap.values()).sort((a, b) => {
|
return Array.from(channelMap.values()).sort((a, b) => {
|
||||||
const ai = order.indexOf(a.channel)
|
const ai = order.indexOf(a.channel)
|
||||||
@@ -219,6 +176,25 @@ function deriveChannels(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function deriveTaskDiscussionChannels(
|
||||||
|
comments: CommentRecord[],
|
||||||
|
tasks: TaskRecord[],
|
||||||
|
): TaskDiscussionDescriptor[] {
|
||||||
|
const taskMap = new Map(tasks.map((task) => [task.id, task]))
|
||||||
|
const discussedTaskIds = new Set(comments.map((comment) => comment.taskId))
|
||||||
|
return Array.from(discussedTaskIds)
|
||||||
|
.map((taskId) => {
|
||||||
|
const task = taskMap.get(taskId)
|
||||||
|
return {
|
||||||
|
key: `task-${taskId}`,
|
||||||
|
kind: "task" as const,
|
||||||
|
taskId,
|
||||||
|
label: task?.title ?? `Task ${taskId.slice(0, 8)}`,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.label.localeCompare(b.label))
|
||||||
|
}
|
||||||
|
|
||||||
function getChannelMessages(
|
function getChannelMessages(
|
||||||
messages: AgentMessageRecord[],
|
messages: AgentMessageRecord[],
|
||||||
channel: ChannelDescriptor,
|
channel: ChannelDescriptor,
|
||||||
@@ -244,10 +220,6 @@ function getChannelMessages(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* Agent Avatar */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
function AgentAvatar({
|
function AgentAvatar({
|
||||||
agent,
|
agent,
|
||||||
size = "md",
|
size = "md",
|
||||||
@@ -269,10 +241,6 @@ function AgentAvatar({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* Channel Sidebar Item */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
function ChannelItem({
|
function ChannelItem({
|
||||||
descriptor,
|
descriptor,
|
||||||
active,
|
active,
|
||||||
@@ -346,36 +314,23 @@ function ChannelItem({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* Message Bubble */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
function MessageBubble({
|
function MessageBubble({
|
||||||
message,
|
message,
|
||||||
agent,
|
agent,
|
||||||
isThread,
|
isThread,
|
||||||
replyCount,
|
replyCount,
|
||||||
onReply,
|
onReply,
|
||||||
onMarkRead,
|
|
||||||
}: {
|
}: {
|
||||||
message: AgentMessageRecord
|
message: AgentMessageRecord
|
||||||
agent: AgentRecord | undefined
|
agent: AgentRecord | undefined
|
||||||
isThread: boolean
|
isThread: boolean
|
||||||
replyCount: number
|
replyCount: number
|
||||||
onReply: () => void
|
onReply: () => void
|
||||||
onMarkRead: () => void
|
|
||||||
}) {
|
}) {
|
||||||
const isUnread = !message.readAt
|
const isUnread = !message.readAt
|
||||||
const isUrgent = message.priority === "urgent"
|
const isUrgent = message.priority === "urgent"
|
||||||
const deptColor = agent ? getDepartmentColor(agent.department) : "var(--muted)"
|
const deptColor = agent ? getDepartmentColor(agent.department) : "var(--muted)"
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isUnread) {
|
|
||||||
const timer = setTimeout(onMarkRead, 1500)
|
|
||||||
return () => clearTimeout(timer)
|
|
||||||
}
|
|
||||||
}, [isUnread, onMarkRead])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 6 }}
|
initial={{ opacity: 0, y: 6 }}
|
||||||
@@ -466,14 +421,12 @@ function MessageBubble({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Subject */}
|
|
||||||
{message.subject ? (
|
{message.subject ? (
|
||||||
<div className="mt-0.5 text-[13px] font-medium text-[color:var(--text)]">
|
<div className="mt-0.5 text-[13px] font-medium text-[color:var(--text)]">
|
||||||
{message.subject}
|
{message.subject}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Body */}
|
|
||||||
<div className="mt-1 whitespace-pre-wrap text-[12px] leading-relaxed text-[color:var(--muted-strong)]">
|
<div className="mt-1 whitespace-pre-wrap text-[12px] leading-relaxed text-[color:var(--muted-strong)]">
|
||||||
{message.body}
|
{message.body}
|
||||||
</div>
|
</div>
|
||||||
@@ -510,9 +463,102 @@ function MessageBubble({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
function TaskCommentBubble({
|
||||||
/* Attachment List */
|
comment,
|
||||||
/* ------------------------------------------------------------------ */
|
agent,
|
||||||
|
}: {
|
||||||
|
comment: CommentRecord
|
||||||
|
agent: AgentRecord | undefined
|
||||||
|
}) {
|
||||||
|
const isBoard = !comment.authorAgentId
|
||||||
|
const deptColor = agent ? getDepartmentColor(agent.department) : "var(--muted)"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 6 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.18 }}
|
||||||
|
className="group relative flex gap-3 rounded-[10px] border-l-[3px] bg-[color:var(--panel)] px-4 py-3"
|
||||||
|
style={{ borderLeftColor: isBoard ? TERRACOTTA : deptColor }}
|
||||||
|
>
|
||||||
|
<AgentAvatar agent={agent} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[13px] font-semibold" style={{ color: isBoard ? TERRACOTTA : deptColor }}>
|
||||||
|
{comment.authorName}
|
||||||
|
</span>
|
||||||
|
{agent?.title ? <span className="text-[10px] text-[color:var(--muted)]">{agent.title}</span> : null}
|
||||||
|
<span className="text-[10px] text-[color:var(--muted)]">{formatMessageTime(comment.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 whitespace-pre-wrap text-[12px] leading-relaxed text-[color:var(--muted-strong)]">
|
||||||
|
{comment.body}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TaskDiscussionComposer({
|
||||||
|
companyId,
|
||||||
|
taskId,
|
||||||
|
onSent,
|
||||||
|
}: {
|
||||||
|
companyId: string
|
||||||
|
taskId: string
|
||||||
|
onSent: () => void
|
||||||
|
}) {
|
||||||
|
const [body, setBody] = useState("")
|
||||||
|
const [sending, setSending] = useState(false)
|
||||||
|
|
||||||
|
const handleSend = useCallback(async () => {
|
||||||
|
if (!body.trim()) return
|
||||||
|
setSending(true)
|
||||||
|
try {
|
||||||
|
await window.agentCompany.addComment({
|
||||||
|
companyId,
|
||||||
|
taskId,
|
||||||
|
authorName: "Board",
|
||||||
|
body: body.trim(),
|
||||||
|
})
|
||||||
|
setBody("")
|
||||||
|
onSent()
|
||||||
|
} finally {
|
||||||
|
setSending(false)
|
||||||
|
}
|
||||||
|
}, [body, companyId, taskId, onSent])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-t border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-3">
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<textarea
|
||||||
|
value={body}
|
||||||
|
onChange={(e) => setBody(e.target.value)}
|
||||||
|
placeholder="Reply in task discussion..."
|
||||||
|
rows={2}
|
||||||
|
className="min-h-[40px] min-w-0 flex-1 resize-none rounded-[8px] border border-[color:var(--line)] bg-[color:var(--panel-soft)] px-3 py-2 text-[12px] text-[color:var(--text)] placeholder:text-[color:var(--muted)] focus:border-transparent focus:outline-none focus:ring-2"
|
||||||
|
style={{ focusRingColor: TERRACOTTA } as React.CSSProperties}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleSend()}
|
||||||
|
disabled={!body.trim() || sending}
|
||||||
|
className="flex h-[40px] w-[40px] shrink-0 items-center justify-center rounded-[8px] text-white transition hover:brightness-90 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none"
|
||||||
|
style={{ backgroundColor: TERRACOTTA }}
|
||||||
|
aria-label="Send task comment"
|
||||||
|
>
|
||||||
|
{sending ? (
|
||||||
|
<svg className="h-4 w-4 animate-spin" viewBox="0 0 16 16" fill="none">
|
||||||
|
<circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="2" opacity="0.3" />
|
||||||
|
<path d="M14 8a6 6 0 0 0-6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<PaperPlaneTilt size={18} weight="fill" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function AttachmentList({ json }: { json: string }) {
|
function AttachmentList({ json }: { json: string }) {
|
||||||
let items: Array<{ type: string; id: string; title: string }> = []
|
let items: Array<{ type: string; id: string; title: string }> = []
|
||||||
@@ -538,10 +584,6 @@ function AttachmentList({ json }: { json: string }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* Message Composer */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
interface MessageComposerProps {
|
interface MessageComposerProps {
|
||||||
agents: AgentRecord[]
|
agents: AgentRecord[]
|
||||||
companyId: string
|
companyId: string
|
||||||
@@ -595,7 +637,7 @@ function MessageComposer({
|
|||||||
setSubject("")
|
setSubject("")
|
||||||
onSent()
|
onSent()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[MessageComposer] send failed:", err)
|
void err
|
||||||
} finally {
|
} finally {
|
||||||
setSending(false)
|
setSending(false)
|
||||||
}
|
}
|
||||||
@@ -615,7 +657,6 @@ function MessageComposer({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-t border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-3">
|
<div className="border-t border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-3">
|
||||||
{/* Reply indicator */}
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{replyTo ? (
|
{replyTo ? (
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -640,7 +681,6 @@ function MessageComposer({
|
|||||||
) : null}
|
) : null}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
{/* Sender selector row */}
|
|
||||||
<div className="mb-2 flex items-center gap-2">
|
<div className="mb-2 flex items-center gap-2">
|
||||||
<AgentAvatar agent={fromAgent} size="sm" />
|
<AgentAvatar agent={fromAgent} size="sm" />
|
||||||
<select
|
<select
|
||||||
@@ -665,7 +705,6 @@ function MessageComposer({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Body input */}
|
|
||||||
<div className="flex items-end gap-2">
|
<div className="flex items-end gap-2">
|
||||||
<textarea
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
@@ -702,10 +741,6 @@ function MessageComposer({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* Compose New Message Modal */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
interface ComposeModalProps {
|
interface ComposeModalProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
@@ -748,14 +783,13 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal
|
|||||||
parentMessageId: null,
|
parentMessageId: null,
|
||||||
attachmentsJson: "[]",
|
attachmentsJson: "[]",
|
||||||
})
|
})
|
||||||
// Reset form
|
|
||||||
setSubject("")
|
setSubject("")
|
||||||
setBody("")
|
setBody("")
|
||||||
setPriority("normal")
|
setPriority("normal")
|
||||||
onSent()
|
onSent()
|
||||||
onClose()
|
onClose()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[ComposeModal] send failed:", err)
|
void err
|
||||||
} finally {
|
} finally {
|
||||||
setSending(false)
|
setSending(false)
|
||||||
}
|
}
|
||||||
@@ -764,7 +798,6 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal
|
|||||||
return (
|
return (
|
||||||
<Modal open={open} title="Compose Message" onClose={onClose} width="560px">
|
<Modal open={open} title="Compose Message" onClose={onClose} width="560px">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* From */}
|
|
||||||
<Select
|
<Select
|
||||||
label="From Agent"
|
label="From Agent"
|
||||||
value={fromAgentId}
|
value={fromAgentId}
|
||||||
@@ -775,7 +808,6 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal
|
|||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Channel type */}
|
|
||||||
<Select
|
<Select
|
||||||
label="Channel"
|
label="Channel"
|
||||||
value={channel}
|
value={channel}
|
||||||
@@ -789,7 +821,6 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Channel target */}
|
|
||||||
{channel === "direct" ? (
|
{channel === "direct" ? (
|
||||||
<Select
|
<Select
|
||||||
label="To Agent"
|
label="To Agent"
|
||||||
@@ -827,7 +858,6 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Subject */}
|
|
||||||
<Input
|
<Input
|
||||||
label="Subject"
|
label="Subject"
|
||||||
value={subject}
|
value={subject}
|
||||||
@@ -835,7 +865,6 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal
|
|||||||
placeholder="Message subject"
|
placeholder="Message subject"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Body */}
|
|
||||||
<TextArea
|
<TextArea
|
||||||
label="Message"
|
label="Message"
|
||||||
value={body}
|
value={body}
|
||||||
@@ -844,7 +873,6 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal
|
|||||||
rows={5}
|
rows={5}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Priority */}
|
|
||||||
<Select
|
<Select
|
||||||
label="Priority"
|
label="Priority"
|
||||||
value={priority}
|
value={priority}
|
||||||
@@ -856,7 +884,6 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex justify-end gap-3 pt-2">
|
<div className="flex justify-end gap-3 pt-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -888,10 +915,6 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* Search & Filter Bar */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
interface FilterState {
|
interface FilterState {
|
||||||
search: string
|
search: string
|
||||||
channelType: MessageChannel | "all"
|
channelType: MessageChannel | "all"
|
||||||
@@ -1030,27 +1053,29 @@ function FilterBar({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* Main: CommunicationHub */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
interface CommunicationHubProps {
|
interface CommunicationHubProps {
|
||||||
messages: AgentMessageRecord[]
|
messages: AgentMessageRecord[]
|
||||||
|
comments: CommentRecord[]
|
||||||
|
tasks: TaskRecord[]
|
||||||
agents: AgentRecord[]
|
agents: AgentRecord[]
|
||||||
companyId: string
|
companyId: string
|
||||||
|
selectedEntityId?: string | null
|
||||||
onRefresh: () => void
|
onRefresh: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CommunicationHub({
|
export function CommunicationHub({
|
||||||
messages,
|
messages,
|
||||||
|
comments,
|
||||||
|
tasks,
|
||||||
agents,
|
agents,
|
||||||
companyId,
|
companyId,
|
||||||
|
selectedEntityId,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
}: CommunicationHubProps) {
|
}: CommunicationHubProps) {
|
||||||
const [selectedChannelKey, setSelectedChannelKey] = useState<string | null>(null)
|
const [selectedChannelKey, setSelectedChannelKey] = useState<string | null>(null)
|
||||||
const [replyTo, setReplyTo] = useState<AgentMessageRecord | null>(null)
|
|
||||||
const [showCompose, setShowCompose] = useState(false)
|
|
||||||
const [showSidebar, setShowSidebar] = useState(true)
|
const [showSidebar, setShowSidebar] = useState(true)
|
||||||
|
const [showCompose, setShowCompose] = useState(false)
|
||||||
|
const [replyTo, setReplyTo] = useState<AgentMessageRecord | null>(null)
|
||||||
const [filters, setFilters] = useState<FilterState>({
|
const [filters, setFilters] = useState<FilterState>({
|
||||||
search: "",
|
search: "",
|
||||||
channelType: "all",
|
channelType: "all",
|
||||||
@@ -1062,7 +1087,9 @@ export function CommunicationHub({
|
|||||||
const agentMap = useMemo(() => new Map(agents.map((a) => [a.id, a])), [agents])
|
const agentMap = useMemo(() => new Map(agents.map((a) => [a.id, a])), [agents])
|
||||||
|
|
||||||
// Derive channels from messages
|
// Derive channels from messages
|
||||||
const channels = useMemo(() => deriveChannels(messages, agents), [messages, agents])
|
const messageChannels = useMemo(() => deriveChannels(messages, agents), [messages, agents])
|
||||||
|
const taskDiscussionChannels = useMemo(() => deriveTaskDiscussionChannels(comments, tasks), [comments, tasks])
|
||||||
|
const channels = useMemo(() => [...messageChannels, ...taskDiscussionChannels], [messageChannels, taskDiscussionChannels])
|
||||||
|
|
||||||
// Auto-select first channel
|
// Auto-select first channel
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1071,6 +1098,27 @@ export function CommunicationHub({
|
|||||||
}
|
}
|
||||||
}, [channels, selectedChannelKey])
|
}, [channels, selectedChannelKey])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedEntityId) return
|
||||||
|
const matchingMessage = messages.find((message) => message.id === selectedEntityId)
|
||||||
|
if (matchingMessage) {
|
||||||
|
const matchingChannel = messageChannels.find((channel) => getChannelMessages(messages, channel).some((message) => message.id === selectedEntityId))
|
||||||
|
if (matchingChannel) {
|
||||||
|
setSelectedChannelKey(matchingChannel.key)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const matchingComment = comments.find((comment) => comment.id === selectedEntityId)
|
||||||
|
if (matchingComment) {
|
||||||
|
setSelectedChannelKey(`task-${matchingComment.taskId}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const matchingTaskDiscussion = taskDiscussionChannels.find((discussion) => discussion.taskId === selectedEntityId)
|
||||||
|
if (matchingTaskDiscussion) {
|
||||||
|
setSelectedChannelKey(matchingTaskDiscussion.key)
|
||||||
|
}
|
||||||
|
}, [selectedEntityId, messages, comments, messageChannels, taskDiscussionChannels])
|
||||||
|
|
||||||
const activeChannel = useMemo(
|
const activeChannel = useMemo(
|
||||||
() => channels.find((c) => c.key === selectedChannelKey) ?? null,
|
() => channels.find((c) => c.key === selectedChannelKey) ?? null,
|
||||||
[channels, selectedChannelKey],
|
[channels, selectedChannelKey],
|
||||||
@@ -1078,16 +1126,32 @@ export function CommunicationHub({
|
|||||||
|
|
||||||
// Messages for the selected channel
|
// Messages for the selected channel
|
||||||
const channelMessages = useMemo(() => {
|
const channelMessages = useMemo(() => {
|
||||||
if (!activeChannel) return []
|
if (!activeChannel || activeChannel.kind !== "message") return []
|
||||||
return getChannelMessages(messages, activeChannel).sort(
|
return getChannelMessages(messages, activeChannel).sort(
|
||||||
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
|
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
|
||||||
)
|
)
|
||||||
}, [messages, activeChannel])
|
}, [messages, activeChannel])
|
||||||
|
|
||||||
|
const taskDiscussionComments = useMemo(() => {
|
||||||
|
if (!activeChannel || activeChannel.kind !== "task") return []
|
||||||
|
return comments
|
||||||
|
.filter((comment) => comment.taskId === activeChannel.taskId)
|
||||||
|
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
|
||||||
|
}, [comments, activeChannel])
|
||||||
|
|
||||||
|
const activeTask = useMemo(() => {
|
||||||
|
if (!activeChannel || activeChannel.kind !== "task") return null
|
||||||
|
return tasks.find((task) => task.id === activeChannel.taskId) ?? null
|
||||||
|
}, [tasks, activeChannel])
|
||||||
|
|
||||||
// Apply filters to visible messages
|
// Apply filters to visible messages
|
||||||
const filteredMessages = useMemo(() => {
|
const filteredMessages = useMemo(() => {
|
||||||
let result = channelMessages
|
let result = channelMessages
|
||||||
|
|
||||||
|
if (filters.channelType !== "all") {
|
||||||
|
result = result.filter((m) => m.channel === filters.channelType)
|
||||||
|
}
|
||||||
|
|
||||||
if (filters.search) {
|
if (filters.search) {
|
||||||
const q = filters.search.toLowerCase()
|
const q = filters.search.toLowerCase()
|
||||||
result = result.filter(
|
result = result.filter(
|
||||||
@@ -1127,6 +1191,10 @@ export function CommunicationHub({
|
|||||||
const unreadByChannel = useMemo(() => {
|
const unreadByChannel = useMemo(() => {
|
||||||
const counts = new Map<string, { unread: number; hasUrgent: boolean }>()
|
const counts = new Map<string, { unread: number; hasUrgent: boolean }>()
|
||||||
for (const ch of channels) {
|
for (const ch of channels) {
|
||||||
|
if (ch.kind !== "message") {
|
||||||
|
counts.set(ch.key, { unread: 0, hasUrgent: false })
|
||||||
|
continue
|
||||||
|
}
|
||||||
const msgs = getChannelMessages(messages, ch)
|
const msgs = getChannelMessages(messages, ch)
|
||||||
const unread = msgs.filter((m) => !m.readAt).length
|
const unread = msgs.filter((m) => !m.readAt).length
|
||||||
const hasUrgent = msgs.some((m) => !m.readAt && m.priority === "urgent")
|
const hasUrgent = msgs.some((m) => !m.readAt && m.priority === "urgent")
|
||||||
@@ -1139,22 +1207,6 @@ export function CommunicationHub({
|
|||||||
return messages.filter((m) => !m.readAt).length
|
return messages.filter((m) => !m.readAt).length
|
||||||
}, [messages])
|
}, [messages])
|
||||||
|
|
||||||
// Mark message read
|
|
||||||
const handleMarkRead = useCallback(
|
|
||||||
(messageId: string) => {
|
|
||||||
void window.agentCompany.markMessageRead({ messageId, companyId })
|
|
||||||
},
|
|
||||||
[companyId],
|
|
||||||
)
|
|
||||||
|
|
||||||
// After sending, refresh and scroll
|
|
||||||
const handleSent = useCallback(() => {
|
|
||||||
onRefresh()
|
|
||||||
setTimeout(() => {
|
|
||||||
messageEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
|
||||||
}, 200)
|
|
||||||
}, [onRefresh])
|
|
||||||
|
|
||||||
// Scroll to bottom when channel changes
|
// Scroll to bottom when channel changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
messageEndRef.current?.scrollIntoView({ behavior: "auto" })
|
messageEndRef.current?.scrollIntoView({ behavior: "auto" })
|
||||||
@@ -1165,7 +1217,7 @@ export function CommunicationHub({
|
|||||||
const groups: Array<{ type: MessageChannel; label: string; items: ChannelDescriptor[] }> = []
|
const groups: Array<{ type: MessageChannel; label: string; items: ChannelDescriptor[] }> = []
|
||||||
const typeOrder: MessageChannel[] = ["company", "department", "project", "incident", "direct"]
|
const typeOrder: MessageChannel[] = ["company", "department", "project", "incident", "direct"]
|
||||||
for (const type of typeOrder) {
|
for (const type of typeOrder) {
|
||||||
const items = channels.filter((c) => c.channel === type)
|
const items = channels.filter((c): c is ChannelDescriptor => c.kind === "message" && c.channel === type)
|
||||||
if (items.length > 0) {
|
if (items.length > 0) {
|
||||||
groups.push({
|
groups.push({
|
||||||
type,
|
type,
|
||||||
@@ -1200,12 +1252,15 @@ export function CommunicationHub({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ActionButton
|
<button
|
||||||
label="Compose"
|
type="button"
|
||||||
onClick={() => setShowCompose(true)}
|
onClick={() => setShowCompose(true)}
|
||||||
icon={Plus}
|
className="inline-flex items-center gap-2 rounded-[8px] px-3 py-2 text-[11px] font-semibold text-white transition hover:brightness-90"
|
||||||
tone="accent"
|
style={{ backgroundColor: TERRACOTTA }}
|
||||||
/>
|
>
|
||||||
|
<Plus size={13} weight="bold" />
|
||||||
|
New message
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main layout: sidebar + messages */}
|
{/* Main layout: sidebar + messages */}
|
||||||
@@ -1213,7 +1268,6 @@ export function CommunicationHub({
|
|||||||
className="flex overflow-hidden rounded-[12px] border border-[color:var(--line)]"
|
className="flex overflow-hidden rounded-[12px] border border-[color:var(--line)]"
|
||||||
style={{ height: "calc(100vh - 220px)", minHeight: "480px" }}
|
style={{ height: "calc(100vh - 220px)", minHeight: "480px" }}
|
||||||
>
|
>
|
||||||
{/* ── Sidebar ────────────────────────────────────────── */}
|
|
||||||
<AnimatePresence initial={false}>
|
<AnimatePresence initial={false}>
|
||||||
{showSidebar ? (
|
{showSidebar ? (
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -1258,7 +1312,6 @@ export function CommunicationHub({
|
|||||||
agents={agents}
|
agents={agents}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedChannelKey(ch.key)
|
setSelectedChannelKey(ch.key)
|
||||||
setReplyTo(null)
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -1266,9 +1319,34 @@ export function CommunicationHub({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{taskDiscussionChannels.length > 0 ? (
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="mb-1 px-2 text-[9px] font-bold uppercase tracking-[0.1em] text-[color:var(--muted)]">
|
||||||
|
Task Discussions
|
||||||
|
</div>
|
||||||
|
{taskDiscussionChannels.map((discussion) => (
|
||||||
|
<button
|
||||||
|
key={discussion.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedChannelKey(discussion.key)}
|
||||||
|
className={`group flex w-full items-center gap-2.5 rounded-[8px] px-3 py-2 text-left transition duration-150 ${
|
||||||
|
selectedChannelKey === discussion.key ? "bg-[color:var(--panel-soft)]" : "hover:bg-[color:var(--panel-soft)]/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-[6px]" style={{ backgroundColor: `${TERRACOTTA}18` }}>
|
||||||
|
<Hash size={14} weight={selectedChannelKey === discussion.key ? "fill" : "regular"} style={{ color: TERRACOTTA }} />
|
||||||
|
</div>
|
||||||
|
<span className={`min-w-0 flex-1 truncate text-[12px] ${selectedChannelKey === discussion.key ? "font-semibold text-[color:var(--text)]" : "font-medium text-[color:var(--muted-strong)]"}`}>
|
||||||
|
{discussion.label}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{channels.length === 0 ? (
|
{channels.length === 0 ? (
|
||||||
<div className="px-3 py-6 text-center text-[11px] text-[color:var(--muted)]">
|
<div className="px-3 py-6 text-center text-[11px] text-[color:var(--muted)]">
|
||||||
No channels yet. Send a message to create one.
|
No active departments yet.
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -1284,7 +1362,6 @@ export function CommunicationHub({
|
|||||||
) : null}
|
) : null}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
{/* ── Main content ─────────────────────────────────── */}
|
|
||||||
<div className="flex min-w-0 flex-1 flex-col bg-[color:var(--bg)]">
|
<div className="flex min-w-0 flex-1 flex-col bg-[color:var(--bg)]">
|
||||||
{/* Channel header */}
|
{/* Channel header */}
|
||||||
<div className="flex items-center gap-3 border-b border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-2.5">
|
<div className="flex items-center gap-3 border-b border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-2.5">
|
||||||
@@ -1301,7 +1378,7 @@ export function CommunicationHub({
|
|||||||
|
|
||||||
{activeChannel ? (
|
{activeChannel ? (
|
||||||
<>
|
<>
|
||||||
{(() => {
|
{activeChannel.kind === "message" ? (() => {
|
||||||
const Icon = channelConfig[activeChannel.channel].icon
|
const Icon = channelConfig[activeChannel.channel].icon
|
||||||
return (
|
return (
|
||||||
<Icon
|
<Icon
|
||||||
@@ -1312,19 +1389,20 @@ export function CommunicationHub({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})()}
|
})() : (
|
||||||
|
<Hash size={16} weight="fill" style={{ color: TERRACOTTA }} />
|
||||||
|
)}
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="text-[13px] font-semibold text-[color:var(--text)]">
|
<div className="text-[13px] font-semibold text-[color:var(--text)]">
|
||||||
{activeChannel.label}
|
{activeChannel.label}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[10px] text-[color:var(--muted)]">
|
<div className="text-[10px] text-[color:var(--muted)]">
|
||||||
{channelMessages.length} message{channelMessages.length !== 1 ? "s" : ""}
|
{activeChannel.kind === "message"
|
||||||
{(() => {
|
? `${channelMessages.length} message${channelMessages.length !== 1 ? "s" : ""}${(() => {
|
||||||
const stats = unreadByChannel.get(activeChannel.key)
|
const stats = unreadByChannel.get(activeChannel.key)
|
||||||
return stats?.unread
|
return stats?.unread ? ` (${stats.unread} unread)` : ""
|
||||||
? ` (${stats.unread} unread)`
|
})()}`
|
||||||
: ""
|
: `${taskDiscussionComments.length} comment${taskDiscussionComments.length !== 1 ? "s" : ""}${activeTask ? ` · ${activeTask.status.replaceAll("_", " ")}` : ""}`}
|
||||||
})()}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -1352,9 +1430,32 @@ export function CommunicationHub({
|
|||||||
Select a channel to view messages
|
Select a channel to view messages
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-[12px] text-[color:var(--muted)]">
|
<div className="mt-1 text-[12px] text-[color:var(--muted)]">
|
||||||
Or compose a new message to get started
|
Monitor internal agent communications across departments
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : activeChannel.kind === "task" ? (
|
||||||
|
taskDiscussionComments.length === 0 ? (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center text-center">
|
||||||
|
<Tray size={36} className="mb-3 text-[color:var(--muted)]" />
|
||||||
|
<div className="text-[14px] font-medium text-[color:var(--muted-strong)]">
|
||||||
|
No discussion yet
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-[12px] text-[color:var(--muted)]">
|
||||||
|
Comments for this task will appear here as agents and the board coordinate.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{taskDiscussionComments.map((comment) => (
|
||||||
|
<TaskCommentBubble
|
||||||
|
key={comment.id}
|
||||||
|
comment={comment}
|
||||||
|
agent={comment.authorAgentId ? (agentMap.get(comment.authorAgentId) ?? undefined) : undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<div ref={messageEndRef} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
) : filteredMessages.length === 0 ? (
|
) : filteredMessages.length === 0 ? (
|
||||||
<div className="flex h-full flex-col items-center justify-center text-center">
|
<div className="flex h-full flex-col items-center justify-center text-center">
|
||||||
<Tray size={36} className="mb-3 text-[color:var(--muted)]" />
|
<Tray size={36} className="mb-3 text-[color:var(--muted)]" />
|
||||||
@@ -1366,7 +1467,7 @@ export function CommunicationHub({
|
|||||||
<div className="mt-1 text-[12px] text-[color:var(--muted)]">
|
<div className="mt-1 text-[12px] text-[color:var(--muted)]">
|
||||||
{filters.search || filters.unreadOnly || filters.priority !== "all"
|
{filters.search || filters.unreadOnly || filters.priority !== "all"
|
||||||
? "Try adjusting your search or filters"
|
? "Try adjusting your search or filters"
|
||||||
: "Be the first to send a message"}
|
: "Messages will appear here as agents work and communicate"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -1381,7 +1482,6 @@ export function CommunicationHub({
|
|||||||
isThread={false}
|
isThread={false}
|
||||||
replyCount={replies.length}
|
replyCount={replies.length}
|
||||||
onReply={() => setReplyTo(msg)}
|
onReply={() => setReplyTo(msg)}
|
||||||
onMarkRead={() => handleMarkRead(msg.id)}
|
|
||||||
/>
|
/>
|
||||||
{/* Thread replies */}
|
{/* Thread replies */}
|
||||||
{replies.length > 0 ? (
|
{replies.length > 0 ? (
|
||||||
@@ -1394,7 +1494,6 @@ export function CommunicationHub({
|
|||||||
isThread={true}
|
isThread={true}
|
||||||
replyCount={0}
|
replyCount={0}
|
||||||
onReply={() => setReplyTo(reply)}
|
onReply={() => setReplyTo(reply)}
|
||||||
onMarkRead={() => handleMarkRead(reply.id)}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -1407,31 +1506,44 @@ export function CommunicationHub({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Composer */}
|
|
||||||
{activeChannel ? (
|
{activeChannel ? (
|
||||||
<MessageComposer
|
activeChannel.kind === "message" ? (
|
||||||
agents={agents}
|
<MessageComposer
|
||||||
companyId={companyId}
|
agents={agents}
|
||||||
channel={activeChannel.channel}
|
companyId={companyId}
|
||||||
channelTargetId={activeChannel.targetId}
|
channel={activeChannel.channel}
|
||||||
replyTo={replyTo}
|
channelTargetId={activeChannel.channel === "direct" ? null : activeChannel.targetId}
|
||||||
onSent={handleSent}
|
replyTo={replyTo}
|
||||||
onCancelReply={() => setReplyTo(null)}
|
onSent={() => {
|
||||||
/>
|
setReplyTo(null)
|
||||||
) : null}
|
onRefresh()
|
||||||
|
}}
|
||||||
|
onCancelReply={() => setReplyTo(null)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<TaskDiscussionComposer
|
||||||
|
companyId={companyId}
|
||||||
|
taskId={activeChannel.taskId}
|
||||||
|
onSent={onRefresh}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div className="border-t border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-2.5">
|
||||||
|
<div className="flex items-center gap-2 text-[11px] text-[color:var(--muted)]">
|
||||||
|
<ChatCircle size={14} />
|
||||||
|
<span>Select a channel to read and send messages.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Compose modal */}
|
|
||||||
<ComposeModal
|
<ComposeModal
|
||||||
open={showCompose}
|
open={showCompose}
|
||||||
onClose={() => setShowCompose(false)}
|
onClose={() => setShowCompose(false)}
|
||||||
agents={agents}
|
agents={agents}
|
||||||
companyId={companyId}
|
companyId={companyId}
|
||||||
onSent={() => {
|
onSent={onRefresh}
|
||||||
onRefresh()
|
|
||||||
setShowCompose(false)
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,716 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
CalendarBlank,
|
||||||
|
FileText,
|
||||||
|
FloppyDisk,
|
||||||
|
Lightbulb,
|
||||||
|
NotePencil,
|
||||||
|
Plus,
|
||||||
|
Rows,
|
||||||
|
Trash,
|
||||||
|
} from "@phosphor-icons/react";
|
||||||
|
import type {
|
||||||
|
AgentRecord,
|
||||||
|
DocumentRecord,
|
||||||
|
GoalRecord,
|
||||||
|
KnowledgeEntryRecord,
|
||||||
|
MeetingRecord,
|
||||||
|
ProjectRecord,
|
||||||
|
SprintRecord,
|
||||||
|
} from "@shared/types";
|
||||||
|
import { ActionButton, ConfirmDialog, InlineNotice, Input, ListFrame, ListRow, Modal, Select, StatusPill, TextArea } from "./ui";
|
||||||
|
|
||||||
|
const documentTypeOptions = [
|
||||||
|
"prd",
|
||||||
|
"technical_spec",
|
||||||
|
"design_doc",
|
||||||
|
"test_plan",
|
||||||
|
"post_mortem",
|
||||||
|
"meeting_notes",
|
||||||
|
"budget_proposal",
|
||||||
|
"hiring_requisition",
|
||||||
|
"performance_review_doc",
|
||||||
|
"sop",
|
||||||
|
"knowledge_article",
|
||||||
|
"project_brief",
|
||||||
|
"sprint_report",
|
||||||
|
"incident_report",
|
||||||
|
"onboarding_guide",
|
||||||
|
"architecture_decision",
|
||||||
|
"status_report",
|
||||||
|
"contract",
|
||||||
|
"proposal",
|
||||||
|
].map((value) => ({ value, label: value.replaceAll("_", " ") }));
|
||||||
|
|
||||||
|
const documentStatusOptions = ["draft", "in_review", "approved", "archived", "superseded"].map((value) => ({
|
||||||
|
value,
|
||||||
|
label: value.replaceAll("_", " "),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const knowledgeCategoryOptions = [
|
||||||
|
"lesson_learned",
|
||||||
|
"best_practice",
|
||||||
|
"decision",
|
||||||
|
"process",
|
||||||
|
"technical",
|
||||||
|
"business",
|
||||||
|
"onboarding",
|
||||||
|
"incident",
|
||||||
|
].map((value) => ({ value, label: value.replaceAll("_", " ") }));
|
||||||
|
|
||||||
|
const importanceOptions = ["critical", "high", "medium", "low"].map((value) => ({ value, label: value }));
|
||||||
|
|
||||||
|
const meetingTypeOptions = [
|
||||||
|
"standup",
|
||||||
|
"sprint_planning",
|
||||||
|
"sprint_review",
|
||||||
|
"retrospective",
|
||||||
|
"one_on_one",
|
||||||
|
"all_hands",
|
||||||
|
"department_sync",
|
||||||
|
"incident_review",
|
||||||
|
"hiring_committee",
|
||||||
|
"budget_review",
|
||||||
|
"design_review",
|
||||||
|
"architecture_review",
|
||||||
|
].map((value) => ({ value, label: value.replaceAll("_", " ") }));
|
||||||
|
|
||||||
|
const meetingStatusOptions = ["scheduled", "in_progress", "completed", "cancelled"].map((value) => ({ value, label: value.replaceAll("_", " ") }));
|
||||||
|
const sprintStatusOptions = ["planning", "active", "review", "completed", "cancelled"].map((value) => ({ value, label: value }));
|
||||||
|
|
||||||
|
function splitTags(value: string) {
|
||||||
|
return JSON.stringify(value.split(",").map((item) => item.trim()).filter(Boolean));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTags(value: string) {
|
||||||
|
try {
|
||||||
|
const tags = JSON.parse(value) as string[];
|
||||||
|
return tags.join(", ");
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseParticipantIds(value: string) {
|
||||||
|
return value
|
||||||
|
.split(",")
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatParticipantIds(raw: string, agents: AgentRecord[]) {
|
||||||
|
try {
|
||||||
|
const ids = JSON.parse(raw) as string[];
|
||||||
|
return ids
|
||||||
|
.map((id) => agents.find((agent) => agent.id === id)?.name ?? id.slice(0, 8))
|
||||||
|
.join(", ");
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function DocumentEditor({
|
||||||
|
companyId,
|
||||||
|
agents,
|
||||||
|
projects,
|
||||||
|
goals,
|
||||||
|
document,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
companyId: string;
|
||||||
|
agents: AgentRecord[];
|
||||||
|
projects: ProjectRecord[];
|
||||||
|
goals: GoalRecord[];
|
||||||
|
document: DocumentRecord | null;
|
||||||
|
onSaved: () => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const [title, setTitle] = useState(document?.title ?? "");
|
||||||
|
const [type, setType] = useState(document?.type ?? "technical_spec");
|
||||||
|
const [content, setContent] = useState(document?.content ?? "");
|
||||||
|
const [status, setStatus] = useState(document?.status ?? "draft");
|
||||||
|
const [reviewerAgentId, setReviewerAgentId] = useState(document?.reviewerAgentId ?? "");
|
||||||
|
const [projectId, setProjectId] = useState(document?.projectId ?? "");
|
||||||
|
const [goalId, setGoalId] = useState(document?.goalId ?? "");
|
||||||
|
const [tags, setTags] = useState(parseTags(document?.tagsJson ?? "[]"));
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!title.trim()) {
|
||||||
|
setError("Document title is required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = await window.agentCompany.saveDocument({
|
||||||
|
id: document?.id ?? null,
|
||||||
|
companyId,
|
||||||
|
type: type as Parameters<typeof window.agentCompany.saveDocument>[0]["type"],
|
||||||
|
title: title.trim(),
|
||||||
|
content,
|
||||||
|
authorAgentId: document?.authorAgentId ?? null,
|
||||||
|
reviewerAgentId: reviewerAgentId || null,
|
||||||
|
projectId: projectId || null,
|
||||||
|
goalId: goalId || null,
|
||||||
|
parentDocId: document?.parentDocId ?? null,
|
||||||
|
version: document?.version ?? 1,
|
||||||
|
status: status as Parameters<typeof window.agentCompany.saveDocument>[0]["status"],
|
||||||
|
tagsJson: splitTags(tags),
|
||||||
|
});
|
||||||
|
if (!result.ok) {
|
||||||
|
setError(result.error.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await onSaved();
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{error ? <InlineNotice message={error} /> : null}
|
||||||
|
<Input label="Title" value={title} onChange={setTitle} placeholder="Architecture review summary" />
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<Select label="Type" value={type} onChange={(value) => setType(value as typeof type)} options={documentTypeOptions} />
|
||||||
|
<Select label="Status" value={status} onChange={(value) => setStatus(value as typeof status)} options={documentStatusOptions} />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<Select
|
||||||
|
label="Reviewer"
|
||||||
|
value={reviewerAgentId}
|
||||||
|
onChange={setReviewerAgentId}
|
||||||
|
options={[{ value: "", label: "None" }, ...agents.map((agent) => ({ value: agent.id, label: `${agent.name} (${agent.role})` }))]}
|
||||||
|
/>
|
||||||
|
<Input label="Tags" value={tags} onChange={setTags} placeholder="architecture, security, release" />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<Select label="Project" value={projectId} onChange={setProjectId} options={[{ value: "", label: "None" }, ...projects.map((project) => ({ value: project.id, label: project.name }))]} />
|
||||||
|
<Select label="Goal" value={goalId} onChange={setGoalId} options={[{ value: "", label: "None" }, ...goals.map((goal) => ({ value: goal.id, label: goal.title }))]} />
|
||||||
|
</div>
|
||||||
|
<TextArea label="Content" value={content} onChange={setContent} rows={12} placeholder="Write the document content here." />
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<ActionButton label={saving ? "Saving..." : document ? "Save document" : "Create document"} icon={FloppyDisk} tone="accent" onClick={() => void save()} loading={saving} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function KnowledgeEditor({
|
||||||
|
companyId,
|
||||||
|
entry,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
companyId: string;
|
||||||
|
entry: KnowledgeEntryRecord | null;
|
||||||
|
onSaved: () => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const [topic, setTopic] = useState(entry?.topic ?? "");
|
||||||
|
const [category, setCategory] = useState(entry?.category ?? "technical");
|
||||||
|
const [importance, setImportance] = useState(entry?.importance ?? "medium");
|
||||||
|
const [content, setContent] = useState(entry?.content ?? "");
|
||||||
|
const [tags, setTags] = useState(parseTags(entry?.tagsJson ?? "[]"));
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!topic.trim() || !content.trim()) {
|
||||||
|
setError("Topic and content are required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = await window.agentCompany.saveKnowledgeEntry({
|
||||||
|
id: entry?.id ?? null,
|
||||||
|
companyId,
|
||||||
|
category: category as Parameters<typeof window.agentCompany.saveKnowledgeEntry>[0]["category"],
|
||||||
|
topic: topic.trim(),
|
||||||
|
content: content.trim(),
|
||||||
|
authorAgentId: entry?.authorAgentId ?? null,
|
||||||
|
importance: importance as Parameters<typeof window.agentCompany.saveKnowledgeEntry>[0]["importance"],
|
||||||
|
tagsJson: splitTags(tags),
|
||||||
|
referencedEntityType: entry?.referencedEntityType ?? null,
|
||||||
|
referencedEntityId: entry?.referencedEntityId ?? null,
|
||||||
|
});
|
||||||
|
if (!result.ok) {
|
||||||
|
setError(result.error.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await onSaved();
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{error ? <InlineNotice message={error} /> : null}
|
||||||
|
<Input label="Topic" value={topic} onChange={setTopic} placeholder="What did the team learn?" />
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<Select label="Category" value={category} onChange={(value) => setCategory(value as typeof category)} options={knowledgeCategoryOptions} />
|
||||||
|
<Select label="Importance" value={importance} onChange={(value) => setImportance(value as typeof importance)} options={importanceOptions} />
|
||||||
|
</div>
|
||||||
|
<Input label="Tags" value={tags} onChange={setTags} placeholder="incident, auth, onboarding" />
|
||||||
|
<TextArea label="Content" value={content} onChange={setContent} rows={10} placeholder="Capture the lesson, decision, or process in full." />
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<ActionButton label={saving ? "Saving..." : entry ? "Save entry" : "Create entry"} icon={FloppyDisk} tone="accent" onClick={() => void save()} loading={saving} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MeetingEditor({
|
||||||
|
companyId,
|
||||||
|
agents,
|
||||||
|
meeting,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
companyId: string;
|
||||||
|
agents: AgentRecord[];
|
||||||
|
meeting: MeetingRecord | null;
|
||||||
|
onSaved: () => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const [title, setTitle] = useState(meeting?.title ?? "");
|
||||||
|
const [type, setType] = useState(meeting?.type ?? "department_sync");
|
||||||
|
const [status, setStatus] = useState(meeting?.status ?? "scheduled");
|
||||||
|
const [scheduledAt, setScheduledAt] = useState(meeting?.scheduledAt?.slice(0, 16) ?? new Date().toISOString().slice(0, 16));
|
||||||
|
const [durationMinutes, setDurationMinutes] = useState(String(meeting?.durationMinutes ?? 30));
|
||||||
|
const [agenda, setAgenda] = useState(parseTags(meeting?.agendaJson ?? "[]"));
|
||||||
|
const [notes, setNotes] = useState(parseTags(meeting?.notesJson ?? "[]"));
|
||||||
|
const [decisions, setDecisions] = useState(parseTags(meeting?.decisionsJson ?? "[]"));
|
||||||
|
const [participants, setParticipants] = useState(formatParticipantIds(meeting?.participantAgentIds ?? "[]", agents));
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!title.trim()) {
|
||||||
|
setError("Meeting title is required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const participantIds = parseParticipantIds(participants)
|
||||||
|
.map((value) => agents.find((agent) => agent.name === value || agent.id === value)?.id ?? value)
|
||||||
|
.filter(Boolean);
|
||||||
|
const result = await window.agentCompany.saveMeeting({
|
||||||
|
id: meeting?.id ?? null,
|
||||||
|
companyId,
|
||||||
|
type: type as Parameters<typeof window.agentCompany.saveMeeting>[0]["type"],
|
||||||
|
title: title.trim(),
|
||||||
|
organizerAgentId: meeting?.organizerAgentId ?? null,
|
||||||
|
participantAgentIds: JSON.stringify(participantIds),
|
||||||
|
scheduledAt: new Date(scheduledAt).toISOString(),
|
||||||
|
durationMinutes: Number.parseInt(durationMinutes, 10) || 30,
|
||||||
|
agendaJson: splitTags(agenda),
|
||||||
|
notesJson: splitTags(notes),
|
||||||
|
decisionsJson: splitTags(decisions),
|
||||||
|
actionItemsJson: meeting?.actionItemsJson ?? "[]",
|
||||||
|
status: status as Parameters<typeof window.agentCompany.saveMeeting>[0]["status"],
|
||||||
|
});
|
||||||
|
if (!result.ok) {
|
||||||
|
setError(result.error.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await onSaved();
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{error ? <InlineNotice message={error} /> : null}
|
||||||
|
<Input label="Title" value={title} onChange={setTitle} placeholder="Sprint review" />
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<Select label="Type" value={type} onChange={(value) => setType(value as typeof type)} options={meetingTypeOptions} />
|
||||||
|
<Select label="Status" value={status} onChange={(value) => setStatus(value as typeof status)} options={meetingStatusOptions} />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<Input label="Scheduled At" value={scheduledAt} onChange={setScheduledAt} type="datetime-local" />
|
||||||
|
<Input label="Duration (minutes)" value={durationMinutes} onChange={setDurationMinutes} type="number" />
|
||||||
|
</div>
|
||||||
|
<Input label="Participants" value={participants} onChange={setParticipants} placeholder="Comma-separated agent names or ids" />
|
||||||
|
<Input label="Agenda" value={agenda} onChange={setAgenda} placeholder="Planning, blockers, decisions" />
|
||||||
|
<Input label="Notes" value={notes} onChange={setNotes} placeholder="Key notes" />
|
||||||
|
<Input label="Decisions" value={decisions} onChange={setDecisions} placeholder="Decision 1, Decision 2" />
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<ActionButton label={saving ? "Saving..." : meeting ? "Save meeting" : "Create meeting"} icon={FloppyDisk} tone="accent" onClick={() => void save()} loading={saving} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SprintEditor({
|
||||||
|
companyId,
|
||||||
|
sprint,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
companyId: string;
|
||||||
|
sprint: SprintRecord | null;
|
||||||
|
onSaved: () => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const [name, setName] = useState(sprint?.name ?? "");
|
||||||
|
const [goal, setGoal] = useState(sprint?.goal ?? "");
|
||||||
|
const [startDate, setStartDate] = useState(sprint?.startDate ?? new Date().toISOString().slice(0, 10));
|
||||||
|
const [endDate, setEndDate] = useState(sprint?.endDate ?? new Date(Date.now() + 14 * 86_400_000).toISOString().slice(0, 10));
|
||||||
|
const [status, setStatus] = useState(sprint?.status ?? "planning");
|
||||||
|
const [velocityPoints, setVelocityPoints] = useState(String(sprint?.velocityPoints ?? 20));
|
||||||
|
const [completedPoints, setCompletedPoints] = useState(String(sprint?.completedPoints ?? 0));
|
||||||
|
const [retrospectiveNotes, setRetrospectiveNotes] = useState(sprint?.retrospectiveNotes ?? "");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!name.trim()) {
|
||||||
|
setError("Sprint name is required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = await window.agentCompany.saveSprint({
|
||||||
|
id: sprint?.id ?? null,
|
||||||
|
companyId,
|
||||||
|
name: name.trim(),
|
||||||
|
goal: goal.trim(),
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
status: status as Parameters<typeof window.agentCompany.saveSprint>[0]["status"],
|
||||||
|
retrospectiveNotes,
|
||||||
|
velocityPoints: Number.parseInt(velocityPoints, 10) || 0,
|
||||||
|
completedPoints: Number.parseInt(completedPoints, 10) || 0,
|
||||||
|
});
|
||||||
|
if (!result.ok) {
|
||||||
|
setError(result.error.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await onSaved();
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{error ? <InlineNotice message={error} /> : null}
|
||||||
|
<Input label="Sprint name" value={name} onChange={setName} placeholder="Sprint 12" />
|
||||||
|
<TextArea label="Goal" value={goal} onChange={setGoal} rows={3} placeholder="What this sprint is meant to achieve." />
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<Input label="Start date" value={startDate} onChange={setStartDate} type="date" />
|
||||||
|
<Input label="End date" value={endDate} onChange={setEndDate} type="date" />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
<Select label="Status" value={status} onChange={(value) => setStatus(value as typeof status)} options={sprintStatusOptions} />
|
||||||
|
<Input label="Velocity points" value={velocityPoints} onChange={setVelocityPoints} type="number" />
|
||||||
|
<Input label="Completed points" value={completedPoints} onChange={setCompletedPoints} type="number" />
|
||||||
|
</div>
|
||||||
|
<TextArea label="Retrospective notes" value={retrospectiveNotes} onChange={setRetrospectiveNotes} rows={5} placeholder="What should the team retain, change, or stop?" />
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<ActionButton label={saving ? "Saving..." : sprint ? "Save sprint" : "Create sprint"} icon={FloppyDisk} tone="accent" onClick={() => void save()} loading={saving} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionHeader({
|
||||||
|
title,
|
||||||
|
count,
|
||||||
|
action,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
count: number;
|
||||||
|
action: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-[16px] font-semibold text-[color:var(--text)]">{title}</div>
|
||||||
|
<div className="text-[12px] text-[color:var(--muted)]">{count} item{count === 1 ? "" : "s"}</div>
|
||||||
|
</div>
|
||||||
|
<ActionButton label="New" icon={Plus} tone="accent" onClick={action} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DocumentCenter({
|
||||||
|
companyId,
|
||||||
|
documents,
|
||||||
|
agents,
|
||||||
|
projects,
|
||||||
|
goals,
|
||||||
|
onRefresh,
|
||||||
|
}: {
|
||||||
|
companyId: string;
|
||||||
|
documents: DocumentRecord[];
|
||||||
|
agents: AgentRecord[];
|
||||||
|
projects: ProjectRecord[];
|
||||||
|
goals: GoalRecord[];
|
||||||
|
onRefresh: () => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||||
|
const activeDocument = useMemo(() => documents.find((document) => document.id === editingId) ?? null, [documents, editingId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<SectionHeader title="Document Center" count={documents.length} action={() => setEditingId("__new__")} />
|
||||||
|
<ListFrame>
|
||||||
|
{documents.map((document) => (
|
||||||
|
<ListRow key={document.id}>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<button type="button" className="min-w-0 flex-1 text-left" onClick={() => setEditingId(document.id)}>
|
||||||
|
<div className="flex items-center gap-2 text-[13px] font-medium text-[color:var(--text)]">
|
||||||
|
<FileText size={15} className="text-[color:var(--accent)]" />
|
||||||
|
<span className="truncate">{document.title}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-[11px] text-[color:var(--muted)]">
|
||||||
|
{document.type.replaceAll("_", " ")} · v{document.version} · {document.updatedAt.slice(0, 10)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<StatusPill status={document.status} />
|
||||||
|
<button type="button" className="rounded p-1 text-[color:var(--muted)] hover:text-[color:var(--text)]" onClick={() => setEditingId(document.id)}>
|
||||||
|
<NotePencil size={14} />
|
||||||
|
</button>
|
||||||
|
<button type="button" className="rounded p-1 text-[color:var(--muted)] hover:text-[color:var(--danger)]" onClick={() => setDeleteId(document.id)}>
|
||||||
|
<Trash size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ListRow>
|
||||||
|
))}
|
||||||
|
</ListFrame>
|
||||||
|
<Modal open={editingId !== null} title={activeDocument ? activeDocument.title : "New document"} onClose={() => setEditingId(null)} width="760px">
|
||||||
|
<DocumentEditor
|
||||||
|
companyId={companyId}
|
||||||
|
agents={agents}
|
||||||
|
projects={projects}
|
||||||
|
goals={goals}
|
||||||
|
document={editingId === "__new__" ? null : activeDocument}
|
||||||
|
onSaved={async () => {
|
||||||
|
setEditingId(null);
|
||||||
|
await onRefresh();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={deleteId !== null}
|
||||||
|
title="Delete document"
|
||||||
|
message="This removes the document from the company knowledge surface."
|
||||||
|
onCancel={() => setDeleteId(null)}
|
||||||
|
onConfirm={() => {
|
||||||
|
if (!deleteId) return;
|
||||||
|
void window.agentCompany.deleteDocument({ id: deleteId, companyId }).then(() => onRefresh());
|
||||||
|
setDeleteId(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KnowledgeBaseManager({
|
||||||
|
companyId,
|
||||||
|
entries,
|
||||||
|
onRefresh,
|
||||||
|
}: {
|
||||||
|
companyId: string;
|
||||||
|
entries: KnowledgeEntryRecord[];
|
||||||
|
onRefresh: () => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||||
|
const activeEntry = useMemo(() => entries.find((entry) => entry.id === editingId) ?? null, [entries, editingId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<SectionHeader title="Knowledge Base" count={entries.length} action={() => setEditingId("__new__")} />
|
||||||
|
<ListFrame>
|
||||||
|
{entries.map((entry) => (
|
||||||
|
<ListRow key={entry.id}>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<button type="button" className="min-w-0 flex-1 text-left" onClick={() => setEditingId(entry.id)}>
|
||||||
|
<div className="flex items-center gap-2 text-[13px] font-medium text-[color:var(--text)]">
|
||||||
|
<Lightbulb size={15} className="text-[color:var(--accent)]" />
|
||||||
|
<span className="truncate">{entry.topic}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-[11px] text-[color:var(--muted)]">
|
||||||
|
{entry.category.replaceAll("_", " ")} · {entry.updatedAt.slice(0, 10)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<StatusPill status={entry.importance} />
|
||||||
|
<button type="button" className="rounded p-1 text-[color:var(--muted)] hover:text-[color:var(--text)]" onClick={() => setEditingId(entry.id)}>
|
||||||
|
<NotePencil size={14} />
|
||||||
|
</button>
|
||||||
|
<button type="button" className="rounded p-1 text-[color:var(--muted)] hover:text-[color:var(--danger)]" onClick={() => setDeleteId(entry.id)}>
|
||||||
|
<Trash size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ListRow>
|
||||||
|
))}
|
||||||
|
</ListFrame>
|
||||||
|
<Modal open={editingId !== null} title={activeEntry ? activeEntry.topic : "New knowledge entry"} onClose={() => setEditingId(null)} width="760px">
|
||||||
|
<KnowledgeEditor
|
||||||
|
companyId={companyId}
|
||||||
|
entry={editingId === "__new__" ? null : activeEntry}
|
||||||
|
onSaved={async () => {
|
||||||
|
setEditingId(null);
|
||||||
|
await onRefresh();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={deleteId !== null}
|
||||||
|
title="Delete knowledge entry"
|
||||||
|
message="This removes the entry from the shared company knowledge base."
|
||||||
|
onCancel={() => setDeleteId(null)}
|
||||||
|
onConfirm={() => {
|
||||||
|
if (!deleteId) return;
|
||||||
|
void window.agentCompany.deleteKnowledgeEntry({ id: deleteId, companyId }).then(() => onRefresh());
|
||||||
|
setDeleteId(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MeetingsManager({
|
||||||
|
companyId,
|
||||||
|
meetings,
|
||||||
|
agents,
|
||||||
|
onRefresh,
|
||||||
|
}: {
|
||||||
|
companyId: string;
|
||||||
|
meetings: MeetingRecord[];
|
||||||
|
agents: AgentRecord[];
|
||||||
|
onRefresh: () => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||||
|
const activeMeeting = useMemo(() => meetings.find((meeting) => meeting.id === editingId) ?? null, [meetings, editingId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<SectionHeader title="Meetings" count={meetings.length} action={() => setEditingId("__new__")} />
|
||||||
|
<ListFrame>
|
||||||
|
{meetings.map((meeting) => (
|
||||||
|
<ListRow key={meeting.id}>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<button type="button" className="min-w-0 flex-1 text-left" onClick={() => setEditingId(meeting.id)}>
|
||||||
|
<div className="flex items-center gap-2 text-[13px] font-medium text-[color:var(--text)]">
|
||||||
|
<CalendarBlank size={15} className="text-[color:var(--accent)]" />
|
||||||
|
<span className="truncate">{meeting.title}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-[11px] text-[color:var(--muted)]">
|
||||||
|
{meeting.type.replaceAll("_", " ")} · {new Date(meeting.scheduledAt).toLocaleString()} · {meeting.durationMinutes} min
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<StatusPill status={meeting.status} />
|
||||||
|
<button type="button" className="rounded p-1 text-[color:var(--muted)] hover:text-[color:var(--text)]" onClick={() => setEditingId(meeting.id)}>
|
||||||
|
<NotePencil size={14} />
|
||||||
|
</button>
|
||||||
|
<button type="button" className="rounded p-1 text-[color:var(--muted)] hover:text-[color:var(--danger)]" onClick={() => setDeleteId(meeting.id)}>
|
||||||
|
<Trash size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ListRow>
|
||||||
|
))}
|
||||||
|
</ListFrame>
|
||||||
|
<Modal open={editingId !== null} title={activeMeeting ? activeMeeting.title : "New meeting"} onClose={() => setEditingId(null)} width="760px">
|
||||||
|
<MeetingEditor
|
||||||
|
companyId={companyId}
|
||||||
|
agents={agents}
|
||||||
|
meeting={editingId === "__new__" ? null : activeMeeting}
|
||||||
|
onSaved={async () => {
|
||||||
|
setEditingId(null);
|
||||||
|
await onRefresh();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={deleteId !== null}
|
||||||
|
title="Delete meeting"
|
||||||
|
message="This removes the meeting and its operational context from the company timeline."
|
||||||
|
onCancel={() => setDeleteId(null)}
|
||||||
|
onConfirm={() => {
|
||||||
|
if (!deleteId) return;
|
||||||
|
void window.agentCompany.deleteMeeting({ id: deleteId, companyId }).then(() => onRefresh());
|
||||||
|
setDeleteId(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SprintManager({
|
||||||
|
companyId,
|
||||||
|
sprints,
|
||||||
|
onRefresh,
|
||||||
|
}: {
|
||||||
|
companyId: string;
|
||||||
|
sprints: SprintRecord[];
|
||||||
|
onRefresh: () => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||||
|
const activeSprint = useMemo(() => sprints.find((sprint) => sprint.id === editingId) ?? null, [sprints, editingId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<SectionHeader title="Sprint Board" count={sprints.length} action={() => setEditingId("__new__")} />
|
||||||
|
<ListFrame>
|
||||||
|
{sprints.map((sprint) => (
|
||||||
|
<ListRow key={sprint.id}>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<button type="button" className="min-w-0 flex-1 text-left" onClick={() => setEditingId(sprint.id)}>
|
||||||
|
<div className="flex items-center gap-2 text-[13px] font-medium text-[color:var(--text)]">
|
||||||
|
<Rows size={15} className="text-[color:var(--accent)]" />
|
||||||
|
<span className="truncate">{sprint.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-[11px] text-[color:var(--muted)]">
|
||||||
|
{sprint.startDate} → {sprint.endDate} · {sprint.completedPoints}/{sprint.velocityPoints} pts
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<StatusPill status={sprint.status} />
|
||||||
|
<button type="button" className="rounded p-1 text-[color:var(--muted)] hover:text-[color:var(--text)]" onClick={() => setEditingId(sprint.id)}>
|
||||||
|
<NotePencil size={14} />
|
||||||
|
</button>
|
||||||
|
<button type="button" className="rounded p-1 text-[color:var(--muted)] hover:text-[color:var(--danger)]" onClick={() => setDeleteId(sprint.id)}>
|
||||||
|
<Trash size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ListRow>
|
||||||
|
))}
|
||||||
|
</ListFrame>
|
||||||
|
<Modal open={editingId !== null} title={activeSprint ? activeSprint.name : "New sprint"} onClose={() => setEditingId(null)} width="720px">
|
||||||
|
<SprintEditor
|
||||||
|
companyId={companyId}
|
||||||
|
sprint={editingId === "__new__" ? null : activeSprint}
|
||||||
|
onSaved={async () => {
|
||||||
|
setEditingId(null);
|
||||||
|
await onRefresh();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={deleteId !== null}
|
||||||
|
title="Delete sprint"
|
||||||
|
message="This removes the sprint board entry. Historical run and task records remain."
|
||||||
|
onCancel={() => setDeleteId(null)}
|
||||||
|
onConfirm={() => {
|
||||||
|
if (!deleteId) return;
|
||||||
|
void window.agentCompany.deleteSprint({ id: deleteId, companyId }).then(() => onRefresh());
|
||||||
|
setDeleteId(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
@@ -25,10 +25,11 @@ import {
|
|||||||
import type {
|
import type {
|
||||||
ActivityRecord,
|
ActivityRecord,
|
||||||
AgentRecord,
|
AgentRecord,
|
||||||
|
AgentMessageRecord,
|
||||||
ApprovalRecord,
|
ApprovalRecord,
|
||||||
|
BrowserActionRecord,
|
||||||
ConnectorRecord,
|
ConnectorRecord,
|
||||||
CostRecord,
|
CostRecord,
|
||||||
DepartmentId,
|
|
||||||
GoalRecord,
|
GoalRecord,
|
||||||
MeetingRecord,
|
MeetingRecord,
|
||||||
ProjectRecord,
|
ProjectRecord,
|
||||||
@@ -36,14 +37,11 @@ import type {
|
|||||||
SectionId,
|
SectionId,
|
||||||
SprintRecord,
|
SprintRecord,
|
||||||
TaskRecord,
|
TaskRecord,
|
||||||
|
WorkflowPipelineRecord,
|
||||||
} from "@shared/types";
|
} from "@shared/types";
|
||||||
import { formatMoney } from "../lib/formatters";
|
import { formatMoney, getAgentInitials, timeAgo } from "../lib/formatters";
|
||||||
import { StatusPill } from "./ui";
|
import { StatusPill } from "./ui";
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* Types */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
interface DashboardProps {
|
interface DashboardProps {
|
||||||
agents: AgentRecord[];
|
agents: AgentRecord[];
|
||||||
tasks: TaskRecord[];
|
tasks: TaskRecord[];
|
||||||
@@ -54,43 +52,45 @@ interface DashboardProps {
|
|||||||
activity: ActivityRecord[];
|
activity: ActivityRecord[];
|
||||||
costs: CostRecord[];
|
costs: CostRecord[];
|
||||||
connectors: ConnectorRecord[];
|
connectors: ConnectorRecord[];
|
||||||
|
messages?: AgentMessageRecord[];
|
||||||
|
browserActions?: BrowserActionRecord[];
|
||||||
|
workflows?: WorkflowPipelineRecord[];
|
||||||
meetings?: MeetingRecord[];
|
meetings?: MeetingRecord[];
|
||||||
sprints?: SprintRecord[];
|
sprints?: SprintRecord[];
|
||||||
onNavigate: (section: SectionId, entityId?: string) => void;
|
onNavigate: (section: SectionId, entityId?: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
function computeHealthScore(props: Pick<DashboardProps, "agents" | "tasks" | "runs" | "approvals" | "connectors" | "messages" | "browserActions" | "workflows">): number {
|
||||||
/* Health Score Computation */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
function computeHealthScore(props: Pick<DashboardProps, "agents" | "tasks" | "runs" | "approvals" | "connectors">): number {
|
|
||||||
let score = 100;
|
let score = 100;
|
||||||
const { agents, tasks, runs, approvals, connectors } = props;
|
const { agents, tasks, runs, approvals, connectors, messages = [], browserActions = [], workflows = [] } = props;
|
||||||
|
|
||||||
// Penalize for failed runs (recent)
|
|
||||||
const failedRuns = runs.filter((r) => r.status === "failed" || r.status === "timed_out");
|
const failedRuns = runs.filter((r) => r.status === "failed" || r.status === "timed_out");
|
||||||
score -= Math.min(failedRuns.length * 5, 25);
|
score -= Math.min(failedRuns.length * 5, 25);
|
||||||
|
|
||||||
// Penalize for blocked tasks
|
|
||||||
const blockedTasks = tasks.filter((t) => t.status === "blocked");
|
const blockedTasks = tasks.filter((t) => t.status === "blocked");
|
||||||
score -= Math.min(blockedTasks.length * 8, 20);
|
score -= Math.min(blockedTasks.length * 8, 20);
|
||||||
|
|
||||||
// Penalize for pending approvals (bottleneck)
|
|
||||||
const pendingApprovals = approvals.filter((a) => a.state === "pending");
|
const pendingApprovals = approvals.filter((a) => a.state === "pending");
|
||||||
score -= Math.min(pendingApprovals.length * 5, 15);
|
score -= Math.min(pendingApprovals.length * 5, 15);
|
||||||
|
|
||||||
// Penalize for agents in error state
|
|
||||||
const errorAgents = agents.filter((a) => a.status === "error");
|
const errorAgents = agents.filter((a) => a.status === "error");
|
||||||
score -= Math.min(errorAgents.length * 10, 20);
|
score -= Math.min(errorAgents.length * 10, 20);
|
||||||
|
|
||||||
// Penalize for no ready connectors
|
|
||||||
const readyConnectors = connectors.filter((c) => c.status === "ready");
|
const readyConnectors = connectors.filter((c) => c.status === "ready");
|
||||||
if (readyConnectors.length === 0 && connectors.length > 0) score -= 15;
|
if (readyConnectors.length === 0 && connectors.length > 0) score -= 15;
|
||||||
|
|
||||||
// Penalize for overbudget agents
|
|
||||||
const overBudget = agents.filter((a) => a.budgetMonthlyUsd > 0 && a.spentMonthlyUsd >= a.budgetMonthlyUsd);
|
const overBudget = agents.filter((a) => a.budgetMonthlyUsd > 0 && a.spentMonthlyUsd >= a.budgetMonthlyUsd);
|
||||||
score -= Math.min(overBudget.length * 5, 10);
|
score -= Math.min(overBudget.length * 5, 10);
|
||||||
|
|
||||||
|
const urgentMessages = messages.filter((message) => !message.readAt && (message.priority === "urgent" || message.channel === "incident"));
|
||||||
|
score -= Math.min(urgentMessages.length * 4, 12);
|
||||||
|
|
||||||
|
const blockedBrowserActions = browserActions.filter((action) => action.status === "approval_required" || action.status === "failed");
|
||||||
|
score -= Math.min(blockedBrowserActions.length * 4, 12);
|
||||||
|
|
||||||
|
const stalledWorkflows = workflows.filter((workflow) => workflow.status === "paused" || workflow.status === "failed");
|
||||||
|
score -= Math.min(stalledWorkflows.length * 5, 15);
|
||||||
|
|
||||||
return Math.max(0, Math.min(100, score));
|
return Math.max(0, Math.min(100, score));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,10 +102,6 @@ function healthLabel(score: number): { label: string; tone: string } {
|
|||||||
return { label: "Critical", tone: "text-[color:var(--danger)]" };
|
return { label: "Critical", tone: "text-[color:var(--danger)]" };
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* Health Ring */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
function HealthRing({ score }: { score: number }) {
|
function HealthRing({ score }: { score: number }) {
|
||||||
const radius = 38;
|
const radius = 38;
|
||||||
const circumference = 2 * Math.PI * radius;
|
const circumference = 2 * Math.PI * radius;
|
||||||
@@ -149,10 +145,6 @@ function HealthRing({ score }: { score: number }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* Metric Card */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
function MetricCard({
|
function MetricCard({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
@@ -201,10 +193,6 @@ function MetricCard({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* Agent Workforce Grid */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
const statusGlow: Record<string, string> = {
|
const statusGlow: Record<string, string> = {
|
||||||
running: "ring-2 ring-[color:var(--success)] ring-opacity-50",
|
running: "ring-2 ring-[color:var(--success)] ring-opacity-50",
|
||||||
active: "ring-1 ring-[color:var(--success)] ring-opacity-30",
|
active: "ring-1 ring-[color:var(--success)] ring-opacity-30",
|
||||||
@@ -216,12 +204,7 @@ const statusGlow: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function AgentCard({ agent, onClick }: { agent: AgentRecord; onClick: () => void }) {
|
function AgentCard({ agent, onClick }: { agent: AgentRecord; onClick: () => void }) {
|
||||||
const initials = agent.name
|
const initials = getAgentInitials(agent.name);
|
||||||
.split(/\s+/)
|
|
||||||
.slice(0, 2)
|
|
||||||
.map((w) => w[0])
|
|
||||||
.join("")
|
|
||||||
.toUpperCase();
|
|
||||||
|
|
||||||
const isActive = agent.status === "running" || agent.status === "active";
|
const isActive = agent.status === "running" || agent.status === "active";
|
||||||
|
|
||||||
@@ -287,10 +270,6 @@ function WorkforceGrid({ agents, onSelectAgent }: { agents: AgentRecord[]; onSel
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* Activity Stream */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
const actionIcons: Record<string, React.ElementType> = {
|
const actionIcons: Record<string, React.ElementType> = {
|
||||||
created: CheckCircle,
|
created: CheckCircle,
|
||||||
updated: Lightning,
|
updated: Lightning,
|
||||||
@@ -302,20 +281,6 @@ const actionIcons: Record<string, React.ElementType> = {
|
|||||||
rejected: XCircle,
|
rejected: XCircle,
|
||||||
};
|
};
|
||||||
|
|
||||||
function timeAgo(dateStr: string): string {
|
|
||||||
const now = Date.now();
|
|
||||||
const then = new Date(dateStr).getTime();
|
|
||||||
const diff = Math.max(0, now - then);
|
|
||||||
const seconds = Math.floor(diff / 1000);
|
|
||||||
if (seconds < 60) return "just now";
|
|
||||||
const minutes = Math.floor(seconds / 60);
|
|
||||||
if (minutes < 60) return `${minutes}m ago`;
|
|
||||||
const hours = Math.floor(minutes / 60);
|
|
||||||
if (hours < 24) return `${hours}h ago`;
|
|
||||||
const days = Math.floor(hours / 24);
|
|
||||||
return `${days}d ago`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ActivityFeed({ activity, agents, limit = 15 }: { activity: ActivityRecord[]; agents: AgentRecord[]; limit?: number }) {
|
function ActivityFeed({ activity, agents, limit = 15 }: { activity: ActivityRecord[]; agents: AgentRecord[]; limit?: number }) {
|
||||||
const recentActivity = useMemo(() => {
|
const recentActivity = useMemo(() => {
|
||||||
return [...activity]
|
return [...activity]
|
||||||
@@ -368,10 +333,6 @@ function ActivityFeed({ activity, agents, limit = 15 }: { activity: ActivityReco
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* Workflow Pipeline (visual task flow) */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
function TaskPipeline({ tasks }: { tasks: TaskRecord[] }) {
|
function TaskPipeline({ tasks }: { tasks: TaskRecord[] }) {
|
||||||
const stages = useMemo(() => {
|
const stages = useMemo(() => {
|
||||||
const counts = { backlog: 0, todo: 0, in_progress: 0, in_review: 0, done: 0, blocked: 0 };
|
const counts = { backlog: 0, todo: 0, in_progress: 0, in_review: 0, done: 0, blocked: 0 };
|
||||||
@@ -405,10 +366,6 @@ function TaskPipeline({ tasks }: { tasks: TaskRecord[] }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* Department Overview */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
const DEPARTMENT_INFO: Record<string, { label: string; icon: React.ElementType; color: string }> = {
|
const DEPARTMENT_INFO: Record<string, { label: string; icon: React.ElementType; color: string }> = {
|
||||||
executive: { label: "Executive", icon: Buildings, color: "var(--accent)" },
|
executive: { label: "Executive", icon: Buildings, color: "var(--accent)" },
|
||||||
engineering: { label: "Engineering", icon: Cpu, color: "#6366f1" },
|
engineering: { label: "Engineering", icon: Cpu, color: "#6366f1" },
|
||||||
@@ -485,7 +442,6 @@ function DepartmentOverview({
|
|||||||
onClick={() => onNavigate("agents")}
|
onClick={() => onNavigate("agents")}
|
||||||
className="focus-ring group relative overflow-hidden rounded-[10px] border border-[color:var(--line)] bg-[color:var(--panel)] p-3 text-left transition hover:border-[color:var(--line-strong)] hover:shadow-sm"
|
className="focus-ring group relative overflow-hidden rounded-[10px] border border-[color:var(--line)] bg-[color:var(--panel)] p-3 text-left transition hover:border-[color:var(--line-strong)] hover:shadow-sm"
|
||||||
>
|
>
|
||||||
{/* Accent bar */}
|
|
||||||
<div className="absolute left-0 top-0 h-full w-[3px]" style={{ backgroundColor: info.color }} />
|
<div className="absolute left-0 top-0 h-full w-[3px]" style={{ backgroundColor: info.color }} />
|
||||||
|
|
||||||
<div className="flex items-center gap-2 pl-1">
|
<div className="flex items-center gap-2 pl-1">
|
||||||
@@ -524,11 +480,13 @@ function DepartmentOverview({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* Sprint Progress */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
function SprintProgress({ sprint, tasks }: { sprint: SprintRecord; tasks: TaskRecord[] }) {
|
function SprintProgress({ sprint, tasks }: { sprint: SprintRecord; tasks: TaskRecord[] }) {
|
||||||
|
const [nowMs, setNowMs] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setNowMs(Date.now());
|
||||||
|
}, []);
|
||||||
|
|
||||||
const progress = useMemo(() => {
|
const progress = useMemo(() => {
|
||||||
if (sprint.velocityPoints === 0) return 0;
|
if (sprint.velocityPoints === 0) return 0;
|
||||||
return Math.min(100, Math.round((sprint.completedPoints / sprint.velocityPoints) * 100));
|
return Math.min(100, Math.round((sprint.completedPoints / sprint.velocityPoints) * 100));
|
||||||
@@ -536,9 +494,9 @@ function SprintProgress({ sprint, tasks }: { sprint: SprintRecord; tasks: TaskRe
|
|||||||
|
|
||||||
const daysLeft = useMemo(() => {
|
const daysLeft = useMemo(() => {
|
||||||
const end = new Date(sprint.endDate).getTime();
|
const end = new Date(sprint.endDate).getTime();
|
||||||
const now = Date.now();
|
const now = nowMs ?? end;
|
||||||
return Math.max(0, Math.ceil((end - now) / (1000 * 60 * 60 * 24)));
|
return Math.max(0, Math.ceil((end - now) / (1000 * 60 * 60 * 24)));
|
||||||
}, [sprint]);
|
}, [sprint, nowMs]);
|
||||||
|
|
||||||
const sprintTasks = useMemo(() => {
|
const sprintTasks = useMemo(() => {
|
||||||
const done = tasks.filter((t) => t.status === "done").length;
|
const done = tasks.filter((t) => t.status === "done").length;
|
||||||
@@ -561,7 +519,6 @@ function SprintProgress({ sprint, tasks }: { sprint: SprintRecord; tasks: TaskRe
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Progress bar */}
|
|
||||||
<div className="mt-3 h-2 w-full overflow-hidden rounded-full bg-[color:var(--panel-soft)]">
|
<div className="mt-3 h-2 w-full overflow-hidden rounded-full bg-[color:var(--panel-soft)]">
|
||||||
<motion.div
|
<motion.div
|
||||||
className="h-full rounded-full bg-[color:var(--accent)]"
|
className="h-full rounded-full bg-[color:var(--accent)]"
|
||||||
@@ -587,10 +544,6 @@ function SprintProgress({ sprint, tasks }: { sprint: SprintRecord; tasks: TaskRe
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* Goal Progress Bars */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
function GoalProgress({ goals, tasks, onNavigate }: { goals: GoalRecord[]; tasks: TaskRecord[]; onNavigate: (section: SectionId) => void }) {
|
function GoalProgress({ goals, tasks, onNavigate }: { goals: GoalRecord[]; tasks: TaskRecord[]; onNavigate: (section: SectionId) => void }) {
|
||||||
const activeGoals = useMemo(() => goals.filter((g) => g.status === "active"), [goals]);
|
const activeGoals = useMemo(() => goals.filter((g) => g.status === "active"), [goals]);
|
||||||
|
|
||||||
@@ -634,16 +587,12 @@ function GoalProgress({ goals, tasks, onNavigate }: { goals: GoalRecord[]; tasks
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* Main Dashboard */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
export function Dashboard(props: DashboardProps) {
|
export function Dashboard(props: DashboardProps) {
|
||||||
const { agents, tasks, runs, approvals, goals, projects, activity, costs, connectors, meetings = [], sprints = [], onNavigate } = props;
|
const { agents, tasks, runs, approvals, goals, projects, activity, costs, connectors, messages = [], browserActions = [], workflows = [], sprints = [], onNavigate } = props;
|
||||||
|
|
||||||
const healthScore = useMemo(
|
const healthScore = useMemo(
|
||||||
() => computeHealthScore({ agents, tasks, runs, approvals, connectors }),
|
() => computeHealthScore({ agents, tasks, runs, approvals, connectors, messages, browserActions, workflows }),
|
||||||
[agents, tasks, runs, approvals, connectors],
|
[agents, tasks, runs, approvals, connectors, messages, browserActions, workflows],
|
||||||
);
|
);
|
||||||
|
|
||||||
const liveRuns = useMemo(() => runs.filter((r) => r.status === "running" || r.status === "queued"), [runs]);
|
const liveRuns = useMemo(() => runs.filter((r) => r.status === "running" || r.status === "queued"), [runs]);
|
||||||
@@ -655,16 +604,14 @@ export function Dashboard(props: DashboardProps) {
|
|||||||
const activeAgents = useMemo(() => agents.filter((a) => a.status !== "terminated" && a.status !== "pending_approval"), [agents]);
|
const activeAgents = useMemo(() => agents.filter((a) => a.status !== "terminated" && a.status !== "pending_approval"), [agents]);
|
||||||
const readyConnectors = useMemo(() => connectors.filter((c) => c.status === "ready"), [connectors]);
|
const readyConnectors = useMemo(() => connectors.filter((c) => c.status === "ready"), [connectors]);
|
||||||
const activeSprint = useMemo(() => sprints.find((s) => s.status === "active"), [sprints]);
|
const activeSprint = useMemo(() => sprints.find((s) => s.status === "active"), [sprints]);
|
||||||
const completionRate = useMemo(() => {
|
const unreadMessages = useMemo(() => messages.filter((message) => !message.readAt), [messages]);
|
||||||
const total = runs.length;
|
const urgentMessages = useMemo(() => messages.filter((message) => !message.readAt && (message.priority === "urgent" || message.channel === "incident")), [messages]);
|
||||||
if (total === 0) return 0;
|
const blockedBrowserActions = useMemo(() => browserActions.filter((action) => action.status === "approval_required" || action.status === "failed"), [browserActions]);
|
||||||
const succeeded = runs.filter((r) => r.status === "succeeded").length;
|
const stalledWorkflows = useMemo(() => workflows.filter((workflow) => workflow.status === "paused" || workflow.status === "failed"), [workflows]);
|
||||||
return Math.round((succeeded / total) * 100);
|
|
||||||
}, [runs]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* Row 1: Health + Metrics */}
|
{/* Health + Metrics */}
|
||||||
<div className="flex flex-col gap-6 lg:flex-row lg:items-start">
|
<div className="flex flex-col gap-6 lg:flex-row lg:items-start">
|
||||||
{/* Health Ring */}
|
{/* Health Ring */}
|
||||||
<div className="shrink-0 rounded-[12px] border border-[color:var(--line)] bg-[color:var(--panel)] p-5">
|
<div className="shrink-0 rounded-[12px] border border-[color:var(--line)] bg-[color:var(--panel)] p-5">
|
||||||
@@ -733,10 +680,34 @@ export function Dashboard(props: DashboardProps) {
|
|||||||
onClick={() => onNavigate("projects")}
|
onClick={() => onNavigate("projects")}
|
||||||
subtitle={`${projects.length} total`}
|
subtitle={`${projects.length} total`}
|
||||||
/>
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label="Unread Messages"
|
||||||
|
value={unreadMessages.length}
|
||||||
|
icon={Users}
|
||||||
|
tone={urgentMessages.length > 0 ? "warn" : unreadMessages.length > 0 ? "accent" : "default"}
|
||||||
|
onClick={() => onNavigate("communication")}
|
||||||
|
subtitle={urgentMessages.length > 0 ? `${urgentMessages.length} urgent` : unreadMessages.length > 0 ? "Needs triage" : "All read"}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label="Browser Ops"
|
||||||
|
value={blockedBrowserActions.length}
|
||||||
|
icon={Cpu}
|
||||||
|
tone={blockedBrowserActions.length > 0 ? "warn" : "default"}
|
||||||
|
onClick={() => onNavigate("social")}
|
||||||
|
subtitle={blockedBrowserActions.length > 0 ? "Blocked or failed" : "Nominal"}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label="Workflow Stalls"
|
||||||
|
value={stalledWorkflows.length}
|
||||||
|
icon={Lightning}
|
||||||
|
tone={stalledWorkflows.length > 0 ? "warn" : "default"}
|
||||||
|
onClick={() => onNavigate("automation")}
|
||||||
|
subtitle={stalledWorkflows.length > 0 ? "Paused or failed" : "Healthy"}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Row 2: Department Overview */}
|
{/* Department Overview */}
|
||||||
<section>
|
<section>
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<div className="mb-3 flex items-center justify-between">
|
||||||
<div className="text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Departments</div>
|
<div className="text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Departments</div>
|
||||||
@@ -751,7 +722,7 @@ export function Dashboard(props: DashboardProps) {
|
|||||||
<DepartmentOverview agents={agents} tasks={tasks} runs={runs} onNavigate={onNavigate} />
|
<DepartmentOverview agents={agents} tasks={tasks} runs={runs} onNavigate={onNavigate} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Row 3: Sprint + Goals side by side */}
|
{/* Sprint + Goals */}
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
{activeSprint ? (
|
{activeSprint ? (
|
||||||
<section>
|
<section>
|
||||||
@@ -784,7 +755,7 @@ export function Dashboard(props: DashboardProps) {
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Row 4: Task Pipeline */}
|
{/* Task Pipeline */}
|
||||||
<section>
|
<section>
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<div className="mb-3 flex items-center justify-between">
|
||||||
<div className="text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Task Pipeline</div>
|
<div className="text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Task Pipeline</div>
|
||||||
@@ -801,7 +772,7 @@ export function Dashboard(props: DashboardProps) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Row 5: Workforce + Activity */}
|
{/* Workforce + Activity */}
|
||||||
<div className="grid gap-6 lg:grid-cols-5">
|
<div className="grid gap-6 lg:grid-cols-5">
|
||||||
{/* Workforce */}
|
{/* Workforce */}
|
||||||
<section className="lg:col-span-3">
|
<section className="lg:col-span-3">
|
||||||
@@ -836,7 +807,7 @@ export function Dashboard(props: DashboardProps) {
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Row 4: Connector health (compact) */}
|
{/* Connector Health */}
|
||||||
<section>
|
<section>
|
||||||
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Infrastructure</div>
|
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Infrastructure</div>
|
||||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-5">
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-5">
|
||||||
@@ -867,8 +838,8 @@ export function Dashboard(props: DashboardProps) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Row 5: Quick Actions / Needs Attention */}
|
{/* Needs Attention */}
|
||||||
{(pendingApprovals.length > 0 || failedRuns.length > 0) ? (
|
{(pendingApprovals.length > 0 || failedRuns.length > 0 || urgentMessages.length > 0 || blockedBrowserActions.length > 0 || stalledWorkflows.length > 0) ? (
|
||||||
<section>
|
<section>
|
||||||
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--warn)]">Needs Attention</div>
|
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--warn)]">Needs Attention</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
@@ -913,6 +884,56 @@ export function Dashboard(props: DashboardProps) {
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{urgentMessages.slice(0, 3).map((message) => {
|
||||||
|
const sender = agents.find((agent) => agent.id === message.fromAgentId);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={message.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onNavigate("communication")}
|
||||||
|
className="focus-ring flex w-full items-center gap-3 rounded-[8px] border border-[color:var(--warn)] border-opacity-20 bg-[color:var(--warn-soft)] px-4 py-3 text-left transition hover:border-opacity-40"
|
||||||
|
>
|
||||||
|
<Users size={18} className="shrink-0 text-[color:var(--warn)]" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate text-[13px] font-medium text-[color:var(--text)]">{message.subject}</div>
|
||||||
|
<div className="mt-0.5 text-[11px] text-[color:var(--muted)]">
|
||||||
|
{sender?.name ?? "Unknown"} — {message.channel} {message.priority === "urgent" ? "· urgent" : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ArrowRight size={14} className="shrink-0 text-[color:var(--muted)]" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{blockedBrowserActions.slice(0, 3).map((action) => (
|
||||||
|
<button
|
||||||
|
key={action.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onNavigate("social")}
|
||||||
|
className="focus-ring flex w-full items-center gap-3 rounded-[8px] border border-[color:var(--warn)] border-opacity-20 bg-[color:var(--warn-soft)] px-4 py-3 text-left transition hover:border-opacity-40"
|
||||||
|
>
|
||||||
|
<Cpu size={18} className="shrink-0 text-[color:var(--warn)]" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate text-[13px] font-medium text-[color:var(--text)]">{action.resultSummary || action.actionType}</div>
|
||||||
|
<div className="mt-0.5 text-[11px] text-[color:var(--muted)]">Browser action {action.status.replaceAll("_", " ")}</div>
|
||||||
|
</div>
|
||||||
|
<ArrowRight size={14} className="shrink-0 text-[color:var(--muted)]" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{stalledWorkflows.slice(0, 3).map((workflow) => (
|
||||||
|
<button
|
||||||
|
key={workflow.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onNavigate("automation")}
|
||||||
|
className="focus-ring flex w-full items-center gap-3 rounded-[8px] border border-[color:var(--warn)] border-opacity-20 bg-[color:var(--warn-soft)] px-4 py-3 text-left transition hover:border-opacity-40"
|
||||||
|
>
|
||||||
|
<Lightning size={18} className="shrink-0 text-[color:var(--warn)]" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate text-[13px] font-medium text-[color:var(--text)]">{workflow.name}</div>
|
||||||
|
<div className="mt-0.5 text-[11px] text-[color:var(--muted)]">Workflow {workflow.status}</div>
|
||||||
|
</div>
|
||||||
|
<ArrowRight size={14} className="shrink-0 text-[color:var(--muted)]" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -920,10 +941,6 @@ export function Dashboard(props: DashboardProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* Kanban Board */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
interface KanbanProps {
|
interface KanbanProps {
|
||||||
tasks: TaskRecord[];
|
tasks: TaskRecord[];
|
||||||
agents: AgentRecord[];
|
agents: AgentRecord[];
|
||||||
@@ -957,7 +974,6 @@ export function KanbanBoard({ tasks, agents, runs, onSelectTask }: KanbanProps)
|
|||||||
if (bucket) bucket.push(task);
|
if (bucket) bucket.push(task);
|
||||||
else grouped.backlog?.push(task);
|
else grouped.backlog?.push(task);
|
||||||
}
|
}
|
||||||
// Sort each column by priority
|
|
||||||
const priorityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
|
const priorityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
|
||||||
for (const col of Object.values(grouped)) {
|
for (const col of Object.values(grouped)) {
|
||||||
col.sort((a, b) => (priorityOrder[a.priority] ?? 9) - (priorityOrder[b.priority] ?? 9));
|
col.sort((a, b) => (priorityOrder[a.priority] ?? 9) - (priorityOrder[b.priority] ?? 9));
|
||||||
@@ -981,7 +997,6 @@ export function KanbanBoard({ tasks, agents, runs, onSelectTask }: KanbanProps)
|
|||||||
const colTasks = tasksByStatus[col.status] ?? [];
|
const colTasks = tasksByStatus[col.status] ?? [];
|
||||||
return (
|
return (
|
||||||
<div key={col.status} className="flex w-[220px] shrink-0 flex-col">
|
<div key={col.status} className="flex w-[220px] shrink-0 flex-col">
|
||||||
{/* Column header */}
|
|
||||||
<div className="mb-2 flex items-center gap-2 px-1">
|
<div className="mb-2 flex items-center gap-2 px-1">
|
||||||
<span className={`h-2 w-2 rounded-full ${col.dotColor}`} />
|
<span className={`h-2 w-2 rounded-full ${col.dotColor}`} />
|
||||||
<span className={`text-[11px] font-semibold uppercase tracking-[0.08em] ${col.tone}`}>{col.label}</span>
|
<span className={`text-[11px] font-semibold uppercase tracking-[0.08em] ${col.tone}`}>{col.label}</span>
|
||||||
@@ -989,7 +1004,6 @@ export function KanbanBoard({ tasks, agents, runs, onSelectTask }: KanbanProps)
|
|||||||
{colTasks.length}
|
{colTasks.length}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/* Cards */}
|
|
||||||
<div className="flex flex-1 flex-col gap-1.5 rounded-[8px] bg-[color:var(--panel-soft)] p-1.5" style={{ minHeight: 120 }}>
|
<div className="flex flex-1 flex-col gap-1.5 rounded-[8px] bg-[color:var(--panel-soft)] p-1.5" style={{ minHeight: 120 }}>
|
||||||
{colTasks.map((task) => {
|
{colTasks.map((task) => {
|
||||||
const assignee = agents.find((a) => a.id === task.assigneeAgentId);
|
const assignee = agents.find((a) => a.id === task.assigneeAgentId);
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import type {
|
|||||||
ActivityRecord,
|
ActivityRecord,
|
||||||
AgentRecord,
|
AgentRecord,
|
||||||
ApprovalRecord,
|
ApprovalRecord,
|
||||||
|
DocumentRecord,
|
||||||
GoalRecord,
|
GoalRecord,
|
||||||
ProfileSnapshot,
|
ProfileSnapshot,
|
||||||
ProjectRecord,
|
ProjectRecord,
|
||||||
@@ -39,76 +40,12 @@ import type {
|
|||||||
} from "@shared/types";
|
} from "@shared/types";
|
||||||
import { formatMoney, formatTime } from "../lib/formatters";
|
import { formatMoney, formatTime } from "../lib/formatters";
|
||||||
import { unwrap } from "../lib/desktop";
|
import { unwrap } from "../lib/desktop";
|
||||||
import { ActionButton, EmptyState, Field, Input, ListFrame, ListRow, Select, StatusPill, TextArea, useFocusTrap } from "./ui";
|
import { ActionButton, ConfirmDialog, EmptyState, Field, InlineNotice, Input, ListFrame, ListRow, Select, StatusPill, TextArea } from "./ui";
|
||||||
|
|
||||||
function describeActionError(error: unknown) {
|
function describeActionError(error: unknown) {
|
||||||
return error instanceof Error ? error.message : "The action could not be completed.";
|
return error instanceof Error ? error.message : "The action could not be completed.";
|
||||||
}
|
}
|
||||||
|
|
||||||
function InlineNotice({
|
|
||||||
message,
|
|
||||||
tone = "danger",
|
|
||||||
}: {
|
|
||||||
message: string;
|
|
||||||
tone?: "danger" | "success" | "warn";
|
|
||||||
}) {
|
|
||||||
const toneClass = tone === "success"
|
|
||||||
? "border-[color:var(--success)] bg-[color:var(--success-soft)] text-[color:var(--success)]"
|
|
||||||
: tone === "warn"
|
|
||||||
? "border-[color:var(--warn)] bg-[color:var(--warn-soft)] text-[color:var(--warn)]"
|
|
||||||
: "border-[color:var(--danger)] bg-[color:var(--danger-soft)] text-[color:var(--danger)]";
|
|
||||||
return (
|
|
||||||
<div className={`rounded-[10px] border px-4 py-3 text-[13px] ${toneClass}`} role={tone === "danger" ? "alert" : "status"}>
|
|
||||||
{message}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Confirm Dialog ─────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
export function ConfirmDialog({ open, title, message, errorMessage, onConfirm, onCancel }: {
|
|
||||||
open: boolean;
|
|
||||||
title: string;
|
|
||||||
message: string;
|
|
||||||
errorMessage?: string | null;
|
|
||||||
onConfirm: () => void;
|
|
||||||
onCancel: () => void;
|
|
||||||
}) {
|
|
||||||
const trapRef = useFocusTrap(open);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
function onKey(event: KeyboardEvent) {
|
|
||||||
if (event.key === "Escape") onCancel();
|
|
||||||
}
|
|
||||||
document.addEventListener("keydown", onKey);
|
|
||||||
return () => document.removeEventListener("keydown", onKey);
|
|
||||||
}, [open, onCancel]);
|
|
||||||
|
|
||||||
if (!open) return null;
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[color:var(--text)]/45" onClick={onCancel}>
|
|
||||||
<div
|
|
||||||
ref={trapRef}
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-labelledby="confirm-dialog-title"
|
|
||||||
className="w-full max-w-[400px] rounded-[12px] bg-[color:var(--panel)] border border-[color:var(--line)] p-6 shadow-lg"
|
|
||||||
onClick={e => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<div id="confirm-dialog-title" className="text-[16px] font-semibold text-[color:var(--text)] mb-2">{title}</div>
|
|
||||||
<div className="text-[13px] text-[color:var(--muted)] mb-6">{message}</div>
|
|
||||||
{errorMessage ? <div className="mb-4"><InlineNotice message={errorMessage} /></div> : null}
|
|
||||||
<div className="flex gap-3 justify-end">
|
|
||||||
<ActionButton label="Cancel" onClick={onCancel} />
|
|
||||||
<ActionButton label="Confirm" tone="danger" onClick={onConfirm} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Entity-type icon lookup ────────────────────────────────────────── */
|
|
||||||
|
|
||||||
const ENTITY_ICONS: Record<string, typeof Robot> = {
|
const ENTITY_ICONS: Record<string, typeof Robot> = {
|
||||||
agent: Robot,
|
agent: Robot,
|
||||||
@@ -127,7 +64,6 @@ const ENTITY_TYPE_OPTIONS = [
|
|||||||
"all", "agent", "task", "run", "approval", "connector", "workspace", "secret", "goal", "project", "company",
|
"all", "agent", "task", "run", "approval", "connector", "workspace", "secret", "goal", "project", "company",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/* ─── Workspace state pills ──────────────────────────────────────────── */
|
|
||||||
|
|
||||||
function WorkspaceStatePill({ state }: { state: string }) {
|
function WorkspaceStatePill({ state }: { state: string }) {
|
||||||
const colorMap: Record<string, string> = {
|
const colorMap: Record<string, string> = {
|
||||||
@@ -144,7 +80,6 @@ function WorkspaceStatePill({ state }: { state: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Priority indicator ─────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
function PriorityDot({ priority }: { priority: string }) {
|
function PriorityDot({ priority }: { priority: string }) {
|
||||||
const color = priority === "critical"
|
const color = priority === "critical"
|
||||||
@@ -157,7 +92,6 @@ function PriorityDot({ priority }: { priority: string }) {
|
|||||||
return <span className={`inline-block h-2 w-2 shrink-0 rounded-full ${color}`} aria-label={`${priority} priority`} />;
|
return <span className={`inline-block h-2 w-2 shrink-0 rounded-full ${color}`} aria-label={`${priority} priority`} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Task Queue ─────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
export function TaskQueue({
|
export function TaskQueue({
|
||||||
tasks,
|
tasks,
|
||||||
@@ -204,7 +138,6 @@ export function TaskQueue({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Task Inspector ─────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
export function TaskInspector({
|
export function TaskInspector({
|
||||||
task,
|
task,
|
||||||
@@ -571,7 +504,6 @@ export function TaskInspector({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Task Comments ──────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
function TaskComments({
|
function TaskComments({
|
||||||
taskId,
|
taskId,
|
||||||
@@ -666,7 +598,6 @@ function TaskComments({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Task Creator ───────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
export function TaskCreator({
|
export function TaskCreator({
|
||||||
companyId,
|
companyId,
|
||||||
@@ -741,7 +672,6 @@ export function TaskCreator({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Pending Review List ────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
export function PendingReviewList({ tasks, agents }: { tasks: TaskRecord[]; agents: AgentRecord[] }) {
|
export function PendingReviewList({ tasks, agents }: { tasks: TaskRecord[]; agents: AgentRecord[] }) {
|
||||||
const reviewTasks = tasks.filter((t) => t.status === "in_review");
|
const reviewTasks = tasks.filter((t) => t.status === "in_review");
|
||||||
@@ -770,7 +700,6 @@ export function PendingReviewList({ tasks, agents }: { tasks: TaskRecord[]; agen
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Approval List ──────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
export function ApprovalList({ approvals, selectedApprovalId, onSelect }: { approvals: ApprovalRecord[]; selectedApprovalId: string | null; onSelect: (id: string) => void }) {
|
export function ApprovalList({ approvals, selectedApprovalId, onSelect }: { approvals: ApprovalRecord[]; selectedApprovalId: string | null; onSelect: (id: string) => void }) {
|
||||||
if (approvals.length === 0) {
|
if (approvals.length === 0) {
|
||||||
@@ -801,7 +730,6 @@ export function ApprovalList({ approvals, selectedApprovalId, onSelect }: { appr
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Approval Inspector ─────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
export function ApprovalInspector({
|
export function ApprovalInspector({
|
||||||
approval,
|
approval,
|
||||||
@@ -915,7 +843,6 @@ export function ApprovalInspector({
|
|||||||
|
|
||||||
{/* Hire agent payload details */}
|
{/* Hire agent payload details */}
|
||||||
{approval.type === "hire_agent" ? (() => {
|
{approval.type === "hire_agent" ? (() => {
|
||||||
// Find the agent that was proposed for hiring (look for pending_approval agents)
|
|
||||||
const proposedAgent = agents.find(
|
const proposedAgent = agents.find(
|
||||||
(a) => a.status === "pending_approval" && a.reportsTo === approval.requestedByAgentId
|
(a) => a.status === "pending_approval" && a.reportsTo === approval.requestedByAgentId
|
||||||
) ?? agents.find(
|
) ?? agents.find(
|
||||||
@@ -982,7 +909,6 @@ export function ApprovalInspector({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Cost List ──────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
export function CostList({ costs, agents, runs }: { costs: ProfileSnapshot["costs"]; agents: ProfileSnapshot["agents"]; runs: ProfileSnapshot["runs"] }) {
|
export function CostList({ costs, agents, runs }: { costs: ProfileSnapshot["costs"]; agents: ProfileSnapshot["agents"]; runs: ProfileSnapshot["runs"] }) {
|
||||||
const [connectorFilter, setConnectorFilter] = useState("all");
|
const [connectorFilter, setConnectorFilter] = useState("all");
|
||||||
@@ -1035,7 +961,6 @@ export function CostList({ costs, agents, runs }: { costs: ProfileSnapshot["cost
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Budget Summary ─────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
export function BudgetSummary({ agents, costs }: { agents: ProfileSnapshot["agents"]; costs: ProfileSnapshot["costs"] }) {
|
export function BudgetSummary({ agents, costs }: { agents: ProfileSnapshot["agents"]; costs: ProfileSnapshot["costs"] }) {
|
||||||
const knownSpend = costs.reduce((sum, entry) => sum + (entry.amountUsd ?? 0), 0);
|
const knownSpend = costs.reduce((sum, entry) => sum + (entry.amountUsd ?? 0), 0);
|
||||||
@@ -1101,7 +1026,6 @@ export function BudgetSummary({ agents, costs }: { agents: ProfileSnapshot["agen
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Activity List ──────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
export function ActivityList({ activity }: { activity: ActivityRecord[] }) {
|
export function ActivityList({ activity }: { activity: ActivityRecord[] }) {
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
@@ -1187,18 +1111,21 @@ export function ActivityList({ activity }: { activity: ActivityRecord[] }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Workspace List ─────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
export function WorkspaceList({
|
export function WorkspaceList({
|
||||||
workspaces,
|
workspaces,
|
||||||
projects,
|
projects,
|
||||||
companyId,
|
companyId,
|
||||||
|
selectedWorkspaceId,
|
||||||
|
onSelect,
|
||||||
onOpen,
|
onOpen,
|
||||||
onDeleted,
|
onDeleted,
|
||||||
}: {
|
}: {
|
||||||
workspaces: WorkspaceRecord[];
|
workspaces: WorkspaceRecord[];
|
||||||
projects: ProjectRecord[];
|
projects: ProjectRecord[];
|
||||||
companyId: string;
|
companyId: string;
|
||||||
|
selectedWorkspaceId: string | null;
|
||||||
|
onSelect: (workspaceId: string) => void;
|
||||||
onOpen: (path: string) => Promise<void>;
|
onOpen: (path: string) => Promise<void>;
|
||||||
onDeleted: () => Promise<void>;
|
onDeleted: () => Promise<void>;
|
||||||
}) {
|
}) {
|
||||||
@@ -1238,7 +1165,8 @@ export function WorkspaceList({
|
|||||||
const linkedProject = projects.find((p) => p.id === workspace.projectId) ?? null;
|
const linkedProject = projects.find((p) => p.id === workspace.projectId) ?? null;
|
||||||
return (
|
return (
|
||||||
<ListRow key={workspace.id}>
|
<ListRow key={workspace.id}>
|
||||||
<div className="mb-2 flex items-center justify-between">
|
<div className={`mb-2 rounded-[8px] p-1 ${selectedWorkspaceId === workspace.id ? "bg-[color:var(--accent-soft)]" : ""}`}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="text-[14px] text-[color:var(--text)]">{workspace.name}</div>
|
<div className="text-[14px] text-[color:var(--text)]">{workspace.name}</div>
|
||||||
@@ -1248,6 +1176,7 @@ export function WorkspaceList({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{workspace.isPrimary ? <StatusPill status="ready" /> : null}
|
{workspace.isPrimary ? <StatusPill status="ready" /> : null}
|
||||||
|
<ActionButton label="Inspect" onClick={() => onSelect(workspace.id)} icon={FolderOpen} />
|
||||||
<ActionButton label="Reveal" onClick={() => void onOpen(workspace.localPath)} icon={ArrowSquareOut} />
|
<ActionButton label="Reveal" onClick={() => void onOpen(workspace.localPath)} icon={ArrowSquareOut} />
|
||||||
<ActionButton label="Delete" onClick={() => setConfirmDeleteId(workspace.id)} icon={Trash} tone="danger" />
|
<ActionButton label="Delete" onClick={() => setConfirmDeleteId(workspace.id)} icon={Trash} tone="danger" />
|
||||||
</div>
|
</div>
|
||||||
@@ -1256,6 +1185,7 @@ export function WorkspaceList({
|
|||||||
{workspace.repoUrl || "No remote URL"} {workspace.repoRef ? `· ${workspace.repoRef}` : ""}
|
{workspace.repoUrl || "No remote URL"} {workspace.repoRef ? `· ${workspace.repoRef}` : ""}
|
||||||
{linkedProject ? <span> · Project: <span className="text-[color:var(--text)]">{linkedProject.name}</span></span> : null}
|
{linkedProject ? <span> · Project: <span className="text-[color:var(--text)]">{linkedProject.name}</span></span> : null}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</ListRow>
|
</ListRow>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -1264,7 +1194,133 @@ export function WorkspaceList({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Workspace Creator ──────────────────────────────────────────────── */
|
export function WorkspaceInspector({
|
||||||
|
workspace,
|
||||||
|
agents,
|
||||||
|
tasks,
|
||||||
|
runs,
|
||||||
|
documents,
|
||||||
|
project,
|
||||||
|
onOpen,
|
||||||
|
}: {
|
||||||
|
workspace: WorkspaceRecord | null;
|
||||||
|
agents: AgentRecord[];
|
||||||
|
tasks: TaskRecord[];
|
||||||
|
runs: RunRecord[];
|
||||||
|
documents: DocumentRecord[];
|
||||||
|
project: ProjectRecord | null;
|
||||||
|
onOpen: (path: string) => Promise<void>;
|
||||||
|
}) {
|
||||||
|
if (!workspace) {
|
||||||
|
return <EmptyState title="Select a workspace" detail="Choose a workspace to inspect the agents, tasks, runs, and documents bound to it." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspaceAgents = agents.filter((agent) => agent.workspaceId === workspace.id);
|
||||||
|
const workspaceTasks = tasks.filter((task) => task.workspaceId === workspace.id);
|
||||||
|
const workspaceRuns = runs.filter((run) => run.workspaceId === workspace.id);
|
||||||
|
const workspaceDocuments = documents.filter((document) => document.projectId && project && document.projectId === project.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="rounded-[10px] border border-[color:var(--line)] bg-[color:var(--panel)] p-4">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="text-[15px] font-semibold text-[color:var(--text)]">{workspace.name}</div>
|
||||||
|
<WorkspaceStatePill state={workspace.state} />
|
||||||
|
</div>
|
||||||
|
<div className="mono mt-1 text-[11px] text-[color:var(--muted)]">{workspace.localPath}</div>
|
||||||
|
<div className="mt-2 text-[12px] text-[color:var(--muted)]">
|
||||||
|
{project ? <>Project: <span className="text-[color:var(--text)]">{project.name}</span></> : "No linked project"}
|
||||||
|
{workspace.repoUrl ? <span> · Repo: <span className="text-[color:var(--text)]">{workspace.repoUrl}</span></span> : null}
|
||||||
|
{workspace.repoRef ? <span> · Ref: <span className="text-[color:var(--text)]">{workspace.repoRef}</span></span> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ActionButton label="Reveal in Finder" onClick={() => void onOpen(workspace.localPath)} icon={ArrowSquareOut} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 md:grid-cols-4">
|
||||||
|
<div className="rounded-[10px] border border-[color:var(--line)] bg-[color:var(--panel)] p-4">
|
||||||
|
<div className="text-[11px] uppercase tracking-[0.08em] text-[color:var(--muted)]">Agents</div>
|
||||||
|
<div className="mt-2 text-[24px] font-semibold text-[color:var(--text)]">{workspaceAgents.length}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[10px] border border-[color:var(--line)] bg-[color:var(--panel)] p-4">
|
||||||
|
<div className="text-[11px] uppercase tracking-[0.08em] text-[color:var(--muted)]">Tasks</div>
|
||||||
|
<div className="mt-2 text-[24px] font-semibold text-[color:var(--text)]">{workspaceTasks.length}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[10px] border border-[color:var(--line)] bg-[color:var(--panel)] p-4">
|
||||||
|
<div className="text-[11px] uppercase tracking-[0.08em] text-[color:var(--muted)]">Runs</div>
|
||||||
|
<div className="mt-2 text-[24px] font-semibold text-[color:var(--text)]">{workspaceRuns.length}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[10px] border border-[color:var(--line)] bg-[color:var(--panel)] p-4">
|
||||||
|
<div className="text-[11px] uppercase tracking-[0.08em] text-[color:var(--muted)]">Docs</div>
|
||||||
|
<div className="mt-2 text-[24px] font-semibold text-[color:var(--text)]">{workspaceDocuments.length}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 xl:grid-cols-3">
|
||||||
|
<div className="rounded-[10px] border border-[color:var(--line)] bg-[color:var(--panel)] p-4">
|
||||||
|
<div className="mb-3 text-[12px] font-semibold text-[color:var(--text)]">Assigned Agents</div>
|
||||||
|
{workspaceAgents.length === 0 ? (
|
||||||
|
<div className="text-[12px] text-[color:var(--muted)]">No agents are currently bound to this workspace.</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{workspaceAgents.map((agent) => (
|
||||||
|
<div key={agent.id} className="flex items-center justify-between rounded-[8px] bg-[color:var(--panel-soft)] px-3 py-2">
|
||||||
|
<div>
|
||||||
|
<div className="text-[12px] font-medium text-[color:var(--text)]">{agent.name}</div>
|
||||||
|
<div className="text-[11px] text-[color:var(--muted)]">{agent.role}</div>
|
||||||
|
</div>
|
||||||
|
<StatusPill status={agent.status} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-[10px] border border-[color:var(--line)] bg-[color:var(--panel)] p-4">
|
||||||
|
<div className="mb-3 text-[12px] font-semibold text-[color:var(--text)]">Open Tasks</div>
|
||||||
|
{workspaceTasks.length === 0 ? (
|
||||||
|
<div className="text-[12px] text-[color:var(--muted)]">No tasks target this workspace yet.</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{workspaceTasks.slice(0, 6).map((task) => (
|
||||||
|
<div key={task.id} className="rounded-[8px] bg-[color:var(--panel-soft)] px-3 py-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<PriorityDot priority={task.priority} />
|
||||||
|
<span className="text-[12px] font-medium text-[color:var(--text)]">{task.title}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-[11px] text-[color:var(--muted)]">{task.status.replaceAll("_", " ")}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-[10px] border border-[color:var(--line)] bg-[color:var(--panel)] p-4">
|
||||||
|
<div className="mb-3 text-[12px] font-semibold text-[color:var(--text)]">Recent Runs</div>
|
||||||
|
{workspaceRuns.length === 0 ? (
|
||||||
|
<div className="text-[12px] text-[color:var(--muted)]">No runs have executed in this workspace.</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{workspaceRuns.slice(0, 6).map((run) => (
|
||||||
|
<div key={run.id} className="rounded-[8px] bg-[color:var(--panel-soft)] px-3 py-2">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-[12px] font-medium text-[color:var(--text)]">{run.summary || `Run ${run.id.slice(0, 8)}`}</span>
|
||||||
|
<StatusPill status={run.status} />
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-[11px] text-[color:var(--muted)]">{formatTime(run.startedAt)} → {formatTime(run.finishedAt)}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export function WorkspaceCreator({ companyId, projects, onSaved }: { companyId: string; projects: ProjectRecord[]; onSaved: () => Promise<void> }) {
|
export function WorkspaceCreator({ companyId, projects, onSaved }: { companyId: string; projects: ProjectRecord[]; onSaved: () => Promise<void> }) {
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
@@ -1332,7 +1388,6 @@ export function WorkspaceCreator({ companyId, projects, onSaved }: { companyId:
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Settings Pane ──────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
export function SettingsPane({
|
export function SettingsPane({
|
||||||
snapshot,
|
snapshot,
|
||||||
@@ -1569,7 +1624,6 @@ export function SettingsPane({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Secret Creator ─────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
export function SecretCreator({ companyId, onSaved }: { companyId: string; onSaved: () => Promise<void> }) {
|
export function SecretCreator({ companyId, onSaved }: { companyId: string; onSaved: () => Promise<void> }) {
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ import {
|
|||||||
X,
|
X,
|
||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react";
|
||||||
import type { AgentRecord, ApprovalRecord } from "@shared/types";
|
import type { AgentRecord, ApprovalRecord } from "@shared/types";
|
||||||
|
import { getAgentInitials } from "../lib/formatters";
|
||||||
import { ActionButton, EmptyState, Panel, StatusPill } from "./ui";
|
import { ActionButton, EmptyState, Panel, StatusPill } from "./ui";
|
||||||
|
|
||||||
interface HiringPipelineProps {
|
interface HiringPipelineProps {
|
||||||
companyId: string;
|
|
||||||
agents: AgentRecord[];
|
agents: AgentRecord[];
|
||||||
approvals: ApprovalRecord[];
|
approvals: ApprovalRecord[];
|
||||||
onRefresh: () => Promise<void>;
|
onRefresh: () => Promise<void>;
|
||||||
@@ -19,7 +19,7 @@ interface HiringPipelineProps {
|
|||||||
type HiringStage = "requisition" | "pending_approval" | "approved" | "onboarding" | "active" | "rejected";
|
type HiringStage = "requisition" | "pending_approval" | "approved" | "onboarding" | "active" | "rejected";
|
||||||
|
|
||||||
interface HiringCandidate {
|
interface HiringCandidate {
|
||||||
agent: AgentRecord;
|
agent: AgentRecord | null;
|
||||||
approval: ApprovalRecord | null;
|
approval: ApprovalRecord | null;
|
||||||
stage: HiringStage;
|
stage: HiringStage;
|
||||||
}
|
}
|
||||||
@@ -33,7 +33,6 @@ function getHiringStage(agent: AgentRecord, approval: ApprovalRecord | null): Hi
|
|||||||
return "approved";
|
return "approved";
|
||||||
}
|
}
|
||||||
if (agent.status === "idle" && approval?.state === "approved") {
|
if (agent.status === "idle" && approval?.state === "approved") {
|
||||||
// Recently approved — check if they have any tasks yet
|
|
||||||
return "onboarding";
|
return "onboarding";
|
||||||
}
|
}
|
||||||
return "active";
|
return "active";
|
||||||
@@ -52,27 +51,28 @@ function stageLabel(stage: HiringStage): string {
|
|||||||
|
|
||||||
function stageColor(stage: HiringStage): string {
|
function stageColor(stage: HiringStage): string {
|
||||||
switch (stage) {
|
switch (stage) {
|
||||||
case "requisition": return "bg-amber-500/20 text-amber-400";
|
case "requisition": return "bg-[color:var(--warn-soft)] text-[color:var(--warn)]";
|
||||||
case "pending_approval": return "bg-yellow-500/20 text-yellow-400";
|
case "pending_approval": return "bg-[color:var(--warn-soft)] text-[color:var(--warn)]";
|
||||||
case "approved": return "bg-green-500/20 text-green-400";
|
case "approved": return "bg-[color:var(--success-soft)] text-[color:var(--success)]";
|
||||||
case "onboarding": return "bg-blue-500/20 text-blue-400";
|
case "onboarding": return "bg-[color:var(--accent-soft)] text-[color:var(--accent)]";
|
||||||
case "active": return "bg-emerald-500/20 text-emerald-400";
|
case "active": return "bg-[color:var(--success-soft)] text-[color:var(--success)]";
|
||||||
case "rejected": return "bg-red-500/20 text-red-400";
|
case "rejected": return "bg-[color:var(--danger-soft)] text-[color:var(--danger)]";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const STAGE_ORDER: HiringStage[] = ["requisition", "pending_approval", "approved", "onboarding", "active", "rejected"];
|
const STAGE_ORDER: HiringStage[] = ["requisition", "pending_approval", "approved", "onboarding", "active", "rejected"];
|
||||||
|
|
||||||
export function HiringPipeline({ companyId: _companyId, agents, approvals, onRefresh, onNavigateToAgent }: HiringPipelineProps) {
|
export function HiringPipeline({ agents, approvals, onRefresh, onNavigateToAgent }: HiringPipelineProps) {
|
||||||
const [filterStage, setFilterStage] = useState<HiringStage | "all">("all");
|
const [filterStage, setFilterStage] = useState<HiringStage | "all">("all");
|
||||||
|
|
||||||
const hireApprovals = approvals.filter(a => a.type === "hire_agent");
|
const hireApprovals = approvals.filter(a => a.type === "hire_agent");
|
||||||
|
|
||||||
const candidates: HiringCandidate[] = agents.map(agent => {
|
const candidates: HiringCandidate[] = agents.map(agent => {
|
||||||
const approval = hireApprovals.find(a =>
|
const approval = hireApprovals.find(a => {
|
||||||
a.relatedAgentId === agent.id ||
|
if (a.relatedAgentId === agent.id) return true;
|
||||||
(a.payloadJson && (() => { try { const p = JSON.parse(a.payloadJson); return p.agentId === agent.id; } catch { return false; } })())
|
if (!a.payloadJson) return false;
|
||||||
) ?? null;
|
try { return JSON.parse(a.payloadJson).agentId === agent.id; } catch { return false; }
|
||||||
|
}) ?? null;
|
||||||
return {
|
return {
|
||||||
agent,
|
agent,
|
||||||
approval,
|
approval,
|
||||||
@@ -80,7 +80,6 @@ export function HiringPipeline({ companyId: _companyId, agents, approvals, onRef
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Also include hire approvals that don't yet have an agent (pre-creation stage)
|
|
||||||
const pendingHireApprovals = hireApprovals.filter(a => {
|
const pendingHireApprovals = hireApprovals.filter(a => {
|
||||||
return a.state === "pending" && !candidates.some(c => c.approval?.id === a.id);
|
return a.state === "pending" && !candidates.some(c => c.approval?.id === a.id);
|
||||||
});
|
});
|
||||||
@@ -88,7 +87,7 @@ export function HiringPipeline({ companyId: _companyId, agents, approvals, onRef
|
|||||||
const allCandidates = [
|
const allCandidates = [
|
||||||
...candidates,
|
...candidates,
|
||||||
...pendingHireApprovals.map(a => ({
|
...pendingHireApprovals.map(a => ({
|
||||||
agent: null as unknown as AgentRecord,
|
agent: null,
|
||||||
approval: a,
|
approval: a,
|
||||||
stage: "pending_approval" as HiringStage,
|
stage: "pending_approval" as HiringStage,
|
||||||
})),
|
})),
|
||||||
@@ -98,7 +97,6 @@ export function HiringPipeline({ companyId: _companyId, agents, approvals, onRef
|
|||||||
? allCandidates
|
? allCandidates
|
||||||
: allCandidates.filter(c => c.stage === filterStage);
|
: allCandidates.filter(c => c.stage === filterStage);
|
||||||
|
|
||||||
// Stage counts for the pipeline overview
|
|
||||||
const stageCounts = STAGE_ORDER.reduce((acc, stage) => {
|
const stageCounts = STAGE_ORDER.reduce((acc, stage) => {
|
||||||
acc[stage] = allCandidates.filter(c => c.stage === stage).length;
|
acc[stage] = allCandidates.filter(c => c.stage === stage).length;
|
||||||
return acc;
|
return acc;
|
||||||
@@ -109,7 +107,6 @@ export function HiringPipeline({ companyId: _companyId, agents, approvals, onRef
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Pipeline Overview */}
|
|
||||||
<Panel title="Hiring Pipeline" eyebrow={`${activeHires} active hiring processes`}>
|
<Panel title="Hiring Pipeline" eyebrow={`${activeHires} active hiring processes`}>
|
||||||
<div className="flex items-center gap-1 px-4 py-3 overflow-x-auto">
|
<div className="flex items-center gap-1 px-4 py-3 overflow-x-auto">
|
||||||
{STAGE_ORDER.filter(s => s !== "rejected").map((stage, idx) => (
|
{STAGE_ORDER.filter(s => s !== "rejected").map((stage, idx) => (
|
||||||
@@ -117,16 +114,16 @@ export function HiringPipeline({ companyId: _companyId, agents, approvals, onRef
|
|||||||
<button
|
<button
|
||||||
onClick={() => setFilterStage(filterStage === stage ? "all" : stage)}
|
onClick={() => setFilterStage(filterStage === stage ? "all" : stage)}
|
||||||
className={`px-3 py-2 rounded-md text-xs font-medium transition-colors whitespace-nowrap ${
|
className={`px-3 py-2 rounded-md text-xs font-medium transition-colors whitespace-nowrap ${
|
||||||
filterStage === stage ? "ring-1 ring-orange-400/40 bg-zinc-700" : "hover:bg-zinc-700/60"
|
filterStage === stage ? "ring-1 ring-[color:var(--accent)]/40 bg-[color:var(--panel-soft)]" : "hover:bg-[color:var(--panel-soft)]"
|
||||||
} ${stageColor(stage)}`}
|
} ${stageColor(stage)}`}
|
||||||
>
|
>
|
||||||
{stageLabel(stage)}
|
{stageLabel(stage)}
|
||||||
<span className="ml-1.5 inline-flex items-center justify-center h-5 w-5 rounded-full bg-zinc-800 text-[10px]">
|
<span className="ml-1.5 inline-flex items-center justify-center h-5 w-5 rounded-full bg-[color:var(--line)] text-[10px]">
|
||||||
{stageCounts[stage]}
|
{stageCounts[stage]}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{idx < STAGE_ORDER.length - 2 && (
|
{idx < STAGE_ORDER.length - 2 && (
|
||||||
<ArrowRight size={12} className="text-zinc-600 flex-shrink-0" />
|
<ArrowRight size={12} className="text-[color:var(--muted)] flex-shrink-0" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -134,11 +131,11 @@ export function HiringPipeline({ companyId: _companyId, agents, approvals, onRef
|
|||||||
<button
|
<button
|
||||||
onClick={() => setFilterStage(filterStage === "rejected" ? "all" : "rejected")}
|
onClick={() => setFilterStage(filterStage === "rejected" ? "all" : "rejected")}
|
||||||
className={`ml-4 px-3 py-2 rounded-md text-xs font-medium transition-colors ${
|
className={`ml-4 px-3 py-2 rounded-md text-xs font-medium transition-colors ${
|
||||||
filterStage === "rejected" ? "ring-1 ring-red-400/40 bg-zinc-700" : "hover:bg-zinc-700/60"
|
filterStage === "rejected" ? "ring-1 ring-[color:var(--danger)]/40 bg-[color:var(--panel-soft)]" : "hover:bg-[color:var(--panel-soft)]"
|
||||||
} ${stageColor("rejected")}`}
|
} ${stageColor("rejected")}`}
|
||||||
>
|
>
|
||||||
Rejected
|
Rejected
|
||||||
<span className="ml-1.5 inline-flex items-center justify-center h-5 w-5 rounded-full bg-zinc-800 text-[10px]">
|
<span className="ml-1.5 inline-flex items-center justify-center h-5 w-5 rounded-full bg-[color:var(--line)] text-[10px]">
|
||||||
{stageCounts.rejected}
|
{stageCounts.rejected}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -146,10 +143,9 @@ export function HiringPipeline({ companyId: _companyId, agents, approvals, onRef
|
|||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
{/* Pending Approvals Section */}
|
|
||||||
{pendingCount > 0 && (
|
{pendingCount > 0 && (
|
||||||
<Panel title="Awaiting Board Decision" eyebrow={`${pendingCount} hire requests need your review`}>
|
<Panel title="Awaiting Board Decision" eyebrow={`${pendingCount} hire requests need your review`}>
|
||||||
<div className="divide-y divide-zinc-800/60">
|
<div className="divide-y divide-[color:var(--line)]">
|
||||||
{allCandidates
|
{allCandidates
|
||||||
.filter(c => c.stage === "requisition" || c.stage === "pending_approval")
|
.filter(c => c.stage === "requisition" || c.stage === "pending_approval")
|
||||||
.map((candidate) => {
|
.map((candidate) => {
|
||||||
@@ -157,32 +153,32 @@ export function HiringPipeline({ companyId: _companyId, agents, approvals, onRef
|
|||||||
const ag = candidate.agent;
|
const ag = candidate.agent;
|
||||||
return (
|
return (
|
||||||
<div key={a?.id ?? ag?.id} className="px-4 py-3 flex items-start gap-3">
|
<div key={a?.id ?? ag?.id} className="px-4 py-3 flex items-start gap-3">
|
||||||
<div className="flex-shrink-0 mt-0.5 h-8 w-8 rounded-full bg-amber-500/15 flex items-center justify-center">
|
<div className="flex-shrink-0 mt-0.5 h-8 w-8 rounded-full bg-[color:var(--warn-soft)] flex items-center justify-center">
|
||||||
<UserPlus size={16} className="text-amber-400" />
|
<UserPlus size={16} className="text-[color:var(--warn)]" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-medium text-sm text-zinc-200">{ag?.name ?? "Proposed Agent"}</span>
|
<span className="font-medium text-sm text-[color:var(--text)]">{ag?.name ?? "Proposed Agent"}</span>
|
||||||
<span className={`text-[10px] px-1.5 py-0.5 rounded ${stageColor(candidate.stage)}`}>
|
<span className={`text-[10px] px-1.5 py-0.5 rounded ${stageColor(candidate.stage)}`}>
|
||||||
{stageLabel(candidate.stage)}
|
{stageLabel(candidate.stage)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-zinc-400 mt-0.5">
|
<div className="text-xs text-[color:var(--muted)] mt-0.5">
|
||||||
{ag ? `${ag.role} — ${ag.title}` : a?.payloadSummary}
|
{ag ? `${ag.role} — ${ag.title}` : a?.payloadSummary}
|
||||||
</div>
|
</div>
|
||||||
{ag?.department && (
|
{ag?.department && (
|
||||||
<div className="text-xs text-zinc-500 mt-0.5">
|
<div className="text-xs text-[color:var(--muted)] mt-0.5">
|
||||||
Dept: {ag.department} | Budget: ${ag.budgetMonthlyUsd}/mo | Connector: {ag.connectorId}
|
Dept: {ag.department} | Budget: ${ag.budgetMonthlyUsd}/mo | Connector: {ag.connectorId}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{a?.payloadSummary && (
|
{a?.payloadSummary && (
|
||||||
<div className="text-xs text-zinc-500 mt-1">{a.payloadSummary}</div>
|
<div className="text-xs text-[color:var(--muted)] mt-1">{a.payloadSummary}</div>
|
||||||
)}
|
)}
|
||||||
{a?.impactSummary && (
|
{a?.impactSummary && (
|
||||||
<div className="text-xs text-zinc-500">{a.impactSummary}</div>
|
<div className="text-xs text-[color:var(--muted)]">{a.impactSummary}</div>
|
||||||
)}
|
)}
|
||||||
{ag?.capabilities && (
|
{ag?.capabilities && (
|
||||||
<div className="text-xs text-zinc-600 mt-1 line-clamp-2">{ag.capabilities}</div>
|
<div className="text-xs text-[color:var(--muted)] mt-1 line-clamp-2">{ag.capabilities}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||||
@@ -194,6 +190,7 @@ export function HiringPipeline({ companyId: _companyId, agents, approvals, onRef
|
|||||||
tone="accent"
|
tone="accent"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await window.agentCompany.decideApproval({
|
await window.agentCompany.decideApproval({
|
||||||
|
companyId: a.companyId,
|
||||||
approvalId: a.id,
|
approvalId: a.id,
|
||||||
state: "approved",
|
state: "approved",
|
||||||
decisionNote: "Hire approved via hiring pipeline",
|
decisionNote: "Hire approved via hiring pipeline",
|
||||||
@@ -207,6 +204,7 @@ export function HiringPipeline({ companyId: _companyId, agents, approvals, onRef
|
|||||||
tone="danger"
|
tone="danger"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await window.agentCompany.decideApproval({
|
await window.agentCompany.decideApproval({
|
||||||
|
companyId: a.companyId,
|
||||||
approvalId: a.id,
|
approvalId: a.id,
|
||||||
state: "rejected",
|
state: "rejected",
|
||||||
decisionNote: "Hire rejected via hiring pipeline",
|
decisionNote: "Hire rejected via hiring pipeline",
|
||||||
@@ -224,7 +222,6 @@ export function HiringPipeline({ companyId: _companyId, agents, approvals, onRef
|
|||||||
</Panel>
|
</Panel>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Full Pipeline List */}
|
|
||||||
<Panel
|
<Panel
|
||||||
title={filterStage === "all" ? "All Agents" : `${stageLabel(filterStage)} (${filteredCandidates.length})`}
|
title={filterStage === "all" ? "All Agents" : `${stageLabel(filterStage)} (${filteredCandidates.length})`}
|
||||||
action={
|
action={
|
||||||
@@ -239,7 +236,7 @@ export function HiringPipeline({ companyId: _companyId, agents, approvals, onRef
|
|||||||
detail="When agents propose new hires via the API, they will appear here for your review."
|
detail="When agents propose new hires via the API, they will appear here for your review."
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y divide-zinc-800/60">
|
<div className="divide-y divide-[color:var(--line)]">
|
||||||
{filteredCandidates.map((candidate) => {
|
{filteredCandidates.map((candidate) => {
|
||||||
const ag = candidate.agent;
|
const ag = candidate.agent;
|
||||||
if (!ag) return null;
|
if (!ag) return null;
|
||||||
@@ -250,32 +247,32 @@ export function HiringPipeline({ companyId: _companyId, agents, approvals, onRef
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={ag.id}
|
key={ag.id}
|
||||||
className="px-4 py-3 flex items-center gap-3 hover:bg-zinc-800/40 cursor-pointer transition-colors"
|
className="px-4 py-3 flex items-center gap-3 hover:bg-[color:var(--panel-soft)] cursor-pointer transition-colors"
|
||||||
onClick={() => onNavigateToAgent(ag.id)}
|
onClick={() => onNavigateToAgent(ag.id)}
|
||||||
>
|
>
|
||||||
<div className="flex-shrink-0 h-8 w-8 rounded-full bg-zinc-700/60 flex items-center justify-center text-xs font-bold text-zinc-300">
|
<div className="flex-shrink-0 h-8 w-8 rounded-full bg-[color:var(--panel-soft)] flex items-center justify-center text-xs font-bold text-[color:var(--muted-strong)]">
|
||||||
{ag.name.split(" ").map(n => n[0]).join("").slice(0, 2)}
|
{getAgentInitials(ag.name)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-medium text-sm text-zinc-200 truncate">{ag.name}</span>
|
<span className="font-medium text-sm text-[color:var(--text)] truncate">{ag.name}</span>
|
||||||
<span className={`text-[10px] px-1.5 py-0.5 rounded ${stageColor(candidate.stage)}`}>
|
<span className={`text-[10px] px-1.5 py-0.5 rounded ${stageColor(candidate.stage)}`}>
|
||||||
{stageLabel(candidate.stage)}
|
{stageLabel(candidate.stage)}
|
||||||
</span>
|
</span>
|
||||||
<StatusPill status={ag.status} />
|
<StatusPill status={ag.status} />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-zinc-400 mt-0.5">
|
<div className="text-xs text-[color:var(--muted)] mt-0.5">
|
||||||
{ag.role} — {ag.title}{ag.department ? ` | ${ag.department}` : ""}
|
{ag.role} — {ag.title}{ag.department ? ` | ${ag.department}` : ""}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-shrink-0 text-right">
|
<div className="flex-shrink-0 text-right">
|
||||||
<div className="text-xs text-zinc-500">
|
<div className="text-xs text-[color:var(--muted)]">
|
||||||
{manager ? `Reports to ${manager.name}` : "Top level"}
|
{manager ? `Reports to ${manager.name}` : "Top level"}
|
||||||
</div>
|
</div>
|
||||||
{requestedBy && (
|
{requestedBy && (
|
||||||
<div className="text-[10px] text-zinc-600">Proposed by {requestedBy.name}</div>
|
<div className="text-[10px] text-[color:var(--muted)]">Proposed by {requestedBy.name}</div>
|
||||||
)}
|
)}
|
||||||
<div className="text-xs text-zinc-500">${ag.budgetMonthlyUsd}/mo</div>
|
<div className="text-xs text-[color:var(--muted)]">${ag.budgetMonthlyUsd}/mo</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -284,31 +281,29 @@ export function HiringPipeline({ companyId: _companyId, agents, approvals, onRef
|
|||||||
)}
|
)}
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
{/* Hiring Statistics */}
|
|
||||||
<div className="grid grid-cols-4 gap-3">
|
<div className="grid grid-cols-4 gap-3">
|
||||||
<div className="bg-zinc-900/60 rounded-lg px-4 py-3 border border-zinc-800/40">
|
<div className="rounded-lg px-4 py-3 border border-[color:var(--line)] bg-[color:var(--panel)]">
|
||||||
<div className="text-[10px] uppercase text-zinc-500 tracking-wider">Total Agents</div>
|
<div className="text-[10px] uppercase text-[color:var(--muted)] tracking-wider">Total Agents</div>
|
||||||
<div className="text-2xl font-bold text-zinc-200 mt-1">{agents.filter(a => a.status !== "terminated").length}</div>
|
<div className="text-2xl font-bold text-[color:var(--text)] mt-1">{agents.filter(a => a.status !== "terminated").length}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-zinc-900/60 rounded-lg px-4 py-3 border border-zinc-800/40">
|
<div className="rounded-lg px-4 py-3 border border-[color:var(--line)] bg-[color:var(--panel)]">
|
||||||
<div className="text-[10px] uppercase text-zinc-500 tracking-wider">Pending Hires</div>
|
<div className="text-[10px] uppercase text-[color:var(--muted)] tracking-wider">Pending Hires</div>
|
||||||
<div className="text-2xl font-bold text-amber-400 mt-1">{agents.filter(a => a.status === "pending_approval").length}</div>
|
<div className="text-2xl font-bold text-[color:var(--warn)] mt-1">{agents.filter(a => a.status === "pending_approval").length}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-zinc-900/60 rounded-lg px-4 py-3 border border-zinc-800/40">
|
<div className="rounded-lg px-4 py-3 border border-[color:var(--line)] bg-[color:var(--panel)]">
|
||||||
<div className="text-[10px] uppercase text-zinc-500 tracking-wider">Departments</div>
|
<div className="text-[10px] uppercase text-[color:var(--muted)] tracking-wider">Departments</div>
|
||||||
<div className="text-2xl font-bold text-zinc-200 mt-1">
|
<div className="text-2xl font-bold text-[color:var(--text)] mt-1">
|
||||||
{new Set(agents.filter(a => a.department && a.status !== "terminated").map(a => a.department)).size}
|
{new Set(agents.filter(a => a.department && a.status !== "terminated").map(a => a.department)).size}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-zinc-900/60 rounded-lg px-4 py-3 border border-zinc-800/40">
|
<div className="rounded-lg px-4 py-3 border border-[color:var(--line)] bg-[color:var(--panel)]">
|
||||||
<div className="text-[10px] uppercase text-zinc-500 tracking-wider">Monthly Payroll</div>
|
<div className="text-[10px] uppercase text-[color:var(--muted)] tracking-wider">Monthly Payroll</div>
|
||||||
<div className="text-2xl font-bold text-zinc-200 mt-1">
|
<div className="text-2xl font-bold text-[color:var(--text)] mt-1">
|
||||||
${agents.filter(a => a.status !== "terminated" && a.status !== "pending_approval").reduce((s, a) => s + a.budgetMonthlyUsd, 0).toLocaleString()}
|
${agents.filter(a => a.status !== "terminated" && a.status !== "pending_approval").reduce((s, a) => s + a.budgetMonthlyUsd, 0).toLocaleString()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Department Headcount */}
|
|
||||||
<Panel title="Department Headcount">
|
<Panel title="Department Headcount">
|
||||||
<div className="grid grid-cols-3 gap-2 p-4">
|
<div className="grid grid-cols-3 gap-2 p-4">
|
||||||
{(["executive", "engineering", "product", "design", "marketing", "sales", "hr", "finance", "legal", "customer_support", "operations", "research"] as const).map(dept => {
|
{(["executive", "engineering", "product", "design", "marketing", "sales", "hr", "finance", "legal", "customer_support", "operations", "research"] as const).map(dept => {
|
||||||
@@ -317,15 +312,15 @@ export function HiringPipeline({ companyId: _companyId, agents, approvals, onRef
|
|||||||
const active = deptAgents.filter(a => a.status !== "pending_approval").length;
|
const active = deptAgents.filter(a => a.status !== "pending_approval").length;
|
||||||
if (active === 0 && pending === 0) return null;
|
if (active === 0 && pending === 0) return null;
|
||||||
return (
|
return (
|
||||||
<div key={dept} className="bg-zinc-800/40 rounded-md px-3 py-2">
|
<div key={dept} className="bg-[color:var(--panel-soft)] rounded-md px-3 py-2">
|
||||||
<div className="text-xs font-medium text-zinc-300 capitalize">{dept.replace("_", " ")}</div>
|
<div className="text-xs font-medium text-[color:var(--text)] capitalize">{dept.replace("_", " ")}</div>
|
||||||
<div className="flex items-center gap-2 mt-1">
|
<div className="flex items-center gap-2 mt-1">
|
||||||
<span className="text-sm font-bold text-zinc-200">{active}</span>
|
<span className="text-sm font-bold text-[color:var(--text)]">{active}</span>
|
||||||
<span className="text-[10px] text-zinc-500">active</span>
|
<span className="text-[10px] text-[color:var(--muted)]">active</span>
|
||||||
{pending > 0 && (
|
{pending > 0 && (
|
||||||
<>
|
<>
|
||||||
<span className="text-sm font-bold text-amber-400">+{pending}</span>
|
<span className="text-sm font-bold text-[color:var(--warn)]">+{pending}</span>
|
||||||
<span className="text-[10px] text-amber-400/70">pending</span>
|
<span className="text-[10px] text-[color:var(--warn)]">pending</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,19 +10,14 @@ import {
|
|||||||
CheckCircle,
|
CheckCircle,
|
||||||
Clock,
|
Clock,
|
||||||
Eye,
|
Eye,
|
||||||
FunnelSimple,
|
|
||||||
Robot,
|
Robot,
|
||||||
Heartbeat,
|
Heartbeat,
|
||||||
Target,
|
Target,
|
||||||
ChatCircle,
|
ChatCircle,
|
||||||
Tray,
|
Tray,
|
||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react";
|
||||||
import type { InboxItem, InboxItemType, SectionId, StandupReport, AgentMetrics, CompanyMetrics } from "@shared/types";
|
import type { InboxItem, InboxItemType, SectionId, StandupReport, CompanyMetrics } from "@shared/types";
|
||||||
import { formatMoney } from "../lib/formatters";
|
import { formatMoney, timeAgo, getAgentInitials, formatDuration } from "../lib/formatters";
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* Inbox Panel */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
const typeConfig: Record<InboxItemType, { icon: React.ElementType; color: string; bg: string; label: string }> = {
|
const typeConfig: Record<InboxItemType, { icon: React.ElementType; color: string; bg: string; label: string }> = {
|
||||||
pending_approval: { icon: ShieldCheck, color: "var(--warn)", bg: "var(--warn-soft)", label: "Approval" },
|
pending_approval: { icon: ShieldCheck, color: "var(--warn)", bg: "var(--warn-soft)", label: "Approval" },
|
||||||
@@ -40,49 +35,28 @@ const severityColors: Record<string, string> = {
|
|||||||
low: "border-l-[color:var(--line)]",
|
low: "border-l-[color:var(--line)]",
|
||||||
};
|
};
|
||||||
|
|
||||||
function timeAgo(dateStr: string): string {
|
|
||||||
const now = Date.now();
|
|
||||||
const then = new Date(dateStr).getTime();
|
|
||||||
const diff = Math.max(0, now - then);
|
|
||||||
const seconds = Math.floor(diff / 1000);
|
|
||||||
if (seconds < 60) return "just now";
|
|
||||||
const minutes = Math.floor(seconds / 60);
|
|
||||||
if (minutes < 60) return `${minutes}m ago`;
|
|
||||||
const hours = Math.floor(minutes / 60);
|
|
||||||
if (hours < 24) return `${hours}h ago`;
|
|
||||||
const days = Math.floor(hours / 24);
|
|
||||||
return `${days}d ago`;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface InboxProps {
|
interface InboxProps {
|
||||||
items: InboxItem[];
|
items: InboxItem[];
|
||||||
onNavigate: (section: SectionId, entityId?: string) => void;
|
onNavigate: (section: SectionId, entityId?: string) => void;
|
||||||
onDismiss: (itemId: string) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Inbox({ items, onNavigate, onDismiss }: InboxProps) {
|
export function Inbox({ items, onNavigate }: InboxProps) {
|
||||||
const [filter, setFilter] = useState<InboxItemType | "all">("all");
|
const [filter, setFilter] = useState<InboxItemType | "all">("all");
|
||||||
const [showDismissed, setShowDismissed] = useState(false);
|
|
||||||
|
|
||||||
const filteredItems = useMemo(() => {
|
const filteredItems = useMemo(() => {
|
||||||
let result = items;
|
if (filter === "all") return items;
|
||||||
if (!showDismissed) result = result.filter(i => !i.dismissed);
|
return items.filter(i => i.type === filter);
|
||||||
if (filter !== "all") result = result.filter(i => i.type === filter);
|
}, [items, filter]);
|
||||||
return result;
|
|
||||||
}, [items, filter, showDismissed]);
|
|
||||||
|
|
||||||
const counts = useMemo(() => {
|
const counts = useMemo(() => ({
|
||||||
const active = items.filter(i => !i.dismissed);
|
all: items.length,
|
||||||
return {
|
pending_approval: items.filter(i => i.type === "pending_approval").length,
|
||||||
all: active.length,
|
failed_run: items.filter(i => i.type === "failed_run").length,
|
||||||
pending_approval: active.filter(i => i.type === "pending_approval").length,
|
budget_warning: items.filter(i => i.type === "budget_warning").length,
|
||||||
failed_run: active.filter(i => i.type === "failed_run").length,
|
blocked_task: items.filter(i => i.type === "blocked_task").length,
|
||||||
budget_warning: active.filter(i => i.type === "budget_warning").length,
|
review_needed: items.filter(i => i.type === "review_needed").length,
|
||||||
blocked_task: active.filter(i => i.type === "blocked_task").length,
|
agent_message: items.filter(i => i.type === "agent_message").length,
|
||||||
review_needed: active.filter(i => i.type === "review_needed").length,
|
}), [items]);
|
||||||
agent_message: active.filter(i => i.type === "agent_message").length,
|
|
||||||
};
|
|
||||||
}, [items]);
|
|
||||||
|
|
||||||
const handleItemClick = (item: InboxItem) => {
|
const handleItemClick = (item: InboxItem) => {
|
||||||
const sectionMap: Record<string, SectionId> = {
|
const sectionMap: Record<string, SectionId> = {
|
||||||
@@ -90,6 +64,7 @@ export function Inbox({ items, onNavigate, onDismiss }: InboxProps) {
|
|||||||
run: "tasks",
|
run: "tasks",
|
||||||
agent: "agents",
|
agent: "agents",
|
||||||
task: "tasks",
|
task: "tasks",
|
||||||
|
message: "communication",
|
||||||
};
|
};
|
||||||
const section = sectionMap[item.entityType] ?? "overview";
|
const section = sectionMap[item.entityType] ?? "overview";
|
||||||
onNavigate(section, item.entityId);
|
onNavigate(section, item.entityId);
|
||||||
@@ -102,11 +77,11 @@ export function Inbox({ items, onNavigate, onDismiss }: InboxProps) {
|
|||||||
{ key: "failed_run", label: "Failed", count: counts.failed_run },
|
{ key: "failed_run", label: "Failed", count: counts.failed_run },
|
||||||
{ key: "blocked_task", label: "Blocked", count: counts.blocked_task },
|
{ key: "blocked_task", label: "Blocked", count: counts.blocked_task },
|
||||||
{ key: "budget_warning", label: "Budget", count: counts.budget_warning },
|
{ key: "budget_warning", label: "Budget", count: counts.budget_warning },
|
||||||
|
{ key: "agent_message", label: "Messages", count: counts.agent_message },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-[10px] bg-[color:var(--accent-soft)]">
|
<div className="flex h-10 w-10 items-center justify-center rounded-[10px] bg-[color:var(--accent-soft)]">
|
||||||
@@ -121,7 +96,6 @@ export function Inbox({ items, onNavigate, onDismiss }: InboxProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filter Bar */}
|
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
{filterButtons.map(btn => (
|
{filterButtons.map(btn => (
|
||||||
<button
|
<button
|
||||||
@@ -146,7 +120,6 @@ export function Inbox({ items, onNavigate, onDismiss }: InboxProps) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Items */}
|
|
||||||
{filteredItems.length === 0 ? (
|
{filteredItems.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center rounded-[10px] border border-dashed border-[color:var(--line)] py-16 text-center">
|
<div className="flex flex-col items-center justify-center rounded-[10px] border border-dashed border-[color:var(--line)] py-16 text-center">
|
||||||
<Tray size={32} className="mb-3 text-[color:var(--muted)]" />
|
<Tray size={32} className="mb-3 text-[color:var(--muted)]" />
|
||||||
@@ -209,9 +182,6 @@ export function Inbox({ items, onNavigate, onDismiss }: InboxProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* Standup Report View */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
interface StandupProps {
|
interface StandupProps {
|
||||||
report: StandupReport | null;
|
report: StandupReport | null;
|
||||||
@@ -231,7 +201,6 @@ export function StandupView({ report, onNavigate }: StandupProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
|
||||||
<div className="rounded-[12px] border border-[color:var(--line)] bg-[color:var(--panel)] p-5">
|
<div className="rounded-[12px] border border-[color:var(--line)] bg-[color:var(--panel)] p-5">
|
||||||
<div className="flex items-center gap-3 mb-4">
|
<div className="flex items-center gap-3 mb-4">
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-[10px] bg-[color:var(--accent-soft)]">
|
<div className="flex h-10 w-10 items-center justify-center rounded-[10px] bg-[color:var(--accent-soft)]">
|
||||||
@@ -270,7 +239,6 @@ export function StandupView({ report, onNavigate }: StandupProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Company Highlights */}
|
|
||||||
{report.companyHighlights.length > 0 ? (
|
{report.companyHighlights.length > 0 ? (
|
||||||
<section>
|
<section>
|
||||||
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Highlights</div>
|
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Highlights</div>
|
||||||
@@ -285,7 +253,6 @@ export function StandupView({ report, onNavigate }: StandupProps) {
|
|||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Blockers */}
|
|
||||||
{report.blockers.length > 0 ? (
|
{report.blockers.length > 0 ? (
|
||||||
<section>
|
<section>
|
||||||
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--danger)]">Blockers</div>
|
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--danger)]">Blockers</div>
|
||||||
@@ -300,7 +267,6 @@ export function StandupView({ report, onNavigate }: StandupProps) {
|
|||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Agent Reports */}
|
|
||||||
<section>
|
<section>
|
||||||
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Agent Status</div>
|
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Agent Status</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -317,7 +283,7 @@ export function StandupView({ report, onNavigate }: StandupProps) {
|
|||||||
: ar.status === "error" ? "bg-[color:var(--danger-soft)] text-[color:var(--danger)]"
|
: ar.status === "error" ? "bg-[color:var(--danger-soft)] text-[color:var(--danger)]"
|
||||||
: "bg-[color:var(--panel-soft)] text-[color:var(--muted-strong)]"
|
: "bg-[color:var(--panel-soft)] text-[color:var(--muted-strong)]"
|
||||||
}`}>
|
}`}>
|
||||||
{ar.agentName.split(/\s+/).slice(0, 2).map(w => w[0]).join("").toUpperCase()}
|
{getAgentInitials(ar.agentName)}
|
||||||
</div>
|
</div>
|
||||||
{ar.status === "running" ? (
|
{ar.status === "running" ? (
|
||||||
<span className="absolute -bottom-0.5 -right-0.5 flex h-3 w-3">
|
<span className="absolute -bottom-0.5 -right-0.5 flex h-3 w-3">
|
||||||
@@ -350,9 +316,6 @@ export function StandupView({ report, onNavigate }: StandupProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* Performance Metrics View */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
interface PerformanceProps {
|
interface PerformanceProps {
|
||||||
metrics: CompanyMetrics | null;
|
metrics: CompanyMetrics | null;
|
||||||
@@ -372,7 +335,6 @@ export function PerformanceView({ metrics, onNavigate }: PerformanceProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Company Overview Metrics */}
|
|
||||||
<div className="grid grid-cols-2 gap-2 lg:grid-cols-4">
|
<div className="grid grid-cols-2 gap-2 lg:grid-cols-4">
|
||||||
<CompactMetric
|
<CompactMetric
|
||||||
label="Success Rate"
|
label="Success Rate"
|
||||||
@@ -398,7 +360,6 @@ export function PerformanceView({ metrics, onNavigate }: PerformanceProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Run Stats */}
|
|
||||||
<div className="rounded-[12px] border border-[color:var(--line)] bg-[color:var(--panel)] p-5">
|
<div className="rounded-[12px] border border-[color:var(--line)] bg-[color:var(--panel)] p-5">
|
||||||
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Run Activity (30d)</div>
|
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Run Activity (30d)</div>
|
||||||
<div className="flex items-center gap-6">
|
<div className="flex items-center gap-6">
|
||||||
@@ -422,7 +383,6 @@ export function PerformanceView({ metrics, onNavigate }: PerformanceProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Agent Performance Table */}
|
|
||||||
<section>
|
<section>
|
||||||
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Agent Performance</div>
|
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Agent Performance</div>
|
||||||
<div className="overflow-x-auto rounded-[10px] border border-[color:var(--line)]">
|
<div className="overflow-x-auto rounded-[10px] border border-[color:var(--line)]">
|
||||||
@@ -449,7 +409,7 @@ export function PerformanceView({ metrics, onNavigate }: PerformanceProps) {
|
|||||||
<td className="px-4 py-2.5">
|
<td className="px-4 py-2.5">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-[color:var(--panel-soft)] text-[10px] font-bold text-[color:var(--muted-strong)]">
|
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-[color:var(--panel-soft)] text-[10px] font-bold text-[color:var(--muted-strong)]">
|
||||||
{am.agentName.split(/\s+/).slice(0, 2).map(w => w[0]).join("").toUpperCase()}
|
{getAgentInitials(am.agentName)}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[12px] font-medium text-[color:var(--text)]">{am.agentName}</span>
|
<span className="text-[12px] font-medium text-[color:var(--text)]">{am.agentName}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -477,9 +437,6 @@ export function PerformanceView({ metrics, onNavigate }: PerformanceProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* Helpers */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
function CompactMetric({ label, value, tone = "default", subtitle }: {
|
function CompactMetric({ label, value, tone = "default", subtitle }: {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -526,12 +483,3 @@ function RunProgressBar({ succeeded, failed, total }: { succeeded: number; faile
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDuration(seconds: number): string {
|
|
||||||
if (seconds < 60) return `${seconds}s`;
|
|
||||||
const minutes = Math.floor(seconds / 60);
|
|
||||||
const secs = seconds % 60;
|
|
||||||
if (minutes < 60) return `${minutes}m ${secs}s`;
|
|
||||||
const hours = Math.floor(minutes / 60);
|
|
||||||
return `${hours}h ${minutes % 60}m`;
|
|
||||||
}
|
|
||||||
@@ -17,7 +17,7 @@ import type {
|
|||||||
ProjectRecord,
|
ProjectRecord,
|
||||||
WorkspaceRecord,
|
WorkspaceRecord,
|
||||||
} from "@shared/types";
|
} from "@shared/types";
|
||||||
import { formatMoney, formatTime } from "../lib/formatters";
|
import { formatMoney, formatTime, getAgentInitials } from "../lib/formatters";
|
||||||
import { unwrap } from "../lib/desktop";
|
import { unwrap } from "../lib/desktop";
|
||||||
import {
|
import {
|
||||||
ActionButton,
|
ActionButton,
|
||||||
@@ -30,10 +30,6 @@ import {
|
|||||||
TextArea,
|
TextArea,
|
||||||
} from "./ui";
|
} from "./ui";
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* Helpers */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
function BudgetBar({ spent, budget }: { spent: number; budget: number }) {
|
function BudgetBar({ spent, budget }: { spent: number; budget: number }) {
|
||||||
const pct = budget > 0 ? Math.min((spent / budget) * 100, 100) : 0;
|
const pct = budget > 0 ? Math.min((spent / budget) * 100, 100) : 0;
|
||||||
const overBudget = spent > budget && budget > 0;
|
const overBudget = spent > budget && budget > 0;
|
||||||
@@ -70,10 +66,6 @@ function ConfirmDelete({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* AgentRoster */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
export function AgentRoster({
|
export function AgentRoster({
|
||||||
agents,
|
agents,
|
||||||
connectors,
|
connectors,
|
||||||
@@ -132,10 +124,6 @@ export function AgentRoster({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* AgentCreator */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
export function AgentCreator({
|
export function AgentCreator({
|
||||||
companyId,
|
companyId,
|
||||||
connectors,
|
connectors,
|
||||||
@@ -261,7 +249,7 @@ export function AgentCreator({
|
|||||||
setReportsTo("");
|
setReportsTo("");
|
||||||
await onSaved();
|
await onSaved();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to create agent:", error);
|
void error;
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
}
|
}
|
||||||
@@ -271,10 +259,6 @@ export function AgentCreator({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* AgentEditor */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
export function AgentEditor({
|
export function AgentEditor({
|
||||||
agent,
|
agent,
|
||||||
connectors,
|
connectors,
|
||||||
@@ -532,10 +516,6 @@ export function AgentEditor({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* GoalList */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
export function GoalList({
|
export function GoalList({
|
||||||
goals,
|
goals,
|
||||||
agents,
|
agents,
|
||||||
@@ -585,10 +565,6 @@ export function GoalList({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* GoalCreator */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
export function GoalCreator({
|
export function GoalCreator({
|
||||||
companyId,
|
companyId,
|
||||||
agents,
|
agents,
|
||||||
@@ -649,7 +625,7 @@ export function GoalCreator({
|
|||||||
setDescription("");
|
setDescription("");
|
||||||
await onSaved();
|
await onSaved();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to create goal:", error);
|
void error;
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
}
|
}
|
||||||
@@ -659,10 +635,6 @@ export function GoalCreator({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* GoalEditor */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
export function GoalEditor({
|
export function GoalEditor({
|
||||||
goal,
|
goal,
|
||||||
goals,
|
goals,
|
||||||
@@ -741,7 +713,7 @@ export function GoalEditor({
|
|||||||
});
|
});
|
||||||
await onSaved();
|
await onSaved();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to save goal:", error);
|
void error;
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
}
|
}
|
||||||
@@ -770,10 +742,6 @@ export function GoalEditor({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* ProjectList */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
export function ProjectList({
|
export function ProjectList({
|
||||||
projects,
|
projects,
|
||||||
goals,
|
goals,
|
||||||
@@ -834,10 +802,6 @@ export function ProjectList({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* ProjectCreator */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
export function ProjectCreator({
|
export function ProjectCreator({
|
||||||
companyId,
|
companyId,
|
||||||
goals,
|
goals,
|
||||||
@@ -902,7 +866,7 @@ export function ProjectCreator({
|
|||||||
setTargetDate("");
|
setTargetDate("");
|
||||||
await onSaved();
|
await onSaved();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to create project:", error);
|
void error;
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
}
|
}
|
||||||
@@ -912,10 +876,6 @@ export function ProjectCreator({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* ProjectEditor */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
export function ProjectEditor({
|
export function ProjectEditor({
|
||||||
project,
|
project,
|
||||||
goals,
|
goals,
|
||||||
@@ -995,7 +955,7 @@ export function ProjectEditor({
|
|||||||
});
|
});
|
||||||
await onSaved();
|
await onSaved();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to save project:", error);
|
void error;
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
}
|
}
|
||||||
@@ -1024,10 +984,6 @@ export function ProjectEditor({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* OrgChart – tree layout with pan & zoom */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
const CARD_W = 220;
|
const CARD_W = 220;
|
||||||
const CARD_H = 110;
|
const CARD_H = 110;
|
||||||
const GAP_X = 32;
|
const GAP_X = 32;
|
||||||
@@ -1276,7 +1232,7 @@ export function OrgChart({
|
|||||||
{allNodes.map((node) => {
|
{allNodes.map((node) => {
|
||||||
const dotColor = statusDotColor[node.agent.status] ?? "#a3a3a3";
|
const dotColor = statusDotColor[node.agent.status] ?? "#a3a3a3";
|
||||||
const connectorLabel = adapterLabels[node.agent.connectorId] ?? node.agent.connectorId;
|
const connectorLabel = adapterLabels[node.agent.connectorId] ?? node.agent.connectorId;
|
||||||
const initials = node.agent.name.split(/\s+/).slice(0, 2).map((w) => w[0]).join("").toUpperCase();
|
const initials = getAgentInitials(node.agent.name);
|
||||||
const isRunning = node.agent.status === "running";
|
const isRunning = node.agent.status === "running";
|
||||||
const isActive = node.agent.status === "active" || isRunning;
|
const isActive = node.agent.status === "active" || isRunning;
|
||||||
const isError = node.agent.status === "error";
|
const isError = node.agent.status === "error";
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import {
|
import {
|
||||||
ArrowRight,
|
|
||||||
Browser,
|
Browser,
|
||||||
|
ArrowSquareOut,
|
||||||
Camera,
|
Camera,
|
||||||
|
ClockCounterClockwise,
|
||||||
ChatCircle,
|
ChatCircle,
|
||||||
Eye,
|
Eye,
|
||||||
Globe,
|
Globe,
|
||||||
@@ -17,16 +18,12 @@ import {
|
|||||||
SignIn,
|
SignIn,
|
||||||
Trash,
|
Trash,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
Warning,
|
|
||||||
X,
|
X,
|
||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react";
|
||||||
import type { BrowserActionRecord, SectionId, SocialAccountRecord, SocialPlatform } from "@shared/types";
|
import type { BrowserActionRecord, SocialAccountRecord, SocialPlatform } from "@shared/types";
|
||||||
|
import { timeAgo } from "../lib/formatters";
|
||||||
import { ActionButton, Input } from "./ui";
|
import { ActionButton, Input } from "./ui";
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* Platform Config */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
const platformConfig: Record<SocialPlatform, { label: string; color: string; bgColor: string; icon: string }> = {
|
const platformConfig: Record<SocialPlatform, { label: string; color: string; bgColor: string; icon: string }> = {
|
||||||
twitter: { label: "Twitter / X", color: "#1DA1F2", bgColor: "#1DA1F210", icon: "𝕏" },
|
twitter: { label: "Twitter / X", color: "#1DA1F2", bgColor: "#1DA1F210", icon: "𝕏" },
|
||||||
linkedin: { label: "LinkedIn", color: "#0A66C2", bgColor: "#0A66C210", icon: "in" },
|
linkedin: { label: "LinkedIn", color: "#0A66C2", bgColor: "#0A66C210", icon: "in" },
|
||||||
@@ -49,27 +46,9 @@ const actionTypeLabels: Record<string, { label: string; icon: React.ElementType
|
|||||||
navigate: { label: "Navigate", icon: Globe },
|
navigate: { label: "Navigate", icon: Globe },
|
||||||
search: { label: "Search", icon: MagnifyingGlass },
|
search: { label: "Search", icon: MagnifyingGlass },
|
||||||
dm: { label: "Direct Message", icon: ChatCircle },
|
dm: { label: "Direct Message", icon: ChatCircle },
|
||||||
custom_script: { label: "Custom Script", icon: Browser },
|
custom_script: { label: "Legacy Action", icon: Browser },
|
||||||
};
|
};
|
||||||
|
|
||||||
function timeAgo(dateStr: string): string {
|
|
||||||
const now = Date.now();
|
|
||||||
const then = new Date(dateStr).getTime();
|
|
||||||
const diff = Math.max(0, now - then);
|
|
||||||
const seconds = Math.floor(diff / 1000);
|
|
||||||
if (seconds < 60) return "just now";
|
|
||||||
const minutes = Math.floor(seconds / 60);
|
|
||||||
if (minutes < 60) return `${minutes}m ago`;
|
|
||||||
const hours = Math.floor(minutes / 60);
|
|
||||||
if (hours < 24) return `${hours}h ago`;
|
|
||||||
const days = Math.floor(hours / 24);
|
|
||||||
return `${days}d ago`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* Social Account Card */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
function AccountCard({
|
function AccountCard({
|
||||||
account,
|
account,
|
||||||
onLogin,
|
onLogin,
|
||||||
@@ -86,7 +65,6 @@ function AccountCard({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="group flex items-center gap-3 rounded-[10px] border border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-3 transition hover:border-[color:var(--line-strong)] hover:shadow-sm">
|
<div className="group flex items-center gap-3 rounded-[10px] border border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-3 transition hover:border-[color:var(--line-strong)] hover:shadow-sm">
|
||||||
{/* Platform icon */}
|
|
||||||
<div
|
<div
|
||||||
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-[10px] text-[14px] font-bold"
|
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-[10px] text-[14px] font-bold"
|
||||||
style={{ backgroundColor: platform.bgColor, color: platform.color }}
|
style={{ backgroundColor: platform.bgColor, color: platform.color }}
|
||||||
@@ -94,7 +72,6 @@ function AccountCard({
|
|||||||
{platform.icon}
|
{platform.icon}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Info */}
|
|
||||||
<button type="button" onClick={onClick} className="min-w-0 flex-1 text-left">
|
<button type="button" onClick={onClick} className="min-w-0 flex-1 text-left">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-[13px] font-semibold text-[color:var(--text)]">{account.displayName || account.accountName}</span>
|
<span className="text-[13px] font-semibold text-[color:var(--text)]">{account.displayName || account.accountName}</span>
|
||||||
@@ -117,7 +94,6 @@ function AccountCard({
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Status + Actions */}
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className={`flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[10px] font-semibold ${
|
<span className={`flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[10px] font-semibold ${
|
||||||
isLoggedIn
|
isLoggedIn
|
||||||
@@ -160,10 +136,6 @@ function AccountCard({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* Add Account Dialog */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
function AddAccountDialog({
|
function AddAccountDialog({
|
||||||
onAdd,
|
onAdd,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -199,7 +171,6 @@ function AddAccountDialog({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Platform selector */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">Platform</label>
|
<label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">Platform</label>
|
||||||
<div className="grid grid-cols-3 gap-1.5">
|
<div className="grid grid-cols-3 gap-1.5">
|
||||||
@@ -235,7 +206,6 @@ function AddAccountDialog({
|
|||||||
placeholder="Your display name"
|
placeholder="Your display name"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Approval toggle */}
|
|
||||||
<div className="rounded-[8px] border border-[color:var(--line)] bg-[color:var(--panel-soft)] px-4 py-3">
|
<div className="rounded-[8px] border border-[color:var(--line)] bg-[color:var(--panel-soft)] px-4 py-3">
|
||||||
<label className="flex items-center gap-3">
|
<label className="flex items-center gap-3">
|
||||||
<input
|
<input
|
||||||
@@ -279,11 +249,19 @@ function AddAccountDialog({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
function ActionHistory({
|
||||||
/* Browser Action History */
|
actions,
|
||||||
/* ------------------------------------------------------------------ */
|
agents,
|
||||||
|
onOpenPath,
|
||||||
function ActionHistory({ actions, agents }: { actions: BrowserActionRecord[]; agents: Array<{ id: string; name: string }> }) {
|
onCancelAction,
|
||||||
|
onNavigate,
|
||||||
|
}: {
|
||||||
|
actions: BrowserActionRecord[];
|
||||||
|
agents: Array<{ id: string; name: string }>;
|
||||||
|
onOpenPath: (path: string) => Promise<void>;
|
||||||
|
onCancelAction: (actionId: string) => Promise<void>;
|
||||||
|
onNavigate: (section: "tasks" | "approvals", entityId: string) => void;
|
||||||
|
}) {
|
||||||
if (actions.length === 0) {
|
if (actions.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="py-8 text-center text-[13px] text-[color:var(--muted)]">
|
<div className="py-8 text-center text-[13px] text-[color:var(--muted)]">
|
||||||
@@ -329,6 +307,46 @@ function ActionHistory({ actions, agents }: { actions: BrowserActionRecord[]; ag
|
|||||||
{action.errorMessage ? (
|
{action.errorMessage ? (
|
||||||
<div className="mt-0.5 truncate text-[11px] text-[color:var(--danger)]">{action.errorMessage}</div>
|
<div className="mt-0.5 truncate text-[11px] text-[color:var(--danger)]">{action.errorMessage}</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-2">
|
||||||
|
{action.taskId ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onNavigate("tasks", action.taskId!)}
|
||||||
|
className="rounded-[5px] bg-[color:var(--panel-soft)] px-2 py-1 text-[10px] font-medium text-[color:var(--muted-strong)] transition hover:text-[color:var(--text)]"
|
||||||
|
>
|
||||||
|
Open task
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{action.approvalId ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onNavigate("approvals", action.approvalId!)}
|
||||||
|
className="rounded-[5px] bg-[color:var(--panel-soft)] px-2 py-1 text-[10px] font-medium text-[color:var(--muted-strong)] transition hover:text-[color:var(--text)]"
|
||||||
|
>
|
||||||
|
Open approval
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{action.screenshotPath ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void onOpenPath(action.screenshotPath!)}
|
||||||
|
className="inline-flex items-center gap-1 rounded-[5px] bg-[color:var(--panel-soft)] px-2 py-1 text-[10px] font-medium text-[color:var(--muted-strong)] transition hover:text-[color:var(--text)]"
|
||||||
|
>
|
||||||
|
<ArrowSquareOut size={10} />
|
||||||
|
Screenshot
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{["queued", "running", "approval_required"].includes(action.status) ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void onCancelAction(action.id)}
|
||||||
|
className="inline-flex items-center gap-1 rounded-[5px] bg-[color:var(--danger-soft)] px-2 py-1 text-[10px] font-medium text-[color:var(--danger)] transition hover:brightness-95"
|
||||||
|
>
|
||||||
|
<ClockCounterClockwise size={10} />
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="shrink-0 text-[10px] text-[color:var(--muted)]">
|
<div className="shrink-0 text-[10px] text-[color:var(--muted)]">
|
||||||
{timeAgo(action.createdAt)}
|
{timeAgo(action.createdAt)}
|
||||||
@@ -349,10 +367,6 @@ function ActionHistory({ actions, agents }: { actions: BrowserActionRecord[]; ag
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* Main Social Accounts Panel */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
interface SocialPanelProps {
|
interface SocialPanelProps {
|
||||||
accounts: SocialAccountRecord[];
|
accounts: SocialAccountRecord[];
|
||||||
actions: BrowserActionRecord[];
|
actions: BrowserActionRecord[];
|
||||||
@@ -367,7 +381,9 @@ interface SocialPanelProps {
|
|||||||
}) => Promise<void>;
|
}) => Promise<void>;
|
||||||
onDelete: (id: string) => Promise<void>;
|
onDelete: (id: string) => Promise<void>;
|
||||||
onLogin: (accountId: string) => Promise<void>;
|
onLogin: (accountId: string) => Promise<void>;
|
||||||
onNavigate: (section: SectionId, entityId?: string) => void;
|
onOpenPath: (path: string) => Promise<void>;
|
||||||
|
onCancelAction: (actionId: string) => Promise<void>;
|
||||||
|
onNavigate: (section: "tasks" | "approvals", entityId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SocialAccountsPanel({
|
export function SocialAccountsPanel({
|
||||||
@@ -378,6 +394,8 @@ export function SocialAccountsPanel({
|
|||||||
onSave,
|
onSave,
|
||||||
onDelete,
|
onDelete,
|
||||||
onLogin,
|
onLogin,
|
||||||
|
onOpenPath,
|
||||||
|
onCancelAction,
|
||||||
onNavigate,
|
onNavigate,
|
||||||
}: SocialPanelProps) {
|
}: SocialPanelProps) {
|
||||||
const [showAddDialog, setShowAddDialog] = useState(false);
|
const [showAddDialog, setShowAddDialog] = useState(false);
|
||||||
@@ -390,7 +408,6 @@ export function SocialAccountsPanel({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-[10px] bg-[color:var(--accent-soft)]">
|
<div className="flex h-10 w-10 items-center justify-center rounded-[10px] bg-[color:var(--accent-soft)]">
|
||||||
@@ -411,7 +428,6 @@ export function SocialAccountsPanel({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Info banner */}
|
|
||||||
<div className="rounded-[10px] border border-[color:var(--accent)] border-opacity-20 bg-[color:var(--accent-soft)] px-4 py-3">
|
<div className="rounded-[10px] border border-[color:var(--accent)] border-opacity-20 bg-[color:var(--accent-soft)] px-4 py-3">
|
||||||
<div className="flex items-start gap-2 text-[12px] text-[color:var(--accent)]">
|
<div className="flex items-start gap-2 text-[12px] text-[color:var(--accent)]">
|
||||||
<Browser size={16} className="mt-0.5 shrink-0" />
|
<Browser size={16} className="mt-0.5 shrink-0" />
|
||||||
@@ -420,13 +436,12 @@ export function SocialAccountsPanel({
|
|||||||
<div className="mt-1 text-[11px] opacity-80">
|
<div className="mt-1 text-[11px] opacity-80">
|
||||||
Add your social media accounts here. Click "Login" to open a browser where you can sign in.
|
Add your social media accounts here. Click "Login" to open a browser where you can sign in.
|
||||||
Your session is saved locally so marketing agents can post content, reply to users, and browse feeds
|
Your session is saved locally so marketing agents can post content, reply to users, and browse feeds
|
||||||
using Playwright browser automation. All actions are logged and can require Board approval.
|
using browser automation. All actions are logged and can require Board approval.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabs */}
|
|
||||||
<div className="flex gap-1 rounded-[8px] bg-[color:var(--panel-soft)] p-1">
|
<div className="flex gap-1 rounded-[8px] bg-[color:var(--panel-soft)] p-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -452,7 +467,6 @@ export function SocialAccountsPanel({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
{activeTab === "accounts" ? (
|
{activeTab === "accounts" ? (
|
||||||
accounts.length === 0 ? (
|
accounts.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center rounded-[10px] border border-dashed border-[color:var(--line)] py-16 text-center">
|
<div className="flex flex-col items-center justify-center rounded-[10px] border border-dashed border-[color:var(--line)] py-16 text-center">
|
||||||
@@ -478,16 +492,21 @@ export function SocialAccountsPanel({
|
|||||||
account={account}
|
account={account}
|
||||||
onLogin={() => void onLogin(account.id)}
|
onLogin={() => void onLogin(account.id)}
|
||||||
onDelete={() => void onDelete(account.id)}
|
onDelete={() => void onDelete(account.id)}
|
||||||
onClick={() => {/* future: account detail view */}}
|
onClick={() => {}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<ActionHistory actions={actions} agents={agents} />
|
<ActionHistory
|
||||||
|
actions={actions}
|
||||||
|
agents={agents}
|
||||||
|
onOpenPath={onOpenPath}
|
||||||
|
onCancelAction={onCancelAction}
|
||||||
|
onNavigate={onNavigate}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Add Account Dialog */}
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{showAddDialog ? (
|
{showAddDialog ? (
|
||||||
<AddAccountDialog
|
<AddAccountDialog
|
||||||
|
|||||||
@@ -353,6 +353,67 @@ export function StatusPill({ status }: { status: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function InlineNotice({
|
||||||
|
message,
|
||||||
|
tone = "danger",
|
||||||
|
}: {
|
||||||
|
message: string;
|
||||||
|
tone?: "danger" | "success" | "warn";
|
||||||
|
}) {
|
||||||
|
const toneClass = tone === "success"
|
||||||
|
? "border-[color:var(--success)] bg-[color:var(--success-soft)] text-[color:var(--success)]"
|
||||||
|
: tone === "warn"
|
||||||
|
? "border-[color:var(--warn)] bg-[color:var(--warn-soft)] text-[color:var(--warn)]"
|
||||||
|
: "border-[color:var(--danger)] bg-[color:var(--danger-soft)] text-[color:var(--danger)]";
|
||||||
|
return (
|
||||||
|
<div className={`rounded-[10px] border px-4 py-3 text-[13px] ${toneClass}`} role={tone === "danger" ? "alert" : "status"}>
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfirmDialog({ open, title, message, errorMessage, onConfirm, onCancel }: {
|
||||||
|
open: boolean;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
errorMessage?: string | null;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}) {
|
||||||
|
const trapRef = useFocusTrap(open);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
function onKey(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Escape") onCancel();
|
||||||
|
}
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
return () => document.removeEventListener("keydown", onKey);
|
||||||
|
}, [open, onCancel]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[color:var(--text)]/45" onClick={onCancel}>
|
||||||
|
<div
|
||||||
|
ref={trapRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="confirm-dialog-title"
|
||||||
|
className="w-full max-w-[400px] rounded-[12px] bg-[color:var(--panel)] border border-[color:var(--line)] p-6 shadow-lg"
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div id="confirm-dialog-title" className="text-[16px] font-semibold text-[color:var(--text)] mb-2">{title}</div>
|
||||||
|
<div className="text-[13px] text-[color:var(--muted)] mb-6">{message}</div>
|
||||||
|
{errorMessage ? <div className="mb-4"><InlineNotice message={errorMessage} /></div> : null}
|
||||||
|
<div className="flex gap-3 justify-end">
|
||||||
|
<ActionButton label="Cancel" onClick={onCancel} />
|
||||||
|
<ActionButton label="Confirm" tone="danger" onClick={onConfirm} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function EmptyState({
|
export function EmptyState({
|
||||||
title,
|
title,
|
||||||
detail,
|
detail,
|
||||||
@@ -404,8 +465,6 @@ export function ListRow({
|
|||||||
return <div className={`border-b border-[color:var(--line)] px-5 py-4 last:border-b-0 ${className}`}>{children}</div>;
|
return <div className={`border-b border-[color:var(--line)] px-5 py-4 last:border-b-0 ${className}`}>{children}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Focus Trap Hook ────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
export function useFocusTrap(active: boolean) {
|
export function useFocusTrap(active: boolean) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -415,7 +474,6 @@ export function useFocusTrap(active: boolean) {
|
|||||||
const container = ref.current;
|
const container = ref.current;
|
||||||
const previouslyFocused = document.activeElement as HTMLElement | null;
|
const previouslyFocused = document.activeElement as HTMLElement | null;
|
||||||
|
|
||||||
// Focus first focusable element
|
|
||||||
const focusable = container.querySelectorAll<HTMLElement>(
|
const focusable = container.querySelectorAll<HTMLElement>(
|
||||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||||
);
|
);
|
||||||
@@ -454,8 +512,6 @@ export function useFocusTrap(active: boolean) {
|
|||||||
return ref;
|
return ref;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Reduced Motion Hook ────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
export function useReducedMotion() {
|
export function useReducedMotion() {
|
||||||
const query = typeof window !== "undefined" ? window.matchMedia("(prefers-reduced-motion: reduce)") : null;
|
const query = typeof window !== "undefined" ? window.matchMedia("(prefers-reduced-motion: reduce)") : null;
|
||||||
// Return static value — Electron doesn't change this mid-session
|
// Return static value — Electron doesn't change this mid-session
|
||||||
|
|||||||
@@ -11,3 +11,60 @@ export function formatTime(value: string | null | undefined) {
|
|||||||
if (!value) return "Not yet";
|
if (!value) return "Not yet";
|
||||||
return new Date(value).toLocaleString();
|
return new Date(value).toLocaleString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function timeAgo(dateStr: string | null): string {
|
||||||
|
if (!dateStr) return "never";
|
||||||
|
const now = Date.now();
|
||||||
|
const then = new Date(dateStr).getTime();
|
||||||
|
const diff = Math.max(0, now - then);
|
||||||
|
const seconds = Math.floor(diff / 1000);
|
||||||
|
if (seconds < 60) return "just now";
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
if (minutes < 60) return `${minutes}m ago`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) return `${hours}h ago`;
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
return `${days}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAgentInitials(name: string): string {
|
||||||
|
return name
|
||||||
|
.split(/\s+/)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((w) => w[0])
|
||||||
|
.join("")
|
||||||
|
.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDuration(seconds: number): string {
|
||||||
|
if (seconds < 60) return `${seconds}s`;
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const secs = seconds % 60;
|
||||||
|
if (minutes < 60) return `${minutes}m ${secs}s`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
return `${hours}h ${minutes % 60}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatMessageTime(dateStr: string): string {
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
const now = new Date();
|
||||||
|
const isToday =
|
||||||
|
d.getDate() === now.getDate() &&
|
||||||
|
d.getMonth() === now.getMonth() &&
|
||||||
|
d.getFullYear() === now.getFullYear();
|
||||||
|
if (isToday) {
|
||||||
|
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||||
|
}
|
||||||
|
const yesterday = new Date(now);
|
||||||
|
yesterday.setDate(yesterday.getDate() - 1);
|
||||||
|
const isYesterday =
|
||||||
|
d.getDate() === yesterday.getDate() &&
|
||||||
|
d.getMonth() === yesterday.getMonth() &&
|
||||||
|
d.getFullYear() === yesterday.getFullYear();
|
||||||
|
if (isYesterday) {
|
||||||
|
return `Yesterday ${d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
|
||||||
|
}
|
||||||
|
return d.toLocaleDateString([], { month: "short", day: "numeric" }) +
|
||||||
|
" " +
|
||||||
|
d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||||
|
}
|
||||||
@@ -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 { z } from "zod";
|
||||||
import type { AgentMessageRecord, AgentMetrics, AutomationRuleRecord, BrowserActionRecord, CommentRecord, CompanyMetrics, CompanyTemplate, DesktopEvent, DocumentRecord, InboxItem, KnowledgeEntryRecord, MeetingRecord, ProfileSnapshot, SocialAccountRecord, SprintRecord, StandupReport, WorkflowPipelineRecord } from "./types";
|
import type { AgentMessageRecord, AgentMetrics, BrowserActionRecord, CommentRecord, CompanyMetrics, CompanyTemplate, DesktopEvent, InboxItem, ProfileSnapshot, StandupReport } from "./types";
|
||||||
|
|
||||||
export const IPC_CHANNELS = {
|
export const IPC_CHANNELS = {
|
||||||
loadSnapshot: "agent-company:load-snapshot",
|
loadSnapshot: "agent-company:load-snapshot",
|
||||||
@@ -41,7 +41,6 @@ export const IPC_CHANNELS = {
|
|||||||
getApiPort: "agent-company:get-api-port",
|
getApiPort: "agent-company:get-api-port",
|
||||||
rendererEvent: "agent-company:event",
|
rendererEvent: "agent-company:event",
|
||||||
getInbox: "agent-company:get-inbox",
|
getInbox: "agent-company:get-inbox",
|
||||||
dismissInboxItem: "agent-company:dismiss-inbox-item",
|
|
||||||
getCompanyMetrics: "agent-company:get-company-metrics",
|
getCompanyMetrics: "agent-company:get-company-metrics",
|
||||||
getAgentMetrics: "agent-company:get-agent-metrics",
|
getAgentMetrics: "agent-company:get-agent-metrics",
|
||||||
getStandupReport: "agent-company:get-standup-report",
|
getStandupReport: "agent-company:get-standup-report",
|
||||||
@@ -52,32 +51,24 @@ export const IPC_CHANNELS = {
|
|||||||
listBrowserActions: "agent-company:list-browser-actions",
|
listBrowserActions: "agent-company:list-browser-actions",
|
||||||
cancelBrowserAction: "agent-company:cancel-browser-action",
|
cancelBrowserAction: "agent-company:cancel-browser-action",
|
||||||
triggerBrowserLogin: "agent-company:trigger-browser-login",
|
triggerBrowserLogin: "agent-company:trigger-browser-login",
|
||||||
// Meetings
|
|
||||||
saveMeeting: "agent-company:save-meeting",
|
saveMeeting: "agent-company:save-meeting",
|
||||||
deleteMeeting: "agent-company:delete-meeting",
|
deleteMeeting: "agent-company:delete-meeting",
|
||||||
// Documents
|
|
||||||
saveDocument: "agent-company:save-document",
|
saveDocument: "agent-company:save-document",
|
||||||
deleteDocument: "agent-company:delete-document",
|
deleteDocument: "agent-company:delete-document",
|
||||||
// Knowledge Base
|
|
||||||
saveKnowledgeEntry: "agent-company:save-knowledge-entry",
|
saveKnowledgeEntry: "agent-company:save-knowledge-entry",
|
||||||
deleteKnowledgeEntry: "agent-company:delete-knowledge-entry",
|
deleteKnowledgeEntry: "agent-company:delete-knowledge-entry",
|
||||||
// Sprints
|
|
||||||
saveSprint: "agent-company:save-sprint",
|
saveSprint: "agent-company:save-sprint",
|
||||||
deleteSprint: "agent-company:delete-sprint",
|
deleteSprint: "agent-company:delete-sprint",
|
||||||
// Automation
|
|
||||||
saveAutomationRule: "agent-company:save-automation-rule",
|
saveAutomationRule: "agent-company:save-automation-rule",
|
||||||
deleteAutomationRule: "agent-company:delete-automation-rule",
|
deleteAutomationRule: "agent-company:delete-automation-rule",
|
||||||
toggleAutomationRule: "agent-company:toggle-automation-rule",
|
toggleAutomationRule: "agent-company:toggle-automation-rule",
|
||||||
getAutomationLog: "agent-company:get-automation-log",
|
getAutomationLog: "agent-company:get-automation-log",
|
||||||
// Agent Messages
|
|
||||||
sendAgentMessage: "agent-company:send-agent-message",
|
sendAgentMessage: "agent-company:send-agent-message",
|
||||||
listAgentMessages: "agent-company:list-agent-messages",
|
listAgentMessages: "agent-company:list-agent-messages",
|
||||||
markMessageRead: "agent-company:mark-message-read",
|
markMessageRead: "agent-company:mark-message-read",
|
||||||
// Workflows
|
|
||||||
saveWorkflow: "agent-company:save-workflow",
|
saveWorkflow: "agent-company:save-workflow",
|
||||||
deleteWorkflow: "agent-company:delete-workflow",
|
deleteWorkflow: "agent-company:delete-workflow",
|
||||||
runWorkflow: "agent-company:run-workflow",
|
runWorkflow: "agent-company:run-workflow",
|
||||||
// Auto-assign
|
|
||||||
autoAssignTask: "agent-company:auto-assign-task",
|
autoAssignTask: "agent-company:auto-assign-task",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
@@ -175,6 +166,7 @@ export const approvalInputSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const approvalDecisionSchema = z.object({
|
export const approvalDecisionSchema = z.object({
|
||||||
|
companyId: z.string().uuid(),
|
||||||
approvalId: z.string().uuid(),
|
approvalId: z.string().uuid(),
|
||||||
state: z.enum(["approved", "rejected", "revision_requested"]),
|
state: z.enum(["approved", "rejected", "revision_requested"]),
|
||||||
decisionNote: z.string().max(400).default(""),
|
decisionNote: z.string().max(400).default(""),
|
||||||
@@ -211,6 +203,7 @@ export const sidebarBadgesSchema = z.object({
|
|||||||
failedRuns: z.number(),
|
failedRuns: z.number(),
|
||||||
todoTasks: z.number(),
|
todoTasks: z.number(),
|
||||||
budgetWarnings: z.number(),
|
budgetWarnings: z.number(),
|
||||||
|
unreadMessages: z.number(),
|
||||||
});
|
});
|
||||||
export type SidebarBadges = z.infer<typeof sidebarBadgesSchema>;
|
export type SidebarBadges = z.infer<typeof sidebarBadgesSchema>;
|
||||||
|
|
||||||
@@ -245,11 +238,6 @@ export const triggerHeartbeatSchema = z.object({
|
|||||||
trigger: z.enum(["manual", "timer", "assignment", "goal_activated", "approval_resolved", "subtask_completed", "comment", "report_blocked", "report_failed", "recovery", "message", "peer_request"]).default("manual"),
|
trigger: z.enum(["manual", "timer", "assignment", "goal_activated", "approval_resolved", "subtask_completed", "comment", "report_blocked", "report_failed", "recovery", "message", "peer_request"]).default("manual"),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const inboxDismissSchema = z.object({
|
|
||||||
itemId: z.string(),
|
|
||||||
companyId: z.string().uuid(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const metricsQuerySchema = z.object({
|
export const metricsQuerySchema = z.object({
|
||||||
companyId: z.string().uuid(),
|
companyId: z.string().uuid(),
|
||||||
agentId: z.string().uuid().optional(),
|
agentId: z.string().uuid().optional(),
|
||||||
@@ -342,7 +330,6 @@ export const browserActionQuerySchema = z.object({
|
|||||||
limit: z.number().int().min(1).max(100).default(50),
|
limit: z.number().int().min(1).max(100).default(50),
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Automation Schemas ──────────────────────────────────────────────────
|
|
||||||
export const automationRuleInputSchema = z.object({
|
export const automationRuleInputSchema = z.object({
|
||||||
id: z.preprocess((v) => (v === "" ? null : v), z.string().uuid().nullable().optional()),
|
id: z.preprocess((v) => (v === "" ? null : v), z.string().uuid().nullable().optional()),
|
||||||
companyId: z.string().uuid(),
|
companyId: z.string().uuid(),
|
||||||
@@ -391,8 +378,9 @@ export const messageQuerySchema = z.object({
|
|||||||
companyId: z.string().uuid(),
|
companyId: z.string().uuid(),
|
||||||
agentId: z.string().uuid().optional(),
|
agentId: z.string().uuid().optional(),
|
||||||
channel: z.enum(["direct", "department", "company", "project", "incident"]).optional(),
|
channel: z.enum(["direct", "department", "company", "project", "incident"]).optional(),
|
||||||
|
channelTargetId: z.string().nullable().optional(),
|
||||||
unreadOnly: z.boolean().optional(),
|
unreadOnly: z.boolean().optional(),
|
||||||
limit: z.number().int().min(1).max(200).default(50),
|
limit: z.number().int().min(1).max(500).default(200),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const workflowInputSchema = z.object({
|
export const workflowInputSchema = z.object({
|
||||||
@@ -411,6 +399,22 @@ export const workflowInputSchema = z.object({
|
|||||||
status: z.enum(["draft", "active", "paused", "completed", "failed"]).default("draft"),
|
status: z.enum(["draft", "active", "paused", "completed", "failed"]).default("draft"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const toggleAutomationRuleSchema = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
companyId: z.string().uuid(),
|
||||||
|
status: z.enum(["active", "paused"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const markMessageReadSchema = z.object({
|
||||||
|
messageId: z.string().uuid(),
|
||||||
|
companyId: z.string().uuid(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const runWorkflowSchema = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
companyId: z.string().uuid(),
|
||||||
|
});
|
||||||
|
|
||||||
export const autoAssignSchema = z.object({
|
export const autoAssignSchema = z.object({
|
||||||
taskId: z.string().uuid(),
|
taskId: z.string().uuid(),
|
||||||
companyId: z.string().uuid(),
|
companyId: z.string().uuid(),
|
||||||
@@ -459,7 +463,6 @@ export type DesktopApi = {
|
|||||||
triggerHeartbeat: (input: z.infer<typeof triggerHeartbeatSchema>) => Promise<DesktopResult<string>>;
|
triggerHeartbeat: (input: z.infer<typeof triggerHeartbeatSchema>) => Promise<DesktopResult<string>>;
|
||||||
getApiPort: () => Promise<DesktopResult<number>>;
|
getApiPort: () => Promise<DesktopResult<number>>;
|
||||||
getInbox: (companyId: string) => Promise<DesktopResult<InboxItem[]>>;
|
getInbox: (companyId: string) => Promise<DesktopResult<InboxItem[]>>;
|
||||||
dismissInboxItem: (input: z.infer<typeof inboxDismissSchema>) => Promise<DesktopResult<boolean>>;
|
|
||||||
getCompanyMetrics: (input: z.infer<typeof metricsQuerySchema>) => Promise<DesktopResult<CompanyMetrics>>;
|
getCompanyMetrics: (input: z.infer<typeof metricsQuerySchema>) => Promise<DesktopResult<CompanyMetrics>>;
|
||||||
getAgentMetrics: (input: z.infer<typeof metricsQuerySchema>) => Promise<DesktopResult<AgentMetrics>>;
|
getAgentMetrics: (input: z.infer<typeof metricsQuerySchema>) => Promise<DesktopResult<AgentMetrics>>;
|
||||||
getStandupReport: (companyId: string) => Promise<DesktopResult<StandupReport>>;
|
getStandupReport: (companyId: string) => Promise<DesktopResult<StandupReport>>;
|
||||||
@@ -470,32 +473,24 @@ export type DesktopApi = {
|
|||||||
listBrowserActions: (input: z.infer<typeof browserActionQuerySchema>) => Promise<DesktopResult<BrowserActionRecord[]>>;
|
listBrowserActions: (input: z.infer<typeof browserActionQuerySchema>) => Promise<DesktopResult<BrowserActionRecord[]>>;
|
||||||
cancelBrowserAction: (input: z.infer<typeof cancelRunSchema>) => Promise<DesktopResult<boolean>>;
|
cancelBrowserAction: (input: z.infer<typeof cancelRunSchema>) => Promise<DesktopResult<boolean>>;
|
||||||
triggerBrowserLogin: (socialAccountId: string) => Promise<DesktopResult<boolean>>;
|
triggerBrowserLogin: (socialAccountId: string) => Promise<DesktopResult<boolean>>;
|
||||||
// Meetings
|
|
||||||
saveMeeting: (input: z.infer<typeof meetingInputSchema>) => Promise<DesktopResult<string>>;
|
saveMeeting: (input: z.infer<typeof meetingInputSchema>) => Promise<DesktopResult<string>>;
|
||||||
deleteMeeting: (input: z.infer<typeof deleteEntitySchema>) => Promise<DesktopResult<boolean>>;
|
deleteMeeting: (input: z.infer<typeof deleteEntitySchema>) => Promise<DesktopResult<boolean>>;
|
||||||
// Documents
|
|
||||||
saveDocument: (input: z.infer<typeof documentInputSchema>) => Promise<DesktopResult<string>>;
|
saveDocument: (input: z.infer<typeof documentInputSchema>) => Promise<DesktopResult<string>>;
|
||||||
deleteDocument: (input: z.infer<typeof deleteEntitySchema>) => Promise<DesktopResult<boolean>>;
|
deleteDocument: (input: z.infer<typeof deleteEntitySchema>) => Promise<DesktopResult<boolean>>;
|
||||||
// Knowledge Base
|
|
||||||
saveKnowledgeEntry: (input: z.infer<typeof knowledgeEntryInputSchema>) => Promise<DesktopResult<string>>;
|
saveKnowledgeEntry: (input: z.infer<typeof knowledgeEntryInputSchema>) => Promise<DesktopResult<string>>;
|
||||||
deleteKnowledgeEntry: (input: z.infer<typeof deleteEntitySchema>) => Promise<DesktopResult<boolean>>;
|
deleteKnowledgeEntry: (input: z.infer<typeof deleteEntitySchema>) => Promise<DesktopResult<boolean>>;
|
||||||
// Sprints
|
|
||||||
saveSprint: (input: z.infer<typeof sprintInputSchema>) => Promise<DesktopResult<string>>;
|
saveSprint: (input: z.infer<typeof sprintInputSchema>) => Promise<DesktopResult<string>>;
|
||||||
deleteSprint: (input: z.infer<typeof deleteEntitySchema>) => Promise<DesktopResult<boolean>>;
|
deleteSprint: (input: z.infer<typeof deleteEntitySchema>) => Promise<DesktopResult<boolean>>;
|
||||||
// Automation
|
|
||||||
saveAutomationRule: (input: z.infer<typeof automationRuleInputSchema>) => Promise<DesktopResult<string>>;
|
saveAutomationRule: (input: z.infer<typeof automationRuleInputSchema>) => Promise<DesktopResult<string>>;
|
||||||
deleteAutomationRule: (input: z.infer<typeof deleteEntitySchema>) => Promise<DesktopResult<boolean>>;
|
deleteAutomationRule: (input: z.infer<typeof deleteEntitySchema>) => Promise<DesktopResult<boolean>>;
|
||||||
toggleAutomationRule: (input: { id: string; companyId: string; status: "active" | "paused" }) => Promise<DesktopResult<boolean>>;
|
toggleAutomationRule: (input: z.infer<typeof toggleAutomationRuleSchema>) => Promise<DesktopResult<boolean>>;
|
||||||
getAutomationLog: (companyId: string) => Promise<DesktopResult<Array<{ ruleId: string; ruleName: string; trigger: string; action: string; executedAt: string }>>>;
|
getAutomationLog: (companyId: string) => Promise<DesktopResult<Array<{ ruleId: string; ruleName: string; trigger: string; action: string; executedAt: string }>>>;
|
||||||
// Agent Messages
|
|
||||||
sendAgentMessage: (input: z.infer<typeof agentMessageInputSchema>) => Promise<DesktopResult<string>>;
|
sendAgentMessage: (input: z.infer<typeof agentMessageInputSchema>) => Promise<DesktopResult<string>>;
|
||||||
listAgentMessages: (input: z.infer<typeof messageQuerySchema>) => Promise<DesktopResult<AgentMessageRecord[]>>;
|
listAgentMessages: (input: z.infer<typeof messageQuerySchema>) => Promise<DesktopResult<AgentMessageRecord[]>>;
|
||||||
markMessageRead: (input: { messageId: string; companyId: string }) => Promise<DesktopResult<boolean>>;
|
markMessageRead: (input: z.infer<typeof markMessageReadSchema>) => Promise<DesktopResult<boolean>>;
|
||||||
// Workflows
|
|
||||||
saveWorkflow: (input: z.infer<typeof workflowInputSchema>) => Promise<DesktopResult<string>>;
|
saveWorkflow: (input: z.infer<typeof workflowInputSchema>) => Promise<DesktopResult<string>>;
|
||||||
deleteWorkflow: (input: z.infer<typeof deleteEntitySchema>) => Promise<DesktopResult<boolean>>;
|
deleteWorkflow: (input: z.infer<typeof deleteEntitySchema>) => Promise<DesktopResult<boolean>>;
|
||||||
runWorkflow: (input: { id: string; companyId: string }) => Promise<DesktopResult<boolean>>;
|
runWorkflow: (input: z.infer<typeof runWorkflowSchema>) => Promise<DesktopResult<boolean>>;
|
||||||
// Auto-assign
|
|
||||||
autoAssignTask: (input: z.infer<typeof autoAssignSchema>) => Promise<DesktopResult<string>>;
|
autoAssignTask: (input: z.infer<typeof autoAssignSchema>) => Promise<DesktopResult<string>>;
|
||||||
subscribe: (callback: (event: DesktopEvent) => void) => () => void;
|
subscribe: (callback: (event: DesktopEvent) => void) => () => void;
|
||||||
};
|
};
|
||||||
+4
-75
@@ -122,7 +122,6 @@ export interface WorkspaceRecord {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Departments ────────────────────────────────────────────────────────
|
|
||||||
export type DepartmentId =
|
export type DepartmentId =
|
||||||
| "executive"
|
| "executive"
|
||||||
| "engineering"
|
| "engineering"
|
||||||
@@ -298,7 +297,6 @@ export interface ProfileSnapshot {
|
|||||||
secrets: SecretRecord[];
|
secrets: SecretRecord[];
|
||||||
comments: CommentRecord[];
|
comments: CommentRecord[];
|
||||||
socialAccounts: SocialAccountRecord[];
|
socialAccounts: SocialAccountRecord[];
|
||||||
browserActions: BrowserActionRecord[];
|
|
||||||
meetings: MeetingRecord[];
|
meetings: MeetingRecord[];
|
||||||
documents: DocumentRecord[];
|
documents: DocumentRecord[];
|
||||||
knowledgeBase: KnowledgeEntryRecord[];
|
knowledgeBase: KnowledgeEntryRecord[];
|
||||||
@@ -331,8 +329,6 @@ export interface RunStatusEvent {
|
|||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type HeartbeatTrigger = "manual" | "timer" | "assignment" | "goal_activated" | "approval_resolved" | "subtask_completed" | "comment" | "report_blocked" | "report_failed" | "recovery" | "message" | "peer_request";
|
|
||||||
|
|
||||||
export interface CommentRecord {
|
export interface CommentRecord {
|
||||||
id: string;
|
id: string;
|
||||||
companyId: string;
|
companyId: string;
|
||||||
@@ -343,11 +339,6 @@ export interface CommentRecord {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OrgChartNode {
|
|
||||||
agent: AgentRecord;
|
|
||||||
children: OrgChartNode[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HeartbeatStatusEvent {
|
export interface HeartbeatStatusEvent {
|
||||||
type: "heartbeat-status";
|
type: "heartbeat-status";
|
||||||
agentId: string;
|
agentId: string;
|
||||||
@@ -384,7 +375,6 @@ export type DesktopEvent =
|
|||||||
| AutomationFiredEvent
|
| AutomationFiredEvent
|
||||||
| NewMessageEvent;
|
| NewMessageEvent;
|
||||||
|
|
||||||
// ── Inbox ──────────────────────────────────────────────────────────────
|
|
||||||
export type InboxItemType = "pending_approval" | "failed_run" | "budget_warning" | "blocked_task" | "review_needed" | "agent_message";
|
export type InboxItemType = "pending_approval" | "failed_run" | "budget_warning" | "blocked_task" | "review_needed" | "agent_message";
|
||||||
|
|
||||||
export interface InboxItem {
|
export interface InboxItem {
|
||||||
@@ -398,23 +388,21 @@ export interface InboxItem {
|
|||||||
agentId: string | null;
|
agentId: string | null;
|
||||||
agentName: string | null;
|
agentName: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
dismissed: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Performance Metrics ────────────────────────────────────────────────
|
|
||||||
export interface AgentMetrics {
|
export interface AgentMetrics {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
agentName: string;
|
agentName: string;
|
||||||
tasksCompleted: number;
|
tasksCompleted: number;
|
||||||
tasksAssigned: number;
|
tasksAssigned: number;
|
||||||
tasksFailed: number;
|
tasksFailed: number;
|
||||||
successRate: number; // 0-100
|
successRate: number;
|
||||||
avgRunDurationSec: number;
|
avgRunDurationSec: number;
|
||||||
totalCostUsd: number;
|
totalCostUsd: number;
|
||||||
runsTotal: number;
|
runsTotal: number;
|
||||||
runsSucceeded: number;
|
runsSucceeded: number;
|
||||||
runsFailed: number;
|
runsFailed: number;
|
||||||
reviewCycles: number; // how many times work was sent back for revision
|
reviewCycles: number;
|
||||||
lastActiveAt: string | null;
|
lastActiveAt: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -434,7 +422,6 @@ export interface CompanyMetrics {
|
|||||||
agentMetrics: AgentMetrics[];
|
agentMetrics: AgentMetrics[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Company Template ───────────────────────────────────────────────────
|
|
||||||
export interface CompanyTemplate {
|
export interface CompanyTemplate {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -461,10 +448,9 @@ export interface CompanyTemplate {
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Social Media / Browser Automation ──────────────────────────────
|
|
||||||
export type SocialPlatform = "twitter" | "linkedin" | "instagram" | "facebook" | "reddit" | "youtube" | "tiktok" | "bluesky" | "other";
|
export type SocialPlatform = "twitter" | "linkedin" | "instagram" | "facebook" | "reddit" | "youtube" | "tiktok" | "bluesky" | "other";
|
||||||
export type SocialAccountStatus = "active" | "logged_in" | "login_required" | "suspended" | "paused";
|
export type SocialAccountStatus = "active" | "logged_in" | "login_required" | "suspended" | "paused";
|
||||||
export type BrowserActionType = "post" | "reply" | "like" | "follow" | "browse_feed" | "screenshot" | "navigate" | "search" | "dm" | "custom_script";
|
export type BrowserActionType = "post" | "reply" | "like" | "follow" | "browse_feed" | "screenshot" | "navigate" | "search";
|
||||||
export type BrowserActionStatus = "queued" | "running" | "succeeded" | "failed" | "cancelled" | "approval_required";
|
export type BrowserActionStatus = "queued" | "running" | "succeeded" | "failed" | "cancelled" | "approval_required";
|
||||||
|
|
||||||
export interface SocialAccountRecord {
|
export interface SocialAccountRecord {
|
||||||
@@ -474,12 +460,9 @@ export interface SocialAccountRecord {
|
|||||||
accountName: string;
|
accountName: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
profileUrl: string;
|
profileUrl: string;
|
||||||
/** Secret ID for stored credentials (username/password/cookies) */
|
|
||||||
credentialSecretId: string | null;
|
credentialSecretId: string | null;
|
||||||
status: SocialAccountStatus;
|
status: SocialAccountStatus;
|
||||||
/** Whether posts require Board approval before publishing */
|
|
||||||
requireApproval: boolean;
|
requireApproval: boolean;
|
||||||
/** JSON metadata: bio, follower count, etc. */
|
|
||||||
metadataJson: string;
|
metadataJson: string;
|
||||||
lastUsedAt: string | null;
|
lastUsedAt: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -493,14 +476,10 @@ export interface BrowserActionRecord {
|
|||||||
agentId: string;
|
agentId: string;
|
||||||
taskId: string | null;
|
taskId: string | null;
|
||||||
actionType: BrowserActionType;
|
actionType: BrowserActionType;
|
||||||
/** JSON payload: content, targetUrl, selector, script, etc. */
|
|
||||||
payloadJson: string;
|
payloadJson: string;
|
||||||
status: BrowserActionStatus;
|
status: BrowserActionStatus;
|
||||||
/** Result summary after execution */
|
|
||||||
resultSummary: string;
|
resultSummary: string;
|
||||||
/** Screenshot path after execution */
|
|
||||||
screenshotPath: string | null;
|
screenshotPath: string | null;
|
||||||
/** Approval ID if approval was required */
|
|
||||||
approvalId: string | null;
|
approvalId: string | null;
|
||||||
errorMessage: string | null;
|
errorMessage: string | null;
|
||||||
startedAt: string | null;
|
startedAt: string | null;
|
||||||
@@ -509,7 +488,6 @@ export interface BrowserActionRecord {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Meetings ──────────────────────────────────────────────────────────
|
|
||||||
export type MeetingType =
|
export type MeetingType =
|
||||||
| "standup"
|
| "standup"
|
||||||
| "sprint_planning"
|
| "sprint_planning"
|
||||||
@@ -544,7 +522,6 @@ export interface MeetingRecord {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Documents ─────────────────────────────────────────────────────────
|
|
||||||
export type DocumentType =
|
export type DocumentType =
|
||||||
| "prd"
|
| "prd"
|
||||||
| "technical_spec"
|
| "technical_spec"
|
||||||
@@ -586,7 +563,6 @@ export interface DocumentRecord {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Knowledge Base ────────────────────────────────────────────────────
|
|
||||||
export type KnowledgeCategory =
|
export type KnowledgeCategory =
|
||||||
| "lesson_learned"
|
| "lesson_learned"
|
||||||
| "best_practice"
|
| "best_practice"
|
||||||
@@ -612,7 +588,6 @@ export interface KnowledgeEntryRecord {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Sprints ───────────────────────────────────────────────────────────
|
|
||||||
export type SprintStatus = "planning" | "active" | "review" | "completed" | "cancelled";
|
export type SprintStatus = "planning" | "active" | "review" | "completed" | "cancelled";
|
||||||
|
|
||||||
export interface SprintRecord {
|
export interface SprintRecord {
|
||||||
@@ -630,25 +605,6 @@ export interface SprintRecord {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Performance Reviews ───────────────────────────────────────────────
|
|
||||||
export type ReviewStatus = "draft" | "submitted" | "acknowledged";
|
|
||||||
|
|
||||||
export interface PerformanceReviewRecord {
|
|
||||||
id: string;
|
|
||||||
companyId: string;
|
|
||||||
agentId: string;
|
|
||||||
reviewerAgentId: string;
|
|
||||||
period: string; // e.g., "2026-Q1"
|
|
||||||
rating: number; // 1-5
|
|
||||||
strengths: string;
|
|
||||||
areasForImprovement: string;
|
|
||||||
goals: string;
|
|
||||||
status: ReviewStatus;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Standup Report ─────────────────────────────────────────────────────
|
|
||||||
export interface StandupReport {
|
export interface StandupReport {
|
||||||
companyId: string;
|
companyId: string;
|
||||||
generatedAt: string;
|
generatedAt: string;
|
||||||
@@ -669,10 +625,9 @@ export interface StandupReport {
|
|||||||
companyHighlights: string[];
|
companyHighlights: string[];
|
||||||
blockers: string[];
|
blockers: string[];
|
||||||
pendingApprovals: number;
|
pendingApprovals: number;
|
||||||
budgetUtilization: number; // 0-100
|
budgetUtilization: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Automation Engine ─────────────────────────────────────────────────
|
|
||||||
export type AutomationTrigger =
|
export type AutomationTrigger =
|
||||||
| "task_status_changed"
|
| "task_status_changed"
|
||||||
| "task_created"
|
| "task_created"
|
||||||
@@ -713,25 +668,19 @@ export interface AutomationRuleRecord {
|
|||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
trigger: AutomationTrigger;
|
trigger: AutomationTrigger;
|
||||||
/** JSON conditions: e.g., {"status": "blocked", "department": "engineering"} */
|
|
||||||
conditionsJson: string;
|
conditionsJson: string;
|
||||||
action: AutomationAction;
|
action: AutomationAction;
|
||||||
/** JSON action config: e.g., {"targetAgentId": "...", "message": "..."} */
|
|
||||||
actionConfigJson: string;
|
actionConfigJson: string;
|
||||||
/** Source department that triggers (null = any) */
|
|
||||||
sourceDepartment: DepartmentId | null;
|
sourceDepartment: DepartmentId | null;
|
||||||
/** Target department for cross-department rules (null = same) */
|
|
||||||
targetDepartment: DepartmentId | null;
|
targetDepartment: DepartmentId | null;
|
||||||
priority: number;
|
priority: number;
|
||||||
status: AutomationStatus;
|
status: AutomationStatus;
|
||||||
/** How many times this rule has fired */
|
|
||||||
executionCount: number;
|
executionCount: number;
|
||||||
lastExecutedAt: string | null;
|
lastExecutedAt: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Agent Messages (Direct Communication) ─────────────────────────────
|
|
||||||
export type MessageChannel = "direct" | "department" | "company" | "project" | "incident";
|
export type MessageChannel = "direct" | "department" | "company" | "project" | "incident";
|
||||||
export type MessagePriority = "urgent" | "normal" | "low";
|
export type MessagePriority = "urgent" | "normal" | "low";
|
||||||
|
|
||||||
@@ -739,24 +688,18 @@ export interface AgentMessageRecord {
|
|||||||
id: string;
|
id: string;
|
||||||
companyId: string;
|
companyId: string;
|
||||||
fromAgentId: string;
|
fromAgentId: string;
|
||||||
/** null = broadcast to channel */
|
|
||||||
toAgentId: string | null;
|
toAgentId: string | null;
|
||||||
channel: MessageChannel;
|
channel: MessageChannel;
|
||||||
/** Department/project/incident ID for channel messages */
|
|
||||||
channelTargetId: string | null;
|
channelTargetId: string | null;
|
||||||
subject: string;
|
subject: string;
|
||||||
body: string;
|
body: string;
|
||||||
priority: MessagePriority;
|
priority: MessagePriority;
|
||||||
/** Whether the recipient has read this message */
|
|
||||||
readAt: string | null;
|
readAt: string | null;
|
||||||
/** Parent message ID for threads */
|
|
||||||
parentMessageId: string | null;
|
parentMessageId: string | null;
|
||||||
/** JSON: attached entity references [{type, id, title}] */
|
|
||||||
attachmentsJson: string;
|
attachmentsJson: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Workflow Pipeline ──────────────────────────────────────────────────
|
|
||||||
export type WorkflowStatus = "draft" | "active" | "paused" | "completed" | "failed";
|
export type WorkflowStatus = "draft" | "active" | "paused" | "completed" | "failed";
|
||||||
|
|
||||||
export interface WorkflowPipelineRecord {
|
export interface WorkflowPipelineRecord {
|
||||||
@@ -764,13 +707,11 @@ export interface WorkflowPipelineRecord {
|
|||||||
companyId: string;
|
companyId: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
/** JSON array of pipeline steps */
|
|
||||||
stepsJson: string;
|
stepsJson: string;
|
||||||
triggerType: AutomationTrigger;
|
triggerType: AutomationTrigger;
|
||||||
triggerConfigJson: string;
|
triggerConfigJson: string;
|
||||||
status: WorkflowStatus;
|
status: WorkflowStatus;
|
||||||
currentStepIndex: number;
|
currentStepIndex: number;
|
||||||
/** How many times this pipeline has run */
|
|
||||||
runCount: number;
|
runCount: number;
|
||||||
lastRunAt: string | null;
|
lastRunAt: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -784,19 +725,7 @@ export interface WorkflowStep {
|
|||||||
configJson: string;
|
configJson: string;
|
||||||
assigneeDepartment: DepartmentId | null;
|
assigneeDepartment: DepartmentId | null;
|
||||||
assigneeAgentId: string | null;
|
assigneeAgentId: string | null;
|
||||||
/** Wait for approval before proceeding */
|
|
||||||
requiresApproval: boolean;
|
requiresApproval: boolean;
|
||||||
/** Timeout in seconds (0 = no timeout) */
|
|
||||||
timeoutSec: number;
|
timeoutSec: number;
|
||||||
/** Status of this step */
|
|
||||||
status: "pending" | "running" | "completed" | "failed" | "skipped";
|
status: "pending" | "running" | "completed" | "failed" | "skipped";
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Cross-Department Trigger Table ────────────────────────────────────
|
|
||||||
export interface CrossDeptTrigger {
|
|
||||||
sourceEvent: string;
|
|
||||||
sourceDepartment: DepartmentId;
|
|
||||||
targetDepartment: DepartmentId;
|
|
||||||
targetAction: string;
|
|
||||||
description: string;
|
|
||||||
}
|
|
||||||
@@ -27,9 +27,12 @@ test("first launch onboarding creates a company and opens the desktop shell", as
|
|||||||
try {
|
try {
|
||||||
const page = await app.firstWindow();
|
const page = await app.firstWindow();
|
||||||
await expect(page.getByText("AgentCompany Desktop")).toBeVisible();
|
await expect(page.getByText("AgentCompany Desktop")).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: /Custom Setup/i }).click();
|
||||||
await page.getByLabel("Company name").fill("Orbit Foundry");
|
await page.getByLabel("Company name").fill("Orbit Foundry");
|
||||||
await page.getByLabel("Workspace folder").fill(workspaceDir);
|
await page.getByLabel("Workspace folder").fill(workspaceDir);
|
||||||
await page.getByRole("button", { name: /Continue to dashboard/i }).click();
|
await page.getByRole("button", { name: /^Continue$/i }).click();
|
||||||
|
await page.getByLabel("Goal").fill("Launch the company");
|
||||||
|
await page.getByRole("button", { name: /Launch your company/i }).click();
|
||||||
|
|
||||||
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
|
||||||
// Company name is rendered in a custom dropdown trigger button in the sidebar.
|
// Company name is rendered in a custom dropdown trigger button in the sidebar.
|
||||||
|
|||||||
@@ -293,7 +293,6 @@ describe("Step 2: CEO phased prompts", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const prompt = buildSystemPrompt(ctx);
|
const prompt = buildSystemPrompt(ctx);
|
||||||
expect(prompt).toContain("escalate by commenting on YOUR parent task");
|
|
||||||
expect(prompt).toContain("Break down work and delegate it");
|
expect(prompt).toContain("Break down work and delegate it");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -310,7 +309,7 @@ describe("Step 2: CEO phased prompts", () => {
|
|||||||
|
|
||||||
const prompt = buildSystemPrompt(ctx);
|
const prompt = buildSystemPrompt(ctx);
|
||||||
expect(prompt).toContain("Your manager is auto-notified and will help");
|
expect(prompt).toContain("Your manager is auto-notified and will help");
|
||||||
expect(prompt).toContain("comment explaining what API key/account you need");
|
expect(prompt).toContain("explain what API key/account you need");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -320,15 +319,15 @@ describe("Step 3: Deferred wake queue (database)", () => {
|
|||||||
it("enqueue and consume pending wakes", () => {
|
it("enqueue and consume pending wakes", () => {
|
||||||
const agentId = createAgent({ name: "TestAgent" });
|
const agentId = createAgent({ name: "TestAgent" });
|
||||||
|
|
||||||
// Enqueue two wakes
|
// Enqueue two wakes for the same busy agent. The queue deduplicates by agent
|
||||||
|
// so only the freshest trigger is retained.
|
||||||
db.enqueuePendingWake(companyId, agentId, "subtask_completed");
|
db.enqueuePendingWake(companyId, agentId, "subtask_completed");
|
||||||
db.enqueuePendingWake(companyId, agentId, "comment");
|
db.enqueuePendingWake(companyId, agentId, "comment");
|
||||||
|
|
||||||
// Consume returns them in order
|
// Consume returns the latest retained wake
|
||||||
const wakes = db.consumePendingWakes(agentId);
|
const wakes = db.consumePendingWakes(agentId);
|
||||||
expect(wakes).toHaveLength(2);
|
expect(wakes).toHaveLength(1);
|
||||||
expect(wakes[0].trigger).toBe("subtask_completed");
|
expect(wakes[0].trigger).toBe("comment");
|
||||||
expect(wakes[1].trigger).toBe("comment");
|
|
||||||
expect(wakes[0].companyId).toBe(companyId);
|
expect(wakes[0].companyId).toBe(companyId);
|
||||||
|
|
||||||
// Second consume returns empty (they've been deleted)
|
// Second consume returns empty (they've been deleted)
|
||||||
@@ -640,10 +639,10 @@ describe("Step 8: Wake reason injection", () => {
|
|||||||
|
|
||||||
const prompt = buildTaskPrompt(ctx);
|
const prompt = buildTaskPrompt(ctx);
|
||||||
expect(prompt).toContain("Wake Reason");
|
expect(prompt).toContain("Wake Reason");
|
||||||
expect(prompt).toContain("subtask assigned to one of your reports has been completed");
|
expect(prompt).toContain("subtask you delegated has been completed");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not include wake reason for timer triggers", () => {
|
it("includes wake reason for timer triggers", () => {
|
||||||
const agentId = createAgent({ name: "Agent" });
|
const agentId = createAgent({ name: "Agent" });
|
||||||
|
|
||||||
const ctx = buildCtx({
|
const ctx = buildCtx({
|
||||||
@@ -652,10 +651,11 @@ describe("Step 8: Wake reason injection", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const prompt = buildTaskPrompt(ctx);
|
const prompt = buildTaskPrompt(ctx);
|
||||||
expect(prompt).not.toContain("Wake Reason");
|
expect(prompt).toContain("Wake Reason");
|
||||||
|
expect(prompt).toContain("Regular heartbeat check-in");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not include wake reason for manual triggers", () => {
|
it("includes wake reason for manual triggers", () => {
|
||||||
const agentId = createAgent({ name: "Agent" });
|
const agentId = createAgent({ name: "Agent" });
|
||||||
|
|
||||||
const ctx = buildCtx({
|
const ctx = buildCtx({
|
||||||
@@ -664,17 +664,18 @@ describe("Step 8: Wake reason injection", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const prompt = buildTaskPrompt(ctx);
|
const prompt = buildTaskPrompt(ctx);
|
||||||
expect(prompt).not.toContain("Wake Reason");
|
expect(prompt).toContain("Wake Reason");
|
||||||
|
expect(prompt).toContain("manually triggered by the human operator");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("includes correct message for each wake reason", () => {
|
it("includes correct message for each wake reason", () => {
|
||||||
const agentId = createAgent({ name: "Agent" });
|
const agentId = createAgent({ name: "Agent" });
|
||||||
|
|
||||||
const reasons: Record<string, string> = {
|
const reasons: Record<string, string> = {
|
||||||
assignment: "assigned new work",
|
assignment: "You have been assigned a new task",
|
||||||
goal_activated: "goal you own has been activated",
|
goal_activated: "goal you own has been activated",
|
||||||
approval_resolved: "approval you requested has been resolved",
|
approval_resolved: "approval you requested has been decided",
|
||||||
subtask_completed: "subtask assigned to one of your reports has been completed",
|
subtask_completed: "subtask you delegated has been completed",
|
||||||
comment: "new comment has been posted",
|
comment: "new comment has been posted",
|
||||||
report_blocked: "reports is blocked",
|
report_blocked: "reports is blocked",
|
||||||
report_failed: "reports has failed",
|
report_failed: "reports has failed",
|
||||||
@@ -887,7 +888,7 @@ describe("E2E: Full autonomous CEO cold start flow", () => {
|
|||||||
});
|
});
|
||||||
const icPrompt = buildTaskPrompt(icPromptCtx);
|
const icPrompt = buildTaskPrompt(icPromptCtx);
|
||||||
expect(icPrompt).toContain("Wake Reason");
|
expect(icPrompt).toContain("Wake Reason");
|
||||||
expect(icPrompt).toContain("assigned new work");
|
expect(icPrompt).toContain("You have been assigned a new task");
|
||||||
expect(icPrompt).toContain("Implement auth");
|
expect(icPrompt).toContain("Implement auth");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -898,17 +899,16 @@ describe("E2E: Deferred wake queue with run lifecycle", () => {
|
|||||||
it("wake enqueue + run finish + consume cycle", () => {
|
it("wake enqueue + run finish + consume cycle", () => {
|
||||||
const agentId = createAgent({ name: "Agent" });
|
const agentId = createAgent({ name: "Agent" });
|
||||||
|
|
||||||
// Simulate: agent is running, wakes come in
|
// Simulate: agent is running, wakes come in. Only the freshest reason is
|
||||||
|
// retained so the agent gets one consolidated follow-up wake.
|
||||||
db.enqueuePendingWake(companyId, agentId, "subtask_completed");
|
db.enqueuePendingWake(companyId, agentId, "subtask_completed");
|
||||||
db.enqueuePendingWake(companyId, agentId, "comment");
|
db.enqueuePendingWake(companyId, agentId, "comment");
|
||||||
db.enqueuePendingWake(companyId, agentId, "report_blocked");
|
db.enqueuePendingWake(companyId, agentId, "report_blocked");
|
||||||
|
|
||||||
// Agent finishes run → consume pending wakes
|
// Agent finishes run → consume pending wakes
|
||||||
const wakes = db.consumePendingWakes(agentId);
|
const wakes = db.consumePendingWakes(agentId);
|
||||||
expect(wakes).toHaveLength(3);
|
expect(wakes).toHaveLength(1);
|
||||||
expect(wakes[0].trigger).toBe("subtask_completed");
|
expect(wakes[0].trigger).toBe("report_blocked");
|
||||||
expect(wakes[1].trigger).toBe("comment");
|
|
||||||
expect(wakes[2].trigger).toBe("report_blocked");
|
|
||||||
|
|
||||||
// All consumed
|
// All consumed
|
||||||
expect(db.consumePendingWakes(agentId)).toHaveLength(0);
|
expect(db.consumePendingWakes(agentId)).toHaveLength(0);
|
||||||
@@ -1192,6 +1192,81 @@ describe("Agent Messages", () => {
|
|||||||
expect(messages[0].readAt).toBeTruthy();
|
expect(messages[0].readAt).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("tracks read state per agent without marking the sender unread", () => {
|
||||||
|
const msgId = db.sendAgentMessage({
|
||||||
|
companyId,
|
||||||
|
fromAgentId: agentAId,
|
||||||
|
toAgentId: agentBId,
|
||||||
|
subject: "Test",
|
||||||
|
body: "Hello",
|
||||||
|
});
|
||||||
|
|
||||||
|
const senderView = db.listAgentMessages(companyId, { agentId: agentAId });
|
||||||
|
expect(senderView).toHaveLength(1);
|
||||||
|
expect(senderView[0].id).toBe(msgId);
|
||||||
|
expect(senderView[0].readAt).toBeTruthy();
|
||||||
|
|
||||||
|
const recipientViewBefore = db.listAgentMessages(companyId, { agentId: agentBId, unreadOnly: true });
|
||||||
|
expect(recipientViewBefore).toHaveLength(1);
|
||||||
|
expect(recipientViewBefore[0].id).toBe(msgId);
|
||||||
|
|
||||||
|
db.markAgentMessageRead(msgId, companyId, agentBId);
|
||||||
|
|
||||||
|
const recipientViewAfter = db.listAgentMessages(companyId, { agentId: agentBId, unreadOnly: true });
|
||||||
|
expect(recipientViewAfter).toHaveLength(0);
|
||||||
|
|
||||||
|
const thirdAgentId = createAgent({ name: "Agent C" });
|
||||||
|
const thirdView = db.listAgentMessages(companyId, { agentId: thirdAgentId });
|
||||||
|
expect(thirdView).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("limits department and project messages to relevant members", () => {
|
||||||
|
const engineeringLeadId = createAgent({ name: "Engineering Lead", role: "Lead", department: "engineering" });
|
||||||
|
const productLeadId = createAgent({ name: "Product Lead", role: "Lead", department: "product" });
|
||||||
|
|
||||||
|
const projectId = db.saveProject({
|
||||||
|
companyId,
|
||||||
|
name: "Cross-team Project",
|
||||||
|
description: "Scope",
|
||||||
|
leadAgentId: engineeringLeadId,
|
||||||
|
status: "active",
|
||||||
|
});
|
||||||
|
db.saveTask({
|
||||||
|
companyId,
|
||||||
|
title: "Project task",
|
||||||
|
description: "Do work",
|
||||||
|
assigneeAgentId: engineeringLeadId,
|
||||||
|
projectId,
|
||||||
|
status: "todo",
|
||||||
|
priority: "medium",
|
||||||
|
});
|
||||||
|
|
||||||
|
const deptMessageId = db.sendAgentMessage({
|
||||||
|
companyId,
|
||||||
|
fromAgentId: engineeringLeadId,
|
||||||
|
channel: "department",
|
||||||
|
channelTargetId: "engineering",
|
||||||
|
subject: "Engineering update",
|
||||||
|
body: "Only engineering should see this",
|
||||||
|
});
|
||||||
|
const projectMessageId = db.sendAgentMessage({
|
||||||
|
companyId,
|
||||||
|
fromAgentId: engineeringLeadId,
|
||||||
|
channel: "project",
|
||||||
|
channelTargetId: projectId,
|
||||||
|
subject: "Project update",
|
||||||
|
body: "Only project members should see this",
|
||||||
|
});
|
||||||
|
|
||||||
|
const engineeringView = db.listAgentMessages(companyId, { agentId: engineeringLeadId });
|
||||||
|
expect(engineeringView.some((message) => message.id === deptMessageId)).toBe(true);
|
||||||
|
expect(engineeringView.some((message) => message.id === projectMessageId)).toBe(true);
|
||||||
|
|
||||||
|
const productView = db.listAgentMessages(companyId, { agentId: productLeadId });
|
||||||
|
expect(productView.some((message) => message.id === deptMessageId)).toBe(false);
|
||||||
|
expect(productView.some((message) => message.id === projectMessageId)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("filters messages by channel", () => {
|
it("filters messages by channel", () => {
|
||||||
db.sendAgentMessage({ companyId, fromAgentId: agentAId, subject: "Dept update", body: "All good", channel: "department", channelTargetId: "engineering" });
|
db.sendAgentMessage({ companyId, fromAgentId: agentAId, subject: "Dept update", body: "All good", channel: "department", channelTargetId: "engineering" });
|
||||||
db.sendAgentMessage({ companyId, fromAgentId: agentAId, toAgentId: agentBId, subject: "DM", body: "Hey", channel: "direct" });
|
db.sendAgentMessage({ companyId, fromAgentId: agentAId, toAgentId: agentBId, subject: "DM", body: "Hey", channel: "direct" });
|
||||||
@@ -1206,7 +1281,7 @@ describe("Agent Messages", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("appears in snapshot", () => {
|
it("appears in snapshot", () => {
|
||||||
db.sendAgentMessage({ companyId, fromAgentId: agentAId, subject: "Snapshot test", body: "Hi" });
|
db.sendAgentMessage({ companyId, fromAgentId: agentAId, channel: "company", subject: "Snapshot test", body: "Hi" });
|
||||||
const snapshot = db.listSnapshot();
|
const snapshot = db.listSnapshot();
|
||||||
expect(snapshot.agentMessages.length).toBeGreaterThanOrEqual(1);
|
expect(snapshot.agentMessages.length).toBeGreaterThanOrEqual(1);
|
||||||
expect(snapshot.agentMessages[0].subject).toBe("Snapshot test");
|
expect(snapshot.agentMessages[0].subject).toBe("Snapshot test");
|
||||||
@@ -1268,10 +1343,13 @@ describe("Auto Task Assignment", () => {
|
|||||||
expect(result).toBe(frontendId);
|
expect(result).toBe(frontendId);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns null if no agents have remaining budget", () => {
|
it("returns null if all agents have exhausted their budget", () => {
|
||||||
// Exhaust both agents' budgets
|
// Set positive budgets that are fully spent
|
||||||
db.saveAgent({ id: backendId, companyId, name: "Backend Dev", role: "Backend", title: "Backend", connectorId: "codex_local", status: "active", capabilities: "backend", budgetMonthlyUsd: 0 });
|
db.saveAgent({ id: backendId, companyId, name: "Backend Dev", role: "Backend", title: "Backend", connectorId: "codex_local", status: "active", capabilities: "backend", budgetMonthlyUsd: 100 });
|
||||||
db.saveAgent({ id: frontendId, companyId, name: "Frontend Dev", role: "Frontend", title: "Frontend", connectorId: "codex_local", status: "active", capabilities: "frontend", budgetMonthlyUsd: 0 });
|
db.saveAgent({ id: frontendId, companyId, name: "Frontend Dev", role: "Frontend", title: "Frontend", connectorId: "codex_local", status: "active", capabilities: "frontend", budgetMonthlyUsd: 100 });
|
||||||
|
// Exhaust budgets by recording spending via direct SQL (since finishRun would need a real run)
|
||||||
|
(db as any).db.prepare("update agents set spent_monthly_usd = 100 where id = ?").run(backendId);
|
||||||
|
(db as any).db.prepare("update agents set spent_monthly_usd = 100 where id = ?").run(frontendId);
|
||||||
|
|
||||||
const taskId = db.saveTask({ companyId, title: "No budget task", description: "Test", priority: "low", status: "todo" });
|
const taskId = db.saveTask({ companyId, title: "No budget task", description: "Test", priority: "low", status: "todo" });
|
||||||
const result = db.autoAssignTask(taskId, companyId);
|
const result = db.autoAssignTask(taskId, companyId);
|
||||||
|
|||||||
@@ -147,4 +147,100 @@ describe("AppDatabase", () => {
|
|||||||
|
|
||||||
db.close();
|
db.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns a renderer snapshot scoped to the current company while preserving global selectors", async () => {
|
||||||
|
const profileDir = await createTempDir("agentcompany-renderer-snapshot-");
|
||||||
|
const db = new AppDatabase(profileDir);
|
||||||
|
db.init();
|
||||||
|
|
||||||
|
const alphaCompanyId = db.saveCompany({
|
||||||
|
name: "Alpha",
|
||||||
|
description: "Alpha company",
|
||||||
|
status: "active",
|
||||||
|
});
|
||||||
|
|
||||||
|
const betaCompanyId = db.saveCompany({
|
||||||
|
name: "Beta",
|
||||||
|
description: "Beta company",
|
||||||
|
status: "active",
|
||||||
|
});
|
||||||
|
|
||||||
|
const alphaWorkspaceId = db.saveWorkspace({
|
||||||
|
companyId: alphaCompanyId,
|
||||||
|
name: "Alpha Workspace",
|
||||||
|
localPath: profileDir,
|
||||||
|
repoUrl: "",
|
||||||
|
repoRef: "",
|
||||||
|
isPrimary: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const betaWorkspaceId = db.saveWorkspace({
|
||||||
|
companyId: betaCompanyId,
|
||||||
|
name: "Beta Workspace",
|
||||||
|
localPath: profileDir,
|
||||||
|
repoUrl: "",
|
||||||
|
repoRef: "",
|
||||||
|
isPrimary: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const alphaAgentId = db.saveAgent({
|
||||||
|
companyId: alphaCompanyId,
|
||||||
|
name: "Alpha CEO",
|
||||||
|
role: "CEO",
|
||||||
|
title: "",
|
||||||
|
status: "active",
|
||||||
|
reportsTo: null,
|
||||||
|
connectorId: "codex_local",
|
||||||
|
workspaceId: alphaWorkspaceId,
|
||||||
|
model: null,
|
||||||
|
capabilities: "Lead alpha",
|
||||||
|
budgetMonthlyUsd: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
db.saveAgent({
|
||||||
|
companyId: betaCompanyId,
|
||||||
|
name: "Beta CEO",
|
||||||
|
role: "CEO",
|
||||||
|
title: "",
|
||||||
|
status: "active",
|
||||||
|
reportsTo: null,
|
||||||
|
connectorId: "codex_local",
|
||||||
|
workspaceId: betaWorkspaceId,
|
||||||
|
model: null,
|
||||||
|
capabilities: "Lead beta",
|
||||||
|
budgetMonthlyUsd: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
db.saveTask({
|
||||||
|
companyId: alphaCompanyId,
|
||||||
|
title: "Alpha task",
|
||||||
|
description: "Alpha work",
|
||||||
|
assigneeAgentId: alphaAgentId,
|
||||||
|
workspaceId: alphaWorkspaceId,
|
||||||
|
priority: "medium",
|
||||||
|
status: "todo",
|
||||||
|
});
|
||||||
|
|
||||||
|
db.saveTask({
|
||||||
|
companyId: betaCompanyId,
|
||||||
|
title: "Beta task",
|
||||||
|
description: "Beta work",
|
||||||
|
assigneeAgentId: null,
|
||||||
|
workspaceId: betaWorkspaceId,
|
||||||
|
priority: "medium",
|
||||||
|
status: "todo",
|
||||||
|
});
|
||||||
|
|
||||||
|
db.setCurrentCompany(alphaCompanyId);
|
||||||
|
|
||||||
|
const rendererSnapshot = db.listRendererSnapshot();
|
||||||
|
expect(rendererSnapshot.currentCompanyId).toBe(alphaCompanyId);
|
||||||
|
expect(rendererSnapshot.companies).toHaveLength(2);
|
||||||
|
expect(rendererSnapshot.connectors.length).toBeGreaterThan(0);
|
||||||
|
expect(rendererSnapshot.workspaces.map((workspace) => workspace.companyId)).toEqual([alphaCompanyId]);
|
||||||
|
expect(rendererSnapshot.agents.every((agent) => agent.companyId === alphaCompanyId)).toBe(true);
|
||||||
|
expect(rendererSnapshot.tasks.every((task) => task.companyId === alphaCompanyId)).toBe(true);
|
||||||
|
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -370,63 +370,6 @@ describe("AppDatabase", () => {
|
|||||||
expect(db.isAgentPendingApproval(pendingAgentId)).toBe(true);
|
expect(db.isAgentPendingApproval(pendingAgentId)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("detects budget exhaustion via isAgentBudgetExhausted", () => {
|
|
||||||
const companyId = db.saveCompany({
|
|
||||||
name: "Budget Gate Co",
|
|
||||||
description: "Budget gate validation",
|
|
||||||
status: "active",
|
|
||||||
});
|
|
||||||
const workspaceId = db.saveWorkspace({
|
|
||||||
companyId,
|
|
||||||
name: "WS",
|
|
||||||
localPath: "/tmp/budget-gate",
|
|
||||||
repoUrl: "",
|
|
||||||
repoRef: "",
|
|
||||||
isPrimary: true,
|
|
||||||
});
|
|
||||||
const agentId = db.saveAgent({
|
|
||||||
companyId,
|
|
||||||
name: "Budget Agent",
|
|
||||||
role: "IC",
|
|
||||||
title: "Budget",
|
|
||||||
status: "active",
|
|
||||||
reportsTo: null,
|
|
||||||
connectorId: "codex_local",
|
|
||||||
workspaceId,
|
|
||||||
model: null,
|
|
||||||
capabilities: "Test",
|
|
||||||
budgetMonthlyUsd: 10,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(db.isAgentBudgetExhausted(agentId)).toBe(false);
|
|
||||||
|
|
||||||
// Exhaust the budget via a run
|
|
||||||
const taskId = db.saveTask({
|
|
||||||
companyId,
|
|
||||||
title: "Budget drain",
|
|
||||||
description: "Test",
|
|
||||||
assigneeAgentId: agentId,
|
|
||||||
workspaceId,
|
|
||||||
priority: "medium",
|
|
||||||
status: "todo",
|
|
||||||
});
|
|
||||||
const run = db.createRun({ companyId, taskId, agentId, workspaceId, connectorId: "codex_local" });
|
|
||||||
db.finishRun({
|
|
||||||
runId: run.id,
|
|
||||||
status: "succeeded",
|
|
||||||
summary: "Done",
|
|
||||||
errorMessage: null,
|
|
||||||
exitCode: 0,
|
|
||||||
signal: null,
|
|
||||||
model: null,
|
|
||||||
sessionDisplayId: null,
|
|
||||||
costUsd: 15,
|
|
||||||
unattributedCost: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(db.isAgentBudgetExhausted(agentId)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("runs forward-only migrations and creates schema_migrations table", () => {
|
it("runs forward-only migrations and creates schema_migrations table", () => {
|
||||||
// The migration system runs during init() — verify the tracking table exists
|
// The migration system runs during init() — verify the tracking table exists
|
||||||
const snapshot = db.listSnapshot();
|
const snapshot = db.listSnapshot();
|
||||||
@@ -632,30 +575,4 @@ describe("AppDatabase", () => {
|
|||||||
expect(approval2.relatedAgentId).toBe(hireId);
|
expect(approval2.relatedAgentId).toBe(hireId);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("updates workspace state", () => {
|
|
||||||
const companyId = db.saveCompany({
|
|
||||||
name: "WS State Co",
|
|
||||||
description: "Workspace state test",
|
|
||||||
status: "active",
|
|
||||||
});
|
|
||||||
const wsId = db.saveWorkspace({
|
|
||||||
companyId,
|
|
||||||
name: "Test WS",
|
|
||||||
localPath: "/tmp/ws-state",
|
|
||||||
repoUrl: "",
|
|
||||||
repoRef: "",
|
|
||||||
isPrimary: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
let ws = db.getWorkspace(wsId);
|
|
||||||
expect(ws?.state).toBe("ready");
|
|
||||||
|
|
||||||
db.setWorkspaceState(wsId, "busy");
|
|
||||||
ws = db.getWorkspace(wsId);
|
|
||||||
expect(ws?.state).toBe("busy");
|
|
||||||
|
|
||||||
db.setWorkspaceState(wsId, "error");
|
|
||||||
ws = db.getWorkspace(wsId);
|
|
||||||
expect(ws?.state).toBe("error");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user