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