git.iko.soy/opencode-android
on 8bc2cce help sign in
913 lines 46 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.FlowRow
10import androidx.compose.foundation.layout.PaddingValues
11import androidx.compose.foundation.layout.Row
12import androidx.compose.foundation.layout.Spacer
13import androidx.compose.foundation.layout.WindowInsets
14import androidx.compose.foundation.layout.fillMaxSize
15import androidx.compose.foundation.layout.fillMaxWidth
16import androidx.compose.foundation.layout.imePadding
17import androidx.compose.foundation.layout.navigationBars
18import androidx.compose.foundation.layout.padding
19import androidx.compose.foundation.layout.size
20import androidx.compose.foundation.layout.union
21import androidx.compose.foundation.layout.windowInsetsPadding
22import androidx.compose.foundation.lazy.LazyColumn
23import androidx.compose.foundation.lazy.items
24import androidx.compose.foundation.lazy.rememberLazyListState
25import androidx.compose.material.icons.Icons
26import androidx.compose.material.icons.automirrored.filled.ArrowBack
27import androidx.compose.material.icons.automirrored.filled.Send
28import androidx.compose.material.icons.filled.AutoAwesome
29import androidx.compose.material.icons.filled.KeyboardArrowDown
30import androidx.compose.material.icons.filled.Refresh
31import androidx.compose.material.icons.filled.Share
32import androidx.compose.material.icons.filled.SmartToy
33import androidx.compose.material.icons.filled.Stop
34import androidx.compose.material.icons.filled.Terminal
35import androidx.compose.material.icons.filled.Tune
36import androidx.compose.material3.AlertDialog
37import androidx.compose.material3.Button
38import androidx.compose.material3.CircularProgressIndicator
39import androidx.compose.material3.ExperimentalMaterial3Api
40import androidx.compose.material3.ExtendedFloatingActionButton
41import androidx.compose.material3.FloatingActionButton
42import androidx.compose.material3.Icon
43import androidx.compose.material3.IconButton
44import androidx.compose.material3.MaterialTheme
45import androidx.compose.material3.OutlinedTextField
46import androidx.compose.material3.Scaffold
47import androidx.compose.material3.SnackbarHost
48import androidx.compose.material3.SnackbarHostState
49import androidx.compose.material3.Surface
50import androidx.compose.material3.Text
51import androidx.compose.material3.TextButton
52import androidx.compose.material3.TopAppBar
53import androidx.compose.material3.pulltorefresh.PullToRefreshBox
54import androidx.compose.runtime.Composable
55import androidx.compose.runtime.CompositionLocalProvider
56import androidx.compose.runtime.DisposableEffect
57import androidx.compose.runtime.LaunchedEffect
58import androidx.compose.runtime.derivedStateOf
59import androidx.compose.runtime.getValue
60import androidx.compose.runtime.mutableStateOf
61import androidx.compose.runtime.remember
62import androidx.compose.runtime.rememberCoroutineScope
63import androidx.compose.runtime.saveable.rememberSaveable
64import androidx.compose.runtime.setValue
65import androidx.compose.runtime.snapshotFlow
66import androidx.compose.foundation.text.KeyboardActions
67import androidx.compose.foundation.text.KeyboardOptions
68import androidx.compose.ui.text.input.KeyboardCapitalization
69import androidx.compose.ui.Alignment
70import androidx.compose.ui.Modifier
71import androidx.compose.ui.focus.focusRequester
72import androidx.compose.ui.input.key.Key
73import androidx.compose.ui.input.key.KeyEventType
74import androidx.compose.ui.input.key.isCtrlPressed
75import androidx.compose.ui.input.key.isShiftPressed
76import androidx.compose.ui.input.key.key
77import androidx.compose.ui.input.key.onPreviewKeyEvent
78import androidx.compose.ui.input.key.type
79import androidx.compose.ui.platform.LocalContext
80import androidx.compose.ui.platform.LocalHapticFeedback
81import androidx.compose.ui.platform.LocalView
82import androidx.compose.ui.platform.testTag
83import androidx.compose.ui.res.stringResource
84import androidx.compose.ui.semantics.Role
85import androidx.compose.ui.semantics.contentDescription
86import androidx.compose.ui.semantics.semantics
87import androidx.compose.ui.text.input.ImeAction
88import androidx.compose.ui.text.style.TextOverflow
89import androidx.compose.ui.unit.dp
90import androidx.lifecycle.compose.collectAsStateWithLifecycle
91import androidx.lifecycle.viewmodel.compose.viewModel
92import kotlinx.coroutines.Dispatchers
93import kotlinx.coroutines.launch
94import kotlinx.coroutines.withContext
95import soy.iko.opencode.data.model.Part
96import soy.iko.opencode.data.model.ReasoningPart
97import soy.iko.opencode.data.model.TextPart
98import soy.iko.opencode.data.model.ToolCompleted
99import soy.iko.opencode.data.model.ToolError
100import soy.iko.opencode.data.model.ToolPart
101import soy.iko.opencode.data.model.ToolRunning
102import soy.iko.opencode.data.network.EventStreamClient
103import soy.iko.opencode.data.network.NetworkConfig
104import soy.iko.opencode.di.AppContainer
105import soy.iko.opencode.R
106import soy.iko.opencode.ui.components.ConnectionBanner
107import soy.iko.opencode.ui.components.LocalRelativeTimeTick
108import soy.iko.opencode.ui.components.rememberRelativeTimeTick
109import soy.iko.opencode.ui.components.showToast
110import soy.iko.opencode.ui.components.toImageContext
111import soy.iko.opencode.ui.vmFactory
112import soy.iko.opencode.util.runCatchingCancellable
114@OptIn(ExperimentalMaterial3Api::class)
115@Composable
116fun ChatScreen(
117 container: AppContainer,
118 sessionId: String,
119 onBack: () -> Unit,
120 onOpenFile: ((String) -> Unit)? = null,
121) {
122 val vm: ChatViewModel = viewModel(key = sessionId, factory = vmFactory { ChatViewModel(container, sessionId) })
123 val hasMessages by vm.hasMessages.collectAsStateWithLifecycle()
124 val running by vm.running.collectAsStateWithLifecycle()
125 val aborting by vm.aborting.collectAsStateWithLifecycle()
126 val loading by vm.loading.collectAsStateWithLifecycle()
127 val loadError by vm.loadError.collectAsStateWithLifecycle()
128 val refreshing by vm.refreshing.collectAsStateWithLifecycle()
129 val models by vm.models.collectAsStateWithLifecycle()
130 val modelsLoading by vm.modelsLoading.collectAsStateWithLifecycle()
131 val modelsError by vm.modelsError.collectAsStateWithLifecycle()
132 val selectedModel by vm.selectedModel.collectAsStateWithLifecycle()
133 val connectionState by vm.connectionState.collectAsStateWithLifecycle()
134 val pendingPermission by vm.pendingPermission.collectAsStateWithLifecycle()
135 val agents by vm.agents.collectAsStateWithLifecycle()
136 val agentsLoading by vm.agentsLoading.collectAsStateWithLifecycle()
137 val agentsError by vm.agentsError.collectAsStateWithLifecycle()
138 val selectedAgent by vm.selectedAgent.collectAsStateWithLifecycle()
139 val commands by vm.commands.collectAsStateWithLifecycle()
140 val commandsLoading by vm.commandsLoading.collectAsStateWithLifecycle()
141 val commandsError by vm.commandsError.collectAsStateWithLifecycle()
142 val sessionTitle by vm.sessionTitle.collectAsStateWithLifecycle()
143 val sessionDeleted by vm.sessionDeleted.collectAsStateWithLifecycle()
144 val draft by vm.draft.collectAsStateWithLifecycle()
145 val reconnecting by vm.reconnecting.collectAsStateWithLifecycle()
146 val sendOnEnter by container.settingsStore.sendOnEnter.collectAsStateWithLifecycle(initialValue = true)
147 val isOnline by container.isOnline.collectAsStateWithLifecycle()
148 val haptics = LocalHapticFeedback.current
149 val scope = rememberCoroutineScope()
150 val shareContext = LocalContext.current
151 val shareLabel = stringResource(R.string.share)
152 val defaultShareSubject = stringResource(R.string.share_subject)
153 val sessionLabel = stringResource(R.string.session)
154 val defaultModelLabel = stringResource(R.string.default_model)
155 val subtitle = remember(selectedModel?.modelLabel, selectedAgent, defaultModelLabel) {
156 buildString {
157 append(selectedModel?.modelLabel ?: defaultModelLabel)
158 selectedAgent?.let { append(" · $it") }
159 }
160 }
162 // Connection profile (with auth) for rendering inline image attachments.
163 // Collect as state so a server switch recomposes the image context.
164 val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
165 val imageContext = remember(activeConnection?.profile) {
166 activeConnection?.profile?.toImageContext()
167 }
169 // Mark this session as read while the user is viewing it; clear on leave so new
170 // background activity can badge it again.
171 DisposableEffect(sessionId) {
172 container.setCurrentSession(sessionId)
173 onDispose { if (container.currentSession.value == sessionId) container.setCurrentSession(null) }
174 }
176 // Navigate away when the session is deleted via SSE so the user isn't left
177 // on a zombie screen showing a conversation that no longer exists. Surface a
178 // toast (not a snackbar) because the screen is about to be popped — a snackbar
179 // would be torn down before the user could read it, while a toast survives.
180 val deletedContext = LocalContext.current
181 LaunchedEffect(sessionDeleted) {
182 if (sessionDeleted) {
183 showToast(deletedContext, deletedContext.getString(R.string.session_deleted_msg))
184 onBack()
185 }
186 }
188 val snackbar = remember { SnackbarHostState() }
189 val retryLabel = stringResource(R.string.retry)
190 val inputFocusRequester = remember { androidx.compose.ui.focus.FocusRequester() }
191 var showModelPicker by rememberSaveable { mutableStateOf(false) }
192 var showAgentPicker by rememberSaveable { mutableStateOf(false) }
193 var showCommandPicker by rememberSaveable { mutableStateOf(false) }
194 var showExitConfirm by rememberSaveable { mutableStateOf(false) }
195 var showStopConfirm by rememberSaveable { mutableStateOf(false) }
197 // Keep the screen awake and hold a foreground priority while the agent is working,
198 // so backgrounding mid-run doesn't let Doze choke the SSE stream.
199 val currentView = LocalView.current
200 val appContext = LocalContext.current.applicationContext
201 DisposableEffect(running) {
202 currentView.keepScreenOn = running
203 if (running) soy.iko.opencode.notification.RunForegroundService.start(appContext)
204 onDispose {
205 currentView.keepScreenOn = false
206 soy.iko.opencode.notification.RunForegroundService.stop(appContext)
207 }
208 }
210 LaunchedEffect(Unit) {
211 vm.errorEvents.collect { event ->
212 // Only a failed *send* is retryable; attaching Retry to any other error (e.g. a
213 // message-load failure) would silently re-submit the last prompt on tap.
214 val result = if (event.retryable) {
215 snackbar.showSnackbar(message = event.message, actionLabel = retryLabel)
216 } else {
217 snackbar.showSnackbar(event.message)
218 }
219 if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) vm.retryFailed()
220 }
221 }
223 fun doSend() {
224 if (vm.send(draft)) {
225 // TextHandleMove (a light tick) is semantically right for a send commit;
226 // LongPress is reserved for long-press/swipe gestures.
227 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
228 }
229 }
231 BackHandler(enabled = running && !showModelPicker && !showAgentPicker && !showCommandPicker) { showExitConfirm = true }
233 Scaffold(
234 topBar = {
235 TopAppBar(
236 title = {
237 val changeLabel = stringResource(R.string.change_model_agent)
238 Row(
239 modifier = Modifier
240 .clickable(enabled = models.isNotEmpty(), role = Role.Button) { showModelPicker = true }
241 .semantics(mergeDescendants = true) { contentDescription = changeLabel },
242 verticalAlignment = Alignment.CenterVertically,
243 ) {
244 Column {
245 Text(sessionTitle ?: sessionLabel)
246 Text(
247 subtitle,
248 style = MaterialTheme.typography.labelSmall,
249 color = MaterialTheme.colorScheme.onSurfaceVariant,
250 )
251 }
252 // Visual affordance that the title opens the model picker;
253 // without it the tappable title is indistinguishable from plain
254 // text and the picker is effectively undiscoverable.
255 if (models.isNotEmpty()) {
256 Icon(
257 Icons.Filled.KeyboardArrowDown,
258 contentDescription = null,
259 tint = MaterialTheme.colorScheme.onSurfaceVariant,
260 modifier = Modifier.size(20.dp),
261 )
262 }
263 }
264 },
265 navigationIcon = {
266 IconButton(onClick = {
267 if (running) showExitConfirm = true else onBack()
268 }) {
269 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
270 }
271 },
272 actions = {
273 // At-a-glance run status in the top bar. When the user is scrolled
274 // up (jump-to-latest FAB visible), the input-bar Stop button and the
275 // trailing "working…" row are off-screen, so a top-bar spinner is the
276 // only signal the agent is still working.
277 if (running) {
278 val workingLabel = stringResource(R.string.working)
279 CircularProgressIndicator(
280 Modifier.size(18.dp).semantics { contentDescription = workingLabel },
281 strokeWidth = 2.dp,
282 )
283 }
284 // Manual refresh: forces an SSE reconnect, which re-seeds messages
285 // from REST. A recovery path when the stream silently drops and the
286 // auto-reconnect re-seed is slow or fails. Shows a brief spinner as
287 // immediate tap feedback — the SSE reconnect may not visibly change
288 // the connection state when already Connected, so without it the tap
289 // appears to do nothing.
290 val refreshLabel = stringResource(R.string.refresh)
291 IconButton(
292 onClick = { vm.refreshMessages() },
293 enabled = activeConnection != null && !refreshing,
294 ) {
295 if (refreshing) {
296 CircularProgressIndicator(
297 Modifier.size(18.dp).semantics { contentDescription = refreshLabel },
298 strokeWidth = 2.dp,
299 )
300 } else {
301 Icon(Icons.Filled.Refresh, contentDescription = refreshLabel)
302 }
303 }
304 IconButton(
305 onClick = {
306 scope.launch {
307 val md = withContext(Dispatchers.Default) {
308 buildConversationMarkdown(vm.messages.value, sessionTitle)
309 }
310 val send = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
311 type = "text/markdown"
312 putExtra(android.content.Intent.EXTRA_SUBJECT, sessionTitle ?: defaultShareSubject)
313 putExtra(android.content.Intent.EXTRA_TEXT, md)
314 }
315 runCatchingCancellable { shareContext.startActivity(android.content.Intent.createChooser(send, shareLabel)) }
316 .onFailure { showToast(shareContext, shareContext.getString(R.string.no_share_app)) }
317 }
318 },
319 enabled = hasMessages,
320 ) {
321 Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share_conversation))
322 }
323 // Show a spinner while a catalog is initially loading (empty list +
324 // loading flag) so the icon doesn't look permanently disabled. Once
325 // loaded, the icon is always enabled — tapping a picker with no items
326 // shows the "no models/agents/commands" or error-with-retry state.
327 if (commandsLoading && commands.isEmpty()) {
328 val loadingLabel = stringResource(R.string.loading)
329 CircularProgressIndicator(
330 Modifier.size(18.dp).semantics { contentDescription = loadingLabel },
331 strokeWidth = 2.dp,
332 )
333 } else {
334 IconButton(onClick = { showCommandPicker = true }) {
335 Icon(Icons.Filled.Terminal, contentDescription = stringResource(R.string.commands))
336 }
337 }
338 if (agentsLoading && agents.isEmpty()) {
339 val loadingLabel = stringResource(R.string.loading)
340 CircularProgressIndicator(
341 Modifier.size(18.dp).semantics { contentDescription = loadingLabel },
342 strokeWidth = 2.dp,
343 )
344 } else {
345 IconButton(onClick = { showAgentPicker = true }) {
346 Icon(Icons.Filled.SmartToy, contentDescription = stringResource(R.string.choose_agent))
347 }
348 }
349 if (modelsLoading && models.isEmpty()) {
350 val loadingLabel = stringResource(R.string.loading)
351 CircularProgressIndicator(
352 Modifier.size(18.dp).semantics { contentDescription = loadingLabel },
353 strokeWidth = 2.dp,
354 )
355 } else {
356 IconButton(onClick = { showModelPicker = true }) {
357 Icon(Icons.Filled.Tune, contentDescription = stringResource(R.string.choose_model))
358 }
359 }
360 },
361 )
362 },
363 snackbarHost = { SnackbarHost(snackbar) },
364 bottomBar = {
365 val queuedFollowUp by vm.queuedFollowUp.collectAsStateWithLifecycle()
366 ChatInputBar(
367 value = draft,
368 onValueChange = vm::updateDraft,
369 running = running,
370 aborting = aborting,
371 enabled = activeConnection != null,
372 sendOnEnter = sendOnEnter,
373 onSend = ::doSend,
374 onAbort = { showStopConfirm = true },
375 queuedFollowUp = queuedFollowUp,
376 onQueueFollowUp = vm::queueFollowUp,
377 onCancelQueue = { vm.queueFollowUp("") },
378 focusRequester = inputFocusRequester,
379 )
380 },
381 ) { padding ->
382 // Collect messages inside the content lambda so streaming token updates
383 // recompose only this subtree, not the top bar / input bar / 20+ other
384 // state reads in the ChatScreen body.
385 val messages by vm.messages.collectAsStateWithLifecycle()
386 val listState = rememberLazyListState()
387 val contentScope = rememberCoroutineScope()
388 val timeTick = rememberRelativeTimeTick()
390 val isPinnedToBottom by remember {
391 derivedStateOf {
392 val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
393 // When a run is active, the LazyColumn has one trailing "__typing"
394 // item after the last message. Account for it so "pinned to bottom"
395 // includes the working indicator — otherwise the pin check targets
396 // messages.lastIndex, the typing row sits just below the viewport,
397 // and the user never sees the progress indicator even when pinned.
398 val effectiveLast = if (running) messages.size else messages.lastIndex
399 lastVisible >= effectiveLast || messages.isEmpty()
400 }
401 }
403 // Jump to the newest message once, when the conversation first loads. Guarded by a
404 // saveable one-shot flag so it doesn't re-fire on recomposition after a config change
405 // (rotation) — rememberLazyListState restores the user's scroll offset, and an
406 // unconditional scrollToItem here would clobber it, snapping them back to the bottom.
407 var didInitialScroll by rememberSaveable { mutableStateOf(false) }
408 LaunchedEffect(messages.isNotEmpty()) {
409 if (messages.isNotEmpty() && !didInitialScroll) {
410 // When a run is active the LazyColumn has a trailing "__typing" row at
411 // messages.size; target it (not messages.lastIndex) so deep-linking into a
412 // running session lands truly pinned to bottom and streaming auto-scroll engages.
413 listState.scrollToItem(if (running) messages.size else messages.lastIndex)
414 didInitialScroll = true
415 }
416 }
418 // When a run starts, the LazyColumn gains a trailing "__typing" row, so
419 // effectiveLast jumps from messages.lastIndex to messages.size and the
420 // isPinnedToBottom check flips to false the instant you send — freezing
421 // auto-scroll. If the user was pinned to the last message just before the run
422 // began, bring the typing row into view so the pin (and streaming follow) is
423 // preserved. Guard on the raw last-visible index (isPinnedToBottom has already
424 // recomputed to false by now) so we don't scroll when the user had scrolled up.
425 LaunchedEffect(running) {
426 if (running && messages.isNotEmpty()) {
427 val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
428 if (lastVisible >= messages.lastIndex) {
429 runCatchingCancellable { listState.animateScrollToItem(messages.size) }
430 }
431 }
432 }
434 LaunchedEffect(Unit) {
435 snapshotFlow {
436 // Track list size + the streaming length of the last part so we auto-scroll
437 // as content arrives, plus the pinned flag. A small data class with primitive
438 // fields avoids the per-frame boxing that Triple<Int,Int,Boolean> pays
439 // (snapshotFlow re-evaluates this lambda every snapshot), and reading only the
440 // last part's length is O(1) vs. a sumOf over every part. Covering reasoning
441 // and tool output — not just TextPart — keeps a pinned view following a long
442 // reasoning/tool block while it streams (previously it stalled at 0).
443 val lastLen = streamingContentLength(messages.lastOrNull()?.parts?.lastOrNull())
444 AutoScrollSignal(messages.size, lastLen, isPinnedToBottom)
445 }.collect { signal ->
446 if (signal.size > 0 && signal.pinned) {
447 // Scroll to the effective last index, including the trailing
448 // "__typing" row when a run is active so the working indicator
449 // is brought into view (not just the last message).
450 val target = if (running) messages.size else messages.lastIndex
451 listState.scrollToItem(target)
452 }
453 }
454 }
456 CompositionLocalProvider(LocalRelativeTimeTick provides timeTick) {
457 // The bottomBar (ChatInputBar) already has imePadding, and the Scaffold's
458 // content padding accounts for the bottomBar's raised height, so the message
459 // list is already above the keyboard. Adding imePadding here would double-apply
460 // the IME inset and push messages too far up.
461 Box(modifier = Modifier.fillMaxSize().padding(padding)) {
462 if (activeConnection == null) {
463 Column(
464 modifier = Modifier.align(Alignment.Center).padding(24.dp),
465 horizontalAlignment = Alignment.CenterHorizontally,
466 ) {
467 Text(
468 stringResource(R.string.not_connected),
469 color = MaterialTheme.colorScheme.error,
470 )
471 Spacer(Modifier.size(12.dp))
472 Button(onClick = { vm.reconnect() }, enabled = !reconnecting) {
473 if (reconnecting) {
474 CircularProgressIndicator(
475 Modifier.size(20.dp),
476 color = MaterialTheme.colorScheme.onPrimary,
477 strokeWidth = 2.dp,
478 )
479 } else {
480 Text(stringResource(R.string.reconnect))
481 }
482 }
483 }
484 } else {
485 ConnectionBanner(
486 state = connectionState,
487 modifier = Modifier.align(Alignment.TopCenter),
488 isOnline = isOnline,
489 // On a hard failure, retry by forcing an SSE reconnect (which
490 // re-seeds from REST). refreshMessages is the right recovery path
491 // when the connection is present but the stream died; reconnect()
492 // is the path when the whole connection is gone (handled by the
493 // separate "Not connected" state below).
494 onRetry = { vm.refreshMessages() },
495 )
496 if (loading && messages.isEmpty()) {
497 val loadingLabel = stringResource(R.string.loading)
498 CircularProgressIndicator(
499 Modifier
500 .align(Alignment.Center)
501 .semantics { contentDescription = loadingLabel },
502 )
503 } else if (loadError && messages.isEmpty()) {
504 // A failed load with nothing to show. Distinct from the empty
505 // conversation state below — the conversation isn't empty, it
506 // failed to load, and offering "Start a conversation" here is
507 // misleading. Offer a Retry instead, mirroring SessionListScreen.
508 Column(
509 modifier = Modifier.align(Alignment.Center).padding(24.dp),
510 horizontalAlignment = Alignment.CenterHorizontally,
511 ) {
512 Text(
513 stringResource(R.string.load_messages_failed),
514 color = MaterialTheme.colorScheme.error,
515 )
516 Spacer(Modifier.size(12.dp))
517 Button(onClick = { vm.refreshMessages() }) {
518 Text(stringResource(R.string.retry))
519 }
520 }
521 } else if (messages.isEmpty() && running) {
522 // A run just started but no parts have arrived yet — the empty
523 // list with only the trailing "working…" row looks broken, so
524 // surface a clear starting state until the first part streams in.
525 val workingText = stringResource(R.string.working)
526 Column(
527 modifier = Modifier.align(Alignment.Center).padding(24.dp),
528 horizontalAlignment = Alignment.CenterHorizontally,
529 ) {
530 CircularProgressIndicator(Modifier.size(28.dp), strokeWidth = 3.dp)
531 Spacer(Modifier.size(12.dp))
532 Text(workingText, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
533 }
534 } else if (messages.isEmpty()) {
535 EmptyConversation(
536 onSuggestion = {
537 vm.updateDraft(it)
538 // Focus the input so the user can edit the suggestion before
539 // sending, instead of having to manually tap the field.
540 runCatching { inputFocusRequester.requestFocus() }
541 },
542 modifier = Modifier.align(Alignment.Center),
543 )
544 } else {
545 // Pull-to-refresh wraps the message list so the user can force an SSE
546 // reconnect + re-seed with the same gesture they use on the session and
547 // file lists, instead of hunting for the top-bar refresh icon. The empty
548 // / loading / error states above stay outside the PTR box since there's
549 // no list content to pull against.
550 PullToRefreshBox(
551 isRefreshing = refreshing,
552 onRefresh = { vm.refreshMessages() },
553 modifier = Modifier.fillMaxSize(),
554 ) {
555 val lastMessageId = messages.lastOrNull()?.info?.id
556 LazyColumn(
557 state = listState,
558 modifier = Modifier.fillMaxSize(),
559 contentPadding = PaddingValues(16.dp),
560 verticalArrangement = Arrangement.spacedBy(16.dp),
561 ) {
562 items(messages, key = { it.info.id }, contentType = { it.info::class }) { message ->
563 // Only the last (streaming) message needs isRunning — it drives the
564 // reasoning-block spinner. Passing the live flag to every bubble
565 // makes all visible messages recompose whenever a run starts or stops.
566 val modelLabel = (message.info as? soy.iko.opencode.data.model.AssistantMessage)
567 ?.let { resolveModelLabel(it, models) }
568 MessageBubble(
569 message,
570 isRunning = running && message.info.id == lastMessageId,
571 imageContext = imageContext,
572 modelLabel = modelLabel,
573 onOpenFile = onOpenFile,
574 )
575 }
576 if (running) {
577 item(key = "__typing") {
578 val workingText = stringResource(R.string.working)
579 Row(
580 verticalAlignment = Alignment.CenterVertically,
581 modifier = Modifier.semantics {
582 contentDescription = workingText
583 },
584 ) {
585 CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
586 Text(workingText, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp))
587 }
588 }
589 }
590 }
591 }
592 // Jump-to-latest affordance when the user has scrolled away during a stream.
593 AnimatedVisibility(
594 visible = !isPinnedToBottom && messages.isNotEmpty(),
595 modifier = Modifier.align(Alignment.BottomEnd),
596 ) {
597 ExtendedFloatingActionButton(
598 onClick = {
599 if (messages.isNotEmpty()) {
600 // Scroll to the effective last index, including the
601 // trailing "__typing" row when a run is active.
602 val target = if (running) messages.size else messages.lastIndex
603 contentScope.launch { runCatchingCancellable { listState.animateScrollToItem(target) } }
604 }
605 },
606 icon = { Icon(Icons.Filled.KeyboardArrowDown, contentDescription = stringResource(R.string.latest)) },
607 text = { Text(stringResource(R.string.latest)) },
608 modifier = Modifier.padding(end = 16.dp, bottom = 16.dp),
609 )
610 }
611 }
612 }
613 }
614 }
615 }
617 if (showExitConfirm) {
618 AlertDialog(
619 onDismissRequest = { showExitConfirm = false },
620 title = { Text(stringResource(R.string.stop_and_exit_title)) },
621 text = { Text(stringResource(R.string.stop_and_exit_text)) },
622 confirmButton = {
623 TextButton(onClick = {
624 showExitConfirm = false
625 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
626 vm.abort()
627 onBack()
628 }) { Text(stringResource(R.string.stop_and_exit)) }
629 },
630 dismissButton = {
631 TextButton(onClick = { showExitConfirm = false }) { Text(stringResource(R.string.stay)) }
632 },
633 )
634 }
636 if (showStopConfirm) {
637 AlertDialog(
638 onDismissRequest = { showStopConfirm = false },
639 title = { Text(stringResource(R.string.stop_run_title)) },
640 text = { Text(stringResource(R.string.stop_run_text)) },
641 confirmButton = {
642 TextButton(onClick = {
643 showStopConfirm = false
644 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
645 vm.abort()
646 }) { Text(stringResource(R.string.stop), color = MaterialTheme.colorScheme.error) }
647 },
648 dismissButton = {
649 TextButton(onClick = { showStopConfirm = false }) { Text(stringResource(R.string.cancel)) }
650 },
651 )
652 }
654 if (showModelPicker) {
655 ModelPickerSheet(
656 options = models,
657 selected = selectedModel,
658 loading = modelsLoading,
659 error = modelsError,
660 onSelect = { vm.selectModel(it) },
661 onRetry = { vm.reloadModels() },
662 onDismiss = { showModelPicker = false },
663 )
664 }
666 if (showAgentPicker) {
667 AgentPickerSheet(
668 agents = agents,
669 selected = selectedAgent,
670 loading = agentsLoading,
671 error = agentsError,
672 onSelect = { vm.selectAgent(it?.name) },
673 onRetry = { vm.reloadAgents() },
674 onDismiss = { showAgentPicker = false },
675 )
676 }
678 if (showCommandPicker) {
679 CommandPickerSheet(
680 commands = commands,
681 loading = commandsLoading,
682 error = commandsError,
683 onSelect = { vm.runCommand(it) },
684 onRetry = { vm.reloadCommands() },
685 onDismiss = { showCommandPicker = false },
686 )
687 }
689 pendingPermission?.let { permission ->
690 PermissionDialog(
691 permission = permission,
692 onRespond = { response -> vm.respondPermission(permission, response) },
693 )
694 }
697@Composable
698private fun ChatInputBar(
699 value: String,
700 onValueChange: (String) -> Unit,
701 running: Boolean,
702 aborting: Boolean,
703 enabled: Boolean,
704 sendOnEnter: Boolean,
705 onSend: () -> Unit,
706 onAbort: () -> Unit,
707 queuedFollowUp: String?,
708 onQueueFollowUp: (String) -> Unit,
709 onCancelQueue: () -> Unit,
710 focusRequester: androidx.compose.ui.focus.FocusRequester,
711) {
712 Surface(tonalElevation = 3.dp, modifier = Modifier.imePadding()) {
713 Column(modifier = Modifier.fillMaxWidth().windowInsetsPadding(WindowInsets.navigationBars)) {
714 // A queued follow-up replaces the Stop button with a "queued" chip so the
715 // user sees their message will be sent when the run finishes, and can cancel.
716 if (queuedFollowUp != null) {
717 Row(
718 modifier = Modifier
719 .fillMaxWidth()
720 .padding(start = 12.dp, top = 6.dp, end = 4.dp),
721 verticalAlignment = Alignment.CenterVertically,
722 ) {
723 Column(modifier = Modifier.weight(1f)) {
724 Text(
725 stringResource(R.string.queued),
726 style = MaterialTheme.typography.labelSmall,
727 color = MaterialTheme.colorScheme.onSurfaceVariant,
728 )
729 Text(
730 queuedFollowUp,
731 style = MaterialTheme.typography.bodySmall,
732 color = MaterialTheme.colorScheme.onSurfaceVariant,
733 maxLines = 1,
734 overflow = TextOverflow.Ellipsis,
735 )
736 }
737 TextButton(onClick = onCancelQueue, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp)) {
738 Text(stringResource(R.string.cancel))
739 }
740 }
741 }
742 Row(
743 modifier = Modifier.fillMaxWidth().padding(8.dp),
744 verticalAlignment = Alignment.Bottom,
745 ) {
746 OutlinedTextField(
747 value = value,
748 onValueChange = { v ->
749 // Cap input length so a huge paste can't stall the UI. Shares are
750 // capped separately in MainActivity; this guards typed/pasted drafts.
751 onValueChange(v.take(NetworkConfig.maxDraftLengthChars))
752 },
753 modifier = Modifier
754 .weight(1f)
755 .focusRequester(focusRequester)
756 .testTag("chat_input")
757 .onPreviewKeyEvent { event ->
758 if (event.type != KeyEventType.KeyDown || event.key != Key.Enter) return@onPreviewKeyEvent false
759 // With "Send on Enter" on, Enter sends (Shift+Enter newlines).
760 // With it off, Enter inserts a newline and Ctrl+Enter sends.
761 val send = when {
762 !enabled || value.isBlank() -> false
763 sendOnEnter -> !event.isShiftPressed
764 else -> event.isCtrlPressed
765 }
766 if (send) {
767 if (running) onQueueFollowUp(value) else onSend()
768 true
769 } else {
770 false
771 }
772 },
773 placeholder = { Text(stringResource(R.string.message_placeholder)) },
774 enabled = enabled,
775 maxLines = 6,
776 // Show a "N / max" countdown once the draft crosses a high fraction of
777 // the cap, so the user knows a paste is about to be truncated instead
778 // of being silently cut off. Hidden for normal short prompts to avoid
779 // clutter under a typical one-line message.
780 supportingText = {
781 val threshold = (NetworkConfig.maxDraftLengthChars * NetworkConfig.draftCountdownThresholdFraction).toInt()
782 if (value.length >= threshold) {
783 Text(
784 stringResource(
785 R.string.draft_chars_remaining,
786 NetworkConfig.maxDraftLengthChars - value.length,
787 NetworkConfig.maxDraftLengthChars,
788 ),
789 style = MaterialTheme.typography.labelSmall,
790 color = if (value.length >= NetworkConfig.maxDraftLengthChars) MaterialTheme.colorScheme.error
791 else MaterialTheme.colorScheme.onSurfaceVariant,
792 )
793 }
794 },
795 keyboardOptions = KeyboardOptions(
796 imeAction = ImeAction.Send,
797 capitalization = KeyboardCapitalization.Sentences,
798 ),
799 keyboardActions = KeyboardActions(onSend = {
800 if (enabled && value.isNotBlank()) {
801 if (running) onQueueFollowUp(value) else onSend()
802 }
803 }),
804 )
805 if (running) {
806 // Show a spinner while the abort REST call is in flight so the user
807 // sees the stop was sent, and disable to prevent a double-tap.
808 IconButton(
809 onClick = onAbort,
810 enabled = !aborting,
811 modifier = Modifier.padding(start = 4.dp).testTag("stop_button"),
812 ) {
813 if (aborting) {
814 val stopLabel = stringResource(R.string.stop)
815 CircularProgressIndicator(
816 Modifier.size(18.dp).semantics { contentDescription = stopLabel },
817 strokeWidth = 2.dp,
818 )
819 } else {
820 Icon(Icons.Filled.Stop, contentDescription = stringResource(R.string.stop))
821 }
822 }
823 } else {
824 IconButton(
825 onClick = onSend,
826 enabled = enabled && value.isNotBlank(),
827 modifier = Modifier.padding(start = 4.dp).testTag("send_button"),
828 ) {
829 Icon(Icons.AutoMirrored.Filled.Send, contentDescription = stringResource(R.string.send))
830 }
831 }
832 }
833 }
834 }
837@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class)
838@Composable
839private fun EmptyConversation(
840 onSuggestion: (String) -> Unit,
841 modifier: Modifier = Modifier,
842) {
843 // A few example prompts so a first-time user has a one-tap path to their first
844 // message instead of staring at a blank input. Tapping a chip fills the draft
845 // (without sending) so the user can edit it before sending.
846 val suggestions = remember {
847 listOf(
848 R.string.empty_chat_suggest_1,
849 R.string.empty_chat_suggest_2,
850 R.string.empty_chat_suggest_3,
851 )
852 }
853 Column(
854 modifier = modifier.padding(32.dp),
855 horizontalAlignment = Alignment.CenterHorizontally,
856 ) {
857 Icon(
858 Icons.Filled.AutoAwesome,
859 contentDescription = null,
860 modifier = Modifier.size(48.dp),
861 tint = MaterialTheme.colorScheme.onSurfaceVariant,
862 )
863 Spacer(Modifier.size(16.dp))
864 Text(
865 stringResource(R.string.empty_chat_title),
866 style = MaterialTheme.typography.titleMedium,
867 )
868 Spacer(Modifier.size(8.dp))
869 Text(
870 stringResource(R.string.empty_chat_hint),
871 style = MaterialTheme.typography.bodyMedium,
872 color = MaterialTheme.colorScheme.onSurfaceVariant,
873 )
874 Spacer(Modifier.size(20.dp))
875 // Suggestion chips wrap across rows on narrow screens instead of stacking full-width,
876 // so a longer localized prompt doesn't push the chips off-screen on a phone.
877 FlowRow(
878 modifier = Modifier.fillMaxWidth(),
879 horizontalArrangement = Arrangement.spacedBy(8.dp),
880 verticalArrangement = Arrangement.spacedBy(8.dp),
881 ) {
882 suggestions.forEach { resId ->
883 val text = stringResource(resId)
884 androidx.compose.material3.AssistChip(
885 onClick = { onSuggestion(text) },
886 label = { Text(text, style = MaterialTheme.typography.bodyMedium) },
887 )
888 }
889 }
890 }
893/**
894 * Change signal for the auto-scroll-on-stream watcher. Primitive fields avoid the
895 * boxing `Triple<Int, Int, Boolean>` would impose on every snapshot evaluation, and
896 * equality is structural so `snapshotFlow` emits only when something relevant changes.
897 */
898private data class AutoScrollSignal(val size: Int, val lastTextLength: Int, val pinned: Boolean)
900/** Length of the streaming content carried by [part], driving auto-scroll as it grows.
901 * Covers text, reasoning, and tool output so a pinned view keeps following non-text
902 * streaming, not just plain assistant text. O(1). */
903private fun streamingContentLength(part: Part?): Int = when (part) {
904 is TextPart -> part.text.length
905 is ReasoningPart -> part.text.length
906 is ToolPart -> when (val st = part.state) {
907 is ToolCompleted -> st.output?.length ?: 0
908 is ToolError -> st.error?.length ?: 0
909 is ToolRunning -> st.title?.length ?: 0
910 else -> 0
911 }
912 else -> 0