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