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)
117 container: AppContainer,
120 onOpenFile: ((String) -> Unit)? = null,
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) {
157 append(selectedModel?.modelLabel ?: defaultModelLabel)
158 selectedAgent?.let { append(" · $it") }
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()
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) }
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))
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)
205 currentView.keepScreenOn = false
206 soy.iko.opencode.notification.RunForegroundService.stop(appContext)
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)
217 snackbar.showSnackbar(event.message)
219 if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) vm.retryFailed()
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)
231 BackHandler(enabled = running && !showModelPicker && !showAgentPicker && !showCommandPicker) { showExitConfirm = true }
237 val changeLabel = stringResource(R.string.change_model_agent)
240 .clickable(enabled = models.isNotEmpty(), role = Role.Button) { showModelPicker = true }
241 .semantics(mergeDescendants = true) { contentDescription = changeLabel },
242 verticalAlignment = Alignment.CenterVertically,
245 Text(sessionTitle ?: sessionLabel)
248 style = MaterialTheme.typography.labelSmall,
249 color = MaterialTheme.colorScheme.onSurfaceVariant,
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()) {
257 Icons.Filled.KeyboardArrowDown,
258 contentDescription = null,
259 tint = MaterialTheme.colorScheme.onSurfaceVariant,
260 modifier = Modifier.size(20.dp),
266 IconButton(onClick = {
267 if (running) showExitConfirm = true else onBack()
269 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
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.
278 val workingLabel = stringResource(R.string.working)
279 CircularProgressIndicator(
280 Modifier.size(18.dp).semantics { contentDescription = workingLabel },
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)
292 onClick = { vm.refreshMessages() },
293 enabled = activeConnection != null && !refreshing,
296 CircularProgressIndicator(
297 Modifier.size(18.dp).semantics { contentDescription = refreshLabel },
301 Icon(Icons.Filled.Refresh, contentDescription = refreshLabel)
307 val md = withContext(Dispatchers.Default) {
308 buildConversationMarkdown(vm.messages.value, sessionTitle)
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)
315 runCatchingCancellable { shareContext.startActivity(android.content.Intent.createChooser(send, shareLabel)) }
316 .onFailure { showToast(shareContext, shareContext.getString(R.string.no_share_app)) }
319 enabled = hasMessages,
321 Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share_conversation))
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 },
334 IconButton(onClick = { showCommandPicker = true }) {
335 Icon(Icons.Filled.Terminal, contentDescription = stringResource(R.string.commands))
338 if (agentsLoading && agents.isEmpty()) {
339 val loadingLabel = stringResource(R.string.loading)
340 CircularProgressIndicator(
341 Modifier.size(18.dp).semantics { contentDescription = loadingLabel },
345 IconButton(onClick = { showAgentPicker = true }) {
346 Icon(Icons.Filled.SmartToy, contentDescription = stringResource(R.string.choose_agent))
349 if (modelsLoading && models.isEmpty()) {
350 val loadingLabel = stringResource(R.string.loading)
351 CircularProgressIndicator(
352 Modifier.size(18.dp).semantics { contentDescription = loadingLabel },
356 IconButton(onClick = { showModelPicker = true }) {
357 Icon(Icons.Filled.Tune, contentDescription = stringResource(R.string.choose_model))
363 snackbarHost = { SnackbarHost(snackbar) },
365 val queuedFollowUp by vm.queuedFollowUp.collectAsStateWithLifecycle()
368 onValueChange = vm::updateDraft,
371 enabled = activeConnection != null,
372 sendOnEnter = sendOnEnter,
374 onAbort = { showStopConfirm = true },
375 queuedFollowUp = queuedFollowUp,
376 onQueueFollowUp = vm::queueFollowUp,
377 onCancelQueue = { vm.queueFollowUp("") },
378 focusRequester = inputFocusRequester,
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 {
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()
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
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) }
434 LaunchedEffect(Unit) {
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)
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) {
464 modifier = Modifier.align(Alignment.Center).padding(24.dp),
465 horizontalAlignment = Alignment.CenterHorizontally,
468 stringResource(R.string.not_connected),
469 color = MaterialTheme.colorScheme.error,
471 Spacer(Modifier.size(12.dp))
472 Button(onClick = { vm.reconnect() }, enabled = !reconnecting) {
474 CircularProgressIndicator(
475 Modifier.size(20.dp),
476 color = MaterialTheme.colorScheme.onPrimary,
480 Text(stringResource(R.string.reconnect))
486 state = connectionState,
487 modifier = Modifier.align(Alignment.TopCenter),
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() },
496 if (loading && messages.isEmpty()) {
497 val loadingLabel = stringResource(R.string.loading)
498 CircularProgressIndicator(
500 .align(Alignment.Center)
501 .semantics { contentDescription = loadingLabel },
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.
509 modifier = Modifier.align(Alignment.Center).padding(24.dp),
510 horizontalAlignment = Alignment.CenterHorizontally,
513 stringResource(R.string.load_messages_failed),
514 color = MaterialTheme.colorScheme.error,
516 Spacer(Modifier.size(12.dp))
517 Button(onClick = { vm.refreshMessages() }) {
518 Text(stringResource(R.string.retry))
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)
527 modifier = Modifier.align(Alignment.Center).padding(24.dp),
528 horizontalAlignment = Alignment.CenterHorizontally,
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)
534 } else if (messages.isEmpty()) {
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() }
542 modifier = Modifier.align(Alignment.Center),
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.
551 isRefreshing = refreshing,
552 onRefresh = { vm.refreshMessages() },
553 modifier = Modifier.fillMaxSize(),
555 val lastMessageId = messages.lastOrNull()?.info?.id
558 modifier = Modifier.fillMaxSize(),
559 contentPadding = PaddingValues(16.dp),
560 verticalArrangement = Arrangement.spacedBy(16.dp),
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) }
570 isRunning = running && message.info.id == lastMessageId,
571 imageContext = imageContext,
572 modelLabel = modelLabel,
573 onOpenFile = onOpenFile,
577 item(key = "__typing") {
578 val workingText = stringResource(R.string.working)
580 verticalAlignment = Alignment.CenterVertically,
581 modifier = Modifier.semantics {
582 contentDescription = workingText
585 CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
586 Text(workingText, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp))
592 // Jump-to-latest affordance when the user has scrolled away during a stream.
594 visible = !isPinnedToBottom && messages.isNotEmpty(),
595 modifier = Modifier.align(Alignment.BottomEnd),
597 ExtendedFloatingActionButton(
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) } }
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),
617 if (showExitConfirm) {
619 onDismissRequest = { showExitConfirm = false },
620 title = { Text(stringResource(R.string.stop_and_exit_title)) },
621 text = { Text(stringResource(R.string.stop_and_exit_text)) },
623 TextButton(onClick = {
624 showExitConfirm = false
625 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
628 }) { Text(stringResource(R.string.stop_and_exit)) }
631 TextButton(onClick = { showExitConfirm = false }) { Text(stringResource(R.string.stay)) }
636 if (showStopConfirm) {
638 onDismissRequest = { showStopConfirm = false },
639 title = { Text(stringResource(R.string.stop_run_title)) },
640 text = { Text(stringResource(R.string.stop_run_text)) },
642 TextButton(onClick = {
643 showStopConfirm = false
644 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
646 }) { Text(stringResource(R.string.stop), color = MaterialTheme.colorScheme.error) }
649 TextButton(onClick = { showStopConfirm = false }) { Text(stringResource(R.string.cancel)) }
654 if (showModelPicker) {
657 selected = selectedModel,
658 loading = modelsLoading,
660 onSelect = { vm.selectModel(it) },
661 onRetry = { vm.reloadModels() },
662 onDismiss = { showModelPicker = false },
666 if (showAgentPicker) {
669 selected = selectedAgent,
670 loading = agentsLoading,
672 onSelect = { vm.selectAgent(it?.name) },
673 onRetry = { vm.reloadAgents() },
674 onDismiss = { showAgentPicker = false },
678 if (showCommandPicker) {
681 loading = commandsLoading,
682 error = commandsError,
683 onSelect = { vm.runCommand(it) },
684 onRetry = { vm.reloadCommands() },
685 onDismiss = { showCommandPicker = false },
689 pendingPermission?.let { permission ->
691 permission = permission,
692 onRespond = { response -> vm.respondPermission(permission, response) },
698private fun ChatInputBar(
700 onValueChange: (String) -> Unit,
704 sendOnEnter: Boolean,
707 queuedFollowUp: String?,
708 onQueueFollowUp: (String) -> Unit,
709 onCancelQueue: () -> Unit,
710 focusRequester: androidx.compose.ui.focus.FocusRequester,
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) {
720 .padding(start = 12.dp, top = 6.dp, end = 4.dp),
721 verticalAlignment = Alignment.CenterVertically,
723 Column(modifier = Modifier.weight(1f)) {
725 stringResource(R.string.queued),
726 style = MaterialTheme.typography.labelSmall,
727 color = MaterialTheme.colorScheme.onSurfaceVariant,
731 style = MaterialTheme.typography.bodySmall,
732 color = MaterialTheme.colorScheme.onSurfaceVariant,
734 overflow = TextOverflow.Ellipsis,
737 TextButton(onClick = onCancelQueue, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp)) {
738 Text(stringResource(R.string.cancel))
743 modifier = Modifier.fillMaxWidth().padding(8.dp),
744 verticalAlignment = Alignment.Bottom,
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))
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.
762 !enabled || value.isBlank() -> false
763 sendOnEnter -> !event.isShiftPressed
764 else -> event.isCtrlPressed
767 if (running) onQueueFollowUp(value) else onSend()
773 placeholder = { Text(stringResource(R.string.message_placeholder)) },
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.
781 val threshold = (NetworkConfig.maxDraftLengthChars * NetworkConfig.draftCountdownThresholdFraction).toInt()
782 if (value.length >= threshold) {
785 R.string.draft_chars_remaining,
786 NetworkConfig.maxDraftLengthChars - value.length,
787 NetworkConfig.maxDraftLengthChars,
789 style = MaterialTheme.typography.labelSmall,
790 color = if (value.length >= NetworkConfig.maxDraftLengthChars) MaterialTheme.colorScheme.error
791 else MaterialTheme.colorScheme.onSurfaceVariant,
795 keyboardOptions = KeyboardOptions(
796 imeAction = ImeAction.Send,
797 capitalization = KeyboardCapitalization.Sentences,
799 keyboardActions = KeyboardActions(onSend = {
800 if (enabled && value.isNotBlank()) {
801 if (running) onQueueFollowUp(value) else onSend()
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.
811 modifier = Modifier.padding(start = 4.dp).testTag("stop_button"),
814 val stopLabel = stringResource(R.string.stop)
815 CircularProgressIndicator(
816 Modifier.size(18.dp).semantics { contentDescription = stopLabel },
820 Icon(Icons.Filled.Stop, contentDescription = stringResource(R.string.stop))
826 enabled = enabled && value.isNotBlank(),
827 modifier = Modifier.padding(start = 4.dp).testTag("send_button"),
829 Icon(Icons.AutoMirrored.Filled.Send, contentDescription = stringResource(R.string.send))
837@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class)
839private fun EmptyConversation(
840 onSuggestion: (String) -> Unit,
841 modifier: Modifier = Modifier,
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 {
848 R.string.empty_chat_suggest_1,
849 R.string.empty_chat_suggest_2,
850 R.string.empty_chat_suggest_3,
854 modifier = modifier.padding(32.dp),
855 horizontalAlignment = Alignment.CenterHorizontally,
858 Icons.Filled.AutoAwesome,
859 contentDescription = null,
860 modifier = Modifier.size(48.dp),
861 tint = MaterialTheme.colorScheme.onSurfaceVariant,
863 Spacer(Modifier.size(16.dp))
865 stringResource(R.string.empty_chat_title),
866 style = MaterialTheme.typography.titleMedium,
868 Spacer(Modifier.size(8.dp))
870 stringResource(R.string.empty_chat_hint),
871 style = MaterialTheme.typography.bodyMedium,
872 color = MaterialTheme.colorScheme.onSurfaceVariant,
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.
878 modifier = Modifier.fillMaxWidth(),
879 horizontalArrangement = Arrangement.spacedBy(8.dp),
880 verticalArrangement = Arrangement.spacedBy(8.dp),
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) },
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.
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
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