From 3bd1ae55136b1a1a8bbf80b221215ef363d35fd3 Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Fri, 26 Jun 2026 04:22:50 -0700 Subject: [PATCH] add spec ./specs/coauthoring-live-progress.md (status: graduated) --- specs/coauthoring-live-progress.md | 398 +++++++++++++++++++++++++++++ 1 file changed, 398 insertions(+) create mode 100644 specs/coauthoring-live-progress.md diff --git a/specs/coauthoring-live-progress.md b/specs/coauthoring-live-progress.md new file mode 100644 index 0000000..fc87c91 --- /dev/null +++ b/specs/coauthoring-live-progress.md @@ -0,0 +1,398 @@ +--- +status: graduated +--- +# Solution Design: Live Turn Progress (#60) + +| | | +| --- | --- | +| **Author(s)** | Ben Stull (with Claude) | +| **Reviewers / approvers** | Ben Stull | +| **Status** | `graduated` | +| **Version** | v0.1.0 | +| **Source artifacts** | Feature `benstull/vscode-cowriting-plugin#60` (Show Claude's live output/progress during the "asking Claude…" status, `type/feature`, `priority/P1`) · Capture session `vscode-cowriting-plugin-0054` (2026-06-15) · Brainstorming session `vscode-cowriting-plugin-0055` · Builds on (all shipped): the LiveTurn machine-edit ingress (`liveTurn.ts`, INV-8/9), F4 `#12` (propose/accept seam), F10 `#29` (interactive review preview), F11 `#43` (preview toolbar + `editDocument`/`runEditAndPropose`) · Parent specs (graduated): `coauthoring-inner-loop.md`, `coauthoring-propose-accept.md`, `coauthoring-rendered-preview.md`, `coauthoring-document-edit-flow.md` · Lineage: `ben.stull/rfc-app#48` | + +**Change log** + +| Date | Version | Change | By | +| --- | --- | --- | --- | +| 2026-06-22 | v0.1.0 | Initial draft — brainstorming session 0055. Three forks locked with the operator: **(1) surface** = the existing "asking Claude…" notification carries a live **activity line** *plus* a host **OutputChannel** streaming the full assistant text; **(2) content** = activity (`thinking…`/`writing… (N chars)`/`running …`) **+ running token count**; **(3) cancellation** = reflect aborted/failed states **and** add a cancel button wired to `agent.abort()`. Two sub-decisions confirmed: OutputChannel auto-reveals (preserveFocus) on first text delta, gated by a `cowriting.liveProgress.revealOutput` setting (default on); the channel **appends** per-turn (with a header), doubling as a debug log. | Ben Stull + Claude | + +--- + +## 1. Business Context + +### 1.1 Executive Summary + +When the writer asks Claude to edit, the plugin shows one static notification — +*"Cowriting: asking Claude…"* — and then nothing until the whole turn resolves. +Both edit entry points (`editSelection` in `extension.ts:232` and the preview's +`askClaude` in `trackChangesPreview.ts:232`) `await` the turn as **one opaque +promise** (`liveTurn.ts` `runEditTurn` → `agent.run()`). For anything past a +trivial edit this is a black box: the writer can't tell whether Claude is +thinking, reading, partway through writing, or stuck — and a long edit feels like +the tool hung. + +The fix is small and almost entirely *additive observability*: `@cline/sdk` +**already streams** everything we need (`assistant-text-delta`, +`tool-started|finished`, `usage-updated`, lifecycle events) via +`agent.subscribe(listener)`, and exposes `agent.abort()` for cancellation — the +extension just never subscribes (`liveTurn.ts` constructs the Agent with no +hook). This design wires that stream to the existing notification (a live +**activity line** + running **token count**), mirrors the full assistant text +into a host **OutputChannel** so the writer can *read it forming*, and makes the +notification **cancellable**. The final result path — `runEditTurn` → +`runEditAndPropose` → F4 proposals — is **untouched**. + +### 1.2 Background + +The plugin's machine-edit ingress is `LiveTurn` (`liveTurn.ts`, spec +`coauthoring-inner-loop.md` §6.2): one `@cline/sdk` Agent turn on the built-in +`claude-code` provider (INV-8 — the extension holds no credentials; it rides the +operator's local Claude Code login). The turn's result feeds the F4 propose/accept +seam (`coauthoring-propose-accept.md`) and is surfaced in the F10/F11 interactive +review preview. `liveTurn.ts` is **deliberately `vscode`-free** (its module header +says so) so the SDK ingress stays independent of the editor host. + +Two gestures invoke a turn, both wrapping it in `vscode.window.withProgress(… +"asking Claude…")`: + +- **`editSelection`** (`extension.ts`) — selection → one single-range proposal. +- **preview `askClaude`** (`trackChangesPreview.ts`) — selection or whole + document → one proposal (range) or one-per-changed-block (document, INV-39). + +The preview path runs the turn through an **injectable** seam — `editTurn` +(`trackChangesPreview.ts:56`, type `EditTurn`) — so host E2E can stub the LLM (no +Claude in CI). This design widens that seam without breaking the stub. + +### 1.3 Who feels it & why it's P1 + +The **human coauthor** — anyone who asks Claude to edit and waits for the turn, +most acutely on longer/slower edits. It is **P1** because the opaque wait hurts +*every* Claude turn (the core gesture of the tool), the enabling stream already +exists (low cost), and visible progress materially raises trust ("the tool is +alive and on task"). WSJF (issue #60): Value 6 · TC 3 · OE 4 ÷ Size 4 ≈ 3.25. + +--- + +## 2. Product Design + +### 2.1 The experience + +During a turn, the writer sees **two coordinated surfaces**, both tied to the +existing "asking Claude…" status — no new window, no webview change: + +1. **The notification line (legible motion + scale).** Below the + "Cowriting: asking Claude…" title, a live activity line that updates as events + arrive: + + | State | Line | + | --- | --- | + | before first output | `thinking…` | + | streaming text | `writing… (412 chars)` | + | streaming text + usage seen | `writing… · 1.2k tokens` | + | a tool is running | `running read_file…` | + + The line shows the **current phase** and, once a `usage-updated` event has + arrived, the **running token total** (input + output). `chars` and `tokens` + render together when both are known (`writing… (412 chars) · 1.2k tokens`). + +2. **The OutputChannel (read it forming).** A single shared `"Cowriting"` output + channel receives the **full assistant text** as it streams, under a per-turn + header `── asking: ──`. On the **first** text delta the channel + reveals itself **without stealing focus** (`show(true)`), so the writer can + start reading the output before the turn finishes. Auto-reveal is gated by a + setting `cowriting.liveProgress.revealOutput` (default `true`) for writers who + prefer to open it manually. + +The notification is **cancellable** (`✕`): clicking it aborts the turn +(`agent.abort()`); the status resolves to **"cancelled"** (not a frozen spinner, +not a "failed" error), and **no proposal is created**. If the turn *fails*, the +existing error path shows the error (no longer a frozen "asking Claude…"). + +### 2.2 What this is *not* (non-goals — from #60) + +- **Not a chat/transcript panel.** This is *in-flight progress*, not a persistent + conversation history surface. (The OutputChannel's append-log is a debug + convenience, not a product history feature.) +- **Not streaming into the document.** The result still lands as F4 proposals + exactly as today; only *observation* is added. +- **Not a cancellation feature in its own right.** The `✕` button + abort wiring + are the cheap, in-scope *reflection* of cancel; rich cancel controls (partial + apply, resume, queueing) are out of scope. +- **Not surfacing reasoning text.** `assistant-reasoning-delta` is collapsed to a + generic `thinking…`; the chain-of-thought text is **not** shown (kept the + notification/OutputChannel uncluttered; operator fork). + +### 2.3 Surfaces considered & rejected + +| Surface | Why not (as the *primary*) | +| --- | --- | +| **Webview relay** (into the preview) | The `editSelection` path has **no** webview; can't cover both entry points uniformly (acceptance requires both). | +| **Status-bar item only** | One terse line; no room for streamed text; weaker "what is it doing" signal than the in-status notification line. | +| **OutputChannel only** (notification stays a plain spinner) | The "in/alongside the asking-Claude status" signal weakens — the writer must open the Output panel to learn anything. | + +Chosen: **notification activity-line (primary, in-status) + OutputChannel +(secondary, full text)** — covers both call sites, no new network/webview surface +(INV-21/INV-45), and delivers both "see motion" and "read output as it forms." + +--- + +## 3. Engineering Design + +### 3.1 Architecture — three units + +``` +@cline/sdk Agent events (raw AgentRuntimeEvent stream) + │ agent.subscribe(listener) + ▼ +┌─────────────────────────┐ pure, vscode-free, SDK-free +│ turnProgress.ts (NEW) │ reduce(state, event) → TurnProgressSnapshot +│ the reducer (INV-46) │ +└─────────────────────────┘ + │ TurnProgressSnapshot + ▼ +┌─────────────────────────┐ vscode-free (INV-43) +│ liveTurn.ts runEditTurn │ subscribe → reduce → onProgress(snapshot); +│ (EXTENDED) │ signal?.abort → agent.abort(); result UNCHANGED +└─────────────────────────┘ + │ onProgress(snapshot) + AbortSignal in + ▼ +┌─────────────────────────┐ the two UI call sites (relay only) +│ extension.ts:editSelection │ format notification line · append OutputChannel · +│ trackChangesPreview:askClaude │ withProgress({cancellable}) · token→AbortController +└─────────────────────────┘ +``` + +The decisive layering rule: **progress flows *out* of `liveTurn` as a domain +`TurnProgressSnapshot`; cancel flows *in* as a web-standard `AbortSignal`.** No +`vscode` import is added to `liveTurn.ts` or `turnProgress.ts` (INV-43). + +### 3.2 `src/turnProgress.ts` (new, pure) — INV-46 + +The reducer owns *all* event→state logic so the call sites stay dumb (format + +relay only) and the logic is unit-tested in isolation. + +```ts +export interface TurnProgressSnapshot { + phase: "thinking" | "writing" | "tool"; + tool?: string; // present iff phase === "tool" + chars: number; // accumulated assistant-text length so far + tokens?: number; // running total (input+output); undefined until first usage event + textDelta?: string; // the new assistant-text chunk since the last snapshot +} + +// Opaque carried state; start from createTurnProgressState(). +export interface TurnProgressState { /* phase, chars, tokens, toolDepth/name */ } +export function createTurnProgressState(): TurnProgressState; + +// Pure: fold one SDK event into the state, returning the next state and the +// snapshot to emit (or undefined for events that don't change the surface). +export function reduceTurnProgress( + state: TurnProgressState, + event: AgentRuntimeEvent, // imported as a TYPE only from @cline/shared +): { state: TurnProgressState; snapshot?: TurnProgressSnapshot }; +``` + +Event mapping (the relevant `AgentRuntimeEvent` members): + +| SDK event | Effect on snapshot | +| --- | --- | +| `run-started` / `turn-started` | `phase: "thinking"` | +| `assistant-text-delta` | `phase: "writing"`, `chars = accumulatedText.length`, `textDelta = text` | +| `assistant-reasoning-delta` | `phase: "thinking"` (text **not** surfaced) | +| `tool-started` / `tool-updated` | `phase: "tool"`, `tool = toolCall.name` | +| `tool-finished` | revert `phase` to `writing` if any text seen, else `thinking` | +| `usage-updated` | `tokens = usage` total (input+output) | +| `turn-finished` / `run-finished` / `run-failed` | no snapshot (resolution handled by the turn promise) | + +`AgentRuntimeEvent` is imported **type-only** (`import type`) — `turnProgress.ts` +has **no runtime dependency** on the SDK and no `vscode` import, so it is a pure, +fast unit (INV-43/46). Tool concurrency is tracked by name/depth so overlapping +`tool-started`/`tool-finished` pairs resolve the phase correctly. + +### 3.3 `liveTurn.ts` `runEditTurn` (extended) + +`opts` gains two optional fields; the signature and result type are otherwise +unchanged (INV-44): + +```ts +export interface RunEditTurnOptions { + modelId?: string; + onProgress?: (s: TurnProgressSnapshot) => void; // out: live progress + signal?: AbortSignal; // in: cancellation +} +export async function runEditTurn( + instruction: string, + selectedText: string, + opts?: RunEditTurnOptions, +): Promise; +``` + +Inside, around the existing `agent.run(...)`: + +```ts +const agent = new sdk.Agent({ providerId: "claude-code", modelId, systemPrompt }); +let state = createTurnProgressState(); +const unsubscribe = opts?.onProgress + ? agent.subscribe((ev) => { + const next = reduceTurnProgress(state, ev); + state = next.state; + if (next.snapshot) opts.onProgress!(next.snapshot); + }) + : undefined; +const onAbort = () => agent.abort(); +opts?.signal?.addEventListener("abort", onAbort); +try { + const result = await agent.run(``); + // existing non-"completed" guard throws (covers "aborted" and "failed") + … +} finally { + unsubscribe?.(); + opts?.signal?.removeEventListener("abort", onAbort); +} +``` + +`AbortSignal`/`addEventListener` are web standards (no `vscode`), preserving +INV-43. An aborted turn yields `status: "aborted"` → the existing guard throws → +the call site reflects "cancelled" and proposes nothing (INV-47). **`extractReplacement` and the return shape are untouched** (INV-44). + +### 3.4 The two call sites (UI relay) + +A small shared host module owns the relay so both sites are identical: + +```ts +// src/liveProgressUi.ts (host; vscode-only) +export function createLiveProgressUi(): { + channel: vscode.OutputChannel; + // Begin a turn: clears nothing, writes the header, returns the wiring for + // one withProgress run. + begin(instruction: string, progress: vscode.Progress<{message: string}>, token: vscode.CancellationToken): + { signal: AbortSignal; onProgress: (s: TurnProgressSnapshot) => void }; +}; +``` + +- **`onProgress(s)`** → `progress.report({ message: formatLine(s) })` where + `formatLine` renders `phase`/`chars`/`tokens`; and appends `s.textDelta` (if + any) to the shared `"Cowriting"` channel. On the **first** non-empty + `textDelta`, if `cowriting.liveProgress.revealOutput` is `true`, call + `channel.show(true)` once. +- **`signal`** comes from an `AbortController` whose `abort()` is fired by + `token.onCancellationRequested`. +- The `withProgress` options gain **`cancellable: true`**. +- On `catch`, the call site checks `token.isCancellationRequested` → show + **"cancelled"** (information) instead of the "failed" error. + +`extension.ts:editSelection` passes `{ onProgress, signal }` straight into +`runEditTurn`. The preview path threads the same through its seam: + +```ts +// trackChangesPreview.ts — widen the injectable seam (back-compat: opts optional) +type EditTurn = (instruction: string, text: string, opts?: RunEditTurnOptions) => Promise; +``` + +`runEditAndPropose` forwards `opts` to its single `editTurn(...)` call (both the +`range` and `document` branches invoke the turn exactly once per gesture, so there +is one progress stream per gesture). The **host-E2E stub** (the default-overriding +injection) keeps working — `opts` is optional and ignored by the stub; a focused +test may simulate progress by calling `opts?.onProgress` (§4). + +### 3.5 OutputChannel semantics + +- **One shared channel** (`createOutputChannel("Cowriting")`), created once at + activation and disposed with the extension. +- **Append, don't clear** — each turn writes a `── asking: ──` + header then streams text, so the channel doubles as a debug log of recent turns + (per #60 solution-notes). (Trimming an unbounded log is a possible later + increment, not v1.) +- **Reveal** on first text delta, `preserveFocus: true`, gated by + `cowriting.liveProgress.revealOutput` (boolean, default `true`). + +### 3.6 New configuration + +| Setting | Type | Default | Effect | +| --- | --- | --- | --- | +| `cowriting.liveProgress.revealOutput` | boolean | `true` | Auto-reveal the "Cowriting" OutputChannel (without focus) on the first streamed text of a turn. | + +Declared in `package.json` `contributes.configuration`. + +### 3.7 Invariants + +- **INV-43** — `liveTurn.ts` and `turnProgress.ts` stay **`vscode`-free**: + progress leaves as a domain `TurnProgressSnapshot`, cancel enters as a + web-standard `AbortSignal`; `AgentRuntimeEvent` is imported **type-only**. No + `vscode` import is added to either module. +- **INV-44** — Live progress is **purely additive**: it never changes the result + path (`runEditTurn` return shape, `extractReplacement`, `runEditAndPropose`, + the F4 proposals). A turn that emits **no** events (the E2E stub) produces the + identical proposals it does today. +- **INV-45** — **No new network/webview surface.** Progress rides the existing + `withProgress` notification + a host `OutputChannel` only; the sealed webview is + untouched (preserves INV-21). +- **INV-46** — The event→progress **reduction is a pure function** + (`reduceTurnProgress`), unit-tested in isolation; the UI call sites only format + and relay its snapshots. +- **INV-47** — **Cancellation reflects, doesn't mutate.** Abort surfaces + "cancelled" (not a frozen spinner, not a silent partial apply); an aborted turn + proposes **nothing** (parity with the empty / no-change outcomes). + +### 3.8 Error & edge handling + +- **Failure** — non-"completed" status throws (existing guard); call site shows + the error message, the notification resolves (no frozen spinner). +- **Cancel** — `token` → `agent.abort()` → `status: "aborted"` → guarded throw → + call site detects `token.isCancellationRequested` → "cancelled", no proposal. +- **No events** — if the provider emits nothing before completing, the line stays + `thinking…` and resolves normally; result identical to today (INV-44). +- **Overlapping tools** — phase tracked by tool name/depth so interleaved + `tool-started`/`tool-finished` resolve back to `writing`/`thinking` correctly. +- **Empty / no-change replacement** — unchanged from today (warning / info), + independent of progress. + +--- + +## 4. Testing & E2E + +Per the §9 pipeline and `coauthoring-*` precedent (unit + host E2E; this is a +VS Code extension — **no flotilla/PPE**, the pipeline's deploy tiers are N/A; the +gate is unit + host-E2E green). + +- **Unit (the core):** `turnProgress.test.ts` exercises `reduceTurnProgress` + across the full event table — phase transitions (thinking→writing→tool→writing), + `chars` accumulation, `tokens` appearing only after `usage-updated`, `textDelta` + carrying each chunk, reasoning-delta staying `thinking`, overlapping tools. Pure, + no vscode/SDK. (`formatLine` gets its own small unit test.) +- **Unit (relay):** `liveProgressUi` line formatting and first-delta reveal gating + (mock `OutputChannel`/`Progress`). +- **Host E2E:** the existing preview `editTurn` stub is extended in one test to + **emit synthetic progress** (call `opts.onProgress` with a couple of snapshots) + and assert the turn still yields the same proposals (INV-44) and that cancel + (firing the token) yields **zero** proposals (INV-47). Streamed UI itself (the + live notification text) isn't asserted — E2E can't read notification subtitles + reliably; that's covered by the unit tests on the mapping + a **manual smoke** + on a longer real edit (per #60 acceptance). +- **Manual smoke:** a longer real edit shows the activity line advancing, token + count rising, the OutputChannel streaming, and `✕` cancelling cleanly. + +--- + +## 5. Delivery Plan (rollout — not a task list) + +One increment (the feature is a single design-then-build, #60 decomposition). +Ships through the standard branch → PR → `main` flow; no migration, no persisted +state, no config beyond the one additive setting (default preserves prior +behavior except that progress is now visible). Reversible by reverting the PR. +The implementation plan (downstream `wgl-planning-and-executing` session) owns +the Task breakdown; a natural cut is: **(1)** pure `turnProgress.ts` + tests → +**(2)** `runEditTurn` subscribe/abort wiring → **(3)** `liveProgressUi` + +both call sites + setting → **(4)** host-E2E stub extension + manual smoke. + +--- + +## 6. Open Questions + +- **OQ-1** — Unbounded OutputChannel log: append-forever is fine for v1 (it's a + manual debug surface); if it becomes noisy, add per-turn trimming or a + "clear on new turn" mode later. *(Deferred — not blocking.)* +- **OQ-2 (inherited)** — The F11 design (`#43`) is still un-graduated (lives in + code + an issue draft). Unrelated to #60 but noted in the shared lineage; track + separately.