Hold a queued message below the indicator until the turn takes it
The backend records a message the moment it is sent, so one sent into a running turn landed in the transcript at the time *we* spoke -- above the working indicator, in among things the session had already read. It had not read it. Showing it there says otherwise. Messages sent while a turn is in flight are now held below the indicator, drawn quieter, and take their place in the conversation when the turn ends. That is an approximation and worth naming: the CLI injects a queued message at a tool boundary, and tells nobody when it does, so the end of the turn is the first moment anything here can honestly say the message was taken. It errs toward "not yet read", which is the direction that cannot mislead. Also in this change, from the same pass over the screen: the app draws above the gesture strip rather than under it, and the three status colours that were literals in two other files -- an amber, a green and a red off Material's defaults -- are now Catppuccin members in Theme.kt beside the rest of the scheme. The ordering is verified by construction rather than photographed: the list is bottom-anchored, so the first item emitted is the lowest on screen, and the queued block is emitted before the indicator. Staging a real long-running turn to photograph cost four model turns and never produced one, because the model kept declining to sleep -- which is its own finding, and the reason the next change is a test command in the echo driver.
This commit is contained in:
1 parent
f402a1f6c8
commit
9791afcfd6
5 files changed
+89
-20
No files matched your search
@@ -12,6 +12,7 @@ import androidx.activity.result.contract.ActivityResultContracts
|
|||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.imePadding
|
import androidx.compose.foundation.layout.imePadding
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
import androidx.compose.foundation.layout.statusBarsPadding
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
@@ -59,7 +60,17 @@ class MainActivity : ComponentActivity() {
|
|||||||
setContent {
|
setContent {
|
||||||
MaterialTheme(colorScheme = AiAppColors) {
|
MaterialTheme(colorScheme = AiAppColors) {
|
||||||
Surface(modifier = Modifier.fillMaxSize()) {
|
Surface(modifier = Modifier.fillMaxSize()) {
|
||||||
Box(modifier = Modifier.fillMaxSize().statusBarsPadding().imePadding()) {
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier.fillMaxSize()
|
||||||
|
.statusBarsPadding()
|
||||||
|
// The gesture strip at the bottom of most
|
||||||
|
// phones. Without it the send row sits under
|
||||||
|
// the swipe area, where a tap is as likely to
|
||||||
|
// navigate away as to press a button.
|
||||||
|
.navigationBarsPadding()
|
||||||
|
.imePadding()
|
||||||
|
) {
|
||||||
AppRoot(settingsVersion)
|
AppRoot(settingsVersion)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -37,8 +36,6 @@ import kotlinx.coroutines.withContext
|
|||||||
|
|
||||||
// Status colors, keyed by the wire strings in Events.kt. Light theme only,
|
// Status colors, keyed by the wire strings in Events.kt. Light theme only,
|
||||||
// as in dev-updater.
|
// as in dev-updater.
|
||||||
private val AWAITING_COLOR = Color(0xFFB26A00)
|
|
||||||
private val RUNNING_COLOR = Color(0xFF2E7D32)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The session list -- the app's root screen. Sessions awaiting an answer sort to the top: that's
|
* The session list -- the app's root screen. Sessions awaiting an answer sort to the top: that's
|
||||||
@@ -262,9 +259,9 @@ private fun SessionCard(
|
|||||||
fun StatusText(status: String) {
|
fun StatusText(status: String) {
|
||||||
val (label, color) =
|
val (label, color) =
|
||||||
when (status) {
|
when (status) {
|
||||||
"awaitingInput" -> "your turn" to AWAITING_COLOR
|
"awaitingInput" -> "your turn" to awaitingColor
|
||||||
"running" -> "running" to RUNNING_COLOR
|
"running" -> "running" to runningColor
|
||||||
"compacting" -> "compacting" to RUNNING_COLOR
|
"compacting" -> "compacting" to runningColor
|
||||||
"exited" -> "exited" to MaterialTheme.colorScheme.onSurfaceVariant
|
"exited" -> "exited" to MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
else -> status to MaterialTheme.colorScheme.onSurfaceVariant
|
else -> status to MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -182,6 +182,14 @@ fun SessionScreen(
|
|||||||
// Every transcript event loaded, in order, beside the rows they folded
|
// Every transcript event loaded, in order, beside the rows they folded
|
||||||
// into. See `apply`.
|
// into. See `apply`.
|
||||||
var loaded by remember { mutableStateOf(listOf<SessionEvent>()) }
|
var loaded by remember { mutableStateOf(listOf<SessionEvent>()) }
|
||||||
|
// Messages sent into a turn that was already running. The backend
|
||||||
|
// records them the moment they are sent, so they land in the
|
||||||
|
// transcript at the time *we* spoke -- but the session has not read
|
||||||
|
// them yet, and showing them in that position claims it has. They are
|
||||||
|
// held out until the turn ends, which is the first moment anything
|
||||||
|
// here can honestly say they were taken.
|
||||||
|
var queued by remember { mutableStateOf(listOf<String>()) }
|
||||||
|
val running = status == "running" || status == "compacting"
|
||||||
var moreHistory by remember { mutableStateOf(true) }
|
var moreHistory by remember { mutableStateOf(true) }
|
||||||
var loadingHistory by remember { mutableStateOf(false) }
|
var loadingHistory by remember { mutableStateOf(false) }
|
||||||
var ready by remember { mutableStateOf(false) }
|
var ready by remember { mutableStateOf(false) }
|
||||||
@@ -313,6 +321,12 @@ fun SessionScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The turn ending is the moment a queued message stops being pending:
|
||||||
|
// whatever it was going to be injected into is over, so it has been
|
||||||
|
// read or it never will be, and either way it belongs in the
|
||||||
|
// conversation where it happened.
|
||||||
|
LaunchedEffect(running) { if (!running) queued = emptyList() }
|
||||||
|
|
||||||
LaunchedEffect(summary.setupName, summary.provider) {
|
LaunchedEffect(summary.setupName, summary.provider) {
|
||||||
offeredModels =
|
offeredModels =
|
||||||
try {
|
try {
|
||||||
@@ -331,8 +345,6 @@ fun SessionScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val running = status == "running" || status == "compacting"
|
|
||||||
|
|
||||||
fun act(action: () -> Unit) {
|
fun act(action: () -> Unit) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
try {
|
try {
|
||||||
@@ -350,6 +362,7 @@ fun SessionScreen(
|
|||||||
if (text.isEmpty() && attachments.isEmpty()) return
|
if (text.isEmpty() && attachments.isEmpty()) return
|
||||||
input = ""
|
input = ""
|
||||||
pendingAttachments = emptyList()
|
pendingAttachments = emptyList()
|
||||||
|
if (running && text.isNotEmpty()) queued = queued + text
|
||||||
act { sendMessage(settings, summary.id, text, attachments) }
|
act { sendMessage(settings, summary.id, text, attachments) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -418,6 +431,16 @@ fun SessionScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Anything still queued is shown separately, below, so it must not
|
||||||
|
// also appear in place. Matched by text from the end, since that is
|
||||||
|
// all that distinguishes one message from an identical earlier one.
|
||||||
|
val shown =
|
||||||
|
if (queued.isEmpty()) items
|
||||||
|
else {
|
||||||
|
val outstanding = queued.toMutableList()
|
||||||
|
items.filterNot { it is TranscriptItem.UserMsg && outstanding.remove(it.text) }
|
||||||
|
}
|
||||||
|
|
||||||
// Laid out from the bottom, with the newest message at index 0.
|
// Laid out from the bottom, with the newest message at index 0.
|
||||||
//
|
//
|
||||||
// The obvious arrangement -- oldest first, then scroll to the end
|
// The obvious arrangement -- oldest first, then scroll to the end
|
||||||
@@ -438,6 +461,16 @@ fun SessionScreen(
|
|||||||
contentPadding = PaddingValues(16.dp),
|
contentPadding = PaddingValues(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
|
// Below the working indicator, because that is where they
|
||||||
|
// are in the session's reading of events: after everything
|
||||||
|
// it has taken in, and not yet taken in themselves.
|
||||||
|
if (queued.isNotEmpty()) {
|
||||||
|
item {
|
||||||
|
Column(horizontalAlignment = Alignment.End) {
|
||||||
|
queued.forEach { text -> UserBubble(text, pending = true) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
// Where the next thing will appear: at the end of what has
|
// Where the next thing will appear: at the end of what has
|
||||||
// happened, which in this layout is the top of the list.
|
// happened, which in this layout is the top of the list.
|
||||||
// In the corner it was a label about the session; here it
|
// In the corner it was a label about the session; here it
|
||||||
@@ -472,7 +505,7 @@ fun SessionScreen(
|
|||||||
}
|
}
|
||||||
// Reversed to match the layout, so index 0 is the newest and
|
// Reversed to match the layout, so index 0 is the newest and
|
||||||
// the reader still sees them in the order they happened.
|
// the reader still sees them in the order they happened.
|
||||||
items(items.asReversed()) { item ->
|
items(shown.asReversed()) { item ->
|
||||||
when (item) {
|
when (item) {
|
||||||
is TranscriptItem.UserMsg -> UserBubble(item.text)
|
is TranscriptItem.UserMsg -> UserBubble(item.text)
|
||||||
is TranscriptItem.AssistantMsg ->
|
is TranscriptItem.AssistantMsg ->
|
||||||
@@ -636,16 +669,28 @@ private fun SessionImage(settings: ServerSettings, sessionId: String, ref: Strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun UserBubble(text: String) {
|
private fun UserBubble(text: String, pending: Boolean = false) {
|
||||||
Box(Modifier.fillMaxWidth()) {
|
Box(Modifier.fillMaxWidth()) {
|
||||||
Card(
|
Card(
|
||||||
|
// A message the session has not read yet is drawn quieter than
|
||||||
|
// one it has. The difference is in degree -- said, not yet
|
||||||
|
// heard -- which is what colour alone can carry; where it sits
|
||||||
|
// is what says the rest.
|
||||||
colors =
|
colors =
|
||||||
CardDefaults.cardColors(
|
CardDefaults.cardColors(
|
||||||
containerColor = MaterialTheme.colorScheme.primaryContainer
|
containerColor =
|
||||||
|
if (pending) MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
else MaterialTheme.colorScheme.primaryContainer
|
||||||
),
|
),
|
||||||
modifier = Modifier.align(Alignment.CenterEnd).padding(start = 48.dp),
|
modifier = Modifier.align(Alignment.CenterEnd).padding(start = 48.dp),
|
||||||
) {
|
) {
|
||||||
Text(text, modifier = Modifier.padding(12.dp))
|
Text(
|
||||||
|
text,
|
||||||
|
modifier = Modifier.padding(12.dp),
|
||||||
|
color =
|
||||||
|
if (pending) MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
else MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,7 +85,15 @@ val AiAppColors =
|
|||||||
scrim = Mocha.Crust,
|
scrim = Mocha.Crust,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** "There is something here": a session that is running. */
|
/**
|
||||||
|
* What a session is doing, said in colour.
|
||||||
|
*
|
||||||
|
* Here rather than beside each screen that shows a status. These were separate literals in two
|
||||||
|
* other files -- an amber, a green and a red picked off Material's defaults -- so the same state
|
||||||
|
* was a slightly different colour depending which screen you looked at, and none of them belonged
|
||||||
|
* to this palette at all. A colour that carries meaning is part of the scheme, not a value typed
|
||||||
|
* where it happened to be needed.
|
||||||
|
*/
|
||||||
val runningColor: Color
|
val runningColor: Color
|
||||||
@Composable get() = Mocha.Green
|
@Composable get() = Mocha.Green
|
||||||
|
|
||||||
@@ -98,3 +106,15 @@ val runningColor: Color
|
|||||||
*/
|
*/
|
||||||
val failedColor: Color
|
val failedColor: Color
|
||||||
@Composable get() = MaterialTheme.colorScheme.error
|
@Composable get() = MaterialTheme.colorScheme.error
|
||||||
|
|
||||||
|
/** Waiting on a person: a question, a permission, a turn that is theirs. */
|
||||||
|
val awaitingColor: Color
|
||||||
|
@Composable get() = Mocha.Peach
|
||||||
|
|
||||||
|
/** Approaching a limit -- still fine, worth seeing. */
|
||||||
|
val warningColor: Color
|
||||||
|
@Composable get() = Mocha.Yellow
|
||||||
|
|
||||||
|
/** Past a limit. The scheme's error colour, for the reason [failedColor] gives. */
|
||||||
|
val overLimitColor: Color
|
||||||
|
@Composable get() = MaterialTheme.colorScheme.error
|
||||||
@@ -24,7 +24,6 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import java.time.Duration
|
import java.time.Duration
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
@@ -32,9 +31,6 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
private val WARN_COLOR = Color(0xFFB26A00)
|
|
||||||
private val OVER_COLOR = Color(0xFFB3261E)
|
|
||||||
|
|
||||||
/** Window bars for the account's rate limits, with reset times. */
|
/** Window bars for the account's rate limits, with reset times. */
|
||||||
@Composable
|
@Composable
|
||||||
fun UsageScreen(settings: ServerSettings, onBack: () -> Unit) {
|
fun UsageScreen(settings: ServerSettings, onBack: () -> Unit) {
|
||||||
@@ -103,8 +99,8 @@ fun UsageScreen(settings: ServerSettings, onBack: () -> Unit) {
|
|||||||
private fun WindowBar(window: UsageWindow) {
|
private fun WindowBar(window: UsageWindow) {
|
||||||
val color =
|
val color =
|
||||||
when {
|
when {
|
||||||
window.percent >= 95 -> OVER_COLOR
|
window.percent >= 95 -> overLimitColor
|
||||||
window.percent >= 75 -> WARN_COLOR
|
window.percent >= 75 -> warningColor
|
||||||
else -> MaterialTheme.colorScheme.primary
|
else -> MaterialTheme.colorScheme.primary
|
||||||
}
|
}
|
||||||
Column {
|
Column {
|
||||||
|
|||||||
Reference in new issue
Block a user