git.iko.soy/opencode-android
on 61cdc75 help sign in
2790 lines 152 kB history raw
1@file:Suppress("TooManyFunctions")
3package soy.iko.opencode.ui.chat
5import android.app.Activity
6import android.content.Intent
7import android.net.Uri
8import android.speech.RecognizerIntent
9import androidx.activity.compose.BackHandler
10import androidx.activity.compose.rememberLauncherForActivityResult
11import androidx.activity.result.PickVisualMediaRequest
12import androidx.activity.result.contract.ActivityResultContracts
13import androidx.compose.animation.AnimatedVisibility
14import androidx.compose.animation.core.RepeatMode
15import androidx.compose.animation.core.animateFloat
16import androidx.compose.animation.core.infiniteRepeatable
17import androidx.compose.animation.core.rememberInfiniteTransition
18import androidx.compose.animation.core.tween
19import androidx.compose.foundation.background
20import androidx.compose.foundation.clickable
21import androidx.compose.foundation.content.contentReceiver
22import androidx.compose.foundation.layout.Arrangement
23import androidx.compose.foundation.layout.Box
24import androidx.compose.foundation.layout.Column
25import androidx.compose.foundation.layout.FlowRow
26import androidx.compose.foundation.layout.PaddingValues
27import androidx.compose.foundation.layout.Row
28import androidx.compose.foundation.layout.Spacer
29import androidx.compose.foundation.layout.WindowInsets
30import androidx.compose.foundation.layout.fillMaxSize
31import androidx.compose.foundation.layout.fillMaxWidth
32import androidx.compose.foundation.layout.height
33import androidx.compose.foundation.layout.imePadding
34import androidx.compose.foundation.layout.ime
35import androidx.compose.foundation.layout.navigationBars
36import androidx.compose.foundation.layout.padding
37import androidx.compose.foundation.layout.size
38import androidx.compose.foundation.layout.union
39import androidx.compose.foundation.layout.widthIn
40import androidx.compose.foundation.layout.windowInsetsPadding
41import androidx.compose.foundation.layout.wrapContentWidth
42import androidx.compose.foundation.lazy.LazyColumn
43import androidx.compose.foundation.lazy.items
44import androidx.compose.foundation.lazy.rememberLazyListState
45import androidx.compose.foundation.shape.RoundedCornerShape
46import androidx.compose.material.icons.Icons
47import androidx.compose.material.icons.automirrored.filled.ArrowBack
48import androidx.compose.material.icons.automirrored.filled.Send
49import androidx.compose.material.icons.filled.Add
50import androidx.compose.material.icons.filled.AutoAwesome
51import androidx.compose.material.icons.filled.Delete
52import androidx.compose.material.icons.filled.Description
53import androidx.compose.material.icons.filled.Close
54import androidx.compose.material.icons.filled.Edit
55import androidx.compose.material.icons.filled.Expand
56import androidx.compose.material.icons.filled.ExpandLess
57import androidx.compose.material.icons.filled.ExpandMore
58import androidx.compose.material.icons.filled.Search
59import androidx.compose.material.icons.filled.KeyboardArrowDown
60import androidx.compose.material.icons.filled.KeyboardArrowUp
61import androidx.compose.material.icons.filled.Mic
62import androidx.compose.material.icons.filled.MoreVert
63import androidx.compose.material.icons.filled.PhotoCamera
64import androidx.compose.material.icons.filled.PhotoLibrary
65import androidx.compose.material.icons.filled.Refresh
66import androidx.compose.material.icons.filled.Restore
67import androidx.compose.material.icons.automirrored.filled.PlaylistAdd
68import androidx.compose.material.icons.automirrored.filled.Reply
69import androidx.compose.material.icons.filled.Schedule
70import androidx.compose.material.icons.filled.Stop
71import androidx.compose.material3.AlertDialog
72import androidx.compose.material3.Badge
73import androidx.compose.material3.BadgedBox
74import androidx.compose.material3.Button
75import androidx.compose.material3.CircularProgressIndicator
76import androidx.compose.material3.SmallFloatingActionButton
77import androidx.compose.material3.DropdownMenu
78import androidx.compose.material3.DropdownMenuItem
79import androidx.compose.material3.ExperimentalMaterial3Api
80import androidx.compose.material3.ExtendedFloatingActionButton
81import androidx.compose.material3.FloatingActionButton
82import androidx.compose.material3.Icon
83import androidx.compose.material3.IconButton
84import androidx.compose.material3.MaterialTheme
85import androidx.compose.material3.OutlinedTextField
86import androidx.compose.material3.Scaffold
87import androidx.compose.material3.SnackbarDuration
88import androidx.compose.material3.SwipeToDismissBox
89import androidx.compose.material3.SwipeToDismissBoxValue
90import androidx.compose.material3.rememberSwipeToDismissBoxState
91import androidx.compose.material3.SnackbarHost
92import androidx.compose.material3.SnackbarHostState
93import androidx.compose.material3.SnackbarResult
94import androidx.compose.material3.Surface
95import androidx.compose.material3.Text
96import androidx.compose.material3.TextButton
97import androidx.compose.material3.TopAppBar
98import androidx.compose.material3.pulltorefresh.PullToRefreshBox
99import androidx.compose.runtime.Composable
100import androidx.compose.runtime.CompositionLocalProvider
101import androidx.compose.runtime.DisposableEffect
102import androidx.compose.runtime.LaunchedEffect
103import androidx.compose.runtime.derivedStateOf
104import androidx.compose.runtime.getValue
105import androidx.compose.runtime.mutableIntStateOf
106import androidx.compose.runtime.mutableLongStateOf
107import androidx.compose.runtime.mutableStateOf
108import androidx.compose.runtime.produceState
109import androidx.compose.runtime.remember
110import androidx.compose.runtime.rememberCoroutineScope
111import androidx.compose.runtime.rememberUpdatedState
112import androidx.compose.runtime.saveable.rememberSaveable
113import androidx.compose.runtime.setValue
114import androidx.compose.runtime.snapshotFlow
115import androidx.compose.foundation.text.KeyboardActions
116import androidx.compose.foundation.text.KeyboardOptions
117import androidx.compose.ui.text.input.KeyboardCapitalization
118import androidx.compose.ui.Alignment
119import androidx.compose.ui.Modifier
120import androidx.compose.ui.draw.alpha
121import androidx.compose.ui.draw.clip
122import androidx.compose.ui.focus.focusRequester
123import androidx.compose.ui.input.key.Key
124import androidx.compose.ui.input.key.KeyEventType
125import androidx.compose.ui.input.key.isCtrlPressed
126import androidx.compose.ui.input.key.isShiftPressed
127import androidx.compose.ui.input.key.key
128import androidx.compose.ui.input.key.onPreviewKeyEvent
129import androidx.compose.ui.input.key.type
130import androidx.compose.ui.layout.onSizeChanged
131import androidx.compose.ui.platform.LocalContext
132import androidx.compose.ui.platform.LocalDensity
133import androidx.compose.ui.platform.LocalHapticFeedback
134import androidx.compose.ui.platform.LocalView
135import androidx.compose.ui.platform.testTag
136import androidx.compose.ui.res.pluralStringResource
137import androidx.compose.ui.res.stringResource
138import androidx.compose.ui.semantics.Role
139import androidx.compose.ui.semantics.contentDescription
140import androidx.compose.ui.semantics.heading
141import androidx.compose.ui.semantics.invisibleToUser
142import androidx.compose.ui.semantics.semantics
143import androidx.compose.ui.text.input.ImeAction
144import androidx.compose.ui.text.style.TextOverflow
145import androidx.compose.ui.unit.dp
146import androidx.lifecycle.compose.collectAsStateWithLifecycle
147import androidx.lifecycle.viewmodel.compose.viewModel
148import kotlinx.coroutines.Dispatchers
149import kotlinx.coroutines.coroutineScope
150import kotlinx.coroutines.delay
151import kotlinx.coroutines.flow.distinctUntilChanged
152import kotlinx.coroutines.launch
153import kotlinx.coroutines.withContext
154import soy.iko.opencode.data.model.Part
155import soy.iko.opencode.data.model.MessageWithParts
156import soy.iko.opencode.data.model.ReasoningPart
157import soy.iko.opencode.data.model.TextPart
158import soy.iko.opencode.data.model.ToolCompleted
159import soy.iko.opencode.data.model.ToolError
160import soy.iko.opencode.data.model.ToolPart
161import soy.iko.opencode.data.model.ToolRunning
162import soy.iko.opencode.data.model.FilePart
163import soy.iko.opencode.data.model.sourcePath
164import soy.iko.opencode.data.model.inputElement
165import soy.iko.opencode.data.network.EventStreamClient
166import soy.iko.opencode.data.network.NetworkConfig
167import soy.iko.opencode.di.AppContainer
168import soy.iko.opencode.R
169import soy.iko.opencode.ui.components.CommandPalette
170import soy.iko.opencode.ui.components.ConnectionBanner
171import soy.iko.opencode.ui.components.DiffView
172import soy.iko.opencode.ui.components.PaletteAction
173import soy.iko.opencode.ui.components.LocalReducedMotion
174import soy.iko.opencode.ui.components.LocalSearchHighlight
175import soy.iko.opencode.ui.components.reducedMotionAnimateItem
176import soy.iko.opencode.ui.components.copyToClipboard
177import soy.iko.opencode.ui.components.LocalRelativeTimeTick
178import soy.iko.opencode.ui.components.rememberRelativeTimeTick
179import soy.iko.opencode.ui.components.rememberVisibilityTransitions
180import soy.iko.opencode.ui.components.showToast
181import soy.iko.opencode.ui.components.toImageContext
182import soy.iko.opencode.ui.vmFactory
183import soy.iko.opencode.util.runCatchingCancellable
185@OptIn(ExperimentalMaterial3Api::class)
186@Composable
187fun ChatScreen(
188 container: AppContainer,
189 sessionId: String,
190 onBack: () -> Unit,
191 onOpenFile: ((String) -> Unit)? = null,
192 onOpenSession: ((String) -> Unit)? = null,
193) {
194 val vm: ChatViewModel = viewModel(key = sessionId, factory = vmFactory { ChatViewModel(container, sessionId) })
195 val hasMessages by vm.hasMessages.collectAsStateWithLifecycle()
196 val running by vm.running.collectAsStateWithLifecycle()
197 val aborting by vm.aborting.collectAsStateWithLifecycle()
198 val loading by vm.loading.collectAsStateWithLifecycle()
199 val loadError by vm.loadError.collectAsStateWithLifecycle()
200 val refreshing by vm.refreshing.collectAsStateWithLifecycle()
201 val models by vm.models.collectAsStateWithLifecycle()
202 val modelsLoading by vm.modelsLoading.collectAsStateWithLifecycle()
203 val modelsError by vm.modelsError.collectAsStateWithLifecycle()
204 val selectedModel by vm.selectedModel.collectAsStateWithLifecycle()
205 val connectionState by vm.connectionState.collectAsStateWithLifecycle()
206 val pendingPermission by vm.pendingPermission.collectAsStateWithLifecycle()
207 val permissionProgress by vm.permissionProgress.collectAsStateWithLifecycle()
208 val agents by vm.agents.collectAsStateWithLifecycle()
209 val agentsLoading by vm.agentsLoading.collectAsStateWithLifecycle()
210 val agentsError by vm.agentsError.collectAsStateWithLifecycle()
211 val selectedAgent by vm.selectedAgent.collectAsStateWithLifecycle()
212 val commands by vm.commands.collectAsStateWithLifecycle()
213 val commandsLoading by vm.commandsLoading.collectAsStateWithLifecycle()
214 val commandsError by vm.commandsError.collectAsStateWithLifecycle()
215 val sessionTitle by vm.sessionTitle.collectAsStateWithLifecycle()
216 val sessionDeleted by vm.sessionDeleted.collectAsStateWithLifecycle()
217 val draft by vm.draft.collectAsStateWithLifecycle()
218 val attachments by vm.attachments.collectAsStateWithLifecycle()
219 val reverted by vm.reverted.collectAsStateWithLifecycle()
220 val revertDiff by vm.revertDiff.collectAsStateWithLifecycle()
221 val shareUrl by vm.shareUrl.collectAsStateWithLifecycle()
222 val reconnecting by vm.reconnecting.collectAsStateWithLifecycle()
223 val sendOnEnter by container.settingsStore.sendOnEnter.collectAsStateWithLifecycle(initialValue = true)
224 val preferredModelId by container.settingsStore.preferredModelId.collectAsStateWithLifecycle(initialValue = "")
225 val recentModelEntries by container.recentModelsStore.entries.collectAsStateWithLifecycle()
226 val compactSpacing by container.settingsStore.compactMessageSpacing.collectAsStateWithLifecycle(initialValue = false)
227 val isOnline by container.isOnline.collectAsStateWithLifecycle()
228 val haptics = LocalHapticFeedback.current
229 val scope = rememberCoroutineScope()
230 val shareContext = LocalContext.current
231 val defaultShareSubject = stringResource(R.string.share_subject)
232 val sessionLabel = stringResource(R.string.session)
233 val defaultModelLabel = stringResource(R.string.default_model)
234 val subtitle = remember(selectedModel?.modelLabel, selectedAgent, defaultModelLabel) {
235 buildString {
236 append(selectedModel?.modelLabel ?: defaultModelLabel)
237 selectedAgent?.let { append(" · $it") }
238 }
239 }
241 // Connection profile (with auth) for rendering inline image attachments.
242 // Collect as state so a server switch recomposes the image context.
243 val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
244 val imageContext = remember(activeConnection?.profile) {
245 activeConnection?.profile?.toImageContext()
246 }
248 // Mark this session as read while the user is viewing it; clear on leave so new
249 // background activity can badge it again.
250 DisposableEffect(sessionId) {
251 container.setCurrentSession(sessionId)
252 onDispose { if (container.currentSession.value == sessionId) container.setCurrentSession(null) }
253 }
255 // Navigate away when the session is deleted via SSE so the user isn't left
256 // on a zombie screen showing a conversation that no longer exists. Surface a
257 // toast (not a snackbar) because the screen is about to be popped — a snackbar
258 // would be torn down before the user could read it, while a toast survives.
259 // Covers both SSE-driven deletion (deleted on another device) and a user-
260 // initiated delete from the chat overflow menu (deleteSession in ChatViewModel
261 // sets the same flag on success).
262 val deletedContext = LocalContext.current
263 LaunchedEffect(sessionDeleted) {
264 if (sessionDeleted) {
265 showToast(deletedContext, deletedContext.getString(R.string.session_deleted_chat))
266 onBack()
267 }
268 }
270 val snackbar = remember { SnackbarHostState() }
271 val retryLabel = stringResource(R.string.retry)
272 val inputFocusRequester = remember { androidx.compose.ui.focus.FocusRequester() }
273 // Dedicated focus requester for the in-conversation search field. The overflow menu's
274 // "Find in conversation" and Ctrl+F previously called inputFocusRequester.requestFocus()
275 // (the composer), so typing went to the composer instead of the search field.
276 val searchFocusRequester = remember { androidx.compose.ui.focus.FocusRequester() }
277 var showModelPicker by rememberSaveable { mutableStateOf(false) }
278 var showAgentPicker by rememberSaveable { mutableStateOf(false) }
279 var showTitleMenu by rememberSaveable { mutableStateOf(false) }
280 var showCommandPicker by rememberSaveable { mutableStateOf(false) }
281 var showExitConfirm by rememberSaveable { mutableStateOf(false) }
282 var showStopConfirm by rememberSaveable { mutableStateOf(false) }
283 var showOverflowMenu by rememberSaveable { mutableStateOf(false) }
284 var showRenameDialog by rememberSaveable { mutableStateOf(false) }
285 var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
286 var showShellDialog by rememberSaveable { mutableStateOf(false) }
287 var showPalette by rememberSaveable { mutableStateOf(false) }
288 var showShortcutsDialog by rememberSaveable { mutableStateOf(false) }
289 var searchActive by rememberSaveable { mutableStateOf(false) }
290 var chatSearch by rememberSaveable { mutableStateOf("") }
291 // Index of the currently-focused in-conversation search match (into the filtered list).
292 // Drives the up/down navigation: the search bar's arrows step through matches and scroll
293 // the list to each one, mirroring desktop chat apps (the bare filtered list previously
294 // required reading each message to find the term).
295 var searchPos by rememberSaveable { mutableIntStateOf(0) }
296 // Summarize and init (generate AGENTS.md) are irreversible, so a single tap is gated behind
297 // a confirmation dialog (from both the overflow menu and the command palette).
298 var showSummarizeConfirm by rememberSaveable { mutableStateOf(false) }
299 var showInitConfirm by rememberSaveable { mutableStateOf(false) }
301 // Command-palette entries (keyboard-first: Ctrl+K). Labels resolved here so the remembered
302 // action list doesn't need a Composable context. Actions map to the same handlers the
303 // overflow menu drives, so the palette is a keyboard shortcut to them, not a parallel path.
304 val paletteModelLabel = stringResource(R.string.palette_switch_model)
305 val paletteAgentLabel = stringResource(R.string.palette_choose_agent)
306 val paletteCommandLabel = stringResource(R.string.palette_run_command)
307 val paletteSummarizeLabel = stringResource(R.string.palette_summarize)
308 val paletteRenameLabel = stringResource(R.string.rename_session_chat)
309 val paletteShellLabel = stringResource(R.string.run_shell_command)
310 val paletteActions = remember(
311 paletteModelLabel, paletteAgentLabel, paletteCommandLabel,
312 paletteSummarizeLabel, paletteRenameLabel, paletteShellLabel, hasMessages, running,
313 ) {
314 buildList {
315 add(PaletteAction("model", paletteModelLabel) { showModelPicker = true })
316 add(PaletteAction("agent", paletteAgentLabel) { showAgentPicker = true })
317 add(PaletteAction("command", paletteCommandLabel) { showCommandPicker = true })
318 if (hasMessages && !running) add(PaletteAction("summarize", paletteSummarizeLabel) { showSummarizeConfirm = true })
319 if (!running) add(PaletteAction("shell", paletteShellLabel) { showShellDialog = true })
320 add(PaletteAction("rename", paletteRenameLabel) { showRenameDialog = true })
321 }
322 }
324 // Keep the screen awake while the agent is working in *this* session. The foreground
325 // service that holds process priority during a run is managed app-wide off
326 // container.anyRunActive (see OpencodeApp) so it survives navigating away from the chat
327 // mid-run — otherwise leaving the screen would drop priority and Doze could choke the
328 // SSE stream before the completion notification fires.
329 val currentView = LocalView.current
330 val appContext = LocalContext.current.applicationContext
331 DisposableEffect(running) {
332 currentView.keepScreenOn = running
333 onDispose { currentView.keepScreenOn = false }
334 }
336 LaunchedEffect(Unit) {
337 vm.errorEvents.collect { event ->
338 // Only a failed *send* is retryable; attaching Retry to any other error (e.g. a
339 // message-load failure) would silently re-submit the last prompt on tap.
340 val result = if (event.retryable) {
341 snackbar.showSnackbar(message = event.message, actionLabel = retryLabel)
342 } else {
343 snackbar.showSnackbar(event.message)
344 }
345 if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) vm.retryFailed()
346 }
347 }
349 // In-place Undo for a user-initiated session delete. The delete is deferred for an undo
350 // window on the container; show the snackbar over the conversation the user is viewing so
351 // they can cancel without being navigated to the session list first. If the window expires
352 // the scheduled delete sets `sessionDeleted` and the effect below navigates away.
353 val undoLabel = stringResource(R.string.undo)
354 val sessionDeletedLabel = stringResource(R.string.session_deleted_chat)
355 LaunchedEffect(Unit) {
356 vm.deleteUndoEvents.collect {
357 coroutineScope {
358 val dismisser = launch {
359 delay(NetworkConfig.undoDeleteDelayMs)
360 snackbar.currentSnackbarData?.dismiss()
361 }
362 val result = snackbar.showSnackbar(
363 message = sessionDeletedLabel,
364 actionLabel = undoLabel,
365 duration = SnackbarDuration.Indefinite,
366 )
367 dismisser.cancel()
368 if (result == SnackbarResult.ActionPerformed) vm.cancelSessionDelete()
369 }
370 }
371 }
373 // --- Attachments, voice, and share-link plumbing ---
374 val attachTooLargeMsg = stringResource(R.string.attachment_too_large)
375 val attachFailedMsg = stringResource(R.string.attachment_failed)
376 val attachLimitMsg = stringResource(R.string.attachment_limit)
377 val noCameraMsg = stringResource(R.string.no_camera_app)
378 val noVoiceMsg = stringResource(R.string.no_voice_app)
379 val voicePrompt = stringResource(R.string.voice_prompt)
380 val linkCopiedMsg = stringResource(R.string.link_copied)
381 val ttsUnavailableMsg = stringResource(R.string.tts_unavailable)
383 // Count of picks currently being read + base64-encoded off the main thread, so the
384 // composer can show a staging placeholder immediately (chips only materialize once done).
385 var stagingCount by remember { mutableStateOf(0) }
386 // Total number of files still being staged across all in-flight batches, so the
387 // staging chip can show "Staging N files…" rather than a bare indeterminate spinner.
388 // A separate counter from [stagingCount] (which tracks batches, not files) so a
389 // multi-file pick reads as more work than a single-file pick.
390 var stagingFileTotal by remember { mutableStateOf(0) }
392 // Convert each picked Uri to a base64 attachment off the main thread, honoring the
393 // per-prompt count cap and surfacing per-file errors without aborting the batch.
394 fun stageUris(uris: List<Uri>) {
395 if (uris.isEmpty()) return
396 scope.launch {
397 stagingCount++
398 stagingFileTotal += uris.size
399 try {
400 uris.forEachIndexed { index, uri ->
401 if (vm.attachments.value.size >= NetworkConfig.maxAttachments) {
402 snackbar.showSnackbar(attachLimitMsg)
403 // The cap dropped the rest of this batch; account for the files we
404 // won't process so stagingFileTotal doesn't linger over-counted.
405 stagingFileTotal -= uris.size - index
406 return@forEachIndexed
407 }
408 when (val result = uri.toAttachmentResult(appContext)) {
409 is AttachmentResult.Ok -> vm.addAttachment(result.attachment)
410 AttachmentResult.TooLarge -> snackbar.showSnackbar(attachTooLargeMsg)
411 AttachmentResult.Failed -> snackbar.showSnackbar(attachFailedMsg)
412 }
413 stagingFileTotal--
414 }
415 } finally {
416 stagingCount--
417 }
418 }
419 }
421 val photoPicker = rememberLauncherForActivityResult(
422 ActivityResultContracts.PickMultipleVisualMedia(NetworkConfig.maxAttachments),
423 ) { uris -> stageUris(uris) }
425 val filePicker = rememberLauncherForActivityResult(
426 ActivityResultContracts.GetMultipleContents(),
427 ) { uris -> stageUris(uris) }
429 // Uri survives a config change mid-capture so the TakePicture result still resolves.
430 var pendingCaptureUri by rememberSaveable { mutableStateOf<Uri?>(null) }
431 val cameraLauncher = rememberLauncherForActivityResult(
432 ActivityResultContracts.TakePicture(),
433 ) { success ->
434 val uri = pendingCaptureUri
435 pendingCaptureUri = null
436 if (success && uri != null) stageUris(listOf(uri))
437 }
439 val voiceLauncher = rememberLauncherForActivityResult(
440 ActivityResultContracts.StartActivityForResult(),
441 ) { result ->
442 if (result.resultCode == Activity.RESULT_OK) {
443 val spoken = result.data
444 ?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)
445 ?.firstOrNull()
446 ?.takeIf { it.isNotBlank() }
447 if (spoken != null) {
448 // Append to the existing draft rather than replacing, so dictation adds to
449 // whatever the user already typed.
450 val current = vm.draft.value
451 val combined = if (current.isBlank()) spoken else "$current $spoken"
452 vm.updateDraft(combined.take(NetworkConfig.maxDraftLengthChars))
453 }
454 }
455 }
457 fun launchCamera() {
458 val uri = newCameraCaptureUri(appContext)
459 if (uri == null) {
460 scope.launch { snackbar.showSnackbar(attachFailedMsg) }
461 return
462 }
463 pendingCaptureUri = uri
464 runCatching { cameraLauncher.launch(uri) }
465 .onFailure { pendingCaptureUri = null; scope.launch { snackbar.showSnackbar(noCameraMsg) } }
466 }
468 fun launchVoice() {
469 val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
470 putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
471 putExtra(RecognizerIntent.EXTRA_PROMPT, voicePrompt)
472 }
473 runCatching { voiceLauncher.launch(intent) }
474 .onFailure { scope.launch { snackbar.showSnackbar(noVoiceMsg) } }
475 }
477 // A freshly-created share link is copied to the clipboard so the user can paste it right away.
478 LaunchedEffect(Unit) {
479 vm.shareLinkEvents.collect { url ->
480 copyToClipboard(shareContext, shareContext.getString(R.string.clip_label_share), url)
481 snackbar.showSnackbar(linkCopiedMsg)
482 }
483 }
485 // Navigate to a session freshly branched from a message (branchFrom), once it's created.
486 // Keyed on Unit (like the sibling one-shot collectors above): keying on the onOpenSession
487 // lambda would cancel/restart this collector whenever the lambda's identity changes, risking
488 // a dropped branch-navigation event in the gap.
489 val branchedMsg = stringResource(R.string.branch_created)
490 LaunchedEffect(Unit) {
491 vm.branchEvents.collect { newId ->
492 showToast(shareContext, branchedMsg)
493 onOpenSession?.invoke(newId)
494 }
495 }
497 // Stage images shared into the app (via the system share sheet) as attachments.
498 // Keyed on pendingSharedMedia (not Unit) so the effect re-fires when new media arrives
499 // while this ChatScreen is already composed — which happens in two-pane mode, where the
500 // detail ChatScreen is persistent (keyed on sessionId) and a LaunchedEffect(Unit) would
501 // only fire on first composition. consumePendingSharedMedia() atomically drains the
502 // pending list (compareAndSet to empty), so re-firing on the now-empty value is a no-op.
503 val pendingSharedMedia by container.pendingSharedMedia.collectAsStateWithLifecycle()
504 LaunchedEffect(pendingSharedMedia) {
505 val shared = container.consumePendingSharedMedia()
506 if (shared.isNotEmpty()) stageUris(shared.map { Uri.parse(it) })
507 }
509 fun doSend() {
510 if (vm.send(draft)) {
511 // TextHandleMove (a light tick) is semantically right for a send commit;
512 // LongPress is reserved for long-press/swipe gestures.
513 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
514 }
515 }
517 BackHandler(enabled = running && !showModelPicker && !showAgentPicker && !showCommandPicker) { showExitConfirm = true }
519 Scaffold(
520 // Hardware-keyboard shortcuts (tablets / DeX / Chromebooks): Ctrl+K opens the command
521 // palette; Escape closes it, or stops a run. onPreviewKeyEvent on the Scaffold root sees
522 // events before the focused text field, but only Ctrl+K / Escape are consumed so normal
523 // typing (and the composer's own Enter-to-send handling) is untouched.
524 modifier = Modifier.onPreviewKeyEvent { ev ->
525 if (ev.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
526 when {
527 ev.isCtrlPressed && ev.key == Key.K -> { showPalette = true; true }
528 // Ctrl+F opens in-conversation search (the standard find shortcut), focusing
529 // the field so the user can type immediately.
530 ev.isCtrlPressed && ev.key == Key.F -> { searchActive = true; true }
531 ev.key == Key.Escape && showPalette -> { showPalette = false; true }
532 ev.key == Key.Escape && searchActive -> { searchActive = false; chatSearch = ""; true }
533 ev.key == Key.Escape && running && !showStopConfirm && !showExitConfirm -> {
534 showStopConfirm = true; true
535 }
536 else -> false
537 }
538 },
539 topBar = {
540 TopAppBar(
541 title = {
542 val changeLabel = stringResource(R.string.change_model_agent)
543 // The subtitle reads "model · agent", so tapping the title offers *both*
544 // — a small menu picks which picker to open. Previously the tap went
545 // straight to the model picker, leaving the agent changeable only via
546 // the overflow menu despite the title implying otherwise.
547 Box {
548 Row(
549 // mergeDescendants so TalkBack reads the title/subtitle Texts as one
550 // node; the click label rides on `clickable` (onClickLabel) instead of
551 // an explicit contentDescription, which would have SUPPRESSED the child
552 // Texts and announced only "Change model and agent".
553 modifier = Modifier
554 .clickable(
555 role = Role.Button,
556 onClickLabel = changeLabel,
557 ) { showTitleMenu = true }
558 .semantics(mergeDescendants = true) {},
559 verticalAlignment = Alignment.CenterVertically,
560 ) {
561 Column(modifier = Modifier.weight(1f, fill = false)) {
562 // maxLines=1 + Ellipsis so a long session title can't wrap and
563 // push the top bar to multiple lines, shifting the whole layout.
564 Text(
565 sessionTitle ?: sessionLabel,
566 maxLines = 1,
567 overflow = TextOverflow.Ellipsis,
568 )
569 Text(
570 subtitle,
571 style = MaterialTheme.typography.labelSmall,
572 color = MaterialTheme.colorScheme.onSurfaceVariant,
573 maxLines = 1,
574 overflow = TextOverflow.Ellipsis,
575 )
576 }
577 // Always show the affordance so the title reads as tappable even when
578 // the model catalog failed to load (models empty). Without it the title
579 // is indistinguishable from plain text and the pickers are effectively
580 // undiscoverable — and the dropdown items already disable themselves
581 // while a catalog is loading or unavailable.
582 Icon(
583 Icons.Filled.KeyboardArrowDown,
584 contentDescription = null,
585 tint = MaterialTheme.colorScheme.onSurfaceVariant,
586 modifier = Modifier.size(20.dp),
587 )
588 }
589 DropdownMenu(expanded = showTitleMenu, onDismissRequest = { showTitleMenu = false }) {
590 DropdownMenuItem(
591 text = { Text(stringResource(R.string.palette_switch_model)) },
592 enabled = !modelsLoading || models.isNotEmpty(),
593 onClick = { showTitleMenu = false; showModelPicker = true },
594 )
595 DropdownMenuItem(
596 text = { Text(stringResource(R.string.palette_choose_agent)) },
597 enabled = !agentsLoading || agents.isNotEmpty(),
598 onClick = { showTitleMenu = false; showAgentPicker = true },
599 )
600 }
601 }
602 },
603 navigationIcon = {
604 IconButton(onClick = {
605 if (running) showExitConfirm = true else onBack()
606 }) {
607 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
608 }
609 },
610 actions = {
611 // At-a-glance run status in the top bar. When the user is scrolled
612 // up (jump-to-latest FAB visible), the input-bar Stop button and the
613 // trailing "working…" row are off-screen, so a top-bar spinner is the
614 // only signal the agent is still working.
615 if (running) {
616 val workingLabel = stringResource(R.string.working)
617 CircularProgressIndicator(
618 Modifier.size(18.dp).semantics { contentDescription = workingLabel },
619 strokeWidth = 2.dp,
620 )
621 }
622 // Manual refresh: forces an SSE reconnect, which re-seeds messages
623 // from REST. A recovery path when the stream silently drops and the
624 // auto-reconnect re-seed is slow or fails. Shows a brief spinner as
625 // immediate tap feedback — the SSE reconnect may not visibly change
626 // the connection state when already Connected, so without it the tap
627 // appears to do nothing.
628 val refreshLabel = stringResource(R.string.refresh)
629 IconButton(
630 onClick = { vm.refreshMessages() },
631 enabled = activeConnection != null && !refreshing,
632 ) {
633 if (refreshing) {
634 CircularProgressIndicator(
635 Modifier.size(18.dp).semantics { contentDescription = refreshLabel },
636 strokeWidth = 2.dp,
637 )
638 } else {
639 Icon(Icons.Filled.Refresh, contentDescription = refreshLabel)
640 }
641 }
642 // Overflow menu consolidates the less-frequent actions (share,
643 // commands, agent, model, rename, delete) so the top bar stays
644 // scannable on narrow phones — previously 5 icons crowded the bar
645 // alongside the back button and tappable title. Catalog-loading
646 // spinners stay inline (next to the overflow) so a load state is
647 // visible without opening the menu.
648 val moreLabel = stringResource(R.string.more)
649 val loadingLabel = stringResource(R.string.loading)
650 val shareLabel = stringResource(R.string.share_conversation)
651 val copyAsMarkdownLabel = stringResource(R.string.copy_as_markdown)
652 val commandsLabel = stringResource(R.string.commands)
653 val renameLabel = stringResource(R.string.rename_session_chat)
654 val deleteLabel = stringResource(R.string.delete_session_chat)
655 val createShareLabel = stringResource(R.string.create_share_link)
656 val copyShareLabel = stringResource(R.string.copy_share_link)
657 val stopShareLabel = stringResource(R.string.stop_sharing)
658 val summarizeLabel = stringResource(R.string.summarize_conversation)
659 val initLabel = stringResource(R.string.generate_agents_md)
660 val shellLabel = stringResource(R.string.run_shell_command)
661 Box {
662 IconButton(onClick = { showOverflowMenu = true }) {
663 Icon(Icons.Filled.MoreVert, contentDescription = moreLabel)
664 }
665 DropdownMenu(
666 expanded = showOverflowMenu,
667 onDismissRequest = { showOverflowMenu = false },
668 ) {
669 DropdownMenuItem(
670 text = { Text(shareLabel) },
671 enabled = hasMessages,
672 onClick = {
673 showOverflowMenu = false
674 scope.launch {
675 val md = withContext(Dispatchers.Default) {
676 buildConversationMarkdown(vm.messages.value, sessionTitle)
677 }
678 val send = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
679 type = "text/markdown"
680 putExtra(android.content.Intent.EXTRA_SUBJECT, sessionTitle ?: defaultShareSubject)
681 putExtra(android.content.Intent.EXTRA_TEXT, md)
682 }
683 runCatchingCancellable { shareContext.startActivity(android.content.Intent.createChooser(send, shareLabel)) }
684 .onFailure { showToast(shareContext, shareContext.getString(R.string.no_share_app)) }
685 }
686 },
687 )
688 // Copy the whole conversation as Markdown to the clipboard, so a
689 // user can paste into another app without the share-sheet round-trip.
690 DropdownMenuItem(
691 text = { Text(copyAsMarkdownLabel) },
692 enabled = hasMessages,
693 onClick = {
694 showOverflowMenu = false
695 scope.launch {
696 val md = withContext(Dispatchers.Default) {
697 buildConversationMarkdown(vm.messages.value, sessionTitle)
698 }
699 copyToClipboard(shareContext, shareContext.getString(R.string.clip_label_message), md)
700 }
701 },
702 )
703 DropdownMenuItem(
704 text = { Text(commandsLabel) },
705 enabled = !commandsLoading || commands.isNotEmpty(),
706 onClick = { showOverflowMenu = false; showCommandPicker = true },
707 )
708 DropdownMenuItem(
709 text = { Text(stringResource(R.string.keyboard_shortcuts)) },
710 onClick = { showOverflowMenu = false; showShortcutsDialog = true },
711 )
712 DropdownMenuItem(
713 text = { Text(stringResource(R.string.find_in_conversation)) },
714 onClick = {
715 showOverflowMenu = false
716 searchActive = true
717 // Focus is requested by the LaunchedEffect(searchActive)
718 // below, which waits for the search field to be composed
719 // before requesting focus (calling requestFocus here would
720 // target a field that isn't in the composition yet).
721 },
722 )
723 androidx.compose.material3.HorizontalDivider()
724 // Session sharing: create a public link, copy an existing one, or revoke it.
725 if (shareUrl == null) {
726 DropdownMenuItem(
727 text = { Text(createShareLabel) },
728 enabled = hasMessages,
729 onClick = { showOverflowMenu = false; vm.shareSession() },
730 )
731 } else {
732 DropdownMenuItem(
733 text = { Text(copyShareLabel) },
734 onClick = {
735 showOverflowMenu = false
736 shareUrl?.let {
737 copyToClipboard(shareContext, shareContext.getString(R.string.clip_label_share), it)
738 scope.launch { snackbar.showSnackbar(linkCopiedMsg) }
739 }
740 },
741 )
742 DropdownMenuItem(
743 text = { Text(stopShareLabel) },
744 onClick = { showOverflowMenu = false; vm.unshareSession() },
745 )
746 }
747 DropdownMenuItem(
748 text = { Text(summarizeLabel) },
749 enabled = hasMessages && !running,
750 onClick = { showOverflowMenu = false; showSummarizeConfirm = true },
751 )
752 DropdownMenuItem(
753 text = { Text(initLabel) },
754 enabled = !running,
755 onClick = { showOverflowMenu = false; showInitConfirm = true },
756 )
757 DropdownMenuItem(
758 text = { Text(shellLabel) },
759 enabled = !running,
760 onClick = { showOverflowMenu = false; showShellDialog = true },
761 )
762 androidx.compose.material3.HorizontalDivider()
763 DropdownMenuItem(
764 text = { Text(renameLabel) },
765 onClick = { showOverflowMenu = false; showRenameDialog = true },
766 )
767 DropdownMenuItem(
768 text = { Text(deleteLabel, color = MaterialTheme.colorScheme.error) },
769 onClick = { showOverflowMenu = false; showDeleteDialog = true },
770 )
771 }
772 // While a catalog is initially loading (empty list + loading flag),
773 // show a tiny inline spinner beside the overflow so the user can see
774 // a load is in progress without opening the menu. Once loaded the
775 // spinner disappears; the menu items are always enabled (tapping a
776 // picker with no items shows the "no models/agents/commands" state).
777 val catalogLoading = (commandsLoading && commands.isEmpty()) ||
778 (agentsLoading && agents.isEmpty()) ||
779 (modelsLoading && models.isEmpty())
780 if (catalogLoading) {
781 CircularProgressIndicator(
782 Modifier
783 .align(Alignment.TopEnd)
784 .size(10.dp)
785 .semantics { contentDescription = loadingLabel },
786 strokeWidth = 1.dp,
787 )
788 }
789 }
790 },
791 )
792 },
793 snackbarHost = { SnackbarHost(snackbar) },
794 bottomBar = {
795 val queuedFollowUp by vm.queuedFollowUp.collectAsStateWithLifecycle()
796 val todoPlan by vm.todoPlan.collectAsStateWithLifecycle()
797 val queuedOffline by vm.outbox.collectAsStateWithLifecycle()
798 val outboxSending by container.outboxSending.collectAsStateWithLifecycle()
799 Column {
800 // Pinned above the composer so the agent's plan and progress stay visible
801 // while scrolled up mid-run; renders nothing when there's no plan.
802 TodoPlanBar(todoPlan)
803 val bannerMotion = rememberVisibilityTransitions()
804 AnimatedVisibility(
805 visible = reverted,
806 enter = bannerMotion.enter,
807 exit = bannerMotion.exit,
808 ) {
809 RevertBanner(
810 diff = revertDiff,
811 onUndo = { vm.unrevert() },
812 )
813 }
814 AnimatedVisibility(
815 visible = queuedOffline.isNotEmpty(),
816 enter = bannerMotion.enter,
817 exit = bannerMotion.exit,
818 ) {
819 OutboxBanner(
820 count = queuedOffline.size,
821 sending = outboxSending,
822 onFlush = { vm.flushQueued() },
823 onDiscard = { vm.discardAllQueued() },
824 )
825 }
826 ChatInputBar(
827 value = draft,
828 onValueChange = vm::updateDraft,
829 running = running,
830 aborting = aborting,
831 enabled = activeConnection != null,
832 sendOnEnter = sendOnEnter,
833 onSend = ::doSend,
834 onAbort = { showStopConfirm = true },
835 queuedFollowUp = queuedFollowUp,
836 onQueueFollowUp = vm::queueFollowUp,
837 onCancelQueue = { vm.queueFollowUp("") },
838 focusRequester = inputFocusRequester,
839 attachments = attachments,
840 staging = stagingCount > 0,
841 stagingFileCount = stagingFileTotal,
842 onRemoveAttachment = vm::removeAttachment,
843 onPickPhoto = {
844 photoPicker.launch(
845 PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly),
846 )
847 },
848 onPickFile = { filePicker.launch("*/*") },
849 onCamera = { launchCamera() },
850 onVoice = { launchVoice() },
851 onPasteImage = { stageUris(it) },
852 )
853 }
854 },
855 ) { padding ->
856 // Collect messages inside the content lambda so streaming token updates
857 // recompose only this subtree, not the top bar / input bar / 20+ other
858 // state reads in the ChatScreen body.
859 val messages by vm.messages.collectAsStateWithLifecycle()
860 val optimisticStatuses by vm.optimisticStatuses.collectAsStateWithLifecycle()
861 val listState = rememberLazyListState()
862 val contentScope = rememberCoroutineScope()
863 val timeTick = rememberRelativeTimeTick()
864 // Dismiss the soft keyboard when the user scrolls the message list, a standard Android
865 // chat affordance for reading while the IME is open. Watching isScrollInProgress via
866 // snapshotFlow avoids wiring a nested scroll connection and fires on any scroll gesture.
867 val keyboardController = androidx.compose.ui.platform.LocalSoftwareKeyboardController.current
868 androidx.compose.runtime.LaunchedEffect(listState) {
869 androidx.compose.runtime.snapshotFlow { listState.isScrollInProgress }
870 .distinctUntilChanged()
871 .collect { scrolling -> if (scrolling) keyboardController?.hide() }
872 } // Group messages by day and interleave date separators so a long conversation
873 // has visual "Today"/"Yesterday"/date breaks. Computed once per messages
874 // emission (memoized) so a scroll-induced recomposition doesn't re-scan. Lives
875 // outside the PullToRefreshBox so the auto-scroll effects below can reference
876 // the same list the LazyColumn renders (separators shift the indices vs. the
877 // raw messages list, so scroll targets must be in listItems space).
878 val todayLabel = stringResource(R.string.today)
879 val yesterdayLabel = stringResource(R.string.yesterday)
880 // In-conversation search: when active, narrow to messages whose text contains the query.
881 // Computed on the filtered set so day separators still align with the visible messages.
882 val searchQuery = chatSearch.trim()
883 val searchMessages = remember(messages, searchQuery) {
884 if (searchQuery.isEmpty()) messages
885 else messages.filter { m ->
886 m.parts.any { p ->
887 when (p) {
888 is TextPart -> p.text.contains(searchQuery, ignoreCase = true)
889 is ReasoningPart -> p.text.contains(searchQuery, ignoreCase = true)
890 is ToolPart -> {
891 // Match the tool name plus its running/completed/error text and the
892 // stringified input, so searching for an error string or a file the
893 // tool touched actually finds the message it appeared in.
894 p.tool.contains(searchQuery, ignoreCase = true) ||
895 (p.state is ToolRunning && p.state.title?.contains(searchQuery, ignoreCase = true) == true) ||
896 (p.state is ToolCompleted && (p.state.title?.contains(searchQuery, ignoreCase = true) == true ||
897 p.state.output?.contains(searchQuery, ignoreCase = true) == true)) ||
898 (p.state is ToolError && p.state.error?.contains(searchQuery, ignoreCase = true) == true) ||
899 p.state.inputElement()?.toString()?.contains(searchQuery, ignoreCase = true) == true
900 }
901 is FilePart -> p.filename?.contains(searchQuery, ignoreCase = true) == true ||
902 p.sourcePath?.contains(searchQuery, ignoreCase = true) == true
903 else -> false
904 }
905 }
906 }
907 }
908 // Keep the focused-match index in range as the query (and thus the match set) changes.
909 // A new query resets to the first match; a shrunk set clamps to the last valid index.
910 LaunchedEffect(searchQuery, searchMessages.size) {
911 if (searchPos >= searchMessages.size) searchPos = 0
912 }
913 val listItems = remember(searchMessages, todayLabel, yesterdayLabel) {
914 buildMessageListItems(searchMessages, todayLabel, yesterdayLabel)
915 }
916 // listItems is a plain (non-snapshot) local, rebuilt each recomposition. The
917 // derivedStateOf and snapshotFlow lambdas below are created once (keyless
918 // remember / LaunchedEffect(Unit)); reading `listItems` directly inside them
919 // would capture the first composition's list — which is empty, since `messages`
920 // starts empty — freezing it forever. That pinned isPinnedToBottom at true and
921 // AutoScrollSignal.size at 0, breaking streaming auto-scroll and the
922 // jump-to-latest FAB. rememberUpdatedState hands those lambdas a stable State
923 // whose value tracks the latest list.
924 val currentListItems by rememberUpdatedState(listItems)
926 // New-content signal for the jump-to-latest FAB: counts how many new items arrived
927 // while the user was scrolled away from the bottom, cleared once they return. A count
928 // (vs. a bare dot) tells the user how much they missed at a glance.
929 var newContentCount by remember { mutableIntStateOf(0) }
931 val isPinnedToBottom by remember {
932 derivedStateOf {
933 val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
934 // When a run is active, the LazyColumn has one trailing "__typing"
935 // item after the last list item. Account for it so "pinned to bottom"
936 // includes the working indicator — otherwise the pin check targets
937 // listItems.lastIndex, the typing row sits just below the viewport,
938 // and the user never sees the progress indicator even when pinned.
939 val items = currentListItems
940 val effectiveLast = if (running) items.size else items.lastIndex
941 lastVisible >= effectiveLast || items.isEmpty()
942 }
943 }
945 // Jump to the newest message once, when the conversation first loads. Guarded by a
946 // saveable one-shot flag so it doesn't re-fire on recomposition after a config change
947 // (rotation) — rememberLazyListState restores the user's scroll offset, and an
948 // unconditional scrollToItem here would clobber it, snapping them back to the bottom.
949 var didInitialScroll by rememberSaveable { mutableStateOf(false) }
950 LaunchedEffect(listItems.isNotEmpty()) {
951 if (listItems.isNotEmpty() && !didInitialScroll) {
952 // When a run is active the LazyColumn has a trailing "__typing" row at
953 // listItems.size; target it (not listItems.lastIndex) so deep-linking into a
954 // running session lands truly pinned to bottom and streaming auto-scroll engages.
955 listState.scrollToItem(if (running) listItems.size else listItems.lastIndex)
956 didInitialScroll = true
957 }
958 }
960 // When a run starts, the LazyColumn gains a trailing "__typing" row, so
961 // effectiveLast jumps from listItems.lastIndex to listItems.size and the
962 // isPinnedToBottom check flips to false the instant you send — freezing
963 // auto-scroll. If the user was pinned to the last item just before the run
964 // began, bring the typing row into view so the pin (and streaming follow) is
965 // preserved. Guard on the raw last-visible index (isPinnedToBottom has already
966 // recomputed to false by now) so we don't scroll when the user had scrolled up.
967 LaunchedEffect(running) {
968 if (running && listItems.isNotEmpty()) {
969 val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
970 if (lastVisible >= listItems.lastIndex) {
971 runCatchingCancellable { listState.animateScrollToItem(listItems.size) }
972 }
973 }
974 }
976 // Scroll the focused search match into view whenever the user steps through results with
977 // the up/down arrows. Resolves the filtered message to its slot in listItems (which
978 // includes date separators) and animates there.
979 LaunchedEffect(searchPos, searchQuery, listItems) {
980 if (!searchActive || searchQuery.isEmpty()) return@LaunchedEffect
981 val target = searchMessages.getOrNull(searchPos) ?: return@LaunchedEffect
982 val index = listItems.indexOfFirst {
983 it is MessageListItem.Message && it.message.info.id == target.info.id
984 }
985 if (index >= 0) runCatchingCancellable { listState.animateScrollToItem(index) }
986 }
988 // Scroll to bottom when the IME opens and the user was already pinned to bottom, so the
989 // latest reply stays visible as the composer rises. Without this the IME can obscure the
990 // last message even though the user was reading it a moment ago. WindowInsets.ime is a
991 // @Composable property, so we read it inside a snapshotFlow registered in the composable
992 // scope; the flow re-emits whenever the IME inset changes (open/close transitions).
993 val density = androidx.compose.ui.platform.LocalDensity.current
994 var wasImeOpen by remember { mutableStateOf(false) }
995 val imeBottomPx = WindowInsets.ime.getBottom(density)
996 androidx.compose.runtime.LaunchedEffect(imeBottomPx, listItems.isNotEmpty(), isPinnedToBottom) {
997 if (listItems.isEmpty()) return@LaunchedEffect
998 val imeOpen = imeBottomPx > 0
999 // Scroll to bottom only on the open transition (not close) and only when the user
1000 // was already pinned to bottom — otherwise we'd yank them out of a mid-history read.
1001 val shouldScroll = imeOpen && !wasImeOpen && isPinnedToBottom
1002 if (shouldScroll) {
1003 runCatchingCancellable {
1004 listState.animateScrollToItem(if (running) listItems.size else listItems.lastIndex)
1007 wasImeOpen = imeOpen
1010 LaunchedEffect(Unit) {
1011 var prevSize = 0
1012 var prevLen = 0
1013 snapshotFlow {
1014 // Track list size + the streaming length of the last part so we auto-scroll
1015 // as content arrives, plus the pinned flag. A small data class with primitive
1016 // fields avoids the per-frame boxing that Triple<Int,Int,Boolean> pays
1017 // (snapshotFlow re-evaluates this lambda every snapshot), and reading only the
1018 // last part's length is O(1) vs. a sumOf over every part. Covering reasoning
1019 // and tool output — not just TextPart — keeps a pinned view following a long
1020 // reasoning/tool block while it streams (previously it stalled at 0).
1021 val lastLen = streamingContentLength(messages.lastOrNull()?.parts?.lastOrNull())
1022 AutoScrollSignal(currentListItems.size, lastLen, isPinnedToBottom)
1023 }.collect { signal ->
1024 if (signal.pinned) {
1025 // Back at the bottom: clear the new-content badge.
1026 newContentCount = 0
1027 if (signal.size > 0) {
1028 // Scroll to the effective last index, including the trailing
1029 // "__typing" row when a run is active so the working indicator
1030 // is brought into view (not just the last message).
1031 val items = currentListItems
1032 val target = if (running) items.size else items.lastIndex
1033 listState.scrollToItem(target)
1035 } else if (signal.size > prevSize || signal.lastTextLength > prevLen) {
1036 // Content arrived while scrolled up — increment the jump-to-latest badge.
1037 // Only count whole new items (not per-token growth) so the number reflects
1038 // new messages, not a token count.
1039 if (signal.size > prevSize) newContentCount += signal.size - prevSize
1040 else if (newContentCount == 0) newContentCount = 1
1042 prevSize = signal.size
1043 prevLen = signal.lastTextLength
1047 CompositionLocalProvider(LocalRelativeTimeTick provides timeTick) {
1048 // The bottomBar (ChatInputBar) already has imePadding, and the Scaffold's
1049 // content padding accounts for the bottomBar's raised height, so the message
1050 // list is already above the keyboard. Adding imePadding here would double-apply
1051 // the IME inset and push messages too far up.
1052 Box(modifier = Modifier.fillMaxSize().padding(padding)) {
1053 if (activeConnection == null) {
1054 Column(
1055 modifier = Modifier.align(Alignment.Center).padding(24.dp),
1056 horizontalAlignment = Alignment.CenterHorizontally,
1057 ) {
1058 Text(
1059 stringResource(R.string.not_connected),
1060 color = MaterialTheme.colorScheme.error,
1062 Spacer(Modifier.size(12.dp))
1063 Button(onClick = { vm.reconnect() }, enabled = !reconnecting) {
1064 if (reconnecting) {
1065 CircularProgressIndicator(
1066 Modifier.size(20.dp),
1067 color = MaterialTheme.colorScheme.onPrimary,
1068 strokeWidth = 2.dp,
1070 } else {
1071 Text(stringResource(R.string.reconnect))
1075 } else {
1076 val bannerVisible = !isOnline || connectionState != EventStreamClient.ConnectionState.Connected
1077 // Measure the banner's actual height instead of hardcoding 44dp. At large
1078 // accessibility font scales the banner's labelMedium text + spinner + Retry
1079 // button exceed 44dp, so a fixed inset lets the first message hide behind it
1080 // (the same hazard FileViewScreen's overlay calls out). onSizeChanged only
1081 // fires while the banner is composed (visible), so gate the inset on
1082 // bannerVisible and fall back to 44dp until the first measurement arrives.
1083 var bannerHeightPx by remember { mutableIntStateOf(0) }
1084 val density = LocalDensity.current
1085 val topPad = if (bannerVisible) {
1086 if (bannerHeightPx > 0) {
1087 with(density) { bannerHeightPx.toDp() } + 4.dp
1088 } else {
1089 44.dp
1091 } else {
1092 16.dp
1094 ConnectionBanner(
1095 state = connectionState,
1096 modifier = Modifier.align(Alignment.TopCenter).onSizeChanged { bannerHeightPx = it.height },
1097 isOnline = isOnline,
1098 // On a hard failure, retry by forcing an SSE reconnect (which
1099 // re-seeds from REST). refreshMessages is the right recovery path
1100 // when the connection is present but the stream died; reconnect()
1101 // is the path when the whole connection is gone (handled by the
1102 // separate "Not connected" state below).
1103 onRetry = { vm.refreshMessages() },
1105 // Pull-to-refresh wraps ALL content states (loading, error, empty, list) so
1106 // recovery is a swipe away everywhere — not just when the message list is
1107 // populated. Previously PTR was only around the populated list, leaving the
1108 // error/loading states reachable only via a button tap.
1109 PullToRefreshBox(
1110 isRefreshing = refreshing,
1111 onRefresh = { vm.refreshMessages() },
1112 // Cap width and center on large screens for readability — full-width
1113 // bubbles stretch edge-to-edge on tablets, hurting line-length comfort.
1114 modifier = Modifier.fillMaxSize().wrapContentWidth(Alignment.CenterHorizontally)
1115 .widthIn(max = NetworkConfig.chatContentMaxWidthDp.dp),
1116 ) {
1117 // Reserve top space when the banner is visible so it doesn't overlap the
1118 // first message / state content. (topPad computed above.)
1119 if (loading && messages.isEmpty()) {
1120 // Skeleton bubbles pre-structure the layout so the perceived load is faster
1121 // than a bare centered spinner and the first real messages pop into an
1122 // already-shaped list instead of a blank-then-pop.
1123 ChatSkeleton(topPad = topPad)
1124 } else if (loadError && messages.isEmpty()) {
1125 // A failed load with nothing to show. Distinct from the empty
1126 // conversation state below — the conversation isn't empty, it
1127 // failed to load, and offering "Start a conversation" here is
1128 // misleading. Offer a Retry instead, mirroring SessionListScreen.
1129 Column(
1130 modifier = Modifier.align(Alignment.Center).padding(top = topPad).padding(24.dp),
1131 horizontalAlignment = Alignment.CenterHorizontally,
1132 ) {
1133 Text(
1134 stringResource(R.string.load_messages_failed),
1135 color = MaterialTheme.colorScheme.error,
1137 Spacer(Modifier.size(12.dp))
1138 Button(onClick = { vm.refreshMessages() }) {
1139 Text(stringResource(R.string.retry))
1142 } else if (messages.isEmpty() && running) {
1143 // A run just started but no parts have arrived yet — the empty list with
1144 // only the trailing "working…" row looks broken. Reuse the skeleton bubbles
1145 // from the initial-load branch so the perceived start latency is lower and
1146 // the first real assistant part pops into an already-shaped list.
1147 ChatSkeleton(topPad = topPad)
1148 } else if (messages.isEmpty()) {
1149 EmptyConversation(
1150 onSuggestion = {
1151 vm.updateDraft(it)
1152 // Focus the input so the user can edit the suggestion before
1153 // sending, instead of having to manually tap the field.
1154 runCatching { inputFocusRequester.requestFocus() }
1155 },
1156 modifier = Modifier.align(Alignment.Center).padding(top = topPad),
1158 } else {
1159 val lastMessageId = messages.lastOrNull()?.info?.id
1160 // Text-to-speech for reading assistant replies aloud; shuts down when the
1161 // message list leaves composition.
1162 val tts = rememberTtsController()
1163 val speakingMessageId by tts.speakingId
1164 val ttsState by tts.state
1165 // Provide the in-conversation search query to the markdown renderer so matching
1166 // text spans are highlighted in-place (in addition to the focused-row highlight
1167 // above). Scoped to the list so cleared search stops highlighting immediately.
1168 val activeSearchQuery = searchQuery.takeIf { searchActive && it.isNotEmpty() }
1169 CompositionLocalProvider(LocalSearchHighlight provides activeSearchQuery) {
1170 LazyColumn(
1171 state = listState,
1172 modifier = Modifier.fillMaxSize(),
1173 // Extra bottom padding reserves room for the jump-to-latest FAB so it
1174 // never floats over the last message. Top padding grows when the
1175 // connection banner is visible so it doesn't overlap the first message.
1176 contentPadding = PaddingValues(
1177 start = 16.dp,
1178 top = topPad,
1179 end = 16.dp,
1180 bottom = 16.dp + NetworkConfig.chatListFabInsetDp.dp,
1181 ),
1182 // Compact spacing (8dp) vs the default 16dp — a power-user toggle to fit
1183 // more conversation on screen without scrolling as much.
1184 verticalArrangement = Arrangement.spacedBy(if (compactSpacing) 8.dp else 16.dp),
1185 ) {
1186 items(
1187 items = listItems,
1188 key = { it.key },
1189 contentType = { it.contentType },
1190 ) { item ->
1191 // When in-conversation search is stepping through matches, highlight the
1192 // focused one's row so the user can spot it at a glance among the filtered
1193 // results (full in-text term highlighting would require threading the query
1194 // through the markdown renderer; the row highlight + scroll-to-match covers
1195 // the navigation use case desktop chat apps solve with next/prev).
1196 val focusedMatchId = if (searchActive && searchQuery.isNotEmpty()) {
1197 searchMessages.getOrNull(searchPos)?.info?.id
1198 } else null
1199 val isFocusedMatch = focusedMatchId != null &&
1200 item is MessageListItem.Message &&
1201 item.message.info.id == focusedMatchId
1202 // Default placement animation so inserted/moved rows glide in. Skipped
1203 // entirely under reduced motion so a streaming message growing in place
1204 // doesn't animate on every token (and respects the a11y preference).
1205 Box(
1206 Modifier
1207 .then(reducedMotionAnimateItem())
1208 .then(
1209 if (isFocusedMatch) {
1210 Modifier.background(
1211 MaterialTheme.colorScheme.secondaryContainer,
1212 MaterialTheme.shapes.medium,
1214 } else {
1215 Modifier
1216 },
1217 ),
1218 ) {
1219 when (item) {
1220 is MessageListItem.Separator -> DateSeparator(item.label)
1221 is MessageListItem.Message -> {
1222 val message = item.message
1223 // Only the last (streaming) message needs isRunning — it drives the
1224 // reasoning-block spinner. Passing the live flag to every bubble
1225 // makes all visible messages recompose whenever a run starts or stops.
1226 val modelLabel = remember(message.info, models) {
1227 (message.info as? soy.iko.opencode.data.model.AssistantMessage)
1228 ?.let { resolveModelLabel(it, models) }
1230 val agentLabel = remember(message.parts) {
1231 message.parts
1232 .filterIsInstance<soy.iko.opencode.data.model.AgentPart>()
1233 .firstOrNull { it.name.isNotBlank() }?.name
1235 // Text used to drive swipe-to-reply (and pre-fill the quote). Empty for
1236 // image/code-only messages, which then opt out of the swipe gesture.
1237 val quoteText = remember(message.parts) {
1238 message.parts
1239 .filterIsInstance<TextPart>()
1240 .joinToString("\n\n") { it.text }
1241 .trim()
1242 .takeIf { it.isNotEmpty() }
1244 // Swipe-start-to-end reveals a reply affordance and triggers quote-reply,
1245 // snapping back (the message isn't dismissed — the quote fills the composer).
1246 // Only enabled when there's text to quote, so non-text messages don't
1247 // capture horizontal drags (e.g. inside a horizontally-scrollable code block).
1248 val swipeState = rememberSwipeToDismissBoxState(
1249 confirmValueChange = { value ->
1250 if (value == SwipeToDismissBoxValue.StartToEnd && quoteText != null) {
1251 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
1252 vm.quoteReply(quoteText)
1253 runCatching { inputFocusRequester.requestFocus() }
1255 false
1256 },
1258 SwipeToDismissBox(
1259 state = swipeState,
1260 enableDismissFromEndToStart = false,
1261 enableDismissFromStartToEnd = quoteText != null,
1262 backgroundContent = {
1263 Box(
1264 Modifier.fillMaxSize().padding(horizontal = 16.dp),
1265 contentAlignment = Alignment.CenterStart,
1266 ) {
1267 Icon(
1268 Icons.AutoMirrored.Filled.Reply,
1269 contentDescription = null,
1270 tint = MaterialTheme.colorScheme.onSurfaceVariant,
1273 },
1274 ) {
1275 MessageBubble(
1276 message,
1277 isRunning = running && message.info.id == lastMessageId,
1278 imageContext = imageContext,
1279 modelLabel = modelLabel,
1280 agentLabel = agentLabel,
1281 onOpenFile = onOpenFile,
1282 onRevert = { vm.revertTo(message.info.id) },
1283 onEdit = { text ->
1284 // editMessage reverts to before this message (hiding it and
1285 // everything after); the revert banner surfaces the rewind
1286 // with an Undo. Focus + scroll the composer into view like the
1287 // Quote action so the prefilled draft isn't off-screen.
1288 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
1289 vm.editMessage(message.info.id, text)
1290 runCatching { inputFocusRequester.requestFocus() }
1291 contentScope.launch {
1292 runCatchingCancellable {
1293 listState.animateScrollToItem(listItems.lastIndex.coerceAtLeast(0))
1296 },
1297 onSpeak = { text ->
1298 if (!tts.toggle(message.info.id, text)) {
1299 // TTS engine unavailable (init failed / not yet ready
1300 // / blank text): surface feedback so the button doesn't
1301 // appear dead. Throttled so a rapid double-tap (stop
1302 // then immediate re-tap before ready) doesn't spam.
1303 scope.launch { snackbar.showSnackbar(ttsUnavailableMsg) }
1305 },
1306 isSpeaking = message.info.id == speakingMessageId,
1307 ttsState = if (message.info.id == speakingMessageId) ttsState else TtsState.IDLE,
1308 onPause = { tts.pause() },
1309 onResume = { tts.resume() },
1310 onStop = { tts.stop() },
1311 onQuote = { text ->
1312 vm.quoteReply(text)
1313 runCatching { inputFocusRequester.requestFocus() }
1314 },
1315 onBranch = { text -> vm.branchFrom(text) },
1316 // Regenerate is only meaningful for a finished assistant reply
1317 // (not the actively-streaming one), so gate it on !running.
1318 onRegenerate = if (!running) {
1319 { vm.regenerate(message.info.id) }
1320 } else null,
1321 sendStatus = optimisticStatuses[message.info.id]?.let { failed ->
1322 if (failed) soy.iko.opencode.ui.chat.MessageSendStatus.FAILED
1323 else soy.iko.opencode.ui.chat.MessageSendStatus.SENDING
1324 },
1325 isEdited = message.info.time?.let { it.updated != null && it.updated != it.created } == true,
1326 onRetry = optimisticStatuses[message.info.id]?.let { failed ->
1327 if (failed) {
1328 { vm.retryOptimisticMessage(message.info.id) }
1329 } else {
1330 null
1332 },
1333 onDismiss = optimisticStatuses[message.info.id]?.let { failed ->
1334 if (failed) {
1335 { vm.dismissOptimistic(message.info.id) }
1336 } else {
1337 null
1339 },
1340 onShare = {
1341 // Per-message share: build a single-message Markdown
1342 // transcript off-thread and fire ACTION_SEND, mirroring the
1343 // whole-conversation share but scoped to just this message.
1344 scope.launch {
1345 val md = withContext(Dispatchers.Default) {
1346 buildMessageMarkdown(message)
1347 } ?: return@launch
1348 val send = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
1349 type = "text/markdown"
1350 putExtra(android.content.Intent.EXTRA_SUBJECT, sessionTitle ?: defaultShareSubject)
1351 putExtra(android.content.Intent.EXTRA_TEXT, md)
1353 runCatchingCancellable { shareContext.startActivity(android.content.Intent.createChooser(send, shareContext.getString(R.string.share_message))) }
1354 .onFailure { showToast(shareContext, shareContext.getString(R.string.no_share_app)) }
1356 },
1363 if (running) {
1364 item(key = "__typing") {
1365 val workingText = stringResource(R.string.working)
1366 // Elapsed-since-run chip driven by the VM's runStartMs so the timer
1367 // survives LazyColumn recycling — a local remember reset to 0:00 when
1368 // the row was disposed and scrolled back into view. The VM stamps the
1369 // start on the real false→true transition (send/run/init), not on
1370 // SSE-reconnect relight, so reconnects don't restart the clock. Updates
1371 // once per second; the value is decorative (the a11y label is the row's).
1372 val startMs by vm.runStartMs.collectAsStateWithLifecycle()
1373 val elapsedMs by produceState(0L, startMs) {
1374 if (startMs == 0L) return@produceState
1375 while (true) {
1376 value = (System.currentTimeMillis() - startMs).coerceAtLeast(0L)
1377 delay(1000)
1380 val elapsedText = remember(elapsedMs) {
1381 val total = elapsedMs / 1000
1382 // Locale.US for stable digit formatting: on locales with
1383 // non-ASCII digits (ar-/fa-) the default locale would render
1384 // localized digits inside the Monospace block, breaking the
1385 // fixed-width alignment the monospace family was chosen for.
1386 String.format(java.util.Locale.US, "%d:%02d", total / 60, total % 60)
1388 Row(
1389 verticalAlignment = Alignment.CenterVertically,
1390 // Animate this slot in/out so the working row doesn't pop in and
1391 // jolt the message list when a run starts/stops — matching the
1392 // AnimatedVisibility used by every other transient banner/FAB here.
1393 modifier = Modifier
1394 .then(reducedMotionAnimateItem())
1395 .padding(vertical = 4.dp),
1396 ) {
1397 // Merge the indicator + label + clock into one TalkBack node so
1398 // it's announced once (the working label, then the elapsed time)
1399 // instead of "working… / working… / 0:14" with the label read
1400 // twice. The Stop IconButton below stays separately focusable.
1401 Row(
1402 verticalAlignment = Alignment.CenterVertically,
1403 modifier = Modifier.semantics(mergeDescendants = true) {
1404 contentDescription = "$workingText $elapsedText"
1405 },
1406 ) {
1407 CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
1408 Text(workingText, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp))
1409 Spacer(Modifier.size(8.dp))
1410 Icon(
1411 Icons.Filled.Schedule,
1412 contentDescription = null,
1413 modifier = Modifier.size(14.dp),
1414 tint = MaterialTheme.colorScheme.onSurfaceVariant,
1416 Text(
1417 elapsedText,
1418 style = MaterialTheme.typography.labelMedium,
1419 fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
1420 color = MaterialTheme.colorScheme.onSurfaceVariant,
1421 modifier = Modifier.padding(start = 4.dp),
1424 // A trailing Stop on the working row so the run can be aborted
1425 // without scrolling back to the composer (which may be off-screen
1426 // while the user reads the streaming output up-thread).
1427 Spacer(Modifier.weight(1f))
1428 val stopLabel = stringResource(R.string.stop)
1429 IconButton(
1430 onClick = { showStopConfirm = true },
1431 modifier = Modifier.semantics { contentDescription = stopLabel },
1432 ) {
1433 Icon(Icons.Filled.Stop, contentDescription = null)
1442 // Jump-to-latest affordance when the user has scrolled away during a stream.
1443 val fabMotion = rememberVisibilityTransitions()
1444 AnimatedVisibility(
1445 visible = !isPinnedToBottom && listItems.isNotEmpty(),
1446 enter = fabMotion.enter,
1447 exit = fabMotion.exit,
1448 modifier = Modifier.align(Alignment.BottomEnd),
1449 ) {
1450 // Badge the FAB when new content arrived while the user was scrolled up, so
1451 // they know there's something new to jump to (cleared once back at bottom).
1452 BadgedBox(
1453 badge = {
1454 if (newContentCount > 0) {
1455 // Merge the count into the badge announcement so TalkBack reads
1456 // "5 new messages" instead of the bare label and a separate number.
1457 val newContentLabel = pluralStringResource(
1458 R.plurals.plurals_new_messages,
1459 newContentCount,
1460 newContentCount,
1462 Badge(modifier = Modifier.semantics { contentDescription = newContentLabel }) {
1463 Text(
1464 if (newContentCount > 99) "99+" else newContentCount.toString(),
1465 modifier = Modifier.semantics { invisibleToUser() },
1469 },
1470 modifier = Modifier.padding(end = 16.dp, bottom = 16.dp),
1471 ) {
1472 ExtendedFloatingActionButton(
1473 onClick = {
1474 newContentCount = 0
1475 if (listItems.isNotEmpty()) {
1476 // Scroll to the effective last index, including the
1477 // trailing "__typing" row when a run is active.
1478 val target = if (running) listItems.size else listItems.lastIndex
1479 contentScope.launch { runCatchingCancellable { listState.animateScrollToItem(target) } }
1481 },
1482 // contentDescription null: the visible "Latest" text already labels
1483 // the FAB, so a description here makes TalkBack announce "Latest" twice.
1484 icon = { Icon(Icons.Filled.KeyboardArrowDown, contentDescription = null) },
1485 text = { Text(stringResource(R.string.latest)) },
1489 // Scroll-to-top affordance: a small FAB at the start edge, visible when the
1490 // user has scrolled down past the first few messages. Symmetric to the
1491 // jump-to-latest FAB, for long conversations where manual fling back is tedious.
1492 val scrolledDown by remember {
1493 derivedStateOf { listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 200 }
1495 AnimatedVisibility(
1496 visible = scrolledDown && listItems.size > 4,
1497 enter = fabMotion.enter,
1498 exit = fabMotion.exit,
1499 modifier = Modifier.align(Alignment.BottomStart),
1500 ) {
1501 SmallFloatingActionButton(
1502 onClick = {
1503 contentScope.launch { runCatchingCancellable { listState.animateScrollToItem(0) } }
1504 },
1505 modifier = Modifier.padding(start = 16.dp, bottom = 16.dp),
1506 ) {
1507 Icon(
1508 Icons.Filled.KeyboardArrowUp,
1509 contentDescription = stringResource(R.string.scroll_to_top),
1513 // In-conversation search bar. Overlays the list so typing filters messages in
1514 // place; the match count + a no-matches note give immediate feedback.
1515 val searchMotion = rememberVisibilityTransitions()
1516 AnimatedVisibility(
1517 visible = searchActive,
1518 enter = searchMotion.enter,
1519 exit = searchMotion.exit,
1520 modifier = Modifier.align(Alignment.TopCenter),
1521 ) {
1522 Surface(
1523 tonalElevation = 3.dp,
1524 modifier = Modifier
1525 .padding(horizontal = 12.dp, vertical = 4.dp)
1526 .fillMaxWidth(),
1527 ) {
1528 // Focus the search field when the bar appears (from the overflow menu's
1529 // "Find in conversation" or Ctrl+F), so the user can start typing
1530 // immediately. Runs after the search field enters the composition.
1531 LaunchedEffect(searchActive) {
1532 if (searchActive) runCatching { searchFocusRequester.requestFocus() }
1534 Row(
1535 modifier = Modifier.padding(start = 8.dp, end = 4.dp),
1536 verticalAlignment = Alignment.CenterVertically,
1537 ) {
1538 OutlinedTextField(
1539 value = chatSearch,
1540 onValueChange = { chatSearch = it },
1541 modifier = Modifier.weight(1f).focusRequester(searchFocusRequester),
1542 placeholder = { Text(stringResource(R.string.search_in_conversation)) },
1543 singleLine = true,
1544 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
1545 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
1547 // Match counter: "N of M" where N is the focused match (1-based) and
1548 // M is the total matching messages. Only shown when there's a query.
1549 val matchCount = searchMessages.size
1550 Text(
1551 if (searchQuery.isNotEmpty() && matchCount > 0) {
1552 stringResource(R.string.search_match_count, searchPos + 1, matchCount)
1553 } else if (searchQuery.isNotEmpty()) {
1554 stringResource(R.string.search_no_matches)
1555 } else "",
1556 style = MaterialTheme.typography.labelSmall,
1557 color = MaterialTheme.colorScheme.onSurfaceVariant,
1558 modifier = Modifier.padding(horizontal = 4.dp),
1560 // Step through matches (Previous / Next), disabled when there are
1561 // fewer than two to navigate. Wrap around at the ends so repeated
1562 // taps cycle through every hit.
1563 val prevLabel = stringResource(R.string.find_previous)
1564 val nextLabel = stringResource(R.string.find_next)
1565 IconButton(
1566 onClick = {
1567 if (matchCount > 0) {
1568 searchPos = (searchPos - 1 + matchCount) % matchCount
1570 },
1571 enabled = matchCount > 1,
1572 ) {
1573 Icon(Icons.Filled.KeyboardArrowUp, contentDescription = prevLabel)
1575 IconButton(
1576 onClick = {
1577 if (matchCount > 0) {
1578 searchPos = (searchPos + 1) % matchCount
1580 },
1581 enabled = matchCount > 1,
1582 ) {
1583 Icon(Icons.Filled.KeyboardArrowDown, contentDescription = nextLabel)
1585 IconButton(onClick = {
1586 searchActive = false
1587 chatSearch = ""
1588 }) {
1589 Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.clear_search))
1594 // When a search yields no matches (but the conversation isn't empty), say so
1595 // instead of showing a blank list.
1596 if (searchActive && searchQuery.isNotEmpty() && searchMessages.isEmpty() && messages.isNotEmpty()) {
1597 Text(
1598 stringResource(R.string.search_no_matches),
1599 style = MaterialTheme.typography.bodyMedium,
1600 color = MaterialTheme.colorScheme.onSurfaceVariant,
1601 modifier = Modifier.align(Alignment.Center),
1609 if (showExitConfirm) {
1610 AlertDialog(
1611 onDismissRequest = { showExitConfirm = false },
1612 title = { Text(stringResource(R.string.stop_and_exit_title)) },
1613 text = { Text(stringResource(R.string.stop_and_exit_text)) },
1614 confirmButton = {
1615 TextButton(onClick = {
1616 showExitConfirm = false
1617 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
1618 vm.abort()
1619 onBack()
1620 }) { Text(stringResource(R.string.stop_and_exit)) }
1621 },
1622 dismissButton = {
1623 TextButton(onClick = { showExitConfirm = false }) { Text(stringResource(R.string.stay)) }
1624 },
1628 if (showStopConfirm) {
1629 AlertDialog(
1630 onDismissRequest = { showStopConfirm = false },
1631 title = { Text(stringResource(R.string.stop_run_title)) },
1632 text = { Text(stringResource(R.string.stop_run_text)) },
1633 confirmButton = {
1634 TextButton(onClick = {
1635 showStopConfirm = false
1636 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
1637 vm.abort()
1638 }) { Text(stringResource(R.string.stop), color = MaterialTheme.colorScheme.error) }
1639 },
1640 dismissButton = {
1641 TextButton(onClick = { showStopConfirm = false }) { Text(stringResource(R.string.cancel)) }
1642 },
1646 if (showSummarizeConfirm) {
1647 AlertDialog(
1648 onDismissRequest = { showSummarizeConfirm = false },
1649 title = { Text(stringResource(R.string.summarize_confirm_title)) },
1650 text = { Text(stringResource(R.string.summarize_confirm_text)) },
1651 confirmButton = {
1652 TextButton(onClick = {
1653 showSummarizeConfirm = false
1654 vm.summarize()
1655 }) { Text(stringResource(R.string.action_continue)) }
1656 },
1657 dismissButton = {
1658 TextButton(onClick = { showSummarizeConfirm = false }) { Text(stringResource(R.string.cancel)) }
1659 },
1663 if (showInitConfirm) {
1664 AlertDialog(
1665 onDismissRequest = { showInitConfirm = false },
1666 title = { Text(stringResource(R.string.init_confirm_title)) },
1667 text = { Text(stringResource(R.string.init_confirm_text)) },
1668 confirmButton = {
1669 TextButton(onClick = {
1670 showInitConfirm = false
1671 vm.initProject()
1672 }) { Text(stringResource(R.string.action_continue)) }
1673 },
1674 dismissButton = {
1675 TextButton(onClick = { showInitConfirm = false }) { Text(stringResource(R.string.cancel)) }
1676 },
1680 if (showRenameDialog) {
1681 RenameSessionChatDialog(
1682 initialTitle = sessionTitle ?: "",
1683 onDismiss = { showRenameDialog = false },
1684 onConfirm = { newName ->
1685 showRenameDialog = false
1686 vm.renameSession(newName)
1687 },
1691 if (showDeleteDialog) {
1692 AlertDialog(
1693 onDismissRequest = { showDeleteDialog = false },
1694 title = { Text(stringResource(R.string.delete_session_chat_title)) },
1695 text = { Text(stringResource(R.string.delete_session_chat_text, sessionTitle ?: sessionLabel)) },
1696 confirmButton = {
1697 TextButton(onClick = {
1698 showDeleteDialog = false
1699 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
1700 vm.deleteSession()
1701 }) { Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) }
1702 },
1703 dismissButton = {
1704 TextButton(onClick = { showDeleteDialog = false }) { Text(stringResource(R.string.cancel)) }
1705 },
1709 if (showShellDialog) {
1710 ShellCommandDialog(
1711 onDismiss = { showShellDialog = false },
1712 onRun = { cmd ->
1713 showShellDialog = false
1714 vm.runShell(cmd)
1715 },
1719 if (showModelPicker) {
1720 // Resolve persisted recent composite keys to the live ModelOption entries they refer
1721 // to, dropping any whose model is no longer offered by the server. Ordered by
1722 // recency (head of the store first).
1723 val recentOptions = remember(models, recentModelEntries) {
1724 val byKey = models.associateBy { it.providerID to it.modelID }
1725 recentModelEntries.mapNotNull { entry ->
1726 container.recentModelsStore.split(entry)?.let { (p, m) -> byKey[p to m] }
1729 ModelPickerSheet(
1730 options = models,
1731 selected = selectedModel,
1732 loading = modelsLoading,
1733 error = modelsError,
1734 onSelect = {
1735 vm.selectModel(it)
1736 container.recentModelsStore.add(it.providerID, it.modelID)
1737 },
1738 onRetry = { vm.reloadModels() },
1739 onDismiss = { showModelPicker = false },
1740 preferredModelId = preferredModelId,
1741 onSetPreferredModel = { id ->
1742 scope.launch { runCatchingCancellable { container.settingsStore.setPreferredModelId(id) } }
1743 },
1744 recent = recentOptions,
1748 if (showAgentPicker) {
1749 AgentPickerSheet(
1750 agents = agents,
1751 selected = selectedAgent,
1752 loading = agentsLoading,
1753 error = agentsError,
1754 onSelect = { vm.selectAgent(it?.name) },
1755 onRetry = { vm.reloadAgents() },
1756 onDismiss = { showAgentPicker = false },
1760 if (showCommandPicker) {
1761 CommandPickerSheet(
1762 commands = commands,
1763 loading = commandsLoading,
1764 error = commandsError,
1765 onSelect = { vm.runCommand(it) },
1766 onRetry = { vm.reloadCommands() },
1767 onDismiss = { showCommandPicker = false },
1771 pendingPermission?.let { permission ->
1772 PermissionDialog(
1773 permission = permission,
1774 position = permissionProgress.position,
1775 total = permissionProgress.total,
1776 onRespond = { response -> vm.respondPermission(permission, response) },
1780 if (showPalette) {
1781 CommandPalette(actions = paletteActions, onDismiss = { showPalette = false })
1784 if (showShortcutsDialog) {
1785 // Surfaces the hardware-keyboard shortcuts (Ctrl+K palette, Escape stop, Enter send)
1786 // that are otherwise wired but undiscoverable. A simple key/value list; Enter behavior
1787 // depends on the "Send on Enter" setting, noted inline.
1788 AlertDialog(
1789 onDismissRequest = { showShortcutsDialog = false },
1790 title = { Text(stringResource(R.string.keyboard_shortcuts_title)) },
1791 text = {
1792 Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
1793 ShortcutRow("Ctrl + K", stringResource(R.string.shortcut_open_palette))
1794 ShortcutRow("Esc", stringResource(R.string.shortcut_stop_run))
1795 ShortcutRow("Esc", stringResource(R.string.shortcut_close))
1796 ShortcutRow("Enter", stringResource(R.string.shortcut_send))
1797 ShortcutRow("Shift + Enter / Ctrl + Enter", stringResource(R.string.shortcut_newline))
1799 },
1800 confirmButton = {
1801 TextButton(onClick = { showShortcutsDialog = false }) { Text(stringResource(R.string.action_continue)) }
1802 },
1807@Composable
1808private fun ShortcutRow(keys: String, desc: String) {
1809 Row(verticalAlignment = Alignment.CenterVertically) {
1810 Text(
1811 keys,
1812 style = MaterialTheme.typography.labelLarge,
1813 fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
1814 modifier = Modifier.widthIn(min = 160.dp),
1816 Text(
1817 desc,
1818 style = MaterialTheme.typography.bodyMedium,
1819 color = MaterialTheme.colorScheme.onSurfaceVariant,
1824@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
1825@Composable
1826private fun ChatInputBar(
1827 value: String,
1828 onValueChange: (String) -> Unit,
1829 running: Boolean,
1830 aborting: Boolean,
1831 enabled: Boolean,
1832 sendOnEnter: Boolean,
1833 onSend: () -> Unit,
1834 onAbort: () -> Unit,
1835 queuedFollowUp: String?,
1836 onQueueFollowUp: (String) -> Unit,
1837 onCancelQueue: () -> Unit,
1838 focusRequester: androidx.compose.ui.focus.FocusRequester,
1839 attachments: List<PendingAttachment>,
1840 staging: Boolean,
1841 stagingFileCount: Int,
1842 onRemoveAttachment: (String) -> Unit,
1843 onPickPhoto: () -> Unit,
1844 onPickFile: () -> Unit,
1845 onCamera: () -> Unit,
1846 onVoice: () -> Unit,
1847 onPasteImage: (List<Uri>) -> Unit,
1848) {
1849 // Sendable when there's text OR at least one attachment (an image-only prompt is valid).
1850 val hasContent = value.isNotBlank() || attachments.isNotEmpty()
1851 val context = LocalContext.current
1852 // Full-screen editor state: opened when the user wants more space for a long prompt.
1853 var showFullScreenEditor by rememberSaveable { mutableStateOf(false) }
1854 Surface(tonalElevation = 3.dp, modifier = Modifier.imePadding()) {
1855 Column(modifier = Modifier.fillMaxWidth().windowInsetsPadding(WindowInsets.navigationBars)) {
1856 // A queued follow-up replaces the Stop button with a "queued" chip so the
1857 // user sees their message will be sent when the run finishes, and can cancel.
1858 if (queuedFollowUp != null) {
1859 val editLabel = stringResource(R.string.edit)
1860 Row(
1861 modifier = Modifier
1862 .fillMaxWidth()
1863 .padding(start = 12.dp, top = 6.dp, end = 4.dp),
1864 verticalAlignment = Alignment.CenterVertically,
1865 ) {
1866 // Tapping the chip reloads the queued text into the draft (and clears the
1867 // queue) so a typo spotted while queuing can be fixed without retyping.
1868 Column(
1869 modifier = Modifier
1870 .weight(1f)
1871 .clip(MaterialTheme.shapes.small)
1872 .clickable(role = Role.Button, onClickLabel = editLabel) {
1873 onValueChange(queuedFollowUp)
1874 onCancelQueue()
1876 .padding(vertical = 4.dp),
1877 ) {
1878 Text(
1879 stringResource(R.string.queued),
1880 style = MaterialTheme.typography.labelSmall,
1881 color = MaterialTheme.colorScheme.onSurfaceVariant,
1883 Text(
1884 queuedFollowUp,
1885 style = MaterialTheme.typography.bodySmall,
1886 color = MaterialTheme.colorScheme.onSurfaceVariant,
1887 maxLines = 1,
1888 overflow = TextOverflow.Ellipsis,
1891 TextButton(onClick = onCancelQueue, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp)) {
1892 Text(stringResource(R.string.cancel))
1896 // Staged attachments: horizontally-scrollable thumbnails/chips, each removable.
1897 AttachmentStrip(attachments, onRemove = onRemoveAttachment, staging = staging, stagingFileCount = stagingFileCount)
1898 Row(
1899 modifier = Modifier.fillMaxWidth().padding(8.dp),
1900 verticalAlignment = Alignment.Bottom,
1901 ) {
1902 // Attach menu: photo picker, camera, or any file.
1903 var showAttachMenu by remember { mutableStateOf(false) }
1904 Box {
1905 IconButton(onClick = { showAttachMenu = true }, enabled = enabled) {
1906 Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.add_attachment))
1908 DropdownMenu(expanded = showAttachMenu, onDismissRequest = { showAttachMenu = false }) {
1909 DropdownMenuItem(
1910 leadingIcon = { Icon(Icons.Filled.PhotoLibrary, contentDescription = null) },
1911 text = { Text(stringResource(R.string.attach_photo)) },
1912 onClick = { showAttachMenu = false; onPickPhoto() },
1914 DropdownMenuItem(
1915 leadingIcon = { Icon(Icons.Filled.PhotoCamera, contentDescription = null) },
1916 text = { Text(stringResource(R.string.attach_camera)) },
1917 onClick = { showAttachMenu = false; onCamera() },
1919 DropdownMenuItem(
1920 leadingIcon = { Icon(Icons.Filled.Description, contentDescription = null) },
1921 text = { Text(stringResource(R.string.attach_file)) },
1922 onClick = { showAttachMenu = false; onPickFile() },
1926 // Voice dictation: appends recognized speech to the draft. Hidden (not just
1927 // disabled) when the device has no speech-recognition activity, so a user
1928 // without a voice app doesn't get a button that only fails noisily on tap.
1929 val voiceAvailable = remember {
1930 val pm = context.packageManager
1931 val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH)
1932 pm.queryIntentActivities(intent, 0).isNotEmpty()
1934 if (voiceAvailable) {
1935 IconButton(onClick = onVoice, enabled = enabled) {
1936 Icon(Icons.Filled.Mic, contentDescription = stringResource(R.string.voice_input))
1939 // Expand to a full-screen editor for long prompts (the inline field caps at 6 lines).
1940 IconButton(onClick = { showFullScreenEditor = true }, enabled = enabled) {
1941 Icon(Icons.Filled.Expand, contentDescription = stringResource(R.string.expand_editor))
1943 OutlinedTextField(
1944 value = value,
1945 onValueChange = { v ->
1946 // Cap input length so a huge paste can't stall the UI. Shares are
1947 // capped separately in MainActivity; this guards typed/pasted drafts.
1948 onValueChange(v.take(NetworkConfig.maxDraftLengthChars))
1949 },
1950 modifier = Modifier
1951 .weight(1f)
1952 .focusRequester(focusRequester)
1953 .testTag("chat_input")
1954 // Intercept image content pasted/dropped into the field (keyboard paste
1955 // of a copied screenshot, drag from Files, etc.) and route it through the
1956 // same staging path as the attach menu. Non-image content (text) falls
1957 // through unchanged so normal typing/paste works.
1958 .contentReceiver(
1959 remember {
1960 androidx.compose.foundation.content.ReceiveContentListener { transferable ->
1961 val clipData = transferable.clipEntry?.clipData
1962 if (clipData != null) {
1963 val imageUris = (0 until clipData.itemCount).mapNotNull { i ->
1964 val item = clipData.getItemAt(i)
1965 item.uri?.takeIf { uri ->
1966 item.text == null && uri.toString().let { s ->
1967 s.startsWith("content:") || s.startsWith("file:")
1971 if (imageUris.isNotEmpty()) {
1972 onPasteImage(imageUris)
1975 transferable
1977 },
1979 .onPreviewKeyEvent { event ->
1980 if (event.type != KeyEventType.KeyDown || event.key != Key.Enter) return@onPreviewKeyEvent false
1981 // With "Send on Enter" on, Enter sends (Shift+Enter newlines).
1982 // With it off, Enter inserts a newline and Ctrl+Enter sends.
1983 val send = enterShouldSend(
1984 enabled = enabled,
1985 hasContent = hasContent,
1986 sendOnEnter = sendOnEnter,
1987 shift = event.isShiftPressed,
1988 ctrl = event.isCtrlPressed,
1990 if (send) {
1991 if (running) onQueueFollowUp(value) else onSend()
1992 true
1993 } else {
1994 false
1996 },
1997 // While a run is active a typed message only queues (sends after the run), so
1998 // the placeholder says so instead of the plain "Message…".
1999 placeholder = {
2000 Text(stringResource(if (running) R.string.composer_hint_running else R.string.message_placeholder))
2001 },
2002 enabled = enabled,
2003 maxLines = 6,
2004 // Show a "N / max" countdown once the draft crosses a high fraction of
2005 // the cap, so the user knows a paste is about to be truncated instead
2006 // of being silently cut off. Hidden for normal short prompts to avoid
2007 // clutter under a typical one-line message.
2008 supportingText = {
2009 val threshold = (NetworkConfig.maxDraftLengthChars * NetworkConfig.draftCountdownThresholdFraction).toInt()
2010 if (value.length >= threshold) {
2011 Text(
2012 stringResource(
2013 R.string.draft_chars_remaining,
2014 NetworkConfig.maxDraftLengthChars - value.length,
2015 NetworkConfig.maxDraftLengthChars,
2016 ),
2017 style = MaterialTheme.typography.labelSmall,
2018 color = if (value.length >= NetworkConfig.maxDraftLengthChars) MaterialTheme.colorScheme.error
2019 else MaterialTheme.colorScheme.onSurfaceVariant,
2022 },
2023 keyboardOptions = KeyboardOptions(
2024 // When "Send on Enter" is on, expose ImeAction.Send so the soft
2025 // keyboard shows a send key — matching the setting's intent and
2026 // giving touch-only users a keyboard send affordance. When off,
2027 // ImeAction.Default keeps the enter/newline key for multi-line input.
2028 imeAction = if (sendOnEnter) ImeAction.Send else ImeAction.Default,
2029 capitalization = KeyboardCapitalization.Sentences,
2030 ),
2031 keyboardActions = KeyboardActions(
2032 onSend = {
2033 if (enabled && hasContent) onSend()
2034 },
2035 ),
2037 ComposerTrailingButton(
2038 running = running,
2039 aborting = aborting,
2040 canSend = enabled && hasContent,
2041 canQueue = enabled && value.isNotBlank(),
2042 onSend = onSend,
2043 onAbort = onAbort,
2044 onQueue = { onQueueFollowUp(value) },
2048 if (showFullScreenEditor) {
2049 FullScreenEditor(
2050 value = value,
2051 onValueChange = onValueChange,
2052 onDismiss = { showFullScreenEditor = false },
2053 onSend = {
2054 showFullScreenEditor = false
2055 // Mirror the inline composer's behavior: while a run is active a send
2056 // can't go immediately, so queue the follow-up instead of silently
2057 // dropping it (vm.send() returns false during a run, which previously
2058 // lost the typed message with no feedback).
2059 if (running) onQueueFollowUp(value) else onSend()
2060 },
2061 canSend = enabled && hasContent,
2062 running = running,
2063 sendOnEnter = sendOnEnter,
2064 attachments = attachments,
2065 onRemoveAttachment = onRemoveAttachment,
2066 staging = staging,
2067 stagingFileCount = stagingFileCount,
2073/** Full-screen editor dialog for composing long prompts. Gives the full viewport height to
2074 * the text field (vs the inline 6-line cap) with a Send button and a collapse action. */
2075@OptIn(ExperimentalMaterial3Api::class)
2076@Composable
2077private fun FullScreenEditor(
2078 value: String,
2079 onValueChange: (String) -> Unit,
2080 onDismiss: () -> Unit,
2081 onSend: () -> Unit,
2082 canSend: Boolean,
2083 running: Boolean = false,
2084 sendOnEnter: Boolean = false,
2085 attachments: List<PendingAttachment> = emptyList(),
2086 onRemoveAttachment: (String) -> Unit = {},
2087 staging: Boolean = false,
2088 stagingFileCount: Int = 0,
2089) {
2090 androidx.compose.ui.window.Dialog(
2091 onDismissRequest = onDismiss,
2092 properties = androidx.compose.ui.window.DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false),
2093 ) {
2094 // Cap the editor width on large screens (tablets) so the text field doesn't stretch
2095 // edge-to-edge — matching the chat list's readability rationale. BoxWithConstraints
2096 // gives the available width so we can center a capped-width column.
2097 androidx.compose.foundation.layout.BoxWithConstraints(
2098 modifier = Modifier.fillMaxSize(),
2099 contentAlignment = Alignment.Center,
2100 ) {
2101 val hPad = if (maxWidth > NetworkConfig.twoPaneWidthThresholdDp.dp)
2102 ((maxWidth - NetworkConfig.composerDialogMaxWidthDp.dp) / 2).coerceAtLeast(0.dp)
2103 else 0.dp
2104 Scaffold(
2105 modifier = Modifier.padding(horizontal = hPad),
2106 topBar = {
2107 androidx.compose.material3.TopAppBar(
2108 title = { Text(stringResource(R.string.compose_editor_title)) },
2109 navigationIcon = {
2110 androidx.compose.material3.TextButton(onClick = onDismiss) {
2111 Text(stringResource(R.string.collapse_editor))
2113 },
2115 },
2116 bottomBar = {
2117 Surface(tonalElevation = 3.dp) {
2118 Row(
2119 modifier = Modifier.fillMaxWidth().imePadding().padding(12.dp),
2120 horizontalArrangement = Arrangement.End,
2121 ) {
2122 androidx.compose.material3.Button(onClick = onSend, enabled = canSend) {
2123 Text(stringResource(if (running) R.string.queue else R.string.send))
2127 },
2128 ) { padding ->
2129 Column(modifier = Modifier.fillMaxSize().padding(padding)) {
2130 // Show staged attachments in the full-screen editor too, so a user who staged
2131 // images then expanded for a long prompt can still see and remove them without
2132 // collapsing first (mirrors the inline composer's AttachmentStrip).
2133 AttachmentStrip(attachments, onRemove = onRemoveAttachment, staging = staging, stagingFileCount = stagingFileCount)
2134 OutlinedTextField(
2135 value = value,
2136 onValueChange = { v -> onValueChange(v.take(NetworkConfig.maxDraftLengthChars)) },
2137 modifier = Modifier
2138 .fillMaxSize()
2139 .weight(1f)
2140 // Honor the "Send on Enter" setting here too, so the full-screen
2141 // editor matches the inline composer: Enter sends (Shift+Enter
2142 // newlines) when on; Enter inserts a newline and Ctrl+Enter sends
2143 // when off. Previously this hard-coded ImeAction.Default and had no
2144 // key handler, so a hardware-keyboard user got a different behaviour
2145 // from the inline composer depending on which they happened to open.
2146 .onPreviewKeyEvent { event ->
2147 if (event.type != KeyEventType.KeyDown || event.key != Key.Enter) return@onPreviewKeyEvent false
2148 val send = enterShouldSend(
2149 enabled = canSend,
2150 hasContent = canSend,
2151 sendOnEnter = sendOnEnter,
2152 shift = event.isShiftPressed,
2153 ctrl = event.isCtrlPressed,
2155 if (send) { onSend(); true } else false
2156 },
2157 placeholder = { Text(stringResource(R.string.message_placeholder)) },
2158 maxLines = Int.MAX_VALUE,
2159 keyboardOptions = KeyboardOptions(
2160 imeAction = if (sendOnEnter) ImeAction.Send else ImeAction.Default,
2161 capitalization = KeyboardCapitalization.Sentences,
2162 ),
2163 keyboardActions = KeyboardActions(
2164 onSend = { if (canSend) onSend() },
2165 ),
2173/** Whether a hardware Enter keypress should send: never when disabled/empty; otherwise
2174 * Enter sends (unless Shift) when send-on-Enter is on, else only Ctrl+Enter sends. */
2175private fun enterShouldSend(
2176 enabled: Boolean,
2177 hasContent: Boolean,
2178 sendOnEnter: Boolean,
2179 shift: Boolean,
2180 ctrl: Boolean,
2181): Boolean = when {
2182 !enabled || !hasContent -> false
2183 sendOnEnter -> !shift
2184 else -> ctrl
2187/** The composer's trailing button: while a run is active a Stop (with in-flight spinner),
2188 * preceded by a Queue button when the user has typed a follow-up; otherwise Send. Extracted so
2189 * [ChatInputBar] stays under the complexity threshold. */
2190@Composable
2191private fun ComposerTrailingButton(
2192 running: Boolean,
2193 aborting: Boolean,
2194 canSend: Boolean,
2195 canQueue: Boolean,
2196 onSend: () -> Unit,
2197 onAbort: () -> Unit,
2198 onQueue: () -> Unit,
2199) {
2200 if (running) {
2201 Row(verticalAlignment = Alignment.CenterVertically) {
2202 // While a run is active the trailing action is Stop, so the only send affordance is
2203 // the keyboard's IME action — which is a plain newline when "Send on Enter" is off.
2204 // That leaves a touch-only user with the setting off no way to queue a follow-up
2205 // (Ctrl+Enter needs a hardware keyboard). Surface an explicit Queue button whenever
2206 // there's typed TEXT so queuing is always reachable, without changing the user's
2207 // Enter-inserts-a-newline preference. Gate on typed text — not the send-oriented
2208 // canSend, which is also true for an attachment-only composer — because queuing "" just
2209 // calls setQueuedFollowUp(null), silently cancelling any already-queued follow-up and
2210 // dropping the staged attachment (queued follow-ups don't carry attachments anyway).
2211 if (canQueue) {
2212 IconButton(
2213 onClick = onQueue,
2214 modifier = Modifier.testTag("queue_button"),
2215 ) {
2216 // PlaylistAdd reads as "add to a queue/list" more clearly than a clock
2217 // icon (which can be read as "snooze" or "later"), and isn't confused with
2218 // the Send action sitting right beside the Stop button.
2219 Icon(
2220 Icons.AutoMirrored.Filled.PlaylistAdd,
2221 contentDescription = stringResource(R.string.queue_followup),
2225 // Show a spinner while the abort REST call is in flight so the user sees the stop
2226 // was sent, and disable to prevent a double-tap.
2227 IconButton(
2228 onClick = onAbort,
2229 enabled = !aborting,
2230 modifier = Modifier.padding(start = 4.dp).testTag("stop_button"),
2231 ) {
2232 if (aborting) {
2233 val stopLabel = stringResource(R.string.stop)
2234 CircularProgressIndicator(
2235 Modifier.size(18.dp).semantics { contentDescription = stopLabel },
2236 strokeWidth = 2.dp,
2238 } else {
2239 Icon(Icons.Filled.Stop, contentDescription = stringResource(R.string.stop))
2243 } else {
2244 IconButton(
2245 onClick = onSend,
2246 enabled = canSend,
2247 modifier = Modifier.padding(start = 4.dp).testTag("send_button"),
2248 ) {
2249 // Tint the send icon with primary when there's content to send so the commit action
2250 // draws the eye; muted otherwise. A plain enabled/disabled flip is easy to miss.
2251 Icon(
2252 Icons.AutoMirrored.Filled.Send,
2253 contentDescription = stringResource(R.string.send),
2254 tint = if (canSend) MaterialTheme.colorScheme.primary
2255 else MaterialTheme.colorScheme.onSurfaceVariant,
2261@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class)
2262@Composable
2263private fun EmptyConversation(
2264 onSuggestion: (String) -> Unit,
2265 modifier: Modifier = Modifier,
2266) {
2267 // A few example prompts so a first-time user has a one-tap path to their first
2268 // message instead of staring at a blank input. Tapping a chip fills the draft
2269 // (without sending) so the user can edit it before sending.
2270 val suggestions = remember {
2271 listOf(
2272 R.string.empty_chat_suggest_1,
2273 R.string.empty_chat_suggest_2,
2274 R.string.empty_chat_suggest_3,
2277 Column(
2278 modifier = modifier.padding(32.dp),
2279 horizontalAlignment = Alignment.CenterHorizontally,
2280 ) {
2281 Icon(
2282 Icons.Filled.AutoAwesome,
2283 contentDescription = null,
2284 modifier = Modifier.size(48.dp),
2285 tint = MaterialTheme.colorScheme.onSurfaceVariant,
2287 Spacer(Modifier.size(16.dp))
2288 Text(
2289 stringResource(R.string.empty_chat_title),
2290 style = MaterialTheme.typography.titleMedium,
2292 Spacer(Modifier.size(8.dp))
2293 Text(
2294 stringResource(R.string.empty_chat_hint),
2295 style = MaterialTheme.typography.bodyMedium,
2296 color = MaterialTheme.colorScheme.onSurfaceVariant,
2298 Spacer(Modifier.size(20.dp))
2299 // Suggestion chips wrap across rows on narrow screens instead of stacking full-width,
2300 // so a longer localized prompt doesn't push the chips off-screen on a phone.
2301 FlowRow(
2302 modifier = Modifier.fillMaxWidth(),
2303 horizontalArrangement = Arrangement.spacedBy(8.dp),
2304 verticalArrangement = Arrangement.spacedBy(8.dp),
2305 ) {
2306 suggestions.forEach { resId ->
2307 val text = stringResource(resId)
2308 androidx.compose.material3.AssistChip(
2309 onClick = { onSuggestion(text) },
2310 label = { Text(text, style = MaterialTheme.typography.bodyMedium) },
2317/** A banner shown while a revert checkpoint is active, offering an Undo (unrevert). When the
2318 * server included a diff of the revert, a collapsible "Show what changed" affordance reveals
2319 * it inline via [DiffView] so the user can see what was rolled back before deciding to undo. */
2320@Composable
2321private fun RevertBanner(diff: String?, onUndo: () -> Unit) {
2322 val haptics = LocalHapticFeedback.current
2323 var showDiff by rememberSaveable { mutableStateOf(false) }
2324 val expandMotion = rememberVisibilityTransitions()
2325 Surface(
2326 color = MaterialTheme.colorScheme.tertiaryContainer,
2327 tonalElevation = 2.dp,
2328 modifier = Modifier.fillMaxWidth(),
2329 ) {
2330 Column {
2331 Row(
2332 modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp),
2333 verticalAlignment = Alignment.CenterVertically,
2334 horizontalArrangement = Arrangement.SpaceBetween,
2335 ) {
2336 Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
2337 Icon(
2338 Icons.Filled.Restore,
2339 contentDescription = null,
2340 tint = MaterialTheme.colorScheme.onTertiaryContainer,
2341 modifier = Modifier.size(18.dp),
2343 Text(
2344 stringResource(R.string.reverted_banner),
2345 style = MaterialTheme.typography.bodySmall,
2346 color = MaterialTheme.colorScheme.onTertiaryContainer,
2347 modifier = Modifier.padding(start = 8.dp),
2349 // "Show what changed" toggle, shown only when the server provided a diff.
2350 // Collapsible so the banner stays compact by default; a user curious about
2351 // what the revert rolled back can expand it inline without leaving the chat.
2352 if (!diff.isNullOrBlank()) {
2353 TextButton(
2354 onClick = {
2355 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
2356 showDiff = !showDiff
2357 },
2358 contentPadding = androidx.compose.foundation.layout.PaddingValues(start = 8.dp, end = 4.dp),
2359 ) {
2360 Icon(
2361 if (showDiff) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
2362 contentDescription = null,
2363 modifier = Modifier.size(16.dp),
2365 Text(
2366 stringResource(if (showDiff) R.string.revert_hide_diff else R.string.revert_show_diff),
2367 style = MaterialTheme.typography.labelSmall,
2368 modifier = Modifier.padding(start = 4.dp),
2373 TextButton(onClick = {
2374 // Haptic on undo to match the other destructive/important confirmations: a
2375 // revert restores potentially hundreds of hidden messages.
2376 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
2377 onUndo()
2378 }) { Text(stringResource(R.string.undo)) }
2380 // The diff render lives inside the banner's Surface so it inherits the tertiary
2381 // tonal container and reads as part of the banner, not a floating panel.
2382 if (!diff.isNullOrBlank()) {
2383 AnimatedVisibility(
2384 visible = showDiff,
2385 enter = expandMotion.enter,
2386 exit = expandMotion.exit,
2387 ) {
2388 Column(modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 8.dp)) {
2389 Text(
2390 stringResource(R.string.revert_diff_header),
2391 style = MaterialTheme.typography.labelSmall,
2392 color = MaterialTheme.colorScheme.onTertiaryContainer,
2393 modifier = Modifier.padding(bottom = 4.dp),
2395 DiffView(diff, saveKey = "revertDiff")
2403/** A banner shown while messages composed offline are queued in the outbox, with a
2404 * "Send now" nudge and a discard. Shows a spinner while the queue is flushing. */
2405@Composable
2406private fun OutboxBanner(
2407 count: Int,
2408 sending: Boolean,
2409 onFlush: () -> Unit,
2410 onDiscard: () -> Unit,
2411) {
2412 var showDiscardConfirm by rememberSaveable { mutableStateOf(false) }
2413 Surface(
2414 color = MaterialTheme.colorScheme.secondaryContainer,
2415 tonalElevation = 2.dp,
2416 modifier = Modifier.fillMaxWidth(),
2417 ) {
2418 Row(
2419 modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp),
2420 verticalAlignment = Alignment.CenterVertically,
2421 horizontalArrangement = Arrangement.SpaceBetween,
2422 ) {
2423 Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
2424 if (sending) {
2425 CircularProgressIndicator(
2426 Modifier.size(16.dp),
2427 strokeWidth = 2.dp,
2428 color = MaterialTheme.colorScheme.onSecondaryContainer,
2430 Spacer(Modifier.size(8.dp))
2432 Text(
2433 if (sending) {
2434 stringResource(R.string.outbox_sending)
2435 } else {
2436 val res = if (count == 1) R.string.outbox_pending_one else R.string.outbox_pending_many
2437 stringResource(res, count)
2438 },
2439 style = MaterialTheme.typography.bodySmall,
2440 color = MaterialTheme.colorScheme.onSecondaryContainer,
2443 if (!sending) {
2444 val haptics = LocalHapticFeedback.current
2445 TextButton(onClick = onFlush) { Text(stringResource(R.string.outbox_flush)) }
2446 TextButton(onClick = {
2447 // Confirm before discarding all queued messages (irreversible), matching
2448 // the confirmation pattern used by deleteSession and other destructive
2449 // actions. Previously a single tap dropped every queued message.
2450 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
2451 showDiscardConfirm = true
2452 }) {
2453 Text(stringResource(R.string.outbox_discard_all), color = MaterialTheme.colorScheme.error)
2458 if (showDiscardConfirm) {
2459 androidx.compose.material3.AlertDialog(
2460 onDismissRequest = { showDiscardConfirm = false },
2461 title = { Text(stringResource(R.string.discard_queued_title)) },
2462 text = { Text(stringResource(R.string.discard_queued_text, count)) },
2463 confirmButton = {
2464 androidx.compose.material3.TextButton(onClick = {
2465 showDiscardConfirm = false
2466 onDiscard()
2467 }) { Text(stringResource(R.string.discard)) }
2468 },
2469 dismissButton = {
2470 androidx.compose.material3.TextButton(onClick = { showDiscardConfirm = false }) {
2471 Text(stringResource(R.string.cancel))
2473 },
2478/** Prompt for a one-off shell command to run in the session's worktree. */
2479@Composable
2480private fun ShellCommandDialog(onDismiss: () -> Unit, onRun: (String) -> Unit) {
2481 var command by rememberSaveable { mutableStateOf("") }
2482 AlertDialog(
2483 onDismissRequest = onDismiss,
2484 title = { Text(stringResource(R.string.run_shell_command)) },
2485 text = {
2486 OutlinedTextField(
2487 value = command,
2488 onValueChange = { command = it },
2489 modifier = Modifier.fillMaxWidth(),
2490 placeholder = { Text(stringResource(R.string.shell_command_hint)) },
2491 label = { Text(stringResource(R.string.shell_command_label)) },
2492 singleLine = true,
2493 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
2494 keyboardActions = KeyboardActions(onDone = { if (command.isNotBlank()) onRun(command.trim()) }),
2496 },
2497 confirmButton = {
2498 TextButton(
2499 onClick = { if (command.isNotBlank()) onRun(command.trim()) },
2500 enabled = command.isNotBlank(),
2501 ) { Text(stringResource(R.string.run)) }
2502 },
2503 dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } },
2507/**
2508 * Change signal for the auto-scroll-on-stream watcher. Primitive fields avoid the
2509 * boxing `Triple<Int, Int, Boolean>` would impose on every snapshot evaluation, and
2510 * equality is structural so `snapshotFlow` emits only when something relevant changes.
2511 */
2512private data class AutoScrollSignal(val size: Int, val lastTextLength: Int, val pinned: Boolean)
2514/** Length of the streaming content carried by [part], driving auto-scroll as it grows.
2515 * Covers text, reasoning, and tool output so a pinned view keeps following non-text
2516 * streaming, not just plain assistant text. O(1). */
2517private fun streamingContentLength(part: Part?): Int = when (part) {
2518 is TextPart -> part.text.length
2519 is ReasoningPart -> part.text.length
2520 is ToolPart -> when (val st = part.state) {
2521 is ToolCompleted -> st.output?.length ?: 0
2522 is ToolError -> st.error?.length ?: 0
2523 is ToolRunning -> st.title?.length ?: 0
2524 else -> 0
2526 else -> 0
2529/** Rename dialog for the currently-open session. Mirrors the SessionListScreen rename
2530 * dialog but lives in the chat screen so the user can rename without backing out to
2531 * the list. Pre-filled with the current title; the cap matches the list dialog. */
2532@Composable
2533private fun RenameSessionChatDialog(
2534 initialTitle: String,
2535 onDismiss: () -> Unit,
2536 onConfirm: (String) -> Unit,
2537) {
2538 // Seed the field once (no key input) so a later external title change — e.g. opencode
2539 // auto-generating a session title after the first prompt, arriving via SessionUpdated —
2540 // doesn't re-initialize the box and discard the user's in-progress edit.
2541 var title by rememberSaveable { mutableStateOf(initialTitle) }
2542 AlertDialog(
2543 onDismissRequest = onDismiss,
2544 title = { Text(stringResource(R.string.rename_session_chat)) },
2545 text = {
2546 OutlinedTextField(
2547 value = title,
2548 onValueChange = { v -> title = v.take(NetworkConfig.maxSessionTitleChars) },
2549 modifier = Modifier.fillMaxWidth(),
2550 placeholder = { Text(stringResource(R.string.session_title_hint)) },
2551 label = { Text(stringResource(R.string.rename_session_chat)) },
2552 singleLine = true,
2553 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
2554 keyboardActions = KeyboardActions(onDone = { if (title.isNotBlank()) onConfirm(title.trim()) }),
2556 },
2557 confirmButton = {
2558 TextButton(onClick = { if (title.isNotBlank()) onConfirm(title.trim()) }, enabled = title.isNotBlank()) {
2559 Text(stringResource(R.string.save))
2561 },
2562 dismissButton = {
2563 TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) }
2564 },
2568/**
2569 * A single item in the chat message list — either a [Message] bubble or a day
2570 * [Separator] inserted before the first message of a new day. The separator shifts
2571 * the LazyColumn indices vs. the raw messages list, so scroll-target math must use
2572 * the [List] of these items (not `messages.size`/`lastIndex`).
2573 */
2574private sealed interface MessageListItem {
2575 val key: Any
2576 val contentType: Any
2578 data class Message(val message: MessageWithParts, override val key: Any) : MessageListItem {
2579 override val contentType: Any get() = message.info::class
2582 data class Separator(val label: String, val ordinal: Int) : MessageListItem {
2583 // Key on the day-occurrence ordinal, not the label: two non-contiguous groups
2584 // can share a label (e.g. an untimestamped message — empty label — between two
2585 // same-day messages, or two untimestamped groups), and a label-only key would
2586 // then collide, which makes LazyColumn throw on duplicate keys. The ordinal is
2587 // stable for a given message ordering so slots aren't needlessly recreated.
2588 override val key: Any get() = "sep_${ordinal}_$label"
2589 // Separators share a contentType so the LazyColumn can recycle their slots.
2590 override val contentType: Any get() = "separator"
2594/**
2595 * Build the interleaved list of [MessageListItem]s for the message list, inserting a
2596 * [MessageListItem.Separator] before the first message of each new calendar day. Uses
2597 * the message's `time.created` (falling back to `updated` then `completed`) to bucket.
2598 * Messages with no timestamp are grouped under an empty-label separator only if they
2599 * start the list, so a server that omits timestamps doesn't suppress the first divider.
2601 * [todayLabel]/[yesterdayLabel] are resolved by the caller (a @Composable can't call
2602 * stringResource from inside this plain function) and used for the "Today"/"Yesterday"
2603 * labels; older days fall back to a locale-stable medium-date format.
2604 */
2605private fun buildMessageListItems(
2606 messages: List<MessageWithParts>,
2607 todayLabel: String,
2608 yesterdayLabel: String,
2609): List<MessageListItem> {
2610 if (messages.isEmpty()) return emptyList()
2611 val today = java.time.LocalDate.now(java.time.ZoneId.systemDefault())
2612 val dateFmt = mediumDateFormat()
2613 val result = ArrayList<MessageListItem>(messages.size + 4)
2614 val seenKeys = HashSet<String>(messages.size * 2)
2615 var lastDayKey: String? = null
2616 var sepOrdinal = 0
2617 var emptyIdOrdinal = 0
2618 var first = true
2619 for (message in messages) {
2620 val ts = message.info.time?.created ?: message.info.time?.updated ?: message.info.time?.completed
2621 val dayKey = ts?.let { dayKey(it) } ?: ""
2622 // Emit a separator before the very first message (so a leading un-timestamped message
2623 // still gets the first divider, empty label and all), and thereafter only when crossing
2624 // into a new NON-empty calendar day. An un-timestamped message mid-list (empty key)
2625 // neither emits its own separator nor resets the day — otherwise a following same-day
2626 // message would see a "" -> "<today>" transition and emit a duplicate "Today" header.
2627 if (first || (dayKey.isNotEmpty() && dayKey != lastDayKey)) {
2628 result.add(MessageListItem.Separator(dayLabel(dayKey, ts ?: 0L, today, todayLabel, yesterdayLabel, dateFmt), sepOrdinal++))
2630 if (dayKey.isNotEmpty()) lastDayKey = dayKey
2631 first = false
2632 // Most messages have a unique non-empty id. An unrecognized-role message can carry an
2633 // empty id — and the reducer intentionally keeps several such messages as distinct
2634 // holders — so give each a stable synthetic key and defensively de-collide any remaining
2635 // duplicate; otherwise LazyColumn throws on a duplicate key and the whole screen crashes.
2636 var key = message.info.id.ifEmpty { "msg-empty-${emptyIdOrdinal++}" }
2637 if (!seenKeys.add(key)) {
2638 var suffix = 1
2639 while (!seenKeys.add("$key#$suffix")) suffix++
2640 key = "$key#$suffix"
2642 result.add(MessageListItem.Message(message, key))
2644 return result
2647/**
2648 * A stable bucket key for a timestamp's calendar day (epoch-days as a string).
2650 * [buildMessageListItems] re-runs on every streaming snapshot (the memo is keyed on the
2651 * whole `messages` list, which must stay so the live message's fresh parts render), so a
2652 * naive implementation would re-allocate an `Instant`/`ZonedDateTime`/`LocalDate` for
2653 * every message on every token. Message timestamps are immutable once assigned, so we
2654 * memoize the parse per epoch-millis: after the first snapshot, each unchanged message is
2655 * a cheap hash lookup. Bounded LRU (access-order) caps memory; only touched on the main
2656 * thread during composition, so the coarse lock is uncontended.
2657 */
2658private val dayKeyCache = object : LinkedHashMap<Long, String>(128, 0.75f, true) {
2659 override fun removeEldestEntry(eldest: MutableMap.MutableEntry<Long, String>): Boolean = size > 1024
2662private fun dayKey(epochMillis: Long): String = synchronized(dayKeyCache) {
2663 dayKeyCache.getOrPut(epochMillis) {
2664 java.time.Instant.ofEpochMilli(epochMillis)
2665 .atZone(java.time.ZoneId.systemDefault()).toLocalDate().toEpochDay().toString()
2669// The medium-date formatter is an ICU locale lookup; cache one instance instead of
2670// allocating a fresh one per buildMessageListItems call, rebuilding only if the default
2671// locale changes at runtime. Main-thread-only, so no synchronization is needed.
2672private var cachedDateFmt: java.text.DateFormat? = null
2673private var cachedDateFmtLocale: java.util.Locale? = null
2675private fun mediumDateFormat(): java.text.DateFormat {
2676 val locale = java.util.Locale.getDefault()
2677 val existing = cachedDateFmt
2678 if (existing != null && cachedDateFmtLocale == locale) return existing
2679 return java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM).also {
2680 cachedDateFmt = it
2681 cachedDateFmtLocale = locale
2685/**
2686 * Resolve a day-key back to a human-readable label. "Today" / "Yesterday" for the
2687 * recent days, otherwise a locale-stable medium-date format. Returns an empty string
2688 * when [dayKey] is empty (no timestamp) so no separator is rendered for un-timestamped
2689 * messages.
2690 */
2691private fun dayLabel(
2692 dayKey: String,
2693 ts: Long,
2694 today: java.time.LocalDate,
2695 todayLabel: String,
2696 yesterdayLabel: String,
2697 dateFmt: java.text.DateFormat,
2698): String {
2699 if (dayKey.isEmpty()) return ""
2700 val epochDay = dayKey.toLongOrNull() ?: return ""
2701 val date = java.time.LocalDate.ofEpochDay(epochDay)
2702 return when {
2703 date == today -> todayLabel
2704 date == today.minusDays(1) -> yesterdayLabel
2705 else -> dateFmt.format(java.util.Date(ts))
2709/**
2710 * A centered day divider in the message list. Renders nothing for an empty label
2711 * (the no-timestamp case) so un-timestamped messages don't get a stray blank divider.
2712 */
2713@Composable
2714private fun DateSeparator(label: String) {
2715 if (label.isBlank()) return
2716 Row(
2717 modifier = Modifier.fillMaxWidth(),
2718 horizontalArrangement = Arrangement.Center,
2719 ) {
2720 Surface(
2721 color = MaterialTheme.colorScheme.surfaceVariant,
2722 shape = MaterialTheme.shapes.small,
2723 // Marked as a heading so TalkBack users can navigate day-by-day with the
2724 // heading-skip gesture — the canonical affordance for date dividers in a list.
2725 modifier = Modifier.semantics(mergeDescendants = true) { heading() },
2726 ) {
2727 Text(
2728 label,
2729 style = MaterialTheme.typography.labelSmall,
2730 color = MaterialTheme.colorScheme.onSurfaceVariant,
2731 modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
2737/** Skeleton placeholder bubbles for the chat loading and run-started states. Alpha pulses
2738 * (gated by reduced-motion) to convey ongoing work; the column carries the a11y label so
2739 * TalkBack announces "Loading" instead of reading the skeleton bars. Rendered as a
2740 * [BoxScope] extension so it can align itself to the top center of its host box. */
2741@Composable
2742private fun androidx.compose.foundation.layout.BoxScope.ChatSkeleton(topPad: androidx.compose.ui.unit.Dp) {
2743 val loadingLabel = stringResource(R.string.loading)
2744 val reducedMotion = LocalReducedMotion.current
2745 val transition = rememberInfiniteTransition(label = "skeleton")
2746 val pulse by transition.animateFloat(
2747 initialValue = 0.35f,
2748 targetValue = 0.7f,
2749 animationSpec = infiniteRepeatable(tween(900), RepeatMode.Reverse),
2750 label = "skeletonAlpha",
2752 val skeletonAlpha = if (reducedMotion) 0.5f else pulse
2753 val skeletonColor = MaterialTheme.colorScheme.surfaceVariant
2754 @Composable
2755 fun bar(widthFraction: Float) {
2756 Box(
2757 Modifier
2758 .fillMaxWidth(widthFraction)
2759 .height(14.dp)
2760 .clip(RoundedCornerShape(4.dp))
2761 .background(skeletonColor)
2762 .alpha(skeletonAlpha),
2765 Column(
2766 modifier = Modifier
2767 .align(Alignment.TopCenter)
2768 .padding(top = topPad)
2769 .padding(16.dp)
2770 .fillMaxWidth()
2771 .semantics { contentDescription = loadingLabel },
2772 verticalArrangement = Arrangement.spacedBy(14.dp),
2773 ) {
2774 repeat(4) { i ->
2775 val left = i % 2 == 0
2776 Row(
2777 modifier = Modifier.fillMaxWidth(),
2778 horizontalArrangement = if (left) Arrangement.Start else Arrangement.End,
2779 ) {
2780 Column(
2781 modifier = Modifier.fillMaxWidth(if (left) 0.7f else 0.55f),
2782 verticalArrangement = Arrangement.spacedBy(6.dp),
2783 ) {
2784 bar(1f)
2785 bar(0.8f)