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
d29e57biko
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+9
@@ -62,6 +62,10 @@ object NetworkConfig {
62 * triggers a full list filter + sort + recomposition, causing scroll jank. */
62 * triggers a full list filter + sort + recomposition, causing scroll jank. */
63 const val sessionUpdateDebounceMs = 300L
63 const val sessionUpdateDebounceMs = 300L
64
64
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+
65 // --- In-memory message store (SessionRepository.MessageStore) ---
69 // --- In-memory message store (SessionRepository.MessageStore) ---
66
70
67 /** Max messages to keep in memory per observed session; oldest are evicted beyond this. */
71 /** Max messages to keep in memory per observed session; oldest are evicted beyond this. */
@@ -82,6 +86,11 @@ object NetworkConfig {
82 /** Debounce delay before persisting a draft to disk after the user stops typing. */
86 /** Debounce delay before persisting a draft to disk after the user stops typing. */
83 const val draftDebounceMs = 500L
87 const val draftDebounceMs = 500L
84
88
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+
85 // --- Profile store (ServerEditViewModel) ---
94 // --- Profile store (ServerEditViewModel) ---
86
95
87 /** Timeout for loading a profile from DataStore before giving up. */
96 /** 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(
126 private val _unread = MutableStateFlow<Set<String>>(emptySet())
126 private val _unread = MutableStateFlow<Set<String>>(emptySet())
127 open val unread: StateFlow<Set<String>> = _unread.asStateFlow()
127 open val unread: StateFlow<Set<String>> = _unread.asStateFlow()
128
128
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+
129 open fun setCurrentSession(id: String?) {
134 open fun setCurrentSession(id: String?) {
130 _currentSession.value = id
135 _currentSession.value = id
131 if (id != null) {
136 if (id != null) {
@@ -233,6 +238,7 @@ open class AppContainer private constructor(
233 if (event is SessionIdle) {
238 if (event is SessionIdle) {
234 val idleSid = event.properties.sessionID ?: return@collect
239 val idleSid = event.properties.sessionID ?: return@collect
235 if (idleSid != _currentSession.value && activeRuns.remove(idleSid)) {
240 if (idleSid != _currentSession.value && activeRuns.remove(idleSid)) {
241+ _anyRunActive.value = activeRuns.isNotEmpty()
236 notifySessionCompleted(idleSid)
242 notifySessionCompleted(idleSid)
237 }
243 }
238 return@collect
244 return@collect
@@ -245,14 +251,12 @@ open class AppContainer private constructor(
245 // represent a finished run worth notifying about.
251 // represent a finished run worth notifying about.
246 if (event is MessagePartUpdated || event is MessageUpdated) {
252 if (event is MessagePartUpdated || event is MessageUpdated) {
247 synchronized(activeRuns) {
253 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.
251 if (activeRuns.size >= activeRunsLimit) {
254 if (activeRuns.size >= activeRunsLimit) {
252 activeRuns.remove(activeRuns.iterator().next())
255 activeRuns.remove(activeRuns.iterator().next())
253 }
256 }
254 activeRuns.add(sid)
257 activeRuns.add(sid)
255 }
258 }
259+ _anyRunActive.value = true
256 }
260 }
257 } }.onFailure { Log.w("AppContainer", "Message activity observer failed, will retry: ${safeExceptionSummary(it)}") }
261 } }.onFailure { Log.w("AppContainer", "Message activity observer failed, will retry: ${safeExceptionSummary(it)}") }
258 if (!isActive) break
262 if (!isActive) break
@@ -333,6 +337,7 @@ open class AppContainer private constructor(
333 _activeConnection.value?.close()
337 _activeConnection.value?.close()
334 _activeConnection.value = null
338 _activeConnection.value = null
335 activeRuns.clear()
339 activeRuns.clear()
340+ _anyRunActive.value = false
336 _unread.value = emptySet()
341 _unread.value = emptySet()
337 val now = System.currentTimeMillis()
342 val now = System.currentTimeMillis()
338 val resolved = profileStore.resolve(profile)
343 val resolved = profileStore.resolve(profile)
@@ -367,14 +372,44 @@ open class AppContainer private constructor(
367 * touch the active connection or the profile store.
372 * touch the active connection or the profile store.
368 */
373 */
369 open suspend fun probeServer(baseUrl: String): ProbeResult {
374 open suspend fun probeServer(baseUrl: String): ProbeResult {
370− val probeProfile = ServerProfile(
375+ return probeWithProfile(ServerProfile(
371 id = "probe",
376 id = "probe",
372 label = "",
377 label = "",
373 baseUrl = baseUrl.trim(),
378 baseUrl = baseUrl.trim(),
374 username = null,
379 username = null,
375 password = null,
380 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() },
376 )
397 )
377 val client = HttpClientFactory.create(probeProfile)
398 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)
378 return try {
413 return try {
379 val api = OpencodeApiClient(client)
414 val api = OpencodeApiClient(client)
380 api.ping()
415 api.ping()
@@ -384,7 +419,7 @@ open class AppContainer private constructor(
384 if (status == 401 || status == 403) {
419 if (status == 401 || status == 403) {
385 ProbeResult.NeedsAuth
420 ProbeResult.NeedsAuth
386 } else {
421 } else {
387− ProbeResult.Unreachable(friendlyErrorFor(e, baseUrl))
422+ ProbeResult.Unreachable(friendlyErrorFor(e, profile.baseUrl))
388 }
423 }
389 } finally {
424 } finally {
390 runCatching { client.close() }
425 runCatching { client.close() }
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+11−1
@@ -164,8 +164,18 @@ fun OpencodeApp(container: AppContainer) {
164 SettingsScreen(
164 SettingsScreen(
165 container = container,
165 container = container,
166 onBack = { navController.popBackStack() },
166 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.
167 onManageServers = {
174 onManageServers = {
168− navController.popBackStack(Routes.SERVERS, inclusive = false)
175+ navController.navigate(Routes.SERVERS) {
176+ popUpTo(Routes.SETTINGS) { inclusive = true }
177+ launchSingleTop = true
178+ }
169 },
179 },
170 onOpenDiagnostics = { navController.navigate(Routes.DIAGNOSTICS) },
180 onOpenDiagnostics = { navController.navigate(Routes.DIAGNOSTICS) },
171 )
181 )
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
26import androidx.compose.material.icons.automirrored.filled.Send
26import androidx.compose.material.icons.automirrored.filled.Send
27import androidx.compose.material.icons.filled.AutoAwesome
27import androidx.compose.material.icons.filled.AutoAwesome
28import androidx.compose.material.icons.filled.KeyboardArrowDown
28import androidx.compose.material.icons.filled.KeyboardArrowDown
29+import androidx.compose.material.icons.filled.Refresh
29import androidx.compose.material.icons.filled.Share
30import androidx.compose.material.icons.filled.Share
30import androidx.compose.material.icons.filled.SmartToy
31import androidx.compose.material.icons.filled.SmartToy
31import androidx.compose.material.icons.filled.Stop
32import androidx.compose.material.icons.filled.Stop
@@ -87,6 +88,7 @@ import kotlinx.coroutines.launch
87import kotlinx.coroutines.withContext
88import kotlinx.coroutines.withContext
88import soy.iko.opencode.data.model.TextPart
89import soy.iko.opencode.data.model.TextPart
89import soy.iko.opencode.data.network.EventStreamClient
90import soy.iko.opencode.data.network.EventStreamClient
91+import soy.iko.opencode.data.network.NetworkConfig
90import soy.iko.opencode.di.AppContainer
92import soy.iko.opencode.di.AppContainer
91import soy.iko.opencode.R
93import soy.iko.opencode.R
92import soy.iko.opencode.ui.components.ConnectionBanner
94import soy.iko.opencode.ui.components.ConnectionBanner
@@ -154,9 +156,15 @@ fun ChatScreen(
154 }
156 }
155
157
156 // Navigate away when the session is deleted via SSE so the user isn't left
158 // 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
158 LaunchedEffect(sessionDeleted) {
163 LaunchedEffect(sessionDeleted) {
159− if (sessionDeleted) onBack()
164+ if (sessionDeleted) {
165+ showToast(deletedContext, deletedContext.getString(R.string.session_deleted_msg))
166+ onBack()
167+ }
160 }
168 }
161
169
162 val snackbar = remember { SnackbarHostState() }
170 val snackbar = remember { SnackbarHostState() }
@@ -165,6 +173,7 @@ fun ChatScreen(
165 var showAgentPicker by rememberSaveable { mutableStateOf(false) }
173 var showAgentPicker by rememberSaveable { mutableStateOf(false) }
166 var showCommandPicker by rememberSaveable { mutableStateOf(false) }
174 var showCommandPicker by rememberSaveable { mutableStateOf(false) }
167 var showExitConfirm by rememberSaveable { mutableStateOf(false) }
175 var showExitConfirm by rememberSaveable { mutableStateOf(false) }
176+ var showStopConfirm by rememberSaveable { mutableStateOf(false) }
168
177
169 // Keep the screen awake and hold a foreground priority while the agent is working,
178 // Keep the screen awake and hold a foreground priority while the agent is working,
170 // so backgrounding mid-run doesn't let Doze choke the SSE stream.
179 // so backgrounding mid-run doesn't let Doze choke the SSE stream.
@@ -249,6 +258,12 @@ fun ChatScreen(
249 strokeWidth = 2.dp,
258 strokeWidth = 2.dp,
250 )
259 )
251 }
260 }
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+ }
252 IconButton(
267 IconButton(
253 onClick = {
268 onClick = {
254 scope.launch {
269 scope.launch {
@@ -282,13 +297,17 @@ fun ChatScreen(
282 },
297 },
283 snackbarHost = { SnackbarHost(snackbar) },
298 snackbarHost = { SnackbarHost(snackbar) },
284 bottomBar = {
299 bottomBar = {
300+ val queuedFollowUp by vm.queuedFollowUp.collectAsStateWithLifecycle()
285 ChatInputBar(
301 ChatInputBar(
286 value = draft,
302 value = draft,
287 onValueChange = vm::updateDraft,
303 onValueChange = vm::updateDraft,
288 running = running,
304 running = running,
289 enabled = activeConnection != null,
305 enabled = activeConnection != null,
290 onSend = ::doSend,
306 onSend = ::doSend,
291− onAbort = { vm.abort() },
307+ onAbort = { showStopConfirm = true },
308+ queuedFollowUp = queuedFollowUp,
309+ onQueueFollowUp = vm::queueFollowUp,
310+ onCancelQueue = { vm.queueFollowUp("") },
292 )
311 )
293 },
312 },
294 ) { padding ->
313 ) { padding ->
@@ -328,7 +347,9 @@ fun ChatScreen(
328 }
347 }
329
348
330 CompositionLocalProvider(LocalRelativeTimeTick provides timeTick) {
349 CompositionLocalProvider(LocalRelativeTimeTick provides timeTick) {
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.
331− Box(modifier = Modifier.fillMaxSize().padding(padding)) {
352+ Box(modifier = Modifier.fillMaxSize().padding(padding).imePadding()) {
332 if (activeConnection == null) {
353 if (activeConnection == null) {
333 Column(
354 Column(
334 modifier = Modifier.align(Alignment.Center).padding(24.dp),
355 modifier = Modifier.align(Alignment.Center).padding(24.dp),
@@ -449,6 +470,23 @@ fun ChatScreen(
449 )
470 )
450 }
471 }
451
472
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+
452 if (showModelPicker) {
490 if (showModelPicker) {
453 ModelPickerSheet(
491 ModelPickerSheet(
454 options = models,
492 options = models,
@@ -500,49 +538,80 @@ private fun ChatInputBar(
500 enabled: Boolean,
538 enabled: Boolean,
501 onSend: () -> Unit,
539 onSend: () -> Unit,
502 onAbort: () -> Unit,
540 onAbort: () -> Unit,
541+ queuedFollowUp: String?,
542+ onQueueFollowUp: (String) -> Unit,
543+ onCancelQueue: () -> Unit,
503) {
544) {
504 Surface(tonalElevation = 3.dp, modifier = Modifier.imePadding()) {
545 Surface(tonalElevation = 3.dp, modifier = Modifier.imePadding()) {
505− Row(
506− modifier = Modifier.fillMaxWidth()
507− .windowInsetsPadding(WindowInsets.navigationBars)
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()
508− .padding(8.dp),
553+ .padding(start = 12.dp, top = 6.dp, end = 4.dp),
509− verticalAlignment = Alignment.Bottom,
554+ verticalAlignment = Alignment.CenterVertically,
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"),
544 ) {
555 ) {
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+ ) {
545− Icon(Icons.AutoMirrored.Filled.Send, contentDescription = stringResource(R.string.send))
613+ Icon(Icons.AutoMirrored.Filled.Send, contentDescription = stringResource(R.string.send))
614+ }
546 }
615 }
547 }
616 }
548 }
617 }
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+40−1
@@ -114,6 +114,12 @@ class ChatViewModel(
114 private val _running = MutableStateFlow(false)
114 private val _running = MutableStateFlow(false)
115 val running: StateFlow<Boolean> = _running.asStateFlow()
115 val running: StateFlow<Boolean> = _running.asStateFlow()
116
116
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+
117 /** One-shot error events surfaced as snackbars. A SharedFlow (not StateFlow) so each
123 /** One-shot error events surfaced as snackbars. A SharedFlow (not StateFlow) so each
118 * emission is delivered independently — two rapid failures both get a snackbar
124 * emission is delivered independently — two rapid failures both get a snackbar
119 * instead of the second silently overwriting the first. */
125 * instead of the second silently overwriting the first. */
@@ -285,13 +291,24 @@ class ChatViewModel(
285 _running.value = false
291 _running.value = false
286 _pendingPermission.value = null
292 _pendingPermission.value = null
287 _failedDraft.value = null
293 _failedDraft.value = null
294+ _queuedFollowUp.value = null
288 _selectedAgent.value = null
295 _selectedAgent.value = null
289 _sessionTitle.value = null
296 _sessionTitle.value = null
290 try {
297 try {
291 conn.events.events.collect { event ->
298 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+ }
293 if (SessionRepository.isError(event, sessionId)) {
309 if (SessionRepository.isError(event, sessionId)) {
294 _running.value = false
310 _running.value = false
311+ _queuedFollowUp.value = null
295 _errorEvents.tryEmit(container.string(R.string.error_agent_reported))
312 _errorEvents.tryEmit(container.string(R.string.error_agent_reported))
296 }
313 }
297 when (event) {
314 when (event) {
@@ -435,6 +452,28 @@ class ChatViewModel(
435 }
452 }
436 }
453 }
437
454
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+
438 /** Invoke a slash-command by name via the server's /command endpoint. */
477 /** Invoke a slash-command by name via the server's /command endpoint. */
439 fun runCommand(command: Command) {
478 fun runCommand(command: Command) {
440 val conn = connection ?: return
479 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)
142 TextButton(
142 TextButton(
143 onClick = { copyToClipboard(context, "reasoning", text) },
143 onClick = { copyToClipboard(context, "reasoning", text) },
144 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
144 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
145+ modifier = Modifier.semantics(mergeDescendants = true) {},
145 ) {
146 ) {
146 Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(14.dp))
147 Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(14.dp))
147 Text(stringResource(R.string.copy), style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(start = 4.dp))
148 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) {
217 TextButton(
218 TextButton(
218 onClick = { copyToClipboard(context, "output", detail) },
219 onClick = { copyToClipboard(context, "output", detail) },
219 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
220 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
221+ modifier = Modifier.semantics(mergeDescendants = true) {},
220 ) {
222 ) {
221 Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(14.dp))
223 Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(14.dp))
222 Text(stringResource(R.string.copy), style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(start = 4.dp))
224 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
5import androidx.compose.foundation.layout.Box
5import androidx.compose.foundation.layout.Box
6import androidx.compose.foundation.layout.Column
6import androidx.compose.foundation.layout.Column
7import androidx.compose.foundation.layout.Row
7import androidx.compose.foundation.layout.Row
8+import androidx.compose.foundation.layout.defaultMinSize
8import androidx.compose.foundation.layout.fillMaxSize
9import androidx.compose.foundation.layout.fillMaxSize
9import androidx.compose.foundation.layout.fillMaxWidth
10import androidx.compose.foundation.layout.fillMaxWidth
10import androidx.compose.foundation.layout.padding
11import androidx.compose.foundation.layout.padding
@@ -65,6 +66,7 @@ fun FileBrowserScreen(
65) {
66) {
66 val vm: FileBrowserViewModel = viewModel(factory = vmFactory { FileBrowserViewModel(container) })
67 val vm: FileBrowserViewModel = viewModel(factory = vmFactory { FileBrowserViewModel(container) })
67 val state by vm.state.collectAsStateWithLifecycle()
68 val state by vm.state.collectAsStateWithLifecycle()
69+ val refreshing by vm.refreshing.collectAsStateWithLifecycle()
68 val snackbar = remember { SnackbarHostState() }
70 val snackbar = remember { SnackbarHostState() }
69
71
70 LaunchedEffect(Unit) {
72 LaunchedEffect(Unit) {
@@ -104,34 +106,40 @@ fun FileBrowserScreen(
104 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
106 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
105 )
107 )
106
108
109+ androidx.compose.material3.pulltorefresh.PullToRefreshBox(
110+ isRefreshing = refreshing,
111+ onRefresh = { vm.refresh() },
112+ modifier = Modifier.fillMaxSize(),
113+ ) {
107− Box(modifier = Modifier.fillMaxSize()) {
114+ Box(modifier = Modifier.fillMaxSize()) {
108− when {
115+ when {
109− state.loading || state.searching -> {
116+ state.loading || state.searching -> {
110− val loadingLabel = stringResource(R.string.loading)
117+ val loadingLabel = stringResource(R.string.loading)
111− CircularProgressIndicator(
118+ CircularProgressIndicator(
112− Modifier.align(Alignment.Center).semantics { contentDescription = loadingLabel },
119+ Modifier.align(Alignment.Center).semantics { contentDescription = loadingLabel },
113− )
120+ )
114− }
121+ }
115− state.error != null -> Column(
122+ state.error != null -> Column(
116− modifier = Modifier.align(Alignment.Center).padding(24.dp),
123+ modifier = Modifier.align(Alignment.Center).padding(24.dp),
117− horizontalAlignment = Alignment.CenterHorizontally,
124+ horizontalAlignment = Alignment.CenterHorizontally,
118− ) {
125+ ) {
119− Text(
126+ Text(
120− state.error ?: "",
127+ state.error ?: "",
121− color = MaterialTheme.colorScheme.error,
128+ color = MaterialTheme.colorScheme.error,
122− )
129+ )
123− androidx.compose.foundation.layout.Spacer(Modifier.size(12.dp))
130+ androidx.compose.foundation.layout.Spacer(Modifier.size(12.dp))
124− androidx.compose.material3.TextButton(onClick = { vm.open(state.path) }) {
131+ androidx.compose.material3.TextButton(onClick = { vm.open(state.path) }) {
125− Text(stringResource(R.string.retry))
132+ Text(stringResource(R.string.retry))
133+ }
126 }
134 }
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+ )
127 }
142 }
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− )
135 }
143 }
136 }
144 }
137 }
145 }
@@ -180,7 +188,16 @@ private fun SearchResults(results: List<String>, onOpenFile: (String) -> Unit) {
180 }
188 }
181 LazyColumn(modifier = Modifier.fillMaxSize()) {
189 LazyColumn(modifier = Modifier.fillMaxSize()) {
182 items(results, key = { it }) { path ->
190 items(results, key = { it }) { 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() },
183− FileRow(icon = false, label = path, onClick = { onOpenFile(path) })
199+ onClick = { onOpenFile(path) },
200+ )
184 HorizontalDivider()
201 HorizontalDivider()
185 }
202 }
186 }
203 }
@@ -226,6 +243,7 @@ private fun FileRow(
226 label: String,
243 label: String,
227 onClick: () -> Unit,
244 onClick: () -> Unit,
228 status: FileStatusEntry? = null,
245 status: FileStatusEntry? = null,
246+ sublabel: String? = null,
229) {
247) {
230 val desc = if (label == "..") stringResource(R.string.parent_dir)
248 val desc = if (label == "..") stringResource(R.string.parent_dir)
231 else if (icon) stringResource(R.string.folder, label)
249 else if (icon) stringResource(R.string.folder, label)
@@ -233,6 +251,7 @@ private fun FileRow(
233 Row(
251 Row(
234 modifier = Modifier
252 modifier = Modifier
235 .fillMaxWidth()
253 .fillMaxWidth()
254+ .defaultMinSize(minHeight = 48.dp)
236 .clickable(role = Role.Button, onClick = onClick)
255 .clickable(role = Role.Button, onClick = onClick)
237 .semantics(mergeDescendants = true) { contentDescription = desc }
256 .semantics(mergeDescendants = true) { contentDescription = desc }
238 .padding(horizontal = 16.dp, vertical = 14.dp),
257 .padding(horizontal = 16.dp, vertical = 14.dp),
@@ -244,13 +263,23 @@ private fun FileRow(
244 modifier = Modifier.size(20.dp),
263 modifier = Modifier.size(20.dp),
245 tint = if (icon) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
264 tint = if (icon) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
246 )
265 )
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),
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,
253− )
280+ )
281+ }
282+ }
254 if (status != null) StatusBadge(status)
283 if (status != null) StatusBadge(status)
255 }
284 }
256}
285}
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserViewModel.kt+17
@@ -45,6 +45,10 @@ class FileBrowserViewModel(private val container: AppContainer) : ViewModel() {
45 private val _state = MutableStateFlow(FileBrowserState())
45 private val _state = MutableStateFlow(FileBrowserState())
46 val state: StateFlow<FileBrowserState> = _state.asStateFlow()
46 val state: StateFlow<FileBrowserState> = _state.asStateFlow()
47
47
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+
48 /** One-shot transient errors (failed VCS status fetch, etc.) surfaced as snackbars without
52 /** One-shot transient errors (failed VCS status fetch, etc.) surfaced as snackbars without
49 * hiding the file list. A SharedFlow (not StateFlow) so each emission is delivered independently. */
53 * hiding the file list. A SharedFlow (not StateFlow) so each emission is delivered independently. */
50 private val _transientErrors = MutableSharedFlow<String>(
54 private val _transientErrors = MutableSharedFlow<String>(
@@ -94,6 +98,19 @@ class FileBrowserViewModel(private val container: AppContainer) : ViewModel() {
94 }
98 }
95 }
99 }
96
100
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+
97 fun open(path: String) {
114 fun open(path: String) {
98 val client = api ?: run {
115 val client = api ?: run {
99 _state.update { it.copy(
116 _state.update { it.copy(
app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+212−92
@@ -1,9 +1,11 @@
1package soy.iko.opencode.ui.server
1package soy.iko.opencode.ui.server
2
2
3+import androidx.activity.compose.BackHandler
3import androidx.compose.animation.AnimatedVisibility
4import androidx.compose.animation.AnimatedVisibility
4import androidx.compose.foundation.layout.Arrangement
5import androidx.compose.foundation.layout.Arrangement
5import androidx.compose.foundation.layout.Box
6import androidx.compose.foundation.layout.Box
6import androidx.compose.foundation.layout.Column
7import androidx.compose.foundation.layout.Column
8+import androidx.compose.foundation.layout.Row
7import androidx.compose.foundation.layout.fillMaxSize
9import androidx.compose.foundation.layout.fillMaxSize
8import androidx.compose.foundation.layout.fillMaxWidth
10import androidx.compose.foundation.layout.fillMaxWidth
9import androidx.compose.foundation.layout.imePadding
11import androidx.compose.foundation.layout.imePadding
@@ -16,6 +18,9 @@ import androidx.compose.foundation.text.KeyboardOptions
16import androidx.compose.foundation.verticalScroll
18import androidx.compose.foundation.verticalScroll
17import androidx.compose.material.icons.Icons
19import androidx.compose.material.icons.Icons
18import androidx.compose.material.icons.automirrored.filled.ArrowBack
20import 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
19import androidx.compose.material3.Button
24import androidx.compose.material3.Button
20import androidx.compose.material3.CircularProgressIndicator
25import androidx.compose.material3.CircularProgressIndicator
21import androidx.compose.material3.ExperimentalMaterial3Api
26import androidx.compose.material3.ExperimentalMaterial3Api
@@ -26,9 +31,14 @@ import androidx.compose.material3.OutlinedButton
26import androidx.compose.material3.OutlinedTextField
31import androidx.compose.material3.OutlinedTextField
27import androidx.compose.material3.Scaffold
32import androidx.compose.material3.Scaffold
28import androidx.compose.material3.Text
33import androidx.compose.material3.Text
34+import androidx.compose.material3.TextButton
29import androidx.compose.material3.TopAppBar
35import androidx.compose.material3.TopAppBar
30import androidx.compose.runtime.Composable
36import androidx.compose.runtime.Composable
31import androidx.compose.runtime.getValue
37import 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
32import androidx.compose.ui.Alignment
42import androidx.compose.ui.Alignment
33import androidx.compose.ui.Modifier
43import androidx.compose.ui.Modifier
34import androidx.compose.ui.platform.testTag
44import androidx.compose.ui.platform.testTag
@@ -37,6 +47,7 @@ import androidx.compose.ui.semantics.semantics
37import androidx.compose.ui.text.input.ImeAction
47import androidx.compose.ui.text.input.ImeAction
38import androidx.compose.ui.text.input.KeyboardType
48import androidx.compose.ui.text.input.KeyboardType
39import androidx.compose.ui.text.input.PasswordVisualTransformation
49import androidx.compose.ui.text.input.PasswordVisualTransformation
50+import androidx.compose.ui.text.input.VisualTransformation
40import androidx.compose.ui.res.stringResource
51import androidx.compose.ui.res.stringResource
41import androidx.compose.ui.unit.dp
52import androidx.compose.ui.unit.dp
42import androidx.lifecycle.compose.collectAsStateWithLifecycle
53import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -55,13 +66,29 @@ fun ServerEditScreen(
55 val vm: ServerEditViewModel =
66 val vm: ServerEditViewModel =
56 viewModel(factory = vmFactory { ServerEditViewModel(container, profileId) })
67 viewModel(factory = vmFactory { ServerEditViewModel(container, profileId) })
57 val state by vm.state.collectAsStateWithLifecycle()
68 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+ }
58
85
59 Scaffold(
86 Scaffold(
60 topBar = {
87 topBar = {
61 TopAppBar(
88 TopAppBar(
62 title = { Text(if (state.isNew) stringResource(R.string.add_server) else stringResource(R.string.edit_server)) },
89 title = { Text(if (state.isNew) stringResource(R.string.add_server) else stringResource(R.string.edit_server)) },
63 navigationIcon = {
90 navigationIcon = {
64− IconButton(onClick = onDone) {
91+ IconButton(onClick = ::safeExit) {
65 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
92 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
66 }
93 }
67 },
94 },
@@ -74,98 +101,117 @@ fun ServerEditScreen(
74 CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
101 CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
75 }
102 }
76 } else {
103 } else {
77− Column(
104+ ServerEditForm(
78− modifier = Modifier
105+ state = state,
79− .padding(padding)
106+ passwordVisible = passwordVisible,
80− .imePadding()
107+ onTogglePassword = { passwordVisible = !passwordVisible },
81− .widthIn(max = 600.dp)
108+ padding = padding,
82− .padding(16.dp)
109+ vm = vm,
83− .verticalScroll(rememberScrollState()),
110+ onDone = onDone,
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"),
93 )
111 )
94− OutlinedTextField(
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(
95− value = state.baseUrl,
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,
96− onValueChange = { v -> vm.update { it.copy(baseUrl = v, error = null) } },
161+ onValueChange = { v -> vm.update { it.copy(baseUrl = v, error = null) } },
97− label = { Text(stringResource(R.string.base_url)) },
162+ label = { Text(stringResource(R.string.base_url)) },
98− placeholder = { Text(stringResource(R.string.base_url_hint)) },
163+ placeholder = { Text(stringResource(R.string.base_url_hint)) },
99− singleLine = true,
164+ singleLine = true,
100− isError = state.baseUrl.isNotBlank() && !isValidUrl(state.baseUrl),
165+ isError = state.baseUrl.isNotBlank() && !isValidUrl(state.baseUrl),
101− supportingText = {
166+ supportingText = {
102− if (state.baseUrl.isNotBlank() && !isValidUrl(state.baseUrl)) {
167+ if (state.baseUrl.isNotBlank() && !isValidUrl(state.baseUrl)) {
103− Text(stringResource(R.string.invalid_url))
168+ 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))
125 }
169 }
126− }
170+ },
127− // Guide users through the probe-for-auth flow: auth fields only appear
171+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
128− // after a probe detects they're required, so a user who doesn't know to
172+ modifier = Modifier.fillMaxWidth().testTag("server_url"),
129− // tap "Check connectivity" would otherwise be confused.
173+ )
130− if (!state.authFieldsVisible) {
174+ OutlinedButton(
131− Text(
175+ onClick = { vm.probe() },
132− stringResource(R.string.auth_help),
176+ enabled = state.canSave && !state.probing && !state.saving,
133− style = MaterialTheme.typography.bodySmall,
177+ modifier = Modifier.fillMaxWidth().testTag("server_probe"),
134− color = MaterialTheme.colorScheme.onSurfaceVariant,
178+ ) {
135− modifier = Modifier.fillMaxWidth(),
179+ if (state.probing) {
180+ CircularProgressIndicator(
181+ modifier = Modifier.size(20.dp),
182+ strokeWidth = 2.dp,
136 )
183 )
184+ Text(stringResource(R.string.checking), modifier = Modifier.padding(start = 8.dp))
185+ } else {
186+ Text(stringResource(R.string.check_connectivity))
137 }
187 }
188+ }
138− AnimatedVisibility(visible = state.authFieldsVisible) {
189+ if (!state.authFieldsVisible) {
139− Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
140− Text(
190+ Text(
141− stringResource(R.string.auth_required),
191+ stringResource(R.string.auth_help),
142− style = MaterialTheme.typography.bodySmall,
192+ style = MaterialTheme.typography.bodySmall,
143− color = MaterialTheme.colorScheme.error,
193+ color = MaterialTheme.colorScheme.onSurfaceVariant,
144− )
194+ modifier = Modifier.fillMaxWidth(),
145− OutlinedTextField(
195+ )
146− value = state.username,
196+ }
147− onValueChange = { v -> vm.update { it.copy(username = v) } },
197+ AnimatedVisibility(visible = state.authFieldsVisible) {
148− label = { Text(stringResource(R.string.username_optional)) },
198+ AuthFields(
149− singleLine = true,
199+ state = state,
150− keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
200+ passwordVisible = passwordVisible,
151− modifier = Modifier.fillMaxWidth().testTag("server_username"),
201+ onTogglePassword = onTogglePassword,
152− )
202+ onUpdate = vm::update,
153− OutlinedTextField(
203+ onTestCredentials = vm::testCredentials,
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) }),
204+ onImeDone = { if (state.canSave && !state.saving) vm.saveAndConnect(onDone) },
205+ )
206+ }
207+ Row(
161− modifier = Modifier.fillMaxWidth().testTag("server_password"),
208+ modifier = Modifier.fillMaxWidth(),
209+ horizontalArrangement = Arrangement.spacedBy(12.dp),
162− )
210+ ) {
163− }
164− }
165 Button(
211 Button(
166 onClick = { vm.save(onDone) },
212 onClick = { vm.save(onDone) },
167 enabled = state.canSave && !state.saving,
213 enabled = state.canSave && !state.saving,
168− modifier = Modifier.fillMaxWidth(),
214+ modifier = Modifier.weight(1f),
169 ) {
215 ) {
170 if (state.saving) {
216 if (state.saving) {
171 CircularProgressIndicator(
217 CircularProgressIndicator(
@@ -178,15 +224,89 @@ fun ServerEditScreen(
178 Text(stringResource(R.string.save))
224 Text(stringResource(R.string.save))
179 }
225 }
180 }
226 }
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+ }
181− state.error?.let { message ->
235+ state.error?.let { message ->
182− Text(
236+ Text(
183− message,
237+ message,
184− color = androidx.compose.material3.MaterialTheme.colorScheme.error,
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),
185− style = androidx.compose.material3.MaterialTheme.typography.bodySmall,
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() }),
186− modifier = Modifier.fillMaxWidth(),
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,
187 )
298 )
299+ Text(stringResource(R.string.testing_credentials), modifier = Modifier.padding(start = 8.dp))
300+ } else {
301+ Text(stringResource(R.string.test_credentials))
188 }
302 }
189 }
303 }
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+ )
190 }
310 }
191 }
311 }
192}
312}
app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+94−31
@@ -32,11 +32,34 @@ data class ServerEditState(
32 val authFieldsVisible: Boolean = false,
32 val authFieldsVisible: Boolean = false,
33 /** Whether a connectivity/auth probe is in progress. */
33 /** Whether a connectivity/auth probe is in progress. */
34 val probing: Boolean = false,
34 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,
35) {
41) {
36 val canSave: Boolean get() = baseUrl.isNotBlank() && isValidUrl(baseUrl)
42 val canSave: Boolean get() = baseUrl.isNotBlank() && isValidUrl(baseUrl)
37 val isNew: Boolean get() = id == null
43 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+ }
38}
53}
39
54
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+
40fun isValidUrl(url: String): Boolean = try {
63fun isValidUrl(url: String): Boolean = try {
41 val u = java.net.URI(url.trim())
64 val u = java.net.URI(url.trim())
42 u.scheme != null && u.host != null && (u.scheme == "http" || u.scheme == "https")
65 u.scheme != null && u.host != null && (u.scheme == "http" || u.scheme == "https")
@@ -55,14 +78,18 @@ class ServerEditViewModel(
55 init {
78 init {
56 viewModelScope.launch {
79 viewModelScope.launch {
57 if (profileId != null) {
80 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.
60 val existing = withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
81 val existing = withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
61 container.profileStore.profiles
82 container.profileStore.profiles
62 .first { it.isNotEmpty() || it.none { p -> p.id == profileId } }
83 .first { it.isNotEmpty() || it.none { p -> p.id == profileId } }
63 .firstOrNull { it.id == profileId }
84 .firstOrNull { it.id == profileId }
64 }
85 }
65 if (existing != null) {
86 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+ )
66 _state.value = ServerEditState(
93 _state.value = ServerEditState(
67 id = existing.id,
94 id = existing.id,
68 label = existing.label,
95 label = existing.label,
@@ -70,14 +97,11 @@ class ServerEditViewModel(
70 username = existing.username.orEmpty(),
97 username = existing.username.orEmpty(),
71 password = existing.password.orEmpty(),
98 password = existing.password.orEmpty(),
72 loaded = true,
99 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.
75 authFieldsVisible = existing.hasAuth,
100 authFieldsVisible = existing.hasAuth,
101+ initial = init,
76 )
102 )
77 return@launch
103 return@launch
78 }
104 }
79− // Profile existed but timed out loading — show an error instead of
80− // silently presenting an empty form that looks like "new server".
81 _state.value = ServerEditState(
105 _state.value = ServerEditState(
82 id = profileId,
106 id = profileId,
83 loaded = true,
107 loaded = true,
@@ -85,25 +109,25 @@ class ServerEditViewModel(
85 )
109 )
86 return@launch
110 return@launch
87 }
111 }
112+ // New-profile form: seed an initial snapshot of empty values so isDirty
113+ // becomes true the moment the user types anything.
88− _state.update { it.copy(loaded = true) }
114+ _state.update {
115+ it.copy(
116+ loaded = true,
117+ initial = InitialProfile("", "", "", ""),
118+ )
119+ }
89 }
120 }
90 }
121 }
91
122
92 fun update(transform: (ServerEditState) -> ServerEditState) {
123 fun update(transform: (ServerEditState) -> ServerEditState) {
93− _state.update(transform)
124+ _state.update { transform(it).copy(credentialsResult = null) }
94 }
125 }
95
126
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− */
103 fun probe() {
127 fun probe() {
104 val s = _state.value
128 val s = _state.value
105 if (!s.canSave || s.probing) return
129 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) }
107 viewModelScope.launch {
131 viewModelScope.launch {
108 val result = runCatchingCancellable { container.probeServer(s.baseUrl) }
132 val result = runCatchingCancellable { container.probeServer(s.baseUrl) }
109 result.onSuccess { pr ->
133 result.onSuccess { pr ->
@@ -132,34 +156,73 @@ class ServerEditViewModel(
132 }
156 }
133 }
157 }
134
158
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+
135 fun save(onDone: () -> Unit) {
192 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) {
136 val s = _state.value
202 val s = _state.value
137 if (!s.canSave || s.saving) return
203 if (!s.canSave || s.saving) return
138 _state.update { it.copy(saving = true) }
204 _state.update { it.copy(saving = true) }
139 viewModelScope.launch {
205 viewModelScope.launch {
140 val result = runCatchingCancellable {
206 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.
147 val existingLastUsed = if (s.id != null) {
207 val existingLastUsed = if (s.id != null) {
148 withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
208 withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
149 container.profileStore.profiles.first()
209 container.profileStore.profiles.first()
150 .firstOrNull { it.id == s.id }?.lastUsed
210 .firstOrNull { it.id == s.id }?.lastUsed
151 } ?: 0L
211 } ?: 0L
152 } else 0L
212 } else 0L
153− container.profileStore.save(
154− ServerProfile(
213+ val saved = ServerProfile(
155− id = s.id ?: UUID.randomUUID().toString(),
214+ id = s.id ?: UUID.randomUUID().toString(),
156− label = s.label.trim(),
215+ label = s.label.trim(),
157− baseUrl = s.baseUrl.trim(),
216+ baseUrl = s.baseUrl.trim(),
158− username = s.username.trim().takeIf { it.isNotBlank() },
217+ username = s.username.trim().takeIf { it.isNotBlank() },
159− password = s.password.trim().takeIf { it.isNotEmpty() },
218+ password = s.password.trim().takeIf { it.isNotEmpty() },
160− lastUsed = existingLastUsed,
219+ lastUsed = existingLastUsed,
161− ),
162 )
220 )
221+ container.profileStore.save(saved)
222+ if (connectAfter) {
223+ container.connect(saved)
224+ container.activeConnection.value?.api?.ping()
225+ }
163 }
226 }
164 result.onSuccess { onDone() }
227 result.onSuccess { onDone() }
165 .onFailure { e -> _state.update { it.copy(error = container.friendlyError(e), saving = false) } }
228 .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(
72 val reconnecting by container.reconnecting.collectAsStateWithLifecycle()
72 val reconnecting by container.reconnecting.collectAsStateWithLifecycle()
73 val haptics = LocalHapticFeedback.current
73 val haptics = LocalHapticFeedback.current
74 val snackbar = remember { SnackbarHostState() }
74 val snackbar = remember { SnackbarHostState() }
75+ val retryLabel = stringResource(R.string.retry)
75 var pendingDeleteId by rememberSaveable { mutableStateOf<String?>(null) }
76 var pendingDeleteId by rememberSaveable { mutableStateOf<String?>(null) }
76 val connectedId = activeConnection?.profile?.id
77 val connectedId = activeConnection?.profile?.id
77
78
78 LaunchedEffect(Unit) {
79 LaunchedEffect(Unit) {
79− vm.errorEvents.collect { msg ->
80+ vm.errorEvents.collect { event ->
81+ val result = if (event.profile != null) {
80− snackbar.showSnackbar(msg)
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+ }
81 }
89 }
82 }
90 }
83
91
@@ -159,6 +167,12 @@ fun ServerListScreen(
159 tint = MaterialTheme.colorScheme.primary,
167 tint = MaterialTheme.colorScheme.primary,
160 modifier = Modifier.size(22.dp),
168 modifier = Modifier.size(22.dp),
161 )
169 )
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+ }
162 } else if (connectingId == profile.id) {
176 } else if (connectingId == profile.id) {
163 val connectingLabel = stringResource(R.string.connecting)
177 val connectingLabel = stringResource(R.string.connecting)
164 CircularProgressIndicator(
178 CircularProgressIndicator(
@@ -181,10 +195,16 @@ fun ServerListScreen(
181 }
195 }
182
196
183 profiles.find { it.id == pendingDeleteId }?.let { profile ->
197 profiles.find { it.id == pendingDeleteId }?.let { profile ->
198+ val isActiveProfile = profile.id == connectedId
184 AlertDialog(
199 AlertDialog(
185 onDismissRequest = { pendingDeleteId = null },
200 onDismissRequest = { pendingDeleteId = null },
186 title = { Text(stringResource(R.string.remove_server_title)) },
201 title = { Text(stringResource(R.string.remove_server_title)) },
202+ text = {
203+ Text(
204+ if (isActiveProfile) stringResource(R.string.remove_server_active_text, profile.displayLabel)
187− text = { Text(stringResource(R.string.remove_server_text, profile.displayLabel)) },
205+ else stringResource(R.string.remove_server_text, profile.displayLabel),
206+ )
207+ },
188 confirmButton = {
208 confirmButton = {
189 TextButton(onClick = {
209 TextButton(onClick = {
190 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
210 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
18import kotlinx.coroutines.flow.stateIn
18import kotlinx.coroutines.flow.stateIn
19import kotlinx.coroutines.launch
19import kotlinx.coroutines.launch
20
20
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+
21class ServerListViewModel(private val container: AppContainer) : ViewModel() {
25class ServerListViewModel(private val container: AppContainer) : ViewModel() {
22
26
23 val profiles: StateFlow<List<ServerProfile>> =
27 val profiles: StateFlow<List<ServerProfile>> =
@@ -32,11 +36,11 @@ class ServerListViewModel(private val container: AppContainer) : ViewModel() {
32
36
33 /** One-shot error events surfaced as snackbars. A SharedFlow (not StateFlow) so each
37 /** One-shot error events surfaced as snackbars. A SharedFlow (not StateFlow) so each
34 * emission is delivered independently. */
38 * emission is delivered independently. */
35− private val _errorEvents = MutableSharedFlow<String>(
39+ private val _errorEvents = MutableSharedFlow<ConnectError>(
36 extraBufferCapacity = NetworkConfig.snackbarEventBufferCapacity,
40 extraBufferCapacity = NetworkConfig.snackbarEventBufferCapacity,
37 onBufferOverflow = BufferOverflow.DROP_OLDEST,
41 onBufferOverflow = BufferOverflow.DROP_OLDEST,
38 )
42 )
39− val errorEvents: SharedFlow<String> = _errorEvents.asSharedFlow()
43+ val errorEvents: SharedFlow<ConnectError> = _errorEvents.asSharedFlow()
40
44
41 fun connect(profile: ServerProfile, onConnected: () -> Unit) {
45 fun connect(profile: ServerProfile, onConnected: () -> Unit) {
42 if (_connecting.value != null) return
46 if (_connecting.value != null) return
@@ -50,7 +54,7 @@ class ServerListViewModel(private val container: AppContainer) : ViewModel() {
50 result.onSuccess { onConnected() }
54 result.onSuccess { onConnected() }
51 .onFailure {
55 .onFailure {
52 container.disconnect()
56 container.disconnect()
53− _errorEvents.tryEmit(container.friendlyError(it))
57+ _errorEvents.tryEmit(ConnectError(container.friendlyError(it), profile))
54 }
58 }
55 } finally {
59 } finally {
56 _connecting.value = null
60 _connecting.value = null
@@ -60,13 +64,11 @@ class ServerListViewModel(private val container: AppContainer) : ViewModel() {
60
64
61 fun delete(profile: ServerProfile) {
65 fun delete(profile: ServerProfile) {
62 viewModelScope.launch {
66 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.
65 if (container.activeConnection.value?.profile?.id == profile.id) {
67 if (container.activeConnection.value?.profile?.id == profile.id) {
66 container.disconnect()
68 container.disconnect()
67 }
69 }
68 runCatchingCancellable { container.profileStore.delete(profile.id) }
70 runCatchingCancellable { container.profileStore.delete(profile.id) }
69− .onFailure { _errorEvents.tryEmit(container.friendlyError(it)) }
71+ .onFailure { _errorEvents.tryEmit(ConnectError(container.friendlyError(it), null)) }
70 }
72 }
71 }
73 }
72}
74}
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
22import androidx.compose.material.icons.filled.Add
22import androidx.compose.material.icons.filled.Add
23import androidx.compose.material.icons.filled.ArrowDropDown
23import androidx.compose.material.icons.filled.ArrowDropDown
24import androidx.compose.material.icons.filled.ChatBubbleOutline
24import androidx.compose.material.icons.filled.ChatBubbleOutline
25+import androidx.compose.material.icons.filled.CheckCircle
25import androidx.compose.material.icons.filled.Delete
26import androidx.compose.material.icons.filled.Delete
26import androidx.compose.material.icons.filled.Edit
27import androidx.compose.material.icons.filled.Edit
27import androidx.compose.material.icons.filled.Folder
28import androidx.compose.material.icons.filled.Folder
@@ -72,6 +73,7 @@ import androidx.compose.ui.unit.dp
72import androidx.compose.ui.res.stringResource
73import androidx.compose.ui.res.stringResource
73import androidx.lifecycle.compose.collectAsStateWithLifecycle
74import androidx.lifecycle.compose.collectAsStateWithLifecycle
74import androidx.lifecycle.viewmodel.compose.viewModel
75import androidx.lifecycle.viewmodel.compose.viewModel
76+import soy.iko.opencode.data.model.ServerProfile
75import soy.iko.opencode.data.model.Session
77import soy.iko.opencode.data.model.Session
76import soy.iko.opencode.di.AppContainer
78import soy.iko.opencode.di.AppContainer
77import soy.iko.opencode.R
79import soy.iko.opencode.R
@@ -98,14 +100,21 @@ fun SessionListScreen(
98 val switchingId by vm.switchingId.collectAsStateWithLifecycle()
100 val switchingId by vm.switchingId.collectAsStateWithLifecycle()
99 val connectionState by vm.connectionState.collectAsStateWithLifecycle()
101 val connectionState by vm.connectionState.collectAsStateWithLifecycle()
100 val unread by vm.unread.collectAsStateWithLifecycle()
102 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
101 val haptics = LocalHapticFeedback.current
107 val haptics = LocalHapticFeedback.current
102 val snackbar = remember { SnackbarHostState() }
108 val snackbar = remember { SnackbarHostState() }
109+ val undoLabel = stringResource(R.string.undo)
110+ val sessionDeletedLabel = stringResource(R.string.session_deleted)
103 // One shared timer drives every relative-time label in the session list instead of
111 // One shared timer drives every relative-time label in the session list instead of
104 // each card spinning up its own coroutine + lifecycle observer while scrolling.
112 // each card spinning up its own coroutine + lifecycle observer while scrolling.
105 val timeTick = rememberRelativeTimeTick()
113 val timeTick = rememberRelativeTimeTick()
106 var showServerMenu by rememberSaveable { mutableStateOf(false) }
114 var showServerMenu by rememberSaveable { mutableStateOf(false) }
107 var pendingDeleteId by rememberSaveable { mutableStateOf<String?>(null) }
115 var pendingDeleteId by rememberSaveable { mutableStateOf<String?>(null) }
108 var pendingRenameId by rememberSaveable { mutableStateOf<String?>(null) }
116 var pendingRenameId by rememberSaveable { mutableStateOf<String?>(null) }
117+ var showDisconnectConfirm by rememberSaveable { mutableStateOf(false) }
109 val pendingDelete = pendingDeleteId?.let { id -> state.sessions.firstOrNull { it.id == id } }
118 val pendingDelete = pendingDeleteId?.let { id -> state.sessions.firstOrNull { it.id == id } }
110 val pendingRename = pendingRenameId?.let { id -> state.sessions.firstOrNull { it.id == id } }
119 val pendingRename = pendingRenameId?.let { id -> state.sessions.firstOrNull { it.id == id } }
111
120
@@ -118,63 +127,39 @@ fun SessionListScreen(
118 }
127 }
119 }
128 }
120
129
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+
121 Scaffold(
146 Scaffold(
122 topBar = {
147 topBar = {
123 TopAppBar(
148 TopAppBar(
124 title = {
149 title = {
125− Column {
150+ ServerSwitcherMenu(
126− Box {
151+ serverLabel = serverLabel,
127− Row(
152+ profiles = profiles,
128− modifier = Modifier
153+ connectedId = connectedId,
129− .clickable(role = Role.Button) { showServerMenu = true },
154+ switchingId = switchingId,
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,
155+ expanded = showServerMenu,
137− onDismissRequest = { showServerMenu = false },
156+ onExpand = { showServerMenu = true },
138− ) {
157+ onDismiss = { showServerMenu = false },
139− profiles.forEach { profile ->
158+ onSelect = { 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
159+ showServerMenu = false
171− vm.switchServer(profile)
160+ vm.switchServer(profile)
172− },
161+ },
173− )
162+ )
174− }
175− }
176− }
177− }
178 },
163 },
179 actions = {
164 actions = {
180 IconButton(onClick = { vm.refresh() }) {
165 IconButton(onClick = { vm.refresh() }) {
@@ -186,7 +171,11 @@ fun SessionListScreen(
186 IconButton(onClick = onOpenSettings) {
171 IconButton(onClick = onOpenSettings) {
187 Icon(Icons.Filled.Settings, contentDescription = stringResource(R.string.settings))
172 Icon(Icons.Filled.Settings, contentDescription = stringResource(R.string.settings))
188 }
173 }
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+ }) {
190 Icon(Icons.AutoMirrored.Filled.Logout, contentDescription = stringResource(R.string.disconnect))
179 Icon(Icons.AutoMirrored.Filled.Logout, contentDescription = stringResource(R.string.disconnect))
191 }
180 }
192 },
181 },
@@ -198,6 +187,10 @@ fun SessionListScreen(
198 onClick = { vm.createSession(onCreated = onOpenSession) },
187 onClick = { vm.createSession(onCreated = onOpenSession) },
199 icon = { Icon(Icons.Filled.Add, contentDescription = null) },
188 icon = { Icon(Icons.Filled.Add, contentDescription = null) },
200 text = { Text(stringResource(R.string.new_session)) },
189 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,
201 )
194 )
202 },
195 },
203 ) { padding ->
196 ) { padding ->
@@ -275,6 +268,10 @@ fun SessionListScreen(
275 // and the dialog guards against accidental data loss.
268 // and the dialog guards against accidental data loss.
276 val swipeState = rememberSwipeToDismissBoxState(
269 val swipeState = rememberSwipeToDismissBoxState(
277 confirmValueChange = {
270 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)
278 pendingDeleteId = session.id
275 pendingDeleteId = session.id
279 false
276 false
280 },
277 },
@@ -350,6 +347,23 @@ fun SessionListScreen(
350 onDismiss = { pendingRenameId = null },
347 onDismiss = { pendingRenameId = null },
351 )
348 )
352 }
349 }
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+ }
353}
367}
354
368
355@Composable
369@Composable
@@ -483,3 +497,79 @@ private fun EmptySessions(onCreate: () -> Unit, modifier: Modifier = Modifier) {
483 }
497 }
484 }
498 }
485}
499}
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() {
87 private val _switchingId = MutableStateFlow<String?>(null)
87 private val _switchingId = MutableStateFlow<String?>(null)
88 val switchingId: StateFlow<String?> = _switchingId.asStateFlow()
88 val switchingId: StateFlow<String?> = _switchingId.asStateFlow()
89
89
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+
90 private val _state = MutableStateFlow(SessionListState())
95 private val _state = MutableStateFlow(SessionListState())
91 val state: StateFlow<SessionListState> = _state.asStateFlow()
96 val state: StateFlow<SessionListState> = _state.asStateFlow()
92
97
@@ -104,6 +109,17 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
104 )
109 )
105 val transientErrors: SharedFlow<String> = _transientErrors.asSharedFlow()
110 val transientErrors: SharedFlow<String> = _transientErrors.asSharedFlow()
106
111
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+
107 private var previewJob: Job? = null
123 private var previewJob: Job? = null
108 private var refreshJob: Job? = null
124 private var refreshJob: Job? = null
109
125
@@ -321,28 +337,64 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
321
337
322 fun createSession(onCreated: (String) -> Unit) {
338 fun createSession(onCreated: (String) -> Unit) {
323 val conn = container.activeConnection.value ?: return
339 val conn = container.activeConnection.value ?: return
340+ if (!_creating.compareAndSet(false, true)) return
324 viewModelScope.launch {
341 viewModelScope.launch {
342+ try {
325− runCatchingCancellable { conn.repository.createSession() }
343+ runCatchingCancellable { conn.repository.createSession() }
326− .onSuccess { onCreated(it.id); refresh() }
344+ .onSuccess { onCreated(it.id); refresh() }
327− .onFailure {
345+ .onFailure {
328− _state.update { it.copy(error = null) }
346+ _state.update { it.copy(error = null) }
329− _transientErrors.tryEmit(container.friendlyError(it))
347+ _transientErrors.tryEmit(container.friendlyError(it))
348+ }
349+ } finally {
350+ _creating.value = false
330− }
351+ }
331 }
352 }
332 }
353 }
333
354
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+ */
334 fun deleteSession(session: Session) {
361 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)
336 viewModelScope.launch {
369 viewModelScope.launch {
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
337− runCatchingCancellable { conn.repository.deleteSession(session.id) }
376+ runCatchingCancellable { conn.repository.deleteSession(session.id) }
338− .onSuccess {
377+ .onSuccess {
339− container.draftStore.remove(session.id)
378+ container.draftStore.remove(session.id)
340− refresh()
379+ refresh()
341− }
380+ }
342− .onFailure {
381+ .onFailure {
343− _state.update { it.copy(error = null) }
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 }) }
344− _transientErrors.tryEmit(container.friendlyError(it))
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 })
345− }
397+ }
346 }
398 }
347 }
399 }
348
400
app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+50−16
@@ -46,6 +46,7 @@ import kotlinx.coroutines.Dispatchers
46import kotlinx.coroutines.launch
46import kotlinx.coroutines.launch
47import kotlinx.coroutines.withContext
47import kotlinx.coroutines.withContext
48import soy.iko.opencode.util.runCatchingCancellable
48import soy.iko.opencode.util.runCatchingCancellable
49+import soy.iko.opencode.ui.components.showToast
49import androidx.compose.ui.Alignment
50import androidx.compose.ui.Alignment
50import androidx.compose.ui.Modifier
51import androidx.compose.ui.Modifier
51import androidx.compose.ui.platform.LocalContext
52import androidx.compose.ui.platform.LocalContext
@@ -71,6 +72,7 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
71 val reports by logger.reports.collectAsStateWithLifecycle()
72 val reports by logger.reports.collectAsStateWithLifecycle()
72 var viewing by rememberSaveable { mutableStateOf<String?>(null) }
73 var viewing by rememberSaveable { mutableStateOf<String?>(null) }
73 var showClearAll by rememberSaveable { mutableStateOf(false) }
74 var showClearAll by rememberSaveable { mutableStateOf(false) }
75+ var pendingReportDelete by rememberSaveable { mutableStateOf<String?>(null) }
74 val shareScope = rememberCoroutineScope()
76 val shareScope = rememberCoroutineScope()
75 val shareLabel = stringResource(R.string.share)
77 val shareLabel = stringResource(R.string.share)
76 val timeTick = rememberRelativeTimeTick()
78 val timeTick = rememberRelativeTimeTick()
@@ -154,7 +156,10 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
154 putExtra(Intent.EXTRA_TEXT, content)
156 putExtra(Intent.EXTRA_TEXT, content)
155 }
157 }
156 runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
158 runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
159+ .onFailure {
157− .onFailure { Log.w("Diagnostics", "Failed to share crash report", it) }
160+ Log.w("Diagnostics", "Failed to share crash report", it)
161+ showToast(context, context.getString(R.string.no_share_app))
162+ }
158 }
163 }
159 }) {
164 }) {
160 Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share))
165 Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share))
@@ -171,16 +176,18 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
171 val reportName = viewing
176 val reportName = viewing
172 if (reportName != null) {
177 if (reportName != null) {
173 var reportContent by remember(reportName) { mutableStateOf<String?>(null) }
178 var reportContent by remember(reportName) { mutableStateOf<String?>(null) }
179+ var loadFailed by remember(reportName) { mutableStateOf(false) }
174 LaunchedEffect(reportName) {
180 LaunchedEffect(reportName) {
175− reportContent = withContext(Dispatchers.IO) {
181+ val loaded = withContext(Dispatchers.IO) {
176 runCatching { logger.readReport(reportName) }.getOrNull()
182 runCatching { logger.readReport(reportName) }.getOrNull()
177 }
183 }
184+ if (loaded != null) reportContent = loaded else loadFailed = true
178 }
185 }
179 val content = reportContent
186 val content = reportContent
180 AlertDialog(
187 AlertDialog(
181 onDismissRequest = { viewing = null },
188 onDismissRequest = { viewing = null },
182 confirmButton = {
189 confirmButton = {
183− TextButton(onClick = { viewing = null }) { Text(stringResource(R.string.back)) }
190+ TextButton(onClick = { viewing = null }) { Text(stringResource(R.string.close)) }
184 },
191 },
185 dismissButton = {
192 dismissButton = {
186 Row {
193 Row {
@@ -192,19 +199,22 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
192 putExtra(Intent.EXTRA_TEXT, content.orEmpty())
199 putExtra(Intent.EXTRA_TEXT, content.orEmpty())
193 }
200 }
194 runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel2)) }
201 runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel2)) }
202+ .onFailure {
195− .onFailure { Log.w("Diagnostics", "Failed to share crash report", it) }
203+ Log.w("Diagnostics", "Failed to share crash report", it)
204+ showToast(context, context.getString(R.string.no_share_app))
205+ }
196 }) { Text(stringResource(R.string.share)) }
206 }) { Text(stringResource(R.string.share)) }
197 Spacer(Modifier.size(8.dp))
207 Spacer(Modifier.size(8.dp))
198 TextButton(onClick = {
208 TextButton(onClick = {
199− logger.deleteReport(reportName)
209+ // Confirm before deleting a single report, matching clear-all.
200− viewing = null
210+ pendingReportDelete = reportName
201− }) { Text(stringResource(R.string.delete)) }
211+ }) { Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) }
202 }
212 }
203 },
213 },
204 title = { Text(stringResource(R.string.crash_report)) },
214 title = { Text(stringResource(R.string.crash_report)) },
205 text = {
215 text = {
206− if (content != null) {
216+ when {
207− Box(
217+ content != null -> Box(
208 modifier = Modifier
218 modifier = Modifier
209 .fillMaxWidth()
219 .fillMaxWidth()
210 .heightIn(max = 400.dp)
220 .heightIn(max = 400.dp)
@@ -216,19 +226,43 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
216 modifier = Modifier.fillMaxWidth(),
226 modifier = Modifier.fillMaxWidth(),
217 )
227 )
218 }
228 }
229+ loadFailed -> Text(
230+ stringResource(R.string.report_load_failed),
231+ color = MaterialTheme.colorScheme.error,
232+ modifier = Modifier.padding(vertical = 24.dp),
233+ )
219− } else {
234+ else -> {
220− val loadingLabel = stringResource(R.string.loading)
235+ val loadingLabel = stringResource(R.string.loading)
221− Box(
236+ Box(
222− modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
237+ modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
223− contentAlignment = Alignment.Center,
238+ contentAlignment = Alignment.Center,
224− ) {
239+ ) {
225− CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
240+ CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
241+ }
226 }
242 }
227 }
243 }
228 },
244 },
229 )
245 )
230 }
246 }
231
247
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+
232 if (showClearAll) {
266 if (showClearAll) {
233 AlertDialog(
267 AlertDialog(
234 onDismissRequest = { showClearAll = false },
268 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
2
2
3import android.content.pm.PackageManager
3import android.content.pm.PackageManager
4import android.os.Build
4import android.os.Build
5+import androidx.compose.foundation.background
5import androidx.compose.foundation.clickable
6import androidx.compose.foundation.clickable
6import androidx.compose.foundation.layout.Arrangement
7import androidx.compose.foundation.layout.Arrangement
8+import androidx.compose.foundation.layout.Box
7import androidx.compose.foundation.layout.Column
9import androidx.compose.foundation.layout.Column
8import androidx.compose.foundation.layout.Row
10import androidx.compose.foundation.layout.Row
9import androidx.compose.foundation.layout.Spacer
11import androidx.compose.foundation.layout.Spacer
@@ -16,6 +18,7 @@ import androidx.compose.foundation.rememberScrollState
16import androidx.compose.foundation.selection.selectable
18import androidx.compose.foundation.selection.selectable
17import androidx.compose.foundation.selection.toggleable
19import androidx.compose.foundation.selection.toggleable
18import androidx.compose.foundation.verticalScroll
20import androidx.compose.foundation.verticalScroll
21+import androidx.compose.foundation.shape.CircleShape
19import androidx.compose.material.icons.Icons
22import androidx.compose.material.icons.Icons
20import androidx.compose.material.icons.automirrored.filled.ArrowBack
23import androidx.compose.material.icons.automirrored.filled.ArrowBack
21import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
24import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
@@ -38,15 +41,18 @@ import androidx.compose.runtime.remember
38import androidx.compose.runtime.rememberCoroutineScope
41import androidx.compose.runtime.rememberCoroutineScope
39import androidx.compose.ui.Alignment
42import androidx.compose.ui.Alignment
40import androidx.compose.ui.Modifier
43import androidx.compose.ui.Modifier
44+import androidx.compose.ui.draw.clip
41import androidx.compose.ui.platform.LocalContext
45import androidx.compose.ui.platform.LocalContext
42import androidx.compose.ui.res.stringResource
46import androidx.compose.ui.res.stringResource
43import androidx.compose.ui.semantics.Role
47import androidx.compose.ui.semantics.Role
44import androidx.compose.ui.semantics.heading
48import androidx.compose.ui.semantics.heading
45import androidx.compose.ui.semantics.role
49import androidx.compose.ui.semantics.role
46import androidx.compose.ui.semantics.semantics
50import androidx.compose.ui.semantics.semantics
51+import androidx.compose.ui.text.style.TextAlign
47import androidx.compose.ui.unit.dp
52import androidx.compose.ui.unit.dp
48import androidx.lifecycle.compose.collectAsStateWithLifecycle
53import androidx.lifecycle.compose.collectAsStateWithLifecycle
49import kotlinx.coroutines.launch
54import kotlinx.coroutines.launch
55+import soy.iko.opencode.data.repo.CrashLogger
50import soy.iko.opencode.data.repo.ThemeMode
56import soy.iko.opencode.data.repo.ThemeMode
51import soy.iko.opencode.di.AppContainer
57import soy.iko.opencode.di.AppContainer
52import soy.iko.opencode.R
58import soy.iko.opencode.R
@@ -60,6 +66,11 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
60 val dynamicColor by container.settingsStore.dynamicColor.collectAsStateWithLifecycle(initialValue = true)
66 val dynamicColor by container.settingsStore.dynamicColor.collectAsStateWithLifecycle(initialValue = true)
61 val activeProfile = container.activeConnection.collectAsStateWithLifecycle().value?.profile
67 val activeProfile = container.activeConnection.collectAsStateWithLifecycle().value?.profile
62 val context = LocalContext.current
68 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
63 val unknownVersion = stringResource(R.string.unknown_version)
74 val unknownVersion = stringResource(R.string.unknown_version)
64 val versionName = remember {
75 val versionName = remember {
65 runCatching {
76 runCatching {
@@ -195,6 +206,12 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
195 ) {
206 ) {
196 Icon(Icons.Filled.BugReport, contentDescription = null, tint = MaterialTheme.colorScheme.error)
207 Icon(Icons.Filled.BugReport, contentDescription = null, tint = MaterialTheme.colorScheme.error)
197 Text(stringResource(R.string.diagnostics), modifier = Modifier.weight(1f).padding(start = 8.dp))
208 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+ }
198 Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
215 Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
199 }
216 }
200 }
217 }
@@ -222,3 +239,22 @@ private fun ThemeRow(mode: ThemeMode, selected: Boolean, onSelect: () -> Unit) {
222 )
239 )
223 }
240 }
224}
241}
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 @@
47 <string name="no_servers_hint">Add an opencode serve endpoint to connect.</string>
47 <string name="no_servers_hint">Add an opencode serve endpoint to connect.</string>
48 <string name="remove_server_title">Remove server?</string>
48 <string name="remove_server_title">Remove server?</string>
49 <string name="remove_server_text">“%1$s” will be removed from your saved servers.</string>
49 <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>
50 <string name="server_auth_short"> • auth</string>
51 <string name="server_auth_short"> • auth</string>
51 <string name="server_auth_basic"> • Basic auth</string>
52 <string name="server_auth_basic"> • Basic auth</string>
52
53
@@ -61,6 +62,17 @@
61 <string name="auth_help">Tap "Check connectivity" to test the server. Authentication fields appear automatically only if the server requires them.</string>
62 <string name="auth_help">Tap "Check connectivity" to test the server. Authentication fields appear automatically only if the server requires them.</string>
62 <string name="username_optional">Username (optional)</string>
63 <string name="username_optional">Username (optional)</string>
63 <string name="password_optional">Password (optional)</string>
64 <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>
64
76
65 <!-- Session list -->
77 <!-- Session list -->
66 <string name="new_session">New session</string>
78 <string name="new_session">New session</string>
@@ -74,6 +86,10 @@
74 <string name="delete_session_text">“%1$s” will be permanently deleted. This cannot be undone.</string>
86 <string name="delete_session_text">“%1$s” will be permanently deleted. This cannot be undone.</string>
75 <string name="rename_session">Rename session</string>
87 <string name="rename_session">Rename session</string>
76 <string name="session_title_hint">Session title</string>
88 <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>
77
93
78 <!-- Chat -->
94 <!-- Chat -->
79 <string name="session">Session</string>
95 <string name="session">Session</string>
@@ -93,6 +109,10 @@
93 <string name="choose_model">Choose model</string>
109 <string name="choose_model">Choose model</string>
94 <string name="change_model_agent">Change model and agent</string>
110 <string name="change_model_agent">Change model and agent</string>
95 <string name="share_conversation">Share conversation</string>
111 <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>
96
116
97 <!-- Errors (view models) -->
117 <!-- Errors (view models) -->
98 <string name="error_generic">Something went wrong.</string>
118 <string name="error_generic">Something went wrong.</string>
@@ -175,6 +195,11 @@
175 <string name="clear_all">Clear all</string>
195 <string name="clear_all">Clear all</string>
176 <string name="clear_all_confirm">Delete all stored crash reports? This cannot be undone.</string>
196 <string name="clear_all_confirm">Delete all stored crash reports? This cannot be undone.</string>
177 <string name="crash_report">Crash report</string>
197 <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>
178
203
179 <!-- Notifications -->
204 <!-- Notifications -->
180 <string name="notif_channel_status">Agent activity</string>
205 <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() {
324 var probeCalls: List<String> = emptyList()
324 var probeCalls: List<String> = emptyList()
325 private set
325 private set
326
326
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+
327 override fun string(id: Int, vararg formatArgs: Any): String = "test-string-$id"
332 override fun string(id: Int, vararg formatArgs: Any): String = "test-string-$id"
328
333
329 override fun friendlyError(t: Throwable): String = "test-error: ${t.message ?: t::class.simpleName}"
334 override fun friendlyError(t: Throwable): String = "test-error: ${t.message ?: t::class.simpleName}"
@@ -334,9 +339,6 @@ class FakeAppContainer : AppContainer() {
334 override suspend fun connect(profile: ServerProfile): OpencodeConnection {
339 override suspend fun connect(profile: ServerProfile): OpencodeConnection {
335 connectCalls = connectCalls + profile
340 connectCalls = connectCalls + profile
336 connectException?.let { throw it }
341 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.
340 val conn = connectResult ?: run {
342 val conn = connectResult ?: run {
341 val api = FakeOpencodeApiClient()
343 val api = FakeOpencodeApiClient()
342 val events = FakeEventStreamClient()
344 val events = FakeEventStreamClient()
@@ -357,6 +359,11 @@ class FakeAppContainer : AppContainer() {
357 return probeResult ?: ProbeResult.Reachable
359 return probeResult ?: ProbeResult.Reachable
358 }
360 }
359
361
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+
360 fun setActiveConnection(conn: OpencodeConnection?) {
367 fun setActiveConnection(conn: OpencodeConnection?) {
361 fakeActiveConnection.value = conn
368 fakeActiveConnection.value = conn
362 }
369 }