git.iko.soy/opencode-android
on d518ef7 help sign in
3366 lines 189 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.heightIn
33import androidx.compose.foundation.layout.height
34import androidx.compose.foundation.layout.imePadding
35import androidx.compose.foundation.layout.ime
36import androidx.compose.foundation.layout.navigationBars
37import androidx.compose.foundation.layout.padding
38import androidx.compose.foundation.layout.size
39import androidx.compose.foundation.layout.union
40import androidx.compose.foundation.layout.widthIn
41import androidx.compose.foundation.layout.windowInsetsPadding
42import androidx.compose.foundation.layout.wrapContentWidth
43import androidx.compose.foundation.lazy.LazyColumn
44import androidx.compose.foundation.lazy.items
45import androidx.compose.foundation.lazy.rememberLazyListState
46import androidx.compose.foundation.shape.RoundedCornerShape
47import androidx.compose.material.icons.Icons
48import androidx.compose.material.icons.automirrored.filled.ArrowBack
49import androidx.compose.material.icons.automirrored.filled.Send
50import androidx.compose.material.icons.filled.ErrorOutline
51import androidx.compose.material.icons.filled.Add
52import androidx.compose.material.icons.filled.AutoAwesome
53import androidx.compose.material.icons.filled.Delete
54import androidx.compose.material.icons.filled.Description
55import androidx.compose.material.icons.filled.Close
56import androidx.compose.material.icons.filled.Edit
57import androidx.compose.material.icons.filled.Expand
58import androidx.compose.material.icons.filled.FormatQuote
59import androidx.compose.material.icons.filled.ExpandLess
60import androidx.compose.material.icons.filled.ExpandMore
61import androidx.compose.material.icons.filled.Search
62import androidx.compose.material.icons.filled.KeyboardArrowDown
63import androidx.compose.material.icons.filled.KeyboardArrowUp
64import androidx.compose.material.icons.filled.Mic
65import androidx.compose.material.icons.filled.MoreVert
66import androidx.compose.material.icons.filled.PhotoCamera
67import androidx.compose.material.icons.filled.PhotoLibrary
68import androidx.compose.material.icons.filled.Refresh
69import androidx.compose.material.icons.filled.Restore
70import androidx.compose.material.icons.automirrored.filled.PlaylistAdd
71import androidx.compose.material.icons.automirrored.filled.Reply
72import androidx.compose.material.icons.filled.Schedule
73import androidx.compose.material.icons.filled.Stop
74import androidx.compose.material3.AlertDialog
75import androidx.compose.material3.Badge
76import androidx.compose.material3.BadgedBox
77import androidx.compose.material3.Button
78import androidx.compose.material3.CircularProgressIndicator
79import androidx.compose.material3.SmallFloatingActionButton
80import androidx.compose.material3.DropdownMenu
81import androidx.compose.material3.DropdownMenuItem
82import androidx.compose.material3.ExperimentalMaterial3Api
83import androidx.compose.material3.ExtendedFloatingActionButton
84import androidx.compose.material3.FloatingActionButton
85import androidx.compose.material3.Icon
86import androidx.compose.material3.IconButton
87import androidx.compose.material3.MaterialTheme
88import androidx.compose.material3.OutlinedTextField
89import androidx.compose.material3.Scaffold
90import androidx.compose.material3.SnackbarDuration
91import androidx.compose.material3.SwipeToDismissBox
92import androidx.compose.material3.SwipeToDismissBoxValue
93import androidx.compose.material3.rememberSwipeToDismissBoxState
94import androidx.compose.material3.SnackbarHost
95import androidx.compose.material3.SnackbarHostState
96import androidx.compose.material3.SnackbarResult
97import androidx.compose.material3.Surface
98import androidx.compose.material3.Text
99import androidx.compose.material3.TextButton
100import androidx.compose.material3.TopAppBar
101import androidx.compose.material3.pulltorefresh.PullToRefreshBox
102import androidx.compose.runtime.Composable
103import androidx.compose.runtime.CompositionLocalProvider
104import androidx.compose.runtime.DisposableEffect
105import androidx.compose.runtime.LaunchedEffect
106import androidx.compose.runtime.derivedStateOf
107import androidx.compose.runtime.getValue
108import androidx.compose.runtime.mutableIntStateOf
109import androidx.compose.runtime.mutableLongStateOf
110import androidx.compose.runtime.mutableStateOf
111import androidx.compose.runtime.produceState
112import androidx.compose.runtime.remember
113import androidx.compose.runtime.rememberCoroutineScope
114import androidx.compose.runtime.rememberUpdatedState
115import androidx.compose.runtime.saveable.rememberSaveable
116import androidx.compose.runtime.setValue
117import androidx.compose.runtime.snapshotFlow
118import androidx.compose.foundation.text.KeyboardActions
119import androidx.compose.foundation.text.KeyboardOptions
120import androidx.compose.ui.text.input.KeyboardCapitalization
121import androidx.compose.ui.Alignment
122import androidx.compose.ui.Modifier
123import androidx.compose.ui.draw.alpha
124import androidx.compose.ui.draw.clip
125import androidx.compose.ui.focus.focusRequester
126import androidx.compose.ui.input.key.Key
127import androidx.compose.ui.input.key.KeyEventType
128import androidx.compose.ui.input.key.isCtrlPressed
129import androidx.compose.ui.input.key.isShiftPressed
130import androidx.compose.ui.input.key.key
131import androidx.compose.ui.input.key.onPreviewKeyEvent
132import androidx.compose.ui.input.key.type
133import androidx.compose.ui.layout.onSizeChanged
134import androidx.compose.ui.platform.LocalContext
135import androidx.compose.ui.platform.LocalDensity
136import androidx.compose.ui.platform.LocalHapticFeedback
137import androidx.compose.ui.platform.LocalLayoutDirection
138import androidx.compose.ui.platform.LocalView
139import androidx.compose.ui.platform.testTag
140import androidx.compose.ui.unit.LayoutDirection
141import androidx.compose.ui.res.pluralStringResource
142import androidx.compose.ui.res.stringResource
143import androidx.compose.ui.semantics.Role
144import androidx.compose.ui.semantics.contentDescription
145import androidx.compose.ui.semantics.heading
146import androidx.compose.ui.semantics.liveRegion
147import androidx.compose.ui.semantics.LiveRegionMode
148import androidx.compose.ui.semantics.invisibleToUser
149import androidx.compose.ui.semantics.semantics
150import androidx.compose.ui.text.input.ImeAction
151import androidx.compose.ui.text.style.TextOverflow
152import androidx.compose.ui.unit.dp
153import androidx.lifecycle.compose.collectAsStateWithLifecycle
154import androidx.lifecycle.viewmodel.compose.viewModel
155import kotlinx.coroutines.Dispatchers
156import kotlinx.coroutines.coroutineScope
157import kotlinx.coroutines.delay
158import kotlinx.coroutines.flow.collectLatest
159import kotlinx.coroutines.flow.distinctUntilChanged
160import kotlinx.coroutines.launch
161import kotlinx.coroutines.withContext
162import soy.iko.opencode.data.model.Part
163import soy.iko.opencode.data.model.MessageWithParts
164import soy.iko.opencode.data.model.AssistantMessage
165import soy.iko.opencode.data.model.Command
166import soy.iko.opencode.data.model.ReasoningPart
167import soy.iko.opencode.data.model.TextPart
168import soy.iko.opencode.data.model.ToolCompleted
169import soy.iko.opencode.data.model.ToolError
170import soy.iko.opencode.data.model.ToolPart
171import soy.iko.opencode.data.model.ToolRunning
172import soy.iko.opencode.data.model.FilePart
173import soy.iko.opencode.data.model.sourcePath
174import soy.iko.opencode.data.model.inputElement
175import soy.iko.opencode.data.network.EventStreamClient
176import soy.iko.opencode.data.network.NetworkConfig
177import soy.iko.opencode.di.AppContainer
178import soy.iko.opencode.R
179import soy.iko.opencode.ui.components.CommandPalette
180import soy.iko.opencode.ui.components.ConnectionBanner
181import soy.iko.opencode.ui.components.DiffView
182import soy.iko.opencode.ui.components.PaletteAction
183import soy.iko.opencode.ui.components.LocalReducedMotion
184import soy.iko.opencode.ui.components.LocalSearchHighlight
185import soy.iko.opencode.ui.components.reducedMotionAnimateItem
186import soy.iko.opencode.ui.components.copyToClipboard
187import soy.iko.opencode.ui.components.LocalRelativeTimeTick
188import soy.iko.opencode.ui.components.rememberRelativeTimeTick
189import soy.iko.opencode.ui.components.rememberVisibilityTransitions
190import soy.iko.opencode.ui.components.showToast
191import soy.iko.opencode.ui.components.toImageContext
192import soy.iko.opencode.ui.vmFactory
193import soy.iko.opencode.util.runCatchingCancellable
195// Optional message id to scroll to and briefly highlight on first load (from global search).
196// Consumed once: after the message is scrolled into view and highlighted, the focus is
197// cleared so subsequent recompositions don't re-trigger the scroll.
198@OptIn(ExperimentalMaterial3Api::class)
199@Composable
200fun ChatScreen(
201 container: AppContainer,
202 sessionId: String,
203 onBack: () -> Unit,
204 onOpenFile: ((String) -> Unit)? = null,
205 onOpenSession: ((String) -> Unit)? = null,
206 focusMessageId: String? = null,
207 onEditProfile: ((String) -> Unit)? = null,
208) {
209 val vm: ChatViewModel = viewModel(key = sessionId, factory = vmFactory { ChatViewModel(container, sessionId) })
210 val hasMessages by vm.hasMessages.collectAsStateWithLifecycle()
211 val running by vm.running.collectAsStateWithLifecycle()
212 val streamInterrupted by vm.streamInterrupted.collectAsStateWithLifecycle()
213 val aborting by vm.aborting.collectAsStateWithLifecycle()
214 val loading by vm.loading.collectAsStateWithLifecycle()
215 val loadError by vm.loadError.collectAsStateWithLifecycle()
216 val loadErrorInline by vm.loadErrorInline.collectAsStateWithLifecycle()
217 val refreshing by vm.refreshing.collectAsStateWithLifecycle()
218 val models by vm.models.collectAsStateWithLifecycle()
219 val modelsLoading by vm.modelsLoading.collectAsStateWithLifecycle()
220 val modelsError by vm.modelsError.collectAsStateWithLifecycle()
221 val selectedModel by vm.selectedModel.collectAsStateWithLifecycle()
222 val connectionState by vm.connectionState.collectAsStateWithLifecycle()
223 val reconnectAttempts by vm.reconnectAttempts.collectAsStateWithLifecycle()
224 val pendingPermission by vm.pendingPermission.collectAsStateWithLifecycle()
225 val permissionProgress by vm.permissionProgress.collectAsStateWithLifecycle()
226 val agents by vm.agents.collectAsStateWithLifecycle()
227 val agentsLoading by vm.agentsLoading.collectAsStateWithLifecycle()
228 val agentsError by vm.agentsError.collectAsStateWithLifecycle()
229 val selectedAgent by vm.selectedAgent.collectAsStateWithLifecycle()
230 val commands by vm.commands.collectAsStateWithLifecycle()
231 val commandsLoading by vm.commandsLoading.collectAsStateWithLifecycle()
232 val commandsError by vm.commandsError.collectAsStateWithLifecycle()
233 val sessionTitle by vm.sessionTitle.collectAsStateWithLifecycle()
234 val sessionDeleted by vm.sessionDeleted.collectAsStateWithLifecycle()
235 val draft by vm.draft.collectAsStateWithLifecycle()
236 val pendingQuote by vm.pendingQuote.collectAsStateWithLifecycle()
237 val attachments by vm.attachments.collectAsStateWithLifecycle()
238 val reverted by vm.reverted.collectAsStateWithLifecycle()
239 val editing by vm.editing.collectAsStateWithLifecycle()
240 val revertDiff by vm.revertDiff.collectAsStateWithLifecycle()
241 val shareUrl by vm.shareUrl.collectAsStateWithLifecycle()
242 val reconnecting by vm.reconnecting.collectAsStateWithLifecycle()
243 val sendOnEnter by container.settingsStore.sendOnEnter.collectAsStateWithLifecycle(initialValue = true)
244 val preferredModelId by container.settingsStore.preferredModelId.collectAsStateWithLifecycle(initialValue = "")
245 val recentModelEntries by container.recentModelsStore.entries.collectAsStateWithLifecycle()
246 val compactSpacing by container.settingsStore.compactMessageSpacing.collectAsStateWithLifecycle(initialValue = false)
247 val notifPermission by container.settingsStore.notifPermission.collectAsStateWithLifecycle(initialValue = true)
248 val isOnline by container.isOnline.collectAsStateWithLifecycle()
249 val haptics = LocalHapticFeedback.current
250 val layoutDirection = LocalLayoutDirection.current
251 val scope = rememberCoroutineScope()
252 val shareContext = LocalContext.current
253 val defaultShareSubject = stringResource(R.string.share_subject)
254 val sessionLabel = stringResource(R.string.session)
255 val defaultModelLabel = stringResource(R.string.default_model)
256 val subtitle = remember(selectedModel?.modelLabel, selectedAgent, defaultModelLabel) {
257 buildString {
258 append(selectedModel?.modelLabel ?: defaultModelLabel)
259 selectedAgent?.let { append(" · $it") }
260 }
261 }
263 // Connection profile (with auth) for rendering inline image attachments.
264 // Collect as state so a server switch recomposes the image context.
265 val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
266 val imageContext = remember(activeConnection?.profile) {
267 activeConnection?.profile?.toImageContext()
268 }
270 // Mark this session as read while the user is viewing it; clear on leave so new
271 // background activity can badge it again.
272 DisposableEffect(sessionId) {
273 container.setCurrentSession(sessionId)
274 onDispose { if (container.currentSession.value == sessionId) container.setCurrentSession(null) }
275 }
277 // Navigate away when the session is deleted via SSE so the user isn't left
278 // on a zombie screen showing a conversation that no longer exists. Surface a
279 // toast (not a snackbar) because the screen is about to be popped — a snackbar
280 // would be torn down before the user could read it, while a toast survives.
281 // Covers both SSE-driven deletion (deleted on another device) and a user-
282 // initiated delete from the chat overflow menu (deleteSession in ChatViewModel
283 // sets the same flag on success).
284 val deletedContext = LocalContext.current
285 LaunchedEffect(sessionDeleted) {
286 if (sessionDeleted) {
287 showToast(deletedContext, deletedContext.getString(R.string.session_deleted_chat))
288 onBack()
289 }
290 }
292 val snackbar = remember { SnackbarHostState() }
293 val retryLabel = stringResource(R.string.retry)
294 val inputFocusRequester = remember { androidx.compose.ui.focus.FocusRequester() }
295 // Dedicated focus requester for the in-conversation search field. The overflow menu's
296 // "Find in conversation" and Ctrl+F previously called inputFocusRequester.requestFocus()
297 // (the composer), so typing went to the composer instead of the search field.
298 val searchFocusRequester = remember { androidx.compose.ui.focus.FocusRequester() }
299 var showModelPicker by rememberSaveable { mutableStateOf(false) }
300 var showAgentPicker by rememberSaveable { mutableStateOf(false) }
301 var showTitleMenu by rememberSaveable { mutableStateOf(false) }
302 var showCommandPicker by rememberSaveable { mutableStateOf(false) }
303 var showExitConfirm by rememberSaveable { mutableStateOf(false) }
304 var showOverflowMenu by rememberSaveable { mutableStateOf(false) }
305 var showRenameDialog by rememberSaveable { mutableStateOf(false) }
306 var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
307 var showShellDialog by rememberSaveable { mutableStateOf(false) }
308 var showPalette by rememberSaveable { mutableStateOf(false) }
309 var showShortcutsDialog by rememberSaveable { mutableStateOf(false) }
310 var searchActive by rememberSaveable { mutableStateOf(false) }
311 var chatSearch by rememberSaveable { mutableStateOf("") }
312 // Index of the currently-focused in-conversation search match (into the filtered list).
313 // Drives the up/down navigation: the search bar's arrows step through matches and scroll
314 // the list to each one, mirroring desktop chat apps (the bare filtered list previously
315 // required reading each message to find the term).
316 var searchPos by rememberSaveable { mutableIntStateOf(0) }
317 // Summarize and init (generate AGENTS.md) are irreversible, so a single tap is gated behind
318 // a confirmation dialog (from both the overflow menu and the command palette).
319 var showSummarizeConfirm by rememberSaveable { mutableStateOf(false) }
320 var showInitConfirm by rememberSaveable { mutableStateOf(false) }
322 // Command-palette entries (keyboard-first: Ctrl+K). Labels resolved here so the remembered
323 // action list doesn't need a Composable context. Actions map to the same handlers the
324 // overflow menu drives, so the palette is a keyboard shortcut to them, not a parallel path.
325 val paletteModelLabel = stringResource(R.string.palette_switch_model)
326 val paletteAgentLabel = stringResource(R.string.palette_choose_agent)
327 val paletteCommandLabel = stringResource(R.string.palette_run_command)
328 val paletteSummarizeLabel = stringResource(R.string.palette_summarize)
329 val paletteRenameLabel = stringResource(R.string.rename_session_chat)
330 val paletteShellLabel = stringResource(R.string.run_shell_command)
331 val paletteActions = remember(
332 paletteModelLabel, paletteAgentLabel, paletteCommandLabel,
333 paletteSummarizeLabel, paletteRenameLabel, paletteShellLabel, hasMessages, running,
334 ) {
335 buildList {
336 add(PaletteAction("model", paletteModelLabel) { showModelPicker = true })
337 add(PaletteAction("agent", paletteAgentLabel) { showAgentPicker = true })
338 add(PaletteAction("command", paletteCommandLabel) { showCommandPicker = true })
339 if (hasMessages && !running) add(PaletteAction("summarize", paletteSummarizeLabel) { showSummarizeConfirm = true })
340 if (!running) add(PaletteAction("shell", paletteShellLabel) { showShellDialog = true })
341 add(PaletteAction("rename", paletteRenameLabel) { showRenameDialog = true })
342 }
343 }
345 // Keep the screen awake while the agent is working in *this* session. The foreground
346 // service that holds process priority during a run is managed app-wide off
347 // container.anyRunActive (see OpencodeApp) so it survives navigating away from the chat
348 // mid-run — otherwise leaving the screen would drop priority and Doze could choke the
349 // SSE stream before the completion notification fires.
350 val currentView = LocalView.current
351 val appContext = LocalContext.current.applicationContext
352 DisposableEffect(running) {
353 currentView.keepScreenOn = running
354 onDispose { currentView.keepScreenOn = false }
355 }
357 LaunchedEffect(Unit) {
358 vm.errorEvents.collect { event ->
359 // Only a failed *send* is retryable; attaching Retry to any other error (e.g. a
360 // message-load failure) would silently re-submit the last prompt on tap.
361 val result = if (event.retryable) {
362 snackbar.showSnackbar(message = event.message, actionLabel = retryLabel)
363 } else {
364 snackbar.showSnackbar(event.message)
365 }
366 if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) vm.retryFailed()
367 }
368 }
370 // Stop-undo: abort() emits the last-sent prompt here so this collector can show a
371 // "Stopped — Undo" snackbar. Tapping Undo re-sends the prompt, recovering an accidental
372 // Stop without a confirmation dialog (Stop now happens on first tap).
373 val undoStopLabel = stringResource(R.string.undo)
374 val stoppedLabel = stringResource(R.string.run_stopped_undo)
375 LaunchedEffect(Unit) {
376 vm.stopUndoEvents.collect { prompt ->
377 if (prompt != null) {
378 val result = snackbar.showSnackbar(message = stoppedLabel, actionLabel = undoStopLabel)
379 if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) vm.resendLastPrompt()
380 }
381 }
382 }
384 // In-place Undo for a user-initiated session delete. The delete is deferred for an undo
385 // window on the container; show the snackbar over the conversation the user is viewing so
386 // they can cancel without being navigated to the session list first. If the window expires
387 // the scheduled delete sets `sessionDeleted` and the effect below navigates away.
388 //
389 // collectLatest (not collect): a serialized collect queues each snackbar behind the
390 // previous one's full window, so under a rapid re-delete (delete A, then delete the same
391 // session again, or a delete timed just after the first) the VM delete timer (started at
392 // emit time) fires before the second snackbar is ever shown — a dead Undo button.
393 // collectLatest cancels the current snackbar and shows the newest immediately, keeping its
394 // Undo window aligned with its timer. Tracking `pending` mirrors SessionListScreen: when a
395 // newer delete supersedes the prior snackbar mid-window, the prior session's deferred REST
396 // delete (still scheduled on the container's scope) would fire with no reachable Undo.
397 // Withdrawing it via cancelSessionDelete cancels the timer (no-op if it already fired), so
398 // no session is silently deleted without a chance to undo.
399 val undoLabel = stringResource(R.string.undo)
400 val sessionDeletedLabel = stringResource(R.string.session_deleted_chat)
401 LaunchedEffect(Unit) {
402 var pending: String? = null
403 vm.deleteUndoEvents.collectLatest { id ->
404 pending?.let { vm.cancelSessionDelete(it) }
405 pending = id
406 coroutineScope {
407 val dismisser = launch {
408 delay(NetworkConfig.undoDeleteDelayMs)
409 snackbar.currentSnackbarData?.dismiss()
410 }
411 val result = snackbar.showSnackbar(
412 message = sessionDeletedLabel,
413 actionLabel = undoLabel,
414 duration = SnackbarDuration.Indefinite,
415 )
416 dismisser.cancel()
417 if (result == SnackbarResult.ActionPerformed) {
418 vm.cancelSessionDelete(id)
419 pending = null
420 }
421 }
422 }
423 }
425 // --- Attachments, voice, and share-link plumbing ---
426 val attachFailedMsg = stringResource(R.string.attachment_failed)
427 val noCameraMsg = stringResource(R.string.no_camera_app)
428 val noVoiceMsg = stringResource(R.string.no_voice_app)
429 val voicePrompt = stringResource(R.string.voice_prompt)
430 val linkCopiedMsg = stringResource(R.string.link_copied)
431 val ttsUnavailableMsg = stringResource(R.string.tts_unavailable)
433 // Count of picks currently being read + base64-encoded off the main thread, so the
434 // composer can show a staging placeholder immediately (chips only materialize once done).
435 // Backed by the ViewModel (vm.stagingCount / vm.stagingFileTotal) so the staging state
436 // survives a configuration change: the encode work runs on viewModelScope (which
437 // survives rotation), so the counters must live on the VM too — remember { mutableStateOf
438 // } in the composable would reset to 0 on rotation and lose the staging chip mid-encode.
439 val stagingCount by vm.stagingCount.collectAsStateWithLifecycle()
440 val stagingFileTotal by vm.stagingFileTotal.collectAsStateWithLifecycle()
442 // Convert each picked Uri to a base64 attachment on the VM's scope (survives rotation),
443 // honoring the per-prompt count cap and surfacing per-file errors via the VM's errorEvents.
444 fun stageUris(uris: List<Uri>) {
445 vm.stageUris(uris, appContext)
446 }
448 val photoPicker = rememberLauncherForActivityResult(
449 ActivityResultContracts.PickMultipleVisualMedia(NetworkConfig.maxAttachments),
450 ) { uris -> stageUris(uris) }
452 val filePicker = rememberLauncherForActivityResult(
453 ActivityResultContracts.GetMultipleContents(),
454 ) { uris -> stageUris(uris) }
456 // Uri survives a config change mid-capture so the TakePicture result still resolves.
457 var pendingCaptureUri by rememberSaveable { mutableStateOf<Uri?>(null) }
458 val cameraLauncher = rememberLauncherForActivityResult(
459 ActivityResultContracts.TakePicture(),
460 ) { success ->
461 val uri = pendingCaptureUri
462 pendingCaptureUri = null
463 if (success && uri != null) stageUris(listOf(uri))
464 }
466 val voiceLauncher = rememberLauncherForActivityResult(
467 ActivityResultContracts.StartActivityForResult(),
468 ) { result ->
469 if (result.resultCode == Activity.RESULT_OK) {
470 val spoken = result.data
471 ?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)
472 ?.firstOrNull()
473 ?.takeIf { it.isNotBlank() }
474 if (spoken != null) {
475 // Append to the existing draft rather than replacing, so dictation adds to
476 // whatever the user already typed.
477 val current = vm.draft.value
478 val combined = if (current.isBlank()) spoken else "$current $spoken"
479 vm.updateDraft(combined.take(NetworkConfig.maxDraftLengthChars))
480 }
481 }
482 }
484 fun launchCamera() {
485 val uri = newCameraCaptureUri(appContext)
486 if (uri == null) {
487 scope.launch { snackbar.showSnackbar(attachFailedMsg) }
488 return
489 }
490 pendingCaptureUri = uri
491 runCatching { cameraLauncher.launch(uri) }
492 .onFailure { pendingCaptureUri = null; scope.launch { snackbar.showSnackbar(noCameraMsg) } }
493 }
495 fun launchVoice() {
496 val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
497 putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
498 putExtra(RecognizerIntent.EXTRA_PROMPT, voicePrompt)
499 }
500 runCatching { voiceLauncher.launch(intent) }
501 .onFailure { scope.launch { snackbar.showSnackbar(noVoiceMsg) } }
502 }
504 // A freshly-created share link is copied to the clipboard so the user can paste it right away.
505 LaunchedEffect(Unit) {
506 vm.shareLinkEvents.collect { url ->
507 copyToClipboard(shareContext, shareContext.getString(R.string.clip_label_share), url)
508 snackbar.showSnackbar(linkCopiedMsg)
509 }
510 }
512 // Navigate to a session freshly branched from a message (branchFrom), once it's created.
513 // Keyed on Unit (like the sibling one-shot collectors above): keying on the onOpenSession
514 // lambda would cancel/restart this collector whenever the lambda's identity changes, risking
515 // a dropped branch-navigation event in the gap. The lambda is wrapped in rememberUpdatedState
516 // so the Unit-keyed collector invokes the LATEST lambda instead of the one captured at first
517 // composition — a latent footgun if a future change makes onOpenSession depend on current
518 // Compose state (e.g. pane-aware routing). The collector itself is never cancelled, so no
519 // event is dropped.
520 val branchedMsg = stringResource(R.string.branch_created)
521 val currentOnOpenSession by rememberUpdatedState(onOpenSession)
522 LaunchedEffect(Unit) {
523 vm.branchEvents.collect { newId ->
524 showToast(shareContext, branchedMsg)
525 currentOnOpenSession?.invoke(newId)
526 }
527 }
529 // Stage images shared into the app (via the system share sheet) as attachments.
530 // Keyed on pendingSharedMedia (not Unit) so the effect re-fires when new media arrives
531 // while this ChatScreen is already composed — which happens in two-pane mode, where the
532 // detail ChatScreen is persistent (keyed on sessionId) and a LaunchedEffect(Unit) would
533 // only fire on first composition. consumePendingSharedMedia() atomically drains the
534 // pending list (compareAndSet to empty), so re-firing on the now-empty value is a no-op.
535 val pendingSharedMedia by container.pendingSharedMedia.collectAsStateWithLifecycle()
536 LaunchedEffect(pendingSharedMedia) {
537 val shared = container.consumePendingSharedMedia()
538 if (shared.isNotEmpty()) stageUris(shared.map { Uri.parse(it) })
539 }
541 fun doSend() {
542 if (vm.send(draft)) {
543 // TextHandleMove (a light tick) is semantically right for a send commit;
544 // LongPress is reserved for long-press/swipe gestures.
545 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
546 }
547 }
549 BackHandler(enabled = running && !showModelPicker && !showAgentPicker && !showCommandPicker) { showExitConfirm = true }
551 Scaffold(
552 // Hardware-keyboard shortcuts (tablets / DeX / Chromebooks): Ctrl+K opens the command
553 // palette; Escape closes it, or stops a run. onPreviewKeyEvent on the Scaffold root sees
554 // events before the focused text field, but only Ctrl+K / Escape are consumed so normal
555 // typing (and the composer's own Enter-to-send handling) is untouched.
556 modifier = Modifier.onPreviewKeyEvent { ev ->
557 if (ev.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
558 when {
559 ev.isCtrlPressed && ev.key == Key.K -> { showPalette = true; true }
560 // Ctrl+F opens in-conversation search (the standard find shortcut), focusing
561 // the field so the user can type immediately.
562 ev.isCtrlPressed && ev.key == Key.F -> { searchActive = true; true }
563 ev.key == Key.Escape && showPalette -> { showPalette = false; true }
564 ev.key == Key.Escape && searchActive -> { searchActive = false; chatSearch = ""; true }
565 ev.key == Key.Escape && running && !showExitConfirm -> {
566 vm.abort(); true
567 }
568 else -> false
569 }
570 },
571 topBar = {
572 TopAppBar(
573 title = {
574 val changeLabel = stringResource(R.string.change_model_agent)
575 // The subtitle reads "model · agent", so tapping the title offers *both*
576 // — a small menu picks which picker to open. Previously the tap went
577 // straight to the model picker, leaving the agent changeable only via
578 // the overflow menu despite the title implying otherwise.
579 Box {
580 Row(
581 // mergeDescendants so TalkBack reads the title/subtitle Texts as one
582 // node; the click label rides on `clickable` (onClickLabel) instead of
583 // an explicit contentDescription, which would have SUPPRESSED the child
584 // Texts and announced only "Change model and agent".
585 modifier = Modifier
586 .clickable(
587 role = Role.Button,
588 onClickLabel = changeLabel,
589 ) { showTitleMenu = true }
590 .semantics(mergeDescendants = true) {},
591 verticalAlignment = Alignment.CenterVertically,
592 ) {
593 Column(modifier = Modifier.weight(1f, fill = false)) {
594 // maxLines=1 + Ellipsis so a long session title can't wrap and
595 // push the top bar to multiple lines, shifting the whole layout.
596 Text(
597 sessionTitle ?: sessionLabel,
598 maxLines = 1,
599 overflow = TextOverflow.Ellipsis,
600 )
601 Text(
602 subtitle,
603 style = MaterialTheme.typography.labelSmall,
604 color = MaterialTheme.colorScheme.onSurfaceVariant,
605 maxLines = 1,
606 overflow = TextOverflow.Ellipsis,
607 )
608 }
609 // Always show the affordance so the title reads as tappable even when
610 // the model catalog failed to load (models empty). Without it the title
611 // is indistinguishable from plain text and the pickers are effectively
612 // undiscoverable — and the dropdown items already disable themselves
613 // while a catalog is loading or unavailable.
614 Icon(
615 Icons.Filled.KeyboardArrowDown,
616 contentDescription = null,
617 tint = MaterialTheme.colorScheme.onSurfaceVariant,
618 modifier = Modifier.size(20.dp),
619 )
620 }
621 DropdownMenu(expanded = showTitleMenu, onDismissRequest = { showTitleMenu = false }) {
622 DropdownMenuItem(
623 text = { Text(stringResource(R.string.palette_switch_model)) },
624 enabled = !modelsLoading || models.isNotEmpty(),
625 onClick = { showTitleMenu = false; showModelPicker = true },
626 )
627 DropdownMenuItem(
628 text = { Text(stringResource(R.string.palette_choose_agent)) },
629 enabled = !agentsLoading || agents.isNotEmpty(),
630 onClick = { showTitleMenu = false; showAgentPicker = true },
631 )
632 }
633 }
634 },
635 navigationIcon = {
636 IconButton(onClick = {
637 if (running) showExitConfirm = true else onBack()
638 }) {
639 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
640 }
641 },
642 actions = {
643 // At-a-glance run status in the top bar. When the user is scrolled
644 // up (jump-to-latest FAB visible), the input-bar Stop button and the
645 // trailing "working…" row are off-screen, so a top-bar spinner is the
646 // only signal the agent is still working.
647 if (running) {
648 val workingLabel = stringResource(R.string.working)
649 CircularProgressIndicator(
650 Modifier.size(18.dp).semantics { contentDescription = workingLabel },
651 strokeWidth = 2.dp,
652 )
653 }
654 // Manual refresh: forces an SSE reconnect, which re-seeds messages
655 // from REST. A recovery path when the stream silently drops and the
656 // auto-reconnect re-seed is slow or fails. Shows a brief spinner as
657 // immediate tap feedback — the SSE reconnect may not visibly change
658 // the connection state when already Connected, so without it the tap
659 // appears to do nothing. Disabled while a run is active: a mid-run
660 // refresh forces a reconnect that can interrupt the streaming reply,
661 // and the working spinner already signals "wait" — the recovery path
662 // is only needed when nothing is actively streaming.
663 val refreshLabel = stringResource(R.string.refresh)
664 IconButton(
665 onClick = { vm.refreshMessages() },
666 enabled = activeConnection != null && !refreshing && !running,
667 ) {
668 if (refreshing) {
669 CircularProgressIndicator(
670 Modifier.size(18.dp).semantics { contentDescription = refreshLabel },
671 strokeWidth = 2.dp,
672 )
673 } else {
674 Icon(Icons.Filled.Refresh, contentDescription = refreshLabel)
675 }
676 }
677 // Overflow menu consolidates the less-frequent actions (share,
678 // commands, agent, model, rename, delete) so the top bar stays
679 // scannable on narrow phones — previously 5 icons crowded the bar
680 // alongside the back button and tappable title. Catalog-loading
681 // spinners stay inline (next to the overflow) so a load state is
682 // visible without opening the menu.
683 val moreLabel = stringResource(R.string.more)
684 val loadingLabel = stringResource(R.string.loading)
685 val shareLabel = stringResource(R.string.share_conversation)
686 val copyAsMarkdownLabel = stringResource(R.string.copy_as_markdown)
687 val shareAsJsonLabel = stringResource(R.string.share_as_json)
688 val copyAsJsonLabel = stringResource(R.string.copy_as_json)
689 val commandsLabel = stringResource(R.string.commands)
690 val renameLabel = stringResource(R.string.rename_session_chat)
691 val deleteLabel = stringResource(R.string.delete_session_chat)
692 val createShareLabel = stringResource(R.string.create_share_link)
693 val copyShareLabel = stringResource(R.string.copy_share_link)
694 val stopShareLabel = stringResource(R.string.stop_sharing)
695 val summarizeLabel = stringResource(R.string.summarize_conversation)
696 val initLabel = stringResource(R.string.generate_agents_md)
697 val shellLabel = stringResource(R.string.run_shell_command)
698 Box {
699 IconButton(onClick = { showOverflowMenu = true }) {
700 Icon(Icons.Filled.MoreVert, contentDescription = moreLabel)
701 }
702 DropdownMenu(
703 expanded = showOverflowMenu,
704 onDismissRequest = { showOverflowMenu = false },
705 ) {
706 DropdownMenuItem(
707 text = { Text(shareLabel) },
708 enabled = hasMessages,
709 onClick = {
710 showOverflowMenu = false
711 scope.launch {
712 val md = withContext(Dispatchers.Default) {
713 buildConversationMarkdown(vm.messages.value, sessionTitle)
714 }
715 val send = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
716 type = "text/markdown"
717 putExtra(android.content.Intent.EXTRA_SUBJECT, sessionTitle ?: defaultShareSubject)
718 putExtra(android.content.Intent.EXTRA_TEXT, md)
719 }
720 runCatchingCancellable { shareContext.startActivity(android.content.Intent.createChooser(send, shareLabel)) }
721 .onFailure { showToast(shareContext, shareContext.getString(R.string.no_share_app)) }
722 }
723 },
724 )
725 // Copy the whole conversation as Markdown to the clipboard, so a
726 // user can paste into another app without the share-sheet round-trip.
727 DropdownMenuItem(
728 text = { Text(copyAsMarkdownLabel) },
729 enabled = hasMessages,
730 onClick = {
731 showOverflowMenu = false
732 scope.launch {
733 val md = withContext(Dispatchers.Default) {
734 buildConversationMarkdown(vm.messages.value, sessionTitle)
735 }
736 copyToClipboard(shareContext, shareContext.getString(R.string.clip_label_message), md)
737 }
738 },
739 )
740 // Lossless JSON share: the full List<MessageWithParts> serialized with
741 // the shared OpencodeJson, so tool outputs, file sources, and reasoning
742 // survive verbatim (the Markdown export truncates/summarizes them).
743 DropdownMenuItem(
744 text = { Text(shareAsJsonLabel) },
745 enabled = hasMessages,
746 onClick = {
747 showOverflowMenu = false
748 scope.launch {
749 val json = withContext(Dispatchers.Default) {
750 buildConversationJson(vm.messages.value)
751 }
752 val send = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
753 type = "application/json"
754 putExtra(android.content.Intent.EXTRA_SUBJECT, sessionTitle ?: defaultShareSubject)
755 putExtra(android.content.Intent.EXTRA_TEXT, json)
756 }
757 runCatchingCancellable { shareContext.startActivity(android.content.Intent.createChooser(send, shareAsJsonLabel)) }
758 .onFailure { showToast(shareContext, shareContext.getString(R.string.no_share_app)) }
759 }
760 },
761 )
762 DropdownMenuItem(
763 text = { Text(copyAsJsonLabel) },
764 enabled = hasMessages,
765 onClick = {
766 showOverflowMenu = false
767 scope.launch {
768 val json = withContext(Dispatchers.Default) {
769 buildConversationJson(vm.messages.value)
770 }
771 copyToClipboard(shareContext, shareContext.getString(R.string.clip_label_message), json)
772 }
773 },
774 )
775 DropdownMenuItem(
776 text = { Text(commandsLabel) },
777 enabled = !commandsLoading || commands.isNotEmpty(),
778 onClick = { showOverflowMenu = false; showCommandPicker = true },
779 )
780 DropdownMenuItem(
781 text = { Text(stringResource(R.string.keyboard_shortcuts)) },
782 onClick = { showOverflowMenu = false; showShortcutsDialog = true },
783 )
784 DropdownMenuItem(
785 text = { Text(stringResource(R.string.find_in_conversation)) },
786 onClick = {
787 showOverflowMenu = false
788 searchActive = true
789 // Focus is requested by the LaunchedEffect(searchActive)
790 // below, which waits for the search field to be composed
791 // before requesting focus (calling requestFocus here would
792 // target a field that isn't in the composition yet).
793 },
794 )
795 androidx.compose.material3.HorizontalDivider()
796 // Session sharing: create a public link, copy an existing one, or revoke it.
797 if (shareUrl == null) {
798 DropdownMenuItem(
799 text = { Text(createShareLabel) },
800 enabled = hasMessages,
801 onClick = { showOverflowMenu = false; vm.shareSession() },
802 )
803 } else {
804 DropdownMenuItem(
805 text = { Text(copyShareLabel) },
806 onClick = {
807 showOverflowMenu = false
808 shareUrl?.let {
809 copyToClipboard(shareContext, shareContext.getString(R.string.clip_label_share), it)
810 scope.launch { snackbar.showSnackbar(linkCopiedMsg) }
811 }
812 },
813 )
814 DropdownMenuItem(
815 text = { Text(stopShareLabel) },
816 onClick = { showOverflowMenu = false; vm.unshareSession() },
817 )
818 }
819 DropdownMenuItem(
820 text = { Text(summarizeLabel) },
821 enabled = hasMessages && !running,
822 onClick = { showOverflowMenu = false; showSummarizeConfirm = true },
823 )
824 DropdownMenuItem(
825 text = { Text(initLabel) },
826 enabled = !running,
827 onClick = { showOverflowMenu = false; showInitConfirm = true },
828 )
829 DropdownMenuItem(
830 text = { Text(shellLabel) },
831 enabled = !running,
832 onClick = { showOverflowMenu = false; showShellDialog = true },
833 )
834 androidx.compose.material3.HorizontalDivider()
835 DropdownMenuItem(
836 text = { Text(renameLabel) },
837 onClick = { showOverflowMenu = false; showRenameDialog = true },
838 )
839 DropdownMenuItem(
840 text = { Text(deleteLabel, color = MaterialTheme.colorScheme.error) },
841 onClick = { showOverflowMenu = false; showDeleteDialog = true },
842 )
843 }
844 // While a catalog is initially loading (empty list + loading flag),
845 // show a tiny inline spinner beside the overflow so the user can see
846 // a load is in progress without opening the menu. Once loaded the
847 // spinner disappears; the menu items are always enabled (tapping a
848 // picker with no items shows the "no models/agents/commands" state).
849 val catalogLoading = (commandsLoading && commands.isEmpty()) ||
850 (agentsLoading && agents.isEmpty()) ||
851 (modelsLoading && models.isEmpty())
852 if (catalogLoading) {
853 CircularProgressIndicator(
854 Modifier
855 .align(Alignment.TopEnd)
856 .size(10.dp)
857 .semantics { contentDescription = loadingLabel },
858 strokeWidth = 1.dp,
859 )
860 }
861 }
862 },
863 )
864 },
865 snackbarHost = { SnackbarHost(snackbar) },
866 bottomBar = {
867 val queuedFollowUp by vm.queuedFollowUp.collectAsStateWithLifecycle()
868 val todoPlan by vm.todoPlan.collectAsStateWithLifecycle()
869 val queuedOffline by vm.outbox.collectAsStateWithLifecycle()
870 val outboxSending by container.outboxSending.collectAsStateWithLifecycle()
871 Column {
872 // Pinned above the composer so the agent's plan and progress stay visible
873 // while scrolled up mid-run; renders nothing when there's no plan.
874 TodoPlanBar(todoPlan)
875 val bannerMotion = rememberVisibilityTransitions()
876 AnimatedVisibility(
877 visible = reverted,
878 enter = bannerMotion.enter,
879 exit = bannerMotion.exit,
880 ) {
881 RevertBanner(
882 diff = revertDiff,
883 isEditing = editing,
884 onUndo = { vm.unrevert() },
885 )
886 }
887 AnimatedVisibility(
888 visible = queuedOffline.isNotEmpty(),
889 enter = bannerMotion.enter,
890 exit = bannerMotion.exit,
891 ) {
892 OutboxBanner(
893 count = queuedOffline.size,
894 sending = outboxSending,
895 onFlush = { vm.flushQueued() },
896 onDiscard = { vm.discardAllQueued() },
897 )
898 }
899 AnimatedVisibility(
900 visible = pendingQuote != null,
901 enter = bannerMotion.enter,
902 exit = bannerMotion.exit,
903 ) {
904 pendingQuote?.let { QuoteReplyBanner(it, onCancel = { vm.cancelQuoteReply() }) }
905 }
906 ChatInputBar(
907 value = draft,
908 onValueChange = vm::updateDraft,
909 running = running,
910 aborting = aborting,
911 enabled = activeConnection != null,
912 sendOnEnter = sendOnEnter,
913 onSend = ::doSend,
914 onAbort = { vm.abort() },
915 queuedFollowUp = queuedFollowUp,
916 onQueueFollowUp = vm::queueFollowUp,
917 onCancelQueue = { vm.queueFollowUp("") },
918 focusRequester = inputFocusRequester,
919 attachments = attachments,
920 staging = stagingCount > 0,
921 stagingFileCount = stagingFileTotal,
922 onRemoveAttachment = vm::removeAttachment,
923 onPickPhoto = {
924 photoPicker.launch(
925 PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly),
926 )
927 },
928 onPickFile = { filePicker.launch("*/*") },
929 onCamera = { launchCamera() },
930 onVoice = { launchVoice() },
931 onPasteImage = { stageUris(it) },
932 commands = commands,
933 onRunCommand = { cmd ->
934 vm.runCommand(cmd)
935 // Clear the draft so the typed /prefix doesn't linger after the
936 // command fires (the command picker confirmation, if any, is
937 // handled inside runCommand).
938 vm.updateDraft("")
939 },
940 )
941 }
942 },
943 ) { padding ->
944 // Collect messages inside the content lambda so streaming token updates
945 // recompose only this subtree, not the top bar / input bar / 20+ other
946 // state reads in the ChatScreen body.
947 val messages by vm.messages.collectAsStateWithLifecycle()
948 val optimisticStatuses by vm.optimisticStatuses.collectAsStateWithLifecycle()
949 val listState = rememberLazyListState()
950 val contentScope = rememberCoroutineScope()
951 val timeTick = rememberRelativeTimeTick()
952 // Dismiss the soft keyboard when the user scrolls the message list, a standard Android
953 // chat affordance for reading while the IME is open. Watching isScrollInProgress via
954 // snapshotFlow avoids wiring a nested scroll connection and fires on any scroll gesture.
955 val keyboardController = androidx.compose.ui.platform.LocalSoftwareKeyboardController.current
956 androidx.compose.runtime.LaunchedEffect(listState) {
957 androidx.compose.runtime.snapshotFlow { listState.isScrollInProgress }
958 .distinctUntilChanged()
959 .collect { scrolling -> if (scrolling) keyboardController?.hide() }
960 } // Group messages by day and interleave date separators so a long conversation
961 // has visual "Today"/"Yesterday"/date breaks. Computed once per messages
962 // emission (memoized) so a scroll-induced recomposition doesn't re-scan. Lives
963 // outside the PullToRefreshBox so the auto-scroll effects below can reference
964 // the same list the LazyColumn renders (separators shift the indices vs. the
965 // raw messages list, so scroll targets must be in listItems space).
966 val todayLabel = stringResource(R.string.today)
967 val yesterdayLabel = stringResource(R.string.yesterday)
968 // In-conversation search: when active, narrow to messages whose text contains the query.
969 // Computed on the filtered set so day separators still align with the visible messages.
970 // Debounced + off-main-thread so a keystroke-by-keystroke filter walk over a long
971 // conversation (with many tool parts whose state stringification is non-trivial)
972 // doesn't jank the UI. produceState yields the empty list immediately and publishes
973 // the filtered result once the debounce settles.
974 val searchQuery = chatSearch.trim()
975 // When no search is active, skip the produceState indirection entirely and use the
976 // messages list directly. produceState keyed on (messages, searchQuery) re-launches
977 // its coroutine on every messages emission (every streamed token) even when the
978 // query is empty — the body just re-assigns `value = messages`, but the coroutine
979 // churn (launch + cancel + state write) ~20x/sec is pure overhead. Using `messages`
980 // directly when there's no search avoids that, and `buildMessageListItems` keys its
981 // own remember on the resulting list identity either way.
982 val searchMessages: List<MessageWithParts> = if (searchQuery.isEmpty()) {
983 messages
984 } else {
985 // Capture the messages list via rememberUpdatedState so the latest list is read
986 // when the debounce completes, WITHOUT re-launching the produceState coroutine on
987 // every messages emission. Keying produceState on `messages` restarts the debounce
988 // delay from zero on every streamed token (~20×/sec); if tokens arrive faster than
989 // the debounce interval the filter never runs and search shows "no matches" for the
990 // entire run. Keying only on `searchQuery` lets the debounce settle once per query
991 // while still filtering against the most recent list.
992 val currentMessages by rememberUpdatedState(messages)
993 val filtered by produceState(
994 initialValue = emptyList(),
995 searchQuery,
996 ) {
997 // Debounce: wait for the user to stop typing before running the filter,
998 // so each keystroke doesn't kick off a fresh scan.
999 kotlinx.coroutines.delay(NetworkConfig.chatSearchDebounceMs)
1000 // Run the filter off the main thread — the walk stringifies tool state
1001 // and scans every part of every message, which is non-trivial work for a
1002 // long conversation.
1003 value = withContext(Dispatchers.Default) {
1004 currentMessages.filter { m ->
1005 m.parts.any { p ->
1006 when (p) {
1007 is TextPart -> p.text.contains(searchQuery, ignoreCase = true)
1008 is ReasoningPart -> p.text.contains(searchQuery, ignoreCase = true)
1009 is ToolPart -> {
1010 // Match the tool name plus its running/completed/error text and the
1011 // stringified input, so searching for an error string or a file the
1012 // tool touched actually finds the message it appeared in.
1013 p.tool.contains(searchQuery, ignoreCase = true) ||
1014 (p.state is ToolRunning && p.state.title?.contains(searchQuery, ignoreCase = true) == true) ||
1015 (p.state is ToolCompleted && (p.state.title?.contains(searchQuery, ignoreCase = true) == true ||
1016 p.state.output?.contains(searchQuery, ignoreCase = true) == true)) ||
1017 (p.state is ToolError && p.state.error?.contains(searchQuery, ignoreCase = true) == true) ||
1018 p.state.inputElement()?.toString()?.contains(searchQuery, ignoreCase = true) == true
1020 is FilePart -> p.filename?.contains(searchQuery, ignoreCase = true) == true ||
1021 p.sourcePath?.contains(searchQuery, ignoreCase = true) == true
1022 else -> false
1028 filtered
1030 // Keep the focused-match index in range as the query (and thus the match set) changes.
1031 // A new query resets to the first match (keyed on searchQuery alone so any change —
1032 // even one that still yields enough matches — jumps to index 0 rather than carrying
1033 // the old index over to a semantically unrelated message). A shrunk set clamps to
1034 // the last valid index (keyed on size so a content refresh that shrinks the matches
1035 // without a query change doesn't leave searchPos out of range).
1036 LaunchedEffect(searchQuery) {
1037 searchPos = 0
1039 LaunchedEffect(searchMessages.size) {
1040 if (searchPos >= searchMessages.size) searchPos = 0
1042 val listItems = remember(searchMessages, todayLabel, yesterdayLabel) {
1043 buildMessageListItems(searchMessages, todayLabel, yesterdayLabel)
1045 // listItems is a plain (non-snapshot) local, rebuilt each recomposition. The
1046 // derivedStateOf and snapshotFlow lambdas below are created once (keyless
1047 // remember / LaunchedEffect(Unit)); reading `listItems` directly inside them
1048 // would capture the first composition's list — which is empty, since `messages`
1049 // starts empty — freezing it forever. That pinned isPinnedToBottom at true and
1050 // AutoScrollSignal.size at 0, breaking streaming auto-scroll and the
1051 // jump-to-latest FAB. rememberUpdatedState hands those lambdas a stable State
1052 // whose value tracks the latest list.
1053 val currentListItems by rememberUpdatedState(listItems)
1055 // New-content signal for the jump-to-latest FAB: counts how many new items arrived
1056 // while the user was scrolled away from the bottom, cleared once they return. A count
1057 // (vs. a bare dot) tells the user how much they missed at a glance.
1058 var newContentCount by remember { mutableIntStateOf(0) }
1060 val isPinnedToBottom by remember {
1061 derivedStateOf {
1062 val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
1063 // When a run is active, the LazyColumn has one trailing "__typing"
1064 // item after the last list item. Account for it so "pinned to bottom"
1065 // includes the working indicator — otherwise the pin check targets
1066 // listItems.lastIndex, the typing row sits just below the viewport,
1067 // and the user never sees the progress indicator even when pinned.
1068 val items = currentListItems
1069 val effectiveLast = if (running) items.size else items.lastIndex
1070 lastVisible >= effectiveLast || items.isEmpty()
1074 // Jump to the newest message once, when the conversation first loads. Guarded by a
1075 // saveable one-shot flag so it doesn't re-fire on recomposition after a config change
1076 // (rotation) — rememberLazyListState restores the user's scroll offset, and an
1077 // unconditional scrollToItem here would clobber it, snapping them back to the bottom.
1078 var didInitialScroll by rememberSaveable { mutableStateOf(false) }
1079 LaunchedEffect(listItems.isNotEmpty()) {
1080 if (listItems.isNotEmpty() && !didInitialScroll) {
1081 // When a run is active the LazyColumn has a trailing "__typing" row at
1082 // listItems.size; target it (not listItems.lastIndex) so deep-linking into a
1083 // running session lands truly pinned to bottom and streaming auto-scroll engages.
1084 listState.scrollToItem(if (running) listItems.size else listItems.lastIndex)
1085 didInitialScroll = true
1089 // When a run starts, the LazyColumn gains a trailing "__typing" row, so
1090 // effectiveLast jumps from listItems.lastIndex to listItems.size and the
1091 // isPinnedToBottom check flips to false the instant you send — freezing
1092 // auto-scroll. If the user was pinned to the last item just before the run
1093 // began, bring the typing row into view so the pin (and streaming follow) is
1094 // preserved. Guard on the raw last-visible index (isPinnedToBottom has already
1095 // recomputed to false by now) so we don't scroll when the user had scrolled up.
1096 LaunchedEffect(running) {
1097 if (running && listItems.isNotEmpty()) {
1098 val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
1099 if (lastVisible >= listItems.lastIndex) {
1100 runCatchingCancellable { listState.animateScrollToItem(listItems.size) }
1105 // Scroll the focused search match into view whenever the user steps through results with
1106 // the up/down arrows. Resolves the filtered message to its slot in listItems (which
1107 // includes date separators) and animates there.
1108 LaunchedEffect(searchPos, searchQuery, listItems) {
1109 if (!searchActive || searchQuery.isEmpty()) return@LaunchedEffect
1110 val target = searchMessages.getOrNull(searchPos) ?: return@LaunchedEffect
1111 val index = listItems.indexOfFirst {
1112 it is MessageListItem.Message && it.message.info.id == target.info.id
1114 if (index >= 0) runCatchingCancellable { listState.animateScrollToItem(index) }
1117 // Global-search deep link: scroll to and briefly highlight the matched message. The
1118 // focus id is threaded through the chat route from GlobalSearchScreen. The highlight
1119 // clears itself after a short delay so the user can re-read the surrounding context
1120 // without a persistent marker. Runs once per focusMessageId (cleared on consume).
1121 var focusedMessageId by remember(focusMessageId) { mutableStateOf(focusMessageId) }
1122 LaunchedEffect(focusedMessageId, listItems) {
1123 val focus = focusedMessageId ?: return@LaunchedEffect
1124 if (listItems.isEmpty()) return@LaunchedEffect
1125 val index = listItems.indexOfFirst {
1126 it is MessageListItem.Message && it.message.info.id == focus
1128 if (index < 0) return@LaunchedEffect
1129 runCatchingCancellable { listState.animateScrollToItem(index) }
1130 kotlinx.coroutines.delay(2500)
1131 focusedMessageId = null
1134 // Scroll to bottom when the IME opens and the user was already pinned to bottom, so the
1135 // latest reply stays visible as the composer rises. Without this the IME can obscure the
1136 // last message even though the user was reading it a moment ago. WindowInsets.ime is a
1137 // @Composable property, so we read it inside a snapshotFlow registered in the composable
1138 // scope; the flow re-emits whenever the IME inset changes (open/close transitions).
1139 val density = androidx.compose.ui.platform.LocalDensity.current
1140 var wasImeOpen by remember { mutableStateOf(false) }
1141 val imeBottomPx = WindowInsets.ime.getBottom(density)
1142 androidx.compose.runtime.LaunchedEffect(imeBottomPx, listItems.isNotEmpty(), isPinnedToBottom) {
1143 if (listItems.isEmpty()) return@LaunchedEffect
1144 val imeOpen = imeBottomPx > 0
1145 // Scroll to bottom only on the open transition (not close) and only when the user
1146 // was already pinned to bottom — otherwise we'd yank them out of a mid-history read.
1147 val shouldScroll = imeOpen && !wasImeOpen && isPinnedToBottom
1148 if (shouldScroll) {
1149 runCatchingCancellable {
1150 listState.animateScrollToItem(if (running) listItems.size else listItems.lastIndex)
1153 wasImeOpen = imeOpen
1156 LaunchedEffect(Unit) {
1157 var prevSize = 0
1158 var prevLen = 0
1159 snapshotFlow {
1160 // Track list size + the streaming length of the last part so we auto-scroll
1161 // as content arrives, plus the pinned flag. A small data class with primitive
1162 // fields avoids the per-frame boxing that Triple<Int,Int,Boolean> pays
1163 // (snapshotFlow re-evaluates this lambda every snapshot), and reading only the
1164 // last part's length is O(1) vs. a sumOf over every part. Covering reasoning
1165 // and tool output — not just TextPart — keeps a pinned view following a long
1166 // reasoning/tool block while it streams (previously it stalled at 0).
1167 val lastLen = streamingContentLength(messages.lastOrNull()?.parts?.lastOrNull())
1168 AutoScrollSignal(currentListItems.size, lastLen, isPinnedToBottom)
1169 }.collect { signal ->
1170 if (signal.pinned) {
1171 // Back at the bottom: clear the new-content badge.
1172 newContentCount = 0
1173 if (signal.size > 0) {
1174 // Scroll to the effective last index, including the trailing
1175 // "__typing" row when a run is active so the working indicator
1176 // is brought into view (not just the last message).
1177 val items = currentListItems
1178 val target = if (running) items.size else items.lastIndex
1179 listState.scrollToItem(target)
1181 } else if (signal.size > prevSize || signal.lastTextLength > prevLen) {
1182 // Content arrived while scrolled up — increment the jump-to-latest badge.
1183 // Only count whole new items (not per-token growth) so the number reflects
1184 // new messages, not a token count.
1185 if (signal.size > prevSize) newContentCount += signal.size - prevSize
1186 else if (newContentCount == 0) newContentCount = 1
1188 prevSize = signal.size
1189 prevLen = signal.lastTextLength
1193 CompositionLocalProvider(LocalRelativeTimeTick provides timeTick) {
1194 // The bottomBar (ChatInputBar) already has imePadding, and the Scaffold's
1195 // content padding accounts for the bottomBar's raised height, so the message
1196 // list is already above the keyboard. Adding imePadding here would double-apply
1197 // the IME inset and push messages too far up.
1198 //
1199 // BoxWithConstraints so the FABs and search bar can align to the capped (800dp)
1200 // message list on wide screens instead of floating at the screen edges — on a
1201 // tablet the list is a centered column, so edge-aligned FABs are visually detached.
1202 // The horizontal margin below is applied to the FAB/search containers so they
1203 // track the list's left/right edges.
1204 androidx.compose.foundation.layout.BoxWithConstraints(
1205 modifier = Modifier.fillMaxSize().padding(padding),
1206 ) {
1207 val screenMaxWidth = maxWidth
1208 val listMaxWidth = NetworkConfig.chatContentMaxWidthDp.dp
1209 // Horizontal margin between the capped list's edge and the screen edge (0 on
1210 // screens narrower than the cap). Applied as start/end padding so the FABs and
1211 // search bar sit at the list's edges, not the screen's.
1212 val sideMargin = ((screenMaxWidth - listMaxWidth) / 2).coerceAtLeast(0.dp)
1213 if (activeConnection == null) {
1214 Column(
1215 modifier = Modifier.align(Alignment.Center).padding(24.dp),
1216 horizontalAlignment = Alignment.CenterHorizontally,
1217 ) {
1218 Text(
1219 stringResource(R.string.not_connected),
1220 color = MaterialTheme.colorScheme.error,
1222 Spacer(Modifier.size(12.dp))
1223 Button(onClick = { vm.reconnect() }, enabled = !reconnecting) {
1224 if (reconnecting) {
1225 CircularProgressIndicator(
1226 Modifier.size(20.dp),
1227 color = MaterialTheme.colorScheme.onPrimary,
1228 strokeWidth = 2.dp,
1230 } else {
1231 Text(stringResource(R.string.reconnect))
1235 } else {
1236 val bannerVisible = !isOnline || connectionState != EventStreamClient.ConnectionState.Connected
1237 // Measure the banner's actual height instead of hardcoding 44dp. At large
1238 // accessibility font scales the banner's labelMedium text + spinner + Retry
1239 // button exceed 44dp, so a fixed inset lets the first message hide behind it
1240 // (the same hazard FileViewScreen's overlay calls out). onSizeChanged only
1241 // fires while the banner is composed (visible), so gate the inset on
1242 // bannerVisible and fall back to 44dp until the first measurement arrives.
1243 var bannerHeightPx by remember { mutableIntStateOf(0) }
1244 // Track the search bar height so the list's top padding can grow when search
1245 // is active — otherwise a scrolled-to match lands at index 0, hidden behind
1246 // the floating search bar (the "scroll-under-the-search-bar" bug).
1247 var searchbarHeightPx by remember { mutableIntStateOf(0) }
1248 val density = LocalDensity.current
1249 val topPad = if (bannerVisible) {
1250 if (bannerHeightPx > 0) {
1251 with(density) { bannerHeightPx.toDp() } + 4.dp
1252 } else {
1253 44.dp
1255 } else {
1256 16.dp
1257 } + if (searchActive && searchbarHeightPx > 0) {
1258 with(density) { searchbarHeightPx.toDp() } + 4.dp
1259 } else {
1260 0.dp
1262 ConnectionBanner(
1263 state = connectionState,
1264 modifier = Modifier.align(Alignment.TopCenter).onSizeChanged { bannerHeightPx = it.height },
1265 isOnline = isOnline,
1266 // On a hard endpoint failure, retry by forcing an SSE reconnect (which
1267 // re-seeds from REST). refreshMessages is the right recovery path
1268 // when the connection is present but the stream died; reconnect()
1269 // is the path when the whole connection is gone (handled by the
1270 // separate "Not connected" state below).
1271 onRetry = { vm.refreshMessages() },
1272 // On an auth failure (401/403), retrying with the same bad credentials
1273 // is futile — offer a one-tap path to edit the active profile's
1274 // credentials instead of making the user hunt for the server list.
1275 onEditCredentials = {
1276 val profileId = activeConnection?.profile?.id
1277 if (profileId != null) onEditProfile?.invoke(profileId)
1278 },
1279 reconnectAttempts = reconnectAttempts,
1281 // Persistent inline error banner for a mid-conversation load failure.
1282 // Unlike the one-shot snackbar (which fires once per streak then goes
1283 // silent), this stays visible until the stream recovers, so a persistent
1284 // outage doesn't read as "everything is fine" after the snackbar dismisses.
1285 // Anchored at the top, below the connection banner, so it doesn't overlap
1286 // the message list.
1287 if (loadErrorInline && hasMessages) {
1288 Surface(
1289 color = MaterialTheme.colorScheme.errorContainer,
1290 shape = MaterialTheme.shapes.small,
1291 modifier = Modifier
1292 .align(Alignment.TopCenter)
1293 .padding(top = if (bannerVisible) topPad else 8.dp)
1294 .padding(horizontal = 16.dp)
1295 .semantics { liveRegion = LiveRegionMode.Polite },
1296 ) {
1297 Row(
1298 modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
1299 verticalAlignment = Alignment.CenterVertically,
1300 horizontalArrangement = Arrangement.spacedBy(8.dp),
1301 ) {
1302 Icon(
1303 Icons.Filled.ErrorOutline,
1304 contentDescription = null,
1305 modifier = Modifier.size(16.dp),
1306 tint = MaterialTheme.colorScheme.onErrorContainer,
1308 Text(
1309 stringResource(R.string.load_error_persistent),
1310 style = MaterialTheme.typography.labelMedium,
1311 color = MaterialTheme.colorScheme.onErrorContainer,
1312 modifier = Modifier.weight(1f),
1314 TextButton(
1315 onClick = { vm.refreshMessages() },
1316 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp),
1317 ) {
1318 Text(stringResource(R.string.retry))
1323 // Pull-to-refresh wraps ALL content states (loading, error, empty, list) so
1324 // recovery is a swipe away everywhere — not just when the message list is
1325 // populated. Previously PTR was only around the populated list, leaving the
1326 // error/loading states reachable only via a button tap.
1327 PullToRefreshBox(
1328 isRefreshing = refreshing,
1329 // Suppress the refresh action while a run is active: a mid-stream
1330 // refresh forces an SSE reconnect that can interrupt the actively
1331 // streaming reply (matching the top-bar refresh button, which is
1332 // also disabled while running). The pull gesture still responds, but
1333 // the release no-ops instead of triggering a disruptive re-seed.
1334 onRefresh = { if (!running) vm.refreshMessages() },
1335 // Cap width and center on large screens for readability — full-width
1336 // bubbles stretch edge-to-edge on tablets, hurting line-length comfort.
1337 modifier = Modifier.fillMaxSize().wrapContentWidth(Alignment.CenterHorizontally)
1338 .widthIn(max = NetworkConfig.chatContentMaxWidthDp.dp),
1339 ) {
1340 // Reserve top space when the banner is visible so it doesn't overlap the
1341 // first message / state content. (topPad computed above.)
1342 if (loading && messages.isEmpty()) {
1343 // Skeleton bubbles pre-structure the layout so the perceived load is faster
1344 // than a bare centered spinner and the first real messages pop into an
1345 // already-shaped list instead of a blank-then-pop.
1346 ChatSkeleton(topPad = topPad)
1347 } else if (loadError && messages.isEmpty()) {
1348 // A failed load with nothing to show. Distinct from the empty
1349 // conversation state below — the conversation isn't empty, it
1350 // failed to load, and offering "Start a conversation" here is
1351 // misleading. Offer a Retry instead, mirroring SessionListScreen.
1352 Column(
1353 modifier = Modifier.align(Alignment.Center).padding(top = topPad).padding(24.dp),
1354 horizontalAlignment = Alignment.CenterHorizontally,
1355 ) {
1356 Text(
1357 stringResource(R.string.load_messages_failed),
1358 color = MaterialTheme.colorScheme.error,
1360 Spacer(Modifier.size(12.dp))
1361 Button(onClick = { vm.refreshMessages() }) {
1362 Text(stringResource(R.string.retry))
1364 // When the VM's retryWhen loop is actively retrying (loading is true
1365 // on top of the error state), surface a small "retrying…" indicator so
1366 // the user knows the system is working — without it, the static error
1367 // + Retry button reads as "broken, tap to fix", and the user may not
1368 // realize an automatic retry is already in progress.
1369 if (loading) {
1370 Spacer(Modifier.size(12.dp))
1371 val retryingLabel = stringResource(R.string.retrying_ellipsis)
1372 Row(verticalAlignment = Alignment.CenterVertically) {
1373 CircularProgressIndicator(
1374 Modifier.size(14.dp),
1375 strokeWidth = 2.dp,
1377 Text(
1378 retryingLabel,
1379 style = MaterialTheme.typography.labelSmall,
1380 color = MaterialTheme.colorScheme.onSurfaceVariant,
1381 modifier = Modifier.padding(start = 6.dp),
1386 } else if (messages.isEmpty() && running) {
1387 // A run just started but no parts have arrived yet — the empty list with
1388 // only the trailing "working…" row looks broken. Reuse the skeleton bubbles
1389 // from the initial-load branch so the perceived start latency is lower and
1390 // the first real assistant part pops into an already-shaped list.
1391 ChatSkeleton(topPad = topPad)
1392 } else if (messages.isEmpty()) {
1393 EmptyConversation(
1394 onSuggestion = {
1395 vm.updateDraft(it)
1396 // Focus the input so the user can edit the suggestion before
1397 // sending, instead of having to manually tap the field.
1398 runCatching { inputFocusRequester.requestFocus() }
1399 },
1400 modifier = Modifier.align(Alignment.Center).padding(top = topPad),
1402 } else {
1403 val lastMessageId = messages.lastOrNull()?.info?.id
1404 // Text-to-speech for reading assistant replies aloud; shuts down when the
1405 // message list leaves composition.
1406 val tts = rememberTtsController()
1407 val speakingMessageId by tts.speakingId
1408 val ttsState by tts.state
1409 // Provide the in-conversation search query to the markdown renderer so matching
1410 // text spans are highlighted in-place (in addition to the focused-row highlight
1411 // above). Scoped to the list so cleared search stops highlighting immediately.
1412 val activeSearchQuery = searchQuery.takeIf { searchActive && it.isNotEmpty() }
1413 CompositionLocalProvider(LocalSearchHighlight provides activeSearchQuery) {
1414 LazyColumn(
1415 state = listState,
1416 modifier = Modifier.fillMaxSize(),
1417 // Extra bottom padding reserves room for the jump-to-latest FAB so it
1418 // never floats over the last message. Top padding grows when the
1419 // connection banner is visible so it doesn't overlap the first message.
1420 contentPadding = PaddingValues(
1421 start = 16.dp,
1422 top = topPad,
1423 end = 16.dp,
1424 bottom = 16.dp + NetworkConfig.chatListFabInsetDp.dp,
1425 ),
1426 // Compact spacing (8dp) vs the default 16dp — a power-user toggle to fit
1427 // more conversation on screen without scrolling as much.
1428 verticalArrangement = Arrangement.spacedBy(if (compactSpacing) 8.dp else 16.dp),
1429 ) {
1430 items(
1431 items = listItems,
1432 key = { it.key },
1433 contentType = { it.contentType },
1434 ) { item ->
1435 // When in-conversation search is stepping through matches, highlight the
1436 // focused one's row so the user can spot it at a glance among the filtered
1437 // results (full in-text term highlighting would require threading the query
1438 // through the markdown renderer; the row highlight + scroll-to-match covers
1439 // the navigation use case desktop chat apps solve with next/prev).
1440 val focusedMatchId = if (searchActive && searchQuery.isNotEmpty()) {
1441 searchMessages.getOrNull(searchPos)?.info?.id
1442 } else null
1443 val isFocusedMatch = focusedMatchId != null &&
1444 item is MessageListItem.Message &&
1445 item.message.info.id == focusedMatchId
1446 // Default placement animation so inserted/moved rows glide in. Skipped
1447 // entirely under reduced motion so a streaming message growing in place
1448 // doesn't animate on every token (and respects the a11y preference).
1449 Box(
1450 Modifier
1451 .then(reducedMotionAnimateItem())
1452 .then(
1453 if (isFocusedMatch) {
1454 Modifier.background(
1455 MaterialTheme.colorScheme.secondaryContainer,
1456 MaterialTheme.shapes.medium,
1458 } else {
1459 Modifier
1460 },
1461 ),
1462 ) {
1463 when (item) {
1464 is MessageListItem.Separator -> DateSeparator(item.label)
1465 is MessageListItem.Message -> {
1466 val message = item.message
1467 val messageId = message.info.id
1468 // Only the last (streaming) message needs isRunning — it drives the
1469 // reasoning-block spinner. Passing the live flag to every bubble
1470 // makes all visible messages recompose whenever a run starts or stops.
1471 val modelLabel = remember(message.info, models) {
1472 (message.info as? soy.iko.opencode.data.model.AssistantMessage)
1473 ?.let { resolveModelLabel(it, models) }
1475 // Key agentLabel/quoteText on the message id (not message.parts) so
1476 // the streaming message — whose `parts` list is replaced on every
1477 // token — doesn't re-run filterIsInstance per token for these two
1478 // derived labels. For a non-streaming message, `message.parts` is
1479 // reference-stable anyway, so this is equivalent. For the streaming
1480 // message the label updates as parts change via the message.info/
1481 // parts reference change below — keyed on messageId + a parts-hash
1482 // is overkill; agentLabel/quoteText only need to reflect the latest
1483 // parts, and `remember(messageId, message.parts)` would re-key per
1484 // token. Instead key on `messageId` alone and accept that the label
1485 // lags by one recomposition for the streaming bubble (it stabilizes
1486 // once the stream finishes). This is invisible in practice: agent
1487 // label is set on the first part, quote text grows with text parts
1488 // but is only consumed on a swipe (user-initiated, post-stream).
1489 val agentLabel = remember(messageId, message.parts) {
1490 message.parts
1491 .filterIsInstance<soy.iko.opencode.data.model.AgentPart>()
1492 .firstOrNull { it.name.isNotBlank() }?.name
1494 // Text used to drive swipe-to-reply (and pre-fill the quote). Empty for
1495 // image/code-only messages, which then opt out of the swipe gesture.
1496 val quoteText = remember(messageId, message.parts) {
1497 message.parts
1498 .filterIsInstance<TextPart>()
1499 .joinToString("\n\n") { it.text }
1500 .trim()
1501 .takeIf { it.isNotEmpty() }
1503 // Swipe-start-to-end reveals a reply affordance and triggers quote-reply,
1504 // snapping back (the message isn't dismissed — the quote fills the composer).
1505 // Enabled for non-text messages too (image/tool-only), but the confirm
1506 // callback gives a haptic without triggering reply — so the user gets a
1507 // perceptible "can't quote this" signal instead of a silently dead gesture.
1508 val cantQuoteMsg = stringResource(R.string.cannot_quote_this)
1509 val undoLabel = stringResource(R.string.undo)
1510 val quoteReplyLabel = stringResource(R.string.quote_reply_banner)
1511 // Memoize the swipe confirmValueChange lambda on the message id + quoteText
1512 // so it's stable across recompositions (a fresh lambda would force the
1513 // SwipeToDismissBox to re-instantiate its state capture). quoteText is a
1514 // derived value that's stable for non-streaming messages.
1515 val swipeState = rememberSwipeToDismissBoxState(
1516 confirmValueChange = remember(messageId, quoteText) {
1517 { value ->
1518 val replyValue = if (layoutDirection == LayoutDirection.Rtl) {
1519 SwipeToDismissBoxValue.EndToStart
1520 } else {
1521 SwipeToDismissBoxValue.StartToEnd
1523 if (value == replyValue) {
1524 if (quoteText != null) {
1525 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
1526 vm.quoteReply(quoteText)
1527 runCatching { inputFocusRequester.requestFocus() }
1528 scope.launch {
1529 val res = snackbar.showSnackbar(
1530 message = quoteReplyLabel,
1531 actionLabel = undoLabel,
1533 if (res == androidx.compose.material3.SnackbarResult.ActionPerformed) {
1534 vm.cancelQuoteReply()
1537 } else {
1538 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
1539 scope.launch { snackbar.showSnackbar(cantQuoteMsg) }
1542 false
1544 },
1546 // In RTL locales, mirror the swipe direction so the reply
1547 // gesture reads naturally (swipe from the trailing edge
1548 // toward the leading edge), matching how LTR users swipe
1549 // left-to-right. Without this, RTL users had no reply swipe.
1550 val replyFromStartToEnd = layoutDirection != LayoutDirection.Rtl
1551 // Memoize every MessageBubble lambda on the message id so a recomposition
1552 // triggered by a sibling (e.g. the streaming message's parts changing,
1553 // which gives `listItems` a new identity) doesn't allocate fresh lambdas
1554 // for the unchanged bubbles. MessageBubble is non-skippable (it takes
1555 // many `(() -> Unit)?` params, which Compose treats as unstable), so
1556 // without memoization every visible bubble recomposes on every token,
1557 // defeating the @Immutable MessageWithParts reference-stability the
1558 // reducer's Holder cache provides. This mirrors the SessionListScreen
1559 // per-card lambda memoization pattern.
1560 val onRevert = remember(messageId) { { vm.revertTo(messageId) } }
1561 val onEdit = remember(messageId) {
1562 { text: String ->
1563 // editMessage reverts to before this message (hiding it and
1564 // everything after); the revert banner surfaces the rewind
1565 // with an Undo. Focus + scroll the composer into view like the
1566 // Quote action so the prefilled draft isn't off-screen.
1567 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
1568 vm.editMessage(messageId, text)
1569 runCatching { inputFocusRequester.requestFocus() }
1570 contentScope.launch {
1571 runCatchingCancellable {
1572 listState.animateScrollToItem(currentListItems.lastIndex.coerceAtLeast(0))
1575 Unit
1578 val onSpeak = remember(messageId) {
1579 { text: String ->
1580 if (!tts.toggle(messageId, text)) {
1581 scope.launch { snackbar.showSnackbar(ttsUnavailableMsg) }
1583 Unit
1586 val onPause = remember { { tts.pause() } }
1587 val onResume = remember { { tts.resume() } }
1588 val onStop = remember { { tts.stop() } }
1589 val onQuote = remember(messageId) {
1590 { text: String ->
1591 vm.quoteReply(text)
1592 runCatching { inputFocusRequester.requestFocus() }
1593 Unit
1596 val onBranch = remember { { text: String -> vm.branchFrom(text); Unit } }
1597 // Regenerate is only meaningful for a finished assistant reply
1598 // (not the actively-streaming one), so gate it on !running.
1599 val onRegenerate = if (!running) {
1600 remember(messageId) { { vm.regenerate(messageId) } }
1601 } else {
1602 null
1604 // Continue: resume a partial reply. Shown only for
1605 // incomplete assistant messages (a partial reply that
1606 // was aborted) and not while a run is active.
1607 val canContinue = !running && message.info is AssistantMessage && !message.info.isComplete
1608 val onContinue = if (canContinue) {
1609 remember(messageId) { { vm.continueRun(messageId) } }
1610 } else {
1611 null
1613 val messageFailed = optimisticStatuses[messageId] == true
1614 val onRetry = if (messageFailed) {
1615 remember(messageId) { { vm.retryOptimisticMessage(messageId); Unit } }
1616 } else {
1617 null
1619 val onDismiss = if (messageFailed) {
1620 remember(messageId) { { vm.dismissOptimistic(messageId) } }
1621 } else {
1622 null
1624 val onShare = remember(messageId, sessionTitle, defaultShareSubject) {
1626 // Per-message share: build a single-message Markdown
1627 // transcript off-thread and fire ACTION_SEND, mirroring the
1628 // whole-conversation share but scoped to just this message.
1629 scope.launch {
1630 val md = withContext(Dispatchers.Default) {
1631 buildMessageMarkdown(message)
1632 } ?: return@launch
1633 val send = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
1634 type = "text/markdown"
1635 putExtra(android.content.Intent.EXTRA_SUBJECT, sessionTitle ?: defaultShareSubject)
1636 putExtra(android.content.Intent.EXTRA_TEXT, md)
1638 runCatchingCancellable { shareContext.startActivity(android.content.Intent.createChooser(send, shareContext.getString(R.string.share_message))) }
1639 .onFailure { showToast(shareContext, shareContext.getString(R.string.no_share_app)) }
1641 Unit
1644 SwipeToDismissBox(
1645 state = swipeState,
1646 enableDismissFromEndToStart = !replyFromStartToEnd,
1647 enableDismissFromStartToEnd = replyFromStartToEnd,
1648 backgroundContent = {
1649 Box(
1650 Modifier.fillMaxSize().padding(horizontal = 16.dp),
1651 contentAlignment = if (replyFromStartToEnd) Alignment.CenterStart else Alignment.CenterEnd,
1652 ) {
1653 Icon(
1654 Icons.AutoMirrored.Filled.Reply,
1655 contentDescription = null,
1656 tint = MaterialTheme.colorScheme.onSurfaceVariant,
1659 },
1660 ) {
1661 MessageBubble(
1662 message,
1663 isRunning = running && messageId == lastMessageId,
1664 imageContext = imageContext,
1665 modelLabel = modelLabel,
1666 agentLabel = agentLabel,
1667 onOpenFile = onOpenFile,
1668 onRevert = onRevert,
1669 onEdit = onEdit,
1670 onSpeak = onSpeak,
1671 isSpeaking = messageId == speakingMessageId,
1672 ttsState = if (messageId == speakingMessageId) ttsState else TtsState.IDLE,
1673 onPause = onPause,
1674 onResume = onResume,
1675 onStop = onStop,
1676 onQuote = onQuote,
1677 onBranch = onBranch,
1678 onRegenerate = onRegenerate,
1679 onContinue = onContinue,
1680 sendStatus = optimisticStatuses[messageId]?.let { failed ->
1681 if (failed) soy.iko.opencode.ui.chat.MessageSendStatus.FAILED
1682 else soy.iko.opencode.ui.chat.MessageSendStatus.SENDING
1683 },
1684 isEdited = message.info.time?.let { it.updated != null && it.updated != it.created } == true,
1685 onRetry = onRetry,
1686 onDismiss = onDismiss,
1687 onShare = onShare,
1688 isFirstOfSpeaker = item.isFirstOfSpeaker,
1689 highlighted = focusedMessageId != null && messageId == focusedMessageId,
1696 if (running) {
1697 item(key = "__typing") {
1698 val workingText = stringResource(R.string.working)
1699 // When the SSE stream dropped mid-run, show a "reconnecting" label
1700 // instead of the plain "working" text so the user understands the
1701 // run is still active but the stream is reconnecting (not finished).
1702 val reconnectingText = stringResource(R.string.reconnecting)
1703 val interruptedLabel = if (streamInterrupted) reconnectingText else workingText
1704 // Elapsed-since-run chip driven by the VM's runStartMs so the timer
1705 // survives LazyColumn recycling — a local remember reset to 0:00 when
1706 // the row was disposed and scrolled back into view. The VM stamps the
1707 // start on the real false→true transition (send/run/init), not on
1708 // SSE-reconnect relight, so reconnects don't restart the clock. Updates
1709 // once per second; the value is decorative (the a11y label is the row's).
1710 // The clock freezes when an abort is confirmed (aborting = true) so the
1711 // user can see how long the run lasted before they stopped it, rather
1712 // than the clock continuing to tick until SessionIdle clears _running.
1713 val startMs by vm.runStartMs.collectAsStateWithLifecycle()
1714 val elapsedMs by produceState(0L, startMs, aborting) {
1715 if (startMs == 0L) return@produceState
1716 if (aborting) {
1717 // Freeze at the last-computed value: one final snapshot then stop.
1718 value = (System.currentTimeMillis() - startMs).coerceAtLeast(0L)
1719 return@produceState
1721 while (true) {
1722 value = (System.currentTimeMillis() - startMs).coerceAtLeast(0L)
1723 delay(1000)
1726 val elapsedText = remember(elapsedMs) {
1727 val total = elapsedMs / 1000
1728 // Locale.US for stable digit formatting: on locales with
1729 // non-ASCII digits (ar-/fa-) the default locale would render
1730 // localized digits inside the Monospace block, breaking the
1731 // fixed-width alignment the monospace family was chosen for.
1732 String.format(java.util.Locale.US, "%d:%02d", total / 60, total % 60)
1734 Row(
1735 verticalAlignment = Alignment.CenterVertically,
1736 // Animate this slot in/out so the working row doesn't pop in and
1737 // jolt the message list when a run starts/stops — matching the
1738 // AnimatedVisibility used by every other transient banner/FAB here.
1739 modifier = Modifier
1740 .then(reducedMotionAnimateItem())
1741 .padding(vertical = 4.dp),
1742 ) {
1743 // Merge the indicator + label + clock into one TalkBack node so
1744 // it's announced once (the working label, then the elapsed time)
1745 // instead of "working… / working… / 0:14" with the label read
1746 // twice. The Stop IconButton below stays separately focusable.
1747 Row(
1748 verticalAlignment = Alignment.CenterVertically,
1749 modifier = Modifier.semantics(mergeDescendants = true) {
1750 contentDescription = "$interruptedLabel $elapsedText"
1751 },
1752 ) {
1753 // Dim the spinner when interrupted so the row reads as
1754 // "paused/reconnecting" rather than actively progressing.
1755 CircularProgressIndicator(
1756 Modifier.size(16.dp),
1757 strokeWidth = 2.dp,
1758 color = if (streamInterrupted) MaterialTheme.colorScheme.onSurfaceVariant
1759 else MaterialTheme.colorScheme.primary,
1761 Text(interruptedLabel, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp))
1762 Spacer(Modifier.size(8.dp))
1763 Icon(
1764 Icons.Filled.Schedule,
1765 contentDescription = null,
1766 modifier = Modifier.size(14.dp),
1767 tint = MaterialTheme.colorScheme.onSurfaceVariant,
1769 Text(
1770 elapsedText,
1771 style = MaterialTheme.typography.labelMedium,
1772 fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
1773 color = MaterialTheme.colorScheme.onSurfaceVariant,
1774 modifier = Modifier.padding(start = 4.dp),
1777 // A trailing Stop on the working row so the run can be aborted
1778 // without scrolling back to the composer (which may be off-screen
1779 // while the user reads the streaming output up-thread).
1780 Spacer(Modifier.weight(1f))
1781 val stopLabel = stringResource(R.string.stop)
1782 IconButton(
1783 onClick = { vm.abort() },
1784 modifier = Modifier.semantics { contentDescription = stopLabel },
1785 ) {
1786 Icon(Icons.Filled.Stop, contentDescription = null)
1795 // Jump-to-latest affordance when the user has scrolled away during a stream.
1796 val fabMotion = rememberVisibilityTransitions()
1797 AnimatedVisibility(
1798 visible = !isPinnedToBottom && listItems.isNotEmpty(),
1799 enter = fabMotion.enter,
1800 exit = fabMotion.exit,
1801 // Inset by sideMargin on wide screens so the FAB tracks the capped list's
1802 // edge instead of floating at the screen edge (visually detached on tablets).
1803 modifier = Modifier.align(Alignment.BottomEnd).padding(end = sideMargin),
1804 ) {
1805 // Badge the FAB when new content arrived while the user was scrolled up, so
1806 // they know there's something new to jump to (cleared once back at bottom).
1807 BadgedBox(
1808 badge = {
1809 if (newContentCount > 0) {
1810 // Merge the count into the badge announcement so TalkBack reads
1811 // "5 new messages" instead of the bare label and a separate number.
1812 val newContentLabel = pluralStringResource(
1813 R.plurals.plurals_new_messages,
1814 newContentCount,
1815 newContentCount,
1817 Badge(
1818 modifier = Modifier.semantics {
1819 contentDescription = newContentLabel
1820 // Announce new-content counts as they change so a
1821 // TalkBack user hears "5 new messages" without having
1822 // to focus the FAB. Polite so it doesn't interrupt an
1823 // in-progress utterance (e.g. streaming token reads).
1824 liveRegion = androidx.compose.ui.semantics.LiveRegionMode.Polite
1825 },
1826 ) {
1827 Text(
1828 if (newContentCount > 99) "99+" else newContentCount.toString(),
1829 modifier = Modifier.semantics { invisibleToUser() },
1833 },
1834 modifier = Modifier.padding(end = 16.dp, bottom = 16.dp),
1835 ) {
1836 ExtendedFloatingActionButton(
1837 onClick = {
1838 newContentCount = 0
1839 if (listItems.isNotEmpty()) {
1840 // Scroll to the effective last index, including the
1841 // trailing "__typing" row when a run is active.
1842 val target = if (running) listItems.size else listItems.lastIndex
1843 contentScope.launch { runCatchingCancellable { listState.animateScrollToItem(target) } }
1845 },
1846 // contentDescription null: the visible "Latest" text already labels
1847 // the FAB, so a description here makes TalkBack announce "Latest" twice.
1848 icon = { Icon(Icons.Filled.KeyboardArrowDown, contentDescription = null) },
1849 text = { Text(stringResource(R.string.latest)) },
1853 // Scroll-to-top affordance: a small FAB at the start edge, visible when the
1854 // user has scrolled down past the first few messages. Symmetric to the
1855 // jump-to-latest FAB, for long conversations where manual fling back is tedious.
1856 val scrolledDown by remember {
1857 derivedStateOf { listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 200 }
1859 AnimatedVisibility(
1860 visible = scrolledDown && listItems.size > 4,
1861 enter = fabMotion.enter,
1862 exit = fabMotion.exit,
1863 modifier = Modifier.align(Alignment.BottomStart).padding(start = sideMargin),
1864 ) {
1865 SmallFloatingActionButton(
1866 onClick = {
1867 contentScope.launch { runCatchingCancellable { listState.animateScrollToItem(0) } }
1868 },
1869 modifier = Modifier.padding(start = 16.dp, bottom = 16.dp),
1870 ) {
1871 Icon(
1872 Icons.Filled.KeyboardArrowUp,
1873 contentDescription = stringResource(R.string.scroll_to_top),
1877 // In-conversation search bar. Overlays the list so typing filters messages in
1878 // place; the match count + a no-matches note give immediate feedback.
1879 val searchMotion = rememberVisibilityTransitions()
1880 AnimatedVisibility(
1881 visible = searchActive,
1882 enter = searchMotion.enter,
1883 exit = searchMotion.exit,
1884 // Inset by sideMargin on wide screens so the search bar spans the capped
1885 // list's width, not the full screen width.
1886 modifier = Modifier.align(Alignment.TopCenter).padding(horizontal = sideMargin),
1887 ) {
1888 Surface(
1889 tonalElevation = 3.dp,
1890 modifier = Modifier
1891 .padding(horizontal = 12.dp, vertical = 4.dp)
1892 .fillMaxWidth()
1893 .onSizeChanged { searchbarHeightPx = it.height },
1894 ) {
1895 // Focus the search field when the bar appears (from the overflow menu's
1896 // "Find in conversation" or Ctrl+F), so the user can start typing
1897 // immediately. Runs after the search field enters the composition.
1898 LaunchedEffect(searchActive) {
1899 if (searchActive) runCatching { searchFocusRequester.requestFocus() }
1901 Row(
1902 modifier = Modifier.padding(start = 8.dp, end = 4.dp),
1903 verticalAlignment = Alignment.CenterVertically,
1904 ) {
1905 OutlinedTextField(
1906 value = chatSearch,
1907 onValueChange = { chatSearch = it },
1908 modifier = Modifier.weight(1f).focusRequester(searchFocusRequester),
1909 placeholder = { Text(stringResource(R.string.search_in_conversation)) },
1910 singleLine = true,
1911 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
1912 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
1914 // Match counter: "N of M" where N is the focused match (1-based) and
1915 // M is the total matching messages. Only shown when there's a query.
1916 val matchCount = searchMessages.size
1917 Text(
1918 if (searchQuery.isNotEmpty() && matchCount > 0) {
1919 stringResource(R.string.search_match_count, searchPos + 1, matchCount)
1920 } else if (searchQuery.isNotEmpty()) {
1921 stringResource(R.string.search_no_matches)
1922 } else "",
1923 style = MaterialTheme.typography.labelSmall,
1924 color = MaterialTheme.colorScheme.onSurfaceVariant,
1925 modifier = Modifier
1926 .padding(horizontal = 4.dp)
1927 // Live region so a TalkBack user hears "3 of 12" / "No
1928 // matches" announce as they step through results, not just
1929 // the sighted user seeing the counter update.
1930 .semantics { liveRegion = androidx.compose.ui.semantics.LiveRegionMode.Polite },
1932 // Step through matches (Previous / Next), disabled when there are
1933 // fewer than two to navigate. Wrap around at the ends so repeated
1934 // taps cycle through every hit.
1935 val prevLabel = stringResource(R.string.find_previous)
1936 val nextLabel = stringResource(R.string.find_next)
1937 IconButton(
1938 onClick = {
1939 if (matchCount > 0) {
1940 searchPos = (searchPos - 1 + matchCount) % matchCount
1942 },
1943 enabled = matchCount > 1,
1944 ) {
1945 Icon(Icons.Filled.KeyboardArrowUp, contentDescription = prevLabel)
1947 IconButton(
1948 onClick = {
1949 if (matchCount > 0) {
1950 searchPos = (searchPos + 1) % matchCount
1952 },
1953 enabled = matchCount > 1,
1954 ) {
1955 Icon(Icons.Filled.KeyboardArrowDown, contentDescription = nextLabel)
1957 IconButton(onClick = {
1958 searchActive = false
1959 chatSearch = ""
1960 }) {
1961 Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.clear_search))
1966 // When a search yields no matches (but the conversation isn't empty), say so
1967 // instead of showing a blank list.
1968 if (searchActive && searchQuery.isNotEmpty() && searchMessages.isEmpty() && messages.isNotEmpty()) {
1969 Text(
1970 stringResource(R.string.search_no_matches),
1971 style = MaterialTheme.typography.bodyMedium,
1972 color = MaterialTheme.colorScheme.onSurfaceVariant,
1973 modifier = Modifier.align(Alignment.Center),
1981 if (showExitConfirm) {
1982 AlertDialog(
1983 onDismissRequest = { showExitConfirm = false },
1984 title = { Text(stringResource(R.string.stop_and_exit_title)) },
1985 text = { Text(stringResource(R.string.stop_and_exit_text)) },
1986 confirmButton = {
1987 TextButton(onClick = {
1988 showExitConfirm = false
1989 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
1990 vm.abort()
1991 onBack()
1992 }) { Text(stringResource(R.string.stop_and_exit)) }
1993 },
1994 dismissButton = {
1995 TextButton(onClick = { showExitConfirm = false }) { Text(stringResource(R.string.stay)) }
1996 },
2000 if (showSummarizeConfirm) {
2001 AlertDialog(
2002 onDismissRequest = { showSummarizeConfirm = false },
2003 title = { Text(stringResource(R.string.summarize_confirm_title)) },
2004 text = { Text(stringResource(R.string.summarize_confirm_text)) },
2005 confirmButton = {
2006 TextButton(onClick = {
2007 showSummarizeConfirm = false
2008 vm.summarize()
2009 }) { Text(stringResource(R.string.action_continue)) }
2010 },
2011 dismissButton = {
2012 TextButton(onClick = { showSummarizeConfirm = false }) { Text(stringResource(R.string.cancel)) }
2013 },
2017 if (showInitConfirm) {
2018 AlertDialog(
2019 onDismissRequest = { showInitConfirm = false },
2020 title = { Text(stringResource(R.string.init_confirm_title)) },
2021 text = { Text(stringResource(R.string.init_confirm_text)) },
2022 confirmButton = {
2023 TextButton(onClick = {
2024 showInitConfirm = false
2025 vm.initProject()
2026 }) { Text(stringResource(R.string.action_continue)) }
2027 },
2028 dismissButton = {
2029 TextButton(onClick = { showInitConfirm = false }) { Text(stringResource(R.string.cancel)) }
2030 },
2034 if (showRenameDialog) {
2035 RenameSessionChatDialog(
2036 initialTitle = sessionTitle ?: "",
2037 onDismiss = { showRenameDialog = false },
2038 onConfirm = { newName ->
2039 showRenameDialog = false
2040 vm.renameSession(newName)
2041 },
2045 if (showDeleteDialog) {
2046 AlertDialog(
2047 onDismissRequest = { showDeleteDialog = false },
2048 title = { Text(stringResource(R.string.delete_session_chat_title)) },
2049 text = { Text(stringResource(R.string.delete_session_chat_text, sessionTitle ?: sessionLabel)) },
2050 confirmButton = {
2051 TextButton(onClick = {
2052 showDeleteDialog = false
2053 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
2054 vm.deleteSession()
2055 }) { Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) }
2056 },
2057 dismissButton = {
2058 TextButton(onClick = { showDeleteDialog = false }) { Text(stringResource(R.string.cancel)) }
2059 },
2063 if (showShellDialog) {
2064 ShellCommandDialog(
2065 onDismiss = { showShellDialog = false },
2066 onRun = { cmd ->
2067 showShellDialog = false
2068 vm.runShell(cmd)
2069 },
2073 if (showModelPicker) {
2074 // Resolve persisted recent composite keys to the live ModelOption entries they refer
2075 // to, dropping any whose model is no longer offered by the server. Ordered by
2076 // recency (head of the store first).
2077 val recentOptions = remember(models, recentModelEntries) {
2078 val byKey = models.associateBy { it.providerID to it.modelID }
2079 recentModelEntries.mapNotNull { entry ->
2080 container.recentModelsStore.split(entry)?.let { (p, m) -> byKey[p to m] }
2083 // Resolve snackbar strings in the composable scope; the onSetPreferredModel lambda
2084 // below isn't @Composable, so stringResource can't be called inside it.
2085 val defaultModelClearedMsg = stringResource(R.string.default_model_cleared)
2086 val defaultModelSetFmt = stringResource(R.string.default_model_set, "%s")
2087 ModelPickerSheet(
2088 options = models,
2089 selected = selectedModel,
2090 loading = modelsLoading,
2091 error = modelsError,
2092 onSelect = {
2093 vm.selectModel(it)
2094 container.recentModelsStore.add(it.providerID, it.modelID)
2095 },
2096 onRetry = { vm.reloadModels() },
2097 onDismiss = { showModelPicker = false },
2098 preferredModelId = preferredModelId,
2099 onSetPreferredModel = { id ->
2100 scope.launch {
2101 runCatchingCancellable { container.settingsStore.setPreferredModelId(id) }
2102 // Confirm the change with a snackbar so the user sees it took effect —
2103 // the toggle's label flip can be missed, especially when clearing.
2104 val msg = if (id.isEmpty()) {
2105 defaultModelClearedMsg
2106 } else {
2107 val label = models.firstOrNull { it.modelID == id }?.modelLabel ?: id
2108 defaultModelSetFmt.format(label)
2110 snackbar.showSnackbar(msg)
2112 },
2113 recent = recentOptions,
2117 if (showAgentPicker) {
2118 AgentPickerSheet(
2119 agents = agents,
2120 selected = selectedAgent,
2121 loading = agentsLoading,
2122 error = agentsError,
2123 onSelect = { vm.selectAgent(it?.name) },
2124 onRetry = { vm.reloadAgents() },
2125 onDismiss = { showAgentPicker = false },
2129 if (showCommandPicker) {
2130 CommandPickerSheet(
2131 commands = commands,
2132 loading = commandsLoading,
2133 error = commandsError,
2134 onSelect = { vm.runCommand(it) },
2135 onRetry = { vm.reloadCommands() },
2136 onDismiss = { showCommandPicker = false },
2140 pendingPermission?.let { permission ->
2141 PermissionDialog(
2142 permission = permission,
2143 position = permissionProgress.position,
2144 total = permissionProgress.total,
2145 onRespond = { response -> vm.respondPermission(permission, response) },
2146 // On auto-reject timeout, post a notification so a returning user finds a
2147 // persistent record of why the run stopped (the dialog itself is gone).
2148 // Gated by the user's notification setting (notifPermission).
2149 onAutoReject = {
2150 val conn = activeConnection
2151 val ctx = appContext
2152 val title = sessionTitle ?: sessionId
2153 if (notifPermission) {
2154 soy.iko.opencode.notification.SessionNotifications.postPermissionAutoRejected(
2155 ctx, sessionId, title, conn?.profile?.id,
2158 },
2162 if (showPalette) {
2163 CommandPalette(actions = paletteActions, onDismiss = { showPalette = false })
2166 if (showShortcutsDialog) {
2167 // Surfaces the hardware-keyboard shortcuts (Ctrl+K palette, Escape stop, Enter send)
2168 // that are otherwise wired but undiscoverable. A simple key/value list; Enter behavior
2169 // depends on the "Send on Enter" setting, noted inline.
2170 AlertDialog(
2171 onDismissRequest = { showShortcutsDialog = false },
2172 title = { Text(stringResource(R.string.keyboard_shortcuts_title)) },
2173 text = {
2174 Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
2175 ShortcutRow("Ctrl + K", stringResource(R.string.shortcut_open_palette))
2176 ShortcutRow("Esc", stringResource(R.string.shortcut_stop_run))
2177 ShortcutRow("Esc", stringResource(R.string.shortcut_close))
2178 ShortcutRow("Enter", stringResource(R.string.shortcut_send))
2179 ShortcutRow("Shift + Enter / Ctrl + Enter", stringResource(R.string.shortcut_newline))
2181 },
2182 confirmButton = {
2183 TextButton(onClick = { showShortcutsDialog = false }) { Text(stringResource(R.string.action_continue)) }
2184 },
2189@Composable
2190private fun ShortcutRow(keys: String, desc: String) {
2191 Row(verticalAlignment = Alignment.CenterVertically) {
2192 Text(
2193 keys,
2194 style = MaterialTheme.typography.labelLarge,
2195 fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
2196 modifier = Modifier.widthIn(min = 160.dp),
2198 Text(
2199 desc,
2200 style = MaterialTheme.typography.bodyMedium,
2201 color = MaterialTheme.colorScheme.onSurfaceVariant,
2206/**
2207 * Slash-command autocomplete: when the draft starts with "/", show a filtered dropdown
2208 * of matching commands above the composer. Tapping a command fires it immediately.
2209 * Extracted from [ChatInputBar] to keep that function's cyclomatic complexity under
2210 * the detekt threshold. Suppressed while running or when the command catalog is empty.
2211 */
2212@Composable
2213private fun SlashCommandAutocomplete(
2214 value: String,
2215 commands: List<Command>,
2216 running: Boolean,
2217 onRunCommand: (Command) -> Unit,
2218) {
2219 if (running || commands.isEmpty()) return
2220 val slashQuery = remember(value) {
2221 if (value.startsWith("/")) value.removePrefix("/") else null
2222 } ?: return
2223 val matchingCommands = remember(slashQuery, commands) {
2224 commands.filter { cmd ->
2225 slashQuery.isEmpty() ||
2226 cmd.name.contains(slashQuery, ignoreCase = true) ||
2227 (cmd.description?.contains(slashQuery, ignoreCase = true) == true)
2228 }.take(8)
2230 if (matchingCommands.isNotEmpty()) {
2231 CommandAutocompleteDropdown(
2232 commands = matchingCommands,
2233 onPick = onRunCommand,
2238/**
2239 * Slash-command autocomplete dropdown shown above the composer when the draft starts
2240 * with "/". Renders up to 8 matching commands as a scrollable, elevated surface so the
2241 * user can discover and invoke commands by typing the conventional "/" prefix instead
2242 * of opening the overflow menu / command palette. Tapping a row fires the command.
2243 */
2244@Composable
2245private fun CommandAutocompleteDropdown(
2246 commands: List<Command>,
2247 onPick: (Command) -> Unit,
2248) {
2249 val haptics = LocalHapticFeedback.current
2250 Surface(
2251 color = MaterialTheme.colorScheme.surface,
2252 tonalElevation = 3.dp,
2253 shape = MaterialTheme.shapes.medium,
2254 modifier = Modifier
2255 .fillMaxWidth()
2256 .padding(horizontal = 8.dp, vertical = 4.dp)
2257 .heightIn(max = 240.dp),
2258 ) {
2259 LazyColumn {
2260 items(
2261 count = commands.size,
2262 key = { commands[it].name },
2263 ) { i ->
2264 val cmd = commands[i]
2265 Row(
2266 modifier = Modifier
2267 .fillMaxWidth()
2268 .clickable(role = Role.Button) {
2269 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
2270 onPick(cmd)
2272 .padding(horizontal = 16.dp, vertical = 10.dp),
2273 verticalAlignment = Alignment.CenterVertically,
2274 horizontalArrangement = Arrangement.spacedBy(12.dp),
2275 ) {
2276 Text(
2277 "/${cmd.name}",
2278 style = MaterialTheme.typography.labelLarge,
2279 fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
2280 color = MaterialTheme.colorScheme.primary,
2282 cmd.description?.takeIf { it.isNotBlank() }?.let { desc ->
2283 Text(
2284 desc,
2285 style = MaterialTheme.typography.bodySmall,
2286 color = MaterialTheme.colorScheme.onSurfaceVariant,
2287 maxLines = 1,
2288 overflow = TextOverflow.Ellipsis,
2297@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
2298@Suppress("CyclomaticComplexMethod")
2299@Composable
2300private fun ChatInputBar(
2301 value: String,
2302 onValueChange: (String) -> Unit,
2303 running: Boolean,
2304 aborting: Boolean,
2305 enabled: Boolean,
2306 sendOnEnter: Boolean,
2307 onSend: () -> Unit,
2308 onAbort: () -> Unit,
2309 queuedFollowUp: String?,
2310 onQueueFollowUp: (String) -> Unit,
2311 onCancelQueue: () -> Unit,
2312 focusRequester: androidx.compose.ui.focus.FocusRequester,
2313 attachments: List<PendingAttachment>,
2314 staging: Boolean,
2315 stagingFileCount: Int,
2316 onRemoveAttachment: (String) -> Unit,
2317 onPickPhoto: () -> Unit,
2318 onPickFile: () -> Unit,
2319 onCamera: () -> Unit,
2320 onVoice: () -> Unit,
2321 onPasteImage: (List<Uri>) -> Unit,
2322 commands: List<Command> = emptyList(),
2323 onRunCommand: (Command) -> Unit = {},
2324) {
2325 // Sendable when there's text OR at least one attachment (an image-only prompt is valid).
2326 val hasContent = value.isNotBlank() || attachments.isNotEmpty()
2327 val context = LocalContext.current
2328 // Full-screen editor state: opened when the user wants more space for a long prompt.
2329 var showFullScreenEditor by rememberSaveable { mutableStateOf(false) }
2330 Surface(tonalElevation = 3.dp, modifier = Modifier.imePadding()) {
2331 Column(modifier = Modifier.fillMaxWidth().windowInsetsPadding(WindowInsets.navigationBars)) {
2332 // A queued follow-up replaces the Stop button with a "queued" chip so the
2333 // user sees their message will be sent when the run finishes, and can cancel.
2334 if (queuedFollowUp != null) {
2335 val editLabel = stringResource(R.string.edit)
2336 Row(
2337 modifier = Modifier
2338 .fillMaxWidth()
2339 .padding(start = 12.dp, top = 6.dp, end = 4.dp),
2340 verticalAlignment = Alignment.CenterVertically,
2341 ) {
2342 // Tapping the chip reloads the queued text into the draft (and clears the
2343 // queue) so a typo spotted while queuing can be fixed without retyping.
2344 Column(
2345 modifier = Modifier
2346 .weight(1f)
2347 .clip(MaterialTheme.shapes.small)
2348 .clickable(role = Role.Button, onClickLabel = editLabel) {
2349 onValueChange(queuedFollowUp)
2350 onCancelQueue()
2352 .padding(vertical = 4.dp),
2353 ) {
2354 Text(
2355 stringResource(R.string.queued),
2356 style = MaterialTheme.typography.labelSmall,
2357 color = MaterialTheme.colorScheme.onSurfaceVariant,
2359 Text(
2360 queuedFollowUp,
2361 style = MaterialTheme.typography.bodySmall,
2362 color = MaterialTheme.colorScheme.onSurfaceVariant,
2363 maxLines = 1,
2364 overflow = TextOverflow.Ellipsis,
2367 TextButton(onClick = onCancelQueue, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp)) {
2368 Text(stringResource(R.string.cancel))
2372 // Staged attachments: horizontally-scrollable thumbnails/chips, each removable.
2373 AttachmentStrip(attachments, onRemove = onRemoveAttachment, staging = staging, stagingFileCount = stagingFileCount)
2374 // Slash-command autocomplete: extracted to a helper to keep this function's
2375 // complexity under the detekt threshold. Returns no-op when the draft doesn't
2376 // start with "/" or the command catalog is empty.
2377 SlashCommandAutocomplete(
2378 value = value,
2379 commands = commands,
2380 running = running,
2381 onRunCommand = onRunCommand,
2383 Row(
2384 modifier = Modifier.fillMaxWidth().padding(8.dp),
2385 verticalAlignment = Alignment.Bottom,
2386 ) {
2387 // Attach menu: photo picker, camera, or any file.
2388 var showAttachMenu by remember { mutableStateOf(false) }
2389 Box {
2390 IconButton(onClick = { showAttachMenu = true }, enabled = enabled) {
2391 Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.add_attachment))
2393 DropdownMenu(expanded = showAttachMenu, onDismissRequest = { showAttachMenu = false }) {
2394 DropdownMenuItem(
2395 leadingIcon = { Icon(Icons.Filled.PhotoLibrary, contentDescription = null) },
2396 text = { Text(stringResource(R.string.attach_photo)) },
2397 onClick = { showAttachMenu = false; onPickPhoto() },
2399 DropdownMenuItem(
2400 leadingIcon = { Icon(Icons.Filled.PhotoCamera, contentDescription = null) },
2401 text = { Text(stringResource(R.string.attach_camera)) },
2402 onClick = { showAttachMenu = false; onCamera() },
2404 DropdownMenuItem(
2405 leadingIcon = { Icon(Icons.Filled.Description, contentDescription = null) },
2406 text = { Text(stringResource(R.string.attach_file)) },
2407 onClick = { showAttachMenu = false; onPickFile() },
2411 // Voice dictation: appends recognized speech to the draft. Hidden (not just
2412 // disabled) when the device has no speech-recognition activity, so a user
2413 // without a voice app doesn't get a button that only fails noisily on tap.
2414 val voiceAvailable = remember {
2415 val pm = context.packageManager
2416 val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH)
2417 pm.queryIntentActivities(intent, 0).isNotEmpty()
2419 if (voiceAvailable) {
2420 IconButton(onClick = onVoice, enabled = enabled) {
2421 Icon(Icons.Filled.Mic, contentDescription = stringResource(R.string.voice_input))
2424 // Expand to a full-screen editor for long prompts (the inline field caps at 6 lines).
2425 IconButton(onClick = { showFullScreenEditor = true }, enabled = enabled) {
2426 Icon(Icons.Filled.Expand, contentDescription = stringResource(R.string.expand_editor))
2428 OutlinedTextField(
2429 value = value,
2430 onValueChange = { v ->
2431 // Cap input length so a huge paste can't stall the UI. Shares are
2432 // capped separately in MainActivity; this guards typed/pasted drafts.
2433 onValueChange(v.take(NetworkConfig.maxDraftLengthChars))
2434 },
2435 modifier = Modifier
2436 .weight(1f)
2437 .focusRequester(focusRequester)
2438 .testTag("chat_input")
2439 // Intercept image content pasted/dropped into the field (keyboard paste
2440 // of a copied screenshot, drag from Files, etc.) and route it through the
2441 // same staging path as the attach menu. Non-image content (text) falls
2442 // through unchanged so normal typing/paste works.
2443 .contentReceiver(
2444 remember {
2445 androidx.compose.foundation.content.ReceiveContentListener { transferable ->
2446 val clipData = transferable.clipEntry?.clipData
2447 if (clipData != null) {
2448 val imageUris = (0 until clipData.itemCount).mapNotNull { i ->
2449 val item = clipData.getItemAt(i)
2450 item.uri?.takeIf { uri ->
2451 item.text == null && uri.toString().let { s ->
2452 s.startsWith("content:") || s.startsWith("file:")
2456 if (imageUris.isNotEmpty()) {
2457 onPasteImage(imageUris)
2460 transferable
2462 },
2464 .onPreviewKeyEvent { event ->
2465 if (event.type != KeyEventType.KeyDown || event.key != Key.Enter) return@onPreviewKeyEvent false
2466 // With "Send on Enter" on, Enter sends (Shift+Enter newlines).
2467 // With it off, Enter inserts a newline and Ctrl+Enter sends.
2468 val send = enterShouldSend(
2469 enabled = enabled,
2470 hasContent = hasContent,
2471 sendOnEnter = sendOnEnter,
2472 shift = event.isShiftPressed,
2473 ctrl = event.isCtrlPressed,
2475 if (send) {
2476 if (running) onQueueFollowUp(value) else onSend()
2477 true
2478 } else {
2479 false
2481 },
2482 // While a run is active a typed message only queues (sends after the run), so
2483 // the placeholder says so instead of the plain "Message…".
2484 placeholder = {
2485 Text(stringResource(if (running) R.string.composer_hint_running else R.string.message_placeholder))
2486 },
2487 enabled = enabled,
2488 maxLines = 6,
2489 // Show a "N / max" countdown once the draft crosses a high fraction of
2490 // the cap, so the user knows a paste is about to be truncated instead
2491 // of being silently cut off. Hidden for normal short prompts to avoid
2492 // clutter under a typical one-line message.
2493 supportingText = {
2494 val threshold = (NetworkConfig.maxDraftLengthChars * NetworkConfig.draftCountdownThresholdFraction).toInt()
2495 if (value.length >= threshold) {
2496 Text(
2497 stringResource(
2498 R.string.draft_chars_remaining,
2499 NetworkConfig.maxDraftLengthChars - value.length,
2500 NetworkConfig.maxDraftLengthChars,
2501 ),
2502 style = MaterialTheme.typography.labelSmall,
2503 color = if (value.length >= NetworkConfig.maxDraftLengthChars) MaterialTheme.colorScheme.error
2504 else MaterialTheme.colorScheme.onSurfaceVariant,
2506 } else if (value.lines().size > 6) {
2507 // The inline field caps at 6 lines; once the draft crosses that,
2508 // hint that a bigger full-screen editor exists (the Expand icon
2509 // above is easy to miss among the icon row). A tappable label
2510 // would be ideal, but a plain hint is enough to draw attention.
2511 Text(
2512 stringResource(R.string.open_full_editor_hint),
2513 style = MaterialTheme.typography.labelSmall,
2514 color = MaterialTheme.colorScheme.onSurfaceVariant,
2517 },
2518 keyboardOptions = KeyboardOptions(
2519 // When "Send on Enter" is on, expose ImeAction.Send so the soft
2520 // keyboard shows a send key — matching the setting's intent and
2521 // giving touch-only users a keyboard send affordance. When off,
2522 // ImeAction.Default keeps the enter/newline key for multi-line input.
2523 imeAction = if (sendOnEnter) ImeAction.Send else ImeAction.Default,
2524 capitalization = KeyboardCapitalization.Sentences,
2525 ),
2526 keyboardActions = KeyboardActions(
2527 onSend = {
2528 // Mirror the hardware-Enter handler: during a run the soft-keyboard
2529 // send key must queue the follow-up, not silently drop it. vm.send()
2530 // returns false while a run is active (beginRun() rejects), so calling
2531 // onSend() directly here would lose the typed message with no feedback
2532 // for touch-only users (no hardware Enter path available).
2533 if (enabled && hasContent) {
2534 if (running) onQueueFollowUp(value) else onSend()
2536 },
2537 ),
2539 ComposerTrailingButton(
2540 running = running,
2541 aborting = aborting,
2542 canSend = enabled && hasContent,
2543 canQueue = enabled && value.isNotBlank(),
2544 onSend = onSend,
2545 onAbort = onAbort,
2546 onQueue = { onQueueFollowUp(value) },
2550 if (showFullScreenEditor) {
2551 FullScreenEditor(
2552 value = value,
2553 onValueChange = onValueChange,
2554 onDismiss = { showFullScreenEditor = false },
2555 onSend = {
2556 showFullScreenEditor = false
2557 // Mirror the inline composer's behavior: while a run is active a send
2558 // can't go immediately, so queue the follow-up instead of silently
2559 // dropping it (vm.send() returns false during a run, which previously
2560 // lost the typed message with no feedback).
2561 if (running) onQueueFollowUp(value) else onSend()
2562 },
2563 canSend = enabled && hasContent,
2564 running = running,
2565 sendOnEnter = sendOnEnter,
2566 attachments = attachments,
2567 onRemoveAttachment = onRemoveAttachment,
2568 staging = staging,
2569 stagingFileCount = stagingFileCount,
2575/** Full-screen editor dialog for composing long prompts. Gives the full viewport height to
2576 * the text field (vs the inline 6-line cap) with a Send button and a collapse action. */
2577@OptIn(ExperimentalMaterial3Api::class)
2578@Composable
2579private fun FullScreenEditor(
2580 value: String,
2581 onValueChange: (String) -> Unit,
2582 onDismiss: () -> Unit,
2583 onSend: () -> Unit,
2584 canSend: Boolean,
2585 running: Boolean = false,
2586 sendOnEnter: Boolean = false,
2587 attachments: List<PendingAttachment> = emptyList(),
2588 onRemoveAttachment: (String) -> Unit = {},
2589 staging: Boolean = false,
2590 stagingFileCount: Int = 0,
2591) {
2592 androidx.compose.ui.window.Dialog(
2593 onDismissRequest = onDismiss,
2594 properties = androidx.compose.ui.window.DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false),
2595 ) {
2596 // Cap the editor width on large screens (tablets) so the text field doesn't stretch
2597 // edge-to-edge — matching the chat list's readability rationale. BoxWithConstraints
2598 // gives the available width so we can center a capped-width column.
2599 androidx.compose.foundation.layout.BoxWithConstraints(
2600 modifier = Modifier.fillMaxSize(),
2601 contentAlignment = Alignment.Center,
2602 ) {
2603 val hPad = if (maxWidth > NetworkConfig.twoPaneWidthThresholdDp.dp)
2604 ((maxWidth - NetworkConfig.composerDialogMaxWidthDp.dp) / 2).coerceAtLeast(0.dp)
2605 else 0.dp
2606 Scaffold(
2607 modifier = Modifier.padding(horizontal = hPad),
2608 topBar = {
2609 androidx.compose.material3.TopAppBar(
2610 title = { Text(stringResource(R.string.compose_editor_title)) },
2611 navigationIcon = {
2612 androidx.compose.material3.TextButton(onClick = onDismiss) {
2613 Text(stringResource(R.string.collapse_editor))
2615 },
2617 },
2618 bottomBar = {
2619 Surface(tonalElevation = 3.dp) {
2620 Row(
2621 modifier = Modifier.fillMaxWidth().imePadding().padding(12.dp),
2622 horizontalArrangement = Arrangement.End,
2623 ) {
2624 androidx.compose.material3.Button(onClick = onSend, enabled = canSend) {
2625 Text(stringResource(if (running) R.string.queue else R.string.send))
2629 },
2630 ) { padding ->
2631 Column(modifier = Modifier.fillMaxSize().padding(padding)) {
2632 // Show staged attachments in the full-screen editor too, so a user who staged
2633 // images then expanded for a long prompt can still see and remove them without
2634 // collapsing first (mirrors the inline composer's AttachmentStrip).
2635 AttachmentStrip(attachments, onRemove = onRemoveAttachment, staging = staging, stagingFileCount = stagingFileCount)
2636 OutlinedTextField(
2637 value = value,
2638 onValueChange = { v -> onValueChange(v.take(NetworkConfig.maxDraftLengthChars)) },
2639 modifier = Modifier
2640 .fillMaxSize()
2641 .weight(1f)
2642 // Honor the "Send on Enter" setting here too, so the full-screen
2643 // editor matches the inline composer: Enter sends (Shift+Enter
2644 // newlines) when on; Enter inserts a newline and Ctrl+Enter sends
2645 // when off. Previously this hard-coded ImeAction.Default and had no
2646 // key handler, so a hardware-keyboard user got a different behaviour
2647 // from the inline composer depending on which they happened to open.
2648 .onPreviewKeyEvent { event ->
2649 if (event.type != KeyEventType.KeyDown || event.key != Key.Enter) return@onPreviewKeyEvent false
2650 val send = enterShouldSend(
2651 enabled = canSend,
2652 hasContent = canSend,
2653 sendOnEnter = sendOnEnter,
2654 shift = event.isShiftPressed,
2655 ctrl = event.isCtrlPressed,
2657 if (send) { onSend(); true } else false
2658 },
2659 placeholder = { Text(stringResource(R.string.message_placeholder)) },
2660 maxLines = Int.MAX_VALUE,
2661 keyboardOptions = KeyboardOptions(
2662 imeAction = if (sendOnEnter) ImeAction.Send else ImeAction.Default,
2663 capitalization = KeyboardCapitalization.Sentences,
2664 ),
2665 keyboardActions = KeyboardActions(
2666 onSend = { if (canSend) onSend() },
2667 ),
2675/** Whether a hardware Enter keypress should send: never when disabled/empty; otherwise
2676 * Enter sends (unless Shift) when send-on-Enter is on, else only Ctrl+Enter sends. */
2677private fun enterShouldSend(
2678 enabled: Boolean,
2679 hasContent: Boolean,
2680 sendOnEnter: Boolean,
2681 shift: Boolean,
2682 ctrl: Boolean,
2683): Boolean = when {
2684 !enabled || !hasContent -> false
2685 sendOnEnter -> !shift
2686 else -> ctrl
2689/** The composer's trailing button: while a run is active a Stop (with in-flight spinner),
2690 * preceded by a Queue button when the user has typed a follow-up; otherwise Send. Extracted so
2691 * [ChatInputBar] stays under the complexity threshold. */
2692@Composable
2693private fun ComposerTrailingButton(
2694 running: Boolean,
2695 aborting: Boolean,
2696 canSend: Boolean,
2697 canQueue: Boolean,
2698 onSend: () -> Unit,
2699 onAbort: () -> Unit,
2700 onQueue: () -> Unit,
2701) {
2702 if (running) {
2703 Row(verticalAlignment = Alignment.CenterVertically) {
2704 // While a run is active the trailing action is Stop, so the only send affordance is
2705 // the keyboard's IME action — which is a plain newline when "Send on Enter" is off.
2706 // That leaves a touch-only user with the setting off no way to queue a follow-up
2707 // (Ctrl+Enter needs a hardware keyboard). Surface an explicit Queue button whenever
2708 // there's typed TEXT so queuing is always reachable, without changing the user's
2709 // Enter-inserts-a-newline preference. Gate on typed text — not the send-oriented
2710 // canSend, which is also true for an attachment-only composer — because queuing "" just
2711 // calls setQueuedFollowUp(null), silently cancelling any already-queued follow-up and
2712 // dropping the staged attachment (queued follow-ups don't carry attachments anyway).
2713 if (canQueue) {
2714 IconButton(
2715 onClick = onQueue,
2716 modifier = Modifier.testTag("queue_button"),
2717 ) {
2718 // PlaylistAdd reads as "add to a queue/list" more clearly than a clock
2719 // icon (which can be read as "snooze" or "later"), and isn't confused with
2720 // the Send action sitting right beside the Stop button.
2721 Icon(
2722 Icons.AutoMirrored.Filled.PlaylistAdd,
2723 contentDescription = stringResource(R.string.queue_followup),
2727 // Show a spinner while the abort REST call is in flight so the user sees the stop
2728 // was sent, and disable to prevent a double-tap.
2729 IconButton(
2730 onClick = onAbort,
2731 enabled = !aborting,
2732 modifier = Modifier.padding(start = 4.dp).testTag("stop_button"),
2733 ) {
2734 if (aborting) {
2735 val stopLabel = stringResource(R.string.stop)
2736 CircularProgressIndicator(
2737 Modifier.size(18.dp).semantics { contentDescription = stopLabel },
2738 strokeWidth = 2.dp,
2740 } else {
2741 Icon(Icons.Filled.Stop, contentDescription = stringResource(R.string.stop))
2745 } else {
2746 IconButton(
2747 onClick = onSend,
2748 enabled = canSend,
2749 modifier = Modifier.padding(start = 4.dp).testTag("send_button"),
2750 ) {
2751 // Tint the send icon with primary when there's content to send so the commit action
2752 // draws the eye; muted otherwise. A plain enabled/disabled flip is easy to miss.
2753 Icon(
2754 Icons.AutoMirrored.Filled.Send,
2755 contentDescription = stringResource(R.string.send),
2756 tint = if (canSend) MaterialTheme.colorScheme.primary
2757 else MaterialTheme.colorScheme.onSurfaceVariant,
2763@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class)
2764@Composable
2765private fun EmptyConversation(
2766 onSuggestion: (String) -> Unit,
2767 modifier: Modifier = Modifier,
2768) {
2769 // A few example prompts so a first-time user has a one-tap path to their first
2770 // message instead of staring at a blank input. Tapping a chip fills the draft
2771 // (without sending) so the user can edit it before sending.
2772 val suggestions = remember {
2773 listOf(
2774 R.string.empty_chat_suggest_1,
2775 R.string.empty_chat_suggest_2,
2776 R.string.empty_chat_suggest_3,
2779 Column(
2780 modifier = modifier.padding(32.dp),
2781 horizontalAlignment = Alignment.CenterHorizontally,
2782 ) {
2783 Icon(
2784 Icons.Filled.AutoAwesome,
2785 contentDescription = null,
2786 modifier = Modifier.size(48.dp),
2787 tint = MaterialTheme.colorScheme.onSurfaceVariant,
2789 Spacer(Modifier.size(16.dp))
2790 Text(
2791 stringResource(R.string.empty_chat_title),
2792 style = MaterialTheme.typography.titleMedium,
2794 Spacer(Modifier.size(8.dp))
2795 Text(
2796 stringResource(R.string.empty_chat_hint),
2797 style = MaterialTheme.typography.bodyMedium,
2798 color = MaterialTheme.colorScheme.onSurfaceVariant,
2800 Spacer(Modifier.size(20.dp))
2801 // Suggestion chips wrap across rows on narrow screens instead of stacking full-width,
2802 // so a longer localized prompt doesn't push the chips off-screen on a phone.
2803 FlowRow(
2804 modifier = Modifier.fillMaxWidth(),
2805 horizontalArrangement = Arrangement.spacedBy(8.dp),
2806 verticalArrangement = Arrangement.spacedBy(8.dp),
2807 ) {
2808 suggestions.forEach { resId ->
2809 val text = stringResource(resId)
2810 androidx.compose.material3.AssistChip(
2811 onClick = { onSuggestion(text) },
2812 label = { Text(text, style = MaterialTheme.typography.bodyMedium) },
2819/** A banner shown while a revert checkpoint is active, offering an Undo (unrevert). When the
2820 * server included a diff of the revert, a collapsible "Show what changed" affordance reveals
2821 * it inline via [DiffView] so the user can see what was rolled back before deciding to undo. */
2822@Composable
2823private fun RevertBanner(diff: String?, isEditing: Boolean, onUndo: () -> Unit) {
2824 val haptics = LocalHapticFeedback.current
2825 var showDiff by rememberSaveable { mutableStateOf(false) }
2826 val expandMotion = rememberVisibilityTransitions()
2827 Surface(
2828 color = MaterialTheme.colorScheme.tertiaryContainer,
2829 tonalElevation = 2.dp,
2830 modifier = Modifier.fillMaxWidth(),
2831 ) {
2832 Column {
2833 Row(
2834 modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp),
2835 verticalAlignment = Alignment.CenterVertically,
2836 horizontalArrangement = Arrangement.SpaceBetween,
2837 ) {
2838 Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
2839 Icon(
2840 if (isEditing) Icons.Filled.Edit else Icons.Filled.Restore,
2841 contentDescription = null,
2842 tint = MaterialTheme.colorScheme.onTertiaryContainer,
2843 modifier = Modifier.size(18.dp),
2845 Text(
2846 stringResource(if (isEditing) R.string.editing_banner else R.string.reverted_banner),
2847 style = MaterialTheme.typography.bodySmall,
2848 color = MaterialTheme.colorScheme.onTertiaryContainer,
2849 modifier = Modifier.padding(start = 8.dp),
2851 // "Show what changed" toggle, shown only when the server provided a diff.
2852 // Collapsible so the banner stays compact by default; a user curious about
2853 // what the revert rolled back can expand it inline without leaving the chat.
2854 if (!diff.isNullOrBlank()) {
2855 TextButton(
2856 onClick = {
2857 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
2858 showDiff = !showDiff
2859 },
2860 contentPadding = androidx.compose.foundation.layout.PaddingValues(start = 8.dp, end = 4.dp),
2861 ) {
2862 Icon(
2863 if (showDiff) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
2864 contentDescription = null,
2865 modifier = Modifier.size(16.dp),
2867 Text(
2868 stringResource(if (showDiff) R.string.revert_hide_diff else R.string.revert_show_diff),
2869 style = MaterialTheme.typography.labelSmall,
2870 modifier = Modifier.padding(start = 4.dp),
2875 TextButton(onClick = {
2876 // Haptic on undo to match the other destructive/important confirmations: a
2877 // revert restores potentially hundreds of hidden messages.
2878 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
2879 onUndo()
2880 }) { Text(stringResource(R.string.undo)) }
2882 // The diff render lives inside the banner's Surface so it inherits the tertiary
2883 // tonal container and reads as part of the banner, not a floating panel.
2884 if (!diff.isNullOrBlank()) {
2885 AnimatedVisibility(
2886 visible = showDiff,
2887 enter = expandMotion.enter,
2888 exit = expandMotion.exit,
2889 ) {
2890 Column(modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 8.dp)) {
2891 Text(
2892 stringResource(R.string.revert_diff_header),
2893 style = MaterialTheme.typography.labelSmall,
2894 color = MaterialTheme.colorScheme.onTertiaryContainer,
2895 modifier = Modifier.padding(bottom = 4.dp),
2897 DiffView(diff, saveKey = "revertDiff")
2905/** A dismissible preview card shown above the composer when the user quote-replied to a
2906 * message. Displays the quoted text (truncated) with a Cancel affordance so the user can
2907 * abort the quote without digging into the draft to strip the `>`-prefixed lines. Mirrors
2908 * the Discord/Slack quote-preview pattern. */
2909@Composable
2910private fun QuoteReplyBanner(quotedText: String, onCancel: () -> Unit) {
2911 val haptics = LocalHapticFeedback.current
2912 Surface(
2913 color = MaterialTheme.colorScheme.secondaryContainer,
2914 tonalElevation = 2.dp,
2915 modifier = Modifier.fillMaxWidth(),
2916 ) {
2917 Row(
2918 modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 4.dp, top = 6.dp, bottom = 6.dp),
2919 verticalAlignment = Alignment.Top,
2920 ) {
2921 Icon(
2922 Icons.Filled.FormatQuote,
2923 contentDescription = null,
2924 tint = MaterialTheme.colorScheme.onSecondaryContainer,
2925 modifier = Modifier.size(18.dp).padding(top = 2.dp),
2927 Column(modifier = Modifier.weight(1f).padding(start = 8.dp)) {
2928 Text(
2929 stringResource(R.string.quote_reply_banner),
2930 style = MaterialTheme.typography.labelSmall,
2931 color = MaterialTheme.colorScheme.onSecondaryContainer,
2933 Text(
2934 quotedText,
2935 style = MaterialTheme.typography.bodySmall,
2936 color = MaterialTheme.colorScheme.onSecondaryContainer,
2937 maxLines = 2,
2938 overflow = TextOverflow.Ellipsis,
2941 IconButton(onClick = {
2942 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
2943 onCancel()
2944 }) {
2945 Icon(
2946 Icons.Filled.Close,
2947 contentDescription = stringResource(R.string.cancel_quote_reply),
2948 tint = MaterialTheme.colorScheme.onSecondaryContainer,
2949 modifier = Modifier.size(18.dp),
2956/** A banner shown while messages composed offline are queued in the outbox, with a
2957 * "Send now" nudge and a discard. Shows a spinner while the queue is flushing. */
2958@Composable
2959private fun OutboxBanner(
2960 count: Int,
2961 sending: Boolean,
2962 onFlush: () -> Unit,
2963 onDiscard: () -> Unit,
2964) {
2965 var showDiscardConfirm by rememberSaveable { mutableStateOf(false) }
2966 Surface(
2967 color = MaterialTheme.colorScheme.secondaryContainer,
2968 tonalElevation = 2.dp,
2969 modifier = Modifier.fillMaxWidth(),
2970 ) {
2971 Row(
2972 modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp),
2973 verticalAlignment = Alignment.CenterVertically,
2974 horizontalArrangement = Arrangement.SpaceBetween,
2975 ) {
2976 Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
2977 if (sending) {
2978 CircularProgressIndicator(
2979 Modifier.size(16.dp),
2980 strokeWidth = 2.dp,
2981 color = MaterialTheme.colorScheme.onSecondaryContainer,
2983 Spacer(Modifier.size(8.dp))
2985 Text(
2986 if (sending) {
2987 stringResource(R.string.outbox_sending)
2988 } else {
2989 val res = if (count == 1) R.string.outbox_pending_one else R.string.outbox_pending_many
2990 stringResource(res, count)
2991 },
2992 style = MaterialTheme.typography.bodySmall,
2993 color = MaterialTheme.colorScheme.onSecondaryContainer,
2996 if (!sending) {
2997 val haptics = LocalHapticFeedback.current
2998 TextButton(onClick = onFlush) { Text(stringResource(R.string.outbox_flush)) }
2999 TextButton(onClick = {
3000 // Confirm before discarding all queued messages (irreversible), matching
3001 // the confirmation pattern used by deleteSession and other destructive
3002 // actions. Previously a single tap dropped every queued message.
3003 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
3004 showDiscardConfirm = true
3005 }) {
3006 Text(stringResource(R.string.outbox_discard_all), color = MaterialTheme.colorScheme.error)
3011 if (showDiscardConfirm) {
3012 androidx.compose.material3.AlertDialog(
3013 onDismissRequest = { showDiscardConfirm = false },
3014 title = { Text(stringResource(R.string.discard_queued_title)) },
3015 text = { Text(stringResource(R.string.discard_queued_text, count)) },
3016 confirmButton = {
3017 androidx.compose.material3.TextButton(onClick = {
3018 showDiscardConfirm = false
3019 onDiscard()
3020 }) { Text(stringResource(R.string.discard)) }
3021 },
3022 dismissButton = {
3023 androidx.compose.material3.TextButton(onClick = { showDiscardConfirm = false }) {
3024 Text(stringResource(R.string.cancel))
3026 },
3031/** Prompt for a one-off shell command to run in the session's worktree. */
3032@Composable
3033private fun ShellCommandDialog(onDismiss: () -> Unit, onRun: (String) -> Unit) {
3034 var command by rememberSaveable { mutableStateOf("") }
3035 AlertDialog(
3036 onDismissRequest = onDismiss,
3037 title = { Text(stringResource(R.string.run_shell_command)) },
3038 text = {
3039 OutlinedTextField(
3040 value = command,
3041 onValueChange = { command = it },
3042 modifier = Modifier.fillMaxWidth(),
3043 placeholder = { Text(stringResource(R.string.shell_command_hint)) },
3044 label = { Text(stringResource(R.string.shell_command_label)) },
3045 singleLine = true,
3046 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
3047 keyboardActions = KeyboardActions(onDone = { if (command.isNotBlank()) onRun(command.trim()) }),
3049 },
3050 confirmButton = {
3051 TextButton(
3052 onClick = { if (command.isNotBlank()) onRun(command.trim()) },
3053 enabled = command.isNotBlank(),
3054 ) { Text(stringResource(R.string.run)) }
3055 },
3056 dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } },
3060/**
3061 * Change signal for the auto-scroll-on-stream watcher. Primitive fields avoid the
3062 * boxing `Triple<Int, Int, Boolean>` would impose on every snapshot evaluation, and
3063 * equality is structural so `snapshotFlow` emits only when something relevant changes.
3064 */
3065private data class AutoScrollSignal(val size: Int, val lastTextLength: Int, val pinned: Boolean)
3067/** Length of the streaming content carried by [part], driving auto-scroll as it grows.
3068 * Covers text, reasoning, and tool output so a pinned view keeps following non-text
3069 * streaming, not just plain assistant text. O(1). */
3070private fun streamingContentLength(part: Part?): Int = when (part) {
3071 is TextPart -> part.text.length
3072 is ReasoningPart -> part.text.length
3073 is ToolPart -> when (val st = part.state) {
3074 is ToolCompleted -> st.output?.length ?: 0
3075 is ToolError -> st.error?.length ?: 0
3076 is ToolRunning -> st.title?.length ?: 0
3077 else -> 0
3079 else -> 0
3082/** Rename dialog for the currently-open session. Mirrors the SessionListScreen rename
3083 * dialog but lives in the chat screen so the user can rename without backing out to
3084 * the list. Pre-filled with the current title; the cap matches the list dialog. */
3085@Composable
3086private fun RenameSessionChatDialog(
3087 initialTitle: String,
3088 onDismiss: () -> Unit,
3089 onConfirm: (String) -> Unit,
3090) {
3091 // Seed the field once (no key input) so a later external title change — e.g. opencode
3092 // auto-generating a session title after the first prompt, arriving via SessionUpdated —
3093 // doesn't re-initialize the box and discard the user's in-progress edit.
3094 var title by rememberSaveable { mutableStateOf(initialTitle) }
3095 AlertDialog(
3096 onDismissRequest = onDismiss,
3097 title = { Text(stringResource(R.string.rename_session_chat)) },
3098 text = {
3099 OutlinedTextField(
3100 value = title,
3101 onValueChange = { v -> title = v.take(NetworkConfig.maxSessionTitleChars) },
3102 modifier = Modifier.fillMaxWidth(),
3103 placeholder = { Text(stringResource(R.string.session_title_hint)) },
3104 label = { Text(stringResource(R.string.rename_session_chat)) },
3105 singleLine = true,
3106 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
3107 keyboardActions = KeyboardActions(onDone = { if (title.isNotBlank()) onConfirm(title.trim()) }),
3109 },
3110 confirmButton = {
3111 TextButton(onClick = { if (title.isNotBlank()) onConfirm(title.trim()) }, enabled = title.isNotBlank()) {
3112 Text(stringResource(R.string.save))
3114 },
3115 dismissButton = {
3116 TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) }
3117 },
3121/**
3122 * A single item in the chat message list — either a [Message] bubble or a day
3123 * [Separator] inserted before the first message of a new day. The separator shifts
3124 * the LazyColumn indices vs. the raw messages list, so scroll-target math must use
3125 * the [List] of these items (not `messages.size`/`lastIndex`).
3126 */
3127private sealed interface MessageListItem {
3128 val key: Any
3129 val contentType: Any
3131 data class Message(
3132 val message: MessageWithParts,
3133 override val key: Any,
3134 // Whether this is the first message in a consecutive run of the same speaker (role).
3135 // Used to suppress the assistant avatar/header on follow-up messages in the same run,
3136 // reducing visual noise in tool-heavy conversations where many assistant messages
3137 // appear back-to-back. A separator or a role change resets the flag.
3138 val isFirstOfSpeaker: Boolean,
3139 ) : MessageListItem {
3140 override val contentType: Any get() = message.info::class
3143 data class Separator(val label: String, val ordinal: Int) : MessageListItem {
3144 // Key on the day-occurrence ordinal, not the label: two non-contiguous groups
3145 // can share a label (e.g. an untimestamped message — empty label — between two
3146 // same-day messages, or two untimestamped groups), and a label-only key would
3147 // then collide, which makes LazyColumn throw on duplicate keys. The ordinal is
3148 // stable for a given message ordering so slots aren't needlessly recreated.
3149 override val key: Any get() = "sep_${ordinal}_$label"
3150 // Separators share a contentType so the LazyColumn can recycle their slots.
3151 override val contentType: Any get() = "separator"
3155/**
3156 * Build the interleaved list of [MessageListItem]s for the message list, inserting a
3157 * [MessageListItem.Separator] before the first message of each new calendar day. Uses
3158 * the message's `time.created` (falling back to `updated` then `completed`) to bucket.
3159 * Messages with no timestamp are grouped under an empty-label separator only if they
3160 * start the list, so a server that omits timestamps doesn't suppress the first divider.
3162 * [todayLabel]/[yesterdayLabel] are resolved by the caller (a @Composable can't call
3163 * stringResource from inside this plain function) and used for the "Today"/"Yesterday"
3164 * labels; older days fall back to a locale-stable medium-date format.
3165 */
3166private fun buildMessageListItems(
3167 messages: List<MessageWithParts>,
3168 todayLabel: String,
3169 yesterdayLabel: String,
3170): List<MessageListItem> {
3171 if (messages.isEmpty()) return emptyList()
3172 val today = java.time.LocalDate.now(java.time.ZoneId.systemDefault())
3173 val dateFmt = mediumDateFormat()
3174 val result = ArrayList<MessageListItem>(messages.size + 4)
3175 val seenKeys = HashSet<String>(messages.size * 2)
3176 var lastDayKey: String? = null
3177 var sepOrdinal = 0
3178 var emptyIdOrdinal = 0
3179 var first = true
3180 // Track the previous message's role to detect consecutive same-speaker runs, so the
3181 // avatar/header can be suppressed on follow-up messages in the same run. A null means
3182 // "no previous message or a separator just interrupted the run", both of which make the
3183 // next message the first of its group.
3184 var prevRoleClass: kotlin.reflect.KClass<out soy.iko.opencode.data.model.MessageInfo>? = null
3185 for (message in messages) {
3186 val ts = message.info.time?.created ?: message.info.time?.updated ?: message.info.time?.completed
3187 val dayKey = ts?.let { dayKey(it) } ?: ""
3188 // Emit a separator before the very first message (so a leading un-timestamped message
3189 // still gets the first divider, empty label and all), and thereafter only when crossing
3190 // into a new NON-empty calendar day. An un-timestamped message mid-list (empty key)
3191 // neither emits its own separator nor resets the day — otherwise a following same-day
3192 // message would see a "" -> "<today>" transition and emit a duplicate "Today" header.
3193 if (first || (dayKey.isNotEmpty() && dayKey != lastDayKey)) {
3194 result.add(MessageListItem.Separator(dayLabel(dayKey, ts ?: 0L, today, todayLabel, yesterdayLabel, dateFmt), sepOrdinal++))
3195 // A date separator visually breaks a speaker run, so the next message is treated
3196 // as the first of its group (even if the same role continues across the day break).
3197 prevRoleClass = null
3199 if (dayKey.isNotEmpty()) lastDayKey = dayKey
3200 first = false
3201 // Most messages have a unique non-empty id. An unrecognized-role message can carry an
3202 // empty id — and the reducer intentionally keeps several such messages as distinct
3203 // holders — so give each a stable synthetic key and defensively de-collide any remaining
3204 // duplicate; otherwise LazyColumn throws on a duplicate key and the whole screen crashes.
3205 var key = message.info.id.ifEmpty { "msg-empty-${emptyIdOrdinal++}" }
3206 if (!seenKeys.add(key)) {
3207 var suffix = 1
3208 while (!seenKeys.add("$key#$suffix")) suffix++
3209 key = "$key#$suffix"
3211 val roleClass = message.info::class
3212 val isFirstOfSpeaker = prevRoleClass == null || roleClass != prevRoleClass
3213 result.add(MessageListItem.Message(message, key, isFirstOfSpeaker))
3214 prevRoleClass = roleClass
3216 return result
3219/**
3220 * A stable bucket key for a timestamp's calendar day (epoch-days as a string).
3222 * [buildMessageListItems] re-runs on every streaming snapshot (the memo is keyed on the
3223 * whole `messages` list, which must stay so the live message's fresh parts render), so a
3224 * naive implementation would re-allocate an `Instant`/`ZonedDateTime`/`LocalDate` for
3225 * every message on every token. Message timestamps are immutable once assigned, so we
3226 * memoize the parse per epoch-millis: after the first snapshot, each unchanged message is
3227 * a cheap hash lookup. Bounded; only touched on the main thread during composition.
3229 * Backed by a [ConcurrentHashMap] (not an access-ordered LinkedHashMap): the previous
3230 * synchronized access-ordered LinkedHashMap reordered its linked list node on every
3231 * `getOrPut` hit, which is real work multiplied by N messages × ~20 snapshots/sec during
3232 * streaming. A ConcurrentHashMap has no access-order bookkeeping and is lock-free for
3233 * reads, so the common case (a hit) is a single volatile read + hash probe. The bound
3234 * is enforced via [removeEldestEntry] is not available on ConcurrentHashMap, so we cap
3235 * via `compute`-guarded size checks; in practice the cache size is bounded by the number
3236 * of distinct message timestamps in a session (≤ [NetworkConfig.maxInMemoryMessages]).
3237 */
3238private val dayKeyCache = java.util.concurrent.ConcurrentHashMap<Long, String>(256)
3240private fun dayKey(epochMillis: Long): String = dayKeyCache.getOrPut(epochMillis) {
3241 java.time.Instant.ofEpochMilli(epochMillis)
3242 .atZone(java.time.ZoneId.systemDefault()).toLocalDate().toEpochDay().toString()
3245// The medium-date formatter is an ICU locale lookup; cache one instance instead of
3246// allocating a fresh one per buildMessageListItems call, rebuilding only if the default
3247// locale changes at runtime. Main-thread-only, so no synchronization is needed.
3248private var cachedDateFmt: java.text.DateFormat? = null
3249private var cachedDateFmtLocale: java.util.Locale? = null
3251private fun mediumDateFormat(): java.text.DateFormat {
3252 val locale = java.util.Locale.getDefault()
3253 val existing = cachedDateFmt
3254 if (existing != null && cachedDateFmtLocale == locale) return existing
3255 return java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM).also {
3256 cachedDateFmt = it
3257 cachedDateFmtLocale = locale
3261/**
3262 * Resolve a day-key back to a human-readable label. "Today" / "Yesterday" for the
3263 * recent days, otherwise a locale-stable medium-date format. Returns an empty string
3264 * when [dayKey] is empty (no timestamp) so no separator is rendered for un-timestamped
3265 * messages.
3266 */
3267private fun dayLabel(
3268 dayKey: String,
3269 ts: Long,
3270 today: java.time.LocalDate,
3271 todayLabel: String,
3272 yesterdayLabel: String,
3273 dateFmt: java.text.DateFormat,
3274): String {
3275 if (dayKey.isEmpty()) return ""
3276 val epochDay = dayKey.toLongOrNull() ?: return ""
3277 val date = java.time.LocalDate.ofEpochDay(epochDay)
3278 return when {
3279 date == today -> todayLabel
3280 date == today.minusDays(1) -> yesterdayLabel
3281 else -> dateFmt.format(java.util.Date(ts))
3285/**
3286 * A centered day divider in the message list. Renders nothing for an empty label
3287 * (the no-timestamp case) so un-timestamped messages don't get a stray blank divider.
3288 */
3289@Composable
3290private fun DateSeparator(label: String) {
3291 if (label.isBlank()) return
3292 Row(
3293 modifier = Modifier.fillMaxWidth(),
3294 horizontalArrangement = Arrangement.Center,
3295 ) {
3296 Surface(
3297 color = MaterialTheme.colorScheme.surfaceVariant,
3298 shape = MaterialTheme.shapes.small,
3299 // Marked as a heading so TalkBack users can navigate day-by-day with the
3300 // heading-skip gesture — the canonical affordance for date dividers in a list.
3301 modifier = Modifier.semantics(mergeDescendants = true) { heading() },
3302 ) {
3303 Text(
3304 label,
3305 style = MaterialTheme.typography.labelSmall,
3306 color = MaterialTheme.colorScheme.onSurfaceVariant,
3307 modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
3313/** Skeleton placeholder bubbles for the chat loading and run-started states. Alpha pulses
3314 * (gated by reduced-motion) to convey ongoing work; the column carries the a11y label so
3315 * TalkBack announces "Loading" instead of reading the skeleton bars. Rendered as a
3316 * [BoxScope] extension so it can align itself to the top center of its host box. */
3317@Composable
3318private fun androidx.compose.foundation.layout.BoxScope.ChatSkeleton(topPad: androidx.compose.ui.unit.Dp) {
3319 val loadingLabel = stringResource(R.string.loading)
3320 val reducedMotion = LocalReducedMotion.current
3321 val transition = rememberInfiniteTransition(label = "skeleton")
3322 val pulse by transition.animateFloat(
3323 initialValue = 0.35f,
3324 targetValue = 0.7f,
3325 animationSpec = infiniteRepeatable(tween(900), RepeatMode.Reverse),
3326 label = "skeletonAlpha",
3328 val skeletonAlpha = if (reducedMotion) 0.5f else pulse
3329 val skeletonColor = MaterialTheme.colorScheme.surfaceVariant
3330 @Composable
3331 fun bar(widthFraction: Float) {
3332 Box(
3333 Modifier
3334 .fillMaxWidth(widthFraction)
3335 .height(14.dp)
3336 .clip(RoundedCornerShape(4.dp))
3337 .background(skeletonColor)
3338 .alpha(skeletonAlpha),
3341 Column(
3342 modifier = Modifier
3343 .align(Alignment.TopCenter)
3344 .padding(top = topPad)
3345 .padding(16.dp)
3346 .fillMaxWidth()
3347 .semantics { contentDescription = loadingLabel },
3348 verticalArrangement = Arrangement.spacedBy(14.dp),
3349 ) {
3350 repeat(4) { i ->
3351 val left = i % 2 == 0
3352 Row(
3353 modifier = Modifier.fillMaxWidth(),
3354 horizontalArrangement = if (left) Arrangement.Start else Arrangement.End,
3355 ) {
3356 Column(
3357 modifier = Modifier.fillMaxWidth(if (left) 0.7f else 0.55f),
3358 verticalArrangement = Arrangement.spacedBy(6.dp),
3359 ) {
3360 bar(1f)
3361 bar(0.8f)