git.iko.soy/opencode-android
on master help sign in
Fix stale connection capture, add REST retry, IME handling, accessibility, and empty states across the app
commit17bd7b00faef4be762a1426181ca0d0cfdde0349 authoriko <mail@iko.soy> dateMon 29 Jun 2026, 12:01 (3 months ago) parentd9842e3
23 files changed+477−162
app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+3−1 app/src/main/java/soy/iko/opencode/data/network/HttpClientFactory.kt−1 app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.kt+29−4 app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+18−12 app/src/main/java/soy/iko/opencode/di/AppContainer.kt+22 app/src/main/java/soy/iko/opencode/ui/chat/AgentPickerSheet.kt+52−12 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+55−4 app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+41−18 app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+1−2 app/src/main/java/soy/iko/opencode/ui/chat/ModelPickerSheet.kt+31−6 app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+28−8 app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+10−2 app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+44−17 app/src/main/java/soy/iko/opencode/ui/file/FileBrowserViewModel.kt+4−3 app/src/main/java/soy/iko/opencode/ui/file/FileViewModel.kt+3−2 app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+9−1 app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+12−3 app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+53−52 app/src/main/java/soy/iko/opencode/ui/server/ServerListViewModel.kt+1−1 app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+16−3 app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+5−5 app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+24−5 app/src/main/res/values/strings.xml+16
app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+3−1
@@ -1,6 +1,7 @@
11package soy.iko.opencode.data.network
22
33import soy.iko.opencode.data.model.BusEvent
4+import android.util.Log
45import io.ktor.client.HttpClient
56import io.ktor.client.plugins.sse.sse
67import kotlinx.coroutines.CancellationException
@@ -72,7 +73,7 @@ class EventStreamClient(
7273 } catch (c: CancellationException) {
7374 throw c
7475 } catch (t: Throwable) {
75− // transient error — fall through to reconnect
76+ Log.w(TAG, "SSE stream error, will retry", t)
7677 }
7778 _state.value = ConnectionState.Disconnected
7879 if (!isActive) break
@@ -87,6 +88,7 @@ class EventStreamClient(
8788 }
8889
8990 private companion object {
91+ private const val TAG = "EventStream"
9092 const val INITIAL_BACKOFF_MS = 500L
9193 const val MAX_BACKOFF_MS = 10_000L
9294 }
app/src/main/java/soy/iko/opencode/data/network/HttpClientFactory.kt−1
@@ -42,7 +42,6 @@ object HttpClientFactory {
4242 install(SSE)
4343
4444 install(HttpTimeout) {
45− connectTimeoutMillis = 30_000
4645 requestTimeoutMillis = 60_000
4746 }
4847
app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.kt+29−4
@@ -26,6 +26,7 @@ import io.ktor.client.request.post
2626import io.ktor.client.request.setBody
2727import io.ktor.http.ContentType
2828import io.ktor.http.contentType
29+import kotlinx.coroutines.delay
2930
3031/**
3132 * Request/response wrapper over the opencode REST endpoints. The long-lived `/event`
@@ -33,10 +34,9 @@ import io.ktor.http.contentType
3334 */
3435class OpencodeApiClient(private val client: HttpClient) {
3536
36− /** Lightweight connectivity check used by the connect screen. */
37− suspend fun ping(): Boolean {
37+ /** Lightweight connectivity check. Throws on non-2xx / network failure. */
38+ suspend fun ping() {
3839 client.get("global/health")
39− return true
4040 }
4141
4242 suspend fun listSessions(): List<Session> =
@@ -66,7 +66,7 @@ class OpencodeApiClient(private val client: HttpClient) {
6666 text: String,
6767 model: ModelRef? = null,
6868 agent: String? = null,
69− ): MessageWithParts =
69+ ): MessageWithParts = withRetry {
7070 client.post("session/$sessionId/message") {
7171 contentType(ContentType.Application.Json)
7272 setBody(
@@ -77,6 +77,7 @@ class OpencodeApiClient(private val client: HttpClient) {
7777 ),
7878 )
7979 }.body()
80+ }
8081
8182 suspend fun abort(sessionId: String) {
8283 client.post("session/$sessionId/abort")
@@ -129,3 +130,27 @@ class OpencodeApiClient(private val client: HttpClient) {
129130 suspend fun fileStatus(): List<FileStatusEntry> =
130131 client.get("file/status").body()
131132}
133+
134+/**
135+ * Retry a suspending block up to [maxAttempts] times with exponential backoff.
136+ * Cancellation exceptions are re-thrown immediately. Non-2xx HTTP responses throw
137+ * (because [HttpClientFactory] sets `expectSuccess = true`) and are retried.
138+ */
139+private suspend fun <T> withRetry(
140+ maxAttempts: Int = 3,
141+ initialDelayMs: Long = 500L,
142+ block: suspend () -> T,
143+): T {
144+ var lastError: Throwable? = null
145+ for (attempt in 1..maxAttempts) {
146+ try {
147+ return block()
148+ } catch (c: kotlinx.coroutines.CancellationException) {
149+ throw c
150+ } catch (t: Throwable) {
151+ lastError = t
152+ if (attempt < maxAttempts) delay(initialDelayMs * (1 shl (attempt - 1)))
153+ }
154+ }
155+ throw lastError ?: IllegalStateException("withRetry failed without error")
156+}
app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+18−12
@@ -1,6 +1,5 @@
11package soy.iko.opencode.data.repo
22
3−import soy.iko.opencode.data.model.AssistantMessage
43import soy.iko.opencode.data.model.BusEvent
54import soy.iko.opencode.data.model.MessagePartRemoved
65import soy.iko.opencode.data.model.MessagePartUpdated
@@ -11,6 +10,7 @@ import soy.iko.opencode.data.model.ModelRef
1110import soy.iko.opencode.data.model.Part
1211import soy.iko.opencode.data.model.SessionError
1312import soy.iko.opencode.data.model.SessionIdle
13+import soy.iko.opencode.data.model.UnknownMessage
1414import soy.iko.opencode.data.network.EventStreamClient
1515import soy.iko.opencode.data.network.OpencodeApiClient
1616import kotlinx.coroutines.flow.Flow
@@ -36,8 +36,12 @@ class SessionRepository(
3636 suspend fun sendPrompt(sessionId: String, text: String, model: ModelRef?, agent: String? = null) =
3737 api.sendPrompt(sessionId, text, model, agent)
3838
39− suspend fun runCommand(sessionId: String, command: String, agent: String? = null) =
40− api.runCommand(sessionId, command, agent = agent)
39+ suspend fun runCommand(
40+ sessionId: String,
41+ command: String,
42+ arguments: String = "",
43+ agent: String? = null,
44+ ) = api.runCommand(sessionId, command, arguments = arguments, agent = agent)
4145
4246 /**
4347 * A live, ordered view of [sessionId]'s messages. Begins collecting the event
@@ -48,22 +52,24 @@ class SessionRepository(
4852 val store = MessageStore()
4953 val lock = Mutex()
5054
51− suspend fun publish() = send(store.snapshot())
55+ // Collect the snapshot under the lock, then publish outside it so a slow
56+ // downstream collector can't stall event processing.
57+ suspend fun publish() {
58+ val snapshot = lock.withLock { store.snapshot() }
59+ send(snapshot)
60+ }
5261
5362 // Subscribe to events first so we don't miss early deltas during the initial load.
5463 val job = launch {
5564 eventStream.events.collect { event ->
56− lock.withLock {
57− if (store.reduce(sessionId, event)) publish()
58− }
65+ val changed = lock.withLock { store.reduce(sessionId, event) }
66+ if (changed) publish()
5967 }
6068 }
6169
6270 val initial = runCatching { api.listMessages(sessionId) }.getOrDefault(emptyList())
63− lock.withLock {
64− store.seed(initial)
65− publish()
66− }
71+ lock.withLock { store.seed(initial) }
72+ publish()
6773
6874 // Keep the flow alive until the collector cancels; the launched job is torn down with it.
6975 job.join()
@@ -144,7 +150,7 @@ internal class MessageStore {
144150 }
145151 messages[messageId] = current?.copy(parts = newParts)
146152 ?: MessageWithParts(
147− info = AssistantMessage(id = messageId, sessionID = part.sessionID ?: ""),
153+ info = UnknownMessage(id = messageId, sessionID = part.sessionID ?: ""),
148154 parts = newParts,
149155 )
150156 return true
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+22
@@ -8,6 +8,7 @@ import soy.iko.opencode.data.model.ServerProfile
88import soy.iko.opencode.data.repo.DraftStore
99import soy.iko.opencode.data.repo.ProfileStore
1010import soy.iko.opencode.data.repo.SettingsStore
11+import soy.iko.opencode.R
1112import kotlinx.coroutines.CoroutineScope
1213import kotlinx.coroutines.Dispatchers
1314import kotlinx.coroutines.SupervisorJob
@@ -58,6 +59,27 @@ class AppContainer(context: Context) {
5859 fun string(id: Int, vararg formatArgs: Any): String =
5960 if (formatArgs.isEmpty()) appContext.getString(id) else appContext.getString(id, *formatArgs)
6061
62+ /**
63+ * Convert a throwable into a user-facing message. Ktor/OkHttp exceptions expose
64+ * developer-oriented detail (URL schemes, internal state) that is not useful to
65+ * end users, so we collapse those to a generic "could not reach server" message.
66+ */
67+ fun friendlyError(t: Throwable): String {
68+ val msg = t.message.orEmpty()
69+ val isNetworkError = t::class.qualifiedName?.let { className ->
70+ className.startsWith("io.ktor.") || className.startsWith("okhttp3.") ||
71+ className.contains("HttpException", ignoreCase = true) ||
72+ className.contains("SocketTimeout", ignoreCase = true) ||
73+ className.contains("ConnectException", ignoreCase = true) ||
74+ className.contains("UnknownHost", ignoreCase = true)
75+ } ?: false
76+ return if (isNetworkError) {
77+ string(R.string.error_not_reachable, activeConnection.value?.profile?.baseUrl.orEmpty())
78+ } else {
79+ msg.ifBlank { string(R.string.error_generic) }
80+ }
81+ }
82+
6183 init {
6284 registerNetworkMonitor()
6385 appScope.launch { autoConnect() }
app/src/main/java/soy/iko/opencode/ui/chat/AgentPickerSheet.kt+52−12
@@ -2,18 +2,28 @@ package soy.iko.opencode.ui.chat
22
33import androidx.compose.foundation.clickable
44import androidx.compose.foundation.layout.Column
5+import androidx.compose.foundation.layout.Row
56import androidx.compose.foundation.layout.fillMaxWidth
67import androidx.compose.foundation.layout.heightIn
78import androidx.compose.foundation.layout.padding
9+import androidx.compose.foundation.layout.size
810import androidx.compose.foundation.lazy.LazyColumn
911import androidx.compose.foundation.lazy.items
12+import androidx.compose.material.icons.Icons
13+import androidx.compose.material.icons.filled.Check
1014import androidx.compose.material3.ExperimentalMaterial3Api
15+import androidx.compose.material3.Icon
1116import androidx.compose.material3.MaterialTheme
1217import androidx.compose.material3.ModalBottomSheet
1318import androidx.compose.material3.Text
1419import androidx.compose.runtime.Composable
20+import androidx.compose.ui.Alignment
1521import androidx.compose.ui.Modifier
1622import androidx.compose.ui.res.stringResource
23+import androidx.compose.ui.semantics.Role
24+import androidx.compose.ui.semantics.role
25+import androidx.compose.ui.semantics.selected
26+import androidx.compose.ui.semantics.semantics
1727import androidx.compose.ui.unit.dp
1828import soy.iko.opencode.data.model.Agent
1929import soy.iko.opencode.R
@@ -46,16 +56,31 @@ fun AgentPickerSheet(
4656 Column(
4757 modifier = Modifier
4858 .fillMaxWidth()
59+ .semantics(mergeDescendants = true) {
60+ role = Role.RadioButton
61+ if (selected == null) this.selected = true
62+ }
4963 .clickable { onSelect(null); onDismiss() }
5064 .padding(horizontal = 24.dp, vertical = 12.dp),
5165 ) {
66+ Row(verticalAlignment = Alignment.CenterVertically) {
67+ Text(
68+ stringResource(R.string.default_agent),
69+ style = MaterialTheme.typography.bodyLarge,
70+ color = if (selected == null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
71+ modifier = Modifier.weight(1f),
72+ )
73+ if (selected == null) {
74+ Icon(
75+ Icons.Filled.Check,
76+ contentDescription = null,
77+ tint = MaterialTheme.colorScheme.primary,
78+ modifier = Modifier.size(18.dp),
79+ )
80+ }
81+ }
5282 Text(
53− stringResource(R.string.default_agent),
54− style = MaterialTheme.typography.bodyLarge,
55− color = if (selected == null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
56− )
57− Text(
58− stringResource(R.string.default_agent_desc) + if (selected == null) " ✓" else "",
83+ stringResource(R.string.default_agent_desc),
5984 style = MaterialTheme.typography.bodySmall,
6085 color = MaterialTheme.colorScheme.onSurfaceVariant,
6186 )
@@ -66,16 +91,31 @@ fun AgentPickerSheet(
6691 Column(
6792 modifier = Modifier
6893 .fillMaxWidth()
94+ .semantics(mergeDescendants = true) {
95+ role = Role.RadioButton
96+ if (isSelected) this.selected = true
97+ }
6998 .clickable { onSelect(agent); onDismiss() }
7099 .padding(horizontal = 24.dp, vertical = 12.dp),
71100 ) {
101+ Row(verticalAlignment = Alignment.CenterVertically) {
102+ Text(
103+ agent.name,
104+ style = MaterialTheme.typography.bodyLarge,
105+ color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
106+ modifier = Modifier.weight(1f),
107+ )
108+ if (isSelected) {
109+ Icon(
110+ Icons.Filled.Check,
111+ contentDescription = null,
112+ tint = MaterialTheme.colorScheme.primary,
113+ modifier = Modifier.size(18.dp),
114+ )
115+ }
116+ }
72117 Text(
73− agent.name,
74− style = MaterialTheme.typography.bodyLarge,
75− color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
76− )
77− Text(
78− agent.displayDescription + if (isSelected) " ✓" else "",
118+ agent.displayDescription,
79119 style = MaterialTheme.typography.bodySmall,
80120 color = MaterialTheme.colorScheme.onSurfaceVariant,
81121 )
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+55−4
@@ -9,16 +9,22 @@ import androidx.compose.foundation.layout.Column
99import androidx.compose.foundation.layout.PaddingValues
1010import androidx.compose.foundation.layout.Row
1111import androidx.compose.foundation.layout.Spacer
12+import androidx.compose.foundation.layout.WindowInsets
1213import androidx.compose.foundation.layout.fillMaxSize
1314import androidx.compose.foundation.layout.fillMaxWidth
15+import androidx.compose.foundation.layout.imePadding
16+import androidx.compose.foundation.layout.navigationBars
1417import androidx.compose.foundation.layout.padding
1518import androidx.compose.foundation.layout.size
19+import androidx.compose.foundation.layout.union
20+import androidx.compose.foundation.layout.windowInsetsPadding
1621import androidx.compose.foundation.lazy.LazyColumn
1722import androidx.compose.foundation.lazy.items
1823import androidx.compose.foundation.lazy.rememberLazyListState
1924import androidx.compose.material.icons.Icons
2025import androidx.compose.material.icons.automirrored.filled.ArrowBack
2126import androidx.compose.material.icons.automirrored.filled.Send
27+import androidx.compose.material.icons.filled.AutoAwesome
2228import androidx.compose.material.icons.filled.KeyboardArrowDown
2329import androidx.compose.material.icons.filled.SmartToy
2430import androidx.compose.material.icons.filled.Stop
@@ -48,6 +54,8 @@ import androidx.compose.runtime.mutableStateOf
4854import androidx.compose.runtime.remember
4955import androidx.compose.runtime.rememberCoroutineScope
5056import androidx.compose.runtime.setValue
57+import androidx.compose.foundation.text.KeyboardActions
58+import androidx.compose.foundation.text.KeyboardOptions
5159import androidx.compose.ui.Alignment
5260import androidx.compose.ui.Modifier
5361import androidx.compose.ui.input.key.Key
@@ -58,6 +66,10 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent
5866import androidx.compose.ui.input.key.type
5967import androidx.compose.ui.platform.LocalHapticFeedback
6068import androidx.compose.ui.res.stringResource
69+import androidx.compose.ui.semantics.Role
70+import androidx.compose.ui.semantics.role
71+import androidx.compose.ui.semantics.semantics
72+import androidx.compose.ui.text.input.ImeAction
6173import androidx.compose.ui.unit.dp
6274import androidx.lifecycle.compose.collectAsStateWithLifecycle
6375import androidx.lifecycle.viewmodel.compose.viewModel
@@ -137,7 +149,10 @@ fun ChatScreen(
137149 topBar = {
138150 TopAppBar(
139151 title = {
140− Column(modifier = Modifier.clickable(enabled = models.isNotEmpty()) { showModelPicker = true }) {
152+ Column(modifier = Modifier
153+ .clickable(enabled = models.isNotEmpty()) { showModelPicker = true }
154+ .semantics { role = Role.Button }
155+ ) {
141156 Text(sessionTitle ?: stringResource(R.string.session))
142157 Text(
143158 buildString {
@@ -193,6 +208,11 @@ fun ChatScreen(
193208 state = connectionState,
194209 modifier = Modifier.align(Alignment.TopCenter),
195210 )
211+ if (messages.isEmpty() && !running) {
212+ EmptyConversation(
213+ modifier = Modifier.align(Alignment.Center),
214+ )
215+ } else {
196216 LazyColumn(
197217 state = listState,
198218 modifier = Modifier.fillMaxSize(),
@@ -222,11 +242,12 @@ fun ChatScreen(
222242 scope.launch { listState.animateScrollToItem(messages.lastIndex) }
223243 }
224244 },
225− icon = { Icon(Icons.Filled.KeyboardArrowDown, contentDescription = null) },
245+ icon = { Icon(Icons.Filled.KeyboardArrowDown, contentDescription = stringResource(R.string.latest)) },
226246 text = { Text(stringResource(R.string.latest)) },
227247 modifier = Modifier.padding(end = 16.dp, bottom = 16.dp),
228248 )
229249 }
250+ }
230251 }
231252 }
232253 }
@@ -323,9 +344,11 @@ private fun ChatInputBar(
323344 onSend: () -> Unit,
324345 onAbort: () -> Unit,
325346) {
326− Surface(tonalElevation = 3.dp) {
347+ Surface(tonalElevation = 3.dp, modifier = Modifier.imePadding()) {
327348 Row(
328− modifier = Modifier.fillMaxWidth().padding(8.dp),
349+ modifier = Modifier.fillMaxWidth()
350+ .windowInsetsPadding(WindowInsets.navigationBars)
351+ .padding(8.dp),
329352 verticalAlignment = Alignment.Bottom,
330353 ) {
331354 OutlinedTextField(
@@ -348,6 +371,8 @@ private fun ChatInputBar(
348371 placeholder = { Text(stringResource(R.string.message_placeholder)) },
349372 enabled = enabled,
350373 maxLines = 6,
374+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
375+ keyboardActions = KeyboardActions(onSend = { if (enabled && value.isNotBlank()) onSend() }),
351376 )
352377 if (running) {
353378 IconButton(onClick = onAbort, modifier = Modifier.padding(start = 4.dp)) {
@@ -365,3 +390,29 @@ private fun ChatInputBar(
365390 }
366391 }
367392}
393+
394+@Composable
395+private fun EmptyConversation(modifier: Modifier = Modifier) {
396+ Column(
397+ modifier = modifier.padding(32.dp),
398+ horizontalAlignment = Alignment.CenterHorizontally,
399+ ) {
400+ Icon(
401+ Icons.Filled.AutoAwesome,
402+ contentDescription = null,
403+ modifier = Modifier.size(48.dp),
404+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
405+ )
406+ Spacer(Modifier.size(16.dp))
407+ Text(
408+ stringResource(R.string.empty_chat_title),
409+ style = MaterialTheme.typography.titleMedium,
410+ )
411+ Spacer(Modifier.size(8.dp))
412+ Text(
413+ stringResource(R.string.empty_chat_hint),
414+ style = MaterialTheme.typography.bodyMedium,
415+ color = MaterialTheme.colorScheme.onSurfaceVariant,
416+ )
417+ }
418+}
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+41−18
@@ -17,25 +17,31 @@ import soy.iko.opencode.data.network.EventStreamClient
1717import soy.iko.opencode.data.repo.SessionRepository
1818import soy.iko.opencode.di.AppContainer
1919import soy.iko.opencode.R
20+import kotlinx.coroutines.ExperimentalCoroutinesApi
2021import kotlinx.coroutines.flow.MutableStateFlow
2122import kotlinx.coroutines.flow.SharingStarted
2223import kotlinx.coroutines.flow.StateFlow
2324import kotlinx.coroutines.flow.asStateFlow
25+import kotlinx.coroutines.flow.flatMapLatest
2426import kotlinx.coroutines.flow.flowOf
2527import kotlinx.coroutines.flow.stateIn
2628import kotlinx.coroutines.launch
2729
30+@OptIn(ExperimentalCoroutinesApi::class)
2831class ChatViewModel(
2932 private val container: AppContainer,
3033 private val sessionId: String,
3134) : ViewModel() {
3235
33− private val connection = container.activeConnection.value
36+ private val connection get() = container.activeConnection.value
3437
3538 val connected: Boolean get() = connection != null
3639
3740 val messages: StateFlow<List<MessageWithParts>> =
38− (connection?.repository?.observeMessages(sessionId) ?: flowOf(emptyList<MessageWithParts>()))
41+ container.activeConnection
42+ .flatMapLatest { conn ->
43+ conn?.repository?.observeMessages(sessionId) ?: flowOf(emptyList())
44+ }
3945 .stateIn(
4046 scope = viewModelScope,
4147 started = SharingStarted.WhileSubscribed(5_000),
@@ -55,8 +61,13 @@ class ChatViewModel(
5561 val selectedModel: StateFlow<ModelOption?> = _selectedModel.asStateFlow()
5662
5763 val connectionState: StateFlow<EventStreamClient.ConnectionState> =
58− connection?.events?.state
59− ?: MutableStateFlow(EventStreamClient.ConnectionState.Disconnected).asStateFlow()
64+ container.activeConnection
65+ .flatMapLatest { it?.events?.state ?: MutableStateFlow(EventStreamClient.ConnectionState.Disconnected) }
66+ .stateIn(
67+ scope = viewModelScope,
68+ started = SharingStarted.WhileSubscribed(5_000),
69+ initialValue = EventStreamClient.ConnectionState.Disconnected,
70+ )
6071
6172 private val _pendingPermission = MutableStateFlow<Permission?>(null)
6273 val pendingPermission: StateFlow<Permission?> = _pendingPermission.asStateFlow()
@@ -94,9 +105,9 @@ class ChatViewModel(
94105
95106 init {
96107 // Watch the bus for run completion / errors / permission asks for this session.
97− val conn = connection
98− if (conn != null) {
99− viewModelScope.launch {
108+ viewModelScope.launch {
109+ container.activeConnection.collect { conn ->
110+ if (conn == null) return@collect
100111 conn.events.events.collect { event ->
101112 if (SessionRepository.isIdle(event, sessionId)) _running.value = false
102113 if (SessionRepository.isError(event, sessionId)) {
@@ -112,25 +123,37 @@ class ChatViewModel(
112123 }
113124 }
114125 }
115− // Load the model catalog; preselect the server default. Failure is non-fatal —
116− // sending with no model just uses the server's default agent/model.
117− viewModelScope.launch {
126+ }
127+ // Load the model catalog; preselect the server default. Failure is non-fatal —
128+ // sending with no model just uses the server's default agent/model.
129+ viewModelScope.launch {
130+ container.activeConnection.collect { conn ->
131+ if (conn == null) return@collect
118132 runCatching { conn.api.providers() }.getOrNull()?.let { resp ->
119133 val options = resp.toOptions()
120134 _models.value = options
121135 _selectedModel.value = resp.defaultOption(options)
122136 }
123137 }
124− // Load the agent catalog (non-fatal).
125− viewModelScope.launch {
138+ }
139+ // Load the agent catalog (non-fatal).
140+ viewModelScope.launch {
141+ container.activeConnection.collect { conn ->
142+ if (conn == null) return@collect
126143 runCatching { conn.api.agents() }.getOrNull()?.let { _agents.value = it }
127144 }
128− // Load the command catalog (non-fatal).
129− viewModelScope.launch {
145+ }
146+ // Load the command catalog (non-fatal).
147+ viewModelScope.launch {
148+ container.activeConnection.collect { conn ->
149+ if (conn == null) return@collect
130150 runCatching { conn.api.commands() }.getOrNull()?.let { _commands.value = it }
131151 }
132− // Resolve the human-readable session title for the app bar (non-fatal).
133− viewModelScope.launch {
152+ }
153+ // Resolve the human-readable session title for the app bar (non-fatal).
154+ viewModelScope.launch {
155+ container.activeConnection.collect { conn ->
156+ if (conn == null) return@collect
134157 runCatching { conn.repository.listSessions() }
135158 .getOrNull()
136159 ?.firstOrNull { it.id == sessionId }
@@ -183,7 +206,7 @@ class ChatViewModel(
183206 viewModelScope.launch {
184207 runCatching {
185208 conn.repository.runCommand(sessionId, command.name, agent = command.agent)
186− }.onFailure { _error.value = it.message ?: container.string(R.string.error_failed_command) }
209+ }.onFailure { _error.value = container.friendlyError(it) }
187210 _running.value = false
188211 }
189212 }
@@ -202,7 +225,7 @@ class ChatViewModel(
202225 _pendingPermission.value = null
203226 viewModelScope.launch {
204227 runCatching { conn.api.respondPermission(sessionId, permission.id, response) }
205− .onFailure { _error.value = it.message ?: "Failed to respond to permission" }
228+ .onFailure { _error.value = container.friendlyError(it) }
206229 }
207230 }
208231}
app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+1−2
@@ -6,7 +6,6 @@ import androidx.compose.foundation.layout.Column
66import androidx.compose.foundation.layout.Row
77import androidx.compose.foundation.layout.fillMaxWidth
88import androidx.compose.foundation.layout.padding
9−import androidx.compose.foundation.layout.widthIn
109import androidx.compose.foundation.shape.RoundedCornerShape
1110import androidx.compose.material3.MaterialTheme
1211import androidx.compose.material3.Text
@@ -35,7 +34,7 @@ private fun UserBubble(message: MessageWithParts, modifier: Modifier) {
3534 Row(modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
3635 Column(
3736 modifier = Modifier
38− .widthIn(max = 320.dp)
37+ .fillMaxWidth(0.85f)
3938 .clip(RoundedCornerShape(16.dp))
4039 .background(MaterialTheme.colorScheme.primaryContainer)
4140 .padding(12.dp),
app/src/main/java/soy/iko/opencode/ui/chat/ModelPickerSheet.kt+31−6
@@ -2,18 +2,28 @@ package soy.iko.opencode.ui.chat
22
33import androidx.compose.foundation.clickable
44import androidx.compose.foundation.layout.Column
5+import androidx.compose.foundation.layout.Row
56import androidx.compose.foundation.layout.fillMaxWidth
67import androidx.compose.foundation.layout.heightIn
78import androidx.compose.foundation.layout.padding
9+import androidx.compose.foundation.layout.size
810import androidx.compose.foundation.lazy.LazyColumn
911import androidx.compose.foundation.lazy.items
12+import androidx.compose.material.icons.Icons
13+import androidx.compose.material.icons.filled.Check
1014import androidx.compose.material3.ExperimentalMaterial3Api
15+import androidx.compose.material3.Icon
1116import androidx.compose.material3.MaterialTheme
1217import androidx.compose.material3.ModalBottomSheet
1318import androidx.compose.material3.Text
1419import androidx.compose.runtime.Composable
20+import androidx.compose.ui.Alignment
1521import androidx.compose.ui.Modifier
1622import androidx.compose.ui.res.stringResource
23+import androidx.compose.ui.semantics.Role
24+import androidx.compose.ui.semantics.role
25+import androidx.compose.ui.semantics.selected
26+import androidx.compose.ui.semantics.semantics
1727import androidx.compose.ui.unit.dp
1828import soy.iko.opencode.data.model.ModelOption
1929import soy.iko.opencode.R
@@ -48,19 +58,34 @@ fun ModelPickerSheet(
4858 Column(
4959 modifier = Modifier
5060 .fillMaxWidth()
61+ .semantics(mergeDescendants = true) {
62+ role = Role.RadioButton
63+ if (isSelected) this.selected = true
64+ }
5165 .clickable {
5266 onSelect(option)
5367 onDismiss()
5468 }
5569 .padding(horizontal = 24.dp, vertical = 12.dp),
5670 ) {
71+ Row(verticalAlignment = Alignment.CenterVertically) {
72+ Text(
73+ option.modelLabel,
74+ style = MaterialTheme.typography.bodyLarge,
75+ color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
76+ modifier = Modifier.weight(1f),
77+ )
78+ if (isSelected) {
79+ Icon(
80+ Icons.Filled.Check,
81+ contentDescription = null,
82+ tint = MaterialTheme.colorScheme.primary,
83+ modifier = Modifier.size(18.dp),
84+ )
85+ }
86+ }
5787 Text(
58− option.modelLabel,
59− style = MaterialTheme.typography.bodyLarge,
60− color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
61− )
62− Text(
63− option.providerLabel + if (isSelected) " ✓" else "",
88+ option.providerLabel,
6489 style = MaterialTheme.typography.bodySmall,
6590 color = MaterialTheme.colorScheme.onSurfaceVariant,
6691 )
app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+28−8
@@ -13,6 +13,7 @@ import androidx.compose.material.icons.Icons
1313import androidx.compose.material.icons.filled.Bolt
1414import androidx.compose.material.icons.filled.CheckCircle
1515import androidx.compose.material.icons.filled.ContentCopy
16+import androidx.compose.material.icons.filled.Description
1617import androidx.compose.material.icons.filled.Error
1718import androidx.compose.material.icons.filled.ExpandLess
1819import androidx.compose.material.icons.filled.ExpandMore
@@ -32,6 +33,9 @@ import androidx.compose.ui.Alignment
3233import androidx.compose.ui.Modifier
3334import androidx.compose.ui.draw.clip
3435import androidx.compose.ui.platform.LocalContext
36+import androidx.compose.ui.semantics.Role
37+import androidx.compose.ui.semantics.role
38+import androidx.compose.ui.semantics.semantics
3539import androidx.compose.ui.text.font.FontFamily
3640import androidx.compose.ui.res.stringResource
3741import androidx.compose.ui.unit.dp
@@ -83,7 +87,10 @@ private fun ReasoningBlock(text: String, streaming: Boolean, modifier: Modifier)
8387 var expanded by remember { mutableStateOf(false) }
8488 Column(modifier = modifier.fillMaxWidth()) {
8589 Row(
86− modifier = Modifier.clickable { expanded = !expanded }.padding(vertical = 4.dp),
90+ modifier = Modifier
91+ .clickable { expanded = !expanded }
92+ .padding(vertical = 4.dp)
93+ .semantics { role = Role.Button },
8794 verticalAlignment = Alignment.CenterVertically,
8895 ) {
8996 if (streaming) {
@@ -145,13 +152,13 @@ private fun ToolCallView(part: ToolPart, modifier: Modifier) {
145152 }
146153 val detail = when (val s = part.state) {
147154 is ToolCompleted -> s.output?.takeIf { it.isNotBlank() }
148− is ToolError -> s.error ?: "error"
155+ is ToolError -> s.error ?: stringResource(R.string.error_generic)
149156 else -> null
150157 }
151158 if (detail != null) {
152159 val collapsed = remember(detail) { detail.take(COLLAPSED_LIMIT) }
153160 var expanded by remember(detail) { mutableStateOf(false) }
154− val display = if (expanded || detail.length <= COLLAPSED_LIMIT) detail.take(5000) else collapsed
161+ val display = if (expanded || detail.length <= COLLAPSED_LIMIT) detail else collapsed
155162 if (looksLikeDiff(display)) {
156163 DiffView(display)
157164 } else {
@@ -205,9 +212,22 @@ private fun ToolStatusIcon(state: ToolState) {
205212
206213@Composable
207214private fun FileChip(part: FilePart, modifier: Modifier) {
208− Text(
209− "📎 ${part.filename ?: part.url ?: "file"}",
210− modifier = modifier.padding(vertical = 2.dp),
211− style = MaterialTheme.typography.bodyMedium,
212− )
215+ Row(
216+ modifier = modifier
217+ .clip(RoundedCornerShape(12.dp))
218+ .background(MaterialTheme.colorScheme.surfaceVariant)
219+ .padding(horizontal = 12.dp, vertical = 6.dp),
220+ verticalAlignment = Alignment.CenterVertically,
221+ ) {
222+ Icon(
223+ Icons.Filled.Description,
224+ contentDescription = null,
225+ modifier = Modifier.size(16.dp),
226+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
227+ )
228+ Text(
229+ " ${part.filename ?: part.url ?: stringResource(R.string.file)}",
230+ style = MaterialTheme.typography.bodyMedium,
231+ )
232+ }
213233}
app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+10−2
@@ -10,6 +10,10 @@ import androidx.compose.material3.MaterialTheme
1010import androidx.compose.runtime.Composable
1111import androidx.compose.ui.Modifier
1212import androidx.compose.ui.platform.LocalContext
13+import androidx.compose.ui.semantics.Role
14+import androidx.compose.ui.semantics.contentDescription
15+import androidx.compose.ui.semantics.role
16+import androidx.compose.ui.semantics.semantics
1317import androidx.compose.ui.text.TextStyle
1418import com.mikepenz.markdown.m3.Markdown
1519import soy.iko.opencode.R
@@ -27,9 +31,13 @@ fun MarkdownText(
2731 style: TextStyle = MaterialTheme.typography.bodyLarge,
2832) {
2933 val context = LocalContext.current
34+ val copyLabel = context.getString(R.string.copy)
3035 Markdown(
3136 content = markdown,
32− modifier = modifier.combinedClickable(
37+ modifier = modifier.semantics(mergeDescendants = true) {
38+ contentDescription = markdown.take(500)
39+ role = Role.Image
40+ }.combinedClickable(
3341 onClick = {},
3442 onLongClick = { copyToClipboard(context, markdown) },
3543 ),
@@ -38,7 +46,7 @@ fun MarkdownText(
3846
3947/** Copy [text] to the system clipboard and show a confirmation toast. */
4048internal fun copyToClipboard(context: Context, label: String, text: String = label) {
41− val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
49+ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return
4250 clipboard.setPrimaryClip(ClipData.newPlainText(label.take(40), text))
4351 Toast.makeText(context, context.getString(R.string.copied), Toast.LENGTH_SHORT).show()
4452}
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+44−17
@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.size
1212import androidx.compose.foundation.lazy.LazyColumn
1313import androidx.compose.foundation.lazy.items
1414import androidx.compose.foundation.rememberScrollState
15+import androidx.compose.foundation.text.KeyboardOptions
1516import androidx.compose.material.icons.Icons
1617import androidx.compose.material.icons.automirrored.filled.ArrowBack
1718import androidx.compose.material.icons.filled.ChevronRight
@@ -34,7 +35,10 @@ import androidx.compose.runtime.getValue
3435import androidx.compose.ui.Alignment
3536import androidx.compose.ui.Modifier
3637import androidx.compose.ui.graphics.Color
38+import androidx.compose.ui.semantics.contentDescription
39+import androidx.compose.ui.semantics.semantics
3740import androidx.compose.ui.text.font.FontFamily
41+import androidx.compose.ui.text.input.ImeAction
3842import androidx.compose.ui.text.style.TextOverflow
3943import androidx.compose.ui.unit.dp
4044import androidx.compose.ui.res.stringResource
@@ -81,13 +85,14 @@ fun FileBrowserScreen(
8185 placeholder = { Text(stringResource(R.string.search_files)) },
8286 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
8387 singleLine = true,
88+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
8489 )
8590
8691 Box(modifier = Modifier.fillMaxSize()) {
8792 when {
8893 state.loading || state.searching -> CircularProgressIndicator(Modifier.align(Alignment.Center))
8994 state.error != null && !state.isSearching -> Text(
90− state.error!!,
95+ state.error ?: "",
9196 modifier = Modifier.align(Alignment.Center).padding(24.dp),
9297 color = MaterialTheme.colorScheme.error,
9398 )
@@ -157,6 +162,14 @@ private fun DirectoryListing(
157162 onUp: () -> Unit,
158163 onOpenFile: (String) -> Unit,
159164) {
165+ if (state.entries.isEmpty() && state.path.isBlank()) {
166+ Text(
167+ stringResource(R.string.empty_folder),
168+ modifier = Modifier.fillMaxWidth().padding(24.dp),
169+ color = MaterialTheme.colorScheme.onSurfaceVariant,
170+ )
171+ return
172+ }
160173 LazyColumn(modifier = Modifier.fillMaxSize()) {
161174 if (state.path.isNotBlank()) {
162175 item(key = "__up") {
@@ -183,8 +196,15 @@ private fun FileRow(
183196 onClick: () -> Unit,
184197 status: FileStatusEntry? = null,
185198) {
199+ val desc = if (label == "..") stringResource(R.string.parent_dir)
200+ else if (icon) stringResource(R.string.folder, label)
201+ else stringResource(R.string.file_label, label)
186202 Row(
187− modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(horizontal = 16.dp, vertical = 14.dp),
203+ modifier = Modifier
204+ .fillMaxWidth()
205+ .clickable(onClick = onClick)
206+ .semantics(mergeDescendants = true) { contentDescription = desc }
207+ .padding(horizontal = 16.dp, vertical = 14.dp),
188208 verticalAlignment = Alignment.CenterVertically,
189209 ) {
190210 Icon(
@@ -206,24 +226,31 @@ private fun FileRow(
206226
207227@Composable
208228private fun StatusBadge(status: FileStatusEntry) {
209− val (letter, color) = when (status.status) {
210− "added" -> "A" to Color(0xFF4CAF50)
211− "modified" -> "M" to Color(0xFFFFA000)
212− "deleted" -> "D" to MaterialTheme.colorScheme.error
213− else -> "·" to MaterialTheme.colorScheme.onSurfaceVariant
229+ val (letter, color, desc) = when (status.status) {
230+ "added" -> Triple("A", Color(0xFF4CAF50), stringResource(R.string.git_added))
231+ "modified" -> Triple("M", Color(0xFFFFA000), stringResource(R.string.git_modified))
232+ "deleted" -> Triple("D", MaterialTheme.colorScheme.error, stringResource(R.string.git_deleted))
233+ else -> Triple("·", MaterialTheme.colorScheme.onSurfaceVariant, "")
214234 }
215− Text(
216− letter,
217− style = MaterialTheme.typography.labelMedium,
218− fontFamily = FontFamily.Monospace,
219− color = color,
220− )
221− if (status.added > 0 || status.removed > 0) {
235+ Row(
236+ verticalAlignment = Alignment.CenterVertically,
237+ modifier = Modifier.semantics(mergeDescendants = true) {
238+ if (desc.isNotEmpty()) contentDescription = desc
239+ },
240+ ) {
222241 Text(
223− " +${status.added} −${status.removed}",
224− style = MaterialTheme.typography.labelSmall,
242+ letter,
243+ style = MaterialTheme.typography.labelMedium,
225244 fontFamily = FontFamily.Monospace,
226− color = MaterialTheme.colorScheme.onSurfaceVariant,
245+ color = color,
227246 )
247+ if (status.added > 0 || status.removed > 0) {
248+ Text(
249+ " +${status.added} −${status.removed}",
250+ style = MaterialTheme.typography.labelSmall,
251+ fontFamily = FontFamily.Monospace,
252+ color = MaterialTheme.colorScheme.onSurfaceVariant,
253+ )
254+ }
228255 }
229256}
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserViewModel.kt+4−3
@@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
55import soy.iko.opencode.data.model.FileNode
66import soy.iko.opencode.data.model.FileStatusEntry
77import soy.iko.opencode.di.AppContainer
8+import soy.iko.opencode.R
89import kotlinx.coroutines.Job
910import kotlinx.coroutines.delay
1011import kotlinx.coroutines.flow.MutableStateFlow
@@ -49,7 +50,7 @@ class FileBrowserViewModel(private val container: AppContainer) : ViewModel() {
4950
5051 fun open(path: String) {
5152 val client = api ?: run {
52− _state.value = _state.value.copy(loading = false, error = "Not connected")
53+ _state.value = _state.value.copy(loading = false, error = container.string(R.string.not_connected))
5354 return
5455 }
5556 _state.value = _state.value.copy(path = path, loading = true, error = null)
@@ -61,7 +62,7 @@ class FileBrowserViewModel(private val container: AppContainer) : ViewModel() {
6162 loading = false,
6263 )
6364 }
64− .onFailure { _state.value = _state.value.copy(loading = false, error = it.message ?: "Failed to list $path") }
65+ .onFailure { _state.value = _state.value.copy(loading = false, error = container.friendlyError(it)) }
6566 }
6667 }
6768
@@ -86,7 +87,7 @@ class FileBrowserViewModel(private val container: AppContainer) : ViewModel() {
8687 _state.value = _state.value.copy(searching = true)
8788 runCatching { client.findFiles(query) }
8889 .onSuccess { _state.value = _state.value.copy(results = it, searching = false) }
89− .onFailure { _state.value = _state.value.copy(searching = false, error = it.message) }
90+ .onFailure { _state.value = _state.value.copy(searching = false, error = container.friendlyError(it)) }
9091 }
9192 }
9293}
app/src/main/java/soy/iko/opencode/ui/file/FileViewModel.kt+3−2
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
44import androidx.lifecycle.viewModelScope
55import soy.iko.opencode.data.model.FileContent
66import soy.iko.opencode.di.AppContainer
7+import soy.iko.opencode.R
78import kotlinx.coroutines.flow.MutableStateFlow
89import kotlinx.coroutines.flow.StateFlow
910import kotlinx.coroutines.flow.asStateFlow
@@ -26,12 +27,12 @@ class FileViewModel(
2627 init {
2728 val api = container.activeConnection.value?.api
2829 if (api == null) {
29− _state.value = FileViewState(loading = false, error = "Not connected")
30+ _state.value = FileViewState(loading = false, error = container.string(R.string.not_connected))
3031 } else {
3132 viewModelScope.launch {
3233 runCatching { api.readFile(path) }
3334 .onSuccess { _state.value = FileViewState(loading = false, content = it) }
34− .onFailure { _state.value = FileViewState(loading = false, error = it.message ?: "Failed to read $path") }
35+ .onFailure { _state.value = FileViewState(loading = false, error = container.friendlyError(it)) }
3536 }
3637 }
3738 }
app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+9−1
@@ -89,7 +89,7 @@ fun FileViewScreen(
8989 when {
9090 state.loading -> CircularProgressIndicator(Modifier.align(Alignment.Center))
9191 state.error != null -> Text(
92− state.error!!,
92+ state.error ?: "",
9393 modifier = Modifier.align(Alignment.Center).padding(24.dp),
9494 color = MaterialTheme.colorScheme.error,
9595 )
@@ -110,6 +110,13 @@ fun FileViewScreen(
110110 )
111111 } else {
112112 val text = content?.content.orEmpty()
113+ if (text.isEmpty()) {
114+ Text(
115+ stringResource(R.string.empty_file),
116+ modifier = Modifier.align(Alignment.Center).padding(24.dp),
117+ color = MaterialTheme.colorScheme.onSurfaceVariant,
118+ )
119+ } else {
113120 val lines = remember(text) { text.split("\n") }
114121 val gutterWidth = remember(lines.size) {
115122 (lines.size.toString().length.coerceAtLeast(3) * 10).dp
@@ -142,6 +149,7 @@ fun FileViewScreen(
142149 }
143150 }
144151 }
152+ }
145153 }
146154 }
147155 }
app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+12−3
@@ -2,8 +2,12 @@ package soy.iko.opencode.ui.server
22
33import androidx.compose.foundation.layout.Column
44import androidx.compose.foundation.layout.fillMaxWidth
5+import androidx.compose.foundation.layout.imePadding
56import androidx.compose.foundation.layout.padding
7+import androidx.compose.foundation.layout.widthIn
68import androidx.compose.foundation.rememberScrollState
9+import androidx.compose.foundation.text.KeyboardActions
10+import androidx.compose.foundation.text.KeyboardOptions
711import androidx.compose.foundation.verticalScroll
812import androidx.compose.material.icons.Icons
913import androidx.compose.material.icons.automirrored.filled.ArrowBack
@@ -18,9 +22,9 @@ import androidx.compose.material3.TopAppBar
1822import androidx.compose.runtime.Composable
1923import androidx.compose.runtime.getValue
2024import androidx.compose.ui.Modifier
25+import androidx.compose.ui.text.input.ImeAction
2126import androidx.compose.ui.text.input.KeyboardType
2227import androidx.compose.ui.text.input.PasswordVisualTransformation
23−import androidx.compose.foundation.text.KeyboardOptions
2428import androidx.compose.foundation.layout.Arrangement
2529import androidx.compose.ui.res.stringResource
2630import androidx.compose.ui.unit.dp
@@ -56,6 +60,8 @@ fun ServerEditScreen(
5660 Column(
5761 modifier = Modifier
5862 .padding(padding)
63+ .imePadding()
64+ .widthIn(max = 600.dp)
5965 .padding(16.dp)
6066 .verticalScroll(rememberScrollState()),
6167 verticalArrangement = Arrangement.spacedBy(12.dp),
@@ -65,6 +71,7 @@ fun ServerEditScreen(
6571 onValueChange = { v -> vm.update { it.copy(label = v) } },
6672 label = { Text(stringResource(R.string.label_optional)) },
6773 singleLine = true,
74+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
6875 modifier = Modifier.fillMaxWidth(),
6976 )
7077 OutlinedTextField(
@@ -73,7 +80,7 @@ fun ServerEditScreen(
7380 label = { Text(stringResource(R.string.base_url)) },
7481 placeholder = { Text(stringResource(R.string.base_url_hint)) },
7582 singleLine = true,
76− keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
83+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
7784 modifier = Modifier.fillMaxWidth(),
7885 )
7986 Text(
@@ -85,6 +92,7 @@ fun ServerEditScreen(
8592 onValueChange = { v -> vm.update { it.copy(username = v) } },
8693 label = { Text(stringResource(R.string.username_optional)) },
8794 singleLine = true,
95+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
8896 modifier = Modifier.fillMaxWidth(),
8997 )
9098 OutlinedTextField(
@@ -93,7 +101,8 @@ fun ServerEditScreen(
93101 label = { Text(stringResource(R.string.password_optional)) },
94102 singleLine = true,
95103 visualTransformation = PasswordVisualTransformation(),
96− keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
104+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
105+ keyboardActions = KeyboardActions(onDone = { if (state.canSave) vm.save(onDone) }),
97106 modifier = Modifier.fillMaxWidth(),
98107 )
99108 Button(
app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+53−52
@@ -106,68 +106,69 @@ fun ServerListScreen(
106106 modifier = Modifier.fillMaxSize().padding(padding),
107107 )
108108 } else {
109− LazyColumn(
110− modifier = Modifier.fillMaxSize().padding(padding),
111− contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
112− verticalArrangement = Arrangement.spacedBy(12.dp),
113− ) {
114− items(profiles, key = { it.id }) { profile ->
115− val isActive = profile.id == connectedId
116− Card(
117− modifier = Modifier
118− .fillMaxWidth()
119− .clickable(enabled = connectingId == null) {
120− vm.connect(profile, onConnected)
121− },
122− ) {
123− Row(
124− modifier = Modifier.fillMaxWidth().padding(16.dp),
125− verticalAlignment = Alignment.CenterVertically,
109+ LazyColumn(
110+ modifier = Modifier.fillMaxSize().padding(padding),
111+ contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
112+ verticalArrangement = Arrangement.spacedBy(12.dp),
113+ ) {
114+ items(profiles, key = { it.id }) { profile ->
115+ val isActive = profile.id == connectedId
116+ Card(
117+ modifier = Modifier
118+ .fillMaxWidth()
119+ .animateItem()
120+ .clickable(enabled = connectingId == null) {
121+ vm.connect(profile, onConnected)
122+ },
126123 ) {
127− Column(modifier = Modifier.weight(1f)) {
128− Text(
129− profile.displayLabel,
130− style = MaterialTheme.typography.titleMedium,
131− maxLines = 1,
132− overflow = TextOverflow.Ellipsis,
133− )
134− Text(
135− profile.baseUrl + if (profile.hasAuth) stringResource(R.string.server_auth_short) else "",
136− style = MaterialTheme.typography.bodySmall,
137− color = MaterialTheme.colorScheme.onSurfaceVariant,
138− maxLines = 1,
139− overflow = TextOverflow.Ellipsis,
140− )
141− if (isActive) {
124+ Row(
125+ modifier = Modifier.fillMaxWidth().padding(16.dp),
126+ verticalAlignment = Alignment.CenterVertically,
127+ ) {
128+ Column(modifier = Modifier.weight(1f)) {
142129 Text(
143− stringResource(R.string.connected),
144− style = MaterialTheme.typography.labelSmall,
145− color = MaterialTheme.colorScheme.primary,
130+ profile.displayLabel,
131+ style = MaterialTheme.typography.titleMedium,
132+ maxLines = 1,
133+ overflow = TextOverflow.Ellipsis,
146134 )
135+ Text(
136+ profile.baseUrl + if (profile.hasAuth) stringResource(R.string.server_auth_short) else "",
137+ style = MaterialTheme.typography.bodySmall,
138+ color = MaterialTheme.colorScheme.onSurfaceVariant,
139+ maxLines = 1,
140+ overflow = TextOverflow.Ellipsis,
141+ )
142+ if (isActive) {
143+ Text(
144+ stringResource(R.string.connected),
145+ style = MaterialTheme.typography.labelSmall,
146+ color = MaterialTheme.colorScheme.primary,
147+ )
148+ }
147149 }
148− }
149− if (isActive) {
150− Icon(
151− Icons.Filled.CheckCircle,
152− contentDescription = stringResource(R.string.connected),
153− tint = MaterialTheme.colorScheme.primary,
154− modifier = Modifier.size(22.dp),
155− )
156− } else if (connectingId == profile.id) {
157− CircularProgressIndicator(modifier = Modifier.size(24.dp))
158− } else {
159− IconButton(onClick = { onEditProfile(profile.id) }) {
160− Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.edit))
161− }
162− IconButton(onClick = { pendingDelete = profile }) {
163− Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete))
150+ if (isActive) {
151+ Icon(
152+ Icons.Filled.CheckCircle,
153+ contentDescription = stringResource(R.string.connected),
154+ tint = MaterialTheme.colorScheme.primary,
155+ modifier = Modifier.size(22.dp),
156+ )
157+ } else if (connectingId == profile.id) {
158+ CircularProgressIndicator(modifier = Modifier.size(24.dp))
159+ } else {
160+ IconButton(onClick = { onEditProfile(profile.id) }) {
161+ Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.edit))
162+ }
163+ IconButton(onClick = { pendingDelete = profile }) {
164+ Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete))
165+ }
164166 }
165167 }
166168 }
167169 }
168170 }
169171 }
170− }
171172 }
172173 }
173174
app/src/main/java/soy/iko/opencode/ui/server/ServerListViewModel.kt+1−1
@@ -42,7 +42,7 @@ class ServerListViewModel(private val container: AppContainer) : ViewModel() {
4242 result.onSuccess { onConnected() }
4343 .onFailure {
4444 container.disconnect()
45− _error.value = it.message ?: container.string(R.string.error_not_reachable, profile.baseUrl)
45+ _error.value = container.friendlyError(it)
4646 }
4747 }
4848 }
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+16−3
@@ -12,6 +12,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
1212import androidx.compose.foundation.layout.padding
1313import androidx.compose.foundation.layout.size
1414import androidx.compose.foundation.lazy.LazyColumn
15+import androidx.compose.foundation.text.KeyboardActions
16+import androidx.compose.foundation.text.KeyboardOptions
1517import androidx.compose.foundation.lazy.items
1618import androidx.compose.material.icons.Icons
1719import androidx.compose.material.icons.automirrored.filled.Logout
@@ -48,6 +50,10 @@ import androidx.compose.runtime.setValue
4850import androidx.compose.ui.Alignment
4951import androidx.compose.ui.Modifier
5052import androidx.compose.ui.platform.LocalHapticFeedback
53+import androidx.compose.ui.semantics.Role
54+import androidx.compose.ui.semantics.role
55+import androidx.compose.ui.semantics.semantics
56+import androidx.compose.ui.text.input.ImeAction
5157import androidx.compose.ui.text.style.TextOverflow
5258import androidx.compose.ui.unit.dp
5359import androidx.compose.ui.res.stringResource
@@ -85,7 +91,9 @@ fun SessionListScreen(
8591 Column {
8692 Box {
8793 Row(
88− modifier = Modifier.clickable { showServerMenu = true },
94+ modifier = Modifier
95+ .clickable { showServerMenu = true }
96+ .semantics { role = Role.Button },
8997 verticalAlignment = Alignment.CenterVertically,
9098 ) {
9199 Text(serverLabel, maxLines = 1, overflow = TextOverflow.Ellipsis)
@@ -157,7 +165,7 @@ fun SessionListScreen(
157165 when {
158166 state.loading -> CircularProgressIndicator(Modifier.align(Alignment.Center))
159167 state.error != null -> Text(
160− state.error!!,
168+ state.error ?: "",
161169 modifier = Modifier.align(Alignment.Center).padding(24.dp),
162170 color = MaterialTheme.colorScheme.error,
163171 )
@@ -173,6 +181,7 @@ fun SessionListScreen(
173181 placeholder = { Text(stringResource(R.string.search_sessions)) },
174182 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
175183 singleLine = true,
184+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
176185 )
177186 val sessions = state.filtered
178187 if (sessions.isEmpty()) {
@@ -194,6 +203,7 @@ fun SessionListScreen(
194203 onClick = { onOpenSession(session.id) },
195204 onRename = { pendingRename = session },
196205 onDelete = { pendingDelete = session },
206+ modifier = Modifier.animateItem(),
197207 )
198208 }
199209 }
@@ -253,6 +263,8 @@ private fun RenameSessionDialog(
253263 modifier = Modifier.fillMaxWidth(),
254264 placeholder = { Text(stringResource(R.string.session_title_hint)) },
255265 singleLine = true,
266+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
267+ keyboardActions = KeyboardActions(onDone = { if (title.isNotBlank()) onConfirm() }),
256268 )
257269 },
258270 confirmButton = {
@@ -271,9 +283,10 @@ private fun SessionCard(
271283 onClick: () -> Unit,
272284 onRename: () -> Unit,
273285 onDelete: () -> Unit,
286+ modifier: Modifier = Modifier,
274287) {
275288 Card(
276− modifier = Modifier
289+ modifier = modifier
277290 .fillMaxWidth()
278291 .clickable { onClick() },
279292 ) {
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+5−5
@@ -84,7 +84,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
8484 _state.value = _state.value.copy(sessions = sorted, loading = false)
8585 loadPreviews(sorted)
8686 }
87− .onFailure { _state.value = SessionListState(loading = false, error = it.message ?: container.string(R.string.error_load_sessions)) }
87+ .onFailure { _state.value = SessionListState(loading = false, error = container.friendlyError(it)) }
8888 }
8989 }
9090
@@ -120,7 +120,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
120120 viewModelScope.launch {
121121 runCatching { conn.repository.createSession() }
122122 .onSuccess { onCreated(it.id); refresh() }
123− .onFailure { _state.value = _state.value.copy(error = it.message ?: container.string(R.string.error_create_session)) }
123+ .onFailure { _state.value = _state.value.copy(error = container.friendlyError(it)) }
124124 }
125125 }
126126
@@ -129,7 +129,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
129129 viewModelScope.launch {
130130 runCatching { conn.repository.deleteSession(session.id) }
131131 .onSuccess { refresh() }
132− .onFailure { _state.value = _state.value.copy(error = it.message ?: container.string(R.string.error_delete_session)) }
132+ .onFailure { _state.value = _state.value.copy(error = container.friendlyError(it)) }
133133 }
134134 }
135135
@@ -140,7 +140,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
140140 viewModelScope.launch {
141141 runCatching { conn.api.updateSession(session.id, title) }
142142 .onSuccess { refresh() }
143− .onFailure { _state.value = _state.value.copy(error = it.message ?: container.string(R.string.error_rename_session)) }
143+ .onFailure { _state.value = _state.value.copy(error = container.friendlyError(it)) }
144144 }
145145 }
146146
@@ -159,7 +159,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
159159 result.onSuccess { refresh() }
160160 .onFailure {
161161 container.disconnect()
162− _state.value = SessionListState(loading = false, error = it.message ?: container.string(R.string.error_not_reachable, profile.baseUrl))
162+ _state.value = SessionListState(loading = false, error = container.friendlyError(it))
163163 refresh()
164164 }
165165 }
app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+24−5
@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Spacer
88import androidx.compose.foundation.layout.fillMaxWidth
99import androidx.compose.foundation.layout.padding
1010import androidx.compose.foundation.layout.size
11+import androidx.compose.foundation.layout.widthIn
1112import androidx.compose.foundation.selection.selectable
1213import androidx.compose.material.icons.Icons
1314import androidx.compose.material.icons.automirrored.filled.ArrowBack
@@ -32,6 +33,8 @@ import androidx.compose.ui.Alignment
3233import androidx.compose.ui.Modifier
3334import androidx.compose.ui.platform.LocalContext
3435import androidx.compose.ui.res.stringResource
36+import androidx.compose.ui.semantics.heading
37+import androidx.compose.ui.semantics.semantics
3538import androidx.compose.ui.unit.dp
3639import androidx.lifecycle.compose.collectAsStateWithLifecycle
3740import kotlinx.coroutines.launch
@@ -47,9 +50,10 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
4750 val dynamicColor by container.settingsStore.dynamicColor.collectAsStateWithLifecycle(initialValue = true)
4851 val activeProfile = container.activeConnection.collectAsStateWithLifecycle().value?.profile
4952 val context = LocalContext.current
53+ val unknownVersion = stringResource(R.string.unknown_version)
5054 val versionName = remember {
5155 runCatching { context.packageManager.getPackageInfo(context.packageName, 0).versionName }
52− .getOrNull() ?: "0.1.0"
56+ .getOrNull() ?: unknownVersion
5357 }
5458
5559 Scaffold(
@@ -64,8 +68,13 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
6468 )
6569 },
6670 ) { padding ->
67− Column(modifier = Modifier.padding(padding).padding(16.dp)) {
68− Text(stringResource(R.string.appearance), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary)
71+ Column(modifier = Modifier.padding(padding).widthIn(max = 600.dp).padding(16.dp)) {
72+ Text(
73+ stringResource(R.string.appearance),
74+ style = MaterialTheme.typography.titleSmall,
75+ color = MaterialTheme.colorScheme.primary,
76+ modifier = Modifier.semantics { heading() },
77+ )
6978 ThemeMode.entries.forEach { mode ->
7079 ThemeRow(
7180 mode = mode,
@@ -95,7 +104,12 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
95104
96105 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
97106
98− Text(stringResource(R.string.connection), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary)
107+ Text(
108+ stringResource(R.string.connection),
109+ style = MaterialTheme.typography.titleSmall,
110+ color = MaterialTheme.colorScheme.primary,
111+ modifier = Modifier.semantics { heading() },
112+ )
99113 if (activeProfile != null) {
100114 Text(activeProfile.displayLabel, modifier = Modifier.padding(top = 8.dp))
101115 Text(
@@ -125,7 +139,12 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
125139
126140 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
127141
128− Text(stringResource(R.string.about), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary)
142+ Text(
143+ stringResource(R.string.about),
144+ style = MaterialTheme.typography.titleSmall,
145+ color = MaterialTheme.colorScheme.primary,
146+ modifier = Modifier.semantics { heading() },
147+ )
129148 Text(
130149 stringResource(R.string.about_version, versionName),
131150 style = MaterialTheme.typography.bodyMedium,
app/src/main/res/values/strings.xml+16
@@ -1,6 +1,10 @@
11<?xml version="1.0" encoding="utf-8"?>
22<resources>
33 <string name="app_name">opencode</string>
4+ <string name="untitled_session">Untitled session</string>
5+ <string name="custom_agent">Custom agent</string>
6+ <string name="unknown_version">0.1.0</string>
7+ <string name="error_output">error</string>
48
59 <!-- Common -->
610 <string name="back">Back</string>
@@ -67,6 +71,8 @@
6771 <string name="session">Session</string>
6872 <string name="default_model">default model</string>
6973 <string name="message_placeholder">Message opencode…</string>
74+ <string name="empty_chat_title">Start a conversation</string>
75+ <string name="empty_chat_hint">Type a message below to begin.</string>
7076 <string name="working">working…</string>
7177 <string name="latest">Latest</string>
7278 <string name="stop_and_exit_title">Stop and exit?</string>
@@ -78,6 +84,7 @@
7884 <string name="choose_model">Choose model</string>
7985
8086 <!-- Errors (view models) -->
87+ <string name="error_generic">Something went wrong.</string>
8188 <string name="error_failed_to_send">Failed to send</string>
8289 <string name="error_agent_reported">The agent reported an error.</string>
8390 <string name="error_failed_command">Failed to run command</string>
@@ -113,7 +120,16 @@
113120 <string name="search_files">Search files…</string>
114121 <string name="root">Root</string>
115122 <string name="no_matches">No matches.</string>
123+ <string name="git_added">Added</string>
124+ <string name="git_modified">Modified</string>
125+ <string name="git_deleted">Deleted</string>
116126 <string name="binary_file">Binary file — preview not supported.</string>
127+ <string name="empty_folder">This folder is empty</string>
128+ <string name="empty_file">This file is empty</string>
129+ <string name="parent_dir">Parent directory</string>
130+ <string name="folder">Folder: %1$s</string>
131+ <string name="file_label">File: %1$s</string>
132+ <string name="file">File</string>
117133
118134 <!-- Settings -->
119135 <string name="appearance">Appearance</string>