package soy.iko.opencode.ui.chat import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.filled.SmartToy import androidx.compose.material.icons.filled.Stop import androidx.compose.material.icons.filled.Terminal import androidx.compose.material.icons.filled.Tune import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import soy.iko.opencode.di.AppContainer import soy.iko.opencode.ui.vmFactory @OptIn(ExperimentalMaterial3Api::class) @Composable fun ChatScreen( container: AppContainer, sessionId: String, onBack: () -> Unit, ) { val vm: ChatViewModel = viewModel(factory = vmFactory { ChatViewModel(container, sessionId) }) val messages by vm.messages.collectAsStateWithLifecycle() val running by vm.running.collectAsStateWithLifecycle() val error by vm.error.collectAsStateWithLifecycle() val models by vm.models.collectAsStateWithLifecycle() val selectedModel by vm.selectedModel.collectAsStateWithLifecycle() val connectionState by vm.connectionState.collectAsStateWithLifecycle() val pendingPermission by vm.pendingPermission.collectAsStateWithLifecycle() val agents by vm.agents.collectAsStateWithLifecycle() val selectedAgent by vm.selectedAgent.collectAsStateWithLifecycle() val commands by vm.commands.collectAsStateWithLifecycle() val listState = rememberLazyListState() val snackbar = remember { SnackbarHostState() } var input by remember { mutableStateOf("") } var showModelPicker by remember { mutableStateOf(false) } var showAgentPicker by remember { mutableStateOf(false) } var showCommandPicker by remember { mutableStateOf(false) } // Keep the newest content in view as parts stream in. LaunchedEffect(messages.size, messages.lastOrNull()?.parts?.size) { if (messages.isNotEmpty()) listState.animateScrollToItem(messages.lastIndex) } LaunchedEffect(error) { error?.let { snackbar.showSnackbar(it) vm.clearError() } } Scaffold( topBar = { TopAppBar( title = { Column(modifier = Modifier.clickable(enabled = models.isNotEmpty()) { showModelPicker = true }) { Text("Session") Text( buildString { append(selectedModel?.modelLabel ?: "default model") selectedAgent?.let { append(" • $it") } }, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") } }, actions = { IconButton(onClick = { showCommandPicker = true }, enabled = commands.isNotEmpty()) { Icon(Icons.Filled.Terminal, contentDescription = "Commands") } IconButton(onClick = { showAgentPicker = true }, enabled = agents.isNotEmpty()) { Icon(Icons.Filled.SmartToy, contentDescription = "Choose agent") } IconButton(onClick = { showModelPicker = true }, enabled = models.isNotEmpty()) { Icon(Icons.Filled.Tune, contentDescription = "Choose model") } }, ) }, snackbarHost = { SnackbarHost(snackbar) }, bottomBar = { ChatInputBar( value = input, onValueChange = { input = it }, running = running, enabled = vm.connected, onSend = { vm.send(input) input = "" }, onAbort = { vm.abort() }, ) }, ) { padding -> Box(modifier = Modifier.fillMaxSize().padding(padding)) { if (!vm.connected) { Text( "Not connected.", modifier = Modifier.align(Alignment.Center).padding(24.dp), color = MaterialTheme.colorScheme.error, ) } else { ConnectionBanner( state = connectionState, modifier = Modifier.align(Alignment.TopCenter), ) LazyColumn( state = listState, modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp), ) { items(messages, key = { it.info.id }) { message -> MessageBubble(message) } if (running) { item(key = "__typing") { Row(verticalAlignment = Alignment.CenterVertically) { CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp) Text(" working…", style = MaterialTheme.typography.labelMedium) } } } } } } } if (showModelPicker) { ModelPickerSheet( options = models, selected = selectedModel, onSelect = { vm.selectModel(it) }, onDismiss = { showModelPicker = false }, ) } if (showAgentPicker) { AgentPickerSheet( agents = agents, selected = selectedAgent, onSelect = { vm.selectAgent(it?.name) }, onDismiss = { showAgentPicker = false }, ) } if (showCommandPicker) { CommandPickerSheet( commands = commands, onSelect = { vm.runCommand(it) }, onDismiss = { showCommandPicker = false }, ) } pendingPermission?.let { permission -> PermissionDialog( permission = permission, onRespond = { response -> vm.respondPermission(permission, response) }, ) } } @Composable private fun ConnectionBanner( state: soy.iko.opencode.data.network.EventStreamClient.ConnectionState, modifier: Modifier = Modifier, ) { val text = when (state) { soy.iko.opencode.data.network.EventStreamClient.ConnectionState.Connecting -> "Connecting…" soy.iko.opencode.data.network.EventStreamClient.ConnectionState.Disconnected -> "Reconnecting…" soy.iko.opencode.data.network.EventStreamClient.ConnectionState.Connected -> null } if (text != null) { Surface( modifier = modifier.fillMaxWidth(), color = MaterialTheme.colorScheme.tertiaryContainer, ) { Row( modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, ) { CircularProgressIndicator(Modifier.size(12.dp), strokeWidth = 2.dp) Text( " $text", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onTertiaryContainer, ) } } } } @Composable private fun ChatInputBar( value: String, onValueChange: (String) -> Unit, running: Boolean, enabled: Boolean, onSend: () -> Unit, onAbort: () -> Unit, ) { Surface(tonalElevation = 3.dp) { Row( modifier = Modifier.fillMaxWidth().padding(8.dp), verticalAlignment = Alignment.Bottom, ) { OutlinedTextField( value = value, onValueChange = onValueChange, modifier = Modifier.weight(1f), placeholder = { Text("Message opencode…") }, enabled = enabled, maxLines = 6, ) if (running) { IconButton(onClick = onAbort, modifier = Modifier.padding(start = 4.dp)) { Icon(Icons.Filled.Stop, contentDescription = "Stop") } } else { IconButton( onClick = onSend, enabled = enabled && value.isNotBlank(), modifier = Modifier.padding(start = 4.dp), ) { Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Send") } } } } }