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