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:
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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user