@@ -1,11 +1,14 @@
1package soy.iko.opencode.ui.chat
1package soy.iko.opencode.ui.chat
3+import androidx.activity.compose.BackHandler
4+import androidx.compose.animation.AnimatedVisibility
3import androidx.compose.foundation.clickable
5import androidx.compose.foundation.clickable
4import androidx.compose.foundation.layout.Arrangement
6import androidx.compose.foundation.layout.Arrangement
5import androidx.compose.foundation.layout.Box
7import androidx.compose.foundation.layout.Box
6import androidx.compose.foundation.layout.Column
8import androidx.compose.foundation.layout.Column
7import androidx.compose.foundation.layout.PaddingValues
9import androidx.compose.foundation.layout.PaddingValues
8import androidx.compose.foundation.layout.Row
10import androidx.compose.foundation.layout.Row
11+import androidx.compose.foundation.layout.Spacer
9import androidx.compose.foundation.layout.fillMaxSize
12import androidx.compose.foundation.layout.fillMaxSize
10import androidx.compose.foundation.layout.fillMaxWidth
13import androidx.compose.foundation.layout.fillMaxWidth
11import androidx.compose.foundation.layout.padding
14import androidx.compose.foundation.layout.padding
@@ -16,12 +19,16 @@ import androidx.compose.foundation.lazy.rememberLazyListState
16import androidx.compose.material.icons.Icons
19import androidx.compose.material.icons.Icons
17import androidx.compose.material.icons.automirrored.filled.ArrowBack
20import androidx.compose.material.icons.automirrored.filled.ArrowBack
18import androidx.compose.material.icons.automirrored.filled.Send
21import androidx.compose.material.icons.automirrored.filled.Send
22+import androidx.compose.material.icons.filled.KeyboardArrowDown
19import androidx.compose.material.icons.filled.SmartToy
23import androidx.compose.material.icons.filled.SmartToy
20import androidx.compose.material.icons.filled.Stop
24import androidx.compose.material.icons.filled.Stop
21import androidx.compose.material.icons.filled.Terminal
25import androidx.compose.material.icons.filled.Terminal
22import androidx.compose.material.icons.filled.Tune
26import androidx.compose.material.icons.filled.Tune
27+import androidx.compose.material3.AlertDialog
23import androidx.compose.material3.CircularProgressIndicator
28import androidx.compose.material3.CircularProgressIndicator
24import androidx.compose.material3.ExperimentalMaterial3Api
29import androidx.compose.material3.ExperimentalMaterial3Api
30+import androidx.compose.material3.ExtendedFloatingActionButton
31+import androidx.compose.material3.FloatingActionButton
25import androidx.compose.material3.Icon
32import androidx.compose.material3.Icon
26import androidx.compose.material3.IconButton
33import androidx.compose.material3.IconButton
27import androidx.compose.material3.MaterialTheme
34import androidx.compose.material3.MaterialTheme
@@ -31,18 +38,24 @@ import androidx.compose.material3.SnackbarHost
31import androidx.compose.material3.SnackbarHostState
38import androidx.compose.material3.SnackbarHostState
32import androidx.compose.material3.Surface
39import androidx.compose.material3.Surface
33import androidx.compose.material3.Text
40import androidx.compose.material3.Text
41+import androidx.compose.material3.TextButton
34import androidx.compose.material3.TopAppBar
42import androidx.compose.material3.TopAppBar
35import androidx.compose.runtime.Composable
43import androidx.compose.runtime.Composable
36import androidx.compose.runtime.LaunchedEffect
44import androidx.compose.runtime.LaunchedEffect
45+import androidx.compose.runtime.derivedStateOf
37import androidx.compose.runtime.getValue
46import androidx.compose.runtime.getValue
38import androidx.compose.runtime.mutableStateOf
47import androidx.compose.runtime.mutableStateOf
39import androidx.compose.runtime.remember
48import androidx.compose.runtime.remember
49+import androidx.compose.runtime.rememberCoroutineScope
40import androidx.compose.runtime.setValue
50import androidx.compose.runtime.setValue
41import androidx.compose.ui.Alignment
51import androidx.compose.ui.Alignment
42import androidx.compose.ui.Modifier
52import androidx.compose.ui.Modifier
53+import androidx.compose.ui.platform.LocalHapticFeedback
43import androidx.compose.ui.unit.dp
54import androidx.compose.ui.unit.dp
44import androidx.lifecycle.compose.collectAsStateWithLifecycle
55import androidx.lifecycle.compose.collectAsStateWithLifecycle
45import androidx.lifecycle.viewmodel.compose.viewModel
56import androidx.lifecycle.viewmodel.compose.viewModel
57+import kotlinx.coroutines.launch
58+import soy.iko.opencode.data.network.EventStreamClient
46import soy.iko.opencode.di.AppContainer
59import soy.iko.opencode.di.AppContainer
47import soy.iko.opencode.ui.vmFactory
60import soy.iko.opencode.ui.vmFactory
@@ -64,6 +77,10 @@ fun ChatScreen(
64 val agents by vm.agents.collectAsStateWithLifecycle()
77 val agents by vm.agents.collectAsStateWithLifecycle()
65 val selectedAgent by vm.selectedAgent.collectAsStateWithLifecycle()
78 val selectedAgent by vm.selectedAgent.collectAsStateWithLifecycle()
66 val commands by vm.commands.collectAsStateWithLifecycle()
79 val commands by vm.commands.collectAsStateWithLifecycle()
80+ val sessionTitle by vm.sessionTitle.collectAsStateWithLifecycle()
81+ val failedDraft by vm.failedDraft.collectAsStateWithLifecycle()
82+ val haptics = LocalHapticFeedback.current
83+ val scope = rememberCoroutineScope()
68 val listState = rememberLazyListState()
85 val listState = rememberLazyListState()
69 val snackbar = remember { SnackbarHostState() }
86 val snackbar = remember { SnackbarHostState() }
@@ -71,24 +88,49 @@ fun ChatScreen(
71 var showModelPicker by remember { mutableStateOf(false) }
88 var showModelPicker by remember { mutableStateOf(false) }
72 var showAgentPicker by remember { mutableStateOf(false) }
89 var showAgentPicker by remember { mutableStateOf(false) }
73 var showCommandPicker by remember { mutableStateOf(false) }
90 var showCommandPicker by remember { mutableStateOf(false) }
91+ var showExitConfirm by remember { mutableStateOf(false) }
93+ // Track whether the list is pinned to the bottom so streaming auto-scroll
94+ // doesn't fight a user who scrolled up to read earlier output.
95+ val isPinnedToBottom by remember {
97+ val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
98+ lastVisible >= messages.lastIndex || messages.isEmpty()
75− // Keep the newest content in view as parts stream in.
102+ // Keep the newest content in view as parts stream in — only if already at the bottom.
76 LaunchedEffect(messages.size, messages.lastOrNull()?.parts?.size) {
103 LaunchedEffect(messages.size, messages.lastOrNull()?.parts?.size) {
104+ if (messages.isNotEmpty() && isPinnedToBottom) {
77− if (messages.isNotEmpty()) listState.animateScrollToItem(messages.lastIndex)
105+ listState.animateScrollToItem(messages.lastIndex)
79 LaunchedEffect(error) {
108 LaunchedEffect(error) {
80− error?.let {
109+ val msg = error ?: return@LaunchedEffect
110+ val result = if (failedDraft != null) {
81− snackbar.showSnackbar(it)
111+ snackbar.showSnackbar(message = msg, actionLabel = "Retry")
82− vm.clearError()
112+ } else {
113+ snackbar.showSnackbar(msg)
115+ if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) vm.retryFailed()
120+ if (vm.send(input)) {
122+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
126+ BackHandler(enabled = running) { showExitConfirm = true }
86 Scaffold(
128 Scaffold(
87 topBar = {
129 topBar = {
88 TopAppBar(
130 TopAppBar(
89 title = {
131 title = {
90 Column(modifier = Modifier.clickable(enabled = models.isNotEmpty()) { showModelPicker = true }) {
132 Column(modifier = Modifier.clickable(enabled = models.isNotEmpty()) { showModelPicker = true }) {
91− Text("Session")
133+ Text(sessionTitle ?: "Session")
93 buildString {
135 buildString {
94 append(selectedModel?.modelLabel ?: "default model")
136 append(selectedModel?.modelLabel ?: "default model")
@@ -100,7 +142,9 @@ fun ChatScreen(
102 navigationIcon = {
144 navigationIcon = {
103− IconButton(onClick = onBack) {
145+ IconButton(onClick = {
146+ if (running) showExitConfirm = true else onBack()
104 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
148 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
@@ -124,10 +168,7 @@ fun ChatScreen(
124 onValueChange = { input = it },
168 onValueChange = { input = it },
125 running = running,
169 running = running,
126 enabled = vm.connected,
170 enabled = vm.connected,
127− onSend = {
171+ onSend = ::doSend,
131 onAbort = { vm.abort() },
172 onAbort = { vm.abort() },
@@ -151,7 +192,7 @@ fun ChatScreen(
151 verticalArrangement = Arrangement.spacedBy(16.dp),
192 verticalArrangement = Arrangement.spacedBy(16.dp),
153 items(messages, key = { it.info.id }) { message ->
194 items(messages, key = { it.info.id }) { message ->
154− MessageBubble(message)
195+ MessageBubble(message, isRunning = running)
156 if (running) {
197 if (running) {
157 item(key = "__typing") {
198 item(key = "__typing") {
@@ -162,10 +203,44 @@ fun ChatScreen(
206+ // Jump-to-latest affordance when the user has scrolled away during a stream.
208+ visible = !isPinnedToBottom && messages.isNotEmpty(),
209+ modifier = Modifier.align(Alignment.BottomEnd),
211+ ExtendedFloatingActionButton(
213+ if (messages.isNotEmpty()) {
214+ scope.launch { listState.animateScrollToItem(messages.lastIndex) }
217+ icon = { Icon(Icons.Filled.KeyboardArrowDown, contentDescription = null) },
218+ text = { Text("Latest") },
219+ modifier = Modifier.padding(end = 16.dp, bottom = 16.dp),
226+ if (showExitConfirm) {
228+ onDismissRequest = { showExitConfirm = false },
229+ title = { Text("Stop and exit?") },
230+ text = { Text("The agent is still working. Exit anyway?") },
232+ TextButton(onClick = {
233+ showExitConfirm = false
236+ }) { Text("Stop & exit") }
239+ TextButton(onClick = { showExitConfirm = false }) { Text("Stay") }
169 if (showModelPicker) {
244 if (showModelPicker) {
170 ModelPickerSheet(
245 ModelPickerSheet(
171 options = models,
246 options = models,
@@ -202,13 +277,13 @@ fun ChatScreen(
203@Composable
278@Composable
204private fun ConnectionBanner(
279private fun ConnectionBanner(
205− state: soy.iko.opencode.data.network.EventStreamClient.ConnectionState,
280+ state: EventStreamClient.ConnectionState,
206 modifier: Modifier = Modifier,
281 modifier: Modifier = Modifier,
208 val text = when (state) {
283 val text = when (state) {
209− soy.iko.opencode.data.network.EventStreamClient.ConnectionState.Connecting -> "Connecting…"
284+ EventStreamClient.ConnectionState.Connecting -> "Connecting…"
210− soy.iko.opencode.data.network.EventStreamClient.ConnectionState.Disconnected -> "Reconnecting…"
285+ EventStreamClient.ConnectionState.Disconnected -> "Reconnecting…"
211− soy.iko.opencode.data.network.EventStreamClient.ConnectionState.Connected -> null
286+ EventStreamClient.ConnectionState.Connected -> null
213 if (text != null) {
288 if (text != null) {