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 attachFailedMsg = stringResource(R.string.attachment_failed)
409 val noCameraMsg = stringResource(R.string.no_camera_app)
410 val noVoiceMsg = stringResource(R.string.no_voice_app)
411 val voicePrompt = stringResource(R.string.voice_prompt)
412 val linkCopiedMsg = stringResource(R.string.link_copied)
413 val ttsUnavailableMsg = stringResource(R.string.tts_unavailable)
415 // Count of picks currently being read + base64-encoded off the main thread, so the
416 // composer can show a staging placeholder immediately (chips only materialize once done).
417 // Backed by the ViewModel (vm.stagingCount / vm.stagingFileTotal) so the staging state
418 // survives a configuration change: the encode work runs on viewModelScope (which
419 // survives rotation), so the counters must live on the VM too — remember { mutableStateOf
420 // } in the composable would reset to 0 on rotation and lose the staging chip mid-encode.
421 val stagingCount by vm.stagingCount.collectAsStateWithLifecycle()
422 val stagingFileTotal by vm.stagingFileTotal.collectAsStateWithLifecycle()
424 // Convert each picked Uri to a base64 attachment on the VM's scope (survives rotation),
425 // honoring the per-prompt count cap and surfacing per-file errors via the VM's errorEvents.
426 fun stageUris(uris: List<Uri>) {
427 vm.stageUris(uris, appContext)
430 val photoPicker = rememberLauncherForActivityResult(
431 ActivityResultContracts.PickMultipleVisualMedia(NetworkConfig.maxAttachments),
432 ) { uris -> stageUris(uris) }
434 val filePicker = rememberLauncherForActivityResult(
435 ActivityResultContracts.GetMultipleContents(),
436 ) { uris -> stageUris(uris) }
438 // Uri survives a config change mid-capture so the TakePicture result still resolves.
439 var pendingCaptureUri by rememberSaveable { mutableStateOf<Uri?>(null) }
440 val cameraLauncher = rememberLauncherForActivityResult(
441 ActivityResultContracts.TakePicture(),
443 val uri = pendingCaptureUri
444 pendingCaptureUri = null
445 if (success && uri != null) stageUris(listOf(uri))
448 val voiceLauncher = rememberLauncherForActivityResult(
449 ActivityResultContracts.StartActivityForResult(),
451 if (result.resultCode == Activity.RESULT_OK) {
452 val spoken = result.data
453 ?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)
455 ?.takeIf { it.isNotBlank() }
456 if (spoken != null) {
457 // Append to the existing draft rather than replacing, so dictation adds to
458 // whatever the user already typed.
459 val current = vm.draft.value
460 val combined = if (current.isBlank()) spoken else "$current $spoken"
461 vm.updateDraft(combined.take(NetworkConfig.maxDraftLengthChars))
467 val uri = newCameraCaptureUri(appContext)
469 scope.launch { snackbar.showSnackbar(attachFailedMsg) }
472 pendingCaptureUri = uri
473 runCatching { cameraLauncher.launch(uri) }
474 .onFailure { pendingCaptureUri = null; scope.launch { snackbar.showSnackbar(noCameraMsg) } }
478 val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
479 putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
480 putExtra(RecognizerIntent.EXTRA_PROMPT, voicePrompt)
482 runCatching { voiceLauncher.launch(intent) }
483 .onFailure { scope.launch { snackbar.showSnackbar(noVoiceMsg) } }
486 // A freshly-created share link is copied to the clipboard so the user can paste it right away.
487 LaunchedEffect(Unit) {
488 vm.shareLinkEvents.collect { url ->
489 copyToClipboard(shareContext, shareContext.getString(R.string.clip_label_share), url)
490 snackbar.showSnackbar(linkCopiedMsg)
494 // Navigate to a session freshly branched from a message (branchFrom), once it's created.
495 // Keyed on Unit (like the sibling one-shot collectors above): keying on the onOpenSession
496 // lambda would cancel/restart this collector whenever the lambda's identity changes, risking
497 // a dropped branch-navigation event in the gap.
498 val branchedMsg = stringResource(R.string.branch_created)
499 LaunchedEffect(Unit) {
500 vm.branchEvents.collect { newId ->
501 showToast(shareContext, branchedMsg)
502 onOpenSession?.invoke(newId)
506 // Stage images shared into the app (via the system share sheet) as attachments.
507 // Keyed on pendingSharedMedia (not Unit) so the effect re-fires when new media arrives
508 // while this ChatScreen is already composed — which happens in two-pane mode, where the
509 // detail ChatScreen is persistent (keyed on sessionId) and a LaunchedEffect(Unit) would
510 // only fire on first composition. consumePendingSharedMedia() atomically drains the
511 // pending list (compareAndSet to empty), so re-firing on the now-empty value is a no-op.
512 val pendingSharedMedia by container.pendingSharedMedia.collectAsStateWithLifecycle()
513 LaunchedEffect(pendingSharedMedia) {
514 val shared = container.consumePendingSharedMedia()
515 if (shared.isNotEmpty()) stageUris(shared.map { Uri.parse(it) })
519 if (vm.send(draft)) {
520 // TextHandleMove (a light tick) is semantically right for a send commit;
521 // LongPress is reserved for long-press/swipe gestures.
522 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
526 BackHandler(enabled = running && !showModelPicker && !showAgentPicker && !showCommandPicker) { showExitConfirm = true }
529 // Hardware-keyboard shortcuts (tablets / DeX / Chromebooks): Ctrl+K opens the command
530 // palette; Escape closes it, or stops a run. onPreviewKeyEvent on the Scaffold root sees
531 // events before the focused text field, but only Ctrl+K / Escape are consumed so normal
532 // typing (and the composer's own Enter-to-send handling) is untouched.
533 modifier = Modifier.onPreviewKeyEvent { ev ->
534 if (ev.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
536 ev.isCtrlPressed && ev.key == Key.K -> { showPalette = true; true }
537 // Ctrl+F opens in-conversation search (the standard find shortcut), focusing
538 // the field so the user can type immediately.
539 ev.isCtrlPressed && ev.key == Key.F -> { searchActive = true; true }
540 ev.key == Key.Escape && showPalette -> { showPalette = false; true }
541 ev.key == Key.Escape && searchActive -> { searchActive = false; chatSearch = ""; true }
542 ev.key == Key.Escape && running && !showExitConfirm -> {
551 val changeLabel = stringResource(R.string.change_model_agent)
552 // The subtitle reads "model · agent", so tapping the title offers *both*
553 // — a small menu picks which picker to open. Previously the tap went
554 // straight to the model picker, leaving the agent changeable only via
555 // the overflow menu despite the title implying otherwise.
558 // mergeDescendants so TalkBack reads the title/subtitle Texts as one
559 // node; the click label rides on `clickable` (onClickLabel) instead of
560 // an explicit contentDescription, which would have SUPPRESSED the child
561 // Texts and announced only "Change model and agent".
565 onClickLabel = changeLabel,
566 ) { showTitleMenu = true }
567 .semantics(mergeDescendants = true) {},
568 verticalAlignment = Alignment.CenterVertically,
570 Column(modifier = Modifier.weight(1f, fill = false)) {
571 // maxLines=1 + Ellipsis so a long session title can't wrap and
572 // push the top bar to multiple lines, shifting the whole layout.
574 sessionTitle ?: sessionLabel,
576 overflow = TextOverflow.Ellipsis,
580 style = MaterialTheme.typography.labelSmall,
581 color = MaterialTheme.colorScheme.onSurfaceVariant,
583 overflow = TextOverflow.Ellipsis,
586 // Always show the affordance so the title reads as tappable even when
587 // the model catalog failed to load (models empty). Without it the title
588 // is indistinguishable from plain text and the pickers are effectively
589 // undiscoverable — and the dropdown items already disable themselves
590 // while a catalog is loading or unavailable.
592 Icons.Filled.KeyboardArrowDown,
593 contentDescription = null,
594 tint = MaterialTheme.colorScheme.onSurfaceVariant,
595 modifier = Modifier.size(20.dp),
598 DropdownMenu(expanded = showTitleMenu, onDismissRequest = { showTitleMenu = false }) {
600 text = { Text(stringResource(R.string.palette_switch_model)) },
601 enabled = !modelsLoading || models.isNotEmpty(),
602 onClick = { showTitleMenu = false; showModelPicker = true },
605 text = { Text(stringResource(R.string.palette_choose_agent)) },
606 enabled = !agentsLoading || agents.isNotEmpty(),
607 onClick = { showTitleMenu = false; showAgentPicker = true },
613 IconButton(onClick = {
614 if (running) showExitConfirm = true else onBack()
616 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
620 // At-a-glance run status in the top bar. When the user is scrolled
621 // up (jump-to-latest FAB visible), the input-bar Stop button and the
622 // trailing "working…" row are off-screen, so a top-bar spinner is the
623 // only signal the agent is still working.
625 val workingLabel = stringResource(R.string.working)
626 CircularProgressIndicator(
627 Modifier.size(18.dp).semantics { contentDescription = workingLabel },
631 // Manual refresh: forces an SSE reconnect, which re-seeds messages
632 // from REST. A recovery path when the stream silently drops and the
633 // auto-reconnect re-seed is slow or fails. Shows a brief spinner as
634 // immediate tap feedback — the SSE reconnect may not visibly change
635 // the connection state when already Connected, so without it the tap
636 // appears to do nothing. Disabled while a run is active: a mid-run
637 // refresh forces a reconnect that can interrupt the streaming reply,
638 // and the working spinner already signals "wait" — the recovery path
639 // is only needed when nothing is actively streaming.
640 val refreshLabel = stringResource(R.string.refresh)
642 onClick = { vm.refreshMessages() },
643 enabled = activeConnection != null && !refreshing && !running,
646 CircularProgressIndicator(
647 Modifier.size(18.dp).semantics { contentDescription = refreshLabel },
651 Icon(Icons.Filled.Refresh, contentDescription = refreshLabel)
654 // Overflow menu consolidates the less-frequent actions (share,
655 // commands, agent, model, rename, delete) so the top bar stays
656 // scannable on narrow phones — previously 5 icons crowded the bar
657 // alongside the back button and tappable title. Catalog-loading
658 // spinners stay inline (next to the overflow) so a load state is
659 // visible without opening the menu.
660 val moreLabel = stringResource(R.string.more)
661 val loadingLabel = stringResource(R.string.loading)
662 val shareLabel = stringResource(R.string.share_conversation)
663 val copyAsMarkdownLabel = stringResource(R.string.copy_as_markdown)
664 val shareAsJsonLabel = stringResource(R.string.share_as_json)
665 val copyAsJsonLabel = stringResource(R.string.copy_as_json)
666 val commandsLabel = stringResource(R.string.commands)
667 val renameLabel = stringResource(R.string.rename_session_chat)
668 val deleteLabel = stringResource(R.string.delete_session_chat)
669 val createShareLabel = stringResource(R.string.create_share_link)
670 val copyShareLabel = stringResource(R.string.copy_share_link)
671 val stopShareLabel = stringResource(R.string.stop_sharing)
672 val summarizeLabel = stringResource(R.string.summarize_conversation)
673 val initLabel = stringResource(R.string.generate_agents_md)
674 val shellLabel = stringResource(R.string.run_shell_command)
676 IconButton(onClick = { showOverflowMenu = true }) {
677 Icon(Icons.Filled.MoreVert, contentDescription = moreLabel)
680 expanded = showOverflowMenu,
681 onDismissRequest = { showOverflowMenu = false },
684 text = { Text(shareLabel) },
685 enabled = hasMessages,
687 showOverflowMenu = false
689 val md = withContext(Dispatchers.Default) {
690 buildConversationMarkdown(vm.messages.value, sessionTitle)
692 val send = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
693 type = "text/markdown"
694 putExtra(android.content.Intent.EXTRA_SUBJECT, sessionTitle ?: defaultShareSubject)
695 putExtra(android.content.Intent.EXTRA_TEXT, md)
697 runCatchingCancellable { shareContext.startActivity(android.content.Intent.createChooser(send, shareLabel)) }
698 .onFailure { showToast(shareContext, shareContext.getString(R.string.no_share_app)) }
702 // Copy the whole conversation as Markdown to the clipboard, so a
703 // user can paste into another app without the share-sheet round-trip.
705 text = { Text(copyAsMarkdownLabel) },
706 enabled = hasMessages,
708 showOverflowMenu = false
710 val md = withContext(Dispatchers.Default) {
711 buildConversationMarkdown(vm.messages.value, sessionTitle)
713 copyToClipboard(shareContext, shareContext.getString(R.string.clip_label_message), md)
717 // Lossless JSON share: the full List<MessageWithParts> serialized with
718 // the shared OpencodeJson, so tool outputs, file sources, and reasoning
719 // survive verbatim (the Markdown export truncates/summarizes them).
721 text = { Text(shareAsJsonLabel) },
722 enabled = hasMessages,
724 showOverflowMenu = false
726 val json = withContext(Dispatchers.Default) {
727 buildConversationJson(vm.messages.value)
729 val send = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
730 type = "application/json"
731 putExtra(android.content.Intent.EXTRA_SUBJECT, sessionTitle ?: defaultShareSubject)
732 putExtra(android.content.Intent.EXTRA_TEXT, json)
734 runCatchingCancellable { shareContext.startActivity(android.content.Intent.createChooser(send, shareAsJsonLabel)) }
735 .onFailure { showToast(shareContext, shareContext.getString(R.string.no_share_app)) }
740 text = { Text(copyAsJsonLabel) },
741 enabled = hasMessages,
743 showOverflowMenu = false
745 val json = withContext(Dispatchers.Default) {
746 buildConversationJson(vm.messages.value)
748 copyToClipboard(shareContext, shareContext.getString(R.string.clip_label_message), json)
753 text = { Text(commandsLabel) },
754 enabled = !commandsLoading || commands.isNotEmpty(),
755 onClick = { showOverflowMenu = false; showCommandPicker = true },
758 text = { Text(stringResource(R.string.keyboard_shortcuts)) },
759 onClick = { showOverflowMenu = false; showShortcutsDialog = true },
762 text = { Text(stringResource(R.string.find_in_conversation)) },
764 showOverflowMenu = false
766 // Focus is requested by the LaunchedEffect(searchActive)
767 // below, which waits for the search field to be composed
768 // before requesting focus (calling requestFocus here would
769 // target a field that isn't in the composition yet).
772 androidx.compose.material3.HorizontalDivider()
773 // Session sharing: create a public link, copy an existing one, or revoke it.
774 if (shareUrl == null) {
776 text = { Text(createShareLabel) },
777 enabled = hasMessages,
778 onClick = { showOverflowMenu = false; vm.shareSession() },
782 text = { Text(copyShareLabel) },
784 showOverflowMenu = false
786 copyToClipboard(shareContext, shareContext.getString(R.string.clip_label_share), it)
787 scope.launch { snackbar.showSnackbar(linkCopiedMsg) }
792 text = { Text(stopShareLabel) },
793 onClick = { showOverflowMenu = false; vm.unshareSession() },
797 text = { Text(summarizeLabel) },
798 enabled = hasMessages && !running,
799 onClick = { showOverflowMenu = false; showSummarizeConfirm = true },
802 text = { Text(initLabel) },
804 onClick = { showOverflowMenu = false; showInitConfirm = true },
807 text = { Text(shellLabel) },
809 onClick = { showOverflowMenu = false; showShellDialog = true },
811 androidx.compose.material3.HorizontalDivider()
813 text = { Text(renameLabel) },
814 onClick = { showOverflowMenu = false; showRenameDialog = true },
817 text = { Text(deleteLabel, color = MaterialTheme.colorScheme.error) },
818 onClick = { showOverflowMenu = false; showDeleteDialog = true },
821 // While a catalog is initially loading (empty list + loading flag),
822 // show a tiny inline spinner beside the overflow so the user can see
823 // a load is in progress without opening the menu. Once loaded the
824 // spinner disappears; the menu items are always enabled (tapping a
825 // picker with no items shows the "no models/agents/commands" state).
826 val catalogLoading = (commandsLoading && commands.isEmpty()) ||
827 (agentsLoading && agents.isEmpty()) ||
828 (modelsLoading && models.isEmpty())
829 if (catalogLoading) {
830 CircularProgressIndicator(
832 .align(Alignment.TopEnd)
834 .semantics { contentDescription = loadingLabel },
842 snackbarHost = { SnackbarHost(snackbar) },
844 val queuedFollowUp by vm.queuedFollowUp.collectAsStateWithLifecycle()
845 val todoPlan by vm.todoPlan.collectAsStateWithLifecycle()
846 val queuedOffline by vm.outbox.collectAsStateWithLifecycle()
847 val outboxSending by container.outboxSending.collectAsStateWithLifecycle()
849 // Pinned above the composer so the agent's plan and progress stay visible
850 // while scrolled up mid-run; renders nothing when there's no plan.
851 TodoPlanBar(todoPlan)
852 val bannerMotion = rememberVisibilityTransitions()
855 enter = bannerMotion.enter,
856 exit = bannerMotion.exit,
861 onUndo = { vm.unrevert() },
865 visible = queuedOffline.isNotEmpty(),
866 enter = bannerMotion.enter,
867 exit = bannerMotion.exit,
870 count = queuedOffline.size,
871 sending = outboxSending,
872 onFlush = { vm.flushQueued() },
873 onDiscard = { vm.discardAllQueued() },
877 visible = pendingQuote != null,
878 enter = bannerMotion.enter,
879 exit = bannerMotion.exit,
881 pendingQuote?.let { QuoteReplyBanner(it, onCancel = { vm.cancelQuoteReply() }) }
885 onValueChange = vm::updateDraft,
888 enabled = activeConnection != null,
889 sendOnEnter = sendOnEnter,
891 onAbort = { vm.abort() },
892 queuedFollowUp = queuedFollowUp,
893 onQueueFollowUp = vm::queueFollowUp,
894 onCancelQueue = { vm.queueFollowUp("") },
895 focusRequester = inputFocusRequester,
896 attachments = attachments,
897 staging = stagingCount > 0,
898 stagingFileCount = stagingFileTotal,
899 onRemoveAttachment = vm::removeAttachment,
902 PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly),
905 onPickFile = { filePicker.launch("*/*") },
906 onCamera = { launchCamera() },
907 onVoice = { launchVoice() },
908 onPasteImage = { stageUris(it) },
910 onRunCommand = { cmd ->
912 // Clear the draft so the typed /prefix doesn't linger after the
913 // command fires (the command picker confirmation, if any, is
914 // handled inside runCommand).
921 // Collect messages inside the content lambda so streaming token updates
922 // recompose only this subtree, not the top bar / input bar / 20+ other
923 // state reads in the ChatScreen body.
924 val messages by vm.messages.collectAsStateWithLifecycle()
925 val optimisticStatuses by vm.optimisticStatuses.collectAsStateWithLifecycle()
926 val listState = rememberLazyListState()
927 val contentScope = rememberCoroutineScope()
928 val timeTick = rememberRelativeTimeTick()
929 // Dismiss the soft keyboard when the user scrolls the message list, a standard Android
930 // chat affordance for reading while the IME is open. Watching isScrollInProgress via
931 // snapshotFlow avoids wiring a nested scroll connection and fires on any scroll gesture.
932 val keyboardController = androidx.compose.ui.platform.LocalSoftwareKeyboardController.current
933 androidx.compose.runtime.LaunchedEffect(listState) {
934 androidx.compose.runtime.snapshotFlow { listState.isScrollInProgress }
935 .distinctUntilChanged()
936 .collect { scrolling -> if (scrolling) keyboardController?.hide() }
937 } // Group messages by day and interleave date separators so a long conversation
938 // has visual "Today"/"Yesterday"/date breaks. Computed once per messages
939 // emission (memoized) so a scroll-induced recomposition doesn't re-scan. Lives
940 // outside the PullToRefreshBox so the auto-scroll effects below can reference
941 // the same list the LazyColumn renders (separators shift the indices vs. the
942 // raw messages list, so scroll targets must be in listItems space).
943 val todayLabel = stringResource(R.string.today)
944 val yesterdayLabel = stringResource(R.string.yesterday)
945 // In-conversation search: when active, narrow to messages whose text contains the query.
946 // Computed on the filtered set so day separators still align with the visible messages.
947 // Debounced + off-main-thread so a keystroke-by-keystroke filter walk over a long
948 // conversation (with many tool parts whose state stringification is non-trivial)
949 // doesn't jank the UI. produceState yields the empty list immediately and publishes
950 // the filtered result once the debounce settles.
951 val searchQuery = chatSearch.trim()
952 // When no search is active, skip the produceState indirection entirely and use the
953 // messages list directly. produceState keyed on (messages, searchQuery) re-launches
954 // its coroutine on every messages emission (every streamed token) even when the
955 // query is empty — the body just re-assigns `value = messages`, but the coroutine
956 // churn (launch + cancel + state write) ~20x/sec is pure overhead. Using `messages`
957 // directly when there's no search avoids that, and `buildMessageListItems` keys its
958 // own remember on the resulting list identity either way.
959 val searchMessages: List<MessageWithParts> = if (searchQuery.isEmpty()) {
962 // Capture the messages list via rememberUpdatedState so the latest list is read
963 // when the debounce completes, WITHOUT re-launching the produceState coroutine on
964 // every messages emission. Keying produceState on `messages` restarts the debounce
965 // delay from zero on every streamed token (~20×/sec); if tokens arrive faster than
966 // the debounce interval the filter never runs and search shows "no matches" for the
967 // entire run. Keying only on `searchQuery` lets the debounce settle once per query
968 // while still filtering against the most recent list.
969 val currentMessages by rememberUpdatedState(messages)
970 val filtered by produceState(
971 initialValue = emptyList(),
974 // Debounce: wait for the user to stop typing before running the filter,
975 // so each keystroke doesn't kick off a fresh scan.
976 kotlinx.coroutines.delay(NetworkConfig.chatSearchDebounceMs)
977 // Run the filter off the main thread — the walk stringifies tool state
978 // and scans every part of every message, which is non-trivial work for a
979 // long conversation.
980 value = withContext(Dispatchers.Default) {
981 currentMessages.filter { m ->
984 is TextPart -> p.text.contains(searchQuery, ignoreCase = true)
985 is ReasoningPart -> p.text.contains(searchQuery, ignoreCase = true)
987 // Match the tool name plus its running/completed/error text and the
988 // stringified input, so searching for an error string or a file the
989 // tool touched actually finds the message it appeared in.
990 p.tool.contains(searchQuery, ignoreCase = true) ||
991 (p.state is ToolRunning && p.state.title?.contains(searchQuery, ignoreCase = true) == true) ||
992 (p.state is ToolCompleted && (p.state.title?.contains(searchQuery, ignoreCase = true) == true ||
993 p.state.output?.contains(searchQuery, ignoreCase = true) == true)) ||
994 (p.state is ToolError && p.state.error?.contains(searchQuery, ignoreCase = true) == true) ||
995 p.state.inputElement()?.toString()?.contains(searchQuery, ignoreCase = true) == true
997 is FilePart -> p.filename?.contains(searchQuery, ignoreCase = true) == true ||
998 p.sourcePath?.contains(searchQuery, ignoreCase = true) == true
1007 // Keep the focused-match index in range as the query (and thus the match set) changes.
1008 // A new query resets to the first match (keyed on searchQuery alone so any change —
1009 // even one that still yields enough matches — jumps to index 0 rather than carrying
1010 // the old index over to a semantically unrelated message). A shrunk set clamps to
1011 // the last valid index (keyed on size so a content refresh that shrinks the matches
1012 // without a query change doesn't leave searchPos out of range).
1013 LaunchedEffect(searchQuery) {
1016 LaunchedEffect(searchMessages.size) {
1017 if (searchPos >= searchMessages.size) searchPos = 0
1019 val listItems = remember(searchMessages, todayLabel, yesterdayLabel) {
1020 buildMessageListItems(searchMessages, todayLabel, yesterdayLabel)
1022 // listItems is a plain (non-snapshot) local, rebuilt each recomposition. The
1023 // derivedStateOf and snapshotFlow lambdas below are created once (keyless
1024 // remember / LaunchedEffect(Unit)); reading `listItems` directly inside them
1025 // would capture the first composition's list — which is empty, since `messages`
1026 // starts empty — freezing it forever. That pinned isPinnedToBottom at true and
1027 // AutoScrollSignal.size at 0, breaking streaming auto-scroll and the
1028 // jump-to-latest FAB. rememberUpdatedState hands those lambdas a stable State
1029 // whose value tracks the latest list.
1030 val currentListItems by rememberUpdatedState(listItems)
1032 // New-content signal for the jump-to-latest FAB: counts how many new items arrived
1033 // while the user was scrolled away from the bottom, cleared once they return. A count
1034 // (vs. a bare dot) tells the user how much they missed at a glance.
1035 var newContentCount by remember { mutableIntStateOf(0) }
1037 val isPinnedToBottom by remember {
1039 val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
1040 // When a run is active, the LazyColumn has one trailing "__typing"
1041 // item after the last list item. Account for it so "pinned to bottom"
1042 // includes the working indicator — otherwise the pin check targets
1043 // listItems.lastIndex, the typing row sits just below the viewport,
1044 // and the user never sees the progress indicator even when pinned.
1045 val items = currentListItems
1046 val effectiveLast = if (running) items.size else items.lastIndex
1047 lastVisible >= effectiveLast || items.isEmpty()
1051 // Jump to the newest message once, when the conversation first loads. Guarded by a
1052 // saveable one-shot flag so it doesn't re-fire on recomposition after a config change
1053 // (rotation) — rememberLazyListState restores the user's scroll offset, and an
1054 // unconditional scrollToItem here would clobber it, snapping them back to the bottom.
1055 var didInitialScroll by rememberSaveable { mutableStateOf(false) }
1056 LaunchedEffect(listItems.isNotEmpty()) {
1057 if (listItems.isNotEmpty() && !didInitialScroll) {
1058 // When a run is active the LazyColumn has a trailing "__typing" row at
1059 // listItems.size; target it (not listItems.lastIndex) so deep-linking into a
1060 // running session lands truly pinned to bottom and streaming auto-scroll engages.
1061 listState.scrollToItem(if (running) listItems.size else listItems.lastIndex)
1062 didInitialScroll = true
1066 // When a run starts, the LazyColumn gains a trailing "__typing" row, so
1067 // effectiveLast jumps from listItems.lastIndex to listItems.size and the
1068 // isPinnedToBottom check flips to false the instant you send — freezing
1069 // auto-scroll. If the user was pinned to the last item just before the run
1070 // began, bring the typing row into view so the pin (and streaming follow) is
1071 // preserved. Guard on the raw last-visible index (isPinnedToBottom has already
1072 // recomputed to false by now) so we don't scroll when the user had scrolled up.
1073 LaunchedEffect(running) {
1074 if (running && listItems.isNotEmpty()) {
1075 val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
1076 if (lastVisible >= listItems.lastIndex) {
1077 runCatchingCancellable { listState.animateScrollToItem(listItems.size) }
1082 // Scroll the focused search match into view whenever the user steps through results with
1083 // the up/down arrows. Resolves the filtered message to its slot in listItems (which
1084 // includes date separators) and animates there.
1085 LaunchedEffect(searchPos, searchQuery, listItems) {
1086 if (!searchActive || searchQuery.isEmpty()) return@LaunchedEffect
1087 val target = searchMessages.getOrNull(searchPos) ?: return@LaunchedEffect
1088 val index = listItems.indexOfFirst {
1089 it is MessageListItem.Message && it.message.info.id == target.info.id
1091 if (index >= 0) runCatchingCancellable { listState.animateScrollToItem(index) }
1094 // Global-search deep link: scroll to and briefly highlight the matched message. The
1095 // focus id is threaded through the chat route from GlobalSearchScreen. The highlight
1096 // clears itself after a short delay so the user can re-read the surrounding context
1097 // without a persistent marker. Runs once per focusMessageId (cleared on consume).
1098 var focusedMessageId by remember(focusMessageId) { mutableStateOf(focusMessageId) }
1099 LaunchedEffect(focusedMessageId, listItems) {
1100 val focus = focusedMessageId ?: return@LaunchedEffect
1101 if (listItems.isEmpty()) return@LaunchedEffect
1102 val index = listItems.indexOfFirst {
1103 it is MessageListItem.Message && it.message.info.id == focus
1105 if (index < 0) return@LaunchedEffect
1106 runCatchingCancellable { listState.animateScrollToItem(index) }
1107 kotlinx.coroutines.delay(2500)
1108 focusedMessageId = null
1111 // Scroll to bottom when the IME opens and the user was already pinned to bottom, so the
1112 // latest reply stays visible as the composer rises. Without this the IME can obscure the
1113 // last message even though the user was reading it a moment ago. WindowInsets.ime is a
1114 // @Composable property, so we read it inside a snapshotFlow registered in the composable
1115 // scope; the flow re-emits whenever the IME inset changes (open/close transitions).
1116 val density = androidx.compose.ui.platform.LocalDensity.current
1117 var wasImeOpen by remember { mutableStateOf(false) }
1118 val imeBottomPx = WindowInsets.ime.getBottom(density)
1119 androidx.compose.runtime.LaunchedEffect(imeBottomPx, listItems.isNotEmpty(), isPinnedToBottom) {
1120 if (listItems.isEmpty()) return@LaunchedEffect
1121 val imeOpen = imeBottomPx > 0
1122 // Scroll to bottom only on the open transition (not close) and only when the user
1123 // was already pinned to bottom — otherwise we'd yank them out of a mid-history read.
1124 val shouldScroll = imeOpen && !wasImeOpen && isPinnedToBottom
1126 runCatchingCancellable {
1127 listState.animateScrollToItem(if (running) listItems.size else listItems.lastIndex)
1130 wasImeOpen = imeOpen
1133 LaunchedEffect(Unit) {
1137 // Track list size + the streaming length of the last part so we auto-scroll
1138 // as content arrives, plus the pinned flag. A small data class with primitive
1139 // fields avoids the per-frame boxing that Triple<Int,Int,Boolean> pays
1140 // (snapshotFlow re-evaluates this lambda every snapshot), and reading only the
1141 // last part's length is O(1) vs. a sumOf over every part. Covering reasoning
1142 // and tool output — not just TextPart — keeps a pinned view following a long
1143 // reasoning/tool block while it streams (previously it stalled at 0).
1144 val lastLen = streamingContentLength(messages.lastOrNull()?.parts?.lastOrNull())
1145 AutoScrollSignal(currentListItems.size, lastLen, isPinnedToBottom)
1146 }.collect { signal ->
1147 if (signal.pinned) {
1148 // Back at the bottom: clear the new-content badge.
1150 if (signal.size > 0) {
1151 // Scroll to the effective last index, including the trailing
1152 // "__typing" row when a run is active so the working indicator
1153 // is brought into view (not just the last message).
1154 val items = currentListItems
1155 val target = if (running) items.size else items.lastIndex
1156 listState.scrollToItem(target)
1158 } else if (signal.size > prevSize || signal.lastTextLength > prevLen) {
1159 // Content arrived while scrolled up — increment the jump-to-latest badge.
1160 // Only count whole new items (not per-token growth) so the number reflects
1161 // new messages, not a token count.
1162 if (signal.size > prevSize) newContentCount += signal.size - prevSize
1163 else if (newContentCount == 0) newContentCount = 1
1165 prevSize = signal.size
1166 prevLen = signal.lastTextLength
1170 CompositionLocalProvider(LocalRelativeTimeTick provides timeTick) {
1171 // The bottomBar (ChatInputBar) already has imePadding, and the Scaffold's
1172 // content padding accounts for the bottomBar's raised height, so the message
1173 // list is already above the keyboard. Adding imePadding here would double-apply
1174 // the IME inset and push messages too far up.
1176 // BoxWithConstraints so the FABs and search bar can align to the capped (800dp)
1177 // message list on wide screens instead of floating at the screen edges — on a
1178 // tablet the list is a centered column, so edge-aligned FABs are visually detached.
1179 // The horizontal margin below is applied to the FAB/search containers so they
1180 // track the list's left/right edges.
1181 androidx.compose.foundation.layout.BoxWithConstraints(
1182 modifier = Modifier.fillMaxSize().padding(padding),
1184 val screenMaxWidth = maxWidth
1185 val listMaxWidth = NetworkConfig.chatContentMaxWidthDp.dp
1186 // Horizontal margin between the capped list's edge and the screen edge (0 on
1187 // screens narrower than the cap). Applied as start/end padding so the FABs and
1188 // search bar sit at the list's edges, not the screen's.
1189 val sideMargin = ((screenMaxWidth - listMaxWidth) / 2).coerceAtLeast(0.dp)
1190 if (activeConnection == null) {
1192 modifier = Modifier.align(Alignment.Center).padding(24.dp),
1193 horizontalAlignment = Alignment.CenterHorizontally,
1196 stringResource(R.string.not_connected),
1197 color = MaterialTheme.colorScheme.error,
1199 Spacer(Modifier.size(12.dp))
1200 Button(onClick = { vm.reconnect() }, enabled = !reconnecting) {
1202 CircularProgressIndicator(
1203 Modifier.size(20.dp),
1204 color = MaterialTheme.colorScheme.onPrimary,
1208 Text(stringResource(R.string.reconnect))
1213 val bannerVisible = !isOnline || connectionState != EventStreamClient.ConnectionState.Connected
1214 // Measure the banner's actual height instead of hardcoding 44dp. At large
1215 // accessibility font scales the banner's labelMedium text + spinner + Retry
1216 // button exceed 44dp, so a fixed inset lets the first message hide behind it
1217 // (the same hazard FileViewScreen's overlay calls out). onSizeChanged only
1218 // fires while the banner is composed (visible), so gate the inset on
1219 // bannerVisible and fall back to 44dp until the first measurement arrives.
1220 var bannerHeightPx by remember { mutableIntStateOf(0) }
1221 // Track the search bar height so the list's top padding can grow when search
1222 // is active — otherwise a scrolled-to match lands at index 0, hidden behind
1223 // the floating search bar (the "scroll-under-the-search-bar" bug).
1224 var searchbarHeightPx by remember { mutableIntStateOf(0) }
1225 val density = LocalDensity.current
1226 val topPad = if (bannerVisible) {
1227 if (bannerHeightPx > 0) {
1228 with(density) { bannerHeightPx.toDp() } + 4.dp
1234 } + if (searchActive && searchbarHeightPx > 0) {
1235 with(density) { searchbarHeightPx.toDp() } + 4.dp
1240 state = connectionState,
1241 modifier = Modifier.align(Alignment.TopCenter).onSizeChanged { bannerHeightPx = it.height },
1242 isOnline = isOnline,
1243 // On a hard endpoint failure, retry by forcing an SSE reconnect (which
1244 // re-seeds from REST). refreshMessages is the right recovery path
1245 // when the connection is present but the stream died; reconnect()
1246 // is the path when the whole connection is gone (handled by the
1247 // separate "Not connected" state below).
1248 onRetry = { vm.refreshMessages() },
1249 // On an auth failure (401/403), retrying with the same bad credentials
1250 // is futile — offer a one-tap path to edit the active profile's
1251 // credentials instead of making the user hunt for the server list.
1252 onEditCredentials = {
1253 val profileId = activeConnection?.profile?.id
1254 if (profileId != null) onEditProfile?.invoke(profileId)
1256 reconnectAttempts = reconnectAttempts,
1258 // Persistent inline error banner for a mid-conversation load failure.
1259 // Unlike the one-shot snackbar (which fires once per streak then goes
1260 // silent), this stays visible until the stream recovers, so a persistent
1261 // outage doesn't read as "everything is fine" after the snackbar dismisses.
1262 // Anchored at the top, below the connection banner, so it doesn't overlap
1263 // the message list.
1264 if (loadErrorInline && hasMessages) {
1266 color = MaterialTheme.colorScheme.errorContainer,
1267 shape = MaterialTheme.shapes.small,
1269 .align(Alignment.TopCenter)
1270 .padding(top = if (bannerVisible) topPad else 8.dp)
1271 .padding(horizontal = 16.dp)
1272 .semantics { liveRegion = LiveRegionMode.Polite },
1275 modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
1276 verticalAlignment = Alignment.CenterVertically,
1277 horizontalArrangement = Arrangement.spacedBy(8.dp),
1280 Icons.Filled.ErrorOutline,
1281 contentDescription = null,
1282 modifier = Modifier.size(16.dp),
1283 tint = MaterialTheme.colorScheme.onErrorContainer,
1286 stringResource(R.string.load_error_persistent),
1287 style = MaterialTheme.typography.labelMedium,
1288 color = MaterialTheme.colorScheme.onErrorContainer,
1289 modifier = Modifier.weight(1f),
1292 onClick = { vm.refreshMessages() },
1293 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp),
1295 Text(stringResource(R.string.retry))
1300 // Pull-to-refresh wraps ALL content states (loading, error, empty, list) so
1301 // recovery is a swipe away everywhere — not just when the message list is
1302 // populated. Previously PTR was only around the populated list, leaving the
1303 // error/loading states reachable only via a button tap.
1305 isRefreshing = refreshing,
1306 // Suppress the refresh action while a run is active: a mid-stream
1307 // refresh forces an SSE reconnect that can interrupt the actively
1308 // streaming reply (matching the top-bar refresh button, which is
1309 // also disabled while running). The pull gesture still responds, but
1310 // the release no-ops instead of triggering a disruptive re-seed.
1311 onRefresh = { if (!running) vm.refreshMessages() },
1312 // Cap width and center on large screens for readability — full-width
1313 // bubbles stretch edge-to-edge on tablets, hurting line-length comfort.
1314 modifier = Modifier.fillMaxSize().wrapContentWidth(Alignment.CenterHorizontally)
1315 .widthIn(max = NetworkConfig.chatContentMaxWidthDp.dp),
1317 // Reserve top space when the banner is visible so it doesn't overlap the
1318 // first message / state content. (topPad computed above.)
1319 if (loading && messages.isEmpty()) {
1320 // Skeleton bubbles pre-structure the layout so the perceived load is faster
1321 // than a bare centered spinner and the first real messages pop into an
1322 // already-shaped list instead of a blank-then-pop.
1323 ChatSkeleton(topPad = topPad)
1324 } else if (loadError && messages.isEmpty()) {
1325 // A failed load with nothing to show. Distinct from the empty
1326 // conversation state below — the conversation isn't empty, it
1327 // failed to load, and offering "Start a conversation" here is
1328 // misleading. Offer a Retry instead, mirroring SessionListScreen.
1330 modifier = Modifier.align(Alignment.Center).padding(top = topPad).padding(24.dp),
1331 horizontalAlignment = Alignment.CenterHorizontally,
1334 stringResource(R.string.load_messages_failed),
1335 color = MaterialTheme.colorScheme.error,
1337 Spacer(Modifier.size(12.dp))
1338 Button(onClick = { vm.refreshMessages() }) {
1339 Text(stringResource(R.string.retry))
1341 // When the VM's retryWhen loop is actively retrying (loading is true
1342 // on top of the error state), surface a small "retrying…" indicator so
1343 // the user knows the system is working — without it, the static error
1344 // + Retry button reads as "broken, tap to fix", and the user may not
1345 // realize an automatic retry is already in progress.
1347 Spacer(Modifier.size(12.dp))
1348 val retryingLabel = stringResource(R.string.retrying_ellipsis)
1349 Row(verticalAlignment = Alignment.CenterVertically) {
1350 CircularProgressIndicator(
1351 Modifier.size(14.dp),
1356 style = MaterialTheme.typography.labelSmall,
1357 color = MaterialTheme.colorScheme.onSurfaceVariant,
1358 modifier = Modifier.padding(start = 6.dp),
1363 } else if (messages.isEmpty() && running) {
1364 // A run just started but no parts have arrived yet — the empty list with
1365 // only the trailing "working…" row looks broken. Reuse the skeleton bubbles
1366 // from the initial-load branch so the perceived start latency is lower and
1367 // the first real assistant part pops into an already-shaped list.
1368 ChatSkeleton(topPad = topPad)
1369 } else if (messages.isEmpty()) {
1373 // Focus the input so the user can edit the suggestion before
1374 // sending, instead of having to manually tap the field.
1375 runCatching { inputFocusRequester.requestFocus() }
1377 modifier = Modifier.align(Alignment.Center).padding(top = topPad),
1380 val lastMessageId = messages.lastOrNull()?.info?.id
1381 // Text-to-speech for reading assistant replies aloud; shuts down when the
1382 // message list leaves composition.
1383 val tts = rememberTtsController()
1384 val speakingMessageId by tts.speakingId
1385 val ttsState by tts.state
1386 // Provide the in-conversation search query to the markdown renderer so matching
1387 // text spans are highlighted in-place (in addition to the focused-row highlight
1388 // above). Scoped to the list so cleared search stops highlighting immediately.
1389 val activeSearchQuery = searchQuery.takeIf { searchActive && it.isNotEmpty() }
1390 CompositionLocalProvider(LocalSearchHighlight provides activeSearchQuery) {
1393 modifier = Modifier.fillMaxSize(),
1394 // Extra bottom padding reserves room for the jump-to-latest FAB so it
1395 // never floats over the last message. Top padding grows when the
1396 // connection banner is visible so it doesn't overlap the first message.
1397 contentPadding = PaddingValues(
1401 bottom = 16.dp + NetworkConfig.chatListFabInsetDp.dp,
1403 // Compact spacing (8dp) vs the default 16dp — a power-user toggle to fit
1404 // more conversation on screen without scrolling as much.
1405 verticalArrangement = Arrangement.spacedBy(if (compactSpacing) 8.dp else 16.dp),
1410 contentType = { it.contentType },
1412 // When in-conversation search is stepping through matches, highlight the
1413 // focused one's row so the user can spot it at a glance among the filtered
1414 // results (full in-text term highlighting would require threading the query
1415 // through the markdown renderer; the row highlight + scroll-to-match covers
1416 // the navigation use case desktop chat apps solve with next/prev).
1417 val focusedMatchId = if (searchActive && searchQuery.isNotEmpty()) {
1418 searchMessages.getOrNull(searchPos)?.info?.id
1420 val isFocusedMatch = focusedMatchId != null &&
1421 item is MessageListItem.Message &&
1422 item.message.info.id == focusedMatchId
1423 // Default placement animation so inserted/moved rows glide in. Skipped
1424 // entirely under reduced motion so a streaming message growing in place
1425 // doesn't animate on every token (and respects the a11y preference).
1428 .then(reducedMotionAnimateItem())
1430 if (isFocusedMatch) {
1431 Modifier.background(
1432 MaterialTheme.colorScheme.secondaryContainer,
1433 MaterialTheme.shapes.medium,
1441 is MessageListItem.Separator -> DateSeparator(item.label)
1442 is MessageListItem.Message -> {
1443 val message = item.message
1444 val messageId = message.info.id
1445 // Only the last (streaming) message needs isRunning — it drives the
1446 // reasoning-block spinner. Passing the live flag to every bubble
1447 // makes all visible messages recompose whenever a run starts or stops.
1448 val modelLabel = remember(message.info, models) {
1449 (message.info as? soy.iko.opencode.data.model.AssistantMessage)
1450 ?.let { resolveModelLabel(it, models) }
1452 // Key agentLabel/quoteText on the message id (not message.parts) so
1453 // the streaming message — whose `parts` list is replaced on every
1454 // token — doesn't re-run filterIsInstance per token for these two
1455 // derived labels. For a non-streaming message, `message.parts` is
1456 // reference-stable anyway, so this is equivalent. For the streaming
1457 // message the label updates as parts change via the message.info/
1458 // parts reference change below — keyed on messageId + a parts-hash
1459 // is overkill; agentLabel/quoteText only need to reflect the latest
1460 // parts, and `remember(messageId, message.parts)` would re-key per
1461 // token. Instead key on `messageId` alone and accept that the label
1462 // lags by one recomposition for the streaming bubble (it stabilizes
1463 // once the stream finishes). This is invisible in practice: agent
1464 // label is set on the first part, quote text grows with text parts
1465 // but is only consumed on a swipe (user-initiated, post-stream).
1466 val agentLabel = remember(messageId, message.parts) {
1468 .filterIsInstance<soy.iko.opencode.data.model.AgentPart>()
1469 .firstOrNull { it.name.isNotBlank() }?.name
1471 // Text used to drive swipe-to-reply (and pre-fill the quote). Empty for
1472 // image/code-only messages, which then opt out of the swipe gesture.
1473 val quoteText = remember(messageId, message.parts) {
1475 .filterIsInstance<TextPart>()
1476 .joinToString("\n\n") { it.text }
1478 .takeIf { it.isNotEmpty() }
1480 // Swipe-start-to-end reveals a reply affordance and triggers quote-reply,
1481 // snapping back (the message isn't dismissed — the quote fills the composer).
1482 // Enabled for non-text messages too (image/tool-only), but the confirm
1483 // callback gives a haptic without triggering reply — so the user gets a
1484 // perceptible "can't quote this" signal instead of a silently dead gesture.
1485 val cantQuoteMsg = stringResource(R.string.cannot_quote_this)
1486 val undoLabel = stringResource(R.string.undo)
1487 val quoteReplyLabel = stringResource(R.string.quote_reply_banner)
1488 // Memoize the swipe confirmValueChange lambda on the message id + quoteText
1489 // so it's stable across recompositions (a fresh lambda would force the
1490 // SwipeToDismissBox to re-instantiate its state capture). quoteText is a
1491 // derived value that's stable for non-streaming messages.
1492 val swipeState = rememberSwipeToDismissBoxState(
1493 confirmValueChange = remember(messageId, quoteText) {
1495 val replyValue = if (layoutDirection == LayoutDirection.Rtl) {
1496 SwipeToDismissBoxValue.EndToStart
1498 SwipeToDismissBoxValue.StartToEnd
1500 if (value == replyValue) {
1501 if (quoteText != null) {
1502 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
1503 vm.quoteReply(quoteText)
1504 runCatching { inputFocusRequester.requestFocus() }
1506 val res = snackbar.showSnackbar(
1507 message = quoteReplyLabel,
1508 actionLabel = undoLabel,
1510 if (res == androidx.compose.material3.SnackbarResult.ActionPerformed) {
1511 vm.cancelQuoteReply()
1515 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
1516 scope.launch { snackbar.showSnackbar(cantQuoteMsg) }
1523 // In RTL locales, mirror the swipe direction so the reply
1524 // gesture reads naturally (swipe from the trailing edge
1525 // toward the leading edge), matching how LTR users swipe
1526 // left-to-right. Without this, RTL users had no reply swipe.
1527 val replyFromStartToEnd = layoutDirection != LayoutDirection.Rtl
1528 // Memoize every MessageBubble lambda on the message id so a recomposition
1529 // triggered by a sibling (e.g. the streaming message's parts changing,
1530 // which gives `listItems` a new identity) doesn't allocate fresh lambdas
1531 // for the unchanged bubbles. MessageBubble is non-skippable (it takes
1532 // many `(() -> Unit)?` params, which Compose treats as unstable), so
1533 // without memoization every visible bubble recomposes on every token,
1534 // defeating the @Immutable MessageWithParts reference-stability the
1535 // reducer's Holder cache provides. This mirrors the SessionListScreen
1536 // per-card lambda memoization pattern.
1537 val onRevert = remember(messageId) { { vm.revertTo(messageId) } }
1538 val onEdit = remember(messageId) {
1540 // editMessage reverts to before this message (hiding it and
1541 // everything after); the revert banner surfaces the rewind
1542 // with an Undo. Focus + scroll the composer into view like the
1543 // Quote action so the prefilled draft isn't off-screen.
1544 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
1545 vm.editMessage(messageId, text)
1546 runCatching { inputFocusRequester.requestFocus() }
1547 contentScope.launch {
1548 runCatchingCancellable {
1549 listState.animateScrollToItem(currentListItems.lastIndex.coerceAtLeast(0))
1555 val onSpeak = remember(messageId) {
1557 if (!tts.toggle(messageId, text)) {
1558 scope.launch { snackbar.showSnackbar(ttsUnavailableMsg) }
1563 val onPause = remember { { tts.pause() } }
1564 val onResume = remember { { tts.resume() } }
1565 val onStop = remember { { tts.stop() } }
1566 val onQuote = remember(messageId) {
1569 runCatching { inputFocusRequester.requestFocus() }
1573 val onBranch = remember { { text: String -> vm.branchFrom(text); Unit } }
1574 // Regenerate is only meaningful for a finished assistant reply
1575 // (not the actively-streaming one), so gate it on !running.
1576 val onRegenerate = if (!running) {
1577 remember(messageId) { { vm.regenerate(messageId) } }
1581 // Continue: resume a partial reply. Shown only for
1582 // incomplete assistant messages (a partial reply that
1583 // was aborted) and not while a run is active.
1584 val canContinue = !running && message.info is AssistantMessage && !message.info.isComplete
1585 val onContinue = if (canContinue) {
1586 remember(messageId) { { vm.continueRun(messageId) } }
1590 val messageFailed = optimisticStatuses[messageId] == true
1591 val onRetry = if (messageFailed) {
1592 remember(messageId) { { vm.retryOptimisticMessage(messageId); Unit } }
1596 val onDismiss = if (messageFailed) {
1597 remember(messageId) { { vm.dismissOptimistic(messageId) } }
1601 val onShare = remember(messageId, sessionTitle, defaultShareSubject) {
1603 // Per-message share: build a single-message Markdown
1604 // transcript off-thread and fire ACTION_SEND, mirroring the
1605 // whole-conversation share but scoped to just this message.
1607 val md = withContext(Dispatchers.Default) {
1608 buildMessageMarkdown(message)
1610 val send = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
1611 type = "text/markdown"
1612 putExtra(android.content.Intent.EXTRA_SUBJECT, sessionTitle ?: defaultShareSubject)
1613 putExtra(android.content.Intent.EXTRA_TEXT, md)
1615 runCatchingCancellable { shareContext.startActivity(android.content.Intent.createChooser(send, shareContext.getString(R.string.share_message))) }
1616 .onFailure { showToast(shareContext, shareContext.getString(R.string.no_share_app)) }
1623 enableDismissFromEndToStart = !replyFromStartToEnd,
1624 enableDismissFromStartToEnd = replyFromStartToEnd,
1625 backgroundContent = {
1627 Modifier.fillMaxSize().padding(horizontal = 16.dp),
1628 contentAlignment = if (replyFromStartToEnd) Alignment.CenterStart else Alignment.CenterEnd,
1631 Icons.AutoMirrored.Filled.Reply,
1632 contentDescription = null,
1633 tint = MaterialTheme.colorScheme.onSurfaceVariant,
1640 isRunning = running && messageId == lastMessageId,
1641 imageContext = imageContext,
1642 modelLabel = modelLabel,
1643 agentLabel = agentLabel,
1644 onOpenFile = onOpenFile,
1645 onRevert = onRevert,
1648 isSpeaking = messageId == speakingMessageId,
1649 ttsState = if (messageId == speakingMessageId) ttsState else TtsState.IDLE,
1651 onResume = onResume,
1654 onBranch = onBranch,
1655 onRegenerate = onRegenerate,
1656 onContinue = onContinue,
1657 sendStatus = optimisticStatuses[messageId]?.let { failed ->
1658 if (failed) soy.iko.opencode.ui.chat.MessageSendStatus.FAILED
1659 else soy.iko.opencode.ui.chat.MessageSendStatus.SENDING
1661 isEdited = message.info.time?.let { it.updated != null && it.updated != it.created } == true,
1663 onDismiss = onDismiss,
1665 isFirstOfSpeaker = item.isFirstOfSpeaker,
1666 highlighted = focusedMessageId != null && messageId == focusedMessageId,
1674 item(key = "__typing") {
1675 val workingText = stringResource(R.string.working)
1676 // When the SSE stream dropped mid-run, show a "reconnecting" label
1677 // instead of the plain "working" text so the user understands the
1678 // run is still active but the stream is reconnecting (not finished).
1679 val reconnectingText = stringResource(R.string.reconnecting)
1680 val interruptedLabel = if (streamInterrupted) reconnectingText else workingText
1681 // Elapsed-since-run chip driven by the VM's runStartMs so the timer
1682 // survives LazyColumn recycling — a local remember reset to 0:00 when
1683 // the row was disposed and scrolled back into view. The VM stamps the
1684 // start on the real false→true transition (send/run/init), not on
1685 // SSE-reconnect relight, so reconnects don't restart the clock. Updates
1686 // once per second; the value is decorative (the a11y label is the row's).
1687 // The clock freezes when an abort is confirmed (aborting = true) so the
1688 // user can see how long the run lasted before they stopped it, rather
1689 // than the clock continuing to tick until SessionIdle clears _running.
1690 val startMs by vm.runStartMs.collectAsStateWithLifecycle()
1691 val elapsedMs by produceState(0L, startMs, aborting) {
1692 if (startMs == 0L) return@produceState
1694 // Freeze at the last-computed value: one final snapshot then stop.
1695 value = (System.currentTimeMillis() - startMs).coerceAtLeast(0L)
1699 value = (System.currentTimeMillis() - startMs).coerceAtLeast(0L)
1703 val elapsedText = remember(elapsedMs) {
1704 val total = elapsedMs / 1000
1705 // Locale.US for stable digit formatting: on locales with
1706 // non-ASCII digits (ar-/fa-) the default locale would render
1707 // localized digits inside the Monospace block, breaking the
1708 // fixed-width alignment the monospace family was chosen for.
1709 String.format(java.util.Locale.US, "%d:%02d", total / 60, total % 60)
1712 verticalAlignment = Alignment.CenterVertically,
1713 // Animate this slot in/out so the working row doesn't pop in and
1714 // jolt the message list when a run starts/stops — matching the
1715 // AnimatedVisibility used by every other transient banner/FAB here.
1717 .then(reducedMotionAnimateItem())
1718 .padding(vertical = 4.dp),
1720 // Merge the indicator + label + clock into one TalkBack node so
1721 // it's announced once (the working label, then the elapsed time)
1722 // instead of "working… / working… / 0:14" with the label read
1723 // twice. The Stop IconButton below stays separately focusable.
1725 verticalAlignment = Alignment.CenterVertically,
1726 modifier = Modifier.semantics(mergeDescendants = true) {
1727 contentDescription = "$interruptedLabel $elapsedText"
1730 // Dim the spinner when interrupted so the row reads as
1731 // "paused/reconnecting" rather than actively progressing.
1732 CircularProgressIndicator(
1733 Modifier.size(16.dp),
1735 color = if (streamInterrupted) MaterialTheme.colorScheme.onSurfaceVariant
1736 else MaterialTheme.colorScheme.primary,
1738 Text(interruptedLabel, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp))
1739 Spacer(Modifier.size(8.dp))
1741 Icons.Filled.Schedule,
1742 contentDescription = null,
1743 modifier = Modifier.size(14.dp),
1744 tint = MaterialTheme.colorScheme.onSurfaceVariant,
1748 style = MaterialTheme.typography.labelMedium,
1749 fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
1750 color = MaterialTheme.colorScheme.onSurfaceVariant,
1751 modifier = Modifier.padding(start = 4.dp),
1754 // A trailing Stop on the working row so the run can be aborted
1755 // without scrolling back to the composer (which may be off-screen
1756 // while the user reads the streaming output up-thread).
1757 Spacer(Modifier.weight(1f))
1758 val stopLabel = stringResource(R.string.stop)
1760 onClick = { vm.abort() },
1761 modifier = Modifier.semantics { contentDescription = stopLabel },
1763 Icon(Icons.Filled.Stop, contentDescription = null)
1772 // Jump-to-latest affordance when the user has scrolled away during a stream.
1773 val fabMotion = rememberVisibilityTransitions()
1775 visible = !isPinnedToBottom && listItems.isNotEmpty(),
1776 enter = fabMotion.enter,
1777 exit = fabMotion.exit,
1778 // Inset by sideMargin on wide screens so the FAB tracks the capped list's
1779 // edge instead of floating at the screen edge (visually detached on tablets).
1780 modifier = Modifier.align(Alignment.BottomEnd).padding(end = sideMargin),
1782 // Badge the FAB when new content arrived while the user was scrolled up, so
1783 // they know there's something new to jump to (cleared once back at bottom).
1786 if (newContentCount > 0) {
1787 // Merge the count into the badge announcement so TalkBack reads
1788 // "5 new messages" instead of the bare label and a separate number.
1789 val newContentLabel = pluralStringResource(
1790 R.plurals.plurals_new_messages,
1795 modifier = Modifier.semantics {
1796 contentDescription = newContentLabel
1797 // Announce new-content counts as they change so a
1798 // TalkBack user hears "5 new messages" without having
1799 // to focus the FAB. Polite so it doesn't interrupt an
1800 // in-progress utterance (e.g. streaming token reads).
1801 liveRegion = androidx.compose.ui.semantics.LiveRegionMode.Polite
1805 if (newContentCount > 99) "99+" else newContentCount.toString(),
1806 modifier = Modifier.semantics { invisibleToUser() },
1811 modifier = Modifier.padding(end = 16.dp, bottom = 16.dp),
1813 ExtendedFloatingActionButton(
1816 if (listItems.isNotEmpty()) {
1817 // Scroll to the effective last index, including the
1818 // trailing "__typing" row when a run is active.
1819 val target = if (running) listItems.size else listItems.lastIndex
1820 contentScope.launch { runCatchingCancellable { listState.animateScrollToItem(target) } }
1823 // contentDescription null: the visible "Latest" text already labels
1824 // the FAB, so a description here makes TalkBack announce "Latest" twice.
1825 icon = { Icon(Icons.Filled.KeyboardArrowDown, contentDescription = null) },
1826 text = { Text(stringResource(R.string.latest)) },
1830 // Scroll-to-top affordance: a small FAB at the start edge, visible when the
1831 // user has scrolled down past the first few messages. Symmetric to the
1832 // jump-to-latest FAB, for long conversations where manual fling back is tedious.
1833 val scrolledDown by remember {
1834 derivedStateOf { listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 200 }
1837 visible = scrolledDown && listItems.size > 4,
1838 enter = fabMotion.enter,
1839 exit = fabMotion.exit,
1840 modifier = Modifier.align(Alignment.BottomStart).padding(start = sideMargin),
1842 SmallFloatingActionButton(
1844 contentScope.launch { runCatchingCancellable { listState.animateScrollToItem(0) } }
1846 modifier = Modifier.padding(start = 16.dp, bottom = 16.dp),
1849 Icons.Filled.KeyboardArrowUp,
1850 contentDescription = stringResource(R.string.scroll_to_top),
1854 // In-conversation search bar. Overlays the list so typing filters messages in
1855 // place; the match count + a no-matches note give immediate feedback.
1856 val searchMotion = rememberVisibilityTransitions()
1858 visible = searchActive,
1859 enter = searchMotion.enter,
1860 exit = searchMotion.exit,
1861 // Inset by sideMargin on wide screens so the search bar spans the capped
1862 // list's width, not the full screen width.
1863 modifier = Modifier.align(Alignment.TopCenter).padding(horizontal = sideMargin),
1866 tonalElevation = 3.dp,
1868 .padding(horizontal = 12.dp, vertical = 4.dp)
1870 .onSizeChanged { searchbarHeightPx = it.height },
1872 // Focus the search field when the bar appears (from the overflow menu's
1873 // "Find in conversation" or Ctrl+F), so the user can start typing
1874 // immediately. Runs after the search field enters the composition.
1875 LaunchedEffect(searchActive) {
1876 if (searchActive) runCatching { searchFocusRequester.requestFocus() }
1879 modifier = Modifier.padding(start = 8.dp, end = 4.dp),
1880 verticalAlignment = Alignment.CenterVertically,
1884 onValueChange = { chatSearch = it },
1885 modifier = Modifier.weight(1f).focusRequester(searchFocusRequester),
1886 placeholder = { Text(stringResource(R.string.search_in_conversation)) },
1888 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
1889 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
1891 // Match counter: "N of M" where N is the focused match (1-based) and
1892 // M is the total matching messages. Only shown when there's a query.
1893 val matchCount = searchMessages.size
1895 if (searchQuery.isNotEmpty() && matchCount > 0) {
1896 stringResource(R.string.search_match_count, searchPos + 1, matchCount)
1897 } else if (searchQuery.isNotEmpty()) {
1898 stringResource(R.string.search_no_matches)
1900 style = MaterialTheme.typography.labelSmall,
1901 color = MaterialTheme.colorScheme.onSurfaceVariant,
1903 .padding(horizontal = 4.dp)
1904 // Live region so a TalkBack user hears "3 of 12" / "No
1905 // matches" announce as they step through results, not just
1906 // the sighted user seeing the counter update.
1907 .semantics { liveRegion = androidx.compose.ui.semantics.LiveRegionMode.Polite },
1909 // Step through matches (Previous / Next), disabled when there are
1910 // fewer than two to navigate. Wrap around at the ends so repeated
1911 // taps cycle through every hit.
1912 val prevLabel = stringResource(R.string.find_previous)
1913 val nextLabel = stringResource(R.string.find_next)
1916 if (matchCount > 0) {
1917 searchPos = (searchPos - 1 + matchCount) % matchCount
1920 enabled = matchCount > 1,
1922 Icon(Icons.Filled.KeyboardArrowUp, contentDescription = prevLabel)
1926 if (matchCount > 0) {
1927 searchPos = (searchPos + 1) % matchCount
1930 enabled = matchCount > 1,
1932 Icon(Icons.Filled.KeyboardArrowDown, contentDescription = nextLabel)
1934 IconButton(onClick = {
1935 searchActive = false
1938 Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.clear_search))
1943 // When a search yields no matches (but the conversation isn't empty), say so
1944 // instead of showing a blank list.
1945 if (searchActive && searchQuery.isNotEmpty() && searchMessages.isEmpty() && messages.isNotEmpty()) {
1947 stringResource(R.string.search_no_matches),
1948 style = MaterialTheme.typography.bodyMedium,
1949 color = MaterialTheme.colorScheme.onSurfaceVariant,
1950 modifier = Modifier.align(Alignment.Center),
1958 if (showExitConfirm) {
1960 onDismissRequest = { showExitConfirm = false },
1961 title = { Text(stringResource(R.string.stop_and_exit_title)) },
1962 text = { Text(stringResource(R.string.stop_and_exit_text)) },
1964 TextButton(onClick = {
1965 showExitConfirm = false
1966 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
1969 }) { Text(stringResource(R.string.stop_and_exit)) }
1972 TextButton(onClick = { showExitConfirm = false }) { Text(stringResource(R.string.stay)) }
1977 if (showSummarizeConfirm) {
1979 onDismissRequest = { showSummarizeConfirm = false },
1980 title = { Text(stringResource(R.string.summarize_confirm_title)) },
1981 text = { Text(stringResource(R.string.summarize_confirm_text)) },
1983 TextButton(onClick = {
1984 showSummarizeConfirm = false
1986 }) { Text(stringResource(R.string.action_continue)) }
1989 TextButton(onClick = { showSummarizeConfirm = false }) { Text(stringResource(R.string.cancel)) }
1994 if (showInitConfirm) {
1996 onDismissRequest = { showInitConfirm = false },
1997 title = { Text(stringResource(R.string.init_confirm_title)) },
1998 text = { Text(stringResource(R.string.init_confirm_text)) },
2000 TextButton(onClick = {
2001 showInitConfirm = false
2003 }) { Text(stringResource(R.string.action_continue)) }
2006 TextButton(onClick = { showInitConfirm = false }) { Text(stringResource(R.string.cancel)) }
2011 if (showRenameDialog) {
2012 RenameSessionChatDialog(
2013 initialTitle = sessionTitle ?: "",
2014 onDismiss = { showRenameDialog = false },
2015 onConfirm = { newName ->
2016 showRenameDialog = false
2017 vm.renameSession(newName)
2022 if (showDeleteDialog) {
2024 onDismissRequest = { showDeleteDialog = false },
2025 title = { Text(stringResource(R.string.delete_session_chat_title)) },
2026 text = { Text(stringResource(R.string.delete_session_chat_text, sessionTitle ?: sessionLabel)) },
2028 TextButton(onClick = {
2029 showDeleteDialog = false
2030 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
2032 }) { Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) }
2035 TextButton(onClick = { showDeleteDialog = false }) { Text(stringResource(R.string.cancel)) }
2040 if (showShellDialog) {
2042 onDismiss = { showShellDialog = false },
2044 showShellDialog = false
2050 if (showModelPicker) {
2051 // Resolve persisted recent composite keys to the live ModelOption entries they refer
2052 // to, dropping any whose model is no longer offered by the server. Ordered by
2053 // recency (head of the store first).
2054 val recentOptions = remember(models, recentModelEntries) {
2055 val byKey = models.associateBy { it.providerID to it.modelID }
2056 recentModelEntries.mapNotNull { entry ->
2057 container.recentModelsStore.split(entry)?.let { (p, m) -> byKey[p to m] }
2060 // Resolve snackbar strings in the composable scope; the onSetPreferredModel lambda
2061 // below isn't @Composable, so stringResource can't be called inside it.
2062 val defaultModelClearedMsg = stringResource(R.string.default_model_cleared)
2063 val defaultModelSetFmt = stringResource(R.string.default_model_set, "%s")
2066 selected = selectedModel,
2067 loading = modelsLoading,
2068 error = modelsError,
2071 container.recentModelsStore.add(it.providerID, it.modelID)
2073 onRetry = { vm.reloadModels() },
2074 onDismiss = { showModelPicker = false },
2075 preferredModelId = preferredModelId,
2076 onSetPreferredModel = { id ->
2078 runCatchingCancellable { container.settingsStore.setPreferredModelId(id) }
2079 // Confirm the change with a snackbar so the user sees it took effect —
2080 // the toggle's label flip can be missed, especially when clearing.
2081 val msg = if (id.isEmpty()) {
2082 defaultModelClearedMsg
2084 val label = models.firstOrNull { it.modelID == id }?.modelLabel ?: id
2085 defaultModelSetFmt.format(label)
2087 snackbar.showSnackbar(msg)
2090 recent = recentOptions,
2094 if (showAgentPicker) {
2097 selected = selectedAgent,
2098 loading = agentsLoading,
2099 error = agentsError,
2100 onSelect = { vm.selectAgent(it?.name) },
2101 onRetry = { vm.reloadAgents() },
2102 onDismiss = { showAgentPicker = false },
2106 if (showCommandPicker) {
2108 commands = commands,
2109 loading = commandsLoading,
2110 error = commandsError,
2111 onSelect = { vm.runCommand(it) },
2112 onRetry = { vm.reloadCommands() },
2113 onDismiss = { showCommandPicker = false },
2117 pendingPermission?.let { permission ->
2119 permission = permission,
2120 position = permissionProgress.position,
2121 total = permissionProgress.total,
2122 onRespond = { response -> vm.respondPermission(permission, response) },
2123 // On auto-reject timeout, post a notification so a returning user finds a
2124 // persistent record of why the run stopped (the dialog itself is gone).
2125 // Gated by the user's notification setting (notifPermission).
2127 val conn = activeConnection
2128 val ctx = appContext
2129 val title = sessionTitle ?: sessionId
2130 if (notifPermission) {
2131 soy.iko.opencode.notification.SessionNotifications.postPermissionAutoRejected(
2132 ctx, sessionId, title, conn?.profile?.id,
2140 CommandPalette(actions = paletteActions, onDismiss = { showPalette = false })
2143 if (showShortcutsDialog) {
2144 // Surfaces the hardware-keyboard shortcuts (Ctrl+K palette, Escape stop, Enter send)
2145 // that are otherwise wired but undiscoverable. A simple key/value list; Enter behavior
2146 // depends on the "Send on Enter" setting, noted inline.
2148 onDismissRequest = { showShortcutsDialog = false },
2149 title = { Text(stringResource(R.string.keyboard_shortcuts_title)) },
2151 Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
2152 ShortcutRow("Ctrl + K", stringResource(R.string.shortcut_open_palette))
2153 ShortcutRow("Esc", stringResource(R.string.shortcut_stop_run))
2154 ShortcutRow("Esc", stringResource(R.string.shortcut_close))
2155 ShortcutRow("Enter", stringResource(R.string.shortcut_send))
2156 ShortcutRow("Shift + Enter / Ctrl + Enter", stringResource(R.string.shortcut_newline))
2160 TextButton(onClick = { showShortcutsDialog = false }) { Text(stringResource(R.string.action_continue)) }
2167private fun ShortcutRow(keys: String, desc: String) {
2168 Row(verticalAlignment = Alignment.CenterVertically) {
2171 style = MaterialTheme.typography.labelLarge,
2172 fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
2173 modifier = Modifier.widthIn(min = 160.dp),
2177 style = MaterialTheme.typography.bodyMedium,
2178 color = MaterialTheme.colorScheme.onSurfaceVariant,
2184 * Slash-command autocomplete: when the draft starts with "/", show a filtered dropdown
2185 * of matching commands above the composer. Tapping a command fires it immediately.
2186 * Extracted from [ChatInputBar] to keep that function's cyclomatic complexity under
2187 * the detekt threshold. Suppressed while running or when the command catalog is empty.
2190private fun SlashCommandAutocomplete(
2192 commands: List<Command>,
2194 onRunCommand: (Command) -> Unit,
2196 if (running || commands.isEmpty()) return
2197 val slashQuery = remember(value) {
2198 if (value.startsWith("/")) value.removePrefix("/") else null
2200 val matchingCommands = remember(slashQuery, commands) {
2201 commands.filter { cmd ->
2202 slashQuery.isEmpty() ||
2203 cmd.name.contains(slashQuery, ignoreCase = true) ||
2204 (cmd.description?.contains(slashQuery, ignoreCase = true) == true)
2207 if (matchingCommands.isNotEmpty()) {
2208 CommandAutocompleteDropdown(
2209 commands = matchingCommands,
2210 onPick = onRunCommand,
2216 * Slash-command autocomplete dropdown shown above the composer when the draft starts
2217 * with "/". Renders up to 8 matching commands as a scrollable, elevated surface so the
2218 * user can discover and invoke commands by typing the conventional "/" prefix instead
2219 * of opening the overflow menu / command palette. Tapping a row fires the command.
2222private fun CommandAutocompleteDropdown(
2223 commands: List<Command>,
2224 onPick: (Command) -> Unit,
2226 val haptics = LocalHapticFeedback.current
2228 color = MaterialTheme.colorScheme.surface,
2229 tonalElevation = 3.dp,
2230 shape = MaterialTheme.shapes.medium,
2233 .padding(horizontal = 8.dp, vertical = 4.dp)
2234 .heightIn(max = 240.dp),
2238 count = commands.size,
2239 key = { commands[it].name },
2241 val cmd = commands[i]
2245 .clickable(role = Role.Button) {
2246 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
2249 .padding(horizontal = 16.dp, vertical = 10.dp),
2250 verticalAlignment = Alignment.CenterVertically,
2251 horizontalArrangement = Arrangement.spacedBy(12.dp),
2255 style = MaterialTheme.typography.labelLarge,
2256 fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
2257 color = MaterialTheme.colorScheme.primary,
2259 cmd.description?.takeIf { it.isNotBlank() }?.let { desc ->
2262 style = MaterialTheme.typography.bodySmall,
2263 color = MaterialTheme.colorScheme.onSurfaceVariant,
2265 overflow = TextOverflow.Ellipsis,
2274@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
2275@Suppress("CyclomaticComplexMethod")
2277private fun ChatInputBar(
2279 onValueChange: (String) -> Unit,
2283 sendOnEnter: Boolean,
2285 onAbort: () -> Unit,
2286 queuedFollowUp: String?,
2287 onQueueFollowUp: (String) -> Unit,
2288 onCancelQueue: () -> Unit,
2289 focusRequester: androidx.compose.ui.focus.FocusRequester,
2290 attachments: List<PendingAttachment>,
2292 stagingFileCount: Int,
2293 onRemoveAttachment: (String) -> Unit,
2294 onPickPhoto: () -> Unit,
2295 onPickFile: () -> Unit,
2296 onCamera: () -> Unit,
2297 onVoice: () -> Unit,
2298 onPasteImage: (List<Uri>) -> Unit,
2299 commands: List<Command> = emptyList(),
2300 onRunCommand: (Command) -> Unit = {},
2302 // Sendable when there's text OR at least one attachment (an image-only prompt is valid).
2303 val hasContent = value.isNotBlank() || attachments.isNotEmpty()
2304 val context = LocalContext.current
2305 // Full-screen editor state: opened when the user wants more space for a long prompt.
2306 var showFullScreenEditor by rememberSaveable { mutableStateOf(false) }
2307 Surface(tonalElevation = 3.dp, modifier = Modifier.imePadding()) {
2308 Column(modifier = Modifier.fillMaxWidth().windowInsetsPadding(WindowInsets.navigationBars)) {
2309 // A queued follow-up replaces the Stop button with a "queued" chip so the
2310 // user sees their message will be sent when the run finishes, and can cancel.
2311 if (queuedFollowUp != null) {
2312 val editLabel = stringResource(R.string.edit)
2316 .padding(start = 12.dp, top = 6.dp, end = 4.dp),
2317 verticalAlignment = Alignment.CenterVertically,
2319 // Tapping the chip reloads the queued text into the draft (and clears the
2320 // queue) so a typo spotted while queuing can be fixed without retyping.
2324 .clip(MaterialTheme.shapes.small)
2325 .clickable(role = Role.Button, onClickLabel = editLabel) {
2326 onValueChange(queuedFollowUp)
2329 .padding(vertical = 4.dp),
2332 stringResource(R.string.queued),
2333 style = MaterialTheme.typography.labelSmall,
2334 color = MaterialTheme.colorScheme.onSurfaceVariant,
2338 style = MaterialTheme.typography.bodySmall,
2339 color = MaterialTheme.colorScheme.onSurfaceVariant,
2341 overflow = TextOverflow.Ellipsis,
2344 TextButton(onClick = onCancelQueue, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp)) {
2345 Text(stringResource(R.string.cancel))
2349 // Staged attachments: horizontally-scrollable thumbnails/chips, each removable.
2350 AttachmentStrip(attachments, onRemove = onRemoveAttachment, staging = staging, stagingFileCount = stagingFileCount)
2351 // Slash-command autocomplete: extracted to a helper to keep this function's
2352 // complexity under the detekt threshold. Returns no-op when the draft doesn't
2353 // start with "/" or the command catalog is empty.
2354 SlashCommandAutocomplete(
2356 commands = commands,
2358 onRunCommand = onRunCommand,
2361 modifier = Modifier.fillMaxWidth().padding(8.dp),
2362 verticalAlignment = Alignment.Bottom,
2364 // Attach menu: photo picker, camera, or any file.
2365 var showAttachMenu by remember { mutableStateOf(false) }
2367 IconButton(onClick = { showAttachMenu = true }, enabled = enabled) {
2368 Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.add_attachment))
2370 DropdownMenu(expanded = showAttachMenu, onDismissRequest = { showAttachMenu = false }) {
2372 leadingIcon = { Icon(Icons.Filled.PhotoLibrary, contentDescription = null) },
2373 text = { Text(stringResource(R.string.attach_photo)) },
2374 onClick = { showAttachMenu = false; onPickPhoto() },
2377 leadingIcon = { Icon(Icons.Filled.PhotoCamera, contentDescription = null) },
2378 text = { Text(stringResource(R.string.attach_camera)) },
2379 onClick = { showAttachMenu = false; onCamera() },
2382 leadingIcon = { Icon(Icons.Filled.Description, contentDescription = null) },
2383 text = { Text(stringResource(R.string.attach_file)) },
2384 onClick = { showAttachMenu = false; onPickFile() },
2388 // Voice dictation: appends recognized speech to the draft. Hidden (not just
2389 // disabled) when the device has no speech-recognition activity, so a user
2390 // without a voice app doesn't get a button that only fails noisily on tap.
2391 val voiceAvailable = remember {
2392 val pm = context.packageManager
2393 val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH)
2394 pm.queryIntentActivities(intent, 0).isNotEmpty()
2396 if (voiceAvailable) {
2397 IconButton(onClick = onVoice, enabled = enabled) {
2398 Icon(Icons.Filled.Mic, contentDescription = stringResource(R.string.voice_input))
2401 // Expand to a full-screen editor for long prompts (the inline field caps at 6 lines).
2402 IconButton(onClick = { showFullScreenEditor = true }, enabled = enabled) {
2403 Icon(Icons.Filled.Expand, contentDescription = stringResource(R.string.expand_editor))
2407 onValueChange = { v ->
2408 // Cap input length so a huge paste can't stall the UI. Shares are
2409 // capped separately in MainActivity; this guards typed/pasted drafts.
2410 onValueChange(v.take(NetworkConfig.maxDraftLengthChars))
2414 .focusRequester(focusRequester)
2415 .testTag("chat_input")
2416 // Intercept image content pasted/dropped into the field (keyboard paste
2417 // of a copied screenshot, drag from Files, etc.) and route it through the
2418 // same staging path as the attach menu. Non-image content (text) falls
2419 // through unchanged so normal typing/paste works.
2422 androidx.compose.foundation.content.ReceiveContentListener { transferable ->
2423 val clipData = transferable.clipEntry?.clipData
2424 if (clipData != null) {
2425 val imageUris = (0 until clipData.itemCount).mapNotNull { i ->
2426 val item = clipData.getItemAt(i)
2427 item.uri?.takeIf { uri ->
2428 item.text == null && uri.toString().let { s ->
2429 s.startsWith("content:") || s.startsWith("file:")
2433 if (imageUris.isNotEmpty()) {
2434 onPasteImage(imageUris)
2441 .onPreviewKeyEvent { event ->
2442 if (event.type != KeyEventType.KeyDown || event.key != Key.Enter) return@onPreviewKeyEvent false
2443 // With "Send on Enter" on, Enter sends (Shift+Enter newlines).
2444 // With it off, Enter inserts a newline and Ctrl+Enter sends.
2445 val send = enterShouldSend(
2447 hasContent = hasContent,
2448 sendOnEnter = sendOnEnter,
2449 shift = event.isShiftPressed,
2450 ctrl = event.isCtrlPressed,
2453 if (running) onQueueFollowUp(value) else onSend()
2459 // While a run is active a typed message only queues (sends after the run), so
2460 // the placeholder says so instead of the plain "Message…".
2462 Text(stringResource(if (running) R.string.composer_hint_running else R.string.message_placeholder))
2466 // Show a "N / max" countdown once the draft crosses a high fraction of
2467 // the cap, so the user knows a paste is about to be truncated instead
2468 // of being silently cut off. Hidden for normal short prompts to avoid
2469 // clutter under a typical one-line message.
2471 val threshold = (NetworkConfig.maxDraftLengthChars * NetworkConfig.draftCountdownThresholdFraction).toInt()
2472 if (value.length >= threshold) {
2475 R.string.draft_chars_remaining,
2476 NetworkConfig.maxDraftLengthChars - value.length,
2477 NetworkConfig.maxDraftLengthChars,
2479 style = MaterialTheme.typography.labelSmall,
2480 color = if (value.length >= NetworkConfig.maxDraftLengthChars) MaterialTheme.colorScheme.error
2481 else MaterialTheme.colorScheme.onSurfaceVariant,
2483 } else if (value.lines().size > 6) {
2484 // The inline field caps at 6 lines; once the draft crosses that,
2485 // hint that a bigger full-screen editor exists (the Expand icon
2486 // above is easy to miss among the icon row). A tappable label
2487 // would be ideal, but a plain hint is enough to draw attention.
2489 stringResource(R.string.open_full_editor_hint),
2490 style = MaterialTheme.typography.labelSmall,
2491 color = MaterialTheme.colorScheme.onSurfaceVariant,
2495 keyboardOptions = KeyboardOptions(
2496 // When "Send on Enter" is on, expose ImeAction.Send so the soft
2497 // keyboard shows a send key — matching the setting's intent and
2498 // giving touch-only users a keyboard send affordance. When off,
2499 // ImeAction.Default keeps the enter/newline key for multi-line input.
2500 imeAction = if (sendOnEnter) ImeAction.Send else ImeAction.Default,
2501 capitalization = KeyboardCapitalization.Sentences,
2503 keyboardActions = KeyboardActions(
2505 // Mirror the hardware-Enter handler: during a run the soft-keyboard
2506 // send key must queue the follow-up, not silently drop it. vm.send()
2507 // returns false while a run is active (beginRun() rejects), so calling
2508 // onSend() directly here would lose the typed message with no feedback
2509 // for touch-only users (no hardware Enter path available).
2510 if (enabled && hasContent) {
2511 if (running) onQueueFollowUp(value) else onSend()
2516 ComposerTrailingButton(
2518 aborting = aborting,
2519 canSend = enabled && hasContent,
2520 canQueue = enabled && value.isNotBlank(),
2523 onQueue = { onQueueFollowUp(value) },
2527 if (showFullScreenEditor) {
2530 onValueChange = onValueChange,
2531 onDismiss = { showFullScreenEditor = false },
2533 showFullScreenEditor = false
2534 // Mirror the inline composer's behavior: while a run is active a send
2535 // can't go immediately, so queue the follow-up instead of silently
2536 // dropping it (vm.send() returns false during a run, which previously
2537 // lost the typed message with no feedback).
2538 if (running) onQueueFollowUp(value) else onSend()
2540 canSend = enabled && hasContent,
2542 sendOnEnter = sendOnEnter,
2543 attachments = attachments,
2544 onRemoveAttachment = onRemoveAttachment,
2546 stagingFileCount = stagingFileCount,
2552/** Full-screen editor dialog for composing long prompts. Gives the full viewport height to
2553 * the text field (vs the inline 6-line cap) with a Send button and a collapse action. */
2554@OptIn(ExperimentalMaterial3Api::class)
2556private fun FullScreenEditor(
2558 onValueChange: (String) -> Unit,
2559 onDismiss: () -> Unit,
2562 running: Boolean = false,
2563 sendOnEnter: Boolean = false,
2564 attachments: List<PendingAttachment> = emptyList(),
2565 onRemoveAttachment: (String) -> Unit = {},
2566 staging: Boolean = false,
2567 stagingFileCount: Int = 0,
2569 androidx.compose.ui.window.Dialog(
2570 onDismissRequest = onDismiss,
2571 properties = androidx.compose.ui.window.DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false),
2573 // Cap the editor width on large screens (tablets) so the text field doesn't stretch
2574 // edge-to-edge — matching the chat list's readability rationale. BoxWithConstraints
2575 // gives the available width so we can center a capped-width column.
2576 androidx.compose.foundation.layout.BoxWithConstraints(
2577 modifier = Modifier.fillMaxSize(),
2578 contentAlignment = Alignment.Center,
2580 val hPad = if (maxWidth > NetworkConfig.twoPaneWidthThresholdDp.dp)
2581 ((maxWidth - NetworkConfig.composerDialogMaxWidthDp.dp) / 2).coerceAtLeast(0.dp)
2584 modifier = Modifier.padding(horizontal = hPad),
2586 androidx.compose.material3.TopAppBar(
2587 title = { Text(stringResource(R.string.compose_editor_title)) },
2589 androidx.compose.material3.TextButton(onClick = onDismiss) {
2590 Text(stringResource(R.string.collapse_editor))
2596 Surface(tonalElevation = 3.dp) {
2598 modifier = Modifier.fillMaxWidth().imePadding().padding(12.dp),
2599 horizontalArrangement = Arrangement.End,
2601 androidx.compose.material3.Button(onClick = onSend, enabled = canSend) {
2602 Text(stringResource(if (running) R.string.queue else R.string.send))
2608 Column(modifier = Modifier.fillMaxSize().padding(padding)) {
2609 // Show staged attachments in the full-screen editor too, so a user who staged
2610 // images then expanded for a long prompt can still see and remove them without
2611 // collapsing first (mirrors the inline composer's AttachmentStrip).
2612 AttachmentStrip(attachments, onRemove = onRemoveAttachment, staging = staging, stagingFileCount = stagingFileCount)
2615 onValueChange = { v -> onValueChange(v.take(NetworkConfig.maxDraftLengthChars)) },
2619 // Honor the "Send on Enter" setting here too, so the full-screen
2620 // editor matches the inline composer: Enter sends (Shift+Enter
2621 // newlines) when on; Enter inserts a newline and Ctrl+Enter sends
2622 // when off. Previously this hard-coded ImeAction.Default and had no
2623 // key handler, so a hardware-keyboard user got a different behaviour
2624 // from the inline composer depending on which they happened to open.
2625 .onPreviewKeyEvent { event ->
2626 if (event.type != KeyEventType.KeyDown || event.key != Key.Enter) return@onPreviewKeyEvent false
2627 val send = enterShouldSend(
2629 hasContent = canSend,
2630 sendOnEnter = sendOnEnter,
2631 shift = event.isShiftPressed,
2632 ctrl = event.isCtrlPressed,
2634 if (send) { onSend(); true } else false
2636 placeholder = { Text(stringResource(R.string.message_placeholder)) },
2637 maxLines = Int.MAX_VALUE,
2638 keyboardOptions = KeyboardOptions(
2639 imeAction = if (sendOnEnter) ImeAction.Send else ImeAction.Default,
2640 capitalization = KeyboardCapitalization.Sentences,
2642 keyboardActions = KeyboardActions(
2643 onSend = { if (canSend) onSend() },
2652/** Whether a hardware Enter keypress should send: never when disabled/empty; otherwise
2653 * Enter sends (unless Shift) when send-on-Enter is on, else only Ctrl+Enter sends. */
2654private fun enterShouldSend(
2656 hasContent: Boolean,
2657 sendOnEnter: Boolean,
2661 !enabled || !hasContent -> false
2662 sendOnEnter -> !shift
2666/** The composer's trailing button: while a run is active a Stop (with in-flight spinner),
2667 * preceded by a Queue button when the user has typed a follow-up; otherwise Send. Extracted so
2668 * [ChatInputBar] stays under the complexity threshold. */
2670private fun ComposerTrailingButton(
2676 onAbort: () -> Unit,
2677 onQueue: () -> Unit,
2680 Row(verticalAlignment = Alignment.CenterVertically) {
2681 // While a run is active the trailing action is Stop, so the only send affordance is
2682 // the keyboard's IME action — which is a plain newline when "Send on Enter" is off.
2683 // That leaves a touch-only user with the setting off no way to queue a follow-up
2684 // (Ctrl+Enter needs a hardware keyboard). Surface an explicit Queue button whenever
2685 // there's typed TEXT so queuing is always reachable, without changing the user's
2686 // Enter-inserts-a-newline preference. Gate on typed text — not the send-oriented
2687 // canSend, which is also true for an attachment-only composer — because queuing "" just
2688 // calls setQueuedFollowUp(null), silently cancelling any already-queued follow-up and
2689 // dropping the staged attachment (queued follow-ups don't carry attachments anyway).
2693 modifier = Modifier.testTag("queue_button"),
2695 // PlaylistAdd reads as "add to a queue/list" more clearly than a clock
2696 // icon (which can be read as "snooze" or "later"), and isn't confused with
2697 // the Send action sitting right beside the Stop button.
2699 Icons.AutoMirrored.Filled.PlaylistAdd,
2700 contentDescription = stringResource(R.string.queue_followup),
2704 // Show a spinner while the abort REST call is in flight so the user sees the stop
2705 // was sent, and disable to prevent a double-tap.
2708 enabled = !aborting,
2709 modifier = Modifier.padding(start = 4.dp).testTag("stop_button"),
2712 val stopLabel = stringResource(R.string.stop)
2713 CircularProgressIndicator(
2714 Modifier.size(18.dp).semantics { contentDescription = stopLabel },
2718 Icon(Icons.Filled.Stop, contentDescription = stringResource(R.string.stop))
2726 modifier = Modifier.padding(start = 4.dp).testTag("send_button"),
2728 // Tint the send icon with primary when there's content to send so the commit action
2729 // draws the eye; muted otherwise. A plain enabled/disabled flip is easy to miss.
2731 Icons.AutoMirrored.Filled.Send,
2732 contentDescription = stringResource(R.string.send),
2733 tint = if (canSend) MaterialTheme.colorScheme.primary
2734 else MaterialTheme.colorScheme.onSurfaceVariant,
2740@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class)
2742private fun EmptyConversation(
2743 onSuggestion: (String) -> Unit,
2744 modifier: Modifier = Modifier,
2746 // A few example prompts so a first-time user has a one-tap path to their first
2747 // message instead of staring at a blank input. Tapping a chip fills the draft
2748 // (without sending) so the user can edit it before sending.
2749 val suggestions = remember {
2751 R.string.empty_chat_suggest_1,
2752 R.string.empty_chat_suggest_2,
2753 R.string.empty_chat_suggest_3,
2757 modifier = modifier.padding(32.dp),
2758 horizontalAlignment = Alignment.CenterHorizontally,
2761 Icons.Filled.AutoAwesome,
2762 contentDescription = null,
2763 modifier = Modifier.size(48.dp),
2764 tint = MaterialTheme.colorScheme.onSurfaceVariant,
2766 Spacer(Modifier.size(16.dp))
2768 stringResource(R.string.empty_chat_title),
2769 style = MaterialTheme.typography.titleMedium,
2771 Spacer(Modifier.size(8.dp))
2773 stringResource(R.string.empty_chat_hint),
2774 style = MaterialTheme.typography.bodyMedium,
2775 color = MaterialTheme.colorScheme.onSurfaceVariant,
2777 Spacer(Modifier.size(20.dp))
2778 // Suggestion chips wrap across rows on narrow screens instead of stacking full-width,
2779 // so a longer localized prompt doesn't push the chips off-screen on a phone.
2781 modifier = Modifier.fillMaxWidth(),
2782 horizontalArrangement = Arrangement.spacedBy(8.dp),
2783 verticalArrangement = Arrangement.spacedBy(8.dp),
2785 suggestions.forEach { resId ->
2786 val text = stringResource(resId)
2787 androidx.compose.material3.AssistChip(
2788 onClick = { onSuggestion(text) },
2789 label = { Text(text, style = MaterialTheme.typography.bodyMedium) },
2796/** A banner shown while a revert checkpoint is active, offering an Undo (unrevert). When the
2797 * server included a diff of the revert, a collapsible "Show what changed" affordance reveals
2798 * it inline via [DiffView] so the user can see what was rolled back before deciding to undo. */
2800private fun RevertBanner(diff: String?, isEditing: Boolean, onUndo: () -> Unit) {
2801 val haptics = LocalHapticFeedback.current
2802 var showDiff by rememberSaveable { mutableStateOf(false) }
2803 val expandMotion = rememberVisibilityTransitions()
2805 color = MaterialTheme.colorScheme.tertiaryContainer,
2806 tonalElevation = 2.dp,
2807 modifier = Modifier.fillMaxWidth(),
2811 modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp),
2812 verticalAlignment = Alignment.CenterVertically,
2813 horizontalArrangement = Arrangement.SpaceBetween,
2815 Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
2817 if (isEditing) Icons.Filled.Edit else Icons.Filled.Restore,
2818 contentDescription = null,
2819 tint = MaterialTheme.colorScheme.onTertiaryContainer,
2820 modifier = Modifier.size(18.dp),
2823 stringResource(if (isEditing) R.string.editing_banner else R.string.reverted_banner),
2824 style = MaterialTheme.typography.bodySmall,
2825 color = MaterialTheme.colorScheme.onTertiaryContainer,
2826 modifier = Modifier.padding(start = 8.dp),
2828 // "Show what changed" toggle, shown only when the server provided a diff.
2829 // Collapsible so the banner stays compact by default; a user curious about
2830 // what the revert rolled back can expand it inline without leaving the chat.
2831 if (!diff.isNullOrBlank()) {
2834 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
2835 showDiff = !showDiff
2837 contentPadding = androidx.compose.foundation.layout.PaddingValues(start = 8.dp, end = 4.dp),
2840 if (showDiff) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
2841 contentDescription = null,
2842 modifier = Modifier.size(16.dp),
2845 stringResource(if (showDiff) R.string.revert_hide_diff else R.string.revert_show_diff),
2846 style = MaterialTheme.typography.labelSmall,
2847 modifier = Modifier.padding(start = 4.dp),
2852 TextButton(onClick = {
2853 // Haptic on undo to match the other destructive/important confirmations: a
2854 // revert restores potentially hundreds of hidden messages.
2855 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
2857 }) { Text(stringResource(R.string.undo)) }
2859 // The diff render lives inside the banner's Surface so it inherits the tertiary
2860 // tonal container and reads as part of the banner, not a floating panel.
2861 if (!diff.isNullOrBlank()) {
2864 enter = expandMotion.enter,
2865 exit = expandMotion.exit,
2867 Column(modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 8.dp)) {
2869 stringResource(R.string.revert_diff_header),
2870 style = MaterialTheme.typography.labelSmall,
2871 color = MaterialTheme.colorScheme.onTertiaryContainer,
2872 modifier = Modifier.padding(bottom = 4.dp),
2874 DiffView(diff, saveKey = "revertDiff")
2882/** A dismissible preview card shown above the composer when the user quote-replied to a
2883 * message. Displays the quoted text (truncated) with a Cancel affordance so the user can
2884 * abort the quote without digging into the draft to strip the `>`-prefixed lines. Mirrors
2885 * the Discord/Slack quote-preview pattern. */
2887private fun QuoteReplyBanner(quotedText: String, onCancel: () -> Unit) {
2888 val haptics = LocalHapticFeedback.current
2890 color = MaterialTheme.colorScheme.secondaryContainer,
2891 tonalElevation = 2.dp,
2892 modifier = Modifier.fillMaxWidth(),
2895 modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 4.dp, top = 6.dp, bottom = 6.dp),
2896 verticalAlignment = Alignment.Top,
2899 Icons.Filled.FormatQuote,
2900 contentDescription = null,
2901 tint = MaterialTheme.colorScheme.onSecondaryContainer,
2902 modifier = Modifier.size(18.dp).padding(top = 2.dp),
2904 Column(modifier = Modifier.weight(1f).padding(start = 8.dp)) {
2906 stringResource(R.string.quote_reply_banner),
2907 style = MaterialTheme.typography.labelSmall,
2908 color = MaterialTheme.colorScheme.onSecondaryContainer,
2912 style = MaterialTheme.typography.bodySmall,
2913 color = MaterialTheme.colorScheme.onSecondaryContainer,
2915 overflow = TextOverflow.Ellipsis,
2918 IconButton(onClick = {
2919 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
2924 contentDescription = stringResource(R.string.cancel_quote_reply),
2925 tint = MaterialTheme.colorScheme.onSecondaryContainer,
2926 modifier = Modifier.size(18.dp),
2933/** A banner shown while messages composed offline are queued in the outbox, with a
2934 * "Send now" nudge and a discard. Shows a spinner while the queue is flushing. */
2936private fun OutboxBanner(
2939 onFlush: () -> Unit,
2940 onDiscard: () -> Unit,
2942 var showDiscardConfirm by rememberSaveable { mutableStateOf(false) }
2944 color = MaterialTheme.colorScheme.secondaryContainer,
2945 tonalElevation = 2.dp,
2946 modifier = Modifier.fillMaxWidth(),
2949 modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp),
2950 verticalAlignment = Alignment.CenterVertically,
2951 horizontalArrangement = Arrangement.SpaceBetween,
2953 Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
2955 CircularProgressIndicator(
2956 Modifier.size(16.dp),
2958 color = MaterialTheme.colorScheme.onSecondaryContainer,
2960 Spacer(Modifier.size(8.dp))
2964 stringResource(R.string.outbox_sending)
2966 val res = if (count == 1) R.string.outbox_pending_one else R.string.outbox_pending_many
2967 stringResource(res, count)
2969 style = MaterialTheme.typography.bodySmall,
2970 color = MaterialTheme.colorScheme.onSecondaryContainer,
2974 val haptics = LocalHapticFeedback.current
2975 TextButton(onClick = onFlush) { Text(stringResource(R.string.outbox_flush)) }
2976 TextButton(onClick = {
2977 // Confirm before discarding all queued messages (irreversible), matching
2978 // the confirmation pattern used by deleteSession and other destructive
2979 // actions. Previously a single tap dropped every queued message.
2980 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
2981 showDiscardConfirm = true
2983 Text(stringResource(R.string.outbox_discard_all), color = MaterialTheme.colorScheme.error)
2988 if (showDiscardConfirm) {
2989 androidx.compose.material3.AlertDialog(
2990 onDismissRequest = { showDiscardConfirm = false },
2991 title = { Text(stringResource(R.string.discard_queued_title)) },
2992 text = { Text(stringResource(R.string.discard_queued_text, count)) },
2994 androidx.compose.material3.TextButton(onClick = {
2995 showDiscardConfirm = false
2997 }) { Text(stringResource(R.string.discard)) }
3000 androidx.compose.material3.TextButton(onClick = { showDiscardConfirm = false }) {
3001 Text(stringResource(R.string.cancel))
3008/** Prompt for a one-off shell command to run in the session's worktree. */
3010private fun ShellCommandDialog(onDismiss: () -> Unit, onRun: (String) -> Unit) {
3011 var command by rememberSaveable { mutableStateOf("") }
3013 onDismissRequest = onDismiss,
3014 title = { Text(stringResource(R.string.run_shell_command)) },
3018 onValueChange = { command = it },
3019 modifier = Modifier.fillMaxWidth(),
3020 placeholder = { Text(stringResource(R.string.shell_command_hint)) },
3021 label = { Text(stringResource(R.string.shell_command_label)) },
3023 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
3024 keyboardActions = KeyboardActions(onDone = { if (command.isNotBlank()) onRun(command.trim()) }),
3029 onClick = { if (command.isNotBlank()) onRun(command.trim()) },
3030 enabled = command.isNotBlank(),
3031 ) { Text(stringResource(R.string.run)) }
3033 dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } },
3038 * Change signal for the auto-scroll-on-stream watcher. Primitive fields avoid the
3039 * boxing `Triple<Int, Int, Boolean>` would impose on every snapshot evaluation, and
3040 * equality is structural so `snapshotFlow` emits only when something relevant changes.
3042private data class AutoScrollSignal(val size: Int, val lastTextLength: Int, val pinned: Boolean)
3044/** Length of the streaming content carried by [part], driving auto-scroll as it grows.
3045 * Covers text, reasoning, and tool output so a pinned view keeps following non-text
3046 * streaming, not just plain assistant text. O(1). */
3047private fun streamingContentLength(part: Part?): Int = when (part) {
3048 is TextPart -> part.text.length
3049 is ReasoningPart -> part.text.length
3050 is ToolPart -> when (val st = part.state) {
3051 is ToolCompleted -> st.output?.length ?: 0
3052 is ToolError -> st.error?.length ?: 0
3053 is ToolRunning -> st.title?.length ?: 0
3059/** Rename dialog for the currently-open session. Mirrors the SessionListScreen rename
3060 * dialog but lives in the chat screen so the user can rename without backing out to
3061 * the list. Pre-filled with the current title; the cap matches the list dialog. */
3063private fun RenameSessionChatDialog(
3064 initialTitle: String,
3065 onDismiss: () -> Unit,
3066 onConfirm: (String) -> Unit,
3068 // Seed the field once (no key input) so a later external title change — e.g. opencode
3069 // auto-generating a session title after the first prompt, arriving via SessionUpdated —
3070 // doesn't re-initialize the box and discard the user's in-progress edit.
3071 var title by rememberSaveable { mutableStateOf(initialTitle) }
3073 onDismissRequest = onDismiss,
3074 title = { Text(stringResource(R.string.rename_session_chat)) },
3078 onValueChange = { v -> title = v.take(NetworkConfig.maxSessionTitleChars) },
3079 modifier = Modifier.fillMaxWidth(),
3080 placeholder = { Text(stringResource(R.string.session_title_hint)) },
3081 label = { Text(stringResource(R.string.rename_session_chat)) },
3083 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
3084 keyboardActions = KeyboardActions(onDone = { if (title.isNotBlank()) onConfirm(title.trim()) }),
3088 TextButton(onClick = { if (title.isNotBlank()) onConfirm(title.trim()) }, enabled = title.isNotBlank()) {
3089 Text(stringResource(R.string.save))
3093 TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) }
3099 * A single item in the chat message list — either a [Message] bubble or a day
3100 * [Separator] inserted before the first message of a new day. The separator shifts
3101 * the LazyColumn indices vs. the raw messages list, so scroll-target math must use
3102 * the [List] of these items (not `messages.size`/`lastIndex`).
3104private sealed interface MessageListItem {
3106 val contentType: Any
3109 val message: MessageWithParts,
3110 override val key: Any,
3111 // Whether this is the first message in a consecutive run of the same speaker (role).
3112 // Used to suppress the assistant avatar/header on follow-up messages in the same run,
3113 // reducing visual noise in tool-heavy conversations where many assistant messages
3114 // appear back-to-back. A separator or a role change resets the flag.
3115 val isFirstOfSpeaker: Boolean,
3116 ) : MessageListItem {
3117 override val contentType: Any get() = message.info::class
3120 data class Separator(val label: String, val ordinal: Int) : MessageListItem {
3121 // Key on the day-occurrence ordinal, not the label: two non-contiguous groups
3122 // can share a label (e.g. an untimestamped message — empty label — between two
3123 // same-day messages, or two untimestamped groups), and a label-only key would
3124 // then collide, which makes LazyColumn throw on duplicate keys. The ordinal is
3125 // stable for a given message ordering so slots aren't needlessly recreated.
3126 override val key: Any get() = "sep_${ordinal}_$label"
3127 // Separators share a contentType so the LazyColumn can recycle their slots.
3128 override val contentType: Any get() = "separator"
3133 * Build the interleaved list of [MessageListItem]s for the message list, inserting a
3134 * [MessageListItem.Separator] before the first message of each new calendar day. Uses
3135 * the message's `time.created` (falling back to `updated` then `completed`) to bucket.
3136 * Messages with no timestamp are grouped under an empty-label separator only if they
3137 * start the list, so a server that omits timestamps doesn't suppress the first divider.
3139 * [todayLabel]/[yesterdayLabel] are resolved by the caller (a @Composable can't call
3140 * stringResource from inside this plain function) and used for the "Today"/"Yesterday"
3141 * labels; older days fall back to a locale-stable medium-date format.
3143private fun buildMessageListItems(
3144 messages: List<MessageWithParts>,
3146 yesterdayLabel: String,
3147): List<MessageListItem> {
3148 if (messages.isEmpty()) return emptyList()
3149 val today = java.time.LocalDate.now(java.time.ZoneId.systemDefault())
3150 val dateFmt = mediumDateFormat()
3151 val result = ArrayList<MessageListItem>(messages.size + 4)
3152 val seenKeys = HashSet<String>(messages.size * 2)
3153 var lastDayKey: String? = null
3155 var emptyIdOrdinal = 0
3157 // Track the previous message's role to detect consecutive same-speaker runs, so the
3158 // avatar/header can be suppressed on follow-up messages in the same run. A null means
3159 // "no previous message or a separator just interrupted the run", both of which make the
3160 // next message the first of its group.
3161 var prevRoleClass: kotlin.reflect.KClass<out soy.iko.opencode.data.model.MessageInfo>? = null
3162 for (message in messages) {
3163 val ts = message.info.time?.created ?: message.info.time?.updated ?: message.info.time?.completed
3164 val dayKey = ts?.let { dayKey(it) } ?: ""
3165 // Emit a separator before the very first message (so a leading un-timestamped message
3166 // still gets the first divider, empty label and all), and thereafter only when crossing
3167 // into a new NON-empty calendar day. An un-timestamped message mid-list (empty key)
3168 // neither emits its own separator nor resets the day — otherwise a following same-day
3169 // message would see a "" -> "<today>" transition and emit a duplicate "Today" header.
3170 if (first || (dayKey.isNotEmpty() && dayKey != lastDayKey)) {
3171 result.add(MessageListItem.Separator(dayLabel(dayKey, ts ?: 0L, today, todayLabel, yesterdayLabel, dateFmt), sepOrdinal++))
3172 // A date separator visually breaks a speaker run, so the next message is treated
3173 // as the first of its group (even if the same role continues across the day break).
3174 prevRoleClass = null
3176 if (dayKey.isNotEmpty()) lastDayKey = dayKey
3178 // Most messages have a unique non-empty id. An unrecognized-role message can carry an
3179 // empty id — and the reducer intentionally keeps several such messages as distinct
3180 // holders — so give each a stable synthetic key and defensively de-collide any remaining
3181 // duplicate; otherwise LazyColumn throws on a duplicate key and the whole screen crashes.
3182 var key = message.info.id.ifEmpty { "msg-empty-${emptyIdOrdinal++}" }
3183 if (!seenKeys.add(key)) {
3185 while (!seenKeys.add("$key#$suffix")) suffix++
3186 key = "$key#$suffix"
3188 val roleClass = message.info::class
3189 val isFirstOfSpeaker = prevRoleClass == null || roleClass != prevRoleClass
3190 result.add(MessageListItem.Message(message, key, isFirstOfSpeaker))
3191 prevRoleClass = roleClass
3197 * A stable bucket key for a timestamp's calendar day (epoch-days as a string).
3199 * [buildMessageListItems] re-runs on every streaming snapshot (the memo is keyed on the
3200 * whole `messages` list, which must stay so the live message's fresh parts render), so a
3201 * naive implementation would re-allocate an `Instant`/`ZonedDateTime`/`LocalDate` for
3202 * every message on every token. Message timestamps are immutable once assigned, so we
3203 * memoize the parse per epoch-millis: after the first snapshot, each unchanged message is
3204 * a cheap hash lookup. Bounded; only touched on the main thread during composition.
3206 * Backed by a [ConcurrentHashMap] (not an access-ordered LinkedHashMap): the previous
3207 * synchronized access-ordered LinkedHashMap reordered its linked list node on every
3208 * `getOrPut` hit, which is real work multiplied by N messages × ~20 snapshots/sec during
3209 * streaming. A ConcurrentHashMap has no access-order bookkeeping and is lock-free for
3210 * reads, so the common case (a hit) is a single volatile read + hash probe. The bound
3211 * is enforced via [removeEldestEntry] is not available on ConcurrentHashMap, so we cap
3212 * via `compute`-guarded size checks; in practice the cache size is bounded by the number
3213 * of distinct message timestamps in a session (≤ [NetworkConfig.maxInMemoryMessages]).
3215private val dayKeyCache = java.util.concurrent.ConcurrentHashMap<Long, String>(256)
3217private fun dayKey(epochMillis: Long): String = dayKeyCache.getOrPut(epochMillis) {
3218 java.time.Instant.ofEpochMilli(epochMillis)
3219 .atZone(java.time.ZoneId.systemDefault()).toLocalDate().toEpochDay().toString()
3222// The medium-date formatter is an ICU locale lookup; cache one instance instead of
3223// allocating a fresh one per buildMessageListItems call, rebuilding only if the default
3224// locale changes at runtime. Main-thread-only, so no synchronization is needed.
3225private var cachedDateFmt: java.text.DateFormat? = null
3226private var cachedDateFmtLocale: java.util.Locale? = null
3228private fun mediumDateFormat(): java.text.DateFormat {
3229 val locale = java.util.Locale.getDefault()
3230 val existing = cachedDateFmt
3231 if (existing != null && cachedDateFmtLocale == locale) return existing
3232 return java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM).also {
3234 cachedDateFmtLocale = locale
3239 * Resolve a day-key back to a human-readable label. "Today" / "Yesterday" for the
3240 * recent days, otherwise a locale-stable medium-date format. Returns an empty string
3241 * when [dayKey] is empty (no timestamp) so no separator is rendered for un-timestamped
3244private fun dayLabel(
3247 today: java.time.LocalDate,
3249 yesterdayLabel: String,
3250 dateFmt: java.text.DateFormat,
3252 if (dayKey.isEmpty()) return ""
3253 val epochDay = dayKey.toLongOrNull() ?: return ""
3254 val date = java.time.LocalDate.ofEpochDay(epochDay)
3256 date == today -> todayLabel
3257 date == today.minusDays(1) -> yesterdayLabel
3258 else -> dateFmt.format(java.util.Date(ts))
3263 * A centered day divider in the message list. Renders nothing for an empty label
3264 * (the no-timestamp case) so un-timestamped messages don't get a stray blank divider.
3267private fun DateSeparator(label: String) {
3268 if (label.isBlank()) return
3270 modifier = Modifier.fillMaxWidth(),
3271 horizontalArrangement = Arrangement.Center,
3274 color = MaterialTheme.colorScheme.surfaceVariant,
3275 shape = MaterialTheme.shapes.small,
3276 // Marked as a heading so TalkBack users can navigate day-by-day with the
3277 // heading-skip gesture — the canonical affordance for date dividers in a list.
3278 modifier = Modifier.semantics(mergeDescendants = true) { heading() },
3282 style = MaterialTheme.typography.labelSmall,
3283 color = MaterialTheme.colorScheme.onSurfaceVariant,
3284 modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
3290/** Skeleton placeholder bubbles for the chat loading and run-started states. Alpha pulses
3291 * (gated by reduced-motion) to convey ongoing work; the column carries the a11y label so
3292 * TalkBack announces "Loading" instead of reading the skeleton bars. Rendered as a
3293 * [BoxScope] extension so it can align itself to the top center of its host box. */
3295private fun androidx.compose.foundation.layout.BoxScope.ChatSkeleton(topPad: androidx.compose.ui.unit.Dp) {
3296 val loadingLabel = stringResource(R.string.loading)
3297 val reducedMotion = LocalReducedMotion.current
3298 val transition = rememberInfiniteTransition(label = "skeleton")
3299 val pulse by transition.animateFloat(
3300 initialValue = 0.35f,
3302 animationSpec = infiniteRepeatable(tween(900), RepeatMode.Reverse),
3303 label = "skeletonAlpha",
3305 val skeletonAlpha = if (reducedMotion) 0.5f else pulse
3306 val skeletonColor = MaterialTheme.colorScheme.surfaceVariant
3308 fun bar(widthFraction: Float) {
3311 .fillMaxWidth(widthFraction)
3313 .clip(RoundedCornerShape(4.dp))
3314 .background(skeletonColor)
3315 .alpha(skeletonAlpha),
3320 .align(Alignment.TopCenter)
3321 .padding(top = topPad)
3324 .semantics { contentDescription = loadingLabel },
3325 verticalArrangement = Arrangement.spacedBy(14.dp),
3328 val left = i % 2 == 0
3330 modifier = Modifier.fillMaxWidth(),
3331 horizontalArrangement = if (left) Arrangement.Start else Arrangement.End,
3334 modifier = Modifier.fillMaxWidth(if (left) 0.7f else 0.55f),
3335 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