Answer a question card as one act, and mark the press that made it

Picking an option marked nothing until the answer had crossed the tunnel,
been recorded and come back as an event, so the card sat unchanged for most
of a second after a tap. What the reader has picked is now the card's own
state and shows at once; a Submit at the foot sends every question the tool
is waiting on, greyed until all of them have an answer and a spinner while
the request is out.

Several questions are paged rather than stacked, with the count and a pair
of arrows on the right, because three questions with four described options
each is several screens and the reader scrolls past the one they are
answering to reach the button that sends it.

A permission ask keeps its single tap -- two bare words are not worth a
submit step -- and marks what was pressed until the request settles, so the
mark either stands on the recorded answer or goes away with the failure.

Chevron draws all four directions from one description of the shape, since
the pager needed two more of them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-09-03 20:04:48 -04:00
1 parent a4d512af26
commit 8dcd2cb708
7 files changed
+294 -122

No files matched your search

+3 -9
View File
@@ -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 (`SessionScreen.expanding`); still to check on the emulator whether a tap
with *no* selection reaches an opened peer card at all. with *no* selection reaches an opened peer card at all.
- [ ] Text inside code blocks does not highlight when selected (selection - [ ] 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, - [ ] Bash logs should apply colour and the other basic text escape sequences,
and filter the rest. and filter the rest.
- [ ] An image should show a loading spinner in an area the size of the image. - [ ] 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 - [ ] Swiping right should open the session list, unless the gesture belongs to
a component (e.g. scrolling left inside a long text block). 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 ## Session settings
- [ ] Autocompact belongs in session settings; empty disables it, which is the - [ ] Autocompact belongs in session settings; empty disables it, which is the
+1 -1
View File
@@ -42,7 +42,7 @@
stateUnchanged: coming back to the app leaves the keyboard as it stateUnchanged: coming back to the app leaves the keyboard as it
was left. The default, stateUnspecified, lets the system decide, was left. The default, stateUnspecified, lets the system decide,
and what it decides with a focused message field is to open the 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 somebody had switched away to compare against. Unchanged rather
than hidden, because a keyboard that was up when the app was left than hidden, because a keyboard that was up when the app was left
is one somebody was in the middle of typing into. --> is one somebody was in the middle of typing into. -->
@@ -10,42 +10,174 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedCard import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp 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>)
/** /**
* 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<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 -- what each option means, what picking it * 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 * 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 * 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. * 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 @Composable
fun AskUserQuestionBody( fun AskUserQuestionBody(
asks: List<TranscriptItem.QuestionCard>, asks: List<TranscriptItem.QuestionCard>,
onAnswer: (questionId: String, answers: List<String>) -> Unit, 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()) { 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)) 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 * 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 * 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. * 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 @Composable
fun AskedQuestion(ask: TranscriptItem.QuestionCard, onAnswer: (List<String>) -> Unit) { fun AskedQuestion(
ask: TranscriptItem.QuestionCard,
draft: Draft,
onDraft: (Draft) -> Unit,
) {
Column(Modifier.fillMaxWidth()) { Column(Modifier.fillMaxWidth()) {
ask.header?.let { header -> ask.header?.let { header ->
// Its own line rather than beside the question, because it is a label *for* the // 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<String>) ->
// very little without the three it was chosen over. Marked in the same purple that 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. // "picked" while the question is still open, so it is one appearance learned once.
val answered = ask.answers.isNotEmpty() val answered = ask.answers.isNotEmpty()
if (ask.multiSelect && !answered) { // What is marked: what was answered once there is an answer, and what the finger has
MultipleChoice(ask.options, onAnswer) // chosen until then.
} else if (ask.options.all { it.description == null && it.preview == null }) { 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 // Nothing to read, so nothing to lay out: Allow and Deny are two words, and two words
// do not need a card each. // do not need a card each.
AnswerOptions(ask.options, ask.answers, onAnswer.takeUnless { answered }) AnswerOptions(ask.options, marked.toList(), onPick)
} else { } else {
ask.options.forEach { option -> ask.options.forEach { option ->
OptionCard(option, selected = option.label in ask.answers) { OptionCard(option, selected = option.label in marked) {
if (!answered) onAnswer(listOf(option.label)) onPick?.invoke(option.label)
} }
} }
} }
@@ -102,35 +245,24 @@ fun AskedQuestion(ask: TranscriptItem.QuestionCard, onAnswer: (List<String>) ->
modifier = Modifier.padding(top = 8.dp), 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 * A single-answer question replaces rather than accumulates, and either way picking puts any typed
* that provider is spoken to -- Claude Code's answers map holds a string, so they are joined there * words away -- see [Draft].
* -- and nothing on this side has to know that.
*/ */
@Composable private fun pick(draft: Draft, label: String, multiSelect: Boolean): Draft =
private fun MultipleChoice(options: List<QuestionOption>, onAnswer: (List<String>) -> Unit) { when {
var chosen by remember { mutableStateOf(setOf<String>()) } !multiSelect -> Draft(picked = setOf(label))
options.forEach { option -> label in draft.picked -> Draft(picked = draft.picked - label)
OptionCard(option, selected = option.label in chosen) { else -> Draft(picked = draft.picked + label)
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. * 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. * cannot tell that it was ever open.
*/ */
@Composable @Composable
private fun OtherAnswer(onAnswer: (List<String>) -> Unit) { private fun OtherAnswer(text: String, onText: (String) -> Unit) {
var text by remember { mutableStateOf("") } // No Send of its own: this is one more way to answer the question, and the card's Submit is
Row(Modifier.fillMaxWidth().padding(top = 8.dp)) { // 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( OutlinedTextField(
value = text, value = text,
onValueChange = { text = it }, onValueChange = onText,
label = { Text("Other") }, label = { Text("Other") },
singleLine = true, singleLine = true,
modifier = Modifier.weight(1f), modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
) )
TextButton(onClick = { onAnswer(listOf(text.trim())) }, enabled = text.isNotBlank()) {
Text("Send")
}
}
} }
/** /**
@@ -235,10 +364,10 @@ private fun OtherAnswer(onAnswer: (List<String>) -> Unit) {
@Composable @Composable
fun AnswerOptions( fun AnswerOptions(
options: List<QuestionOption>, options: List<QuestionOption>,
/** 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<String> = emptyList(), answers: List<String> = emptyList(),
/** Null once the question is answered -- the buttons stay, and stop being buttons. */ /** Null once the question is answered -- the buttons stay, and stop being buttons. */
onAnswer: ((List<String>) -> Unit)?, onPick: ((String) -> Unit)?,
) { ) {
FlowRow( FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
@@ -248,11 +377,11 @@ fun AnswerOptions(
options.forEach { option -> options.forEach { option ->
val taken = option.label in answers val taken = option.label in answers
OutlinedButton( OutlinedButton(
onClick = { onAnswer?.invoke(listOf(option.label)) }, onClick = { onPick?.invoke(option.label) },
// Disabled rather than removed, so an answered question still shows what it // 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 // offered. Material dims a disabled button's own border and label, which would
// take the mark with it -- both are stated here instead. // take the mark with it -- both are stated here instead.
enabled = onAnswer != null, enabled = onPick != null,
border = border =
BorderStroke( BorderStroke(
if (taken) 2.dp else 1.dp, if (taken) 2.dp else 1.dp,
@@ -11,14 +11,25 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.unit.dp 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 * 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. * 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 * One composable for all four directions rather than one per axis that differ by which coordinate
* drift, and the drift would be a bug in exactly one direction. * 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 * 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" * 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 @Composable
fun Chevron( fun Chevron(
pointingUp: Boolean, pointing: Pointing,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
colour: Color = MaterialTheme.colorScheme.onSurfaceVariant, 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 inset = 2.dp.toPx()
val point = if (pointingUp) inset else size.height - inset val wide = size.width - inset
val ends = if (pointingUp) size.height - inset else 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() val stroke = 2.dp.toPx()
drawLine( drawLine(colour, at(0f, 0f), at(0.5f, 1f), strokeWidth = stroke, cap = StrokeCap.Round)
colour, drawLine(colour, at(0.5f, 1f), at(1f, 0f), strokeWidth = stroke, cap = StrokeCap.Round)
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,
)
} }
} }
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
@@ -183,13 +183,34 @@ fun GlyphButton(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
enabled: Boolean = true, enabled: Boolean = true,
colour: Color = MaterialTheme.colorScheme.primary, 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( IconButton(
onClick = onClick, onClick = onClick,
enabled = enabled, enabled = enabled,
modifier = modifier.size(GLYPH_BUTTON_SIZE).semantics { contentDescription = label }, modifier = modifier.size(GLYPH_BUTTON_SIZE).semantics { contentDescription = label },
) { ) {
Glyph(glyph, colour = if (enabled) colour else MaterialTheme.colorScheme.outline) mark()
} }
} }
@@ -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<QuestionAnswer>, onSettled: () -> Unit) {
act(onDone = onSettled) {
answers.forEach { answerQuestion(settings, summary.id, it.questionId, it.answers) }
}
}
fun send() { fun send() {
val text = input.text.trim() val text = input.text.trim()
val attachments = pendingAttachments val attachments = pendingAttachments
@@ -1509,16 +1522,7 @@ fun SessionScreen(
else expandedTools + id else expandedTools + id
} }
}, },
onAnswer = { questionId, answers -> onAnswer = ::answerAll,
act {
answerQuestion(
settings,
summary.id,
questionId,
answers,
)
}
},
image = { ref -> image = { ref ->
SessionImage( SessionImage(
settings, settings,
@@ -1568,16 +1572,7 @@ fun SessionScreen(
else expandedTools + item.id else expandedTools + item.id
} }
}, },
onAnswer = { questionId, answers -> onAnswer = ::answerAll,
act {
answerQuestion(
settings,
summary.id,
questionId,
answers,
)
}
},
image = { ref -> image = { ref ->
SessionImage( SessionImage(
settings, settings,
@@ -1588,16 +1583,7 @@ fun SessionScreen(
}, },
) )
is TranscriptItem.QuestionCard -> is TranscriptItem.QuestionCard ->
QuestionRow(item) { answers -> QuestionRow(item, ::answerAll)
act {
answerQuestion(
settings,
summary.id,
item.id,
answers,
)
}
}
is TranscriptItem.ErrorMsg -> is TranscriptItem.ErrorMsg ->
Text( Text(
item.message, item.message,
@@ -1691,7 +1677,7 @@ fun SessionScreen(
.semantics { contentDescription = "Jump to latest" }, .semantics { contentDescription = "Jump to latest" },
) { ) {
Chevron( Chevron(
pointingUp = false, Pointing.Down,
colour = MaterialTheme.colorScheme.onSurface, colour = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
) )
@@ -2310,13 +2296,14 @@ private fun SessionStatusRow(
@Composable @Composable
private fun QuestionRow( private fun QuestionRow(
question: TranscriptItem.QuestionCard, question: TranscriptItem.QuestionCard,
onAnswer: (List<String>) -> Unit, onAnswer: (List<QuestionAnswer>, onSettled: () -> Unit) -> Unit,
) { ) {
Card(Modifier.fillMaxWidth()) { Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) { Column(Modifier.padding(12.dp)) {
// The same body the questions on a tool call get: one question is the same // The same body the questions on a tool call get, down to the submit button: one
// thing whether or not something else asked it. // question is the same thing whether or not something asked it, and two renderings of
AskedQuestion(question, onAnswer) // it would be two places for an answer to go missing.
AskUserQuestionBody(listOf(question), onAnswer)
} }
} }
} }
@@ -19,7 +19,10 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
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.draw.clip import androidx.compose.ui.draw.clip
@@ -174,7 +177,7 @@ fun ToolGroup(
onToggle: () -> Unit, onToggle: () -> Unit,
isToolExpanded: (String) -> Boolean, isToolExpanded: (String) -> Boolean,
onToolToggle: (String) -> Unit, onToolToggle: (String) -> Unit,
onAnswer: (questionId: String, answers: List<String>) -> Unit, onAnswer: (List<QuestionAnswer>, onSettled: () -> Unit) -> Unit,
image: @Composable (String) -> Unit, image: @Composable (String) -> Unit,
) { ) {
val heading = "Called ${group.calls.size} tools" val heading = "Called ${group.calls.size} tools"
@@ -254,7 +257,7 @@ private fun CollapseBar(height: Dp, onToggle: () -> Unit) {
horizontalArrangement = Arrangement.Center, horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Chevron(pointingUp = true, colour = colour) Chevron(Pointing.Up, colour = colour)
} }
} }
@@ -306,7 +309,7 @@ fun ToolCard(
tool: TranscriptItem.ToolRun, tool: TranscriptItem.ToolRun,
expanded: Boolean, expanded: Boolean,
onToggle: () -> Unit, onToggle: () -> Unit,
onAnswer: (questionId: String, answers: List<String>) -> Unit, onAnswer: (List<QuestionAnswer>, onSettled: () -> Unit) -> Unit,
image: @Composable (String) -> Unit = {}, image: @Composable (String) -> Unit = {},
/** Square where this card faces another in a group; see [connectedShape]. */ /** Square where this card faces another in a group; see [connectedShape]. */
shape: Shape = CardDefaults.shape, shape: Shape = CardDefaults.shape,
@@ -398,9 +401,7 @@ fun ToolCard(
if (tool.tool == ASK_USER_QUESTION) { if (tool.tool == ASK_USER_QUESTION) {
AskUserQuestionBody(tool.asks, onAnswer) AskUserQuestionBody(tool.asks, onAnswer)
} else { } else {
tool.asks.forEach { ask -> tool.asks.forEach { ask -> PermissionAsk(ask, onAnswer) }
PermissionAsk(ask) { answers -> onAnswer(ask.id, answers) }
}
} }
} }
} }
@@ -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. * the ask can stand alone, and here it does not have to -- the card above is showing exactly that.
*/ */
@Composable @Composable
private fun PermissionAsk(ask: TranscriptItem.QuestionCard, onAnswer: (List<String>) -> Unit) { private fun PermissionAsk(
ask: TranscriptItem.QuestionCard,
onAnswer: (List<QuestionAnswer>, 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<String?>(null) }
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text( Text(
ask.prompt.substringBefore('\n'), ask.prompt.substringBefore('\n'),
@@ -425,7 +436,18 @@ private fun PermissionAsk(ask: TranscriptItem.QuestionCard, onAnswer: (List<Stri
// [AskedQuestion], which is the same rule on the question card. A permission is where it // [AskedQuestion], which is the same rule on the question card. A permission is where it
// matters most: "Answered: Deny" alone does not say that Allow was the alternative, and // matters most: "Answered: Deny" alone does not say that Allow was the alternative, and
// whether a tool was allowed or refused is the thing a reader comes back to this row for. // whether a tool was allowed or refused is the thing a reader comes back to this row for.
AnswerOptions(ask.options, ask.answers, onAnswer.takeIf { ask.answers.isEmpty() }) val settled = ask.answers.isNotEmpty()
AnswerOptions(
ask.options,
if (settled) ask.answers else listOfNotNull(pressed),
onPick =
if (settled || pressed != null) null
else
{ label ->
pressed = label
onAnswer(listOf(QuestionAnswer(ask.id, listOf(label)))) { pressed = null }
},
)
} }
/** /**