@@ -4,6 +4,7 @@ import androidx.compose.foundation.background
4import androidx.activity.compose.BackHandler
4import androidx.activity.compose.BackHandler
5import androidx.compose.foundation.clickable
5import androidx.compose.foundation.clickable
6import androidx.compose.foundation.combinedClickable
6import androidx.compose.foundation.combinedClickable
7+import androidx.compose.foundation.horizontalScroll
7import androidx.compose.foundation.layout.Arrangement
8import androidx.compose.foundation.layout.Arrangement
8import androidx.compose.foundation.layout.Box
9import androidx.compose.foundation.layout.Box
9import androidx.compose.foundation.layout.Column
10import androidx.compose.foundation.layout.Column
@@ -19,6 +20,7 @@ import androidx.compose.foundation.layout.padding
19import androidx.compose.foundation.layout.size
20import androidx.compose.foundation.layout.size
20import androidx.compose.foundation.lazy.LazyColumn
21import androidx.compose.foundation.lazy.LazyColumn
21import androidx.compose.foundation.lazy.items
22import androidx.compose.foundation.lazy.items
23+import androidx.compose.foundation.rememberScrollState
22import androidx.compose.foundation.text.KeyboardActions
24import androidx.compose.foundation.text.KeyboardActions
23import androidx.compose.foundation.text.KeyboardOptions
25import androidx.compose.foundation.text.KeyboardOptions
24import androidx.compose.material.icons.Icons
26import androidx.compose.material.icons.Icons
@@ -55,6 +57,7 @@ import androidx.compose.material3.DropdownMenu
55import androidx.compose.material3.DropdownMenuItem
57import androidx.compose.material3.DropdownMenuItem
56import androidx.compose.material3.ExperimentalMaterial3Api
58import androidx.compose.material3.ExperimentalMaterial3Api
57import androidx.compose.material3.ExtendedFloatingActionButton
59import androidx.compose.material3.ExtendedFloatingActionButton
60+import androidx.compose.material3.FilterChip
58import androidx.compose.material3.Icon
61import androidx.compose.material3.Icon
59import androidx.compose.material3.IconButton
62import androidx.compose.material3.IconButton
60import androidx.compose.material3.MaterialTheme
63import androidx.compose.material3.MaterialTheme
@@ -118,7 +121,10 @@ import soy.iko.opencode.platform.SessionsWidgetProvider
118import soy.iko.opencode.R
121import soy.iko.opencode.R
119import soy.iko.opencode.ui.components.ConnectionBanner
122import soy.iko.opencode.ui.components.ConnectionBanner
120import soy.iko.opencode.ui.components.EmptyState
123import soy.iko.opencode.ui.components.EmptyState
124+import soy.iko.opencode.ui.components.LoadingSize
125+import soy.iko.opencode.ui.components.LoadingSpinner
121import soy.iko.opencode.ui.components.LocalRelativeTimeTick
126import soy.iko.opencode.ui.components.LocalRelativeTimeTick
127+import soy.iko.opencode.ui.components.copyToClipboard
122import soy.iko.opencode.ui.components.reducedMotionAnimateItem
128import soy.iko.opencode.ui.components.reducedMotionAnimateItem
123import soy.iko.opencode.ui.components.RelativeTimeText
129import soy.iko.opencode.ui.components.RelativeTimeText
124import soy.iko.opencode.ui.components.rememberRelativeTimeTick
130import soy.iko.opencode.ui.components.rememberRelativeTimeTick
@@ -151,6 +157,7 @@ fun SessionListScreen(
151 val creating by vm.creating.collectAsStateWithLifecycle()
157 val creating by vm.creating.collectAsStateWithLifecycle()
152 val directoryOptions by vm.directoryOptions.collectAsStateWithLifecycle()
158 val directoryOptions by vm.directoryOptions.collectAsStateWithLifecycle()
153 val anyRunActive by container.anyRunActive.collectAsStateWithLifecycle()
159 val anyRunActive by container.anyRunActive.collectAsStateWithLifecycle()
160+ val runningSessionIds by container.runningSessionIds.collectAsStateWithLifecycle()
154 val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
161 val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
155 val isOnline by container.isOnline.collectAsStateWithLifecycle()
162 val isOnline by container.isOnline.collectAsStateWithLifecycle()
156 val connectedId = activeConnection?.profile?.id
163 val connectedId = activeConnection?.profile?.id
@@ -221,6 +228,12 @@ fun SessionListScreen(
231+ LaunchedEffect(Unit) {
232+ vm.transientInfo.collect { msg ->
233+ snackbar.showSnackbar(msg)
224 // Retryable failures (create/rename): offer a Retry action so a transient network error
237 // Retryable failures (create/rename): offer a Retry action so a transient network error
225 // is recoverable in one tap instead of forcing the user back through the dialog/FAB.
238 // is recoverable in one tap instead of forcing the user back through the dialog/FAB.
226 LaunchedEffect(Unit) {
239 LaunchedEffect(Unit) {
@@ -275,6 +288,7 @@ fun SessionListScreen(
277 SessionActionUndoEffect(vm, snackbar)
290 SessionActionUndoEffect(vm, snackbar)
291+ BulkSessionActionUndoEffect(vm, snackbar)
279 Scaffold(
293 Scaffold(
280 topBar = {
294 topBar = {
@@ -442,7 +456,9 @@ fun SessionListScreen(
442 refreshing = refreshing,
456 refreshing = refreshing,
443 haptics = haptics,
457 haptics = haptics,
444 selectedSessionId = selectedSessionId,
458 selectedSessionId = selectedSessionId,
459+ connected = activeConnection != null,
445 onRefresh = vm::refresh,
460 onRefresh = vm::refresh,
461+ onReconnect = vm::retryConnection,
446 onQueryChange = vm::setQuery,
462 onQueryChange = vm::setQuery,
447 onOpenSession = onOpenSession,
463 onOpenSession = onOpenSession,
448 onCreateSession = openNewSession,
464 onCreateSession = openNewSession,
@@ -453,9 +469,12 @@ fun SessionListScreen(
453 onMarkUnread = { vm.markUnread(it) },
469 onMarkUnread = { vm.markUnread(it) },
454 onToggleMute = { container.toggleSessionMute(it) },
470 onToggleMute = { container.toggleSessionMute(it) },
455 onFilterByDirectory = vm::setDirectoryFilter,
471 onFilterByDirectory = vm::setDirectoryFilter,
472+ onClearDirectoryFilter = { vm.setDirectoryFilter(null) },
473+ onShowArchived = { vm.setShowArchived(true) },
456 inSelection = inSelection,
474 inSelection = inSelection,
457 selectedIds = selectedIds,
475 selectedIds = selectedIds,
458 onToggleSelect = ::toggleSelected,
476 onToggleSelect = ::toggleSelected,
477+ runningSessionIds = runningSessionIds,
@@ -555,7 +574,9 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
555 refreshing: Boolean,
574 refreshing: Boolean,
556 haptics: androidx.compose.ui.hapticfeedback.HapticFeedback,
575 haptics: androidx.compose.ui.hapticfeedback.HapticFeedback,
557 selectedSessionId: String?,
576 selectedSessionId: String?,
558 onRefresh: () -> Unit,
578 onRefresh: () -> Unit,
579+ onReconnect: () -> Unit,
559 onQueryChange: (String) -> Unit,
580 onQueryChange: (String) -> Unit,
560 onOpenSession: (String) -> Unit,
581 onOpenSession: (String) -> Unit,
561 onCreateSession: () -> Unit,
582 onCreateSession: () -> Unit,
@@ -566,9 +587,12 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
566 onMarkUnread: (String) -> Unit,
587 onMarkUnread: (String) -> Unit,
567 onToggleMute: (String) -> Unit,
588 onToggleMute: (String) -> Unit,
568 onFilterByDirectory: (String) -> Unit,
589 onFilterByDirectory: (String) -> Unit,
590+ onClearDirectoryFilter: () -> Unit,
591+ onShowArchived: () -> Unit,
569 inSelection: Boolean,
592 inSelection: Boolean,
570 selectedIds: Set<String>,
593 selectedIds: Set<String>,
571 onToggleSelect: (String) -> Unit,
594 onToggleSelect: (String) -> Unit,
595+ runningSessionIds: Set<String> = emptySet(),
574 state.loading -> {
598 state.loading -> {
@@ -607,7 +631,7 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
607 textAlign = androidx.compose.ui.text.style.TextAlign.Center,
631 textAlign = androidx.compose.ui.text.style.TextAlign.Center,
609 Spacer(Modifier.size(12.dp))
633 Spacer(Modifier.size(12.dp))
610− TextButton(onClick = onRefresh) {
634+ TextButton(onClick = if (connected) onRefresh else onReconnect) {
611 Text(stringResource(R.string.retry))
635 Text(stringResource(R.string.retry))
@@ -644,11 +668,45 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
644 keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
668 keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
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 ->
678+ .horizontalScroll(rememberScrollState())
679+ .padding(horizontal = 16.dp, vertical = 4.dp),
683+ onClick = onClearDirectoryFilter,
684+ label = { Text(stringResource(R.string.directory_filter_chip, dir)) },
688+ contentDescription = stringResource(R.string.clear_filter),
647 val sessions = remember(
694 val sessions = remember(
648 state.sessions, state.query, state.previews,
695 state.sessions, state.query, state.previews,
649 state.pinnedIds, state.archivedIds, state.showArchived,
696 state.pinnedIds, state.archivedIds, state.showArchived,
650 ) { state.filtered }
697 ) { state.filtered }
651 if (sessions.isEmpty()) {
698 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)
708+ stringResource(R.string.no_sessions_archived_hidden)
653 modifier = Modifier.fillMaxSize(),
711 modifier = Modifier.fillMaxSize(),
654 contentAlignment = Alignment.Center,
712 contentAlignment = Alignment.Center,
@@ -665,16 +723,29 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
666 Spacer(Modifier.size(12.dp))
724 Spacer(Modifier.size(12.dp))
668− stringResource(R.string.no_sessions_match, state.query),
726+ message,
669 style = MaterialTheme.typography.bodyMedium,
727 style = MaterialTheme.typography.bodyMedium,
670 color = MaterialTheme.colorScheme.onSurfaceVariant,
728 color = MaterialTheme.colorScheme.onSurfaceVariant,
729+ textAlign = androidx.compose.ui.text.style.TextAlign.Center,
672− // An empty filtered list is recoverable in one tap; offer it inline
731+ when {
673− // rather than making the user hunt for the field's trailing clear icon.
674− if (state.query.isNotEmpty()) {
732+ state.query.isNotEmpty() -> {
675− Spacer(Modifier.size(8.dp))
733+ Spacer(Modifier.size(8.dp))
676− TextButton(onClick = { onQueryChange("") }) {
734+ TextButton(onClick = { onQueryChange("") }) {
677− Text(stringResource(R.string.clear_search))
735+ Text(stringResource(R.string.clear_search))
738+ state.directoryFilter != null -> {
739+ Spacer(Modifier.size(8.dp))
740+ TextButton(onClick = onClearDirectoryFilter) {
741+ Text(stringResource(R.string.clear_filter))
744+ state.hiddenArchivedCount > 0 -> {
745+ Spacer(Modifier.size(8.dp))
746+ TextButton(onClick = onShowArchived) {
747+ Text(stringResource(R.string.show_archived))
@@ -823,6 +894,7 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
823 isPinned = isPinned,
894 isPinned = isPinned,
824 isArchived = isArchived,
895 isArchived = isArchived,
825 isMuted = isMuted,
896 isMuted = isMuted,
897+ isRunning = session.id in runningSessionIds,
826 onClick = onCardClick,
898 onClick = onCardClick,
827 onRename = onCardRename,
899 onRename = onCardRename,
828 onDelete = onCardDelete,
900 onDelete = onCardDelete,
@@ -988,6 +1060,7 @@ private fun SessionCard(
988 isSelected: Boolean = false,
1060 isSelected: Boolean = false,
989 isPinned: Boolean = false,
1061 isPinned: Boolean = false,
990 isArchived: Boolean = false,
1062 isArchived: Boolean = false,
1063+ isRunning: Boolean = false,
991 onFilterByDirectory: ((String) -> Unit)? = null,
1064 onFilterByDirectory: ((String) -> Unit)? = null,
992 isDirectoryFiltered: Boolean = false,
1065 isDirectoryFiltered: Boolean = false,
993 inSelection: Boolean = false,
1066 inSelection: Boolean = false,
@@ -1045,6 +1118,15 @@ private fun SessionCard(
1046 Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) {
1119 Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) {
1047 Row(verticalAlignment = Alignment.CenterVertically) {
1120 Row(verticalAlignment = Alignment.CenterVertically) {
1122+ val runningLabel = stringResource(R.string.session_running_badge)
1124+ size = LoadingSize.Inline,
1125+ modifier = Modifier
1126+ .padding(end = 8.dp)
1127+ .semantics { contentDescription = runningLabel },
1048 if (unreadCount > 0 && !isMuted) {
1130 if (unreadCount > 0 && !isMuted) {
1049 val unreadLabel = stringResource(R.string.unread_count, unreadCount)
1131 val unreadLabel = stringResource(R.string.unread_count, unreadCount)
1050 // Count badge: shows the number of unread messages so the user
1132 // Count badge: shows the number of unread messages so the user
@@ -1198,6 +1280,9 @@ private fun SessionCard(
1198 val archiveLabel = stringResource(if (isArchived) R.string.session_unarchive else R.string.session_archive)
1280 val archiveLabel = stringResource(if (isArchived) R.string.session_unarchive else R.string.session_archive)
1199 val markUnreadLabel = stringResource(R.string.session_mark_unread)
1281 val markUnreadLabel = stringResource(R.string.session_mark_unread)
1200 val muteLabel = stringResource(if (isMuted) R.string.session_unmute else R.string.session_mute)
1282 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)
1202 IconButton(onClick = { showRowMenu = true }) {
1287 IconButton(onClick = { showRowMenu = true }) {
1203 Icon(Icons.Filled.MoreVert, contentDescription = moreLabel)
1288 Icon(Icons.Filled.MoreVert, contentDescription = moreLabel)
@@ -1226,6 +1311,13 @@ private fun SessionCard(
1226 text = { Text(muteLabel) },
1311 text = { Text(muteLabel) },
1227 onClick = { showRowMenu = false; onToggleMute() },
1312 onClick = { showRowMenu = false; onToggleMute() },
1315+ text = { Text(copyIdLabel) },
1317+ showRowMenu = false
1318+ copyToClipboard(rowContext, clipLabelSessionId, session.id)
1229 DropdownMenuItem(
1321 DropdownMenuItem(
1230 text = { Text(renameLabel) },
1322 text = { Text(renameLabel) },
1231 onClick = { showRowMenu = false; onRename() },
1323 onClick = { showRowMenu = false; onRename() },
@@ -1320,6 +1412,32 @@ private fun SessionActionUndoEffect(vm: SessionListViewModel, snackbar: Snackbar
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.
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
1429+ val result = snackbar.showSnackbar(
1431+ actionLabel = undoLabel,
1432+ duration = androidx.compose.material3.SnackbarDuration.Short,
1434+ if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) {
1435+ vm.undoBulkAction(event)
1323@Composable
1441@Composable
1324private fun SortMenu(
1442private fun SortMenu(
1325 sortMode: SessionSortMode,
1443 sortMode: SessionSortMode,