git.iko.soy/opencode-android
on 8865a77 help sign in
271 lines 11 kB history raw
1package soy.iko.opencode.ui.chat
3import androidx.compose.foundation.clickable
4import androidx.compose.foundation.layout.Arrangement
5import androidx.compose.foundation.layout.Box
6import androidx.compose.foundation.layout.Column
7import androidx.compose.foundation.layout.PaddingValues
8import androidx.compose.foundation.layout.Row
9import androidx.compose.foundation.layout.fillMaxSize
10import androidx.compose.foundation.layout.fillMaxWidth
11import androidx.compose.foundation.layout.padding
12import androidx.compose.foundation.layout.size
13import androidx.compose.foundation.lazy.LazyColumn
14import androidx.compose.foundation.lazy.items
15import androidx.compose.foundation.lazy.rememberLazyListState
16import androidx.compose.material.icons.Icons
17import androidx.compose.material.icons.automirrored.filled.ArrowBack
18import androidx.compose.material.icons.automirrored.filled.Send
19import androidx.compose.material.icons.filled.SmartToy
20import androidx.compose.material.icons.filled.Stop
21import androidx.compose.material.icons.filled.Terminal
22import androidx.compose.material.icons.filled.Tune
23import androidx.compose.material3.CircularProgressIndicator
24import androidx.compose.material3.ExperimentalMaterial3Api
25import androidx.compose.material3.Icon
26import androidx.compose.material3.IconButton
27import androidx.compose.material3.MaterialTheme
28import androidx.compose.material3.OutlinedTextField
29import androidx.compose.material3.Scaffold
30import androidx.compose.material3.SnackbarHost
31import androidx.compose.material3.SnackbarHostState
32import androidx.compose.material3.Surface
33import androidx.compose.material3.Text
34import androidx.compose.material3.TopAppBar
35import androidx.compose.runtime.Composable
36import androidx.compose.runtime.LaunchedEffect
37import androidx.compose.runtime.getValue
38import androidx.compose.runtime.mutableStateOf
39import androidx.compose.runtime.remember
40import androidx.compose.runtime.setValue
41import androidx.compose.ui.Alignment
42import androidx.compose.ui.Modifier
43import androidx.compose.ui.unit.dp
44import androidx.lifecycle.compose.collectAsStateWithLifecycle
45import androidx.lifecycle.viewmodel.compose.viewModel
46import soy.iko.opencode.di.AppContainer
47import soy.iko.opencode.ui.vmFactory
49@OptIn(ExperimentalMaterial3Api::class)
50@Composable
51fun ChatScreen(
52 container: AppContainer,
53 sessionId: String,
54 onBack: () -> Unit,
55) {
56 val vm: ChatViewModel = viewModel(factory = vmFactory { ChatViewModel(container, sessionId) })
57 val messages by vm.messages.collectAsStateWithLifecycle()
58 val running by vm.running.collectAsStateWithLifecycle()
59 val error by vm.error.collectAsStateWithLifecycle()
60 val models by vm.models.collectAsStateWithLifecycle()
61 val selectedModel by vm.selectedModel.collectAsStateWithLifecycle()
62 val connectionState by vm.connectionState.collectAsStateWithLifecycle()
63 val pendingPermission by vm.pendingPermission.collectAsStateWithLifecycle()
64 val agents by vm.agents.collectAsStateWithLifecycle()
65 val selectedAgent by vm.selectedAgent.collectAsStateWithLifecycle()
66 val commands by vm.commands.collectAsStateWithLifecycle()
68 val listState = rememberLazyListState()
69 val snackbar = remember { SnackbarHostState() }
70 var input by remember { mutableStateOf("") }
71 var showModelPicker by remember { mutableStateOf(false) }
72 var showAgentPicker by remember { mutableStateOf(false) }
73 var showCommandPicker by remember { mutableStateOf(false) }
75 // Keep the newest content in view as parts stream in.
76 LaunchedEffect(messages.size, messages.lastOrNull()?.parts?.size) {
77 if (messages.isNotEmpty()) listState.animateScrollToItem(messages.lastIndex)
78 }
79 LaunchedEffect(error) {
80 error?.let {
81 snackbar.showSnackbar(it)
82 vm.clearError()
83 }
84 }
86 Scaffold(
87 topBar = {
88 TopAppBar(
89 title = {
90 Column(modifier = Modifier.clickable(enabled = models.isNotEmpty()) { showModelPicker = true }) {
91 Text("Session")
92 Text(
93 buildString {
94 append(selectedModel?.modelLabel ?: "default model")
95 selectedAgent?.let { append(" • $it") }
96 },
97 style = MaterialTheme.typography.labelSmall,
98 color = MaterialTheme.colorScheme.onSurfaceVariant,
99 )
100 }
101 },
102 navigationIcon = {
103 IconButton(onClick = onBack) {
104 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
105 }
106 },
107 actions = {
108 IconButton(onClick = { showCommandPicker = true }, enabled = commands.isNotEmpty()) {
109 Icon(Icons.Filled.Terminal, contentDescription = "Commands")
110 }
111 IconButton(onClick = { showAgentPicker = true }, enabled = agents.isNotEmpty()) {
112 Icon(Icons.Filled.SmartToy, contentDescription = "Choose agent")
113 }
114 IconButton(onClick = { showModelPicker = true }, enabled = models.isNotEmpty()) {
115 Icon(Icons.Filled.Tune, contentDescription = "Choose model")
116 }
117 },
118 )
119 },
120 snackbarHost = { SnackbarHost(snackbar) },
121 bottomBar = {
122 ChatInputBar(
123 value = input,
124 onValueChange = { input = it },
125 running = running,
126 enabled = vm.connected,
127 onSend = {
128 vm.send(input)
129 input = ""
130 },
131 onAbort = { vm.abort() },
132 )
133 },
134 ) { padding ->
135 Box(modifier = Modifier.fillMaxSize().padding(padding)) {
136 if (!vm.connected) {
137 Text(
138 "Not connected.",
139 modifier = Modifier.align(Alignment.Center).padding(24.dp),
140 color = MaterialTheme.colorScheme.error,
141 )
142 } else {
143 ConnectionBanner(
144 state = connectionState,
145 modifier = Modifier.align(Alignment.TopCenter),
146 )
147 LazyColumn(
148 state = listState,
149 modifier = Modifier.fillMaxSize(),
150 contentPadding = PaddingValues(16.dp),
151 verticalArrangement = Arrangement.spacedBy(16.dp),
152 ) {
153 items(messages, key = { it.info.id }) { message ->
154 MessageBubble(message)
155 }
156 if (running) {
157 item(key = "__typing") {
158 Row(verticalAlignment = Alignment.CenterVertically) {
159 CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
160 Text(" working…", style = MaterialTheme.typography.labelMedium)
161 }
162 }
163 }
164 }
165 }
166 }
167 }
169 if (showModelPicker) {
170 ModelPickerSheet(
171 options = models,
172 selected = selectedModel,
173 onSelect = { vm.selectModel(it) },
174 onDismiss = { showModelPicker = false },
175 )
176 }
178 if (showAgentPicker) {
179 AgentPickerSheet(
180 agents = agents,
181 selected = selectedAgent,
182 onSelect = { vm.selectAgent(it?.name) },
183 onDismiss = { showAgentPicker = false },
184 )
185 }
187 if (showCommandPicker) {
188 CommandPickerSheet(
189 commands = commands,
190 onSelect = { vm.runCommand(it) },
191 onDismiss = { showCommandPicker = false },
192 )
193 }
195 pendingPermission?.let { permission ->
196 PermissionDialog(
197 permission = permission,
198 onRespond = { response -> vm.respondPermission(permission, response) },
199 )
200 }
203@Composable
204private fun ConnectionBanner(
205 state: soy.iko.opencode.data.network.EventStreamClient.ConnectionState,
206 modifier: Modifier = Modifier,
207) {
208 val text = when (state) {
209 soy.iko.opencode.data.network.EventStreamClient.ConnectionState.Connecting -> "Connecting…"
210 soy.iko.opencode.data.network.EventStreamClient.ConnectionState.Disconnected -> "Reconnecting…"
211 soy.iko.opencode.data.network.EventStreamClient.ConnectionState.Connected -> null
212 }
213 if (text != null) {
214 Surface(
215 modifier = modifier.fillMaxWidth(),
216 color = MaterialTheme.colorScheme.tertiaryContainer,
217 ) {
218 Row(
219 modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
220 horizontalArrangement = Arrangement.Center,
221 verticalAlignment = Alignment.CenterVertically,
222 ) {
223 CircularProgressIndicator(Modifier.size(12.dp), strokeWidth = 2.dp)
224 Text(
225 " $text",
226 style = MaterialTheme.typography.labelMedium,
227 color = MaterialTheme.colorScheme.onTertiaryContainer,
228 )
229 }
230 }
231 }
234@Composable
235private fun ChatInputBar(
236 value: String,
237 onValueChange: (String) -> Unit,
238 running: Boolean,
239 enabled: Boolean,
240 onSend: () -> Unit,
241 onAbort: () -> Unit,
242) {
243 Surface(tonalElevation = 3.dp) {
244 Row(
245 modifier = Modifier.fillMaxWidth().padding(8.dp),
246 verticalAlignment = Alignment.Bottom,
247 ) {
248 OutlinedTextField(
249 value = value,
250 onValueChange = onValueChange,
251 modifier = Modifier.weight(1f),
252 placeholder = { Text("Message opencode…") },
253 enabled = enabled,
254 maxLines = 6,
255 )
256 if (running) {
257 IconButton(onClick = onAbort, modifier = Modifier.padding(start = 4.dp)) {
258 Icon(Icons.Filled.Stop, contentDescription = "Stop")
259 }
260 } else {
261 IconButton(
262 onClick = onSend,
263 enabled = enabled && value.isNotBlank(),
264 modifier = Modifier.padding(start = 4.dp),
265 ) {
266 Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Send")
267 }
268 }
269 }
270 }