git.iko.soy/opencode-android
on master help sign in
UX/UI: disconnect confirmation, tool call count summary, disable connect when auth creds empty
0c49dafiko
app/src/main/java/soy/iko/opencode/core/OpencodeCore.kt+7
@@ -189,6 +189,9 @@ class OpencodeCore(
189 removedMessageIds.clear()
189 removedMessageIds.clear()
190 draft = ""
190 draft = ""
191 generating = false
191 generating = false
192+ // Clear any error from the chat (e.g. a failed send) so it doesn't
193+ // linger on the sessions list after the user navigates back.
194+ error = null
192 loading = true
195 loading = true
193 emit()
196 emit()
194 connectJob?.cancel()
197 connectJob?.cancel()
@@ -358,6 +361,10 @@ class OpencodeCore(
358 if (session != null) {
361 if (session != null) {
359 if (sessions.none { it.id == session.id }) {
362 if (sessions.none { it.id == session.id }) {
360 sessions.add(0, SessionView(session.id, displayTitle(session.title)))
363 sessions.add(0, SessionView(session.id, displayTitle(session.title)))
364+ // Restore the alphabetical order immediately (matching loadSessions /
365+ // SessionUpserted) so the new session doesn't sit out-of-order at the
366+ // top until the SSE SessionUpserted event arrives to re-sort.
367+ sessions.sortBy { it.title.lowercase() }
361 }
368 }
362 emit()
369 emit()
363 // SelectSession takes over the loading state (loads messages).
370 // SelectSession takes over the loading state (loads messages).
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+24−1
@@ -32,6 +32,7 @@ import soy.iko.opencode.ui.screens.SessionsScreen
32fun OpencodeApp(state: UiState, dispatch: (Event) -> Unit) {
32fun OpencodeApp(state: UiState, dispatch: (Event) -> Unit) {
33 val snackbar = remember { SnackbarHostState() }
33 val snackbar = remember { SnackbarHostState() }
34 var showStopConfirm by remember { mutableStateOf(false) }
34 var showStopConfirm by remember { mutableStateOf(false) }
35+ var showDisconnectConfirm by remember { mutableStateOf(false) }
35
36
36 LaunchedEffect(state.error) {
37 LaunchedEffect(state.error) {
37 state.error?.let {
38 state.error?.let {
@@ -49,7 +50,8 @@ fun OpencodeApp(state: UiState, dispatch: (Event) -> Unit) {
49 when (state.screen) {
50 when (state.screen) {
50 // Leaving mid-generation would silently drop the reply, so confirm first.
51 // Leaving mid-generation would silently drop the reply, so confirm first.
51 Screen.Chat -> if (state.generating) showStopConfirm = true else dispatch(Event.NavigateToSessions)
52 Screen.Chat -> if (state.generating) showStopConfirm = true else dispatch(Event.NavigateToSessions)
52− Screen.Sessions -> dispatch(Event.NavigateToConnect)
53+ // Disconnecting drops the server session, so guard the back button.
54+ Screen.Sessions -> showDisconnectConfirm = true
53 Screen.Connect -> Unit
55 Screen.Connect -> Unit
54 }
56 }
55 }
57 }
@@ -76,6 +78,27 @@ fun OpencodeApp(state: UiState, dispatch: (Event) -> Unit) {
76 )
78 )
77 }
79 }
78
80
81+ if (showDisconnectConfirm) {
82+ AlertDialog(
83+ onDismissRequest = { showDisconnectConfirm = false },
84+ title = { Text("Disconnect?") },
85+ text = { Text("You will be disconnected from the server.") },
86+ confirmButton = {
87+ TextButton(onClick = {
88+ showDisconnectConfirm = false
89+ dispatch(Event.NavigateToConnect)
90+ }) {
91+ Text("Disconnect")
92+ }
93+ },
94+ dismissButton = {
95+ TextButton(onClick = { showDisconnectConfirm = false }) {
96+ Text("Cancel")
97+ }
98+ },
99+ )
100+ }
101+
79 Scaffold(snackbarHost = { SnackbarHost(snackbar) }) { padding ->
102 Scaffold(snackbarHost = { SnackbarHost(snackbar) }) { padding ->
80 AnimatedContent(
103 AnimatedContent(
81 targetState = state.screen,
104 targetState = state.screen,
app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.kt+13−1
@@ -127,12 +127,24 @@ fun MessageBubble(message: MessageView, modifier: Modifier = Modifier, onRetry:
127
127
128 message.reasoning?.let { ReasoningBlock(it, context) }
128 message.reasoning?.let { ReasoningBlock(it, context) }
129
129
130+ // Give a quick overview of how many tools ran before listing them.
131+ if (message.tools.size > 1) {
132+ Text(
133+ text = "${message.tools.size} tool calls",
134+ style = MaterialTheme.typography.labelSmall,
135+ color = MaterialTheme.colorScheme.onSurfaceVariant,
136+ modifier = Modifier.padding(bottom = 4.dp),
137+ )
138+ }
139+
130 message.tools.forEach { ToolRow(it) }
140 message.tools.forEach { ToolRow(it) }
131
141
132 SelectionContainer {
142 SelectionContainer {
133 if (message.text.isNotEmpty()) {
143 if (message.text.isNotEmpty()) {
134 val linkColor = MaterialTheme.colorScheme.primary
144 val linkColor = MaterialTheme.colorScheme.primary
145+ // Tint the code background off the bubble's own foreground colour so it
146+ // contrasts on both bubble types (user bubbles use primaryContainer).
135− val codeBackground = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f)
147+ val codeBackground = (if (isUser) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface).copy(alpha = 0.08f)
136 val annotatedText = remember(message.text, message.streaming, linkColor, codeBackground, context) {
148 val annotatedText = remember(message.text, message.streaming, linkColor, codeBackground, context) {
137 buildLinkedText(message.text, message.streaming, linkColor, codeBackground, context)
149 buildLinkedText(message.text, message.streaming, linkColor, codeBackground, context)
138 }
150 }
app/src/main/java/soy/iko/opencode/ui/screens/ConnectScreen.kt+4−1
@@ -170,7 +170,10 @@ fun ConnectScreen(state: UiState, dispatch: (Event) -> Unit) {
170
170
171 Button(
171 Button(
172 onClick = { dispatch(Event.Connect) },
172 onClick = { dispatch(Event.Connect) },
173+ // When auth is required, keep the button disabled until at least one
174+ // credential is entered — otherwise it just triggers a validation error.
173− enabled = !state.loading && state.serverUrl.isNotBlank(),
175+ enabled = !state.loading && state.serverUrl.isNotBlank() &&
176+ (!state.authRequired || state.username.isNotBlank() || state.password.isNotBlank()),
174 modifier = Modifier.fillMaxWidth(),
177 modifier = Modifier.fillMaxWidth(),
175 ) {
178 ) {
176 if (state.loading) {
179 if (state.loading) {