git.iko.soy/opencode-android
on master help sign in
Add UX improvements: chat overflow menu with rename/delete, two-pane selected highlight, date separators, decluttered cards, settings retry, and long-title ellipsis
commit178e422c58e39e7caf5a6bdbe1ba803a72eb8139 authoriko <mail@iko.soy> dateWed 1 Jul 2026, 18:11 (3 months ago) parent8bc2cce
8 files changed+504−134
app/detekt-baseline.xml+2−2 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+332−91 app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+30 app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+66−24 app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+40−8 app/src/main/java/soy/iko/opencode/ui/session/TwoPaneSessionChat.kt+1 app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+23−9 app/src/main/res/values/strings.xml+10
app/detekt-baseline.xml+2−2
@@ -2,13 +2,13 @@
22<SmellBaseline>
33 <ManuallySuppressedIssues/>
44 <CurrentIssues>
5− <ID>ComplexCondition:ChatScreen.kt$event.type == KeyEventType.KeyDown &amp;&amp; event.key == Key.Enter &amp;&amp; !event.isShiftPressed &amp;&amp; enabled &amp;&amp; value.isNotBlank()</ID>
65 <ID>ComplexCondition:PartRenderer.kt$part.isImage &amp;&amp; imageContext != null &amp;&amp; (part.source != null || !part.url.isNullOrBlank())</ID>
76 <ID>CyclomaticComplexMethod:ChatScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ChatScreen( container: AppContainer, sessionId: String, onBack: () -&gt; Unit, onOpenFile: ((String) -&gt; Unit)? = null, )</ID>
8− <ID>InstanceOfCheckForException:OpencodeApiClient.kt$t is ClientRequestException</ID>
7+ <ID>CyclomaticComplexMethod:CodeHighlighter.kt$private fun findNumberEnd(line: String, start: Int): Int</ID>
98 <ID>MatchingDeclarationName:DiffView.kt$DiffLine</ID>
109 <ID>MatchingDeclarationName:RemoteImage.kt$ImageLoadContext</ID>
1110 <ID>TooManyFunctions:AppContainer.kt$AppContainer</ID>
11+ <ID>TooManyFunctions:ChatViewModel.kt$ChatViewModel : ViewModel</ID>
1212 <ID>TooManyFunctions:OpencodeApiClient.kt$OpencodeApiClient</ID>
1313 <ID>UnusedParameter:MarkdownText.kt$style: TextStyle = MaterialTheme.typography.bodyLarge</ID>
1414 </CurrentIssues>
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+332−91
@@ -26,16 +26,17 @@ import androidx.compose.material.icons.Icons
2626import androidx.compose.material.icons.automirrored.filled.ArrowBack
2727import androidx.compose.material.icons.automirrored.filled.Send
2828import androidx.compose.material.icons.filled.AutoAwesome
29+import androidx.compose.material.icons.filled.Delete
30+import androidx.compose.material.icons.filled.Edit
2931import androidx.compose.material.icons.filled.KeyboardArrowDown
32+import androidx.compose.material.icons.filled.MoreVert
3033import androidx.compose.material.icons.filled.Refresh
31−import androidx.compose.material.icons.filled.Share
32−import androidx.compose.material.icons.filled.SmartToy
3334import androidx.compose.material.icons.filled.Stop
34−import androidx.compose.material.icons.filled.Terminal
35−import androidx.compose.material.icons.filled.Tune
3635import androidx.compose.material3.AlertDialog
3736import androidx.compose.material3.Button
3837import androidx.compose.material3.CircularProgressIndicator
38+import androidx.compose.material3.DropdownMenu
39+import androidx.compose.material3.DropdownMenuItem
3940import androidx.compose.material3.ExperimentalMaterial3Api
4041import androidx.compose.material3.ExtendedFloatingActionButton
4142import androidx.compose.material3.FloatingActionButton
@@ -93,6 +94,7 @@ import kotlinx.coroutines.Dispatchers
9394import kotlinx.coroutines.launch
9495import kotlinx.coroutines.withContext
9596import soy.iko.opencode.data.model.Part
97+import soy.iko.opencode.data.model.MessageWithParts
9698import soy.iko.opencode.data.model.ReasoningPart
9799import soy.iko.opencode.data.model.TextPart
98100import soy.iko.opencode.data.model.ToolCompleted
@@ -148,7 +150,6 @@ fun ChatScreen(
148150 val haptics = LocalHapticFeedback.current
149151 val scope = rememberCoroutineScope()
150152 val shareContext = LocalContext.current
151− val shareLabel = stringResource(R.string.share)
152153 val defaultShareSubject = stringResource(R.string.share_subject)
153154 val sessionLabel = stringResource(R.string.session)
154155 val defaultModelLabel = stringResource(R.string.default_model)
@@ -177,10 +178,13 @@ fun ChatScreen(
177178 // on a zombie screen showing a conversation that no longer exists. Surface a
178179 // toast (not a snackbar) because the screen is about to be popped — a snackbar
179180 // would be torn down before the user could read it, while a toast survives.
181+ // Covers both SSE-driven deletion (deleted on another device) and a user-
182+ // initiated delete from the chat overflow menu (deleteSession in ChatViewModel
183+ // sets the same flag on success).
180184 val deletedContext = LocalContext.current
181185 LaunchedEffect(sessionDeleted) {
182186 if (sessionDeleted) {
183− showToast(deletedContext, deletedContext.getString(R.string.session_deleted_msg))
187+ showToast(deletedContext, deletedContext.getString(R.string.session_deleted_chat))
184188 onBack()
185189 }
186190 }
@@ -193,6 +197,9 @@ fun ChatScreen(
193197 var showCommandPicker by rememberSaveable { mutableStateOf(false) }
194198 var showExitConfirm by rememberSaveable { mutableStateOf(false) }
195199 var showStopConfirm by rememberSaveable { mutableStateOf(false) }
200+ var showOverflowMenu by rememberSaveable { mutableStateOf(false) }
201+ var showRenameDialog by rememberSaveable { mutableStateOf(false) }
202+ var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
196203
197204 // Keep the screen awake and hold a foreground priority while the agent is working,
198205 // so backgrounding mid-run doesn't let Doze choke the SSE stream.
@@ -241,12 +248,20 @@ fun ChatScreen(
241248 .semantics(mergeDescendants = true) { contentDescription = changeLabel },
242249 verticalAlignment = Alignment.CenterVertically,
243250 ) {
244− Column {
245− Text(sessionTitle ?: sessionLabel)
251+ Column(modifier = Modifier.weight(1f, fill = false)) {
252+ // maxLines=1 + Ellipsis so a long session title can't wrap and
253+ // push the top bar to multiple lines, shifting the whole layout.
254+ Text(
255+ sessionTitle ?: sessionLabel,
256+ maxLines = 1,
257+ overflow = TextOverflow.Ellipsis,
258+ )
246259 Text(
247260 subtitle,
248261 style = MaterialTheme.typography.labelSmall,
249262 color = MaterialTheme.colorScheme.onSurfaceVariant,
263+ maxLines = 1,
264+ overflow = TextOverflow.Ellipsis,
250265 )
251266 }
252267 // Visual affordance that the title opens the model picker;
@@ -301,60 +316,88 @@ fun ChatScreen(
301316 Icon(Icons.Filled.Refresh, contentDescription = refreshLabel)
302317 }
303318 }
304− IconButton(
305− onClick = {
306− scope.launch {
307− val md = withContext(Dispatchers.Default) {
308− buildConversationMarkdown(vm.messages.value, sessionTitle)
309− }
310− val send = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
311− type = "text/markdown"
312− putExtra(android.content.Intent.EXTRA_SUBJECT, sessionTitle ?: defaultShareSubject)
313− putExtra(android.content.Intent.EXTRA_TEXT, md)
314− }
315− runCatchingCancellable { shareContext.startActivity(android.content.Intent.createChooser(send, shareLabel)) }
316− .onFailure { showToast(shareContext, shareContext.getString(R.string.no_share_app)) }
317− }
318− },
319− enabled = hasMessages,
320− ) {
321− Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share_conversation))
322− }
323− // Show a spinner while a catalog is initially loading (empty list +
324− // loading flag) so the icon doesn't look permanently disabled. Once
325− // loaded, the icon is always enabled — tapping a picker with no items
326− // shows the "no models/agents/commands" or error-with-retry state.
327− if (commandsLoading && commands.isEmpty()) {
328− val loadingLabel = stringResource(R.string.loading)
329− CircularProgressIndicator(
330− Modifier.size(18.dp).semantics { contentDescription = loadingLabel },
331− strokeWidth = 2.dp,
332− )
333− } else {
334− IconButton(onClick = { showCommandPicker = true }) {
335− Icon(Icons.Filled.Terminal, contentDescription = stringResource(R.string.commands))
319+ // Overflow menu consolidates the less-frequent actions (share,
320+ // commands, agent, model, rename, delete) so the top bar stays
321+ // scannable on narrow phones — previously 5 icons crowded the bar
322+ // alongside the back button and tappable title. Catalog-loading
323+ // spinners stay inline (next to the overflow) so a load state is
324+ // visible without opening the menu.
325+ val moreLabel = stringResource(R.string.more)
326+ val loadingLabel = stringResource(R.string.loading)
327+ val shareLabel = stringResource(R.string.share_conversation)
328+ val commandsLabel = stringResource(R.string.commands)
329+ val agentLabel = stringResource(R.string.choose_agent)
330+ val modelLabel = stringResource(R.string.choose_model)
331+ val renameLabel = stringResource(R.string.rename_session_chat)
332+ val deleteLabel = stringResource(R.string.delete_session_chat)
333+ Box {
334+ IconButton(onClick = { showOverflowMenu = true }) {
335+ Icon(Icons.Filled.MoreVert, contentDescription = moreLabel)
336336 }
337− }
338− if (agentsLoading && agents.isEmpty()) {
339− val loadingLabel = stringResource(R.string.loading)
340− CircularProgressIndicator(
341− Modifier.size(18.dp).semantics { contentDescription = loadingLabel },
342− strokeWidth = 2.dp,
343− )
344− } else {
345− IconButton(onClick = { showAgentPicker = true }) {
346− Icon(Icons.Filled.SmartToy, contentDescription = stringResource(R.string.choose_agent))
337+ DropdownMenu(
338+ expanded = showOverflowMenu,
339+ onDismissRequest = { showOverflowMenu = false },
340+ ) {
341+ DropdownMenuItem(
342+ text = { Text(shareLabel) },
343+ enabled = hasMessages,
344+ onClick = {
345+ showOverflowMenu = false
346+ scope.launch {
347+ val md = withContext(Dispatchers.Default) {
348+ buildConversationMarkdown(vm.messages.value, sessionTitle)
349+ }
350+ val send = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
351+ type = "text/markdown"
352+ putExtra(android.content.Intent.EXTRA_SUBJECT, sessionTitle ?: defaultShareSubject)
353+ putExtra(android.content.Intent.EXTRA_TEXT, md)
354+ }
355+ runCatchingCancellable { shareContext.startActivity(android.content.Intent.createChooser(send, shareLabel)) }
356+ .onFailure { showToast(shareContext, shareContext.getString(R.string.no_share_app)) }
357+ }
358+ },
359+ )
360+ DropdownMenuItem(
361+ text = { Text(commandsLabel) },
362+ enabled = !commandsLoading || commands.isNotEmpty(),
363+ onClick = { showOverflowMenu = false; showCommandPicker = true },
364+ )
365+ DropdownMenuItem(
366+ text = { Text(agentLabel) },
367+ enabled = !agentsLoading || agents.isNotEmpty(),
368+ onClick = { showOverflowMenu = false; showAgentPicker = true },
369+ )
370+ DropdownMenuItem(
371+ text = { Text(modelLabel) },
372+ enabled = !modelsLoading || models.isNotEmpty(),
373+ onClick = { showOverflowMenu = false; showModelPicker = true },
374+ )
375+ androidx.compose.material3.HorizontalDivider()
376+ DropdownMenuItem(
377+ text = { Text(renameLabel) },
378+ onClick = { showOverflowMenu = false; showRenameDialog = true },
379+ )
380+ DropdownMenuItem(
381+ text = { Text(deleteLabel, color = MaterialTheme.colorScheme.error) },
382+ onClick = { showOverflowMenu = false; showDeleteDialog = true },
383+ )
347384 }
348− }
349− if (modelsLoading && models.isEmpty()) {
350− val loadingLabel = stringResource(R.string.loading)
351− CircularProgressIndicator(
352− Modifier.size(18.dp).semantics { contentDescription = loadingLabel },
353− strokeWidth = 2.dp,
354− )
355− } else {
356− IconButton(onClick = { showModelPicker = true }) {
357− Icon(Icons.Filled.Tune, contentDescription = stringResource(R.string.choose_model))
385+ // While a catalog is initially loading (empty list + loading flag),
386+ // show a tiny inline spinner beside the overflow so the user can see
387+ // a load is in progress without opening the menu. Once loaded the
388+ // spinner disappears; the menu items are always enabled (tapping a
389+ // picker with no items shows the "no models/agents/commands" state).
390+ val catalogLoading = (commandsLoading && commands.isEmpty()) ||
391+ (agentsLoading && agents.isEmpty()) ||
392+ (modelsLoading && models.isEmpty())
393+ if (catalogLoading) {
394+ CircularProgressIndicator(
395+ Modifier
396+ .align(Alignment.TopEnd)
397+ .size(10.dp)
398+ .semantics { contentDescription = loadingLabel },
399+ strokeWidth = 1.dp,
400+ )
358401 }
359402 }
360403 },
@@ -386,17 +429,28 @@ fun ChatScreen(
386429 val listState = rememberLazyListState()
387430 val contentScope = rememberCoroutineScope()
388431 val timeTick = rememberRelativeTimeTick()
432+ // Group messages by day and interleave date separators so a long conversation
433+ // has visual "Today"/"Yesterday"/date breaks. Computed once per messages
434+ // emission (memoized) so a scroll-induced recomposition doesn't re-scan. Lives
435+ // outside the PullToRefreshBox so the auto-scroll effects below can reference
436+ // the same list the LazyColumn renders (separators shift the indices vs. the
437+ // raw messages list, so scroll targets must be in listItems space).
438+ val todayLabel = stringResource(R.string.today)
439+ val yesterdayLabel = stringResource(R.string.yesterday)
440+ val listItems = remember(messages, todayLabel, yesterdayLabel) {
441+ buildMessageListItems(messages, todayLabel, yesterdayLabel)
442+ }
389443
390444 val isPinnedToBottom by remember {
391445 derivedStateOf {
392446 val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
393447 // When a run is active, the LazyColumn has one trailing "__typing"
394− // item after the last message. Account for it so "pinned to bottom"
448+ // item after the last list item. Account for it so "pinned to bottom"
395449 // includes the working indicator — otherwise the pin check targets
396− // messages.lastIndex, the typing row sits just below the viewport,
450+ // listItems.lastIndex, the typing row sits just below the viewport,
397451 // and the user never sees the progress indicator even when pinned.
398− val effectiveLast = if (running) messages.size else messages.lastIndex
399− lastVisible >= effectiveLast || messages.isEmpty()
452+ val effectiveLast = if (running) listItems.size else listItems.lastIndex
453+ lastVisible >= effectiveLast || listItems.isEmpty()
400454 }
401455 }
402456
@@ -405,28 +459,28 @@ fun ChatScreen(
405459 // (rotation) — rememberLazyListState restores the user's scroll offset, and an
406460 // unconditional scrollToItem here would clobber it, snapping them back to the bottom.
407461 var didInitialScroll by rememberSaveable { mutableStateOf(false) }
408− LaunchedEffect(messages.isNotEmpty()) {
409− if (messages.isNotEmpty() && !didInitialScroll) {
462+ LaunchedEffect(listItems.isNotEmpty()) {
463+ if (listItems.isNotEmpty() && !didInitialScroll) {
410464 // When a run is active the LazyColumn has a trailing "__typing" row at
411− // messages.size; target it (not messages.lastIndex) so deep-linking into a
465+ // listItems.size; target it (not listItems.lastIndex) so deep-linking into a
412466 // running session lands truly pinned to bottom and streaming auto-scroll engages.
413− listState.scrollToItem(if (running) messages.size else messages.lastIndex)
467+ listState.scrollToItem(if (running) listItems.size else listItems.lastIndex)
414468 didInitialScroll = true
415469 }
416470 }
417471
418472 // When a run starts, the LazyColumn gains a trailing "__typing" row, so
419− // effectiveLast jumps from messages.lastIndex to messages.size and the
473+ // effectiveLast jumps from listItems.lastIndex to listItems.size and the
420474 // isPinnedToBottom check flips to false the instant you send — freezing
421− // auto-scroll. If the user was pinned to the last message just before the run
475+ // auto-scroll. If the user was pinned to the last item just before the run
422476 // began, bring the typing row into view so the pin (and streaming follow) is
423477 // preserved. Guard on the raw last-visible index (isPinnedToBottom has already
424478 // recomputed to false by now) so we don't scroll when the user had scrolled up.
425479 LaunchedEffect(running) {
426− if (running && messages.isNotEmpty()) {
480+ if (running && listItems.isNotEmpty()) {
427481 val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
428− if (lastVisible >= messages.lastIndex) {
429− runCatchingCancellable { listState.animateScrollToItem(messages.size) }
482+ if (lastVisible >= listItems.lastIndex) {
483+ runCatchingCancellable { listState.animateScrollToItem(listItems.size) }
430484 }
431485 }
432486 }
@@ -441,13 +495,13 @@ fun ChatScreen(
441495 // and tool output — not just TextPart — keeps a pinned view following a long
442496 // reasoning/tool block while it streams (previously it stalled at 0).
443497 val lastLen = streamingContentLength(messages.lastOrNull()?.parts?.lastOrNull())
444− AutoScrollSignal(messages.size, lastLen, isPinnedToBottom)
498+ AutoScrollSignal(listItems.size, lastLen, isPinnedToBottom)
445499 }.collect { signal ->
446500 if (signal.size > 0 && signal.pinned) {
447501 // Scroll to the effective last index, including the trailing
448502 // "__typing" row when a run is active so the working indicator
449503 // is brought into view (not just the last message).
450− val target = if (running) messages.size else messages.lastIndex
504+ val target = if (running) listItems.size else listItems.lastIndex
451505 listState.scrollToItem(target)
452506 }
453507 }
@@ -559,19 +613,29 @@ fun ChatScreen(
559613 contentPadding = PaddingValues(16.dp),
560614 verticalArrangement = Arrangement.spacedBy(16.dp),
561615 ) {
562− items(messages, key = { it.info.id }, contentType = { it.info::class }) { message ->
563− // Only the last (streaming) message needs isRunning — it drives the
564− // reasoning-block spinner. Passing the live flag to every bubble
565− // makes all visible messages recompose whenever a run starts or stops.
566− val modelLabel = (message.info as? soy.iko.opencode.data.model.AssistantMessage)
567− ?.let { resolveModelLabel(it, models) }
568− MessageBubble(
569− message,
570− isRunning = running && message.info.id == lastMessageId,
571− imageContext = imageContext,
572− modelLabel = modelLabel,
573− onOpenFile = onOpenFile,
574− )
616+ items(
617+ items = listItems,
618+ key = { it.key },
619+ contentType = { it.contentType },
620+ ) { item ->
621+ when (item) {
622+ is MessageListItem.Separator -> DateSeparator(item.label)
623+ is MessageListItem.Message -> {
624+ val message = item.message
625+ // Only the last (streaming) message needs isRunning — it drives the
626+ // reasoning-block spinner. Passing the live flag to every bubble
627+ // makes all visible messages recompose whenever a run starts or stops.
628+ val modelLabel = (message.info as? soy.iko.opencode.data.model.AssistantMessage)
629+ ?.let { resolveModelLabel(it, models) }
630+ MessageBubble(
631+ message,
632+ isRunning = running && message.info.id == lastMessageId,
633+ imageContext = imageContext,
634+ modelLabel = modelLabel,
635+ onOpenFile = onOpenFile,
636+ )
637+ }
638+ }
575639 }
576640 if (running) {
577641 item(key = "__typing") {
@@ -591,15 +655,15 @@ fun ChatScreen(
591655 }
592656 // Jump-to-latest affordance when the user has scrolled away during a stream.
593657 AnimatedVisibility(
594− visible = !isPinnedToBottom && messages.isNotEmpty(),
658+ visible = !isPinnedToBottom && listItems.isNotEmpty(),
595659 modifier = Modifier.align(Alignment.BottomEnd),
596660 ) {
597661 ExtendedFloatingActionButton(
598662 onClick = {
599− if (messages.isNotEmpty()) {
663+ if (listItems.isNotEmpty()) {
600664 // Scroll to the effective last index, including the
601665 // trailing "__typing" row when a run is active.
602− val target = if (running) messages.size else messages.lastIndex
666+ val target = if (running) listItems.size else listItems.lastIndex
603667 contentScope.launch { runCatchingCancellable { listState.animateScrollToItem(target) } }
604668 }
605669 },
@@ -651,6 +715,35 @@ fun ChatScreen(
651715 )
652716 }
653717
718+ if (showRenameDialog) {
719+ RenameSessionChatDialog(
720+ initialTitle = sessionTitle ?: "",
721+ onDismiss = { showRenameDialog = false },
722+ onConfirm = { newName ->
723+ showRenameDialog = false
724+ vm.renameSession(newName)
725+ },
726+ )
727+ }
728+
729+ if (showDeleteDialog) {
730+ AlertDialog(
731+ onDismissRequest = { showDeleteDialog = false },
732+ title = { Text(stringResource(R.string.delete_session_chat_title)) },
733+ text = { Text(stringResource(R.string.delete_session_chat_text, sessionTitle ?: sessionLabel)) },
734+ confirmButton = {
735+ TextButton(onClick = {
736+ showDeleteDialog = false
737+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
738+ vm.deleteSession()
739+ }) { Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) }
740+ },
741+ dismissButton = {
742+ TextButton(onClick = { showDeleteDialog = false }) { Text(stringResource(R.string.cancel)) }
743+ },
744+ )
745+ }
746+
654747 if (showModelPicker) {
655748 ModelPickerSheet(
656749 options = models,
@@ -911,3 +1004,151 @@ private fun streamingContentLength(part: Part?): Int = when (part) {
9111004 }
9121005 else -> 0
9131006}
1007+
1008+/** Rename dialog for the currently-open session. Mirrors the SessionListScreen rename
1009+ * dialog but lives in the chat screen so the user can rename without backing out to
1010+ * the list. Pre-filled with the current title; the cap matches the list dialog. */
1011+@Composable
1012+private fun RenameSessionChatDialog(
1013+ initialTitle: String,
1014+ onDismiss: () -> Unit,
1015+ onConfirm: (String) -> Unit,
1016+) {
1017+ var title by rememberSaveable(initialTitle) { mutableStateOf(initialTitle) }
1018+ AlertDialog(
1019+ onDismissRequest = onDismiss,
1020+ title = { Text(stringResource(R.string.rename_session_chat)) },
1021+ text = {
1022+ OutlinedTextField(
1023+ value = title,
1024+ onValueChange = { v -> title = v.take(NetworkConfig.maxSessionTitleChars) },
1025+ modifier = Modifier.fillMaxWidth(),
1026+ placeholder = { Text(stringResource(R.string.session_title_hint)) },
1027+ label = { Text(stringResource(R.string.rename_session_chat)) },
1028+ singleLine = true,
1029+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
1030+ keyboardActions = KeyboardActions(onDone = { if (title.isNotBlank()) onConfirm(title.trim()) }),
1031+ )
1032+ },
1033+ confirmButton = {
1034+ TextButton(onClick = { if (title.isNotBlank()) onConfirm(title.trim()) }, enabled = title.isNotBlank()) {
1035+ Text(stringResource(R.string.save))
1036+ }
1037+ },
1038+ dismissButton = {
1039+ TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) }
1040+ },
1041+ )
1042+}
1043+
1044+/**
1045+ * A single item in the chat message list — either a [Message] bubble or a day
1046+ * [Separator] inserted before the first message of a new day. The separator shifts
1047+ * the LazyColumn indices vs. the raw messages list, so scroll-target math must use
1048+ * the [List] of these items (not `messages.size`/`lastIndex`).
1049+ */
1050+private sealed interface MessageListItem {
1051+ val key: Any
1052+ val contentType: Any
1053+
1054+ data class Message(val message: MessageWithParts) : MessageListItem {
1055+ override val key: Any get() = message.info.id
1056+ override val contentType: Any get() = message.info::class
1057+ }
1058+
1059+ data class Separator(val label: String) : MessageListItem {
1060+ // Stable key per label so a recomposition doesn't re-create the item.
1061+ override val key: Any get() = "sep_$label"
1062+ // Separators share a contentType so the LazyColumn can recycle their slots.
1063+ override val contentType: Any get() = "separator"
1064+ }
1065+}
1066+
1067+/**
1068+ * Build the interleaved list of [MessageListItem]s for the message list, inserting a
1069+ * [MessageListItem.Separator] before the first message of each new calendar day. Uses
1070+ * the message's `time.created` (falling back to `updated` then `completed`) to bucket.
1071+ * Messages with no timestamp are grouped under an empty-label separator only if they
1072+ * start the list, so a server that omits timestamps doesn't suppress the first divider.
1073+ *
1074+ * [todayLabel]/[yesterdayLabel] are resolved by the caller (a @Composable can't call
1075+ * stringResource from inside this plain function) and used for the "Today"/"Yesterday"
1076+ * labels; older days fall back to a locale-stable medium-date format.
1077+ */
1078+private fun buildMessageListItems(
1079+ messages: List<MessageWithParts>,
1080+ todayLabel: String,
1081+ yesterdayLabel: String,
1082+): List<MessageListItem> {
1083+ if (messages.isEmpty()) return emptyList()
1084+ val today = java.time.LocalDate.now(java.time.ZoneId.systemDefault())
1085+ val dateFmt = java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM)
1086+ val result = ArrayList<MessageListItem>(messages.size + 4)
1087+ var lastDayKey: String? = null
1088+ for (message in messages) {
1089+ val ts = message.info.time?.created ?: message.info.time?.updated ?: message.info.time?.completed
1090+ val dayKey = ts?.let { dayKey(it) } ?: ""
1091+ if (dayKey != lastDayKey) {
1092+ result.add(MessageListItem.Separator(dayLabel(dayKey, ts ?: 0L, today, todayLabel, yesterdayLabel, dateFmt)))
1093+ lastDayKey = dayKey
1094+ }
1095+ result.add(MessageListItem.Message(message))
1096+ }
1097+ return result
1098+}
1099+
1100+/** A stable bucket key for a timestamp's calendar day (epoch-days as a string). */
1101+private fun dayKey(epochMillis: Long): String {
1102+ val instant = java.time.Instant.ofEpochMilli(epochMillis)
1103+ val date = instant.atZone(java.time.ZoneId.systemDefault()).toLocalDate()
1104+ return date.toEpochDay().toString()
1105+}
1106+
1107+/**
1108+ * Resolve a day-key back to a human-readable label. "Today" / "Yesterday" for the
1109+ * recent days, otherwise a locale-stable medium-date format. Returns an empty string
1110+ * when [dayKey] is empty (no timestamp) so no separator is rendered for un-timestamped
1111+ * messages.
1112+ */
1113+private fun dayLabel(
1114+ dayKey: String,
1115+ ts: Long,
1116+ today: java.time.LocalDate,
1117+ todayLabel: String,
1118+ yesterdayLabel: String,
1119+ dateFmt: java.text.DateFormat,
1120+): String {
1121+ if (dayKey.isEmpty()) return ""
1122+ val epochDay = dayKey.toLongOrNull() ?: return ""
1123+ val date = java.time.LocalDate.ofEpochDay(epochDay)
1124+ return when {
1125+ date == today -> todayLabel
1126+ date == today.minusDays(1) -> yesterdayLabel
1127+ else -> dateFmt.format(java.util.Date(ts))
1128+ }
1129+}
1130+
1131+/**
1132+ * A centered day divider in the message list. Renders nothing for an empty label
1133+ * (the no-timestamp case) so un-timestamped messages don't get a stray blank divider.
1134+ */
1135+@Composable
1136+private fun DateSeparator(label: String) {
1137+ if (label.isBlank()) return
1138+ Row(
1139+ modifier = Modifier.fillMaxWidth(),
1140+ horizontalArrangement = Arrangement.Center,
1141+ ) {
1142+ Surface(
1143+ color = MaterialTheme.colorScheme.surfaceVariant,
1144+ shape = MaterialTheme.shapes.small,
1145+ ) {
1146+ Text(
1147+ label,
1148+ style = MaterialTheme.typography.labelSmall,
1149+ color = MaterialTheme.colorScheme.onSurfaceVariant,
1150+ modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
1151+ )
1152+ }
1153+ }
1154+}
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+30
@@ -675,6 +675,36 @@ class ChatViewModel(
675675 }
676676 }
677677
678+ /** Rename the current session via PATCH /session/:id. On success updates [sessionTitle]
679+ * so the top bar reflects the new name immediately. A failure surfaces as a snackbar;
680+ * the caller keeps the dialog open so the user can retry without retyping. */
681+ fun renameSession(newTitle: String) {
682+ val conn = connection ?: return
683+ val title = newTitle.trim()
684+ if (title.isEmpty() || title == _sessionTitle.value) return
685+ viewModelScope.launch {
686+ runCatchingCancellable { conn.api.updateSession(sessionId, title) }
687+ .onSuccess { _sessionTitle.value = it.displayTitle }
688+ .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
689+ }
690+ }
691+
692+ /** Delete the current session via DELETE /session/:id. Surfaces a toast (via the UI's
693+ * sessionDeleted flow) and navigates away on success; a failure surfaces as a snackbar.
694+ * Unlike [SessionListViewModel.deleteSession] there's no undo window — the user is
695+ * already viewing the session, so a confirmation dialog guards the action instead. */
696+ fun deleteSession() {
697+ val conn = connection ?: return
698+ viewModelScope.launch {
699+ runCatchingCancellable { conn.repository.deleteSession(sessionId) }
700+ .onSuccess {
701+ container.draftStore.remove(sessionId)
702+ _sessionDeleted.value = true
703+ }
704+ .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
705+ }
706+ }
707+
678708 /** Reconnect to the most recently used server profile (used when the connection is gone). */
679709 fun reconnect() {
680710 if (container.activeConnection.value != null) return
app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+66−24
@@ -16,13 +16,14 @@ import androidx.compose.foundation.lazy.items
1616import androidx.compose.material.icons.Icons
1717import androidx.compose.material.icons.filled.Add
1818import androidx.compose.material.icons.filled.CheckCircle
19−import androidx.compose.material.icons.filled.ContentCopy
2019import androidx.compose.material.icons.filled.Delete
2120import androidx.compose.material.icons.filled.Dns
22−import androidx.compose.material.icons.filled.Edit
21+import androidx.compose.material.icons.filled.MoreVert
2322import androidx.compose.material3.AlertDialog
2423import androidx.compose.material3.Card
2524import androidx.compose.material3.CircularProgressIndicator
25+import androidx.compose.material3.DropdownMenu
26+import androidx.compose.material3.DropdownMenuItem
2627import androidx.compose.material3.ExperimentalMaterial3Api
2728import androidx.compose.material3.FloatingActionButton
2829import androidx.compose.material3.Icon
@@ -227,20 +228,39 @@ fun ServerListScreen(
227228 tint = MaterialTheme.colorScheme.primary,
228229 modifier = Modifier.size(22.dp),
229230 )
230− // Keep edit available on the active server so credentials can be
231− // fixed without disconnecting first. Delete is also offered: the
232− // confirmation dialog warns that removing the active server also
233− // disconnects (remove_server_active_text), and the ViewModel
234− // disconnects immediately on confirm. Swipe remains disabled for
235− // the active row so an accidental swipe can't disconnect.
236− IconButton(onClick = { onEditProfile(profile.id) }) {
237− Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.edit))
238− }
239− IconButton(onClick = { onDuplicateProfile(profile.id) }) {
240− Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.duplicate_server))
241− }
242− IconButton(onClick = { pendingDeleteId = profile.id }) {
243− Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete))
231+ // Overflow menu consolidates edit/duplicate/delete so the
232+ // active row shows a single chevron instead of three icons.
233+ // Keep edit available on the active server so credentials can
234+ // be fixed without disconnecting first. Delete is also offered:
235+ // the confirmation dialog warns that removing the active server
236+ // also disconnects (remove_server_active_text), and the
237+ // ViewModel disconnects immediately on confirm.
238+ var showRowMenu by rememberSaveable(profile.id) { mutableStateOf(false) }
239+ val editLabel = stringResource(R.string.edit)
240+ val duplicateLabel = stringResource(R.string.duplicate_server)
241+ val removeLabel = stringResource(R.string.remove)
242+ val moreLabel = stringResource(R.string.more)
243+ Box {
244+ IconButton(onClick = { showRowMenu = true }) {
245+ Icon(Icons.Filled.MoreVert, contentDescription = moreLabel)
246+ }
247+ DropdownMenu(
248+ expanded = showRowMenu,
249+ onDismissRequest = { showRowMenu = false },
250+ ) {
251+ DropdownMenuItem(
252+ text = { Text(editLabel) },
253+ onClick = { showRowMenu = false; onEditProfile(profile.id) },
254+ )
255+ DropdownMenuItem(
256+ text = { Text(duplicateLabel) },
257+ onClick = { showRowMenu = false; onDuplicateProfile(profile.id) },
258+ )
259+ DropdownMenuItem(
260+ text = { Text(removeLabel, color = MaterialTheme.colorScheme.error) },
261+ onClick = { showRowMenu = false; pendingDeleteId = profile.id },
262+ )
263+ }
244264 }
245265 }
246266 }
@@ -312,14 +332,36 @@ fun ServerListScreen(
312332 modifier = Modifier.size(24.dp).semantics { contentDescription = connectingLabel },
313333 )
314334 } else {
315− IconButton(onClick = { onEditProfile(profile.id) }) {
316− Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.edit))
317− }
318− IconButton(onClick = { onDuplicateProfile(profile.id) }) {
319− Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.duplicate_server))
320− }
321− IconButton(onClick = { pendingDeleteId = profile.id }) {
322− Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete))
335+ // Overflow menu replaces the three inline edit/duplicate/
336+ // delete icons so a long server list looks less busy.
337+ // Swipe-to-delete remains the gesture path; the overflow
338+ // is the discovery path (matching the session cards).
339+ var showRowMenu by rememberSaveable(profile.id) { mutableStateOf(false) }
340+ val editLabel = stringResource(R.string.edit)
341+ val duplicateLabel = stringResource(R.string.duplicate_server)
342+ val removeLabel = stringResource(R.string.remove)
343+ val moreLabel = stringResource(R.string.more)
344+ Box {
345+ IconButton(onClick = { showRowMenu = true }) {
346+ Icon(Icons.Filled.MoreVert, contentDescription = moreLabel)
347+ }
348+ DropdownMenu(
349+ expanded = showRowMenu,
350+ onDismissRequest = { showRowMenu = false },
351+ ) {
352+ DropdownMenuItem(
353+ text = { Text(editLabel) },
354+ onClick = { showRowMenu = false; onEditProfile(profile.id) },
355+ )
356+ DropdownMenuItem(
357+ text = { Text(duplicateLabel) },
358+ onClick = { showRowMenu = false; onDuplicateProfile(profile.id) },
359+ )
360+ DropdownMenuItem(
361+ text = { Text(removeLabel, color = MaterialTheme.colorScheme.error) },
362+ onClick = { showRowMenu = false; pendingDeleteId = profile.id },
363+ )
364+ }
323365 }
324366 }
325367 }
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+40−8
@@ -24,11 +24,9 @@ import androidx.compose.material.icons.filled.Add
2424import androidx.compose.material.icons.filled.ArrowDropDown
2525import androidx.compose.material.icons.filled.ChatBubbleOutline
2626import androidx.compose.material.icons.filled.CheckCircle
27−import androidx.compose.material.icons.filled.ContentCopy
2827import androidx.compose.material.icons.filled.Delete
29−import androidx.compose.material.icons.filled.Dns
30−import androidx.compose.material.icons.filled.Edit
3128import androidx.compose.material.icons.filled.Folder
29+import androidx.compose.material.icons.filled.MoreVert
3230import androidx.compose.material.icons.filled.Refresh
3331import androidx.compose.material.icons.filled.Search
3432import androidx.compose.material.icons.filled.Settings
@@ -101,6 +99,7 @@ fun SessionListScreen(
10199 onOpenFiles: () -> Unit,
102100 onOpenSettings: () -> Unit,
103101 onAddServer: () -> Unit,
102+ selectedSessionId: String? = null,
104103) {
105104 val vm: SessionListViewModel = viewModel(factory = vmFactory { SessionListViewModel(container) })
106105 val state by vm.state.collectAsStateWithLifecycle()
@@ -264,6 +263,7 @@ fun SessionListScreen(
264263 unread = unread,
265264 refreshing = refreshing,
266265 haptics = haptics,
266+ selectedSessionId = selectedSessionId,
267267 onRefresh = vm::refresh,
268268 onQueryChange = vm::setQuery,
269269 onOpenSession = onOpenSession,
@@ -332,6 +332,7 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
332332 unread: Map<String, Int>,
333333 refreshing: Boolean,
334334 haptics: androidx.compose.ui.hapticfeedback.HapticFeedback,
335+ selectedSessionId: String?,
335336 onRefresh: () -> Unit,
336337 onQueryChange: (String) -> Unit,
337338 onOpenSession: (String) -> Unit,
@@ -458,6 +459,7 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
458459 session = session,
459460 preview = state.previews[session.id],
460461 unreadCount = unread[session.id] ?: 0,
462+ isSelected = session.id == selectedSessionId,
461463 onClick = { onOpenSession(session.id) },
462464 onRename = { onRename(session.id) },
463465 onDelete = { onDelete(session.id) },
@@ -513,11 +515,21 @@ private fun SessionCard(
513515 onRename: () -> Unit,
514516 onDelete: () -> Unit,
515517 modifier: Modifier = Modifier,
518+ isSelected: Boolean = false,
516519) {
520+ // In two-pane mode the selected row is highlighted (border + container tint) so
521+ // the user can tell which conversation is open in the detail pane at a glance —
522+ // without it every row looks identical and the open session is a guess.
523+ val containerColor = if (isSelected) MaterialTheme.colorScheme.primaryContainer
524+ else MaterialTheme.colorScheme.surface
525+ val borderColor = if (isSelected) MaterialTheme.colorScheme.primary
526+ else androidx.compose.ui.graphics.Color.Transparent
517527 Card(
518528 modifier = modifier
519529 .fillMaxWidth()
520530 .clickable { onClick() },
531+ colors = androidx.compose.material3.CardDefaults.cardColors(containerColor = containerColor),
532+ border = androidx.compose.foundation.BorderStroke(1.dp, borderColor),
521533 ) {
522534 Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
523535 Row(
@@ -576,11 +588,31 @@ private fun SessionCard(
576588 )
577589 }
578590 }
579− IconButton(onClick = onRename) {
580− Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.rename))
581− }
582− IconButton(onClick = onDelete) {
583− Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete))
591+ // Overflow menu replaces the inline edit + delete IconButtons so the
592+ // card row is decluttered — two always-visible icons per row made a
593+ // long list look busy. Swipe-to-delete and the overflow cover the same
594+ // actions; swipe remains the gesture path, overflow the discovery path.
595+ var showRowMenu by rememberSaveable(session.id) { mutableStateOf(false) }
596+ val renameLabel = stringResource(R.string.rename)
597+ val deleteLabel = stringResource(R.string.delete)
598+ val moreLabel = stringResource(R.string.more)
599+ Box {
600+ IconButton(onClick = { showRowMenu = true }) {
601+ Icon(Icons.Filled.MoreVert, contentDescription = moreLabel)
602+ }
603+ DropdownMenu(
604+ expanded = showRowMenu,
605+ onDismissRequest = { showRowMenu = false },
606+ ) {
607+ DropdownMenuItem(
608+ text = { Text(renameLabel) },
609+ onClick = { showRowMenu = false; onRename() },
610+ )
611+ DropdownMenuItem(
612+ text = { Text(deleteLabel, color = MaterialTheme.colorScheme.error) },
613+ onClick = { showRowMenu = false; onDelete() },
614+ )
615+ }
584616 }
585617 }
586618 if (!preview.isNullOrBlank()) {
app/src/main/java/soy/iko/opencode/ui/session/TwoPaneSessionChat.kt+1
@@ -104,6 +104,7 @@ fun TwoPaneSessionChat(
104104 onOpenFiles = onOpenFiles,
105105 onOpenSettings = onOpenSettings,
106106 onAddServer = onAddServer,
107+ selectedSessionId = selected,
107108 )
108109 }
109110
app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+23−9
@@ -229,8 +229,12 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
229229 color = MaterialTheme.colorScheme.onSurfaceVariant,
230230 )
231231 // Surface the SSE stream state so a user who opens Settings during a
232− // reconnect sees it (the ConnectionBanner on other screens handles this
233− // elsewhere). Only show non-Connected states to avoid clutter.
232+ // reconnect saw it (the ConnectionBanner on other screens handles this
233+ // elsewhere). Only show non-Connected states to avoid clutter. On a hard
234+ // failure offer an inline Retry — without it a user who opened Settings
235+ // during a failure is stuck on an error message with no recovery path
236+ // (the ConnectionBanner used elsewhere has Retry, but Settings doesn't
237+ // host it). Retry forces an SSE reconnect, which re-seeds from REST.
234238 val stateText = when (connectionState) {
235239 EventStreamClient.ConnectionState.Connecting -> stringResource(R.string.connecting)
236240 EventStreamClient.ConnectionState.Disconnected -> stringResource(R.string.reconnecting)
@@ -238,14 +242,24 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
238242 EventStreamClient.ConnectionState.Connected -> null
239243 }
240244 stateText?.let {
241− Text(
242− it,
243− style = MaterialTheme.typography.bodySmall,
244− color = if (connectionState == EventStreamClient.ConnectionState.Failed)
245− MaterialTheme.colorScheme.error
246− else MaterialTheme.colorScheme.onSurfaceVariant,
245+ Row(
247246 modifier = Modifier.padding(top = 4.dp),
248− )
247+ verticalAlignment = Alignment.CenterVertically,
248+ ) {
249+ Text(
250+ it,
251+ style = MaterialTheme.typography.bodySmall,
252+ color = if (connectionState == EventStreamClient.ConnectionState.Failed)
253+ MaterialTheme.colorScheme.error
254+ else MaterialTheme.colorScheme.onSurfaceVariant,
255+ )
256+ if (connectionState == EventStreamClient.ConnectionState.Failed) {
257+ TextButton(
258+ onClick = { container.activeConnection.value?.events?.triggerReconnect() },
259+ contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp),
260+ ) { Text(stringResource(R.string.retry_now)) }
261+ }
262+ }
249263 }
250264 } else {
251265 Text(
app/src/main/res/values/strings.xml+10
@@ -260,4 +260,14 @@
260260 <string name="run_command_text">Run the command “%1$s”?</string>
261261 <string name="run">Run</string>
262262 <string name="command_busy">Wait for the current response to finish before running a command.</string>
263+
264+ <!-- UX additions: chat overflow, date separators, settings retry -->
265+ <string name="more">More</string>
266+ <string name="delete_session_chat">Delete session</string>
267+ <string name="delete_session_chat_title">Delete this session?</string>
268+ <string name="delete_session_chat_text">“%1$s” will be permanently deleted. This cannot be undone.</string>
269+ <string name="rename_session_chat">Rename session</string>
270+ <string name="session_deleted_chat">Session deleted</string>
271+ <string name="today">Today</string>
272+ <string name="yesterday">Yesterday</string>
263273</resources>