git.iko.soy/opencode-android
on 066cded help sign in
259 lines 11 kB history raw
1package soy.iko.opencode.ui.session
3import androidx.compose.foundation.horizontalScroll
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.rememberScrollState
11import androidx.compose.foundation.selection.selectable
12import androidx.compose.foundation.text.KeyboardActions
13import androidx.compose.foundation.text.KeyboardOptions
14import androidx.compose.foundation.verticalScroll
15import androidx.compose.material3.AlertDialog
16import androidx.compose.material3.CircularProgressIndicator
17import androidx.compose.material3.MaterialTheme
18import androidx.compose.material3.OutlinedTextField
19import androidx.compose.material3.RadioButton
20import androidx.compose.material3.Text
21import androidx.compose.material3.TextButton
22import androidx.compose.runtime.Composable
23import androidx.compose.runtime.LaunchedEffect
24import androidx.compose.runtime.getValue
25import androidx.compose.runtime.mutableStateOf
26import androidx.compose.runtime.remember
27import androidx.compose.runtime.saveable.Saver
28import androidx.compose.runtime.saveable.rememberSaveable
29import androidx.compose.runtime.setValue
30import androidx.compose.ui.Alignment
31import androidx.compose.ui.Modifier
32import androidx.compose.ui.focus.FocusRequester
33import androidx.compose.ui.focus.focusRequester
34import androidx.compose.ui.semantics.Role
35import androidx.compose.ui.text.font.FontFamily
36import androidx.compose.ui.text.input.ImeAction
37import androidx.compose.ui.text.style.TextOverflow
38import androidx.compose.ui.unit.dp
39import androidx.compose.ui.res.stringResource
40import soy.iko.opencode.R
42/** The user's directory choice in the new-session picker. */
43private sealed interface DirChoice {
44 /** Create with no directory override — the server uses its launch cwd. */
45 object ServerDefault : DirChoice
47 /** A known worktree / recent-session directory. */
48 data class Known(val path: String) : DirChoice
50 /** A path the user types by hand. */
51 object Custom : DirChoice
54// Persist the radio selection across configuration changes / process death as a short
55// string tag ("d" = server default, "c" = custom, "k:<path>" = a known directory).
56private val dirChoiceSaver = Saver<DirChoice, String>(
57 save = {
58 when (it) {
59 is DirChoice.ServerDefault -> "d"
60 is DirChoice.Custom -> "c"
61 is DirChoice.Known -> "k:${it.path}"
62 }
63 },
64 restore = {
65 when {
66 it == "c" -> DirChoice.Custom
67 it.startsWith("k:") -> DirChoice.Known(it.removePrefix("k:"))
68 else -> DirChoice.ServerDefault
69 }
70 },
73/**
74 * Dialog shown when creating a new session: lets the user pick the working directory the
75 * agent will run in. Offers the server's default (cwd), the directories the server knows
76 * about ([options].projects) plus any directories of existing [sessionDirectories], and a
77 * free-text path. Sends the resolved path (or null for the server default) to [onCreate].
78 *
79 * [lastChosenDirectory] preselects the directory used for the previous new session.
80 */
81@Composable
82fun NewSessionDialog(
83 options: DirectoryOptionsState,
84 sessionDirectories: List<String>,
85 lastChosenDirectory: String?,
86 creating: Boolean,
87 onCreate: (String?) -> Unit,
88 onDismiss: () -> Unit,
89) {
90 // Directories to list as selectable rows: known projects + directories of existing
91 // sessions + the last chosen one, minus the server default (which has its own row).
92 val knownDirs = remember(options.projects, sessionDirectories, lastChosenDirectory, options.serverDefault) {
93 (options.projects + sessionDirectories + listOfNotNull(lastChosenDirectory))
94 .filter { it.isNotBlank() && it != options.serverDefault }
95 .distinct()
96 }
98 // Preselect the last chosen directory (or the most-recent session's directory); both are
99 // included in knownDirs above, so a non-null, non-default preselect always maps to a row.
100 var choice by rememberSaveable(stateSaver = dirChoiceSaver) {
101 val preselect = lastChosenDirectory ?: sessionDirectories.firstOrNull { it.isNotBlank() }
102 mutableStateOf(
103 if (preselect == null || preselect == options.serverDefault) DirChoice.ServerDefault
104 else DirChoice.Known(preselect),
105 )
106 }
107 var customText by rememberSaveable { mutableStateOf("") }
109 // The initializer above may have run while options were still loading (serverDefault
110 // null), pinning choice to DirChoice.Known(preselect). Once the real serverDefault
111 // arrives, knownDirs filters that path out (it has its own row), so no DirectoryRow
112 // matches a Known(serverDefault) choice and every radio shows unselected. Collapse it
113 // into ServerDefault so the selected radio always tracks a visible row.
114 LaunchedEffect(options.serverDefault) {
115 val current = choice
116 if (current is DirChoice.Known && current.path == options.serverDefault) {
117 choice = DirChoice.ServerDefault
118 }
119 }
121 val resolved: String? = when (val c = choice) {
122 is DirChoice.ServerDefault -> null
123 is DirChoice.Known -> c.path
124 is DirChoice.Custom -> customText.trim().takeIf { it.isNotBlank() }
125 }
126 // Custom requires a non-blank path; the other choices are always valid.
127 val canCreate = !creating && (choice !is DirChoice.Custom || customText.isNotBlank())
129 AlertDialog(
130 onDismissRequest = { if (!creating) onDismiss() },
131 title = { Text(stringResource(R.string.new_session)) },
132 text = {
133 Column(
134 modifier = Modifier
135 .fillMaxWidth()
136 .heightIn(max = 380.dp)
137 .verticalScroll(rememberScrollState()),
138 ) {
139 Text(
140 stringResource(R.string.directory_label),
141 style = MaterialTheme.typography.labelLarge,
142 color = MaterialTheme.colorScheme.onSurfaceVariant,
143 modifier = Modifier.padding(bottom = 4.dp),
144 )
146 DirectoryRow(
147 label = stringResource(R.string.directory_server_default),
148 sublabel = options.serverDefault,
149 selected = choice is DirChoice.ServerDefault,
150 enabled = !creating,
151 onSelect = { choice = DirChoice.ServerDefault },
152 )
154 knownDirs.forEach { path ->
155 DirectoryRow(
156 label = path.trimEnd('/').substringAfterLast('/').ifBlank { path },
157 sublabel = path,
158 selected = (choice as? DirChoice.Known)?.path == path,
159 enabled = !creating,
160 onSelect = { choice = DirChoice.Known(path) },
161 )
162 }
164 DirectoryRow(
165 label = stringResource(R.string.directory_custom),
166 sublabel = null,
167 selected = choice is DirChoice.Custom,
168 enabled = !creating,
169 onSelect = { choice = DirChoice.Custom },
170 )
171 if (choice is DirChoice.Custom) {
172 // Autofocus the path field the moment the Custom row is chosen so the
173 // keyboard is ready without a second tap.
174 val customFocus = remember { FocusRequester() }
175 LaunchedEffect(Unit) { customFocus.requestFocus() }
176 OutlinedTextField(
177 value = customText,
178 onValueChange = { customText = it },
179 modifier = Modifier
180 .fillMaxWidth()
181 .focusRequester(customFocus)
182 .padding(start = 36.dp, top = 4.dp, bottom = 4.dp),
183 placeholder = { Text(stringResource(R.string.directory_custom_hint)) },
184 singleLine = true,
185 enabled = !creating,
186 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
187 keyboardActions = KeyboardActions(onDone = { if (canCreate) onCreate(resolved) }),
188 )
189 }
191 if (options.loading && !options.loaded) {
192 Row(
193 modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
194 verticalAlignment = Alignment.CenterVertically,
195 ) {
196 CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
197 Text(
198 stringResource(R.string.directory_loading),
199 style = MaterialTheme.typography.bodySmall,
200 color = MaterialTheme.colorScheme.onSurfaceVariant,
201 modifier = Modifier.padding(start = 8.dp),
202 )
203 }
204 }
205 }
206 },
207 confirmButton = {
208 TextButton(onClick = { onCreate(resolved) }, enabled = canCreate) {
209 if (creating) {
210 CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
211 } else {
212 Text(stringResource(R.string.create))
213 }
214 }
215 },
216 dismissButton = {
217 TextButton(onClick = onDismiss, enabled = !creating) { Text(stringResource(R.string.cancel)) }
218 },
219 )
222@Composable
223private fun DirectoryRow(
224 label: String,
225 sublabel: String?,
226 selected: Boolean,
227 enabled: Boolean,
228 onSelect: () -> Unit,
229) {
230 Row(
231 modifier = Modifier
232 .fillMaxWidth()
233 .selectable(selected = selected, enabled = enabled, role = Role.RadioButton, onClick = onSelect)
234 .padding(vertical = 4.dp),
235 verticalAlignment = Alignment.CenterVertically,
236 ) {
237 RadioButton(selected = selected, onClick = null, enabled = enabled)
238 Column(modifier = Modifier.padding(start = 12.dp)) {
239 Text(
240 label,
241 style = MaterialTheme.typography.bodyLarge,
242 maxLines = 1,
243 overflow = TextOverflow.Ellipsis,
244 )
245 if (!sublabel.isNullOrBlank() && sublabel != label) {
246 // Full path can be long; let it scroll horizontally rather than truncate so
247 // the user can verify exactly which directory they're selecting.
248 Text(
249 sublabel,
250 style = MaterialTheme.typography.bodySmall,
251 color = MaterialTheme.colorScheme.onSurfaceVariant,
252 fontFamily = FontFamily.Monospace,
253 maxLines = 1,
254 modifier = Modifier.horizontalScroll(rememberScrollState()),
255 )
256 }
257 }
258 }