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:
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(
|
||||
|
||||
Reference in new issue
Block a user