Trim the sidebar to the surfaces that earn their place
Inbox, Knowledge, Meetings, and Sprints leave the navigation and their page components go with them; the backend services and data they surfaced stay intact for agents and automation. The secondary rail is now Documents, Social, and Office.
This commit is contained in:
1 parent
d528cd6f75
commit
a11d2dfa07
6 files changed
-511
No files matched your search
@@ -10,11 +10,7 @@ import { FilesPage } from "../pages/FilesPage";
|
|||||||
import { ApprovalsPage } from "../pages/ApprovalsPage";
|
import { ApprovalsPage } from "../pages/ApprovalsPage";
|
||||||
import { SocialPage } from "../pages/SocialPage";
|
import { SocialPage } from "../pages/SocialPage";
|
||||||
import { DocumentsPage } from "../pages/DocumentsPage";
|
import { DocumentsPage } from "../pages/DocumentsPage";
|
||||||
import { KnowledgePage } from "../pages/KnowledgePage";
|
|
||||||
import { InboxPage } from "../pages/InboxPage";
|
|
||||||
import { RunsPage } from "../pages/RunsPage";
|
import { RunsPage } from "../pages/RunsPage";
|
||||||
import { MeetingsPage } from "../pages/MeetingsPage";
|
|
||||||
import { SprintsPage } from "../pages/SprintsPage";
|
|
||||||
import { SettingsPage } from "../pages/SettingsPage";
|
import { SettingsPage } from "../pages/SettingsPage";
|
||||||
import { OfficePage } from "../pages/OfficePage";
|
import { OfficePage } from "../pages/OfficePage";
|
||||||
|
|
||||||
@@ -45,11 +41,7 @@ export function App() {
|
|||||||
chat: <ChatPage {...pageProps} />,
|
chat: <ChatPage {...pageProps} />,
|
||||||
files: <FilesPage {...pageProps} />,
|
files: <FilesPage {...pageProps} />,
|
||||||
docs: <DocumentsPage {...pageProps} />,
|
docs: <DocumentsPage {...pageProps} />,
|
||||||
knowledge: <KnowledgePage {...pageProps} />,
|
|
||||||
inbox: <InboxPage {...pageProps} />,
|
|
||||||
runs: <RunsPage {...pageProps} />,
|
runs: <RunsPage {...pageProps} />,
|
||||||
meetings: <MeetingsPage {...pageProps} />,
|
|
||||||
sprints: <SprintsPage {...pageProps} />,
|
|
||||||
approvals: <ApprovalsPage {...pageProps} />,
|
approvals: <ApprovalsPage {...pageProps} />,
|
||||||
social: <SocialPage {...pageProps} />,
|
social: <SocialPage {...pageProps} />,
|
||||||
office: <OfficePage {...pageProps} />,
|
office: <OfficePage {...pageProps} />,
|
||||||
|
|||||||
@@ -1,197 +0,0 @@
|
|||||||
import { useMemo, useState } from "react";
|
|
||||||
import { Avatar, Pill, Segmented } from "../shared/ui";
|
|
||||||
import { TopbarActions } from "../shared/TopbarActions";
|
|
||||||
import type { AgentRecord, ApprovalRecord, ChatMessageRecord, TaskRecord, TaskRunRecord } from "../../../shared/domain";
|
|
||||||
import { TASK_STATUS } from "../../../shared/domain";
|
|
||||||
import type { PageProps } from "./types";
|
|
||||||
import { activeOrgId, formatRelative } from "./types";
|
|
||||||
|
|
||||||
type InboxFilter = "all" | "approvals" | "failures" | "blocked" | "messages";
|
|
||||||
const FILTERS: Array<{ id: InboxFilter; label: string }> = [
|
|
||||||
{ id: "all", label: "All" },
|
|
||||||
{ id: "approvals", label: "Approvals" },
|
|
||||||
{ id: "failures", label: "Failures" },
|
|
||||||
{ id: "blocked", label: "Blocked" },
|
|
||||||
{ id: "messages", label: "Messages" },
|
|
||||||
];
|
|
||||||
|
|
||||||
type View = "inbox" | "standup" | "performance";
|
|
||||||
const VIEWS: Array<{ id: View; label: string }> = [
|
|
||||||
{ id: "inbox", label: "Inbox" },
|
|
||||||
{ id: "standup", label: "Standup" },
|
|
||||||
{ id: "performance", label: "Performance" },
|
|
||||||
];
|
|
||||||
|
|
||||||
interface InboxItem {
|
|
||||||
id: string;
|
|
||||||
kind: InboxFilter;
|
|
||||||
title: string;
|
|
||||||
detail: string;
|
|
||||||
agentName: string | null;
|
|
||||||
timestamp: string;
|
|
||||||
tone: "amber" | "red" | "violet" | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function InboxPage({ snapshot }: PageProps) {
|
|
||||||
const orgId = activeOrgId(snapshot);
|
|
||||||
const agents = useMemo(() => snapshot.agents.filter((a) => a.orgId === orgId), [snapshot.agents, orgId]);
|
|
||||||
const tasks = useMemo(() => snapshot.tasks.filter((t) => t.orgId === orgId), [snapshot.tasks, orgId]);
|
|
||||||
const taskRuns = useMemo(() => snapshot.taskRuns.filter((r) => r.orgId === orgId), [snapshot.taskRuns, orgId]);
|
|
||||||
const approvals = useMemo(() => snapshot.approvals.filter((a) => a.orgId === orgId), [snapshot.approvals, orgId]);
|
|
||||||
const messages = useMemo(() => snapshot.chatMessages.filter((m) => m.orgId === orgId), [snapshot.chatMessages, orgId]);
|
|
||||||
const [filter, setFilter] = useState<InboxFilter>("all");
|
|
||||||
const [view, setView] = useState<View>("inbox");
|
|
||||||
|
|
||||||
const items = useMemo(() => buildInboxItems(approvals, taskRuns, tasks, messages, agents), [approvals, taskRuns, tasks, messages, agents]);
|
|
||||||
const filtered = filter === "all" ? items : items.filter((item) => item.kind === filter);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="page">
|
|
||||||
<TopbarActions>
|
|
||||||
<Segmented value={view} onChange={setView} options={VIEWS} />
|
|
||||||
</TopbarActions>
|
|
||||||
<h1 className="page-h1">Inbox</h1>
|
|
||||||
<p className="page-sub">{items.length} {items.length === 1 ? "item" : "items"}</p>
|
|
||||||
|
|
||||||
{view === "inbox" && (
|
|
||||||
<>
|
|
||||||
<div style={{ marginBottom: 14 }}><Segmented value={filter} onChange={setFilter} options={FILTERS} /></div>
|
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
|
||||||
{filtered.map((item) => (
|
|
||||||
<div key={item.id} className="card" style={{ padding: 12 }}>
|
|
||||||
<div className="row" style={{ gap: 10 }}>
|
|
||||||
{item.agentName && <Avatar name={item.agentName} size="sm" bot />}
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<div style={{ fontSize: 14, fontWeight: 500, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{item.title}</div>
|
|
||||||
<div style={{ fontSize: 12, color: "var(--text-3)" }}>{item.detail}</div>
|
|
||||||
</div>
|
|
||||||
<Pill tone={item.tone}>{humanize(item.kind)}</Pill>
|
|
||||||
<span className="mono" style={{ fontSize: 12, color: "var(--text-3)", flexShrink: 0 }}>{formatRelative(item.timestamp)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{view === "standup" && <StandupView agents={agents} tasks={tasks} taskRuns={taskRuns} />}
|
|
||||||
{view === "performance" && <PerformanceView agents={agents} tasks={tasks} taskRuns={taskRuns} />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StandupView({ agents, tasks, taskRuns }: { agents: AgentRecord[]; tasks: TaskRecord[]; taskRuns: TaskRunRecord[] }) {
|
|
||||||
return (
|
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
|
||||||
{agents.map((agent) => {
|
|
||||||
const agentTasks = tasks.filter((t) => t.assigneeAgentId === agent.id);
|
|
||||||
const inProgress = agentTasks.filter((t) => t.status === TASK_STATUS.IN_PROGRESS).length;
|
|
||||||
const done = agentTasks.filter((t) => t.status === TASK_STATUS.DONE).length;
|
|
||||||
const agentRuns = taskRuns.filter((r) => r.agentId === agent.id);
|
|
||||||
const lastRun = agentRuns[agentRuns.length - 1];
|
|
||||||
return (
|
|
||||||
<div key={agent.id} className="card" style={{ padding: 14 }}>
|
|
||||||
<div className="row" style={{ gap: 10, marginBottom: 8 }}>
|
|
||||||
<Avatar name={agent.name} size="sm" bot />
|
|
||||||
<span style={{ fontWeight: 540 }}>{agent.name}</span>
|
|
||||||
<span style={{ fontSize: 12, color: "var(--text-3)" }}>{agent.role} · {agent.department}</span>
|
|
||||||
</div>
|
|
||||||
<div className="row" style={{ gap: 12, fontSize: 12 }}>
|
|
||||||
<span>{inProgress} in progress</span>
|
|
||||||
<span>{done} done</span>
|
|
||||||
<span>{agentRuns.length} runs</span>
|
|
||||||
{lastRun && <span style={{ color: "var(--text-3)" }}>{formatRelative(lastRun.createdAt)}</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PerformanceView({ agents, tasks, taskRuns }: { agents: AgentRecord[]; tasks: TaskRecord[]; taskRuns: TaskRunRecord[] }) {
|
|
||||||
const totalTasks = tasks.length;
|
|
||||||
const doneTasks = tasks.filter((t) => t.status === TASK_STATUS.DONE).length;
|
|
||||||
const completedRuns = taskRuns.filter((r) => r.state === "completed").length;
|
|
||||||
const failedRuns = taskRuns.filter((r) => r.state === "failed").length;
|
|
||||||
const successRate = completedRuns + failedRuns > 0 ? Math.round((completedRuns / (completedRuns + failedRuns)) * 100) : 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, minmax(0, 1fr))", gap: 12, marginBottom: 20 }}>
|
|
||||||
<MetricCard label="Tasks completed" value={String(doneTasks)} sub={`/ ${totalTasks}`} />
|
|
||||||
<MetricCard label="Success rate" value={`${successRate}%`} />
|
|
||||||
<MetricCard label="Total runs" value={String(taskRuns.length)} />
|
|
||||||
<MetricCard label="Active agents" value={String(agents.filter((a) => a.lifecycle === "active").length)} />
|
|
||||||
</div>
|
|
||||||
<div className="section-label" style={{ marginBottom: 10 }}>Agent throughput</div>
|
|
||||||
<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>Agent</div><div>Tasks done</div><div>Runs</div><div>Failures</div>
|
|
||||||
</div>
|
|
||||||
{agents.map((agent, i) => {
|
|
||||||
const agentDone = tasks.filter((t) => t.assigneeAgentId === agent.id && t.status === TASK_STATUS.DONE).length;
|
|
||||||
const agentRuns = taskRuns.filter((r) => r.agentId === agent.id);
|
|
||||||
const agentFailed = agentRuns.filter((r) => r.state === "failed").length;
|
|
||||||
return (
|
|
||||||
<div key={agent.id} className="table-row" style={{ gridTemplateColumns: "minmax(0, 2fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr)", borderBottom: i === agents.length - 1 ? "none" : "1px solid var(--border-soft)" }}>
|
|
||||||
<div className="row" style={{ gap: 8 }}>
|
|
||||||
<Avatar name={agent.name} size="sm" bot />
|
|
||||||
<span style={{ fontWeight: 480 }}>{agent.name}</span>
|
|
||||||
</div>
|
|
||||||
<div className="num mono">{agentDone}</div>
|
|
||||||
<div className="num mono">{agentRuns.length}</div>
|
|
||||||
<div>{agentFailed > 0 ? <Pill tone="red">{agentFailed}</Pill> : <span className="num mono" style={{ color: "var(--text-3)" }}>0</span>}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MetricCard({ label, value, sub }: { label: string; value: string; sub?: string }) {
|
|
||||||
return (
|
|
||||||
<div className="card" style={{ padding: 14 }}>
|
|
||||||
<div className="card-label" style={{ marginBottom: 6 }}>{label}</div>
|
|
||||||
<div className="row" style={{ alignItems: "baseline", gap: 4 }}>
|
|
||||||
<span className="num" style={{ fontSize: 18, fontWeight: 600 }}>{value}</span>
|
|
||||||
{sub && <span style={{ fontSize: 12, color: "var(--text-3)" }}>{sub}</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildInboxItems(
|
|
||||||
approvals: ApprovalRecord[],
|
|
||||||
taskRuns: TaskRunRecord[],
|
|
||||||
tasks: TaskRecord[],
|
|
||||||
messages: ChatMessageRecord[],
|
|
||||||
agents: AgentRecord[],
|
|
||||||
): InboxItem[] {
|
|
||||||
const items: InboxItem[] = [];
|
|
||||||
for (const a of approvals.filter((a) => a.state === "pending")) {
|
|
||||||
items.push({ id: `app-${a.id}`, kind: "approvals", title: a.title, detail: a.kind.replace(/_/g, " "), agentName: null, timestamp: a.createdAt, tone: "amber" });
|
|
||||||
}
|
|
||||||
for (const r of taskRuns.filter((r) => r.state === "failed")) {
|
|
||||||
const task = tasks.find((t) => t.id === r.taskId);
|
|
||||||
const agent = agents.find((a) => a.id === r.agentId);
|
|
||||||
items.push({ id: `fail-${r.id}`, kind: "failures", title: task?.title ?? r.taskId, detail: r.errorMessage ?? "Run failed", agentName: agent?.name ?? null, timestamp: r.completedAt ?? r.createdAt, tone: "red" });
|
|
||||||
}
|
|
||||||
for (const t of tasks.filter((t) => t.status === TASK_STATUS.REVIEW)) {
|
|
||||||
const agent = agents.find((a) => a.id === t.assigneeAgentId);
|
|
||||||
items.push({ id: `blk-${t.id}`, kind: "blocked", title: t.title, detail: "Awaiting review", agentName: agent?.name ?? null, timestamp: t.updatedAt, tone: "violet" });
|
|
||||||
}
|
|
||||||
const recent = messages.filter((m) => m.senderType === "agent").slice(-10);
|
|
||||||
for (const m of recent) {
|
|
||||||
const agent = agents.find((a) => a.id === m.senderId);
|
|
||||||
const preview = m.bodyMd.length > 60 ? `${m.bodyMd.slice(0, 57)}...` : m.bodyMd;
|
|
||||||
items.push({ id: `msg-${m.id}`, kind: "messages", title: agent?.name ?? "Agent", detail: preview, agentName: agent?.name ?? null, timestamp: m.createdAt, tone: undefined });
|
|
||||||
}
|
|
||||||
items.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
function humanize(value: string): string {
|
|
||||||
return value.charAt(0).toUpperCase() + value.slice(1);
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
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={16} /> 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: 14, color: "var(--text-2)", lineHeight: 1.5, whiteSpace: "pre-wrap", maxHeight: 120, overflow: "hidden" }}>
|
|
||||||
{entry.content}
|
|
||||||
</div>
|
|
||||||
<div style={{ marginTop: 8, fontSize: 12, 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;
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
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 } from "./types";
|
|
||||||
|
|
||||||
const MEETING_TYPES = ["standup", "all_hands", "sprint_review", "retrospective", "planning", "one_on_one", "ad_hoc"] as const;
|
|
||||||
|
|
||||||
function text(form: FormData, key: string): string {
|
|
||||||
return String(form.get(key) ?? "").trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MeetingsPage({ snapshot, actions }: PageProps) {
|
|
||||||
const orgId = activeOrgId(snapshot);
|
|
||||||
const meetings = useMemo(() => snapshot.meetings.filter((m) => m.orgId === orgId), [snapshot.meetings, orgId]);
|
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
|
||||||
|
|
||||||
const create = (event: FormEvent<HTMLFormElement>) => {
|
|
||||||
event.preventDefault();
|
|
||||||
const form = new FormData(event.currentTarget);
|
|
||||||
const scheduledAt = text(form, "scheduledAt");
|
|
||||||
void actions.run(() => window.agentCompany.createMeeting({
|
|
||||||
orgId,
|
|
||||||
title: text(form, "title"),
|
|
||||||
meetingType: text(form, "meetingType"),
|
|
||||||
scheduledAt: scheduledAt || null,
|
|
||||||
durationMinutes: Number(text(form, "durationMinutes")) || 30,
|
|
||||||
agenda: text(form, "agenda"),
|
|
||||||
})).then((r) => { if (r) setCreateOpen(false); });
|
|
||||||
};
|
|
||||||
|
|
||||||
const remove = (meetingId: string) => {
|
|
||||||
void actions.run(() => window.agentCompany.deleteMeeting({ orgId, meetingId }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const scheduled = meetings.filter((m) => m.status === "scheduled");
|
|
||||||
const completed = meetings.filter((m) => m.status !== "scheduled");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="page">
|
|
||||||
<TopbarActions>
|
|
||||||
<Button tone="primary" onClick={() => setCreateOpen(true)}><Icon name="plus" size={16} /> Schedule</Button>
|
|
||||||
</TopbarActions>
|
|
||||||
<h1 className="page-h1">Meetings</h1>
|
|
||||||
<p className="page-sub">{scheduled.length} upcoming · {completed.length} past</p>
|
|
||||||
|
|
||||||
{scheduled.length > 0 && <div className="section-label" style={{ marginBottom: 10 }}>Upcoming</div>}
|
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 10, marginBottom: 24 }}>
|
|
||||||
{scheduled.map((m) => (
|
|
||||||
<MeetingCard key={m.id} meeting={m} onDelete={() => remove(m.id)} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{completed.length > 0 && (
|
|
||||||
<>
|
|
||||||
<div className="section-label" style={{ marginBottom: 10 }}>Past</div>
|
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
|
||||||
{completed.map((m) => (
|
|
||||||
<MeetingCard key={m.id} meeting={m} onDelete={() => remove(m.id)} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Dialog title="Schedule meeting" open={createOpen} submitLabel="Schedule" onClose={() => setCreateOpen(false)} onSubmit={create}>
|
|
||||||
<Field label="Title"><input className="input" name="title" required /></Field>
|
|
||||||
<Field label="Type">
|
|
||||||
<select className="input" name="meetingType" defaultValue="standup">
|
|
||||||
{MEETING_TYPES.map((t) => <option key={t} value={t}>{t.replace(/_/g, " ")}</option>)}
|
|
||||||
</select>
|
|
||||||
</Field>
|
|
||||||
<Field label="Scheduled at"><input className="input" name="scheduledAt" type="datetime-local" /></Field>
|
|
||||||
<Field label="Duration (min)"><input className="input" name="durationMinutes" type="number" defaultValue="30" min="5" /></Field>
|
|
||||||
<Field label="Agenda"><textarea className="input" name="agenda" rows={3} /></Field>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MeetingCard({ meeting, onDelete }: { meeting: { id: string; title: string; meetingType: string; status: string; scheduledAt: string | null; durationMinutes: number; agenda: string; createdAt: string }; onDelete: () => void }) {
|
|
||||||
return (
|
|
||||||
<div className="card" style={{ padding: 14 }}>
|
|
||||||
<div className="row" style={{ marginBottom: 6 }}>
|
|
||||||
<span style={{ fontWeight: 540, flex: 1 }}>{meeting.title}</span>
|
|
||||||
<IconButton icon="x" label="Delete" onClick={onDelete} />
|
|
||||||
</div>
|
|
||||||
<div className="row" style={{ gap: 6, marginBottom: 8 }}>
|
|
||||||
<Pill>{meeting.meetingType.replace(/_/g, " ")}</Pill>
|
|
||||||
<Pill tone={meeting.status === "scheduled" ? "amber" : undefined}>{meeting.status}</Pill>
|
|
||||||
<Pill>{meeting.durationMinutes}m</Pill>
|
|
||||||
</div>
|
|
||||||
{meeting.scheduledAt && <div style={{ fontSize: 12, color: "var(--text-3)" }}>{new Date(meeting.scheduledAt).toLocaleString()}</div>}
|
|
||||||
{meeting.agenda && <div style={{ fontSize: 14, color: "var(--text-2)", marginTop: 6, whiteSpace: "pre-wrap", lineHeight: 1.5 }}>{meeting.agenda}</div>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
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 { SprintRecord, TaskRecord } from "../../../shared/domain";
|
|
||||||
import { TASK_STATUS } from "../../../shared/domain";
|
|
||||||
import type { PageProps } from "./types";
|
|
||||||
import { activeOrgId } from "./types";
|
|
||||||
|
|
||||||
function text(form: FormData, key: string): string {
|
|
||||||
return String(form.get(key) ?? "").trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SprintsPage({ snapshot, actions }: PageProps) {
|
|
||||||
const orgId = activeOrgId(snapshot);
|
|
||||||
const sprints = useMemo(() => snapshot.sprints.filter((s) => s.orgId === orgId), [snapshot.sprints, orgId]);
|
|
||||||
const tasks = useMemo(() => snapshot.tasks.filter((t) => t.orgId === orgId), [snapshot.tasks, orgId]);
|
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
|
||||||
|
|
||||||
const create = (event: FormEvent<HTMLFormElement>) => {
|
|
||||||
event.preventDefault();
|
|
||||||
const form = new FormData(event.currentTarget);
|
|
||||||
void actions.run(() => window.agentCompany.createSprint({
|
|
||||||
orgId,
|
|
||||||
name: text(form, "name"),
|
|
||||||
goal: text(form, "goal"),
|
|
||||||
startDate: text(form, "startDate"),
|
|
||||||
endDate: text(form, "endDate"),
|
|
||||||
})).then((r) => { if (r) setCreateOpen(false); });
|
|
||||||
};
|
|
||||||
|
|
||||||
const remove = (sprintId: string) => {
|
|
||||||
void actions.run(() => window.agentCompany.deleteSprint({ orgId, sprintId }));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="page">
|
|
||||||
<TopbarActions>
|
|
||||||
<Button tone="primary" onClick={() => setCreateOpen(true)}><Icon name="plus" size={16} /> New sprint</Button>
|
|
||||||
</TopbarActions>
|
|
||||||
<h1 className="page-h1">Sprints</h1>
|
|
||||||
<p className="page-sub">{sprints.length} {sprints.length === 1 ? "sprint" : "sprints"}</p>
|
|
||||||
|
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(300px, 1fr))", gap: 12 }}>
|
|
||||||
{sprints.map((sprint) => (
|
|
||||||
<SprintCard key={sprint.id} sprint={sprint} tasks={tasks} onDelete={() => remove(sprint.id)} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Dialog title="New sprint" open={createOpen} submitLabel="Create" onClose={() => setCreateOpen(false)} onSubmit={create}>
|
|
||||||
<Field label="Name"><input className="input" name="name" required /></Field>
|
|
||||||
<Field label="Goal"><textarea className="input" name="goal" rows={2} required /></Field>
|
|
||||||
<Field label="Start date"><input className="input" name="startDate" type="date" required /></Field>
|
|
||||||
<Field label="End date"><input className="input" name="endDate" type="date" required /></Field>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SprintCard({ sprint, tasks, onDelete }: { sprint: SprintRecord; tasks: TaskRecord[]; onDelete: () => void }) {
|
|
||||||
const start = new Date(sprint.startDate);
|
|
||||||
const end = new Date(sprint.endDate);
|
|
||||||
const now = new Date();
|
|
||||||
const totalDays = Math.max(1, Math.ceil((end.getTime() - start.getTime()) / 86400000));
|
|
||||||
const elapsed = Math.max(0, Math.ceil((now.getTime() - start.getTime()) / 86400000));
|
|
||||||
const progressPct = Math.min(100, Math.round((elapsed / totalDays) * 100));
|
|
||||||
const done = tasks.filter((t) => t.status === TASK_STATUS.DONE).length;
|
|
||||||
const total = tasks.length;
|
|
||||||
const taskPct = total > 0 ? Math.round((done / total) * 100) : 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<article className="card" style={{ padding: 16 }}>
|
|
||||||
<div className="row" style={{ marginBottom: 8 }}>
|
|
||||||
<span style={{ fontWeight: 560, flex: 1 }}>{sprint.name}</span>
|
|
||||||
<IconButton icon="x" label="Delete" onClick={onDelete} />
|
|
||||||
</div>
|
|
||||||
<div className="row" style={{ gap: 6, marginBottom: 10 }}>
|
|
||||||
<Pill tone={sprintStatusTone(sprint.status)}>{sprint.status}</Pill>
|
|
||||||
<Pill>{sprint.startDate} to {sprint.endDate}</Pill>
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 14, color: "var(--text-2)", lineHeight: 1.5, marginBottom: 12 }}>{sprint.goal}</div>
|
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
|
||||||
<ProgressBar label="Time" pct={progressPct} />
|
|
||||||
<ProgressBar label="Tasks" pct={taskPct} />
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProgressBar({ label, pct }: { label: string; pct: number }) {
|
|
||||||
return (
|
|
||||||
<div className="row" style={{ gap: 8, fontSize: 12 }}>
|
|
||||||
<span style={{ width: 40, color: "var(--text-3)" }}>{label}</span>
|
|
||||||
<div style={{ flex: 1, height: 3, background: "var(--panel-3)", borderRadius: 2, overflow: "hidden" }}>
|
|
||||||
<div style={{ width: `${pct}%`, height: "100%", background: "var(--text-1)" }} />
|
|
||||||
</div>
|
|
||||||
<span className="num" style={{ color: "var(--text-3)", width: 30, textAlign: "right" }}>{pct}%</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function sprintStatusTone(status: string): "amber" | "green" | "violet" | undefined {
|
|
||||||
if (status === "planned") return undefined;
|
|
||||||
if (status === "active") return "amber";
|
|
||||||
if (status === "completed") return "green";
|
|
||||||
return "violet";
|
|
||||||
}
|
|
||||||
@@ -13,11 +13,7 @@ export type RouteId =
|
|||||||
| "chat"
|
| "chat"
|
||||||
| "files"
|
| "files"
|
||||||
| "docs"
|
| "docs"
|
||||||
| "knowledge"
|
|
||||||
| "inbox"
|
|
||||||
| "runs"
|
| "runs"
|
||||||
| "meetings"
|
|
||||||
| "sprints"
|
|
||||||
| "approvals"
|
| "approvals"
|
||||||
| "social"
|
| "social"
|
||||||
| "office"
|
| "office"
|
||||||
@@ -46,11 +42,7 @@ const primaryNav: NavSpec[] = [
|
|||||||
|
|
||||||
/** Less-frequent surfaces, demoted under a divider so the primary set leads. */
|
/** Less-frequent surfaces, demoted under a divider so the primary set leads. */
|
||||||
const secondaryNav: NavSpec[] = [
|
const secondaryNav: NavSpec[] = [
|
||||||
{ id: "inbox", label: "Inbox", icon: "inbox" },
|
|
||||||
{ id: "docs", label: "Documents", icon: "file" },
|
{ id: "docs", label: "Documents", icon: "file" },
|
||||||
{ id: "knowledge", label: "Knowledge", icon: "sparkle" },
|
|
||||||
{ id: "meetings", label: "Meetings", icon: "calendar" },
|
|
||||||
{ id: "sprints", label: "Sprints", icon: "target" },
|
|
||||||
{ id: "social", label: "Social", icon: "social" },
|
{ id: "social", label: "Social", icon: "social" },
|
||||||
{ id: "office", label: "Office", icon: "team" },
|
{ id: "office", label: "Office", icon: "team" },
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in new issue
Block a user