The same pass the server had, on the Kotlin side: comments restating what the code says are gone, and the ones recording a measurement, a constraint or an incident are kept but cut to a few lines each. 6540 comment lines to 5674, and 920 lines off the app. Two doc comments had drifted onto the item above the one they describe -- `contextAfter`'s onto `sessionWorking` in Events.kt, and `UsageMonitor`'s equivalent on the server was fixed in the previous commit. Each is back on its own item, which is the only non-comment line this diff moves. The comments are reflowed to the column limit at their own indentation: several were written wide, and ktfmt re-wrapped them into lines holding a single orphan word. `/tmp` script, not kept -- ktfmt is idempotent over the result, which is the check. Left alone deliberately: this codebase's remaining comment density is high because the comments carry things the code cannot say -- what a null means, what a number was measured against, which bug a guard exists for. Of the 238 one-line doc comments in the app, five were pure restatement of the name and were removed; the rest each say something the signature does not. ktfmtFormat, compileDebugKotlin, lintDebug and testDebugUnitTest pass; cargo test (127), clippy --all-targets and fmt still clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
393 lines
17 KiB
Kotlin
393 lines
17 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.layout.width
|
|
import androidx.compose.foundation.rememberScrollState
|
|
import androidx.compose.material3.Button
|
|
import androidx.compose.material3.ButtonDefaults
|
|
import androidx.compose.material3.CardDefaults
|
|
import androidx.compose.material3.CircularProgressIndicator
|
|
import androidx.compose.material3.LocalContentColor
|
|
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.runtime.Composable
|
|
import androidx.compose.runtime.getValue
|
|
import androidx.compose.runtime.mutableIntStateOf
|
|
import androidx.compose.runtime.mutableStateOf
|
|
import androidx.compose.runtime.remember
|
|
import androidx.compose.runtime.setValue
|
|
import androidx.compose.ui.Alignment
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.text.font.FontFamily
|
|
import androidx.compose.ui.unit.dp
|
|
|
|
/** One question's answer on its way back, so a card can hand over several at once. */
|
|
data class QuestionAnswer(val questionId: String, val answers: List<String>)
|
|
|
|
/**
|
|
* What the reader has settled on for one question, before any of it is sent.
|
|
*
|
|
* Held here rather than inferred from the transcript, which is what made picking an option feel
|
|
* broken: the mark used to appear only when the answer had crossed the tunnel and come back as an
|
|
* event, so the card sat unchanged for most of a second after a tap.
|
|
*
|
|
* Picked options and typed words are one field each because they are alternatives rather than
|
|
* parts: typing puts the picks away and picking puts the words away, so there is never a draft that
|
|
* means two things.
|
|
*/
|
|
data class Draft(val picked: Set<String> = emptySet(), val other: String = "") {
|
|
val settled: Boolean
|
|
get() = picked.isNotEmpty() || other.isNotBlank()
|
|
|
|
/**
|
|
* What goes back, in the order the options were offered rather than the order they were tapped:
|
|
* the reader is answering a list, and it should read back as that list.
|
|
*/
|
|
fun answers(options: List<QuestionOption>): List<String> =
|
|
if (other.isNotBlank()) listOf(other.trim())
|
|
else options.map { it.label }.filter { it in picked }
|
|
}
|
|
|
|
/**
|
|
* Every question one tool call is waiting on, one at a time.
|
|
*
|
|
* All of it comes from the question events themselves. 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.
|
|
*
|
|
* One question on screen with arrows to the others, rather than all of them stacked. A card asking
|
|
* three questions with four options and a description each is several screens tall, so the reader
|
|
* scrolls past the question they are answering to reach the button that sends it. Paged, each
|
|
* question is a screen and the count says how many are left.
|
|
*
|
|
* Nothing is sent until Submit. Answering is one act even when it is several questions: the tool
|
|
* asked them together, and sending each as it was tapped meant the reader could not change their
|
|
* mind about the first after reading the third.
|
|
*/
|
|
@Composable
|
|
fun AskUserQuestionBody(
|
|
asks: List<TranscriptItem.QuestionCard>,
|
|
onAnswer: (List<QuestionAnswer>, onSettled: () -> Unit) -> Unit,
|
|
) {
|
|
// Seeded from what was already answered, so a card the reader comes back to shows their answers
|
|
// rather than an empty draft over them.
|
|
var drafts by
|
|
remember(asks.map { it.id }) {
|
|
mutableStateOf(
|
|
asks.associate { ask ->
|
|
ask.id to
|
|
Draft(
|
|
picked =
|
|
ask.answers
|
|
.filter { a -> ask.options.any { it.label == a } }
|
|
.toSet(),
|
|
other =
|
|
ask.answers
|
|
.firstOrNull { a -> ask.options.none { it.label == a } }
|
|
.orEmpty(),
|
|
)
|
|
}
|
|
)
|
|
}
|
|
var at by remember(asks.map { it.id }) { mutableIntStateOf(0) }
|
|
var sending by remember(asks.map { it.id }) { mutableStateOf(false) }
|
|
if (asks.isEmpty()) return
|
|
val showing = asks[at.coerceIn(0, asks.size - 1)]
|
|
val outstanding = asks.filter { it.answers.isEmpty() }
|
|
|
|
Column(Modifier.fillMaxWidth()) {
|
|
if (asks.size > 1) {
|
|
Row(
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
modifier = Modifier.fillMaxWidth(),
|
|
) {
|
|
Text(
|
|
"Question ${at + 1} of ${asks.size}",
|
|
style = MaterialTheme.typography.labelSmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
modifier = Modifier.weight(1f),
|
|
)
|
|
// Disabled at the ends rather than absent, so the pair keeps its place and the
|
|
// reader can see there is nothing further that way.
|
|
MarkButton("Previous question", { at-- }, enabled = at > 0) {
|
|
Chevron(Pointing.Left, colour = LocalContentColor.current)
|
|
}
|
|
MarkButton("Next question", { at++ }, enabled = at < asks.size - 1) {
|
|
Chevron(Pointing.Right, colour = LocalContentColor.current)
|
|
}
|
|
}
|
|
}
|
|
Spacer(Modifier.height(4.dp))
|
|
AskedQuestion(
|
|
showing,
|
|
draft = drafts[showing.id] ?: Draft(),
|
|
onDraft = { drafts = drafts + (showing.id to it) },
|
|
)
|
|
if (outstanding.isNotEmpty()) {
|
|
Spacer(Modifier.height(12.dp))
|
|
// Greyed until every question has an answer, because the tool is waiting on all of
|
|
// them: a submit that sent two of three would leave the third asked and the card
|
|
// looking dealt with.
|
|
val ready = outstanding.all { drafts[it.id]?.settled == true }
|
|
Button(
|
|
onClick = {
|
|
sending = true
|
|
onAnswer(
|
|
outstanding.map { ask ->
|
|
QuestionAnswer(ask.id, (drafts[ask.id] ?: Draft()).answers(ask.options))
|
|
}
|
|
) {
|
|
// Back to a button whatever happened. A refusal is reported by the screen
|
|
// around this, and the draft is still here to send again -- a spinner that
|
|
// never stops would be the only sign of a failure this card cannot
|
|
// describe.
|
|
sending = false
|
|
}
|
|
},
|
|
enabled = ready && !sending,
|
|
modifier = Modifier.fillMaxWidth(),
|
|
) {
|
|
if (sending) {
|
|
// In the button rather than beside it, so the row does not change height at the
|
|
// moment it is pressed.
|
|
CircularProgressIndicator(
|
|
Modifier.height(18.dp).width(18.dp),
|
|
strokeWidth = 2.dp,
|
|
color = LocalContentColor.current,
|
|
)
|
|
} else {
|
|
Text(
|
|
if (outstanding.size > 1) "Submit ${outstanding.size} answers" else "Submit"
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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. Two renderings of it would be two places for an answer to go missing.
|
|
*
|
|
* [draft] is what the reader has picked so far and [onDraft] is how they change it; nothing here
|
|
* sends anything. An answered question ignores both and draws what was answered.
|
|
*/
|
|
@Composable
|
|
fun AskedQuestion(
|
|
ask: TranscriptItem.QuestionCard,
|
|
draft: Draft,
|
|
onDraft: (Draft) -> 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 open, so it is one appearance learned once.
|
|
val answered = ask.answers.isNotEmpty()
|
|
// What is marked: what was answered once there is an answer, and what the finger has chosen
|
|
// until then.
|
|
val marked = if (answered) ask.answers.toSet() else draft.picked
|
|
// Null once the question is answered: the options stay and stop being pressable.
|
|
val onPick: ((String) -> Unit)? =
|
|
if (answered) null else { label -> onDraft(pick(draft, label, ask.multiSelect)) }
|
|
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, marked.toList(), onPick)
|
|
} else {
|
|
ask.options.forEach { option ->
|
|
OptionCard(option, selected = option.label in marked) {
|
|
onPick?.invoke(option.label)
|
|
}
|
|
}
|
|
}
|
|
// What was answered in the reader's own words, which no option can mark. Only ever the
|
|
// answers that match nothing offered, so a question answered by picking says it by the
|
|
// mark.
|
|
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(draft.other) { onDraft(Draft(other = it)) }
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* [label] added to, or taken out of, what [draft] has picked. A single-answer question replaces
|
|
* rather than accumulates, and either way picking puts any typed words away -- see [Draft].
|
|
*/
|
|
private fun pick(draft: Draft, label: String, multiSelect: Boolean): Draft =
|
|
when {
|
|
!multiSelect -> Draft(picked = setOf(label))
|
|
label in draft.picked -> Draft(picked = draft.picked - label)
|
|
else -> Draft(picked = draft.picked + label)
|
|
}
|
|
|
|
/**
|
|
* 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. 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.
|
|
*/
|
|
@Composable
|
|
private fun OtherAnswer(text: String, onText: (String) -> Unit) {
|
|
// No Send of its own: this is one more way to answer the question, and the card's Submit is
|
|
// what sends it. A second send button beside the field made the shorter half of the card look
|
|
// like the one that finishes it.
|
|
OutlinedTextField(
|
|
value = text,
|
|
onValueChange = onText,
|
|
label = { Text("Other") },
|
|
singleLine = true,
|
|
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
|
)
|
|
}
|
|
|
|
/**
|
|
* 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 reads as those having been the only choices.
|
|
*/
|
|
@Composable
|
|
fun AnswerOptions(
|
|
options: List<QuestionOption>,
|
|
/** What is chosen: the answer once there is one, and what the finger has marked until then. */
|
|
answers: List<String> = emptyList(),
|
|
/** Null once the question is answered -- the buttons stay, and stop being buttons. */
|
|
onPick: ((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 = { onPick?.invoke(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 = onPick != 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)
|
|
}
|
|
}
|
|
}
|
|
}
|