git.iko.soy/opencode-android
on 325e359 help sign in
530 lines 24 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.WindowInsets
13import androidx.compose.foundation.layout.fillMaxSize
14import androidx.compose.foundation.layout.fillMaxWidth
15import androidx.compose.foundation.layout.imePadding
16import androidx.compose.foundation.layout.navigationBars
17import androidx.compose.foundation.layout.padding
18import androidx.compose.foundation.layout.size
19import androidx.compose.foundation.layout.union
20import androidx.compose.foundation.layout.windowInsetsPadding
21import androidx.compose.foundation.lazy.LazyColumn
22import androidx.compose.foundation.lazy.items
23import androidx.compose.foundation.lazy.rememberLazyListState
24import androidx.compose.material.icons.Icons
25import androidx.compose.material.icons.automirrored.filled.ArrowBack
26import androidx.compose.material.icons.automirrored.filled.Send
27import androidx.compose.material.icons.filled.AutoAwesome
28import androidx.compose.material.icons.filled.KeyboardArrowDown
29import androidx.compose.material.icons.filled.Share
30import androidx.compose.material.icons.filled.SmartToy
31import androidx.compose.material.icons.filled.Stop
32import androidx.compose.material.icons.filled.Terminal
33import androidx.compose.material.icons.filled.Tune
34import androidx.compose.material3.AlertDialog
35import androidx.compose.material3.Button
36import androidx.compose.material3.CircularProgressIndicator
37import androidx.compose.material3.ExperimentalMaterial3Api
38import androidx.compose.material3.ExtendedFloatingActionButton
39import androidx.compose.material3.FloatingActionButton
40import androidx.compose.material3.Icon
41import androidx.compose.material3.IconButton
42import androidx.compose.material3.MaterialTheme
43import androidx.compose.material3.OutlinedTextField
44import androidx.compose.material3.Scaffold
45import androidx.compose.material3.SnackbarHost
46import androidx.compose.material3.SnackbarHostState
47import androidx.compose.material3.Surface
48import androidx.compose.material3.Text
49import androidx.compose.material3.TextButton
50import androidx.compose.material3.TopAppBar
51import androidx.compose.runtime.Composable
52import androidx.compose.runtime.DisposableEffect
53import androidx.compose.runtime.LaunchedEffect
54import androidx.compose.runtime.derivedStateOf
55import androidx.compose.runtime.getValue
56import androidx.compose.runtime.mutableStateOf
57import androidx.compose.runtime.remember
58import androidx.compose.runtime.rememberCoroutineScope
59import androidx.compose.runtime.saveable.rememberSaveable
60import androidx.compose.runtime.setValue
61import androidx.compose.runtime.snapshotFlow
62import androidx.compose.foundation.text.KeyboardActions
63import androidx.compose.foundation.text.KeyboardOptions
64import androidx.compose.ui.Alignment
65import androidx.compose.ui.Modifier
66import androidx.compose.ui.input.key.Key
67import androidx.compose.ui.input.key.KeyEventType
68import androidx.compose.ui.input.key.isShiftPressed
69import androidx.compose.ui.input.key.key
70import androidx.compose.ui.input.key.onPreviewKeyEvent
71import androidx.compose.ui.input.key.type
72import androidx.compose.ui.platform.LocalContext
73import androidx.compose.ui.platform.LocalHapticFeedback
74import androidx.compose.ui.platform.LocalView
75import androidx.compose.ui.platform.testTag
76import androidx.compose.ui.res.stringResource
77import androidx.compose.ui.semantics.Role
78import androidx.compose.ui.semantics.contentDescription
79import androidx.compose.ui.semantics.semantics
80import androidx.compose.ui.text.input.ImeAction
81import androidx.compose.ui.unit.dp
82import androidx.lifecycle.compose.collectAsStateWithLifecycle
83import androidx.lifecycle.viewmodel.compose.viewModel
84import kotlinx.coroutines.Dispatchers
85import kotlinx.coroutines.flow.collectLatest
86import kotlinx.coroutines.launch
87import kotlinx.coroutines.withContext
88import soy.iko.opencode.data.model.TextPart
89import soy.iko.opencode.data.network.EventStreamClient
90import soy.iko.opencode.di.AppContainer
91import soy.iko.opencode.R
92import soy.iko.opencode.ui.components.ConnectionBanner
93import soy.iko.opencode.ui.components.toImageContext
94import soy.iko.opencode.ui.vmFactory
95import soy.iko.opencode.util.runCatchingCancellable
97@OptIn(ExperimentalMaterial3Api::class)
98@Composable
99fun ChatScreen(
100 container: AppContainer,
101 sessionId: String,
102 onBack: () -> Unit,
103) {
104 val vm: ChatViewModel = viewModel(key = sessionId, factory = vmFactory { ChatViewModel(container, sessionId) })
105 val messages by vm.messages.collectAsStateWithLifecycle()
106 val running by vm.running.collectAsStateWithLifecycle()
107 val error by vm.error.collectAsStateWithLifecycle()
108 val loading by vm.loading.collectAsStateWithLifecycle()
109 val models by vm.models.collectAsStateWithLifecycle()
110 val modelsLoading by vm.modelsLoading.collectAsStateWithLifecycle()
111 val modelsError by vm.modelsError.collectAsStateWithLifecycle()
112 val selectedModel by vm.selectedModel.collectAsStateWithLifecycle()
113 val connectionState by vm.connectionState.collectAsStateWithLifecycle()
114 val pendingPermission by vm.pendingPermission.collectAsStateWithLifecycle()
115 val agents by vm.agents.collectAsStateWithLifecycle()
116 val agentsLoading by vm.agentsLoading.collectAsStateWithLifecycle()
117 val agentsError by vm.agentsError.collectAsStateWithLifecycle()
118 val selectedAgent by vm.selectedAgent.collectAsStateWithLifecycle()
119 val commands by vm.commands.collectAsStateWithLifecycle()
120 val commandsLoading by vm.commandsLoading.collectAsStateWithLifecycle()
121 val commandsError by vm.commandsError.collectAsStateWithLifecycle()
122 val sessionTitle by vm.sessionTitle.collectAsStateWithLifecycle()
123 val sessionDeleted by vm.sessionDeleted.collectAsStateWithLifecycle()
124 val failedDraft by vm.failedDraft.collectAsStateWithLifecycle()
125 val draft by vm.draft.collectAsStateWithLifecycle()
126 val reconnecting by vm.reconnecting.collectAsStateWithLifecycle()
127 val haptics = LocalHapticFeedback.current
128 val scope = rememberCoroutineScope()
129 val shareContext = LocalContext.current
130 val shareLabel = stringResource(R.string.share)
131 val defaultShareSubject = stringResource(R.string.share_subject)
133 // Connection profile (with auth) for rendering inline image attachments.
134 // Collect as state so a server switch recomposes the image context.
135 val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
136 val imageContext = remember(activeConnection?.profile) {
137 activeConnection?.profile?.toImageContext()
138 }
140 // Mark this session as read while the user is viewing it; clear on leave so new
141 // background activity can badge it again.
142 DisposableEffect(sessionId) {
143 container.setCurrentSession(sessionId)
144 onDispose { if (container.currentSession.value == sessionId) container.setCurrentSession(null) }
145 }
147 // Navigate away when the session is deleted via SSE so the user isn't left
148 // on a zombie screen showing a conversation that no longer exists.
149 LaunchedEffect(sessionDeleted) {
150 if (sessionDeleted) onBack()
151 }
153 val listState = rememberLazyListState()
154 val snackbar = remember { SnackbarHostState() }
155 var showModelPicker by rememberSaveable { mutableStateOf(false) }
156 var showAgentPicker by rememberSaveable { mutableStateOf(false) }
157 var showCommandPicker by rememberSaveable { mutableStateOf(false) }
158 var showExitConfirm by rememberSaveable { mutableStateOf(false) }
160 // Keep the screen awake and hold a foreground priority while the agent is working,
161 // so backgrounding mid-run doesn't let Doze choke the SSE stream.
162 val currentView = LocalView.current
163 val appContext = LocalContext.current.applicationContext
164 DisposableEffect(running) {
165 currentView.keepScreenOn = running
166 if (running) soy.iko.opencode.notification.RunForegroundService.start(appContext)
167 onDispose {
168 currentView.keepScreenOn = false
169 soy.iko.opencode.notification.RunForegroundService.stop(appContext)
170 }
171 }
173 // Track whether the list is pinned to the bottom so streaming auto-scroll
174 // doesn't fight a user who scrolled up to read earlier output.
175 val isPinnedToBottom by remember {
176 derivedStateOf {
177 val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
178 lastVisible >= messages.lastIndex || messages.isEmpty()
179 }
180 }
182 // Jump to the latest message when the conversation first loads so the user
183 // lands on recent context, not the oldest.
184 LaunchedEffect(messages.isNotEmpty()) {
185 if (messages.isNotEmpty()) listState.scrollToItem(messages.lastIndex)
186 }
188 // Keep the newest content in view as parts stream in — only if already at the bottom.
189 // A single LaunchedEffect(Unit) + snapshotFlow avoids the rapid cancel/re-launch
190 // churn that keying on lastPartLen (which changes on every streaming token) caused.
191 // collectLatest cancels the in-flight scroll when a newer snapshot arrives, so
192 // streaming stays smooth without dozens of competing animations per second.
193 //
194 // The second Triple component tracks the total text length of the last message's
195 // parts. When a streaming token updates an existing TextPart (same part ID, longer
196 // text), messages.size and parts.size don't change — without the text length, the
197 // snapshotFlow wouldn't emit and the view wouldn't auto-scroll to follow the stream.
198 LaunchedEffect(Unit) {
199 snapshotFlow {
200 Triple(
201 messages.size,
202 messages.lastOrNull()?.parts?.sumOf { (it as? TextPart)?.text?.length ?: 0 } ?: 0,
203 isPinnedToBottom,
204 )
205 }.collectLatest { (_, _, pinned) ->
206 if (messages.isNotEmpty() && pinned) {
207 listState.animateScrollToItem(messages.lastIndex)
208 }
209 }
210 }
211 val retryLabel = stringResource(R.string.retry)
212 LaunchedEffect(error) {
213 val msg = error ?: return@LaunchedEffect
214 try {
215 val result = if (failedDraft != null) {
216 snackbar.showSnackbar(message = msg, actionLabel = retryLabel)
217 } else {
218 snackbar.showSnackbar(msg)
219 }
220 if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) vm.retryFailed()
221 } finally {
222 vm.clearError()
223 }
224 }
226 fun doSend() {
227 if (vm.send(draft)) {
228 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
229 }
230 }
232 BackHandler(enabled = running && !showModelPicker && !showAgentPicker && !showCommandPicker) { showExitConfirm = true }
234 Scaffold(
235 topBar = {
236 TopAppBar(
237 title = {
238 Column(modifier = Modifier
239 .clickable(enabled = models.isNotEmpty(), role = Role.Button) { showModelPicker = true }
240 ) {
241 Text(sessionTitle ?: stringResource(R.string.session))
242 Text(
243 buildString {
244 append(selectedModel?.modelLabel ?: stringResource(R.string.default_model))
245 selectedAgent?.let { append(" · $it") }
246 },
247 style = MaterialTheme.typography.labelSmall,
248 color = MaterialTheme.colorScheme.onSurfaceVariant,
249 )
250 }
251 },
252 navigationIcon = {
253 IconButton(onClick = {
254 if (running) showExitConfirm = true else onBack()
255 }) {
256 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
257 }
258 },
259 actions = {
260 IconButton(
261 onClick = {
262 scope.launch {
263 val md = withContext(Dispatchers.Default) {
264 buildConversationMarkdown(messages, sessionTitle)
265 }
266 val send = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
267 type = "text/markdown"
268 putExtra(android.content.Intent.EXTRA_SUBJECT, sessionTitle ?: defaultShareSubject)
269 putExtra(android.content.Intent.EXTRA_TEXT, md)
270 }
271 runCatchingCancellable { shareContext.startActivity(android.content.Intent.createChooser(send, shareLabel)) }
272 }
273 },
274 enabled = messages.isNotEmpty(),
275 ) {
276 Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share_conversation))
277 }
278 IconButton(onClick = { showCommandPicker = true }, enabled = commands.isNotEmpty()) {
279 Icon(Icons.Filled.Terminal, contentDescription = stringResource(R.string.commands))
280 }
281 IconButton(onClick = { showAgentPicker = true }, enabled = agents.isNotEmpty()) {
282 Icon(Icons.Filled.SmartToy, contentDescription = stringResource(R.string.choose_agent))
283 }
284 IconButton(onClick = { showModelPicker = true }, enabled = models.isNotEmpty()) {
285 Icon(Icons.Filled.Tune, contentDescription = stringResource(R.string.choose_model))
286 }
287 },
288 )
289 },
290 snackbarHost = { SnackbarHost(snackbar) },
291 bottomBar = {
292 ChatInputBar(
293 value = draft,
294 onValueChange = vm::updateDraft,
295 running = running,
296 enabled = activeConnection != null,
297 onSend = ::doSend,
298 onAbort = { vm.abort() },
299 )
300 },
301 ) { padding ->
302 Box(modifier = Modifier.fillMaxSize().padding(padding)) {
303 if (activeConnection == null) {
304 Column(
305 modifier = Modifier.align(Alignment.Center).padding(24.dp),
306 horizontalAlignment = Alignment.CenterHorizontally,
307 ) {
308 Text(
309 stringResource(R.string.not_connected),
310 color = MaterialTheme.colorScheme.error,
311 )
312 Spacer(Modifier.size(12.dp))
313 Button(onClick = { vm.reconnect() }, enabled = !reconnecting) {
314 if (reconnecting) {
315 CircularProgressIndicator(
316 Modifier.size(20.dp),
317 color = MaterialTheme.colorScheme.onPrimary,
318 strokeWidth = 2.dp,
319 )
320 } else {
321 Text(stringResource(R.string.reconnect))
322 }
323 }
324 }
325 } else {
326 ConnectionBanner(
327 state = connectionState,
328 modifier = Modifier.align(Alignment.TopCenter),
329 )
330 if (loading && messages.isEmpty()) {
331 val loadingLabel = stringResource(R.string.loading)
332 CircularProgressIndicator(
333 Modifier
334 .align(Alignment.Center)
335 .semantics { contentDescription = loadingLabel },
336 )
337 } else if (messages.isEmpty() && !running) {
338 EmptyConversation(
339 modifier = Modifier.align(Alignment.Center),
340 )
341 } else {
342 LazyColumn(
343 state = listState,
344 modifier = Modifier.fillMaxSize(),
345 contentPadding = PaddingValues(16.dp),
346 verticalArrangement = Arrangement.spacedBy(16.dp),
347 ) {
348 items(messages, key = { it.info.id }, contentType = { it.info::class }) { message ->
349 MessageBubble(message, isRunning = running, imageContext = imageContext)
350 }
351 if (running) {
352 item(key = "__typing") {
353 val workingText = stringResource(R.string.working)
354 Row(
355 verticalAlignment = Alignment.CenterVertically,
356 modifier = Modifier.semantics {
357 contentDescription = workingText
358 },
359 ) {
360 CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
361 Text(workingText, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp))
362 }
363 }
364 }
365 }
366 // Jump-to-latest affordance when the user has scrolled away during a stream.
367 AnimatedVisibility(
368 visible = !isPinnedToBottom && messages.isNotEmpty(),
369 modifier = Modifier.align(Alignment.BottomEnd),
370 ) {
371 ExtendedFloatingActionButton(
372 onClick = {
373 if (messages.isNotEmpty()) {
374 scope.launch { runCatchingCancellable { listState.animateScrollToItem(messages.lastIndex) } }
375 }
376 },
377 icon = { Icon(Icons.Filled.KeyboardArrowDown, contentDescription = stringResource(R.string.latest)) },
378 text = { Text(stringResource(R.string.latest)) },
379 modifier = Modifier.padding(end = 16.dp, bottom = 16.dp),
380 )
381 }
382 }
383 }
384 }
385 }
387 if (showExitConfirm) {
388 AlertDialog(
389 onDismissRequest = { showExitConfirm = false },
390 title = { Text(stringResource(R.string.stop_and_exit_title)) },
391 text = { Text(stringResource(R.string.stop_and_exit_text)) },
392 confirmButton = {
393 TextButton(onClick = {
394 showExitConfirm = false
395 vm.abort()
396 onBack()
397 }) { Text(stringResource(R.string.stop_and_exit)) }
398 },
399 dismissButton = {
400 TextButton(onClick = { showExitConfirm = false }) { Text(stringResource(R.string.stay)) }
401 },
402 )
403 }
405 if (showModelPicker) {
406 ModelPickerSheet(
407 options = models,
408 selected = selectedModel,
409 loading = modelsLoading,
410 error = modelsError,
411 onSelect = { vm.selectModel(it) },
412 onRetry = { vm.reloadModels() },
413 onDismiss = { showModelPicker = false },
414 )
415 }
417 if (showAgentPicker) {
418 AgentPickerSheet(
419 agents = agents,
420 selected = selectedAgent,
421 loading = agentsLoading,
422 error = agentsError,
423 onSelect = { vm.selectAgent(it?.name) },
424 onRetry = { vm.reloadAgents() },
425 onDismiss = { showAgentPicker = false },
426 )
427 }
429 if (showCommandPicker) {
430 CommandPickerSheet(
431 commands = commands,
432 loading = commandsLoading,
433 error = commandsError,
434 onSelect = { vm.runCommand(it) },
435 onRetry = { vm.reloadCommands() },
436 onDismiss = { showCommandPicker = false },
437 )
438 }
440 pendingPermission?.let { permission ->
441 PermissionDialog(
442 permission = permission,
443 onRespond = { response -> vm.respondPermission(permission, response) },
444 )
445 }
448@Composable
449private fun ChatInputBar(
450 value: String,
451 onValueChange: (String) -> Unit,
452 running: Boolean,
453 enabled: Boolean,
454 onSend: () -> Unit,
455 onAbort: () -> Unit,
456) {
457 Surface(tonalElevation = 3.dp, modifier = Modifier.imePadding()) {
458 Row(
459 modifier = Modifier.fillMaxWidth()
460 .windowInsetsPadding(WindowInsets.navigationBars)
461 .padding(8.dp),
462 verticalAlignment = Alignment.Bottom,
463 ) {
464 OutlinedTextField(
465 value = value,
466 onValueChange = onValueChange,
467 modifier = Modifier
468 .weight(1f)
469 .testTag("chat_input")
470 .onPreviewKeyEvent { event ->
471 if (event.type == KeyEventType.KeyDown &&
472 event.key == Key.Enter &&
473 !event.isShiftPressed &&
474 enabled && value.isNotBlank()
475 ) {
476 onSend()
477 true
478 } else {
479 false
480 }
481 },
482 placeholder = { Text(stringResource(R.string.message_placeholder)) },
483 label = { Text(stringResource(R.string.message_placeholder)) },
484 enabled = enabled,
485 maxLines = 6,
486 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
487 keyboardActions = KeyboardActions(onSend = { if (enabled && value.isNotBlank()) onSend() }),
488 )
489 if (running) {
490 IconButton(onClick = onAbort, modifier = Modifier.padding(start = 4.dp).testTag("stop_button")) {
491 Icon(Icons.Filled.Stop, contentDescription = stringResource(R.string.stop))
492 }
493 } else {
494 IconButton(
495 onClick = onSend,
496 enabled = enabled && value.isNotBlank(),
497 modifier = Modifier.padding(start = 4.dp).testTag("send_button"),
498 ) {
499 Icon(Icons.AutoMirrored.Filled.Send, contentDescription = stringResource(R.string.send))
500 }
501 }
502 }
503 }
506@Composable
507private fun EmptyConversation(modifier: Modifier = Modifier) {
508 Column(
509 modifier = modifier.padding(32.dp),
510 horizontalAlignment = Alignment.CenterHorizontally,
511 ) {
512 Icon(
513 Icons.Filled.AutoAwesome,
514 contentDescription = null,
515 modifier = Modifier.size(48.dp),
516 tint = MaterialTheme.colorScheme.onSurfaceVariant,
517 )
518 Spacer(Modifier.size(16.dp))
519 Text(
520 stringResource(R.string.empty_chat_title),
521 style = MaterialTheme.typography.titleMedium,
522 )
523 Spacer(Modifier.size(8.dp))
524 Text(
525 stringResource(R.string.empty_chat_hint),
526 style = MaterialTheme.typography.bodyMedium,
527 color = MaterialTheme.colorScheme.onSurfaceVariant,
528 )
529 }