A row per finished background task is a screenful of dividers about work the reader was not asking after, and one of them turned out to be a whole shell command drawn as centred prose, because its words came from somewhere with no reason to keep them short. `Event::TaskNote` is gone entirely, along with the row that drew it. A subagent's closing report is recorded as that subagent's own transcript's closing text and is read in the subcard, which is where it was already going; what the parent gets a row for is a message a subagent genuinely sends it, which arrives by the peer path and has had one all along. What remains is the actual defect and the smallest thing that fixes it. The fold still refuses to grow a settled reply, so a turn boundary is always a message boundary, and where two replies then abut it puts a `TurnBreak` between them: a hairline, no words, no colour. Made by the fold rather than sent by the server, because it is not something that happened -- it is the boundary between two things that did. `joinPages` puts one in at a page seam, which the fold never gets to see. The task notification is still what closes a task in `Status::Waiting`'s bookkeeping, and the registry lookup that recognises one this translator never saw start is what makes that work for a session adopted across a restart. Verified on the emulator: three replies, three rules, and nothing about the helpers anywhere in the parent. 170 server tests, 85 JVM tests, ktfmt, clippy, rustfmt and Android lint clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
113 lines
4.7 KiB
Kotlin
113 lines
4.7 KiB
Kotlin
package com.example.aiapp
|
|
|
|
import androidx.compose.foundation.layout.Arrangement
|
|
import androidx.compose.foundation.layout.Row
|
|
import androidx.compose.foundation.layout.fillMaxWidth
|
|
import androidx.compose.foundation.layout.padding
|
|
import androidx.compose.material3.HorizontalDivider
|
|
import androidx.compose.material3.MaterialTheme
|
|
import androidx.compose.material3.Text
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.ui.Alignment
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.graphics.Color
|
|
import androidx.compose.ui.unit.dp
|
|
import java.time.Instant
|
|
import java.time.ZoneId
|
|
import java.time.format.DateTimeFormatter
|
|
import java.time.format.FormatStyle
|
|
|
|
/**
|
|
* A line across the transcript saying what left the session's context.
|
|
*
|
|
* Centred between two rules, because it is a divider rather than something anybody said. Two things
|
|
* produce one -- a compaction and a clear -- and they are drawn the same way on purpose: to a
|
|
* reader scrolling back, both mean "the session no longer has what is above this", and which of the
|
|
* two it was is said by the words and the colour.
|
|
*
|
|
* The rules take [color] too, so the whole divider reads as one mark of one kind.
|
|
*/
|
|
@Composable
|
|
fun TranscriptDivider(text: String, color: Color, modifier: Modifier = Modifier) {
|
|
Row(
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
modifier = modifier.fillMaxWidth().padding(vertical = 8.dp),
|
|
) {
|
|
HorizontalDivider(Modifier.weight(1f), color = color)
|
|
Text(text, style = MaterialTheme.typography.bodySmall, color = color)
|
|
HorizontalDivider(Modifier.weight(1f), color = color)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The rule between two replies that met with nothing said in between -- see
|
|
* [TranscriptItem.TurnBreak].
|
|
*
|
|
* No words and no colour. Every other divider here reports something that happened and is worth
|
|
* finding by scanning; this one only says "these are two", and it appears once per turn that
|
|
* started without anybody typing. Saying more was a screenful of announcements about background
|
|
* work the reader was not asking after -- one of them a whole shell command, drawn as centred prose
|
|
* because the words came from somewhere that had no reason to keep them short.
|
|
*
|
|
* The outline colour is the scheme's one for structure rather than for meaning, which is what this
|
|
* is. Inset from both edges so it reads as a separator between two rows rather than as the top edge
|
|
* of the one under it.
|
|
*/
|
|
@Composable
|
|
fun TurnBreakRow(modifier: Modifier = Modifier) {
|
|
HorizontalDivider(
|
|
modifier.fillMaxWidth().padding(horizontal = 48.dp, vertical = 6.dp),
|
|
color = MaterialTheme.colorScheme.outlineVariant,
|
|
)
|
|
}
|
|
|
|
/**
|
|
* The mark a clear leaves.
|
|
*
|
|
* Red, and no counts: a clear takes the conversation out of what the session is given, and unlike a
|
|
* compaction it summarises nothing and measures nothing. Everything above stays on screen and stays
|
|
* scrollable -- the reader can see that, which is why this does not say it.
|
|
*/
|
|
@Composable
|
|
fun ClearedRow(modifier: Modifier = Modifier) {
|
|
TranscriptDivider("Context cleared", clearedColor, modifier)
|
|
}
|
|
|
|
/**
|
|
* The mark running out of quota leaves.
|
|
*
|
|
* The same red the usage bar takes when a window is spent, because it is the same fact in a second
|
|
* place: colour by consequence, so "there is nothing left to spend" is learned once.
|
|
*
|
|
* A time rather than a countdown. The row is folded once and never re-measured, so a span would go
|
|
* stale on screen the moment it was drawn; and this is when the *account* said it would reset,
|
|
* which is not a promise about when the session picks back up. A limit the session was told no
|
|
* reset time for says nothing about one -- that state has its own words rather than a plausible
|
|
* number.
|
|
*/
|
|
@Composable
|
|
fun LimitRow(item: TranscriptItem.LimitNote, modifier: Modifier = Modifier) {
|
|
TranscriptDivider(limitSummary(item.resetsAt, ZoneId.systemDefault()), overLimitColor, modifier)
|
|
}
|
|
|
|
/**
|
|
* What the row says. Split out so the wording is testable without a screen, since the two states it
|
|
* has to keep apart -- a reset time that arrived and one that never did -- are exactly the pair
|
|
* that reads the same when it goes wrong.
|
|
*
|
|
* [zone] is a parameter rather than read here so a test says the same thing wherever it runs.
|
|
*/
|
|
fun limitSummary(resetsAt: Double?, zone: ZoneId): String {
|
|
val at = resetsAt?.let {
|
|
try {
|
|
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
|
|
.withZone(zone)
|
|
.format(Instant.ofEpochSecond(it.toLong()))
|
|
} catch (_: Exception) {
|
|
null
|
|
}
|
|
}
|
|
return if (at == null) "Usage limit reached" else "Usage limit reached • resets $at"
|
|
}
|