git.iko.soy/opencode-android
on 66d239c help sign in
479 lines 21 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.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)
94@Composable
95fun ChatScreen(
96 container: AppContainer,
97 sessionId: String,
98 onBack: () -> Unit,
99) {
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()
129 }
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) }
136 }
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)
152 onDispose {
153 currentView.keepScreenOn = false
154 soy.iko.opencode.notification.RunForegroundService.stop(appContext)
155 }
156 }
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 {
161 derivedStateOf {
162 val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
163 lastVisible >= messages.lastIndex || messages.isEmpty()
164 }
165 }
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)
171 }
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 ->
177 when (part) {
178 is TextPart -> part.text.length
179 is ReasoningPart -> part.text.length
180 else -> 0
181 }
182 } ?: 0
183 LaunchedEffect(messages.size, messages.lastOrNull()?.parts?.size, lastPartLen) {
184 if (messages.isNotEmpty() && isPinnedToBottom) {
185 listState.animateScrollToItem(messages.lastIndex)
186 }
187 }
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)
193 } else {
194 snackbar.showSnackbar(msg)
195 }
196 if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) vm.retryFailed()
197 vm.clearError()
198 }
200 fun doSend() {
201 if (vm.send(draft)) {
202 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
203 }
204 }
206 BackHandler(enabled = running) { showExitConfirm = true }
208 Scaffold(
209 topBar = {
210 TopAppBar(
211 title = {
212 Column(modifier = Modifier
213 .clickable(enabled = models.isNotEmpty()) { showModelPicker = true }
214 .semantics { role = Role.Button }
215 ) {
216 Text(sessionTitle ?: stringResource(R.string.session))
217 Text(
218 buildString {
219 append(selectedModel?.modelLabel ?: stringResource(R.string.default_model))
220 selectedAgent?.let { append(" · $it") }
221 },
222 style = MaterialTheme.typography.labelSmall,
223 color = MaterialTheme.colorScheme.onSurfaceVariant,
224 )
225 }
226 },
227 navigationIcon = {
228 IconButton(onClick = {
229 if (running) showExitConfirm = true else onBack()
230 }) {
231 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
232 }
233 },
234 actions = {
235 IconButton(
236 onClick = {
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)
242 }
243 runCatching { shareContext.startActivity(android.content.Intent.createChooser(send, shareLabel)) }
244 },
245 enabled = messages.isNotEmpty(),
246 ) {
247 Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share_conversation))
248 }
249 IconButton(onClick = { showCommandPicker = true }, enabled = commands.isNotEmpty()) {
250 Icon(Icons.Filled.Terminal, contentDescription = stringResource(R.string.commands))
251 }
252 IconButton(onClick = { showAgentPicker = true }, enabled = agents.isNotEmpty()) {
253 Icon(Icons.Filled.SmartToy, contentDescription = stringResource(R.string.choose_agent))
254 }
255 IconButton(onClick = { showModelPicker = true }, enabled = models.isNotEmpty()) {
256 Icon(Icons.Filled.Tune, contentDescription = stringResource(R.string.choose_model))
257 }
258 },
259 )
260 },
261 snackbarHost = { SnackbarHost(snackbar) },
262 bottomBar = {
263 ChatInputBar(
264 value = draft,
265 onValueChange = vm::updateDraft,
266 running = running,
267 enabled = vm.connected,
268 onSend = ::doSend,
269 onAbort = { vm.abort() },
270 )
271 },
272 ) { padding ->
273 Box(modifier = Modifier.fillMaxSize().padding(padding)) {
274 if (!vm.connected) {
275 Column(
276 modifier = Modifier.align(Alignment.Center).padding(24.dp),
277 horizontalAlignment = Alignment.CenterHorizontally,
278 ) {
279 Text(
280 stringResource(R.string.not_connected),
281 color = MaterialTheme.colorScheme.error,
282 )
283 Spacer(Modifier.size(12.dp))
284 Button(onClick = { vm.reconnect() }) {
285 Text(stringResource(R.string.reconnect))
286 }
287 }
288 } else {
289 ConnectionBanner(
290 state = connectionState,
291 modifier = Modifier.align(Alignment.TopCenter),
292 )
293 if (loading && messages.isEmpty()) {
294 CircularProgressIndicator(Modifier.align(Alignment.Center))
295 } else if (messages.isEmpty() && !running) {
296 EmptyConversation(
297 modifier = Modifier.align(Alignment.Center),
298 )
299 } else {
300 LazyColumn(
301 state = listState,
302 modifier = Modifier.fillMaxSize(),
303 contentPadding = PaddingValues(16.dp),
304 verticalArrangement = Arrangement.spacedBy(16.dp),
305 ) {
306 items(messages, key = { it.info.id }, contentType = { it.info::class }) { message ->
307 MessageBubble(message, isRunning = running, imageContext = imageContext)
308 }
309 if (running) {
310 item(key = "__typing") {
311 Row(
312 verticalAlignment = Alignment.CenterVertically,
313 modifier = Modifier.semantics {
314 contentDescription = shareContext.getString(R.string.working)
315 },
316 ) {
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))
319 }
320 }
321 }
322 }
323 // Jump-to-latest affordance when the user has scrolled away during a stream.
324 AnimatedVisibility(
325 visible = !isPinnedToBottom && messages.isNotEmpty(),
326 modifier = Modifier.align(Alignment.BottomEnd),
327 ) {
328 ExtendedFloatingActionButton(
329 onClick = {
330 if (messages.isNotEmpty()) {
331 scope.launch { listState.animateScrollToItem(messages.lastIndex) }
332 }
333 },
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),
337 )
338 }
339 }
340 }
341 }
342 }
344 if (showExitConfirm) {
345 AlertDialog(
346 onDismissRequest = { showExitConfirm = false },
347 title = { Text(stringResource(R.string.stop_and_exit_title)) },
348 text = { Text(stringResource(R.string.stop_and_exit_text)) },
349 confirmButton = {
350 TextButton(onClick = {
351 showExitConfirm = false
352 vm.abort()
353 onBack()
354 }) { Text(stringResource(R.string.stop_and_exit)) }
355 },
356 dismissButton = {
357 TextButton(onClick = { showExitConfirm = false }) { Text(stringResource(R.string.stay)) }
358 },
359 )
360 }
362 if (showModelPicker) {
363 ModelPickerSheet(
364 options = models,
365 selected = selectedModel,
366 loading = modelsLoading,
367 onSelect = { vm.selectModel(it) },
368 onDismiss = { showModelPicker = false },
369 )
370 }
372 if (showAgentPicker) {
373 AgentPickerSheet(
374 agents = agents,
375 selected = selectedAgent,
376 loading = agentsLoading,
377 onSelect = { vm.selectAgent(it?.name) },
378 onDismiss = { showAgentPicker = false },
379 )
380 }
382 if (showCommandPicker) {
383 CommandPickerSheet(
384 commands = commands,
385 loading = commandsLoading,
386 onSelect = { vm.runCommand(it) },
387 onDismiss = { showCommandPicker = false },
388 )
389 }
391 pendingPermission?.let { permission ->
392 PermissionDialog(
393 permission = permission,
394 onRespond = { response -> vm.respondPermission(permission, response) },
395 )
396 }
399@Composable
400private fun ChatInputBar(
401 value: String,
402 onValueChange: (String) -> Unit,
403 running: Boolean,
404 enabled: Boolean,
405 onSend: () -> Unit,
406 onAbort: () -> Unit,
407) {
408 Surface(tonalElevation = 3.dp, modifier = Modifier.imePadding()) {
409 Row(
410 modifier = Modifier.fillMaxWidth()
411 .windowInsetsPadding(WindowInsets.navigationBars)
412 .padding(8.dp),
413 verticalAlignment = Alignment.Bottom,
414 ) {
415 OutlinedTextField(
416 value = value,
417 onValueChange = onValueChange,
418 modifier = Modifier
419 .weight(1f)
420 .onPreviewKeyEvent { event ->
421 if (event.type == KeyEventType.KeyDown &&
422 event.key == Key.Enter &&
423 !event.isShiftPressed &&
424 enabled && value.isNotBlank()
425 ) {
426 onSend()
427 true
428 } else {
429 false
430 }
431 },
432 placeholder = { Text(stringResource(R.string.message_placeholder)) },
433 enabled = enabled,
434 maxLines = 6,
435 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
436 keyboardActions = KeyboardActions(onSend = { if (enabled && value.isNotBlank()) onSend() }),
437 )
438 if (running) {
439 IconButton(onClick = onAbort, modifier = Modifier.padding(start = 4.dp)) {
440 Icon(Icons.Filled.Stop, contentDescription = stringResource(R.string.stop))
441 }
442 } else {
443 IconButton(
444 onClick = onSend,
445 enabled = enabled && value.isNotBlank(),
446 modifier = Modifier.padding(start = 4.dp),
447 ) {
448 Icon(Icons.AutoMirrored.Filled.Send, contentDescription = stringResource(R.string.send))
449 }
450 }
451 }
452 }
455@Composable
456private fun EmptyConversation(modifier: Modifier = Modifier) {
457 Column(
458 modifier = modifier.padding(32.dp),
459 horizontalAlignment = Alignment.CenterHorizontally,
460 ) {
461 Icon(
462 Icons.Filled.AutoAwesome,
463 contentDescription = null,
464 modifier = Modifier.size(48.dp),
465 tint = MaterialTheme.colorScheme.onSurfaceVariant,
466 )
467 Spacer(Modifier.size(16.dp))
468 Text(
469 stringResource(R.string.empty_chat_title),
470 style = MaterialTheme.typography.titleMedium,
471 )
472 Spacer(Modifier.size(8.dp))
473 Text(
474 stringResource(R.string.empty_chat_hint),
475 style = MaterialTheme.typography.bodyMedium,
476 color = MaterialTheme.colorScheme.onSurfaceVariant,
477 )
478 }