Read a timeout in units, share one usage answer, and let a tilde mean home

The composer's settings row is outlined bubbles opening round menus, and
the message box is a TextFieldValue so anything put into it without being
typed -- a draft, a share, a slash command -- leaves the cursor at the end.

A tap that puts a text selection away no longer also collapses the card the
text was drawn in: every open and close on the session screen goes through
one guard that spends such a press on the selection.

The usage bar and the usage dialog were two polls of one measurement and
disagreed for up to a minute at a time; they are one feed now, and the
countdown rounds up to the minute in the one place both read.

A working directory typed as ~/repos/ai-app was four literal characters on
the local transport and as an argument on both, so the existence check
refused every home-relative path. It is checked by entering the directory
now, expanded for a local spawn the way the remote shell expands it, and
stored short so the phone draws what somebody would write.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-09-03 19:56:36 -04:00
1 parent 2970a6cf9a
commit 32b2a4871a
16 files changed
+414 -99

No files matched your search

@@ -32,6 +32,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.selection.rememberSelectionState
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
@@ -71,6 +72,8 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.PopupProperties
@@ -267,13 +270,17 @@ fun SessionScreen(
// leaving the screen -- or the system reclaiming the app -- does not throw away a half-typed
// message. See `Drafts.kt` for why this one piece of state is the device's rather than the
// server's.
var input by remember(summary.id) { mutableStateOf(loadDraft(context, summary.id)) }
var input by remember(summary.id) { mutableStateOf(atEnd(loadDraft(context, summary.id))) }
// A model the reader has chosen and not yet confirmed. See [ModelSwitchWarning]: switching
// makes the session re-read the whole conversation, which is worth asking about first.
var pendingModel by remember { mutableStateOf<String?>(null) }
// What was last taken from the command suggestions, so the list closes behind it; see
// [CommandSuggestions] at its call site.
var picked by remember { mutableStateOf<String?>(null) }
// The transcript's selection, held here rather than inside [TranscriptList] because the rows
// have to ask whether anything is selected before they treat a tap as their own -- see
// [expanding].
val selection = rememberSelectionState()
var expandedTools by remember { mutableStateOf(setOf<String>()) }
// Which runs of adjacent tool calls are open. Keyed by the first call's
// id, so a group survives more calls arriving after it.
@@ -485,6 +492,30 @@ fun SessionScreen(
}
}
/**
* A press on the transcript that would open or close something, and the one thing every such
* press has to check first.
*
* The transcript is one [SelectionContainer], so a reader who has selected some text puts that
* selection away by tapping -- and the tap that does it lands on whatever card the text is
* drawn in. Left alone, that card takes it as a press of its own: the reader clears a selection
* and the tool call under their finger collapses, which is a second thing happening for a
* gesture that meant one. So a press with a selection outstanding spends itself clearing it and
* does nothing else, and the press after that -- with nothing selected -- opens or closes as
* usual.
*
* Every open and close on this screen goes through here rather than each writing the check,
* since which card the finger lands on is not something the reader chose and the rule cannot
* hold for only some of them.
*/
fun expanding(toggle: () -> Unit) {
if (selection.selectedTexts.isNotEmpty()) {
selection.clear()
return
}
toggle()
}
/**
* Changes a row's height while the end the reader touched stays where it is.
*
@@ -505,7 +536,7 @@ fun SessionScreen(
* comes from the row's own detector ([LastTouch]), written by the gesture that is about to run
* [toggle].
*/
fun toggleAnchored(row: TranscriptRow, toggle: () -> Unit) {
fun toggleAnchored(row: TranscriptRow, toggle: () -> Unit) = expanding {
if (lastTouch.key == row.key && lastTouch.high) topEdgeHeld.key = row.key
toggle()
}
@@ -1027,7 +1058,7 @@ fun SessionScreen(
}
/** Opens or closes one memory note, wherever it is drawn; see [MemoryNote]. */
fun toggleMemory(text: String) {
fun toggleMemory(text: String) = expanding {
openMemories = if (text in openMemories) openMemories - text else openMemories + text
}
@@ -1043,7 +1074,7 @@ fun SessionScreen(
* the reader tapped keeps its place because the list keeps it, not because a measurement
* corrected it afterwards.
*/
fun togglePeer(seq: Long) {
fun togglePeer(seq: Long) = expanding {
expandedNotes = if (seq in expandedNotes) expandedNotes - seq else expandedNotes + seq
}
@@ -1064,7 +1095,7 @@ fun SessionScreen(
}
fun send() {
val text = input.trim()
val text = input.text.trim()
val attachments = pendingAttachments
if (text.isEmpty() && attachments.isEmpty()) return
// A command is not a message: it is an instruction to the session about itself, and one
@@ -1072,7 +1103,7 @@ fun SessionScreen(
// turn ends and says so, which is where its waiting bubble comes from -- so nothing is
// held here, and there is no local guess to correct when the answer arrives.
if (text.startsWith("/") && attachments.isEmpty()) {
input = ""
input = atEnd("")
saveDraft(context, summary.id, "")
// The one command with a visible effect outside the transcript, applied when the
// server has accepted it rather than when it was typed: the name is this app's own
@@ -1087,7 +1118,7 @@ fun SessionScreen(
}
return
}
input = ""
input = atEnd("")
saveDraft(context, summary.id, "")
pendingAttachments = emptyList()
// Nothing is added here. The server says what is waiting -- it emits `messageQueued`
@@ -1132,14 +1163,15 @@ fun SessionScreen(
onShareTaken()
incoming.uris.forEach(::attach)
incoming.text?.let { shared ->
input = if (input.isBlank()) shared else input + "\n" + shared
saveDraft(context, summary.id, input)
input = atEnd(if (input.text.isBlank()) shared else input.text + "\n" + shared)
saveDraft(context, summary.id, input.text)
}
}
// One poll for this machine's limits, read by the two things that show them: the bar under
// the header, and the colour of the button that opens the dialog.
val usage = rememberSessionUsage(settings, summary.setup)
// One poll for the machines' limits, read by everything on this screen that reports them:
// the bar under the header, the colour of the button that opens the dialog, and the dialog.
val usageFeed = rememberUsageFeed(settings)
val usage = usageFeed.forSetup(summary.setup)
RecordFrames()
var usageOpen by remember { mutableStateOf(false) }
var settingsOpen by remember { mutableStateOf(false) }
@@ -1341,6 +1373,7 @@ fun SessionScreen(
units = units,
state = listState,
moreHistory = moreHistory,
selection = selection,
modifier =
Modifier.fillMaxSize().drawWithContent { if (settled) drawContent() },
below = {
@@ -1720,9 +1753,13 @@ fun SessionScreen(
// has nothing left to offer, in front of the box they are about to send from.
// Held by what was picked rather than by a flag, so typing anything else brings
// the list back without needing a second thing to reset.
commands = if (input == picked) emptyList() else suggestedCommands(input),
commands = if (input.text == picked) emptyList() else suggestedCommands(input.text),
onPick = { command ->
input = command.typed()
// At the end of what was inserted, which is where the reader carries on
// typing: a command with an argument is put in the box half-written, and a
// cursor left at the front makes the next keystroke the first character of
// "/rename" rather than of the name.
input = atEnd(command.typed())
picked = command.typed()
},
)
@@ -1747,7 +1784,7 @@ fun SessionScreen(
value = input,
onValueChange = {
input = it
saveDraft(context, summary.id, it)
saveDraft(context, summary.id, it.text)
},
modifier = Modifier.fillMaxWidth(),
// No longer "(+image)": the images are on screen above this, and a placeholder
@@ -1764,13 +1801,14 @@ fun SessionScreen(
var attaching by remember { mutableStateOf(false) }
Box {
// Just "+". The count it used to carry was standing in for showing them.
TextButton(onClick = { attaching = true }) { Text("+") }
BubbleButton(onClick = { attaching = true }) { Text("+") }
DropdownMenu(
expanded = attaching,
onDismissRequest = { attaching = false },
// See PickerButton: without this the menu opens a status bar's
// height away from the button in an edge-to-edge activity.
properties = PopupProperties(clippingEnabled = false),
shape = BubbleMenuShape,
) {
DropdownMenuItem(
text = { Text("Photo") },
@@ -1883,7 +1921,7 @@ fun SessionScreen(
// not hidden, for the reason the button beside it is always here.
Button(
onClick = { send() },
enabled = input.isNotBlank() || pendingAttachments.isNotEmpty(),
enabled = input.text.isNotBlank() || pendingAttachments.isNotEmpty(),
colors = actionButtonColors(if (running) queueColor else sendColor),
) {
Glyph(
@@ -1902,7 +1940,7 @@ fun SessionScreen(
// open is the screen's business rather than any row's. See [SessionImageViewer].
fullImage?.let { ref -> SessionImageViewer(settings, summary.id, ref) { fullImage = null } }
if (usageOpen) {
UsageDialog(settings = settings, onDismiss = { usageOpen = false })
UsageDialog(feed = usageFeed, onDismiss = { usageOpen = false })
}
if (settingsOpen) {
SessionSettingsDialog(
@@ -2283,6 +2321,17 @@ private fun QuestionRow(
}
}
/**
* [text] in the message box, with the cursor after it.
*
* Everything that puts words in the box without the reader typing them goes through here: a
* restored draft, a share arriving from another app, a slash command taken from the suggestions.
* All three leave the reader mid-sentence, and all three used to leave the cursor at whatever
* offset it happened to hold -- which for a box that has never been focused is the very start, so
* picking `/rename` and typing put the name in front of the command.
*/
private fun atEnd(text: String) = TextFieldValue(text, TextRange(text.length))
/**
* How long after a menu closes a press on its own button still counts as the press that closed it.
*
@@ -2314,7 +2363,7 @@ private fun PickerButton(current: String, options: List<String>, onPick: (String
// as a new one.
var closedAt by remember { mutableLongStateOf(0L) }
Box {
TextButton(
BubbleButton(
onClick = { if (SystemClock.uptimeMillis() - closedAt > ONE_TAP_MS) open = true }
) {
// One line, truncated rather than wrapped: this sits in a row
@@ -2353,6 +2402,7 @@ private fun PickerButton(current: String, options: List<String>, onPick: (String
closedAt = SystemClock.uptimeMillis()
},
properties = PopupProperties(focusable = false, clippingEnabled = false),
shape = BubbleMenuShape,
) {
options.forEach { option ->
DropdownMenuItem(