Show every option a question offers, on the call that asked

Reported by Iris through the dev-updater session: a two-question
AskUserQuestion arrived with only one option visible per question, so
the answer she sent was the only one she had been offered.

The cause was a `Row`. It hands out intrinsic widths in order and clips
whatever runs past the edge, so the first option or two drew and the
rest went off the side of the screen -- which does not read as a bug, it
reads as those having been the only choices. The same Row was in the
permission ask beside it; both wrap now. That pairing is the reason to
look: a rule stated on one member of a set is usually missing from the
others.

The rest of what she asked for, and what each was:

- It drew twice, as the tool call and again as loose question cards,
  because the backend marked these questions as belonging to no call.
  They belong to the call that asked, and now say so.
- So it renders like any other tool: one card, its own heading, opened
  because a decision cannot be made from a closed row.
- Each option shows its description and its `preview` block, which is
  the part a reader is deciding on and none of which was reaching them.
- "Other" is a field on every question. The harness always offers it, so
  leaving it out narrowed a question that was never that narrow.
- A multi-select sends the labels it collected as one string, which is
  the tool's own schema rather than a guess -- its answers map is
  string-valued.
- No spinner while it waits. A spinner says the machine is working; here
  the machine is idle and the turn is stopped on the person, so the card
  says "your turn" in the colour this app already uses for that.

Verified against a real session as well as the echo fixture: haiku asked
two questions with three described options each, both were answered from
the phone, and the model carried on with the answers. Echo grew `/ask`
so the shape can be looked at without paying a model to produce one, and
its option cards are outlined rather than tinted -- as one surface step
up they were three paragraphs where three things to press should be.
This commit is contained in:
iris committed 2026-08-29 16:22:15 -04:00
1 parent cae04c2559
commit fea8e7e92b
5 files changed
+518 -61

No files matched your search

@@ -97,14 +97,17 @@ sealed class TranscriptItem {
val output: String,
val done: Boolean,
/**
* The permission ask for this call, when there is one.
* The questions this call is waiting on, in the order they were asked.
*
* On the call's own row rather than beside it: the ask used to arrive as a second card
* On the call's own row rather than beside it: an ask used to arrive as a second card
* repeating the input verbatim, so the reader saw the same command twice and had to work
* out that it was one event. The backend says which call a permission is about, so this is
* a fact rather than a match on the input.
* out that it was one event. The backend says which call a question is about, so this is a
* fact rather than a match on the input.
*
* A list because AskUserQuestion asks up to four at once, and they are one decision to make
* -- a permission is the case of exactly one, not a different shape.
*/
val ask: QuestionCard? = null,
val asks: List<QuestionCard> = emptyList(),
/**
* Images this call's result carried, drawn under it.
*
@@ -214,7 +217,7 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
event.about != null &&
items.any { it is TranscriptItem.ToolRun && it.id == event.about }
) {
updateTool(items, event.about) { it.copy(ask = card) }
updateTool(items, event.about) { it.copy(asks = it.asks + card) }
} else {
items + card
}
@@ -227,8 +230,13 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
when {
it is TranscriptItem.QuestionCard && it.id == event.id ->
it.copy(answer = event.answer)
it is TranscriptItem.ToolRun && it.ask?.id == event.id ->
it.copy(ask = it.ask.copy(answer = event.answer))
it is TranscriptItem.ToolRun && it.asks.any { ask -> ask.id == event.id } ->
it.copy(
asks =
it.asks.map { ask ->
if (ask.id == event.id) ask.copy(answer = event.answer) else ask
}
)
else -> it
}
}
@@ -798,10 +806,8 @@ fun SessionScreen(
if (id in expandedTools) expandedTools - id
else expandedTools + id
},
onAnswer = { call, answer ->
call.ask?.let { ask ->
act { answerQuestion(settings, summary.id, ask.id, answer) }
}
onAnswer = { questionId, answer ->
act { answerQuestion(settings, summary.id, questionId, answer) }
},
image = { ref -> SessionImage(settings, summary.id, ref) },
)
@@ -819,16 +825,14 @@ fun SessionScreen(
expandedTools - item.id
else expandedTools + item.id
},
onAnswer = { answer ->
item.ask?.let { ask ->
act {
answerQuestion(
settings,
summary.id,
ask.id,
answer,
)
}
onAnswer = { questionId, answer ->
act {
answerQuestion(
settings,
summary.id,
questionId,
answer,
)
}
},
image = { ref -> SessionImage(settings, summary.id, ref) },
@@ -1033,11 +1037,11 @@ private fun QuestionRow(question: TranscriptItem.QuestionCard, onAnswer: (String
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
question.options.forEach { option ->
OutlinedButton(onClick = { onAnswer(option) }) { Text(option) }
}
}
// Wrapped, not 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 silently dropped the rest off the side of the screen -- which
// does not look like a bug, it looks like those were the only choices.
AnswerOptions(question.options, onAnswer)
}
}
}