git.iko.soy/opencode-android
on master help sign in
Fix FileBrowserViewModel state race, rotation state loss, main-thread markdown export, and fragile TimeFormat recomposition
commit894896258329532853e59489d0d1287b3313eadc authoriko <mail@iko.soy> dateTue 30 Jun 2026, 09:54 (3 months ago) parentd643c13
5 files changed+43−25
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+12−6 app/src/main/java/soy/iko/opencode/ui/chat/PermissionDialog.kt+2−1 app/src/main/java/soy/iko/opencode/ui/components/TimeFormat.kt+13−3 app/src/main/java/soy/iko/opencode/ui/file/FileBrowserViewModel.kt+15−14 app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+1−1
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+12−6
@@ -82,8 +82,10 @@ import androidx.compose.ui.text.input.ImeAction
8282import androidx.compose.ui.unit.dp
8383import androidx.lifecycle.compose.collectAsStateWithLifecycle
8484import androidx.lifecycle.viewmodel.compose.viewModel
85+import kotlinx.coroutines.Dispatchers
8586import kotlinx.coroutines.flow.collectLatest
8687import kotlinx.coroutines.launch
88+import kotlinx.coroutines.withContext
8789import soy.iko.opencode.data.network.EventStreamClient
8890import soy.iko.opencode.di.AppContainer
8991import soy.iko.opencode.R
@@ -244,13 +246,17 @@ fun ChatScreen(
244246 actions = {
245247 IconButton(
246248 onClick = {
247− val md = buildConversationMarkdown(messages, sessionTitle)
248− val send = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
249− type = "text/markdown"
250− putExtra(android.content.Intent.EXTRA_SUBJECT, sessionTitle ?: defaultShareSubject)
251− putExtra(android.content.Intent.EXTRA_TEXT, md)
249+ scope.launch {
250+ val md = withContext(Dispatchers.Default) {
251+ buildConversationMarkdown(messages, sessionTitle)
252+ }
253+ val send = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
254+ type = "text/markdown"
255+ putExtra(android.content.Intent.EXTRA_SUBJECT, sessionTitle ?: defaultShareSubject)
256+ putExtra(android.content.Intent.EXTRA_TEXT, md)
257+ }
258+ runCatching { shareContext.startActivity(android.content.Intent.createChooser(send, shareLabel)) }
252259 }
253− runCatching { shareContext.startActivity(android.content.Intent.createChooser(send, shareLabel)) }
254260 },
255261 enabled = messages.isNotEmpty(),
256262 ) {
app/src/main/java/soy/iko/opencode/ui/chat/PermissionDialog.kt+2−1
@@ -18,6 +18,7 @@ import androidx.compose.material3.TextButton
1818import androidx.compose.runtime.Composable
1919import androidx.compose.runtime.mutableStateOf
2020import androidx.compose.runtime.remember
21+import androidx.compose.runtime.saveable.rememberSaveable
2122import androidx.compose.runtime.getValue
2223import androidx.compose.runtime.setValue
2324import androidx.compose.ui.Modifier
@@ -42,7 +43,7 @@ fun PermissionDialog(
4243 val haptics = LocalHapticFeedback.current
4344 // Guard against double-respond: back press + button tap, or rapid double-tap,
4445 // could call onRespond twice. Once a response is sent, subsequent calls are no-ops.
45− var responded by remember { mutableStateOf(false) }
46+ var responded by rememberSaveable { mutableStateOf(false) }
4647 val respond: (PermissionResponse) -> Unit = { response ->
4748 if (!responded) {
4849 responded = true
app/src/main/java/soy/iko/opencode/ui/components/TimeFormat.kt+13−3
@@ -2,6 +2,7 @@ package soy.iko.opencode.ui.components
22
33import androidx.compose.runtime.Composable
44import androidx.compose.runtime.LaunchedEffect
5+import androidx.compose.runtime.derivedStateOf
56import androidx.compose.runtime.getValue
67import androidx.compose.runtime.mutableLongStateOf
78import androidx.compose.runtime.remember
@@ -59,7 +60,16 @@ fun rememberRelativeTime(epochMillis: Long?, intervalMs: Long = 30_000L): String
5960 lifecycleOwner.lifecycle.removeObserver(observer)
6061 }
6162 }
62− // Read tick so Compose tracks it as a dependency and recomposes when it changes.
63− @Suppress("UNUSED_EXPRESSION") tick
64− return relativeTime(epochMillis)
63+ // derivedStateOf reads tick so Compose recomposes when it changes, and recomputes
64+ // the relative-time string from the current wall clock on each tick.
65+ // Keying remember on epochMillis ensures a new derivedStateOf is created when the
66+ // timestamp changes, so the new value is reflected immediately.
67+ val formatted by remember(epochMillis) {
68+ derivedStateOf {
69+ // Read tick to create a recomposition dependency on the timer.
70+ tick
71+ relativeTime(epochMillis)
72+ }
73+ }
74+ return formatted
6575}
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserViewModel.kt+15−14
@@ -16,6 +16,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
1616import kotlinx.coroutines.flow.StateFlow
1717import kotlinx.coroutines.flow.asStateFlow
1818import kotlinx.coroutines.flow.collectLatest
19+import kotlinx.coroutines.flow.update
1920import kotlinx.coroutines.launch
2021
2122@Immutable
@@ -76,7 +77,7 @@ class FileBrowserViewModel(private val container: AppContainer) : ViewModel() {
7677 statusJob = viewModelScope.launch {
7778 runCatchingCancellable { client.fileStatus() }
7879 .onSuccess { entries ->
79− _state.value = _state.value.copy(statusMap = entries.associateBy { it.path })
80+ _state.update { it.copy(statusMap = entries.associateBy { it.path }) }
8081 }
8182 .onFailure {
8283 // VCS status is best-effort: don't block the file browser, but surface
@@ -88,32 +89,32 @@ class FileBrowserViewModel(private val container: AppContainer) : ViewModel() {
8889
8990 fun open(path: String) {
9091 val client = api ?: run {
91− _state.value = _state.value.copy(
92+ _state.update { it.copy(
9293 path = path,
9394 loading = false,
9495 error = container.string(R.string.not_connected),
95− )
96+ ) }
9697 return
9798 }
98− _state.value = _state.value.copy(
99+ _state.update { it.copy(
99100 path = path,
100101 query = "",
101102 results = emptyList(),
102103 searching = false,
103104 loading = true,
104105 error = null,
105− )
106+ ) }
106107 openJob?.cancel()
107108 searchJob?.cancel()
108109 openJob = viewModelScope.launch {
109110 runCatchingCancellable { client.listDirectory(path) }
110111 .onSuccess { entries ->
111− _state.value = _state.value.copy(
112+ _state.update { it.copy(
112113 entries = entries.sortedWith(compareByDescending<FileNode> { it.isDirectory }.thenBy { it.name.lowercase() }),
113114 loading = false,
114− )
115+ ) }
115116 }
116− .onFailure { _state.value = _state.value.copy(loading = false, error = container.friendlyError(it)) }
117+ .onFailure { e -> _state.update { it.copy(loading = false, error = container.friendlyError(e)) } }
117118 }
118119 }
119120
@@ -126,23 +127,23 @@ class FileBrowserViewModel(private val container: AppContainer) : ViewModel() {
126127 }
127128
128129 fun setQuery(query: String) {
129− _state.value = _state.value.copy(query = query, error = null)
130+ _state.update { it.copy(query = query, error = null) }
130131 searchJob?.cancel()
131132 openJob?.cancel()
132133 if (query.isBlank()) {
133− _state.value = _state.value.copy(results = emptyList(), searching = false)
134+ _state.update { it.copy(results = emptyList(), searching = false) }
134135 return
135136 }
136137 val client = api ?: run {
137− _state.value = _state.value.copy(searching = false, error = container.string(R.string.not_connected))
138+ _state.update { it.copy(searching = false, error = container.string(R.string.not_connected)) }
138139 return
139140 }
140141 searchJob = viewModelScope.launch {
141142 delay(NetworkConfig.fileSearchDebounceMs) // debounce
142− _state.value = _state.value.copy(searching = true)
143+ _state.update { it.copy(searching = true) }
143144 runCatchingCancellable { client.findFiles(query) }
144− .onSuccess { _state.value = _state.value.copy(results = it, searching = false) }
145− .onFailure { _state.value = _state.value.copy(searching = false, error = container.friendlyError(it)) }
145+ .onSuccess { results -> _state.update { it.copy(results = results, searching = false) } }
146+ .onFailure { e -> _state.update { it.copy(searching = false, error = container.friendlyError(e)) } }
146147 }
147148 }
148149}
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+1−1
@@ -287,7 +287,7 @@ fun SessionListScreen(
287287 }
288288
289289 pendingRename?.let { session ->
290− var title by remember(session.id) { mutableStateOf(session.title ?: "") }
290+ var title by rememberSaveable(session.id) { mutableStateOf(session.title ?: "") }
291291 RenameSessionDialog(
292292 title = title,
293293 onTitleChange = { title = it },