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
0e7d36ciko
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+11
@@ -155,6 +155,17 @@ fun OpencodeApp(container: AppContainer) {
155 val session = runCatchingCancellable { conn.repository.createSession() }.getOrNull()
155 val session = runCatchingCancellable { conn.repository.createSession() }.getOrNull()
156 ?: return@LaunchedEffect
156 ?: return@LaunchedEffect
157 if (!container.consumePendingNewSession()) return@LaunchedEffect
157 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+ }
158 if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
169 if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
159 navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
170 navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
160 }
171 }
app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+6−1
@@ -399,9 +399,14 @@ fun FileViewScreen(
399 // Start a new session with this file attached as an @path reference,
399 // Start a new session with this file attached as an @path reference,
400 // mirroring the file browser row's "Open in chat" action — the viewer is
400 // mirroring the file browser row's "Open in chat" action — the viewer is
401 // the more natural place for it since the user is already looking at the file.
401 // 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.
402 container.setPendingShare("@$path")
408 container.setPendingShare("@$path")
403 container.requestNewSession()
409 container.requestNewSession()
404− onBack()
405 },
410 },
406 )
411 )
407 },
412 },
app/src/main/java/soy/iko/opencode/ui/mcp/McpScreen.kt+12−1
@@ -147,7 +147,18 @@ fun McpScreen(container: AppContainer, onBack: () -> Unit) {
147 // layer keeps rendering the OLD state type while it fades out — reading `state`
147 // layer keeps rendering the OLD state type while it fades out — reading `state`
148 // directly would recompose both layers to the latest content and defeat the
148 // directly would recompose both layers to the latest content and defeat the
149 // crossfade into an instant snap.
149 // crossfade into an instant snap.
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.
150− val stateKey = state::class.simpleName
156+ val stateKey = state.let { s ->
157+ when (s) {
158+ is McpViewModel.State.Error -> "Error:${s.message}"
159+ else -> s::class.simpleName
160+ }
161+ }
151 Crossfade(
162 Crossfade(
152 targetState = stateKey,
163 targetState = stateKey,
153 animationSpec = tween(soy.iko.opencode.data.network.NetworkConfig.motionFadeDurationMs.toInt()),
164 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
26import kotlinx.coroutines.flow.MutableStateFlow
26import kotlinx.coroutines.flow.MutableStateFlow
27import kotlinx.coroutines.flow.StateFlow
27import kotlinx.coroutines.flow.StateFlow
28import kotlinx.coroutines.flow.asStateFlow
28import kotlinx.coroutines.flow.asStateFlow
29+import kotlinx.coroutines.flow.collectLatest
29import kotlinx.coroutines.flow.update
30import kotlinx.coroutines.flow.update
30import kotlinx.coroutines.launch
31import kotlinx.coroutines.launch
31import kotlinx.coroutines.sync.Semaphore
32import kotlinx.coroutines.sync.Semaphore
@@ -102,6 +103,28 @@ class GlobalSearchViewModel(private val container: AppContainer) : ViewModel() {
102 @Volatile private var sessionsCache: List<Session>? = null
103 @Volatile private var sessionsCache: List<Session>? = null
103 private val messageCache = ConcurrentHashMap<String, List<MessageWithParts>>()
104 private val messageCache = ConcurrentHashMap<String, List<MessageWithParts>>()
104
105
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+
105 fun setQuery(query: String) {
128 fun setQuery(query: String) {
106 _state.update { it.copy(query = query) }
129 _state.update { it.copy(query = query) }
107 searchJob?.cancel()
130 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
113 // layer keeps rendering the OLD state type while it fades out — reading `state`
113 // layer keeps rendering the OLD state type while it fades out — reading `state`
114 // directly would recompose both layers to the latest content and defeat the
114 // directly would recompose both layers to the latest content and defeat the
115 // crossfade into an instant snap.
115 // crossfade into an instant snap.
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.
116− val stateKey = state::class.simpleName
122+ val stateKey = state.let { s ->
123+ when (s) {
124+ is UsageViewModel.State.Error -> "Error:${s.message}"
125+ else -> s::class.simpleName
126+ }
127+ }
117 Crossfade(
128 Crossfade(
118 targetState = stateKey,
129 targetState = stateKey,
119 animationSpec = tween(NetworkConfig.motionFadeDurationMs.toInt()),
130 animationSpec = tween(NetworkConfig.motionFadeDurationMs.toInt()),