diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/MainActivity.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/MainActivity.kt index 8f9bcef..d0a7393 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/MainActivity.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/MainActivity.kt @@ -12,6 +12,7 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -59,7 +60,17 @@ class MainActivity : ComponentActivity() { setContent { MaterialTheme(colorScheme = AiAppColors) { 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) } } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt index ea691dd..fb4c141 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt @@ -29,7 +29,6 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import kotlinx.coroutines.Dispatchers 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, // 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 @@ -262,9 +259,9 @@ private fun SessionCard( fun StatusText(status: String) { val (label, color) = when (status) { - "awaitingInput" -> "your turn" to AWAITING_COLOR - "running" -> "running" to RUNNING_COLOR - "compacting" -> "compacting" to RUNNING_COLOR + "awaitingInput" -> "your turn" to awaitingColor + "running" -> "running" to runningColor + "compacting" -> "compacting" to runningColor "exited" -> "exited" to MaterialTheme.colorScheme.onSurfaceVariant else -> status to MaterialTheme.colorScheme.onSurfaceVariant } 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 b8718c6..3b6b5af 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -182,6 +182,14 @@ fun SessionScreen( // Every transcript event loaded, in order, beside the rows they folded // into. See `apply`. var loaded by remember { mutableStateOf(listOf()) } + // 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()) } + val running = status == "running" || status == "compacting" var moreHistory by remember { mutableStateOf(true) } var loadingHistory 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) { offeredModels = try { @@ -331,8 +345,6 @@ fun SessionScreen( } } - val running = status == "running" || status == "compacting" - fun act(action: () -> Unit) { scope.launch { try { @@ -350,6 +362,7 @@ fun SessionScreen( if (text.isEmpty() && attachments.isEmpty()) return input = "" pendingAttachments = emptyList() + if (running && text.isNotEmpty()) queued = queued + text 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. // // The obvious arrangement -- oldest first, then scroll to the end @@ -438,6 +461,16 @@ fun SessionScreen( contentPadding = PaddingValues(16.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 // happened, which in this layout is the top of the list. // 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 // the reader still sees them in the order they happened. - items(items.asReversed()) { item -> + items(shown.asReversed()) { item -> when (item) { is TranscriptItem.UserMsg -> UserBubble(item.text) is TranscriptItem.AssistantMsg -> @@ -636,16 +669,28 @@ private fun SessionImage(settings: ServerSettings, sessionId: String, ref: Strin } @Composable -private fun UserBubble(text: String) { +private fun UserBubble(text: String, pending: Boolean = false) { Box(Modifier.fillMaxWidth()) { 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 = 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), ) { - Text(text, modifier = Modifier.padding(12.dp)) + Text( + text, + modifier = Modifier.padding(12.dp), + color = + if (pending) MaterialTheme.colorScheme.onSurfaceVariant + else MaterialTheme.colorScheme.onPrimaryContainer, + ) } } } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Theme.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Theme.kt index e5a0090..e59ebaf 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Theme.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Theme.kt @@ -85,7 +85,15 @@ val AiAppColors = 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 @Composable get() = Mocha.Green @@ -98,3 +106,15 @@ val runningColor: Color */ val failedColor: Color @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 diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/UsageScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/UsageScreen.kt index 18b9dea..75cb14c 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/UsageScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/UsageScreen.kt @@ -24,7 +24,6 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue 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.Duration import java.time.OffsetDateTime @@ -32,9 +31,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch 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. */ @Composable fun UsageScreen(settings: ServerSettings, onBack: () -> Unit) { @@ -103,8 +99,8 @@ fun UsageScreen(settings: ServerSettings, onBack: () -> Unit) { private fun WindowBar(window: UsageWindow) { val color = when { - window.percent >= 95 -> OVER_COLOR - window.percent >= 75 -> WARN_COLOR + window.percent >= 95 -> overLimitColor + window.percent >= 75 -> warningColor else -> MaterialTheme.colorScheme.primary } Column {