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
178e422iko
app/detekt-baseline.xml+2−2
@@ -2,13 +2,13 @@
2<SmellBaseline>
2<SmellBaseline>
3 <ManuallySuppressedIssues/>
3 <ManuallySuppressedIssues/>
4 <CurrentIssues>
4 <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>
6 <ID>ComplexCondition:PartRenderer.kt$part.isImage &amp;&amp; imageContext != null &amp;&amp; (part.source != null || !part.url.isNullOrBlank())</ID>
5 <ID>ComplexCondition:PartRenderer.kt$part.isImage &amp;&amp; imageContext != null &amp;&amp; (part.source != null || !part.url.isNullOrBlank())</ID>
7 <ID>CyclomaticComplexMethod:ChatScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ChatScreen( container: AppContainer, sessionId: String, onBack: () -&gt; Unit, onOpenFile: ((String) -&gt; Unit)? = null, )</ID>
6 <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>
9 <ID>MatchingDeclarationName:DiffView.kt$DiffLine</ID>
8 <ID>MatchingDeclarationName:DiffView.kt$DiffLine</ID>
10 <ID>MatchingDeclarationName:RemoteImage.kt$ImageLoadContext</ID>
9 <ID>MatchingDeclarationName:RemoteImage.kt$ImageLoadContext</ID>
11 <ID>TooManyFunctions:AppContainer.kt$AppContainer</ID>
10 <ID>TooManyFunctions:AppContainer.kt$AppContainer</ID>
11+ <ID>TooManyFunctions:ChatViewModel.kt$ChatViewModel : ViewModel</ID>
12 <ID>TooManyFunctions:OpencodeApiClient.kt$OpencodeApiClient</ID>
12 <ID>TooManyFunctions:OpencodeApiClient.kt$OpencodeApiClient</ID>
13 <ID>UnusedParameter:MarkdownText.kt$style: TextStyle = MaterialTheme.typography.bodyLarge</ID>
13 <ID>UnusedParameter:MarkdownText.kt$style: TextStyle = MaterialTheme.typography.bodyLarge</ID>
14 </CurrentIssues>
14 </CurrentIssues>
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+332−91
@@ -26,16 +26,17 @@ import androidx.compose.material.icons.Icons
26import androidx.compose.material.icons.automirrored.filled.ArrowBack
26import androidx.compose.material.icons.automirrored.filled.ArrowBack
27import androidx.compose.material.icons.automirrored.filled.Send
27import androidx.compose.material.icons.automirrored.filled.Send
28import androidx.compose.material.icons.filled.AutoAwesome
28import androidx.compose.material.icons.filled.AutoAwesome
29+import androidx.compose.material.icons.filled.Delete
30+import androidx.compose.material.icons.filled.Edit
29import androidx.compose.material.icons.filled.KeyboardArrowDown
31import androidx.compose.material.icons.filled.KeyboardArrowDown
32+import androidx.compose.material.icons.filled.MoreVert
30import androidx.compose.material.icons.filled.Refresh
33import androidx.compose.material.icons.filled.Refresh
31−import androidx.compose.material.icons.filled.Share
32−import androidx.compose.material.icons.filled.SmartToy
33import androidx.compose.material.icons.filled.Stop
34import androidx.compose.material.icons.filled.Stop
34−import androidx.compose.material.icons.filled.Terminal
35−import androidx.compose.material.icons.filled.Tune
36import androidx.compose.material3.AlertDialog
35import androidx.compose.material3.AlertDialog
37import androidx.compose.material3.Button
36import androidx.compose.material3.Button
38import androidx.compose.material3.CircularProgressIndicator
37import androidx.compose.material3.CircularProgressIndicator
38+import androidx.compose.material3.DropdownMenu
39+import androidx.compose.material3.DropdownMenuItem
39import androidx.compose.material3.ExperimentalMaterial3Api
40import androidx.compose.material3.ExperimentalMaterial3Api
40import androidx.compose.material3.ExtendedFloatingActionButton
41import androidx.compose.material3.ExtendedFloatingActionButton
41import androidx.compose.material3.FloatingActionButton
42import androidx.compose.material3.FloatingActionButton
@@ -93,6 +94,7 @@ import kotlinx.coroutines.Dispatchers
93import kotlinx.coroutines.launch
94import kotlinx.coroutines.launch
94import kotlinx.coroutines.withContext
95import kotlinx.coroutines.withContext
95import soy.iko.opencode.data.model.Part
96import soy.iko.opencode.data.model.Part
97+import soy.iko.opencode.data.model.MessageWithParts
96import soy.iko.opencode.data.model.ReasoningPart
98import soy.iko.opencode.data.model.ReasoningPart
97import soy.iko.opencode.data.model.TextPart
99import soy.iko.opencode.data.model.TextPart
98import soy.iko.opencode.data.model.ToolCompleted
100import soy.iko.opencode.data.model.ToolCompleted
@@ -148,7 +150,6 @@ fun ChatScreen(
148 val haptics = LocalHapticFeedback.current
150 val haptics = LocalHapticFeedback.current
149 val scope = rememberCoroutineScope()
151 val scope = rememberCoroutineScope()
150 val shareContext = LocalContext.current
152 val shareContext = LocalContext.current
151− val shareLabel = stringResource(R.string.share)
152 val defaultShareSubject = stringResource(R.string.share_subject)
153 val defaultShareSubject = stringResource(R.string.share_subject)
153 val sessionLabel = stringResource(R.string.session)
154 val sessionLabel = stringResource(R.string.session)
154 val defaultModelLabel = stringResource(R.string.default_model)
155 val defaultModelLabel = stringResource(R.string.default_model)
@@ -177,10 +178,13 @@ fun ChatScreen(
177 // on a zombie screen showing a conversation that no longer exists. Surface a
178 // on a zombie screen showing a conversation that no longer exists. Surface a
178 // toast (not a snackbar) because the screen is about to be popped — a snackbar
179 // toast (not a snackbar) because the screen is about to be popped — a snackbar
179 // would be torn down before the user could read it, while a toast survives.
180 // 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).
180 val deletedContext = LocalContext.current
184 val deletedContext = LocalContext.current
181 LaunchedEffect(sessionDeleted) {
185 LaunchedEffect(sessionDeleted) {
182 if (sessionDeleted) {
186 if (sessionDeleted) {
183− showToast(deletedContext, deletedContext.getString(R.string.session_deleted_msg))
187+ showToast(deletedContext, deletedContext.getString(R.string.session_deleted_chat))
184 onBack()
188 onBack()
185 }
189 }
186 }
190 }
@@ -193,6 +197,9 @@ fun ChatScreen(
193 var showCommandPicker by rememberSaveable { mutableStateOf(false) }
197 var showCommandPicker by rememberSaveable { mutableStateOf(false) }
194 var showExitConfirm by rememberSaveable { mutableStateOf(false) }
198 var showExitConfirm by rememberSaveable { mutableStateOf(false) }
195 var showStopConfirm by rememberSaveable { mutableStateOf(false) }
199 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) }
196
203
197 // Keep the screen awake and hold a foreground priority while the agent is working,
204 // Keep the screen awake and hold a foreground priority while the agent is working,
198 // so backgrounding mid-run doesn't let Doze choke the SSE stream.
205 // so backgrounding mid-run doesn't let Doze choke the SSE stream.
@@ -241,12 +248,20 @@ fun ChatScreen(
241 .semantics(mergeDescendants = true) { contentDescription = changeLabel },
248 .semantics(mergeDescendants = true) { contentDescription = changeLabel },
242 verticalAlignment = Alignment.CenterVertically,
249 verticalAlignment = Alignment.CenterVertically,
243 ) {
250 ) {
244− Column {
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(
245− Text(sessionTitle ?: sessionLabel)
255+ sessionTitle ?: sessionLabel,
256+ maxLines = 1,
257+ overflow = TextOverflow.Ellipsis,
258+ )
246 Text(
259 Text(
247 subtitle,
260 subtitle,
248 style = MaterialTheme.typography.labelSmall,
261 style = MaterialTheme.typography.labelSmall,
249 color = MaterialTheme.colorScheme.onSurfaceVariant,
262 color = MaterialTheme.colorScheme.onSurfaceVariant,
263+ maxLines = 1,
264+ overflow = TextOverflow.Ellipsis,
250 )
265 )
251 }
266 }
252 // Visual affordance that the title opens the model picker;
267 // Visual affordance that the title opens the model picker;
@@ -301,60 +316,88 @@ fun ChatScreen(
301 Icon(Icons.Filled.Refresh, contentDescription = refreshLabel)
316 Icon(Icons.Filled.Refresh, contentDescription = refreshLabel)
302 }
317 }
303 }
318 }
304− IconButton(
319+ // Overflow menu consolidates the less-frequent actions (share,
305− onClick = {
320+ // commands, agent, model, rename, delete) so the top bar stays
306− scope.launch {
321+ // scannable on narrow phones — previously 5 icons crowded the bar
307− val md = withContext(Dispatchers.Default) {
322+ // alongside the back button and tappable title. Catalog-loading
308− buildConversationMarkdown(vm.messages.value, sessionTitle)
323+ // spinners stay inline (next to the overflow) so a load state is
309− }
324+ // visible without opening the menu.
310− val send = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
325+ val moreLabel = stringResource(R.string.more)
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)
326+ val loadingLabel = stringResource(R.string.loading)
329− CircularProgressIndicator(
327+ val shareLabel = stringResource(R.string.share_conversation)
330− Modifier.size(18.dp).semantics { contentDescription = loadingLabel },
328+ val commandsLabel = stringResource(R.string.commands)
331− strokeWidth = 2.dp,
329+ val agentLabel = stringResource(R.string.choose_agent)
332− )
330+ val modelLabel = stringResource(R.string.choose_model)
333− } else {
331+ val renameLabel = stringResource(R.string.rename_session_chat)
332+ val deleteLabel = stringResource(R.string.delete_session_chat)
333+ Box {
334− IconButton(onClick = { showCommandPicker = true }) {
334+ IconButton(onClick = { showOverflowMenu = true }) {
335− Icon(Icons.Filled.Terminal, contentDescription = stringResource(R.string.commands))
335+ Icon(Icons.Filled.MoreVert, contentDescription = moreLabel)
336 }
336 }
337− }
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) },
338− if (agentsLoading && agents.isEmpty()) {
367+ enabled = !agentsLoading || agents.isNotEmpty(),
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 }) {
368+ onClick = { showOverflowMenu = false; showAgentPicker = true },
346− Icon(Icons.Filled.SmartToy, contentDescription = stringResource(R.string.choose_agent))
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+ )
347 }
384 }
348− }
385+ // While a catalog is initially loading (empty list + loading flag),
349− if (modelsLoading && models.isEmpty()) {
386+ // show a tiny inline spinner beside the overflow so the user can see
350− val loadingLabel = stringResource(R.string.loading)
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) {
351− CircularProgressIndicator(
394+ CircularProgressIndicator(
395+ Modifier
396+ .align(Alignment.TopEnd)
397+ .size(10.dp)
352− Modifier.size(18.dp).semantics { contentDescription = loadingLabel },
398+ .semantics { contentDescription = loadingLabel },
353− strokeWidth = 2.dp,
399+ strokeWidth = 1.dp,
354− )
400+ )
355− } else {
356− IconButton(onClick = { showModelPicker = true }) {
357− Icon(Icons.Filled.Tune, contentDescription = stringResource(R.string.choose_model))
358 }
401 }
359 }
402 }
360 },
403 },
@@ -386,17 +429,28 @@ fun ChatScreen(
386 val listState = rememberLazyListState()
429 val listState = rememberLazyListState()
387 val contentScope = rememberCoroutineScope()
430 val contentScope = rememberCoroutineScope()
388 val timeTick = rememberRelativeTimeTick()
431 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+ }
389
443
390 val isPinnedToBottom by remember {
444 val isPinnedToBottom by remember {
391 derivedStateOf {
445 derivedStateOf {
392 val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
446 val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
393 // When a run is active, the LazyColumn has one trailing "__typing"
447 // 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"
395 // includes the working indicator — otherwise the pin check targets
449 // 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,
397 // and the user never sees the progress indicator even when pinned.
451 // and the user never sees the progress indicator even when pinned.
398− val effectiveLast = if (running) messages.size else messages.lastIndex
452+ val effectiveLast = if (running) listItems.size else listItems.lastIndex
399− lastVisible >= effectiveLast || messages.isEmpty()
453+ lastVisible >= effectiveLast || listItems.isEmpty()
400 }
454 }
401 }
455 }
402
456
@@ -405,28 +459,28 @@ fun ChatScreen(
405 // (rotation) — rememberLazyListState restores the user's scroll offset, and an
459 // (rotation) — rememberLazyListState restores the user's scroll offset, and an
406 // unconditional scrollToItem here would clobber it, snapping them back to the bottom.
460 // unconditional scrollToItem here would clobber it, snapping them back to the bottom.
407 var didInitialScroll by rememberSaveable { mutableStateOf(false) }
461 var didInitialScroll by rememberSaveable { mutableStateOf(false) }
408− LaunchedEffect(messages.isNotEmpty()) {
462+ LaunchedEffect(listItems.isNotEmpty()) {
409− if (messages.isNotEmpty() && !didInitialScroll) {
463+ if (listItems.isNotEmpty() && !didInitialScroll) {
410 // When a run is active the LazyColumn has a trailing "__typing" row at
464 // 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
412 // running session lands truly pinned to bottom and streaming auto-scroll engages.
466 // 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)
414 didInitialScroll = true
468 didInitialScroll = true
415 }
469 }
416 }
470 }
417
471
418 // When a run starts, the LazyColumn gains a trailing "__typing" row, so
472 // 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
420 // isPinnedToBottom check flips to false the instant you send — freezing
474 // 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
422 // began, bring the typing row into view so the pin (and streaming follow) is
476 // began, bring the typing row into view so the pin (and streaming follow) is
423 // preserved. Guard on the raw last-visible index (isPinnedToBottom has already
477 // preserved. Guard on the raw last-visible index (isPinnedToBottom has already
424 // recomputed to false by now) so we don't scroll when the user had scrolled up.
478 // recomputed to false by now) so we don't scroll when the user had scrolled up.
425 LaunchedEffect(running) {
479 LaunchedEffect(running) {
426− if (running && messages.isNotEmpty()) {
480+ if (running && listItems.isNotEmpty()) {
427 val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
481 val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
428− if (lastVisible >= messages.lastIndex) {
482+ if (lastVisible >= listItems.lastIndex) {
429− runCatchingCancellable { listState.animateScrollToItem(messages.size) }
483+ runCatchingCancellable { listState.animateScrollToItem(listItems.size) }
430 }
484 }
431 }
485 }
432 }
486 }
@@ -441,13 +495,13 @@ fun ChatScreen(
441 // and tool output — not just TextPart — keeps a pinned view following a long
495 // and tool output — not just TextPart — keeps a pinned view following a long
442 // reasoning/tool block while it streams (previously it stalled at 0).
496 // reasoning/tool block while it streams (previously it stalled at 0).
443 val lastLen = streamingContentLength(messages.lastOrNull()?.parts?.lastOrNull())
497 val lastLen = streamingContentLength(messages.lastOrNull()?.parts?.lastOrNull())
444− AutoScrollSignal(messages.size, lastLen, isPinnedToBottom)
498+ AutoScrollSignal(listItems.size, lastLen, isPinnedToBottom)
445 }.collect { signal ->
499 }.collect { signal ->
446 if (signal.size > 0 && signal.pinned) {
500 if (signal.size > 0 && signal.pinned) {
447 // Scroll to the effective last index, including the trailing
501 // Scroll to the effective last index, including the trailing
448 // "__typing" row when a run is active so the working indicator
502 // "__typing" row when a run is active so the working indicator
449 // is brought into view (not just the last message).
503 // 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
451 listState.scrollToItem(target)
505 listState.scrollToItem(target)
452 }
506 }
453 }
507 }
@@ -559,19 +613,29 @@ fun ChatScreen(
559 contentPadding = PaddingValues(16.dp),
613 contentPadding = PaddingValues(16.dp),
560 verticalArrangement = Arrangement.spacedBy(16.dp),
614 verticalArrangement = Arrangement.spacedBy(16.dp),
561 ) {
615 ) {
562− items(messages, key = { it.info.id }, contentType = { it.info::class }) { message ->
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
563− // Only the last (streaming) message needs isRunning — it drives the
625+ // Only the last (streaming) message needs isRunning — it drives the
564− // reasoning-block spinner. Passing the live flag to every bubble
626+ // reasoning-block spinner. Passing the live flag to every bubble
565− // makes all visible messages recompose whenever a run starts or stops.
627+ // makes all visible messages recompose whenever a run starts or stops.
566− val modelLabel = (message.info as? soy.iko.opencode.data.model.AssistantMessage)
628+ val modelLabel = (message.info as? soy.iko.opencode.data.model.AssistantMessage)
567− ?.let { resolveModelLabel(it, models) }
629+ ?.let { resolveModelLabel(it, models) }
568− MessageBubble(
630+ MessageBubble(
569− message,
631+ message,
570− isRunning = running && message.info.id == lastMessageId,
632+ isRunning = running && message.info.id == lastMessageId,
571− imageContext = imageContext,
633+ imageContext = imageContext,
572− modelLabel = modelLabel,
634+ modelLabel = modelLabel,
573− onOpenFile = onOpenFile,
635+ onOpenFile = onOpenFile,
574− )
636+ )
637+ }
638+ }
575 }
639 }
576 if (running) {
640 if (running) {
577 item(key = "__typing") {
641 item(key = "__typing") {
@@ -591,15 +655,15 @@ fun ChatScreen(
591 }
655 }
592 // Jump-to-latest affordance when the user has scrolled away during a stream.
656 // Jump-to-latest affordance when the user has scrolled away during a stream.
593 AnimatedVisibility(
657 AnimatedVisibility(
594− visible = !isPinnedToBottom && messages.isNotEmpty(),
658+ visible = !isPinnedToBottom && listItems.isNotEmpty(),
595 modifier = Modifier.align(Alignment.BottomEnd),
659 modifier = Modifier.align(Alignment.BottomEnd),
596 ) {
660 ) {
597 ExtendedFloatingActionButton(
661 ExtendedFloatingActionButton(
598 onClick = {
662 onClick = {
599− if (messages.isNotEmpty()) {
663+ if (listItems.isNotEmpty()) {
600 // Scroll to the effective last index, including the
664 // Scroll to the effective last index, including the
601 // trailing "__typing" row when a run is active.
665 // 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
603 contentScope.launch { runCatchingCancellable { listState.animateScrollToItem(target) } }
667 contentScope.launch { runCatchingCancellable { listState.animateScrollToItem(target) } }
604 }
668 }
605 },
669 },
@@ -651,6 +715,35 @@ fun ChatScreen(
651 )
715 )
652 }
716 }
653
717
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+
654 if (showModelPicker) {
747 if (showModelPicker) {
655 ModelPickerSheet(
748 ModelPickerSheet(
656 options = models,
749 options = models,
@@ -911,3 +1004,151 @@ private fun streamingContentLength(part: Part?): Int = when (part) {
911 }
1004 }
912 else -> 0
1005 else -> 0
913}
1006}
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(
675 }
675 }
676 }
676 }
677
677
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+
678 /** Reconnect to the most recently used server profile (used when the connection is gone). */
708 /** Reconnect to the most recently used server profile (used when the connection is gone). */
679 fun reconnect() {
709 fun reconnect() {
680 if (container.activeConnection.value != null) return
710 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
16import androidx.compose.material.icons.Icons
16import androidx.compose.material.icons.Icons
17import androidx.compose.material.icons.filled.Add
17import androidx.compose.material.icons.filled.Add
18import androidx.compose.material.icons.filled.CheckCircle
18import androidx.compose.material.icons.filled.CheckCircle
19−import androidx.compose.material.icons.filled.ContentCopy
20import androidx.compose.material.icons.filled.Delete
19import androidx.compose.material.icons.filled.Delete
21import androidx.compose.material.icons.filled.Dns
20import androidx.compose.material.icons.filled.Dns
22−import androidx.compose.material.icons.filled.Edit
21+import androidx.compose.material.icons.filled.MoreVert
23import androidx.compose.material3.AlertDialog
22import androidx.compose.material3.AlertDialog
24import androidx.compose.material3.Card
23import androidx.compose.material3.Card
25import androidx.compose.material3.CircularProgressIndicator
24import androidx.compose.material3.CircularProgressIndicator
25+import androidx.compose.material3.DropdownMenu
26+import androidx.compose.material3.DropdownMenuItem
26import androidx.compose.material3.ExperimentalMaterial3Api
27import androidx.compose.material3.ExperimentalMaterial3Api
27import androidx.compose.material3.FloatingActionButton
28import androidx.compose.material3.FloatingActionButton
28import androidx.compose.material3.Icon
29import androidx.compose.material3.Icon
@@ -227,20 +228,39 @@ fun ServerListScreen(
227 tint = MaterialTheme.colorScheme.primary,
228 tint = MaterialTheme.colorScheme.primary,
228 modifier = Modifier.size(22.dp),
229 modifier = Modifier.size(22.dp),
229 )
230 )
231+ // Overflow menu consolidates edit/duplicate/delete so the
232+ // active row shows a single chevron instead of three icons.
230− // Keep edit available on the active server so credentials can be
233+ // Keep edit available on the active server so credentials can
231− // fixed without disconnecting first. Delete is also offered: the
234+ // be fixed without disconnecting first. Delete is also offered:
232− // confirmation dialog warns that removing the active server also
235+ // the confirmation dialog warns that removing the active server
233− // disconnects (remove_server_active_text), and the ViewModel
236+ // also disconnects (remove_server_active_text), and the
234− // disconnects immediately on confirm. Swipe remains disabled for
237+ // ViewModel disconnects immediately on confirm.
235− // the active row so an accidental swipe can't disconnect.
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) },
236− IconButton(onClick = { onEditProfile(profile.id) }) {
253+ onClick = { showRowMenu = false; onEditProfile(profile.id) },
237− Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.edit))
254+ )
238− }
255+ DropdownMenuItem(
256+ text = { Text(duplicateLabel) },
239− IconButton(onClick = { onDuplicateProfile(profile.id) }) {
257+ onClick = { showRowMenu = false; onDuplicateProfile(profile.id) },
240− Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.duplicate_server))
258+ )
241− }
259+ DropdownMenuItem(
260+ text = { Text(removeLabel, color = MaterialTheme.colorScheme.error) },
242− IconButton(onClick = { pendingDeleteId = profile.id }) {
261+ onClick = { showRowMenu = false; pendingDeleteId = profile.id },
243− Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete))
262+ )
263+ }
244 }
264 }
245 }
265 }
246 }
266 }
@@ -312,14 +332,36 @@ fun ServerListScreen(
312 modifier = Modifier.size(24.dp).semantics { contentDescription = connectingLabel },
332 modifier = Modifier.size(24.dp).semantics { contentDescription = connectingLabel },
313 )
333 )
314 } else {
334 } else {
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) },
315− IconButton(onClick = { onEditProfile(profile.id) }) {
354+ onClick = { showRowMenu = false; onEditProfile(profile.id) },
316− Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.edit))
355+ )
317− }
356+ DropdownMenuItem(
357+ text = { Text(duplicateLabel) },
318− IconButton(onClick = { onDuplicateProfile(profile.id) }) {
358+ onClick = { showRowMenu = false; onDuplicateProfile(profile.id) },
319− Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.duplicate_server))
359+ )
320− }
360+ DropdownMenuItem(
361+ text = { Text(removeLabel, color = MaterialTheme.colorScheme.error) },
321− IconButton(onClick = { pendingDeleteId = profile.id }) {
362+ onClick = { showRowMenu = false; pendingDeleteId = profile.id },
322− Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete))
363+ )
364+ }
323 }
365 }
324 }
366 }
325 }
367 }
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+40−8
@@ -24,11 +24,9 @@ import androidx.compose.material.icons.filled.Add
24import androidx.compose.material.icons.filled.ArrowDropDown
24import androidx.compose.material.icons.filled.ArrowDropDown
25import androidx.compose.material.icons.filled.ChatBubbleOutline
25import androidx.compose.material.icons.filled.ChatBubbleOutline
26import androidx.compose.material.icons.filled.CheckCircle
26import androidx.compose.material.icons.filled.CheckCircle
27−import androidx.compose.material.icons.filled.ContentCopy
28import androidx.compose.material.icons.filled.Delete
27import androidx.compose.material.icons.filled.Delete
29−import androidx.compose.material.icons.filled.Dns
30−import androidx.compose.material.icons.filled.Edit
31import androidx.compose.material.icons.filled.Folder
28import androidx.compose.material.icons.filled.Folder
29+import androidx.compose.material.icons.filled.MoreVert
32import androidx.compose.material.icons.filled.Refresh
30import androidx.compose.material.icons.filled.Refresh
33import androidx.compose.material.icons.filled.Search
31import androidx.compose.material.icons.filled.Search
34import androidx.compose.material.icons.filled.Settings
32import androidx.compose.material.icons.filled.Settings
@@ -101,6 +99,7 @@ fun SessionListScreen(
101 onOpenFiles: () -> Unit,
99 onOpenFiles: () -> Unit,
102 onOpenSettings: () -> Unit,
100 onOpenSettings: () -> Unit,
103 onAddServer: () -> Unit,
101 onAddServer: () -> Unit,
102+ selectedSessionId: String? = null,
104) {
103) {
105 val vm: SessionListViewModel = viewModel(factory = vmFactory { SessionListViewModel(container) })
104 val vm: SessionListViewModel = viewModel(factory = vmFactory { SessionListViewModel(container) })
106 val state by vm.state.collectAsStateWithLifecycle()
105 val state by vm.state.collectAsStateWithLifecycle()
@@ -264,6 +263,7 @@ fun SessionListScreen(
264 unread = unread,
263 unread = unread,
265 refreshing = refreshing,
264 refreshing = refreshing,
266 haptics = haptics,
265 haptics = haptics,
266+ selectedSessionId = selectedSessionId,
267 onRefresh = vm::refresh,
267 onRefresh = vm::refresh,
268 onQueryChange = vm::setQuery,
268 onQueryChange = vm::setQuery,
269 onOpenSession = onOpenSession,
269 onOpenSession = onOpenSession,
@@ -332,6 +332,7 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
332 unread: Map<String, Int>,
332 unread: Map<String, Int>,
333 refreshing: Boolean,
333 refreshing: Boolean,
334 haptics: androidx.compose.ui.hapticfeedback.HapticFeedback,
334 haptics: androidx.compose.ui.hapticfeedback.HapticFeedback,
335+ selectedSessionId: String?,
335 onRefresh: () -> Unit,
336 onRefresh: () -> Unit,
336 onQueryChange: (String) -> Unit,
337 onQueryChange: (String) -> Unit,
337 onOpenSession: (String) -> Unit,
338 onOpenSession: (String) -> Unit,
@@ -458,6 +459,7 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
458 session = session,
459 session = session,
459 preview = state.previews[session.id],
460 preview = state.previews[session.id],
460 unreadCount = unread[session.id] ?: 0,
461 unreadCount = unread[session.id] ?: 0,
462+ isSelected = session.id == selectedSessionId,
461 onClick = { onOpenSession(session.id) },
463 onClick = { onOpenSession(session.id) },
462 onRename = { onRename(session.id) },
464 onRename = { onRename(session.id) },
463 onDelete = { onDelete(session.id) },
465 onDelete = { onDelete(session.id) },
@@ -513,11 +515,21 @@ private fun SessionCard(
513 onRename: () -> Unit,
515 onRename: () -> Unit,
514 onDelete: () -> Unit,
516 onDelete: () -> Unit,
515 modifier: Modifier = Modifier,
517 modifier: Modifier = Modifier,
518+ isSelected: Boolean = false,
516) {
519) {
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
517 Card(
527 Card(
518 modifier = modifier
528 modifier = modifier
519 .fillMaxWidth()
529 .fillMaxWidth()
520 .clickable { onClick() },
530 .clickable { onClick() },
531+ colors = androidx.compose.material3.CardDefaults.cardColors(containerColor = containerColor),
532+ border = androidx.compose.foundation.BorderStroke(1.dp, borderColor),
521 ) {
533 ) {
522 Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
534 Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
523 Row(
535 Row(
@@ -576,11 +588,31 @@ private fun SessionCard(
576 )
588 )
577 }
589 }
578 }
590 }
579− IconButton(onClick = onRename) {
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) }
580− Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.rename))
596+ val renameLabel = stringResource(R.string.rename)
581− }
597+ val deleteLabel = stringResource(R.string.delete)
598+ val moreLabel = stringResource(R.string.more)
599+ Box {
582− IconButton(onClick = onDelete) {
600+ IconButton(onClick = { showRowMenu = true }) {
583− Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete))
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+ }
584 }
616 }
585 }
617 }
586 if (!preview.isNullOrBlank()) {
618 if (!preview.isNullOrBlank()) {
app/src/main/java/soy/iko/opencode/ui/session/TwoPaneSessionChat.kt+1
@@ -104,6 +104,7 @@ fun TwoPaneSessionChat(
104 onOpenFiles = onOpenFiles,
104 onOpenFiles = onOpenFiles,
105 onOpenSettings = onOpenSettings,
105 onOpenSettings = onOpenSettings,
106 onAddServer = onAddServer,
106 onAddServer = onAddServer,
107+ selectedSessionId = selected,
107 )
108 )
108 }
109 }
109
110
app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+23−9
@@ -229,8 +229,12 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
229 color = MaterialTheme.colorScheme.onSurfaceVariant,
229 color = MaterialTheme.colorScheme.onSurfaceVariant,
230 )
230 )
231 // Surface the SSE stream state so a user who opens Settings during a
231 // Surface the SSE stream state so a user who opens Settings during a
232− // reconnect sees it (the ConnectionBanner on other screens handles this
232+ // reconnect saw it (the ConnectionBanner on other screens handles this
233− // elsewhere). Only show non-Connected states to avoid clutter.
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.
234 val stateText = when (connectionState) {
238 val stateText = when (connectionState) {
235 EventStreamClient.ConnectionState.Connecting -> stringResource(R.string.connecting)
239 EventStreamClient.ConnectionState.Connecting -> stringResource(R.string.connecting)
236 EventStreamClient.ConnectionState.Disconnected -> stringResource(R.string.reconnecting)
240 EventStreamClient.ConnectionState.Disconnected -> stringResource(R.string.reconnecting)
@@ -238,14 +242,24 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
238 EventStreamClient.ConnectionState.Connected -> null
242 EventStreamClient.ConnectionState.Connected -> null
239 }
243 }
240 stateText?.let {
244 stateText?.let {
241− Text(
245+ Row(
242− it,
243− style = MaterialTheme.typography.bodySmall,
244− color = if (connectionState == EventStreamClient.ConnectionState.Failed)
245− MaterialTheme.colorScheme.error
246− else MaterialTheme.colorScheme.onSurfaceVariant,
247 modifier = Modifier.padding(top = 4.dp),
246 modifier = Modifier.padding(top = 4.dp),
247+ verticalAlignment = Alignment.CenterVertically,
248− )
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+ }
249 }
263 }
250 } else {
264 } else {
251 Text(
265 Text(
app/src/main/res/values/strings.xml+10
@@ -260,4 +260,14 @@
260 <string name="run_command_text">Run the command “%1$s”?</string>
260 <string name="run_command_text">Run the command “%1$s”?</string>
261 <string name="run">Run</string>
261 <string name="run">Run</string>
262 <string name="command_busy">Wait for the current response to finish before running a command.</string>
262 <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>
263</resources>
273</resources>