From 5cd73e82f906dce75edb9be1240df507166ac023 Mon Sep 17 00:00:00 2001 From: Alexander Ivanov Date: Tue, 13 Oct 2026 08:35:17 +0300 Subject: [PATCH 1/2] Show a change's run logs from its own worktree A run logs where it runs: a change worked in its own worktree logs there, not in the host's root. Show Logs read only the host's root, so for such a change it said there were no logs. Core now resolves the change's own worktree, the extension and server read through it, and the card of a change in its own worktree offers Show Logs. --- ...ange-shows-the-logs-of-its-own-worktree.md | 8 + .../.openspec.yaml | 4 + .../design.md | 54 + .../proposal.md | 54 + .../specs/execution-core/spec.md | 69 + .../specs/standalone-app/spec.md | 60 + .../specs/vscode-extension/spec.md | 28 + .../tasks.md | 69 + packages/core/src/change-run-logs.git.test.ts | 42 + packages/core/src/change-run-logs.test.ts | 79 + packages/core/src/change-run-logs.ts | 30 + packages/core/src/index.ts | 1 + packages/core/vitest.workspace.ts | 3 + .../src/webview/pipeline-panel.test.ts | 16 + .../extension/src/webview/pipeline-panel.ts | 19 +- packages/server/src/rest.ts | 23 +- packages/server/src/server.test.ts | 79 +- .../src/components/PipelineView.test.tsx | 18 + .../webui/src/components/PipelineView.tsx | 731 +++---- packages/webui/src/pipeline-entry.tsx | 2 +- packages/webui/src/run-logs-client.ts | 4 +- packages/webui/src/standalone-entry.tsx | 1916 ++++++++--------- 22 files changed, 1944 insertions(+), 1365 deletions(-) create mode 100644 .changeset/a-change-shows-the-logs-of-its-own-worktree.md create mode 100644 openspec/changes/a-change-shows-the-logs-of-its-own-worktree/.openspec.yaml create mode 100644 openspec/changes/a-change-shows-the-logs-of-its-own-worktree/design.md create mode 100644 openspec/changes/a-change-shows-the-logs-of-its-own-worktree/proposal.md create mode 100644 openspec/changes/a-change-shows-the-logs-of-its-own-worktree/specs/execution-core/spec.md create mode 100644 openspec/changes/a-change-shows-the-logs-of-its-own-worktree/specs/standalone-app/spec.md create mode 100644 openspec/changes/a-change-shows-the-logs-of-its-own-worktree/specs/vscode-extension/spec.md create mode 100644 openspec/changes/a-change-shows-the-logs-of-its-own-worktree/tasks.md create mode 100644 packages/core/src/change-run-logs.git.test.ts create mode 100644 packages/core/src/change-run-logs.test.ts create mode 100644 packages/core/src/change-run-logs.ts diff --git a/.changeset/a-change-shows-the-logs-of-its-own-worktree.md b/.changeset/a-change-shows-the-logs-of-its-own-worktree.md new file mode 100644 index 00000000..aed06bd0 --- /dev/null +++ b/.changeset/a-change-shows-the-logs-of-its-own-worktree.md @@ -0,0 +1,8 @@ +--- +"@openspec-ui/core": patch +"@openspec-ui/server": patch +"openspec-ui-vscode": patch +"@openspec-ui/webui": patch +--- + +Show Logs on a change's card reads the logs of the change's own worktree, where its runs write them, and the card of a change worked in its own worktree offers it. Before, the button read only the host's root and said there were no logs. diff --git a/openspec/changes/a-change-shows-the-logs-of-its-own-worktree/.openspec.yaml b/openspec/changes/a-change-shows-the-logs-of-its-own-worktree/.openspec.yaml new file mode 100644 index 00000000..f7f8dd2a --- /dev/null +++ b/openspec/changes/a-change-shows-the-logs-of-its-own-worktree/.openspec.yaml @@ -0,0 +1,4 @@ +schema: spec-driven +created: 2026-10-12 +follows: + - a-change-shows-its-run-logs diff --git a/openspec/changes/a-change-shows-the-logs-of-its-own-worktree/design.md b/openspec/changes/a-change-shows-the-logs-of-its-own-worktree/design.md new file mode 100644 index 00000000..feaeff75 --- /dev/null +++ b/openspec/changes/a-change-shows-the-logs-of-its-own-worktree/design.md @@ -0,0 +1,54 @@ +# Design + +## Context + +`createFileRunLogs(workspaceRoot)` writes under the root of the directory a run +runs in. A change worked in its own worktree therefore logs there, not in the +checkout the host was opened on. The read side, `listRunLogs` and `readRunLog`, +takes one root, and every host passes its own. ADR 0026 already forbids acting +on a path a request names; `resolveOwnWorktree` finds a change's worktree from +its name and `git worktree list`. + +## Goals / Non-Goals + +**Goals:** + +- A card's Show Logs lists, and opens, the runs of its change wherever they + were logged. +- Every card of a change in its own worktree offers Show Logs. + +**Non-Goals:** + +- No change to where logs are written, to the log format, or to retention. +- No log reading for a worktree that is not a change's own. A directory that + is not the worktree of the change the card names is not read. +- No archived card gains Show Logs: its worktree is gone and so are its logs. + +## Decisions + +1. **The host resolves the place; the request names the change.** A new + function in core, beside `own-worktree-tasks.ts`, returns the change's own + worktree path where `git worktree list` has one, else the host's root. + Rejected: sending the worktree's path from the page - a request is data from + a page, and ADR 0026 forbids writing to, and by the same reasoning reading + from, a path it names. +2. **Reading one run names its change.** `pipeline/run-log` and + `/api/run-logs/read` take an optional `changeName` and read where the list + found the run. Without it they read the host root, as before, so an older + caller is unchanged. Rejected: searching every worktree for a run id - it + reads directories the card did not name and costs a listing per worktree. +3. **Fall back to the host root, never fail.** A change with no worktree of its + own keeps its logs at the host root (a run started there), which is what the + button read before. The resolver therefore never refuses. +4. **The button follows `own`.** `DirectoryCard` already knows `own` (the + directory is the worktree of exactly this change) and already offers Run + Change on that condition. Show Logs is offered on the same condition, and + the handler is passed down the same way `onStart` is. + +## Risks / Trade-offs + +- A worktree removed after a run takes its logs with it (ADR 0027: a directory + is disposable). The card then says no log was kept, which is true; the + harvest on removal does not copy `.openspec-ui/runs`. Not changed here. +- Two worktrees on one change name cannot exist (the branch is the change + name), so the resolver has one answer. diff --git a/openspec/changes/a-change-shows-the-logs-of-its-own-worktree/proposal.md b/openspec/changes/a-change-shows-the-logs-of-its-own-worktree/proposal.md new file mode 100644 index 00000000..1d6a8626 --- /dev/null +++ b/openspec/changes/a-change-shows-the-logs-of-its-own-worktree/proposal.md @@ -0,0 +1,54 @@ +# Proposal + +## Why + +A change is worked in the worktree made for it (ADR 0027 keeps one root for +them), and a run there writes its log under that worktree's +`.openspec-ui/runs`. A card's Show Logs reads only the host's own root, so for +a change worked in its own worktree it answers "nothing" while the logs sit +one directory away. Measured on 2026-10-12 with the very call the button +makes: `listRunLogs` over the host root returned 0 logs for +`remaining-groups-move-into-the-register`; over its own worktree, 2. + +Two more defects share the cause. Opening one run sends only its run id, so +a list that found the logs could not open one. And the card of a change +worked in its own worktree, drawn from another directory's picture, offers no +Show Logs at all, although `a-change-shows-its-run-logs` says every card has +one. ADR 0026 decides that a change is the pair of a directory and a name and +that a request is data, never a path to read: the host finds the worktree +from the name. + +## What Changes + +- Core resolves where a change's logs are: the change's own worktree where + `git worktree list` has one, else the host's root. A request names the + change, never a path. +- `pipeline/run-logs` and `pipeline/run-log` (extension) and + `/api/run-logs/list` and `/api/run-logs/read` (server) read from that + place. Reading one run's log names its change, so it is read where the + list found it. +- The card of a change in its own worktree, drawn from another directory's + picture, offers Show Logs. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `execution-core`: where a change's run logs are read from. +- `standalone-app`: the log endpoints read a change's own worktree; Logs on + every card. +- `vscode-extension`: the bridge operations read a change's own worktree. + +## Impact + +`packages/core` (`run-log.ts`, a resolver beside `own-worktree-tasks.ts`), +`packages/server` (`rest.ts`), `packages/extension` +(`webview/pipeline-panel.ts`), `packages/webui` (`run-logs-client.ts`, +`pipeline-entry.tsx`, `standalone-entry.tsx`, `PipelineView.tsx`). No +command or event of the protocol changes; the two read operations gain an +optional `changeName`, so an old caller that sends none reads the host root as +before. A patch-level behaviour fix for each affected package. diff --git a/openspec/changes/a-change-shows-the-logs-of-its-own-worktree/specs/execution-core/spec.md b/openspec/changes/a-change-shows-the-logs-of-its-own-worktree/specs/execution-core/spec.md new file mode 100644 index 00000000..632a637d --- /dev/null +++ b/openspec/changes/a-change-shows-the-logs-of-its-own-worktree/specs/execution-core/spec.md @@ -0,0 +1,69 @@ +## MODIFIED Requirements + +### Requirement: Every run keeps a log + +When a host hands its runners the workspace's run logs, `packages/core` +SHALL write each run's log to `.openspec-ui/runs/.jsonl`. The log +SHALL hold, for every stage the run id is given: + +- a start record naming the agent, the kind, the working directory, the + change, the stage and the task where there is one; +- a line for each event that says something, read the way the hosts read + it; +- an end record with the outcome, reason and summary. + +A run the sandbox or the allowlist refused SHALL get a log whose end says +it was refused and why. + +A log SHALL stop at a size cap and say where it stopped, and its end SHALL +still be written. The directory SHALL keep only the newest logs. Writing a +log SHALL NOT delay or fail a run. + +Core SHALL list a workspace's logged runs, newest first, and one change's +when asked, without reading each log whole. It SHALL read one run's log by +its id, and SHALL refuse an id that could name a file outside the +directory. + +Core SHALL resolve where one change's logs are read from: the change's own +worktree, found from the change's name and the repository's worktree list, +where it has one, and the host's own root where it has none. A request SHALL +name the change and SHALL NOT name a path. + +#### Scenario: A run completes + +- **WHEN** a run writes to stdout and completes +- **THEN** its log holds its start, what it wrote, and an end that says it + completed + +#### Scenario: A chain runs two stages + +- **WHEN** a chain's `propose` and `apply` stages run under one run id +- **THEN** one log holds both, each with its own start and end +- **AND** the list names both stages for that run + +#### Scenario: The allowlist refuses a run + +- **WHEN** the allowlist refuses a run's invocation +- **THEN** the run's log ends as refused, with the reason + +#### Scenario: A run says too much + +- **WHEN** a run's output passes the cap +- **THEN** the log says it stopped there, and still ends with the outcome + +#### Scenario: A request names a path + +- **WHEN** a log is asked for under the id `../audit` +- **THEN** nothing is read + +#### Scenario: A change worked in its own worktree + +- **WHEN** a change has a worktree of its own, a run there kept two logs, + and the host's own root kept none +- **THEN** listing that change's logs answers both runs + +#### Scenario: A change with no worktree of its own + +- **WHEN** a change has no worktree of its own and the host's own root kept + a log of its run +- **THEN** listing that change's logs answers that run from the host's root diff --git a/openspec/changes/a-change-shows-the-logs-of-its-own-worktree/specs/standalone-app/spec.md b/openspec/changes/a-change-shows-the-logs-of-its-own-worktree/specs/standalone-app/spec.md new file mode 100644 index 00000000..41a3c4a2 --- /dev/null +++ b/openspec/changes/a-change-shows-the-logs-of-its-own-worktree/specs/standalone-app/spec.md @@ -0,0 +1,60 @@ +## MODIFIED Requirements + +### Requirement: A change's card opens its run logs + +The standalone server SHALL answer `POST /api/run-logs/list` with an +authorized workspace's logged runs, one change's when the body names it, +and `POST /api/run-logs/read` with one run's log. Where the body names a +change, both SHALL read from that change's own worktree, found from its +name, and from the workspace root where it has none. It SHALL answer 400 +for a run id that could name a file outside the log directory, and 404 +where no log is kept. + +The Pipeline SHALL offer Logs on every change's card, including the card of +a change worked in its own worktree drawn from another directory's +picture. It SHALL open, over the board and in view whatever the board's +height and scroll: + +- the change's runs, newest first, each with when it started, what it + was, the agent and how it ended; +- the chosen run's log, with each stage as a heading and the output of + one stream joined. + +It SHALL say so where no run of the change left a log. + +The view SHALL take the focus when it opens. Escape SHALL close it as its +Close button does, and closing it SHALL give the focus back to the Logs +button that opened it. A view opened beneath a picture taller than the +window is a press that seems to do nothing: reported on 2026-09-23, it +showed 82 pixels of itself at the window's bottom edge. + +#### Scenario: A person opens a change's logs + +- **WHEN** a person presses Logs on a change's card +- **THEN** the change's runs are listed and the newest run's log is shown + +#### Scenario: A change never ran + +- **WHEN** no run of the change left a log +- **THEN** the view says that no run of this change has left a log + +#### Scenario: The board is taller than the window + +- **WHEN** a person presses Logs on a card of a board taller than the window +- **THEN** the view is drawn over the board, whole within the window, with the focus in it + +#### Scenario: A person closes the logs with Escape + +- **WHEN** a person presses Escape in the logs view +- **THEN** the view closes and the focus is back on the Logs button that opened it + +#### Scenario: A change worked in its own worktree + +- **WHEN** a person presses Logs on the card of a change whose runs were + logged in its own worktree +- **THEN** the view lists those runs and opens the one chosen + +#### Scenario: A request names a path + +- **WHEN** the list request names a path in place of a change +- **THEN** the server reads nothing outside the workspace diff --git a/openspec/changes/a-change-shows-the-logs-of-its-own-worktree/specs/vscode-extension/spec.md b/openspec/changes/a-change-shows-the-logs-of-its-own-worktree/specs/vscode-extension/spec.md new file mode 100644 index 00000000..7e74b845 --- /dev/null +++ b/openspec/changes/a-change-shows-the-logs-of-its-own-worktree/specs/vscode-extension/spec.md @@ -0,0 +1,28 @@ +## MODIFIED Requirements + +### Requirement: The Pipeline panel opens a change's run logs + +The extension SHALL hand its runners the workspace's run logs, so every +run it starts keeps one. Its Pipeline panel SHALL offer Logs on every +change's card and show the same view the standalone shows. It reads +through the bridge operations `pipeline/run-logs` and `pipeline/run-log`, +which answer from the change's own worktree, found from the change's name, +and from the panel's own workspace root where the change has none. A +request that names no change, or no valid run id, SHALL be refused by +name. + +#### Scenario: A person opens a change's logs in the editor + +- **WHEN** a person presses Logs on a card in the Pipeline panel +- **THEN** the panel lists the change's runs and shows the newest run's log + +#### Scenario: A change worked in its own worktree + +- **WHEN** a person presses Logs on the card of a change whose runs were + logged in its own worktree +- **THEN** the panel lists those runs, and opening one reads it from there + +#### Scenario: A request names a path + +- **WHEN** the panel is asked for the log of run `../audit` +- **THEN** it answers that a run id is required, and reads nothing diff --git a/openspec/changes/a-change-shows-the-logs-of-its-own-worktree/tasks.md b/openspec/changes/a-change-shows-the-logs-of-its-own-worktree/tasks.md new file mode 100644 index 00000000..8bee34c9 --- /dev/null +++ b/openspec/changes/a-change-shows-the-logs-of-its-own-worktree/tasks.md @@ -0,0 +1,69 @@ +From a bug found on 2026-10-12: Show Logs on a card said there were no logs +for a change whose runs had logged in its own worktree. + +## 1. Core + +- [x] 1.1 `packages/core/src/change-run-logs.ts`: `changeLogsRoot`, + `listChangeRunLogs` and `readChangeRunLog` resolve a change's own worktree + with `resolveOwnWorktree` and fall back to the host's root; none takes a + path. Exported from `packages/core/src/index.ts`, and not from `browser.ts`. +- [x] 1.2 `packages/core/src/change-run-logs.test.ts`: the own worktree's + logs are listed and read where the host's root kept none; the host's root is + the fallback with no worktree and where the list cannot be read; a run id + naming a path reads nothing. Verified to fail with the resolver returning + the host's root. +- [x] 1.3 `packages/core/src/change-run-logs.git.test.ts` and + `packages/core/vitest.workspace.ts`: the same over a real `git worktree + add`, in the `core-git-subprocess` project. + +## 2. Hosts + +- [x] 2.1 `packages/server/src/rest.ts`: `handleRunLogsListRequest` lists + through `listChangeRunLogs` where the body names a change, and + `handleRunLogReadRequest` reads through `readChangeRunLog` where it names + one, answering 400 for a change name that is not a non-empty string. + `authorizeCwd` still checks the workspace root, never a path computed here. +- [x] 2.2 `packages/extension/src/webview/pipeline-panel.ts`: + `pipeline/run-logs` lists through `listChangeRunLogs`, and `pipeline/run-log` + reads through `readChangeRunLog` where `changeName` is given, refusing one + that is not a non-empty string. +- [x] 2.3 Tests in `packages/server/src/server.test.ts` and + `packages/extension/src/webview/pipeline-panel.test.ts` for a named change + and for one that is not a name. + +## 3. Web UI + +- [x] 3.1 `packages/webui/src/run-logs-client.ts`, + `packages/webui/src/standalone-entry.tsx` and + `packages/webui/src/pipeline-entry.tsx`: reading one run sends the change + name the view was opened for. +- [x] 3.2 `packages/webui/src/components/PipelineView.tsx`: `DirectoryCard` + offers Show Logs on a card of a change's own worktree, with `onViewLogs` + threaded from `controls` along the route `onStart` takes. Not on a card of a + directory that is not the change's own. +- [x] 3.3 Test in `packages/webui/src/components/PipelineView.test.tsx`: the + button is on `pipeline-directory-0-node-fresh` and calls `onViewLogs` with + `fresh`, and is absent from another directory's card. Verified to fail with + the `own` condition removed. + +## 4. Documents + +- [x] 4.1 A changeset: core, server, extension, webui - patch. + +## 5. Checks + +- [x] 5.1 `npm run typecheck && npm run lint`, and the affected tests of core, + server, extension and webui. + 2026-10-12/13: typecheck clean; lint 0 errors; core 2303 passed, cli 285, + extension 539, server 136, webui 769. +- [x] 5.2 `openspec validate a-change-shows-the-logs-of-its-own-worktree + --strict`, and the merge gate with `--change` and the worktree's absolute + path as `--cwd`. + 2026-10-13: "Change 'a-change-shows-the-logs-of-its-own-worktree' is + valid". +- [x] 5.3 On the real data that showed the bug: `listChangeRunLogs` over the + main checkout for `remaining-groups-move-into-the-register` lists its two + runs and reads the newest. + 2026-10-12: listed 2 runs (3257e8cf, 4143b08e, both apply, cancelled) and + read the newest's 494 records from its own worktree, where the old read + over the host root found 0. diff --git a/packages/core/src/change-run-logs.git.test.ts b/packages/core/src/change-run-logs.git.test.ts new file mode 100644 index 00000000..75d48bde --- /dev/null +++ b/packages/core/src/change-run-logs.git.test.ts @@ -0,0 +1,42 @@ +import { execFileSync } from "node:child_process"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { listChangeRunLogs } from "./change-run-logs.js"; +import { createFileRunLogs } from "./run-log.js"; + +// a-change-shows-the-logs-of-its-own-worktree: the real `git worktree list`, +// which the other file replaces with a list. + +let base: string; + +const git = (cwd: string, ...args: string[]) => execFileSync("git", args, { cwd, stdio: "pipe" }); + +beforeEach(async () => { + base = await mkdtemp(path.join(os.tmpdir(), "change-run-logs-git-")); +}); + +afterEach(async () => { + await rm(base, { recursive: true, force: true }); +}); + +describe("a change's run logs, over a real worktree", () => { + it("finds the logs through a real git worktree list", async () => { + const repo = path.join(base, "real"); + const own = path.join(base, "real-worktrees", "demo"); + await mkdir(repo, { recursive: true }); + git(repo, "init", "-b", "main"); + git(repo, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "--allow-empty", "-m", "init"); + git(repo, "worktree", "add", own, "-b", "demo"); + await mkdir(path.join(own, "openspec", "changes", "demo"), { recursive: true }); + await writeFile(path.join(own, "openspec", "changes", "demo", "tasks.md"), "- [ ] 1.1 x\n"); + await createFileRunLogs(own) + .open({ runId: "run-real", agent: "claude-cli", kind: "implement", cwd: own, changeName: "demo" }) + .end({ outcome: "completed" }); + + const found = await listChangeRunLogs({ repositoryRoot: repo, changeName: "demo" }); + + expect(found.map((one) => one.runId)).toEqual(["run-real"]); + }, 30_000); +}); diff --git a/packages/core/src/change-run-logs.test.ts b/packages/core/src/change-run-logs.test.ts new file mode 100644 index 00000000..dfe12fa8 --- /dev/null +++ b/packages/core/src/change-run-logs.test.ts @@ -0,0 +1,79 @@ +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { GitWorktree } from "./git.js"; +import { changeLogsRoot, listChangeRunLogs, readChangeRunLog } from "./change-run-logs.js"; +import { createFileRunLogs } from "./run-log.js"; + +// a-change-shows-the-logs-of-its-own-worktree. Real log files in a temporary +// repository and worktree; only the worktree list is supplied. + +let base: string; +let host: string; +let own: string; + +const worktrees = (entries: GitWorktree[]) => ({ worktreeList: async () => entries }); + +async function logRun(root: string, runId: string, changeName: string): Promise { + await createFileRunLogs(root) + .open({ runId, agent: "claude-cli", kind: "implement", cwd: root, changeName }) + .end({ outcome: "completed" }); +} + +beforeEach(async () => { + base = await mkdtemp(path.join(os.tmpdir(), "change-run-logs-")); + host = path.join(base, "repo"); + own = path.join(base, "worktrees", "demo"); + await mkdir(path.join(own, "openspec", "changes", "demo"), { recursive: true }); + await mkdir(host, { recursive: true }); + await (await import("node:fs/promises")).writeFile(path.join(own, "openspec", "changes", "demo", "tasks.md"), "- [ ] 1.1 x\n"); +}); + +afterEach(async () => { + await rm(base, { recursive: true, force: true }); +}); + +const listed = (): GitWorktree[] => [ + { path: host, branch: "main", head: "a" }, + { path: own, branch: "demo", head: "b" }, +]; + +describe("a change's run logs", () => { + it("lists the logs of its own worktree where the host's root kept none", async () => { + await logRun(own, "run-1", "demo"); + await logRun(own, "run-2", "demo"); + + const found = await listChangeRunLogs({ repositoryRoot: host, changeName: "demo", git: worktrees(listed()) }); + + expect(found.map((one) => one.runId).sort()).toEqual(["run-1", "run-2"]); + }); + + it("reads a run's log where the list found it", async () => { + await logRun(own, "run-1", "demo"); + + const records = await readChangeRunLog({ repositoryRoot: host, changeName: "demo", runId: "run-1", git: worktrees(listed()) }); + + expect(records?.[0]).toMatchObject({ type: "start", changeName: "demo" }); + }); + + it("falls back to the host's root for a change with no worktree of its own", async () => { + await logRun(host, "run-9", "other"); + + const root = await changeLogsRoot({ repositoryRoot: host, changeName: "other", git: worktrees([{ path: host, branch: "main", head: "a" }]) }); + const found = await listChangeRunLogs({ repositoryRoot: host, changeName: "other", git: worktrees([{ path: host, branch: "main", head: "a" }]) }); + + expect(root).toBe(host); + expect(found.map((one) => one.runId)).toEqual(["run-9"]); + }); + + it("falls back to the host's root where the worktree list cannot be read", async () => { + const broken = { worktreeList: async () => { throw new Error("not a repository"); } }; + + expect(await changeLogsRoot({ repositoryRoot: host, changeName: "demo", git: broken })).toBe(host); + }); + + it("does not read a run id that could name a file outside the directory", async () => { + expect(await readChangeRunLog({ repositoryRoot: host, changeName: "demo", runId: "../audit", git: worktrees(listed()) })).toBeUndefined(); + }); +}); diff --git a/packages/core/src/change-run-logs.ts b/packages/core/src/change-run-logs.ts new file mode 100644 index 00000000..9130a127 --- /dev/null +++ b/packages/core/src/change-run-logs.ts @@ -0,0 +1,30 @@ +import { resolveOwnWorktree } from "./own-worktree-tasks.js"; +import { listRunLogs, readRunLog, type RunLogRecord, type RunLogSummary } from "./run-log.js"; +import type { GitWrapper } from "./git.js"; + +export interface ChangeLogsOptions { + repositoryRoot: string; + changeName: string; + /** Test seam; production lists the repository's worktrees. */ + git?: Pick; +} + +/** Where a change's logs are: its own worktree where it has one, else the host's root. */ +export async function changeLogsRoot(options: ChangeLogsOptions): Promise { + const own = await resolveOwnWorktree({ + repositoryRoot: options.repositoryRoot, + changeName: options.changeName, + ...(options.git !== undefined ? { git: options.git } : {}), + }); + return own.ok ? own.path : options.repositoryRoot; +} + +/** The runs a change left logs of, newest first. */ +export async function listChangeRunLogs(options: ChangeLogsOptions): Promise { + return listRunLogs(await changeLogsRoot(options), { changeName: options.changeName }); +} + +/** One run's log, read where the change's own logs are. */ +export async function readChangeRunLog(options: ChangeLogsOptions & { runId: string }): Promise { + return readRunLog(await changeLogsRoot(options), options.runId); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index cdab840e..a6e123a3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -52,6 +52,7 @@ export * from "./change-in-its-worktree.js"; export * from "./openspec-setup-commit.js"; export * from "./harness-config.js"; export * from "./run-log.js"; +export * from "./change-run-logs.js"; export * from "./harness-config-schema.js"; export * from "./harness-chain-runner.js"; export * from "./process-scheduler.js"; diff --git a/packages/core/vitest.workspace.ts b/packages/core/vitest.workspace.ts index 815980c5..dbfe4f75 100644 --- a/packages/core/vitest.workspace.ts +++ b/packages/core/vitest.workspace.ts @@ -68,6 +68,9 @@ const SHELL_STARTING_TESTS = [ // Real git with a push to a local bare remote, for the task list a card // commits (a-card-works-its-own-tasks). "src/own-worktree-tasks.commit.test.ts", + // Real git, for `worktree add` against a temporary repository + // (a-change-shows-the-logs-of-its-own-worktree). + "src/change-run-logs.git.test.ts", ]; export default defineWorkspace([ diff --git a/packages/extension/src/webview/pipeline-panel.test.ts b/packages/extension/src/webview/pipeline-panel.test.ts index b51f5b99..5bf3825a 100644 --- a/packages/extension/src/webview/pipeline-panel.test.ts +++ b/packages/extension/src/webview/pipeline-panel.test.ts @@ -321,6 +321,22 @@ describe("PipelinePanel — answering the view", () => { expect(post).toHaveBeenCalledWith(expect.objectContaining({ id: "g:3", ok: false, error: "no log is kept for run never-ran" })); }); + // a-change-shows-the-logs-of-its-own-worktree: reading one run names its + // change, so it is read where the list found it; a path is never taken. + it("reads one run's log for the change a request names, and refuses a change name that is not a name", async () => { + const { pipeline } = createPipelinePanel(); + pipeline.show(); + + await pipeline.deliverMessageForTesting({ type: "openspec-ui/request", id: "n:0", op: "pipeline/run-log", args: { runId: "never-ran", changeName: "alpha" } }); + await pipeline.deliverMessageForTesting({ type: "openspec-ui/request", id: "n:1", op: "pipeline/run-log", args: { runId: "never-ran", changeName: "" } }); + await pipeline.deliverMessageForTesting({ type: "openspec-ui/request", id: "n:2", op: "pipeline/run-log", args: { runId: "never-ran", changeName: 7 } }); + + const post = created[0]!.webview.postMessage; + expect(post).toHaveBeenCalledWith(expect.objectContaining({ id: "n:0", ok: false, error: "no log is kept for run never-ran" })); + expect(post).toHaveBeenCalledWith(expect.objectContaining({ id: "n:1", ok: false, error: "a change name, when given, must be a non-empty string" })); + expect(post).toHaveBeenCalledWith(expect.objectContaining({ id: "n:2", ok: false, error: "a change name, when given, must be a non-empty string" })); + }); + // a-card-works-its-own-tasks 3.2: a card's task controls, refused by // shape before anything is looked up, and answered with core's refusal // where the change has no worktree of its own. diff --git a/packages/extension/src/webview/pipeline-panel.ts b/packages/extension/src/webview/pipeline-panel.ts index 3d813cac..2e70113a 100644 --- a/packages/extension/src/webview/pipeline-panel.ts +++ b/packages/extension/src/webview/pipeline-panel.ts @@ -36,14 +36,15 @@ import { surveyWorktrees, type CatchUpResult, type ChangeStageReading, - type ArchiveReading, type BoardColumnsReading, + type ArchiveReading, type BoardColumnsReading, type ChangeStandings, type MainDrift, type LastRunsReport, type LiveRuns, type WorktreeSurvey, isRunLogId, - listRunLogs, + listChangeRunLogs, + readChangeRunLog, readRunLog, commitTaskList, resolveOwnWorktree, @@ -586,16 +587,24 @@ export class PipelinePanel { reply({ ok: false, error: "a change name is required" }); return; } - reply({ ok: true, value: await listRunLogs(workspaceRoot, { changeName }) }); + reply({ ok: true, value: await listChangeRunLogs({ repositoryRoot: workspaceRoot, changeName }) }); return; } case "pipeline/run-log": { - const runId = (request.args as { runId?: unknown } | undefined)?.runId; + const logArgs = request.args as { runId?: unknown; changeName?: unknown } | undefined; + const runId = logArgs?.runId; if (typeof runId !== "string" || !isRunLogId(runId)) { reply({ ok: false, error: "a run id is required" }); return; } - const records = await readRunLog(workspaceRoot, runId); + const named = logArgs?.changeName; + if (named !== undefined && (typeof named !== "string" || named.length === 0)) { + reply({ ok: false, error: "a change name, when given, must be a non-empty string" }); + return; + } + const records = named === undefined + ? await readRunLog(workspaceRoot, runId) + : await readChangeRunLog({ repositoryRoot: workspaceRoot, changeName: named, runId }); if (records === undefined) { reply({ ok: false, error: `no log is kept for run ${runId}` }); return; diff --git a/packages/server/src/rest.ts b/packages/server/src/rest.ts index d8d2bed6..a340d49e 100644 --- a/packages/server/src/rest.ts +++ b/packages/server/src/rest.ts @@ -113,6 +113,8 @@ import { resolveAgentStatusDirectory, isRunLogId, listRunLogs, + listChangeRunLogs, + readChangeRunLog, readRunLog, } from "@openspec-ui/core"; import { isCommandLike } from "./wire.js"; @@ -122,7 +124,7 @@ export interface RestRequestPolicy { isCwdAllowed(cwd: string): boolean; } -class PayloadTooLargeError extends Error {} +class PayloadTooLargeError extends Error { } export async function readJsonBody(req: IncomingMessage, maxPayloadBytes: number): Promise { const chunks: Buffer[] = []; @@ -614,9 +616,9 @@ export async function handleSprintReportRequest(req: IncomingMessage, res: Serve try { // The summary, as data. The page that draws it is the browser's - // half, drawn with the product's own stylesheets, which live in - // `webui` (the-sprint-report-is-a-page-of-the-product): this layer - // stays thin, and sends JSON like everything else it sends. + // half, drawn with the product's own stylesheets, which live in + // `webui` (the-sprint-report-is-a-page-of-the-product): this layer + // stays thin, and sends JSON like everything else it sends. sendJson(res, 200, await buildSprintReport(parsed.cwd, parsed.entries, parsed.rangeStart, parsed.rangeEnd)); } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -1449,7 +1451,9 @@ export async function handleRunLogsListRequest(req: IncomingMessage, res: Server } if (!authorizeCwd(res, policy, parsed.cwd)) return; try { - sendJson(res, 200, await listRunLogs(parsed.cwd, changeName === undefined ? {} : { changeName })); + sendJson(res, 200, changeName === undefined + ? await listRunLogs(parsed.cwd) + : await listChangeRunLogs({ repositoryRoot: parsed.cwd, changeName })); } catch (error) { sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) }); } @@ -1470,8 +1474,15 @@ export async function handleRunLogReadRequest(req: IncomingMessage, res: ServerR return; } if (!authorizeCwd(res, policy, parsed.cwd)) return; + const changeName = (parsed as { changeName?: unknown }).changeName; + if (changeName !== undefined && (typeof changeName !== "string" || changeName.length === 0)) { + sendJson(res, 400, { error: "changeName, when given, must be a non-empty string" }); + return; + } try { - const records = await readRunLog(parsed.cwd, runId); + const records = changeName === undefined + ? await readRunLog(parsed.cwd, runId) + : await readChangeRunLog({ repositoryRoot: parsed.cwd, changeName, runId }); if (records === undefined) { sendJson(res, 404, { error: `no log is kept for run ${runId}` }); return; diff --git a/packages/server/src/server.test.ts b/packages/server/src/server.test.ts index 96980b6b..b206628a 100644 --- a/packages/server/src/server.test.ts +++ b/packages/server/src/server.test.ts @@ -1770,6 +1770,19 @@ describe("server — REST /api/status", () => { expect(escaping.status).toBe(400); expect(missing.status).toBe(404); }); + + // a-change-shows-the-logs-of-its-own-worktree: the change is named, never + // a path; a change with no worktree of its own reads the workspace root. + it("reads a named change's log from the workspace root where it has no worktree, and refuses a change that is not a name", async () => { + const cwd = await createTempWorkspace(); + await createFileRunLogs(cwd).open({ runId: "run-c", agent: "claude-cli", kind: "implement", cwd, changeName: "demo" }).end({ outcome: "completed" }); + + const named = await fetch(`${baseUrl}/api/run-logs/read`, { method: "POST", headers: JSON_HEADERS, body: JSON.stringify({ cwd, runId: "run-c", changeName: "demo" }) }); + const bad = await fetch(`${baseUrl}/api/run-logs/read`, { method: "POST", headers: JSON_HEADERS, body: JSON.stringify({ cwd, runId: "run-c", changeName: 5 }) }); + + expect(named.status).toBe(200); + expect(bad.status).toBe(400); + }); }); describe("change last runs (a-card-says-what-its-change-is-doing)", () => { @@ -1994,50 +2007,50 @@ describe("server — REST /api/status", () => { expect(await readFile(journal.filePath, "utf8")).toBe(futureJournal); }); -// main-catches-up-with-what-landed 3.1: the two routes, over a workspace -// that is not a git repository at all - which is the honest shape of "the -// repository cannot answer". -describe("the drift between a checkout and its remote", () => { - it("answers null where the repository cannot say", async () => { - const cwd = await createTempWorkspace(); + // main-catches-up-with-what-landed 3.1: the two routes, over a workspace + // that is not a git repository at all - which is the honest shape of "the + // repository cannot answer". + describe("the drift between a checkout and its remote", () => { + it("answers null where the repository cannot say", async () => { + const cwd = await createTempWorkspace(); - const response = await fetch(`${baseUrl}/api/main-drift`, { - method: "POST", - headers: JSON_HEADERS, - body: JSON.stringify({ cwd }), + const response = await fetch(`${baseUrl}/api/main-drift`, { + method: "POST", + headers: JSON_HEADERS, + body: JSON.stringify({ cwd }), + }); + const body = (await response.json()) as { drift: unknown }; + + expect(response.status).toBe(200); + expect(body.drift).toBeNull(); }); - const body = (await response.json()) as { drift: unknown }; - expect(response.status).toBe(200); - expect(body.drift).toBeNull(); - }); + it("refuses a body with no cwd", async () => { + const response = await fetch(`${baseUrl}/api/main-drift`, { + method: "POST", + headers: JSON_HEADERS, + body: JSON.stringify({}), + }); - it("refuses a body with no cwd", async () => { - const response = await fetch(`${baseUrl}/api/main-drift`, { - method: "POST", - headers: JSON_HEADERS, - body: JSON.stringify({}), + expect(response.status).toBe(400); }); - expect(response.status).toBe(400); - }); + it("answers a catch-up with a refusal rather than a failure", async () => { + const cwd = await createTempWorkspace(); - it("answers a catch-up with a refusal rather than a failure", async () => { - const cwd = await createTempWorkspace(); + const response = await fetch(`${baseUrl}/api/main-drift/catch-up`, { + method: "POST", + headers: JSON_HEADERS, + body: JSON.stringify({ cwd }), + }); + const body = (await response.json()) as { ok: boolean; why?: string }; - const response = await fetch(`${baseUrl}/api/main-drift/catch-up`, { - method: "POST", - headers: JSON_HEADERS, - body: JSON.stringify({ cwd }), + expect(response.status).toBe(200); + expect(body.ok).toBe(false); + expect(typeof body.why).toBe("string"); }); - const body = (await response.json()) as { ok: boolean; why?: string }; - - expect(response.status).toBe(200); - expect(body.ok).toBe(false); - expect(typeof body.why).toBe("string"); }); }); -}); describe("server — WebSocket /api/ws", () => { // `implement` is the one mutating command kind, and is now routed through diff --git a/packages/webui/src/components/PipelineView.test.tsx b/packages/webui/src/components/PipelineView.test.tsx index b9f0a9d9..56f5e722 100644 --- a/packages/webui/src/components/PipelineView.test.tsx +++ b/packages/webui/src/components/PipelineView.test.tsx @@ -1974,6 +1974,24 @@ describe("PipelineView — a card works its own tasks", () => { expect(screen.queryByTestId("pipeline-directory-0-node-fresh-start")).toBeNull(); }); + it("shows the logs of a change from the card of its own worktree, and not on a card that is not its own", async () => { + const onViewLogs = vi.fn(); + render( + report(change("alpha"))} + survey={async () => survey(directory(), own(), theirs({ changes: [{ changeName: "their-change", tasksDone: 0, tasksTotal: 1, blockers: [], alsoIn: [], tasks: [ownRows[1]!] }] }))} + onViewLogs={onViewLogs} + />, + ); + + const logs = await screen.findByTestId("pipeline-directory-0-node-fresh-logs"); + expect(logs).toHaveAccessibleName("Show Logs fresh"); + fireEvent.click(logs); + expect(onViewLogs).toHaveBeenCalledWith("fresh"); + expect(screen.queryByTestId("pipeline-directory-1-node-their-change-logs")).toBeNull(); + }); + it("holds a task's whole text in the hint of its row", async () => { await openOwnCard(); const row = screen.getByTestId("pipeline-directory-0-node-fresh-tasks").querySelector("[data-word='only a person can close it']"); diff --git a/packages/webui/src/components/PipelineView.tsx b/packages/webui/src/components/PipelineView.tsx index f8d465b3..8aa1fd3b 100644 --- a/packages/webui/src/components/PipelineView.tsx +++ b/packages/webui/src/components/PipelineView.tsx @@ -672,7 +672,7 @@ export function PipelineView({ // branch, or deleted from it after being there // (what-is-finished-is-tidied-away). // What the default branch already carries archived, for a foreign card - // to say so (main-catches-up-with-what-landed). + // to say so (main-catches-up-with-what-landed). const archivedOnDefault = new Set((stands.value?.standings ?? []) .filter((standing) => standing.main?.kind === "archived") .map((standing) => standing.changeName)); @@ -828,329 +828,329 @@ export function PipelineView({ // The zoom is one factor on everything the picture draws, cards, text // and lines alike; no layout unit changes with it. -
}> -
-
- {here ? ( -

- Read from {branchPhrase(here)} in {here.label} -

- ) : null} -

- {/* A reading, not a subscription: between two of them a run can +

}> +
+
+ {here ? ( +

+ Read from {branchPhrase(here)} in {here.label} +

+ ) : null} +

+ {/* A reading, not a subscription: between two of them a run can start and finish, so this never presents itself as live. */} - Last read {local.readAt ? local.readAt.toLocaleTimeString() : "not yet"} - {survey ? `; other working directories ${others.readAt ? others.readAt.toLocaleTimeString() : "not yet"}` : ""}. -

- {here ? ( -
    - {describeDirectoryRuns(here, now, onCards).map((line, index) =>
  • {line}
  • )} -
- ) : null} -
-
- - -
- - {/* The level is the control that resets it. */} -
+
+ - -
- {/* Two arrangements of the same cards: by what each change waits - for, or as a board of the stages it goes through - (the-board-shows-the-stages). Offered only where a host reads - the stages. */} - {stages ? ( -
- - +
- ) : null} - {/* The order within each column (the-board-sorts-its-cards). Names + {/* Two arrangements of the same cards: by what each change waits + for, or as a board of the stages it goes through + (the-board-shows-the-stages). Offered only where a host reads + the stages. */} + {stages ? ( +
+ + +
+ ) : null} + {/* The order within each column (the-board-sorts-its-cards). Names are read as a person reads numbers, so a numbered change stands in its place. */} - - {refresh ? ( - - ) : null} - {/* The same narrowing the editor's views take, over a change's + + {refresh ? ( + + ) : null} + {/* The same narrowing the editor's views take, over a change's name and the word beside it (what-is-finished-is-tidied-away). */} - + +
-
- - {refs ?

{refs}

: null} - {refreshError !== undefined - ?

{`Refresh failed: ${refreshError}`}

- : null} + + {refs ?

{refs}

: null} + {refreshError !== undefined + ?

{`Refresh failed: ${refreshError}`}

+ : null} - {/* The board is of the work, so it is named for the work; the + {/* The board is of the work, so it is named for the work; the arrangement by declared order draws this checkout's own changes and says so (a-change-is-one-card-wherever-it-is). */} -
-
-

{onBoard ? "Changes" : "Changes in this checkout"}

- - {onBoard ? "a column is where a change is now, wherever it is worked" : "a column starts after the ones before it"} - -
-
- {filter.trim().length > 0 && report !== undefined ? ( -

- {shownChanges.length === 0 - ? `Nothing matches "${filter.trim()}"` - : `Filtered by "${filter.trim()}" - showing ${shownChanges.length} of ${report.changes.length}`} -

- ) : null} - {/* How far this checkout is behind what has landed. Said only +
+
+

{onBoard ? "Changes" : "Changes in this checkout"}

+ + {onBoard ? "a column is where a change is now, wherever it is worked" : "a column starts after the ones before it"} + +
+
+ {filter.trim().length > 0 && report !== undefined ? ( +

+ {shownChanges.length === 0 + ? `Nothing matches "${filter.trim()}"` + : `Filtered by "${filter.trim()}" - showing ${shownChanges.length} of ${report.changes.length}`} +

+ ) : null} + {/* How far this checkout is behind what has landed. Said only where it is behind: a line that is always there is a line nobody reads (main-catches-up-with-what-landed). */} - {behind.value !== undefined && behind.value.behind > 0 ? ( -

- {driftWords(behind.value)} - {onCatchUp ? ( - + ) : null} + {caughtUp !== null ? {caughtUp} : null} +

+ ) : null} + {foldedCount > 0 ? ( +

+ {`${foldedCount} ${foldedCount === 1 ? "change has" : "changes have"} landed`} + - ) : null} - {caughtUp !== null ? {caughtUp} : null} -

- ) : null} - {foldedCount > 0 ? ( -

- {`${foldedCount} ${foldedCount === 1 ? "change has" : "changes have"} landed`} - - {onArchive ? ( - + ) : null} +

+ ) : null} + {showLanded && landedHere.length > 0 ? ( +

+ {`${landedHere.length} of these have landed.`} + - ) : null} -

- ) : null} - {showLanded && landedHere.length > 0 ? ( -

- {`${landedHere.length} of these have landed.`} - -

- ) : null} - {showLegend ? : null} - {local.error !== undefined - ?

{local.error}

- : null} - {report === undefined - ? (local.error === undefined - ?

{PIPELINE_FIRST_READING}

- : null) - : nothingToDraw && !onBoard - // Names the branch it read where the survey says which: an - // empty queue and a reading taken on a stale checkout - // otherwise look identical. - ? (report.changes.length === 0 - ?

No active changes{here ? ` on ${branchPhrase(here)}` : ""}.

- :

Nothing to draw here.

) - : ( - <> - {/* Drawn even with nothing on it. A board whose columns +

+ ) : null} + {showLegend ? : null} + {local.error !== undefined + ?

{local.error}

+ : null} + {report === undefined + ? (local.error === undefined + ?

{PIPELINE_FIRST_READING}

+ : null) + : nothingToDraw && !onBoard + // Names the branch it read where the survey says which: an + // empty queue and a reading taken on a stale checkout + // otherwise look identical. + ? (report.changes.length === 0 + ?

No active changes{here ? ` on ${branchPhrase(here)}` : ""}.

+ :

Nothing to draw here.

) + : ( + <> + {/* Drawn even with nothing on it. A board whose columns appeared only once something stood in them would say nothing about the way through, and a person pressing "By stage" on an empty queue would see no board at all (the-board-is-of-every-change). */} - - {/* A file that breaks a rule is refused whole, and said: + + {/* A file that breaks a rule is refused whole, and said: silence would leave a person believing the board is theirs (a-team-names-its-columns). */} - {onBoard && named.value?.kind === "refused" ? ( -

{`openspec/board.json is not used: ${named.value.reason}`}

- ) : null} - {/* The archive holds more than a board should draw: the + {onBoard && named.value?.kind === "refused" ? ( +

{`openspec/board.json is not used: ${named.value.reason}`}

+ ) : null} + {/* The archive holds more than a board should draw: the rest is counted, never listed. */} - {onBoard && describeOlderArchive(filed.value) !== undefined ? ( -

{describeOlderArchive(filed.value)}

- ) : null} - {nothingToDraw ? ( -

- {report.changes.length === 0 - ? `No active changes${here ? ` on ${branchPhrase(here)}` : ""}: every column is empty.` - : "Every column is empty: nothing here matches the filter."} -

- ) : null} - - )} -
-
+ {onBoard && describeOlderArchive(filed.value) !== undefined ? ( +

{describeOlderArchive(filed.value)}

+ ) : null} + {nothingToDraw ? ( +

+ {report.changes.length === 0 + ? `No active changes${here ? ` on ${branchPhrase(here)}` : ""}: every column is empty.` + : "Every column is empty: nothing here matches the filter."} +

+ ) : null} + + )} +
+
- {/* From the report this already read: no second fetch, and no + {/* From the report this already read: no second fetch, and no suggestion computed here — `buildHints` derived them in core before the payload was sent. */} - {report !== undefined ? : null} - {others.value ? : null} - {others.error !== undefined - ?

The other working directories could not be read: {others.error}

- : null} - {answerTarget !== undefined && !nothingLeftToAnswer && onAnswerQuestion !== undefined ? ( - setAnswerFor(undefined)}> - { - onAnswerQuestion({ - changeName: answerTarget.changeName, - ...(answerTarget.runId !== undefined ? { runId: answerTarget.runId } : {}), - workingDirectory: answerTarget.workingDirectory, - questionId, - answer, - }); - setTimeout(() => void Promise.all([others.read(), held.read()]), RUN_CONTROL_REREAD_MS); - }} - onClose={() => setAnswerFor(undefined)} - /> - - ) : null} - {archiveLanded !== undefined && onArchive !== undefined ? ( - setArchiveLanded(undefined)}> -
{ - event.preventDefault(); - onArchive(archiveLanded); - setArchiveLanded(undefined); - }} - > -

{`Archive ${archiveLanded.length === 1 ? archiveLanded[0] : `${archiveLanded.length} landed changes`}?`}

-

Their specs are merged into openspec/specs, and the changes move to the archive.

-
- - -
-
-
- ) : null} - {confirmFor !== undefined && changeActions !== undefined ? ( - setConfirmFor(undefined)}> - { - changeActions.perform(confirmFor.action.id, confirmFor.changeName, { confirmed: true }); - setConfirmFor(undefined); - }} - onCancel={() => setConfirmFor(undefined)} - /> - - ) : null} - {stopFor !== undefined && ("runId" in stopFor ? sendRunControl !== undefined : onAskToStop !== undefined) ? ( - setStopFor(undefined)}> - { - if ("runId" in stopFor) { - sendRunControl?.({ changeName: stopFor.changeName, runId: stopFor.runId, kind: "stop", reason }); - } else { - // Through the signed channel to a run held elsewhere. The card - // says it is waiting until the run's record shows the stop. It - // reads the runs again soon, and at each clock tick until the - // window a run has to read the request is past, so "has not - // read" rests on a reading taken after that window, never on - // one from before the run's renewal (found by 4.5's live stop). - const { changeName, instanceId } = stopFor; - onAskToStop?.({ changeName, instanceId, reason }); - setStopsAsked((current) => new Map(current).set(instanceId, new Date().toISOString())); - const rereadUntil = Date.now() + STOP_REQUEST_READ_WITHIN_MS; - const reread = () => { - void Promise.all([others.read(), held.read()]); - if (Date.now() <= rereadUntil) setTimeout(reread, PIPELINE_CLOCK_INTERVAL_MS); - }; - setTimeout(reread, RUN_CONTROL_REREAD_MS); - } - setStopFor(undefined); - }} - onCancel={() => setStopFor(undefined)} - /> - - ) : null} - {chosen !== undefined ? ( - setChosen(undefined)}> - setChosen(undefined)} - // What a press changed is read again at once, and the panel shows - // the task as it now reads. - onChanged={() => void Promise.all([local.read(), others.read(), ended.read()])} - /> - - ) : null} -
+ {report !== undefined ? : null} + {others.value ? : null} + {others.error !== undefined + ?

The other working directories could not be read: {others.error}

+ : null} + {answerTarget !== undefined && !nothingLeftToAnswer && onAnswerQuestion !== undefined ? ( + setAnswerFor(undefined)}> + { + onAnswerQuestion({ + changeName: answerTarget.changeName, + ...(answerTarget.runId !== undefined ? { runId: answerTarget.runId } : {}), + workingDirectory: answerTarget.workingDirectory, + questionId, + answer, + }); + setTimeout(() => void Promise.all([others.read(), held.read()]), RUN_CONTROL_REREAD_MS); + }} + onClose={() => setAnswerFor(undefined)} + /> + + ) : null} + {archiveLanded !== undefined && onArchive !== undefined ? ( + setArchiveLanded(undefined)}> +
{ + event.preventDefault(); + onArchive(archiveLanded); + setArchiveLanded(undefined); + }} + > +

{`Archive ${archiveLanded.length === 1 ? archiveLanded[0] : `${archiveLanded.length} landed changes`}?`}

+

Their specs are merged into openspec/specs, and the changes move to the archive.

+
+ + +
+
+
+ ) : null} + {confirmFor !== undefined && changeActions !== undefined ? ( + setConfirmFor(undefined)}> + { + changeActions.perform(confirmFor.action.id, confirmFor.changeName, { confirmed: true }); + setConfirmFor(undefined); + }} + onCancel={() => setConfirmFor(undefined)} + /> + + ) : null} + {stopFor !== undefined && ("runId" in stopFor ? sendRunControl !== undefined : onAskToStop !== undefined) ? ( + setStopFor(undefined)}> + { + if ("runId" in stopFor) { + sendRunControl?.({ changeName: stopFor.changeName, runId: stopFor.runId, kind: "stop", reason }); + } else { + // Through the signed channel to a run held elsewhere. The card + // says it is waiting until the run's record shows the stop. It + // reads the runs again soon, and at each clock tick until the + // window a run has to read the request is past, so "has not + // read" rests on a reading taken after that window, never on + // one from before the run's renewal (found by 4.5's live stop). + const { changeName, instanceId } = stopFor; + onAskToStop?.({ changeName, instanceId, reason }); + setStopsAsked((current) => new Map(current).set(instanceId, new Date().toISOString())); + const rereadUntil = Date.now() + STOP_REQUEST_READ_WITHIN_MS; + const reread = () => { + void Promise.all([others.read(), held.read()]); + if (Date.now() <= rereadUntil) setTimeout(reread, PIPELINE_CLOCK_INTERVAL_MS); + }; + setTimeout(reread, RUN_CONTROL_REREAD_MS); + } + setStopFor(undefined); + }} + onCancel={() => setStopFor(undefined)} + /> + + ) : null} + {chosen !== undefined ? ( + setChosen(undefined)}> + setChosen(undefined)} + // What a press changed is read again at once, and the panel shows + // the task as it now reads. + onChanged={() => void Promise.all([local.read(), others.read(), ended.read()])} + /> + + ) : null} +
); } @@ -1407,6 +1407,7 @@ function LocalPicture({ report, cards, now, onOpenChange, alsoIn, controls, dire actions={elsewhereActions.get(name) ?? []} {...(controls.chooseAction !== undefined ? { onChooseAction: controls.chooseAction } : {})} {...(controls.onStart !== undefined ? { onStart: controls.onStart } : {})} + {...(controls.onViewLogs !== undefined ? { onViewLogs: controls.onViewLogs } : {})} /> ); } @@ -1627,68 +1628,68 @@ function Picture({ layout, testIdPrefix, laneHeading, renderNode }: { return ( <> - {/* Its own scroller, so a wide repository never makes the page body + {/* Its own scroller, so a wide repository never makes the page body scroll sideways — the rule the shell's tables already follow. */} -
-
} - > - - {byColumn.map((nodes, column) => { - // The board dresses its columns: a stage's picture and its own - // colour beside its word, and how many stand in it - // (the-board-wears-its-stages). Absent in the arrangement by - // declared order, whose columns are a sequence rather than six - // named places. - const look = layout.laneLooks?.[column]; - const x = column * (NODE_WIDTH + COLUMN_GAP); - return ( -
- {/* A rule between stages, never before the first. */} - {look && column > 0 ? ( - ); } @@ -2216,7 +2217,7 @@ function OtherDirectories({ survey, labels, now, onCards, openCards, archivedOnM survey: WorktreeSurvey; /** A change's actions, for the card of its own worktree drawn here, as * on the board (a-change-is-committed-where-it-is-made). */ - controls?: Pick; + controls?: Pick; order: ChangeOrder; labels: Map; now: Date; @@ -2261,7 +2262,7 @@ function OtherDirectories({ survey, labels, now, onCards, openCards, archivedOnM function OtherDirectory({ directory, index, labels, now, onCards, openCards, archivedOnMain, drawsChanges, order, controls }: { directory: SurveyedDirectory; - controls?: Pick; + controls?: Pick; order: ChangeOrder; index: number; labels: Map; @@ -2347,7 +2348,7 @@ function foreignDetails( function ForeignChanges({ directory, testId, labels, openCards, archivedOnMain, order, controls }: { directory: Extract; - controls?: Pick; + controls?: Pick; order: ChangeOrder; testId: string; labels: Map; @@ -2408,6 +2409,7 @@ function ForeignChanges({ directory, testId, labels, openCards, archivedOnMain, actions={actionsOf.get(node.change.changeName) ?? []} {...(controls?.chooseAction !== undefined ? { onChooseAction: controls.chooseAction } : {})} {...(controls?.onStart !== undefined ? { onStart: controls.onStart } : {})} + {...(controls?.onViewLogs !== undefined ? { onViewLogs: controls.onViewLogs } : {})} /> )} /> @@ -2440,7 +2442,7 @@ function elsewhereDetails( * that directory is the change's own worktree, which its card works * (ADR 0026 amended 2026-10-05, a-card-works-its-own-tasks). Then its name * opens the change there, and its rows and list carry their actions. */ -function DirectoryCard({ node, change, directory, details, testId, open, onToggle, actions, onChooseAction, onStart }: { +function DirectoryCard({ node, change, directory, details, testId, open, onToggle, actions, onChooseAction, onStart, onViewLogs }: { node: ChangeLayoutNode; change: SurveyedChange | undefined; directory: Extract; @@ -2455,6 +2457,8 @@ function DirectoryCard({ node, change, directory, details, testId, open, onToggl /** Starts the change, on a card of its own worktree: the run runs there * (a-change-is-committed-where-it-is-made). */ onStart?: (changeName: string, directory?: string) => void; + /** Shows the change's run logs, on a card of its own worktree, where they are kept. */ + onViewLogs?: (changeName: string) => void; }) { const taskCards = useContext(TaskCardContext); const name = node.change.changeName; @@ -2499,6 +2503,11 @@ function DirectoryCard({ node, change, directory, details, testId, open, onToggl : }
) : null} + {own && onViewLogs !== undefined ? ( +
+ +
+ ) : null} {counted ? : null} {rows.length > 0 ? ( @@ -2517,8 +2526,9 @@ function DirectoryCard({ node, change, directory, details, testId, open, onToggl } /** A change of another working directory, drawn on this board. */ -function ElsewhereNode({ node, change, where, labels, archivedOnMain, stage, now, testId, open, onToggle, actions, onChooseAction, onStart }: { +function ElsewhereNode({ node, change, where, labels, archivedOnMain, stage, now, testId, open, onToggle, actions, onChooseAction, onStart, onViewLogs }: { onStart?: (changeName: string, directory?: string) => void; + onViewLogs?: (changeName: string) => void; node: ChangeLayoutNode; change: SurveyedChange; where: Extract; @@ -2533,13 +2543,14 @@ function ElsewhereNode({ node, change, where, labels, archivedOnMain, stage, now onChooseAction?: (action: ChangeAction, changeName: string) => void; }) { const details = elsewhereDetails(change, where, labels, archivedOnMain, stage, now); - return ; + return ; } /** A change of another working directory, drawn in that directory's own * picture. */ -function ForeignNode({ node, change, directory, labels, testId, open, onToggle, archivedOnMain, actions, onChooseAction, onStart }: { +function ForeignNode({ node, change, directory, labels, testId, open, onToggle, archivedOnMain, actions, onChooseAction, onStart, onViewLogs }: { onStart?: (changeName: string, directory?: string) => void; + onViewLogs?: (changeName: string) => void; node: ChangeLayoutNode; actions?: readonly ChangeActionState[]; onChooseAction?: (action: ChangeAction, changeName: string) => void; @@ -2552,5 +2563,5 @@ function ForeignNode({ node, change, directory, labels, testId, open, onToggle, archivedOnMain: ReadonlySet; }) { const details = [...foreignDetails(change, node.change.blockers, labels, archivedOnMain), ...notOnServerDetails(change, directory)]; - return ; + return ; } diff --git a/packages/webui/src/pipeline-entry.tsx b/packages/webui/src/pipeline-entry.tsx index 9130f497..2d264453 100644 --- a/packages/webui/src/pipeline-entry.tsx +++ b/packages/webui/src/pipeline-entry.tsx @@ -146,7 +146,7 @@ function PipelineApp() { // (a-change-shows-its-run-logs). const [logsFor, setLogsFor] = useState(null); const logsLoad = useCallback(() => bridge.request("pipeline/run-logs", { changeName: logsFor ?? "" }), [bridge, logsFor]); - const logsRead = useCallback((runId: string) => bridge.request("pipeline/run-log", { runId }), [bridge]); + const logsRead = useCallback((runId: string) => bridge.request("pipeline/run-log", { runId, ...(logsFor !== null ? { changeName: logsFor } : {}) }), [bridge, logsFor]); // The zoom and the open cards, in the webview's own state: the panel does // not keep its page while hidden, so a view that kept them alone would // reset every time (a-card-opens-to-its-tasks). The view guards both. diff --git a/packages/webui/src/run-logs-client.ts b/packages/webui/src/run-logs-client.ts index 71f0b10e..8d2ee22b 100644 --- a/packages/webui/src/run-logs-client.ts +++ b/packages/webui/src/run-logs-client.ts @@ -21,6 +21,6 @@ export function listChangeRunLogs(request: RunLogsRequest, cwd: string, changeNa return post(request, "/api/run-logs/list", { cwd, changeName }); } -export function readChangeRunLog(request: RunLogsRequest, cwd: string, runId: string): Promise { - return post(request, "/api/run-logs/read", { cwd, runId }); +export function readChangeRunLog(request: RunLogsRequest, cwd: string, runId: string, changeName?: string): Promise { + return post(request, "/api/run-logs/read", { cwd, runId, ...(changeName !== undefined ? { changeName } : {}) }); } diff --git a/packages/webui/src/standalone-entry.tsx b/packages/webui/src/standalone-entry.tsx index 17ce9871..ba5b1e47 100644 --- a/packages/webui/src/standalone-entry.tsx +++ b/packages/webui/src/standalone-entry.tsx @@ -600,9 +600,9 @@ function StandaloneApp() { void askRunToStopRequest(apiFetch, cwd, request.instanceId, request.reason).catch(() => undefined); }, [cwd]); // What the folded row presses: the changes it named, archived one after - // another by the server, and the readings taken again so the picture - // stops drawing what is now in the archive - // (what-is-finished-is-tidied-away). + // another by the server, and the readings taken again so the picture + // stops drawing what is now in the archive + // (what-is-finished-is-tidied-away). const pipelineArchive = useCallback((changeNames: string[]) => { void (async () => { try { @@ -681,7 +681,7 @@ function StandaloneApp() { }, }), []); const logsLoad = useCallback(() => listChangeRunLogs(apiFetch, cwd, logsFor ?? ""), [cwd, logsFor]); - const logsRead = useCallback((runId: string) => readChangeRunLog(apiFetch, cwd, runId), [cwd]); + const logsRead = useCallback((runId: string) => readChangeRunLog(apiFetch, cwd, runId, logsFor ?? undefined), [cwd, logsFor]); // `handleRunWithHarness` is a hoisted declaration further down and reads // `cwd` itself, as `loadChangeEditor` does for `openChangeInEditor` below. const pipelineStart = useCallback((changeName: string, directory?: string) => { @@ -2007,1006 +2007,1006 @@ function StandaloneApp() {
: null}
- {isStandaloneHost && pageHead ? ( - - Refresh Summary - - ) : activeTab === "harness-settings" ? ( - - ) : undefined} - /> - ) : null} + {isStandaloneHost && pageHead ? ( + + Refresh Summary + + ) : activeTab === "harness-settings" ? ( + + ) : undefined} + /> + ) : null} - {/* Outside the tabs, because a schedule moves a person between + {/* Outside the tabs, because a schedule moves a person between them: a sentence rendered only inside the change editor was unreadable to whoever it had just moved somewhere else, and unannounced to a screen reader either way. See a-schedule-keeps-its-promise. */} -
- {scheduleMessage ?

{scheduleMessage}

: null} -
+
+ {scheduleMessage ?

{scheduleMessage}

: null} +
- + - - {/* The shared components (the-remaining-tabs-wear-metro): where the + + {/* The shared components (the-remaining-tabs-wear-metro): where the work happens in one panel, initialization in its own, and the run itself below them. */} -
-
-
-

Where the work happens

- the workspace every command runs in -
-
-
- - -
-
-

- Changing Workspace root (cwd) auto-fills Change directory as - openspec/changes. - {workspaceRootSyncError ? ` Workspace root sync failed: ${workspaceRootSyncError}` : ""} -

-
- - {canInitialize ? ( -
-
-

This workspace is not initialized

- OpenSpec initialization artifacts were not found -
-
- - - -
-
- - {initMessage ? {initMessage} : null} -
-
- ) : null} +
+
+
+

Where the work happens

+ the workspace every command runs in +
+
+
+ + +
+
+

+ Changing Workspace root (cwd) auto-fills Change directory as + openspec/changes. + {workspaceRootSyncError ? ` Workspace root sync failed: ${workspaceRootSyncError}` : ""} +

+
- {cwd.trim().length > 0 && changeDir.trim().length > 0 ? ( - void handleRefreshAgents()} - onRunTerminal={isStandaloneHost ? handleRunTerminal : undefined} - /> - ) : ( -
-

Run a command

-

Enter cwd and change directory to enable the AI panel.

-
+ {canInitialize ? ( +
+
+

This workspace is not initialized

+ OpenSpec initialization artifacts were not found +
+
+ + + +
+
+ + {initMessage ? {initMessage} : null} +
+
+ ) : null} + + {cwd.trim().length > 0 && changeDir.trim().length > 0 ? ( + void handleRefreshAgents()} + onRunTerminal={isStandaloneHost ? handleRunTerminal : undefined} + /> + ) : ( +
+

Run a command

+

Enter cwd and change directory to enable the AI panel.

+
+ )} +
+ + + {visibleTabIds.has("processes") && ( + + + +
+ {cwd.trim().length > 0 ? :

Enter workspace root to load processes.

} +
+
+
)} -
-
- - {visibleTabIds.has("processes") && ( - - - -
- {cwd.trim().length > 0 ? :

Enter workspace root to load processes.

} -
-
-
- )} - - {visibleTabIds.has("diff-preview") && ( - - - - {/* The shared components, as every redrawn tab uses them + + {visibleTabIds.has("diff-preview") && ( + + + + {/* The shared components, as every redrawn tab uses them (the-remaining-tabs-wear-metro): the tab's controls in one toolbar, and what it read in a panel that names it. */} -
-
- - - {diffError ? {diffError} : null} -
- -
-
-

{diffChangeName.length > 0 ? diffChangeName : "Uncommitted work"}

- {diffAnswer && diffAnswer.diff.length > 0 ? ( - - {diffAnswer.files.length === 1 ? "1 file" : `${diffAnswer.files.length} files`} changed - - ) : null} -
- {diffAnswer ? ( - diffAnswer.diff.length === 0 ? ( -

This change has nothing uncommitted.

- ) : ( - <> - {/* The files the answer already carries, which the tab - only counted before. */} - - - - - - {diffAnswer.files.map((file) => )} - -
File
{file}
-
- +
+
+ + + {diffError ? {diffError} : null}
- {diffAnswer.truncated ? ( -

- The diff was cut to its first {Math.round(diffAnswer.maxBytes / 1000)} KB; the rest is not shown. -

- ) : null} - - ) - ) : ( -

Choose a change to see what it has changed and not yet committed.

- )} -
-
-
-
- )} - - {visibleTabIds.has("overview") && ( - - - -
- {overviewError ?

Failed to load summary: {overviewError}

: null} - - {overview ? (() => { - // What the mockup's summary shows, worked out in one place - // (the-summary-looks-like-the-mockup). - const figures = summaryFigures(overview, humanOnly?.status === "loaded" ? humanOnly.inbox.items : null); - return ( -
-
    - {figures.tiles.map((tile) => ( -
  • - - - {tile.label} - {tile.value} - {tile.note} - -
  • - ))} -
- - setComplianceOpen(true)} - changes={overview.changes.map((change) => toChangeSummary(change, toChangeState(change.status)))} - ownerships={changeOwners} - {...(standings && standingStates - ? { - states: standingStates, - sources: describeStandingSources(standings.sources), - } - : {})} - onRefresh={() => void refreshStandings()} - refreshing={standingsRefreshing} - {...(standingsError ? { refreshError: standingsError } : {})} - footnote={Workspace read from {overview.root.path} ({overview.root.source}).} - /> -
- - toChangeSummary(change, "archived"))} - /> +
+
+

{diffChangeName.length > 0 ? diffChangeName : "Uncommitted work"}

+ {diffAnswer && diffAnswer.diff.length > 0 ? ( + + {diffAnswer.files.length === 1 ? "1 file" : `${diffAnswer.files.length} files`} changed + + ) : null} +
+ {diffAnswer ? ( + diffAnswer.diff.length === 0 ? ( +

This change has nothing uncommitted.

+ ) : ( + <> + {/* The files the answer already carries, which the tab + only counted before. */} + + + + + + {diffAnswer.files.map((file) => )} + +
File
{file}
+
+ +
+ {diffAnswer.truncated ? ( +

+ The diff was cut to its first {Math.round(diffAnswer.maxBytes / 1000)} KB; the rest is not shown. +

+ ) : null} + + ) + ) : ( +

Choose a change to see what it has changed and not yet committed.

+ )} +
-
- ); - })() : null} - - void handleRemoveLeftover(target)} /> - - {humanOnly ? ( -
-

Waiting on somebody

-
-

- {(() => { - const said = describeHumanOnlyInboxState(humanOnly); - return withMessageCode(said.text, said.code); - })()} -

- {humanOnly.status === "loaded" && humanOnly.inbox.items.length > 0 ? ( -
    - {humanOnly.inbox.items.map((item) => { - const key = `${item.changeName}:${item.lineNumber}`; - // The control exists only where it can do something: - // an item waiting on a person is offered no run, and - // neither is one naming an id this build does not - // carry. See a-delegated-item-runs-its-agent. - const runnable = item.waitingOn.kind === "agent" && item.waitingOn.known; - const outcome = delegatedOutcomes[key]; - const who = describeWaitingOn(item.waitingOn); + + + )} + + {visibleTabIds.has("overview") && ( + + + +
    + {overviewError ?

    Failed to load summary: {overviewError}

    : null} + + {overview ? (() => { + // What the mockup's summary shows, worked out in one place + // (the-summary-looks-like-the-mockup). + const figures = summaryFigures(overview, humanOnly?.status === "loaded" ? humanOnly.inbox.items : null); return ( -
  • - {item.changeName}{` — ${item.text}`} - {` (waiting on ${withMessageCode(who.text, who.code)})`} - {runnable ? ( - <> - {" "} - - +
    +
      + {figures.tiles.map((tile) => ( +
    • + + + {tile.label} + {tile.value} + {tile.note} + +
    • + ))} +
    + + setComplianceOpen(true)} + changes={overview.changes.map((change) => toChangeSummary(change, toChangeState(change.status)))} + ownerships={changeOwners} + {...(standings && standingStates + ? { + states: standingStates, + sources: describeStandingSources(standings.sources), + } + : {})} + onRefresh={() => void refreshStandings()} + refreshing={standingsRefreshing} + {...(standingsError ? { refreshError: standingsError } : {})} + footnote={Workspace read from {overview.root.path} ({overview.root.source}).} + /> + +
    + + toChangeSummary(change, "archived"))} + /> +
    +
    + ); + })() : null} + + void handleRemoveLeftover(target)} /> + + {humanOnly ? ( +
    +

    Waiting on somebody

    +
    +

    + {(() => { + const said = describeHumanOnlyInboxState(humanOnly); + return withMessageCode(said.text, said.code); + })()} +

    + {humanOnly.status === "loaded" && humanOnly.inbox.items.length > 0 ? ( +
      + {humanOnly.inbox.items.map((item) => { + const key = `${item.changeName}:${item.lineNumber}`; + // The control exists only where it can do something: + // an item waiting on a person is offered no run, and + // neither is one naming an id this build does not + // carry. See a-delegated-item-runs-its-agent. + const runnable = item.waitingOn.kind === "agent" && item.waitingOn.known; + const outcome = delegatedOutcomes[key]; + const who = describeWaitingOn(item.waitingOn); + return ( +
    • + {item.changeName}{` — ${item.text}`} + {` (waiting on ${withMessageCode(who.text, who.code)})`} + {runnable ? ( + <> + {" "} + + + ) : null} + {item.reply ? : null} + {outcome ? ( +
      + {withMessageCode(outcome.message, outcome.code)} + {outcome.lastStderr ? ( + // The whole tail, one click away; the message + // already quotes its last line. +
      + What the agent last said +
      {outcome.lastStderr}
      +
      + ) : null} +
      + ) : null} +
    • + ); + })} +
    ) : null} - {item.reply ? : null} - {outcome ? ( -
    - {withMessageCode(outcome.message, outcome.code)} - {outcome.lastStderr ? ( - // The whole tail, one click away; the message - // already quotes its last line. -
    - What the agent last said -
    {outcome.lastStderr}
    -
    - ) : null} -
    + {humanOnly.status === "loaded" && (humanOnly.inbox.questions ?? []).length > 0 ? ( + ) : null} -
  • - ); - })} -
- ) : null} - {humanOnly.status === "loaded" && (humanOnly.inbox.questions ?? []).length > 0 ? ( - - ) : null} - {humanOnly.status === "loaded" ? ( - void confirmEnrolment(keyId)} - /> - ) : null} -
-
- ) : null} -
-
-
- )} - - {visibleTabIds.has("change-editor") && ( - - - - {/* The shared components (the-remaining-tabs-wear-metro): one + {humanOnly.status === "loaded" ? ( + void confirmEnrolment(keyId)} + /> + ) : null} +
+ + ) : null} +
+ + + )} + + {visibleTabIds.has("change-editor") && ( + + + + {/* The shared components (the-remaining-tabs-wear-metro): one toolbar for the tab, the documents behind a segmented control, and the editor in a panel with its save in the foot. */} -
-
-
-

A new change

- creates the directory and its documents -
-
-
- - -
-
-
- -
-
- -
- - - - - - - {editorMessage ? {editorMessage} : null} - {runHarnessMessage ? {runHarnessMessage} : null} -
+
+
+
+

A new change

+ creates the directory and its documents +
+
+
+ + +
+
+
+ +
+
+ +
+ + + + + + + {editorMessage ? {editorMessage} : null} + {runHarnessMessage ? {runHarnessMessage} : null} +
- {runDispatch && runOpenedFrom === "editor" ? ( - void applyTemplateToChange(template)} - appliedNote={runAppliedNote} - onUseAgent={(agentId) => void putAgentOnEveryStage(agentId)} - useAgentNote={runUseAgentNote} - onSchedule={(path, startAt) => void scheduleRun(path, startAt)} - {...(runNote ? { note: runNote } : {})} - onDismiss={() => { setRunDispatch(null); setRunNote(null); setRunAppliedNote(null); setRunUseAgentNote(null); }} - /> - ) : null} - {chainChangeDir ? ( - - ) : null} + {runDispatch && runOpenedFrom === "editor" ? ( + void applyTemplateToChange(template)} + appliedNote={runAppliedNote} + onUseAgent={(agentId) => void putAgentOnEveryStage(agentId)} + useAgentNote={runUseAgentNote} + onSchedule={(path, startAt) => void scheduleRun(path, startAt)} + {...(runNote ? { note: runNote } : {})} + onDismiss={() => { setRunDispatch(null); setRunNote(null); setRunAppliedNote(null); setRunUseAgentNote(null); }} + /> + ) : null} + {chainChangeDir ? ( + + ) : null} -
-
- {(["proposal", "design", "tasks", "spec"] as EditorTab[]).map((tab) => ( - - ))} - -
-
+
+
+ {(["proposal", "design", "tasks", "spec"] as EditorTab[]).map((tab) => ( + + ))} + +
+
- {harnessPaneOpen ? ( - // The loaded change, not the one merely selected: choosing another - // change in the list clears the revision until it is loaded. - editorRevision.length > 0 && editorChangeName.trim().length > 0 ? ( - setActiveTab("harness-settings")} - /> - ) : ( -
-

Harness

-

Load a change to configure it.

-
- ) - ) : ( - <> -
- {/* The change, with the document as its note: the textarea + {harnessPaneOpen ? ( + // The loaded change, not the one merely selected: choosing another + // change in the list clears the revision until it is loaded. + editorRevision.length > 0 && editorChangeName.trim().length > 0 ? ( + setActiveTab("harness-settings")} + /> + ) : ( +
+

Harness

+

Load a change to configure it.

+
+ ) + ) : ( + <> +
+ {/* The change, with the document as its note: the textarea below already carries the label "Markdown (proposal)", and a head repeating it spends the line twice. */} -
-

{editorChangeName.trim().length > 0 ? editorChangeName : "No change loaded"}

- {editorTab} -
- {editorTab === "tasks" ? ( -
- - -
- ) : null} - {editorTab === "tasks" && archivedTemplateMessage ? ( -

{archivedTemplateMessage}

- ) : null} - -
-