Rebuild AgentCompany Electron app

This commit is contained in:
ZacharyZhang-NY committed 2026-05-20 23:27:00 -04:00
1 parent bc5daa6eb8
commit fcf6d90731
56 files changed
+8667

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
node_modules/
out/
release/
test-results/
playwright-report/
coverage/
*.tsbuildinfo
design-source/
+41
View File
@@ -0,0 +1,41 @@
appId: ai.agentcompany.desktop
productName: AgentCompany
icon: resources/icon.png
directories:
output: release
buildResources: resources
files:
- out/**/*
- package.json
asar: true
mac:
category: public.app-category.business
hardenedRuntime: true
gatekeeperAssess: false
entitlements: resources/entitlements.mac.plist
entitlementsInherit: resources/entitlements.mac.plist
target:
- target: dmg
arch:
- x64
- arm64
- target: zip
arch:
- x64
- arm64
win:
target:
- target: nsis
arch:
- x64
artifactName: ${productName}-${version}-${arch}.${ext}
linux:
category: Office
target:
- target: AppImage
arch:
- x64
- target: deb
arch:
- x64
publish: null
+30
View File
@@ -0,0 +1,30 @@
import { resolve } from "node:path";
import { defineConfig, externalizeDepsPlugin } from "electron-vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
main: {
plugins: [externalizeDepsPlugin()],
build: {
rollupOptions: {
input: resolve(__dirname, "src/main/index.ts"),
},
},
},
preload: {
plugins: [externalizeDepsPlugin()],
build: {
rollupOptions: {
input: resolve(__dirname, "src/preload/index.ts"),
output: {
format: "cjs",
entryFileNames: "index.js",
},
},
},
},
renderer: {
root: resolve(__dirname, "src/renderer"),
plugins: [react()],
},
});
+27
View File
@@ -0,0 +1,27 @@
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ["out", "release", "node_modules", "design-source"] },
js.configs.recommended,
...tseslint.configs.recommended,
{
languageOptions: {
ecmaVersion: 2022,
globals: { ...globals.browser, ...globals.node },
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
},
},
);
+63
View File
@@ -0,0 +1,63 @@
{
"name": "agentcompany-new",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "Enterprise Electron control plane for AI agent companies.",
"author": {
"name": "AgentCompany",
"email": "dev@agentcompany.ai"
},
"main": "out/main/index.js",
"scripts": {
"dev": "electron-vite dev",
"build": "electron-vite build",
"typecheck": "tsc --noEmit",
"lint": "eslint .",
"test": "vitest run",
"test:e2e": "pnpm build && playwright test -c tests/e2e/playwright.config.ts",
"verify": "pnpm typecheck && pnpm lint && pnpm test && pnpm build",
"package:dir": "pnpm build && electron-builder --dir --publish never",
"package:win": "pnpm build && electron-builder --win nsis --x64 --publish never",
"package:mac:arm": "pnpm build && electron-builder --mac dmg --arm64 --publish never",
"package:mac:x64": "pnpm build && electron-builder --mac dmg --x64 --publish never",
"package:linux": "pnpm build && electron-builder --linux AppImage deb --x64 --publish never"
},
"dependencies": {
"lucide-react": "0.468.0",
"react": "19.2.4",
"react-dom": "19.2.4",
"zod": "4.3.6"
},
"devDependencies": {
"@eslint/js": "9.38.0",
"@playwright/test": "1.58.2",
"@types/node": "25.2.3",
"@types/react": "19.2.2",
"@types/react-dom": "19.2.2",
"@vitejs/plugin-react": "5.0.0",
"electron": "39.8.5",
"electron-builder": "26.8.1",
"electron-vite": "5.0.0",
"eslint": "9.38.0",
"eslint-plugin-react-hooks": "7.0.0",
"eslint-plugin-react-refresh": "0.4.24",
"globals": "16.4.0",
"typescript": "5.9.3",
"typescript-eslint": "8.46.1",
"vite": "7.3.2",
"vitest": "4.1.0"
},
"engines": {
"node": ">=22.0.0",
"pnpm": ">=10.0.0"
},
"packageManager": "pnpm@10.24.0",
"pnpm": {
"onlyBuiltDependencies": [
"electron",
"electron-winstaller",
"esbuild"
]
}
}
+4745
View File
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "https://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
</dict>
</plist>
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

@@ -0,0 +1,43 @@
import { BrowserWindow } from "electron";
import { join } from "node:path";
import { attachMainWindowSecurity } from "./window-security";
export interface CreateWindowOptions {
preloadPath: string;
rendererDist: string;
devServerUrl?: string;
}
export function createMainWindow(options: CreateWindowOptions): BrowserWindow {
const win = new BrowserWindow({
width: 1280,
height: 860,
minWidth: 980,
minHeight: 700,
title: "AgentCompany",
show: false,
titleBarStyle: process.platform === "darwin" ? "hiddenInset" : "hidden",
webPreferences: {
preload: options.preloadPath,
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
webSecurity: true,
allowRunningInsecureContent: false,
},
});
attachMainWindowSecurity(
win,
options.devServerUrl ? new URL(options.devServerUrl).origin : null,
);
win.once("ready-to-show", () => win.show());
if (options.devServerUrl) {
win.loadURL(options.devServerUrl);
} else {
win.loadFile(join(options.rendererDist, "index.html"));
}
return win;
}
@@ -0,0 +1,83 @@
import { ipcMain, type BrowserWindow } from "electron";
import type { DatabaseConnection } from "../database/connection";
import { IPC_CHANNELS } from "../../shared/ipc";
import { fail, ok, type DesktopResult } from "../../shared/result";
import type { AppSnapshot } from "../../shared/domain";
import { SnapshotService } from "../services/snapshot-service";
import { OrganizationService } from "../services/organization-service";
import { ApprovalService } from "../services/approval-service";
import { ChatService } from "../services/chat-service";
import { ProviderService } from "../services/provider-service";
import { SkillService } from "../services/skill-service";
import { SocialService } from "../services/social-service";
import { FileService } from "../services/file-service";
export interface IpcDeps {
connection: DatabaseConnection;
mainWindow: BrowserWindow;
artifactRoot: string;
}
export function registerIpcHandlers(deps: IpcDeps): void {
const snapshot = new SnapshotService(deps.connection.db);
const organization = new OrganizationService(deps.connection.db);
const approvals = new ApprovalService(deps.connection.db);
const chat = new ChatService(deps.connection.db);
const providers = new ProviderService(deps.connection.db);
const skills = new SkillService(deps.connection.db);
const social = new SocialService(deps.connection.db);
const files = new FileService(deps.connection.db, deps.artifactRoot);
const mutate = <T>(operation: () => T): DesktopResult<AppSnapshot> => {
try {
deps.connection.transaction(operation);
deps.mainWindow.webContents.send(IPC_CHANNELS.rendererEvent, { type: "snapshot-changed" });
return ok(snapshot.load());
} catch (error) {
return fail("operation_failed", error instanceof Error ? error.message : String(error));
}
};
ipcMain.handle(IPC_CHANNELS.loadSnapshot, () => {
try {
return ok(snapshot.load());
} catch (error) {
return fail("snapshot_failed", error instanceof Error ? error.message : String(error));
}
});
ipcMain.handle(IPC_CHANNELS.createOrganization, (_event, input) => mutate(() => organization.createOrganization(input)));
ipcMain.handle(IPC_CHANNELS.createAgent, (_event, input) => mutate(() => organization.createAgent(input)));
ipcMain.handle(IPC_CHANNELS.createTask, (_event, input) => mutate(() => organization.createTask(input)));
ipcMain.handle(IPC_CHANNELS.updateTaskStatus, (_event, input) => mutate(() => organization.updateTaskStatus(input.orgId, input.taskId, input.status)));
ipcMain.handle(IPC_CHANNELS.createApproval, (_event, input) => mutate(() => approvals.create(input)));
ipcMain.handle(IPC_CHANNELS.decideApproval, (_event, input) => mutate(() => approvals.decide(input)));
ipcMain.handle(IPC_CHANNELS.sendMessage, (_event, input) => mutate(() => chat.sendMessage(input)));
ipcMain.handle(IPC_CHANNELS.saveProviderCredential, (_event, input) => mutate(() => providers.saveCredential(input)));
ipcMain.handle(IPC_CHANNELS.importSkillDirectory, (_event, input) => mutate(() => skills.importDirectory(input)));
ipcMain.handle(IPC_CHANNELS.bindSkill, (_event, input) => mutate(() => skills.bind(input)));
ipcMain.handle(IPC_CHANNELS.createSocialAccount, (_event, input) => mutate(() => social.createAccount(input)));
ipcMain.handle(IPC_CHANNELS.launchSocialLogin, (_event, input) => {
try {
social.launchLoginWindow(input.socialAccountId);
return ok(null);
} catch (error) {
return fail("social_login_failed", error instanceof Error ? error.message : String(error));
}
});
ipcMain.handle(IPC_CHANNELS.importFile, async (_event, input) => {
try {
await files.importFile(input);
deps.mainWindow.webContents.send(IPC_CHANNELS.rendererEvent, { type: "snapshot-changed" });
return ok(snapshot.load());
} catch (error) {
return fail("file_import_failed", error instanceof Error ? error.message : String(error));
}
});
ipcMain.handle(IPC_CHANNELS.windowMinimize, () => deps.mainWindow.minimize());
ipcMain.handle(IPC_CHANNELS.windowMaximize, () => deps.mainWindow.isMaximized() ? deps.mainWindow.unmaximize() : deps.mainWindow.maximize());
ipcMain.handle(IPC_CHANNELS.windowClose, () => deps.mainWindow.close());
}
@@ -0,0 +1,10 @@
import type { BrowserWindow } from "electron";
export function attachMainWindowSecurity(win: BrowserWindow, devServerOrigin: string | null): void {
win.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
win.webContents.on("will-navigate", (event, url) => {
if (devServerOrigin && url.startsWith(devServerOrigin)) return;
if (url.startsWith("file://")) return;
event.preventDefault();
});
}
@@ -0,0 +1,32 @@
import { mkdirSync } from "node:fs";
import { dirname } from "node:path";
import { DatabaseSync } from "node:sqlite";
import { installSchema } from "./schema";
export class DatabaseConnection {
readonly db: DatabaseSync;
constructor(dbPath: string) {
mkdirSync(dirname(dbPath), { recursive: true });
this.db = new DatabaseSync(dbPath);
this.db.exec("pragma journal_mode = WAL");
this.db.exec("pragma foreign_keys = ON");
installSchema(this.db);
}
transaction<T>(operation: () => T): T {
this.db.exec("begin immediate");
try {
const result = operation();
this.db.exec("commit");
return result;
} catch (error) {
this.db.exec("rollback");
throw error;
}
}
close(): void {
this.db.close();
}
}
@@ -0,0 +1,255 @@
import type { DatabaseSync } from "node:sqlite";
const SCHEMA_VERSION = 1;
export function installSchema(db: DatabaseSync): void {
db.exec(`
create table if not exists schema_migrations (
version integer primary key,
name text not null,
applied_at text not null
);
`);
const row = db.prepare("select coalesce(max(version), 0) as version from schema_migrations").get() as { version: number };
if (row.version > SCHEMA_VERSION) {
throw new Error(`Database schema ${row.version} is newer than supported schema ${SCHEMA_VERSION}.`);
}
if (row.version === SCHEMA_VERSION) return;
db.exec(`
create table if not exists organizations (
id text primary key,
name text not null,
mission text not null,
goal text not null,
created_at text not null
);
create table if not exists employees (
id text primary key,
org_id text not null references organizations(id) on delete cascade,
display_name text not null,
role text not null,
created_at text not null
);
create table if not exists agents (
id text primary key,
org_id text not null references organizations(id) on delete cascade,
name text not null,
role text not null,
department text not null,
model_provider_id text,
model_id text,
budget_cents integer not null,
manager_agent_id text references agents(id) on delete set null,
lifecycle text not null,
created_at text not null
);
create table if not exists tasks (
id text primary key,
org_id text not null references organizations(id) on delete cascade,
title text not null,
description text not null,
status text not null,
assignee_agent_id text references agents(id) on delete set null,
risk text not null,
created_at text not null,
updated_at text not null
);
create table if not exists approvals (
id text primary key,
org_id text not null references organizations(id) on delete cascade,
kind text not null,
title text not null,
details text not null,
risk text not null,
requested_by_type text not null,
requested_by_id text,
state text not null,
decided_by_employee_id text references employees(id) on delete set null,
created_at text not null,
decided_at text
);
create table if not exists chat_spaces (
id text primary key,
org_id text not null references organizations(id) on delete cascade,
space_type text not null,
name text not null,
topic text not null,
visibility text not null,
created_at text not null
);
create table if not exists chat_memberships (
space_id text not null references chat_spaces(id) on delete cascade,
principal_type text not null,
principal_id text not null,
role text not null,
mute_state text not null,
primary key (space_id, principal_type, principal_id)
);
create table if not exists chat_messages (
id text primary key,
org_id text not null references organizations(id) on delete cascade,
space_id text not null references chat_spaces(id) on delete cascade,
thread_id text,
sender_type text not null,
sender_id text,
body_md text not null,
created_at text not null,
edited_at text,
deleted_at text
);
create table if not exists file_objects (
id text primary key,
org_id text not null references organizations(id) on delete cascade,
name text not null,
kind text not null,
size_bytes integer not null,
sha256 text not null,
storage_path text not null,
owner_type text not null,
owner_id text,
created_at text not null
);
create table if not exists providers (
id text primary key,
org_id text not null references organizations(id) on delete cascade,
provider_key text not null,
label text not null,
auth_type text not null,
model text,
credential_id text,
enabled integer not null,
created_at text not null,
unique(org_id, provider_key)
);
create table if not exists credentials (
id text primary key,
org_id text not null references organizations(id) on delete cascade,
provider_key text not null,
kind text not null,
ciphertext blob not null,
display_hint text not null,
created_at text not null
);
create table if not exists skill_definitions (
id text primary key,
org_id text not null references organizations(id) on delete cascade,
slug text not null,
name text not null,
owner_type text not null,
status text not null,
created_at text not null,
unique(org_id, slug)
);
create table if not exists skill_revisions (
id text primary key,
skill_id text not null references skill_definitions(id) on delete cascade,
version_semver text not null,
manifest_json text not null,
source_type text not null,
source_ref text not null,
published_at text not null,
immutable_checksum text not null,
unique(skill_id, version_semver)
);
create table if not exists skill_artifacts (
id text primary key,
revision_id text not null references skill_revisions(id) on delete cascade,
artifact_type text not null,
object_key text not null,
sha256 text not null,
size_bytes integer not null
);
create table if not exists skill_tests (
id text primary key,
revision_id text not null references skill_revisions(id) on delete cascade,
test_type text not null,
expected_output_ref text not null,
last_result text,
last_run_at text
);
create table if not exists skill_bindings (
id text primary key,
org_id text not null references organizations(id) on delete cascade,
agent_id text not null references agents(id) on delete cascade,
skill_id text not null references skill_definitions(id) on delete cascade,
pinned_revision_id text not null references skill_revisions(id) on delete cascade,
binding_mode text not null,
priority integer not null,
enabled integer not null,
unique(agent_id, skill_id)
);
create table if not exists social_accounts (
id text primary key,
org_id text not null references organizations(id) on delete cascade,
platform text not null,
handle text not null,
display_name text not null,
authorized_employee_id text references employees(id) on delete set null,
created_at text not null
);
create table if not exists social_account_grants (
id text primary key,
org_id text not null references organizations(id) on delete cascade,
social_account_id text not null references social_accounts(id) on delete cascade,
employee_id text not null references employees(id) on delete cascade,
role text not null,
scope_json text not null,
created_at text not null
);
create table if not exists browser_actions (
id text primary key,
org_id text not null references organizations(id) on delete cascade,
social_account_id text not null references social_accounts(id) on delete cascade,
action_type text not null,
policy_decision text not null,
evidence_file_id text references file_objects(id) on delete set null,
created_at text not null
);
create table if not exists migration_reports (
id text primary key,
org_id text not null references organizations(id) on delete cascade,
source_ref text not null,
report_json text not null,
created_at text not null
);
create table if not exists audit_events (
id text primary key,
org_id text not null references organizations(id) on delete cascade,
entity_type text not null,
entity_id text not null,
actor_type text not null,
actor_id text,
action text not null,
payload_json text not null,
created_at text not null
);
create index if not exists idx_tasks_org_status on tasks(org_id, status);
create index if not exists idx_messages_space_created on chat_messages(space_id, created_at);
create index if not exists idx_audit_org_created on audit_events(org_id, created_at);
insert into schema_migrations(version, name, applied_at)
values (${SCHEMA_VERSION}, 'initial_enterprise_schema', datetime('now'));
`);
}
+58
View File
@@ -0,0 +1,58 @@
import { app, session } from "electron";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { DatabaseConnection } from "./database/connection";
import { createMainWindow } from "./app/create-window";
import { registerIpcHandlers } from "./app/ipc-handlers";
const __dirname = dirname(fileURLToPath(import.meta.url));
const hasSingleInstanceLock = app.requestSingleInstanceLock();
if (!hasSingleInstanceLock) {
app.quit();
}
app.whenReady().then(() => {
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
callback({
responseHeaders: {
...details.responseHeaders,
"Content-Security-Policy": [
"default-src 'self'",
"script-src 'self'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data:",
"connect-src 'self' http://localhost:* ws://localhost:*",
"font-src 'self' data:",
].join("; "),
},
});
});
const profileDir = process.env.AGENTCOMPANY_NEW_PROFILE_DIR ?? join(app.getPath("userData"), "profile");
const connection = new DatabaseConnection(join(profileDir, "agentcompany.db"));
const win = createMainWindow({
preloadPath: join(__dirname, "../preload/index.js"),
rendererDist: join(__dirname, "../renderer"),
devServerUrl: process.env.ELECTRON_RENDERER_URL,
});
registerIpcHandlers({
connection,
mainWindow: win,
artifactRoot: join(profileDir, "artifacts"),
});
app.on("second-instance", () => {
if (win.isMinimized()) win.restore();
win.focus();
});
app.on("before-quit", () => {
connection.close();
});
});
app.on("window-all-closed", () => {
if (process.platform !== "darwin") app.quit();
});
@@ -0,0 +1,66 @@
import type { DatabaseSync } from "node:sqlite";
import type { CreateApprovalInput, DecideApprovalInput } from "../../shared/commands";
import { APPROVAL_STATE } from "../../shared/domain";
import { createId } from "../../shared/ids";
import { AuditService } from "./audit-service";
export class ApprovalService {
private readonly audit: AuditService;
constructor(private readonly db: DatabaseSync) {
this.audit = new AuditService(db);
}
create(input: CreateApprovalInput): string {
const id = createId("app");
const now = new Date().toISOString();
this.db.prepare(`
insert into approvals(
id, org_id, kind, title, details, risk, requested_by_type, requested_by_id,
state, decided_by_employee_id, created_at, decided_at
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
id,
input.orgId,
input.kind.trim(),
input.title.trim(),
input.details.trim(),
input.risk,
input.requestedByType,
input.requestedById,
APPROVAL_STATE.PENDING,
null,
now,
null,
);
this.audit.record({
orgId: input.orgId,
entityType: "approval",
entityId: id,
actorType: input.requestedByType,
actorId: input.requestedById,
action: "requested",
payload: { kind: input.kind, risk: input.risk },
});
return id;
}
decide(input: DecideApprovalInput): void {
const now = new Date().toISOString();
const result = this.db.prepare(`
update approvals
set state = ?, decided_by_employee_id = ?, decided_at = ?
where id = ? and org_id = ? and state = ?
`).run(input.state, input.decidedByEmployeeId, now, input.approvalId, input.orgId, APPROVAL_STATE.PENDING);
if (result.changes === 0) throw new Error("Pending approval was not found.");
this.audit.record({
orgId: input.orgId,
entityType: "approval",
entityId: input.approvalId,
actorType: "employee",
actorId: input.decidedByEmployeeId,
action: input.state,
payload: { decidedAt: now },
});
}
}
@@ -0,0 +1,31 @@
import type { DatabaseSync } from "node:sqlite";
import { createId } from "../../shared/ids";
export class AuditService {
constructor(private readonly db: DatabaseSync) {}
record(input: {
orgId: string;
entityType: string;
entityId: string;
actorType: string;
actorId: string | null;
action: string;
payload: unknown;
}): void {
this.db.prepare(`
insert into audit_events(id, org_id, entity_type, entity_id, actor_type, actor_id, action, payload_json, created_at)
values (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
createId("aud"),
input.orgId,
input.entityType,
input.entityId,
input.actorType,
input.actorId,
input.action,
JSON.stringify(input.payload),
new Date().toISOString(),
);
}
}
@@ -0,0 +1,45 @@
import type { DatabaseSync } from "node:sqlite";
import type { SendMessageInput } from "../../shared/commands";
import { createId } from "../../shared/ids";
import { AuditService } from "./audit-service";
export class ChatService {
private readonly audit: AuditService;
constructor(private readonly db: DatabaseSync) {
this.audit = new AuditService(db);
}
sendMessage(input: SendMessageInput): string {
const body = input.bodyMd.trim();
if (!body) throw new Error("Message body is required.");
const id = createId("msg");
const now = new Date().toISOString();
this.db.prepare(`
insert into chat_messages(
id, org_id, space_id, thread_id, sender_type, sender_id, body_md, created_at, edited_at, deleted_at
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
id,
input.orgId,
input.spaceId,
input.threadId,
input.senderType,
input.senderId,
body,
now,
null,
null,
);
this.audit.record({
orgId: input.orgId,
entityType: "chat_message",
entityId: id,
actorType: input.senderType,
actorId: input.senderId,
action: "sent",
payload: { spaceId: input.spaceId, threadId: input.threadId },
});
return id;
}
}
@@ -0,0 +1,77 @@
import { createHash } from "node:crypto";
import { copyFileSync, mkdirSync, readFileSync, statSync } from "node:fs";
import { basename, extname, join } from "node:path";
import type { DatabaseSync } from "node:sqlite";
import { dialog } from "electron";
import type { ImportFileInput } from "../../shared/commands";
import { createId } from "../../shared/ids";
import { AuditService } from "./audit-service";
function fileKind(path: string): string {
const ext = extname(path).toLowerCase().replace(".", "");
if (!ext) return "file";
if (["md", "markdown"].includes(ext)) return "md";
if (["pdf"].includes(ext)) return "pdf";
if (["png", "jpg", "jpeg", "gif", "webp", "svg"].includes(ext)) return "img";
if (["csv", "xlsx", "xls"].includes(ext)) return "sheet";
if (["doc", "docx", "txt"].includes(ext)) return "doc";
return ext;
}
export class FileService {
private readonly audit: AuditService;
constructor(
private readonly db: DatabaseSync,
private readonly artifactRoot: string,
) {
this.audit = new AuditService(db);
mkdirSync(this.artifactRoot, { recursive: true });
}
async importFile(input: ImportFileInput): Promise<string | null> {
const result = await dialog.showOpenDialog({
properties: ["openFile"],
securityScopedBookmarks: true,
});
if (result.canceled || result.filePaths.length === 0) return null;
return this.importPath(input.orgId, result.filePaths[0]);
}
importPath(orgId: string, sourcePath: string): string {
const id = createId("fil");
const now = new Date().toISOString();
const stats = statSync(sourcePath);
if (!stats.isFile()) throw new Error("Selected path is not a file.");
const hash = createHash("sha256").update(readFileSync(sourcePath)).digest("hex");
const orgDir = join(this.artifactRoot, orgId);
mkdirSync(orgDir, { recursive: true });
const target = join(orgDir, `${id}-${basename(sourcePath)}`);
copyFileSync(sourcePath, target);
this.db.prepare(`
insert into file_objects(id, org_id, name, kind, size_bytes, sha256, storage_path, owner_type, owner_id, created_at)
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
id,
orgId,
basename(sourcePath),
fileKind(sourcePath),
stats.size,
hash,
target,
"system",
null,
now,
);
this.audit.record({
orgId,
entityType: "file_object",
entityId: id,
actorType: "system",
actorId: null,
action: "imported",
payload: { sha256: hash, sizeBytes: stats.size },
});
return id;
}
}
@@ -0,0 +1,168 @@
import type { DatabaseSync } from "node:sqlite";
import { APPROVAL_STATE, RISK_LEVEL, TASK_STATUS } from "../../shared/domain";
import { PROVIDER_CATALOG } from "../../shared/provider-catalog";
import { createId } from "../../shared/ids";
import type { CreateAgentInput, CreateOrganizationInput, CreateTaskInput } from "../../shared/commands";
import { AuditService } from "./audit-service";
export class OrganizationService {
private readonly audit: AuditService;
constructor(private readonly db: DatabaseSync) {
this.audit = new AuditService(db);
}
createOrganization(input: CreateOrganizationInput): string {
const orgId = createId("org");
const employeeId = createId("emp");
const now = new Date().toISOString();
this.db.prepare("insert into organizations(id, name, mission, goal, created_at) values (?, ?, ?, ?, ?)")
.run(orgId, input.organizationName.trim(), input.mission.trim(), input.goal.trim(), now);
this.db.prepare("insert into employees(id, org_id, display_name, role, created_at) values (?, ?, ?, ?, ?)")
.run(employeeId, orgId, input.employeeName.trim(), "Board Chair", now);
for (const space of [
["company", "general", "Company coordination", "workspace"],
["department", "product", "Product decisions", "workspace"],
["department", "engineering", "Engineering delivery", "workspace"],
["company", "board", "Board decisions", "restricted"],
]) {
const spaceId = createId("spc");
this.db.prepare(`
insert into chat_spaces(id, org_id, space_type, name, topic, visibility, created_at)
values (?, ?, ?, ?, ?, ?, ?)
`).run(spaceId, orgId, space[0], space[1], space[2], space[3], now);
this.db.prepare(`
insert into chat_memberships(space_id, principal_type, principal_id, role, mute_state)
values (?, ?, ?, ?, ?)
`).run(spaceId, "employee", employeeId, space[1] === "board" ? "approver" : "member", "unmuted");
}
for (const provider of PROVIDER_CATALOG) {
this.db.prepare(`
insert into providers(id, org_id, provider_key, label, auth_type, model, credential_id, enabled, created_at)
values (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
createId("prv"),
orgId,
provider.key,
provider.label,
provider.authType,
provider.defaultModel,
null,
0,
now,
);
}
this.audit.record({
orgId,
entityType: "organization",
entityId: orgId,
actorType: "employee",
actorId: employeeId,
action: "created",
payload: { name: input.organizationName.trim() },
});
return orgId;
}
createAgent(input: CreateAgentInput): string {
const id = createId("agt");
const now = new Date().toISOString();
this.db.prepare(`
insert into agents(
id, org_id, name, role, department, model_provider_id, model_id,
budget_cents, manager_agent_id, lifecycle, created_at
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
id,
input.orgId,
input.name.trim(),
input.role.trim(),
input.department.trim(),
input.modelProviderId,
input.modelId,
input.budgetCents,
input.managerAgentId,
"active",
now,
);
this.audit.record({
orgId: input.orgId,
entityType: "agent",
entityId: id,
actorType: "system",
actorId: null,
action: "created",
payload: { role: input.role, department: input.department },
});
return id;
}
createTask(input: CreateTaskInput): string {
const id = createId("tsk");
const now = new Date().toISOString();
this.db.prepare(`
insert into tasks(id, org_id, title, description, status, assignee_agent_id, risk, created_at, updated_at)
values (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
id,
input.orgId,
input.title.trim(),
input.description.trim(),
input.status,
input.assigneeAgentId,
input.risk,
now,
now,
);
if (input.risk === RISK_LEVEL.HIGH) {
this.db.prepare(`
insert into approvals(
id, org_id, kind, title, details, risk, requested_by_type,
requested_by_id, state, decided_by_employee_id, created_at, decided_at
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
createId("app"),
input.orgId,
"task_risk",
input.title.trim(),
input.description.trim(),
RISK_LEVEL.HIGH,
"system",
null,
APPROVAL_STATE.PENDING,
null,
now,
null,
);
}
this.audit.record({
orgId: input.orgId,
entityType: "task",
entityId: id,
actorType: "system",
actorId: null,
action: "created",
payload: { status: TASK_STATUS.BACKLOG },
});
return id;
}
updateTaskStatus(orgId: string, taskId: string, status: string): void {
const result = this.db.prepare("update tasks set status = ?, updated_at = ? where id = ? and org_id = ?")
.run(status, new Date().toISOString(), taskId, orgId);
if (result.changes === 0) throw new Error("Task was not found.");
this.audit.record({
orgId,
entityType: "task",
entityId: taskId,
actorType: "system",
actorId: null,
action: "status_changed",
payload: { status },
});
}
}
@@ -0,0 +1,53 @@
import type { DatabaseSync } from "node:sqlite";
import type { SaveProviderCredentialInput } from "../../shared/commands";
import { createId } from "../../shared/ids";
import { AuditService } from "./audit-service";
import { SecretVault } from "./secret-vault";
function displayHint(secret: string): string {
const clean = secret.trim();
if (clean.length <= 10) return `${clean.slice(0, 2)}...`;
return `${clean.slice(0, 6)}...${clean.slice(-4)}`;
}
export class ProviderService {
private readonly audit: AuditService;
private readonly vault = new SecretVault();
constructor(private readonly db: DatabaseSync) {
this.audit = new AuditService(db);
}
saveCredential(input: SaveProviderCredentialInput): void {
const provider = this.db.prepare("select * from providers where id = ? and org_id = ?")
.get(input.providerId, input.orgId) as { provider_key: string; auth_type: string } | undefined;
if (!provider) throw new Error("Provider was not found.");
const secret = input.secret.trim();
if (!secret) throw new Error("Credential is required.");
const credentialId = createId("cred");
const now = new Date().toISOString();
this.db.prepare(`
insert into credentials(id, org_id, provider_key, kind, ciphertext, display_hint, created_at)
values (?, ?, ?, ?, ?, ?, ?)
`).run(
credentialId,
input.orgId,
provider.provider_key,
provider.auth_type,
this.vault.encryptSecret(secret),
displayHint(secret),
now,
);
this.db.prepare("update providers set credential_id = ?, model = ?, enabled = 1 where id = ? and org_id = ?")
.run(credentialId, input.model, input.providerId, input.orgId);
this.audit.record({
orgId: input.orgId,
entityType: "provider",
entityId: input.providerId,
actorType: "system",
actorId: null,
action: "credential_saved",
payload: { providerKey: provider.provider_key, authType: provider.auth_type },
});
}
}
@@ -0,0 +1,17 @@
import { safeStorage } from "electron";
export class SecretVault {
encryptSecret(secret: string): Buffer {
if (!safeStorage.isEncryptionAvailable()) {
throw new Error("OS credential encryption is unavailable.");
}
return safeStorage.encryptString(secret);
}
decryptSecret(ciphertext: Buffer): string {
if (!safeStorage.isEncryptionAvailable()) {
throw new Error("OS credential encryption is unavailable.");
}
return safeStorage.decryptString(ciphertext);
}
}
@@ -0,0 +1,130 @@
import { createHash } from "node:crypto";
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
import { basename, join } from "node:path";
import type { DatabaseSync } from "node:sqlite";
import type { BindSkillInput, ImportSkillDirectoryInput } from "../../shared/commands";
import { createId } from "../../shared/ids";
import { AuditService } from "./audit-service";
interface SkillManifest {
name: string;
version: string;
entrypoint: string;
capabilities: string[];
requiredTools: string[];
compatibleModels: string[];
riskLevel: string;
}
function slugify(value: string): string {
return value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
}
function parseSkillName(markdown: string, directoryPath: string): string {
const frontmatterName = markdown.match(/^---[\s\S]*?\nname:\s*["']?([^"'\n]+)["']?/m)?.[1];
if (frontmatterName) return frontmatterName.trim();
const heading = markdown.match(/^#\s+(.+)$/m)?.[1];
return heading?.trim() || basename(directoryPath);
}
function hashFiles(directoryPath: string): string {
const hash = createHash("sha256");
const files = readdirSync(directoryPath).filter((name) => !name.startsWith(".")).sort();
for (const file of files) {
const path = join(directoryPath, file);
const stats = statSync(path);
if (stats.isFile()) {
hash.update(file);
hash.update(readFileSync(path));
}
}
return hash.digest("hex");
}
export class SkillService {
private readonly audit: AuditService;
constructor(private readonly db: DatabaseSync) {
this.audit = new AuditService(db);
}
importDirectory(input: ImportSkillDirectoryInput): string {
const skillPath = join(input.directoryPath, "SKILL.md");
if (!existsSync(skillPath)) throw new Error("SKILL.md was not found in the selected directory.");
const markdown = readFileSync(skillPath, "utf8");
const name = parseSkillName(markdown, input.directoryPath);
const slug = slugify(name);
if (!slug) throw new Error("Skill name could not be derived.");
const now = new Date().toISOString();
const skillId = createId("skl");
const revisionId = createId("skr");
const checksum = hashFiles(input.directoryPath);
const manifest: SkillManifest = {
name,
version: input.versionSemver,
entrypoint: "SKILL.md",
capabilities: [],
requiredTools: [],
compatibleModels: ["openai/*", "anthropic/*", "xai/*"],
riskLevel: "medium",
};
this.db.prepare(`
insert into skill_definitions(id, org_id, slug, name, owner_type, status, created_at)
values (?, ?, ?, ?, ?, ?, ?)
on conflict(org_id, slug) do update set name = excluded.name, status = 'active'
`).run(skillId, input.orgId, slug, name, "organization", "active", now);
const existing = this.db.prepare("select id from skill_definitions where org_id = ? and slug = ?")
.get(input.orgId, slug) as { id: string };
this.db.prepare(`
insert into skill_revisions(
id, skill_id, version_semver, manifest_json, source_type, source_ref, published_at, immutable_checksum
) values (?, ?, ?, ?, ?, ?, ?, ?)
`).run(
revisionId,
existing.id,
input.versionSemver,
JSON.stringify(manifest),
"local_directory",
input.directoryPath,
now,
checksum,
);
this.db.prepare(`
insert into skill_artifacts(id, revision_id, artifact_type, object_key, sha256, size_bytes)
values (?, ?, ?, ?, ?, ?)
`).run(createId("ska"), revisionId, "markdown", skillPath, checksum, Buffer.byteLength(markdown));
this.audit.record({
orgId: input.orgId,
entityType: "skill_revision",
entityId: revisionId,
actorType: "system",
actorId: null,
action: "published",
payload: { slug, version: input.versionSemver },
});
return revisionId;
}
bind(input: BindSkillInput): string {
const id = createId("skb");
this.db.prepare(`
insert into skill_bindings(id, org_id, agent_id, skill_id, pinned_revision_id, binding_mode, priority, enabled)
values (?, ?, ?, ?, ?, ?, ?, ?)
on conflict(agent_id, skill_id) do update set pinned_revision_id = excluded.pinned_revision_id, enabled = 1
`).run(id, input.orgId, input.agentId, input.skillId, input.revisionId, "pinned", 100, 1);
this.audit.record({
orgId: input.orgId,
entityType: "skill_binding",
entityId: id,
actorType: "system",
actorId: null,
action: "bound",
payload: { agentId: input.agentId, skillId: input.skillId, revisionId: input.revisionId },
});
return id;
}
}
@@ -0,0 +1,57 @@
import type { DatabaseSync, StatementSync } from "node:sqlite";
import type { AppSnapshot } from "../../shared/domain";
import { PROVIDER_CATALOG } from "../../shared/provider-catalog";
type Row = Record<string, unknown>;
function all<T>(statement: StatementSync): T[] {
return statement.all() as T[];
}
function toBool(value: unknown): boolean {
return value === 1 || value === true;
}
function camel(row: Row): Row {
const next: Row = {};
for (const [key, value] of Object.entries(row)) {
next[key.replace(/_([a-z])/g, (_m, ch: string) => ch.toUpperCase())] = value;
}
return next;
}
function list<T>(db: DatabaseSync, table: string, orderBy: string): T[] {
return all<Row>(db.prepare(`select * from ${table} order by ${orderBy}`)).map((row) => camel(row) as T);
}
export class SnapshotService {
constructor(private readonly db: DatabaseSync) {}
load(): AppSnapshot {
const organizations = list<AppSnapshot["organizations"][number]>(this.db, "organizations", "created_at asc");
const activeOrgId = organizations[0]?.id ?? null;
const providers = list<AppSnapshot["providers"][number]>(this.db, "providers", "label asc")
.map((provider) => ({ ...provider, enabled: toBool(provider.enabled) }));
const skillBindings = list<AppSnapshot["skillBindings"][number]>(this.db, "skill_bindings", "priority asc")
.map((binding) => ({ ...binding, enabled: toBool(binding.enabled) }));
return {
activeOrgId,
organizations,
employees: list(this.db, "employees", "created_at asc"),
agents: list(this.db, "agents", "created_at asc"),
tasks: list(this.db, "tasks", "created_at asc"),
approvals: list(this.db, "approvals", "created_at desc"),
chatSpaces: list(this.db, "chat_spaces", "created_at asc"),
chatMessages: list(this.db, "chat_messages", "created_at asc"),
files: list(this.db, "file_objects", "created_at desc"),
providers,
providerCatalog: PROVIDER_CATALOG,
skillDefinitions: list(this.db, "skill_definitions", "created_at asc"),
skillRevisions: list(this.db, "skill_revisions", "published_at desc"),
skillBindings,
socialAccounts: list(this.db, "social_accounts", "created_at asc"),
auditEvents: list<AppSnapshot["auditEvents"][number]>(this.db, "audit_events", "created_at desc").slice(0, 200),
};
}
}
@@ -0,0 +1,113 @@
import type { DatabaseSync } from "node:sqlite";
import { BrowserWindow, session } from "electron";
import type { CreateSocialAccountInput } from "../../shared/commands";
import { SOCIAL_PLATFORM, type SocialPlatform } from "../../shared/domain";
import { createId } from "../../shared/ids";
import { AuditService } from "./audit-service";
const PLATFORM_LOGIN_URL: Record<SocialPlatform, string> = {
[SOCIAL_PLATFORM.X]: "https://x.com/i/flow/login",
[SOCIAL_PLATFORM.LINKEDIN]: "https://www.linkedin.com/login",
[SOCIAL_PLATFORM.YOUTUBE]: "https://accounts.google.com/",
[SOCIAL_PLATFORM.INSTAGRAM]: "https://www.instagram.com/accounts/login/",
};
const ALLOWED_HOSTS: Record<SocialPlatform, string[]> = {
[SOCIAL_PLATFORM.X]: ["x.com", "twitter.com"],
[SOCIAL_PLATFORM.LINKEDIN]: ["linkedin.com", "www.linkedin.com"],
[SOCIAL_PLATFORM.YOUTUBE]: ["accounts.google.com", "youtube.com", "www.youtube.com"],
[SOCIAL_PLATFORM.INSTAGRAM]: ["instagram.com", "www.instagram.com"],
};
export class SocialService {
private readonly audit: AuditService;
constructor(private readonly db: DatabaseSync) {
this.audit = new AuditService(db);
}
createAccount(input: CreateSocialAccountInput): string {
const id = createId("soc");
const now = new Date().toISOString();
this.db.prepare(`
insert into social_accounts(id, org_id, platform, handle, display_name, authorized_employee_id, created_at)
values (?, ?, ?, ?, ?, ?, ?)
`).run(
id,
input.orgId,
input.platform,
input.handle.trim(),
input.displayName.trim(),
input.authorizedEmployeeId,
now,
);
if (input.authorizedEmployeeId) {
this.db.prepare(`
insert into social_account_grants(id, org_id, social_account_id, employee_id, role, scope_json, created_at)
values (?, ?, ?, ?, ?, ?, ?)
`).run(
createId("sgr"),
input.orgId,
id,
input.authorizedEmployeeId,
"operator",
JSON.stringify({ actions: ["login", "draft", "approve"] }),
now,
);
}
this.audit.record({
orgId: input.orgId,
entityType: "social_account",
entityId: id,
actorType: "system",
actorId: null,
action: "created",
payload: { platform: input.platform, handle: input.handle.trim() },
});
return id;
}
launchLoginWindow(accountId: string): void {
const account = this.db.prepare("select * from social_accounts where id = ?")
.get(accountId) as { id: string; org_id: string; platform: SocialPlatform; handle: string } | undefined;
if (!account) throw new Error("Social account was not found.");
const partition = `persist:agentcompany-social-${account.id}`;
const socialSession = session.fromPartition(partition);
socialSession.setPermissionRequestHandler((_webContents, _permission, callback) => callback(false));
socialSession.setPermissionCheckHandler(() => false);
const win = new BrowserWindow({
width: 1280,
height: 860,
title: `${account.handle}`,
webPreferences: {
session: socialSession,
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
},
});
this.attachNavigationPolicy(win, account.platform);
win.loadURL(PLATFORM_LOGIN_URL[account.platform]);
this.audit.record({
orgId: account.org_id,
entityType: "social_account",
entityId: account.id,
actorType: "system",
actorId: null,
action: "login_window_opened",
payload: { platform: account.platform },
});
}
private attachNavigationPolicy(win: BrowserWindow, platform: SocialPlatform): void {
const allowed = ALLOWED_HOSTS[platform];
win.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
win.webContents.on("will-navigate", (event, url) => {
const host = new URL(url).hostname.toLowerCase();
if (!allowed.some((entry) => host === entry || host.endsWith(`.${entry}`))) {
event.preventDefault();
}
});
}
}
+35
View File
@@ -0,0 +1,35 @@
import { contextBridge, ipcRenderer } from "electron";
import { IPC_CHANNELS } from "../shared/ipc";
import type { DesktopApi, RendererEvent } from "../shared/desktop-api";
function invoke<T>(channel: string, payload?: unknown): Promise<T> {
return ipcRenderer.invoke(channel, payload) as Promise<T>;
}
const api: DesktopApi = {
loadSnapshot: () => invoke(IPC_CHANNELS.loadSnapshot),
createOrganization: (input) => invoke(IPC_CHANNELS.createOrganization, input),
createAgent: (input) => invoke(IPC_CHANNELS.createAgent, input),
createTask: (input) => invoke(IPC_CHANNELS.createTask, input),
updateTaskStatus: (input) => invoke(IPC_CHANNELS.updateTaskStatus, input),
createApproval: (input) => invoke(IPC_CHANNELS.createApproval, input),
decideApproval: (input) => invoke(IPC_CHANNELS.decideApproval, input),
sendMessage: (input) => invoke(IPC_CHANNELS.sendMessage, input),
saveProviderCredential: (input) => invoke(IPC_CHANNELS.saveProviderCredential, input),
importSkillDirectory: (input) => invoke(IPC_CHANNELS.importSkillDirectory, input),
bindSkill: (input) => invoke(IPC_CHANNELS.bindSkill, input),
createSocialAccount: (input) => invoke(IPC_CHANNELS.createSocialAccount, input),
launchSocialLogin: (input) => invoke(IPC_CHANNELS.launchSocialLogin, input),
importFile: (input) => invoke(IPC_CHANNELS.importFile, input),
subscribe: (callback) => {
const listener = (_event: Electron.IpcRendererEvent, payload: RendererEvent) => callback(payload);
ipcRenderer.on(IPC_CHANNELS.rendererEvent, listener);
return () => ipcRenderer.removeListener(IPC_CHANNELS.rendererEvent, listener);
},
platform: process.platform as DesktopApi["platform"],
windowMinimize: () => invoke<void>(IPC_CHANNELS.windowMinimize),
windowMaximize: () => invoke<void>(IPC_CHANNELS.windowMaximize),
windowClose: () => invoke<void>(IPC_CHANNELS.windowClose),
};
contextBridge.exposeInMainWorld("agentCompany", api);
+7
View File
@@ -0,0 +1,7 @@
import type { DesktopApi } from "../shared/desktop-api";
declare global {
interface Window {
agentCompany: DesktopApi;
}
}
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:;"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AgentCompany</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
@@ -0,0 +1,47 @@
import { useState } from "react";
import { Shell, type RouteId } from "../shared/Shell";
import { Onboarding } from "./Onboarding";
import { useSnapshot } from "./useSnapshot";
import { HomePage } from "../pages/HomePage";
import { TeamPage } from "../pages/TeamPage";
import { WorkPage } from "../pages/WorkPage";
import { ChatPage } from "../pages/ChatPage";
import { FilesPage } from "../pages/FilesPage";
import { ApprovalsPage } from "../pages/ApprovalsPage";
import { SocialPage } from "../pages/SocialPage";
import { SettingsPage } from "../pages/SettingsPage";
export function App() {
const { snapshot, error, actions } = useSnapshot();
const [route, setRoute] = useState<RouteId>("home");
if (!snapshot) {
return <div className="app-shell" style={{ display: "grid", placeItems: "center" }} />;
}
if (snapshot.organizations.length === 0) {
return <Onboarding actions={actions} />;
}
const pageProps = { snapshot, actions };
const page = {
home: <HomePage {...pageProps} />,
team: <TeamPage {...pageProps} />,
work: <WorkPage {...pageProps} />,
chat: <ChatPage {...pageProps} />,
files: <FilesPage {...pageProps} />,
approvals: <ApprovalsPage {...pageProps} />,
social: <SocialPage {...pageProps} />,
settings: <SettingsPage {...pageProps} />,
}[route];
return (
<Shell snapshot={snapshot} route={route} setRoute={setRoute}>
{error && (
<div className="card" style={{ marginBottom: 16, color: "var(--red)" }}>
{error}
</div>
)}
{page}
</Shell>
);
}
@@ -0,0 +1,46 @@
import type { FormEvent } from "react";
import { Button, Field } from "../shared/ui";
import type { SnapshotActions } from "./useSnapshot";
function value(form: FormData, key: string): string {
return String(form.get(key) ?? "").trim();
}
export function Onboarding({ actions }: { actions: SnapshotActions }) {
const submit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
void actions.run(() => window.agentCompany.createOrganization({
organizationName: value(form, "organizationName"),
mission: value(form, "mission"),
goal: value(form, "goal"),
employeeName: value(form, "employeeName"),
}));
};
return (
<main className="app-shell" style={{ display: "grid", placeItems: "center", padding: 24 }}>
<form className="card" style={{ width: "min(560px, 100%)", display: "grid", gap: 14 }} onSubmit={submit}>
<div>
<h1 className="page-h1">AgentCompany</h1>
<p className="page-sub" style={{ marginBottom: 0 }}>Create the company control plane.</p>
</div>
<Field label="Company name">
<input className="input" name="organizationName" required autoFocus />
</Field>
<Field label="Your name">
<input className="input" name="employeeName" required />
</Field>
<Field label="Mission">
<textarea className="input" name="mission" required />
</Field>
<Field label="First goal">
<textarea className="input" name="goal" required />
</Field>
<div style={{ display: "flex", justifyContent: "flex-end" }}>
<Button tone="primary" type="submit">Create</Button>
</div>
</form>
</main>
);
}
@@ -0,0 +1,49 @@
import { useCallback, useEffect, useState } from "react";
import type { AppSnapshot } from "../../../shared/domain";
import type { DesktopResult } from "../../../shared/result";
export interface SnapshotActions {
run<T extends AppSnapshot | null>(operation: () => Promise<DesktopResult<T>>): Promise<T | null>;
reload(): Promise<void>;
}
export function useSnapshot(): {
snapshot: AppSnapshot | null;
error: string | null;
actions: SnapshotActions;
} {
const [snapshot, setSnapshot] = useState<AppSnapshot | null>(null);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
const result = await window.agentCompany.loadSnapshot();
if (result.ok) {
setSnapshot(result.data);
setError(null);
} else {
setError(result.error.message);
}
}, []);
const run = useCallback(async <T extends AppSnapshot | null>(
operation: () => Promise<DesktopResult<T>>,
): Promise<T | null> => {
const result = await operation();
if (result.ok) {
if (result.data) setSnapshot(result.data as AppSnapshot);
setError(null);
return result.data;
}
setError(result.error.message);
return null;
}, []);
useEffect(() => {
void Promise.resolve().then(load);
return window.agentCompany.subscribe((event) => {
if (event.type === "snapshot-changed") void load();
});
}, [load]);
return { snapshot, error, actions: { run, reload: load } };
}
@@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { App } from "./app/App";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
@@ -0,0 +1,95 @@
import { Avatar, Button, Icon, Pill } from "../shared/ui";
import type { ApprovalRecord } from "../../../shared/domain";
import type { PageProps } from "./types";
import { activeOrgId } from "./types";
export function ApprovalsPage({ snapshot, actions }: PageProps) {
const orgId = activeOrgId(snapshot);
const employee = snapshot.employees.find((item) => item.orgId === orgId);
const approvals = snapshot.approvals.filter((item) => item.orgId === orgId);
const pending = approvals.filter((item) => item.state === "pending");
const decided = approvals.filter((item) => item.state !== "pending");
const decide = (approvalId: string, state: "approved" | "declined") => {
if (!employee) return;
void actions.run(() => window.agentCompany.decideApproval({
orgId,
approvalId,
state,
decidedByEmployeeId: employee.id,
}));
};
return (
<div className="page">
<h1 className="page-h1">Approvals</h1>
<p className="page-sub">{pending.length} pending · {decided.length} decided</p>
<div className="grid-4" style={{ marginBottom: 24 }}>
<StatTile label="Pending" value={String(pending.length)} tone="amber" />
<StatTile label="Approved" value={String(decided.filter((item) => item.state === "approved").length)} />
<StatTile label="Declined" value={String(decided.filter((item) => item.state === "declined").length)} />
<StatTile label="High risk" value={String(pending.filter((item) => item.risk === "high").length)} />
</div>
<div className="section-label">Pending</div>
{pending.length === 0 ? (
<div className="card" style={{ color: "var(--text-3)" }}>No pending approvals</div>
) : pending.map((approval) => (
<ApprovalRow key={approval.id} approval={approval} onApprove={() => decide(approval.id, "approved")} onDecline={() => decide(approval.id, "declined")} />
))}
<div className="section-label" style={{ marginTop: 32 }}>Decided</div>
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
{decided.length === 0 ? (
<div style={{ padding: 18, color: "var(--text-3)" }}>No decisions</div>
) : decided.map((approval) => (
<div key={approval.id} className="table-row" style={{ gridTemplateColumns: "24px 1fr 120px" }}>
<Icon name="check" />
<span>{approval.title}</span>
<Pill tone={approval.state === "approved" ? "green" : "amber"}>{approval.state}</Pill>
</div>
))}
</div>
</div>
);
}
function StatTile({ label, value, tone }: { label: string; value: string; tone?: "amber" }) {
return (
<div className="card">
<div className="card-label" style={{ marginBottom: 6 }}>{label}</div>
<div className="num" style={{ fontSize: 22, fontWeight: 600, color: tone === "amber" ? "var(--amber)" : "var(--text-1)" }}>{value}</div>
</div>
);
}
function ApprovalRow({
approval,
onApprove,
onDecline,
}: {
approval: ApprovalRecord;
onApprove: () => void;
onDecline: () => void;
}) {
const riskColor = approval.risk === "high" ? "var(--red)" : approval.risk === "medium" ? "var(--amber)" : "var(--text-3)";
return (
<div className="card" style={{ marginBottom: 10, display: "grid", gridTemplateColumns: "6px 1fr auto", gap: 16 }}>
<div style={{ width: 3, alignSelf: "stretch", background: riskColor, borderRadius: 2 }} />
<div>
<div className="row" style={{ marginBottom: 4 }}>
<span style={{ color: riskColor, fontSize: 11, fontWeight: 540 }}>{approval.risk}</span>
<Pill>{approval.kind}</Pill>
</div>
<div style={{ fontSize: 15, fontWeight: 540, marginBottom: 4 }}>{approval.title}</div>
<div style={{ color: "var(--text-2)", lineHeight: 1.5, marginBottom: 12 }}>{approval.details}</div>
<div className="row">
<Avatar name={approval.requestedByType} size="sm" bot={approval.requestedByType !== "employee"} />
<span style={{ color: "var(--text-3)", fontSize: 12 }}>{new Date(approval.createdAt).toLocaleString()}</span>
</div>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 6, alignItems: "flex-end" }}>
<Button tone="primary" onClick={onApprove}>Approve</Button>
<Button tone="ghost" onClick={onDecline}>Decline</Button>
</div>
</div>
);
}
@@ -0,0 +1,158 @@
import { useState, type FormEvent, type ReactNode } from "react";
import { Avatar, Button, Icon, IconButton } from "../shared/ui";
import type { ChatSpaceRecord } from "../../../shared/domain";
import type { PageProps } from "./types";
import { activeOrgId } from "./types";
function messageBody(form: FormData): string {
return String(form.get("message") ?? "").trim();
}
export function ChatPage({ snapshot, actions }: PageProps) {
const orgId = activeOrgId(snapshot);
const spaces = snapshot.chatSpaces.filter((item) => item.orgId === orgId);
const employees = snapshot.employees.filter((item) => item.orgId === orgId);
const agents = snapshot.agents.filter((item) => item.orgId === orgId);
const [spaceId, setSpaceId] = useState(spaces[0]?.id ?? "");
const activeSpace = spaces.find((space) => space.id === spaceId) ?? spaces[0];
const messages = snapshot.chatMessages.filter((item) => item.orgId === orgId && item.spaceId === activeSpace?.id);
const sender = employees[0];
const submit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!activeSpace || !sender) return;
const form = new FormData(event.currentTarget);
const bodyMd = messageBody(form);
if (!bodyMd) return;
event.currentTarget.reset();
void actions.run(() => window.agentCompany.sendMessage({
orgId,
spaceId: activeSpace.id,
threadId: null,
senderType: "employee",
senderId: sender.id,
bodyMd,
}));
};
return (
<div className="split-main" style={{ height: "calc(100vh - 112px)", margin: "-28px -32px -40px" }}>
<aside className="secondary-pane">
<div className="row" style={{ padding: "4px 8px 12px" }}>
<div style={{ fontWeight: 600 }}>Messages</div>
<IconButton icon="plus" label="New space" />
</div>
<NavGroup label="Channels">
{spaces.map((space) => (
<SpaceItem
key={space.id}
space={space}
active={activeSpace?.id === space.id}
count={snapshot.chatMessages.filter((message) => message.spaceId === space.id).length}
onClick={() => setSpaceId(space.id)}
/>
))}
</NavGroup>
<NavGroup label="Agents">
{agents.map((agent) => (
<div key={agent.id} className="nav-item">
<Avatar name={agent.name} size="sm" bot />
<span>{agent.name}</span>
</div>
))}
</NavGroup>
</aside>
<section style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column", background: "var(--panel)" }}>
<header className="row" style={{ height: 52, padding: "0 24px", borderBottom: "1px solid var(--border-soft)" }}>
<Icon name={activeSpace?.visibility === "restricted" ? "lock" : "hash"} />
<div style={{ fontSize: 14, fontWeight: 600 }}>{activeSpace?.name ?? "Messages"}</div>
<div style={{ color: "var(--text-3)", borderLeft: "1px solid var(--border)", paddingLeft: 10 }}>
{activeSpace?.topic ?? ""}
</div>
<div style={{ marginLeft: "auto" }}><Icon name="search" /></div>
</header>
<div style={{ flex: 1, overflow: "auto", padding: "20px 24px 8px" }}>
{messages.length === 0 ? (
<div style={{ color: "var(--text-3)" }}>No messages</div>
) : messages.map((message) => (
<MessageRow
key={message.id}
body={message.bodyMd}
time={new Date(message.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
name={displayName(message.senderType, message.senderId, employees, agents)}
bot={message.senderType === "agent"}
/>
))}
</div>
<form onSubmit={submit} style={{ padding: 14, paddingTop: 4 }}>
<div className="card" style={{ padding: "10px 12px" }}>
<textarea className="input" name="message" rows={2} placeholder={activeSpace ? `Message #${activeSpace.name}` : "Message"} />
<div className="row" style={{ paddingTop: 8, borderTop: "1px solid var(--border-soft)" }}>
<Icon name="attach" />
<Icon name="social" />
<div style={{ marginLeft: "auto" }}>
<Button tone="primary" size="small" type="submit"><Icon name="send" /> Send</Button>
</div>
</div>
</div>
</form>
</section>
</div>
);
}
function NavGroup({ label, children }: { label: string; children: ReactNode }) {
return (
<div style={{ marginTop: 10 }}>
<div className="section-label" style={{ padding: "0 8px", marginBottom: 6 }}>{label}</div>
{children}
</div>
);
}
function SpaceItem({
space,
active,
count,
onClick,
}: {
space: ChatSpaceRecord;
active: boolean;
count: number;
onClick: () => void;
}) {
return (
<button type="button" className={`nav-item ${active ? "active" : ""}`} onClick={onClick}>
<Icon name={space.visibility === "restricted" ? "lock" : "hash"} />
<span>{space.name}</span>
{count > 0 && <span className="nav-badge">{count}</span>}
</button>
);
}
function MessageRow({ name, body, time, bot }: { name: string; body: string; time: string; bot: boolean }) {
return (
<div style={{ display: "flex", gap: 12, marginBottom: 18 }}>
<Avatar name={name} bot={bot} />
<div style={{ minWidth: 0, flex: 1 }}>
<div className="row" style={{ alignItems: "baseline", marginBottom: 2 }}>
<span style={{ fontWeight: 540 }}>{name}</span>
<span className="num" style={{ color: "var(--text-3)", fontSize: 11 }}>{time}</span>
</div>
<div style={{ lineHeight: 1.5, whiteSpace: "pre-wrap" }}>{body}</div>
</div>
</div>
);
}
function displayName(
senderType: string,
senderId: string | null,
employees: Array<{ id: string; displayName: string }>,
agents: Array<{ id: string; name: string }>,
): string {
if (senderType === "employee") return employees.find((item) => item.id === senderId)?.displayName ?? "Employee";
if (senderType === "agent") return agents.find((item) => item.id === senderId)?.name ?? "Agent";
if (senderType === "board") return "Board";
return "System";
}
@@ -0,0 +1,112 @@
import { Button, Icon, IconButton } from "../shared/ui";
import type { FileObjectRecord } from "../../../shared/domain";
import type { PageProps } from "./types";
import { activeOrgId } from "./types";
export function FilesPage({ snapshot, actions }: PageProps) {
const orgId = activeOrgId(snapshot);
const files = snapshot.files.filter((item) => item.orgId === orgId);
return (
<div style={{ display: "grid", gridTemplateColumns: "200px minmax(0, 1fr)", gap: 24 }}>
<aside>
<div className="section-label">Library</div>
<TreeItem icon="folder" label="All files" count={files.length} active />
<TreeItem icon="social" label="Recent" />
<TreeItem icon="check" label="Pinned" />
</aside>
<div className="page">
<div className="row" style={{ marginBottom: 24 }}>
<div>
<h1 className="page-h1">Files</h1>
<p className="page-sub" style={{ marginBottom: 0 }}>{files.length} files · {formatBytes(files.reduce((sum, file) => sum + file.sizeBytes, 0))}</p>
</div>
<div style={{ marginLeft: "auto" }}>
<Button tone="primary" onClick={() => void actions.run(() => window.agentCompany.importFile({ orgId }))}>
<Icon name="upload" /> Upload
</Button>
</div>
</div>
<div className="section-label">All files</div>
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
<div className="table-row" style={{ gridTemplateColumns: "3fr 1fr 1.4fr 1fr 40px", background: "var(--panel-2)", color: "var(--text-3)", fontSize: 11 }}>
<div>Name</div><div>Size</div><div>Checksum</div><div>Added</div><div />
</div>
{files.length === 0 ? (
<div style={{ padding: 18, color: "var(--text-3)" }}>No files</div>
) : files.map((file) => <FileRow key={file.id} file={file} />)}
</div>
</div>
</div>
);
}
function TreeItem({
icon,
label,
count,
active = false,
}: {
icon: string;
label: string;
count?: number;
active?: boolean;
}) {
return (
<div className={`nav-item ${active ? "active" : ""}`}>
<Icon name={icon} />
<span>{label}</span>
{typeof count === "number" && <span className="nav-badge">{count}</span>}
</div>
);
}
function FileRow({ file }: { file: FileObjectRecord }) {
return (
<div className="table-row" style={{ gridTemplateColumns: "3fr 1fr 1.4fr 1fr 40px" }}>
<div className="row">
<FileIcon kind={file.kind} />
<span style={{ fontWeight: 480 }}>{file.name}</span>
</div>
<div className="num" style={{ color: "var(--text-3)", fontSize: 12 }}>{formatBytes(file.sizeBytes)}</div>
<div className="num" style={{ color: "var(--text-3)", fontSize: 12 }}>{file.sha256.slice(0, 12)}</div>
<div style={{ color: "var(--text-3)", fontSize: 12 }}>{new Date(file.createdAt).toLocaleDateString()}</div>
<IconButton icon="more" label="More" />
</div>
);
}
function FileIcon({ kind }: { kind: string }) {
const colors: Record<string, string> = {
md: "#52525b",
pdf: "#d23b3b",
img: "#c87d12",
sheet: "#2f9e58",
doc: "#5e6ad2",
};
if (kind === "folder") return <Icon name="folder" />;
return (
<span
style={{
width: 22,
height: 22,
display: "grid",
placeItems: "center",
borderRadius: 4,
background: `${colors[kind] ?? "#a1a1aa"}14`,
color: colors[kind] ?? "#52525b",
fontSize: 8,
fontWeight: 700,
}}
>
{kind.slice(0, 3).toUpperCase()}
</span>
);
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`;
}
@@ -0,0 +1,75 @@
import { Icon, Pill } from "../shared/ui";
import type { PageProps } from "./types";
import { activeOrgId, money } from "./types";
export function HomePage({ snapshot }: PageProps) {
const orgId = activeOrgId(snapshot);
const organization = snapshot.organizations.find((item) => item.id === orgId)!;
const tasks = snapshot.tasks.filter((item) => item.orgId === orgId);
const approvals = snapshot.approvals.filter((item) => item.orgId === orgId);
const agents = snapshot.agents.filter((item) => item.orgId === orgId);
const providers = snapshot.providers.filter((item) => item.orgId === orgId && item.enabled);
const budget = agents.reduce((sum, agent) => sum + agent.budgetCents, 0);
const pending = approvals.filter((item) => item.state === "pending");
return (
<div className="page">
<h1 className="page-h1">{organization.name}</h1>
<p className="page-sub">{organization.goal}</p>
<div className="grid-3" style={{ marginBottom: 12 }}>
<MetricCard label="Agents" value={String(agents.length)} />
<MetricCard label="Open work" value={String(tasks.filter((item) => item.status !== "done").length)} />
<MetricCard label="Monthly budget" value={money(budget)} />
</div>
<div className="grid-3">
<div className="card" style={{ gridColumn: "span 2" }}>
<div className="card-label" style={{ marginBottom: 14 }}>Work</div>
{tasks.length === 0 ? <EmptyLine label="No tasks" /> : tasks.slice(0, 6).map((task) => (
<div key={task.id} className="row" style={{ padding: "10px 0", borderBottom: "1px solid var(--border-soft)" }}>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: "var(--text-4)" }} />
<span style={{ flex: 1, fontWeight: 480 }}>{task.title}</span>
<Pill>{task.status.replaceAll("_", " ")}</Pill>
</div>
))}
</div>
<div className="card">
<div className="card-label" style={{ marginBottom: 14 }}>Decisions</div>
{pending.length === 0 ? <EmptyLine label="No pending approvals" /> : pending.slice(0, 4).map((approval) => (
<div key={approval.id} style={{ padding: "10px 0", borderBottom: "1px solid var(--border-soft)" }}>
<div style={{ fontWeight: 540, marginBottom: 4 }}>{approval.title}</div>
<Pill tone={approval.risk === "high" ? "amber" : undefined}>{approval.risk}</Pill>
</div>
))}
</div>
</div>
<div className="grid-3" style={{ marginTop: 12 }}>
<div className="card">
<div className="card-label" style={{ marginBottom: 14 }}>Providers</div>
{providers.length === 0 ? <EmptyLine label="No connected providers" /> : providers.map((provider) => (
<div key={provider.id} className="row" style={{ padding: "8px 0" }}>
<Icon name="check" />
<span>{provider.label}</span>
</div>
))}
</div>
<div className="card" style={{ gridColumn: "span 2" }}>
<div className="card-label" style={{ marginBottom: 14 }}>Mission</div>
<div style={{ fontSize: 15, lineHeight: 1.5 }}>{organization.mission}</div>
</div>
</div>
</div>
);
}
function MetricCard({ label, value }: { label: string; value: string }) {
return (
<div className="card">
<div className="card-label" style={{ marginBottom: 8 }}>{label}</div>
<div className="num" style={{ fontSize: 28, fontWeight: 600 }}>{value}</div>
</div>
);
}
function EmptyLine({ label }: { label: string }) {
return <div style={{ color: "var(--text-3)", padding: "10px 0" }}>{label}</div>;
}
@@ -0,0 +1,215 @@
import { useState, type FormEvent } from "react";
import { Button, Field, Icon, Pill } from "../shared/ui";
import { Dialog } from "../shared/Dialog";
import type { ProviderRecord, SkillDefinitionRecord } from "../../../shared/domain";
import type { PageProps } from "./types";
import { activeOrgId } from "./types";
type SettingsTab = "providers" | "skills" | "policies" | "migration";
function value(form: FormData, key: string): string {
return String(form.get(key) ?? "").trim();
}
export function SettingsPage({ snapshot, actions }: PageProps) {
const orgId = activeOrgId(snapshot);
const [tab, setTab] = useState<SettingsTab>("providers");
const [providerDialog, setProviderDialog] = useState<ProviderRecord | null>(null);
const [skillOpen, setSkillOpen] = useState(false);
const providers = snapshot.providers.filter((item) => item.orgId === orgId);
const skills = snapshot.skillDefinitions.filter((item) => item.orgId === orgId);
const agents = snapshot.agents.filter((item) => item.orgId === orgId);
const revisions = snapshot.skillRevisions;
const saveProvider = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!providerDialog) return;
const form = new FormData(event.currentTarget);
void actions.run(() => window.agentCompany.saveProviderCredential({
orgId,
providerId: providerDialog.id,
secret: value(form, "secret"),
model: value(form, "model") || null,
})).then(() => setProviderDialog(null));
};
const importSkill = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
void actions.run(() => window.agentCompany.importSkillDirectory({
orgId,
directoryPath: value(form, "directoryPath"),
versionSemver: value(form, "versionSemver") || "1.0.0",
})).then(() => setSkillOpen(false));
};
return (
<div className="split-main" style={{ height: "calc(100vh - 112px)", margin: "-28px -32px -40px" }}>
<aside className="secondary-pane">
<div style={{ fontWeight: 600, padding: "4px 8px 14px" }}>Settings</div>
<SettingsNav label="Providers" active={tab === "providers"} onClick={() => setTab("providers")} />
<SettingsNav label="Skills" active={tab === "skills"} onClick={() => setTab("skills")} />
<SettingsNav label="Policies" active={tab === "policies"} onClick={() => setTab("policies")} />
<SettingsNav label="Migration" active={tab === "migration"} onClick={() => setTab("migration")} />
</aside>
<div className="content" style={{ flex: 1 }}>
{tab === "providers" && (
<ProvidersPanel providers={providers} onManage={setProviderDialog} />
)}
{tab === "skills" && (
<SkillsPanel
skills={skills}
agents={agents}
revisions={revisions}
onImport={() => setSkillOpen(true)}
onBind={(agentId, skill, revisionId) => void actions.run(() => window.agentCompany.bindSkill({
orgId,
agentId,
skillId: skill.id,
revisionId,
}))}
/>
)}
{tab === "policies" && <PolicyPanel />}
{tab === "migration" && <MigrationPanel />}
</div>
<Dialog title="Provider credential" open={Boolean(providerDialog)} submitLabel="Save" onClose={() => setProviderDialog(null)} onSubmit={saveProvider}>
<Field label="Provider"><input className="input" value={providerDialog?.label ?? ""} readOnly /></Field>
<Field label="Secret"><input className="input" name="secret" type="password" required /></Field>
<Field label="Model"><input className="input" name="model" defaultValue={providerDialog?.model ?? ""} /></Field>
</Dialog>
<Dialog title="Import skill" open={skillOpen} submitLabel="Import" onClose={() => setSkillOpen(false)} onSubmit={importSkill}>
<Field label="Directory path"><input className="input" name="directoryPath" required /></Field>
<Field label="Version"><input className="input" name="versionSemver" defaultValue="1.0.0" required /></Field>
</Dialog>
</div>
);
}
function SettingsNav({ label, active, onClick }: { label: string; active: boolean; onClick: () => void }) {
return <button type="button" className={`nav-item ${active ? "active" : ""}`} onClick={onClick}>{label}</button>;
}
function ProvidersPanel({ providers, onManage }: { providers: ProviderRecord[]; onManage: (provider: ProviderRecord) => void }) {
return (
<div className="page">
<h1 className="page-h1">Providers</h1>
<p className="page-sub">Keys, account grants, OAuth profiles.</p>
<div className="section-label">Configured providers</div>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{providers.map((provider) => (
<div key={provider.id} className="card row">
<ProviderLogo label={provider.label} />
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 540 }}>{provider.label}</div>
<div style={{ fontSize: 12, color: "var(--text-3)" }}>{provider.authType.replaceAll("_", " ")}</div>
</div>
<Pill tone={provider.enabled ? "green" : undefined}>{provider.enabled ? "Enabled" : "Unconfigured"}</Pill>
<Button onClick={() => onManage(provider)}>Manage</Button>
</div>
))}
</div>
</div>
);
}
function SkillsPanel({
skills,
agents,
revisions,
onImport,
onBind,
}: {
skills: SkillDefinitionRecord[];
agents: Array<{ id: string; name: string }>;
revisions: Array<{ id: string; skillId: string; versionSemver: string }>;
onImport: () => void;
onBind: (agentId: string, skill: SkillDefinitionRecord, revisionId: string) => void;
}) {
return (
<div className="page">
<div className="row" style={{ marginBottom: 24 }}>
<div>
<h1 className="page-h1">Skills</h1>
<p className="page-sub" style={{ marginBottom: 0 }}>{skills.length} skills · {agents.length} agents</p>
</div>
<div style={{ marginLeft: "auto" }}>
<Button tone="primary" onClick={onImport}><Icon name="plus" /> Import</Button>
</div>
</div>
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
{skills.length === 0 ? (
<div style={{ padding: 18, color: "var(--text-3)" }}>No skills</div>
) : skills.map((skill) => {
const revision = revisions.find((item) => item.skillId === skill.id);
return (
<div key={skill.id} className="table-row" style={{ gridTemplateColumns: "1.6fr 1fr 1.6fr" }}>
<div>
<div style={{ fontWeight: 540 }}>{skill.name}</div>
<div style={{ color: "var(--text-3)", fontSize: 11 }}>{skill.slug}</div>
</div>
<Pill>{revision?.versionSemver ?? "No revision"}</Pill>
<select
className="input"
defaultValue=""
onChange={(event) => {
if (event.target.value && revision) onBind(event.target.value, skill, revision.id);
event.target.value = "";
}}
>
<option value="">Bind to agent</option>
{agents.map((agent) => <option key={agent.id} value={agent.id}>{agent.name}</option>)}
</select>
</div>
);
})}
</div>
</div>
);
}
function PolicyPanel() {
return (
<div className="page">
<h1 className="page-h1">Policies</h1>
<p className="page-sub">Approval, risk, provider, browser boundaries.</p>
<div className="card" style={{ display: "grid", gap: 10 }}>
<PolicyRow label="High-risk task" value="Board approval" />
<PolicyRow label="Social publish" value="Human approval" />
<PolicyRow label="Browser permissions" value="Deny by default" />
<PolicyRow label="Provider secrets" value="OS encrypted" />
</div>
</div>
);
}
function MigrationPanel() {
return (
<div className="page">
<h1 className="page-h1">Migration</h1>
<p className="page-sub">Legacy import, id maps, checksum validation.</p>
<div className="card" style={{ display: "grid", gap: 10 }}>
<PolicyRow label="Legacy snapshot" value="Immutable" />
<PolicyRow label="Entity checksums" value="Required" />
<PolicyRow label="Delta journal" value="Recorded" />
</div>
</div>
);
}
function PolicyRow({ label, value }: { label: string; value: string }) {
return (
<div className="row" style={{ justifyContent: "space-between" }}>
<span>{label}</span>
<Pill>{value}</Pill>
</div>
);
}
function ProviderLogo({ label }: { label: string }) {
return (
<div style={{ width: 30, height: 30, borderRadius: 7, display: "grid", placeItems: "center", background: "var(--text-1)", color: "#fff", fontWeight: 700 }}>
{label.slice(0, 2)}
</div>
);
}
@@ -0,0 +1,138 @@
import { useState, type FormEvent } from "react";
import { Avatar, Button, Field, Icon, Pill } from "../shared/ui";
import { Dialog } from "../shared/Dialog";
import { SOCIAL_PLATFORM, type SocialAccountRecord, type SocialPlatform } from "../../../shared/domain";
import type { PageProps } from "./types";
import { activeOrgId } from "./types";
function value(form: FormData, key: string): string {
return String(form.get(key) ?? "").trim();
}
export function SocialPage({ snapshot, actions }: PageProps) {
const orgId = activeOrgId(snapshot);
const [open, setOpen] = useState(false);
const accounts = snapshot.socialAccounts.filter((item) => item.orgId === orgId);
const employees = snapshot.employees.filter((item) => item.orgId === orgId);
const submit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
void actions.run(() => window.agentCompany.createSocialAccount({
orgId,
platform: value(form, "platform") as SocialPlatform,
handle: value(form, "handle"),
displayName: value(form, "displayName"),
authorizedEmployeeId: value(form, "authorizedEmployeeId") || null,
})).then(() => setOpen(false));
};
return (
<div className="page">
<div className="row" style={{ marginBottom: 24 }}>
<div>
<h1 className="page-h1">Social</h1>
<p className="page-sub" style={{ marginBottom: 0 }}>{accounts.length} accounts</p>
</div>
<div style={{ marginLeft: "auto" }}>
<Button tone="primary" onClick={() => setOpen(true)}><Icon name="plus" /> Connect account</Button>
</div>
</div>
<div className="section-label">Accounts</div>
<div className="grid-4" style={{ marginBottom: 28 }}>
{accounts.length === 0 ? (
<div className="card" style={{ color: "var(--text-3)" }}>No social accounts</div>
) : accounts.map((account) => (
<AccountCard
key={account.id}
account={account}
employeeName={employees.find((employee) => employee.id === account.authorizedEmployeeId)?.displayName ?? null}
onLogin={() => void actions.run(() => window.agentCompany.launchSocialLogin({ socialAccountId: account.id }))}
/>
))}
</div>
<div className="section-label">Governance</div>
<div className="card" style={{ display: "grid", gap: 10 }}>
<PolicyRow label="Session isolation" value="Per account partition" />
<PolicyRow label="Permissions" value="Camera, microphone, downloads denied" />
<PolicyRow label="Navigation" value="Platform host allowlist" />
</div>
<Dialog title="Connect social account" open={open} submitLabel="Connect" onClose={() => setOpen(false)} onSubmit={submit}>
<Field label="Platform">
<select className="input" name="platform" defaultValue={SOCIAL_PLATFORM.X}>
<option value={SOCIAL_PLATFORM.X}>X</option>
<option value={SOCIAL_PLATFORM.LINKEDIN}>LinkedIn</option>
<option value={SOCIAL_PLATFORM.YOUTUBE}>YouTube</option>
<option value={SOCIAL_PLATFORM.INSTAGRAM}>Instagram</option>
</select>
</Field>
<Field label="Handle"><input className="input" name="handle" required /></Field>
<Field label="Display name"><input className="input" name="displayName" required /></Field>
<Field label="Authorized employee">
<select className="input" name="authorizedEmployeeId">
<option value="">Unassigned</option>
{employees.map((employee) => <option key={employee.id} value={employee.id}>{employee.displayName}</option>)}
</select>
</Field>
</Dialog>
</div>
);
}
function AccountCard({
account,
employeeName,
onLogin,
}: {
account: SocialAccountRecord;
employeeName: string | null;
onLogin: () => void;
}) {
return (
<div className="card" style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<div className="row">
<PlatformIcon platform={account.platform} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 540 }}>{account.handle}</div>
<div style={{ color: "var(--text-3)", fontSize: 11 }}>{account.displayName}</div>
</div>
</div>
{employeeName && (
<div className="row" style={{ color: "var(--text-3)", fontSize: 11 }}>
<Avatar name={employeeName} size="sm" />
<span>{employeeName}</span>
</div>
)}
<Button size="small" onClick={onLogin}>Sign in</Button>
</div>
);
}
function PolicyRow({ label, value }: { label: string; value: string }) {
return (
<div className="row" style={{ justifyContent: "space-between" }}>
<span>{label}</span>
<Pill>{value}</Pill>
</div>
);
}
function PlatformIcon({ platform }: { platform: SocialPlatform }) {
const labels: Record<SocialPlatform, string> = {
x: "X",
linkedin: "in",
youtube: "▶",
instagram: "◎",
};
const colors: Record<SocialPlatform, string> = {
x: "#0a0a0b",
linkedin: "#0a66c2",
youtube: "#ff0000",
instagram: "#e1306c",
};
return (
<div style={{ width: 30, height: 30, borderRadius: 7, background: colors[platform], color: "#fff", display: "grid", placeItems: "center", fontWeight: 700 }}>
{labels[platform]}
</div>
);
}
@@ -0,0 +1,137 @@
import { useState, type FormEvent } from "react";
import { Avatar, Button, Field, Icon, Pill } from "../shared/ui";
import { Dialog } from "../shared/Dialog";
import type { PageProps } from "./types";
import { activeOrgId, money } from "./types";
function text(form: FormData, key: string): string {
return String(form.get(key) ?? "").trim();
}
export function TeamPage({ snapshot, actions }: PageProps) {
const orgId = activeOrgId(snapshot);
const [open, setOpen] = useState(false);
const employees = snapshot.employees.filter((item) => item.orgId === orgId);
const agents = snapshot.agents.filter((item) => item.orgId === orgId);
const providers = snapshot.providers.filter((item) => item.orgId === orgId && item.enabled);
const submit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
void actions.run(() => window.agentCompany.createAgent({
orgId,
name: text(form, "name"),
role: text(form, "role"),
department: text(form, "department"),
modelProviderId: text(form, "providerId") || null,
modelId: text(form, "modelId") || null,
budgetCents: Math.max(0, Math.round(Number(text(form, "budget")) * 100)),
managerAgentId: text(form, "managerAgentId") || null,
})).then(() => setOpen(false));
};
return (
<div className="page">
<div className="row" style={{ marginBottom: 24 }}>
<div>
<h1 className="page-h1">Team</h1>
<p className="page-sub" style={{ marginBottom: 0 }}>{employees.length} human · {agents.length} agents</p>
</div>
<div style={{ marginLeft: "auto" }}>
<Button tone="primary" onClick={() => setOpen(true)}><Icon name="plus" /> Hire</Button>
</div>
</div>
<div className="card" style={{ padding: 32, marginBottom: 28 }}>
<div className="section-label">Org</div>
<div style={{ display: "flex", justifyContent: "center", marginBottom: 20 }}>
{employees[0] && <OrgCard name={employees[0].displayName} role={employees[0].role} human />}
</div>
<div className="grid-4">
{agents.map((agent) => (
<OrgCard key={agent.id} name={agent.name} role={agent.role} />
))}
</div>
</div>
<div className="section-label">Roster</div>
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
<div className="table-row" style={{ gridTemplateColumns: "2fr 1.2fr 1.4fr 1fr", background: "var(--panel-2)", fontSize: 11, color: "var(--text-3)" }}>
<div>Member</div><div>Department</div><div>Model</div><div>Budget</div>
</div>
{employees.map((employee) => (
<RosterRow key={employee.id} name={employee.displayName} role={employee.role} dept="Board" model="" budget={null} human />
))}
{agents.map((agent) => (
<RosterRow
key={agent.id}
name={agent.name}
role={agent.role}
dept={agent.department}
model={agent.modelId ?? ""}
budget={agent.budgetCents}
/>
))}
</div>
<Dialog title="Hire agent" open={open} submitLabel="Hire" onClose={() => setOpen(false)} onSubmit={submit}>
<Field label="Name"><input className="input" name="name" required /></Field>
<Field label="Role"><input className="input" name="role" required /></Field>
<Field label="Department"><input className="input" name="department" required /></Field>
<Field label="Provider">
<select className="input" name="providerId">
<option value="">Unassigned</option>
{providers.map((provider) => <option key={provider.id} value={provider.id}>{provider.label}</option>)}
</select>
</Field>
<Field label="Model"><input className="input" name="modelId" /></Field>
<Field label="Monthly budget"><input className="input" name="budget" type="number" min="0" step="1" defaultValue="0" /></Field>
</Dialog>
</div>
);
}
function OrgCard({ name, role, human = false }: { name: string; role: string; human?: boolean }) {
return (
<div className="card" style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<div className="row">
<Avatar name={name} bot={!human} />
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 540 }}>{name}</div>
<div style={{ fontSize: 11, color: "var(--text-3)" }}>{role}</div>
</div>
</div>
<Pill tone={human ? undefined : "violet"} dot={!human}>{human ? "Human" : "Agent"}</Pill>
</div>
);
}
function RosterRow({
name,
role,
dept,
model,
budget,
human = false,
}: {
name: string;
role: string;
dept: string;
model: string;
budget: number | null;
human?: boolean;
}) {
return (
<div className="table-row" style={{ gridTemplateColumns: "2fr 1.2fr 1.4fr 1fr" }}>
<div className="row">
<Avatar name={name} size="sm" bot={!human} />
<div>
<div style={{ fontWeight: 500 }}>{name}</div>
<div style={{ fontSize: 11, color: "var(--text-3)" }}>{role}</div>
</div>
</div>
<div style={{ color: "var(--text-2)" }}>{dept}</div>
<div style={{ color: "var(--text-2)", fontFamily: model ? "var(--font-mono)" : "inherit", fontSize: model ? 12 : 13 }}>
{model || "—"}
</div>
<div>{budget === null ? <span style={{ color: "var(--text-4)" }}>—</span> : <span className="num">{money(budget)}</span>}</div>
</div>
);
}
@@ -0,0 +1,130 @@
import { useState, type FormEvent } from "react";
import { Avatar, Button, Field, Icon, IconButton, Pill } from "../shared/ui";
import { Dialog } from "../shared/Dialog";
import { RISK_LEVEL, TASK_STATUS, type TaskRecord, type TaskStatus } from "../../../shared/domain";
import type { PageProps } from "./types";
import { activeOrgId } from "./types";
const columns: Array<{ id: TaskStatus; label: string }> = [
{ id: TASK_STATUS.BACKLOG, label: "Backlog" },
{ id: TASK_STATUS.IN_PROGRESS, label: "In progress" },
{ id: TASK_STATUS.REVIEW, label: "In review" },
{ id: TASK_STATUS.DONE, label: "Done" },
];
function value(form: FormData, key: string): string {
return String(form.get(key) ?? "").trim();
}
export function WorkPage({ snapshot, actions }: PageProps) {
const orgId = activeOrgId(snapshot);
const [open, setOpen] = useState(false);
const tasks = snapshot.tasks.filter((item) => item.orgId === orgId);
const agents = snapshot.agents.filter((item) => item.orgId === orgId);
const submit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
void actions.run(() => window.agentCompany.createTask({
orgId,
title: value(form, "title"),
description: value(form, "description"),
status: TASK_STATUS.BACKLOG,
assigneeAgentId: value(form, "assigneeAgentId") || null,
risk: value(form, "risk") as "low" | "medium" | "high",
})).then(() => setOpen(false));
};
return (
<div className="page-wide">
<div className="row" style={{ marginBottom: 24 }}>
<div>
<h1 className="page-h1">Work</h1>
<p className="page-sub" style={{ marginBottom: 0 }}>{tasks.length} tasks · {agents.length} assignees</p>
</div>
<div style={{ marginLeft: "auto" }}>
<Button tone="primary" onClick={() => setOpen(true)}><Icon name="plus" /> New task</Button>
</div>
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, minmax(240px, 1fr))", gap: 12 }}>
{columns.map((column) => {
const columnTasks = tasks.filter((task) => task.status === column.id);
return (
<div key={column.id} style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<div className="row" style={{ padding: "4px 6px 8px" }}>
<span style={{ width: 8, height: 8, borderRadius: 2, background: columnColor(column.id) }} />
<span style={{ fontSize: 12.5, fontWeight: 540 }}>{column.label}</span>
<span className="num" style={{ color: "var(--text-3)" }}>{columnTasks.length}</span>
</div>
{columnTasks.map((task) => (
<TaskCard
key={task.id}
task={task}
assignee={agents.find((agent) => agent.id === task.assigneeAgentId)?.name ?? null}
onMove={(status) => void actions.run(() => window.agentCompany.updateTaskStatus({ orgId, taskId: task.id, status }))}
/>
))}
</div>
);
})}
</div>
<Dialog title="New task" open={open} submitLabel="Create" onClose={() => setOpen(false)} onSubmit={submit}>
<Field label="Title"><input className="input" name="title" required /></Field>
<Field label="Description"><textarea className="input" name="description" required /></Field>
<Field label="Assignee">
<select className="input" name="assigneeAgentId">
<option value="">Unassigned</option>
{agents.map((agent) => <option key={agent.id} value={agent.id}>{agent.name}</option>)}
</select>
</Field>
<Field label="Risk">
<select className="input" name="risk" defaultValue={RISK_LEVEL.LOW}>
<option value={RISK_LEVEL.LOW}>Low</option>
<option value={RISK_LEVEL.MEDIUM}>Medium</option>
<option value={RISK_LEVEL.HIGH}>High</option>
</select>
</Field>
</Dialog>
</div>
);
}
function TaskCard({
task,
assignee,
onMove,
}: {
task: TaskRecord;
assignee: string | null;
onMove: (status: TaskStatus) => void;
}) {
return (
<div className="card" style={{ display: "flex", flexDirection: "column", gap: 10, opacity: task.status === "done" ? 0.7 : 1 }}>
<div style={{ display: "flex", alignItems: "flex-start", gap: 6 }}>
{task.risk === "high" && <span style={{ width: 3, height: 14, background: "var(--red)", borderRadius: 2, marginTop: 2 }} />}
<div style={{ fontWeight: 480, lineHeight: 1.35 }}>{task.title}</div>
</div>
<div className="row">
<Pill tone={task.risk === "high" ? "amber" : undefined}>{task.risk}</Pill>
{assignee && <Avatar name={assignee} size="sm" bot />}
<div style={{ marginLeft: "auto" }}>
{nextStatus(task.status) && <IconButton icon="check" label="Move" onClick={() => onMove(nextStatus(task.status)!)} />}
</div>
</div>
</div>
);
}
function columnColor(status: TaskStatus): string {
if (status === TASK_STATUS.IN_PROGRESS) return "var(--accent)";
if (status === TASK_STATUS.REVIEW) return "var(--amber)";
if (status === TASK_STATUS.DONE) return "var(--green)";
return "var(--text-4)";
}
function nextStatus(status: TaskStatus): TaskStatus | null {
if (status === TASK_STATUS.BACKLOG) return TASK_STATUS.IN_PROGRESS;
if (status === TASK_STATUS.IN_PROGRESS) return TASK_STATUS.REVIEW;
if (status === TASK_STATUS.REVIEW) return TASK_STATUS.DONE;
return null;
}
@@ -0,0 +1,18 @@
import type { AppSnapshot } from "../../../shared/domain";
import type { SnapshotActions } from "../app/useSnapshot";
export interface PageProps {
snapshot: AppSnapshot;
actions: SnapshotActions;
}
export function activeOrgId(snapshot: AppSnapshot): string {
const id = snapshot.activeOrgId;
if (!id) throw new Error("Active organization is required.");
return id;
}
export function money(cents: number): string {
return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 })
.format(cents / 100);
}
@@ -0,0 +1,32 @@
import type { FormEvent, ReactNode } from "react";
import { Button } from "./ui";
export function Dialog({
title,
open,
children,
submitLabel,
onClose,
onSubmit,
}: {
title: string;
open: boolean;
children: ReactNode;
submitLabel: string;
onClose: () => void;
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
}) {
if (!open) return null;
return (
<div className="modal-backdrop" role="presentation" onMouseDown={onClose}>
<form className="modal" onMouseDown={(event) => event.stopPropagation()} onSubmit={onSubmit}>
<h2 className="modal-title">{title}</h2>
<div style={{ display: "grid", gap: 12 }}>{children}</div>
<div className="modal-actions">
<Button tone="ghost" onClick={onClose}>Cancel</Button>
<Button tone="primary" type="submit">{submitLabel}</Button>
</div>
</form>
</div>
);
}
@@ -0,0 +1,86 @@
import type { ReactNode } from "react";
import type { AppSnapshot } from "../../../shared/domain";
import { Avatar, Icon, IconButton } from "./ui";
export type RouteId = "home" | "team" | "work" | "chat" | "files" | "approvals" | "social" | "settings";
const navItems: Array<{ id: RouteId; label: string; icon: string }> = [
{ id: "home", label: "Home", icon: "home" },
{ id: "team", label: "Team", icon: "team" },
{ id: "work", label: "Work", icon: "work" },
{ id: "chat", label: "Messages", icon: "chat" },
{ id: "files", label: "Files", icon: "docs" },
{ id: "approvals", label: "Approvals", icon: "approve" },
{ id: "social", label: "Social", icon: "social" },
{ id: "settings", label: "Settings", icon: "settings" },
];
export function Shell({
snapshot,
route,
setRoute,
actions,
children,
}: {
snapshot: AppSnapshot;
route: RouteId;
setRoute: (route: RouteId) => void;
actions?: ReactNode;
children: ReactNode;
}) {
const organization = snapshot.organizations.find((item) => item.id === snapshot.activeOrgId);
const employee = snapshot.employees.find((item) => item.orgId === snapshot.activeOrgId);
const badges = {
work: snapshot.tasks.filter((item) => item.orgId === snapshot.activeOrgId && item.status !== "done").length,
chat: snapshot.chatMessages.filter((item) => item.orgId === snapshot.activeOrgId).length,
approvals: snapshot.approvals.filter((item) => item.orgId === snapshot.activeOrgId && item.state === "pending").length,
} as Partial<Record<RouteId, number>>;
return (
<div className="app-shell">
<aside className="sidebar">
<div className="brand">
<div className="brand-mark">A</div>
<div className="brand-name">{organization?.name ?? "AgentCompany"}</div>
<Icon name="chevronDown" size={12} />
</div>
<nav className="nav">
{navItems.map((item) => (
<button
key={item.id}
type="button"
className={`nav-item ${route === item.id ? "active" : ""}`}
onClick={() => setRoute(item.id)}
>
<Icon name={item.icon} size={15} />
<span>{item.label}</span>
{badges[item.id] ? <span className="nav-badge">{badges[item.id]}</span> : null}
</button>
))}
</nav>
<div className="sidebar-foot">
<Avatar name={employee?.displayName ?? "User"} size="sm" />
<div style={{ flex: 1, minWidth: 0 }}>{employee?.displayName ?? "User"}</div>
<Icon name="bell" />
</div>
</aside>
<main className="main">
<header className="topbar">
<span className="crumb-sub">{organization?.name ?? "Workspace"}</span>
<span style={{ color: "var(--text-4)" }}>/</span>
<span className="crumb">{navItems.find((item) => item.id === route)?.label}</span>
<div className="topbar-actions">
{actions}
{window.agentCompany.platform !== "darwin" && (
<>
<IconButton icon="more" label="Minimize" onClick={() => void window.agentCompany.windowMinimize()} />
<IconButton icon="x" label="Close" onClick={() => void window.agentCompany.windowClose()} />
</>
)}
</div>
</header>
<div className="content">{children}</div>
</main>
</div>
);
}
@@ -0,0 +1,159 @@
import type { ReactNode } from "react";
import {
Bell,
Bot,
Check,
ChevronDown,
FileText,
Filter,
Folder,
Grid2X2,
Hash,
Home,
Inbox,
List,
Lock,
MessageCircle,
MoreHorizontal,
Paperclip,
Plus,
Search,
Send,
Settings,
ShieldCheck,
Sparkles,
Upload,
Users,
X,
type LucideIcon,
} from "lucide-react";
const icons: Record<string, LucideIcon> = {
approve: ShieldCheck,
attach: Paperclip,
bell: Bell,
bot: Bot,
chat: MessageCircle,
check: Check,
chevronDown: ChevronDown,
docs: FileText,
file: FileText,
filter: Filter,
folder: Folder,
grid: Grid2X2,
hash: Hash,
home: Home,
inbox: Inbox,
list: List,
lock: Lock,
more: MoreHorizontal,
plus: Plus,
search: Search,
send: Send,
settings: Settings,
social: Sparkles,
team: Users,
upload: Upload,
work: Inbox,
x: X,
};
const palette = [
["#5e6ad2", "#fff"],
["#e8b339", "#231600"],
["#26b69a", "#fff"],
["#e57373", "#fff"],
["#8b6dd9", "#fff"],
["#3a7bd5", "#fff"],
["#2a2a2e", "#fff"],
["#c2410c", "#fff"],
];
function hashName(value: string): number {
let hash = 0;
for (const char of value) hash = (hash * 31 + char.charCodeAt(0)) | 0;
return Math.abs(hash);
}
export function Icon({ name, size = 14 }: { name: string; size?: number }) {
const Component = icons[name] ?? FileText;
return <Component size={size} strokeWidth={1.7} />;
}
export function Avatar({ name, size = "", bot = false }: { name: string; size?: "sm" | ""; bot?: boolean }) {
const initials = name.split(/\s+/).map((part) => part[0]).slice(0, 2).join("").toUpperCase();
const [background, color] = palette[hashName(name) % palette.length];
return (
<div className={`avatar ${size} ${bot ? "bot" : ""}`} style={{ background, color }}>
{bot ? <Icon name="bot" size={size === "sm" ? 13 : 16} /> : initials}
</div>
);
}
export function Pill({
children,
tone,
dot = false,
}: {
children: ReactNode;
tone?: "amber" | "green" | "violet";
dot?: boolean;
}) {
return (
<span className={`pill ${tone ?? ""}`}>
{dot && <span className="dot" />}
{children}
</span>
);
}
export function Button({
children,
tone,
size,
onClick,
type = "button",
}: {
children: ReactNode;
tone?: "primary" | "ghost";
size?: "small";
onClick?: () => void;
type?: "button" | "submit";
}) {
return (
<button type={type} onClick={onClick} className={`button ${tone ?? ""} ${size ?? ""}`}>
{children}
</button>
);
}
export function IconButton({
icon,
onClick,
label,
}: {
icon: string;
onClick?: () => void;
label: string;
}) {
return (
<button type="button" className="icon-button ghost" aria-label={label} title={label} onClick={onClick}>
<Icon name={icon} />
</button>
);
}
export function Field({
label,
children,
}: {
label: string;
children: ReactNode;
}) {
return (
<label className="field">
<span>{label}</span>
{children}
</label>
);
}
@@ -0,0 +1,261 @@
:root {
--bg: #fafafa;
--panel: #ffffff;
--panel-2: #f6f6f7;
--panel-3: #f1f1f2;
--hover: #f4f4f5;
--border: #ececef;
--border-strong: #e2e2e6;
--border-soft: #f1f1f3;
--text-1: #09090b;
--text-2: #52525b;
--text-3: #a1a1aa;
--text-4: #d4d4d8;
--accent: #5e6ad2;
--accent-soft: #eef0ff;
--green: #2f9e58;
--green-soft: #e8f5ec;
--amber: #c87d12;
--amber-soft: #fbf3e2;
--red: #d23b3b;
--r-sm: 6px;
--r-md: 8px;
--r-lg: 12px;
--shadow-sm: 0 1px 0 rgba(9, 9, 11, 0.04), 0 1px 2px rgba(9, 9, 11, 0.04);
--font-sans: Inter, -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif;
--font-mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, monospace;
}
* { box-sizing: border-box; }
html, body, #root { width: 100%; height: 100%; margin: 0; }
body {
font-family: var(--font-sans);
color: var(--text-1);
background: var(--bg);
font-size: 13px;
letter-spacing: 0;
-webkit-font-smoothing: antialiased;
}
button, input, textarea, select { font: inherit; }
button { cursor: pointer; }
.app-shell {
display: flex;
width: 100%;
height: 100%;
overflow: hidden;
background: var(--bg);
}
.sidebar {
width: 232px;
flex-shrink: 0;
display: flex;
flex-direction: column;
padding: 14px 10px;
border-right: 1px solid var(--border-soft);
}
.brand {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: var(--r-md);
margin-bottom: 18px;
}
.brand-mark {
width: 22px;
height: 22px;
border-radius: 6px;
display: grid;
place-items: center;
color: white;
background: linear-gradient(135deg, #1a1a1d 0%, #3a3a40 100%);
font-size: 11px;
font-weight: 600;
}
.brand-name { flex: 1; font-weight: 540; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.nav { display: flex; flex-direction: column; gap: 1px; }
.nav-item {
display: flex;
align-items: center;
gap: 9px;
padding: 6px 8px;
border: 0;
border-radius: var(--r-md);
background: transparent;
color: var(--text-2);
font-weight: 480;
text-align: left;
}
.nav-item:hover { background: var(--hover); color: var(--text-1); }
.nav-item.active { background: var(--panel-3); color: var(--text-1); font-weight: 540; }
.nav-badge { margin-left: auto; font-size: 11px; color: var(--text-3); font-variant-numeric: tabular-nums; }
.sidebar-foot {
margin-top: auto;
padding: 10px 8px 4px;
border-top: 1px solid var(--border-soft);
display: flex;
align-items: center;
gap: 9px;
}
.main {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
background: var(--bg);
}
.topbar {
height: 44px;
flex-shrink: 0;
display: flex;
align-items: center;
gap: 12px;
padding: 0 20px;
border-bottom: 1px solid var(--border-soft);
-webkit-app-region: drag;
}
.topbar button, .topbar input, .sidebar button { -webkit-app-region: no-drag; }
.crumb { font-size: 13px; font-weight: 540; }
.crumb-sub { color: var(--text-3); }
.topbar-actions { margin-left: auto; display: flex; align-items: center; gap: 6px; }
.content { flex: 1; overflow: auto; padding: 28px 32px 40px; }
.page { max-width: 1100px; }
.page-wide { min-width: 980px; }
.page-h1 { margin: 0 0 4px; font-size: 22px; font-weight: 620; letter-spacing: 0; }
.page-sub { margin: 0 0 24px; color: var(--text-3); }
.section-label { margin-bottom: 10px; color: var(--text-3); font-size: 11px; font-weight: 540; letter-spacing: 0.02em; }
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--r-lg);
padding: 16px;
}
.card-label { color: var(--text-3); font-size: 11px; font-weight: 540; letter-spacing: 0.02em; }
.button {
height: 28px;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0 10px;
border-radius: var(--r-md);
border: 1px solid var(--border-strong);
background: var(--panel);
color: var(--text-1);
font-size: 12.5px;
font-weight: 500;
}
.button:hover { background: var(--panel-2); }
.button.primary { background: var(--text-1); color: white; border-color: var(--text-1); }
.button.ghost { border-color: transparent; background: transparent; color: var(--text-2); }
.button.small { height: 24px; padding: 0 8px; font-size: 12px; }
.icon-button {
width: 28px;
height: 28px;
display: grid;
place-items: center;
border-radius: var(--r-md);
border: 1px solid var(--border);
background: var(--panel);
color: var(--text-2);
}
.icon-button.ghost { border-color: transparent; background: transparent; }
.pill {
display: inline-flex;
align-items: center;
gap: 5px;
height: 20px;
padding: 0 7px;
border-radius: 999px;
background: var(--panel-2);
color: var(--text-2);
border: 1px solid var(--border);
font-size: 11px;
font-weight: 500;
}
.pill.amber { background: var(--amber-soft); color: var(--amber); border-color: transparent; }
.pill.green { background: var(--green-soft); color: var(--green); border-color: transparent; }
.pill.violet { background: var(--accent-soft); color: var(--accent); border-color: transparent; }
.dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.avatar {
width: 28px;
height: 28px;
flex-shrink: 0;
display: grid;
place-items: center;
border-radius: 50%;
color: white;
font-size: 11px;
font-weight: 600;
}
.avatar.bot { border-radius: 8px; }
.avatar.sm { width: 22px; height: 22px; font-size: 10px; }
.grid-3 { display: grid; grid-template-columns: 1.4fr 1fr 1fr; gap: 12px; }
.grid-4 { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
.row { display: flex; align-items: center; gap: 10px; }
.num { font-variant-numeric: tabular-nums; font-family: var(--font-mono); }
.table-row {
display: grid;
gap: 12px;
align-items: center;
padding: 12px 18px;
border-bottom: 1px solid var(--border-soft);
}
.table-row:last-child { border-bottom: 0; }
.field {
display: flex;
flex-direction: column;
gap: 6px;
color: var(--text-2);
font-size: 12px;
}
.input {
width: 100%;
border: 1px solid var(--border-strong);
border-radius: var(--r-md);
background: var(--panel);
color: var(--text-1);
padding: 8px 10px;
outline: none;
}
textarea.input { min-height: 84px; resize: vertical; }
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 40;
background: rgba(9, 9, 11, 0.18);
display: grid;
place-items: center;
padding: 24px;
}
.modal {
width: min(520px, 100%);
max-height: min(760px, 100%);
overflow: auto;
border: 1px solid var(--border);
border-radius: 14px;
background: var(--panel);
box-shadow: 0 20px 60px rgba(9, 9, 11, 0.18);
padding: 18px;
}
.modal-title { margin: 0 0 16px; font-size: 16px; font-weight: 620; }
.modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
.split-main { display: flex; flex-direction: row; min-height: 0; flex: 1; }
.secondary-pane {
width: 240px;
flex-shrink: 0;
border-right: 1px solid var(--border-soft);
padding: 14px 10px;
background: var(--bg);
}
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 4px; }
@media (max-width: 980px) {
.sidebar { width: 64px; }
.brand-name, .nav-item span:not(.nav-badge), .sidebar-foot > div { display: none; }
.content { padding: 20px 18px 32px; }
.grid-3, .grid-4 { grid-template-columns: 1fr; }
.page-wide { min-width: 0; }
.secondary-pane { width: 180px; }
}
+106
View File
@@ -0,0 +1,106 @@
import type {
ApprovalState,
RiskLevel,
SocialPlatform,
TaskStatus,
} from "./domain";
export interface CreateOrganizationInput {
organizationName: string;
mission: string;
goal: string;
employeeName: string;
}
export interface CreateAgentInput {
orgId: string;
name: string;
role: string;
department: string;
modelProviderId: string | null;
modelId: string | null;
budgetCents: number;
managerAgentId: string | null;
}
export interface CreateTaskInput {
orgId: string;
title: string;
description: string;
status: TaskStatus;
assigneeAgentId: string | null;
risk: RiskLevel;
}
export interface UpdateTaskStatusInput {
orgId: string;
taskId: string;
status: TaskStatus;
}
export interface CreateApprovalInput {
orgId: string;
kind: string;
title: string;
details: string;
risk: RiskLevel;
requestedByType: "employee" | "agent" | "system";
requestedById: string | null;
}
export interface DecideApprovalInput {
orgId: string;
approvalId: string;
state: Extract<ApprovalState, "approved" | "declined">;
decidedByEmployeeId: string;
}
export interface SendMessageInput {
orgId: string;
spaceId: string;
threadId: string | null;
senderType: "employee" | "agent" | "board" | "system";
senderId: string | null;
bodyMd: string;
}
export interface SaveProviderCredentialInput {
orgId: string;
providerId: string;
secret: string;
model: string | null;
}
export interface ImportSkillDirectoryInput {
orgId: string;
directoryPath: string;
versionSemver: string;
}
export interface BindSkillInput {
orgId: string;
agentId: string;
skillId: string;
revisionId: string;
}
export interface CreateSocialAccountInput {
orgId: string;
platform: SocialPlatform;
handle: string;
displayName: string;
authorizedEmployeeId: string | null;
}
export interface LaunchSocialLoginInput {
socialAccountId: string;
}
export interface ImportFileInput {
orgId: string;
}
export interface NativeDialogResult {
canceled: boolean;
filePaths: string[];
}
@@ -0,0 +1,43 @@
import type { DesktopResult } from "./result";
import type { AppSnapshot } from "./domain";
import type {
BindSkillInput,
CreateAgentInput,
CreateApprovalInput,
CreateOrganizationInput,
CreateSocialAccountInput,
CreateTaskInput,
DecideApprovalInput,
ImportFileInput,
ImportSkillDirectoryInput,
LaunchSocialLoginInput,
SaveProviderCredentialInput,
SendMessageInput,
UpdateTaskStatusInput,
} from "./commands";
export interface RendererEvent {
type: "snapshot-changed";
}
export interface DesktopApi {
loadSnapshot(): Promise<DesktopResult<AppSnapshot>>;
createOrganization(input: CreateOrganizationInput): Promise<DesktopResult<AppSnapshot>>;
createAgent(input: CreateAgentInput): Promise<DesktopResult<AppSnapshot>>;
createTask(input: CreateTaskInput): Promise<DesktopResult<AppSnapshot>>;
updateTaskStatus(input: UpdateTaskStatusInput): Promise<DesktopResult<AppSnapshot>>;
createApproval(input: CreateApprovalInput): Promise<DesktopResult<AppSnapshot>>;
decideApproval(input: DecideApprovalInput): Promise<DesktopResult<AppSnapshot>>;
sendMessage(input: SendMessageInput): Promise<DesktopResult<AppSnapshot>>;
saveProviderCredential(input: SaveProviderCredentialInput): Promise<DesktopResult<AppSnapshot>>;
importSkillDirectory(input: ImportSkillDirectoryInput): Promise<DesktopResult<AppSnapshot>>;
bindSkill(input: BindSkillInput): Promise<DesktopResult<AppSnapshot>>;
createSocialAccount(input: CreateSocialAccountInput): Promise<DesktopResult<AppSnapshot>>;
launchSocialLogin(input: LaunchSocialLoginInput): Promise<DesktopResult<null>>;
importFile(input: ImportFileInput): Promise<DesktopResult<AppSnapshot>>;
subscribe(callback: (event: RendererEvent) => void): () => void;
platform: "win32" | "darwin" | "linux";
windowMinimize(): Promise<void>;
windowMaximize(): Promise<void>;
windowClose(): Promise<void>;
}
+225
View File
@@ -0,0 +1,225 @@
export const TASK_STATUS = {
BACKLOG: "backlog",
IN_PROGRESS: "in_progress",
REVIEW: "review",
DONE: "done",
} as const;
export const APPROVAL_STATE = {
PENDING: "pending",
APPROVED: "approved",
DECLINED: "declined",
} as const;
export const RISK_LEVEL = {
LOW: "low",
MEDIUM: "medium",
HIGH: "high",
} as const;
export const PROVIDER_AUTH_TYPE = {
API_KEY: "api_key",
OAUTH_PKCE: "oauth_pkce",
ACCOUNT_SUBSCRIPTION: "account_subscription",
CLOUD: "cloud",
} as const;
export const SOCIAL_PLATFORM = {
X: "x",
LINKEDIN: "linkedin",
YOUTUBE: "youtube",
INSTAGRAM: "instagram",
} as const;
export type ValueOf<T> = T[keyof T];
export type TaskStatus = ValueOf<typeof TASK_STATUS>;
export type ApprovalState = ValueOf<typeof APPROVAL_STATE>;
export type RiskLevel = ValueOf<typeof RISK_LEVEL>;
export type ProviderAuthType = ValueOf<typeof PROVIDER_AUTH_TYPE>;
export type SocialPlatform = ValueOf<typeof SOCIAL_PLATFORM>;
export interface OrganizationRecord {
id: string;
name: string;
mission: string;
goal: string;
createdAt: string;
}
export interface EmployeeRecord {
id: string;
orgId: string;
displayName: string;
role: string;
createdAt: string;
}
export interface AgentRecord {
id: string;
orgId: string;
name: string;
role: string;
department: string;
modelProviderId: string | null;
modelId: string | null;
budgetCents: number;
managerAgentId: string | null;
lifecycle: string;
createdAt: string;
}
export interface TaskRecord {
id: string;
orgId: string;
title: string;
description: string;
status: TaskStatus;
assigneeAgentId: string | null;
risk: RiskLevel;
createdAt: string;
updatedAt: string;
}
export interface ApprovalRecord {
id: string;
orgId: string;
kind: string;
title: string;
details: string;
risk: RiskLevel;
requestedByType: "employee" | "agent" | "system";
requestedById: string | null;
state: ApprovalState;
decidedByEmployeeId: string | null;
createdAt: string;
decidedAt: string | null;
}
export interface ChatSpaceRecord {
id: string;
orgId: string;
spaceType: string;
name: string;
topic: string;
visibility: string;
createdAt: string;
}
export interface ChatMessageRecord {
id: string;
orgId: string;
spaceId: string;
threadId: string | null;
senderType: "employee" | "agent" | "board" | "system";
senderId: string | null;
bodyMd: string;
createdAt: string;
editedAt: string | null;
deletedAt: string | null;
}
export interface FileObjectRecord {
id: string;
orgId: string;
name: string;
kind: string;
sizeBytes: number;
sha256: string;
storagePath: string;
ownerType: "employee" | "agent" | "system";
ownerId: string | null;
createdAt: string;
}
export interface ProviderRecord {
id: string;
orgId: string;
providerKey: string;
label: string;
authType: ProviderAuthType;
model: string | null;
credentialId: string | null;
enabled: boolean;
createdAt: string;
}
export interface ProviderCatalogEntry {
key: string;
label: string;
authType: ProviderAuthType;
defaultModel: string | null;
capabilityTags: string[];
}
export interface SkillDefinitionRecord {
id: string;
orgId: string;
slug: string;
name: string;
ownerType: string;
status: string;
createdAt: string;
}
export interface SkillRevisionRecord {
id: string;
skillId: string;
versionSemver: string;
manifestJson: string;
sourceType: string;
sourceRef: string;
publishedAt: string;
immutableChecksum: string;
}
export interface SkillBindingRecord {
id: string;
orgId: string;
agentId: string;
skillId: string;
pinnedRevisionId: string;
bindingMode: string;
priority: number;
enabled: boolean;
}
export interface SocialAccountRecord {
id: string;
orgId: string;
platform: SocialPlatform;
handle: string;
displayName: string;
authorizedEmployeeId: string | null;
createdAt: string;
}
export interface AuditEventRecord {
id: string;
orgId: string;
entityType: string;
entityId: string;
actorType: string;
actorId: string | null;
action: string;
payloadJson: string;
createdAt: string;
}
export interface AppSnapshot {
activeOrgId: string | null;
organizations: OrganizationRecord[];
employees: EmployeeRecord[];
agents: AgentRecord[];
tasks: TaskRecord[];
approvals: ApprovalRecord[];
chatSpaces: ChatSpaceRecord[];
chatMessages: ChatMessageRecord[];
files: FileObjectRecord[];
providers: ProviderRecord[];
providerCatalog: ProviderCatalogEntry[];
skillDefinitions: SkillDefinitionRecord[];
skillRevisions: SkillRevisionRecord[];
skillBindings: SkillBindingRecord[];
socialAccounts: SocialAccountRecord[];
auditEvents: AuditEventRecord[];
}
+5
View File
@@ -0,0 +1,5 @@
export type EntityId = string;
export function createId(prefix: string): EntityId {
return `${prefix}_${crypto.randomUUID().replaceAll("-", "")}`;
}
+20
View File
@@ -0,0 +1,20 @@
export const IPC_CHANNELS = {
loadSnapshot: "agentcompany:load-snapshot",
createOrganization: "agentcompany:create-organization",
createAgent: "agentcompany:create-agent",
createTask: "agentcompany:create-task",
updateTaskStatus: "agentcompany:update-task-status",
createApproval: "agentcompany:create-approval",
decideApproval: "agentcompany:decide-approval",
sendMessage: "agentcompany:send-message",
saveProviderCredential: "agentcompany:save-provider-credential",
importSkillDirectory: "agentcompany:import-skill-directory",
bindSkill: "agentcompany:bind-skill",
createSocialAccount: "agentcompany:create-social-account",
launchSocialLogin: "agentcompany:launch-social-login",
importFile: "agentcompany:import-file",
rendererEvent: "agentcompany:renderer-event",
windowMinimize: "agentcompany:window-minimize",
windowMaximize: "agentcompany:window-maximize",
windowClose: "agentcompany:window-close",
} as const;
@@ -0,0 +1,74 @@
import { PROVIDER_AUTH_TYPE, type ProviderCatalogEntry } from "./domain";
export const PROVIDER_CATALOG: ProviderCatalogEntry[] = [
{
key: "openai",
label: "OpenAI",
authType: PROVIDER_AUTH_TYPE.API_KEY,
defaultModel: "gpt-5.1",
capabilityTags: ["responses", "tools", "background"],
},
{
key: "anthropic",
label: "Anthropic",
authType: PROVIDER_AUTH_TYPE.API_KEY,
defaultModel: "claude-sonnet-4-5",
capabilityTags: ["messages", "tool-use", "token-bucket"],
},
{
key: "xai",
label: "xAI Grok",
authType: PROVIDER_AUTH_TYPE.API_KEY,
defaultModel: "grok-4",
capabilityTags: ["chat", "reasoning"],
},
{
key: "github-copilot",
label: "GitHub Copilot",
authType: PROVIDER_AUTH_TYPE.OAUTH_PKCE,
defaultModel: null,
capabilityTags: ["account", "coding"],
},
{
key: "google-antigravity",
label: "Google Antigravity",
authType: PROVIDER_AUTH_TYPE.OAUTH_PKCE,
defaultModel: null,
capabilityTags: ["account", "coding"],
},
{
key: "gpt-account",
label: "GPT account",
authType: PROVIDER_AUTH_TYPE.ACCOUNT_SUBSCRIPTION,
defaultModel: null,
capabilityTags: ["codex", "subscription"],
},
{
key: "azure-openai",
label: "Azure OpenAI",
authType: PROVIDER_AUTH_TYPE.CLOUD,
defaultModel: null,
capabilityTags: ["enterprise", "regional"],
},
{
key: "aws-bedrock",
label: "AWS Bedrock",
authType: PROVIDER_AUTH_TYPE.CLOUD,
defaultModel: null,
capabilityTags: ["enterprise", "multi-model"],
},
{
key: "vertex-ai",
label: "Vertex AI",
authType: PROVIDER_AUTH_TYPE.CLOUD,
defaultModel: null,
capabilityTags: ["enterprise", "regional"],
},
{
key: "groq",
label: "Groq",
authType: PROVIDER_AUTH_TYPE.API_KEY,
defaultModel: null,
capabilityTags: ["inference", "low-latency"],
},
];
+16
View File
@@ -0,0 +1,16 @@
export interface DesktopError {
code: string;
message: string;
}
export type DesktopResult<T> =
| { ok: true; data: T }
| { ok: false; error: DesktopError };
export function ok<T>(data: T): DesktopResult<T> {
return { ok: true, data };
}
export function fail(code: string, message: string): DesktopResult<never> {
return { ok: false, error: { code, message } };
}
@@ -0,0 +1,50 @@
import { _electron as electron, expect, test } from "@playwright/test";
import { mkdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { randomUUID } from "node:crypto";
test("onboards, hires, and creates work in the Electron shell", async () => {
const profileDir = join(tmpdir(), `agentcompany-new-e2e-${randomUUID()}`);
mkdirSync(profileDir, { recursive: true });
const app = await electron.launch({
args: [resolve("out/main/index.js")],
env: {
...process.env,
AGENTCOMPANY_NEW_PROFILE_DIR: profileDir,
},
});
try {
const page = await app.firstWindow();
await expect(page.getByRole("heading", { name: "AgentCompany" })).toBeVisible();
await page.getByLabel("Company name").fill("Acme Operations");
await page.getByLabel("Your name").fill("Dana Lee");
await page.getByLabel("Mission").fill("Coordinate agents with auditable approvals.");
await page.getByLabel("First goal").fill("Ship a verified control plane.");
await page.getByRole("button", { name: "Create" }).click();
await expect(page.getByRole("heading", { name: "Acme Operations" })).toBeVisible();
await page.getByRole("button", { name: "Team" }).click();
await page.getByRole("button", { name: "Hire" }).click();
await page.getByLabel("Name").fill("Alex Chen");
await page.getByLabel("Role").fill("CTO");
await page.getByLabel("Department").fill("Engineering");
await page.getByLabel("Monthly budget").fill("250");
await page.locator(".modal").getByRole("button", { name: "Hire" }).click();
await expect(page.getByText("Alex Chen").first()).toBeVisible();
await page.getByRole("button", { name: "Work" }).click();
await page.getByRole("button", { name: "New task" }).click();
await page.getByLabel("Title").fill("Production deploy");
await page.getByLabel("Description").fill("Deploy a high-risk change.");
await page.getByLabel("Risk").selectOption("high");
await page.locator(".modal").getByRole("button", { name: "Create" }).click();
await expect(page.getByText("Production deploy").first()).toBeVisible();
await page.getByRole("button", { name: "Approvals" }).click();
await expect(page.getByText("Production deploy").first()).toBeVisible();
} finally {
await app.close();
}
});
@@ -0,0 +1,9 @@
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: ".",
timeout: 60_000,
retries: 0,
workers: 1,
reporter: "list",
});
@@ -0,0 +1,94 @@
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { randomUUID } from "node:crypto";
import { describe, expect, test } from "vitest";
import { DatabaseConnection } from "../../src/main/database/connection";
import { OrganizationService } from "../../src/main/services/organization-service";
import { SnapshotService } from "../../src/main/services/snapshot-service";
import { SkillService } from "../../src/main/services/skill-service";
import { RISK_LEVEL, TASK_STATUS } from "../../src/shared/domain";
function createConnection(): DatabaseConnection {
return new DatabaseConnection(join(tmpdir(), `agentcompany-new-${randomUUID()}.db`));
}
describe("domain services", () => {
test("creates an organization with providers, chat spaces, employee, and audit", () => {
const connection = createConnection();
const organization = new OrganizationService(connection.db);
const snapshot = new SnapshotService(connection.db);
connection.transaction(() => organization.createOrganization({
organizationName: "Acme Operations",
employeeName: "Dana Lee",
mission: "Coordinate agents with auditable approvals.",
goal: "Ship a verified control plane.",
}));
const state = snapshot.load();
expect(state.organizations).toHaveLength(1);
expect(state.employees[0]?.displayName).toBe("Dana Lee");
expect(state.providers.length).toBeGreaterThanOrEqual(6);
expect(state.chatSpaces.map((space) => space.name)).toContain("board");
expect(state.auditEvents[0]?.action).toBe("created");
connection.close();
});
test("high risk work creates a pending approval", () => {
const connection = createConnection();
const organization = new OrganizationService(connection.db);
const snapshot = new SnapshotService(connection.db);
let orgId = "";
connection.transaction(() => {
orgId = organization.createOrganization({
organizationName: "Acme Operations",
employeeName: "Dana Lee",
mission: "Coordinate agents with auditable approvals.",
goal: "Ship a verified control plane.",
});
organization.createTask({
orgId,
title: "Production deploy",
description: "Deploy a high-risk change.",
status: TASK_STATUS.BACKLOG,
assigneeAgentId: null,
risk: RISK_LEVEL.HIGH,
});
});
const state = snapshot.load();
expect(state.tasks).toHaveLength(1);
expect(state.approvals).toHaveLength(1);
expect(state.approvals[0]?.state).toBe("pending");
connection.close();
});
test("imports a versioned skill revision from a local directory", () => {
const connection = createConnection();
const organization = new OrganizationService(connection.db);
const skills = new SkillService(connection.db);
const snapshot = new SnapshotService(connection.db);
const skillDir = join(tmpdir(), `agentcompany-skill-${randomUUID()}`);
mkdirSync(skillDir, { recursive: true });
writeFileSync(join(skillDir, "SKILL.md"), "---\nname: review-routes\n---\n# Review Routes\n");
let orgId = "";
connection.transaction(() => {
orgId = organization.createOrganization({
organizationName: "Acme Operations",
employeeName: "Dana Lee",
mission: "Coordinate agents with auditable approvals.",
goal: "Ship a verified control plane.",
});
skills.importDirectory({ orgId, directoryPath: skillDir, versionSemver: "1.0.0" });
});
const state = snapshot.load();
expect(state.skillDefinitions[0]?.slug).toBe("review-routes");
expect(state.skillRevisions[0]?.versionSemver).toBe("1.0.0");
expect(state.skillRevisions[0]?.immutableChecksum).toHaveLength(64);
connection.close();
});
});
+29
View File
@@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"types": ["node", "electron", "vitest/globals"],
"baseUrl": ".",
"paths": {
"@main/*": ["src/main/*"],
"@preload/*": ["src/preload/*"],
"@renderer/*": ["src/renderer/*"],
"@shared/*": ["src/shared/*"]
}
},
"include": ["src", "tests", "electron.vite.config.ts", "vitest.config.ts", "eslint.config.mjs"],
"references": []
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
include: ["tests/unit/**/*.test.ts"],
},
});