Merge branch 'main' of git.arirex.me:iris/ai-app

This commit is contained in:
iris committed 2026-08-30 00:58:04 -04:00
commit 03a8d7d3d3
16 files changed
+529 -70

No files matched your search

@@ -99,7 +99,12 @@ sealed class TranscriptItem {
*/
abstract val seq: Long
data class UserMsg(override val seq: Long, val text: String) : TranscriptItem()
data class UserMsg(
override val seq: Long,
val text: String,
/** Refs of what was attached, drawn inside the bubble. */
val images: List<String> = emptyList(),
) : TranscriptItem()
data class AssistantMsg(override val seq: Long, val text: String) : TranscriptItem()
@@ -311,7 +316,8 @@ private fun adoptRun(
fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem> =
when (val event = entry.event) {
is SessionEvent.UserMessage -> items + TranscriptItem.UserMsg(entry.seq, event.text)
is SessionEvent.UserMessage ->
items + TranscriptItem.UserMsg(entry.seq, event.text, event.images)
is SessionEvent.AssistantText -> {
// Deltas accumulate into the message they're streaming, which keeps the seq of the
// first of them: a row whose identity changed with every delta would be a new row on
@@ -517,7 +523,7 @@ fun SessionScreen(
// them. From the event stream rather than from what this screen sent, so they are still here
// after leaving the session or restarting the app -- and so a message sent from another device
// is drawn waiting on this one too.
var queued by remember { mutableStateOf(listOf<Pair<String, String>>()) }
var queued by remember { mutableStateOf(listOf<QueuedMessage>()) }
// Commands the session has been asked to run and cannot yet, by the id that will resolve
// them. From the server rather than from this screen, so a rename sent from the settings
// screen -- or from another device -- is drawn waiting here too.
@@ -577,10 +583,10 @@ fun SessionScreen(
// Waiting, then read. Matched by id: the same message sent twice is two
// bubbles, and clearing by text would take away whichever matched first.
if (event is SessionEvent.MessageQueued) {
queued = queued + (event.id to event.text)
queued = queued + QueuedMessage(event.id, event.text, event.images)
}
if (event is SessionEvent.UserMessage) {
queued = queued.filterNot { it.first == event.id }
queued = queued.filterNot { it.id == event.id }
}
// Waiting, then gone: a command leaves this list when the session takes it,
// and the row it becomes is added by `foldEvent` in the same pass.
@@ -924,12 +930,16 @@ fun SessionScreen(
try {
val id =
withContext(Dispatchers.IO) {
val bytes =
context.contentResolver.openInputStream(uri)?.use {
it.readBytes()
} ?: throw ApiException("couldn't read the picked image")
val mime = context.contentResolver.getType(uri) ?: "image/jpeg"
uploadAttachment(settings, summary.id, bytes, mime)
// Shrunk to what this session's provider takes before it is
// uploaded, so a twelve-megapixel photo does not cross the tunnel
// to be rejected at the far end -- see `uploadPickedImage`.
uploadPickedImage(
context,
settings,
summary.id,
uri,
summary.maxImageEdge,
)
}
pendingAttachments = pendingAttachments + id
actionError = null
@@ -1042,7 +1052,15 @@ fun SessionScreen(
waitingCommands.forEach { (_, text) ->
CommandBubble(text, waiting = true)
}
queued.forEach { (_, text) -> UserBubble(text, pending = true) }
queued.forEach { waiting ->
UserBubble(
settings = settings,
sessionId = summary.id,
text = waiting.text,
images = waiting.images,
pending = true,
)
}
}
}
}
@@ -1084,7 +1102,13 @@ fun SessionScreen(
)
is TranscriptRow.Single ->
when (val item = row.item) {
is TranscriptItem.UserMsg -> UserBubble(item.text)
is TranscriptItem.UserMsg ->
UserBubble(
settings = settings,
sessionId = summary.id,
text = item.text,
images = item.images,
)
is TranscriptItem.AssistantMsg -> AssistantMessage(item.text)
is TranscriptItem.ToolRun ->
ToolCard(
@@ -1210,6 +1234,14 @@ fun SessionScreen(
// put the full width behind three controls, so the thing being
// typed into was the narrowest thing on the row.
Column(Modifier.fillMaxWidth().padding(8.dp)) {
// Directly above the box they will be sent from, so what is attached is visible
// rather than counted: the "+2" on the button below said how many and never which.
PendingAttachments(
settings = settings,
sessionId = summary.id,
refs = pendingAttachments,
onRemove = { pendingAttachments = pendingAttachments - it },
)
OutlinedTextField(
value = input,
onValueChange = {
@@ -1217,9 +1249,9 @@ fun SessionScreen(
saveDraft(context, summary.id, it)
},
modifier = Modifier.fillMaxWidth(),
placeholder = {
Text(if (pendingAttachments.isEmpty()) "Message" else "Message (+image)")
},
// No longer "(+image)": the images are on screen above this, and a placeholder
// saying so said it in words beside the thing itself.
placeholder = { Text("Message") },
maxLines = 4,
)
Row(
@@ -1235,7 +1267,8 @@ fun SessionScreen(
)
}
) {
Text(if (pendingAttachments.isEmpty()) "+" else "+${pendingAttachments.size}")
// Just "+" now. The count was standing in for showing them.
Text("+")
}
// The settings share what is left after the actions have
// taken what they need. A Row hands out intrinsic widths in
@@ -1323,7 +1356,13 @@ private fun sendLabel(running: Boolean) = if (running) "Queue" else "Send"
* bitmap is remembered per ref, so scrolling doesn't refetch.
*/
@Composable
private fun UserBubble(text: String, pending: Boolean = false) {
private fun UserBubble(
settings: ServerSettings,
sessionId: String,
text: String,
images: List<String> = emptyList(),
pending: Boolean = false,
) {
Box(Modifier.fillMaxWidth()) {
Card(
// A message the session has not read yet is drawn quieter than
@@ -1338,17 +1377,31 @@ private fun UserBubble(text: String, pending: Boolean = false) {
),
modifier = Modifier.align(Alignment.CenterEnd).padding(start = 48.dp),
) {
Text(
text,
modifier = Modifier.padding(12.dp),
color =
if (pending) MaterialTheme.colorScheme.onSurfaceVariant
else MaterialTheme.colorScheme.onPrimaryContainer,
)
Column(Modifier.padding(12.dp)) {
// Above the words, in the order they were composed: the picture was attached
// before the sentence about it was typed, and it is what the sentence refers to.
images.forEach { ref ->
SessionImage(settings, sessionId, ref)
Spacer(Modifier.height(4.dp))
}
// A message can be nothing but an attachment, and an empty line under a picture
// is a bubble with a gap in it for a sentence nobody wrote.
if (text.isNotEmpty()) {
Text(
text,
color =
if (pending) MaterialTheme.colorScheme.onSurfaceVariant
else MaterialTheme.colorScheme.onPrimaryContainer,
)
}
}
}
}
}
/** A message the server has accepted and the session has not read yet. */
private data class QueuedMessage(val id: String, val text: String, val images: List<String>)
/**
* Collapsed by default: name plus a spinner while running, expandable to the input and output. The
* spinner-while-unfinished is exactly "ToolStart with no matching ToolEnd yet".