Files
ai-app/app/androidApp/src/main/kotlin/com/example/aiapp/AskQuestion.kt
T
iris ed88bdb31f Mark the answer on the question, and close the notes that are not turns
Four things the transcript and the composer said badly.

**An answered question threw away the question.** It collapsed into
"Answered: Deny", which does not say that Allow was the alternative -- and
whether a tool was allowed or refused is what a reader comes back to that
row for. The options stay now and the one that was taken is marked, in the
same purple border that says "picked" while the question is still open, so
it is one appearance learned once rather than two renderings of one thing.
The buttons are disabled rather than removed, and state their own border
and label colour, because Material dims a disabled button's and that would
have taken the mark with it. Both places got it: the question card, and the
permission ask on a tool row, which had the same line.

An answer typed into **Other** matches no option, so nothing could mark it.
That one is still written out -- it is the state the marking cannot say.

**Memory notes were open.** A `<cc-memory>` note is not part of what was
said to the reader, it is a note about where a claim came from, and left
open it breaks a reply in half around a card. Closed like a tool call and a
peer message, with the file it came from still visible, since that is what
somebody scanning for "why does it think that" is looking for. Open-ness is
the screen's rather than the card's, so a note opened and scrolled past is
still open on the way back.

**Picking a slash command left its own suggestion up.** `/compact` is a
whole command and a prefix of itself, so the list stayed with the one row
already chosen -- something to dismiss, in front of the box it was about to
be sent from.

**A model switch warned when there was nothing to warn about.** The warning
is that a cache is dropped, so it needs there to be one: a session whose
process has exited has nothing holding a cache, and one reporting zero
context is holding nothing. Where the figure is *unknown* the fallback is
what it was -- whether anything has been said -- because unknown is not
nothing, and an import nobody has measured yet is exactly where the
conversation may be enormous.
2026-08-31 22:33:31 -04:00

274 lines
12 KiB
Kotlin

package com.example.aiapp
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
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.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
/**
* Every question one tool call is waiting on.
*
* All of it comes from the question events themselves -- what each option means, what picking it
* would produce, whether several may be picked at once. None of it is read out of the call's own
* input, which is one provider's JSON: parsing that here would put that provider's schema in the
* app, where no other provider can reach it and where it drifts the first time the schema moves.
*/
@Composable
fun AskUserQuestionBody(
asks: List<TranscriptItem.QuestionCard>,
onAnswer: (questionId: String, answers: List<String>) -> Unit,
) {
Column(Modifier.fillMaxWidth()) {
asks.forEach { ask ->
Spacer(Modifier.height(12.dp))
AskedQuestion(ask) { answers -> onAnswer(ask.id, answers) }
}
}
}
/**
* One question: what is being asked, what can be answered, and what was.
*
* The same body wherever a question appears -- on the call that asked it, or as a card of its own
* when nothing did. A question is the same thing either way, and two renderings of it would be two
* places for an answer to go missing.
*/
@Composable
fun AskedQuestion(ask: TranscriptItem.QuestionCard, onAnswer: (List<String>) -> Unit) {
Column(Modifier.fillMaxWidth()) {
ask.header?.let { header ->
// Its own line rather than beside the question, because it is a label *for* the
// question and the question is the thing to read.
Text(
header.uppercase(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(ask.prompt, style = MaterialTheme.typography.bodyLarge)
Spacer(Modifier.height(8.dp))
// An answered question keeps its options and marks the one that was taken, rather than
// replacing them with a line repeating it. The options are what the question *was*, and
// dropping them leaves an answer with nothing to have been an answer to -- "Sonnet" says
// very little without the three it was chosen over. Marked in the same purple that says
// "picked" while the question is still open, so it is one appearance learned once.
val answered = ask.answers.isNotEmpty()
if (ask.multiSelect && !answered) {
MultipleChoice(ask.options, onAnswer)
} else if (ask.options.all { it.description == null && it.preview == null }) {
// Nothing to read, so nothing to lay out: Allow and Deny are two words, and two words
// do not need a card each.
AnswerOptions(ask.options, ask.answers, onAnswer.takeUnless { answered })
} else {
ask.options.forEach { option ->
OptionCard(option, selected = option.label in ask.answers) {
if (!answered) onAnswer(listOf(option.label))
}
}
}
// What was answered in the reader's own words, which no option can mark -- see
// [OtherAnswer]. Only ever the answers that match nothing offered, so a question answered
// by picking says it by the mark alone.
val inWords = ask.answers.filterNot { answer -> ask.options.any { it.label == answer } }
if (inWords.isNotEmpty()) {
Text(
"Answered: ${inWords.joinToString(", ")}",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = 8.dp),
)
}
if (!answered) OtherAnswer(onAnswer)
}
}
/**
* Options that can be chosen together, with one button to send them.
*
* The answer goes back as the list it is. What a provider makes of several answers is decided where
* that provider is spoken to -- Claude Code's answers map holds a string, so they are joined there
* -- and nothing on this side has to know that.
*/
@Composable
private fun MultipleChoice(options: List<QuestionOption>, onAnswer: (List<String>) -> Unit) {
var chosen by remember { mutableStateOf(setOf<String>()) }
options.forEach { option ->
OptionCard(option, selected = option.label in chosen) {
chosen = if (option.label in chosen) chosen - option.label else chosen + option.label
}
}
Spacer(Modifier.height(4.dp))
OutlinedButton(
// In the order they were offered rather than the order they were tapped: the reader is
// answering a list, and it should read back as that list.
onClick = { onAnswer(options.map { it.label }.filter { it in chosen }) },
enabled = chosen.isNotEmpty(),
) {
Text(if (chosen.size <= 1) "Send answer" else "Send ${chosen.size} answers")
}
}
/**
* One option: what it is called, what it means, and what it would produce.
*
* Outlined rather than tinted. Drawn first as a card one step up the surface ladder, it was
* indistinguishable from the card behind it -- three paragraphs of text where three things to press
* should have been, which is the failure a tint step routinely produces on a dark theme. A border
* is one cue and it is unambiguous.
*/
@Composable
private fun OptionCard(option: QuestionOption, selected: Boolean, onPick: () -> Unit) {
OutlinedCard(
onClick = onPick,
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
colors =
CardDefaults.outlinedCardColors(
containerColor =
if (selected) MaterialTheme.colorScheme.primaryContainer
else MaterialTheme.colorScheme.surface
),
// Picked shows in the border as well as the fill, because the fill alone is a colour
// difference somebody has to have seen the unpicked version to notice.
border =
BorderStroke(
if (selected) 2.dp else 1.dp,
if (selected) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.outlineVariant,
),
) {
Column(Modifier.padding(12.dp)) {
Text(option.label, style = MaterialTheme.typography.titleSmall)
option.description?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp),
)
}
option.preview?.let { Preview(it) }
}
}
}
/**
* An option's worked example, shown as written.
*
* On its own surface, because it is a different kind of thing from the sentence above it: that
* describes the option, this is a sample of what the option produces, and monospace alone reads as
* a description that happens to be in code font.
*/
@Composable
private fun Preview(preview: String) {
Surface(
color = MaterialTheme.colorScheme.surfaceContainerLowest,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
) {
Text(
preview,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
// Not wrapped: these are mockups and diffs, where a wrapped line reads as two lines
// of the thing being previewed.
softWrap = false,
modifier = Modifier.padding(8.dp).horizontalScroll(rememberScrollState()),
)
}
}
/**
* The choice the asker always leaves open, and the app has to as well.
*
* Every AskUserQuestion carries an implicit "Other" -- the reader may answer in their own words
* rather than pick. Leaving it out narrows a question that was never that narrow, and the reader
* cannot tell that it was ever open.
*/
@Composable
private fun OtherAnswer(onAnswer: (List<String>) -> Unit) {
var text by remember { mutableStateOf("") }
Row(Modifier.fillMaxWidth().padding(top = 8.dp)) {
OutlinedTextField(
value = text,
onValueChange = { text = it },
label = { Text("Other") },
singleLine = true,
modifier = Modifier.weight(1f),
)
TextButton(onClick = { onAnswer(listOf(text.trim())) }, enabled = text.isNotBlank()) {
Text("Send")
}
}
}
/**
* Bare options, wrapped rather than in a row.
*
* A Row hands out intrinsic widths in order and clips whatever runs past the edge, so a question
* with four options showed the first one or two and dropped the rest off the side of the screen.
* That does not read as a bug: it reads as those having been the only choices, which is the worst
* way for a list of choices to be wrong.
*/
@Composable
fun AnswerOptions(
options: List<QuestionOption>,
/** What was chosen, marked rather than restated; empty while the question is open. */
answers: List<String> = emptyList(),
/** Null once the question is answered -- the buttons stay, and stop being buttons. */
onAnswer: ((List<String>) -> Unit)?,
) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth(),
) {
options.forEach { option ->
val taken = option.label in answers
OutlinedButton(
onClick = { onAnswer?.invoke(listOf(option.label)) },
// Disabled rather than removed, so an answered question still shows what it
// offered. Material dims a disabled button's own border and label, which would
// take the mark with it -- both are stated here instead.
enabled = onAnswer != null,
border =
BorderStroke(
if (taken) 2.dp else 1.dp,
if (taken) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.outlineVariant,
),
colors =
ButtonDefaults.outlinedButtonColors(
disabledContentColor =
if (taken) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant
),
) {
Text(option.label)
}
}
}
}