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.foundation.text.KeyboardActions
62import androidx.compose.foundation.text.KeyboardOptions
63import androidx.compose.ui.Alignment
64import androidx.compose.ui.Modifier
65import androidx.compose.ui.input.key.Key
66import androidx.compose.ui.input.key.KeyEventType
67import androidx.compose.ui.input.key.isShiftPressed
68import androidx.compose.ui.input.key.key
69import androidx.compose.ui.input.key.onPreviewKeyEvent
70import androidx.compose.ui.input.key.type
71import androidx.compose.ui.platform.LocalContext
72import androidx.compose.ui.platform.LocalHapticFeedback
73import androidx.compose.ui.platform.LocalView
74import androidx.compose.ui.res.stringResource
75import androidx.compose.ui.semantics.Role
76import androidx.compose.ui.semantics.contentDescription
77import androidx.compose.ui.semantics.role
78import androidx.compose.ui.semantics.semantics
79import androidx.compose.ui.text.input.ImeAction
80import androidx.compose.ui.unit.dp
81import androidx.lifecycle.compose.collectAsStateWithLifecycle
82import androidx.lifecycle.viewmodel.compose.viewModel
83import kotlinx.coroutines.launch
84import soy.iko.opencode.data.network.EventStreamClient
85import soy.iko.opencode.data.model.ReasoningPart
86import soy.iko.opencode.data.model.TextPart
87import soy.iko.opencode.di.AppContainer
88import soy.iko.opencode.R
89import soy.iko.opencode.ui.components.ConnectionBanner
90import soy.iko.opencode.ui.components.toImageContext
91import soy.iko.opencode.ui.vmFactory
93@OptIn(ExperimentalMaterial3Api::class)
96 container: AppContainer,
100 val vm: ChatViewModel = viewModel(factory = vmFactory { ChatViewModel(container, sessionId) })
101 val messages by vm.messages.collectAsStateWithLifecycle()
102 val running by vm.running.collectAsStateWithLifecycle()
103 val error by vm.error.collectAsStateWithLifecycle()
104 val loading by vm.loading.collectAsStateWithLifecycle()
105 val models by vm.models.collectAsStateWithLifecycle()
106 val modelsLoading by vm.modelsLoading.collectAsStateWithLifecycle()
107 val selectedModel by vm.selectedModel.collectAsStateWithLifecycle()
108 val connectionState by vm.connectionState.collectAsStateWithLifecycle()
109 val pendingPermission by vm.pendingPermission.collectAsStateWithLifecycle()
110 val agents by vm.agents.collectAsStateWithLifecycle()
111 val agentsLoading by vm.agentsLoading.collectAsStateWithLifecycle()
112 val selectedAgent by vm.selectedAgent.collectAsStateWithLifecycle()
113 val commands by vm.commands.collectAsStateWithLifecycle()
114 val commandsLoading by vm.commandsLoading.collectAsStateWithLifecycle()
115 val sessionTitle by vm.sessionTitle.collectAsStateWithLifecycle()
116 val failedDraft by vm.failedDraft.collectAsStateWithLifecycle()
117 val draft by vm.draft.collectAsStateWithLifecycle()
118 val haptics = LocalHapticFeedback.current
119 val scope = rememberCoroutineScope()
120 val shareContext = LocalContext.current
121 val shareLabel = stringResource(R.string.share)
122 val defaultShareSubject = stringResource(R.string.share_subject)
124 // Connection profile (with auth) for rendering inline image attachments.
125 // Collect as state so a server switch recomposes the image context.
126 val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
127 val imageContext = remember(activeConnection?.profile) {
128 activeConnection?.profile?.toImageContext()
131 // Mark this session as read while the user is viewing it; clear on leave so new
132 // background activity can badge it again.
133 DisposableEffect(sessionId) {
134 container.setCurrentSession(sessionId)
135 onDispose { if (container.currentSession.value == sessionId) container.setCurrentSession(null) }
138 val listState = rememberLazyListState()
139 val snackbar = remember { SnackbarHostState() }
140 var showModelPicker by rememberSaveable { mutableStateOf(false) }
141 var showAgentPicker by rememberSaveable { mutableStateOf(false) }
142 var showCommandPicker by rememberSaveable { mutableStateOf(false) }
143 var showExitConfirm by rememberSaveable { mutableStateOf(false) }
145 // Keep the screen awake and hold a foreground priority while the agent is working,
146 // so backgrounding mid-run doesn't let Doze choke the SSE stream.
147 val currentView = LocalView.current
148 val appContext = LocalContext.current.applicationContext
149 DisposableEffect(running) {
150 currentView.keepScreenOn = running
151 if (running) soy.iko.opencode.notification.RunForegroundService.start(appContext)
153 currentView.keepScreenOn = false
154 soy.iko.opencode.notification.RunForegroundService.stop(appContext)
158 // Track whether the list is pinned to the bottom so streaming auto-scroll
159 // doesn't fight a user who scrolled up to read earlier output.
160 val isPinnedToBottom by remember {
162 val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
163 lastVisible >= messages.lastIndex || messages.isEmpty()
167 // Jump to the latest message when the conversation first loads so the user
168 // lands on recent context, not the oldest.
169 LaunchedEffect(messages.isNotEmpty()) {
170 if (messages.isNotEmpty()) listState.scrollToItem(messages.lastIndex)
173 // Keep the newest content in view as parts stream in — only if already at the bottom.
174 // Include the growing text length so we follow streaming within a single part, not
175 // only when a brand-new message or part is added.
176 val lastPartLen = messages.lastOrNull()?.parts?.lastOrNull()?.let { part ->
178 is TextPart -> part.text.length
179 is ReasoningPart -> part.text.length
183 LaunchedEffect(messages.size, messages.lastOrNull()?.parts?.size, lastPartLen) {
184 if (messages.isNotEmpty() && isPinnedToBottom) {
185 listState.animateScrollToItem(messages.lastIndex)
188 val retryLabel = stringResource(R.string.retry)
189 LaunchedEffect(error) {
190 val msg = error ?: return@LaunchedEffect
191 val result = if (failedDraft != null) {
192 snackbar.showSnackbar(message = msg, actionLabel = retryLabel)
194 snackbar.showSnackbar(msg)
196 if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) vm.retryFailed()
201 if (vm.send(draft)) {
202 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
206 BackHandler(enabled = running) { showExitConfirm = true }
212 Column(modifier = Modifier
213 .clickable(enabled = models.isNotEmpty()) { showModelPicker = true }
214 .semantics { role = Role.Button }
216 Text(sessionTitle ?: stringResource(R.string.session))
219 append(selectedModel?.modelLabel ?: stringResource(R.string.default_model))
220 selectedAgent?.let { append(" · $it") }
222 style = MaterialTheme.typography.labelSmall,
223 color = MaterialTheme.colorScheme.onSurfaceVariant,
228 IconButton(onClick = {
229 if (running) showExitConfirm = true else onBack()
231 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
237 val md = buildConversationMarkdown(messages, sessionTitle)
238 val send = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
239 type = "text/markdown"
240 putExtra(android.content.Intent.EXTRA_SUBJECT, sessionTitle ?: defaultShareSubject)
241 putExtra(android.content.Intent.EXTRA_TEXT, md)
243 runCatching { shareContext.startActivity(android.content.Intent.createChooser(send, shareLabel)) }
245 enabled = messages.isNotEmpty(),
247 Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share_conversation))
249 IconButton(onClick = { showCommandPicker = true }, enabled = commands.isNotEmpty()) {
250 Icon(Icons.Filled.Terminal, contentDescription = stringResource(R.string.commands))
252 IconButton(onClick = { showAgentPicker = true }, enabled = agents.isNotEmpty()) {
253 Icon(Icons.Filled.SmartToy, contentDescription = stringResource(R.string.choose_agent))
255 IconButton(onClick = { showModelPicker = true }, enabled = models.isNotEmpty()) {
256 Icon(Icons.Filled.Tune, contentDescription = stringResource(R.string.choose_model))
261 snackbarHost = { SnackbarHost(snackbar) },
265 onValueChange = vm::updateDraft,
267 enabled = vm.connected,
269 onAbort = { vm.abort() },
273 Box(modifier = Modifier.fillMaxSize().padding(padding)) {
276 modifier = Modifier.align(Alignment.Center).padding(24.dp),
277 horizontalAlignment = Alignment.CenterHorizontally,
280 stringResource(R.string.not_connected),
281 color = MaterialTheme.colorScheme.error,
283 Spacer(Modifier.size(12.dp))
284 Button(onClick = { vm.reconnect() }) {
285 Text(stringResource(R.string.reconnect))
290 state = connectionState,
291 modifier = Modifier.align(Alignment.TopCenter),
293 if (loading && messages.isEmpty()) {
294 CircularProgressIndicator(Modifier.align(Alignment.Center))
295 } else if (messages.isEmpty() && !running) {
297 modifier = Modifier.align(Alignment.Center),
302 modifier = Modifier.fillMaxSize(),
303 contentPadding = PaddingValues(16.dp),
304 verticalArrangement = Arrangement.spacedBy(16.dp),
306 items(messages, key = { it.info.id }, contentType = { it.info::class }) { message ->
307 MessageBubble(message, isRunning = running, imageContext = imageContext)
310 item(key = "__typing") {
312 verticalAlignment = Alignment.CenterVertically,
313 modifier = Modifier.semantics {
314 contentDescription = shareContext.getString(R.string.working)
317 CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
318 Text(stringResource(R.string.working), style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp))
323 // Jump-to-latest affordance when the user has scrolled away during a stream.
325 visible = !isPinnedToBottom && messages.isNotEmpty(),
326 modifier = Modifier.align(Alignment.BottomEnd),
328 ExtendedFloatingActionButton(
330 if (messages.isNotEmpty()) {
331 scope.launch { listState.animateScrollToItem(messages.lastIndex) }
334 icon = { Icon(Icons.Filled.KeyboardArrowDown, contentDescription = stringResource(R.string.latest)) },
335 text = { Text(stringResource(R.string.latest)) },
336 modifier = Modifier.padding(end = 16.dp, bottom = 16.dp),
344 if (showExitConfirm) {
346 onDismissRequest = { showExitConfirm = false },
347 title = { Text(stringResource(R.string.stop_and_exit_title)) },
348 text = { Text(stringResource(R.string.stop_and_exit_text)) },
350 TextButton(onClick = {
351 showExitConfirm = false
354 }) { Text(stringResource(R.string.stop_and_exit)) }
357 TextButton(onClick = { showExitConfirm = false }) { Text(stringResource(R.string.stay)) }
362 if (showModelPicker) {
365 selected = selectedModel,
366 loading = modelsLoading,
367 onSelect = { vm.selectModel(it) },
368 onDismiss = { showModelPicker = false },
372 if (showAgentPicker) {
375 selected = selectedAgent,
376 loading = agentsLoading,
377 onSelect = { vm.selectAgent(it?.name) },
378 onDismiss = { showAgentPicker = false },
382 if (showCommandPicker) {
385 loading = commandsLoading,
386 onSelect = { vm.runCommand(it) },
387 onDismiss = { showCommandPicker = false },
391 pendingPermission?.let { permission ->
393 permission = permission,
394 onRespond = { response -> vm.respondPermission(permission, response) },
400private fun ChatInputBar(
402 onValueChange: (String) -> Unit,
408 Surface(tonalElevation = 3.dp, modifier = Modifier.imePadding()) {
410 modifier = Modifier.fillMaxWidth()
411 .windowInsetsPadding(WindowInsets.navigationBars)
413 verticalAlignment = Alignment.Bottom,
417 onValueChange = onValueChange,
420 .onPreviewKeyEvent { event ->
421 if (event.type == KeyEventType.KeyDown &&
422 event.key == Key.Enter &&
423 !event.isShiftPressed &&
424 enabled && value.isNotBlank()
432 placeholder = { Text(stringResource(R.string.message_placeholder)) },
435 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
436 keyboardActions = KeyboardActions(onSend = { if (enabled && value.isNotBlank()) onSend() }),
439 IconButton(onClick = onAbort, modifier = Modifier.padding(start = 4.dp)) {
440 Icon(Icons.Filled.Stop, contentDescription = stringResource(R.string.stop))
445 enabled = enabled && value.isNotBlank(),
446 modifier = Modifier.padding(start = 4.dp),
448 Icon(Icons.AutoMirrored.Filled.Send, contentDescription = stringResource(R.string.send))
456private fun EmptyConversation(modifier: Modifier = Modifier) {
458 modifier = modifier.padding(32.dp),
459 horizontalAlignment = Alignment.CenterHorizontally,
462 Icons.Filled.AutoAwesome,
463 contentDescription = null,
464 modifier = Modifier.size(48.dp),
465 tint = MaterialTheme.colorScheme.onSurfaceVariant,
467 Spacer(Modifier.size(16.dp))
469 stringResource(R.string.empty_chat_title),
470 style = MaterialTheme.typography.titleMedium,
472 Spacer(Modifier.size(8.dp))
474 stringResource(R.string.empty_chat_hint),
475 style = MaterialTheme.typography.bodyMedium,
476 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