git.iko.soy/opencode-android
on master help sign in
Add confirmation guards, undo, refresh, and accessibility fixes across chat, session, server, file, and settings screens to prevent data loss and dead-end navigation
commitd29e57b4be390af234535d9be700b55f864b93d0 authoriko <mail@iko.soy> dateWed 1 Jul 2026, 09:25 (3 months ago) parent27b5505
18 files changed+965−306
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+9 app/src/main/java/soy/iko/opencode/di/AppContainer.kt+40−5 app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+11−1 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+113−44 app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+40−1 app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+2 app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+63−34 app/src/main/java/soy/iko/opencode/ui/file/FileBrowserViewModel.kt+17 app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+212−92 app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+94−31 app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+23−3 app/src/main/java/soy/iko/opencode/ui/server/ServerListViewModel.kt+8−6 app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+144−54 app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+68−16 app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+50−16 app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+36 app/src/main/res/values/strings.xml+25 app/src/test/java/soy/iko/opencode/ui/testing/TestFakes.kt+10−3
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+9
@@ -62,6 +62,10 @@ object NetworkConfig {
6262 * triggers a full list filter + sort + recomposition, causing scroll jank. */
6363 const val sessionUpdateDebounceMs = 300L
6464
65+ /** Grace period before a deferred session delete actually fires, during which an
66+ * Undo snackbar lets the user cancel it. */
67+ const val undoDeleteDelayMs = 5_000L
68+
6569 // --- In-memory message store (SessionRepository.MessageStore) ---
6670
6771 /** Max messages to keep in memory per observed session; oldest are evicted beyond this. */
@@ -82,6 +86,11 @@ object NetworkConfig {
8286 /** Debounce delay before persisting a draft to disk after the user stops typing. */
8387 const val draftDebounceMs = 500L
8488
89+ /** Maximum characters allowed in the chat input field. A generous cap that prevents
90+ * a huge paste from stalling the UI (the field would otherwise buffer and lay out
91+ * an unbounded string) while leaving plenty of room for long prompts. */
92+ const val maxDraftLengthChars = 32_000
93+
8594 // --- Profile store (ServerEditViewModel) ---
8695
8796 /** Timeout for loading a profile from DataStore before giving up. */
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+40−5
@@ -126,6 +126,11 @@ open class AppContainer private constructor(
126126 private val _unread = MutableStateFlow<Set<String>>(emptySet())
127127 open val unread: StateFlow<Set<String>> = _unread.asStateFlow()
128128
129+ /** True when any assistant run is actively streaming across all sessions. Drives the
130+ * disconnect confirmation on the session list (disconnecting mid-run kills it). */
131+ private val _anyRunActive = MutableStateFlow(false)
132+ open val anyRunActive: StateFlow<Boolean> = _anyRunActive.asStateFlow()
133+
129134 open fun setCurrentSession(id: String?) {
130135 _currentSession.value = id
131136 if (id != null) {
@@ -233,6 +238,7 @@ open class AppContainer private constructor(
233238 if (event is SessionIdle) {
234239 val idleSid = event.properties.sessionID ?: return@collect
235240 if (idleSid != _currentSession.value && activeRuns.remove(idleSid)) {
241+ _anyRunActive.value = activeRuns.isNotEmpty()
236242 notifySessionCompleted(idleSid)
237243 }
238244 return@collect
@@ -245,14 +251,12 @@ open class AppContainer private constructor(
245251 // represent a finished run worth notifying about.
246252 if (event is MessagePartUpdated || event is MessageUpdated) {
247253 synchronized(activeRuns) {
248− // Evict the oldest entry when the cap is reached instead of
249− // clearing everything, so completion notifications for the
250− // remaining active runs still fire on SessionIdle.
251254 if (activeRuns.size >= activeRunsLimit) {
252255 activeRuns.remove(activeRuns.iterator().next())
253256 }
254257 activeRuns.add(sid)
255258 }
259+ _anyRunActive.value = true
256260 }
257261 } }.onFailure { Log.w("AppContainer", "Message activity observer failed, will retry: ${safeExceptionSummary(it)}") }
258262 if (!isActive) break
@@ -333,6 +337,7 @@ open class AppContainer private constructor(
333337 _activeConnection.value?.close()
334338 _activeConnection.value = null
335339 activeRuns.clear()
340+ _anyRunActive.value = false
336341 _unread.value = emptySet()
337342 val now = System.currentTimeMillis()
338343 val resolved = profileStore.resolve(profile)
@@ -367,14 +372,44 @@ open class AppContainer private constructor(
367372 * touch the active connection or the profile store.
368373 */
369374 open suspend fun probeServer(baseUrl: String): ProbeResult {
370− val probeProfile = ServerProfile(
375+ return probeWithProfile(ServerProfile(
371376 id = "probe",
372377 label = "",
373378 baseUrl = baseUrl.trim(),
374379 username = null,
375380 password = null,
381+ ))
382+ }
383+
384+ /**
385+ * Probe a server URL *with* credentials to validate them. Returns true if the
386+ * server accepts the credentials (ping succeeds), false on 401/403, and throws
387+ * on other failures (unreachable, timeout) so the caller can surface a friendly
388+ * message. Mirrors [probeServer]'s short-lived-client pattern.
389+ */
390+ open suspend fun probeWithCredentials(baseUrl: String, username: String, password: String): Boolean {
391+ val probeProfile = ServerProfile(
392+ id = "probe-auth",
393+ label = "",
394+ baseUrl = baseUrl.trim(),
395+ username = username.trim().takeIf { it.isNotBlank() },
396+ password = password.trim().takeIf { it.isNotEmpty() },
376397 )
377398 val client = HttpClientFactory.create(probeProfile)
399+ return try {
400+ val api = OpencodeApiClient(client)
401+ api.ping()
402+ true
403+ } catch (e: Exception) {
404+ val status = responseStatusCode(e)
405+ if (status == 401 || status == 403) false else throw e
406+ } finally {
407+ runCatching { client.close() }
408+ }
409+ }
410+
411+ private suspend fun probeWithProfile(profile: ServerProfile): ProbeResult {
412+ val client = HttpClientFactory.create(profile)
378413 return try {
379414 val api = OpencodeApiClient(client)
380415 api.ping()
@@ -384,7 +419,7 @@ open class AppContainer private constructor(
384419 if (status == 401 || status == 403) {
385420 ProbeResult.NeedsAuth
386421 } else {
387− ProbeResult.Unreachable(friendlyErrorFor(e, baseUrl))
422+ ProbeResult.Unreachable(friendlyErrorFor(e, profile.baseUrl))
388423 }
389424 } finally {
390425 runCatching { client.close() }
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+11−1
@@ -164,8 +164,18 @@ fun OpencodeApp(container: AppContainer) {
164164 SettingsScreen(
165165 container = container,
166166 onBack = { navController.popBackStack() },
167+ // Navigate to the server list as a new entry on top of Sessions so the
168+ // user can back out to their conversations. Previously this popped back
169+ // to the root SERVERS destination, which removed Sessions from the stack
170+ // and left the user unable to return to their conversations without
171+ // reconnecting. Pop only Settings, then push SERVERS; back from the
172+ // server list lands on Sessions (the screen the user was on before
173+ // Settings), and connecting pushes a fresh SESSIONS as usual.
167174 onManageServers = {
168− navController.popBackStack(Routes.SERVERS, inclusive = false)
175+ navController.navigate(Routes.SERVERS) {
176+ popUpTo(Routes.SETTINGS) { inclusive = true }
177+ launchSingleTop = true
178+ }
169179 },
170180 onOpenDiagnostics = { navController.navigate(Routes.DIAGNOSTICS) },
171181 )
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+113−44
@@ -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.Refresh
2930import androidx.compose.material.icons.filled.Share
3031import androidx.compose.material.icons.filled.SmartToy
3132import androidx.compose.material.icons.filled.Stop
@@ -87,6 +88,7 @@ import kotlinx.coroutines.launch
8788import kotlinx.coroutines.withContext
8889import soy.iko.opencode.data.model.TextPart
8990import soy.iko.opencode.data.network.EventStreamClient
91+import soy.iko.opencode.data.network.NetworkConfig
9092import soy.iko.opencode.di.AppContainer
9193import soy.iko.opencode.R
9294import soy.iko.opencode.ui.components.ConnectionBanner
@@ -154,9 +156,15 @@ fun ChatScreen(
154156 }
155157
156158 // Navigate away when the session is deleted via SSE so the user isn't left
157− // on a zombie screen showing a conversation that no longer exists.
159+ // on a zombie screen showing a conversation that no longer exists. Surface a
160+ // toast (not a snackbar) because the screen is about to be popped — a snackbar
161+ // would be torn down before the user could read it, while a toast survives.
162+ val deletedContext = LocalContext.current
158163 LaunchedEffect(sessionDeleted) {
159− if (sessionDeleted) onBack()
164+ if (sessionDeleted) {
165+ showToast(deletedContext, deletedContext.getString(R.string.session_deleted_msg))
166+ onBack()
167+ }
160168 }
161169
162170 val snackbar = remember { SnackbarHostState() }
@@ -165,6 +173,7 @@ fun ChatScreen(
165173 var showAgentPicker by rememberSaveable { mutableStateOf(false) }
166174 var showCommandPicker by rememberSaveable { mutableStateOf(false) }
167175 var showExitConfirm by rememberSaveable { mutableStateOf(false) }
176+ var showStopConfirm by rememberSaveable { mutableStateOf(false) }
168177
169178 // Keep the screen awake and hold a foreground priority while the agent is working,
170179 // so backgrounding mid-run doesn't let Doze choke the SSE stream.
@@ -249,6 +258,12 @@ fun ChatScreen(
249258 strokeWidth = 2.dp,
250259 )
251260 }
261+ // Manual refresh: forces an SSE reconnect, which re-seeds messages
262+ // from REST. A recovery path when the stream silently drops and the
263+ // auto-reconnect re-seed is slow or fails.
264+ IconButton(onClick = { vm.refreshMessages() }, enabled = activeConnection != null) {
265+ Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh))
266+ }
252267 IconButton(
253268 onClick = {
254269 scope.launch {
@@ -282,13 +297,17 @@ fun ChatScreen(
282297 },
283298 snackbarHost = { SnackbarHost(snackbar) },
284299 bottomBar = {
300+ val queuedFollowUp by vm.queuedFollowUp.collectAsStateWithLifecycle()
285301 ChatInputBar(
286302 value = draft,
287303 onValueChange = vm::updateDraft,
288304 running = running,
289305 enabled = activeConnection != null,
290306 onSend = ::doSend,
291− onAbort = { vm.abort() },
307+ onAbort = { showStopConfirm = true },
308+ queuedFollowUp = queuedFollowUp,
309+ onQueueFollowUp = vm::queueFollowUp,
310+ onCancelQueue = { vm.queueFollowUp("") },
292311 )
293312 },
294313 ) { padding ->
@@ -328,7 +347,9 @@ fun ChatScreen(
328347 }
329348
330349 CompositionLocalProvider(LocalRelativeTimeTick provides timeTick) {
331− Box(modifier = Modifier.fillMaxSize().padding(padding)) {
350+ // imePadding so the jump-to-latest FAB (aligned BottomEnd) is pushed above the
351+ // IME instead of being hidden behind it when the keyboard is open.
352+ Box(modifier = Modifier.fillMaxSize().padding(padding).imePadding()) {
332353 if (activeConnection == null) {
333354 Column(
334355 modifier = Modifier.align(Alignment.Center).padding(24.dp),
@@ -449,6 +470,23 @@ fun ChatScreen(
449470 )
450471 }
451472
473+ if (showStopConfirm) {
474+ AlertDialog(
475+ onDismissRequest = { showStopConfirm = false },
476+ title = { Text(stringResource(R.string.stop_run_title)) },
477+ text = { Text(stringResource(R.string.stop_run_text)) },
478+ confirmButton = {
479+ TextButton(onClick = {
480+ showStopConfirm = false
481+ vm.abort()
482+ }) { Text(stringResource(R.string.stop), color = MaterialTheme.colorScheme.error) }
483+ },
484+ dismissButton = {
485+ TextButton(onClick = { showStopConfirm = false }) { Text(stringResource(R.string.cancel)) }
486+ },
487+ )
488+ }
489+
452490 if (showModelPicker) {
453491 ModelPickerSheet(
454492 options = models,
@@ -500,49 +538,80 @@ private fun ChatInputBar(
500538 enabled: Boolean,
501539 onSend: () -> Unit,
502540 onAbort: () -> Unit,
541+ queuedFollowUp: String?,
542+ onQueueFollowUp: (String) -> Unit,
543+ onCancelQueue: () -> Unit,
503544) {
504545 Surface(tonalElevation = 3.dp, modifier = Modifier.imePadding()) {
505− Row(
506− modifier = Modifier.fillMaxWidth()
507− .windowInsetsPadding(WindowInsets.navigationBars)
508− .padding(8.dp),
509− verticalAlignment = Alignment.Bottom,
510− ) {
511− OutlinedTextField(
512− value = value,
513− onValueChange = onValueChange,
514− modifier = Modifier
515− .weight(1f)
516− .testTag("chat_input")
517− .onPreviewKeyEvent { event ->
518− if (event.type == KeyEventType.KeyDown &&
519− event.key == Key.Enter &&
520− !event.isShiftPressed &&
521− enabled && value.isNotBlank()
522− ) {
523− onSend()
524− true
525− } else {
526− false
527− }
528− },
529− placeholder = { Text(stringResource(R.string.message_placeholder)) },
530− enabled = enabled,
531− maxLines = 6,
532− keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
533− keyboardActions = KeyboardActions(onSend = { if (enabled && value.isNotBlank()) onSend() }),
534− )
535− if (running) {
536− IconButton(onClick = onAbort, modifier = Modifier.padding(start = 4.dp).testTag("stop_button")) {
537− Icon(Icons.Filled.Stop, contentDescription = stringResource(R.string.stop))
538− }
539− } else {
540− IconButton(
541− onClick = onSend,
542− enabled = enabled && value.isNotBlank(),
543− modifier = Modifier.padding(start = 4.dp).testTag("send_button"),
546+ Column(modifier = Modifier.fillMaxWidth().windowInsetsPadding(WindowInsets.navigationBars)) {
547+ // A queued follow-up replaces the Stop button with a "queued" chip so the
548+ // user sees their message will be sent when the run finishes, and can cancel.
549+ if (queuedFollowUp != null) {
550+ Row(
551+ modifier = Modifier
552+ .fillMaxWidth()
553+ .padding(start = 12.dp, top = 6.dp, end = 4.dp),
554+ verticalAlignment = Alignment.CenterVertically,
544555 ) {
545− Icon(Icons.AutoMirrored.Filled.Send, contentDescription = stringResource(R.string.send))
556+ Text(
557+ stringResource(R.string.queued),
558+ style = MaterialTheme.typography.labelSmall,
559+ color = MaterialTheme.colorScheme.onSurfaceVariant,
560+ modifier = Modifier.weight(1f),
561+ )
562+ TextButton(onClick = onCancelQueue, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp)) {
563+ Text(stringResource(R.string.cancel))
564+ }
565+ }
566+ }
567+ Row(
568+ modifier = Modifier.fillMaxWidth().padding(8.dp),
569+ verticalAlignment = Alignment.Bottom,
570+ ) {
571+ OutlinedTextField(
572+ value = value,
573+ onValueChange = { v ->
574+ // Cap input length so a huge paste can't stall the UI. Shares are
575+ // capped separately in MainActivity; this guards typed/pasted drafts.
576+ onValueChange(v.take(NetworkConfig.maxDraftLengthChars))
577+ },
578+ modifier = Modifier
579+ .weight(1f)
580+ .testTag("chat_input")
581+ .onPreviewKeyEvent { event ->
582+ if (event.type == KeyEventType.KeyDown &&
583+ event.key == Key.Enter &&
584+ !event.isShiftPressed &&
585+ enabled && value.isNotBlank()
586+ ) {
587+ if (running) onQueueFollowUp(value) else onSend()
588+ true
589+ } else {
590+ false
591+ }
592+ },
593+ placeholder = { Text(stringResource(R.string.message_placeholder)) },
594+ enabled = enabled,
595+ maxLines = 6,
596+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
597+ keyboardActions = KeyboardActions(onSend = {
598+ if (enabled && value.isNotBlank()) {
599+ if (running) onQueueFollowUp(value) else onSend()
600+ }
601+ }),
602+ )
603+ if (running) {
604+ IconButton(onClick = onAbort, modifier = Modifier.padding(start = 4.dp).testTag("stop_button")) {
605+ Icon(Icons.Filled.Stop, contentDescription = stringResource(R.string.stop))
606+ }
607+ } else {
608+ IconButton(
609+ onClick = onSend,
610+ enabled = enabled && value.isNotBlank(),
611+ modifier = Modifier.padding(start = 4.dp).testTag("send_button"),
612+ ) {
613+ Icon(Icons.AutoMirrored.Filled.Send, contentDescription = stringResource(R.string.send))
614+ }
546615 }
547616 }
548617 }
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+40−1
@@ -114,6 +114,12 @@ class ChatViewModel(
114114 private val _running = MutableStateFlow(false)
115115 val running: StateFlow<Boolean> = _running.asStateFlow()
116116
117+ /** A follow-up the user typed while a run was active. send() queues it here and
118+ * auto-sends once the run completes (SessionIdle), so the user isn't blocked with
119+ * no way to send and no indication why the Send button is gone. */
120+ private val _queuedFollowUp = MutableStateFlow<String?>(null)
121+ val queuedFollowUp: StateFlow<String?> = _queuedFollowUp.asStateFlow()
122+
117123 /** One-shot error events surfaced as snackbars. A SharedFlow (not StateFlow) so each
118124 * emission is delivered independently — two rapid failures both get a snackbar
119125 * instead of the second silently overwriting the first. */
@@ -285,13 +291,24 @@ class ChatViewModel(
285291 _running.value = false
286292 _pendingPermission.value = null
287293 _failedDraft.value = null
294+ _queuedFollowUp.value = null
288295 _selectedAgent.value = null
289296 _sessionTitle.value = null
290297 try {
291298 conn.events.events.collect { event ->
292− if (SessionRepository.isIdle(event, sessionId)) _running.value = false
299+ if (SessionRepository.isIdle(event, sessionId)) {
300+ _running.value = false
301+ // Auto-send a queued follow-up once the previous run finishes,
302+ // so the user's drafted-while-running message isn't lost.
303+ val queued = _queuedFollowUp.value
304+ if (queued != null) {
305+ _queuedFollowUp.value = null
306+ send(queued)
307+ }
308+ }
293309 if (SessionRepository.isError(event, sessionId)) {
294310 _running.value = false
311+ _queuedFollowUp.value = null
295312 _errorEvents.tryEmit(container.string(R.string.error_agent_reported))
296313 }
297314 when (event) {
@@ -435,6 +452,28 @@ class ChatViewModel(
435452 }
436453 }
437454
455+ /**
456+ * Queue [text] to be sent automatically when the current run finishes, or clear any
457+ * queued follow-up when [text] is blank. Used when the user taps Send while a run
458+ * is already active — the Send button is replaced by Stop during a run, but the
459+ * input field stays enabled, so a follow-up typed mid-run would otherwise have
460+ * nowhere to go.
461+ */
462+ fun queueFollowUp(text: String) {
463+ val trimmed = text.trim()
464+ _queuedFollowUp.value = trimmed.takeIf { it.isNotEmpty() }
465+ }
466+
467+ /** Manually re-fetch messages by forcing the SSE stream to reconnect, which
468+ * triggers the repository's re-seed-from-REST logic. Gives the user a recovery
469+ * path when the SSE stream silently drops and the auto-reconnect re-seed is slow
470+ * or fails — a pull-to-refresh or top-bar refresh forces a fresh fetch under the
471+ * existing generation-guarded merge. */
472+ fun refreshMessages() {
473+ val conn = connection ?: return
474+ conn.events.triggerReconnect()
475+ }
476+
438477 /** Invoke a slash-command by name via the server's /command endpoint. */
439478 fun runCommand(command: Command) {
440479 val conn = connection ?: return
app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+2
@@ -142,6 +142,7 @@ private fun ReasoningBlock(text: String, streaming: Boolean, modifier: Modifier)
142142 TextButton(
143143 onClick = { copyToClipboard(context, "reasoning", text) },
144144 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
145+ modifier = Modifier.semantics(mergeDescendants = true) {},
145146 ) {
146147 Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(14.dp))
147148 Text(stringResource(R.string.copy), style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(start = 4.dp))
@@ -217,6 +218,7 @@ private fun ToolCallView(part: ToolPart, modifier: Modifier) {
217218 TextButton(
218219 onClick = { copyToClipboard(context, "output", detail) },
219220 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
221+ modifier = Modifier.semantics(mergeDescendants = true) {},
220222 ) {
221223 Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(14.dp))
222224 Text(stringResource(R.string.copy), style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(start = 4.dp))
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+63−34
@@ -5,6 +5,7 @@ import androidx.compose.foundation.horizontalScroll
55import androidx.compose.foundation.layout.Box
66import androidx.compose.foundation.layout.Column
77import androidx.compose.foundation.layout.Row
8+import androidx.compose.foundation.layout.defaultMinSize
89import androidx.compose.foundation.layout.fillMaxSize
910import androidx.compose.foundation.layout.fillMaxWidth
1011import androidx.compose.foundation.layout.padding
@@ -65,6 +66,7 @@ fun FileBrowserScreen(
6566) {
6667 val vm: FileBrowserViewModel = viewModel(factory = vmFactory { FileBrowserViewModel(container) })
6768 val state by vm.state.collectAsStateWithLifecycle()
69+ val refreshing by vm.refreshing.collectAsStateWithLifecycle()
6870 val snackbar = remember { SnackbarHostState() }
6971
7072 LaunchedEffect(Unit) {
@@ -104,34 +106,40 @@ fun FileBrowserScreen(
104106 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
105107 )
106108
107− Box(modifier = Modifier.fillMaxSize()) {
108− when {
109− state.loading || state.searching -> {
110− val loadingLabel = stringResource(R.string.loading)
111− CircularProgressIndicator(
112− Modifier.align(Alignment.Center).semantics { contentDescription = loadingLabel },
113− )
114− }
115− state.error != null -> Column(
116− modifier = Modifier.align(Alignment.Center).padding(24.dp),
117− horizontalAlignment = Alignment.CenterHorizontally,
118− ) {
119− Text(
120− state.error ?: "",
121− color = MaterialTheme.colorScheme.error,
122− )
123− androidx.compose.foundation.layout.Spacer(Modifier.size(12.dp))
124− androidx.compose.material3.TextButton(onClick = { vm.open(state.path) }) {
125− Text(stringResource(R.string.retry))
109+ androidx.compose.material3.pulltorefresh.PullToRefreshBox(
110+ isRefreshing = refreshing,
111+ onRefresh = { vm.refresh() },
112+ modifier = Modifier.fillMaxSize(),
113+ ) {
114+ Box(modifier = Modifier.fillMaxSize()) {
115+ when {
116+ state.loading || state.searching -> {
117+ val loadingLabel = stringResource(R.string.loading)
118+ CircularProgressIndicator(
119+ Modifier.align(Alignment.Center).semantics { contentDescription = loadingLabel },
120+ )
121+ }
122+ state.error != null -> Column(
123+ modifier = Modifier.align(Alignment.Center).padding(24.dp),
124+ horizontalAlignment = Alignment.CenterHorizontally,
125+ ) {
126+ Text(
127+ state.error ?: "",
128+ color = MaterialTheme.colorScheme.error,
129+ )
130+ androidx.compose.foundation.layout.Spacer(Modifier.size(12.dp))
131+ androidx.compose.material3.TextButton(onClick = { vm.open(state.path) }) {
132+ Text(stringResource(R.string.retry))
133+ }
126134 }
135+ state.isSearching -> SearchResults(state.results, onOpenFile)
136+ else -> DirectoryListing(
137+ state = state,
138+ onOpenDir = vm::open,
139+ onUp = vm::up,
140+ onOpenFile = onOpenFile,
141+ )
127142 }
128− state.isSearching -> SearchResults(state.results, onOpenFile)
129− else -> DirectoryListing(
130− state = state,
131− onOpenDir = vm::open,
132− onUp = vm::up,
133− onOpenFile = onOpenFile,
134− )
135143 }
136144 }
137145 }
@@ -180,7 +188,16 @@ private fun SearchResults(results: List<String>, onOpenFile: (String) -> Unit) {
180188 }
181189 LazyColumn(modifier = Modifier.fillMaxSize()) {
182190 items(results, key = { it }) { path ->
183− FileRow(icon = false, label = path, onClick = { onOpenFile(path) })
191+ // Split the path into directory (muted) + filename (emphasized) so results
192+ // are scannable instead of a wall of identical-looking full paths.
193+ val dir = path.substringBeforeLast('/', missingDelimiterValue = "").trimEnd('/')
194+ val name = path.substringAfterLast('/')
195+ FileRow(
196+ icon = false,
197+ label = name,
198+ sublabel = dir.takeIf { it.isNotEmpty() },
199+ onClick = { onOpenFile(path) },
200+ )
184201 HorizontalDivider()
185202 }
186203 }
@@ -226,6 +243,7 @@ private fun FileRow(
226243 label: String,
227244 onClick: () -> Unit,
228245 status: FileStatusEntry? = null,
246+ sublabel: String? = null,
229247) {
230248 val desc = if (label == "..") stringResource(R.string.parent_dir)
231249 else if (icon) stringResource(R.string.folder, label)
@@ -233,6 +251,7 @@ private fun FileRow(
233251 Row(
234252 modifier = Modifier
235253 .fillMaxWidth()
254+ .defaultMinSize(minHeight = 48.dp)
236255 .clickable(role = Role.Button, onClick = onClick)
237256 .semantics(mergeDescendants = true) { contentDescription = desc }
238257 .padding(horizontal = 16.dp, vertical = 14.dp),
@@ -244,13 +263,23 @@ private fun FileRow(
244263 modifier = Modifier.size(20.dp),
245264 tint = if (icon) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
246265 )
247− Text(
248− label,
249− style = MaterialTheme.typography.bodyLarge,
250− maxLines = 1,
251− overflow = TextOverflow.Ellipsis,
252− modifier = Modifier.weight(1f).padding(start = 8.dp),
253− )
266+ Column(modifier = Modifier.weight(1f).padding(start = 8.dp)) {
267+ Text(
268+ label,
269+ style = MaterialTheme.typography.bodyLarge,
270+ maxLines = 1,
271+ overflow = TextOverflow.Ellipsis,
272+ )
273+ if (sublabel != null) {
274+ Text(
275+ sublabel,
276+ style = MaterialTheme.typography.labelSmall,
277+ color = MaterialTheme.colorScheme.onSurfaceVariant,
278+ maxLines = 1,
279+ overflow = TextOverflow.Ellipsis,
280+ )
281+ }
282+ }
254283 if (status != null) StatusBadge(status)
255284 }
256285}
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserViewModel.kt+17
@@ -45,6 +45,10 @@ class FileBrowserViewModel(private val container: AppContainer) : ViewModel() {
4545 private val _state = MutableStateFlow(FileBrowserState())
4646 val state: StateFlow<FileBrowserState> = _state.asStateFlow()
4747
48+ /** Tracks an in-flight manual refresh so pull-to-refresh can show its indicator. */
49+ private val _refreshing = MutableStateFlow(false)
50+ val refreshing: StateFlow<Boolean> = _refreshing.asStateFlow()
51+
4852 /** One-shot transient errors (failed VCS status fetch, etc.) surfaced as snackbars without
4953 * hiding the file list. A SharedFlow (not StateFlow) so each emission is delivered independently. */
5054 private val _transientErrors = MutableSharedFlow<String>(
@@ -94,6 +98,19 @@ class FileBrowserViewModel(private val container: AppContainer) : ViewModel() {
9498 }
9599 }
96100
101+ /** Re-open the current directory (manual refresh). Used by pull-to-refresh. */
102+ fun refresh() {
103+ _refreshing.value = true
104+ open(_state.value.path)
105+ viewModelScope.launch {
106+ // Clear the spinner once the open() launch settles. open() sets loading=true
107+ // synchronously and flips it false in its own coroutine; wait one frame cycle.
108+ // Simpler: clear after the openJob completes by joining it.
109+ openJob?.join()
110+ _refreshing.value = false
111+ }
112+ }
113+
97114 fun open(path: String) {
98115 val client = api ?: run {
99116 _state.update { it.copy(
app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+212−92
@@ -1,9 +1,11 @@
11package soy.iko.opencode.ui.server
22
3+import androidx.activity.compose.BackHandler
34import androidx.compose.animation.AnimatedVisibility
45import androidx.compose.foundation.layout.Arrangement
56import androidx.compose.foundation.layout.Box
67import androidx.compose.foundation.layout.Column
8+import androidx.compose.foundation.layout.Row
79import androidx.compose.foundation.layout.fillMaxSize
810import androidx.compose.foundation.layout.fillMaxWidth
911import androidx.compose.foundation.layout.imePadding
@@ -16,6 +18,9 @@ import androidx.compose.foundation.text.KeyboardOptions
1618import androidx.compose.foundation.verticalScroll
1719import androidx.compose.material.icons.Icons
1820import androidx.compose.material.icons.automirrored.filled.ArrowBack
21+import androidx.compose.material.icons.filled.Visibility
22+import androidx.compose.material.icons.filled.VisibilityOff
23+import androidx.compose.material3.AlertDialog
1924import androidx.compose.material3.Button
2025import androidx.compose.material3.CircularProgressIndicator
2126import androidx.compose.material3.ExperimentalMaterial3Api
@@ -26,9 +31,14 @@ import androidx.compose.material3.OutlinedButton
2631import androidx.compose.material3.OutlinedTextField
2732import androidx.compose.material3.Scaffold
2833import androidx.compose.material3.Text
34+import androidx.compose.material3.TextButton
2935import androidx.compose.material3.TopAppBar
3036import androidx.compose.runtime.Composable
3137import androidx.compose.runtime.getValue
38+import androidx.compose.runtime.mutableStateOf
39+import androidx.compose.runtime.remember
40+import androidx.compose.runtime.saveable.rememberSaveable
41+import androidx.compose.runtime.setValue
3242import androidx.compose.ui.Alignment
3343import androidx.compose.ui.Modifier
3444import androidx.compose.ui.platform.testTag
@@ -37,6 +47,7 @@ import androidx.compose.ui.semantics.semantics
3747import androidx.compose.ui.text.input.ImeAction
3848import androidx.compose.ui.text.input.KeyboardType
3949import androidx.compose.ui.text.input.PasswordVisualTransformation
50+import androidx.compose.ui.text.input.VisualTransformation
4051import androidx.compose.ui.res.stringResource
4152import androidx.compose.ui.unit.dp
4253import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -55,13 +66,29 @@ fun ServerEditScreen(
5566 val vm: ServerEditViewModel =
5667 viewModel(factory = vmFactory { ServerEditViewModel(container, profileId) })
5768 val state by vm.state.collectAsStateWithLifecycle()
69+ var showDiscardConfirm by rememberSaveable { mutableStateOf(false) }
70+ var passwordVisible by rememberSaveable { mutableStateOf(false) }
71+
72+ fun safeExit() {
73+ if (state.isDirty && !state.saving) {
74+ showDiscardConfirm = true
75+ } else {
76+ onDone()
77+ }
78+ }
79+
80+ // Intercept the system back gesture when the form has unsaved changes so the user
81+ // gets a chance to keep editing instead of losing their work silently.
82+ BackHandler(enabled = state.isDirty && !state.saving && !showDiscardConfirm) {
83+ showDiscardConfirm = true
84+ }
5885
5986 Scaffold(
6087 topBar = {
6188 TopAppBar(
6289 title = { Text(if (state.isNew) stringResource(R.string.add_server) else stringResource(R.string.edit_server)) },
6390 navigationIcon = {
64− IconButton(onClick = onDone) {
91+ IconButton(onClick = ::safeExit) {
6592 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
6693 }
6794 },
@@ -74,98 +101,117 @@ fun ServerEditScreen(
74101 CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
75102 }
76103 } else {
77− Column(
78− modifier = Modifier
79− .padding(padding)
80− .imePadding()
81− .widthIn(max = 600.dp)
82− .padding(16.dp)
83− .verticalScroll(rememberScrollState()),
84− verticalArrangement = Arrangement.spacedBy(12.dp),
85− ) {
86− OutlinedTextField(
87− value = state.label,
88− onValueChange = { v -> vm.update { it.copy(label = v) } },
89− label = { Text(stringResource(R.string.label_optional)) },
90− singleLine = true,
91− keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
92− modifier = Modifier.fillMaxWidth().testTag("server_label"),
104+ ServerEditForm(
105+ state = state,
106+ passwordVisible = passwordVisible,
107+ onTogglePassword = { passwordVisible = !passwordVisible },
108+ padding = padding,
109+ vm = vm,
110+ onDone = onDone,
93111 )
94− OutlinedTextField(
95− value = state.baseUrl,
96− onValueChange = { v -> vm.update { it.copy(baseUrl = v, error = null) } },
97− label = { Text(stringResource(R.string.base_url)) },
98− placeholder = { Text(stringResource(R.string.base_url_hint)) },
99− singleLine = true,
100− isError = state.baseUrl.isNotBlank() && !isValidUrl(state.baseUrl),
101− supportingText = {
102− if (state.baseUrl.isNotBlank() && !isValidUrl(state.baseUrl)) {
103− Text(stringResource(R.string.invalid_url))
104− }
105− },
106− keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
107− modifier = Modifier.fillMaxWidth().testTag("server_url"),
108− )
109− // Connectivity check — probes the server without credentials to detect
110− // whether auth is required. Auth fields are only revealed afterwards (or
111− // when editing a profile that already has saved credentials).
112− OutlinedButton(
113− onClick = { vm.probe() },
114− enabled = state.canSave && !state.probing && !state.saving,
115− modifier = Modifier.fillMaxWidth().testTag("server_probe"),
116− ) {
117− if (state.probing) {
118− CircularProgressIndicator(
119− modifier = Modifier.size(20.dp),
120− strokeWidth = 2.dp,
121− )
122− Text(stringResource(R.string.checking), modifier = Modifier.padding(start = 8.dp))
123− } else {
124− Text(stringResource(R.string.check_connectivity))
112+ }
113+ }
114+
115+ if (showDiscardConfirm) {
116+ AlertDialog(
117+ onDismissRequest = { showDiscardConfirm = false },
118+ title = { Text(stringResource(R.string.discard_title)) },
119+ text = { Text(stringResource(R.string.discard_text)) },
120+ confirmButton = {
121+ TextButton(onClick = {
122+ showDiscardConfirm = false
123+ onDone()
124+ }) { Text(stringResource(R.string.discard), color = MaterialTheme.colorScheme.error) }
125+ },
126+ dismissButton = {
127+ TextButton(onClick = { showDiscardConfirm = false }) { Text(stringResource(R.string.keep_editing)) }
128+ },
129+ )
130+ }
131+}
132+
133+@Composable
134+private fun ServerEditForm(
135+ state: ServerEditState,
136+ passwordVisible: Boolean,
137+ onTogglePassword: () -> Unit,
138+ padding: androidx.compose.foundation.layout.PaddingValues,
139+ vm: ServerEditViewModel,
140+ onDone: () -> Unit,
141+) {
142+ Column(
143+ modifier = Modifier
144+ .padding(padding)
145+ .imePadding()
146+ .widthIn(max = 600.dp)
147+ .padding(16.dp)
148+ .verticalScroll(rememberScrollState()),
149+ verticalArrangement = Arrangement.spacedBy(12.dp),
150+ ) {
151+ OutlinedTextField(
152+ value = state.label,
153+ onValueChange = { v -> vm.update { it.copy(label = v) } },
154+ label = { Text(stringResource(R.string.label_optional)) },
155+ singleLine = true,
156+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
157+ modifier = Modifier.fillMaxWidth().testTag("server_label"),
158+ )
159+ OutlinedTextField(
160+ value = state.baseUrl,
161+ onValueChange = { v -> vm.update { it.copy(baseUrl = v, error = null) } },
162+ label = { Text(stringResource(R.string.base_url)) },
163+ placeholder = { Text(stringResource(R.string.base_url_hint)) },
164+ singleLine = true,
165+ isError = state.baseUrl.isNotBlank() && !isValidUrl(state.baseUrl),
166+ supportingText = {
167+ if (state.baseUrl.isNotBlank() && !isValidUrl(state.baseUrl)) {
168+ Text(stringResource(R.string.invalid_url))
125169 }
126− }
127− // Guide users through the probe-for-auth flow: auth fields only appear
128− // after a probe detects they're required, so a user who doesn't know to
129− // tap "Check connectivity" would otherwise be confused.
130− if (!state.authFieldsVisible) {
131− Text(
132− stringResource(R.string.auth_help),
133− style = MaterialTheme.typography.bodySmall,
134− color = MaterialTheme.colorScheme.onSurfaceVariant,
135− modifier = Modifier.fillMaxWidth(),
170+ },
171+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
172+ modifier = Modifier.fillMaxWidth().testTag("server_url"),
173+ )
174+ OutlinedButton(
175+ onClick = { vm.probe() },
176+ enabled = state.canSave && !state.probing && !state.saving,
177+ modifier = Modifier.fillMaxWidth().testTag("server_probe"),
178+ ) {
179+ if (state.probing) {
180+ CircularProgressIndicator(
181+ modifier = Modifier.size(20.dp),
182+ strokeWidth = 2.dp,
136183 )
184+ Text(stringResource(R.string.checking), modifier = Modifier.padding(start = 8.dp))
185+ } else {
186+ Text(stringResource(R.string.check_connectivity))
137187 }
138− AnimatedVisibility(visible = state.authFieldsVisible) {
139− Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
140− Text(
141− stringResource(R.string.auth_required),
142− style = MaterialTheme.typography.bodySmall,
143− color = MaterialTheme.colorScheme.error,
144− )
145− OutlinedTextField(
146− value = state.username,
147− onValueChange = { v -> vm.update { it.copy(username = v) } },
148− label = { Text(stringResource(R.string.username_optional)) },
149− singleLine = true,
150− keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
151− modifier = Modifier.fillMaxWidth().testTag("server_username"),
152− )
153− OutlinedTextField(
154− value = state.password,
155− onValueChange = { v -> vm.update { it.copy(password = v) } },
156− label = { Text(stringResource(R.string.password_optional)) },
157− singleLine = true,
158− visualTransformation = PasswordVisualTransformation(),
159− keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
160− keyboardActions = KeyboardActions(onDone = { if (state.canSave && !state.saving) vm.save(onDone) }),
161− modifier = Modifier.fillMaxWidth().testTag("server_password"),
162− )
163− }
164− }
188+ }
189+ if (!state.authFieldsVisible) {
190+ Text(
191+ stringResource(R.string.auth_help),
192+ style = MaterialTheme.typography.bodySmall,
193+ color = MaterialTheme.colorScheme.onSurfaceVariant,
194+ modifier = Modifier.fillMaxWidth(),
195+ )
196+ }
197+ AnimatedVisibility(visible = state.authFieldsVisible) {
198+ AuthFields(
199+ state = state,
200+ passwordVisible = passwordVisible,
201+ onTogglePassword = onTogglePassword,
202+ onUpdate = vm::update,
203+ onTestCredentials = vm::testCredentials,
204+ onImeDone = { if (state.canSave && !state.saving) vm.saveAndConnect(onDone) },
205+ )
206+ }
207+ Row(
208+ modifier = Modifier.fillMaxWidth(),
209+ horizontalArrangement = Arrangement.spacedBy(12.dp),
210+ ) {
165211 Button(
166212 onClick = { vm.save(onDone) },
167213 enabled = state.canSave && !state.saving,
168− modifier = Modifier.fillMaxWidth(),
214+ modifier = Modifier.weight(1f),
169215 ) {
170216 if (state.saving) {
171217 CircularProgressIndicator(
@@ -178,15 +224,89 @@ fun ServerEditScreen(
178224 Text(stringResource(R.string.save))
179225 }
180226 }
181− state.error?.let { message ->
182− Text(
183− message,
184− color = androidx.compose.material3.MaterialTheme.colorScheme.error,
185− style = androidx.compose.material3.MaterialTheme.typography.bodySmall,
186− modifier = Modifier.fillMaxWidth(),
227+ Button(
228+ onClick = { vm.saveAndConnect(onDone) },
229+ enabled = state.canSave && !state.saving,
230+ modifier = Modifier.weight(1f),
231+ ) {
232+ Text(stringResource(R.string.save_and_connect))
233+ }
234+ }
235+ state.error?.let { message ->
236+ Text(
237+ message,
238+ color = MaterialTheme.colorScheme.error,
239+ style = MaterialTheme.typography.bodySmall,
240+ modifier = Modifier.fillMaxWidth(),
241+ )
242+ }
243+ }
244+}
245+
246+@Composable
247+private fun AuthFields(
248+ state: ServerEditState,
249+ passwordVisible: Boolean,
250+ onTogglePassword: () -> Unit,
251+ onUpdate: ((ServerEditState) -> ServerEditState) -> Unit,
252+ onTestCredentials: () -> Unit,
253+ onImeDone: () -> Unit,
254+) {
255+ val showPasswordLabel = stringResource(R.string.show_password)
256+ val hidePasswordLabel = stringResource(R.string.hide_password)
257+ Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
258+ Text(
259+ stringResource(R.string.auth_required),
260+ style = MaterialTheme.typography.bodySmall,
261+ color = MaterialTheme.colorScheme.error,
262+ )
263+ OutlinedTextField(
264+ value = state.username,
265+ onValueChange = { v -> onUpdate { it.copy(username = v) } },
266+ label = { Text(stringResource(R.string.username_optional)) },
267+ singleLine = true,
268+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
269+ modifier = Modifier.fillMaxWidth().testTag("server_username"),
270+ )
271+ OutlinedTextField(
272+ value = state.password,
273+ onValueChange = { v -> onUpdate { it.copy(password = v) } },
274+ label = { Text(stringResource(R.string.password_optional)) },
275+ singleLine = true,
276+ visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
277+ trailingIcon = {
278+ IconButton(onClick = onTogglePassword) {
279+ Icon(
280+ if (passwordVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility,
281+ contentDescription = if (passwordVisible) hidePasswordLabel else showPasswordLabel,
282+ )
283+ }
284+ },
285+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
286+ keyboardActions = KeyboardActions(onDone = { onImeDone() }),
287+ modifier = Modifier.fillMaxWidth().testTag("server_password"),
288+ )
289+ OutlinedButton(
290+ onClick = onTestCredentials,
291+ enabled = state.canSave && !state.testingCredentials && !state.saving,
292+ modifier = Modifier.fillMaxWidth().testTag("server_test_creds"),
293+ ) {
294+ if (state.testingCredentials) {
295+ CircularProgressIndicator(
296+ modifier = Modifier.size(20.dp),
297+ strokeWidth = 2.dp,
187298 )
299+ Text(stringResource(R.string.testing_credentials), modifier = Modifier.padding(start = 8.dp))
300+ } else {
301+ Text(stringResource(R.string.test_credentials))
188302 }
189303 }
304+ state.credentialsResult?.let { ok ->
305+ Text(
306+ stringResource(if (ok) R.string.credentials_ok else R.string.credentials_rejected),
307+ style = MaterialTheme.typography.bodySmall,
308+ color = if (ok) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
309+ )
190310 }
191311 }
192312}
app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+94−31
@@ -32,11 +32,34 @@ data class ServerEditState(
3232 val authFieldsVisible: Boolean = false,
3333 /** Whether a connectivity/auth probe is in progress. */
3434 val probing: Boolean = false,
35+ /** Snapshot of the profile as loaded (for dirty detection). Null until loaded. */
36+ val initial: InitialProfile? = null,
37+ /** Whether a credential test is in progress. */
38+ val testingCredentials: Boolean = false,
39+ /** Non-null when the last credential test succeeded (true) or failed (false). */
40+ val credentialsResult: Boolean? = null,
3541) {
3642 val canSave: Boolean get() = baseUrl.isNotBlank() && isValidUrl(baseUrl)
3743 val isNew: Boolean get() = id == null
44+ /** True if any field differs from its loaded value. */
45+ val isDirty: Boolean
46+ get() {
47+ val init = initial ?: return false
48+ return label.trim() != init.label ||
49+ baseUrl.trim() != init.baseUrl ||
50+ username.trim() != init.username ||
51+ password.trim() != init.password
52+ }
3853}
3954
55+/** Snapshot of the profile loaded into the editor, normalized the same way save() stores it. */
56+data class InitialProfile(
57+ val label: String,
58+ val baseUrl: String,
59+ val username: String,
60+ val password: String,
61+)
62+
4063fun isValidUrl(url: String): Boolean = try {
4164 val u = java.net.URI(url.trim())
4265 u.scheme != null && u.host != null && (u.scheme == "http" || u.scheme == "https")
@@ -55,14 +78,18 @@ class ServerEditViewModel(
5578 init {
5679 viewModelScope.launch {
5780 if (profileId != null) {
58− // Wait for a non-empty profile list before searching, so DataStore
59− // emitting an empty list during loading doesn't cause a false timeout.
6081 val existing = withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
6182 container.profileStore.profiles
6283 .first { it.isNotEmpty() || it.none { p -> p.id == profileId } }
6384 .firstOrNull { it.id == profileId }
6485 }
6586 if (existing != null) {
87+ val init = InitialProfile(
88+ label = existing.label,
89+ baseUrl = existing.baseUrl,
90+ username = existing.username.orEmpty(),
91+ password = existing.password.orEmpty(),
92+ )
6693 _state.value = ServerEditState(
6794 id = existing.id,
6895 label = existing.label,
@@ -70,14 +97,11 @@ class ServerEditViewModel(
7097 username = existing.username.orEmpty(),
7198 password = existing.password.orEmpty(),
7299 loaded = true,
73− // An existing profile that already has credentials saved should
74− // show the auth fields immediately — the user may want to edit them.
75100 authFieldsVisible = existing.hasAuth,
101+ initial = init,
76102 )
77103 return@launch
78104 }
79− // Profile existed but timed out loading — show an error instead of
80− // silently presenting an empty form that looks like "new server".
81105 _state.value = ServerEditState(
82106 id = profileId,
83107 loaded = true,
@@ -85,25 +109,25 @@ class ServerEditViewModel(
85109 )
86110 return@launch
87111 }
88− _state.update { it.copy(loaded = true) }
112+ // New-profile form: seed an initial snapshot of empty values so isDirty
113+ // becomes true the moment the user types anything.
114+ _state.update {
115+ it.copy(
116+ loaded = true,
117+ initial = InitialProfile("", "", "", ""),
118+ )
119+ }
89120 }
90121 }
91122
92123 fun update(transform: (ServerEditState) -> ServerEditState) {
93− _state.update(transform)
124+ _state.update { transform(it).copy(credentialsResult = null) }
94125 }
95126
96− /**
97− * Probe the server at [baseUrl] to check reachability and detect whether
98− * authentication is required. On success the auth fields are shown only if the
99− * server returned an auth error (401/403); on failure the error is surfaced.
100− * Editing the base URL after a probe resets the auth-visibility so the next
101− * save re-probes against the new URL.
102− */
103127 fun probe() {
104128 val s = _state.value
105129 if (!s.canSave || s.probing) return
106− _state.update { it.copy(probing = true, error = null) }
130+ _state.update { it.copy(probing = true, error = null, credentialsResult = null) }
107131 viewModelScope.launch {
108132 val result = runCatchingCancellable { container.probeServer(s.baseUrl) }
109133 result.onSuccess { pr ->
@@ -132,34 +156,73 @@ class ServerEditViewModel(
132156 }
133157 }
134158
159+ /**
160+ * Probe the server *with* the entered credentials to validate them before saving.
161+ * Only meaningful once auth fields are visible (the server requires auth); calling
162+ * it when no auth is needed is a no-op. Surfaces [ServerEditState.credentialsResult]
163+ * so the UI can show success/failure without dismissing the form.
164+ */
165+ fun testCredentials() {
166+ val s = _state.value
167+ if (!s.canSave || s.testingCredentials) return
168+ if (s.username.isBlank() && s.password.isBlank()) return
169+ _state.update { it.copy(testingCredentials = true, credentialsResult = null, error = null) }
170+ viewModelScope.launch {
171+ val result = runCatchingCancellable { container.probeWithCredentials(s.baseUrl, s.username, s.password) }
172+ result.onSuccess { ok ->
173+ _state.update {
174+ it.copy(
175+ testingCredentials = false,
176+ credentialsResult = ok,
177+ error = if (ok) null else container.string(R.string.credentials_rejected),
178+ )
179+ }
180+ }.onFailure { e ->
181+ _state.update {
182+ it.copy(
183+ testingCredentials = false,
184+ credentialsResult = false,
185+ error = container.friendlyError(e),
186+ )
187+ }
188+ }
189+ }
190+ }
191+
135192 fun save(onDone: () -> Unit) {
193+ saveInternal(connectAfter = false, onDone = onDone)
194+ }
195+
196+ /** Save and then connect to the saved profile in one step. */
197+ fun saveAndConnect(onDone: () -> Unit) {
198+ saveInternal(connectAfter = true, onDone = onDone)
199+ }
200+
201+ private fun saveInternal(connectAfter: Boolean, onDone: () -> Unit) {
136202 val s = _state.value
137203 if (!s.canSave || s.saving) return
138204 _state.update { it.copy(saving = true) }
139205 viewModelScope.launch {
140206 val result = runCatchingCancellable {
141− // Preserve the existing lastUsed timestamp so saving an edit doesn't
142− // reset the profile's sort position (profiles are sorted descending
143− // by lastUsed). connect() is responsible for updating lastUsed on
144− // actual server use. If loading the existing profile times out,
145− // lastUsed=0 is passed — ProfileStore.save() preserves the existing
146− // nonzero lastUsed in that case so the sort position isn't lost.
147207 val existingLastUsed = if (s.id != null) {
148208 withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
149209 container.profileStore.profiles.first()
150210 .firstOrNull { it.id == s.id }?.lastUsed
151211 } ?: 0L
152212 } else 0L
153− container.profileStore.save(
154− ServerProfile(
155− id = s.id ?: UUID.randomUUID().toString(),
156− label = s.label.trim(),
157− baseUrl = s.baseUrl.trim(),
158− username = s.username.trim().takeIf { it.isNotBlank() },
159− password = s.password.trim().takeIf { it.isNotEmpty() },
160− lastUsed = existingLastUsed,
161− ),
213+ val saved = ServerProfile(
214+ id = s.id ?: UUID.randomUUID().toString(),
215+ label = s.label.trim(),
216+ baseUrl = s.baseUrl.trim(),
217+ username = s.username.trim().takeIf { it.isNotBlank() },
218+ password = s.password.trim().takeIf { it.isNotEmpty() },
219+ lastUsed = existingLastUsed,
162220 )
221+ container.profileStore.save(saved)
222+ if (connectAfter) {
223+ container.connect(saved)
224+ container.activeConnection.value?.api?.ping()
225+ }
163226 }
164227 result.onSuccess { onDone() }
165228 .onFailure { e -> _state.update { it.copy(error = container.friendlyError(e), saving = false) } }
app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+23−3
@@ -72,12 +72,20 @@ fun ServerListScreen(
7272 val reconnecting by container.reconnecting.collectAsStateWithLifecycle()
7373 val haptics = LocalHapticFeedback.current
7474 val snackbar = remember { SnackbarHostState() }
75+ val retryLabel = stringResource(R.string.retry)
7576 var pendingDeleteId by rememberSaveable { mutableStateOf<String?>(null) }
7677 val connectedId = activeConnection?.profile?.id
7778
7879 LaunchedEffect(Unit) {
79− vm.errorEvents.collect { msg ->
80− snackbar.showSnackbar(msg)
80+ vm.errorEvents.collect { event ->
81+ val result = if (event.profile != null) {
82+ snackbar.showSnackbar(message = event.message, actionLabel = retryLabel)
83+ } else {
84+ snackbar.showSnackbar(event.message)
85+ }
86+ if (result == androidx.compose.material3.SnackbarResult.ActionPerformed && event.profile != null) {
87+ vm.connect(event.profile, onConnected)
88+ }
8189 }
8290 }
8391
@@ -159,6 +167,12 @@ fun ServerListScreen(
159167 tint = MaterialTheme.colorScheme.primary,
160168 modifier = Modifier.size(22.dp),
161169 )
170+ // Keep edit available on the active server so credentials can be
171+ // fixed without disconnecting first. Only delete is hidden, since
172+ // deleting the active server also disconnects (SV1 warns about that).
173+ IconButton(onClick = { onEditProfile(profile.id) }) {
174+ Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.edit))
175+ }
162176 } else if (connectingId == profile.id) {
163177 val connectingLabel = stringResource(R.string.connecting)
164178 CircularProgressIndicator(
@@ -181,10 +195,16 @@ fun ServerListScreen(
181195 }
182196
183197 profiles.find { it.id == pendingDeleteId }?.let { profile ->
198+ val isActiveProfile = profile.id == connectedId
184199 AlertDialog(
185200 onDismissRequest = { pendingDeleteId = null },
186201 title = { Text(stringResource(R.string.remove_server_title)) },
187− text = { Text(stringResource(R.string.remove_server_text, profile.displayLabel)) },
202+ text = {
203+ Text(
204+ if (isActiveProfile) stringResource(R.string.remove_server_active_text, profile.displayLabel)
205+ else stringResource(R.string.remove_server_text, profile.displayLabel),
206+ )
207+ },
188208 confirmButton = {
189209 TextButton(onClick = {
190210 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
app/src/main/java/soy/iko/opencode/ui/server/ServerListViewModel.kt+8−6
@@ -18,6 +18,10 @@ import kotlinx.coroutines.flow.asStateFlow
1818import kotlinx.coroutines.flow.stateIn
1919import kotlinx.coroutines.launch
2020
21+/** A transient error surfaced as a snackbar, optionally paired with the profile that
22+ * failed to connect so the snackbar can offer a Retry action. */
23+data class ConnectError(val message: String, val profile: ServerProfile?)
24+
2125class ServerListViewModel(private val container: AppContainer) : ViewModel() {
2226
2327 val profiles: StateFlow<List<ServerProfile>> =
@@ -32,11 +36,11 @@ class ServerListViewModel(private val container: AppContainer) : ViewModel() {
3236
3337 /** One-shot error events surfaced as snackbars. A SharedFlow (not StateFlow) so each
3438 * emission is delivered independently. */
35− private val _errorEvents = MutableSharedFlow<String>(
39+ private val _errorEvents = MutableSharedFlow<ConnectError>(
3640 extraBufferCapacity = NetworkConfig.snackbarEventBufferCapacity,
3741 onBufferOverflow = BufferOverflow.DROP_OLDEST,
3842 )
39− val errorEvents: SharedFlow<String> = _errorEvents.asSharedFlow()
43+ val errorEvents: SharedFlow<ConnectError> = _errorEvents.asSharedFlow()
4044
4145 fun connect(profile: ServerProfile, onConnected: () -> Unit) {
4246 if (_connecting.value != null) return
@@ -50,7 +54,7 @@ class ServerListViewModel(private val container: AppContainer) : ViewModel() {
5054 result.onSuccess { onConnected() }
5155 .onFailure {
5256 container.disconnect()
53− _errorEvents.tryEmit(container.friendlyError(it))
57+ _errorEvents.tryEmit(ConnectError(container.friendlyError(it), profile))
5458 }
5559 } finally {
5660 _connecting.value = null
@@ -60,13 +64,11 @@ class ServerListViewModel(private val container: AppContainer) : ViewModel() {
6064
6165 fun delete(profile: ServerProfile) {
6266 viewModelScope.launch {
63− // If the user is deleting the currently-connected profile, disconnect
64− // first so the active connection doesn't linger pointing at a deleted server.
6567 if (container.activeConnection.value?.profile?.id == profile.id) {
6668 container.disconnect()
6769 }
6870 runCatchingCancellable { container.profileStore.delete(profile.id) }
69− .onFailure { _errorEvents.tryEmit(container.friendlyError(it)) }
71+ .onFailure { _errorEvents.tryEmit(ConnectError(container.friendlyError(it), null)) }
7072 }
7173 }
7274}
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+144−54
@@ -22,6 +22,7 @@ import androidx.compose.material.icons.automirrored.filled.Logout
2222import androidx.compose.material.icons.filled.Add
2323import androidx.compose.material.icons.filled.ArrowDropDown
2424import androidx.compose.material.icons.filled.ChatBubbleOutline
25+import androidx.compose.material.icons.filled.CheckCircle
2526import androidx.compose.material.icons.filled.Delete
2627import androidx.compose.material.icons.filled.Edit
2728import androidx.compose.material.icons.filled.Folder
@@ -72,6 +73,7 @@ import androidx.compose.ui.unit.dp
7273import androidx.compose.ui.res.stringResource
7374import androidx.lifecycle.compose.collectAsStateWithLifecycle
7475import androidx.lifecycle.viewmodel.compose.viewModel
76+import soy.iko.opencode.data.model.ServerProfile
7577import soy.iko.opencode.data.model.Session
7678import soy.iko.opencode.di.AppContainer
7779import soy.iko.opencode.R
@@ -98,14 +100,21 @@ fun SessionListScreen(
98100 val switchingId by vm.switchingId.collectAsStateWithLifecycle()
99101 val connectionState by vm.connectionState.collectAsStateWithLifecycle()
100102 val unread by vm.unread.collectAsStateWithLifecycle()
103+ val creating by vm.creating.collectAsStateWithLifecycle()
104+ val anyRunActive by container.anyRunActive.collectAsStateWithLifecycle()
105+ val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
106+ val connectedId = activeConnection?.profile?.id
101107 val haptics = LocalHapticFeedback.current
102108 val snackbar = remember { SnackbarHostState() }
109+ val undoLabel = stringResource(R.string.undo)
110+ val sessionDeletedLabel = stringResource(R.string.session_deleted)
103111 // One shared timer drives every relative-time label in the session list instead of
104112 // each card spinning up its own coroutine + lifecycle observer while scrolling.
105113 val timeTick = rememberRelativeTimeTick()
106114 var showServerMenu by rememberSaveable { mutableStateOf(false) }
107115 var pendingDeleteId by rememberSaveable { mutableStateOf<String?>(null) }
108116 var pendingRenameId by rememberSaveable { mutableStateOf<String?>(null) }
117+ var showDisconnectConfirm by rememberSaveable { mutableStateOf(false) }
109118 val pendingDelete = pendingDeleteId?.let { id -> state.sessions.firstOrNull { it.id == id } }
110119 val pendingRename = pendingRenameId?.let { id -> state.sessions.firstOrNull { it.id == id } }
111120
@@ -118,63 +127,39 @@ fun SessionListScreen(
118127 }
119128 }
120129
130+ // Undo snackbar: when a session is marked for deferred deletion, offer Undo. If the
131+ // action is taken before the delay expires, the session is restored and the REST
132+ // delete never fires.
133+ LaunchedEffect(Unit) {
134+ vm.undoEvents.collect { sessionId ->
135+ val result = snackbar.showSnackbar(
136+ message = sessionDeletedLabel,
137+ actionLabel = undoLabel,
138+ duration = androidx.compose.material3.SnackbarDuration.Short,
139+ )
140+ if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) {
141+ vm.undoDelete(sessionId)
142+ }
143+ }
144+ }
145+
121146 Scaffold(
122147 topBar = {
123148 TopAppBar(
124149 title = {
125− Column {
126− Box {
127− Row(
128− modifier = Modifier
129− .clickable(role = Role.Button) { showServerMenu = true },
130− verticalAlignment = Alignment.CenterVertically,
131− ) {
132− Text(serverLabel, maxLines = 1, overflow = TextOverflow.Ellipsis)
133− Icon(Icons.Filled.ArrowDropDown, contentDescription = stringResource(R.string.switch_server))
134− }
135− DropdownMenu(
136− expanded = showServerMenu,
137− onDismissRequest = { showServerMenu = false },
138− ) {
139− profiles.forEach { profile ->
140− DropdownMenuItem(
141− text = {
142− Row(verticalAlignment = Alignment.CenterVertically) {
143− Column(modifier = Modifier.weight(1f)) {
144− Text(
145− profile.displayLabel,
146− style = MaterialTheme.typography.bodyLarge,
147− maxLines = 1,
148− overflow = TextOverflow.Ellipsis,
149− )
150− Text(
151− profile.baseUrl,
152− style = MaterialTheme.typography.bodySmall,
153− color = MaterialTheme.colorScheme.onSurfaceVariant,
154− maxLines = 1,
155− overflow = TextOverflow.Ellipsis,
156− )
157− }
158− if (switchingId == profile.id) {
159− val switchingLabel = stringResource(R.string.loading)
160− CircularProgressIndicator(
161− Modifier
162− .size(18.dp)
163− .semantics { contentDescription = switchingLabel },
164− strokeWidth = 2.dp,
165− )
166− }
167− }
168− },
169− onClick = {
170− showServerMenu = false
171− vm.switchServer(profile)
172− },
173− )
174− }
175− }
176− }
177− }
150+ ServerSwitcherMenu(
151+ serverLabel = serverLabel,
152+ profiles = profiles,
153+ connectedId = connectedId,
154+ switchingId = switchingId,
155+ expanded = showServerMenu,
156+ onExpand = { showServerMenu = true },
157+ onDismiss = { showServerMenu = false },
158+ onSelect = { profile ->
159+ showServerMenu = false
160+ vm.switchServer(profile)
161+ },
162+ )
178163 },
179164 actions = {
180165 IconButton(onClick = { vm.refresh() }) {
@@ -186,7 +171,11 @@ fun SessionListScreen(
186171 IconButton(onClick = onOpenSettings) {
187172 Icon(Icons.Filled.Settings, contentDescription = stringResource(R.string.settings))
188173 }
189− IconButton(onClick = onDisconnect) {
174+ IconButton(onClick = {
175+ // Confirm before disconnecting if an agent run is active in any
176+ // session — disconnecting kills the SSE stream and the run.
177+ if (anyRunActive) showDisconnectConfirm = true else onDisconnect()
178+ }) {
190179 Icon(Icons.AutoMirrored.Filled.Logout, contentDescription = stringResource(R.string.disconnect))
191180 }
192181 },
@@ -198,6 +187,10 @@ fun SessionListScreen(
198187 onClick = { vm.createSession(onCreated = onOpenSession) },
199188 icon = { Icon(Icons.Filled.Add, contentDescription = null) },
200189 text = { Text(stringResource(R.string.new_session)) },
190+ // Disable while a creation is in flight so a double-tap can't spawn two
191+ // sessions. The container guard in createSession is the real protection;
192+ // this is the visual signal.
193+ expanded = !creating,
201194 )
202195 },
203196 ) { padding ->
@@ -275,6 +268,10 @@ fun SessionListScreen(
275268 // and the dialog guards against accidental data loss.
276269 val swipeState = rememberSwipeToDismissBoxState(
277270 confirmValueChange = {
271+ // Haptic at the trigger so the swipe path matches the icon-path
272+ // delete, which vibrates on confirm. Returns false (snap back) so
273+ // the dialog guards the actual deletion.
274+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
278275 pendingDeleteId = session.id
279276 false
280277 },
@@ -350,6 +347,23 @@ fun SessionListScreen(
350347 onDismiss = { pendingRenameId = null },
351348 )
352349 }
350+
351+ if (showDisconnectConfirm) {
352+ AlertDialog(
353+ onDismissRequest = { showDisconnectConfirm = false },
354+ title = { Text(stringResource(R.string.disconnect_active_title)) },
355+ text = { Text(stringResource(R.string.disconnect_active_text)) },
356+ confirmButton = {
357+ TextButton(onClick = {
358+ showDisconnectConfirm = false
359+ onDisconnect()
360+ }) { Text(stringResource(R.string.disconnect), color = MaterialTheme.colorScheme.error) }
361+ },
362+ dismissButton = {
363+ TextButton(onClick = { showDisconnectConfirm = false }) { Text(stringResource(R.string.cancel)) }
364+ },
365+ )
366+ }
353367}
354368
355369@Composable
@@ -483,3 +497,79 @@ private fun EmptySessions(onCreate: () -> Unit, modifier: Modifier = Modifier) {
483497 }
484498 }
485499}
500+
501+/** Title + dropdown for quick-switching between saved servers. Highlights the active
502+ * server with a check mark and primary color so the user can tell which one they're
503+ * on at a glance. */
504+@Composable
505+private fun ServerSwitcherMenu(
506+ serverLabel: String,
507+ profiles: List<ServerProfile>,
508+ connectedId: String?,
509+ switchingId: String?,
510+ expanded: Boolean,
511+ onExpand: () -> Unit,
512+ onDismiss: () -> Unit,
513+ onSelect: (ServerProfile) -> Unit,
514+) {
515+ Column {
516+ Box {
517+ Row(
518+ modifier = Modifier
519+ .clickable(role = Role.Button) { onExpand() },
520+ verticalAlignment = Alignment.CenterVertically,
521+ ) {
522+ Text(serverLabel, maxLines = 1, overflow = TextOverflow.Ellipsis)
523+ Icon(Icons.Filled.ArrowDropDown, contentDescription = stringResource(R.string.switch_server))
524+ }
525+ DropdownMenu(
526+ expanded = expanded,
527+ onDismissRequest = onDismiss,
528+ ) {
529+ profiles.forEach { profile ->
530+ val isActiveProfile = profile.id == connectedId
531+ DropdownMenuItem(
532+ text = {
533+ Row(verticalAlignment = Alignment.CenterVertically) {
534+ Column(modifier = Modifier.weight(1f)) {
535+ Text(
536+ profile.displayLabel,
537+ style = MaterialTheme.typography.bodyLarge,
538+ color = if (isActiveProfile) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
539+ maxLines = 1,
540+ overflow = TextOverflow.Ellipsis,
541+ )
542+ Text(
543+ profile.baseUrl,
544+ style = MaterialTheme.typography.bodySmall,
545+ color = MaterialTheme.colorScheme.onSurfaceVariant,
546+ maxLines = 1,
547+ overflow = TextOverflow.Ellipsis,
548+ )
549+ }
550+ if (isActiveProfile) {
551+ Icon(
552+ Icons.Filled.CheckCircle,
553+ contentDescription = stringResource(R.string.connected),
554+ tint = MaterialTheme.colorScheme.primary,
555+ modifier = Modifier.size(18.dp),
556+ )
557+ }
558+ if (switchingId == profile.id) {
559+ val switchingLabel = stringResource(R.string.loading)
560+ CircularProgressIndicator(
561+ Modifier
562+ .size(18.dp)
563+ .semantics { contentDescription = switchingLabel },
564+ strokeWidth = 2.dp,
565+ )
566+ }
567+ }
568+ },
569+ onClick = { onSelect(profile) },
570+ )
571+ }
572+ }
573+ }
574+ }
575+}
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+68−16
@@ -87,6 +87,11 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
8787 private val _switchingId = MutableStateFlow<String?>(null)
8888 val switchingId: StateFlow<String?> = _switchingId.asStateFlow()
8989
90+ /** True while a session creation is in flight, so the FAB can disable itself and
91+ * prevent double-taps from firing two createSession calls. */
92+ private val _creating = MutableStateFlow(false)
93+ val creating: StateFlow<Boolean> = _creating.asStateFlow()
94+
9095 private val _state = MutableStateFlow(SessionListState())
9196 val state: StateFlow<SessionListState> = _state.asStateFlow()
9297
@@ -104,6 +109,17 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
104109 )
105110 val transientErrors: SharedFlow<String> = _transientErrors.asSharedFlow()
106111
112+ /** One-shot events carrying the id of a session marked for deferred deletion, so the
113+ * UI can show an Undo snackbar. */
114+ private val _undoEvents = MutableSharedFlow<String>(
115+ extraBufferCapacity = NetworkConfig.snackbarEventBufferCapacity,
116+ onBufferOverflow = BufferOverflow.DROP_OLDEST,
117+ )
118+ val undoEvents: SharedFlow<String> = _undoEvents.asSharedFlow()
119+
120+ /** Session awaiting the undo window to expire before its REST delete fires. */
121+ private var pendingDelete: Session? = null
122+
107123 private var previewJob: Job? = null
108124 private var refreshJob: Job? = null
109125
@@ -321,28 +337,64 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
321337
322338 fun createSession(onCreated: (String) -> Unit) {
323339 val conn = container.activeConnection.value ?: return
340+ if (!_creating.compareAndSet(false, true)) return
324341 viewModelScope.launch {
325− runCatchingCancellable { conn.repository.createSession() }
326− .onSuccess { onCreated(it.id); refresh() }
327− .onFailure {
328− _state.update { it.copy(error = null) }
329− _transientErrors.tryEmit(container.friendlyError(it))
330− }
342+ try {
343+ runCatchingCancellable { conn.repository.createSession() }
344+ .onSuccess { onCreated(it.id); refresh() }
345+ .onFailure {
346+ _state.update { it.copy(error = null) }
347+ _transientErrors.tryEmit(container.friendlyError(it))
348+ }
349+ } finally {
350+ _creating.value = false
351+ }
331352 }
332353 }
333354
355+ /**
356+ * Mark [session] as pending deletion and hide it from the list immediately, then
357+ * surface an Undo via [undoEvents]. The actual REST delete is deferred by
358+ * [NetworkConfig.undoDeleteDelayMs]; if [undoDelete] is called before it fires,
359+ * the session is restored. Otherwise the delete proceeds.
360+ */
334361 fun deleteSession(session: Session) {
335− val conn = container.activeConnection.value ?: return
362+ // Optimistically remove from the visible list so the UI feels instant.
363+ _state.update { s ->
364+ s.copy(sessions = s.sessions.filterNot { it.id == session.id })
365+ }
366+ // Hold the pending delete so the snackbar can undo it.
367+ pendingDelete = session
368+ _undoEvents.tryEmit(session.id)
336369 viewModelScope.launch {
337− runCatchingCancellable { conn.repository.deleteSession(session.id) }
338− .onSuccess {
339− container.draftStore.remove(session.id)
340− refresh()
341− }
342− .onFailure {
343− _state.update { it.copy(error = null) }
344− _transientErrors.tryEmit(container.friendlyError(it))
345− }
370+ delay(NetworkConfig.undoDeleteDelayMs)
371+ // If undo was called, pendingDelete was cleared — don't delete.
372+ val toDelete = pendingDelete
373+ if (toDelete != null && toDelete.id == session.id) {
374+ pendingDelete = null
375+ val conn = container.activeConnection.value ?: return@launch
376+ runCatchingCancellable { conn.repository.deleteSession(session.id) }
377+ .onSuccess {
378+ container.draftStore.remove(session.id)
379+ refresh()
380+ }
381+ .onFailure {
382+ // Restore the session to the list since the delete failed.
383+ _state.update { s -> s.copy(sessions = (s.sessions + session).sortedByDescending { it.time?.updated ?: it.time?.created ?: 0 }) }
384+ _transientErrors.tryEmit(container.friendlyError(it))
385+ }
386+ }
387+ }
388+ }
389+
390+ /** Cancel a pending delete (Undo snackbar action) and restore the session. */
391+ fun undoDelete(sessionId: String) {
392+ val pending = pendingDelete
393+ if (pending != null && pending.id == sessionId) {
394+ pendingDelete = null
395+ _state.update { s ->
396+ s.copy(sessions = (s.sessions + pending).sortedByDescending { it.time?.updated ?: it.time?.created ?: 0 })
397+ }
346398 }
347399 }
348400
app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+50−16
@@ -46,6 +46,7 @@ import kotlinx.coroutines.Dispatchers
4646import kotlinx.coroutines.launch
4747import kotlinx.coroutines.withContext
4848import soy.iko.opencode.util.runCatchingCancellable
49+import soy.iko.opencode.ui.components.showToast
4950import androidx.compose.ui.Alignment
5051import androidx.compose.ui.Modifier
5152import androidx.compose.ui.platform.LocalContext
@@ -71,6 +72,7 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
7172 val reports by logger.reports.collectAsStateWithLifecycle()
7273 var viewing by rememberSaveable { mutableStateOf<String?>(null) }
7374 var showClearAll by rememberSaveable { mutableStateOf(false) }
75+ var pendingReportDelete by rememberSaveable { mutableStateOf<String?>(null) }
7476 val shareScope = rememberCoroutineScope()
7577 val shareLabel = stringResource(R.string.share)
7678 val timeTick = rememberRelativeTimeTick()
@@ -154,7 +156,10 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
154156 putExtra(Intent.EXTRA_TEXT, content)
155157 }
156158 runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
157− .onFailure { Log.w("Diagnostics", "Failed to share crash report", it) }
159+ .onFailure {
160+ Log.w("Diagnostics", "Failed to share crash report", it)
161+ showToast(context, context.getString(R.string.no_share_app))
162+ }
158163 }
159164 }) {
160165 Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share))
@@ -171,16 +176,18 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
171176 val reportName = viewing
172177 if (reportName != null) {
173178 var reportContent by remember(reportName) { mutableStateOf<String?>(null) }
179+ var loadFailed by remember(reportName) { mutableStateOf(false) }
174180 LaunchedEffect(reportName) {
175− reportContent = withContext(Dispatchers.IO) {
181+ val loaded = withContext(Dispatchers.IO) {
176182 runCatching { logger.readReport(reportName) }.getOrNull()
177183 }
184+ if (loaded != null) reportContent = loaded else loadFailed = true
178185 }
179186 val content = reportContent
180187 AlertDialog(
181188 onDismissRequest = { viewing = null },
182189 confirmButton = {
183− TextButton(onClick = { viewing = null }) { Text(stringResource(R.string.back)) }
190+ TextButton(onClick = { viewing = null }) { Text(stringResource(R.string.close)) }
184191 },
185192 dismissButton = {
186193 Row {
@@ -192,19 +199,22 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
192199 putExtra(Intent.EXTRA_TEXT, content.orEmpty())
193200 }
194201 runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel2)) }
195− .onFailure { Log.w("Diagnostics", "Failed to share crash report", it) }
202+ .onFailure {
203+ Log.w("Diagnostics", "Failed to share crash report", it)
204+ showToast(context, context.getString(R.string.no_share_app))
205+ }
196206 }) { Text(stringResource(R.string.share)) }
197207 Spacer(Modifier.size(8.dp))
198208 TextButton(onClick = {
199− logger.deleteReport(reportName)
200− viewing = null
201− }) { Text(stringResource(R.string.delete)) }
209+ // Confirm before deleting a single report, matching clear-all.
210+ pendingReportDelete = reportName
211+ }) { Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) }
202212 }
203213 },
204214 title = { Text(stringResource(R.string.crash_report)) },
205215 text = {
206− if (content != null) {
207− Box(
216+ when {
217+ content != null -> Box(
208218 modifier = Modifier
209219 .fillMaxWidth()
210220 .heightIn(max = 400.dp)
@@ -216,19 +226,43 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
216226 modifier = Modifier.fillMaxWidth(),
217227 )
218228 }
219− } else {
220− val loadingLabel = stringResource(R.string.loading)
221− Box(
222− modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
223− contentAlignment = Alignment.Center,
224− ) {
225− CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
229+ loadFailed -> Text(
230+ stringResource(R.string.report_load_failed),
231+ color = MaterialTheme.colorScheme.error,
232+ modifier = Modifier.padding(vertical = 24.dp),
233+ )
234+ else -> {
235+ val loadingLabel = stringResource(R.string.loading)
236+ Box(
237+ modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
238+ contentAlignment = Alignment.Center,
239+ ) {
240+ CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
241+ }
226242 }
227243 }
228244 },
229245 )
230246 }
231247
248+ if (pendingReportDelete != null) {
249+ AlertDialog(
250+ onDismissRequest = { pendingReportDelete = null },
251+ title = { Text(stringResource(R.string.delete_report_title)) },
252+ text = { Text(stringResource(R.string.delete_report_text)) },
253+ confirmButton = {
254+ TextButton(onClick = {
255+ pendingReportDelete?.let { logger.deleteReport(it) }
256+ pendingReportDelete = null
257+ viewing = null
258+ }) { Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) }
259+ },
260+ dismissButton = {
261+ TextButton(onClick = { pendingReportDelete = null }) { Text(stringResource(R.string.cancel)) }
262+ },
263+ )
264+ }
265+
232266 if (showClearAll) {
233267 AlertDialog(
234268 onDismissRequest = { showClearAll = false },
app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+36
@@ -2,8 +2,10 @@ package soy.iko.opencode.ui.settings
22
33import android.content.pm.PackageManager
44import android.os.Build
5+import androidx.compose.foundation.background
56import androidx.compose.foundation.clickable
67import androidx.compose.foundation.layout.Arrangement
8+import androidx.compose.foundation.layout.Box
79import androidx.compose.foundation.layout.Column
810import androidx.compose.foundation.layout.Row
911import androidx.compose.foundation.layout.Spacer
@@ -16,6 +18,7 @@ import androidx.compose.foundation.rememberScrollState
1618import androidx.compose.foundation.selection.selectable
1719import androidx.compose.foundation.selection.toggleable
1820import androidx.compose.foundation.verticalScroll
21+import androidx.compose.foundation.shape.CircleShape
1922import androidx.compose.material.icons.Icons
2023import androidx.compose.material.icons.automirrored.filled.ArrowBack
2124import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
@@ -38,15 +41,18 @@ import androidx.compose.runtime.remember
3841import androidx.compose.runtime.rememberCoroutineScope
3942import androidx.compose.ui.Alignment
4043import androidx.compose.ui.Modifier
44+import androidx.compose.ui.draw.clip
4145import androidx.compose.ui.platform.LocalContext
4246import androidx.compose.ui.res.stringResource
4347import androidx.compose.ui.semantics.Role
4448import androidx.compose.ui.semantics.heading
4549import androidx.compose.ui.semantics.role
4650import androidx.compose.ui.semantics.semantics
51+import androidx.compose.ui.text.style.TextAlign
4752import androidx.compose.ui.unit.dp
4853import androidx.lifecycle.compose.collectAsStateWithLifecycle
4954import kotlinx.coroutines.launch
55+import soy.iko.opencode.data.repo.CrashLogger
5056import soy.iko.opencode.data.repo.ThemeMode
5157import soy.iko.opencode.di.AppContainer
5258import soy.iko.opencode.R
@@ -60,6 +66,11 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
6066 val dynamicColor by container.settingsStore.dynamicColor.collectAsStateWithLifecycle(initialValue = true)
6167 val activeProfile = container.activeConnection.collectAsStateWithLifecycle().value?.profile
6268 val context = LocalContext.current
69+ // Crash count badge: surface that there are reports to look at without making the
70+ // user open the screen to find out.
71+ val crashLogger = remember { CrashLogger.get(context) }
72+ val crashReports by crashLogger.reports.collectAsStateWithLifecycle()
73+ val crashCount = crashReports.size
6374 val unknownVersion = stringResource(R.string.unknown_version)
6475 val versionName = remember {
6576 runCatching {
@@ -195,6 +206,12 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
195206 ) {
196207 Icon(Icons.Filled.BugReport, contentDescription = null, tint = MaterialTheme.colorScheme.error)
197208 Text(stringResource(R.string.diagnostics), modifier = Modifier.weight(1f).padding(start = 8.dp))
209+ // Badge with the crash count so the user can tell at a glance whether
210+ // there's something worth investigating.
211+ if (crashCount > 0) {
212+ Badge(count = crashCount)
213+ Spacer(Modifier.size(8.dp))
214+ }
198215 Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
199216 }
200217 }
@@ -222,3 +239,22 @@ private fun ThemeRow(mode: ThemeMode, selected: Boolean, onSelect: () -> Unit) {
222239 )
223240 }
224241}
242+
243+/** Small circular count badge used to indicate pending crash reports. */
244+@Composable
245+private fun Badge(count: Int) {
246+ Box(
247+ modifier = Modifier
248+ .size(20.dp)
249+ .clip(CircleShape)
250+ .background(MaterialTheme.colorScheme.error),
251+ contentAlignment = Alignment.Center,
252+ ) {
253+ Text(
254+ if (count > 99) "99+" else count.toString(),
255+ style = MaterialTheme.typography.labelSmall,
256+ color = MaterialTheme.colorScheme.onError,
257+ textAlign = TextAlign.Center,
258+ )
259+ }
260+}
app/src/main/res/values/strings.xml+25
@@ -47,6 +47,7 @@
4747 <string name="no_servers_hint">Add an opencode serve endpoint to connect.</string>
4848 <string name="remove_server_title">Remove server?</string>
4949 <string name="remove_server_text">“%1$s” will be removed from your saved servers.</string>
50+ <string name="remove_server_active_text">“%1$s” is your current server. Removing it will also disconnect you.</string>
5051 <string name="server_auth_short"> • auth</string>
5152 <string name="server_auth_basic"> • Basic auth</string>
5253
@@ -61,6 +62,17 @@
6162 <string name="auth_help">Tap "Check connectivity" to test the server. Authentication fields appear automatically only if the server requires them.</string>
6263 <string name="username_optional">Username (optional)</string>
6364 <string name="password_optional">Password (optional)</string>
65+ <string name="save_and_connect">Save &amp; connect</string>
66+ <string name="discard_title">Discard changes?</string>
67+ <string name="discard_text">Your edits will be lost.</string>
68+ <string name="discard">Discard</string>
69+ <string name="keep_editing">Keep editing</string>
70+ <string name="show_password">Show password</string>
71+ <string name="hide_password">Hide password</string>
72+ <string name="test_credentials">Test credentials</string>
73+ <string name="testing_credentials">Testing…</string>
74+ <string name="credentials_ok">Credentials accepted.</string>
75+ <string name="credentials_rejected">Credentials rejected.</string>
6476
6577 <!-- Session list -->
6678 <string name="new_session">New session</string>
@@ -74,6 +86,10 @@
7486 <string name="delete_session_text">“%1$s” will be permanently deleted. This cannot be undone.</string>
7587 <string name="rename_session">Rename session</string>
7688 <string name="session_title_hint">Session title</string>
89+ <string name="undo">Undo</string>
90+ <string name="session_deleted">Session deleted</string>
91+ <string name="disconnect_active_title">Disconnect?</string>
92+ <string name="disconnect_active_text">An agent run is still active. Disconnecting will stop it.</string>
7793
7894 <!-- Chat -->
7995 <string name="session">Session</string>
@@ -93,6 +109,10 @@
93109 <string name="choose_model">Choose model</string>
94110 <string name="change_model_agent">Change model and agent</string>
95111 <string name="share_conversation">Share conversation</string>
112+ <string name="stop_run_title">Stop the run?</string>
113+ <string name="stop_run_text">The agent is still working. Stop it now?</string>
114+ <string name="session_deleted_msg">This session was deleted on another device.</string>
115+ <string name="queued">Queued — will send when the run finishes</string>
96116
97117 <!-- Errors (view models) -->
98118 <string name="error_generic">Something went wrong.</string>
@@ -175,6 +195,11 @@
175195 <string name="clear_all">Clear all</string>
176196 <string name="clear_all_confirm">Delete all stored crash reports? This cannot be undone.</string>
177197 <string name="crash_report">Crash report</string>
198+ <string name="close">Close</string>
199+ <string name="delete_report_title">Delete crash report?</string>
200+ <string name="delete_report_text">This report will be permanently deleted.</string>
201+ <string name="report_load_failed">Failed to load crash report.</string>
202+ <string name="crash_count_format">%1$d</string>
178203
179204 <!-- Notifications -->
180205 <string name="notif_channel_status">Agent activity</string>
app/src/test/java/soy/iko/opencode/ui/testing/TestFakes.kt+10−3
@@ -324,6 +324,11 @@ class FakeAppContainer : AppContainer() {
324324 var probeCalls: List<String> = emptyList()
325325 private set
326326
327+ /** When non-null, [probeWithCredentials] returns this directly. */
328+ var probeWithCredentialsResult: Boolean? = null
329+ var probeWithCredentialsCalls: List<Triple<String, String, String>> = emptyList()
330+ private set
331+
327332 override fun string(id: Int, vararg formatArgs: Any): String = "test-string-$id"
328333
329334 override fun friendlyError(t: Throwable): String = "test-error: ${t.message ?: t::class.simpleName}"
@@ -334,9 +339,6 @@ class FakeAppContainer : AppContainer() {
334339 override suspend fun connect(profile: ServerProfile): OpencodeConnection {
335340 connectCalls = connectCalls + profile
336341 connectException?.let { throw it }
337− // Mirror the real AppContainer: connect() publishes the new connection so
338− // collectors (SSE observer, refresh) see it immediately. This lets the
339− // switchServer failure/restore paths be exercised end-to-end.
340342 val conn = connectResult ?: run {
341343 val api = FakeOpencodeApiClient()
342344 val events = FakeEventStreamClient()
@@ -357,6 +359,11 @@ class FakeAppContainer : AppContainer() {
357359 return probeResult ?: ProbeResult.Reachable
358360 }
359361
362+ override suspend fun probeWithCredentials(baseUrl: String, username: String, password: String): Boolean {
363+ probeWithCredentialsCalls = probeWithCredentialsCalls + Triple(baseUrl, username, password)
364+ return probeWithCredentialsResult ?: true
365+ }
366+
360367 fun setActiveConnection(conn: OpencodeConnection?) {
361368 fakeActiveConnection.value = conn
362369 }