Keep the reader's place: in a group, in a compaction, and in the list
Five things Iris asked for, all about the transcript screen holding still around whoever is reading it. A tool call opened on its own stayed open when a second call in the same run turns it into a group. Watching a Bash call and having the session make another one used to shut the card being read and fold it behind "Called 2 tools" -- the reader lost their place because something else happened. The transition is noticed once, at the moment a run first becomes a group; after that the group's own toggle owns it, so shutting a group whose inner call is still expanded does not re-open it. The compaction clock is taken from the `compacting` status event's own timestamp rather than from this device noticing one, so it survives leaving the session and coming back -- it used to disappear, because the only thing that knew when the compaction started was a screen that had been disposed. The server timestamps every transcript line, so this is still a measurement; it is compared against the phone's wall clock, which is the same comparison a session's "last active" already makes. Session settings are a dialog over the session instead of a screen below it. Two controls did not warrant a page transition and a back stack, and the thing they change was hidden while they were on screen. Captions are gone -- each control is a labelled noun -- and "Notify me" is "Notifications" with a bell beside it (`md-bell`, added to the committed Nerd Fonts subset). Failures keep their words, since those are what a reader cannot work out by looking. Tool groups are rounded like every other card, their foot bar is the same height as their heading (both derived from the heading's own line height, so the pair cannot drift), and the calls inside are a connected stack: square where they face a neighbour, rounded on the outside, with a small gap so the join reads as a join. Scroll position is persistent on the device, per session, keyed by the row rather than by an index -- an index means nothing across a reopen, where the transcript is fetched newest-first. Reopening pages backwards until that row is loaded *and* has something older behind it, because the oldest loaded row is a half-row that grows when the page behind it arrives; anchoring into one landed a screen and a half out. The list draws nothing until the position lands, so there is no frame in which the transcript is somewhere other than where it was left. Two things found on the way. `snapshotFlow`'s first emission is the state before anybody has touched the list, and reading it as a scroll that had just ended at the newest end wiped every saved position on the way in. And backwards pages now ask for 800 events rather than 80: ai-app-2 measured a real transcript at 2,426 events for seven assistant messages, so a page of eighty is a fifth of one row and filling the lookahead took about thirty sequential round trips -- seconds of a list that will not move, over the tunnel. `/tools [n] [gap]` in the echo driver takes seconds between calls, which is what makes a run grow slowly enough for somebody to have opened one of its calls first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8b0e654733
commit
61d2c78afe
10 files changed
+626
-323
No files matched your search
@@ -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<TranscriptItem>) {
|
||||
}
|
||||
|
||||
@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<TranscriptItem>()) }
|
||||
@@ -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<Long?>(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<Double?>(null) }
|
||||
var compactingFor by remember { mutableStateOf<Long?>(null) }
|
||||
var streamError by remember { mutableStateOf<String?>(null) }
|
||||
var actionError by remember { mutableStateOf<String?>(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<String>()) }
|
||||
// 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<String>()) }
|
||||
// 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<TranscriptItem>()
|
||||
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<TranscriptRow.Tools>().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<TranscriptItem>()
|
||||
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. */
|
||||
|
||||
Reference in new issue
Block a user