Skip to content

Load the neighbor session in the background after a delete - #1184

Merged
edwin-zvs merged 1 commit into
mainfrom
async-post-delete-transcript-load
Aug 3, 2026
Merged

Load the neighbor session in the background after a delete#1184
edwin-zvs merged 1 commit into
mainfrom
async-post-delete-transcript-load

Conversation

@edwin-zvs

Copy link
Copy Markdown
Contributor

The stall

C-x kd freezes the TUI for a beat. Deletion itself is already async on both sides — spawn_session_delete dispatches on a dedicated connection, and the daemon's delete() kills the adapter, broadcasts Deleted, and spawns the slow worktree/storage cleanup before returning.

The freeze is in the reaction to the broadcast. on_session_deleted moves selection to a neighbor and then awaited, inline on the event loop:

refresh_selected_transcript()
 ├─ client.transcript(neighbor)            // full transcript
 └─ bootstrap_terminal(neighbor)           // when no cached history:
     ├─ client.pty_replay(neighbor)        // whole pty.log snapshot
     ├─ history.feed_pty(bytes)            // vt parse, CPU-bound
     ├─ client.transcript(neighbor)        // again
     └─ apply_transcript_to_local_state    // rebuild scrollback

So the cost scales with the neighbor's history, not the deleted session's, and it's worst when the neighbor was never opened in this TUI (bootstrap_terminal returns early only on a cached history). Ordinary navigation never hits this — select_session is fully synchronous and fetches nothing.

The change

  • Split bootstrap_terminal into fetch_terminal_bootstrap (takes a client plus a plain-data snapshot of the app state it needs, so it runs equally well inline or in a spawned task) and apply_terminal_bootstrap (installs history + replayed editor/agent/panel state). The existing inline caller keeps its behavior.
  • on_session_deleted now calls spawn_selected_transcript_load: the local consequences (selection, view mode, scrollback, session transition, cleanup) still apply in the same frame; the transcript + PTY replay + parse go to a background connection and land as SessionMutationResult::SelectedSessionLoaded.
  • Each load carries the selection generation it was dispatched for. A result that arrives after the user navigated on, or for a session that is no longer selected, is discarded instead of painted into the current pane.
  • The two fire-and-forget pty_resize calls that follow a bootstrap go out on their own connection too.

Same shape the fork picker's turn list already uses (spec 0163). Added specs/0185-fleet-events-never-block-the-client-loop.md since the rule generalizes: dispatching a mutation off the loop isn't enough when the completion notification's handler does the blocking work.

Tests

  • deleting_the_selected_session_does_not_await_the_neighbor_load — mock daemon accepts the connection and then goes silent, so any inline request blocks for the client's full 120s timeout. Verified it fails on the old code (5s budget exceeded) and passes here.
  • stale_selected_session_load_is_discarded — generation guard.

Full construct-cli suite: 1353 passed, 3 failed — empty_tui_renders_welcome_and_modeline_hint, modeline_tour_hint_clickable_only_in_empty_state, tour_cta_dims_and_loses_zone_while_tour_active, all three failing identically on main at 8edba01, untouched by this diff.

Binary

This PR is crates/cli only → the relevant binary is construct:

/Users/moon/construct/.claude/worktrees/async-post-delete-transcript-load/target/debug/construct

No recording: the change is the absence of a stall on an existing gesture, which a vhs capture wouldn't show any more clearly than the timing test does. Happy to record one if you'd like it on the PR.

`C-x k d` stuttered even though the delete RPC itself was already
dispatched off the event loop and the daemon returned before doing its
slow filesystem work. The stall came from the *reaction* to the
resulting `session/deleted`: `on_session_deleted` reselects a neighbor
and then awaited `refresh_selected_transcript`, which fetches that
session's whole transcript and — for a PTY session with no cached
history — its pty.log replay, plus the vt parse that rebuilds its
scrollback. No frames, no keystrokes, until the daemon answered.

Split `bootstrap_terminal` into a pure fetch (client + a plain-data
snapshot of the app state it needs) and an install step, so the
expensive half can run anywhere. The delete path now applies the local
consequences inline — selection, view mode, scrollback, cleanup — and
dispatches the loads on their own connection, landing as
`SelectedSessionLoaded`. Each load carries the selection generation it
was requested for; a load the user has navigated past is dropped rather
than painted into whatever pane is showing when it returns.

Same shape the fork picker's turn list already uses (spec 0163),
recorded now as spec 0185 since it generalizes to any client-side
reaction to a fleet notification.
@edwin-zvs
edwin-zvs merged commit 283e385 into main Aug 3, 2026
1 check passed
@edwin-zvs
edwin-zvs deleted the async-post-delete-transcript-load branch August 3, 2026 04:51
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