git.iko.soy/opencode-android
on master help sign in
Add live session list updates, pull-to-refresh, chat loading state, keep-screen-on, reconnect button, code-block copy buttons, and retry on transient failures
commit960e1dc92efe8662f4441c78d2cb38af7773b92d authoriko <mail@iko.soy> dateMon 29 Jun 2026, 12:59 (3 months ago) parentfbd824e
8 files changed+273−30
README.md+13−1 app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.kt+18−9 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+26−5 app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+45 app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+90−2 app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+20−12 app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+60−1 app/src/main/res/values/strings.xml+1
README.md+13−1
@@ -6,7 +6,7 @@ Application id: `soy.iko.opencode`
66
77## Status
88
9−Milestones M0–M8 complete:
9+Milestones M0–M9 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.
@@ -43,6 +43,18 @@ Milestones M0–M8 complete:
4343 and **bounded preview loading** (concurrency-capped, cancelled on refresh) so the session
4444 list never floods the server with parallel requests.
4545
46+- **M9:** **Live session list** — `session.updated` / `session.deleted` SSE events now
47+ drive incremental list updates (new sessions, renames, deletions appear without a
48+ manual refresh); **pull-to-refresh** on the session list; **keep-screen-on** while an
49+ agent is running so long tasks aren't interrupted by screen-off; **chat loading
50+ state** — a spinner replaces the empty-conversation flash before the first message
51+ load; **reconnect button** when the connection is lost mid-chat; **live session
52+ title** in the chat header (updates when the agent renames the session);
53+ **connection-aware run indicator** — the spinner resets if the SSE stream drops
54+ mid-run instead of spinning forever; **retry-on-transient-failure** for all read
55+ endpoints (sessions, messages, files, providers); and **per-code-block copy
56+ buttons** in rendered markdown.
57+
4658## Architecture
4759
4860- **Ktor 3 (OkHttp engine)** for REST + the long-lived `GET /event` SSE stream.
app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.kt+18−9
@@ -39,8 +39,9 @@ class OpencodeApiClient(private val client: HttpClient) {
3939 client.get("global/health")
4040 }
4141
42− suspend fun listSessions(): List<Session> =
42+ suspend fun listSessions(): List<Session> = withRetry {
4343 client.get("session").body()
44+ }
4445
4546 suspend fun createSession(title: String? = null): Session =
4647 client.post("session") {
@@ -58,8 +59,9 @@ class OpencodeApiClient(private val client: HttpClient) {
5859 client.delete("session/$id")
5960 }
6061
61− suspend fun listMessages(sessionId: String): List<MessageWithParts> =
62+ suspend fun listMessages(sessionId: String): List<MessageWithParts> = withRetry {
6263 client.get("session/$sessionId/message").body()
64+ }
6365
6466 suspend fun sendPrompt(
6567 sessionId: String,
@@ -95,14 +97,17 @@ class OpencodeApiClient(private val client: HttpClient) {
9597 setBody(CommandRequest(command = command, arguments = arguments, agent = agent))
9698 }.body()
9799
98− suspend fun providers(): ProvidersResponse =
100+ suspend fun providers(): ProvidersResponse = withRetry {
99101 client.get("config/providers").body()
102+ }
100103
101− suspend fun agents(): List<Agent> =
104+ suspend fun agents(): List<Agent> = withRetry {
102105 client.get("agent").body()
106+ }
103107
104− suspend fun commands(): List<Command> =
108+ suspend fun commands(): List<Command> = withRetry {
105109 client.get("command").body()
110+ }
106111
107112 /** Respond to a permission request so a paused tool run can proceed. */
108113 suspend fun respondPermission(
@@ -118,17 +123,21 @@ class OpencodeApiClient(private val client: HttpClient) {
118123
119124 // --- Files ---
120125
121− suspend fun findFiles(query: String): List<String> =
126+ suspend fun findFiles(query: String): List<String> = withRetry {
122127 client.get("find/file") { parameter("query", query) }.body()
128+ }
123129
124− suspend fun listDirectory(path: String): List<FileNode> =
130+ suspend fun listDirectory(path: String): List<FileNode> = withRetry {
125131 client.get("file") { parameter("path", path) }.body()
132+ }
126133
127− suspend fun readFile(path: String): FileContent =
134+ suspend fun readFile(path: String): FileContent = withRetry {
128135 client.get("file/content") { parameter("path", path) }.body()
136+ }
129137
130− suspend fun fileStatus(): List<FileStatusEntry> =
138+ suspend fun fileStatus(): List<FileStatusEntry> = withRetry {
131139 client.get("file/status").body()
140+ }
132141}
133142
134143/**
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+26−5
@@ -31,6 +31,7 @@ import androidx.compose.material.icons.filled.Stop
3131import androidx.compose.material.icons.filled.Terminal
3232import androidx.compose.material.icons.filled.Tune
3333import androidx.compose.material3.AlertDialog
34+import androidx.compose.material3.Button
3435import androidx.compose.material3.CircularProgressIndicator
3536import androidx.compose.material3.ExperimentalMaterial3Api
3637import androidx.compose.material3.ExtendedFloatingActionButton
@@ -47,6 +48,7 @@ import androidx.compose.material3.Text
4748import androidx.compose.material3.TextButton
4849import androidx.compose.material3.TopAppBar
4950import androidx.compose.runtime.Composable
51+import androidx.compose.runtime.DisposableEffect
5052import androidx.compose.runtime.LaunchedEffect
5153import androidx.compose.runtime.derivedStateOf
5254import androidx.compose.runtime.getValue
@@ -65,6 +67,7 @@ import androidx.compose.ui.input.key.key
6567import androidx.compose.ui.input.key.onPreviewKeyEvent
6668import androidx.compose.ui.input.key.type
6769import androidx.compose.ui.platform.LocalHapticFeedback
70+import androidx.compose.ui.platform.LocalView
6871import androidx.compose.ui.res.stringResource
6972import androidx.compose.ui.semantics.Role
7073import androidx.compose.ui.semantics.role
@@ -92,6 +95,7 @@ fun ChatScreen(
9295 val messages by vm.messages.collectAsStateWithLifecycle()
9396 val running by vm.running.collectAsStateWithLifecycle()
9497 val error by vm.error.collectAsStateWithLifecycle()
98+ val loading by vm.loading.collectAsStateWithLifecycle()
9599 val models by vm.models.collectAsStateWithLifecycle()
96100 val selectedModel by vm.selectedModel.collectAsStateWithLifecycle()
97101 val connectionState by vm.connectionState.collectAsStateWithLifecycle()
@@ -112,6 +116,13 @@ fun ChatScreen(
112116 var showCommandPicker by remember { mutableStateOf(false) }
113117 var showExitConfirm by remember { mutableStateOf(false) }
114118
119+ // Keep the screen awake while the agent is actively working.
120+ val currentView = LocalView.current
121+ DisposableEffect(running) {
122+ currentView.keepScreenOn = running
123+ onDispose { currentView.keepScreenOn = false }
124+ }
125+
115126 // Track whether the list is pinned to the bottom so streaming auto-scroll
116127 // doesn't fight a user who scrolled up to read earlier output.
117128 val isPinnedToBottom by remember {
@@ -209,17 +220,27 @@ fun ChatScreen(
209220 ) { padding ->
210221 Box(modifier = Modifier.fillMaxSize().padding(padding)) {
211222 if (!vm.connected) {
212− Text(
213− stringResource(R.string.not_connected),
223+ Column(
214224 modifier = Modifier.align(Alignment.Center).padding(24.dp),
215− color = MaterialTheme.colorScheme.error,
216− )
225+ horizontalAlignment = Alignment.CenterHorizontally,
226+ ) {
227+ Text(
228+ stringResource(R.string.not_connected),
229+ color = MaterialTheme.colorScheme.error,
230+ )
231+ Spacer(Modifier.size(12.dp))
232+ Button(onClick = { vm.reconnect() }) {
233+ Text(stringResource(R.string.reconnect))
234+ }
235+ }
217236 } else {
218237 ConnectionBanner(
219238 state = connectionState,
220239 modifier = Modifier.align(Alignment.TopCenter),
221240 )
222− if (messages.isEmpty() && !running) {
241+ if (loading && messages.isEmpty()) {
242+ CircularProgressIndicator(Modifier.align(Alignment.Center))
243+ } else if (messages.isEmpty() && !running) {
223244 EmptyConversation(
224245 modifier = Modifier.align(Alignment.Center),
225246 )
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+45
@@ -10,6 +10,8 @@ import soy.iko.opencode.data.model.Permission
1010import soy.iko.opencode.data.model.PermissionReplied
1111import soy.iko.opencode.data.model.PermissionResponse
1212import soy.iko.opencode.data.model.PermissionUpdated
13+import soy.iko.opencode.data.model.SessionDeleted
14+import soy.iko.opencode.data.model.SessionUpdated
1315import soy.iko.opencode.data.model.TextPart
1416import soy.iko.opencode.data.model.defaultOption
1517import soy.iko.opencode.data.model.toOptions
@@ -22,8 +24,10 @@ import kotlinx.coroutines.flow.MutableStateFlow
2224import kotlinx.coroutines.flow.SharingStarted
2325import kotlinx.coroutines.flow.StateFlow
2426import kotlinx.coroutines.flow.asStateFlow
27+import kotlinx.coroutines.flow.first
2528import kotlinx.coroutines.flow.flatMapLatest
2629import kotlinx.coroutines.flow.flowOf
30+import kotlinx.coroutines.flow.onEach
2731import kotlinx.coroutines.flow.stateIn
2832import kotlinx.coroutines.launch
2933
@@ -37,11 +41,15 @@ class ChatViewModel(
3741
3842 val connected: Boolean get() = connection != null
3943
44+ private val _loading = MutableStateFlow(true)
45+ val loading: StateFlow<Boolean> = _loading.asStateFlow()
46+
4047 val messages: StateFlow<List<MessageWithParts>> =
4148 container.activeConnection
4249 .flatMapLatest { conn ->
4350 conn?.repository?.observeMessages(sessionId) ?: flowOf(emptyList())
4451 }
52+ .onEach { _loading.value = false }
4553 .stateIn(
4654 scope = viewModelScope,
4755 started = SharingStarted.WhileSubscribed(5_000),
@@ -119,6 +127,12 @@ class ChatViewModel(
119127 if (event.properties.sessionID == sessionId) _pendingPermission.value = event.properties
120128 is PermissionReplied ->
121129 if (event.properties.permissionID == _pendingPermission.value?.id) _pendingPermission.value = null
130+ is SessionUpdated ->
131+ if (event.properties.info.id == sessionId) _sessionTitle.value = event.properties.info.displayTitle
132+ is SessionDeleted ->
133+ if (event.properties.info?.id == sessionId || event.properties.sessionID == sessionId) {
134+ _sessionTitle.value = event.properties.info?.displayTitle
135+ }
122136 else -> {}
123137 }
124138 }
@@ -160,6 +174,18 @@ class ChatViewModel(
160174 ?.let { _sessionTitle.value = it.displayTitle }
161175 }
162176 }
177+ // If the SSE stream drops mid-run, the run indicator would spin forever;
178+ // reset it so the UI doesn't look stuck while the banner shows "Reconnecting…".
179+ viewModelScope.launch {
180+ container.activeConnection.collect { conn ->
181+ if (conn == null) return@collect
182+ conn.events.state.collect { state ->
183+ if (state == EventStreamClient.ConnectionState.Disconnected && _running.value) {
184+ _running.value = false
185+ }
186+ }
187+ }
188+ }
163189 }
164190
165191 /** Sends [text]; returns true on success so the caller can clear the draft only then. */
@@ -219,6 +245,25 @@ class ChatViewModel(
219245 }
220246 }
221247
248+ /** Reconnect to the most recently used server profile (used when the connection is gone). */
249+ fun reconnect() {
250+ if (container.activeConnection.value != null) return
251+ viewModelScope.launch {
252+ _loading.value = true
253+ val recent = runCatching { container.profileStore.profiles.first() }
254+ .getOrDefault(emptyList())
255+ .firstOrNull() ?: run { _loading.value = false; return@launch }
256+ runCatching {
257+ val conn = container.connect(recent)
258+ conn.api.ping()
259+ }.onFailure {
260+ container.disconnect()
261+ _error.value = container.friendlyError(it)
262+ _loading.value = false
263+ }
264+ }
265+ }
266+
222267 fun respondPermission(permission: Permission, response: PermissionResponse) {
223268 val conn = connection ?: return
224269 // Clear optimistically; permission.replied will confirm.
app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+90−2
@@ -5,23 +5,47 @@ import android.content.ClipboardManager
55import android.content.Context
66import android.widget.Toast
77import androidx.compose.foundation.ExperimentalFoundationApi
8+import androidx.compose.foundation.background
89import androidx.compose.foundation.combinedClickable
10+import androidx.compose.foundation.horizontalScroll
11+import androidx.compose.foundation.layout.Box
12+import androidx.compose.foundation.layout.fillMaxWidth
13+import androidx.compose.foundation.layout.padding
14+import androidx.compose.foundation.layout.size
15+import androidx.compose.foundation.rememberScrollState
16+import androidx.compose.foundation.shape.RoundedCornerShape
17+import androidx.compose.material.icons.Icons
18+import androidx.compose.material.icons.filled.ContentCopy
19+import androidx.compose.material3.Icon
20+import androidx.compose.material3.IconButton
921import androidx.compose.material3.MaterialTheme
22+import androidx.compose.material3.Surface
23+import androidx.compose.material3.Text
1024import androidx.compose.runtime.Composable
25+import androidx.compose.ui.Alignment
1126import androidx.compose.ui.Modifier
27+import androidx.compose.ui.draw.clip
1228import androidx.compose.ui.platform.LocalContext
1329import androidx.compose.ui.semantics.Role
1430import androidx.compose.ui.semantics.contentDescription
1531import androidx.compose.ui.semantics.role
1632import androidx.compose.ui.semantics.semantics
1733import androidx.compose.ui.text.TextStyle
34+import androidx.compose.ui.text.font.FontFamily
35+import androidx.compose.ui.unit.dp
36+import com.mikepenz.markdown.compose.components.MarkdownComponentModel
37+import com.mikepenz.markdown.compose.components.markdownComponents
1838import com.mikepenz.markdown.m3.Markdown
39+import org.intellij.markdown.MarkdownElementTypes
40+import org.intellij.markdown.ast.ASTNode
41+import org.intellij.markdown.ast.getTextInNode
1942import soy.iko.opencode.R
2043
2144/**
2245 * Renders markdown using [multiplatform-markdown-renderer](https://github.com/mikepenz/multiplatform-markdown-renderer)
2346 * (commonmark-java under the hood). Keeps a local API so callers don't import the library directly.
24− * Long-press copies the raw markdown to the system clipboard.
47+ * Long-press copies the raw markdown to the system clipboard. Code blocks/fences get an
48+ * inline copy button.
2549 */
2650@OptIn(ExperimentalFoundationApi::class)
2751@Composable
@@ -31,7 +55,6 @@ fun MarkdownText(
3155 style: TextStyle = MaterialTheme.typography.bodyLarge,
3256) {
3357 val context = LocalContext.current
34− val copyLabel = context.getString(R.string.copy)
3558 Markdown(
3659 content = markdown,
3760 modifier = modifier.semantics(mergeDescendants = true) {
@@ -41,9 +64,74 @@ fun MarkdownText(
4164 onClick = {},
4265 onLongClick = { copyToClipboard(context, markdown) },
4366 ),
67+ components = markdownComponents(
68+ codeFence = { CodeWithCopy(it) },
69+ codeBlock = { CodeWithCopy(it) },
70+ ),
4471 )
4572}
4673
74+/**
75+ * Renders a fenced or indented code block with a small copy button in the top-right corner.
76+ * Self-contained so it doesn't depend on the library's internal code-block composables.
77+ */
78+@Composable
79+private fun CodeWithCopy(model: MarkdownComponentModel) {
80+ val context = LocalContext.current
81+ val code = androidx.compose.runtime.remember(model) { extractCode(model.content, model.node) }
82+ val codeStyle = model.typography.code.copy(fontFamily = FontFamily.Monospace)
83+ Box(
84+ modifier = Modifier
85+ .fillMaxWidth()
86+ .clip(RoundedCornerShape(8.dp))
87+ .background(MaterialTheme.colorScheme.surfaceVariant),
88+ ) {
89+ Text(
90+ text = code,
91+ style = codeStyle,
92+ modifier = Modifier
93+ .horizontalScroll(rememberScrollState())
94+ .fillMaxWidth()
95+ .padding(start = 12.dp, top = 12.dp, end = 40.dp, bottom = 12.dp),
96+ )
97+ Surface(
98+ color = MaterialTheme.colorScheme.surface,
99+ shape = RoundedCornerShape(6.dp),
100+ modifier = Modifier
101+ .align(Alignment.TopEnd)
102+ .padding(4.dp),
103+ ) {
104+ IconButton(
105+ onClick = { copyToClipboard(context, "code", code) },
106+ modifier = Modifier.size(28.dp),
107+ ) {
108+ Icon(
109+ Icons.Filled.ContentCopy,
110+ contentDescription = context.getString(R.string.copy),
111+ modifier = Modifier.size(14.dp),
112+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
113+ )
114+ }
115+ }
116+ }
117+}
118+
119+/**
120+ * Extract the raw code text from a markdown code node, stripping fence markers
121+ * (```/~~~ and the language tag) so only the code content is copied.
122+ */
123+private fun extractCode(content: String, node: ASTNode): String {
124+ val raw = node.getTextInNode(content).toString()
125+ val isOpenFence = node.type == MarkdownElementTypes.CODE_FENCE
126+ if (!isOpenFence) return raw.trimIndent()
127+ val lines = raw.lines()
128+ val body = lines.drop(1).toMutableList()
129+ if (body.isNotEmpty() && (body.last().startsWith("```") || body.last().startsWith("~~~"))) {
130+ body.removeAt(body.lastIndex)
131+ }
132+ return body.joinToString("\n").trimEnd()
133+}
134+
47135/** Copy [text] to the system clipboard and show a confirmation toast. */
48136internal fun copyToClipboard(context: Context, label: String, text: String = label) {
49137 val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+20−12
@@ -44,6 +44,7 @@ import androidx.compose.material3.SnackbarHostState
4444import androidx.compose.material3.Text
4545import androidx.compose.material3.TextButton
4646import androidx.compose.material3.TopAppBar
47+import androidx.compose.material3.pulltorefresh.PullToRefreshBox
4748import androidx.compose.runtime.Composable
4849import androidx.compose.runtime.LaunchedEffect
4950import androidx.compose.runtime.getValue
@@ -79,6 +80,7 @@ fun SessionListScreen(
7980) {
8081 val vm: SessionListViewModel = viewModel(factory = vmFactory { SessionListViewModel(container) })
8182 val state by vm.state.collectAsStateWithLifecycle()
83+ val refreshing by vm.refreshing.collectAsStateWithLifecycle()
8284 val transientError by vm.transientError.collectAsStateWithLifecycle()
8385 val serverLabel by vm.serverLabel.collectAsStateWithLifecycle()
8486 val profiles by vm.profiles.collectAsStateWithLifecycle()
@@ -203,20 +205,26 @@ fun SessionListScreen(
203205 color = MaterialTheme.colorScheme.onSurfaceVariant,
204206 )
205207 } else {
206− LazyColumn(
208+ PullToRefreshBox(
209+ isRefreshing = refreshing,
210+ onRefresh = { vm.refresh() },
207211 modifier = Modifier.fillMaxSize(),
208− contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 96.dp),
209− verticalArrangement = Arrangement.spacedBy(12.dp),
210212 ) {
211− items(sessions, key = { it.id }) { session ->
212− SessionCard(
213− session = session,
214− preview = state.previews[session.id],
215− onClick = { onOpenSession(session.id) },
216− onRename = { pendingRename = session },
217− onDelete = { pendingDelete = session },
218− modifier = Modifier.animateItem(),
219− )
213+ LazyColumn(
214+ modifier = Modifier.fillMaxSize(),
215+ contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 96.dp),
216+ verticalArrangement = Arrangement.spacedBy(12.dp),
217+ ) {
218+ items(sessions, key = { it.id }) { session ->
219+ SessionCard(
220+ session = session,
221+ preview = state.previews[session.id],
222+ onClick = { onOpenSession(session.id) },
223+ onRename = { pendingRename = session },
224+ onDelete = { pendingDelete = session },
225+ modifier = Modifier.animateItem(),
226+ )
227+ }
220228 }
221229 }
222230 }
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+60−1
@@ -4,6 +4,8 @@ import androidx.lifecycle.ViewModel
44import androidx.lifecycle.viewModelScope
55import soy.iko.opencode.data.model.ServerProfile
66import soy.iko.opencode.data.model.Session
7+import soy.iko.opencode.data.model.SessionDeleted
8+import soy.iko.opencode.data.model.SessionUpdated
79import soy.iko.opencode.data.model.TextPart
810import soy.iko.opencode.di.AppContainer
911import soy.iko.opencode.R
@@ -58,6 +60,10 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
5860 private val _state = MutableStateFlow(SessionListState())
5961 val state: StateFlow<SessionListState> = _state.asStateFlow()
6062
63+ /** Tracks an in-flight manual refresh so pull-to-refresh can show its indicator. */
64+ private val _refreshing = MutableStateFlow(false)
65+ val refreshing: StateFlow<Boolean> = _refreshing.asStateFlow()
66+
6167 /** Transient errors (failed create/delete/rename) surfaced as a snackbar, not by hiding the list. */
6268 private val _transientError = MutableStateFlow<String?>(null)
6369 val transientError: StateFlow<String?> = _transientError.asStateFlow()
@@ -68,7 +74,44 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
6874 private val activeProfileId: String?
6975 get() = container.activeConnection.value?.profile?.id
7076
71− init { refresh() }
77+ init { refresh(); observeSessionEvents() }
78+
79+ /**
80+ * Keep the session list live by reacting to SSE session events. New/renamed
81+ * sessions upsert and re-sort; deleted sessions drop immediately — no manual
82+ * refresh needed when activity happens server-side.
83+ */
84+ private fun observeSessionEvents() {
85+ viewModelScope.launch {
86+ container.activeConnection.collect { conn ->
87+ if (conn == null) return@collect
88+ conn.events.events.collect { event ->
89+ when (event) {
90+ is SessionUpdated -> {
91+ val session = event.properties.info
92+ val current = _state.value.sessions
93+ val updated = (current.filterNot { it.id == session.id } + session)
94+ .sortedByDescending { it.time?.updated ?: it.time?.created ?: 0 }
95+ _state.value = _state.value.copy(sessions = updated)
96+ loadPreview(session.id)
97+ }
98+ is SessionDeleted -> {
99+ val id = event.properties.info?.id ?: event.properties.sessionID
100+ if (id != null) {
101+ _state.update { s ->
102+ s.copy(
103+ sessions = s.sessions.filterNot { it.id == id },
104+ previews = s.previews - id,
105+ )
106+ }
107+ }
108+ }
109+ else -> {}
110+ }
111+ }
112+ }
113+ }
114+ }
72115
73116 fun setQuery(query: String) {
74117 _state.value = _state.value.copy(query = query)
@@ -87,6 +130,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
87130 }
88131 _serverLabel.value = conn.profile.displayLabel
89132 _state.value = _state.value.copy(loading = true, error = null)
133+ _refreshing.value = true
90134 viewModelScope.launch {
91135 runCatching { conn.repository.listSessions() }
92136 .onSuccess { list ->
@@ -102,6 +146,21 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
102146 _state.value = SessionListState(loading = false, error = container.friendlyError(it))
103147 }
104148 }
149+ _refreshing.value = false
150+ }
151+ }
152+
153+ /** Reload the preview for a single session (used by the live SSE handler). */
154+ private fun loadPreview(sessionId: String) {
155+ val conn = container.activeConnection.value ?: return
156+ val api = conn.api
157+ viewModelScope.launch {
158+ val preview = runCatching {
159+ api.listMessages(sessionId).lastOrNull()?.let { msg ->
160+ msg.parts.filterIsInstance<TextPart>().lastOrNull()?.text
161+ }
162+ }.getOrNull()?.takeIf { it.isNotBlank() }?.take(200) ?: return@launch
163+ _state.update { s -> s.copy(previews = s.previews + (sessionId to preview)) }
105164 }
106165 }
107166
app/src/main/res/values/strings.xml+1
@@ -33,6 +33,7 @@
3333 <!-- Connection banner -->
3434 <string name="connecting">Connecting…</string>
3535 <string name="reconnecting">Reconnecting…</string>
36+ <string name="reconnect">Reconnect</string>
3637 <string name="not_connected">Not connected.</string>
3738
3839 <!-- Server list -->