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