Agent chat: a steer is one continuous turn; only Stop or Esc reads as stopped

Merged
TE
16d ago
+843-199

Problem

When Leo sends a message while a turn is running, and the agent can't take it mid-turn, acpmux ends the running turn and runs the message next. It ends the turn with a cancel, or, after acpmux #22808, Claude ends it with end_turn. The transcript drew this as a Stop: "You stopped after 55s" over the work, "Sent now · stopped the turn" under his message, and a fresh "Working for 18s". He never pressed Stop.

The turn really does end and restart under the hood. This PR keeps that data model and only merges how it is drawn. The "Worked for" and "You stopped after" lines below are the turn's fold label.

Change

  • direct.ts: telling a Send now from a real Stop.
    • acpmux sends a queue_order with next: true while a turn is open and the prompt is still queued. That arms interrupting.
    • The next turn_result that is not failed (cancelled or end_turn) marks its summary continued. A failure still shows.
    • The prompt's user_message row is marked continues. That means accepted and drawn inline, with no mark of its own: nothing reads "Steered".
    • A real Stop or Esc sends no queue_order next, so it is unchanged.
    • foldOlder saves, resets and restores interrupting, and patches a held sent-now row after an older page brings its cancel.
    • A Send now that never runs leaves the turn ended, but only on a record that says so: queue_removed (removed or withdrawn), prompt_refused (a local acpmux streams it now that the pane asks for it by name), another prompt's user_message, or the refusal answering this pane's own send. A transport error on that send (a closed socket fails it with native.timed_out) is not a refusal: acpmux still runs the prompt, so the turn stays continued. The summary then drops continued and shows its footer, "You stopped after" and its checkpoint. Between dequeued and the prompt's record, the chat reads idle while the prompt is about to run; the turn stays continued through that gap.
    • Stop clears a pending Send now, and a Send now inside Stop's grace period does not arm one.
  • New turnStart.ts. It holds continuesTurn (a steer, or a prompt that continues) and opensTurn. Everything that finds a turn by its prompt now walks past such a prompt:
    • turns.ts turns, with one Working and Worked clock from the first prompt. It also draws no "You stopped after" for a continued summary, including the moment before the prompt's record arrives.
    • diff.ts turnRows, and App's last edit turn.
    • The checkpoint pair, which spans from the first part's start to the summary that ends the turn (endsTurn).
    • rowMarks.ts, which marks only turn-ending summaries.
    • Find and shell reveal (turnFoldFor), and the hover footer (turnFooterFrom).
    • runningGroups and the minimap's turns.
  • Removed: the stopped steer state and its steer.stopped string. Native steers keep their existing marks; #22773 removes those.

Screenshots

These show the first version, where the sent-now message read "Steered". It now carries no mark.

BeforeAfter (first version)
beforeafter

Verification

Each fix lands as a tests-only commit followed by the fix commit. The tests are in webviews/test/transcript-steer-continuous.test.tsx. Each test commit fails on the fleet and its fix passes, running scripts/ci/run-webviews-tests.sh with cmux-ci run. They cover:

  • the inline steer and the clock;
  • a real Stop;
  • the checkpoint span;
  • Find and footer;
  • the live flash;
  • history paging;
  • the edit-then-Send-now checkpoint request;
  • both foldOlder leaks;
  • end_turn;
  • a failed turn;
  • a Send now removed before or after the turn's end, or refused with its record, each live and replayed;
  • a snapshot drawn between dequeued and the Send now's record (still continued, no checkpoint request);
  • a Send now whose request a dropped socket fails (native.timed_out): the turn stays continued;
  • Stop followed by Send now.

The suite's other failures are the same on each test commit and its fix: agent-pane-column-edges, apps-flow, composer-html-paste-edit, hover-row-sweep, stats-page, thread-minimap-feel, tooltip-surface, whats-new-text-selection.

Observed / Not run

  • The red-to-green proof is transcript-steer-continuous.test.tsx (bun + JSDOM, runs on the fleet). agent-pane-queue-steer.test.ts is a browser-lane file: requireBrowserLane skips it unless CI=true or CMUX_BROWSER_TESTS=1, so it never ran on the fleet and its update is checked only by GitHub CI.

  • The later commits were made in a sparse /tmp clone, because the shared repo's git was frozen on cosmic-gray. scripts/web-lint.sh there reported no errors in changed files, also after the base merge. Its other errors were in untouched pages (missing generated strings.json and out-of-clone fixtures), plus checks that need schemas/, Packages/ or the acpmux spec, which the clone lacks. CI's web lint lane is the check.

  • No macOS build or dogfood.

  • Known gaps:

    • A refused Send now stays drawn as continued until the next prompt when the pane never sees its prompt_refused: a remote acpmux, a pane reloaded after the refusal, or a pane other than the sender's. prompt_refused is a log record, not a transcript record, so only the sending pane's own refusal answer or a local live stream ends the turn.
    • A refusal can sit on an older-page boundary: when the attached page starts between the cancel and the prompt_refused, the refusal is read before the turn it ends, so loading the older page draws that turn as continued.
    • Stop is live-only. cancel() clears the pending Send now, but acpmux records no Stop, so replaying Stop followed by Send now draws a continued turn where the live pane drew it stopped. The durable fix is for acpmux to record a cancel_requested event; that is not built here.
  • Localization: one key removed, no new strings.

Agent session: cosmic-gray

🤖 Generated with Claude Code