git.iko.soy/opencode-android
on master help sign in
Fix 4 bugs: lost @path share on Open in chat, stale cross-server search results, file viewer screen flash on Open in chat, and defeated Crossfade between distinct error variants
commit0e7d36c53f16334afca3e947030121415856e3e4 authoriko <mail@iko.soy> dateTue 7 Jul 2026, 12:03 (3 months ago) parent79564d6
5 files changed+64−3
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+11 app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+6−1 app/src/main/java/soy/iko/opencode/ui/mcp/McpScreen.kt+12−1 app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchViewModel.kt+23 app/src/main/java/soy/iko/opencode/ui/usage/UsageScreen.kt+12−1
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+11
@@ -155,6 +155,17 @@ fun OpencodeApp(container: AppContainer) {
155155 val session = runCatchingCancellable { conn.repository.createSession() }.getOrNull()
156156 ?: return@LaunchedEffect
157157 if (!container.consumePendingNewSession()) return@LaunchedEffect
158+ // The file browser/viewer "Open in chat" action queues an @path reference as a
159+ // pending share alongside the new-session request. Unlike onOpenSession (which
160+ // injects it there), this effect creates the session directly and navigates to it
161+ // — so consume the share here and seed the new session's draft, otherwise the
162+ // text stays queued and silently misfires into the next session the user opens
163+ // from the list. Mirror the onOpenSession pattern: set in-memory synchronously
164+ // so ChatScreen sees it on first composition, then persist to disk in the back.
165+ container.consumePendingShare()?.let { shareText ->
166+ container.draftStore.setImmediate(session.id, shareText)
167+ scope.launch { runCatchingCancellable { container.draftStore.set(session.id, shareText) } }
168+ }
158169 if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
159170 navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
160171 }
app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+6−1
@@ -399,9 +399,14 @@ fun FileViewScreen(
399399 // Start a new session with this file attached as an @path reference,
400400 // mirroring the file browser row's "Open in chat" action — the viewer is
401401 // the more natural place for it since the user is already looking at the file.
402+ // Don't call onBack() here (unlike a plain dismiss): the pendingNewSession
403+ // effect in OpencodeApp creates the session and navigates to it, so calling
404+ // onBack() first would pop/clear this screen prematurely and flash through
405+ // the file browser (single-pane) or an empty detail pane (two-pane) before
406+ // the chat navigation fires. The browser's "Open in chat" doesn't call onBack
407+ // either — this mirrors that.
402408 container.setPendingShare("@$path")
403409 container.requestNewSession()
404− onBack()
405410 },
406411 )
407412 },
app/src/main/java/soy/iko/opencode/ui/mcp/McpScreen.kt+12−1
@@ -147,7 +147,18 @@ fun McpScreen(container: AppContainer, onBack: () -> Unit) {
147147 // layer keeps rendering the OLD state type while it fades out — reading `state`
148148 // directly would recompose both layers to the latest content and defeat the
149149 // crossfade into an instant snap.
150− val stateKey = state::class.simpleName
150+ //
151+ // Include the Error message in the key so a transition between two distinct
152+ // errors (e.g. "not connected" -> "auth failed") crossfades instead of snapping:
153+ // the class-name-only key stays constant across Error variants, so Crossfade
154+ // treats them as the same target and doesn't animate. The other states carry no
155+ // data, so their class name alone is a stable key.
156+ val stateKey = state.let { s ->
157+ when (s) {
158+ is McpViewModel.State.Error -> "Error:${s.message}"
159+ else -> s::class.simpleName
160+ }
161+ }
151162 Crossfade(
152163 targetState = stateKey,
153164 animationSpec = tween(soy.iko.opencode.data.network.NetworkConfig.motionFadeDurationMs.toInt()),
app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchViewModel.kt+23
@@ -26,6 +26,7 @@ import kotlinx.coroutines.delay
2626import kotlinx.coroutines.flow.MutableStateFlow
2727import kotlinx.coroutines.flow.StateFlow
2828import kotlinx.coroutines.flow.asStateFlow
29+import kotlinx.coroutines.flow.collectLatest
2930import kotlinx.coroutines.flow.update
3031import kotlinx.coroutines.launch
3132import kotlinx.coroutines.sync.Semaphore
@@ -102,6 +103,28 @@ class GlobalSearchViewModel(private val container: AppContainer) : ViewModel() {
102103 @Volatile private var sessionsCache: List<Session>? = null
103104 private val messageCache = ConcurrentHashMap<String, List<MessageWithParts>>()
104105
106+ init {
107+ // Observe the active connection so a server switch clears the cross-server caches
108+ // and re-runs the current query against the new server. Without this, a search run
109+ // on server A leaves sessionsCache / messageCache populated with A's sessions, so
110+ // re-running the same query on server B reuses A's cached list — returning sessions
111+ // that don't exist on B (opening them navigates to a non-existent conversation) and
112+ // B's history for sessions that happen to share an id. Also covers the reconnect
113+ // window: if the screen opens while activeConnection.value is null, the first search
114+ // shows "not connected" and never auto-retries once the connection lands. Mirrors
115+ // FileBrowserViewModel's activeConnection.collectLatest pattern.
116+ viewModelScope.launch {
117+ container.activeConnection.collectLatest { conn ->
118+ sessionsCache = null
119+ messageCache.clear()
120+ if (conn != null) {
121+ val trimmed = _state.value.query.trim()
122+ if (trimmed.length >= NetworkConfig.minSearchQueryLength) runSearch(trimmed)
123+ }
124+ }
125+ }
126+ }
127+
105128 fun setQuery(query: String) {
106129 _state.update { it.copy(query = query) }
107130 searchJob?.cancel()
app/src/main/java/soy/iko/opencode/ui/usage/UsageScreen.kt+12−1
@@ -113,7 +113,18 @@ fun UsageScreen(container: AppContainer, onBack: () -> Unit, onOpenSession: (Str
113113 // layer keeps rendering the OLD state type while it fades out — reading `state`
114114 // directly would recompose both layers to the latest content and defeat the
115115 // crossfade into an instant snap.
116− val stateKey = state::class.simpleName
116+ //
117+ // Include the Error message in the key so a transition between two distinct
118+ // errors (e.g. "not connected" -> "auth failed") crossfades instead of snapping:
119+ // the class-name-only key stays constant across Error variants, so Crossfade
120+ // treats them as the same target and doesn't animate. The other states carry no
121+ // data, so their class name alone is a stable key.
122+ val stateKey = state.let { s ->
123+ when (s) {
124+ is UsageViewModel.State.Error -> "Error:${s.message}"
125+ else -> s::class.simpleName
126+ }
127+ }
117128 Crossfade(
118129 targetState = stateKey,
119130 animationSpec = tween(NetworkConfig.motionFadeDurationMs.toInt()),