git.iko.soy/opencode-android
on a0ce9ce help sign in
3343 lines 187 kB history raw
1@file:Suppress("TooManyFunctions")
3package soy.iko.opencode.ui.chat
5import android.app.Activity
6import android.content.Intent
7import android.net.Uri
8import android.speech.RecognizerIntent
9import androidx.activity.compose.BackHandler
10import androidx.activity.compose.rememberLauncherForActivityResult
11import androidx.activity.result.PickVisualMediaRequest
12import androidx.activity.result.contract.ActivityResultContracts
13import androidx.compose.animation.AnimatedVisibility
14import androidx.compose.animation.core.RepeatMode
15import androidx.compose.animation.core.animateFloat
16import androidx.compose.animation.core.infiniteRepeatable
17import androidx.compose.animation.core.rememberInfiniteTransition
18import androidx.compose.animation.core.tween
19import androidx.compose.foundation.background
20import androidx.compose.foundation.clickable
21import androidx.compose.foundation.content.contentReceiver
22import androidx.compose.foundation.layout.Arrangement
23import androidx.compose.foundation.layout.Box
24import androidx.compose.foundation.layout.Column
25import androidx.compose.foundation.layout.FlowRow
26import androidx.compose.foundation.layout.PaddingValues
27import androidx.compose.foundation.layout.Row
28import androidx.compose.foundation.layout.Spacer
29import androidx.compose.foundation.layout.WindowInsets
30import androidx.compose.foundation.layout.fillMaxSize
31import androidx.compose.foundation.layout.fillMaxWidth
32import androidx.compose.foundation.layout.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)
198@Composable
199fun ChatScreen(
200 container: AppContainer,
201 sessionId: String,
202 onBack: () -> Unit,
203 onOpenFile: ((String) -> Unit)? = null,
204 onOpenSession: ((String) -> Unit)? = null,
205 focusMessageId: String? = null,
206 onEditProfile: ((String) -> Unit)? = null,
207) {
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) {
256 buildString {
257 append(selectedModel?.modelLabel ?: defaultModelLabel)
258 selectedAgent?.let { append(" · $it") }
259 }
260 }
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()
267 }
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) }
274 }
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))
287 onBack()
288 }
289 }
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,
333 ) {
334 buildList {
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 })
341 }
342 }
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 }
354 }
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)
362 } else {
363 snackbar.showSnackbar(event.message)
364 }
365 if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) vm.retryFailed()
366 }
367 }
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()
379 }
380 }
381 }
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 {
391 coroutineScope {
392 val dismisser = launch {
393 delay(NetworkConfig.undoDeleteDelayMs)
394 snackbar.currentSnackbarData?.dismiss()
395 }
396 val result = snackbar.showSnackbar(
397 message = sessionDeletedLabel,
398 actionLabel = undoLabel,
399 duration = SnackbarDuration.Indefinite,
400 )
401 dismisser.cancel()
402 if (result == SnackbarResult.ActionPerformed) vm.cancelSessionDelete()
403 }
404 }
405 }
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)
428 }
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(),
442 ) { success ->
443 val uri = pendingCaptureUri
444 pendingCaptureUri = null
445 if (success && uri != null) stageUris(listOf(uri))
446 }
448 val voiceLauncher = rememberLauncherForActivityResult(
449 ActivityResultContracts.StartActivityForResult(),
450 ) { result ->
451 if (result.resultCode == Activity.RESULT_OK) {
452 val spoken = result.data
453 ?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)
454 ?.firstOrNull()
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))
462 }
463 }
464 }
466 fun launchCamera() {
467 val uri = newCameraCaptureUri(appContext)
468 if (uri == null) {
469 scope.launch { snackbar.showSnackbar(attachFailedMsg) }
470 return
471 }
472 pendingCaptureUri = uri
473 runCatching { cameraLauncher.launch(uri) }
474 .onFailure { pendingCaptureUri = null; scope.launch { snackbar.showSnackbar(noCameraMsg) } }
475 }
477 fun launchVoice() {
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)
481 }
482 runCatching { voiceLauncher.launch(intent) }
483 .onFailure { scope.launch { snackbar.showSnackbar(noVoiceMsg) } }
484 }
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)
491 }
492 }
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)
503 }
504 }
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) })
516 }
518 fun doSend() {
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)
523 }
524 }
526 BackHandler(enabled = running && !showModelPicker && !showAgentPicker && !showCommandPicker) { showExitConfirm = true }
528 Scaffold(
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
535 when {
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 -> {
543 vm.abort(); true
544 }
545 else -> false
546 }
547 },
548 topBar = {
549 TopAppBar(
550 title = {
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.
556 Box {
557 Row(
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".
562 modifier = Modifier
563 .clickable(
564 role = Role.Button,
565 onClickLabel = changeLabel,
566 ) { showTitleMenu = true }
567 .semantics(mergeDescendants = true) {},
568 verticalAlignment = Alignment.CenterVertically,
569 ) {
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.
573 Text(
574 sessionTitle ?: sessionLabel,
575 maxLines = 1,
576 overflow = TextOverflow.Ellipsis,
577 )
578 Text(
579 subtitle,
580 style = MaterialTheme.typography.labelSmall,
581 color = MaterialTheme.colorScheme.onSurfaceVariant,
582 maxLines = 1,
583 overflow = TextOverflow.Ellipsis,
584 )
585 }
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.
591 Icon(
592 Icons.Filled.KeyboardArrowDown,
593 contentDescription = null,
594 tint = MaterialTheme.colorScheme.onSurfaceVariant,
595 modifier = Modifier.size(20.dp),
596 )
597 }
598 DropdownMenu(expanded = showTitleMenu, onDismissRequest = { showTitleMenu = false }) {
599 DropdownMenuItem(
600 text = { Text(stringResource(R.string.palette_switch_model)) },
601 enabled = !modelsLoading || models.isNotEmpty(),
602 onClick = { showTitleMenu = false; showModelPicker = true },
603 )
604 DropdownMenuItem(
605 text = { Text(stringResource(R.string.palette_choose_agent)) },
606 enabled = !agentsLoading || agents.isNotEmpty(),
607 onClick = { showTitleMenu = false; showAgentPicker = true },
608 )
609 }
610 }
611 },
612 navigationIcon = {
613 IconButton(onClick = {
614 if (running) showExitConfirm = true else onBack()
615 }) {
616 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
617 }
618 },
619 actions = {
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.
624 if (running) {
625 val workingLabel = stringResource(R.string.working)
626 CircularProgressIndicator(
627 Modifier.size(18.dp).semantics { contentDescription = workingLabel },
628 strokeWidth = 2.dp,
629 )
630 }
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)
641 IconButton(
642 onClick = { vm.refreshMessages() },
643 enabled = activeConnection != null && !refreshing && !running,
644 ) {
645 if (refreshing) {
646 CircularProgressIndicator(
647 Modifier.size(18.dp).semantics { contentDescription = refreshLabel },
648 strokeWidth = 2.dp,
649 )
650 } else {
651 Icon(Icons.Filled.Refresh, contentDescription = refreshLabel)
652 }
653 }
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)
675 Box {
676 IconButton(onClick = { showOverflowMenu = true }) {
677 Icon(Icons.Filled.MoreVert, contentDescription = moreLabel)
678 }
679 DropdownMenu(
680 expanded = showOverflowMenu,
681 onDismissRequest = { showOverflowMenu = false },
682 ) {
683 DropdownMenuItem(
684 text = { Text(shareLabel) },
685 enabled = hasMessages,
686 onClick = {
687 showOverflowMenu = false
688 scope.launch {
689 val md = withContext(Dispatchers.Default) {
690 buildConversationMarkdown(vm.messages.value, sessionTitle)
691 }
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)
696 }
697 runCatchingCancellable { shareContext.startActivity(android.content.Intent.createChooser(send, shareLabel)) }
698 .onFailure { showToast(shareContext, shareContext.getString(R.string.no_share_app)) }
699 }
700 },
701 )
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.
704 DropdownMenuItem(
705 text = { Text(copyAsMarkdownLabel) },
706 enabled = hasMessages,
707 onClick = {
708 showOverflowMenu = false
709 scope.launch {
710 val md = withContext(Dispatchers.Default) {
711 buildConversationMarkdown(vm.messages.value, sessionTitle)
712 }
713 copyToClipboard(shareContext, shareContext.getString(R.string.clip_label_message), md)
714 }
715 },
716 )
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).
720 DropdownMenuItem(
721 text = { Text(shareAsJsonLabel) },
722 enabled = hasMessages,
723 onClick = {
724 showOverflowMenu = false
725 scope.launch {
726 val json = withContext(Dispatchers.Default) {
727 buildConversationJson(vm.messages.value)
728 }
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)
733 }
734 runCatchingCancellable { shareContext.startActivity(android.content.Intent.createChooser(send, shareAsJsonLabel)) }
735 .onFailure { showToast(shareContext, shareContext.getString(R.string.no_share_app)) }
736 }
737 },
738 )
739 DropdownMenuItem(
740 text = { Text(copyAsJsonLabel) },
741 enabled = hasMessages,
742 onClick = {
743 showOverflowMenu = false
744 scope.launch {
745 val json = withContext(Dispatchers.Default) {
746 buildConversationJson(vm.messages.value)
747 }
748 copyToClipboard(shareContext, shareContext.getString(R.string.clip_label_message), json)
749 }
750 },
751 )
752 DropdownMenuItem(
753 text = { Text(commandsLabel) },
754 enabled = !commandsLoading || commands.isNotEmpty(),
755 onClick = { showOverflowMenu = false; showCommandPicker = true },
756 )
757 DropdownMenuItem(
758 text = { Text(stringResource(R.string.keyboard_shortcuts)) },
759 onClick = { showOverflowMenu = false; showShortcutsDialog = true },
760 )
761 DropdownMenuItem(
762 text = { Text(stringResource(R.string.find_in_conversation)) },
763 onClick = {
764 showOverflowMenu = false
765 searchActive = true
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).
770 },
771 )
772 androidx.compose.material3.HorizontalDivider()
773 // Session sharing: create a public link, copy an existing one, or revoke it.
774 if (shareUrl == null) {
775 DropdownMenuItem(
776 text = { Text(createShareLabel) },
777 enabled = hasMessages,
778 onClick = { showOverflowMenu = false; vm.shareSession() },
779 )
780 } else {
781 DropdownMenuItem(
782 text = { Text(copyShareLabel) },
783 onClick = {
784 showOverflowMenu = false
785 shareUrl?.let {
786 copyToClipboard(shareContext, shareContext.getString(R.string.clip_label_share), it)
787 scope.launch { snackbar.showSnackbar(linkCopiedMsg) }
788 }
789 },
790 )
791 DropdownMenuItem(
792 text = { Text(stopShareLabel) },
793 onClick = { showOverflowMenu = false; vm.unshareSession() },
794 )
795 }
796 DropdownMenuItem(
797 text = { Text(summarizeLabel) },
798 enabled = hasMessages && !running,
799 onClick = { showOverflowMenu = false; showSummarizeConfirm = true },
800 )
801 DropdownMenuItem(
802 text = { Text(initLabel) },
803 enabled = !running,
804 onClick = { showOverflowMenu = false; showInitConfirm = true },
805 )
806 DropdownMenuItem(
807 text = { Text(shellLabel) },
808 enabled = !running,
809 onClick = { showOverflowMenu = false; showShellDialog = true },
810 )
811 androidx.compose.material3.HorizontalDivider()
812 DropdownMenuItem(
813 text = { Text(renameLabel) },
814 onClick = { showOverflowMenu = false; showRenameDialog = true },
815 )
816 DropdownMenuItem(
817 text = { Text(deleteLabel, color = MaterialTheme.colorScheme.error) },
818 onClick = { showOverflowMenu = false; showDeleteDialog = true },
819 )
820 }
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(
831 Modifier
832 .align(Alignment.TopEnd)
833 .size(10.dp)
834 .semantics { contentDescription = loadingLabel },
835 strokeWidth = 1.dp,
836 )
837 }
838 }
839 },
840 )
841 },
842 snackbarHost = { SnackbarHost(snackbar) },
843 bottomBar = {
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()
848 Column {
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()
853 AnimatedVisibility(
854 visible = reverted,
855 enter = bannerMotion.enter,
856 exit = bannerMotion.exit,
857 ) {
858 RevertBanner(
859 diff = revertDiff,
860 isEditing = editing,
861 onUndo = { vm.unrevert() },
862 )
863 }
864 AnimatedVisibility(
865 visible = queuedOffline.isNotEmpty(),
866 enter = bannerMotion.enter,
867 exit = bannerMotion.exit,
868 ) {
869 OutboxBanner(
870 count = queuedOffline.size,
871 sending = outboxSending,
872 onFlush = { vm.flushQueued() },
873 onDiscard = { vm.discardAllQueued() },
874 )
875 }
876 AnimatedVisibility(
877 visible = pendingQuote != null,
878 enter = bannerMotion.enter,
879 exit = bannerMotion.exit,
880 ) {
881 pendingQuote?.let { QuoteReplyBanner(it, onCancel = { vm.cancelQuoteReply() }) }
882 }
883 ChatInputBar(
884 value = draft,
885 onValueChange = vm::updateDraft,
886 running = running,
887 aborting = aborting,
888 enabled = activeConnection != null,
889 sendOnEnter = sendOnEnter,
890 onSend = ::doSend,
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,
900 onPickPhoto = {
901 photoPicker.launch(
902 PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly),
903 )
904 },
905 onPickFile = { filePicker.launch("*/*") },
906 onCamera = { launchCamera() },
907 onVoice = { launchVoice() },
908 onPasteImage = { stageUris(it) },
909 commands = commands,
910 onRunCommand = { cmd ->
911 vm.runCommand(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).
915 vm.updateDraft("")
916 },
917 )
918 }
919 },
920 ) { padding ->
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()) {
960 messages
961 } else {
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(),
972 searchQuery,
973 ) {
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 ->
982 m.parts.any { p ->
983 when (p) {
984 is TextPart -> p.text.contains(searchQuery, ignoreCase = true)
985 is ReasoningPart -> p.text.contains(searchQuery, ignoreCase = true)
986 is ToolPart -> {
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
996 }
997 is FilePart -> p.filename?.contains(searchQuery, ignoreCase = true) == true ||
998 p.sourcePath?.contains(searchQuery, ignoreCase = true) == true
999 else -> false
1005 filtered
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) {
1014 searchPos = 0
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 {
1038 derivedStateOf {
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
1125 if (shouldScroll) {
1126 runCatchingCancellable {
1127 listState.animateScrollToItem(if (running) listItems.size else listItems.lastIndex)
1130 wasImeOpen = imeOpen
1133 LaunchedEffect(Unit) {
1134 var prevSize = 0
1135 var prevLen = 0
1136 snapshotFlow {
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.
1149 newContentCount = 0
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.
1175 //
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),
1183 ) {
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) {
1191 Column(
1192 modifier = Modifier.align(Alignment.Center).padding(24.dp),
1193 horizontalAlignment = Alignment.CenterHorizontally,
1194 ) {
1195 Text(
1196 stringResource(R.string.not_connected),
1197 color = MaterialTheme.colorScheme.error,
1199 Spacer(Modifier.size(12.dp))
1200 Button(onClick = { vm.reconnect() }, enabled = !reconnecting) {
1201 if (reconnecting) {
1202 CircularProgressIndicator(
1203 Modifier.size(20.dp),
1204 color = MaterialTheme.colorScheme.onPrimary,
1205 strokeWidth = 2.dp,
1207 } else {
1208 Text(stringResource(R.string.reconnect))
1212 } else {
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
1229 } else {
1230 44.dp
1232 } else {
1233 16.dp
1234 } + if (searchActive && searchbarHeightPx > 0) {
1235 with(density) { searchbarHeightPx.toDp() } + 4.dp
1236 } else {
1237 0.dp
1239 ConnectionBanner(
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)
1255 },
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) {
1265 Surface(
1266 color = MaterialTheme.colorScheme.errorContainer,
1267 shape = MaterialTheme.shapes.small,
1268 modifier = Modifier
1269 .align(Alignment.TopCenter)
1270 .padding(top = if (bannerVisible) topPad else 8.dp)
1271 .padding(horizontal = 16.dp)
1272 .semantics { liveRegion = LiveRegionMode.Polite },
1273 ) {
1274 Row(
1275 modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
1276 verticalAlignment = Alignment.CenterVertically,
1277 horizontalArrangement = Arrangement.spacedBy(8.dp),
1278 ) {
1279 Icon(
1280 Icons.Filled.ErrorOutline,
1281 contentDescription = null,
1282 modifier = Modifier.size(16.dp),
1283 tint = MaterialTheme.colorScheme.onErrorContainer,
1285 Text(
1286 stringResource(R.string.load_error_persistent),
1287 style = MaterialTheme.typography.labelMedium,
1288 color = MaterialTheme.colorScheme.onErrorContainer,
1289 modifier = Modifier.weight(1f),
1291 TextButton(
1292 onClick = { vm.refreshMessages() },
1293 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp),
1294 ) {
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.
1304 PullToRefreshBox(
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),
1316 ) {
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.
1329 Column(
1330 modifier = Modifier.align(Alignment.Center).padding(top = topPad).padding(24.dp),
1331 horizontalAlignment = Alignment.CenterHorizontally,
1332 ) {
1333 Text(
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.
1346 if (loading) {
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),
1352 strokeWidth = 2.dp,
1354 Text(
1355 retryingLabel,
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()) {
1370 EmptyConversation(
1371 onSuggestion = {
1372 vm.updateDraft(it)
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() }
1376 },
1377 modifier = Modifier.align(Alignment.Center).padding(top = topPad),
1379 } else {
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) {
1391 LazyColumn(
1392 state = listState,
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(
1398 start = 16.dp,
1399 top = topPad,
1400 end = 16.dp,
1401 bottom = 16.dp + NetworkConfig.chatListFabInsetDp.dp,
1402 ),
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),
1406 ) {
1407 items(
1408 items = listItems,
1409 key = { it.key },
1410 contentType = { it.contentType },
1411 ) { item ->
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
1419 } else null
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).
1426 Box(
1427 Modifier
1428 .then(reducedMotionAnimateItem())
1429 .then(
1430 if (isFocusedMatch) {
1431 Modifier.background(
1432 MaterialTheme.colorScheme.secondaryContainer,
1433 MaterialTheme.shapes.medium,
1435 } else {
1436 Modifier
1437 },
1438 ),
1439 ) {
1440 when (item) {
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) {
1467 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) {
1474 message.parts
1475 .filterIsInstance<TextPart>()
1476 .joinToString("\n\n") { it.text }
1477 .trim()
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) {
1494 { value ->
1495 val replyValue = if (layoutDirection == LayoutDirection.Rtl) {
1496 SwipeToDismissBoxValue.EndToStart
1497 } else {
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() }
1505 scope.launch {
1506 val res = snackbar.showSnackbar(
1507 message = quoteReplyLabel,
1508 actionLabel = undoLabel,
1510 if (res == androidx.compose.material3.SnackbarResult.ActionPerformed) {
1511 vm.cancelQuoteReply()
1514 } else {
1515 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
1516 scope.launch { snackbar.showSnackbar(cantQuoteMsg) }
1519 false
1521 },
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) {
1539 { text: String ->
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))
1552 Unit
1555 val onSpeak = remember(messageId) {
1556 { text: String ->
1557 if (!tts.toggle(messageId, text)) {
1558 scope.launch { snackbar.showSnackbar(ttsUnavailableMsg) }
1560 Unit
1563 val onPause = remember { { tts.pause() } }
1564 val onResume = remember { { tts.resume() } }
1565 val onStop = remember { { tts.stop() } }
1566 val onQuote = remember(messageId) {
1567 { text: String ->
1568 vm.quoteReply(text)
1569 runCatching { inputFocusRequester.requestFocus() }
1570 Unit
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) } }
1578 } else {
1579 null
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) } }
1587 } else {
1588 null
1590 val messageFailed = optimisticStatuses[messageId] == true
1591 val onRetry = if (messageFailed) {
1592 remember(messageId) { { vm.retryOptimisticMessage(messageId); Unit } }
1593 } else {
1594 null
1596 val onDismiss = if (messageFailed) {
1597 remember(messageId) { { vm.dismissOptimistic(messageId) } }
1598 } else {
1599 null
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.
1606 scope.launch {
1607 val md = withContext(Dispatchers.Default) {
1608 buildMessageMarkdown(message)
1609 } ?: return@launch
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)) }
1618 Unit
1621 SwipeToDismissBox(
1622 state = swipeState,
1623 enableDismissFromEndToStart = !replyFromStartToEnd,
1624 enableDismissFromStartToEnd = replyFromStartToEnd,
1625 backgroundContent = {
1626 Box(
1627 Modifier.fillMaxSize().padding(horizontal = 16.dp),
1628 contentAlignment = if (replyFromStartToEnd) Alignment.CenterStart else Alignment.CenterEnd,
1629 ) {
1630 Icon(
1631 Icons.AutoMirrored.Filled.Reply,
1632 contentDescription = null,
1633 tint = MaterialTheme.colorScheme.onSurfaceVariant,
1636 },
1637 ) {
1638 MessageBubble(
1639 message,
1640 isRunning = running && messageId == lastMessageId,
1641 imageContext = imageContext,
1642 modelLabel = modelLabel,
1643 agentLabel = agentLabel,
1644 onOpenFile = onOpenFile,
1645 onRevert = onRevert,
1646 onEdit = onEdit,
1647 onSpeak = onSpeak,
1648 isSpeaking = messageId == speakingMessageId,
1649 ttsState = if (messageId == speakingMessageId) ttsState else TtsState.IDLE,
1650 onPause = onPause,
1651 onResume = onResume,
1652 onStop = onStop,
1653 onQuote = onQuote,
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
1660 },
1661 isEdited = message.info.time?.let { it.updated != null && it.updated != it.created } == true,
1662 onRetry = onRetry,
1663 onDismiss = onDismiss,
1664 onShare = onShare,
1665 isFirstOfSpeaker = item.isFirstOfSpeaker,
1666 highlighted = focusedMessageId != null && messageId == focusedMessageId,
1673 if (running) {
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
1693 if (aborting) {
1694 // Freeze at the last-computed value: one final snapshot then stop.
1695 value = (System.currentTimeMillis() - startMs).coerceAtLeast(0L)
1696 return@produceState
1698 while (true) {
1699 value = (System.currentTimeMillis() - startMs).coerceAtLeast(0L)
1700 delay(1000)
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)
1711 Row(
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.
1716 modifier = Modifier
1717 .then(reducedMotionAnimateItem())
1718 .padding(vertical = 4.dp),
1719 ) {
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.
1724 Row(
1725 verticalAlignment = Alignment.CenterVertically,
1726 modifier = Modifier.semantics(mergeDescendants = true) {
1727 contentDescription = "$interruptedLabel $elapsedText"
1728 },
1729 ) {
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),
1734 strokeWidth = 2.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))
1740 Icon(
1741 Icons.Filled.Schedule,
1742 contentDescription = null,
1743 modifier = Modifier.size(14.dp),
1744 tint = MaterialTheme.colorScheme.onSurfaceVariant,
1746 Text(
1747 elapsedText,
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)
1759 IconButton(
1760 onClick = { vm.abort() },
1761 modifier = Modifier.semantics { contentDescription = stopLabel },
1762 ) {
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()
1774 AnimatedVisibility(
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),
1781 ) {
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).
1784 BadgedBox(
1785 badge = {
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,
1791 newContentCount,
1792 newContentCount,
1794 Badge(
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
1802 },
1803 ) {
1804 Text(
1805 if (newContentCount > 99) "99+" else newContentCount.toString(),
1806 modifier = Modifier.semantics { invisibleToUser() },
1810 },
1811 modifier = Modifier.padding(end = 16.dp, bottom = 16.dp),
1812 ) {
1813 ExtendedFloatingActionButton(
1814 onClick = {
1815 newContentCount = 0
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) } }
1822 },
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 }
1836 AnimatedVisibility(
1837 visible = scrolledDown && listItems.size > 4,
1838 enter = fabMotion.enter,
1839 exit = fabMotion.exit,
1840 modifier = Modifier.align(Alignment.BottomStart).padding(start = sideMargin),
1841 ) {
1842 SmallFloatingActionButton(
1843 onClick = {
1844 contentScope.launch { runCatchingCancellable { listState.animateScrollToItem(0) } }
1845 },
1846 modifier = Modifier.padding(start = 16.dp, bottom = 16.dp),
1847 ) {
1848 Icon(
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()
1857 AnimatedVisibility(
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),
1864 ) {
1865 Surface(
1866 tonalElevation = 3.dp,
1867 modifier = Modifier
1868 .padding(horizontal = 12.dp, vertical = 4.dp)
1869 .fillMaxWidth()
1870 .onSizeChanged { searchbarHeightPx = it.height },
1871 ) {
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() }
1878 Row(
1879 modifier = Modifier.padding(start = 8.dp, end = 4.dp),
1880 verticalAlignment = Alignment.CenterVertically,
1881 ) {
1882 OutlinedTextField(
1883 value = chatSearch,
1884 onValueChange = { chatSearch = it },
1885 modifier = Modifier.weight(1f).focusRequester(searchFocusRequester),
1886 placeholder = { Text(stringResource(R.string.search_in_conversation)) },
1887 singleLine = true,
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
1894 Text(
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)
1899 } else "",
1900 style = MaterialTheme.typography.labelSmall,
1901 color = MaterialTheme.colorScheme.onSurfaceVariant,
1902 modifier = Modifier
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)
1914 IconButton(
1915 onClick = {
1916 if (matchCount > 0) {
1917 searchPos = (searchPos - 1 + matchCount) % matchCount
1919 },
1920 enabled = matchCount > 1,
1921 ) {
1922 Icon(Icons.Filled.KeyboardArrowUp, contentDescription = prevLabel)
1924 IconButton(
1925 onClick = {
1926 if (matchCount > 0) {
1927 searchPos = (searchPos + 1) % matchCount
1929 },
1930 enabled = matchCount > 1,
1931 ) {
1932 Icon(Icons.Filled.KeyboardArrowDown, contentDescription = nextLabel)
1934 IconButton(onClick = {
1935 searchActive = false
1936 chatSearch = ""
1937 }) {
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()) {
1946 Text(
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) {
1959 AlertDialog(
1960 onDismissRequest = { showExitConfirm = false },
1961 title = { Text(stringResource(R.string.stop_and_exit_title)) },
1962 text = { Text(stringResource(R.string.stop_and_exit_text)) },
1963 confirmButton = {
1964 TextButton(onClick = {
1965 showExitConfirm = false
1966 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
1967 vm.abort()
1968 onBack()
1969 }) { Text(stringResource(R.string.stop_and_exit)) }
1970 },
1971 dismissButton = {
1972 TextButton(onClick = { showExitConfirm = false }) { Text(stringResource(R.string.stay)) }
1973 },
1977 if (showSummarizeConfirm) {
1978 AlertDialog(
1979 onDismissRequest = { showSummarizeConfirm = false },
1980 title = { Text(stringResource(R.string.summarize_confirm_title)) },
1981 text = { Text(stringResource(R.string.summarize_confirm_text)) },
1982 confirmButton = {
1983 TextButton(onClick = {
1984 showSummarizeConfirm = false
1985 vm.summarize()
1986 }) { Text(stringResource(R.string.action_continue)) }
1987 },
1988 dismissButton = {
1989 TextButton(onClick = { showSummarizeConfirm = false }) { Text(stringResource(R.string.cancel)) }
1990 },
1994 if (showInitConfirm) {
1995 AlertDialog(
1996 onDismissRequest = { showInitConfirm = false },
1997 title = { Text(stringResource(R.string.init_confirm_title)) },
1998 text = { Text(stringResource(R.string.init_confirm_text)) },
1999 confirmButton = {
2000 TextButton(onClick = {
2001 showInitConfirm = false
2002 vm.initProject()
2003 }) { Text(stringResource(R.string.action_continue)) }
2004 },
2005 dismissButton = {
2006 TextButton(onClick = { showInitConfirm = false }) { Text(stringResource(R.string.cancel)) }
2007 },
2011 if (showRenameDialog) {
2012 RenameSessionChatDialog(
2013 initialTitle = sessionTitle ?: "",
2014 onDismiss = { showRenameDialog = false },
2015 onConfirm = { newName ->
2016 showRenameDialog = false
2017 vm.renameSession(newName)
2018 },
2022 if (showDeleteDialog) {
2023 AlertDialog(
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)) },
2027 confirmButton = {
2028 TextButton(onClick = {
2029 showDeleteDialog = false
2030 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
2031 vm.deleteSession()
2032 }) { Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) }
2033 },
2034 dismissButton = {
2035 TextButton(onClick = { showDeleteDialog = false }) { Text(stringResource(R.string.cancel)) }
2036 },
2040 if (showShellDialog) {
2041 ShellCommandDialog(
2042 onDismiss = { showShellDialog = false },
2043 onRun = { cmd ->
2044 showShellDialog = false
2045 vm.runShell(cmd)
2046 },
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")
2064 ModelPickerSheet(
2065 options = models,
2066 selected = selectedModel,
2067 loading = modelsLoading,
2068 error = modelsError,
2069 onSelect = {
2070 vm.selectModel(it)
2071 container.recentModelsStore.add(it.providerID, it.modelID)
2072 },
2073 onRetry = { vm.reloadModels() },
2074 onDismiss = { showModelPicker = false },
2075 preferredModelId = preferredModelId,
2076 onSetPreferredModel = { id ->
2077 scope.launch {
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
2083 } else {
2084 val label = models.firstOrNull { it.modelID == id }?.modelLabel ?: id
2085 defaultModelSetFmt.format(label)
2087 snackbar.showSnackbar(msg)
2089 },
2090 recent = recentOptions,
2094 if (showAgentPicker) {
2095 AgentPickerSheet(
2096 agents = agents,
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) {
2107 CommandPickerSheet(
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 ->
2118 PermissionDialog(
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).
2126 onAutoReject = {
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,
2135 },
2139 if (showPalette) {
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.
2147 AlertDialog(
2148 onDismissRequest = { showShortcutsDialog = false },
2149 title = { Text(stringResource(R.string.keyboard_shortcuts_title)) },
2150 text = {
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))
2158 },
2159 confirmButton = {
2160 TextButton(onClick = { showShortcutsDialog = false }) { Text(stringResource(R.string.action_continue)) }
2161 },
2166@Composable
2167private fun ShortcutRow(keys: String, desc: String) {
2168 Row(verticalAlignment = Alignment.CenterVertically) {
2169 Text(
2170 keys,
2171 style = MaterialTheme.typography.labelLarge,
2172 fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
2173 modifier = Modifier.widthIn(min = 160.dp),
2175 Text(
2176 desc,
2177 style = MaterialTheme.typography.bodyMedium,
2178 color = MaterialTheme.colorScheme.onSurfaceVariant,
2183/**
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.
2188 */
2189@Composable
2190private fun SlashCommandAutocomplete(
2191 value: String,
2192 commands: List<Command>,
2193 running: Boolean,
2194 onRunCommand: (Command) -> Unit,
2195) {
2196 if (running || commands.isEmpty()) return
2197 val slashQuery = remember(value) {
2198 if (value.startsWith("/")) value.removePrefix("/") else null
2199 } ?: return
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)
2205 }.take(8)
2207 if (matchingCommands.isNotEmpty()) {
2208 CommandAutocompleteDropdown(
2209 commands = matchingCommands,
2210 onPick = onRunCommand,
2215/**
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.
2220 */
2221@Composable
2222private fun CommandAutocompleteDropdown(
2223 commands: List<Command>,
2224 onPick: (Command) -> Unit,
2225) {
2226 val haptics = LocalHapticFeedback.current
2227 Surface(
2228 color = MaterialTheme.colorScheme.surface,
2229 tonalElevation = 3.dp,
2230 shape = MaterialTheme.shapes.medium,
2231 modifier = Modifier
2232 .fillMaxWidth()
2233 .padding(horizontal = 8.dp, vertical = 4.dp)
2234 .heightIn(max = 240.dp),
2235 ) {
2236 LazyColumn {
2237 items(
2238 count = commands.size,
2239 key = { commands[it].name },
2240 ) { i ->
2241 val cmd = commands[i]
2242 Row(
2243 modifier = Modifier
2244 .fillMaxWidth()
2245 .clickable(role = Role.Button) {
2246 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
2247 onPick(cmd)
2249 .padding(horizontal = 16.dp, vertical = 10.dp),
2250 verticalAlignment = Alignment.CenterVertically,
2251 horizontalArrangement = Arrangement.spacedBy(12.dp),
2252 ) {
2253 Text(
2254 "/${cmd.name}",
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 ->
2260 Text(
2261 desc,
2262 style = MaterialTheme.typography.bodySmall,
2263 color = MaterialTheme.colorScheme.onSurfaceVariant,
2264 maxLines = 1,
2265 overflow = TextOverflow.Ellipsis,
2274@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
2275@Suppress("CyclomaticComplexMethod")
2276@Composable
2277private fun ChatInputBar(
2278 value: String,
2279 onValueChange: (String) -> Unit,
2280 running: Boolean,
2281 aborting: Boolean,
2282 enabled: Boolean,
2283 sendOnEnter: Boolean,
2284 onSend: () -> Unit,
2285 onAbort: () -> Unit,
2286 queuedFollowUp: String?,
2287 onQueueFollowUp: (String) -> Unit,
2288 onCancelQueue: () -> Unit,
2289 focusRequester: androidx.compose.ui.focus.FocusRequester,
2290 attachments: List<PendingAttachment>,
2291 staging: Boolean,
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 = {},
2301) {
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)
2313 Row(
2314 modifier = Modifier
2315 .fillMaxWidth()
2316 .padding(start = 12.dp, top = 6.dp, end = 4.dp),
2317 verticalAlignment = Alignment.CenterVertically,
2318 ) {
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.
2321 Column(
2322 modifier = Modifier
2323 .weight(1f)
2324 .clip(MaterialTheme.shapes.small)
2325 .clickable(role = Role.Button, onClickLabel = editLabel) {
2326 onValueChange(queuedFollowUp)
2327 onCancelQueue()
2329 .padding(vertical = 4.dp),
2330 ) {
2331 Text(
2332 stringResource(R.string.queued),
2333 style = MaterialTheme.typography.labelSmall,
2334 color = MaterialTheme.colorScheme.onSurfaceVariant,
2336 Text(
2337 queuedFollowUp,
2338 style = MaterialTheme.typography.bodySmall,
2339 color = MaterialTheme.colorScheme.onSurfaceVariant,
2340 maxLines = 1,
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(
2355 value = value,
2356 commands = commands,
2357 running = running,
2358 onRunCommand = onRunCommand,
2360 Row(
2361 modifier = Modifier.fillMaxWidth().padding(8.dp),
2362 verticalAlignment = Alignment.Bottom,
2363 ) {
2364 // Attach menu: photo picker, camera, or any file.
2365 var showAttachMenu by remember { mutableStateOf(false) }
2366 Box {
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 }) {
2371 DropdownMenuItem(
2372 leadingIcon = { Icon(Icons.Filled.PhotoLibrary, contentDescription = null) },
2373 text = { Text(stringResource(R.string.attach_photo)) },
2374 onClick = { showAttachMenu = false; onPickPhoto() },
2376 DropdownMenuItem(
2377 leadingIcon = { Icon(Icons.Filled.PhotoCamera, contentDescription = null) },
2378 text = { Text(stringResource(R.string.attach_camera)) },
2379 onClick = { showAttachMenu = false; onCamera() },
2381 DropdownMenuItem(
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))
2405 OutlinedTextField(
2406 value = value,
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))
2411 },
2412 modifier = Modifier
2413 .weight(1f)
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.
2420 .contentReceiver(
2421 remember {
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)
2437 transferable
2439 },
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(
2446 enabled = enabled,
2447 hasContent = hasContent,
2448 sendOnEnter = sendOnEnter,
2449 shift = event.isShiftPressed,
2450 ctrl = event.isCtrlPressed,
2452 if (send) {
2453 if (running) onQueueFollowUp(value) else onSend()
2454 true
2455 } else {
2456 false
2458 },
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…".
2461 placeholder = {
2462 Text(stringResource(if (running) R.string.composer_hint_running else R.string.message_placeholder))
2463 },
2464 enabled = enabled,
2465 maxLines = 6,
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.
2470 supportingText = {
2471 val threshold = (NetworkConfig.maxDraftLengthChars * NetworkConfig.draftCountdownThresholdFraction).toInt()
2472 if (value.length >= threshold) {
2473 Text(
2474 stringResource(
2475 R.string.draft_chars_remaining,
2476 NetworkConfig.maxDraftLengthChars - value.length,
2477 NetworkConfig.maxDraftLengthChars,
2478 ),
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.
2488 Text(
2489 stringResource(R.string.open_full_editor_hint),
2490 style = MaterialTheme.typography.labelSmall,
2491 color = MaterialTheme.colorScheme.onSurfaceVariant,
2494 },
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,
2502 ),
2503 keyboardActions = KeyboardActions(
2504 onSend = {
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()
2513 },
2514 ),
2516 ComposerTrailingButton(
2517 running = running,
2518 aborting = aborting,
2519 canSend = enabled && hasContent,
2520 canQueue = enabled && value.isNotBlank(),
2521 onSend = onSend,
2522 onAbort = onAbort,
2523 onQueue = { onQueueFollowUp(value) },
2527 if (showFullScreenEditor) {
2528 FullScreenEditor(
2529 value = value,
2530 onValueChange = onValueChange,
2531 onDismiss = { showFullScreenEditor = false },
2532 onSend = {
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()
2539 },
2540 canSend = enabled && hasContent,
2541 running = running,
2542 sendOnEnter = sendOnEnter,
2543 attachments = attachments,
2544 onRemoveAttachment = onRemoveAttachment,
2545 staging = staging,
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)
2555@Composable
2556private fun FullScreenEditor(
2557 value: String,
2558 onValueChange: (String) -> Unit,
2559 onDismiss: () -> Unit,
2560 onSend: () -> Unit,
2561 canSend: Boolean,
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,
2568) {
2569 androidx.compose.ui.window.Dialog(
2570 onDismissRequest = onDismiss,
2571 properties = androidx.compose.ui.window.DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false),
2572 ) {
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,
2579 ) {
2580 val hPad = if (maxWidth > NetworkConfig.twoPaneWidthThresholdDp.dp)
2581 ((maxWidth - NetworkConfig.composerDialogMaxWidthDp.dp) / 2).coerceAtLeast(0.dp)
2582 else 0.dp
2583 Scaffold(
2584 modifier = Modifier.padding(horizontal = hPad),
2585 topBar = {
2586 androidx.compose.material3.TopAppBar(
2587 title = { Text(stringResource(R.string.compose_editor_title)) },
2588 navigationIcon = {
2589 androidx.compose.material3.TextButton(onClick = onDismiss) {
2590 Text(stringResource(R.string.collapse_editor))
2592 },
2594 },
2595 bottomBar = {
2596 Surface(tonalElevation = 3.dp) {
2597 Row(
2598 modifier = Modifier.fillMaxWidth().imePadding().padding(12.dp),
2599 horizontalArrangement = Arrangement.End,
2600 ) {
2601 androidx.compose.material3.Button(onClick = onSend, enabled = canSend) {
2602 Text(stringResource(if (running) R.string.queue else R.string.send))
2606 },
2607 ) { padding ->
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)
2613 OutlinedTextField(
2614 value = value,
2615 onValueChange = { v -> onValueChange(v.take(NetworkConfig.maxDraftLengthChars)) },
2616 modifier = Modifier
2617 .fillMaxSize()
2618 .weight(1f)
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(
2628 enabled = canSend,
2629 hasContent = canSend,
2630 sendOnEnter = sendOnEnter,
2631 shift = event.isShiftPressed,
2632 ctrl = event.isCtrlPressed,
2634 if (send) { onSend(); true } else false
2635 },
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,
2641 ),
2642 keyboardActions = KeyboardActions(
2643 onSend = { if (canSend) onSend() },
2644 ),
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(
2655 enabled: Boolean,
2656 hasContent: Boolean,
2657 sendOnEnter: Boolean,
2658 shift: Boolean,
2659 ctrl: Boolean,
2660): Boolean = when {
2661 !enabled || !hasContent -> false
2662 sendOnEnter -> !shift
2663 else -> ctrl
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. */
2669@Composable
2670private fun ComposerTrailingButton(
2671 running: Boolean,
2672 aborting: Boolean,
2673 canSend: Boolean,
2674 canQueue: Boolean,
2675 onSend: () -> Unit,
2676 onAbort: () -> Unit,
2677 onQueue: () -> Unit,
2678) {
2679 if (running) {
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).
2690 if (canQueue) {
2691 IconButton(
2692 onClick = onQueue,
2693 modifier = Modifier.testTag("queue_button"),
2694 ) {
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.
2698 Icon(
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.
2706 IconButton(
2707 onClick = onAbort,
2708 enabled = !aborting,
2709 modifier = Modifier.padding(start = 4.dp).testTag("stop_button"),
2710 ) {
2711 if (aborting) {
2712 val stopLabel = stringResource(R.string.stop)
2713 CircularProgressIndicator(
2714 Modifier.size(18.dp).semantics { contentDescription = stopLabel },
2715 strokeWidth = 2.dp,
2717 } else {
2718 Icon(Icons.Filled.Stop, contentDescription = stringResource(R.string.stop))
2722 } else {
2723 IconButton(
2724 onClick = onSend,
2725 enabled = canSend,
2726 modifier = Modifier.padding(start = 4.dp).testTag("send_button"),
2727 ) {
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.
2730 Icon(
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)
2741@Composable
2742private fun EmptyConversation(
2743 onSuggestion: (String) -> Unit,
2744 modifier: Modifier = Modifier,
2745) {
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 {
2750 listOf(
2751 R.string.empty_chat_suggest_1,
2752 R.string.empty_chat_suggest_2,
2753 R.string.empty_chat_suggest_3,
2756 Column(
2757 modifier = modifier.padding(32.dp),
2758 horizontalAlignment = Alignment.CenterHorizontally,
2759 ) {
2760 Icon(
2761 Icons.Filled.AutoAwesome,
2762 contentDescription = null,
2763 modifier = Modifier.size(48.dp),
2764 tint = MaterialTheme.colorScheme.onSurfaceVariant,
2766 Spacer(Modifier.size(16.dp))
2767 Text(
2768 stringResource(R.string.empty_chat_title),
2769 style = MaterialTheme.typography.titleMedium,
2771 Spacer(Modifier.size(8.dp))
2772 Text(
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.
2780 FlowRow(
2781 modifier = Modifier.fillMaxWidth(),
2782 horizontalArrangement = Arrangement.spacedBy(8.dp),
2783 verticalArrangement = Arrangement.spacedBy(8.dp),
2784 ) {
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. */
2799@Composable
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()
2804 Surface(
2805 color = MaterialTheme.colorScheme.tertiaryContainer,
2806 tonalElevation = 2.dp,
2807 modifier = Modifier.fillMaxWidth(),
2808 ) {
2809 Column {
2810 Row(
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,
2814 ) {
2815 Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
2816 Icon(
2817 if (isEditing) Icons.Filled.Edit else Icons.Filled.Restore,
2818 contentDescription = null,
2819 tint = MaterialTheme.colorScheme.onTertiaryContainer,
2820 modifier = Modifier.size(18.dp),
2822 Text(
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()) {
2832 TextButton(
2833 onClick = {
2834 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
2835 showDiff = !showDiff
2836 },
2837 contentPadding = androidx.compose.foundation.layout.PaddingValues(start = 8.dp, end = 4.dp),
2838 ) {
2839 Icon(
2840 if (showDiff) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
2841 contentDescription = null,
2842 modifier = Modifier.size(16.dp),
2844 Text(
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)
2856 onUndo()
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()) {
2862 AnimatedVisibility(
2863 visible = showDiff,
2864 enter = expandMotion.enter,
2865 exit = expandMotion.exit,
2866 ) {
2867 Column(modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 8.dp)) {
2868 Text(
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. */
2886@Composable
2887private fun QuoteReplyBanner(quotedText: String, onCancel: () -> Unit) {
2888 val haptics = LocalHapticFeedback.current
2889 Surface(
2890 color = MaterialTheme.colorScheme.secondaryContainer,
2891 tonalElevation = 2.dp,
2892 modifier = Modifier.fillMaxWidth(),
2893 ) {
2894 Row(
2895 modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 4.dp, top = 6.dp, bottom = 6.dp),
2896 verticalAlignment = Alignment.Top,
2897 ) {
2898 Icon(
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)) {
2905 Text(
2906 stringResource(R.string.quote_reply_banner),
2907 style = MaterialTheme.typography.labelSmall,
2908 color = MaterialTheme.colorScheme.onSecondaryContainer,
2910 Text(
2911 quotedText,
2912 style = MaterialTheme.typography.bodySmall,
2913 color = MaterialTheme.colorScheme.onSecondaryContainer,
2914 maxLines = 2,
2915 overflow = TextOverflow.Ellipsis,
2918 IconButton(onClick = {
2919 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
2920 onCancel()
2921 }) {
2922 Icon(
2923 Icons.Filled.Close,
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. */
2935@Composable
2936private fun OutboxBanner(
2937 count: Int,
2938 sending: Boolean,
2939 onFlush: () -> Unit,
2940 onDiscard: () -> Unit,
2941) {
2942 var showDiscardConfirm by rememberSaveable { mutableStateOf(false) }
2943 Surface(
2944 color = MaterialTheme.colorScheme.secondaryContainer,
2945 tonalElevation = 2.dp,
2946 modifier = Modifier.fillMaxWidth(),
2947 ) {
2948 Row(
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,
2952 ) {
2953 Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
2954 if (sending) {
2955 CircularProgressIndicator(
2956 Modifier.size(16.dp),
2957 strokeWidth = 2.dp,
2958 color = MaterialTheme.colorScheme.onSecondaryContainer,
2960 Spacer(Modifier.size(8.dp))
2962 Text(
2963 if (sending) {
2964 stringResource(R.string.outbox_sending)
2965 } else {
2966 val res = if (count == 1) R.string.outbox_pending_one else R.string.outbox_pending_many
2967 stringResource(res, count)
2968 },
2969 style = MaterialTheme.typography.bodySmall,
2970 color = MaterialTheme.colorScheme.onSecondaryContainer,
2973 if (!sending) {
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
2982 }) {
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)) },
2993 confirmButton = {
2994 androidx.compose.material3.TextButton(onClick = {
2995 showDiscardConfirm = false
2996 onDiscard()
2997 }) { Text(stringResource(R.string.discard)) }
2998 },
2999 dismissButton = {
3000 androidx.compose.material3.TextButton(onClick = { showDiscardConfirm = false }) {
3001 Text(stringResource(R.string.cancel))
3003 },
3008/** Prompt for a one-off shell command to run in the session's worktree. */
3009@Composable
3010private fun ShellCommandDialog(onDismiss: () -> Unit, onRun: (String) -> Unit) {
3011 var command by rememberSaveable { mutableStateOf("") }
3012 AlertDialog(
3013 onDismissRequest = onDismiss,
3014 title = { Text(stringResource(R.string.run_shell_command)) },
3015 text = {
3016 OutlinedTextField(
3017 value = 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)) },
3022 singleLine = true,
3023 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
3024 keyboardActions = KeyboardActions(onDone = { if (command.isNotBlank()) onRun(command.trim()) }),
3026 },
3027 confirmButton = {
3028 TextButton(
3029 onClick = { if (command.isNotBlank()) onRun(command.trim()) },
3030 enabled = command.isNotBlank(),
3031 ) { Text(stringResource(R.string.run)) }
3032 },
3033 dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } },
3037/**
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.
3041 */
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
3054 else -> 0
3056 else -> 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. */
3062@Composable
3063private fun RenameSessionChatDialog(
3064 initialTitle: String,
3065 onDismiss: () -> Unit,
3066 onConfirm: (String) -> Unit,
3067) {
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) }
3072 AlertDialog(
3073 onDismissRequest = onDismiss,
3074 title = { Text(stringResource(R.string.rename_session_chat)) },
3075 text = {
3076 OutlinedTextField(
3077 value = title,
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)) },
3082 singleLine = true,
3083 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
3084 keyboardActions = KeyboardActions(onDone = { if (title.isNotBlank()) onConfirm(title.trim()) }),
3086 },
3087 confirmButton = {
3088 TextButton(onClick = { if (title.isNotBlank()) onConfirm(title.trim()) }, enabled = title.isNotBlank()) {
3089 Text(stringResource(R.string.save))
3091 },
3092 dismissButton = {
3093 TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) }
3094 },
3098/**
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`).
3103 */
3104private sealed interface MessageListItem {
3105 val key: Any
3106 val contentType: Any
3108 data class Message(
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"
3132/**
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.
3142 */
3143private fun buildMessageListItems(
3144 messages: List<MessageWithParts>,
3145 todayLabel: String,
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
3154 var sepOrdinal = 0
3155 var emptyIdOrdinal = 0
3156 var first = true
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
3177 first = false
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)) {
3184 var suffix = 1
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
3193 return result
3196/**
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]).
3214 */
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 {
3233 cachedDateFmt = it
3234 cachedDateFmtLocale = locale
3238/**
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
3242 * messages.
3243 */
3244private fun dayLabel(
3245 dayKey: String,
3246 ts: Long,
3247 today: java.time.LocalDate,
3248 todayLabel: String,
3249 yesterdayLabel: String,
3250 dateFmt: java.text.DateFormat,
3251): String {
3252 if (dayKey.isEmpty()) return ""
3253 val epochDay = dayKey.toLongOrNull() ?: return ""
3254 val date = java.time.LocalDate.ofEpochDay(epochDay)
3255 return when {
3256 date == today -> todayLabel
3257 date == today.minusDays(1) -> yesterdayLabel
3258 else -> dateFmt.format(java.util.Date(ts))
3262/**
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.
3265 */
3266@Composable
3267private fun DateSeparator(label: String) {
3268 if (label.isBlank()) return
3269 Row(
3270 modifier = Modifier.fillMaxWidth(),
3271 horizontalArrangement = Arrangement.Center,
3272 ) {
3273 Surface(
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() },
3279 ) {
3280 Text(
3281 label,
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. */
3294@Composable
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,
3301 targetValue = 0.7f,
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
3307 @Composable
3308 fun bar(widthFraction: Float) {
3309 Box(
3310 Modifier
3311 .fillMaxWidth(widthFraction)
3312 .height(14.dp)
3313 .clip(RoundedCornerShape(4.dp))
3314 .background(skeletonColor)
3315 .alpha(skeletonAlpha),
3318 Column(
3319 modifier = Modifier
3320 .align(Alignment.TopCenter)
3321 .padding(top = topPad)
3322 .padding(16.dp)
3323 .fillMaxWidth()
3324 .semantics { contentDescription = loadingLabel },
3325 verticalArrangement = Arrangement.spacedBy(14.dp),
3326 ) {
3327 repeat(4) { i ->
3328 val left = i % 2 == 0
3329 Row(
3330 modifier = Modifier.fillMaxWidth(),
3331 horizontalArrangement = if (left) Arrangement.Start else Arrangement.End,
3332 ) {
3333 Column(
3334 modifier = Modifier.fillMaxWidth(if (left) 0.7f else 0.55f),
3335 verticalArrangement = Arrangement.spacedBy(6.dp),
3336 ) {
3337 bar(1f)
3338 bar(0.8f)