Skip to content

fix(preview): keep the last denoise frame through completion, reconnect and tab hiding - #214

Merged
lstein merged 3 commits into
mainfrom
fix/preview-coordinator
Sep 6, 2026
Merged

fix(preview): keep the last denoise frame through completion, reconnect and tab hiding#214
lstein merged 3 commits into
mainfrom
fix/preview-coordinator

Conversation

@lstein

@lstein lstein commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

Summary

Phase 1 (client only) of the generation-preview restructuring discussed on Discord. The server-side part (retained last frame, revision, throttling, reconnect snapshot) follows as a separate additive PR that stacks on this one.

Two reported problems: (1) progress updates missed while the tab is inactive leave the preview blank or on the previous image; (2) on completion the preview often shows black or an older gallery image instead of the just-finished one. Both turned out to be mostly client-side lifecycle bugs, verified in the code:

  • coordinator.ts released the followed slot synchronously on the terminal event, while the finished image only arrives after two HTTP round trips. Preview fell out of live-follow and showed the previous selection in that window.
  • handleConnectionChange wiped the frame and target on every non-connected status, and nothing re-requested them. A run that finished while hidden left a blank card.
  • The "hold the last frame until the final decodes" path existed and was unit-tested but the hook never forwarded it.
  • At every batch handoff the next slot became live before it had a frame, so the ownership check returned null and the panel rendered an empty card.
  • One manual gallery click set showProgressImagesInViewer=false for good, so later completions never auto-selected.

What changes

Preview state machine

  • activeProgressTargetStore gains a settling phase. A completed slot stays followed until its result routing lands; running slots win over settling ones; the tile grid counts running slots only (so a single-GPU batch never flashes into a two-tile grid).
  • progressImageStore holds the last frame per queue item on completion: a bridge frame shown while the batch's next slot has none of its own, and a swap frame painted over the finished image until the browser has decoded it. The swap is bound to the image names routing delivered (never item 3's frame over item 1), consumed on first decode, and expires after 10 s.
  • PreviewFrame gets holdSource/onSourceLoaded: while a hold is up the real <img> keeps the geometry with visibility:hidden and the held frame paints over it. No detached preload: in multiuser mode the image route is no-store, so the swap has to happen on the same element.
  • The followed placeholder is derived from the followed targets filtered against existing queue placeholders, so the instant between "placeholder removed" and "target released" never shows a stale slot.

Reconnect / hidden tab

  • Frames and targets survive a socket drop. A visibilitychange listener runs the reconcile sweep when the tab becomes visible; sweep requests coalesce instead of being dropped while one is in flight.
  • invocation_progress gets an optional revision (additive; the backend does not send it yet). Frames at or below the last accepted revision for the same item + session are dropped.

Auto-select policy (confirmed with @lstein)

  • A deliberate gallery selection still pauses live-follow but now also stamps liveFollowPausedAt. Submitting new work resumes live-follow. A completed result is auto-selected only if its generation was submitted after the pick, so the batch that was running when the user clicked stays out of the way. An explicit toggle of the setting lifts the stamp; an explicit opt-out is never re-armed by Invoke.

Routing

  • scheduleResultRoute returns a per-route promise instead of the shared flush, so a settling slot is released when its route lands rather than when every pending route drains.

Known behavioural notes

  • If the backend process dies mid-run (connection refused, not a 404), the preview keeps the last frame and the followed target until the backend returns and the sweep can reconcile. Previously it blanked on disconnect. The connection status chip still reports the drop.
  • Programmatic selections (upload auto-select, delete-successor) stamp the pause like a click does. They already paused live-follow on main.

Test plan

  • pnpm lint (format, oxlint, tsc, architecture)
  • pnpm test — 523 files / 7595 tests
  • pnpm test:browser — full suite, plus new cases: hold-until-decoded swap (PreviewFrameHold.browser.test.tsx), following a settling slot, running-over-settling, batch bridge (PreviewNavigation.browser.test.tsx)
  • pnpm run test:performance:build and test:performance:browser pass in check mode (no baseline re-record needed)
  • Adversarial fresh-context review of the diff; 5 confirmed findings fixed (swap keyed per queue item, multi-GPU settling regression, sweep shadowing, shared-flush lifetime, gate leak on detach)
  • Manual: start a batch, switch tabs for >1 min, return: preview shows the last frame and then the completed image. Repeat with a manual gallery click before Invoke.

🤖 Generated with Claude Code

https://claude.ai/code/session_01DoHBJ9QPWfq1qMTfzdzuke

…ct and tab hiding

The generation preview lost its frame at exactly the moments it mattered:

- On the terminal socket event the coordinator released the followed slot at
  once, but the finished image is two HTTP round trips away (queue item read,
  then per-image read). Preview dropped out of live-follow and rendered the
  previous selection until the new image landed.
- On any socket drop the coordinator wiped the frame and target, and nothing
  re-requested them. A run that finished while the tab was hidden left a blank
  card until the next event, or for good.
- Between two items of a batch the next slot became live before it produced a
  frame, so the panel rendered an empty card.
- One manual gallery click permanently disabled auto-select of new results, so
  later completions never reached the preview.

Client-side preview state machine (Phase 1 of the preview restructuring):

- Completed slots move to a "settling" phase and stay followed until result
  routing lands; the last frame is held for a bridge to the batch's next slot
  and for a swap that is painted over the finished image until it has decoded
  (bound to the image names routing delivered, consumed on first decode, 10 s
  TTL). Running slots win over settling ones so multi-GPU streams are never
  hidden, and the tile grid counts running slots only.
- Frames and targets survive a socket drop; a `visibilitychange` listener runs
  the reconcile sweep on the visibility edge, and sweep requests coalesce
  instead of being dropped while one is in flight.
- Progress frames carry an optional `revision`; a frame at or below the last
  accepted revision for the same item+session is dropped (ready for the server
  snapshot in Phase 2; no-op for today's ordered socket stream).
- A deliberate gallery selection stamps `liveFollowPausedAt`; submitting new
  work resumes live-follow, and a result is auto-selected only when its
  generation was submitted after the pick. An explicit toggle lifts the stamp.
- Result routing returns a per-route promise instead of the shared flush, so a
  settling slot is released when its own route lands.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DoHBJ9QPWfq1qMTfzdzuke
@lstein
lstein enabled auto-merge September 6, 2026 20:37
@lstein
lstein merged commit 386e0db into main Sep 6, 2026
19 checks passed
@lstein
lstein deleted the fix/preview-coordinator branch September 6, 2026 21:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant