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
commit0adc7fdaca452fcbb373ad7cceebc0302aa44a34 authoriko <mail@iko.soy> dateMon 29 Jun 2026, 13:49 (3 months ago) parent960e1dc
19 files changed+719−107
README.md+15−1 app/build.gradle.kts+16−1 app/src/main/java/soy/iko/opencode/data/repo/ErrorKind.kt+77 app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+5−2 app/src/main/java/soy/iko/opencode/di/AppContainer.kt+67−14 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+33−32 app/src/main/java/soy/iko/opencode/ui/chat/ConversationExport.kt+40 app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+13−7 app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+9−2 app/src/main/java/soy/iko/opencode/ui/components/ConnectionBanner.kt+53 app/src/main/java/soy/iko/opencode/ui/components/DiffView.kt+17−11 app/src/main/java/soy/iko/opencode/ui/components/RemoteImage.kt+85 app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+3−1 app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+95−36 app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+17 app/src/main/res/values/strings.xml+3 app/src/test/java/soy/iko/opencode/data/repo/ErrorKindTest.kt+60 app/src/test/java/soy/iko/opencode/ui/chat/ConversationExportTest.kt+56 app/src/test/java/soy/iko/opencode/ui/components/DiffParseTest.kt+55
README.md+15−1
@@ -6,7 +6,7 @@ Application id: `soy.iko.opencode`
66
77## Status
88
9−Milestones M0–M9 complete:
9+Milestones M0–M10 complete:
1010
1111- **M0–M4:** connect to a server → list/create/delete sessions → chat with **live SSE streaming**
1212 of the assistant's reply. Tool calls, reasoning, and token/cost are rendered.
@@ -55,6 +55,20 @@ Milestones M0–M9 complete:
5555 endpoints (sessions, messages, files, providers); and **per-code-block copy
5656 buttons** in rendered markdown.
5757
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+
5872## Architecture
5973
6074- **Ktor 3 (OkHttp engine)** for REST + the long-lived `GET /event` SSE stream.
app/build.gradle.kts+16−1
@@ -5,6 +5,17 @@ plugins {
55 alias(libs.plugins.kotlin.compose)
66}
77
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+
819android {
920 namespace = "soy.iko.opencode"
1021 compileSdk = 35
@@ -13,7 +24,11 @@ android {
1324 applicationId = "soy.iko.opencode"
1425 minSdk = 26
1526 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
1732 versionName = "0.1.0"
1833 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
1934 }
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
1515import soy.iko.opencode.data.network.OpencodeApiClient
1616import kotlinx.coroutines.flow.Flow
1717import kotlinx.coroutines.flow.channelFlow
18+import kotlinx.coroutines.flow.conflate
1819import kotlinx.coroutines.launch
1920import kotlinx.coroutines.sync.Mutex
2021import kotlinx.coroutines.sync.withLock
@@ -46,7 +47,9 @@ class SessionRepository(
4647 /**
4748 * A live, ordered view of [sessionId]'s messages. Begins collecting the event
4849 * 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.
5053 */
5154 fun observeMessages(sessionId: String): Flow<List<MessageWithParts>> = channelFlow {
5255 val store = MessageStore()
@@ -73,7 +76,7 @@ class SessionRepository(
7376
7477 // Keep the flow alive until the collector cancels; the launched job is torn down with it.
7578 job.join()
76− }
79+ }.conflate()
7780
7881 companion object {
7982 /** 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
44import android.net.ConnectivityManager
55import android.net.Network
66import 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
710import soy.iko.opencode.data.model.ServerProfile
811import soy.iko.opencode.data.repo.DraftStore
12+import soy.iko.opencode.data.repo.ErrorKind
913import soy.iko.opencode.data.repo.ProfileStore
1014import soy.iko.opencode.data.repo.SettingsStore
15+import soy.iko.opencode.data.repo.classifyError
1116import soy.iko.opencode.R
1217import kotlinx.coroutines.CoroutineScope
1318import kotlinx.coroutines.Dispatchers
19+import kotlinx.coroutines.ExperimentalCoroutinesApi
1420import kotlinx.coroutines.SupervisorJob
1521import kotlinx.coroutines.flow.MutableStateFlow
1622import kotlinx.coroutines.flow.StateFlow
1723import kotlinx.coroutines.flow.asStateFlow
24+import kotlinx.coroutines.flow.emptyFlow
1825import kotlinx.coroutines.flow.first
26+import kotlinx.coroutines.flow.flatMapLatest
27+import kotlinx.coroutines.flow.update
1928import kotlinx.coroutines.launch
2029import java.util.concurrent.atomic.AtomicBoolean
2130
@@ -55,36 +64,80 @@ class AppContainer(context: Context) {
5564 fun setPendingShare(text: String) { _pendingShare.value = text }
5665 fun consumePendingShare(): String? = _pendingShare.value.also { _pendingShare.value = null }
5766
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+
5884 /** Resolve a localized string — view models reach resources through the container. */
5985 fun string(id: Int, vararg formatArgs: Any): String =
6086 if (formatArgs.isEmpty()) appContext.getString(id) else appContext.getString(id, *formatArgs)
6187
6288 /**
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.
89+ * Convert a throwable into a user-facing message. Classifies the error by concrete
90+ * type (network, timeout, HTTP status) rather than string-matching class names, so
91+ * the message reflects what actually went wrong without leaking internal URLs/state.
6692 */
6793 fun friendlyError(t: Throwable): String {
6894 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) }
95+ val baseUrl = activeConnection.value?.profile?.baseUrl.orEmpty()
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) }
102+ ErrorKind.UNKNOWN -> msg.ifBlank { string(R.string.error_generic) }
80103 }
81104 }
82105
83106 init {
84107 registerNetworkMonitor()
108+ observeMessageActivity()
85109 appScope.launch { autoConnect() }
86110 }
87111
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+
88141 /**
89142 * On cold start, transparently reconnect to the most recently used server so a
90143 * 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
2626import androidx.compose.material.icons.automirrored.filled.Send
2727import androidx.compose.material.icons.filled.AutoAwesome
2828import androidx.compose.material.icons.filled.KeyboardArrowDown
29+import androidx.compose.material.icons.filled.Share
2930import androidx.compose.material.icons.filled.SmartToy
3031import androidx.compose.material.icons.filled.Stop
3132import androidx.compose.material.icons.filled.Terminal
@@ -66,6 +67,7 @@ import androidx.compose.ui.input.key.isShiftPressed
6667import androidx.compose.ui.input.key.key
6768import androidx.compose.ui.input.key.onPreviewKeyEvent
6869import androidx.compose.ui.input.key.type
70+import androidx.compose.ui.platform.LocalContext
6971import androidx.compose.ui.platform.LocalHapticFeedback
7072import androidx.compose.ui.platform.LocalView
7173import androidx.compose.ui.res.stringResource
@@ -82,6 +84,8 @@ import soy.iko.opencode.data.model.ReasoningPart
8284import soy.iko.opencode.data.model.TextPart
8385import soy.iko.opencode.di.AppContainer
8486import soy.iko.opencode.R
87+import soy.iko.opencode.ui.components.ConnectionBanner
88+import soy.iko.opencode.ui.components.toImageContext
8589import soy.iko.opencode.ui.vmFactory
8690
8791@OptIn(ExperimentalMaterial3Api::class)
@@ -108,6 +112,20 @@ fun ChatScreen(
108112 val draft by vm.draft.collectAsStateWithLifecycle()
109113 val haptics = LocalHapticFeedback.current
110114 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+ }
111129
112130 val listState = rememberLazyListState()
113131 val snackbar = remember { SnackbarHostState() }
@@ -194,6 +212,20 @@ fun ChatScreen(
194212 }
195213 },
196214 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+ }
197229 IconButton(onClick = { showCommandPicker = true }, enabled = commands.isNotEmpty()) {
198230 Icon(Icons.Filled.Terminal, contentDescription = stringResource(R.string.commands))
199231 }
@@ -252,7 +284,7 @@ fun ChatScreen(
252284 verticalArrangement = Arrangement.spacedBy(16.dp),
253285 ) {
254286 items(messages, key = { it.info.id }) { message ->
255− MessageBubble(message, isRunning = running)
287+ MessageBubble(message, isRunning = running, imageContext = imageContext)
256288 }
257289 if (running) {
258290 item(key = "__typing") {
@@ -336,37 +368,6 @@ fun ChatScreen(
336368 }
337369}
338370
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−
370371@Composable
371372private fun ChatInputBar(
372373 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
1818import soy.iko.opencode.data.model.MessageWithParts
1919import soy.iko.opencode.data.model.Tokens
2020import soy.iko.opencode.data.model.UserMessage
21+import soy.iko.opencode.ui.components.ImageLoadContext
2122import soy.iko.opencode.ui.components.relativeTime
2223
2324/** A single message: user prompts right-aligned in a bubble, assistant output full-width. */
2425@Composable
25−fun MessageBubble(message: MessageWithParts, isRunning: Boolean = false, modifier: Modifier = Modifier) {
26+fun MessageBubble(
27+ message: MessageWithParts,
28+ isRunning: Boolean = false,
29+ modifier: Modifier = Modifier,
30+ imageContext: ImageLoadContext? = null,
31+) {
2632 when (message.info) {
27− is UserMessage -> UserBubble(message, modifier)
28− else -> AssistantBlock(message, isRunning, modifier)
33+ is UserMessage -> UserBubble(message, imageContext, modifier)
34+ else -> AssistantBlock(message, isRunning, imageContext, modifier)
2935 }
3036}
3137
3238@Composable
33−private fun UserBubble(message: MessageWithParts, modifier: Modifier) {
39+private fun UserBubble(message: MessageWithParts, imageContext: ImageLoadContext?, modifier: Modifier) {
3440 Row(modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
3541 Column(
3642 modifier = Modifier
@@ -40,14 +46,14 @@ private fun UserBubble(message: MessageWithParts, modifier: Modifier) {
4046 .padding(12.dp),
4147 verticalArrangement = Arrangement.spacedBy(6.dp),
4248 ) {
43− for (part in message.parts) PartView(part)
49+ for (part in message.parts) PartView(part, imageContext = imageContext)
4450 MessageTimestamp(message.info)
4551 }
4652 }
4753}
4854
4955@Composable
50−private fun AssistantBlock(message: MessageWithParts, isRunning: Boolean, modifier: Modifier) {
56+private fun AssistantBlock(message: MessageWithParts, isRunning: Boolean, imageContext: ImageLoadContext?, modifier: Modifier) {
5157 Column(
5258 modifier = modifier.fillMaxWidth(),
5359 verticalArrangement = Arrangement.spacedBy(8.dp),
@@ -61,7 +67,7 @@ private fun AssistantBlock(message: MessageWithParts, isRunning: Boolean, modifi
6167 )
6268 }
6369 for (part in message.parts) {
64− PartView(part, isRunning = isRunning, modifier = Modifier.fillMaxWidth())
70+ PartView(part, isRunning = isRunning, modifier = Modifier.fillMaxWidth(), imageContext = imageContext)
6571 }
6672 if (info is AssistantMessage) {
6773 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
5454import soy.iko.opencode.data.model.ToolUnknown
5555import soy.iko.opencode.data.model.UnknownPart
5656import soy.iko.opencode.ui.components.DiffView
57+import soy.iko.opencode.ui.components.ImageLoadContext
5758import soy.iko.opencode.ui.components.MarkdownText
59+import soy.iko.opencode.ui.components.RemoteImage
5860import soy.iko.opencode.ui.components.copyToClipboard
61+import soy.iko.opencode.ui.components.isImage
5962import soy.iko.opencode.ui.components.looksLikeDiff
6063import soy.iko.opencode.R
6164
@@ -66,14 +69,18 @@ private const val COLLAPSED_LIMIT = 600
6669 * compile-time coverage; the [UnknownPart] arm keeps the UI forward-compatible.
6770 */
6871@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) {
7073 when (part) {
7174 is TextPart -> if (!part.ignored && part.text.isNotEmpty()) {
7275 MarkdownText(part.text, modifier = modifier)
7376 }
7477 is ReasoningPart -> ReasoningBlock(part.text, streaming = isRunning, modifier)
7578 is ToolPart -> ToolCallView(part, modifier)
76− is FilePart -> FileChip(part, modifier)
79+ is FilePart -> if (part.isImage && imageContext != null && (part.source != null || !part.url.isNullOrBlank())) {
80+ RemoteImage(part, imageContext, modifier)
81+ } else {
82+ FileChip(part, modifier)
83+ }
7784 is StepStartPart -> {} // boundary marker — nothing to draw
7885 is StepFinishPart -> {} // metrics handled at message level
7986 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
33import androidx.compose.foundation.background
44import androidx.compose.foundation.horizontalScroll
55import androidx.compose.foundation.layout.Arrangement
6+import androidx.compose.foundation.layout.Column
67import androidx.compose.foundation.layout.Row
78import androidx.compose.foundation.layout.Spacer
89import androidx.compose.foundation.layout.fillMaxWidth
910import androidx.compose.foundation.layout.padding
1011import androidx.compose.foundation.layout.width
11−import androidx.compose.foundation.lazy.LazyColumn
12−import androidx.compose.foundation.lazy.itemsIndexed
1312import androidx.compose.foundation.rememberScrollState
1413import androidx.compose.foundation.shape.RoundedCornerShape
1514import androidx.compose.material.icons.Icons
@@ -75,6 +74,13 @@ fun looksLikeDiff(text: String): Boolean {
7574 return false
7675}
7776
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+ */
7884@Composable
7985fun DiffView(diff: String, modifier: Modifier = Modifier) {
8086 val lines = remember(diff) { parseDiff(diff) }
@@ -85,21 +91,21 @@ fun DiffView(diff: String, modifier: Modifier = Modifier) {
8591 val context = LocalContext.current
8692 val hScrollState = rememberScrollState()
8793
88− LazyColumn(
94+ Column(
8995 modifier = modifier
9096 .fillMaxWidth()
91− .padding(vertical = 4.dp)
9297 .clip(RoundedCornerShape(8.dp))
9398 .background(MaterialTheme.colorScheme.surfaceVariant),
9499 ) {
95− item(key = "__copy") {
96− Row(modifier = Modifier.fillMaxWidth().padding(top = 2.dp, end = 4.dp), horizontalArrangement = Arrangement.End) {
97− IconButton(onClick = { copyToClipboard(context, "diff", diff) }) {
98− Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy), tint = MaterialTheme.colorScheme.onSurfaceVariant)
99− }
100+ Row(
101+ modifier = Modifier.fillMaxWidth().padding(top = 2.dp, end = 4.dp),
102+ horizontalArrangement = Arrangement.End,
103+ ) {
104+ IconButton(onClick = { copyToClipboard(context, "diff", diff) }) {
105+ Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy), tint = MaterialTheme.colorScheme.onSurfaceVariant)
100106 }
101107 }
102− itemsIndexed(lines, key = { index, _ -> index }) { _, line ->
108+ lines.forEach { line ->
103109 Row(modifier = Modifier.horizontalScroll(hScrollState).padding(horizontal = 10.dp)) {
104110 when (line) {
105111 is DiffLine.Hunk -> Text(
@@ -122,7 +128,7 @@ fun DiffView(diff: String, modifier: Modifier = Modifier) {
122128 }
123129 }
124130 }
125− item(key = "__spacer") { Spacer(Modifier.padding(bottom = 10.dp)) }
131+ Spacer(Modifier.padding(bottom = 10.dp))
126132 }
127133}
128134
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
1010import androidx.compose.foundation.lazy.LazyColumn
1111import androidx.compose.foundation.lazy.itemsIndexed
1212import androidx.compose.foundation.rememberScrollState
13+import androidx.compose.foundation.verticalScroll
1314import androidx.compose.material.icons.Icons
1415import androidx.compose.material.icons.automirrored.filled.ArrowBack
1516import androidx.compose.material.icons.filled.ContentCopy
@@ -103,7 +104,8 @@ fun FileViewScreen(
103104 diff = content.diff,
104105 modifier = Modifier
105106 .fillMaxSize()
106− .padding(padding),
107+ .verticalScroll(rememberScrollState())
108+ .padding(8.dp),
107109 )
108110 } else {
109111 val text = content?.content.orEmpty()
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+95−36
@@ -1,5 +1,6 @@
11package soy.iko.opencode.ui.session
22
3+import androidx.compose.foundation.background
34import androidx.compose.foundation.clickable
45import androidx.compose.foundation.layout.Arrangement
56import androidx.compose.foundation.layout.Box
@@ -41,10 +42,12 @@ import androidx.compose.material3.OutlinedTextField
4142import androidx.compose.material3.Scaffold
4243import androidx.compose.material3.SnackbarHost
4344import androidx.compose.material3.SnackbarHostState
45+import androidx.compose.material3.SwipeToDismissBox
4446import androidx.compose.material3.Text
4547import androidx.compose.material3.TextButton
4648import androidx.compose.material3.TopAppBar
4749import androidx.compose.material3.pulltorefresh.PullToRefreshBox
50+import androidx.compose.material3.rememberSwipeToDismissBoxState
4851import androidx.compose.runtime.Composable
4952import androidx.compose.runtime.LaunchedEffect
5053import androidx.compose.runtime.getValue
@@ -53,6 +56,7 @@ import androidx.compose.runtime.remember
5356import androidx.compose.runtime.setValue
5457import androidx.compose.ui.Alignment
5558import androidx.compose.ui.Modifier
59+import androidx.compose.ui.draw.clip
5660import androidx.compose.ui.platform.LocalHapticFeedback
5761import androidx.compose.ui.semantics.Role
5862import androidx.compose.ui.semantics.role
@@ -66,6 +70,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
6670import soy.iko.opencode.data.model.Session
6771import soy.iko.opencode.di.AppContainer
6872import soy.iko.opencode.R
73+import soy.iko.opencode.ui.components.ConnectionBanner
6974import soy.iko.opencode.ui.components.relativeTime
7075import soy.iko.opencode.ui.vmFactory
7176
@@ -85,6 +90,8 @@ fun SessionListScreen(
8590 val serverLabel by vm.serverLabel.collectAsStateWithLifecycle()
8691 val profiles by vm.profiles.collectAsStateWithLifecycle()
8792 val switchingId by vm.switchingId.collectAsStateWithLifecycle()
93+ val connectionState by vm.connectionState.collectAsStateWithLifecycle()
94+ val unread by vm.unread.collectAsStateWithLifecycle()
8895 val haptics = LocalHapticFeedback.current
8996 val snackbar = remember { SnackbarHostState() }
9097 var showServerMenu by remember { mutableStateOf(false) }
@@ -176,6 +183,10 @@ fun SessionListScreen(
176183 },
177184 ) { padding ->
178185 Box(modifier = Modifier.fillMaxSize().padding(padding)) {
186+ ConnectionBanner(
187+ state = connectionState,
188+ modifier = Modifier.align(Alignment.TopCenter),
189+ )
179190 when {
180191 state.loading -> CircularProgressIndicator(Modifier.align(Alignment.Center))
181192 state.sessions.isEmpty() && state.error != null -> Text(
@@ -219,6 +230,7 @@ fun SessionListScreen(
219230 SessionCard(
220231 session = session,
221232 preview = state.previews[session.id],
233+ unread = unread.contains(session.id),
222234 onClick = { onOpenSession(session.id) },
223235 onRename = { pendingRename = session },
224236 onDelete = { pendingDelete = session },
@@ -296,61 +308,108 @@ private fun RenameSessionDialog(
296308 )
297309}
298310
311+@OptIn(ExperimentalMaterial3Api::class)
299312@Composable
300313private fun SessionCard(
301314 session: Session,
302315 preview: String?,
316+ unread: Boolean,
303317 onClick: () -> Unit,
304318 onRename: () -> Unit,
305319 onDelete: () -> Unit,
306320 modifier: Modifier = Modifier,
307321) {
308− Card(
309− modifier = modifier
310− .fillMaxWidth()
311− .clickable { onClick() },
322+ // Swipe toward the start reveals a delete affordance; releasing snaps back and opens
323+ // the same confirmation dialog the trash icon uses, so the destructive action is
324+ // always confirmed.
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,
312336 ) {
313− Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
314− Row(
315− modifier = Modifier.fillMaxWidth(),
316− verticalAlignment = Alignment.CenterVertically,
317− ) {
318− Text(
319− session.displayTitle,
320− style = MaterialTheme.typography.titleMedium,
321− maxLines = 1,
322− overflow = TextOverflow.Ellipsis,
323− modifier = Modifier.weight(1f),
324− )
325− val time = relativeTime(session.time?.updated ?: session.time?.created)
326− if (time.isNotEmpty()) {
337+ Card(
338+ modifier = Modifier
339+ .fillMaxWidth()
340+ .clickable { onClick() },
341+ ) {
342+ Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
343+ Row(
344+ modifier = Modifier.fillMaxWidth(),
345+ verticalAlignment = Alignment.CenterVertically,
346+ ) {
347+ if (unread) {
348+ Box(
349+ modifier = Modifier
350+ .padding(end = 8.dp)
351+ .size(8.dp)
352+ .clip(androidx.compose.foundation.shape.CircleShape)
353+ .background(MaterialTheme.colorScheme.primary),
354+ )
355+ }
327356 Text(
328− time,
329− style = MaterialTheme.typography.labelSmall,
330− color = MaterialTheme.colorScheme.onSurfaceVariant,
357+ session.displayTitle,
358+ style = MaterialTheme.typography.titleMedium,
359+ color = if (unread) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
360+ maxLines = 1,
361+ overflow = TextOverflow.Ellipsis,
362+ modifier = Modifier.weight(1f),
331363 )
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+ }
332378 }
333− IconButton(onClick = onRename) {
334− Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.rename))
335− }
336− IconButton(onClick = onDelete) {
337− Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete))
379+ if (!preview.isNullOrBlank()) {
380+ Spacer(Modifier.size(4.dp))
381+ Text(
382+ preview,
383+ style = MaterialTheme.typography.bodyMedium,
384+ color = if (unread) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
385+ maxLines = 2,
386+ overflow = TextOverflow.Ellipsis,
387+ )
338388 }
339389 }
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− }
350390 }
351391 }
352392}
353393
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+
354413@Composable
355414private fun EmptySessions(onCreate: () -> Unit, modifier: Modifier = Modifier) {
356415 Column(
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+17
@@ -7,13 +7,16 @@ import soy.iko.opencode.data.model.Session
77import soy.iko.opencode.data.model.SessionDeleted
88import soy.iko.opencode.data.model.SessionUpdated
99import soy.iko.opencode.data.model.TextPart
10+import soy.iko.opencode.data.network.EventStreamClient
1011import soy.iko.opencode.di.AppContainer
1112import soy.iko.opencode.R
13+import kotlinx.coroutines.ExperimentalCoroutinesApi
1214import kotlinx.coroutines.Job
1315import kotlinx.coroutines.flow.MutableStateFlow
1416import kotlinx.coroutines.flow.SharingStarted
1517import kotlinx.coroutines.flow.StateFlow
1618import kotlinx.coroutines.flow.asStateFlow
19+import kotlinx.coroutines.flow.flatMapLatest
1720import kotlinx.coroutines.flow.stateIn
1821import kotlinx.coroutines.flow.update
1922import kotlinx.coroutines.joinAll
@@ -42,6 +45,20 @@ data class SessionListState(
4245
4346class SessionListViewModel(private val container: AppContainer) : ViewModel() {
4447
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+
4562 val profiles: StateFlow<List<ServerProfile>> =
4663 container.profileStore.profiles.stateIn(
4764 scope = viewModelScope,
app/src/main/res/values/strings.xml+3
@@ -83,6 +83,7 @@
8383 <string name="commands">Commands</string>
8484 <string name="choose_agent">Choose agent</string>
8585 <string name="choose_model">Choose model</string>
86+ <string name="share_conversation">Share conversation</string>
8687
8788 <!-- Errors (view models) -->
8889 <string name="error_generic">Something went wrong.</string>
@@ -90,6 +91,8 @@
9091 <string name="error_agent_reported">The agent reported an error.</string>
9192 <string name="error_failed_command">Failed to run command</string>
9293 <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>
9396 <string name="error_load_sessions">Failed to load sessions</string>
9497 <string name="error_create_session">Failed to create session</string>
9598 <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+}