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
960e1dciko
README.md+13−1
@@ -6,7 +6,7 @@ Application id: `soy.iko.opencode`
6
6
7## Status
7## Status
8
8
9−Milestones M0–M8 complete:
9+Milestones M0–M9 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.
@@ -43,6 +43,18 @@ Milestones M0–M8 complete:
43 and **bounded preview loading** (concurrency-capped, cancelled on refresh) so the session
43 and **bounded preview loading** (concurrency-capped, cancelled on refresh) so the session
44 list never floods the server with parallel requests.
44 list never floods the server with parallel requests.
45
45
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+
46## Architecture
58## Architecture
47
59
48- **Ktor 3 (OkHttp engine)** for REST + the long-lived `GET /event` SSE stream.
60- **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) {
39 client.get("global/health")
39 client.get("global/health")
40 }
40 }
41
41
42− suspend fun listSessions(): List<Session> =
42+ suspend fun listSessions(): List<Session> = withRetry {
43 client.get("session").body()
43 client.get("session").body()
44+ }
44
45
45 suspend fun createSession(title: String? = null): Session =
46 suspend fun createSession(title: String? = null): Session =
46 client.post("session") {
47 client.post("session") {
@@ -58,8 +59,9 @@ class OpencodeApiClient(private val client: HttpClient) {
58 client.delete("session/$id")
59 client.delete("session/$id")
59 }
60 }
60
61
61− suspend fun listMessages(sessionId: String): List<MessageWithParts> =
62+ suspend fun listMessages(sessionId: String): List<MessageWithParts> = withRetry {
62 client.get("session/$sessionId/message").body()
63 client.get("session/$sessionId/message").body()
64+ }
63
65
64 suspend fun sendPrompt(
66 suspend fun sendPrompt(
65 sessionId: String,
67 sessionId: String,
@@ -95,14 +97,17 @@ class OpencodeApiClient(private val client: HttpClient) {
95 setBody(CommandRequest(command = command, arguments = arguments, agent = agent))
97 setBody(CommandRequest(command = command, arguments = arguments, agent = agent))
96 }.body()
98 }.body()
97
99
98− suspend fun providers(): ProvidersResponse =
100+ suspend fun providers(): ProvidersResponse = withRetry {
99 client.get("config/providers").body()
101 client.get("config/providers").body()
102+ }
100
103
101− suspend fun agents(): List<Agent> =
104+ suspend fun agents(): List<Agent> = withRetry {
102 client.get("agent").body()
105 client.get("agent").body()
106+ }
103
107
104− suspend fun commands(): List<Command> =
108+ suspend fun commands(): List<Command> = withRetry {
105 client.get("command").body()
109 client.get("command").body()
110+ }
106
111
107 /** Respond to a permission request so a paused tool run can proceed. */
112 /** Respond to a permission request so a paused tool run can proceed. */
108 suspend fun respondPermission(
113 suspend fun respondPermission(
@@ -118,17 +123,21 @@ class OpencodeApiClient(private val client: HttpClient) {
118
123
119 // --- Files ---
124 // --- Files ---
120
125
121− suspend fun findFiles(query: String): List<String> =
126+ suspend fun findFiles(query: String): List<String> = withRetry {
122 client.get("find/file") { parameter("query", query) }.body()
127 client.get("find/file") { parameter("query", query) }.body()
128+ }
123
129
124− suspend fun listDirectory(path: String): List<FileNode> =
130+ suspend fun listDirectory(path: String): List<FileNode> = withRetry {
125 client.get("file") { parameter("path", path) }.body()
131 client.get("file") { parameter("path", path) }.body()
132+ }
126
133
127− suspend fun readFile(path: String): FileContent =
134+ suspend fun readFile(path: String): FileContent = withRetry {
128 client.get("file/content") { parameter("path", path) }.body()
135 client.get("file/content") { parameter("path", path) }.body()
136+ }
129
137
130− suspend fun fileStatus(): List<FileStatusEntry> =
138+ suspend fun fileStatus(): List<FileStatusEntry> = withRetry {
131 client.get("file/status").body()
139 client.get("file/status").body()
140+ }
132}
141}
133
142
134/**
143/**
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+26−5
@@ -31,6 +31,7 @@ import androidx.compose.material.icons.filled.Stop
31import androidx.compose.material.icons.filled.Terminal
31import androidx.compose.material.icons.filled.Terminal
32import androidx.compose.material.icons.filled.Tune
32import androidx.compose.material.icons.filled.Tune
33import androidx.compose.material3.AlertDialog
33import androidx.compose.material3.AlertDialog
34+import androidx.compose.material3.Button
34import androidx.compose.material3.CircularProgressIndicator
35import androidx.compose.material3.CircularProgressIndicator
35import androidx.compose.material3.ExperimentalMaterial3Api
36import androidx.compose.material3.ExperimentalMaterial3Api
36import androidx.compose.material3.ExtendedFloatingActionButton
37import androidx.compose.material3.ExtendedFloatingActionButton
@@ -47,6 +48,7 @@ import androidx.compose.material3.Text
47import androidx.compose.material3.TextButton
48import androidx.compose.material3.TextButton
48import androidx.compose.material3.TopAppBar
49import androidx.compose.material3.TopAppBar
49import androidx.compose.runtime.Composable
50import androidx.compose.runtime.Composable
51+import androidx.compose.runtime.DisposableEffect
50import androidx.compose.runtime.LaunchedEffect
52import androidx.compose.runtime.LaunchedEffect
51import androidx.compose.runtime.derivedStateOf
53import androidx.compose.runtime.derivedStateOf
52import androidx.compose.runtime.getValue
54import androidx.compose.runtime.getValue
@@ -65,6 +67,7 @@ import androidx.compose.ui.input.key.key
65import androidx.compose.ui.input.key.onPreviewKeyEvent
67import androidx.compose.ui.input.key.onPreviewKeyEvent
66import androidx.compose.ui.input.key.type
68import androidx.compose.ui.input.key.type
67import androidx.compose.ui.platform.LocalHapticFeedback
69import androidx.compose.ui.platform.LocalHapticFeedback
70+import androidx.compose.ui.platform.LocalView
68import androidx.compose.ui.res.stringResource
71import androidx.compose.ui.res.stringResource
69import androidx.compose.ui.semantics.Role
72import androidx.compose.ui.semantics.Role
70import androidx.compose.ui.semantics.role
73import androidx.compose.ui.semantics.role
@@ -92,6 +95,7 @@ fun ChatScreen(
92 val messages by vm.messages.collectAsStateWithLifecycle()
95 val messages by vm.messages.collectAsStateWithLifecycle()
93 val running by vm.running.collectAsStateWithLifecycle()
96 val running by vm.running.collectAsStateWithLifecycle()
94 val error by vm.error.collectAsStateWithLifecycle()
97 val error by vm.error.collectAsStateWithLifecycle()
98+ val loading by vm.loading.collectAsStateWithLifecycle()
95 val models by vm.models.collectAsStateWithLifecycle()
99 val models by vm.models.collectAsStateWithLifecycle()
96 val selectedModel by vm.selectedModel.collectAsStateWithLifecycle()
100 val selectedModel by vm.selectedModel.collectAsStateWithLifecycle()
97 val connectionState by vm.connectionState.collectAsStateWithLifecycle()
101 val connectionState by vm.connectionState.collectAsStateWithLifecycle()
@@ -112,6 +116,13 @@ fun ChatScreen(
112 var showCommandPicker by remember { mutableStateOf(false) }
116 var showCommandPicker by remember { mutableStateOf(false) }
113 var showExitConfirm by remember { mutableStateOf(false) }
117 var showExitConfirm by remember { mutableStateOf(false) }
114
118
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+
115 // Track whether the list is pinned to the bottom so streaming auto-scroll
126 // Track whether the list is pinned to the bottom so streaming auto-scroll
116 // doesn't fight a user who scrolled up to read earlier output.
127 // doesn't fight a user who scrolled up to read earlier output.
117 val isPinnedToBottom by remember {
128 val isPinnedToBottom by remember {
@@ -209,17 +220,27 @@ fun ChatScreen(
209 ) { padding ->
220 ) { padding ->
210 Box(modifier = Modifier.fillMaxSize().padding(padding)) {
221 Box(modifier = Modifier.fillMaxSize().padding(padding)) {
211 if (!vm.connected) {
222 if (!vm.connected) {
212− Text(
223+ Column(
213− stringResource(R.string.not_connected),
214 modifier = Modifier.align(Alignment.Center).padding(24.dp),
224 modifier = Modifier.align(Alignment.Center).padding(24.dp),
225+ horizontalAlignment = Alignment.CenterHorizontally,
226+ ) {
227+ Text(
228+ stringResource(R.string.not_connected),
215− color = MaterialTheme.colorScheme.error,
229+ color = MaterialTheme.colorScheme.error,
216− )
230+ )
231+ Spacer(Modifier.size(12.dp))
232+ Button(onClick = { vm.reconnect() }) {
233+ Text(stringResource(R.string.reconnect))
234+ }
235+ }
217 } else {
236 } else {
218 ConnectionBanner(
237 ConnectionBanner(
219 state = connectionState,
238 state = connectionState,
220 modifier = Modifier.align(Alignment.TopCenter),
239 modifier = Modifier.align(Alignment.TopCenter),
221 )
240 )
241+ if (loading && messages.isEmpty()) {
242+ CircularProgressIndicator(Modifier.align(Alignment.Center))
222− if (messages.isEmpty() && !running) {
243+ } else if (messages.isEmpty() && !running) {
223 EmptyConversation(
244 EmptyConversation(
224 modifier = Modifier.align(Alignment.Center),
245 modifier = Modifier.align(Alignment.Center),
225 )
246 )
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+45
@@ -10,6 +10,8 @@ import soy.iko.opencode.data.model.Permission
10import soy.iko.opencode.data.model.PermissionReplied
10import soy.iko.opencode.data.model.PermissionReplied
11import soy.iko.opencode.data.model.PermissionResponse
11import soy.iko.opencode.data.model.PermissionResponse
12import soy.iko.opencode.data.model.PermissionUpdated
12import soy.iko.opencode.data.model.PermissionUpdated
13+import soy.iko.opencode.data.model.SessionDeleted
14+import soy.iko.opencode.data.model.SessionUpdated
13import soy.iko.opencode.data.model.TextPart
15import soy.iko.opencode.data.model.TextPart
14import soy.iko.opencode.data.model.defaultOption
16import soy.iko.opencode.data.model.defaultOption
15import soy.iko.opencode.data.model.toOptions
17import soy.iko.opencode.data.model.toOptions
@@ -22,8 +24,10 @@ import kotlinx.coroutines.flow.MutableStateFlow
22import kotlinx.coroutines.flow.SharingStarted
24import kotlinx.coroutines.flow.SharingStarted
23import kotlinx.coroutines.flow.StateFlow
25import kotlinx.coroutines.flow.StateFlow
24import kotlinx.coroutines.flow.asStateFlow
26import kotlinx.coroutines.flow.asStateFlow
27+import kotlinx.coroutines.flow.first
25import kotlinx.coroutines.flow.flatMapLatest
28import kotlinx.coroutines.flow.flatMapLatest
26import kotlinx.coroutines.flow.flowOf
29import kotlinx.coroutines.flow.flowOf
30+import kotlinx.coroutines.flow.onEach
27import kotlinx.coroutines.flow.stateIn
31import kotlinx.coroutines.flow.stateIn
28import kotlinx.coroutines.launch
32import kotlinx.coroutines.launch
29
33
@@ -37,11 +41,15 @@ class ChatViewModel(
37
41
38 val connected: Boolean get() = connection != null
42 val connected: Boolean get() = connection != null
39
43
44+ private val _loading = MutableStateFlow(true)
45+ val loading: StateFlow<Boolean> = _loading.asStateFlow()
46+
40 val messages: StateFlow<List<MessageWithParts>> =
47 val messages: StateFlow<List<MessageWithParts>> =
41 container.activeConnection
48 container.activeConnection
42 .flatMapLatest { conn ->
49 .flatMapLatest { conn ->
43 conn?.repository?.observeMessages(sessionId) ?: flowOf(emptyList())
50 conn?.repository?.observeMessages(sessionId) ?: flowOf(emptyList())
44 }
51 }
52+ .onEach { _loading.value = false }
45 .stateIn(
53 .stateIn(
46 scope = viewModelScope,
54 scope = viewModelScope,
47 started = SharingStarted.WhileSubscribed(5_000),
55 started = SharingStarted.WhileSubscribed(5_000),
@@ -119,6 +127,12 @@ class ChatViewModel(
119 if (event.properties.sessionID == sessionId) _pendingPermission.value = event.properties
127 if (event.properties.sessionID == sessionId) _pendingPermission.value = event.properties
120 is PermissionReplied ->
128 is PermissionReplied ->
121 if (event.properties.permissionID == _pendingPermission.value?.id) _pendingPermission.value = null
129 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+ }
122 else -> {}
136 else -> {}
123 }
137 }
124 }
138 }
@@ -160,6 +174,18 @@ class ChatViewModel(
160 ?.let { _sessionTitle.value = it.displayTitle }
174 ?.let { _sessionTitle.value = it.displayTitle }
161 }
175 }
162 }
176 }
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+ }
163 }
189 }
164
190
165 /** Sends [text]; returns true on success so the caller can clear the draft only then. */
191 /** Sends [text]; returns true on success so the caller can clear the draft only then. */
@@ -219,6 +245,25 @@ class ChatViewModel(
219 }
245 }
220 }
246 }
221
247
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+
222 fun respondPermission(permission: Permission, response: PermissionResponse) {
267 fun respondPermission(permission: Permission, response: PermissionResponse) {
223 val conn = connection ?: return
268 val conn = connection ?: return
224 // Clear optimistically; permission.replied will confirm.
269 // 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
5import android.content.Context
5import android.content.Context
6import android.widget.Toast
6import android.widget.Toast
7import androidx.compose.foundation.ExperimentalFoundationApi
7import androidx.compose.foundation.ExperimentalFoundationApi
8+import androidx.compose.foundation.background
8import androidx.compose.foundation.combinedClickable
9import 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
9import androidx.compose.material3.MaterialTheme
21import androidx.compose.material3.MaterialTheme
22+import androidx.compose.material3.Surface
23+import androidx.compose.material3.Text
10import androidx.compose.runtime.Composable
24import androidx.compose.runtime.Composable
25+import androidx.compose.ui.Alignment
11import androidx.compose.ui.Modifier
26import androidx.compose.ui.Modifier
27+import androidx.compose.ui.draw.clip
12import androidx.compose.ui.platform.LocalContext
28import androidx.compose.ui.platform.LocalContext
13import androidx.compose.ui.semantics.Role
29import androidx.compose.ui.semantics.Role
14import androidx.compose.ui.semantics.contentDescription
30import androidx.compose.ui.semantics.contentDescription
15import androidx.compose.ui.semantics.role
31import androidx.compose.ui.semantics.role
16import androidx.compose.ui.semantics.semantics
32import androidx.compose.ui.semantics.semantics
17import androidx.compose.ui.text.TextStyle
33import 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
18import com.mikepenz.markdown.m3.Markdown
38import 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
19import soy.iko.opencode.R
42import soy.iko.opencode.R
20
43
21/**
44/**
22 * Renders markdown using [multiplatform-markdown-renderer](https://github.com/mikepenz/multiplatform-markdown-renderer)
45 * Renders markdown using [multiplatform-markdown-renderer](https://github.com/mikepenz/multiplatform-markdown-renderer)
23 * (commonmark-java under the hood). Keeps a local API so callers don't import the library directly.
46 * (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.
25 */
49 */
26@OptIn(ExperimentalFoundationApi::class)
50@OptIn(ExperimentalFoundationApi::class)
27@Composable
51@Composable
@@ -31,7 +55,6 @@ fun MarkdownText(
31 style: TextStyle = MaterialTheme.typography.bodyLarge,
55 style: TextStyle = MaterialTheme.typography.bodyLarge,
32) {
56) {
33 val context = LocalContext.current
57 val context = LocalContext.current
34− val copyLabel = context.getString(R.string.copy)
35 Markdown(
58 Markdown(
36 content = markdown,
59 content = markdown,
37 modifier = modifier.semantics(mergeDescendants = true) {
60 modifier = modifier.semantics(mergeDescendants = true) {
@@ -41,9 +64,74 @@ fun MarkdownText(
41 onClick = {},
64 onClick = {},
42 onLongClick = { copyToClipboard(context, markdown) },
65 onLongClick = { copyToClipboard(context, markdown) },
43 ),
66 ),
67+ components = markdownComponents(
68+ codeFence = { CodeWithCopy(it) },
69+ codeBlock = { CodeWithCopy(it) },
70+ ),
44 )
71 )
45}
72}
46
73
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+
47/** Copy [text] to the system clipboard and show a confirmation toast. */
135/** Copy [text] to the system clipboard and show a confirmation toast. */
48internal fun copyToClipboard(context: Context, label: String, text: String = label) {
136internal fun copyToClipboard(context: Context, label: String, text: String = label) {
49 val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return
137 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
44import androidx.compose.material3.Text
44import androidx.compose.material3.Text
45import androidx.compose.material3.TextButton
45import androidx.compose.material3.TextButton
46import androidx.compose.material3.TopAppBar
46import androidx.compose.material3.TopAppBar
47+import androidx.compose.material3.pulltorefresh.PullToRefreshBox
47import androidx.compose.runtime.Composable
48import androidx.compose.runtime.Composable
48import androidx.compose.runtime.LaunchedEffect
49import androidx.compose.runtime.LaunchedEffect
49import androidx.compose.runtime.getValue
50import androidx.compose.runtime.getValue
@@ -79,6 +80,7 @@ fun SessionListScreen(
79) {
80) {
80 val vm: SessionListViewModel = viewModel(factory = vmFactory { SessionListViewModel(container) })
81 val vm: SessionListViewModel = viewModel(factory = vmFactory { SessionListViewModel(container) })
81 val state by vm.state.collectAsStateWithLifecycle()
82 val state by vm.state.collectAsStateWithLifecycle()
83+ val refreshing by vm.refreshing.collectAsStateWithLifecycle()
82 val transientError by vm.transientError.collectAsStateWithLifecycle()
84 val transientError by vm.transientError.collectAsStateWithLifecycle()
83 val serverLabel by vm.serverLabel.collectAsStateWithLifecycle()
85 val serverLabel by vm.serverLabel.collectAsStateWithLifecycle()
84 val profiles by vm.profiles.collectAsStateWithLifecycle()
86 val profiles by vm.profiles.collectAsStateWithLifecycle()
@@ -203,20 +205,26 @@ fun SessionListScreen(
203 color = MaterialTheme.colorScheme.onSurfaceVariant,
205 color = MaterialTheme.colorScheme.onSurfaceVariant,
204 )
206 )
205 } else {
207 } else {
206− LazyColumn(
208+ PullToRefreshBox(
209+ isRefreshing = refreshing,
210+ onRefresh = { vm.refresh() },
207 modifier = Modifier.fillMaxSize(),
211 modifier = Modifier.fillMaxSize(),
208− contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 96.dp),
209− verticalArrangement = Arrangement.spacedBy(12.dp),
210 ) {
212 ) {
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+ ) {
211− items(sessions, key = { it.id }) { session ->
218+ items(sessions, key = { it.id }) { session ->
212− SessionCard(
219+ SessionCard(
213− session = session,
220+ session = session,
214− preview = state.previews[session.id],
221+ preview = state.previews[session.id],
215− onClick = { onOpenSession(session.id) },
222+ onClick = { onOpenSession(session.id) },
216− onRename = { pendingRename = session },
223+ onRename = { pendingRename = session },
217− onDelete = { pendingDelete = session },
224+ onDelete = { pendingDelete = session },
218− modifier = Modifier.animateItem(),
225+ modifier = Modifier.animateItem(),
219− )
226+ )
227+ }
220 }
228 }
221 }
229 }
222 }
230 }
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+60−1
@@ -4,6 +4,8 @@ import androidx.lifecycle.ViewModel
4import androidx.lifecycle.viewModelScope
4import androidx.lifecycle.viewModelScope
5import soy.iko.opencode.data.model.ServerProfile
5import soy.iko.opencode.data.model.ServerProfile
6import soy.iko.opencode.data.model.Session
6import soy.iko.opencode.data.model.Session
7+import soy.iko.opencode.data.model.SessionDeleted
8+import soy.iko.opencode.data.model.SessionUpdated
7import soy.iko.opencode.data.model.TextPart
9import soy.iko.opencode.data.model.TextPart
8import soy.iko.opencode.di.AppContainer
10import soy.iko.opencode.di.AppContainer
9import soy.iko.opencode.R
11import soy.iko.opencode.R
@@ -58,6 +60,10 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
58 private val _state = MutableStateFlow(SessionListState())
60 private val _state = MutableStateFlow(SessionListState())
59 val state: StateFlow<SessionListState> = _state.asStateFlow()
61 val state: StateFlow<SessionListState> = _state.asStateFlow()
60
62
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+
61 /** Transient errors (failed create/delete/rename) surfaced as a snackbar, not by hiding the list. */
67 /** Transient errors (failed create/delete/rename) surfaced as a snackbar, not by hiding the list. */
62 private val _transientError = MutableStateFlow<String?>(null)
68 private val _transientError = MutableStateFlow<String?>(null)
63 val transientError: StateFlow<String?> = _transientError.asStateFlow()
69 val transientError: StateFlow<String?> = _transientError.asStateFlow()
@@ -68,7 +74,44 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
68 private val activeProfileId: String?
74 private val activeProfileId: String?
69 get() = container.activeConnection.value?.profile?.id
75 get() = container.activeConnection.value?.profile?.id
70
76
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+ }
72
115
73 fun setQuery(query: String) {
116 fun setQuery(query: String) {
74 _state.value = _state.value.copy(query = query)
117 _state.value = _state.value.copy(query = query)
@@ -87,6 +130,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
87 }
130 }
88 _serverLabel.value = conn.profile.displayLabel
131 _serverLabel.value = conn.profile.displayLabel
89 _state.value = _state.value.copy(loading = true, error = null)
132 _state.value = _state.value.copy(loading = true, error = null)
133+ _refreshing.value = true
90 viewModelScope.launch {
134 viewModelScope.launch {
91 runCatching { conn.repository.listSessions() }
135 runCatching { conn.repository.listSessions() }
92 .onSuccess { list ->
136 .onSuccess { list ->
@@ -102,6 +146,21 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
102 _state.value = SessionListState(loading = false, error = container.friendlyError(it))
146 _state.value = SessionListState(loading = false, error = container.friendlyError(it))
103 }
147 }
104 }
148 }
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)) }
105 }
164 }
106 }
165 }
107
166
app/src/main/res/values/strings.xml+1
@@ -33,6 +33,7 @@
33 <!-- Connection banner -->
33 <!-- Connection banner -->
34 <string name="connecting">Connecting…</string>
34 <string name="connecting">Connecting…</string>
35 <string name="reconnecting">Reconnecting…</string>
35 <string name="reconnecting">Reconnecting…</string>
36+ <string name="reconnect">Reconnect</string>
36 <string name="not_connected">Not connected.</string>
37 <string name="not_connected">Not connected.</string>
37
38
38 <!-- Server list -->
39 <!-- Server list -->