Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed88bdb31f | ||
|
|
b7277a5a04 |
No files matched your search
@@ -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
|
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
|
under the reader's finger, so a row that has just moved ignores taps for
|
||||||
half a second (`SETTLE_MS`).
|
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
|
- **All transcript text is selectable, from one `SelectionContainer` around
|
||||||
the whole list** (`TranscriptList.kt`). Not per row: a transcript is one
|
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
|
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.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
@@ -70,27 +71,38 @@ fun AskedQuestion(ask: TranscriptItem.QuestionCard, onAnswer: (List<String>) ->
|
|||||||
}
|
}
|
||||||
Text(ask.prompt, style = MaterialTheme.typography.bodyLarge)
|
Text(ask.prompt, style = MaterialTheme.typography.bodyLarge)
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
if (ask.answers.isNotEmpty()) {
|
// An answered question keeps its options and marks the one that was taken, rather than
|
||||||
// Joined for reading only: they arrived as a list and stay one everywhere else.
|
// replacing them with a line repeating it. The options are what the question *was*, and
|
||||||
Text(
|
// dropping them leaves an answer with nothing to have been an answer to -- "Sonnet" says
|
||||||
"Answered: ${ask.answers.joinToString(", ")}",
|
// very little without the three it was chosen over. Marked in the same purple that says
|
||||||
style = MaterialTheme.typography.labelLarge,
|
// "picked" while the question is still open, so it is one appearance learned once.
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
val answered = ask.answers.isNotEmpty()
|
||||||
)
|
if (ask.multiSelect && !answered) {
|
||||||
return@Column
|
|
||||||
}
|
|
||||||
if (ask.multiSelect) {
|
|
||||||
MultipleChoice(ask.options, onAnswer)
|
MultipleChoice(ask.options, onAnswer)
|
||||||
} else if (ask.options.all { it.description == null && it.preview == null }) {
|
} 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
|
// 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, onAnswer)
|
AnswerOptions(ask.options, ask.answers, onAnswer.takeUnless { answered })
|
||||||
} else {
|
} else {
|
||||||
ask.options.forEach { option ->
|
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.
|
* way for a list of choices to be wrong.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@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(
|
FlowRow(
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
options.forEach { option ->
|
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
|
package com.example.aiapp
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
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.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.MaterialTheme
|
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.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -29,6 +35,9 @@ import androidx.compose.ui.unit.dp
|
|||||||
fun AssistantMessage(
|
fun AssistantMessage(
|
||||||
text: String,
|
text: String,
|
||||||
replies: ParsedReplies,
|
replies: ParsedReplies,
|
||||||
|
/** Which notes are open, by [MessagePart.Remembered.text] -- see [MemoryNote]. */
|
||||||
|
openNotes: Set<String>,
|
||||||
|
onToggleNote: (String) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
live: Boolean = false,
|
live: Boolean = false,
|
||||||
) {
|
) {
|
||||||
@@ -43,7 +52,8 @@ fun AssistantMessage(
|
|||||||
parts.forEach { part ->
|
parts.forEach { part ->
|
||||||
when (part) {
|
when (part) {
|
||||||
is MessagePart.Prose -> BlockedMarkdown(part.text, replies, live = live)
|
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
|
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
|
@Composable
|
||||||
fun MemoryNote(note: MessagePart.Remembered, replies: ParsedReplies) {
|
fun MemoryNote(
|
||||||
Card(Modifier.fillMaxWidth()) {
|
note: MessagePart.Remembered,
|
||||||
|
replies: ParsedReplies,
|
||||||
|
expanded: Boolean,
|
||||||
|
onToggle: () -> Unit,
|
||||||
|
) {
|
||||||
|
Card(Modifier.fillMaxWidth().clickable(onClick = onToggle)) {
|
||||||
Column(Modifier.padding(12.dp)) {
|
Column(Modifier.padding(12.dp)) {
|
||||||
// Named, not just tinted: a colour can say "this one is different", but it cannot say
|
// 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.
|
// what kind of different, and "recalled from a file" is a difference in kind.
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(
|
Text(
|
||||||
if (note.files.size == 1) "remembered from ${note.files[0]}"
|
if (note.files.size == 1) "remembered from ${note.files[0]}"
|
||||||
else "remembered from ${note.files.joinToString(", ")}",
|
else "remembered from ${note.files.joinToString(", ")}",
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
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
|
// 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.
|
// makes the session re-read the whole conversation, which is worth asking about first.
|
||||||
var pendingModel by remember { mutableStateOf<String?>(null) }
|
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>()) }
|
var expandedTools by remember { mutableStateOf(setOf<String>()) }
|
||||||
// Which runs of adjacent tool calls are open. Keyed by the first call's
|
// Which runs of adjacent tool calls are open. Keyed by the first call's
|
||||||
// id, so a group survives more calls arriving after it.
|
// 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
|
// by default, which is the rule for anything new in this transcript: a screen that opens
|
||||||
// everything it can is one nobody can scan.
|
// everything it can is one nobody can scan.
|
||||||
var expandedNotes by remember { mutableStateOf(setOf<Long>()) }
|
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.
|
// Uploaded-but-not-yet-sent attachment ids; sent with the next message.
|
||||||
var pendingAttachments by remember { mutableStateOf(listOf<String>()) }
|
var pendingAttachments by remember { mutableStateOf(listOf<String>()) }
|
||||||
// What this session is set to now, seeded from the row that opened it and
|
// 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) {
|
fun act(onDone: () -> Unit = {}, action: () -> Unit) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
try {
|
try {
|
||||||
@@ -1209,7 +1221,14 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
|||||||
) { unit ->
|
) { unit ->
|
||||||
when (unit) {
|
when (unit) {
|
||||||
is TranscriptUnit.Block -> MarkdownText(unit.text, replies)
|
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 -> {
|
is TranscriptUnit.Whole -> {
|
||||||
val row = unit.row
|
val row = unit.row
|
||||||
Box(
|
Box(
|
||||||
@@ -1322,6 +1341,8 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
|||||||
AssistantMessage(
|
AssistantMessage(
|
||||||
item.text,
|
item.text,
|
||||||
replies,
|
replies,
|
||||||
|
openNotes = openMemories,
|
||||||
|
onToggleNote = ::toggleMemory,
|
||||||
live = true,
|
live = true,
|
||||||
)
|
)
|
||||||
is TranscriptItem.ToolRun ->
|
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
|
// 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.
|
// cover the thing the command is about, and below everything that explains it.
|
||||||
CommandSuggestions(
|
CommandSuggestions(
|
||||||
commands = suggestedCommands(input),
|
// Nothing to suggest about a suggestion that was just taken. `/compact` is a
|
||||||
onPick = { command -> input = command.typed() },
|
// 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
|
// Always enabled -- a send while the session is running becomes a
|
||||||
@@ -1579,7 +1609,8 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
|||||||
// see [ModelSwitchWarning].
|
// see [ModelSwitchWarning].
|
||||||
onPick = { chosen ->
|
onPick = { chosen ->
|
||||||
if (
|
if (
|
||||||
modelLabel(chosen) == modelLabel(model) || items.isEmpty()
|
modelLabel(chosen) == modelLabel(model) ||
|
||||||
|
!worthWarningAbout(status, contextTokens, items)
|
||||||
) {
|
) {
|
||||||
act { setSessionModel(settings, summary.id, chosen) }
|
act { setSessionModel(settings, summary.id, chosen) }
|
||||||
} else {
|
} else {
|
||||||
@@ -1806,6 +1837,32 @@ private data class QueuedMessage(
|
|||||||
val refusal: String? = null,
|
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.
|
* 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,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = awaitingColor,
|
color = awaitingColor,
|
||||||
)
|
)
|
||||||
if (ask.answers.isNotEmpty()) {
|
// Answered or not, the options stay and the one that was taken is marked -- see
|
||||||
Text(
|
// [AskedQuestion], which is the same rule on the question card. A permission is where it
|
||||||
"Answered: ${ask.answers.joinToString(", ")}",
|
// matters most: "Answered: Deny" alone does not say that Allow was the alternative, and
|
||||||
style = MaterialTheme.typography.labelLarge,
|
// whether a tool was allowed or refused is the thing a reader comes back to this row for.
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
AnswerOptions(ask.options, ask.answers, onAnswer.takeIf { ask.answers.isEmpty() })
|
||||||
)
|
|
||||||
} else {
|
|
||||||
AnswerOptions(ask.options, onAnswer)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -150,10 +150,22 @@ impl Queue {
|
|||||||
/// Reported rather than dropped. These are messages somebody typed
|
/// Reported rather than dropped. These are messages somebody typed
|
||||||
/// that never reached the session and never reached the transcript, so
|
/// that never reached the session and never reached the transcript, so
|
||||||
/// this is the only place they can be mentioned at all.
|
/// this is the only place they can be mentioned at all.
|
||||||
|
///
|
||||||
|
/// Each one is also *resolved*, with the same `MessageDropped` that a
|
||||||
|
/// phone tapping the bubble produces. Without it the bubble sat there
|
||||||
|
/// for good: a message drawn as waiting to be read, by a session that
|
||||||
|
/// no longer exists, with the only thing that ever clears it -- the
|
||||||
|
/// `UserMessage` -- exactly what is not coming. The error says what
|
||||||
|
/// happened and the drop is what ends it, which is the same division
|
||||||
|
/// of labour as everywhere else here.
|
||||||
fn close(&mut self, sink: &EventSink, why: &str) {
|
fn close(&mut self, sink: &EventSink, why: &str) {
|
||||||
self.closed = true;
|
self.closed = true;
|
||||||
self.running = false;
|
self.running = false;
|
||||||
let lost: Vec<String> = self.awaiting.drain(..).map(|(_, text, _)| text).collect();
|
let lost: Vec<(String, String)> = self
|
||||||
|
.awaiting
|
||||||
|
.drain(..)
|
||||||
|
.map(|(id, text, _)| (id, text))
|
||||||
|
.collect();
|
||||||
if lost.is_empty() {
|
if lost.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -165,9 +177,15 @@ impl Queue {
|
|||||||
} else {
|
} else {
|
||||||
format!("{} queued messages", lost.len())
|
format!("{} queued messages", lost.len())
|
||||||
},
|
},
|
||||||
lost.join(" / ")
|
lost.iter()
|
||||||
|
.map(|(_, text)| text.as_str())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" / ")
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
for (id, _) in lost {
|
||||||
|
let _ = sink.send(Event::MessageDropped { id });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user