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

@@ -8,6 +8,7 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.Box
@@ -371,6 +372,11 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
if (event is SessionEvent.UserMessage) {
queued = queued.filterNot { it.id == event.id }
}
// Waiting, then taken back. From the server rather than from the tap, so every device
// drops the bubble and a reconnect does not put back one that was cancelled.
if (event is SessionEvent.MessageDropped) {
queued = queued.filterNot { it.id == event.id }
}
// Waiting, then gone: a command leaves this list when the session takes it,
// and the row it becomes is added by `foldEvent` in the same pass.
if (event is SessionEvent.CommandQueued) {
@@ -883,6 +889,33 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
}
}
/**
* Asks the server to take back a message the session has not read yet.
*
* Nothing is removed here. The bubble goes on the `messageDropped` the server records, which is
* what makes the cancellation the session's own fact rather than this screen's opinion of it --
* a second device watching the same session has to lose the bubble too, and this one has to
* still lose it after a reconnect.
*
* The refusal is kept on the message it was about rather than in [actionError]: the error row
* lives under the header, and a bubble at the foot of the transcript is the thing that was
* pressed. It is the ordinary answer here rather than the exceptional one -- a Claude session
* writes a steer into the CLI the moment it arrives, so what is on screen as "waiting" is
* waiting to be *read*, not waiting to be sent.
*/
fun takeBack(messageId: String) {
scope.launch {
val refusal =
try {
withContext(Dispatchers.IO) { unqueueMessage(settings, summary.id, messageId) }
null
} catch (e: ApiException) {
e.message ?: "this message could not be taken back"
}
queued = queued.map { if (it.id == messageId) it.copy(refusal = refusal) else it }
}
}
fun act(onDone: () -> Unit = {}, action: () -> Unit) {
scope.launch {
try {
@@ -1154,6 +1187,13 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
text = waiting.text,
images = waiting.images,
pending = true,
refusal = waiting.refusal,
// The bubble goes away on the `messageDropped` this
// produces, not here: the server is what knows whether
// the message was still its to take back, and the
// other devices watching this session have to be told
// by the same event.
onTakeBack = { takeBack(waiting.id) },
)
}
}
@@ -1672,6 +1712,11 @@ private fun ProcessAction.perform(settings: ServerSettings, sessionId: String) =
*
* [pending] is one the server has taken and the session has not read yet -- drawn quieter, because
* "said" and "heard" are different claims and the transcript must not merge them.
*
* A pending bubble is tappable: [onTakeBack] asks the server to drop the message before the session
* reads it, and [refusal] is what came back when it would not. The refusal is drawn here rather
* than with the screen's other errors because this is where the reader pressed -- the error row is
* under the header, a screen away from the bubble they were looking at.
*/
@Composable
private fun UserBubble(
@@ -1680,6 +1725,8 @@ private fun UserBubble(
text: String,
images: List<String> = emptyList(),
pending: Boolean = false,
refusal: String? = null,
onTakeBack: (() -> Unit)? = null,
) {
Box(Modifier.fillMaxWidth()) {
Card(
@@ -1693,7 +1740,19 @@ private fun UserBubble(
if (pending) MaterialTheme.colorScheme.surfaceVariant
else MaterialTheme.colorScheme.primaryContainer
),
modifier = Modifier.align(Alignment.CenterEnd).padding(start = 48.dp),
modifier =
Modifier.align(Alignment.CenterEnd)
.padding(start = 48.dp)
.then(
if (onTakeBack == null) Modifier
else
Modifier.clickable(onClick = onTakeBack).semantics {
// The bubble is its own control and its own label; without this
// the only thing to read is the message, which does not say what
// pressing it does.
contentDescription = "Waiting to be read; tap to take it back"
}
),
) {
Column(Modifier.padding(12.dp)) {
// A message can be nothing but an attachment, and an empty line above a picture
@@ -1713,13 +1772,32 @@ private fun UserBubble(
if (index > 0 || text.isNotEmpty()) Spacer(Modifier.height(4.dp))
SessionImage(settings, sessionId, ref)
}
refusal?.let {
Spacer(Modifier.height(6.dp))
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
}
}
}
}
/** A message the server has accepted and the session has not read yet. */
private data class QueuedMessage(val id: String, val text: String, val images: List<String>)
/**
* A message the server has accepted and the session has not read yet.
*
* [refusal] is why taking it back did not work, kept per message rather than on the screen: two
* bubbles can be waiting at once, and an error above them both would not say which one it was
* about.
*/
private data class QueuedMessage(
val id: String,
val text: String,
val images: List<String>,
val refusal: String? = null,
)
/**
* Asked before switching model, because switching is not free and the cost is invisible.