1@file:Suppress("TooManyFunctions")
3package soy.iko.opencode.ui.chat
5import android.app.Activity
6import android.content.Intent
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.heightIn
33import androidx.compose.foundation.layout.height
34import androidx.compose.foundation.layout.imePadding
35import androidx.compose.foundation.layout.ime
36import androidx.compose.foundation.layout.navigationBars
37import androidx.compose.foundation.layout.padding
38import androidx.compose.foundation.layout.size
39import androidx.compose.foundation.layout.union
40import androidx.compose.foundation.layout.widthIn
41import androidx.compose.foundation.layout.windowInsetsPadding
42import androidx.compose.foundation.layout.wrapContentWidth
43import androidx.compose.foundation.lazy.LazyColumn
44import androidx.compose.foundation.lazy.items
45import androidx.compose.foundation.lazy.rememberLazyListState
46import androidx.compose.foundation.shape.RoundedCornerShape
47import androidx.compose.material.icons.Icons
48import androidx.compose.material.icons.automirrored.filled.ArrowBack
49import androidx.compose.material.icons.automirrored.filled.Send
50import androidx.compose.material.icons.filled.ErrorOutline
51import androidx.compose.material.icons.filled.Add
52import androidx.compose.material.icons.filled.AutoAwesome
53import androidx.compose.material.icons.filled.Delete
54import androidx.compose.material.icons.filled.Description
55import androidx.compose.material.icons.filled.Close
56import androidx.compose.material.icons.filled.Edit
57import androidx.compose.material.icons.filled.Expand
58import androidx.compose.material.icons.filled.FormatQuote
59import androidx.compose.material.icons.filled.ExpandLess
60import androidx.compose.material.icons.filled.ExpandMore
61import androidx.compose.material.icons.filled.Search
62import androidx.compose.material.icons.filled.KeyboardArrowDown
63import androidx.compose.material.icons.filled.KeyboardArrowUp
64import androidx.compose.material.icons.filled.Mic
65import androidx.compose.material.icons.filled.MoreVert
66import androidx.compose.material.icons.filled.PhotoCamera
67import androidx.compose.material.icons.filled.PhotoLibrary
68import androidx.compose.material.icons.filled.Refresh
69import androidx.compose.material.icons.filled.Restore
70import androidx.compose.material.icons.automirrored.filled.PlaylistAdd
71import androidx.compose.material.icons.automirrored.filled.Reply
72import androidx.compose.material.icons.filled.Schedule
73import androidx.compose.material.icons.filled.Stop
74import androidx.compose.material3.AlertDialog
75import androidx.compose.material3.Badge
76import androidx.compose.material3.BadgedBox
77import androidx.compose.material3.Button
78import androidx.compose.material3.CircularProgressIndicator
79import androidx.compose.material3.SmallFloatingActionButton
80import androidx.compose.material3.DropdownMenu
81import androidx.compose.material3.DropdownMenuItem
82import androidx.compose.material3.ExperimentalMaterial3Api
83import androidx.compose.material3.ExtendedFloatingActionButton
84import androidx.compose.material3.FloatingActionButton
85import androidx.compose.material3.Icon
86import androidx.compose.material3.IconButton
87import androidx.compose.material3.MaterialTheme
88import androidx.compose.material3.OutlinedTextField
89import androidx.compose.material3.Scaffold
90import androidx.compose.material3.SnackbarDuration
91import androidx.compose.material3.SwipeToDismissBox
92import androidx.compose.material3.SwipeToDismissBoxValue
93import androidx.compose.material3.rememberSwipeToDismissBoxState
94import androidx.compose.material3.SnackbarHost
95import androidx.compose.material3.SnackbarHostState
96import androidx.compose.material3.SnackbarResult
97import androidx.compose.material3.Surface
98import androidx.compose.material3.Text
99import androidx.compose.material3.TextButton
100import androidx.compose.material3.TopAppBar
101import androidx.compose.material3.pulltorefresh.PullToRefreshBox
102import androidx.compose.runtime.Composable
103import androidx.compose.runtime.CompositionLocalProvider
104import androidx.compose.runtime.DisposableEffect
105import androidx.compose.runtime.LaunchedEffect
106import androidx.compose.runtime.derivedStateOf
107import androidx.compose.runtime.getValue
108import androidx.compose.runtime.mutableIntStateOf
109import androidx.compose.runtime.mutableLongStateOf
110import androidx.compose.runtime.mutableStateOf
111import androidx.compose.runtime.produceState
112import androidx.compose.runtime.remember
113import androidx.compose.runtime.rememberCoroutineScope
114import androidx.compose.runtime.rememberUpdatedState
115import androidx.compose.runtime.saveable.rememberSaveable
116import androidx.compose.runtime.setValue
117import androidx.compose.runtime.snapshotFlow
118import androidx.compose.foundation.text.KeyboardActions
119import androidx.compose.foundation.text.KeyboardOptions
120import androidx.compose.ui.text.input.KeyboardCapitalization
121import androidx.compose.ui.Alignment
122import androidx.compose.ui.Modifier
123import androidx.compose.ui.draw.alpha
124import androidx.compose.ui.draw.clip
125import androidx.compose.ui.focus.focusRequester
126import androidx.compose.ui.input.key.Key
127import androidx.compose.ui.input.key.KeyEventType
128import androidx.compose.ui.input.key.isCtrlPressed
129import androidx.compose.ui.input.key.isShiftPressed
130import androidx.compose.ui.input.key.key
131import androidx.compose.ui.input.key.onPreviewKeyEvent
132import androidx.compose.ui.input.key.type
133import androidx.compose.ui.layout.onSizeChanged
134import androidx.compose.ui.platform.LocalContext
135import androidx.compose.ui.platform.LocalDensity
136import androidx.compose.ui.platform.LocalHapticFeedback
137import androidx.compose.ui.platform.LocalLayoutDirection
138import androidx.compose.ui.platform.LocalView
139import androidx.compose.ui.platform.testTag
140import androidx.compose.ui.unit.LayoutDirection
141import androidx.compose.ui.res.pluralStringResource
142import androidx.compose.ui.res.stringResource
143import androidx.compose.ui.semantics.Role
144import androidx.compose.ui.semantics.contentDescription
145import androidx.compose.ui.semantics.heading
146import androidx.compose.ui.semantics.liveRegion
147import androidx.compose.ui.semantics.LiveRegionMode
148import androidx.compose.ui.semantics.invisibleToUser
149import androidx.compose.ui.semantics.semantics
150import androidx.compose.ui.text.input.ImeAction
151import androidx.compose.ui.text.style.TextOverflow
152import androidx.compose.ui.unit.dp
153import androidx.lifecycle.compose.collectAsStateWithLifecycle
154import androidx.lifecycle.viewmodel.compose.viewModel
155import kotlinx.coroutines.Dispatchers
156import kotlinx.coroutines.coroutineScope
157import kotlinx.coroutines.delay
158import kotlinx.coroutines.flow.distinctUntilChanged
159import kotlinx.coroutines.launch
160import kotlinx.coroutines.withContext
161import soy.iko.opencode.data.model.Part
162import soy.iko.opencode.data.model.MessageWithParts
163import soy.iko.opencode.data.model.AssistantMessage
164import soy.iko.opencode.data.model.Command
165import soy.iko.opencode.data.model.ReasoningPart
166import soy.iko.opencode.data.model.TextPart
167import soy.iko.opencode.data.model.ToolCompleted
168import soy.iko.opencode.data.model.ToolError
169import soy.iko.opencode.data.model.ToolPart
170import soy.iko.opencode.data.model.ToolRunning
171import soy.iko.opencode.data.model.FilePart
172import soy.iko.opencode.data.model.sourcePath
173import soy.iko.opencode.data.model.inputElement
174import soy.iko.opencode.data.network.EventStreamClient
175import soy.iko.opencode.data.network.NetworkConfig
176import soy.iko.opencode.di.AppContainer
177import soy.iko.opencode.R
178import soy.iko.opencode.ui.components.CommandPalette
179import soy.iko.opencode.ui.components.ConnectionBanner
180import soy.iko.opencode.ui.components.DiffView
181import soy.iko.opencode.ui.components.PaletteAction
182import soy.iko.opencode.ui.components.LocalReducedMotion
183import soy.iko.opencode.ui.components.LocalSearchHighlight
184import soy.iko.opencode.ui.components.reducedMotionAnimateItem
185import soy.iko.opencode.ui.components.copyToClipboard
186import soy.iko.opencode.ui.components.LocalRelativeTimeTick
187import soy.iko.opencode.ui.components.rememberRelativeTimeTick
188import soy.iko.opencode.ui.components.rememberVisibilityTransitions
189import soy.iko.opencode.ui.components.showToast
190import soy.iko.opencode.ui.components.toImageContext
191import soy.iko.opencode.ui.vmFactory
192import soy.iko.opencode.util.runCatchingCancellable
194// Optional message id to scroll to and briefly highlight on first load (from global search).
195// Consumed once: after the message is scrolled into view and highlighted, the focus is
196// cleared so subsequent recompositions don't re-trigger the scroll.
197@OptIn(ExperimentalMaterial3Api::class)
200 container: AppContainer,
203 onOpenFile: ((String) -> Unit)? = null,
204 onOpenSession: ((String) -> Unit)? = null,
205 focusMessageId: String? = null,
206 onEditProfile: ((String) -> Unit)? = null,
208 val vm: ChatViewModel = viewModel(key = sessionId, factory = vmFactory { ChatViewModel(container, sessionId) })
209 val hasMessages by vm.hasMessages.collectAsStateWithLifecycle()
210 val running by vm.running.collectAsStateWithLifecycle()
211 val streamInterrupted by vm.streamInterrupted.collectAsStateWithLifecycle()
212 val aborting by vm.aborting.collectAsStateWithLifecycle()
213 val loading by vm.loading.collectAsStateWithLifecycle()
214 val loadError by vm.loadError.collectAsStateWithLifecycle()
215 val loadErrorInline by vm.loadErrorInline.collectAsStateWithLifecycle()
216 val refreshing by vm.refreshing.collectAsStateWithLifecycle()
217 val models by vm.models.collectAsStateWithLifecycle()
218 val modelsLoading by vm.modelsLoading.collectAsStateWithLifecycle()
219 val modelsError by vm.modelsError.collectAsStateWithLifecycle()
220 val selectedModel by vm.selectedModel.collectAsStateWithLifecycle()
221 val connectionState by vm.connectionState.collectAsStateWithLifecycle()
222 val reconnectAttempts by vm.reconnectAttempts.collectAsStateWithLifecycle()
223 val pendingPermission by vm.pendingPermission.collectAsStateWithLifecycle()
224 val permissionProgress by vm.permissionProgress.collectAsStateWithLifecycle()
225 val agents by vm.agents.collectAsStateWithLifecycle()
226 val agentsLoading by vm.agentsLoading.collectAsStateWithLifecycle()
227 val agentsError by vm.agentsError.collectAsStateWithLifecycle()
228 val selectedAgent by vm.selectedAgent.collectAsStateWithLifecycle()
229 val commands by vm.commands.collectAsStateWithLifecycle()
230 val commandsLoading by vm.commandsLoading.collectAsStateWithLifecycle()
231 val commandsError by vm.commandsError.collectAsStateWithLifecycle()
232 val sessionTitle by vm.sessionTitle.collectAsStateWithLifecycle()
233 val sessionDeleted by vm.sessionDeleted.collectAsStateWithLifecycle()
234 val draft by vm.draft.collectAsStateWithLifecycle()
235 val pendingQuote by vm.pendingQuote.collectAsStateWithLifecycle()
236 val attachments by vm.attachments.collectAsStateWithLifecycle()
237 val reverted by vm.reverted.collectAsStateWithLifecycle()
238 val editing by vm.editing.collectAsStateWithLifecycle()
239 val revertDiff by vm.revertDiff.collectAsStateWithLifecycle()
240 val shareUrl by vm.shareUrl.collectAsStateWithLifecycle()
241 val reconnecting by vm.reconnecting.collectAsStateWithLifecycle()
242 val sendOnEnter by container.settingsStore.sendOnEnter.collectAsStateWithLifecycle(initialValue = true)
243 val preferredModelId by container.settingsStore.preferredModelId.collectAsStateWithLifecycle(initialValue = "")
244 val recentModelEntries by container.recentModelsStore.entries.collectAsStateWithLifecycle()
245 val compactSpacing by container.settingsStore.compactMessageSpacing.collectAsStateWithLifecycle(initialValue = false)
246 val notifPermission by container.settingsStore.notifPermission.collectAsStateWithLifecycle(initialValue = true)
247 val isOnline by container.isOnline.collectAsStateWithLifecycle()
248 val haptics = LocalHapticFeedback.current
249 val layoutDirection = LocalLayoutDirection.current
250 val scope = rememberCoroutineScope()
251 val shareContext = LocalContext.current
252 val defaultShareSubject = stringResource(R.string.share_subject)
253 val sessionLabel = stringResource(R.string.session)
254 val defaultModelLabel = stringResource(R.string.default_model)
255 val subtitle = remember(selectedModel?.modelLabel, selectedAgent, defaultModelLabel) {
257 append(selectedModel?.modelLabel ?: defaultModelLabel)
258 selectedAgent?.let { append(" · $it") }
262 // Connection profile (with auth) for rendering inline image attachments.
263 // Collect as state so a server switch recomposes the image context.
264 val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
265 val imageContext = remember(activeConnection?.profile) {
266 activeConnection?.profile?.toImageContext()
269 // Mark this session as read while the user is viewing it; clear on leave so new
270 // background activity can badge it again.
271 DisposableEffect(sessionId) {
272 container.setCurrentSession(sessionId)
273 onDispose { if (container.currentSession.value == sessionId) container.setCurrentSession(null) }
276 // Navigate away when the session is deleted via SSE so the user isn't left
277 // on a zombie screen showing a conversation that no longer exists. Surface a
278 // toast (not a snackbar) because the screen is about to be popped — a snackbar
279 // would be torn down before the user could read it, while a toast survives.
280 // Covers both SSE-driven deletion (deleted on another device) and a user-
281 // initiated delete from the chat overflow menu (deleteSession in ChatViewModel
282 // sets the same flag on success).
283 val deletedContext = LocalContext.current
284 LaunchedEffect(sessionDeleted) {
285 if (sessionDeleted) {
286 showToast(deletedContext, deletedContext.getString(R.string.session_deleted_chat))
291 val snackbar = remember { SnackbarHostState() }
292 val retryLabel = stringResource(R.string.retry)
293 val inputFocusRequester = remember { androidx.compose.ui.focus.FocusRequester() }
294 // Dedicated focus requester for the in-conversation search field. The overflow menu's
295 // "Find in conversation" and Ctrl+F previously called inputFocusRequester.requestFocus()
296 // (the composer), so typing went to the composer instead of the search field.
297 val searchFocusRequester = remember { androidx.compose.ui.focus.FocusRequester() }
298 var showModelPicker by rememberSaveable { mutableStateOf(false) }
299 var showAgentPicker by rememberSaveable { mutableStateOf(false) }
300 var showTitleMenu by rememberSaveable { mutableStateOf(false) }
301 var showCommandPicker by rememberSaveable { mutableStateOf(false) }
302 var showExitConfirm by rememberSaveable { mutableStateOf(false) }
303 var showOverflowMenu by rememberSaveable { mutableStateOf(false) }
304 var showRenameDialog by rememberSaveable { mutableStateOf(false) }
305 var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
306 var showShellDialog by rememberSaveable { mutableStateOf(false) }
307 var showPalette by rememberSaveable { mutableStateOf(false) }
308 var showShortcutsDialog by rememberSaveable { mutableStateOf(false) }
309 var searchActive by rememberSaveable { mutableStateOf(false) }
310 var chatSearch by rememberSaveable { mutableStateOf("") }
311 // Index of the currently-focused in-conversation search match (into the filtered list).
312 // Drives the up/down navigation: the search bar's arrows step through matches and scroll
313 // the list to each one, mirroring desktop chat apps (the bare filtered list previously
314 // required reading each message to find the term).
315 var searchPos by rememberSaveable { mutableIntStateOf(0) }
316 // Summarize and init (generate AGENTS.md) are irreversible, so a single tap is gated behind
317 // a confirmation dialog (from both the overflow menu and the command palette).
318 var showSummarizeConfirm by rememberSaveable { mutableStateOf(false) }
319 var showInitConfirm by rememberSaveable { mutableStateOf(false) }
321 // Command-palette entries (keyboard-first: Ctrl+K). Labels resolved here so the remembered
322 // action list doesn't need a Composable context. Actions map to the same handlers the
323 // overflow menu drives, so the palette is a keyboard shortcut to them, not a parallel path.
324 val paletteModelLabel = stringResource(R.string.palette_switch_model)
325 val paletteAgentLabel = stringResource(R.string.palette_choose_agent)
326 val paletteCommandLabel = stringResource(R.string.palette_run_command)
327 val paletteSummarizeLabel = stringResource(R.string.palette_summarize)
328 val paletteRenameLabel = stringResource(R.string.rename_session_chat)
329 val paletteShellLabel = stringResource(R.string.run_shell_command)
330 val paletteActions = remember(
331 paletteModelLabel, paletteAgentLabel, paletteCommandLabel,
332 paletteSummarizeLabel, paletteRenameLabel, paletteShellLabel, hasMessages, running,
335 add(PaletteAction("model", paletteModelLabel) { showModelPicker = true })
336 add(PaletteAction("agent", paletteAgentLabel) { showAgentPicker = true })
337 add(PaletteAction("command", paletteCommandLabel) { showCommandPicker = true })
338 if (hasMessages && !running) add(PaletteAction("summarize", paletteSummarizeLabel) { showSummarizeConfirm = true })
339 if (!running) add(PaletteAction("shell", paletteShellLabel) { showShellDialog = true })
340 add(PaletteAction("rename", paletteRenameLabel) { showRenameDialog = true })
344 // Keep the screen awake while the agent is working in *this* session. The foreground
345 // service that holds process priority during a run is managed app-wide off
346 // container.anyRunActive (see OpencodeApp) so it survives navigating away from the chat
347 // mid-run — otherwise leaving the screen would drop priority and Doze could choke the
348 // SSE stream before the completion notification fires.
349 val currentView = LocalView.current
350 val appContext = LocalContext.current.applicationContext
351 DisposableEffect(running) {
352 currentView.keepScreenOn = running
353 onDispose { currentView.keepScreenOn = false }
356 LaunchedEffect(Unit) {
357 vm.errorEvents.collect { event ->
358 // Only a failed *send* is retryable; attaching Retry to any other error (e.g. a
359 // message-load failure) would silently re-submit the last prompt on tap.
360 val result = if (event.retryable) {
361 snackbar.showSnackbar(message = event.message, actionLabel = retryLabel)
363 snackbar.showSnackbar(event.message)
365 if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) vm.retryFailed()
369 // Stop-undo: abort() emits the last-sent prompt here so this collector can show a
370 // "Stopped — Undo" snackbar. Tapping Undo re-sends the prompt, recovering an accidental
371 // Stop without a confirmation dialog (Stop now happens on first tap).
372 val undoStopLabel = stringResource(R.string.undo)
373 val stoppedLabel = stringResource(R.string.run_stopped_undo)
374 LaunchedEffect(Unit) {
375 vm.stopUndoEvents.collect { prompt ->
376 if (prompt != null) {
377 val result = snackbar.showSnackbar(message = stoppedLabel, actionLabel = undoStopLabel)
378 if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) vm.resendLastPrompt()
383 // In-place Undo for a user-initiated session delete. The delete is deferred for an undo
384 // window on the container; show the snackbar over the conversation the user is viewing so
385 // they can cancel without being navigated to the session list first. If the window expires
386 // the scheduled delete sets `sessionDeleted` and the effect below navigates away.
387 val undoLabel = stringResource(R.string.undo)
388 val sessionDeletedLabel = stringResource(R.string.session_deleted_chat)
389 LaunchedEffect(Unit) {
390 vm.deleteUndoEvents.collect {
392 val dismisser = launch {
393 delay(NetworkConfig.undoDeleteDelayMs)
394 snackbar.currentSnackbarData?.dismiss()
396 val result = snackbar.showSnackbar(
397 message = sessionDeletedLabel,
398 actionLabel = undoLabel,
399 duration = SnackbarDuration.Indefinite,
402 if (result == SnackbarResult.ActionPerformed) vm.cancelSessionDelete()
407 // --- Attachments, voice, and share-link plumbing ---
408 val attachTooLargeMsg = stringResource(R.string.attachment_too_large)
409 val attachFailedMsg = stringResource(R.string.attachment_failed)
410 val attachLimitMsg = stringResource(R.string.attachment_limit)
411 val noCameraMsg = stringResource(R.string.no_camera_app)
412 val noVoiceMsg = stringResource(R.string.no_voice_app)
413 val voicePrompt = stringResource(R.string.voice_prompt)
414 val linkCopiedMsg = stringResource(R.string.link_copied)
415 val ttsUnavailableMsg = stringResource(R.string.tts_unavailable)
417 // Count of picks currently being read + base64-encoded off the main thread, so the
418 // composer can show a staging placeholder immediately (chips only materialize once done).
419 var stagingCount by remember { mutableStateOf(0) }
420 // Total number of files still being staged across all in-flight batches, so the
421 // staging chip can show "Staging N files…" rather than a bare indeterminate spinner.
422 // A separate counter from [stagingCount] (which tracks batches, not files) so a
423 // multi-file pick reads as more work than a single-file pick.
424 var stagingFileTotal by remember { mutableStateOf(0) }
426 // Convert each picked Uri to a base64 attachment off the main thread, honoring the
427 // per-prompt count cap and surfacing per-file errors without aborting the batch.
428 fun stageUris(uris: List<Uri>) {
429 if (uris.isEmpty()) return
432 stagingFileTotal += uris.size
434 uris.forEachIndexed { index, uri ->
435 if (vm.attachments.value.size >= NetworkConfig.maxAttachments) {
436 snackbar.showSnackbar(attachLimitMsg)
437 // The cap dropped the rest of this batch; account for the files we
438 // won't process so stagingFileTotal doesn't linger over-counted,
439 // then abandon the batch. return@launch (not return@forEachIndexed)
440 // so the remaining uris aren't re-checked against a never-relieved
441 // cap (which would over-decrement the counter and re-fire the snackbar
442 // once per leftover uri). The `finally` below still decrements
444 stagingFileTotal -= uris.size - index
447 when (val result = uri.toAttachmentResult(appContext)) {
448 is AttachmentResult.Ok -> vm.addAttachment(result.attachment)
449 AttachmentResult.TooLarge -> snackbar.showSnackbar(attachTooLargeMsg)
450 AttachmentResult.Failed -> snackbar.showSnackbar(attachFailedMsg)
460 val photoPicker = rememberLauncherForActivityResult(
461 ActivityResultContracts.PickMultipleVisualMedia(NetworkConfig.maxAttachments),
462 ) { uris -> stageUris(uris) }
464 val filePicker = rememberLauncherForActivityResult(
465 ActivityResultContracts.GetMultipleContents(),
466 ) { uris -> stageUris(uris) }
468 // Uri survives a config change mid-capture so the TakePicture result still resolves.
469 var pendingCaptureUri by rememberSaveable { mutableStateOf<Uri?>(null) }
470 val cameraLauncher = rememberLauncherForActivityResult(
471 ActivityResultContracts.TakePicture(),
473 val uri = pendingCaptureUri
474 pendingCaptureUri = null
475 if (success && uri != null) stageUris(listOf(uri))
478 val voiceLauncher = rememberLauncherForActivityResult(
479 ActivityResultContracts.StartActivityForResult(),
481 if (result.resultCode == Activity.RESULT_OK) {
482 val spoken = result.data
483 ?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)
485 ?.takeIf { it.isNotBlank() }
486 if (spoken != null) {
487 // Append to the existing draft rather than replacing, so dictation adds to
488 // whatever the user already typed.
489 val current = vm.draft.value
490 val combined = if (current.isBlank()) spoken else "$current $spoken"
491 vm.updateDraft(combined.take(NetworkConfig.maxDraftLengthChars))
497 val uri = newCameraCaptureUri(appContext)
499 scope.launch { snackbar.showSnackbar(attachFailedMsg) }
502 pendingCaptureUri = uri
503 runCatching { cameraLauncher.launch(uri) }
504 .onFailure { pendingCaptureUri = null; scope.launch { snackbar.showSnackbar(noCameraMsg) } }
508 val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
509 putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
510 putExtra(RecognizerIntent.EXTRA_PROMPT, voicePrompt)
512 runCatching { voiceLauncher.launch(intent) }
513 .onFailure { scope.launch { snackbar.showSnackbar(noVoiceMsg) } }
516 // A freshly-created share link is copied to the clipboard so the user can paste it right away.
517 LaunchedEffect(Unit) {
518 vm.shareLinkEvents.collect { url ->
519 copyToClipboard(shareContext, shareContext.getString(R.string.clip_label_share), url)
520 snackbar.showSnackbar(linkCopiedMsg)
524 // Navigate to a session freshly branched from a message (branchFrom), once it's created.
525 // Keyed on Unit (like the sibling one-shot collectors above): keying on the onOpenSession
526 // lambda would cancel/restart this collector whenever the lambda's identity changes, risking
527 // a dropped branch-navigation event in the gap.
528 val branchedMsg = stringResource(R.string.branch_created)
529 LaunchedEffect(Unit) {
530 vm.branchEvents.collect { newId ->
531 showToast(shareContext, branchedMsg)
532 onOpenSession?.invoke(newId)
536 // Stage images shared into the app (via the system share sheet) as attachments.
537 // Keyed on pendingSharedMedia (not Unit) so the effect re-fires when new media arrives
538 // while this ChatScreen is already composed — which happens in two-pane mode, where the
539 // detail ChatScreen is persistent (keyed on sessionId) and a LaunchedEffect(Unit) would
540 // only fire on first composition. consumePendingSharedMedia() atomically drains the
541 // pending list (compareAndSet to empty), so re-firing on the now-empty value is a no-op.
542 val pendingSharedMedia by container.pendingSharedMedia.collectAsStateWithLifecycle()
543 LaunchedEffect(pendingSharedMedia) {
544 val shared = container.consumePendingSharedMedia()
545 if (shared.isNotEmpty()) stageUris(shared.map { Uri.parse(it) })
549 if (vm.send(draft)) {
550 // TextHandleMove (a light tick) is semantically right for a send commit;
551 // LongPress is reserved for long-press/swipe gestures.
552 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
556 BackHandler(enabled = running && !showModelPicker && !showAgentPicker && !showCommandPicker) { showExitConfirm = true }
559 // Hardware-keyboard shortcuts (tablets / DeX / Chromebooks): Ctrl+K opens the command
560 // palette; Escape closes it, or stops a run. onPreviewKeyEvent on the Scaffold root sees
561 // events before the focused text field, but only Ctrl+K / Escape are consumed so normal
562 // typing (and the composer's own Enter-to-send handling) is untouched.
563 modifier = Modifier.onPreviewKeyEvent { ev ->
564 if (ev.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
566 ev.isCtrlPressed && ev.key == Key.K -> { showPalette = true; true }
567 // Ctrl+F opens in-conversation search (the standard find shortcut), focusing
568 // the field so the user can type immediately.
569 ev.isCtrlPressed && ev.key == Key.F -> { searchActive = true; true }
570 ev.key == Key.Escape && showPalette -> { showPalette = false; true }
571 ev.key == Key.Escape && searchActive -> { searchActive = false; chatSearch = ""; true }
572 ev.key == Key.Escape && running && !showExitConfirm -> {
581 val changeLabel = stringResource(R.string.change_model_agent)
582 // The subtitle reads "model · agent", so tapping the title offers *both*
583 // — a small menu picks which picker to open. Previously the tap went
584 // straight to the model picker, leaving the agent changeable only via
585 // the overflow menu despite the title implying otherwise.
588 // mergeDescendants so TalkBack reads the title/subtitle Texts as one
589 // node; the click label rides on `clickable` (onClickLabel) instead of
590 // an explicit contentDescription, which would have SUPPRESSED the child
591 // Texts and announced only "Change model and agent".
595 onClickLabel = changeLabel,
596 ) { showTitleMenu = true }
597 .semantics(mergeDescendants = true) {},
598 verticalAlignment = Alignment.CenterVertically,
600 Column(modifier = Modifier.weight(1f, fill = false)) {
601 // maxLines=1 + Ellipsis so a long session title can't wrap and
602 // push the top bar to multiple lines, shifting the whole layout.
604 sessionTitle ?: sessionLabel,
606 overflow = TextOverflow.Ellipsis,
610 style = MaterialTheme.typography.labelSmall,
611 color = MaterialTheme.colorScheme.onSurfaceVariant,
613 overflow = TextOverflow.Ellipsis,
616 // Always show the affordance so the title reads as tappable even when
617 // the model catalog failed to load (models empty). Without it the title
618 // is indistinguishable from plain text and the pickers are effectively
619 // undiscoverable — and the dropdown items already disable themselves
620 // while a catalog is loading or unavailable.
622 Icons.Filled.KeyboardArrowDown,
623 contentDescription = null,
624 tint = MaterialTheme.colorScheme.onSurfaceVariant,
625 modifier = Modifier.size(20.dp),
628 DropdownMenu(expanded = showTitleMenu, onDismissRequest = { showTitleMenu = false }) {
630 text = { Text(stringResource(R.string.palette_switch_model)) },
631 enabled = !modelsLoading || models.isNotEmpty(),
632 onClick = { showTitleMenu = false; showModelPicker = true },
635 text = { Text(stringResource(R.string.palette_choose_agent)) },
636 enabled = !agentsLoading || agents.isNotEmpty(),
637 onClick = { showTitleMenu = false; showAgentPicker = true },
643 IconButton(onClick = {
644 if (running) showExitConfirm = true else onBack()
646 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
650 // At-a-glance run status in the top bar. When the user is scrolled
651 // up (jump-to-latest FAB visible), the input-bar Stop button and the
652 // trailing "working…" row are off-screen, so a top-bar spinner is the
653 // only signal the agent is still working.
655 val workingLabel = stringResource(R.string.working)
656 CircularProgressIndicator(
657 Modifier.size(18.dp).semantics { contentDescription = workingLabel },
661 // Manual refresh: forces an SSE reconnect, which re-seeds messages
662 // from REST. A recovery path when the stream silently drops and the
663 // auto-reconnect re-seed is slow or fails. Shows a brief spinner as
664 // immediate tap feedback — the SSE reconnect may not visibly change
665 // the connection state when already Connected, so without it the tap
666 // appears to do nothing. Disabled while a run is active: a mid-run
667 // refresh forces a reconnect that can interrupt the streaming reply,
668 // and the working spinner already signals "wait" — the recovery path
669 // is only needed when nothing is actively streaming.
670 val refreshLabel = stringResource(R.string.refresh)
672 onClick = { vm.refreshMessages() },
673 enabled = activeConnection != null && !refreshing && !running,
676 CircularProgressIndicator(
677 Modifier.size(18.dp).semantics { contentDescription = refreshLabel },
681 Icon(Icons.Filled.Refresh, contentDescription = refreshLabel)
684 // Overflow menu consolidates the less-frequent actions (share,
685 // commands, agent, model, rename, delete) so the top bar stays
686 // scannable on narrow phones — previously 5 icons crowded the bar
687 // alongside the back button and tappable title. Catalog-loading
688 // spinners stay inline (next to the overflow) so a load state is
689 // visible without opening the menu.
690 val moreLabel = stringResource(R.string.more)
691 val loadingLabel = stringResource(R.string.loading)
692 val shareLabel = stringResource(R.string.share_conversation)
693 val copyAsMarkdownLabel = stringResource(R.string.copy_as_markdown)
694 val shareAsJsonLabel = stringResource(R.string.share_as_json)
695 val copyAsJsonLabel = stringResource(R.string.copy_as_json)
696 val commandsLabel = stringResource(R.string.commands)
697 val renameLabel = stringResource(R.string.rename_session_chat)
698 val deleteLabel = stringResource(R.string.delete_session_chat)
699 val createShareLabel = stringResource(R.string.create_share_link)
700 val copyShareLabel = stringResource(R.string.copy_share_link)
701 val stopShareLabel = stringResource(R.string.stop_sharing)
702 val summarizeLabel = stringResource(R.string.summarize_conversation)
703 val initLabel = stringResource(R.string.generate_agents_md)
704 val shellLabel = stringResource(R.string.run_shell_command)
706 IconButton(onClick = { showOverflowMenu = true }) {
707 Icon(Icons.Filled.MoreVert, contentDescription = moreLabel)
710 expanded = showOverflowMenu,
711 onDismissRequest = { showOverflowMenu = false },
714 text = { Text(shareLabel) },
715 enabled = hasMessages,
717 showOverflowMenu = false
719 val md = withContext(Dispatchers.Default) {
720 buildConversationMarkdown(vm.messages.value, sessionTitle)
722 val send = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
723 type = "text/markdown"
724 putExtra(android.content.Intent.EXTRA_SUBJECT, sessionTitle ?: defaultShareSubject)
725 putExtra(android.content.Intent.EXTRA_TEXT, md)
727 runCatchingCancellable { shareContext.startActivity(android.content.Intent.createChooser(send, shareLabel)) }
728 .onFailure { showToast(shareContext, shareContext.getString(R.string.no_share_app)) }
732 // Copy the whole conversation as Markdown to the clipboard, so a
733 // user can paste into another app without the share-sheet round-trip.
735 text = { Text(copyAsMarkdownLabel) },
736 enabled = hasMessages,
738 showOverflowMenu = false
740 val md = withContext(Dispatchers.Default) {
741 buildConversationMarkdown(vm.messages.value, sessionTitle)
743 copyToClipboard(shareContext, shareContext.getString(R.string.clip_label_message), md)
747 // Lossless JSON share: the full List<MessageWithParts> serialized with
748 // the shared OpencodeJson, so tool outputs, file sources, and reasoning
749 // survive verbatim (the Markdown export truncates/summarizes them).
751 text = { Text(shareAsJsonLabel) },
752 enabled = hasMessages,
754 showOverflowMenu = false
756 val json = withContext(Dispatchers.Default) {
757 buildConversationJson(vm.messages.value)
759 val send = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
760 type = "application/json"
761 putExtra(android.content.Intent.EXTRA_SUBJECT, sessionTitle ?: defaultShareSubject)
762 putExtra(android.content.Intent.EXTRA_TEXT, json)
764 runCatchingCancellable { shareContext.startActivity(android.content.Intent.createChooser(send, shareAsJsonLabel)) }
765 .onFailure { showToast(shareContext, shareContext.getString(R.string.no_share_app)) }
770 text = { Text(copyAsJsonLabel) },
771 enabled = hasMessages,
773 showOverflowMenu = false
775 val json = withContext(Dispatchers.Default) {
776 buildConversationJson(vm.messages.value)
778 copyToClipboard(shareContext, shareContext.getString(R.string.clip_label_message), json)
783 text = { Text(commandsLabel) },
784 enabled = !commandsLoading || commands.isNotEmpty(),
785 onClick = { showOverflowMenu = false; showCommandPicker = true },
788 text = { Text(stringResource(R.string.keyboard_shortcuts)) },
789 onClick = { showOverflowMenu = false; showShortcutsDialog = true },
792 text = { Text(stringResource(R.string.find_in_conversation)) },
794 showOverflowMenu = false
796 // Focus is requested by the LaunchedEffect(searchActive)
797 // below, which waits for the search field to be composed
798 // before requesting focus (calling requestFocus here would
799 // target a field that isn't in the composition yet).
802 androidx.compose.material3.HorizontalDivider()
803 // Session sharing: create a public link, copy an existing one, or revoke it.
804 if (shareUrl == null) {
806 text = { Text(createShareLabel) },
807 enabled = hasMessages,
808 onClick = { showOverflowMenu = false; vm.shareSession() },
812 text = { Text(copyShareLabel) },
814 showOverflowMenu = false
816 copyToClipboard(shareContext, shareContext.getString(R.string.clip_label_share), it)
817 scope.launch { snackbar.showSnackbar(linkCopiedMsg) }
822 text = { Text(stopShareLabel) },
823 onClick = { showOverflowMenu = false; vm.unshareSession() },
827 text = { Text(summarizeLabel) },
828 enabled = hasMessages && !running,
829 onClick = { showOverflowMenu = false; showSummarizeConfirm = true },
832 text = { Text(initLabel) },
834 onClick = { showOverflowMenu = false; showInitConfirm = true },
837 text = { Text(shellLabel) },
839 onClick = { showOverflowMenu = false; showShellDialog = true },
841 androidx.compose.material3.HorizontalDivider()
843 text = { Text(renameLabel) },
844 onClick = { showOverflowMenu = false; showRenameDialog = true },
847 text = { Text(deleteLabel, color = MaterialTheme.colorScheme.error) },
848 onClick = { showOverflowMenu = false; showDeleteDialog = true },
851 // While a catalog is initially loading (empty list + loading flag),
852 // show a tiny inline spinner beside the overflow so the user can see
853 // a load is in progress without opening the menu. Once loaded the
854 // spinner disappears; the menu items are always enabled (tapping a
855 // picker with no items shows the "no models/agents/commands" state).
856 val catalogLoading = (commandsLoading && commands.isEmpty()) ||
857 (agentsLoading && agents.isEmpty()) ||
858 (modelsLoading && models.isEmpty())
859 if (catalogLoading) {
860 CircularProgressIndicator(
862 .align(Alignment.TopEnd)
864 .semantics { contentDescription = loadingLabel },
872 snackbarHost = { SnackbarHost(snackbar) },
874 val queuedFollowUp by vm.queuedFollowUp.collectAsStateWithLifecycle()
875 val todoPlan by vm.todoPlan.collectAsStateWithLifecycle()
876 val queuedOffline by vm.outbox.collectAsStateWithLifecycle()
877 val outboxSending by container.outboxSending.collectAsStateWithLifecycle()
879 // Pinned above the composer so the agent's plan and progress stay visible
880 // while scrolled up mid-run; renders nothing when there's no plan.
881 TodoPlanBar(todoPlan)
882 val bannerMotion = rememberVisibilityTransitions()
885 enter = bannerMotion.enter,
886 exit = bannerMotion.exit,
891 onUndo = { vm.unrevert() },
895 visible = queuedOffline.isNotEmpty(),
896 enter = bannerMotion.enter,
897 exit = bannerMotion.exit,
900 count = queuedOffline.size,
901 sending = outboxSending,
902 onFlush = { vm.flushQueued() },
903 onDiscard = { vm.discardAllQueued() },
907 visible = pendingQuote != null,
908 enter = bannerMotion.enter,
909 exit = bannerMotion.exit,
911 pendingQuote?.let { QuoteReplyBanner(it, onCancel = { vm.cancelQuoteReply() }) }
915 onValueChange = vm::updateDraft,
918 enabled = activeConnection != null,
919 sendOnEnter = sendOnEnter,
921 onAbort = { vm.abort() },
922 queuedFollowUp = queuedFollowUp,
923 onQueueFollowUp = vm::queueFollowUp,
924 onCancelQueue = { vm.queueFollowUp("") },
925 focusRequester = inputFocusRequester,
926 attachments = attachments,
927 staging = stagingCount > 0,
928 stagingFileCount = stagingFileTotal,
929 onRemoveAttachment = vm::removeAttachment,
932 PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly),
935 onPickFile = { filePicker.launch("*/*") },
936 onCamera = { launchCamera() },
937 onVoice = { launchVoice() },
938 onPasteImage = { stageUris(it) },
940 onRunCommand = { cmd ->
942 // Clear the draft so the typed /prefix doesn't linger after the
943 // command fires (the command picker confirmation, if any, is
944 // handled inside runCommand).
951 // Collect messages inside the content lambda so streaming token updates
952 // recompose only this subtree, not the top bar / input bar / 20+ other
953 // state reads in the ChatScreen body.
954 val messages by vm.messages.collectAsStateWithLifecycle()
955 val optimisticStatuses by vm.optimisticStatuses.collectAsStateWithLifecycle()
956 val listState = rememberLazyListState()
957 val contentScope = rememberCoroutineScope()
958 val timeTick = rememberRelativeTimeTick()
959 // Dismiss the soft keyboard when the user scrolls the message list, a standard Android
960 // chat affordance for reading while the IME is open. Watching isScrollInProgress via
961 // snapshotFlow avoids wiring a nested scroll connection and fires on any scroll gesture.
962 val keyboardController = androidx.compose.ui.platform.LocalSoftwareKeyboardController.current
963 androidx.compose.runtime.LaunchedEffect(listState) {
964 androidx.compose.runtime.snapshotFlow { listState.isScrollInProgress }
965 .distinctUntilChanged()
966 .collect { scrolling -> if (scrolling) keyboardController?.hide() }
967 } // Group messages by day and interleave date separators so a long conversation
968 // has visual "Today"/"Yesterday"/date breaks. Computed once per messages
969 // emission (memoized) so a scroll-induced recomposition doesn't re-scan. Lives
970 // outside the PullToRefreshBox so the auto-scroll effects below can reference
971 // the same list the LazyColumn renders (separators shift the indices vs. the
972 // raw messages list, so scroll targets must be in listItems space).
973 val todayLabel = stringResource(R.string.today)
974 val yesterdayLabel = stringResource(R.string.yesterday)
975 // In-conversation search: when active, narrow to messages whose text contains the query.
976 // Computed on the filtered set so day separators still align with the visible messages.
977 // Debounced + off-main-thread so a keystroke-by-keystroke filter walk over a long
978 // conversation (with many tool parts whose state stringification is non-trivial)
979 // doesn't jank the UI. produceState yields the empty list immediately and publishes
980 // the filtered result once the debounce settles.
981 val searchQuery = chatSearch.trim()
982 val searchMessages by produceState(
983 initialValue = if (searchQuery.isEmpty()) messages else emptyList(),
987 if (searchQuery.isEmpty()) {
990 // Debounce: wait for the user to stop typing before running the filter,
991 // so each keystroke doesn't kick off a fresh scan.
992 kotlinx.coroutines.delay(NetworkConfig.chatSearchDebounceMs)
993 // Run the filter off the main thread — the walk stringifies tool state
994 // and scans every part of every message, which is non-trivial work for a
995 // long conversation.
996 value = withContext(Dispatchers.Default) {
997 messages.filter { m ->
1000 is TextPart -> p.text.contains(searchQuery, ignoreCase = true)
1001 is ReasoningPart -> p.text.contains(searchQuery, ignoreCase = true)
1003 // Match the tool name plus its running/completed/error text and the
1004 // stringified input, so searching for an error string or a file the
1005 // tool touched actually finds the message it appeared in.
1006 p.tool.contains(searchQuery, ignoreCase = true) ||
1007 (p.state is ToolRunning && p.state.title?.contains(searchQuery, ignoreCase = true) == true) ||
1008 (p.state is ToolCompleted && (p.state.title?.contains(searchQuery, ignoreCase = true) == true ||
1009 p.state.output?.contains(searchQuery, ignoreCase = true) == true)) ||
1010 (p.state is ToolError && p.state.error?.contains(searchQuery, ignoreCase = true) == true) ||
1011 p.state.inputElement()?.toString()?.contains(searchQuery, ignoreCase = true) == true
1013 is FilePart -> p.filename?.contains(searchQuery, ignoreCase = true) == true ||
1014 p.sourcePath?.contains(searchQuery, ignoreCase = true) == true
1022 // Keep the focused-match index in range as the query (and thus the match set) changes.
1023 // A new query resets to the first match; a shrunk set clamps to the last valid index.
1024 LaunchedEffect(searchQuery, searchMessages.size) {
1025 if (searchPos >= searchMessages.size) searchPos = 0
1027 val listItems = remember(searchMessages, todayLabel, yesterdayLabel) {
1028 buildMessageListItems(searchMessages, todayLabel, yesterdayLabel)
1030 // listItems is a plain (non-snapshot) local, rebuilt each recomposition. The
1031 // derivedStateOf and snapshotFlow lambdas below are created once (keyless
1032 // remember / LaunchedEffect(Unit)); reading `listItems` directly inside them
1033 // would capture the first composition's list — which is empty, since `messages`
1034 // starts empty — freezing it forever. That pinned isPinnedToBottom at true and
1035 // AutoScrollSignal.size at 0, breaking streaming auto-scroll and the
1036 // jump-to-latest FAB. rememberUpdatedState hands those lambdas a stable State
1037 // whose value tracks the latest list.
1038 val currentListItems by rememberUpdatedState(listItems)
1040 // New-content signal for the jump-to-latest FAB: counts how many new items arrived
1041 // while the user was scrolled away from the bottom, cleared once they return. A count
1042 // (vs. a bare dot) tells the user how much they missed at a glance.
1043 var newContentCount by remember { mutableIntStateOf(0) }
1045 val isPinnedToBottom by remember {
1047 val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
1048 // When a run is active, the LazyColumn has one trailing "__typing"
1049 // item after the last list item. Account for it so "pinned to bottom"
1050 // includes the working indicator — otherwise the pin check targets
1051 // listItems.lastIndex, the typing row sits just below the viewport,
1052 // and the user never sees the progress indicator even when pinned.
1053 val items = currentListItems
1054 val effectiveLast = if (running) items.size else items.lastIndex
1055 lastVisible >= effectiveLast || items.isEmpty()
1059 // Jump to the newest message once, when the conversation first loads. Guarded by a
1060 // saveable one-shot flag so it doesn't re-fire on recomposition after a config change
1061 // (rotation) — rememberLazyListState restores the user's scroll offset, and an
1062 // unconditional scrollToItem here would clobber it, snapping them back to the bottom.
1063 var didInitialScroll by rememberSaveable { mutableStateOf(false) }
1064 LaunchedEffect(listItems.isNotEmpty()) {
1065 if (listItems.isNotEmpty() && !didInitialScroll) {
1066 // When a run is active the LazyColumn has a trailing "__typing" row at
1067 // listItems.size; target it (not listItems.lastIndex) so deep-linking into a
1068 // running session lands truly pinned to bottom and streaming auto-scroll engages.
1069 listState.scrollToItem(if (running) listItems.size else listItems.lastIndex)
1070 didInitialScroll = true
1074 // When a run starts, the LazyColumn gains a trailing "__typing" row, so
1075 // effectiveLast jumps from listItems.lastIndex to listItems.size and the
1076 // isPinnedToBottom check flips to false the instant you send — freezing
1077 // auto-scroll. If the user was pinned to the last item just before the run
1078 // began, bring the typing row into view so the pin (and streaming follow) is
1079 // preserved. Guard on the raw last-visible index (isPinnedToBottom has already
1080 // recomputed to false by now) so we don't scroll when the user had scrolled up.
1081 LaunchedEffect(running) {
1082 if (running && listItems.isNotEmpty()) {
1083 val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
1084 if (lastVisible >= listItems.lastIndex) {
1085 runCatchingCancellable { listState.animateScrollToItem(listItems.size) }
1090 // Scroll the focused search match into view whenever the user steps through results with
1091 // the up/down arrows. Resolves the filtered message to its slot in listItems (which
1092 // includes date separators) and animates there.
1093 LaunchedEffect(searchPos, searchQuery, listItems) {
1094 if (!searchActive || searchQuery.isEmpty()) return@LaunchedEffect
1095 val target = searchMessages.getOrNull(searchPos) ?: return@LaunchedEffect
1096 val index = listItems.indexOfFirst {
1097 it is MessageListItem.Message && it.message.info.id == target.info.id
1099 if (index >= 0) runCatchingCancellable { listState.animateScrollToItem(index) }
1102 // Global-search deep link: scroll to and briefly highlight the matched message. The
1103 // focus id is threaded through the chat route from GlobalSearchScreen. The highlight
1104 // clears itself after a short delay so the user can re-read the surrounding context
1105 // without a persistent marker. Runs once per focusMessageId (cleared on consume).
1106 var focusedMessageId by remember(focusMessageId) { mutableStateOf(focusMessageId) }
1107 LaunchedEffect(focusedMessageId, listItems) {
1108 val focus = focusedMessageId ?: return@LaunchedEffect
1109 if (listItems.isEmpty()) return@LaunchedEffect
1110 val index = listItems.indexOfFirst {
1111 it is MessageListItem.Message && it.message.info.id == focus
1113 if (index < 0) return@LaunchedEffect
1114 runCatchingCancellable { listState.animateScrollToItem(index) }
1115 kotlinx.coroutines.delay(2500)
1116 focusedMessageId = null
1119 // Scroll to bottom when the IME opens and the user was already pinned to bottom, so the
1120 // latest reply stays visible as the composer rises. Without this the IME can obscure the
1121 // last message even though the user was reading it a moment ago. WindowInsets.ime is a
1122 // @Composable property, so we read it inside a snapshotFlow registered in the composable
1123 // scope; the flow re-emits whenever the IME inset changes (open/close transitions).
1124 val density = androidx.compose.ui.platform.LocalDensity.current
1125 var wasImeOpen by remember { mutableStateOf(false) }
1126 val imeBottomPx = WindowInsets.ime.getBottom(density)
1127 androidx.compose.runtime.LaunchedEffect(imeBottomPx, listItems.isNotEmpty(), isPinnedToBottom) {
1128 if (listItems.isEmpty()) return@LaunchedEffect
1129 val imeOpen = imeBottomPx > 0
1130 // Scroll to bottom only on the open transition (not close) and only when the user
1131 // was already pinned to bottom — otherwise we'd yank them out of a mid-history read.
1132 val shouldScroll = imeOpen && !wasImeOpen && isPinnedToBottom
1134 runCatchingCancellable {
1135 listState.animateScrollToItem(if (running) listItems.size else listItems.lastIndex)
1138 wasImeOpen = imeOpen
1141 LaunchedEffect(Unit) {
1145 // Track list size + the streaming length of the last part so we auto-scroll
1146 // as content arrives, plus the pinned flag. A small data class with primitive
1147 // fields avoids the per-frame boxing that Triple<Int,Int,Boolean> pays
1148 // (snapshotFlow re-evaluates this lambda every snapshot), and reading only the
1149 // last part's length is O(1) vs. a sumOf over every part. Covering reasoning
1150 // and tool output — not just TextPart — keeps a pinned view following a long
1151 // reasoning/tool block while it streams (previously it stalled at 0).
1152 val lastLen = streamingContentLength(messages.lastOrNull()?.parts?.lastOrNull())
1153 AutoScrollSignal(currentListItems.size, lastLen, isPinnedToBottom)
1154 }.collect { signal ->
1155 if (signal.pinned) {
1156 // Back at the bottom: clear the new-content badge.
1158 if (signal.size > 0) {
1159 // Scroll to the effective last index, including the trailing
1160 // "__typing" row when a run is active so the working indicator
1161 // is brought into view (not just the last message).
1162 val items = currentListItems
1163 val target = if (running) items.size else items.lastIndex
1164 listState.scrollToItem(target)
1166 } else if (signal.size > prevSize || signal.lastTextLength > prevLen) {
1167 // Content arrived while scrolled up — increment the jump-to-latest badge.
1168 // Only count whole new items (not per-token growth) so the number reflects
1169 // new messages, not a token count.
1170 if (signal.size > prevSize) newContentCount += signal.size - prevSize
1171 else if (newContentCount == 0) newContentCount = 1
1173 prevSize = signal.size
1174 prevLen = signal.lastTextLength
1178 CompositionLocalProvider(LocalRelativeTimeTick provides timeTick) {
1179 // The bottomBar (ChatInputBar) already has imePadding, and the Scaffold's
1180 // content padding accounts for the bottomBar's raised height, so the message
1181 // list is already above the keyboard. Adding imePadding here would double-apply
1182 // the IME inset and push messages too far up.
1184 // BoxWithConstraints so the FABs and search bar can align to the capped (800dp)
1185 // message list on wide screens instead of floating at the screen edges — on a
1186 // tablet the list is a centered column, so edge-aligned FABs are visually detached.
1187 // The horizontal margin below is applied to the FAB/search containers so they
1188 // track the list's left/right edges.
1189 androidx.compose.foundation.layout.BoxWithConstraints(
1190 modifier = Modifier.fillMaxSize().padding(padding),
1192 val screenMaxWidth = maxWidth
1193 val listMaxWidth = NetworkConfig.chatContentMaxWidthDp.dp
1194 // Horizontal margin between the capped list's edge and the screen edge (0 on
1195 // screens narrower than the cap). Applied as start/end padding so the FABs and
1196 // search bar sit at the list's edges, not the screen's.
1197 val sideMargin = ((screenMaxWidth - listMaxWidth) / 2).coerceAtLeast(0.dp)
1198 if (activeConnection == null) {
1200 modifier = Modifier.align(Alignment.Center).padding(24.dp),
1201 horizontalAlignment = Alignment.CenterHorizontally,
1204 stringResource(R.string.not_connected),
1205 color = MaterialTheme.colorScheme.error,
1207 Spacer(Modifier.size(12.dp))
1208 Button(onClick = { vm.reconnect() }, enabled = !reconnecting) {
1210 CircularProgressIndicator(
1211 Modifier.size(20.dp),
1212 color = MaterialTheme.colorScheme.onPrimary,
1216 Text(stringResource(R.string.reconnect))
1221 val bannerVisible = !isOnline || connectionState != EventStreamClient.ConnectionState.Connected
1222 // Measure the banner's actual height instead of hardcoding 44dp. At large
1223 // accessibility font scales the banner's labelMedium text + spinner + Retry
1224 // button exceed 44dp, so a fixed inset lets the first message hide behind it
1225 // (the same hazard FileViewScreen's overlay calls out). onSizeChanged only
1226 // fires while the banner is composed (visible), so gate the inset on
1227 // bannerVisible and fall back to 44dp until the first measurement arrives.
1228 var bannerHeightPx by remember { mutableIntStateOf(0) }
1229 // Track the search bar height so the list's top padding can grow when search
1230 // is active — otherwise a scrolled-to match lands at index 0, hidden behind
1231 // the floating search bar (the "scroll-under-the-search-bar" bug).
1232 var searchbarHeightPx by remember { mutableIntStateOf(0) }
1233 val density = LocalDensity.current
1234 val topPad = if (bannerVisible) {
1235 if (bannerHeightPx > 0) {
1236 with(density) { bannerHeightPx.toDp() } + 4.dp
1242 } + if (searchActive && searchbarHeightPx > 0) {
1243 with(density) { searchbarHeightPx.toDp() } + 4.dp
1248 state = connectionState,
1249 modifier = Modifier.align(Alignment.TopCenter).onSizeChanged { bannerHeightPx = it.height },
1250 isOnline = isOnline,
1251 // On a hard endpoint failure, retry by forcing an SSE reconnect (which
1252 // re-seeds from REST). refreshMessages is the right recovery path
1253 // when the connection is present but the stream died; reconnect()
1254 // is the path when the whole connection is gone (handled by the
1255 // separate "Not connected" state below).
1256 onRetry = { vm.refreshMessages() },
1257 // On an auth failure (401/403), retrying with the same bad credentials
1258 // is futile — offer a one-tap path to edit the active profile's
1259 // credentials instead of making the user hunt for the server list.
1260 onEditCredentials = {
1261 val profileId = activeConnection?.profile?.id
1262 if (profileId != null) onEditProfile?.invoke(profileId)
1264 reconnectAttempts = reconnectAttempts,
1266 // Persistent inline error banner for a mid-conversation load failure.
1267 // Unlike the one-shot snackbar (which fires once per streak then goes
1268 // silent), this stays visible until the stream recovers, so a persistent
1269 // outage doesn't read as "everything is fine" after the snackbar dismisses.
1270 // Anchored at the top, below the connection banner, so it doesn't overlap
1271 // the message list.
1272 if (loadErrorInline && hasMessages) {
1274 color = MaterialTheme.colorScheme.errorContainer,
1275 shape = MaterialTheme.shapes.small,
1277 .align(Alignment.TopCenter)
1278 .padding(top = if (bannerVisible) topPad else 8.dp)
1279 .padding(horizontal = 16.dp)
1280 .semantics { liveRegion = LiveRegionMode.Polite },
1283 modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
1284 verticalAlignment = Alignment.CenterVertically,
1285 horizontalArrangement = Arrangement.spacedBy(8.dp),
1288 Icons.Filled.ErrorOutline,
1289 contentDescription = null,
1290 modifier = Modifier.size(16.dp),
1291 tint = MaterialTheme.colorScheme.onErrorContainer,
1294 stringResource(R.string.load_error_persistent),
1295 style = MaterialTheme.typography.labelMedium,
1296 color = MaterialTheme.colorScheme.onErrorContainer,
1297 modifier = Modifier.weight(1f),
1300 onClick = { vm.refreshMessages() },
1301 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp),
1303 Text(stringResource(R.string.retry))
1308 // Pull-to-refresh wraps ALL content states (loading, error, empty, list) so
1309 // recovery is a swipe away everywhere — not just when the message list is
1310 // populated. Previously PTR was only around the populated list, leaving the
1311 // error/loading states reachable only via a button tap.
1313 isRefreshing = refreshing,
1314 // Suppress the refresh action while a run is active: a mid-stream
1315 // refresh forces an SSE reconnect that can interrupt the actively
1316 // streaming reply (matching the top-bar refresh button, which is
1317 // also disabled while running). The pull gesture still responds, but
1318 // the release no-ops instead of triggering a disruptive re-seed.
1319 onRefresh = { if (!running) vm.refreshMessages() },
1320 // Cap width and center on large screens for readability — full-width
1321 // bubbles stretch edge-to-edge on tablets, hurting line-length comfort.
1322 modifier = Modifier.fillMaxSize().wrapContentWidth(Alignment.CenterHorizontally)
1323 .widthIn(max = NetworkConfig.chatContentMaxWidthDp.dp),
1325 // Reserve top space when the banner is visible so it doesn't overlap the
1326 // first message / state content. (topPad computed above.)
1327 if (loading && messages.isEmpty()) {
1328 // Skeleton bubbles pre-structure the layout so the perceived load is faster
1329 // than a bare centered spinner and the first real messages pop into an
1330 // already-shaped list instead of a blank-then-pop.
1331 ChatSkeleton(topPad = topPad)
1332 } else if (loadError && messages.isEmpty()) {
1333 // A failed load with nothing to show. Distinct from the empty
1334 // conversation state below — the conversation isn't empty, it
1335 // failed to load, and offering "Start a conversation" here is
1336 // misleading. Offer a Retry instead, mirroring SessionListScreen.
1338 modifier = Modifier.align(Alignment.Center).padding(top = topPad).padding(24.dp),
1339 horizontalAlignment = Alignment.CenterHorizontally,
1342 stringResource(R.string.load_messages_failed),
1343 color = MaterialTheme.colorScheme.error,
1345 Spacer(Modifier.size(12.dp))
1346 Button(onClick = { vm.refreshMessages() }) {
1347 Text(stringResource(R.string.retry))
1349 // When the VM's retryWhen loop is actively retrying (loading is true
1350 // on top of the error state), surface a small "retrying…" indicator so
1351 // the user knows the system is working — without it, the static error
1352 // + Retry button reads as "broken, tap to fix", and the user may not
1353 // realize an automatic retry is already in progress.
1355 Spacer(Modifier.size(12.dp))
1356 val retryingLabel = stringResource(R.string.retrying_ellipsis)
1357 Row(verticalAlignment = Alignment.CenterVertically) {
1358 CircularProgressIndicator(
1359 Modifier.size(14.dp),
1364 style = MaterialTheme.typography.labelSmall,
1365 color = MaterialTheme.colorScheme.onSurfaceVariant,
1366 modifier = Modifier.padding(start = 6.dp),
1371 } else if (messages.isEmpty() && running) {
1372 // A run just started but no parts have arrived yet — the empty list with
1373 // only the trailing "working…" row looks broken. Reuse the skeleton bubbles
1374 // from the initial-load branch so the perceived start latency is lower and
1375 // the first real assistant part pops into an already-shaped list.
1376 ChatSkeleton(topPad = topPad)
1377 } else if (messages.isEmpty()) {
1381 // Focus the input so the user can edit the suggestion before
1382 // sending, instead of having to manually tap the field.
1383 runCatching { inputFocusRequester.requestFocus() }
1385 modifier = Modifier.align(Alignment.Center).padding(top = topPad),
1388 val lastMessageId = messages.lastOrNull()?.info?.id
1389 // Text-to-speech for reading assistant replies aloud; shuts down when the
1390 // message list leaves composition.
1391 val tts = rememberTtsController()
1392 val speakingMessageId by tts.speakingId
1393 val ttsState by tts.state
1394 // Provide the in-conversation search query to the markdown renderer so matching
1395 // text spans are highlighted in-place (in addition to the focused-row highlight
1396 // above). Scoped to the list so cleared search stops highlighting immediately.
1397 val activeSearchQuery = searchQuery.takeIf { searchActive && it.isNotEmpty() }
1398 CompositionLocalProvider(LocalSearchHighlight provides activeSearchQuery) {
1401 modifier = Modifier.fillMaxSize(),
1402 // Extra bottom padding reserves room for the jump-to-latest FAB so it
1403 // never floats over the last message. Top padding grows when the
1404 // connection banner is visible so it doesn't overlap the first message.
1405 contentPadding = PaddingValues(
1409 bottom = 16.dp + NetworkConfig.chatListFabInsetDp.dp,
1411 // Compact spacing (8dp) vs the default 16dp — a power-user toggle to fit
1412 // more conversation on screen without scrolling as much.
1413 verticalArrangement = Arrangement.spacedBy(if (compactSpacing) 8.dp else 16.dp),
1418 contentType = { it.contentType },
1420 // When in-conversation search is stepping through matches, highlight the
1421 // focused one's row so the user can spot it at a glance among the filtered
1422 // results (full in-text term highlighting would require threading the query
1423 // through the markdown renderer; the row highlight + scroll-to-match covers
1424 // the navigation use case desktop chat apps solve with next/prev).
1425 val focusedMatchId = if (searchActive && searchQuery.isNotEmpty()) {
1426 searchMessages.getOrNull(searchPos)?.info?.id
1428 val isFocusedMatch = focusedMatchId != null &&
1429 item is MessageListItem.Message &&
1430 item.message.info.id == focusedMatchId
1431 // Default placement animation so inserted/moved rows glide in. Skipped
1432 // entirely under reduced motion so a streaming message growing in place
1433 // doesn't animate on every token (and respects the a11y preference).
1436 .then(reducedMotionAnimateItem())
1438 if (isFocusedMatch) {
1439 Modifier.background(
1440 MaterialTheme.colorScheme.secondaryContainer,
1441 MaterialTheme.shapes.medium,
1449 is MessageListItem.Separator -> DateSeparator(item.label)
1450 is MessageListItem.Message -> {
1451 val message = item.message
1452 // Only the last (streaming) message needs isRunning — it drives the
1453 // reasoning-block spinner. Passing the live flag to every bubble
1454 // makes all visible messages recompose whenever a run starts or stops.
1455 val modelLabel = remember(message.info, models) {
1456 (message.info as? soy.iko.opencode.data.model.AssistantMessage)
1457 ?.let { resolveModelLabel(it, models) }
1459 val agentLabel = remember(message.parts) {
1461 .filterIsInstance<soy.iko.opencode.data.model.AgentPart>()
1462 .firstOrNull { it.name.isNotBlank() }?.name
1464 // Text used to drive swipe-to-reply (and pre-fill the quote). Empty for
1465 // image/code-only messages, which then opt out of the swipe gesture.
1466 val quoteText = remember(message.parts) {
1468 .filterIsInstance<TextPart>()
1469 .joinToString("\n\n") { it.text }
1471 .takeIf { it.isNotEmpty() }
1473 // Swipe-start-to-end reveals a reply affordance and triggers quote-reply,
1474 // snapping back (the message isn't dismissed — the quote fills the composer).
1475 // Enabled for non-text messages too (image/tool-only), but the confirm
1476 // callback gives a haptic without triggering reply — so the user gets a
1477 // perceptible "can't quote this" signal instead of a silently dead gesture.
1478 val cantQuoteMsg = stringResource(R.string.cannot_quote_this)
1479 val undoLabel = stringResource(R.string.undo)
1480 val quoteReplyLabel = stringResource(R.string.quote_reply_banner)
1481 val swipeState = rememberSwipeToDismissBoxState(
1482 confirmValueChange = { value ->
1483 val replyValue = if (layoutDirection == LayoutDirection.Rtl) {
1484 SwipeToDismissBoxValue.EndToStart
1486 SwipeToDismissBoxValue.StartToEnd
1488 if (value == replyValue) {
1489 if (quoteText != null) {
1490 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
1491 vm.quoteReply(quoteText)
1492 runCatching { inputFocusRequester.requestFocus() }
1493 // Offer a quick Undo for an accidental swipe,
1494 // mirroring the app's undo-everywhere ethos so
1495 // the user needn't reach for the banner's X.
1497 val res = snackbar.showSnackbar(
1498 message = quoteReplyLabel,
1499 actionLabel = undoLabel,
1501 if (res == androidx.compose.material3.SnackbarResult.ActionPerformed) {
1502 vm.cancelQuoteReply()
1506 // Non-text message: give a longer haptic so the user
1507 // feels the gesture was received but can't quote it,
1508 // and surface a brief toast so the reason is clear.
1509 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
1510 scope.launch { snackbar.showSnackbar(cantQuoteMsg) }
1516 // In RTL locales, mirror the swipe direction so the reply
1517 // gesture reads naturally (swipe from the trailing edge
1518 // toward the leading edge), matching how LTR users swipe
1519 // left-to-right. Without this, RTL users had no reply swipe.
1520 val replyFromStartToEnd = layoutDirection != LayoutDirection.Rtl
1523 enableDismissFromEndToStart = !replyFromStartToEnd,
1524 enableDismissFromStartToEnd = replyFromStartToEnd,
1525 backgroundContent = {
1527 Modifier.fillMaxSize().padding(horizontal = 16.dp),
1528 contentAlignment = if (replyFromStartToEnd) Alignment.CenterStart else Alignment.CenterEnd,
1531 Icons.AutoMirrored.Filled.Reply,
1532 contentDescription = null,
1533 tint = MaterialTheme.colorScheme.onSurfaceVariant,
1540 isRunning = running && message.info.id == lastMessageId,
1541 imageContext = imageContext,
1542 modelLabel = modelLabel,
1543 agentLabel = agentLabel,
1544 onOpenFile = onOpenFile,
1545 onRevert = { vm.revertTo(message.info.id) },
1546 onEdit = { text -> // editMessage reverts to before this message (hiding it and
1547 // everything after); the revert banner surfaces the rewind
1548 // with an Undo. Focus + scroll the composer into view like the
1549 // Quote action so the prefilled draft isn't off-screen.
1550 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
1551 vm.editMessage(message.info.id, text)
1552 runCatching { inputFocusRequester.requestFocus() }
1553 contentScope.launch {
1554 runCatchingCancellable {
1555 listState.animateScrollToItem(listItems.lastIndex.coerceAtLeast(0))
1560 if (!tts.toggle(message.info.id, text)) {
1561 // TTS engine unavailable (init failed / not yet ready
1562 // / blank text): surface feedback so the button doesn't
1563 // appear dead. Throttled so a rapid double-tap (stop
1564 // then immediate re-tap before ready) doesn't spam.
1565 scope.launch { snackbar.showSnackbar(ttsUnavailableMsg) }
1568 isSpeaking = message.info.id == speakingMessageId,
1569 ttsState = if (message.info.id == speakingMessageId) ttsState else TtsState.IDLE,
1570 onPause = { tts.pause() },
1571 onResume = { tts.resume() },
1572 onStop = { tts.stop() },
1575 runCatching { inputFocusRequester.requestFocus() }
1577 onBranch = { text -> vm.branchFrom(text) },
1578 // Regenerate is only meaningful for a finished assistant reply
1579 // (not the actively-streaming one), so gate it on !running.
1580 onRegenerate = if (!running) {
1581 { vm.regenerate(message.info.id) }
1583 // Continue: resume a partial reply. Shown only for
1584 // incomplete assistant messages (a partial reply that
1585 // was aborted) and not while a run is active.
1586 onContinue = if (!running && message.info is AssistantMessage && !message.info.isComplete) {
1587 { vm.continueRun(message.info.id) }
1589 sendStatus = optimisticStatuses[message.info.id]?.let { failed ->
1590 if (failed) soy.iko.opencode.ui.chat.MessageSendStatus.FAILED
1591 else soy.iko.opencode.ui.chat.MessageSendStatus.SENDING
1593 isEdited = message.info.time?.let { it.updated != null && it.updated != it.created } == true,
1594 onRetry = optimisticStatuses[message.info.id]?.let { failed ->
1596 { vm.retryOptimisticMessage(message.info.id) }
1601 onDismiss = optimisticStatuses[message.info.id]?.let { failed ->
1603 { vm.dismissOptimistic(message.info.id) }
1609 // Per-message share: build a single-message Markdown
1610 // transcript off-thread and fire ACTION_SEND, mirroring the
1611 // whole-conversation share but scoped to just this message.
1613 val md = withContext(Dispatchers.Default) {
1614 buildMessageMarkdown(message)
1616 val send = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
1617 type = "text/markdown"
1618 putExtra(android.content.Intent.EXTRA_SUBJECT, sessionTitle ?: defaultShareSubject)
1619 putExtra(android.content.Intent.EXTRA_TEXT, md)
1621 runCatchingCancellable { shareContext.startActivity(android.content.Intent.createChooser(send, shareContext.getString(R.string.share_message))) }
1622 .onFailure { showToast(shareContext, shareContext.getString(R.string.no_share_app)) }
1625 isFirstOfSpeaker = item.isFirstOfSpeaker,
1626 highlighted = focusedMessageId != null && message.info.id == focusedMessageId,
1634 item(key = "__typing") {
1635 val workingText = stringResource(R.string.working)
1636 // When the SSE stream dropped mid-run, show a "reconnecting" label
1637 // instead of the plain "working" text so the user understands the
1638 // run is still active but the stream is reconnecting (not finished).
1639 val reconnectingText = stringResource(R.string.reconnecting)
1640 val interruptedLabel = if (streamInterrupted) reconnectingText else workingText
1641 // Elapsed-since-run chip driven by the VM's runStartMs so the timer
1642 // survives LazyColumn recycling — a local remember reset to 0:00 when
1643 // the row was disposed and scrolled back into view. The VM stamps the
1644 // start on the real false→true transition (send/run/init), not on
1645 // SSE-reconnect relight, so reconnects don't restart the clock. Updates
1646 // once per second; the value is decorative (the a11y label is the row's).
1647 // The clock freezes when an abort is confirmed (aborting = true) so the
1648 // user can see how long the run lasted before they stopped it, rather
1649 // than the clock continuing to tick until SessionIdle clears _running.
1650 val startMs by vm.runStartMs.collectAsStateWithLifecycle()
1651 val elapsedMs by produceState(0L, startMs, aborting) {
1652 if (startMs == 0L) return@produceState
1654 // Freeze at the last-computed value: one final snapshot then stop.
1655 value = (System.currentTimeMillis() - startMs).coerceAtLeast(0L)
1659 value = (System.currentTimeMillis() - startMs).coerceAtLeast(0L)
1663 val elapsedText = remember(elapsedMs) {
1664 val total = elapsedMs / 1000
1665 // Locale.US for stable digit formatting: on locales with
1666 // non-ASCII digits (ar-/fa-) the default locale would render
1667 // localized digits inside the Monospace block, breaking the
1668 // fixed-width alignment the monospace family was chosen for.
1669 String.format(java.util.Locale.US, "%d:%02d", total / 60, total % 60)
1672 verticalAlignment = Alignment.CenterVertically,
1673 // Animate this slot in/out so the working row doesn't pop in and
1674 // jolt the message list when a run starts/stops — matching the
1675 // AnimatedVisibility used by every other transient banner/FAB here.
1677 .then(reducedMotionAnimateItem())
1678 .padding(vertical = 4.dp),
1680 // Merge the indicator + label + clock into one TalkBack node so
1681 // it's announced once (the working label, then the elapsed time)
1682 // instead of "working… / working… / 0:14" with the label read
1683 // twice. The Stop IconButton below stays separately focusable.
1685 verticalAlignment = Alignment.CenterVertically,
1686 modifier = Modifier.semantics(mergeDescendants = true) {
1687 contentDescription = "$interruptedLabel $elapsedText"
1690 // Dim the spinner when interrupted so the row reads as
1691 // "paused/reconnecting" rather than actively progressing.
1692 CircularProgressIndicator(
1693 Modifier.size(16.dp),
1695 color = if (streamInterrupted) MaterialTheme.colorScheme.onSurfaceVariant
1696 else MaterialTheme.colorScheme.primary,
1698 Text(interruptedLabel, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp))
1699 Spacer(Modifier.size(8.dp))
1701 Icons.Filled.Schedule,
1702 contentDescription = null,
1703 modifier = Modifier.size(14.dp),
1704 tint = MaterialTheme.colorScheme.onSurfaceVariant,
1708 style = MaterialTheme.typography.labelMedium,
1709 fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
1710 color = MaterialTheme.colorScheme.onSurfaceVariant,
1711 modifier = Modifier.padding(start = 4.dp),
1714 // A trailing Stop on the working row so the run can be aborted
1715 // without scrolling back to the composer (which may be off-screen
1716 // while the user reads the streaming output up-thread).
1717 Spacer(Modifier.weight(1f))
1718 val stopLabel = stringResource(R.string.stop)
1720 onClick = { vm.abort() },
1721 modifier = Modifier.semantics { contentDescription = stopLabel },
1723 Icon(Icons.Filled.Stop, contentDescription = null)
1732 // Jump-to-latest affordance when the user has scrolled away during a stream.
1733 val fabMotion = rememberVisibilityTransitions()
1735 visible = !isPinnedToBottom && listItems.isNotEmpty(),
1736 enter = fabMotion.enter,
1737 exit = fabMotion.exit,
1738 // Inset by sideMargin on wide screens so the FAB tracks the capped list's
1739 // edge instead of floating at the screen edge (visually detached on tablets).
1740 modifier = Modifier.align(Alignment.BottomEnd).padding(end = sideMargin),
1742 // Badge the FAB when new content arrived while the user was scrolled up, so
1743 // they know there's something new to jump to (cleared once back at bottom).
1746 if (newContentCount > 0) {
1747 // Merge the count into the badge announcement so TalkBack reads
1748 // "5 new messages" instead of the bare label and a separate number.
1749 val newContentLabel = pluralStringResource(
1750 R.plurals.plurals_new_messages,
1755 modifier = Modifier.semantics {
1756 contentDescription = newContentLabel
1757 // Announce new-content counts as they change so a
1758 // TalkBack user hears "5 new messages" without having
1759 // to focus the FAB. Polite so it doesn't interrupt an
1760 // in-progress utterance (e.g. streaming token reads).
1761 liveRegion = androidx.compose.ui.semantics.LiveRegionMode.Polite
1765 if (newContentCount > 99) "99+" else newContentCount.toString(),
1766 modifier = Modifier.semantics { invisibleToUser() },
1771 modifier = Modifier.padding(end = 16.dp, bottom = 16.dp),
1773 ExtendedFloatingActionButton(
1776 if (listItems.isNotEmpty()) {
1777 // Scroll to the effective last index, including the
1778 // trailing "__typing" row when a run is active.
1779 val target = if (running) listItems.size else listItems.lastIndex
1780 contentScope.launch { runCatchingCancellable { listState.animateScrollToItem(target) } }
1783 // contentDescription null: the visible "Latest" text already labels
1784 // the FAB, so a description here makes TalkBack announce "Latest" twice.
1785 icon = { Icon(Icons.Filled.KeyboardArrowDown, contentDescription = null) },
1786 text = { Text(stringResource(R.string.latest)) },
1790 // Scroll-to-top affordance: a small FAB at the start edge, visible when the
1791 // user has scrolled down past the first few messages. Symmetric to the
1792 // jump-to-latest FAB, for long conversations where manual fling back is tedious.
1793 val scrolledDown by remember {
1794 derivedStateOf { listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 200 }
1797 visible = scrolledDown && listItems.size > 4,
1798 enter = fabMotion.enter,
1799 exit = fabMotion.exit,
1800 modifier = Modifier.align(Alignment.BottomStart).padding(start = sideMargin),
1802 SmallFloatingActionButton(
1804 contentScope.launch { runCatchingCancellable { listState.animateScrollToItem(0) } }
1806 modifier = Modifier.padding(start = 16.dp, bottom = 16.dp),
1809 Icons.Filled.KeyboardArrowUp,
1810 contentDescription = stringResource(R.string.scroll_to_top),
1814 // In-conversation search bar. Overlays the list so typing filters messages in
1815 // place; the match count + a no-matches note give immediate feedback.
1816 val searchMotion = rememberVisibilityTransitions()
1818 visible = searchActive,
1819 enter = searchMotion.enter,
1820 exit = searchMotion.exit,
1821 // Inset by sideMargin on wide screens so the search bar spans the capped
1822 // list's width, not the full screen width.
1823 modifier = Modifier.align(Alignment.TopCenter).padding(horizontal = sideMargin),
1826 tonalElevation = 3.dp,
1828 .padding(horizontal = 12.dp, vertical = 4.dp)
1830 .onSizeChanged { searchbarHeightPx = it.height },
1832 // Focus the search field when the bar appears (from the overflow menu's
1833 // "Find in conversation" or Ctrl+F), so the user can start typing
1834 // immediately. Runs after the search field enters the composition.
1835 LaunchedEffect(searchActive) {
1836 if (searchActive) runCatching { searchFocusRequester.requestFocus() }
1839 modifier = Modifier.padding(start = 8.dp, end = 4.dp),
1840 verticalAlignment = Alignment.CenterVertically,
1844 onValueChange = { chatSearch = it },
1845 modifier = Modifier.weight(1f).focusRequester(searchFocusRequester),
1846 placeholder = { Text(stringResource(R.string.search_in_conversation)) },
1848 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
1849 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
1851 // Match counter: "N of M" where N is the focused match (1-based) and
1852 // M is the total matching messages. Only shown when there's a query.
1853 val matchCount = searchMessages.size
1855 if (searchQuery.isNotEmpty() && matchCount > 0) {
1856 stringResource(R.string.search_match_count, searchPos + 1, matchCount)
1857 } else if (searchQuery.isNotEmpty()) {
1858 stringResource(R.string.search_no_matches)
1860 style = MaterialTheme.typography.labelSmall,
1861 color = MaterialTheme.colorScheme.onSurfaceVariant,
1863 .padding(horizontal = 4.dp)
1864 // Live region so a TalkBack user hears "3 of 12" / "No
1865 // matches" announce as they step through results, not just
1866 // the sighted user seeing the counter update.
1867 .semantics { liveRegion = androidx.compose.ui.semantics.LiveRegionMode.Polite },
1869 // Step through matches (Previous / Next), disabled when there are
1870 // fewer than two to navigate. Wrap around at the ends so repeated
1871 // taps cycle through every hit.
1872 val prevLabel = stringResource(R.string.find_previous)
1873 val nextLabel = stringResource(R.string.find_next)
1876 if (matchCount > 0) {
1877 searchPos = (searchPos - 1 + matchCount) % matchCount
1880 enabled = matchCount > 1,
1882 Icon(Icons.Filled.KeyboardArrowUp, contentDescription = prevLabel)
1886 if (matchCount > 0) {
1887 searchPos = (searchPos + 1) % matchCount
1890 enabled = matchCount > 1,
1892 Icon(Icons.Filled.KeyboardArrowDown, contentDescription = nextLabel)
1894 IconButton(onClick = {
1895 searchActive = false
1898 Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.clear_search))
1903 // When a search yields no matches (but the conversation isn't empty), say so
1904 // instead of showing a blank list.
1905 if (searchActive && searchQuery.isNotEmpty() && searchMessages.isEmpty() && messages.isNotEmpty()) {
1907 stringResource(R.string.search_no_matches),
1908 style = MaterialTheme.typography.bodyMedium,
1909 color = MaterialTheme.colorScheme.onSurfaceVariant,
1910 modifier = Modifier.align(Alignment.Center),
1918 if (showExitConfirm) {
1920 onDismissRequest = { showExitConfirm = false },
1921 title = { Text(stringResource(R.string.stop_and_exit_title)) },
1922 text = { Text(stringResource(R.string.stop_and_exit_text)) },
1924 TextButton(onClick = {
1925 showExitConfirm = false
1926 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
1929 }) { Text(stringResource(R.string.stop_and_exit)) }
1932 TextButton(onClick = { showExitConfirm = false }) { Text(stringResource(R.string.stay)) }
1937 if (showSummarizeConfirm) {
1939 onDismissRequest = { showSummarizeConfirm = false },
1940 title = { Text(stringResource(R.string.summarize_confirm_title)) },
1941 text = { Text(stringResource(R.string.summarize_confirm_text)) },
1943 TextButton(onClick = {
1944 showSummarizeConfirm = false
1946 }) { Text(stringResource(R.string.action_continue)) }
1949 TextButton(onClick = { showSummarizeConfirm = false }) { Text(stringResource(R.string.cancel)) }
1954 if (showInitConfirm) {
1956 onDismissRequest = { showInitConfirm = false },
1957 title = { Text(stringResource(R.string.init_confirm_title)) },
1958 text = { Text(stringResource(R.string.init_confirm_text)) },
1960 TextButton(onClick = {
1961 showInitConfirm = false
1963 }) { Text(stringResource(R.string.action_continue)) }
1966 TextButton(onClick = { showInitConfirm = false }) { Text(stringResource(R.string.cancel)) }
1971 if (showRenameDialog) {
1972 RenameSessionChatDialog(
1973 initialTitle = sessionTitle ?: "",
1974 onDismiss = { showRenameDialog = false },
1975 onConfirm = { newName ->
1976 showRenameDialog = false
1977 vm.renameSession(newName)
1982 if (showDeleteDialog) {
1984 onDismissRequest = { showDeleteDialog = false },
1985 title = { Text(stringResource(R.string.delete_session_chat_title)) },
1986 text = { Text(stringResource(R.string.delete_session_chat_text, sessionTitle ?: sessionLabel)) },
1988 TextButton(onClick = {
1989 showDeleteDialog = false
1990 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
1992 }) { Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) }
1995 TextButton(onClick = { showDeleteDialog = false }) { Text(stringResource(R.string.cancel)) }
2000 if (showShellDialog) {
2002 onDismiss = { showShellDialog = false },
2004 showShellDialog = false
2010 if (showModelPicker) {
2011 // Resolve persisted recent composite keys to the live ModelOption entries they refer
2012 // to, dropping any whose model is no longer offered by the server. Ordered by
2013 // recency (head of the store first).
2014 val recentOptions = remember(models, recentModelEntries) {
2015 val byKey = models.associateBy { it.providerID to it.modelID }
2016 recentModelEntries.mapNotNull { entry ->
2017 container.recentModelsStore.split(entry)?.let { (p, m) -> byKey[p to m] }
2020 // Resolve snackbar strings in the composable scope; the onSetPreferredModel lambda
2021 // below isn't @Composable, so stringResource can't be called inside it.
2022 val defaultModelClearedMsg = stringResource(R.string.default_model_cleared)
2023 val defaultModelSetFmt = stringResource(R.string.default_model_set, "%s")
2026 selected = selectedModel,
2027 loading = modelsLoading,
2028 error = modelsError,
2031 container.recentModelsStore.add(it.providerID, it.modelID)
2033 onRetry = { vm.reloadModels() },
2034 onDismiss = { showModelPicker = false },
2035 preferredModelId = preferredModelId,
2036 onSetPreferredModel = { id ->
2038 runCatchingCancellable { container.settingsStore.setPreferredModelId(id) }
2039 // Confirm the change with a snackbar so the user sees it took effect —
2040 // the toggle's label flip can be missed, especially when clearing.
2041 val msg = if (id.isEmpty()) {
2042 defaultModelClearedMsg
2044 val label = models.firstOrNull { it.modelID == id }?.modelLabel ?: id
2045 defaultModelSetFmt.format(label)
2047 snackbar.showSnackbar(msg)
2050 recent = recentOptions,
2054 if (showAgentPicker) {
2057 selected = selectedAgent,
2058 loading = agentsLoading,
2059 error = agentsError,
2060 onSelect = { vm.selectAgent(it?.name) },
2061 onRetry = { vm.reloadAgents() },
2062 onDismiss = { showAgentPicker = false },
2066 if (showCommandPicker) {
2068 commands = commands,
2069 loading = commandsLoading,
2070 error = commandsError,
2071 onSelect = { vm.runCommand(it) },
2072 onRetry = { vm.reloadCommands() },
2073 onDismiss = { showCommandPicker = false },
2077 pendingPermission?.let { permission ->
2079 permission = permission,
2080 position = permissionProgress.position,
2081 total = permissionProgress.total,
2082 onRespond = { response -> vm.respondPermission(permission, response) },
2083 // On auto-reject timeout, post a notification so a returning user finds a
2084 // persistent record of why the run stopped (the dialog itself is gone).
2085 // Gated by the user's notification setting (notifPermission).
2087 val conn = activeConnection
2088 val ctx = appContext
2089 val title = sessionTitle ?: sessionId
2090 if (notifPermission) {
2091 soy.iko.opencode.notification.SessionNotifications.postPermissionAutoRejected(
2092 ctx, sessionId, title, conn?.profile?.id,
2100 CommandPalette(actions = paletteActions, onDismiss = { showPalette = false })
2103 if (showShortcutsDialog) {
2104 // Surfaces the hardware-keyboard shortcuts (Ctrl+K palette, Escape stop, Enter send)
2105 // that are otherwise wired but undiscoverable. A simple key/value list; Enter behavior
2106 // depends on the "Send on Enter" setting, noted inline.
2108 onDismissRequest = { showShortcutsDialog = false },
2109 title = { Text(stringResource(R.string.keyboard_shortcuts_title)) },
2111 Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
2112 ShortcutRow("Ctrl + K", stringResource(R.string.shortcut_open_palette))
2113 ShortcutRow("Esc", stringResource(R.string.shortcut_stop_run))
2114 ShortcutRow("Esc", stringResource(R.string.shortcut_close))
2115 ShortcutRow("Enter", stringResource(R.string.shortcut_send))
2116 ShortcutRow("Shift + Enter / Ctrl + Enter", stringResource(R.string.shortcut_newline))
2120 TextButton(onClick = { showShortcutsDialog = false }) { Text(stringResource(R.string.action_continue)) }
2127private fun ShortcutRow(keys: String, desc: String) {
2128 Row(verticalAlignment = Alignment.CenterVertically) {
2131 style = MaterialTheme.typography.labelLarge,
2132 fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
2133 modifier = Modifier.widthIn(min = 160.dp),
2137 style = MaterialTheme.typography.bodyMedium,
2138 color = MaterialTheme.colorScheme.onSurfaceVariant,
2144 * Slash-command autocomplete: when the draft starts with "/", show a filtered dropdown
2145 * of matching commands above the composer. Tapping a command fires it immediately.
2146 * Extracted from [ChatInputBar] to keep that function's cyclomatic complexity under
2147 * the detekt threshold. Suppressed while running or when the command catalog is empty.
2150private fun SlashCommandAutocomplete(
2152 commands: List<Command>,
2154 onRunCommand: (Command) -> Unit,
2156 if (running || commands.isEmpty()) return
2157 val slashQuery = remember(value) {
2158 if (value.startsWith("/")) value.removePrefix("/") else null
2160 val matchingCommands = remember(slashQuery, commands) {
2161 commands.filter { cmd ->
2162 slashQuery.isEmpty() ||
2163 cmd.name.contains(slashQuery, ignoreCase = true) ||
2164 (cmd.description?.contains(slashQuery, ignoreCase = true) == true)
2167 if (matchingCommands.isNotEmpty()) {
2168 CommandAutocompleteDropdown(
2169 commands = matchingCommands,
2170 onPick = onRunCommand,
2176 * Slash-command autocomplete dropdown shown above the composer when the draft starts
2177 * with "/". Renders up to 8 matching commands as a scrollable, elevated surface so the
2178 * user can discover and invoke commands by typing the conventional "/" prefix instead
2179 * of opening the overflow menu / command palette. Tapping a row fires the command.
2182private fun CommandAutocompleteDropdown(
2183 commands: List<Command>,
2184 onPick: (Command) -> Unit,
2186 val haptics = LocalHapticFeedback.current
2188 color = MaterialTheme.colorScheme.surface,
2189 tonalElevation = 3.dp,
2190 shape = MaterialTheme.shapes.medium,
2193 .padding(horizontal = 8.dp, vertical = 4.dp)
2194 .heightIn(max = 240.dp),
2198 count = commands.size,
2199 key = { commands[it].name },
2201 val cmd = commands[i]
2205 .clickable(role = Role.Button) {
2206 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
2209 .padding(horizontal = 16.dp, vertical = 10.dp),
2210 verticalAlignment = Alignment.CenterVertically,
2211 horizontalArrangement = Arrangement.spacedBy(12.dp),
2215 style = MaterialTheme.typography.labelLarge,
2216 fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
2217 color = MaterialTheme.colorScheme.primary,
2219 cmd.description?.takeIf { it.isNotBlank() }?.let { desc ->
2222 style = MaterialTheme.typography.bodySmall,
2223 color = MaterialTheme.colorScheme.onSurfaceVariant,
2225 overflow = TextOverflow.Ellipsis,
2234@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
2235@Suppress("CyclomaticComplexMethod")
2237private fun ChatInputBar(
2239 onValueChange: (String) -> Unit,
2243 sendOnEnter: Boolean,
2245 onAbort: () -> Unit,
2246 queuedFollowUp: String?,
2247 onQueueFollowUp: (String) -> Unit,
2248 onCancelQueue: () -> Unit,
2249 focusRequester: androidx.compose.ui.focus.FocusRequester,
2250 attachments: List<PendingAttachment>,
2252 stagingFileCount: Int,
2253 onRemoveAttachment: (String) -> Unit,
2254 onPickPhoto: () -> Unit,
2255 onPickFile: () -> Unit,
2256 onCamera: () -> Unit,
2257 onVoice: () -> Unit,
2258 onPasteImage: (List<Uri>) -> Unit,
2259 commands: List<Command> = emptyList(),
2260 onRunCommand: (Command) -> Unit = {},
2262 // Sendable when there's text OR at least one attachment (an image-only prompt is valid).
2263 val hasContent = value.isNotBlank() || attachments.isNotEmpty()
2264 val context = LocalContext.current
2265 // Full-screen editor state: opened when the user wants more space for a long prompt.
2266 var showFullScreenEditor by rememberSaveable { mutableStateOf(false) }
2267 Surface(tonalElevation = 3.dp, modifier = Modifier.imePadding()) {
2268 Column(modifier = Modifier.fillMaxWidth().windowInsetsPadding(WindowInsets.navigationBars)) {
2269 // A queued follow-up replaces the Stop button with a "queued" chip so the
2270 // user sees their message will be sent when the run finishes, and can cancel.
2271 if (queuedFollowUp != null) {
2272 val editLabel = stringResource(R.string.edit)
2276 .padding(start = 12.dp, top = 6.dp, end = 4.dp),
2277 verticalAlignment = Alignment.CenterVertically,
2279 // Tapping the chip reloads the queued text into the draft (and clears the
2280 // queue) so a typo spotted while queuing can be fixed without retyping.
2284 .clip(MaterialTheme.shapes.small)
2285 .clickable(role = Role.Button, onClickLabel = editLabel) {
2286 onValueChange(queuedFollowUp)
2289 .padding(vertical = 4.dp),
2292 stringResource(R.string.queued),
2293 style = MaterialTheme.typography.labelSmall,
2294 color = MaterialTheme.colorScheme.onSurfaceVariant,
2298 style = MaterialTheme.typography.bodySmall,
2299 color = MaterialTheme.colorScheme.onSurfaceVariant,
2301 overflow = TextOverflow.Ellipsis,
2304 TextButton(onClick = onCancelQueue, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp)) {
2305 Text(stringResource(R.string.cancel))
2309 // Staged attachments: horizontally-scrollable thumbnails/chips, each removable.
2310 AttachmentStrip(attachments, onRemove = onRemoveAttachment, staging = staging, stagingFileCount = stagingFileCount)
2311 // Slash-command autocomplete: extracted to a helper to keep this function's
2312 // complexity under the detekt threshold. Returns no-op when the draft doesn't
2313 // start with "/" or the command catalog is empty.
2314 SlashCommandAutocomplete(
2316 commands = commands,
2318 onRunCommand = onRunCommand,
2321 modifier = Modifier.fillMaxWidth().padding(8.dp),
2322 verticalAlignment = Alignment.Bottom,
2324 // Attach menu: photo picker, camera, or any file.
2325 var showAttachMenu by remember { mutableStateOf(false) }
2327 IconButton(onClick = { showAttachMenu = true }, enabled = enabled) {
2328 Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.add_attachment))
2330 DropdownMenu(expanded = showAttachMenu, onDismissRequest = { showAttachMenu = false }) {
2332 leadingIcon = { Icon(Icons.Filled.PhotoLibrary, contentDescription = null) },
2333 text = { Text(stringResource(R.string.attach_photo)) },
2334 onClick = { showAttachMenu = false; onPickPhoto() },
2337 leadingIcon = { Icon(Icons.Filled.PhotoCamera, contentDescription = null) },
2338 text = { Text(stringResource(R.string.attach_camera)) },
2339 onClick = { showAttachMenu = false; onCamera() },
2342 leadingIcon = { Icon(Icons.Filled.Description, contentDescription = null) },
2343 text = { Text(stringResource(R.string.attach_file)) },
2344 onClick = { showAttachMenu = false; onPickFile() },
2348 // Voice dictation: appends recognized speech to the draft. Hidden (not just
2349 // disabled) when the device has no speech-recognition activity, so a user
2350 // without a voice app doesn't get a button that only fails noisily on tap.
2351 val voiceAvailable = remember {
2352 val pm = context.packageManager
2353 val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH)
2354 pm.queryIntentActivities(intent, 0).isNotEmpty()
2356 if (voiceAvailable) {
2357 IconButton(onClick = onVoice, enabled = enabled) {
2358 Icon(Icons.Filled.Mic, contentDescription = stringResource(R.string.voice_input))
2361 // Expand to a full-screen editor for long prompts (the inline field caps at 6 lines).
2362 IconButton(onClick = { showFullScreenEditor = true }, enabled = enabled) {
2363 Icon(Icons.Filled.Expand, contentDescription = stringResource(R.string.expand_editor))
2367 onValueChange = { v ->
2368 // Cap input length so a huge paste can't stall the UI. Shares are
2369 // capped separately in MainActivity; this guards typed/pasted drafts.
2370 onValueChange(v.take(NetworkConfig.maxDraftLengthChars))
2374 .focusRequester(focusRequester)
2375 .testTag("chat_input")
2376 // Intercept image content pasted/dropped into the field (keyboard paste
2377 // of a copied screenshot, drag from Files, etc.) and route it through the
2378 // same staging path as the attach menu. Non-image content (text) falls
2379 // through unchanged so normal typing/paste works.
2382 androidx.compose.foundation.content.ReceiveContentListener { transferable ->
2383 val clipData = transferable.clipEntry?.clipData
2384 if (clipData != null) {
2385 val imageUris = (0 until clipData.itemCount).mapNotNull { i ->
2386 val item = clipData.getItemAt(i)
2387 item.uri?.takeIf { uri ->
2388 item.text == null && uri.toString().let { s ->
2389 s.startsWith("content:") || s.startsWith("file:")
2393 if (imageUris.isNotEmpty()) {
2394 onPasteImage(imageUris)
2401 .onPreviewKeyEvent { event ->
2402 if (event.type != KeyEventType.KeyDown || event.key != Key.Enter) return@onPreviewKeyEvent false
2403 // With "Send on Enter" on, Enter sends (Shift+Enter newlines).
2404 // With it off, Enter inserts a newline and Ctrl+Enter sends.
2405 val send = enterShouldSend(
2407 hasContent = hasContent,
2408 sendOnEnter = sendOnEnter,
2409 shift = event.isShiftPressed,
2410 ctrl = event.isCtrlPressed,
2413 if (running) onQueueFollowUp(value) else onSend()
2419 // While a run is active a typed message only queues (sends after the run), so
2420 // the placeholder says so instead of the plain "Message…".
2422 Text(stringResource(if (running) R.string.composer_hint_running else R.string.message_placeholder))
2426 // Show a "N / max" countdown once the draft crosses a high fraction of
2427 // the cap, so the user knows a paste is about to be truncated instead
2428 // of being silently cut off. Hidden for normal short prompts to avoid
2429 // clutter under a typical one-line message.
2431 val threshold = (NetworkConfig.maxDraftLengthChars * NetworkConfig.draftCountdownThresholdFraction).toInt()
2432 if (value.length >= threshold) {
2435 R.string.draft_chars_remaining,
2436 NetworkConfig.maxDraftLengthChars - value.length,
2437 NetworkConfig.maxDraftLengthChars,
2439 style = MaterialTheme.typography.labelSmall,
2440 color = if (value.length >= NetworkConfig.maxDraftLengthChars) MaterialTheme.colorScheme.error
2441 else MaterialTheme.colorScheme.onSurfaceVariant,
2443 } else if (value.lines().size > 6) {
2444 // The inline field caps at 6 lines; once the draft crosses that,
2445 // hint that a bigger full-screen editor exists (the Expand icon
2446 // above is easy to miss among the icon row). A tappable label
2447 // would be ideal, but a plain hint is enough to draw attention.
2449 stringResource(R.string.open_full_editor_hint),
2450 style = MaterialTheme.typography.labelSmall,
2451 color = MaterialTheme.colorScheme.onSurfaceVariant,
2455 keyboardOptions = KeyboardOptions(
2456 // When "Send on Enter" is on, expose ImeAction.Send so the soft
2457 // keyboard shows a send key — matching the setting's intent and
2458 // giving touch-only users a keyboard send affordance. When off,
2459 // ImeAction.Default keeps the enter/newline key for multi-line input.
2460 imeAction = if (sendOnEnter) ImeAction.Send else ImeAction.Default,
2461 capitalization = KeyboardCapitalization.Sentences,
2463 keyboardActions = KeyboardActions(
2465 if (enabled && hasContent) onSend()
2469 ComposerTrailingButton(
2471 aborting = aborting,
2472 canSend = enabled && hasContent,
2473 canQueue = enabled && value.isNotBlank(),
2476 onQueue = { onQueueFollowUp(value) },
2480 if (showFullScreenEditor) {
2483 onValueChange = onValueChange,
2484 onDismiss = { showFullScreenEditor = false },
2486 showFullScreenEditor = false
2487 // Mirror the inline composer's behavior: while a run is active a send
2488 // can't go immediately, so queue the follow-up instead of silently
2489 // dropping it (vm.send() returns false during a run, which previously
2490 // lost the typed message with no feedback).
2491 if (running) onQueueFollowUp(value) else onSend()
2493 canSend = enabled && hasContent,
2495 sendOnEnter = sendOnEnter,
2496 attachments = attachments,
2497 onRemoveAttachment = onRemoveAttachment,
2499 stagingFileCount = stagingFileCount,
2505/** Full-screen editor dialog for composing long prompts. Gives the full viewport height to
2506 * the text field (vs the inline 6-line cap) with a Send button and a collapse action. */
2507@OptIn(ExperimentalMaterial3Api::class)
2509private fun FullScreenEditor(
2511 onValueChange: (String) -> Unit,
2512 onDismiss: () -> Unit,
2515 running: Boolean = false,
2516 sendOnEnter: Boolean = false,
2517 attachments: List<PendingAttachment> = emptyList(),
2518 onRemoveAttachment: (String) -> Unit = {},
2519 staging: Boolean = false,
2520 stagingFileCount: Int = 0,
2522 androidx.compose.ui.window.Dialog(
2523 onDismissRequest = onDismiss,
2524 properties = androidx.compose.ui.window.DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false),
2526 // Cap the editor width on large screens (tablets) so the text field doesn't stretch
2527 // edge-to-edge — matching the chat list's readability rationale. BoxWithConstraints
2528 // gives the available width so we can center a capped-width column.
2529 androidx.compose.foundation.layout.BoxWithConstraints(
2530 modifier = Modifier.fillMaxSize(),
2531 contentAlignment = Alignment.Center,
2533 val hPad = if (maxWidth > NetworkConfig.twoPaneWidthThresholdDp.dp)
2534 ((maxWidth - NetworkConfig.composerDialogMaxWidthDp.dp) / 2).coerceAtLeast(0.dp)
2537 modifier = Modifier.padding(horizontal = hPad),
2539 androidx.compose.material3.TopAppBar(
2540 title = { Text(stringResource(R.string.compose_editor_title)) },
2542 androidx.compose.material3.TextButton(onClick = onDismiss) {
2543 Text(stringResource(R.string.collapse_editor))
2549 Surface(tonalElevation = 3.dp) {
2551 modifier = Modifier.fillMaxWidth().imePadding().padding(12.dp),
2552 horizontalArrangement = Arrangement.End,
2554 androidx.compose.material3.Button(onClick = onSend, enabled = canSend) {
2555 Text(stringResource(if (running) R.string.queue else R.string.send))
2561 Column(modifier = Modifier.fillMaxSize().padding(padding)) {
2562 // Show staged attachments in the full-screen editor too, so a user who staged
2563 // images then expanded for a long prompt can still see and remove them without
2564 // collapsing first (mirrors the inline composer's AttachmentStrip).
2565 AttachmentStrip(attachments, onRemove = onRemoveAttachment, staging = staging, stagingFileCount = stagingFileCount)
2568 onValueChange = { v -> onValueChange(v.take(NetworkConfig.maxDraftLengthChars)) },
2572 // Honor the "Send on Enter" setting here too, so the full-screen
2573 // editor matches the inline composer: Enter sends (Shift+Enter
2574 // newlines) when on; Enter inserts a newline and Ctrl+Enter sends
2575 // when off. Previously this hard-coded ImeAction.Default and had no
2576 // key handler, so a hardware-keyboard user got a different behaviour
2577 // from the inline composer depending on which they happened to open.
2578 .onPreviewKeyEvent { event ->
2579 if (event.type != KeyEventType.KeyDown || event.key != Key.Enter) return@onPreviewKeyEvent false
2580 val send = enterShouldSend(
2582 hasContent = canSend,
2583 sendOnEnter = sendOnEnter,
2584 shift = event.isShiftPressed,
2585 ctrl = event.isCtrlPressed,
2587 if (send) { onSend(); true } else false
2589 placeholder = { Text(stringResource(R.string.message_placeholder)) },
2590 maxLines = Int.MAX_VALUE,
2591 keyboardOptions = KeyboardOptions(
2592 imeAction = if (sendOnEnter) ImeAction.Send else ImeAction.Default,
2593 capitalization = KeyboardCapitalization.Sentences,
2595 keyboardActions = KeyboardActions(
2596 onSend = { if (canSend) onSend() },
2605/** Whether a hardware Enter keypress should send: never when disabled/empty; otherwise
2606 * Enter sends (unless Shift) when send-on-Enter is on, else only Ctrl+Enter sends. */
2607private fun enterShouldSend(
2609 hasContent: Boolean,
2610 sendOnEnter: Boolean,
2614 !enabled || !hasContent -> false
2615 sendOnEnter -> !shift
2619/** The composer's trailing button: while a run is active a Stop (with in-flight spinner),
2620 * preceded by a Queue button when the user has typed a follow-up; otherwise Send. Extracted so
2621 * [ChatInputBar] stays under the complexity threshold. */
2623private fun ComposerTrailingButton(
2629 onAbort: () -> Unit,
2630 onQueue: () -> Unit,
2633 Row(verticalAlignment = Alignment.CenterVertically) {
2634 // While a run is active the trailing action is Stop, so the only send affordance is
2635 // the keyboard's IME action — which is a plain newline when "Send on Enter" is off.
2636 // That leaves a touch-only user with the setting off no way to queue a follow-up
2637 // (Ctrl+Enter needs a hardware keyboard). Surface an explicit Queue button whenever
2638 // there's typed TEXT so queuing is always reachable, without changing the user's
2639 // Enter-inserts-a-newline preference. Gate on typed text — not the send-oriented
2640 // canSend, which is also true for an attachment-only composer — because queuing "" just
2641 // calls setQueuedFollowUp(null), silently cancelling any already-queued follow-up and
2642 // dropping the staged attachment (queued follow-ups don't carry attachments anyway).
2646 modifier = Modifier.testTag("queue_button"),
2648 // PlaylistAdd reads as "add to a queue/list" more clearly than a clock
2649 // icon (which can be read as "snooze" or "later"), and isn't confused with
2650 // the Send action sitting right beside the Stop button.
2652 Icons.AutoMirrored.Filled.PlaylistAdd,
2653 contentDescription = stringResource(R.string.queue_followup),
2657 // Show a spinner while the abort REST call is in flight so the user sees the stop
2658 // was sent, and disable to prevent a double-tap.
2661 enabled = !aborting,
2662 modifier = Modifier.padding(start = 4.dp).testTag("stop_button"),
2665 val stopLabel = stringResource(R.string.stop)
2666 CircularProgressIndicator(
2667 Modifier.size(18.dp).semantics { contentDescription = stopLabel },
2671 Icon(Icons.Filled.Stop, contentDescription = stringResource(R.string.stop))
2679 modifier = Modifier.padding(start = 4.dp).testTag("send_button"),
2681 // Tint the send icon with primary when there's content to send so the commit action
2682 // draws the eye; muted otherwise. A plain enabled/disabled flip is easy to miss.
2684 Icons.AutoMirrored.Filled.Send,
2685 contentDescription = stringResource(R.string.send),
2686 tint = if (canSend) MaterialTheme.colorScheme.primary
2687 else MaterialTheme.colorScheme.onSurfaceVariant,
2693@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class)
2695private fun EmptyConversation(
2696 onSuggestion: (String) -> Unit,
2697 modifier: Modifier = Modifier,
2699 // A few example prompts so a first-time user has a one-tap path to their first
2700 // message instead of staring at a blank input. Tapping a chip fills the draft
2701 // (without sending) so the user can edit it before sending.
2702 val suggestions = remember {
2704 R.string.empty_chat_suggest_1,
2705 R.string.empty_chat_suggest_2,
2706 R.string.empty_chat_suggest_3,
2710 modifier = modifier.padding(32.dp),
2711 horizontalAlignment = Alignment.CenterHorizontally,
2714 Icons.Filled.AutoAwesome,
2715 contentDescription = null,
2716 modifier = Modifier.size(48.dp),
2717 tint = MaterialTheme.colorScheme.onSurfaceVariant,
2719 Spacer(Modifier.size(16.dp))
2721 stringResource(R.string.empty_chat_title),
2722 style = MaterialTheme.typography.titleMedium,
2724 Spacer(Modifier.size(8.dp))
2726 stringResource(R.string.empty_chat_hint),
2727 style = MaterialTheme.typography.bodyMedium,
2728 color = MaterialTheme.colorScheme.onSurfaceVariant,
2730 Spacer(Modifier.size(20.dp))
2731 // Suggestion chips wrap across rows on narrow screens instead of stacking full-width,
2732 // so a longer localized prompt doesn't push the chips off-screen on a phone.
2734 modifier = Modifier.fillMaxWidth(),
2735 horizontalArrangement = Arrangement.spacedBy(8.dp),
2736 verticalArrangement = Arrangement.spacedBy(8.dp),
2738 suggestions.forEach { resId ->
2739 val text = stringResource(resId)
2740 androidx.compose.material3.AssistChip(
2741 onClick = { onSuggestion(text) },
2742 label = { Text(text, style = MaterialTheme.typography.bodyMedium) },
2749/** A banner shown while a revert checkpoint is active, offering an Undo (unrevert). When the
2750 * server included a diff of the revert, a collapsible "Show what changed" affordance reveals
2751 * it inline via [DiffView] so the user can see what was rolled back before deciding to undo. */
2753private fun RevertBanner(diff: String?, isEditing: Boolean, onUndo: () -> Unit) {
2754 val haptics = LocalHapticFeedback.current
2755 var showDiff by rememberSaveable { mutableStateOf(false) }
2756 val expandMotion = rememberVisibilityTransitions()
2758 color = MaterialTheme.colorScheme.tertiaryContainer,
2759 tonalElevation = 2.dp,
2760 modifier = Modifier.fillMaxWidth(),
2764 modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp),
2765 verticalAlignment = Alignment.CenterVertically,
2766 horizontalArrangement = Arrangement.SpaceBetween,
2768 Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
2770 if (isEditing) Icons.Filled.Edit else Icons.Filled.Restore,
2771 contentDescription = null,
2772 tint = MaterialTheme.colorScheme.onTertiaryContainer,
2773 modifier = Modifier.size(18.dp),
2776 stringResource(if (isEditing) R.string.editing_banner else R.string.reverted_banner),
2777 style = MaterialTheme.typography.bodySmall,
2778 color = MaterialTheme.colorScheme.onTertiaryContainer,
2779 modifier = Modifier.padding(start = 8.dp),
2781 // "Show what changed" toggle, shown only when the server provided a diff.
2782 // Collapsible so the banner stays compact by default; a user curious about
2783 // what the revert rolled back can expand it inline without leaving the chat.
2784 if (!diff.isNullOrBlank()) {
2787 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
2788 showDiff = !showDiff
2790 contentPadding = androidx.compose.foundation.layout.PaddingValues(start = 8.dp, end = 4.dp),
2793 if (showDiff) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
2794 contentDescription = null,
2795 modifier = Modifier.size(16.dp),
2798 stringResource(if (showDiff) R.string.revert_hide_diff else R.string.revert_show_diff),
2799 style = MaterialTheme.typography.labelSmall,
2800 modifier = Modifier.padding(start = 4.dp),
2805 TextButton(onClick = {
2806 // Haptic on undo to match the other destructive/important confirmations: a
2807 // revert restores potentially hundreds of hidden messages.
2808 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
2810 }) { Text(stringResource(R.string.undo)) }
2812 // The diff render lives inside the banner's Surface so it inherits the tertiary
2813 // tonal container and reads as part of the banner, not a floating panel.
2814 if (!diff.isNullOrBlank()) {
2817 enter = expandMotion.enter,
2818 exit = expandMotion.exit,
2820 Column(modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 8.dp)) {
2822 stringResource(R.string.revert_diff_header),
2823 style = MaterialTheme.typography.labelSmall,
2824 color = MaterialTheme.colorScheme.onTertiaryContainer,
2825 modifier = Modifier.padding(bottom = 4.dp),
2827 DiffView(diff, saveKey = "revertDiff")
2835/** A dismissible preview card shown above the composer when the user quote-replied to a
2836 * message. Displays the quoted text (truncated) with a Cancel affordance so the user can
2837 * abort the quote without digging into the draft to strip the `>`-prefixed lines. Mirrors
2838 * the Discord/Slack quote-preview pattern. */
2840private fun QuoteReplyBanner(quotedText: String, onCancel: () -> Unit) {
2841 val haptics = LocalHapticFeedback.current
2843 color = MaterialTheme.colorScheme.secondaryContainer,
2844 tonalElevation = 2.dp,
2845 modifier = Modifier.fillMaxWidth(),
2848 modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 4.dp, top = 6.dp, bottom = 6.dp),
2849 verticalAlignment = Alignment.Top,
2852 Icons.Filled.FormatQuote,
2853 contentDescription = null,
2854 tint = MaterialTheme.colorScheme.onSecondaryContainer,
2855 modifier = Modifier.size(18.dp).padding(top = 2.dp),
2857 Column(modifier = Modifier.weight(1f).padding(start = 8.dp)) {
2859 stringResource(R.string.quote_reply_banner),
2860 style = MaterialTheme.typography.labelSmall,
2861 color = MaterialTheme.colorScheme.onSecondaryContainer,
2865 style = MaterialTheme.typography.bodySmall,
2866 color = MaterialTheme.colorScheme.onSecondaryContainer,
2868 overflow = TextOverflow.Ellipsis,
2871 IconButton(onClick = {
2872 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
2877 contentDescription = stringResource(R.string.cancel_quote_reply),
2878 tint = MaterialTheme.colorScheme.onSecondaryContainer,
2879 modifier = Modifier.size(18.dp),
2886/** A banner shown while messages composed offline are queued in the outbox, with a
2887 * "Send now" nudge and a discard. Shows a spinner while the queue is flushing. */
2889private fun OutboxBanner(
2892 onFlush: () -> Unit,
2893 onDiscard: () -> Unit,
2895 var showDiscardConfirm by rememberSaveable { mutableStateOf(false) }
2897 color = MaterialTheme.colorScheme.secondaryContainer,
2898 tonalElevation = 2.dp,
2899 modifier = Modifier.fillMaxWidth(),
2902 modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp),
2903 verticalAlignment = Alignment.CenterVertically,
2904 horizontalArrangement = Arrangement.SpaceBetween,
2906 Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
2908 CircularProgressIndicator(
2909 Modifier.size(16.dp),
2911 color = MaterialTheme.colorScheme.onSecondaryContainer,
2913 Spacer(Modifier.size(8.dp))
2917 stringResource(R.string.outbox_sending)
2919 val res = if (count == 1) R.string.outbox_pending_one else R.string.outbox_pending_many
2920 stringResource(res, count)
2922 style = MaterialTheme.typography.bodySmall,
2923 color = MaterialTheme.colorScheme.onSecondaryContainer,
2927 val haptics = LocalHapticFeedback.current
2928 TextButton(onClick = onFlush) { Text(stringResource(R.string.outbox_flush)) }
2929 TextButton(onClick = {
2930 // Confirm before discarding all queued messages (irreversible), matching
2931 // the confirmation pattern used by deleteSession and other destructive
2932 // actions. Previously a single tap dropped every queued message.
2933 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
2934 showDiscardConfirm = true
2936 Text(stringResource(R.string.outbox_discard_all), color = MaterialTheme.colorScheme.error)
2941 if (showDiscardConfirm) {
2942 androidx.compose.material3.AlertDialog(
2943 onDismissRequest = { showDiscardConfirm = false },
2944 title = { Text(stringResource(R.string.discard_queued_title)) },
2945 text = { Text(stringResource(R.string.discard_queued_text, count)) },
2947 androidx.compose.material3.TextButton(onClick = {
2948 showDiscardConfirm = false
2950 }) { Text(stringResource(R.string.discard)) }
2953 androidx.compose.material3.TextButton(onClick = { showDiscardConfirm = false }) {
2954 Text(stringResource(R.string.cancel))
2961/** Prompt for a one-off shell command to run in the session's worktree. */
2963private fun ShellCommandDialog(onDismiss: () -> Unit, onRun: (String) -> Unit) {
2964 var command by rememberSaveable { mutableStateOf("") }
2966 onDismissRequest = onDismiss,
2967 title = { Text(stringResource(R.string.run_shell_command)) },
2971 onValueChange = { command = it },
2972 modifier = Modifier.fillMaxWidth(),
2973 placeholder = { Text(stringResource(R.string.shell_command_hint)) },
2974 label = { Text(stringResource(R.string.shell_command_label)) },
2976 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
2977 keyboardActions = KeyboardActions(onDone = { if (command.isNotBlank()) onRun(command.trim()) }),
2982 onClick = { if (command.isNotBlank()) onRun(command.trim()) },
2983 enabled = command.isNotBlank(),
2984 ) { Text(stringResource(R.string.run)) }
2986 dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } },
2991 * Change signal for the auto-scroll-on-stream watcher. Primitive fields avoid the
2992 * boxing `Triple<Int, Int, Boolean>` would impose on every snapshot evaluation, and
2993 * equality is structural so `snapshotFlow` emits only when something relevant changes.
2995private data class AutoScrollSignal(val size: Int, val lastTextLength: Int, val pinned: Boolean)
2997/** Length of the streaming content carried by [part], driving auto-scroll as it grows.
2998 * Covers text, reasoning, and tool output so a pinned view keeps following non-text
2999 * streaming, not just plain assistant text. O(1). */
3000private fun streamingContentLength(part: Part?): Int = when (part) {
3001 is TextPart -> part.text.length
3002 is ReasoningPart -> part.text.length
3003 is ToolPart -> when (val st = part.state) {
3004 is ToolCompleted -> st.output?.length ?: 0
3005 is ToolError -> st.error?.length ?: 0
3006 is ToolRunning -> st.title?.length ?: 0
3012/** Rename dialog for the currently-open session. Mirrors the SessionListScreen rename
3013 * dialog but lives in the chat screen so the user can rename without backing out to
3014 * the list. Pre-filled with the current title; the cap matches the list dialog. */
3016private fun RenameSessionChatDialog(
3017 initialTitle: String,
3018 onDismiss: () -> Unit,
3019 onConfirm: (String) -> Unit,
3021 // Seed the field once (no key input) so a later external title change — e.g. opencode
3022 // auto-generating a session title after the first prompt, arriving via SessionUpdated —
3023 // doesn't re-initialize the box and discard the user's in-progress edit.
3024 var title by rememberSaveable { mutableStateOf(initialTitle) }
3026 onDismissRequest = onDismiss,
3027 title = { Text(stringResource(R.string.rename_session_chat)) },
3031 onValueChange = { v -> title = v.take(NetworkConfig.maxSessionTitleChars) },
3032 modifier = Modifier.fillMaxWidth(),
3033 placeholder = { Text(stringResource(R.string.session_title_hint)) },
3034 label = { Text(stringResource(R.string.rename_session_chat)) },
3036 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
3037 keyboardActions = KeyboardActions(onDone = { if (title.isNotBlank()) onConfirm(title.trim()) }),
3041 TextButton(onClick = { if (title.isNotBlank()) onConfirm(title.trim()) }, enabled = title.isNotBlank()) {
3042 Text(stringResource(R.string.save))
3046 TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) }
3052 * A single item in the chat message list — either a [Message] bubble or a day
3053 * [Separator] inserted before the first message of a new day. The separator shifts
3054 * the LazyColumn indices vs. the raw messages list, so scroll-target math must use
3055 * the [List] of these items (not `messages.size`/`lastIndex`).
3057private sealed interface MessageListItem {
3059 val contentType: Any
3062 val message: MessageWithParts,
3063 override val key: Any,
3064 // Whether this is the first message in a consecutive run of the same speaker (role).
3065 // Used to suppress the assistant avatar/header on follow-up messages in the same run,
3066 // reducing visual noise in tool-heavy conversations where many assistant messages
3067 // appear back-to-back. A separator or a role change resets the flag.
3068 val isFirstOfSpeaker: Boolean,
3069 ) : MessageListItem {
3070 override val contentType: Any get() = message.info::class
3073 data class Separator(val label: String, val ordinal: Int) : MessageListItem {
3074 // Key on the day-occurrence ordinal, not the label: two non-contiguous groups
3075 // can share a label (e.g. an untimestamped message — empty label — between two
3076 // same-day messages, or two untimestamped groups), and a label-only key would
3077 // then collide, which makes LazyColumn throw on duplicate keys. The ordinal is
3078 // stable for a given message ordering so slots aren't needlessly recreated.
3079 override val key: Any get() = "sep_${ordinal}_$label"
3080 // Separators share a contentType so the LazyColumn can recycle their slots.
3081 override val contentType: Any get() = "separator"
3086 * Build the interleaved list of [MessageListItem]s for the message list, inserting a
3087 * [MessageListItem.Separator] before the first message of each new calendar day. Uses
3088 * the message's `time.created` (falling back to `updated` then `completed`) to bucket.
3089 * Messages with no timestamp are grouped under an empty-label separator only if they
3090 * start the list, so a server that omits timestamps doesn't suppress the first divider.
3092 * [todayLabel]/[yesterdayLabel] are resolved by the caller (a @Composable can't call
3093 * stringResource from inside this plain function) and used for the "Today"/"Yesterday"
3094 * labels; older days fall back to a locale-stable medium-date format.
3096private fun buildMessageListItems(
3097 messages: List<MessageWithParts>,
3099 yesterdayLabel: String,
3100): List<MessageListItem> {
3101 if (messages.isEmpty()) return emptyList()
3102 val today = java.time.LocalDate.now(java.time.ZoneId.systemDefault())
3103 val dateFmt = mediumDateFormat()
3104 val result = ArrayList<MessageListItem>(messages.size + 4)
3105 val seenKeys = HashSet<String>(messages.size * 2)
3106 var lastDayKey: String? = null
3108 var emptyIdOrdinal = 0
3110 // Track the previous message's role to detect consecutive same-speaker runs, so the
3111 // avatar/header can be suppressed on follow-up messages in the same run. A null means
3112 // "no previous message or a separator just interrupted the run", both of which make the
3113 // next message the first of its group.
3114 var prevRoleClass: kotlin.reflect.KClass<out soy.iko.opencode.data.model.MessageInfo>? = null
3115 for (message in messages) {
3116 val ts = message.info.time?.created ?: message.info.time?.updated ?: message.info.time?.completed
3117 val dayKey = ts?.let { dayKey(it) } ?: ""
3118 // Emit a separator before the very first message (so a leading un-timestamped message
3119 // still gets the first divider, empty label and all), and thereafter only when crossing
3120 // into a new NON-empty calendar day. An un-timestamped message mid-list (empty key)
3121 // neither emits its own separator nor resets the day — otherwise a following same-day
3122 // message would see a "" -> "<today>" transition and emit a duplicate "Today" header.
3123 if (first || (dayKey.isNotEmpty() && dayKey != lastDayKey)) {
3124 result.add(MessageListItem.Separator(dayLabel(dayKey, ts ?: 0L, today, todayLabel, yesterdayLabel, dateFmt), sepOrdinal++))
3125 // A date separator visually breaks a speaker run, so the next message is treated
3126 // as the first of its group (even if the same role continues across the day break).
3127 prevRoleClass = null
3129 if (dayKey.isNotEmpty()) lastDayKey = dayKey
3131 // Most messages have a unique non-empty id. An unrecognized-role message can carry an
3132 // empty id — and the reducer intentionally keeps several such messages as distinct
3133 // holders — so give each a stable synthetic key and defensively de-collide any remaining
3134 // duplicate; otherwise LazyColumn throws on a duplicate key and the whole screen crashes.
3135 var key = message.info.id.ifEmpty { "msg-empty-${emptyIdOrdinal++}" }
3136 if (!seenKeys.add(key)) {
3138 while (!seenKeys.add("$key#$suffix")) suffix++
3139 key = "$key#$suffix"
3141 val roleClass = message.info::class
3142 val isFirstOfSpeaker = prevRoleClass == null || roleClass != prevRoleClass
3143 result.add(MessageListItem.Message(message, key, isFirstOfSpeaker))
3144 prevRoleClass = roleClass
3150 * A stable bucket key for a timestamp's calendar day (epoch-days as a string).
3152 * [buildMessageListItems] re-runs on every streaming snapshot (the memo is keyed on the
3153 * whole `messages` list, which must stay so the live message's fresh parts render), so a
3154 * naive implementation would re-allocate an `Instant`/`ZonedDateTime`/`LocalDate` for
3155 * every message on every token. Message timestamps are immutable once assigned, so we
3156 * memoize the parse per epoch-millis: after the first snapshot, each unchanged message is
3157 * a cheap hash lookup. Bounded LRU (access-order) caps memory; only touched on the main
3158 * thread during composition, so the coarse lock is uncontended.
3160private val dayKeyCache = object : LinkedHashMap<Long, String>(128, 0.75f, true) {
3161 override fun removeEldestEntry(eldest: MutableMap.MutableEntry<Long, String>): Boolean = size > 1024
3164private fun dayKey(epochMillis: Long): String = synchronized(dayKeyCache) {
3165 dayKeyCache.getOrPut(epochMillis) {
3166 java.time.Instant.ofEpochMilli(epochMillis)
3167 .atZone(java.time.ZoneId.systemDefault()).toLocalDate().toEpochDay().toString()
3171// The medium-date formatter is an ICU locale lookup; cache one instance instead of
3172// allocating a fresh one per buildMessageListItems call, rebuilding only if the default
3173// locale changes at runtime. Main-thread-only, so no synchronization is needed.
3174private var cachedDateFmt: java.text.DateFormat? = null
3175private var cachedDateFmtLocale: java.util.Locale? = null
3177private fun mediumDateFormat(): java.text.DateFormat {
3178 val locale = java.util.Locale.getDefault()
3179 val existing = cachedDateFmt
3180 if (existing != null && cachedDateFmtLocale == locale) return existing
3181 return java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM).also {
3183 cachedDateFmtLocale = locale
3188 * Resolve a day-key back to a human-readable label. "Today" / "Yesterday" for the
3189 * recent days, otherwise a locale-stable medium-date format. Returns an empty string
3190 * when [dayKey] is empty (no timestamp) so no separator is rendered for un-timestamped
3193private fun dayLabel(
3196 today: java.time.LocalDate,
3198 yesterdayLabel: String,
3199 dateFmt: java.text.DateFormat,
3201 if (dayKey.isEmpty()) return ""
3202 val epochDay = dayKey.toLongOrNull() ?: return ""
3203 val date = java.time.LocalDate.ofEpochDay(epochDay)
3205 date == today -> todayLabel
3206 date == today.minusDays(1) -> yesterdayLabel
3207 else -> dateFmt.format(java.util.Date(ts))
3212 * A centered day divider in the message list. Renders nothing for an empty label
3213 * (the no-timestamp case) so un-timestamped messages don't get a stray blank divider.
3216private fun DateSeparator(label: String) {
3217 if (label.isBlank()) return
3219 modifier = Modifier.fillMaxWidth(),
3220 horizontalArrangement = Arrangement.Center,
3223 color = MaterialTheme.colorScheme.surfaceVariant,
3224 shape = MaterialTheme.shapes.small,
3225 // Marked as a heading so TalkBack users can navigate day-by-day with the
3226 // heading-skip gesture — the canonical affordance for date dividers in a list.
3227 modifier = Modifier.semantics(mergeDescendants = true) { heading() },
3231 style = MaterialTheme.typography.labelSmall,
3232 color = MaterialTheme.colorScheme.onSurfaceVariant,
3233 modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
3239/** Skeleton placeholder bubbles for the chat loading and run-started states. Alpha pulses
3240 * (gated by reduced-motion) to convey ongoing work; the column carries the a11y label so
3241 * TalkBack announces "Loading" instead of reading the skeleton bars. Rendered as a
3242 * [BoxScope] extension so it can align itself to the top center of its host box. */
3244private fun androidx.compose.foundation.layout.BoxScope.ChatSkeleton(topPad: androidx.compose.ui.unit.Dp) {
3245 val loadingLabel = stringResource(R.string.loading)
3246 val reducedMotion = LocalReducedMotion.current
3247 val transition = rememberInfiniteTransition(label = "skeleton")
3248 val pulse by transition.animateFloat(
3249 initialValue = 0.35f,
3251 animationSpec = infiniteRepeatable(tween(900), RepeatMode.Reverse),
3252 label = "skeletonAlpha",
3254 val skeletonAlpha = if (reducedMotion) 0.5f else pulse
3255 val skeletonColor = MaterialTheme.colorScheme.surfaceVariant
3257 fun bar(widthFraction: Float) {
3260 .fillMaxWidth(widthFraction)
3262 .clip(RoundedCornerShape(4.dp))
3263 .background(skeletonColor)
3264 .alpha(skeletonAlpha),
3269 .align(Alignment.TopCenter)
3270 .padding(top = topPad)
3273 .semantics { contentDescription = loadingLabel },
3274 verticalArrangement = Arrangement.spacedBy(14.dp),
3277 val left = i % 2 == 0
3279 modifier = Modifier.fillMaxWidth(),
3280 horizontalArrangement = if (left) Arrangement.Start else Arrangement.End,
3283 modifier = Modifier.fillMaxWidth(if (left) 0.7f else 0.55f),
3284 verticalArrangement = Arrangement.spacedBy(6.dp),
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list