Rebuild AgentCompany-New renderer to match design spec

- Port design tokens, sidebar, topbar, and icons to inline SVG set
- Rewrite each page (Home/Team/Work/Chat/Files/Approvals/Social/Settings)
  to drive real snapshot data and match the bento, kanban, three-pane chat,
  file tree + pinned cards, approval queue, social schedule + calendar,
  and settings sub-nav layouts
- Split Chat and Settings into per-concern subcomponents so every file
  stays under 500 lines
- Add schema v4 for pinned files and scheduled posts plus IPC + service
  wiring; cover new behavior in unit tests
- Update onboarding labels and Hire form to remove ambiguity for the
  expanded e2e suite (all settings tabs + 8 routes at 1280 and 980)
This commit is contained in:
ZacharyZhang-NY committed 2026-05-21 19:42:38 -04:00
1 parent 00736d5719
commit 74266566b7
47 files changed
+4609 -1552

No files matched your search

@@ -88,6 +88,9 @@ export function registerIpcHandlers(deps: IpcDeps): void {
ipcMain.handle(IPC_CHANNELS.bindSkill, (_event, input) => mutate(() => skills.bind(input)));
ipcMain.handle(IPC_CHANNELS.resolveAgentSkills, (_event, input) => mutate(() => skills.resolveRuntimeSnapshot(input)));
ipcMain.handle(IPC_CHANNELS.createSocialAccount, (_event, input) => mutate(() => social.createAccount(input)));
ipcMain.handle(IPC_CHANNELS.createScheduledPost, (_event, input) => mutate(() => social.createScheduledPost(input)));
ipcMain.handle(IPC_CHANNELS.setScheduledPostState, (_event, input) => mutate(() => social.setScheduledPostState(input)));
ipcMain.handle(IPC_CHANNELS.setFilePinned, (_event, input) => mutate(() => files.setPinned(input.orgId, input.fileId, input.pinned)));
ipcMain.handle(IPC_CHANNELS.launchSocialLogin, (_event, input) => {
try {
+30 -1
View File
@@ -1,6 +1,6 @@
import type { DatabaseSync } from "node:sqlite";
const SCHEMA_VERSION = 3;
const SCHEMA_VERSION = 4;
export function installSchema(db: DatabaseSync): void {
db.exec(`
@@ -455,5 +455,34 @@ export function installSchema(db: DatabaseSync): void {
insert into schema_migrations(version, name, applied_at)
values (3, 'onboarding_execution_schema', datetime('now'));
`);
currentVersion = 3;
}
if (currentVersion < 4) {
db.exec(`
alter table file_objects add column pinned integer not null default 0;
alter table file_objects add column space_id text;
alter table file_objects add column updated_at text;
create table if not exists scheduled_posts (
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,
body_md text not null,
scheduled_for text not null,
state text not null,
approval_id text references approvals(id) on delete set null,
created_by_employee_id text references employees(id) on delete set null,
created_at text not null,
updated_at text not null
);
create index if not exists idx_files_org_pinned on file_objects(org_id, pinned);
create index if not exists idx_scheduled_posts_org_state on scheduled_posts(org_id, state);
create index if not exists idx_scheduled_posts_account_date on scheduled_posts(social_account_id, scheduled_for);
insert into schema_migrations(version, name, applied_at)
values (4, 'files_pinned_and_scheduled_posts', datetime('now'));
`);
}
}
@@ -49,8 +49,11 @@ export class FileService {
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
insert into file_objects(
id, org_id, name, kind, size_bytes, sha256, storage_path,
owner_type, owner_id, space_id, pinned, created_at, updated_at
)
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
id,
orgId,
@@ -61,6 +64,9 @@ export class FileService {
target,
"system",
null,
null,
0,
now,
now,
);
this.audit.record({
@@ -74,4 +80,20 @@ export class FileService {
});
return id;
}
setPinned(orgId: string, fileId: string, pinned: boolean): void {
const result = this.db.prepare(
"update file_objects set pinned = ?, updated_at = ? where id = ? and org_id = ?",
).run(pinned ? 1 : 0, new Date().toISOString(), fileId, orgId);
if (result.changes === 0) throw new Error("File was not found.");
this.audit.record({
orgId,
entityType: "file_object",
entityId: fileId,
actorType: "employee",
actorId: null,
action: pinned ? "pinned" : "unpinned",
payload: { fileId },
});
}
}
@@ -45,6 +45,8 @@ export class SnapshotService {
.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) }));
const files = list<AppSnapshot["files"][number]>(this.db, "file_objects", "created_at desc")
.map((file) => ({ ...file, pinned: toBool(file.pinned) }));
return {
activeOrgId,
@@ -62,7 +64,8 @@ export class SnapshotService {
chatThreads: list(this.db, "chat_threads", "updated_at desc"),
chatAttachments: list(this.db, "chat_attachments", "message_id asc"),
chatPresence: list(this.db, "chat_presence", "last_seen_at desc"),
files: list(this.db, "file_objects", "created_at desc"),
files,
scheduledPosts: list(this.db, "scheduled_posts", "scheduled_for asc"),
providers,
providerCatalog: PROVIDER_CATALOG,
providerRequests: list(this.db, "provider_requests", "created_at desc"),
@@ -1,6 +1,10 @@
import type { DatabaseSync } from "node:sqlite";
import { BrowserWindow, session } from "electron";
import type { CreateSocialAccountInput } from "../../shared/commands";
import type {
CreateScheduledPostInput,
CreateSocialAccountInput,
SetScheduledPostStateInput,
} from "../../shared/commands";
import { SOCIAL_PLATFORM, type SocialPlatform } from "../../shared/domain";
import { createId } from "../../shared/ids";
import { AuditService } from "./audit-service";
@@ -26,6 +30,63 @@ export class SocialService {
this.audit = new AuditService(db);
}
createScheduledPost(input: CreateScheduledPostInput): string {
const account = this.db.prepare("select id from social_accounts where id = ? and org_id = ?")
.get(input.socialAccountId, input.orgId) as { id: string } | undefined;
if (!account) throw new Error("Social account was not found.");
const body = input.bodyMd.trim();
if (!body) throw new Error("Post body is required.");
const scheduledFor = input.scheduledFor.trim();
if (!scheduledFor) throw new Error("Scheduled date is required.");
const id = createId("spost");
const now = new Date().toISOString();
this.db.prepare(`
insert into scheduled_posts(
id, org_id, social_account_id, body_md, scheduled_for,
state, approval_id, created_by_employee_id, created_at, updated_at
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
id,
input.orgId,
input.socialAccountId,
body,
scheduledFor,
input.state,
null,
input.createdByEmployeeId,
now,
now,
);
this.audit.record({
orgId: input.orgId,
entityType: "scheduled_post",
entityId: id,
actorType: input.createdByEmployeeId ? "employee" : "system",
actorId: input.createdByEmployeeId,
action: "scheduled",
payload: { socialAccountId: input.socialAccountId, scheduledFor, state: input.state },
});
return id;
}
setScheduledPostState(input: SetScheduledPostStateInput): void {
const now = new Date().toISOString();
const result = this.db.prepare(`
update scheduled_posts set state = ?, updated_at = ?
where id = ? and org_id = ?
`).run(input.state, now, input.scheduledPostId, input.orgId);
if (result.changes === 0) throw new Error("Scheduled post was not found.");
this.audit.record({
orgId: input.orgId,
entityType: "scheduled_post",
entityId: input.scheduledPostId,
actorType: "employee",
actorId: null,
action: `state_${input.state}`,
payload: { state: input.state },
});
}
createAccount(input: CreateSocialAccountInput): string {
const id = createId("soc");
const now = new Date().toISOString();
+3
View File
@@ -24,7 +24,10 @@ const api: DesktopApi = {
resolveAgentSkills: (input) => invoke(IPC_CHANNELS.resolveAgentSkills, input),
createSocialAccount: (input) => invoke(IPC_CHANNELS.createSocialAccount, input),
launchSocialLogin: (input) => invoke(IPC_CHANNELS.launchSocialLogin, input),
createScheduledPost: (input) => invoke(IPC_CHANNELS.createScheduledPost, input),
setScheduledPostState: (input) => invoke(IPC_CHANNELS.setScheduledPostState, input),
importFile: (input) => invoke(IPC_CHANNELS.importFile, input),
setFilePinned: (input) => invoke(IPC_CHANNELS.setFilePinned, input),
createMigrationReport: (input) => invoke(IPC_CHANNELS.createMigrationReport, input),
subscribe: (callback) => {
const listener = (_event: Electron.IpcRendererEvent, payload: RendererEvent) => callback(payload);
@@ -1,10 +1,13 @@
import { useMemo, useState, type FormEvent } from "react";
import { PROVIDER_AUTH_TYPE } from "../../../shared/domain";
import {
PROVIDER_AUTH_TYPE,
type AppSnapshot,
type ProviderCatalogEntry,
} from "../../../shared/domain";
import { Button, Field, Icon, Pill } from "../shared/ui";
import type { SnapshotActions } from "./useSnapshot";
import type { AppSnapshot, ProviderCatalogEntry } from "../../../shared/domain";
type Step = "welcome" | "provider" | "company" | "mode" | "mission" | "launch";
type Step = "welcome" | "provider" | "company" | "mode" | "mission";
interface Draft {
companyName: string;
@@ -19,54 +22,62 @@ interface Draft {
goalDescription: string;
}
const steps: Array<{ id: Step; label: string }> = [
const STEPS: Array<{ id: Step; label: string }> = [
{ id: "welcome", label: "Start" },
{ id: "provider", label: "Provider" },
{ id: "company", label: "Company" },
{ id: "mode", label: "Mode" },
{ id: "mission", label: "Goal" },
{ id: "launch", label: "Launch" },
];
const initialDraft: Draft = {
companyName: "",
companyDescription: "",
employeeName: "",
workspacePath: "",
executionMode: "ai_ceo",
providerKey: "",
providerSecret: "",
providerModel: "",
goalTitle: "",
goalDescription: "",
};
export function Onboarding({ snapshot, actions }: { snapshot: AppSnapshot; actions: SnapshotActions }) {
const apiProviders = useMemo(
() => snapshot.providerCatalog.filter((entry) => entry.authType === PROVIDER_AUTH_TYPE.API_KEY),
[snapshot.providerCatalog],
);
const firstProvider = apiProviders[0];
if (!firstProvider) throw new Error("Provider catalog must include at least one API-key provider.");
if (!firstProvider.defaultModel) throw new Error("Default API provider needs a default model.");
const [step, setStep] = useState<Step>("welcome");
const [draft, setDraft] = useState<Draft>(() => {
const firstProvider = apiProviders[0];
if (!firstProvider?.defaultModel) throw new Error("Provider catalog requires a default API model.");
return { ...initialDraft, providerKey: firstProvider.key, providerModel: firstProvider.defaultModel };
const [draft, setDraft] = useState<Draft>({
companyName: "",
companyDescription: "",
employeeName: "",
workspacePath: "",
executionMode: "ai_ceo",
providerKey: firstProvider.key,
providerSecret: "",
providerModel: firstProvider.defaultModel,
goalTitle: "",
goalDescription: "",
});
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const selectedProvider = requireProvider(apiProviders, draft.providerKey);
const selectedProvider = requireProvider(apiProviders, draft.providerKey);
const update = (patch: Partial<Draft>) => setDraft((current) => ({ ...current, ...patch }));
const go = (next: Step) => {
const guard = (validator: () => string | null, next: Step) => () => {
const message = validator();
if (message) {
setError(message);
return;
}
setError(null);
setStep(next);
};
const launch = async (event: FormEvent<HTMLFormElement>) => {
const back = (target: Step) => () => {
setError(null);
setStep(target);
};
const submit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const validation = validateDraft(draft);
if (validation) {
setError(validation);
const message = validateAll(draft);
if (message) {
setError(message);
return;
}
setSubmitting(true);
@@ -97,72 +108,96 @@ export function Onboarding({ snapshot, actions }: { snapshot: AppSnapshot; actio
<main className="onboarding-shell">
<section className="onboarding-hero">
<div>
<div className="brand" style={{ padding: 0 }}>
<div className="row" style={{ gap: 8 }}>
<div className="brand-mark">A</div>
<div className="brand-name">AgentCompany</div>
<div style={{ fontWeight: 540 }}>AgentCompany</div>
</div>
<h1 className="page-h1" style={{ fontSize: 34, marginTop: 34 }}>Start your AI company.</h1>
<p className="page-sub" style={{ maxWidth: 420 }}>
Create the company, bind the CEO runtime, and queue the first operating loop.
<h1 className="page-h1" style={{ fontSize: 34, marginTop: 32, letterSpacing: "-0.02em" }}>Start your AI company.</h1>
<p className="page-sub" style={{ maxWidth: 460, fontSize: 14, lineHeight: 1.55 }}>
Stand up the workspace, bind a runtime, and queue the first operating loop. Real data
from your provider, hairline UI, zero placeholder noise.
</p>
<div className="onboarding-steps">
{steps.map((item) => (
<span key={item.id} className={`step-chip ${item.id === step ? "active" : ""}`}>{item.label}</span>
{STEPS.map((entry) => (
<span key={entry.id} className={`step-chip ${entry.id === step ? "active" : ""}`}>{entry.label}</span>
))}
</div>
</div>
<div className="card" style={{ maxWidth: 440 }}>
<div className="card-label">Selected runtime</div>
<div className="row" style={{ marginTop: 12 }}>
<Icon name="bot" />
<strong>{selectedProvider.label}</strong>
<div className="card" style={{ maxWidth: 460 }}>
<div className="card-label" style={{ marginBottom: 10 }}>Runtime selected</div>
<div className="row" style={{ gap: 10 }}>
<Icon name="bot" size={16} />
<strong style={{ fontSize: 13 }}>{selectedProvider.label}</strong>
<Pill tone="violet">{draft.providerModel}</Pill>
</div>
<div style={{ marginTop: 10, fontSize: 12, color: "var(--text-3)" }}>
We never embed the key in the build. Credentials encrypt locally via OS-protected storage.
</div>
</div>
</section>
<form className="onboarding-panel" onSubmit={launch}>
{step === "welcome" && <WelcomeStep onNext={() => go("provider")} />}
<form className="onboarding-panel" onSubmit={submit} noValidate>
{step === "welcome" && (
<WelcomeStep onNext={() => setStep("provider")} />
)}
{step === "provider" && (
<ProviderStep
providers={apiProviders}
draft={draft}
onChange={update}
onBack={() => go("welcome")}
onNext={() => validateProvider(draft) ? setError(validateProvider(draft)) : go("company")}
onBack={back("welcome")}
onNext={guard(() => validateProvider(draft), "company")}
/>
)}
{step === "company" && (
<CompanyStep
draft={draft}
onChange={update}
onBack={() => go("provider")}
onNext={() => validateCompany(draft) ? setError(validateCompany(draft)) : go("mode")}
onBack={back("provider")}
onNext={guard(() => validateCompany(draft), "mode")}
/>
)}
{step === "mode" && (
<ModeStep draft={draft} onChange={update} onBack={() => go("company")} onNext={() => go("mission")} />
<ModeStep draft={draft} onChange={update} onBack={back("company")} onNext={() => setStep("mission")} />
)}
{step === "mission" && (
<MissionStep
draft={draft}
submitting={submitting}
onChange={update}
onBack={() => go("mode")}
onBack={back("mode")}
/>
)}
{step === "launch" && null}
{error && <div style={{ color: "var(--red)", marginTop: 12 }}>{error}</div>}
{error && <div style={{ color: "var(--red)", marginTop: 14, fontSize: 12.5 }}>{error}</div>}
</form>
</main>
);
}
interface StepProps {
draft: Draft;
onChange: (patch: Partial<Draft>) => void;
onBack: () => void;
onNext: () => void;
}
function WelcomeStep({ onNext }: { onNext: () => void }) {
return (
<div className="form-grid">
<h2 className="page-h1">Welcome</h2>
<Button tone="primary" onClick={onNext}>Get started</Button>
<p style={{ color: "var(--text-2)", fontSize: 14, lineHeight: 1.55 }}>
AgentCompany sets up your operating loop in five steps. Bring an API key, pick a runtime
for the founding CEO, and we'll wire up the first goal, project, and task.
</p>
<ul style={{ paddingLeft: 18, color: "var(--text-2)", lineHeight: 1.8, margin: 0 }}>
<li>Connect one model provider (OpenAI, Anthropic, xAI, or Groq).</li>
<li>Name the company and the human board chair.</li>
<li>Pick execution mode and bind the workspace path.</li>
<li>State the first goal — we kick off the rest.</li>
</ul>
<div className="modal-actions" style={{ marginTop: 4 }}>
<Button tone="primary" onClick={onNext}>Get started</Button>
</div>
</div>
);
}
@@ -183,28 +218,41 @@ function ProviderStep({
return (
<div className="form-grid">
<h2 className="page-h1">Provider</h2>
<p style={{ color: "var(--text-3)", fontSize: 13 }}>
Pick the runtime for the founding CEO. You can attach more providers in Settings later.
</p>
<div className="option-grid">
{providers.map((provider) => (
<button
key={provider.key}
type="button"
className={`option-card ${draft.providerKey === provider.key ? "active" : ""}`}
onClick={() => onChange({ providerKey: provider.key, providerModel: provider.defaultModel ?? "" })}
className={`option-card ${draft.providerKey === provider.key ? "active" : ""}`.trim()}
onClick={() => onChange({
providerKey: provider.key,
providerModel: provider.defaultModel ?? draft.providerModel,
})}
>
<div className="row">
<strong>{provider.label}</strong>
<Pill>{provider.authType}</Pill>
<div className="opt-title" style={{ flex: 1 }}>{provider.label}</div>
<Pill tone="violet">{provider.defaultModel ?? "model"}</Pill>
</div>
<div className="opt-sub">{provider.capabilityTags.join(" · ") || "API key"}</div>
</button>
))}
</div>
<Field label="API key">
<input className="input" type="password" value={draft.providerSecret} onChange={(event) => onChange({ providerSecret: event.target.value })} />
<input
className="input"
type="password"
autoComplete="off"
value={draft.providerSecret}
onChange={(event) => onChange({ providerSecret: event.target.value })}
/>
</Field>
<Field label="Model">
<input className="input" value={draft.providerModel} onChange={(event) => onChange({ providerModel: event.target.value })} />
</Field>
<FormActions onBack={onBack} onNext={onNext} />
<Actions onBack={onBack} onNext={onNext} />
</div>
);
}
@@ -214,18 +262,18 @@ function CompanyStep({ draft, onChange, onBack, onNext }: StepProps) {
<div className="form-grid">
<h2 className="page-h1">Company</h2>
<Field label="Company name">
<input className="input" value={draft.companyName} onChange={(event) => onChange({ companyName: event.target.value })} autoFocus />
<input className="input" value={draft.companyName} onChange={(event) => onChange({ companyName: event.target.value })} autoFocus required />
</Field>
<Field label="Your name">
<input className="input" value={draft.employeeName} onChange={(event) => onChange({ employeeName: event.target.value })} />
<input className="input" value={draft.employeeName} onChange={(event) => onChange({ employeeName: event.target.value })} required />
</Field>
<Field label="Description">
<textarea className="input" value={draft.companyDescription} onChange={(event) => onChange({ companyDescription: event.target.value })} />
<textarea className="input" value={draft.companyDescription} onChange={(event) => onChange({ companyDescription: event.target.value })} required />
</Field>
<Field label="Workspace path">
<input className="input" value={draft.workspacePath} onChange={(event) => onChange({ workspacePath: event.target.value })} />
<Field label="Workspace path (optional)">
<input className="input" value={draft.workspacePath} onChange={(event) => onChange({ workspacePath: event.target.value })} placeholder="/Users/yu/Projects/orbitops" />
</Field>
<FormActions onBack={onBack} onNext={onNext} />
<Actions onBack={onBack} onNext={onNext} />
</div>
);
}
@@ -234,40 +282,67 @@ function ModeStep({ draft, onChange, onBack, onNext }: StepProps) {
return (
<div className="form-grid">
<h2 className="page-h1">Mode</h2>
{(["ai_ceo", "ai_cofounder"] as const).map((mode) => (
<button
key={mode}
type="button"
className={`option-card ${draft.executionMode === mode ? "active" : ""}`}
onClick={() => onChange({ executionMode: mode })}
>
<strong>{mode === "ai_ceo" ? "AI CEO" : "AI Co-Founder"}</strong>
</button>
))}
<FormActions onBack={onBack} onNext={onNext} />
<p style={{ color: "var(--text-3)", fontSize: 13 }}>How autonomous should the CEO be?</p>
<ModeCard
title="AI CEO"
description="Agents auto-approve hires up to the budget envelope and ship work that clears policy."
active={draft.executionMode === "ai_ceo"}
onClick={() => onChange({ executionMode: "ai_ceo" })}
/>
<ModeCard
title="AI Co-Founder"
description="Hires need your stamp; deliverables route through review before shipping."
active={draft.executionMode === "ai_cofounder"}
onClick={() => onChange({ executionMode: "ai_cofounder" })}
/>
<Actions onBack={onBack} onNext={onNext} />
</div>
);
}
function MissionStep({ draft, submitting, onChange, onBack }: Omit<StepProps, "onNext"> & { submitting: boolean }) {
function MissionStep({
draft,
submitting,
onChange,
onBack,
}: Omit<StepProps, "onNext"> & { submitting: boolean }) {
return (
<div className="form-grid">
<h2 className="page-h1">Goal</h2>
<Field label="Goal">
<input className="input" value={draft.goalTitle} onChange={(event) => onChange({ goalTitle: event.target.value })} />
<input className="input" value={draft.goalTitle} onChange={(event) => onChange({ goalTitle: event.target.value })} required />
</Field>
<Field label="Acceptance criteria">
<textarea className="input" value={draft.goalDescription} onChange={(event) => onChange({ goalDescription: event.target.value })} />
<textarea className="input" value={draft.goalDescription} onChange={(event) => onChange({ goalDescription: event.target.value })} required />
</Field>
<div className="modal-actions" style={{ marginTop: 4 }}>
<Button onClick={onBack}>Back</Button>
<Button tone="primary" type="submit" disabled={submitting}>Launch</Button>
<Button tone="primary" type="submit" disabled={submitting}>{submitting ? "Launching…" : "Launch"}</Button>
</div>
</div>
);
}
function FormActions({ onBack, onNext }: { onBack: () => void; onNext: () => void }) {
function ModeCard({
title,
description,
active,
onClick,
}: {
title: string;
description: string;
active: boolean;
onClick: () => void;
}) {
return (
<button type="button" className={`option-card ${active ? "active" : ""}`.trim()} onClick={onClick}>
<div className="opt-title">{title}</div>
<div className="opt-sub">{description}</div>
</button>
);
}
function Actions({ onBack, onNext }: { onBack: () => void; onNext: () => void }) {
return (
<div className="modal-actions" style={{ marginTop: 4 }}>
<Button onClick={onBack}>Back</Button>
@@ -276,13 +351,6 @@ function FormActions({ onBack, onNext }: { onBack: () => void; onNext: () => voi
);
}
interface StepProps {
draft: Draft;
onChange: (patch: Partial<Draft>) => void;
onBack: () => void;
onNext: () => void;
}
function requireProvider(providers: ProviderCatalogEntry[], key: string): ProviderCatalogEntry {
const provider = providers.find((entry) => entry.key === key);
if (!provider) throw new Error("Provider selection is invalid.");
@@ -290,7 +358,7 @@ function requireProvider(providers: ProviderCatalogEntry[], key: string): Provid
}
function validateProvider(draft: Draft): string | null {
if (!draft.providerKey) return "Select a provider.";
if (!draft.providerKey) return "Pick a provider.";
if (!draft.providerSecret.trim()) return "API key is required.";
if (!draft.providerModel.trim()) return "Model is required.";
return null;
@@ -299,9 +367,10 @@ function validateProvider(draft: Draft): string | null {
function validateCompany(draft: Draft): string | null {
if (!draft.companyName.trim()) return "Company name is required.";
if (!draft.employeeName.trim()) return "Your name is required.";
if (!draft.companyDescription.trim()) return "Description is required.";
return null;
}
function validateDraft(draft: Draft): string | null {
function validateAll(draft: Draft): string | null {
return validateProvider(draft) ?? validateCompany(draft) ?? (!draft.goalTitle.trim() ? "Goal is required." : null);
}
@@ -1,70 +1,138 @@
import { useMemo, useState } from "react";
import { Avatar, Button, Icon, Pill } from "../shared/ui";
import type { AgentRecord, ApprovalRecord, EmployeeRecord } from "../../../shared/domain";
import type {
AgentRecord,
ApprovalRecord,
EmployeeRecord,
RiskLevel,
} from "../../../shared/domain";
import { APPROVAL_STATE, RISK_LEVEL } from "../../../shared/domain";
import type { PageProps } from "./types";
import { activeOrgId } from "./types";
import { activeOrgId, formatRelative, requireActiveEmployee } from "./types";
type RiskFilter = "all" | RiskLevel;
const RISK_FILTERS: ReadonlyArray<{ id: RiskFilter; label: string }> = [
{ id: "all", label: "All risk" },
{ id: RISK_LEVEL.HIGH, label: "High" },
{ id: RISK_LEVEL.MEDIUM, label: "Medium" },
{ id: RISK_LEVEL.LOW, label: "Low" },
];
const RISK_TONES: Record<RiskLevel, { label: string; color: string }> = {
[RISK_LEVEL.HIGH]: { label: "High risk", color: "var(--red)" },
[RISK_LEVEL.MEDIUM]: { label: "Medium risk", color: "var(--amber)" },
[RISK_LEVEL.LOW]: { label: "Low risk", color: "var(--text-3)" },
};
export function ApprovalsPage({ snapshot, actions }: PageProps) {
const orgId = activeOrgId(snapshot);
const employee = snapshot.employees.find((item) => item.orgId === orgId);
const employee = requireActiveEmployee(snapshot);
const [filter, setFilter] = useState<RiskFilter>("all");
const approvals = useMemo(() => snapshot.approvals.filter((item) => item.orgId === orgId), [snapshot.approvals, orgId]);
const employees = snapshot.employees.filter((item) => item.orgId === orgId);
const agents = snapshot.agents.filter((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) throw new Error("Employee is required for approval decisions.");
const pending = approvals.filter((approval) => approval.state === APPROVAL_STATE.PENDING);
const decided = approvals.filter((approval) => approval.state !== APPROVAL_STATE.PENDING);
const filteredPending = filter === "all" ? pending : pending.filter((approval) => approval.risk === filter);
const weeklyDecided = decided.filter((approval) => withinDays(approval.decidedAt, 7));
const approved7d = weeklyDecided.filter((approval) => approval.state === APPROVAL_STATE.APPROVED).length;
const declined7d = weeklyDecided.filter((approval) => approval.state === APPROVAL_STATE.DECLINED).length;
const medianTime = computeMedianMinutes(weeklyDecided);
const decide = (approval: ApprovalRecord, state: "approved" | "declined") =>
void actions.run(() => window.agentCompany.decideApproval({
orgId,
approvalId,
approvalId: approval.id,
state,
decidedByEmployeeId: employee.id,
}));
const approveAllLowRisk = () => {
const lows = filteredPending.filter((approval) => approval.risk === RISK_LEVEL.LOW);
for (const approval of lows) decide(approval, "approved");
};
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 className="row" style={{ marginBottom: 24 }}>
<div>
<h1 className="page-h1">Approvals</h1>
<p className="page-sub" style={{ marginBottom: 0 }}>
{pending.length} pending · {approved7d} approved this week
</p>
</div>
<div style={{ marginLeft: "auto", display: "flex", gap: 6 }}>
<RiskMenu filter={filter} onChange={setFilter} />
<Button tone="primary" onClick={approveAllLowRisk}>Approve all low risk</Button>
</div>
</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) => (
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))", gap: 12, marginBottom: 24 }}>
<StatTile label="Pending" value={String(pending.length)} tone="amber" />
<StatTile label="Median time" value={formatMinutes(medianTime)} />
<StatTile label="Approved (7d)" value={String(approved7d)} />
<StatTile label="Declined (7d)" value={String(declined7d)} />
</div>
<div className="section-label" style={{ marginBottom: 10 }}>Pending</div>
{filteredPending.length === 0 ? (
<div className="card" style={{ color: "var(--text-3)" }}>Nothing in the queue.</div>
) : filteredPending.map((approval) => (
<ApprovalRow
key={approval.id}
approval={approval}
requester={requesterName(approval, employees, agents)}
onApprove={() => decide(approval.id, "approved")}
onDecline={() => decide(approval.id, "declined")}
requester={resolveRequester(approval, employees, agents)}
isBot={approval.requestedByType === "agent"}
onApprove={() => decide(approval, "approved")}
onDecline={() => decide(approval, "declined")}
/>
))}
<div className="section-label" style={{ marginTop: 32 }}>Decided</div>
<div className="section-label" style={{ margin: "32px 0 10px" }}>Recently 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 style={{ padding: 18, color: "var(--text-3)" }}>No decisions yet.</div>
) : decided.slice(0, 24).map((approval, index) => (
<DecidedRow
key={approval.id}
approval={approval}
requester={resolveRequester(approval, employees, agents)}
decider={resolveDecider(approval, employees)}
last={index === Math.min(decided.length, 24) - 1}
/>
))}
</div>
</div>
);
}
function RiskMenu({ filter, onChange }: { filter: RiskFilter; onChange: (next: RiskFilter) => void }) {
return (
<select
className="button"
value={filter}
onChange={(event) => onChange(event.target.value as RiskFilter)}
style={{ padding: "0 10px" }}
aria-label="Filter risk"
>
{RISK_FILTERS.map((option) => <option key={option.id} value={option.id}>{option.label}</option>)}
</select>
);
}
function StatTile({ label, value, tone }: { label: string; value: string; tone?: "amber" }) {
return (
<div className="card">
<div className="card" style={{ padding: 14 }}>
<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
className="num"
style={{ fontSize: 22, fontWeight: 600, color: tone === "amber" ? "var(--amber)" : "var(--text-1)", letterSpacing: "-0.02em" }}
>
{value}
</div>
</div>
);
}
@@ -72,53 +140,129 @@ function StatTile({ label, value, tone }: { label: string; value: string; tone?:
function ApprovalRow({
approval,
requester,
isBot,
onApprove,
onDecline,
}: {
approval: ApprovalRecord;
requester: string;
isBot: boolean;
onApprove: () => void;
onDecline: () => void;
}) {
const riskColor = approval.risk === "high" ? "var(--red)" : approval.risk === "medium" ? "var(--amber)" : "var(--text-3)";
const tone = RISK_TONES[approval.risk];
if (!tone) throw new Error("Approval risk tone is missing.");
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
style={{
border: "1px solid var(--border)",
borderRadius: 12,
background: "var(--panel)",
padding: 18,
marginBottom: 10,
display: "grid",
gridTemplateColumns: "6px minmax(0, 1fr) auto",
gap: 16,
alignItems: "flex-start",
}}
>
<div style={{ width: 3, alignSelf: "stretch", background: tone.color, borderRadius: 2 }} />
<div style={{ minWidth: 0 }}>
<div className="row" style={{ marginBottom: 4 }}>
<span style={{ color: riskColor, fontSize: 11, fontWeight: 540 }}>{approval.risk}</span>
<span style={{ fontSize: 11, color: tone.color, fontWeight: 540, letterSpacing: "0.02em" }}>{tone.label}</span>
<span style={{ color: "var(--text-4)" }}>·</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={requester} size="sm" bot={approval.requestedByType !== "employee"} />
<span style={{ color: "var(--text-3)", fontSize: 12 }}>{requester}</span>
<span style={{ color: "var(--text-3)", fontSize: 12 }}>{new Date(approval.createdAt).toLocaleString()}</span>
<div style={{ fontSize: 15, fontWeight: 540, letterSpacing: "-0.01em", marginBottom: 4 }}>{approval.title}</div>
<div style={{ fontSize: 13, color: "var(--text-2)", lineHeight: 1.5, marginBottom: 12, whiteSpace: "pre-wrap" }}>
{approval.details}
</div>
<div className="row" style={{ gap: 8 }}>
<Avatar name={requester} size="sm" bot={isBot} />
<span style={{ fontSize: 12, color: "var(--text-3)" }}>Requested by {requester} · {formatRelative(approval.createdAt)}</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>
<Button onClick={onDecline}>Decline</Button>
</div>
</div>
);
}
function requesterName(
approval: ApprovalRecord,
employees: EmployeeRecord[],
agents: AgentRecord[],
): string {
if (approval.requestedByType === "employee") {
const employee = employees.find((item) => item.id === approval.requestedById);
if (!employee) throw new Error("Approval employee requester is missing.");
return employee.displayName;
function DecidedRow({
approval,
requester,
decider,
last,
}: {
approval: ApprovalRecord;
requester: string;
decider: string;
last: boolean;
}) {
const decidedTone = approval.state === APPROVAL_STATE.APPROVED ? "var(--green)" : "var(--text-3)";
const decidedLabel = approval.state === APPROVAL_STATE.APPROVED ? "Approved" : "Declined";
return (
<div
className="row"
style={{
gap: 12,
padding: "12px 18px",
fontSize: 13,
borderBottom: last ? "none" : "1px solid var(--border-soft)",
}}
>
<Icon name="check" size={14} color={decidedTone} />
<div style={{ flex: 1, fontWeight: 480, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{approval.title}</div>
<Avatar name={requester} size="sm" bot />
<div style={{ fontSize: 12, color: "var(--text-3)", width: 180, textAlign: "right", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{decidedLabel} · {formatRelative(approval.decidedAt ?? approval.createdAt)} · {decider.split(" ")[0]}
</div>
</div>
);
}
function resolveRequester(approval: ApprovalRecord, employees: EmployeeRecord[], agents: AgentRecord[]): string {
if (approval.requestedByType === "employee" && approval.requestedById) {
return employees.find((item) => item.id === approval.requestedById)?.displayName ?? "Unknown";
}
if (approval.requestedByType === "agent") {
const agent = agents.find((item) => item.id === approval.requestedById);
if (!agent) throw new Error("Approval agent requester is missing.");
return agent.name;
if (approval.requestedByType === "agent" && approval.requestedById) {
return agents.find((item) => item.id === approval.requestedById)?.name ?? "Agent";
}
return "System";
}
function resolveDecider(approval: ApprovalRecord, employees: EmployeeRecord[]): string {
if (!approval.decidedByEmployeeId) return "—";
return employees.find((item) => item.id === approval.decidedByEmployeeId)?.displayName ?? "Unknown";
}
function withinDays(iso: string | null, days: number): boolean {
if (!iso) return false;
const target = new Date(iso).getTime();
return Date.now() - target < days * 24 * 60 * 60 * 1000;
}
function computeMedianMinutes(approvals: ApprovalRecord[]): number {
const intervals = approvals
.map((approval) => approval.decidedAt ? new Date(approval.decidedAt).getTime() - new Date(approval.createdAt).getTime() : null)
.filter((interval): interval is number => interval !== null && interval > 0)
.map((interval) => Math.round(interval / 60_000))
.sort((a, b) => a - b);
if (intervals.length === 0) return 0;
const mid = Math.floor(intervals.length / 2);
const lower = intervals[mid - 1];
const higher = intervals[mid];
if (intervals.length % 2 === 1) return higher ?? 0;
if (lower === undefined || higher === undefined) return 0;
return Math.round((lower + higher) / 2);
}
function formatMinutes(minutes: number): string {
if (minutes <= 0) return "—";
if (minutes < 60) return `${minutes}m`;
const hours = Math.floor(minutes / 60);
const remaining = minutes % 60;
return remaining === 0 ? `${hours}h` : `${hours}h ${remaining}m`;
}
@@ -1,13 +1,19 @@
import { useState, type FormEvent, type ReactNode } from "react";
import { Avatar, Button, Field, Icon, IconButton } from "../shared/ui";
import { useMemo, useRef, useState, type FormEvent } from "react";
import { Field } from "../shared/ui";
import { Dialog } from "../shared/Dialog";
import type { ChatAttachmentRecord, ChatSpaceRecord, FileObjectRecord } from "../../../shared/domain";
import { ChatSidebar } from "./chat/ChatSidebar";
import { ChatConversation } from "./chat/ChatConversation";
import { ChatThreadPanel } from "./chat/ChatThreadPanel";
import {
collectParticipants,
filterApprovalsForSpace,
isRootMessage,
} from "./chat/chat-helpers";
import type { PageProps } from "./types";
import { activeOrgId } from "./types";
import { activeOrgId, requireActiveEmployee } from "./types";
function messageBody(form: FormData): string {
return String(form.get("message") ?? "").trim();
}
type SpaceType = "company" | "department" | "project" | "direct";
type Visibility = "workspace" | "restricted";
function value(form: FormData, key: string): string {
return String(form.get(key) ?? "").trim();
@@ -15,55 +21,83 @@ function value(form: FormData, key: string): string {
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 files = snapshot.files.filter((item) => item.orgId === orgId);
const [spaceId, setSpaceId] = useState(spaces[0]?.id ?? "");
const [spaceOpen, setSpaceOpen] = useState(false);
const [selectedThreadId, setSelectedThreadId] = useState<string | null>(null);
const activeSpace = spaces.find((space) => space.id === spaceId) ?? spaces[0];
if (!activeSpace) throw new Error("At least one chat space is required.");
const messages = snapshot.chatMessages.filter((item) => item.orgId === orgId && item.spaceId === activeSpace.id);
const rootMessages = messages.filter((m) => m.threadId === null);
const threads = snapshot.chatThreads.filter((t) => t.spaceId === activeSpace.id);
const selectedThread = threads.find((t) => t.id === selectedThreadId) ?? null;
const threadMessages = selectedThread ? messages.filter((m) => m.threadId === selectedThread.id) : [];
const sender = employees[0];
const employee = requireActiveEmployee(snapshot);
const spaces = useMemo(() => snapshot.chatSpaces.filter((space) => space.orgId === orgId), [snapshot.chatSpaces, orgId]);
const employees = useMemo(() => snapshot.employees.filter((item) => item.orgId === orgId), [snapshot.employees, orgId]);
const agents = useMemo(() => snapshot.agents.filter((item) => item.orgId === orgId), [snapshot.agents, orgId]);
const files = useMemo(() => snapshot.files.filter((item) => item.orgId === orgId), [snapshot.files, orgId]);
const messagesAll = useMemo(() => snapshot.chatMessages.filter((m) => m.orgId === orgId), [snapshot.chatMessages, orgId]);
const threadsAll = useMemo(() => snapshot.chatThreads.filter((t) => t.orgId === orgId), [snapshot.chatThreads, orgId]);
const approvalsAll = useMemo(
() => snapshot.approvals.filter((a) => a.orgId === orgId && a.state === "pending"),
[snapshot.approvals, orgId],
);
const channels = useMemo(() => spaces.filter((space) => space.spaceType !== "direct"), [spaces]);
const directs = useMemo(() => spaces.filter((space) => space.spaceType === "direct"), [spaces]);
const submit = (event: FormEvent<HTMLFormElement>) => {
const [selectedSpaceId, setSelectedSpaceId] = useState<string | null>(null);
const [openThreadId, setOpenThreadId] = useState<string | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [attachmentFileId, setAttachmentFileId] = useState<string>("");
const composerRef = useRef<HTMLTextAreaElement | null>(null);
const fallbackSpaceId = channels[0]?.id ?? directs[0]?.id ?? null;
const activeSpaceId = selectedSpaceId && spaces.some((space) => space.id === selectedSpaceId)
? selectedSpaceId
: fallbackSpaceId;
const activeSpace = spaces.find((space) => space.id === activeSpaceId) ?? null;
const messagesInSpace = useMemo(
() => (activeSpace ? messagesAll.filter((m) => m.spaceId === activeSpace.id) : []),
[messagesAll, activeSpace],
);
const threadsInSpace = useMemo(
() => (activeSpace ? threadsAll.filter((t) => t.spaceId === activeSpace.id) : []),
[threadsAll, activeSpace],
);
const rootMessages = messagesInSpace.filter((message) => isRootMessage(message, threadsInSpace));
const selectedThread = threadsInSpace.find((t) => t.id === openThreadId) ?? null;
const threadMessages = selectedThread
? messagesInSpace.filter((m) => m.threadId === selectedThread.id && m.id !== selectedThread.rootMessageId)
: [];
const participants = useMemo(
() => collectParticipants(messagesInSpace, employees, agents),
[messagesInSpace, employees, agents],
);
const pendingApprovalsInSpace = filterApprovalsForSpace(approvalsAll, activeSpace);
const sendInChannel = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!activeSpace || !sender) return;
if (!activeSpace) return;
const form = new FormData(event.currentTarget);
const bodyMd = messageBody(form);
if (!bodyMd) return;
const body = value(form, "message");
if (!body) return;
event.currentTarget.reset();
const attachmentFileId = value(form, "attachmentFileId");
setAttachmentFileId("");
void actions.run(() => window.agentCompany.sendMessage({
orgId,
spaceId: activeSpace.id,
threadId: null,
senderType: "employee",
senderId: sender.id,
bodyMd,
senderId: employee.id,
bodyMd: body,
attachmentFileIds: attachmentFileId ? [attachmentFileId] : [],
}));
};
const submitReply = (event: FormEvent<HTMLFormElement>) => {
const sendInThread = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!selectedThread || !sender) return;
if (!activeSpace || !selectedThread) return;
const form = new FormData(event.currentTarget);
const bodyMd = messageBody(form);
if (!bodyMd) return;
const body = value(form, "message");
if (!body) return;
event.currentTarget.reset();
void actions.run(() => window.agentCompany.sendMessage({
orgId,
spaceId: activeSpace.id,
threadId: selectedThread.id,
senderType: "employee",
senderId: sender.id,
bodyMd,
senderId: employee.id,
bodyMd: body,
attachmentFileIds: [],
}));
};
@@ -73,102 +107,75 @@ export function ChatPage({ snapshot, actions }: PageProps) {
const form = new FormData(event.currentTarget);
void actions.run(() => window.agentCompany.createChatSpace({
orgId,
spaceType: value(form, "spaceType") as "company" | "department" | "project" | "direct",
spaceType: value(form, "spaceType") as SpaceType,
name: value(form, "name"),
topic: value(form, "topic"),
visibility: value(form, "visibility") as "workspace" | "restricted",
})).then(() => setSpaceOpen(false));
visibility: value(form, "visibility") as Visibility,
})).then((result) => { if (result) setCreateOpen(false); });
};
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" onClick={() => setSpaceOpen(true)} />
</div>
<NavGroup label="Channels">
{spaces.length === 0 ? <div className="nav-item">No channels</div> : 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>
const decideApproval = (approvalId: string, state: "approved" | "declined") =>
void actions.run(() => window.agentCompany.decideApproval({
orgId,
approvalId,
state,
decidedByEmployeeId: employee.id,
}));
<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)", flexShrink: 0 }}>
<Icon name={activeSpace.visibility === "restricted" ? "lock" : "hash"} />
<div style={{ fontSize: 14, fontWeight: 600 }}>{activeSpace.name}</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" }}>
{rootMessages.length === 0 ? (
<div style={{ color: "var(--text-3)" }}>No messages</div>
) : rootMessages.map((message) => {
const thread = threads.find((t) => t.rootMessageId === message.id) ?? null;
const replyCount = thread ? messages.filter((m) => m.threadId === thread.id).length : 0;
return (
<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"}
files={messageFiles(message.id, snapshot.chatAttachments, files)}
threadReplyCount={replyCount}
onOpenThread={thread ? () => setSelectedThreadId(thread.id) : null}
/>
);
})}
</div>
<form onSubmit={submit} style={{ padding: 14, paddingTop: 4, flexShrink: 0 }}>
<div className="card" style={{ padding: "10px 12px" }}>
<textarea className="input" name="message" rows={2} aria-label="Message" />
<div className="row" style={{ paddingTop: 8, borderTop: "1px solid var(--border-soft)" }}>
<Icon name="attach" />
<select className="input" name="attachmentFileId" aria-label="File attachment" style={{ maxWidth: 220 }}>
<option value="">File</option>
{files.map((file) => <option key={file.id} value={file.id}>{file.name}</option>)}
</select>
<div style={{ marginLeft: "auto" }}>
<Button tone="primary" size="small" type="submit" disabled={!activeSpace || !sender}><Icon name="send" /> Send</Button>
</div>
</div>
</div>
</form>
</section>
return (
<div className="split-main" style={{ height: "calc(100vh - 44px)", margin: "-28px -32px -40px" }}>
<ChatSidebar
channels={channels}
directs={directs}
agents={agents}
messages={messagesAll}
activeSpaceId={activeSpaceId}
onSelect={(spaceId) => {
setSelectedSpaceId(spaceId);
setOpenThreadId(null);
}}
onCreateSpace={() => setCreateOpen(true)}
/>
<ChatConversation
space={activeSpace}
participants={participants}
rootMessages={rootMessages}
messagesInSpace={messagesInSpace}
threadsInSpace={threadsInSpace}
pendingApprovals={pendingApprovalsInSpace}
attachments={snapshot.chatAttachments}
files={files}
context={{ employees, agents }}
attachmentFileId={attachmentFileId}
onAttachmentChange={setAttachmentFileId}
onOpenThread={setOpenThreadId}
onApprove={(approvalId) => decideApproval(approvalId, "approved")}
onDecline={(approvalId) => decideApproval(approvalId, "declined")}
onSubmit={sendInChannel}
composerRef={composerRef}
/>
{selectedThread && (
<ThreadPanel
threadMessages={threadMessages}
rootMessage={messages.find((m) => m.id === selectedThread.rootMessageId) ?? null}
employees={employees}
agents={agents}
files={files}
<ChatThreadPanel
thread={selectedThread}
rootMessage={messagesInSpace.find((m) => m.id === selectedThread.rootMessageId) ?? null}
messages={threadMessages}
attachments={snapshot.chatAttachments}
senderDisabled={!sender}
onClose={() => setSelectedThreadId(null)}
onSubmit={submitReply}
files={files}
context={{ employees, agents }}
onClose={() => setOpenThreadId(null)}
onSubmit={sendInThread}
/>
)}
<Dialog title="New space" open={spaceOpen} submitLabel="Create" onClose={() => setSpaceOpen(false)} onSubmit={createSpace}>
<Dialog
title="New space"
open={createOpen}
submitLabel="Create"
onClose={() => setCreateOpen(false)}
onSubmit={createSpace}
>
<Field label="Name"><input className="input" name="name" required /></Field>
<Field label="Topic"><input className="input" name="topic" required /></Field>
<Field label="Type">
@@ -189,188 +196,3 @@ export function ChatPage({ snapshot, actions }: PageProps) {
</div>
);
}
function ThreadPanel({
threadMessages,
rootMessage,
employees,
agents,
files,
attachments,
senderDisabled,
onClose,
onSubmit,
}: {
threadMessages: Array<{ id: string; bodyMd: string; createdAt: string; senderType: string; senderId: string | null; threadId: string | null }>;
rootMessage: { id: string; bodyMd: string; createdAt: string; senderType: string; senderId: string | null } | null;
employees: Array<{ id: string; displayName: string }>;
agents: Array<{ id: string; name: string }>;
files: FileObjectRecord[];
attachments: ChatAttachmentRecord[];
senderDisabled: boolean;
onClose: () => void;
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
}) {
return (
<aside style={{ width: 320, flexShrink: 0, borderLeft: "1px solid var(--border-soft)", background: "var(--bg)", display: "flex", flexDirection: "column" }}>
<div className="row" style={{ padding: "14px 16px", borderBottom: "1px solid var(--border-soft)", flexShrink: 0 }}>
<span style={{ fontWeight: 600 }}>Thread</span>
<div style={{ marginLeft: "auto" }}>
<IconButton icon="x" label="Close thread" onClick={onClose} />
</div>
</div>
<div style={{ flex: 1, overflow: "auto", padding: 16 }}>
{rootMessage && (
<div style={{ border: "1px solid var(--border)", borderRadius: 10, padding: 12, marginBottom: 16, background: "var(--panel)" }}>
<MessageRow
body={rootMessage.bodyMd}
time={new Date(rootMessage.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
name={displayName(rootMessage.senderType, rootMessage.senderId, employees, agents)}
bot={rootMessage.senderType === "agent"}
files={messageFiles(rootMessage.id, attachments, files)}
compact
/>
</div>
)}
{threadMessages.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"}
files={messageFiles(message.id, attachments, files)}
compact
/>
))}
</div>
<form onSubmit={onSubmit} style={{ padding: 12, flexShrink: 0 }}>
<div className="card" style={{ padding: "8px 10px" }}>
<textarea className="input" name="message" rows={2} aria-label="Thread reply" />
<div style={{ paddingTop: 6, display: "flex", justifyContent: "flex-end" }}>
<Button tone="primary" size="small" type="submit" disabled={senderDisabled}><Icon name="send" /> Reply</Button>
</div>
</div>
</form>
</aside>
);
}
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" aria-label={space.name} 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,
files,
threadReplyCount = 0,
onOpenThread,
compact = false,
}: {
name: string;
body: string;
time: string;
bot: boolean;
files: FileObjectRecord[];
threadReplyCount?: number;
onOpenThread?: (() => void) | null;
compact?: boolean;
}) {
return (
<div style={{ display: "flex", gap: compact ? 10 : 12, marginBottom: compact ? 12 : 18 }}>
<Avatar name={name} bot={bot} size={compact ? "sm" : ""} />
<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>
{files.length > 0 && (
<div className="row" style={{ marginTop: 8, flexWrap: "wrap" }}>
{files.map((file) => (
<span key={file.id} className="attachment-pill">
<Icon name="file" size={13} /> {file.name}
</span>
))}
</div>
)}
{threadReplyCount > 0 && onOpenThread && (
<button
type="button"
onClick={onOpenThread}
style={{
marginTop: 8, display: "inline-flex", alignItems: "center", gap: 6,
fontSize: 11.5, color: "var(--accent)", cursor: "pointer",
padding: "3px 8px", borderRadius: 6, background: "var(--accent-soft)",
border: 0,
}}
>
<Icon name="chat" size={11} /> {threadReplyCount} {threadReplyCount === 1 ? "reply" : "replies"} →
</button>
)}
</div>
</div>
);
}
function messageFiles(
messageId: string,
attachments: ChatAttachmentRecord[],
files: FileObjectRecord[],
): FileObjectRecord[] {
return attachments
.filter((attachment) => attachment.messageId === messageId)
.map((attachment) => {
const file = files.find((item) => item.id === attachment.fileId);
if (!file) throw new Error("Chat attachment file is missing.");
return file;
});
}
function displayName(
senderType: string,
senderId: string | null,
employees: Array<{ id: string; displayName: string }>,
agents: Array<{ id: string; name: string }>,
): string {
if (senderType === "employee") {
const employee = employees.find((item) => item.id === senderId);
if (!employee) throw new Error("Message employee sender is missing.");
return employee.displayName;
}
if (senderType === "agent") {
const agent = agents.find((item) => item.id === senderId);
if (!agent) throw new Error("Message agent sender is missing.");
return agent.name;
}
if (senderType === "board") return "Board";
return "System";
}
@@ -1,129 +1,448 @@
import { useState } from "react";
import { Button, Icon } from "../shared/ui";
import type { FileObjectRecord } from "../../../shared/domain";
import { useMemo, useState } from "react";
import { Avatar, Button, Icon, IconButton, Pill, Segmented } from "../shared/ui";
import type {
AgentRecord,
EmployeeRecord,
FileObjectRecord,
} from "../../../shared/domain";
import type { PageProps } from "./types";
import { activeOrgId } from "./types";
import { activeOrgId, formatBytes, formatRelative } from "./types";
type ViewMode = "grid" | "list";
type Section =
| { kind: "all" }
| { kind: "recent" }
| { kind: "pinned" }
| { kind: "department"; value: string }
| { kind: "agent"; value: string };
const FILE_COLOR_MAP: Record<string, { color: string; label: string }> = {
doc: { color: "#5e6ad2", label: "DOC" },
sheet: { color: "#2f9e58", label: "XLS" },
md: { color: "#52525b", label: "MD" },
pdf: { color: "#d23b3b", label: "PDF" },
img: { color: "#c87d12", label: "IMG" },
folder: { color: "#a1a1aa", label: "" },
};
export function FilesPage({ snapshot, actions }: PageProps) {
const orgId = activeOrgId(snapshot);
const [selectedKind, setSelectedKind] = useState("all");
const files = snapshot.files.filter((item) => item.orgId === orgId);
const kinds = Array.from(new Set(files.map((file) => file.kind))).sort((a, b) => a.localeCompare(b));
const visibleFiles = selectedKind === "all" ? files : files.filter((file) => file.kind === selectedKind);
const employees = snapshot.employees.filter((employee) => employee.orgId === orgId);
const agents = snapshot.agents.filter((agent) => agent.orgId === orgId);
const files = useMemo(() => snapshot.files.filter((file) => file.orgId === orgId), [snapshot.files, orgId]);
const [section, setSection] = useState<Section>({ kind: "all" });
const [view, setView] = useState<ViewMode>("list");
const departments = collectDepartments(agents);
const filesByAgent = collectAgentFiles(files, agents);
const visibleFiles = filterFiles(files, section, agents);
const pinnedFiles = files.filter((file) => file.pinned);
const totalBytes = visibleFiles.reduce((sum, file) => sum + file.sizeBytes, 0);
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={selectedKind === "all"} onClick={() => setSelectedKind("all")} />
{kinds.map((kind) => (
<TreeItem
key={kind}
icon="file"
label={formatKind(kind)}
count={files.filter((file) => file.kind === kind).length}
active={selectedKind === kind}
onClick={() => setSelectedKind(kind)}
/>
))}
</aside>
<div className="page">
<div style={{ display: "grid", gridTemplateColumns: "minmax(160px, 200px) minmax(0, 1fr)", gap: 24 }}>
<FilesTree
files={files}
pinnedFiles={pinnedFiles}
section={section}
departments={departments}
filesByAgent={filesByAgent}
onChange={setSection}
/>
<div style={{ minWidth: 0 }}>
<div className="row" style={{ marginBottom: 24 }}>
<div>
<h1 className="page-h1">Files</h1>
<p className="page-sub" style={{ marginBottom: 0 }}>{visibleFiles.length} files · {formatBytes(visibleFiles.reduce((sum, file) => sum + file.sizeBytes, 0))}</p>
<p className="page-sub" style={{ marginBottom: 0 }}>
{visibleFiles.length} {visibleFiles.length === 1 ? "file" : "files"} · {formatBytes(totalBytes)}
</p>
</div>
<div style={{ marginLeft: "auto" }}>
<div style={{ marginLeft: "auto", display: "flex", gap: 6, alignItems: "center" }}>
<Button tone="ghost"><Icon name="filter" size={13} /></Button>
<Segmented<ViewMode>
value={view}
onChange={setView}
options={[
{ id: "grid", label: "Grid" },
{ id: "list", label: "List" },
]}
/>
<Button tone="primary" onClick={() => void actions.run(() => window.agentCompany.importFile({ orgId }))}>
<Icon name="upload" /> Upload
<Icon name="upload" size={13} /> Upload
</Button>
</div>
</div>
<div className="section-label">{selectedKind === "all" ? "All files" : formatKind(selectedKind)}</div>
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
<div className="table-row" style={{ gridTemplateColumns: "3fr 1fr 1.4fr 1fr", background: "var(--panel-2)", color: "var(--text-3)", fontSize: 11 }}>
<div>Name</div><div>Size</div><div>Checksum</div><div>Added</div>
</div>
{visibleFiles.length === 0 ? (
<div style={{ padding: 18, color: "var(--text-3)" }}>No files</div>
) : visibleFiles.map((file) => <FileRow key={file.id} file={file} />)}
{pinnedFiles.length > 0 && section.kind === "all" && (
<>
<div className="section-label" style={{ marginBottom: 10 }}>Pinned</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))", gap: 12, marginBottom: 28 }}>
{pinnedFiles.slice(0, 8).map((file) => (
<PinnedCard
key={file.id}
file={file}
ownerName={resolveOwnerName(file, employees, agents)}
onUnpin={() => void actions.run(() => window.agentCompany.setFilePinned({ orgId, fileId: file.id, pinned: false }))}
/>
))}
</div>
</>
)}
<div className="row" style={{ marginBottom: 10 }}>
<div className="section-label">{labelFor(section)}</div>
<div style={{ marginLeft: "auto", fontSize: 11, color: "var(--text-3)" }}>Modified ↓</div>
</div>
{view === "list" ? (
<FilesTable
files={visibleFiles}
employees={employees}
agents={agents}
onTogglePin={(file) => void actions.run(() => window.agentCompany.setFilePinned({ orgId, fileId: file.id, pinned: !file.pinned }))}
/>
) : (
<FilesGrid
files={visibleFiles}
employees={employees}
agents={agents}
onTogglePin={(file) => void actions.run(() => window.agentCompany.setFilePinned({ orgId, fileId: file.id, pinned: !file.pinned }))}
/>
)}
</div>
</div>
);
}
function FilesTree({
files,
pinnedFiles,
section,
departments,
filesByAgent,
onChange,
}: {
files: FileObjectRecord[];
pinnedFiles: FileObjectRecord[];
section: Section;
departments: Array<{ name: string; count: number }>;
filesByAgent: Array<{ agent: AgentRecord; count: number }>;
onChange: (next: Section) => void;
}) {
const recent = files.filter((file) => isRecent(file.createdAt));
return (
<aside>
<div className="section-label">Library</div>
<TreeItem
icon="folder"
label="All files"
count={files.length}
active={section.kind === "all"}
onClick={() => onChange({ kind: "all" })}
/>
<TreeItem
icon="sparkle"
label="Recent"
count={recent.length}
active={section.kind === "recent"}
onClick={() => onChange({ kind: "recent" })}
/>
<TreeItem
icon="pin"
label="Pinned"
count={pinnedFiles.length}
active={section.kind === "pinned"}
onClick={() => onChange({ kind: "pinned" })}
/>
{departments.length > 0 && (
<>
<div className="section-label" style={{ marginTop: 22 }}>Spaces</div>
{departments.map((department) => (
<TreeItem
key={department.name}
icon="folder"
label={department.name}
count={department.count}
active={section.kind === "department" && section.value === department.name}
onClick={() => onChange({ kind: "department", value: department.name })}
/>
))}
</>
)}
{filesByAgent.length > 0 && (
<>
<div className="section-label" style={{ marginTop: 22 }}>By agent</div>
{filesByAgent.map(({ agent, count }) => (
<TreeItem
key={agent.id}
icon="bot"
label={agent.name}
count={count}
active={section.kind === "agent" && section.value === agent.id}
onClick={() => onChange({ kind: "agent", value: agent.id })}
/>
))}
</>
)}
</aside>
);
}
function TreeItem({
icon,
label,
count,
active,
onClick,
active = false,
}: {
icon: string;
label: string;
count?: number;
count: number;
active: boolean;
onClick: () => void;
active?: boolean;
}) {
return (
<button type="button" aria-label={label} className={`nav-item ${active ? "active" : ""}`} onClick={onClick}>
<Icon name={icon} />
<button type="button" className={`nav-item ${active ? "active" : ""}`.trim()} onClick={onClick}>
<Icon name={icon} size={14} />
<span>{label}</span>
{typeof count === "number" && <span className="nav-badge">{count}</span>}
<span className="nav-badge num">{count}</span>
</button>
);
}
function FileRow({ file }: { file: FileObjectRecord }) {
function FilesTable({
files,
employees,
agents,
onTogglePin,
}: {
files: FileObjectRecord[];
employees: EmployeeRecord[];
agents: AgentRecord[];
onTogglePin: (file: FileObjectRecord) => void;
}) {
return (
<div className="table-row" style={{ gridTemplateColumns: "3fr 1fr 1.4fr 1fr" }}>
<div className="row">
<FileIcon kind={file.kind} />
<span style={{ fontWeight: 480 }}>{file.name}</span>
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
<div
className="table-row head"
style={{ gridTemplateColumns: "minmax(0, 3fr) minmax(0, 1fr) minmax(0, 1.4fr) minmax(0, 1fr) 60px" }}
>
<div>Name</div><div>Size</div><div>Owner</div><div>Modified</div><div />
</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>
{files.length === 0 ? (
<div style={{ padding: 18, color: "var(--text-3)" }}>No files here yet.</div>
) : files.map((file, index) => (
<FileRow
key={file.id}
file={file}
owner={resolveOwnerName(file, employees, agents)}
last={index === files.length - 1}
onTogglePin={() => onTogglePin(file)}
/>
))}
</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" />;
function FilesGrid({
files,
employees,
agents,
onTogglePin,
}: {
files: FileObjectRecord[];
employees: EmployeeRecord[];
agents: AgentRecord[];
onTogglePin: (file: FileObjectRecord) => void;
}) {
if (files.length === 0) {
return <div className="card" style={{ color: "var(--text-3)" }}>No files here yet.</div>;
}
return (
<span
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))", gap: 12 }}>
{files.map((file) => (
<PinnedCard
key={file.id}
file={file}
ownerName={resolveOwnerName(file, employees, agents)}
onUnpin={() => onTogglePin(file)}
/>
))}
</div>
);
}
function FileRow({
file,
owner,
last,
onTogglePin,
}: {
file: FileObjectRecord;
owner: string;
last: boolean;
onTogglePin: () => void;
}) {
return (
<div
className="table-row"
style={{
width: 22,
height: 22,
display: "grid",
placeItems: "center",
borderRadius: 4,
background: `${colors[kind] ?? "#a1a1aa"}14`,
color: colors[kind] ?? "#52525b",
fontSize: 8,
fontWeight: 700,
gridTemplateColumns: "minmax(0, 3fr) minmax(0, 1fr) minmax(0, 1.4fr) minmax(0, 1fr) 60px",
borderBottom: last ? "none" : "1px solid var(--border-soft)",
}}
>
{kind.slice(0, 3).toUpperCase()}
<div className="row" style={{ gap: 10, minWidth: 0 }}>
<FileIcon kind={file.kind} />
<span style={{ fontWeight: 480, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{file.name}</span>
{file.pinned && <Pill tone="violet" dot>Pinned</Pill>}
</div>
<div className="num mono" style={{ color: "var(--text-3)", fontSize: 12 }}>{formatBytes(file.sizeBytes)}</div>
<div className="row" style={{ gap: 8, minWidth: 0 }}>
<Avatar name={owner} size="sm" bot={isAgentOwner(file)} />
<span style={{ color: "var(--text-2)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{owner}</span>
</div>
<div style={{ color: "var(--text-3)", fontSize: 12 }}>{formatRelative(file.updatedAt ?? file.createdAt)}</div>
<div style={{ textAlign: "right" }}>
<IconButton icon={file.pinned ? "pin" : "more"} label={file.pinned ? "Unpin" : "More"} onClick={onTogglePin} />
</div>
</div>
);
}
function PinnedCard({
file,
ownerName,
onUnpin,
}: {
file: FileObjectRecord;
ownerName: string;
onUnpin: () => void;
}) {
return (
<div
style={{
border: "1px solid var(--border)",
borderRadius: 10,
background: "var(--panel)",
padding: 14,
display: "flex",
flexDirection: "column",
gap: 10,
minWidth: 0,
}}
>
<div className="row" style={{ gap: 10 }}>
<FileIcon kind={file.kind} big />
<button
type="button"
aria-label={file.pinned ? "Unpin" : "Pin"}
onClick={onUnpin}
style={{ marginLeft: "auto", padding: 4, border: 0, background: "transparent", color: file.pinned ? "var(--accent)" : "var(--text-4)", cursor: "pointer" }}
>
<Icon name="pin" size={14} />
</button>
</div>
<div style={{ fontSize: 13, fontWeight: 540, letterSpacing: "-0.005em", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{file.name}
</div>
<div className="row" style={{ gap: 8, marginTop: "auto", minWidth: 0 }}>
<Avatar name={ownerName} size="sm" bot={isAgentOwner(file)} />
<span style={{ fontSize: 11, color: "var(--text-3)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{formatRelative(file.updatedAt ?? file.createdAt)} · {ownerName.split(" ")[0]}
</span>
</div>
</div>
);
}
function FileIcon({ kind, big = false }: { kind: string; big?: boolean }) {
const map = FILE_COLOR_MAP[kind] ?? FILE_COLOR_MAP.doc;
if (!map) throw new Error("File color map default missing.");
if (kind === "folder") {
return (
<div style={{ width: big ? 28 : 22, height: big ? 28 : 22, display: "grid", placeItems: "center", color: "var(--text-3)" }}>
<Icon name="folder" size={big ? 22 : 17} />
</div>
);
}
const label = map.label || kind.slice(0, 3).toUpperCase();
return (
<span
className="mono"
style={{
width: big ? 28 : 22,
height: big ? 28 : 22,
borderRadius: big ? 6 : 4,
background: `${map.color}14`,
color: map.color,
display: "grid",
placeItems: "center",
fontSize: big ? 9 : 8,
fontWeight: 700,
letterSpacing: 0.5,
}}
>
{label}
</span>
);
}
function formatKind(kind: string): string {
return kind.toUpperCase();
function collectDepartments(agents: AgentRecord[]): Array<{ name: string; count: number }> {
const seen = new Map<string, number>();
for (const agent of agents) {
if (!agent.department) continue;
seen.set(agent.department, (seen.get(agent.department) ?? 0) + 1);
}
return [...seen.entries()].map(([name, count]) => ({ name, count })).sort((a, b) => a.name.localeCompare(b.name));
}
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`;
function collectAgentFiles(files: FileObjectRecord[], agents: AgentRecord[]): Array<{ agent: AgentRecord; count: number }> {
const out: Array<{ agent: AgentRecord; count: number }> = [];
for (const agent of agents) {
const count = files.filter((file) => file.ownerType === "agent" && file.ownerId === agent.id).length;
if (count > 0) out.push({ agent, count });
}
return out.sort((a, b) => b.count - a.count);
}
function filterFiles(files: FileObjectRecord[], section: Section, agents: AgentRecord[]): FileObjectRecord[] {
if (section.kind === "all") return files;
if (section.kind === "pinned") return files.filter((file) => file.pinned);
if (section.kind === "recent") return files.filter((file) => isRecent(file.createdAt));
if (section.kind === "agent") return files.filter((file) => file.ownerType === "agent" && file.ownerId === section.value);
if (section.kind === "department") {
const departmentAgents = new Set(agents.filter((agent) => agent.department === section.value).map((agent) => agent.id));
return files.filter((file) => file.ownerType === "agent" && file.ownerId !== null && departmentAgents.has(file.ownerId));
}
return files;
}
function isRecent(iso: string): boolean {
const sevenDays = 7 * 24 * 60 * 60 * 1000;
return Date.now() - new Date(iso).getTime() <= sevenDays;
}
function isAgentOwner(file: FileObjectRecord): boolean {
return file.ownerType === "agent";
}
function resolveOwnerName(file: FileObjectRecord, employees: EmployeeRecord[], agents: AgentRecord[]): string {
if (file.ownerType === "employee" && file.ownerId) {
const employee = employees.find((item) => item.id === file.ownerId);
if (employee) return employee.displayName;
}
if (file.ownerType === "agent" && file.ownerId) {
const agent = agents.find((item) => item.id === file.ownerId);
if (agent) return agent.name;
}
return "System";
}
function labelFor(section: Section): string {
if (section.kind === "all") return "All files";
if (section.kind === "recent") return "Recent";
if (section.kind === "pinned") return "Pinned";
if (section.kind === "agent") return "By agent";
return section.value;
}
@@ -1,206 +1,352 @@
import { useMemo, useState } from "react";
import { Avatar, Button, Icon, Pill } from "../shared/ui";
import type { ApprovalRecord, TaskRecord } from "../../../shared/domain";
import type {
AgentRecord,
ApprovalRecord,
EmployeeRecord,
GoalRecord,
ProviderRecord,
TaskRecord,
} from "../../../shared/domain";
import { NewTaskDialog } from "./NewTaskDialog";
import type { PageProps } from "./types";
import { activeOrgId, money } from "./types";
import { activeOrgId, formatTime, money, requireActiveEmployee, requireActiveOrganization } from "./types";
export function HomePage({ snapshot, actions }: PageProps) {
const orgId = activeOrgId(snapshot);
const organization = snapshot.organizations.find((item) => item.id === orgId);
if (!organization) throw new Error("Active organization is missing.");
const employee = snapshot.employees.find((item) => item.orgId === orgId);
if (!employee) throw new Error("Active employee is missing.");
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 goals = snapshot.goals.filter((item) => item.orgId === orgId);
const pending = approvals.filter((item) => item.state === "pending");
const inProgress = tasks.filter((item) => item.status === "in_progress");
const organization = requireActiveOrganization(snapshot);
const employee = requireActiveEmployee(snapshot);
const tasks = useMemo(() => snapshot.tasks.filter((task) => task.orgId === orgId), [snapshot.tasks, orgId]);
const agents = useMemo(() => snapshot.agents.filter((agent) => agent.orgId === orgId), [snapshot.agents, orgId]);
const approvals = useMemo(() => snapshot.approvals.filter((item) => item.orgId === orgId), [snapshot.approvals, orgId]);
const goals = useMemo(() => snapshot.goals.filter((item) => item.orgId === orgId), [snapshot.goals, orgId]);
const providers = useMemo(
() => snapshot.providers.filter((provider) => provider.orgId === orgId),
[snapshot.providers, orgId],
);
const [newTaskOpen, setNewTaskOpen] = useState(false);
const pendingApprovals = approvals.filter((approval) => approval.state === "pending");
const inProgressTasks = tasks.filter((task) => task.status === "in_progress");
const today = new Date();
const agendaTasks = collectAgendaTasks(tasks, today);
const totalBudgetCents = agents.reduce((sum, agent) => sum + agent.budgetCents, 0);
const primaryGoal = goals[0] ?? null;
const greeting = greetingByHour(new Date().getHours());
const firstName = employee.displayName.split(" ")[0];
const primaryGoal = goals.find((goal) => goal.status === "active") ?? goals[0] ?? null;
const goalProgress = computeGoalProgress(primaryGoal, tasks);
const firstName = employee.displayName.split(/\s+/)[0] ?? employee.displayName;
const greeting = greetingFor(today.getHours());
const summaryText = renderSummary(pendingApprovals.length, agents.length);
return (
<div className="page">
<div className="row" style={{ marginBottom: 24 }}>
<div>
<h1 className="page-h1">{greeting}, {firstName}</h1>
<p className="page-sub" style={{ marginBottom: 0 }}>
{pending.length} {pending.length === 1 ? "thing" : "things"} waiting on you · {agents.length} {agents.length === 1 ? "agent" : "agents"} on deck
</p>
<p className="page-sub" style={{ marginBottom: 0 }}>{summaryText}</p>
</div>
<div style={{ marginLeft: "auto", display: "flex", gap: 6 }}>
<Button tone="ghost" onClick={() => void actions.reload()}><Icon name="refresh" /></Button>
<Button tone="primary" onClick={() => void 0}><Icon name="plus" /> New task</Button>
<Button tone="ghost" onClick={() => void actions.reload()} title="Refresh"><Icon name="refresh" size={13} /></Button>
<Button tone="primary" onClick={() => setNewTaskOpen(true)}><Icon name="plus" size={13} /> New task</Button>
</div>
</div>
<div className="grid-3" style={{ marginBottom: 12 }}>
<TodayCard tasks={inProgress} agents={agents} />
<WaitingCard approvals={pending} onApprove={(id) => {
const emp = employee;
void actions.run(() => window.agentCompany.decideApproval({ orgId, approvalId: id, state: "approved", decidedByEmployeeId: emp.id }));
}} />
<SpendCard providers={providers} totalBudgetCents={totalBudgetCents} />
<div style={{ display: "grid", gridTemplateColumns: "minmax(0, 1.4fr) minmax(0, 1fr) minmax(0, 1fr)", gap: 12, marginBottom: 12 }}>
<TodayCard date={today} tasks={agendaTasks} agents={agents} employee={employee} approvals={pendingApprovals} />
<WaitingCard
approvals={pendingApprovals}
agents={agents}
employees={[employee]}
onApprove={(approvalId) => void actions.run(() => window.agentCompany.decideApproval({
orgId,
approvalId,
state: "approved",
decidedByEmployeeId: employee.id,
}))}
/>
<SpendCard providers={providers} totalBudgetCents={totalBudgetCents} agents={agents} />
</div>
<div className="grid-3">
<InProgressCard tasks={inProgress} agents={agents} />
<div style={{ display: "grid", gridTemplateColumns: "minmax(0, 1.4fr) minmax(0, 1fr) minmax(0, 1fr)", gap: 12 }}>
<InProgressCard tasks={inProgressTasks} agents={agents} />
<TeamCard agents={agents} employee={employee} />
<GoalCard goal={primaryGoal} totalTasks={tasks.length} doneTasks={tasks.filter((t) => t.status === "done").length} />
<GoalCard goal={primaryGoal} progress={goalProgress} organizationName={organization.name} />
</div>
<NewTaskDialog open={newTaskOpen} orgId={orgId} snapshot={snapshot} actions={actions} onClose={() => setNewTaskOpen(false)} />
</div>
);
}
function TodayCard({ tasks, agents }: { tasks: TaskRecord[]; agents: Array<{ id: string; name: string }> }) {
const today = new Date().toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" });
function TodayCard({
date,
tasks,
agents,
employee,
approvals,
}: {
date: Date;
tasks: TaskRecord[];
agents: AgentRecord[];
employee: EmployeeRecord;
approvals: ApprovalRecord[];
}) {
const formatted = date.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" });
const rows = tasks.slice(0, 4);
return (
<div className="card" style={{ padding: 18 }}>
<section className="card" style={{ padding: 18 }}>
<div className="row" style={{ marginBottom: 14 }}>
<div className="card-label">Today</div>
<div style={{ marginLeft: "auto", fontSize: 11, color: "var(--text-3)" }}>{today}</div>
<span className="card-label">Today</span>
<span style={{ marginLeft: "auto", fontSize: 11, color: "var(--text-3)" }}>{formatted}</span>
</div>
{tasks.length === 0 ? (
<div style={{ color: "var(--text-3)", padding: "6px 0" }}>No active work</div>
) : tasks.slice(0, 4).map((task) => {
const agent = agents.find((a) => a.id === task.assigneeAgentId);
return (
<div key={task.id} className="row" style={{ gap: 12, padding: "9px 0", borderBottom: "1px solid var(--border-soft)" }}>
<span className="num" style={{ width: 36, fontSize: 12, color: "var(--text-3)" }}>—</span>
<div style={{ flex: 1 }}>
<span style={{ fontWeight: 500 }}>{task.title}</span>
{agent && <span style={{ color: "var(--text-3)", marginLeft: 6 }}>· {agent.name}</span>}
</div>
{task.risk === "high" && <Pill tone="amber">High risk</Pill>}
</div>
);
})}
</div>
{rows.length === 0 ? (
<EmptyLine>No items on today's agenda.</EmptyLine>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
{rows.map((task) => {
const owner = agents.find((agent) => agent.id === task.assigneeAgentId);
const ownerName = owner?.name ?? employee.displayName;
const time = formatTime(task.updatedAt);
const requiresApproval = approvals.some((approval) => approval.title === task.title);
return (
<div key={task.id} className="row" style={{ gap: 12 }}>
<span className="num mono" style={{ width: 36, fontSize: 12, color: "var(--text-3)" }}>{time}</span>
<div style={{ flex: 1, minWidth: 0, fontSize: 13 }}>
<span style={{ fontWeight: 500 }}>{task.title}</span>
<span style={{ color: "var(--text-3)", marginLeft: 6 }}>· {ownerName}</span>
</div>
{requiresApproval && <Pill tone="amber">You</Pill>}
</div>
);
})}
</div>
)}
</section>
);
}
function WaitingCard({ approvals, onApprove }: { approvals: ApprovalRecord[]; onApprove: (id: string) => void }) {
function WaitingCard({
approvals,
agents,
employees,
onApprove,
}: {
approvals: ApprovalRecord[];
agents: AgentRecord[];
employees: EmployeeRecord[];
onApprove: (approvalId: string) => void;
}) {
return (
<div className="card" style={{ padding: 18 }}>
<section className="card" style={{ padding: 18 }}>
<div className="row" style={{ marginBottom: 14 }}>
<div className="card-label">Waiting on you</div>
<span className="card-label">Waiting on you</span>
{approvals.length > 0 && <span style={{ marginLeft: "auto" }}><Pill tone="amber">{approvals.length}</Pill></span>}
</div>
{approvals.length === 0 ? (
<div style={{ color: "var(--text-3)", padding: "6px 0" }}>Nothing pending</div>
) : approvals.slice(0, 2).map((approval) => (
<div key={approval.id} style={{ padding: 12, border: "1px solid var(--border)", borderRadius: 8, marginBottom: 8 }}>
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 2 }}>{approval.title}</div>
<div style={{ fontSize: 12, color: "var(--text-3)", marginBottom: 10 }}>
{approval.risk} risk · {approval.kind}
</div>
<div className="row" style={{ gap: 6 }}>
<Button tone="primary" size="small" onClick={() => onApprove(approval.id)}>Approve</Button>
<Button size="small">Open</Button>
</div>
</div>
))}
</div>
);
}
function SpendCard({ providers, totalBudgetCents }: { providers: Array<{ id: string; label: string; budgetCents?: number }>; totalBudgetCents: number }) {
const topProviders = providers.slice(0, 3);
const budgetLabel = money(totalBudgetCents);
return (
<div className="card" style={{ padding: 18 }}>
<div className="card-label" style={{ marginBottom: 14 }}>Monthly budget</div>
<div className="row" style={{ alignItems: "baseline", gap: 6 }}>
<div className="num" style={{ fontSize: 28, fontWeight: 600, letterSpacing: "-0.02em" }}>{budgetLabel}</div>
<div style={{ fontSize: 12, color: "var(--text-3)" }}>allocated</div>
</div>
<div style={{ marginTop: 14, height: 4, background: "var(--panel-3)", borderRadius: 2, overflow: "hidden" }}>
<div style={{ width: totalBudgetCents > 0 ? "100%" : "0%", height: "100%", background: "var(--text-1)" }} />
</div>
{topProviders.length === 0 ? (
<div style={{ marginTop: 14, fontSize: 12, color: "var(--text-3)" }}>No providers</div>
<EmptyLine>Nothing pending.</EmptyLine>
) : (
<div style={{ marginTop: 14, display: "flex", flexDirection: "column", gap: 8 }}>
{topProviders.map((provider) => (
<div key={provider.id} className="row" style={{ fontSize: 12 }}>
<span style={{ color: "var(--text-2)" }}>{provider.label}</span>
<span className="num" style={{ marginLeft: "auto", color: "var(--text-1)", fontWeight: 500 }}>—</span>
</div>
))}
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
{approvals.slice(0, 2).map((approval) => {
const from = resolveRequester(approval, employees, agents);
return (
<div key={approval.id} style={{ padding: 12, border: "1px solid var(--border)", borderRadius: 8 }}>
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 2 }}>{approval.title}</div>
<div style={{ fontSize: 12, color: "var(--text-3)", marginBottom: 10 }}>
{approval.kind} · {approval.risk} risk · from {from}
</div>
<div className="row" style={{ gap: 6 }}>
<Button tone="primary" size="small" onClick={() => onApprove(approval.id)}>Approve</Button>
<Button size="small">Open</Button>
</div>
</div>
);
})}
</div>
)}
</div>
</section>
);
}
function InProgressCard({ tasks, agents }: { tasks: TaskRecord[]; agents: Array<{ id: string; name: string; department: string }> }) {
function SpendCard({
providers,
totalBudgetCents,
agents,
}: {
providers: ProviderRecord[];
totalBudgetCents: number;
agents: AgentRecord[];
}) {
const connected = providers.filter((provider) => provider.enabled);
const cap = totalBudgetCents > 0 ? totalBudgetCents * 4 : 0;
const fillPct = cap > 0 ? Math.min(100, Math.round((totalBudgetCents / cap) * 100)) : 0;
return (
<div className="card" style={{ padding: 18 }}>
<div className="row" style={{ marginBottom: 14 }}>
<div className="card-label">In progress</div>
<div style={{ marginLeft: "auto", fontSize: 11, color: "var(--text-3)", cursor: "pointer" }}>All work →</div>
<section className="card" style={{ padding: 18 }}>
<div className="card-label" style={{ marginBottom: 14 }}>This month</div>
<div className="row" style={{ alignItems: "baseline", gap: 6 }}>
<span className="num" style={{ fontSize: 28, fontWeight: 600, letterSpacing: "-0.02em" }}>{money(totalBudgetCents)}</span>
<span style={{ fontSize: 12, color: "var(--text-3)" }}>{cap > 0 ? `/ ${money(cap)}` : "allocated"}</span>
</div>
{tasks.length === 0 ? (
<div style={{ color: "var(--text-3)", padding: "6px 0" }}>No active work</div>
) : tasks.slice(0, 4).map((task, index) => {
const agent = agents.find((a) => a.id === task.assigneeAgentId);
const isLast = index === tasks.slice(0, 4).length - 1;
<div style={{ marginTop: 14, height: 4, background: "var(--panel-3)", borderRadius: 2, overflow: "hidden" }}>
<div style={{ width: `${fillPct}%`, height: "100%", background: "var(--text-1)" }} />
</div>
{connected.length === 0 ? (
<EmptyLine style={{ marginTop: 14 }}>No providers connected.</EmptyLine>
) : (
<div style={{ marginTop: 14, display: "flex", flexDirection: "column", gap: 8 }}>
{connected.slice(0, 3).map((provider) => {
const usingAgents = agents.filter((agent) => agent.modelProviderId === provider.id).length;
return (
<div key={provider.id} className="row" style={{ fontSize: 12 }}>
<span style={{ color: "var(--text-2)" }}>{provider.label}</span>
<span className="num mono" style={{ marginLeft: "auto", color: "var(--text-1)", fontWeight: 500 }}>
{usingAgents} {usingAgents === 1 ? "agent" : "agents"}
</span>
</div>
);
})}
</div>
)}
</section>
);
}
function InProgressCard({ tasks, agents }: { tasks: TaskRecord[]; agents: AgentRecord[] }) {
const rows = tasks.slice(0, 4);
return (
<section className="card" style={{ padding: 18 }}>
<div className="row" style={{ marginBottom: 14 }}>
<span className="card-label">In progress</span>
<span style={{ marginLeft: "auto", fontSize: 11, color: "var(--text-3)" }}>{tasks.length} active</span>
</div>
{rows.length === 0 ? (
<EmptyLine>No tasks in progress.</EmptyLine>
) : rows.map((task, index) => {
const owner = agents.find((agent) => agent.id === task.assigneeAgentId);
const last = index === rows.length - 1;
return (
<div key={task.id} className="row" style={{ gap: 10, padding: "10px 0", borderBottom: isLast ? "none" : "1px solid var(--border-soft)" }}>
<div key={task.id} className="row" style={{ gap: 10, padding: "10px 0", borderBottom: last ? "none" : "1px solid var(--border-soft)" }}>
<span style={{ width: 5, height: 5, borderRadius: "50%", background: "var(--text-4)", flexShrink: 0 }} />
<div style={{ flex: 1, fontSize: 13, fontWeight: 480 }}>{task.title}</div>
{agent && <Pill>{agent.department || "Agent"}</Pill>}
{agent && <Avatar name={agent.name} size="sm" bot />}
<div style={{ flex: 1, minWidth: 0, fontSize: 13, fontWeight: 480, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{task.title}</div>
{owner && <Pill>{owner.department}</Pill>}
{owner && <Avatar name={owner.name} size="sm" bot />}
</div>
);
})}
</div>
</section>
);
}
function TeamCard({ agents, employee }: { agents: Array<{ id: string; name: string; role: string }>; employee: { displayName: string; role: string } }) {
function TeamCard({ agents, employee }: { agents: AgentRecord[]; employee: EmployeeRecord }) {
return (
<div className="card" style={{ padding: 18 }}>
<section className="card" style={{ padding: 18 }}>
<div className="card-label" style={{ marginBottom: 14 }}>Team</div>
{agents.slice(0, 4).map((agent) => (
<div key={agent.id} className="row" style={{ gap: 10, padding: "7px 0" }}>
<Avatar name={agent.name} size="sm" bot />
<div style={{ flex: 1, fontSize: 13, fontWeight: 500 }}>{agent.name}</div>
<div style={{ fontSize: 11, color: "var(--text-3)" }}>{agent.role}</div>
<span style={{ flex: 1, fontSize: 13, fontWeight: 500, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{agent.name}</span>
<span style={{ fontSize: 11, color: "var(--text-3)" }}>{agent.role}</span>
</div>
))}
<div className="row" style={{ gap: 10, padding: "7px 0" }}>
<Avatar name={employee.displayName} size="sm" />
<div style={{ flex: 1, fontSize: 13, fontWeight: 500 }}>{employee.displayName}</div>
<div style={{ fontSize: 11, color: "var(--text-3)" }}>{employee.role} · human</div>
<span style={{ flex: 1, fontSize: 13, fontWeight: 500 }}>{employee.displayName}</span>
<span style={{ fontSize: 11, color: "var(--text-3)" }}>{employee.role} · human</span>
</div>
</div>
</section>
);
}
function GoalCard({ goal, totalTasks, doneTasks }: { goal: { title: string; description: string } | null; totalTasks: number; doneTasks: number }) {
const pct = totalTasks > 0 ? Math.round((doneTasks / totalTasks) * 100) : 0;
function GoalCard({
goal,
progress,
organizationName,
}: {
goal: GoalRecord | null;
progress: { percent: number; status: "ahead" | "on_track" | "behind" } | null;
organizationName: string;
}) {
return (
<div className="card" style={{ padding: 18, background: "linear-gradient(180deg, #0a0a0b 0%, #18181b 100%)", color: "#fff", border: "1px solid #18181b" }}>
<div style={{ fontSize: 11, color: "rgba(255,255,255,0.5)", fontWeight: 540, letterSpacing: "0.02em", marginBottom: 12 }}>Quarter goal</div>
<section
className="card"
style={{
padding: 18,
background: "linear-gradient(180deg, #0a0a0b 0%, #18181b 100%)",
color: "#fff",
border: "1px solid #18181b",
}}
>
<div style={{ fontSize: 11, color: "rgba(255,255,255,0.5)", fontWeight: 540, letterSpacing: "0.02em", marginBottom: 12 }}>
Quarter goal
</div>
<div style={{ fontSize: 15, fontWeight: 540, lineHeight: 1.35, marginBottom: 18, letterSpacing: "-0.01em" }}>
{goal ? goal.title : "No active goal"}
{goal ? goal.title : `Set the first goal for ${organizationName}.`}
</div>
<div className="row" style={{ alignItems: "baseline", gap: 8 }}>
<div className="num" style={{ fontSize: 24, fontWeight: 600 }}>{pct}%</div>
<div style={{ fontSize: 12, color: "rgba(255,255,255,0.5)" }}>complete</div>
<span className="num" style={{ fontSize: 24, fontWeight: 600 }}>{progress ? `${progress.percent}%` : "—"}</span>
<span style={{ fontSize: 12, color: "rgba(255,255,255,0.5)" }}>
{progress ? statusLabel(progress.status) : "no work yet"}
</span>
</div>
<div style={{ marginTop: 10, height: 3, background: "rgba(255,255,255,0.12)", borderRadius: 2, overflow: "hidden" }}>
<div style={{ width: `${pct}%`, height: "100%", background: "#fff" }} />
<div style={{ width: `${progress?.percent ?? 0}%`, height: "100%", background: "#fff" }} />
</div>
</div>
</section>
);
}
function greetingByHour(hour: number): string {
function EmptyLine({ children, style }: { children: string; style?: React.CSSProperties }) {
return (
<div style={{ color: "var(--text-3)", fontSize: 12.5, padding: "4px 0", ...style }}>{children}</div>
);
}
function collectAgendaTasks(tasks: TaskRecord[], reference: Date): TaskRecord[] {
const startOfDay = new Date(reference.getFullYear(), reference.getMonth(), reference.getDate()).getTime();
const endOfDay = startOfDay + 24 * 60 * 60 * 1000;
const todays = tasks.filter((task) => {
const updated = new Date(task.updatedAt).getTime();
return updated >= startOfDay && updated < endOfDay && task.status !== "done";
});
if (todays.length > 0) return todays.sort((a, b) => a.updatedAt.localeCompare(b.updatedAt));
return tasks.filter((task) => task.status !== "done").slice(0, 4);
}
function computeGoalProgress(goal: GoalRecord | null, tasks: TaskRecord[]): { percent: number; status: "ahead" | "on_track" | "behind" } | null {
if (!goal) return null;
const scoped = tasks.filter((task) => task.goalId === goal.id);
if (scoped.length === 0) return { percent: 0, status: "on_track" };
const done = scoped.filter((task) => task.status === "done").length;
const percent = Math.round((done / scoped.length) * 100);
if (percent >= 75) return { percent, status: "ahead" };
if (percent >= 30) return { percent, status: "on_track" };
return { percent, status: "behind" };
}
function statusLabel(status: "ahead" | "on_track" | "behind"): string {
if (status === "ahead") return "ahead";
if (status === "behind") return "behind";
return "on track";
}
function greetingFor(hour: number): string {
if (hour < 12) return "Good morning";
if (hour < 17) return "Good afternoon";
return "Good evening";
}
function renderSummary(pending: number, agents: number): string {
const things = `${pending} ${pending === 1 ? "thing" : "things"} waiting on you`;
const onDeck = `${agents} ${agents === 1 ? "agent" : "agents"} on deck`;
return `${things} · ${onDeck}`;
}
function resolveRequester(approval: ApprovalRecord, employees: EmployeeRecord[], agents: AgentRecord[]): string {
if (approval.requestedByType === "employee" && approval.requestedById) {
const employee = employees.find((item) => item.id === approval.requestedById);
if (employee) return employee.displayName;
}
if (approval.requestedByType === "agent" && approval.requestedById) {
const agent = agents.find((item) => item.id === approval.requestedById);
if (agent) return agent.name;
}
return "System";
}
@@ -0,0 +1,109 @@
import type { FormEvent } from "react";
import { Dialog } from "../shared/Dialog";
import { Field } from "../shared/ui";
import type { AppSnapshot, RiskLevel, TaskPriority } from "../../../shared/domain";
import { RISK_LEVEL, TASK_PRIORITY, TASK_STATUS } from "../../../shared/domain";
import type { SnapshotActions } from "../app/useSnapshot";
const RISK_OPTIONS: ReadonlyArray<{ value: RiskLevel; label: string }> = [
{ value: RISK_LEVEL.LOW, label: "Low" },
{ value: RISK_LEVEL.MEDIUM, label: "Medium" },
{ value: RISK_LEVEL.HIGH, label: "High" },
];
const PRIORITY_OPTIONS: ReadonlyArray<{ value: TaskPriority; label: string }> = [
{ value: TASK_PRIORITY.LOW, label: "Low" },
{ value: TASK_PRIORITY.NORMAL, label: "Normal" },
{ value: TASK_PRIORITY.HIGH, label: "High" },
];
function value(form: FormData, key: string): string {
return String(form.get(key) ?? "").trim();
}
function optional(form: FormData, key: string): string | null {
return value(form, key) || null;
}
export function NewTaskDialog({
open,
onClose,
orgId,
snapshot,
actions,
}: {
open: boolean;
onClose: () => void;
orgId: string;
snapshot: AppSnapshot;
actions: SnapshotActions;
}) {
const agents = snapshot.agents.filter((item) => item.orgId === orgId);
const projects = snapshot.projects.filter((item) => item.orgId === orgId);
const goals = snapshot.goals.filter((item) => item.orgId === orgId);
const workspaces = snapshot.workspaces.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: optional(form, "assigneeAgentId"),
risk: value(form, "risk") as RiskLevel,
projectId: optional(form, "projectId"),
goalId: optional(form, "goalId"),
workspaceId: optional(form, "workspaceId"),
priority: value(form, "priority") as TaskPriority,
requiresFilesystem: form.get("requiresFilesystem") === "on",
})).then((result) => {
if (result) onClose();
});
};
return (
<Dialog title="New task" open={open} submitLabel="Create" onClose={onClose} onSubmit={submit}>
<Field label="Title"><input className="input" name="title" required autoFocus /></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="Project">
<select className="input" name="projectId">
<option value="">None</option>
{projects.map((project) => <option key={project.id} value={project.id}>{project.name}</option>)}
</select>
</Field>
<Field label="Goal">
<select className="input" name="goalId">
<option value="">None</option>
{goals.map((goal) => <option key={goal.id} value={goal.id}>{goal.title}</option>)}
</select>
</Field>
<Field label="Workspace">
<select className="input" name="workspaceId">
<option value="">None</option>
{workspaces.map((workspace) => <option key={workspace.id} value={workspace.id}>{workspace.name}</option>)}
</select>
</Field>
<Field label="Risk">
<select className="input" name="risk" defaultValue={RISK_LEVEL.LOW}>
{RISK_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</Field>
<Field label="Priority">
<select className="input" name="priority" defaultValue={TASK_PRIORITY.NORMAL}>
{PRIORITY_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</Field>
<label className="checkbox-row">
<input type="checkbox" name="requiresFilesystem" /> Requires workspace access
</label>
</Dialog>
);
}
@@ -1,341 +1,158 @@
import { useState, type FormEvent } from "react";
import { Button, Field, Icon, Pill } from "../shared/ui";
import { Dialog } from "../shared/Dialog";
import type { MigrationReportRecord, PolicyRuleRecord, ProviderRecord, SkillDefinitionRecord } from "../../../shared/domain";
import { useMemo, useState } from "react";
import { ProvidersPanel } from "./settings/ProvidersPanel";
import { SkillsPanel } from "./settings/SkillsPanel";
import { WorkspacePanel } from "./settings/WorkspacePanel";
import { MembersPanel } from "./settings/MembersPanel";
import { BillingPanel } from "./settings/BillingPanel";
import { ModelsPanel } from "./settings/ModelsPanel";
import { PoliciesPanel } from "./settings/PoliciesPanel";
import { MigrationPanel } from "./settings/MigrationPanel";
import { NotificationsPanel, ProfilePanel, SecurityPanel } from "./settings/AccountPanels";
import type { PageProps } from "./types";
import { activeOrgId } from "./types";
import { activeOrgId, requireActiveEmployee, requireActiveOrganization } from "./types";
type SettingsTab = "providers" | "skills" | "policies" | "migration";
type SettingsTab =
| "workspace"
| "members"
| "billing"
| "models"
| "providers"
| "skills"
| "policies"
| "migration"
| "profile"
| "notifications"
| "security";
function value(form: FormData, key: string): string {
return String(form.get(key) ?? "").trim();
interface NavSpec {
id: SettingsTab;
label: string;
}
const WORKSPACE_GROUP: NavSpec[] = [
{ id: "workspace", label: "Workspace" },
{ id: "members", label: "Members" },
{ id: "billing", label: "Billing" },
];
const AGENTS_GROUP: NavSpec[] = [
{ id: "models", label: "Models" },
{ id: "providers", label: "Providers" },
{ id: "skills", label: "Skills" },
{ id: "policies", label: "Policies" },
{ id: "migration", label: "Migration" },
];
const ACCOUNT_GROUP: NavSpec[] = [
{ id: "profile", label: "Profile" },
{ id: "notifications", label: "Notifications" },
{ id: "security", label: "Security" },
];
const TAB_TITLES: Record<SettingsTab, string> = {
workspace: "Workspace",
members: "Members",
billing: "Billing",
models: "Models",
providers: "Providers",
skills: "Skills",
policies: "Policies",
migration: "Migration",
profile: "Profile",
notifications: "Notifications",
security: "Security",
};
export function SettingsPage({ snapshot, actions }: PageProps) {
const orgId = activeOrgId(snapshot);
const organization = requireActiveOrganization(snapshot);
const employee = requireActiveEmployee(snapshot);
const [tab, setTab] = useState<SettingsTab>("providers");
const [providerDialog, setProviderDialog] = useState<ProviderRecord | null>(null);
const [skillOpen, setSkillOpen] = useState(false);
const [migrationOpen, setMigrationOpen] = 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 policies = snapshot.policyRules.filter((item) => item.orgId === orgId);
const reports = snapshot.migrationReports.filter((item) => item.orgId === orgId);
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"),
})).then(() => setSkillOpen(false));
};
const createMigrationReport = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
void actions.run(() => window.agentCompany.createMigrationReport({
orgId,
sourceDatabasePath: value(form, "sourceDatabasePath"),
})).then(() => setMigrationOpen(false));
};
const employees = useMemo(() => snapshot.employees.filter((item) => item.orgId === orgId), [snapshot.employees, orgId]);
const agents = useMemo(() => snapshot.agents.filter((item) => item.orgId === orgId), [snapshot.agents, orgId]);
const providers = useMemo(() => snapshot.providers.filter((item) => item.orgId === orgId), [snapshot.providers, orgId]);
const skills = useMemo(() => snapshot.skillDefinitions.filter((item) => item.orgId === orgId), [snapshot.skillDefinitions, orgId]);
const skillRevisions = useMemo(() => {
const skillIds = new Set(skills.map((skill) => skill.id));
return snapshot.skillRevisions.filter((revision) => skillIds.has(revision.skillId));
}, [snapshot.skillRevisions, skills]);
const skillBindings = useMemo(() => snapshot.skillBindings.filter((item) => item.orgId === orgId), [snapshot.skillBindings, orgId]);
const policies = useMemo(() => snapshot.policyRules.filter((item) => item.orgId === orgId), [snapshot.policyRules, orgId]);
const workspaces = useMemo(() => snapshot.workspaces.filter((item) => item.orgId === orgId), [snapshot.workspaces, orgId]);
const providerRequests = useMemo(() => snapshot.providerRequests.filter((item) => item.orgId === orgId), [snapshot.providerRequests, orgId]);
const auditEvents = useMemo(() => snapshot.auditEvents.filter((event) => event.orgId === orgId), [snapshot.auditEvents, orgId]);
const migrationReports = useMemo(() => snapshot.migrationReports.filter((report) => report.orgId === orgId), [snapshot.migrationReports, orgId]);
return (
<div className="split-main" style={{ height: "calc(100vh - 112px)", margin: "-28px -32px -40px" }}>
<div className="split-main" style={{ height: "calc(100vh - 44px)", margin: "-28px -32px -40px" }}>
<aside className="secondary-pane">
<div style={{ fontWeight: 600, padding: "4px 8px 14px" }}>Settings</div>
<div className="section-label" style={{ padding: "0 8px", marginBottom: 4, marginTop: 8 }}>Agents</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")} />
<div style={{ fontSize: 13, fontWeight: 600, padding: "4px 8px 14px" }}>Settings</div>
<NavGroup items={WORKSPACE_GROUP} active={tab} onChange={setTab} />
<NavGroupLabel>Agents</NavGroupLabel>
<NavGroup items={AGENTS_GROUP} active={tab} onChange={setTab} />
<NavGroupLabel>Account</NavGroupLabel>
<NavGroup items={ACCOUNT_GROUP} active={tab} onChange={setTab} />
</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 policies={policies} />}
{tab === "migration" && <MigrationPanel reports={reports} onCreate={() => setMigrationOpen(true)} />}
<div className="main" style={{ flex: 1, minWidth: 0 }}>
<header className="topbar" style={{ borderBottom: "1px solid var(--border-soft)" }}>
<span className="crumb-sub">Settings</span>
<span className="crumb-sep">/</span>
<span className="crumb">{TAB_TITLES[tab]}</span>
</header>
<div className="content">
{tab === "workspace" && <WorkspacePanel organization={organization} workspaces={workspaces} />}
{tab === "members" && <MembersPanel employees={employees} agents={agents} />}
{tab === "billing" && <BillingPanel agents={agents} providers={providers} providerRequests={providerRequests} />}
{tab === "models" && <ModelsPanel providers={providers} agents={agents} />}
{tab === "providers" && <ProvidersPanel orgId={orgId} providers={providers} agents={agents} actions={actions} />}
{tab === "skills" && (
<SkillsPanel
orgId={orgId}
skills={skills}
revisions={skillRevisions}
bindings={skillBindings}
agents={agents}
actions={actions}
/>
)}
{tab === "policies" && <PoliciesPanel policies={policies} />}
{tab === "migration" && <MigrationPanel orgId={orgId} reports={migrationReports} actions={actions} />}
{tab === "profile" && <ProfilePanel employee={employee} />}
{tab === "notifications" && <NotificationsPanel events={auditEvents} employee={employee} />}
{tab === "security" && <SecurityPanel events={auditEvents} />}
</div>
</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" required /></Field>
</Dialog>
<Dialog title="Migration report" open={migrationOpen} submitLabel="Create" onClose={() => setMigrationOpen(false)} onSubmit={createMigrationReport}>
<Field label="Source database path"><input className="input" name="sourceDatabasePath" required /></Field>
</Dialog>
</div>
);
}
function SettingsNav({ label, active, onClick }: { label: string; active: boolean; onClick: () => void }) {
return <button type="button" aria-label={label} className={`nav-item ${active ? "active" : ""}`} onClick={onClick}>{label}</button>;
}
function ProvidersPanel({ providers, onManage }: { providers: ProviderRecord[]; onManage: (provider: ProviderRecord) => void }) {
const connected = providers.filter((p) => p.enabled);
const available = providers.filter((p) => !p.enabled);
return (
<div className="page">
<h1 className="page-h1">Providers</h1>
<p className="page-sub" style={{ marginBottom: 0 }}>Connect model providers — keys, accounts, OAuth.</p>
{connected.length > 0 && (
<>
<div className="section-label" style={{ marginTop: 24, marginBottom: 10 }}>Connected</div>
<div style={{ display: "flex", flexDirection: "column", gap: 8, marginBottom: 28 }}>
{connected.map((provider) => (
<ProviderConnectedRow key={provider.id} provider={provider} onManage={() => onManage(provider)} />
))}
</div>
</>
)}
{available.length > 0 && (
<>
<div className="section-label" style={{ marginTop: connected.length > 0 ? 0 : 24, marginBottom: 10 }}>Available</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(2, 1fr)", gap: 10 }}>
{available.map((provider) => (
<ProviderAvailableCard key={provider.id} provider={provider} onConnect={() => onManage(provider)} />
))}
</div>
</>
)}
</div>
);
}
function ProviderConnectedRow({ provider, onManage }: { provider: ProviderRecord; onManage: () => void }) {
return (
<div 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>
<Button onClick={onManage}>Manage</Button>
</div>
);
}
function ProviderAvailableCard({ provider, onConnect }: { provider: ProviderRecord; onConnect: () => void }) {
return (
<div className="card row">
<ProviderLogo label={provider.label} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 540, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{provider.label}</div>
<div style={{ fontSize: 12, color: "var(--text-3)" }}>{provider.authType.replaceAll("_", " ")}</div>
</div>
<Button size="small" onClick={onConnect}>Connect</Button>
</div>
);
}
function SkillsPanel({
skills,
agents,
revisions,
onImport,
onBind,
function NavGroup({
items,
active,
onChange,
}: {
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;
items: NavSpec[];
active: SettingsTab;
onChange: (next: SettingsTab) => 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 = requireRevision(skill, revisions);
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}</Pill>
{agents.length === 0 ? (
<Pill>No agents</Pill>
) : (
<select
className="input"
defaultValue=""
onChange={(event) => {
if (event.target.value) onBind(event.target.value, skill, revision.id);
event.target.value = "";
}}
>
<option value="">Agent</option>
{agents.map((agent) => <option key={agent.id} value={agent.id}>{agent.name}</option>)}
</select>
)}
</div>
);
})}
</div>
<div className="nav" style={{ gap: 1 }}>
{items.map((item) => (
<button
key={item.id}
type="button"
aria-label={item.label}
className={`nav-item ${item.id === active ? "active" : ""}`.trim()}
onClick={() => onChange(item.id)}
>
<span>{item.label}</span>
</button>
))}
</div>
);
}
function PolicyPanel({ policies }: { policies: PolicyRuleRecord[] }) {
function NavGroupLabel({ children }: { children: string }) {
return (
<div className="page">
<h1 className="page-h1">Policies</h1>
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
<div className="table-row" style={{ gridTemplateColumns: "1.2fr 1.2fr 1fr", background: "var(--panel-2)", color: "var(--text-3)", fontSize: 11 }}>
<div>Action</div><div>Effect</div><div>Risk ceiling</div>
</div>
{policies.length === 0 ? (
<div style={{ padding: 18, color: "var(--text-3)" }}>No policies</div>
) : policies.map((policy) => (
<div key={policy.id} className="table-row" style={{ gridTemplateColumns: "1.2fr 1.2fr 1fr" }}>
<span>{policy.action}</span>
<Pill>{policy.effect}</Pill>
<Pill tone={policy.riskCeiling === "high" ? "amber" : undefined}>{policy.riskCeiling}</Pill>
</div>
))}
</div>
</div>
);
}
function MigrationPanel({ reports, onCreate }: { reports: MigrationReportRecord[]; onCreate: () => void }) {
return (
<div className="page">
<div className="row" style={{ marginBottom: 24 }}>
<div>
<h1 className="page-h1">Migration</h1>
<p className="page-sub" style={{ marginBottom: 0 }}>{reports.length} reports</p>
</div>
<div style={{ marginLeft: "auto" }}>
<Button tone="primary" onClick={onCreate}><Icon name="plus" /> Report</Button>
</div>
</div>
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
{reports.length === 0 ? (
<div style={{ padding: 18, color: "var(--text-3)" }}>No reports</div>
) : reports.map((report) => <MigrationReportRow key={report.id} report={report} />)}
</div>
</div>
);
}
function MigrationReportRow({ report }: { report: MigrationReportRecord }) {
const body = parseMigrationReport(report.reportJson);
const idCount = Object.values(body.idMaps).reduce((sum, ids) => sum + ids.length, 0);
return (
<div className="table-row" style={{ gridTemplateColumns: "1.4fr 1fr 1fr 1fr" }}>
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 540, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} title={report.sourceRef}>{report.sourceRef}</div>
<div style={{ color: "var(--text-3)", fontSize: 11 }}>{new Date(report.createdAt).toLocaleString()}</div>
</div>
<div className="row" style={{ flexWrap: "wrap" }}>
{Object.entries(body.tableCounts).map(([table, count]) => <Pill key={table}>{table}: {count}</Pill>)}
</div>
<Pill>{Object.keys(body.tableChecksums).length} checksums</Pill>
<div className="row" style={{ flexWrap: "wrap" }}>
<Pill>{idCount} ids</Pill>
<Pill>{body.failures.length} failures</Pill>
</div>
</div>
);
}
function parseMigrationReport(reportJson: string): {
tableCounts: Record<string, number>;
tableChecksums: Record<string, string>;
idMaps: Record<string, string[]>;
failures: Array<{ table: string; reason: string }>;
} {
const parsed = JSON.parse(reportJson) as {
tableCounts?: Record<string, number>;
tableChecksums?: Record<string, string>;
idMaps?: Record<string, string[]>;
failures?: Array<{ table: string; reason: string }>;
};
if (!parsed.tableCounts || !parsed.tableChecksums || !parsed.idMaps || !Array.isArray(parsed.failures)) {
throw new Error("Migration report is invalid.");
}
return {
tableCounts: parsed.tableCounts,
tableChecksums: parsed.tableChecksums,
idMaps: parsed.idMaps,
failures: parsed.failures,
};
}
function requireRevision(
skill: SkillDefinitionRecord,
revisions: Array<{ id: string; skillId: string; versionSemver: string }>,
): { id: string; versionSemver: string } {
const revision = revisions.find((item) => item.skillId === skill.id);
if (!revision) throw new Error(`Skill revision is missing for ${skill.slug}.`);
return revision;
}
const PROVIDER_BRAND: Record<string, [string, string]> = {
"Anthropic": ["#cb9a5b", "A"],
"OpenAI": ["#0a0a0b", "O"],
"xAI Grok": ["#0a0a0b", "X"],
"GitHub Copilot": ["#2da44e", "G"],
"Google Antigravity": ["#3a7bd5", "G"],
"GPT account": ["#10a37f", "G"],
"Azure OpenAI": ["#0078d4", "Az"],
"AWS Bedrock": ["#e87722", "A"],
"Vertex AI": ["#4285f4", "V"],
"Groq": ["#f55036", "Q"],
};
function ProviderLogo({ label }: { label: string }) {
const [bg, ch] = PROVIDER_BRAND[label] ?? ["#a1a1aa", label.slice(0, 2)];
return (
<div style={{ width: 30, height: 30, borderRadius: 7, display: "grid", placeItems: "center", background: bg, color: "#fff", fontSize: 13, fontWeight: 600, flexShrink: 0 }}>
{ch}
</div>
<div className="section-label" style={{ margin: "18px 0 6px", padding: "0 8px" }}>{children}</div>
);
}
@@ -1,28 +1,42 @@
import { useState, type FormEvent } from "react";
import { Avatar, Button, Field, Icon } from "../shared/ui";
import { useMemo, useState, type FormEvent } from "react";
import { Avatar, Button, Field, Icon, IconButton, Pill } from "../shared/ui";
import { Dialog } from "../shared/Dialog";
import {
SOCIAL_PLATFORM,
type SocialAccountGrantRecord,
type EmployeeRecord,
type ScheduledPostRecord,
type SocialAccountRecord,
type SocialPlatform,
} from "../../../shared/domain";
import { SocialCalendar } from "./social/SocialCalendar";
import { PlatformIcon } from "./social/PlatformIcon";
import type { PageProps } from "./types";
import { activeOrgId } from "./types";
import { activeOrgId, requireActiveEmployee } from "./types";
function value(form: FormData, key: string): string {
return String(form.get(key) ?? "").trim();
}
function optional(form: FormData, key: string): string | null {
return value(form, key) || null;
}
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 grants = snapshot.socialAccountGrants.filter((item) => item.orgId === orgId);
const actionsByAccount = snapshot.browserActions.filter((item) => item.orgId === orgId);
const employee = requireActiveEmployee(snapshot);
const accounts = useMemo(() => snapshot.socialAccounts.filter((account) => account.orgId === orgId), [snapshot.socialAccounts, orgId]);
const scheduledPosts = useMemo(() => snapshot.scheduledPosts.filter((post) => post.orgId === orgId), [snapshot.scheduledPosts, orgId]);
const employees = snapshot.employees.filter((item) => item.orgId === orgId);
const submit = (event: FormEvent<HTMLFormElement>) => {
const [connectOpen, setConnectOpen] = useState(false);
const [draftOpen, setDraftOpen] = useState(false);
const scheduled = scheduledPosts.filter((post) => post.state === "scheduled");
const drafts = scheduledPosts.filter((post) => post.state === "draft");
const operator = accounts.find((account) => account.authorizedEmployeeId)?.authorizedEmployeeId ?? null;
const operatorName = operator ? employees.find((item) => item.id === operator)?.displayName ?? null : null;
const connect = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
void actions.run(() => window.agentCompany.createSocialAccount({
@@ -30,37 +44,93 @@ export function SocialPage({ snapshot, actions }: PageProps) {
platform: value(form, "platform") as SocialPlatform,
handle: value(form, "handle"),
displayName: value(form, "displayName"),
authorizedEmployeeId: value(form, "authorizedEmployeeId") || null,
})).then(() => setOpen(false));
authorizedEmployeeId: optional(form, "authorizedEmployeeId"),
})).then((result) => { if (result) setConnectOpen(false); });
};
const draft = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
void actions.run(() => window.agentCompany.createScheduledPost({
orgId,
socialAccountId: value(form, "socialAccountId"),
bodyMd: value(form, "bodyMd"),
scheduledFor: value(form, "scheduledFor"),
state: value(form, "state") === "scheduled" ? "scheduled" : "draft",
createdByEmployeeId: employee.id,
})).then((result) => { if (result) setDraftOpen(false); });
};
const updatePostState = (post: ScheduledPostRecord, state: "draft" | "scheduled" | "published" | "cancelled") =>
void actions.run(() => window.agentCompany.setScheduledPostState({ orgId, scheduledPostId: post.id, state }));
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>
<p className="page-sub" style={{ marginBottom: 0 }}>
{accounts.length} {accounts.length === 1 ? "account" : "accounts"} · {scheduled.length} scheduled
{operatorName ? ` · managed by ${operatorName}` : ""}
</p>
</div>
<div style={{ marginLeft: "auto" }}>
<Button tone="primary" onClick={() => setOpen(true)}><Icon name="plus" /> Connect account</Button>
<div style={{ marginLeft: "auto", display: "flex", gap: 6 }}>
<Button onClick={() => setConnectOpen(true)}><Icon name="plus" size={13} /> Connect account</Button>
<Button tone="primary" onClick={() => setDraftOpen(true)} disabled={accounts.length === 0}>
<Icon name="sparkle" size={13} /> Draft post
</Button>
</div>
</div>
<div className="section-label">Accounts</div>
<div className="grid-4" style={{ marginBottom: 28 }}>
<div className="section-label" style={{ marginBottom: 10 }}>Accounts</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))", gap: 10, marginBottom: 28 }}>
{accounts.length === 0 ? (
<div className="card" style={{ color: "var(--text-3)" }}>No social accounts</div>
<div className="card" style={{ color: "var(--text-3)" }}>No social accounts connected.</div>
) : accounts.map((account) => (
<AccountCard
key={account.id}
account={account}
employeeName={authorizedEmployeeName(account.authorizedEmployeeId, employees)}
grants={grants.filter((grant) => grant.socialAccountId === account.id)}
actionCount={actionsByAccount.filter((action) => action.socialAccountId === account.id).length}
onLogin={() => void actions.run(() => window.agentCompany.launchSocialLogin({ socialAccountId: account.id }))}
authorizedName={account.authorizedEmployeeId ? employees.find((item) => item.id === account.authorizedEmployeeId)?.displayName ?? null : null}
onSignIn={() => void actions.run(() => window.agentCompany.launchSocialLogin({ socialAccountId: account.id }))}
/>
))}
</div>
<Dialog title="Connect social account" open={open} submitLabel="Connect" onClose={() => setOpen(false)} onSubmit={submit}>
<div style={{ display: "grid", gridTemplateColumns: "minmax(0, 1.4fr) minmax(0, 1fr)", gap: 12, alignItems: "start" }}>
<div>
<div className="section-label" style={{ marginBottom: 10 }}>Scheduled</div>
{scheduled.length === 0 ? (
<div className="card" style={{ color: "var(--text-3)", marginBottom: 24 }}>No posts scheduled.</div>
) : scheduled.map((post) => (
<PostRow
key={post.id}
post={post}
account={requireAccount(accounts, post.socialAccountId)}
authorName={authorName(post, employees)}
onCancel={() => updatePostState(post, "cancelled")}
onPublish={() => updatePostState(post, "published")}
/>
))}
<div className="section-label" style={{ margin: "24px 0 10px" }}>Drafts</div>
{drafts.length === 0 ? (
<div className="card" style={{ color: "var(--text-3)" }}>No drafts.</div>
) : drafts.map((post) => (
<PostRow
key={post.id}
post={post}
account={requireAccount(accounts, post.socialAccountId)}
authorName={authorName(post, employees)}
onCancel={() => updatePostState(post, "cancelled")}
onPublish={() => updatePostState(post, "scheduled")}
/>
))}
</div>
<SocialCalendar posts={scheduledPosts} accounts={accounts} />
</div>
<Dialog title="Connect social account" open={connectOpen} submitLabel="Connect" onClose={() => setConnectOpen(false)} onSubmit={connect}>
<Field label="Platform">
<select className="input" name="platform" defaultValue={SOCIAL_PLATFORM.X}>
<option value={SOCIAL_PLATFORM.X}>X</option>
@@ -72,9 +142,27 @@ export function SocialPage({ snapshot, actions }: PageProps) {
<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">
<select className="input" name="authorizedEmployeeId" defaultValue={employee.id}>
<option value="">Unassigned</option>
{employees.map((employee) => <option key={employee.id} value={employee.id}>{employee.displayName}</option>)}
{employees.map((item) => <option key={item.id} value={item.id}>{item.displayName}</option>)}
</select>
</Field>
</Dialog>
<Dialog title="Draft post" open={draftOpen} submitLabel="Save" onClose={() => setDraftOpen(false)} onSubmit={draft}>
<Field label="Account">
<select className="input" name="socialAccountId" required defaultValue={accounts[0]?.id ?? ""}>
{accounts.map((account) => (
<option key={account.id} value={account.id}>{account.platform.toUpperCase()} · {account.handle}</option>
))}
</select>
</Field>
<Field label="Body"><textarea className="input" name="bodyMd" required /></Field>
<Field label="Scheduled for"><input className="input" name="scheduledFor" type="datetime-local" required /></Field>
<Field label="State">
<select className="input" name="state" defaultValue="scheduled">
<option value="draft">Draft</option>
<option value="scheduled">Scheduled</option>
</select>
</Field>
</Dialog>
@@ -84,67 +172,120 @@ export function SocialPage({ snapshot, actions }: PageProps) {
function AccountCard({
account,
employeeName,
grants,
actionCount,
onLogin,
authorizedName,
onSignIn,
}: {
account: SocialAccountRecord;
employeeName: string | null;
grants: SocialAccountGrantRecord[];
actionCount: number;
onLogin: () => void;
authorizedName: string | null;
onSignIn: () => void;
}) {
return (
<div className="card" style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<div className="row">
<div
style={{
border: "1px solid var(--border)",
borderRadius: 10,
padding: 14,
background: "var(--panel)",
display: "flex",
flexDirection: "column",
gap: 10,
minWidth: 0,
}}
>
<div className="row" style={{ gap: 10, minWidth: 0 }}>
<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 style={{ fontSize: 13, fontWeight: 540, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{account.handle}</div>
<div style={{ fontSize: 11, color: "var(--text-3)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{account.displayName}</div>
</div>
</div>
{employeeName && (
<div className="row" style={{ color: "var(--text-3)", fontSize: 11 }}>
<Avatar name={employeeName} size="sm" />
<span>{employeeName}</span>
{authorizedName ? (
<div className="row" style={{ fontSize: 11, color: "var(--text-3)", gap: 6 }}>
<span style={{ width: 6, height: 6, borderRadius: 3, background: "var(--green)" }} /> Authorized to {authorizedName}
</div>
) : (
<Button size="small" onClick={onSignIn}>Sign in</Button>
)}
<div className="row" style={{ color: "var(--text-3)", fontSize: 11 }}>
<span>{grants.length} grants</span>
<span>{actionCount} actions</span>
</div>
<Button size="small" onClick={onLogin}>Sign in</Button>
</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",
};
function PostRow({
post,
account,
authorName,
onCancel,
onPublish,
}: {
post: ScheduledPostRecord;
account: SocialAccountRecord;
authorName: string;
onCancel: () => void;
onPublish: () => void;
}) {
const isDraft = post.state === "draft";
return (
<div style={{ width: 30, height: 30, borderRadius: 7, background: colors[platform], color: "#fff", display: "grid", placeItems: "center", fontWeight: 700 }}>
{labels[platform]}
<div
style={{
display: "grid",
gridTemplateColumns: "36px minmax(0, 1fr) auto",
gap: 14,
padding: 14,
border: "1px solid var(--border)",
borderRadius: 10,
background: "var(--panel)",
marginBottom: 8,
}}
>
<PlatformIcon platform={account.platform} size={18} />
<div style={{ minWidth: 0 }}>
<div className="row" style={{ gap: 8, marginBottom: 6 }}>
{isDraft ? (
<Pill>Draft</Pill>
) : (
<span className="num mono" style={{ fontSize: 12, color: "var(--text-2)", fontWeight: 540 }}>
{formatSchedule(post.scheduledFor)}
</span>
)}
{post.approvalId && <Pill tone="amber" dot>Awaiting approval</Pill>}
{post.state === "published" && <Pill tone="green" dot>Published</Pill>}
{post.state === "cancelled" && <Pill tone="red">Cancelled</Pill>}
</div>
<div style={{ fontSize: 13, color: "var(--text-1)", lineHeight: 1.5, marginBottom: 8, whiteSpace: "pre-wrap" }}>{post.bodyMd}</div>
<div className="row" style={{ gap: 6 }}>
<Avatar name={authorName} size="sm" bot={authorName !== "System"} />
<span style={{ fontSize: 11, color: "var(--text-3)" }}>By {authorName}</span>
</div>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{post.state === "scheduled" && <Button size="small" tone="primary" onClick={onPublish}>Publish</Button>}
{post.state === "draft" && <Button size="small" tone="primary" onClick={onPublish}>Schedule</Button>}
{post.state !== "cancelled" && post.state !== "published" && (
<Button size="small" tone="ghost" onClick={onCancel}>Cancel</Button>
)}
<IconButton icon="more" label="More" />
</div>
</div>
);
}
function authorizedEmployeeName(
employeeId: string | null,
employees: Array<{ id: string; displayName: string }>,
): string | null {
if (!employeeId) return null;
const employee = employees.find((item) => item.id === employeeId);
if (!employee) throw new Error("Authorized employee is missing.");
return employee.displayName;
function requireAccount(accounts: SocialAccountRecord[], id: string): SocialAccountRecord {
const account = accounts.find((item) => item.id === id);
if (!account) throw new Error("Scheduled post references missing account.");
return account;
}
function authorName(post: ScheduledPostRecord, employees: EmployeeRecord[]): string {
if (!post.createdByEmployeeId) return "System";
return employees.find((item) => item.id === post.createdByEmployeeId)?.displayName ?? "System";
}
function formatSchedule(iso: string): string {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return iso;
return date.toLocaleString(undefined, {
weekday: "short",
hour: "2-digit",
minute: "2-digit",
});
}
@@ -1,6 +1,7 @@
import { useState, type FormEvent } from "react";
import { Avatar, Button, Field, Icon, Pill } from "../shared/ui";
import { useMemo, useState, type FormEvent } from "react";
import { Avatar, Button, Field, Icon, IconButton, Pill } from "../shared/ui";
import { Dialog } from "../shared/Dialog";
import type { AgentRecord, EmployeeRecord, ProviderRecord } from "../../../shared/domain";
import type { PageProps } from "./types";
import { activeOrgId, money } from "./types";
@@ -8,12 +9,21 @@ function text(form: FormData, key: string): string {
return String(form.get(key) ?? "").trim();
}
function optional(form: FormData, key: string): string | null {
return text(form, key) || null;
}
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 [hireOpen, setHireOpen] = useState(false);
const [filter, setFilter] = useState<"all" | "human" | "agent">("all");
const employees = useMemo(() => snapshot.employees.filter((item) => item.orgId === orgId), [snapshot.employees, orgId]);
const agents = useMemo(() => snapshot.agents.filter((item) => item.orgId === orgId), [snapshot.agents, orgId]);
const providers = useMemo(
() => snapshot.providers.filter((provider) => provider.orgId === orgId && provider.enabled),
[snapshot.providers, orgId],
);
const submit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
@@ -23,134 +33,258 @@ export function TeamPage({ snapshot, actions }: PageProps) {
name: text(form, "name"),
role: text(form, "role"),
department: text(form, "department"),
modelProviderId: text(form, "providerId") || null,
modelId: text(form, "modelId") || null,
modelProviderId: optional(form, "modelProviderId"),
modelId: optional(form, "modelId"),
budgetCents: Math.max(0, Math.round(Number(text(form, "budget")) * 100)),
managerAgentId: text(form, "managerAgentId") || null,
})).then(() => setOpen(false));
managerAgentId: optional(form, "managerAgentId"),
title: text(form, "title") || text(form, "role"),
roleLevel: text(form, "roleLevel") || "staff",
modelStrategy: text(form, "modelId") ? "explicit" : "pooled",
heartbeatEnabled: false,
heartbeatIntervalSec: 0,
})).then((result) => { if (result) setHireOpen(false); });
};
const rosterRows = buildRoster(employees, agents, providers, filter);
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>
<p className="page-sub" style={{ marginBottom: 0 }}>
{employees.length + agents.length} {employees.length + agents.length === 1 ? "member" : "members"} ·
{" "}{employees.length} {employees.length === 1 ? "human" : "humans"} ·
{" "}{agents.length} {agents.length === 1 ? "agent" : "agents"}
</p>
</div>
<div style={{ marginLeft: "auto" }}>
<Button tone="primary" onClick={() => setOpen(true)}><Icon name="plus" /> Hire</Button>
<div style={{ marginLeft: "auto", display: "flex", gap: 6 }}>
<Button tone="ghost" onClick={() => cycleFilter(filter, setFilter)} title="Filter">
<Icon name="filter" size={13} /> {filterLabel(filter)}
</Button>
<Button tone="primary" onClick={() => setHireOpen(true)}><Icon name="plus" size={13} /> Hire</Button>
</div>
</div>
<div className="card" style={{ padding: 32, marginBottom: 28 }}>
<section className="card" style={{ padding: 32, marginBottom: 28 }}>
<div className="section-label">Org</div>
<div style={{ display: "flex", justifyContent: "center", marginTop: 18, marginBottom: 12 }}>
{employees[0] && <OrgCard name={employees[0].displayName} role={employees[0].role} human />}
</div>
{agents.length > 0 && <OrgConnector count={agents.length} />}
<div className="grid-4" style={{ marginTop: 18 }}>
{agents.map((agent) => (
{employees[0] ? (
<div style={{ display: "flex", justifyContent: "center", marginTop: 18, marginBottom: 12 }}>
<OrgCard name={employees[0].displayName} role={employees[0].role} human />
</div>
) : null}
{agents.length > 0 && <OrgConnector slots={agents.length} />}
<div style={{ display: "grid", gridTemplateColumns: `repeat(${Math.max(1, Math.min(4, agents.length))}, 1fr)`, gap: 14, marginTop: 18 }}>
{agents.slice(0, 4).map((agent) => (
<OrgCard key={agent.id} name={agent.name} role={agent.role} />
))}
</div>
</div>
<div className="section-label">Roster</div>
{agents.length > 4 && (
<div style={{ marginTop: 16, color: "var(--text-3)", fontSize: 12, textAlign: "center" }}>
+ {agents.length - 4} more agents in the roster below
</div>
)}
</section>
<div className="section-label" style={{ marginBottom: 10 }}>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
className="table-row head"
style={{ gridTemplateColumns: "minmax(0, 2fr) minmax(0, 1.2fr) minmax(0, 1.4fr) minmax(0, 1.4fr) 60px" }}
>
<div>Member</div><div>Department</div><div>Model</div><div>Budget</div><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}
/>
{rosterRows.length === 0 ? (
<div style={{ padding: 18, color: "var(--text-3)" }}>No members for this filter.</div>
) : rosterRows.map((row, index) => (
<RosterRow key={row.id} row={row} last={index === rosterRows.length - 1} />
))}
</div>
<Dialog title="Hire agent" open={open} submitLabel="Hire" onClose={() => setOpen(false)} onSubmit={submit}>
<Dialog title="Hire agent" open={hireOpen} submitLabel="Hire" onClose={() => setHireOpen(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="Title"><input className="input" name="title" placeholder="Optional" /></Field>
<Field label="Department"><input className="input" name="department" required /></Field>
<Field label="Provider">
<select className="input" name="providerId">
<select className="input" name="modelProviderId">
<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>
<Field label="Monthly budget"><input className="input" name="budget" type="number" min="0" step="1" defaultValue="250" /></Field>
<Field label="Seniority">
<select className="input" name="roleLevel" defaultValue="staff">
<option value="staff">Staff</option>
<option value="lead">Lead</option>
<option value="director">Director</option>
<option value="c_suite">Executive</option>
</select>
</Field>
</Dialog>
</div>
);
}
function OrgConnector({ count }: { count: number }) {
const positions = Array.from({ length: count }, (_, i) => {
const left = count === 1 ? 50 : ((i / (count - 1)) * 100);
return left;
});
function OrgCard({ name, role, human = false }: { name: string; role: string; human?: boolean }) {
return (
<svg width="100%" height="36" style={{ display: "block" }}>
<div
style={{
width: 220,
border: "1px solid var(--border)",
borderRadius: 12,
padding: "14px 14px 12px",
background: "var(--panel)",
display: "flex",
flexDirection: "column",
gap: 10,
}}
>
<div className="row">
<Avatar name={name} bot={!human} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 540, letterSpacing: "-0.01em", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{name}</div>
<div style={{ fontSize: 11, color: "var(--text-3)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{role}</div>
</div>
</div>
<div>
{human ? <Pill>Human</Pill> : <Pill tone="violet" dot>Agent</Pill>}
</div>
</div>
);
}
function OrgConnector({ slots }: { slots: number }) {
const count = Math.max(1, Math.min(4, slots));
const step = 100 / (count * 2);
const positions = Array.from({ length: count }, (_, i) => step * (2 * i + 1));
return (
<svg width="100%" height="36" style={{ display: "block", marginTop: 18 }} aria-hidden="true">
<line x1="50%" x2="50%" y1="0" y2="14" stroke="var(--border-strong)" strokeWidth="1" />
{count > 1 && (
<line x1={`${positions[0]}%`} x2={`${positions[positions.length - 1]}%`} y1="14" y2="14" stroke="var(--border-strong)" strokeWidth="1" />
)}
{positions.map((pos, i) => (
<line key={i} x1={`${pos}%`} x2={`${pos}%`} y1="14" y2="36" stroke="var(--border-strong)" strokeWidth="1" />
{positions.map((pos) => (
<line key={pos} x1={`${pos}%`} x2={`${pos}%`} y1="14" y2="36" stroke="var(--border-strong)" strokeWidth="1" />
))}
</svg>
);
}
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,
}: {
interface RosterEntry {
id: string;
name: string;
role: string;
dept: string;
model: string;
budget: number | null;
human?: boolean;
}) {
providerLabel: string;
budgetCents: number | null;
budgetCapCents: number | null;
human: boolean;
}
function buildRoster(
employees: EmployeeRecord[],
agents: AgentRecord[],
providers: ProviderRecord[],
filter: "all" | "human" | "agent",
): RosterEntry[] {
const rows: RosterEntry[] = [];
if (filter !== "agent") {
for (const employee of employees) {
rows.push({
id: employee.id,
name: employee.displayName,
role: employee.role,
dept: "Executive",
model: "—",
providerLabel: "—",
budgetCents: null,
budgetCapCents: null,
human: true,
});
}
}
if (filter !== "human") {
const cap = agents.reduce((max, agent) => (agent.budgetCents > max ? agent.budgetCents : max), 0);
for (const agent of agents) {
const provider = agent.modelProviderId ? providers.find((entry) => entry.id === agent.modelProviderId) : null;
rows.push({
id: agent.id,
name: agent.name,
role: agent.role,
dept: agent.department || "—",
model: agent.modelId ?? (provider?.model ?? "—"),
providerLabel: provider?.label ?? "—",
budgetCents: agent.budgetCents,
budgetCapCents: cap > 0 ? cap : null,
human: false,
});
}
}
return rows;
}
function RosterRow({ row, last }: { row: RosterEntry; last: 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
className="table-row"
style={{ gridTemplateColumns: "minmax(0, 2fr) minmax(0, 1.2fr) minmax(0, 1.4fr) minmax(0, 1.4fr) 60px", borderBottom: last ? "none" : "1px solid var(--border-soft)" }}
>
<div className="row" style={{ gap: 10, minWidth: 0 }}>
<Avatar name={row.name} size="sm" bot={!row.human} />
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 500, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{row.name}</div>
<div style={{ fontSize: 11, color: "var(--text-3)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{row.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 style={{ color: "var(--text-2)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{row.dept}</div>
<div
style={{
color: "var(--text-2)",
fontFamily: row.human ? "inherit" : "var(--font-mono)",
fontSize: row.human ? 13 : 12,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{row.model}
</div>
<div>
{row.budgetCents === null ? (
<span style={{ color: "var(--text-4)" }}>—</span>
) : (
<div className="row" style={{ gap: 8 }}>
<div style={{ flex: 1, height: 3, background: "var(--panel-3)", borderRadius: 2, overflow: "hidden", maxWidth: 100 }}>
<div
style={{
width: `${row.budgetCapCents && row.budgetCapCents > 0 ? Math.min(100, Math.round((row.budgetCents / row.budgetCapCents) * 100)) : 0}%`,
height: "100%",
background: "var(--text-2)",
}}
/>
</div>
<span className="num mono" style={{ fontSize: 11, color: "var(--text-3)" }}>{money(row.budgetCents)}</span>
</div>
)}
</div>
<div style={{ textAlign: "right" }}>
<IconButton icon="more" label="More" />
</div>
<div>{budget === null ? <span style={{ color: "var(--text-4)" }}>—</span> : <span className="num">{money(budget)}</span>}</div>
</div>
);
}
function cycleFilter(current: "all" | "human" | "agent", set: (value: "all" | "human" | "agent") => void) {
if (current === "all") set("human");
else if (current === "human") set("agent");
else set("all");
}
function filterLabel(filter: "all" | "human" | "agent"): string {
if (filter === "human") return "Humans only";
if (filter === "agent") return "Agents only";
return "All";
}
@@ -1,65 +1,48 @@
import { useState, type FormEvent } from "react";
import { Avatar, Button, Field, Icon, IconButton, Pill } from "../shared/ui";
import { Dialog } from "../shared/Dialog";
import { useMemo, useState } from "react";
import { Avatar, Button, Icon, IconButton, Pill, Segmented } from "../shared/ui";
import {
RISK_LEVEL,
TASK_PRIORITY,
TASK_STATUS,
type AgentRecord,
type GoalRecord,
type ProjectRecord,
type TaskPriority,
type TaskRecord,
type TaskStatus,
type WorkspaceRecord,
} from "../../../shared/domain";
import { NewTaskDialog } from "./NewTaskDialog";
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" },
type View = "board" | "list" | "timeline";
const COLUMNS: ReadonlyArray<{ id: TaskStatus; label: string; tone: string }> = [
{ id: TASK_STATUS.BACKLOG, label: "Backlog", tone: "var(--text-4)" },
{ id: TASK_STATUS.IN_PROGRESS, label: "In progress", tone: "var(--accent)" },
{ id: TASK_STATUS.REVIEW, label: "In review", tone: "var(--amber)" },
{ id: TASK_STATUS.DONE, label: "Done", tone: "var(--green)" },
];
function value(form: FormData, key: string): string {
return String(form.get(key) ?? "").trim();
}
function optionalValue(form: FormData, key: string): string | null {
const text = value(form, key);
return text ? text : null;
}
type ViewMode = "board" | "list";
const VIEW_OPTIONS: ReadonlyArray<{ id: View; label: string }> = [
{ id: "board", label: "Board" },
{ id: "list", label: "List" },
{ id: "timeline", label: "Timeline" },
];
export function WorkPage({ snapshot, actions }: PageProps) {
const orgId = activeOrgId(snapshot);
const [open, setOpen] = useState(false);
const [view, setView] = useState<ViewMode>("board");
const tasks = snapshot.tasks.filter((item) => item.orgId === orgId);
const agents = snapshot.agents.filter((item) => item.orgId === orgId);
const projects = snapshot.projects.filter((item) => item.orgId === orgId);
const goals = snapshot.goals.filter((item) => item.orgId === orgId);
const workspaces = snapshot.workspaces.filter((item) => item.orgId === orgId);
const [view, setView] = useState<View>("board");
const [newTaskOpen, setNewTaskOpen] = useState(false);
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: optionalValue(form, "assigneeAgentId"),
risk: value(form, "risk") as "low" | "medium" | "high",
projectId: optionalValue(form, "projectId"),
goalId: optionalValue(form, "goalId"),
workspaceId: optionalValue(form, "workspaceId"),
priority: value(form, "priority") as TaskPriority,
requiresFilesystem: form.get("requiresFilesystem") === "on",
})).then(() => setOpen(false));
const tasks = useMemo(() => snapshot.tasks.filter((task) => task.orgId === orgId), [snapshot.tasks, orgId]);
const agents = useMemo(() => snapshot.agents.filter((agent) => agent.orgId === orgId), [snapshot.agents, orgId]);
const projects = useMemo(() => snapshot.projects.filter((project) => project.orgId === orgId), [snapshot.projects, orgId]);
const goals = useMemo(() => snapshot.goals.filter((goal) => goal.orgId === orgId), [snapshot.goals, orgId]);
const workspaces = useMemo(() => snapshot.workspaces.filter((workspace) => workspace.orgId === orgId), [snapshot.workspaces, orgId]);
const advance = (task: TaskRecord) => {
const next = nextStatus(task.status);
if (!next) return;
void actions.run(() => window.agentCompany.updateTaskStatus({ orgId, taskId: task.id, status: next }));
};
return (
@@ -67,125 +50,156 @@ export function WorkPage({ snapshot, actions }: PageProps) {
<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>
<p className="page-sub" style={{ marginBottom: 0 }}>
{tasks.length} {tasks.length === 1 ? "task" : "tasks"} · {agents.length} {agents.length === 1 ? "assignee" : "assignees"}
</p>
</div>
<div style={{ marginLeft: "auto", display: "flex", gap: 6, alignItems: "center" }}>
<div style={{ display: "flex", gap: 2, padding: 2, border: "1px solid var(--border)", borderRadius: 8 }}>
<button
type="button"
className="button small"
style={{ borderColor: "transparent", background: view === "board" ? "var(--panel-3)" : "transparent" }}
onClick={() => setView("board")}
>
Board
</button>
<button
type="button"
className="button small"
style={{ borderColor: "transparent", background: view === "list" ? "var(--panel-3)" : "transparent" }}
onClick={() => setView("list")}
>
List
</button>
</div>
<button type="button" className="button"><Icon name="filter" size={13} /> Filter</button>
<Button tone="primary" onClick={() => setOpen(true)}><Icon name="plus" /> New task</Button>
<Segmented value={view} onChange={setView} options={VIEW_OPTIONS} />
<Button tone="ghost"><Icon name="filter" size={13} /></Button>
<Button tone="primary" onClick={() => setNewTaskOpen(true)}><Icon name="plus" size={13} /> New task</Button>
</div>
</div>
{view === "board" ? (
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(220px, 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={optionalRecordName(task.assigneeAgentId, agents, (agent) => agent.name, "Agent")}
project={optionalRecordName(task.projectId, projects, (project) => project.name, "Project")}
goal={optionalRecordName(task.goalId, goals, (goal) => goal.title, "Goal")}
workspace={optionalRecordName(task.workspaceId, workspaces, (workspace) => workspace.name, "Workspace")}
onMove={(status) => void actions.run(() => window.agentCompany.updateTaskStatus({ orgId, taskId: task.id, status }))}
/>
))}
</div>
);
})}
</div>
) : (
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
<div className="table-row" style={{ gridTemplateColumns: "3fr 1fr 1fr 1fr", background: "var(--panel-2)", color: "var(--text-3)", fontSize: 11 }}>
<div>Title</div><div>Status</div><div>Priority</div><div>Assignee</div>
</div>
{tasks.length === 0 ? (
<div style={{ padding: 18, color: "var(--text-3)" }}>No tasks</div>
) : tasks.map((task) => {
const assigneeName = optionalRecordName(task.assigneeAgentId, agents, (a) => a.name, "Agent");
return (
<div key={task.id} className="table-row" style={{ gridTemplateColumns: "3fr 1fr 1fr 1fr" }}>
<div className="row">
{task.risk === "high" && <span style={{ width: 3, height: 12, background: "var(--red)", borderRadius: 2, flexShrink: 0 }} />}
<span style={{ fontWeight: 480 }}>{task.title}</span>
</div>
<Pill>{task.status.replaceAll("_", " ")}</Pill>
<Pill tone={task.priority === "high" ? "amber" : undefined}>{task.priority}</Pill>
<div>{assigneeName ? <div className="row"><Avatar name={assigneeName} size="sm" bot /><span style={{ color: "var(--text-2)" }}>{assigneeName}</span></div> : <span style={{ color: "var(--text-4)" }}>—</span>}</div>
</div>
);
})}
</div>
{view === "board" && (
<BoardView
tasks={tasks}
agents={agents}
projects={projects}
goals={goals}
workspaces={workspaces}
onAdvance={advance}
onCreate={() => setNewTaskOpen(true)}
/>
)}
<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="Project">
<select className="input" name="projectId">
<option value="">Unassigned</option>
{projects.map((project) => <option key={project.id} value={project.id}>{project.name}</option>)}
</select>
</Field>
<Field label="Goal">
<select className="input" name="goalId">
<option value="">Unassigned</option>
{goals.map((goal) => <option key={goal.id} value={goal.id}>{goal.title}</option>)}
</select>
</Field>
<Field label="Workspace">
<select className="input" name="workspaceId">
<option value="">Unassigned</option>
{workspaces.map((workspace) => <option key={workspace.id} value={workspace.id}>{workspace.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>
<Field label="Priority">
<select className="input" name="priority" defaultValue={TASK_PRIORITY.NORMAL}>
<option value={TASK_PRIORITY.LOW}>Low</option>
<option value={TASK_PRIORITY.NORMAL}>Normal</option>
<option value={TASK_PRIORITY.HIGH}>High</option>
</select>
</Field>
<Field label="Filesystem">
<label className="checkbox-row"><input type="checkbox" name="requiresFilesystem" /> Requires workspace access</label>
</Field>
</Dialog>
{view === "list" && <ListView tasks={tasks} agents={agents} projects={projects} />}
{view === "timeline" && <TimelineView tasks={tasks} goals={goals} />}
<NewTaskDialog open={newTaskOpen} orgId={orgId} snapshot={snapshot} actions={actions} onClose={() => setNewTaskOpen(false)} />
</div>
);
}
function BoardView({
tasks,
agents,
projects,
goals,
workspaces,
onAdvance,
onCreate,
}: {
tasks: TaskRecord[];
agents: AgentRecord[];
projects: ProjectRecord[];
goals: GoalRecord[];
workspaces: WorkspaceRecord[];
onAdvance: (task: TaskRecord) => void;
onCreate: () => void;
}) {
return (
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))", gap: 12, minWidth: 0 }}>
{COLUMNS.map((column) => {
const columnTasks = tasks.filter((task) => task.status === column.id);
return (
<div key={column.id} style={{ display: "flex", flexDirection: "column", gap: 8, minWidth: 0 }}>
<div className="row" style={{ padding: "4px 6px 8px" }}>
<span style={{ width: 8, height: 8, borderRadius: 2, background: column.tone, flexShrink: 0 }} />
<span style={{ fontSize: 12.5, fontWeight: 540 }}>{column.label}</span>
<span className="num" style={{ fontSize: 12, color: "var(--text-3)" }}>{columnTasks.length}</span>
<IconButton icon="plus" label="Add task" onClick={onCreate} />
</div>
{columnTasks.map((task) => (
<TaskCard
key={task.id}
task={task}
assignee={agents.find((agent) => agent.id === task.assigneeAgentId) ?? null}
project={projects.find((project) => project.id === task.projectId) ?? null}
goal={goals.find((goal) => goal.id === task.goalId) ?? null}
workspace={workspaces.find((workspace) => workspace.id === task.workspaceId) ?? null}
onAdvance={() => onAdvance(task)}
/>
))}
{columnTasks.length === 0 && (
<div style={{ color: "var(--text-3)", fontSize: 12, padding: "12px 6px" }}>Empty.</div>
)}
</div>
);
})}
</div>
);
}
function ListView({ tasks, agents, projects }: { tasks: TaskRecord[]; agents: AgentRecord[]; projects: ProjectRecord[] }) {
return (
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
<div
className="table-row head"
style={{ gridTemplateColumns: "minmax(0, 3fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1.2fr) 60px" }}
>
<div>Title</div><div>Status</div><div>Priority</div><div>Assignee</div><div />
</div>
{tasks.length === 0 ? (
<div style={{ padding: 18, color: "var(--text-3)" }}>No tasks.</div>
) : tasks.map((task, index) => {
const assignee = agents.find((agent) => agent.id === task.assigneeAgentId) ?? null;
const project = projects.find((p) => p.id === task.projectId) ?? null;
const last = index === tasks.length - 1;
return (
<div
key={task.id}
className="table-row"
style={{ gridTemplateColumns: "minmax(0, 3fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1.2fr) 60px", borderBottom: last ? "none" : "1px solid var(--border-soft)" }}
>
<div className="row" style={{ gap: 8, minWidth: 0 }}>
{task.risk === "high" && <span style={{ width: 3, height: 12, background: "var(--red)", borderRadius: 2, flexShrink: 0 }} />}
<span style={{ fontWeight: 480, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{task.title}</span>
{project && <Pill>{project.name}</Pill>}
</div>
<div><Pill>{prettyStatus(task.status)}</Pill></div>
<div><Pill tone={task.priority === "high" ? "amber" : undefined}>{task.priority}</Pill></div>
<div>
{assignee ? (
<div className="row" style={{ gap: 8 }}>
<Avatar name={assignee.name} size="sm" bot />
<span style={{ color: "var(--text-2)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{assignee.name}</span>
</div>
) : (
<span style={{ color: "var(--text-4)" }}>—</span>
)}
</div>
<div style={{ textAlign: "right" }}>
<IconButton icon="more" label="More" />
</div>
</div>
);
})}
</div>
);
}
function TimelineView({ tasks, goals }: { tasks: TaskRecord[]; goals: GoalRecord[] }) {
return (
<div className="card" style={{ padding: 18 }}>
<div className="section-label">By goal</div>
{goals.length === 0 ? (
<div style={{ color: "var(--text-3)" }}>No goals defined.</div>
) : goals.map((goal) => {
const linked = tasks.filter((task) => task.goalId === goal.id);
const done = linked.filter((task) => task.status === TASK_STATUS.DONE).length;
const pct = linked.length === 0 ? 0 : Math.round((done / linked.length) * 100);
return (
<div key={goal.id} style={{ padding: "12px 0", borderBottom: "1px solid var(--border-soft)" }}>
<div className="row">
<span style={{ fontWeight: 540 }}>{goal.title}</span>
<span className="num" style={{ marginLeft: "auto", color: "var(--text-3)" }}>{done} / {linked.length}</span>
</div>
<div style={{ marginTop: 8, height: 3, background: "var(--panel-3)", borderRadius: 2, overflow: "hidden" }}>
<div style={{ width: `${pct}%`, height: "100%", background: "var(--text-1)" }} />
</div>
</div>
);
})}
</div>
);
}
@@ -196,44 +210,58 @@ function TaskCard({
project,
goal,
workspace,
onMove,
onAdvance,
}: {
task: TaskRecord;
assignee: string | null;
project: string | null;
goal: string | null;
workspace: string | null;
onMove: (status: TaskStatus) => void;
assignee: AgentRecord | null;
project: ProjectRecord | null;
goal: GoalRecord | null;
workspace: WorkspaceRecord | null;
onAdvance: () => void;
}) {
return (
<div className="card" style={{ display: "flex", flexDirection: "column", gap: 10, opacity: task.status === "done" ? 0.7 : 1 }}>
<div
style={{
background: "var(--panel)",
border: "1px solid var(--border)",
borderRadius: 10,
padding: 12,
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>
{task.priority === "high" && <span style={{ width: 3, height: 14, background: "var(--red)", borderRadius: 2, marginTop: 2, flexShrink: 0 }} />}
<div
style={{
flex: 1,
fontSize: 13,
fontWeight: 480,
lineHeight: 1.35,
letterSpacing: "-0.005em",
textDecoration: task.status === "done" ? "line-through" : "none",
color: task.status === "done" ? "var(--text-3)" : "var(--text-1)",
}}
>
{task.title}
</div>
</div>
<div className="row" style={{ flexWrap: "wrap" }}>
<Pill tone={task.risk === "high" ? "amber" : undefined}>{task.risk}</Pill>
<Pill>{task.priority}</Pill>
{project && <Pill>{project}</Pill>}
{goal && <Pill>{goal}</Pill>}
{workspace && <Pill>{workspace}</Pill>}
{task.requiresFilesystem && <Pill>filesystem</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 className="row" style={{ flexWrap: "wrap", gap: 6 }}>
{project ? <Pill>{project.name}</Pill> : <Pill>{capitalize(task.risk)}</Pill>}
{goal && <Pill tone="violet">{truncate(goal.title, 20)}</Pill>}
{workspace && <Pill>{workspace.name}</Pill>}
{task.requiresFilesystem && <Pill>workspace</Pill>}
<div style={{ marginLeft: "auto", display: "flex", gap: 6, alignItems: "center" }}>
{assignee && <Avatar name={assignee.name} size="sm" bot />}
{nextStatus(task.status) && <IconButton icon="check" label="Advance" onClick={onAdvance} />}
</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;
@@ -241,14 +269,15 @@ function nextStatus(status: TaskStatus): TaskStatus | null {
return null;
}
function optionalRecordName<T extends AgentRecord | ProjectRecord | GoalRecord | WorkspaceRecord>(
id: string | null,
records: T[],
getName: (record: T) => string,
label: string,
): string | null {
if (!id) return null;
const record = records.find((item) => item.id === id);
if (!record) throw new Error(`${label} reference is missing.`);
return getName(record);
function prettyStatus(status: TaskStatus): string {
return status.replaceAll("_", " ");
}
function capitalize(value: string): string {
return value.charAt(0).toUpperCase() + value.slice(1);
}
function truncate(value: string, max: number): string {
if (value.length <= max) return value;
return `${value.slice(0, max - 1)}…`;
}
@@ -0,0 +1,103 @@
import { useState, type FormEvent, type MutableRefObject } from "react";
import { Button, Icon, IconButton } from "../../shared/ui";
import type { FileObjectRecord } from "../../../../shared/domain";
import { SLASH_COMMANDS } from "./chat-helpers";
export function ChatComposer({
spaceName,
disabled,
files,
attachmentFileId,
onAttachmentChange,
onSubmit,
composerRef,
}: {
spaceName: string;
disabled: boolean;
files: FileObjectRecord[];
attachmentFileId: string;
onAttachmentChange: (next: string) => void;
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
composerRef: MutableRefObject<HTMLTextAreaElement | null>;
}) {
const [slashOpen, setSlashOpen] = useState(false);
const insert = (text: string) => {
const node = composerRef.current;
if (!node) return;
const start = node.selectionStart;
const end = node.selectionEnd;
const before = node.value.slice(0, start);
const after = node.value.slice(end);
node.value = `${before}${text} ${after}`;
const caret = before.length + text.length + 1;
node.setSelectionRange(caret, caret);
node.focus();
};
return (
<form onSubmit={onSubmit} style={{ padding: 14, paddingTop: 4, flexShrink: 0 }}>
<div style={{ border: "1px solid var(--border-strong)", borderRadius: 12, background: "var(--panel)", padding: "10px 12px" }}>
<textarea
ref={composerRef}
name="message"
rows={2}
placeholder={spaceName ? `Message #${spaceName}…` : "Select a channel"}
disabled={disabled}
aria-label="Message"
style={{
width: "100%",
border: 0,
resize: "none",
outline: "none",
fontSize: 13,
color: "var(--text-1)",
background: "transparent",
padding: 0,
minHeight: 38,
}}
onKeyDown={(event) => {
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
event.currentTarget.form?.requestSubmit();
}
}}
/>
<div className="row" style={{ paddingTop: 6, borderTop: "1px solid var(--border-soft)", gap: 4 }}>
<IconButton icon="attach" label="Attach" />
<select
className="input"
value={attachmentFileId}
onChange={(event) => onAttachmentChange(event.target.value)}
aria-label="File attachment"
style={{ maxWidth: 180, padding: "4px 8px", height: 28, fontSize: 12 }}
disabled={disabled}
>
<option value="">No attachment</option>
{files.map((file) => <option key={file.id} value={file.id}>{file.name}</option>)}
</select>
<IconButton icon="sparkle" label="Commands" onClick={() => setSlashOpen((open) => !open)} />
<span style={{ fontSize: 11, color: "var(--text-3)", marginLeft: 6 }}>/ to invoke a command</span>
<div style={{ marginLeft: "auto", display: "flex", alignItems: "center", gap: 6 }}>
<span className="mono" style={{ fontSize: 11, color: "var(--text-3)" }}>⌘↵</span>
<Button tone="primary" size="small" type="submit" disabled={disabled}><Icon name="send" size={12} /> Send</Button>
</div>
</div>
{slashOpen && (
<div style={{ marginTop: 8, display: "flex", flexWrap: "wrap", gap: 6 }}>
{SLASH_COMMANDS.map((command) => (
<button
key={command}
type="button"
className="pill"
onClick={() => { insert(command); setSlashOpen(false); }}
>
{command}
</button>
))}
</div>
)}
</div>
</form>
);
}
@@ -0,0 +1,124 @@
import type { FormEvent, MutableRefObject, ReactNode } from "react";
import { AvatarStack, Icon, IconButton } from "../../shared/ui";
import type {
ApprovalRecord,
ChatAttachmentRecord,
ChatMessageRecord,
ChatSpaceRecord,
ChatThreadRecord,
FileObjectRecord,
} from "../../../../shared/domain";
import { ChatComposer } from "./ChatComposer";
import { ChatTimeline } from "./ChatTimeline";
import type { MessageContext } from "./chat-helpers";
export function ChatConversation({
space,
participants,
rootMessages,
messagesInSpace,
threadsInSpace,
pendingApprovals,
attachments,
files,
context,
attachmentFileId,
onAttachmentChange,
onOpenThread,
onApprove,
onDecline,
onSubmit,
composerRef,
}: {
space: ChatSpaceRecord | null;
participants: string[];
rootMessages: ChatMessageRecord[];
messagesInSpace: ChatMessageRecord[];
threadsInSpace: ChatThreadRecord[];
pendingApprovals: ApprovalRecord[];
attachments: ChatAttachmentRecord[];
files: FileObjectRecord[];
context: MessageContext;
attachmentFileId: string;
onAttachmentChange: (next: string) => void;
onOpenThread: (threadId: string) => void;
onApprove: (approvalId: string) => void;
onDecline: (approvalId: string) => void;
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
composerRef: MutableRefObject<HTMLTextAreaElement | null>;
}) {
return (
<section style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column", background: "var(--panel)" }}>
{space && <ConversationHeader space={space} participants={participants} />}
<div style={{ flex: 1, overflow: "auto", padding: "20px 24px 8px" }}>
{!space ? (
<Empty>Select a channel to start.</Empty>
) : rootMessages.length === 0 && pendingApprovals.length === 0 ? (
<Empty>No messages in {space.name} yet.</Empty>
) : (
<ChatTimeline
rootMessages={rootMessages}
messagesInSpace={messagesInSpace}
threadsInSpace={threadsInSpace}
pendingApprovals={pendingApprovals}
attachments={attachments}
files={files}
context={context}
onOpenThread={onOpenThread}
onApprove={onApprove}
onDecline={onDecline}
/>
)}
</div>
<ChatComposer
spaceName={space?.name ?? ""}
disabled={!space}
files={files}
attachmentFileId={attachmentFileId}
onAttachmentChange={onAttachmentChange}
onSubmit={onSubmit}
composerRef={composerRef}
/>
</section>
);
}
function ConversationHeader({
space,
participants,
}: {
space: ChatSpaceRecord;
participants: string[];
}) {
return (
<header className="row" style={{ height: 52, padding: "0 24px", borderBottom: "1px solid var(--border-soft)", flexShrink: 0 }}>
<Icon name={space.visibility === "restricted" ? "lock" : "hash"} size={15} color="var(--text-3)" />
<span style={{ fontSize: 14, fontWeight: 600 }}>{space.name}</span>
<span
style={{
fontSize: 12,
color: "var(--text-3)",
borderLeft: "1px solid var(--border)",
paddingLeft: 10,
marginLeft: 4,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
flex: 1,
minWidth: 0,
}}
>
{space.topic}
</span>
<div style={{ marginLeft: "auto", display: "flex", gap: 8, alignItems: "center" }}>
{participants.length > 0 && <AvatarStack names={participants.slice(0, 5)} bot />}
<IconButton icon="search" label="Search" />
<IconButton icon="more" label="More" />
</div>
</header>
);
}
function Empty({ children }: { children: ReactNode }) {
return <div style={{ color: "var(--text-3)", padding: 12 }}>{children}</div>;
}
@@ -0,0 +1,128 @@
import type { ReactNode } from "react";
import { Avatar, Icon, IconButton } from "../../shared/ui";
import type {
ChatAttachmentRecord,
ChatMessageRecord,
FileObjectRecord,
} from "../../../../shared/domain";
import { formatTime } from "../types";
import { messageAttachments, senderName, type MessageContext } from "./chat-helpers";
export function ChatMessageRow({
message,
attachments,
files,
replyCount,
onOpenThread,
context,
compact = false,
}: {
message: ChatMessageRecord;
attachments: ChatAttachmentRecord[];
files: FileObjectRecord[];
replyCount: number;
onOpenThread: (() => void) | null;
context: MessageContext;
compact?: boolean;
}) {
const name = senderName(message, context);
const bot = message.senderType === "agent";
const messageFiles = messageAttachments(message.id, attachments, files);
return (
<div style={{ display: "flex", gap: compact ? 10 : 12, marginBottom: compact ? 12 : 18 }}>
<Avatar name={name} size={compact ? "sm" : ""} bot={bot} />
<div style={{ flex: 1, minWidth: 0 }}>
<div className="row" style={{ alignItems: "baseline", gap: 8, marginBottom: 2 }}>
<span style={{ fontSize: 13, fontWeight: 540 }}>{name}</span>
<span className="mono" style={{ fontSize: 11, color: "var(--text-3)" }}>{formatTime(message.createdAt)}</span>
</div>
<div style={{ fontSize: 13, color: "var(--text-1)", lineHeight: 1.5, whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
{renderBody(message.bodyMd)}
</div>
{messageFiles.length > 0 && (
<div style={{ display: "flex", flexDirection: "column", gap: 8, marginTop: 8 }}>
{messageFiles.map((file) => <AttachmentCard key={file.id} file={file} />)}
</div>
)}
{replyCount > 0 && onOpenThread && (
<button
type="button"
onClick={onOpenThread}
style={{
marginTop: 8,
display: "inline-flex",
alignItems: "center",
gap: 6,
fontSize: 11.5,
color: "var(--accent)",
cursor: "pointer",
padding: "3px 8px",
borderRadius: 6,
background: "var(--accent-soft)",
border: 0,
}}
>
<Icon name="chat" size={11} /> {replyCount} {replyCount === 1 ? "reply" : "replies"} →
</button>
)}
</div>
</div>
);
}
function renderBody(body: string): ReactNode {
const parts: ReactNode[] = [];
const regex = /@([A-Za-z][A-Za-z0-9_-]{1,63})/g;
let lastIndex = 0;
let match;
while ((match = regex.exec(body)) !== null) {
if (match.index > lastIndex) parts.push(body.slice(lastIndex, match.index));
parts.push(
<span
key={`m-${match.index}`}
style={{ background: "var(--accent-soft)", color: "var(--accent)", padding: "1px 5px", borderRadius: 4, fontWeight: 500 }}
>
@{match[1]}
</span>,
);
lastIndex = match.index + match[0].length;
}
if (lastIndex < body.length) parts.push(body.slice(lastIndex));
return parts;
}
function AttachmentCard({ file }: { file: FileObjectRecord }) {
return (
<div
style={{
maxWidth: 360,
border: "1px solid var(--border)",
borderRadius: 10,
padding: 10,
display: "flex",
alignItems: "center",
gap: 10,
background: "var(--panel)",
}}
>
<div
style={{
width: 32,
height: 32,
borderRadius: 6,
background: "var(--panel-3)",
color: "var(--text-2)",
display: "grid",
placeItems: "center",
}}
>
<Icon name={file.kind === "folder" ? "folder" : "file"} size={16} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 12.5, fontWeight: 540, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{file.name}</div>
<div style={{ fontSize: 11, color: "var(--text-3)" }}>{file.kind.toUpperCase()} · {file.sha256.slice(0, 8)}</div>
</div>
<IconButton icon="arrowUpRight" label="Open" />
</div>
);
}
@@ -0,0 +1,114 @@
import type { ReactNode } from "react";
import { Avatar, Icon, IconButton } from "../../shared/ui";
import type { AgentRecord, ChatMessageRecord, ChatSpaceRecord } from "../../../../shared/domain";
import { countMessagesIn } from "./chat-helpers";
export function ChatSidebar({
channels,
directs,
agents,
messages,
activeSpaceId,
onSelect,
onCreateSpace,
}: {
channels: ChatSpaceRecord[];
directs: ChatSpaceRecord[];
agents: AgentRecord[];
messages: ChatMessageRecord[];
activeSpaceId: string | null;
onSelect: (spaceId: string) => void;
onCreateSpace: () => void;
}) {
return (
<aside className="secondary-pane">
<div className="row" style={{ padding: "4px 8px 12px" }}>
<div style={{ fontSize: 13, fontWeight: 600 }}>Messages</div>
<IconButton icon="plus" label="New space" onClick={onCreateSpace} />
</div>
<NavGroup label="Channels">
{channels.length === 0 ? (
<Empty>No channels.</Empty>
) : channels.map((space) => (
<ChannelItem
key={space.id}
space={space}
active={space.id === activeSpaceId}
count={countMessagesIn(space.id, messages)}
onClick={() => onSelect(space.id)}
/>
))}
</NavGroup>
{directs.length > 0 && (
<NavGroup label="Direct">
{directs.map((space) => (
<DirectItem
key={space.id}
space={space}
agents={agents}
active={space.id === activeSpaceId}
count={countMessagesIn(space.id, messages)}
onClick={() => onSelect(space.id)}
/>
))}
</NavGroup>
)}
</aside>
);
}
function NavGroup({ label, children }: { label: string; children: ReactNode }) {
return (
<div style={{ marginTop: 10 }}>
<div style={{ fontSize: 11, color: "var(--text-3)", padding: "6px 8px", fontWeight: 540, letterSpacing: "0.02em" }}>{label}</div>
<div style={{ display: "flex", flexDirection: "column", gap: 1 }}>{children}</div>
</div>
);
}
function Empty({ children }: { children: ReactNode }) {
return <div style={{ padding: "4px 8px", color: "var(--text-3)", fontSize: 12 }}>{children}</div>;
}
function ChannelItem({
space,
active,
count,
onClick,
}: {
space: ChatSpaceRecord;
active: boolean;
count: number;
onClick: () => void;
}) {
return (
<button type="button" className={`nav-item ${active ? "active" : ""}`.trim()} onClick={onClick}>
<Icon name={space.visibility === "restricted" ? "lock" : "hash"} size={13} color={active ? "var(--text-1)" : "var(--text-3)"} />
<span>{space.name}</span>
{count > 0 && <span className="nav-badge">{count}</span>}
</button>
);
}
function DirectItem({
space,
agents,
active,
count,
onClick,
}: {
space: ChatSpaceRecord;
agents: AgentRecord[];
active: boolean;
count: number;
onClick: () => void;
}) {
const counterpart = agents.find((agent) => space.name.includes(agent.name));
return (
<button type="button" className={`nav-item ${active ? "active" : ""}`.trim()} onClick={onClick}>
{counterpart ? <Avatar name={counterpart.name} size="sm" bot /> : <Icon name="user" size={13} />}
<span>{space.name}</span>
{count > 0 && <span className="nav-badge">{count}</span>}
</button>
);
}
@@ -0,0 +1,109 @@
import type { FormEvent } from "react";
import { Button, Icon, IconButton } from "../../shared/ui";
import type {
ChatAttachmentRecord,
ChatMessageRecord,
ChatThreadRecord,
FileObjectRecord,
} from "../../../../shared/domain";
import { formatTime } from "../types";
import { ChatMessageRow } from "./ChatMessageRow";
import { senderName, type MessageContext } from "./chat-helpers";
export function ChatThreadPanel({
thread,
rootMessage,
messages,
attachments,
files,
context,
onClose,
onSubmit,
}: {
thread: ChatThreadRecord;
rootMessage: ChatMessageRecord | null;
messages: ChatMessageRecord[];
attachments: ChatAttachmentRecord[];
files: FileObjectRecord[];
context: MessageContext;
onClose: () => void;
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
}) {
return (
<aside
style={{
width: 320,
flexShrink: 0,
borderLeft: "1px solid var(--border-soft)",
background: "var(--bg)",
display: "flex",
flexDirection: "column",
minHeight: 0,
}}
>
<div className="row" style={{ padding: "14px 16px", borderBottom: "1px solid var(--border-soft)", flexShrink: 0 }}>
<div style={{ fontSize: 12.5, fontWeight: 600 }}>Thread</div>
<div style={{ marginLeft: "auto" }}>
<IconButton icon="x" label="Close thread" onClick={onClose} />
</div>
</div>
<div style={{ padding: 16, overflow: "auto", flex: 1 }}>
{rootMessage && (
<>
<div style={{ fontSize: 12, color: "var(--text-3)", marginBottom: 10 }}>
{formatTime(rootMessage.createdAt)} · {senderName(rootMessage, context)}
</div>
<div style={{ border: "1px solid var(--border)", borderRadius: 10, padding: 12, background: "var(--panel)", marginBottom: 16 }}>
<ChatMessageRow
message={rootMessage}
attachments={attachments}
files={files}
replyCount={0}
onOpenThread={null}
context={context}
compact
/>
</div>
</>
)}
{messages.map((message) => (
<ChatMessageRow
key={message.id}
message={message}
attachments={attachments}
files={files}
replyCount={0}
onOpenThread={null}
context={context}
compact
/>
))}
{messages.length === 0 && (
<div style={{ marginTop: 14, fontSize: 11, color: "var(--text-3)", textAlign: "center" }}>
{thread.state === "open" ? "No replies yet." : `Thread ${thread.state}`}
</div>
)}
</div>
<form onSubmit={onSubmit} style={{ padding: 12, flexShrink: 0 }}>
<div style={{ border: "1px solid var(--border-strong)", borderRadius: 10, background: "var(--panel)", padding: "8px 10px" }}>
<textarea
name="message"
rows={2}
placeholder="Reply in thread…"
aria-label="Thread reply"
style={{ width: "100%", border: 0, resize: "none", outline: "none", fontSize: 13, background: "transparent", padding: 0, minHeight: 36 }}
onKeyDown={(event) => {
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
event.currentTarget.form?.requestSubmit();
}
}}
/>
<div className="row" style={{ paddingTop: 6, justifyContent: "flex-end" }}>
<Button tone="primary" size="small" type="submit"><Icon name="send" size={12} /> Reply</Button>
</div>
</div>
</form>
</aside>
);
}
@@ -0,0 +1,116 @@
import type { ReactNode } from "react";
import { Button, Icon, Pill } from "../../shared/ui";
import type {
ApprovalRecord,
ChatAttachmentRecord,
ChatMessageRecord,
ChatThreadRecord,
FileObjectRecord,
} from "../../../../shared/domain";
import { ChatMessageRow } from "./ChatMessageRow";
import { formatDay, type MessageContext } from "./chat-helpers";
export function ChatTimeline({
rootMessages,
messagesInSpace,
threadsInSpace,
pendingApprovals,
attachments,
files,
context,
onOpenThread,
onApprove,
onDecline,
}: {
rootMessages: ChatMessageRecord[];
messagesInSpace: ChatMessageRecord[];
threadsInSpace: ChatThreadRecord[];
pendingApprovals: ApprovalRecord[];
attachments: ChatAttachmentRecord[];
files: FileObjectRecord[];
context: MessageContext;
onOpenThread: (threadId: string) => void;
onApprove: (approvalId: string) => void;
onDecline: (approvalId: string) => void;
}) {
const elements: ReactNode[] = [];
let lastDay = "";
rootMessages.forEach((message) => {
const day = formatDay(message.createdAt);
if (day !== lastDay) {
lastDay = day;
elements.push(<DayDivider key={`day-${day}`} label={day} />);
}
const thread = threadsInSpace.find((t) => t.rootMessageId === message.id) ?? null;
const replyCount = thread
? messagesInSpace.filter((m) => m.threadId === thread.id && m.id !== thread.rootMessageId).length
: 0;
elements.push(
<ChatMessageRow
key={message.id}
message={message}
attachments={attachments}
files={files}
replyCount={replyCount}
onOpenThread={thread ? () => onOpenThread(thread.id) : null}
context={context}
/>,
);
});
pendingApprovals.forEach((approval) => {
elements.push(
<ApprovalInline
key={`approval-${approval.id}`}
approval={approval}
onApprove={() => onApprove(approval.id)}
onDecline={() => onDecline(approval.id)}
/>,
);
});
return <>{elements}</>;
}
function DayDivider({ label }: { label: string }) {
return (
<div className="row" style={{ gap: 12, margin: "8px 0 16px" }}>
<div style={{ flex: 1, height: 1, background: "var(--border-soft)" }} />
<div style={{ fontSize: 11, color: "var(--text-3)", fontWeight: 540 }}>{label}</div>
<div style={{ flex: 1, height: 1, background: "var(--border-soft)" }} />
</div>
);
}
function ApprovalInline({
approval,
onApprove,
onDecline,
}: {
approval: ApprovalRecord;
onApprove: () => void;
onDecline: () => void;
}) {
return (
<div
style={{
margin: "14px 0 18px 44px",
maxWidth: 480,
border: "1px solid var(--border-strong)",
borderRadius: 12,
background: "var(--panel)",
padding: 14,
}}
>
<div className="row" style={{ marginBottom: 8 }}>
<Icon name="approve" size={14} color="var(--amber)" />
<span style={{ fontSize: 12, color: "var(--amber)", fontWeight: 540, letterSpacing: "0.01em" }}>Approval needed</span>
<Pill tone={approval.risk === "high" ? "red" : approval.risk === "medium" ? "amber" : undefined}>{approval.risk}</Pill>
</div>
<div style={{ fontSize: 14, fontWeight: 540, marginBottom: 4 }}>{approval.title}</div>
<div style={{ fontSize: 12.5, color: "var(--text-3)", marginBottom: 12, lineHeight: 1.5 }}>{approval.details}</div>
<div className="row" style={{ gap: 6 }}>
<Button tone="primary" size="small" onClick={onApprove}>Approve</Button>
<Button size="small" onClick={onDecline}>Decline</Button>
</div>
</div>
);
}
@@ -0,0 +1,98 @@
import type {
AgentRecord,
ApprovalRecord,
ChatAttachmentRecord,
ChatMessageRecord,
ChatSpaceRecord,
ChatThreadRecord,
EmployeeRecord,
FileObjectRecord,
} from "../../../../shared/domain";
export interface MessageContext {
employees: EmployeeRecord[];
agents: AgentRecord[];
}
export function senderName(message: ChatMessageRecord, context: MessageContext): string {
if (message.senderType === "employee") {
const employee = context.employees.find((item) => item.id === message.senderId);
if (!employee) throw new Error("Message employee sender is missing.");
return employee.displayName;
}
if (message.senderType === "agent") {
const agent = context.agents.find((item) => item.id === message.senderId);
if (!agent) throw new Error("Message agent sender is missing.");
return agent.name;
}
if (message.senderType === "board") return "Board";
return "System";
}
export function isRootMessage(message: ChatMessageRecord, threads: ChatThreadRecord[]): boolean {
if (!message.threadId) return true;
const thread = threads.find((item) => item.id === message.threadId);
if (!thread) return true;
return thread.rootMessageId === message.id;
}
export function messageAttachments(
messageId: string,
attachments: ChatAttachmentRecord[],
files: FileObjectRecord[],
): FileObjectRecord[] {
return attachments
.filter((attachment) => attachment.messageId === messageId)
.map((attachment) => files.find((file) => file.id === attachment.fileId))
.filter((file): file is FileObjectRecord => Boolean(file));
}
export function collectParticipants(
messages: ChatMessageRecord[],
employees: EmployeeRecord[],
agents: AgentRecord[],
): string[] {
const seen = new Map<string, string>();
for (const message of messages) {
if (!message.senderId) continue;
if (seen.has(message.senderId)) continue;
if (message.senderType === "employee") {
const employee = employees.find((item) => item.id === message.senderId);
if (employee) seen.set(message.senderId, employee.displayName);
} else if (message.senderType === "agent") {
const agent = agents.find((item) => item.id === message.senderId);
if (agent) seen.set(message.senderId, agent.name);
}
}
return [...seen.values()];
}
export function filterApprovalsForSpace(
approvals: ApprovalRecord[],
space: ChatSpaceRecord | null,
): ApprovalRecord[] {
if (!space) return [];
if (space.name === "board" || space.visibility === "restricted") return approvals;
return [];
}
export function countMessagesIn(spaceId: string, messages: ChatMessageRecord[]): number {
return messages.filter((message) => message.spaceId === spaceId).length;
}
export function formatDay(iso: string): string {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return "—";
const today = new Date();
if (sameDay(date, today)) return "Today";
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
if (sameDay(date, yesterday)) return "Yesterday";
return date.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" });
}
function sameDay(a: Date, b: Date): boolean {
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
}
export const SLASH_COMMANDS = ["/agent", "/handoff", "/approve", "/deny", "/skill", "/runbook", "/file", "/escalate"];
@@ -0,0 +1,88 @@
import { Avatar, Pill } from "../../shared/ui";
import type { AuditEventRecord, EmployeeRecord } from "../../../../shared/domain";
import { formatRelative } from "../types";
export function ProfilePanel({ employee }: { employee: EmployeeRecord }) {
return (
<div className="page">
<h1 className="page-h1">Profile</h1>
<p className="page-sub">Your identity in this workspace.</p>
<div className="card" style={{ padding: 18, display: "flex", flexDirection: "column", gap: 14 }}>
<div className="row" style={{ gap: 14 }}>
<Avatar name={employee.displayName} size="xl" />
<div>
<div style={{ fontSize: 18, fontWeight: 620 }}>{employee.displayName}</div>
<div style={{ color: "var(--text-3)" }}>{employee.role}</div>
</div>
</div>
<div className="row">
<span className="card-label" style={{ width: 140 }}>Joined</span>
<span className="mono" style={{ color: "var(--text-2)" }}>{new Date(employee.createdAt).toLocaleDateString()}</span>
</div>
<div className="row">
<span className="card-label" style={{ width: 140 }}>Employee ID</span>
<span className="mono" style={{ color: "var(--text-2)" }}>{employee.id}</span>
</div>
</div>
</div>
);
}
export function NotificationsPanel({ events, employee }: { events: AuditEventRecord[]; employee: EmployeeRecord }) {
const visible = events.filter((event) => event.actorId === employee.id || event.actorType === "system").slice(0, 40);
return (
<div className="page">
<h1 className="page-h1">Notifications</h1>
<p className="page-sub">Real audit events flagged for you.</p>
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
{visible.length === 0 ? (
<div style={{ padding: 18, color: "var(--text-3)" }}>Quiet inbox.</div>
) : visible.map((event, index) => (
<div
key={event.id}
className="row"
style={{ padding: "12px 18px", borderBottom: index === visible.length - 1 ? "none" : "1px solid var(--border-soft)" }}
>
<Pill>{event.entityType.replaceAll("_", " ")}</Pill>
<span style={{ flex: 1, fontWeight: 480, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{event.action}</span>
<span style={{ color: "var(--text-3)", fontSize: 12 }}>{formatRelative(event.createdAt)}</span>
</div>
))}
</div>
</div>
);
}
export function SecurityPanel({ events }: { events: AuditEventRecord[] }) {
const security = events.filter((event) => isSecurityEvent(event.action, event.entityType)).slice(0, 50);
return (
<div className="page">
<h1 className="page-h1">Security</h1>
<p className="page-sub">Recent security-relevant audit events.</p>
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
{security.length === 0 ? (
<div style={{ padding: 18, color: "var(--text-3)" }}>No security events recorded.</div>
) : security.map((event, index) => (
<div
key={event.id}
className="row"
style={{ padding: "12px 18px", borderBottom: index === security.length - 1 ? "none" : "1px solid var(--border-soft)" }}
>
<Pill tone="amber">{event.entityType.replaceAll("_", " ")}</Pill>
<span style={{ flex: 1, fontWeight: 480, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{event.action}</span>
<span style={{ color: "var(--text-3)", fontSize: 12 }}>{formatRelative(event.createdAt)}</span>
</div>
))}
</div>
</div>
);
}
function isSecurityEvent(action: string, entityType: string): boolean {
return (
action.includes("credential") ||
action.includes("login") ||
entityType.includes("approval") ||
entityType.includes("policy")
);
}
@@ -0,0 +1,64 @@
import { Pill } from "../../shared/ui";
import type { AgentRecord, ProviderRecord, ProviderRequestRecord } from "../../../../shared/domain";
import { money } from "../types";
export function BillingPanel({
agents,
providers,
providerRequests,
}: {
agents: AgentRecord[];
providers: ProviderRecord[];
providerRequests: ProviderRequestRecord[];
}) {
const totalBudget = agents.reduce((sum, agent) => sum + agent.budgetCents, 0);
const monthRequests = providerRequests.filter((request) => withinDays(request.createdAt, 30));
return (
<div className="page">
<h1 className="page-h1">Billing</h1>
<p className="page-sub">Allocations, provider activity, and budget windows.</p>
<div className="card" style={{ padding: 18, marginBottom: 16 }}>
<div className="section-label" style={{ marginBottom: 8 }}>Monthly allocation</div>
<div className="num" style={{ fontSize: 28, fontWeight: 600 }}>{money(totalBudget)}</div>
<div style={{ fontSize: 12, color: "var(--text-3)" }}>across {agents.length} {agents.length === 1 ? "agent" : "agents"}</div>
</div>
<div className="section-label" style={{ marginBottom: 10 }}>Activity (30 days)</div>
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
<div
className="table-row head"
style={{ gridTemplateColumns: "minmax(0, 1.6fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr)" }}
>
<div>Provider</div><div>Requests</div><div>Errors</div><div>Agents</div>
</div>
{providers.length === 0 ? (
<div style={{ padding: 18, color: "var(--text-3)" }}>No providers configured.</div>
) : providers.map((provider, index) => {
const requests = monthRequests.filter((request) => request.providerId === provider.id);
const errors = requests.filter((request) => request.state === "failed").length;
const boundAgents = agents.filter((agent) => agent.modelProviderId === provider.id).length;
const last = index === providers.length - 1;
return (
<div
key={provider.id}
className="table-row"
style={{ gridTemplateColumns: "minmax(0, 1.6fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr)", borderBottom: last ? "none" : "1px solid var(--border-soft)" }}
>
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 540 }}>{provider.label}</div>
<div style={{ fontSize: 11, color: "var(--text-3)" }}>{provider.model ?? "no default model"}</div>
</div>
<div className="num mono">{requests.length}</div>
<div>{errors > 0 ? <Pill tone="amber">{errors}</Pill> : <span className="num mono" style={{ color: "var(--text-3)" }}>0</span>}</div>
<div className="num mono" style={{ color: "var(--text-2)" }}>{boundAgents}</div>
</div>
);
})}
</div>
</div>
);
}
function withinDays(iso: string, days: number): boolean {
return Date.now() - new Date(iso).getTime() < days * 24 * 60 * 60 * 1000;
}
@@ -0,0 +1,68 @@
import { Avatar, Pill } from "../../shared/ui";
import type { AgentRecord, EmployeeRecord } from "../../../../shared/domain";
import { money } from "../types";
export function MembersPanel({
employees,
agents,
}: {
employees: EmployeeRecord[];
agents: AgentRecord[];
}) {
const total = employees.length + agents.length;
return (
<div className="page">
<h1 className="page-h1">Members</h1>
<p className="page-sub">
{total} {total === 1 ? "member" : "members"} · {employees.length} {employees.length === 1 ? "human" : "humans"} · {agents.length} {agents.length === 1 ? "agent" : "agents"}
</p>
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
<div
className="table-row head"
style={{ gridTemplateColumns: "minmax(0, 2fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr)" }}
>
<div>Member</div><div>Type</div><div>Department</div><div>Budget</div>
</div>
{employees.map((employee) => (
<div
key={employee.id}
className="table-row"
style={{ gridTemplateColumns: "minmax(0, 2fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr)" }}
>
<div className="row" style={{ gap: 10 }}>
<Avatar name={employee.displayName} size="sm" />
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 500 }}>{employee.displayName}</div>
<div style={{ fontSize: 11, color: "var(--text-3)" }}>{employee.role}</div>
</div>
</div>
<div><Pill>Human</Pill></div>
<div style={{ color: "var(--text-2)" }}>Executive</div>
<div style={{ color: "var(--text-4)" }}>—</div>
</div>
))}
{agents.map((agent, index) => (
<div
key={agent.id}
className="table-row"
style={{
gridTemplateColumns: "minmax(0, 2fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr)",
borderBottom: index === agents.length - 1 ? "none" : undefined,
}}
>
<div className="row" style={{ gap: 10 }}>
<Avatar name={agent.name} size="sm" bot />
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 500 }}>{agent.name}</div>
<div style={{ fontSize: 11, color: "var(--text-3)" }}>{agent.role}</div>
</div>
</div>
<div><Pill tone="violet" dot>Agent</Pill></div>
<div style={{ color: "var(--text-2)" }}>{agent.department}</div>
<div className="num mono" style={{ color: "var(--text-2)" }}>{money(agent.budgetCents)}</div>
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,93 @@
import { useState, type FormEvent } from "react";
import { Button, Field, Icon, Pill } from "../../shared/ui";
import { Dialog } from "../../shared/Dialog";
import type { MigrationReportRecord } from "../../../../shared/domain";
import type { SnapshotActions } from "../../app/useSnapshot";
import { formatRelative } from "../types";
interface ParsedReport {
tableCounts: Record<string, number>;
tableChecksums: Record<string, string>;
idMaps: Record<string, string[]>;
failures: Array<{ table: string; reason: string }>;
rollbackPack?: { sourceDatabasePath: string; capturedAt: string; restoreStrategy: string };
}
export function MigrationPanel({
orgId,
reports,
actions,
}: {
orgId: string;
reports: MigrationReportRecord[];
actions: SnapshotActions;
}) {
const [open, setOpen] = useState(false);
const submit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
void actions.run(() => window.agentCompany.createMigrationReport({
orgId,
sourceDatabasePath: String(form.get("sourceDatabasePath") ?? "").trim(),
})).then((result) => { if (result) setOpen(false); });
};
return (
<div className="page">
<div className="row" style={{ marginBottom: 24 }}>
<div>
<h1 className="page-h1">Migration</h1>
<p className="page-sub" style={{ marginBottom: 0 }}>{reports.length} migration {reports.length === 1 ? "report" : "reports"}.</p>
</div>
<div style={{ marginLeft: "auto" }}>
<Button tone="primary" onClick={() => setOpen(true)}><Icon name="plus" size={13} /> Report</Button>
</div>
</div>
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
{reports.length === 0 ? (
<div style={{ padding: 18, color: "var(--text-3)" }}>No migration reports yet.</div>
) : reports.map((report, index) => (
<ReportRow key={report.id} report={report} last={index === reports.length - 1} />
))}
</div>
<Dialog title="Migration report" open={open} submitLabel="Create" onClose={() => setOpen(false)} onSubmit={submit}>
<Field label="Source database path"><input className="input" name="sourceDatabasePath" required /></Field>
</Dialog>
</div>
);
}
function ReportRow({ report, last }: { report: MigrationReportRecord; last: boolean }) {
const body = parseReport(report.reportJson);
const idCount = Object.values(body.idMaps).reduce((sum, ids) => sum + ids.length, 0);
return (
<div
className="table-row"
style={{ gridTemplateColumns: "minmax(0, 1.4fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr)", borderBottom: last ? "none" : "1px solid var(--border-soft)" }}
>
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 540, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} title={report.sourceRef}>{report.sourceRef}</div>
<div style={{ fontSize: 11, color: "var(--text-3)" }}>{formatRelative(report.createdAt)}</div>
</div>
<div className="row" style={{ flexWrap: "wrap", gap: 4 }}>
{Object.entries(body.tableCounts).map(([table, count]) => (
<Pill key={table}>{table}: {count}</Pill>
))}
</div>
<Pill>{Object.keys(body.tableChecksums).length} checksums</Pill>
<div className="row" style={{ flexWrap: "wrap", gap: 4 }}>
<Pill>{idCount} ids</Pill>
{body.failures.length > 0 ? <Pill tone="amber">{body.failures.length} failures</Pill> : null}
</div>
</div>
);
}
function parseReport(reportJson: string): ParsedReport {
const parsed = JSON.parse(reportJson) as ParsedReport;
if (!parsed.tableCounts || !parsed.tableChecksums || !parsed.idMaps || !Array.isArray(parsed.failures)) {
throw new Error("Migration report is invalid.");
}
return parsed;
}
@@ -0,0 +1,96 @@
import { Pill } from "../../shared/ui";
import type { AgentRecord, ProviderRecord } from "../../../../shared/domain";
export function ModelsPanel({
providers,
agents,
}: {
providers: ProviderRecord[];
agents: AgentRecord[];
}) {
const rows = collectModels(providers, agents);
return (
<div className="page">
<h1 className="page-h1">Models</h1>
<p className="page-sub">Models that agents reference, derived from connected providers and agent assignments.</p>
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
<div
className="table-row head"
style={{ gridTemplateColumns: "minmax(0, 1.4fr) minmax(0, 1.6fr) minmax(0, 1fr) minmax(0, 1fr)" }}
>
<div>Provider</div><div>Model</div><div>Agents</div><div>Source</div>
</div>
{rows.length === 0 ? (
<div style={{ padding: 18, color: "var(--text-3)" }}>No models configured.</div>
) : rows.map((row, index) => (
<div
key={`${row.providerId}-${row.model}`}
className="table-row"
style={{ gridTemplateColumns: "minmax(0, 1.4fr) minmax(0, 1.6fr) minmax(0, 1fr) minmax(0, 1fr)", borderBottom: index === rows.length - 1 ? "none" : "1px solid var(--border-soft)" }}
>
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 540 }}>{row.providerLabel}</div>
<div style={{ fontSize: 11, color: "var(--text-3)" }}>{row.authType}</div>
</div>
<div className="mono" style={{ fontSize: 12, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{row.model}</div>
<div className="num mono">{row.agentCount}</div>
<div>{row.source === "default" ? <Pill>Provider default</Pill> : <Pill tone="violet">Agent assignment</Pill>}</div>
</div>
))}
</div>
</div>
);
}
interface ModelRow {
providerId: string;
providerLabel: string;
authType: string;
model: string;
agentCount: number;
source: "default" | "agent";
}
function collectModels(providers: ProviderRecord[], agents: AgentRecord[]): ModelRow[] {
const map = new Map<string, ModelRow>();
for (const provider of providers) {
if (!provider.enabled || !provider.model) continue;
const key = `${provider.id}::${provider.model}`;
map.set(key, {
providerId: provider.id,
providerLabel: provider.label,
authType: humanAuth(provider.authType),
model: provider.model,
agentCount: agents.filter((agent) => agent.modelProviderId === provider.id && (agent.modelId === provider.model || !agent.modelId)).length,
source: "default",
});
}
for (const agent of agents) {
if (!agent.modelProviderId || !agent.modelId) continue;
const provider = providers.find((item) => item.id === agent.modelProviderId);
if (!provider) continue;
const key = `${provider.id}::${agent.modelId}`;
const existing = map.get(key);
if (existing) {
existing.agentCount += 1;
continue;
}
map.set(key, {
providerId: provider.id,
providerLabel: provider.label,
authType: humanAuth(provider.authType),
model: agent.modelId,
agentCount: 1,
source: "agent",
});
}
return [...map.values()].sort((a, b) => a.providerLabel.localeCompare(b.providerLabel));
}
function humanAuth(authType: string): string {
if (authType === "api_key") return "API key";
if (authType === "oauth_pkce") return "OAuth";
if (authType === "account_subscription") return "Account";
if (authType === "cloud") return "Cloud";
return authType;
}
@@ -0,0 +1,37 @@
import { Pill } from "../../shared/ui";
import type { PolicyRuleRecord } from "../../../../shared/domain";
export function PoliciesPanel({ policies }: { policies: PolicyRuleRecord[] }) {
return (
<div className="page">
<h1 className="page-h1">Policies</h1>
<p className="page-sub">Policy rules evaluated for actions across the workspace.</p>
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
<div
className="table-row head"
style={{ gridTemplateColumns: "minmax(0, 1.2fr) minmax(0, 1.2fr) minmax(0, 1fr) minmax(0, 1fr)" }}
>
<div>Action</div><div>Effect</div><div>Risk ceiling</div><div>Subject</div>
</div>
{policies.length === 0 ? (
<div style={{ padding: 18, color: "var(--text-3)" }}>No policies.</div>
) : policies.map((policy, index) => (
<div
key={policy.id}
className="table-row"
style={{ gridTemplateColumns: "minmax(0, 1.2fr) minmax(0, 1.2fr) minmax(0, 1fr) minmax(0, 1fr)", borderBottom: index === policies.length - 1 ? "none" : "1px solid var(--border-soft)" }}
>
<div style={{ fontWeight: 540 }}>{policy.action.replaceAll("_", " ")}</div>
<div><Pill>{policy.effect.replaceAll("_", " ")}</Pill></div>
<div>
<Pill tone={policy.riskCeiling === "high" ? "red" : policy.riskCeiling === "medium" ? "amber" : undefined}>
{policy.riskCeiling}
</Pill>
</div>
<div style={{ fontSize: 12, color: "var(--text-3)" }}>{policy.subjectType}</div>
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,35 @@
const PROVIDER_BRAND: Record<string, [string, string]> = {
"Anthropic": ["#cb9a5b", "A"],
"OpenAI": ["#0a0a0b", "O"],
"xAI Grok": ["#0a0a0b", "X"],
"GitHub Copilot": ["#2da44e", "⌬"],
"Google Antigravity": ["#3a7bd5", "G"],
"GPT account": ["#10a37f", "◔"],
"Azure OpenAI": ["#0078d4", "Az"],
"AWS Bedrock": ["#e87722", "Bd"],
"Vertex AI": ["#4285f4", "V"],
"Groq": ["#f55036", "Q"],
};
export function ProviderLogo({ label, size = 30 }: { label: string; size?: number }) {
const entry = PROVIDER_BRAND[label] ?? ["#a1a1aa", label.slice(0, 1).toUpperCase()];
const [background, glyph] = entry;
return (
<div
style={{
width: size,
height: size,
borderRadius: 7,
background,
color: "#fff",
display: "grid",
placeItems: "center",
fontSize: Math.round(size * 0.42),
fontWeight: 600,
flexShrink: 0,
}}
>
{glyph}
</div>
);
}
@@ -0,0 +1,145 @@
import { useState, type FormEvent } from "react";
import { Button, Field, Pill } from "../../shared/ui";
import { Dialog } from "../../shared/Dialog";
import type { AgentRecord, ProviderRecord } from "../../../../shared/domain";
import { ProviderLogo } from "./ProviderLogo";
import type { SnapshotActions } from "../../app/useSnapshot";
interface ProvidersPanelProps {
orgId: string;
providers: ProviderRecord[];
agents: AgentRecord[];
actions: SnapshotActions;
}
export function ProvidersPanel({ orgId, providers, agents, actions }: ProvidersPanelProps) {
const [active, setActive] = useState<ProviderRecord | null>(null);
const connected = providers.filter((provider) => provider.enabled);
const available = providers.filter((provider) => !provider.enabled);
const save = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!active) return;
const form = new FormData(event.currentTarget);
void actions.run(() => window.agentCompany.saveProviderCredential({
orgId,
providerId: active.id,
secret: String(form.get("secret") ?? "").trim(),
model: String(form.get("model") ?? "").trim() || null,
})).then((result) => { if (result) setActive(null); });
};
return (
<div className="page">
<h1 className="page-h1">Providers</h1>
<p className="page-sub">Connect model providers — keys, accounts, OAuth.</p>
{connected.length > 0 && (
<>
<div className="section-label" style={{ marginBottom: 10 }}>Connected</div>
<div style={{ display: "flex", flexDirection: "column", gap: 8, marginBottom: 28 }}>
{connected.map((provider) => (
<ProviderRow
key={provider.id}
provider={provider}
agents={agents}
onManage={() => setActive(provider)}
/>
))}
</div>
</>
)}
<div className="section-label" style={{ marginBottom: 10 }}>Available</div>
{available.length === 0 ? (
<div className="card" style={{ color: "var(--text-3)" }}>All providers connected.</div>
) : (
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(260px, 1fr))", gap: 10 }}>
{available.map((provider) => (
<ProviderCard key={provider.id} provider={provider} onConnect={() => setActive(provider)} />
))}
</div>
)}
<Dialog title="Provider credential" open={Boolean(active)} submitLabel="Save" onClose={() => setActive(null)} onSubmit={save}>
<Field label="Provider"><input className="input" value={active?.label ?? ""} readOnly /></Field>
<Field label="Auth type"><input className="input" value={active ? readableAuth(active.authType) : ""} readOnly /></Field>
<Field label="Secret"><input className="input" name="secret" type="password" required autoFocus /></Field>
<Field label="Default model"><input className="input" name="model" defaultValue={active?.model ?? ""} placeholder="Optional" /></Field>
</Dialog>
</div>
);
}
function ProviderRow({
provider,
agents,
onManage,
}: {
provider: ProviderRecord;
agents: AgentRecord[];
onManage: () => void;
}) {
const boundCount = agents.filter((agent) => agent.modelProviderId === provider.id).length;
return (
<div
style={{
display: "flex",
alignItems: "center",
gap: 14,
padding: 14,
background: "var(--panel)",
border: "1px solid var(--border)",
borderRadius: 10,
}}
>
<ProviderLogo label={provider.label} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13.5, fontWeight: 540, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{provider.label}</div>
<div style={{ fontSize: 12, color: "var(--text-3)" }}>
{provider.model ? `${provider.model} · ` : ""}{readableAuth(provider.authType)}
</div>
</div>
<div style={{ textAlign: "right", minWidth: 120 }}>
<div className="num mono" style={{ fontSize: 13, fontWeight: 540 }}>{boundCount} {boundCount === 1 ? "agent" : "agents"}</div>
<div className="mono" style={{ fontSize: 11, color: "var(--text-3)" }}>bound</div>
</div>
<Button onClick={onManage}>Manage</Button>
</div>
);
}
function ProviderCard({ provider, onConnect }: { provider: ProviderRecord; onConnect: () => void }) {
return (
<div
style={{
display: "flex",
alignItems: "center",
gap: 12,
padding: 14,
background: "var(--panel)",
border: "1px solid var(--border)",
borderRadius: 10,
minWidth: 0,
}}
>
<ProviderLogo label={provider.label} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13.5, fontWeight: 540, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{provider.label}</div>
<div style={{ fontSize: 12, color: "var(--text-3)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{readableAuth(provider.authType)}
</div>
</div>
<Pill>{readableAuth(provider.authType)}</Pill>
<Button size="small" onClick={onConnect}>Connect</Button>
</div>
);
}
function readableAuth(authType: ProviderRecord["authType"]): string {
if (authType === "api_key") return "API key";
if (authType === "oauth_pkce") return "OAuth";
if (authType === "account_subscription") return "Account";
if (authType === "cloud") return "Cloud";
return authType;
}
@@ -0,0 +1,130 @@
import { useState, type FormEvent } from "react";
import { Button, Field, Icon, Pill } from "../../shared/ui";
import { Dialog } from "../../shared/Dialog";
import type {
AgentRecord,
SkillBindingRecord,
SkillDefinitionRecord,
SkillRevisionRecord,
} from "../../../../shared/domain";
import type { SnapshotActions } from "../../app/useSnapshot";
interface SkillsPanelProps {
orgId: string;
skills: SkillDefinitionRecord[];
revisions: SkillRevisionRecord[];
bindings: SkillBindingRecord[];
agents: AgentRecord[];
actions: SnapshotActions;
}
export function SkillsPanel({ orgId, skills, revisions, bindings, agents, actions }: SkillsPanelProps) {
const [importOpen, setImportOpen] = useState(false);
const importSkill = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
void actions.run(() => window.agentCompany.importSkillDirectory({
orgId,
directoryPath: String(form.get("directoryPath") ?? "").trim(),
versionSemver: String(form.get("versionSemver") ?? "").trim(),
})).then((result) => { if (result) setImportOpen(false); });
};
const bind = (skill: SkillDefinitionRecord, agentId: string) => {
const latest = revisions.find((revision) => revision.skillId === skill.id);
if (!latest) return;
void actions.run(() => window.agentCompany.bindSkill({ orgId, agentId, skillId: skill.id, revisionId: latest.id }));
};
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.length === 1 ? "definition" : "definitions"} · {revisions.length} revisions · {bindings.filter((b) => b.enabled).length} bindings
</p>
</div>
<div style={{ marginLeft: "auto" }}>
<Button tone="primary" onClick={() => setImportOpen(true)}><Icon name="plus" size={13} /> Import</Button>
</div>
</div>
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
<div
className="table-row head"
style={{ gridTemplateColumns: "minmax(0, 1.6fr) minmax(0, 1fr) minmax(0, 1.4fr) minmax(0, 1.6fr)" }}
>
<div>Skill</div><div>Version</div><div>Bindings</div><div>Bind to agent</div>
</div>
{skills.length === 0 ? (
<div style={{ padding: 18, color: "var(--text-3)" }}>No skills imported yet.</div>
) : skills.map((skill, index) => {
const last = index === skills.length - 1;
const latest = revisions.find((revision) => revision.skillId === skill.id);
const skillBindings = bindings.filter((binding) => binding.skillId === skill.id && binding.enabled);
return (
<div
key={skill.id}
className="table-row"
style={{ gridTemplateColumns: "minmax(0, 1.6fr) minmax(0, 1fr) minmax(0, 1.4fr) minmax(0, 1.6fr)", borderBottom: last ? "none" : "1px solid var(--border-soft)" }}
>
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 540, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{skill.name}</div>
<div style={{ color: "var(--text-3)", fontSize: 11 }}>{skill.slug}</div>
</div>
<Pill>{latest ? latest.versionSemver : "—"}</Pill>
<div className="row" style={{ gap: 6, flexWrap: "wrap" }}>
{skillBindings.length === 0 ? (
<span style={{ color: "var(--text-3)", fontSize: 12 }}>Not bound</span>
) : skillBindings.map((binding) => (
<Pill key={binding.id}>{agentName(binding.agentId, agents)}</Pill>
))}
</div>
<BindAgent skill={skill} agents={agents} onBind={(agentId) => bind(skill, agentId)} disabled={!latest} />
</div>
);
})}
</div>
<Dialog title="Import skill" open={importOpen} submitLabel="Import" onClose={() => setImportOpen(false)} onSubmit={importSkill}>
<Field label="Directory path"><input className="input" name="directoryPath" required /></Field>
<Field label="Version (semver)"><input className="input" name="versionSemver" required defaultValue="1.0.0" /></Field>
</Dialog>
</div>
);
}
function BindAgent({
skill: _skill,
agents,
onBind,
disabled,
}: {
skill: SkillDefinitionRecord;
agents: AgentRecord[];
onBind: (agentId: string) => void;
disabled: boolean;
}) {
if (agents.length === 0) return <span style={{ color: "var(--text-3)", fontSize: 12 }}>No agents available</span>;
return (
<select
className="input"
style={{ height: 28, padding: "0 8px", fontSize: 12 }}
defaultValue=""
disabled={disabled}
onChange={(event) => {
if (event.target.value) onBind(event.target.value);
event.target.value = "";
}}
>
<option value="">Pick an agent…</option>
{agents.map((agent) => <option key={agent.id} value={agent.id}>{agent.name}</option>)}
</select>
);
}
function agentName(agentId: string, agents: AgentRecord[]): string {
return agents.find((agent) => agent.id === agentId)?.name ?? agentId;
}
@@ -0,0 +1,60 @@
import type { OrganizationRecord, WorkspaceRecord } from "../../../../shared/domain";
import { Pill } from "../../shared/ui";
export function WorkspacePanel({
organization,
workspaces,
}: {
organization: OrganizationRecord;
workspaces: WorkspaceRecord[];
}) {
return (
<div className="page">
<h1 className="page-h1">Workspace</h1>
<p className="page-sub">Identity, mission, and execution mode for {organization.name}.</p>
<div className="card" style={{ padding: 18, marginBottom: 20 }}>
<div className="section-label" style={{ marginBottom: 12 }}>Identity</div>
<KeyValue label="Name" value={organization.name} />
<KeyValue label="Mission" value={organization.mission || "—"} />
<KeyValue label="Goal" value={organization.goal || "—"} />
<KeyValue label="Description" value={organization.description || "—"} />
</div>
<div className="card" style={{ padding: 18, marginBottom: 20 }}>
<div className="section-label" style={{ marginBottom: 12 }}>Execution mode</div>
<div className="row" style={{ gap: 8 }}>
<Pill tone="violet" dot>{organization.executionMode === "ai_ceo" ? "AI CEO" : "AI Co-Founder"}</Pill>
{organization.autoApproveHires ? <Pill tone="green" dot>Auto-approve hires</Pill> : <Pill>Hires require approval</Pill>}
{organization.reviewDeliverables ? <Pill tone="amber" dot>Reviews deliverables</Pill> : <Pill>Deliverables ship without review</Pill>}
</div>
</div>
<div className="card" style={{ padding: 18 }}>
<div className="section-label" style={{ marginBottom: 12 }}>Workspaces</div>
{workspaces.length === 0 ? (
<div style={{ color: "var(--text-3)" }}>No workspaces.</div>
) : workspaces.map((workspace) => (
<div key={workspace.id} className="row" style={{ padding: "8px 0", borderBottom: "1px solid var(--border-soft)" }}>
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 540 }}>{workspace.name}</div>
<div style={{ fontSize: 12, color: "var(--text-3)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{workspace.localPath}</div>
</div>
<div style={{ marginLeft: "auto" }}>
{workspace.isPrimary ? <Pill tone="violet">Primary</Pill> : <Pill>Secondary</Pill>}
</div>
</div>
))}
</div>
</div>
);
}
function KeyValue({ label, value }: { label: string; value: string }) {
return (
<div className="row" style={{ padding: "6px 0", borderBottom: "1px solid var(--border-soft)" }}>
<span style={{ color: "var(--text-3)", width: 120, flexShrink: 0, fontSize: 12 }}>{label}</span>
<span style={{ flex: 1, color: "var(--text-1)", overflow: "hidden", textOverflow: "ellipsis" }}>{value}</span>
</div>
);
}
@@ -0,0 +1,33 @@
import type { SocialPlatform } from "../../../../shared/domain";
const PLATFORM_VISUAL: Record<SocialPlatform, { background: string; label: string; font: string }> = {
x: { background: "#0a0a0b", label: "𝕏", font: "system-ui" },
linkedin: { background: "#0a66c2", label: "in", font: "inherit" },
youtube: { background: "#ff0000", label: "▶", font: "system-ui" },
instagram: { background: "#e1306c", label: "◎", font: "system-ui" },
};
export function PlatformIcon({ platform, size = 16 }: { platform: SocialPlatform; size?: number }) {
const visual = PLATFORM_VISUAL[platform];
if (!visual) throw new Error("Platform visual is missing.");
const box = size + 12;
return (
<div
style={{
width: box,
height: box,
borderRadius: 6,
background: visual.background,
color: "#fff",
display: "grid",
placeItems: "center",
fontSize: size - 3,
fontWeight: 700,
flexShrink: 0,
fontFamily: visual.font,
}}
>
{visual.label}
</div>
);
}
@@ -0,0 +1,155 @@
import { useMemo, useState } from "react";
import { IconButton } from "../../shared/ui";
import type { ScheduledPostRecord, SocialAccountRecord, SocialPlatform } from "../../../../shared/domain";
import { PLATFORM_COLOR_TOKENS } from "./platform-tokens";
interface MonthAnchor {
year: number;
month: number;
}
export function SocialCalendar({
posts,
accounts,
}: {
posts: ScheduledPostRecord[];
accounts: SocialAccountRecord[];
}) {
const [{ year, month }, setMonth] = useState<MonthAnchor>(() => {
const today = new Date();
return { year: today.getFullYear(), month: today.getMonth() };
});
const accountPlatform = useMemo(() => buildAccountPlatformIndex(accounts), [accounts]);
const eventMap = useMemo(() => buildEventMap(posts, accountPlatform), [posts, accountPlatform]);
const monthLabel = new Date(year, month, 1).toLocaleDateString(undefined, { month: "long", year: "numeric" });
const cells = computeCalendarCells(year, month);
const today = new Date();
return (
<div className="card" style={{ padding: 18, height: "fit-content" }}>
<div className="row" style={{ marginBottom: 14 }}>
<div className="card-label">{monthLabel}</div>
<div style={{ marginLeft: "auto", display: "flex", gap: 2 }}>
<IconButton icon="chevronLeft" label="Previous month" onClick={() => setMonth(stepMonth({ year, month }, -1))} />
<IconButton icon="chevron" label="Next month" onClick={() => setMonth(stepMonth({ year, month }, 1))} />
</div>
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(7, 1fr)", gap: 4, marginBottom: 6 }}>
{["S", "M", "T", "W", "T", "F", "S"].map((label, index) => (
<div key={`${label}-${index}`} style={{ fontSize: 10, color: "var(--text-3)", textAlign: "center", fontWeight: 540 }}>{label}</div>
))}
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(7, 1fr)", gap: 4 }}>
{cells.map((cell) => {
const events = cell.inMonth ? eventMap.get(cell.day) ?? [] : [];
const isToday = cell.inMonth && cell.year === today.getFullYear() && cell.month === today.getMonth() && cell.day === today.getDate();
return (
<div
key={`${cell.year}-${cell.month}-${cell.day}`}
style={{
aspectRatio: "1",
borderRadius: 6,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 3,
fontSize: 11,
color: cell.inMonth ? "var(--text-2)" : "var(--text-4)",
fontWeight: isToday ? 600 : 480,
background: isToday ? "var(--text-1)" : "transparent",
cursor: cell.inMonth ? "pointer" : "default",
}}
>
<span className="num" style={{ color: isToday ? "#fff" : "inherit" }}>{cell.day}</span>
{events.length > 0 && (
<div style={{ display: "flex", gap: 2 }}>
{events.slice(0, 3).map((platform, index) => (
<span
key={`${platform}-${index}`}
style={{
width: 4,
height: 4,
borderRadius: 2,
background: isToday ? "#fff" : PLATFORM_COLOR_TOKENS[platform],
}}
/>
))}
</div>
)}
</div>
);
})}
</div>
<div className="row" style={{ marginTop: 16, fontSize: 11, color: "var(--text-3)", gap: 10 }}>
<Legend label="X" color="var(--accent)" />
<Legend label="LinkedIn" color="var(--green)" />
<Legend label="YouTube" color="var(--amber)" />
<Legend label="Instagram" color="var(--red)" />
</div>
</div>
);
}
function Legend({ label, color }: { label: string; color: string }) {
return (
<span style={{ display: "inline-flex", alignItems: "center", gap: 5 }}>
<span style={{ width: 6, height: 6, borderRadius: 3, background: color }} />
{label}
</span>
);
}
interface CalendarCell {
year: number;
month: number;
day: number;
inMonth: boolean;
}
function computeCalendarCells(year: number, month: number): CalendarCell[] {
const firstOfMonth = new Date(year, month, 1);
const startDay = firstOfMonth.getDay();
const daysInMonth = new Date(year, month + 1, 0).getDate();
const cells: CalendarCell[] = [];
const prevMonthDate = new Date(year, month, 0).getDate();
for (let i = startDay; i > 0; i--) {
cells.push({ year: month === 0 ? year - 1 : year, month: (month + 11) % 12, day: prevMonthDate - i + 1, inMonth: false });
}
for (let day = 1; day <= daysInMonth; day++) {
cells.push({ year, month, day, inMonth: true });
}
while (cells.length % 7 !== 0) {
const next = cells.length - (startDay + daysInMonth) + 1;
cells.push({ year: month === 11 ? year + 1 : year, month: (month + 1) % 12, day: next, inMonth: false });
}
return cells;
}
function buildAccountPlatformIndex(accounts: SocialAccountRecord[]): Map<string, SocialPlatform> {
const index = new Map<string, SocialPlatform>();
for (const account of accounts) index.set(account.id, account.platform);
return index;
}
function buildEventMap(posts: ScheduledPostRecord[], accountPlatform: Map<string, SocialPlatform>): Map<number, SocialPlatform[]> {
const eventMap = new Map<number, SocialPlatform[]>();
for (const post of posts) {
if (post.state !== "scheduled") continue;
const date = new Date(post.scheduledFor);
if (Number.isNaN(date.getTime())) continue;
const platform = accountPlatform.get(post.socialAccountId);
if (!platform) continue;
const day = date.getDate();
const existing = eventMap.get(day) ?? [];
existing.push(platform);
eventMap.set(day, existing);
}
return eventMap;
}
function stepMonth({ year, month }: MonthAnchor, delta: number): MonthAnchor {
const total = year * 12 + month + delta;
return { year: Math.floor(total / 12), month: ((total % 12) + 12) % 12 };
}
@@ -0,0 +1,8 @@
import type { SocialPlatform } from "../../../../shared/domain";
export const PLATFORM_COLOR_TOKENS: Record<SocialPlatform, string> = {
x: "var(--accent)",
linkedin: "var(--green)",
youtube: "var(--amber)",
instagram: "var(--red)",
};
@@ -1,4 +1,8 @@
import type { AppSnapshot } from "../../../shared/domain";
import type {
AppSnapshot,
EmployeeRecord,
OrganizationRecord,
} from "../../../shared/domain";
import type { SnapshotActions } from "../app/useSnapshot";
export interface PageProps {
@@ -12,7 +16,53 @@ export function activeOrgId(snapshot: AppSnapshot): string {
return id;
}
export function requireActiveOrganization(snapshot: AppSnapshot): OrganizationRecord {
const orgId = activeOrgId(snapshot);
const organization = snapshot.organizations.find((item) => item.id === orgId);
if (!organization) throw new Error("Active organization is missing.");
return organization;
}
export function requireActiveEmployee(snapshot: AppSnapshot): EmployeeRecord {
const orgId = activeOrgId(snapshot);
const employee = snapshot.employees.find((item) => item.orgId === orgId);
if (!employee) throw new Error("Active employee is missing.");
return employee;
}
export function money(cents: number): string {
return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 })
.format(cents / 100);
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 0,
}).format(cents / 100);
}
export function formatTime(iso: string): string {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return "—";
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false });
}
export 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`;
}
export function formatRelative(iso: string, reference: Date = new Date()): string {
const target = new Date(iso).getTime();
if (Number.isNaN(target)) return "—";
const diff = reference.getTime() - target;
const minutes = Math.round(diff / 60_000);
if (minutes < 1) return "just now";
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.round(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.round(hours / 24);
if (days < 7) return `${days}d ago`;
const weeks = Math.round(days / 7);
if (weeks < 5) return `${weeks}w ago`;
return new Date(iso).toLocaleDateString();
}
@@ -0,0 +1,76 @@
import type { ReactNode } from "react";
const PATHS: Record<string, ReactNode> = {
home: <><path d="M3 12 12 3l9 9" /><path d="M5 10v10h14V10" /></>,
inbox: <><path d="M22 12h-6l-2 3h-4l-2-3H2" /><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11Z" /></>,
team: <><circle cx="9" cy="8" r="3.2" /><path d="M3 20c0-3.3 2.7-6 6-6s6 2.7 6 6" /><circle cx="17" cy="9" r="2.6" /><path d="M21 18.5c0-2.5-2-4.5-4.5-4.5" /></>,
work: <><rect x="3" y="4" width="18" height="16" rx="2" /><path d="M8 4v16M16 4v16" /></>,
chat: <path d="M21 12a8 8 0 0 1-11.6 7.15L4 20l1-4.4A8 8 0 1 1 21 12Z" />,
docs: <><path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z" /><path d="M14 3v5h5" /></>,
approve: <><path d="M9 12l2 2 4-4" /><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Z" /></>,
social: <><path d="M4 11a8 8 0 0 1 8-8" /><path d="M4 11v4a4 4 0 0 0 4 4h1" /><circle cx="18" cy="6" r="2" /><circle cx="6" cy="18" r="2" /><circle cx="18" cy="18" r="2" /><path d="M8 17l8-9M18 8v8" /></>,
settings: <><circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1Z" /></>,
search: <><circle cx="11" cy="11" r="7" /><path d="m20 20-3-3" /></>,
plus: <path d="M12 5v14M5 12h14" />,
minus: <path d="M5 12h14" />,
chevron: <path d="m9 18 6-6-6-6" />,
chevronDown: <path d="m6 9 6 6 6-6" />,
chevronLeft: <path d="m15 18-6-6 6-6" />,
more: <><circle cx="5" cy="12" r="1" /><circle cx="12" cy="12" r="1" /><circle cx="19" cy="12" r="1" /></>,
filter: <path d="M3 5h18M6 12h12M10 19h4" />,
file: <><path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z" /><path d="M14 3v6h6" /></>,
folder: <path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z" />,
grid: <><rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="3" width="7" height="7" rx="1" /><rect x="3" y="14" width="7" height="7" rx="1" /><rect x="14" y="14" width="7" height="7" rx="1" /></>,
list: <path d="M8 6h13M8 12h13M8 18h13M4 6h.01M4 12h.01M4 18h.01" />,
upload: <><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><path d="m17 8-5-5-5 5M12 3v12" /></>,
arrowRight: <path d="M5 12h14M13 5l7 7-7 7" />,
arrowUpRight: <path d="M7 17 17 7M7 7h10v10" />,
sparkle: <path d="M12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2.1 2.1M16.3 16.3l2.1 2.1M5.6 18.4l2.1-2.1M16.3 7.7l2.1-2.1" />,
bot: <><rect x="4" y="7" width="16" height="12" rx="3" /><circle cx="9" cy="13" r="1" /><circle cx="15" cy="13" r="1" /><path d="M12 3v4M8 19v2M16 19v2" /></>,
user: <><circle cx="12" cy="8" r="4" /><path d="M4 21c0-4.4 3.6-8 8-8s8 3.6 8 8" /></>,
check: <path d="M5 12.5 10 17l9-10" />,
x: <path d="M18 6 6 18M6 6l12 12" />,
pin: <><path d="m12 17 .01 5M9.2 12.8 5 17M19 8c-1.3 0-3.5.3-4.7 1.5l-2 2c-.4.5-.4 1.1 0 1.5L14.7 15c.4.4 1 .4 1.5 0l2-2C19.4 11.6 20 9.5 20 8.2c0-.1-.1-.2-.2-.2" /></>,
attach: <path d="m21 11.5-9.6 9.6a5.5 5.5 0 0 1-7.8-7.8l9.6-9.6a3.7 3.7 0 0 1 5.2 5.2L8.8 18.4a1.8 1.8 0 0 1-2.6-2.6L15 7" />,
send: <><path d="m22 2-7 20-4-9-9-4Z" /><path d="M22 2 11 13" /></>,
hash: <path d="M4 9h16M4 15h16M10 3 8 21M16 3l-2 18" />,
lock: <><rect x="4" y="11" width="16" height="10" rx="2" /><path d="M8 11V7a4 4 0 0 1 8 0v4" /></>,
bell: <><path d="M6 8a6 6 0 1 1 12 0c0 7 3 9 3 9H3s3-2 3-9" /><path d="M10 21a2 2 0 0 0 4 0" /></>,
cmd: <path d="M15 15h3a3 3 0 0 0 0-6h-3v6Zm0 0v-6h-6m6 6v3a3 3 0 0 1-6 0v-3m0 0H6a3 3 0 0 1 0-6h3m0 6v-6m0 0V6a3 3 0 1 0-3 3h3Z" />,
refresh: <><path d="M21 12a9 9 0 1 1-3-6.7" /><path d="M21 3v6h-6" /></>,
pause: <><rect x="6" y="5" width="4" height="14" rx="1" /><rect x="14" y="5" width="4" height="14" rx="1" /></>,
shield: <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Z" />,
};
export type IconName = keyof typeof PATHS | (string & {});
export function Icon({
name,
size = 14,
color = "currentColor",
strokeWidth = 1.6,
}: {
name: IconName;
size?: number;
color?: string;
strokeWidth?: number;
}) {
const node = PATHS[name];
if (!node) throw new Error(`Unknown icon: ${name}`);
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
{node}
</svg>
);
}
@@ -1,10 +1,25 @@
import type { ReactNode } from "react";
import type { AppSnapshot } from "../../../shared/domain";
import { Avatar, Icon, IconButton } from "./ui";
import type { IconName } from "./Icon";
export type RouteId = "home" | "team" | "work" | "chat" | "files" | "approvals" | "social" | "settings";
export type RouteId =
| "home"
| "team"
| "work"
| "chat"
| "files"
| "approvals"
| "social"
| "settings";
const mainNavItems: Array<{ id: RouteId; label: string; icon: string }> = [
interface NavSpec {
id: RouteId;
label: string;
icon: IconName;
}
const primaryNav: NavSpec[] = [
{ id: "home", label: "Home", icon: "home" },
{ id: "team", label: "Team", icon: "team" },
{ id: "work", label: "Work", icon: "work" },
@@ -14,8 +29,6 @@ const mainNavItems: Array<{ id: RouteId; label: string; icon: string }> = [
{ id: "social", label: "Social", icon: "social" },
];
const navItems = [...mainNavItems, { id: "settings" as RouteId, label: "Settings", icon: "settings" }];
export function Shell({
snapshot,
route,
@@ -31,68 +44,68 @@ export function Shell({
}) {
const organization = snapshot.organizations.find((item) => item.id === snapshot.activeOrgId);
const employee = snapshot.employees.find((item) => item.orgId === snapshot.activeOrgId);
const activeRoute = navItems.find((item) => item.id === route);
if (!organization) throw new Error("Active organization is missing.");
if (!employee) throw new Error("Active employee is missing.");
if (!activeRoute) throw new Error("Active route is missing.");
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>>;
const badges = computeBadges(snapshot, organization.id);
const activeLabel = labelFor(route);
const orgInitial = organization.name.trim().charAt(0).toUpperCase() || "A";
return (
<div className="app-shell">
<aside className="sidebar">
<div className="brand">
<div className="brand-mark">A</div>
<div className="brand-name">{organization.name}</div>
<Icon name="chevronDown" size={12} />
</div>
<div className="search-bar">
<button
type="button"
className="sidebar-org"
aria-label={`${organization.name} — switch organization`}
title={organization.name}
>
<span className="brand-mark">{orgInitial}</span>
<span className="brand-name">{organization.name}</span>
<Icon name="chevronDown" size={12} color="var(--text-3)" />
</button>
<button type="button" className="search-bar" aria-label="Search">
<Icon name="search" size={13} />
<span>Search</span>
<span className="search-kbd">⌘K</span>
</div>
</button>
<nav className="nav">
{mainNavItems.map((item) => (
{primaryNav.map((item) => (
<button
key={item.id}
type="button"
aria-label={item.label}
className={`nav-item ${route === item.id ? "active" : ""}`}
className={`nav-item ${route === item.id ? "active" : ""}`.trim()}
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}
{renderBadge(badges[item.id])}
</button>
))}
</nav>
<div style={{ marginTop: "auto" }}>
<nav className="nav" style={{ marginBottom: 4 }}>
<button
type="button"
aria-label="Settings"
className={`nav-item ${route === "settings" ? "active" : ""}`}
onClick={() => setRoute("settings")}
>
<Icon name="settings" size={15} />
<span>Settings</span>
</button>
</nav>
<div className="sidebar-foot">
<Avatar name={employee.displayName} size="sm" />
<div style={{ flex: 1, minWidth: 0 }}>{employee.displayName}</div>
<Icon name="bell" />
</div>
<div className="sidebar-spacer" />
<nav className="nav">
<button
type="button"
aria-label="Settings"
className={`nav-item ${route === "settings" ? "active" : ""}`.trim()}
onClick={() => setRoute("settings")}
>
<Icon name="settings" size={15} />
<span>Settings</span>
</button>
</nav>
<div className="sidebar-foot">
<Avatar name={employee.displayName} size="sm" />
<span className="sidebar-foot-name">{employee.displayName}</span>
<Icon name="bell" size={14} color="var(--text-3)" />
</div>
</aside>
<main className="main">
<header className="topbar">
<span className="crumb-sub">{organization.name}</span>
<span style={{ color: "var(--text-4)" }}>/</span>
<span className="crumb">{activeRoute.label}</span>
<span className="crumb-sep">/</span>
<span className="crumb">{activeLabel}</span>
<div className="topbar-actions">
{actions}
{window.agentCompany.platform !== "darwin" && (
@@ -108,3 +121,26 @@ export function Shell({
</div>
);
}
function labelFor(route: RouteId): string {
const spec = primaryNav.find((item) => item.id === route);
if (spec) return spec.label;
if (route === "settings") return "Settings";
throw new Error(`Unknown route: ${route}`);
}
function renderBadge(value: number | undefined): ReactNode {
if (!value || value <= 0) return null;
return <span className="nav-badge">{value > 99 ? "99+" : value}</span>;
}
function computeBadges(snapshot: AppSnapshot, orgId: string): Partial<Record<RouteId, number>> {
const tasks = snapshot.tasks.filter((item) => item.orgId === orgId);
const messages = snapshot.chatMessages.filter((item) => item.orgId === orgId);
const approvals = snapshot.approvals.filter((item) => item.orgId === orgId);
return {
work: tasks.filter((task) => task.status !== "done").length,
chat: messages.length,
approvals: approvals.filter((approval) => approval.state === "pending").length,
};
}
+89 -99
View File
@@ -1,82 +1,7 @@
import type { ReactNode } from "react";
import {
Bell,
Bot,
Check,
ChevronDown,
FileText,
Filter,
Folder,
Grid2X2,
Hash,
Home,
List,
Lock,
MessageCircle,
Minus,
MoreHorizontal,
Paperclip,
Plus,
RefreshCw,
Search,
Send,
Settings,
ShieldCheck,
Upload,
Users,
X,
type LucideIcon,
} from "lucide-react";
import { Icon, type IconName } from "./Icon";
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,
list: List,
lock: Lock,
minus: Minus,
more: MoreHorizontal,
plus: Plus,
refresh: RefreshCw,
search: Search,
send: Send,
settings: Settings,
team: Users,
upload: Upload,
x: X,
};
const customIcons: Record<string, (size: number) => ReactNode> = {
work: (size) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.7} strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="4" width="18" height="16" rx="2"/>
<path d="M8 4v16M16 4v16"/>
</svg>
),
social: (size) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.7} strokeLinecap="round" strokeLinejoin="round">
<path d="M4 11a8 8 0 0 1 8-8"/>
<path d="M4 11v4a4 4 0 0 0 4 4h1"/>
<circle cx="18" cy="6" r="2"/>
<circle cx="6" cy="18" r="2"/>
<circle cx="18" cy="18" r="2"/>
<path d="M8 17l8-9M18 8v8"/>
</svg>
),
};
const palette = [
const palette: ReadonlyArray<readonly [string, string]> = [
["#5e6ad2", "#fff"],
["#e8b339", "#231600"],
["#26b69a", "#fff"],
@@ -93,18 +18,44 @@ function hashName(value: string): number {
return Math.abs(hash);
}
export function Icon({ name, size = 14 }: { name: string; size?: number }) {
if (customIcons[name]) return <>{customIcons[name](size)}</>;
const Component = icons[name] ?? FileText;
return <Component size={size} strokeWidth={1.7} />;
function paletteFor(name: string): readonly [string, string] {
const fallback = palette[0];
if (!fallback) throw new Error("Avatar palette is empty.");
return palette[hashName(name) % palette.length] ?? fallback;
}
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];
function initials(name: string): string {
return name.split(/\s+/).filter(Boolean).map((part) => part[0]).slice(0, 2).join("").toUpperCase();
}
export type AvatarSize = "sm" | "" | "lg" | "xl";
export function Avatar({
name,
size = "",
bot = false,
}: {
name: string;
size?: AvatarSize;
bot?: boolean;
}) {
const [bg, fg] = paletteFor(name);
const dim = size === "sm" ? 14 : size === "lg" ? 18 : size === "xl" ? 24 : 14;
return (
<div className={`avatar ${size} ${bot ? "bot" : ""}`} style={{ background, color }}>
{bot ? <Icon name="bot" size={size === "sm" ? 13 : 16} /> : initials}
<div className={`avatar ${size}`.trim() + (bot ? " bot" : "")} style={{ background: bg, color: fg }}>
{bot ? <Icon name="bot" size={dim} /> : initials(name)}
</div>
);
}
export function AvatarStack({ names, bot = false }: { names: string[]; bot?: boolean }) {
return (
<div className="avatar-stack">
{names.map((name) => (
<div key={name} className="avatar-ring">
<Avatar name={name} size="sm" bot={bot} />
</div>
))}
</div>
);
}
@@ -115,11 +66,11 @@ export function Pill({
dot = false,
}: {
children: ReactNode;
tone?: "amber" | "green" | "violet";
tone?: "amber" | "green" | "violet" | "red";
dot?: boolean;
}) {
return (
<span className={`pill ${tone ?? ""}`}>
<span className={`pill ${tone ?? ""}`.trim()}>
{dot && <span className="dot" />}
{children}
</span>
@@ -133,6 +84,7 @@ export function Button({
onClick,
type = "button",
disabled = false,
title,
}: {
children: ReactNode;
tone?: "primary" | "ghost";
@@ -140,9 +92,16 @@ export function Button({
onClick?: () => void;
type?: "button" | "submit";
disabled?: boolean;
title?: string;
}) {
return (
<button type={type} onClick={onClick} disabled={disabled} className={`button ${tone ?? ""} ${size ?? ""}`}>
<button
type={type}
onClick={onClick}
disabled={disabled}
title={title}
className={`button ${tone ?? ""} ${size ?? ""}`.trim()}
>
{children}
</button>
);
@@ -152,25 +111,29 @@ export function IconButton({
icon,
onClick,
label,
variant = "ghost",
size = 14,
}: {
icon: string;
icon: IconName;
onClick?: () => void;
label: string;
variant?: "ghost" | "solid";
size?: number;
}) {
return (
<button type="button" className="icon-button ghost" aria-label={label} title={label} onClick={onClick}>
<Icon name={icon} />
<button
type="button"
className={`icon-button ${variant === "ghost" ? "ghost" : ""}`.trim()}
aria-label={label}
title={label}
onClick={onClick}
>
<Icon name={icon} size={size} />
</button>
);
}
export function Field({
label,
children,
}: {
label: string;
children: ReactNode;
}) {
export function Field({ label, children }: { label: string; children: ReactNode }) {
return (
<label className="field">
<span>{label}</span>
@@ -178,3 +141,30 @@ export function Field({
</label>
);
}
export function Segmented<T extends string>({
value,
onChange,
options,
}: {
value: T;
onChange: (next: T) => void;
options: ReadonlyArray<{ id: T; label: string }>;
}) {
return (
<div className="segmented">
{options.map((option) => (
<button
key={option.id}
type="button"
className={`segment ${value === option.id ? "active" : ""}`.trim()}
onClick={() => onChange(option.id)}
>
{option.label}
</button>
))}
</div>
);
}
export { Icon };
+240 -91
View File
@@ -24,8 +24,9 @@
--r-xl: 16px;
--shadow-sm: 0 1px 0 rgba(9, 9, 11, 0.04), 0 1px 2px rgba(9, 9, 11, 0.04);
--shadow-md: 0 1px 3px rgba(9, 9, 11, 0.06), 0 6px 24px rgba(9, 9, 11, 0.04);
--font-sans: Inter, -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif;
--font-sans: "Inter", -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif;
--font-mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, monospace;
--font-display: "Inter", -apple-system, BlinkMacSystemFont, "SF Pro Display", system-ui, sans-serif;
}
* { box-sizing: border-box; }
@@ -39,8 +40,9 @@ body {
letter-spacing: -0.005em;
-webkit-font-smoothing: antialiased;
}
button, input, textarea, select { font: inherit; }
button, input, textarea, select { font: inherit; color: inherit; }
button { cursor: pointer; }
a { color: inherit; }
.app-shell {
display: flex;
@@ -49,6 +51,7 @@ button { cursor: pointer; }
overflow: hidden;
background: var(--bg);
}
.sidebar {
width: 232px;
flex-shrink: 0;
@@ -56,27 +59,43 @@ button { cursor: pointer; }
flex-direction: column;
padding: 14px 10px;
border-right: 1px solid var(--border-soft);
background: var(--bg);
}
.brand {
.sidebar-org {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border: 0;
background: transparent;
text-align: left;
border-radius: var(--r-md);
cursor: pointer;
margin-bottom: 18px;
}
.sidebar-org:hover { background: var(--hover); }
.brand-mark {
width: 22px;
height: 22px;
flex-shrink: 0;
border-radius: 6px;
display: grid;
place-items: center;
color: white;
color: #fff;
background: linear-gradient(135deg, #1a1a1d 0%, #3a3a40 100%);
font-size: 11px;
font-weight: 600;
letter-spacing: -0.02em;
}
.brand-name {
flex: 1;
font-weight: 540;
font-size: 13px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.brand-name { flex: 1; font-weight: 540; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.search-bar {
display: flex;
align-items: center;
@@ -88,6 +107,8 @@ button { cursor: pointer; }
margin-bottom: 16px;
font-size: 12.5px;
cursor: text;
border: 0;
width: 100%;
}
.search-kbd {
margin-left: auto;
@@ -96,6 +117,13 @@ button { cursor: pointer; }
font-family: var(--font-mono);
}
.nav { display: flex; flex-direction: column; gap: 1px; }
.nav-label {
font-size: 11px;
color: var(--text-3);
padding: 12px 8px 6px;
font-weight: 540;
letter-spacing: 0.02em;
}
.nav-item {
display: flex;
align-items: center;
@@ -106,18 +134,40 @@ button { cursor: pointer; }
background: transparent;
color: var(--text-2);
font-weight: 480;
font-size: 13px;
text-align: left;
width: 100%;
min-width: 0;
}
.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; }
.nav-item.active svg { color: var(--text-1); }
.nav-item svg { color: var(--text-3); flex-shrink: 0; }
.nav-item span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.nav-badge {
margin-left: auto;
font-size: 11px;
color: var(--text-3);
font-variant-numeric: tabular-nums;
}
.sidebar-spacer { flex: 1; }
.sidebar-foot {
padding: 10px 8px 4px;
margin-top: 8px;
border-top: 1px solid var(--border-soft);
display: flex;
align-items: center;
gap: 9px;
}
.sidebar-foot-name {
flex: 1;
font-size: 12.5px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.main {
min-width: 0;
flex: 1;
@@ -135,23 +185,57 @@ button { cursor: pointer; }
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 button,
.topbar select,
.topbar input,
.topbar a { -webkit-app-region: no-drag; }
.crumb { font-size: 13px; font-weight: 540; color: var(--text-1); }
.crumb-sub { font-size: 13px; color: var(--text-3); }
.crumb-sep { color: var(--text-4); }
.topbar-actions { margin-left: auto; display: flex; align-items: center; gap: 6px; }
.content { flex: 1; overflow: auto; padding: 28px 32px 40px; }
.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; }
.page-wide { width: 100%; }
.page-h1 {
margin: 0 0 4px;
font-family: var(--font-display);
font-size: 22px;
font-weight: 620;
letter-spacing: -0.018em;
}
.page-sub {
margin: 0 0 24px;
color: var(--text-3);
font-size: 13px;
}
.section-label {
margin-bottom: 10px;
color: var(--text-3);
font-size: 11px;
font-weight: 540;
letter-spacing: 0.02em;
}
.row { display: flex; align-items: center; gap: 10px; min-width: 0; }
.col { display: flex; flex-direction: column; min-width: 0; }
.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; }
.card-label {
color: var(--text-3);
font-size: 11px;
font-weight: 540;
letter-spacing: 0.02em;
}
.button {
height: 28px;
display: inline-flex;
@@ -164,12 +248,16 @@ button { cursor: pointer; }
color: var(--text-1);
font-size: 12.5px;
font-weight: 500;
white-space: nowrap;
}
.button:hover { background: var(--panel-2); }
.button.primary { background: var(--text-1); color: white; border-color: var(--text-1); }
.button.primary { background: var(--text-1); color: #fff; border-color: var(--text-1); }
.button.primary:hover { background: #27272a; }
.button.ghost { border-color: transparent; background: transparent; color: var(--text-2); }
.button.ghost:hover { background: var(--hover); color: var(--text-1); }
.button.small { height: 24px; padding: 0 8px; font-size: 12px; }
.button:disabled { opacity: 0.45; cursor: default; }
.icon-button {
width: 28px;
height: 28px;
@@ -179,8 +267,33 @@ button { cursor: pointer; }
border: 1px solid var(--border);
background: var(--panel);
color: var(--text-2);
flex-shrink: 0;
}
.icon-button:hover { background: var(--panel-2); color: var(--text-1); }
.icon-button.ghost { border-color: transparent; background: transparent; }
.icon-button.ghost:hover { background: var(--hover); color: var(--text-1); }
.segmented {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 2px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--panel);
}
.segmented .segment {
height: 22px;
padding: 0 8px;
border-radius: 6px;
border: 0;
background: transparent;
color: var(--text-2);
font-size: 12px;
font-weight: 500;
}
.segmented .segment.active { background: var(--panel-3); color: var(--text-1); font-weight: 540; }
.pill {
display: inline-flex;
align-items: center;
@@ -193,25 +306,18 @@ button { cursor: pointer; }
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; }
.attachment-pill {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 260px;
padding: 6px 8px;
border: 1px solid var(--border);
border-radius: var(--r-md);
background: var(--panel-2);
color: var(--text-2);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.pill .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--text-3); }
.pill.amber { background: var(--amber-soft); color: var(--amber); border-color: transparent; }
.pill.amber .dot { background: var(--amber); }
.pill.green { background: var(--green-soft); color: var(--green); border-color: transparent; }
.pill.green .dot { background: var(--green); }
.pill.violet { background: var(--accent-soft); color: var(--accent); border-color: transparent; }
.pill.violet .dot { background: var(--accent); }
.pill.red { background: rgba(210, 59, 59, 0.1); color: var(--red); border-color: transparent; }
.pill.red .dot { background: var(--red); }
.avatar {
width: 28px;
height: 28px;
@@ -219,31 +325,22 @@ button { cursor: pointer; }
display: grid;
place-items: center;
border-radius: 50%;
color: white;
color: #fff;
font-size: 11px;
font-weight: 600;
letter-spacing: -0.01em;
}
.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;
}
.avatar.lg { width: 36px; height: 36px; font-size: 13px; }
.avatar.xl { width: 48px; height: 48px; font-size: 16px; }
.avatar-stack { display: inline-flex; }
.avatar-stack > .avatar-ring { margin-left: -6px; border: 2px solid var(--panel); border-radius: 50%; }
.avatar-stack > .avatar-ring:first-child { margin-left: 0; }
.num { font-variant-numeric: tabular-nums; letter-spacing: -0.02em; }
.mono { font-family: var(--font-mono); }
.input {
width: 100%;
border: 1px solid var(--border-strong);
@@ -252,19 +349,87 @@ button { cursor: pointer; }
color: var(--text-1);
padding: 8px 10px;
outline: none;
font-size: 13px;
}
textarea.input { min-height: 84px; resize: vertical; }
.input:focus { border-color: var(--text-1); }
textarea.input { min-height: 84px; resize: vertical; line-height: 1.5; }
.field {
display: flex;
flex-direction: column;
gap: 6px;
color: var(--text-2);
font-size: 12px;
}
.field > span { color: var(--text-2); font-weight: 500; }
.checkbox-row {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-2);
font-size: 12.5px;
}
.divider { height: 1px; background: var(--border-soft); margin: 16px 0; }
.table-row {
display: grid;
gap: 12px;
align-items: center;
padding: 12px 18px;
border-bottom: 1px solid var(--border-soft);
font-size: 13px;
}
.table-row.head {
padding: 10px 18px;
font-size: 11px;
color: var(--text-3);
font-weight: 540;
letter-spacing: 0.02em;
background: var(--panel-2);
border-bottom: 1px solid var(--border-soft);
}
.table-row:last-child { border-bottom: 0; }
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 40;
background: rgba(9, 9, 11, 0.22);
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: 20px;
}
.modal-title { margin: 0 0 14px; font-size: 16px; font-weight: 620; letter-spacing: -0.01em; }
.modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; }
.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);
display: flex;
flex-direction: column;
gap: 2px;
min-height: 0;
}
.onboarding-shell {
width: 100%;
min-height: 100%;
display: grid;
grid-template-columns: minmax(0, 1fr) 520px;
grid-template-columns: minmax(0, 1.05fr) minmax(420px, 0.95fr);
background: var(--bg);
}
.onboarding-hero {
@@ -280,7 +445,7 @@ textarea.input { min-height: 84px; resize: vertical; }
display: flex;
flex-direction: column;
justify-content: center;
padding: 42px;
padding: 48px;
background: var(--panel);
}
.onboarding-steps { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 28px; }
@@ -296,56 +461,40 @@ textarea.input { min-height: 84px; resize: vertical; }
font-size: 11px;
}
.step-chip.active { color: var(--text-1); border-color: var(--text-1); }
.form-grid { display: grid; gap: 14px; }
.option-grid { display: grid; gap: 8px; }
.option-card {
border: 1px solid var(--border);
border-radius: var(--r-md);
background: var(--panel);
padding: 12px;
padding: 14px;
text-align: left;
cursor: pointer;
width: 100%;
}
.option-card:hover { border-color: var(--border-strong); }
.option-card.active { border-color: var(--text-1); box-shadow: inset 0 0 0 1px var(--text-1); }
.form-grid { display: grid; gap: 12px; }
.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);
}
.option-card .opt-title { font-size: 13.5px; font-weight: 540; }
.option-card .opt-sub { font-size: 12px; color: var(--text-3); margin-top: 4px; }
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 4px; }
::-webkit-scrollbar-track { background: transparent; }
@media (max-width: 1100px) {
.content { padding: 24px 24px 36px; }
}
@media (max-width: 980px) {
.sidebar { width: 64px; }
.brand-name, .nav-item span:not(.nav-badge), .sidebar-foot > div { display: none; }
.brand-name,
.nav-item span:not(.nav-badge),
.sidebar-foot-name,
.search-bar > span:not(.search-kbd) { display: none; }
.search-bar { justify-content: center; padding: 6px; }
.search-kbd { display: none; }
.content { padding: 20px 18px 32px; }
.grid-3, .grid-4 { grid-template-columns: 1fr; }
.page-wide { min-width: 0; }
.secondary-pane { width: 180px; }
.onboarding-shell { grid-template-columns: 1fr; }
.onboarding-hero { padding: 28px; border-right: 0; border-bottom: 1px solid var(--border-soft); }
.onboarding-panel { padding: 24px; justify-content: flex-start; }
.onboarding-panel { padding: 28px; justify-content: flex-start; }
}
+21
View File
@@ -143,6 +143,27 @@ export interface CreateMigrationReportInput {
sourceDatabasePath: string;
}
export interface CreateScheduledPostInput {
orgId: string;
socialAccountId: string;
bodyMd: string;
scheduledFor: string;
state: "draft" | "scheduled";
createdByEmployeeId: string | null;
}
export interface SetScheduledPostStateInput {
orgId: string;
scheduledPostId: string;
state: "draft" | "scheduled" | "published" | "cancelled";
}
export interface SetFilePinnedInput {
orgId: string;
fileId: string;
pinned: boolean;
}
export interface BootstrapOnboardingInput {
companyName: string;
companyDescription: string;
@@ -10,6 +10,7 @@ import type {
CreateMigrationReportInput,
CreateOrganizationInput,
CreateProviderResponseInput,
CreateScheduledPostInput,
CreateSocialAccountInput,
CreateTaskInput,
DecideApprovalInput,
@@ -19,6 +20,8 @@ import type {
ResolveAgentSkillsInput,
SaveProviderCredentialInput,
SendMessageInput,
SetFilePinnedInput,
SetScheduledPostStateInput,
UpdateTaskStatusInput,
} from "./commands";
@@ -44,7 +47,10 @@ export interface DesktopApi {
resolveAgentSkills(input: ResolveAgentSkillsInput): Promise<DesktopResult<AppSnapshot>>;
createSocialAccount(input: CreateSocialAccountInput): Promise<DesktopResult<AppSnapshot>>;
launchSocialLogin(input: LaunchSocialLoginInput): Promise<DesktopResult<null>>;
createScheduledPost(input: CreateScheduledPostInput): Promise<DesktopResult<AppSnapshot>>;
setScheduledPostState(input: SetScheduledPostStateInput): Promise<DesktopResult<AppSnapshot>>;
importFile(input: ImportFileInput): Promise<DesktopResult<AppSnapshot>>;
setFilePinned(input: SetFilePinnedInput): Promise<DesktopResult<AppSnapshot>>;
createMigrationReport(input: CreateMigrationReportInput): Promise<DesktopResult<AppSnapshot>>;
subscribe(callback: (event: RendererEvent) => void): () => void;
platform: "win32" | "darwin" | "linux";
+17
View File
@@ -234,7 +234,23 @@ export interface FileObjectRecord {
storagePath: string;
ownerType: "employee" | "agent" | "system";
ownerId: string | null;
spaceId: string | null;
pinned: boolean;
createdAt: string;
updatedAt: string | null;
}
export interface ScheduledPostRecord {
id: string;
orgId: string;
socialAccountId: string;
bodyMd: string;
scheduledFor: string;
state: "draft" | "scheduled" | "published" | "cancelled";
approvalId: string | null;
createdByEmployeeId: string | null;
createdAt: string;
updatedAt: string;
}
export interface ProviderRecord {
@@ -392,6 +408,7 @@ export interface AppSnapshot {
chatAttachments: ChatAttachmentRecord[];
chatPresence: ChatPresenceRecord[];
files: FileObjectRecord[];
scheduledPosts: ScheduledPostRecord[];
providers: ProviderRecord[];
providerCatalog: ProviderCatalogEntry[];
providerRequests: ProviderRequestRecord[];
+3
View File
@@ -16,7 +16,10 @@ export const IPC_CHANNELS = {
resolveAgentSkills: "agentcompany:resolve-agent-skills",
createSocialAccount: "agentcompany:create-social-account",
launchSocialLogin: "agentcompany:launch-social-login",
createScheduledPost: "agentcompany:create-scheduled-post",
setScheduledPostState: "agentcompany:set-scheduled-post-state",
importFile: "agentcompany:import-file",
setFilePinned: "agentcompany:set-file-pinned",
createMigrationReport: "agentcompany:create-migration-report",
rendererEvent: "agentcompany:renderer-event",
windowMinimize: "agentcompany:window-minimize",
+2 -2
View File
@@ -30,7 +30,7 @@ test("onboards, hires, and creates work in the Electron shell", async () => {
await page.getByLabel("Acceptance criteria").fill("The CEO creates the kickoff operating loop.");
await page.getByRole("button", { name: "Launch" }).click();
await expect(page.getByRole("heading", { name: "Acme Operations" })).toBeVisible();
await expect(page.getByRole("button", { 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");
@@ -74,7 +74,7 @@ async function verifyPagesDoNotOverflow(page: Page): Promise<void> {
await page.getByRole("button", { name: nav }).click();
await expectPageToFit(page);
}
for (const tab of ["Providers", "Skills", "Policies", "Migration"]) {
for (const tab of ["Workspace", "Members", "Billing", "Models", "Providers", "Skills", "Policies", "Migration", "Profile", "Notifications", "Security"]) {
await page.getByRole("button", { name: "Settings" }).click();
await page.getByRole("button", { name: tab }).click();
await expectPageToFit(page);
@@ -5,6 +5,7 @@ import { randomUUID } from "node:crypto";
import { DatabaseSync } from "node:sqlite";
import { describe, expect, test } from "vitest";
import { DatabaseConnection } from "../../src/main/database/connection";
import { FileService } from "../../src/main/services/file-service";
import { OrganizationService } from "../../src/main/services/organization-service";
import { SnapshotService } from "../../src/main/services/snapshot-service";
import { SkillService } from "../../src/main/services/skill-service";
@@ -244,6 +245,59 @@ describe("domain services", () => {
connection.close();
});
test("pins files and schedules social posts", () => {
const connection = createConnection();
const organization = new OrganizationService(connection.db);
const social = new SocialService(connection.db);
const files = new FileService(connection.db, join(tmpdir(), `artifacts-${randomUUID()}`));
const snapshot = new SnapshotService(connection.db);
let orgId = "";
let socialAccountId = "";
let fileId = "";
let employeeId = "";
connection.transaction(() => {
orgId = organization.createOrganization({
organizationName: "Acme Operations",
employeeName: "Dana Lee",
mission: "Coordinate agents with auditable approvals.",
goal: "Ship a verified control plane.",
});
employeeId = (connection.db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id;
socialAccountId = social.createAccount({
orgId,
platform: SOCIAL_PLATFORM.X,
handle: "@acme",
displayName: "Acme",
authorizedEmployeeId: employeeId,
});
fileId = createId("fil");
connection.db.prepare(`
insert into file_objects(
id, org_id, name, kind, size_bytes, sha256, storage_path,
owner_type, owner_id, space_id, pinned, created_at, updated_at
)
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(fileId, orgId, "launch-thread.md", "md", 256, "abc", "artifacts/launch-thread.md", "employee", employeeId, null, 0, new Date().toISOString(), new Date().toISOString());
files.setPinned(orgId, fileId, true);
social.createScheduledPost({
orgId,
socialAccountId,
bodyMd: "Pilot starts Friday.",
scheduledFor: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
state: "scheduled",
createdByEmployeeId: employeeId,
});
});
const state = snapshot.load();
expect(state.files[0]?.pinned).toBe(true);
expect(state.scheduledPosts).toHaveLength(1);
expect(state.scheduledPosts[0]?.state).toBe("scheduled");
expect(state.scheduledPosts[0]?.socialAccountId).toBe(socialAccountId);
connection.close();
});
test("bootstraps the original onboarding chain with company, CEO, goal, project, task, and run", () => {
const connection = createConnection();
const onboarding = new OnboardingService({