git.iko.soy/opencode-android
on master help sign in
Fix two-pane share loss, ServerEditViewModel hang, foreground service leak, disconnect race, SSE auth retry and handshake hang, URL path encoding, notification ID prefix, and several error-handling and lifecycle gaps
commit3e723a158e415c16c0285f0b8c8028ea91fdf09a authoriko <mail@iko.soy> dateTue 30 Jun 2026, 04:34 (3 months ago) parentc68ef0e
18 files changed+162−57
app/src/main/java/soy/iko/opencode/MainActivity.kt+21−5 app/src/main/java/soy/iko/opencode/OpencodeApp.kt+5 app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+18−1 app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+2−2 app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.kt+16−8 app/src/main/java/soy/iko/opencode/data/repo/CrashLogger.kt+6−1 app/src/main/java/soy/iko/opencode/di/AppContainer.kt+10−5 app/src/main/java/soy/iko/opencode/notification/RunForegroundService.kt+4−1 app/src/main/java/soy/iko/opencode/notification/SessionNotifications.kt+6−2 app/src/main/java/soy/iko/opencode/ui/Routes.kt+2−2 app/src/main/java/soy/iko/opencode/ui/components/RemoteImage.kt+12−2 app/src/main/java/soy/iko/opencode/ui/components/TimeFormat.kt+1 app/src/main/java/soy/iko/opencode/ui/file/FileBrowserViewModel.kt+4−1 app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+8 app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+25−16 app/src/main/java/soy/iko/opencode/ui/server/ServerListViewModel.kt+4−1 app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+12−9 app/src/main/java/soy/iko/opencode/ui/session/TwoPaneSessionChat.kt+6−1
app/src/main/java/soy/iko/opencode/MainActivity.kt+21−5
@@ -24,13 +24,16 @@ class MainActivity : ComponentActivity() {
2424 ActivityResultContracts.RequestPermission(),
2525 ) { /* Result is best-effort; notifications are silently skipped if denied. */ }
2626
27+ private var shareIntentHandled = false
28+
2729 override fun onCreate(savedInstanceState: Bundle?) {
2830 installSplashScreen()
2931 super.onCreate(savedInstanceState)
3032 enableEdgeToEdge()
33+ savedInstanceState?.let { shareIntentHandled = it.getBoolean(KEY_SHARE_HANDLED, false) }
3134 val container = (application as OpencodeApp).container
32− if (savedInstanceState == null) handleIntent(intent)
33− maybeRequestNotificationPermission()
35+ handleIntent(intent)
36+ if (savedInstanceState == null) maybeRequestNotificationPermission()
3437 setContent {
3538 val themeMode by container.settingsStore.themeMode
3639 .collectAsStateWithLifecycle(initialValue = ThemeMode.SYSTEM)
@@ -50,6 +53,7 @@ class MainActivity : ComponentActivity() {
5053 override fun onNewIntent(intent: Intent) {
5154 super.onNewIntent(intent)
5255 setIntent(intent)
56+ shareIntentHandled = false
5357 handleIntent(intent)
5458 }
5559
@@ -57,9 +61,14 @@ class MainActivity : ComponentActivity() {
5761 * and session ids from notification taps / deep links so we can open them. */
5862 private fun handleIntent(intent: Intent?) {
5963 val container = (application as OpencodeApp).container
60− if (intent?.action == Intent.ACTION_SEND) {
61− val text = intent.getStringExtra(Intent.EXTRA_TEXT)?.takeIf { it.isNotBlank() }
62− if (text != null) container.setPendingShare(text)
64+ if (intent?.action == Intent.ACTION_SEND && !shareIntentHandled) {
65+ val text = intent.getStringExtra(Intent.EXTRA_TEXT)
66+ ?.takeIf { it.isNotBlank() }
67+ ?.take(10_000) // cap to prevent unbounded memory usage from malicious shares
68+ if (text != null) {
69+ container.setPendingShare(text)
70+ shareIntentHandled = true
71+ }
6372 }
6473 // Deep link: opencode://session/{sessionId} (or the EXTRA_SESSION_ID extra).
6574 val deepLinkId = intent?.takeIf { it.action == Intent.ACTION_VIEW }
@@ -79,6 +88,13 @@ class MainActivity : ComponentActivity() {
7988
8089 /** Session ids are alphanumeric with dashes/underscores. Reject path traversal. */
8190 private val VALID_SESSION_ID = Regex("[A-Za-z0-9_-]+")
91+
92+ private const val KEY_SHARE_HANDLED = "shareIntentHandled"
93+ }
94+
95+ override fun onSaveInstanceState(outState: Bundle) {
96+ super.onSaveInstanceState(outState)
97+ outState.putBoolean(KEY_SHARE_HANDLED, shareIntentHandled)
8298 }
8399
84100 /** Ask for POST_NOTIFICATIONS once on Android 13+ so run/completion notifications show. */
app/src/main/java/soy/iko/opencode/OpencodeApp.kt+5
@@ -13,4 +13,9 @@ class OpencodeApp : Application() {
1313 CrashLogger.get(this).install()
1414 container = AppContainer(this)
1515 }
16+
17+ override fun onTerminate() {
18+ super.onTerminate()
19+ container.shutdown()
20+ }
1621}
app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+18−1
@@ -59,6 +59,11 @@ class EventStreamClient(
5959 /** Conflated channel used to cut the reconnect backoff short when network returns. */
6060 private val reconnectSignal = Channel<Unit>(Channel.CONFLATED)
6161
62+ /** Returns true if the exception is an SSE auth failure (401/403) that should stop retrying. */
63+ private fun isSseAuthFailure(e: Throwable): Boolean =
64+ e is io.ktor.client.plugins.ClientRequestException &&
65+ (e.response.status.value == 401 || e.response.status.value == 403)
66+
6267 /** Request an immediate reconnect, skipping any in-progress backoff. */
6368 fun triggerReconnect() { reconnectSignal.trySend(Unit) }
6469
@@ -81,11 +86,18 @@ class EventStreamClient(
8186 request = {
8287 timeout {
8388 requestTimeoutMillis = Long.MAX_VALUE
84− socketTimeoutMillis = Long.MAX_VALUE
89+ // Use a finite socket timeout so a server that accepts TCP but
90+ // never sends SSE headers doesn't hang indefinitely. Once events
91+ // are flowing, the idle-timeout watchdog handles half-open detection.
92+ socketTimeoutMillis = NetworkConfig.sseHandshakeTimeoutMs
8593 }
8694 },
8795 ) {
8896 backoffMs = NetworkConfig.sseInitialBackoffMs
97+ // Clear any stale reconnect signal from a triggerReconnect() call
98+ // made while the previous stream was healthy, so it doesn't suppress
99+ // the next legitimate backoff.
100+ while (reconnectSignal.tryReceive().isSuccess) { /* drain */ }
89101 // `incoming` is a cold Flow; bridge it to a ReceiveChannel so each
90102 // element can be raced against an idle timeout via select.
91103 val events = incoming.produceIn(scope)
@@ -126,6 +138,11 @@ class EventStreamClient(
126138 } catch (c: CancellationException) {
127139 throw c
128140 } catch (e: Exception) {
141+ if (isSseAuthFailure(e)) {
142+ Log.w("EventStream", "SSE auth failed, stopping reconnect", e)
143+ _state.value = ConnectionState.Disconnected
144+ throw e
145+ }
129146 Log.w("EventStream", "SSE stream error, will retry", e)
130147 }
131148 _state.value = ConnectionState.Disconnected
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+2−2
@@ -1,7 +1,5 @@
11package soy.iko.opencode.data.network
22
3−import java.util.concurrent.TimeUnit
4−
53/**
64 * Centralized tuning parameters for the network and presentation layers. Keeping these
75 * in one place makes them easy to find and adjust without hunting through source files,
@@ -39,6 +37,8 @@ object NetworkConfig {
3937 const val sseIdleTimeoutMs = 90_000L
4038 /** Buffer capacity for the SSE events SharedFlow; prevents a slow subscriber stalling the read loop. */
4139 const val sseEventBufferCapacity = 64
40+ /** Max time to wait for the first SSE event before treating the connection as stale. */
41+ const val sseHandshakeTimeoutMs = 30_000L
4242
4343 // --- Session list (SessionListViewModel) ---
4444
app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.kt+16−8
@@ -29,6 +29,7 @@ import io.ktor.http.ContentType
2929import io.ktor.http.contentType
3030import kotlinx.coroutines.delay
3131import kotlin.random.Random
32+import java.net.URLEncoder
3233
3334/**
3435 * Request/response wrapper over the opencode REST endpoints. The long-lived `/event`
@@ -52,17 +53,17 @@ class OpencodeApiClient(private val client: HttpClient) {
5253 }.body()
5354
5455 suspend fun updateSession(id: String, title: String): Session =
55− client.patch("session/$id") {
56+ client.patch("session/${encode(id)}") {
5657 contentType(ContentType.Application.Json)
5758 setBody(UpdateSessionRequest(title = title))
5859 }.body()
5960
6061 suspend fun deleteSession(id: String) {
61− client.delete("session/$id")
62+ client.delete("session/${encode(id)}")
6263 }
6364
6465 suspend fun listMessages(sessionId: String): List<MessageWithParts> = withRetry {
65− client.get("session/$sessionId/message").body()
66+ client.get("session/${encode(sessionId)}/message").body()
6667 }
6768
6869 suspend fun sendPrompt(
@@ -71,7 +72,7 @@ class OpencodeApiClient(private val client: HttpClient) {
7172 model: ModelRef? = null,
7273 agent: String? = null,
7374 ): MessageWithParts =
74− client.post("session/$sessionId/message") {
75+ client.post("session/${encode(sessionId)}/message") {
7576 contentType(ContentType.Application.Json)
7677 setBody(
7778 PromptRequest(
@@ -83,7 +84,7 @@ class OpencodeApiClient(private val client: HttpClient) {
8384 }.body()
8485
8586 suspend fun abort(sessionId: String) {
86− client.post("session/$sessionId/abort")
87+ client.post("session/${encode(sessionId)}/abort")
8788 }
8889
8990 /** Invoke a slash-command by name via `POST /session/:id/command`. */
@@ -93,7 +94,7 @@ class OpencodeApiClient(private val client: HttpClient) {
9394 arguments: String = "",
9495 agent: String? = null,
9596 ): MessageWithParts =
96− client.post("session/$sessionId/command") {
97+ client.post("session/${encode(sessionId)}/command") {
9798 contentType(ContentType.Application.Json)
9899 setBody(CommandRequest(command = command, arguments = arguments, agent = agent))
99100 }.body()
@@ -116,7 +117,7 @@ class OpencodeApiClient(private val client: HttpClient) {
116117 permissionId: String,
117118 response: PermissionResponse,
118119 ) = withRetry {
119− client.post("session/$sessionId/permissions/$permissionId") {
120+ client.post("session/${encode(sessionId)}/permissions/${encode(permissionId)}") {
120121 contentType(ContentType.Application.Json)
121122 setBody(PermissionReplyBody(response.wire))
122123 }
@@ -169,7 +170,10 @@ internal suspend fun <T> withRetryInternal(
169170 throw c
170171 } catch (t: Exception) {
171172 lastError = t
172− if (t is ClientRequestException) throw t
173+ if (t is ClientRequestException) {
174+ // 429 (Too Many Requests) is transient — retry with backoff.
175+ if (t.response.status.value != 429) throw t
176+ }
173177 if (attempt < maxAttempts) {
174178 val baseDelay = initialDelayMs * (1 shl (attempt - 1))
175179 val jitter = (baseDelay * jitterFactor * Random.nextDouble()).toLong()
@@ -179,3 +183,7 @@ internal suspend fun <T> withRetryInternal(
179183 }
180184 throw lastError ?: IllegalStateException("withRetry failed without error")
181185}
186+
187+/** URL-encode a path segment, replacing + with %20 (URLEncoder uses query-param encoding). */
188+private fun encode(segment: String): String =
189+ URLEncoder.encode(segment, "UTF-8").replace("+", "%20")
app/src/main/java/soy/iko/opencode/data/repo/CrashLogger.kt+6−1
@@ -5,9 +5,13 @@ import android.content.pm.PackageManager
55import android.os.Build
66import android.util.Log
77import androidx.core.content.pm.PackageInfoCompat
8+import kotlinx.coroutines.CoroutineScope
9+import kotlinx.coroutines.Dispatchers
10+import kotlinx.coroutines.SupervisorJob
811import kotlinx.coroutines.flow.MutableStateFlow
912import kotlinx.coroutines.flow.StateFlow
1013import kotlinx.coroutines.flow.asStateFlow
14+import kotlinx.coroutines.launch
1115import java.io.File
1216import java.io.PrintWriter
1317import java.io.StringWriter
@@ -34,6 +38,7 @@ class CrashLogger private constructor(private val appContext: Context) {
3438 )
3539
3640 private val crashDir = File(appContext.filesDir, "crashes").apply { mkdirs() }
41+ private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
3742
3843 private val _reports = MutableStateFlow<List<CrashReport>>(emptyList())
3944 val reports: StateFlow<List<CrashReport>> = _reports.asStateFlow()
@@ -48,7 +53,7 @@ class CrashLogger private constructor(private val appContext: Context) {
4853 // Load crash reports off the main thread so startup isn't blocked by file I/O
4954 // (listFiles + reading the first line of each report). The StateFlow updates
5055 // whenever the scan completes, so the Diagnostics screen reflects the result.
51− Thread { refresh() }.start()
56+ scope.launch { refresh() }
5257 }
5358
5459 fun refresh() {
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+10−5
@@ -251,6 +251,7 @@ class AppContainer(context: Context) {
251251 connectionMutex.withLock {
252252 _activeConnection.value?.close()
253253 _activeConnection.value = null
254+ activeRuns.clear()
254255 val now = System.currentTimeMillis()
255256 val resolved = profileStore.resolve(profile)
256257 val needsSave = (now - resolved.lastUsed) > LAST_USED_SAVE_THRESHOLD_MS
@@ -260,11 +261,15 @@ class AppContainer(context: Context) {
260261 }
261262
262263 fun disconnect() {
263− // Non-suspending best-effort disconnect; if a connect is in progress under the
264− // mutex, the connection it sets will be closed on the next disconnect/connect.
265− val conn = _activeConnection.value ?: return
266− _activeConnection.value = null
267− conn.close()
264+ // Launch on appScope so disconnect acquires the connection mutex, preventing
265+ // a race where a concurrent connect() sets a new connection after disconnect
266+ // reads null — which would leak the just-created connection.
267+ appScope.launch {
268+ connectionMutex.withLock {
269+ _activeConnection.value?.close()
270+ _activeConnection.value = null
271+ }
272+ }
268273 }
269274}
270275
app/src/main/java/soy/iko/opencode/notification/RunForegroundService.kt+4−1
@@ -41,7 +41,10 @@ class RunForegroundService : Service() {
4141 0
4242 },
4343 )
44− }.onFailure { Log.w(TAG, "startForeground failed; running without foreground priority", it) }
44+ }.onFailure {
45+ Log.w(TAG, "startForeground failed; running without foreground priority", it)
46+ stopSelf()
47+ }
4548 return START_NOT_STICKY
4649 }
4750
app/src/main/java/soy/iko/opencode/notification/SessionNotifications.kt+6−2
@@ -30,10 +30,14 @@ object SessionNotifications {
3030 private fun notifIdFor(sessionId: String): Int {
3131 val digest = MessageDigest.getInstance("SHA-256").digest(sessionId.toByteArray())
3232 // Take the first 4 bytes, mask to 31 bits (always positive) to fit an Int id.
33− return (NOTIF_ID_PREFIX + ((digest[0].toInt() and 0xFF) shl 24 or
33+ val hash = ((digest[0].toInt() and 0xFF) shl 24 or
3434 (digest[1].toInt() and 0xFF) shl 16 or
3535 (digest[2].toInt() and 0xFF) shl 8 or
36− (digest[3].toInt() and 0xFF))).and(0x7FFFFFFF)
36+ (digest[3].toInt() and 0xFF)).and(0x7FFFFFFF)
37+ // Offset by NOTIF_ID_PREFIX, wrapping within the positive Int range so the
38+ // prefix is preserved (not masked away) and ids don't collide with the
39+ // foreground service's NOTIF_ID = 1.
40+ return (NOTIF_ID_PREFIX + hash).and(0x7FFFFFFF)
3741 }
3842
3943 fun postCompleted(context: Context, sessionId: String, title: String) {
app/src/main/java/soy/iko/opencode/ui/Routes.kt+2−2
@@ -10,10 +10,10 @@ object Routes {
1010
1111 const val SERVER_EDIT = "server_edit"
1212 fun serverEdit(id: String? = null) =
13− if (id.isNullOrEmpty()) SERVER_EDIT else "$SERVER_EDIT?id=$id"
13+ if (id.isNullOrEmpty()) SERVER_EDIT else "$SERVER_EDIT?id=${android.net.Uri.encode(id)}"
1414
1515 const val CHAT = "chat"
16− fun chat(sessionId: String) = "$CHAT/$sessionId"
16+ fun chat(sessionId: String) = "$CHAT/${android.net.Uri.encode(sessionId)}"
1717
1818 const val FILE_VIEW = "file_view"
1919 fun fileView(path: String) = "$FILE_VIEW?path=${android.net.Uri.encode(path)}"
app/src/main/java/soy/iko/opencode/ui/components/RemoteImage.kt+12−2
@@ -58,7 +58,12 @@ private fun FilePart.resolveModel(ctx: ImageLoadContext): Any? {
5858 return if (url.startsWith("http://") || url.startsWith("https://")) {
5959 url
6060 } else {
61− HttpClientFactory.normalizeBaseUrl(ctx.baseUrl) + url.trimStart('/')
61+ // Resolve relative URL against the base. Collapse any ../ segments to
62+ // prevent path traversal from escaping the server's base path.
63+ val base = HttpClientFactory.normalizeBaseUrl(ctx.baseUrl)
64+ val resolved = java.net.URI(base).resolve(url).normalize().toString()
65+ // Guard: if normalization escaped the base path, fall back to the base.
66+ if (!resolved.startsWith(base)) base else resolved
6267 }
6368}
6469
@@ -74,7 +79,12 @@ fun RemoteImage(part: FilePart, ctx: ImageLoadContext, modifier: Modifier = Modi
7479 val request = remember(part.source, part.url, ctx.baseUrl, ctx.basicAuthHeader) {
7580 ImageRequest.Builder(context)
7681 .data(model)
77− .apply { ctx.basicAuthHeader?.let { addHeader("Authorization", it) } }
82+ .apply {
83+ // Only send Basic auth over HTTPS; sending credentials over
84+ // cleartext HTTP would expose them on the network.
85+ val isHttps = (model as? String)?.startsWith("https://") == true
86+ if (isHttps) ctx.basicAuthHeader?.let { addHeader("Authorization", it) }
87+ }
7888 .crossfade(true)
7989 .build()
8090 }
app/src/main/java/soy/iko/opencode/ui/components/TimeFormat.kt+1
@@ -40,6 +40,7 @@ fun rememberRelativeTime(epochMillis: Long?, intervalMs: Long = 30_000L): String
4040 // on each interval, re-evaluating relativeTime() so the label stays fresh.
4141 var tick by remember { mutableLongStateOf(0L) }
4242 LaunchedEffect(epochMillis) {
43+ if (epochMillis == null) return@LaunchedEffect
4344 while (true) {
4445 delay(intervalMs)
4546 tick++
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserViewModel.kt+4−1
@@ -113,7 +113,10 @@ class FileBrowserViewModel(private val container: AppContainer) : ViewModel() {
113113 _state.value = _state.value.copy(results = emptyList(), searching = false)
114114 return
115115 }
116− val client = api ?: return
116+ val client = api ?: run {
117+ _state.value = _state.value.copy(searching = false, error = container.string(R.string.not_connected))
118+ return
119+ }
117120 searchJob = viewModelScope.launch {
118121 delay(NetworkConfig.fileSearchDebounceMs) // debounce
119122 _state.value = _state.value.copy(searching = true)
app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+8
@@ -121,6 +121,14 @@ fun ServerEditScreen(
121121 ) {
122122 Text(stringResource(R.string.save))
123123 }
124+ state.error?.let { message ->
125+ Text(
126+ message,
127+ color = androidx.compose.material3.MaterialTheme.colorScheme.error,
128+ style = androidx.compose.material3.MaterialTheme.typography.bodySmall,
129+ modifier = Modifier.fillMaxWidth(),
130+ )
131+ }
124132 }
125133 }
126134 }
app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+25−16
@@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.StateFlow
99import kotlinx.coroutines.flow.asStateFlow
1010import kotlinx.coroutines.flow.first
1111import kotlinx.coroutines.launch
12+import kotlinx.coroutines.withTimeoutOrNull
1213import java.util.UUID
1314
1415data class ServerEditState(
@@ -18,6 +19,7 @@ data class ServerEditState(
1819 val username: String = "",
1920 val password: String = "",
2021 val loaded: Boolean = false,
22+ val error: String? = null,
2123) {
2224 val canSave: Boolean get() = baseUrl.isNotBlank()
2325 val isNew: Boolean get() = id == null
@@ -34,7 +36,9 @@ class ServerEditViewModel(
3436 init {
3537 viewModelScope.launch {
3638 if (profileId != null) {
37− val existing = container.profileStore.profiles.first().firstOrNull { it.id == profileId }
39+ val existing = withTimeoutOrNull(5_000) {
40+ container.profileStore.profiles.first().firstOrNull { it.id == profileId }
41+ }
3842 if (existing != null) {
3943 _state.value = ServerEditState(
4044 id = existing.id,
@@ -59,21 +63,26 @@ class ServerEditViewModel(
5963 val s = _state.value
6064 if (!s.canSave) return
6165 viewModelScope.launch {
62− val existingLastUsed = if (s.id != null) {
63− container.profileStore.profiles.first()
64− .firstOrNull { it.id == s.id }?.lastUsed ?: 0L
65− } else 0L
66− container.profileStore.save(
67− ServerProfile(
68− id = s.id ?: UUID.randomUUID().toString(),
69− label = s.label.trim(),
70− baseUrl = s.baseUrl.trim(),
71− username = s.username.trim().takeIf { it.isNotBlank() },
72− password = s.password.trim().takeIf { it.isNotEmpty() },
73− lastUsed = existingLastUsed,
74− ),
75− )
76− onDone()
66+ val result = runCatching {
67+ val existingLastUsed = if (s.id != null) {
68+ withTimeoutOrNull(5_000) {
69+ container.profileStore.profiles.first()
70+ .firstOrNull { it.id == s.id }?.lastUsed
71+ } ?: 0L
72+ } else 0L
73+ container.profileStore.save(
74+ ServerProfile(
75+ id = s.id ?: UUID.randomUUID().toString(),
76+ label = s.label.trim(),
77+ baseUrl = s.baseUrl.trim(),
78+ username = s.username.trim().takeIf { it.isNotBlank() },
79+ password = s.password.trim().takeIf { it.isNotEmpty() },
80+ lastUsed = existingLastUsed,
81+ ),
82+ )
83+ }
84+ result.onSuccess { onDone() }
85+ .onFailure { _state.value = _state.value.copy(error = it.message ?: "Failed to save") }
7786 }
7887 }
7988}
app/src/main/java/soy/iko/opencode/ui/server/ServerListViewModel.kt+4−1
@@ -48,6 +48,9 @@ class ServerListViewModel(private val container: AppContainer) : ViewModel() {
4848 }
4949
5050 fun delete(profile: ServerProfile) {
51− viewModelScope.launch { container.profileStore.delete(profile.id) }
51+ viewModelScope.launch {
52+ runCatching { container.profileStore.delete(profile.id) }
53+ .onFailure { _error.value = container.friendlyError(it) }
54+ }
5255 }
5356}
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+12−9
@@ -53,6 +53,7 @@ import androidx.compose.runtime.LaunchedEffect
5353import androidx.compose.runtime.getValue
5454import androidx.compose.runtime.mutableStateOf
5555import androidx.compose.runtime.remember
56+import androidx.compose.runtime.saveable.rememberSaveable
5657import androidx.compose.runtime.setValue
5758import androidx.compose.ui.Alignment
5859import androidx.compose.ui.Modifier
@@ -96,8 +97,10 @@ fun SessionListScreen(
9697 val haptics = LocalHapticFeedback.current
9798 val snackbar = remember { SnackbarHostState() }
9899 var showServerMenu by remember { mutableStateOf(false) }
99− var pendingDelete by remember { mutableStateOf<Session?>(null) }
100− var pendingRename by remember { mutableStateOf<Session?>(null) }
100+ var pendingDeleteId by rememberSaveable { mutableStateOf<String?>(null) }
101+ var pendingRenameId by rememberSaveable { mutableStateOf<String?>(null) }
102+ val pendingDelete = pendingDeleteId?.let { id -> state.sessions.firstOrNull { it.id == id } }
103+ val pendingRename = pendingRenameId?.let { id -> state.sessions.firstOrNull { it.id == id } }
101104
102105 LaunchedEffect(transientError) {
103106 val msg = transientError ?: return@LaunchedEffect
@@ -233,8 +236,8 @@ fun SessionListScreen(
233236 preview = state.previews[session.id],
234237 unread = unread.contains(session.id),
235238 onClick = { onOpenSession(session.id) },
236− onRename = { pendingRename = session },
237− onDelete = { pendingDelete = session },
239+ onRename = { pendingRenameId = session.id },
240+ onDelete = { pendingDeleteId = session.id },
238241 modifier = Modifier.animateItem(),
239242 )
240243 }
@@ -248,18 +251,18 @@ fun SessionListScreen(
248251
249252 pendingDelete?.let { session ->
250253 AlertDialog(
251− onDismissRequest = { pendingDelete = null },
254+ onDismissRequest = { pendingDeleteId = null },
252255 title = { Text(stringResource(R.string.delete_session_title)) },
253256 text = { Text(stringResource(R.string.delete_session_text, session.displayTitle)) },
254257 confirmButton = {
255258 TextButton(onClick = {
256259 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
257− pendingDelete = null
260+ pendingDeleteId = null
258261 vm.deleteSession(session)
259262 }) { Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) }
260263 },
261264 dismissButton = {
262− TextButton(onClick = { pendingDelete = null }) { Text(stringResource(R.string.cancel)) }
265+ TextButton(onClick = { pendingDeleteId = null }) { Text(stringResource(R.string.cancel)) }
263266 },
264267 )
265268 }
@@ -271,10 +274,10 @@ fun SessionListScreen(
271274 onTitleChange = { title = it },
272275 onConfirm = {
273276 val newName = title.trim()
274− pendingRename = null
277+ pendingRenameId = null
275278 if (newName.isNotEmpty() && newName != session.title) vm.renameSession(session, newName)
276279 },
277− onDismiss = { pendingRename = null },
280+ onDismiss = { pendingRenameId = null },
278281 )
279282 }
280283}
app/src/main/java/soy/iko/opencode/ui/session/TwoPaneSessionChat.kt+6−1
@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Row
77import androidx.compose.foundation.layout.fillMaxSize
88import androidx.compose.foundation.layout.padding
99import androidx.compose.foundation.layout.widthIn
10+import androidx.activity.compose.BackHandler
1011import androidx.compose.material.icons.Icons
1112import androidx.compose.material.icons.automirrored.filled.Chat
1213import androidx.compose.material3.Icon
@@ -65,7 +66,10 @@ fun TwoPaneSessionChat(
6566 ) {
6667 SessionListScreen(
6768 container = container,
68− onOpenSession = { id -> selected = id },
69+ onOpenSession = { id ->
70+ container.consumePendingShare()?.let { container.draftStore.set(id, it) }
71+ selected = id
72+ },
6973 onDisconnect = onDisconnect,
7074 onOpenFiles = onOpenFiles,
7175 onOpenSettings = onOpenSettings,
@@ -77,6 +81,7 @@ fun TwoPaneSessionChat(
7781 if (sessionId == null) {
7882 EmptyDetail()
7983 } else {
84+ BackHandler { selected = null }
8085 key(sessionId) {
8186 ChatScreen(
8287 container = container,