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

@@ -492,6 +492,23 @@ fun sendMessage(
) {}
}
/**
* Takes back a message the session has not read yet, named by the id its `messageQueued` carried.
*
* Throws rather than returning an outcome, because both ways of failing are things the reader has
* to be told: 409 means the session was already given it, and 404 means nothing is waiting under
* that id. The bubble disappearing is the success case and it arrives on the event stream, not from
* here -- every device drops it, not only the one that tapped.
*/
fun unqueueMessage(settings: ServerSettings, sessionId: String, messageId: String) {
requestFromServer(
settings,
"/sessions/$sessionId/unqueue",
method = "POST",
jsonBody = JSONObject().put("messageId", messageId).toString(),
) {}
}
/** Uploads one picked image; the returned id goes into [sendMessage]. */
fun uploadAttachment(
settings: ServerSettings,
@@ -53,6 +53,16 @@ sealed class SessionEvent {
data class MessageQueued(val id: String, val text: String, val images: List<String>) :
SessionEvent()
/**
* A queued message taken back before the session read it.
*
* Recorded by the server for the same reason [MessageQueued] is: a phone that reconnects
* replays both, and without this one it would put back a bubble for a message that is never
* coming -- with nothing left to resolve it, since the [UserMessage] that normally does is
* exactly what was cancelled.
*/
data class MessageDropped(val id: String) : SessionEvent()
data class AssistantText(val delta: String) : SessionEvent()
data class ToolStart(val id: String, val tool: String, val input: String) : SessionEvent()
@@ -183,6 +193,7 @@ fun parseSeqEvent(json: String): SeqEvent {
body.getString("text"),
body.stringList("images"),
)
"messageDropped" -> SessionEvent.MessageDropped(body.getString("id"))
"assistantText" -> SessionEvent.AssistantText(body.getString("delta"))
"toolStart" ->
SessionEvent.ToolStart(
@@ -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.
@@ -363,6 +363,9 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
// No row of its own: a message that is still waiting is drawn as a pending bubble below
// the transcript, and becomes an ordinary one where the session read it.
is SessionEvent.MessageQueued -> items
// The bubble goes away and nothing takes its place: the message was never read, so there
// is nothing it belongs above.
is SessionEvent.MessageDropped -> items
is SessionEvent.Settings -> items
is SessionEvent.Status -> items
is SessionEvent.Error -> items + TranscriptItem.ErrorMsg(entry.seq, event.message)
@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
@@ -35,6 +36,14 @@ import androidx.compose.ui.unit.dp
* What keeps a unit's arrival cheap enough to happen mid-fling: a unit is at most one block of a
* reply, and its parse is already made by [warm] before the fold that introduces it -- so entering
* composition costs laying out one paragraph, not parsing a message.
*
* The whole list sits in a [SelectionContainer], which is what makes every word in the transcript
* selectable by the platform's own press-and-hold. Here rather than at each place text is drawn: a
* transcript is one body of text to a reader, and a container per row would mean a selection could
* never cross from a reply into the tool output that follows it -- and would leave whatever was
* drawn without one silently unselectable, which is a state nothing on screen reports. Rows keep
* their tap handlers: selection is a long press, and the container passes an ordinary click through
* to the card under it.
*/
@Composable
fun TranscriptList(
@@ -45,51 +54,58 @@ fun TranscriptList(
below: @Composable () -> Unit,
unit: @Composable (TranscriptUnit) -> Unit,
) {
LazyColumn(
state = state,
reverseLayout = true,
contentPadding = TRANSCRIPT_PADDING,
modifier =
// Timed in two halves because the frame's draw phase is where Compose's measurement
// lands, and "draw is high while nothing is being recorded" does not say which half;
// see [drawAccounting]. Measure includes composing the items that scrolled in.
modifier
.layout { measurable, constraints ->
val started = System.nanoTime()
val placeable = measurable.measure(constraints)
DebugStats.record("measure: the whole transcript", System.nanoTime() - started)
layout(placeable.width, placeable.height) {
val placing = System.nanoTime()
placeable.place(0, 0)
SelectionContainer {
LazyColumn(
state = state,
reverseLayout = true,
contentPadding = TRANSCRIPT_PADDING,
modifier =
// Timed in two halves because the frame's draw phase is where Compose's measurement
// lands, and "draw is high while nothing is being recorded" does not say which
// half;
// see [drawAccounting]. Measure includes composing the items that scrolled in.
modifier
.layout { measurable, constraints ->
val started = System.nanoTime()
val placeable = measurable.measure(constraints)
DebugStats.record(
"place: the whole transcript",
System.nanoTime() - placing,
"measure: the whole transcript",
System.nanoTime() - started,
)
layout(placeable.width, placeable.height) {
val placing = System.nanoTime()
placeable.place(0, 0)
DebugStats.record(
"place: the whole transcript",
System.nanoTime() - placing,
)
}
}
.drawWithContent {
val started = System.nanoTime()
drawContent()
DebugStats.record("draw: the whole transcript", System.nanoTime() - started)
},
) {
// The bottom of the screen: what is waiting to be read sits under the newest message.
item(key = "below", contentType = "below") { below() }
items(count = units.size, key = { units[it].key }, contentType = { units[it]::class }) {
val u = units[it]
DebugStats.count("unit composed")
Box(Modifier.fillMaxWidth().padding(top = u.gap)) { unit(u) }
}
// Standing in for everything not fetched yet. Only here while there is more -- its
// appearance at the top edge is also roughly when the next page is asked for, so what
// it
// reports is a fetch in flight rather than an end reached.
if (moreHistory) {
item(key = "history", contentType = "history") {
Box(Modifier.fillMaxWidth().padding(vertical = 24.dp)) {
CircularProgressIndicator(
Modifier.align(Alignment.Center).size(HISTORY_SPINNER)
)
}
}
.drawWithContent {
val started = System.nanoTime()
drawContent()
DebugStats.record("draw: the whole transcript", System.nanoTime() - started)
},
) {
// The bottom of the screen: what is waiting to be read sits under the newest message.
item(key = "below", contentType = "below") { below() }
items(count = units.size, key = { units[it].key }, contentType = { units[it]::class }) {
val u = units[it]
DebugStats.count("unit composed")
Box(Modifier.fillMaxWidth().padding(top = u.gap)) { unit(u) }
}
// Standing in for everything not fetched yet. Only here while there is more -- its
// appearance at the top edge is also roughly when the next page is asked for, so what it
// reports is a fetch in flight rather than an end reached.
if (moreHistory) {
item(key = "history", contentType = "history") {
Box(Modifier.fillMaxWidth().padding(vertical = 24.dp)) {
CircularProgressIndicator(
Modifier.align(Alignment.Center).size(HISTORY_SPINNER)
)
}
}
}
}