diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt index 2a06453..8121c1f 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt @@ -29,7 +29,9 @@ import kotlinx.coroutines.withContext * * Import, models and setups are not here any more. They are tabs inside [MainScreen] -- four views * of the same backend, none of them a step down from another -- and what is left in this `when` is - * only what genuinely is a step down: one session, spawning one, and settings. + * only what genuinely is a step down: one session, spawning one, and settings. A session's own + * settings are not among them: they are a dialog over the session, which is where the thing they + * change is. */ private sealed class Screen { data object Main : Screen() @@ -38,13 +40,6 @@ private sealed class Screen { data object Spawn : Screen() - /** - * What can be changed about one session. Carries the session back with it so Back returns where - * it came from, and carries it *out* renamed, so the session behind it shows the new name - * without waiting for a list refresh. - */ - data class SessionSettings(val from: SessionSummary) : Screen() - data object Settings : Screen() } @@ -174,12 +169,7 @@ fun AppRoot(settingsVersion: Int, openRequest: SessionOpenRequest?) { // row key. Only reachable since a notification can move straight from one session to // another; every other way here passes through [Screen.Main], which disposes it anyway. key(here.summary.id) { - SessionScreen( - settings = current, - summary = here.summary, - onBack = goToMain, - onSettings = { screen = Screen.SessionSettings(here.summary) }, - ) + SessionScreen(settings = current, summary = here.summary, onBack = goToMain) } is Screen.Spawn -> SpawnScreen( @@ -190,18 +180,6 @@ fun AppRoot(settingsVersion: Int, openRequest: SessionOpenRequest?) { }, onBack = goToMain, ) - is Screen.SessionSettings -> - SessionSettingsScreen( - settings = current, - session = here.from, - onRenamed = { renamed -> - // The list shows the name too, so it has to refetch; and the session - // returned to is the renamed one, not the one this was opened from. - reloadToken++ - screen = Screen.Session(renamed) - }, - onBack = { screen = Screen.Session(here.from) }, - ) is Screen.Settings -> SettingsScreen( existing = current, diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/NerdIcons.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/NerdIcons.kt index 7910a67..16eaec8 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/NerdIcons.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/NerdIcons.kt @@ -26,9 +26,9 @@ import androidx.compose.ui.unit.sp * grounds that a system font may not have the glyph and whoever gets the empty box instead is never * the person who wrote it. That objection is about *relying* on a system font, and it is exactly * right: the answer is not to avoid glyphs but to ship them. The font here is - * `app/build-icon-font.sh`'s output -- five glyphs, 1.4 KB, subset out of the 3 MB symbols font and - * committed -- so the codepoints below are resolved by an asset in the APK and cannot come back as - * tofu. Adding one means adding its codepoint in *both* places; a codepoint here that the script + * `app/build-icon-font.sh`'s output -- eleven glyphs, 2.1 KB, subset out of the 3 MB symbols font + * and committed -- so the codepoints below are resolved by an asset in the APK and cannot come back + * as tofu. Adding one means adding its codepoint in *both* places; a codepoint here that the script * did not subset is a glyph that silently isn't there. * * The subset is the font's **Mono** face, where every glyph is exactly one em wide and one em tall. @@ -94,6 +94,9 @@ val CLOSE_GLYPH = glyph(0xF0156) /** `md-arrow_left` -- back one level, to whatever this was opened from. */ val BACK_GLYPH = glyph(0xF004D) +/** `md-bell` -- the notifications this session is allowed to raise. */ +val BELL_GLYPH = glyph(0xF009A) + /** * `fa-line_chart` -- how much of the account's rate limits is gone. * diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ScrollAnchor.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ScrollAnchor.kt new file mode 100644 index 0000000..dd2f15a --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ScrollAnchor.kt @@ -0,0 +1,51 @@ +package com.example.aiapp + +import android.content.Context +import androidx.core.content.edit + +private const val ANCHORS = "session-scroll" + +/** + * Where a session's transcript was left, so reopening it lands where reading stopped. + * + * Named by the *row* rather than by an index, because an index means nothing across a reopen: the + * transcript is fetched newest-first and a session that has said anything since has renumbered + * every position. The row's own key survives all of it -- it is the same value the list is keyed + * by, which is what already stops the view moving when history pages in. + * + * [offset] is how far into that row the viewport starts, in pixels, and is the reason this is a + * pair rather than a key: a reader stopped halfway down a long tool output is put back halfway down + * it. + */ +data class ScrollAnchor(val key: String, val offset: Int) + +/** + * On this device rather than on the backend, which is where this app otherwise keeps state so every + * device sees it. Scroll position is the same exception a draft is: it is where the phone in + * somebody's hand is pointed, and having one device jump because another was scrolled would be a + * surprise rather than a convenience. + */ +fun loadScrollAnchor(context: Context, sessionId: String): ScrollAnchor? { + val stored = + context.getSharedPreferences(ANCHORS, Context.MODE_PRIVATE).getString(sessionId, null) + ?: return null + // Offset first so the split is unambiguous: a row key is an arbitrary string and may contain + // anything, where the offset is digits. + val offset = stored.substringBefore(':').toIntOrNull() ?: return null + return ScrollAnchor(stored.substringAfter(':'), offset) +} + +/** + * Records where [sessionId] is being read, or forgets it when [anchor] is null. + * + * The path out is reading to the newest end, which is what the caller passes null for: a session + * left at the bottom has nothing to restore and should open at the bottom, which is also the cheap + * case. A session *deleted* while it held an anchor leaves its key behind, for the reason and at + * the cost `Drafts.kt` describes. + */ +fun saveScrollAnchor(context: Context, sessionId: String, anchor: ScrollAnchor?) { + context.getSharedPreferences(ANCHORS, Context.MODE_PRIVATE).edit { + if (anchor == null) remove(sessionId) + else putString(sessionId, "${anchor.offset}:${anchor.key}") + } +} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt index 8dac1be..fed289c 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -64,6 +64,7 @@ import java.util.concurrent.atomic.AtomicReference import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -82,6 +83,29 @@ private const val RECONNECT_DELAY_MS = 1500L */ private const val HISTORY_LOOKAHEAD = 8 +/** + * How many events a backwards page asks for, which is ten times what the opening page takes. + * + * Because an event is not a row, and the ratio is nothing like one to one. Measured on a real + * transcript (2,426 events, 2026-08-30): the whole conversation is *seven* assistant messages, and + * the median run of consecutive text deltas that fold into one of them is four hundred. A page of + * eighty is therefore a fifth of a single row, and reaching [HISTORY_LOOKAHEAD] fresh rows took + * about thirty sequential round trips inside one collect -- a stutter on loopback, and four or five + * seconds of a list that will not move over the tunnel, which reads as history having run out. + * + * The opening page stays small: it is the one on the critical path of showing the screen at all, + * and it only has to fill a viewport. + */ +private const val HISTORY_PAGE = 800 + +/** + * The list key of the bubble holding what has been sent and not read yet. + * + * Named rather than written at the `item` that draws it, because a saved scroll position stores + * whatever key it was left on and this is one of the values that can be. + */ +private const val QUEUED_KEY = "queued" + /** * Which row was asked to hold its top edge, and how tall it was when it last measured. * @@ -547,12 +571,7 @@ private suspend fun warm(replies: ParsedReplies, rows: List) { } @Composable -fun SessionScreen( - settings: ServerSettings, - summary: SessionSummary, - onBack: () -> Unit, - onSettings: () -> Unit, -) { +fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: () -> Unit) { val scope = rememberCoroutineScope() val topEdgeHeld = remember { TopEdgeHold() } var items by remember { mutableStateOf(listOf()) } @@ -561,10 +580,12 @@ fun SessionScreen( // how much it is holding before any turn happens here. Null is "nobody has measured it", // which is a different answer from an empty context and is drawn differently. var contextTokens by remember(summary.id) { mutableStateOf(summary.contextTokens) } - // When this screen saw the current compaction start, on this device's own clock, and how long - // ago that is. See `compactingLabel`: null is the honest answer whenever the start was not - // witnessed here, which is what opening a session that is already compacting looks like. - var compactingSince by remember { mutableStateOf(null) } + // When the current compaction started and how long ago that is. The moment comes off the + // `compacting` status event itself -- the server timestamps every transcript line -- rather + // than off this device noticing one, which is what makes it survive leaving the session and + // reopening it. See `compactingLabel`: null is still the honest answer for a session whose + // status was never reported as compacting at all. + var compactingSince by remember { mutableStateOf(null) } var compactingFor by remember { mutableStateOf(null) } var streamError by remember { mutableStateOf(null) } var actionError by remember { mutableStateOf(null) } @@ -587,6 +608,9 @@ fun SessionScreen( // Which runs of adjacent tool calls are open. Keyed by the first call's // id, so a group survives more calls arriving after it. var expandedGroups by remember { mutableStateOf(setOf()) } + // Runs that have already been drawn as a group, so the transition into one is noticed exactly + // once. See the effect below. + var everGrouped by remember { mutableStateOf(setOf()) } // Which messages from other agents are open, by the seq that identifies their row. Closed // by default, which is the rule for anything new in this transcript: a screen that opens // everything it can is one nobody can scan. @@ -614,6 +638,25 @@ fun SessionScreen( // screen starts with the end of the conversation and fetches earlier // pages only when somebody scrolls to them. var oldestSeq by remember { mutableLongStateOf(0L) } + // Where this session was last being read, from this device's own store. Read once, because + // it is the question "where did I leave off" and the answer stops being interesting the + // moment the list is on screen. + val savedAnchor = remember(summary.id) { loadScrollAnchor(context, summary.id) } + // Whether the list is still being put back where it was left. Nothing is drawn while it is: + // opening at the newest end and then travelling to the anchor is exactly the journey + // `reverseLayout` exists to remove, and this transcript is not allowed to move under a reader. + var restoring by remember(summary.id) { mutableStateOf(savedAnchor != null) } + // Remembered, and only ever written when a scroll settles -- so it records where the reader + // last left the list, and an insertion cannot change the answer. Reading the live position + // instead looks right and is subtly wrong: a keyed list moves its anchor to keep the reader's + // content still, so by the time the new item can be observed the view is already one item + // away from the newest and reports itself as scrolled back. The message then never followed, + // which was visible as a compaction whose progress bar sat just off the bottom of the screen + // while the button that started it said it was running. + // + // Seeded from whether there is a position to go back to, so the correction it drives does not + // pull the list to the newest end before the restore has put it anywhere. + var followTail by remember(summary.id) { mutableStateOf(savedAnchor == null) } // Sent, but not yet read by the session -- which is when the backend // records it and it comes back as a row. Until then it is drawn below // the working indicator, because that is where it is in the session's @@ -728,16 +771,15 @@ fun SessionScreen( event.permissionMode?.let { permissionMode = it } } if (event is SessionEvent.Status) { - // Started here, or nowhere. `ready` is what separates the live stream from - // the page of history the screen opens with, and a compaction found in that - // page began before anybody here was watching -- timing it from now would - // report the moment we arrived as the moment it started. + // The event's own timestamp, so a compaction that began before this screen + // opened is timed from when it actually began. Timing it from the moment we + // arrived would report the wait as shorter than it was, in exactly the case + // somebody is asking about -- a compaction worth asking about is a long one. compactingSince = when { event.state != "compacting" -> null status == "compacting" -> compactingSince - ready -> SystemClock.elapsedRealtime() - else -> null + else -> entry.ts } status = event.state } @@ -771,6 +813,88 @@ fun SessionScreen( toggle() } + /** + * How many list items sit above every transcript row -- one while something is waiting, none + * otherwise. + * + * Read both here and by the `item` that draws that bubble, so a restored position and the list + * cannot disagree about what is at which index. Anything else added above the rows later + * belongs in this count. + */ + fun itemsAboveRows() = if (queued.isNotEmpty() || waitingCommands.isNotEmpty()) 1 else 0 + + /** + * Where the row named [key] sits in the list, or null when it is not loaded. + * + * Computed from `items` rather than from `rows` for the reason [loadOlderPage] gives. + */ + /** Everything the list draws, in items rather than in rows. See [itemsAboveRows]. */ + fun listItemCount() = groupToolRuns(items).size + itemsAboveRows() + + fun indexOfKey(key: String): Int? { + // The bubble is not a row, and it is above all of them. + if (key == QUEUED_KEY) return if (itemsAboveRows() > 0) 0 else null + val row = groupToolRuns(items).asReversed().indexOfFirst { it.key.toString() == key } + return if (row < 0) null else row + itemsAboveRows() + } + + /** + * One page of older events onto the front of what is loaded; false when there was none. + * + * Shared by the two things that page backwards -- somebody scrolling to the far end, and + * putting the list back where it was left -- because they want the same page for the same + * reason and a second copy of this would be a second answer to "what is loaded". + * + * Reads `items` rather than `rows`: this runs in a coroutine, and `rows` is the composition's + * value, which does not change under a running one. + */ + suspend fun loadOlderPage(): Boolean { + val older = + withContext(Dispatchers.IO) { + fetchTranscript(settings, summary.id, before = oldestSeq, limit = HISTORY_PAGE) + } + if (older.isEmpty()) { + moreHistory = false + return false + } + oldestSeq = older.first().seq + moreHistory = oldestSeq > 1L + // Folded oldest-first into a list of their own, then put in front: `foldEvent` merges + // streaming text into the item before it, so replaying an older page through the live + // list would glue it onto the newest message rather than its own. + var earlier = listOf() + older.forEach { entry -> + if (entry.event !is SessionEvent.UsageDelta) { + earlier = foldEvent(earlier, entry) + } + } + val joined = joinPages(earlier, items) + // After the join rather than on the page alone: a boundary that fell through a reply + // leaves `joinPages` holding a message made of both halves, and that text has existed for + // no time at all. Warming the page by itself warmed the two halves and missed the one + // thing drawn -- which showed up as a single 22ms parse surviving every page. + warm(replies, joined) + items = joined + return true + } + + // A call opened on its own stays open when a second call in the same run turns it into a + // group. Until this, watching a Bash call and having the session make another one shut the + // one being read and folded it behind "Called 2 tools" -- the reader lost what they were + // looking at because something else happened. + // + // Considered once per run, at the moment it first becomes a group, and never again: after + // that the group's own toggle owns it, and re-deriving this every time would re-open a group + // the reader had just shut while one of its calls was still expanded. + LaunchedEffect(rows) { + val fresh = rows.filterIsInstance().filter { it.id !in everGrouped } + if (fresh.isEmpty()) return@LaunchedEffect + expandedGroups = + expandedGroups + + fresh.filter { group -> group.calls.any { it.id in expandedTools } }.map { it.id } + everGrouped = everGrouped + fresh.map { it.id } + } + // A compaction reports nothing about its own progress -- measured against the CLI, which // says it has started, and then says nothing at all until it is done. So what this counts is // the one thing anybody here can measure: how long it has been going. A bar filling up would @@ -782,7 +906,11 @@ fun SessionScreen( return@LaunchedEffect } while (true) { - compactingFor = (SystemClock.elapsedRealtime() - since) / 1000 + // Against this device's wall clock, because `since` is the server's -- the same + // comparison `relativeTime` already makes for a session's last activity. Floored at + // zero so a phone running a little behind the backend counts up from nothing rather + // than reporting a compaction that has not started yet. + compactingFor = (System.currentTimeMillis() / 1000.0 - since).toLong().coerceAtLeast(0) delay(1000) } } @@ -794,15 +922,50 @@ fun SessionScreen( // stream then starts from where that page ended, so it carries live // events only -- which is what it is good at. LaunchedEffect(summary.id) { + // Whether the list ended up where the reader left it. False covers every way it did not + // -- no saved position, a row that is no longer in the transcript, a page that never + // arrived -- and all of them mean the same thing to the list: this is the newest end now, + // so follow it. + var restored = false try { val page = withContext(Dispatchers.IO) { fetchTranscript(settings, summary.id) } page.forEach { apply(it) } warm(replies, items) + // Then back where reading stopped. An anchor deeper than the newest page is exactly + // the one worth restoring -- somebody who read to the bottom has no anchor at all -- + // and the cost was already paid on the way down there. + savedAnchor?.let { anchor -> + // Pages until the row is loaded and has something older behind it. The oldest + // loaded row is a half-row: `joinPages` welds the other half onto it when the + // page behind it arrives, and it grows -- so anchoring into one puts the reader + // where they were only until the next page lands. Any row that is not the oldest + // is final. Landing a screen and a half out was what this cost. + var index = indexOfKey(anchor.key) + while (moreHistory && (index == null || index >= listItemCount() - 1)) { + if (!loadOlderPage()) break + index = indexOfKey(anchor.key) + } + // Both writes before this coroutine yields, so the list's first measurement is + // the one with every row in it *and* the requested position -- the list is drawn + // where it was left rather than drawn and then moved. `requestScrollToItem` is + // the form that is applied during a layout pass; see [holdTopEdge]. + restoring = false + // A null index is a row that is no longer in the transcript -- a reset stream, or + // a session cleared from elsewhere. + index?.let { + listState.requestScrollToItem(it, anchor.offset) + restored = true + } + } } catch (e: ApiException) { // Not fatal: the stream below still replays from zero, which is // slow but complete. Saying so beats silently showing nothing. streamError = e.message } + if (!restored) followTail = true + // Whatever happened above, including a page that never arrived: an empty transcript is a + // state the screen can draw, and a permanently blank one is not. + restoring = false ready = true } @@ -895,20 +1058,37 @@ fun SessionScreen( backlog.forEach { record(it) } } } - // Whether they *chose* to be there, which is a different question and the one that decides - // whether an arriving message brings the view with it. - // - // Remembered, and only ever written when a scroll settles -- so it records where the reader - // last left the list, and an insertion cannot change the answer. Reading the live position - // instead looks right and is subtly wrong: a keyed list moves its anchor to keep the reader's - // content still, so by the time the new item can be observed the view is already one item - // away from the newest and reports itself as scrolled back. The message then never followed, - // which was visible as a compaction whose progress bar sat just off the bottom of the screen - // while the button that started it said it was running. - var followTail by remember { mutableStateOf(true) } + // Whether they *chose* to be at the newest end, which is a different question from being + // there and the one that decides whether an arriving message brings the view with it. Written + // where a scroll settles, below. LaunchedEffect(listState) { snapshotFlow { listState.isScrollInProgress } - .collect { scrolling -> if (!scrolling) followTail = atNewest } + // The value `snapshotFlow` emits on collection is the state of things before anybody + // has touched the list, and it is `false` -- which reads here as a scroll that has + // just ended at the newest end, and so as an instruction to forget where the reader + // was. That wiped every saved position on the way in, before the restore below could + // use it. Only the transitions after it are scrolls. + .drop(1) + .collect { scrolling -> + if (scrolling) return@collect + followTail = atNewest + // Written where the answer settles, for the same reason [followTail] is: mid-fling + // is not where anybody left off. Cleared at the newest end rather than recorded, + // because that is where a session with nothing to restore opens anyway -- so the + // ordinary case costs a `remove` and no page-back on the way in. + saveScrollAnchor( + context, + summary.id, + if (atNewest) null + else + listState.layoutInfo.visibleItemsInfo.firstOrNull()?.let { first -> + ScrollAnchor( + first.key.toString(), + listState.firstVisibleItemScrollOffset, + ) + }, + ) + } } // A new item at the newest end shifts every index by one, so the view // has to step back to 0 to stay put. One item, instantly -- not a @@ -978,36 +1158,7 @@ fun SessionScreen( // composition's value and does not change under a running coroutine. val start = groupToolRuns(items).size var have = start - while (moreHistory && have - start < HISTORY_LOOKAHEAD) { - val older = - withContext(Dispatchers.IO) { - fetchTranscript(settings, summary.id, before = oldestSeq) - } - if (older.isEmpty()) { - moreHistory = false - break - } - oldestSeq = older.first().seq - moreHistory = oldestSeq > 1L - // Folded oldest-first into a list of their own, then - // put in front: `foldEvent` merges streaming text - // into the item before it, so replaying an older page - // through the live list would glue it onto the newest - // message rather than its own. - var earlier = listOf() - older.forEach { entry -> - if (entry.event !is SessionEvent.UsageDelta) { - earlier = foldEvent(earlier, entry) - } - } - val joined = joinPages(earlier, items) - // After the join rather than on the page alone: a boundary that fell - // through a reply leaves `joinPages` holding a message made of both - // halves, and that text has existed for no time at all. Warming the page - // by itself warmed the two halves and missed the one thing drawn -- - // which showed up as a single 22ms parse surviving every page. - warm(replies, joined) - items = joined + while (moreHistory && have - start < HISTORY_LOOKAHEAD && loadOlderPage()) { have = groupToolRuns(items).size } } catch (_: ApiException) { @@ -1120,6 +1271,7 @@ fun SessionScreen( // the header, and the colour of the button that opens the dialog. val usage = rememberSessionUsage(settings, summary.setup) var usageOpen by remember { mutableStateOf(false) } + var settingsOpen by remember { mutableStateOf(false) } Column(Modifier.fillMaxSize()) { Row( @@ -1166,10 +1318,10 @@ fun SessionScreen( { usageOpen = true }, colour = usageGlyphColour(usage), ) - // A step down from this session, so it sits at the end of the session's own row. - // The name is the whole of what it holds today, which is why it is a cog and not - // a word: there will be more, and a bar of words has nowhere to put it. - GlyphButton(SETTINGS_GLYPH, "Session settings", onSettings) + // What it opens is about this session, so it sits at the end of the session's + // own row. The name is the whole of what it holds today, which is why it is a cog + // and not a word: there will be more, and a bar of words has nowhere to put it. + GlyphButton(SETTINGS_GLYPH, "Session settings", { settingsOpen = true }) } } @@ -1199,6 +1351,10 @@ fun SessionScreen( // it: the first frame is already the newest message, and older // ones are composed only as somebody scrolls back to them, which // is also what makes history cheap on a long conversation. + // Empty until a saved position has been put back -- see the opening effect. Held out of + // the list rather than drawn and scrolled, so there is no frame in which the transcript is + // somewhere other than where it was left. + val drawnRows = if (restoring) emptyList() else rows.asReversed() Box(Modifier.weight(1f).fillMaxWidth()) { LazyColumn( state = listState, @@ -1221,8 +1377,8 @@ fun SessionScreen( // everything it has taken in, and not yet taken in // themselves. What the session is *doing* about them is a // line below, in [SessionStatusRow]. - if (queued.isNotEmpty() || waitingCommands.isNotEmpty()) { - item(key = "queued") { + if (!restoring && itemsAboveRows() > 0) { + item(key = QUEUED_KEY) { Column(horizontalAlignment = Alignment.End) { waitingCommands.forEach { (_, text) -> CommandBubble(text, waiting = true) @@ -1251,7 +1407,7 @@ fun SessionScreen( // reason: the working indicator appearing and disappearing is another insertion // at the same end. Paging older history is the opposite insertion and was // already fine, and stays fine, because a key survives both. - items(rows.asReversed(), key = { it.key }) { row -> + items(drawnRows, key = { it.key }) { row -> val bounds = remember { RowBounds() } Box( Modifier.onGloballyPositioned { @@ -1592,6 +1748,21 @@ fun SessionScreen( if (usageOpen) { UsageDialog(settings = settings, onDismiss = { usageOpen = false }) } + if (settingsOpen) { + SessionSettingsDialog( + settings = settings, + sessionId = summary.id, + title = title, + // The header takes the new name at once and the dialog closes on it, because the + // rename has already been accepted by the server -- see [title], which is this app's + // own datum. The list behind this refetches on the way out of the session anyway. + onRenamed = { + title = it + settingsOpen = false + }, + onDismiss = { settingsOpen = false }, + ) + } } /** What pressing Send does right now, said the same way to the eye and to a screen reader. */ diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionSettingsDialog.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionSettingsDialog.kt new file mode 100644 index 0000000..7816ba0 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionSettingsDialog.kt @@ -0,0 +1,189 @@ +package com.example.aiapp + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * What can be changed about one session, as opposed to about this app. + * + * Over the session rather than a step down from it: everything here is about the conversation + * behind it, and a dialog keeps that conversation on screen while it is being adjusted. It was a + * screen of its own until 2026-08-30, which put a page transition and a back stack around two + * controls and hid the thing they act on. + * + * The model and the permission mode are deliberately still on the session's own bar, because those + * are changed *while* reading a turn -- "not this model, try that one" -- and a control belongs + * with the thing it acts on. + * + * Nothing here is captioned. Each control is a labelled noun with a switch or a field beside it, + * and a paragraph under every one of them made the dialog longer than the conversation it covers. + * Failures still get their words: those are what the reader cannot work out by looking. + */ +@Composable +fun SessionSettingsDialog( + settings: ServerSettings, + sessionId: String, + /** + * What the session is called now, as the screen behind this knows it -- see the rename below. + */ + title: String, + onRenamed: (String) -> Unit, + onDismiss: () -> Unit, +) { + val scope = rememberCoroutineScope() + var name by remember(sessionId) { mutableStateOf(title) } + var saving by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + // Null until the server has been asked. The row this dialog was opened over is a snapshot of + // whenever the list was last fetched, so drawing the switch straight from it would show a + // position that may have been changed since -- from here or from another device -- with + // nothing to say so. Until the answer arrives the switch is disabled and a spinner sits beside + // it, which is what not knowing looks like: distinguishable from off, and from a refusal. + var notify by remember(sessionId) { mutableStateOf(null) } + var notifyError by remember { mutableStateOf(null) } + + LaunchedEffect(sessionId) { + notify = + try { + withContext(Dispatchers.IO) { fetchSession(settings, sessionId).notify } + } catch (e: ApiException) { + // Left unknown rather than falling back to the stale row: the switch stays + // disabled, instead of offering a position nothing confirmed. + notifyError = e.message + null + } + } + + // Moved optimistically so the switch answers the finger that moved it, and put back if the + // request is refused -- a switch that waits for a round trip reads as broken on a slow + // tunnel, and one that stays moved after a refusal lies. + fun setNotify(wanted: Boolean) { + val was = notify + notify = wanted + notifyError = null + scope.launch { + try { + withContext(Dispatchers.IO) { setSessionNotify(settings, sessionId, wanted) } + } catch (e: ApiException) { + notify = was + notifyError = e.message + } + } + } + + // Nothing to do when the name has not changed, so the button says so rather than sending a + // request whose success would look exactly like the failure of having typed nothing. + val changed = name.trim().isNotEmpty() && name.trim() != title + + fun save() { + if (!changed || saving) return + val chosen = name.trim() + saving = true + error = null + scope.launch { + try { + withContext(Dispatchers.IO) { renameSession(settings, sessionId, chosen) } + onRenamed(chosen) + } catch (e: ApiException) { + // Reported here, where it happened, because this dialog is the only place that + // knows a rename was attempted -- the session behind it shows nothing about it. + error = e.message + saving = false + } + } + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Session settings") }, + text = { + Column { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("Name") }, + singleLine = true, + enabled = !saving, + modifier = Modifier.fillMaxWidth(), + // The keyboard's own action does what the button does: a one-field form + // where the return key does nothing is a form people press return at anyway. + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { save() }), + ) + Spacer(Modifier.height(8.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Glyph(BELL_GLYPH, colour = MaterialTheme.colorScheme.onSurface) + Spacer(Modifier.width(8.dp)) + Text("Notifications", modifier = Modifier.weight(1f)) + if (notify == null && notifyError == null) { + CircularProgressIndicator( + modifier = Modifier.width(16.dp).height(16.dp), + strokeWidth = 2.dp, + ) + Spacer(Modifier.width(8.dp)) + } + Switch( + checked = notify == true, + onCheckedChange = { setNotify(it) }, + enabled = notify != null, + ) + } + // Beside the switch that failed, not with the rename's error: they are two + // requests and a reader has to be able to tell which one the server refused. + notifyError?.let { + Text( + it, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + error?.let { + Spacer(Modifier.height(8.dp)) + Text( + it, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + } + }, + // Disabled rather than absent while there is nothing to save: a button that comes and + // goes makes its own presence the signal, and its absence cannot say why. + confirmButton = { + TextButton(onClick = { save() }, enabled = changed && !saving) { + Text(if (saving) "Saving..." else "Save") + } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Close") } }, + ) +} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionSettingsScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionSettingsScreen.kt deleted file mode 100644 index f20822a..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionSettingsScreen.kt +++ /dev/null @@ -1,196 +0,0 @@ -package com.example.aiapp - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Button -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Switch -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.unit.dp -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -/** - * What can be changed about one session, as opposed to about this app. - * - * A step down from the session rather than a menu over it: the name is a text field with a keyboard - * in front of it, and that is more than belongs in a bar above a conversation. Back returns to the - * session it was opened from, which is the only thing back can mean here. - * - * The name is the one setting so far. The model and the permission mode are deliberately still on - * the session's own bar, because those are changed *while* reading a turn -- "not this model, try - * that one" -- and a control belongs with the thing it acts on. - */ -@Composable -fun SessionSettingsScreen( - settings: ServerSettings, - session: SessionSummary, - onRenamed: (SessionSummary) -> Unit, - onBack: () -> Unit, -) { - val scope = rememberCoroutineScope() - var name by remember(session.id) { mutableStateOf(session.title) } - var saving by remember { mutableStateOf(false) } - var error by remember { mutableStateOf(null) } - // Null until the server has been asked. The row this screen was opened from is a snapshot of - // whenever the list was last fetched, so drawing the switch straight from it would show a - // position that may have been changed since -- from here or from another device -- with - // nothing to say so. Until the answer arrives the switch is disabled and the caption says it - // is being read, which is the one honest thing a two-position control can do about not - // knowing. - var notify by remember(session.id) { mutableStateOf(null) } - var notifyError by remember { mutableStateOf(null) } - - LaunchedEffect(session.id) { - notify = - try { - withContext(Dispatchers.IO) { fetchSession(settings, session.id).notify } - } catch (e: ApiException) { - // Left unknown rather than falling back to the stale row: the switch stays - // disabled and says why, instead of offering a position nothing confirmed. - notifyError = e.message - null - } - } - - // Moved optimistically so the switch answers the finger that moved it, and put back if the - // request is refused -- a switch that waits for a round trip reads as broken on a slow - // tunnel, and one that stays moved after a refusal lies. - fun setNotify(wanted: Boolean) { - val was = notify - notify = wanted - notifyError = null - scope.launch { - try { - withContext(Dispatchers.IO) { setSessionNotify(settings, session.id, wanted) } - } catch (e: ApiException) { - notify = was - notifyError = e.message - } - } - } - - // Nothing to do when the name has not changed, so the button says so rather than sending a - // request whose success would look exactly like the failure of having typed nothing. - val changed = name.trim().isNotEmpty() && name.trim() != session.title - - fun save() { - if (!changed || saving) return - val chosen = name.trim() - saving = true - error = null - scope.launch { - try { - withContext(Dispatchers.IO) { renameSession(settings, session.id, chosen) } - onRenamed(session.copy(title = chosen)) - } catch (e: ApiException) { - // Reported here, where it happened, because this screen is the only place that - // knows a rename was attempted -- the session behind it shows nothing about it. - error = e.message - saving = false - } - } - } - - Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) { - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { - GlyphButton(BACK_GLYPH, "Back", onBack) - Spacer(Modifier.width(8.dp)) - Text( - "Session settings", - style = MaterialTheme.typography.headlineSmall, - modifier = Modifier.weight(1f), - ) - } - Spacer(Modifier.height(16.dp)) - - OutlinedTextField( - value = name, - onValueChange = { name = it }, - label = { Text("Name") }, - singleLine = true, - enabled = !saving, - modifier = Modifier.fillMaxWidth(), - // The keyboard's own action does what the button does: a one-field form where the - // return key does nothing is a form people press return at anyway. - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { save() }), - ) - Text( - "Passed on to whatever is running this session, so Claude Code's own session " + - "picker and any agent listing sessions use the same name.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 4.dp), - ) - Spacer(Modifier.height(24.dp)) - - // The switch gets its own row rather than sitting beside the label: a control is taller - // than a line of text, so putting one in a row with a label re-centres that label and - // knocks it out of line with everything above it. - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { - Text("Notify me", modifier = Modifier.weight(1f)) - Switch( - checked = notify == true, - onCheckedChange = { setNotify(it) }, - enabled = notify != null, - ) - } - Text( - if (notify == null && notifyError == null) "Reading the current setting..." - else - "A notification when this session asks you something or finishes a turn. Kept " + - "on the backend, so every device agrees about which sessions are worth " + - "interrupting you for.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - notifyError?.let { - Spacer(Modifier.height(4.dp)) - // Beside the switch that failed, not with the rename's error: they are two requests - // and a reader has to be able to tell which one the server refused. - Text( - it, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall, - ) - } - - Spacer(Modifier.height(24.dp)) - // Disabled rather than absent while there is nothing to save: a button that comes and - // goes makes its own presence the signal, and its absence cannot say why. - Button(onClick = { save() }, enabled = changed && !saving) { - Text(if (saving) "Saving..." else "Save") - } - error?.let { - Spacer(Modifier.height(8.dp)) - Text( - it, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall, - ) - } - } -} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt index d8a946e..580ed8a 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt @@ -12,7 +12,10 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CornerBasedShape +import androidx.compose.foundation.shape.CornerSize import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -20,13 +23,17 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Shape import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp /** @@ -140,12 +147,21 @@ fun Modifier.clickableAt(onClick: (Float) -> Unit): Modifier { /** * Several calls under one heading, closed until somebody asks. * - * The calls keep their own full width -- no indent, no inset -- because they are the same rows they - * would be on their own, and stepping them in would say they are something lesser. What says they - * belong together is the surface behind them, which is the one cue rather than two half-cues. + * What says the calls belong together is the surface behind them, which is the one cue rather than + * two half-cues -- rounded to the same corner every other card in the app has, so a group reads as + * one object rather than as a square patch behind round things. The calls sit on it inset by + * [GROUP_INSET], which is the container's own padding rather than an indent: they are the same rows + * they would be on their own, and a rounded corner drawn hard against a rounded corner reads as a + * notch. + * + * Inside, the calls are a connected stack. Facing corners are square and the outer ones are not, so + * the run reads as one thing broken into its parts; [GROUP_GAP] keeps the parts legible without + * separating them. See [connectedShape]. * * It closes from either end. A long group's header scrolls off while its last call is still on - * screen, and the reader who wants it shut is looking at the bottom, not hunting for the top. + * screen, and the reader who wants it shut is looking at the bottom, not hunting for the top. The + * bar at the foot is the same height as the heading at the top, so the surface the calls sit on is + * as thick below them as above. */ @Composable fun ToolGroup( @@ -160,52 +176,116 @@ fun ToolGroup( onAnswer: (questionId: String, answers: List) -> Unit, image: @Composable (String) -> Unit, ) { + val heading = "Called ${group.calls.size} tools" if (!expanded) { Card(Modifier.fillMaxWidth().clickableAt(onToggle)) { Text( - "Called ${group.calls.size} tools", + heading, style = MaterialTheme.typography.titleSmall, - modifier = Modifier.padding(12.dp), + modifier = Modifier.padding(GROUP_INSET_LARGE), ) } return } - Column(Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surfaceContainerLow)) { - Text( - "Called ${group.calls.size} tools", - style = MaterialTheme.typography.titleSmall, - modifier = Modifier.fillMaxWidth().clickableAt(onToggle).padding(12.dp), - ) - group.calls.forEach { call -> - ToolCard( - tool = call, - expanded = isToolExpanded(call.id), - onToggle = { at -> onToolToggle(call.id, at) }, - onAnswer = onAnswer, - image = image, + Column( + Modifier.fillMaxWidth() + .clip(MaterialTheme.shapes.medium) + .background(MaterialTheme.colorScheme.surfaceContainerLow) + ) { + val barHeight = groupBarHeight() + Row( + Modifier.fillMaxWidth().height(barHeight).clickableAt(onToggle), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + heading, + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding(horizontal = GROUP_INSET_LARGE), ) } + Column( + Modifier.padding(horizontal = GROUP_INSET), + verticalArrangement = Arrangement.spacedBy(GROUP_GAP), + ) { + group.calls.forEachIndexed { index, call -> + ToolCard( + tool = call, + expanded = isToolExpanded(call.id), + onToggle = { at -> onToolToggle(call.id, at) }, + onAnswer = onAnswer, + image = image, + shape = connectedShape(index, group.calls.size), + ) + } + } // Shutting it from here anchors the other end: the reader is at the bottom of a long // group, and what they are looking at is what follows it. - CollapseBar(onToggle) + CollapseBar(barHeight, onToggle) } } -/** The bottom half of a group's toggle: an arrow back up to its heading. */ +/** + * The height of a group's heading, and so of the bar at its foot. + * + * Derived from the type the heading is set in rather than written down, because the two have to + * match and a pair of numbers chosen to look equal stops being equal the moment either the style or + * the density changes. Taking the line height also means the heading cannot be clipped by it. + */ @Composable -private fun CollapseBar(onToggle: (Float) -> Unit) { +private fun groupBarHeight(): Dp { + val line = MaterialTheme.typography.titleSmall.lineHeight + return with(LocalDensity.current) { line.toDp() } + GROUP_INSET_LARGE * 2 +} + +/** + * The bottom half of a group's toggle: an arrow back up to its heading. + * + * Given the heading's height rather than padded to something that looks close, so the surface the + * calls sit on is the same thickness at both ends. See [groupBarHeight]. + */ +@Composable +private fun CollapseBar(height: Dp, onToggle: (Float) -> Unit) { val colour = MaterialTheme.colorScheme.onSurfaceVariant Row( - Modifier.fillMaxWidth() - .clickableAt(onToggle) - .semantics { contentDescription = "Collapse these tool calls" } - .padding(vertical = 10.dp), + Modifier.fillMaxWidth().height(height).clickableAt(onToggle).semantics { + contentDescription = "Collapse these tool calls" + }, horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, ) { Chevron(pointingUp = true, colour = colour) } } +/** + * The shape of one card in a stack of [count]: square where it faces a neighbour, rounded where it + * does not. + * + * Written once and given an index rather than branched at each end, because a stack has three cases + * that are one rule -- and the middle one is the case a hand-written first/last pair gets wrong + * when a run turns out to have three calls in it. + */ +@Composable +private fun connectedShape(index: Int, count: Int): CornerBasedShape { + val shape = MaterialTheme.shapes.medium + val square = CornerSize(0.dp) + return shape.copy( + topStart = if (index == 0) shape.topStart else square, + topEnd = if (index == 0) shape.topEnd else square, + bottomStart = if (index == count - 1) shape.bottomStart else square, + bottomEnd = if (index == count - 1) shape.bottomEnd else square, + ) +} + +/** The padding inside a card, and so the height a bar of one line of text comes to. */ +private val GROUP_INSET_LARGE = 12.dp + +/** How far the stack of calls is held off the edge of the surface it sits on. */ +private val GROUP_INSET = 4.dp + +/** Enough to read the join as a join rather than as one tall card. */ +private val GROUP_GAP = 2.dp + /** * One tool call. * @@ -227,12 +307,14 @@ fun ToolCard( onToggle: (Float) -> Unit, onAnswer: (questionId: String, answers: List) -> Unit, image: @Composable (String) -> Unit = {}, + /** Square where this card faces another in a group; see [connectedShape]. */ + shape: Shape = CardDefaults.shape, ) { val parsed = remember(tool.tool, tool.input) { parseToolInput(tool.tool, tool.input) } val deciding = tool.asks.any { it.answers.isEmpty() } val open = expanded || deciding - Card(Modifier.fillMaxWidth().clickableAt(onToggle)) { - Column(Modifier.padding(12.dp)) { + Card(Modifier.fillMaxWidth().clickableAt(onToggle), shape = shape) { + Column(Modifier.padding(GROUP_INSET_LARGE)) { Row(verticalAlignment = Alignment.CenterVertically) { Text(tool.tool, style = MaterialTheme.typography.titleSmall) if (open) { diff --git a/app/androidApp/src/main/res/font/nerd_icons.ttf b/app/androidApp/src/main/res/font/nerd_icons.ttf index 2001639..6769aae 100644 Binary files a/app/androidApp/src/main/res/font/nerd_icons.ttf and b/app/androidApp/src/main/res/font/nerd_icons.ttf differ diff --git a/app/build-icon-font.sh b/app/build-icon-font.sh index a04c4e9..c250514 100755 --- a/app/build-icon-font.sh +++ b/app/build-icon-font.sh @@ -37,6 +37,7 @@ GLYPHS=( U+F1163 # md-send_clock U+F0156 # md-close U+F004D # md-arrow_left + U+F009A # md-bell U+F201 # fa-line_chart -- Font Awesome's, asked for by name ) diff --git a/server/src/session/echo.rs b/server/src/session/echo.rs index ce23aa6..5d84371 100644 --- a/server/src/session/echo.rs +++ b/server/src/session/echo.rs @@ -7,8 +7,11 @@ //! A leading word asks for something more specific: //! //! - `/tool [input]` -- a full tool run, start through end. -//! - `/tools [n]` -- n calls back to back, for what a run of them looks -//! like when a screen groups them. +//! - `/tools [n] [gap]` -- n calls back to back, for what a run of them +//! looks like when a screen groups them. `gap` is seconds between one +//! call and the next, default none: it is what makes a run *grow* while +//! somebody is looking at it, which is the only way to reach the state +//! where a call opened on its own gains a neighbour. //! - `/question [text]` -- a question, exercising the answer path. //! - `/ask` -- an AskUserQuestion call: two questions on one tool call, //! with descriptions, a preview and a multi-select, which is the shape @@ -357,9 +360,27 @@ impl EchoDriver { // shorter one first would read "/tools 4" as a single tool whose // input is "s 4". let many_tools = text.strip_prefix("/tools").map(|rest| { + let mut words = rest.split_whitespace(); // At least two, because one call is not a run of them and this // exists to produce a run. - rest.trim().parse::().unwrap_or(3).clamp(2, 12) + let count = words + .next() + .and_then(|w| w.parse().ok()) + .unwrap_or(3usize) + .clamp(2, 12); + // How long to wait between calls, default none. A run that + // arrives all at once cannot exercise anything about a run + // *growing*: the case worth watching is a call somebody has + // opened and is reading when the next one turns it into a + // group, and 50ms apart is faster than anybody can open one. + let gap = Duration::from_secs( + words + .next() + .and_then(|w| w.parse().ok()) + .unwrap_or(0u64) + .clamp(0, 30), + ); + (count, gap) }); let run_tool = if many_tools.is_some() { None @@ -437,8 +458,11 @@ impl EchoDriver { return; } - if let Some(count) = many_tools { + if let Some((count, gap)) = many_tools { for i in 1..=count { + if i > 1 { + tokio::time::sleep(gap).await; + } let id = format!("t-{}", super::random_hex()); send(Event::ToolStart { id: id.clone(),