From cae04c255992a96f163944387c904ba0882d45b4 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Sat, 29 Aug 2026 16:11:18 -0400 Subject: [PATCH] Delete one session without putting the rest through loading Pressing Delete refetched the whole list on success, so every other row went back through its loading state and the reader got a blank screen for the length of a round trip -- to report on something that was never in doubt. Now the row being deleted fades, says so where its status goes, and stops responding to taps; when the server answers, that one row is removed and nothing else moves. A refusal keeps the row, because it is still there: the server answered and said no, so the session it said no about is exactly as it was, and the error goes on its own card as it already did. Faded rather than removed on the way out, deliberately. Taking the row away when Delete is pressed is a promise about a request that has not been answered, and putting it back when the server refuses is worse than never having taken it away. Looked at rather than reasoned about: the in-flight state lasts milliseconds against a local server, so I slowed the delete route to four seconds, watched the faded row and its spinner, watched it removed on success, then killed the server and watched a refusal leave the row in place with the reason on it. --- .../com/example/aiapp/SessionListScreen.kt | 69 ++++++++++++++++++- 1 file changed, 66 insertions(+), 3 deletions(-) diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt index 9816659..732633a 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt @@ -29,6 +29,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.unit.dp import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -67,6 +68,11 @@ fun SessionListScreen( // fetched next. var deleteErrors by remember { mutableStateOf>(emptyMap()) } + // Which sessions have a delete in flight. A set of ids rather than a flag on the row, + // because the rows are rebuilt from whatever the server last said and this belongs to the + // request rather than to the session. + var deleting by remember { mutableStateOf>(emptySet()) } + fun refresh() { listState = LoadState.Loading scope.launch { @@ -138,6 +144,7 @@ fun SessionListScreen( SessionCard( session = session, error = deleteErrors[session.id], + deleting = session.id in deleting, onOpen = { onOpen(session) }, onLongPress = { confirmingDelete = session }, ) @@ -177,13 +184,32 @@ fun SessionListScreen( TextButton( onClick = { confirmingDelete = null + // Marked here rather than after the request returns: the row has to say + // something is happening to it from the moment it is asked for, which + // is the whole of what this state is for. + deleting = deleting + session.id + deleteErrors = deleteErrors - session.id scope.launch { try { withContext(Dispatchers.IO) { deleteSession(settings, session.id) } - refresh() + // Only this row, and only what changed. Refetching the list + // instead put every other session back through loading and + // handed the reader an empty screen -- to report on something + // that was never in doubt. + val loaded = listState + if (loaded is LoadState.Loaded) { + listState = + LoadState.Loaded( + loaded.value.filterNot { it.id == session.id } + ) + } } catch (e: ApiException) { + // Kept, because it is still there: the server refused, so the + // session it refused about is exactly as it was. deleteErrors = deleteErrors + (session.id to (e.message ?: "Delete failed")) + } finally { + deleting = deleting - session.id } } } @@ -204,10 +230,25 @@ private fun SessionCard( session: SessionSummary, /** What went wrong acting on *this* session, if anything has. */ error: String?, + /** + * Whether this session is being deleted right now. + * + * Faded and inert while it is, which says the row is on its way out without claiming it has + * gone: a row removed the moment Delete is pressed is a promise about a request that has not + * been answered yet, and putting it back when the server refuses is worse than never having + * taken it away. + */ + deleting: Boolean, onOpen: () -> Unit, onLongPress: () -> Unit, ) { - Card(Modifier.fillMaxWidth().combinedClickable(onClick = onOpen, onLongClick = onLongPress)) { + Card( + Modifier.fillMaxWidth() + .alpha(if (deleting) 0.45f else 1f) + // Not just faded: a card that still opens a session it is deleting is a race the + // reader can start by tapping. + .combinedClickable(enabled = !deleting, onClick = onOpen, onLongClick = onLongPress) + ) { Column(Modifier.padding(16.dp)) { Row( verticalAlignment = Alignment.CenterVertically, @@ -218,7 +259,9 @@ private fun SessionCard( style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f), ) - StatusText(session.status) + // In the status's own place, because that is what it is: what this session is + // doing now, which is being deleted. + if (deleting) DeletingMark() else StatusText(session.status) } Spacer(Modifier.height(4.dp)) Row(modifier = Modifier.fillMaxWidth()) { @@ -255,6 +298,26 @@ private fun SessionCard( } } +/** A session on its way out: the same shape as a status, because that is the slot it fills. */ +@Composable +private fun DeletingMark() { + Row(verticalAlignment = Alignment.CenterVertically) { + CircularProgressIndicator( + modifier = Modifier.width(14.dp).height(14.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.width(6.dp)) + // Not in the error colour, though it is destructive: red here means something went + // wrong on its own, and this is going exactly as asked. + Text( + "deleting", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + @Composable fun StatusText(status: String) { val (label, color) =