git.iko.soy/opencode-android
on master help sign in
Add per-session directory selection for agent runs
commitea52e62818dc10359739e0f22c1dfd8e6b567b72 authoriko <mail@iko.soy> dateThu 2 Jul 2026, 06:16 (3 months ago) parentbc10148
13 files changed+534−10
app/build.gradle.kts+4−1 app/detekt-baseline.xml+1 app/src/main/java/soy/iko/opencode/data/model/Project.kt+32 app/src/main/java/soy/iko/opencode/data/model/Session.kt+10 app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.kt+23−1 app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+2−1 app/src/main/java/soy/iko/opencode/ui/session/NewSessionDialog.kt+237 app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+58−2 app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+58−3 app/src/main/res/values/strings.xml+7 app/src/test/java/soy/iko/opencode/M5DecodingTest.kt+37 app/src/test/java/soy/iko/opencode/ui/session/SessionListViewModelTest.kt+46 app/src/test/java/soy/iko/opencode/ui/testing/TestFakes.kt+19−2
app/build.gradle.kts+4−1
@@ -81,7 +81,10 @@ android {
8181 // minor/style issues don't gate development.
8282 abortOnError = true
8383 // Dependency upgrades are tracked by Renovate, so don't lint for them here.
84− disable += setOf("GradleDependency", "AndroidGradlePluginVersion")
84+ // ProduceStateDoesNotAssignValue false-positives on valid produceState calls that
85+ // assign `value` via a conditional/`withContext` expression (see FileViewScreen's
86+ // match-index scan), so it's disabled rather than worked around at each call site.
87+ disable += setOf("GradleDependency", "AndroidGradlePluginVersion", "ProduceStateDoesNotAssignValue")
8588 }
8689
8790 packaging {
app/detekt-baseline.xml+1
@@ -3,6 +3,7 @@
33 <ManuallySuppressedIssues/>
44 <CurrentIssues>
55 <ID>ComplexCondition:PartRenderer.kt$part.isImage &amp;&amp; imageContext != null &amp;&amp; (part.source != null || !part.url.isNullOrBlank())</ID>
6+ <ID>CyclomaticComplexMethod:AppContainer.kt$AppContainer$@OptIn(ExperimentalCoroutinesApi::class) private fun observeMessageActivity()</ID>
67 <ID>CyclomaticComplexMethod:ChatScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ChatScreen( container: AppContainer, sessionId: String, onBack: () -&gt; Unit, onOpenFile: ((String) -&gt; Unit)? = null, )</ID>
78 <ID>CyclomaticComplexMethod:CodeHighlighter.kt$private fun findNumberEnd(line: String, start: Int): Int</ID>
89 <ID>MatchingDeclarationName:DiffView.kt$DiffLine</ID>
app/src/main/java/soy/iko/opencode/data/model/Project.ktnew file+32
@@ -0,0 +1,32 @@
1+package soy.iko.opencode.data.model
2+
3+import androidx.compose.runtime.Immutable
4+import kotlinx.serialization.Serializable
5+
6+/**
7+ * A project known to the server, from `GET /project`. A project corresponds to a git
8+ * worktree root; [worktree] is the absolute path the agent can run in. Used to populate
9+ * the directory picker when creating a session in a specific directory.
10+ */
11+@Immutable
12+@Serializable
13+data class Project(
14+ val id: String = "",
15+ val worktree: String = "",
16+ val vcs: String? = null,
17+ val time: TimeInfo? = null,
18+)
19+
20+/**
21+ * The server's current path info, from `GET /path`. [directory] is the working directory
22+ * a request with no `directory` override resolves to (the server's launch cwd), shown in
23+ * the picker as the "server default" option.
24+ */
25+@Immutable
26+@Serializable
27+data class PathInfo(
28+ val directory: String? = null,
29+ val worktree: String? = null,
30+ val state: String? = null,
31+ val config: String? = null,
32+)
app/src/main/java/soy/iko/opencode/data/model/Session.kt+10
@@ -12,8 +12,18 @@ data class Session(
1212 val title: String? = null,
1313 val version: String? = null,
1414 val time: TimeInfo? = null,
15+ // The absolute worktree/directory the agent runs in for this session. Set server-side
16+ // from the `directory` query param at creation; persisted per session so the client
17+ // needn't re-send it on follow-up calls (see OpencodeApiClient.createSession).
18+ val directory: String? = null,
19+ val projectID: String? = null,
1520) {
1621 val displayTitle: String get() = title?.takeIf { it.isNotBlank() } ?: "Untitled session"
22+
23+ /** A compact form of [directory] for list/subtitle display: the last path segment,
24+ * or the full path when it has no separators. Null/blank when no directory is set. */
25+ val displayDirectory: String?
26+ get() = directory?.takeIf { it.isNotBlank() }?.trimEnd('/')?.substringAfterLast('/')?.takeIf { it.isNotBlank() }
1727}
1828
1929/** Body for `POST /session`. */
app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.kt+23−1
@@ -9,8 +9,10 @@ import soy.iko.opencode.data.model.FileNode
99import soy.iko.opencode.data.model.FileStatusEntry
1010import soy.iko.opencode.data.model.MessageWithParts
1111import soy.iko.opencode.data.model.ModelRef
12+import soy.iko.opencode.data.model.PathInfo
1213import soy.iko.opencode.data.model.PermissionReplyBody
1314import soy.iko.opencode.data.model.PermissionResponse
15+import soy.iko.opencode.data.model.Project
1416import soy.iko.opencode.data.model.PromptPart
1517import soy.iko.opencode.data.model.PromptRequest
1618import soy.iko.opencode.data.model.ProvidersResponse
@@ -71,20 +73,40 @@ open class OpencodeApiClient private constructor(
7173 client!!.get("session").body()
7274 }
7375
74− open suspend fun createSession(title: String? = null): Session {
76+ /**
77+ * Create a session, optionally in a specific worktree [directory]. The directory is a
78+ * query parameter (not a body field): the server records it on the session and resolves
79+ * it by session id thereafter, so follow-up calls (messages, commands, abort, …) don't
80+ * — and shouldn't — re-send it. A null/blank [directory] lets the server fall back to
81+ * its own launch cwd, preserving the pre-directory behavior.
82+ */
83+ open suspend fun createSession(title: String? = null, directory: String? = null): Session {
7584 // Generate the idempotency key before entering withRetry so every retry attempt
7685 // shares it. Without this, a POST that reached the server but whose response was
7786 // lost (reset/timeout) would be retried and create a second orphan session.
7887 val idempotencyKey = java.util.UUID.randomUUID().toString()
88+ val dir = directory?.takeIf { it.isNotBlank() }
7989 return withRetry {
8090 client!!.post("session") {
8191 contentType(ContentType.Application.Json)
8292 header("Idempotency-Key", idempotencyKey)
93+ if (dir != null) parameter("directory", dir)
8394 setBody(CreateSessionRequest(title = title))
8495 }.body()
8596 }
8697 }
8798
99+ /** List the projects (worktree roots) the server knows about, for the directory picker. */
100+ open suspend fun listProjects(): List<Project> = withRetry {
101+ client!!.get("project").body()
102+ }
103+
104+ /** The server's current path info; its [PathInfo.directory] is the default (cwd) a
105+ * session with no directory override runs in. */
106+ open suspend fun currentPath(): PathInfo = withRetry {
107+ client!!.get("path").body()
108+ }
109+
88110 open suspend fun updateSession(id: String, title: String): Session = withRetry {
89111 client!!.patch("session/${encode(id)}") {
90112 contentType(ContentType.Application.Json)
app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+2−1
@@ -39,7 +39,8 @@ open class SessionRepository(
3939 private val eventStream: EventStreamClient,
4040) {
4141 open suspend fun listSessions() = api.listSessions()
42− open suspend fun createSession(title: String? = null) = api.createSession(title)
42+ open suspend fun createSession(title: String? = null, directory: String? = null) =
43+ api.createSession(title, directory)
4344 open suspend fun deleteSession(id: String) = api.deleteSession(id)
4445 open suspend fun abort(sessionId: String) = api.abort(sessionId)
4546
app/src/main/java/soy/iko/opencode/ui/session/NewSessionDialog.ktnew file+237
@@ -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+}
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+58−2
@@ -111,6 +111,7 @@ fun SessionListScreen(
111111 val connectionState by vm.connectionState.collectAsStateWithLifecycle()
112112 val unread by vm.unread.collectAsStateWithLifecycle()
113113 val creating by vm.creating.collectAsStateWithLifecycle()
114+ val directoryOptions by vm.directoryOptions.collectAsStateWithLifecycle()
114115 val anyRunActive by container.anyRunActive.collectAsStateWithLifecycle()
115116 val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
116117 val isOnline by container.isOnline.collectAsStateWithLifecycle()
@@ -124,6 +125,12 @@ fun SessionListScreen(
124125 val timeTick = rememberRelativeTimeTick()
125126 var showServerMenu by rememberSaveable { mutableStateOf(false) }
126127 var showSortMenu by rememberSaveable { mutableStateOf(false) }
128+ var showNewSessionDialog by rememberSaveable { mutableStateOf(false) }
129+ // Open the new-session directory picker, kicking off a fetch of the server's known
130+ // directories so they're ready by the time the user looks at the list.
131+ val openNewSession = {
132+ if (!creating) { vm.loadDirectoryOptions(); showNewSessionDialog = true }
133+ }
127134 var pendingDeleteId by rememberSaveable { mutableStateOf<String?>(null) }
128135 var pendingRenameId by rememberSaveable { mutableStateOf<String?>(null) }
129136 var showDisconnectConfirm by rememberSaveable { mutableStateOf(false) }
@@ -231,7 +238,7 @@ fun SessionListScreen(
231238 snackbarHost = { SnackbarHost(snackbar) },
232239 floatingActionButton = {
233240 ExtendedFloatingActionButton(
234− onClick = { if (!creating) vm.createSession(onCreated = onOpenSession) },
241+ onClick = openNewSession,
235242 icon = {
236243 if (creating) {
237244 CircularProgressIndicator(
@@ -268,7 +275,7 @@ fun SessionListScreen(
268275 onRefresh = vm::refresh,
269276 onQueryChange = vm::setQuery,
270277 onOpenSession = onOpenSession,
271− onCreateSession = { vm.createSession(onCreated = onOpenSession) },
278+ onCreateSession = openNewSession,
272279 onRename = { pendingRenameId = it },
273280 onDelete = { pendingDeleteId = it },
274281 )
@@ -324,6 +331,28 @@ fun SessionListScreen(
324331 },
325332 )
326333 }
334+
335+ if (showNewSessionDialog) {
336+ // Distinct session directories, most-recent first (state.sessions is sorted), so the
337+ // picker can offer directories the user has recently worked in even if the server's
338+ // project list is unavailable.
339+ val sessionDirectories = remember(state.sessions) {
340+ state.sessions.mapNotNull { it.directory?.takeIf { d -> d.isNotBlank() } }.distinct()
341+ }
342+ NewSessionDialog(
343+ options = directoryOptions,
344+ sessionDirectories = sessionDirectories,
345+ lastChosenDirectory = vm.lastChosenDirectory,
346+ creating = creating,
347+ onCreate = { dir ->
348+ vm.createSession(directory = dir) { id ->
349+ showNewSessionDialog = false
350+ onOpenSession(id)
351+ }
352+ },
353+ onDismiss = { showNewSessionDialog = false },
354+ )
355+ }
327356}
328357
329358@OptIn(ExperimentalMaterial3Api::class)
@@ -599,6 +628,33 @@ private fun SessionCard(
599628 session.time?.updated ?: session.time?.created,
600629 modifier = Modifier.padding(top = 2.dp),
601630 )
631+ // The working directory the agent runs in for this session. Shown because
632+ // the list spans sessions across directories, so the folder name tells
633+ // otherwise-similar sessions apart at a glance.
634+ session.displayDirectory?.let { dir ->
635+ val dirDesc = stringResource(R.string.session_directory_desc, session.directory.orEmpty())
636+ Row(
637+ verticalAlignment = Alignment.CenterVertically,
638+ modifier = Modifier
639+ .padding(top = 2.dp)
640+ .semantics { contentDescription = dirDesc },
641+ ) {
642+ Icon(
643+ Icons.Filled.Folder,
644+ contentDescription = null,
645+ modifier = Modifier.size(12.dp),
646+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
647+ )
648+ Text(
649+ dir,
650+ style = MaterialTheme.typography.labelSmall,
651+ color = MaterialTheme.colorScheme.onSurfaceVariant,
652+ maxLines = 1,
653+ overflow = TextOverflow.Ellipsis,
654+ modifier = Modifier.padding(start = 4.dp),
655+ )
656+ }
657+ }
602658 }
603659 // Overflow menu replaces the inline edit + delete IconButtons so the
604660 // card row is decluttered — two always-visible icons per row made a
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+58−3
@@ -43,6 +43,17 @@ import kotlinx.coroutines.sync.withPermit
4343/** Sort order for the session list. */
4444enum class SessionSortMode { RECENT, TITLE }
4545
46+/** Directory options for the new-session picker: worktree paths the server knows about
47+ * ([projects]) and the server's default working directory ([serverDefault], its cwd).
48+ * [loaded] distinguishes "not fetched yet" from "fetched, none found". */
49+@Immutable
50+data class DirectoryOptionsState(
51+ val loading: Boolean = false,
52+ val loaded: Boolean = false,
53+ val projects: List<String> = emptyList(),
54+ val serverDefault: String? = null,
55+)
56+
4657@Immutable
4758data class SessionListState(
4859 val sessions: List<Session> = emptyList(),
@@ -413,13 +424,20 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
413424 }
414425 }
415426
416− fun createSession(onCreated: (String) -> Unit) {
427+ /**
428+ * Create a new session, optionally in a specific worktree [directory] (the agent's
429+ * working directory for that session). A null/blank directory lets the server use its
430+ * launch cwd — the pre-directory behavior. The chosen directory is remembered
431+ * ([lastChosenDirectory]) so the next new-session picker can preselect it.
432+ */
433+ fun createSession(directory: String? = null, onCreated: (String) -> Unit) {
417434 val conn = container.activeConnection.value ?: return
418435 if (!_creating.compareAndSet(false, true)) return
436+ val dir = directory?.takeIf { it.isNotBlank() }
419437 viewModelScope.launch {
420438 try {
421− runCatchingCancellable { conn.repository.createSession() }
422− .onSuccess { onCreated(it.id); refresh() }
439+ runCatchingCancellable { conn.repository.createSession(directory = dir) }
440+ .onSuccess { lastChosenDirectory = dir; onCreated(it.id); refresh() }
423441 .onFailure {
424442 _state.update { it.copy(error = null) }
425443 _transientErrors.tryEmit(container.friendlyError(it))
@@ -430,6 +448,43 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
430448 }
431449 }
432450
451+ /** The directory the user last created a session in (this VM lifetime), so the picker
452+ * can preselect it. Best-effort: not persisted across process death. */
453+ var lastChosenDirectory: String? = null
454+ private set
455+
456+ private val _directoryOptions = MutableStateFlow(DirectoryOptionsState())
457+ val directoryOptions: StateFlow<DirectoryOptionsState> = _directoryOptions.asStateFlow()
458+
459+ private var directoryOptionsJob: Job? = null
460+
461+ /**
462+ * Load the directory options for the new-session picker: the server's known projects
463+ * (`GET /project`) plus its default working directory (`GET /path`). Best-effort — a
464+ * failure leaves an empty option set, and the picker still offers the directories of
465+ * existing sessions and manual path entry. Safe to call on each dialog open; an
466+ * in-flight load is not restarted.
467+ */
468+ fun loadDirectoryOptions() {
469+ val conn = container.activeConnection.value ?: return
470+ if (_directoryOptions.value.loading) return
471+ _directoryOptions.update { it.copy(loading = true) }
472+ directoryOptionsJob = viewModelScope.launch {
473+ val projects = runCatchingCancellable { conn.api.listProjects() }
474+ .getOrDefault(emptyList())
475+ .mapNotNull { it.worktree.takeIf { w -> w.isNotBlank() } }
476+ .distinct()
477+ val serverDefault = runCatchingCancellable { conn.api.currentPath() }
478+ .getOrNull()?.directory?.takeIf { it.isNotBlank() }
479+ _directoryOptions.value = DirectoryOptionsState(
480+ loading = false,
481+ loaded = true,
482+ projects = projects,
483+ serverDefault = serverDefault,
484+ )
485+ }
486+ }
487+
433488 /**
434489 * Mark [session] as pending deletion and hide it from the list immediately, then
435490 * surface an Undo via [undoEvents]. The actual REST delete is deferred by
app/src/main/res/values/strings.xml+7
@@ -79,6 +79,13 @@
7979
8080 <!-- Session list -->
8181 <string name="new_session">New session</string>
82+ <string name="create">Create</string>
83+ <string name="directory_label">Directory</string>
84+ <string name="directory_server_default">Server default</string>
85+ <string name="directory_custom">Enter a path…</string>
86+ <string name="directory_custom_hint">/path/to/project</string>
87+ <string name="directory_loading">Loading directories…</string>
88+ <string name="session_directory_desc">Directory: %1$s</string>
8289 <string name="unread">Unread</string>
8390 <string name="unread_count">%1$d unread</string>
8491 <string name="no_sessions_yet">No sessions yet</string>
app/src/test/java/soy/iko/opencode/M5DecodingTest.kt+37
@@ -3,9 +3,12 @@ package soy.iko.opencode
33import soy.iko.opencode.data.model.BusEvent
44import soy.iko.opencode.data.model.FileContent
55import soy.iko.opencode.data.model.FileNode
6+import soy.iko.opencode.data.model.PathInfo
67import soy.iko.opencode.data.model.PermissionReplied
78import soy.iko.opencode.data.model.PermissionUpdated
9+import soy.iko.opencode.data.model.Project
810import soy.iko.opencode.data.model.ProvidersResponse
11+import soy.iko.opencode.data.model.Session
912import soy.iko.opencode.data.model.defaultOption
1013import soy.iko.opencode.data.model.toOptions
1114import soy.iko.opencode.data.network.OpencodeJson
@@ -45,6 +48,40 @@ class M5DecodingTest {
4548 assertEquals("src/Main.kt", event.properties.patternText)
4649 }
4750
51+ @Test
52+ fun decodesSessionWithDirectory() {
53+ // A session created in a specific worktree carries directory + projectID; older
54+ // servers omit them, so both must be optional (default null).
55+ val withDir = decode(
56+ Session.serializer(),
57+ """{"id":"ses_1","title":"Fix bug","directory":"/home/me/code/my-project","projectID":"prj_1"}""",
58+ )
59+ assertEquals("/home/me/code/my-project", withDir.directory)
60+ assertEquals("prj_1", withDir.projectID)
61+ assertEquals("my-project", withDir.displayDirectory)
62+
63+ val withoutDir = decode(Session.serializer(), """{"id":"ses_2","title":"No dir"}""")
64+ assertNull(withoutDir.directory)
65+ assertNull(withoutDir.displayDirectory)
66+ }
67+
68+ @Test
69+ fun decodesProjectsAndPath() {
70+ val projects = decode(
71+ ListSerializer(Project.serializer()),
72+ """[{"id":"p1","worktree":"/home/me/a","vcs":"git","time":{"created":1}},{"id":"p2","worktree":"/home/me/b"}]""",
73+ )
74+ assertEquals(2, projects.size)
75+ assertEquals("/home/me/a", projects[0].worktree)
76+ assertEquals("git", projects[0].vcs)
77+
78+ val path = decode(
79+ PathInfo.serializer(),
80+ """{"directory":"/home/me/cwd","worktree":"/home/me/cwd","state":"/s","config":"/c"}""",
81+ )
82+ assertEquals("/home/me/cwd", path.directory)
83+ }
84+
4885 @Test
4986 fun decodesPermissionReplied() {
5087 val json = """{"type":"permission.replied","properties":{"sessionID":"s","permissionID":"perm_1","response":"once"}}"""
app/src/test/java/soy/iko/opencode/ui/session/SessionListViewModelTest.kt+46
@@ -206,6 +206,52 @@ class SessionListViewModelTest {
206206 assertEquals("new-id", createdId)
207207 }
208208
209+ @Test
210+ fun createSession_passesDirectoryAndRemembersIt() = testScope.runTest {
211+ val repo = FakeSessionRepository(FakeOpencodeApiClient(), FakeEventStreamClient())
212+ repo.createdSession = Session(id = "new-id", title = "New", directory = "/home/me/proj")
213+ val container = makeContainer(repo = repo)
214+ val vm = makeVm(container)
215+
216+ vm.createSession(directory = "/home/me/proj") { }
217+ testScheduler.advanceUntilIdle()
218+ assertEquals("/home/me/proj", repo.lastCreatedDirectory)
219+ assertEquals("/home/me/proj", vm.lastChosenDirectory)
220+ }
221+
222+ @Test
223+ fun createSession_blankDirectoryPassesNull() = testScope.runTest {
224+ val repo = FakeSessionRepository(FakeOpencodeApiClient(), FakeEventStreamClient())
225+ val container = makeContainer(repo = repo)
226+ val vm = makeVm(container)
227+
228+ vm.createSession(directory = " ") { }
229+ testScheduler.advanceUntilIdle()
230+ assertNull(repo.lastCreatedDirectory)
231+ assertNull(vm.lastChosenDirectory)
232+ }
233+
234+ @Test
235+ fun loadDirectoryOptions_populatesProjectsAndServerDefault() = testScope.runTest {
236+ val api = FakeOpencodeApiClient()
237+ api.projectsList = listOf(
238+ soy.iko.opencode.data.model.Project(id = "p1", worktree = "/home/me/a"),
239+ soy.iko.opencode.data.model.Project(id = "p2", worktree = "/home/me/b"),
240+ )
241+ api.pathInfo = soy.iko.opencode.data.model.PathInfo(directory = "/home/me/cwd")
242+ val repo = FakeSessionRepository(api, FakeEventStreamClient())
243+ val container = makeContainer(api = api, repo = repo)
244+ val vm = makeVm(container)
245+
246+ vm.loadDirectoryOptions()
247+ testScheduler.advanceUntilIdle()
248+ val opts = vm.directoryOptions.value
249+ assertTrue(opts.loaded)
250+ assertFalse(opts.loading)
251+ assertEquals(listOf("/home/me/a", "/home/me/b"), opts.projects)
252+ assertEquals("/home/me/cwd", opts.serverDefault)
253+ }
254+
209255 // --- deleteSession ---
210256
211257 @Test
app/src/test/java/soy/iko/opencode/ui/testing/TestFakes.kt+19−2
@@ -17,7 +17,9 @@ import soy.iko.opencode.data.model.FileNode
1717import soy.iko.opencode.data.model.FileStatusEntry
1818import soy.iko.opencode.data.model.MessageWithParts
1919import soy.iko.opencode.data.model.ModelRef
20+import soy.iko.opencode.data.model.PathInfo
2021import soy.iko.opencode.data.model.PermissionResponse
22+import soy.iko.opencode.data.model.Project
2123import soy.iko.opencode.data.model.ProvidersResponse
2224import soy.iko.opencode.data.model.ServerProfile
2325import soy.iko.opencode.data.model.Session
@@ -117,6 +119,10 @@ class FakeOpencodeApiClient : OpencodeApiClient() {
117119 var fileSearchResults: List<String> = emptyList()
118120 var fileStatusEntries: List<FileStatusEntry> = emptyList()
119121 var fileContent: FileContent = FileContent(content = "")
122+ var projectsList: List<Project> = emptyList()
123+ var pathInfo: PathInfo = PathInfo()
124+ var lastCreatedDirectory: String? = null
125+ private set
120126 var sendPromptThrows: Throwable? = null
121127 var respondPermissionThrows: Throwable? = null
122128 var cacheInvalidated = false
@@ -140,7 +146,13 @@ class FakeOpencodeApiClient : OpencodeApiClient() {
140146
141147 override suspend fun listSessions(): List<Session> = sessions
142148
143− override suspend fun createSession(title: String?): Session = createdSession
149+ override suspend fun createSession(title: String?, directory: String?): Session {
150+ lastCreatedDirectory = directory
151+ return createdSession
152+ }
153+
154+ override suspend fun listProjects(): List<Project> = projectsList
155+ override suspend fun currentPath(): PathInfo = pathInfo
144156
145157 override suspend fun updateSession(id: String, title: String): Session {
146158 return sessions.firstOrNull { it.id == id }?.copy(title = title) ?: Session(id = id, title = title)
@@ -243,6 +255,8 @@ class FakeSessionRepository(
243255 var listSessionsGate: kotlinx.coroutines.CompletableDeferred<Unit>? = null
244256 var abortCalls: List<String> = emptyList()
245257 private set
258+ var lastCreatedDirectory: String? = null
259+ private set
246260
247261 /** When non-null, [observeMessages] returns this flow instead of [messages]. */
248262 var observeMessagesOverride: kotlinx.coroutines.flow.Flow<List<MessageWithParts>>? = null
@@ -252,7 +266,10 @@ class FakeSessionRepository(
252266 listSessionsThrows?.let { throw it }
253267 return sessions
254268 }
255− override suspend fun createSession(title: String?): Session = createdSession
269+ override suspend fun createSession(title: String?, directory: String?): Session {
270+ lastCreatedDirectory = directory
271+ return createdSession
272+ }
256273 override suspend fun deleteSession(id: String) { /* no-op */ }
257274 override suspend fun abort(sessionId: String) { abortCalls = abortCalls + sessionId }
258275