add spec ./specs/coauthoring-live-progress.md (status: graduated)

This commit is contained in:
BenStullsBets
2026-06-26 04:22:50 -07:00
parent 9d016e8f39
commit 3bd1ae5513
+398
View File
@@ -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 <tool>…`) **+ 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: <instruction> ──`. 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<EditTurnResult>;
```
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(`<instruction>…</instruction><text>…</text>`);
// 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<EditTurnResult>;
```
`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: <instruction> ──`
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.