From 673003062503c9c728b7d01a21004e99d0ae3b4b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 07:11:52 +0000 Subject: [PATCH 1/2] Redesign the Command dashboard and application chrome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Command page was a stack of bordered lists: nine flat counts, an attention queue, and two link lists. It answered "what exists" but not "what is moving", and the chrome around it gave the product no shape. Chrome: - Sidebar keeps its group vocabulary but reads as navigation — brand block, spaced groups, an accent indicator on the active row, and the collapse control moved to the footer where it is out of the way. - Top bar carries organisation context, a real search affordance with its shortcut, an approvals bell with its pending count, theme, and the signed-in actor with initials. - Page background drops to the darkest paper so cards sit above it. Card now uses the card token, which fixes the flat-on-flat surfaces everywhere, not only here. Dashboard: - Stat tiles gain a measured 24h delta and the seven-day series behind the number, and the two tiles whose value disagreed with their series are now scoped to the window they chart. - New panels: hourly agent run activity (toggleable series), work-item status donut counted in SQL over the whole backlog, a work queue split between items you own and items nobody owns, per-agent run volume and success rate over 7 days, and control-plane integration health. - Attention queue, risk radar, and the audit feed stay, restyled. Every number comes from the database. Where the rate cannot be measured — an agent with no settled runs, a session without administration.manage — the panel says so instead of inventing a figure. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MT2TCg43f2npxaN3HzQg7g --- DESIGN.md | 14 +- apps/web/components/os/charts.tsx | 300 ++++++++ apps/web/components/os/company-os-shell.tsx | 185 +++-- apps/web/components/os/metric-tile.tsx | 75 +- apps/web/components/os/panel.tsx | 68 ++ apps/web/components/page-header.tsx | 10 +- apps/web/components/ui/card.tsx | 2 +- apps/web/components/ui/progress.tsx | 54 ++ apps/web/features/command/command-view.tsx | 715 ++++++++++++++------ apps/web/lib/command-summary-domain.ts | 407 ++++++++++- apps/web/types/os.ts | 70 ++ 11 files changed, 1618 insertions(+), 282 deletions(-) create mode 100644 apps/web/components/os/charts.tsx create mode 100644 apps/web/components/os/panel.tsx create mode 100644 apps/web/components/ui/progress.tsx diff --git a/DESIGN.md b/DESIGN.md index cc9ee66..3f0fcf4 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -34,8 +34,15 @@ Primitives: `apps/web/components/status/status-badges.tsx` and `apps/web/types/s ## Components -- `CompanyOsShell` application chrome -- Metric tiles, empty/error/skeleton states +- `CompanyOsShell` application chrome: grouped sidebar with an active-row + indicator, and a top bar carrying organisation context, search (⌘K), + approvals bell, theme, and the signed-in actor +- `Panel` / `PanelLink` titled dashboard containers +- Metric tiles (value, measured 24h delta, seven-day sparkline), + empty/error/skeleton states +- `Progress` ratio bar and `components/os/charts.tsx` (sparkline, hourly run + activity lines, work-status donut) — chart colour comes from tokens and is + always paired with a legend label - Approval cards in Governance Inbox - Work item tables and board mode - Agent roster / dossier (existing agents views) @@ -54,5 +61,8 @@ never as a chat bubble product. ## Data rules - Server-backed queries and mutations only for authoritative state +- Trends, sparklines, rates, and chart series are computed from stored rows; + a tile with no history shows no trend rather than a decorative arrow, and a + count and the series beneath it must measure the same window - Theme preference may use localStorage; operational state must not - Fixture adapters must be labelled `source: fixture` in UI and types diff --git a/apps/web/components/os/charts.tsx b/apps/web/components/os/charts.tsx new file mode 100644 index 0000000..5cc30d7 --- /dev/null +++ b/apps/web/components/os/charts.tsx @@ -0,0 +1,300 @@ +"use client"; + +import { useId } from "react"; +import { + CartesianGrid, + Cell, + Line, + LineChart, + Pie, + PieChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import { cn } from "@/lib/utils"; + +/** + * Charts read their colours from the token layer so dark and light stay in + * step, and every series is also labelled in the legend — colour alone never + * carries meaning. + */ +export const SERIES_COLOURS = { + completed: "var(--color-success)", + running: "var(--color-info)", + failed: "var(--color-error)", + cancelled: "var(--color-faint)", + accent: "var(--color-accent)", + agent: "var(--color-agent)", + warning: "var(--color-warning)", +} as const; + +function TooltipCard({ + title, + rows, +}: { + title: string; + rows: Array<{ label: string; value: string; colour: string }>; +}) { + return ( +
+

{title}

+
    + {rows.map((row) => ( +
  • + + {row.label} + + {row.value} + +
  • + ))} +
+
+ ); +} + +/** + * Small inline trend line for a stat tile. Deliberately hand-rolled: a tile + * sparkline needs no axes, tooltip, or layout engine. + */ +export function Sparkline({ + values, + tone = "neutral", + label, + className, +}: { + values: number[]; + tone?: "neutral" | "positive" | "negative" | "warning"; + label: string; + className?: string; +}) { + const gradientFreeId = useId(); + if (values.length < 2) return null; + + const width = 96; + const height = 28; + const max = Math.max(...values); + const min = Math.min(...values); + const span = max - min || 1; + const step = width / (values.length - 1); + const points = values.map((value, index) => { + const x = index * step; + const y = height - ((value - min) / span) * (height - 4) - 2; + return `${x.toFixed(2)},${y.toFixed(2)}`; + }); + + const stroke = + tone === "positive" + ? "var(--color-success)" + : tone === "negative" + ? "var(--color-error)" + : tone === "warning" + ? "var(--color-warning)" + : "var(--color-agent)"; + + return ( + + {label} + + + ); +} + +export type RunActivitySeriesKey = + | "completed" + | "running" + | "failed" + | "cancelled"; + +export const RUN_ACTIVITY_SERIES: Array<{ + key: RunActivitySeriesKey; + label: string; + colour: string; +}> = [ + { key: "completed", label: "Completed", colour: SERIES_COLOURS.completed }, + { key: "running", label: "In flight", colour: SERIES_COLOURS.running }, + { key: "failed", label: "Failed", colour: SERIES_COLOURS.failed }, + { key: "cancelled", label: "Cancelled", colour: SERIES_COLOURS.cancelled }, +]; + +type ActivityDatum = { + bucket: string; + label: string; + completed: number; + running: number; + failed: number; + cancelled: number; +}; + +/** Hourly agent-run volume. One line per terminal state. */ +export function RunActivityChart({ + data, + visible, +}: { + data: ActivityDatum[]; + visible: RunActivitySeriesKey[]; +}) { + const points = data.map((point) => ({ + ...point, + axis: new Date(point.bucket).toLocaleTimeString(undefined, { + hour: "2-digit", + minute: "2-digit", + hour12: false, + }), + })); + + return ( +
+ + + + + + { + if (!active || !payload?.length) return null; + return ( + ({ + label: String(entry.name ?? entry.dataKey), + value: String(entry.value ?? 0), + colour: String(entry.color ?? "var(--color-muted)"), + }))} + /> + ); + }} + /> + {RUN_ACTIVITY_SERIES.filter((series) => + visible.includes(series.key), + ).map((series) => ( + + ))} + + +
+ ); +} + +export const STATUS_SLICE_COLOURS: Record = { + backlog: "var(--color-faint)", + ready: "var(--color-info)", + in_progress: "var(--color-accent)", + review: "var(--color-warning)", + done: "var(--color-success)", +}; + +/** Work-item status split. The centre states the total it is a split of. */ +export function StatusDonut({ + slices, + total, + totalLabel, +}: { + slices: Array<{ status: string; label: string; count: number }>; + total: number; + totalLabel: string; +}) { + return ( +
+ + + + {slices.map((slice) => ( + + ))} + + { + if (!active || !payload?.length) return null; + const entry = payload[0]; + if (!entry) return null; + const count = Number(entry.value ?? 0); + return ( + 0 ? Math.round((count / total) * 100) : 0}%)`, + colour: String( + entry.payload?.fill ?? "var(--color-muted)", + ), + }, + ]} + /> + ); + }} + /> + + +
+

+ {total} +

+

{totalLabel}

+
+
+ ); +} diff --git a/apps/web/components/os/company-os-shell.tsx b/apps/web/components/os/company-os-shell.tsx index f347707..dba8ce4 100644 --- a/apps/web/components/os/company-os-shell.tsx +++ b/apps/web/components/os/company-os-shell.tsx @@ -11,6 +11,7 @@ import { } from "react"; import { Activity, + Bell, BookOpen, Bot, Cable, @@ -31,6 +32,7 @@ import { X, } from "lucide-react"; import { authClient } from "@muster/auth/client"; +import { Avatar } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { OsCommandPalette } from "@/components/os/os-command-palette"; @@ -92,6 +94,15 @@ const navGroups: Array<{ heading: string; items: NavItem[] }> = [ const navItems: NavItem[] = navGroups.flatMap((group) => group.items); +/** Two letters is enough to tell operators apart without a photo service. */ +function initialsOf(name: string): string { + const parts = name.trim().split(/\s+/).filter(Boolean); + if (parts.length === 0) return "??"; + const first = parts[0]?.[0] ?? ""; + const second = parts.length > 1 ? (parts.at(-1)?.[0] ?? "") : (parts[0]?.[1] ?? ""); + return `${first}${second}`.toUpperCase(); +} + function NavLink({ href, label, @@ -120,14 +131,26 @@ function NavLink({ aria-current={active ? "page" : undefined} title={collapsed ? label : undefined} className={cn( - "flex min-h-9 items-center gap-2 rounded-md px-2.5 text-sm font-medium transition-colors", + "relative flex min-h-9 items-center gap-2.5 rounded-md px-2.5 text-sm font-medium transition-colors", active - ? "bg-muted text-foreground" - : "text-muted-foreground hover:bg-muted/70 hover:text-foreground", + ? "bg-[var(--color-accent-soft)] text-foreground" + : "text-muted-foreground hover:bg-[var(--color-paper-3)] hover:text-foreground", collapsed && "justify-center px-2", )} > - + {active ? ( + + ) : null} + {!collapsed ? {label} : null} {!collapsed && badge && badge > 0 ? ( @@ -154,60 +177,45 @@ function Sidebar({ ); } @@ -286,7 +315,7 @@ export function CompanyOsShell({ children }: { children: ReactNode }) { } return ( -
+
-
+
- + + - {pendingApprovals > 0 ? ( - - - {pendingApprovals} - pending approvals - - ) : null} + 0 + ? `${pendingApprovals} pending approvals` + : "Approvals" + } + className="relative inline-grid size-9 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-[var(--color-paper-3)] hover:text-foreground" + > + + {pendingApprovals > 0 ? ( + + {pendingApprovals > 9 ? "9+" : pendingApprovals} + + ) : null} + + <> + {data ? ( + + Updated {relativeTime(data.generatedAt)} + + ) : null} + + + New work item + + } /> @@ -46,210 +210,379 @@ export function CommandView() { {query.isLoading ? : null} - {query.data ? ( + {data ? ( <> - {query.data.notes.length > 0 ? ( -
- {query.data.notes.join(" · ")} -
+ {data.notes.length > 0 ? ( +

+ {data.notes.join(" · ")} +

) : null}

Top metrics

-
- {query.data.metrics.map((metric) => ( +
+ {data.metrics.map((metric) => ( ))}
-
-
+ + {RUN_ACTIVITY_SERIES.map((series) => { + const on = visibleSeries.includes(series.key); + return ( + + ); + })} +
+ } > -
-

- Attention queue -

- - {query.data.attention.length} - -
-
- {query.data.attention.length === 0 ? ( -
- -
- ) : ( - query.data.attention.map((item) => ( -
-
-
-
-

- {item.href ? ( - - {item.title} - - ) : ( - item.title - )} -

- - - {item.type.replaceAll("_", " ")} - -
-

- {item.sourceSystem} - {item.owner ? ` · ${item.owner}` : ""} · {item.age} -

-

- Next: {item.recommendedAction} -

-
-
-
- )) - )} -
- + {runTotal === 0 ? ( + + ) : ( + + )} + -
-
-

- Operational risk radar -

-

- Heuristic summaries from live counts — not a composite score. -

-
-
    - {query.data.riskRadar.map((cell) => ( -
  • + ) : ( + <> + +
      + {taskStatus.map((slice) => ( +
    • + + + {slice.label} + + + {slice.count} + + + {Math.round((slice.count / taskTotal) * 100)}% + +
    • + ))} +
    + + )} + + + View all} + > +
    + {( + [ + ["mine", "Assigned to me", assignedToMe.length], + ["unassigned", "Unassigned", unassigned.length], + ] as const + ).map(([key, label, total]) => ( +
  • + {label} + + {total} + + ))} -
-
+
+ {shownTasks.length === 0 ? ( +
+ +
+ ) : ( +
+ {shownTasks.slice(0, 6).map((task) => ( + + ))} +
+ )} + -
-
+ + {data.attention.length} + + } + > + {data.attention.length === 0 ? ( +
+ +
+ ) : ( +
+ {data.attention.slice(0, 6).map((item) => ( + + ))} +
+ )} +
+ + View all} > -
-

- Agent status -

- - View all - -
-
- {query.data.agents.length === 0 ? ( -

- No agents visible for this session. -

- ) : ( - query.data.agents.map((agent) => ( + {data.agentActivity.length === 0 ? ( +
+ +
+ ) : ( +
+ {data.agentActivity.map((agent) => (
- - {agent.name} - - - {agent.status} - - - {agent.runtime} - - - {agent.lastRunStatus - ? `${agent.lastRunStatus}${ - agent.lastRunAt - ? ` · ${relativeTime(agent.lastRunAt)}` - : "" - }` - : "No recent run"} - + +
+ + {agent.name} + +

+ {agent.runtime} · {agent.status} +

+
+
+

+ {agent.runs} + + runs + +

+ {agent.successRate === null ? ( +

+ No settled runs +

+ ) : ( + <> +

+ {Math.round(agent.successRate * 100)}% success +

+ = 0.9 + ? "success" + : agent.successRate >= 0.6 + ? "warning" + : "error" + } + /> + + )} +
- )) - )} -
-
+ ))} +
+ )} + -
Full audit} > -
-

- Live activity -

- - Full audit - -
-
- {query.data.activity.length === 0 ? ( -

- No recent audit events (or not authorised). -

- ) : ( - query.data.activity.map((event) => ( -
-
- - {relativeTime(event.timestamp)} - - {event.actor} - - {event.action} - + {data.activity.length === 0 ? ( +
+ +
+ ) : ( +
    + {data.activity.slice(0, 7).map((event) => ( +
  • + +
    +

    + {event.actor}{" "} + + {event.action} + +

    +

    + {event.target} +

    -

    - {event.target} + + {relativeTime(event.timestamp)} + +

  • + ))} +
+ )} + +
+ + View all} + > + {data.integrations.length === 0 ? ( + + ) : ( +
    + {data.integrations.map((integration) => ( +
  • +
    +

    + {integration.name} +

    +

    + {integration.detail}

    - )) - )} -
-
- + + + ))} + + )} + + + +
    + {data.riskRadar.map((cell) => ( +
  • +
    + + {cell.label} + + +
    +

    + {cell.summary} +

    +
  • + ))} +
+
) : null} diff --git a/apps/web/lib/command-summary-domain.ts b/apps/web/lib/command-summary-domain.ts index 7d276b6..35f91db 100644 --- a/apps/web/lib/command-summary-domain.ts +++ b/apps/web/lib/command-summary-domain.ts @@ -1,4 +1,4 @@ -import { and, desc, eq, gt, inArray, isNull } from "drizzle-orm"; +import { and, count, desc, eq, gt, gte, inArray, isNull } from "drizzle-orm"; import { hasCapability, type AuthorisationSubject, @@ -8,11 +8,17 @@ import { getControlPlaneStatus } from "./control-plane-status.ts"; import { relativeTime } from "./utils.ts"; import type { ActivityEvent, + AgentActivityRow, AttentionItem, CommandMetric, + IntegrationHealthChip, + MetricTrend, + MyTaskRow, RiskRadarCell, + RunActivityPoint, + TaskStatusSlice, } from "@/types/os"; -import { toHealthState } from "@/types/status"; +import { toHealthState, toOperationalState } from "@/types/status"; export type CommandSummary = { generatedAt: string; @@ -20,6 +26,16 @@ export type CommandSummary = { attention: AttentionItem[]; riskRadar: RiskRadarCell[]; activity: ActivityEvent[]; + /** Live status distribution of every non-archived work item. */ + taskStatus: TaskStatusSlice[]; + /** Agent runs bucketed by hour over the last 24 hours. */ + runActivity: RunActivityPoint[]; + /** Per-agent run volume and success rate over the last 7 days. */ + agentActivity: AgentActivityRow[]; + /** Open work items the session's actor owns, plus the unassigned queue. */ + myTasks: MyTaskRow[]; + /** Control-plane components, one chip each. */ + integrations: IntegrationHealthChip[]; agents: Array<{ id: string; name: string; @@ -36,6 +52,70 @@ export type CommandSummary = { notes: string[]; }; +const DAY_MS = 86_400_000; +const HOUR_MS = 3_600_000; +const TREND_WINDOW_DAYS = 7; + +/** Oldest → newest daily counts, so a sparkline reads left to right. */ +function dailySeries(timestamps: Date[], now: number): number[] { + const buckets = new Array(TREND_WINDOW_DAYS).fill(0); + for (const at of timestamps) { + const age = now - at.getTime(); + if (age < 0 || age >= TREND_WINDOW_DAYS * DAY_MS) continue; + const index = TREND_WINDOW_DAYS - 1 - Math.floor(age / DAY_MS); + buckets[index] = (buckets[index] ?? 0) + 1; + } + return buckets; +} + +/** + * Compares the last 24 hours with the 24 before it. Returns undefined when + * both windows are empty — an arrow drawn over no events is decoration. + */ +function dayOverDayTrend( + timestamps: Date[], + now: number, + label: string, + improving: MetricTrend["improving"], +): MetricTrend | undefined { + let current = 0; + let previous = 0; + for (const at of timestamps) { + const age = now - at.getTime(); + if (age < 0) continue; + if (age < DAY_MS) current += 1; + else if (age < 2 * DAY_MS) previous += 1; + } + if (current === 0 && previous === 0) return undefined; + const delta = current - previous; + return { + delta, + direction: delta > 0 ? "up" : delta < 0 ? "down" : "flat", + label, + improving, + }; +} + +/** Optional-property spread so an absent trend stays absent, not undefined. */ +function trendField(trend: MetricTrend | undefined) { + return trend ? { trend } : {}; +} + +const TASK_STATUS_LABELS: Record = { + backlog: "Backlog", + ready: "Ready", + in_progress: "In progress", + review: "Review", + done: "Done", +}; + +const TASK_PRIORITY_SEVERITY = { + urgent: "critical", + high: "high", + normal: "medium", + low: "low", +} as const; + export async function getCommandSummary( subject: AuthorisationSubject, ): Promise { @@ -133,6 +213,8 @@ export async function getCommandSummary( title: schema.tasks.title, status: schema.tasks.status, priority: schema.tasks.priority, + assignedActorId: schema.tasks.assignedActorId, + dueAt: schema.tasks.dueAt, createdAt: schema.tasks.createdAt, updatedAt: schema.tasks.updatedAt, }) @@ -153,6 +235,90 @@ export async function getCommandSummary( .limit(50) : []; + const now = Date.now(); + const windowStart = new Date(now - TREND_WINDOW_DAYS * DAY_MS); + + // Counted in SQL rather than from the capped list above: the donut claims to + // describe the whole backlog, so it must not silently stop at 50 rows. + const taskStatusCounts = canReadTasks + ? await db + .select({ status: schema.tasks.status, total: count() }) + .from(schema.tasks) + .where( + and( + eq(schema.tasks.organisationId, subject.organisationId), + isNull(schema.tasks.archivedAt), + ), + ) + .groupBy(schema.tasks.status) + : []; + + const recentTasks = canReadTasks + ? await db + .select({ createdAt: schema.tasks.createdAt }) + .from(schema.tasks) + .where( + and( + eq(schema.tasks.organisationId, subject.organisationId), + isNull(schema.tasks.archivedAt), + gte(schema.tasks.createdAt, windowStart), + ), + ) + .limit(2_000) + : []; + + const recentApprovals = canApprove + ? await db + .select({ requestedAt: schema.approvals.requestedAt }) + .from(schema.approvals) + .where( + and( + eq(schema.approvals.organisationId, subject.organisationId), + gte(schema.approvals.requestedAt, windowStart), + ), + ) + .limit(2_000) + : []; + + const recentMissionRuns = canReadWorkflows + ? await db + .select({ + status: schema.governedMissionRuns.status, + createdAt: schema.governedMissionRuns.createdAt, + }) + .from(schema.governedMissionRuns) + .where( + and( + eq( + schema.governedMissionRuns.organisationId, + subject.organisationId, + ), + gte(schema.governedMissionRuns.createdAt, windowStart), + ), + ) + .limit(2_000) + : []; + + const recentAgentRuns = + canReadAgents || canAdmin + ? await db + .select({ + agentId: schema.agentRuns.agentId, + status: schema.agentRuns.status, + startedAt: schema.agentRuns.startedAt, + completedAt: schema.agentRuns.completedAt, + }) + .from(schema.agentRuns) + .where( + and( + eq(schema.agentRuns.organisationId, subject.organisationId), + gte(schema.agentRuns.startedAt, windowStart), + ), + ) + .orderBy(desc(schema.agentRuns.startedAt)) + .limit(5_000) + : []; + // Blocked and approval-stalled pack handoffs are operational debt: an agent // asked for help and nothing is moving. Surface them, do not bury them. const stalledHandoffs = canReadAgents @@ -293,6 +459,188 @@ export async function getCommandSummary( ].filter((s) => s === "degraded" || s === "unavailable").length : 0; + const taskCreatedAt = recentTasks + .map((row) => row.createdAt) + .filter((at): at is Date => at instanceof Date); + const approvalRequestedAt = recentApprovals + .map((row) => row.requestedAt) + .filter((at): at is Date => at instanceof Date); + const failedMissionRunAt = recentMissionRuns + .filter((row) => row.status === "failed") + .map((row) => row.createdAt) + .filter((at): at is Date => at instanceof Date); + const failedAgentRunAt = recentAgentRuns + .filter((row) => toOperationalState(row.status) === "failed") + .map((row) => row.completedAt ?? row.startedAt) + .filter((at): at is Date => at instanceof Date); + // Tiles state a window, not "ever": a count and the series under it have to + // be measuring the same thing or the tile contradicts itself. + const failedAgentRuns24h = failedAgentRunAt.filter( + (at) => now - at.getTime() < DAY_MS, + ).length; + const failedMissionRuns7d = failedMissionRunAt.length; + + const statusOrder = ["backlog", "ready", "in_progress", "review", "done"]; + const taskStatus: TaskStatusSlice[] = taskStatusCounts + .map((row) => ({ + status: row.status, + label: TASK_STATUS_LABELS[row.status] ?? row.status, + count: Number(row.total), + })) + .sort( + (a, b) => + (statusOrder.indexOf(a.status) + 1 || 99) - + (statusOrder.indexOf(b.status) + 1 || 99), + ); + + // 24 hourly buckets ending with the hour in progress. Bucket starts are + // absolute instants; the browser decides how to label them locally. + const firstBucket = Math.floor(now / HOUR_MS) * HOUR_MS - 23 * HOUR_MS; + const runActivity: RunActivityPoint[] = Array.from({ length: 24 }, (_, i) => { + const start = new Date(firstBucket + i * HOUR_MS); + return { + bucket: start.toISOString(), + label: `${String(start.getUTCHours()).padStart(2, "0")}:00`, + completed: 0, + failed: 0, + running: 0, + cancelled: 0, + }; + }); + + type AgentRunTally = { + runs: number; + succeeded: number; + settled: number; + lastRunAt: Date | null; + }; + const runsByAgent = new Map(); + + for (const run of recentAgentRuns) { + const startedAt = run.startedAt; + if (!startedAt) continue; + const state = toOperationalState(run.status); + + const index = Math.floor((startedAt.getTime() - firstBucket) / HOUR_MS); + const point = index >= 0 && index < 24 ? runActivity[index] : undefined; + if (point) { + if (state === "completed") point.completed += 1; + else if (state === "failed") point.failed += 1; + else if (state === "cancelled") point.cancelled += 1; + else point.running += 1; + } + + const tally = runsByAgent.get(run.agentId) ?? { + runs: 0, + succeeded: 0, + settled: 0, + lastRunAt: null, + }; + tally.runs += 1; + if (state === "completed") { + tally.succeeded += 1; + tally.settled += 1; + } else if (state === "failed") { + tally.settled += 1; + } + const seenAt = run.completedAt ?? startedAt; + if (!tally.lastRunAt || seenAt > tally.lastRunAt) tally.lastRunAt = seenAt; + runsByAgent.set(run.agentId, tally); + } + + const agentActivity: AgentActivityRow[] = agentRows + .map((agent) => { + const tally = runsByAgent.get(agent.id); + return { + id: agent.id, + name: agent.name, + status: agent.killSwitch ? "isolated" : agent.status, + runtime: agent.runtime, + runs: tally?.runs ?? 0, + succeeded: tally?.succeeded ?? 0, + // A rate over zero settled runs would be an invented 100%. + successRate: + tally && tally.settled > 0 ? tally.succeeded / tally.settled : null, + lastRunAt: + tally?.lastRunAt?.toISOString() ?? + agent.lastRun?.completedAt ?? + agent.lastRun?.startedAt ?? + null, + }; + }) + .sort((a, b) => b.runs - a.runs || a.name.localeCompare(b.name)) + .slice(0, 6); + + const priorityRank: Record = { + urgent: 0, + high: 1, + normal: 2, + low: 3, + }; + const myTasks: MyTaskRow[] = openTasks + .filter( + (task) => + task.assignedActorId === subject.actorId || + task.assignedActorId === null, + ) + .sort( + (a, b) => + (priorityRank[a.priority] ?? 9) - (priorityRank[b.priority] ?? 9) || + b.updatedAt.getTime() - a.updatedAt.getTime(), + ) + .slice(0, 20) + .map((task) => ({ + id: task.id, + title: task.title, + status: toOperationalState(task.status), + rawStatus: task.status, + priority: task.priority, + severity: + TASK_PRIORITY_SEVERITY[ + task.priority as keyof typeof TASK_PRIORITY_SEVERITY + ] ?? "medium", + sourceSystem: "Muster", + updatedAt: task.updatedAt.toISOString(), + dueAt: task.dueAt?.toISOString() ?? null, + assignedToMe: task.assignedActorId === subject.actorId, + })); + + const integrations: IntegrationHealthChip[] = controlPlane + ? [ + { + id: "kelpie", + name: controlPlane.kelpie.displayName ?? "Kelpie", + health: toHealthState(controlPlane.kelpie.status), + detail: controlPlane.kelpie.lastSyncAt + ? `Synced ${relativeTime(controlPlane.kelpie.lastSyncAt)}` + : "No sync recorded", + }, + { + id: "slack", + name: "Slack", + health: toHealthState(controlPlane.slack.status), + detail: `Workspace ${controlPlane.slack.status}`, + }, + { + id: "mcp", + name: "MCP", + health: toHealthState(controlPlane.mcp.status), + detail: `${controlPlane.mcp.activeInstallations} active installation${ + controlPlane.mcp.activeInstallations === 1 ? "" : "s" + }`, + }, + { + id: "codex", + name: "Codex runtime", + health: toHealthState(controlPlane.codex.status), + detail: + controlPlane.codex.detail ?? + controlPlane.codex.runtime ?? + `Runtime ${controlPlane.codex.status}`, + }, + ] + : []; + const metrics: CommandMetric[] = [ { id: "pending-approvals", @@ -301,6 +649,20 @@ export async function getCommandSummary( tone: pendingApprovals.length > 0 ? "warning" : "default", href: "/approvals", ...(canApprove ? {} : { hint: "Requires workflows.approve" }), + ...(canApprove + ? { + series: dailySeries(approvalRequestedAt, now), + seriesLabel: "Approvals requested per day, last 7 days", + } + : {}), + ...trendField( + dayOverDayTrend( + approvalRequestedAt, + now, + "requested vs previous 24h", + "down", + ), + ), }, { id: "high-priority", @@ -331,17 +693,32 @@ export async function getCommandSummary( }, { id: "failed-agent-runs", - label: "Failed agent runs", - value: failedAgentRuns, - tone: failedAgentRuns > 0 ? "danger" : "default", + label: "Failed agent runs (24h)", + value: failedAgentRuns24h, + tone: failedAgentRuns24h > 0 ? "danger" : "default", href: "/agents", + series: dailySeries(failedAgentRunAt, now), + seriesLabel: "Failed agent runs per day, last 7 days", + ...trendField( + dayOverDayTrend(failedAgentRunAt, now, "failures vs previous 24h", "down"), + ), }, { id: "failed-missions", - label: "Failed mission runs", - value: failedRuns.length, - tone: failedRuns.length > 0 ? "danger" : "default", + label: "Failed mission runs (7d)", + value: failedMissionRuns7d, + tone: failedMissionRuns7d > 0 ? "danger" : "default", href: "/missions", + series: dailySeries(failedMissionRunAt, now), + seriesLabel: "Failed mission runs per day, last 7 days", + ...trendField( + dayOverDayTrend( + failedMissionRunAt, + now, + "failures vs previous 24h", + "down", + ), + ), }, { id: "blocked-handoffs", @@ -356,6 +733,15 @@ export async function getCommandSummary( label: "Open work items", value: openTasks.length, href: "/operations", + ...(canReadTasks + ? { + series: dailySeries(taskCreatedAt, now), + seriesLabel: "Work items opened per day, last 7 days", + } + : {}), + ...trendField( + dayOverDayTrend(taskCreatedAt, now, "opened vs previous 24h", "neutral"), + ), }, ]; @@ -558,6 +944,11 @@ export async function getCommandSummary( attention: attention.slice(0, 40), riskRadar, activity, + taskStatus, + runActivity, + agentActivity, + myTasks, + integrations, agents: agentRows.map((agent) => ({ id: agent.id, name: agent.name, diff --git a/apps/web/types/os.ts b/apps/web/types/os.ts index b257c8c..b3b75ee 100644 --- a/apps/web/types/os.ts +++ b/apps/web/types/os.ts @@ -31,6 +31,20 @@ export type SessionContext = { customer: { id: string; name: string } | null; }; +/** + * Change against the immediately preceding window of the same length. Only + * present where the database can answer the comparison — a tile with no + * honest history shows no trend rather than a decorative arrow. + */ +export type MetricTrend = { + delta: number; + direction: "up" | "down" | "flat"; + /** What the comparison was, e.g. "vs previous 24h". */ + label: string; + /** Which direction is the good news, so colour never guesses. */ + improving: "up" | "down" | "neutral"; +}; + export type CommandMetric = { id: string; label: string; @@ -38,6 +52,62 @@ export type CommandMetric = { hint?: string; tone?: "default" | "warning" | "danger" | "success"; href?: string; + trend?: MetricTrend; + /** Oldest → newest daily counts behind the tile. Omitted when unknown. */ + series?: number[]; + /** What the series counts, for the sparkline's accessible description. */ + seriesLabel?: string; +}; + +/** Live distribution of work items by status — the donut is not a sample. */ +export type TaskStatusSlice = { + status: string; + label: string; + count: number; +}; + +/** Hourly agent-run buckets over the last 24 hours. */ +export type RunActivityPoint = { + /** ISO timestamp for the start of the bucket. */ + bucket: string; + /** Short axis label, local time. */ + label: string; + completed: number; + failed: number; + running: number; + cancelled: number; +}; + +export type AgentActivityRow = { + id: string; + name: string; + status: string; + runtime: string; + runs: number; + succeeded: number; + /** Null when the agent has no completed runs in the window. */ + successRate: number | null; + lastRunAt: string | null; +}; + +export type MyTaskRow = { + id: string; + title: string; + status: OperationalState; + rawStatus: string; + priority: string; + severity: Severity; + sourceSystem: string; + updatedAt: string; + dueAt: string | null; + assignedToMe: boolean; +}; + +export type IntegrationHealthChip = { + id: string; + name: string; + health: HealthState; + detail: string; }; export type AttentionItem = { From fb881d15a8997df453c3db86c3a8808e4b0044f2 Mon Sep 17 00:00:00 2001 From: Justin Middler Date: Thu, 30 Jul 2026 18:12:55 +1000 Subject: [PATCH 2/2] fix(web): address Command dashboard review findings Match approvals badge truncation to the sidebar (99+), replace incomplete tab semantics on the work-queue filter, drop the unused UTC run-activity label, load myTasks from a dedicated mine/unassigned query, make capped history reads deterministic, and extract panel builders with concurrent reads. Chart and panel accessibility nits included. --- apps/web/components/os/charts.tsx | 33 +- apps/web/components/os/company-os-shell.tsx | 2 +- apps/web/components/os/panel.tsx | 4 +- apps/web/features/command/command-view.tsx | 5 +- apps/web/lib/command-summary-domain.ts | 513 ++++++++++++-------- apps/web/types/os.ts | 7 +- 6 files changed, 341 insertions(+), 223 deletions(-) diff --git a/apps/web/components/os/charts.tsx b/apps/web/components/os/charts.tsx index 5cc30d7..85dd5e1 100644 --- a/apps/web/components/os/charts.tsx +++ b/apps/web/components/os/charts.tsx @@ -139,7 +139,6 @@ export const RUN_ACTIVITY_SERIES: Array<{ type ActivityDatum = { bucket: string; - label: string; completed: number; running: number; failed: number; @@ -154,6 +153,9 @@ export function RunActivityChart({ data: ActivityDatum[]; visible: RunActivitySeriesKey[]; }) { + const seriesKeys = RUN_ACTIVITY_SERIES.filter((series) => + visible.includes(series.key), + ); const points = data.map((point) => ({ ...point, axis: new Date(point.bucket).toLocaleTimeString(undefined, { @@ -165,7 +167,30 @@ export function RunActivityChart({ return (
- + + + + + + {seriesKeys.map((series) => ( + + ))} + + + + {points.map((point) => ( + + + {seriesKeys.map((series) => ( + + ))} + + ))} + +
Agent run activity by hour
Time + {series.label} +
{point.axis}{point[series.key]}
+ - {RUN_ACTIVITY_SERIES.filter((series) => - visible.includes(series.key), - ).map((series) => ( + {seriesKeys.map((series) => ( - {pendingApprovals > 9 ? "9+" : pendingApprovals} + {pendingApprovals > 99 ? "99+" : pendingApprovals} ) : null} diff --git a/apps/web/components/os/panel.tsx b/apps/web/components/os/panel.tsx index f3a72d7..dbee96c 100644 --- a/apps/web/components/os/panel.tsx +++ b/apps/web/components/os/panel.tsx @@ -1,6 +1,6 @@ import Link from "next/link"; import { ArrowRight } from "lucide-react"; -import type { ReactNode } from "react"; +import { useId, type ReactNode } from "react"; import { cn } from "@/lib/utils"; /** @@ -23,7 +23,7 @@ export function Panel({ className?: string; bodyClassName?: string; }) { - const headingId = `panel-${title.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`; + const headingId = useId(); return (
View all} >
@@ -350,8 +350,7 @@ export function CommandView() {