Put a refused delete on the session it was refused for

Deleting a session the server no longer has replaced the whole list with an
error. The server answered -- it said "no session <id>" -- so what it told
us is about one row, and every other row was still exactly as fetched. The
list going away said otherwise.

The line between the two scopes is whether the server answered. Answered
and refused is about one item and belongs on that item; never answered
leaves every row stale against a server that has stopped talking, and that
is the list's own state to report. Both cases exist here and had been
sharing one slot, which is why the message had to hedge about which it was.

So failures acting on one session live in a map keyed by its id and render
inside its card. The path out is the next successful load, which clears the
map: an entry would otherwise outlive the session it names and reappear
against whatever the phone fetched next.

The shape is dev-updater's `cardStates`, which solved this first; the rule
above is theirs too, and it moves their "gave up waiting" case the other
way, to list-level, which is a good sign it is a rule rather than a
description of what either of us already had.

Verified on the emulator by producing the real failure -- delete a session
out from under the app with curl, then delete its stale row from the phone.
The refusal appears on that card, the other card is untouched, the list
stays, and Refresh clears it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
This commit is contained in:
irisandClaude Opus 5 committed 2026-08-28 03:33:47 -04:00
1 parent 9e11e860e5
commit 7a02e79613
1 file changed
+30 -2
@@ -57,11 +57,25 @@ fun SessionListScreen(
var listState by remember { mutableStateOf<LoadState<List<SessionSummary>>>(LoadState.Loading) }
var confirmingDelete by remember { mutableStateOf<SessionSummary?>(null) }
// Failures that belong to one session rather than to the list, keyed by
// its id and shown on its own card. The two scopes are decided by
// whether the server answered: it answered and refused, so this says
// nothing about the other rows, where a server that has stopped
// answering leaves every row stale and is `listState`'s to report.
//
// Cleared on the next successful load below -- an entry outlives its
// session otherwise, and would reappear against whatever the phone
// fetched next.
var deleteErrors by remember { mutableStateOf<Map<String, String>>(emptyMap()) }
fun refresh() {
listState = LoadState.Loading
scope.launch {
listState = try {
withContext(Dispatchers.IO) { LoadState.Loaded(fetchSessions(settings)) }
val loaded =
withContext(Dispatchers.IO) { LoadState.Loaded(fetchSessions(settings)) }
deleteErrors = emptyMap()
loaded
} catch (e: ApiException) {
LoadState.failed(e)
}
@@ -114,6 +128,7 @@ fun SessionListScreen(
items(ordered, key = { it.id }) { session ->
SessionCard(
session = session,
error = deleteErrors[session.id],
onOpen = { onOpen(session) },
onLongPress = { confirmingDelete = session },
)
@@ -143,7 +158,8 @@ fun SessionListScreen(
withContext(Dispatchers.IO) { deleteSession(settings, session.id) }
refresh()
} catch (e: ApiException) {
listState = LoadState.Error(e.message ?: "Delete failed")
deleteErrors =
deleteErrors + (session.id to (e.message ?: "Delete failed"))
}
}
}) { Text("Delete") }
@@ -159,6 +175,8 @@ fun SessionListScreen(
@Composable
private fun SessionCard(
session: SessionSummary,
/** What went wrong acting on *this* session, if anything has. */
error: String?,
onOpen: () -> Unit,
onLongPress: () -> Unit,
) {
@@ -194,6 +212,16 @@ private fun SessionCard(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
error?.let {
Spacer(Modifier.height(8.dp))
// The server's own words, unprefixed, the way every other
// failure in this app is shown.
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
}
}
}