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