diff --git a/apps/web/src/command-center/CommandCenterHome.logic.test.ts b/apps/web/src/command-center/CommandCenterHome.logic.test.ts index d59bebdb5b70..afa700b821c8 100644 --- a/apps/web/src/command-center/CommandCenterHome.logic.test.ts +++ b/apps/web/src/command-center/CommandCenterHome.logic.test.ts @@ -16,6 +16,8 @@ import { projectBootstrap, projectEnvironmentProjects, routeReceiptFromResult, + routeReceiptFromRoute, + routeReceiptFromTimelineEntry, routeTimelineMessage, timelineMessages, waitForRouteReceiptPaint, @@ -347,6 +349,37 @@ describe("CommandCenterHome projection", () => { expect(message).toMatchObject({ linkedRunId: "run-1", linkedThreadId: "thread-1" }); }); + it("maps a persisted approval-required route from its authoritative run status", () => { + const projects = projectEnvironmentProjects(PROJECTS, BOOTSTRAP); + const options = buildRouteOptions(BOOTSTRAP, projects, PROVIDERS); + const display = { projects, options }; + const route = RESULT.route; + + expect(route.approvalRequired).toBe(true); + expect(routeReceiptFromRoute(route, "waiting_approval", BOOTSTRAP, display).status).toBe( + "waiting-approval", + ); + expect(routeReceiptFromRoute(route, "running", BOOTSTRAP, display).status).toBe("running"); + expect(routeReceiptFromRoute(route, "succeeded", BOOTSTRAP, display).status).toBe("complete"); + expect(routeReceiptFromRoute(route, "failed", BOOTSTRAP, display).status).toBe("failed"); + expect(routeReceiptFromRoute(route, "canceled", BOOTSTRAP, display).status).toBe("failed"); + expect(routeReceiptFromRoute(route, "waiting", BOOTSTRAP, display).status).toBe( + "waiting-approval", + ); + expect(routeReceiptFromRoute(route, "queued", BOOTSTRAP, display).status).toBe("running"); + + const entry = { route } as unknown as CommandCenterTimelineEntry; + expect( + routeReceiptFromTimelineEntry({ ...entry, status: "succeeded" }, BOOTSTRAP, display).status, + ).toBe("complete"); + expect( + routeReceiptFromTimelineEntry({ ...entry, status: "failed" }, BOOTSTRAP, display).status, + ).toBe("failed"); + expect( + routeReceiptFromTimelineEntry({ ...entry, status: "running" }, BOOTSTRAP, display).status, + ).toBe("running"); + }); + it("waits through a complete browser paint boundary before starting work", async () => { const frames: Array<() => void> = []; const waiting = waitForRouteReceiptPaint((callback) => frames.push(callback)); @@ -424,6 +457,11 @@ describe("CommandCenterHome projection", () => { author: "system", authorLabel: "Route receipt", linkedThreadId: "thread-durable", + receipt: { + providerName: "provider-1", + modelName: "model-1", + status: "running", + }, }); expect(messages[1]?.body).toContain("Studio App · project-studio"); expect(messages[1]?.body).toContain("provider-1 · model-1"); diff --git a/apps/web/src/command-center/CommandCenterHome.logic.ts b/apps/web/src/command-center/CommandCenterHome.logic.ts index 52c27e0cd247..a5f2ad865b4d 100644 --- a/apps/web/src/command-center/CommandCenterHome.logic.ts +++ b/apps/web/src/command-center/CommandCenterHome.logic.ts @@ -510,7 +510,25 @@ export function waitForRouteReceiptPaint( }); } -function routeReceiptFromRoute( +function routeStatusFromRunStatus(runStatus: RunStatus): CommandCenterRouteReceipt["status"] { + switch (runStatus) { + case "succeeded": + return "complete"; + case "failed": + case "canceled": + return "failed"; + case "waiting": + case "waiting_approval": + return "waiting-approval"; + case "queued": + case "running": + return "running"; + } + runStatus satisfies never; + throw new Error("Unsupported run status"); +} + +export function routeReceiptFromRoute( route: RouteDecision, runStatus: RunStatus, bootstrap: CommandCenterBootstrap | null, @@ -525,14 +543,8 @@ function routeReceiptFromRoute( (provider) => provider.id === route.providerId, ); const routedModel = display.options.models.find((model) => model.id === route.modelId); - const status = - route.status === "blocked" - ? ("blocked" as const) - : route.approvalRequired || runStatus === "waiting_approval" - ? ("waiting-approval" as const) - : runStatus === "succeeded" - ? ("complete" as const) - : ("running" as const); + const status: CommandCenterRouteReceipt["status"] = + route.status === "blocked" ? "blocked" : routeStatusFromRunStatus(runStatus); const reasonSummary = route.reasons.length > 0 ? route.reasons.join(" ") @@ -642,7 +654,11 @@ export function routeTimelineMessage( ? "The route was blocked." : receipt.status === "waiting-approval" ? "The route is waiting for approval." - : "The route is active."; + : receipt.status === "complete" + ? "The run completed." + : receipt.status === "failed" + ? "The run failed." + : "The route is active."; const linkedContext = [receipt.repositoryName, receipt.projectName] .filter((value): value is string => value !== undefined) .join(" · "); @@ -657,6 +673,7 @@ export function routeTimelineMessage( body: `${routeState} ${receipt.spaceName}${linkedContext ? ` · ${linkedContext}` : ""} · ${receipt.providerName} · ${receipt.modelName}. ${receipt.summary} ${capabilitySummary}`, createdAtLabel, linkedRunId: result.run.id, + receipt, ...(result.run.threadId === undefined ? {} : { linkedThreadId: result.run.threadId }), }; } diff --git a/apps/web/src/command-center/CommandCenterHome.tsx b/apps/web/src/command-center/CommandCenterHome.tsx index 9e9f66e85c66..c746280ce723 100644 --- a/apps/web/src/command-center/CommandCenterHome.tsx +++ b/apps/web/src/command-center/CommandCenterHome.tsx @@ -25,6 +25,7 @@ import { projectBootstrap, projectEnvironmentProjects, routeReceiptFromResult, + routeReceiptFromTimelineEntry, routeTimelineMessage, timelineMessages, waitForRouteReceiptPaint, @@ -82,6 +83,11 @@ function failureMessage(failure: unknown): string { : "Command Center could not submit the command."; } +interface LastRouteReceipt { + readonly receipt: CommandCenterRouteReceipt; + readonly runId?: string | undefined; +} + export function CommandCenterHome() { const navigate = useNavigate(); const environmentId = usePrimaryEnvironmentId(); @@ -93,7 +99,7 @@ export function CommandCenterHome() { const [draft, setDraft] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); const [resolvingNeedsYouId, setResolvingNeedsYouId] = useState(); - const [lastReceipt, setLastReceipt] = useState(null); + const [lastReceipt, setLastReceipt] = useState(null); const selectedSpaceId = routeSelection.spaceId === undefined ? undefined : SpaceId.make(routeSelection.spaceId); const bootstrapQuery = useEnvironmentQuery( @@ -181,7 +187,15 @@ export function CommandCenterHome() { [authoritativeMessages, optimisticMessages], ); const routeReceipt = useMemo(() => { - if (lastReceipt !== null) return lastReceipt; + if (lastReceipt !== null) { + const timelineEntry = + lastReceipt.runId === undefined + ? undefined + : timelineQuery.data?.entries.find((entry) => entry.runId === lastReceipt.runId); + return timelineEntry === undefined + ? lastReceipt.receipt + : routeReceiptFromTimelineEntry(timelineEntry, bootstrap, routeDisplay); + } if (timelineQuery.error !== null || eventQuery.error !== null) { return blockedReceipt("The durable Command timeline is not available right now."); } @@ -200,6 +214,7 @@ export function CommandCenterHome() { lastReceipt, routeDisplay, routeSelection, + timelineQuery.data?.entries, timelineQuery.error, ]); @@ -421,7 +436,7 @@ export function CommandCenterHome() { if (result._tag === "Success") { const receipt = routeReceiptFromResult(result.value, bootstrap, routeDisplay); - setLastReceipt(receipt); + setLastReceipt({ receipt, runId: result.value.run.id }); setActiveConversationId(result.value.run.id); setOptimisticMessages((current) => [ ...current, @@ -454,7 +469,7 @@ export function CommandCenterHome() { } } else { const message = failureMessage(squashAtomCommandFailure(result)); - setLastReceipt(blockedReceipt(message)); + setLastReceipt({ receipt: blockedReceipt(message) }); setOptimisticMessages((current) => [ ...current, { diff --git a/apps/web/src/command-center/CommandCenterShell.test.tsx b/apps/web/src/command-center/CommandCenterShell.test.tsx index 0d08dba5e82e..e58741fa1745 100644 --- a/apps/web/src/command-center/CommandCenterShell.test.tsx +++ b/apps/web/src/command-center/CommandCenterShell.test.tsx @@ -1,8 +1,16 @@ import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it, vi } from "vite-plus/test"; -import { CommandCenterShell, shouldSubmitCommandComposerOnKeyDown } from "./CommandCenterShell"; -import type { CommandCenterShellProps } from "./types"; +import { + CommandCenterShell, + Messages, + shouldSubmitCommandComposerOnKeyDown, +} from "./CommandCenterShell"; +import type { + CommandCenterMessage, + CommandCenterRouteReceipt, + CommandCenterShellProps, +} from "./types"; const FIXTURE: CommandCenterShellProps = { activeConversationId: "conversation-1", @@ -177,6 +185,82 @@ describe("CommandCenterShell", () => { ).toBe(false); }); + it("renders each historical route receipt from its own message state", () => { + const completeReceipt: CommandCenterRouteReceipt = { + ...FIXTURE.routeReceipt, + spaceName: "Completed Space", + status: "complete", + summary: "The completed run settled successfully.", + }; + const runningReceipt: CommandCenterRouteReceipt = { + ...FIXTURE.routeReceipt, + spaceName: "Running Space", + status: "running", + summary: "The current run is still active.", + }; + const messages: readonly CommandCenterMessage[] = [ + { + id: "completed-route", + author: "system", + authorLabel: "Route receipt", + body: "The run completed.", + createdAtLabel: "10:30 AM", + receipt: completeReceipt, + }, + { + id: "running-route", + author: "system", + authorLabel: "Route receipt", + body: "The route is active.", + createdAtLabel: "10:31 AM", + receipt: runningReceipt, + }, + ]; + + const html = renderToStaticMarkup(); + const runningRowStart = html.indexOf(">Working<"); + const completedRow = html.slice(0, runningRowStart); + const runningRow = html.slice(runningRowStart); + + expect(runningRowStart).toBeGreaterThan(-1); + expect(completedRow).toContain(">Work complete<"); + expect(completedRow).toContain(">Complete<"); + expect(completedRow).toContain("Completed Space"); + expect(completedRow).not.toContain(">Working<"); + expect(completedRow).not.toContain(">Running<"); + expect(runningRow).toContain(">Working<"); + expect(runningRow).toContain(">Running<"); + expect(runningRow).toContain("Running Space"); + }); + + it("renders failed route receipts as destructive and inactive", () => { + const failedReceipt: CommandCenterRouteReceipt = { + ...FIXTURE.routeReceipt, + status: "failed", + summary: "The run stopped with an error.", + }; + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain(">Run failed<"); + expect(html).toContain(">Failed<"); + expect(html).toContain("bg-destructive"); + expect(html).not.toContain("animate-pulse"); + }); + it("renders the T3 command surface and visible route receipt", () => { const html = renderToStaticMarkup(); diff --git a/apps/web/src/command-center/CommandCenterShell.tsx b/apps/web/src/command-center/CommandCenterShell.tsx index 45d1529649ae..3a82b3e34c89 100644 --- a/apps/web/src/command-center/CommandCenterShell.tsx +++ b/apps/web/src/command-center/CommandCenterShell.tsx @@ -128,9 +128,11 @@ function RouteReceipt({ receipt }: { readonly receipt: CommandCenterRouteReceipt ? "Waiting for approval" : receipt.status === "complete" ? "Work complete" - : receipt.status === "running" - ? "Working" - : "Route ready"} + : receipt.status === "failed" + ? "Run failed" + : receipt.status === "running" + ? "Working" + : "Route ready"} {receipt.spaceName} @@ -143,7 +145,7 @@ function RouteReceipt({ receipt }: { readonly receipt: CommandCenterRouteReceipt "size-1.5 rounded-full", isActive ? "animate-pulse bg-info" : "bg-success", receipt.status === "waiting-approval" && "bg-warning", - receipt.status === "blocked" && "bg-destructive", + (receipt.status === "blocked" || receipt.status === "failed") && "bg-destructive", )} /> {receipt.status === "blocked" @@ -152,9 +154,11 @@ function RouteReceipt({ receipt }: { readonly receipt: CommandCenterRouteReceipt ? "Approval" : receipt.status === "complete" ? "Complete" - : receipt.status === "running" - ? "Running" - : "Ready"} + : receipt.status === "failed" + ? "Failed" + : receipt.status === "running" + ? "Running" + : "Ready"} @@ -207,7 +211,7 @@ function RouteReceipt({ receipt }: { readonly receipt: CommandCenterRouteReceipt ); } -function Messages({ +export function Messages({ messages, receipt, onOpenLinkedThread, @@ -250,7 +254,7 @@ function Messages({ if (message.authorLabel === "Route receipt") { return (
- +
); } diff --git a/apps/web/src/command-center/types.ts b/apps/web/src/command-center/types.ts index 23aeb193384f..8d48c586bcc5 100644 --- a/apps/web/src/command-center/types.ts +++ b/apps/web/src/command-center/types.ts @@ -39,6 +39,7 @@ export interface CommandCenterMessage { readonly authorLabel?: string | undefined; readonly linkedRunId?: string | undefined; readonly linkedThreadId?: string | undefined; + readonly receipt?: CommandCenterRouteReceipt | undefined; } export type CommandCenterRisk = "low" | "reversible" | "approval-required" | "blocked"; @@ -47,6 +48,7 @@ export type CommandCenterRouteStatus = | "running" | "waiting-approval" | "complete" + | "failed" | "blocked"; export type CommandCenterRouteSource =