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.
This commit is contained in:
iris committed 2026-08-31 22:33:31 -04:00
1 parent b7277a5a04
commit ed88bdb31f
5 files changed
+182 -32

No files matched your search

+14
View File
@@ -245,6 +245,20 @@ first if a remote spawn ever mangles an argument.
on the same transcript. What that costs is that the rows below slide up
under the reader's finger, so a row that has just moved ignores taps for
half a second (`SETTLE_MS`).
- **An answered question keeps its options and marks the one that was
taken**, in the same purple that says "picked" while it is still open --
it does not collapse into a line repeating the answer. The options are
what the question *was*, and "Deny" alone does not say that Allow was the
alternative. One rule in two places (`AskedQuestion` and `PermissionAsk`),
since a permission is a question with two bare options rather than a
different kind of thing. An answer typed into **Other** matches no option,
so that one is still written out -- the state the marking cannot say.
- **Anything that is a note *about* the conversation rather than a turn in
it is closed by default**: a tool call, a peer message, and now a memory
note (`<cc-memory>`). Open-ness is the screen's, never the card's -- a
card that remembered for itself forgets the moment the lazy list stops
composing it, so a note opened and scrolled past would shut behind the
reader.
- **All transcript text is selectable, from one `SelectionContainer` around
the whole list** (`TranscriptList.kt`). Not per row: a transcript is one
body of text to a reader, so a selection has to be able to run from a
@@ -11,6 +11,7 @@ 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
@@ -70,27 +71,38 @@ fun AskedQuestion(ask: TranscriptItem.QuestionCard, onAnswer: (List<String>) ->
}
Text(ask.prompt, style = MaterialTheme.typography.bodyLarge)
Spacer(Modifier.height(8.dp))
if (ask.answers.isNotEmpty()) {
// Joined for reading only: they arrived as a list and stay one everywhere else.
Text(
"Answered: ${ask.answers.joinToString(", ")}",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
return@Column
}
if (ask.multiSelect) {
// 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, onAnswer)
AnswerOptions(ask.options, ask.answers, onAnswer.takeUnless { answered })
} else {
ask.options.forEach { option ->
OptionCard(option, selected = false) { onAnswer(listOf(option.label)) }
OptionCard(option, selected = option.label in ask.answers) {
if (!answered) onAnswer(listOf(option.label))
}
}
OtherAnswer(onAnswer)
}
// 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)
}
}
@@ -221,14 +233,41 @@ private fun OtherAnswer(onAnswer: (List<String>) -> Unit) {
* way for a list of choices to be wrong.
*/
@Composable
fun AnswerOptions(options: List<QuestionOption>, onAnswer: (List<String>) -> Unit) {
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 ->
OutlinedButton(onClick = { onAnswer(listOf(option.label)) }) { Text(option.label) }
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)
}
}
}
}
@@ -1,15 +1,21 @@
package com.example.aiapp
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
/**
@@ -29,6 +35,9 @@ import androidx.compose.ui.unit.dp
fun AssistantMessage(
text: String,
replies: ParsedReplies,
/** Which notes are open, by [MessagePart.Remembered.text] -- see [MemoryNote]. */
openNotes: Set<String>,
onToggleNote: (String) -> Unit,
modifier: Modifier = Modifier,
live: Boolean = false,
) {
@@ -43,7 +52,8 @@ fun AssistantMessage(
parts.forEach { part ->
when (part) {
is MessagePart.Prose -> BlockedMarkdown(part.text, replies, live = live)
is MessagePart.Remembered -> MemoryNote(part, replies)
is MessagePart.Remembered ->
MemoryNote(part, replies, part.text in openNotes) { onToggleNote(part.text) }
}
}
}
@@ -67,19 +77,53 @@ fun messageParts(text: String): List<MessagePart> {
return if (parts.singleOrNull() is MessagePart.Prose) listOf(MessagePart.Prose(text)) else parts
}
/**
* One sentence the model attributed to a memory file, closed until somebody asks.
*
* Closed by default, like a tool call and a peer message and for the same reason: it is not part of
* what was said to the reader, it is a note about where a claim came from. Left open it breaks the
* reply in half around a card, which reads as the answer having stopped and restarted -- and these
* arrive several to a message.
*
* What stays visible is which file it came from, because that is the whole of what the note claims
* and it is the part a reader scanning for "why does it think that" is looking for.
*
* Open-ness is the screen's, keyed by the note's own text: a note opened and scrolled past has to
* still be open on the way back, and a card that remembered for itself would forget the moment the
* list stopped composing it. The text is a good enough name -- it does not change once the closing
* tag has arrived, so a note stays open across the moment its reply settles.
*/
@Composable
fun MemoryNote(note: MessagePart.Remembered, replies: ParsedReplies) {
Card(Modifier.fillMaxWidth()) {
fun MemoryNote(
note: MessagePart.Remembered,
replies: ParsedReplies,
expanded: Boolean,
onToggle: () -> Unit,
) {
Card(Modifier.fillMaxWidth().clickable(onClick = onToggle)) {
Column(Modifier.padding(12.dp)) {
// Named, not just tinted: a colour can say "this one is different", but it cannot say
// what kind of different, and "recalled from a file" is a difference in kind.
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
if (note.files.size == 1) "remembered from ${note.files[0]}"
else "remembered from ${note.files.joinToString(", ")}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
MarkdownText(note.text, replies, Modifier.padding(top = 4.dp))
if (!expanded) {
Spacer(Modifier.width(8.dp))
Text(
note.text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
// The head, not the tail: a sentence is identified by how it opens.
overflow = TextOverflow.Ellipsis,
)
}
}
if (expanded) MarkdownText(note.text, replies, Modifier.padding(top = 4.dp))
}
}
}
@@ -246,6 +246,9 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
// A model the reader has chosen and not yet confirmed. See [ModelSwitchWarning]: switching
// makes the session re-read the whole conversation, which is worth asking about first.
var pendingModel by remember { mutableStateOf<String?>(null) }
// What was last taken from the command suggestions, so the list closes behind it; see
// [CommandSuggestions] at its call site.
var picked by remember { mutableStateOf<String?>(null) }
var expandedTools by remember { mutableStateOf(setOf<String>()) }
// Which runs of adjacent tool calls are open. Keyed by the first call's
// id, so a group survives more calls arriving after it.
@@ -257,6 +260,10 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
// by default, which is the rule for anything new in this transcript: a screen that opens
// everything it can is one nobody can scan.
var expandedNotes by remember { mutableStateOf(setOf<Long>()) }
// Which memory notes are open, by the note's own text -- see [MemoryNote]. Closed by default
// like everything else new in this transcript, and held here rather than in the card so a
// note opened and scrolled past is still open on the way back.
var openMemories by remember { mutableStateOf(setOf<String>()) }
// Uploaded-but-not-yet-sent attachment ids; sent with the next message.
var pendingAttachments by remember { mutableStateOf(listOf<String>()) }
// What this session is set to now, seeded from the row that opened it and
@@ -923,6 +930,11 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
}
}
/** Opens or closes one memory note, wherever it is drawn; see [MemoryNote]. */
fun toggleMemory(text: String) {
openMemories = if (text in openMemories) openMemories - text else openMemories + text
}
fun act(onDone: () -> Unit = {}, action: () -> Unit) {
scope.launch {
try {
@@ -1209,7 +1221,14 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
) { unit ->
when (unit) {
is TranscriptUnit.Block -> MarkdownText(unit.text, replies)
is TranscriptUnit.Memory -> MemoryNote(unit.part, replies)
is TranscriptUnit.Memory ->
MemoryNote(
unit.part,
replies,
unit.part.text in openMemories,
) {
toggleMemory(unit.part.text)
}
is TranscriptUnit.Whole -> {
val row = unit.row
Box(
@@ -1322,6 +1341,8 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
AssistantMessage(
item.text,
replies,
openNotes = openMemories,
onToggleNote = ::toggleMemory,
live = true,
)
is TranscriptItem.ToolRun ->
@@ -1505,8 +1526,17 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
// Between the transcript and the box: above what is being typed, so the list does not
// cover the thing the command is about, and below everything that explains it.
CommandSuggestions(
commands = suggestedCommands(input),
onPick = { command -> input = command.typed() },
// Nothing to suggest about a suggestion that was just taken. `/compact` is a
// whole command *and* a prefix of itself, so picking it left the list standing
// there with the one row already chosen -- the reader has to dismiss a list that
// has nothing left to offer, in front of the box they are about to send from.
// Held by what was picked rather than by a flag, so typing anything else brings
// the list back without needing a second thing to reset.
commands = if (input == picked) emptyList() else suggestedCommands(input),
onPick = { command ->
input = command.typed()
picked = command.typed()
},
)
// Always enabled -- a send while the session is running becomes a
@@ -1579,7 +1609,8 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
// see [ModelSwitchWarning].
onPick = { chosen ->
if (
modelLabel(chosen) == modelLabel(model) || items.isEmpty()
modelLabel(chosen) == modelLabel(model) ||
!worthWarningAbout(status, contextTokens, items)
) {
act { setSessionModel(settings, summary.id, chosen) }
} else {
@@ -1806,6 +1837,32 @@ private data class QueuedMessage(
val refusal: String? = null,
)
/**
* Whether a model switch has anything to warn about -- see [ModelSwitchWarning].
*
* What the warning is about is a *cache* being dropped, so the question is whether there is one.
* Two answers say there is not, and both used to produce the dialog anyway:
*
* A session whose process has exited has nothing running to hold a cache, so the next turn was
* always going to re-read the conversation -- the switch adds nothing to that bill. And a session
* reporting zero context is holding nothing, which is what `/clear` leaves behind.
*
* Where the figure is *unknown* rather than zero the fallback is what it always was: whether
* anything has been said at all. Unknown is not nothing, and treating it as nothing would drop the
* warning on exactly the sessions -- an import, a fresh reattach -- where nobody has measured yet
* and the conversation may be enormous.
*/
private fun worthWarningAbout(
status: String,
contextTokens: Long?,
items: List<TranscriptItem>,
): Boolean =
when {
status == "exited" -> false
contextTokens != null -> contextTokens > 0
else -> items.isNotEmpty()
}
/**
* Asked before switching model, because switching is not free and the cost is invisible.
*
@@ -420,15 +420,11 @@ private fun PermissionAsk(ask: TranscriptItem.QuestionCard, onAnswer: (List<Stri
style = MaterialTheme.typography.bodyMedium,
color = awaitingColor,
)
if (ask.answers.isNotEmpty()) {
Text(
"Answered: ${ask.answers.joinToString(", ")}",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
AnswerOptions(ask.options, onAnswer)
}
// Answered or not, the options stay and the one that was taken is marked -- see
// [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
// 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() })
}
/**