diff --git a/AgentCompany-New/src/renderer/src/pages/HomePage.tsx b/AgentCompany-New/src/renderer/src/pages/HomePage.tsx index 23a3f083..c06ef15a 100644 --- a/AgentCompany-New/src/renderer/src/pages/HomePage.tsx +++ b/AgentCompany-New/src/renderer/src/pages/HomePage.tsx @@ -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 ( -
+
- - + +

{greeting}, {firstName}

-

{summaryText}

+

{renderSummary(pendingApprovals.length, agents.length)}

-
- +
+ + 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")} /> -
-
- navigate("work")} /> - - -
- -
+
-
-
+
navigate("runs")} /> navigate("chat")} />
@@ -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 ( -
-
- Today - {formatted} +
+
Quarter goal
+
+ {goal ? goal.title : `Set the first goal for ${organizationName}.`}
-
- {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 ( -
- {time} -
- {task.title} - · {ownerName} -
- {requiresApproval && You} -
- ); - })} +
+ {progress ? `${percent}%` : "—"} + {progress ? statusLabel(progress.status) : "no work yet"}
-
- ); -} - -function WaitingCard({ - approvals, - agents, - employees, - onApprove, - onOpen, -}: { - approvals: ApprovalRecord[]; - agents: AgentRecord[]; - employees: EmployeeRecord[]; - onApprove: (approvalId: string) => void; - onOpen: () => void; -}) { - return ( -
-
- Waiting on you - {approvals.length > 0 && {approvals.length}} -
-
- {approvals.slice(0, 2).map((approval) => { - const from = resolveRequester(approval, employees, agents); - return ( -
-
{approval.title}
-
- {humanizeLabel(approval.kind)} · {humanizeLabel(approval.risk)} risk · from {from} -
-
- - -
-
- ); - })} +
+
); @@ -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 ( -
-
Monthly budget
+
+
Monthly budget
- {money(totalBudgetCents)} + {money(totalBudgetCents)} {capCents > 0 ? `/ ${money(capCents)}` : "allocated"}
-
+
-
+
{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 ( -
- {provider.label} - - {money(allocatedCents)} - +
+ {provider.label} + {money(allocatedCents)}
); })} + {connected.length === 0 && No providers connected}
); } -function InProgressCard({ tasks, agents, onViewAll }: { tasks: TaskRecord[]; agents: AgentRecord[]; onViewAll: () => void }) { - const rows = tasks.slice(0, 4); - return ( -
-
- In progress - -
- {rows.map((task, index) => { - const owner = agents.find((agent) => agent.id === task.assigneeAgentId); - const last = index === rows.length - 1; - return ( -
- -
{task.title}
- {owner && {owner.department}} - {owner && } -
- ); - })} -
- ); -} - -function TeamCard({ agents, employee }: { agents: AgentRecord[]; employee: EmployeeRecord }) { - return ( -
-
Team
- {agents.slice(0, 4).map((agent) => ( -
- - {agent.name} - {agent.role} -
- ))} -
- - {employee.displayName} - {employee.role} · human -
-
- ); -} - -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 ( -
-
- Quarter goal +
+
+ Waiting on you + {approvals.length > 0 && {approvals.length}}
-
- {goal ? goal.title : `Set the first goal for ${organizationName}.`} + {approvals.length === 0 ? ( +
+ You're all caught up +
+ ) : ( +
+ {approvals.slice(0, 2).map((approval) => ( +
+
{approval.title}
+
+ {humanizeLabel(approval.kind)} · {humanizeLabel(approval.risk)} risk · from {resolveRequester(approval, employees, agents)} +
+
+ + +
+
+ ))} +
+ )} +
+ ); +} + +function RunActivityCard({ + taskRuns, + tasks, + agents, + onViewAll, +}: { + taskRuns: TaskRunRecord[]; + tasks: TaskRecord[]; + agents: AgentRecord[]; + onViewAll: () => void; +}) { + const recent = taskRuns.slice(0, 6); + return ( +
+
+ Agent runs +
-
- {progress ? `${progress.percent}%` : "—"} - - {progress ? statusLabel(progress.status) : "no work yet"} - -
-
-
+
+ {recent.map((run) => { + const task = tasks.find((t) => t.id === run.taskId); + const agent = agents.find((a) => a.id === run.agentId); + return ( +
+ {agent && } +
+
{task?.title ?? run.taskId}
+
{agent?.name ?? "Agent"} · {run.trigger}
+
+ {runStateLabel(run.state)} + {formatRelative(run.createdAt)} +
+ ); + })} + {recent.length === 0 &&
No runs yet
}
); } -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 ( +
+
+ Recent messages + +
+
+ {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 ( +
+ +
+
+ {name} + {space && #{space.name}} + {formatTime(msg.createdAt)} +
+
{preview}
+
+
+ ); + })} + {recent.length === 0 &&
No messages yet
} +
+
+ ); } 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 ( -
-
- Agent runs - -
-
- {recent.map((run) => { - const task = tasks.find((t) => t.id === run.taskId); - const agent = agents.find((a) => a.id === run.agentId); - return ( -
- {agent && } -
-
- {task?.title ?? run.taskId} -
-
- {agent?.name ?? "Agent"} · {run.trigger} -
-
- {runStateLabel(run.state)} - {formatRelative(run.createdAt)} -
- ); - })} - {recent.length === 0 &&
No runs yet
} -
-
- ); -} - -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 ( -
-
- Recent messages - -
-
- {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 ( -
- -
-
- {name} - {space && #{space.name}} - {formatTime(msg.createdAt)} -
-
- {preview} -
-
-
- ); - })} - {recent.length === 0 &&
No messages yet
} -
-
- ); -} - -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; } diff --git a/AgentCompany-New/src/renderer/src/pages/home/DashboardCharts.tsx b/AgentCompany-New/src/renderer/src/pages/home/DashboardCharts.tsx index a1c2ef01..0773fff3 100644 --- a/AgentCompany-New/src/renderer/src/pages/home/DashboardCharts.tsx +++ b/AgentCompany-New/src/renderer/src/pages/home/DashboardCharts.tsx @@ -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 ( +
+
+ {title} + {readout} +
+ {children} +
+ ); +} + 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(null); + const readout = hover !== null && data[hover] ? `${data[hover]!.name} · ${data[hover]!.count}` : `${total} open`; return ( - + {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 ( - - + setHover(i)} onMouseLeave={() => setHover(null)}> + + {d.name} ); @@ -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 ( - - - {segments.map((segment, i) => ( - - - {segment.label} - ${Math.round(segment.cents / 100)} - - ))} - - - ); -} - -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(null); + const readout = hover !== null && buckets[hover] ? `${buckets[hover]!.label} · ${buckets[hover]!.count}` : `${total} this week`; return ( - + {buckets.map((b, i) => { const h = (b.count / max) * (CHART_H - 24); const x = i * (barW + BAR_GAP); return ( - - - {b.label} + setHover(i)} onMouseLeave={() => setHover(null)}> + + + {b.label} ); })} @@ -103,17 +104,17 @@ export function SuccessRateChart({ taskRuns }: { taskRuns: TaskRunRecord[] }) { const offset = circumference - (rate / 100) * circumference; return ( - - + + - - {rate}% + strokeLinecap="round" transform="rotate(-90 50 50)" + style={{ transition: "stroke-dashoffset var(--motion-slow) var(--ease-out)" }} + /> + {rate}% -
- {completed} passed · {failed} failed -
); } @@ -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(null); + const readout = hover !== null && charBuckets[hover] + ? `${charBuckets[hover]!.label} · ${kchars(charBuckets[hover]!.chars)}` + : `${kchars(totalChars)} this week`; return ( - + - - {charBuckets.map((b, i) => { - const x = (i / 6) * (CHART_W - 10) + 5; - const y = CHART_H - 20 - (b.chars / max) * (CHART_H - 30); - return ; - })} - {charBuckets.map((b, i) => { - const x = (i / 6) * (CHART_W - 10) + 5; - return {b.label}; - })} + + {coords.map((c, i) => ( + setHover(i)} onMouseLeave={() => setHover(null)}> + + + {c.b.label} + + ))} ); } -function ChartCard({ title, children }: { title: string; children: React.ReactNode }) { - return ( -
-
{title}
- {children} -
- ); +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 }; }); } diff --git a/AgentCompany-New/src/renderer/src/styles/components.css b/AgentCompany-New/src/renderer/src/styles/components.css index ba4cbb75..2d9e5e69 100644 --- a/AgentCompany-New/src/renderer/src/styles/components.css +++ b/AgentCompany-New/src/renderer/src/styles/components.css @@ -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;