git.iko.soy/opencode-android
on master help sign in
Implement 22 UI/UX improvements: long-press menu, image zoom, full-screen editor, AMOLED theme, session-scoped permissions, notification grouping, responsive pickers, mDNS timeout, reduced-motion, export metadata
decfac5iko
app/src/main/java/soy/iko/opencode/MainActivity.kt+3−1
@@ -90,12 +90,14 @@ class MainActivity : FragmentActivity() {
90 val locked = appLock
90 val locked = appLock
91 if (themePair != null && locked != null) {
91 if (themePair != null && locked != null) {
92 val (themeMode, dynamicColor) = themePair
92 val (themeMode, dynamicColor) = themePair
93+ val amoled = themeMode == ThemeMode.AMOLED
93 val dark = when (themeMode) {
94 val dark = when (themeMode) {
94 ThemeMode.SYSTEM -> isSystemInDarkTheme()
95 ThemeMode.SYSTEM -> isSystemInDarkTheme()
95 ThemeMode.LIGHT -> false
96 ThemeMode.LIGHT -> false
96 ThemeMode.DARK -> true
97 ThemeMode.DARK -> true
98+ ThemeMode.AMOLED -> true
97 }
99 }
98− OpencodeTheme(darkTheme = dark, dynamicColor = dynamicColor) {
100+ OpencodeTheme(darkTheme = dark, dynamicColor = dynamicColor, amoled = amoled) {
99 AppLockGate(enabled = locked) {
101 AppLockGate(enabled = locked) {
100 OpencodeAppUi(container = container)
102 OpencodeAppUi(container = container)
101 }
103 }
app/src/main/java/soy/iko/opencode/data/model/Permission.kt+9−1
@@ -38,11 +38,19 @@ data class Permission(
38 }
38 }
39}
39}
40
40
41−/** Allowed values for the permission response body. */
41+/** Allowed values for the permission response body.
42+ *
43+ * [SESSION] is a client-side scope: it auto-responds [ONCE] to subsequent permission requests
44+ * whose (type, pattern) match a remembered set, without showing the dialog. The set lives in
45+ * [soy.iko.opencode.ui.chat.ChatViewModel] and is cleared on connection change. It is NOT sent
46+ * to the server (the server only knows `once`/`always`/`reject`); the VM maps it to [ONCE] on
47+ * the wire and tracks the grant locally. This gives users a middle ground between "allow once"
48+ * (re-prompts on every file read) and "always" (persists in the server config outliving the app). */
42enum class PermissionResponse(val wire: String) {
49enum class PermissionResponse(val wire: String) {
43 ONCE("once"),
50 ONCE("once"),
44 ALWAYS("always"),
51 ALWAYS("always"),
45 REJECT("reject"),
52 REJECT("reject"),
53+ SESSION("once"),
46}
54}
47
55
48/** Body for `POST /session/:id/permissions/:permissionID`. */
56/** Body for `POST /session/:id/permissions/:permissionID`. */
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+17
@@ -107,6 +107,13 @@ object NetworkConfig {
107 /** Max messages to keep in memory per observed session; oldest are evicted beyond this. */
107 /** Max messages to keep in memory per observed session; oldest are evicted beyond this. */
108 const val maxInMemoryMessages = 500
108 const val maxInMemoryMessages = 500
109
109
110+ // --- File viewer (FileViewScreen) ---
111+
112+ /** Cap on how many lines the raw viewer renders and searches. A multi-megabyte file is
113+ * split once (not twice) into this many lines at most, so it can't hold two full line
114+ * lists in memory or stall the LazyColumn; beyond the cap a truncation banner is shown. */
115+ const val maxRenderedFileLines = 5000
116+
110 // --- File browser (FileBrowserViewModel) ---
117 // --- File browser (FileBrowserViewModel) ---
111
118
112 /** Debounce delay before firing a file-search request after the user stops typing. */
119 /** Debounce delay before firing a file-search request after the user stops typing. */
@@ -165,6 +172,16 @@ object NetworkConfig {
165 /** Timeout for loading a profile from DataStore before giving up. */
172 /** Timeout for loading a profile from DataStore before giving up. */
166 const val profileLoadTimeoutMs = 5_000L
173 const val profileLoadTimeoutMs = 5_000L
167
174
175+ /** How long mDNS discovery searches before showing a "no servers found" terminal state.
176+ * Discovery continues running in the background (a late server can still appear), but the
177+ * spinner is replaced with the no-results message so the user isn't left looking at an
178+ * infinite spinner on a network with no opencode servers. */
179+ const val nsdDiscoveryNoResultTimeoutMs = 8_000L
180+
181+ /** Timeout for a credential test (probe) before giving up, so a hung/unresponsive server
182+ * doesn't leave the "Testing…" spinner spinning indefinitely. */
183+ const val testCredentialsTimeoutMs = 15_000L
184+
168 // --- SSE stream (EventStreamClient, AppContainer) ---
185 // --- SSE stream (EventStreamClient, AppContainer) ---
169
186
170 /** Delay before retrying the message-activity observer after a failure. */
187 /** Delay before retrying the message-activity observer after a failure. */
app/src/main/java/soy/iko/opencode/data/repo/SettingsStore.kt+1−1
@@ -9,7 +9,7 @@ import androidx.datastore.preferences.preferencesDataStore
9import kotlinx.coroutines.flow.Flow
9import kotlinx.coroutines.flow.Flow
10import kotlinx.coroutines.flow.map
10import kotlinx.coroutines.flow.map
11
11
12−enum class ThemeMode { SYSTEM, LIGHT, DARK }
12+enum class ThemeMode { SYSTEM, LIGHT, DARK, AMOLED }
13
13
14private val Context.settingsDataStore by preferencesDataStore(name = "settings")
14private val Context.settingsDataStore by preferencesDataStore(name = "settings")
15
15
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+16−1
@@ -316,7 +316,22 @@ open class AppContainer private constructor(
316 .debounce(NetworkConfig.runForegroundDebounceMs)
316 .debounce(NetworkConfig.runForegroundDebounceMs)
317 .distinctUntilChanged()
317 .distinctUntilChanged()
318 .collect { active ->
318 .collect { active ->
319+ if (active) {
320+ // When exactly one run is active, resolve its title so the notification
321+ // identifies which session is running. Multiple concurrent runs fall back
322+ // to the generic title (naming one would be misleading).
323+ val title = synchronized(activeRuns) {
324+ activeRuns.toList().singleOrNull()
325+ }?.let { sid ->
326+ runCatchingCancellable {
327+ activeConnection.value?.repository?.listSessions()
328+ ?.firstOrNull { it.id == sid }?.displayTitle
329+ }.getOrNull()
330+ }
331+ RunForegroundService.start(ctx, title)
332+ } else {
319− if (active) RunForegroundService.start(ctx) else RunForegroundService.stop(ctx)
333+ RunForegroundService.stop(ctx)
334+ }
320 }
335 }
321 }
336 }
322 }
337 }
app/src/main/java/soy/iko/opencode/notification/RunForegroundService.kt+13−5
@@ -26,7 +26,11 @@ import soy.iko.opencode.R
26class RunForegroundService : Service() {
26class RunForegroundService : Service() {
27
27
28 override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
28 override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
29+ // The session title (when a single run is active) is passed via the intent extra so
30+ // the notification can identify which session is running, instead of a generic
31+ // "Agent is working…". Null/blank falls back to the generic title.
32+ val sessionTitle = intent?.getStringExtra(EXTRA_SESSION_TITLE)?.takeIf { it.isNotBlank() }
29− val notification = buildNotification()
33+ val notification = buildNotification(sessionTitle)
30 // startForeground can throw ForegroundServiceStartNotAllowedException on
34 // startForeground can throw ForegroundServiceStartNotAllowedException on
31 // Android 12+ if the app is in the background when the service starts. Wrap
35 // Android 12+ if the app is in the background when the service starts. Wrap
32 // it so a backgrounded start (e.g. the user navigates away at the wrong
36 // it so a backgrounded start (e.g. the user navigates away at the wrong
@@ -50,7 +54,7 @@ class RunForegroundService : Service() {
50 return START_NOT_STICKY
54 return START_NOT_STICKY
51 }
55 }
52
56
53− private fun buildNotification(): Notification {
57+ private fun buildNotification(sessionTitle: String?): Notification {
54 // Tapping the notification opens the app so the user can see the running session.
58 // Tapping the notification opens the app so the user can see the running session.
55 val openIntent = Intent(this, MainActivity::class.java).apply {
59 val openIntent = Intent(this, MainActivity::class.java).apply {
56 flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
60 flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
@@ -59,10 +63,11 @@ class RunForegroundService : Service() {
59 this, 0, openIntent,
63 this, 0, openIntent,
60 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
64 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
61 )
65 )
66+ val contentText = sessionTitle ?: getString(R.string.notif_running_text)
62 return NotificationCompat.Builder(this, NotificationChannels.STATUS)
67 return NotificationCompat.Builder(this, NotificationChannels.STATUS)
63 .setSmallIcon(R.drawable.ic_launcher_foreground)
68 .setSmallIcon(R.drawable.ic_launcher_foreground)
64 .setContentTitle(getString(R.string.notif_running_title))
69 .setContentTitle(getString(R.string.notif_running_title))
65− .setContentText(getString(R.string.notif_running_text))
70+ .setContentText(contentText)
66 .setContentIntent(pendingIntent)
71 .setContentIntent(pendingIntent)
67 .setOngoing(true)
72 .setOngoing(true)
68 // Show a live elapsed timer so the user can see how long the current run has
73 // Show a live elapsed timer so the user can see how long the current run has
@@ -102,13 +107,16 @@ class RunForegroundService : Service() {
102 companion object {
107 companion object {
103 private const val TAG = "RunForegroundService"
108 private const val TAG = "RunForegroundService"
104 private const val NOTIF_ID = 1
109 private const val NOTIF_ID = 1
110+ const val EXTRA_SESSION_TITLE = "soy.iko.opencode.extra.SESSION_TITLE"
105
111
106− fun start(context: Context) {
112+ fun start(context: Context, sessionTitle: String? = null) {
107 // startForegroundService can throw ForegroundServiceStartNotAllowedException
113 // startForegroundService can throw ForegroundServiceStartNotAllowedException
108 // on Android 12+ if the app is in the background. Wrap it so a backgrounded
114 // on Android 12+ if the app is in the background. Wrap it so a backgrounded
109 // start (e.g. the user navigates away at the wrong moment) doesn't crash.
115 // start (e.g. the user navigates away at the wrong moment) doesn't crash.
110 runCatching {
116 runCatching {
111− context.startForegroundService(Intent(context, RunForegroundService::class.java))
117+ val intent = Intent(context, RunForegroundService::class.java)
118+ sessionTitle?.let { intent.putExtra(EXTRA_SESSION_TITLE, it) }
119+ context.startForegroundService(intent)
112 }.onFailure {
120 }.onFailure {
113 Log.w(TAG, "startForegroundService failed; running without foreground priority", it)
121 Log.w(TAG, "startForegroundService failed; running without foreground priority", it)
114 }
122 }
app/src/main/java/soy/iko/opencode/notification/SessionNotifications.kt+30−2
@@ -137,10 +137,35 @@ object SessionNotifications {
137 .addAction(replyAction)
137 .addAction(replyAction)
138 .setCategory(NotificationCompat.CATEGORY_MESSAGE)
138 .setCategory(NotificationCompat.CATEGORY_MESSAGE)
139 .setPriority(NotificationCompat.PRIORITY_DEFAULT)
139 .setPriority(NotificationCompat.PRIORITY_DEFAULT)
140+ // Group notifications from the same server so multiple completions collapse into
141+ // a stackable group on Android 7+ instead of filling the shade with individual entries.
142+ .setGroup(GROUP_COMPLETED)
140 .build()
143 .build()
141
144
145+ runCatching {
142− runCatching { NotificationManagerCompat.from(context).notify(notifId, notification) }
146+ NotificationManagerCompat.from(context).notify(notifId, notification)
147+ // Post a summary notification so the group has a single top-level entry in the shade.
148+ postCompletedSummary(context)
149+ }.onFailure { Log.w(TAG, "Failed to post completion notification", it) }
150+ }
151+
152+ /** Post (or update) a summary notification for the completed-sessions group so multiple
153+ * completions collapse into one stackable entry instead of filling the shade. */
154+ @SuppressLint("MissingPermission")
155+ private fun postCompletedSummary(context: Context) {
156+ val summary = NotificationCompat.Builder(context, NotificationChannels.COMPLETED)
157+ .setSmallIcon(R.drawable.ic_launcher_foreground)
158+ .setContentTitle(context.getString(R.string.notif_completed_title))
159+ .setContentText(context.getString(R.string.app_name))
160+ .setSmallIcon(R.drawable.ic_launcher_foreground)
161+ .setAutoCancel(true)
162+ .setCategory(NotificationCompat.CATEGORY_MESSAGE)
163+ .setGroup(GROUP_COMPLETED)
164+ .setGroupSummary(true)
165+ .build()
166+ runCatching {
167+ NotificationManagerCompat.from(context).notify(SUMMARY_COMPLETED_ID, summary)
143− .onFailure { Log.w(TAG, "Failed to post completion notification", it) }
168+ }.onFailure { Log.w(TAG, "Failed to post completion summary notification", it) }
144 }
169 }
145
170
146 /** Post a heads-up notification for a permission request with Allow once / Always /
171 /** Post a heads-up notification for a permission request with Allow once / Always /
@@ -253,4 +278,7 @@ object SessionNotifications {
253 fun cancelError(context: Context, sessionId: String) {
278 fun cancelError(context: Context, sessionId: String) {
254 NotificationManagerCompat.from(context).cancel(notifId(NS_ERROR, sessionId))
279 NotificationManagerCompat.from(context).cancel(notifId(NS_ERROR, sessionId))
255 }
280 }
281+
282+ private const val GROUP_COMPLETED = "soy.iko.opencode.COMPLETED"
283+ private const val SUMMARY_COMPLETED_ID = -1
256}
284}
app/src/main/java/soy/iko/opencode/ui/chat/AgentPickerSheet.kt+4−1
@@ -48,6 +48,7 @@ import androidx.compose.ui.semantics.semantics
48import androidx.compose.ui.text.input.ImeAction
48import androidx.compose.ui.text.input.ImeAction
49import androidx.compose.ui.unit.dp
49import androidx.compose.ui.unit.dp
50import androidx.compose.foundation.layout.Box
50import androidx.compose.foundation.layout.Box
51+import androidx.compose.foundation.layout.fillMaxHeight
51import soy.iko.opencode.data.model.Agent
52import soy.iko.opencode.data.model.Agent
52import soy.iko.opencode.data.network.NetworkConfig
53import soy.iko.opencode.data.network.NetworkConfig
53import soy.iko.opencode.R
54import soy.iko.opencode.R
@@ -183,7 +184,9 @@ fun AgentPickerSheet(
183 LaunchedEffect(Unit) {
184 LaunchedEffect(Unit) {
184 if (selectedIndex >= 0) listState.scrollToItem(selectedIndex)
185 if (selectedIndex >= 0) listState.scrollToItem(selectedIndex)
185 }
186 }
187+ // Responsive height: half the viewport rather than a hardcoded 420dp cap, so
188+ // the sheet doesn't cramp in landscape or leave empty space on a tall phone.
186− LazyColumn(state = listState, modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
189+ LazyColumn(state = listState, modifier = Modifier.fillMaxHeight(0.5f)) {
187 if (defaultMatches) {
190 if (defaultMatches) {
188 item(key = "__default") {
191 item(key = "__default") {
189 Column(
192 Column(
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+81−21
@@ -1,3 +1,5 @@
1+@file:Suppress("TooManyFunctions")
2+
1package soy.iko.opencode.ui.chat
3package soy.iko.opencode.ui.chat
2
4
3import android.app.Activity
5import android.app.Activity
@@ -37,6 +39,7 @@ import androidx.compose.material.icons.filled.AutoAwesome
37import androidx.compose.material.icons.filled.Delete
39import androidx.compose.material.icons.filled.Delete
38import androidx.compose.material.icons.filled.Description
40import androidx.compose.material.icons.filled.Description
39import androidx.compose.material.icons.filled.Edit
41import androidx.compose.material.icons.filled.Edit
42+import androidx.compose.material.icons.filled.Expand
40import androidx.compose.material.icons.filled.KeyboardArrowDown
43import androidx.compose.material.icons.filled.KeyboardArrowDown
41import androidx.compose.material.icons.filled.Mic
44import androidx.compose.material.icons.filled.Mic
42import androidx.compose.material.icons.filled.MoreVert
45import androidx.compose.material.icons.filled.MoreVert
@@ -44,6 +47,7 @@ import androidx.compose.material.icons.filled.PhotoCamera
44import androidx.compose.material.icons.filled.PhotoLibrary
47import androidx.compose.material.icons.filled.PhotoLibrary
45import androidx.compose.material.icons.filled.Refresh
48import androidx.compose.material.icons.filled.Refresh
46import androidx.compose.material.icons.filled.Restore
49import androidx.compose.material.icons.filled.Restore
50+import androidx.compose.material.icons.automirrored.filled.PlaylistAdd
47import androidx.compose.material.icons.filled.Schedule
51import androidx.compose.material.icons.filled.Schedule
48import androidx.compose.material.icons.filled.Stop
52import androidx.compose.material.icons.filled.Stop
49import androidx.compose.material3.AlertDialog
53import androidx.compose.material3.AlertDialog
@@ -1224,6 +1228,8 @@ private fun ChatInputBar(
1224) {
1228) {
1225 // Sendable when there's text OR at least one attachment (an image-only prompt is valid).
1229 // Sendable when there's text OR at least one attachment (an image-only prompt is valid).
1226 val hasContent = value.isNotBlank() || attachments.isNotEmpty()
1230 val hasContent = value.isNotBlank() || attachments.isNotEmpty()
1231+ // Full-screen editor state: opened when the user wants more space for a long prompt.
1232+ var showFullScreenEditor by rememberSaveable { mutableStateOf(false) }
1227 Surface(tonalElevation = 3.dp, modifier = Modifier.imePadding()) {
1233 Surface(tonalElevation = 3.dp, modifier = Modifier.imePadding()) {
1228 Column(modifier = Modifier.fillMaxWidth().windowInsetsPadding(WindowInsets.navigationBars)) {
1234 Column(modifier = Modifier.fillMaxWidth().windowInsetsPadding(WindowInsets.navigationBars)) {
1229 // A queued follow-up replaces the Stop button with a "queued" chip so the
1235 // A queued follow-up replaces the Stop button with a "queued" chip so the
@@ -1288,6 +1294,10 @@ private fun ChatInputBar(
1288 IconButton(onClick = onVoice, enabled = enabled) {
1294 IconButton(onClick = onVoice, enabled = enabled) {
1289 Icon(Icons.Filled.Mic, contentDescription = stringResource(R.string.voice_input))
1295 Icon(Icons.Filled.Mic, contentDescription = stringResource(R.string.voice_input))
1290 }
1296 }
1297+ // Expand to a full-screen editor for long prompts (the inline field caps at 6 lines).
1298+ IconButton(onClick = { showFullScreenEditor = true }, enabled = enabled) {
1299+ Icon(Icons.Filled.Expand, contentDescription = stringResource(R.string.expand_editor))
1300+ }
1291 OutlinedTextField(
1301 OutlinedTextField(
1292 value = value,
1302 value = value,
1293 onValueChange = { v ->
1303 onValueChange = { v ->
@@ -1344,26 +1354,15 @@ private fun ChatInputBar(
1344 }
1354 }
1345 },
1355 },
1346 keyboardOptions = KeyboardOptions(
1356 keyboardOptions = KeyboardOptions(
1347− // With send-on-Enter off, expose the platform default action so the soft
1357+ // Always expose the platform default (newline) IME action so a soft-
1348− // keyboard shows a newline key (this is a multi-line field) instead of a
1358+ // keyboard user can insert a newline even when "Send on Enter" is on —
1349− // Send action — otherwise the setting has no effect on soft keyboards and
1359+ // soft keyboards have no Shift key, so the hardware Shift+Enter escape
1350− // there's no way to type a newline. Hardware Enter is handled separately.
1360+ // hatch doesn't apply. The trailing Send button and the hardware Enter
1361+ // (onPreviewKeyEvent above) are the send affordances; the soft IME
1362+ // action is the newline affordance.
1351− imeAction = if (sendOnEnter) ImeAction.Send else ImeAction.Default,
1363+ imeAction = ImeAction.Default,
1352 capitalization = KeyboardCapitalization.Sentences,
1364 capitalization = KeyboardCapitalization.Sentences,
1353 ),
1365 ),
1354− keyboardActions = KeyboardActions(onSend = {
1355− if (enabled) {
1356− // During a run the Send IME action queues a follow-up, but only when
1357− // there's typed text — queuing "" would cancel an existing queued
1358− // follow-up (see the Queue button gate). When not running, an
1359− // attachment-only send is valid, so gate that path on hasContent.
1360− if (running) {
1361− if (value.isNotBlank()) onQueueFollowUp(value)
1362− } else if (hasContent) {
1363− onSend()
1364− }
1365− }
1366− }),
1367 )
1366 )
1368 ComposerTrailingButton(
1367 ComposerTrailingButton(
1369 running = running,
1368 running = running,
@@ -1376,6 +1375,66 @@ private fun ChatInputBar(
1376 )
1375 )
1377 }
1376 }
1378 }
1377 }
1378+ if (showFullScreenEditor) {
1379+ FullScreenEditor(
1380+ value = value,
1381+ onValueChange = onValueChange,
1382+ onDismiss = { showFullScreenEditor = false },
1383+ onSend = { showFullScreenEditor = false; onSend() },
1384+ canSend = enabled && hasContent,
1385+ )
1386+ }
1387+ }
1388+}
1389+
1390+/** Full-screen editor dialog for composing long prompts. Gives the full viewport height to
1391+ * the text field (vs the inline 6-line cap) with a Send button and a collapse action. */
1392+@OptIn(ExperimentalMaterial3Api::class)
1393+@Composable
1394+private fun FullScreenEditor(
1395+ value: String,
1396+ onValueChange: (String) -> Unit,
1397+ onDismiss: () -> Unit,
1398+ onSend: () -> Unit,
1399+ canSend: Boolean,
1400+) {
1401+ androidx.compose.ui.window.Dialog(
1402+ onDismissRequest = onDismiss,
1403+ properties = androidx.compose.ui.window.DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false),
1404+ ) {
1405+ Scaffold(
1406+ topBar = {
1407+ androidx.compose.material3.TopAppBar(
1408+ title = { Text(stringResource(R.string.message_placeholder)) },
1409+ navigationIcon = {
1410+ androidx.compose.material3.TextButton(onClick = onDismiss) {
1411+ Text(stringResource(R.string.collapse_editor))
1412+ }
1413+ },
1414+ )
1415+ },
1416+ bottomBar = {
1417+ Surface(tonalElevation = 3.dp) {
1418+ Row(
1419+ modifier = Modifier.fillMaxWidth().imePadding().padding(12.dp),
1420+ horizontalArrangement = Arrangement.End,
1421+ ) {
1422+ androidx.compose.material3.Button(onClick = onSend, enabled = canSend) {
1423+ Text(stringResource(R.string.send))
1424+ }
1425+ }
1426+ }
1427+ },
1428+ ) { padding ->
1429+ OutlinedTextField(
1430+ value = value,
1431+ onValueChange = { v -> onValueChange(v.take(NetworkConfig.maxDraftLengthChars)) },
1432+ modifier = Modifier.fillMaxSize().padding(padding),
1433+ placeholder = { Text(stringResource(R.string.message_placeholder)) },
1434+ maxLines = Int.MAX_VALUE,
1435+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Default, capitalization = KeyboardCapitalization.Sentences),
1436+ )
1437+ }
1379 }
1438 }
1380}
1439}
1381
1440
@@ -1422,10 +1481,11 @@ private fun ComposerTrailingButton(
1422 onClick = onQueue,
1481 onClick = onQueue,
1423 modifier = Modifier.testTag("queue_button"),
1482 modifier = Modifier.testTag("queue_button"),
1424 ) {
1483 ) {
1425− // A clock/schedule icon (not Send) so it reads as "queue for later" and
1484+ // PlaylistAdd reads as "add to a queue/list" more clearly than a clock
1485+ // icon (which can be read as "snooze" or "later"), and isn't confused with
1426− // isn't confused with the send action sitting right beside the Stop button.
1486+ // the Send action sitting right beside the Stop button.
1427 Icon(
1487 Icon(
1428− Icons.Filled.Schedule,
1488+ Icons.AutoMirrored.Filled.PlaylistAdd,
1429 contentDescription = stringResource(R.string.queue_followup),
1489 contentDescription = stringResource(R.string.queue_followup),
1430 )
1490 )
1431 }
1491 }
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+32−5
@@ -321,7 +321,24 @@ class ChatViewModel(
321 // dispatcher (event collector + viewModelScope launches), so no extra synchronization is needed.
321 // dispatcher (event collector + viewModelScope launches), so no extra synchronization is needed.
322 private val pendingPermissions = LinkedHashMap<String, Permission>()
322 private val pendingPermissions = LinkedHashMap<String, Permission>()
323
323
324+ // Session-scoped permission grants (Allow-for-this-session). Each entry is a (type, patternText)
325+ // pair the user approved with PermissionResponse.SESSION. Subsequent matching requests are
326+ // auto-responded ONCE without showing the dialog, so e.g. a user granting "read" for one path
327+ // isn't re-prompted for every other file read in the same conversation. Cleared on connection
328+ // change (see the activeConnection collector) so it never leaks across servers. The server only
329+ // knows once/always/reject; SESSION is a client-side scope that maps to ONCE on the wire.
330+ private val sessionAllowed = mutableSetOf<Pair<String, String>>()
331+
324 private fun enqueuePermission(permission: Permission) {
332 private fun enqueuePermission(permission: Permission) {
333+ // Auto-respond to a permission whose (type, pattern) the user previously granted for this
334+ // session, instead of showing the dialog again. Resolved immediately (not via the queue) so
335+ // the tool run isn't paused waiting for a dialog the user will never see.
336+ val type = permission.type.orEmpty()
337+ val pattern = permission.patternText.orEmpty()
338+ if (type.isNotEmpty() && sessionAllowed.contains(type to pattern)) {
339+ respondPermission(permission, PermissionResponse.ONCE)
340+ return
341+ }
325 pendingPermissions[permission.id] = permission
342 pendingPermissions[permission.id] = permission
326 _pendingPermission.value = pendingPermissions.values.lastOrNull()
343 _pendingPermission.value = pendingPermissions.values.lastOrNull()
327 updatePermissionProgress()
344 updatePermissionProgress()
@@ -600,11 +617,12 @@ class ChatViewModel(
600 // otherwise the working spinner sticks on, ChatScreen keeps keepScreenOn +
617 // otherwise the working spinner sticks on, ChatScreen keeps keepScreenOn +
601 // the RunForegroundService alive, and the Stop button no-ops (abort() early-
618 // the RunForegroundService alive, and the Stop button no-ops (abort() early-
602 // returns with no connection) until a later reconnect happens to clear it.
619 // returns with no connection) until a later reconnect happens to clear it.
603− _running.value = false
620+ _running.value = false
604− _aborting.value = false
621+ _aborting.value = false
605− clearPermissions()
622+ clearPermissions()
623+ sessionAllowed.clear()
606− sessionTitleJob?.cancel()
624+ sessionTitleJob?.cancel()
607− return@collectLatest
625+ return@collectLatest
608 }
626 }
609 _running.value = false
627 _running.value = false
610 // Allow the re-light below to recover a still-running run after this (re)connect;
628 // Allow the re-light below to recover a still-running run after this (re)connect;
@@ -612,6 +630,7 @@ class ChatViewModel(
612 // won't spuriously re-light.
630 // won't spuriously re-light.
613 runEndedByIdle = false
631 runEndedByIdle = false
614 clearPermissions()
632 clearPermissions()
633+ sessionAllowed.clear()
615 _failedDraft.value = null
634 _failedDraft.value = null
616 // NOTE: deliberately not clearing _queuedFollowUp here. It's session-scoped
635 // NOTE: deliberately not clearing _queuedFollowUp here. It's session-scoped
617 // user intent that is now persisted; wiping it on every (re)connect — which
636 // user intent that is now persisted; wiping it on every (re)connect — which
@@ -1353,6 +1372,14 @@ class ChatViewModel(
1353
1372
1354 fun respondPermission(permission: Permission, response: PermissionResponse) {
1373 fun respondPermission(permission: Permission, response: PermissionResponse) {
1355 val conn = connection ?: return
1374 val conn = connection ?: return
1375+ // Record a session-scoped grant so subsequent matching requests are auto-answered.
1376+ // SESSION maps to ONCE on the wire (the server has no session-scope concept); the
1377+ // client-side set is what gives it the "for this session" semantics.
1378+ if (response == PermissionResponse.SESSION) {
1379+ val type = permission.type.orEmpty()
1380+ val pattern = permission.patternText.orEmpty()
1381+ if (type.isNotEmpty()) sessionAllowed.add(type to pattern)
1382+ }
1356 // Dismiss optimistically (revealing any queued request); permission.replied will confirm.
1383 // Dismiss optimistically (revealing any queued request); permission.replied will confirm.
1357 // If the call fails we re-surface this request so the user can retry instead of being stuck
1384 // If the call fails we re-surface this request so the user can retry instead of being stuck
1358 // with a dismissed dialog and a tool run that's still paused server-side.
1385 // with a dismissed dialog and a tool run that's still paused server-side.
app/src/main/java/soy/iko/opencode/ui/chat/CommandPickerSheet.kt+4−1
@@ -45,6 +45,8 @@ import androidx.compose.ui.text.input.ImeAction
45import androidx.compose.ui.text.style.TextOverflow
45import androidx.compose.ui.text.style.TextOverflow
46import androidx.compose.ui.unit.dp
46import androidx.compose.ui.unit.dp
47import androidx.compose.foundation.layout.Box
47import androidx.compose.foundation.layout.Box
48+import androidx.compose.foundation.layout.fillMaxHeight
49+import androidx.compose.ui.platform.LocalConfiguration
48import soy.iko.opencode.data.model.Command
50import soy.iko.opencode.data.model.Command
49import soy.iko.opencode.data.network.NetworkConfig
51import soy.iko.opencode.data.network.NetworkConfig
50import soy.iko.opencode.R
52import soy.iko.opencode.R
@@ -144,7 +146,8 @@ fun CommandPickerSheet(
144 modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
146 modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
145 )
147 )
146 } else {
148 } else {
149+ // Responsive height: half the viewport rather than a hardcoded 420dp cap.
147− LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
150+ LazyColumn(modifier = Modifier.fillMaxHeight(0.5f)) {
148 // distinctBy the name so duplicate command names from the server can't
151 // distinctBy the name so duplicate command names from the server can't
149 // crash the list with LazyColumn's "Key was already used".
152 // crash the list with LazyColumn's "Key was already used".
150 items(filtered.distinctBy { it.name }, key = { it.name }) { cmd ->
153 items(filtered.distinctBy { it.name }, key = { it.name }) { cmd ->
app/src/main/java/soy/iko/opencode/ui/chat/ConversationExport.kt+18
@@ -21,6 +21,7 @@ fun buildConversationMarkdown(messages: List<MessageWithParts>, title: String?):
21 if (!title.isNullOrBlank()) {
21 if (!title.isNullOrBlank()) {
22 sb.append("# ").append(escapeMarkdown(title.trim())).append("\n\n")
22 sb.append("# ").append(escapeMarkdown(title.trim())).append("\n\n")
23 }
23 }
24+ appendMetadataHeader(sb, messages)
24 for (message in messages) {
25 for (message in messages) {
25 val heading = when (message.info) {
26 val heading = when (message.info) {
26 is UserMessage -> "## You"
27 is UserMessage -> "## You"
@@ -50,6 +51,23 @@ fun buildConversationMarkdown(messages: List<MessageWithParts>, title: String?):
50 return sb.toString().trimEnd()
51 return sb.toString().trimEnd()
51}
52}
52
53
54+/** Append a metadata header (message counts + timestamp range) so a shared transcript has
55+ * context for support/debugging. Skipped when there are no messages. */
56+private fun appendMetadataHeader(sb: StringBuilder, messages: List<MessageWithParts>) {
57+ if (messages.isEmpty()) return
58+ val userCount = messages.count { it.info is UserMessage }
59+ val assistantCount = messages.count { it.info is AssistantMessage }
60+ val firstTime = messages.firstOrNull()?.info?.time?.created
61+ val lastTime = messages.lastOrNull()?.info?.time?.created
62+ sb.append("> ")
63+ sb.append("$userCount user · $assistantCount assistant message")
64+ if (userCount != 1) sb.append("s")
65+ if (firstTime != null && lastTime != null) {
66+ sb.append(" · $firstTime → $lastTime")
67+ }
68+ sb.append("\n\n---\n\n")
69+}
70+
53/** Render a tool call as a compact, readable blockquote summary: the tool name, its
71/** Render a tool call as a compact, readable blockquote summary: the tool name, its
54 * human-readable title (if any), and a truncated output/error. Keeps the exported
72 * human-readable title (if any), and a truncated output/error. Keeps the exported
55 * transcript useful when the agent's work (edits, command runs) is the substance. */
73 * transcript useful when the agent's work (edits, command runs) is the substance. */
app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+151−16
@@ -1,7 +1,9 @@
1package soy.iko.opencode.ui.chat
1package soy.iko.opencode.ui.chat
2
2
3import androidx.compose.foundation.background
3import androidx.compose.foundation.background
4+import androidx.compose.foundation.combinedClickable
4import androidx.compose.foundation.layout.Arrangement
5import androidx.compose.foundation.layout.Arrangement
6+import androidx.compose.foundation.layout.Box
5import androidx.compose.foundation.layout.BoxWithConstraints
7import androidx.compose.foundation.layout.BoxWithConstraints
6import androidx.compose.foundation.layout.Column
8import androidx.compose.foundation.layout.Column
7import androidx.compose.foundation.layout.Row
9import androidx.compose.foundation.layout.Row
@@ -18,6 +20,7 @@ import androidx.compose.material.icons.filled.Edit
18import androidx.compose.material.icons.filled.FormatQuote
20import androidx.compose.material.icons.filled.FormatQuote
19import androidx.compose.material.icons.filled.MoreVert
21import androidx.compose.material.icons.filled.MoreVert
20import androidx.compose.material.icons.filled.Restore
22import androidx.compose.material.icons.filled.Restore
23+import androidx.compose.material.icons.filled.SmartToy
21import androidx.compose.material.icons.filled.StopCircle
24import androidx.compose.material.icons.filled.StopCircle
22import androidx.compose.material3.DropdownMenu
25import androidx.compose.material3.DropdownMenu
23import androidx.compose.material3.DropdownMenuItem
26import androidx.compose.material3.DropdownMenuItem
@@ -38,6 +41,7 @@ import androidx.compose.ui.hapticfeedback.HapticFeedbackType
38import androidx.compose.ui.platform.LocalContext
41import androidx.compose.ui.platform.LocalContext
39import androidx.compose.ui.platform.LocalHapticFeedback
42import androidx.compose.ui.platform.LocalHapticFeedback
40import androidx.compose.ui.res.stringResource
43import androidx.compose.ui.res.stringResource
44+import androidx.compose.ui.semantics.Role
41import androidx.compose.ui.unit.dp
45import androidx.compose.ui.unit.dp
42import soy.iko.opencode.R
46import soy.iko.opencode.R
43import soy.iko.opencode.data.model.AssistantMessage
47import soy.iko.opencode.data.model.AssistantMessage
@@ -69,6 +73,7 @@ fun resolveModelLabel(info: AssistantMessage, models: List<ModelOption>): String
69}
73}
70
74
71/** A single message: user prompts right-aligned in a bubble, assistant output full-width. */
75/** A single message: user prompts right-aligned in a bubble, assistant output full-width. */
76+@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
72@Composable
77@Composable
73fun MessageBubble(
78fun MessageBubble(
74 message: MessageWithParts,
79 message: MessageWithParts,
@@ -91,6 +96,54 @@ fun MessageBubble(
91 }
96 }
92}
97}
93
98
99+/** A long-press context menu consolidating all per-message actions (copy, quote, branch,
100+ * revert). Surfacing these via long-press matches the conventional Android pattern so the
101+ * actions are discoverable without spotting the small 18dp inline icon row. The menu is
102+ * anchored to the long-press position via [offset], and only items with non-null callbacks
103+ * (and text to act on) are shown. */
104+@Composable
105+private fun MessageLongPressMenu(
106+ expanded: Boolean,
107+ onDismiss: () -> Unit,
108+ offset: androidx.compose.ui.unit.DpOffset,
109+ text: String?,
110+ onCopy: (() -> Unit)?,
111+ onQuote: ((String) -> Unit)?,
112+ onBranch: ((String) -> Unit)?,
113+ onRevert: (() -> Unit)?,
114+) {
115+ DropdownMenu(expanded = expanded, onDismissRequest = onDismiss, offset = offset) {
116+ if (text != null && onCopy != null) {
117+ DropdownMenuItem(
118+ leadingIcon = { Icon(Icons.Filled.ContentCopy, contentDescription = null) },
119+ text = { Text(stringResource(R.string.copy)) },
120+ onClick = { onDismiss(); onCopy() },
121+ )
122+ }
123+ if (text != null && onQuote != null) {
124+ DropdownMenuItem(
125+ leadingIcon = { Icon(Icons.Filled.FormatQuote, contentDescription = null) },
126+ text = { Text(stringResource(R.string.quote_reply)) },
127+ onClick = { onDismiss(); onQuote(text) },
128+ )
129+ }
130+ if (text != null && onBranch != null) {
131+ DropdownMenuItem(
132+ leadingIcon = { Icon(Icons.Filled.CallSplit, contentDescription = null) },
133+ text = { Text(stringResource(R.string.branch_session)) },
134+ onClick = { onDismiss(); onBranch(text) },
135+ )
136+ }
137+ if (onRevert != null) {
138+ DropdownMenuItem(
139+ leadingIcon = { Icon(Icons.Filled.Restore, contentDescription = null) },
140+ text = { Text(stringResource(R.string.revert_to_here)) },
141+ onClick = { onDismiss(); onRevert() },
142+ )
143+ }
144+ }
145+}
146+
94/** Overflow menu of secondary per-message actions (quote-reply, branch-a-new-session).
147/** Overflow menu of secondary per-message actions (quote-reply, branch-a-new-session).
95 * Shown when at least one action is available and there's text to act on. Keeps the
148 * Shown when at least one action is available and there's text to act on. Keeps the
96 * inline action row (copy/edit/revert/speak) uncluttered while still surfacing the extras. */
149 * inline action row (copy/edit/revert/speak) uncluttered while still surfacing the extras. */
@@ -201,6 +254,7 @@ private fun UnknownMessageBlock(
201 }
254 }
202}
255}
203
256
257+@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
204@Composable
258@Composable
205private fun UserBubble(
259private fun UserBubble(
206 message: MessageWithParts,
260 message: MessageWithParts,
@@ -216,6 +270,9 @@ private fun UserBubble(
216 val haptics = LocalHapticFeedback.current
270 val haptics = LocalHapticFeedback.current
217 val copyLabel = stringResource(R.string.copy)
271 val copyLabel = stringResource(R.string.copy)
218 val editLabel = stringResource(R.string.edit_message)
272 val editLabel = stringResource(R.string.edit_message)
273+ // Long-press context menu state: consolidates copy/quote/branch/revert into a standard
274+ // Android long-press menu so the actions are discoverable without spotting the 18dp icons.
275+ var longPressMenu by remember { mutableStateOf(false) }
219 // Collect text from all TextParts for copying, so a user can reuse/repost their
276 // Collect text from all TextParts for copying, so a user can reuse/repost their
220 // own prompt. Memoized so a scroll-induced recomposition doesn't re-scan the list.
277 // own prompt. Memoized so a scroll-induced recomposition doesn't re-scan the list.
221 val textToCopy = remember(message.parts) {
278 val textToCopy = remember(message.parts) {
@@ -224,6 +281,12 @@ private fun UserBubble(
224 .joinToString("\n\n") { it.text }
281 .joinToString("\n\n") { it.text }
225 .takeIf { it.isNotBlank() }
282 .takeIf { it.isNotBlank() }
226 }
283 }
284+ val onCopy: (() -> Unit)? = textToCopy?.let { text ->
285+ {
286+ haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
287+ copyToClipboard(context, context.getString(R.string.clip_label_message), text)
288+ }
289+ }
227 // Wrap-content bubble capped at the width fraction (end-aligned), so a one-word prompt is
290 // Wrap-content bubble capped at the width fraction (end-aligned), so a one-word prompt is
228 // snug instead of a wide empty box while long prompts still cap at the readable fraction.
291 // snug instead of a wide empty box while long prompts still cap at the readable fraction.
229 BoxWithConstraints(modifier = modifier.fillMaxWidth()) {
292 BoxWithConstraints(modifier = modifier.fillMaxWidth()) {
@@ -240,8 +303,19 @@ private fun UserBubble(
240 message.parts.forEachIndexed { index, part ->
303 message.parts.forEachIndexed { index, part ->
241 key(part.id, index) { PartView(part, imageContext = imageContext, onOpenFile = onOpenFile) }
304 key(part.id, index) { PartView(part, imageContext = imageContext, onOpenFile = onOpenFile) }
242 }
305 }
306+ // Footer row with timestamp + inline actions. Long-press opens a context menu
307+ // consolidating copy/quote/branch/revert — the conventional Android pattern — so
308+ // the actions are discoverable without spotting the 18dp inline icons.
309+ Box {
243 Row(
310 Row(
244− modifier = Modifier.fillMaxWidth(),
311+ modifier = Modifier.fillMaxWidth().combinedClickable(
312+ role = Role.Button,
313+ onClick = {},
314+ onLongClick = {
315+ haptics.performHapticFeedback(HapticFeedbackType.LongPress)
316+ longPressMenu = true
317+ },
318+ ),
245 horizontalArrangement = Arrangement.SpaceBetween,
319 horizontalArrangement = Arrangement.SpaceBetween,
246 verticalAlignment = Alignment.CenterVertically,
320 verticalAlignment = Alignment.CenterVertically,
247 ) {
321 ) {
@@ -294,10 +368,22 @@ private fun UserBubble(
294 }
368 }
295 }
369 }
296 }
370 }
371+ MessageLongPressMenu(
372+ expanded = longPressMenu,
373+ onDismiss = { longPressMenu = false },
374+ offset = androidx.compose.ui.unit.DpOffset(0.dp, 0.dp),
375+ text = textToCopy,
376+ onCopy = onCopy,
377+ onQuote = onQuote,
378+ onBranch = onBranch,
379+ onRevert = onRevert,
380+ )
381+ }
297 }
382 }
298 }
383 }
299}
384}
300
385
386+@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
301@Composable
387@Composable
302private fun AssistantBlock(
388private fun AssistantBlock(
303 message: MessageWithParts,
389 message: MessageWithParts,
@@ -318,12 +404,32 @@ private fun AssistantBlock(
318 val info = message.info
404 val info = message.info
319 if (info is AssistantMessage) {
405 if (info is AssistantMessage) {
320 val label = modelLabel ?: info.modelID
406 val label = modelLabel ?: info.modelID
407+ // Avatar + model label row: the robot icon gives the assistant a consistent visual
408+ // identity (left-aligned, mirroring the user's right-aligned bubble) so on a long
409+ // scroll the speaker is unambiguous without reading the label.
410+ Row(verticalAlignment = Alignment.CenterVertically) {
411+ Box(
412+ modifier = Modifier
413+ .size(20.dp)
414+ .clip(MaterialTheme.shapes.extraSmall)
415+ .background(MaterialTheme.colorScheme.secondaryContainer),
416+ contentAlignment = Alignment.Center,
417+ ) {
418+ Icon(
419+ Icons.Filled.SmartToy,
420+ contentDescription = null,
421+ modifier = Modifier.size(14.dp),
422+ tint = MaterialTheme.colorScheme.onSecondaryContainer,
423+ )
424+ }
321− if (label != null) {
425+ if (label != null) {
322− Text(
426+ Text(
323− label,
427+ label,
324− style = MaterialTheme.typography.labelSmall,
428+ style = MaterialTheme.typography.labelSmall,
325− color = MaterialTheme.colorScheme.onSurfaceVariant,
429+ color = MaterialTheme.colorScheme.onSurfaceVariant,
326− )
430+ modifier = Modifier.padding(start = 6.dp),
431+ )
432+ }
327 }
433 }
328 }
434 }
329 message.parts.forEachIndexed { index, part ->
435 message.parts.forEachIndexed { index, part ->
@@ -357,8 +463,34 @@ private fun AssistantBlock(
357 cost?.takeIf { it > 0 }?.let { add(formatCost(it, costShort, costLong)) }
463 cost?.takeIf { it > 0 }?.let { add(formatCost(it, costShort, costLong)) }
358 }.takeIf { it.isNotEmpty() }?.joinToString(" • ")
464 }.takeIf { it.isNotEmpty() }?.joinToString(" • ")
359 }
465 }
466+ // Long-press context menu state (see UserBubble for rationale).
467+ var longPressMenu by remember { mutableStateOf(false) }
468+ // Collect text from all TextParts for copy/read-aloud. Memoized so a
469+ // scroll-induced recomposition doesn't re-scan the parts list.
470+ val textToCopy = remember(message.parts) {
471+ message.parts
472+ .filterIsInstance<TextPart>()
473+ .joinToString("\n\n") { it.text }
474+ .takeIf { it.isNotBlank() }
475+ }
476+ val assistantHaptics = LocalHapticFeedback.current
477+ val assistantContext = LocalContext.current
478+ val onCopy: (() -> Unit)? = textToCopy?.let { text ->
479+ {
480+ assistantHaptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
481+ copyToClipboard(assistantContext, assistantContext.getString(R.string.clip_label_message), text)
482+ }
483+ }
484+ Box {
360 Row(
485 Row(
361− modifier = Modifier.fillMaxWidth(),
486+ modifier = Modifier.fillMaxWidth().combinedClickable(
487+ role = Role.Button,
488+ onClick = {},
489+ onLongClick = {
490+ assistantHaptics.performHapticFeedback(HapticFeedbackType.LongPress)
491+ longPressMenu = true
492+ },
493+ ),
362 horizontalArrangement = Arrangement.SpaceBetween,
494 horizontalArrangement = Arrangement.SpaceBetween,
363 verticalAlignment = Alignment.CenterVertically,
495 verticalAlignment = Alignment.CenterVertically,
364 ) {
496 ) {
@@ -372,14 +504,6 @@ private fun AssistantBlock(
372 }
504 }
373 MessageTimestampText(message.info)
505 MessageTimestampText(message.info)
374 }
506 }
375− // Collect text from all TextParts for copy/read-aloud. Memoized so a
376− // scroll-induced recomposition doesn't re-scan the parts list.
377− val textToCopy = remember(message.parts) {
378− message.parts
379− .filterIsInstance<TextPart>()
380− .joinToString("\n\n") { it.text }
381− .takeIf { it.isNotBlank() }
382− }
383 AssistantActions(
507 AssistantActions(
384 textToCopy = textToCopy,
508 textToCopy = textToCopy,
385 isSpeaking = isSpeaking,
509 isSpeaking = isSpeaking,
@@ -388,6 +512,17 @@ private fun AssistantBlock(
388 onQuote = onQuote,
512 onQuote = onQuote,
389 )
513 )
390 }
514 }
515+ MessageLongPressMenu(
516+ expanded = longPressMenu,
517+ onDismiss = { longPressMenu = false },
518+ offset = androidx.compose.ui.unit.DpOffset(0.dp, 0.dp),
519+ text = textToCopy,
520+ onCopy = onCopy,
521+ onQuote = onQuote,
522+ onBranch = null,
523+ onRevert = onRevert,
524+ )
525+ }
391 } else {
526 } else {
392 MessageTimestampText(message.info)
527 MessageTimestampText(message.info)
393 }
528 }
app/src/main/java/soy/iko/opencode/ui/chat/ModelPickerSheet.kt+5−1
@@ -48,6 +48,8 @@ import androidx.compose.ui.semantics.semantics
48import androidx.compose.ui.text.input.ImeAction
48import androidx.compose.ui.text.input.ImeAction
49import androidx.compose.ui.unit.dp
49import androidx.compose.ui.unit.dp
50import androidx.compose.foundation.layout.Box
50import androidx.compose.foundation.layout.Box
51+import androidx.compose.foundation.layout.fillMaxHeight
52+import androidx.compose.ui.platform.LocalConfiguration
51import soy.iko.opencode.data.model.ModelOption
53import soy.iko.opencode.data.model.ModelOption
52import soy.iko.opencode.data.network.NetworkConfig
54import soy.iko.opencode.data.network.NetworkConfig
53import soy.iko.opencode.R
55import soy.iko.opencode.R
@@ -177,7 +179,9 @@ fun ModelPickerSheet(
177 LaunchedEffect(Unit) {
179 LaunchedEffect(Unit) {
178 if (selectedIndex >= 0) listState.scrollToItem(selectedIndex)
180 if (selectedIndex >= 0) listState.scrollToItem(selectedIndex)
179 }
181 }
182+ // Responsive height: half the viewport rather than a hardcoded 420dp cap, so
183+ // the sheet doesn't cramp in landscape or leave empty space on a tall phone.
180− LazyColumn(state = listState, modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
184+ LazyColumn(state = listState, modifier = Modifier.fillMaxHeight(0.5f)) {
181 grouped.forEach { (providerID, opts) ->
185 grouped.forEach { (providerID, opts) ->
182 item(key = "header_$providerID") {
186 item(key = "header_$providerID") {
183 Text(
187 Text(
app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+14−11
@@ -225,19 +225,22 @@ private fun ReasoningBlock(text: String, streaming: Boolean, keyId: String, modi
225 color = MaterialTheme.colorScheme.onSurfaceVariant,
225 color = MaterialTheme.colorScheme.onSurfaceVariant,
226 )
226 )
227 }
227 }
228− TextButton(
229− onClick = {
230− haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
231− copyToClipboard(context, context.getString(R.string.clip_label_reasoning), text)
232− },
233− contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
234− modifier = Modifier.semantics(mergeDescendants = true) {},
235− ) {
236− Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(14.dp))
237− Text(stringResource(R.string.copy), style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(start = 4.dp))
238− }
239 }
228 }
240 }
229 }
230+ // Copy affordance lives outside AnimatedVisibility so it's reachable when the
231+ // block is collapsed — previously the copy button was inside the expanded content,
232+ // leaving a collapsed reasoning block with no copy affordance at all.
233+ TextButton(
234+ onClick = {
235+ haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
236+ copyToClipboard(context, context.getString(R.string.clip_label_reasoning), text)
237+ },
238+ contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
239+ modifier = Modifier.semantics(mergeDescendants = true) {},
240+ ) {
241+ Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(14.dp))
242+ Text(stringResource(R.string.copy), style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(start = 4.dp))
243+ }
241 }
244 }
242}
245}
243
246
app/src/main/java/soy/iko/opencode/ui/chat/PermissionDialog.kt+10−3
@@ -130,6 +130,13 @@ fun PermissionDialog(
130 },
130 },
131 modifier = Modifier.fillMaxWidth(),
131 modifier = Modifier.fillMaxWidth(),
132 ) { Text(stringResource(R.string.allow_once)) }
132 ) { Text(stringResource(R.string.allow_once)) }
133+ OutlinedButton(
134+ onClick = {
135+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
136+ respond(PermissionResponse.SESSION)
137+ },
138+ modifier = Modifier.fillMaxWidth(),
139+ ) { Text(stringResource(R.string.allow_for_session)) }
133 OutlinedButton(
140 OutlinedButton(
134 onClick = {
141 onClick = {
135 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
142 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
@@ -138,9 +145,9 @@ fun PermissionDialog(
138 modifier = Modifier.fillMaxWidth(),
145 modifier = Modifier.fillMaxWidth(),
139 ) { Text(stringResource(R.string.always_allow)) }
146 ) { Text(stringResource(R.string.always_allow)) }
140 // Reject is an OutlinedButton (not a low-prominence TextButton) in the
147 // Reject is an OutlinedButton (not a low-prominence TextButton) in the
141− // same column so the three actions are visually balanced — the prior
148+ // same column so the actions are visually balanced — the prior layout buried
142− // layout buried Reject as a small dismiss-button, nudging users toward
149+ // Reject as a small dismiss-button, nudging users toward granting. Reject
143− // granting. Reject uses the error color to signal its consequence.
150+ // uses the error color to signal its consequence.
144 OutlinedButton(
151 OutlinedButton(
145 onClick = {
152 onClick = {
146 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
153 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
app/src/main/java/soy/iko/opencode/ui/components/ConnectionBanner.kt+37
@@ -4,6 +4,7 @@ import androidx.compose.animation.AnimatedVisibility
4import androidx.compose.animation.fadeIn
4import androidx.compose.animation.fadeIn
5import androidx.compose.animation.fadeOut
5import androidx.compose.animation.fadeOut
6import androidx.compose.foundation.layout.Arrangement
6import androidx.compose.foundation.layout.Arrangement
7+import androidx.compose.foundation.layout.BoxScope
7import androidx.compose.foundation.layout.Row
8import androidx.compose.foundation.layout.Row
8import androidx.compose.foundation.layout.fillMaxWidth
9import androidx.compose.foundation.layout.fillMaxWidth
9import androidx.compose.foundation.layout.padding
10import androidx.compose.foundation.layout.padding
@@ -14,6 +15,8 @@ import androidx.compose.material3.Surface
14import androidx.compose.material3.Text
15import androidx.compose.material3.Text
15import androidx.compose.material3.TextButton
16import androidx.compose.material3.TextButton
16import androidx.compose.runtime.Composable
17import androidx.compose.runtime.Composable
18+import androidx.compose.runtime.getValue
19+import androidx.compose.runtime.produceState
17import androidx.compose.runtime.remember
20import androidx.compose.runtime.remember
18import androidx.compose.runtime.mutableStateOf
21import androidx.compose.runtime.mutableStateOf
19import androidx.compose.ui.Alignment
22import androidx.compose.ui.Alignment
@@ -24,8 +27,13 @@ import androidx.compose.ui.semantics.liveRegion
24import androidx.compose.ui.semantics.LiveRegionMode
27import androidx.compose.ui.semantics.LiveRegionMode
25import androidx.compose.ui.semantics.semantics
28import androidx.compose.ui.semantics.semantics
26import androidx.compose.ui.unit.dp
29import androidx.compose.ui.unit.dp
30+import androidx.lifecycle.compose.collectAsStateWithLifecycle
31+import kotlinx.coroutines.ExperimentalCoroutinesApi
32+import kotlinx.coroutines.flow.flatMapLatest
33+import kotlinx.coroutines.flow.flowOf
27import soy.iko.opencode.R
34import soy.iko.opencode.R
28import soy.iko.opencode.data.network.EventStreamClient
35import soy.iko.opencode.data.network.EventStreamClient
36+import soy.iko.opencode.di.AppContainer
29
37
30/**
38/**
31 * Banner shown when the SSE event stream is not connected. Shared by the chat and the
39 * Banner shown when the SSE event stream is not connected. Shared by the chat and the
@@ -122,3 +130,32 @@ fun ConnectionBanner(
122 }
130 }
123 }
131 }
124}
132}
133+
134+/**
135+ * Convenience wrapper that collects the SSE connection state and device connectivity from
136+ * [container] and renders a [ConnectionBanner] aligned to the top center of the calling
137+ * [BoxScope]. Screens that don't already expose connection state (Files, Settings,
138+ * Diagnostics) can call this instead of duplicating the flatMapLatest boilerplate.
139+ * The retry callback triggers an SSE reconnect on the active connection.
140+ */
141+@OptIn(ExperimentalCoroutinesApi::class)
142+@Composable
143+fun BoxScope.ConnectionBannerFor(container: AppContainer) {
144+ // flatMapLatest without stateIn: collectAsStateWithLifecycle handles the lifecycle and
145+ // gives an initial value. No ViewModel scope needed, so this works on any screen.
146+ val connectionState by produceState(
147+ initialValue = EventStreamClient.ConnectionState.Disconnected,
148+ container,
149+ ) {
150+ container.activeConnection
151+ .flatMapLatest { it?.events?.state ?: flowOf(EventStreamClient.ConnectionState.Disconnected) }
152+ .collect { value = it }
153+ }
154+ val isOnline by container.isOnline.collectAsStateWithLifecycle()
155+ ConnectionBanner(
156+ state = connectionState,
157+ modifier = Modifier.align(Alignment.TopCenter),
158+ isOnline = isOnline,
159+ onRetry = { container.activeConnection.value?.events?.triggerReconnect() },
160+ )
161+}
app/src/main/java/soy/iko/opencode/ui/components/ReducedMotion.ktnew file+47
@@ -0,0 +1,47 @@
1+package soy.iko.opencode.ui.components
2+
3+import android.provider.Settings
4+import androidx.compose.animation.ContentTransform
5+import androidx.compose.animation.EnterTransition
6+import androidx.compose.animation.ExitTransition
7+import androidx.compose.animation.core.tween
8+import androidx.compose.animation.fadeIn
9+import androidx.compose.animation.fadeOut
10+import androidx.compose.animation.slideInHorizontally
11+import androidx.compose.animation.slideOutHorizontally
12+import androidx.compose.animation.togetherWith
13+import androidx.compose.runtime.Composable
14+import androidx.compose.runtime.remember
15+import androidx.compose.ui.platform.LocalContext
16+
17+/**
18+ * Whether the user has reduced motion enabled at the system level (animator duration scale
19+ * set to 0 in Developer Options). When true, transitions should skip animations and content
20+ * swaps should be instant, respecting the user's accessibility preference.
21+ */
22+@Composable
23+fun isReducedMotion(): Boolean {
24+ val context = LocalContext.current
25+ return remember(context) {
26+ runCatching {
27+ Settings.Global.getFloat(
28+ context.contentResolver,
29+ Settings.Global.ANIMATOR_DURATION_SCALE,
30+ 1f,
31+ ) == 0f
32+ }.getOrDefault(false)
33+ }
34+}
35+
36+/**
37+ * Nav transition that respects reduced motion: a horizontal slide + fade normally, but an
38+ * instant swap when the user has disabled animations. Uses the shared [NetworkConfig] durations.
39+ */
40+@Composable
41+fun motionAwareNavTransition(): ContentTransform {
42+ if (isReducedMotion()) {
43+ return EnterTransition.None togetherWith ExitTransition.None
44+ }
45+ return slideInHorizontally(tween(220)) { it } + fadeIn(tween(180)) togetherWith
46+ slideOutHorizontally(tween(220)) { -it } + fadeOut(tween(180))
47+}
app/src/main/java/soy/iko/opencode/ui/components/RemoteImage.kt+83−3
@@ -1,24 +1,33 @@
1package soy.iko.opencode.ui.components
1package soy.iko.opencode.ui.components
2
2
3import android.util.Base64
3import android.util.Base64
4+import androidx.compose.foundation.background
4import androidx.compose.foundation.clickable
5import androidx.compose.foundation.clickable
6+import androidx.compose.foundation.gestures.detectTransformGestures
5import androidx.compose.foundation.layout.Box
7import androidx.compose.foundation.layout.Box
6import androidx.compose.foundation.layout.Column
8import androidx.compose.foundation.layout.Column
9+import androidx.compose.foundation.layout.fillMaxSize
7import androidx.compose.foundation.layout.fillMaxWidth
10import androidx.compose.foundation.layout.fillMaxWidth
8import androidx.compose.foundation.layout.heightIn
11import androidx.compose.foundation.layout.heightIn
9import androidx.compose.foundation.layout.padding
12import androidx.compose.foundation.layout.padding
13+import androidx.compose.material3.CircularProgressIndicator
14+import androidx.compose.material3.Icon
10import androidx.compose.material3.MaterialTheme
15import androidx.compose.material3.MaterialTheme
11import androidx.compose.material3.Text
16import androidx.compose.material3.Text
12import androidx.compose.runtime.Composable
17import androidx.compose.runtime.Composable
13import androidx.compose.runtime.Immutable
18import androidx.compose.runtime.Immutable
14import androidx.compose.runtime.getValue
19import androidx.compose.runtime.getValue
20+import androidx.compose.runtime.mutableFloatStateOf
15import androidx.compose.runtime.mutableIntStateOf
21import androidx.compose.runtime.mutableIntStateOf
22+import androidx.compose.runtime.mutableStateOf
16import androidx.compose.runtime.produceState
23import androidx.compose.runtime.produceState
17import androidx.compose.runtime.remember
24import androidx.compose.runtime.remember
18import androidx.compose.runtime.setValue
25import androidx.compose.runtime.setValue
19import androidx.compose.ui.Alignment
26import androidx.compose.ui.Alignment
20import androidx.compose.ui.Modifier
27import androidx.compose.ui.Modifier
21import androidx.compose.ui.draw.clip
28import androidx.compose.ui.draw.clip
29+import androidx.compose.ui.graphics.graphicsLayer
30+import androidx.compose.ui.input.pointer.pointerInput
22import androidx.compose.ui.layout.ContentScale
31import androidx.compose.ui.layout.ContentScale
23import androidx.compose.ui.platform.LocalContext
32import androidx.compose.ui.platform.LocalContext
24import androidx.compose.ui.res.stringResource
33import androidx.compose.ui.res.stringResource
@@ -26,14 +35,15 @@ import androidx.compose.ui.semantics.Role
26import androidx.compose.ui.semantics.contentDescription
35import androidx.compose.ui.semantics.contentDescription
27import androidx.compose.ui.semantics.semantics
36import androidx.compose.ui.semantics.semantics
28import androidx.compose.ui.unit.dp
37import androidx.compose.ui.unit.dp
38+import androidx.compose.ui.window.Dialog
39+import androidx.compose.ui.window.DialogProperties
29import coil.compose.SubcomposeAsyncImage
40import coil.compose.SubcomposeAsyncImage
30import coil.request.ImageRequest
41import coil.request.ImageRequest
31import kotlinx.coroutines.Dispatchers
42import kotlinx.coroutines.Dispatchers
32import kotlinx.coroutines.withContext
43import kotlinx.coroutines.withContext
33−import androidx.compose.material3.CircularProgressIndicator
34−import androidx.compose.material3.Icon
35import androidx.compose.material.icons.Icons
44import androidx.compose.material.icons.Icons
36import androidx.compose.material.icons.filled.BrokenImage
45import androidx.compose.material.icons.filled.BrokenImage
46+import androidx.compose.material.icons.filled.Close
37import soy.iko.opencode.R
47import soy.iko.opencode.R
38import soy.iko.opencode.data.model.FilePart
48import soy.iko.opencode.data.model.FilePart
39import soy.iko.opencode.data.model.ServerProfile
49import soy.iko.opencode.data.model.ServerProfile
@@ -175,6 +185,8 @@ fun RemoteImage(part: FilePart, ctx: ImageLoadContext, modifier: Modifier = Modi
175 // Bumping retryKey re-builds the ImageRequest, so a failed load can be re-issued by tapping
185 // Bumping retryKey re-builds the ImageRequest, so a failed load can be re-issued by tapping
176 // the error state instead of being a permanent broken-image icon.
186 // the error state instead of being a permanent broken-image icon.
177 var retryKey by remember(part.source, part.url, ctx.baseUrl) { mutableIntStateOf(0) }
187 var retryKey by remember(part.source, part.url, ctx.baseUrl) { mutableIntStateOf(0) }
188+ // Fullscreen zoomable viewer state: opened by tapping the inline image.
189+ var showFullscreen by remember { mutableStateOf(false) }
178 val request = remember(part.source, part.url, ctx.baseUrl, ctx.basicAuthHeader, model, retryKey) {
190 val request = remember(part.source, part.url, ctx.baseUrl, ctx.basicAuthHeader, model, retryKey) {
179 ImageRequest.Builder(context)
191 ImageRequest.Builder(context)
180 .data(model)
192 .data(model)
@@ -193,7 +205,10 @@ fun RemoteImage(part: FilePart, ctx: ImageLoadContext, modifier: Modifier = Modi
193 contentScale = ContentScale.FillWidth,
205 contentScale = ContentScale.FillWidth,
194 modifier = modifier
206 modifier = modifier
195 .heightIn(max = 320.dp)
207 .heightIn(max = 320.dp)
196− .clip(MaterialTheme.shapes.small),
208+ .clip(MaterialTheme.shapes.small)
209+ // Tap to open a fullscreen zoomable viewer so the user can inspect details
210+ // without the inline 320dp height cap.
211+ .clickable(role = Role.Button) { showFullscreen = true },
197 loading = {
212 loading = {
198 Box(
213 Box(
199 modifier = Modifier.fillMaxWidth().heightIn(min = 120.dp),
214 modifier = Modifier.fillMaxWidth().heightIn(min = 120.dp),
@@ -207,6 +222,71 @@ fun RemoteImage(part: FilePart, ctx: ImageLoadContext, modifier: Modifier = Modi
207 },
222 },
208 error = { ImageRetry(onRetry = { retryKey++ }) },
223 error = { ImageRetry(onRetry = { retryKey++ }) },
209 )
224 )
225+ if (showFullscreen) {
226+ FullscreenImageViewer(request = request, contentDescription = part.filename ?: stringResource(R.string.image)) {
227+ showFullscreen = false
228+ }
229+ }
230+}
231+
232+/**
233+ * Fullscreen zoomable image viewer opened by tapping an inline [RemoteImage]. Supports
234+ * pinch-to-zoom and pan via [detectTransformGestures], with a close button and tap-to-dismiss.
235+ * Uses a non-interactive Dialog window so it overlays the whole screen.
236+ */
237+@Composable
238+private fun FullscreenImageViewer(
239+ request: ImageRequest,
240+ contentDescription: String,
241+ onDismiss: () -> Unit,
242+) {
243+ var scale by remember { mutableFloatStateOf(1f) }
244+ var offsetX by remember { mutableFloatStateOf(0f) }
245+ var offsetY by remember { mutableFloatStateOf(0f) }
246+ Dialog(
247+ onDismissRequest = onDismiss,
248+ properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false),
249+ ) {
250+ Box(
251+ modifier = Modifier
252+ .fillMaxSize()
253+ .background(androidx.compose.ui.graphics.Color.Black)
254+ .pointerInput(Unit) {
255+ detectTransformGestures { _, pan, zoom, _ ->
256+ scale *= zoom
257+ offsetX += pan.x
258+ offsetY += pan.y
259+ }
260+ }
261+ .clickable(role = Role.Button, onClick = onDismiss),
262+ contentAlignment = Alignment.Center,
263+ ) {
264+ SubcomposeAsyncImage(
265+ model = request,
266+ contentDescription = contentDescription,
267+ contentScale = ContentScale.Fit,
268+ modifier = Modifier
269+ .fillMaxSize()
270+ .graphicsLayer(
271+ scaleX = scale,
272+ scaleY = scale,
273+ translationX = offsetX,
274+ translationY = offsetY,
275+ ),
276+ )
277+ // Close button in the top corner.
278+ androidx.compose.material3.IconButton(
279+ onClick = onDismiss,
280+ modifier = Modifier.align(Alignment.TopEnd).padding(16.dp),
281+ ) {
282+ Icon(
283+ Icons.Filled.Close,
284+ contentDescription = stringResource(R.string.close),
285+ tint = androidx.compose.ui.graphics.Color.White,
286+ )
287+ }
288+ }
289+ }
210}
290}
211
291
212/** Error slot for [RemoteImage]: a broken-image icon plus a "Tap to retry" caption, the whole box
292/** Error slot for [RemoteImage]: a broken-image icon plus a "Tap to retry" caption, the whole box
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+6
@@ -73,6 +73,7 @@ import soy.iko.opencode.data.model.SymbolResult
73import soy.iko.opencode.data.model.symbolKindLabel
73import soy.iko.opencode.data.model.symbolKindLabel
74import soy.iko.opencode.di.AppContainer
74import soy.iko.opencode.di.AppContainer
75import soy.iko.opencode.R
75import soy.iko.opencode.R
76+import soy.iko.opencode.ui.components.ConnectionBannerFor
76import soy.iko.opencode.ui.vmFactory
77import soy.iko.opencode.ui.vmFactory
77import soy.iko.opencode.util.runCatchingCancellable
78import soy.iko.opencode.util.runCatchingCancellable
78
79
@@ -108,6 +109,11 @@ fun FileBrowserScreen(
108 snackbarHost = { SnackbarHost(snackbar) },
109 snackbarHost = { SnackbarHost(snackbar) },
109 ) { padding ->
110 ) { padding ->
110 Column(modifier = Modifier.fillMaxSize().padding(padding)) {
111 Column(modifier = Modifier.fillMaxSize().padding(padding)) {
112+ // Surface SSE connection state so a dropped stream is visible while browsing
113+ // files, not just on the chat/session screens.
114+ Box(modifier = Modifier.fillMaxWidth()) {
115+ ConnectionBannerFor(container)
116+ }
111 // Tappable breadcrumb trail so deep paths are navigable.
117 // Tappable breadcrumb trail so deep paths are navigable.
112 Breadcrumbs(
118 Breadcrumbs(
113 path = state.path,
119 path = state.path,
app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+21−6
@@ -80,6 +80,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
80import androidx.lifecycle.viewmodel.compose.viewModel
80import androidx.lifecycle.viewmodel.compose.viewModel
81import soy.iko.opencode.di.AppContainer
81import soy.iko.opencode.di.AppContainer
82import soy.iko.opencode.R
82import soy.iko.opencode.R
83+import soy.iko.opencode.ui.components.ConnectionBannerFor
83import soy.iko.opencode.ui.components.DiffView
84import soy.iko.opencode.ui.components.DiffView
84import soy.iko.opencode.ui.components.LocalCodeWrap
85import soy.iko.opencode.ui.components.LocalCodeWrap
85import soy.iko.opencode.ui.components.copyToClipboard
86import soy.iko.opencode.ui.components.copyToClipboard
@@ -93,10 +94,10 @@ import kotlinx.coroutines.delay
93import kotlinx.coroutines.flow.collect
94import kotlinx.coroutines.flow.collect
94import kotlinx.coroutines.withContext
95import kotlinx.coroutines.withContext
95
96
97+import soy.iko.opencode.data.network.NetworkConfig
98+
96−/** Cap on how many lines the raw viewer renders and searches. A multi-megabyte file is
99+/** Cap on how many lines the raw viewer renders and searches. See [NetworkConfig.maxRenderedFileLines]. */
97− * split once (not twice) into this many lines at most, so it can't hold two full line
98− * lists in memory or stall the LazyColumn; beyond the cap a truncation banner is shown. */
99−private const val MAX_RENDERED_LINES = 5000
100+private const val MAX_RENDERED_LINES = NetworkConfig.maxRenderedFileLines
100
101
101@OptIn(ExperimentalMaterial3Api::class)
102@OptIn(ExperimentalMaterial3Api::class)
102@Composable
103@Composable
@@ -207,7 +208,17 @@ fun FileViewScreen(
207 )
208 )
208 },
209 },
209 ) { padding ->
210 ) { padding ->
211+ // Pull-to-refresh wraps the content so a user who pull-to-refreshes out of habit
212+ // can reload the file, mirroring the file browser and session list gestures. The
213+ // top-bar reload icon remains for a targeted tap.
214+ androidx.compose.material3.pulltorefresh.PullToRefreshBox(
215+ isRefreshing = state.loading && state.content != null,
216+ onRefresh = { vm.reload() },
217+ modifier = Modifier.fillMaxSize().padding(padding),
218+ ) {
210− Box(modifier = Modifier.fillMaxSize().padding(padding)) {
219+ Box(modifier = Modifier.fillMaxSize()) {
220+ // Surface SSE connection state so a dropped stream is visible while viewing a file.
221+ ConnectionBannerFor(container)
211 // AnimatedContent cross-fades between loading / error / content states so the
222 // AnimatedContent cross-fades between loading / error / content states so the
212 // viewer doesn't snap abruptly when a reload finishes or fails.
223 // viewer doesn't snap abruptly when a reload finishes or fails.
213 AnimatedContent(
224 AnimatedContent(
@@ -251,6 +262,7 @@ fun FileViewScreen(
251 onRetry = { vm.reload() },
262 onRetry = { vm.reload() },
252 )
263 )
253 }
264 }
265+ }
254 }
266 }
255 }
267 }
256}
268}
@@ -586,7 +598,10 @@ private fun FindBar(
586 else MaterialTheme.colorScheme.onSurfaceVariant
598 else MaterialTheme.colorScheme.onSurfaceVariant
587 Text(
599 Text(
588 countText,
600 countText,
601+ // labelMedium (not labelSmall) so the counter is legible for low-vision
602+ // users at large font scales; the error color still distinguishes zero
603+ // matches at a glance.
589− style = MaterialTheme.typography.labelSmall,
604+ style = MaterialTheme.typography.labelMedium,
590 color = color,
605 color = color,
591 )
606 )
592 }
607 }
app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+32−14
@@ -65,6 +65,7 @@ import androidx.compose.ui.unit.dp
65import androidx.lifecycle.compose.collectAsStateWithLifecycle
65import androidx.lifecycle.compose.collectAsStateWithLifecycle
66import androidx.lifecycle.viewmodel.compose.viewModel
66import androidx.lifecycle.viewmodel.compose.viewModel
67import soy.iko.opencode.data.network.NsdDiscovery
67import soy.iko.opencode.data.network.NsdDiscovery
68+import soy.iko.opencode.data.network.NetworkConfig
68import soy.iko.opencode.di.AppContainer
69import soy.iko.opencode.di.AppContainer
69import soy.iko.opencode.R
70import soy.iko.opencode.R
70import soy.iko.opencode.ui.vmFactory
71import soy.iko.opencode.ui.vmFactory
@@ -459,20 +460,17 @@ private fun DiscoverySection(onPick: (String) -> Unit, initiallyExpanded: Boolea
459 val discovery = remember { NsdDiscovery(context) }
460 val discovery = remember { NsdDiscovery(context) }
460 val servers by remember(discovery) { discovery.discover() }
461 val servers by remember(discovery) { discovery.discover() }
461 .collectAsStateWithLifecycle(initialValue = emptyList())
462 .collectAsStateWithLifecycle(initialValue = emptyList())
463+ // mDNS discovery is a continuous flow that never emits a terminal "no results"
464+ // state — on a network with no opencode servers the spinner would spin forever.
465+ // After the timeout, swap the spinner for a "no servers found" message. Discovery
466+ // keeps running, so a late server can still replace the message when it arrives.
467+ var searchTimedOut by remember { mutableStateOf(false) }
468+ LaunchedEffect(Unit) {
469+ searchTimedOut = false
470+ kotlinx.coroutines.delay(NetworkConfig.nsdDiscoveryNoResultTimeoutMs)
471+ searchTimedOut = true
472+ }
462− if (servers.isEmpty()) {
473+ if (servers.isNotEmpty()) {
463− Row(
464− verticalAlignment = Alignment.CenterVertically,
465− modifier = Modifier.padding(vertical = 8.dp),
466− ) {
467− CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
468− Text(
469− stringResource(R.string.searching_network),
470− style = MaterialTheme.typography.bodySmall,
471− color = MaterialTheme.colorScheme.onSurfaceVariant,
472− modifier = Modifier.padding(start = 8.dp),
473− )
474− }
475− } else {
476 servers.forEach { server ->
474 servers.forEach { server ->
477 Column(
475 Column(
478 modifier = Modifier
476 modifier = Modifier
@@ -490,6 +488,26 @@ private fun DiscoverySection(onPick: (String) -> Unit, initiallyExpanded: Boolea
490 )
488 )
491 }
489 }
492 }
490 }
491+ } else if (searchTimedOut) {
492+ Text(
493+ stringResource(R.string.no_servers_found),
494+ style = MaterialTheme.typography.bodySmall,
495+ color = MaterialTheme.colorScheme.onSurfaceVariant,
496+ modifier = Modifier.padding(vertical = 8.dp),
497+ )
498+ } else {
499+ Row(
500+ verticalAlignment = Alignment.CenterVertically,
501+ modifier = Modifier.padding(vertical = 8.dp),
502+ ) {
503+ CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
504+ Text(
505+ stringResource(R.string.searching_network),
506+ style = MaterialTheme.typography.bodySmall,
507+ color = MaterialTheme.colorScheme.onSurfaceVariant,
508+ modifier = Modifier.padding(start = 8.dp),
509+ )
510+ }
493 }
511 }
494 }
512 }
495 }
513 }
app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+7−2
@@ -285,8 +285,13 @@ class ServerEditViewModel(
285 val probedRequireHttps = s.requireHttps
285 val probedRequireHttps = s.requireHttps
286 val probedCertPin = s.certPin.trim().takeIf { it.isNotBlank() }
286 val probedCertPin = s.certPin.trim().takeIf { it.isNotBlank() }
287 viewModelScope.launch {
287 viewModelScope.launch {
288+ // Bound the probe so a hung/unresponsive server doesn't leave the "Testing…"
289+ // spinner spinning indefinitely. A timeout surfaces as a failure (the probe
290+ // didn't confirm credentials), not a silent hang.
288 val result = runCatchingCancellable {
291 val result = runCatchingCancellable {
292+ withTimeoutOrNull(NetworkConfig.testCredentialsTimeoutMs) {
289− container.probeWithCredentials(probedUrl, probedUser, probedPass, probedRequireHttps, probedCertPin)
293+ container.probeWithCredentials(probedUrl, probedUser, probedPass, probedRequireHttps, probedCertPin)
294+ }
290 }
295 }
291 result.onSuccess { ok ->
296 result.onSuccess { ok ->
292 _state.update {
297 _state.update {
@@ -295,7 +300,7 @@ class ServerEditViewModel(
295 it.copy(
300 it.copy(
296 testingCredentials = false,
301 testingCredentials = false,
297 credentialsResult = ok,
302 credentialsResult = ok,
298− error = if (ok) null else container.string(R.string.credentials_rejected),
303+ error = if (ok == true) null else container.string(R.string.credentials_rejected),
299 )
304 )
300 }
305 }
301 }.onFailure { e ->
306 }.onFailure { e ->
app/src/main/java/soy/iko/opencode/ui/session/NewSessionDialog.kt+2
@@ -3,6 +3,7 @@ package soy.iko.opencode.ui.session
3import androidx.compose.foundation.horizontalScroll
3import androidx.compose.foundation.horizontalScroll
4import androidx.compose.foundation.layout.Column
4import androidx.compose.foundation.layout.Column
5import androidx.compose.foundation.layout.Row
5import androidx.compose.foundation.layout.Row
6+import androidx.compose.foundation.layout.defaultMinSize
6import androidx.compose.foundation.layout.fillMaxWidth
7import androidx.compose.foundation.layout.fillMaxWidth
7import androidx.compose.foundation.layout.heightIn
8import androidx.compose.foundation.layout.heightIn
8import androidx.compose.foundation.layout.padding
9import androidx.compose.foundation.layout.padding
@@ -230,6 +231,7 @@ private fun DirectoryRow(
230 Row(
231 Row(
231 modifier = Modifier
232 modifier = Modifier
232 .fillMaxWidth()
233 .fillMaxWidth()
234+ .defaultMinSize(minHeight = 48.dp)
233 .selectable(selected = selected, enabled = enabled, role = Role.RadioButton, onClick = onSelect)
235 .selectable(selected = selected, enabled = enabled, role = Role.RadioButton, onClick = onSelect)
234 .padding(vertical = 4.dp),
236 .padding(vertical = 4.dp),
235 verticalAlignment = Alignment.CenterVertically,
237 verticalAlignment = Alignment.CenterVertically,
app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+2
@@ -660,6 +660,7 @@ private fun ThemeRow(mode: ThemeMode, selected: Boolean, usingSystemColors: Bool
660 }
660 }
661 ThemeMode.LIGHT -> LightSwatches
661 ThemeMode.LIGHT -> LightSwatches
662 ThemeMode.DARK -> DarkSwatches
662 ThemeMode.DARK -> DarkSwatches
663+ ThemeMode.AMOLED -> DarkSwatches
663 }
664 }
664 Row(
665 Row(
665 modifier = Modifier
666 modifier = Modifier
@@ -675,6 +676,7 @@ private fun ThemeRow(mode: ThemeMode, selected: Boolean, usingSystemColors: Bool
675 ThemeMode.SYSTEM -> stringResource(R.string.theme_system)
676 ThemeMode.SYSTEM -> stringResource(R.string.theme_system)
676 ThemeMode.LIGHT -> stringResource(R.string.theme_light)
677 ThemeMode.LIGHT -> stringResource(R.string.theme_light)
677 ThemeMode.DARK -> stringResource(R.string.theme_dark)
678 ThemeMode.DARK -> stringResource(R.string.theme_dark)
679+ ThemeMode.AMOLED -> stringResource(R.string.theme_amoled)
678 },
680 },
679 style = MaterialTheme.typography.bodyLarge,
681 style = MaterialTheme.typography.bodyLarge,
680 modifier = Modifier.weight(1f),
682 modifier = Modifier.weight(1f),
app/src/main/java/soy/iko/opencode/ui/theme/Theme.kt+15
@@ -95,16 +95,31 @@ private val LightColors = lightColorScheme(
95 scrim = Color(0xFF000000),
95 scrim = Color(0xFF000000),
96)
96)
97
97
98+/**
99+ * AMOLED — the Tokyo Night dark palette with pure-black backgrounds (#000000) so OLED
100+ * displays power off pixels in large black areas, extending battery life. The accent
101+ * colours stay the same so the brand identity is preserved; only the backgrounds and
102+ * surfaces drop to true black.
103+ */
104+private val AmoledColors = DarkColors.copy(
105+ background = Color(0xFF000000),
106+ surface = Color(0xFF000000),
107+ surfaceVariant = Color(0xFF1A1F2E),
108+ inverseOnSurface = Color(0xFF000000),
109+)
110+
98@Composable
111@Composable
99fun OpencodeTheme(
112fun OpencodeTheme(
100 darkTheme: Boolean = isSystemInDarkTheme(),
113 darkTheme: Boolean = isSystemInDarkTheme(),
101 dynamicColor: Boolean = false,
114 dynamicColor: Boolean = false,
115+ amoled: Boolean = false,
102 content: @Composable () -> Unit,
116 content: @Composable () -> Unit,
103) {
117) {
104 val context = LocalContext.current
118 val context = LocalContext.current
105 val colorScheme = when {
119 val colorScheme = when {
106 dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ->
120 dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ->
107 if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
121 if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
122+ darkTheme && amoled -> AmoledColors
108 darkTheme -> DarkColors
123 darkTheme -> DarkColors
109 else -> LightColors
124 else -> LightColors
110 }
125 }
app/src/main/res/values-es/strings.xml+5
@@ -32,6 +32,7 @@
32 <string name="theme_system">Sistema</string>
32 <string name="theme_system">Sistema</string>
33 <string name="theme_light">Claro</string>
33 <string name="theme_light">Claro</string>
34 <string name="theme_dark">Oscuro</string>
34 <string name="theme_dark">Oscuro</string>
35+ <string name="theme_amoled">Negro AMOLED</string>
35
36
36 <!-- Connection banner -->
37 <!-- Connection banner -->
37 <string name="connecting">Conectando…</string>
38 <string name="connecting">Conectando…</string>
@@ -75,6 +76,7 @@
75 <string name="test_credentials">Probar credenciales</string>
76 <string name="test_credentials">Probar credenciales</string>
76 <string name="discover_on_network">Descubrir en la red</string>
77 <string name="discover_on_network">Descubrir en la red</string>
77 <string name="searching_network">Buscando en la red local…</string>
78 <string name="searching_network">Buscando en la red local…</string>
79+ <string name="no_servers_found">No se encontraron servidores de opencode en la red local.</string>
78 <string name="testing_credentials">Probando…</string>
80 <string name="testing_credentials">Probando…</string>
79 <string name="credentials_ok">Credenciales aceptadas.</string>
81 <string name="credentials_ok">Credenciales aceptadas.</string>
80 <string name="credentials_rejected">Credenciales rechazadas.</string>
82 <string name="credentials_rejected">Credenciales rechazadas.</string>
@@ -108,6 +110,8 @@
108 <string name="session">Sesión</string>
110 <string name="session">Sesión</string>
109 <string name="default_model">modelo predeterminado</string>
111 <string name="default_model">modelo predeterminado</string>
110 <string name="message_placeholder">Escribe a opencode…</string>
112 <string name="message_placeholder">Escribe a opencode…</string>
113+ <string name="expand_editor">Expandir editor</string>
114+ <string name="collapse_editor">Contraer</string>
111 <string name="empty_chat_title">Inicia una conversación</string>
115 <string name="empty_chat_title">Inicia una conversación</string>
112 <string name="empty_chat_hint">Escribe un mensaje abajo para empezar.</string>
116 <string name="empty_chat_hint">Escribe un mensaje abajo para empezar.</string>
113 <string name="empty_chat_suggest_1">Explica qué hace este código</string>
117 <string name="empty_chat_suggest_1">Explica qué hace este código</string>
@@ -163,6 +167,7 @@
163 <string name="permission_title">Permiso solicitado</string>
167 <string name="permission_title">Permiso solicitado</string>
164 <string name="permission_text">¿Permitir que esta herramienta se ejecute?</string>
168 <string name="permission_text">¿Permitir que esta herramienta se ejecute?</string>
165 <string name="allow_once">Permitir una vez</string>
169 <string name="allow_once">Permitir una vez</string>
170+ <string name="allow_for_session">Permitir por esta sesión</string>
166 <string name="always_allow">Permitir siempre</string>
171 <string name="always_allow">Permitir siempre</string>
167 <string name="reject">Rechazar</string>
172 <string name="reject">Rechazar</string>
168
173
app/src/main/res/values/strings.xml+5
@@ -32,6 +32,7 @@
32 <string name="theme_system">System</string>
32 <string name="theme_system">System</string>
33 <string name="theme_light">Light</string>
33 <string name="theme_light">Light</string>
34 <string name="theme_dark">Dark</string>
34 <string name="theme_dark">Dark</string>
35+ <string name="theme_amoled">AMOLED Black</string>
35
36
36 <!-- Connection banner -->
37 <!-- Connection banner -->
37 <string name="connecting">Connecting…</string>
38 <string name="connecting">Connecting…</string>
@@ -75,6 +76,7 @@
75 <string name="test_credentials">Test credentials</string>
76 <string name="test_credentials">Test credentials</string>
76 <string name="discover_on_network">Discover on network</string>
77 <string name="discover_on_network">Discover on network</string>
77 <string name="searching_network">Searching the local network…</string>
78 <string name="searching_network">Searching the local network…</string>
79+ <string name="no_servers_found">No opencode servers found on the local network.</string>
78 <string name="testing_credentials">Testing…</string>
80 <string name="testing_credentials">Testing…</string>
79 <string name="credentials_ok">Credentials accepted.</string>
81 <string name="credentials_ok">Credentials accepted.</string>
80 <string name="credentials_rejected">Credentials rejected.</string>
82 <string name="credentials_rejected">Credentials rejected.</string>
@@ -108,6 +110,8 @@
108 <string name="session">Session</string>
110 <string name="session">Session</string>
109 <string name="default_model">default model</string>
111 <string name="default_model">default model</string>
110 <string name="message_placeholder">Message opencode…</string>
112 <string name="message_placeholder">Message opencode…</string>
113+ <string name="expand_editor">Expand editor</string>
114+ <string name="collapse_editor">Collapse</string>
111 <string name="empty_chat_title">Start a conversation</string>
115 <string name="empty_chat_title">Start a conversation</string>
112 <string name="empty_chat_hint">Type a message below to begin.</string>
116 <string name="empty_chat_hint">Type a message below to begin.</string>
113 <string name="empty_chat_suggest_1">Explain what this codebase does</string>
117 <string name="empty_chat_suggest_1">Explain what this codebase does</string>
@@ -163,6 +167,7 @@
163 <string name="permission_title">Permission requested</string>
167 <string name="permission_title">Permission requested</string>
164 <string name="permission_text">Allow this tool to run?</string>
168 <string name="permission_text">Allow this tool to run?</string>
165 <string name="allow_once">Allow once</string>
169 <string name="allow_once">Allow once</string>
170+ <string name="allow_for_session">Allow for this session</string>
166 <string name="always_allow">Always allow</string>
171 <string name="always_allow">Always allow</string>
167 <string name="reject">Reject</string>
172 <string name="reject">Reject</string>
168
173
app/src/test/java/soy/iko/opencode/ui/chat/ConversationExportTest.kt+3−1
@@ -35,7 +35,9 @@ class ConversationExportTest {
35 @Test
35 @Test
36 fun omitsTitleWhenBlank() {
36 fun omitsTitleWhenBlank() {
37 val md = buildConversationMarkdown(listOf(user("u1", "hi")), title = null)
37 val md = buildConversationMarkdown(listOf(user("u1", "hi")), title = null)
38+ // The metadata header is now prepended (message count + timestamp range), so the
39+ // first message heading follows it.
38− assertTrue(md.startsWith("## You"))
40+ assertTrue(md.contains("## You"))
39 }
41 }
40
42
41 @Test
43 @Test