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
View File
@@ -269,6 +269,20 @@ first if a remote spawn ever mangles an argument.
be there to import again" is exactly the one the switch makes false. The be there to import again" is exactly the one the switch makes false. The
server deletes the machine's copy *first*, so a machine it cannot reach server deletes the machine's copy *first*, so a machine it cannot reach
leaves the session where it was instead of half-deleted. leaves the session where it was instead of half-deleted.
- **One Claude Code session id can name two files, and the listing offers
it once.** Resuming a session from a different working directory makes
the CLI write a second transcript with the same id under that
directory's project folder -- an ordinary state of a machine, not
corruption. Everything downstream addresses a session by id (`--resume`,
the delete glob, the in-flight registry) and the phone keyed its list on
it, so two rows sharing one *closed the app* on a Compose duplicate-key
throw. `parse_listing` keeps the copy with the most lines, because the
other is usually a few-hundred-byte stub and is often the *newer* of the
two -- so recency is the wrong key. Deleting removes every copy rather
than the first, or the row came back after a delete that reported
success. The phone's half is `uniqueItems`, which every list keyed on a
server-chosen id goes through: a repeat there must never be able to
close the app, whatever produced it.
- **A markdown table wraps its cells and never cuts one off.** The - **A markdown table wraps its cells and never cuts one off.** The
renderer's own defaults draw every cell at one line with an ellipsis, renderer's own defaults draw every cell at one line with an ellipsis,
which on a phone loses most of a table -- and an elided cell looks which on a phone loses most of a table -- and an elided cell looks
@@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults 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. // 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 { } finally {
stream.close() stream.close()
} }
@@ -541,7 +550,7 @@ private fun ImportableList(
Modifier.fillMaxSize(), Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = bottomInset), contentPadding = PaddingValues(bottom = bottomInset),
) { ) {
items(state.value, key = { it.id }) { session -> uniqueItems(state.value, key = { it.id }) { session ->
val picked = session.id in selected val picked = session.id in selected
BusyItem(label = running[session.id]) { BusyItem(label = running[session.id]) {
Card( Card(
@@ -8,7 +8,6 @@ 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.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LinearProgressIndicator
@@ -114,7 +113,8 @@ fun ModelsScreen(settings: ServerSettings, reloadToken: Int) {
is LoadState.Loaded -> { is LoadState.Loaded -> {
if (current.value.downloads.isNotEmpty()) { if (current.value.downloads.isNotEmpty()) {
item { SectionLabel("Downloading") } 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) { DownloadCard(download) {
scope.launch { scope.launch {
actionError = 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) { LocalModelCard(model) {
scope.launch { scope.launch {
actionError = actionError =
@@ -164,7 +164,7 @@ fun ModelsScreen(settings: ServerSettings, reloadToken: Int) {
is LoadState.Error -> is LoadState.Error ->
item { Text(found.message, color = MaterialTheme.colorScheme.error) } item { Text(found.message, color = MaterialTheme.colorScheme.error) }
is LoadState.Loaded -> is LoadState.Loaded ->
items(found.value, key = { it.id }) { repo -> uniqueItems(found.value, key = { it.id }) { repo ->
val open = openRepo == repo.id val open = openRepo == repo.id
RepoRow(repo, expanded = open) { RepoRow(repo, expanded = open) {
if (open) { if (open) {
@@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
@@ -117,7 +116,7 @@ fun SessionListScreen(
.thenByDescending { it.lastActivity } .thenByDescending { it.lastActivity }
) )
LazyColumn { LazyColumn {
items(ordered, key = { it.id }) { session -> uniqueItems(ordered, key = { it.id }) { session ->
SessionCard( SessionCard(
session = session, session = session,
error = deleteErrors[session.id], error = deleteErrors[session.id],
@@ -724,8 +724,15 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
apply(entry) apply(entry)
} }
} }
} catch (e: ApiException) { } catch (e: kotlinx.coroutines.CancellationException) {
streamError = e.message // 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 { } finally {
stream.close() stream.close()
} }
@@ -8,7 +8,6 @@ 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.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator 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.Error -> Text(current.message, color = MaterialTheme.colorScheme.error)
is LoadState.Loaded -> is LoadState.Loaded ->
LazyColumn(Modifier.fillMaxSize()) { LazyColumn(Modifier.fillMaxSize()) {
items(current.value, key = { it.id }) { setup -> uniqueItems(current.value, key = { it.id }) { setup ->
SetupCard( SetupCard(
setup = setup, setup = setup,
onRename = { renaming = setup }, onRename = { renaming = setup },
@@ -47,9 +47,16 @@ class Sse(private val settings: ServerSettings) {
* recovered from, indefinitely. * recovered from, indefinitely.
*/ */
fun run(path: String, onOpen: () -> Unit, onFrame: (name: String?, data: String) -> Unit) { fun run(path: String, onOpen: () -> Unit, onFrame: (name: String?, data: String) -> Unit) {
val connection = URL("${settings.baseUrl}$path").openConnection() as HttpURLConnection // Opening is inside the try, not before it. Everything this method can fail at owes the
this.connection = connection // 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 { try {
connection =
(URL("${settings.baseUrl}$path").openConnection() as HttpURLConnection).also {
this.connection = it
}
connection.applyPinnedTls() connection.applyPinnedTls()
connection.connectTimeout = CONNECT_TIMEOUT_MS connection.connectTimeout = CONNECT_TIMEOUT_MS
// No read timeout: between events there is nothing to read for as long as the thing // 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 { } finally {
connection.disconnect() connection?.disconnect()
this.connection = null 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) }
}
+59 -1
View File
@@ -257,6 +257,30 @@ fn parse_listing(found: &str) -> Result<Vec<Importable>> {
(false, false) => InUse::Unknown, (false, false) => InUse::Unknown,
}; };
} }
// One row per session id, because the id is what everything downstream
// addresses: `--resume` takes it, deleting globs for it, and the
// in-flight registry is keyed on it. So two rows sharing an id are two
// rows that no operation can tell apart -- and the phone keys its list
// on it too, which turned this into a crash rather than a confusion.
//
// It is a real state of the machine, not corruption: resuming a session
// from a different working directory makes the CLI write a second file
// under that directory's project folder with the same id. One of the two
// is then usually a stub of a few hundred bytes and the other is the
// conversation somebody means.
//
// So the copy with the most in it wins, and the row's `cwd` comes from
// that same copy -- which is the directory `--resume` will find it under.
// Ties go to the more recent, and the *stub* is often the more recent, so
// the size has to be the first key rather than the tie-break.
sessions.sort_by(|a, b| {
b.lines
.cmp(&a.lines)
.then_with(|| b.modified.total_cmp(&a.modified))
});
let mut seen = std::collections::HashSet::new();
sessions.retain(|session| seen.insert(session.id.clone()));
// Most recent first, and only that. Naming was tried as the first key // Most recent first, and only that. Naming was tried as the first key
// and is a worse list: it buries what somebody was just doing under // and is a worse list: it buries what somebody was just doing under
// everything they ever named, and the reason to open this screen is // everything they ever named, and the reason to open this screen is
@@ -672,12 +696,15 @@ pub async fn delete(transport: &Transport, id: &str) -> Result<()> {
// `context_of` below already resolved an id the cheap way; this is the // `context_of` below already resolved an id the cheap way; this is the
// same lookup, and the two now agree. // same lookup, and the two now agree.
ensure!(is_session_id(id), "not a Claude Code session id: {id}"); ensure!(is_session_id(id), "not a Claude Code session id: {id}");
// Every copy, not the first. The same id can name a file under two
// project directories -- see the de-duplication in `parse_listing` --
// and stopping at the first left the other behind, so the row came back
// on the next listing after a delete that had reported success.
let script = r#" let script = r#"
for f in "$HOME"/.claude/projects/*/"$1".jsonl; do for f in "$HOME"/.claude/projects/*/"$1".jsonl; do
[ -f "$f" ] || continue [ -f "$f" ] || continue
rm -f "$f" || exit 1 rm -f "$f" || exit 1
printf '%s\n' "$f" printf '%s\n' "$f"
exit 0
done done
"#; "#;
let launch = Launch::new( let launch = Launch::new(
@@ -852,6 +879,37 @@ pub async fn replay_after(
mod tests { mod tests {
use super::*; use super::*;
/// One session id, two files, one row.
///
/// Resuming a session from a different working directory makes the CLI
/// write a second file with the same id under that directory's project
/// folder, so this is an ordinary state of a machine rather than a
/// corrupt one. Everything downstream addresses a session by id, and
/// the phone keys its list on it, so two rows sharing one was a crash.
///
/// The stub is deliberately the *newer* of the two here, because that
/// is how the real case looked: ordering by recency alone picks the
/// near-empty copy and describes the session by the wrong cwd.
#[test]
fn a_session_recorded_under_two_projects_is_offered_once() {
let id = "3114dee1-2f95-4de0-9c04-3d6fcc594afe";
let said = r#"{"cwd":"/home/bob/repos/survey","message":{"role":"user","content":"the real conversation"}}"#;
let stub = r#"{"cwd":"/home/bob/repos/elsewhere","message":{"role":"user","content":"resumed here once"}}"#;
let listing = format!(
"LIVEKNOWN\n\
1000.0\t412\t160638\t\t/home/bob/.claude/projects/-home-bob-repos-survey/{id}.jsonl\t{said}\n\
2000.0\t4\t614\t\t/home/bob/.claude/projects/-home-bob-repos-elsewhere/{id}.jsonl\t{stub}\n"
);
let rows = parse_listing(&listing).expect("parse");
assert_eq!(rows.len(), 1, "one id is one row: {rows:#?}");
assert_eq!(rows[0].lines, 412, "the conversation, not the stub");
// The cwd has to come from the copy that was kept, because that is
// the directory `--resume` will find those 412 lines under.
assert_eq!(rows[0].cwd, "/home/bob/repos/survey");
}
/// The guard on the only thing this module ever puts in a glob. /// The guard on the only thing this module ever puts in a glob.
/// ///
/// Worth a test of its own because what it protects is a `rm`: `delete` /// Worth a test of its own because what it protects is a `rm`: `delete`