@@ -4,6 +4,7 @@ import androidx.compose.foundation.background
44import androidx.activity.compose.BackHandler
55import androidx.compose.foundation.clickable
66import androidx.compose.foundation.combinedClickable
7+import androidx.compose.foundation.horizontalScroll
78import androidx.compose.foundation.layout.Arrangement
89import androidx.compose.foundation.layout.Box
910import androidx.compose.foundation.layout.Column
@@ -19,6 +20,7 @@ import androidx.compose.foundation.layout.padding
1920import androidx.compose.foundation.layout.size
2021import androidx.compose.foundation.lazy.LazyColumn
2122import androidx.compose.foundation.lazy.items
23+import androidx.compose.foundation.rememberScrollState
2224import androidx.compose.foundation.text.KeyboardActions
2325import androidx.compose.foundation.text.KeyboardOptions
2426import androidx.compose.material.icons.Icons
@@ -55,6 +57,7 @@ import androidx.compose.material3.DropdownMenu
5557import androidx.compose.material3.DropdownMenuItem
5658import androidx.compose.material3.ExperimentalMaterial3Api
5759import androidx.compose.material3.ExtendedFloatingActionButton
60+import androidx.compose.material3.FilterChip
5861import androidx.compose.material3.Icon
5962import androidx.compose.material3.IconButton
6063import androidx.compose.material3.MaterialTheme
@@ -118,7 +121,10 @@ import soy.iko.opencode.platform.SessionsWidgetProvider
118121import soy.iko.opencode.R
119122import soy.iko.opencode.ui.components.ConnectionBanner
120123import soy.iko.opencode.ui.components.EmptyState
124+import soy.iko.opencode.ui.components.LoadingSize
125+import soy.iko.opencode.ui.components.LoadingSpinner
121126import soy.iko.opencode.ui.components.LocalRelativeTimeTick
127+import soy.iko.opencode.ui.components.copyToClipboard
122128import soy.iko.opencode.ui.components.reducedMotionAnimateItem
123129import soy.iko.opencode.ui.components.RelativeTimeText
124130import soy.iko.opencode.ui.components.rememberRelativeTimeTick
@@ -151,6 +157,7 @@ fun SessionListScreen(
151157 val creating by vm.creating.collectAsStateWithLifecycle()
152158 val directoryOptions by vm.directoryOptions.collectAsStateWithLifecycle()
153159 val anyRunActive by container.anyRunActive.collectAsStateWithLifecycle()
160+ val runningSessionIds by container.runningSessionIds.collectAsStateWithLifecycle()
154161 val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
155162 val isOnline by container.isOnline.collectAsStateWithLifecycle()
156163 val connectedId = activeConnection?.profile?.id
@@ -221,6 +228,12 @@ fun SessionListScreen(
221228 }
222229 }
223230
231+ LaunchedEffect(Unit) {
232+ vm.transientInfo.collect { msg ->
233+ snackbar.showSnackbar(msg)
234+ }
235+ }
236+
224237 // Retryable failures (create/rename): offer a Retry action so a transient network error
225238 // is recoverable in one tap instead of forcing the user back through the dialog/FAB.
226239 LaunchedEffect(Unit) {
@@ -275,6 +288,7 @@ fun SessionListScreen(
275288 }
276289
277290 SessionActionUndoEffect(vm, snackbar)
291+ BulkSessionActionUndoEffect(vm, snackbar)
278292
279293 Scaffold(
280294 topBar = {
@@ -442,7 +456,9 @@ fun SessionListScreen(
442456 refreshing = refreshing,
443457 haptics = haptics,
444458 selectedSessionId = selectedSessionId,
459+ connected = activeConnection != null,
445460 onRefresh = vm::refresh,
461+ onReconnect = vm::retryConnection,
446462 onQueryChange = vm::setQuery,
447463 onOpenSession = onOpenSession,
448464 onCreateSession = openNewSession,
@@ -453,9 +469,12 @@ fun SessionListScreen(
453469 onMarkUnread = { vm.markUnread(it) },
454470 onToggleMute = { container.toggleSessionMute(it) },
455471 onFilterByDirectory = vm::setDirectoryFilter,
472+ onClearDirectoryFilter = { vm.setDirectoryFilter(null) },
473+ onShowArchived = { vm.setShowArchived(true) },
456474 inSelection = inSelection,
457475 selectedIds = selectedIds,
458476 onToggleSelect = ::toggleSelected,
477+ runningSessionIds = runningSessionIds,
459478 )
460479 }
461480 }
@@ -555,7 +574,9 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
555574 refreshing: Boolean,
556575 haptics: androidx.compose.ui.hapticfeedback.HapticFeedback,
557576 selectedSessionId: String?,
577+ connected: Boolean,
558578 onRefresh: () -> Unit,
579+ onReconnect: () -> Unit,
559580 onQueryChange: (String) -> Unit,
560581 onOpenSession: (String) -> Unit,
561582 onCreateSession: () -> Unit,
@@ -566,9 +587,12 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
566587 onMarkUnread: (String) -> Unit,
567588 onToggleMute: (String) -> Unit,
568589 onFilterByDirectory: (String) -> Unit,
590+ onClearDirectoryFilter: () -> Unit,
591+ onShowArchived: () -> Unit,
569592 inSelection: Boolean,
570593 selectedIds: Set<String>,
571594 onToggleSelect: (String) -> Unit,
595+ runningSessionIds: Set<String> = emptySet(),
572596) {
573597 when {
574598 state.loading -> {
@@ -607,7 +631,7 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
607631 textAlign = androidx.compose.ui.text.style.TextAlign.Center,
608632 )
609633 Spacer(Modifier.size(12.dp))
610− TextButton(onClick = onRefresh) {
634+ TextButton(onClick = if (connected) onRefresh else onReconnect) {
611635 Text(stringResource(R.string.retry))
612636 }
613637 }
@@ -644,11 +668,45 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
644668 keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
645669 )
646670 }
671+ // List-level indicator that a directory filter is scoping the list. Tapping the
672+ // trailing X clears it (equivalent to tapping the active card chip again). The
673+ // Row scrolls horizontally so a long path doesn't clip the clear action.
674+ state.directoryFilter?.let { dir ->
675+ Row(
676+ modifier = Modifier
677+ .fillMaxWidth()
678+ .horizontalScroll(rememberScrollState())
679+ .padding(horizontal = 16.dp, vertical = 4.dp),
680+ ) {
681+ FilterChip(
682+ selected = true,
683+ onClick = onClearDirectoryFilter,
684+ label = { Text(stringResource(R.string.directory_filter_chip, dir)) },
685+ trailingIcon = {
686+ Icon(
687+ Icons.Filled.Close,
688+ contentDescription = stringResource(R.string.clear_filter),
689+ )
690+ },
691+ )
692+ }
693+ }
647694 val sessions = remember(
648695 state.sessions, state.query, state.previews,
649696 state.pinnedIds, state.archivedIds, state.showArchived,
650697 ) { state.filtered }
651698 if (sessions.isEmpty()) {
699+ // Distinguish why the list is empty so the offered recovery action matches
700+ // the active filter: a query clears the search, a directory filter clears
701+ // the project scope, and an all-archived list reveals archived sessions.
702+ val message = when {
703+ state.query.isNotEmpty() ->
704+ stringResource(R.string.no_sessions_match, state.query)
705+ state.directoryFilter != null ->
706+ stringResource(R.string.no_sessions_in_directory)
707+ else ->
708+ stringResource(R.string.no_sessions_archived_hidden)
709+ }
652710 Box(
653711 modifier = Modifier.fillMaxSize(),
654712 contentAlignment = Alignment.Center,
@@ -665,16 +723,29 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
665723 )
666724 Spacer(Modifier.size(12.dp))
667725 Text(
668− stringResource(R.string.no_sessions_match, state.query),
726+ message,
669727 style = MaterialTheme.typography.bodyMedium,
670728 color = MaterialTheme.colorScheme.onSurfaceVariant,
729+ textAlign = androidx.compose.ui.text.style.TextAlign.Center,
671730 )
672− // An empty filtered list is recoverable in one tap; offer it inline
673− // rather than making the user hunt for the field's trailing clear icon.
674− if (state.query.isNotEmpty()) {
675− Spacer(Modifier.size(8.dp))
676− TextButton(onClick = { onQueryChange("") }) {
677− Text(stringResource(R.string.clear_search))
731+ when {
732+ state.query.isNotEmpty() -> {
733+ Spacer(Modifier.size(8.dp))
734+ TextButton(onClick = { onQueryChange("") }) {
735+ Text(stringResource(R.string.clear_search))
736+ }
737+ }
738+ state.directoryFilter != null -> {
739+ Spacer(Modifier.size(8.dp))
740+ TextButton(onClick = onClearDirectoryFilter) {
741+ Text(stringResource(R.string.clear_filter))
742+ }
743+ }
744+ state.hiddenArchivedCount > 0 -> {
745+ Spacer(Modifier.size(8.dp))
746+ TextButton(onClick = onShowArchived) {
747+ Text(stringResource(R.string.show_archived))
748+ }
678749 }
679750 }
680751 }
@@ -823,6 +894,7 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
823894 isPinned = isPinned,
824895 isArchived = isArchived,
825896 isMuted = isMuted,
897+ isRunning = session.id in runningSessionIds,
826898 onClick = onCardClick,
827899 onRename = onCardRename,
828900 onDelete = onCardDelete,
@@ -988,6 +1060,7 @@ private fun SessionCard(
9881060 isSelected: Boolean = false,
9891061 isPinned: Boolean = false,
9901062 isArchived: Boolean = false,
1063+ isRunning: Boolean = false,
9911064 onFilterByDirectory: ((String) -> Unit)? = null,
9921065 isDirectoryFiltered: Boolean = false,
9931066 inSelection: Boolean = false,
@@ -1045,6 +1118,15 @@ private fun SessionCard(
10451118 }
10461119 Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) {
10471120 Row(verticalAlignment = Alignment.CenterVertically) {
1121+ if (isRunning) {
1122+ val runningLabel = stringResource(R.string.session_running_badge)
1123+ LoadingSpinner(
1124+ size = LoadingSize.Inline,
1125+ modifier = Modifier
1126+ .padding(end = 8.dp)
1127+ .semantics { contentDescription = runningLabel },
1128+ )
1129+ }
10481130 if (unreadCount > 0 && !isMuted) {
10491131 val unreadLabel = stringResource(R.string.unread_count, unreadCount)
10501132 // Count badge: shows the number of unread messages so the user
@@ -1198,6 +1280,9 @@ private fun SessionCard(
11981280 val archiveLabel = stringResource(if (isArchived) R.string.session_unarchive else R.string.session_archive)
11991281 val markUnreadLabel = stringResource(R.string.session_mark_unread)
12001282 val muteLabel = stringResource(if (isMuted) R.string.session_unmute else R.string.session_mute)
1283+ val copyIdLabel = stringResource(R.string.copy_session_id)
1284+ val rowContext = LocalContext.current
1285+ val clipLabelSessionId = stringResource(R.string.clip_label_session_id)
12011286 Box {
12021287 IconButton(onClick = { showRowMenu = true }) {
12031288 Icon(Icons.Filled.MoreVert, contentDescription = moreLabel)
@@ -1226,6 +1311,13 @@ private fun SessionCard(
12261311 text = { Text(muteLabel) },
12271312 onClick = { showRowMenu = false; onToggleMute() },
12281313 )
1314+ DropdownMenuItem(
1315+ text = { Text(copyIdLabel) },
1316+ onClick = {
1317+ showRowMenu = false
1318+ copyToClipboard(rowContext, clipLabelSessionId, session.id)
1319+ },
1320+ )
12291321 DropdownMenuItem(
12301322 text = { Text(renameLabel) },
12311323 onClick = { showRowMenu = false; onRename() },
@@ -1320,6 +1412,32 @@ private fun SessionActionUndoEffect(vm: SessionListViewModel, snackbar: Snackbar
13201412 }
13211413}
13221414
1415+// Confirmation + Undo for bulk archive actions. Mirrors SessionActionUndoEffect but for
1416+// the bulk path, so archiving N sessions at once shows a single snackbar (not N) with Undo.
1417+@Composable
1418+private fun BulkSessionActionUndoEffect(vm: SessionListViewModel, snackbar: SnackbarHostState) {
1419+ val undoLabel = stringResource(R.string.undo)
1420+ val archived = stringResource(R.string.bulk_archived)
1421+ val unarchived = stringResource(R.string.bulk_unarchived)
1422+ LaunchedEffect(Unit) {
1423+ vm.bulkSessionActionEvents.collectLatest { event ->
1424+ val message = when (event.kind) {
1425+ SessionActionKind.ARCHIVED -> archived.format(event.ids.size)
1426+ SessionActionKind.UNARCHIVED -> unarchived.format(event.ids.size)
1427+ SessionActionKind.PINNED, SessionActionKind.UNPINNED -> return@collectLatest
1428+ }
1429+ val result = snackbar.showSnackbar(
1430+ message = message,
1431+ actionLabel = undoLabel,
1432+ duration = androidx.compose.material3.SnackbarDuration.Short,
1433+ )
1434+ if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) {
1435+ vm.undoBulkAction(event)
1436+ }
1437+ }
1438+ }
1439+}
1440+
13231441@Composable
13241442private fun SortMenu(
13251443 sortMode: SessionSortMode,