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)
100 container: AppContainer,
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()
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) }
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()
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)
168 currentView.keepScreenOn = false
169 soy.iko.opencode.notification.RunForegroundService.stop(appContext)
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 {
177 val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
178 lastVisible >= messages.lastIndex || messages.isEmpty()
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)
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.
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) {
202 messages.lastOrNull()?.parts?.sumOf { (it as? TextPart)?.text?.length ?: 0 } ?: 0,
205 }.collectLatest { (_, _, pinned) ->
206 if (messages.isNotEmpty() && pinned) {
207 listState.animateScrollToItem(messages.lastIndex)
211 val retryLabel = stringResource(R.string.retry)
212 LaunchedEffect(error) {
213 val msg = error ?: return@LaunchedEffect
215 val result = if (failedDraft != null) {
216 snackbar.showSnackbar(message = msg, actionLabel = retryLabel)
218 snackbar.showSnackbar(msg)
220 if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) vm.retryFailed()
227 if (vm.send(draft)) {
228 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
232 BackHandler(enabled = running && !showModelPicker && !showAgentPicker && !showCommandPicker) { showExitConfirm = true }
238 Column(modifier = Modifier
239 .clickable(enabled = models.isNotEmpty(), role = Role.Button) { showModelPicker = true }
241 Text(sessionTitle ?: stringResource(R.string.session))
244 append(selectedModel?.modelLabel ?: stringResource(R.string.default_model))
245 selectedAgent?.let { append(" · $it") }
247 style = MaterialTheme.typography.labelSmall,
248 color = MaterialTheme.colorScheme.onSurfaceVariant,
253 IconButton(onClick = {
254 if (running) showExitConfirm = true else onBack()
256 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
263 val md = withContext(Dispatchers.Default) {
264 buildConversationMarkdown(messages, sessionTitle)
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)
271 runCatchingCancellable { shareContext.startActivity(android.content.Intent.createChooser(send, shareLabel)) }
274 enabled = messages.isNotEmpty(),
276 Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share_conversation))
278 IconButton(onClick = { showCommandPicker = true }, enabled = commands.isNotEmpty()) {
279 Icon(Icons.Filled.Terminal, contentDescription = stringResource(R.string.commands))
281 IconButton(onClick = { showAgentPicker = true }, enabled = agents.isNotEmpty()) {
282 Icon(Icons.Filled.SmartToy, contentDescription = stringResource(R.string.choose_agent))
284 IconButton(onClick = { showModelPicker = true }, enabled = models.isNotEmpty()) {
285 Icon(Icons.Filled.Tune, contentDescription = stringResource(R.string.choose_model))
290 snackbarHost = { SnackbarHost(snackbar) },
294 onValueChange = vm::updateDraft,
296 enabled = activeConnection != null,
298 onAbort = { vm.abort() },
302 Box(modifier = Modifier.fillMaxSize().padding(padding)) {
303 if (activeConnection == null) {
305 modifier = Modifier.align(Alignment.Center).padding(24.dp),
306 horizontalAlignment = Alignment.CenterHorizontally,
309 stringResource(R.string.not_connected),
310 color = MaterialTheme.colorScheme.error,
312 Spacer(Modifier.size(12.dp))
313 Button(onClick = { vm.reconnect() }, enabled = !reconnecting) {
315 CircularProgressIndicator(
316 Modifier.size(20.dp),
317 color = MaterialTheme.colorScheme.onPrimary,
321 Text(stringResource(R.string.reconnect))
327 state = connectionState,
328 modifier = Modifier.align(Alignment.TopCenter),
330 if (loading && messages.isEmpty()) {
331 val loadingLabel = stringResource(R.string.loading)
332 CircularProgressIndicator(
334 .align(Alignment.Center)
335 .semantics { contentDescription = loadingLabel },
337 } else if (messages.isEmpty() && !running) {
339 modifier = Modifier.align(Alignment.Center),
344 modifier = Modifier.fillMaxSize(),
345 contentPadding = PaddingValues(16.dp),
346 verticalArrangement = Arrangement.spacedBy(16.dp),
348 items(messages, key = { it.info.id }, contentType = { it.info::class }) { message ->
349 MessageBubble(message, isRunning = running, imageContext = imageContext)
352 item(key = "__typing") {
353 val workingText = stringResource(R.string.working)
355 verticalAlignment = Alignment.CenterVertically,
356 modifier = Modifier.semantics {
357 contentDescription = workingText
360 CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
361 Text(workingText, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp))
366 // Jump-to-latest affordance when the user has scrolled away during a stream.
368 visible = !isPinnedToBottom && messages.isNotEmpty(),
369 modifier = Modifier.align(Alignment.BottomEnd),
371 ExtendedFloatingActionButton(
373 if (messages.isNotEmpty()) {
374 scope.launch { runCatchingCancellable { listState.animateScrollToItem(messages.lastIndex) } }
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),
387 if (showExitConfirm) {
389 onDismissRequest = { showExitConfirm = false },
390 title = { Text(stringResource(R.string.stop_and_exit_title)) },
391 text = { Text(stringResource(R.string.stop_and_exit_text)) },
393 TextButton(onClick = {
394 showExitConfirm = false
397 }) { Text(stringResource(R.string.stop_and_exit)) }
400 TextButton(onClick = { showExitConfirm = false }) { Text(stringResource(R.string.stay)) }
405 if (showModelPicker) {
408 selected = selectedModel,
409 loading = modelsLoading,
411 onSelect = { vm.selectModel(it) },
412 onRetry = { vm.reloadModels() },
413 onDismiss = { showModelPicker = false },
417 if (showAgentPicker) {
420 selected = selectedAgent,
421 loading = agentsLoading,
423 onSelect = { vm.selectAgent(it?.name) },
424 onRetry = { vm.reloadAgents() },
425 onDismiss = { showAgentPicker = false },
429 if (showCommandPicker) {
432 loading = commandsLoading,
433 error = commandsError,
434 onSelect = { vm.runCommand(it) },
435 onRetry = { vm.reloadCommands() },
436 onDismiss = { showCommandPicker = false },
440 pendingPermission?.let { permission ->
442 permission = permission,
443 onRespond = { response -> vm.respondPermission(permission, response) },
449private fun ChatInputBar(
451 onValueChange: (String) -> Unit,
457 Surface(tonalElevation = 3.dp, modifier = Modifier.imePadding()) {
459 modifier = Modifier.fillMaxWidth()
460 .windowInsetsPadding(WindowInsets.navigationBars)
462 verticalAlignment = Alignment.Bottom,
466 onValueChange = onValueChange,
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()
482 placeholder = { Text(stringResource(R.string.message_placeholder)) },
483 label = { Text(stringResource(R.string.message_placeholder)) },
486 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
487 keyboardActions = KeyboardActions(onSend = { if (enabled && value.isNotBlank()) onSend() }),
490 IconButton(onClick = onAbort, modifier = Modifier.padding(start = 4.dp).testTag("stop_button")) {
491 Icon(Icons.Filled.Stop, contentDescription = stringResource(R.string.stop))
496 enabled = enabled && value.isNotBlank(),
497 modifier = Modifier.padding(start = 4.dp).testTag("send_button"),
499 Icon(Icons.AutoMirrored.Filled.Send, contentDescription = stringResource(R.string.send))
507private fun EmptyConversation(modifier: Modifier = Modifier) {
509 modifier = modifier.padding(32.dp),
510 horizontalAlignment = Alignment.CenterHorizontally,
513 Icons.Filled.AutoAwesome,
514 contentDescription = null,
515 modifier = Modifier.size(48.dp),
516 tint = MaterialTheme.colorScheme.onSurfaceVariant,
518 Spacer(Modifier.size(16.dp))
520 stringResource(R.string.empty_chat_title),
521 style = MaterialTheme.typography.titleMedium,
523 Spacer(Modifier.size(8.dp))
525 stringResource(R.string.empty_chat_hint),
526 style = MaterialTheme.typography.bodyMedium,
527 color = MaterialTheme.colorScheme.onSurfaceVariant,
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list