git.iko.soy/opencode-android
on 3e723a1 help sign in
466 lines 20 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.setValue
60import androidx.compose.foundation.text.KeyboardActions
61import androidx.compose.foundation.text.KeyboardOptions
62import androidx.compose.ui.Alignment
63import androidx.compose.ui.Modifier
64import androidx.compose.ui.input.key.Key
65import androidx.compose.ui.input.key.KeyEventType
66import androidx.compose.ui.input.key.isShiftPressed
67import androidx.compose.ui.input.key.key
68import androidx.compose.ui.input.key.onPreviewKeyEvent
69import androidx.compose.ui.input.key.type
70import androidx.compose.ui.platform.LocalContext
71import androidx.compose.ui.platform.LocalHapticFeedback
72import androidx.compose.ui.platform.LocalView
73import androidx.compose.ui.res.stringResource
74import androidx.compose.ui.semantics.Role
75import androidx.compose.ui.semantics.role
76import androidx.compose.ui.semantics.semantics
77import androidx.compose.ui.text.input.ImeAction
78import androidx.compose.ui.unit.dp
79import androidx.lifecycle.compose.collectAsStateWithLifecycle
80import androidx.lifecycle.viewmodel.compose.viewModel
81import kotlinx.coroutines.launch
82import soy.iko.opencode.data.network.EventStreamClient
83import soy.iko.opencode.data.model.ReasoningPart
84import soy.iko.opencode.data.model.TextPart
85import soy.iko.opencode.di.AppContainer
86import soy.iko.opencode.R
87import soy.iko.opencode.ui.components.ConnectionBanner
88import soy.iko.opencode.ui.components.toImageContext
89import soy.iko.opencode.ui.vmFactory
91@OptIn(ExperimentalMaterial3Api::class)
92@Composable
93fun ChatScreen(
94 container: AppContainer,
95 sessionId: String,
96 onBack: () -> Unit,
97) {
98 val vm: ChatViewModel = viewModel(factory = vmFactory { ChatViewModel(container, sessionId) })
99 val messages by vm.messages.collectAsStateWithLifecycle()
100 val running by vm.running.collectAsStateWithLifecycle()
101 val error by vm.error.collectAsStateWithLifecycle()
102 val loading by vm.loading.collectAsStateWithLifecycle()
103 val models by vm.models.collectAsStateWithLifecycle()
104 val selectedModel by vm.selectedModel.collectAsStateWithLifecycle()
105 val connectionState by vm.connectionState.collectAsStateWithLifecycle()
106 val pendingPermission by vm.pendingPermission.collectAsStateWithLifecycle()
107 val agents by vm.agents.collectAsStateWithLifecycle()
108 val selectedAgent by vm.selectedAgent.collectAsStateWithLifecycle()
109 val commands by vm.commands.collectAsStateWithLifecycle()
110 val sessionTitle by vm.sessionTitle.collectAsStateWithLifecycle()
111 val failedDraft by vm.failedDraft.collectAsStateWithLifecycle()
112 val draft by vm.draft.collectAsStateWithLifecycle()
113 val haptics = LocalHapticFeedback.current
114 val scope = rememberCoroutineScope()
115 val shareContext = LocalContext.current
116 val shareLabel = stringResource(R.string.share)
117 val defaultShareSubject = stringResource(R.string.share_subject)
119 // Connection profile (with auth) for rendering inline image attachments.
120 // Collect as state so a server switch recomposes the image context.
121 val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
122 val imageContext = remember(activeConnection?.profile) {
123 activeConnection?.profile?.toImageContext()
124 }
126 // Mark this session as read while the user is viewing it; clear on leave so new
127 // background activity can badge it again.
128 DisposableEffect(sessionId) {
129 container.setCurrentSession(sessionId)
130 onDispose { if (container.currentSession.value == sessionId) container.setCurrentSession(null) }
131 }
133 val listState = rememberLazyListState()
134 val snackbar = remember { SnackbarHostState() }
135 var showModelPicker by remember { mutableStateOf(false) }
136 var showAgentPicker by remember { mutableStateOf(false) }
137 var showCommandPicker by remember { mutableStateOf(false) }
138 var showExitConfirm by remember { mutableStateOf(false) }
140 // Keep the screen awake and hold a foreground priority while the agent is working,
141 // so backgrounding mid-run doesn't let Doze choke the SSE stream.
142 val currentView = LocalView.current
143 val appContext = LocalContext.current.applicationContext
144 DisposableEffect(running) {
145 currentView.keepScreenOn = running
146 if (running) soy.iko.opencode.notification.RunForegroundService.start(appContext)
147 onDispose {
148 currentView.keepScreenOn = false
149 soy.iko.opencode.notification.RunForegroundService.stop(appContext)
150 }
151 }
153 // Track whether the list is pinned to the bottom so streaming auto-scroll
154 // doesn't fight a user who scrolled up to read earlier output.
155 val isPinnedToBottom by remember {
156 derivedStateOf {
157 val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
158 lastVisible >= messages.lastIndex || messages.isEmpty()
159 }
160 }
162 // Jump to the latest message when the conversation first loads so the user
163 // lands on recent context, not the oldest.
164 LaunchedEffect(messages.isNotEmpty()) {
165 if (messages.isNotEmpty()) listState.scrollToItem(messages.lastIndex)
166 }
168 // Keep the newest content in view as parts stream in — only if already at the bottom.
169 // Include the growing text length so we follow streaming within a single part, not
170 // only when a brand-new message or part is added.
171 val lastPartLen = messages.lastOrNull()?.parts?.lastOrNull()?.let { part ->
172 when (part) {
173 is TextPart -> part.text.length
174 is ReasoningPart -> part.text.length
175 else -> 0
176 }
177 } ?: 0
178 LaunchedEffect(messages.size, messages.lastOrNull()?.parts?.size, lastPartLen) {
179 if (messages.isNotEmpty() && isPinnedToBottom) {
180 listState.animateScrollToItem(messages.lastIndex)
181 }
182 }
183 val retryLabel = stringResource(R.string.retry)
184 LaunchedEffect(error) {
185 val msg = error ?: return@LaunchedEffect
186 val result = if (failedDraft != null) {
187 snackbar.showSnackbar(message = msg, actionLabel = retryLabel)
188 } else {
189 snackbar.showSnackbar(msg)
190 }
191 if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) vm.retryFailed()
192 vm.clearError()
193 }
195 fun doSend() {
196 if (vm.send(draft)) {
197 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
198 }
199 }
201 BackHandler(enabled = running) { showExitConfirm = true }
203 Scaffold(
204 topBar = {
205 TopAppBar(
206 title = {
207 Column(modifier = Modifier
208 .clickable(enabled = models.isNotEmpty()) { showModelPicker = true }
209 .semantics { role = Role.Button }
210 ) {
211 Text(sessionTitle ?: stringResource(R.string.session))
212 Text(
213 buildString {
214 append(selectedModel?.modelLabel ?: stringResource(R.string.default_model))
215 selectedAgent?.let { append(" · $it") }
216 },
217 style = MaterialTheme.typography.labelSmall,
218 color = MaterialTheme.colorScheme.onSurfaceVariant,
219 )
220 }
221 },
222 navigationIcon = {
223 IconButton(onClick = {
224 if (running) showExitConfirm = true else onBack()
225 }) {
226 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
227 }
228 },
229 actions = {
230 IconButton(
231 onClick = {
232 val md = buildConversationMarkdown(messages, sessionTitle)
233 val send = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
234 type = "text/markdown"
235 putExtra(android.content.Intent.EXTRA_SUBJECT, sessionTitle ?: defaultShareSubject)
236 putExtra(android.content.Intent.EXTRA_TEXT, md)
237 }
238 runCatching { shareContext.startActivity(android.content.Intent.createChooser(send, shareLabel)) }
239 },
240 enabled = messages.isNotEmpty(),
241 ) {
242 Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share_conversation))
243 }
244 IconButton(onClick = { showCommandPicker = true }, enabled = commands.isNotEmpty()) {
245 Icon(Icons.Filled.Terminal, contentDescription = stringResource(R.string.commands))
246 }
247 IconButton(onClick = { showAgentPicker = true }, enabled = agents.isNotEmpty()) {
248 Icon(Icons.Filled.SmartToy, contentDescription = stringResource(R.string.choose_agent))
249 }
250 IconButton(onClick = { showModelPicker = true }, enabled = models.isNotEmpty()) {
251 Icon(Icons.Filled.Tune, contentDescription = stringResource(R.string.choose_model))
252 }
253 },
254 )
255 },
256 snackbarHost = { SnackbarHost(snackbar) },
257 bottomBar = {
258 ChatInputBar(
259 value = draft,
260 onValueChange = vm::updateDraft,
261 running = running,
262 enabled = vm.connected,
263 onSend = ::doSend,
264 onAbort = { vm.abort() },
265 )
266 },
267 ) { padding ->
268 Box(modifier = Modifier.fillMaxSize().padding(padding)) {
269 if (!vm.connected) {
270 Column(
271 modifier = Modifier.align(Alignment.Center).padding(24.dp),
272 horizontalAlignment = Alignment.CenterHorizontally,
273 ) {
274 Text(
275 stringResource(R.string.not_connected),
276 color = MaterialTheme.colorScheme.error,
277 )
278 Spacer(Modifier.size(12.dp))
279 Button(onClick = { vm.reconnect() }) {
280 Text(stringResource(R.string.reconnect))
281 }
282 }
283 } else {
284 ConnectionBanner(
285 state = connectionState,
286 modifier = Modifier.align(Alignment.TopCenter),
287 )
288 if (loading && messages.isEmpty()) {
289 CircularProgressIndicator(Modifier.align(Alignment.Center))
290 } else if (messages.isEmpty() && !running) {
291 EmptyConversation(
292 modifier = Modifier.align(Alignment.Center),
293 )
294 } else {
295 LazyColumn(
296 state = listState,
297 modifier = Modifier.fillMaxSize(),
298 contentPadding = PaddingValues(16.dp),
299 verticalArrangement = Arrangement.spacedBy(16.dp),
300 ) {
301 items(messages, key = { it.info.id }) { message ->
302 MessageBubble(message, isRunning = running, imageContext = imageContext)
303 }
304 if (running) {
305 item(key = "__typing") {
306 Row(verticalAlignment = Alignment.CenterVertically) {
307 CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
308 Text(stringResource(R.string.working), style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp))
309 }
310 }
311 }
312 }
313 // Jump-to-latest affordance when the user has scrolled away during a stream.
314 AnimatedVisibility(
315 visible = !isPinnedToBottom && messages.isNotEmpty(),
316 modifier = Modifier.align(Alignment.BottomEnd),
317 ) {
318 ExtendedFloatingActionButton(
319 onClick = {
320 if (messages.isNotEmpty()) {
321 scope.launch { listState.animateScrollToItem(messages.lastIndex) }
322 }
323 },
324 icon = { Icon(Icons.Filled.KeyboardArrowDown, contentDescription = stringResource(R.string.latest)) },
325 text = { Text(stringResource(R.string.latest)) },
326 modifier = Modifier.padding(end = 16.dp, bottom = 16.dp),
327 )
328 }
329 }
330 }
331 }
332 }
334 if (showExitConfirm) {
335 AlertDialog(
336 onDismissRequest = { showExitConfirm = false },
337 title = { Text(stringResource(R.string.stop_and_exit_title)) },
338 text = { Text(stringResource(R.string.stop_and_exit_text)) },
339 confirmButton = {
340 TextButton(onClick = {
341 showExitConfirm = false
342 vm.abort()
343 onBack()
344 }) { Text(stringResource(R.string.stop_and_exit)) }
345 },
346 dismissButton = {
347 TextButton(onClick = { showExitConfirm = false }) { Text(stringResource(R.string.stay)) }
348 },
349 )
350 }
352 if (showModelPicker) {
353 ModelPickerSheet(
354 options = models,
355 selected = selectedModel,
356 onSelect = { vm.selectModel(it) },
357 onDismiss = { showModelPicker = false },
358 )
359 }
361 if (showAgentPicker) {
362 AgentPickerSheet(
363 agents = agents,
364 selected = selectedAgent,
365 onSelect = { vm.selectAgent(it?.name) },
366 onDismiss = { showAgentPicker = false },
367 )
368 }
370 if (showCommandPicker) {
371 CommandPickerSheet(
372 commands = commands,
373 onSelect = { vm.runCommand(it) },
374 onDismiss = { showCommandPicker = false },
375 )
376 }
378 pendingPermission?.let { permission ->
379 PermissionDialog(
380 permission = permission,
381 onRespond = { response -> vm.respondPermission(permission, response) },
382 )
383 }
386@Composable
387private fun ChatInputBar(
388 value: String,
389 onValueChange: (String) -> Unit,
390 running: Boolean,
391 enabled: Boolean,
392 onSend: () -> Unit,
393 onAbort: () -> Unit,
394) {
395 Surface(tonalElevation = 3.dp, modifier = Modifier.imePadding()) {
396 Row(
397 modifier = Modifier.fillMaxWidth()
398 .windowInsetsPadding(WindowInsets.navigationBars)
399 .padding(8.dp),
400 verticalAlignment = Alignment.Bottom,
401 ) {
402 OutlinedTextField(
403 value = value,
404 onValueChange = onValueChange,
405 modifier = Modifier
406 .weight(1f)
407 .onPreviewKeyEvent { event ->
408 if (event.type == KeyEventType.KeyDown &&
409 event.key == Key.Enter &&
410 !event.isShiftPressed &&
411 enabled && value.isNotBlank()
412 ) {
413 onSend()
414 true
415 } else {
416 false
417 }
418 },
419 placeholder = { Text(stringResource(R.string.message_placeholder)) },
420 enabled = enabled,
421 maxLines = 6,
422 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
423 keyboardActions = KeyboardActions(onSend = { if (enabled && value.isNotBlank()) onSend() }),
424 )
425 if (running) {
426 IconButton(onClick = onAbort, modifier = Modifier.padding(start = 4.dp)) {
427 Icon(Icons.Filled.Stop, contentDescription = stringResource(R.string.stop))
428 }
429 } else {
430 IconButton(
431 onClick = onSend,
432 enabled = enabled && value.isNotBlank(),
433 modifier = Modifier.padding(start = 4.dp),
434 ) {
435 Icon(Icons.AutoMirrored.Filled.Send, contentDescription = stringResource(R.string.send))
436 }
437 }
438 }
439 }
442@Composable
443private fun EmptyConversation(modifier: Modifier = Modifier) {
444 Column(
445 modifier = modifier.padding(32.dp),
446 horizontalAlignment = Alignment.CenterHorizontally,
447 ) {
448 Icon(
449 Icons.Filled.AutoAwesome,
450 contentDescription = null,
451 modifier = Modifier.size(48.dp),
452 tint = MaterialTheme.colorScheme.onSurfaceVariant,
453 )
454 Spacer(Modifier.size(16.dp))
455 Text(
456 stringResource(R.string.empty_chat_title),
457 style = MaterialTheme.typography.titleMedium,
458 )
459 Spacer(Modifier.size(8.dp))
460 Text(
461 stringResource(R.string.empty_chat_hint),
462 style = MaterialTheme.typography.bodyMedium,
463 color = MaterialTheme.colorScheme.onSurfaceVariant,
464 )
465 }