Select any of the transcript, and take a queued message back

Two things a reader could not do to what is on screen.

**Selection.** Nothing in the transcript was selectable at all, so a
command, a path or an error message could be read and not copied. One
`SelectionContainer` around the whole list rather than one per row: a
transcript is one body of text to a reader, and a selection has to be able
to run from a reply into the tool output under it. Per row it also could
not, and whatever was drawn without a container would have been silently
unselectable -- a state nothing on screen reports. Rows keep their tap
handlers; checked on the emulator that expanding a tool call, scrolling and
flinging are all unaffected, since a selection is a long press.

**Taking a message back.** A message sent into a running turn sits as a
bubble waiting to be read, and there was no way to change your mind: it is
tappable now, and the server answers `POST /sessions/{id}/unqueue`.

The answer has three states, and the middle one is the point. Claude's
driver writes a steer into the CLI's stdin the instant it arrives -- that
is what makes it reach the model at the next tool boundary rather than at
the end of the turn, and it was measured -- so the line is already gone and
`AlreadySent` is the only honest answer it can give. Holding the write
until a boundary would make the drop real and cost a steer one model call,
which is the latency the immediate write exists to remove; rejected on that
trade, with the reasoning in PLAN.md. The refusal is drawn on the bubble
that was pressed rather than in the error row under the header, a screen
away from it.

Where a driver really does hold its queue -- echo today -- the message goes
for good, and it goes as an `Event::MessageDropped` rather than as a return
value: every device watching the session loses the bubble, and a phone that
reconnects and replays the `messageQueued` does not put back one that was
cancelled with nothing left to resolve it.
This commit is contained in:
iris committed 2026-08-31 22:20:47 -04:00
1 parent 778b2e3b04
commit 82401cd887
12 files changed
+439 -48

No files matched your search

+48
View File
@@ -114,6 +114,22 @@ pub enum Event {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
images: Vec<ImageRef>,
},
/// A message taken out of the queue before the session read it, by
/// somebody tapping the bubble that was waiting for it.
///
/// Recorded for the same reason `MessageQueued` is: the queue is the
/// server's, so what is waiting has to be answerable from the
/// transcript alone. Without it a phone that reconnects replays the
/// `MessageQueued` and puts back a bubble for a message that will
/// never arrive -- and nothing later would ever resolve it, since the
/// `UserMessage` that normally does is exactly what is not coming.
///
/// Only ever sent for a message that had not been handed over. One
/// that has is not droppable and says so instead; see
/// [`Unqueued::AlreadySent`].
MessageDropped {
id: String,
},
/// A driver has taken one of the user's messages and started reading
/// it. The manager turns this into the `UserMessage` above, so it
/// never reaches a phone itself.
@@ -444,6 +460,25 @@ pub enum SessionStatus {
Unknown,
}
/// What became of a request to take a queued message back.
///
/// Three states rather than a bool because the two failures are not the
/// same fact. A driver that writes into its session the moment a message
/// arrives -- which is what `ClaudeDriver` does, so that a steer reaches
/// the model at the next tool boundary rather than at the end of the turn
/// -- can never take one back, and a phone that was told only "no" would
/// have to guess whether it had asked too late or asked about nothing.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Unqueued {
/// Out of the queue; the session will never read it.
Dropped,
/// Already handed to the session, so there is nothing left to take
/// back. The message is on its way into the conversation.
AlreadySent,
/// Nothing is waiting under that id.
Unknown,
}
/// Where a driver reports events. Unbounded because producers are child
/// processes a slow phone must never be able to stall; the transcript file
/// is the backpressure-free buffer of record.
@@ -463,6 +498,19 @@ pub trait Driver: Send + Sync {
/// message in the transcript, so a driver that never sends it drops
/// the message from the conversation entirely.
fn send_user_message(&self, text: String, images: Vec<ImageRef>);
/// Takes back a message that is still waiting, named by the id its
/// [`Event::MessageQueued`] carried.
///
/// Answering is the whole of the contract: a driver that drops the
/// message owes an [`Event::MessageDropped`], and one that cannot must
/// say which of the two reasons it is, because they are different
/// things to a reader -- "the session has already been told" is worth
/// knowing, and "there is nothing under that id" means the bubble on
/// screen is stale. The default is the honest answer for a driver with
/// no queue at all: nothing of yours is waiting.
fn unqueue(&self, _id: &str) -> Unqueued {
Unqueued::Unknown
}
/// Answers one question with everything that was chosen, in the order
/// it was offered. One answer is a list of one; a driver whose dialect
/// takes a single value joins them where it writes it.