git.iko.soy/opencode-android
on 8bc2cce help sign in
204 lines 9.9 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.text.KeyboardActions
13import androidx.compose.foundation.text.KeyboardOptions
14import androidx.compose.material.icons.Icons
15import androidx.compose.material.icons.filled.Check
16import androidx.compose.material.icons.filled.Search
17import androidx.compose.material3.CircularProgressIndicator
18import androidx.compose.material3.ExperimentalMaterial3Api
19import androidx.compose.material3.Icon
20import androidx.compose.material3.MaterialTheme
21import androidx.compose.material3.ModalBottomSheet
22import androidx.compose.material3.OutlinedTextField
23import androidx.compose.material3.Text
24import androidx.compose.material3.TextButton
25import androidx.compose.runtime.Composable
26import androidx.compose.runtime.getValue
27import androidx.compose.runtime.mutableStateOf
28import androidx.compose.runtime.remember
29import androidx.compose.runtime.saveable.rememberSaveable
30import androidx.compose.runtime.setValue
31import androidx.compose.ui.Alignment
32import androidx.compose.ui.Modifier
33import androidx.compose.ui.platform.LocalSoftwareKeyboardController
34import androidx.compose.ui.res.stringResource
35import androidx.compose.ui.semantics.Role
36import androidx.compose.ui.semantics.contentDescription
37import androidx.compose.ui.semantics.role
38import androidx.compose.ui.semantics.selected
39import androidx.compose.ui.semantics.semantics
40import androidx.compose.ui.text.input.ImeAction
41import androidx.compose.ui.unit.dp
42import androidx.compose.foundation.layout.Box
43import soy.iko.opencode.data.model.Agent
44import soy.iko.opencode.R
46/** Bottom sheet that lists every agent and lets the user pick one. */
47@OptIn(ExperimentalMaterial3Api::class)
48@Composable
49fun AgentPickerSheet(
50 agents: List<Agent>,
51 selected: String?,
52 loading: Boolean,
53 error: Boolean,
54 onSelect: (Agent?) -> Unit,
55 onRetry: () -> Unit,
56 onDismiss: () -> Unit,
57) {
58 ModalBottomSheet(onDismissRequest = onDismiss) {
59 Text(
60 stringResource(R.string.agent),
61 style = MaterialTheme.typography.titleMedium,
62 modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
63 )
64 if (loading) {
65 val loadingLabel = stringResource(R.string.loading)
66 Box(
67 modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
68 contentAlignment = Alignment.Center,
69 ) {
70 CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
71 }
72 } else if (error && agents.isEmpty()) {
73 Column(
74 modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp),
75 horizontalAlignment = Alignment.CenterHorizontally,
76 ) {
77 Text(
78 stringResource(R.string.load_failed),
79 style = MaterialTheme.typography.bodyMedium,
80 color = MaterialTheme.colorScheme.error,
81 )
82 TextButton(onClick = onRetry) { Text(stringResource(R.string.retry)) }
83 }
84 } else if (agents.isEmpty()) {
85 Text(
86 stringResource(R.string.no_agents),
87 style = MaterialTheme.typography.bodyMedium,
88 color = MaterialTheme.colorScheme.onSurfaceVariant,
89 modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
90 )
91 } else {
92 var query by rememberSaveable { mutableStateOf("") }
93 val keyboardController = LocalSoftwareKeyboardController.current
94 OutlinedTextField(
95 value = query,
96 onValueChange = { query = it },
97 modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 8.dp),
98 placeholder = { Text(stringResource(R.string.search_agents)) },
99 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
100 singleLine = true,
101 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
102 keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
103 )
104 val filtered = remember(agents, query) {
105 val q = query.trim()
106 if (q.isEmpty()) agents
107 else agents.filter {
108 it.name.contains(q, ignoreCase = true) ||
109 it.displayDescription.contains(q, ignoreCase = true)
110 }
111 }
112 // The "Default" option participates in the search too: show it when the query is
113 // empty or matches its label/description. Without this it either always showed
114 // (ignoring the filter) or, when a query matched no named agent, disappeared with
115 // the whole list — leaving the default unselectable.
116 val defaultLabel = stringResource(R.string.default_agent)
117 val defaultDesc = stringResource(R.string.default_agent_desc)
118 val q = query.trim()
119 val defaultMatches = q.isEmpty() ||
120 defaultLabel.contains(q, ignoreCase = true) ||
121 defaultDesc.contains(q, ignoreCase = true)
122 if (filtered.isEmpty() && !defaultMatches) {
123 Text(
124 stringResource(R.string.no_agents_match, query.trim()),
125 style = MaterialTheme.typography.bodyMedium,
126 color = MaterialTheme.colorScheme.onSurfaceVariant,
127 modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
128 )
129 } else {
130 LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
131 if (defaultMatches) {
132 item(key = "__default") {
133 Column(
134 modifier = Modifier
135 .fillMaxWidth()
136 .semantics(mergeDescendants = true) {
137 if (selected == null) this.selected = true
138 }
139 .clickable(role = Role.RadioButton) { onSelect(null); onDismiss() }
140 .padding(horizontal = 24.dp, vertical = 12.dp),
141 ) {
142 Row(verticalAlignment = Alignment.CenterVertically) {
143 Text(
144 stringResource(R.string.default_agent),
145 style = MaterialTheme.typography.bodyLarge,
146 color = if (selected == null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
147 modifier = Modifier.weight(1f),
148 )
149 if (selected == null) {
150 Icon(
151 Icons.Filled.Check,
152 contentDescription = null,
153 tint = MaterialTheme.colorScheme.primary,
154 modifier = Modifier.size(18.dp),
155 )
156 }
157 }
158 Text(
159 stringResource(R.string.default_agent_desc),
160 style = MaterialTheme.typography.bodySmall,
161 color = MaterialTheme.colorScheme.onSurfaceVariant,
162 )
163 }
164 }
165 }
166 items(filtered, key = { it.name }) { agent ->
167 val isSelected = agent.name == selected
168 Column(
169 modifier = Modifier
170 .fillMaxWidth()
171 .semantics(mergeDescendants = true) {
172 if (isSelected) this.selected = true
173 }
174 .clickable(role = Role.RadioButton) { onSelect(agent); onDismiss() }
175 .padding(horizontal = 24.dp, vertical = 12.dp),
176 ) {
177 Row(verticalAlignment = Alignment.CenterVertically) {
178 Text(
179 agent.name,
180 style = MaterialTheme.typography.bodyLarge,
181 color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
182 modifier = Modifier.weight(1f),
183 )
184 if (isSelected) {
185 Icon(
186 Icons.Filled.Check,
187 contentDescription = null,
188 tint = MaterialTheme.colorScheme.primary,
189 modifier = Modifier.size(18.dp),
190 )
191 }
192 }
193 Text(
194 agent.displayDescription,
195 style = MaterialTheme.typography.bodySmall,
196 color = MaterialTheme.colorScheme.onSurfaceVariant,
197 )
198 }
199 }
200 }
201 }
202 }
203 }