27 KiB
status
| status |
|---|
| graduated |
Solution Design: Diff-View Toggle (F6)
| Author(s) | Ben Stull (with Claude) |
| Reviewers / approvers | Ben Stull |
| Status | draft |
| Version | v0.1.0 |
| Source artifacts | Feature benstull/vscode-cowriting-plugin#17 (F6) · Epic #1 (closed — F6 is the first post-epic UX feature) · Parent specs: vscode-cowriting-plugin-content/specs/coauthoring-inner-loop.md, specs/coauthoring-attribution.md, specs/coauthoring-propose-accept.md, specs/coauthoring-cross-rung-format.md + specs/coauthoring-sidecar-contract.md (all graduated) · F5 shipped: #14 (PR #15, session 0015) · Lineage: ben.stull/rfc-app#48 |
Change log
| Date | Version | Change | By |
|---|---|---|---|
| 2026-06-11 | v0.1.0 | Initial draft — brainstorming session 0017 | Ben Stull + Claude |
1. Business Context
1.1 Executive Summary
F6 gives the human coauthor the review surface the machine already has. F4
made every machine change reviewable-before-landing (proposal diffs); F3
paints who owns the current text; but the operator's own contribution is
visible only as final text. F6 adds a one-gesture diff-view toggle: flip
the editor into a diff of the document against a coauthoring baseline —
and back — so "what did I actually change?" is answered in one keystroke
instead of git archaeology. The load-bearing design move is the baseline
itself: it advances automatically at every machine landing (every
successful applyAgentEdit seam application — the only way machine text ever
enters the document, INV-9/INV-10), so everything the diff shows is
operator-authored by construction. A single pin gesture gives the
operator an arbitrary epoch when they want one. The baseline lives in
extension workspace storage — never in the repo — so the sidecar, the
cross-rung contract (INV-14..17), and SCHEMA_VERSION are untouched.
1.2 Background
The inner-loop triad shipped across F2–F4 (threads · attribution · propose/accept) and F5 made its sidecar a cross-rung contract. Review parity is now asymmetric in the human's favor toward the machine's changes: machine edits arrive as anchored diffs (F4) and land tinted (F3), while the operator's own work between machine landings has no diff perspective at all. Git's diff is against HEAD — the wrong baseline mid-session, where the meaningful "before" is a coauthoring moment (a machine landing, a chosen instant), not the last commit. Issue #17 parked the design forks — baseline choice (the load-bearing one), snapshot home, whose-changes, and the relationship to git's own features — for this session; all are resolved here (§6.7, §9).
1.3 Business Actors / Roles
- Coauthor (human) — the writer/engineer (PP-1); F6's sole user: the diff view exists to make their own contribution self-reviewable.
- Coauthor (machine) — Claude via
@cline/sdk; not a user of F6, but its landings define the default baseline epoch.
1.4 Problem Statement
While coauthoring, the operator cannot see their own changes as a diff. The editor shows ownership of current text (F3) and machine intent before it lands (F4), but the human's contribution since the last meaningful coauthoring moment is only visible as final prose. The only diff available — git's — is anchored to the wrong moment (HEAD).
1.5 Pain Points
- "What did I actually change?" requires git archaeology or memory.
- Self-review during coauthoring is expensive exactly where machine-review is cheap — an inverted trust asymmetry.
- No way to mark "I'm about to do a risky pass — let me review it as a diff afterwards" (no operator-chosen baseline moment).
1.6 Targeted Business Outcomes
One gesture flips the active document into a diff of the operator's own changes against a coauthoring-meaningful baseline; the same gesture flips back; nothing about the document, sidecar, or editor state is disturbed. Self-review during coauthoring becomes as cheap as accepting a proposal.
1.7 Scope (business)
In scope: the baseline lifecycle (initialize at first track, advance at
machine landings, pin on demand); persistence of the baseline across window
reloads (extension workspace storage); the toggle command + keybinding; a
native vscode.diff view (readonly baseline left, the live document
right — editing continues inside the diff view); test-facing surface; host
E2E + unit coverage; manual smoke.
Out of scope (deferred, not forgotten): baseline splicing (keeping pre-accept operator edits visible across a baseline advance — §6.7, §9); multiple named/selectable baselines; any cross-rung sharing of baselines; diffing against arbitrary git refs; changed-line gutter indicators in the normal (non-diff) editor view.
Non-goals (firm, from #17): replacing git history; a general-purpose diff tool; changing how proposals render (F4 owns machine diffs).
1.8 Assumptions · Constraints · Dependencies
- Anchor: Feature #17. Builds on the shipped inner loop (F2
#4· F3#6· F4#12· F5#14); Epic #1 is closed. - The whole design leans on two shipped invariants: INV-9 (the seam
applyAgentEditis the single machine-edit ingress) and INV-10 (proposals never mutate the document) — together: between machine landings, every buffer change is operator-authored. - F3 attribution is state, not history (cannot reconstruct before-text) — confirmed: F6 needs a snapshot, and takes one; it does not consume attribution records at all.
- Nothing persisted touches
.threads/: the cross-rung contract (INV-14..17) andSCHEMA_VERSION = 1are untouched by construction (INV-19). - No new credential surface (INV-8 untouched); no LLM anywhere in F6 or its tests.
1.9 Business Use Cases
- BUC-1 (self-review) Mid-session, the operator flips into the diff view and sees exactly what they changed since Claude last landed text — then flips back and keeps writing.
- BUC-2 (deliberate pass) Before a risky rewrite pass, the operator pins the baseline; afterwards they review their whole pass as one diff.
2. Solution Proposal
Keep one baseline snapshot per tracked document: the full document text
captured at an epoch. The epoch is initialized when the document is first
tracked, advances automatically on every successful seam application
(applyAgentEdit — the moment machine text lands), and can be pinned to
now by the operator. Because the seam is the only machine ingress
(INV-9/10), the diff baseline → current buffer shows operator-authored
changes by construction (INV-18) — no attribution filtering needed. A
cowriting.toggleDiffView command (with default keybinding) opens a native
vscode.diff editor: left side a readonly virtual document
(cowriting-baseline: scheme) serving the baseline text, right side the
live document itself — so the operator keeps editing inside the diff
view, and toggling back simply closes the diff tab. Baselines persist in
extension workspace storage via a vscode-free BaselineStore (the
CoauthorStore pattern), surviving window reloads without ever entering
the repo (INV-19). The machine-landing signal is one additive event on the
seam owner: AttributionController.onDidApplyAgentEdit.
3. Product Personas
- PP-1 Inner-loop coauthor — the human writer/engineer (as F2–F5); the only persona F6 serves.
4. Product Use Cases
- PUC-1 (toggle) In a tracked document, run the toggle (command or keybinding) → a diff view opens: baseline left, live document right. Run it again → the diff closes and the normal editor is back. The document text, sidecar, threads, attributions, and proposals are untouched either way.
- PUC-2 (epoch follows the machine) Accept a pending proposal → the baseline advances past it: the accepted text does not appear as a change in the diff; the operator's subsequent edits do.
- PUC-3 (pin) Run "pin diff baseline" → the diff goes empty (baseline = now); everything the operator changes from here shows against the pin.
- PUC-4 (survive reload) Reload the window mid-session → the baseline (and therefore the diff) is the same as before the reload.
- PUC-5 (graceful edges) An untracked document refuses the toggle with a warning; a missing/ancient baseline is visible in the diff title (epoch + when) and one pin gesture recovers; external file changes (e.g. a git pull) show up as changes — pin recovers there too.
5. UX Layout
No bespoke UI surface — native VS Code primitives only (the F2–F5
Claude-Code-rubric precedent). The diff view is VS Code's diff editor:
readonly baseline on the left, the live, editable document on the right;
all existing decorations and comment threads keep working on the right side
because it is the same TextDocument. The diff tab title carries the epoch
story: <basename> — my changes since <epoch> where epoch reads opened <time> / Claude landed <time> / pinned <time>. Two contributed
commands: "Cowriting: Toggle Diff View" (cowriting.toggleDiffView,
default keybinding ctrl+alt+d, when: editorTextFocus) and "Cowriting:
Pin Diff Baseline to Now" (cowriting.pinDiffBaseline). No status-bar
item: the diff tab itself is the indicator.
6. Technical Design
6.1 Invariants
Parent invariants INV-1..INV-17 carry over unchanged. F6 adds:
- INV-18 (machine-factored baseline) The baseline advances on every successful seam application, so text the machine landed through the seam never appears as a change in the diff view. With INV-9/INV-10, the corollary: everything the diff view shows changed is operator-authored — modulo changes made to the file from outside the session (git operations), which are surfaced as changes and recoverable by pin.
- INV-19 (pure view, repo-free) The diff view never mutates the
document, the sidecar, or attribution state; the baseline snapshot lives
in extension workspace storage, never in the repo —
.threads/, the cross-rung contract (INV-14..17), andSCHEMA_VERSIONare untouched.
6.2 High-level architecture
One new vscode-free store plus one new controller (the F2/F3/F4 unit pattern), and one additive event on an existing unit:
BaselineStore(src/baselineStore.ts, vscode-free) — load/save one baseline JSON per docPath under a given storage directory (theCoauthorStoreshape: Nodefs,mkdir -p,<dir>/baselines/<docPath>.json).DiffViewController(src/diffViewController.ts, vscode layer) — owns the baseline lifecycle (initialize / advance / pin), registers thecowriting-baseline:TextDocumentContentProvider(with anonDidChangeemitter so an open diff refreshes when the baseline advances), implements toggle open/close via the tab-groups API, and exposes the test-facing surface.AttributionController.onDidApplyAgentEdit— a newvscode.EventEmitterfired after a successful, non-no-op seam apply (src/attributionController.ts:232); the single machine-landing signal (INV-9 makes the seam the single source). Carries the document.- Everything else reused unchanged: seam semantics, ProposalController, ThreadController, CoauthorStore, Anchorer, VersionGuard (F6 never writes the sidecar, so the guard is not consulted).
flowchart LR
open["tracked doc first seen"] --> init["baseline := buffer text\n(reason: opened)"]
seam["applyAgentEdit succeeds\n(accept / E2E ingress, INV-9)"] -- onDidApplyAgentEdit --> adv["baseline := buffer text\n(reason: machine-landing)"]
pin["cowriting.pinDiffBaseline\n(human)"] --> pinned["baseline := buffer text\n(reason: pinned)"]
init & adv & pinned --> bs["BaselineStore\n(extension workspace storage,\nINV-19: never the repo)"]
toggle["cowriting.toggleDiffView"] --> diff["vscode.diff\nleft: cowriting-baseline (readonly)\nright: the LIVE document"]
bs --> diff
diff -- "toggle again" --> closed["diff tab closed,\nnormal editor back"]
6.3 Data model & ownership
No sidecar change of any kind (INV-19). The baseline is a private, per-workspace extension artifact:
// <context.storageUri>/baselines/<repo-relative-docPath>.json
{
"docPath": "notes/chapter-1.md",
"text": "…full document text at the epoch…",
"capturedAt": "ISO-8601",
"reason": "opened" | "machine-landing" | "pinned"
}
- One baseline per document — the newest epoch wins; no history of past baselines (state, not history — the F3/F4 precedent).
- Owner: the extension alone; the file lives outside the repo (VS Code's per-workspace storage directory), so it can never be committed, merged, or read by another rung. Unknown-field preservation is moot.
- Capture source is the buffer (
document.getText()), not the disk — the baseline must include unsaved text, since seam landings apply to the buffer.
6.4 Interfaces & contracts
BaselineStore(vscode-free):load(docPath): Baseline | null·save(docPath, baseline): void·baselinePath(docPath): string.DiffViewController:ensureBaseline(document)— on first sight of a tracked doc (wired intoextension.ts's existingrenderIfOpenhook); loads the stored baseline or capturesreason: "opened".advance(document)— capturesreason: "machine-landing"; fires the provider'sonDidChangeso an open diff refreshes.pin(document)— capturesreason: "pinned"; same refresh.toggle(editorOrTab)— if the active tab is this doc's baseline diff → close it and reveal the normal editor; if the active editor is a tracked doc →vscode.diff(baselineUri, document.uri, title). Untracked → warning. Test-facing:getBaseline(docPath): { text, reason, capturedAt } | undefined·isDiffOpen(docPath): boolean.AttributionController: addsreadonly onDidApplyAgentEdit: vscode.Event<{ document: vscode.TextDocument }>— fired inapplyAgentEditafterapplyEditsucceeds for a real (non-no-op) change; the seam's signature and return contract are unchanged.- Commands/keybindings (
package.jsoncontributions):cowriting.toggleDiffView(palette +ctrl+alt+d) ·cowriting.pinDiffBaseline(palette). Both registered as warning stubs in the no-folder activation path (the #8 precedent). CowritingApigainsdiffViewController(the E2E handle).
6.5 Per–Product-Use-Case design
- PUC-1 (toggle):
toggleinspectsvscode.window.tabGroupsfor aTabInputTextDiffwhoseoriginal.scheme === "cowriting-baseline"and whosemodifiedequals the document's URI. Present → close that tab andshowTextDocumentthe file. Absent →ensureBaselinethen executevscode.diffwith{ preview: false }. The right side is the sameTextDocumentthe normal editor shows — no content is copied, so "without losing editor state" holds trivially: the normal editor tab is left in place; the diff is an additional tab on the same buffer. - PUC-2 (epoch follows the machine):
DiffViewControllersubscribes toonDidApplyAgentEditat activation. On fire:advance(document)— synchronous capture +BaselineStore.save+ provideronDidChange(an open diff's left side updates in place). Both seam drivers are covered by the one event: accept (ProposalController.accept) and the hidden E2E command (cowriting.applyAgentEdit). - PUC-3 (pin): command → guard tracked →
pin(activeEditor.document)→ if the diff is open it visibly empties (left = right). - PUC-4 (survive reload):
ensureBaselineprefers the stored file; a baseline written at the last landing/pin is what the reloaded window diffs against. (The toggle's tab does not need to survive reload — the baseline does.) - PUC-5 (graceful edges): untracked / non-file schemes → warning, no
diff (the F2
isTrackedrule). Storage unavailable (context.storageUriundefined or write failure) → degrade to in-memory baselines + one warning (the session still works; reload survival is lost). Ancient baseline (workspace reopened weeks later) → the diff title showssince opened <date>— self-describing — and pin recovers. External-to-session file changes appear as changes (INV-18's stated modulo); pin recovers.
6.6 Non-functional requirements & cross-cutting concerns
Capture cost is O(document) at three rare moments (first track, machine
landing, pin) — trivial at inner-loop scale. Storage is one document-sized
JSON per tracked doc in VS Code's workspace storage (local machine, same
trust domain as the working tree; baseline text is operator content, the
document's own sensitivity class). The content provider serves from the
in-memory copy (disk only on load). No LLM, no network, no new credential
surface (INV-8). Skip non-text/oversized files (the F2 rule, via
isTracked). The diff editor is native — no rendering machinery to
maintain.
6.7 Key decisions & alternatives considered
| Decision | Chosen | Alternatives rejected |
|---|---|---|
| Baseline epoch (the load-bearing fork) | Machine-landing–anchored auto-baseline: initialize at first track, advance on every successful seam application, plus a human pin gesture. INV-9/10 make the resulting diff operator-authored by construction (INV-18) | turn start (under F4 propose-by-default the document doesn't change at turn start — the epoch collapses into the accept; not a distinct moment anymore); last save (duplicates native compareWithSaved / local history / timeline — #17's complement-not-duplicate rule); fixed session-start (machine landings bleed into the "my changes" diff — breaks INV-18); multiple selectable baselines (a selector UI for epochs nobody asked for — YAGNI; pin covers the operator-chosen case; selectable is additive later) |
| Pre-accept operator edits absorbed by an advance | Accepted trade-off: the diff reads "my changes since the machine last landed", not "my whole session"; pin gives manual control | baseline splicing (apply the accepted replacement into the baseline so older operator edits stay visible — needs current→baseline range mapping with murky edge cases when the machine edits operator-authored-since-baseline text; deferred additive refinement, §9) |
| Snapshot home | Extension workspace storage (context.storageUri) via vscode-free BaselineStore; survives reload; zero repo/contract footprint (INV-19) |
sidecar .threads/ (full document text per accept dwarfs the artifact and churns git diffs — INV-2; drags INV-14..17 into a local view-state concern no other rung needs); gitignored repo cache (litters the operator's working tree; needs .gitignore management; risks accidental commit); in-memory only (baseline dies at reload — the post-reload diff is empty and lies) |
| Whose changes the diff shows | Whole-document diff; operator-authorship is INV-18's corollary, no filtering | F3-attribution-filtered diff (F3 is state-not-history — cannot reconstruct before-text; #17 itself notes this); per-author diff lenses (no user story; heavy) |
| View primitive | Native vscode.diff: readonly cowriting-baseline: virtual doc left, the live document right (editable in place; decorations/threads keep working) |
decoration-based inline diff in the normal editor (re-implements diff rendering VS Code already has; F4's fenced-diff primitive is for proposals, not whole documents); webview diff (bespoke UI against the F2–F5 native-primitives precedent) |
| Machine-landing signal | Additive onDidApplyAgentEdit event on the seam owner — INV-9's single ingress is the single signal source |
call-site wiring (ProposalController + the hidden command both call advance — two sites today, N tomorrow, and each new seam driver must remember); watching attribution records (indirect, save-coupled, reconstructive) |
| Toggle keybinding | ctrl+alt+d on all platforms (unbound by default in stock VS Code; user-remappable) |
cmd+alt+d (macOS Dock toggle); cmd+k d (taken: compare-with-saved) |
6.8 Testing strategy
- Unit (vitest, vscode-free):
BaselineStoreround-trip (save → load, nested docPaths, overwrite-in-place, missing → null); baseline JSON shape stability; storage-dir path mapping. - Host E2E (
@vscode/test-electron, the F2–F5 pattern, no LLM): toggle on a tracked doc → diff tab open (original schemecowriting-baseline), provider text = open-time text, document + sidecar untouched (INV-19) → type → baseline unchanged, buffer changed → programmatic propose + accept → baseline advanced past the accepted text (INV-18:getBaseline().textcontains the replacement;reason: "machine-landing") → operator edit after the landing → baseline ≠ buffer (the operator delta) → pin → baseline = buffer,reason: "pinned"→ toggle again → diff tab closed, normal editor active → baseline file present on disk under the storage dir with the expected content (the reload-survival half is the store's unit-tested round-trip) → untracked doc → toggle warns, no diff tab. - Live smoke (manual, documented —
docs/MANUAL-SMOKE-F6.md): open a tracked doc → edit → toggle → see own changes → ask Claude to edit a selection → accept → toggle: the accepted text is not shown as a change → pin → diff empties.
6.9 Failure modes, rollback & flags
Storage unavailable / write failure → in-memory fallback + one warning (reload survival degrades; nothing else). Crash between a seam landing and the baseline write → the stored baseline is one landing behind: on reload the machine's last text shows as a change (a visible, self-describing stale-epoch state, the F4-husk spirit) — one pin recovers; the capture is synchronous in the event handler to keep this window minimal. Ancient baseline → self-describing title + pin. External file changes → shown as changes (INV-18's modulo), pin recovers. No feature flag: the feature is a pure view (INV-19) — not toggling it is the off state; rollback is reverting the PR with zero data migration (the baseline cache is disposable by design).
7. Delivery Plan
7.1 Approach / strategy
One planning-and-executing session (F6 = #17), plan written just-in-time from this spec — the F2–F5 precedent.
7.2 Slicing plan
- SLICE-1
BaselineStore(vscode-free) + unit tests (round-trip, paths, overwrite). - SLICE-2 Seam event (
onDidApplyAgentEdit, additive) +DiffViewControllerlifecycle: ensure/advance/pin, content provider with refresh, storage-unavailable fallback. - SLICE-3 Toggle UX:
vscode.diffopen/close via tab groups, epoch titles,package.jsoncontributions (commands + keybinding + no-folder stubs),CowritingApi.diffViewController+ test-facing surface. - SLICE-4 Host E2E per §6.8 +
docs/MANUAL-SMOKE-F6.md+ README pointer.
E2E are first-class plan tasks (handbook §9/§4; this app's required tier is host E2E — a VS Code extension has no browser surface or deploy stage, the F2–F5 precedent).
7.3 Rollout / launch plan
Still non-shippable (no marketplace publish). "Done" = issue #17 acceptance met: toggle there-and-back without losing editor state, against a coauthoring-meaningful baseline; unit + host E2E green; live smoke performed once on this machine.
7.4 Risks & mitigations
| Risk | Mitigation |
|---|---|
| Baseline advance absorbs pre-accept operator edits (operator surprise) | Diff title names the epoch ("since Claude landed |
ctrl+alt+d collides with an operator's keymap |
Default keybinding only — remappable; command palette always works |
| Storage dir missing/unwritable | In-memory fallback + one warning; feature degrades to session-scoped, never errors out of the toggle |
| Diff left side stale while baseline advances | Provider onDidChange fired on every advance/pin — the open diff refreshes in place |
| Future seam drivers forget the baseline | They can't — the advance subscribes to the seam's own event (INV-9: one ingress, one signal) |
| Operator expects "my whole session" semantics | §6.7 records the trade-off; the epoch is visible in the title; pin-at-session-start reproduces session semantics manually |
8. Traceability matrix
| Requirement (issue #17 acceptance) | Use case | Design | Slice |
|---|---|---|---|
| Toggle (command and/or keybinding) editor ↔ diff, and back | PUC-1 | §6.4, §6.5 | SLICE-3 |
| Diff is the operator's own changes | PUC-2 | INV-18, §6.2 | SLICE-2 |
| Against a coauthoring-meaningful baseline | PUC-2/3 | §6.7 (epoch fork) | SLICE-2 |
| Without losing editor state | PUC-1 | §6.5 (live doc is the right side) | SLICE-3 |
| Sidecar/contract untouched if persisted artifacts exist | — | INV-19, §6.3 | all |
| Complement, not duplicate, git's diff/timeline | — | §6.7 (save rejected) | — |
| Unit + host E2E coverage, no LLM in CI | — | §6.8 | SLICE-1/4 |
9. Open Questions & Decisions log
- RESOLVED (this session — the forks routed by #17):
(a) baseline = machine-landing–anchored auto-baseline (init at first
track, advance on every successful seam application) + human pin; turn
start collapses into accept under F4, save duplicates native features,
fixed session-start breaks INV-18, selectable-multi is YAGNI;
(b) snapshot home = extension workspace storage via vscode-free
BaselineStore— never the sidecar, no contract impact (INV-19); (c) whose changes = whole-document diff, operator-authored by construction (INV-18) — no attribution filtering; (d) git relationship = complement: this diffs against coauthoring moments, git diffs against commits; save-points stay native; (e) view primitive = nativevscode.diff, live document on the right; (f) landing signal =onDidApplyAgentEditon the seam owner. - OPEN → later: baseline splicing (keep pre-accept operator edits visible across an advance — needs current→baseline range mapping); multiple named baselines / epoch selector (additive on the same store); changed-line gutter indicators in the normal view (a decoration consumer of the same baseline); whether a future rung wants to share a pinned baseline (would move it into the contract — deliberately not now).
- Deferred decisions (autonomous-mode calls logged for the operator):
the absorb-on-advance semantics (vs. splicing) and the
ctrl+alt+ddefault keybinding were decided without operator input — both are cheap to revisit.
10. Glossary & References
- Baseline — the full text of a tracked document captured at an epoch;
the diff view's left side. Epoch — the moment the baseline was
captured:
opened,machine-landing, orpinned. Machine landing — a successfulapplyAgentEditseam application (the only way machine text enters the document, INV-9/10). Pin — the human gesture resetting the baseline to now. Advance — the automatic baseline re-capture at a machine landing (INV-18). - Feature #17 (F6) · Epic #1 (closed) · F5 #14 (PR #15) · F4 #12 (PR #13) ·
F3 #6 (PR #7) · F2 #4 (PR #5) · parent specs
coauthoring-inner-loop.md,coauthoring-attribution.md,coauthoring-propose-accept.md,coauthoring-cross-rung-format.md,coauthoring-sidecar-contract.md· lineageben.stull/rfc-app#48.