git.iko.soy/opencode-android
on master help sign in
Fix nested-LazyColumn diff crash, render inline image attachments, add session-list connection banner and unread badges, conflate the message stream, classify network errors by concrete type, support swipe-to-delete and conversation export, and derive versionCode from the git commit count
0adc7fdiko
README.md+15−1
@@ -6,7 +6,7 @@ Application id: `soy.iko.opencode`
6
6
7## Status
7## Status
8
8
9−Milestones M0–M9 complete:
9+Milestones M0–M10 complete:
10
10
11- **M0–M4:** connect to a server → list/create/delete sessions → chat with **live SSE streaming**
11- **M0–M4:** connect to a server → list/create/delete sessions → chat with **live SSE streaming**
12 of the assistant's reply. Tool calls, reasoning, and token/cost are rendered.
12 of the assistant's reply. Tool calls, reasoning, and token/cost are rendered.
@@ -55,6 +55,20 @@ Milestones M0–M9 complete:
55 endpoints (sessions, messages, files, providers); and **per-code-block copy
55 endpoints (sessions, messages, files, providers); and **per-code-block copy
56 buttons** in rendered markdown.
56 buttons** in rendered markdown.
57
57
58+- **M10:** **Reliability & UX hardening pass.** Fixed a latent crash where a diff in a
59+ tool output nested a second `LazyColumn` inside the message list (now a bounded
60+ column); **inline image rendering** for `file` parts — attachments are decoded
61+ (data URIs) or fetched with Basic auth via the already-present Coil dependency;
62+ **connection banner on the session list** so a dropped SSE stream is visible before
63+ the list goes stale; **unread activity badges** — background sessions that receive a
64+ reply get a dot until opened; **conflated message stream** so per-token snapshots no
65+ longer back-pressure the event reducer on long replies; **typed error reporting** —
66+ network/timeout/HTTP-status failures are classified by concrete exception type (with
67+ a unit-tested classifier) instead of string-matching class names; **swipe-to-delete**
68+ on session cards; **conversation export/share** (Markdown via the system share
69+ sheet); and an **auto-incrementing `versionCode`** derived from the git commit count
70+ so each release is strictly newer.
71+
58## Architecture
72## Architecture
59
73
60- **Ktor 3 (OkHttp engine)** for REST + the long-lived `GET /event` SSE stream.
74- **Ktor 3 (OkHttp engine)** for REST + the long-lived `GET /event` SSE stream.
app/build.gradle.kts+16−1
@@ -5,6 +5,17 @@ plugins {
5 alias(libs.plugins.kotlin.compose)
5 alias(libs.plugins.kotlin.compose)
6}
6}
7
7
8+/**
9+ * Monotonically increasing versionCode: the git commit count on HEAD, so each release
10+ * ships a strictly higher code (never a "downgrade" block) without manual bookkeeping.
11+ * Falls back to 1 when git or the history isn't available (e.g. a plain source export).
12+ */
13+val gitCommitCount: Int = runCatching {
14+ providers.exec {
15+ commandLine("git", "rev-list", "--count", "HEAD")
16+ }.standardOutput.asText.get().trim().toInt()
17+}.getOrDefault(1)
18+
8android {
19android {
9 namespace = "soy.iko.opencode"
20 namespace = "soy.iko.opencode"
10 compileSdk = 35
21 compileSdk = 35
@@ -13,7 +24,11 @@ android {
13 applicationId = "soy.iko.opencode"
24 applicationId = "soy.iko.opencode"
14 minSdk = 26
25 minSdk = 26
15 targetSdk = 35
26 targetSdk = 35
16− versionCode = 1
27+ // Monotonically increasing across builds: derived from the git commit count so
28+ // each release ships a strictly higher versionCode without manual bookkeeping
29+ // (and never triggers a "downgrade" install block). Falls back to 1 when the
30+ // .git history isn't available (e.g. a plain source export).
31+ versionCode = gitCommitCount
17 versionName = "0.1.0"
32 versionName = "0.1.0"
18 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
33 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
19 }
34 }
app/src/main/java/soy/iko/opencode/data/repo/ErrorKind.ktnew file+77
@@ -0,0 +1,77 @@
1+package soy.iko.opencode.data.repo
2+
3+import io.ktor.client.plugins.ClientRequestException
4+import io.ktor.client.plugins.HttpRequestTimeoutException
5+import io.ktor.client.plugins.ResponseException
6+import io.ktor.client.plugins.ServerResponseException
7+import java.io.IOException
8+import java.net.ConnectException
9+import java.net.SocketTimeoutException
10+import java.net.UnknownHostException
11+
12+/**
13+ * User-facing categories for a thrown error. Extracted so the classification logic is
14+ * unit-testable without an Android [android.content.Context] (the mapping from a kind
15+ * to a localized string lives in [friendlyError]).
16+ */
17+enum class ErrorKind {
18+ /** Server host could not be resolved / connection refused. */
19+ NOT_REACHABLE,
20+
21+ /** Connect or read timed out. */
22+ TIMEOUT,
23+
24+ /** Server returned a 5xx response. */
25+ SERVER,
26+
27+ /** Server returned a 4xx response (auth, not-found, etc.). */
28+ CLIENT,
29+
30+ /** Any other network-level I/O failure. */
31+ NETWORK,
32+
33+ /** Anything that isn't network-related. */
34+ UNKNOWN,
35+}
36+
37+/**
38+ * Classify a throwable into an [ErrorKind] by inspecting concrete exception types rather
39+ * than string-matching class names. Unwraps [cause] chains so an exception surface-wrapped
40+ * by a library still classifies correctly.
41+ */
42+fun classifyError(t: Throwable): ErrorKind {
43+ var current: Throwable? = t
44+ // Walk a bounded cause chain so wrappers (e.g. an IllegalStateException around an
45+ // IOException) still classify by their root.
46+ var hops = 0
47+ while (current != null && hops < 8) {
48+ when (current) {
49+ is UnknownHostException, is ConnectException -> return ErrorKind.NOT_REACHABLE
50+ is SocketTimeoutException, is HttpRequestTimeoutException ->
51+ return ErrorKind.TIMEOUT
52+ is ServerResponseException -> return ErrorKind.SERVER
53+ is ClientRequestException -> return ErrorKind.CLIENT
54+ is IOException -> return ErrorKind.NETWORK
55+ else -> {
56+ // ResponseException covers any other HTTP status failure (e.g. a redirect
57+ // or an unrecognized status family) surfaced because expectSuccess = true.
58+ if (current is ResponseException) return ErrorKind.SERVER
59+ }
60+ }
61+ current = current.cause
62+ hops++
63+ }
64+ return ErrorKind.UNKNOWN
65+}
66+
67+/** The HTTP status code carried by a client/server error, if any. */
68+fun responseStatusCode(t: Throwable): Int? {
69+ var current: Throwable? = t
70+ var hops = 0
71+ while (current != null && hops < 8) {
72+ if (current is ResponseException) return current.response.status.value
73+ current = current.cause
74+ hops++
75+ }
76+ return null
77+}
app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+5−2
@@ -15,6 +15,7 @@ import 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
17import kotlinx.coroutines.flow.channelFlow
17import kotlinx.coroutines.flow.channelFlow
18+import kotlinx.coroutines.flow.conflate
18import kotlinx.coroutines.launch
19import kotlinx.coroutines.launch
19import kotlinx.coroutines.sync.Mutex
20import kotlinx.coroutines.sync.Mutex
20import kotlinx.coroutines.sync.withLock
21import kotlinx.coroutines.sync.withLock
@@ -46,7 +47,9 @@ class SessionRepository(
46 /**
47 /**
47 * A live, ordered view of [sessionId]'s messages. Begins collecting the event
48 * A live, ordered view of [sessionId]'s messages. Begins collecting the event
48 * stream before the initial REST load so no streamed part is missed, then reduces
49 * stream before the initial REST load so no streamed part is missed, then reduces
49− * events into the in-memory state.
50+ * events into the in-memory state. The flow is conflated so a fast burst of
51+ * streaming tokens (each publishing a fresh snapshot) never back-pressures the
52+ * event reducer — a slow collector simply sees the most recent snapshot.
50 */
53 */
51 fun observeMessages(sessionId: String): Flow<List<MessageWithParts>> = channelFlow {
54 fun observeMessages(sessionId: String): Flow<List<MessageWithParts>> = channelFlow {
52 val store = MessageStore()
55 val store = MessageStore()
@@ -73,7 +76,7 @@ class SessionRepository(
73
76
74 // Keep the flow alive until the collector cancels; the launched job is torn down with it.
77 // Keep the flow alive until the collector cancels; the launched job is torn down with it.
75 job.join()
78 job.join()
76− }
79+ }.conflate()
77
80
78 companion object {
81 companion object {
79 /** Convenience: is this event a run-completion signal for [sessionId]? */
82 /** Convenience: is this event a run-completion signal for [sessionId]? */
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+67−14
@@ -4,18 +4,27 @@ import android.content.Context
4import android.net.ConnectivityManager
4import android.net.ConnectivityManager
5import android.net.Network
5import android.net.Network
6import android.net.NetworkRequest
6import android.net.NetworkRequest
7+import soy.iko.opencode.data.model.BusEvent
8+import soy.iko.opencode.data.model.MessagePartUpdated
9+import soy.iko.opencode.data.model.MessageUpdated
7import soy.iko.opencode.data.model.ServerProfile
10import soy.iko.opencode.data.model.ServerProfile
8import soy.iko.opencode.data.repo.DraftStore
11import soy.iko.opencode.data.repo.DraftStore
12+import soy.iko.opencode.data.repo.ErrorKind
9import soy.iko.opencode.data.repo.ProfileStore
13import soy.iko.opencode.data.repo.ProfileStore
10import soy.iko.opencode.data.repo.SettingsStore
14import soy.iko.opencode.data.repo.SettingsStore
15+import soy.iko.opencode.data.repo.classifyError
11import soy.iko.opencode.R
16import soy.iko.opencode.R
12import kotlinx.coroutines.CoroutineScope
17import kotlinx.coroutines.CoroutineScope
13import kotlinx.coroutines.Dispatchers
18import kotlinx.coroutines.Dispatchers
19+import kotlinx.coroutines.ExperimentalCoroutinesApi
14import kotlinx.coroutines.SupervisorJob
20import kotlinx.coroutines.SupervisorJob
15import kotlinx.coroutines.flow.MutableStateFlow
21import kotlinx.coroutines.flow.MutableStateFlow
16import kotlinx.coroutines.flow.StateFlow
22import kotlinx.coroutines.flow.StateFlow
17import kotlinx.coroutines.flow.asStateFlow
23import kotlinx.coroutines.flow.asStateFlow
24+import kotlinx.coroutines.flow.emptyFlow
18import kotlinx.coroutines.flow.first
25import kotlinx.coroutines.flow.first
26+import kotlinx.coroutines.flow.flatMapLatest
27+import kotlinx.coroutines.flow.update
19import kotlinx.coroutines.launch
28import kotlinx.coroutines.launch
20import java.util.concurrent.atomic.AtomicBoolean
29import java.util.concurrent.atomic.AtomicBoolean
21
30
@@ -55,36 +64,80 @@ class AppContainer(context: Context) {
55 fun setPendingShare(text: String) { _pendingShare.value = text }
64 fun setPendingShare(text: String) { _pendingShare.value = text }
56 fun consumePendingShare(): String? = _pendingShare.value.also { _pendingShare.value = null }
65 fun consumePendingShare(): String? = _pendingShare.value.also { _pendingShare.value = null }
57
66
67+ /**
68+ * The session the user is currently viewing (or null when not in a chat). Drives the
69+ * unread tracker: messages arriving for any *other* session mark it unread, and
70+ * opening a session clears its unread state.
71+ */
72+ private val _currentSession = MutableStateFlow<String?>(null)
73+ val currentSession: StateFlow<String?> = _currentSession.asStateFlow()
74+
75+ /** Session ids that received activity while not being viewed. */
76+ private val _unread = MutableStateFlow<Set<String>>(emptySet())
77+ val unread: StateFlow<Set<String>> = _unread.asStateFlow()
78+
79+ fun setCurrentSession(id: String?) {
80+ _currentSession.value = id
81+ if (id != null) _unread.update { it - id }
82+ }
83+
58 /** Resolve a localized string — view models reach resources through the container. */
84 /** Resolve a localized string — view models reach resources through the container. */
59 fun string(id: Int, vararg formatArgs: Any): String =
85 fun string(id: Int, vararg formatArgs: Any): String =
60 if (formatArgs.isEmpty()) appContext.getString(id) else appContext.getString(id, *formatArgs)
86 if (formatArgs.isEmpty()) appContext.getString(id) else appContext.getString(id, *formatArgs)
61
87
62 /**
88 /**
63− * Convert a throwable into a user-facing message. Ktor/OkHttp exceptions expose
89+ * Convert a throwable into a user-facing message. Classifies the error by concrete
64− * developer-oriented detail (URL schemes, internal state) that is not useful to
90+ * type (network, timeout, HTTP status) rather than string-matching class names, so
65− * end users, so we collapse those to a generic "could not reach server" message.
91+ * the message reflects what actually went wrong without leaking internal URLs/state.
66 */
92 */
67 fun friendlyError(t: Throwable): String {
93 fun friendlyError(t: Throwable): String {
68 val msg = t.message.orEmpty()
94 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())
95+ val baseUrl = activeConnection.value?.profile?.baseUrl.orEmpty()
78− } else {
96+ return when (classifyError(t)) {
97+ ErrorKind.NOT_REACHABLE, ErrorKind.NETWORK ->
98+ string(R.string.error_not_reachable, baseUrl)
99+ ErrorKind.TIMEOUT -> string(R.string.error_timeout)
100+ ErrorKind.SERVER -> string(R.string.error_server)
101+ ErrorKind.CLIENT -> msg.ifBlank { string(R.string.error_generic) }
79− msg.ifBlank { string(R.string.error_generic) }
102+ ErrorKind.UNKNOWN -> msg.ifBlank { string(R.string.error_generic) }
80 }
103 }
81 }
104 }
82
105
83 init {
106 init {
84 registerNetworkMonitor()
107 registerNetworkMonitor()
108+ observeMessageActivity()
85 appScope.launch { autoConnect() }
109 appScope.launch { autoConnect() }
86 }
110 }
87
111
112+ /**
113+ * Watch the SSE bus for new message activity and badge any session that isn't
114+ * currently open. This powers the unread dot on the session list so the user can
115+ * tell which conversations got a reply while they were elsewhere.
116+ *
117+ * Uses [flatMapLatest] so a server switch (which replaces the active connection)
118+ * re-subscribes to the new event stream and drops the old one.
119+ */
120+ @OptIn(ExperimentalCoroutinesApi::class)
121+ private fun observeMessageActivity() {
122+ appScope.launch {
123+ activeConnection
124+ .flatMapLatest { conn -> conn?.events?.events ?: emptyFlow() }
125+ .collect { event ->
126+ val sid = sessionOf(event) ?: return@collect
127+ if (sid != _currentSession.value) {
128+ _unread.update { it + sid }
129+ }
130+ }
131+ }
132+ }
133+
134+ /** Extract the session id an event pertains to, for message-activity events. */
135+ private fun sessionOf(event: BusEvent): String? = when (event) {
136+ is MessageUpdated -> event.properties.info.sessionID
137+ is MessagePartUpdated -> event.properties.part.sessionID ?: event.properties.sessionID
138+ else -> null
139+ }
140+
88 /**
141 /**
89 * On cold start, transparently reconnect to the most recently used server so a
142 * On cold start, transparently reconnect to the most recently used server so a
90 * returning user lands in their session list instead of the empty server screen.
143 * returning user lands in their session list instead of the empty server screen.
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+33−32
@@ -26,6 +26,7 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
26import androidx.compose.material.icons.automirrored.filled.Send
26import androidx.compose.material.icons.automirrored.filled.Send
27import androidx.compose.material.icons.filled.AutoAwesome
27import androidx.compose.material.icons.filled.AutoAwesome
28import androidx.compose.material.icons.filled.KeyboardArrowDown
28import androidx.compose.material.icons.filled.KeyboardArrowDown
29+import androidx.compose.material.icons.filled.Share
29import androidx.compose.material.icons.filled.SmartToy
30import androidx.compose.material.icons.filled.SmartToy
30import androidx.compose.material.icons.filled.Stop
31import androidx.compose.material.icons.filled.Stop
31import androidx.compose.material.icons.filled.Terminal
32import androidx.compose.material.icons.filled.Terminal
@@ -66,6 +67,7 @@ import androidx.compose.ui.input.key.isShiftPressed
66import androidx.compose.ui.input.key.key
67import androidx.compose.ui.input.key.key
67import androidx.compose.ui.input.key.onPreviewKeyEvent
68import androidx.compose.ui.input.key.onPreviewKeyEvent
68import androidx.compose.ui.input.key.type
69import androidx.compose.ui.input.key.type
70+import androidx.compose.ui.platform.LocalContext
69import androidx.compose.ui.platform.LocalHapticFeedback
71import androidx.compose.ui.platform.LocalHapticFeedback
70import androidx.compose.ui.platform.LocalView
72import androidx.compose.ui.platform.LocalView
71import androidx.compose.ui.res.stringResource
73import androidx.compose.ui.res.stringResource
@@ -82,6 +84,8 @@ import soy.iko.opencode.data.model.ReasoningPart
82import soy.iko.opencode.data.model.TextPart
84import soy.iko.opencode.data.model.TextPart
83import soy.iko.opencode.di.AppContainer
85import soy.iko.opencode.di.AppContainer
84import soy.iko.opencode.R
86import soy.iko.opencode.R
87+import soy.iko.opencode.ui.components.ConnectionBanner
88+import soy.iko.opencode.ui.components.toImageContext
85import soy.iko.opencode.ui.vmFactory
89import soy.iko.opencode.ui.vmFactory
86
90
87@OptIn(ExperimentalMaterial3Api::class)
91@OptIn(ExperimentalMaterial3Api::class)
@@ -108,6 +112,20 @@ fun ChatScreen(
108 val draft by vm.draft.collectAsStateWithLifecycle()
112 val draft by vm.draft.collectAsStateWithLifecycle()
109 val haptics = LocalHapticFeedback.current
113 val haptics = LocalHapticFeedback.current
110 val scope = rememberCoroutineScope()
114 val scope = rememberCoroutineScope()
115+ val shareLauncher = LocalContext.current
116+ val shareLabel = stringResource(R.string.share)
117+
118+ // Connection profile (with auth) for rendering inline image attachments.
119+ val imageContext = remember(container.activeConnection.value?.profile) {
120+ container.activeConnection.value?.profile?.toImageContext()
121+ }
122+
123+ // Mark this session as read while the user is viewing it; clear on leave so new
124+ // background activity can badge it again.
125+ DisposableEffect(sessionId) {
126+ container.setCurrentSession(sessionId)
127+ onDispose { if (container.currentSession.value == sessionId) container.setCurrentSession(null) }
128+ }
111
129
112 val listState = rememberLazyListState()
130 val listState = rememberLazyListState()
113 val snackbar = remember { SnackbarHostState() }
131 val snackbar = remember { SnackbarHostState() }
@@ -194,6 +212,20 @@ fun ChatScreen(
194 }
212 }
195 },
213 },
196 actions = {
214 actions = {
215+ IconButton(
216+ onClick = {
217+ val md = buildConversationMarkdown(messages, sessionTitle)
218+ val send = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
219+ type = "text/markdown"
220+ putExtra(android.content.Intent.EXTRA_SUBJECT, sessionTitle ?: "opencode session")
221+ putExtra(android.content.Intent.EXTRA_TEXT, md)
222+ }
223+ shareLauncher.startActivity(android.content.Intent.createChooser(send, shareLabel))
224+ },
225+ enabled = messages.isNotEmpty(),
226+ ) {
227+ Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share_conversation))
228+ }
197 IconButton(onClick = { showCommandPicker = true }, enabled = commands.isNotEmpty()) {
229 IconButton(onClick = { showCommandPicker = true }, enabled = commands.isNotEmpty()) {
198 Icon(Icons.Filled.Terminal, contentDescription = stringResource(R.string.commands))
230 Icon(Icons.Filled.Terminal, contentDescription = stringResource(R.string.commands))
199 }
231 }
@@ -252,7 +284,7 @@ fun ChatScreen(
252 verticalArrangement = Arrangement.spacedBy(16.dp),
284 verticalArrangement = Arrangement.spacedBy(16.dp),
253 ) {
285 ) {
254 items(messages, key = { it.info.id }) { message ->
286 items(messages, key = { it.info.id }) { message ->
255− MessageBubble(message, isRunning = running)
287+ MessageBubble(message, isRunning = running, imageContext = imageContext)
256 }
288 }
257 if (running) {
289 if (running) {
258 item(key = "__typing") {
290 item(key = "__typing") {
@@ -336,37 +368,6 @@ fun ChatScreen(
336 }
368 }
337}
369}
338
370
339−@Composable
340−private fun ConnectionBanner(
341− state: EventStreamClient.ConnectionState,
342− modifier: Modifier = Modifier,
343−) {
344− val text = when (state) {
345− EventStreamClient.ConnectionState.Connecting -> stringResource(R.string.connecting)
346− EventStreamClient.ConnectionState.Disconnected -> stringResource(R.string.reconnecting)
347− EventStreamClient.ConnectionState.Connected -> null
348− }
349− if (text != null) {
350− Surface(
351− modifier = modifier.fillMaxWidth(),
352− color = MaterialTheme.colorScheme.tertiaryContainer,
353− ) {
354− Row(
355− modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
356− horizontalArrangement = Arrangement.Center,
357− verticalAlignment = Alignment.CenterVertically,
358− ) {
359− CircularProgressIndicator(Modifier.size(12.dp), strokeWidth = 2.dp)
360− Text(
361− " $text",
362− style = MaterialTheme.typography.labelMedium,
363− color = MaterialTheme.colorScheme.onTertiaryContainer,
364− )
365− }
366− }
367− }
368−}
369−
370@Composable
371@Composable
371private fun ChatInputBar(
372private fun ChatInputBar(
372 value: String,
373 value: String,
app/src/main/java/soy/iko/opencode/ui/chat/ConversationExport.ktnew file+40
@@ -0,0 +1,40 @@
1+package soy.iko.opencode.ui.chat
2+
3+import soy.iko.opencode.data.model.AssistantMessage
4+import soy.iko.opencode.data.model.MessageWithParts
5+import soy.iko.opencode.data.model.ReasoningPart
6+import soy.iko.opencode.data.model.TextPart
7+import soy.iko.opencode.data.model.UserMessage
8+
9+/**
10+ * Render a conversation as Markdown for sharing/exporting. Each message becomes a
11+ * role-prefixed section containing its text parts; reasoning and tool parts are
12+ * omitted to keep the exported transcript readable. Pure (no Android deps) so it can
13+ * be unit-tested directly.
14+ */
15+fun buildConversationMarkdown(messages: List<MessageWithParts>, title: String?): String {
16+ val sb = StringBuilder()
17+ if (!title.isNullOrBlank()) {
18+ sb.append("# ").append(title.trim()).append("\n\n")
19+ }
20+ for (message in messages) {
21+ val heading = when (message.info) {
22+ is UserMessage -> "## You"
23+ is AssistantMessage -> "## opencode"
24+ else -> null
25+ } ?: continue
26+ val body = message.parts
27+ .mapNotNull { part ->
28+ when (part) {
29+ is TextPart -> part.text.takeIf { it.isNotBlank() }
30+ is ReasoningPart -> part.text.takeIf { it.isNotBlank() }?.let { "_${it}_" }
31+ else -> null
32+ }
33+ }
34+ .joinToString("\n\n")
35+ .trim()
36+ if (body.isEmpty()) continue
37+ sb.append(heading).append("\n\n").append(body).append("\n\n")
38+ }
39+ return sb.toString().trimEnd()
40+}
app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+13−7
@@ -18,19 +18,25 @@ import soy.iko.opencode.data.model.AssistantMessage
18import soy.iko.opencode.data.model.MessageWithParts
18import soy.iko.opencode.data.model.MessageWithParts
19import soy.iko.opencode.data.model.Tokens
19import soy.iko.opencode.data.model.Tokens
20import soy.iko.opencode.data.model.UserMessage
20import soy.iko.opencode.data.model.UserMessage
21+import soy.iko.opencode.ui.components.ImageLoadContext
21import soy.iko.opencode.ui.components.relativeTime
22import soy.iko.opencode.ui.components.relativeTime
22
23
23/** A single message: user prompts right-aligned in a bubble, assistant output full-width. */
24/** A single message: user prompts right-aligned in a bubble, assistant output full-width. */
24@Composable
25@Composable
26+fun MessageBubble(
27+ message: MessageWithParts,
28+ isRunning: Boolean = false,
25−fun MessageBubble(message: MessageWithParts, isRunning: Boolean = false, modifier: Modifier = Modifier) {
29+ modifier: Modifier = Modifier,
30+ imageContext: ImageLoadContext? = null,
31+) {
26 when (message.info) {
32 when (message.info) {
27− is UserMessage -> UserBubble(message, modifier)
33+ is UserMessage -> UserBubble(message, imageContext, modifier)
28− else -> AssistantBlock(message, isRunning, modifier)
34+ else -> AssistantBlock(message, isRunning, imageContext, modifier)
29 }
35 }
30}
36}
31
37
32@Composable
38@Composable
33−private fun UserBubble(message: MessageWithParts, modifier: Modifier) {
39+private fun UserBubble(message: MessageWithParts, imageContext: ImageLoadContext?, modifier: Modifier) {
34 Row(modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
40 Row(modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
35 Column(
41 Column(
36 modifier = Modifier
42 modifier = Modifier
@@ -40,14 +46,14 @@ private fun UserBubble(message: MessageWithParts, modifier: Modifier) {
40 .padding(12.dp),
46 .padding(12.dp),
41 verticalArrangement = Arrangement.spacedBy(6.dp),
47 verticalArrangement = Arrangement.spacedBy(6.dp),
42 ) {
48 ) {
43− for (part in message.parts) PartView(part)
49+ for (part in message.parts) PartView(part, imageContext = imageContext)
44 MessageTimestamp(message.info)
50 MessageTimestamp(message.info)
45 }
51 }
46 }
52 }
47}
53}
48
54
49@Composable
55@Composable
50−private fun AssistantBlock(message: MessageWithParts, isRunning: Boolean, modifier: Modifier) {
56+private fun AssistantBlock(message: MessageWithParts, isRunning: Boolean, imageContext: ImageLoadContext?, modifier: Modifier) {
51 Column(
57 Column(
52 modifier = modifier.fillMaxWidth(),
58 modifier = modifier.fillMaxWidth(),
53 verticalArrangement = Arrangement.spacedBy(8.dp),
59 verticalArrangement = Arrangement.spacedBy(8.dp),
@@ -61,7 +67,7 @@ private fun AssistantBlock(message: MessageWithParts, isRunning: Boolean, modifi
61 )
67 )
62 }
68 }
63 for (part in message.parts) {
69 for (part in message.parts) {
64− PartView(part, isRunning = isRunning, modifier = Modifier.fillMaxWidth())
70+ PartView(part, isRunning = isRunning, modifier = Modifier.fillMaxWidth(), imageContext = imageContext)
65 }
71 }
66 if (info is AssistantMessage) {
72 if (info is AssistantMessage) {
67 val cost = info.cost
73 val cost = info.cost
app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+9−2
@@ -54,8 +54,11 @@ import soy.iko.opencode.data.model.ToolState
54import soy.iko.opencode.data.model.ToolUnknown
54import soy.iko.opencode.data.model.ToolUnknown
55import soy.iko.opencode.data.model.UnknownPart
55import soy.iko.opencode.data.model.UnknownPart
56import soy.iko.opencode.ui.components.DiffView
56import soy.iko.opencode.ui.components.DiffView
57+import soy.iko.opencode.ui.components.ImageLoadContext
57import soy.iko.opencode.ui.components.MarkdownText
58import soy.iko.opencode.ui.components.MarkdownText
59+import soy.iko.opencode.ui.components.RemoteImage
58import soy.iko.opencode.ui.components.copyToClipboard
60import soy.iko.opencode.ui.components.copyToClipboard
61+import soy.iko.opencode.ui.components.isImage
59import soy.iko.opencode.ui.components.looksLikeDiff
62import soy.iko.opencode.ui.components.looksLikeDiff
60import soy.iko.opencode.R
63import soy.iko.opencode.R
61
64
@@ -66,14 +69,18 @@ private const val COLLAPSED_LIMIT = 600
66 * compile-time coverage; the [UnknownPart] arm keeps the UI forward-compatible.
69 * compile-time coverage; the [UnknownPart] arm keeps the UI forward-compatible.
67 */
70 */
68@Composable
71@Composable
69−fun PartView(part: Part, isRunning: Boolean = false, modifier: Modifier = Modifier) {
72+fun PartView(part: Part, isRunning: Boolean = false, modifier: Modifier = Modifier, imageContext: ImageLoadContext? = null) {
70 when (part) {
73 when (part) {
71 is TextPart -> if (!part.ignored && part.text.isNotEmpty()) {
74 is TextPart -> if (!part.ignored && part.text.isNotEmpty()) {
72 MarkdownText(part.text, modifier = modifier)
75 MarkdownText(part.text, modifier = modifier)
73 }
76 }
74 is ReasoningPart -> ReasoningBlock(part.text, streaming = isRunning, modifier)
77 is ReasoningPart -> ReasoningBlock(part.text, streaming = isRunning, modifier)
75 is ToolPart -> ToolCallView(part, modifier)
78 is ToolPart -> ToolCallView(part, modifier)
79+ is FilePart -> if (part.isImage && imageContext != null && (part.source != null || !part.url.isNullOrBlank())) {
80+ RemoteImage(part, imageContext, modifier)
81+ } else {
76− is FilePart -> FileChip(part, modifier)
82+ FileChip(part, modifier)
83+ }
77 is StepStartPart -> {} // boundary marker — nothing to draw
84 is StepStartPart -> {} // boundary marker — nothing to draw
78 is StepFinishPart -> {} // metrics handled at message level
85 is StepFinishPart -> {} // metrics handled at message level
79 is UnknownPart -> {} // forward-compat: unknown part types are ignored
86 is UnknownPart -> {} // forward-compat: unknown part types are ignored
app/src/main/java/soy/iko/opencode/ui/components/ConnectionBanner.ktnew file+53
@@ -0,0 +1,53 @@
1+package soy.iko.opencode.ui.components
2+
3+import androidx.compose.foundation.layout.Arrangement
4+import androidx.compose.foundation.layout.Row
5+import androidx.compose.foundation.layout.fillMaxWidth
6+import androidx.compose.foundation.layout.padding
7+import androidx.compose.foundation.layout.size
8+import androidx.compose.material3.CircularProgressIndicator
9+import androidx.compose.material3.MaterialTheme
10+import androidx.compose.material3.Surface
11+import androidx.compose.material3.Text
12+import androidx.compose.runtime.Composable
13+import androidx.compose.ui.Alignment
14+import androidx.compose.ui.Modifier
15+import androidx.compose.ui.res.stringResource
16+import androidx.compose.ui.unit.dp
17+import soy.iko.opencode.R
18+import soy.iko.opencode.data.network.EventStreamClient
19+
20+/**
21+ * Banner shown when the SSE event stream is not connected. Shared by the chat and the
22+ * session list so a dropped stream is visible everywhere, not just mid-conversation.
23+ */
24+@Composable
25+fun ConnectionBanner(
26+ state: EventStreamClient.ConnectionState,
27+ modifier: Modifier = Modifier,
28+) {
29+ val text = when (state) {
30+ EventStreamClient.ConnectionState.Connecting -> stringResource(R.string.connecting)
31+ EventStreamClient.ConnectionState.Disconnected -> stringResource(R.string.reconnecting)
32+ EventStreamClient.ConnectionState.Connected -> null
33+ }
34+ if (text != null) {
35+ Surface(
36+ modifier = modifier.fillMaxWidth(),
37+ color = MaterialTheme.colorScheme.tertiaryContainer,
38+ ) {
39+ Row(
40+ modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
41+ horizontalArrangement = Arrangement.Center,
42+ verticalAlignment = Alignment.CenterVertically,
43+ ) {
44+ CircularProgressIndicator(Modifier.size(12.dp), strokeWidth = 2.dp)
45+ Text(
46+ " $text",
47+ style = MaterialTheme.typography.labelMedium,
48+ color = MaterialTheme.colorScheme.onTertiaryContainer,
49+ )
50+ }
51+ }
52+ }
53+}
app/src/main/java/soy/iko/opencode/ui/components/DiffView.kt+17−11
@@ -3,13 +3,12 @@ package soy.iko.opencode.ui.components
3import androidx.compose.foundation.background
3import androidx.compose.foundation.background
4import androidx.compose.foundation.horizontalScroll
4import androidx.compose.foundation.horizontalScroll
5import androidx.compose.foundation.layout.Arrangement
5import androidx.compose.foundation.layout.Arrangement
6+import androidx.compose.foundation.layout.Column
6import androidx.compose.foundation.layout.Row
7import androidx.compose.foundation.layout.Row
7import androidx.compose.foundation.layout.Spacer
8import androidx.compose.foundation.layout.Spacer
8import androidx.compose.foundation.layout.fillMaxWidth
9import androidx.compose.foundation.layout.fillMaxWidth
9import androidx.compose.foundation.layout.padding
10import androidx.compose.foundation.layout.padding
10import androidx.compose.foundation.layout.width
11import androidx.compose.foundation.layout.width
11−import androidx.compose.foundation.lazy.LazyColumn
12−import androidx.compose.foundation.lazy.itemsIndexed
13import androidx.compose.foundation.rememberScrollState
12import androidx.compose.foundation.rememberScrollState
14import androidx.compose.foundation.shape.RoundedCornerShape
13import androidx.compose.foundation.shape.RoundedCornerShape
15import androidx.compose.material.icons.Icons
14import androidx.compose.material.icons.Icons
@@ -75,6 +74,13 @@ fun looksLikeDiff(text: String): Boolean {
75 return false
74 return false
76}
75}
77
76
77+/**
78+ * Renders a parsed unified diff. Implemented as a plain (non-lazy) [Column] so it is
79+ * safe to embed inside another vertically scrolling container (e.g. the chat message
80+ * list) — a nested `LazyColumn` would crash with an unbounded-height constraint.
81+ * Callers that want the view to scroll on its own (e.g. the file viewer) should pass a
82+ * [Modifier.verticalScroll] in [modifier].
83+ */
78@Composable
84@Composable
79fun DiffView(diff: String, modifier: Modifier = Modifier) {
85fun DiffView(diff: String, modifier: Modifier = Modifier) {
80 val lines = remember(diff) { parseDiff(diff) }
86 val lines = remember(diff) { parseDiff(diff) }
@@ -85,21 +91,21 @@ fun DiffView(diff: String, modifier: Modifier = Modifier) {
85 val context = LocalContext.current
91 val context = LocalContext.current
86 val hScrollState = rememberScrollState()
92 val hScrollState = rememberScrollState()
87
93
88− LazyColumn(
94+ Column(
89 modifier = modifier
95 modifier = modifier
90 .fillMaxWidth()
96 .fillMaxWidth()
91− .padding(vertical = 4.dp)
92 .clip(RoundedCornerShape(8.dp))
97 .clip(RoundedCornerShape(8.dp))
93 .background(MaterialTheme.colorScheme.surfaceVariant),
98 .background(MaterialTheme.colorScheme.surfaceVariant),
94 ) {
99 ) {
95− item(key = "__copy") {
100+ Row(
96− Row(modifier = Modifier.fillMaxWidth().padding(top = 2.dp, end = 4.dp), horizontalArrangement = Arrangement.End) {
101+ modifier = Modifier.fillMaxWidth().padding(top = 2.dp, end = 4.dp),
102+ horizontalArrangement = Arrangement.End,
103+ ) {
97− IconButton(onClick = { copyToClipboard(context, "diff", diff) }) {
104+ IconButton(onClick = { copyToClipboard(context, "diff", diff) }) {
98− Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy), tint = MaterialTheme.colorScheme.onSurfaceVariant)
105+ Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy), tint = MaterialTheme.colorScheme.onSurfaceVariant)
99− }
100 }
106 }
101 }
107 }
102− itemsIndexed(lines, key = { index, _ -> index }) { _, line ->
108+ lines.forEach { line ->
103 Row(modifier = Modifier.horizontalScroll(hScrollState).padding(horizontal = 10.dp)) {
109 Row(modifier = Modifier.horizontalScroll(hScrollState).padding(horizontal = 10.dp)) {
104 when (line) {
110 when (line) {
105 is DiffLine.Hunk -> Text(
111 is DiffLine.Hunk -> Text(
@@ -122,7 +128,7 @@ fun DiffView(diff: String, modifier: Modifier = Modifier) {
122 }
128 }
123 }
129 }
124 }
130 }
125− item(key = "__spacer") { Spacer(Modifier.padding(bottom = 10.dp)) }
131+ Spacer(Modifier.padding(bottom = 10.dp))
126 }
132 }
127}
133}
128
134
app/src/main/java/soy/iko/opencode/ui/components/RemoteImage.ktnew file+85
@@ -0,0 +1,85 @@
1+package soy.iko.opencode.ui.components
2+
3+import android.util.Base64
4+import androidx.compose.foundation.layout.heightIn
5+import androidx.compose.foundation.shape.RoundedCornerShape
6+import androidx.compose.runtime.Composable
7+import androidx.compose.runtime.remember
8+import androidx.compose.ui.Modifier
9+import androidx.compose.ui.draw.clip
10+import androidx.compose.ui.layout.ContentScale
11+import androidx.compose.ui.platform.LocalContext
12+import androidx.compose.ui.unit.dp
13+import coil.compose.AsyncImage
14+import coil.request.ImageRequest
15+import soy.iko.opencode.data.model.FilePart
16+import soy.iko.opencode.data.model.ServerProfile
17+import soy.iko.opencode.data.network.HttpClientFactory
18+
19+/**
20+ * Carries the bits needed to load an image off the opencode server: the base URL (for
21+ * resolving relative `url`s) and an optional HTTP Basic header for protected servers.
22+ */
23+data class ImageLoadContext(
24+ val baseUrl: String,
25+ val basicAuthHeader: String?,
26+)
27+
28+/** Build an [ImageLoadContext] from a resolved connection profile (password included). */
29+fun ServerProfile.toImageContext(): ImageLoadContext {
30+ val auth = if (hasAuth && !password.isNullOrEmpty()) {
31+ val raw = "$username:$password".toByteArray()
32+ "Basic " + Base64.encodeToString(raw, Base64.NO_WRAP)
33+ } else {
34+ null
35+ }
36+ return ImageLoadContext(baseUrl = baseUrl, basicAuthHeader = auth)
37+}
38+
39+/** True if the part references a raster/vector image we can render. */
40+val FilePart.isImage: Boolean
41+ get() = mime?.startsWith("image/") == true
42+
43+/**
44+ * Resolve a [FilePart] to a Coil-loadable model: a decoded [ByteArray] for inline
45+ * data URIs, an absolute URL string otherwise. Returns null when nothing loadable.
46+ */
47+private fun FilePart.resolveModel(ctx: ImageLoadContext): Any? {
48+ val src = source
49+ if (!src.isNullOrBlank() && src.startsWith("data:")) {
50+ val comma = src.indexOf(',')
51+ if (comma < 0) return null
52+ val payload = src.substring(comma + 1)
53+ return runCatching { Base64.decode(payload, Base64.DEFAULT) }.getOrNull()
54+ }
55+ val url = url ?: return null
56+ return if (url.startsWith("http://") || url.startsWith("https://")) {
57+ url
58+ } else {
59+ HttpClientFactory.normalizeBaseUrl(ctx.baseUrl) + url.trimStart('/')
60+ }
61+}
62+
63+/**
64+ * Renders an image attachment from a [FilePart]. Handles inline data URIs (no network)
65+ * and server-relative URLs (fetched with Basic auth when the server requires it).
66+ * Falls back to nothing when the part has no loadable source.
67+ */
68+@Composable
69+fun RemoteImage(part: FilePart, ctx: ImageLoadContext, modifier: Modifier = Modifier) {
70+ val context = LocalContext.current
71+ val model = remember(part.source, part.url, ctx.baseUrl) { part.resolveModel(ctx) } ?: return
72+ val request = ImageRequest.Builder(context)
73+ .data(model)
74+ .apply { ctx.basicAuthHeader?.let { addHeader("Authorization", it) } }
75+ .crossfade(true)
76+ .build()
77+ AsyncImage(
78+ model = request,
79+ contentDescription = part.filename ?: "image",
80+ contentScale = ContentScale.FillWidth,
81+ modifier = modifier
82+ .heightIn(max = 320.dp)
83+ .clip(RoundedCornerShape(12.dp)),
84+ )
85+}
app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+3−1
@@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.width
10import androidx.compose.foundation.lazy.LazyColumn
10import androidx.compose.foundation.lazy.LazyColumn
11import androidx.compose.foundation.lazy.itemsIndexed
11import androidx.compose.foundation.lazy.itemsIndexed
12import androidx.compose.foundation.rememberScrollState
12import androidx.compose.foundation.rememberScrollState
13+import androidx.compose.foundation.verticalScroll
13import androidx.compose.material.icons.Icons
14import androidx.compose.material.icons.Icons
14import androidx.compose.material.icons.automirrored.filled.ArrowBack
15import androidx.compose.material.icons.automirrored.filled.ArrowBack
15import androidx.compose.material.icons.filled.ContentCopy
16import androidx.compose.material.icons.filled.ContentCopy
@@ -103,7 +104,8 @@ fun FileViewScreen(
103 diff = content.diff,
104 diff = content.diff,
104 modifier = Modifier
105 modifier = Modifier
105 .fillMaxSize()
106 .fillMaxSize()
107+ .verticalScroll(rememberScrollState())
106− .padding(padding),
108+ .padding(8.dp),
107 )
109 )
108 } else {
110 } else {
109 val text = content?.content.orEmpty()
111 val text = content?.content.orEmpty()
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+95−36
@@ -1,5 +1,6 @@
1package soy.iko.opencode.ui.session
1package soy.iko.opencode.ui.session
2
2
3+import androidx.compose.foundation.background
3import androidx.compose.foundation.clickable
4import androidx.compose.foundation.clickable
4import androidx.compose.foundation.layout.Arrangement
5import androidx.compose.foundation.layout.Arrangement
5import androidx.compose.foundation.layout.Box
6import androidx.compose.foundation.layout.Box
@@ -41,10 +42,12 @@ import androidx.compose.material3.OutlinedTextField
41import androidx.compose.material3.Scaffold
42import androidx.compose.material3.Scaffold
42import androidx.compose.material3.SnackbarHost
43import androidx.compose.material3.SnackbarHost
43import androidx.compose.material3.SnackbarHostState
44import androidx.compose.material3.SnackbarHostState
45+import androidx.compose.material3.SwipeToDismissBox
44import androidx.compose.material3.Text
46import androidx.compose.material3.Text
45import androidx.compose.material3.TextButton
47import androidx.compose.material3.TextButton
46import androidx.compose.material3.TopAppBar
48import androidx.compose.material3.TopAppBar
47import androidx.compose.material3.pulltorefresh.PullToRefreshBox
49import androidx.compose.material3.pulltorefresh.PullToRefreshBox
50+import androidx.compose.material3.rememberSwipeToDismissBoxState
48import androidx.compose.runtime.Composable
51import androidx.compose.runtime.Composable
49import androidx.compose.runtime.LaunchedEffect
52import androidx.compose.runtime.LaunchedEffect
50import androidx.compose.runtime.getValue
53import androidx.compose.runtime.getValue
@@ -53,6 +56,7 @@ import androidx.compose.runtime.remember
53import androidx.compose.runtime.setValue
56import androidx.compose.runtime.setValue
54import androidx.compose.ui.Alignment
57import androidx.compose.ui.Alignment
55import androidx.compose.ui.Modifier
58import androidx.compose.ui.Modifier
59+import androidx.compose.ui.draw.clip
56import androidx.compose.ui.platform.LocalHapticFeedback
60import androidx.compose.ui.platform.LocalHapticFeedback
57import androidx.compose.ui.semantics.Role
61import androidx.compose.ui.semantics.Role
58import androidx.compose.ui.semantics.role
62import androidx.compose.ui.semantics.role
@@ -66,6 +70,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
66import soy.iko.opencode.data.model.Session
70import soy.iko.opencode.data.model.Session
67import soy.iko.opencode.di.AppContainer
71import soy.iko.opencode.di.AppContainer
68import soy.iko.opencode.R
72import soy.iko.opencode.R
73+import soy.iko.opencode.ui.components.ConnectionBanner
69import soy.iko.opencode.ui.components.relativeTime
74import soy.iko.opencode.ui.components.relativeTime
70import soy.iko.opencode.ui.vmFactory
75import soy.iko.opencode.ui.vmFactory
71
76
@@ -85,6 +90,8 @@ fun SessionListScreen(
85 val serverLabel by vm.serverLabel.collectAsStateWithLifecycle()
90 val serverLabel by vm.serverLabel.collectAsStateWithLifecycle()
86 val profiles by vm.profiles.collectAsStateWithLifecycle()
91 val profiles by vm.profiles.collectAsStateWithLifecycle()
87 val switchingId by vm.switchingId.collectAsStateWithLifecycle()
92 val switchingId by vm.switchingId.collectAsStateWithLifecycle()
93+ val connectionState by vm.connectionState.collectAsStateWithLifecycle()
94+ val unread by vm.unread.collectAsStateWithLifecycle()
88 val haptics = LocalHapticFeedback.current
95 val haptics = LocalHapticFeedback.current
89 val snackbar = remember { SnackbarHostState() }
96 val snackbar = remember { SnackbarHostState() }
90 var showServerMenu by remember { mutableStateOf(false) }
97 var showServerMenu by remember { mutableStateOf(false) }
@@ -176,6 +183,10 @@ fun SessionListScreen(
176 },
183 },
177 ) { padding ->
184 ) { padding ->
178 Box(modifier = Modifier.fillMaxSize().padding(padding)) {
185 Box(modifier = Modifier.fillMaxSize().padding(padding)) {
186+ ConnectionBanner(
187+ state = connectionState,
188+ modifier = Modifier.align(Alignment.TopCenter),
189+ )
179 when {
190 when {
180 state.loading -> CircularProgressIndicator(Modifier.align(Alignment.Center))
191 state.loading -> CircularProgressIndicator(Modifier.align(Alignment.Center))
181 state.sessions.isEmpty() && state.error != null -> Text(
192 state.sessions.isEmpty() && state.error != null -> Text(
@@ -219,6 +230,7 @@ fun SessionListScreen(
219 SessionCard(
230 SessionCard(
220 session = session,
231 session = session,
221 preview = state.previews[session.id],
232 preview = state.previews[session.id],
233+ unread = unread.contains(session.id),
222 onClick = { onOpenSession(session.id) },
234 onClick = { onOpenSession(session.id) },
223 onRename = { pendingRename = session },
235 onRename = { pendingRename = session },
224 onDelete = { pendingDelete = session },
236 onDelete = { pendingDelete = session },
@@ -296,61 +308,108 @@ private fun RenameSessionDialog(
296 )
308 )
297}
309}
298
310
311+@OptIn(ExperimentalMaterial3Api::class)
299@Composable
312@Composable
300private fun SessionCard(
313private fun SessionCard(
301 session: Session,
314 session: Session,
302 preview: String?,
315 preview: String?,
316+ unread: Boolean,
303 onClick: () -> Unit,
317 onClick: () -> Unit,
304 onRename: () -> Unit,
318 onRename: () -> Unit,
305 onDelete: () -> Unit,
319 onDelete: () -> Unit,
306 modifier: Modifier = Modifier,
320 modifier: Modifier = Modifier,
307) {
321) {
308− Card(
322+ // Swipe toward the start reveals a delete affordance; releasing snaps back and opens
309− modifier = modifier
323+ // the same confirmation dialog the trash icon uses, so the destructive action is
310− .fillMaxWidth()
324+ // always confirmed.
311− .clickable { onClick() },
325+ val dismissState = rememberSwipeToDismissBoxState(
326+ confirmValueChange = {
327+ onDelete()
328+ false // snap back; the dialog owns the actual deletion
329+ },
330+ )
331+ SwipeToDismissBox(
332+ state = dismissState,
333+ backgroundContent = { SwipeDeleteBackground() },
334+ enableDismissFromStartToEnd = false,
335+ modifier = modifier,
312 ) {
336 ) {
337+ Card(
338+ modifier = Modifier
339+ .fillMaxWidth()
340+ .clickable { onClick() },
341+ ) {
313− Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
342+ Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
314− Row(
343+ Row(
315− modifier = Modifier.fillMaxWidth(),
344+ modifier = Modifier.fillMaxWidth(),
316− verticalAlignment = Alignment.CenterVertically,
345+ verticalAlignment = Alignment.CenterVertically,
317− ) {
346+ ) {
318− Text(
347+ if (unread) {
319− session.displayTitle,
348+ Box(
320− style = MaterialTheme.typography.titleMedium,
349+ modifier = Modifier
321− maxLines = 1,
350+ .padding(end = 8.dp)
322− overflow = TextOverflow.Ellipsis,
351+ .size(8.dp)
323− modifier = Modifier.weight(1f),
352+ .clip(androidx.compose.foundation.shape.CircleShape)
353+ .background(MaterialTheme.colorScheme.primary),
324− )
354+ )
325− val time = relativeTime(session.time?.updated ?: session.time?.created)
355+ }
326− if (time.isNotEmpty()) {
327 Text(
356 Text(
328− time,
357+ session.displayTitle,
329− style = MaterialTheme.typography.labelSmall,
358+ style = MaterialTheme.typography.titleMedium,
330− color = MaterialTheme.colorScheme.onSurfaceVariant,
359+ color = if (unread) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
360+ maxLines = 1,
361+ overflow = TextOverflow.Ellipsis,
362+ modifier = Modifier.weight(1f),
331 )
363 )
364+ val time = relativeTime(session.time?.updated ?: session.time?.created)
365+ if (time.isNotEmpty()) {
366+ Text(
367+ time,
368+ style = MaterialTheme.typography.labelSmall,
369+ color = MaterialTheme.colorScheme.onSurfaceVariant,
370+ )
371+ }
372+ IconButton(onClick = onRename) {
373+ Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.rename))
374+ }
375+ IconButton(onClick = onDelete) {
376+ Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete))
377+ }
332 }
378 }
333− IconButton(onClick = onRename) {
379+ if (!preview.isNullOrBlank()) {
334− Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.rename))
380+ Spacer(Modifier.size(4.dp))
335− }
381+ Text(
336− IconButton(onClick = onDelete) {
382+ preview,
337− Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete))
383+ style = MaterialTheme.typography.bodyMedium,
384+ color = if (unread) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
385+ maxLines = 2,
386+ overflow = TextOverflow.Ellipsis,
387+ )
338 }
388 }
339 }
389 }
340− if (!preview.isNullOrBlank()) {
341− Spacer(Modifier.size(4.dp))
342− Text(
343− preview,
344− style = MaterialTheme.typography.bodyMedium,
345− color = MaterialTheme.colorScheme.onSurfaceVariant,
346− maxLines = 2,
347− overflow = TextOverflow.Ellipsis,
348− )
349− }
350 }
390 }
351 }
391 }
352}
392}
353
393
394+/** Red background with a trash icon shown behind a session card as it is swiped away. */
395+@Composable
396+private fun SwipeDeleteBackground() {
397+ Box(
398+ modifier = Modifier
399+ .fillMaxSize()
400+ .clip(androidx.compose.foundation.shape.RoundedCornerShape(12.dp))
401+ .background(MaterialTheme.colorScheme.errorContainer)
402+ .padding(horizontal = 20.dp),
403+ contentAlignment = Alignment.CenterEnd,
404+ ) {
405+ Icon(
406+ Icons.Filled.Delete,
407+ contentDescription = null,
408+ tint = MaterialTheme.colorScheme.onErrorContainer,
409+ )
410+ }
411+}
412+
354@Composable
413@Composable
355private fun EmptySessions(onCreate: () -> Unit, modifier: Modifier = Modifier) {
414private fun EmptySessions(onCreate: () -> Unit, modifier: Modifier = Modifier) {
356 Column(
415 Column(
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+17
@@ -7,13 +7,16 @@ import soy.iko.opencode.data.model.Session
7import soy.iko.opencode.data.model.SessionDeleted
7import soy.iko.opencode.data.model.SessionDeleted
8import soy.iko.opencode.data.model.SessionUpdated
8import soy.iko.opencode.data.model.SessionUpdated
9import soy.iko.opencode.data.model.TextPart
9import soy.iko.opencode.data.model.TextPart
10+import soy.iko.opencode.data.network.EventStreamClient
10import soy.iko.opencode.di.AppContainer
11import soy.iko.opencode.di.AppContainer
11import soy.iko.opencode.R
12import soy.iko.opencode.R
13+import kotlinx.coroutines.ExperimentalCoroutinesApi
12import kotlinx.coroutines.Job
14import kotlinx.coroutines.Job
13import kotlinx.coroutines.flow.MutableStateFlow
15import kotlinx.coroutines.flow.MutableStateFlow
14import kotlinx.coroutines.flow.SharingStarted
16import kotlinx.coroutines.flow.SharingStarted
15import kotlinx.coroutines.flow.StateFlow
17import kotlinx.coroutines.flow.StateFlow
16import kotlinx.coroutines.flow.asStateFlow
18import kotlinx.coroutines.flow.asStateFlow
19+import kotlinx.coroutines.flow.flatMapLatest
17import kotlinx.coroutines.flow.stateIn
20import kotlinx.coroutines.flow.stateIn
18import kotlinx.coroutines.flow.update
21import kotlinx.coroutines.flow.update
19import kotlinx.coroutines.joinAll
22import kotlinx.coroutines.joinAll
@@ -42,6 +45,20 @@ data class SessionListState(
42
45
43class SessionListViewModel(private val container: AppContainer) : ViewModel() {
46class SessionListViewModel(private val container: AppContainer) : ViewModel() {
44
47
48+ /** Live SSE connection state, surfaced so the list can show a reconnect banner. */
49+ @OptIn(ExperimentalCoroutinesApi::class)
50+ val connectionState: StateFlow<EventStreamClient.ConnectionState> =
51+ container.activeConnection
52+ .flatMapLatest { it?.events?.state ?: MutableStateFlow(EventStreamClient.ConnectionState.Disconnected) }
53+ .stateIn(
54+ scope = viewModelScope,
55+ started = SharingStarted.WhileSubscribed(5_000),
56+ initialValue = EventStreamClient.ConnectionState.Disconnected,
57+ )
58+
59+ /** Sessions with activity that arrived while not being viewed. */
60+ val unread: StateFlow<Set<String>> = container.unread
61+
45 val profiles: StateFlow<List<ServerProfile>> =
62 val profiles: StateFlow<List<ServerProfile>> =
46 container.profileStore.profiles.stateIn(
63 container.profileStore.profiles.stateIn(
47 scope = viewModelScope,
64 scope = viewModelScope,
app/src/main/res/values/strings.xml+3
@@ -83,6 +83,7 @@
83 <string name="commands">Commands</string>
83 <string name="commands">Commands</string>
84 <string name="choose_agent">Choose agent</string>
84 <string name="choose_agent">Choose agent</string>
85 <string name="choose_model">Choose model</string>
85 <string name="choose_model">Choose model</string>
86+ <string name="share_conversation">Share conversation</string>
86
87
87 <!-- Errors (view models) -->
88 <!-- Errors (view models) -->
88 <string name="error_generic">Something went wrong.</string>
89 <string name="error_generic">Something went wrong.</string>
@@ -90,6 +91,8 @@
90 <string name="error_agent_reported">The agent reported an error.</string>
91 <string name="error_agent_reported">The agent reported an error.</string>
91 <string name="error_failed_command">Failed to run command</string>
92 <string name="error_failed_command">Failed to run command</string>
92 <string name="error_not_reachable">Could not reach %1$s</string>
93 <string name="error_not_reachable">Could not reach %1$s</string>
94+ <string name="error_timeout">The request timed out. Check the connection and try again.</string>
95+ <string name="error_server">The server reported an error. Try again later.</string>
93 <string name="error_load_sessions">Failed to load sessions</string>
96 <string name="error_load_sessions">Failed to load sessions</string>
94 <string name="error_create_session">Failed to create session</string>
97 <string name="error_create_session">Failed to create session</string>
95 <string name="error_delete_session">Failed to delete session</string>
98 <string name="error_delete_session">Failed to delete session</string>
app/src/test/java/soy/iko/opencode/data/repo/ErrorKindTest.ktnew file+60
@@ -0,0 +1,60 @@
1+package soy.iko.opencode.data.repo
2+
3+import io.ktor.client.plugins.HttpRequestTimeoutException
4+import io.ktor.client.request.HttpRequestBuilder
5+import org.junit.Assert.assertEquals
6+import org.junit.Test
7+import java.io.IOException
8+import java.net.ConnectException
9+import java.net.SocketTimeoutException
10+import java.net.UnknownHostException
11+
12+class ErrorKindTest {
13+
14+ @Test
15+ fun unknownHostIsNotReachable() {
16+ assertEquals(ErrorKind.NOT_REACHABLE, classifyError(UnknownHostException("nope")))
17+ }
18+
19+ @Test
20+ fun connectRefusedIsNotReachable() {
21+ assertEquals(ErrorKind.NOT_REACHABLE, classifyError(ConnectException("refused")))
22+ }
23+
24+ @Test
25+ fun socketTimeoutIsTimeout() {
26+ assertEquals(ErrorKind.TIMEOUT, classifyError(SocketTimeoutException("read timed out")))
27+ }
28+
29+ @Test
30+ fun httpRequestTimeoutIsTimeout() {
31+ assertEquals(ErrorKind.TIMEOUT, classifyError(HttpRequestTimeoutException(HttpRequestBuilder())))
32+ }
33+
34+ @Test
35+ fun genericIoIsNetwork() {
36+ assertEquals(ErrorKind.NETWORK, classifyError(IOException("broken pipe")))
37+ }
38+
39+ @Test
40+ fun unrelatedThrowableIsUnknown() {
41+ assertEquals(ErrorKind.UNKNOWN, classifyError(IllegalStateException("boom")))
42+ }
43+
44+ @Test
45+ fun unwrapsCauseChainToClassifyRoot() {
46+ // A library may surface a network failure wrapped in a non-IO exception.
47+ val wrapped = RuntimeException("request failed", UnknownHostException("nested"))
48+ assertEquals(ErrorKind.NOT_REACHABLE, classifyError(wrapped))
49+ }
50+
51+ @Test
52+ fun doesNotLoopInfinitelyOnCyclicCause() {
53+ // A self-referential cause chain must be terminated by the bounded hop count,
54+ // not followed into a stack overflow.
55+ val cyclic: Throwable = object : Throwable("cyclic") {
56+ override val cause: Throwable? get() = this
57+ }
58+ assertEquals(ErrorKind.UNKNOWN, classifyError(cyclic))
59+ }
60+}
app/src/test/java/soy/iko/opencode/ui/chat/ConversationExportTest.ktnew file+56
@@ -0,0 +1,56 @@
1+package soy.iko.opencode.ui.chat
2+
3+import org.junit.Assert.assertEquals
4+import org.junit.Assert.assertTrue
5+import org.junit.Test
6+import soy.iko.opencode.data.model.AssistantMessage
7+import soy.iko.opencode.data.model.MessageWithParts
8+import soy.iko.opencode.data.model.ReasoningPart
9+import soy.iko.opencode.data.model.TextPart
10+import soy.iko.opencode.data.model.UserMessage
11+
12+class ConversationExportTest {
13+
14+ private val sid = "s1"
15+
16+ private fun user(id: String, text: String) =
17+ MessageWithParts(UserMessage(id, sid), listOf(TextPart(id = "p$id", text = text)))
18+
19+ private fun assistant(id: String, text: String) =
20+ MessageWithParts(AssistantMessage(id, sid), listOf(TextPart(id = "p$id", text = text)))
21+
22+ @Test
23+ fun rendersRoleHeadersAndBodies() {
24+ val md = buildConversationMarkdown(
25+ listOf(user("u1", "Hello"), assistant("a1", "Hi there!")),
26+ title = "My chat",
27+ )
28+ assertTrue(md.startsWith("# My chat"))
29+ assertTrue(md.contains("## You\n\nHello"))
30+ assertTrue(md.contains("## opencode\n\nHi there!"))
31+ }
32+
33+ @Test
34+ fun omitsTitleWhenBlank() {
35+ val md = buildConversationMarkdown(listOf(user("u1", "hi")), title = null)
36+ assertTrue(md.startsWith("## You"))
37+ }
38+
39+ @Test
40+ fun skipsMessagesWithoutText() {
41+ val onlyReasoning = MessageWithParts(
42+ AssistantMessage("a1", sid),
43+ listOf(ReasoningPart(id = "r1", text = "hmm")),
44+ )
45+ val md = buildConversationMarkdown(listOf(onlyReasoning), title = null)
46+ // Reasoning is exported in italics, so it still appears.
47+ assertTrue(md.contains("## opencode"))
48+ assertTrue(md.contains("_hmm_"))
49+ }
50+
51+ @Test
52+ fun emptyConversationProducesJustTheTitle() {
53+ // No message bodies to export, but the title header is still emitted.
54+ assertEquals("# x", buildConversationMarkdown(emptyList(), title = "x"))
55+ }
56+}
app/src/test/java/soy/iko/opencode/ui/components/DiffParseTest.ktnew file+55
@@ -0,0 +1,55 @@
1+package soy.iko.opencode.ui.components
2+
3+import org.junit.Assert.assertEquals
4+import org.junit.Assert.assertFalse
5+import org.junit.Assert.assertTrue
6+import org.junit.Test
7+
8+class DiffParseTest {
9+
10+ @Test
11+ fun parsesAddedRemovedContextAndHunks() {
12+ val diff = """
13+ --- a/foo.txt
14+ +++ b/foo.txt
15+ @@ -1,2 +1,2 @@
16+ context line
17+ -removed line
18+ +added line
19+ """.trimIndent()
20+
21+ val lines = parseDiff(diff)
22+ assertEquals(6, lines.size)
23+ assertTrue(lines[0] is DiffLine.FileHeader) // ---
24+ assertTrue(lines[1] is DiffLine.FileHeader) // +++
25+ assertTrue(lines[2] is DiffLine.Hunk) // @@
26+ assertTrue(lines[3] is DiffLine.Context)
27+ assertTrue(lines[4] is DiffLine.Remove)
28+ assertEquals("removed line", (lines[4] as DiffLine.Remove).text)
29+ assertTrue(lines[5] is DiffLine.Add)
30+ assertEquals("added line", (lines[5] as DiffLine.Add).text)
31+ }
32+
33+ @Test
34+ fun stripsPrefixFromAddAndRemove() {
35+ val lines = parseDiff("-gone\n+new")
36+ assertEquals("gone", (lines[0] as DiffLine.Remove).text)
37+ assertEquals("new", (lines[1] as DiffLine.Add).text)
38+ }
39+
40+ @Test
41+ fun looksLikeDiffTrueForUnifiedDiff() {
42+ assertTrue(looksLikeDiff("@@ -1,1 +1,1 @@\n-old\n+new"))
43+ }
44+
45+ @Test
46+ fun looksLikeDiffFalseForPlainText() {
47+ assertFalse(looksLikeDiff("This is just some prose.\nNo diff here."))
48+ assertFalse(looksLikeDiff(""))
49+ }
50+
51+ @Test
52+ fun emptyInputParsesToEmpty() {
53+ assertTrue(parseDiff("").isEmpty())
54+ }
55+}