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