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