Compare commits
2
Commits
c9d74b63f2
...
ed88bdb31f
| 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
|
||||
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.
|
||||
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))
|
||||
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,
|
||||
)
|
||||
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() })
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -150,10 +150,22 @@ impl Queue {
|
||||
/// Reported rather than dropped. These are messages somebody typed
|
||||
/// that never reached the session and never reached the transcript, so
|
||||
/// 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) {
|
||||
self.closed = true;
|
||||
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() {
|
||||
return;
|
||||
}
|
||||
@@ -165,9 +177,15 @@ impl Queue {
|
||||
} else {
|
||||
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