git.iko.soy/opencode-android
on 0a26f8d help sign in
1723 lines 87 kB history raw
1package soy.iko.opencode.ui.chat
3import android.app.Activity
4import android.content.Intent
5import android.net.Uri
6import android.speech.RecognizerIntent
7import androidx.activity.compose.BackHandler
8import androidx.activity.compose.rememberLauncherForActivityResult
9import androidx.activity.result.PickVisualMediaRequest
10import androidx.activity.result.contract.ActivityResultContracts
11import androidx.compose.animation.AnimatedVisibility
12import androidx.compose.foundation.clickable
13import androidx.compose.foundation.layout.Arrangement
14import androidx.compose.foundation.layout.Box
15import androidx.compose.foundation.layout.Column
16import androidx.compose.foundation.layout.FlowRow
17import androidx.compose.foundation.layout.PaddingValues
18import androidx.compose.foundation.layout.Row
19import androidx.compose.foundation.layout.Spacer
20import androidx.compose.foundation.layout.WindowInsets
21import androidx.compose.foundation.layout.fillMaxSize
22import androidx.compose.foundation.layout.fillMaxWidth
23import androidx.compose.foundation.layout.imePadding
24import androidx.compose.foundation.layout.navigationBars
25import androidx.compose.foundation.layout.padding
26import androidx.compose.foundation.layout.size
27import androidx.compose.foundation.layout.union
28import androidx.compose.foundation.layout.windowInsetsPadding
29import androidx.compose.foundation.lazy.LazyColumn
30import androidx.compose.foundation.lazy.items
31import androidx.compose.foundation.lazy.rememberLazyListState
32import androidx.compose.material.icons.Icons
33import androidx.compose.material.icons.automirrored.filled.ArrowBack
34import androidx.compose.material.icons.automirrored.filled.Send
35import androidx.compose.material.icons.filled.Add
36import androidx.compose.material.icons.filled.AutoAwesome
37import androidx.compose.material.icons.filled.Delete
38import androidx.compose.material.icons.filled.Description
39import androidx.compose.material.icons.filled.Edit
40import androidx.compose.material.icons.filled.KeyboardArrowDown
41import androidx.compose.material.icons.filled.Mic
42import androidx.compose.material.icons.filled.MoreVert
43import androidx.compose.material.icons.filled.PhotoCamera
44import androidx.compose.material.icons.filled.PhotoLibrary
45import androidx.compose.material.icons.filled.Refresh
46import androidx.compose.material.icons.filled.Restore
47import androidx.compose.material.icons.filled.Stop
48import androidx.compose.material3.AlertDialog
49import androidx.compose.material3.Button
50import androidx.compose.material3.CircularProgressIndicator
51import androidx.compose.material3.DropdownMenu
52import androidx.compose.material3.DropdownMenuItem
53import androidx.compose.material3.ExperimentalMaterial3Api
54import androidx.compose.material3.ExtendedFloatingActionButton
55import androidx.compose.material3.FloatingActionButton
56import androidx.compose.material3.Icon
57import androidx.compose.material3.IconButton
58import androidx.compose.material3.MaterialTheme
59import androidx.compose.material3.OutlinedTextField
60import androidx.compose.material3.Scaffold
61import androidx.compose.material3.SnackbarHost
62import androidx.compose.material3.SnackbarHostState
63import androidx.compose.material3.Surface
64import androidx.compose.material3.Text
65import androidx.compose.material3.TextButton
66import androidx.compose.material3.TopAppBar
67import androidx.compose.material3.pulltorefresh.PullToRefreshBox
68import androidx.compose.runtime.Composable
69import androidx.compose.runtime.CompositionLocalProvider
70import androidx.compose.runtime.DisposableEffect
71import androidx.compose.runtime.LaunchedEffect
72import androidx.compose.runtime.derivedStateOf
73import androidx.compose.runtime.getValue
74import androidx.compose.runtime.mutableStateOf
75import androidx.compose.runtime.remember
76import androidx.compose.runtime.rememberCoroutineScope
77import androidx.compose.runtime.rememberUpdatedState
78import androidx.compose.runtime.saveable.rememberSaveable
79import androidx.compose.runtime.setValue
80import androidx.compose.runtime.snapshotFlow
81import androidx.compose.foundation.text.KeyboardActions
82import androidx.compose.foundation.text.KeyboardOptions
83import androidx.compose.ui.text.input.KeyboardCapitalization
84import androidx.compose.ui.Alignment
85import androidx.compose.ui.Modifier
86import androidx.compose.ui.focus.focusRequester
87import androidx.compose.ui.input.key.Key
88import androidx.compose.ui.input.key.KeyEventType
89import androidx.compose.ui.input.key.isCtrlPressed
90import androidx.compose.ui.input.key.isShiftPressed
91import androidx.compose.ui.input.key.key
92import androidx.compose.ui.input.key.onPreviewKeyEvent
93import androidx.compose.ui.input.key.type
94import androidx.compose.ui.platform.LocalContext
95import androidx.compose.ui.platform.LocalHapticFeedback
96import androidx.compose.ui.platform.LocalView
97import androidx.compose.ui.platform.testTag
98import androidx.compose.ui.res.stringResource
99import androidx.compose.ui.semantics.Role
100import androidx.compose.ui.semantics.contentDescription
101import androidx.compose.ui.semantics.semantics
102import androidx.compose.ui.text.input.ImeAction
103import androidx.compose.ui.text.style.TextOverflow
104import androidx.compose.ui.unit.dp
105import androidx.lifecycle.compose.collectAsStateWithLifecycle
106import androidx.lifecycle.viewmodel.compose.viewModel
107import kotlinx.coroutines.Dispatchers
108import kotlinx.coroutines.launch
109import kotlinx.coroutines.withContext
110import soy.iko.opencode.data.model.Part
111import soy.iko.opencode.data.model.MessageWithParts
112import soy.iko.opencode.data.model.ReasoningPart
113import soy.iko.opencode.data.model.TextPart
114import soy.iko.opencode.data.model.ToolCompleted
115import soy.iko.opencode.data.model.ToolError
116import soy.iko.opencode.data.model.ToolPart
117import soy.iko.opencode.data.model.ToolRunning
118import soy.iko.opencode.data.network.EventStreamClient
119import soy.iko.opencode.data.network.NetworkConfig
120import soy.iko.opencode.di.AppContainer
121import soy.iko.opencode.R
122import soy.iko.opencode.ui.components.CommandPalette
123import soy.iko.opencode.ui.components.ConnectionBanner
124import soy.iko.opencode.ui.components.PaletteAction
125import soy.iko.opencode.ui.components.copyToClipboard
126import soy.iko.opencode.ui.components.LocalRelativeTimeTick
127import soy.iko.opencode.ui.components.rememberRelativeTimeTick
128import soy.iko.opencode.ui.components.showToast
129import soy.iko.opencode.ui.components.toImageContext
130import soy.iko.opencode.ui.vmFactory
131import soy.iko.opencode.util.runCatchingCancellable
133@OptIn(ExperimentalMaterial3Api::class)
134@Composable
135fun ChatScreen(
136 container: AppContainer,
137 sessionId: String,
138 onBack: () -> Unit,
139 onOpenFile: ((String) -> Unit)? = null,
140 onOpenSession: ((String) -> Unit)? = null,
141) {
142 val vm: ChatViewModel = viewModel(key = sessionId, factory = vmFactory { ChatViewModel(container, sessionId) })
143 val hasMessages by vm.hasMessages.collectAsStateWithLifecycle()
144 val running by vm.running.collectAsStateWithLifecycle()
145 val aborting by vm.aborting.collectAsStateWithLifecycle()
146 val loading by vm.loading.collectAsStateWithLifecycle()
147 val loadError by vm.loadError.collectAsStateWithLifecycle()
148 val refreshing by vm.refreshing.collectAsStateWithLifecycle()
149 val models by vm.models.collectAsStateWithLifecycle()
150 val modelsLoading by vm.modelsLoading.collectAsStateWithLifecycle()
151 val modelsError by vm.modelsError.collectAsStateWithLifecycle()
152 val selectedModel by vm.selectedModel.collectAsStateWithLifecycle()
153 val connectionState by vm.connectionState.collectAsStateWithLifecycle()
154 val pendingPermission by vm.pendingPermission.collectAsStateWithLifecycle()
155 val agents by vm.agents.collectAsStateWithLifecycle()
156 val agentsLoading by vm.agentsLoading.collectAsStateWithLifecycle()
157 val agentsError by vm.agentsError.collectAsStateWithLifecycle()
158 val selectedAgent by vm.selectedAgent.collectAsStateWithLifecycle()
159 val commands by vm.commands.collectAsStateWithLifecycle()
160 val commandsLoading by vm.commandsLoading.collectAsStateWithLifecycle()
161 val commandsError by vm.commandsError.collectAsStateWithLifecycle()
162 val sessionTitle by vm.sessionTitle.collectAsStateWithLifecycle()
163 val sessionDeleted by vm.sessionDeleted.collectAsStateWithLifecycle()
164 val draft by vm.draft.collectAsStateWithLifecycle()
165 val attachments by vm.attachments.collectAsStateWithLifecycle()
166 val reverted by vm.reverted.collectAsStateWithLifecycle()
167 val shareUrl by vm.shareUrl.collectAsStateWithLifecycle()
168 val reconnecting by vm.reconnecting.collectAsStateWithLifecycle()
169 val sendOnEnter by container.settingsStore.sendOnEnter.collectAsStateWithLifecycle(initialValue = true)
170 val isOnline by container.isOnline.collectAsStateWithLifecycle()
171 val haptics = LocalHapticFeedback.current
172 val scope = rememberCoroutineScope()
173 val shareContext = LocalContext.current
174 val defaultShareSubject = stringResource(R.string.share_subject)
175 val sessionLabel = stringResource(R.string.session)
176 val defaultModelLabel = stringResource(R.string.default_model)
177 val subtitle = remember(selectedModel?.modelLabel, selectedAgent, defaultModelLabel) {
178 buildString {
179 append(selectedModel?.modelLabel ?: defaultModelLabel)
180 selectedAgent?.let { append(" · $it") }
181 }
182 }
184 // Connection profile (with auth) for rendering inline image attachments.
185 // Collect as state so a server switch recomposes the image context.
186 val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
187 val imageContext = remember(activeConnection?.profile) {
188 activeConnection?.profile?.toImageContext()
189 }
191 // Mark this session as read while the user is viewing it; clear on leave so new
192 // background activity can badge it again.
193 DisposableEffect(sessionId) {
194 container.setCurrentSession(sessionId)
195 onDispose { if (container.currentSession.value == sessionId) container.setCurrentSession(null) }
196 }
198 // Navigate away when the session is deleted via SSE so the user isn't left
199 // on a zombie screen showing a conversation that no longer exists. Surface a
200 // toast (not a snackbar) because the screen is about to be popped — a snackbar
201 // would be torn down before the user could read it, while a toast survives.
202 // Covers both SSE-driven deletion (deleted on another device) and a user-
203 // initiated delete from the chat overflow menu (deleteSession in ChatViewModel
204 // sets the same flag on success).
205 val deletedContext = LocalContext.current
206 LaunchedEffect(sessionDeleted) {
207 if (sessionDeleted) {
208 showToast(deletedContext, deletedContext.getString(R.string.session_deleted_chat))
209 onBack()
210 }
211 }
213 val snackbar = remember { SnackbarHostState() }
214 val retryLabel = stringResource(R.string.retry)
215 val inputFocusRequester = remember { androidx.compose.ui.focus.FocusRequester() }
216 var showModelPicker by rememberSaveable { mutableStateOf(false) }
217 var showAgentPicker by rememberSaveable { mutableStateOf(false) }
218 var showCommandPicker by rememberSaveable { mutableStateOf(false) }
219 var showExitConfirm by rememberSaveable { mutableStateOf(false) }
220 var showStopConfirm by rememberSaveable { mutableStateOf(false) }
221 var showOverflowMenu by rememberSaveable { mutableStateOf(false) }
222 var showRenameDialog by rememberSaveable { mutableStateOf(false) }
223 var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
224 var showShellDialog by rememberSaveable { mutableStateOf(false) }
225 var showPalette by rememberSaveable { mutableStateOf(false) }
227 // Command-palette entries (keyboard-first: Ctrl+K). Labels resolved here so the remembered
228 // action list doesn't need a Composable context. Actions map to the same handlers the
229 // overflow menu drives, so the palette is a keyboard shortcut to them, not a parallel path.
230 val paletteModelLabel = stringResource(R.string.palette_switch_model)
231 val paletteAgentLabel = stringResource(R.string.palette_choose_agent)
232 val paletteCommandLabel = stringResource(R.string.palette_run_command)
233 val paletteSummarizeLabel = stringResource(R.string.palette_summarize)
234 val paletteRenameLabel = stringResource(R.string.rename_session_chat)
235 val paletteShellLabel = stringResource(R.string.run_shell_command)
236 val paletteActions = remember(
237 paletteModelLabel, paletteAgentLabel, paletteCommandLabel,
238 paletteSummarizeLabel, paletteRenameLabel, paletteShellLabel, hasMessages, running,
239 ) {
240 buildList {
241 add(PaletteAction("model", paletteModelLabel) { showModelPicker = true })
242 add(PaletteAction("agent", paletteAgentLabel) { showAgentPicker = true })
243 add(PaletteAction("command", paletteCommandLabel) { showCommandPicker = true })
244 if (hasMessages && !running) add(PaletteAction("summarize", paletteSummarizeLabel) { vm.summarize() })
245 if (!running) add(PaletteAction("shell", paletteShellLabel) { showShellDialog = true })
246 add(PaletteAction("rename", paletteRenameLabel) { showRenameDialog = true })
247 }
248 }
250 // Keep the screen awake while the agent is working in *this* session. The foreground
251 // service that holds process priority during a run is managed app-wide off
252 // container.anyRunActive (see OpencodeApp) so it survives navigating away from the chat
253 // mid-run — otherwise leaving the screen would drop priority and Doze could choke the
254 // SSE stream before the completion notification fires.
255 val currentView = LocalView.current
256 val appContext = LocalContext.current.applicationContext
257 DisposableEffect(running) {
258 currentView.keepScreenOn = running
259 onDispose { currentView.keepScreenOn = false }
260 }
262 LaunchedEffect(Unit) {
263 vm.errorEvents.collect { event ->
264 // Only a failed *send* is retryable; attaching Retry to any other error (e.g. a
265 // message-load failure) would silently re-submit the last prompt on tap.
266 val result = if (event.retryable) {
267 snackbar.showSnackbar(message = event.message, actionLabel = retryLabel)
268 } else {
269 snackbar.showSnackbar(event.message)
270 }
271 if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) vm.retryFailed()
272 }
273 }
275 // --- Attachments, voice, and share-link plumbing ---
276 val attachTooLargeMsg = stringResource(R.string.attachment_too_large)
277 val attachFailedMsg = stringResource(R.string.attachment_failed)
278 val attachLimitMsg = stringResource(R.string.attachment_limit)
279 val noCameraMsg = stringResource(R.string.no_camera_app)
280 val noVoiceMsg = stringResource(R.string.no_voice_app)
281 val voicePrompt = stringResource(R.string.voice_prompt)
282 val linkCopiedMsg = stringResource(R.string.link_copied)
284 // Convert each picked Uri to a base64 attachment off the main thread, honoring the
285 // per-prompt count cap and surfacing per-file errors without aborting the batch.
286 fun stageUris(uris: List<Uri>) {
287 if (uris.isEmpty()) return
288 scope.launch {
289 for (uri in uris) {
290 if (vm.attachments.value.size >= NetworkConfig.maxAttachments) {
291 snackbar.showSnackbar(attachLimitMsg)
292 break
293 }
294 when (val result = uri.toAttachmentResult(appContext)) {
295 is AttachmentResult.Ok -> vm.addAttachment(result.attachment)
296 AttachmentResult.TooLarge -> snackbar.showSnackbar(attachTooLargeMsg)
297 AttachmentResult.Failed -> snackbar.showSnackbar(attachFailedMsg)
298 }
299 }
300 }
301 }
303 val photoPicker = rememberLauncherForActivityResult(
304 ActivityResultContracts.PickMultipleVisualMedia(NetworkConfig.maxAttachments),
305 ) { uris -> stageUris(uris) }
307 val filePicker = rememberLauncherForActivityResult(
308 ActivityResultContracts.GetMultipleContents(),
309 ) { uris -> stageUris(uris) }
311 // Uri survives a config change mid-capture so the TakePicture result still resolves.
312 var pendingCaptureUri by rememberSaveable { mutableStateOf<Uri?>(null) }
313 val cameraLauncher = rememberLauncherForActivityResult(
314 ActivityResultContracts.TakePicture(),
315 ) { success ->
316 val uri = pendingCaptureUri
317 pendingCaptureUri = null
318 if (success && uri != null) stageUris(listOf(uri))
319 }
321 val voiceLauncher = rememberLauncherForActivityResult(
322 ActivityResultContracts.StartActivityForResult(),
323 ) { result ->
324 if (result.resultCode == Activity.RESULT_OK) {
325 val spoken = result.data
326 ?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)
327 ?.firstOrNull()
328 ?.takeIf { it.isNotBlank() }
329 if (spoken != null) {
330 // Append to the existing draft rather than replacing, so dictation adds to
331 // whatever the user already typed.
332 val current = vm.draft.value
333 val combined = if (current.isBlank()) spoken else "$current $spoken"
334 vm.updateDraft(combined.take(NetworkConfig.maxDraftLengthChars))
335 }
336 }
337 }
339 fun launchCamera() {
340 val uri = newCameraCaptureUri(appContext)
341 if (uri == null) {
342 scope.launch { snackbar.showSnackbar(attachFailedMsg) }
343 return
344 }
345 pendingCaptureUri = uri
346 runCatching { cameraLauncher.launch(uri) }
347 .onFailure { pendingCaptureUri = null; scope.launch { snackbar.showSnackbar(noCameraMsg) } }
348 }
350 fun launchVoice() {
351 val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
352 putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
353 putExtra(RecognizerIntent.EXTRA_PROMPT, voicePrompt)
354 }
355 runCatching { voiceLauncher.launch(intent) }
356 .onFailure { scope.launch { snackbar.showSnackbar(noVoiceMsg) } }
357 }
359 // A freshly-created share link is copied to the clipboard so the user can paste it right away.
360 LaunchedEffect(Unit) {
361 vm.shareLinkEvents.collect { url ->
362 copyToClipboard(shareContext, "share", url)
363 snackbar.showSnackbar(linkCopiedMsg)
364 }
365 }
367 // Navigate to a session freshly branched from a message (branchFrom), once it's created.
368 // Keyed on Unit (like the sibling one-shot collectors above): keying on the onOpenSession
369 // lambda would cancel/restart this collector whenever the lambda's identity changes, risking
370 // a dropped branch-navigation event in the gap.
371 val branchedMsg = stringResource(R.string.branch_created)
372 LaunchedEffect(Unit) {
373 vm.branchEvents.collect { newId ->
374 showToast(shareContext, branchedMsg)
375 onOpenSession?.invoke(newId)
376 }
377 }
379 // Stage images shared into the app (via the system share sheet) as attachments.
380 // Keyed on pendingSharedMedia (not Unit) so the effect re-fires when new media arrives
381 // while this ChatScreen is already composed — which happens in two-pane mode, where the
382 // detail ChatScreen is persistent (keyed on sessionId) and a LaunchedEffect(Unit) would
383 // only fire on first composition. consumePendingSharedMedia() atomically drains the
384 // pending list (compareAndSet to empty), so re-firing on the now-empty value is a no-op.
385 val pendingSharedMedia by container.pendingSharedMedia.collectAsStateWithLifecycle()
386 LaunchedEffect(pendingSharedMedia) {
387 val shared = container.consumePendingSharedMedia()
388 if (shared.isNotEmpty()) stageUris(shared.map { Uri.parse(it) })
389 }
391 fun doSend() {
392 if (vm.send(draft)) {
393 // TextHandleMove (a light tick) is semantically right for a send commit;
394 // LongPress is reserved for long-press/swipe gestures.
395 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
396 }
397 }
399 BackHandler(enabled = running && !showModelPicker && !showAgentPicker && !showCommandPicker) { showExitConfirm = true }
401 Scaffold(
402 // Hardware-keyboard shortcuts (tablets / DeX / Chromebooks): Ctrl+K opens the command
403 // palette; Escape closes it, or stops a run. onPreviewKeyEvent on the Scaffold root sees
404 // events before the focused text field, but only Ctrl+K / Escape are consumed so normal
405 // typing (and the composer's own Enter-to-send handling) is untouched.
406 modifier = Modifier.onPreviewKeyEvent { ev ->
407 if (ev.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
408 when {
409 ev.isCtrlPressed && ev.key == Key.K -> { showPalette = true; true }
410 ev.key == Key.Escape && showPalette -> { showPalette = false; true }
411 ev.key == Key.Escape && running && !showStopConfirm && !showExitConfirm -> {
412 showStopConfirm = true; true
413 }
414 else -> false
415 }
416 },
417 topBar = {
418 TopAppBar(
419 title = {
420 val changeLabel = stringResource(R.string.change_model_agent)
421 Row(
422 modifier = Modifier
423 .clickable(enabled = models.isNotEmpty(), role = Role.Button) { showModelPicker = true }
424 .semantics(mergeDescendants = true) { contentDescription = changeLabel },
425 verticalAlignment = Alignment.CenterVertically,
426 ) {
427 Column(modifier = Modifier.weight(1f, fill = false)) {
428 // maxLines=1 + Ellipsis so a long session title can't wrap and
429 // push the top bar to multiple lines, shifting the whole layout.
430 Text(
431 sessionTitle ?: sessionLabel,
432 maxLines = 1,
433 overflow = TextOverflow.Ellipsis,
434 )
435 Text(
436 subtitle,
437 style = MaterialTheme.typography.labelSmall,
438 color = MaterialTheme.colorScheme.onSurfaceVariant,
439 maxLines = 1,
440 overflow = TextOverflow.Ellipsis,
441 )
442 }
443 // Visual affordance that the title opens the model picker;
444 // without it the tappable title is indistinguishable from plain
445 // text and the picker is effectively undiscoverable.
446 if (models.isNotEmpty()) {
447 Icon(
448 Icons.Filled.KeyboardArrowDown,
449 contentDescription = null,
450 tint = MaterialTheme.colorScheme.onSurfaceVariant,
451 modifier = Modifier.size(20.dp),
452 )
453 }
454 }
455 },
456 navigationIcon = {
457 IconButton(onClick = {
458 if (running) showExitConfirm = true else onBack()
459 }) {
460 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
461 }
462 },
463 actions = {
464 // At-a-glance run status in the top bar. When the user is scrolled
465 // up (jump-to-latest FAB visible), the input-bar Stop button and the
466 // trailing "working…" row are off-screen, so a top-bar spinner is the
467 // only signal the agent is still working.
468 if (running) {
469 val workingLabel = stringResource(R.string.working)
470 CircularProgressIndicator(
471 Modifier.size(18.dp).semantics { contentDescription = workingLabel },
472 strokeWidth = 2.dp,
473 )
474 }
475 // Manual refresh: forces an SSE reconnect, which re-seeds messages
476 // from REST. A recovery path when the stream silently drops and the
477 // auto-reconnect re-seed is slow or fails. Shows a brief spinner as
478 // immediate tap feedback — the SSE reconnect may not visibly change
479 // the connection state when already Connected, so without it the tap
480 // appears to do nothing.
481 val refreshLabel = stringResource(R.string.refresh)
482 IconButton(
483 onClick = { vm.refreshMessages() },
484 enabled = activeConnection != null && !refreshing,
485 ) {
486 if (refreshing) {
487 CircularProgressIndicator(
488 Modifier.size(18.dp).semantics { contentDescription = refreshLabel },
489 strokeWidth = 2.dp,
490 )
491 } else {
492 Icon(Icons.Filled.Refresh, contentDescription = refreshLabel)
493 }
494 }
495 // Overflow menu consolidates the less-frequent actions (share,
496 // commands, agent, model, rename, delete) so the top bar stays
497 // scannable on narrow phones — previously 5 icons crowded the bar
498 // alongside the back button and tappable title. Catalog-loading
499 // spinners stay inline (next to the overflow) so a load state is
500 // visible without opening the menu.
501 val moreLabel = stringResource(R.string.more)
502 val loadingLabel = stringResource(R.string.loading)
503 val shareLabel = stringResource(R.string.share_conversation)
504 val commandsLabel = stringResource(R.string.commands)
505 val agentLabel = stringResource(R.string.choose_agent)
506 val modelLabel = stringResource(R.string.choose_model)
507 val renameLabel = stringResource(R.string.rename_session_chat)
508 val deleteLabel = stringResource(R.string.delete_session_chat)
509 val createShareLabel = stringResource(R.string.create_share_link)
510 val copyShareLabel = stringResource(R.string.copy_share_link)
511 val stopShareLabel = stringResource(R.string.stop_sharing)
512 val summarizeLabel = stringResource(R.string.summarize_conversation)
513 val initLabel = stringResource(R.string.generate_agents_md)
514 val shellLabel = stringResource(R.string.run_shell_command)
515 Box {
516 IconButton(onClick = { showOverflowMenu = true }) {
517 Icon(Icons.Filled.MoreVert, contentDescription = moreLabel)
518 }
519 DropdownMenu(
520 expanded = showOverflowMenu,
521 onDismissRequest = { showOverflowMenu = false },
522 ) {
523 DropdownMenuItem(
524 text = { Text(shareLabel) },
525 enabled = hasMessages,
526 onClick = {
527 showOverflowMenu = false
528 scope.launch {
529 val md = withContext(Dispatchers.Default) {
530 buildConversationMarkdown(vm.messages.value, sessionTitle)
531 }
532 val send = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
533 type = "text/markdown"
534 putExtra(android.content.Intent.EXTRA_SUBJECT, sessionTitle ?: defaultShareSubject)
535 putExtra(android.content.Intent.EXTRA_TEXT, md)
536 }
537 runCatchingCancellable { shareContext.startActivity(android.content.Intent.createChooser(send, shareLabel)) }
538 .onFailure { showToast(shareContext, shareContext.getString(R.string.no_share_app)) }
539 }
540 },
541 )
542 DropdownMenuItem(
543 text = { Text(commandsLabel) },
544 enabled = !commandsLoading || commands.isNotEmpty(),
545 onClick = { showOverflowMenu = false; showCommandPicker = true },
546 )
547 DropdownMenuItem(
548 text = { Text(agentLabel) },
549 enabled = !agentsLoading || agents.isNotEmpty(),
550 onClick = { showOverflowMenu = false; showAgentPicker = true },
551 )
552 DropdownMenuItem(
553 text = { Text(modelLabel) },
554 enabled = !modelsLoading || models.isNotEmpty(),
555 onClick = { showOverflowMenu = false; showModelPicker = true },
556 )
557 androidx.compose.material3.HorizontalDivider()
558 // Session sharing: create a public link, copy an existing one, or revoke it.
559 if (shareUrl == null) {
560 DropdownMenuItem(
561 text = { Text(createShareLabel) },
562 enabled = hasMessages,
563 onClick = { showOverflowMenu = false; vm.shareSession() },
564 )
565 } else {
566 DropdownMenuItem(
567 text = { Text(copyShareLabel) },
568 onClick = {
569 showOverflowMenu = false
570 shareUrl?.let {
571 copyToClipboard(shareContext, "share", it)
572 scope.launch { snackbar.showSnackbar(linkCopiedMsg) }
573 }
574 },
575 )
576 DropdownMenuItem(
577 text = { Text(stopShareLabel) },
578 onClick = { showOverflowMenu = false; vm.unshareSession() },
579 )
580 }
581 DropdownMenuItem(
582 text = { Text(summarizeLabel) },
583 enabled = hasMessages && !running,
584 onClick = { showOverflowMenu = false; vm.summarize() },
585 )
586 DropdownMenuItem(
587 text = { Text(initLabel) },
588 enabled = !running,
589 onClick = { showOverflowMenu = false; vm.initProject() },
590 )
591 DropdownMenuItem(
592 text = { Text(shellLabel) },
593 enabled = !running,
594 onClick = { showOverflowMenu = false; showShellDialog = true },
595 )
596 androidx.compose.material3.HorizontalDivider()
597 DropdownMenuItem(
598 text = { Text(renameLabel) },
599 onClick = { showOverflowMenu = false; showRenameDialog = true },
600 )
601 DropdownMenuItem(
602 text = { Text(deleteLabel, color = MaterialTheme.colorScheme.error) },
603 onClick = { showOverflowMenu = false; showDeleteDialog = true },
604 )
605 }
606 // While a catalog is initially loading (empty list + loading flag),
607 // show a tiny inline spinner beside the overflow so the user can see
608 // a load is in progress without opening the menu. Once loaded the
609 // spinner disappears; the menu items are always enabled (tapping a
610 // picker with no items shows the "no models/agents/commands" state).
611 val catalogLoading = (commandsLoading && commands.isEmpty()) ||
612 (agentsLoading && agents.isEmpty()) ||
613 (modelsLoading && models.isEmpty())
614 if (catalogLoading) {
615 CircularProgressIndicator(
616 Modifier
617 .align(Alignment.TopEnd)
618 .size(10.dp)
619 .semantics { contentDescription = loadingLabel },
620 strokeWidth = 1.dp,
621 )
622 }
623 }
624 },
625 )
626 },
627 snackbarHost = { SnackbarHost(snackbar) },
628 bottomBar = {
629 val queuedFollowUp by vm.queuedFollowUp.collectAsStateWithLifecycle()
630 val todoPlan by vm.todoPlan.collectAsStateWithLifecycle()
631 val queuedOffline by vm.outbox.collectAsStateWithLifecycle()
632 val outboxSending by container.outboxSending.collectAsStateWithLifecycle()
633 Column {
634 // Pinned above the composer so the agent's plan and progress stay visible
635 // while scrolled up mid-run; renders nothing when there's no plan.
636 TodoPlanBar(todoPlan)
637 AnimatedVisibility(visible = reverted) {
638 RevertBanner(onUndo = { vm.unrevert() })
639 }
640 AnimatedVisibility(visible = queuedOffline.isNotEmpty()) {
641 OutboxBanner(
642 count = queuedOffline.size,
643 sending = outboxSending,
644 onFlush = { vm.flushQueued() },
645 onDiscard = { vm.discardAllQueued() },
646 )
647 }
648 ChatInputBar(
649 value = draft,
650 onValueChange = vm::updateDraft,
651 running = running,
652 aborting = aborting,
653 enabled = activeConnection != null,
654 sendOnEnter = sendOnEnter,
655 onSend = ::doSend,
656 onAbort = { showStopConfirm = true },
657 queuedFollowUp = queuedFollowUp,
658 onQueueFollowUp = vm::queueFollowUp,
659 onCancelQueue = { vm.queueFollowUp("") },
660 focusRequester = inputFocusRequester,
661 attachments = attachments,
662 onRemoveAttachment = vm::removeAttachment,
663 onPickPhoto = {
664 photoPicker.launch(
665 PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly),
666 )
667 },
668 onPickFile = { filePicker.launch("*/*") },
669 onCamera = { launchCamera() },
670 onVoice = { launchVoice() },
671 )
672 }
673 },
674 ) { padding ->
675 // Collect messages inside the content lambda so streaming token updates
676 // recompose only this subtree, not the top bar / input bar / 20+ other
677 // state reads in the ChatScreen body.
678 val messages by vm.messages.collectAsStateWithLifecycle()
679 val listState = rememberLazyListState()
680 val contentScope = rememberCoroutineScope()
681 val timeTick = rememberRelativeTimeTick()
682 // Group messages by day and interleave date separators so a long conversation
683 // has visual "Today"/"Yesterday"/date breaks. Computed once per messages
684 // emission (memoized) so a scroll-induced recomposition doesn't re-scan. Lives
685 // outside the PullToRefreshBox so the auto-scroll effects below can reference
686 // the same list the LazyColumn renders (separators shift the indices vs. the
687 // raw messages list, so scroll targets must be in listItems space).
688 val todayLabel = stringResource(R.string.today)
689 val yesterdayLabel = stringResource(R.string.yesterday)
690 val listItems = remember(messages, todayLabel, yesterdayLabel) {
691 buildMessageListItems(messages, todayLabel, yesterdayLabel)
692 }
693 // listItems is a plain (non-snapshot) local, rebuilt each recomposition. The
694 // derivedStateOf and snapshotFlow lambdas below are created once (keyless
695 // remember / LaunchedEffect(Unit)); reading `listItems` directly inside them
696 // would capture the first composition's list — which is empty, since `messages`
697 // starts empty — freezing it forever. That pinned isPinnedToBottom at true and
698 // AutoScrollSignal.size at 0, breaking streaming auto-scroll and the
699 // jump-to-latest FAB. rememberUpdatedState hands those lambdas a stable State
700 // whose value tracks the latest list.
701 val currentListItems by rememberUpdatedState(listItems)
703 val isPinnedToBottom by remember {
704 derivedStateOf {
705 val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
706 // When a run is active, the LazyColumn has one trailing "__typing"
707 // item after the last list item. Account for it so "pinned to bottom"
708 // includes the working indicator — otherwise the pin check targets
709 // listItems.lastIndex, the typing row sits just below the viewport,
710 // and the user never sees the progress indicator even when pinned.
711 val items = currentListItems
712 val effectiveLast = if (running) items.size else items.lastIndex
713 lastVisible >= effectiveLast || items.isEmpty()
714 }
715 }
717 // Jump to the newest message once, when the conversation first loads. Guarded by a
718 // saveable one-shot flag so it doesn't re-fire on recomposition after a config change
719 // (rotation) — rememberLazyListState restores the user's scroll offset, and an
720 // unconditional scrollToItem here would clobber it, snapping them back to the bottom.
721 var didInitialScroll by rememberSaveable { mutableStateOf(false) }
722 LaunchedEffect(listItems.isNotEmpty()) {
723 if (listItems.isNotEmpty() && !didInitialScroll) {
724 // When a run is active the LazyColumn has a trailing "__typing" row at
725 // listItems.size; target it (not listItems.lastIndex) so deep-linking into a
726 // running session lands truly pinned to bottom and streaming auto-scroll engages.
727 listState.scrollToItem(if (running) listItems.size else listItems.lastIndex)
728 didInitialScroll = true
729 }
730 }
732 // When a run starts, the LazyColumn gains a trailing "__typing" row, so
733 // effectiveLast jumps from listItems.lastIndex to listItems.size and the
734 // isPinnedToBottom check flips to false the instant you send — freezing
735 // auto-scroll. If the user was pinned to the last item just before the run
736 // began, bring the typing row into view so the pin (and streaming follow) is
737 // preserved. Guard on the raw last-visible index (isPinnedToBottom has already
738 // recomputed to false by now) so we don't scroll when the user had scrolled up.
739 LaunchedEffect(running) {
740 if (running && listItems.isNotEmpty()) {
741 val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
742 if (lastVisible >= listItems.lastIndex) {
743 runCatchingCancellable { listState.animateScrollToItem(listItems.size) }
744 }
745 }
746 }
748 LaunchedEffect(Unit) {
749 snapshotFlow {
750 // Track list size + the streaming length of the last part so we auto-scroll
751 // as content arrives, plus the pinned flag. A small data class with primitive
752 // fields avoids the per-frame boxing that Triple<Int,Int,Boolean> pays
753 // (snapshotFlow re-evaluates this lambda every snapshot), and reading only the
754 // last part's length is O(1) vs. a sumOf over every part. Covering reasoning
755 // and tool output — not just TextPart — keeps a pinned view following a long
756 // reasoning/tool block while it streams (previously it stalled at 0).
757 val lastLen = streamingContentLength(messages.lastOrNull()?.parts?.lastOrNull())
758 AutoScrollSignal(currentListItems.size, lastLen, isPinnedToBottom)
759 }.collect { signal ->
760 if (signal.size > 0 && signal.pinned) {
761 // Scroll to the effective last index, including the trailing
762 // "__typing" row when a run is active so the working indicator
763 // is brought into view (not just the last message).
764 val items = currentListItems
765 val target = if (running) items.size else items.lastIndex
766 listState.scrollToItem(target)
767 }
768 }
769 }
771 CompositionLocalProvider(LocalRelativeTimeTick provides timeTick) {
772 // The bottomBar (ChatInputBar) already has imePadding, and the Scaffold's
773 // content padding accounts for the bottomBar's raised height, so the message
774 // list is already above the keyboard. Adding imePadding here would double-apply
775 // the IME inset and push messages too far up.
776 Box(modifier = Modifier.fillMaxSize().padding(padding)) {
777 if (activeConnection == null) {
778 Column(
779 modifier = Modifier.align(Alignment.Center).padding(24.dp),
780 horizontalAlignment = Alignment.CenterHorizontally,
781 ) {
782 Text(
783 stringResource(R.string.not_connected),
784 color = MaterialTheme.colorScheme.error,
785 )
786 Spacer(Modifier.size(12.dp))
787 Button(onClick = { vm.reconnect() }, enabled = !reconnecting) {
788 if (reconnecting) {
789 CircularProgressIndicator(
790 Modifier.size(20.dp),
791 color = MaterialTheme.colorScheme.onPrimary,
792 strokeWidth = 2.dp,
793 )
794 } else {
795 Text(stringResource(R.string.reconnect))
796 }
797 }
798 }
799 } else {
800 ConnectionBanner(
801 state = connectionState,
802 modifier = Modifier.align(Alignment.TopCenter),
803 isOnline = isOnline,
804 // On a hard failure, retry by forcing an SSE reconnect (which
805 // re-seeds from REST). refreshMessages is the right recovery path
806 // when the connection is present but the stream died; reconnect()
807 // is the path when the whole connection is gone (handled by the
808 // separate "Not connected" state below).
809 onRetry = { vm.refreshMessages() },
810 )
811 if (loading && messages.isEmpty()) {
812 val loadingLabel = stringResource(R.string.loading)
813 CircularProgressIndicator(
814 Modifier
815 .align(Alignment.Center)
816 .semantics { contentDescription = loadingLabel },
817 )
818 } else if (loadError && messages.isEmpty()) {
819 // A failed load with nothing to show. Distinct from the empty
820 // conversation state below — the conversation isn't empty, it
821 // failed to load, and offering "Start a conversation" here is
822 // misleading. Offer a Retry instead, mirroring SessionListScreen.
823 Column(
824 modifier = Modifier.align(Alignment.Center).padding(24.dp),
825 horizontalAlignment = Alignment.CenterHorizontally,
826 ) {
827 Text(
828 stringResource(R.string.load_messages_failed),
829 color = MaterialTheme.colorScheme.error,
830 )
831 Spacer(Modifier.size(12.dp))
832 Button(onClick = { vm.refreshMessages() }) {
833 Text(stringResource(R.string.retry))
834 }
835 }
836 } else if (messages.isEmpty() && running) {
837 // A run just started but no parts have arrived yet — the empty
838 // list with only the trailing "working…" row looks broken, so
839 // surface a clear starting state until the first part streams in.
840 val workingText = stringResource(R.string.working)
841 Column(
842 modifier = Modifier.align(Alignment.Center).padding(24.dp),
843 horizontalAlignment = Alignment.CenterHorizontally,
844 ) {
845 CircularProgressIndicator(Modifier.size(28.dp), strokeWidth = 3.dp)
846 Spacer(Modifier.size(12.dp))
847 Text(workingText, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
848 }
849 } else if (messages.isEmpty()) {
850 EmptyConversation(
851 onSuggestion = {
852 vm.updateDraft(it)
853 // Focus the input so the user can edit the suggestion before
854 // sending, instead of having to manually tap the field.
855 runCatching { inputFocusRequester.requestFocus() }
856 },
857 modifier = Modifier.align(Alignment.Center),
858 )
859 } else {
860 // Pull-to-refresh wraps the message list so the user can force an SSE
861 // reconnect + re-seed with the same gesture they use on the session and
862 // file lists, instead of hunting for the top-bar refresh icon. The empty
863 // / loading / error states above stay outside the PTR box since there's
864 // no list content to pull against.
865 PullToRefreshBox(
866 isRefreshing = refreshing,
867 onRefresh = { vm.refreshMessages() },
868 modifier = Modifier.fillMaxSize(),
869 ) {
870 val lastMessageId = messages.lastOrNull()?.info?.id
871 // Text-to-speech for reading assistant replies aloud; shuts down when the
872 // message list leaves composition.
873 val tts = rememberTtsController()
874 val speakingMessageId by tts.speakingId
875 LazyColumn(
876 state = listState,
877 modifier = Modifier.fillMaxSize(),
878 contentPadding = PaddingValues(16.dp),
879 verticalArrangement = Arrangement.spacedBy(16.dp),
880 ) {
881 items(
882 items = listItems,
883 key = { it.key },
884 contentType = { it.contentType },
885 ) { item ->
886 when (item) {
887 is MessageListItem.Separator -> DateSeparator(item.label)
888 is MessageListItem.Message -> {
889 val message = item.message
890 // Only the last (streaming) message needs isRunning — it drives the
891 // reasoning-block spinner. Passing the live flag to every bubble
892 // makes all visible messages recompose whenever a run starts or stops.
893 val modelLabel = remember(message.info, models) {
894 (message.info as? soy.iko.opencode.data.model.AssistantMessage)
895 ?.let { resolveModelLabel(it, models) }
896 }
897 MessageBubble(
898 message,
899 isRunning = running && message.info.id == lastMessageId,
900 imageContext = imageContext,
901 modelLabel = modelLabel,
902 onOpenFile = onOpenFile,
903 onRevert = { vm.revertTo(message.info.id) },
904 onEdit = { text -> vm.editMessage(message.info.id, text) },
905 onSpeak = { text -> tts.toggle(message.info.id, text) },
906 isSpeaking = message.info.id == speakingMessageId,
907 onQuote = { text ->
908 vm.quoteReply(text)
909 runCatching { inputFocusRequester.requestFocus() }
910 },
911 onBranch = { text -> vm.branchFrom(text) },
912 )
913 }
914 }
915 }
916 if (running) {
917 item(key = "__typing") {
918 val workingText = stringResource(R.string.working)
919 Row(
920 verticalAlignment = Alignment.CenterVertically,
921 modifier = Modifier.semantics {
922 contentDescription = workingText
923 },
924 ) {
925 CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
926 Text(workingText, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp))
927 }
928 }
929 }
930 }
931 }
932 // Jump-to-latest affordance when the user has scrolled away during a stream.
933 AnimatedVisibility(
934 visible = !isPinnedToBottom && listItems.isNotEmpty(),
935 modifier = Modifier.align(Alignment.BottomEnd),
936 ) {
937 ExtendedFloatingActionButton(
938 onClick = {
939 if (listItems.isNotEmpty()) {
940 // Scroll to the effective last index, including the
941 // trailing "__typing" row when a run is active.
942 val target = if (running) listItems.size else listItems.lastIndex
943 contentScope.launch { runCatchingCancellable { listState.animateScrollToItem(target) } }
944 }
945 },
946 icon = { Icon(Icons.Filled.KeyboardArrowDown, contentDescription = stringResource(R.string.latest)) },
947 text = { Text(stringResource(R.string.latest)) },
948 modifier = Modifier.padding(end = 16.dp, bottom = 16.dp),
949 )
950 }
951 }
952 }
953 }
954 }
955 }
957 if (showExitConfirm) {
958 AlertDialog(
959 onDismissRequest = { showExitConfirm = false },
960 title = { Text(stringResource(R.string.stop_and_exit_title)) },
961 text = { Text(stringResource(R.string.stop_and_exit_text)) },
962 confirmButton = {
963 TextButton(onClick = {
964 showExitConfirm = false
965 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
966 vm.abort()
967 onBack()
968 }) { Text(stringResource(R.string.stop_and_exit)) }
969 },
970 dismissButton = {
971 TextButton(onClick = { showExitConfirm = false }) { Text(stringResource(R.string.stay)) }
972 },
973 )
974 }
976 if (showStopConfirm) {
977 AlertDialog(
978 onDismissRequest = { showStopConfirm = false },
979 title = { Text(stringResource(R.string.stop_run_title)) },
980 text = { Text(stringResource(R.string.stop_run_text)) },
981 confirmButton = {
982 TextButton(onClick = {
983 showStopConfirm = false
984 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
985 vm.abort()
986 }) { Text(stringResource(R.string.stop), color = MaterialTheme.colorScheme.error) }
987 },
988 dismissButton = {
989 TextButton(onClick = { showStopConfirm = false }) { Text(stringResource(R.string.cancel)) }
990 },
991 )
992 }
994 if (showRenameDialog) {
995 RenameSessionChatDialog(
996 initialTitle = sessionTitle ?: "",
997 onDismiss = { showRenameDialog = false },
998 onConfirm = { newName ->
999 showRenameDialog = false
1000 vm.renameSession(newName)
1001 },
1005 if (showDeleteDialog) {
1006 AlertDialog(
1007 onDismissRequest = { showDeleteDialog = false },
1008 title = { Text(stringResource(R.string.delete_session_chat_title)) },
1009 text = { Text(stringResource(R.string.delete_session_chat_text, sessionTitle ?: sessionLabel)) },
1010 confirmButton = {
1011 TextButton(onClick = {
1012 showDeleteDialog = false
1013 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
1014 vm.deleteSession()
1015 }) { Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) }
1016 },
1017 dismissButton = {
1018 TextButton(onClick = { showDeleteDialog = false }) { Text(stringResource(R.string.cancel)) }
1019 },
1023 if (showShellDialog) {
1024 ShellCommandDialog(
1025 onDismiss = { showShellDialog = false },
1026 onRun = { cmd ->
1027 showShellDialog = false
1028 vm.runShell(cmd)
1029 },
1033 if (showModelPicker) {
1034 ModelPickerSheet(
1035 options = models,
1036 selected = selectedModel,
1037 loading = modelsLoading,
1038 error = modelsError,
1039 onSelect = { vm.selectModel(it) },
1040 onRetry = { vm.reloadModels() },
1041 onDismiss = { showModelPicker = false },
1045 if (showAgentPicker) {
1046 AgentPickerSheet(
1047 agents = agents,
1048 selected = selectedAgent,
1049 loading = agentsLoading,
1050 error = agentsError,
1051 onSelect = { vm.selectAgent(it?.name) },
1052 onRetry = { vm.reloadAgents() },
1053 onDismiss = { showAgentPicker = false },
1057 if (showCommandPicker) {
1058 CommandPickerSheet(
1059 commands = commands,
1060 loading = commandsLoading,
1061 error = commandsError,
1062 onSelect = { vm.runCommand(it) },
1063 onRetry = { vm.reloadCommands() },
1064 onDismiss = { showCommandPicker = false },
1068 pendingPermission?.let { permission ->
1069 PermissionDialog(
1070 permission = permission,
1071 onRespond = { response -> vm.respondPermission(permission, response) },
1075 if (showPalette) {
1076 CommandPalette(actions = paletteActions, onDismiss = { showPalette = false })
1080@Composable
1081private fun ChatInputBar(
1082 value: String,
1083 onValueChange: (String) -> Unit,
1084 running: Boolean,
1085 aborting: Boolean,
1086 enabled: Boolean,
1087 sendOnEnter: Boolean,
1088 onSend: () -> Unit,
1089 onAbort: () -> Unit,
1090 queuedFollowUp: String?,
1091 onQueueFollowUp: (String) -> Unit,
1092 onCancelQueue: () -> Unit,
1093 focusRequester: androidx.compose.ui.focus.FocusRequester,
1094 attachments: List<PendingAttachment>,
1095 onRemoveAttachment: (String) -> Unit,
1096 onPickPhoto: () -> Unit,
1097 onPickFile: () -> Unit,
1098 onCamera: () -> Unit,
1099 onVoice: () -> Unit,
1100) {
1101 // Sendable when there's text OR at least one attachment (an image-only prompt is valid).
1102 val hasContent = value.isNotBlank() || attachments.isNotEmpty()
1103 Surface(tonalElevation = 3.dp, modifier = Modifier.imePadding()) {
1104 Column(modifier = Modifier.fillMaxWidth().windowInsetsPadding(WindowInsets.navigationBars)) {
1105 // A queued follow-up replaces the Stop button with a "queued" chip so the
1106 // user sees their message will be sent when the run finishes, and can cancel.
1107 if (queuedFollowUp != null) {
1108 Row(
1109 modifier = Modifier
1110 .fillMaxWidth()
1111 .padding(start = 12.dp, top = 6.dp, end = 4.dp),
1112 verticalAlignment = Alignment.CenterVertically,
1113 ) {
1114 Column(modifier = Modifier.weight(1f)) {
1115 Text(
1116 stringResource(R.string.queued),
1117 style = MaterialTheme.typography.labelSmall,
1118 color = MaterialTheme.colorScheme.onSurfaceVariant,
1120 Text(
1121 queuedFollowUp,
1122 style = MaterialTheme.typography.bodySmall,
1123 color = MaterialTheme.colorScheme.onSurfaceVariant,
1124 maxLines = 1,
1125 overflow = TextOverflow.Ellipsis,
1128 TextButton(onClick = onCancelQueue, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp)) {
1129 Text(stringResource(R.string.cancel))
1133 // Staged attachments: horizontally-scrollable thumbnails/chips, each removable.
1134 AttachmentStrip(attachments, onRemove = onRemoveAttachment)
1135 Row(
1136 modifier = Modifier.fillMaxWidth().padding(8.dp),
1137 verticalAlignment = Alignment.Bottom,
1138 ) {
1139 // Attach menu: photo picker, camera, or any file.
1140 var showAttachMenu by remember { mutableStateOf(false) }
1141 Box {
1142 IconButton(onClick = { showAttachMenu = true }, enabled = enabled) {
1143 Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.add_attachment))
1145 DropdownMenu(expanded = showAttachMenu, onDismissRequest = { showAttachMenu = false }) {
1146 DropdownMenuItem(
1147 leadingIcon = { Icon(Icons.Filled.PhotoLibrary, contentDescription = null) },
1148 text = { Text(stringResource(R.string.attach_photo)) },
1149 onClick = { showAttachMenu = false; onPickPhoto() },
1151 DropdownMenuItem(
1152 leadingIcon = { Icon(Icons.Filled.PhotoCamera, contentDescription = null) },
1153 text = { Text(stringResource(R.string.attach_camera)) },
1154 onClick = { showAttachMenu = false; onCamera() },
1156 DropdownMenuItem(
1157 leadingIcon = { Icon(Icons.Filled.Description, contentDescription = null) },
1158 text = { Text(stringResource(R.string.attach_file)) },
1159 onClick = { showAttachMenu = false; onPickFile() },
1163 // Voice dictation: appends recognized speech to the draft.
1164 IconButton(onClick = onVoice, enabled = enabled) {
1165 Icon(Icons.Filled.Mic, contentDescription = stringResource(R.string.voice_input))
1167 OutlinedTextField(
1168 value = value,
1169 onValueChange = { v ->
1170 // Cap input length so a huge paste can't stall the UI. Shares are
1171 // capped separately in MainActivity; this guards typed/pasted drafts.
1172 onValueChange(v.take(NetworkConfig.maxDraftLengthChars))
1173 },
1174 modifier = Modifier
1175 .weight(1f)
1176 .focusRequester(focusRequester)
1177 .testTag("chat_input")
1178 .onPreviewKeyEvent { event ->
1179 if (event.type != KeyEventType.KeyDown || event.key != Key.Enter) return@onPreviewKeyEvent false
1180 // With "Send on Enter" on, Enter sends (Shift+Enter newlines).
1181 // With it off, Enter inserts a newline and Ctrl+Enter sends.
1182 val send = enterShouldSend(
1183 enabled = enabled,
1184 hasContent = hasContent,
1185 sendOnEnter = sendOnEnter,
1186 shift = event.isShiftPressed,
1187 ctrl = event.isCtrlPressed,
1189 if (send) {
1190 if (running) onQueueFollowUp(value) else onSend()
1191 true
1192 } else {
1193 false
1195 },
1196 placeholder = { Text(stringResource(R.string.message_placeholder)) },
1197 enabled = enabled,
1198 maxLines = 6,
1199 // Show a "N / max" countdown once the draft crosses a high fraction of
1200 // the cap, so the user knows a paste is about to be truncated instead
1201 // of being silently cut off. Hidden for normal short prompts to avoid
1202 // clutter under a typical one-line message.
1203 supportingText = {
1204 val threshold = (NetworkConfig.maxDraftLengthChars * NetworkConfig.draftCountdownThresholdFraction).toInt()
1205 if (value.length >= threshold) {
1206 Text(
1207 stringResource(
1208 R.string.draft_chars_remaining,
1209 NetworkConfig.maxDraftLengthChars - value.length,
1210 NetworkConfig.maxDraftLengthChars,
1211 ),
1212 style = MaterialTheme.typography.labelSmall,
1213 color = if (value.length >= NetworkConfig.maxDraftLengthChars) MaterialTheme.colorScheme.error
1214 else MaterialTheme.colorScheme.onSurfaceVariant,
1217 },
1218 keyboardOptions = KeyboardOptions(
1219 // With send-on-Enter off, expose the platform default action so the soft
1220 // keyboard shows a newline key (this is a multi-line field) instead of a
1221 // Send action — otherwise the setting has no effect on soft keyboards and
1222 // there's no way to type a newline. Hardware Enter is handled separately.
1223 imeAction = if (sendOnEnter) ImeAction.Send else ImeAction.Default,
1224 capitalization = KeyboardCapitalization.Sentences,
1225 ),
1226 keyboardActions = KeyboardActions(onSend = {
1227 if (enabled) {
1228 // During a run the Send IME action queues a follow-up, but only when
1229 // there's typed text — queuing "" would cancel an existing queued
1230 // follow-up (see the Queue button gate). When not running, an
1231 // attachment-only send is valid, so gate that path on hasContent.
1232 if (running) {
1233 if (value.isNotBlank()) onQueueFollowUp(value)
1234 } else if (hasContent) {
1235 onSend()
1238 }),
1240 ComposerTrailingButton(
1241 running = running,
1242 aborting = aborting,
1243 canSend = enabled && hasContent,
1244 canQueue = enabled && value.isNotBlank(),
1245 onSend = onSend,
1246 onAbort = onAbort,
1247 onQueue = { onQueueFollowUp(value) },
1254/** Whether a hardware Enter keypress should send: never when disabled/empty; otherwise
1255 * Enter sends (unless Shift) when send-on-Enter is on, else only Ctrl+Enter sends. */
1256private fun enterShouldSend(
1257 enabled: Boolean,
1258 hasContent: Boolean,
1259 sendOnEnter: Boolean,
1260 shift: Boolean,
1261 ctrl: Boolean,
1262): Boolean = when {
1263 !enabled || !hasContent -> false
1264 sendOnEnter -> !shift
1265 else -> ctrl
1268/** The composer's trailing button: while a run is active a Stop (with in-flight spinner),
1269 * preceded by a Queue button when the user has typed a follow-up; otherwise Send. Extracted so
1270 * [ChatInputBar] stays under the complexity threshold. */
1271@Composable
1272private fun ComposerTrailingButton(
1273 running: Boolean,
1274 aborting: Boolean,
1275 canSend: Boolean,
1276 canQueue: Boolean,
1277 onSend: () -> Unit,
1278 onAbort: () -> Unit,
1279 onQueue: () -> Unit,
1280) {
1281 if (running) {
1282 Row(verticalAlignment = Alignment.CenterVertically) {
1283 // While a run is active the trailing action is Stop, so the only send affordance is
1284 // the keyboard's IME action — which is a plain newline when "Send on Enter" is off.
1285 // That leaves a touch-only user with the setting off no way to queue a follow-up
1286 // (Ctrl+Enter needs a hardware keyboard). Surface an explicit Queue button whenever
1287 // there's typed TEXT so queuing is always reachable, without changing the user's
1288 // Enter-inserts-a-newline preference. Gate on typed text — not the send-oriented
1289 // canSend, which is also true for an attachment-only composer — because queuing "" just
1290 // calls setQueuedFollowUp(null), silently cancelling any already-queued follow-up and
1291 // dropping the staged attachment (queued follow-ups don't carry attachments anyway).
1292 if (canQueue) {
1293 IconButton(
1294 onClick = onQueue,
1295 modifier = Modifier.testTag("queue_button"),
1296 ) {
1297 Icon(
1298 Icons.AutoMirrored.Filled.Send,
1299 contentDescription = stringResource(R.string.queue_followup),
1303 // Show a spinner while the abort REST call is in flight so the user sees the stop
1304 // was sent, and disable to prevent a double-tap.
1305 IconButton(
1306 onClick = onAbort,
1307 enabled = !aborting,
1308 modifier = Modifier.padding(start = 4.dp).testTag("stop_button"),
1309 ) {
1310 if (aborting) {
1311 val stopLabel = stringResource(R.string.stop)
1312 CircularProgressIndicator(
1313 Modifier.size(18.dp).semantics { contentDescription = stopLabel },
1314 strokeWidth = 2.dp,
1316 } else {
1317 Icon(Icons.Filled.Stop, contentDescription = stringResource(R.string.stop))
1321 } else {
1322 IconButton(
1323 onClick = onSend,
1324 enabled = canSend,
1325 modifier = Modifier.padding(start = 4.dp).testTag("send_button"),
1326 ) {
1327 Icon(Icons.AutoMirrored.Filled.Send, contentDescription = stringResource(R.string.send))
1332@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class)
1333@Composable
1334private fun EmptyConversation(
1335 onSuggestion: (String) -> Unit,
1336 modifier: Modifier = Modifier,
1337) {
1338 // A few example prompts so a first-time user has a one-tap path to their first
1339 // message instead of staring at a blank input. Tapping a chip fills the draft
1340 // (without sending) so the user can edit it before sending.
1341 val suggestions = remember {
1342 listOf(
1343 R.string.empty_chat_suggest_1,
1344 R.string.empty_chat_suggest_2,
1345 R.string.empty_chat_suggest_3,
1348 Column(
1349 modifier = modifier.padding(32.dp),
1350 horizontalAlignment = Alignment.CenterHorizontally,
1351 ) {
1352 Icon(
1353 Icons.Filled.AutoAwesome,
1354 contentDescription = null,
1355 modifier = Modifier.size(48.dp),
1356 tint = MaterialTheme.colorScheme.onSurfaceVariant,
1358 Spacer(Modifier.size(16.dp))
1359 Text(
1360 stringResource(R.string.empty_chat_title),
1361 style = MaterialTheme.typography.titleMedium,
1363 Spacer(Modifier.size(8.dp))
1364 Text(
1365 stringResource(R.string.empty_chat_hint),
1366 style = MaterialTheme.typography.bodyMedium,
1367 color = MaterialTheme.colorScheme.onSurfaceVariant,
1369 Spacer(Modifier.size(20.dp))
1370 // Suggestion chips wrap across rows on narrow screens instead of stacking full-width,
1371 // so a longer localized prompt doesn't push the chips off-screen on a phone.
1372 FlowRow(
1373 modifier = Modifier.fillMaxWidth(),
1374 horizontalArrangement = Arrangement.spacedBy(8.dp),
1375 verticalArrangement = Arrangement.spacedBy(8.dp),
1376 ) {
1377 suggestions.forEach { resId ->
1378 val text = stringResource(resId)
1379 androidx.compose.material3.AssistChip(
1380 onClick = { onSuggestion(text) },
1381 label = { Text(text, style = MaterialTheme.typography.bodyMedium) },
1388/** A banner shown while a revert checkpoint is active, offering an Undo (unrevert). */
1389@Composable
1390private fun RevertBanner(onUndo: () -> Unit) {
1391 Surface(
1392 color = MaterialTheme.colorScheme.tertiaryContainer,
1393 tonalElevation = 2.dp,
1394 modifier = Modifier.fillMaxWidth(),
1395 ) {
1396 Row(
1397 modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp),
1398 verticalAlignment = Alignment.CenterVertically,
1399 horizontalArrangement = Arrangement.SpaceBetween,
1400 ) {
1401 Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
1402 Icon(
1403 Icons.Filled.Restore,
1404 contentDescription = null,
1405 tint = MaterialTheme.colorScheme.onTertiaryContainer,
1406 modifier = Modifier.size(18.dp),
1408 Text(
1409 stringResource(R.string.reverted_banner),
1410 style = MaterialTheme.typography.bodySmall,
1411 color = MaterialTheme.colorScheme.onTertiaryContainer,
1412 modifier = Modifier.padding(start = 8.dp),
1415 TextButton(onClick = onUndo) { Text(stringResource(R.string.undo)) }
1420/** A banner shown while messages composed offline are queued in the outbox, with a
1421 * "Send now" nudge and a discard. Shows a spinner while the queue is flushing. */
1422@Composable
1423private fun OutboxBanner(
1424 count: Int,
1425 sending: Boolean,
1426 onFlush: () -> Unit,
1427 onDiscard: () -> Unit,
1428) {
1429 Surface(
1430 color = MaterialTheme.colorScheme.secondaryContainer,
1431 tonalElevation = 2.dp,
1432 modifier = Modifier.fillMaxWidth(),
1433 ) {
1434 Row(
1435 modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp),
1436 verticalAlignment = Alignment.CenterVertically,
1437 horizontalArrangement = Arrangement.SpaceBetween,
1438 ) {
1439 Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
1440 if (sending) {
1441 CircularProgressIndicator(
1442 Modifier.size(16.dp),
1443 strokeWidth = 2.dp,
1444 color = MaterialTheme.colorScheme.onSecondaryContainer,
1446 Spacer(Modifier.size(8.dp))
1448 Text(
1449 if (sending) {
1450 stringResource(R.string.outbox_sending)
1451 } else {
1452 val res = if (count == 1) R.string.outbox_pending_one else R.string.outbox_pending_many
1453 stringResource(res, count)
1454 },
1455 style = MaterialTheme.typography.bodySmall,
1456 color = MaterialTheme.colorScheme.onSecondaryContainer,
1459 if (!sending) {
1460 TextButton(onClick = onFlush) { Text(stringResource(R.string.outbox_flush)) }
1461 TextButton(onClick = onDiscard) {
1462 Text(stringResource(R.string.outbox_discard_all), color = MaterialTheme.colorScheme.error)
1469/** Prompt for a one-off shell command to run in the session's worktree. */
1470@Composable
1471private fun ShellCommandDialog(onDismiss: () -> Unit, onRun: (String) -> Unit) {
1472 var command by rememberSaveable { mutableStateOf("") }
1473 AlertDialog(
1474 onDismissRequest = onDismiss,
1475 title = { Text(stringResource(R.string.run_shell_command)) },
1476 text = {
1477 OutlinedTextField(
1478 value = command,
1479 onValueChange = { command = it },
1480 modifier = Modifier.fillMaxWidth(),
1481 placeholder = { Text(stringResource(R.string.shell_command_hint)) },
1482 label = { Text(stringResource(R.string.shell_command_label)) },
1483 singleLine = true,
1484 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
1485 keyboardActions = KeyboardActions(onDone = { if (command.isNotBlank()) onRun(command.trim()) }),
1487 },
1488 confirmButton = {
1489 TextButton(
1490 onClick = { if (command.isNotBlank()) onRun(command.trim()) },
1491 enabled = command.isNotBlank(),
1492 ) { Text(stringResource(R.string.run)) }
1493 },
1494 dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } },
1498/**
1499 * Change signal for the auto-scroll-on-stream watcher. Primitive fields avoid the
1500 * boxing `Triple<Int, Int, Boolean>` would impose on every snapshot evaluation, and
1501 * equality is structural so `snapshotFlow` emits only when something relevant changes.
1502 */
1503private data class AutoScrollSignal(val size: Int, val lastTextLength: Int, val pinned: Boolean)
1505/** Length of the streaming content carried by [part], driving auto-scroll as it grows.
1506 * Covers text, reasoning, and tool output so a pinned view keeps following non-text
1507 * streaming, not just plain assistant text. O(1). */
1508private fun streamingContentLength(part: Part?): Int = when (part) {
1509 is TextPart -> part.text.length
1510 is ReasoningPart -> part.text.length
1511 is ToolPart -> when (val st = part.state) {
1512 is ToolCompleted -> st.output?.length ?: 0
1513 is ToolError -> st.error?.length ?: 0
1514 is ToolRunning -> st.title?.length ?: 0
1515 else -> 0
1517 else -> 0
1520/** Rename dialog for the currently-open session. Mirrors the SessionListScreen rename
1521 * dialog but lives in the chat screen so the user can rename without backing out to
1522 * the list. Pre-filled with the current title; the cap matches the list dialog. */
1523@Composable
1524private fun RenameSessionChatDialog(
1525 initialTitle: String,
1526 onDismiss: () -> Unit,
1527 onConfirm: (String) -> Unit,
1528) {
1529 // Seed the field once (no key input) so a later external title change — e.g. opencode
1530 // auto-generating a session title after the first prompt, arriving via SessionUpdated —
1531 // doesn't re-initialize the box and discard the user's in-progress edit.
1532 var title by rememberSaveable { mutableStateOf(initialTitle) }
1533 AlertDialog(
1534 onDismissRequest = onDismiss,
1535 title = { Text(stringResource(R.string.rename_session_chat)) },
1536 text = {
1537 OutlinedTextField(
1538 value = title,
1539 onValueChange = { v -> title = v.take(NetworkConfig.maxSessionTitleChars) },
1540 modifier = Modifier.fillMaxWidth(),
1541 placeholder = { Text(stringResource(R.string.session_title_hint)) },
1542 label = { Text(stringResource(R.string.rename_session_chat)) },
1543 singleLine = true,
1544 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
1545 keyboardActions = KeyboardActions(onDone = { if (title.isNotBlank()) onConfirm(title.trim()) }),
1547 },
1548 confirmButton = {
1549 TextButton(onClick = { if (title.isNotBlank()) onConfirm(title.trim()) }, enabled = title.isNotBlank()) {
1550 Text(stringResource(R.string.save))
1552 },
1553 dismissButton = {
1554 TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) }
1555 },
1559/**
1560 * A single item in the chat message list — either a [Message] bubble or a day
1561 * [Separator] inserted before the first message of a new day. The separator shifts
1562 * the LazyColumn indices vs. the raw messages list, so scroll-target math must use
1563 * the [List] of these items (not `messages.size`/`lastIndex`).
1564 */
1565private sealed interface MessageListItem {
1566 val key: Any
1567 val contentType: Any
1569 data class Message(val message: MessageWithParts, override val key: Any) : MessageListItem {
1570 override val contentType: Any get() = message.info::class
1573 data class Separator(val label: String, val ordinal: Int) : MessageListItem {
1574 // Key on the day-occurrence ordinal, not the label: two non-contiguous groups
1575 // can share a label (e.g. an untimestamped message — empty label — between two
1576 // same-day messages, or two untimestamped groups), and a label-only key would
1577 // then collide, which makes LazyColumn throw on duplicate keys. The ordinal is
1578 // stable for a given message ordering so slots aren't needlessly recreated.
1579 override val key: Any get() = "sep_${ordinal}_$label"
1580 // Separators share a contentType so the LazyColumn can recycle their slots.
1581 override val contentType: Any get() = "separator"
1585/**
1586 * Build the interleaved list of [MessageListItem]s for the message list, inserting a
1587 * [MessageListItem.Separator] before the first message of each new calendar day. Uses
1588 * the message's `time.created` (falling back to `updated` then `completed`) to bucket.
1589 * Messages with no timestamp are grouped under an empty-label separator only if they
1590 * start the list, so a server that omits timestamps doesn't suppress the first divider.
1592 * [todayLabel]/[yesterdayLabel] are resolved by the caller (a @Composable can't call
1593 * stringResource from inside this plain function) and used for the "Today"/"Yesterday"
1594 * labels; older days fall back to a locale-stable medium-date format.
1595 */
1596private fun buildMessageListItems(
1597 messages: List<MessageWithParts>,
1598 todayLabel: String,
1599 yesterdayLabel: String,
1600): List<MessageListItem> {
1601 if (messages.isEmpty()) return emptyList()
1602 val today = java.time.LocalDate.now(java.time.ZoneId.systemDefault())
1603 val dateFmt = mediumDateFormat()
1604 val result = ArrayList<MessageListItem>(messages.size + 4)
1605 val seenKeys = HashSet<String>(messages.size * 2)
1606 var lastDayKey: String? = null
1607 var sepOrdinal = 0
1608 var emptyIdOrdinal = 0
1609 var first = true
1610 for (message in messages) {
1611 val ts = message.info.time?.created ?: message.info.time?.updated ?: message.info.time?.completed
1612 val dayKey = ts?.let { dayKey(it) } ?: ""
1613 // Emit a separator before the very first message (so a leading un-timestamped message
1614 // still gets the first divider, empty label and all), and thereafter only when crossing
1615 // into a new NON-empty calendar day. An un-timestamped message mid-list (empty key)
1616 // neither emits its own separator nor resets the day — otherwise a following same-day
1617 // message would see a "" -> "<today>" transition and emit a duplicate "Today" header.
1618 if (first || (dayKey.isNotEmpty() && dayKey != lastDayKey)) {
1619 result.add(MessageListItem.Separator(dayLabel(dayKey, ts ?: 0L, today, todayLabel, yesterdayLabel, dateFmt), sepOrdinal++))
1621 if (dayKey.isNotEmpty()) lastDayKey = dayKey
1622 first = false
1623 // Most messages have a unique non-empty id. An unrecognized-role message can carry an
1624 // empty id — and the reducer intentionally keeps several such messages as distinct
1625 // holders — so give each a stable synthetic key and defensively de-collide any remaining
1626 // duplicate; otherwise LazyColumn throws on a duplicate key and the whole screen crashes.
1627 var key = message.info.id.ifEmpty { "msg-empty-${emptyIdOrdinal++}" }
1628 if (!seenKeys.add(key)) {
1629 var suffix = 1
1630 while (!seenKeys.add("$key#$suffix")) suffix++
1631 key = "$key#$suffix"
1633 result.add(MessageListItem.Message(message, key))
1635 return result
1638/**
1639 * A stable bucket key for a timestamp's calendar day (epoch-days as a string).
1641 * [buildMessageListItems] re-runs on every streaming snapshot (the memo is keyed on the
1642 * whole `messages` list, which must stay so the live message's fresh parts render), so a
1643 * naive implementation would re-allocate an `Instant`/`ZonedDateTime`/`LocalDate` for
1644 * every message on every token. Message timestamps are immutable once assigned, so we
1645 * memoize the parse per epoch-millis: after the first snapshot, each unchanged message is
1646 * a cheap hash lookup. Bounded LRU (access-order) caps memory; only touched on the main
1647 * thread during composition, so the coarse lock is uncontended.
1648 */
1649private val dayKeyCache = object : LinkedHashMap<Long, String>(128, 0.75f, true) {
1650 override fun removeEldestEntry(eldest: MutableMap.MutableEntry<Long, String>): Boolean = size > 1024
1653private fun dayKey(epochMillis: Long): String = synchronized(dayKeyCache) {
1654 dayKeyCache.getOrPut(epochMillis) {
1655 java.time.Instant.ofEpochMilli(epochMillis)
1656 .atZone(java.time.ZoneId.systemDefault()).toLocalDate().toEpochDay().toString()
1660// The medium-date formatter is an ICU locale lookup; cache one instance instead of
1661// allocating a fresh one per buildMessageListItems call, rebuilding only if the default
1662// locale changes at runtime. Main-thread-only, so no synchronization is needed.
1663private var cachedDateFmt: java.text.DateFormat? = null
1664private var cachedDateFmtLocale: java.util.Locale? = null
1666private fun mediumDateFormat(): java.text.DateFormat {
1667 val locale = java.util.Locale.getDefault()
1668 val existing = cachedDateFmt
1669 if (existing != null && cachedDateFmtLocale == locale) return existing
1670 return java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM).also {
1671 cachedDateFmt = it
1672 cachedDateFmtLocale = locale
1676/**
1677 * Resolve a day-key back to a human-readable label. "Today" / "Yesterday" for the
1678 * recent days, otherwise a locale-stable medium-date format. Returns an empty string
1679 * when [dayKey] is empty (no timestamp) so no separator is rendered for un-timestamped
1680 * messages.
1681 */
1682private fun dayLabel(
1683 dayKey: String,
1684 ts: Long,
1685 today: java.time.LocalDate,
1686 todayLabel: String,
1687 yesterdayLabel: String,
1688 dateFmt: java.text.DateFormat,
1689): String {
1690 if (dayKey.isEmpty()) return ""
1691 val epochDay = dayKey.toLongOrNull() ?: return ""
1692 val date = java.time.LocalDate.ofEpochDay(epochDay)
1693 return when {
1694 date == today -> todayLabel
1695 date == today.minusDays(1) -> yesterdayLabel
1696 else -> dateFmt.format(java.util.Date(ts))
1700/**
1701 * A centered day divider in the message list. Renders nothing for an empty label
1702 * (the no-timestamp case) so un-timestamped messages don't get a stray blank divider.
1703 */
1704@Composable
1705private fun DateSeparator(label: String) {
1706 if (label.isBlank()) return
1707 Row(
1708 modifier = Modifier.fillMaxWidth(),
1709 horizontalArrangement = Arrangement.Center,
1710 ) {
1711 Surface(
1712 color = MaterialTheme.colorScheme.surfaceVariant,
1713 shape = MaterialTheme.shapes.small,
1714 ) {
1715 Text(
1716 label,
1717 style = MaterialTheme.typography.labelSmall,
1718 color = MaterialTheme.colorScheme.onSurfaceVariant,
1719 modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),