git.iko.soy/opencode-android
on e37d1bb help sign in
346 lines 14 kB history raw
1package soy.iko.opencode.ui.chat
3import androidx.activity.compose.BackHandler
4import androidx.compose.animation.AnimatedVisibility
5import androidx.compose.foundation.clickable
6import androidx.compose.foundation.layout.Arrangement
7import androidx.compose.foundation.layout.Box
8import androidx.compose.foundation.layout.Column
9import androidx.compose.foundation.layout.PaddingValues
10import androidx.compose.foundation.layout.Row
11import androidx.compose.foundation.layout.Spacer
12import androidx.compose.foundation.layout.fillMaxSize
13import androidx.compose.foundation.layout.fillMaxWidth
14import androidx.compose.foundation.layout.padding
15import androidx.compose.foundation.layout.size
16import androidx.compose.foundation.lazy.LazyColumn
17import androidx.compose.foundation.lazy.items
18import androidx.compose.foundation.lazy.rememberLazyListState
19import androidx.compose.material.icons.Icons
20import androidx.compose.material.icons.automirrored.filled.ArrowBack
21import androidx.compose.material.icons.automirrored.filled.Send
22import androidx.compose.material.icons.filled.KeyboardArrowDown
23import androidx.compose.material.icons.filled.SmartToy
24import androidx.compose.material.icons.filled.Stop
25import androidx.compose.material.icons.filled.Terminal
26import androidx.compose.material.icons.filled.Tune
27import androidx.compose.material3.AlertDialog
28import androidx.compose.material3.CircularProgressIndicator
29import androidx.compose.material3.ExperimentalMaterial3Api
30import androidx.compose.material3.ExtendedFloatingActionButton
31import androidx.compose.material3.FloatingActionButton
32import androidx.compose.material3.Icon
33import androidx.compose.material3.IconButton
34import androidx.compose.material3.MaterialTheme
35import androidx.compose.material3.OutlinedTextField
36import androidx.compose.material3.Scaffold
37import androidx.compose.material3.SnackbarHost
38import androidx.compose.material3.SnackbarHostState
39import androidx.compose.material3.Surface
40import androidx.compose.material3.Text
41import androidx.compose.material3.TextButton
42import androidx.compose.material3.TopAppBar
43import androidx.compose.runtime.Composable
44import androidx.compose.runtime.LaunchedEffect
45import androidx.compose.runtime.derivedStateOf
46import androidx.compose.runtime.getValue
47import androidx.compose.runtime.mutableStateOf
48import androidx.compose.runtime.remember
49import androidx.compose.runtime.rememberCoroutineScope
50import androidx.compose.runtime.setValue
51import androidx.compose.ui.Alignment
52import androidx.compose.ui.Modifier
53import androidx.compose.ui.platform.LocalHapticFeedback
54import androidx.compose.ui.unit.dp
55import androidx.lifecycle.compose.collectAsStateWithLifecycle
56import androidx.lifecycle.viewmodel.compose.viewModel
57import kotlinx.coroutines.launch
58import soy.iko.opencode.data.network.EventStreamClient
59import soy.iko.opencode.di.AppContainer
60import soy.iko.opencode.ui.vmFactory
62@OptIn(ExperimentalMaterial3Api::class)
63@Composable
64fun ChatScreen(
65 container: AppContainer,
66 sessionId: String,
67 onBack: () -> Unit,
68) {
69 val vm: ChatViewModel = viewModel(factory = vmFactory { ChatViewModel(container, sessionId) })
70 val messages by vm.messages.collectAsStateWithLifecycle()
71 val running by vm.running.collectAsStateWithLifecycle()
72 val error by vm.error.collectAsStateWithLifecycle()
73 val models by vm.models.collectAsStateWithLifecycle()
74 val selectedModel by vm.selectedModel.collectAsStateWithLifecycle()
75 val connectionState by vm.connectionState.collectAsStateWithLifecycle()
76 val pendingPermission by vm.pendingPermission.collectAsStateWithLifecycle()
77 val agents by vm.agents.collectAsStateWithLifecycle()
78 val selectedAgent by vm.selectedAgent.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()
85 val listState = rememberLazyListState()
86 val snackbar = remember { SnackbarHostState() }
87 var input by remember { mutableStateOf("") }
88 var showModelPicker by remember { mutableStateOf(false) }
89 var showAgentPicker 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 {
96 derivedStateOf {
97 val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
98 lastVisible >= messages.lastIndex || messages.isEmpty()
99 }
100 }
102 // Keep the newest content in view as parts stream in — only if already at the bottom.
103 LaunchedEffect(messages.size, messages.lastOrNull()?.parts?.size) {
104 if (messages.isNotEmpty() && isPinnedToBottom) {
105 listState.animateScrollToItem(messages.lastIndex)
106 }
107 }
108 LaunchedEffect(error) {
109 val msg = error ?: return@LaunchedEffect
110 val result = if (failedDraft != null) {
111 snackbar.showSnackbar(message = msg, actionLabel = "Retry")
112 } else {
113 snackbar.showSnackbar(msg)
114 }
115 if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) vm.retryFailed()
116 vm.clearError()
117 }
119 fun doSend() {
120 if (vm.send(input)) {
121 input = ""
122 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
123 }
124 }
126 BackHandler(enabled = running) { showExitConfirm = true }
128 Scaffold(
129 topBar = {
130 TopAppBar(
131 title = {
132 Column(modifier = Modifier.clickable(enabled = models.isNotEmpty()) { showModelPicker = true }) {
133 Text(sessionTitle ?: "Session")
134 Text(
135 buildString {
136 append(selectedModel?.modelLabel ?: "default model")
137 selectedAgent?.let { append(" • $it") }
138 },
139 style = MaterialTheme.typography.labelSmall,
140 color = MaterialTheme.colorScheme.onSurfaceVariant,
141 )
142 }
143 },
144 navigationIcon = {
145 IconButton(onClick = {
146 if (running) showExitConfirm = true else onBack()
147 }) {
148 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
149 }
150 },
151 actions = {
152 IconButton(onClick = { showCommandPicker = true }, enabled = commands.isNotEmpty()) {
153 Icon(Icons.Filled.Terminal, contentDescription = "Commands")
154 }
155 IconButton(onClick = { showAgentPicker = true }, enabled = agents.isNotEmpty()) {
156 Icon(Icons.Filled.SmartToy, contentDescription = "Choose agent")
157 }
158 IconButton(onClick = { showModelPicker = true }, enabled = models.isNotEmpty()) {
159 Icon(Icons.Filled.Tune, contentDescription = "Choose model")
160 }
161 },
162 )
163 },
164 snackbarHost = { SnackbarHost(snackbar) },
165 bottomBar = {
166 ChatInputBar(
167 value = input,
168 onValueChange = { input = it },
169 running = running,
170 enabled = vm.connected,
171 onSend = ::doSend,
172 onAbort = { vm.abort() },
173 )
174 },
175 ) { padding ->
176 Box(modifier = Modifier.fillMaxSize().padding(padding)) {
177 if (!vm.connected) {
178 Text(
179 "Not connected.",
180 modifier = Modifier.align(Alignment.Center).padding(24.dp),
181 color = MaterialTheme.colorScheme.error,
182 )
183 } else {
184 ConnectionBanner(
185 state = connectionState,
186 modifier = Modifier.align(Alignment.TopCenter),
187 )
188 LazyColumn(
189 state = listState,
190 modifier = Modifier.fillMaxSize(),
191 contentPadding = PaddingValues(16.dp),
192 verticalArrangement = Arrangement.spacedBy(16.dp),
193 ) {
194 items(messages, key = { it.info.id }) { message ->
195 MessageBubble(message, isRunning = running)
196 }
197 if (running) {
198 item(key = "__typing") {
199 Row(verticalAlignment = Alignment.CenterVertically) {
200 CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
201 Text(" working…", style = MaterialTheme.typography.labelMedium)
202 }
203 }
204 }
205 }
206 // Jump-to-latest affordance when the user has scrolled away during a stream.
207 AnimatedVisibility(
208 visible = !isPinnedToBottom && messages.isNotEmpty(),
209 modifier = Modifier.align(Alignment.BottomEnd),
210 ) {
211 ExtendedFloatingActionButton(
212 onClick = {
213 if (messages.isNotEmpty()) {
214 scope.launch { listState.animateScrollToItem(messages.lastIndex) }
215 }
216 },
217 icon = { Icon(Icons.Filled.KeyboardArrowDown, contentDescription = null) },
218 text = { Text("Latest") },
219 modifier = Modifier.padding(end = 16.dp, bottom = 16.dp),
220 )
221 }
222 }
223 }
224 }
226 if (showExitConfirm) {
227 AlertDialog(
228 onDismissRequest = { showExitConfirm = false },
229 title = { Text("Stop and exit?") },
230 text = { Text("The agent is still working. Exit anyway?") },
231 confirmButton = {
232 TextButton(onClick = {
233 showExitConfirm = false
234 vm.abort()
235 onBack()
236 }) { Text("Stop & exit") }
237 },
238 dismissButton = {
239 TextButton(onClick = { showExitConfirm = false }) { Text("Stay") }
240 },
241 )
242 }
244 if (showModelPicker) {
245 ModelPickerSheet(
246 options = models,
247 selected = selectedModel,
248 onSelect = { vm.selectModel(it) },
249 onDismiss = { showModelPicker = false },
250 )
251 }
253 if (showAgentPicker) {
254 AgentPickerSheet(
255 agents = agents,
256 selected = selectedAgent,
257 onSelect = { vm.selectAgent(it?.name) },
258 onDismiss = { showAgentPicker = false },
259 )
260 }
262 if (showCommandPicker) {
263 CommandPickerSheet(
264 commands = commands,
265 onSelect = { vm.runCommand(it) },
266 onDismiss = { showCommandPicker = false },
267 )
268 }
270 pendingPermission?.let { permission ->
271 PermissionDialog(
272 permission = permission,
273 onRespond = { response -> vm.respondPermission(permission, response) },
274 )
275 }
278@Composable
279private fun ConnectionBanner(
280 state: EventStreamClient.ConnectionState,
281 modifier: Modifier = Modifier,
282) {
283 val text = when (state) {
284 EventStreamClient.ConnectionState.Connecting -> "Connecting…"
285 EventStreamClient.ConnectionState.Disconnected -> "Reconnecting…"
286 EventStreamClient.ConnectionState.Connected -> null
287 }
288 if (text != null) {
289 Surface(
290 modifier = modifier.fillMaxWidth(),
291 color = MaterialTheme.colorScheme.tertiaryContainer,
292 ) {
293 Row(
294 modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
295 horizontalArrangement = Arrangement.Center,
296 verticalAlignment = Alignment.CenterVertically,
297 ) {
298 CircularProgressIndicator(Modifier.size(12.dp), strokeWidth = 2.dp)
299 Text(
300 " $text",
301 style = MaterialTheme.typography.labelMedium,
302 color = MaterialTheme.colorScheme.onTertiaryContainer,
303 )
304 }
305 }
306 }
309@Composable
310private fun ChatInputBar(
311 value: String,
312 onValueChange: (String) -> Unit,
313 running: Boolean,
314 enabled: Boolean,
315 onSend: () -> Unit,
316 onAbort: () -> Unit,
317) {
318 Surface(tonalElevation = 3.dp) {
319 Row(
320 modifier = Modifier.fillMaxWidth().padding(8.dp),
321 verticalAlignment = Alignment.Bottom,
322 ) {
323 OutlinedTextField(
324 value = value,
325 onValueChange = onValueChange,
326 modifier = Modifier.weight(1f),
327 placeholder = { Text("Message opencode…") },
328 enabled = enabled,
329 maxLines = 6,
330 )
331 if (running) {
332 IconButton(onClick = onAbort, modifier = Modifier.padding(start = 4.dp)) {
333 Icon(Icons.Filled.Stop, contentDescription = "Stop")
334 }
335 } else {
336 IconButton(
337 onClick = onSend,
338 enabled = enabled && value.isNotBlank(),
339 modifier = Modifier.padding(start = 4.dp),
340 ) {
341 Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Send")
342 }
343 }
344 }
345 }