Add Documents, Knowledge Base, and Automation pages

Three new pages in the sidebar navigation: Documents (CRUD with type
filtering, inline editing, status lifecycle), Knowledge Base (card grid
with category/importance filters, create/delete), and Automation (rule
table with trigger/action/priority/enable toggle, create/edit/delete).
Each page connects to the corresponding backend service from the
previous commit.
This commit is contained in:
ZacharyZhang-NY committed 2026-06-07 17:49:39 -04:00
1 parent b5b8f3ba3c
commit d50f0d8d1f
5 files changed
+421

No files matched your search

@@ -9,6 +9,9 @@ import { ChatPage } from "../pages/ChatPage";
import { FilesPage } from "../pages/FilesPage";
import { ApprovalsPage } from "../pages/ApprovalsPage";
import { SocialPage } from "../pages/SocialPage";
import { DocumentsPage } from "../pages/DocumentsPage";
import { KnowledgePage } from "../pages/KnowledgePage";
import { AutomationPage } from "../pages/AutomationPage";
import { SettingsPage } from "../pages/SettingsPage";
export function App() {
@@ -37,6 +40,9 @@ export function App() {
work: <WorkPage {...pageProps} />,
chat: <ChatPage {...pageProps} />,
files: <FilesPage {...pageProps} />,
docs: <DocumentsPage {...pageProps} />,
knowledge: <KnowledgePage {...pageProps} />,
automation: <AutomationPage {...pageProps} />,
approvals: <ApprovalsPage {...pageProps} />,
social: <SocialPage {...pageProps} />,
settings: <SettingsPage {...pageProps} />,
@@ -0,0 +1,157 @@
import { useMemo, useState, type FormEvent } from "react";
import { Button, Field, Icon, IconButton, Pill } from "../shared/ui";
import { TopbarActions } from "../shared/TopbarActions";
import { Dialog } from "../shared/Dialog";
import type { AutomationRuleRecord } from "../../../shared/domain";
import type { PageProps } from "./types";
import { activeOrgId, formatRelative } from "./types";
const TRIGGER_EVENTS = [
"task_created",
"task_status_changed",
"agent_hired",
"approval_created",
"message_sent",
"file_uploaded",
"goal_created",
"project_created",
] as const;
const ACTION_TYPES = [
"assign_to_department",
"create_task",
"send_notification",
"update_status",
"escalate",
] as const;
function text(form: FormData, key: string): string {
return String(form.get(key) ?? "").trim();
}
export function AutomationPage({ snapshot, actions }: PageProps) {
const orgId = activeOrgId(snapshot);
const rules = useMemo(() => snapshot.automationRules.filter((r) => r.orgId === orgId), [snapshot.automationRules, orgId]);
const [createOpen, setCreateOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const editing = editingId ? rules.find((r) => r.id === editingId) ?? null : null;
const enabledCount = rules.filter((r) => r.enabled).length;
const totalFires = rules.reduce((sum, r) => sum + r.fireCount, 0);
const create = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
void actions.run(() => window.agentCompany.createAutomationRule({
orgId,
name: text(form, "name"),
triggerEvent: text(form, "triggerEvent"),
conditions: {},
actionType: text(form, "actionType"),
actionConfig: {},
priority: Number(text(form, "priority")) || 100,
enabled: true,
})).then((r) => { if (r) setCreateOpen(false); });
};
const save = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!editing) return;
const form = new FormData(event.currentTarget);
void actions.run(() => window.agentCompany.updateAutomationRule({
orgId,
ruleId: editing.id,
name: text(form, "name"),
triggerEvent: text(form, "triggerEvent"),
conditions: {},
actionType: text(form, "actionType"),
actionConfig: {},
priority: Number(text(form, "priority")) || editing.priority,
enabled: form.get("enabled") === "on",
})).then((r) => { if (r) setEditingId(null); });
};
const remove = (ruleId: string) => {
void actions.run(() => window.agentCompany.deleteAutomationRule({ orgId, ruleId }));
setEditingId(null);
};
return (
<div className="page">
<TopbarActions>
<Button tone="primary" onClick={() => setCreateOpen(true)}><Icon name="plus" size={13} /> New rule</Button>
</TopbarActions>
<h1 className="page-h1">Automation</h1>
<p className="page-sub">{enabledCount} active · {totalFires} total fires</p>
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
<div className="table-row head" style={{ gridTemplateColumns: "minmax(0, 2fr) minmax(0, 1.2fr) minmax(0, 1.2fr) 70px 80px 50px" }}>
<div>Name</div><div>Trigger</div><div>Action</div><div>Fires</div><div>Status</div><div />
</div>
{rules.map((rule, index) => (
<div
key={rule.id}
role="button"
tabIndex={0}
className="table-row clickable"
onClick={() => setEditingId(rule.id)}
onKeyDown={(e) => { if (e.key === "Enter") setEditingId(rule.id); }}
style={{ gridTemplateColumns: "minmax(0, 2fr) minmax(0, 1.2fr) minmax(0, 1.2fr) 70px 80px 50px", borderBottom: index === rules.length - 1 ? "none" : "1px solid var(--border-soft)" }}
>
<div style={{ fontWeight: 480, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{rule.name}</div>
<div><Pill>{humanize(rule.triggerEvent)}</Pill></div>
<div><Pill tone="violet">{humanize(rule.actionType)}</Pill></div>
<div className="num" style={{ fontSize: 12 }}>{rule.fireCount}</div>
<div>{rule.enabled ? <Pill tone="green">Active</Pill> : <Pill>Paused</Pill>}</div>
<div style={{ textAlign: "right" }} onClick={(e) => e.stopPropagation()}>
<IconButton icon="x" label="Delete" onClick={() => remove(rule.id)} />
</div>
</div>
))}
</div>
<Dialog title="New automation rule" open={createOpen} submitLabel="Create" onClose={() => setCreateOpen(false)} onSubmit={create}>
<Field label="Name"><input className="input" name="name" required /></Field>
<Field label="Trigger">
<select className="input" name="triggerEvent" defaultValue="task_created">
{TRIGGER_EVENTS.map((t) => <option key={t} value={t}>{humanize(t)}</option>)}
</select>
</Field>
<Field label="Action">
<select className="input" name="actionType" defaultValue="assign_to_department">
{ACTION_TYPES.map((a) => <option key={a} value={a}>{humanize(a)}</option>)}
</select>
</Field>
<Field label="Priority"><input className="input" name="priority" type="number" defaultValue="100" /></Field>
</Dialog>
{editing && (
<Dialog key={editing.id} title={editing.name} open submitLabel="Save" onClose={() => setEditingId(null)} onSubmit={save}>
<Field label="Name"><input className="input" name="name" defaultValue={editing.name} required /></Field>
<Field label="Trigger">
<select className="input" name="triggerEvent" defaultValue={editing.triggerEvent}>
{TRIGGER_EVENTS.map((t) => <option key={t} value={t}>{humanize(t)}</option>)}
</select>
</Field>
<Field label="Action">
<select className="input" name="actionType" defaultValue={editing.actionType}>
{ACTION_TYPES.map((a) => <option key={a} value={a}>{humanize(a)}</option>)}
</select>
</Field>
<Field label="Priority"><input className="input" name="priority" type="number" defaultValue={editing.priority} /></Field>
<Field label="Enabled">
<label className="row" style={{ gap: 8 }}>
<input type="checkbox" name="enabled" defaultChecked={editing.enabled} />
<span style={{ fontSize: 13 }}>Rule is active</span>
</label>
</Field>
{editing.lastFiredAt && <div style={{ fontSize: 12, color: "var(--text-3)" }}>Last fired {formatRelative(editing.lastFiredAt)}</div>}
</Dialog>
)}
</div>
);
}
function humanize(value: string): string {
return value.replace(/_/g, " ");
}
@@ -0,0 +1,158 @@
import { useMemo, useState, type FormEvent } from "react";
import { Avatar, Button, Field, Icon, IconButton, Pill } from "../shared/ui";
import { TopbarActions } from "../shared/TopbarActions";
import { Dialog } from "../shared/Dialog";
import type { DocumentRecord } from "../../../shared/domain";
import type { PageProps } from "./types";
import { activeOrgId, formatRelative } from "./types";
const DOC_TYPES = ["prd", "spec", "memo", "report", "runbook", "proposal", "policy", "guide", "template", "other"] as const;
const DOC_STATUSES = ["draft", "in_review", "approved", "archived"] as const;
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 DocumentsPage({ snapshot, actions }: PageProps) {
const orgId = activeOrgId(snapshot);
const documents = useMemo(() => snapshot.documents.filter((d) => d.orgId === orgId), [snapshot.documents, orgId]);
const agents = useMemo(() => snapshot.agents.filter((a) => a.orgId === orgId), [snapshot.agents, orgId]);
const projects = useMemo(() => snapshot.projects.filter((p) => p.orgId === orgId), [snapshot.projects, orgId]);
const goals = useMemo(() => snapshot.goals.filter((g) => g.orgId === orgId), [snapshot.goals, orgId]);
const [createOpen, setCreateOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [filter, setFilter] = useState<string>("all");
const filtered = filter === "all" ? documents : documents.filter((d) => d.docType === filter);
const editing = editingId ? documents.find((d) => d.id === editingId) ?? null : null;
const create = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
void actions.run(() => window.agentCompany.createDocument({
orgId,
title: text(form, "title"),
docType: text(form, "docType"),
content: text(form, "content"),
authorAgentId: optional(form, "authorAgentId"),
reviewerAgentId: null,
projectId: optional(form, "projectId"),
goalId: optional(form, "goalId"),
taskId: null,
})).then((r) => { if (r) setCreateOpen(false); });
};
const save = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!editing) return;
const form = new FormData(event.currentTarget);
void actions.run(() => window.agentCompany.updateDocument({
orgId,
documentId: editing.id,
title: text(form, "title"),
content: text(form, "content"),
status: text(form, "status"),
})).then((r) => { if (r) setEditingId(null); });
};
const remove = (docId: string) => {
void actions.run(() => window.agentCompany.deleteDocument({ orgId, documentId: docId }));
setEditingId(null);
};
return (
<div className="page">
<TopbarActions>
<select className="button" value={filter} onChange={(e) => setFilter(e.target.value)} aria-label="Filter type">
<option value="all">All types</option>
{DOC_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
</select>
<Button tone="primary" onClick={() => setCreateOpen(true)}><Icon name="plus" size={13} /> New document</Button>
</TopbarActions>
<h1 className="page-h1">Documents</h1>
<p className="page-sub">{documents.length} {documents.length === 1 ? "document" : "documents"}</p>
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
<div className="table-row head" style={{ gridTemplateColumns: "minmax(0, 2.5fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr) 60px" }}>
<div>Title</div><div>Type</div><div>Status</div><div>Updated</div><div />
</div>
{filtered.map((doc, index) => {
const author = agents.find((a) => a.id === doc.authorAgentId);
return (
<div
key={doc.id}
role="button"
tabIndex={0}
className="table-row clickable"
onClick={() => setEditingId(doc.id)}
onKeyDown={(e) => { if (e.key === "Enter") setEditingId(doc.id); }}
style={{ gridTemplateColumns: "minmax(0, 2.5fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr) 60px", borderBottom: index === filtered.length - 1 ? "none" : "1px solid var(--border-soft)" }}
>
<div className="row" style={{ gap: 8, minWidth: 0 }}>
{author && <Avatar name={author.name} size="sm" bot />}
<span style={{ fontWeight: 480, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{doc.title}</span>
</div>
<div><Pill>{doc.docType}</Pill></div>
<div><Pill tone={statusTone(doc.status)}>{doc.status}</Pill></div>
<div style={{ fontSize: 12, color: "var(--text-3)" }}>{formatRelative(doc.updatedAt)}</div>
<div style={{ textAlign: "right" }} onClick={(e) => e.stopPropagation()}>
<IconButton icon="x" label="Delete" onClick={() => remove(doc.id)} />
</div>
</div>
);
})}
</div>
<Dialog title="New document" open={createOpen} submitLabel="Create" onClose={() => setCreateOpen(false)} onSubmit={create}>
<Field label="Title"><input className="input" name="title" required /></Field>
<Field label="Type">
<select className="input" name="docType" defaultValue="memo">
{DOC_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
</select>
</Field>
<Field label="Content"><textarea className="input" name="content" rows={6} required /></Field>
<Field label="Author">
<select className="input" name="authorAgentId" defaultValue="">
<option value="">None</option>
{agents.map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}
</select>
</Field>
<Field label="Project">
<select className="input" name="projectId" defaultValue="">
<option value="">None</option>
{projects.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
</Field>
<Field label="Goal">
<select className="input" name="goalId" defaultValue="">
<option value="">None</option>
{goals.map((g) => <option key={g.id} value={g.id}>{g.title}</option>)}
</select>
</Field>
</Dialog>
{editing && (
<Dialog key={editing.id} title={editing.title} open submitLabel="Save" onClose={() => setEditingId(null)} onSubmit={save}>
<Field label="Title"><input className="input" name="title" defaultValue={editing.title} required /></Field>
<Field label="Status">
<select className="input" name="status" defaultValue={editing.status}>
{DOC_STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
</Field>
<Field label="Content"><textarea className="input" name="content" rows={10} defaultValue={editing.content} required /></Field>
</Dialog>
)}
</div>
);
}
function statusTone(status: string): "amber" | "violet" | "green" | undefined {
if (status === "draft") return undefined;
if (status === "in_review") return "amber";
if (status === "approved") return "green";
return "violet";
}
@@ -0,0 +1,94 @@
import { useMemo, useState, type FormEvent } from "react";
import { Button, Field, Icon, IconButton, Pill } from "../shared/ui";
import { TopbarActions } from "../shared/TopbarActions";
import { Dialog } from "../shared/Dialog";
import type { PageProps } from "./types";
import { activeOrgId, formatRelative } from "./types";
const CATEGORIES = ["process", "technical", "product", "market", "legal", "financial", "operational", "general"] as const;
const IMPORTANCES = ["critical", "high", "medium", "low"] as const;
function text(form: FormData, key: string): string {
return String(form.get(key) ?? "").trim();
}
export function KnowledgePage({ snapshot, actions }: PageProps) {
const orgId = activeOrgId(snapshot);
const entries = useMemo(() => snapshot.knowledgeEntries.filter((e) => e.orgId === orgId), [snapshot.knowledgeEntries, orgId]);
const [createOpen, setCreateOpen] = useState(false);
const [filter, setFilter] = useState<string>("all");
const filtered = filter === "all" ? entries : entries.filter((e) => e.category === filter);
const create = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
void actions.run(() => window.agentCompany.createKnowledgeEntry({
orgId,
topic: text(form, "topic"),
category: text(form, "category"),
content: text(form, "content"),
importance: text(form, "importance"),
sourceType: "manual",
sourceId: null,
})).then((r) => { if (r) setCreateOpen(false); });
};
const remove = (entryId: string) => {
void actions.run(() => window.agentCompany.deleteKnowledgeEntry({ orgId, entryId }));
};
return (
<div className="page">
<TopbarActions>
<select className="button" value={filter} onChange={(e) => setFilter(e.target.value)} aria-label="Filter category">
<option value="all">All categories</option>
{CATEGORIES.map((c) => <option key={c} value={c}>{c}</option>)}
</select>
<Button tone="primary" onClick={() => setCreateOpen(true)}><Icon name="plus" size={13} /> Add entry</Button>
</TopbarActions>
<h1 className="page-h1">Knowledge Base</h1>
<p className="page-sub">{entries.length} {entries.length === 1 ? "entry" : "entries"}</p>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(320px, 1fr))", gap: 12 }}>
{filtered.map((entry) => (
<article key={entry.id} className="card" style={{ padding: 16 }}>
<div className="row" style={{ marginBottom: 8 }}>
<span style={{ fontWeight: 540, flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{entry.topic}</span>
<IconButton icon="x" label="Delete" onClick={() => remove(entry.id)} />
</div>
<div className="row" style={{ gap: 6, marginBottom: 10 }}>
<Pill>{entry.category}</Pill>
<Pill tone={importanceTone(entry.importance)}>{entry.importance}</Pill>
</div>
<div style={{ fontSize: 13, color: "var(--text-2)", lineHeight: 1.5, whiteSpace: "pre-wrap", maxHeight: 120, overflow: "hidden" }}>
{entry.content}
</div>
<div style={{ marginTop: 8, fontSize: 11, color: "var(--text-3)" }}>{formatRelative(entry.updatedAt)}</div>
</article>
))}
</div>
<Dialog title="New knowledge entry" open={createOpen} submitLabel="Add" onClose={() => setCreateOpen(false)} onSubmit={create}>
<Field label="Topic"><input className="input" name="topic" required /></Field>
<Field label="Category">
<select className="input" name="category" defaultValue="general">
{CATEGORIES.map((c) => <option key={c} value={c}>{c}</option>)}
</select>
</Field>
<Field label="Importance">
<select className="input" name="importance" defaultValue="medium">
{IMPORTANCES.map((i) => <option key={i} value={i}>{i}</option>)}
</select>
</Field>
<Field label="Content"><textarea className="input" name="content" rows={6} required /></Field>
</Dialog>
</div>
);
}
function importanceTone(importance: string): "red" | "amber" | undefined {
if (importance === "critical") return "red";
if (importance === "high") return "amber";
return undefined;
}
@@ -12,6 +12,9 @@ export type RouteId =
| "work"
| "chat"
| "files"
| "docs"
| "knowledge"
| "automation"
| "approvals"
| "social"
| "settings";
@@ -32,6 +35,9 @@ const primaryNav: NavSpec[] = [
{ id: "work", label: "Work", icon: "work" },
{ id: "chat", label: "Messages", icon: "chat" },
{ id: "files", label: "Files", icon: "docs" },
{ id: "docs", label: "Documents", icon: "file" },
{ id: "knowledge", label: "Knowledge", icon: "sparkle" },
{ id: "automation", label: "Automation", icon: "bot" },
{ id: "approvals", label: "Approvals", icon: "approve" },
{ id: "social", label: "Social", icon: "social" },
];