@@ -1,5 +1,6 @@
11package soy.iko.opencode.ui.session
22
3+import androidx.compose.foundation.background
34import androidx.compose.foundation.clickable
45import androidx.compose.foundation.layout.Arrangement
56import androidx.compose.foundation.layout.Box
@@ -41,10 +42,12 @@ import androidx.compose.material3.OutlinedTextField
4142import androidx.compose.material3.Scaffold
4243import androidx.compose.material3.SnackbarHost
4344import androidx.compose.material3.SnackbarHostState
45+import androidx.compose.material3.SwipeToDismissBox
4446import androidx.compose.material3.Text
4547import androidx.compose.material3.TextButton
4648import androidx.compose.material3.TopAppBar
4749import androidx.compose.material3.pulltorefresh.PullToRefreshBox
50+import androidx.compose.material3.rememberSwipeToDismissBoxState
4851import androidx.compose.runtime.Composable
4952import androidx.compose.runtime.LaunchedEffect
5053import androidx.compose.runtime.getValue
@@ -53,6 +56,7 @@ import androidx.compose.runtime.remember
5356import androidx.compose.runtime.setValue
5457import androidx.compose.ui.Alignment
5558import androidx.compose.ui.Modifier
59+import androidx.compose.ui.draw.clip
5660import androidx.compose.ui.platform.LocalHapticFeedback
5761import androidx.compose.ui.semantics.Role
5862import androidx.compose.ui.semantics.role
@@ -66,6 +70,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
6670import soy.iko.opencode.data.model.Session
6771import soy.iko.opencode.di.AppContainer
6872import soy.iko.opencode.R
73+import soy.iko.opencode.ui.components.ConnectionBanner
6974import soy.iko.opencode.ui.components.relativeTime
7075import soy.iko.opencode.ui.vmFactory
7176
@@ -85,6 +90,8 @@ fun SessionListScreen(
8590 val serverLabel by vm.serverLabel.collectAsStateWithLifecycle()
8691 val profiles by vm.profiles.collectAsStateWithLifecycle()
8792 val switchingId by vm.switchingId.collectAsStateWithLifecycle()
93+ val connectionState by vm.connectionState.collectAsStateWithLifecycle()
94+ val unread by vm.unread.collectAsStateWithLifecycle()
8895 val haptics = LocalHapticFeedback.current
8996 val snackbar = remember { SnackbarHostState() }
9097 var showServerMenu by remember { mutableStateOf(false) }
@@ -176,6 +183,10 @@ fun SessionListScreen(
176183 },
177184 ) { padding ->
178185 Box(modifier = Modifier.fillMaxSize().padding(padding)) {
186+ ConnectionBanner(
187+ state = connectionState,
188+ modifier = Modifier.align(Alignment.TopCenter),
189+ )
179190 when {
180191 state.loading -> CircularProgressIndicator(Modifier.align(Alignment.Center))
181192 state.sessions.isEmpty() && state.error != null -> Text(
@@ -219,6 +230,7 @@ fun SessionListScreen(
219230 SessionCard(
220231 session = session,
221232 preview = state.previews[session.id],
233+ unread = unread.contains(session.id),
222234 onClick = { onOpenSession(session.id) },
223235 onRename = { pendingRename = session },
224236 onDelete = { pendingDelete = session },
@@ -296,61 +308,108 @@ private fun RenameSessionDialog(
296308 )
297309}
298310
311+@OptIn(ExperimentalMaterial3Api::class)
299312@Composable
300313private fun SessionCard(
301314 session: Session,
302315 preview: String?,
316+ unread: Boolean,
303317 onClick: () -> Unit,
304318 onRename: () -> Unit,
305319 onDelete: () -> Unit,
306320 modifier: Modifier = Modifier,
307321) {
308− Card(
309− modifier = modifier
310− .fillMaxWidth()
311− .clickable { onClick() },
322+ // Swipe toward the start reveals a delete affordance; releasing snaps back and opens
323+ // the same confirmation dialog the trash icon uses, so the destructive action is
324+ // always confirmed.
325+ val dismissState = rememberSwipeToDismissBoxState(
326+ confirmValueChange = {
327+ onDelete()
328+ false // snap back; the dialog owns the actual deletion
329+ },
330+ )
331+ SwipeToDismissBox(
332+ state = dismissState,
333+ backgroundContent = { SwipeDeleteBackground() },
334+ enableDismissFromStartToEnd = false,
335+ modifier = modifier,
312336 ) {
313− Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
314− Row(
315− modifier = Modifier.fillMaxWidth(),
316− verticalAlignment = Alignment.CenterVertically,
317− ) {
318− Text(
319− session.displayTitle,
320− style = MaterialTheme.typography.titleMedium,
321− maxLines = 1,
322− overflow = TextOverflow.Ellipsis,
323− modifier = Modifier.weight(1f),
324− )
325− val time = relativeTime(session.time?.updated ?: session.time?.created)
326− if (time.isNotEmpty()) {
337+ Card(
338+ modifier = Modifier
339+ .fillMaxWidth()
340+ .clickable { onClick() },
341+ ) {
342+ Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
343+ Row(
344+ modifier = Modifier.fillMaxWidth(),
345+ verticalAlignment = Alignment.CenterVertically,
346+ ) {
347+ if (unread) {
348+ Box(
349+ modifier = Modifier
350+ .padding(end = 8.dp)
351+ .size(8.dp)
352+ .clip(androidx.compose.foundation.shape.CircleShape)
353+ .background(MaterialTheme.colorScheme.primary),
354+ )
355+ }
327356 Text(
328− time,
329− style = MaterialTheme.typography.labelSmall,
330− color = MaterialTheme.colorScheme.onSurfaceVariant,
357+ session.displayTitle,
358+ style = MaterialTheme.typography.titleMedium,
359+ color = if (unread) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
360+ maxLines = 1,
361+ overflow = TextOverflow.Ellipsis,
362+ modifier = Modifier.weight(1f),
331363 )
364+ val time = relativeTime(session.time?.updated ?: session.time?.created)
365+ if (time.isNotEmpty()) {
366+ Text(
367+ time,
368+ style = MaterialTheme.typography.labelSmall,
369+ color = MaterialTheme.colorScheme.onSurfaceVariant,
370+ )
371+ }
372+ IconButton(onClick = onRename) {
373+ Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.rename))
374+ }
375+ IconButton(onClick = onDelete) {
376+ Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete))
377+ }
332378 }
333− IconButton(onClick = onRename) {
334− Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.rename))
335− }
336− IconButton(onClick = onDelete) {
337− Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete))
379+ if (!preview.isNullOrBlank()) {
380+ Spacer(Modifier.size(4.dp))
381+ Text(
382+ preview,
383+ style = MaterialTheme.typography.bodyMedium,
384+ color = if (unread) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
385+ maxLines = 2,
386+ overflow = TextOverflow.Ellipsis,
387+ )
338388 }
339389 }
340− if (!preview.isNullOrBlank()) {
341− Spacer(Modifier.size(4.dp))
342− Text(
343− preview,
344− style = MaterialTheme.typography.bodyMedium,
345− color = MaterialTheme.colorScheme.onSurfaceVariant,
346− maxLines = 2,
347− overflow = TextOverflow.Ellipsis,
348− )
349− }
350390 }
351391 }
352392}
353393
394+/** Red background with a trash icon shown behind a session card as it is swiped away. */
395+@Composable
396+private fun SwipeDeleteBackground() {
397+ Box(
398+ modifier = Modifier
399+ .fillMaxSize()
400+ .clip(androidx.compose.foundation.shape.RoundedCornerShape(12.dp))
401+ .background(MaterialTheme.colorScheme.errorContainer)
402+ .padding(horizontal = 20.dp),
403+ contentAlignment = Alignment.CenterEnd,
404+ ) {
405+ Icon(
406+ Icons.Filled.Delete,
407+ contentDescription = null,
408+ tint = MaterialTheme.colorScheme.onErrorContainer,
409+ )
410+ }
411+}
412+
354413@Composable
355414private fun EmptySessions(onCreate: () -> Unit, modifier: Modifier = Modifier) {
356415 Column(