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

This commit is contained in:
iris committed 2026-08-31 22:20:51 -04:00
commit c9d74b63f2
9 files changed
+154 -17

No files matched your search

@@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
@@ -323,8 +322,18 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
}
}
}
} catch (_: ApiException) {
} catch (e: kotlinx.coroutines.CancellationException) {
// The screen leaving, not a failure -- and swallowing it would leave this
// loop reconnecting to a stream nobody is watching.
throw e
} catch (_: Exception) {
// Retried below; the listing is the truth in the meantime.
//
// Any failure, not only an [ApiException]. A stream is an optimisation over
// the listing here, so nothing it can do is worth taking the app down for --
// and catching only the failure that was expected means an unexpected one
// reaches the top of the app and closes it, from a screen that is merely
// loading a list.
} finally {
stream.close()
}
@@ -541,7 +550,7 @@ private fun ImportableList(
Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = bottomInset),
) {
items(state.value, key = { it.id }) { session ->
uniqueItems(state.value, key = { it.id }) { session ->
val picked = session.id in selected
BusyItem(label = running[session.id]) {
Card(
@@ -8,7 +8,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.LinearProgressIndicator
@@ -114,7 +113,8 @@ fun ModelsScreen(settings: ServerSettings, reloadToken: Int) {
is LoadState.Loaded -> {
if (current.value.downloads.isNotEmpty()) {
item { SectionLabel("Downloading") }
items(current.value.downloads, key = { it.key + it.run }) { download ->
uniqueItems(current.value.downloads, key = { it.key + it.run }) { download
->
DownloadCard(download) {
scope.launch {
actionError =
@@ -139,7 +139,7 @@ fun ModelsScreen(settings: ServerSettings, reloadToken: Int) {
)
}
}
items(current.value.local, key = { it.key }) { model ->
uniqueItems(current.value.local, key = { it.key }) { model ->
LocalModelCard(model) {
scope.launch {
actionError =
@@ -164,7 +164,7 @@ fun ModelsScreen(settings: ServerSettings, reloadToken: Int) {
is LoadState.Error ->
item { Text(found.message, color = MaterialTheme.colorScheme.error) }
is LoadState.Loaded ->
items(found.value, key = { it.id }) { repo ->
uniqueItems(found.value, key = { it.id }) { repo ->
val open = openRepo == repo.id
RepoRow(repo, expanded = open) {
if (open) {
@@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
@@ -117,7 +116,7 @@ fun SessionListScreen(
.thenByDescending { it.lastActivity }
)
LazyColumn {
items(ordered, key = { it.id }) { session ->
uniqueItems(ordered, key = { it.id }) { session ->
SessionCard(
session = session,
error = deleteErrors[session.id],
@@ -724,8 +724,15 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
apply(entry)
}
}
} catch (e: ApiException) {
streamError = e.message
} catch (e: kotlinx.coroutines.CancellationException) {
// Leaving the screen or going below STARTED. Not a failure, and
// swallowing it would leave this loop reconnecting forever.
throw e
} catch (e: Exception) {
// Any failure, not only an [ApiException]: the stream reconnects from its
// cursor, so there is nothing a failure here can cost that is worth
// closing the app over. Reported on the screen either way.
streamError = e.message ?: e::class.simpleName
} finally {
stream.close()
}
@@ -8,7 +8,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
@@ -83,7 +82,7 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
is LoadState.Error -> Text(current.message, color = MaterialTheme.colorScheme.error)
is LoadState.Loaded ->
LazyColumn(Modifier.fillMaxSize()) {
items(current.value, key = { it.id }) { setup ->
uniqueItems(current.value, key = { it.id }) { setup ->
SetupCard(
setup = setup,
onRename = { renaming = setup },
@@ -47,9 +47,16 @@ class Sse(private val settings: ServerSettings) {
* recovered from, indefinitely.
*/
fun run(path: String, onOpen: () -> Unit, onFrame: (name: String?, data: String) -> Unit) {
val connection = URL("${settings.baseUrl}$path").openConnection() as HttpURLConnection
this.connection = connection
// Opening is inside the try, not before it. Everything this method can fail at owes the
// caller the same kind of failure -- both callers retry an [ApiException] and let anything
// else reach the top of the app -- and a connection that could not even be constructed
// used to escape as a raw `IOException` from a line no `catch` covered.
var connection: HttpURLConnection? = null
try {
connection =
(URL("${settings.baseUrl}$path").openConnection() as HttpURLConnection).also {
this.connection = it
}
connection.applyPinnedTls()
connection.connectTimeout = CONNECT_TIMEOUT_MS
// No read timeout: between events there is nothing to read for as long as the thing
@@ -90,7 +97,7 @@ class Sse(private val settings: ServerSettings) {
)
}
} finally {
connection.disconnect()
connection?.disconnect()
this.connection = null
}
}
@@ -0,0 +1,44 @@
package com.example.aiapp
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
/**
* Keyed [items], with anything repeating a key already used left out.
*
* A lazy list throws when two of its items claim the same key, and the throw happens during measure
* on the main thread -- so it is not an error the screen can show, it closes the app. That is a
* disproportionate answer to a list with a repeat in it, and it lands on the reader rather than on
* whoever produced the repeat: on 2026-08-31 the import list crashed on a Claude Code session id
* recorded under two project directories, which is an ordinary state of a machine and not something
* the phone did.
*
* Every list in this app keyed on an id keyed it on an id *the server chose*, so all of them shared
* the hazard and none of them could rule it out locally. Hence one function they all go through
* rather than a `distinctBy` remembered at each call site.
*
* Dropping the repeat is the right answer here because the key is the whole identity: two rows with
* one id are two rows every action would treat as the same thing, so there is nothing to show about
* the second that the first is not already showing. Where the duplicate means something -- the
* import list's did -- the fix belongs at the source, and this is only what stops a data problem
* from being a crash. It is counted so the render report says it happened rather than leaving a
* silently shorter list.
*
* The transcript's own list is deliberately not on this: its keys are made here rather than
* received, and it is the one list where an extra pass over the items is measurable.
*/
inline fun <T> LazyListScope.uniqueItems(
items: List<T>,
crossinline key: (T) -> Any,
noinline contentType: (T) -> Any? = { null },
crossinline itemContent: @Composable LazyItemScope.(T) -> Unit,
) {
val seen = HashSet<Any>(items.size)
val unique = items.filter { seen.add(key(it)) }
if (unique.size != items.size) {
DebugStats.count("list items dropped for a repeated key")
}
items(unique, key = { key(it) }, contentType = contentType) { itemContent(it) }
}