diff --git a/.changeset/pipeline-picture.md b/.changeset/pipeline-picture.md new file mode 100644 index 00000000..60220890 --- /dev/null +++ b/.changeset/pipeline-picture.md @@ -0,0 +1,19 @@ +--- +"@openspec-ui/core": minor +"@openspec-ui/server": minor +"@openspec-ui/webui": minor +--- + +A Pipeline tab: every active change in the order it declares, what is +running right now and whose run it is, and what can be started alongside +what. + +The placement is derived in core from the readiness report, coordinates +and all, so the tab and `openspec-ui-cli ready` cannot disagree and +nothing in the view is measured. A declared blocker is drawn as a +relation; a collision is not, because a collision is not an order. A +cycle of blockers is named rather than placed. + +The readiness report now carries the blockers each change declares, and +its shape and wording moved to a browser-safe leaf so both surfaces +describe a collision in the same words. diff --git a/docs/adr/0025-the-pipeline-picture-is-derived-and-drawn-by-hand.md b/docs/adr/0025-the-pipeline-picture-is-derived-and-drawn-by-hand.md new file mode 100644 index 00000000..ae175aca --- /dev/null +++ b/docs/adr/0025-the-pipeline-picture-is-derived-and-drawn-by-hand.md @@ -0,0 +1,165 @@ +# 0025: The Pipeline Picture Is Derived, and Drawn Without a Graph Library + +Status: Accepted + +Date: 2026-09-11 + +## Context + +ADR-0024 established that whether two changes can run side by side is +derived from what the repository already contains, never declared: from +`blocked_by`, from two deltas naming the same capability, and from two +branches having changed the same file. `change-readiness.ts` produces +that report, and `openspec-ui-cli ready` prints it. + +The owner asked on 2026-09-11 for the same thing as a picture — a graph +of the changes that are running and the ones that could run alongside +them, laid out the way a CI service draws a pipeline, in a tab of its +own — and, after a review of multi-person use, for it to show who is +implementing each change. + +Two questions arise that a list never had to answer. Where does each +node go? And what is drawn between them? + +The tempting answer to the first is a layout library — dagre, elk, or a +whole diagramming component. The tempting answer to the second is "every +relation we know about", which would put a line between two changes that +collide. + +## Decision + +**The layout is derived from the readiness report, in core.** + +A change's column is its depth in the `blocked_by` order: a change with +no blockers is column 0, and a change's column is one past the deepest +change it is blocked by. Order within a column is by change name, so the +picture is stable between reads — a node that moves when nothing changed +reads as something having happened. + +This lives in `packages/core` beside the report it is computed from, not +in the view. The CLI and the shell then place changes identically, +because there is one placement. A second implementation in the browser +would be free to drift, and the drift would be invisible: both pictures +would look plausible. + +**Collisions are not edges.** + +`blocked_by` is an order: A before B, and an arrow means exactly that. +A collision is not an order — two changes that would meet in one spec +file have no precedence between them, and either may go first. An edge +between them would assert a sequence the repository does not contain, +and a reader would believe it, because it would look like every other +edge in the drawing. + +So a collision is shown on the node it affects, as text, naming the +other change and the reason. The `ready` command already reports them +this way ("not with beta — both deliver a delta to ci-cli"), and the two +surfaces should not describe the same fact differently. + +**The geometry is derived in core too, so nothing is measured.** + +The layout returns coordinates, not just an ordering: each node's +position and size, and each edge's path, in abstract units. A node is an +absolutely positioned control at `calc(var(--u) * x)`; the edges are one +SVG whose `viewBox` is in the same units and whose rendered width is the +same multiple of `--u`. One unit means one thing in both, so they line +up without either being asked where the other ended up. + +The alternative was to lay nodes out with CSS and measure them — +`getBoundingClientRect` per node, a `ResizeObserver`, and a re-run of +the overlay after every layout pass and after fonts settle. That is the +only part of this view that would have held state derived from a moment +of rendering, the only part not checkable without a browser, and the +only part with a failure mode that looks fine: an edge positioned from +stale geometry is a line pointing at nothing. + +Deriving the coordinates instead makes edge placement exactly as +testable as the ordering it comes from, in the same unit tests, with no +DOM at all. + +`--u` is a `rem`, and not an `em`, for two reasons that both bite. A +custom property holds a token rather than a computed length, so `1em` +would resolve against the font size of whichever element used it — a +card that set its own font size would move. And the shell fixes `body` +at 14px, so an `em` inside it does not follow the reader's browser font +setting at all. A `rem` does, which is the thing that was wanted. + +The cost is that a node card has a fixed size, and text longer than it +is truncated with the full text available on the element. That is what +a pipeline node looks like anyway, and the change name — the part a +reader scans for — is the part that gets the room. + +**Nodes are DOM controls; SVG carries only the edges.** + +No graph library is added. The reasons are specific rather than general: + +- The layout is already decided by the time the view runs, so what a + layout library would contribute is the part this ADR puts in core. +- A node must be a real focusable element with real text: this shell is + held to WCAG AA by a browser suite that runs axe on every screen + (ADR-0016's descendants), and a canvas or an SVG-only rendering makes + every node something that has to be given an accessible name by hand. + A ` + ); +} + +function stateWord(node: ChangeLayoutNode): string { + switch (node.change.run.state) { + case "running": + return "Running"; + case "blocked": + return "Blocked"; + case "ready": + return "Ready"; + } +} + +/** What this change has to say for itself, in the order a reader wants + * it. Sentences rather than fields: the terminal's `ready` says the same + * things the same way, and two surfaces wording one fact differently is + * two facts as far as a reader is concerned. */ +function describeChange(node: ChangeLayoutNode): string[] { + const { change } = node; + const lines: string[] = []; + + if (change.run.state === "running") { + lines.push(`in ${change.run.worktreePath}`); + // "git author", never "user": self-declared, and nothing is gated on + // it (a-lease-says-who). A run that recorded none claims nothing. + if (change.run.holder.author) lines.push(`git author ${change.run.holder.author}`); + } + + if (change.run.state === "blocked") lines.push(`waiting on ${change.run.blockedBy.join(", ")}`); + + if (change.run.state === "ready") { + if (change.needsWorktree) lines.push(`no working directory of its own — ${change.needsWorktree}`); + else if (change.canJoin.length > 0) lines.push(`can start alongside ${change.canJoin.join(", ")}`); + else if (change.blockedFrom.length === 0) lines.push("nothing else can start alongside it"); + for (const other of change.blockedFrom) { + lines.push(`not with ${other.changeName} — ${other.collisions.map(describeCollision).join("; ")}`); + } + } + + return lines; +} diff --git a/packages/webui/src/host-embed.ts b/packages/webui/src/host-embed.ts index 55edd961..b2ec134d 100644 --- a/packages/webui/src/host-embed.ts +++ b/packages/webui/src/host-embed.ts @@ -20,6 +20,7 @@ export const ALL_TABS: readonly TabDefinition[] = [ { id: "change-editor", label: "Change Editor" }, { id: "templates", label: "Templates" }, { id: "timeline", label: "Timeline" }, + { id: "pipeline", label: "Pipeline" }, { id: "harness-settings", label: "Harness Settings" }, ]; diff --git a/packages/webui/src/shell-ui.ts b/packages/webui/src/shell-ui.ts index 37ca5b7d..59204f48 100644 --- a/packages/webui/src/shell-ui.ts +++ b/packages/webui/src/shell-ui.ts @@ -1170,6 +1170,128 @@ export const shellThemeCss = ` .openspec-extension-app { margin: 10px auto; padding: 10px; } } + /* The pipeline picture. Every position here came from core: the + element carries --pipeline-w/h and each card carries --x/--y/--w/--h, + all in the layout's abstract units, and this turns a unit into a + length. Nothing is measured (ADR 0025). + + A unit is a rem and deliberately not an em. A custom property holds + a token, not a computed length, so --u set to 1em would resolve + against the font-size of whichever element used it — and the shell + fixes body font-size at 14px anyway, so an em would not follow the + reader's browser setting at all. A rem does. */ + .openspec-pipeline-scroll { + overflow-x: auto; + /* Its own container, so the page body never scrolls sideways — the + same rule the shell's tables already follow. */ + max-width: 100%; + } + + .openspec-pipeline-picture { + --u: 1rem; + position: relative; + width: calc(var(--u) * var(--pipeline-w)); + height: calc(var(--u) * var(--pipeline-h)); + margin: 12px 0; + } + + /* No box of its own: in the wide view a lane exists only to group, + and its cards are placed by coordinate. It becomes a real container + at phone width, at the bottom of this file. */ + .openspec-pipeline-lane { display: contents; } + .openspec-pipeline-lane-heading { display: none; } + + .openspec-pipeline-edges { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + /* A lane below the grid is inside the reported extent, but a stroke + has width and half of it falls outside. */ + overflow: visible; + } + + .openspec-pipeline-edges path { + stroke: var(--line-strong); + stroke-width: 0.1; + stroke-linejoin: round; + } + + .openspec-pipeline-node { + position: absolute; + left: calc(var(--u) * var(--x)); + top: calc(var(--u) * var(--y)); + width: calc(var(--u) * var(--w)); + height: calc(var(--u) * var(--h)); + /* Sets no font-size of its own, on purpose: --u is a token, and a + card that changed its font-size would still be fine with rem but + would silently break the moment anybody made the unit an em. */ + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 1px; + /* Fixed size, so text beyond it is clipped rather than allowed to + move the card's neighbours away from the coordinates core gave. + The text stays in the DOM: a card must not be able to remove a + fact the change is required to state. */ + overflow: hidden; + text-align: left; + padding: 6px 8px; + border: 1px solid var(--line-strong); + border-left-width: 4px; + border-radius: var(--radius); + background: var(--surface); + color: var(--ink); + cursor: pointer; + } + + .openspec-pipeline-node:hover { border-color: var(--primary-soft); } + + /* State is carried by the word inside the card first; these only + agree with it. A reader who cannot tell two hues apart has already + been told which state this is. */ + .openspec-pipeline-node[data-state="running"] { + background: var(--primary-bg); + border-left-color: var(--primary); + } + + .openspec-pipeline-node[data-state="blocked"] { + background: var(--warn-bg); + border-left-color: var(--warn); + } + + .openspec-pipeline-node-name { + font-weight: 600; + /* The part a reader scans for gets the room. */ + align-self: stretch; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .openspec-pipeline-node-state { + font-size: 0.85em; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--muted); + } + + .openspec-pipeline-node-detail { + font-size: 0.85em; + color: var(--muted); + align-self: stretch; + } + + .openspec-pipeline-cycles { + border: 1px solid var(--warn); + background: var(--warn-bg); + border-radius: var(--radius); + padding: 8px 12px; + margin: 12px 0; + } + + .openspec-pipeline-cycles ul { margin: 4px 0 0; padding-left: 20px; } + /* LAST in this layer on purpose. These selectors have the same specificity as the ones they override, and at equal specificity the later rule wins — placed earlier, the whole block did nothing. @@ -1190,6 +1312,41 @@ export const shellThemeCss = ` width: 100%; min-width: 0; } + + /* The picture becomes headed lanes. Four columns of cards do not fit + a phone in any implementation, and shrinking until it is + technically present and practically unreadable is the worse + answer. Nothing is lost: each card already states in words what it + waits on, which is what the edges illustrate. */ + .openspec-pipeline-picture { + width: auto; + height: auto; + } + + .openspec-pipeline-edges { display: none; } + + .openspec-pipeline-lane { display: block; margin-bottom: 16px; } + + .openspec-pipeline-lane-heading { + display: block; + margin: 0 0 6px; + font-size: 0.9em; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--muted); + } + + .openspec-pipeline-node { + position: static; + width: 100%; + height: auto; + /* Nothing is clipped here: there is room to run on, and the card + is no longer holding a coordinate for anything else. */ + overflow: visible; + margin-bottom: 8px; + } + + .openspec-pipeline-node-name { white-space: normal; } } `; diff --git a/packages/webui/src/standalone-entry.tsx b/packages/webui/src/standalone-entry.tsx index 5b823641..f349236f 100644 --- a/packages/webui/src/standalone-entry.tsx +++ b/packages/webui/src/standalone-entry.tsx @@ -5,7 +5,7 @@ // not library code reused in the extension. import { createRoot } from "react-dom/client"; -import { Fragment, useEffect, useMemo, useRef, useState } from "react"; +import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { FetchTransport } from "./transport/fetch-transport.js"; import { AiPanel } from "./components/AiPanel.js"; import { describeRunCompletionNotification } from "./notify-run-completion.js"; @@ -14,6 +14,8 @@ import { ChangeTimelineView } from "./components/ChangeTimelineView.js"; import { ChangesList } from "./components/ChangesList.js"; import { ArchiveList } from "./components/ArchiveList.js"; import { ProcessesView, type ProcessesApi } from "./components/ProcessesView.js"; +import { PipelineView } from "./components/PipelineView.js"; +import { loadChangeReadiness } from "./change-readiness-client.js"; import { Tabs, TabPanel } from "./components/Tabs.js"; import { buildDefaultChangeDir, shellThemeCss } from "./shell-ui.js"; import { VSCODE_LOCAL_SERVER_EMBED_SIGNAL, computeVisibleTabs, readEmbedSignal } from "./host-embed.js"; @@ -320,6 +322,17 @@ function StandaloneApp() { writeChangeOverride: (changeName, config) => writeHarnessConfigApi(apiFetch, cwd, config, changeName), }), [cwd]); + // Stable across renders so the pipeline's polling effect is not torn + // down and restarted on every one of them. + const pipelineLoad = useCallback(() => loadChangeReadiness(apiFetch, cwd), [cwd]); + + // `loadChangeEditor` is a hoisted declaration further down and reads + // `cwd` itself, so `cwd` is the only thing this has to be rebuilt for. + const openChangeInEditor = useCallback((changeName: string) => { + setActiveTab("change-editor"); + void loadChangeEditor(changeName); + }, [cwd]); + useEffect(() => { let cancelled = false; void (async () => { @@ -1840,6 +1853,27 @@ function StandaloneApp() { )} + {visibleTabIds.has("pipeline") && ( + +
+

Pipeline

+

+ Every active change in the order it declares, what is running right now, and what can be started + alongside what. The same report openspec-ui-cli ready prints. +

+ {cwd.trim().length > 0 + ? ( + + ) + :

Enter workspace root to see the pipeline.

} +
+
+ )} + {visibleTabIds.has("harness-settings") && (