@@ -91,6 +91,7 @@ fun SessionListScreen(
91 onDisconnect: () -> Unit,
91 onDisconnect: () -> Unit,
92 onOpenFiles: () -> Unit,
92 onOpenFiles: () -> Unit,
93 onOpenSettings: () -> Unit,
93 onOpenSettings: () -> Unit,
94+ onAddServer: () -> Unit,
95 val vm: SessionListViewModel = viewModel(factory = vmFactory { SessionListViewModel(container) })
96 val vm: SessionListViewModel = viewModel(factory = vmFactory { SessionListViewModel(container) })
96 val state by vm.state.collectAsStateWithLifecycle()
97 val state by vm.state.collectAsStateWithLifecycle()
@@ -159,6 +160,10 @@ fun SessionListScreen(
159 showServerMenu = false
160 showServerMenu = false
160 vm.switchServer(profile)
161 vm.switchServer(profile)
164+ showServerMenu = false
164 actions = {
169 actions = {
@@ -199,119 +204,20 @@ fun SessionListScreen(
199 ConnectionBanner(
204 ConnectionBanner(
200 state = connectionState,
205 state = connectionState,
201 modifier = Modifier.align(Alignment.TopCenter),
206 modifier = Modifier.align(Alignment.TopCenter),
207+ onRetry = { vm.retryConnection() },
212+ refreshing = refreshing,
214+ onRefresh = vm::refresh,
215+ onQueryChange = vm::setQuery,
216+ onOpenSession = onOpenSession,
217+ onCreateSession = { vm.createSession(onCreated = onOpenSession) },
218+ onRename = { pendingRenameId = it },
219+ onDelete = { pendingDeleteId = it },
205− val loadingLabel = stringResource(R.string.loading)
206− CircularProgressIndicator(
208− .align(Alignment.Center)
209− .semantics { contentDescription = loadingLabel },
212− state.sessions.isEmpty() && state.error != null -> Column(
213− modifier = Modifier.align(Alignment.Center).padding(24.dp),
214− horizontalAlignment = Alignment.CenterHorizontally,
218− color = MaterialTheme.colorScheme.error,
220− Spacer(Modifier.size(12.dp))
221− TextButton(onClick = { vm.refresh() }) {
222− Text(stringResource(R.string.retry))
225− state.sessions.isEmpty() -> EmptySessions(
226− onCreate = { vm.createSession(onCreated = onOpenSession) },
227− modifier = Modifier.align(Alignment.Center),
229− else -> Column(modifier = Modifier.fillMaxSize()) {
231− value = state.query,
232− onValueChange = vm::setQuery,
233− modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp).testTag("session_search"),
234− placeholder = { Text(stringResource(R.string.search_sessions)) },
235− label = { Text(stringResource(R.string.search_sessions)) },
236− leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
238− keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
240− val sessions = remember(state.sessions, state.query, state.previews) {
241− // Fast-path an empty query: filtered just returns sessions, so skip
242− // the recompute (and the fresh list reference) when only previews
243− // churned in the background. With a non-empty query the previews
244− // map affects the match set, so recompute on any of the three.
245− if (state.query.isBlank()) state.sessions else state.filtered
247− if (sessions.isEmpty()) {
249− stringResource(R.string.no_sessions_match, state.query),
250− modifier = Modifier.padding(24.dp),
251− color = MaterialTheme.colorScheme.onSurfaceVariant,
255− isRefreshing = refreshing,
256− onRefresh = { vm.refresh() },
257− modifier = Modifier.fillMaxSize(),
260− modifier = Modifier.fillMaxSize(),
261− contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 96.dp),
262− verticalArrangement = Arrangement.spacedBy(12.dp),
264− items(sessions, key = { it.id }) { session ->
265− // Swipe end-to-start reveals a delete affordance and opens the
266− // same confirmation dialog as the trash icon. We never commit
267− // the dismissal (always reset to Settled) so the card snaps back
268− // and the dialog guards against accidental data loss.
269− val swipeState = rememberSwipeToDismissBoxState(
270− confirmValueChange = {
271− // Haptic at the trigger so the swipe path matches the icon-path
272− // delete, which vibrates on confirm. Returns false (snap back) so
273− // the dialog guards the actual deletion.
274− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
275− pendingDeleteId = session.id
281− enableDismissFromStartToEnd = false,
282− backgroundContent = {
286− .clip(MaterialTheme.shapes.medium)
287− .background(MaterialTheme.colorScheme.errorContainer)
288− .padding(horizontal = 20.dp),
289− contentAlignment = Alignment.CenterEnd,
292− Icons.Filled.Delete,
293− contentDescription = stringResource(R.string.delete),
294− tint = MaterialTheme.colorScheme.onErrorContainer,
301− preview = state.previews[session.id],
302− unread = unread.contains(session.id),
303− onClick = { onOpenSession(session.id) },
304− onRename = { pendingRenameId = session.id },
305− onDelete = { pendingDeleteId = session.id },
306− modifier = Modifier.testTag("session_card"),
@@ -366,6 +272,134 @@ fun SessionListScreen(
275+@OptIn(ExperimentalMaterial3Api::class)
277+private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
278+ state: SessionListState,
279+ unread: Map<String, Int>,
280+ refreshing: Boolean,
281+ haptics: androidx.compose.ui.hapticfeedback.HapticFeedback,
282+ onRefresh: () -> Unit,
283+ onQueryChange: (String) -> Unit,
284+ onOpenSession: (String) -> Unit,
285+ onCreateSession: () -> Unit,
286+ onRename: (String) -> Unit,
287+ onDelete: (String) -> Unit,
291+ val loadingLabel = stringResource(R.string.loading)
292+ CircularProgressIndicator(
294+ .align(Alignment.Center)
295+ .semantics { contentDescription = loadingLabel },
298+ state.sessions.isEmpty() && state.error != null -> Column(
299+ modifier = Modifier.align(Alignment.Center).padding(24.dp),
300+ horizontalAlignment = Alignment.CenterHorizontally,
304+ color = MaterialTheme.colorScheme.error,
306+ Spacer(Modifier.size(12.dp))
307+ TextButton(onClick = onRefresh) {
308+ Text(stringResource(R.string.retry))
311+ state.sessions.isEmpty() -> EmptySessions(
312+ onCreate = onCreateSession,
313+ modifier = Modifier.align(Alignment.Center),
315+ else -> Column(modifier = Modifier.fillMaxSize()) {
317+ value = state.query,
318+ onValueChange = onQueryChange,
319+ modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp).testTag("session_search"),
320+ placeholder = { Text(stringResource(R.string.search_sessions)) },
321+ label = { Text(stringResource(R.string.search_sessions)) },
322+ leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
324+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
326+ val sessions = remember(state.sessions, state.query, state.previews) {
327+ // Fast-path an empty query: filtered just returns sessions, so skip
328+ // the recompute (and the fresh list reference) when only previews
329+ // churned in the background. With a non-empty query the previews
330+ // map affects the match set, so recompute on any of the three.
331+ if (state.query.isBlank()) state.sessions else state.filtered
333+ if (sessions.isEmpty()) {
335+ stringResource(R.string.no_sessions_match, state.query),
336+ modifier = Modifier.padding(24.dp),
337+ color = MaterialTheme.colorScheme.onSurfaceVariant,
341+ isRefreshing = refreshing,
342+ onRefresh = onRefresh,
343+ modifier = Modifier.fillMaxSize(),
346+ modifier = Modifier.fillMaxSize(),
347+ contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 96.dp),
348+ verticalArrangement = Arrangement.spacedBy(12.dp),
350+ items(sessions, key = { it.id }) { session ->
351+ // Swipe end-to-start reveals a delete affordance and opens the
352+ // same confirmation dialog as the trash icon. We never commit
353+ // the dismissal (always reset to Settled) so the card snaps back
354+ // and the dialog guards against accidental data loss.
355+ val swipeState = rememberSwipeToDismissBoxState(
356+ confirmValueChange = {
357+ // Haptic at the trigger so the swipe path matches the icon-path
358+ // delete, which vibrates on confirm. Returns false (snap back) so
359+ // the dialog guards the actual deletion.
360+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
361+ onDelete(session.id)
367+ enableDismissFromStartToEnd = false,
368+ backgroundContent = {
372+ .clip(MaterialTheme.shapes.medium)
373+ .background(MaterialTheme.colorScheme.errorContainer)
374+ .padding(horizontal = 20.dp),
375+ contentAlignment = Alignment.CenterEnd,
378+ Icons.Filled.Delete,
379+ contentDescription = stringResource(R.string.delete),
380+ tint = MaterialTheme.colorScheme.onErrorContainer,
387+ preview = state.previews[session.id],
388+ unreadCount = unread[session.id] ?: 0,
389+ onClick = { onOpenSession(session.id) },
390+ onRename = { onRename(session.id) },
391+ onDelete = { onDelete(session.id) },
392+ modifier = Modifier.testTag("session_card"),
369@Composable
403@Composable
370private fun RenameSessionDialog(
404private fun RenameSessionDialog(
371 title: String,
405 title: String,
@@ -402,7 +436,7 @@ private fun RenameSessionDialog(
402private fun SessionCard(
436private fun SessionCard(
403 session: Session,
437 session: Session,
404 preview: String?,
438 preview: String?,
405− unread: Boolean,
439+ unreadCount: Int,
406 onClick: () -> Unit,
440 onClick: () -> Unit,
407 onRename: () -> Unit,
441 onRename: () -> Unit,
408 onDelete: () -> Unit,
442 onDelete: () -> Unit,
@@ -420,21 +454,42 @@ private fun SessionCard(
421 Column(modifier = Modifier.weight(1f)) {
455 Column(modifier = Modifier.weight(1f)) {
422 Row(verticalAlignment = Alignment.CenterVertically) {
456 Row(verticalAlignment = Alignment.CenterVertically) {
423− if (unread) {
457+ if (unreadCount > 0) {
424− val unreadLabel = stringResource(R.string.unread)
458+ val unreadLabel = stringResource(R.string.unread_count, unreadCount)
459+ // Count badge: shows the number of unread messages so the user
460+ // can tell a single reply from a burst. Falls back to a dot for
461+ // a count of 1 (the common "one reply" case) to avoid clutter.
462+ if (unreadCount == 1) {
426− modifier = Modifier
464+ modifier = Modifier
427− .padding(end = 8.dp)
465+ .padding(end = 8.dp)
428− .size(8.dp)
466+ .size(8.dp)
429− .clip(androidx.compose.foundation.shape.CircleShape)
467+ .clip(androidx.compose.foundation.shape.CircleShape)
430− .background(MaterialTheme.colorScheme.primary)
468+ .background(MaterialTheme.colorScheme.primary)
431− .semantics { contentDescription = unreadLabel },
469+ .semantics { contentDescription = unreadLabel },
474+ .padding(end = 8.dp)
475+ .clip(androidx.compose.foundation.shape.RoundedCornerShape(8.dp))
476+ .background(MaterialTheme.colorScheme.primary)
477+ .padding(horizontal = 6.dp, vertical = 1.dp)
478+ .semantics { contentDescription = unreadLabel },
479+ contentAlignment = Alignment.Center,
482+ unreadCount.toString(),
483+ style = MaterialTheme.typography.labelSmall,
484+ color = MaterialTheme.colorScheme.onPrimary,
435 session.displayTitle,
490 session.displayTitle,
436 style = MaterialTheme.typography.titleMedium,
491 style = MaterialTheme.typography.titleMedium,
437− color = if (unread) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
492+ color = if (unreadCount > 0) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
438 maxLines = 1,
493 maxLines = 1,
439 overflow = TextOverflow.Ellipsis,
494 overflow = TextOverflow.Ellipsis,
@@ -461,7 +516,7 @@ private fun SessionCard(
463 style = MaterialTheme.typography.bodyMedium,
518 style = MaterialTheme.typography.bodyMedium,
464− color = if (unread) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
519+ color = if (unreadCount > 0) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
465 maxLines = 2,
520 maxLines = 2,
466 overflow = TextOverflow.Ellipsis,
521 overflow = TextOverflow.Ellipsis,
@@ -500,7 +555,8 @@ private fun EmptySessions(onCreate: () -> Unit, modifier: Modifier = Modifier) {
501/** Title + dropdown for quick-switching between saved servers. Highlights the active
556/** Title + dropdown for quick-switching between saved servers. Highlights the active
502 * server with a check mark and primary color so the user can tell which one they're
557 * server with a check mark and primary color so the user can tell which one they're
503− * on at a glance. */
558+ * on at a glance. Includes an "Add server" item so adding a new server doesn't require
559+ * navigating out to Settings → Manage servers — a long path for a common action. */
504@Composable
560@Composable
505private fun ServerSwitcherMenu(
561private fun ServerSwitcherMenu(
506 serverLabel: String,
562 serverLabel: String,
@@ -511,6 +567,7 @@ private fun ServerSwitcherMenu(
511 onExpand: () -> Unit,
567 onExpand: () -> Unit,
512 onDismiss: () -> Unit,
568 onDismiss: () -> Unit,
513 onSelect: (ServerProfile) -> Unit,
569 onSelect: (ServerProfile) -> Unit,
570+ onAddServer: () -> Unit,
@@ -569,6 +626,17 @@ private fun ServerSwitcherMenu(
569 onClick = { onSelect(profile) },
626 onClick = { onSelect(profile) },
629+ // Divider + Add server item so the user can add a server without leaving
630+ // the session list. The trailing icon reinforces the primary action.
631+ androidx.compose.material3.HorizontalDivider()
633+ text = { Text(stringResource(R.string.add_server)) },
634+ leadingIcon = { Icon(Icons.Filled.Add, contentDescription = null) },