Skip to content

feat(examples/chat): /stage — the real chat beside the real devtools, seekable to any recorded time (live-stage plan 2 of 3) - #1030

Merged
blove merged 17 commits into
mainfrom
blove/stage-route
Sep 6, 2026
Merged

feat(examples/chat): /stage — the real chat beside the real devtools, seekable to any recorded time (live-stage plan 2 of 3)#1030
blove merged 17 commits into
mainfrom
blove/stage-route

Conversation

@blove

@blove blove commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Why

Plan 2 of the homepage live stage (docs/superpowers/specs/2026-09-05-homepage-live-stage-design.md §4–6): the website will scrub one real run with scroll. This PR builds the thing it scrubs, in the demo app, verifiable on its own.

What

  • /stage: real <chat> beside real <chat-debug>, replaying public/stage-replay.json (a live capture of four beats: stream with citations, reload + fork, the backup cleanup with its interrupt, the generated form) to any recorded time via ?t= or the tplane-stage postMessage protocol.
  • StageReplayTransport gates recorded events behind a target time; StageController performs each run's recorded action as time crosses it, holds at the interrupt for an authored window, and rewinds by reset + fast-forward.
  • ?record=1 runs the four-beat script against the live backend through StageRecordingTransport, which captures runs with their actions, history responses, and the thread id. The record host resolves a run only once its closing history refresh has landed; resolving earlier let the reload beat abort the first run mid-stream.
  • The stage re-pins the transcript after each settled seek: a seek applies a whole run in ~70 ms, before tool views and the interrupt panel have rendered, so the chat's own pin landed ~700 px short. Deliberate override of unpin-on-user-scroll for a scrubbed surface; a public scrollToBottom() on ChatComponent is noted as the follow-up that removes the .chat-scroll coupling.
  • Still recorder writes eight apps/website/public/screenshots/stage-*.webp for the website's non-pinned fallback (phone stills are chat-only: below 768px the stage docks no devtools).
  • examples/chat/angular production config no longer externalizes @threadplane/chat/debug; it ships in the lazy stage chunk. Initial bundle unchanged at 1.65 MB.

Tests

Types, timeline, transport gate, controller against the real LangGraph agent (forward, reload restore, hold, resume, rewind, coalescing), recording transport, script order, bridge, component (incl. pin re-arm, mutation-checked); fixture spec pins the four beats and the guardrail (7/7 on take 6); e2e on the committed replay with viewport guards. Verified: demo unit suite 250/250, lint 0 errors, production build green, stage.spec.ts + hero.spec.ts + interrupt-approval.spec.ts 6/6 on aimock, website section-media spec 7/7.

🤖 Generated with Claude Code

blove and others added 17 commits September 5, 2026 20:10
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ory validation

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…cks checkpointIndex and histories; fixture moves out of the spec

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…h an authored hold

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…r pins one resume run and time-ordered events

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…d a seekable target time

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…re loading and pins the absolute-time gate

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… time, with hold and rewind

The controller performs each crossed run's recorded action against the real
LangGraph agent, draining the previous run to the next run's start first —
the bridge's submit path aborts whatever stream is open, so acting early
truncates the run being replaced. Rewinding resets the agent and the
transport, then fast-forwards; seeks are coalesced so a burst in one frame
performs each action once.

Two departures from the plan's sketch, both forced by the real bridge:

- The reported phase is the phase of the moment REACHED (t minus an
  epsilon), not the one about to begin. Runs are laid end to end, so a run's
  end is the next run's start, and phaseAt() would name the incoming run
  before it has rendered anything.
- StageReplayTransport.getHistory now answers only the snapshot recorded at
  exactly the current replay position, never an earlier one. The bridge
  force-refreshes history at the close of every run
  (stream-manager.bridge.ts finalizeClosedAttempt → refreshHistory(true))
  and projects it over the transcript, so an at-or-before snapshot snapped
  the replayed transcript back to whatever the recorder last captured.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… the run; phase boundary named; reload runs carry no events

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ript

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…double announcement

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…eekable by ?t= and by postMessage

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…he devtools open, docks responsively, and reports the approve stream as streaming

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The record host now resolves a submit/resume only once the run has closed
AND its closing history refresh has landed. Resolving as soon as the agent
reported "running" let the script start the next beat (a reload, which
detaches and re-adopts the thread) while the first run was still streaming,
which aborted it: run 0 recorded zero events and its thread had no
checkpoints at reload time. A run that closes on an interrupt is the one
exception — the bridge finalizes it as the interrupt arrives and skips the
refresh, in replay exactly as in record — so the fixture spec expects a
snapshot after every completed run except the one a resume follows.

Take 6: 7 runs, 2,275 events, one list_backups → one delete_backups pause,
a 116-character audit on resume, 9.1 MB pretty-printed like hero-replay.json.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…website fallback

stage.spec.ts drives the committed replay through ?t=: the chat renders
beside the devtools, the approve hold shows the inert interrupt panel over
the backup table, and the end of the recording mounts the generated form
with the thread in the Timeline tab. record-stage.config.ts +
record-stage-stills.record.ts capture one still per beat at desktop
(1200x720) and phone (390x650 at DPR 2, shipped 585 wide) for the
website's non-pinned fallback; each still asserts its beat and its byte
budget.

The recorder exposed a gap: a seek applies a run in one burst, so the
chat's last scroll write lands before the tool view, the A2UI surface and
the interrupt panel render, leaving the newest content ~700px below the
fold. The stage now pins the transcript to its bottom once a seek settles.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…e2e guards the pinned viewport

The transcript pin scheduled one two-frame pair per seek and dropped any
publish that landed while that pair was in flight, so content rendering
after the pending pair's inner frame could stay below the fold. pinTranscript
now keeps one pair in flight at a time and a publish during the pair re-arms
exactly one more pair after it (pinPending). The destroy hook still cancels
whichever frame is pending and clears the flag, so nothing runs after
destroy. The docblock now also states that this deliberately overrides the
chat's unpin-on-user-scroll for a scrubbed display surface.

The pin still reaches into the chat's private .chat-scroll container. The
proper fix is a public scrollToBottom() on ChatComponent (today the
protected onScrollBubbleClick); that touches libs/ and is deferred, recorded
as a TODO at the call site.

The unit spec polls frame by frame instead of hard-coding "two frames deep",
and gains a re-arm case whose scrollHeight grows after the first pair's inner
frame. Mutation-checked: removing the re-arm fails only the new case;
removing the publish() call fails both.

The still recorder looks beats up by name (StageBeat) rather than by index,
collects oversized files and asserts once after both loops so a failure
cannot leave a half-rewritten set, and its header says deviceScaleFactor 2
applies to both sizes. stage.spec.ts asserts the interrupt panel and the
A2UI surface are in the viewport, guarding the pin end to end, and explains
the +1 on the hold boundary. The shared window augmentation moves to
e2e/stage-globals.d.ts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…nstead of racing the real one

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@blove
blove enabled auto-merge (squash) September 6, 2026 03:10
@vercel

vercel Bot commented Sep 6, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
threadplane Ready Ready Preview Sep 6, 2026 3:13am UTC

Request Review

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated approval: this PR received an intelligent (AI) code review. See the review comments on this PR.

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Claude finished @blove's task in 0s —— View job


I'll analyze this and get back to you.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant