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