git.iko.soy/opencode-android
on 06fd41b help sign in
2604 lines 141 kB history raw
1@file:Suppress("TooManyFunctions")
3package soy.iko.opencode.ui.chat
5import android.app.Activity
6import android.content.Intent
7import android.net.Uri
8import android.speech.RecognizerIntent
9import androidx.activity.compose.BackHandler
10import androidx.activity.compose.rememberLauncherForActivityResult
11import androidx.activity.result.PickVisualMediaRequest
12import androidx.activity.result.contract.ActivityResultContracts
13import androidx.compose.animation.AnimatedVisibility
14import androidx.compose.animation.core.RepeatMode
15import androidx.compose.animation.core.animateFloat
16import androidx.compose.animation.core.infiniteRepeatable
17import androidx.compose.animation.core.rememberInfiniteTransition
18import androidx.compose.animation.core.tween
19import androidx.compose.foundation.background
20import androidx.compose.foundation.clickable
21import androidx.compose.foundation.content.contentReceiver
22import androidx.compose.foundation.layout.Arrangement
23import androidx.compose.foundation.layout.Box
24import androidx.compose.foundation.layout.Column
25import androidx.compose.foundation.layout.FlowRow
26import androidx.compose.foundation.layout.PaddingValues
27import androidx.compose.foundation.layout.Row
28import androidx.compose.foundation.layout.Spacer
29import androidx.compose.foundation.layout.WindowInsets
30import androidx.compose.foundation.layout.fillMaxSize
31import androidx.compose.foundation.layout.fillMaxWidth
32import androidx.compose.foundation.layout.height
33import androidx.compose.foundation.layout.imePadding
34import androidx.compose.foundation.layout.navigationBars
35import androidx.compose.foundation.layout.padding
36import androidx.compose.foundation.layout.size
37import androidx.compose.foundation.layout.union
38import androidx.compose.foundation.layout.widthIn
39import androidx.compose.foundation.layout.windowInsetsPadding
40import androidx.compose.foundation.layout.wrapContentWidth
41import androidx.compose.foundation.lazy.LazyColumn
42import androidx.compose.foundation.lazy.items
43import androidx.compose.foundation.lazy.rememberLazyListState
44import androidx.compose.foundation.shape.RoundedCornerShape
45import androidx.compose.material.icons.Icons
46import androidx.compose.material.icons.automirrored.filled.ArrowBack
47import androidx.compose.material.icons.automirrored.filled.Send
48import androidx.compose.material.icons.filled.Add
49import androidx.compose.material.icons.filled.AutoAwesome
50import androidx.compose.material.icons.filled.Delete
51import androidx.compose.material.icons.filled.Description
52import androidx.compose.material.icons.filled.Close
53import androidx.compose.material.icons.filled.Edit
54import androidx.compose.material.icons.filled.Expand
55import androidx.compose.material.icons.filled.ExpandLess
56import androidx.compose.material.icons.filled.ExpandMore
57import androidx.compose.material.icons.filled.Search
58import androidx.compose.material.icons.filled.KeyboardArrowDown
59import androidx.compose.material.icons.filled.KeyboardArrowUp
60import androidx.compose.material.icons.filled.Mic
61import androidx.compose.material.icons.filled.MoreVert
62import androidx.compose.material.icons.filled.PhotoCamera
63import androidx.compose.material.icons.filled.PhotoLibrary
64import androidx.compose.material.icons.filled.Refresh
65import androidx.compose.material.icons.filled.Restore
66import androidx.compose.material.icons.automirrored.filled.PlaylistAdd
67import androidx.compose.material.icons.automirrored.filled.Reply
68import androidx.compose.material.icons.filled.Schedule
69import androidx.compose.material.icons.filled.Stop
70import androidx.compose.material3.AlertDialog
71import androidx.compose.material3.Badge
72import androidx.compose.material3.BadgedBox
73import androidx.compose.material3.Button
74import androidx.compose.material3.CircularProgressIndicator
75import androidx.compose.material3.SmallFloatingActionButton
76import androidx.compose.material3.DropdownMenu
77import androidx.compose.material3.DropdownMenuItem
78import androidx.compose.material3.ExperimentalMaterial3Api
79import androidx.compose.material3.ExtendedFloatingActionButton
80import androidx.compose.material3.FloatingActionButton
81import androidx.compose.material3.Icon
82import androidx.compose.material3.IconButton
83import androidx.compose.material3.MaterialTheme
84import androidx.compose.material3.OutlinedTextField
85import androidx.compose.material3.Scaffold
86import androidx.compose.material3.SnackbarDuration
87import androidx.compose.material3.SwipeToDismissBox
88import androidx.compose.material3.SwipeToDismissBoxValue
89import androidx.compose.material3.rememberSwipeToDismissBoxState
90import androidx.compose.material3.SnackbarHost
91import androidx.compose.material3.SnackbarHostState
92import androidx.compose.material3.SnackbarResult
93import androidx.compose.material3.Surface
94import androidx.compose.material3.Text
95import androidx.compose.material3.TextButton
96import androidx.compose.material3.TopAppBar
97import androidx.compose.material3.pulltorefresh.PullToRefreshBox
98import androidx.compose.runtime.Composable
99import androidx.compose.runtime.CompositionLocalProvider
100import androidx.compose.runtime.DisposableEffect
101import androidx.compose.runtime.LaunchedEffect
102import androidx.compose.runtime.derivedStateOf
103import androidx.compose.runtime.getValue
104import androidx.compose.runtime.mutableIntStateOf
105import androidx.compose.runtime.mutableLongStateOf
106import androidx.compose.runtime.mutableStateOf
107import androidx.compose.runtime.produceState
108import androidx.compose.runtime.remember
109import androidx.compose.runtime.rememberCoroutineScope
110import androidx.compose.runtime.rememberUpdatedState
111import androidx.compose.runtime.saveable.rememberSaveable
112import androidx.compose.runtime.setValue
113import androidx.compose.runtime.snapshotFlow
114import androidx.compose.foundation.text.KeyboardActions
115import androidx.compose.foundation.text.KeyboardOptions
116import androidx.compose.ui.text.input.KeyboardCapitalization
117import androidx.compose.ui.Alignment
118import androidx.compose.ui.Modifier
119import androidx.compose.ui.draw.alpha
120import androidx.compose.ui.draw.clip
121import androidx.compose.ui.focus.focusRequester
122import androidx.compose.ui.input.key.Key
123import androidx.compose.ui.input.key.KeyEventType
124import androidx.compose.ui.input.key.isCtrlPressed
125import androidx.compose.ui.input.key.isShiftPressed
126import androidx.compose.ui.input.key.key
127import androidx.compose.ui.input.key.onPreviewKeyEvent
128import androidx.compose.ui.input.key.type
129import androidx.compose.ui.layout.onSizeChanged
130import androidx.compose.ui.platform.LocalContext
131import androidx.compose.ui.platform.LocalDensity
132import androidx.compose.ui.platform.LocalHapticFeedback
133import androidx.compose.ui.platform.LocalView
134import androidx.compose.ui.platform.testTag
135import androidx.compose.ui.res.stringResource
136import androidx.compose.ui.semantics.Role
137import androidx.compose.ui.semantics.contentDescription
138import androidx.compose.ui.semantics.heading
139import androidx.compose.ui.semantics.semantics
140import androidx.compose.ui.text.input.ImeAction
141import androidx.compose.ui.text.style.TextOverflow
142import androidx.compose.ui.unit.dp
143import androidx.lifecycle.compose.collectAsStateWithLifecycle
144import androidx.lifecycle.viewmodel.compose.viewModel
145import kotlinx.coroutines.Dispatchers
146import kotlinx.coroutines.coroutineScope
147import kotlinx.coroutines.delay
148import kotlinx.coroutines.flow.distinctUntilChanged
149import kotlinx.coroutines.launch
150import kotlinx.coroutines.withContext
151import soy.iko.opencode.data.model.Part
152import soy.iko.opencode.data.model.MessageWithParts
153import soy.iko.opencode.data.model.ReasoningPart
154import soy.iko.opencode.data.model.TextPart
155import soy.iko.opencode.data.model.ToolCompleted
156import soy.iko.opencode.data.model.ToolError
157import soy.iko.opencode.data.model.ToolPart
158import soy.iko.opencode.data.model.ToolRunning
159import soy.iko.opencode.data.network.EventStreamClient
160import soy.iko.opencode.data.network.NetworkConfig
161import soy.iko.opencode.di.AppContainer
162import soy.iko.opencode.R
163import soy.iko.opencode.ui.components.CommandPalette
164import soy.iko.opencode.ui.components.ConnectionBanner
165import soy.iko.opencode.ui.components.DiffView
166import soy.iko.opencode.ui.components.PaletteAction
167import soy.iko.opencode.ui.components.LocalReducedMotion
168import soy.iko.opencode.ui.components.copyToClipboard
169import soy.iko.opencode.ui.components.LocalRelativeTimeTick
170import soy.iko.opencode.ui.components.rememberRelativeTimeTick
171import soy.iko.opencode.ui.components.rememberVisibilityTransitions
172import soy.iko.opencode.ui.components.showToast
173import soy.iko.opencode.ui.components.toImageContext
174import soy.iko.opencode.ui.vmFactory
175import soy.iko.opencode.util.runCatchingCancellable
177@OptIn(ExperimentalMaterial3Api::class)
178@Composable
179fun ChatScreen(
180 container: AppContainer,
181 sessionId: String,
182 onBack: () -> Unit,
183 onOpenFile: ((String) -> Unit)? = null,
184 onOpenSession: ((String) -> Unit)? = null,
185) {
186 val vm: ChatViewModel = viewModel(key = sessionId, factory = vmFactory { ChatViewModel(container, sessionId) })
187 val hasMessages by vm.hasMessages.collectAsStateWithLifecycle()
188 val running by vm.running.collectAsStateWithLifecycle()
189 val aborting by vm.aborting.collectAsStateWithLifecycle()
190 val loading by vm.loading.collectAsStateWithLifecycle()
191 val loadError by vm.loadError.collectAsStateWithLifecycle()
192 val refreshing by vm.refreshing.collectAsStateWithLifecycle()
193 val models by vm.models.collectAsStateWithLifecycle()
194 val modelsLoading by vm.modelsLoading.collectAsStateWithLifecycle()
195 val modelsError by vm.modelsError.collectAsStateWithLifecycle()
196 val selectedModel by vm.selectedModel.collectAsStateWithLifecycle()
197 val connectionState by vm.connectionState.collectAsStateWithLifecycle()
198 val pendingPermission by vm.pendingPermission.collectAsStateWithLifecycle()
199 val permissionProgress by vm.permissionProgress.collectAsStateWithLifecycle()
200 val agents by vm.agents.collectAsStateWithLifecycle()
201 val agentsLoading by vm.agentsLoading.collectAsStateWithLifecycle()
202 val agentsError by vm.agentsError.collectAsStateWithLifecycle()
203 val selectedAgent by vm.selectedAgent.collectAsStateWithLifecycle()
204 val commands by vm.commands.collectAsStateWithLifecycle()
205 val commandsLoading by vm.commandsLoading.collectAsStateWithLifecycle()
206 val commandsError by vm.commandsError.collectAsStateWithLifecycle()
207 val sessionTitle by vm.sessionTitle.collectAsStateWithLifecycle()
208 val sessionDeleted by vm.sessionDeleted.collectAsStateWithLifecycle()
209 val draft by vm.draft.collectAsStateWithLifecycle()
210 val attachments by vm.attachments.collectAsStateWithLifecycle()
211 val reverted by vm.reverted.collectAsStateWithLifecycle()
212 val revertDiff by vm.revertDiff.collectAsStateWithLifecycle()
213 val shareUrl by vm.shareUrl.collectAsStateWithLifecycle()
214 val reconnecting by vm.reconnecting.collectAsStateWithLifecycle()
215 val sendOnEnter by container.settingsStore.sendOnEnter.collectAsStateWithLifecycle(initialValue = true)
216 val isOnline by container.isOnline.collectAsStateWithLifecycle()
217 val haptics = LocalHapticFeedback.current
218 val scope = rememberCoroutineScope()
219 val shareContext = LocalContext.current
220 val defaultShareSubject = stringResource(R.string.share_subject)
221 val sessionLabel = stringResource(R.string.session)
222 val defaultModelLabel = stringResource(R.string.default_model)
223 val subtitle = remember(selectedModel?.modelLabel, selectedAgent, defaultModelLabel) {
224 buildString {
225 append(selectedModel?.modelLabel ?: defaultModelLabel)
226 selectedAgent?.let { append(" · $it") }
227 }
228 }
230 // Connection profile (with auth) for rendering inline image attachments.
231 // Collect as state so a server switch recomposes the image context.
232 val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
233 val imageContext = remember(activeConnection?.profile) {
234 activeConnection?.profile?.toImageContext()
235 }
237 // Mark this session as read while the user is viewing it; clear on leave so new
238 // background activity can badge it again.
239 DisposableEffect(sessionId) {
240 container.setCurrentSession(sessionId)
241 onDispose { if (container.currentSession.value == sessionId) container.setCurrentSession(null) }
242 }
244 // Navigate away when the session is deleted via SSE so the user isn't left
245 // on a zombie screen showing a conversation that no longer exists. Surface a
246 // toast (not a snackbar) because the screen is about to be popped — a snackbar
247 // would be torn down before the user could read it, while a toast survives.
248 // Covers both SSE-driven deletion (deleted on another device) and a user-
249 // initiated delete from the chat overflow menu (deleteSession in ChatViewModel
250 // sets the same flag on success).
251 val deletedContext = LocalContext.current
252 LaunchedEffect(sessionDeleted) {
253 if (sessionDeleted) {
254 showToast(deletedContext, deletedContext.getString(R.string.session_deleted_chat))
255 onBack()
256 }
257 }
259 val snackbar = remember { SnackbarHostState() }
260 val retryLabel = stringResource(R.string.retry)
261 val inputFocusRequester = remember { androidx.compose.ui.focus.FocusRequester() }
262 var showModelPicker by rememberSaveable { mutableStateOf(false) }
263 var showAgentPicker by rememberSaveable { mutableStateOf(false) }
264 var showTitleMenu by rememberSaveable { mutableStateOf(false) }
265 var showCommandPicker by rememberSaveable { mutableStateOf(false) }
266 var showExitConfirm by rememberSaveable { mutableStateOf(false) }
267 var showStopConfirm by rememberSaveable { mutableStateOf(false) }
268 var showOverflowMenu by rememberSaveable { mutableStateOf(false) }
269 var showRenameDialog by rememberSaveable { mutableStateOf(false) }
270 var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
271 var showShellDialog by rememberSaveable { mutableStateOf(false) }
272 var showPalette by rememberSaveable { mutableStateOf(false) }
273 var showShortcutsDialog by rememberSaveable { mutableStateOf(false) }
274 var searchActive by rememberSaveable { mutableStateOf(false) }
275 var chatSearch by rememberSaveable { mutableStateOf("") }
276 // Index of the currently-focused in-conversation search match (into the filtered list).
277 // Drives the up/down navigation: the search bar's arrows step through matches and scroll
278 // the list to each one, mirroring desktop chat apps (the bare filtered list previously
279 // required reading each message to find the term).
280 var searchPos by rememberSaveable { mutableIntStateOf(0) }
281 // Summarize and init (generate AGENTS.md) are irreversible, so a single tap is gated behind
282 // a confirmation dialog (from both the overflow menu and the command palette).
283 var showSummarizeConfirm by rememberSaveable { mutableStateOf(false) }
284 var showInitConfirm by rememberSaveable { mutableStateOf(false) }
286 // Command-palette entries (keyboard-first: Ctrl+K). Labels resolved here so the remembered
287 // action list doesn't need a Composable context. Actions map to the same handlers the
288 // overflow menu drives, so the palette is a keyboard shortcut to them, not a parallel path.
289 val paletteModelLabel = stringResource(R.string.palette_switch_model)
290 val paletteAgentLabel = stringResource(R.string.palette_choose_agent)
291 val paletteCommandLabel = stringResource(R.string.palette_run_command)
292 val paletteSummarizeLabel = stringResource(R.string.palette_summarize)
293 val paletteRenameLabel = stringResource(R.string.rename_session_chat)
294 val paletteShellLabel = stringResource(R.string.run_shell_command)
295 val paletteActions = remember(
296 paletteModelLabel, paletteAgentLabel, paletteCommandLabel,
297 paletteSummarizeLabel, paletteRenameLabel, paletteShellLabel, hasMessages, running,
298 ) {
299 buildList {
300 add(PaletteAction("model", paletteModelLabel) { showModelPicker = true })
301 add(PaletteAction("agent", paletteAgentLabel) { showAgentPicker = true })
302 add(PaletteAction("command", paletteCommandLabel) { showCommandPicker = true })
303 if (hasMessages && !running) add(PaletteAction("summarize", paletteSummarizeLabel) { showSummarizeConfirm = true })
304 if (!running) add(PaletteAction("shell", paletteShellLabel) { showShellDialog = true })
305 add(PaletteAction("rename", paletteRenameLabel) { showRenameDialog = true })
306 }
307 }
309 // Keep the screen awake while the agent is working in *this* session. The foreground
310 // service that holds process priority during a run is managed app-wide off
311 // container.anyRunActive (see OpencodeApp) so it survives navigating away from the chat
312 // mid-run — otherwise leaving the screen would drop priority and Doze could choke the
313 // SSE stream before the completion notification fires.
314 val currentView = LocalView.current
315 val appContext = LocalContext.current.applicationContext
316 DisposableEffect(running) {
317 currentView.keepScreenOn = running
318 onDispose { currentView.keepScreenOn = false }
319 }
321 LaunchedEffect(Unit) {
322 vm.errorEvents.collect { event ->
323 // Only a failed *send* is retryable; attaching Retry to any other error (e.g. a
324 // message-load failure) would silently re-submit the last prompt on tap.
325 val result = if (event.retryable) {
326 snackbar.showSnackbar(message = event.message, actionLabel = retryLabel)
327 } else {
328 snackbar.showSnackbar(event.message)
329 }
330 if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) vm.retryFailed()
331 }
332 }
334 // In-place Undo for a user-initiated session delete. The delete is deferred for an undo
335 // window on the container; show the snackbar over the conversation the user is viewing so
336 // they can cancel without being navigated to the session list first. If the window expires
337 // the scheduled delete sets `sessionDeleted` and the effect below navigates away.
338 val undoLabel = stringResource(R.string.undo)
339 val sessionDeletedLabel = stringResource(R.string.session_deleted_chat)
340 LaunchedEffect(Unit) {
341 vm.deleteUndoEvents.collect {
342 coroutineScope {
343 val dismisser = launch {
344 delay(NetworkConfig.undoDeleteDelayMs)
345 snackbar.currentSnackbarData?.dismiss()
346 }
347 val result = snackbar.showSnackbar(
348 message = sessionDeletedLabel,
349 actionLabel = undoLabel,
350 duration = SnackbarDuration.Indefinite,
351 )
352 dismisser.cancel()
353 if (result == SnackbarResult.ActionPerformed) vm.cancelSessionDelete()
354 }
355 }
356 }
358 // --- Attachments, voice, and share-link plumbing ---
359 val attachTooLargeMsg = stringResource(R.string.attachment_too_large)
360 val attachFailedMsg = stringResource(R.string.attachment_failed)
361 val attachLimitMsg = stringResource(R.string.attachment_limit)
362 val noCameraMsg = stringResource(R.string.no_camera_app)
363 val noVoiceMsg = stringResource(R.string.no_voice_app)
364 val voicePrompt = stringResource(R.string.voice_prompt)
365 val linkCopiedMsg = stringResource(R.string.link_copied)
366 val ttsUnavailableMsg = stringResource(R.string.tts_unavailable)
368 // Count of picks currently being read + base64-encoded off the main thread, so the
369 // composer can show a staging placeholder immediately (chips only materialize once done).
370 var stagingCount by remember { mutableStateOf(0) }
372 // Convert each picked Uri to a base64 attachment off the main thread, honoring the
373 // per-prompt count cap and surfacing per-file errors without aborting the batch.
374 fun stageUris(uris: List<Uri>) {
375 if (uris.isEmpty()) return
376 scope.launch {
377 stagingCount++
378 try {
379 for (uri in uris) {
380 if (vm.attachments.value.size >= NetworkConfig.maxAttachments) {
381 snackbar.showSnackbar(attachLimitMsg)
382 break
383 }
384 when (val result = uri.toAttachmentResult(appContext)) {
385 is AttachmentResult.Ok -> vm.addAttachment(result.attachment)
386 AttachmentResult.TooLarge -> snackbar.showSnackbar(attachTooLargeMsg)
387 AttachmentResult.Failed -> snackbar.showSnackbar(attachFailedMsg)
388 }
389 }
390 } finally {
391 stagingCount--
392 }
393 }
394 }
396 val photoPicker = rememberLauncherForActivityResult(
397 ActivityResultContracts.PickMultipleVisualMedia(NetworkConfig.maxAttachments),
398 ) { uris -> stageUris(uris) }
400 val filePicker = rememberLauncherForActivityResult(
401 ActivityResultContracts.GetMultipleContents(),
402 ) { uris -> stageUris(uris) }
404 // Uri survives a config change mid-capture so the TakePicture result still resolves.
405 var pendingCaptureUri by rememberSaveable { mutableStateOf<Uri?>(null) }
406 val cameraLauncher = rememberLauncherForActivityResult(
407 ActivityResultContracts.TakePicture(),
408 ) { success ->
409 val uri = pendingCaptureUri
410 pendingCaptureUri = null
411 if (success && uri != null) stageUris(listOf(uri))
412 }
414 val voiceLauncher = rememberLauncherForActivityResult(
415 ActivityResultContracts.StartActivityForResult(),
416 ) { result ->
417 if (result.resultCode == Activity.RESULT_OK) {
418 val spoken = result.data
419 ?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)
420 ?.firstOrNull()
421 ?.takeIf { it.isNotBlank() }
422 if (spoken != null) {
423 // Append to the existing draft rather than replacing, so dictation adds to
424 // whatever the user already typed.
425 val current = vm.draft.value
426 val combined = if (current.isBlank()) spoken else "$current $spoken"
427 vm.updateDraft(combined.take(NetworkConfig.maxDraftLengthChars))
428 }
429 }
430 }
432 fun launchCamera() {
433 val uri = newCameraCaptureUri(appContext)
434 if (uri == null) {
435 scope.launch { snackbar.showSnackbar(attachFailedMsg) }
436 return
437 }
438 pendingCaptureUri = uri
439 runCatching { cameraLauncher.launch(uri) }
440 .onFailure { pendingCaptureUri = null; scope.launch { snackbar.showSnackbar(noCameraMsg) } }
441 }
443 fun launchVoice() {
444 val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
445 putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
446 putExtra(RecognizerIntent.EXTRA_PROMPT, voicePrompt)
447 }
448 runCatching { voiceLauncher.launch(intent) }
449 .onFailure { scope.launch { snackbar.showSnackbar(noVoiceMsg) } }
450 }
452 // A freshly-created share link is copied to the clipboard so the user can paste it right away.
453 LaunchedEffect(Unit) {
454 vm.shareLinkEvents.collect { url ->
455 copyToClipboard(shareContext, shareContext.getString(R.string.clip_label_share), url)
456 snackbar.showSnackbar(linkCopiedMsg)
457 }
458 }
460 // Navigate to a session freshly branched from a message (branchFrom), once it's created.
461 // Keyed on Unit (like the sibling one-shot collectors above): keying on the onOpenSession
462 // lambda would cancel/restart this collector whenever the lambda's identity changes, risking
463 // a dropped branch-navigation event in the gap.
464 val branchedMsg = stringResource(R.string.branch_created)
465 LaunchedEffect(Unit) {
466 vm.branchEvents.collect { newId ->
467 showToast(shareContext, branchedMsg)
468 onOpenSession?.invoke(newId)
469 }
470 }
472 // Stage images shared into the app (via the system share sheet) as attachments.
473 // Keyed on pendingSharedMedia (not Unit) so the effect re-fires when new media arrives
474 // while this ChatScreen is already composed — which happens in two-pane mode, where the
475 // detail ChatScreen is persistent (keyed on sessionId) and a LaunchedEffect(Unit) would
476 // only fire on first composition. consumePendingSharedMedia() atomically drains the
477 // pending list (compareAndSet to empty), so re-firing on the now-empty value is a no-op.
478 val pendingSharedMedia by container.pendingSharedMedia.collectAsStateWithLifecycle()
479 LaunchedEffect(pendingSharedMedia) {
480 val shared = container.consumePendingSharedMedia()
481 if (shared.isNotEmpty()) stageUris(shared.map { Uri.parse(it) })
482 }
484 fun doSend() {
485 if (vm.send(draft)) {
486 // TextHandleMove (a light tick) is semantically right for a send commit;
487 // LongPress is reserved for long-press/swipe gestures.
488 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
489 }
490 }
492 BackHandler(enabled = running && !showModelPicker && !showAgentPicker && !showCommandPicker) { showExitConfirm = true }
494 Scaffold(
495 // Hardware-keyboard shortcuts (tablets / DeX / Chromebooks): Ctrl+K opens the command
496 // palette; Escape closes it, or stops a run. onPreviewKeyEvent on the Scaffold root sees
497 // events before the focused text field, but only Ctrl+K / Escape are consumed so normal
498 // typing (and the composer's own Enter-to-send handling) is untouched.
499 modifier = Modifier.onPreviewKeyEvent { ev ->
500 if (ev.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
501 when {
502 ev.isCtrlPressed && ev.key == Key.K -> { showPalette = true; true }
503 // Ctrl+F opens in-conversation search (the standard find shortcut), focusing
504 // the field so the user can type immediately.
505 ev.isCtrlPressed && ev.key == Key.F -> { searchActive = true; true }
506 ev.key == Key.Escape && showPalette -> { showPalette = false; true }
507 ev.key == Key.Escape && searchActive -> { searchActive = false; chatSearch = ""; true }
508 ev.key == Key.Escape && running && !showStopConfirm && !showExitConfirm -> {
509 showStopConfirm = true; true
510 }
511 else -> false
512 }
513 },
514 topBar = {
515 TopAppBar(
516 title = {
517 val changeLabel = stringResource(R.string.change_model_agent)
518 // The subtitle reads "model · agent", so tapping the title offers *both*
519 // — a small menu picks which picker to open. Previously the tap went
520 // straight to the model picker, leaving the agent changeable only via
521 // the overflow menu despite the title implying otherwise.
522 Box {
523 Row(
524 // mergeDescendants so TalkBack reads the title/subtitle Texts as one
525 // node; the click label rides on `clickable` (onClickLabel) instead of
526 // an explicit contentDescription, which would have SUPPRESSED the child
527 // Texts and announced only "Change model and agent".
528 modifier = Modifier
529 .clickable(
530 role = Role.Button,
531 onClickLabel = changeLabel,
532 ) { showTitleMenu = true }
533 .semantics(mergeDescendants = true) {},
534 verticalAlignment = Alignment.CenterVertically,
535 ) {
536 Column(modifier = Modifier.weight(1f, fill = false)) {
537 // maxLines=1 + Ellipsis so a long session title can't wrap and
538 // push the top bar to multiple lines, shifting the whole layout.
539 Text(
540 sessionTitle ?: sessionLabel,
541 maxLines = 1,
542 overflow = TextOverflow.Ellipsis,
543 )
544 Text(
545 subtitle,
546 style = MaterialTheme.typography.labelSmall,
547 color = MaterialTheme.colorScheme.onSurfaceVariant,
548 maxLines = 1,
549 overflow = TextOverflow.Ellipsis,
550 )
551 }
552 // Always show the affordance so the title reads as tappable even when
553 // the model catalog failed to load (models empty). Without it the title
554 // is indistinguishable from plain text and the pickers are effectively
555 // undiscoverable — and the dropdown items already disable themselves
556 // while a catalog is loading or unavailable.
557 Icon(
558 Icons.Filled.KeyboardArrowDown,
559 contentDescription = null,
560 tint = MaterialTheme.colorScheme.onSurfaceVariant,
561 modifier = Modifier.size(20.dp),
562 )
563 }
564 DropdownMenu(expanded = showTitleMenu, onDismissRequest = { showTitleMenu = false }) {
565 DropdownMenuItem(
566 text = { Text(stringResource(R.string.palette_switch_model)) },
567 enabled = !modelsLoading || models.isNotEmpty(),
568 onClick = { showTitleMenu = false; showModelPicker = true },
569 )
570 DropdownMenuItem(
571 text = { Text(stringResource(R.string.palette_choose_agent)) },
572 enabled = !agentsLoading || agents.isNotEmpty(),
573 onClick = { showTitleMenu = false; showAgentPicker = true },
574 )
575 }
576 }
577 },
578 navigationIcon = {
579 IconButton(onClick = {
580 if (running) showExitConfirm = true else onBack()
581 }) {
582 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
583 }
584 },
585 actions = {
586 // At-a-glance run status in the top bar. When the user is scrolled
587 // up (jump-to-latest FAB visible), the input-bar Stop button and the
588 // trailing "working…" row are off-screen, so a top-bar spinner is the
589 // only signal the agent is still working.
590 if (running) {
591 val workingLabel = stringResource(R.string.working)
592 CircularProgressIndicator(
593 Modifier.size(18.dp).semantics { contentDescription = workingLabel },
594 strokeWidth = 2.dp,
595 )
596 }
597 // Manual refresh: forces an SSE reconnect, which re-seeds messages
598 // from REST. A recovery path when the stream silently drops and the
599 // auto-reconnect re-seed is slow or fails. Shows a brief spinner as
600 // immediate tap feedback — the SSE reconnect may not visibly change
601 // the connection state when already Connected, so without it the tap
602 // appears to do nothing.
603 val refreshLabel = stringResource(R.string.refresh)
604 IconButton(
605 onClick = { vm.refreshMessages() },
606 enabled = activeConnection != null && !refreshing,
607 ) {
608 if (refreshing) {
609 CircularProgressIndicator(
610 Modifier.size(18.dp).semantics { contentDescription = refreshLabel },
611 strokeWidth = 2.dp,
612 )
613 } else {
614 Icon(Icons.Filled.Refresh, contentDescription = refreshLabel)
615 }
616 }
617 // Overflow menu consolidates the less-frequent actions (share,
618 // commands, agent, model, rename, delete) so the top bar stays
619 // scannable on narrow phones — previously 5 icons crowded the bar
620 // alongside the back button and tappable title. Catalog-loading
621 // spinners stay inline (next to the overflow) so a load state is
622 // visible without opening the menu.
623 val moreLabel = stringResource(R.string.more)
624 val loadingLabel = stringResource(R.string.loading)
625 val shareLabel = stringResource(R.string.share_conversation)
626 val commandsLabel = stringResource(R.string.commands)
627 val renameLabel = stringResource(R.string.rename_session_chat)
628 val deleteLabel = stringResource(R.string.delete_session_chat)
629 val createShareLabel = stringResource(R.string.create_share_link)
630 val copyShareLabel = stringResource(R.string.copy_share_link)
631 val stopShareLabel = stringResource(R.string.stop_sharing)
632 val summarizeLabel = stringResource(R.string.summarize_conversation)
633 val initLabel = stringResource(R.string.generate_agents_md)
634 val shellLabel = stringResource(R.string.run_shell_command)
635 Box {
636 IconButton(onClick = { showOverflowMenu = true }) {
637 Icon(Icons.Filled.MoreVert, contentDescription = moreLabel)
638 }
639 DropdownMenu(
640 expanded = showOverflowMenu,
641 onDismissRequest = { showOverflowMenu = false },
642 ) {
643 DropdownMenuItem(
644 text = { Text(shareLabel) },
645 enabled = hasMessages,
646 onClick = {
647 showOverflowMenu = false
648 scope.launch {
649 val md = withContext(Dispatchers.Default) {
650 buildConversationMarkdown(vm.messages.value, sessionTitle)
651 }
652 val send = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
653 type = "text/markdown"
654 putExtra(android.content.Intent.EXTRA_SUBJECT, sessionTitle ?: defaultShareSubject)
655 putExtra(android.content.Intent.EXTRA_TEXT, md)
656 }
657 runCatchingCancellable { shareContext.startActivity(android.content.Intent.createChooser(send, shareLabel)) }
658 .onFailure { showToast(shareContext, shareContext.getString(R.string.no_share_app)) }
659 }
660 },
661 )
662 DropdownMenuItem(
663 text = { Text(commandsLabel) },
664 enabled = !commandsLoading || commands.isNotEmpty(),
665 onClick = { showOverflowMenu = false; showCommandPicker = true },
666 )
667 DropdownMenuItem(
668 text = { Text(stringResource(R.string.keyboard_shortcuts)) },
669 onClick = { showOverflowMenu = false; showShortcutsDialog = true },
670 )
671 DropdownMenuItem(
672 text = { Text(stringResource(R.string.find_in_conversation)) },
673 onClick = {
674 showOverflowMenu = false
675 searchActive = true
676 runCatching { inputFocusRequester.requestFocus() }
677 },
678 )
679 androidx.compose.material3.HorizontalDivider()
680 // Session sharing: create a public link, copy an existing one, or revoke it.
681 if (shareUrl == null) {
682 DropdownMenuItem(
683 text = { Text(createShareLabel) },
684 enabled = hasMessages,
685 onClick = { showOverflowMenu = false; vm.shareSession() },
686 )
687 } else {
688 DropdownMenuItem(
689 text = { Text(copyShareLabel) },
690 onClick = {
691 showOverflowMenu = false
692 shareUrl?.let {
693 copyToClipboard(shareContext, shareContext.getString(R.string.clip_label_share), it)
694 scope.launch { snackbar.showSnackbar(linkCopiedMsg) }
695 }
696 },
697 )
698 DropdownMenuItem(
699 text = { Text(stopShareLabel) },
700 onClick = { showOverflowMenu = false; vm.unshareSession() },
701 )
702 }
703 DropdownMenuItem(
704 text = { Text(summarizeLabel) },
705 enabled = hasMessages && !running,
706 onClick = { showOverflowMenu = false; showSummarizeConfirm = true },
707 )
708 DropdownMenuItem(
709 text = { Text(initLabel) },
710 enabled = !running,
711 onClick = { showOverflowMenu = false; showInitConfirm = true },
712 )
713 DropdownMenuItem(
714 text = { Text(shellLabel) },
715 enabled = !running,
716 onClick = { showOverflowMenu = false; showShellDialog = true },
717 )
718 androidx.compose.material3.HorizontalDivider()
719 DropdownMenuItem(
720 text = { Text(renameLabel) },
721 onClick = { showOverflowMenu = false; showRenameDialog = true },
722 )
723 DropdownMenuItem(
724 text = { Text(deleteLabel, color = MaterialTheme.colorScheme.error) },
725 onClick = { showOverflowMenu = false; showDeleteDialog = true },
726 )
727 }
728 // While a catalog is initially loading (empty list + loading flag),
729 // show a tiny inline spinner beside the overflow so the user can see
730 // a load is in progress without opening the menu. Once loaded the
731 // spinner disappears; the menu items are always enabled (tapping a
732 // picker with no items shows the "no models/agents/commands" state).
733 val catalogLoading = (commandsLoading && commands.isEmpty()) ||
734 (agentsLoading && agents.isEmpty()) ||
735 (modelsLoading && models.isEmpty())
736 if (catalogLoading) {
737 CircularProgressIndicator(
738 Modifier
739 .align(Alignment.TopEnd)
740 .size(10.dp)
741 .semantics { contentDescription = loadingLabel },
742 strokeWidth = 1.dp,
743 )
744 }
745 }
746 },
747 )
748 },
749 snackbarHost = { SnackbarHost(snackbar) },
750 bottomBar = {
751 val queuedFollowUp by vm.queuedFollowUp.collectAsStateWithLifecycle()
752 val todoPlan by vm.todoPlan.collectAsStateWithLifecycle()
753 val queuedOffline by vm.outbox.collectAsStateWithLifecycle()
754 val outboxSending by container.outboxSending.collectAsStateWithLifecycle()
755 Column {
756 // Pinned above the composer so the agent's plan and progress stay visible
757 // while scrolled up mid-run; renders nothing when there's no plan.
758 TodoPlanBar(todoPlan)
759 val bannerMotion = rememberVisibilityTransitions()
760 AnimatedVisibility(
761 visible = reverted,
762 enter = bannerMotion.enter,
763 exit = bannerMotion.exit,
764 ) {
765 RevertBanner(
766 diff = revertDiff,
767 onUndo = { vm.unrevert() },
768 )
769 }
770 AnimatedVisibility(
771 visible = queuedOffline.isNotEmpty(),
772 enter = bannerMotion.enter,
773 exit = bannerMotion.exit,
774 ) {
775 OutboxBanner(
776 count = queuedOffline.size,
777 sending = outboxSending,
778 onFlush = { vm.flushQueued() },
779 onDiscard = { vm.discardAllQueued() },
780 )
781 }
782 ChatInputBar(
783 value = draft,
784 onValueChange = vm::updateDraft,
785 running = running,
786 aborting = aborting,
787 enabled = activeConnection != null,
788 sendOnEnter = sendOnEnter,
789 onSend = ::doSend,
790 onAbort = { showStopConfirm = true },
791 queuedFollowUp = queuedFollowUp,
792 onQueueFollowUp = vm::queueFollowUp,
793 onCancelQueue = { vm.queueFollowUp("") },
794 focusRequester = inputFocusRequester,
795 attachments = attachments,
796 staging = stagingCount > 0,
797 onRemoveAttachment = vm::removeAttachment,
798 onPickPhoto = {
799 photoPicker.launch(
800 PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly),
801 )
802 },
803 onPickFile = { filePicker.launch("*/*") },
804 onCamera = { launchCamera() },
805 onVoice = { launchVoice() },
806 onPasteImage = { stageUris(it) },
807 )
808 }
809 },
810 ) { padding ->
811 // Collect messages inside the content lambda so streaming token updates
812 // recompose only this subtree, not the top bar / input bar / 20+ other
813 // state reads in the ChatScreen body.
814 val messages by vm.messages.collectAsStateWithLifecycle()
815 val optimisticStatuses by vm.optimisticStatuses.collectAsStateWithLifecycle()
816 val listState = rememberLazyListState()
817 val contentScope = rememberCoroutineScope()
818 val timeTick = rememberRelativeTimeTick()
819 // Dismiss the soft keyboard when the user scrolls the message list, a standard Android
820 // chat affordance for reading while the IME is open. Watching isScrollInProgress via
821 // snapshotFlow avoids wiring a nested scroll connection and fires on any scroll gesture.
822 val keyboardController = androidx.compose.ui.platform.LocalSoftwareKeyboardController.current
823 androidx.compose.runtime.LaunchedEffect(listState) {
824 androidx.compose.runtime.snapshotFlow { listState.isScrollInProgress }
825 .distinctUntilChanged()
826 .collect { scrolling -> if (scrolling) keyboardController?.hide() }
827 } // Group messages by day and interleave date separators so a long conversation
828 // has visual "Today"/"Yesterday"/date breaks. Computed once per messages
829 // emission (memoized) so a scroll-induced recomposition doesn't re-scan. Lives
830 // outside the PullToRefreshBox so the auto-scroll effects below can reference
831 // the same list the LazyColumn renders (separators shift the indices vs. the
832 // raw messages list, so scroll targets must be in listItems space).
833 val todayLabel = stringResource(R.string.today)
834 val yesterdayLabel = stringResource(R.string.yesterday)
835 // In-conversation search: when active, narrow to messages whose text contains the query.
836 // Computed on the filtered set so day separators still align with the visible messages.
837 val searchQuery = chatSearch.trim()
838 val searchMessages = remember(messages, searchQuery) {
839 if (searchQuery.isEmpty()) messages
840 else messages.filter { m ->
841 m.parts.any { p -> (p as? TextPart)?.text?.contains(searchQuery, ignoreCase = true) == true }
842 }
843 }
844 // Keep the focused-match index in range as the query (and thus the match set) changes.
845 // A new query resets to the first match; a shrunk set clamps to the last valid index.
846 LaunchedEffect(searchQuery, searchMessages.size) {
847 if (searchPos >= searchMessages.size) searchPos = 0
848 }
849 val listItems = remember(searchMessages, todayLabel, yesterdayLabel) {
850 buildMessageListItems(searchMessages, todayLabel, yesterdayLabel)
851 }
852 // listItems is a plain (non-snapshot) local, rebuilt each recomposition. The
853 // derivedStateOf and snapshotFlow lambdas below are created once (keyless
854 // remember / LaunchedEffect(Unit)); reading `listItems` directly inside them
855 // would capture the first composition's list — which is empty, since `messages`
856 // starts empty — freezing it forever. That pinned isPinnedToBottom at true and
857 // AutoScrollSignal.size at 0, breaking streaming auto-scroll and the
858 // jump-to-latest FAB. rememberUpdatedState hands those lambdas a stable State
859 // whose value tracks the latest list.
860 val currentListItems by rememberUpdatedState(listItems)
862 // New-content signal for the jump-to-latest FAB: counts how many new items arrived
863 // while the user was scrolled away from the bottom, cleared once they return. A count
864 // (vs. a bare dot) tells the user how much they missed at a glance.
865 var newContentCount by remember { mutableIntStateOf(0) }
867 val isPinnedToBottom by remember {
868 derivedStateOf {
869 val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
870 // When a run is active, the LazyColumn has one trailing "__typing"
871 // item after the last list item. Account for it so "pinned to bottom"
872 // includes the working indicator — otherwise the pin check targets
873 // listItems.lastIndex, the typing row sits just below the viewport,
874 // and the user never sees the progress indicator even when pinned.
875 val items = currentListItems
876 val effectiveLast = if (running) items.size else items.lastIndex
877 lastVisible >= effectiveLast || items.isEmpty()
878 }
879 }
881 // Jump to the newest message once, when the conversation first loads. Guarded by a
882 // saveable one-shot flag so it doesn't re-fire on recomposition after a config change
883 // (rotation) — rememberLazyListState restores the user's scroll offset, and an
884 // unconditional scrollToItem here would clobber it, snapping them back to the bottom.
885 var didInitialScroll by rememberSaveable { mutableStateOf(false) }
886 LaunchedEffect(listItems.isNotEmpty()) {
887 if (listItems.isNotEmpty() && !didInitialScroll) {
888 // When a run is active the LazyColumn has a trailing "__typing" row at
889 // listItems.size; target it (not listItems.lastIndex) so deep-linking into a
890 // running session lands truly pinned to bottom and streaming auto-scroll engages.
891 listState.scrollToItem(if (running) listItems.size else listItems.lastIndex)
892 didInitialScroll = true
893 }
894 }
896 // When a run starts, the LazyColumn gains a trailing "__typing" row, so
897 // effectiveLast jumps from listItems.lastIndex to listItems.size and the
898 // isPinnedToBottom check flips to false the instant you send — freezing
899 // auto-scroll. If the user was pinned to the last item just before the run
900 // began, bring the typing row into view so the pin (and streaming follow) is
901 // preserved. Guard on the raw last-visible index (isPinnedToBottom has already
902 // recomputed to false by now) so we don't scroll when the user had scrolled up.
903 LaunchedEffect(running) {
904 if (running && listItems.isNotEmpty()) {
905 val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
906 if (lastVisible >= listItems.lastIndex) {
907 runCatchingCancellable { listState.animateScrollToItem(listItems.size) }
908 }
909 }
910 }
912 // Scroll the focused search match into view whenever the user steps through results with
913 // the up/down arrows. Resolves the filtered message to its slot in listItems (which
914 // includes date separators) and animates there.
915 LaunchedEffect(searchPos, searchQuery, listItems) {
916 if (!searchActive || searchQuery.isEmpty()) return@LaunchedEffect
917 val target = searchMessages.getOrNull(searchPos) ?: return@LaunchedEffect
918 val index = listItems.indexOfFirst {
919 it is MessageListItem.Message && it.message.info.id == target.info.id
920 }
921 if (index >= 0) runCatchingCancellable { listState.animateScrollToItem(index) }
922 }
924 LaunchedEffect(Unit) {
925 var prevSize = 0
926 var prevLen = 0
927 snapshotFlow {
928 // Track list size + the streaming length of the last part so we auto-scroll
929 // as content arrives, plus the pinned flag. A small data class with primitive
930 // fields avoids the per-frame boxing that Triple<Int,Int,Boolean> pays
931 // (snapshotFlow re-evaluates this lambda every snapshot), and reading only the
932 // last part's length is O(1) vs. a sumOf over every part. Covering reasoning
933 // and tool output — not just TextPart — keeps a pinned view following a long
934 // reasoning/tool block while it streams (previously it stalled at 0).
935 val lastLen = streamingContentLength(messages.lastOrNull()?.parts?.lastOrNull())
936 AutoScrollSignal(currentListItems.size, lastLen, isPinnedToBottom)
937 }.collect { signal ->
938 if (signal.pinned) {
939 // Back at the bottom: clear the new-content badge.
940 newContentCount = 0
941 if (signal.size > 0) {
942 // Scroll to the effective last index, including the trailing
943 // "__typing" row when a run is active so the working indicator
944 // is brought into view (not just the last message).
945 val items = currentListItems
946 val target = if (running) items.size else items.lastIndex
947 listState.scrollToItem(target)
948 }
949 } else if (signal.size > prevSize || signal.lastTextLength > prevLen) {
950 // Content arrived while scrolled up — increment the jump-to-latest badge.
951 // Only count whole new items (not per-token growth) so the number reflects
952 // new messages, not a token count.
953 if (signal.size > prevSize) newContentCount += signal.size - prevSize
954 else if (newContentCount == 0) newContentCount = 1
955 }
956 prevSize = signal.size
957 prevLen = signal.lastTextLength
958 }
959 }
961 CompositionLocalProvider(LocalRelativeTimeTick provides timeTick) {
962 // The bottomBar (ChatInputBar) already has imePadding, and the Scaffold's
963 // content padding accounts for the bottomBar's raised height, so the message
964 // list is already above the keyboard. Adding imePadding here would double-apply
965 // the IME inset and push messages too far up.
966 Box(modifier = Modifier.fillMaxSize().padding(padding)) {
967 if (activeConnection == null) {
968 Column(
969 modifier = Modifier.align(Alignment.Center).padding(24.dp),
970 horizontalAlignment = Alignment.CenterHorizontally,
971 ) {
972 Text(
973 stringResource(R.string.not_connected),
974 color = MaterialTheme.colorScheme.error,
975 )
976 Spacer(Modifier.size(12.dp))
977 Button(onClick = { vm.reconnect() }, enabled = !reconnecting) {
978 if (reconnecting) {
979 CircularProgressIndicator(
980 Modifier.size(20.dp),
981 color = MaterialTheme.colorScheme.onPrimary,
982 strokeWidth = 2.dp,
983 )
984 } else {
985 Text(stringResource(R.string.reconnect))
986 }
987 }
988 }
989 } else {
990 val bannerVisible = !isOnline || connectionState != EventStreamClient.ConnectionState.Connected
991 // Measure the banner's actual height instead of hardcoding 44dp. At large
992 // accessibility font scales the banner's labelMedium text + spinner + Retry
993 // button exceed 44dp, so a fixed inset lets the first message hide behind it
994 // (the same hazard FileViewScreen's overlay calls out). onSizeChanged only
995 // fires while the banner is composed (visible), so gate the inset on
996 // bannerVisible and fall back to 44dp until the first measurement arrives.
997 var bannerHeightPx by remember { mutableIntStateOf(0) }
998 val density = LocalDensity.current
999 val topPad = if (bannerVisible) {
1000 if (bannerHeightPx > 0) {
1001 with(density) { bannerHeightPx.toDp() } + 4.dp
1002 } else {
1003 44.dp
1005 } else {
1006 16.dp
1008 ConnectionBanner(
1009 state = connectionState,
1010 modifier = Modifier.align(Alignment.TopCenter).onSizeChanged { bannerHeightPx = it.height },
1011 isOnline = isOnline,
1012 // On a hard failure, retry by forcing an SSE reconnect (which
1013 // re-seeds from REST). refreshMessages is the right recovery path
1014 // when the connection is present but the stream died; reconnect()
1015 // is the path when the whole connection is gone (handled by the
1016 // separate "Not connected" state below).
1017 onRetry = { vm.refreshMessages() },
1019 // Pull-to-refresh wraps ALL content states (loading, error, empty, list) so
1020 // recovery is a swipe away everywhere — not just when the message list is
1021 // populated. Previously PTR was only around the populated list, leaving the
1022 // error/loading states reachable only via a button tap.
1023 PullToRefreshBox(
1024 isRefreshing = refreshing,
1025 onRefresh = { vm.refreshMessages() },
1026 // Cap width and center on large screens for readability — full-width
1027 // bubbles stretch edge-to-edge on tablets, hurting line-length comfort.
1028 modifier = Modifier.fillMaxSize().wrapContentWidth(Alignment.CenterHorizontally)
1029 .widthIn(max = NetworkConfig.chatContentMaxWidthDp.dp),
1030 ) {
1031 // Reserve top space when the banner is visible so it doesn't overlap the
1032 // first message / state content. (topPad computed above.)
1033 if (loading && messages.isEmpty()) {
1034 val loadingLabel = stringResource(R.string.loading)
1035 // Skeleton bubbles pre-structure the layout so the perceived load is faster
1036 // than a bare centered spinner and the first real messages pop into an
1037 // already-shaped list instead of a blank-then-pop. Alpha pulses (gated by
1038 // reduced-motion) to convey ongoing work; the column carries the a11y label.
1039 val reducedMotion = LocalReducedMotion.current
1040 val transition = rememberInfiniteTransition(label = "skeleton")
1041 val pulse by transition.animateFloat(
1042 initialValue = 0.35f,
1043 targetValue = 0.7f,
1044 animationSpec = infiniteRepeatable(tween(900), RepeatMode.Reverse),
1045 label = "skeletonAlpha",
1047 val skeletonAlpha = if (reducedMotion) 0.5f else pulse
1048 val skeletonColor = MaterialTheme.colorScheme.surfaceVariant
1049 @Composable
1050 fun bar(widthFraction: Float) {
1051 Box(
1052 Modifier
1053 .fillMaxWidth(widthFraction)
1054 .height(14.dp)
1055 .clip(RoundedCornerShape(4.dp))
1056 .background(skeletonColor)
1057 .alpha(skeletonAlpha),
1060 Column(
1061 modifier = Modifier
1062 .align(Alignment.TopCenter)
1063 .padding(top = topPad)
1064 .padding(16.dp)
1065 .fillMaxWidth()
1066 .semantics { contentDescription = loadingLabel },
1067 verticalArrangement = Arrangement.spacedBy(14.dp),
1068 ) {
1069 repeat(4) { i ->
1070 val left = i % 2 == 0
1071 Row(
1072 modifier = Modifier.fillMaxWidth(),
1073 horizontalArrangement = if (left) Arrangement.Start else Arrangement.End,
1074 ) {
1075 Column(
1076 modifier = Modifier.fillMaxWidth(if (left) 0.7f else 0.55f),
1077 verticalArrangement = Arrangement.spacedBy(6.dp),
1078 ) {
1079 bar(1f)
1080 bar(0.8f)
1085 } else if (loadError && messages.isEmpty()) {
1086 // A failed load with nothing to show. Distinct from the empty
1087 // conversation state below — the conversation isn't empty, it
1088 // failed to load, and offering "Start a conversation" here is
1089 // misleading. Offer a Retry instead, mirroring SessionListScreen.
1090 Column(
1091 modifier = Modifier.align(Alignment.Center).padding(top = topPad).padding(24.dp),
1092 horizontalAlignment = Alignment.CenterHorizontally,
1093 ) {
1094 Text(
1095 stringResource(R.string.load_messages_failed),
1096 color = MaterialTheme.colorScheme.error,
1098 Spacer(Modifier.size(12.dp))
1099 Button(onClick = { vm.refreshMessages() }) {
1100 Text(stringResource(R.string.retry))
1103 } else if (messages.isEmpty() && running) {
1104 // A run just started but no parts have arrived yet — the empty
1105 // list with only the trailing "working…" row looks broken, so
1106 // surface a clear starting state until the first part streams in.
1107 val workingText = stringResource(R.string.working)
1108 Column(
1109 modifier = Modifier.align(Alignment.Center).padding(top = topPad).padding(24.dp),
1110 horizontalAlignment = Alignment.CenterHorizontally,
1111 ) {
1112 CircularProgressIndicator(Modifier.size(28.dp), strokeWidth = 3.dp)
1113 Spacer(Modifier.size(12.dp))
1114 Text(workingText, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
1116 } else if (messages.isEmpty()) {
1117 EmptyConversation(
1118 onSuggestion = {
1119 vm.updateDraft(it)
1120 // Focus the input so the user can edit the suggestion before
1121 // sending, instead of having to manually tap the field.
1122 runCatching { inputFocusRequester.requestFocus() }
1123 },
1124 modifier = Modifier.align(Alignment.Center).padding(top = topPad),
1126 } else {
1127 val lastMessageId = messages.lastOrNull()?.info?.id
1128 // Text-to-speech for reading assistant replies aloud; shuts down when the
1129 // message list leaves composition.
1130 val tts = rememberTtsController()
1131 val speakingMessageId by tts.speakingId
1132 val ttsState by tts.state
1133 LazyColumn(
1134 state = listState,
1135 modifier = Modifier.fillMaxSize(),
1136 // Extra bottom padding reserves room for the jump-to-latest FAB so it
1137 // never floats over the last message. Top padding grows when the
1138 // connection banner is visible so it doesn't overlap the first message.
1139 contentPadding = PaddingValues(
1140 start = 16.dp,
1141 top = topPad,
1142 end = 16.dp,
1143 bottom = 16.dp + NetworkConfig.chatListFabInsetDp.dp,
1144 ),
1145 verticalArrangement = Arrangement.spacedBy(16.dp),
1146 ) {
1147 items(
1148 items = listItems,
1149 key = { it.key },
1150 contentType = { it.contentType },
1151 ) { item ->
1152 // When in-conversation search is stepping through matches, highlight the
1153 // focused one's row so the user can spot it at a glance among the filtered
1154 // results (full in-text term highlighting would require threading the query
1155 // through the markdown renderer; the row highlight + scroll-to-match covers
1156 // the navigation use case desktop chat apps solve with next/prev).
1157 val focusedMatchId = if (searchActive && searchQuery.isNotEmpty()) {
1158 searchMessages.getOrNull(searchPos)?.info?.id
1159 } else null
1160 val isFocusedMatch = focusedMatchId != null &&
1161 item is MessageListItem.Message &&
1162 item.message.info.id == focusedMatchId
1163 // Default placement animation so inserted/moved rows glide in. Skipped
1164 // entirely under reduced motion so a streaming message growing in place
1165 // doesn't animate on every token (and respects the a11y preference).
1166 Box(
1167 Modifier
1168 .then(if (LocalReducedMotion.current) Modifier else Modifier.animateItem())
1169 .then(
1170 if (isFocusedMatch) {
1171 Modifier.background(
1172 MaterialTheme.colorScheme.secondaryContainer,
1173 MaterialTheme.shapes.medium,
1175 } else {
1176 Modifier
1177 },
1178 ),
1179 ) {
1180 when (item) {
1181 is MessageListItem.Separator -> DateSeparator(item.label)
1182 is MessageListItem.Message -> {
1183 val message = item.message
1184 // Only the last (streaming) message needs isRunning — it drives the
1185 // reasoning-block spinner. Passing the live flag to every bubble
1186 // makes all visible messages recompose whenever a run starts or stops.
1187 val modelLabel = remember(message.info, models) {
1188 (message.info as? soy.iko.opencode.data.model.AssistantMessage)
1189 ?.let { resolveModelLabel(it, models) }
1191 // Text used to drive swipe-to-reply (and pre-fill the quote). Empty for
1192 // image/code-only messages, which then opt out of the swipe gesture.
1193 val quoteText = remember(message.parts) {
1194 message.parts
1195 .filterIsInstance<TextPart>()
1196 .joinToString("\n\n") { it.text }
1197 .trim()
1198 .takeIf { it.isNotEmpty() }
1200 // Swipe-start-to-end reveals a reply affordance and triggers quote-reply,
1201 // snapping back (the message isn't dismissed — the quote fills the composer).
1202 // Only enabled when there's text to quote, so non-text messages don't
1203 // capture horizontal drags (e.g. inside a horizontally-scrollable code block).
1204 val swipeState = rememberSwipeToDismissBoxState(
1205 confirmValueChange = { value ->
1206 if (value == SwipeToDismissBoxValue.StartToEnd && quoteText != null) {
1207 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
1208 vm.quoteReply(quoteText)
1209 runCatching { inputFocusRequester.requestFocus() }
1211 false
1212 },
1214 SwipeToDismissBox(
1215 state = swipeState,
1216 enableDismissFromEndToStart = false,
1217 enableDismissFromStartToEnd = quoteText != null,
1218 backgroundContent = {
1219 Box(
1220 Modifier.fillMaxSize().padding(horizontal = 16.dp),
1221 contentAlignment = Alignment.CenterStart,
1222 ) {
1223 Icon(
1224 Icons.AutoMirrored.Filled.Reply,
1225 contentDescription = null,
1226 tint = MaterialTheme.colorScheme.onSurfaceVariant,
1229 },
1230 ) {
1231 MessageBubble(
1232 message,
1233 isRunning = running && message.info.id == lastMessageId,
1234 imageContext = imageContext,
1235 modelLabel = modelLabel,
1236 onOpenFile = onOpenFile,
1237 onRevert = { vm.revertTo(message.info.id) },
1238 onEdit = { text ->
1239 // editMessage reverts to before this message (hiding it and
1240 // everything after); the revert banner surfaces the rewind
1241 // with an Undo. Focus + scroll the composer into view like the
1242 // Quote action so the prefilled draft isn't off-screen.
1243 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
1244 vm.editMessage(message.info.id, text)
1245 runCatching { inputFocusRequester.requestFocus() }
1246 contentScope.launch {
1247 runCatchingCancellable {
1248 listState.animateScrollToItem(listItems.lastIndex.coerceAtLeast(0))
1251 },
1252 onSpeak = { text ->
1253 if (!tts.toggle(message.info.id, text)) {
1254 // TTS engine unavailable (init failed / not yet ready
1255 // / blank text): surface feedback so the button doesn't
1256 // appear dead. Throttled so a rapid double-tap (stop
1257 // then immediate re-tap before ready) doesn't spam.
1258 scope.launch { snackbar.showSnackbar(ttsUnavailableMsg) }
1260 },
1261 isSpeaking = message.info.id == speakingMessageId,
1262 ttsState = if (message.info.id == speakingMessageId) ttsState else TtsState.IDLE,
1263 onPause = { tts.pause() },
1264 onResume = { tts.resume() },
1265 onQuote = { text ->
1266 vm.quoteReply(text)
1267 runCatching { inputFocusRequester.requestFocus() }
1268 },
1269 onBranch = { text -> vm.branchFrom(text) },
1270 // Regenerate is only meaningful for a finished assistant reply
1271 // (not the actively-streaming one), so gate it on !running.
1272 onRegenerate = if (!running) {
1273 { vm.regenerate(message.info.id) }
1274 } else null,
1275 sendStatus = optimisticStatuses[message.info.id]?.let { failed ->
1276 if (failed) soy.iko.opencode.ui.chat.MessageSendStatus.FAILED
1277 else soy.iko.opencode.ui.chat.MessageSendStatus.SENDING
1278 },
1279 isEdited = message.info.time?.let { it.updated != null && it.updated != it.created } == true,
1280 onRetry = optimisticStatuses[message.info.id]?.let { failed ->
1281 if (failed) {
1282 { vm.retryOptimisticMessage(message.info.id) }
1283 } else {
1284 null
1286 },
1287 onDismiss = optimisticStatuses[message.info.id]?.let { failed ->
1288 if (failed) {
1289 { vm.dismissOptimistic(message.info.id) }
1290 } else {
1291 null
1293 },
1294 onShare = {
1295 // Per-message share: build a single-message Markdown
1296 // transcript off-thread and fire ACTION_SEND, mirroring the
1297 // whole-conversation share but scoped to just this message.
1298 scope.launch {
1299 val md = withContext(Dispatchers.Default) {
1300 buildMessageMarkdown(message)
1301 } ?: return@launch
1302 val send = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
1303 type = "text/markdown"
1304 putExtra(android.content.Intent.EXTRA_SUBJECT, sessionTitle ?: defaultShareSubject)
1305 putExtra(android.content.Intent.EXTRA_TEXT, md)
1307 runCatchingCancellable { shareContext.startActivity(android.content.Intent.createChooser(send, shareContext.getString(R.string.share_message))) }
1308 .onFailure { showToast(shareContext, shareContext.getString(R.string.no_share_app)) }
1310 },
1317 if (running) {
1318 item(key = "__typing") {
1319 val workingText = stringResource(R.string.working)
1320 // Elapsed-since-run chip: the typing row only composes while a run is
1321 // active, so anchoring the timer to first composition approximates the
1322 // run start and gives the user a sense of how long the agent has been
1323 // working — distinguishing a healthy long run from a hung one. Updates
1324 // once per second; the value is decorative (the a11y label is the row's).
1325 var startMs by remember { mutableLongStateOf(0L) }
1326 LaunchedEffect(Unit) { startMs = System.currentTimeMillis() }
1327 val elapsedMs by produceState(0L, startMs) {
1328 if (startMs == 0L) return@produceState
1329 while (true) {
1330 value = (System.currentTimeMillis() - startMs).coerceAtLeast(0L)
1331 delay(1000)
1334 val elapsedText = remember(elapsedMs) {
1335 val total = elapsedMs / 1000
1336 // Locale.US for stable digit formatting: on locales with
1337 // non-ASCII digits (ar-/fa-) the default locale would render
1338 // localized digits inside the Monospace block, breaking the
1339 // fixed-width alignment the monospace family was chosen for.
1340 String.format(java.util.Locale.US, "%d:%02d", total / 60, total % 60)
1342 Row(
1343 verticalAlignment = Alignment.CenterVertically,
1344 // Animate this slot in/out so the working row doesn't pop in and
1345 // jolt the message list when a run starts/stops — matching the
1346 // AnimatedVisibility used by every other transient banner/FAB here.
1347 modifier = Modifier
1348 .animateItem()
1349 .padding(vertical = 4.dp),
1350 ) {
1351 // Merge the indicator + label + clock into one TalkBack node so
1352 // it's announced once (the working label, then the elapsed time)
1353 // instead of "working… / working… / 0:14" with the label read
1354 // twice. The Stop IconButton below stays separately focusable.
1355 Row(
1356 verticalAlignment = Alignment.CenterVertically,
1357 modifier = Modifier.semantics(mergeDescendants = true) {
1358 contentDescription = "$workingText $elapsedText"
1359 },
1360 ) {
1361 CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
1362 Text(workingText, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp))
1363 Spacer(Modifier.size(8.dp))
1364 Icon(
1365 Icons.Filled.Schedule,
1366 contentDescription = null,
1367 modifier = Modifier.size(14.dp),
1368 tint = MaterialTheme.colorScheme.onSurfaceVariant,
1370 Text(
1371 elapsedText,
1372 style = MaterialTheme.typography.labelMedium,
1373 fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
1374 color = MaterialTheme.colorScheme.onSurfaceVariant,
1375 modifier = Modifier.padding(start = 4.dp),
1378 // A trailing Stop on the working row so the run can be aborted
1379 // without scrolling back to the composer (which may be off-screen
1380 // while the user reads the streaming output up-thread).
1381 Spacer(Modifier.weight(1f))
1382 val stopLabel = stringResource(R.string.stop)
1383 IconButton(
1384 onClick = { showStopConfirm = true },
1385 modifier = Modifier.semantics { contentDescription = stopLabel },
1386 ) {
1387 Icon(Icons.Filled.Stop, contentDescription = null)
1395 // Jump-to-latest affordance when the user has scrolled away during a stream.
1396 val fabMotion = rememberVisibilityTransitions()
1397 AnimatedVisibility(
1398 visible = !isPinnedToBottom && listItems.isNotEmpty(),
1399 enter = fabMotion.enter,
1400 exit = fabMotion.exit,
1401 modifier = Modifier.align(Alignment.BottomEnd),
1402 ) {
1403 // Badge the FAB when new content arrived while the user was scrolled up, so
1404 // they know there's something new to jump to (cleared once back at bottom).
1405 BadgedBox(
1406 badge = {
1407 if (newContentCount > 0) {
1408 val newContentLabel = stringResource(R.string.new_messages)
1409 Badge(modifier = Modifier.semantics { contentDescription = newContentLabel }) {
1410 Text(if (newContentCount > 99) "99+" else newContentCount.toString())
1413 },
1414 modifier = Modifier.padding(end = 16.dp, bottom = 16.dp),
1415 ) {
1416 ExtendedFloatingActionButton(
1417 onClick = {
1418 newContentCount = 0
1419 if (listItems.isNotEmpty()) {
1420 // Scroll to the effective last index, including the
1421 // trailing "__typing" row when a run is active.
1422 val target = if (running) listItems.size else listItems.lastIndex
1423 contentScope.launch { runCatchingCancellable { listState.animateScrollToItem(target) } }
1425 },
1426 // contentDescription null: the visible "Latest" text already labels
1427 // the FAB, so a description here makes TalkBack announce "Latest" twice.
1428 icon = { Icon(Icons.Filled.KeyboardArrowDown, contentDescription = null) },
1429 text = { Text(stringResource(R.string.latest)) },
1433 // Scroll-to-top affordance: a small FAB at the start edge, visible when the
1434 // user has scrolled down past the first few messages. Symmetric to the
1435 // jump-to-latest FAB, for long conversations where manual fling back is tedious.
1436 val scrolledDown by remember {
1437 derivedStateOf { listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 200 }
1439 AnimatedVisibility(
1440 visible = scrolledDown && listItems.size > 4,
1441 enter = fabMotion.enter,
1442 exit = fabMotion.exit,
1443 modifier = Modifier.align(Alignment.BottomStart),
1444 ) {
1445 SmallFloatingActionButton(
1446 onClick = {
1447 contentScope.launch { runCatchingCancellable { listState.animateScrollToItem(0) } }
1448 },
1449 modifier = Modifier.padding(start = 16.dp, bottom = 16.dp),
1450 ) {
1451 Icon(
1452 Icons.Filled.KeyboardArrowUp,
1453 contentDescription = stringResource(R.string.scroll_to_top),
1457 // In-conversation search bar. Overlays the list so typing filters messages in
1458 // place; the match count + a no-matches note give immediate feedback.
1459 val searchMotion = rememberVisibilityTransitions()
1460 AnimatedVisibility(
1461 visible = searchActive,
1462 enter = searchMotion.enter,
1463 exit = searchMotion.exit,
1464 modifier = Modifier.align(Alignment.TopCenter),
1465 ) {
1466 Surface(
1467 tonalElevation = 3.dp,
1468 modifier = Modifier
1469 .padding(horizontal = 12.dp, vertical = 4.dp)
1470 .fillMaxWidth(),
1471 ) {
1472 Row(
1473 modifier = Modifier.padding(start = 8.dp, end = 4.dp),
1474 verticalAlignment = Alignment.CenterVertically,
1475 ) {
1476 OutlinedTextField(
1477 value = chatSearch,
1478 onValueChange = { chatSearch = it },
1479 modifier = Modifier.weight(1f),
1480 placeholder = { Text(stringResource(R.string.search_in_conversation)) },
1481 singleLine = true,
1482 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
1483 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
1485 // Match counter: "N of M" where N is the focused match (1-based) and
1486 // M is the total matching messages. Only shown when there's a query.
1487 val matchCount = searchMessages.size
1488 Text(
1489 if (searchQuery.isNotEmpty() && matchCount > 0) {
1490 stringResource(R.string.search_match_count, searchPos + 1, matchCount)
1491 } else if (searchQuery.isNotEmpty()) {
1492 stringResource(R.string.search_match_count, 0, messages.size)
1493 } else "",
1494 style = MaterialTheme.typography.labelSmall,
1495 color = MaterialTheme.colorScheme.onSurfaceVariant,
1496 modifier = Modifier.padding(horizontal = 4.dp),
1498 // Step through matches (Previous / Next), disabled when there are
1499 // fewer than two to navigate. Wrap around at the ends so repeated
1500 // taps cycle through every hit.
1501 val prevLabel = stringResource(R.string.find_previous)
1502 val nextLabel = stringResource(R.string.find_next)
1503 IconButton(
1504 onClick = {
1505 if (matchCount > 0) {
1506 searchPos = (searchPos - 1 + matchCount) % matchCount
1508 },
1509 enabled = matchCount > 1,
1510 ) {
1511 Icon(Icons.Filled.KeyboardArrowUp, contentDescription = prevLabel)
1513 IconButton(
1514 onClick = {
1515 if (matchCount > 0) {
1516 searchPos = (searchPos + 1) % matchCount
1518 },
1519 enabled = matchCount > 1,
1520 ) {
1521 Icon(Icons.Filled.KeyboardArrowDown, contentDescription = nextLabel)
1523 IconButton(onClick = {
1524 searchActive = false
1525 chatSearch = ""
1526 }) {
1527 Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.clear_search))
1532 // When a search yields no matches (but the conversation isn't empty), say so
1533 // instead of showing a blank list.
1534 if (searchActive && searchQuery.isNotEmpty() && searchMessages.isEmpty() && messages.isNotEmpty()) {
1535 Text(
1536 stringResource(R.string.search_no_matches),
1537 style = MaterialTheme.typography.bodyMedium,
1538 color = MaterialTheme.colorScheme.onSurfaceVariant,
1539 modifier = Modifier.align(Alignment.Center),
1547 if (showExitConfirm) {
1548 AlertDialog(
1549 onDismissRequest = { showExitConfirm = false },
1550 title = { Text(stringResource(R.string.stop_and_exit_title)) },
1551 text = { Text(stringResource(R.string.stop_and_exit_text)) },
1552 confirmButton = {
1553 TextButton(onClick = {
1554 showExitConfirm = false
1555 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
1556 vm.abort()
1557 onBack()
1558 }) { Text(stringResource(R.string.stop_and_exit)) }
1559 },
1560 dismissButton = {
1561 TextButton(onClick = { showExitConfirm = false }) { Text(stringResource(R.string.stay)) }
1562 },
1566 if (showStopConfirm) {
1567 AlertDialog(
1568 onDismissRequest = { showStopConfirm = false },
1569 title = { Text(stringResource(R.string.stop_run_title)) },
1570 text = { Text(stringResource(R.string.stop_run_text)) },
1571 confirmButton = {
1572 TextButton(onClick = {
1573 showStopConfirm = false
1574 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
1575 vm.abort()
1576 }) { Text(stringResource(R.string.stop), color = MaterialTheme.colorScheme.error) }
1577 },
1578 dismissButton = {
1579 TextButton(onClick = { showStopConfirm = false }) { Text(stringResource(R.string.cancel)) }
1580 },
1584 if (showSummarizeConfirm) {
1585 AlertDialog(
1586 onDismissRequest = { showSummarizeConfirm = false },
1587 title = { Text(stringResource(R.string.summarize_confirm_title)) },
1588 text = { Text(stringResource(R.string.summarize_confirm_text)) },
1589 confirmButton = {
1590 TextButton(onClick = {
1591 showSummarizeConfirm = false
1592 vm.summarize()
1593 }) { Text(stringResource(R.string.action_continue)) }
1594 },
1595 dismissButton = {
1596 TextButton(onClick = { showSummarizeConfirm = false }) { Text(stringResource(R.string.cancel)) }
1597 },
1601 if (showInitConfirm) {
1602 AlertDialog(
1603 onDismissRequest = { showInitConfirm = false },
1604 title = { Text(stringResource(R.string.init_confirm_title)) },
1605 text = { Text(stringResource(R.string.init_confirm_text)) },
1606 confirmButton = {
1607 TextButton(onClick = {
1608 showInitConfirm = false
1609 vm.initProject()
1610 }) { Text(stringResource(R.string.action_continue)) }
1611 },
1612 dismissButton = {
1613 TextButton(onClick = { showInitConfirm = false }) { Text(stringResource(R.string.cancel)) }
1614 },
1618 if (showRenameDialog) {
1619 RenameSessionChatDialog(
1620 initialTitle = sessionTitle ?: "",
1621 onDismiss = { showRenameDialog = false },
1622 onConfirm = { newName ->
1623 showRenameDialog = false
1624 vm.renameSession(newName)
1625 },
1629 if (showDeleteDialog) {
1630 AlertDialog(
1631 onDismissRequest = { showDeleteDialog = false },
1632 title = { Text(stringResource(R.string.delete_session_chat_title)) },
1633 text = { Text(stringResource(R.string.delete_session_chat_text, sessionTitle ?: sessionLabel)) },
1634 confirmButton = {
1635 TextButton(onClick = {
1636 showDeleteDialog = false
1637 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
1638 vm.deleteSession()
1639 }) { Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) }
1640 },
1641 dismissButton = {
1642 TextButton(onClick = { showDeleteDialog = false }) { Text(stringResource(R.string.cancel)) }
1643 },
1647 if (showShellDialog) {
1648 ShellCommandDialog(
1649 onDismiss = { showShellDialog = false },
1650 onRun = { cmd ->
1651 showShellDialog = false
1652 vm.runShell(cmd)
1653 },
1657 if (showModelPicker) {
1658 ModelPickerSheet(
1659 options = models,
1660 selected = selectedModel,
1661 loading = modelsLoading,
1662 error = modelsError,
1663 onSelect = { vm.selectModel(it) },
1664 onRetry = { vm.reloadModels() },
1665 onDismiss = { showModelPicker = false },
1669 if (showAgentPicker) {
1670 AgentPickerSheet(
1671 agents = agents,
1672 selected = selectedAgent,
1673 loading = agentsLoading,
1674 error = agentsError,
1675 onSelect = { vm.selectAgent(it?.name) },
1676 onRetry = { vm.reloadAgents() },
1677 onDismiss = { showAgentPicker = false },
1681 if (showCommandPicker) {
1682 CommandPickerSheet(
1683 commands = commands,
1684 loading = commandsLoading,
1685 error = commandsError,
1686 onSelect = { vm.runCommand(it) },
1687 onRetry = { vm.reloadCommands() },
1688 onDismiss = { showCommandPicker = false },
1692 pendingPermission?.let { permission ->
1693 PermissionDialog(
1694 permission = permission,
1695 position = permissionProgress.position,
1696 total = permissionProgress.total,
1697 onRespond = { response -> vm.respondPermission(permission, response) },
1701 if (showPalette) {
1702 CommandPalette(actions = paletteActions, onDismiss = { showPalette = false })
1705 if (showShortcutsDialog) {
1706 // Surfaces the hardware-keyboard shortcuts (Ctrl+K palette, Escape stop, Enter send)
1707 // that are otherwise wired but undiscoverable. A simple key/value list; Enter behavior
1708 // depends on the "Send on Enter" setting, noted inline.
1709 AlertDialog(
1710 onDismissRequest = { showShortcutsDialog = false },
1711 title = { Text(stringResource(R.string.keyboard_shortcuts_title)) },
1712 text = {
1713 Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
1714 ShortcutRow("Ctrl + K", stringResource(R.string.shortcut_open_palette))
1715 ShortcutRow("Esc", stringResource(R.string.shortcut_stop_run))
1716 ShortcutRow("Esc", stringResource(R.string.shortcut_close))
1717 ShortcutRow("Enter", stringResource(R.string.shortcut_send))
1718 ShortcutRow("Shift + Enter / Ctrl + Enter", stringResource(R.string.shortcut_newline))
1720 },
1721 confirmButton = {
1722 TextButton(onClick = { showShortcutsDialog = false }) { Text(stringResource(R.string.action_continue)) }
1723 },
1728@Composable
1729private fun ShortcutRow(keys: String, desc: String) {
1730 Row(verticalAlignment = Alignment.CenterVertically) {
1731 Text(
1732 keys,
1733 style = MaterialTheme.typography.labelLarge,
1734 fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
1735 modifier = Modifier.widthIn(min = 160.dp),
1737 Text(
1738 desc,
1739 style = MaterialTheme.typography.bodyMedium,
1740 color = MaterialTheme.colorScheme.onSurfaceVariant,
1745@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
1746@Composable
1747private fun ChatInputBar(
1748 value: String,
1749 onValueChange: (String) -> Unit,
1750 running: Boolean,
1751 aborting: Boolean,
1752 enabled: Boolean,
1753 sendOnEnter: Boolean,
1754 onSend: () -> Unit,
1755 onAbort: () -> Unit,
1756 queuedFollowUp: String?,
1757 onQueueFollowUp: (String) -> Unit,
1758 onCancelQueue: () -> Unit,
1759 focusRequester: androidx.compose.ui.focus.FocusRequester,
1760 attachments: List<PendingAttachment>,
1761 staging: Boolean,
1762 onRemoveAttachment: (String) -> Unit,
1763 onPickPhoto: () -> Unit,
1764 onPickFile: () -> Unit,
1765 onCamera: () -> Unit,
1766 onVoice: () -> Unit,
1767 onPasteImage: (List<Uri>) -> Unit,
1768) {
1769 // Sendable when there's text OR at least one attachment (an image-only prompt is valid).
1770 val hasContent = value.isNotBlank() || attachments.isNotEmpty()
1771 // Full-screen editor state: opened when the user wants more space for a long prompt.
1772 var showFullScreenEditor by rememberSaveable { mutableStateOf(false) }
1773 Surface(tonalElevation = 3.dp, modifier = Modifier.imePadding()) {
1774 Column(modifier = Modifier.fillMaxWidth().windowInsetsPadding(WindowInsets.navigationBars)) {
1775 // A queued follow-up replaces the Stop button with a "queued" chip so the
1776 // user sees their message will be sent when the run finishes, and can cancel.
1777 if (queuedFollowUp != null) {
1778 val editLabel = stringResource(R.string.edit)
1779 Row(
1780 modifier = Modifier
1781 .fillMaxWidth()
1782 .padding(start = 12.dp, top = 6.dp, end = 4.dp),
1783 verticalAlignment = Alignment.CenterVertically,
1784 ) {
1785 // Tapping the chip reloads the queued text into the draft (and clears the
1786 // queue) so a typo spotted while queuing can be fixed without retyping.
1787 Column(
1788 modifier = Modifier
1789 .weight(1f)
1790 .clip(MaterialTheme.shapes.small)
1791 .clickable(role = Role.Button, onClickLabel = editLabel) {
1792 onValueChange(queuedFollowUp)
1793 onCancelQueue()
1795 .padding(vertical = 4.dp),
1796 ) {
1797 Text(
1798 stringResource(R.string.queued),
1799 style = MaterialTheme.typography.labelSmall,
1800 color = MaterialTheme.colorScheme.onSurfaceVariant,
1802 Text(
1803 queuedFollowUp,
1804 style = MaterialTheme.typography.bodySmall,
1805 color = MaterialTheme.colorScheme.onSurfaceVariant,
1806 maxLines = 1,
1807 overflow = TextOverflow.Ellipsis,
1810 TextButton(onClick = onCancelQueue, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp)) {
1811 Text(stringResource(R.string.cancel))
1815 // Staged attachments: horizontally-scrollable thumbnails/chips, each removable.
1816 AttachmentStrip(attachments, onRemove = onRemoveAttachment, staging = staging)
1817 Row(
1818 modifier = Modifier.fillMaxWidth().padding(8.dp),
1819 verticalAlignment = Alignment.Bottom,
1820 ) {
1821 // Attach menu: photo picker, camera, or any file.
1822 var showAttachMenu by remember { mutableStateOf(false) }
1823 Box {
1824 IconButton(onClick = { showAttachMenu = true }, enabled = enabled) {
1825 Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.add_attachment))
1827 DropdownMenu(expanded = showAttachMenu, onDismissRequest = { showAttachMenu = false }) {
1828 DropdownMenuItem(
1829 leadingIcon = { Icon(Icons.Filled.PhotoLibrary, contentDescription = null) },
1830 text = { Text(stringResource(R.string.attach_photo)) },
1831 onClick = { showAttachMenu = false; onPickPhoto() },
1833 DropdownMenuItem(
1834 leadingIcon = { Icon(Icons.Filled.PhotoCamera, contentDescription = null) },
1835 text = { Text(stringResource(R.string.attach_camera)) },
1836 onClick = { showAttachMenu = false; onCamera() },
1838 DropdownMenuItem(
1839 leadingIcon = { Icon(Icons.Filled.Description, contentDescription = null) },
1840 text = { Text(stringResource(R.string.attach_file)) },
1841 onClick = { showAttachMenu = false; onPickFile() },
1845 // Voice dictation: appends recognized speech to the draft.
1846 IconButton(onClick = onVoice, enabled = enabled) {
1847 Icon(Icons.Filled.Mic, contentDescription = stringResource(R.string.voice_input))
1849 // Expand to a full-screen editor for long prompts (the inline field caps at 6 lines).
1850 IconButton(onClick = { showFullScreenEditor = true }, enabled = enabled) {
1851 Icon(Icons.Filled.Expand, contentDescription = stringResource(R.string.expand_editor))
1853 OutlinedTextField(
1854 value = value,
1855 onValueChange = { v ->
1856 // Cap input length so a huge paste can't stall the UI. Shares are
1857 // capped separately in MainActivity; this guards typed/pasted drafts.
1858 onValueChange(v.take(NetworkConfig.maxDraftLengthChars))
1859 },
1860 modifier = Modifier
1861 .weight(1f)
1862 .focusRequester(focusRequester)
1863 .testTag("chat_input")
1864 // Intercept image content pasted/dropped into the field (keyboard paste
1865 // of a copied screenshot, drag from Files, etc.) and route it through the
1866 // same staging path as the attach menu. Non-image content (text) falls
1867 // through unchanged so normal typing/paste works.
1868 .contentReceiver(
1869 remember {
1870 androidx.compose.foundation.content.ReceiveContentListener { transferable ->
1871 val clipData = transferable.clipEntry?.clipData
1872 if (clipData != null) {
1873 val imageUris = (0 until clipData.itemCount).mapNotNull { i ->
1874 val item = clipData.getItemAt(i)
1875 item.uri?.takeIf { uri ->
1876 item.text == null && uri.toString().let { s ->
1877 s.startsWith("content:") || s.startsWith("file:")
1881 if (imageUris.isNotEmpty()) {
1882 onPasteImage(imageUris)
1885 transferable
1887 },
1889 .onPreviewKeyEvent { event ->
1890 if (event.type != KeyEventType.KeyDown || event.key != Key.Enter) return@onPreviewKeyEvent false
1891 // With "Send on Enter" on, Enter sends (Shift+Enter newlines).
1892 // With it off, Enter inserts a newline and Ctrl+Enter sends.
1893 val send = enterShouldSend(
1894 enabled = enabled,
1895 hasContent = hasContent,
1896 sendOnEnter = sendOnEnter,
1897 shift = event.isShiftPressed,
1898 ctrl = event.isCtrlPressed,
1900 if (send) {
1901 if (running) onQueueFollowUp(value) else onSend()
1902 true
1903 } else {
1904 false
1906 },
1907 // While a run is active a typed message only queues (sends after the run), so
1908 // the placeholder says so instead of the plain "Message…".
1909 placeholder = {
1910 Text(stringResource(if (running) R.string.composer_hint_running else R.string.message_placeholder))
1911 },
1912 enabled = enabled,
1913 maxLines = 6,
1914 // Show a "N / max" countdown once the draft crosses a high fraction of
1915 // the cap, so the user knows a paste is about to be truncated instead
1916 // of being silently cut off. Hidden for normal short prompts to avoid
1917 // clutter under a typical one-line message.
1918 supportingText = {
1919 val threshold = (NetworkConfig.maxDraftLengthChars * NetworkConfig.draftCountdownThresholdFraction).toInt()
1920 if (value.length >= threshold) {
1921 Text(
1922 stringResource(
1923 R.string.draft_chars_remaining,
1924 NetworkConfig.maxDraftLengthChars - value.length,
1925 NetworkConfig.maxDraftLengthChars,
1926 ),
1927 style = MaterialTheme.typography.labelSmall,
1928 color = if (value.length >= NetworkConfig.maxDraftLengthChars) MaterialTheme.colorScheme.error
1929 else MaterialTheme.colorScheme.onSurfaceVariant,
1932 },
1933 keyboardOptions = KeyboardOptions(
1934 // When "Send on Enter" is on, expose ImeAction.Send so the soft
1935 // keyboard shows a send key — matching the setting's intent and
1936 // giving touch-only users a keyboard send affordance. When off,
1937 // ImeAction.Default keeps the enter/newline key for multi-line input.
1938 imeAction = if (sendOnEnter) ImeAction.Send else ImeAction.Default,
1939 capitalization = KeyboardCapitalization.Sentences,
1940 ),
1941 keyboardActions = KeyboardActions(
1942 onSend = {
1943 if (enabled && hasContent) onSend()
1944 },
1945 ),
1947 ComposerTrailingButton(
1948 running = running,
1949 aborting = aborting,
1950 canSend = enabled && hasContent,
1951 canQueue = enabled && value.isNotBlank(),
1952 onSend = onSend,
1953 onAbort = onAbort,
1954 onQueue = { onQueueFollowUp(value) },
1958 if (showFullScreenEditor) {
1959 FullScreenEditor(
1960 value = value,
1961 onValueChange = onValueChange,
1962 onDismiss = { showFullScreenEditor = false },
1963 onSend = {
1964 showFullScreenEditor = false
1965 // Mirror the inline composer's behavior: while a run is active a send
1966 // can't go immediately, so queue the follow-up instead of silently
1967 // dropping it (vm.send() returns false during a run, which previously
1968 // lost the typed message with no feedback).
1969 if (running) onQueueFollowUp(value) else onSend()
1970 },
1971 canSend = enabled && hasContent,
1972 running = running,
1973 sendOnEnter = sendOnEnter,
1974 attachments = attachments,
1975 onRemoveAttachment = onRemoveAttachment,
1976 staging = staging,
1982/** Full-screen editor dialog for composing long prompts. Gives the full viewport height to
1983 * the text field (vs the inline 6-line cap) with a Send button and a collapse action. */
1984@OptIn(ExperimentalMaterial3Api::class)
1985@Composable
1986private fun FullScreenEditor(
1987 value: String,
1988 onValueChange: (String) -> Unit,
1989 onDismiss: () -> Unit,
1990 onSend: () -> Unit,
1991 canSend: Boolean,
1992 running: Boolean = false,
1993 sendOnEnter: Boolean = false,
1994 attachments: List<PendingAttachment> = emptyList(),
1995 onRemoveAttachment: (String) -> Unit = {},
1996 staging: Boolean = false,
1997) {
1998 androidx.compose.ui.window.Dialog(
1999 onDismissRequest = onDismiss,
2000 properties = androidx.compose.ui.window.DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false),
2001 ) {
2002 Scaffold(
2003 topBar = {
2004 androidx.compose.material3.TopAppBar(
2005 title = { Text(stringResource(R.string.message_placeholder)) },
2006 navigationIcon = {
2007 androidx.compose.material3.TextButton(onClick = onDismiss) {
2008 Text(stringResource(R.string.collapse_editor))
2010 },
2012 },
2013 bottomBar = {
2014 Surface(tonalElevation = 3.dp) {
2015 Row(
2016 modifier = Modifier.fillMaxWidth().imePadding().padding(12.dp),
2017 horizontalArrangement = Arrangement.End,
2018 ) {
2019 androidx.compose.material3.Button(onClick = onSend, enabled = canSend) {
2020 Text(stringResource(if (running) R.string.queue else R.string.send))
2024 },
2025 ) { padding ->
2026 Column(modifier = Modifier.fillMaxSize().padding(padding)) {
2027 // Show staged attachments in the full-screen editor too, so a user who staged
2028 // images then expanded for a long prompt can still see and remove them without
2029 // collapsing first (mirrors the inline composer's AttachmentStrip).
2030 AttachmentStrip(attachments, onRemove = onRemoveAttachment, staging = staging)
2031 OutlinedTextField(
2032 value = value,
2033 onValueChange = { v -> onValueChange(v.take(NetworkConfig.maxDraftLengthChars)) },
2034 modifier = Modifier
2035 .fillMaxSize()
2036 .weight(1f)
2037 // Honor the "Send on Enter" setting here too, so the full-screen
2038 // editor matches the inline composer: Enter sends (Shift+Enter
2039 // newlines) when on; Enter inserts a newline and Ctrl+Enter sends
2040 // when off. Previously this hard-coded ImeAction.Default and had no
2041 // key handler, so a hardware-keyboard user got a different behaviour
2042 // from the inline composer depending on which they happened to open.
2043 .onPreviewKeyEvent { event ->
2044 if (event.type != KeyEventType.KeyDown || event.key != Key.Enter) return@onPreviewKeyEvent false
2045 val send = enterShouldSend(
2046 enabled = canSend,
2047 hasContent = canSend,
2048 sendOnEnter = sendOnEnter,
2049 shift = event.isShiftPressed,
2050 ctrl = event.isCtrlPressed,
2052 if (send) { onSend(); true } else false
2053 },
2054 placeholder = { Text(stringResource(R.string.message_placeholder)) },
2055 maxLines = Int.MAX_VALUE,
2056 keyboardOptions = KeyboardOptions(
2057 imeAction = if (sendOnEnter) ImeAction.Send else ImeAction.Default,
2058 capitalization = KeyboardCapitalization.Sentences,
2059 ),
2060 keyboardActions = KeyboardActions(
2061 onSend = { if (canSend) onSend() },
2062 ),
2069/** Whether a hardware Enter keypress should send: never when disabled/empty; otherwise
2070 * Enter sends (unless Shift) when send-on-Enter is on, else only Ctrl+Enter sends. */
2071private fun enterShouldSend(
2072 enabled: Boolean,
2073 hasContent: Boolean,
2074 sendOnEnter: Boolean,
2075 shift: Boolean,
2076 ctrl: Boolean,
2077): Boolean = when {
2078 !enabled || !hasContent -> false
2079 sendOnEnter -> !shift
2080 else -> ctrl
2083/** The composer's trailing button: while a run is active a Stop (with in-flight spinner),
2084 * preceded by a Queue button when the user has typed a follow-up; otherwise Send. Extracted so
2085 * [ChatInputBar] stays under the complexity threshold. */
2086@Composable
2087private fun ComposerTrailingButton(
2088 running: Boolean,
2089 aborting: Boolean,
2090 canSend: Boolean,
2091 canQueue: Boolean,
2092 onSend: () -> Unit,
2093 onAbort: () -> Unit,
2094 onQueue: () -> Unit,
2095) {
2096 if (running) {
2097 Row(verticalAlignment = Alignment.CenterVertically) {
2098 // While a run is active the trailing action is Stop, so the only send affordance is
2099 // the keyboard's IME action — which is a plain newline when "Send on Enter" is off.
2100 // That leaves a touch-only user with the setting off no way to queue a follow-up
2101 // (Ctrl+Enter needs a hardware keyboard). Surface an explicit Queue button whenever
2102 // there's typed TEXT so queuing is always reachable, without changing the user's
2103 // Enter-inserts-a-newline preference. Gate on typed text — not the send-oriented
2104 // canSend, which is also true for an attachment-only composer — because queuing "" just
2105 // calls setQueuedFollowUp(null), silently cancelling any already-queued follow-up and
2106 // dropping the staged attachment (queued follow-ups don't carry attachments anyway).
2107 if (canQueue) {
2108 IconButton(
2109 onClick = onQueue,
2110 modifier = Modifier.testTag("queue_button"),
2111 ) {
2112 // PlaylistAdd reads as "add to a queue/list" more clearly than a clock
2113 // icon (which can be read as "snooze" or "later"), and isn't confused with
2114 // the Send action sitting right beside the Stop button.
2115 Icon(
2116 Icons.AutoMirrored.Filled.PlaylistAdd,
2117 contentDescription = stringResource(R.string.queue_followup),
2121 // Show a spinner while the abort REST call is in flight so the user sees the stop
2122 // was sent, and disable to prevent a double-tap.
2123 IconButton(
2124 onClick = onAbort,
2125 enabled = !aborting,
2126 modifier = Modifier.padding(start = 4.dp).testTag("stop_button"),
2127 ) {
2128 if (aborting) {
2129 val stopLabel = stringResource(R.string.stop)
2130 CircularProgressIndicator(
2131 Modifier.size(18.dp).semantics { contentDescription = stopLabel },
2132 strokeWidth = 2.dp,
2134 } else {
2135 Icon(Icons.Filled.Stop, contentDescription = stringResource(R.string.stop))
2139 } else {
2140 IconButton(
2141 onClick = onSend,
2142 enabled = canSend,
2143 modifier = Modifier.padding(start = 4.dp).testTag("send_button"),
2144 ) {
2145 Icon(Icons.AutoMirrored.Filled.Send, contentDescription = stringResource(R.string.send))
2150@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class)
2151@Composable
2152private fun EmptyConversation(
2153 onSuggestion: (String) -> Unit,
2154 modifier: Modifier = Modifier,
2155) {
2156 // A few example prompts so a first-time user has a one-tap path to their first
2157 // message instead of staring at a blank input. Tapping a chip fills the draft
2158 // (without sending) so the user can edit it before sending.
2159 val suggestions = remember {
2160 listOf(
2161 R.string.empty_chat_suggest_1,
2162 R.string.empty_chat_suggest_2,
2163 R.string.empty_chat_suggest_3,
2166 Column(
2167 modifier = modifier.padding(32.dp),
2168 horizontalAlignment = Alignment.CenterHorizontally,
2169 ) {
2170 Icon(
2171 Icons.Filled.AutoAwesome,
2172 contentDescription = null,
2173 modifier = Modifier.size(48.dp),
2174 tint = MaterialTheme.colorScheme.onSurfaceVariant,
2176 Spacer(Modifier.size(16.dp))
2177 Text(
2178 stringResource(R.string.empty_chat_title),
2179 style = MaterialTheme.typography.titleMedium,
2181 Spacer(Modifier.size(8.dp))
2182 Text(
2183 stringResource(R.string.empty_chat_hint),
2184 style = MaterialTheme.typography.bodyMedium,
2185 color = MaterialTheme.colorScheme.onSurfaceVariant,
2187 Spacer(Modifier.size(20.dp))
2188 // Suggestion chips wrap across rows on narrow screens instead of stacking full-width,
2189 // so a longer localized prompt doesn't push the chips off-screen on a phone.
2190 FlowRow(
2191 modifier = Modifier.fillMaxWidth(),
2192 horizontalArrangement = Arrangement.spacedBy(8.dp),
2193 verticalArrangement = Arrangement.spacedBy(8.dp),
2194 ) {
2195 suggestions.forEach { resId ->
2196 val text = stringResource(resId)
2197 androidx.compose.material3.AssistChip(
2198 onClick = { onSuggestion(text) },
2199 label = { Text(text, style = MaterialTheme.typography.bodyMedium) },
2206/** A banner shown while a revert checkpoint is active, offering an Undo (unrevert). When the
2207 * server included a diff of the revert, a collapsible "Show what changed" affordance reveals
2208 * it inline via [DiffView] so the user can see what was rolled back before deciding to undo. */
2209@Composable
2210private fun RevertBanner(diff: String?, onUndo: () -> Unit) {
2211 val haptics = LocalHapticFeedback.current
2212 var showDiff by rememberSaveable { mutableStateOf(false) }
2213 val expandMotion = rememberVisibilityTransitions()
2214 Surface(
2215 color = MaterialTheme.colorScheme.tertiaryContainer,
2216 tonalElevation = 2.dp,
2217 modifier = Modifier.fillMaxWidth(),
2218 ) {
2219 Column {
2220 Row(
2221 modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp),
2222 verticalAlignment = Alignment.CenterVertically,
2223 horizontalArrangement = Arrangement.SpaceBetween,
2224 ) {
2225 Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
2226 Icon(
2227 Icons.Filled.Restore,
2228 contentDescription = null,
2229 tint = MaterialTheme.colorScheme.onTertiaryContainer,
2230 modifier = Modifier.size(18.dp),
2232 Text(
2233 stringResource(R.string.reverted_banner),
2234 style = MaterialTheme.typography.bodySmall,
2235 color = MaterialTheme.colorScheme.onTertiaryContainer,
2236 modifier = Modifier.padding(start = 8.dp),
2238 // "Show what changed" toggle, shown only when the server provided a diff.
2239 // Collapsible so the banner stays compact by default; a user curious about
2240 // what the revert rolled back can expand it inline without leaving the chat.
2241 if (!diff.isNullOrBlank()) {
2242 TextButton(
2243 onClick = {
2244 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
2245 showDiff = !showDiff
2246 },
2247 contentPadding = androidx.compose.foundation.layout.PaddingValues(start = 8.dp, end = 4.dp),
2248 ) {
2249 Icon(
2250 if (showDiff) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
2251 contentDescription = null,
2252 modifier = Modifier.size(16.dp),
2254 Text(
2255 stringResource(if (showDiff) R.string.revert_hide_diff else R.string.revert_show_diff),
2256 style = MaterialTheme.typography.labelSmall,
2257 modifier = Modifier.padding(start = 4.dp),
2262 TextButton(onClick = {
2263 // Haptic on undo to match the other destructive/important confirmations: a
2264 // revert restores potentially hundreds of hidden messages.
2265 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
2266 onUndo()
2267 }) { Text(stringResource(R.string.undo)) }
2269 // The diff render lives inside the banner's Surface so it inherits the tertiary
2270 // tonal container and reads as part of the banner, not a floating panel.
2271 if (!diff.isNullOrBlank()) {
2272 AnimatedVisibility(
2273 visible = showDiff,
2274 enter = expandMotion.enter,
2275 exit = expandMotion.exit,
2276 ) {
2277 Column(modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 8.dp)) {
2278 Text(
2279 stringResource(R.string.revert_diff_header),
2280 style = MaterialTheme.typography.labelSmall,
2281 color = MaterialTheme.colorScheme.onTertiaryContainer,
2282 modifier = Modifier.padding(bottom = 4.dp),
2284 DiffView(diff, saveKey = "revertDiff")
2292/** A banner shown while messages composed offline are queued in the outbox, with a
2293 * "Send now" nudge and a discard. Shows a spinner while the queue is flushing. */
2294@Composable
2295private fun OutboxBanner(
2296 count: Int,
2297 sending: Boolean,
2298 onFlush: () -> Unit,
2299 onDiscard: () -> Unit,
2300) {
2301 Surface(
2302 color = MaterialTheme.colorScheme.secondaryContainer,
2303 tonalElevation = 2.dp,
2304 modifier = Modifier.fillMaxWidth(),
2305 ) {
2306 Row(
2307 modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp),
2308 verticalAlignment = Alignment.CenterVertically,
2309 horizontalArrangement = Arrangement.SpaceBetween,
2310 ) {
2311 Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
2312 if (sending) {
2313 CircularProgressIndicator(
2314 Modifier.size(16.dp),
2315 strokeWidth = 2.dp,
2316 color = MaterialTheme.colorScheme.onSecondaryContainer,
2318 Spacer(Modifier.size(8.dp))
2320 Text(
2321 if (sending) {
2322 stringResource(R.string.outbox_sending)
2323 } else {
2324 val res = if (count == 1) R.string.outbox_pending_one else R.string.outbox_pending_many
2325 stringResource(res, count)
2326 },
2327 style = MaterialTheme.typography.bodySmall,
2328 color = MaterialTheme.colorScheme.onSecondaryContainer,
2331 if (!sending) {
2332 val haptics = LocalHapticFeedback.current
2333 TextButton(onClick = onFlush) { Text(stringResource(R.string.outbox_flush)) }
2334 TextButton(onClick = {
2335 // Haptic on the irreversible "Discard queued" (drops every queued message
2336 // for this session), matching the error-colored destructive actions.
2337 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
2338 onDiscard()
2339 }) {
2340 Text(stringResource(R.string.outbox_discard_all), color = MaterialTheme.colorScheme.error)
2347/** Prompt for a one-off shell command to run in the session's worktree. */
2348@Composable
2349private fun ShellCommandDialog(onDismiss: () -> Unit, onRun: (String) -> Unit) {
2350 var command by rememberSaveable { mutableStateOf("") }
2351 AlertDialog(
2352 onDismissRequest = onDismiss,
2353 title = { Text(stringResource(R.string.run_shell_command)) },
2354 text = {
2355 OutlinedTextField(
2356 value = command,
2357 onValueChange = { command = it },
2358 modifier = Modifier.fillMaxWidth(),
2359 placeholder = { Text(stringResource(R.string.shell_command_hint)) },
2360 label = { Text(stringResource(R.string.shell_command_label)) },
2361 singleLine = true,
2362 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
2363 keyboardActions = KeyboardActions(onDone = { if (command.isNotBlank()) onRun(command.trim()) }),
2365 },
2366 confirmButton = {
2367 TextButton(
2368 onClick = { if (command.isNotBlank()) onRun(command.trim()) },
2369 enabled = command.isNotBlank(),
2370 ) { Text(stringResource(R.string.run)) }
2371 },
2372 dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } },
2376/**
2377 * Change signal for the auto-scroll-on-stream watcher. Primitive fields avoid the
2378 * boxing `Triple<Int, Int, Boolean>` would impose on every snapshot evaluation, and
2379 * equality is structural so `snapshotFlow` emits only when something relevant changes.
2380 */
2381private data class AutoScrollSignal(val size: Int, val lastTextLength: Int, val pinned: Boolean)
2383/** Length of the streaming content carried by [part], driving auto-scroll as it grows.
2384 * Covers text, reasoning, and tool output so a pinned view keeps following non-text
2385 * streaming, not just plain assistant text. O(1). */
2386private fun streamingContentLength(part: Part?): Int = when (part) {
2387 is TextPart -> part.text.length
2388 is ReasoningPart -> part.text.length
2389 is ToolPart -> when (val st = part.state) {
2390 is ToolCompleted -> st.output?.length ?: 0
2391 is ToolError -> st.error?.length ?: 0
2392 is ToolRunning -> st.title?.length ?: 0
2393 else -> 0
2395 else -> 0
2398/** Rename dialog for the currently-open session. Mirrors the SessionListScreen rename
2399 * dialog but lives in the chat screen so the user can rename without backing out to
2400 * the list. Pre-filled with the current title; the cap matches the list dialog. */
2401@Composable
2402private fun RenameSessionChatDialog(
2403 initialTitle: String,
2404 onDismiss: () -> Unit,
2405 onConfirm: (String) -> Unit,
2406) {
2407 // Seed the field once (no key input) so a later external title change — e.g. opencode
2408 // auto-generating a session title after the first prompt, arriving via SessionUpdated —
2409 // doesn't re-initialize the box and discard the user's in-progress edit.
2410 var title by rememberSaveable { mutableStateOf(initialTitle) }
2411 AlertDialog(
2412 onDismissRequest = onDismiss,
2413 title = { Text(stringResource(R.string.rename_session_chat)) },
2414 text = {
2415 OutlinedTextField(
2416 value = title,
2417 onValueChange = { v -> title = v.take(NetworkConfig.maxSessionTitleChars) },
2418 modifier = Modifier.fillMaxWidth(),
2419 placeholder = { Text(stringResource(R.string.session_title_hint)) },
2420 label = { Text(stringResource(R.string.rename_session_chat)) },
2421 singleLine = true,
2422 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
2423 keyboardActions = KeyboardActions(onDone = { if (title.isNotBlank()) onConfirm(title.trim()) }),
2425 },
2426 confirmButton = {
2427 TextButton(onClick = { if (title.isNotBlank()) onConfirm(title.trim()) }, enabled = title.isNotBlank()) {
2428 Text(stringResource(R.string.save))
2430 },
2431 dismissButton = {
2432 TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) }
2433 },
2437/**
2438 * A single item in the chat message list — either a [Message] bubble or a day
2439 * [Separator] inserted before the first message of a new day. The separator shifts
2440 * the LazyColumn indices vs. the raw messages list, so scroll-target math must use
2441 * the [List] of these items (not `messages.size`/`lastIndex`).
2442 */
2443private sealed interface MessageListItem {
2444 val key: Any
2445 val contentType: Any
2447 data class Message(val message: MessageWithParts, override val key: Any) : MessageListItem {
2448 override val contentType: Any get() = message.info::class
2451 data class Separator(val label: String, val ordinal: Int) : MessageListItem {
2452 // Key on the day-occurrence ordinal, not the label: two non-contiguous groups
2453 // can share a label (e.g. an untimestamped message — empty label — between two
2454 // same-day messages, or two untimestamped groups), and a label-only key would
2455 // then collide, which makes LazyColumn throw on duplicate keys. The ordinal is
2456 // stable for a given message ordering so slots aren't needlessly recreated.
2457 override val key: Any get() = "sep_${ordinal}_$label"
2458 // Separators share a contentType so the LazyColumn can recycle their slots.
2459 override val contentType: Any get() = "separator"
2463/**
2464 * Build the interleaved list of [MessageListItem]s for the message list, inserting a
2465 * [MessageListItem.Separator] before the first message of each new calendar day. Uses
2466 * the message's `time.created` (falling back to `updated` then `completed`) to bucket.
2467 * Messages with no timestamp are grouped under an empty-label separator only if they
2468 * start the list, so a server that omits timestamps doesn't suppress the first divider.
2470 * [todayLabel]/[yesterdayLabel] are resolved by the caller (a @Composable can't call
2471 * stringResource from inside this plain function) and used for the "Today"/"Yesterday"
2472 * labels; older days fall back to a locale-stable medium-date format.
2473 */
2474private fun buildMessageListItems(
2475 messages: List<MessageWithParts>,
2476 todayLabel: String,
2477 yesterdayLabel: String,
2478): List<MessageListItem> {
2479 if (messages.isEmpty()) return emptyList()
2480 val today = java.time.LocalDate.now(java.time.ZoneId.systemDefault())
2481 val dateFmt = mediumDateFormat()
2482 val result = ArrayList<MessageListItem>(messages.size + 4)
2483 val seenKeys = HashSet<String>(messages.size * 2)
2484 var lastDayKey: String? = null
2485 var sepOrdinal = 0
2486 var emptyIdOrdinal = 0
2487 var first = true
2488 for (message in messages) {
2489 val ts = message.info.time?.created ?: message.info.time?.updated ?: message.info.time?.completed
2490 val dayKey = ts?.let { dayKey(it) } ?: ""
2491 // Emit a separator before the very first message (so a leading un-timestamped message
2492 // still gets the first divider, empty label and all), and thereafter only when crossing
2493 // into a new NON-empty calendar day. An un-timestamped message mid-list (empty key)
2494 // neither emits its own separator nor resets the day — otherwise a following same-day
2495 // message would see a "" -> "<today>" transition and emit a duplicate "Today" header.
2496 if (first || (dayKey.isNotEmpty() && dayKey != lastDayKey)) {
2497 result.add(MessageListItem.Separator(dayLabel(dayKey, ts ?: 0L, today, todayLabel, yesterdayLabel, dateFmt), sepOrdinal++))
2499 if (dayKey.isNotEmpty()) lastDayKey = dayKey
2500 first = false
2501 // Most messages have a unique non-empty id. An unrecognized-role message can carry an
2502 // empty id — and the reducer intentionally keeps several such messages as distinct
2503 // holders — so give each a stable synthetic key and defensively de-collide any remaining
2504 // duplicate; otherwise LazyColumn throws on a duplicate key and the whole screen crashes.
2505 var key = message.info.id.ifEmpty { "msg-empty-${emptyIdOrdinal++}" }
2506 if (!seenKeys.add(key)) {
2507 var suffix = 1
2508 while (!seenKeys.add("$key#$suffix")) suffix++
2509 key = "$key#$suffix"
2511 result.add(MessageListItem.Message(message, key))
2513 return result
2516/**
2517 * A stable bucket key for a timestamp's calendar day (epoch-days as a string).
2519 * [buildMessageListItems] re-runs on every streaming snapshot (the memo is keyed on the
2520 * whole `messages` list, which must stay so the live message's fresh parts render), so a
2521 * naive implementation would re-allocate an `Instant`/`ZonedDateTime`/`LocalDate` for
2522 * every message on every token. Message timestamps are immutable once assigned, so we
2523 * memoize the parse per epoch-millis: after the first snapshot, each unchanged message is
2524 * a cheap hash lookup. Bounded LRU (access-order) caps memory; only touched on the main
2525 * thread during composition, so the coarse lock is uncontended.
2526 */
2527private val dayKeyCache = object : LinkedHashMap<Long, String>(128, 0.75f, true) {
2528 override fun removeEldestEntry(eldest: MutableMap.MutableEntry<Long, String>): Boolean = size > 1024
2531private fun dayKey(epochMillis: Long): String = synchronized(dayKeyCache) {
2532 dayKeyCache.getOrPut(epochMillis) {
2533 java.time.Instant.ofEpochMilli(epochMillis)
2534 .atZone(java.time.ZoneId.systemDefault()).toLocalDate().toEpochDay().toString()
2538// The medium-date formatter is an ICU locale lookup; cache one instance instead of
2539// allocating a fresh one per buildMessageListItems call, rebuilding only if the default
2540// locale changes at runtime. Main-thread-only, so no synchronization is needed.
2541private var cachedDateFmt: java.text.DateFormat? = null
2542private var cachedDateFmtLocale: java.util.Locale? = null
2544private fun mediumDateFormat(): java.text.DateFormat {
2545 val locale = java.util.Locale.getDefault()
2546 val existing = cachedDateFmt
2547 if (existing != null && cachedDateFmtLocale == locale) return existing
2548 return java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM).also {
2549 cachedDateFmt = it
2550 cachedDateFmtLocale = locale
2554/**
2555 * Resolve a day-key back to a human-readable label. "Today" / "Yesterday" for the
2556 * recent days, otherwise a locale-stable medium-date format. Returns an empty string
2557 * when [dayKey] is empty (no timestamp) so no separator is rendered for un-timestamped
2558 * messages.
2559 */
2560private fun dayLabel(
2561 dayKey: String,
2562 ts: Long,
2563 today: java.time.LocalDate,
2564 todayLabel: String,
2565 yesterdayLabel: String,
2566 dateFmt: java.text.DateFormat,
2567): String {
2568 if (dayKey.isEmpty()) return ""
2569 val epochDay = dayKey.toLongOrNull() ?: return ""
2570 val date = java.time.LocalDate.ofEpochDay(epochDay)
2571 return when {
2572 date == today -> todayLabel
2573 date == today.minusDays(1) -> yesterdayLabel
2574 else -> dateFmt.format(java.util.Date(ts))
2578/**
2579 * A centered day divider in the message list. Renders nothing for an empty label
2580 * (the no-timestamp case) so un-timestamped messages don't get a stray blank divider.
2581 */
2582@Composable
2583private fun DateSeparator(label: String) {
2584 if (label.isBlank()) return
2585 Row(
2586 modifier = Modifier.fillMaxWidth(),
2587 horizontalArrangement = Arrangement.Center,
2588 ) {
2589 Surface(
2590 color = MaterialTheme.colorScheme.surfaceVariant,
2591 shape = MaterialTheme.shapes.small,
2592 // Marked as a heading so TalkBack users can navigate day-by-day with the
2593 // heading-skip gesture — the canonical affordance for date dividers in a list.
2594 modifier = Modifier.semantics(mergeDescendants = true) { heading() },
2595 ) {
2596 Text(
2597 label,
2598 style = MaterialTheme.typography.labelSmall,
2599 color = MaterialTheme.colorScheme.onSurfaceVariant,
2600 modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),