diff --git a/apps/web/src/app.css b/apps/web/src/app.css
index 13eea5353..7713d37d1 100644
--- a/apps/web/src/app.css
+++ b/apps/web/src/app.css
@@ -1173,6 +1173,47 @@ select:disabled,
color: var(--primary);
}
+/* Mission Control — pinned above the footer rail as its own row (DESIGN.md),
+ never a 7th button mixed into Routines/Files/Skills/Agents/Plugins/
+ Insights below. The border-bottom is what reads it as a separate band
+ rather than the first row of that rail. */
+.shell-sidebar-mission-control {
+ flex-shrink: 0;
+ padding: 0.35rem;
+ border-bottom: 1px solid var(--border);
+}
+.shell-sidebar-mission-control-row {
+ display: flex;
+ align-items: center;
+ gap: 0.55rem;
+ width: 100%;
+ padding: 0.45rem 0.4rem;
+ border: 0;
+ border-radius: 0;
+ background: transparent;
+ color: var(--foreground);
+ font: inherit;
+ font-weight: 600;
+ cursor: pointer;
+ text-align: left;
+}
+.shell-sidebar-mission-control-row svg {
+ width: 1.05rem;
+ height: 1.05rem;
+ color: var(--muted-foreground);
+}
+.shell-sidebar-mission-control-row:hover,
+.shell-sidebar-mission-control-row[data-active="true"] {
+ background: color-mix(in srgb, var(--foreground) 8%, transparent);
+}
+.shell-sidebar-mission-control-row[data-active="true"] {
+ box-shadow: inset 2px 0 0 0 var(--shell-accent);
+}
+.shell-sidebar-mission-control-row:focus-visible {
+ outline: 1px solid var(--foreground);
+ outline-offset: -1px;
+}
+
/* Footer (reference shape): a Plugins row, then the account row that
anchors the pop-up menu. Both are full-width quiet rows; the account
row has no top margin of its own so the two never read as separate
@@ -3298,3 +3339,146 @@ tr.insights-row-clickable:hover {
font-size: 0.75rem;
color: var(--muted-foreground);
}
+
+/* Mission Control (CL-6488/CL-6489) — stat strip, then a main column
+ (Needs you / In flight) beside a 320px rail (Jump back in / This week),
+ the same "stack under 1100px" rule DESIGN.md gives every right rail. */
+.mission-control-layout {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 20rem;
+ grid-template-areas:
+ "stats stats"
+ "main rail";
+ gap: 1rem;
+ width: 100%;
+}
+.mission-control-layout > .mission-control-stats {
+ grid-area: stats;
+}
+.mission-control-layout > .mission-control-panel {
+ grid-area: main;
+}
+.mission-control-layout > .mission-control-panel + .mission-control-panel {
+ margin-top: 1rem;
+}
+.mission-control-layout > .mission-control-rail {
+ grid-area: rail;
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ min-width: 0;
+}
+@media (max-width: 1100px) {
+ .mission-control-layout {
+ grid-template-columns: 1fr;
+ grid-template-areas:
+ "stats"
+ "main"
+ "rail";
+ }
+}
+.mission-control-layout [data-slot="stat-grid-item"] {
+ border-radius: 0;
+}
+
+.mission-control-panel {
+ padding: 0.9rem 1rem;
+ border: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
+ background: var(--card, var(--background));
+ min-width: 0;
+}
+.mission-control-panel-header {
+ display: flex;
+ align-items: baseline;
+ gap: 0.6rem;
+ margin-bottom: 0.6rem;
+}
+.mission-control-panel-header h2 {
+ margin: 0;
+ font-size: 0.85rem;
+ font-weight: 700;
+}
+.mission-control-hint {
+ font-size: 0.72rem;
+ color: var(--muted-foreground);
+}
+.mission-control-hint-link {
+ margin-left: auto;
+ font-size: 0.75rem;
+ font-weight: 700;
+ color: var(--primary);
+}
+.mission-control-empty-note {
+ margin: 0;
+ font-size: 0.8rem;
+ color: var(--muted-foreground);
+}
+.mission-control-cell-primary {
+ font-size: 0.85rem;
+ font-weight: 700;
+}
+.mission-control-cell-context {
+ font-size: 0.7rem;
+ color: var(--muted-foreground);
+}
+.mission-control-row-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 0.4rem;
+}
+.mission-control-opt {
+ white-space: nowrap;
+}
+@media (max-width: 880px) {
+ .mission-control-opt {
+ display: none;
+ }
+}
+
+.mission-control-rows {
+ display: flex;
+ flex-direction: column;
+ border: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
+}
+.mission-control-jump-row {
+ display: grid;
+ grid-template-columns: auto 1fr auto;
+ align-items: center;
+ gap: 0.6rem;
+ width: 100%;
+ padding: 0.55rem 0.7rem;
+ border: 0;
+ border-bottom: 1px solid color-mix(in srgb, var(--foreground) 8%, transparent);
+ background: transparent;
+ color: var(--foreground);
+ font: inherit;
+ text-align: left;
+ cursor: pointer;
+}
+button.mission-control-jump-row:hover {
+ background: color-mix(in srgb, var(--primary) 8%, transparent);
+}
+.mission-control-rows > .mission-control-jump-row:last-child {
+ border-bottom: 0;
+}
+.mission-control-jump-row svg {
+ width: 1rem;
+ height: 1rem;
+ color: var(--muted-foreground);
+ flex-shrink: 0;
+}
+.mission-control-jump-body {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+}
+.mission-control-jump-when {
+ font-size: 0.72rem;
+ color: var(--muted-foreground);
+ white-space: nowrap;
+}
+.mission-control-week-summary {
+ margin: 0;
+ font-size: 0.85rem;
+ font-weight: 600;
+}
diff --git a/apps/web/src/pages/mission-control-page.tsx b/apps/web/src/pages/mission-control-page.tsx
new file mode 100644
index 000000000..8f0aafa0c
--- /dev/null
+++ b/apps/web/src/pages/mission-control-page.tsx
@@ -0,0 +1,568 @@
+// Mission Control (CL-6488/CL-6489): the bench's dashboard — what needs a
+// decision, what's running, and a way back into recent context. A new
+// top-level route (`/mission-control`), never `/` — `/` stays the Myra
+// land-hop redirect (see routes.tsx's header comment). Every panel here is
+// backed by a query already used elsewhere in this app (needs-you
+// approvals, working tasks, top-level runs, insights activity); nothing on
+// this page is invented. A panel with no honest data source renders an
+// empty state naming what's missing instead of a fabricated number.
+
+import {
+ Badge,
+ Button,
+ PageShell,
+ RichEmptyState,
+ Skeleton,
+ StatGrid,
+ StatGridItem,
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+ formatRelativeTime,
+ toast,
+} from "@corbits/react-ui";
+import type { BadgeTone } from "@corbits/react-ui";
+import { ChatCircleDots, Plus, Robot } from "@corbits/icons";
+import { useMemo, useState } from "react";
+import { useQueryClient } from "@tanstack/react-query";
+
+import { formatUsd } from "@corbits/insights/client";
+import type { Workbench } from "@corbits/chat-ui";
+import type { WorkingTask } from "@corbits/tasks-ui";
+
+import {
+ approveApproval,
+ NeedsYouSchema,
+ rejectApproval,
+ useAPIQuery,
+ type NeedsYouItem,
+} from "../api";
+import { useBench } from "../bench-context";
+import { ActivityResponseSchema, insightsActivityPath } from "../insights-api";
+import { Link } from "../navigation";
+import { tenantKeys } from "../query-client";
+import { NEW_WORKBENCH_PATH } from "../routes";
+import { useBenchActivity } from "../shell/bench-activity";
+import type { RoutineActivityItem } from "../shell/routine-activity";
+import { StageTopBar } from "../shell/stage-top-bar";
+import { workbenchPath } from "../workbench-path";
+
+function dash(value: string | number | null | undefined): string {
+ if (value === null || value === undefined || value === "") return "—";
+ return String(value);
+}
+
+type InFlightRow = {
+ readonly key: string;
+ readonly label: string;
+ readonly context: string;
+ readonly createdAt: string;
+ readonly statusLabel: string;
+ readonly statusTone: BadgeTone;
+ readonly steps: string;
+};
+
+function taskInFlightRow(task: WorkingTask): InFlightRow {
+ const statusLabel = task.status === "needs-you" ? "Needs you" : "Running";
+ return {
+ key: `task:${task.id}`,
+ label: task.prompt,
+ context: `${task.agentName} · task`,
+ createdAt: task.createdAt,
+ statusLabel,
+ statusTone: task.status === "needs-you" ? "accent" : "info",
+ // stepCount is the task's planned total; runIds is how many legs have
+ // actually dispatched so far — a real ratio, not an invented total.
+ steps: `${task.runIds.length}/${task.stepCount}`,
+ };
+}
+
+function routineInFlightRow(routine: RoutineActivityItem): InFlightRow {
+ return {
+ key: `routine:${routine.id}`,
+ label: routine.name,
+ context: "routine",
+ createdAt: routine.startedAt,
+ statusLabel: "Running",
+ statusTone: "info",
+ // The routine feed carries no step count — an honest dash, not a guess.
+ steps: "—",
+ };
+}
+
+/** Every task/routine this bench is actively running right now, newest
+ * first. Queued tasks (accepted, not yet executing) are left out — they
+ * are not yet "in flight." */
+export function computeInFlightRows(
+ workingTasks: readonly WorkingTask[],
+ routines: readonly RoutineActivityItem[],
+): readonly InFlightRow[] {
+ const rows = [
+ ...workingTasks
+ .filter((task) => task.status !== "queued")
+ .map(taskInFlightRow),
+ ...routines
+ .filter((routine) => routine.status === "running")
+ .map(routineInFlightRow),
+ ];
+ return rows.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
+}
+
+type JumpBackRow = {
+ readonly key: string;
+ readonly icon: "chat" | "agent";
+ readonly label: string;
+ readonly context: string;
+ readonly when: string;
+ readonly onSelect?: () => void;
+};
+
+/** Recent conversations and agents to jump back into — real bench activity
+ * (workbenches, chats, visible agent definitions), sorted by their own
+ * recency field. Nothing here is invented: a workbench with no recorded
+ * activity timestamp is left out rather than given a fake one. */
+export function computeJumpBackRows(
+ workbenches: readonly Workbench[],
+ chats: readonly Workbench[],
+ agents: readonly { id: string; name: string; createdAt: string }[],
+ navigate: (to: string) => void,
+ limit = 4,
+): readonly JumpBackRow[] {
+ const conversations = [...workbenches, ...chats]
+ .filter(
+ (bench): bench is Workbench & { lastActivityAt: string } =>
+ bench.lastActivityAt !== undefined,
+ )
+ .map((bench) => ({
+ key: `bench:${bench.id}`,
+ icon: "chat" as const,
+ label: bench.title,
+ context: bench.kind === "chat" ? "chat" : "workbench",
+ when: bench.lastActivityAt,
+ onSelect: () => navigate(workbenchPath(bench.id)),
+ }));
+ const agentRows = agents.map((agent) => ({
+ key: `agent:${agent.id}`,
+ icon: "agent" as const,
+ label: agent.name,
+ context: "agent",
+ when: agent.createdAt,
+ }));
+ return [...conversations, ...agentRows]
+ .sort((a, b) => Date.parse(b.when) - Date.parse(a.when))
+ .slice(0, limit);
+}
+
+function ApprovalRow({
+ item,
+ tenantId,
+}: {
+ readonly item: NeedsYouItem;
+ readonly tenantId: string;
+}) {
+ const queryClient = useQueryClient();
+ const [pending, setPending] = useState<"approve" | "deny" | null>(null);
+
+ async function resolve(action: "approve" | "deny") {
+ setPending(action);
+ try {
+ if (action === "approve") await approveApproval(tenantId, item.id);
+ else await rejectApproval(tenantId, item.id);
+ await queryClient.invalidateQueries({
+ queryKey: tenantKeys.needsYou(tenantId),
+ });
+ } catch (cause) {
+ toast(
+ cause instanceof Error
+ ? cause.message
+ : `Couldn't ${action === "approve" ? "approve" : "deny"} that request.`,
+ );
+ } finally {
+ setPending(null);
+ }
+ }
+
+ return (
+
+
+ {item.headline}
+
+ {item.agentName}
+
+ {formatRelativeTime(item.createdAt)}
+
+
+
+
+
+
+
+
+ );
+}
+
+export function MissionControlRoute({
+ navigate,
+}: {
+ readonly navigate: (to: string) => void;
+}) {
+ const { selectedTenantId: tenantId } = useBench();
+ const needsYouQuery = useAPIQuery(
+ tenantId === null ? "" : `/api/tenants/${tenantId}/approvals/needs-you`,
+ NeedsYouSchema,
+ );
+ const activity = useBenchActivity(tenantId);
+ const activityRange = useMemo(
+ () => ({
+ from: new Date(Date.now() - 8 * 24 * 60 * 60 * 1000).toISOString(),
+ to: new Date().toISOString(),
+ }),
+ [],
+ );
+ const insightsActivity = useAPIQuery(
+ tenantId === null ? "" : insightsActivityPath(tenantId, activityRange),
+ ActivityResponseSchema,
+ );
+
+ const needsYouItems =
+ needsYouQuery.kind === "ready" ? needsYouQuery.data.items : null;
+ const oldestWaitingAt =
+ needsYouItems !== null && needsYouItems.length > 0
+ ? needsYouItems.reduce((oldest, item) =>
+ Date.parse(item.createdAt) < Date.parse(oldest.createdAt)
+ ? item
+ : oldest,
+ ).createdAt
+ : null;
+
+ const inFlightRows =
+ activity.kind === "ready"
+ ? computeInFlightRows(activity.workingTasks, activity.routines)
+ : [];
+ const activeRunsCount =
+ activity.kind === "ready"
+ ? activity.workingTasks.filter((task) => task.status === "running")
+ .length +
+ activity.routines.filter((routine) => routine.status === "running")
+ .length
+ : null;
+
+ const jumpBackRows =
+ activity.kind === "ready"
+ ? computeJumpBackRows(
+ activity.workbenches,
+ activity.chats,
+ activity.agents,
+ navigate,
+ )
+ : [];
+
+ const days =
+ insightsActivity.kind === "ready" ? insightsActivity.data.days : [];
+ const todayKey = new Date().toISOString().slice(0, 10);
+ const today = days.find((day) => day.day === todayKey) ?? null;
+ const priorDays = days.filter((day) => day.day !== todayKey);
+ const avgTurns =
+ priorDays.length > 0
+ ? priorDays.reduce((sum, day) => sum + day.turns, 0) / priorDays.length
+ : null;
+ const todaySpend =
+ today === null
+ ? null
+ : today.byModel.some((model) => model.costUsd === null)
+ ? null
+ : today.byModel.reduce((sum, model) => sum + (model.costUsd ?? 0), 0);
+
+ return (
+
+
navigate(NEW_WORKBENCH_PATH)}
+ >
+ New bench
+
+ }
+ />
+
+
+
+
+ 0
+ ? "live now"
+ : "nothing running"
+ }
+ />
+ 0}
+ sub={
+ oldestWaitingAt !== null
+ ? `oldest ${formatRelativeTime(oldestWaitingAt)}`
+ : "all caught up"
+ }
+ />
+
+
+
+
+
+
+
Needs you
+
+ approvals block agents until you act
+
+
+ {needsYouQuery.kind === "loading" ? (
+
+ ) : null}
+ {needsYouQuery.kind === "error" ? (
+
+ ) : null}
+ {needsYouQuery.kind === "unauthenticated" ? (
+
+ ) : null}
+ {needsYouItems !== null && needsYouItems.length === 0 ? (
+
+ ) : null}
+ {needsYouItems !== null && needsYouItems.length > 0 ? (
+
+
+
+ Request
+
+ From
+
+
+ Waiting
+
+
+
+
+
+ {tenantId !== null &&
+ needsYouItems.map((item) => (
+
+ ))}
+
+
+ ) : null}
+
+
+
+
+
In flight
+ live
+
+ {activity.kind === "loading" ? (
+
+ ) : null}
+ {activity.kind === "error" ? (
+
+ ) : null}
+ {activity.kind !== "loading" &&
+ activity.kind !== "error" &&
+ inFlightRows.length === 0 ? (
+
+ ) : null}
+ {inFlightRows.length > 0 ? (
+
+
+
+ Run
+
+ Elapsed
+
+
+ Steps
+
+
+
+
+
+ {inFlightRows.map((row) => (
+
+
+
+ {row.label}
+
+
+
+ {row.context}
+
+
+
+ {formatRelativeTime(row.createdAt)}
+
+
+ {row.steps}
+
+
+ {row.statusLabel}
+
+
+ ))}
+
+
+ ) : null}
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/routes.tsx b/apps/web/src/routes.tsx
index 12e444282..2a5bccae2 100644
--- a/apps/web/src/routes.tsx
+++ b/apps/web/src/routes.tsx
@@ -53,6 +53,9 @@ import {
const HomeRoute = lazy(async () => ({
default: (await import("./pages/home-page")).HomeRoute,
}));
+const MissionControlRoute = lazy(async () => ({
+ default: (await import("./pages/mission-control-page")).MissionControlRoute,
+}));
const NewWorkbenchPickerRoute = lazy(async () => ({
default: (await import("./pages/new-workbench-picker"))
.NewWorkbenchPickerRoute,
@@ -111,6 +114,14 @@ export const ONBOARDING_PATH = "/onboarding";
/** Settings path — sidebar footer + settings page. */
export const SETTINGS_PATH = "/settings";
+/** Mission Control — the bench's dashboard (CL-6488/CL-6489). Pinned above
+ * the sidebar's footer rail as its own row (see DESIGN.md's Shell &
+ * Navigation section), reachable by direct URL and the command palette
+ * like everything else, but deliberately off `NAV_ROUTES`: it isn't a
+ * roster to browse, it's the one destination the sidebar always pins in
+ * view, the same way Plugins stays reachable without joining that list. */
+export const MISSION_CONTROL_PATH = "/mission-control";
+
/** The template picker (CL-6342) — every "+ New workbench" affordance
* (sidebar, command palette) hops here first; picking a row is what
* actually mints the workbench. Not in `NAV_ROUTES`: it has no sidebar
@@ -247,6 +258,14 @@ export const APP_ROUTES: readonly AppRoute[] = [
render: () => ,
hasStageTopBar: false,
},
+ {
+ path: MISSION_CONTROL_PATH,
+ label: "Mission Control",
+ icon: ,
+ render: (_path: string, navigate: (to: string) => void) => (
+
+ ),
+ },
{
path: NEW_WORKBENCH_PATH,
label: "New workbench",
diff --git a/apps/web/src/shell/sidebar.tsx b/apps/web/src/shell/sidebar.tsx
index b964f788b..65f555dff 100644
--- a/apps/web/src/shell/sidebar.tsx
+++ b/apps/web/src/shell/sidebar.tsx
@@ -43,6 +43,7 @@ import {
SignOut,
Repeat,
SlidersHorizontal,
+ SquaresFour,
} from "@corbits/icons";
import { useMemo } from "react";
@@ -56,7 +57,12 @@ import webPackage from "../../package.json";
import { useAPIQuery } from "../api";
import { useBench } from "../bench-context";
import { OverallUsageSchema, insightsUsagePath } from "../insights-api";
-import { matchesRoute, NEW_WORKBENCH_PATH, SETTINGS_PATH } from "../routes";
+import {
+ matchesRoute,
+ MISSION_CONTROL_PATH,
+ NEW_WORKBENCH_PATH,
+ SETTINGS_PATH,
+} from "../routes";
import type { SessionUser } from "../session";
import { SidebarBrandMark } from "./brand-mark";
import { initialsOf } from "./docks";
@@ -142,6 +148,27 @@ export function Sidebar({
+ {/* Mission Control is pinned above the footer rail as its own row
+ (DESIGN.md's Shell & Navigation) — not a 7th button inside the
+ rail below, which stays Routines/Files/Skills/Agents/Plugins/
+ Insights exactly as it was. */}
+
+
+
+
{/* Footer order: Routines, Files, Skills, Agents, Plugins, Insights,
then the account row anchors everything else (weekly usage,
diff --git a/apps/web/test/mission-control-page.test.tsx b/apps/web/test/mission-control-page.test.tsx
new file mode 100644
index 000000000..0b811e3c2
--- /dev/null
+++ b/apps/web/test/mission-control-page.test.tsx
@@ -0,0 +1,239 @@
+import { afterEach, describe, expect, test } from "bun:test";
+import { act } from "react";
+import { createRoot } from "react-dom/client";
+import type { Root } from "react-dom/client";
+
+import type { Workbench } from "@corbits/chat-ui";
+import type { WorkingTask } from "@corbits/tasks-ui";
+
+import { BenchContext, type BenchState } from "../src/bench-context";
+import { NavigationProvider } from "../src/navigation";
+import {
+ computeInFlightRows,
+ computeJumpBackRows,
+ MissionControlRoute,
+} from "../src/pages/mission-control-page";
+import type { RoutineActivityItem } from "../src/shell/routine-activity";
+import { TestQueryProvider } from "./test-query-provider";
+
+const realFetch = globalThis.fetch;
+
+afterEach(() => {
+ globalThis.fetch = realFetch;
+});
+
+function workingTask(overrides: Partial): WorkingTask {
+ return {
+ id: "task_1",
+ definitionId: "def_1",
+ workbenchId: null,
+ agentName: "Research Analyst",
+ prompt: "Summarize 3 threads",
+ modelPreference: null,
+ status: "running",
+ runId: "run_1",
+ runIds: ["run_1"],
+ stepCount: 6,
+ resultMailId: null,
+ createdAt: "2026-08-19T10:00:00.000Z",
+ completedAt: null,
+ ...overrides,
+ };
+}
+
+function routine(overrides: Partial): RoutineActivityItem {
+ return {
+ id: "rtn_1",
+ name: "Weekly digest",
+ status: "running",
+ startedAt: "2026-08-19T09:00:00.000Z",
+ ...overrides,
+ };
+}
+
+function workbench(overrides: Partial): Workbench {
+ return {
+ id: "wb_1",
+ title: "Launch plan",
+ kind: "workbench",
+ pinned: false,
+ participants: [],
+ lastActivityAt: "2026-08-19T09:00:00.000Z",
+ ...overrides,
+ };
+}
+
+describe("computeInFlightRows", () => {
+ test("drops queued tasks — nothing has started executing yet", () => {
+ const rows = computeInFlightRows([workingTask({ status: "queued" })], []);
+ expect(rows).toEqual([]);
+ });
+
+ test("keeps running and needs-you tasks, and only running routines", () => {
+ const rows = computeInFlightRows(
+ [
+ workingTask({ id: "t1", status: "running" }),
+ workingTask({ id: "t2", status: "needs-you" }),
+ ],
+ [
+ routine({ id: "r1", status: "running" }),
+ routine({ id: "r2", status: "deployed" }),
+ ],
+ );
+ expect(rows.map((row) => row.key)).toEqual([
+ "task:t1",
+ "task:t2",
+ "routine:r1",
+ ]);
+ });
+
+ test("sorts newest first and derives an honest steps ratio from real run ids", () => {
+ const rows = computeInFlightRows(
+ [
+ workingTask({
+ id: "old",
+ createdAt: "2026-08-19T08:00:00.000Z",
+ runIds: ["a", "b"],
+ stepCount: 9,
+ }),
+ workingTask({ id: "new", createdAt: "2026-08-19T11:00:00.000Z" }),
+ ],
+ [],
+ );
+ expect(rows.map((row) => row.key)).toEqual(["task:new", "task:old"]);
+ expect(rows[1]?.steps).toBe("2/9");
+ });
+});
+
+describe("computeJumpBackRows", () => {
+ test("drops a workbench with no recorded activity instead of inventing a time", () => {
+ // `lastActivityAt` is optional, and under `exactOptionalPropertyTypes`
+ // "no recorded activity" means the key is absent, not set to undefined.
+ const silent: Workbench = {
+ id: "silent",
+ title: "Launch plan",
+ kind: "workbench",
+ pinned: false,
+ participants: [],
+ };
+ const rows = computeJumpBackRows([silent], [], [], () => undefined);
+ expect(rows).toEqual([]);
+ });
+
+ test("merges workbenches, chats, and agents, newest first, capped at the limit", () => {
+ const rows = computeJumpBackRows(
+ [workbench({ id: "wb1", lastActivityAt: "2026-08-19T09:00:00.000Z" })],
+ [
+ workbench({
+ id: "chat1",
+ kind: "chat",
+ lastActivityAt: "2026-08-19T11:00:00.000Z",
+ }),
+ ],
+ [
+ {
+ id: "agent1",
+ name: "Research Analyst",
+ createdAt: "2026-08-19T10:00:00.000Z",
+ },
+ ],
+ () => undefined,
+ 2,
+ );
+ expect(rows.map((row) => row.key)).toEqual(["bench:chat1", "agent:agent1"]);
+ expect(rows[0]?.context).toBe("chat");
+ });
+});
+
+const benchState: BenchState = {
+ memberships: { kind: "ready", data: { data: [], nextCursor: null } },
+ selectedTenantId: "tnt_bench_a",
+ selectedPrincipalId: "prn_bench_a",
+ selectTenant: () => {},
+ onBenchCreated: () => {},
+};
+
+function stubEmptyBenchFetch(): void {
+ globalThis.fetch = ((input: RequestInfo | URL) => {
+ const url = typeof input === "string" ? input : input.toString();
+ if (url.includes("/approvals/needs-you")) {
+ return Promise.resolve(
+ new Response(JSON.stringify({ items: [] }), {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ }),
+ );
+ }
+ if (url.includes("/insights/activity")) {
+ return Promise.resolve(
+ new Response(JSON.stringify({ days: [] }), {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ }),
+ );
+ }
+ if (url.includes("/tasks")) {
+ return Promise.resolve(
+ new Response(JSON.stringify({ items: [] }), {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ }),
+ );
+ }
+ if (url.includes("/agent-definitions/visible")) {
+ return Promise.resolve(
+ new Response(JSON.stringify({ definitions: [] }), {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ }),
+ );
+ }
+ return Promise.resolve(
+ new Response(JSON.stringify({ items: [], data: [], nextCursor: null }), {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ }),
+ );
+ }) as typeof fetch;
+}
+
+describe("MissionControlRoute", () => {
+ let container: HTMLDivElement | null = null;
+ let root: Root | null = null;
+
+ afterEach(() => {
+ if (root !== null) {
+ act(() => root?.unmount());
+ root = null;
+ }
+ container?.remove();
+ container = null;
+ });
+
+ test("renders honest empty states with nothing waiting and nothing running", async () => {
+ stubEmptyBenchFetch();
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ root = createRoot(container);
+ await act(async () => {
+ root?.render(
+
+ undefined}>
+
+ undefined} />
+
+
+ ,
+ );
+ });
+ for (let count = 0; count < 5; count += 1) {
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
+ }
+ expect(container.textContent).toContain("Mission Control");
+ expect(container.textContent).toContain("Nothing waiting on you");
+ expect(container.textContent).toContain("Nothing running right now");
+ expect(container.textContent).toContain("Nothing recent yet.");
+ });
+});
diff --git a/apps/web/test/routes.test.tsx b/apps/web/test/routes.test.tsx
index 7d04807a1..623466e8a 100644
--- a/apps/web/test/routes.test.tsx
+++ b/apps/web/test/routes.test.tsx
@@ -144,6 +144,7 @@ describe("route table", () => {
test("covers every screen the app can route to", () => {
expect(APP_ROUTES.map((route) => route.path)).toEqual([
"/",
+ "/mission-control",
"/new",
"/w",
"/inbox",