Skip to content

fix(chat): steer a draft with image attachments instead of refusing it - #640

Merged
xintaofei merged 5 commits into
xintaofei:mainfrom
Adam-Dalloul:fix/steer-with-attachments
Sep 4, 2026
Merged

fix(chat): steer a draft with image attachments instead of refusing it#640
xintaofei merged 5 commits into
xintaofei:mainfrom
Adam-Dalloul:fix/steer-with-attachments

Conversation

@Adam-Dalloul

Copy link
Copy Markdown
Contributor

With the Claude Code agent, "Insert into current turn" refused any draft that had an image staged: the menu entry disabled itself with a text-only tooltip, and a draft holding file badges was quietly rerouted to the queue. The limit was on our side, not the wire's. The steering chain narrowed everything to a bare string (ConnectionCommand::Steer { text }, build_steer_params hardcoding a single text block), while claude-agent-acp's _session/steering handler feeds the prompt array through the exact same conversion as session/prompt, image blocks included.

Steer now carries the draft's PromptInputBlocks end to end. The composer sends the full block list whenever the draft holds more than plain text, with the display text as the recorded note; the backend maps it with map_prompt_blocks (the session/prompt encoding) and re-hydrates uploaded file:// markers exactly like /acp_prompt, so web and remote mode work unchanged. The prompt ledger fingerprints the steered blocks the way a prompt's are, and a text-only steer stays byte-identical to before.

Attachments are still never silently dropped: the pull tool carries plain text only, so a blocks-bearing note that races a mid-session channel downgrade is rejected with NoActiveTurn and the composer's existing fallback queues the whole draft, image included, with the usual toast. Steering while an upload is still settling gets the same "still uploading" toast a plain send does, and the now-unused steerAttachmentsUnsupported string is removed from all ten locales.

Adam-Dalloul and others added 5 commits September 2, 2026 13:44
The composer's "insert into current turn" entry went dark the moment an
image was staged, and a draft with file badges was silently rerouted to
the queue. The whole steering chain narrowed the draft to a bare string
(ConnectionCommand::Steer carried text, build_steer_params hardcoded a
single text block), so those gates were the honest option. The wire never
had that limit: the claude adapter's _session/steering handler feeds the
prompt array through the same conversion as session/prompt, images
included.

Steer now carries the draft's PromptInputBlocks end to end. The composer
sends the full block list whenever the draft holds more than plain text,
with the display text as the recorded note; the backend maps the blocks
with map_prompt_blocks, the exact session/prompt encoding, and re-hydrates
uploaded file:// markers like /acp_prompt does, so web and remote mode
work unchanged. The prompt ledger fingerprints the steered blocks the same
way a prompt's are, and a text-only steer stays byte-identical to before.

The pull channel still delivers plain text, so a blocks-bearing note on a
session that downgraded mid-race is rejected with NoActiveTurn and the
composer's existing fallback queues the whole draft, attachments included;
an attachment is never silently dropped. Steering while an upload is still
settling gets the same toast a plain send does, and the now-unused
steerAttachmentsUnsupported string is gone from all ten locales.
`onSteer` widened to `(text, blocks?)` in `MessageInput` and in the panel's
handler, but `ConversationShell` and `ChatInput` — the two layers the prop
actually travels through — still declared the one-argument form. The optional
second parameter keeps that assignable, so tsc reports nothing and the leak
would only appear the day either layer wraps the callback instead of
forwarding it.

Also lock down the two claims the change rests on: the text-only steer's wire
shape is now asserted by exact equality rather than field probes, and the
enqueue fallback is asserted to carry the image block, not just the prose.
Attachment hydration is the one await `submit_feedback_native` puts between
the `turn_in_flight` admission check and the enqueue, and it runs for as long
as reading the uploads takes. The loop's idle arm covers "the turn ended" — it
replies `NoActiveTurn` and the composer queues the whole draft — but it cannot
cover "the next turn started in the meantime": the flag reads true either way,
so the loop is in its active arm and injects the note into a turn the user
never aimed at, recorded `Delivered` while the composer clears.

`turn_in_flight` says only that some turn is running, never which one, so give
the state a turn identity: `SessionState.turns_completed`, bumped next to the
`turn_in_flight` clear in the `TurnComplete` handler — the single production
site that ends a turn. A steer captures it during admission and re-checks it
after hydration; a change means the admitted turn is over and the note takes
the caller's queue fallback, which re-routes the whole draft, attachment
included.

A counter rather than the existing `pending_user_message_started_at`: that
stamp only exists once a turn has published a user message, and `user_message`
is `None` for delegation children and unbound conversations, so those turns
would have carried no identity at all. The counter is monotonic rather than an
exact turn count — `TurnComplete` has three emitters and a repeat can land on
a settled turn — and only inequality is ever read.
main's 830ea83 dropped fork-and-send, taking the `onForkSend` prop and the
composer's send split button with it — the same declarations this branch edits
to widen `onSteer` with the steered draft's blocks. Three files conflicted:
message-input.tsx, chat-input.tsx and conversation-shell.tsx, all on that
adjacent prop block. Each resolves the same way: keep main's removal of
fork-and-send, keep the widened `onSteer(text, blocks?)`.

Nothing else needed hand-resolving; the steering chain, the i18n removals and
the backend guard all merged clean, and no fork-send reference survives.
`830ea832` removed fork-and-send and `8eb05f8e` swept its comments, but the
steering handler's note still contrasted itself with "the synchronous
send/enqueue/fork paths". There is no fork path in the composer any more.
@xintaofei

Copy link
Copy Markdown
Owner

codeg work task 146 is done — #640 (97 files, +8431/-659).

@xintaofei
xintaofei merged commit 37acf93 into xintaofei:main Sep 4, 2026
7 checks passed
xintaofei added a commit that referenced this pull request Sep 5, 2026
main's #640 rewrote the same mid-turn send this branch widens, so both sides
of every steer hunk are kept:

* `onSteer` takes main's `(text, blocks?)` across all three composer layers,
  alongside this branch's `steerChannel`. `onForkSend` stays deleted.
* `handleSteerClick` keeps main's body — the uploading gate and the block
  payload — and this branch's channel-keyed label, icon and failure toast.
  A pull session with an attachment now reaches the backend's blocks
  rejection as ordinary operation, and lands on the same queue fallback.
* `steerAttachmentsUnsupported` goes with main's deletion (attachments are
  supported now); `steerNoteFailed` stays in all ten locales.
* Both test suites keep every case from both sides.

`submit_feedback`'s comment on the pull-path blocks rejection said it only
arises after a native downgrade; with the affordance on every session it is
now the normal path for a pull session, so it says both.
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