@file:Suppress("TooManyFunctions") package soy.iko.opencode.ui.chat import android.app.Activity import android.content.Intent import android.net.Uri import android.speech.RecognizerIntent import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.content.contentReceiver import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.union import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.AutoAwesome import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Description import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Expand import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.PhotoCamera import androidx.compose.material.icons.filled.PhotoLibrary import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Restore import androidx.compose.material.icons.automirrored.filled.PlaylistAdd import androidx.compose.material.icons.automirrored.filled.Reply import androidx.compose.material.icons.filled.Schedule import androidx.compose.material.icons.filled.Stop import androidx.compose.material3.AlertDialog import androidx.compose.material3.Badge import androidx.compose.material3.BadgedBox import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.SmallFloatingActionButton import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SwipeToDismissBox import androidx.compose.material3.SwipeToDismissBoxValue import androidx.compose.material3.rememberSwipeToDismissBoxState import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.isCtrlPressed import androidx.compose.ui.input.key.isShiftPressed import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.invisibleToUser import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import soy.iko.opencode.data.model.Part import soy.iko.opencode.data.model.MessageWithParts import soy.iko.opencode.data.model.ReasoningPart import soy.iko.opencode.data.model.TextPart import soy.iko.opencode.data.model.ToolCompleted import soy.iko.opencode.data.model.ToolError import soy.iko.opencode.data.model.ToolPart import soy.iko.opencode.data.model.ToolRunning import soy.iko.opencode.data.model.FilePart import soy.iko.opencode.data.model.sourcePath import soy.iko.opencode.data.model.inputElement import soy.iko.opencode.data.network.EventStreamClient import soy.iko.opencode.data.network.NetworkConfig import soy.iko.opencode.di.AppContainer import soy.iko.opencode.R import soy.iko.opencode.ui.components.CommandPalette import soy.iko.opencode.ui.components.ConnectionBanner import soy.iko.opencode.ui.components.DiffView import soy.iko.opencode.ui.components.PaletteAction import soy.iko.opencode.ui.components.LocalReducedMotion import soy.iko.opencode.ui.components.LocalSearchHighlight import soy.iko.opencode.ui.components.reducedMotionAnimateItem import soy.iko.opencode.ui.components.copyToClipboard import soy.iko.opencode.ui.components.LocalRelativeTimeTick import soy.iko.opencode.ui.components.rememberRelativeTimeTick import soy.iko.opencode.ui.components.rememberVisibilityTransitions import soy.iko.opencode.ui.components.showToast import soy.iko.opencode.ui.components.toImageContext import soy.iko.opencode.ui.vmFactory import soy.iko.opencode.util.runCatchingCancellable @OptIn(ExperimentalMaterial3Api::class) @Composable fun ChatScreen( container: AppContainer, sessionId: String, onBack: () -> Unit, onOpenFile: ((String) -> Unit)? = null, onOpenSession: ((String) -> Unit)? = null, ) { val vm: ChatViewModel = viewModel(key = sessionId, factory = vmFactory { ChatViewModel(container, sessionId) }) val hasMessages by vm.hasMessages.collectAsStateWithLifecycle() val running by vm.running.collectAsStateWithLifecycle() val aborting by vm.aborting.collectAsStateWithLifecycle() val loading by vm.loading.collectAsStateWithLifecycle() val loadError by vm.loadError.collectAsStateWithLifecycle() val refreshing by vm.refreshing.collectAsStateWithLifecycle() val models by vm.models.collectAsStateWithLifecycle() val modelsLoading by vm.modelsLoading.collectAsStateWithLifecycle() val modelsError by vm.modelsError.collectAsStateWithLifecycle() val selectedModel by vm.selectedModel.collectAsStateWithLifecycle() val connectionState by vm.connectionState.collectAsStateWithLifecycle() val pendingPermission by vm.pendingPermission.collectAsStateWithLifecycle() val permissionProgress by vm.permissionProgress.collectAsStateWithLifecycle() val agents by vm.agents.collectAsStateWithLifecycle() val agentsLoading by vm.agentsLoading.collectAsStateWithLifecycle() val agentsError by vm.agentsError.collectAsStateWithLifecycle() val selectedAgent by vm.selectedAgent.collectAsStateWithLifecycle() val commands by vm.commands.collectAsStateWithLifecycle() val commandsLoading by vm.commandsLoading.collectAsStateWithLifecycle() val commandsError by vm.commandsError.collectAsStateWithLifecycle() val sessionTitle by vm.sessionTitle.collectAsStateWithLifecycle() val sessionDeleted by vm.sessionDeleted.collectAsStateWithLifecycle() val draft by vm.draft.collectAsStateWithLifecycle() val attachments by vm.attachments.collectAsStateWithLifecycle() val reverted by vm.reverted.collectAsStateWithLifecycle() val revertDiff by vm.revertDiff.collectAsStateWithLifecycle() val shareUrl by vm.shareUrl.collectAsStateWithLifecycle() val reconnecting by vm.reconnecting.collectAsStateWithLifecycle() val sendOnEnter by container.settingsStore.sendOnEnter.collectAsStateWithLifecycle(initialValue = true) val preferredModelId by container.settingsStore.preferredModelId.collectAsStateWithLifecycle(initialValue = "") val recentModelEntries by container.recentModelsStore.entries.collectAsStateWithLifecycle() val compactSpacing by container.settingsStore.compactMessageSpacing.collectAsStateWithLifecycle(initialValue = false) val isOnline by container.isOnline.collectAsStateWithLifecycle() val haptics = LocalHapticFeedback.current val scope = rememberCoroutineScope() val shareContext = LocalContext.current val defaultShareSubject = stringResource(R.string.share_subject) val sessionLabel = stringResource(R.string.session) val defaultModelLabel = stringResource(R.string.default_model) val subtitle = remember(selectedModel?.modelLabel, selectedAgent, defaultModelLabel) { buildString { append(selectedModel?.modelLabel ?: defaultModelLabel) selectedAgent?.let { append(" · $it") } } } // Connection profile (with auth) for rendering inline image attachments. // Collect as state so a server switch recomposes the image context. val activeConnection by container.activeConnection.collectAsStateWithLifecycle() val imageContext = remember(activeConnection?.profile) { activeConnection?.profile?.toImageContext() } // Mark this session as read while the user is viewing it; clear on leave so new // background activity can badge it again. DisposableEffect(sessionId) { container.setCurrentSession(sessionId) onDispose { if (container.currentSession.value == sessionId) container.setCurrentSession(null) } } // Navigate away when the session is deleted via SSE so the user isn't left // on a zombie screen showing a conversation that no longer exists. Surface a // toast (not a snackbar) because the screen is about to be popped — a snackbar // would be torn down before the user could read it, while a toast survives. // Covers both SSE-driven deletion (deleted on another device) and a user- // initiated delete from the chat overflow menu (deleteSession in ChatViewModel // sets the same flag on success). val deletedContext = LocalContext.current LaunchedEffect(sessionDeleted) { if (sessionDeleted) { showToast(deletedContext, deletedContext.getString(R.string.session_deleted_chat)) onBack() } } val snackbar = remember { SnackbarHostState() } val retryLabel = stringResource(R.string.retry) val inputFocusRequester = remember { androidx.compose.ui.focus.FocusRequester() } // Dedicated focus requester for the in-conversation search field. The overflow menu's // "Find in conversation" and Ctrl+F previously called inputFocusRequester.requestFocus() // (the composer), so typing went to the composer instead of the search field. val searchFocusRequester = remember { androidx.compose.ui.focus.FocusRequester() } var showModelPicker by rememberSaveable { mutableStateOf(false) } var showAgentPicker by rememberSaveable { mutableStateOf(false) } var showTitleMenu by rememberSaveable { mutableStateOf(false) } var showCommandPicker by rememberSaveable { mutableStateOf(false) } var showExitConfirm by rememberSaveable { mutableStateOf(false) } var showStopConfirm by rememberSaveable { mutableStateOf(false) } var showOverflowMenu by rememberSaveable { mutableStateOf(false) } var showRenameDialog by rememberSaveable { mutableStateOf(false) } var showDeleteDialog by rememberSaveable { mutableStateOf(false) } var showShellDialog by rememberSaveable { mutableStateOf(false) } var showPalette by rememberSaveable { mutableStateOf(false) } var showShortcutsDialog by rememberSaveable { mutableStateOf(false) } var searchActive by rememberSaveable { mutableStateOf(false) } var chatSearch by rememberSaveable { mutableStateOf("") } // Index of the currently-focused in-conversation search match (into the filtered list). // Drives the up/down navigation: the search bar's arrows step through matches and scroll // the list to each one, mirroring desktop chat apps (the bare filtered list previously // required reading each message to find the term). var searchPos by rememberSaveable { mutableIntStateOf(0) } // Summarize and init (generate AGENTS.md) are irreversible, so a single tap is gated behind // a confirmation dialog (from both the overflow menu and the command palette). var showSummarizeConfirm by rememberSaveable { mutableStateOf(false) } var showInitConfirm by rememberSaveable { mutableStateOf(false) } // Command-palette entries (keyboard-first: Ctrl+K). Labels resolved here so the remembered // action list doesn't need a Composable context. Actions map to the same handlers the // overflow menu drives, so the palette is a keyboard shortcut to them, not a parallel path. val paletteModelLabel = stringResource(R.string.palette_switch_model) val paletteAgentLabel = stringResource(R.string.palette_choose_agent) val paletteCommandLabel = stringResource(R.string.palette_run_command) val paletteSummarizeLabel = stringResource(R.string.palette_summarize) val paletteRenameLabel = stringResource(R.string.rename_session_chat) val paletteShellLabel = stringResource(R.string.run_shell_command) val paletteActions = remember( paletteModelLabel, paletteAgentLabel, paletteCommandLabel, paletteSummarizeLabel, paletteRenameLabel, paletteShellLabel, hasMessages, running, ) { buildList { add(PaletteAction("model", paletteModelLabel) { showModelPicker = true }) add(PaletteAction("agent", paletteAgentLabel) { showAgentPicker = true }) add(PaletteAction("command", paletteCommandLabel) { showCommandPicker = true }) if (hasMessages && !running) add(PaletteAction("summarize", paletteSummarizeLabel) { showSummarizeConfirm = true }) if (!running) add(PaletteAction("shell", paletteShellLabel) { showShellDialog = true }) add(PaletteAction("rename", paletteRenameLabel) { showRenameDialog = true }) } } // Keep the screen awake while the agent is working in *this* session. The foreground // service that holds process priority during a run is managed app-wide off // container.anyRunActive (see OpencodeApp) so it survives navigating away from the chat // mid-run — otherwise leaving the screen would drop priority and Doze could choke the // SSE stream before the completion notification fires. val currentView = LocalView.current val appContext = LocalContext.current.applicationContext DisposableEffect(running) { currentView.keepScreenOn = running onDispose { currentView.keepScreenOn = false } } LaunchedEffect(Unit) { vm.errorEvents.collect { event -> // Only a failed *send* is retryable; attaching Retry to any other error (e.g. a // message-load failure) would silently re-submit the last prompt on tap. val result = if (event.retryable) { snackbar.showSnackbar(message = event.message, actionLabel = retryLabel) } else { snackbar.showSnackbar(event.message) } if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) vm.retryFailed() } } // In-place Undo for a user-initiated session delete. The delete is deferred for an undo // window on the container; show the snackbar over the conversation the user is viewing so // they can cancel without being navigated to the session list first. If the window expires // the scheduled delete sets `sessionDeleted` and the effect below navigates away. val undoLabel = stringResource(R.string.undo) val sessionDeletedLabel = stringResource(R.string.session_deleted_chat) LaunchedEffect(Unit) { vm.deleteUndoEvents.collect { coroutineScope { val dismisser = launch { delay(NetworkConfig.undoDeleteDelayMs) snackbar.currentSnackbarData?.dismiss() } val result = snackbar.showSnackbar( message = sessionDeletedLabel, actionLabel = undoLabel, duration = SnackbarDuration.Indefinite, ) dismisser.cancel() if (result == SnackbarResult.ActionPerformed) vm.cancelSessionDelete() } } } // --- Attachments, voice, and share-link plumbing --- val attachTooLargeMsg = stringResource(R.string.attachment_too_large) val attachFailedMsg = stringResource(R.string.attachment_failed) val attachLimitMsg = stringResource(R.string.attachment_limit) val noCameraMsg = stringResource(R.string.no_camera_app) val noVoiceMsg = stringResource(R.string.no_voice_app) val voicePrompt = stringResource(R.string.voice_prompt) val linkCopiedMsg = stringResource(R.string.link_copied) val ttsUnavailableMsg = stringResource(R.string.tts_unavailable) // Count of picks currently being read + base64-encoded off the main thread, so the // composer can show a staging placeholder immediately (chips only materialize once done). var stagingCount by remember { mutableStateOf(0) } // Total number of files still being staged across all in-flight batches, so the // staging chip can show "Staging N files…" rather than a bare indeterminate spinner. // A separate counter from [stagingCount] (which tracks batches, not files) so a // multi-file pick reads as more work than a single-file pick. var stagingFileTotal by remember { mutableStateOf(0) } // Convert each picked Uri to a base64 attachment off the main thread, honoring the // per-prompt count cap and surfacing per-file errors without aborting the batch. fun stageUris(uris: List) { if (uris.isEmpty()) return scope.launch { stagingCount++ stagingFileTotal += uris.size try { uris.forEachIndexed { index, uri -> if (vm.attachments.value.size >= NetworkConfig.maxAttachments) { snackbar.showSnackbar(attachLimitMsg) // The cap dropped the rest of this batch; account for the files we // won't process so stagingFileTotal doesn't linger over-counted. stagingFileTotal -= uris.size - index return@forEachIndexed } when (val result = uri.toAttachmentResult(appContext)) { is AttachmentResult.Ok -> vm.addAttachment(result.attachment) AttachmentResult.TooLarge -> snackbar.showSnackbar(attachTooLargeMsg) AttachmentResult.Failed -> snackbar.showSnackbar(attachFailedMsg) } stagingFileTotal-- } } finally { stagingCount-- } } } val photoPicker = rememberLauncherForActivityResult( ActivityResultContracts.PickMultipleVisualMedia(NetworkConfig.maxAttachments), ) { uris -> stageUris(uris) } val filePicker = rememberLauncherForActivityResult( ActivityResultContracts.GetMultipleContents(), ) { uris -> stageUris(uris) } // Uri survives a config change mid-capture so the TakePicture result still resolves. var pendingCaptureUri by rememberSaveable { mutableStateOf(null) } val cameraLauncher = rememberLauncherForActivityResult( ActivityResultContracts.TakePicture(), ) { success -> val uri = pendingCaptureUri pendingCaptureUri = null if (success && uri != null) stageUris(listOf(uri)) } val voiceLauncher = rememberLauncherForActivityResult( ActivityResultContracts.StartActivityForResult(), ) { result -> if (result.resultCode == Activity.RESULT_OK) { val spoken = result.data ?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS) ?.firstOrNull() ?.takeIf { it.isNotBlank() } if (spoken != null) { // Append to the existing draft rather than replacing, so dictation adds to // whatever the user already typed. val current = vm.draft.value val combined = if (current.isBlank()) spoken else "$current $spoken" vm.updateDraft(combined.take(NetworkConfig.maxDraftLengthChars)) } } } fun launchCamera() { val uri = newCameraCaptureUri(appContext) if (uri == null) { scope.launch { snackbar.showSnackbar(attachFailedMsg) } return } pendingCaptureUri = uri runCatching { cameraLauncher.launch(uri) } .onFailure { pendingCaptureUri = null; scope.launch { snackbar.showSnackbar(noCameraMsg) } } } fun launchVoice() { val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply { putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM) putExtra(RecognizerIntent.EXTRA_PROMPT, voicePrompt) } runCatching { voiceLauncher.launch(intent) } .onFailure { scope.launch { snackbar.showSnackbar(noVoiceMsg) } } } // A freshly-created share link is copied to the clipboard so the user can paste it right away. LaunchedEffect(Unit) { vm.shareLinkEvents.collect { url -> copyToClipboard(shareContext, shareContext.getString(R.string.clip_label_share), url) snackbar.showSnackbar(linkCopiedMsg) } } // Navigate to a session freshly branched from a message (branchFrom), once it's created. // Keyed on Unit (like the sibling one-shot collectors above): keying on the onOpenSession // lambda would cancel/restart this collector whenever the lambda's identity changes, risking // a dropped branch-navigation event in the gap. val branchedMsg = stringResource(R.string.branch_created) LaunchedEffect(Unit) { vm.branchEvents.collect { newId -> showToast(shareContext, branchedMsg) onOpenSession?.invoke(newId) } } // Stage images shared into the app (via the system share sheet) as attachments. // Keyed on pendingSharedMedia (not Unit) so the effect re-fires when new media arrives // while this ChatScreen is already composed — which happens in two-pane mode, where the // detail ChatScreen is persistent (keyed on sessionId) and a LaunchedEffect(Unit) would // only fire on first composition. consumePendingSharedMedia() atomically drains the // pending list (compareAndSet to empty), so re-firing on the now-empty value is a no-op. val pendingSharedMedia by container.pendingSharedMedia.collectAsStateWithLifecycle() LaunchedEffect(pendingSharedMedia) { val shared = container.consumePendingSharedMedia() if (shared.isNotEmpty()) stageUris(shared.map { Uri.parse(it) }) } fun doSend() { if (vm.send(draft)) { // TextHandleMove (a light tick) is semantically right for a send commit; // LongPress is reserved for long-press/swipe gestures. haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove) } } BackHandler(enabled = running && !showModelPicker && !showAgentPicker && !showCommandPicker) { showExitConfirm = true } Scaffold( // Hardware-keyboard shortcuts (tablets / DeX / Chromebooks): Ctrl+K opens the command // palette; Escape closes it, or stops a run. onPreviewKeyEvent on the Scaffold root sees // events before the focused text field, but only Ctrl+K / Escape are consumed so normal // typing (and the composer's own Enter-to-send handling) is untouched. modifier = Modifier.onPreviewKeyEvent { ev -> if (ev.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false when { ev.isCtrlPressed && ev.key == Key.K -> { showPalette = true; true } // Ctrl+F opens in-conversation search (the standard find shortcut), focusing // the field so the user can type immediately. ev.isCtrlPressed && ev.key == Key.F -> { searchActive = true; true } ev.key == Key.Escape && showPalette -> { showPalette = false; true } ev.key == Key.Escape && searchActive -> { searchActive = false; chatSearch = ""; true } ev.key == Key.Escape && running && !showStopConfirm && !showExitConfirm -> { showStopConfirm = true; true } else -> false } }, topBar = { TopAppBar( title = { val changeLabel = stringResource(R.string.change_model_agent) // The subtitle reads "model · agent", so tapping the title offers *both* // — a small menu picks which picker to open. Previously the tap went // straight to the model picker, leaving the agent changeable only via // the overflow menu despite the title implying otherwise. Box { Row( // mergeDescendants so TalkBack reads the title/subtitle Texts as one // node; the click label rides on `clickable` (onClickLabel) instead of // an explicit contentDescription, which would have SUPPRESSED the child // Texts and announced only "Change model and agent". modifier = Modifier .clickable( role = Role.Button, onClickLabel = changeLabel, ) { showTitleMenu = true } .semantics(mergeDescendants = true) {}, verticalAlignment = Alignment.CenterVertically, ) { Column(modifier = Modifier.weight(1f, fill = false)) { // maxLines=1 + Ellipsis so a long session title can't wrap and // push the top bar to multiple lines, shifting the whole layout. Text( sessionTitle ?: sessionLabel, maxLines = 1, overflow = TextOverflow.Ellipsis, ) Text( subtitle, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } // Always show the affordance so the title reads as tappable even when // the model catalog failed to load (models empty). Without it the title // is indistinguishable from plain text and the pickers are effectively // undiscoverable — and the dropdown items already disable themselves // while a catalog is loading or unavailable. Icon( Icons.Filled.KeyboardArrowDown, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(20.dp), ) } DropdownMenu(expanded = showTitleMenu, onDismissRequest = { showTitleMenu = false }) { DropdownMenuItem( text = { Text(stringResource(R.string.palette_switch_model)) }, enabled = !modelsLoading || models.isNotEmpty(), onClick = { showTitleMenu = false; showModelPicker = true }, ) DropdownMenuItem( text = { Text(stringResource(R.string.palette_choose_agent)) }, enabled = !agentsLoading || agents.isNotEmpty(), onClick = { showTitleMenu = false; showAgentPicker = true }, ) } } }, navigationIcon = { IconButton(onClick = { if (running) showExitConfirm = true else onBack() }) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) } }, actions = { // At-a-glance run status in the top bar. When the user is scrolled // up (jump-to-latest FAB visible), the input-bar Stop button and the // trailing "working…" row are off-screen, so a top-bar spinner is the // only signal the agent is still working. if (running) { val workingLabel = stringResource(R.string.working) CircularProgressIndicator( Modifier.size(18.dp).semantics { contentDescription = workingLabel }, strokeWidth = 2.dp, ) } // Manual refresh: forces an SSE reconnect, which re-seeds messages // from REST. A recovery path when the stream silently drops and the // auto-reconnect re-seed is slow or fails. Shows a brief spinner as // immediate tap feedback — the SSE reconnect may not visibly change // the connection state when already Connected, so without it the tap // appears to do nothing. val refreshLabel = stringResource(R.string.refresh) IconButton( onClick = { vm.refreshMessages() }, enabled = activeConnection != null && !refreshing, ) { if (refreshing) { CircularProgressIndicator( Modifier.size(18.dp).semantics { contentDescription = refreshLabel }, strokeWidth = 2.dp, ) } else { Icon(Icons.Filled.Refresh, contentDescription = refreshLabel) } } // Overflow menu consolidates the less-frequent actions (share, // commands, agent, model, rename, delete) so the top bar stays // scannable on narrow phones — previously 5 icons crowded the bar // alongside the back button and tappable title. Catalog-loading // spinners stay inline (next to the overflow) so a load state is // visible without opening the menu. val moreLabel = stringResource(R.string.more) val loadingLabel = stringResource(R.string.loading) val shareLabel = stringResource(R.string.share_conversation) val copyAsMarkdownLabel = stringResource(R.string.copy_as_markdown) val commandsLabel = stringResource(R.string.commands) val renameLabel = stringResource(R.string.rename_session_chat) val deleteLabel = stringResource(R.string.delete_session_chat) val createShareLabel = stringResource(R.string.create_share_link) val copyShareLabel = stringResource(R.string.copy_share_link) val stopShareLabel = stringResource(R.string.stop_sharing) val summarizeLabel = stringResource(R.string.summarize_conversation) val initLabel = stringResource(R.string.generate_agents_md) val shellLabel = stringResource(R.string.run_shell_command) Box { IconButton(onClick = { showOverflowMenu = true }) { Icon(Icons.Filled.MoreVert, contentDescription = moreLabel) } DropdownMenu( expanded = showOverflowMenu, onDismissRequest = { showOverflowMenu = false }, ) { DropdownMenuItem( text = { Text(shareLabel) }, enabled = hasMessages, onClick = { showOverflowMenu = false scope.launch { val md = withContext(Dispatchers.Default) { buildConversationMarkdown(vm.messages.value, sessionTitle) } val send = android.content.Intent(android.content.Intent.ACTION_SEND).apply { type = "text/markdown" putExtra(android.content.Intent.EXTRA_SUBJECT, sessionTitle ?: defaultShareSubject) putExtra(android.content.Intent.EXTRA_TEXT, md) } runCatchingCancellable { shareContext.startActivity(android.content.Intent.createChooser(send, shareLabel)) } .onFailure { showToast(shareContext, shareContext.getString(R.string.no_share_app)) } } }, ) // Copy the whole conversation as Markdown to the clipboard, so a // user can paste into another app without the share-sheet round-trip. DropdownMenuItem( text = { Text(copyAsMarkdownLabel) }, enabled = hasMessages, onClick = { showOverflowMenu = false scope.launch { val md = withContext(Dispatchers.Default) { buildConversationMarkdown(vm.messages.value, sessionTitle) } copyToClipboard(shareContext, shareContext.getString(R.string.clip_label_message), md) } }, ) DropdownMenuItem( text = { Text(commandsLabel) }, enabled = !commandsLoading || commands.isNotEmpty(), onClick = { showOverflowMenu = false; showCommandPicker = true }, ) DropdownMenuItem( text = { Text(stringResource(R.string.keyboard_shortcuts)) }, onClick = { showOverflowMenu = false; showShortcutsDialog = true }, ) DropdownMenuItem( text = { Text(stringResource(R.string.find_in_conversation)) }, onClick = { showOverflowMenu = false searchActive = true // Focus is requested by the LaunchedEffect(searchActive) // below, which waits for the search field to be composed // before requesting focus (calling requestFocus here would // target a field that isn't in the composition yet). }, ) androidx.compose.material3.HorizontalDivider() // Session sharing: create a public link, copy an existing one, or revoke it. if (shareUrl == null) { DropdownMenuItem( text = { Text(createShareLabel) }, enabled = hasMessages, onClick = { showOverflowMenu = false; vm.shareSession() }, ) } else { DropdownMenuItem( text = { Text(copyShareLabel) }, onClick = { showOverflowMenu = false shareUrl?.let { copyToClipboard(shareContext, shareContext.getString(R.string.clip_label_share), it) scope.launch { snackbar.showSnackbar(linkCopiedMsg) } } }, ) DropdownMenuItem( text = { Text(stopShareLabel) }, onClick = { showOverflowMenu = false; vm.unshareSession() }, ) } DropdownMenuItem( text = { Text(summarizeLabel) }, enabled = hasMessages && !running, onClick = { showOverflowMenu = false; showSummarizeConfirm = true }, ) DropdownMenuItem( text = { Text(initLabel) }, enabled = !running, onClick = { showOverflowMenu = false; showInitConfirm = true }, ) DropdownMenuItem( text = { Text(shellLabel) }, enabled = !running, onClick = { showOverflowMenu = false; showShellDialog = true }, ) androidx.compose.material3.HorizontalDivider() DropdownMenuItem( text = { Text(renameLabel) }, onClick = { showOverflowMenu = false; showRenameDialog = true }, ) DropdownMenuItem( text = { Text(deleteLabel, color = MaterialTheme.colorScheme.error) }, onClick = { showOverflowMenu = false; showDeleteDialog = true }, ) } // While a catalog is initially loading (empty list + loading flag), // show a tiny inline spinner beside the overflow so the user can see // a load is in progress without opening the menu. Once loaded the // spinner disappears; the menu items are always enabled (tapping a // picker with no items shows the "no models/agents/commands" state). val catalogLoading = (commandsLoading && commands.isEmpty()) || (agentsLoading && agents.isEmpty()) || (modelsLoading && models.isEmpty()) if (catalogLoading) { CircularProgressIndicator( Modifier .align(Alignment.TopEnd) .size(10.dp) .semantics { contentDescription = loadingLabel }, strokeWidth = 1.dp, ) } } }, ) }, snackbarHost = { SnackbarHost(snackbar) }, bottomBar = { val queuedFollowUp by vm.queuedFollowUp.collectAsStateWithLifecycle() val todoPlan by vm.todoPlan.collectAsStateWithLifecycle() val queuedOffline by vm.outbox.collectAsStateWithLifecycle() val outboxSending by container.outboxSending.collectAsStateWithLifecycle() Column { // Pinned above the composer so the agent's plan and progress stay visible // while scrolled up mid-run; renders nothing when there's no plan. TodoPlanBar(todoPlan) val bannerMotion = rememberVisibilityTransitions() AnimatedVisibility( visible = reverted, enter = bannerMotion.enter, exit = bannerMotion.exit, ) { RevertBanner( diff = revertDiff, onUndo = { vm.unrevert() }, ) } AnimatedVisibility( visible = queuedOffline.isNotEmpty(), enter = bannerMotion.enter, exit = bannerMotion.exit, ) { OutboxBanner( count = queuedOffline.size, sending = outboxSending, onFlush = { vm.flushQueued() }, onDiscard = { vm.discardAllQueued() }, ) } ChatInputBar( value = draft, onValueChange = vm::updateDraft, running = running, aborting = aborting, enabled = activeConnection != null, sendOnEnter = sendOnEnter, onSend = ::doSend, onAbort = { showStopConfirm = true }, queuedFollowUp = queuedFollowUp, onQueueFollowUp = vm::queueFollowUp, onCancelQueue = { vm.queueFollowUp("") }, focusRequester = inputFocusRequester, attachments = attachments, staging = stagingCount > 0, stagingFileCount = stagingFileTotal, onRemoveAttachment = vm::removeAttachment, onPickPhoto = { photoPicker.launch( PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly), ) }, onPickFile = { filePicker.launch("*/*") }, onCamera = { launchCamera() }, onVoice = { launchVoice() }, onPasteImage = { stageUris(it) }, ) } }, ) { padding -> // Collect messages inside the content lambda so streaming token updates // recompose only this subtree, not the top bar / input bar / 20+ other // state reads in the ChatScreen body. val messages by vm.messages.collectAsStateWithLifecycle() val optimisticStatuses by vm.optimisticStatuses.collectAsStateWithLifecycle() val listState = rememberLazyListState() val contentScope = rememberCoroutineScope() val timeTick = rememberRelativeTimeTick() // Dismiss the soft keyboard when the user scrolls the message list, a standard Android // chat affordance for reading while the IME is open. Watching isScrollInProgress via // snapshotFlow avoids wiring a nested scroll connection and fires on any scroll gesture. val keyboardController = androidx.compose.ui.platform.LocalSoftwareKeyboardController.current androidx.compose.runtime.LaunchedEffect(listState) { androidx.compose.runtime.snapshotFlow { listState.isScrollInProgress } .distinctUntilChanged() .collect { scrolling -> if (scrolling) keyboardController?.hide() } } // Group messages by day and interleave date separators so a long conversation // has visual "Today"/"Yesterday"/date breaks. Computed once per messages // emission (memoized) so a scroll-induced recomposition doesn't re-scan. Lives // outside the PullToRefreshBox so the auto-scroll effects below can reference // the same list the LazyColumn renders (separators shift the indices vs. the // raw messages list, so scroll targets must be in listItems space). val todayLabel = stringResource(R.string.today) val yesterdayLabel = stringResource(R.string.yesterday) // In-conversation search: when active, narrow to messages whose text contains the query. // Computed on the filtered set so day separators still align with the visible messages. val searchQuery = chatSearch.trim() val searchMessages = remember(messages, searchQuery) { if (searchQuery.isEmpty()) messages else messages.filter { m -> m.parts.any { p -> when (p) { is TextPart -> p.text.contains(searchQuery, ignoreCase = true) is ReasoningPart -> p.text.contains(searchQuery, ignoreCase = true) is ToolPart -> { // Match the tool name plus its running/completed/error text and the // stringified input, so searching for an error string or a file the // tool touched actually finds the message it appeared in. p.tool.contains(searchQuery, ignoreCase = true) || (p.state is ToolRunning && p.state.title?.contains(searchQuery, ignoreCase = true) == true) || (p.state is ToolCompleted && (p.state.title?.contains(searchQuery, ignoreCase = true) == true || p.state.output?.contains(searchQuery, ignoreCase = true) == true)) || (p.state is ToolError && p.state.error?.contains(searchQuery, ignoreCase = true) == true) || p.state.inputElement()?.toString()?.contains(searchQuery, ignoreCase = true) == true } is FilePart -> p.filename?.contains(searchQuery, ignoreCase = true) == true || p.sourcePath?.contains(searchQuery, ignoreCase = true) == true else -> false } } } } // Keep the focused-match index in range as the query (and thus the match set) changes. // A new query resets to the first match; a shrunk set clamps to the last valid index. LaunchedEffect(searchQuery, searchMessages.size) { if (searchPos >= searchMessages.size) searchPos = 0 } val listItems = remember(searchMessages, todayLabel, yesterdayLabel) { buildMessageListItems(searchMessages, todayLabel, yesterdayLabel) } // listItems is a plain (non-snapshot) local, rebuilt each recomposition. The // derivedStateOf and snapshotFlow lambdas below are created once (keyless // remember / LaunchedEffect(Unit)); reading `listItems` directly inside them // would capture the first composition's list — which is empty, since `messages` // starts empty — freezing it forever. That pinned isPinnedToBottom at true and // AutoScrollSignal.size at 0, breaking streaming auto-scroll and the // jump-to-latest FAB. rememberUpdatedState hands those lambdas a stable State // whose value tracks the latest list. val currentListItems by rememberUpdatedState(listItems) // New-content signal for the jump-to-latest FAB: counts how many new items arrived // while the user was scrolled away from the bottom, cleared once they return. A count // (vs. a bare dot) tells the user how much they missed at a glance. var newContentCount by remember { mutableIntStateOf(0) } val isPinnedToBottom by remember { derivedStateOf { val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 // When a run is active, the LazyColumn has one trailing "__typing" // item after the last list item. Account for it so "pinned to bottom" // includes the working indicator — otherwise the pin check targets // listItems.lastIndex, the typing row sits just below the viewport, // and the user never sees the progress indicator even when pinned. val items = currentListItems val effectiveLast = if (running) items.size else items.lastIndex lastVisible >= effectiveLast || items.isEmpty() } } // Jump to the newest message once, when the conversation first loads. Guarded by a // saveable one-shot flag so it doesn't re-fire on recomposition after a config change // (rotation) — rememberLazyListState restores the user's scroll offset, and an // unconditional scrollToItem here would clobber it, snapping them back to the bottom. var didInitialScroll by rememberSaveable { mutableStateOf(false) } LaunchedEffect(listItems.isNotEmpty()) { if (listItems.isNotEmpty() && !didInitialScroll) { // When a run is active the LazyColumn has a trailing "__typing" row at // listItems.size; target it (not listItems.lastIndex) so deep-linking into a // running session lands truly pinned to bottom and streaming auto-scroll engages. listState.scrollToItem(if (running) listItems.size else listItems.lastIndex) didInitialScroll = true } } // When a run starts, the LazyColumn gains a trailing "__typing" row, so // effectiveLast jumps from listItems.lastIndex to listItems.size and the // isPinnedToBottom check flips to false the instant you send — freezing // auto-scroll. If the user was pinned to the last item just before the run // began, bring the typing row into view so the pin (and streaming follow) is // preserved. Guard on the raw last-visible index (isPinnedToBottom has already // recomputed to false by now) so we don't scroll when the user had scrolled up. LaunchedEffect(running) { if (running && listItems.isNotEmpty()) { val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 if (lastVisible >= listItems.lastIndex) { runCatchingCancellable { listState.animateScrollToItem(listItems.size) } } } } // Scroll the focused search match into view whenever the user steps through results with // the up/down arrows. Resolves the filtered message to its slot in listItems (which // includes date separators) and animates there. LaunchedEffect(searchPos, searchQuery, listItems) { if (!searchActive || searchQuery.isEmpty()) return@LaunchedEffect val target = searchMessages.getOrNull(searchPos) ?: return@LaunchedEffect val index = listItems.indexOfFirst { it is MessageListItem.Message && it.message.info.id == target.info.id } if (index >= 0) runCatchingCancellable { listState.animateScrollToItem(index) } } // Scroll to bottom when the IME opens and the user was already pinned to bottom, so the // latest reply stays visible as the composer rises. Without this the IME can obscure the // last message even though the user was reading it a moment ago. WindowInsets.ime is a // @Composable property, so we read it inside a snapshotFlow registered in the composable // scope; the flow re-emits whenever the IME inset changes (open/close transitions). val density = androidx.compose.ui.platform.LocalDensity.current var wasImeOpen by remember { mutableStateOf(false) } val imeBottomPx = WindowInsets.ime.getBottom(density) androidx.compose.runtime.LaunchedEffect(imeBottomPx, listItems.isNotEmpty(), isPinnedToBottom) { if (listItems.isEmpty()) return@LaunchedEffect val imeOpen = imeBottomPx > 0 // Scroll to bottom only on the open transition (not close) and only when the user // was already pinned to bottom — otherwise we'd yank them out of a mid-history read. val shouldScroll = imeOpen && !wasImeOpen && isPinnedToBottom if (shouldScroll) { runCatchingCancellable { listState.animateScrollToItem(if (running) listItems.size else listItems.lastIndex) } } wasImeOpen = imeOpen } LaunchedEffect(Unit) { var prevSize = 0 var prevLen = 0 snapshotFlow { // Track list size + the streaming length of the last part so we auto-scroll // as content arrives, plus the pinned flag. A small data class with primitive // fields avoids the per-frame boxing that Triple pays // (snapshotFlow re-evaluates this lambda every snapshot), and reading only the // last part's length is O(1) vs. a sumOf over every part. Covering reasoning // and tool output — not just TextPart — keeps a pinned view following a long // reasoning/tool block while it streams (previously it stalled at 0). val lastLen = streamingContentLength(messages.lastOrNull()?.parts?.lastOrNull()) AutoScrollSignal(currentListItems.size, lastLen, isPinnedToBottom) }.collect { signal -> if (signal.pinned) { // Back at the bottom: clear the new-content badge. newContentCount = 0 if (signal.size > 0) { // Scroll to the effective last index, including the trailing // "__typing" row when a run is active so the working indicator // is brought into view (not just the last message). val items = currentListItems val target = if (running) items.size else items.lastIndex listState.scrollToItem(target) } } else if (signal.size > prevSize || signal.lastTextLength > prevLen) { // Content arrived while scrolled up — increment the jump-to-latest badge. // Only count whole new items (not per-token growth) so the number reflects // new messages, not a token count. if (signal.size > prevSize) newContentCount += signal.size - prevSize else if (newContentCount == 0) newContentCount = 1 } prevSize = signal.size prevLen = signal.lastTextLength } } CompositionLocalProvider(LocalRelativeTimeTick provides timeTick) { // The bottomBar (ChatInputBar) already has imePadding, and the Scaffold's // content padding accounts for the bottomBar's raised height, so the message // list is already above the keyboard. Adding imePadding here would double-apply // the IME inset and push messages too far up. Box(modifier = Modifier.fillMaxSize().padding(padding)) { if (activeConnection == null) { Column( modifier = Modifier.align(Alignment.Center).padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { Text( stringResource(R.string.not_connected), color = MaterialTheme.colorScheme.error, ) Spacer(Modifier.size(12.dp)) Button(onClick = { vm.reconnect() }, enabled = !reconnecting) { if (reconnecting) { CircularProgressIndicator( Modifier.size(20.dp), color = MaterialTheme.colorScheme.onPrimary, strokeWidth = 2.dp, ) } else { Text(stringResource(R.string.reconnect)) } } } } else { val bannerVisible = !isOnline || connectionState != EventStreamClient.ConnectionState.Connected // Measure the banner's actual height instead of hardcoding 44dp. At large // accessibility font scales the banner's labelMedium text + spinner + Retry // button exceed 44dp, so a fixed inset lets the first message hide behind it // (the same hazard FileViewScreen's overlay calls out). onSizeChanged only // fires while the banner is composed (visible), so gate the inset on // bannerVisible and fall back to 44dp until the first measurement arrives. var bannerHeightPx by remember { mutableIntStateOf(0) } val density = LocalDensity.current val topPad = if (bannerVisible) { if (bannerHeightPx > 0) { with(density) { bannerHeightPx.toDp() } + 4.dp } else { 44.dp } } else { 16.dp } ConnectionBanner( state = connectionState, modifier = Modifier.align(Alignment.TopCenter).onSizeChanged { bannerHeightPx = it.height }, isOnline = isOnline, // On a hard failure, retry by forcing an SSE reconnect (which // re-seeds from REST). refreshMessages is the right recovery path // when the connection is present but the stream died; reconnect() // is the path when the whole connection is gone (handled by the // separate "Not connected" state below). onRetry = { vm.refreshMessages() }, ) // Pull-to-refresh wraps ALL content states (loading, error, empty, list) so // recovery is a swipe away everywhere — not just when the message list is // populated. Previously PTR was only around the populated list, leaving the // error/loading states reachable only via a button tap. PullToRefreshBox( isRefreshing = refreshing, onRefresh = { vm.refreshMessages() }, // Cap width and center on large screens for readability — full-width // bubbles stretch edge-to-edge on tablets, hurting line-length comfort. modifier = Modifier.fillMaxSize().wrapContentWidth(Alignment.CenterHorizontally) .widthIn(max = NetworkConfig.chatContentMaxWidthDp.dp), ) { // Reserve top space when the banner is visible so it doesn't overlap the // first message / state content. (topPad computed above.) if (loading && messages.isEmpty()) { // Skeleton bubbles pre-structure the layout so the perceived load is faster // than a bare centered spinner and the first real messages pop into an // already-shaped list instead of a blank-then-pop. ChatSkeleton(topPad = topPad) } else if (loadError && messages.isEmpty()) { // A failed load with nothing to show. Distinct from the empty // conversation state below — the conversation isn't empty, it // failed to load, and offering "Start a conversation" here is // misleading. Offer a Retry instead, mirroring SessionListScreen. Column( modifier = Modifier.align(Alignment.Center).padding(top = topPad).padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { Text( stringResource(R.string.load_messages_failed), color = MaterialTheme.colorScheme.error, ) Spacer(Modifier.size(12.dp)) Button(onClick = { vm.refreshMessages() }) { Text(stringResource(R.string.retry)) } } } else if (messages.isEmpty() && running) { // A run just started but no parts have arrived yet — the empty list with // only the trailing "working…" row looks broken. Reuse the skeleton bubbles // from the initial-load branch so the perceived start latency is lower and // the first real assistant part pops into an already-shaped list. ChatSkeleton(topPad = topPad) } else if (messages.isEmpty()) { EmptyConversation( onSuggestion = { vm.updateDraft(it) // Focus the input so the user can edit the suggestion before // sending, instead of having to manually tap the field. runCatching { inputFocusRequester.requestFocus() } }, modifier = Modifier.align(Alignment.Center).padding(top = topPad), ) } else { val lastMessageId = messages.lastOrNull()?.info?.id // Text-to-speech for reading assistant replies aloud; shuts down when the // message list leaves composition. val tts = rememberTtsController() val speakingMessageId by tts.speakingId val ttsState by tts.state // Provide the in-conversation search query to the markdown renderer so matching // text spans are highlighted in-place (in addition to the focused-row highlight // above). Scoped to the list so cleared search stops highlighting immediately. val activeSearchQuery = searchQuery.takeIf { searchActive && it.isNotEmpty() } CompositionLocalProvider(LocalSearchHighlight provides activeSearchQuery) { LazyColumn( state = listState, modifier = Modifier.fillMaxSize(), // Extra bottom padding reserves room for the jump-to-latest FAB so it // never floats over the last message. Top padding grows when the // connection banner is visible so it doesn't overlap the first message. contentPadding = PaddingValues( start = 16.dp, top = topPad, end = 16.dp, bottom = 16.dp + NetworkConfig.chatListFabInsetDp.dp, ), // Compact spacing (8dp) vs the default 16dp — a power-user toggle to fit // more conversation on screen without scrolling as much. verticalArrangement = Arrangement.spacedBy(if (compactSpacing) 8.dp else 16.dp), ) { items( items = listItems, key = { it.key }, contentType = { it.contentType }, ) { item -> // When in-conversation search is stepping through matches, highlight the // focused one's row so the user can spot it at a glance among the filtered // results (full in-text term highlighting would require threading the query // through the markdown renderer; the row highlight + scroll-to-match covers // the navigation use case desktop chat apps solve with next/prev). val focusedMatchId = if (searchActive && searchQuery.isNotEmpty()) { searchMessages.getOrNull(searchPos)?.info?.id } else null val isFocusedMatch = focusedMatchId != null && item is MessageListItem.Message && item.message.info.id == focusedMatchId // Default placement animation so inserted/moved rows glide in. Skipped // entirely under reduced motion so a streaming message growing in place // doesn't animate on every token (and respects the a11y preference). Box( Modifier .then(reducedMotionAnimateItem()) .then( if (isFocusedMatch) { Modifier.background( MaterialTheme.colorScheme.secondaryContainer, MaterialTheme.shapes.medium, ) } else { Modifier }, ), ) { when (item) { is MessageListItem.Separator -> DateSeparator(item.label) is MessageListItem.Message -> { val message = item.message // Only the last (streaming) message needs isRunning — it drives the // reasoning-block spinner. Passing the live flag to every bubble // makes all visible messages recompose whenever a run starts or stops. val modelLabel = remember(message.info, models) { (message.info as? soy.iko.opencode.data.model.AssistantMessage) ?.let { resolveModelLabel(it, models) } } val agentLabel = remember(message.parts) { message.parts .filterIsInstance() .firstOrNull { it.name.isNotBlank() }?.name } // Text used to drive swipe-to-reply (and pre-fill the quote). Empty for // image/code-only messages, which then opt out of the swipe gesture. val quoteText = remember(message.parts) { message.parts .filterIsInstance() .joinToString("\n\n") { it.text } .trim() .takeIf { it.isNotEmpty() } } // Swipe-start-to-end reveals a reply affordance and triggers quote-reply, // snapping back (the message isn't dismissed — the quote fills the composer). // Only enabled when there's text to quote, so non-text messages don't // capture horizontal drags (e.g. inside a horizontally-scrollable code block). val swipeState = rememberSwipeToDismissBoxState( confirmValueChange = { value -> if (value == SwipeToDismissBoxValue.StartToEnd && quoteText != null) { haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove) vm.quoteReply(quoteText) runCatching { inputFocusRequester.requestFocus() } } false }, ) SwipeToDismissBox( state = swipeState, enableDismissFromEndToStart = false, enableDismissFromStartToEnd = quoteText != null, backgroundContent = { Box( Modifier.fillMaxSize().padding(horizontal = 16.dp), contentAlignment = Alignment.CenterStart, ) { Icon( Icons.AutoMirrored.Filled.Reply, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } }, ) { MessageBubble( message, isRunning = running && message.info.id == lastMessageId, imageContext = imageContext, modelLabel = modelLabel, agentLabel = agentLabel, onOpenFile = onOpenFile, onRevert = { vm.revertTo(message.info.id) }, onEdit = { text -> // editMessage reverts to before this message (hiding it and // everything after); the revert banner surfaces the rewind // with an Undo. Focus + scroll the composer into view like the // Quote action so the prefilled draft isn't off-screen. haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove) vm.editMessage(message.info.id, text) runCatching { inputFocusRequester.requestFocus() } contentScope.launch { runCatchingCancellable { listState.animateScrollToItem(listItems.lastIndex.coerceAtLeast(0)) } } }, onSpeak = { text -> if (!tts.toggle(message.info.id, text)) { // TTS engine unavailable (init failed / not yet ready // / blank text): surface feedback so the button doesn't // appear dead. Throttled so a rapid double-tap (stop // then immediate re-tap before ready) doesn't spam. scope.launch { snackbar.showSnackbar(ttsUnavailableMsg) } } }, isSpeaking = message.info.id == speakingMessageId, ttsState = if (message.info.id == speakingMessageId) ttsState else TtsState.IDLE, onPause = { tts.pause() }, onResume = { tts.resume() }, onStop = { tts.stop() }, onQuote = { text -> vm.quoteReply(text) runCatching { inputFocusRequester.requestFocus() } }, onBranch = { text -> vm.branchFrom(text) }, // Regenerate is only meaningful for a finished assistant reply // (not the actively-streaming one), so gate it on !running. onRegenerate = if (!running) { { vm.regenerate(message.info.id) } } else null, sendStatus = optimisticStatuses[message.info.id]?.let { failed -> if (failed) soy.iko.opencode.ui.chat.MessageSendStatus.FAILED else soy.iko.opencode.ui.chat.MessageSendStatus.SENDING }, isEdited = message.info.time?.let { it.updated != null && it.updated != it.created } == true, onRetry = optimisticStatuses[message.info.id]?.let { failed -> if (failed) { { vm.retryOptimisticMessage(message.info.id) } } else { null } }, onDismiss = optimisticStatuses[message.info.id]?.let { failed -> if (failed) { { vm.dismissOptimistic(message.info.id) } } else { null } }, onShare = { // Per-message share: build a single-message Markdown // transcript off-thread and fire ACTION_SEND, mirroring the // whole-conversation share but scoped to just this message. scope.launch { val md = withContext(Dispatchers.Default) { buildMessageMarkdown(message) } ?: return@launch val send = android.content.Intent(android.content.Intent.ACTION_SEND).apply { type = "text/markdown" putExtra(android.content.Intent.EXTRA_SUBJECT, sessionTitle ?: defaultShareSubject) putExtra(android.content.Intent.EXTRA_TEXT, md) } runCatchingCancellable { shareContext.startActivity(android.content.Intent.createChooser(send, shareContext.getString(R.string.share_message))) } .onFailure { showToast(shareContext, shareContext.getString(R.string.no_share_app)) } } }, ) } } } } } if (running) { item(key = "__typing") { val workingText = stringResource(R.string.working) // Elapsed-since-run chip driven by the VM's runStartMs so the timer // survives LazyColumn recycling — a local remember reset to 0:00 when // the row was disposed and scrolled back into view. The VM stamps the // start on the real false→true transition (send/run/init), not on // SSE-reconnect relight, so reconnects don't restart the clock. Updates // once per second; the value is decorative (the a11y label is the row's). val startMs by vm.runStartMs.collectAsStateWithLifecycle() val elapsedMs by produceState(0L, startMs) { if (startMs == 0L) return@produceState while (true) { value = (System.currentTimeMillis() - startMs).coerceAtLeast(0L) delay(1000) } } val elapsedText = remember(elapsedMs) { val total = elapsedMs / 1000 // Locale.US for stable digit formatting: on locales with // non-ASCII digits (ar-/fa-) the default locale would render // localized digits inside the Monospace block, breaking the // fixed-width alignment the monospace family was chosen for. String.format(java.util.Locale.US, "%d:%02d", total / 60, total % 60) } Row( verticalAlignment = Alignment.CenterVertically, // Animate this slot in/out so the working row doesn't pop in and // jolt the message list when a run starts/stops — matching the // AnimatedVisibility used by every other transient banner/FAB here. modifier = Modifier .then(reducedMotionAnimateItem()) .padding(vertical = 4.dp), ) { // Merge the indicator + label + clock into one TalkBack node so // it's announced once (the working label, then the elapsed time) // instead of "working… / working… / 0:14" with the label read // twice. The Stop IconButton below stays separately focusable. Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.semantics(mergeDescendants = true) { contentDescription = "$workingText $elapsedText" }, ) { CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp) Text(workingText, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp)) Spacer(Modifier.size(8.dp)) Icon( Icons.Filled.Schedule, contentDescription = null, modifier = Modifier.size(14.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) Text( elapsedText, style = MaterialTheme.typography.labelMedium, fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 4.dp), ) } // A trailing Stop on the working row so the run can be aborted // without scrolling back to the composer (which may be off-screen // while the user reads the streaming output up-thread). Spacer(Modifier.weight(1f)) val stopLabel = stringResource(R.string.stop) IconButton( onClick = { showStopConfirm = true }, modifier = Modifier.semantics { contentDescription = stopLabel }, ) { Icon(Icons.Filled.Stop, contentDescription = null) } } } } } } } } // Jump-to-latest affordance when the user has scrolled away during a stream. val fabMotion = rememberVisibilityTransitions() AnimatedVisibility( visible = !isPinnedToBottom && listItems.isNotEmpty(), enter = fabMotion.enter, exit = fabMotion.exit, modifier = Modifier.align(Alignment.BottomEnd), ) { // Badge the FAB when new content arrived while the user was scrolled up, so // they know there's something new to jump to (cleared once back at bottom). BadgedBox( badge = { if (newContentCount > 0) { // Merge the count into the badge announcement so TalkBack reads // "5 new messages" instead of the bare label and a separate number. val newContentLabel = pluralStringResource( R.plurals.plurals_new_messages, newContentCount, newContentCount, ) Badge(modifier = Modifier.semantics { contentDescription = newContentLabel }) { Text( if (newContentCount > 99) "99+" else newContentCount.toString(), modifier = Modifier.semantics { invisibleToUser() }, ) } } }, modifier = Modifier.padding(end = 16.dp, bottom = 16.dp), ) { ExtendedFloatingActionButton( onClick = { newContentCount = 0 if (listItems.isNotEmpty()) { // Scroll to the effective last index, including the // trailing "__typing" row when a run is active. val target = if (running) listItems.size else listItems.lastIndex contentScope.launch { runCatchingCancellable { listState.animateScrollToItem(target) } } } }, // contentDescription null: the visible "Latest" text already labels // the FAB, so a description here makes TalkBack announce "Latest" twice. icon = { Icon(Icons.Filled.KeyboardArrowDown, contentDescription = null) }, text = { Text(stringResource(R.string.latest)) }, ) } } // Scroll-to-top affordance: a small FAB at the start edge, visible when the // user has scrolled down past the first few messages. Symmetric to the // jump-to-latest FAB, for long conversations where manual fling back is tedious. val scrolledDown by remember { derivedStateOf { listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 200 } } AnimatedVisibility( visible = scrolledDown && listItems.size > 4, enter = fabMotion.enter, exit = fabMotion.exit, modifier = Modifier.align(Alignment.BottomStart), ) { SmallFloatingActionButton( onClick = { contentScope.launch { runCatchingCancellable { listState.animateScrollToItem(0) } } }, modifier = Modifier.padding(start = 16.dp, bottom = 16.dp), ) { Icon( Icons.Filled.KeyboardArrowUp, contentDescription = stringResource(R.string.scroll_to_top), ) } } // In-conversation search bar. Overlays the list so typing filters messages in // place; the match count + a no-matches note give immediate feedback. val searchMotion = rememberVisibilityTransitions() AnimatedVisibility( visible = searchActive, enter = searchMotion.enter, exit = searchMotion.exit, modifier = Modifier.align(Alignment.TopCenter), ) { Surface( tonalElevation = 3.dp, modifier = Modifier .padding(horizontal = 12.dp, vertical = 4.dp) .fillMaxWidth(), ) { // Focus the search field when the bar appears (from the overflow menu's // "Find in conversation" or Ctrl+F), so the user can start typing // immediately. Runs after the search field enters the composition. LaunchedEffect(searchActive) { if (searchActive) runCatching { searchFocusRequester.requestFocus() } } Row( modifier = Modifier.padding(start = 8.dp, end = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { OutlinedTextField( value = chatSearch, onValueChange = { chatSearch = it }, modifier = Modifier.weight(1f).focusRequester(searchFocusRequester), placeholder = { Text(stringResource(R.string.search_in_conversation)) }, singleLine = true, leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) }, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), ) // Match counter: "N of M" where N is the focused match (1-based) and // M is the total matching messages. Only shown when there's a query. val matchCount = searchMessages.size Text( if (searchQuery.isNotEmpty() && matchCount > 0) { stringResource(R.string.search_match_count, searchPos + 1, matchCount) } else if (searchQuery.isNotEmpty()) { stringResource(R.string.search_no_matches) } else "", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(horizontal = 4.dp), ) // Step through matches (Previous / Next), disabled when there are // fewer than two to navigate. Wrap around at the ends so repeated // taps cycle through every hit. val prevLabel = stringResource(R.string.find_previous) val nextLabel = stringResource(R.string.find_next) IconButton( onClick = { if (matchCount > 0) { searchPos = (searchPos - 1 + matchCount) % matchCount } }, enabled = matchCount > 1, ) { Icon(Icons.Filled.KeyboardArrowUp, contentDescription = prevLabel) } IconButton( onClick = { if (matchCount > 0) { searchPos = (searchPos + 1) % matchCount } }, enabled = matchCount > 1, ) { Icon(Icons.Filled.KeyboardArrowDown, contentDescription = nextLabel) } IconButton(onClick = { searchActive = false chatSearch = "" }) { Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.clear_search)) } } } } // When a search yields no matches (but the conversation isn't empty), say so // instead of showing a blank list. if (searchActive && searchQuery.isNotEmpty() && searchMessages.isEmpty() && messages.isNotEmpty()) { Text( stringResource(R.string.search_no_matches), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.align(Alignment.Center), ) } } } } } if (showExitConfirm) { AlertDialog( onDismissRequest = { showExitConfirm = false }, title = { Text(stringResource(R.string.stop_and_exit_title)) }, text = { Text(stringResource(R.string.stop_and_exit_text)) }, confirmButton = { TextButton(onClick = { showExitConfirm = false haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove) vm.abort() onBack() }) { Text(stringResource(R.string.stop_and_exit)) } }, dismissButton = { TextButton(onClick = { showExitConfirm = false }) { Text(stringResource(R.string.stay)) } }, ) } if (showStopConfirm) { AlertDialog( onDismissRequest = { showStopConfirm = false }, title = { Text(stringResource(R.string.stop_run_title)) }, text = { Text(stringResource(R.string.stop_run_text)) }, confirmButton = { TextButton(onClick = { showStopConfirm = false haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove) vm.abort() }) { Text(stringResource(R.string.stop), color = MaterialTheme.colorScheme.error) } }, dismissButton = { TextButton(onClick = { showStopConfirm = false }) { Text(stringResource(R.string.cancel)) } }, ) } if (showSummarizeConfirm) { AlertDialog( onDismissRequest = { showSummarizeConfirm = false }, title = { Text(stringResource(R.string.summarize_confirm_title)) }, text = { Text(stringResource(R.string.summarize_confirm_text)) }, confirmButton = { TextButton(onClick = { showSummarizeConfirm = false vm.summarize() }) { Text(stringResource(R.string.action_continue)) } }, dismissButton = { TextButton(onClick = { showSummarizeConfirm = false }) { Text(stringResource(R.string.cancel)) } }, ) } if (showInitConfirm) { AlertDialog( onDismissRequest = { showInitConfirm = false }, title = { Text(stringResource(R.string.init_confirm_title)) }, text = { Text(stringResource(R.string.init_confirm_text)) }, confirmButton = { TextButton(onClick = { showInitConfirm = false vm.initProject() }) { Text(stringResource(R.string.action_continue)) } }, dismissButton = { TextButton(onClick = { showInitConfirm = false }) { Text(stringResource(R.string.cancel)) } }, ) } if (showRenameDialog) { RenameSessionChatDialog( initialTitle = sessionTitle ?: "", onDismiss = { showRenameDialog = false }, onConfirm = { newName -> showRenameDialog = false vm.renameSession(newName) }, ) } if (showDeleteDialog) { AlertDialog( onDismissRequest = { showDeleteDialog = false }, title = { Text(stringResource(R.string.delete_session_chat_title)) }, text = { Text(stringResource(R.string.delete_session_chat_text, sessionTitle ?: sessionLabel)) }, confirmButton = { TextButton(onClick = { showDeleteDialog = false haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress) vm.deleteSession() }) { Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) } }, dismissButton = { TextButton(onClick = { showDeleteDialog = false }) { Text(stringResource(R.string.cancel)) } }, ) } if (showShellDialog) { ShellCommandDialog( onDismiss = { showShellDialog = false }, onRun = { cmd -> showShellDialog = false vm.runShell(cmd) }, ) } if (showModelPicker) { // Resolve persisted recent composite keys to the live ModelOption entries they refer // to, dropping any whose model is no longer offered by the server. Ordered by // recency (head of the store first). val recentOptions = remember(models, recentModelEntries) { val byKey = models.associateBy { it.providerID to it.modelID } recentModelEntries.mapNotNull { entry -> container.recentModelsStore.split(entry)?.let { (p, m) -> byKey[p to m] } } } ModelPickerSheet( options = models, selected = selectedModel, loading = modelsLoading, error = modelsError, onSelect = { vm.selectModel(it) container.recentModelsStore.add(it.providerID, it.modelID) }, onRetry = { vm.reloadModels() }, onDismiss = { showModelPicker = false }, preferredModelId = preferredModelId, onSetPreferredModel = { id -> scope.launch { runCatchingCancellable { container.settingsStore.setPreferredModelId(id) } } }, recent = recentOptions, ) } if (showAgentPicker) { AgentPickerSheet( agents = agents, selected = selectedAgent, loading = agentsLoading, error = agentsError, onSelect = { vm.selectAgent(it?.name) }, onRetry = { vm.reloadAgents() }, onDismiss = { showAgentPicker = false }, ) } if (showCommandPicker) { CommandPickerSheet( commands = commands, loading = commandsLoading, error = commandsError, onSelect = { vm.runCommand(it) }, onRetry = { vm.reloadCommands() }, onDismiss = { showCommandPicker = false }, ) } pendingPermission?.let { permission -> PermissionDialog( permission = permission, position = permissionProgress.position, total = permissionProgress.total, onRespond = { response -> vm.respondPermission(permission, response) }, ) } if (showPalette) { CommandPalette(actions = paletteActions, onDismiss = { showPalette = false }) } if (showShortcutsDialog) { // Surfaces the hardware-keyboard shortcuts (Ctrl+K palette, Escape stop, Enter send) // that are otherwise wired but undiscoverable. A simple key/value list; Enter behavior // depends on the "Send on Enter" setting, noted inline. AlertDialog( onDismissRequest = { showShortcutsDialog = false }, title = { Text(stringResource(R.string.keyboard_shortcuts_title)) }, text = { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { ShortcutRow("Ctrl + K", stringResource(R.string.shortcut_open_palette)) ShortcutRow("Esc", stringResource(R.string.shortcut_stop_run)) ShortcutRow("Esc", stringResource(R.string.shortcut_close)) ShortcutRow("Enter", stringResource(R.string.shortcut_send)) ShortcutRow("Shift + Enter / Ctrl + Enter", stringResource(R.string.shortcut_newline)) } }, confirmButton = { TextButton(onClick = { showShortcutsDialog = false }) { Text(stringResource(R.string.action_continue)) } }, ) } } @Composable private fun ShortcutRow(keys: String, desc: String) { Row(verticalAlignment = Alignment.CenterVertically) { Text( keys, style = MaterialTheme.typography.labelLarge, fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, modifier = Modifier.widthIn(min = 160.dp), ) Text( desc, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } @OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class) @Composable private fun ChatInputBar( value: String, onValueChange: (String) -> Unit, running: Boolean, aborting: Boolean, enabled: Boolean, sendOnEnter: Boolean, onSend: () -> Unit, onAbort: () -> Unit, queuedFollowUp: String?, onQueueFollowUp: (String) -> Unit, onCancelQueue: () -> Unit, focusRequester: androidx.compose.ui.focus.FocusRequester, attachments: List, staging: Boolean, stagingFileCount: Int, onRemoveAttachment: (String) -> Unit, onPickPhoto: () -> Unit, onPickFile: () -> Unit, onCamera: () -> Unit, onVoice: () -> Unit, onPasteImage: (List) -> Unit, ) { // Sendable when there's text OR at least one attachment (an image-only prompt is valid). val hasContent = value.isNotBlank() || attachments.isNotEmpty() val context = LocalContext.current // Full-screen editor state: opened when the user wants more space for a long prompt. var showFullScreenEditor by rememberSaveable { mutableStateOf(false) } Surface(tonalElevation = 3.dp, modifier = Modifier.imePadding()) { Column(modifier = Modifier.fillMaxWidth().windowInsetsPadding(WindowInsets.navigationBars)) { // A queued follow-up replaces the Stop button with a "queued" chip so the // user sees their message will be sent when the run finishes, and can cancel. if (queuedFollowUp != null) { val editLabel = stringResource(R.string.edit) Row( modifier = Modifier .fillMaxWidth() .padding(start = 12.dp, top = 6.dp, end = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { // Tapping the chip reloads the queued text into the draft (and clears the // queue) so a typo spotted while queuing can be fixed without retyping. Column( modifier = Modifier .weight(1f) .clip(MaterialTheme.shapes.small) .clickable(role = Role.Button, onClickLabel = editLabel) { onValueChange(queuedFollowUp) onCancelQueue() } .padding(vertical = 4.dp), ) { Text( stringResource(R.string.queued), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Text( queuedFollowUp, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } TextButton(onClick = onCancelQueue, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp)) { Text(stringResource(R.string.cancel)) } } } // Staged attachments: horizontally-scrollable thumbnails/chips, each removable. AttachmentStrip(attachments, onRemove = onRemoveAttachment, staging = staging, stagingFileCount = stagingFileCount) Row( modifier = Modifier.fillMaxWidth().padding(8.dp), verticalAlignment = Alignment.Bottom, ) { // Attach menu: photo picker, camera, or any file. var showAttachMenu by remember { mutableStateOf(false) } Box { IconButton(onClick = { showAttachMenu = true }, enabled = enabled) { Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.add_attachment)) } DropdownMenu(expanded = showAttachMenu, onDismissRequest = { showAttachMenu = false }) { DropdownMenuItem( leadingIcon = { Icon(Icons.Filled.PhotoLibrary, contentDescription = null) }, text = { Text(stringResource(R.string.attach_photo)) }, onClick = { showAttachMenu = false; onPickPhoto() }, ) DropdownMenuItem( leadingIcon = { Icon(Icons.Filled.PhotoCamera, contentDescription = null) }, text = { Text(stringResource(R.string.attach_camera)) }, onClick = { showAttachMenu = false; onCamera() }, ) DropdownMenuItem( leadingIcon = { Icon(Icons.Filled.Description, contentDescription = null) }, text = { Text(stringResource(R.string.attach_file)) }, onClick = { showAttachMenu = false; onPickFile() }, ) } } // Voice dictation: appends recognized speech to the draft. Hidden (not just // disabled) when the device has no speech-recognition activity, so a user // without a voice app doesn't get a button that only fails noisily on tap. val voiceAvailable = remember { val pm = context.packageManager val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH) pm.queryIntentActivities(intent, 0).isNotEmpty() } if (voiceAvailable) { IconButton(onClick = onVoice, enabled = enabled) { Icon(Icons.Filled.Mic, contentDescription = stringResource(R.string.voice_input)) } } // Expand to a full-screen editor for long prompts (the inline field caps at 6 lines). IconButton(onClick = { showFullScreenEditor = true }, enabled = enabled) { Icon(Icons.Filled.Expand, contentDescription = stringResource(R.string.expand_editor)) } OutlinedTextField( value = value, onValueChange = { v -> // Cap input length so a huge paste can't stall the UI. Shares are // capped separately in MainActivity; this guards typed/pasted drafts. onValueChange(v.take(NetworkConfig.maxDraftLengthChars)) }, modifier = Modifier .weight(1f) .focusRequester(focusRequester) .testTag("chat_input") // Intercept image content pasted/dropped into the field (keyboard paste // of a copied screenshot, drag from Files, etc.) and route it through the // same staging path as the attach menu. Non-image content (text) falls // through unchanged so normal typing/paste works. .contentReceiver( remember { androidx.compose.foundation.content.ReceiveContentListener { transferable -> val clipData = transferable.clipEntry?.clipData if (clipData != null) { val imageUris = (0 until clipData.itemCount).mapNotNull { i -> val item = clipData.getItemAt(i) item.uri?.takeIf { uri -> item.text == null && uri.toString().let { s -> s.startsWith("content:") || s.startsWith("file:") } } } if (imageUris.isNotEmpty()) { onPasteImage(imageUris) } } transferable } }, ) .onPreviewKeyEvent { event -> if (event.type != KeyEventType.KeyDown || event.key != Key.Enter) return@onPreviewKeyEvent false // With "Send on Enter" on, Enter sends (Shift+Enter newlines). // With it off, Enter inserts a newline and Ctrl+Enter sends. val send = enterShouldSend( enabled = enabled, hasContent = hasContent, sendOnEnter = sendOnEnter, shift = event.isShiftPressed, ctrl = event.isCtrlPressed, ) if (send) { if (running) onQueueFollowUp(value) else onSend() true } else { false } }, // While a run is active a typed message only queues (sends after the run), so // the placeholder says so instead of the plain "Message…". placeholder = { Text(stringResource(if (running) R.string.composer_hint_running else R.string.message_placeholder)) }, enabled = enabled, maxLines = 6, // Show a "N / max" countdown once the draft crosses a high fraction of // the cap, so the user knows a paste is about to be truncated instead // of being silently cut off. Hidden for normal short prompts to avoid // clutter under a typical one-line message. supportingText = { val threshold = (NetworkConfig.maxDraftLengthChars * NetworkConfig.draftCountdownThresholdFraction).toInt() if (value.length >= threshold) { Text( stringResource( R.string.draft_chars_remaining, NetworkConfig.maxDraftLengthChars - value.length, NetworkConfig.maxDraftLengthChars, ), style = MaterialTheme.typography.labelSmall, color = if (value.length >= NetworkConfig.maxDraftLengthChars) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, ) } }, keyboardOptions = KeyboardOptions( // When "Send on Enter" is on, expose ImeAction.Send so the soft // keyboard shows a send key — matching the setting's intent and // giving touch-only users a keyboard send affordance. When off, // ImeAction.Default keeps the enter/newline key for multi-line input. imeAction = if (sendOnEnter) ImeAction.Send else ImeAction.Default, capitalization = KeyboardCapitalization.Sentences, ), keyboardActions = KeyboardActions( onSend = { if (enabled && hasContent) onSend() }, ), ) ComposerTrailingButton( running = running, aborting = aborting, canSend = enabled && hasContent, canQueue = enabled && value.isNotBlank(), onSend = onSend, onAbort = onAbort, onQueue = { onQueueFollowUp(value) }, ) } } if (showFullScreenEditor) { FullScreenEditor( value = value, onValueChange = onValueChange, onDismiss = { showFullScreenEditor = false }, onSend = { showFullScreenEditor = false // Mirror the inline composer's behavior: while a run is active a send // can't go immediately, so queue the follow-up instead of silently // dropping it (vm.send() returns false during a run, which previously // lost the typed message with no feedback). if (running) onQueueFollowUp(value) else onSend() }, canSend = enabled && hasContent, running = running, sendOnEnter = sendOnEnter, attachments = attachments, onRemoveAttachment = onRemoveAttachment, staging = staging, stagingFileCount = stagingFileCount, ) } } } /** Full-screen editor dialog for composing long prompts. Gives the full viewport height to * the text field (vs the inline 6-line cap) with a Send button and a collapse action. */ @OptIn(ExperimentalMaterial3Api::class) @Composable private fun FullScreenEditor( value: String, onValueChange: (String) -> Unit, onDismiss: () -> Unit, onSend: () -> Unit, canSend: Boolean, running: Boolean = false, sendOnEnter: Boolean = false, attachments: List = emptyList(), onRemoveAttachment: (String) -> Unit = {}, staging: Boolean = false, stagingFileCount: Int = 0, ) { androidx.compose.ui.window.Dialog( onDismissRequest = onDismiss, properties = androidx.compose.ui.window.DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false), ) { // Cap the editor width on large screens (tablets) so the text field doesn't stretch // edge-to-edge — matching the chat list's readability rationale. BoxWithConstraints // gives the available width so we can center a capped-width column. androidx.compose.foundation.layout.BoxWithConstraints( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { val hPad = if (maxWidth > NetworkConfig.twoPaneWidthThresholdDp.dp) ((maxWidth - NetworkConfig.composerDialogMaxWidthDp.dp) / 2).coerceAtLeast(0.dp) else 0.dp Scaffold( modifier = Modifier.padding(horizontal = hPad), topBar = { androidx.compose.material3.TopAppBar( title = { Text(stringResource(R.string.compose_editor_title)) }, navigationIcon = { androidx.compose.material3.TextButton(onClick = onDismiss) { Text(stringResource(R.string.collapse_editor)) } }, ) }, bottomBar = { Surface(tonalElevation = 3.dp) { Row( modifier = Modifier.fillMaxWidth().imePadding().padding(12.dp), horizontalArrangement = Arrangement.End, ) { androidx.compose.material3.Button(onClick = onSend, enabled = canSend) { Text(stringResource(if (running) R.string.queue else R.string.send)) } } } }, ) { padding -> Column(modifier = Modifier.fillMaxSize().padding(padding)) { // Show staged attachments in the full-screen editor too, so a user who staged // images then expanded for a long prompt can still see and remove them without // collapsing first (mirrors the inline composer's AttachmentStrip). AttachmentStrip(attachments, onRemove = onRemoveAttachment, staging = staging, stagingFileCount = stagingFileCount) OutlinedTextField( value = value, onValueChange = { v -> onValueChange(v.take(NetworkConfig.maxDraftLengthChars)) }, modifier = Modifier .fillMaxSize() .weight(1f) // Honor the "Send on Enter" setting here too, so the full-screen // editor matches the inline composer: Enter sends (Shift+Enter // newlines) when on; Enter inserts a newline and Ctrl+Enter sends // when off. Previously this hard-coded ImeAction.Default and had no // key handler, so a hardware-keyboard user got a different behaviour // from the inline composer depending on which they happened to open. .onPreviewKeyEvent { event -> if (event.type != KeyEventType.KeyDown || event.key != Key.Enter) return@onPreviewKeyEvent false val send = enterShouldSend( enabled = canSend, hasContent = canSend, sendOnEnter = sendOnEnter, shift = event.isShiftPressed, ctrl = event.isCtrlPressed, ) if (send) { onSend(); true } else false }, placeholder = { Text(stringResource(R.string.message_placeholder)) }, maxLines = Int.MAX_VALUE, keyboardOptions = KeyboardOptions( imeAction = if (sendOnEnter) ImeAction.Send else ImeAction.Default, capitalization = KeyboardCapitalization.Sentences, ), keyboardActions = KeyboardActions( onSend = { if (canSend) onSend() }, ), ) } } } } } /** Whether a hardware Enter keypress should send: never when disabled/empty; otherwise * Enter sends (unless Shift) when send-on-Enter is on, else only Ctrl+Enter sends. */ private fun enterShouldSend( enabled: Boolean, hasContent: Boolean, sendOnEnter: Boolean, shift: Boolean, ctrl: Boolean, ): Boolean = when { !enabled || !hasContent -> false sendOnEnter -> !shift else -> ctrl } /** The composer's trailing button: while a run is active a Stop (with in-flight spinner), * preceded by a Queue button when the user has typed a follow-up; otherwise Send. Extracted so * [ChatInputBar] stays under the complexity threshold. */ @Composable private fun ComposerTrailingButton( running: Boolean, aborting: Boolean, canSend: Boolean, canQueue: Boolean, onSend: () -> Unit, onAbort: () -> Unit, onQueue: () -> Unit, ) { if (running) { Row(verticalAlignment = Alignment.CenterVertically) { // While a run is active the trailing action is Stop, so the only send affordance is // the keyboard's IME action — which is a plain newline when "Send on Enter" is off. // That leaves a touch-only user with the setting off no way to queue a follow-up // (Ctrl+Enter needs a hardware keyboard). Surface an explicit Queue button whenever // there's typed TEXT so queuing is always reachable, without changing the user's // Enter-inserts-a-newline preference. Gate on typed text — not the send-oriented // canSend, which is also true for an attachment-only composer — because queuing "" just // calls setQueuedFollowUp(null), silently cancelling any already-queued follow-up and // dropping the staged attachment (queued follow-ups don't carry attachments anyway). if (canQueue) { IconButton( onClick = onQueue, modifier = Modifier.testTag("queue_button"), ) { // PlaylistAdd reads as "add to a queue/list" more clearly than a clock // icon (which can be read as "snooze" or "later"), and isn't confused with // the Send action sitting right beside the Stop button. Icon( Icons.AutoMirrored.Filled.PlaylistAdd, contentDescription = stringResource(R.string.queue_followup), ) } } // Show a spinner while the abort REST call is in flight so the user sees the stop // was sent, and disable to prevent a double-tap. IconButton( onClick = onAbort, enabled = !aborting, modifier = Modifier.padding(start = 4.dp).testTag("stop_button"), ) { if (aborting) { val stopLabel = stringResource(R.string.stop) CircularProgressIndicator( Modifier.size(18.dp).semantics { contentDescription = stopLabel }, strokeWidth = 2.dp, ) } else { Icon(Icons.Filled.Stop, contentDescription = stringResource(R.string.stop)) } } } } else { IconButton( onClick = onSend, enabled = canSend, modifier = Modifier.padding(start = 4.dp).testTag("send_button"), ) { // Tint the send icon with primary when there's content to send so the commit action // draws the eye; muted otherwise. A plain enabled/disabled flip is easy to miss. Icon( Icons.AutoMirrored.Filled.Send, contentDescription = stringResource(R.string.send), tint = if (canSend) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, ) } } } @OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class) @Composable private fun EmptyConversation( onSuggestion: (String) -> Unit, modifier: Modifier = Modifier, ) { // A few example prompts so a first-time user has a one-tap path to their first // message instead of staring at a blank input. Tapping a chip fills the draft // (without sending) so the user can edit it before sending. val suggestions = remember { listOf( R.string.empty_chat_suggest_1, R.string.empty_chat_suggest_2, R.string.empty_chat_suggest_3, ) } Column( modifier = modifier.padding(32.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { Icon( Icons.Filled.AutoAwesome, contentDescription = null, modifier = Modifier.size(48.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(Modifier.size(16.dp)) Text( stringResource(R.string.empty_chat_title), style = MaterialTheme.typography.titleMedium, ) Spacer(Modifier.size(8.dp)) Text( stringResource(R.string.empty_chat_hint), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(Modifier.size(20.dp)) // Suggestion chips wrap across rows on narrow screens instead of stacking full-width, // so a longer localized prompt doesn't push the chips off-screen on a phone. FlowRow( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { suggestions.forEach { resId -> val text = stringResource(resId) androidx.compose.material3.AssistChip( onClick = { onSuggestion(text) }, label = { Text(text, style = MaterialTheme.typography.bodyMedium) }, ) } } } } /** A banner shown while a revert checkpoint is active, offering an Undo (unrevert). When the * server included a diff of the revert, a collapsible "Show what changed" affordance reveals * it inline via [DiffView] so the user can see what was rolled back before deciding to undo. */ @Composable private fun RevertBanner(diff: String?, onUndo: () -> Unit) { val haptics = LocalHapticFeedback.current var showDiff by rememberSaveable { mutableStateOf(false) } val expandMotion = rememberVisibilityTransitions() Surface( color = MaterialTheme.colorScheme.tertiaryContainer, tonalElevation = 2.dp, modifier = Modifier.fillMaxWidth(), ) { Column { Row( modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, ) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) { Icon( Icons.Filled.Restore, contentDescription = null, tint = MaterialTheme.colorScheme.onTertiaryContainer, modifier = Modifier.size(18.dp), ) Text( stringResource(R.string.reverted_banner), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onTertiaryContainer, modifier = Modifier.padding(start = 8.dp), ) // "Show what changed" toggle, shown only when the server provided a diff. // Collapsible so the banner stays compact by default; a user curious about // what the revert rolled back can expand it inline without leaving the chat. if (!diff.isNullOrBlank()) { TextButton( onClick = { haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove) showDiff = !showDiff }, contentPadding = androidx.compose.foundation.layout.PaddingValues(start = 8.dp, end = 4.dp), ) { Icon( if (showDiff) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, contentDescription = null, modifier = Modifier.size(16.dp), ) Text( stringResource(if (showDiff) R.string.revert_hide_diff else R.string.revert_show_diff), style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(start = 4.dp), ) } } } TextButton(onClick = { // Haptic on undo to match the other destructive/important confirmations: a // revert restores potentially hundreds of hidden messages. haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove) onUndo() }) { Text(stringResource(R.string.undo)) } } // The diff render lives inside the banner's Surface so it inherits the tertiary // tonal container and reads as part of the banner, not a floating panel. if (!diff.isNullOrBlank()) { AnimatedVisibility( visible = showDiff, enter = expandMotion.enter, exit = expandMotion.exit, ) { Column(modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 8.dp)) { Text( stringResource(R.string.revert_diff_header), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onTertiaryContainer, modifier = Modifier.padding(bottom = 4.dp), ) DiffView(diff, saveKey = "revertDiff") } } } } } } /** A banner shown while messages composed offline are queued in the outbox, with a * "Send now" nudge and a discard. Shows a spinner while the queue is flushing. */ @Composable private fun OutboxBanner( count: Int, sending: Boolean, onFlush: () -> Unit, onDiscard: () -> Unit, ) { var showDiscardConfirm by rememberSaveable { mutableStateOf(false) } Surface( color = MaterialTheme.colorScheme.secondaryContainer, tonalElevation = 2.dp, modifier = Modifier.fillMaxWidth(), ) { Row( modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, ) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) { if (sending) { CircularProgressIndicator( Modifier.size(16.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.onSecondaryContainer, ) Spacer(Modifier.size(8.dp)) } Text( if (sending) { stringResource(R.string.outbox_sending) } else { val res = if (count == 1) R.string.outbox_pending_one else R.string.outbox_pending_many stringResource(res, count) }, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSecondaryContainer, ) } if (!sending) { val haptics = LocalHapticFeedback.current TextButton(onClick = onFlush) { Text(stringResource(R.string.outbox_flush)) } TextButton(onClick = { // Confirm before discarding all queued messages (irreversible), matching // the confirmation pattern used by deleteSession and other destructive // actions. Previously a single tap dropped every queued message. haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress) showDiscardConfirm = true }) { Text(stringResource(R.string.outbox_discard_all), color = MaterialTheme.colorScheme.error) } } } } if (showDiscardConfirm) { androidx.compose.material3.AlertDialog( onDismissRequest = { showDiscardConfirm = false }, title = { Text(stringResource(R.string.discard_queued_title)) }, text = { Text(stringResource(R.string.discard_queued_text, count)) }, confirmButton = { androidx.compose.material3.TextButton(onClick = { showDiscardConfirm = false onDiscard() }) { Text(stringResource(R.string.discard)) } }, dismissButton = { androidx.compose.material3.TextButton(onClick = { showDiscardConfirm = false }) { Text(stringResource(R.string.cancel)) } }, ) } } /** Prompt for a one-off shell command to run in the session's worktree. */ @Composable private fun ShellCommandDialog(onDismiss: () -> Unit, onRun: (String) -> Unit) { var command by rememberSaveable { mutableStateOf("") } AlertDialog( onDismissRequest = onDismiss, title = { Text(stringResource(R.string.run_shell_command)) }, text = { OutlinedTextField( value = command, onValueChange = { command = it }, modifier = Modifier.fillMaxWidth(), placeholder = { Text(stringResource(R.string.shell_command_hint)) }, label = { Text(stringResource(R.string.shell_command_label)) }, singleLine = true, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), keyboardActions = KeyboardActions(onDone = { if (command.isNotBlank()) onRun(command.trim()) }), ) }, confirmButton = { TextButton( onClick = { if (command.isNotBlank()) onRun(command.trim()) }, enabled = command.isNotBlank(), ) { Text(stringResource(R.string.run)) } }, dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } }, ) } /** * Change signal for the auto-scroll-on-stream watcher. Primitive fields avoid the * boxing `Triple` would impose on every snapshot evaluation, and * equality is structural so `snapshotFlow` emits only when something relevant changes. */ private data class AutoScrollSignal(val size: Int, val lastTextLength: Int, val pinned: Boolean) /** Length of the streaming content carried by [part], driving auto-scroll as it grows. * Covers text, reasoning, and tool output so a pinned view keeps following non-text * streaming, not just plain assistant text. O(1). */ private fun streamingContentLength(part: Part?): Int = when (part) { is TextPart -> part.text.length is ReasoningPart -> part.text.length is ToolPart -> when (val st = part.state) { is ToolCompleted -> st.output?.length ?: 0 is ToolError -> st.error?.length ?: 0 is ToolRunning -> st.title?.length ?: 0 else -> 0 } else -> 0 } /** Rename dialog for the currently-open session. Mirrors the SessionListScreen rename * dialog but lives in the chat screen so the user can rename without backing out to * the list. Pre-filled with the current title; the cap matches the list dialog. */ @Composable private fun RenameSessionChatDialog( initialTitle: String, onDismiss: () -> Unit, onConfirm: (String) -> Unit, ) { // Seed the field once (no key input) so a later external title change — e.g. opencode // auto-generating a session title after the first prompt, arriving via SessionUpdated — // doesn't re-initialize the box and discard the user's in-progress edit. var title by rememberSaveable { mutableStateOf(initialTitle) } AlertDialog( onDismissRequest = onDismiss, title = { Text(stringResource(R.string.rename_session_chat)) }, text = { OutlinedTextField( value = title, onValueChange = { v -> title = v.take(NetworkConfig.maxSessionTitleChars) }, modifier = Modifier.fillMaxWidth(), placeholder = { Text(stringResource(R.string.session_title_hint)) }, label = { Text(stringResource(R.string.rename_session_chat)) }, singleLine = true, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), keyboardActions = KeyboardActions(onDone = { if (title.isNotBlank()) onConfirm(title.trim()) }), ) }, confirmButton = { TextButton(onClick = { if (title.isNotBlank()) onConfirm(title.trim()) }, enabled = title.isNotBlank()) { Text(stringResource(R.string.save)) } }, dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } }, ) } /** * A single item in the chat message list — either a [Message] bubble or a day * [Separator] inserted before the first message of a new day. The separator shifts * the LazyColumn indices vs. the raw messages list, so scroll-target math must use * the [List] of these items (not `messages.size`/`lastIndex`). */ private sealed interface MessageListItem { val key: Any val contentType: Any data class Message(val message: MessageWithParts, override val key: Any) : MessageListItem { override val contentType: Any get() = message.info::class } data class Separator(val label: String, val ordinal: Int) : MessageListItem { // Key on the day-occurrence ordinal, not the label: two non-contiguous groups // can share a label (e.g. an untimestamped message — empty label — between two // same-day messages, or two untimestamped groups), and a label-only key would // then collide, which makes LazyColumn throw on duplicate keys. The ordinal is // stable for a given message ordering so slots aren't needlessly recreated. override val key: Any get() = "sep_${ordinal}_$label" // Separators share a contentType so the LazyColumn can recycle their slots. override val contentType: Any get() = "separator" } } /** * Build the interleaved list of [MessageListItem]s for the message list, inserting a * [MessageListItem.Separator] before the first message of each new calendar day. Uses * the message's `time.created` (falling back to `updated` then `completed`) to bucket. * Messages with no timestamp are grouped under an empty-label separator only if they * start the list, so a server that omits timestamps doesn't suppress the first divider. * * [todayLabel]/[yesterdayLabel] are resolved by the caller (a @Composable can't call * stringResource from inside this plain function) and used for the "Today"/"Yesterday" * labels; older days fall back to a locale-stable medium-date format. */ private fun buildMessageListItems( messages: List, todayLabel: String, yesterdayLabel: String, ): List { if (messages.isEmpty()) return emptyList() val today = java.time.LocalDate.now(java.time.ZoneId.systemDefault()) val dateFmt = mediumDateFormat() val result = ArrayList(messages.size + 4) val seenKeys = HashSet(messages.size * 2) var lastDayKey: String? = null var sepOrdinal = 0 var emptyIdOrdinal = 0 var first = true for (message in messages) { val ts = message.info.time?.created ?: message.info.time?.updated ?: message.info.time?.completed val dayKey = ts?.let { dayKey(it) } ?: "" // Emit a separator before the very first message (so a leading un-timestamped message // still gets the first divider, empty label and all), and thereafter only when crossing // into a new NON-empty calendar day. An un-timestamped message mid-list (empty key) // neither emits its own separator nor resets the day — otherwise a following same-day // message would see a "" -> "" transition and emit a duplicate "Today" header. if (first || (dayKey.isNotEmpty() && dayKey != lastDayKey)) { result.add(MessageListItem.Separator(dayLabel(dayKey, ts ?: 0L, today, todayLabel, yesterdayLabel, dateFmt), sepOrdinal++)) } if (dayKey.isNotEmpty()) lastDayKey = dayKey first = false // Most messages have a unique non-empty id. An unrecognized-role message can carry an // empty id — and the reducer intentionally keeps several such messages as distinct // holders — so give each a stable synthetic key and defensively de-collide any remaining // duplicate; otherwise LazyColumn throws on a duplicate key and the whole screen crashes. var key = message.info.id.ifEmpty { "msg-empty-${emptyIdOrdinal++}" } if (!seenKeys.add(key)) { var suffix = 1 while (!seenKeys.add("$key#$suffix")) suffix++ key = "$key#$suffix" } result.add(MessageListItem.Message(message, key)) } return result } /** * A stable bucket key for a timestamp's calendar day (epoch-days as a string). * * [buildMessageListItems] re-runs on every streaming snapshot (the memo is keyed on the * whole `messages` list, which must stay so the live message's fresh parts render), so a * naive implementation would re-allocate an `Instant`/`ZonedDateTime`/`LocalDate` for * every message on every token. Message timestamps are immutable once assigned, so we * memoize the parse per epoch-millis: after the first snapshot, each unchanged message is * a cheap hash lookup. Bounded LRU (access-order) caps memory; only touched on the main * thread during composition, so the coarse lock is uncontended. */ private val dayKeyCache = object : LinkedHashMap(128, 0.75f, true) { override fun removeEldestEntry(eldest: MutableMap.MutableEntry): Boolean = size > 1024 } private fun dayKey(epochMillis: Long): String = synchronized(dayKeyCache) { dayKeyCache.getOrPut(epochMillis) { java.time.Instant.ofEpochMilli(epochMillis) .atZone(java.time.ZoneId.systemDefault()).toLocalDate().toEpochDay().toString() } } // The medium-date formatter is an ICU locale lookup; cache one instance instead of // allocating a fresh one per buildMessageListItems call, rebuilding only if the default // locale changes at runtime. Main-thread-only, so no synchronization is needed. private var cachedDateFmt: java.text.DateFormat? = null private var cachedDateFmtLocale: java.util.Locale? = null private fun mediumDateFormat(): java.text.DateFormat { val locale = java.util.Locale.getDefault() val existing = cachedDateFmt if (existing != null && cachedDateFmtLocale == locale) return existing return java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM).also { cachedDateFmt = it cachedDateFmtLocale = locale } } /** * Resolve a day-key back to a human-readable label. "Today" / "Yesterday" for the * recent days, otherwise a locale-stable medium-date format. Returns an empty string * when [dayKey] is empty (no timestamp) so no separator is rendered for un-timestamped * messages. */ private fun dayLabel( dayKey: String, ts: Long, today: java.time.LocalDate, todayLabel: String, yesterdayLabel: String, dateFmt: java.text.DateFormat, ): String { if (dayKey.isEmpty()) return "" val epochDay = dayKey.toLongOrNull() ?: return "" val date = java.time.LocalDate.ofEpochDay(epochDay) return when { date == today -> todayLabel date == today.minusDays(1) -> yesterdayLabel else -> dateFmt.format(java.util.Date(ts)) } } /** * A centered day divider in the message list. Renders nothing for an empty label * (the no-timestamp case) so un-timestamped messages don't get a stray blank divider. */ @Composable private fun DateSeparator(label: String) { if (label.isBlank()) return Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center, ) { Surface( color = MaterialTheme.colorScheme.surfaceVariant, shape = MaterialTheme.shapes.small, // Marked as a heading so TalkBack users can navigate day-by-day with the // heading-skip gesture — the canonical affordance for date dividers in a list. modifier = Modifier.semantics(mergeDescendants = true) { heading() }, ) { Text( label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), ) } } } /** Skeleton placeholder bubbles for the chat loading and run-started states. Alpha pulses * (gated by reduced-motion) to convey ongoing work; the column carries the a11y label so * TalkBack announces "Loading" instead of reading the skeleton bars. Rendered as a * [BoxScope] extension so it can align itself to the top center of its host box. */ @Composable private fun androidx.compose.foundation.layout.BoxScope.ChatSkeleton(topPad: androidx.compose.ui.unit.Dp) { val loadingLabel = stringResource(R.string.loading) val reducedMotion = LocalReducedMotion.current val transition = rememberInfiniteTransition(label = "skeleton") val pulse by transition.animateFloat( initialValue = 0.35f, targetValue = 0.7f, animationSpec = infiniteRepeatable(tween(900), RepeatMode.Reverse), label = "skeletonAlpha", ) val skeletonAlpha = if (reducedMotion) 0.5f else pulse val skeletonColor = MaterialTheme.colorScheme.surfaceVariant @Composable fun bar(widthFraction: Float) { Box( Modifier .fillMaxWidth(widthFraction) .height(14.dp) .clip(RoundedCornerShape(4.dp)) .background(skeletonColor) .alpha(skeletonAlpha), ) } Column( modifier = Modifier .align(Alignment.TopCenter) .padding(top = topPad) .padding(16.dp) .fillMaxWidth() .semantics { contentDescription = loadingLabel }, verticalArrangement = Arrangement.spacedBy(14.dp), ) { repeat(4) { i -> val left = i % 2 == 0 Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = if (left) Arrangement.Start else Arrangement.End, ) { Column( modifier = Modifier.fillMaxWidth(if (left) 0.7f else 0.55f), verticalArrangement = Arrangement.spacedBy(6.dp), ) { bar(1f) bar(0.8f) } } } } }