@@ -91,6 +91,7 @@ fun SessionListScreen(
9191 onDisconnect: () -> Unit,
9292 onOpenFiles: () -> Unit,
9393 onOpenSettings: () -> Unit,
94+ onAddServer: () -> Unit,
9495) {
9596 val vm: SessionListViewModel = viewModel(factory = vmFactory { SessionListViewModel(container) })
9697 val state by vm.state.collectAsStateWithLifecycle()
@@ -159,6 +160,10 @@ fun SessionListScreen(
159160 showServerMenu = false
160161 vm.switchServer(profile)
161162 },
163+ onAddServer = {
164+ showServerMenu = false
165+ onAddServer()
166+ },
162167 )
163168 },
164169 actions = {
@@ -199,119 +204,20 @@ fun SessionListScreen(
199204 ConnectionBanner(
200205 state = connectionState,
201206 modifier = Modifier.align(Alignment.TopCenter),
207+ onRetry = { vm.retryConnection() },
208+ )
209+ SessionListBody(
210+ state = state,
211+ unread = unread,
212+ refreshing = refreshing,
213+ haptics = haptics,
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 },
202220 )
203− when {
204− state.loading -> {
205− val loadingLabel = stringResource(R.string.loading)
206− CircularProgressIndicator(
207− Modifier
208− .align(Alignment.Center)
209− .semantics { contentDescription = loadingLabel },
210− )
211− }
212− state.sessions.isEmpty() && state.error != null -> Column(
213− modifier = Modifier.align(Alignment.Center).padding(24.dp),
214− horizontalAlignment = Alignment.CenterHorizontally,
215− ) {
216− Text(
217− state.error ?: "",
218− color = MaterialTheme.colorScheme.error,
219− )
220− Spacer(Modifier.size(12.dp))
221− TextButton(onClick = { vm.refresh() }) {
222− Text(stringResource(R.string.retry))
223− }
224− }
225− state.sessions.isEmpty() -> EmptySessions(
226− onCreate = { vm.createSession(onCreated = onOpenSession) },
227− modifier = Modifier.align(Alignment.Center),
228− )
229− else -> Column(modifier = Modifier.fillMaxSize()) {
230− OutlinedTextField(
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) },
237− singleLine = true,
238− keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
239− )
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
246− }
247− if (sessions.isEmpty()) {
248− Text(
249− stringResource(R.string.no_sessions_match, state.query),
250− modifier = Modifier.padding(24.dp),
251− color = MaterialTheme.colorScheme.onSurfaceVariant,
252− )
253− } else {
254− PullToRefreshBox(
255− isRefreshing = refreshing,
256− onRefresh = { vm.refresh() },
257− modifier = Modifier.fillMaxSize(),
258− ) {
259− LazyColumn(
260− modifier = Modifier.fillMaxSize(),
261− contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 96.dp),
262− verticalArrangement = Arrangement.spacedBy(12.dp),
263− ) {
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
276− false
277− },
278− )
279− SwipeToDismissBox(
280− state = swipeState,
281− enableDismissFromStartToEnd = false,
282− backgroundContent = {
283− Box(
284− modifier = Modifier
285− .fillMaxSize()
286− .clip(MaterialTheme.shapes.medium)
287− .background(MaterialTheme.colorScheme.errorContainer)
288− .padding(horizontal = 20.dp),
289− contentAlignment = Alignment.CenterEnd,
290− ) {
291− Icon(
292− Icons.Filled.Delete,
293− contentDescription = stringResource(R.string.delete),
294− tint = MaterialTheme.colorScheme.onErrorContainer,
295− )
296− }
297− },
298− ) {
299− SessionCard(
300− session = session,
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"),
307− )
308− }
309− }
310− }
311− }
312− }
313− }
314− }
315221 }
316222 }
317223 }
@@ -366,6 +272,134 @@ fun SessionListScreen(
366272 }
367273}
368274
275+@OptIn(ExperimentalMaterial3Api::class)
276+@Composable
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,
288+) {
289+ when {
290+ state.loading -> {
291+ val loadingLabel = stringResource(R.string.loading)
292+ CircularProgressIndicator(
293+ Modifier
294+ .align(Alignment.Center)
295+ .semantics { contentDescription = loadingLabel },
296+ )
297+ }
298+ state.sessions.isEmpty() && state.error != null -> Column(
299+ modifier = Modifier.align(Alignment.Center).padding(24.dp),
300+ horizontalAlignment = Alignment.CenterHorizontally,
301+ ) {
302+ Text(
303+ state.error ?: "",
304+ color = MaterialTheme.colorScheme.error,
305+ )
306+ Spacer(Modifier.size(12.dp))
307+ TextButton(onClick = onRefresh) {
308+ Text(stringResource(R.string.retry))
309+ }
310+ }
311+ state.sessions.isEmpty() -> EmptySessions(
312+ onCreate = onCreateSession,
313+ modifier = Modifier.align(Alignment.Center),
314+ )
315+ else -> Column(modifier = Modifier.fillMaxSize()) {
316+ OutlinedTextField(
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) },
323+ singleLine = true,
324+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
325+ )
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
332+ }
333+ if (sessions.isEmpty()) {
334+ Text(
335+ stringResource(R.string.no_sessions_match, state.query),
336+ modifier = Modifier.padding(24.dp),
337+ color = MaterialTheme.colorScheme.onSurfaceVariant,
338+ )
339+ } else {
340+ PullToRefreshBox(
341+ isRefreshing = refreshing,
342+ onRefresh = onRefresh,
343+ modifier = Modifier.fillMaxSize(),
344+ ) {
345+ LazyColumn(
346+ modifier = Modifier.fillMaxSize(),
347+ contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 96.dp),
348+ verticalArrangement = Arrangement.spacedBy(12.dp),
349+ ) {
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)
362+ false
363+ },
364+ )
365+ SwipeToDismissBox(
366+ state = swipeState,
367+ enableDismissFromStartToEnd = false,
368+ backgroundContent = {
369+ Box(
370+ modifier = Modifier
371+ .fillMaxSize()
372+ .clip(MaterialTheme.shapes.medium)
373+ .background(MaterialTheme.colorScheme.errorContainer)
374+ .padding(horizontal = 20.dp),
375+ contentAlignment = Alignment.CenterEnd,
376+ ) {
377+ Icon(
378+ Icons.Filled.Delete,
379+ contentDescription = stringResource(R.string.delete),
380+ tint = MaterialTheme.colorScheme.onErrorContainer,
381+ )
382+ }
383+ },
384+ ) {
385+ SessionCard(
386+ session = session,
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"),
393+ )
394+ }
395+ }
396+ }
397+ }
398+ }
399+ }
400+ }
401+}
402+
369403@Composable
370404private fun RenameSessionDialog(
371405 title: String,
@@ -402,7 +436,7 @@ private fun RenameSessionDialog(
402436private fun SessionCard(
403437 session: Session,
404438 preview: String?,
405− unread: Boolean,
439+ unreadCount: Int,
406440 onClick: () -> Unit,
407441 onRename: () -> Unit,
408442 onDelete: () -> Unit,
@@ -420,21 +454,42 @@ private fun SessionCard(
420454 ) {
421455 Column(modifier = Modifier.weight(1f)) {
422456 Row(verticalAlignment = Alignment.CenterVertically) {
423− if (unread) {
424− val unreadLabel = stringResource(R.string.unread)
425− Box(
426− modifier = Modifier
427− .padding(end = 8.dp)
428− .size(8.dp)
429− .clip(androidx.compose.foundation.shape.CircleShape)
430− .background(MaterialTheme.colorScheme.primary)
431− .semantics { contentDescription = unreadLabel },
432− )
457+ if (unreadCount > 0) {
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) {
463+ Box(
464+ modifier = Modifier
465+ .padding(end = 8.dp)
466+ .size(8.dp)
467+ .clip(androidx.compose.foundation.shape.CircleShape)
468+ .background(MaterialTheme.colorScheme.primary)
469+ .semantics { contentDescription = unreadLabel },
470+ )
471+ } else {
472+ Box(
473+ modifier = Modifier
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,
480+ ) {
481+ Text(
482+ unreadCount.toString(),
483+ style = MaterialTheme.typography.labelSmall,
484+ color = MaterialTheme.colorScheme.onPrimary,
485+ )
486+ }
487+ }
433488 }
434489 Text(
435490 session.displayTitle,
436491 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,
438493 maxLines = 1,
439494 overflow = TextOverflow.Ellipsis,
440495 )
@@ -461,7 +516,7 @@ private fun SessionCard(
461516 Text(
462517 preview,
463518 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,
465520 maxLines = 2,
466521 overflow = TextOverflow.Ellipsis,
467522 )
@@ -500,7 +555,8 @@ private fun EmptySessions(onCreate: () -> Unit, modifier: Modifier = Modifier) {
500555
501556/** Title + dropdown for quick-switching between saved servers. Highlights the active
502557 * 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. */
504560@Composable
505561private fun ServerSwitcherMenu(
506562 serverLabel: String,
@@ -511,6 +567,7 @@ private fun ServerSwitcherMenu(
511567 onExpand: () -> Unit,
512568 onDismiss: () -> Unit,
513569 onSelect: (ServerProfile) -> Unit,
570+ onAddServer: () -> Unit,
514571) {
515572 Column {
516573 Box {
@@ -569,6 +626,17 @@ private fun ServerSwitcherMenu(
569626 onClick = { onSelect(profile) },
570627 )
571628 }
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()
632+ DropdownMenuItem(
633+ text = { Text(stringResource(R.string.add_server)) },
634+ leadingIcon = { Icon(Icons.Filled.Add, contentDescription = null) },
635+ onClick = {
636+ onDismiss()
637+ onAddServer()
638+ },
639+ )
572640 }
573641 }
574642 }