git.iko.soy/opencode-android
on 06fd41b help sign in
275 lines 14 kB history raw
1package soy.iko.opencode.ui.chat
3import androidx.compose.foundation.clickable
4import androidx.compose.foundation.layout.Column
5import androidx.compose.foundation.layout.Row
6import androidx.compose.foundation.layout.fillMaxWidth
7import androidx.compose.foundation.layout.heightIn
8import androidx.compose.foundation.layout.padding
9import androidx.compose.foundation.layout.size
10import androidx.compose.foundation.lazy.LazyColumn
11import androidx.compose.foundation.lazy.items
12import androidx.compose.foundation.lazy.rememberLazyListState
13import androidx.compose.foundation.text.KeyboardActions
14import androidx.compose.foundation.text.KeyboardOptions
15import androidx.compose.material.icons.Icons
16import androidx.compose.material.icons.filled.Check
17import androidx.compose.material.icons.filled.Close
18import androidx.compose.material.icons.filled.Search
19import androidx.compose.material3.CircularProgressIndicator
20import androidx.compose.material3.ExperimentalMaterial3Api
21import androidx.compose.material3.Icon
22import androidx.compose.material3.IconButton
23import androidx.compose.material3.MaterialTheme
24import androidx.compose.material3.ModalBottomSheet
25import androidx.compose.material3.OutlinedTextField
26import androidx.compose.material3.Text
27import androidx.compose.material3.TextButton
28import androidx.compose.runtime.Composable
29import androidx.compose.runtime.LaunchedEffect
30import androidx.compose.runtime.getValue
31import androidx.compose.runtime.mutableStateOf
32import androidx.compose.runtime.remember
33import androidx.compose.runtime.saveable.rememberSaveable
34import androidx.compose.runtime.setValue
35import androidx.compose.ui.Alignment
36import androidx.compose.ui.Modifier
37import androidx.compose.ui.focus.FocusRequester
38import androidx.compose.ui.focus.focusRequester
39import androidx.compose.ui.hapticfeedback.HapticFeedbackType
40import androidx.compose.ui.platform.LocalHapticFeedback
41import androidx.compose.ui.platform.LocalSoftwareKeyboardController
42import androidx.compose.ui.res.stringResource
43import androidx.compose.ui.semantics.Role
44import androidx.compose.ui.semantics.contentDescription
45import androidx.compose.ui.semantics.role
46import androidx.compose.ui.semantics.selected
47import androidx.compose.ui.semantics.semantics
48import androidx.compose.ui.text.input.ImeAction
49import androidx.compose.ui.unit.dp
50import androidx.compose.foundation.layout.Box
51import androidx.compose.foundation.layout.fillMaxHeight
52import soy.iko.opencode.data.model.Agent
53import soy.iko.opencode.data.network.NetworkConfig
54import soy.iko.opencode.R
56/**
57 * Flat LazyColumn index of the selected agent, offset by the leading "Default" row when it's
58 * shown. A null selection targets that Default row (index 0). -1 when the selection isn't in
59 * the current list.
60 */
61/** Whether the synthetic "Default" agent row matches the current search query. */
62private fun defaultAgentMatches(query: String, label: String, desc: String): Boolean =
63 query.isEmpty() ||
64 label.contains(query, ignoreCase = true) ||
65 desc.contains(query, ignoreCase = true)
67private fun selectedAgentIndex(
68 distinctAgents: List<Agent>,
69 selected: String?,
70 defaultMatches: Boolean,
71): Int {
72 if (selected == null) return if (defaultMatches) 0 else -1
73 val base = if (defaultMatches) 1 else 0
74 val pos = distinctAgents.indexOfFirst { it.name == selected }
75 return if (pos >= 0) base + pos else -1
78/** Bottom sheet that lists every agent and lets the user pick one. */
79@OptIn(ExperimentalMaterial3Api::class)
80@Composable
81fun AgentPickerSheet(
82 agents: List<Agent>,
83 selected: String?,
84 loading: Boolean,
85 error: Boolean,
86 onSelect: (Agent?) -> Unit,
87 onRetry: () -> Unit,
88 onDismiss: () -> Unit,
89) {
90 ModalBottomSheet(onDismissRequest = onDismiss) {
91 Text(
92 stringResource(R.string.agent),
93 style = MaterialTheme.typography.titleMedium,
94 modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
95 )
96 if (loading) {
97 val loadingLabel = stringResource(R.string.loading)
98 Box(
99 modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
100 contentAlignment = Alignment.Center,
101 ) {
102 CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
103 }
104 } else if (error && agents.isEmpty()) {
105 Column(
106 modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp),
107 horizontalAlignment = Alignment.CenterHorizontally,
108 ) {
109 Text(
110 stringResource(R.string.load_failed),
111 style = MaterialTheme.typography.bodyMedium,
112 color = MaterialTheme.colorScheme.error,
113 )
114 TextButton(onClick = onRetry) { Text(stringResource(R.string.retry)) }
115 }
116 } else if (agents.isEmpty()) {
117 Text(
118 stringResource(R.string.no_agents),
119 style = MaterialTheme.typography.bodyMedium,
120 color = MaterialTheme.colorScheme.onSurfaceVariant,
121 modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
122 )
123 } else {
124 var query by rememberSaveable { mutableStateOf("") }
125 val keyboardController = LocalSoftwareKeyboardController.current
126 val haptics = LocalHapticFeedback.current
127 // Auto-focus the search field (and raise the keyboard) when the sheet opens, but skip
128 // it for a short catalog that's faster to scan by eye than to filter.
129 val searchFocus = remember { FocusRequester() }
130 LaunchedEffect(Unit) {
131 if (agents.size > NetworkConfig.pickerSearchAutofocusThreshold) {
132 runCatching { searchFocus.requestFocus() }
133 }
134 }
135 OutlinedTextField(
136 value = query,
137 onValueChange = { query = it },
138 modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 8.dp).focusRequester(searchFocus),
139 placeholder = { Text(stringResource(R.string.search_agents)) },
140 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
141 trailingIcon = if (query.isNotEmpty()) {
142 {
143 IconButton(onClick = { query = "" }) {
144 Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.clear_search))
145 }
146 }
147 } else null,
148 singleLine = true,
149 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
150 keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
151 )
152 val filtered = remember(agents, query) {
153 val q = query.trim()
154 if (q.isEmpty()) agents
155 else agents.filter {
156 it.name.contains(q, ignoreCase = true) ||
157 it.displayDescription.contains(q, ignoreCase = true)
158 }
159 }
160 // The "Default" option participates in the search too: show it when the query is
161 // empty or matches its label/description. Without this it either always showed
162 // (ignoring the filter) or, when a query matched no named agent, disappeared with
163 // the whole list — leaving the default unselectable.
164 val defaultLabel = stringResource(R.string.default_agent)
165 val defaultDesc = stringResource(R.string.default_agent_desc)
166 val q = query.trim()
167 val defaultMatches = defaultAgentMatches(q, defaultLabel, defaultDesc)
168 if (filtered.isEmpty() && !defaultMatches) {
169 Text(
170 stringResource(R.string.no_agents_match, query.trim()),
171 style = MaterialTheme.typography.bodyMedium,
172 color = MaterialTheme.colorScheme.onSurfaceVariant,
173 modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
174 )
175 } else {
176 val listState = rememberLazyListState()
177 val distinctAgents = remember(filtered) { filtered.distinctBy { it.name } }
178 // Flat index of the selected agent, offset by the leading "Default" row when
179 // it's shown. selected == null targets that Default row (index 0). -1 when the
180 // selection isn't in the current list.
181 val selectedIndex = remember(distinctAgents, selected, defaultMatches) {
182 selectedAgentIndex(distinctAgents, selected, defaultMatches)
183 }
184 LaunchedEffect(Unit) {
185 if (selectedIndex >= 0) listState.scrollToItem(selectedIndex)
186 }
187 // Responsive height: half the viewport rather than a hardcoded 420dp cap, so
188 // the sheet doesn't cramp in landscape or leave empty space on a tall phone.
189 LazyColumn(state = listState, modifier = Modifier.fillMaxHeight(0.5f)) {
190 if (defaultMatches) {
191 item(key = "__default") {
192 Column(
193 modifier = Modifier
194 .fillMaxWidth()
195 .semantics(mergeDescendants = true) {
196 if (selected == null) this.selected = true
197 }
198 .clickable(role = Role.RadioButton) {
199 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
200 onSelect(null); onDismiss()
201 }
202 .padding(horizontal = 24.dp, vertical = 12.dp),
203 ) {
204 Row(verticalAlignment = Alignment.CenterVertically) {
205 Text(
206 stringResource(R.string.default_agent),
207 style = MaterialTheme.typography.bodyLarge,
208 color = if (selected == null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
209 modifier = Modifier.weight(1f),
210 )
211 if (selected == null) {
212 Icon(
213 Icons.Filled.Check,
214 contentDescription = null,
215 tint = MaterialTheme.colorScheme.primary,
216 modifier = Modifier.size(18.dp),
217 )
218 }
219 }
220 Text(
221 stringResource(R.string.default_agent_desc),
222 style = MaterialTheme.typography.bodySmall,
223 color = MaterialTheme.colorScheme.onSurfaceVariant,
224 )
225 }
226 }
227 }
228 // distinctBy the name so a server that returns two agents with the same name
229 // (e.g. a built-in colliding with a project-level one) can't crash the list
230 // with LazyColumn's "Key was already used".
231 // Namespace the agent keys so an agent literally named "__default" can't
232 // collide with the sentinel key of the default item above (a duplicate key
233 // crashes LazyColumn).
234 items(distinctAgents, key = { "agent_" + it.name }) { agent ->
235 val isSelected = agent.name == selected
236 Column(
237 modifier = Modifier
238 .fillMaxWidth()
239 .semantics(mergeDescendants = true) {
240 if (isSelected) this.selected = true
241 }
242 .clickable(role = Role.RadioButton) {
243 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
244 onSelect(agent); onDismiss()
245 }
246 .padding(horizontal = 24.dp, vertical = 12.dp),
247 ) {
248 Row(verticalAlignment = Alignment.CenterVertically) {
249 Text(
250 agent.name,
251 style = MaterialTheme.typography.bodyLarge,
252 color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
253 modifier = Modifier.weight(1f),
254 )
255 if (isSelected) {
256 Icon(
257 Icons.Filled.Check,
258 contentDescription = null,
259 tint = MaterialTheme.colorScheme.primary,
260 modifier = Modifier.size(18.dp),
261 )
262 }
263 }
264 Text(
265 agent.displayDescription,
266 style = MaterialTheme.typography.bodySmall,
267 color = MaterialTheme.colorScheme.onSurfaceVariant,
268 )
269 }
270 }
271 }
272 }
273 }
274 }