Refocus the Home dashboard and make metric tiles interactive

The home page was 13 cards deep with empty roll-ups (Today, In progress,
Team duplicated other pages) and a stark full-black goal card that fought
the rest of the surface. Now:
- Three intent cards lead — Quarter goal (light, integrated), Monthly
  budget, and Waiting-on-you with a real "all caught up" empty state.
- A four-up metric strip (workload, run activity, success rate, token
  usage) replaces the redundant budget chart. Each tile lifts on hover
  and its header shows a live readout of the hovered datum.
- Activity (agent runs, recent messages) closes the page.
No data wiring or approval logic changed; HomePage drops 500 → 344 lines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 4.8 committed 2026-06-10 19:51:25 -04:00
1 parent ea9f27916e
commit 8b6b8ba4b7
3 files changed
+270 -414

No files matched your search

@@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
import { useMemo, useState, type CSSProperties } from "react";
import { Avatar, Button, Icon, Pill } from "../shared/ui";
import { TopbarActions } from "../shared/TopbarActions";
import type {
@@ -12,10 +12,14 @@ import type {
TaskRunRecord,
} from "../../../shared/domain";
import { NewTaskDialog } from "./NewTaskDialog";
import { AgentWorkloadChart, BudgetUtilizationChart, CostTrendChart, RunActivityChart, SuccessRateChart } from "./home/DashboardCharts";
import { AgentWorkloadChart, CostTrendChart, RunActivityChart, SuccessRateChart } from "./home/DashboardCharts";
import type { PageProps } from "./types";
import { activeOrgId, formatRelative, formatTime, money, requireActiveEmployee, requireActiveOrganization } from "./types";
const STRIP: CSSProperties = { display: "grid", gridTemplateColumns: "repeat(3, minmax(0, 1fr))", gap: 16, marginBottom: 16 };
const METRICS: CSSProperties = { display: "grid", gridTemplateColumns: "repeat(4, minmax(0, 1fr))", gap: 16, marginBottom: 16 };
const ACTIVITY: CSSProperties = { display: "grid", gridTemplateColumns: "repeat(2, minmax(0, 1fr))", gap: 16 };
export function HomePage({ snapshot, actions, navigate }: PageProps) {
const orgId = activeOrgId(snapshot);
const organization = requireActiveOrganization(snapshot);
@@ -24,15 +28,9 @@ export function HomePage({ snapshot, actions, navigate }: PageProps) {
const agents = useMemo(() => snapshot.agents.filter((agent) => agent.orgId === orgId), [snapshot.agents, orgId]);
const approvals = useMemo(() => snapshot.approvals.filter((item) => item.orgId === orgId), [snapshot.approvals, orgId]);
const goals = useMemo(() => snapshot.goals.filter((item) => item.orgId === orgId), [snapshot.goals, orgId]);
const providers = useMemo(
() => snapshot.providers.filter((provider) => provider.orgId === orgId),
[snapshot.providers, orgId],
);
const providers = useMemo(() => snapshot.providers.filter((provider) => provider.orgId === orgId), [snapshot.providers, orgId]);
const budgetCapCents = useMemo(
() =>
snapshot.hiringPolicies
.filter((policy) => policy.orgId === orgId)
.reduce((sum, policy) => sum + policy.monthlyBudgetLimitCents, 0),
() => snapshot.hiringPolicies.filter((policy) => policy.orgId === orgId).reduce((sum, policy) => sum + policy.monthlyBudgetLimitCents, 0),
[snapshot.hiringPolicies, orgId],
);
const taskRuns = useMemo(() => snapshot.taskRuns.filter((run) => run.orgId === orgId), [snapshot.taskRuns, orgId]);
@@ -42,57 +40,41 @@ export function HomePage({ snapshot, actions, navigate }: PageProps) {
const [newTaskOpen, setNewTaskOpen] = useState(false);
const pendingApprovals = approvals.filter((approval) => approval.state === "pending");
const inProgressTasks = tasks.filter((task) => task.status === "in_progress");
const today = new Date();
const agendaTasks = collectAgendaTasks(tasks, today);
const totalBudgetCents = agents.reduce((sum, agent) => sum + agent.budgetCents, 0);
const primaryGoal = goals.find((goal) => goal.status === "active") ?? goals[0] ?? null;
const goalProgress = computeGoalProgress(primaryGoal, tasks);
const firstName = employee.displayName.split(/\s+/)[0] ?? employee.displayName;
const greeting = greetingFor(today.getHours());
const summaryText = renderSummary(pendingApprovals.length, agents.length);
const greeting = greetingFor(new Date().getHours());
return (
<div className="page">
<div className="page-wide" style={{ maxWidth: 1240 }}>
<TopbarActions>
<Button tone="ghost" onClick={() => void actions.reload()} title="Refresh"><Icon name="refresh" size={13} /></Button>
<Button tone="primary" onClick={() => setNewTaskOpen(true)}><Icon name="plus" size={13} /> New task</Button>
<Button tone="ghost" onClick={() => void actions.reload()} title="Refresh"><Icon name="refresh" size={16} /></Button>
<Button tone="primary" onClick={() => setNewTaskOpen(true)}><Icon name="plus" size={16} /> New task</Button>
</TopbarActions>
<h1 className="page-h1">{greeting}, {firstName}</h1>
<p className="page-sub">{summaryText}</p>
<p className="page-sub">{renderSummary(pendingApprovals.length, agents.length)}</p>
<div style={{ display: "grid", gridTemplateColumns: "minmax(0, 1.4fr) minmax(0, 1fr) minmax(0, 1fr)", gap: 12, marginBottom: 12 }}>
<TodayCard date={today} tasks={agendaTasks} agents={agents} employee={employee} approvals={pendingApprovals} />
<div style={STRIP}>
<GoalCard goal={primaryGoal} progress={goalProgress} organizationName={organization.name} />
<SpendCard providers={providers} totalBudgetCents={totalBudgetCents} capCents={budgetCapCents} agents={agents} />
<WaitingCard
approvals={pendingApprovals}
agents={agents}
employees={[employee]}
onApprove={(approvalId) => void actions.run(() => window.agentCompany.decideApproval({
orgId,
approvalId,
state: "approved",
decidedByEmployeeId: employee.id,
}))}
onApprove={(approvalId) => void actions.run(() => window.agentCompany.decideApproval({ orgId, approvalId, state: "approved", decidedByEmployeeId: employee.id }))}
onOpen={() => navigate("approvals")}
/>
<SpendCard providers={providers} totalBudgetCents={totalBudgetCents} capCents={budgetCapCents} agents={agents} />
</div>
<div style={{ display: "grid", gridTemplateColumns: "minmax(0, 1.4fr) minmax(0, 1fr) minmax(0, 1fr)", gap: 12 }}>
<InProgressCard tasks={inProgressTasks} agents={agents} onViewAll={() => navigate("work")} />
<TeamCard agents={agents} employee={employee} />
<GoalCard goal={primaryGoal} progress={goalProgress} organizationName={organization.name} />
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(5, minmax(0, 1fr))", gap: 12, marginTop: 12 }}>
<div style={METRICS}>
<AgentWorkloadChart agents={agents} tasks={tasks} />
<BudgetUtilizationChart agents={agents} providers={providers} />
<RunActivityChart taskRuns={taskRuns} />
<SuccessRateChart taskRuns={taskRuns} />
<CostTrendChart providerRequests={providerRequests} />
</div>
<div style={{ display: "grid", gridTemplateColumns: "minmax(0, 1fr) minmax(0, 1fr)", gap: 12, marginTop: 12 }}>
<div style={ACTIVITY}>
<RunActivityCard taskRuns={taskRuns} tasks={tasks} agents={agents} onViewAll={() => navigate("runs")} />
<RecentMessagesCard messages={chatMessages} agents={agents} employees={[employee]} spaces={chatSpaces} onViewAll={() => navigate("chat")} />
</div>
@@ -102,86 +84,28 @@ export function HomePage({ snapshot, actions, navigate }: PageProps) {
);
}
function TodayCard({
date,
tasks,
agents,
employee,
approvals,
function GoalCard({
goal,
progress,
organizationName,
}: {
date: Date;
tasks: TaskRecord[];
agents: AgentRecord[];
employee: EmployeeRecord;
approvals: ApprovalRecord[];
goal: GoalRecord | null;
progress: { percent: number; status: "ahead" | "on_track" | "behind" } | null;
organizationName: string;
}) {
const formatted = date.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" });
const rows = tasks.slice(0, 4);
const percent = progress?.percent ?? 0;
return (
<section className="card" style={{ padding: 18 }}>
<div className="row" style={{ marginBottom: 14 }}>
<span className="card-label">Today</span>
<span style={{ marginLeft: "auto", fontSize: 11, color: "var(--text-3)" }}>{formatted}</span>
<section className="card">
<div className="card-label" style={{ marginBottom: 12 }}>Quarter goal</div>
<div style={{ fontSize: 16, fontWeight: 540, lineHeight: 1.4, letterSpacing: "-0.01em", display: "-webkit-box", WebkitLineClamp: 2, WebkitBoxOrient: "vertical", overflow: "hidden", minHeight: 44 }}>
{goal ? goal.title : `Set the first goal for ${organizationName}.`}
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
{rows.map((task) => {
const owner = agents.find((agent) => agent.id === task.assigneeAgentId);
const ownerName = owner?.name ?? employee.displayName;
const time = agendaTime(task, date);
const requiresApproval = approvals.some(
(approval) => approval.sourceEntityType === "task" && approval.sourceEntityId === task.id,
);
return (
<div key={task.id} className="row" style={{ gap: 12 }}>
<span className="num mono" style={{ width: 36, fontSize: 12, color: "var(--text-3)" }}>{time}</span>
<div style={{ flex: 1, minWidth: 0, fontSize: 13 }}>
<span style={{ fontWeight: 500 }}>{task.title}</span>
<span style={{ color: "var(--text-3)", marginLeft: 6 }}>· {ownerName}</span>
</div>
{requiresApproval && <Pill tone="amber">You</Pill>}
</div>
);
})}
<div className="row" style={{ alignItems: "baseline", gap: 8, marginTop: 16 }}>
<span className="num" style={{ fontSize: 18, fontWeight: 600 }}>{progress ? `${percent}%` : "—"}</span>
<span style={{ fontSize: 12, color: "var(--text-3)" }}>{progress ? statusLabel(progress.status) : "no work yet"}</span>
</div>
</section>
);
}
function WaitingCard({
approvals,
agents,
employees,
onApprove,
onOpen,
}: {
approvals: ApprovalRecord[];
agents: AgentRecord[];
employees: EmployeeRecord[];
onApprove: (approvalId: string) => void;
onOpen: () => void;
}) {
return (
<section className="card" style={{ padding: 18 }}>
<div className="row" style={{ marginBottom: 14 }}>
<span className="card-label">Waiting on you</span>
{approvals.length > 0 && <span style={{ marginLeft: "auto" }}><Pill tone="amber">{approvals.length}</Pill></span>}
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
{approvals.slice(0, 2).map((approval) => {
const from = resolveRequester(approval, employees, agents);
return (
<div key={approval.id} style={{ padding: 12, border: "1px solid var(--border)", borderRadius: 8 }}>
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 2 }}>{approval.title}</div>
<div style={{ fontSize: 12, color: "var(--text-3)", marginBottom: 10 }}>
{humanizeLabel(approval.kind)} · {humanizeLabel(approval.risk)} risk · from {from}
</div>
<div className="row" style={{ gap: 6 }}>
<Button tone="primary" size="small" onClick={() => onApprove(approval.id)}>Approve</Button>
<Button size="small" onClick={onOpen}>Open</Button>
</div>
</div>
);
})}
<div style={{ marginTop: 12, height: 4, background: "var(--panel-3)", borderRadius: 999, overflow: "hidden" }}>
<div style={{ width: `${percent}%`, height: "100%", background: "var(--text-1)", transition: "width var(--motion-slow) var(--ease-out)" }} />
</div>
</section>
);
@@ -201,143 +125,159 @@ function SpendCard({
const connected = providers.filter((provider) => provider.enabled);
const fillPct = capCents > 0 ? Math.min(100, Math.round((totalBudgetCents / capCents) * 100)) : 0;
return (
<section className="card" style={{ padding: 18 }}>
<div className="card-label" style={{ marginBottom: 14 }}>Monthly budget</div>
<section className="card">
<div className="card-label" style={{ marginBottom: 12 }}>Monthly budget</div>
<div className="row" style={{ alignItems: "baseline", gap: 6 }}>
<span className="num" style={{ fontSize: 28, fontWeight: 600, letterSpacing: "-0.02em" }}>{money(totalBudgetCents)}</span>
<span className="num" style={{ fontSize: 18, fontWeight: 600 }}>{money(totalBudgetCents)}</span>
<span style={{ fontSize: 12, color: "var(--text-3)" }}>{capCents > 0 ? `/ ${money(capCents)}` : "allocated"}</span>
</div>
<div style={{ marginTop: 14, height: 4, background: "var(--panel-3)", borderRadius: 2, overflow: "hidden" }}>
<div style={{ marginTop: 12, height: 4, background: "var(--panel-3)", borderRadius: 999, overflow: "hidden" }}>
<div style={{ width: `${fillPct}%`, height: "100%", background: "var(--text-1)" }} />
</div>
<div style={{ marginTop: 14, display: "flex", flexDirection: "column", gap: 8 }}>
<div style={{ marginTop: 12, display: "flex", flexDirection: "column", gap: 8 }}>
{connected.slice(0, 3).map((provider) => {
const allocatedCents = agents
.filter((agent) => agent.modelProviderId === provider.id)
.reduce((sum, agent) => sum + agent.budgetCents, 0);
const allocatedCents = agents.filter((agent) => agent.modelProviderId === provider.id).reduce((sum, agent) => sum + agent.budgetCents, 0);
return (
<div key={provider.id} className="row" style={{ fontSize: 12 }}>
<span style={{ color: "var(--text-2)" }}>{provider.label}</span>
<span className="num mono" style={{ marginLeft: "auto", color: "var(--text-1)", fontWeight: 500 }}>
{money(allocatedCents)}
</span>
<div key={provider.id} className="row" style={{ fontSize: 14 }}>
<span style={{ color: "var(--text-2)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{provider.label}</span>
<span className="num mono" style={{ marginLeft: "auto", color: "var(--text-1)", fontWeight: 500 }}>{money(allocatedCents)}</span>
</div>
);
})}
{connected.length === 0 && <span style={{ fontSize: 14, color: "var(--text-3)" }}>No providers connected</span>}
</div>
</section>
);
}
function InProgressCard({ tasks, agents, onViewAll }: { tasks: TaskRecord[]; agents: AgentRecord[]; onViewAll: () => void }) {
const rows = tasks.slice(0, 4);
return (
<section className="card" style={{ padding: 18 }}>
<div className="row" style={{ marginBottom: 14 }}>
<span className="card-label">In progress</span>
<button
type="button"
className="link-button"
style={{ marginLeft: "auto", fontSize: 11 }}
onClick={onViewAll}
>
All work →
</button>
</div>
{rows.map((task, index) => {
const owner = agents.find((agent) => agent.id === task.assigneeAgentId);
const last = index === rows.length - 1;
return (
<div key={task.id} className="row" style={{ gap: 10, padding: "10px 0", borderBottom: last ? "none" : "1px solid var(--border-soft)" }}>
<span style={{ width: 5, height: 5, borderRadius: "50%", background: "var(--text-4)", flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0, fontSize: 13, fontWeight: 480, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{task.title}</div>
{owner && <Pill>{owner.department}</Pill>}
{owner && <Avatar name={owner.name} size="sm" bot />}
</div>
);
})}
</section>
);
}
function TeamCard({ agents, employee }: { agents: AgentRecord[]; employee: EmployeeRecord }) {
return (
<section className="card" style={{ padding: 18 }}>
<div className="card-label" style={{ marginBottom: 14 }}>Team</div>
{agents.slice(0, 4).map((agent) => (
<div key={agent.id} className="row" style={{ gap: 10, padding: "7px 0" }}>
<Avatar name={agent.name} size="sm" bot />
<span style={{ flex: 1, fontSize: 13, fontWeight: 500, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{agent.name}</span>
<span style={{ fontSize: 11, color: "var(--text-3)" }}>{agent.role}</span>
</div>
))}
<div className="row" style={{ gap: 10, padding: "7px 0" }}>
<Avatar name={employee.displayName} size="sm" />
<span style={{ flex: 1, fontSize: 13, fontWeight: 500 }}>{employee.displayName}</span>
<span style={{ fontSize: 11, color: "var(--text-3)" }}>{employee.role} · human</span>
</div>
</section>
);
}
function GoalCard({
goal,
progress,
organizationName,
function WaitingCard({
approvals,
agents,
employees,
onApprove,
onOpen,
}: {
goal: GoalRecord | null;
progress: { percent: number; status: "ahead" | "on_track" | "behind" } | null;
organizationName: string;
approvals: ApprovalRecord[];
agents: AgentRecord[];
employees: EmployeeRecord[];
onApprove: (approvalId: string) => void;
onOpen: () => void;
}) {
return (
<section
className="card"
style={{
padding: 18,
background: "linear-gradient(180deg, #0a0a0b 0%, #18181b 100%)",
color: "#fff",
border: "1px solid #18181b",
}}
>
<div style={{ fontSize: 11, color: "rgba(255,255,255,0.5)", fontWeight: 540, letterSpacing: "0.02em", marginBottom: 12 }}>
Quarter goal
<section className="card">
<div className="row" style={{ marginBottom: 12 }}>
<span className="card-label">Waiting on you</span>
{approvals.length > 0 && <span style={{ marginLeft: "auto" }}><Pill tone="amber">{approvals.length}</Pill></span>}
</div>
<div style={{ fontSize: 15, fontWeight: 540, lineHeight: 1.35, marginBottom: 18, letterSpacing: "-0.01em" }}>
{goal ? goal.title : `Set the first goal for ${organizationName}.`}
{approvals.length === 0 ? (
<div className="row" style={{ gap: 8, color: "var(--text-3)", fontSize: 14 }}>
<Icon name="check" size={16} /><span>You're all caught up</span>
</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
{approvals.slice(0, 2).map((approval) => (
<div key={approval.id}>
<div style={{ fontSize: 14, fontWeight: 500, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{approval.title}</div>
<div style={{ fontSize: 12, color: "var(--text-3)", margin: "2px 0 8px" }}>
{humanizeLabel(approval.kind)} · {humanizeLabel(approval.risk)} risk · from {resolveRequester(approval, employees, agents)}
</div>
<div className="row" style={{ gap: 8 }}>
<Button tone="primary" size="small" onClick={() => onApprove(approval.id)}>Approve</Button>
<Button size="small" onClick={onOpen}>Open</Button>
</div>
</div>
))}
</div>
)}
</section>
);
}
function RunActivityCard({
taskRuns,
tasks,
agents,
onViewAll,
}: {
taskRuns: TaskRunRecord[];
tasks: TaskRecord[];
agents: AgentRecord[];
onViewAll: () => void;
}) {
const recent = taskRuns.slice(0, 6);
return (
<section className="card">
<div className="row" style={{ marginBottom: 12 }}>
<span className="card-label">Agent runs</span>
<button type="button" className="link-button" style={{ marginLeft: "auto", fontSize: 12 }} onClick={onViewAll}>All runs →</button>
</div>
<div className="row" style={{ alignItems: "baseline", gap: 8 }}>
<span className="num" style={{ fontSize: 24, fontWeight: 600 }}>{progress ? `${progress.percent}%` : "—"}</span>
<span style={{ fontSize: 12, color: "rgba(255,255,255,0.5)" }}>
{progress ? statusLabel(progress.status) : "no work yet"}
</span>
</div>
<div style={{ marginTop: 10, height: 3, background: "rgba(255,255,255,0.12)", borderRadius: 2, overflow: "hidden" }}>
<div style={{ width: `${progress?.percent ?? 0}%`, height: "100%", background: "#fff" }} />
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
{recent.map((run) => {
const task = tasks.find((t) => t.id === run.taskId);
const agent = agents.find((a) => a.id === run.agentId);
return (
<div key={run.id} className="row" style={{ gap: 10 }}>
{agent && <Avatar name={agent.name} size="sm" bot />}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 14, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{task?.title ?? run.taskId}</div>
<div style={{ fontSize: 12, color: "var(--text-3)" }}>{agent?.name ?? "Agent"} · {run.trigger}</div>
</div>
<Pill tone={runStateTone(run.state)}>{runStateLabel(run.state)}</Pill>
<span className="mono" style={{ fontSize: 12, color: "var(--text-3)", flexShrink: 0 }}>{formatRelative(run.createdAt)}</span>
</div>
);
})}
{recent.length === 0 && <div style={{ fontSize: 14, color: "var(--text-3)" }}>No runs yet</div>}
</div>
</section>
);
}
function agendaTime(task: TaskRecord, reference: Date): string {
const updated = new Date(task.updatedAt);
if (Number.isNaN(updated.getTime())) return "—";
const sameDay =
updated.getFullYear() === reference.getFullYear() &&
updated.getMonth() === reference.getMonth() &&
updated.getDate() === reference.getDate();
if (!sameDay) return "—";
return formatTime(task.updatedAt);
}
function collectAgendaTasks(tasks: TaskRecord[], reference: Date): TaskRecord[] {
const startOfDay = new Date(reference.getFullYear(), reference.getMonth(), reference.getDate()).getTime();
const endOfDay = startOfDay + 24 * 60 * 60 * 1000;
const todays = tasks.filter((task) => {
const updated = new Date(task.updatedAt).getTime();
return updated >= startOfDay && updated < endOfDay && task.status !== "done";
});
if (todays.length > 0) return todays.sort((a, b) => a.updatedAt.localeCompare(b.updatedAt));
return tasks.filter((task) => task.status !== "done").slice(0, 4);
function RecentMessagesCard({
messages,
agents,
employees,
spaces,
onViewAll,
}: {
messages: ChatMessageRecord[];
agents: AgentRecord[];
employees: EmployeeRecord[];
spaces: Array<{ id: string; name: string }>;
onViewAll: () => void;
}) {
const recent = messages.slice(-6).reverse();
return (
<section className="card">
<div className="row" style={{ marginBottom: 12 }}>
<span className="card-label">Recent messages</span>
<button type="button" className="link-button" style={{ marginLeft: "auto", fontSize: 12 }} onClick={onViewAll}>Open chat →</button>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
{recent.map((msg) => {
const senderAgent = msg.senderType === "agent" ? agents.find((a) => a.id === msg.senderId) : null;
const senderEmployee = msg.senderType === "employee" ? employees.find((e) => e.id === msg.senderId) : null;
const name = senderAgent?.name ?? senderEmployee?.displayName ?? msg.senderType;
const space = spaces.find((s) => s.id === msg.spaceId);
const preview = msg.bodyMd.length > 80 ? `${msg.bodyMd.slice(0, 77)}...` : msg.bodyMd;
return (
<div key={msg.id} className="row" style={{ gap: 10, alignItems: "flex-start" }}>
<Avatar name={name} size="sm" bot={msg.senderType === "agent"} />
<div style={{ flex: 1, minWidth: 0 }}>
<div className="row" style={{ gap: 6 }}>
<span style={{ fontSize: 14, fontWeight: 540 }}>{name}</span>
{space && <span style={{ fontSize: 12, color: "var(--text-3)" }}>#{space.name}</span>}
<span className="mono" style={{ marginLeft: "auto", fontSize: 12, color: "var(--text-3)", flexShrink: 0 }}>{formatTime(msg.createdAt)}</span>
</div>
<div style={{ fontSize: 14, color: "var(--text-2)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", lineHeight: 1.4 }}>{preview}</div>
</div>
</div>
);
})}
{recent.length === 0 && <div style={{ fontSize: 14, color: "var(--text-3)" }}>No messages yet</div>}
</div>
</section>
);
}
function computeGoalProgress(goal: GoalRecord | null, tasks: TaskRecord[]): { percent: number; status: "ahead" | "on_track" | "behind" } | null {
@@ -387,107 +327,11 @@ function resolveRequester(approval: ApprovalRecord, employees: EmployeeRecord[],
return "System";
}
function RunActivityCard({
taskRuns,
tasks,
agents,
onViewAll,
}: {
taskRuns: TaskRunRecord[];
tasks: TaskRecord[];
agents: AgentRecord[];
onViewAll: () => void;
}) {
const recent = taskRuns.slice(0, 8);
return (
<section className="card" style={{ padding: 18 }}>
<div className="row" style={{ marginBottom: 14 }}>
<span className="card-label">Agent runs</span>
<button type="button" className="link-button" style={{ marginLeft: "auto", fontSize: 11 }} onClick={onViewAll}>
All work →
</button>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
{recent.map((run) => {
const task = tasks.find((t) => t.id === run.taskId);
const agent = agents.find((a) => a.id === run.agentId);
return (
<div key={run.id} className="row" style={{ gap: 10 }}>
{agent && <Avatar name={agent.name} size="sm" bot />}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 12.5, fontWeight: 480, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{task?.title ?? run.taskId}
</div>
<div style={{ fontSize: 11, color: "var(--text-3)" }}>
{agent?.name ?? "Agent"} · {run.trigger}
</div>
</div>
<Pill tone={runStateTone(run.state)}>{runStateLabel(run.state)}</Pill>
<span className="mono" style={{ fontSize: 11, color: "var(--text-3)", flexShrink: 0 }}>{formatRelative(run.createdAt)}</span>
</div>
);
})}
{recent.length === 0 && <div style={{ fontSize: 13, color: "var(--text-3)" }}>No runs yet</div>}
</div>
</section>
);
}
function RecentMessagesCard({
messages,
agents,
employees,
spaces,
onViewAll,
}: {
messages: ChatMessageRecord[];
agents: AgentRecord[];
employees: EmployeeRecord[];
spaces: Array<{ id: string; name: string }>;
onViewAll: () => void;
}) {
const recent = messages.slice(-8).reverse();
return (
<section className="card" style={{ padding: 18 }}>
<div className="row" style={{ marginBottom: 14 }}>
<span className="card-label">Recent messages</span>
<button type="button" className="link-button" style={{ marginLeft: "auto", fontSize: 11 }} onClick={onViewAll}>
Open chat →
</button>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
{recent.map((msg) => {
const senderAgent = msg.senderType === "agent" ? agents.find((a) => a.id === msg.senderId) : null;
const senderEmployee = msg.senderType === "employee" ? employees.find((e) => e.id === msg.senderId) : null;
const name = senderAgent?.name ?? senderEmployee?.displayName ?? msg.senderType;
const space = spaces.find((s) => s.id === msg.spaceId);
const preview = msg.bodyMd.length > 80 ? `${msg.bodyMd.slice(0, 77)}...` : msg.bodyMd;
return (
<div key={msg.id} className="row" style={{ gap: 10, alignItems: "flex-start" }}>
<Avatar name={name} size="sm" bot={msg.senderType === "agent"} />
<div style={{ flex: 1, minWidth: 0 }}>
<div className="row" style={{ gap: 6 }}>
<span style={{ fontSize: 12.5, fontWeight: 540 }}>{name}</span>
{space && <span style={{ fontSize: 11, color: "var(--text-3)" }}>#{space.name}</span>}
<span className="mono" style={{ marginLeft: "auto", fontSize: 11, color: "var(--text-3)", flexShrink: 0 }}>{formatTime(msg.createdAt)}</span>
</div>
<div style={{ fontSize: 12, color: "var(--text-2)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", lineHeight: 1.4 }}>
{preview}
</div>
</div>
</div>
);
})}
{recent.length === 0 && <div style={{ fontSize: 13, color: "var(--text-3)" }}>No messages yet</div>}
</div>
</section>
);
}
function runStateTone(state: string): "red" | "amber" | "violet" | undefined {
function runStateTone(state: string): "red" | "amber" | "violet" | "green" | undefined {
if (state === "failed") return "red";
if (state === "running") return "violet";
if (state === "queued") return "amber";
if (state === "completed") return "green";
return undefined;
}
@@ -1,27 +1,55 @@
import type { AgentRecord, ProviderRecord, TaskRecord, TaskRunRecord } from "../../../../shared/domain";
import { useState, type ReactNode } from "react";
import type { AgentRecord, TaskRecord, TaskRunRecord } from "../../../../shared/domain";
import { TASK_STATUS } from "../../../../shared/domain";
const CHART_H = 120;
const CHART_H = 116;
const CHART_W = 260;
const BAR_GAP = 4;
/** A metric tile that lifts on hover and surfaces a live readout in its
* header — the hovered datum's value, or a resting summary otherwise. */
function ChartCard({ title, readout, children }: { title: string; readout: string; children: ReactNode }) {
return (
<section className="card metric-card">
<div className="row" style={{ marginBottom: 12, gap: 8 }}>
<span className="card-label">{title}</span>
<span className="metric-readout num">{readout}</span>
</div>
{children}
</section>
);
}
export function AgentWorkloadChart({ agents, tasks }: { agents: AgentRecord[]; tasks: TaskRecord[] }) {
const data = agents.slice(0, 8).map((a) => ({
name: a.name.split(/\s+/)[0],
name: a.name.split(/\s+/)[0] ?? a.name,
count: tasks.filter((t) => t.assigneeAgentId === a.id && t.status !== TASK_STATUS.DONE).length,
}));
const total = data.reduce((s, d) => s + d.count, 0);
const max = Math.max(1, ...data.map((d) => d.count));
const barW = data.length > 0 ? (CHART_W - (data.length - 1) * BAR_GAP) / data.length : 0;
const [hover, setHover] = useState<number | null>(null);
const readout = hover !== null && data[hover] ? `${data[hover]!.name} · ${data[hover]!.count}` : `${total} open`;
return (
<ChartCard title="Agent workload">
<ChartCard title="Agent workload" readout={readout}>
<svg viewBox={`0 0 ${CHART_W} ${CHART_H}`} style={{ width: "100%", height: CHART_H }}>
{data.map((d, i) => {
const h = (d.count / max) * (CHART_H - 20);
const h = (d.count / max) * (CHART_H - 22);
const x = i * (barW + BAR_GAP);
return (
<g key={d.name}>
<rect x={x} y={CHART_H - h - 16} width={barW} height={h} rx={3} fill="var(--accent)" opacity={0.7 + (d.count / max) * 0.3} />
<g key={d.name} onMouseEnter={() => setHover(i)} onMouseLeave={() => setHover(null)}>
<rect x={x} y={0} width={barW} height={CHART_H - 16} fill="transparent" />
<rect
x={x}
y={CHART_H - h - 16}
width={barW}
height={Math.max(2, h)}
rx={4}
fill="var(--accent)"
opacity={hover === null || hover === i ? 0.85 : 0.32}
style={{ transition: "opacity var(--motion-fast) var(--ease-out)" }}
/>
<text x={x + barW / 2} y={CHART_H - 2} textAnchor="middle" fontSize="9" fill="var(--text-3)">{d.name}</text>
</g>
);
@@ -31,61 +59,34 @@ export function AgentWorkloadChart({ agents, tasks }: { agents: AgentRecord[]; t
);
}
export function BudgetUtilizationChart({ agents, providers }: { agents: AgentRecord[]; providers: ProviderRecord[] }) {
const connected = providers.filter((p) => p.enabled);
const data = connected.slice(0, 6).map((p) => {
const allocated = agents.filter((a) => a.modelProviderId === p.id).reduce((s, a) => s + a.budgetCents, 0);
return { label: p.label.split(/\s+/)[0], cents: allocated };
});
const total = Math.max(1, data.reduce((s, d) => s + d.cents, 0));
const segments = stackSegments(data, total);
const colors = ["var(--accent)", "var(--green)", "var(--amber)", "var(--violet)", "var(--red)", "var(--text-3)"];
return (
<ChartCard title="Budget utilization">
<svg viewBox={`0 0 ${CHART_W} ${CHART_H}`} style={{ width: "100%", height: CHART_H }}>
{segments.map((segment, i) => (
<g key={segment.label}>
<rect x={segment.x} y={20} width={Math.max(1, segment.w - 2)} height={CHART_H - 40} rx={4} fill={colors[i % colors.length]} opacity={0.8} />
<text x={segment.x + segment.w / 2} y={CHART_H - 4} textAnchor="middle" fontSize="9" fill="var(--text-3)">{segment.label}</text>
<text x={segment.x + segment.w / 2} y={14} textAnchor="middle" fontSize="9" fill="var(--text-2)">${Math.round(segment.cents / 100)}</text>
</g>
))}
</svg>
</ChartCard>
);
}
function stackSegments(
data: Array<{ label: string; cents: number }>,
total: number,
): Array<{ label: string; cents: number; x: number; w: number }> {
const segments: Array<{ label: string; cents: number; x: number; w: number }> = [];
let cumX = 0;
for (const d of data) {
const w = (d.cents / total) * CHART_W;
segments.push({ label: d.label, cents: d.cents, x: cumX, w });
cumX += w;
}
return segments;
}
export function RunActivityChart({ taskRuns }: { taskRuns: TaskRunRecord[] }) {
const buckets = last7DayBuckets(taskRuns.map((r) => r.createdAt));
const total = buckets.reduce((s, b) => s + b.count, 0);
const max = Math.max(1, ...buckets.map((b) => b.count));
const barW = (CHART_W - 6 * BAR_GAP) / 7;
const [hover, setHover] = useState<number | null>(null);
const readout = hover !== null && buckets[hover] ? `${buckets[hover]!.label} · ${buckets[hover]!.count}` : `${total} this week`;
return (
<ChartCard title="Run activity (7d)">
<ChartCard title="Run activity" readout={readout}>
<svg viewBox={`0 0 ${CHART_W} ${CHART_H}`} style={{ width: "100%", height: CHART_H }}>
{buckets.map((b, i) => {
const h = (b.count / max) * (CHART_H - 24);
const x = i * (barW + BAR_GAP);
return (
<g key={b.label}>
<rect x={x} y={CHART_H - h - 16} width={barW} height={h} rx={3} fill="var(--text-1)" opacity={0.6 + (b.count / max) * 0.4} />
<text x={x + barW / 2} y={CHART_H - 2} textAnchor="middle" fontSize="8" fill="var(--text-3)">{b.label}</text>
<g key={`${b.label}-${i}`} onMouseEnter={() => setHover(i)} onMouseLeave={() => setHover(null)}>
<rect x={x} y={0} width={barW} height={CHART_H - 16} fill="transparent" />
<rect
x={x}
y={CHART_H - h - 16}
width={barW}
height={Math.max(2, h)}
rx={4}
fill="var(--text-1)"
opacity={hover === null || hover === i ? 0.82 : 0.28}
style={{ transition: "opacity var(--motion-fast) var(--ease-out)" }}
/>
<text x={x + barW / 2} y={CHART_H - 2} textAnchor="middle" fontSize="9" fill="var(--text-3)">{b.label}</text>
</g>
);
})}
@@ -103,17 +104,17 @@ export function SuccessRateChart({ taskRuns }: { taskRuns: TaskRunRecord[] }) {
const offset = circumference - (rate / 100) * circumference;
return (
<ChartCard title="Success rate">
<svg viewBox="0 0 100 100" style={{ width: 100, height: 100, margin: "0 auto", display: "block" }}>
<ChartCard title="Success rate" readout={`${completed} ok · ${failed} failed`}>
<svg viewBox="0 0 100 100" style={{ width: 96, height: 96, margin: "4px auto", display: "block" }}>
<circle cx="50" cy="50" r="42" fill="none" stroke="var(--panel-3)" strokeWidth="8" />
<circle cx="50" cy="50" r="42" fill="none" stroke="var(--green)" strokeWidth="8"
<circle
cx="50" cy="50" r="42" fill="none" stroke="var(--green)" strokeWidth="8"
strokeDasharray={circumference} strokeDashoffset={offset}
strokeLinecap="round" transform="rotate(-90 50 50)" />
<text x="50" y="50" textAnchor="middle" dominantBaseline="central" fontSize="18" fontWeight="600" fill="var(--text-1)">{rate}%</text>
strokeLinecap="round" transform="rotate(-90 50 50)"
style={{ transition: "stroke-dashoffset var(--motion-slow) var(--ease-out)" }}
/>
<text x="50" y="50" textAnchor="middle" dominantBaseline="central" fontSize="20" fontWeight="600" fill="var(--text-1)">{rate}%</text>
</svg>
<div style={{ textAlign: "center", fontSize: 11, color: "var(--text-3)", marginTop: 4 }}>
{completed} passed · {failed} failed
</div>
</ChartCard>
);
}
@@ -123,44 +124,43 @@ export function CostTrendChart({ providerRequests }: { providerRequests: Array<{
const charBuckets = buckets.map((b, i) => {
const dayStart = dayStartMs(i);
const dayEnd = dayStart + 86400000;
const dayRequests = providerRequests.filter((r) => {
const t = new Date(r.createdAt).getTime();
return t >= dayStart && t < dayEnd;
});
return { ...b, chars: dayRequests.reduce((s, r) => s + r.inputCharCount + r.outputCharCount, 0) };
const chars = providerRequests
.filter((r) => { const t = new Date(r.createdAt).getTime(); return t >= dayStart && t < dayEnd; })
.reduce((s, r) => s + r.inputCharCount + r.outputCharCount, 0);
return { ...b, chars };
});
const totalChars = charBuckets.reduce((s, b) => s + b.chars, 0);
const max = Math.max(1, ...charBuckets.map((b) => b.chars));
const points = charBuckets.map((b, i) => {
const x = (i / 6) * (CHART_W - 10) + 5;
const y = CHART_H - 20 - (b.chars / max) * (CHART_H - 30);
return `${x},${y}`;
}).join(" ");
const coords = charBuckets.map((b, i) => ({
x: (i / 6) * (CHART_W - 10) + 5,
y: CHART_H - 20 - (b.chars / max) * (CHART_H - 30),
b,
}));
const points = coords.map((c) => `${c.x},${c.y}`).join(" ");
const [hover, setHover] = useState<number | null>(null);
const readout = hover !== null && charBuckets[hover]
? `${charBuckets[hover]!.label} · ${kchars(charBuckets[hover]!.chars)}`
: `${kchars(totalChars)} this week`;
return (
<ChartCard title="Token usage (7d)">
<ChartCard title="Token usage" readout={readout}>
<svg viewBox={`0 0 ${CHART_W} ${CHART_H}`} style={{ width: "100%", height: CHART_H }}>
<polyline points={points} fill="none" stroke="var(--accent)" strokeWidth="2" strokeLinejoin="round" />
{charBuckets.map((b, i) => {
const x = (i / 6) * (CHART_W - 10) + 5;
const y = CHART_H - 20 - (b.chars / max) * (CHART_H - 30);
return <circle key={b.label} cx={x} cy={y} r="3" fill="var(--accent)" />;
})}
{charBuckets.map((b, i) => {
const x = (i / 6) * (CHART_W - 10) + 5;
return <text key={`l-${b.label}`} x={x} y={CHART_H - 2} textAnchor="middle" fontSize="8" fill="var(--text-3)">{b.label}</text>;
})}
<polyline points={points} fill="none" stroke="var(--accent)" strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" />
{coords.map((c, i) => (
<g key={`${c.b.label}-${i}`} onMouseEnter={() => setHover(i)} onMouseLeave={() => setHover(null)}>
<circle cx={c.x} cy={c.y} r="9" fill="transparent" />
<circle cx={c.x} cy={c.y} r={hover === i ? 4 : 3} fill="var(--accent)" style={{ transition: "r var(--motion-fast) var(--ease-out)" }} />
<text x={c.x} y={CHART_H - 2} textAnchor="middle" fontSize="9" fill="var(--text-3)">{c.b.label}</text>
</g>
))}
</svg>
</ChartCard>
);
}
function ChartCard({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="card" style={{ padding: 14 }}>
<div className="card-label" style={{ marginBottom: 10 }}>{title}</div>
{children}
</section>
);
function kchars(chars: number): string {
if (chars >= 1000) return `${(chars / 1000).toFixed(chars >= 10000 ? 0 : 1)}k`;
return String(chars);
}
function last7DayBuckets(dates: string[]): Array<{ label: string; count: number }> {
@@ -171,11 +171,8 @@ function last7DayBuckets(dates: string[]): Array<{ label: string; count: number
const start = new Date(now - offset * 86400000);
start.setHours(0, 0, 0, 0);
const end = new Date(start.getTime() + 86400000);
const count = dates.filter((d) => {
const t = new Date(d).getTime();
return t >= start.getTime() && t < end.getTime();
}).length;
return { label: dayNames[start.getDay()], count };
const count = dates.filter((d) => { const t = new Date(d).getTime(); return t >= start.getTime() && t < end.getTime(); }).length;
return { label: dayNames[start.getDay()] ?? "", count };
});
}
@@ -190,6 +190,21 @@ textarea.input { min-height: 84px; resize: vertical; line-height: 1.5; }
.clickable-card { transition: border-color var(--motion-fast) var(--ease-out), box-shadow var(--motion-fast) var(--ease-out); }
.clickable-card:hover { border-color: var(--border-strong); box-shadow: var(--shadow-sm); }
/* Dashboard metric tiles lift on hover; the readout brightens with the card. */
.metric-card {
position: relative;
transition: transform var(--motion-base) var(--ease-out), box-shadow var(--motion-base) var(--ease-out), border-color var(--motion-base) var(--ease-out);
}
.metric-card:hover { transform: translateY(-2px); box-shadow: var(--shadow-md); border-color: var(--border-strong); }
.metric-readout {
margin-left: auto;
font-size: var(--fs-caption);
color: var(--text-3);
font-variant-numeric: tabular-nums;
transition: color var(--motion-fast) var(--ease-out);
}
.metric-card:hover .metric-readout { color: var(--text-1); }
.link-button {
border: 0;
background: transparent;