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",