diff --git a/TODO.md b/TODO.md index 74e8cc1..a7df47d 100644 --- a/TODO.md +++ b/TODO.md @@ -10,7 +10,9 @@ one in place when it turns out to need a decision. (`SessionScreen.expanding`); still to check on the emulator whether a tap with *no* selection reaches an opened peer card at all. - [ ] Text inside code blocks does not highlight when selected (selection - itself works — only the highlight is missing). + itself works — only the highlight is missing). Waiting on the app-c7 + session, which is replacing the highlighter (`HIGHLIGHTER_PLAN.md`) and + owns `CodeFence.kt` until it pushes; it confirmed this item is ours. - [ ] Bash logs should apply colour and the other basic text escape sequences, and filter the rest. - [ ] An image should show a loading spinner in an area the size of the image. @@ -22,14 +24,6 @@ one in place when it turns out to need a decision. - [ ] Swiping right should open the session list, unless the gesture belongs to a component (e.g. scrolling left inside a long text block). -## App — AskUserQuestion card - -- [ ] Selection should highlight instantly instead of waiting; a Submit button - at the bottom sends, and becomes a spinner while sending. -- [ ] Arrow buttons in the top right navigate between questions instead of - stacking them all in a row. -- [ ] Submit stays greyed out until every question is answered. - ## Session settings - [ ] Autocompact belongs in session settings; empty disables it, which is the diff --git a/app/androidApp/src/main/AndroidManifest.xml b/app/androidApp/src/main/AndroidManifest.xml index 3a4512d..cb94924 100644 --- a/app/androidApp/src/main/AndroidManifest.xml +++ b/app/androidApp/src/main/AndroidManifest.xml @@ -42,7 +42,7 @@ stateUnchanged: coming back to the app leaves the keyboard as it was left. The default, stateUnspecified, lets the system decide, and what it decides with a focused message field is to open the - keyboard -- so switching away and back covered half the transcript + keyboard, so switching away and back covered half the transcript somebody had switched away to compare against. Unchanged rather than hidden, because a keyboard that was up when the app was left is one somebody was in the middle of typing into. --> diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/AskQuestion.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/AskQuestion.kt index 36300d1..3e09769 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/AskQuestion.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/AskQuestion.kt @@ -10,42 +10,174 @@ 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.material3.TextButton 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) + /** - * Every question one tool call is waiting on. + * 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, been recorded and + * come back as an event, so on a phone the card sat unchanged for most of a second after a tap and + * the natural response was to tap again. + * + * Picked options and typed words are one field each because they are alternatives rather than + * parts: answering in the reader's own words is the case no option covers, so typing puts the picks + * away and picking puts the words away, and there is never a draft that means two things. + */ +data class Draft(val picked: Set = 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): List = + 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 -- 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. + * + * 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, and never sees + * the whole of any one of them. Paged, each question is a screen and the count says how many are + * left -- which is also what makes "not all of them are answered" something the reader can act on + * rather than something to go hunting for. + * + * Nothing is sent until Submit. Answering is one act even when it is several questions: the tool + * asked them together and is waiting on all of them, 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, - onAnswer: (questionId: String, answers: List) -> Unit, + onAnswer: (List, 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()) { - asks.forEach { ask -> + 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 that 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)) - AskedQuestion(ask) { answers -> onAnswer(ask.id, answers) } + // 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 one 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" + ) + } + } } } } @@ -56,9 +188,16 @@ fun AskUserQuestionBody( * 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. + * + * [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, onAnswer: (List) -> Unit) { +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 @@ -77,16 +216,20 @@ fun AskedQuestion(ask: TranscriptItem.QuestionCard, onAnswer: (List) -> // 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 }) { + // 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, ask.answers, onAnswer.takeUnless { answered }) + AnswerOptions(ask.options, marked.toList(), onPick) } else { ask.options.forEach { option -> - OptionCard(option, selected = option.label in ask.answers) { - if (!answered) onAnswer(listOf(option.label)) + OptionCard(option, selected = option.label in marked) { + onPick?.invoke(option.label) } } } @@ -102,35 +245,24 @@ fun AskedQuestion(ask: TranscriptItem.QuestionCard, onAnswer: (List) -> modifier = Modifier.padding(top = 8.dp), ) } - if (!answered) OtherAnswer(onAnswer) + if (!answered) { + OtherAnswer(draft.other) { onDraft(Draft(other = it)) } + } } } /** - * Options that can be chosen together, with one button to send them. + * [label] added to, or taken out of, what [draft] has picked. * - * 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. + * A single-answer question replaces rather than accumulates, and either way picking puts any typed + * words away -- see [Draft]. */ -@Composable -private fun MultipleChoice(options: List, onAnswer: (List) -> Unit) { - var chosen by remember { mutableStateOf(setOf()) } - options.forEach { option -> - OptionCard(option, selected = option.label in chosen) { - chosen = if (option.label in chosen) chosen - option.label else chosen + option.label - } +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) } - 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. @@ -208,20 +340,17 @@ private fun Preview(preview: String) { * cannot tell that it was ever open. */ @Composable -private fun OtherAnswer(onAnswer: (List) -> 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") - } - } +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), + ) } /** @@ -235,10 +364,10 @@ private fun OtherAnswer(onAnswer: (List) -> Unit) { @Composable fun AnswerOptions( options: List, - /** What was chosen, marked rather than restated; empty while the question is open. */ + /** What is chosen: the answer once there is one, and what the finger has marked until then. */ answers: List = emptyList(), /** Null once the question is answered -- the buttons stay, and stop being buttons. */ - onAnswer: ((List) -> Unit)?, + onPick: ((String) -> Unit)?, ) { FlowRow( horizontalArrangement = Arrangement.spacedBy(8.dp), @@ -248,11 +377,11 @@ fun AnswerOptions( options.forEach { option -> val taken = option.label in answers OutlinedButton( - onClick = { onAnswer?.invoke(listOf(option.label)) }, + 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 = onAnswer != null, + enabled = onPick != null, border = BorderStroke( if (taken) 2.dp else 1.dp, diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Chevron.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Chevron.kt index 560a411..36cda0c 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Chevron.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Chevron.kt @@ -11,14 +11,25 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.unit.dp +/** Which way a [Chevron] points. */ +enum class Pointing { + Up, + Down, + Left, + Right, +} + /** - * A chevron, pointing up or down. + * A chevron, pointing whichever of the four ways is asked for. * * Drawn rather than set in a font: a chevron from an icon font is one of the glyphs a system font * may simply not have, and the reader who gets an empty box instead is never the one who wrote it. * - * One composable for both directions rather than two that differ by a minus sign -- the pair would - * drift, and the drift would be a bug in exactly one direction. + * One composable for all four directions rather than one per axis that differ by which coordinate + * gets the minus sign -- the copies would drift, and the drift would be a bug in exactly one + * direction. The shape is written once in its own coordinates, where x runs across the opening and + * y runs from the open side to the tip, and [Pointing] is only a table of how those two map onto + * the box. * * It draws no label of its own, so every caller owes it a `contentDescription`: this is the whole * of what assistive technology has to go on, and it is also the answer to "what was that arrow for" @@ -26,28 +37,36 @@ import androidx.compose.ui.unit.dp */ @Composable fun Chevron( - pointingUp: Boolean, + pointing: Pointing, modifier: Modifier = Modifier, colour: Color = MaterialTheme.colorScheme.onSurfaceVariant, ) { - Canvas(modifier.width(20.dp).height(10.dp)) { + val sideways = pointing == Pointing.Left || pointing == Pointing.Right + Canvas( + modifier + .width(if (sideways) CHEVRON_DEPTH else CHEVRON_SPAN) + .height(if (sideways) CHEVRON_SPAN else CHEVRON_DEPTH) + ) { val inset = 2.dp.toPx() - val point = if (pointingUp) inset else size.height - inset - val ends = if (pointingUp) size.height - inset else inset + val wide = size.width - inset + val tall = size.height - inset + fun at(across: Float, along: Float) = + when (pointing) { + Pointing.Up -> Offset(lerp(inset, wide, across), lerp(tall, inset, along)) + Pointing.Down -> Offset(lerp(inset, wide, across), lerp(inset, tall, along)) + Pointing.Left -> Offset(lerp(wide, inset, along), lerp(inset, tall, across)) + Pointing.Right -> Offset(lerp(inset, wide, along), lerp(inset, tall, across)) + } val stroke = 2.dp.toPx() - drawLine( - colour, - Offset(inset, ends), - Offset(size.width / 2, point), - strokeWidth = stroke, - cap = StrokeCap.Round, - ) - drawLine( - colour, - Offset(size.width / 2, point), - Offset(size.width - inset, ends), - strokeWidth = stroke, - cap = StrokeCap.Round, - ) + drawLine(colour, at(0f, 0f), at(0.5f, 1f), strokeWidth = stroke, cap = StrokeCap.Round) + drawLine(colour, at(0.5f, 1f), at(1f, 0f), strokeWidth = stroke, cap = StrokeCap.Round) } } + +private fun lerp(from: Float, to: Float, fraction: Float) = from + (to - from) * fraction + +/** How far the chevron opens, across the direction it points. */ +private val CHEVRON_SPAN = 20.dp + +/** How far it reaches in the direction it points. */ +private val CHEVRON_DEPTH = 10.dp diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/NerdIcons.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/NerdIcons.kt index 66093c7..9482384 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/NerdIcons.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/NerdIcons.kt @@ -183,13 +183,34 @@ fun GlyphButton( modifier: Modifier = Modifier, enabled: Boolean = true, colour: Color = MaterialTheme.colorScheme.primary, +) { + MarkButton(label, onClick, modifier, enabled) { + Glyph(glyph, colour = if (enabled) colour else MaterialTheme.colorScheme.outline) + } +} + +/** + * The same square, around a mark that is not a glyph. + * + * A [Chevron] is drawn rather than set in a font, and a pair of them used as buttons has to be the + * size, spacing and touch target every other icon button on this app's headers already is -- so + * this is [GlyphButton] with the mark left to the caller rather than a second set of measurements + * beside it. The caller still owes it a [label]: nothing here draws a word. + */ +@Composable +fun MarkButton( + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + mark: @Composable () -> Unit, ) { IconButton( onClick = onClick, enabled = enabled, modifier = modifier.size(GLYPH_BUTTON_SIZE).semantics { contentDescription = label }, ) { - Glyph(glyph, colour = if (enabled) colour else MaterialTheme.colorScheme.outline) + mark() } } 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 767f8a9..9c28de3 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -1094,6 +1094,19 @@ fun SessionScreen( } } + /** + * Sends every answer a question card handed over, and says when the last of them has settled. + * + * All of them in one go because a card asks its questions together and the tool is waiting on + * all of them; the completion is what turns the card's spinner back into a button, whether the + * server took them or refused. + */ + fun answerAll(answers: List, onSettled: () -> Unit) { + act(onDone = onSettled) { + answers.forEach { answerQuestion(settings, summary.id, it.questionId, it.answers) } + } + } + fun send() { val text = input.text.trim() val attachments = pendingAttachments @@ -1509,16 +1522,7 @@ fun SessionScreen( else expandedTools + id } }, - onAnswer = { questionId, answers -> - act { - answerQuestion( - settings, - summary.id, - questionId, - answers, - ) - } - }, + onAnswer = ::answerAll, image = { ref -> SessionImage( settings, @@ -1568,16 +1572,7 @@ fun SessionScreen( else expandedTools + item.id } }, - onAnswer = { questionId, answers -> - act { - answerQuestion( - settings, - summary.id, - questionId, - answers, - ) - } - }, + onAnswer = ::answerAll, image = { ref -> SessionImage( settings, @@ -1588,16 +1583,7 @@ fun SessionScreen( }, ) is TranscriptItem.QuestionCard -> - QuestionRow(item) { answers -> - act { - answerQuestion( - settings, - summary.id, - item.id, - answers, - ) - } - } + QuestionRow(item, ::answerAll) is TranscriptItem.ErrorMsg -> Text( item.message, @@ -1691,7 +1677,7 @@ fun SessionScreen( .semantics { contentDescription = "Jump to latest" }, ) { Chevron( - pointingUp = false, + Pointing.Down, colour = MaterialTheme.colorScheme.onSurface, modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), ) @@ -2310,13 +2296,14 @@ private fun SessionStatusRow( @Composable private fun QuestionRow( question: TranscriptItem.QuestionCard, - onAnswer: (List) -> Unit, + onAnswer: (List, onSettled: () -> Unit) -> Unit, ) { Card(Modifier.fillMaxWidth()) { Column(Modifier.padding(12.dp)) { - // The same body the questions on a tool call get: one question is the same - // thing whether or not something else asked it. - AskedQuestion(question, onAnswer) + // The same body the questions on a tool call get, down to the submit button: one + // question is the same thing whether or not something asked it, and two renderings of + // it would be two places for an answer to go missing. + AskUserQuestionBody(listOf(question), onAnswer) } } } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt index 11fb2b4..b672633 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt @@ -19,7 +19,10 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable +import androidx.compose.runtime.getValue +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.draw.clip @@ -174,7 +177,7 @@ fun ToolGroup( onToggle: () -> Unit, isToolExpanded: (String) -> Boolean, onToolToggle: (String) -> Unit, - onAnswer: (questionId: String, answers: List) -> Unit, + onAnswer: (List, onSettled: () -> Unit) -> Unit, image: @Composable (String) -> Unit, ) { val heading = "Called ${group.calls.size} tools" @@ -254,7 +257,7 @@ private fun CollapseBar(height: Dp, onToggle: () -> Unit) { horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, ) { - Chevron(pointingUp = true, colour = colour) + Chevron(Pointing.Up, colour = colour) } } @@ -306,7 +309,7 @@ fun ToolCard( tool: TranscriptItem.ToolRun, expanded: Boolean, onToggle: () -> Unit, - onAnswer: (questionId: String, answers: List) -> Unit, + onAnswer: (List, onSettled: () -> Unit) -> Unit, image: @Composable (String) -> Unit = {}, /** Square where this card faces another in a group; see [connectedShape]. */ shape: Shape = CardDefaults.shape, @@ -398,9 +401,7 @@ fun ToolCard( if (tool.tool == ASK_USER_QUESTION) { AskUserQuestionBody(tool.asks, onAnswer) } else { - tool.asks.forEach { ask -> - PermissionAsk(ask) { answers -> onAnswer(ask.id, answers) } - } + tool.asks.forEach { ask -> PermissionAsk(ask, onAnswer) } } } } @@ -414,7 +415,17 @@ fun ToolCard( * the ask can stand alone, and here it does not have to -- the card above is showing exactly that. */ @Composable -private fun PermissionAsk(ask: TranscriptItem.QuestionCard, onAnswer: (List) -> Unit) { +private fun PermissionAsk( + ask: TranscriptItem.QuestionCard, + onAnswer: (List, onSettled: () -> Unit) -> Unit, +) { + // What was pressed, before the answer has been round-tripped. Two bare words with no submit + // step -- unlike a question card, where the answer is several choices and worth reviewing -- + // so the press has to be its own acknowledgement or the row sits unchanged for a round trip + // and reads as having missed the tap. Cleared when the request settles: by then either the + // answer is in `ask.answers` and the mark stands on a measurement, or it failed and the + // buttons come back rather than leaving a decision marked that nothing recorded. + var pressed by remember(ask.id) { mutableStateOf(null) } Spacer(Modifier.height(8.dp)) Text( ask.prompt.substringBefore('\n'), @@ -425,7 +436,18 @@ private fun PermissionAsk(ask: TranscriptItem.QuestionCard, onAnswer: (List + pressed = label + onAnswer(listOf(QuestionAnswer(ask.id, listOf(label)))) { pressed = null } + }, + ) } /**