Skip to content

fix(app): show a steer as pending from its first frame - #55251

Merged
Hona merged 2 commits into
anomalyco:devfrom
Hona:steer-pending-flicker
Oct 14, 2026
Merged

Hona merged 2 commits into
anomalyco:devfrom
Hona:steer-pending-flicker

Conversation

@Hona

@Hona Hona commented Oct 13, 2026

Copy link
Copy Markdown
Member

A steer sent while the agent works showed as delivered (blue) for a few frames, then faded to pending (gray).

Causes (both needed for the flash)

  • Late admission: sendPrompt awaited the agent and model switch before data.session.prompt admitted the optimistic row. Until then the timeline had only the handoff message. It has no pending status, so it rendered as delivered, and as the active turn with Working under it.
  • Fade into pending: user-message-text transitions background-color and color over 200 ms. The row computes its style as delivered once before admission marks it pending, so it faded from blue even when admission landed in the same task.

Fix

  • composer/submit.ts: pass the selection commit as the data layer's prepare, like the TUI and /compact. The prompt admits locally in the same task as the handoff. The switch still lands before the POST.
    • A staged revert still settles before admission, because its echo deletes local rows from the boundary on. Only an idle session stages a revert, so this path never shows a pending steer.
    • A retried admission reuses the first commit, so a retry does not switch the agent again.
  • message-part.css: transition: none in the pending state. Only delivery (gray → blue) animates.
const admission = {
  // ...
  prepare: staged ? undefined : () => (committed ??= commit()),
}

await session.data.session.prompt(admission).catch(() => session.data.session.prompt(admission))
sequenceDiagram
  participant C as Composer
  participant T as Timeline
  participant D as Data layer
  participant S as Server
  Note over C,S: Before
  C->>T: handoff (no pending status: blue)
  C->>S: switch agent / model
  S-->>C: ok
  C->>D: prompt() admits the optimistic row (pending: fades to gray)
  D->>S: POST prompt
  Note over C,S: After
  C->>T: handoff
  C->>D: prompt({ prepare }) admits in the same task (pending: gray)
  D->>S: switch agent / model (prepare)
  D->>S: POST prompt
Loading

Model switch held in flight

Before After
Steer renders blue with Working under it Steer renders as pending

Bubble background on each painted frame after Enter (Chromium, local mock server)

Measured frames: before 7 off-color frames, after 0

Model switch slowed to 900 ms

Before

before-slowed.mp4

After

after-slowed.mp4

A steer sent while the agent works rendered as delivered until the agent and model switch returned, then faded to pending. Admit the prompt before the selection commits, as the TUI does, and stop the fade into the pending state.
Copilot AI balanced review requested due to automatic review settings October 13, 2026 22:57
@Hona
Hona requested a review from Brendonovich as a code owner October 13, 2026 22:57

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@opencode-agent opencode-agent 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.

The approach looks right. Passing the selection commit as the data layer's prepare matches what /compact and the TUI already do. The steer's row now appears in the same step as the handoff, and the agent/model switch still runs inside the per-session admission queue, before the POST. Retries reuse a finished switch, a failed switch still isn't retried, and a staged revert still settles before admission. The CSS change only removes the fade while a message is pending.

Checked by reading the code against packages/client/src/solid/data.ts and running bun test src/composer/submit.test.ts (15/15 pass). I couldn't run the Playwright spec or watch the colour change myself because there's no browser in this environment, so I'm relying on your before/after captures.

Public API: unchanged (prepare already existed on the data layer's prompt).

Blocking: bun run typecheck in packages/app now fails in submit.test.ts. It passes on dev. See the inline comment.

const request = await admitted.promise

expect(calls).toEqual([...row.calls, "prompt"])
expect(calls).toEqual(row.calls)

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.

This breaks bun run typecheck in packages/app (passes on dev): test.each makes row.calls a readonly array, so toEqual rejects it with TS2769 (readonly [...] is not assignable to string[]). expect(calls).toEqual([...row.calls]) fixes it.

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.

2 participants