Offer a session once when two project folders hold it, and never crash on a repeat

Resuming a Claude Code session from a different working directory makes the
CLI write a second transcript with the same id under that directory's
project folder. This machine has one: 160 KB under `-home-bob-repos-tdep-survey`
and a 614-byte stub under `-home-bob-repos-tdep`. Everything downstream
addresses a session by id -- `--resume` takes it, the delete glob resolves
it, the in-flight registry is keyed on it -- so two rows sharing an id are
two rows no operation can tell apart, and the phone keys its list on it, so
scrolling to them closed the app on Compose's duplicate-key throw.

The listing now keeps the copy with the most in it. Size rather than
recency, because the stub is often the newer of the two, and picking it
describes the session by the wrong size, the wrong cwd and the wrong title.
Deleting removes every copy rather than stopping at the first, which had
left the row to come back on the next listing after a delete that reported
success.

The phone's half is `uniqueItems`: every list keyed on a server-chosen id
goes through it, since none of them could rule the repeat out locally and a
data problem must not be able to close the app. Verified both ways against
the real duplicate -- the unguarded build reproduces the reported stack on
the same id, the guarded one scrolls the whole list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-08-31 22:12:49 -04:00
1 parent 8257030280
commit 7a8811aab3
7 files changed
+124 -11

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
@@ -551,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],
@@ -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 },
@@ -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) }
}