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