git.iko.soy/opencode-android
on master help sign in
Add per-session directory selection for agent runs
ea52e62iko
app/build.gradle.kts+4−1
@@ -81,7 +81,10 @@ android {
81 // minor/style issues don't gate development.
81 // minor/style issues don't gate development.
82 abortOnError = true
82 abortOnError = true
83 // Dependency upgrades are tracked by Renovate, so don't lint for them here.
83 // Dependency upgrades are tracked by Renovate, so don't lint for them here.
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.
84− disable += setOf("GradleDependency", "AndroidGradlePluginVersion")
87+ disable += setOf("GradleDependency", "AndroidGradlePluginVersion", "ProduceStateDoesNotAssignValue")
85 }
88 }
86
89
87 packaging {
90 packaging {
app/detekt-baseline.xml+1
@@ -3,6 +3,7 @@
3 <ManuallySuppressedIssues/>
3 <ManuallySuppressedIssues/>
4 <CurrentIssues>
4 <CurrentIssues>
5 <ID>ComplexCondition:PartRenderer.kt$part.isImage &amp;&amp; imageContext != null &amp;&amp; (part.source != null || !part.url.isNullOrBlank())</ID>
5 <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>
6 <ID>CyclomaticComplexMethod:ChatScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ChatScreen( container: AppContainer, sessionId: String, onBack: () -&gt; Unit, onOpenFile: ((String) -&gt; Unit)? = null, )</ID>
7 <ID>CyclomaticComplexMethod:ChatScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ChatScreen( container: AppContainer, sessionId: String, onBack: () -&gt; Unit, onOpenFile: ((String) -&gt; Unit)? = null, )</ID>
7 <ID>CyclomaticComplexMethod:CodeHighlighter.kt$private fun findNumberEnd(line: String, start: Int): Int</ID>
8 <ID>CyclomaticComplexMethod:CodeHighlighter.kt$private fun findNumberEnd(line: String, start: Int): Int</ID>
8 <ID>MatchingDeclarationName:DiffView.kt$DiffLine</ID>
9 <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(
12 val title: String? = null,
12 val title: String? = null,
13 val version: String? = null,
13 val version: String? = null,
14 val time: TimeInfo? = null,
14 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,
15) {
20) {
16 val displayTitle: String get() = title?.takeIf { it.isNotBlank() } ?: "Untitled session"
21 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() }
17}
27}
18
28
19/** Body for `POST /session`. */
29/** 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
9import soy.iko.opencode.data.model.FileStatusEntry
9import soy.iko.opencode.data.model.FileStatusEntry
10import soy.iko.opencode.data.model.MessageWithParts
10import soy.iko.opencode.data.model.MessageWithParts
11import soy.iko.opencode.data.model.ModelRef
11import soy.iko.opencode.data.model.ModelRef
12+import soy.iko.opencode.data.model.PathInfo
12import soy.iko.opencode.data.model.PermissionReplyBody
13import soy.iko.opencode.data.model.PermissionReplyBody
13import soy.iko.opencode.data.model.PermissionResponse
14import soy.iko.opencode.data.model.PermissionResponse
15+import soy.iko.opencode.data.model.Project
14import soy.iko.opencode.data.model.PromptPart
16import soy.iko.opencode.data.model.PromptPart
15import soy.iko.opencode.data.model.PromptRequest
17import soy.iko.opencode.data.model.PromptRequest
16import soy.iko.opencode.data.model.ProvidersResponse
18import soy.iko.opencode.data.model.ProvidersResponse
@@ -71,20 +73,40 @@ open class OpencodeApiClient private constructor(
71 client!!.get("session").body()
73 client!!.get("session").body()
72 }
74 }
73
75
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+ */
74− open suspend fun createSession(title: String? = null): Session {
83+ open suspend fun createSession(title: String? = null, directory: String? = null): Session {
75 // Generate the idempotency key before entering withRetry so every retry attempt
84 // Generate the idempotency key before entering withRetry so every retry attempt
76 // shares it. Without this, a POST that reached the server but whose response was
85 // shares it. Without this, a POST that reached the server but whose response was
77 // lost (reset/timeout) would be retried and create a second orphan session.
86 // lost (reset/timeout) would be retried and create a second orphan session.
78 val idempotencyKey = java.util.UUID.randomUUID().toString()
87 val idempotencyKey = java.util.UUID.randomUUID().toString()
88+ val dir = directory?.takeIf { it.isNotBlank() }
79 return withRetry {
89 return withRetry {
80 client!!.post("session") {
90 client!!.post("session") {
81 contentType(ContentType.Application.Json)
91 contentType(ContentType.Application.Json)
82 header("Idempotency-Key", idempotencyKey)
92 header("Idempotency-Key", idempotencyKey)
93+ if (dir != null) parameter("directory", dir)
83 setBody(CreateSessionRequest(title = title))
94 setBody(CreateSessionRequest(title = title))
84 }.body()
95 }.body()
85 }
96 }
86 }
97 }
87
98
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+
88 open suspend fun updateSession(id: String, title: String): Session = withRetry {
110 open suspend fun updateSession(id: String, title: String): Session = withRetry {
89 client!!.patch("session/${encode(id)}") {
111 client!!.patch("session/${encode(id)}") {
90 contentType(ContentType.Application.Json)
112 contentType(ContentType.Application.Json)
app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+2−1
@@ -39,7 +39,8 @@ open class SessionRepository(
39 private val eventStream: EventStreamClient,
39 private val eventStream: EventStreamClient,
40) {
40) {
41 open suspend fun listSessions() = api.listSessions()
41 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)
43 open suspend fun deleteSession(id: String) = api.deleteSession(id)
44 open suspend fun deleteSession(id: String) = api.deleteSession(id)
44 open suspend fun abort(sessionId: String) = api.abort(sessionId)
45 open suspend fun abort(sessionId: String) = api.abort(sessionId)
45
46
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(
111 val connectionState by vm.connectionState.collectAsStateWithLifecycle()
111 val connectionState by vm.connectionState.collectAsStateWithLifecycle()
112 val unread by vm.unread.collectAsStateWithLifecycle()
112 val unread by vm.unread.collectAsStateWithLifecycle()
113 val creating by vm.creating.collectAsStateWithLifecycle()
113 val creating by vm.creating.collectAsStateWithLifecycle()
114+ val directoryOptions by vm.directoryOptions.collectAsStateWithLifecycle()
114 val anyRunActive by container.anyRunActive.collectAsStateWithLifecycle()
115 val anyRunActive by container.anyRunActive.collectAsStateWithLifecycle()
115 val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
116 val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
116 val isOnline by container.isOnline.collectAsStateWithLifecycle()
117 val isOnline by container.isOnline.collectAsStateWithLifecycle()
@@ -124,6 +125,12 @@ fun SessionListScreen(
124 val timeTick = rememberRelativeTimeTick()
125 val timeTick = rememberRelativeTimeTick()
125 var showServerMenu by rememberSaveable { mutableStateOf(false) }
126 var showServerMenu by rememberSaveable { mutableStateOf(false) }
126 var showSortMenu by rememberSaveable { mutableStateOf(false) }
127 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+ }
127 var pendingDeleteId by rememberSaveable { mutableStateOf<String?>(null) }
134 var pendingDeleteId by rememberSaveable { mutableStateOf<String?>(null) }
128 var pendingRenameId by rememberSaveable { mutableStateOf<String?>(null) }
135 var pendingRenameId by rememberSaveable { mutableStateOf<String?>(null) }
129 var showDisconnectConfirm by rememberSaveable { mutableStateOf(false) }
136 var showDisconnectConfirm by rememberSaveable { mutableStateOf(false) }
@@ -231,7 +238,7 @@ fun SessionListScreen(
231 snackbarHost = { SnackbarHost(snackbar) },
238 snackbarHost = { SnackbarHost(snackbar) },
232 floatingActionButton = {
239 floatingActionButton = {
233 ExtendedFloatingActionButton(
240 ExtendedFloatingActionButton(
234− onClick = { if (!creating) vm.createSession(onCreated = onOpenSession) },
241+ onClick = openNewSession,
235 icon = {
242 icon = {
236 if (creating) {
243 if (creating) {
237 CircularProgressIndicator(
244 CircularProgressIndicator(
@@ -268,7 +275,7 @@ fun SessionListScreen(
268 onRefresh = vm::refresh,
275 onRefresh = vm::refresh,
269 onQueryChange = vm::setQuery,
276 onQueryChange = vm::setQuery,
270 onOpenSession = onOpenSession,
277 onOpenSession = onOpenSession,
271− onCreateSession = { vm.createSession(onCreated = onOpenSession) },
278+ onCreateSession = openNewSession,
272 onRename = { pendingRenameId = it },
279 onRename = { pendingRenameId = it },
273 onDelete = { pendingDeleteId = it },
280 onDelete = { pendingDeleteId = it },
274 )
281 )
@@ -324,6 +331,28 @@ fun SessionListScreen(
324 },
331 },
325 )
332 )
326 }
333 }
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+ }
327}
356}
328
357
329@OptIn(ExperimentalMaterial3Api::class)
358@OptIn(ExperimentalMaterial3Api::class)
@@ -599,6 +628,33 @@ private fun SessionCard(
599 session.time?.updated ?: session.time?.created,
628 session.time?.updated ?: session.time?.created,
600 modifier = Modifier.padding(top = 2.dp),
629 modifier = Modifier.padding(top = 2.dp),
601 )
630 )
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+ }
602 }
658 }
603 // Overflow menu replaces the inline edit + delete IconButtons so the
659 // Overflow menu replaces the inline edit + delete IconButtons so the
604 // card row is decluttered — two always-visible icons per row made a
660 // 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
43/** Sort order for the session list. */
43/** Sort order for the session list. */
44enum class SessionSortMode { RECENT, TITLE }
44enum class SessionSortMode { RECENT, TITLE }
45
45
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+
46@Immutable
57@Immutable
47data class SessionListState(
58data class SessionListState(
48 val sessions: List<Session> = emptyList(),
59 val sessions: List<Session> = emptyList(),
@@ -413,13 +424,20 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
413 }
424 }
414 }
425 }
415
426
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+ */
416− fun createSession(onCreated: (String) -> Unit) {
433+ fun createSession(directory: String? = null, onCreated: (String) -> Unit) {
417 val conn = container.activeConnection.value ?: return
434 val conn = container.activeConnection.value ?: return
418 if (!_creating.compareAndSet(false, true)) return
435 if (!_creating.compareAndSet(false, true)) return
436+ val dir = directory?.takeIf { it.isNotBlank() }
419 viewModelScope.launch {
437 viewModelScope.launch {
420 try {
438 try {
421− runCatchingCancellable { conn.repository.createSession() }
439+ runCatchingCancellable { conn.repository.createSession(directory = dir) }
422− .onSuccess { onCreated(it.id); refresh() }
440+ .onSuccess { lastChosenDirectory = dir; onCreated(it.id); refresh() }
423 .onFailure {
441 .onFailure {
424 _state.update { it.copy(error = null) }
442 _state.update { it.copy(error = null) }
425 _transientErrors.tryEmit(container.friendlyError(it))
443 _transientErrors.tryEmit(container.friendlyError(it))
@@ -430,6 +448,43 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
430 }
448 }
431 }
449 }
432
450
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+
433 /**
488 /**
434 * Mark [session] as pending deletion and hide it from the list immediately, then
489 * Mark [session] as pending deletion and hide it from the list immediately, then
435 * surface an Undo via [undoEvents]. The actual REST delete is deferred by
490 * surface an Undo via [undoEvents]. The actual REST delete is deferred by
app/src/main/res/values/strings.xml+7
@@ -79,6 +79,13 @@
79
79
80 <!-- Session list -->
80 <!-- Session list -->
81 <string name="new_session">New session</string>
81 <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>
82 <string name="unread">Unread</string>
89 <string name="unread">Unread</string>
83 <string name="unread_count">%1$d unread</string>
90 <string name="unread_count">%1$d unread</string>
84 <string name="no_sessions_yet">No sessions yet</string>
91 <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
3import soy.iko.opencode.data.model.BusEvent
3import soy.iko.opencode.data.model.BusEvent
4import soy.iko.opencode.data.model.FileContent
4import soy.iko.opencode.data.model.FileContent
5import soy.iko.opencode.data.model.FileNode
5import soy.iko.opencode.data.model.FileNode
6+import soy.iko.opencode.data.model.PathInfo
6import soy.iko.opencode.data.model.PermissionReplied
7import soy.iko.opencode.data.model.PermissionReplied
7import soy.iko.opencode.data.model.PermissionUpdated
8import soy.iko.opencode.data.model.PermissionUpdated
9+import soy.iko.opencode.data.model.Project
8import soy.iko.opencode.data.model.ProvidersResponse
10import soy.iko.opencode.data.model.ProvidersResponse
11+import soy.iko.opencode.data.model.Session
9import soy.iko.opencode.data.model.defaultOption
12import soy.iko.opencode.data.model.defaultOption
10import soy.iko.opencode.data.model.toOptions
13import soy.iko.opencode.data.model.toOptions
11import soy.iko.opencode.data.network.OpencodeJson
14import soy.iko.opencode.data.network.OpencodeJson
@@ -45,6 +48,40 @@ class M5DecodingTest {
45 assertEquals("src/Main.kt", event.properties.patternText)
48 assertEquals("src/Main.kt", event.properties.patternText)
46 }
49 }
47
50
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+
48 @Test
85 @Test
49 fun decodesPermissionReplied() {
86 fun decodesPermissionReplied() {
50 val json = """{"type":"permission.replied","properties":{"sessionID":"s","permissionID":"perm_1","response":"once"}}"""
87 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 {
206 assertEquals("new-id", createdId)
206 assertEquals("new-id", createdId)
207 }
207 }
208
208
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+
209 // --- deleteSession ---
255 // --- deleteSession ---
210
256
211 @Test
257 @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
17import soy.iko.opencode.data.model.FileStatusEntry
17import soy.iko.opencode.data.model.FileStatusEntry
18import soy.iko.opencode.data.model.MessageWithParts
18import soy.iko.opencode.data.model.MessageWithParts
19import soy.iko.opencode.data.model.ModelRef
19import soy.iko.opencode.data.model.ModelRef
20+import soy.iko.opencode.data.model.PathInfo
20import soy.iko.opencode.data.model.PermissionResponse
21import soy.iko.opencode.data.model.PermissionResponse
22+import soy.iko.opencode.data.model.Project
21import soy.iko.opencode.data.model.ProvidersResponse
23import soy.iko.opencode.data.model.ProvidersResponse
22import soy.iko.opencode.data.model.ServerProfile
24import soy.iko.opencode.data.model.ServerProfile
23import soy.iko.opencode.data.model.Session
25import soy.iko.opencode.data.model.Session
@@ -117,6 +119,10 @@ class FakeOpencodeApiClient : OpencodeApiClient() {
117 var fileSearchResults: List<String> = emptyList()
119 var fileSearchResults: List<String> = emptyList()
118 var fileStatusEntries: List<FileStatusEntry> = emptyList()
120 var fileStatusEntries: List<FileStatusEntry> = emptyList()
119 var fileContent: FileContent = FileContent(content = "")
121 var fileContent: FileContent = FileContent(content = "")
122+ var projectsList: List<Project> = emptyList()
123+ var pathInfo: PathInfo = PathInfo()
124+ var lastCreatedDirectory: String? = null
125+ private set
120 var sendPromptThrows: Throwable? = null
126 var sendPromptThrows: Throwable? = null
121 var respondPermissionThrows: Throwable? = null
127 var respondPermissionThrows: Throwable? = null
122 var cacheInvalidated = false
128 var cacheInvalidated = false
@@ -140,7 +146,13 @@ class FakeOpencodeApiClient : OpencodeApiClient() {
140
146
141 override suspend fun listSessions(): List<Session> = sessions
147 override suspend fun listSessions(): List<Session> = sessions
142
148
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
144
156
145 override suspend fun updateSession(id: String, title: String): Session {
157 override suspend fun updateSession(id: String, title: String): Session {
146 return sessions.firstOrNull { it.id == id }?.copy(title = title) ?: Session(id = id, title = title)
158 return sessions.firstOrNull { it.id == id }?.copy(title = title) ?: Session(id = id, title = title)
@@ -243,6 +255,8 @@ class FakeSessionRepository(
243 var listSessionsGate: kotlinx.coroutines.CompletableDeferred<Unit>? = null
255 var listSessionsGate: kotlinx.coroutines.CompletableDeferred<Unit>? = null
244 var abortCalls: List<String> = emptyList()
256 var abortCalls: List<String> = emptyList()
245 private set
257 private set
258+ var lastCreatedDirectory: String? = null
259+ private set
246
260
247 /** When non-null, [observeMessages] returns this flow instead of [messages]. */
261 /** When non-null, [observeMessages] returns this flow instead of [messages]. */
248 var observeMessagesOverride: kotlinx.coroutines.flow.Flow<List<MessageWithParts>>? = null
262 var observeMessagesOverride: kotlinx.coroutines.flow.Flow<List<MessageWithParts>>? = null
@@ -252,7 +266,10 @@ class FakeSessionRepository(
252 listSessionsThrows?.let { throw it }
266 listSessionsThrows?.let { throw it }
253 return sessions
267 return sessions
254 }
268 }
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+ }
256 override suspend fun deleteSession(id: String) { /* no-op */ }
273 override suspend fun deleteSession(id: String) { /* no-op */ }
257 override suspend fun abort(sessionId: String) { abortCalls = abortCalls + sessionId }
274 override suspend fun abort(sessionId: String) { abortCalls = abortCalls + sessionId }
258
275