Rebuild AgentCompany Electron app
This commit is contained in:
1 parent
bc5daa6eb8
commit
fcf6d90731
56 files changed
+8667
No files matched your search
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
out/
|
||||||
|
release/
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
coverage/
|
||||||
|
*.tsbuildinfo
|
||||||
|
design-source/
|
||||||
@@ -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
|
||||||
@@ -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()],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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: "^_" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+4745
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'));
|
||||||
|
`);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -0,0 +1,7 @@
|
|||||||
|
import type { DesktopApi } from "../shared/desktop-api";
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
agentCompany: DesktopApi;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export type EntityId = string;
|
||||||
|
|
||||||
|
export function createId(prefix: string): EntityId {
|
||||||
|
return `${prefix}_${crypto.randomUUID().replaceAll("-", "")}`;
|
||||||
|
}
|
||||||
@@ -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"],
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: "node",
|
||||||
|
include: ["tests/unit/**/*.test.ts"],
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in new issue
Block a user