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