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
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user