git.iko.soy/opencode-android
on master help sign in
Add Enter-to-send, per-session draft persistence, message timestamps, VCS status badges in the file browser, connectivity-aware SSE reconnect, cleaner token/cost formatting, and bounded preview loading
4743e50iko
README.md+10−1
@@ -6,7 +6,7 @@ Application id: `soy.iko.opencode`
6
6
7## Status
7## Status
8
8
9−Milestones M0–M7 complete:
9+Milestones M0–M8 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.
@@ -33,6 +33,15 @@ Milestones M0–M7 complete:
33 polished empty states; richer **Settings** (Dynamic Color / Material You toggle, version info,
33 polished empty states; richer **Settings** (Dynamic Color / Material You toggle, version info,
34 manage-servers link); haptic feedback; and a file viewer with line numbers, copy, and share.
34 manage-servers link); haptic feedback; and a file viewer with line numbers, copy, and share.
35 The session list also supports **renaming** a session (`PATCH /session/:id`).
35 The session list also supports **renaming** a session (`PATCH /session/:id`).
36+- **M8:** **Enter-to-send** (hardware-keyboard Enter sends, Shift+Enter for newline);
37+ **per-session draft persistence** — typed text survives back-navigation and process
38+ death; **message timestamps** (relative time on every bubble); **VCS status in the file
39+ browser** — `GET /file/status` powers added/modified/deleted badges with `+N/−N` line
40+ counts on tracked files; **connectivity-aware SSE reconnect** — a `ConnectivityManager`
41+ callback triggers an immediate reconnect (skipping backoff) when the device regains
42+ network; cleaner **token/cost formatting** (`1,234 in · 5,678 out`, precision-aware cost);
43+ and **bounded preview loading** (concurrency-capped, cancelled on refresh) so the session
44+ list never floods the server with parallel requests.
36
45
37## Architecture
46## Architecture
38
47
app/src/main/AndroidManifest.xml+1
@@ -2,6 +2,7 @@
2<manifest xmlns:android="http://schemas.android.com/apk/res/android">
2<manifest xmlns:android="http://schemas.android.com/apk/res/android">
3
3
4 <uses-permission android:name="android.permission.INTERNET" />
4 <uses-permission android:name="android.permission.INTERNET" />
5+ <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
5
6
6 <application
7 <application
7 android:name=".OpencodeApp"
8 android:name=".OpencodeApp"
app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+17−2
@@ -5,6 +5,7 @@ import io.ktor.client.HttpClient
5import io.ktor.client.plugins.sse.sse
5import io.ktor.client.plugins.sse.sse
6import kotlinx.coroutines.CancellationException
6import kotlinx.coroutines.CancellationException
7import kotlinx.coroutines.CoroutineScope
7import kotlinx.coroutines.CoroutineScope
8+import kotlinx.coroutines.channels.Channel
8import kotlinx.coroutines.delay
9import kotlinx.coroutines.delay
9import kotlinx.coroutines.flow.Flow
10import kotlinx.coroutines.flow.Flow
10import kotlinx.coroutines.flow.MutableStateFlow
11import kotlinx.coroutines.flow.MutableStateFlow
@@ -15,6 +16,8 @@ import kotlinx.coroutines.flow.asStateFlow
15import kotlinx.coroutines.flow.channelFlow
16import kotlinx.coroutines.flow.channelFlow
16import kotlinx.coroutines.flow.shareIn
17import kotlinx.coroutines.flow.shareIn
17import kotlinx.coroutines.isActive
18import kotlinx.coroutines.isActive
19+import kotlinx.coroutines.selects.onTimeout
20+import kotlinx.coroutines.selects.select
18
21
19/**
22/**
20 * Owns the single long-lived `GET /event` SSE subscription and exposes it as a hot
23 * Owns the single long-lived `GET /event` SSE subscription and exposes it as a hot
@@ -24,6 +27,7 @@ import kotlinx.coroutines.isActive
24 * Ordering contract: callers that send a prompt must already be collecting [events]
27 * Ordering contract: callers that send a prompt must already be collecting [events]
25 * (directly or via the repository) so early `message.part.updated` deltas aren't missed.
28 * (directly or via the repository) so early `message.part.updated` deltas aren't missed.
26 */
29 */
30+@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
27class EventStreamClient(
31class EventStreamClient(
28 private val client: HttpClient,
32 private val client: HttpClient,
29 scope: CoroutineScope,
33 scope: CoroutineScope,
@@ -40,6 +44,12 @@ class EventStreamClient(
40 replay = 0,
44 replay = 0,
41 )
45 )
42
46
47+ /** Conflated channel used to cut the reconnect backoff short when network returns. */
48+ private val reconnectSignal = Channel<Unit>(Channel.CONFLATED)
49+
50+ /** Request an immediate reconnect, skipping any in-progress backoff. */
51+ fun triggerReconnect() { reconnectSignal.trySend(Unit) }
52+
43 private fun stream(): Flow<BusEvent> = channelFlow {
53 private fun stream(): Flow<BusEvent> = channelFlow {
44 var backoffMs = INITIAL_BACKOFF_MS
54 var backoffMs = INITIAL_BACKOFF_MS
45 while (isActive) {
55 while (isActive) {
@@ -66,8 +76,13 @@ class EventStreamClient(
66 }
76 }
67 _state.value = ConnectionState.Disconnected
77 _state.value = ConnectionState.Disconnected
68 if (!isActive) break
78 if (!isActive) break
69− delay(backoffMs)
79+ // Wait for the backoff, but allow a reconnect signal to cut it short.
80+ var signaled = false
81+ select {
82+ reconnectSignal.onReceive { signaled = true }
83+ onTimeout(backoffMs) { /* normal backoff elapsed */ }
84+ }
70− backoffMs = (backoffMs * 2).coerceAtMost(MAX_BACKOFF_MS)
85+ backoffMs = if (signaled) INITIAL_BACKOFF_MS else (backoffMs * 2).coerceAtMost(MAX_BACKOFF_MS)
71 }
86 }
72 }
87 }
73
88
app/src/main/java/soy/iko/opencode/data/repo/DraftStore.ktnew file+22
@@ -0,0 +1,22 @@
1+package soy.iko.opencode.data.repo
2+
3+import android.content.Context
4+
5+/**
6+ * Persists the in-progress message draft for each session so it survives
7+ * back-navigation and process death. Backed by SharedPreferences because the
8+ * UI needs the initial value synchronously and drafts are low-stakes data.
9+ */
10+class DraftStore(context: Context) {
11+
12+ private val prefs = context.applicationContext
13+ .getSharedPreferences("drafts", Context.MODE_PRIVATE)
14+
15+ fun get(sessionId: String): String = prefs.getString(sessionId, "").orEmpty()
16+
17+ fun set(sessionId: String, text: String) {
18+ prefs.edit().apply {
19+ if (text.isBlank()) remove(sessionId) else putString(sessionId, text)
20+ }.apply()
21+ }
22+}
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+27
@@ -1,7 +1,11 @@
1package soy.iko.opencode.di
1package soy.iko.opencode.di
2
2
3import android.content.Context
3import android.content.Context
4+import android.net.ConnectivityManager
5+import android.net.Network
6+import android.net.NetworkRequest
4import soy.iko.opencode.data.model.ServerProfile
7import soy.iko.opencode.data.model.ServerProfile
8+import soy.iko.opencode.data.repo.DraftStore
5import soy.iko.opencode.data.repo.ProfileStore
9import soy.iko.opencode.data.repo.ProfileStore
6import soy.iko.opencode.data.repo.SettingsStore
10import soy.iko.opencode.data.repo.SettingsStore
7import kotlinx.coroutines.CoroutineScope
11import kotlinx.coroutines.CoroutineScope
@@ -20,10 +24,33 @@ class AppContainer(context: Context) {
20 val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
24 val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
21 val profileStore = ProfileStore(context)
25 val profileStore = ProfileStore(context)
22 val settingsStore = SettingsStore(context)
26 val settingsStore = SettingsStore(context)
27+ val draftStore = DraftStore(context)
28+
29+ private val appContext = context.applicationContext
23
30
24 private val _activeConnection = MutableStateFlow<OpencodeConnection?>(null)
31 private val _activeConnection = MutableStateFlow<OpencodeConnection?>(null)
25 val activeConnection: StateFlow<OpencodeConnection?> = _activeConnection.asStateFlow()
32 val activeConnection: StateFlow<OpencodeConnection?> = _activeConnection.asStateFlow()
26
33
34+ init {
35+ registerNetworkMonitor()
36+ }
37+
38+ /**
39+ * When the device regains connectivity, nudge the active SSE stream to reconnect
40+ * immediately instead of waiting out the exponential backoff.
41+ */
42+ private fun registerNetworkMonitor() {
43+ val cm = appContext.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
44+ ?: return
45+ runCatching {
46+ cm.registerNetworkCallback(NetworkRequest.Builder().build(), object : ConnectivityManager.NetworkCallback() {
47+ override fun onAvailable(network: Network) {
48+ activeConnection.value?.events?.triggerReconnect()
49+ }
50+ })
51+ }
52+ }
53+
27 /** Open (or re-open) a connection to [profile], replacing any current one. */
54 /** Open (or re-open) a connection to [profile], replacing any current one. */
28 suspend fun connect(profile: ServerProfile): OpencodeConnection {
55 suspend fun connect(profile: ServerProfile): OpencodeConnection {
29 disconnect()
56 disconnect()
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+24−6
@@ -50,6 +50,12 @@ import androidx.compose.runtime.rememberCoroutineScope
50import androidx.compose.runtime.setValue
50import androidx.compose.runtime.setValue
51import androidx.compose.ui.Alignment
51import androidx.compose.ui.Alignment
52import androidx.compose.ui.Modifier
52import androidx.compose.ui.Modifier
53+import androidx.compose.ui.input.key.Key
54+import androidx.compose.ui.input.key.KeyEventType
55+import androidx.compose.ui.input.key.isShiftPressed
56+import androidx.compose.ui.input.key.key
57+import androidx.compose.ui.input.key.onPreviewKeyEvent
58+import androidx.compose.ui.input.key.type
53import androidx.compose.ui.platform.LocalHapticFeedback
59import androidx.compose.ui.platform.LocalHapticFeedback
54import androidx.compose.ui.unit.dp
60import androidx.compose.ui.unit.dp
55import androidx.lifecycle.compose.collectAsStateWithLifecycle
61import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -79,12 +85,12 @@ fun ChatScreen(
79 val commands by vm.commands.collectAsStateWithLifecycle()
85 val commands by vm.commands.collectAsStateWithLifecycle()
80 val sessionTitle by vm.sessionTitle.collectAsStateWithLifecycle()
86 val sessionTitle by vm.sessionTitle.collectAsStateWithLifecycle()
81 val failedDraft by vm.failedDraft.collectAsStateWithLifecycle()
87 val failedDraft by vm.failedDraft.collectAsStateWithLifecycle()
88+ val draft by vm.draft.collectAsStateWithLifecycle()
82 val haptics = LocalHapticFeedback.current
89 val haptics = LocalHapticFeedback.current
83 val scope = rememberCoroutineScope()
90 val scope = rememberCoroutineScope()
84
91
85 val listState = rememberLazyListState()
92 val listState = rememberLazyListState()
86 val snackbar = remember { SnackbarHostState() }
93 val snackbar = remember { SnackbarHostState() }
87− var input by remember { mutableStateOf("") }
88 var showModelPicker by remember { mutableStateOf(false) }
94 var showModelPicker by remember { mutableStateOf(false) }
89 var showAgentPicker by remember { mutableStateOf(false) }
95 var showAgentPicker by remember { mutableStateOf(false) }
90 var showCommandPicker by remember { mutableStateOf(false) }
96 var showCommandPicker by remember { mutableStateOf(false) }
@@ -117,8 +123,7 @@ fun ChatScreen(
117 }
123 }
118
124
119 fun doSend() {
125 fun doSend() {
120− if (vm.send(input)) {
126+ if (vm.send(draft)) {
121− input = ""
122 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
127 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
123 }
128 }
124 }
129 }
@@ -164,8 +169,8 @@ fun ChatScreen(
164 snackbarHost = { SnackbarHost(snackbar) },
169 snackbarHost = { SnackbarHost(snackbar) },
165 bottomBar = {
170 bottomBar = {
166 ChatInputBar(
171 ChatInputBar(
167− value = input,
172+ value = draft,
168− onValueChange = { input = it },
173+ onValueChange = vm::updateDraft,
169 running = running,
174 running = running,
170 enabled = vm.connected,
175 enabled = vm.connected,
171 onSend = ::doSend,
176 onSend = ::doSend,
@@ -323,7 +328,20 @@ private fun ChatInputBar(
323 OutlinedTextField(
328 OutlinedTextField(
324 value = value,
329 value = value,
325 onValueChange = onValueChange,
330 onValueChange = onValueChange,
326− modifier = Modifier.weight(1f),
331+ modifier = Modifier
332+ .weight(1f)
333+ .onPreviewKeyEvent { event ->
334+ if (event.type == KeyEventType.KeyDown &&
335+ event.key == Key.Enter &&
336+ !event.isShiftPressed &&
337+ enabled && value.isNotBlank()
338+ ) {
339+ onSend()
340+ true
341+ } else {
342+ false
343+ }
344+ },
327 placeholder = { Text("Message opencode…") },
345 placeholder = { Text("Message opencode…") },
328 enabled = enabled,
346 enabled = enabled,
329 maxLines = 6,
347 maxLines = 6,
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+11
@@ -76,12 +76,21 @@ class ChatViewModel(
76 private val _failedDraft = MutableStateFlow<String?>(null)
76 private val _failedDraft = MutableStateFlow<String?>(null)
77 val failedDraft: StateFlow<String?> = _failedDraft.asStateFlow()
77 val failedDraft: StateFlow<String?> = _failedDraft.asStateFlow()
78
78
79+ /** Per-session draft, persisted so it survives navigation/process death. */
80+ private val _draft = MutableStateFlow(container.draftStore.get(sessionId))
81+ val draft: StateFlow<String> = _draft.asStateFlow()
82+
79 fun clearError() { _error.value = null }
83 fun clearError() { _error.value = null }
80
84
81 fun selectModel(option: ModelOption) { _selectedModel.value = option }
85 fun selectModel(option: ModelOption) { _selectedModel.value = option }
82
86
83 fun selectAgent(name: String?) { _selectedAgent.value = name }
87 fun selectAgent(name: String?) { _selectedAgent.value = name }
84
88
89+ fun updateDraft(text: String) {
90+ _draft.value = text
91+ container.draftStore.set(sessionId, text)
92+ }
93+
85 init {
94 init {
86 // Watch the bus for run completion / errors / permission asks for this session.
95 // Watch the bus for run completion / errors / permission asks for this session.
87 val conn = connection
96 val conn = connection
@@ -137,6 +146,7 @@ class ChatViewModel(
137 _running.value = true
146 _running.value = true
138 _error.value = null
147 _error.value = null
139 _failedDraft.value = null
148 _failedDraft.value = null
149+ updateDraft("")
140 viewModelScope.launch {
150 viewModelScope.launch {
141 val ok = runCatching {
151 val ok = runCatching {
142 conn.repository.sendPrompt(
152 conn.repository.sendPrompt(
@@ -149,6 +159,7 @@ class ChatViewModel(
149 _running.value = false
159 _running.value = false
150 if (!ok) {
160 if (!ok) {
151 _failedDraft.value = trimmed
161 _failedDraft.value = trimmed
162+ updateDraft(trimmed)
152 _error.value = "Failed to send"
163 _error.value = "Failed to send"
153 }
164 }
154 }
165 }
app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+26−2
@@ -17,7 +17,9 @@ import androidx.compose.ui.draw.clip
17import androidx.compose.ui.unit.dp
17import androidx.compose.ui.unit.dp
18import soy.iko.opencode.data.model.AssistantMessage
18import soy.iko.opencode.data.model.AssistantMessage
19import soy.iko.opencode.data.model.MessageWithParts
19import soy.iko.opencode.data.model.MessageWithParts
20+import soy.iko.opencode.data.model.Tokens
20import soy.iko.opencode.data.model.UserMessage
21import soy.iko.opencode.data.model.UserMessage
22+import soy.iko.opencode.ui.components.relativeTime
21
23
22/** A single message: user prompts right-aligned in a bubble, assistant output full-width. */
24/** A single message: user prompts right-aligned in a bubble, assistant output full-width. */
23@Composable
25@Composable
@@ -40,6 +42,7 @@ private fun UserBubble(message: MessageWithParts, modifier: Modifier) {
40 verticalArrangement = Arrangement.spacedBy(6.dp),
42 verticalArrangement = Arrangement.spacedBy(6.dp),
41 ) {
43 ) {
42 for (part in message.parts) PartView(part)
44 for (part in message.parts) PartView(part)
45+ MessageTimestamp(message.info)
43 }
46 }
44 }
47 }
45}
48}
@@ -66,8 +69,8 @@ private fun AssistantBlock(message: MessageWithParts, isRunning: Boolean, modifi
66 val tokens = info.tokens
69 val tokens = info.tokens
67 if (info.isComplete && (cost != null || tokens != null)) {
70 if (info.isComplete && (cost != null || tokens != null)) {
68 val parts = buildList {
71 val parts = buildList {
69− tokens?.let { add("${it.input}→${it.output} tok") }
72+ tokens?.let { add(formatTokens(it)) }
70− cost?.takeIf { it > 0 }?.let { add("$%.4f".format(it)) }
73+ cost?.takeIf { it > 0 }?.let { add(formatCost(it)) }
71 }
74 }
72 if (parts.isNotEmpty()) {
75 if (parts.isNotEmpty()) {
73 Text(
76 Text(
@@ -78,5 +81,26 @@ private fun AssistantBlock(message: MessageWithParts, isRunning: Boolean, modifi
78 }
81 }
79 }
82 }
80 }
83 }
84+ MessageTimestamp(message.info)
81 }
85 }
82}
86}
87+
88+@Composable
89+private fun MessageTimestamp(info: soy.iko.opencode.data.model.MessageInfo) {
90+ val t = relativeTime(info.time?.created)
91+ if (t.isNotEmpty()) {
92+ Text(
93+ t,
94+ style = MaterialTheme.typography.labelSmall,
95+ color = MaterialTheme.colorScheme.onSurfaceVariant,
96+ )
97+ }
98+}
99+
100+private fun formatTokens(tokens: Tokens): String {
101+ val dec = java.text.DecimalFormat("#,###")
102+ return "${dec.format(tokens.input)} in · ${dec.format(tokens.output)} out"
103+}
104+
105+private fun formatCost(cost: Double): String =
106+ if (cost < 0.01) "$%.4f".format(cost) else "$%.2f".format(cost)
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+36−1
@@ -33,10 +33,13 @@ import androidx.compose.runtime.Composable
33import androidx.compose.runtime.getValue
33import androidx.compose.runtime.getValue
34import androidx.compose.ui.Alignment
34import androidx.compose.ui.Alignment
35import androidx.compose.ui.Modifier
35import androidx.compose.ui.Modifier
36+import androidx.compose.ui.graphics.Color
37+import androidx.compose.ui.text.font.FontFamily
36import androidx.compose.ui.text.style.TextOverflow
38import androidx.compose.ui.text.style.TextOverflow
37import androidx.compose.ui.unit.dp
39import androidx.compose.ui.unit.dp
38import androidx.lifecycle.compose.collectAsStateWithLifecycle
40import androidx.lifecycle.compose.collectAsStateWithLifecycle
39import androidx.lifecycle.viewmodel.compose.viewModel
41import androidx.lifecycle.viewmodel.compose.viewModel
42+import soy.iko.opencode.data.model.FileStatusEntry
40import soy.iko.opencode.di.AppContainer
43import soy.iko.opencode.di.AppContainer
41import soy.iko.opencode.ui.vmFactory
44import soy.iko.opencode.ui.vmFactory
42
45
@@ -164,6 +167,7 @@ private fun DirectoryListing(
164 icon = node.isDirectory,
167 icon = node.isDirectory,
165 label = node.name,
168 label = node.name,
166 onClick = { if (node.isDirectory) onOpenDir(node.path) else onOpenFile(node.path) },
169 onClick = { if (node.isDirectory) onOpenDir(node.path) else onOpenFile(node.path) },
170+ status = state.statusMap[node.path],
167 )
171 )
168 HorizontalDivider()
172 HorizontalDivider()
169 }
173 }
@@ -171,7 +175,12 @@ private fun DirectoryListing(
171}
175}
172
176
173@Composable
177@Composable
174−private fun FileRow(icon: Boolean, label: String, onClick: () -> Unit) {
178+private fun FileRow(
179+ icon: Boolean,
180+ label: String,
181+ onClick: () -> Unit,
182+ status: FileStatusEntry? = null,
183+) {
175 Row(
184 Row(
176 modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(horizontal = 16.dp, vertical = 14.dp),
185 modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(horizontal = 16.dp, vertical = 14.dp),
177 verticalAlignment = Alignment.CenterVertically,
186 verticalAlignment = Alignment.CenterVertically,
@@ -187,6 +196,32 @@ private fun FileRow(icon: Boolean, label: String, onClick: () -> Unit) {
187 style = MaterialTheme.typography.bodyLarge,
196 style = MaterialTheme.typography.bodyLarge,
188 maxLines = 1,
197 maxLines = 1,
189 overflow = TextOverflow.Ellipsis,
198 overflow = TextOverflow.Ellipsis,
199+ modifier = Modifier.weight(1f),
200+ )
201+ if (status != null) StatusBadge(status)
202+ }
203+}
204+
205+@Composable
206+private fun StatusBadge(status: FileStatusEntry) {
207+ val (letter, color) = when (status.status) {
208+ "added" -> "A" to Color(0xFF4CAF50)
209+ "modified" -> "M" to Color(0xFFFFA000)
210+ "deleted" -> "D" to MaterialTheme.colorScheme.error
211+ else -> "·" to MaterialTheme.colorScheme.onSurfaceVariant
212+ }
213+ Text(
214+ letter,
215+ style = MaterialTheme.typography.labelMedium,
216+ fontFamily = FontFamily.Monospace,
217+ color = color,
218+ )
219+ if (status.added > 0 || status.removed > 0) {
220+ Text(
221+ " +${status.added} −${status.removed}",
222+ style = MaterialTheme.typography.labelSmall,
223+ fontFamily = FontFamily.Monospace,
224+ color = MaterialTheme.colorScheme.onSurfaceVariant,
190 )
225 )
191 }
226 }
192}
227}
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserViewModel.kt+14−1
@@ -3,6 +3,7 @@ package soy.iko.opencode.ui.file
3import androidx.lifecycle.ViewModel
3import androidx.lifecycle.ViewModel
4import androidx.lifecycle.viewModelScope
4import androidx.lifecycle.viewModelScope
5import soy.iko.opencode.data.model.FileNode
5import soy.iko.opencode.data.model.FileNode
6+import soy.iko.opencode.data.model.FileStatusEntry
6import soy.iko.opencode.di.AppContainer
7import soy.iko.opencode.di.AppContainer
7import kotlinx.coroutines.Job
8import kotlinx.coroutines.Job
8import kotlinx.coroutines.delay
9import kotlinx.coroutines.delay
@@ -14,6 +15,7 @@ import kotlinx.coroutines.launch
14data class FileBrowserState(
15data class FileBrowserState(
15 val path: String = "",
16 val path: String = "",
16 val entries: List<FileNode> = emptyList(),
17 val entries: List<FileNode> = emptyList(),
18+ val statusMap: Map<String, FileStatusEntry> = emptyMap(),
17 val query: String = "",
19 val query: String = "",
18 val results: List<String> = emptyList(),
20 val results: List<String> = emptyList(),
19 val searching: Boolean = false,
21 val searching: Boolean = false,
@@ -32,7 +34,18 @@ class FileBrowserViewModel(private val container: AppContainer) : ViewModel() {
32
34
33 private var searchJob: Job? = null
35 private var searchJob: Job? = null
34
36
35− init { open("") }
37+ init { open(""); loadStatus() }
38+
39+ /** Fetch the repo-wide VCS status once so file rows can show git badges. */
40+ private fun loadStatus() {
41+ val client = api ?: return
42+ viewModelScope.launch {
43+ runCatching { client.fileStatus() }
44+ .onSuccess { entries ->
45+ _state.value = _state.value.copy(statusMap = entries.associateBy { it.path })
46+ }
47+ }
48+ }
36
49
37 fun open(path: String) {
50 fun open(path: String) {
38 val client = api ?: run {
51 val client = api ?: run {
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+26−9
@@ -6,12 +6,17 @@ import soy.iko.opencode.data.model.ServerProfile
6import soy.iko.opencode.data.model.Session
6import soy.iko.opencode.data.model.Session
7import soy.iko.opencode.data.model.TextPart
7import soy.iko.opencode.data.model.TextPart
8import soy.iko.opencode.di.AppContainer
8import soy.iko.opencode.di.AppContainer
9+import kotlinx.coroutines.Job
9import kotlinx.coroutines.flow.MutableStateFlow
10import kotlinx.coroutines.flow.MutableStateFlow
10import kotlinx.coroutines.flow.SharingStarted
11import kotlinx.coroutines.flow.SharingStarted
11import kotlinx.coroutines.flow.StateFlow
12import kotlinx.coroutines.flow.StateFlow
12import kotlinx.coroutines.flow.asStateFlow
13import kotlinx.coroutines.flow.asStateFlow
13import kotlinx.coroutines.flow.stateIn
14import kotlinx.coroutines.flow.stateIn
15+import kotlinx.coroutines.flow.update
16+import kotlinx.coroutines.joinAll
14import kotlinx.coroutines.launch
17import kotlinx.coroutines.launch
18+import kotlinx.coroutines.sync.Semaphore
19+import kotlinx.coroutines.sync.withPermit
15
20
16data class SessionListState(
21data class SessionListState(
17 val sessions: List<Session> = emptyList(),
22 val sessions: List<Session> = emptyList(),
@@ -52,6 +57,8 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
52 private val _state = MutableStateFlow(SessionListState())
57 private val _state = MutableStateFlow(SessionListState())
53 val state: StateFlow<SessionListState> = _state.asStateFlow()
58 val state: StateFlow<SessionListState> = _state.asStateFlow()
54
59
60+ private var previewJob: Job? = null
61+
55 private val activeProfileId: String?
62 private val activeProfileId: String?
56 get() = container.activeConnection.value?.profile?.id
63 get() = container.activeConnection.value?.profile?.id
57
64
@@ -82,21 +89,31 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
82
89
83 /** Fetch the last text part of each session for list previews (best-effort, bounded). */
90 /** Fetch the last text part of each session for list previews (best-effort, bounded). */
84 private fun loadPreviews(sessions: List<Session>) {
91 private fun loadPreviews(sessions: List<Session>) {
92+ previewJob?.cancel()
85 val conn = container.activeConnection.value ?: return
93 val conn = container.activeConnection.value ?: return
86 val api = conn.api
94 val api = conn.api
95+ _state.update { it.copy(previews = emptyMap()) }
96+ previewJob = viewModelScope.launch {
97+ val semaphore = Semaphore(MAX_CONCURRENT_PREVIEWS)
87− val toLoad = sessions.take(50)
98+ sessions.take(50).map { session ->
88− toLoad.forEach { session ->
99+ launch {
89− viewModelScope.launch {
100+ semaphore.withPermit {
90− val preview = runCatching {
101+ val preview = runCatching {
91− api.listMessages(session.id).lastOrNull()?.let { msg ->
102+ api.listMessages(session.id).lastOrNull()?.let { msg ->
92− msg.parts.filterIsInstance<TextPart>().lastOrNull()?.text
103+ msg.parts.filterIsInstance<TextPart>().lastOrNull()?.text
104+ }
105+ }.getOrNull()?.takeIf { it.isNotBlank() } ?: return@withPermit
106+ _state.update { s -> s.copy(previews = s.previews + (session.id to preview)) }
93 }
107 }
94− }.getOrNull()?.takeIf { p -> p.isNotBlank() } ?: return@launch
108+ }
95− _state.value = _state.value.copy(previews = _state.value.previews + (session.id to preview))
109+ }.joinAll()
96− }
97 }
110 }
98 }
111 }
99
112
113+ private companion object {
114+ const val MAX_CONCURRENT_PREVIEWS = 8
115+ }
116+
100 fun createSession(onCreated: (String) -> Unit) {
117 fun createSession(onCreated: (String) -> Unit) {
101 val conn = container.activeConnection.value ?: return
118 val conn = container.activeConnection.value ?: return
102 viewModelScope.launch {
119 viewModelScope.launch {