Rebuild AgentCompany Electron app
This commit is contained in:
1 parent
bc5daa6eb8
commit
fcf6d90731
56 files changed
+8667
No files matched your search
+7
@@ -0,0 +1,7 @@
|
||||
import type { DesktopApi } from "../shared/desktop-api";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
agentCompany: DesktopApi;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:;"
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AgentCompany</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useState } from "react";
|
||||
import { Shell, type RouteId } from "../shared/Shell";
|
||||
import { Onboarding } from "./Onboarding";
|
||||
import { useSnapshot } from "./useSnapshot";
|
||||
import { HomePage } from "../pages/HomePage";
|
||||
import { TeamPage } from "../pages/TeamPage";
|
||||
import { WorkPage } from "../pages/WorkPage";
|
||||
import { ChatPage } from "../pages/ChatPage";
|
||||
import { FilesPage } from "../pages/FilesPage";
|
||||
import { ApprovalsPage } from "../pages/ApprovalsPage";
|
||||
import { SocialPage } from "../pages/SocialPage";
|
||||
import { SettingsPage } from "../pages/SettingsPage";
|
||||
|
||||
export function App() {
|
||||
const { snapshot, error, actions } = useSnapshot();
|
||||
const [route, setRoute] = useState<RouteId>("home");
|
||||
|
||||
if (!snapshot) {
|
||||
return <div className="app-shell" style={{ display: "grid", placeItems: "center" }} />;
|
||||
}
|
||||
if (snapshot.organizations.length === 0) {
|
||||
return <Onboarding actions={actions} />;
|
||||
}
|
||||
|
||||
const pageProps = { snapshot, actions };
|
||||
const page = {
|
||||
home: <HomePage {...pageProps} />,
|
||||
team: <TeamPage {...pageProps} />,
|
||||
work: <WorkPage {...pageProps} />,
|
||||
chat: <ChatPage {...pageProps} />,
|
||||
files: <FilesPage {...pageProps} />,
|
||||
approvals: <ApprovalsPage {...pageProps} />,
|
||||
social: <SocialPage {...pageProps} />,
|
||||
settings: <SettingsPage {...pageProps} />,
|
||||
}[route];
|
||||
|
||||
return (
|
||||
<Shell snapshot={snapshot} route={route} setRoute={setRoute}>
|
||||
{error && (
|
||||
<div className="card" style={{ marginBottom: 16, color: "var(--red)" }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{page}
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { FormEvent } from "react";
|
||||
import { Button, Field } from "../shared/ui";
|
||||
import type { SnapshotActions } from "./useSnapshot";
|
||||
|
||||
function value(form: FormData, key: string): string {
|
||||
return String(form.get(key) ?? "").trim();
|
||||
}
|
||||
|
||||
export function Onboarding({ actions }: { actions: SnapshotActions }) {
|
||||
const submit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const form = new FormData(event.currentTarget);
|
||||
void actions.run(() => window.agentCompany.createOrganization({
|
||||
organizationName: value(form, "organizationName"),
|
||||
mission: value(form, "mission"),
|
||||
goal: value(form, "goal"),
|
||||
employeeName: value(form, "employeeName"),
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="app-shell" style={{ display: "grid", placeItems: "center", padding: 24 }}>
|
||||
<form className="card" style={{ width: "min(560px, 100%)", display: "grid", gap: 14 }} onSubmit={submit}>
|
||||
<div>
|
||||
<h1 className="page-h1">AgentCompany</h1>
|
||||
<p className="page-sub" style={{ marginBottom: 0 }}>Create the company control plane.</p>
|
||||
</div>
|
||||
<Field label="Company name">
|
||||
<input className="input" name="organizationName" required autoFocus />
|
||||
</Field>
|
||||
<Field label="Your name">
|
||||
<input className="input" name="employeeName" required />
|
||||
</Field>
|
||||
<Field label="Mission">
|
||||
<textarea className="input" name="mission" required />
|
||||
</Field>
|
||||
<Field label="First goal">
|
||||
<textarea className="input" name="goal" required />
|
||||
</Field>
|
||||
<div style={{ display: "flex", justifyContent: "flex-end" }}>
|
||||
<Button tone="primary" type="submit">Create</Button>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { AppSnapshot } from "../../../shared/domain";
|
||||
import type { DesktopResult } from "../../../shared/result";
|
||||
|
||||
export interface SnapshotActions {
|
||||
run<T extends AppSnapshot | null>(operation: () => Promise<DesktopResult<T>>): Promise<T | null>;
|
||||
reload(): Promise<void>;
|
||||
}
|
||||
|
||||
export function useSnapshot(): {
|
||||
snapshot: AppSnapshot | null;
|
||||
error: string | null;
|
||||
actions: SnapshotActions;
|
||||
} {
|
||||
const [snapshot, setSnapshot] = useState<AppSnapshot | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const result = await window.agentCompany.loadSnapshot();
|
||||
if (result.ok) {
|
||||
setSnapshot(result.data);
|
||||
setError(null);
|
||||
} else {
|
||||
setError(result.error.message);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const run = useCallback(async <T extends AppSnapshot | null>(
|
||||
operation: () => Promise<DesktopResult<T>>,
|
||||
): Promise<T | null> => {
|
||||
const result = await operation();
|
||||
if (result.ok) {
|
||||
if (result.data) setSnapshot(result.data as AppSnapshot);
|
||||
setError(null);
|
||||
return result.data;
|
||||
}
|
||||
setError(result.error.message);
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void Promise.resolve().then(load);
|
||||
return window.agentCompany.subscribe((event) => {
|
||||
if (event.type === "snapshot-changed") void load();
|
||||
});
|
||||
}, [load]);
|
||||
|
||||
return { snapshot, error, actions: { run, reload: load } };
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { App } from "./app/App";
|
||||
import "./styles.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,95 @@
|
||||
import { Avatar, Button, Icon, Pill } from "../shared/ui";
|
||||
import type { ApprovalRecord } from "../../../shared/domain";
|
||||
import type { PageProps } from "./types";
|
||||
import { activeOrgId } from "./types";
|
||||
|
||||
export function ApprovalsPage({ snapshot, actions }: PageProps) {
|
||||
const orgId = activeOrgId(snapshot);
|
||||
const employee = snapshot.employees.find((item) => item.orgId === orgId);
|
||||
const approvals = snapshot.approvals.filter((item) => item.orgId === orgId);
|
||||
const pending = approvals.filter((item) => item.state === "pending");
|
||||
const decided = approvals.filter((item) => item.state !== "pending");
|
||||
|
||||
const decide = (approvalId: string, state: "approved" | "declined") => {
|
||||
if (!employee) return;
|
||||
void actions.run(() => window.agentCompany.decideApproval({
|
||||
orgId,
|
||||
approvalId,
|
||||
state,
|
||||
decidedByEmployeeId: employee.id,
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<h1 className="page-h1">Approvals</h1>
|
||||
<p className="page-sub">{pending.length} pending · {decided.length} decided</p>
|
||||
<div className="grid-4" style={{ marginBottom: 24 }}>
|
||||
<StatTile label="Pending" value={String(pending.length)} tone="amber" />
|
||||
<StatTile label="Approved" value={String(decided.filter((item) => item.state === "approved").length)} />
|
||||
<StatTile label="Declined" value={String(decided.filter((item) => item.state === "declined").length)} />
|
||||
<StatTile label="High risk" value={String(pending.filter((item) => item.risk === "high").length)} />
|
||||
</div>
|
||||
<div className="section-label">Pending</div>
|
||||
{pending.length === 0 ? (
|
||||
<div className="card" style={{ color: "var(--text-3)" }}>No pending approvals</div>
|
||||
) : pending.map((approval) => (
|
||||
<ApprovalRow key={approval.id} approval={approval} onApprove={() => decide(approval.id, "approved")} onDecline={() => decide(approval.id, "declined")} />
|
||||
))}
|
||||
<div className="section-label" style={{ marginTop: 32 }}>Decided</div>
|
||||
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
|
||||
{decided.length === 0 ? (
|
||||
<div style={{ padding: 18, color: "var(--text-3)" }}>No decisions</div>
|
||||
) : decided.map((approval) => (
|
||||
<div key={approval.id} className="table-row" style={{ gridTemplateColumns: "24px 1fr 120px" }}>
|
||||
<Icon name="check" />
|
||||
<span>{approval.title}</span>
|
||||
<Pill tone={approval.state === "approved" ? "green" : "amber"}>{approval.state}</Pill>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatTile({ label, value, tone }: { label: string; value: string; tone?: "amber" }) {
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-label" style={{ marginBottom: 6 }}>{label}</div>
|
||||
<div className="num" style={{ fontSize: 22, fontWeight: 600, color: tone === "amber" ? "var(--amber)" : "var(--text-1)" }}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ApprovalRow({
|
||||
approval,
|
||||
onApprove,
|
||||
onDecline,
|
||||
}: {
|
||||
approval: ApprovalRecord;
|
||||
onApprove: () => void;
|
||||
onDecline: () => void;
|
||||
}) {
|
||||
const riskColor = approval.risk === "high" ? "var(--red)" : approval.risk === "medium" ? "var(--amber)" : "var(--text-3)";
|
||||
return (
|
||||
<div className="card" style={{ marginBottom: 10, display: "grid", gridTemplateColumns: "6px 1fr auto", gap: 16 }}>
|
||||
<div style={{ width: 3, alignSelf: "stretch", background: riskColor, borderRadius: 2 }} />
|
||||
<div>
|
||||
<div className="row" style={{ marginBottom: 4 }}>
|
||||
<span style={{ color: riskColor, fontSize: 11, fontWeight: 540 }}>{approval.risk}</span>
|
||||
<Pill>{approval.kind}</Pill>
|
||||
</div>
|
||||
<div style={{ fontSize: 15, fontWeight: 540, marginBottom: 4 }}>{approval.title}</div>
|
||||
<div style={{ color: "var(--text-2)", lineHeight: 1.5, marginBottom: 12 }}>{approval.details}</div>
|
||||
<div className="row">
|
||||
<Avatar name={approval.requestedByType} size="sm" bot={approval.requestedByType !== "employee"} />
|
||||
<span style={{ color: "var(--text-3)", fontSize: 12 }}>{new Date(approval.createdAt).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6, alignItems: "flex-end" }}>
|
||||
<Button tone="primary" onClick={onApprove}>Approve</Button>
|
||||
<Button tone="ghost" onClick={onDecline}>Decline</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { useState, type FormEvent, type ReactNode } from "react";
|
||||
import { Avatar, Button, Icon, IconButton } from "../shared/ui";
|
||||
import type { ChatSpaceRecord } from "../../../shared/domain";
|
||||
import type { PageProps } from "./types";
|
||||
import { activeOrgId } from "./types";
|
||||
|
||||
function messageBody(form: FormData): string {
|
||||
return String(form.get("message") ?? "").trim();
|
||||
}
|
||||
|
||||
export function ChatPage({ snapshot, actions }: PageProps) {
|
||||
const orgId = activeOrgId(snapshot);
|
||||
const spaces = snapshot.chatSpaces.filter((item) => item.orgId === orgId);
|
||||
const employees = snapshot.employees.filter((item) => item.orgId === orgId);
|
||||
const agents = snapshot.agents.filter((item) => item.orgId === orgId);
|
||||
const [spaceId, setSpaceId] = useState(spaces[0]?.id ?? "");
|
||||
const activeSpace = spaces.find((space) => space.id === spaceId) ?? spaces[0];
|
||||
const messages = snapshot.chatMessages.filter((item) => item.orgId === orgId && item.spaceId === activeSpace?.id);
|
||||
const sender = employees[0];
|
||||
|
||||
const submit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!activeSpace || !sender) return;
|
||||
const form = new FormData(event.currentTarget);
|
||||
const bodyMd = messageBody(form);
|
||||
if (!bodyMd) return;
|
||||
event.currentTarget.reset();
|
||||
void actions.run(() => window.agentCompany.sendMessage({
|
||||
orgId,
|
||||
spaceId: activeSpace.id,
|
||||
threadId: null,
|
||||
senderType: "employee",
|
||||
senderId: sender.id,
|
||||
bodyMd,
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="split-main" style={{ height: "calc(100vh - 112px)", margin: "-28px -32px -40px" }}>
|
||||
<aside className="secondary-pane">
|
||||
<div className="row" style={{ padding: "4px 8px 12px" }}>
|
||||
<div style={{ fontWeight: 600 }}>Messages</div>
|
||||
<IconButton icon="plus" label="New space" />
|
||||
</div>
|
||||
<NavGroup label="Channels">
|
||||
{spaces.map((space) => (
|
||||
<SpaceItem
|
||||
key={space.id}
|
||||
space={space}
|
||||
active={activeSpace?.id === space.id}
|
||||
count={snapshot.chatMessages.filter((message) => message.spaceId === space.id).length}
|
||||
onClick={() => setSpaceId(space.id)}
|
||||
/>
|
||||
))}
|
||||
</NavGroup>
|
||||
<NavGroup label="Agents">
|
||||
{agents.map((agent) => (
|
||||
<div key={agent.id} className="nav-item">
|
||||
<Avatar name={agent.name} size="sm" bot />
|
||||
<span>{agent.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</NavGroup>
|
||||
</aside>
|
||||
<section style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column", background: "var(--panel)" }}>
|
||||
<header className="row" style={{ height: 52, padding: "0 24px", borderBottom: "1px solid var(--border-soft)" }}>
|
||||
<Icon name={activeSpace?.visibility === "restricted" ? "lock" : "hash"} />
|
||||
<div style={{ fontSize: 14, fontWeight: 600 }}>{activeSpace?.name ?? "Messages"}</div>
|
||||
<div style={{ color: "var(--text-3)", borderLeft: "1px solid var(--border)", paddingLeft: 10 }}>
|
||||
{activeSpace?.topic ?? ""}
|
||||
</div>
|
||||
<div style={{ marginLeft: "auto" }}><Icon name="search" /></div>
|
||||
</header>
|
||||
<div style={{ flex: 1, overflow: "auto", padding: "20px 24px 8px" }}>
|
||||
{messages.length === 0 ? (
|
||||
<div style={{ color: "var(--text-3)" }}>No messages</div>
|
||||
) : messages.map((message) => (
|
||||
<MessageRow
|
||||
key={message.id}
|
||||
body={message.bodyMd}
|
||||
time={new Date(message.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
|
||||
name={displayName(message.senderType, message.senderId, employees, agents)}
|
||||
bot={message.senderType === "agent"}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<form onSubmit={submit} style={{ padding: 14, paddingTop: 4 }}>
|
||||
<div className="card" style={{ padding: "10px 12px" }}>
|
||||
<textarea className="input" name="message" rows={2} placeholder={activeSpace ? `Message #${activeSpace.name}` : "Message"} />
|
||||
<div className="row" style={{ paddingTop: 8, borderTop: "1px solid var(--border-soft)" }}>
|
||||
<Icon name="attach" />
|
||||
<Icon name="social" />
|
||||
<div style={{ marginLeft: "auto" }}>
|
||||
<Button tone="primary" size="small" type="submit"><Icon name="send" /> Send</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NavGroup({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<div className="section-label" style={{ padding: "0 8px", marginBottom: 6 }}>{label}</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SpaceItem({
|
||||
space,
|
||||
active,
|
||||
count,
|
||||
onClick,
|
||||
}: {
|
||||
space: ChatSpaceRecord;
|
||||
active: boolean;
|
||||
count: number;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button type="button" className={`nav-item ${active ? "active" : ""}`} onClick={onClick}>
|
||||
<Icon name={space.visibility === "restricted" ? "lock" : "hash"} />
|
||||
<span>{space.name}</span>
|
||||
{count > 0 && <span className="nav-badge">{count}</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageRow({ name, body, time, bot }: { name: string; body: string; time: string; bot: boolean }) {
|
||||
return (
|
||||
<div style={{ display: "flex", gap: 12, marginBottom: 18 }}>
|
||||
<Avatar name={name} bot={bot} />
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div className="row" style={{ alignItems: "baseline", marginBottom: 2 }}>
|
||||
<span style={{ fontWeight: 540 }}>{name}</span>
|
||||
<span className="num" style={{ color: "var(--text-3)", fontSize: 11 }}>{time}</span>
|
||||
</div>
|
||||
<div style={{ lineHeight: 1.5, whiteSpace: "pre-wrap" }}>{body}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function displayName(
|
||||
senderType: string,
|
||||
senderId: string | null,
|
||||
employees: Array<{ id: string; displayName: string }>,
|
||||
agents: Array<{ id: string; name: string }>,
|
||||
): string {
|
||||
if (senderType === "employee") return employees.find((item) => item.id === senderId)?.displayName ?? "Employee";
|
||||
if (senderType === "agent") return agents.find((item) => item.id === senderId)?.name ?? "Agent";
|
||||
if (senderType === "board") return "Board";
|
||||
return "System";
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Button, Icon, IconButton } from "../shared/ui";
|
||||
import type { FileObjectRecord } from "../../../shared/domain";
|
||||
import type { PageProps } from "./types";
|
||||
import { activeOrgId } from "./types";
|
||||
|
||||
export function FilesPage({ snapshot, actions }: PageProps) {
|
||||
const orgId = activeOrgId(snapshot);
|
||||
const files = snapshot.files.filter((item) => item.orgId === orgId);
|
||||
|
||||
return (
|
||||
<div style={{ display: "grid", gridTemplateColumns: "200px minmax(0, 1fr)", gap: 24 }}>
|
||||
<aside>
|
||||
<div className="section-label">Library</div>
|
||||
<TreeItem icon="folder" label="All files" count={files.length} active />
|
||||
<TreeItem icon="social" label="Recent" />
|
||||
<TreeItem icon="check" label="Pinned" />
|
||||
</aside>
|
||||
<div className="page">
|
||||
<div className="row" style={{ marginBottom: 24 }}>
|
||||
<div>
|
||||
<h1 className="page-h1">Files</h1>
|
||||
<p className="page-sub" style={{ marginBottom: 0 }}>{files.length} files · {formatBytes(files.reduce((sum, file) => sum + file.sizeBytes, 0))}</p>
|
||||
</div>
|
||||
<div style={{ marginLeft: "auto" }}>
|
||||
<Button tone="primary" onClick={() => void actions.run(() => window.agentCompany.importFile({ orgId }))}>
|
||||
<Icon name="upload" /> Upload
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="section-label">All files</div>
|
||||
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
|
||||
<div className="table-row" style={{ gridTemplateColumns: "3fr 1fr 1.4fr 1fr 40px", background: "var(--panel-2)", color: "var(--text-3)", fontSize: 11 }}>
|
||||
<div>Name</div><div>Size</div><div>Checksum</div><div>Added</div><div />
|
||||
</div>
|
||||
{files.length === 0 ? (
|
||||
<div style={{ padding: 18, color: "var(--text-3)" }}>No files</div>
|
||||
) : files.map((file) => <FileRow key={file.id} file={file} />)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TreeItem({
|
||||
icon,
|
||||
label,
|
||||
count,
|
||||
active = false,
|
||||
}: {
|
||||
icon: string;
|
||||
label: string;
|
||||
count?: number;
|
||||
active?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className={`nav-item ${active ? "active" : ""}`}>
|
||||
<Icon name={icon} />
|
||||
<span>{label}</span>
|
||||
{typeof count === "number" && <span className="nav-badge">{count}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FileRow({ file }: { file: FileObjectRecord }) {
|
||||
return (
|
||||
<div className="table-row" style={{ gridTemplateColumns: "3fr 1fr 1.4fr 1fr 40px" }}>
|
||||
<div className="row">
|
||||
<FileIcon kind={file.kind} />
|
||||
<span style={{ fontWeight: 480 }}>{file.name}</span>
|
||||
</div>
|
||||
<div className="num" style={{ color: "var(--text-3)", fontSize: 12 }}>{formatBytes(file.sizeBytes)}</div>
|
||||
<div className="num" style={{ color: "var(--text-3)", fontSize: 12 }}>{file.sha256.slice(0, 12)}</div>
|
||||
<div style={{ color: "var(--text-3)", fontSize: 12 }}>{new Date(file.createdAt).toLocaleDateString()}</div>
|
||||
<IconButton icon="more" label="More" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FileIcon({ kind }: { kind: string }) {
|
||||
const colors: Record<string, string> = {
|
||||
md: "#52525b",
|
||||
pdf: "#d23b3b",
|
||||
img: "#c87d12",
|
||||
sheet: "#2f9e58",
|
||||
doc: "#5e6ad2",
|
||||
};
|
||||
if (kind === "folder") return <Icon name="folder" />;
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
width: 22,
|
||||
height: 22,
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
borderRadius: 4,
|
||||
background: `${colors[kind] ?? "#a1a1aa"}14`,
|
||||
color: colors[kind] ?? "#52525b",
|
||||
fontSize: 8,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{kind.slice(0, 3).toUpperCase()}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Icon, Pill } from "../shared/ui";
|
||||
import type { PageProps } from "./types";
|
||||
import { activeOrgId, money } from "./types";
|
||||
|
||||
export function HomePage({ snapshot }: PageProps) {
|
||||
const orgId = activeOrgId(snapshot);
|
||||
const organization = snapshot.organizations.find((item) => item.id === orgId)!;
|
||||
const tasks = snapshot.tasks.filter((item) => item.orgId === orgId);
|
||||
const approvals = snapshot.approvals.filter((item) => item.orgId === orgId);
|
||||
const agents = snapshot.agents.filter((item) => item.orgId === orgId);
|
||||
const providers = snapshot.providers.filter((item) => item.orgId === orgId && item.enabled);
|
||||
const budget = agents.reduce((sum, agent) => sum + agent.budgetCents, 0);
|
||||
const pending = approvals.filter((item) => item.state === "pending");
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<h1 className="page-h1">{organization.name}</h1>
|
||||
<p className="page-sub">{organization.goal}</p>
|
||||
<div className="grid-3" style={{ marginBottom: 12 }}>
|
||||
<MetricCard label="Agents" value={String(agents.length)} />
|
||||
<MetricCard label="Open work" value={String(tasks.filter((item) => item.status !== "done").length)} />
|
||||
<MetricCard label="Monthly budget" value={money(budget)} />
|
||||
</div>
|
||||
<div className="grid-3">
|
||||
<div className="card" style={{ gridColumn: "span 2" }}>
|
||||
<div className="card-label" style={{ marginBottom: 14 }}>Work</div>
|
||||
{tasks.length === 0 ? <EmptyLine label="No tasks" /> : tasks.slice(0, 6).map((task) => (
|
||||
<div key={task.id} className="row" style={{ padding: "10px 0", borderBottom: "1px solid var(--border-soft)" }}>
|
||||
<span style={{ width: 6, height: 6, borderRadius: "50%", background: "var(--text-4)" }} />
|
||||
<span style={{ flex: 1, fontWeight: 480 }}>{task.title}</span>
|
||||
<Pill>{task.status.replaceAll("_", " ")}</Pill>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="card-label" style={{ marginBottom: 14 }}>Decisions</div>
|
||||
{pending.length === 0 ? <EmptyLine label="No pending approvals" /> : pending.slice(0, 4).map((approval) => (
|
||||
<div key={approval.id} style={{ padding: "10px 0", borderBottom: "1px solid var(--border-soft)" }}>
|
||||
<div style={{ fontWeight: 540, marginBottom: 4 }}>{approval.title}</div>
|
||||
<Pill tone={approval.risk === "high" ? "amber" : undefined}>{approval.risk}</Pill>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid-3" style={{ marginTop: 12 }}>
|
||||
<div className="card">
|
||||
<div className="card-label" style={{ marginBottom: 14 }}>Providers</div>
|
||||
{providers.length === 0 ? <EmptyLine label="No connected providers" /> : providers.map((provider) => (
|
||||
<div key={provider.id} className="row" style={{ padding: "8px 0" }}>
|
||||
<Icon name="check" />
|
||||
<span>{provider.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="card" style={{ gridColumn: "span 2" }}>
|
||||
<div className="card-label" style={{ marginBottom: 14 }}>Mission</div>
|
||||
<div style={{ fontSize: 15, lineHeight: 1.5 }}>{organization.mission}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-label" style={{ marginBottom: 8 }}>{label}</div>
|
||||
<div className="num" style={{ fontSize: 28, fontWeight: 600 }}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyLine({ label }: { label: string }) {
|
||||
return <div style={{ color: "var(--text-3)", padding: "10px 0" }}>{label}</div>;
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { Button, Field, Icon, Pill } from "../shared/ui";
|
||||
import { Dialog } from "../shared/Dialog";
|
||||
import type { ProviderRecord, SkillDefinitionRecord } from "../../../shared/domain";
|
||||
import type { PageProps } from "./types";
|
||||
import { activeOrgId } from "./types";
|
||||
|
||||
type SettingsTab = "providers" | "skills" | "policies" | "migration";
|
||||
|
||||
function value(form: FormData, key: string): string {
|
||||
return String(form.get(key) ?? "").trim();
|
||||
}
|
||||
|
||||
export function SettingsPage({ snapshot, actions }: PageProps) {
|
||||
const orgId = activeOrgId(snapshot);
|
||||
const [tab, setTab] = useState<SettingsTab>("providers");
|
||||
const [providerDialog, setProviderDialog] = useState<ProviderRecord | null>(null);
|
||||
const [skillOpen, setSkillOpen] = useState(false);
|
||||
const providers = snapshot.providers.filter((item) => item.orgId === orgId);
|
||||
const skills = snapshot.skillDefinitions.filter((item) => item.orgId === orgId);
|
||||
const agents = snapshot.agents.filter((item) => item.orgId === orgId);
|
||||
const revisions = snapshot.skillRevisions;
|
||||
|
||||
const saveProvider = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!providerDialog) return;
|
||||
const form = new FormData(event.currentTarget);
|
||||
void actions.run(() => window.agentCompany.saveProviderCredential({
|
||||
orgId,
|
||||
providerId: providerDialog.id,
|
||||
secret: value(form, "secret"),
|
||||
model: value(form, "model") || null,
|
||||
})).then(() => setProviderDialog(null));
|
||||
};
|
||||
|
||||
const importSkill = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const form = new FormData(event.currentTarget);
|
||||
void actions.run(() => window.agentCompany.importSkillDirectory({
|
||||
orgId,
|
||||
directoryPath: value(form, "directoryPath"),
|
||||
versionSemver: value(form, "versionSemver") || "1.0.0",
|
||||
})).then(() => setSkillOpen(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="split-main" style={{ height: "calc(100vh - 112px)", margin: "-28px -32px -40px" }}>
|
||||
<aside className="secondary-pane">
|
||||
<div style={{ fontWeight: 600, padding: "4px 8px 14px" }}>Settings</div>
|
||||
<SettingsNav label="Providers" active={tab === "providers"} onClick={() => setTab("providers")} />
|
||||
<SettingsNav label="Skills" active={tab === "skills"} onClick={() => setTab("skills")} />
|
||||
<SettingsNav label="Policies" active={tab === "policies"} onClick={() => setTab("policies")} />
|
||||
<SettingsNav label="Migration" active={tab === "migration"} onClick={() => setTab("migration")} />
|
||||
</aside>
|
||||
<div className="content" style={{ flex: 1 }}>
|
||||
{tab === "providers" && (
|
||||
<ProvidersPanel providers={providers} onManage={setProviderDialog} />
|
||||
)}
|
||||
{tab === "skills" && (
|
||||
<SkillsPanel
|
||||
skills={skills}
|
||||
agents={agents}
|
||||
revisions={revisions}
|
||||
onImport={() => setSkillOpen(true)}
|
||||
onBind={(agentId, skill, revisionId) => void actions.run(() => window.agentCompany.bindSkill({
|
||||
orgId,
|
||||
agentId,
|
||||
skillId: skill.id,
|
||||
revisionId,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
{tab === "policies" && <PolicyPanel />}
|
||||
{tab === "migration" && <MigrationPanel />}
|
||||
</div>
|
||||
<Dialog title="Provider credential" open={Boolean(providerDialog)} submitLabel="Save" onClose={() => setProviderDialog(null)} onSubmit={saveProvider}>
|
||||
<Field label="Provider"><input className="input" value={providerDialog?.label ?? ""} readOnly /></Field>
|
||||
<Field label="Secret"><input className="input" name="secret" type="password" required /></Field>
|
||||
<Field label="Model"><input className="input" name="model" defaultValue={providerDialog?.model ?? ""} /></Field>
|
||||
</Dialog>
|
||||
<Dialog title="Import skill" open={skillOpen} submitLabel="Import" onClose={() => setSkillOpen(false)} onSubmit={importSkill}>
|
||||
<Field label="Directory path"><input className="input" name="directoryPath" required /></Field>
|
||||
<Field label="Version"><input className="input" name="versionSemver" defaultValue="1.0.0" required /></Field>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsNav({ label, active, onClick }: { label: string; active: boolean; onClick: () => void }) {
|
||||
return <button type="button" className={`nav-item ${active ? "active" : ""}`} onClick={onClick}>{label}</button>;
|
||||
}
|
||||
|
||||
function ProvidersPanel({ providers, onManage }: { providers: ProviderRecord[]; onManage: (provider: ProviderRecord) => void }) {
|
||||
return (
|
||||
<div className="page">
|
||||
<h1 className="page-h1">Providers</h1>
|
||||
<p className="page-sub">Keys, account grants, OAuth profiles.</p>
|
||||
<div className="section-label">Configured providers</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{providers.map((provider) => (
|
||||
<div key={provider.id} className="card row">
|
||||
<ProviderLogo label={provider.label} />
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontWeight: 540 }}>{provider.label}</div>
|
||||
<div style={{ fontSize: 12, color: "var(--text-3)" }}>{provider.authType.replaceAll("_", " ")}</div>
|
||||
</div>
|
||||
<Pill tone={provider.enabled ? "green" : undefined}>{provider.enabled ? "Enabled" : "Unconfigured"}</Pill>
|
||||
<Button onClick={() => onManage(provider)}>Manage</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillsPanel({
|
||||
skills,
|
||||
agents,
|
||||
revisions,
|
||||
onImport,
|
||||
onBind,
|
||||
}: {
|
||||
skills: SkillDefinitionRecord[];
|
||||
agents: Array<{ id: string; name: string }>;
|
||||
revisions: Array<{ id: string; skillId: string; versionSemver: string }>;
|
||||
onImport: () => void;
|
||||
onBind: (agentId: string, skill: SkillDefinitionRecord, revisionId: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="row" style={{ marginBottom: 24 }}>
|
||||
<div>
|
||||
<h1 className="page-h1">Skills</h1>
|
||||
<p className="page-sub" style={{ marginBottom: 0 }}>{skills.length} skills · {agents.length} agents</p>
|
||||
</div>
|
||||
<div style={{ marginLeft: "auto" }}>
|
||||
<Button tone="primary" onClick={onImport}><Icon name="plus" /> Import</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
|
||||
{skills.length === 0 ? (
|
||||
<div style={{ padding: 18, color: "var(--text-3)" }}>No skills</div>
|
||||
) : skills.map((skill) => {
|
||||
const revision = revisions.find((item) => item.skillId === skill.id);
|
||||
return (
|
||||
<div key={skill.id} className="table-row" style={{ gridTemplateColumns: "1.6fr 1fr 1.6fr" }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 540 }}>{skill.name}</div>
|
||||
<div style={{ color: "var(--text-3)", fontSize: 11 }}>{skill.slug}</div>
|
||||
</div>
|
||||
<Pill>{revision?.versionSemver ?? "No revision"}</Pill>
|
||||
<select
|
||||
className="input"
|
||||
defaultValue=""
|
||||
onChange={(event) => {
|
||||
if (event.target.value && revision) onBind(event.target.value, skill, revision.id);
|
||||
event.target.value = "";
|
||||
}}
|
||||
>
|
||||
<option value="">Bind to agent</option>
|
||||
{agents.map((agent) => <option key={agent.id} value={agent.id}>{agent.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PolicyPanel() {
|
||||
return (
|
||||
<div className="page">
|
||||
<h1 className="page-h1">Policies</h1>
|
||||
<p className="page-sub">Approval, risk, provider, browser boundaries.</p>
|
||||
<div className="card" style={{ display: "grid", gap: 10 }}>
|
||||
<PolicyRow label="High-risk task" value="Board approval" />
|
||||
<PolicyRow label="Social publish" value="Human approval" />
|
||||
<PolicyRow label="Browser permissions" value="Deny by default" />
|
||||
<PolicyRow label="Provider secrets" value="OS encrypted" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MigrationPanel() {
|
||||
return (
|
||||
<div className="page">
|
||||
<h1 className="page-h1">Migration</h1>
|
||||
<p className="page-sub">Legacy import, id maps, checksum validation.</p>
|
||||
<div className="card" style={{ display: "grid", gap: 10 }}>
|
||||
<PolicyRow label="Legacy snapshot" value="Immutable" />
|
||||
<PolicyRow label="Entity checksums" value="Required" />
|
||||
<PolicyRow label="Delta journal" value="Recorded" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PolicyRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="row" style={{ justifyContent: "space-between" }}>
|
||||
<span>{label}</span>
|
||||
<Pill>{value}</Pill>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderLogo({ label }: { label: string }) {
|
||||
return (
|
||||
<div style={{ width: 30, height: 30, borderRadius: 7, display: "grid", placeItems: "center", background: "var(--text-1)", color: "#fff", fontWeight: 700 }}>
|
||||
{label.slice(0, 2)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { Avatar, Button, Field, Icon, Pill } from "../shared/ui";
|
||||
import { Dialog } from "../shared/Dialog";
|
||||
import { SOCIAL_PLATFORM, type SocialAccountRecord, type SocialPlatform } from "../../../shared/domain";
|
||||
import type { PageProps } from "./types";
|
||||
import { activeOrgId } from "./types";
|
||||
|
||||
function value(form: FormData, key: string): string {
|
||||
return String(form.get(key) ?? "").trim();
|
||||
}
|
||||
|
||||
export function SocialPage({ snapshot, actions }: PageProps) {
|
||||
const orgId = activeOrgId(snapshot);
|
||||
const [open, setOpen] = useState(false);
|
||||
const accounts = snapshot.socialAccounts.filter((item) => item.orgId === orgId);
|
||||
const employees = snapshot.employees.filter((item) => item.orgId === orgId);
|
||||
|
||||
const submit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const form = new FormData(event.currentTarget);
|
||||
void actions.run(() => window.agentCompany.createSocialAccount({
|
||||
orgId,
|
||||
platform: value(form, "platform") as SocialPlatform,
|
||||
handle: value(form, "handle"),
|
||||
displayName: value(form, "displayName"),
|
||||
authorizedEmployeeId: value(form, "authorizedEmployeeId") || null,
|
||||
})).then(() => setOpen(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="row" style={{ marginBottom: 24 }}>
|
||||
<div>
|
||||
<h1 className="page-h1">Social</h1>
|
||||
<p className="page-sub" style={{ marginBottom: 0 }}>{accounts.length} accounts</p>
|
||||
</div>
|
||||
<div style={{ marginLeft: "auto" }}>
|
||||
<Button tone="primary" onClick={() => setOpen(true)}><Icon name="plus" /> Connect account</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="section-label">Accounts</div>
|
||||
<div className="grid-4" style={{ marginBottom: 28 }}>
|
||||
{accounts.length === 0 ? (
|
||||
<div className="card" style={{ color: "var(--text-3)" }}>No social accounts</div>
|
||||
) : accounts.map((account) => (
|
||||
<AccountCard
|
||||
key={account.id}
|
||||
account={account}
|
||||
employeeName={employees.find((employee) => employee.id === account.authorizedEmployeeId)?.displayName ?? null}
|
||||
onLogin={() => void actions.run(() => window.agentCompany.launchSocialLogin({ socialAccountId: account.id }))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="section-label">Governance</div>
|
||||
<div className="card" style={{ display: "grid", gap: 10 }}>
|
||||
<PolicyRow label="Session isolation" value="Per account partition" />
|
||||
<PolicyRow label="Permissions" value="Camera, microphone, downloads denied" />
|
||||
<PolicyRow label="Navigation" value="Platform host allowlist" />
|
||||
</div>
|
||||
<Dialog title="Connect social account" open={open} submitLabel="Connect" onClose={() => setOpen(false)} onSubmit={submit}>
|
||||
<Field label="Platform">
|
||||
<select className="input" name="platform" defaultValue={SOCIAL_PLATFORM.X}>
|
||||
<option value={SOCIAL_PLATFORM.X}>X</option>
|
||||
<option value={SOCIAL_PLATFORM.LINKEDIN}>LinkedIn</option>
|
||||
<option value={SOCIAL_PLATFORM.YOUTUBE}>YouTube</option>
|
||||
<option value={SOCIAL_PLATFORM.INSTAGRAM}>Instagram</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Handle"><input className="input" name="handle" required /></Field>
|
||||
<Field label="Display name"><input className="input" name="displayName" required /></Field>
|
||||
<Field label="Authorized employee">
|
||||
<select className="input" name="authorizedEmployeeId">
|
||||
<option value="">Unassigned</option>
|
||||
{employees.map((employee) => <option key={employee.id} value={employee.id}>{employee.displayName}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AccountCard({
|
||||
account,
|
||||
employeeName,
|
||||
onLogin,
|
||||
}: {
|
||||
account: SocialAccountRecord;
|
||||
employeeName: string | null;
|
||||
onLogin: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="card" style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<div className="row">
|
||||
<PlatformIcon platform={account.platform} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 540 }}>{account.handle}</div>
|
||||
<div style={{ color: "var(--text-3)", fontSize: 11 }}>{account.displayName}</div>
|
||||
</div>
|
||||
</div>
|
||||
{employeeName && (
|
||||
<div className="row" style={{ color: "var(--text-3)", fontSize: 11 }}>
|
||||
<Avatar name={employeeName} size="sm" />
|
||||
<span>{employeeName}</span>
|
||||
</div>
|
||||
)}
|
||||
<Button size="small" onClick={onLogin}>Sign in</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PolicyRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="row" style={{ justifyContent: "space-between" }}>
|
||||
<span>{label}</span>
|
||||
<Pill>{value}</Pill>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlatformIcon({ platform }: { platform: SocialPlatform }) {
|
||||
const labels: Record<SocialPlatform, string> = {
|
||||
x: "X",
|
||||
linkedin: "in",
|
||||
youtube: "▶",
|
||||
instagram: "◎",
|
||||
};
|
||||
const colors: Record<SocialPlatform, string> = {
|
||||
x: "#0a0a0b",
|
||||
linkedin: "#0a66c2",
|
||||
youtube: "#ff0000",
|
||||
instagram: "#e1306c",
|
||||
};
|
||||
return (
|
||||
<div style={{ width: 30, height: 30, borderRadius: 7, background: colors[platform], color: "#fff", display: "grid", placeItems: "center", fontWeight: 700 }}>
|
||||
{labels[platform]}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { Avatar, Button, Field, Icon, Pill } from "../shared/ui";
|
||||
import { Dialog } from "../shared/Dialog";
|
||||
import type { PageProps } from "./types";
|
||||
import { activeOrgId, money } from "./types";
|
||||
|
||||
function text(form: FormData, key: string): string {
|
||||
return String(form.get(key) ?? "").trim();
|
||||
}
|
||||
|
||||
export function TeamPage({ snapshot, actions }: PageProps) {
|
||||
const orgId = activeOrgId(snapshot);
|
||||
const [open, setOpen] = useState(false);
|
||||
const employees = snapshot.employees.filter((item) => item.orgId === orgId);
|
||||
const agents = snapshot.agents.filter((item) => item.orgId === orgId);
|
||||
const providers = snapshot.providers.filter((item) => item.orgId === orgId && item.enabled);
|
||||
|
||||
const submit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const form = new FormData(event.currentTarget);
|
||||
void actions.run(() => window.agentCompany.createAgent({
|
||||
orgId,
|
||||
name: text(form, "name"),
|
||||
role: text(form, "role"),
|
||||
department: text(form, "department"),
|
||||
modelProviderId: text(form, "providerId") || null,
|
||||
modelId: text(form, "modelId") || null,
|
||||
budgetCents: Math.max(0, Math.round(Number(text(form, "budget")) * 100)),
|
||||
managerAgentId: text(form, "managerAgentId") || null,
|
||||
})).then(() => setOpen(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="row" style={{ marginBottom: 24 }}>
|
||||
<div>
|
||||
<h1 className="page-h1">Team</h1>
|
||||
<p className="page-sub" style={{ marginBottom: 0 }}>{employees.length} human · {agents.length} agents</p>
|
||||
</div>
|
||||
<div style={{ marginLeft: "auto" }}>
|
||||
<Button tone="primary" onClick={() => setOpen(true)}><Icon name="plus" /> Hire</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card" style={{ padding: 32, marginBottom: 28 }}>
|
||||
<div className="section-label">Org</div>
|
||||
<div style={{ display: "flex", justifyContent: "center", marginBottom: 20 }}>
|
||||
{employees[0] && <OrgCard name={employees[0].displayName} role={employees[0].role} human />}
|
||||
</div>
|
||||
<div className="grid-4">
|
||||
{agents.map((agent) => (
|
||||
<OrgCard key={agent.id} name={agent.name} role={agent.role} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="section-label">Roster</div>
|
||||
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
|
||||
<div className="table-row" style={{ gridTemplateColumns: "2fr 1.2fr 1.4fr 1fr", background: "var(--panel-2)", fontSize: 11, color: "var(--text-3)" }}>
|
||||
<div>Member</div><div>Department</div><div>Model</div><div>Budget</div>
|
||||
</div>
|
||||
{employees.map((employee) => (
|
||||
<RosterRow key={employee.id} name={employee.displayName} role={employee.role} dept="Board" model="" budget={null} human />
|
||||
))}
|
||||
{agents.map((agent) => (
|
||||
<RosterRow
|
||||
key={agent.id}
|
||||
name={agent.name}
|
||||
role={agent.role}
|
||||
dept={agent.department}
|
||||
model={agent.modelId ?? ""}
|
||||
budget={agent.budgetCents}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Dialog title="Hire agent" open={open} submitLabel="Hire" onClose={() => setOpen(false)} onSubmit={submit}>
|
||||
<Field label="Name"><input className="input" name="name" required /></Field>
|
||||
<Field label="Role"><input className="input" name="role" required /></Field>
|
||||
<Field label="Department"><input className="input" name="department" required /></Field>
|
||||
<Field label="Provider">
|
||||
<select className="input" name="providerId">
|
||||
<option value="">Unassigned</option>
|
||||
{providers.map((provider) => <option key={provider.id} value={provider.id}>{provider.label}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Model"><input className="input" name="modelId" /></Field>
|
||||
<Field label="Monthly budget"><input className="input" name="budget" type="number" min="0" step="1" defaultValue="0" /></Field>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OrgCard({ name, role, human = false }: { name: string; role: string; human?: boolean }) {
|
||||
return (
|
||||
<div className="card" style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<div className="row">
|
||||
<Avatar name={name} bot={!human} />
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 540 }}>{name}</div>
|
||||
<div style={{ fontSize: 11, color: "var(--text-3)" }}>{role}</div>
|
||||
</div>
|
||||
</div>
|
||||
<Pill tone={human ? undefined : "violet"} dot={!human}>{human ? "Human" : "Agent"}</Pill>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RosterRow({
|
||||
name,
|
||||
role,
|
||||
dept,
|
||||
model,
|
||||
budget,
|
||||
human = false,
|
||||
}: {
|
||||
name: string;
|
||||
role: string;
|
||||
dept: string;
|
||||
model: string;
|
||||
budget: number | null;
|
||||
human?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="table-row" style={{ gridTemplateColumns: "2fr 1.2fr 1.4fr 1fr" }}>
|
||||
<div className="row">
|
||||
<Avatar name={name} size="sm" bot={!human} />
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{name}</div>
|
||||
<div style={{ fontSize: 11, color: "var(--text-3)" }}>{role}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ color: "var(--text-2)" }}>{dept}</div>
|
||||
<div style={{ color: "var(--text-2)", fontFamily: model ? "var(--font-mono)" : "inherit", fontSize: model ? 12 : 13 }}>
|
||||
{model || "—"}
|
||||
</div>
|
||||
<div>{budget === null ? <span style={{ color: "var(--text-4)" }}>—</span> : <span className="num">{money(budget)}</span>}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { Avatar, Button, Field, Icon, IconButton, Pill } from "../shared/ui";
|
||||
import { Dialog } from "../shared/Dialog";
|
||||
import { RISK_LEVEL, TASK_STATUS, type TaskRecord, type TaskStatus } from "../../../shared/domain";
|
||||
import type { PageProps } from "./types";
|
||||
import { activeOrgId } from "./types";
|
||||
|
||||
const columns: Array<{ id: TaskStatus; label: string }> = [
|
||||
{ id: TASK_STATUS.BACKLOG, label: "Backlog" },
|
||||
{ id: TASK_STATUS.IN_PROGRESS, label: "In progress" },
|
||||
{ id: TASK_STATUS.REVIEW, label: "In review" },
|
||||
{ id: TASK_STATUS.DONE, label: "Done" },
|
||||
];
|
||||
|
||||
function value(form: FormData, key: string): string {
|
||||
return String(form.get(key) ?? "").trim();
|
||||
}
|
||||
|
||||
export function WorkPage({ snapshot, actions }: PageProps) {
|
||||
const orgId = activeOrgId(snapshot);
|
||||
const [open, setOpen] = useState(false);
|
||||
const tasks = snapshot.tasks.filter((item) => item.orgId === orgId);
|
||||
const agents = snapshot.agents.filter((item) => item.orgId === orgId);
|
||||
|
||||
const submit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const form = new FormData(event.currentTarget);
|
||||
void actions.run(() => window.agentCompany.createTask({
|
||||
orgId,
|
||||
title: value(form, "title"),
|
||||
description: value(form, "description"),
|
||||
status: TASK_STATUS.BACKLOG,
|
||||
assigneeAgentId: value(form, "assigneeAgentId") || null,
|
||||
risk: value(form, "risk") as "low" | "medium" | "high",
|
||||
})).then(() => setOpen(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-wide">
|
||||
<div className="row" style={{ marginBottom: 24 }}>
|
||||
<div>
|
||||
<h1 className="page-h1">Work</h1>
|
||||
<p className="page-sub" style={{ marginBottom: 0 }}>{tasks.length} tasks · {agents.length} assignees</p>
|
||||
</div>
|
||||
<div style={{ marginLeft: "auto" }}>
|
||||
<Button tone="primary" onClick={() => setOpen(true)}><Icon name="plus" /> New task</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, minmax(240px, 1fr))", gap: 12 }}>
|
||||
{columns.map((column) => {
|
||||
const columnTasks = tasks.filter((task) => task.status === column.id);
|
||||
return (
|
||||
<div key={column.id} style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<div className="row" style={{ padding: "4px 6px 8px" }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: 2, background: columnColor(column.id) }} />
|
||||
<span style={{ fontSize: 12.5, fontWeight: 540 }}>{column.label}</span>
|
||||
<span className="num" style={{ color: "var(--text-3)" }}>{columnTasks.length}</span>
|
||||
</div>
|
||||
{columnTasks.map((task) => (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
assignee={agents.find((agent) => agent.id === task.assigneeAgentId)?.name ?? null}
|
||||
onMove={(status) => void actions.run(() => window.agentCompany.updateTaskStatus({ orgId, taskId: task.id, status }))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Dialog title="New task" open={open} submitLabel="Create" onClose={() => setOpen(false)} onSubmit={submit}>
|
||||
<Field label="Title"><input className="input" name="title" required /></Field>
|
||||
<Field label="Description"><textarea className="input" name="description" required /></Field>
|
||||
<Field label="Assignee">
|
||||
<select className="input" name="assigneeAgentId">
|
||||
<option value="">Unassigned</option>
|
||||
{agents.map((agent) => <option key={agent.id} value={agent.id}>{agent.name}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Risk">
|
||||
<select className="input" name="risk" defaultValue={RISK_LEVEL.LOW}>
|
||||
<option value={RISK_LEVEL.LOW}>Low</option>
|
||||
<option value={RISK_LEVEL.MEDIUM}>Medium</option>
|
||||
<option value={RISK_LEVEL.HIGH}>High</option>
|
||||
</select>
|
||||
</Field>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskCard({
|
||||
task,
|
||||
assignee,
|
||||
onMove,
|
||||
}: {
|
||||
task: TaskRecord;
|
||||
assignee: string | null;
|
||||
onMove: (status: TaskStatus) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="card" style={{ display: "flex", flexDirection: "column", gap: 10, opacity: task.status === "done" ? 0.7 : 1 }}>
|
||||
<div style={{ display: "flex", alignItems: "flex-start", gap: 6 }}>
|
||||
{task.risk === "high" && <span style={{ width: 3, height: 14, background: "var(--red)", borderRadius: 2, marginTop: 2 }} />}
|
||||
<div style={{ fontWeight: 480, lineHeight: 1.35 }}>{task.title}</div>
|
||||
</div>
|
||||
<div className="row">
|
||||
<Pill tone={task.risk === "high" ? "amber" : undefined}>{task.risk}</Pill>
|
||||
{assignee && <Avatar name={assignee} size="sm" bot />}
|
||||
<div style={{ marginLeft: "auto" }}>
|
||||
{nextStatus(task.status) && <IconButton icon="check" label="Move" onClick={() => onMove(nextStatus(task.status)!)} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function columnColor(status: TaskStatus): string {
|
||||
if (status === TASK_STATUS.IN_PROGRESS) return "var(--accent)";
|
||||
if (status === TASK_STATUS.REVIEW) return "var(--amber)";
|
||||
if (status === TASK_STATUS.DONE) return "var(--green)";
|
||||
return "var(--text-4)";
|
||||
}
|
||||
|
||||
function nextStatus(status: TaskStatus): TaskStatus | null {
|
||||
if (status === TASK_STATUS.BACKLOG) return TASK_STATUS.IN_PROGRESS;
|
||||
if (status === TASK_STATUS.IN_PROGRESS) return TASK_STATUS.REVIEW;
|
||||
if (status === TASK_STATUS.REVIEW) return TASK_STATUS.DONE;
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { AppSnapshot } from "../../../shared/domain";
|
||||
import type { SnapshotActions } from "../app/useSnapshot";
|
||||
|
||||
export interface PageProps {
|
||||
snapshot: AppSnapshot;
|
||||
actions: SnapshotActions;
|
||||
}
|
||||
|
||||
export function activeOrgId(snapshot: AppSnapshot): string {
|
||||
const id = snapshot.activeOrgId;
|
||||
if (!id) throw new Error("Active organization is required.");
|
||||
return id;
|
||||
}
|
||||
|
||||
export function money(cents: number): string {
|
||||
return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 })
|
||||
.format(cents / 100);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { FormEvent, ReactNode } from "react";
|
||||
import { Button } from "./ui";
|
||||
|
||||
export function Dialog({
|
||||
title,
|
||||
open,
|
||||
children,
|
||||
submitLabel,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: {
|
||||
title: string;
|
||||
open: boolean;
|
||||
children: ReactNode;
|
||||
submitLabel: string;
|
||||
onClose: () => void;
|
||||
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
|
||||
}) {
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div className="modal-backdrop" role="presentation" onMouseDown={onClose}>
|
||||
<form className="modal" onMouseDown={(event) => event.stopPropagation()} onSubmit={onSubmit}>
|
||||
<h2 className="modal-title">{title}</h2>
|
||||
<div style={{ display: "grid", gap: 12 }}>{children}</div>
|
||||
<div className="modal-actions">
|
||||
<Button tone="ghost" onClick={onClose}>Cancel</Button>
|
||||
<Button tone="primary" type="submit">{submitLabel}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { AppSnapshot } from "../../../shared/domain";
|
||||
import { Avatar, Icon, IconButton } from "./ui";
|
||||
|
||||
export type RouteId = "home" | "team" | "work" | "chat" | "files" | "approvals" | "social" | "settings";
|
||||
|
||||
const navItems: Array<{ id: RouteId; label: string; icon: string }> = [
|
||||
{ id: "home", label: "Home", icon: "home" },
|
||||
{ id: "team", label: "Team", icon: "team" },
|
||||
{ id: "work", label: "Work", icon: "work" },
|
||||
{ id: "chat", label: "Messages", icon: "chat" },
|
||||
{ id: "files", label: "Files", icon: "docs" },
|
||||
{ id: "approvals", label: "Approvals", icon: "approve" },
|
||||
{ id: "social", label: "Social", icon: "social" },
|
||||
{ id: "settings", label: "Settings", icon: "settings" },
|
||||
];
|
||||
|
||||
export function Shell({
|
||||
snapshot,
|
||||
route,
|
||||
setRoute,
|
||||
actions,
|
||||
children,
|
||||
}: {
|
||||
snapshot: AppSnapshot;
|
||||
route: RouteId;
|
||||
setRoute: (route: RouteId) => void;
|
||||
actions?: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const organization = snapshot.organizations.find((item) => item.id === snapshot.activeOrgId);
|
||||
const employee = snapshot.employees.find((item) => item.orgId === snapshot.activeOrgId);
|
||||
const badges = {
|
||||
work: snapshot.tasks.filter((item) => item.orgId === snapshot.activeOrgId && item.status !== "done").length,
|
||||
chat: snapshot.chatMessages.filter((item) => item.orgId === snapshot.activeOrgId).length,
|
||||
approvals: snapshot.approvals.filter((item) => item.orgId === snapshot.activeOrgId && item.state === "pending").length,
|
||||
} as Partial<Record<RouteId, number>>;
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<aside className="sidebar">
|
||||
<div className="brand">
|
||||
<div className="brand-mark">A</div>
|
||||
<div className="brand-name">{organization?.name ?? "AgentCompany"}</div>
|
||||
<Icon name="chevronDown" size={12} />
|
||||
</div>
|
||||
<nav className="nav">
|
||||
{navItems.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={`nav-item ${route === item.id ? "active" : ""}`}
|
||||
onClick={() => setRoute(item.id)}
|
||||
>
|
||||
<Icon name={item.icon} size={15} />
|
||||
<span>{item.label}</span>
|
||||
{badges[item.id] ? <span className="nav-badge">{badges[item.id]}</span> : null}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<div className="sidebar-foot">
|
||||
<Avatar name={employee?.displayName ?? "User"} size="sm" />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>{employee?.displayName ?? "User"}</div>
|
||||
<Icon name="bell" />
|
||||
</div>
|
||||
</aside>
|
||||
<main className="main">
|
||||
<header className="topbar">
|
||||
<span className="crumb-sub">{organization?.name ?? "Workspace"}</span>
|
||||
<span style={{ color: "var(--text-4)" }}>/</span>
|
||||
<span className="crumb">{navItems.find((item) => item.id === route)?.label}</span>
|
||||
<div className="topbar-actions">
|
||||
{actions}
|
||||
{window.agentCompany.platform !== "darwin" && (
|
||||
<>
|
||||
<IconButton icon="more" label="Minimize" onClick={() => void window.agentCompany.windowMinimize()} />
|
||||
<IconButton icon="x" label="Close" onClick={() => void window.agentCompany.windowClose()} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
<div className="content">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
Bell,
|
||||
Bot,
|
||||
Check,
|
||||
ChevronDown,
|
||||
FileText,
|
||||
Filter,
|
||||
Folder,
|
||||
Grid2X2,
|
||||
Hash,
|
||||
Home,
|
||||
Inbox,
|
||||
List,
|
||||
Lock,
|
||||
MessageCircle,
|
||||
MoreHorizontal,
|
||||
Paperclip,
|
||||
Plus,
|
||||
Search,
|
||||
Send,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
Upload,
|
||||
Users,
|
||||
X,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
const icons: Record<string, LucideIcon> = {
|
||||
approve: ShieldCheck,
|
||||
attach: Paperclip,
|
||||
bell: Bell,
|
||||
bot: Bot,
|
||||
chat: MessageCircle,
|
||||
check: Check,
|
||||
chevronDown: ChevronDown,
|
||||
docs: FileText,
|
||||
file: FileText,
|
||||
filter: Filter,
|
||||
folder: Folder,
|
||||
grid: Grid2X2,
|
||||
hash: Hash,
|
||||
home: Home,
|
||||
inbox: Inbox,
|
||||
list: List,
|
||||
lock: Lock,
|
||||
more: MoreHorizontal,
|
||||
plus: Plus,
|
||||
search: Search,
|
||||
send: Send,
|
||||
settings: Settings,
|
||||
social: Sparkles,
|
||||
team: Users,
|
||||
upload: Upload,
|
||||
work: Inbox,
|
||||
x: X,
|
||||
};
|
||||
|
||||
const palette = [
|
||||
["#5e6ad2", "#fff"],
|
||||
["#e8b339", "#231600"],
|
||||
["#26b69a", "#fff"],
|
||||
["#e57373", "#fff"],
|
||||
["#8b6dd9", "#fff"],
|
||||
["#3a7bd5", "#fff"],
|
||||
["#2a2a2e", "#fff"],
|
||||
["#c2410c", "#fff"],
|
||||
];
|
||||
|
||||
function hashName(value: string): number {
|
||||
let hash = 0;
|
||||
for (const char of value) hash = (hash * 31 + char.charCodeAt(0)) | 0;
|
||||
return Math.abs(hash);
|
||||
}
|
||||
|
||||
export function Icon({ name, size = 14 }: { name: string; size?: number }) {
|
||||
const Component = icons[name] ?? FileText;
|
||||
return <Component size={size} strokeWidth={1.7} />;
|
||||
}
|
||||
|
||||
export function Avatar({ name, size = "", bot = false }: { name: string; size?: "sm" | ""; bot?: boolean }) {
|
||||
const initials = name.split(/\s+/).map((part) => part[0]).slice(0, 2).join("").toUpperCase();
|
||||
const [background, color] = palette[hashName(name) % palette.length];
|
||||
return (
|
||||
<div className={`avatar ${size} ${bot ? "bot" : ""}`} style={{ background, color }}>
|
||||
{bot ? <Icon name="bot" size={size === "sm" ? 13 : 16} /> : initials}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Pill({
|
||||
children,
|
||||
tone,
|
||||
dot = false,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
tone?: "amber" | "green" | "violet";
|
||||
dot?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<span className={`pill ${tone ?? ""}`}>
|
||||
{dot && <span className="dot" />}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function Button({
|
||||
children,
|
||||
tone,
|
||||
size,
|
||||
onClick,
|
||||
type = "button",
|
||||
}: {
|
||||
children: ReactNode;
|
||||
tone?: "primary" | "ghost";
|
||||
size?: "small";
|
||||
onClick?: () => void;
|
||||
type?: "button" | "submit";
|
||||
}) {
|
||||
return (
|
||||
<button type={type} onClick={onClick} className={`button ${tone ?? ""} ${size ?? ""}`}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconButton({
|
||||
icon,
|
||||
onClick,
|
||||
label,
|
||||
}: {
|
||||
icon: string;
|
||||
onClick?: () => void;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<button type="button" className="icon-button ghost" aria-label={label} title={label} onClick={onClick}>
|
||||
<Icon name={icon} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function Field({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<label className="field">
|
||||
<span>{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
:root {
|
||||
--bg: #fafafa;
|
||||
--panel: #ffffff;
|
||||
--panel-2: #f6f6f7;
|
||||
--panel-3: #f1f1f2;
|
||||
--hover: #f4f4f5;
|
||||
--border: #ececef;
|
||||
--border-strong: #e2e2e6;
|
||||
--border-soft: #f1f1f3;
|
||||
--text-1: #09090b;
|
||||
--text-2: #52525b;
|
||||
--text-3: #a1a1aa;
|
||||
--text-4: #d4d4d8;
|
||||
--accent: #5e6ad2;
|
||||
--accent-soft: #eef0ff;
|
||||
--green: #2f9e58;
|
||||
--green-soft: #e8f5ec;
|
||||
--amber: #c87d12;
|
||||
--amber-soft: #fbf3e2;
|
||||
--red: #d23b3b;
|
||||
--r-sm: 6px;
|
||||
--r-md: 8px;
|
||||
--r-lg: 12px;
|
||||
--shadow-sm: 0 1px 0 rgba(9, 9, 11, 0.04), 0 1px 2px rgba(9, 9, 11, 0.04);
|
||||
--font-sans: Inter, -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif;
|
||||
--font-mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, monospace;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body, #root { width: 100%; height: 100%; margin: 0; }
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
color: var(--text-1);
|
||||
background: var(--bg);
|
||||
font-size: 13px;
|
||||
letter-spacing: 0;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
button, input, textarea, select { font: inherit; }
|
||||
button { cursor: pointer; }
|
||||
|
||||
.app-shell {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
}
|
||||
.sidebar {
|
||||
width: 232px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 14px 10px;
|
||||
border-right: 1px solid var(--border-soft);
|
||||
}
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: var(--r-md);
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.brand-mark {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 6px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: white;
|
||||
background: linear-gradient(135deg, #1a1a1d 0%, #3a3a40 100%);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.brand-name { flex: 1; font-weight: 540; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.nav { display: flex; flex-direction: column; gap: 1px; }
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 6px 8px;
|
||||
border: 0;
|
||||
border-radius: var(--r-md);
|
||||
background: transparent;
|
||||
color: var(--text-2);
|
||||
font-weight: 480;
|
||||
text-align: left;
|
||||
}
|
||||
.nav-item:hover { background: var(--hover); color: var(--text-1); }
|
||||
.nav-item.active { background: var(--panel-3); color: var(--text-1); font-weight: 540; }
|
||||
.nav-badge { margin-left: auto; font-size: 11px; color: var(--text-3); font-variant-numeric: tabular-nums; }
|
||||
.sidebar-foot {
|
||||
margin-top: auto;
|
||||
padding: 10px 8px 4px;
|
||||
border-top: 1px solid var(--border-soft);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
}
|
||||
.main {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg);
|
||||
}
|
||||
.topbar {
|
||||
height: 44px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0 20px;
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
.topbar button, .topbar input, .sidebar button { -webkit-app-region: no-drag; }
|
||||
.crumb { font-size: 13px; font-weight: 540; }
|
||||
.crumb-sub { color: var(--text-3); }
|
||||
.topbar-actions { margin-left: auto; display: flex; align-items: center; gap: 6px; }
|
||||
.content { flex: 1; overflow: auto; padding: 28px 32px 40px; }
|
||||
.page { max-width: 1100px; }
|
||||
.page-wide { min-width: 980px; }
|
||||
.page-h1 { margin: 0 0 4px; font-size: 22px; font-weight: 620; letter-spacing: 0; }
|
||||
.page-sub { margin: 0 0 24px; color: var(--text-3); }
|
||||
.section-label { margin-bottom: 10px; color: var(--text-3); font-size: 11px; font-weight: 540; letter-spacing: 0.02em; }
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-lg);
|
||||
padding: 16px;
|
||||
}
|
||||
.card-label { color: var(--text-3); font-size: 11px; font-weight: 540; letter-spacing: 0.02em; }
|
||||
.button {
|
||||
height: 28px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 10px;
|
||||
border-radius: var(--r-md);
|
||||
border: 1px solid var(--border-strong);
|
||||
background: var(--panel);
|
||||
color: var(--text-1);
|
||||
font-size: 12.5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.button:hover { background: var(--panel-2); }
|
||||
.button.primary { background: var(--text-1); color: white; border-color: var(--text-1); }
|
||||
.button.ghost { border-color: transparent; background: transparent; color: var(--text-2); }
|
||||
.button.small { height: 24px; padding: 0 8px; font-size: 12px; }
|
||||
.icon-button {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: var(--r-md);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
color: var(--text-2);
|
||||
}
|
||||
.icon-button.ghost { border-color: transparent; background: transparent; }
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
height: 20px;
|
||||
padding: 0 7px;
|
||||
border-radius: 999px;
|
||||
background: var(--panel-2);
|
||||
color: var(--text-2);
|
||||
border: 1px solid var(--border);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.pill.amber { background: var(--amber-soft); color: var(--amber); border-color: transparent; }
|
||||
.pill.green { background: var(--green-soft); color: var(--green); border-color: transparent; }
|
||||
.pill.violet { background: var(--accent-soft); color: var(--accent); border-color: transparent; }
|
||||
.dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
||||
.avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex-shrink: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
color: white;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.avatar.bot { border-radius: 8px; }
|
||||
.avatar.sm { width: 22px; height: 22px; font-size: 10px; }
|
||||
.grid-3 { display: grid; grid-template-columns: 1.4fr 1fr 1fr; gap: 12px; }
|
||||
.grid-4 { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
|
||||
.row { display: flex; align-items: center; gap: 10px; }
|
||||
.num { font-variant-numeric: tabular-nums; font-family: var(--font-mono); }
|
||||
.table-row {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 12px 18px;
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
}
|
||||
.table-row:last-child { border-bottom: 0; }
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
color: var(--text-2);
|
||||
font-size: 12px;
|
||||
}
|
||||
.input {
|
||||
width: 100%;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--panel);
|
||||
color: var(--text-1);
|
||||
padding: 8px 10px;
|
||||
outline: none;
|
||||
}
|
||||
textarea.input { min-height: 84px; resize: vertical; }
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
background: rgba(9, 9, 11, 0.18);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
}
|
||||
.modal {
|
||||
width: min(520px, 100%);
|
||||
max-height: min(760px, 100%);
|
||||
overflow: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
background: var(--panel);
|
||||
box-shadow: 0 20px 60px rgba(9, 9, 11, 0.18);
|
||||
padding: 18px;
|
||||
}
|
||||
.modal-title { margin: 0 0 16px; font-size: 16px; font-weight: 620; }
|
||||
.modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
|
||||
.split-main { display: flex; flex-direction: row; min-height: 0; flex: 1; }
|
||||
.secondary-pane {
|
||||
width: 240px;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid var(--border-soft);
|
||||
padding: 14px 10px;
|
||||
background: var(--bg);
|
||||
}
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 4px; }
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.sidebar { width: 64px; }
|
||||
.brand-name, .nav-item span:not(.nav-badge), .sidebar-foot > div { display: none; }
|
||||
.content { padding: 20px 18px 32px; }
|
||||
.grid-3, .grid-4 { grid-template-columns: 1fr; }
|
||||
.page-wide { min-width: 0; }
|
||||
.secondary-pane { width: 180px; }
|
||||
}
|
||||
Reference in new issue
Block a user