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

+43 -1
View File
@@ -17,6 +17,8 @@
//! `reset` frame plus the newest window)
//! POST /sessions/{id}/message {text, attachmentIds?}
//! (starts the process first if it has exited)
//! POST /sessions/{id}/unqueue {messageId} -- take back one not read yet
//! (409 when the session already has it)
//! POST /sessions/{id}/answer {questionId, answers} (questions and permissions)
//! POST /sessions/{id}/interrupt stop the running turn; the process stays
//! POST /sessions/{id}/stop end the process; the session and transcript stay
@@ -68,7 +70,7 @@ use tokio::sync::{broadcast, mpsc};
use tokio_stream::StreamExt;
use tokio_stream::wrappers::{BroadcastStream, ReceiverStream};
use crate::session::driver::SessionCommand;
use crate::session::driver::{SessionCommand, Unqueued};
use crate::session::pending::Operation;
use crate::session::transcript::{CATCH_UP_LIMIT, CatchUp, SeqEvent, catch_up};
use crate::session::{LiveSession, SessionInfo, SessionManager, SpawnSpec};
@@ -93,6 +95,7 @@ pub fn router(manager: Arc<SessionManager>) -> Router {
.route("/sessions/{id}/events", get(events))
.route("/sessions/{id}/transcript", get(transcript))
.route("/sessions/{id}/message", post(message))
.route("/sessions/{id}/unqueue", post(unqueue))
.route("/sessions/{id}/answer", post(answer))
.route("/sessions/{id}/interrupt", post(interrupt))
.route("/sessions/{id}/stop", post(stop))
@@ -123,6 +126,10 @@ enum ApiError {
UnknownRoute,
#[error("{0}")]
BadRequest(String),
/// The request was understood and the state it names has moved on --
/// distinct from `BadRequest`, which is a caller that got it wrong.
#[error("{0}")]
Conflict(String),
#[error(transparent)]
Internal(#[from] anyhow::Error),
}
@@ -132,6 +139,7 @@ impl IntoResponse for ApiError {
let status = match self {
Self::NotFound(_) | Self::UnknownRoute => StatusCode::NOT_FOUND,
Self::BadRequest(_) => StatusCode::BAD_REQUEST,
Self::Conflict(_) => StatusCode::CONFLICT,
Self::Internal(err) => {
// The only variant whose real cause isn't safe to hand
// back verbatim, and the only one worth a log line.
@@ -900,6 +908,40 @@ async fn message(
Ok(StatusCode::NO_CONTENT)
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
struct UnqueueRequest {
/// The id the `messageQueued` event carried, which is what the bubble
/// on screen is drawn from.
message_id: String,
}
/// Takes back a message the session has not read yet.
///
/// The two failures are separate answers rather than one refusal, because
/// they are different things to whoever tapped: `409` means the session has
/// already been told and the message is on its way into the conversation,
/// and `404` means nothing is waiting under that id -- a bubble on screen
/// that something else has already resolved. See [`Driver::unqueue`]; the
/// Claude driver can only ever give the first, since it writes a steer into
/// the CLI the moment it arrives.
async fn unqueue(
State(manager): State<Arc<SessionManager>>,
UrlPath(id): UrlPath<String>,
axum::Json(body): axum::Json<UnqueueRequest>,
) -> Result<StatusCode, ApiError> {
match lookup(&manager, &id)?.unqueue(&body.message_id) {
Unqueued::Dropped => Ok(StatusCode::NO_CONTENT),
Unqueued::AlreadySent => Err(ApiError::Conflict(
"the session has already been given this message".to_string(),
)),
Unqueued::Unknown => Err(ApiError::NotFound(
"this message is not waiting to be read".to_string(),
)),
}
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]