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