diff --git a/apps/web/src/build-view.css b/apps/web/src/build-view.css index 9e5de6216..e9c513109 100644 --- a/apps/web/src/build-view.css +++ b/apps/web/src/build-view.css @@ -54,21 +54,196 @@ font-variant-numeric: tabular-nums; } -.build-activity { +/* + * The build's status card: title and count, what the agent is doing, one segment per task, then who + * started it, the machine it runs on and the action, under a rule that runs edge to edge. + */ +.build-card { + display: grid; + gap: calc(var(--spacing) * 3); + padding: calc(var(--spacing) * 3) calc(var(--spacing) * 4); + border: var(--edge-width) solid var(--color-edge); + border-radius: var(--radius-xl); + background: var(--color-inset); + font-size: var(--text-sm); +} + +.build-card-top { display: flex; - flex-wrap: wrap; - gap: var(--spacing) calc(var(--spacing) * 3); - margin: calc(var(--spacing) * 2) 0 0; + min-width: 0; + align-items: center; + gap: calc(var(--spacing) * 2); + margin: 0; +} + +.build-card-title { + min-width: 0; + flex: 1; + color: var(--color-text-primary); + font-weight: var(--font-weight-medium); +} + +.build-card-count { + flex: none; + color: var(--color-text-tertiary); + font-size: var(--text-xs); + font-variant-numeric: tabular-nums; +} + +.build-card-detail { + display: flex; + min-width: 0; + margin: 0; color: var(--color-text-secondary); font-size: var(--text-xs); - overflow-wrap: anywhere; } -.build-activity time { +/* The activity line sits close under the title, above the segments. */ +.build-card-top + .build-card-detail { + margin-block-start: calc(var(--spacing) * -2); +} + +.build-card-ticker { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.build-card-check { + width: var(--icon-size-default); + height: var(--icon-size-default); + flex: none; + color: var(--color-success-icon); +} + +/* A build that is live breathes; the words beside it say what it is doing. */ +@keyframes build-card-breathe { + 50% { + opacity: var(--opacity-quiet); + } +} + +/* One segment per task: green when done, and green with a soft lighter sweep while it runs. */ +@keyframes build-card-sweep { + from { + background-position: 100% 0; + } + to { + background-position: -100% 0; + } +} + +.build-card-segments { + display: flex; + gap: var(--spacing); +} + +.build-card-segments > span { + height: var(--spacing); + flex: 1; + border-radius: var(--radius-full); + background: var(--color-selected-strong); + transition: background-color var(--duration-base) var(--ease-out); +} + +.build-card-segments > span:is([data-state="completed"], [data-state="in_progress"]) { + background: var(--color-success-graphic); +} + +.build-card-segments > span[data-state="blocked"] { + background: var(--color-warning-graphic); +} + +@media (prefers-reduced-motion: no-preference) { + .build-card-spin { + animation: button-loader-spin calc(var(--duration-base) * 4) linear infinite; + } + + .build-card-ticker { + animation: build-fade var(--duration-linger) var(--ease-out); + } + + .build-card .build-glyph[data-live] { + animation: build-card-breathe calc(var(--duration-linger) * 6) var(--motion-move) infinite; + } + + .build-card-segments > span[data-state="in_progress"] { + background: linear-gradient( + 90deg, + var(--color-success-graphic) 0%, + var(--color-success-graphic) 30%, + color-mix(in srgb, var(--color-success-graphic) 55%, var(--color-page)) 50%, + var(--color-success-graphic) 70%, + var(--color-success-graphic) 100% + ); + background-size: 250% 100%; + animation: build-card-sweep calc(var(--duration-linger) * 8) linear infinite; + } +} + +.build-card-foot { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: calc(var(--spacing) * 3) calc(var(--spacing) * 5); + margin-inline: calc(var(--spacing) * -4); + padding: calc(var(--spacing) * 3) calc(var(--spacing) * 4) 0; + border-block-start: var(--edge-width) solid var(--color-edge); + font-size: var(--text-xs); +} + +.build-card-person, +.build-card-machine { + display: inline-flex; + min-width: 0; + align-items: center; + gap: calc(var(--spacing) * 2); +} + +.build-card-person-name { + color: var(--color-text-secondary); + font-weight: var(--font-weight-medium); +} + +.build-card-quiet { color: var(--color-text-tertiary); font-variant-numeric: tabular-nums; } +.build-card-machine { + color: var(--color-text-secondary); +} + +.build-card-machine-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* A 6px state dot, smaller than the shared glyph's, ringed in the card's inset surface. */ +.build-card-machine .machine-glyph-dot { + width: calc(var(--spacing) * 1.5); + height: calc(var(--spacing) * 1.5); + right: calc(var(--spacing) * -0.75); + bottom: calc(var(--spacing) * -0.75); + border-width: calc(var(--spacing) * 0.375); + border-color: var(--color-inset); +} + +/* Stop's square, drawn in the button's ink. */ +.build-card-stop { + width: calc(var(--spacing) * 2); + height: calc(var(--spacing) * 2); + flex: none; + background: currentColor; +} + +.build-card + .build-tasks { + margin-block-start: calc(var(--spacing) * 6); +} + /* Tasks drafted for an earlier revision stay readable but recede. */ .build-tasks[data-stale] { opacity: var(--opacity-quiet); diff --git a/apps/web/src/build-view.tsx b/apps/web/src/build-view.tsx index 97173b0d6..1a21ddeb7 100644 --- a/apps/web/src/build-view.tsx +++ b/apps/web/src/build-view.tsx @@ -1,4 +1,12 @@ -import { CheckIcon } from "@chopin/icons"; +import { Face } from "@chopin/editor/face"; +import { + CheckIcon, + LoaderIcon, + MachineIcon, + ProgressRingIcon, + ServerIcon, + WarningIcon, +} from "@chopin/icons"; import { useEffect, useId, useState } from "react"; import { BuildGlyph } from "./build-plan-button"; @@ -10,7 +18,6 @@ import { buildLabel, buildPhase, buildProgress, - elapsed, linkParts, liveTaskGroups, liveTaskState, @@ -18,7 +25,6 @@ import { pullRequestCommits, pullRequestNumber, retryAction, - startedBy, TASK_STATE_LABEL, taskStartsOpen, unfinishedReason, @@ -30,10 +36,58 @@ import { stopBuild, } from "./build-start"; import { LocalAgentNote } from "./local-agent-note"; +import { MachineGlyph } from "./machine-button"; import type { ReactNode } from "react"; import type { BuildPlan } from "./build-plan-button"; import type { TaskState } from "./build-model"; +import type { BuildMachine, BuildRequest } from "@chopin/protocol/implementation"; + +/** What the status card says: a title and count, one line under it, and per-task progress. */ +type Card = { + title: string; + count?: string; + glyph?: ReactNode; + detail?: ReactNode; + /** What the coding agent says it is doing now, in place of `detail`. */ + activity?: string; + segments?: TaskState[]; +}; + +function CardSpinner() { + return ( + + ); +} + +/** + * The machine a build runs on, as everyone sees it. Only its starter is told whether it is + * online, so the state dot shows only for them. + */ +function MachineLabel({ machine }: { machine: BuildMachine }) { + let Icon = machine.service ? ServerIcon : MachineIcon; + return ( + + {machine.connected === undefined + ? ( + + ) + : ( + + )} + {machine.label} + + ); +} function sentence(text: string): string { return text.replace(/^[a-z]/, letter => letter.toUpperCase()); @@ -145,96 +199,162 @@ export function BuildView( && !(phase.kind === "blocked" && phase.stale) && !(phase.kind === "drafting" && !staleTasks); - let status: ReactNode; + let outstanding = new Set(snapshot?.live?.outstandingTasks?.map(task => task.id)); + let rebuilding = phase.kind === "live" && phase.sync.kind === "building"; + let reportFor = (task: (typeof tasks)[number]) => + phase.kind === "live" + ? snapshot?.live?.tasks.find(item => item.id === task.id)?.progress + : progress?.tasks.find(item => item.id === task.id); + let stateFor = (task: (typeof tasks)[number], report = reportFor(task)): TaskState => + phase.kind === "live" + ? liveTaskState(report?.state ?? "queued", outstanding.has(task.id), rebuilding) + : report?.state ?? "queued"; + let action: ReactNode; - let since: string | undefined; - if (phase.kind === "loading") { - status = loadError - ? {sentence(loadError)} - : Loading tasks; - } else if (look.kind === "plan") { - // Built before, now back in planning: Build plan in the header builds it again. - if (phase.kind === "blocked") { - status = phase.decisions || phase.comments - ? ( - - {phase.decisions && ( - - )} - {phase.comments && ( - - )} - - ) - : "This document can’t be built yet."; - } else if (phase.kind === "drafting" && phase.draft === "returned") { - status = "Returned for changes. Build plan builds it again."; - } else if (phase.kind === "review") { - status = plural(tasks.length, "task", "tasks"); - } else { - status = "Not built yet."; + function statusCard(): Card { + if (look.kind === "plan") { + // Built before, now back in planning: Build plan in the header builds it again. + if (phase.kind === "blocked") { + return { + title: "Waiting to build", + glyph: