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