Merge branch 'main' of git.arirex.me:iris/ai-app
This commit is contained in:
commit
c9d74b63f2
9 files changed
+154
-17
No files matched your search
@@ -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) }
|
||||||
|
}
|
||||||
@@ -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`
|
||||||
|
|||||||
Reference in new issue
Block a user