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:
1 parent
778b2e3b04
commit
82401cd887
12 files changed
+439
-48
No files matched your search
+43
-1
@@ -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)]
|
||||
|
||||
Reference in new issue
Block a user