From bd8489d783f363933d03c56f3bd0e973a9b33630 Mon Sep 17 00:00:00 2001 From: Alexander Ivanov Date: Fri, 11 Sep 2026 21:27:36 +0300 Subject: [PATCH 1/2] Where each change goes in the pipeline picture ADR 0025 and the change documents for a graph of what is running, plus the layout it is drawn from. The layout is a pure function of the readiness report and lives in core, not in the view: the terminal and the shell must place changes identically, and a second implementation in the browser would be free to drift invisibly, both pictures continuing to look plausible. A column is a change's depth in the declared blocked_by order. Within a column, changes are ordered by name and not by state: a node that moved because a run started would read as the plan having changed. A collision is not an edge. Two changes that would meet in one spec file have no precedence between them, and a line would assert a sequence the repository does not contain, which a reader would believe because it would look like every other line in the drawing. A cycle of declared blockers is named rather than placed, and so is anything waiting on one. A cycle has no depth, and there is no column one past nowhere; a picture that quietly put one somewhere would be a wrong answer that looks like a right one. ChangeReadiness gains the blockers it declares, kept to those still active. The report carried them only for blocked changes, so the picture would have lost the relations of a running one. Co-Authored-By: Claude Opus 5 (1M context) --- ...ne-picture-is-derived-and-drawn-by-hand.md | 113 ++++++++++ docs/adr/README.md | 1 + .../a-graph-of-what-is-running/.openspec.yaml | 7 + .../a-graph-of-what-is-running/design.md | 95 +++++++++ .../a-graph-of-what-is-running/proposal.md | 52 +++++ .../specs/shared-ui/spec.md | 92 ++++++++ .../a-graph-of-what-is-running/tasks.md | 102 +++++++++ packages/cli/src/ready-command.test.ts | 13 +- packages/core/src/change-layout.test.ts | 152 ++++++++++++++ packages/core/src/change-layout.ts | 197 ++++++++++++++++++ packages/core/src/change-readiness.ts | 10 + packages/core/src/index.ts | 1 + 12 files changed, 834 insertions(+), 1 deletion(-) create mode 100644 docs/adr/0025-the-pipeline-picture-is-derived-and-drawn-by-hand.md create mode 100644 openspec/changes/a-graph-of-what-is-running/.openspec.yaml create mode 100644 openspec/changes/a-graph-of-what-is-running/design.md create mode 100644 openspec/changes/a-graph-of-what-is-running/proposal.md create mode 100644 openspec/changes/a-graph-of-what-is-running/specs/shared-ui/spec.md create mode 100644 openspec/changes/a-graph-of-what-is-running/tasks.md create mode 100644 packages/core/src/change-layout.test.ts create mode 100644 packages/core/src/change-layout.ts 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 0000000..5e4e1a2 --- /dev/null +++ b/docs/adr/0025-the-pipeline-picture-is-derived-and-drawn-by-hand.md @@ -0,0 +1,113 @@ +# 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 — "граф с +ченджами, которые идут, а могут параллельно", 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 drawing is DOM nodes in a CSS grid, with an SVG overlay for +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 55edd96..b2ec134 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 37ca5b7..59204f4 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 5b82364..f349236 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") && (