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
17bd7b0iko
app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+3−1
@@ -1,6 +1,7 @@
1package soy.iko.opencode.data.network
1package soy.iko.opencode.data.network
2
2
3import soy.iko.opencode.data.model.BusEvent
3import soy.iko.opencode.data.model.BusEvent
4+import android.util.Log
4import io.ktor.client.HttpClient
5import io.ktor.client.HttpClient
5import io.ktor.client.plugins.sse.sse
6import io.ktor.client.plugins.sse.sse
6import kotlinx.coroutines.CancellationException
7import kotlinx.coroutines.CancellationException
@@ -72,7 +73,7 @@ class EventStreamClient(
72 } catch (c: CancellationException) {
73 } catch (c: CancellationException) {
73 throw c
74 throw c
74 } catch (t: Throwable) {
75 } catch (t: Throwable) {
75− // transient error — fall through to reconnect
76+ Log.w(TAG, "SSE stream error, will retry", t)
76 }
77 }
77 _state.value = ConnectionState.Disconnected
78 _state.value = ConnectionState.Disconnected
78 if (!isActive) break
79 if (!isActive) break
@@ -87,6 +88,7 @@ class EventStreamClient(
87 }
88 }
88
89
89 private companion object {
90 private companion object {
91+ private const val TAG = "EventStream"
90 const val INITIAL_BACKOFF_MS = 500L
92 const val INITIAL_BACKOFF_MS = 500L
91 const val MAX_BACKOFF_MS = 10_000L
93 const val MAX_BACKOFF_MS = 10_000L
92 }
94 }
app/src/main/java/soy/iko/opencode/data/network/HttpClientFactory.kt−1
@@ -42,7 +42,6 @@ object HttpClientFactory {
42 install(SSE)
42 install(SSE)
43
43
44 install(HttpTimeout) {
44 install(HttpTimeout) {
45− connectTimeoutMillis = 30_000
46 requestTimeoutMillis = 60_000
45 requestTimeoutMillis = 60_000
47 }
46 }
48
47
app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.kt+29−4
@@ -26,6 +26,7 @@ import io.ktor.client.request.post
26import io.ktor.client.request.setBody
26import io.ktor.client.request.setBody
27import io.ktor.http.ContentType
27import io.ktor.http.ContentType
28import io.ktor.http.contentType
28import io.ktor.http.contentType
29+import kotlinx.coroutines.delay
29
30
30/**
31/**
31 * Request/response wrapper over the opencode REST endpoints. The long-lived `/event`
32 * Request/response wrapper over the opencode REST endpoints. The long-lived `/event`
@@ -33,10 +34,9 @@ import io.ktor.http.contentType
33 */
34 */
34class OpencodeApiClient(private val client: HttpClient) {
35class OpencodeApiClient(private val client: HttpClient) {
35
36
36− /** Lightweight connectivity check used by the connect screen. */
37+ /** Lightweight connectivity check. Throws on non-2xx / network failure. */
37− suspend fun ping(): Boolean {
38+ suspend fun ping() {
38 client.get("global/health")
39 client.get("global/health")
39− return true
40 }
40 }
41
41
42 suspend fun listSessions(): List<Session> =
42 suspend fun listSessions(): List<Session> =
@@ -66,7 +66,7 @@ class OpencodeApiClient(private val client: HttpClient) {
66 text: String,
66 text: String,
67 model: ModelRef? = null,
67 model: ModelRef? = null,
68 agent: String? = null,
68 agent: String? = null,
69− ): MessageWithParts =
69+ ): MessageWithParts = withRetry {
70 client.post("session/$sessionId/message") {
70 client.post("session/$sessionId/message") {
71 contentType(ContentType.Application.Json)
71 contentType(ContentType.Application.Json)
72 setBody(
72 setBody(
@@ -77,6 +77,7 @@ class OpencodeApiClient(private val client: HttpClient) {
77 ),
77 ),
78 )
78 )
79 }.body()
79 }.body()
80+ }
80
81
81 suspend fun abort(sessionId: String) {
82 suspend fun abort(sessionId: String) {
82 client.post("session/$sessionId/abort")
83 client.post("session/$sessionId/abort")
@@ -129,3 +130,27 @@ class OpencodeApiClient(private val client: HttpClient) {
129 suspend fun fileStatus(): List<FileStatusEntry> =
130 suspend fun fileStatus(): List<FileStatusEntry> =
130 client.get("file/status").body()
131 client.get("file/status").body()
131}
132}
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 @@
1package soy.iko.opencode.data.repo
1package soy.iko.opencode.data.repo
2
2
3−import soy.iko.opencode.data.model.AssistantMessage
4import soy.iko.opencode.data.model.BusEvent
3import soy.iko.opencode.data.model.BusEvent
5import soy.iko.opencode.data.model.MessagePartRemoved
4import soy.iko.opencode.data.model.MessagePartRemoved
6import soy.iko.opencode.data.model.MessagePartUpdated
5import soy.iko.opencode.data.model.MessagePartUpdated
@@ -11,6 +10,7 @@ import soy.iko.opencode.data.model.ModelRef
11import soy.iko.opencode.data.model.Part
10import soy.iko.opencode.data.model.Part
12import soy.iko.opencode.data.model.SessionError
11import soy.iko.opencode.data.model.SessionError
13import soy.iko.opencode.data.model.SessionIdle
12import soy.iko.opencode.data.model.SessionIdle
13+import soy.iko.opencode.data.model.UnknownMessage
14import soy.iko.opencode.data.network.EventStreamClient
14import soy.iko.opencode.data.network.EventStreamClient
15import soy.iko.opencode.data.network.OpencodeApiClient
15import soy.iko.opencode.data.network.OpencodeApiClient
16import kotlinx.coroutines.flow.Flow
16import kotlinx.coroutines.flow.Flow
@@ -36,8 +36,12 @@ class SessionRepository(
36 suspend fun sendPrompt(sessionId: String, text: String, model: ModelRef?, agent: String? = null) =
36 suspend fun sendPrompt(sessionId: String, text: String, model: ModelRef?, agent: String? = null) =
37 api.sendPrompt(sessionId, text, model, agent)
37 api.sendPrompt(sessionId, text, model, agent)
38
38
39− suspend fun runCommand(sessionId: String, command: String, agent: String? = null) =
39+ suspend fun runCommand(
40+ sessionId: String,
41+ command: String,
42+ arguments: String = "",
43+ agent: String? = null,
40− api.runCommand(sessionId, command, agent = agent)
44+ ) = api.runCommand(sessionId, command, arguments = arguments, agent = agent)
41
45
42 /**
46 /**
43 * A live, ordered view of [sessionId]'s messages. Begins collecting the event
47 * A live, ordered view of [sessionId]'s messages. Begins collecting the event
@@ -48,22 +52,24 @@ class SessionRepository(
48 val store = MessageStore()
52 val store = MessageStore()
49 val lock = Mutex()
53 val lock = Mutex()
50
54
55+ // Collect the snapshot under the lock, then publish outside it so a slow
56+ // downstream collector can't stall event processing.
51− suspend fun publish() = send(store.snapshot())
57+ suspend fun publish() {
58+ val snapshot = lock.withLock { store.snapshot() }
59+ send(snapshot)
60+ }
52
61
53 // Subscribe to events first so we don't miss early deltas during the initial load.
62 // Subscribe to events first so we don't miss early deltas during the initial load.
54 val job = launch {
63 val job = launch {
55 eventStream.events.collect { event ->
64 eventStream.events.collect { event ->
56− lock.withLock {
57− if (store.reduce(sessionId, event)) publish()
65+ val changed = lock.withLock { store.reduce(sessionId, event) }
58− }
66+ if (changed) publish()
59 }
67 }
60 }
68 }
61
69
62 val initial = runCatching { api.listMessages(sessionId) }.getOrDefault(emptyList())
70 val initial = runCatching { api.listMessages(sessionId) }.getOrDefault(emptyList())
63− lock.withLock {
64− store.seed(initial)
71+ lock.withLock { store.seed(initial) }
65− publish()
72+ publish()
66− }
67
73
68 // Keep the flow alive until the collector cancels; the launched job is torn down with it.
74 // Keep the flow alive until the collector cancels; the launched job is torn down with it.
69 job.join()
75 job.join()
@@ -144,7 +150,7 @@ internal class MessageStore {
144 }
150 }
145 messages[messageId] = current?.copy(parts = newParts)
151 messages[messageId] = current?.copy(parts = newParts)
146 ?: MessageWithParts(
152 ?: MessageWithParts(
147− info = AssistantMessage(id = messageId, sessionID = part.sessionID ?: ""),
153+ info = UnknownMessage(id = messageId, sessionID = part.sessionID ?: ""),
148 parts = newParts,
154 parts = newParts,
149 )
155 )
150 return true
156 return true
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+22
@@ -8,6 +8,7 @@ import soy.iko.opencode.data.model.ServerProfile
8import soy.iko.opencode.data.repo.DraftStore
8import soy.iko.opencode.data.repo.DraftStore
9import soy.iko.opencode.data.repo.ProfileStore
9import soy.iko.opencode.data.repo.ProfileStore
10import soy.iko.opencode.data.repo.SettingsStore
10import soy.iko.opencode.data.repo.SettingsStore
11+import soy.iko.opencode.R
11import kotlinx.coroutines.CoroutineScope
12import kotlinx.coroutines.CoroutineScope
12import kotlinx.coroutines.Dispatchers
13import kotlinx.coroutines.Dispatchers
13import kotlinx.coroutines.SupervisorJob
14import kotlinx.coroutines.SupervisorJob
@@ -58,6 +59,27 @@ class AppContainer(context: Context) {
58 fun string(id: Int, vararg formatArgs: Any): String =
59 fun string(id: Int, vararg formatArgs: Any): String =
59 if (formatArgs.isEmpty()) appContext.getString(id) else appContext.getString(id, *formatArgs)
60 if (formatArgs.isEmpty()) appContext.getString(id) else appContext.getString(id, *formatArgs)
60
61
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+
61 init {
83 init {
62 registerNetworkMonitor()
84 registerNetworkMonitor()
63 appScope.launch { autoConnect() }
85 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
2
2
3import androidx.compose.foundation.clickable
3import androidx.compose.foundation.clickable
4import androidx.compose.foundation.layout.Column
4import androidx.compose.foundation.layout.Column
5+import androidx.compose.foundation.layout.Row
5import androidx.compose.foundation.layout.fillMaxWidth
6import androidx.compose.foundation.layout.fillMaxWidth
6import androidx.compose.foundation.layout.heightIn
7import androidx.compose.foundation.layout.heightIn
7import androidx.compose.foundation.layout.padding
8import androidx.compose.foundation.layout.padding
9+import androidx.compose.foundation.layout.size
8import androidx.compose.foundation.lazy.LazyColumn
10import androidx.compose.foundation.lazy.LazyColumn
9import androidx.compose.foundation.lazy.items
11import androidx.compose.foundation.lazy.items
12+import androidx.compose.material.icons.Icons
13+import androidx.compose.material.icons.filled.Check
10import androidx.compose.material3.ExperimentalMaterial3Api
14import androidx.compose.material3.ExperimentalMaterial3Api
15+import androidx.compose.material3.Icon
11import androidx.compose.material3.MaterialTheme
16import androidx.compose.material3.MaterialTheme
12import androidx.compose.material3.ModalBottomSheet
17import androidx.compose.material3.ModalBottomSheet
13import androidx.compose.material3.Text
18import androidx.compose.material3.Text
14import androidx.compose.runtime.Composable
19import androidx.compose.runtime.Composable
20+import androidx.compose.ui.Alignment
15import androidx.compose.ui.Modifier
21import androidx.compose.ui.Modifier
16import androidx.compose.ui.res.stringResource
22import 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
17import androidx.compose.ui.unit.dp
27import androidx.compose.ui.unit.dp
18import soy.iko.opencode.data.model.Agent
28import soy.iko.opencode.data.model.Agent
19import soy.iko.opencode.R
29import soy.iko.opencode.R
@@ -46,16 +56,31 @@ fun AgentPickerSheet(
46 Column(
56 Column(
47 modifier = Modifier
57 modifier = Modifier
48 .fillMaxWidth()
58 .fillMaxWidth()
59+ .semantics(mergeDescendants = true) {
60+ role = Role.RadioButton
61+ if (selected == null) this.selected = true
62+ }
49 .clickable { onSelect(null); onDismiss() }
63 .clickable { onSelect(null); onDismiss() }
50 .padding(horizontal = 24.dp, vertical = 12.dp),
64 .padding(horizontal = 24.dp, vertical = 12.dp),
51 ) {
65 ) {
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+ }
52 Text(
82 Text(
53− stringResource(R.string.default_agent),
83+ stringResource(R.string.default_agent_desc),
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 "",
59 style = MaterialTheme.typography.bodySmall,
84 style = MaterialTheme.typography.bodySmall,
60 color = MaterialTheme.colorScheme.onSurfaceVariant,
85 color = MaterialTheme.colorScheme.onSurfaceVariant,
61 )
86 )
@@ -66,16 +91,31 @@ fun AgentPickerSheet(
66 Column(
91 Column(
67 modifier = Modifier
92 modifier = Modifier
68 .fillMaxWidth()
93 .fillMaxWidth()
94+ .semantics(mergeDescendants = true) {
95+ role = Role.RadioButton
96+ if (isSelected) this.selected = true
97+ }
69 .clickable { onSelect(agent); onDismiss() }
98 .clickable { onSelect(agent); onDismiss() }
70 .padding(horizontal = 24.dp, vertical = 12.dp),
99 .padding(horizontal = 24.dp, vertical = 12.dp),
71 ) {
100 ) {
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+ }
72 Text(
117 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,
79 style = MaterialTheme.typography.bodySmall,
119 style = MaterialTheme.typography.bodySmall,
80 color = MaterialTheme.colorScheme.onSurfaceVariant,
120 color = MaterialTheme.colorScheme.onSurfaceVariant,
81 )
121 )
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+55−4
@@ -9,16 +9,22 @@ import androidx.compose.foundation.layout.Column
9import androidx.compose.foundation.layout.PaddingValues
9import androidx.compose.foundation.layout.PaddingValues
10import androidx.compose.foundation.layout.Row
10import androidx.compose.foundation.layout.Row
11import androidx.compose.foundation.layout.Spacer
11import androidx.compose.foundation.layout.Spacer
12+import androidx.compose.foundation.layout.WindowInsets
12import androidx.compose.foundation.layout.fillMaxSize
13import androidx.compose.foundation.layout.fillMaxSize
13import androidx.compose.foundation.layout.fillMaxWidth
14import androidx.compose.foundation.layout.fillMaxWidth
15+import androidx.compose.foundation.layout.imePadding
16+import androidx.compose.foundation.layout.navigationBars
14import androidx.compose.foundation.layout.padding
17import androidx.compose.foundation.layout.padding
15import androidx.compose.foundation.layout.size
18import androidx.compose.foundation.layout.size
19+import androidx.compose.foundation.layout.union
20+import androidx.compose.foundation.layout.windowInsetsPadding
16import androidx.compose.foundation.lazy.LazyColumn
21import androidx.compose.foundation.lazy.LazyColumn
17import androidx.compose.foundation.lazy.items
22import androidx.compose.foundation.lazy.items
18import androidx.compose.foundation.lazy.rememberLazyListState
23import androidx.compose.foundation.lazy.rememberLazyListState
19import androidx.compose.material.icons.Icons
24import androidx.compose.material.icons.Icons
20import androidx.compose.material.icons.automirrored.filled.ArrowBack
25import androidx.compose.material.icons.automirrored.filled.ArrowBack
21import androidx.compose.material.icons.automirrored.filled.Send
26import androidx.compose.material.icons.automirrored.filled.Send
27+import androidx.compose.material.icons.filled.AutoAwesome
22import androidx.compose.material.icons.filled.KeyboardArrowDown
28import androidx.compose.material.icons.filled.KeyboardArrowDown
23import androidx.compose.material.icons.filled.SmartToy
29import androidx.compose.material.icons.filled.SmartToy
24import androidx.compose.material.icons.filled.Stop
30import androidx.compose.material.icons.filled.Stop
@@ -48,6 +54,8 @@ import androidx.compose.runtime.mutableStateOf
48import androidx.compose.runtime.remember
54import androidx.compose.runtime.remember
49import androidx.compose.runtime.rememberCoroutineScope
55import androidx.compose.runtime.rememberCoroutineScope
50import androidx.compose.runtime.setValue
56import androidx.compose.runtime.setValue
57+import androidx.compose.foundation.text.KeyboardActions
58+import androidx.compose.foundation.text.KeyboardOptions
51import androidx.compose.ui.Alignment
59import androidx.compose.ui.Alignment
52import androidx.compose.ui.Modifier
60import androidx.compose.ui.Modifier
53import androidx.compose.ui.input.key.Key
61import androidx.compose.ui.input.key.Key
@@ -58,6 +66,10 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent
58import androidx.compose.ui.input.key.type
66import androidx.compose.ui.input.key.type
59import androidx.compose.ui.platform.LocalHapticFeedback
67import androidx.compose.ui.platform.LocalHapticFeedback
60import androidx.compose.ui.res.stringResource
68import 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
61import androidx.compose.ui.unit.dp
73import androidx.compose.ui.unit.dp
62import androidx.lifecycle.compose.collectAsStateWithLifecycle
74import androidx.lifecycle.compose.collectAsStateWithLifecycle
63import androidx.lifecycle.viewmodel.compose.viewModel
75import androidx.lifecycle.viewmodel.compose.viewModel
@@ -137,7 +149,10 @@ fun ChatScreen(
137 topBar = {
149 topBar = {
138 TopAppBar(
150 TopAppBar(
139 title = {
151 title = {
152+ Column(modifier = Modifier
140− Column(modifier = Modifier.clickable(enabled = models.isNotEmpty()) { showModelPicker = true }) {
153+ .clickable(enabled = models.isNotEmpty()) { showModelPicker = true }
154+ .semantics { role = Role.Button }
155+ ) {
141 Text(sessionTitle ?: stringResource(R.string.session))
156 Text(sessionTitle ?: stringResource(R.string.session))
142 Text(
157 Text(
143 buildString {
158 buildString {
@@ -193,6 +208,11 @@ fun ChatScreen(
193 state = connectionState,
208 state = connectionState,
194 modifier = Modifier.align(Alignment.TopCenter),
209 modifier = Modifier.align(Alignment.TopCenter),
195 )
210 )
211+ if (messages.isEmpty() && !running) {
212+ EmptyConversation(
213+ modifier = Modifier.align(Alignment.Center),
214+ )
215+ } else {
196 LazyColumn(
216 LazyColumn(
197 state = listState,
217 state = listState,
198 modifier = Modifier.fillMaxSize(),
218 modifier = Modifier.fillMaxSize(),
@@ -222,11 +242,12 @@ fun ChatScreen(
222 scope.launch { listState.animateScrollToItem(messages.lastIndex) }
242 scope.launch { listState.animateScrollToItem(messages.lastIndex) }
223 }
243 }
224 },
244 },
225− icon = { Icon(Icons.Filled.KeyboardArrowDown, contentDescription = null) },
245+ icon = { Icon(Icons.Filled.KeyboardArrowDown, contentDescription = stringResource(R.string.latest)) },
226 text = { Text(stringResource(R.string.latest)) },
246 text = { Text(stringResource(R.string.latest)) },
227 modifier = Modifier.padding(end = 16.dp, bottom = 16.dp),
247 modifier = Modifier.padding(end = 16.dp, bottom = 16.dp),
228 )
248 )
229 }
249 }
250+ }
230 }
251 }
231 }
252 }
232 }
253 }
@@ -323,9 +344,11 @@ private fun ChatInputBar(
323 onSend: () -> Unit,
344 onSend: () -> Unit,
324 onAbort: () -> Unit,
345 onAbort: () -> Unit,
325) {
346) {
326− Surface(tonalElevation = 3.dp) {
347+ Surface(tonalElevation = 3.dp, modifier = Modifier.imePadding()) {
327 Row(
348 Row(
328− modifier = Modifier.fillMaxWidth().padding(8.dp),
349+ modifier = Modifier.fillMaxWidth()
350+ .windowInsetsPadding(WindowInsets.navigationBars)
351+ .padding(8.dp),
329 verticalAlignment = Alignment.Bottom,
352 verticalAlignment = Alignment.Bottom,
330 ) {
353 ) {
331 OutlinedTextField(
354 OutlinedTextField(
@@ -348,6 +371,8 @@ private fun ChatInputBar(
348 placeholder = { Text(stringResource(R.string.message_placeholder)) },
371 placeholder = { Text(stringResource(R.string.message_placeholder)) },
349 enabled = enabled,
372 enabled = enabled,
350 maxLines = 6,
373 maxLines = 6,
374+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
375+ keyboardActions = KeyboardActions(onSend = { if (enabled && value.isNotBlank()) onSend() }),
351 )
376 )
352 if (running) {
377 if (running) {
353 IconButton(onClick = onAbort, modifier = Modifier.padding(start = 4.dp)) {
378 IconButton(onClick = onAbort, modifier = Modifier.padding(start = 4.dp)) {
@@ -365,3 +390,29 @@ private fun ChatInputBar(
365 }
390 }
366 }
391 }
367}
392}
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
17import soy.iko.opencode.data.repo.SessionRepository
17import soy.iko.opencode.data.repo.SessionRepository
18import soy.iko.opencode.di.AppContainer
18import soy.iko.opencode.di.AppContainer
19import soy.iko.opencode.R
19import soy.iko.opencode.R
20+import kotlinx.coroutines.ExperimentalCoroutinesApi
20import kotlinx.coroutines.flow.MutableStateFlow
21import kotlinx.coroutines.flow.MutableStateFlow
21import kotlinx.coroutines.flow.SharingStarted
22import kotlinx.coroutines.flow.SharingStarted
22import kotlinx.coroutines.flow.StateFlow
23import kotlinx.coroutines.flow.StateFlow
23import kotlinx.coroutines.flow.asStateFlow
24import kotlinx.coroutines.flow.asStateFlow
25+import kotlinx.coroutines.flow.flatMapLatest
24import kotlinx.coroutines.flow.flowOf
26import kotlinx.coroutines.flow.flowOf
25import kotlinx.coroutines.flow.stateIn
27import kotlinx.coroutines.flow.stateIn
26import kotlinx.coroutines.launch
28import kotlinx.coroutines.launch
27
29
30+@OptIn(ExperimentalCoroutinesApi::class)
28class ChatViewModel(
31class ChatViewModel(
29 private val container: AppContainer,
32 private val container: AppContainer,
30 private val sessionId: String,
33 private val sessionId: String,
31) : ViewModel() {
34) : ViewModel() {
32
35
33− private val connection = container.activeConnection.value
36+ private val connection get() = container.activeConnection.value
34
37
35 val connected: Boolean get() = connection != null
38 val connected: Boolean get() = connection != null
36
39
37 val messages: StateFlow<List<MessageWithParts>> =
40 val messages: StateFlow<List<MessageWithParts>> =
41+ container.activeConnection
42+ .flatMapLatest { conn ->
38− (connection?.repository?.observeMessages(sessionId) ?: flowOf(emptyList<MessageWithParts>()))
43+ conn?.repository?.observeMessages(sessionId) ?: flowOf(emptyList())
44+ }
39 .stateIn(
45 .stateIn(
40 scope = viewModelScope,
46 scope = viewModelScope,
41 started = SharingStarted.WhileSubscribed(5_000),
47 started = SharingStarted.WhileSubscribed(5_000),
@@ -55,8 +61,13 @@ class ChatViewModel(
55 val selectedModel: StateFlow<ModelOption?> = _selectedModel.asStateFlow()
61 val selectedModel: StateFlow<ModelOption?> = _selectedModel.asStateFlow()
56
62
57 val connectionState: StateFlow<EventStreamClient.ConnectionState> =
63 val connectionState: StateFlow<EventStreamClient.ConnectionState> =
58− connection?.events?.state
64+ container.activeConnection
59− ?: MutableStateFlow(EventStreamClient.ConnectionState.Disconnected).asStateFlow()
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+ )
60
71
61 private val _pendingPermission = MutableStateFlow<Permission?>(null)
72 private val _pendingPermission = MutableStateFlow<Permission?>(null)
62 val pendingPermission: StateFlow<Permission?> = _pendingPermission.asStateFlow()
73 val pendingPermission: StateFlow<Permission?> = _pendingPermission.asStateFlow()
@@ -94,9 +105,9 @@ class ChatViewModel(
94
105
95 init {
106 init {
96 // Watch the bus for run completion / errors / permission asks for this session.
107 // Watch the bus for run completion / errors / permission asks for this session.
97− val conn = connection
108+ viewModelScope.launch {
109+ container.activeConnection.collect { conn ->
98− if (conn != null) {
110+ if (conn == null) return@collect
99− viewModelScope.launch {
100 conn.events.events.collect { event ->
111 conn.events.events.collect { event ->
101 if (SessionRepository.isIdle(event, sessionId)) _running.value = false
112 if (SessionRepository.isIdle(event, sessionId)) _running.value = false
102 if (SessionRepository.isError(event, sessionId)) {
113 if (SessionRepository.isError(event, sessionId)) {
@@ -112,25 +123,37 @@ class ChatViewModel(
112 }
123 }
113 }
124 }
114 }
125 }
126+ }
115− // Load the model catalog; preselect the server default. Failure is non-fatal —
127+ // 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.
128+ // sending with no model just uses the server's default agent/model.
117− viewModelScope.launch {
129+ viewModelScope.launch {
130+ container.activeConnection.collect { conn ->
131+ if (conn == null) return@collect
118 runCatching { conn.api.providers() }.getOrNull()?.let { resp ->
132 runCatching { conn.api.providers() }.getOrNull()?.let { resp ->
119 val options = resp.toOptions()
133 val options = resp.toOptions()
120 _models.value = options
134 _models.value = options
121 _selectedModel.value = resp.defaultOption(options)
135 _selectedModel.value = resp.defaultOption(options)
122 }
136 }
123 }
137 }
138+ }
124− // Load the agent catalog (non-fatal).
139+ // Load the agent catalog (non-fatal).
125− viewModelScope.launch {
140+ viewModelScope.launch {
141+ container.activeConnection.collect { conn ->
142+ if (conn == null) return@collect
126 runCatching { conn.api.agents() }.getOrNull()?.let { _agents.value = it }
143 runCatching { conn.api.agents() }.getOrNull()?.let { _agents.value = it }
127 }
144 }
145+ }
128− // Load the command catalog (non-fatal).
146+ // Load the command catalog (non-fatal).
129− viewModelScope.launch {
147+ viewModelScope.launch {
148+ container.activeConnection.collect { conn ->
149+ if (conn == null) return@collect
130 runCatching { conn.api.commands() }.getOrNull()?.let { _commands.value = it }
150 runCatching { conn.api.commands() }.getOrNull()?.let { _commands.value = it }
131 }
151 }
152+ }
132− // Resolve the human-readable session title for the app bar (non-fatal).
153+ // Resolve the human-readable session title for the app bar (non-fatal).
133− viewModelScope.launch {
154+ viewModelScope.launch {
155+ container.activeConnection.collect { conn ->
156+ if (conn == null) return@collect
134 runCatching { conn.repository.listSessions() }
157 runCatching { conn.repository.listSessions() }
135 .getOrNull()
158 .getOrNull()
136 ?.firstOrNull { it.id == sessionId }
159 ?.firstOrNull { it.id == sessionId }
@@ -183,7 +206,7 @@ class ChatViewModel(
183 viewModelScope.launch {
206 viewModelScope.launch {
184 runCatching {
207 runCatching {
185 conn.repository.runCommand(sessionId, command.name, agent = command.agent)
208 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) }
187 _running.value = false
210 _running.value = false
188 }
211 }
189 }
212 }
@@ -202,7 +225,7 @@ class ChatViewModel(
202 _pendingPermission.value = null
225 _pendingPermission.value = null
203 viewModelScope.launch {
226 viewModelScope.launch {
204 runCatching { conn.api.respondPermission(sessionId, permission.id, response) }
227 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) }
206 }
229 }
207 }
230 }
208}
231}
app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+1−2
@@ -6,7 +6,6 @@ import androidx.compose.foundation.layout.Column
6import androidx.compose.foundation.layout.Row
6import androidx.compose.foundation.layout.Row
7import androidx.compose.foundation.layout.fillMaxWidth
7import androidx.compose.foundation.layout.fillMaxWidth
8import androidx.compose.foundation.layout.padding
8import androidx.compose.foundation.layout.padding
9−import androidx.compose.foundation.layout.widthIn
10import androidx.compose.foundation.shape.RoundedCornerShape
9import androidx.compose.foundation.shape.RoundedCornerShape
11import androidx.compose.material3.MaterialTheme
10import androidx.compose.material3.MaterialTheme
12import androidx.compose.material3.Text
11import androidx.compose.material3.Text
@@ -35,7 +34,7 @@ private fun UserBubble(message: MessageWithParts, modifier: Modifier) {
35 Row(modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
34 Row(modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
36 Column(
35 Column(
37 modifier = Modifier
36 modifier = Modifier
38− .widthIn(max = 320.dp)
37+ .fillMaxWidth(0.85f)
39 .clip(RoundedCornerShape(16.dp))
38 .clip(RoundedCornerShape(16.dp))
40 .background(MaterialTheme.colorScheme.primaryContainer)
39 .background(MaterialTheme.colorScheme.primaryContainer)
41 .padding(12.dp),
40 .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
2
2
3import androidx.compose.foundation.clickable
3import androidx.compose.foundation.clickable
4import androidx.compose.foundation.layout.Column
4import androidx.compose.foundation.layout.Column
5+import androidx.compose.foundation.layout.Row
5import androidx.compose.foundation.layout.fillMaxWidth
6import androidx.compose.foundation.layout.fillMaxWidth
6import androidx.compose.foundation.layout.heightIn
7import androidx.compose.foundation.layout.heightIn
7import androidx.compose.foundation.layout.padding
8import androidx.compose.foundation.layout.padding
9+import androidx.compose.foundation.layout.size
8import androidx.compose.foundation.lazy.LazyColumn
10import androidx.compose.foundation.lazy.LazyColumn
9import androidx.compose.foundation.lazy.items
11import androidx.compose.foundation.lazy.items
12+import androidx.compose.material.icons.Icons
13+import androidx.compose.material.icons.filled.Check
10import androidx.compose.material3.ExperimentalMaterial3Api
14import androidx.compose.material3.ExperimentalMaterial3Api
15+import androidx.compose.material3.Icon
11import androidx.compose.material3.MaterialTheme
16import androidx.compose.material3.MaterialTheme
12import androidx.compose.material3.ModalBottomSheet
17import androidx.compose.material3.ModalBottomSheet
13import androidx.compose.material3.Text
18import androidx.compose.material3.Text
14import androidx.compose.runtime.Composable
19import androidx.compose.runtime.Composable
20+import androidx.compose.ui.Alignment
15import androidx.compose.ui.Modifier
21import androidx.compose.ui.Modifier
16import androidx.compose.ui.res.stringResource
22import 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
17import androidx.compose.ui.unit.dp
27import androidx.compose.ui.unit.dp
18import soy.iko.opencode.data.model.ModelOption
28import soy.iko.opencode.data.model.ModelOption
19import soy.iko.opencode.R
29import soy.iko.opencode.R
@@ -48,19 +58,34 @@ fun ModelPickerSheet(
48 Column(
58 Column(
49 modifier = Modifier
59 modifier = Modifier
50 .fillMaxWidth()
60 .fillMaxWidth()
61+ .semantics(mergeDescendants = true) {
62+ role = Role.RadioButton
63+ if (isSelected) this.selected = true
64+ }
51 .clickable {
65 .clickable {
52 onSelect(option)
66 onSelect(option)
53 onDismiss()
67 onDismiss()
54 }
68 }
55 .padding(horizontal = 24.dp, vertical = 12.dp),
69 .padding(horizontal = 24.dp, vertical = 12.dp),
56 ) {
70 ) {
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+ }
57 Text(
87 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,
64 style = MaterialTheme.typography.bodySmall,
89 style = MaterialTheme.typography.bodySmall,
65 color = MaterialTheme.colorScheme.onSurfaceVariant,
90 color = MaterialTheme.colorScheme.onSurfaceVariant,
66 )
91 )
app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+28−8
@@ -13,6 +13,7 @@ import androidx.compose.material.icons.Icons
13import androidx.compose.material.icons.filled.Bolt
13import androidx.compose.material.icons.filled.Bolt
14import androidx.compose.material.icons.filled.CheckCircle
14import androidx.compose.material.icons.filled.CheckCircle
15import androidx.compose.material.icons.filled.ContentCopy
15import androidx.compose.material.icons.filled.ContentCopy
16+import androidx.compose.material.icons.filled.Description
16import androidx.compose.material.icons.filled.Error
17import androidx.compose.material.icons.filled.Error
17import androidx.compose.material.icons.filled.ExpandLess
18import androidx.compose.material.icons.filled.ExpandLess
18import androidx.compose.material.icons.filled.ExpandMore
19import androidx.compose.material.icons.filled.ExpandMore
@@ -32,6 +33,9 @@ import androidx.compose.ui.Alignment
32import androidx.compose.ui.Modifier
33import androidx.compose.ui.Modifier
33import androidx.compose.ui.draw.clip
34import androidx.compose.ui.draw.clip
34import androidx.compose.ui.platform.LocalContext
35import 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
35import androidx.compose.ui.text.font.FontFamily
39import androidx.compose.ui.text.font.FontFamily
36import androidx.compose.ui.res.stringResource
40import androidx.compose.ui.res.stringResource
37import androidx.compose.ui.unit.dp
41import androidx.compose.ui.unit.dp
@@ -83,7 +87,10 @@ private fun ReasoningBlock(text: String, streaming: Boolean, modifier: Modifier)
83 var expanded by remember { mutableStateOf(false) }
87 var expanded by remember { mutableStateOf(false) }
84 Column(modifier = modifier.fillMaxWidth()) {
88 Column(modifier = modifier.fillMaxWidth()) {
85 Row(
89 Row(
90+ modifier = Modifier
86− modifier = Modifier.clickable { expanded = !expanded }.padding(vertical = 4.dp),
91+ .clickable { expanded = !expanded }
92+ .padding(vertical = 4.dp)
93+ .semantics { role = Role.Button },
87 verticalAlignment = Alignment.CenterVertically,
94 verticalAlignment = Alignment.CenterVertically,
88 ) {
95 ) {
89 if (streaming) {
96 if (streaming) {
@@ -145,13 +152,13 @@ private fun ToolCallView(part: ToolPart, modifier: Modifier) {
145 }
152 }
146 val detail = when (val s = part.state) {
153 val detail = when (val s = part.state) {
147 is ToolCompleted -> s.output?.takeIf { it.isNotBlank() }
154 is ToolCompleted -> s.output?.takeIf { it.isNotBlank() }
148− is ToolError -> s.error ?: "error"
155+ is ToolError -> s.error ?: stringResource(R.string.error_generic)
149 else -> null
156 else -> null
150 }
157 }
151 if (detail != null) {
158 if (detail != null) {
152 val collapsed = remember(detail) { detail.take(COLLAPSED_LIMIT) }
159 val collapsed = remember(detail) { detail.take(COLLAPSED_LIMIT) }
153 var expanded by remember(detail) { mutableStateOf(false) }
160 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
155 if (looksLikeDiff(display)) {
162 if (looksLikeDiff(display)) {
156 DiffView(display)
163 DiffView(display)
157 } else {
164 } else {
@@ -205,9 +212,22 @@ private fun ToolStatusIcon(state: ToolState) {
205
212
206@Composable
213@Composable
207private fun FileChip(part: FilePart, modifier: Modifier) {
214private fun FileChip(part: FilePart, modifier: Modifier) {
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+ )
208− Text(
228+ Text(
209− "📎 ${part.filename ?: part.url ?: "file"}",
229+ " ${part.filename ?: part.url ?: stringResource(R.string.file)}",
210− modifier = modifier.padding(vertical = 2.dp),
211− style = MaterialTheme.typography.bodyMedium,
230+ style = MaterialTheme.typography.bodyMedium,
212− )
231+ )
232+ }
213}
233}
app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+10−2
@@ -10,6 +10,10 @@ import androidx.compose.material3.MaterialTheme
10import androidx.compose.runtime.Composable
10import androidx.compose.runtime.Composable
11import androidx.compose.ui.Modifier
11import androidx.compose.ui.Modifier
12import androidx.compose.ui.platform.LocalContext
12import 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
13import androidx.compose.ui.text.TextStyle
17import androidx.compose.ui.text.TextStyle
14import com.mikepenz.markdown.m3.Markdown
18import com.mikepenz.markdown.m3.Markdown
15import soy.iko.opencode.R
19import soy.iko.opencode.R
@@ -27,9 +31,13 @@ fun MarkdownText(
27 style: TextStyle = MaterialTheme.typography.bodyLarge,
31 style: TextStyle = MaterialTheme.typography.bodyLarge,
28) {
32) {
29 val context = LocalContext.current
33 val context = LocalContext.current
34+ val copyLabel = context.getString(R.string.copy)
30 Markdown(
35 Markdown(
31 content = markdown,
36 content = markdown,
37+ modifier = modifier.semantics(mergeDescendants = true) {
38+ contentDescription = markdown.take(500)
39+ role = Role.Image
32− modifier = modifier.combinedClickable(
40+ }.combinedClickable(
33 onClick = {},
41 onClick = {},
34 onLongClick = { copyToClipboard(context, markdown) },
42 onLongClick = { copyToClipboard(context, markdown) },
35 ),
43 ),
@@ -38,7 +46,7 @@ fun MarkdownText(
38
46
39/** Copy [text] to the system clipboard and show a confirmation toast. */
47/** Copy [text] to the system clipboard and show a confirmation toast. */
40internal fun copyToClipboard(context: Context, label: String, text: String = label) {
48internal 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
42 clipboard.setPrimaryClip(ClipData.newPlainText(label.take(40), text))
50 clipboard.setPrimaryClip(ClipData.newPlainText(label.take(40), text))
43 Toast.makeText(context, context.getString(R.string.copied), Toast.LENGTH_SHORT).show()
51 Toast.makeText(context, context.getString(R.string.copied), Toast.LENGTH_SHORT).show()
44}
52}
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+44−17
@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.size
12import androidx.compose.foundation.lazy.LazyColumn
12import androidx.compose.foundation.lazy.LazyColumn
13import androidx.compose.foundation.lazy.items
13import androidx.compose.foundation.lazy.items
14import androidx.compose.foundation.rememberScrollState
14import androidx.compose.foundation.rememberScrollState
15+import androidx.compose.foundation.text.KeyboardOptions
15import androidx.compose.material.icons.Icons
16import androidx.compose.material.icons.Icons
16import androidx.compose.material.icons.automirrored.filled.ArrowBack
17import androidx.compose.material.icons.automirrored.filled.ArrowBack
17import androidx.compose.material.icons.filled.ChevronRight
18import androidx.compose.material.icons.filled.ChevronRight
@@ -34,7 +35,10 @@ import androidx.compose.runtime.getValue
34import androidx.compose.ui.Alignment
35import androidx.compose.ui.Alignment
35import androidx.compose.ui.Modifier
36import androidx.compose.ui.Modifier
36import androidx.compose.ui.graphics.Color
37import androidx.compose.ui.graphics.Color
38+import androidx.compose.ui.semantics.contentDescription
39+import androidx.compose.ui.semantics.semantics
37import androidx.compose.ui.text.font.FontFamily
40import androidx.compose.ui.text.font.FontFamily
41+import androidx.compose.ui.text.input.ImeAction
38import androidx.compose.ui.text.style.TextOverflow
42import androidx.compose.ui.text.style.TextOverflow
39import androidx.compose.ui.unit.dp
43import androidx.compose.ui.unit.dp
40import androidx.compose.ui.res.stringResource
44import androidx.compose.ui.res.stringResource
@@ -81,13 +85,14 @@ fun FileBrowserScreen(
81 placeholder = { Text(stringResource(R.string.search_files)) },
85 placeholder = { Text(stringResource(R.string.search_files)) },
82 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
86 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
83 singleLine = true,
87 singleLine = true,
88+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
84 )
89 )
85
90
86 Box(modifier = Modifier.fillMaxSize()) {
91 Box(modifier = Modifier.fillMaxSize()) {
87 when {
92 when {
88 state.loading || state.searching -> CircularProgressIndicator(Modifier.align(Alignment.Center))
93 state.loading || state.searching -> CircularProgressIndicator(Modifier.align(Alignment.Center))
89 state.error != null && !state.isSearching -> Text(
94 state.error != null && !state.isSearching -> Text(
90− state.error!!,
95+ state.error ?: "",
91 modifier = Modifier.align(Alignment.Center).padding(24.dp),
96 modifier = Modifier.align(Alignment.Center).padding(24.dp),
92 color = MaterialTheme.colorScheme.error,
97 color = MaterialTheme.colorScheme.error,
93 )
98 )
@@ -157,6 +162,14 @@ private fun DirectoryListing(
157 onUp: () -> Unit,
162 onUp: () -> Unit,
158 onOpenFile: (String) -> Unit,
163 onOpenFile: (String) -> Unit,
159) {
164) {
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+ }
160 LazyColumn(modifier = Modifier.fillMaxSize()) {
173 LazyColumn(modifier = Modifier.fillMaxSize()) {
161 if (state.path.isNotBlank()) {
174 if (state.path.isNotBlank()) {
162 item(key = "__up") {
175 item(key = "__up") {
@@ -183,8 +196,15 @@ private fun FileRow(
183 onClick: () -> Unit,
196 onClick: () -> Unit,
184 status: FileStatusEntry? = null,
197 status: FileStatusEntry? = null,
185) {
198) {
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)
186 Row(
202 Row(
203+ modifier = Modifier
204+ .fillMaxWidth()
205+ .clickable(onClick = onClick)
206+ .semantics(mergeDescendants = true) { contentDescription = desc }
187− modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(horizontal = 16.dp, vertical = 14.dp),
207+ .padding(horizontal = 16.dp, vertical = 14.dp),
188 verticalAlignment = Alignment.CenterVertically,
208 verticalAlignment = Alignment.CenterVertically,
189 ) {
209 ) {
190 Icon(
210 Icon(
@@ -206,24 +226,31 @@ private fun FileRow(
206
226
207@Composable
227@Composable
208private fun StatusBadge(status: FileStatusEntry) {
228private fun StatusBadge(status: FileStatusEntry) {
209− val (letter, color) = when (status.status) {
229+ val (letter, color, desc) = when (status.status) {
210− "added" -> "A" to Color(0xFF4CAF50)
230+ "added" -> Triple("A", Color(0xFF4CAF50), stringResource(R.string.git_added))
211− "modified" -> "M" to Color(0xFFFFA000)
231+ "modified" -> Triple("M", Color(0xFFFFA000), stringResource(R.string.git_modified))
212− "deleted" -> "D" to MaterialTheme.colorScheme.error
232+ "deleted" -> Triple("D", MaterialTheme.colorScheme.error, stringResource(R.string.git_deleted))
213− else -> "·" to MaterialTheme.colorScheme.onSurfaceVariant
233+ else -> Triple("·", MaterialTheme.colorScheme.onSurfaceVariant, "")
214 }
234 }
215− Text(
235+ Row(
216− letter,
236+ verticalAlignment = Alignment.CenterVertically,
217− style = MaterialTheme.typography.labelMedium,
237+ modifier = Modifier.semantics(mergeDescendants = true) {
218− fontFamily = FontFamily.Monospace,
238+ if (desc.isNotEmpty()) contentDescription = desc
219− color = color,
239+ },
220− )
240+ ) {
221− if (status.added > 0 || status.removed > 0) {
222 Text(
241 Text(
223− " +${status.added} −${status.removed}",
242+ letter,
224− style = MaterialTheme.typography.labelSmall,
243+ style = MaterialTheme.typography.labelMedium,
225 fontFamily = FontFamily.Monospace,
244 fontFamily = FontFamily.Monospace,
226− color = MaterialTheme.colorScheme.onSurfaceVariant,
245+ color = color,
227 )
246 )
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+ }
228 }
255 }
229}
256}
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserViewModel.kt+4−3
@@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
5import soy.iko.opencode.data.model.FileNode
5import soy.iko.opencode.data.model.FileNode
6import soy.iko.opencode.data.model.FileStatusEntry
6import soy.iko.opencode.data.model.FileStatusEntry
7import soy.iko.opencode.di.AppContainer
7import soy.iko.opencode.di.AppContainer
8+import soy.iko.opencode.R
8import kotlinx.coroutines.Job
9import kotlinx.coroutines.Job
9import kotlinx.coroutines.delay
10import kotlinx.coroutines.delay
10import kotlinx.coroutines.flow.MutableStateFlow
11import kotlinx.coroutines.flow.MutableStateFlow
@@ -49,7 +50,7 @@ class FileBrowserViewModel(private val container: AppContainer) : ViewModel() {
49
50
50 fun open(path: String) {
51 fun open(path: String) {
51 val client = api ?: run {
52 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))
53 return
54 return
54 }
55 }
55 _state.value = _state.value.copy(path = path, loading = true, error = null)
56 _state.value = _state.value.copy(path = path, loading = true, error = null)
@@ -61,7 +62,7 @@ class FileBrowserViewModel(private val container: AppContainer) : ViewModel() {
61 loading = false,
62 loading = false,
62 )
63 )
63 }
64 }
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)) }
65 }
66 }
66 }
67 }
67
68
@@ -86,7 +87,7 @@ class FileBrowserViewModel(private val container: AppContainer) : ViewModel() {
86 _state.value = _state.value.copy(searching = true)
87 _state.value = _state.value.copy(searching = true)
87 runCatching { client.findFiles(query) }
88 runCatching { client.findFiles(query) }
88 .onSuccess { _state.value = _state.value.copy(results = it, searching = false) }
89 .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)) }
90 }
91 }
91 }
92 }
92}
93}
app/src/main/java/soy/iko/opencode/ui/file/FileViewModel.kt+3−2
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
4import androidx.lifecycle.viewModelScope
4import androidx.lifecycle.viewModelScope
5import soy.iko.opencode.data.model.FileContent
5import soy.iko.opencode.data.model.FileContent
6import soy.iko.opencode.di.AppContainer
6import soy.iko.opencode.di.AppContainer
7+import soy.iko.opencode.R
7import kotlinx.coroutines.flow.MutableStateFlow
8import kotlinx.coroutines.flow.MutableStateFlow
8import kotlinx.coroutines.flow.StateFlow
9import kotlinx.coroutines.flow.StateFlow
9import kotlinx.coroutines.flow.asStateFlow
10import kotlinx.coroutines.flow.asStateFlow
@@ -26,12 +27,12 @@ class FileViewModel(
26 init {
27 init {
27 val api = container.activeConnection.value?.api
28 val api = container.activeConnection.value?.api
28 if (api == null) {
29 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))
30 } else {
31 } else {
31 viewModelScope.launch {
32 viewModelScope.launch {
32 runCatching { api.readFile(path) }
33 runCatching { api.readFile(path) }
33 .onSuccess { _state.value = FileViewState(loading = false, content = it) }
34 .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)) }
35 }
36 }
36 }
37 }
37 }
38 }
app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+9−1
@@ -89,7 +89,7 @@ fun FileViewScreen(
89 when {
89 when {
90 state.loading -> CircularProgressIndicator(Modifier.align(Alignment.Center))
90 state.loading -> CircularProgressIndicator(Modifier.align(Alignment.Center))
91 state.error != null -> Text(
91 state.error != null -> Text(
92− state.error!!,
92+ state.error ?: "",
93 modifier = Modifier.align(Alignment.Center).padding(24.dp),
93 modifier = Modifier.align(Alignment.Center).padding(24.dp),
94 color = MaterialTheme.colorScheme.error,
94 color = MaterialTheme.colorScheme.error,
95 )
95 )
@@ -110,6 +110,13 @@ fun FileViewScreen(
110 )
110 )
111 } else {
111 } else {
112 val text = content?.content.orEmpty()
112 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 {
113 val lines = remember(text) { text.split("\n") }
120 val lines = remember(text) { text.split("\n") }
114 val gutterWidth = remember(lines.size) {
121 val gutterWidth = remember(lines.size) {
115 (lines.size.toString().length.coerceAtLeast(3) * 10).dp
122 (lines.size.toString().length.coerceAtLeast(3) * 10).dp
@@ -142,6 +149,7 @@ fun FileViewScreen(
142 }
149 }
143 }
150 }
144 }
151 }
152+ }
145 }
153 }
146 }
154 }
147 }
155 }
app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+12−3
@@ -2,8 +2,12 @@ package soy.iko.opencode.ui.server
2
2
3import androidx.compose.foundation.layout.Column
3import androidx.compose.foundation.layout.Column
4import androidx.compose.foundation.layout.fillMaxWidth
4import androidx.compose.foundation.layout.fillMaxWidth
5+import androidx.compose.foundation.layout.imePadding
5import androidx.compose.foundation.layout.padding
6import androidx.compose.foundation.layout.padding
7+import androidx.compose.foundation.layout.widthIn
6import androidx.compose.foundation.rememberScrollState
8import androidx.compose.foundation.rememberScrollState
9+import androidx.compose.foundation.text.KeyboardActions
10+import androidx.compose.foundation.text.KeyboardOptions
7import androidx.compose.foundation.verticalScroll
11import androidx.compose.foundation.verticalScroll
8import androidx.compose.material.icons.Icons
12import androidx.compose.material.icons.Icons
9import androidx.compose.material.icons.automirrored.filled.ArrowBack
13import androidx.compose.material.icons.automirrored.filled.ArrowBack
@@ -18,9 +22,9 @@ import androidx.compose.material3.TopAppBar
18import androidx.compose.runtime.Composable
22import androidx.compose.runtime.Composable
19import androidx.compose.runtime.getValue
23import androidx.compose.runtime.getValue
20import androidx.compose.ui.Modifier
24import androidx.compose.ui.Modifier
25+import androidx.compose.ui.text.input.ImeAction
21import androidx.compose.ui.text.input.KeyboardType
26import androidx.compose.ui.text.input.KeyboardType
22import androidx.compose.ui.text.input.PasswordVisualTransformation
27import androidx.compose.ui.text.input.PasswordVisualTransformation
23−import androidx.compose.foundation.text.KeyboardOptions
24import androidx.compose.foundation.layout.Arrangement
28import androidx.compose.foundation.layout.Arrangement
25import androidx.compose.ui.res.stringResource
29import androidx.compose.ui.res.stringResource
26import androidx.compose.ui.unit.dp
30import androidx.compose.ui.unit.dp
@@ -56,6 +60,8 @@ fun ServerEditScreen(
56 Column(
60 Column(
57 modifier = Modifier
61 modifier = Modifier
58 .padding(padding)
62 .padding(padding)
63+ .imePadding()
64+ .widthIn(max = 600.dp)
59 .padding(16.dp)
65 .padding(16.dp)
60 .verticalScroll(rememberScrollState()),
66 .verticalScroll(rememberScrollState()),
61 verticalArrangement = Arrangement.spacedBy(12.dp),
67 verticalArrangement = Arrangement.spacedBy(12.dp),
@@ -65,6 +71,7 @@ fun ServerEditScreen(
65 onValueChange = { v -> vm.update { it.copy(label = v) } },
71 onValueChange = { v -> vm.update { it.copy(label = v) } },
66 label = { Text(stringResource(R.string.label_optional)) },
72 label = { Text(stringResource(R.string.label_optional)) },
67 singleLine = true,
73 singleLine = true,
74+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
68 modifier = Modifier.fillMaxWidth(),
75 modifier = Modifier.fillMaxWidth(),
69 )
76 )
70 OutlinedTextField(
77 OutlinedTextField(
@@ -73,7 +80,7 @@ fun ServerEditScreen(
73 label = { Text(stringResource(R.string.base_url)) },
80 label = { Text(stringResource(R.string.base_url)) },
74 placeholder = { Text(stringResource(R.string.base_url_hint)) },
81 placeholder = { Text(stringResource(R.string.base_url_hint)) },
75 singleLine = true,
82 singleLine = true,
76− keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
83+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
77 modifier = Modifier.fillMaxWidth(),
84 modifier = Modifier.fillMaxWidth(),
78 )
85 )
79 Text(
86 Text(
@@ -85,6 +92,7 @@ fun ServerEditScreen(
85 onValueChange = { v -> vm.update { it.copy(username = v) } },
92 onValueChange = { v -> vm.update { it.copy(username = v) } },
86 label = { Text(stringResource(R.string.username_optional)) },
93 label = { Text(stringResource(R.string.username_optional)) },
87 singleLine = true,
94 singleLine = true,
95+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
88 modifier = Modifier.fillMaxWidth(),
96 modifier = Modifier.fillMaxWidth(),
89 )
97 )
90 OutlinedTextField(
98 OutlinedTextField(
@@ -93,7 +101,8 @@ fun ServerEditScreen(
93 label = { Text(stringResource(R.string.password_optional)) },
101 label = { Text(stringResource(R.string.password_optional)) },
94 singleLine = true,
102 singleLine = true,
95 visualTransformation = PasswordVisualTransformation(),
103 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) }),
97 modifier = Modifier.fillMaxWidth(),
106 modifier = Modifier.fillMaxWidth(),
98 )
107 )
99 Button(
108 Button(
app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+53−52
@@ -106,68 +106,69 @@ fun ServerListScreen(
106 modifier = Modifier.fillMaxSize().padding(padding),
106 modifier = Modifier.fillMaxSize().padding(padding),
107 )
107 )
108 } else {
108 } else {
109− LazyColumn(
109+ LazyColumn(
110− modifier = Modifier.fillMaxSize().padding(padding),
110+ modifier = Modifier.fillMaxSize().padding(padding),
111− contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
111+ contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
112− verticalArrangement = Arrangement.spacedBy(12.dp),
112+ verticalArrangement = Arrangement.spacedBy(12.dp),
113− ) {
113+ ) {
114− items(profiles, key = { it.id }) { profile ->
114+ items(profiles, key = { it.id }) { profile ->
115− val isActive = profile.id == connectedId
115+ val isActive = profile.id == connectedId
116− Card(
116+ Card(
117− modifier = Modifier
117+ modifier = Modifier
118− .fillMaxWidth()
118+ .fillMaxWidth()
119+ .animateItem()
119− .clickable(enabled = connectingId == null) {
120+ .clickable(enabled = connectingId == null) {
120− vm.connect(profile, onConnected)
121+ vm.connect(profile, onConnected)
121− },
122+ },
122− ) {
123− Row(
124− modifier = Modifier.fillMaxWidth().padding(16.dp),
125− verticalAlignment = Alignment.CenterVertically,
126 ) {
123 ) {
124+ Row(
125+ modifier = Modifier.fillMaxWidth().padding(16.dp),
126+ verticalAlignment = Alignment.CenterVertically,
127+ ) {
127− Column(modifier = Modifier.weight(1f)) {
128+ 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) {
142 Text(
129 Text(
143− stringResource(R.string.connected),
130+ profile.displayLabel,
144− style = MaterialTheme.typography.labelSmall,
131+ style = MaterialTheme.typography.titleMedium,
145− color = MaterialTheme.colorScheme.primary,
132+ maxLines = 1,
133+ overflow = TextOverflow.Ellipsis,
146 )
134 )
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+ }
147 }
149 }
148− }
149− if (isActive) {
150+ if (isActive) {
150− Icon(
151+ Icon(
151− Icons.Filled.CheckCircle,
152+ Icons.Filled.CheckCircle,
152− contentDescription = stringResource(R.string.connected),
153+ contentDescription = stringResource(R.string.connected),
153− tint = MaterialTheme.colorScheme.primary,
154+ tint = MaterialTheme.colorScheme.primary,
154− modifier = Modifier.size(22.dp),
155+ modifier = Modifier.size(22.dp),
155− )
156+ )
156− } else if (connectingId == profile.id) {
157+ } else if (connectingId == profile.id) {
157− CircularProgressIndicator(modifier = Modifier.size(24.dp))
158+ CircularProgressIndicator(modifier = Modifier.size(24.dp))
158− } else {
159+ } else {
159− IconButton(onClick = { onEditProfile(profile.id) }) {
160+ IconButton(onClick = { onEditProfile(profile.id) }) {
160− Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.edit))
161+ Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.edit))
161− }
162+ }
162− IconButton(onClick = { pendingDelete = profile }) {
163+ IconButton(onClick = { pendingDelete = profile }) {
163− Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete))
164+ Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete))
165+ }
164 }
166 }
165 }
167 }
166 }
168 }
167 }
169 }
168 }
170 }
169 }
171 }
170− }
171 }
172 }
172 }
173 }
173
174
app/src/main/java/soy/iko/opencode/ui/server/ServerListViewModel.kt+1−1
@@ -42,7 +42,7 @@ class ServerListViewModel(private val container: AppContainer) : ViewModel() {
42 result.onSuccess { onConnected() }
42 result.onSuccess { onConnected() }
43 .onFailure {
43 .onFailure {
44 container.disconnect()
44 container.disconnect()
45− _error.value = it.message ?: container.string(R.string.error_not_reachable, profile.baseUrl)
45+ _error.value = container.friendlyError(it)
46 }
46 }
47 }
47 }
48 }
48 }
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+16−3
@@ -12,6 +12,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
12import androidx.compose.foundation.layout.padding
12import androidx.compose.foundation.layout.padding
13import androidx.compose.foundation.layout.size
13import androidx.compose.foundation.layout.size
14import androidx.compose.foundation.lazy.LazyColumn
14import androidx.compose.foundation.lazy.LazyColumn
15+import androidx.compose.foundation.text.KeyboardActions
16+import androidx.compose.foundation.text.KeyboardOptions
15import androidx.compose.foundation.lazy.items
17import androidx.compose.foundation.lazy.items
16import androidx.compose.material.icons.Icons
18import androidx.compose.material.icons.Icons
17import androidx.compose.material.icons.automirrored.filled.Logout
19import androidx.compose.material.icons.automirrored.filled.Logout
@@ -48,6 +50,10 @@ import androidx.compose.runtime.setValue
48import androidx.compose.ui.Alignment
50import androidx.compose.ui.Alignment
49import androidx.compose.ui.Modifier
51import androidx.compose.ui.Modifier
50import androidx.compose.ui.platform.LocalHapticFeedback
52import 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
51import androidx.compose.ui.text.style.TextOverflow
57import androidx.compose.ui.text.style.TextOverflow
52import androidx.compose.ui.unit.dp
58import androidx.compose.ui.unit.dp
53import androidx.compose.ui.res.stringResource
59import androidx.compose.ui.res.stringResource
@@ -85,7 +91,9 @@ fun SessionListScreen(
85 Column {
91 Column {
86 Box {
92 Box {
87 Row(
93 Row(
94+ modifier = Modifier
88− modifier = Modifier.clickable { showServerMenu = true },
95+ .clickable { showServerMenu = true }
96+ .semantics { role = Role.Button },
89 verticalAlignment = Alignment.CenterVertically,
97 verticalAlignment = Alignment.CenterVertically,
90 ) {
98 ) {
91 Text(serverLabel, maxLines = 1, overflow = TextOverflow.Ellipsis)
99 Text(serverLabel, maxLines = 1, overflow = TextOverflow.Ellipsis)
@@ -157,7 +165,7 @@ fun SessionListScreen(
157 when {
165 when {
158 state.loading -> CircularProgressIndicator(Modifier.align(Alignment.Center))
166 state.loading -> CircularProgressIndicator(Modifier.align(Alignment.Center))
159 state.error != null -> Text(
167 state.error != null -> Text(
160− state.error!!,
168+ state.error ?: "",
161 modifier = Modifier.align(Alignment.Center).padding(24.dp),
169 modifier = Modifier.align(Alignment.Center).padding(24.dp),
162 color = MaterialTheme.colorScheme.error,
170 color = MaterialTheme.colorScheme.error,
163 )
171 )
@@ -173,6 +181,7 @@ fun SessionListScreen(
173 placeholder = { Text(stringResource(R.string.search_sessions)) },
181 placeholder = { Text(stringResource(R.string.search_sessions)) },
174 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
182 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
175 singleLine = true,
183 singleLine = true,
184+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
176 )
185 )
177 val sessions = state.filtered
186 val sessions = state.filtered
178 if (sessions.isEmpty()) {
187 if (sessions.isEmpty()) {
@@ -194,6 +203,7 @@ fun SessionListScreen(
194 onClick = { onOpenSession(session.id) },
203 onClick = { onOpenSession(session.id) },
195 onRename = { pendingRename = session },
204 onRename = { pendingRename = session },
196 onDelete = { pendingDelete = session },
205 onDelete = { pendingDelete = session },
206+ modifier = Modifier.animateItem(),
197 )
207 )
198 }
208 }
199 }
209 }
@@ -253,6 +263,8 @@ private fun RenameSessionDialog(
253 modifier = Modifier.fillMaxWidth(),
263 modifier = Modifier.fillMaxWidth(),
254 placeholder = { Text(stringResource(R.string.session_title_hint)) },
264 placeholder = { Text(stringResource(R.string.session_title_hint)) },
255 singleLine = true,
265 singleLine = true,
266+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
267+ keyboardActions = KeyboardActions(onDone = { if (title.isNotBlank()) onConfirm() }),
256 )
268 )
257 },
269 },
258 confirmButton = {
270 confirmButton = {
@@ -271,9 +283,10 @@ private fun SessionCard(
271 onClick: () -> Unit,
283 onClick: () -> Unit,
272 onRename: () -> Unit,
284 onRename: () -> Unit,
273 onDelete: () -> Unit,
285 onDelete: () -> Unit,
286+ modifier: Modifier = Modifier,
274) {
287) {
275 Card(
288 Card(
276− modifier = Modifier
289+ modifier = modifier
277 .fillMaxWidth()
290 .fillMaxWidth()
278 .clickable { onClick() },
291 .clickable { onClick() },
279 ) {
292 ) {
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+5−5
@@ -84,7 +84,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
84 _state.value = _state.value.copy(sessions = sorted, loading = false)
84 _state.value = _state.value.copy(sessions = sorted, loading = false)
85 loadPreviews(sorted)
85 loadPreviews(sorted)
86 }
86 }
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)) }
88 }
88 }
89 }
89 }
90
90
@@ -120,7 +120,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
120 viewModelScope.launch {
120 viewModelScope.launch {
121 runCatching { conn.repository.createSession() }
121 runCatching { conn.repository.createSession() }
122 .onSuccess { onCreated(it.id); refresh() }
122 .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)) }
124 }
124 }
125 }
125 }
126
126
@@ -129,7 +129,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
129 viewModelScope.launch {
129 viewModelScope.launch {
130 runCatching { conn.repository.deleteSession(session.id) }
130 runCatching { conn.repository.deleteSession(session.id) }
131 .onSuccess { refresh() }
131 .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)) }
133 }
133 }
134 }
134 }
135
135
@@ -140,7 +140,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
140 viewModelScope.launch {
140 viewModelScope.launch {
141 runCatching { conn.api.updateSession(session.id, title) }
141 runCatching { conn.api.updateSession(session.id, title) }
142 .onSuccess { refresh() }
142 .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)) }
144 }
144 }
145 }
145 }
146
146
@@ -159,7 +159,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
159 result.onSuccess { refresh() }
159 result.onSuccess { refresh() }
160 .onFailure {
160 .onFailure {
161 container.disconnect()
161 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))
163 refresh()
163 refresh()
164 }
164 }
165 }
165 }
app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+24−5
@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Spacer
8import androidx.compose.foundation.layout.fillMaxWidth
8import androidx.compose.foundation.layout.fillMaxWidth
9import androidx.compose.foundation.layout.padding
9import androidx.compose.foundation.layout.padding
10import androidx.compose.foundation.layout.size
10import androidx.compose.foundation.layout.size
11+import androidx.compose.foundation.layout.widthIn
11import androidx.compose.foundation.selection.selectable
12import androidx.compose.foundation.selection.selectable
12import androidx.compose.material.icons.Icons
13import androidx.compose.material.icons.Icons
13import androidx.compose.material.icons.automirrored.filled.ArrowBack
14import androidx.compose.material.icons.automirrored.filled.ArrowBack
@@ -32,6 +33,8 @@ import androidx.compose.ui.Alignment
32import androidx.compose.ui.Modifier
33import androidx.compose.ui.Modifier
33import androidx.compose.ui.platform.LocalContext
34import androidx.compose.ui.platform.LocalContext
34import androidx.compose.ui.res.stringResource
35import androidx.compose.ui.res.stringResource
36+import androidx.compose.ui.semantics.heading
37+import androidx.compose.ui.semantics.semantics
35import androidx.compose.ui.unit.dp
38import androidx.compose.ui.unit.dp
36import androidx.lifecycle.compose.collectAsStateWithLifecycle
39import androidx.lifecycle.compose.collectAsStateWithLifecycle
37import kotlinx.coroutines.launch
40import kotlinx.coroutines.launch
@@ -47,9 +50,10 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
47 val dynamicColor by container.settingsStore.dynamicColor.collectAsStateWithLifecycle(initialValue = true)
50 val dynamicColor by container.settingsStore.dynamicColor.collectAsStateWithLifecycle(initialValue = true)
48 val activeProfile = container.activeConnection.collectAsStateWithLifecycle().value?.profile
51 val activeProfile = container.activeConnection.collectAsStateWithLifecycle().value?.profile
49 val context = LocalContext.current
52 val context = LocalContext.current
53+ val unknownVersion = stringResource(R.string.unknown_version)
50 val versionName = remember {
54 val versionName = remember {
51 runCatching { context.packageManager.getPackageInfo(context.packageName, 0).versionName }
55 runCatching { context.packageManager.getPackageInfo(context.packageName, 0).versionName }
52− .getOrNull() ?: "0.1.0"
56+ .getOrNull() ?: unknownVersion
53 }
57 }
54
58
55 Scaffold(
59 Scaffold(
@@ -64,8 +68,13 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
64 )
68 )
65 },
69 },
66 ) { padding ->
70 ) { padding ->
67− Column(modifier = Modifier.padding(padding).padding(16.dp)) {
71+ Column(modifier = Modifier.padding(padding).widthIn(max = 600.dp).padding(16.dp)) {
72+ Text(
73+ stringResource(R.string.appearance),
68− Text(stringResource(R.string.appearance), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary)
74+ style = MaterialTheme.typography.titleSmall,
75+ color = MaterialTheme.colorScheme.primary,
76+ modifier = Modifier.semantics { heading() },
77+ )
69 ThemeMode.entries.forEach { mode ->
78 ThemeMode.entries.forEach { mode ->
70 ThemeRow(
79 ThemeRow(
71 mode = mode,
80 mode = mode,
@@ -95,7 +104,12 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
95
104
96 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
105 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
97
106
107+ Text(
108+ stringResource(R.string.connection),
98− Text(stringResource(R.string.connection), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary)
109+ style = MaterialTheme.typography.titleSmall,
110+ color = MaterialTheme.colorScheme.primary,
111+ modifier = Modifier.semantics { heading() },
112+ )
99 if (activeProfile != null) {
113 if (activeProfile != null) {
100 Text(activeProfile.displayLabel, modifier = Modifier.padding(top = 8.dp))
114 Text(activeProfile.displayLabel, modifier = Modifier.padding(top = 8.dp))
101 Text(
115 Text(
@@ -125,7 +139,12 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
125
139
126 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
140 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
127
141
142+ Text(
143+ stringResource(R.string.about),
128− Text(stringResource(R.string.about), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary)
144+ style = MaterialTheme.typography.titleSmall,
145+ color = MaterialTheme.colorScheme.primary,
146+ modifier = Modifier.semantics { heading() },
147+ )
129 Text(
148 Text(
130 stringResource(R.string.about_version, versionName),
149 stringResource(R.string.about_version, versionName),
131 style = MaterialTheme.typography.bodyMedium,
150 style = MaterialTheme.typography.bodyMedium,
app/src/main/res/values/strings.xml+16
@@ -1,6 +1,10 @@
1<?xml version="1.0" encoding="utf-8"?>
1<?xml version="1.0" encoding="utf-8"?>
2<resources>
2<resources>
3 <string name="app_name">opencode</string>
3 <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>
4
8
5 <!-- Common -->
9 <!-- Common -->
6 <string name="back">Back</string>
10 <string name="back">Back</string>
@@ -67,6 +71,8 @@
67 <string name="session">Session</string>
71 <string name="session">Session</string>
68 <string name="default_model">default model</string>
72 <string name="default_model">default model</string>
69 <string name="message_placeholder">Message opencode…</string>
73 <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>
70 <string name="working">working…</string>
76 <string name="working">working…</string>
71 <string name="latest">Latest</string>
77 <string name="latest">Latest</string>
72 <string name="stop_and_exit_title">Stop and exit?</string>
78 <string name="stop_and_exit_title">Stop and exit?</string>
@@ -78,6 +84,7 @@
78 <string name="choose_model">Choose model</string>
84 <string name="choose_model">Choose model</string>
79
85
80 <!-- Errors (view models) -->
86 <!-- Errors (view models) -->
87+ <string name="error_generic">Something went wrong.</string>
81 <string name="error_failed_to_send">Failed to send</string>
88 <string name="error_failed_to_send">Failed to send</string>
82 <string name="error_agent_reported">The agent reported an error.</string>
89 <string name="error_agent_reported">The agent reported an error.</string>
83 <string name="error_failed_command">Failed to run command</string>
90 <string name="error_failed_command">Failed to run command</string>
@@ -113,7 +120,16 @@
113 <string name="search_files">Search files…</string>
120 <string name="search_files">Search files…</string>
114 <string name="root">Root</string>
121 <string name="root">Root</string>
115 <string name="no_matches">No matches.</string>
122 <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>
116 <string name="binary_file">Binary file — preview not supported.</string>
126 <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>
117
133
118 <!-- Settings -->
134 <!-- Settings -->
119 <string name="appearance">Appearance</string>
135 <string name="appearance">Appearance</string>