git.iko.soy/opencode-android
on master help sign in
Polish app UX with draft retention, confirmations, smart scroll, copy support, session previews, and richer settings
e9b2cc3iko
README.md+11−1
@@ -6,7 +6,7 @@ Application id: `soy.iko.opencode`
6
6
7## Status
7## Status
8
8
9−Milestones M0–M6 complete:
9+Milestones M0–M7 complete:
10
10
11- **M0–M4:** connect to a server → list/create/delete sessions → chat with **live SSE streaming**
11- **M0–M4:** connect to a server → list/create/delete sessions → chat with **live SSE streaming**
12 of the assistant's reply. Tool calls, reasoning, and token/cost are rendered.
12 of the assistant's reply. Tool calls, reasoning, and token/cost are rendered.
@@ -22,6 +22,16 @@ Milestones M0–M6 complete:
22 the `agent` field on the prompt body); `GET /command` powers a slash-command runner that sends the
22 the `agent` field on the prompt body); `GET /command` powers a slash-command runner that sends the
23 command template with its configured agent; **multi-server quick-switch** — the session list title
23 command template with its configured agent; **multi-server quick-switch** — the session list title
24 is a dropdown that lists all saved servers and switches instantly without navigating back.
24 is a dropdown that lists all saved servers and switches instantly without navigating back.
25+- **M7:** **UX polish pass.** Draft retention (failed sends restore your text with a Retry action);
26+ destructive-action confirmations (delete session / remove server); the app bar now shows the real
27+ session title; smart auto-scroll that won't fight a reader who scrolled up, with a jump-to-latest
28+ FAB; long-press to copy assistant markdown, plus copy buttons on tool output and diffs; expandable
29+ tool output (Show more / Show less) instead of a hard truncation; **session list** with relative
30+ timestamps, last-message previews, and a search filter; tappable **file-browser breadcrumbs**;
31+ a "connected" marker on the active server; permission dialog with clear action hierarchy;
32+ dynamic "Thinking…" → "Thoughts" reasoning label; back-press guard while an agent is running;
33+ polished empty states; richer **Settings** (Dynamic Color / Material You toggle, version info,
34+ manage-servers link); haptic feedback; and a file viewer with line numbers, copy, and share.
25
35
26## Architecture
36## Architecture
27
37
app/src/main/java/soy/iko/opencode/MainActivity.kt+3−1
@@ -19,12 +19,14 @@ class MainActivity : ComponentActivity() {
19 setContent {
19 setContent {
20 val themeMode by container.settingsStore.themeMode
20 val themeMode by container.settingsStore.themeMode
21 .collectAsStateWithLifecycle(initialValue = ThemeMode.SYSTEM)
21 .collectAsStateWithLifecycle(initialValue = ThemeMode.SYSTEM)
22+ val dynamicColor by container.settingsStore.dynamicColor
23+ .collectAsStateWithLifecycle(initialValue = true)
22 val dark = when (themeMode) {
24 val dark = when (themeMode) {
23 ThemeMode.SYSTEM -> isSystemInDarkTheme()
25 ThemeMode.SYSTEM -> isSystemInDarkTheme()
24 ThemeMode.LIGHT -> false
26 ThemeMode.LIGHT -> false
25 ThemeMode.DARK -> true
27 ThemeMode.DARK -> true
26 }
28 }
27− OpencodeTheme(darkTheme = dark) {
29+ OpencodeTheme(darkTheme = dark, dynamicColor = dynamicColor) {
28 OpencodeAppUi(container = container)
30 OpencodeAppUi(container = container)
29 }
31 }
30 }
32 }
app/src/main/java/soy/iko/opencode/data/repo/SettingsStore.kt+11−1
@@ -1,6 +1,7 @@
1package soy.iko.opencode.data.repo
1package soy.iko.opencode.data.repo
2
2
3import android.content.Context
3import android.content.Context
4+import androidx.datastore.preferences.core.booleanPreferencesKey
4import androidx.datastore.preferences.core.edit
5import androidx.datastore.preferences.core.edit
5import androidx.datastore.preferences.core.stringPreferencesKey
6import androidx.datastore.preferences.core.stringPreferencesKey
6import androidx.datastore.preferences.preferencesDataStore
7import androidx.datastore.preferences.preferencesDataStore
@@ -11,18 +12,27 @@ enum class ThemeMode { SYSTEM, LIGHT, DARK }
11
12
12private val Context.settingsDataStore by preferencesDataStore(name = "settings")
13private val Context.settingsDataStore by preferencesDataStore(name = "settings")
13
14
14−/** Small app-preferences store (theme mode for now; default agent etc. can join later). */
15+/** App-preferences store: theme mode, dynamic color (Material You). */
15class SettingsStore(context: Context) {
16class SettingsStore(context: Context) {
16
17
17 private val appContext = context.applicationContext
18 private val appContext = context.applicationContext
18 private val themeKey = stringPreferencesKey("theme_mode")
19 private val themeKey = stringPreferencesKey("theme_mode")
20+ private val dynamicColorKey = booleanPreferencesKey("dynamic_color")
19
21
20 val themeMode: Flow<ThemeMode> = appContext.settingsDataStore.data.map { prefs ->
22 val themeMode: Flow<ThemeMode> = appContext.settingsDataStore.data.map { prefs ->
21 runCatching { ThemeMode.valueOf(prefs[themeKey] ?: ThemeMode.SYSTEM.name) }
23 runCatching { ThemeMode.valueOf(prefs[themeKey] ?: ThemeMode.SYSTEM.name) }
22 .getOrDefault(ThemeMode.SYSTEM)
24 .getOrDefault(ThemeMode.SYSTEM)
23 }
25 }
24
26
27+ val dynamicColor: Flow<Boolean> = appContext.settingsDataStore.data.map { prefs ->
28+ prefs[dynamicColorKey] ?: true
29+ }
30+
25 suspend fun setThemeMode(mode: ThemeMode) {
31 suspend fun setThemeMode(mode: ThemeMode) {
26 appContext.settingsDataStore.edit { it[themeKey] = mode.name }
32 appContext.settingsDataStore.edit { it[themeKey] = mode.name }
27 }
33 }
34+
35+ suspend fun setDynamicColor(enabled: Boolean) {
36+ appContext.settingsDataStore.edit { it[dynamicColorKey] = enabled }
37+ }
28}
38}
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+3
@@ -91,6 +91,9 @@ fun OpencodeApp(container: AppContainer) {
91 SettingsScreen(
91 SettingsScreen(
92 container = container,
92 container = container,
93 onBack = { navController.popBackStack() },
93 onBack = { navController.popBackStack() },
94+ onManageServers = {
95+ navController.popBackStack(Routes.SERVERS, inclusive = false)
96+ },
94 )
97 )
95 }
98 }
96 }
99 }
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+91−16
@@ -1,11 +1,14 @@
1package soy.iko.opencode.ui.chat
1package soy.iko.opencode.ui.chat
2
2
3+import androidx.activity.compose.BackHandler
4+import androidx.compose.animation.AnimatedVisibility
3import androidx.compose.foundation.clickable
5import androidx.compose.foundation.clickable
4import androidx.compose.foundation.layout.Arrangement
6import androidx.compose.foundation.layout.Arrangement
5import androidx.compose.foundation.layout.Box
7import androidx.compose.foundation.layout.Box
6import androidx.compose.foundation.layout.Column
8import androidx.compose.foundation.layout.Column
7import androidx.compose.foundation.layout.PaddingValues
9import androidx.compose.foundation.layout.PaddingValues
8import androidx.compose.foundation.layout.Row
10import androidx.compose.foundation.layout.Row
11+import androidx.compose.foundation.layout.Spacer
9import androidx.compose.foundation.layout.fillMaxSize
12import androidx.compose.foundation.layout.fillMaxSize
10import androidx.compose.foundation.layout.fillMaxWidth
13import androidx.compose.foundation.layout.fillMaxWidth
11import androidx.compose.foundation.layout.padding
14import androidx.compose.foundation.layout.padding
@@ -16,12 +19,16 @@ import androidx.compose.foundation.lazy.rememberLazyListState
16import androidx.compose.material.icons.Icons
19import androidx.compose.material.icons.Icons
17import androidx.compose.material.icons.automirrored.filled.ArrowBack
20import androidx.compose.material.icons.automirrored.filled.ArrowBack
18import androidx.compose.material.icons.automirrored.filled.Send
21import androidx.compose.material.icons.automirrored.filled.Send
22+import androidx.compose.material.icons.filled.KeyboardArrowDown
19import androidx.compose.material.icons.filled.SmartToy
23import androidx.compose.material.icons.filled.SmartToy
20import androidx.compose.material.icons.filled.Stop
24import androidx.compose.material.icons.filled.Stop
21import androidx.compose.material.icons.filled.Terminal
25import androidx.compose.material.icons.filled.Terminal
22import androidx.compose.material.icons.filled.Tune
26import androidx.compose.material.icons.filled.Tune
27+import androidx.compose.material3.AlertDialog
23import androidx.compose.material3.CircularProgressIndicator
28import androidx.compose.material3.CircularProgressIndicator
24import androidx.compose.material3.ExperimentalMaterial3Api
29import androidx.compose.material3.ExperimentalMaterial3Api
30+import androidx.compose.material3.ExtendedFloatingActionButton
31+import androidx.compose.material3.FloatingActionButton
25import androidx.compose.material3.Icon
32import androidx.compose.material3.Icon
26import androidx.compose.material3.IconButton
33import androidx.compose.material3.IconButton
27import androidx.compose.material3.MaterialTheme
34import androidx.compose.material3.MaterialTheme
@@ -31,18 +38,24 @@ import androidx.compose.material3.SnackbarHost
31import androidx.compose.material3.SnackbarHostState
38import androidx.compose.material3.SnackbarHostState
32import androidx.compose.material3.Surface
39import androidx.compose.material3.Surface
33import androidx.compose.material3.Text
40import androidx.compose.material3.Text
41+import androidx.compose.material3.TextButton
34import androidx.compose.material3.TopAppBar
42import androidx.compose.material3.TopAppBar
35import androidx.compose.runtime.Composable
43import androidx.compose.runtime.Composable
36import androidx.compose.runtime.LaunchedEffect
44import androidx.compose.runtime.LaunchedEffect
45+import androidx.compose.runtime.derivedStateOf
37import androidx.compose.runtime.getValue
46import androidx.compose.runtime.getValue
38import androidx.compose.runtime.mutableStateOf
47import androidx.compose.runtime.mutableStateOf
39import androidx.compose.runtime.remember
48import androidx.compose.runtime.remember
49+import androidx.compose.runtime.rememberCoroutineScope
40import androidx.compose.runtime.setValue
50import androidx.compose.runtime.setValue
41import androidx.compose.ui.Alignment
51import androidx.compose.ui.Alignment
42import androidx.compose.ui.Modifier
52import androidx.compose.ui.Modifier
53+import androidx.compose.ui.platform.LocalHapticFeedback
43import androidx.compose.ui.unit.dp
54import androidx.compose.ui.unit.dp
44import androidx.lifecycle.compose.collectAsStateWithLifecycle
55import androidx.lifecycle.compose.collectAsStateWithLifecycle
45import androidx.lifecycle.viewmodel.compose.viewModel
56import androidx.lifecycle.viewmodel.compose.viewModel
57+import kotlinx.coroutines.launch
58+import soy.iko.opencode.data.network.EventStreamClient
46import soy.iko.opencode.di.AppContainer
59import soy.iko.opencode.di.AppContainer
47import soy.iko.opencode.ui.vmFactory
60import soy.iko.opencode.ui.vmFactory
48
61
@@ -64,6 +77,10 @@ fun ChatScreen(
64 val agents by vm.agents.collectAsStateWithLifecycle()
77 val agents by vm.agents.collectAsStateWithLifecycle()
65 val selectedAgent by vm.selectedAgent.collectAsStateWithLifecycle()
78 val selectedAgent by vm.selectedAgent.collectAsStateWithLifecycle()
66 val commands by vm.commands.collectAsStateWithLifecycle()
79 val commands by vm.commands.collectAsStateWithLifecycle()
80+ val sessionTitle by vm.sessionTitle.collectAsStateWithLifecycle()
81+ val failedDraft by vm.failedDraft.collectAsStateWithLifecycle()
82+ val haptics = LocalHapticFeedback.current
83+ val scope = rememberCoroutineScope()
67
84
68 val listState = rememberLazyListState()
85 val listState = rememberLazyListState()
69 val snackbar = remember { SnackbarHostState() }
86 val snackbar = remember { SnackbarHostState() }
@@ -71,24 +88,49 @@ fun ChatScreen(
71 var showModelPicker by remember { mutableStateOf(false) }
88 var showModelPicker by remember { mutableStateOf(false) }
72 var showAgentPicker by remember { mutableStateOf(false) }
89 var showAgentPicker by remember { mutableStateOf(false) }
73 var showCommandPicker by remember { mutableStateOf(false) }
90 var showCommandPicker by remember { mutableStateOf(false) }
91+ var showExitConfirm by remember { mutableStateOf(false) }
74
92
93+ // Track whether the list is pinned to the bottom so streaming auto-scroll
94+ // doesn't fight a user who scrolled up to read earlier output.
95+ val isPinnedToBottom by remember {
96+ derivedStateOf {
97+ val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
98+ lastVisible >= messages.lastIndex || messages.isEmpty()
99+ }
100+ }
101+
75− // Keep the newest content in view as parts stream in.
102+ // Keep the newest content in view as parts stream in — only if already at the bottom.
76 LaunchedEffect(messages.size, messages.lastOrNull()?.parts?.size) {
103 LaunchedEffect(messages.size, messages.lastOrNull()?.parts?.size) {
104+ if (messages.isNotEmpty() && isPinnedToBottom) {
77− if (messages.isNotEmpty()) listState.animateScrollToItem(messages.lastIndex)
105+ listState.animateScrollToItem(messages.lastIndex)
106+ }
78 }
107 }
79 LaunchedEffect(error) {
108 LaunchedEffect(error) {
80− error?.let {
109+ val msg = error ?: return@LaunchedEffect
110+ val result = if (failedDraft != null) {
81− snackbar.showSnackbar(it)
111+ snackbar.showSnackbar(message = msg, actionLabel = "Retry")
82− vm.clearError()
112+ } else {
113+ snackbar.showSnackbar(msg)
83 }
114 }
115+ if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) vm.retryFailed()
116+ vm.clearError()
84 }
117 }
85
118
119+ fun doSend() {
120+ if (vm.send(input)) {
121+ input = ""
122+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
123+ }
124+ }
125+
126+ BackHandler(enabled = running) { showExitConfirm = true }
127+
86 Scaffold(
128 Scaffold(
87 topBar = {
129 topBar = {
88 TopAppBar(
130 TopAppBar(
89 title = {
131 title = {
90 Column(modifier = Modifier.clickable(enabled = models.isNotEmpty()) { showModelPicker = true }) {
132 Column(modifier = Modifier.clickable(enabled = models.isNotEmpty()) { showModelPicker = true }) {
91− Text("Session")
133+ Text(sessionTitle ?: "Session")
92 Text(
134 Text(
93 buildString {
135 buildString {
94 append(selectedModel?.modelLabel ?: "default model")
136 append(selectedModel?.modelLabel ?: "default model")
@@ -100,7 +142,9 @@ fun ChatScreen(
100 }
142 }
101 },
143 },
102 navigationIcon = {
144 navigationIcon = {
103− IconButton(onClick = onBack) {
145+ IconButton(onClick = {
146+ if (running) showExitConfirm = true else onBack()
147+ }) {
104 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
148 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
105 }
149 }
106 },
150 },
@@ -124,10 +168,7 @@ fun ChatScreen(
124 onValueChange = { input = it },
168 onValueChange = { input = it },
125 running = running,
169 running = running,
126 enabled = vm.connected,
170 enabled = vm.connected,
127− onSend = {
171+ onSend = ::doSend,
128− vm.send(input)
129− input = ""
130− },
131 onAbort = { vm.abort() },
172 onAbort = { vm.abort() },
132 )
173 )
133 },
174 },
@@ -151,7 +192,7 @@ fun ChatScreen(
151 verticalArrangement = Arrangement.spacedBy(16.dp),
192 verticalArrangement = Arrangement.spacedBy(16.dp),
152 ) {
193 ) {
153 items(messages, key = { it.info.id }) { message ->
194 items(messages, key = { it.info.id }) { message ->
154− MessageBubble(message)
195+ MessageBubble(message, isRunning = running)
155 }
196 }
156 if (running) {
197 if (running) {
157 item(key = "__typing") {
198 item(key = "__typing") {
@@ -162,10 +203,44 @@ fun ChatScreen(
162 }
203 }
163 }
204 }
164 }
205 }
206+ // Jump-to-latest affordance when the user has scrolled away during a stream.
207+ AnimatedVisibility(
208+ visible = !isPinnedToBottom && messages.isNotEmpty(),
209+ modifier = Modifier.align(Alignment.BottomEnd),
210+ ) {
211+ ExtendedFloatingActionButton(
212+ onClick = {
213+ if (messages.isNotEmpty()) {
214+ scope.launch { listState.animateScrollToItem(messages.lastIndex) }
215+ }
216+ },
217+ icon = { Icon(Icons.Filled.KeyboardArrowDown, contentDescription = null) },
218+ text = { Text("Latest") },
219+ modifier = Modifier.padding(end = 16.dp, bottom = 16.dp),
220+ )
221+ }
165 }
222 }
166 }
223 }
167 }
224 }
168
225
226+ if (showExitConfirm) {
227+ AlertDialog(
228+ onDismissRequest = { showExitConfirm = false },
229+ title = { Text("Stop and exit?") },
230+ text = { Text("The agent is still working. Exit anyway?") },
231+ confirmButton = {
232+ TextButton(onClick = {
233+ showExitConfirm = false
234+ vm.abort()
235+ onBack()
236+ }) { Text("Stop & exit") }
237+ },
238+ dismissButton = {
239+ TextButton(onClick = { showExitConfirm = false }) { Text("Stay") }
240+ },
241+ )
242+ }
243+
169 if (showModelPicker) {
244 if (showModelPicker) {
170 ModelPickerSheet(
245 ModelPickerSheet(
171 options = models,
246 options = models,
@@ -202,13 +277,13 @@ fun ChatScreen(
202
277
203@Composable
278@Composable
204private fun ConnectionBanner(
279private fun ConnectionBanner(
205− state: soy.iko.opencode.data.network.EventStreamClient.ConnectionState,
280+ state: EventStreamClient.ConnectionState,
206 modifier: Modifier = Modifier,
281 modifier: Modifier = Modifier,
207) {
282) {
208 val text = when (state) {
283 val text = when (state) {
209− soy.iko.opencode.data.network.EventStreamClient.ConnectionState.Connecting -> "Connecting…"
284+ EventStreamClient.ConnectionState.Connecting -> "Connecting…"
210− soy.iko.opencode.data.network.EventStreamClient.ConnectionState.Disconnected -> "Reconnecting…"
285+ EventStreamClient.ConnectionState.Disconnected -> "Reconnecting…"
211− soy.iko.opencode.data.network.EventStreamClient.ConnectionState.Connected -> null
286+ EventStreamClient.ConnectionState.Connected -> null
212 }
287 }
213 if (text != null) {
288 if (text != null) {
214 Surface(
289 Surface(
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+34−5
@@ -10,6 +10,7 @@ import soy.iko.opencode.data.model.Permission
10import soy.iko.opencode.data.model.PermissionReplied
10import soy.iko.opencode.data.model.PermissionReplied
11import soy.iko.opencode.data.model.PermissionResponse
11import soy.iko.opencode.data.model.PermissionResponse
12import soy.iko.opencode.data.model.PermissionUpdated
12import soy.iko.opencode.data.model.PermissionUpdated
13+import soy.iko.opencode.data.model.TextPart
13import soy.iko.opencode.data.model.defaultOption
14import soy.iko.opencode.data.model.defaultOption
14import soy.iko.opencode.data.model.toOptions
15import soy.iko.opencode.data.model.toOptions
15import soy.iko.opencode.data.network.EventStreamClient
16import soy.iko.opencode.data.network.EventStreamClient
@@ -68,6 +69,13 @@ class ChatViewModel(
68 private val _commands = MutableStateFlow<List<Command>>(emptyList())
69 private val _commands = MutableStateFlow<List<Command>>(emptyList())
69 val commands: StateFlow<List<Command>> = _commands.asStateFlow()
70 val commands: StateFlow<List<Command>> = _commands.asStateFlow()
70
71
72+ private val _sessionTitle = MutableStateFlow<String?>(null)
73+ val sessionTitle: StateFlow<String?> = _sessionTitle.asStateFlow()
74+
75+ /** The text of the last send that failed, surfaced so the UI can offer a retry. */
76+ private val _failedDraft = MutableStateFlow<String?>(null)
77+ val failedDraft: StateFlow<String?> = _failedDraft.asStateFlow()
78+
71 fun clearError() { _error.value = null }
79 fun clearError() { _error.value = null }
72
80
73 fun selectModel(option: ModelOption) { _selectedModel.value = option }
81 fun selectModel(option: ModelOption) { _selectedModel.value = option }
@@ -111,26 +119,47 @@ class ChatViewModel(
111 viewModelScope.launch {
119 viewModelScope.launch {
112 runCatching { conn.api.commands() }.getOrNull()?.let { _commands.value = it }
120 runCatching { conn.api.commands() }.getOrNull()?.let { _commands.value = it }
113 }
121 }
122+ // Resolve the human-readable session title for the app bar (non-fatal).
123+ viewModelScope.launch {
124+ runCatching { conn.repository.listSessions() }
125+ .getOrNull()
126+ ?.firstOrNull { it.id == sessionId }
127+ ?.let { _sessionTitle.value = it.displayTitle }
128+ }
114 }
129 }
115 }
130 }
116
131
132+ /** Sends [text]; returns true on success so the caller can clear the draft only then. */
117− fun send(text: String) {
133+ fun send(text: String): Boolean {
118− val conn = connection ?: return
134+ val conn = connection ?: return false
119 val trimmed = text.trim()
135 val trimmed = text.trim()
120− if (trimmed.isEmpty() || _running.value) return
136+ if (trimmed.isEmpty() || _running.value) return false
121 _running.value = true
137 _running.value = true
122 _error.value = null
138 _error.value = null
139+ _failedDraft.value = null
123 viewModelScope.launch {
140 viewModelScope.launch {
124− runCatching {
141+ val ok = runCatching {
125 conn.repository.sendPrompt(
142 conn.repository.sendPrompt(
126 sessionId,
143 sessionId,
127 trimmed,
144 trimmed,
128 model = _selectedModel.value?.ref,
145 model = _selectedModel.value?.ref,
129 agent = _selectedAgent.value,
146 agent = _selectedAgent.value,
130 )
147 )
131− }.onFailure { _error.value = it.message ?: "Failed to send" }
148+ }.isSuccess
132 _running.value = false
149 _running.value = false
150+ if (!ok) {
151+ _failedDraft.value = trimmed
152+ _error.value = "Failed to send"
153+ }
133 }
154 }
155+ return true
156+ }
157+
158+ /** Re-send the last draft whose send failed, if any. */
159+ fun retryFailed() {
160+ val draft = _failedDraft.value ?: return
161+ _failedDraft.value = null
162+ send(draft)
134 }
163 }
135
164
136 /** Invoke a slash-command by name via the server's /command endpoint. */
165 /** Invoke a slash-command by name via the server's /command endpoint. */
app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+4−4
@@ -21,10 +21,10 @@ import soy.iko.opencode.data.model.UserMessage
21
21
22/** A single message: user prompts right-aligned in a bubble, assistant output full-width. */
22/** A single message: user prompts right-aligned in a bubble, assistant output full-width. */
23@Composable
23@Composable
24−fun MessageBubble(message: MessageWithParts, modifier: Modifier = Modifier) {
24+fun MessageBubble(message: MessageWithParts, isRunning: Boolean = false, modifier: Modifier = Modifier) {
25 when (message.info) {
25 when (message.info) {
26 is UserMessage -> UserBubble(message, modifier)
26 is UserMessage -> UserBubble(message, modifier)
27− else -> AssistantBlock(message, modifier)
27+ else -> AssistantBlock(message, isRunning, modifier)
28 }
28 }
29}
29}
30
30
@@ -45,7 +45,7 @@ private fun UserBubble(message: MessageWithParts, modifier: Modifier) {
45}
45}
46
46
47@Composable
47@Composable
48−private fun AssistantBlock(message: MessageWithParts, modifier: Modifier) {
48+private fun AssistantBlock(message: MessageWithParts, isRunning: Boolean, modifier: Modifier) {
49 Column(
49 Column(
50 modifier = modifier.fillMaxWidth(),
50 modifier = modifier.fillMaxWidth(),
51 verticalArrangement = Arrangement.spacedBy(8.dp),
51 verticalArrangement = Arrangement.spacedBy(8.dp),
@@ -59,7 +59,7 @@ private fun AssistantBlock(message: MessageWithParts, modifier: Modifier) {
59 )
59 )
60 }
60 }
61 for (part in message.parts) {
61 for (part in message.parts) {
62− PartView(part, modifier = Modifier.fillMaxWidth())
62+ PartView(part, isRunning = isRunning, modifier = Modifier.fillMaxWidth())
63 }
63 }
64 if (info is AssistantMessage) {
64 if (info is AssistantMessage) {
65 val cost = info.cost
65 val cost = info.cost
app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+72−19
@@ -12,12 +12,17 @@ import androidx.compose.foundation.shape.RoundedCornerShape
12import androidx.compose.material.icons.Icons
12import androidx.compose.material.icons.Icons
13import androidx.compose.material.icons.filled.Bolt
13import androidx.compose.material.icons.filled.Bolt
14import androidx.compose.material.icons.filled.CheckCircle
14import androidx.compose.material.icons.filled.CheckCircle
15+import androidx.compose.material.icons.filled.ContentCopy
15import androidx.compose.material.icons.filled.Error
16import androidx.compose.material.icons.filled.Error
17+import androidx.compose.material.icons.filled.ExpandLess
18+import androidx.compose.material.icons.filled.ExpandMore
16import androidx.compose.material.icons.filled.Psychology
19import androidx.compose.material.icons.filled.Psychology
17import androidx.compose.material3.CircularProgressIndicator
20import androidx.compose.material3.CircularProgressIndicator
18import androidx.compose.material3.Icon
21import androidx.compose.material3.Icon
22+import androidx.compose.material3.IconButton
19import androidx.compose.material3.MaterialTheme
23import androidx.compose.material3.MaterialTheme
20import androidx.compose.material3.Text
24import androidx.compose.material3.Text
25+import androidx.compose.material3.TextButton
21import androidx.compose.runtime.Composable
26import androidx.compose.runtime.Composable
22import androidx.compose.runtime.getValue
27import androidx.compose.runtime.getValue
23import androidx.compose.runtime.mutableStateOf
28import androidx.compose.runtime.mutableStateOf
@@ -26,6 +31,7 @@ import androidx.compose.runtime.setValue
26import androidx.compose.ui.Alignment
31import androidx.compose.ui.Alignment
27import androidx.compose.ui.Modifier
32import androidx.compose.ui.Modifier
28import androidx.compose.ui.draw.clip
33import androidx.compose.ui.draw.clip
34+import androidx.compose.ui.platform.LocalContext
29import androidx.compose.ui.text.font.FontFamily
35import androidx.compose.ui.text.font.FontFamily
30import androidx.compose.ui.unit.dp
36import androidx.compose.ui.unit.dp
31import soy.iko.opencode.data.model.FilePart
37import soy.iko.opencode.data.model.FilePart
@@ -44,19 +50,22 @@ import soy.iko.opencode.data.model.ToolUnknown
44import soy.iko.opencode.data.model.UnknownPart
50import soy.iko.opencode.data.model.UnknownPart
45import soy.iko.opencode.ui.components.DiffView
51import soy.iko.opencode.ui.components.DiffView
46import soy.iko.opencode.ui.components.MarkdownText
52import soy.iko.opencode.ui.components.MarkdownText
53+import soy.iko.opencode.ui.components.copyToClipboard
47import soy.iko.opencode.ui.components.looksLikeDiff
54import soy.iko.opencode.ui.components.looksLikeDiff
48
55
56+private const val COLLAPSED_LIMIT = 600
57+
49/**
58/**
50 * Renders a single message [Part]. The exhaustive `when` over the sealed type gives
59 * Renders a single message [Part]. The exhaustive `when` over the sealed type gives
51 * compile-time coverage; the [UnknownPart] arm keeps the UI forward-compatible.
60 * compile-time coverage; the [UnknownPart] arm keeps the UI forward-compatible.
52 */
61 */
53@Composable
62@Composable
54−fun PartView(part: Part, modifier: Modifier = Modifier) {
63+fun PartView(part: Part, isRunning: Boolean = false, modifier: Modifier = Modifier) {
55 when (part) {
64 when (part) {
56 is TextPart -> if (!part.ignored && part.text.isNotEmpty()) {
65 is TextPart -> if (!part.ignored && part.text.isNotEmpty()) {
57 MarkdownText(part.text, modifier = modifier)
66 MarkdownText(part.text, modifier = modifier)
58 }
67 }
59− is ReasoningPart -> ReasoningBlock(part.text, modifier)
68+ is ReasoningPart -> ReasoningBlock(part.text, streaming = isRunning, modifier)
60 is ToolPart -> ToolCallView(part, modifier)
69 is ToolPart -> ToolCallView(part, modifier)
61 is FilePart -> FileChip(part, modifier)
70 is FilePart -> FileChip(part, modifier)
62 is StepStartPart -> {} // boundary marker — nothing to draw
71 is StepStartPart -> {} // boundary marker — nothing to draw
@@ -66,38 +75,57 @@ fun PartView(part: Part, modifier: Modifier = Modifier) {
66}
75}
67
76
68@Composable
77@Composable
69−private fun ReasoningBlock(text: String, modifier: Modifier) {
78+private fun ReasoningBlock(text: String, streaming: Boolean, modifier: Modifier) {
70 if (text.isBlank()) return
79 if (text.isBlank()) return
80+ val context = LocalContext.current
71 var expanded by remember { mutableStateOf(false) }
81 var expanded by remember { mutableStateOf(false) }
72 Column(modifier = modifier.fillMaxWidth()) {
82 Column(modifier = modifier.fillMaxWidth()) {
73 Row(
83 Row(
74 modifier = Modifier.clickable { expanded = !expanded }.padding(vertical = 4.dp),
84 modifier = Modifier.clickable { expanded = !expanded }.padding(vertical = 4.dp),
75 verticalAlignment = Alignment.CenterVertically,
85 verticalAlignment = Alignment.CenterVertically,
76 ) {
86 ) {
87+ if (streaming) {
88+ CircularProgressIndicator(
89+ Modifier.size(14.dp),
90+ strokeWidth = 2.dp,
91+ color = MaterialTheme.colorScheme.onSurfaceVariant,
92+ )
93+ } else {
77− Icon(
94+ Icon(
78− Icons.Filled.Psychology,
95+ Icons.Filled.Psychology,
79− contentDescription = null,
96+ contentDescription = null,
80− modifier = Modifier.size(16.dp),
97+ modifier = Modifier.size(16.dp),
81− tint = MaterialTheme.colorScheme.onSurfaceVariant,
98+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
82− )
99+ )
100+ }
83 Text(
101 Text(
84− if (expanded) " Thinking" else " Thinking…",
102+ " ${if (streaming) "Thinking…" else "Thoughts"}",
85 style = MaterialTheme.typography.labelMedium,
103 style = MaterialTheme.typography.labelMedium,
86 color = MaterialTheme.colorScheme.onSurfaceVariant,
104 color = MaterialTheme.colorScheme.onSurfaceVariant,
87 )
105 )
88 }
106 }
89 AnimatedVisibility(visible = expanded) {
107 AnimatedVisibility(visible = expanded) {
108+ Column {
90− Text(
109+ Text(
91− text,
110+ text,
92− style = MaterialTheme.typography.bodySmall,
111+ style = MaterialTheme.typography.bodySmall,
93− color = MaterialTheme.colorScheme.onSurfaceVariant,
112+ color = MaterialTheme.colorScheme.onSurfaceVariant,
94− )
113+ )
114+ TextButton(
115+ onClick = { copyToClipboard(context, "reasoning", text) },
116+ contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
117+ ) {
118+ Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(14.dp))
119+ Text(" Copy", style = MaterialTheme.typography.labelSmall)
120+ }
121+ }
95 }
122 }
96 }
123 }
97}
124}
98
125
99@Composable
126@Composable
100private fun ToolCallView(part: ToolPart, modifier: Modifier) {
127private fun ToolCallView(part: ToolPart, modifier: Modifier) {
128+ val context = LocalContext.current
101 Column(
129 Column(
102 modifier = modifier
130 modifier = modifier
103 .fillMaxWidth()
131 .fillMaxWidth()
@@ -119,17 +147,42 @@ private fun ToolCallView(part: ToolPart, modifier: Modifier) {
119 else -> null
147 else -> null
120 }
148 }
121 if (detail != null) {
149 if (detail != null) {
122− val truncated = detail.take(5000)
150+ val collapsed = remember(detail) { detail.take(COLLAPSED_LIMIT) }
151+ var expanded by remember(detail) { mutableStateOf(false) }
152+ val display = if (expanded || detail.length <= COLLAPSED_LIMIT) detail.take(5000) else collapsed
123− if (looksLikeDiff(truncated)) {
153+ if (looksLikeDiff(display)) {
124− DiffView(truncated)
154+ DiffView(display)
125 } else {
155 } else {
126 Text(
156 Text(
127− truncated.take(2000),
157+ display,
128 style = MaterialTheme.typography.bodySmall,
158 style = MaterialTheme.typography.bodySmall,
129 fontFamily = FontFamily.Monospace,
159 fontFamily = FontFamily.Monospace,
130 modifier = Modifier.padding(top = 6.dp),
160 modifier = Modifier.padding(top = 6.dp),
131 )
161 )
132 }
162 }
163+ if (detail.length > COLLAPSED_LIMIT) {
164+ TextButton(
165+ onClick = { expanded = !expanded },
166+ contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
167+ ) {
168+ Icon(
169+ if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
170+ contentDescription = null,
171+ modifier = Modifier.size(16.dp),
172+ )
173+ Text(
174+ " ${if (expanded) "Show less" else "Show more"}",
175+ style = MaterialTheme.typography.labelSmall,
176+ )
177+ }
178+ }
179+ TextButton(
180+ onClick = { copyToClipboard(context, "output", detail) },
181+ contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
182+ ) {
183+ Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(14.dp))
184+ Text(" Copy", style = MaterialTheme.typography.labelSmall)
185+ }
133 }
186 }
134 }
187 }
135}
188}
app/src/main/java/soy/iko/opencode/ui/chat/PermissionDialog.kt+41−6
@@ -1,14 +1,24 @@
1package soy.iko.opencode.ui.chat
1package soy.iko.opencode.ui.chat
2
2
3+import androidx.compose.foundation.layout.Arrangement
3import androidx.compose.foundation.layout.Column
4import androidx.compose.foundation.layout.Column
5+import androidx.compose.foundation.layout.Row
4import androidx.compose.foundation.layout.fillMaxWidth
6import androidx.compose.foundation.layout.fillMaxWidth
7+import androidx.compose.foundation.layout.padding
8+import androidx.compose.material.icons.Icons
9+import androidx.compose.material.icons.filled.Shield
5import androidx.compose.material3.AlertDialog
10import androidx.compose.material3.AlertDialog
11+import androidx.compose.material3.Button
12+import androidx.compose.material3.Icon
6import androidx.compose.material3.MaterialTheme
13import androidx.compose.material3.MaterialTheme
14+import androidx.compose.material3.OutlinedButton
7import androidx.compose.material3.Text
15import androidx.compose.material3.Text
8import androidx.compose.material3.TextButton
16import androidx.compose.material3.TextButton
9import androidx.compose.runtime.Composable
17import androidx.compose.runtime.Composable
10import androidx.compose.ui.Modifier
18import androidx.compose.ui.Modifier
19+import androidx.compose.ui.platform.LocalHapticFeedback
11import androidx.compose.ui.text.font.FontFamily
20import androidx.compose.ui.text.font.FontFamily
21+import androidx.compose.ui.unit.dp
12import soy.iko.opencode.data.model.Permission
22import soy.iko.opencode.data.model.Permission
13import soy.iko.opencode.data.model.PermissionResponse
23import soy.iko.opencode.data.model.PermissionResponse
14
24
@@ -22,32 +32,57 @@ fun PermissionDialog(
22 permission: Permission,
32 permission: Permission,
23 onRespond: (PermissionResponse) -> Unit,
33 onRespond: (PermissionResponse) -> Unit,
24) {
34) {
35+ val haptics = LocalHapticFeedback.current
25 AlertDialog(
36 AlertDialog(
26 onDismissRequest = { /* require an explicit choice */ },
37 onDismissRequest = { /* require an explicit choice */ },
38+ icon = { Icon(Icons.Filled.Shield, contentDescription = null) },
27 title = { Text(permission.title ?: "Permission requested") },
39 title = { Text(permission.title ?: "Permission requested") },
28 text = {
40 text = {
29− Column {
41+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
30 permission.type?.let {
42 permission.type?.let {
31 Text(it, style = MaterialTheme.typography.labelLarge, fontFamily = FontFamily.Monospace)
43 Text(it, style = MaterialTheme.typography.labelLarge, fontFamily = FontFamily.Monospace)
32 }
44 }
33 permission.patternText?.let {
45 permission.patternText?.let {
34 Text(
46 Text(
35 it,
47 it,
36− style = MaterialTheme.typography.bodySmall,
48+ style = MaterialTheme.typography.bodyMedium,
37 fontFamily = FontFamily.Monospace,
49 fontFamily = FontFamily.Monospace,
38 color = MaterialTheme.colorScheme.onSurfaceVariant,
50 color = MaterialTheme.colorScheme.onSurfaceVariant,
39 )
51 )
40 }
52 }
53+ Text(
54+ "Allow this tool to run?",
55+ style = MaterialTheme.typography.bodySmall,
56+ color = MaterialTheme.colorScheme.onSurfaceVariant,
57+ )
41 }
58 }
42 },
59 },
43 confirmButton = {
60 confirmButton = {
61+ Column(
44− Column(modifier = Modifier.fillMaxWidth()) {
62+ modifier = Modifier.fillMaxWidth(),
63+ verticalArrangement = Arrangement.spacedBy(4.dp),
64+ ) {
65+ Button(
66+ onClick = {
67+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
45− TextButton(onClick = { onRespond(PermissionResponse.ONCE) }) { Text("Allow once") }
68+ onRespond(PermissionResponse.ONCE)
69+ },
70+ modifier = Modifier.fillMaxWidth(),
71+ ) { Text("Allow once") }
72+ OutlinedButton(
73+ onClick = {
74+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
46− TextButton(onClick = { onRespond(PermissionResponse.ALWAYS) }) { Text("Always allow") }
75+ onRespond(PermissionResponse.ALWAYS)
76+ },
77+ modifier = Modifier.fillMaxWidth(),
78+ ) { Text("Always allow") }
47 }
79 }
48 },
80 },
49 dismissButton = {
81 dismissButton = {
82+ TextButton(onClick = {
83+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
50− TextButton(onClick = { onRespond(PermissionResponse.REJECT) }) {
84+ onRespond(PermissionResponse.REJECT)
85+ }) {
51 Text("Reject", color = MaterialTheme.colorScheme.error)
86 Text("Reject", color = MaterialTheme.colorScheme.error)
52 }
87 }
53 },
88 },
app/src/main/java/soy/iko/opencode/ui/components/DiffView.kt+40−23
@@ -2,15 +2,19 @@ package soy.iko.opencode.ui.components
2
2
3import androidx.compose.foundation.background
3import androidx.compose.foundation.background
4import androidx.compose.foundation.horizontalScroll
4import androidx.compose.foundation.horizontalScroll
5+import androidx.compose.foundation.layout.Arrangement
5import androidx.compose.foundation.layout.Column
6import androidx.compose.foundation.layout.Column
6import androidx.compose.foundation.layout.Row
7import androidx.compose.foundation.layout.Row
7import androidx.compose.foundation.layout.fillMaxWidth
8import androidx.compose.foundation.layout.fillMaxWidth
8import androidx.compose.foundation.layout.padding
9import androidx.compose.foundation.layout.padding
9import androidx.compose.foundation.layout.width
10import androidx.compose.foundation.layout.width
10−import androidx.compose.foundation.layout.widthIn
11import androidx.compose.foundation.rememberScrollState
11import androidx.compose.foundation.rememberScrollState
12import androidx.compose.foundation.shape.RoundedCornerShape
12import androidx.compose.foundation.shape.RoundedCornerShape
13import androidx.compose.foundation.verticalScroll
13import androidx.compose.foundation.verticalScroll
14+import androidx.compose.material.icons.Icons
15+import androidx.compose.material.icons.filled.ContentCopy
16+import androidx.compose.material3.Icon
17+import androidx.compose.material3.IconButton
14import androidx.compose.material3.MaterialTheme
18import androidx.compose.material3.MaterialTheme
15import androidx.compose.material3.Text
19import androidx.compose.material3.Text
16import androidx.compose.runtime.Composable
20import androidx.compose.runtime.Composable
@@ -19,6 +23,7 @@ import androidx.compose.ui.Alignment
19import androidx.compose.ui.Modifier
23import androidx.compose.ui.Modifier
20import androidx.compose.ui.draw.clip
24import androidx.compose.ui.draw.clip
21import androidx.compose.ui.graphics.Color
25import androidx.compose.ui.graphics.Color
26+import androidx.compose.ui.platform.LocalContext
22import androidx.compose.ui.text.font.FontFamily
27import androidx.compose.ui.text.font.FontFamily
23import androidx.compose.ui.unit.dp
28import androidx.compose.ui.unit.dp
24
29
@@ -74,6 +79,7 @@ fun DiffView(diff: String, modifier: Modifier = Modifier) {
74 val removeColor = MaterialTheme.colorScheme.error.copy(alpha = 0.15f)
79 val removeColor = MaterialTheme.colorScheme.error.copy(alpha = 0.15f)
75 val addText = MaterialTheme.colorScheme.primary
80 val addText = MaterialTheme.colorScheme.primary
76 val removeText = MaterialTheme.colorScheme.error
81 val removeText = MaterialTheme.colorScheme.error
82+ val context = LocalContext.current
77
83
78 Column(
84 Column(
79 modifier = modifier
85 modifier = modifier
@@ -81,29 +87,40 @@ fun DiffView(diff: String, modifier: Modifier = Modifier) {
81 .padding(vertical = 4.dp)
87 .padding(vertical = 4.dp)
82 .clip(RoundedCornerShape(8.dp))
88 .clip(RoundedCornerShape(8.dp))
83 .background(MaterialTheme.colorScheme.surfaceVariant)
89 .background(MaterialTheme.colorScheme.surfaceVariant)
84− .verticalScroll(rememberScrollState())
85− .horizontalScroll(rememberScrollState())
86− .padding(10.dp),
90+ .padding(top = 2.dp, end = 4.dp),
87 ) {
91 ) {
92+ Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
93+ IconButton(onClick = { copyToClipboard(context, "diff", diff) }) {
94+ Icon(Icons.Filled.ContentCopy, contentDescription = "Copy", tint = MaterialTheme.colorScheme.onSurfaceVariant)
95+ }
96+ }
97+ Column(
98+ modifier = Modifier
99+ .horizontalScroll(rememberScrollState())
100+ .verticalScroll(rememberScrollState())
101+ .padding(horizontal = 10.dp)
102+ .padding(bottom = 10.dp),
103+ ) {
88− for (line in lines) {
104+ for (line in lines) {
89− when (line) {
105+ when (line) {
90− is DiffLine.Hunk -> Text(
106+ is DiffLine.Hunk -> Text(
91− line.text,
107+ line.text,
92− style = MaterialTheme.typography.bodySmall,
108+ style = MaterialTheme.typography.bodySmall,
93− fontFamily = FontFamily.Monospace,
109+ fontFamily = FontFamily.Monospace,
94− color = MaterialTheme.colorScheme.tertiary,
110+ color = MaterialTheme.colorScheme.tertiary,
95− modifier = Modifier.padding(vertical = 2.dp),
111+ modifier = Modifier.padding(vertical = 2.dp),
96− )
112+ )
97− is DiffLine.FileHeader -> Text(
113+ is DiffLine.FileHeader -> Text(
98− line.text,
114+ line.text,
99− style = MaterialTheme.typography.bodySmall,
115+ style = MaterialTheme.typography.bodySmall,
100− fontFamily = FontFamily.Monospace,
116+ fontFamily = FontFamily.Monospace,
101− color = MaterialTheme.colorScheme.onSurfaceVariant,
117+ color = MaterialTheme.colorScheme.onSurfaceVariant,
102− modifier = Modifier.padding(vertical = 1.dp),
118+ modifier = Modifier.padding(vertical = 1.dp),
103− )
119+ )
104− is DiffLine.Add -> DiffRow(line.text, "+", addColor, addText)
120+ is DiffLine.Add -> DiffRow(line.text, "+", addColor, addText)
105− is DiffLine.Remove -> DiffRow(line.text, "-", removeColor, removeText)
121+ is DiffLine.Remove -> DiffRow(line.text, "-", removeColor, removeText)
106− is DiffLine.Context -> DiffRow(line.text, " ", Color.Transparent, MaterialTheme.colorScheme.onSurface)
122+ is DiffLine.Context -> DiffRow(line.text, " ", Color.Transparent, MaterialTheme.colorScheme.onSurface)
123+ }
107 }
124 }
108 }
125 }
109 }
126 }
app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+21−1
@@ -1,23 +1,43 @@
1package soy.iko.opencode.ui.components
1package soy.iko.opencode.ui.components
2
2
3+import android.content.ClipData
4+import android.content.ClipboardManager
5+import android.content.Context
6+import android.widget.Toast
7+import androidx.compose.foundation.ExperimentalFoundationApi
8+import androidx.compose.foundation.combinedClickable
3import androidx.compose.material3.MaterialTheme
9import androidx.compose.material3.MaterialTheme
4import androidx.compose.runtime.Composable
10import androidx.compose.runtime.Composable
5import androidx.compose.ui.Modifier
11import androidx.compose.ui.Modifier
12+import androidx.compose.ui.platform.LocalContext
6import androidx.compose.ui.text.TextStyle
13import androidx.compose.ui.text.TextStyle
7import com.mikepenz.markdown.m3.Markdown
14import com.mikepenz.markdown.m3.Markdown
8
15
9/**
16/**
10 * Renders markdown using [multiplatform-markdown-renderer](https://github.com/mikepenz/multiplatform-markdown-renderer)
17 * Renders markdown using [multiplatform-markdown-renderer](https://github.com/mikepenz/multiplatform-markdown-renderer)
11 * (commonmark-java under the hood). Keeps a local API so callers don't import the library directly.
18 * (commonmark-java under the hood). Keeps a local API so callers don't import the library directly.
19+ * Long-press copies the raw markdown to the system clipboard.
12 */
20 */
21+@OptIn(ExperimentalFoundationApi::class)
13@Composable
22@Composable
14fun MarkdownText(
23fun MarkdownText(
15 markdown: String,
24 markdown: String,
16 modifier: Modifier = Modifier,
25 modifier: Modifier = Modifier,
17 style: TextStyle = MaterialTheme.typography.bodyLarge,
26 style: TextStyle = MaterialTheme.typography.bodyLarge,
18) {
27) {
28+ val context = LocalContext.current
19 Markdown(
29 Markdown(
20 content = markdown,
30 content = markdown,
21− modifier = modifier,
31+ modifier = modifier.combinedClickable(
32+ onClick = {},
33+ onLongClick = { copyToClipboard(context, markdown) },
34+ ),
22 )
35 )
23}
36}
37+
38+/** Copy [text] to the system clipboard and show a confirmation toast. */
39+internal fun copyToClipboard(context: Context, label: String, text: String = label) {
40+ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
41+ clipboard.setPrimaryClip(ClipData.newPlainText(label.take(40), text))
42+ Toast.makeText(context, "Copied", Toast.LENGTH_SHORT).show()
43+}
app/src/main/java/soy/iko/opencode/ui/components/StubScreen.ktdeleted−41
@@ -1,41 +0,0 @@
1−package soy.iko.opencode.ui.components
2−
3−import androidx.compose.foundation.layout.Box
4−import androidx.compose.foundation.layout.padding
5−import androidx.compose.material.icons.Icons
6−import androidx.compose.material.icons.automirrored.filled.ArrowBack
7−import androidx.compose.material3.ExperimentalMaterial3Api
8−import androidx.compose.material3.Icon
9−import androidx.compose.material3.IconButton
10−import androidx.compose.material3.Scaffold
11−import androidx.compose.material3.Text
12−import androidx.compose.material3.TopAppBar
13−import androidx.compose.runtime.Composable
14−import androidx.compose.ui.Alignment
15−import androidx.compose.ui.Modifier
16−import androidx.compose.ui.unit.dp
17−
18−/** Shared placeholder for not-yet-implemented destinations. */
19−@OptIn(ExperimentalMaterial3Api::class)
20−@Composable
21−fun StubScreen(title: String, message: String, onBack: () -> Unit) {
22− Scaffold(
23− topBar = {
24− TopAppBar(
25− title = { Text(title) },
26− navigationIcon = {
27− IconButton(onClick = onBack) {
28− Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
29− }
30− },
31− )
32− },
33− ) { padding ->
34− Box(
35− modifier = Modifier.padding(padding).padding(24.dp),
36− contentAlignment = Alignment.Center,
37− ) {
38− Text(message)
39− }
40− }
41−}
app/src/main/java/soy/iko/opencode/ui/components/TimeFormat.ktnew file+22
@@ -0,0 +1,22 @@
1+package soy.iko.opencode.ui.components
2+
3+import java.util.concurrent.TimeUnit
4+
5+/** Formats an epoch-millis timestamp as a short relative string (e.g. "3m", "2h", "5d"). */
6+fun relativeTime(epochMillis: Long?): String {
7+ if (epochMillis == null || epochMillis <= 0) return ""
8+ val diff = System.currentTimeMillis() - epochMillis
9+ if (diff < 0) return "now"
10+ val minutes = TimeUnit.MILLISECONDS.toMinutes(diff)
11+ if (minutes < 1) return "now"
12+ if (minutes < 60) return "${minutes}m"
13+ val hours = TimeUnit.MILLISECONDS.toHours(diff)
14+ if (hours < 24) return "${hours}h"
15+ val days = TimeUnit.MILLISECONDS.toDays(diff)
16+ if (days < 7) return "${days}d"
17+ val weeks = days / 7
18+ if (weeks < 5) return "${weeks}w"
19+ val months = days / 30
20+ if (months < 12) return "${months}mo"
21+ return "${days / 365}y"
22+}
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+43−1
@@ -1,6 +1,7 @@
1package soy.iko.opencode.ui.file
1package soy.iko.opencode.ui.file
2
2
3import androidx.compose.foundation.clickable
3import androidx.compose.foundation.clickable
4+import androidx.compose.foundation.horizontalScroll
4import androidx.compose.foundation.layout.Box
5import androidx.compose.foundation.layout.Box
5import androidx.compose.foundation.layout.Column
6import androidx.compose.foundation.layout.Column
6import androidx.compose.foundation.layout.Row
7import androidx.compose.foundation.layout.Row
@@ -10,10 +11,13 @@ import androidx.compose.foundation.layout.padding
10import androidx.compose.foundation.layout.size
11import androidx.compose.foundation.layout.size
11import androidx.compose.foundation.lazy.LazyColumn
12import androidx.compose.foundation.lazy.LazyColumn
12import androidx.compose.foundation.lazy.items
13import androidx.compose.foundation.lazy.items
14+import androidx.compose.foundation.rememberScrollState
13import androidx.compose.material.icons.Icons
15import androidx.compose.material.icons.Icons
14import androidx.compose.material.icons.automirrored.filled.ArrowBack
16import androidx.compose.material.icons.automirrored.filled.ArrowBack
17+import androidx.compose.material.icons.filled.ChevronRight
15import androidx.compose.material.icons.filled.Description
18import androidx.compose.material.icons.filled.Description
16import androidx.compose.material.icons.filled.Folder
19import androidx.compose.material.icons.filled.Folder
20+import androidx.compose.material.icons.filled.Home
17import androidx.compose.material.icons.filled.Search
21import androidx.compose.material.icons.filled.Search
18import androidx.compose.material3.CircularProgressIndicator
22import androidx.compose.material3.CircularProgressIndicator
19import androidx.compose.material3.ExperimentalMaterial3Api
23import androidx.compose.material3.ExperimentalMaterial3Api
@@ -49,7 +53,7 @@ fun FileBrowserScreen(
49 Scaffold(
53 Scaffold(
50 topBar = {
54 topBar = {
51 TopAppBar(
55 TopAppBar(
52− title = { Text(if (state.path.isBlank()) "Files" else state.path, maxLines = 1, overflow = TextOverflow.Ellipsis) },
56+ title = { Text("Files", maxLines = 1, overflow = TextOverflow.Ellipsis) },
53 navigationIcon = {
57 navigationIcon = {
54 IconButton(onClick = onBack) {
58 IconButton(onClick = onBack) {
55 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
59 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
@@ -59,6 +63,12 @@ fun FileBrowserScreen(
59 },
63 },
60 ) { padding ->
64 ) { padding ->
61 Column(modifier = Modifier.fillMaxSize().padding(padding)) {
65 Column(modifier = Modifier.fillMaxSize().padding(padding)) {
66+ // Tappable breadcrumb trail so deep paths are navigable.
67+ Breadcrumbs(
68+ path = state.path,
69+ onNavigate = vm::open,
70+ modifier = Modifier.fillMaxWidth(),
71+ )
62 OutlinedTextField(
72 OutlinedTextField(
63 value = state.query,
73 value = state.query,
64 onValueChange = vm::setQuery,
74 onValueChange = vm::setQuery,
@@ -89,6 +99,38 @@ fun FileBrowserScreen(
89 }
99 }
90}
100}
91
101
102+@Composable
103+private fun Breadcrumbs(path: String, onNavigate: (String) -> Unit, modifier: Modifier = Modifier) {
104+ val segments = if (path.isBlank()) emptyList() else path.trim('/').split('/').filter { it.isNotEmpty() }
105+ Row(
106+ modifier = modifier
107+ .horizontalScroll(rememberScrollState())
108+ .padding(horizontal = 16.dp, vertical = 8.dp),
109+ verticalAlignment = Alignment.CenterVertically,
110+ ) {
111+ IconButton(onClick = { onNavigate("") }, modifier = Modifier.size(20.dp)) {
112+ Icon(Icons.Filled.Home, contentDescription = "Root", modifier = Modifier.size(18.dp))
113+ }
114+ var acc = ""
115+ segments.forEachIndexed { index, segment ->
116+ acc = if (acc.isEmpty()) segment else "$acc/$segment"
117+ Icon(
118+ Icons.Filled.ChevronRight,
119+ contentDescription = null,
120+ modifier = Modifier.size(16.dp),
121+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
122+ )
123+ val target = acc
124+ Text(
125+ segment,
126+ style = MaterialTheme.typography.bodyMedium,
127+ color = if (index == segments.lastIndex) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.primary,
128+ modifier = Modifier.clickable { onNavigate(target) }.padding(vertical = 2.dp),
129+ )
130+ }
131+ }
132+}
133+
92@Composable
134@Composable
93private fun SearchResults(results: List<String>, onOpenFile: (String) -> Unit) {
135private fun SearchResults(results: List<String>, onOpenFile: (String) -> Unit) {
94 if (results.isEmpty()) {
136 if (results.isEmpty()) {
app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+61−7
@@ -1,13 +1,21 @@
1package soy.iko.opencode.ui.file
1package soy.iko.opencode.ui.file
2
2
3+import android.content.Intent
3import androidx.compose.foundation.horizontalScroll
4import androidx.compose.foundation.horizontalScroll
4import androidx.compose.foundation.layout.Box
5import androidx.compose.foundation.layout.Box
6+import androidx.compose.foundation.layout.Column
7+import androidx.compose.foundation.layout.Row
5import androidx.compose.foundation.layout.fillMaxSize
8import androidx.compose.foundation.layout.fillMaxSize
6import androidx.compose.foundation.layout.padding
9import androidx.compose.foundation.layout.padding
10+import androidx.compose.foundation.layout.width
11+import androidx.compose.foundation.lazy.LazyColumn
12+import androidx.compose.foundation.lazy.items
7import androidx.compose.foundation.rememberScrollState
13import androidx.compose.foundation.rememberScrollState
8import androidx.compose.foundation.verticalScroll
14import androidx.compose.foundation.verticalScroll
9import androidx.compose.material.icons.Icons
15import androidx.compose.material.icons.Icons
10import androidx.compose.material.icons.automirrored.filled.ArrowBack
16import androidx.compose.material.icons.automirrored.filled.ArrowBack
17+import androidx.compose.material.icons.filled.ContentCopy
18+import androidx.compose.material.icons.filled.Share
11import androidx.compose.material3.CircularProgressIndicator
19import androidx.compose.material3.CircularProgressIndicator
12import androidx.compose.material3.ExperimentalMaterial3Api
20import androidx.compose.material3.ExperimentalMaterial3Api
13import androidx.compose.material3.Icon
21import androidx.compose.material3.Icon
@@ -18,8 +26,10 @@ import androidx.compose.material3.Text
18import androidx.compose.material3.TopAppBar
26import androidx.compose.material3.TopAppBar
19import androidx.compose.runtime.Composable
27import androidx.compose.runtime.Composable
20import androidx.compose.runtime.getValue
28import androidx.compose.runtime.getValue
29+import androidx.compose.runtime.remember
21import androidx.compose.ui.Alignment
30import androidx.compose.ui.Alignment
22import androidx.compose.ui.Modifier
31import androidx.compose.ui.Modifier
32+import androidx.compose.ui.platform.LocalContext
23import androidx.compose.ui.text.font.FontFamily
33import androidx.compose.ui.text.font.FontFamily
24import androidx.compose.ui.text.style.TextOverflow
34import androidx.compose.ui.text.style.TextOverflow
25import androidx.compose.ui.unit.dp
35import androidx.compose.ui.unit.dp
@@ -27,6 +37,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
27import androidx.lifecycle.viewmodel.compose.viewModel
37import androidx.lifecycle.viewmodel.compose.viewModel
28import soy.iko.opencode.di.AppContainer
38import soy.iko.opencode.di.AppContainer
29import soy.iko.opencode.ui.components.DiffView
39import soy.iko.opencode.ui.components.DiffView
40+import soy.iko.opencode.ui.components.copyToClipboard
30import soy.iko.opencode.ui.vmFactory
41import soy.iko.opencode.ui.vmFactory
31
42
32@OptIn(ExperimentalMaterial3Api::class)
43@OptIn(ExperimentalMaterial3Api::class)
@@ -38,16 +49,36 @@ fun FileViewScreen(
38) {
49) {
39 val vm: FileViewModel = viewModel(factory = vmFactory { FileViewModel(container, path) })
50 val vm: FileViewModel = viewModel(factory = vmFactory { FileViewModel(container, path) })
40 val state by vm.state.collectAsStateWithLifecycle()
51 val state by vm.state.collectAsStateWithLifecycle()
52+ val context = LocalContext.current
53+ val filename = remember(path) { path.substringAfterLast('/') }
41
54
42 Scaffold(
55 Scaffold(
43 topBar = {
56 topBar = {
44 TopAppBar(
57 TopAppBar(
45− title = { Text(path.substringAfterLast('/'), maxLines = 1, overflow = TextOverflow.Ellipsis) },
58+ title = { Text(filename, maxLines = 1, overflow = TextOverflow.Ellipsis) },
46 navigationIcon = {
59 navigationIcon = {
47 IconButton(onClick = onBack) {
60 IconButton(onClick = onBack) {
48 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
61 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
49 }
62 }
50 },
63 },
64+ actions = {
65+ val content = state.content?.content.orEmpty()
66+ if (content.isNotEmpty()) {
67+ IconButton(onClick = { copyToClipboard(context, filename, content) }) {
68+ Icon(Icons.Filled.ContentCopy, contentDescription = "Copy")
69+ }
70+ IconButton(onClick = {
71+ val send = Intent(Intent.ACTION_SEND).apply {
72+ type = "text/plain"
73+ putExtra(Intent.EXTRA_SUBJECT, filename)
74+ putExtra(Intent.EXTRA_TEXT, content)
75+ }
76+ context.startActivity(Intent.createChooser(send, "Share $filename"))
77+ }) {
78+ Icon(Icons.Filled.Share, contentDescription = "Share")
79+ }
80+ }
81+ },
51 )
82 )
52 },
83 },
53 ) { padding ->
84 ) { padding ->
@@ -75,16 +106,39 @@ fun FileViewScreen(
75 .padding(padding),
106 .padding(padding),
76 )
107 )
77 } else {
108 } else {
78− Text(
79− text = content?.content.orEmpty(),
109+ val text = content?.content.orEmpty()
110+ val lines = remember(text) { text.split("\n") }
111+ val gutterWidth = remember(lines.size) {
112+ (lines.size.toString().length.coerceAtLeast(3) * 10).dp
113+ }
114+ Row(
80 modifier = Modifier
115 modifier = Modifier
81 .fillMaxSize()
116 .fillMaxSize()
82 .verticalScroll(scrollState)
117 .verticalScroll(scrollState)
83 .horizontalScroll(rememberScrollState())
118 .horizontalScroll(rememberScrollState())
84− .padding(12.dp),
119+ .padding(8.dp),
120+ ) {
121+ // Line-number gutter
122+ Column(modifier = Modifier.width(gutterWidth)) {
123+ lines.forEachIndexed { i, _ ->
124+ Text(
125+ "${i + 1}",
85− style = MaterialTheme.typography.bodySmall,
126+ style = MaterialTheme.typography.bodySmall,
127+ fontFamily = FontFamily.Monospace,
128+ color = MaterialTheme.colorScheme.onSurfaceVariant,
129+ )
130+ }
131+ }
132+ Column(modifier = Modifier.padding(start = 8.dp)) {
133+ lines.forEach { line ->
134+ Text(
135+ line,
136+ style = MaterialTheme.typography.bodySmall,
86− fontFamily = FontFamily.Monospace,
137+ fontFamily = FontFamily.Monospace,
87− )
138+ )
139+ }
140+ }
141+ }
88 }
142 }
89 }
143 }
90 }
144 }
app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+83−12
@@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Arrangement
5import androidx.compose.foundation.layout.Box
5import androidx.compose.foundation.layout.Box
6import androidx.compose.foundation.layout.Column
6import androidx.compose.foundation.layout.Column
7import androidx.compose.foundation.layout.Row
7import androidx.compose.foundation.layout.Row
8+import androidx.compose.foundation.layout.Spacer
8import androidx.compose.foundation.layout.fillMaxSize
9import androidx.compose.foundation.layout.fillMaxSize
9import androidx.compose.foundation.layout.fillMaxWidth
10import androidx.compose.foundation.layout.fillMaxWidth
10import androidx.compose.foundation.layout.padding
11import androidx.compose.foundation.layout.padding
@@ -13,30 +14,38 @@ import androidx.compose.foundation.lazy.LazyColumn
13import androidx.compose.foundation.lazy.items
14import androidx.compose.foundation.lazy.items
14import androidx.compose.material.icons.Icons
15import androidx.compose.material.icons.Icons
15import androidx.compose.material.icons.filled.Add
16import androidx.compose.material.icons.filled.Add
17+import androidx.compose.material.icons.filled.CheckCircle
16import androidx.compose.material.icons.filled.Delete
18import androidx.compose.material.icons.filled.Delete
19+import androidx.compose.material.icons.filled.Dns
17import androidx.compose.material.icons.filled.Edit
20import androidx.compose.material.icons.filled.Edit
21+import androidx.compose.material3.AlertDialog
22+import androidx.compose.material3.Card
18import androidx.compose.material3.CircularProgressIndicator
23import androidx.compose.material3.CircularProgressIndicator
19import androidx.compose.material3.ExperimentalMaterial3Api
24import androidx.compose.material3.ExperimentalMaterial3Api
20import androidx.compose.material3.FloatingActionButton
25import androidx.compose.material3.FloatingActionButton
21import androidx.compose.material3.Icon
26import androidx.compose.material3.Icon
22import androidx.compose.material3.IconButton
27import androidx.compose.material3.IconButton
23import androidx.compose.material3.MaterialTheme
28import androidx.compose.material3.MaterialTheme
24−import androidx.compose.material3.Card
25import androidx.compose.material3.Scaffold
29import androidx.compose.material3.Scaffold
26import androidx.compose.material3.SnackbarHost
30import androidx.compose.material3.SnackbarHost
27import androidx.compose.material3.SnackbarHostState
31import androidx.compose.material3.SnackbarHostState
28import androidx.compose.material3.Text
32import androidx.compose.material3.Text
33+import androidx.compose.material3.TextButton
29import androidx.compose.material3.TopAppBar
34import androidx.compose.material3.TopAppBar
30import androidx.compose.runtime.Composable
35import androidx.compose.runtime.Composable
31import androidx.compose.runtime.LaunchedEffect
36import androidx.compose.runtime.LaunchedEffect
32import androidx.compose.runtime.getValue
37import androidx.compose.runtime.getValue
38+import androidx.compose.runtime.mutableStateOf
33import androidx.compose.runtime.remember
39import androidx.compose.runtime.remember
40+import androidx.compose.runtime.setValue
34import androidx.compose.ui.Alignment
41import androidx.compose.ui.Alignment
35import androidx.compose.ui.Modifier
42import androidx.compose.ui.Modifier
43+import androidx.compose.ui.platform.LocalHapticFeedback
36import androidx.compose.ui.text.style.TextOverflow
44import androidx.compose.ui.text.style.TextOverflow
37import androidx.compose.ui.unit.dp
45import androidx.compose.ui.unit.dp
38import androidx.lifecycle.compose.collectAsStateWithLifecycle
46import androidx.lifecycle.compose.collectAsStateWithLifecycle
39import androidx.lifecycle.viewmodel.compose.viewModel
47import androidx.lifecycle.viewmodel.compose.viewModel
48+import soy.iko.opencode.data.model.ServerProfile
40import soy.iko.opencode.di.AppContainer
49import soy.iko.opencode.di.AppContainer
41import soy.iko.opencode.ui.vmFactory
50import soy.iko.opencode.ui.vmFactory
42
51
@@ -52,7 +61,11 @@ fun ServerListScreen(
52 val profiles by vm.profiles.collectAsStateWithLifecycle()
61 val profiles by vm.profiles.collectAsStateWithLifecycle()
53 val connectingId by vm.connectingId.collectAsStateWithLifecycle()
62 val connectingId by vm.connectingId.collectAsStateWithLifecycle()
54 val error by vm.error.collectAsStateWithLifecycle()
63 val error by vm.error.collectAsStateWithLifecycle()
64+ val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
65+ val haptics = LocalHapticFeedback.current
55 val snackbar = remember { SnackbarHostState() }
66 val snackbar = remember { SnackbarHostState() }
67+ var pendingDelete by remember { mutableStateOf<ServerProfile?>(null) }
68+ val connectedId = activeConnection?.profile?.id
56
69
57 LaunchedEffect(error) {
70 LaunchedEffect(error) {
58 error?.let {
71 error?.let {
@@ -71,15 +84,10 @@ fun ServerListScreen(
71 },
84 },
72 ) { padding ->
85 ) { padding ->
73 if (profiles.isEmpty()) {
86 if (profiles.isEmpty()) {
74− Box(
87+ EmptyServers(
88+ onAdd = onAddProfile,
75− modifier = Modifier.fillMaxSize().padding(padding).padding(24.dp),
89+ modifier = Modifier.fillMaxSize().padding(padding),
76− contentAlignment = Alignment.Center,
77− ) {
90+ )
78− Text(
79− "No servers yet.\nTap + to add an opencode serve endpoint.",
80− style = MaterialTheme.typography.bodyLarge,
81− )
82− }
83 } else {
91 } else {
84 LazyColumn(
92 LazyColumn(
85 modifier = Modifier.fillMaxSize().padding(padding),
93 modifier = Modifier.fillMaxSize().padding(padding),
@@ -87,6 +95,7 @@ fun ServerListScreen(
87 verticalArrangement = Arrangement.spacedBy(12.dp),
95 verticalArrangement = Arrangement.spacedBy(12.dp),
88 ) {
96 ) {
89 items(profiles, key = { it.id }) { profile ->
97 items(profiles, key = { it.id }) { profile ->
98+ val isActive = profile.id == connectedId
90 Card(
99 Card(
91 modifier = Modifier
100 modifier = Modifier
92 .fillMaxWidth()
101 .fillMaxWidth()
@@ -112,14 +121,28 @@ fun ServerListScreen(
112 maxLines = 1,
121 maxLines = 1,
113 overflow = TextOverflow.Ellipsis,
122 overflow = TextOverflow.Ellipsis,
114 )
123 )
124+ if (isActive) {
125+ Text(
126+ "Connected",
127+ style = MaterialTheme.typography.labelSmall,
128+ color = MaterialTheme.colorScheme.primary,
129+ )
130+ }
115 }
131 }
132+ if (isActive) {
133+ Icon(
134+ Icons.Filled.CheckCircle,
135+ contentDescription = "Connected",
136+ tint = MaterialTheme.colorScheme.primary,
137+ modifier = Modifier.size(22.dp),
138+ )
116− if (connectingId == profile.id) {
139+ } else if (connectingId == profile.id) {
117 CircularProgressIndicator(modifier = Modifier.size(24.dp))
140 CircularProgressIndicator(modifier = Modifier.size(24.dp))
118 } else {
141 } else {
119 IconButton(onClick = { onEditProfile(profile.id) }) {
142 IconButton(onClick = { onEditProfile(profile.id) }) {
120 Icon(Icons.Filled.Edit, contentDescription = "Edit")
143 Icon(Icons.Filled.Edit, contentDescription = "Edit")
121 }
144 }
122− IconButton(onClick = { vm.delete(profile) }) {
145+ IconButton(onClick = { pendingDelete = profile }) {
123 Icon(Icons.Filled.Delete, contentDescription = "Delete")
146 Icon(Icons.Filled.Delete, contentDescription = "Delete")
124 }
147 }
125 }
148 }
@@ -129,4 +152,52 @@ fun ServerListScreen(
129 }
152 }
130 }
153 }
131 }
154 }
155+
156+ pendingDelete?.let { profile ->
157+ AlertDialog(
158+ onDismissRequest = { pendingDelete = null },
159+ title = { Text("Remove server?") },
160+ text = { Text("“${profile.displayLabel}” will be removed from your saved servers.") },
161+ confirmButton = {
162+ TextButton(onClick = {
163+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
164+ pendingDelete = null
165+ vm.delete(profile)
166+ }) { Text("Remove", color = MaterialTheme.colorScheme.error) }
167+ },
168+ dismissButton = {
169+ TextButton(onClick = { pendingDelete = null }) { Text("Cancel") }
170+ },
171+ )
172+ }
173+}
174+
175+@Composable
176+private fun EmptyServers(onAdd: () -> Unit, modifier: Modifier = Modifier) {
177+ Box(modifier = modifier, contentAlignment = Alignment.Center) {
178+ Column(
179+ modifier = Modifier.padding(32.dp),
180+ horizontalAlignment = Alignment.CenterHorizontally,
181+ ) {
182+ Icon(
183+ Icons.Filled.Dns,
184+ contentDescription = null,
185+ modifier = Modifier.size(56.dp),
186+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
187+ )
188+ Spacer(Modifier.size(16.dp))
189+ Text("No servers yet", style = MaterialTheme.typography.titleMedium)
190+ Spacer(Modifier.size(8.dp))
191+ Text(
192+ "Add an opencode serve endpoint to connect.",
193+ style = MaterialTheme.typography.bodyMedium,
194+ color = MaterialTheme.colorScheme.onSurfaceVariant,
195+ )
196+ Spacer(Modifier.size(20.dp))
197+ TextButton(onClick = onAdd) {
198+ Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(18.dp))
199+ Text(" Add server")
200+ }
201+ }
202+ }
132}
203}
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+138−26
@@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Box
6import androidx.compose.foundation.layout.Column
6import androidx.compose.foundation.layout.Column
7import androidx.compose.foundation.layout.PaddingValues
7import androidx.compose.foundation.layout.PaddingValues
8import androidx.compose.foundation.layout.Row
8import androidx.compose.foundation.layout.Row
9+import androidx.compose.foundation.layout.Spacer
9import androidx.compose.foundation.layout.fillMaxSize
10import androidx.compose.foundation.layout.fillMaxSize
10import androidx.compose.foundation.layout.fillMaxWidth
11import androidx.compose.foundation.layout.fillMaxWidth
11import androidx.compose.foundation.layout.padding
12import androidx.compose.foundation.layout.padding
@@ -16,10 +17,14 @@ import androidx.compose.material.icons.Icons
16import androidx.compose.material.icons.automirrored.filled.Logout
17import androidx.compose.material.icons.automirrored.filled.Logout
17import androidx.compose.material.icons.filled.Add
18import androidx.compose.material.icons.filled.Add
18import androidx.compose.material.icons.filled.ArrowDropDown
19import androidx.compose.material.icons.filled.ArrowDropDown
20+import androidx.compose.material.icons.filled.ChatBubbleOutline
19import androidx.compose.material.icons.filled.Delete
21import androidx.compose.material.icons.filled.Delete
20import androidx.compose.material.icons.filled.Folder
22import androidx.compose.material.icons.filled.Folder
21import androidx.compose.material.icons.filled.Refresh
23import androidx.compose.material.icons.filled.Refresh
24+import androidx.compose.material.icons.filled.Search
22import androidx.compose.material.icons.filled.Settings
25import androidx.compose.material.icons.filled.Settings
26+import androidx.compose.material3.AlertDialog
27+import androidx.compose.material3.Button
23import androidx.compose.material3.Card
28import androidx.compose.material3.Card
24import androidx.compose.material3.CircularProgressIndicator
29import androidx.compose.material3.CircularProgressIndicator
25import androidx.compose.material3.DropdownMenu
30import androidx.compose.material3.DropdownMenu
@@ -29,8 +34,10 @@ import androidx.compose.material3.ExtendedFloatingActionButton
29import androidx.compose.material3.Icon
34import androidx.compose.material3.Icon
30import androidx.compose.material3.IconButton
35import androidx.compose.material3.IconButton
31import androidx.compose.material3.MaterialTheme
36import androidx.compose.material3.MaterialTheme
37+import androidx.compose.material3.OutlinedTextField
32import androidx.compose.material3.Scaffold
38import androidx.compose.material3.Scaffold
33import androidx.compose.material3.Text
39import androidx.compose.material3.Text
40+import androidx.compose.material3.TextButton
34import androidx.compose.material3.TopAppBar
41import androidx.compose.material3.TopAppBar
35import androidx.compose.runtime.Composable
42import androidx.compose.runtime.Composable
36import androidx.compose.runtime.getValue
43import androidx.compose.runtime.getValue
@@ -39,11 +46,14 @@ import androidx.compose.runtime.remember
39import androidx.compose.runtime.setValue
46import androidx.compose.runtime.setValue
40import androidx.compose.ui.Alignment
47import androidx.compose.ui.Alignment
41import androidx.compose.ui.Modifier
48import androidx.compose.ui.Modifier
49+import androidx.compose.ui.platform.LocalHapticFeedback
42import androidx.compose.ui.text.style.TextOverflow
50import androidx.compose.ui.text.style.TextOverflow
43import androidx.compose.ui.unit.dp
51import androidx.compose.ui.unit.dp
44import androidx.lifecycle.compose.collectAsStateWithLifecycle
52import androidx.lifecycle.compose.collectAsStateWithLifecycle
45import androidx.lifecycle.viewmodel.compose.viewModel
53import androidx.lifecycle.viewmodel.compose.viewModel
54+import soy.iko.opencode.data.model.Session
46import soy.iko.opencode.di.AppContainer
55import soy.iko.opencode.di.AppContainer
56+import soy.iko.opencode.ui.components.relativeTime
47import soy.iko.opencode.ui.vmFactory
57import soy.iko.opencode.ui.vmFactory
48
58
49@OptIn(ExperimentalMaterial3Api::class)
59@OptIn(ExperimentalMaterial3Api::class)
@@ -60,7 +70,9 @@ fun SessionListScreen(
60 val serverLabel by vm.serverLabel.collectAsStateWithLifecycle()
70 val serverLabel by vm.serverLabel.collectAsStateWithLifecycle()
61 val profiles by vm.profiles.collectAsStateWithLifecycle()
71 val profiles by vm.profiles.collectAsStateWithLifecycle()
62 val switchingId by vm.switchingId.collectAsStateWithLifecycle()
72 val switchingId by vm.switchingId.collectAsStateWithLifecycle()
73+ val haptics = LocalHapticFeedback.current
63 var showServerMenu by remember { mutableStateOf(false) }
74 var showServerMenu by remember { mutableStateOf(false) }
75+ var pendingDelete by remember { mutableStateOf<Session?>(null) }
64
76
65 Scaffold(
77 Scaffold(
66 topBar = {
78 topBar = {
@@ -145,35 +157,39 @@ fun SessionListScreen(
145 modifier = Modifier.align(Alignment.Center).padding(24.dp),
157 modifier = Modifier.align(Alignment.Center).padding(24.dp),
146 color = MaterialTheme.colorScheme.error,
158 color = MaterialTheme.colorScheme.error,
147 )
159 )
148− state.sessions.isEmpty() -> Text(
160+ state.sessions.isEmpty() -> EmptySessions(
149− "No sessions yet.\nTap “New session” to start.",
161+ onCreate = { vm.createSession(onCreated = onOpenSession) },
150− modifier = Modifier.align(Alignment.Center).padding(24.dp),
162+ modifier = Modifier.align(Alignment.Center),
151 )
163 )
164+ else -> Column(modifier = Modifier.fillMaxSize()) {
165+ OutlinedTextField(
166+ value = state.query,
167+ onValueChange = vm::setQuery,
168+ modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
169+ placeholder = { Text("Search sessions…") },
170+ leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
171+ singleLine = true,
172+ )
173+ val sessions = state.filtered
174+ if (sessions.isEmpty()) {
175+ Text(
176+ "No sessions match “${state.query}”.",
177+ modifier = Modifier.padding(24.dp),
178+ color = MaterialTheme.colorScheme.onSurfaceVariant,
179+ )
180+ } else {
152− else -> LazyColumn(
181+ LazyColumn(
153− modifier = Modifier.fillMaxSize(),
182+ modifier = Modifier.fillMaxSize(),
154− contentPadding = PaddingValues(16.dp),
183+ contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 96.dp),
155− verticalArrangement = Arrangement.spacedBy(12.dp),
184+ verticalArrangement = Arrangement.spacedBy(12.dp),
156− ) {
157− items(state.sessions, key = { it.id }) { session ->
158− Card(
159− modifier = Modifier
160− .fillMaxWidth()
161− .clickable { onOpenSession(session.id) },
162 ) {
185 ) {
163− Row(
186+ items(sessions, key = { it.id }) { session ->
164− modifier = Modifier.fillMaxWidth().padding(16.dp),
187+ SessionCard(
165− verticalAlignment = Alignment.CenterVertically,
166− ) {
167− Text(
168− session.displayTitle,
188+ session = session,
169− style = MaterialTheme.typography.titleMedium,
189+ preview = state.previews[session.id],
170− maxLines = 2,
190+ onClick = { onOpenSession(session.id) },
171− overflow = TextOverflow.Ellipsis,
191+ onDelete = { pendingDelete = session },
172− modifier = Modifier.weight(1f),
173 )
192 )
174− IconButton(onClick = { vm.deleteSession(session) }) {
175− Icon(Icons.Filled.Delete, contentDescription = "Delete")
176− }
177 }
193 }
178 }
194 }
179 }
195 }
@@ -181,4 +197,100 @@ fun SessionListScreen(
181 }
197 }
182 }
198 }
183 }
199 }
200+
201+ pendingDelete?.let { session ->
202+ AlertDialog(
203+ onDismissRequest = { pendingDelete = null },
204+ title = { Text("Delete session?") },
205+ text = { Text("“${session.displayTitle}” will be permanently deleted. This cannot be undone.") },
206+ confirmButton = {
207+ TextButton(onClick = {
208+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
209+ pendingDelete = null
210+ vm.deleteSession(session)
211+ }) { Text("Delete", color = MaterialTheme.colorScheme.error) }
212+ },
213+ dismissButton = {
214+ TextButton(onClick = { pendingDelete = null }) { Text("Cancel") }
215+ },
216+ )
217+ }
218+}
219+
220+@Composable
221+private fun SessionCard(
222+ session: Session,
223+ preview: String?,
224+ onClick: () -> Unit,
225+ onDelete: () -> Unit,
226+) {
227+ Card(
228+ modifier = Modifier
229+ .fillMaxWidth()
230+ .clickable { onClick() },
231+ ) {
232+ Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
233+ Row(
234+ modifier = Modifier.fillMaxWidth(),
235+ verticalAlignment = Alignment.CenterVertically,
236+ ) {
237+ Text(
238+ session.displayTitle,
239+ style = MaterialTheme.typography.titleMedium,
240+ maxLines = 1,
241+ overflow = TextOverflow.Ellipsis,
242+ modifier = Modifier.weight(1f),
243+ )
244+ val time = relativeTime(session.time?.updated ?: session.time?.created)
245+ if (time.isNotEmpty()) {
246+ Text(
247+ time,
248+ style = MaterialTheme.typography.labelSmall,
249+ color = MaterialTheme.colorScheme.onSurfaceVariant,
250+ )
251+ }
252+ IconButton(onClick = onDelete) {
253+ Icon(Icons.Filled.Delete, contentDescription = "Delete")
254+ }
255+ }
256+ if (!preview.isNullOrBlank()) {
257+ Spacer(Modifier.size(4.dp))
258+ Text(
259+ preview,
260+ style = MaterialTheme.typography.bodyMedium,
261+ color = MaterialTheme.colorScheme.onSurfaceVariant,
262+ maxLines = 2,
263+ overflow = TextOverflow.Ellipsis,
264+ )
265+ }
266+ }
267+ }
268+}
269+
270+@Composable
271+private fun EmptySessions(onCreate: () -> Unit, modifier: Modifier = Modifier) {
272+ Column(
273+ modifier = modifier.padding(32.dp),
274+ horizontalAlignment = Alignment.CenterHorizontally,
275+ ) {
276+ Icon(
277+ Icons.Filled.ChatBubbleOutline,
278+ contentDescription = null,
279+ modifier = Modifier.size(56.dp),
280+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
281+ )
282+ Spacer(Modifier.size(16.dp))
283+ Text("No sessions yet", style = MaterialTheme.typography.titleMedium)
284+ Spacer(Modifier.size(8.dp))
285+ Text(
286+ "Start a conversation with opencode.",
287+ style = MaterialTheme.typography.bodyMedium,
288+ color = MaterialTheme.colorScheme.onSurfaceVariant,
289+ )
290+ Spacer(Modifier.size(20.dp))
291+ Button(onClick = onCreate) {
292+ Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(18.dp))
293+ Text(" New session")
294+ }
295+ }
184}
296}
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+39−5
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
4import androidx.lifecycle.viewModelScope
4import androidx.lifecycle.viewModelScope
5import soy.iko.opencode.data.model.ServerProfile
5import soy.iko.opencode.data.model.ServerProfile
6import soy.iko.opencode.data.model.Session
6import soy.iko.opencode.data.model.Session
7+import soy.iko.opencode.data.model.TextPart
7import soy.iko.opencode.di.AppContainer
8import soy.iko.opencode.di.AppContainer
8import kotlinx.coroutines.flow.MutableStateFlow
9import kotlinx.coroutines.flow.MutableStateFlow
9import kotlinx.coroutines.flow.SharingStarted
10import kotlinx.coroutines.flow.SharingStarted
@@ -14,9 +15,22 @@ import kotlinx.coroutines.launch
14
15
15data class SessionListState(
16data class SessionListState(
16 val sessions: List<Session> = emptyList(),
17 val sessions: List<Session> = emptyList(),
18+ val query: String = "",
19+ val previews: Map<String, String> = emptyMap(),
17 val loading: Boolean = true,
20 val loading: Boolean = true,
18 val error: String? = null,
21 val error: String? = null,
19−)
22+) {
23+ /** Sessions filtered by the current search query (title or preview text). */
24+ val filtered: List<Session>
25+ get() {
26+ val q = query.trim()
27+ if (q.isEmpty()) return sessions
28+ return sessions.filter { session ->
29+ session.displayTitle.contains(q, ignoreCase = true) ||
30+ (previews[session.id]?.contains(q, ignoreCase = true) == true)
31+ }
32+ }
33+}
20
34
21class SessionListViewModel(private val container: AppContainer) : ViewModel() {
35class SessionListViewModel(private val container: AppContainer) : ViewModel() {
22
36
@@ -43,6 +57,10 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
43
57
44 init { refresh() }
58 init { refresh() }
45
59
60+ fun setQuery(query: String) {
61+ _state.value = _state.value.copy(query = query)
62+ }
63+
46 fun refresh() {
64 fun refresh() {
47 val conn = container.activeConnection.value
65 val conn = container.activeConnection.value
48 if (conn == null) {
66 if (conn == null) {
@@ -54,15 +72,31 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
54 viewModelScope.launch {
72 viewModelScope.launch {
55 runCatching { conn.repository.listSessions() }
73 runCatching { conn.repository.listSessions() }
56 .onSuccess { list ->
74 .onSuccess { list ->
57− _state.value = SessionListState(
58− sessions = list.sortedByDescending { it.time?.updated ?: it.time?.created ?: 0 },
75+ val sorted = list.sortedByDescending { it.time?.updated ?: it.time?.created ?: 0 }
59− loading = false,
76+ _state.value = _state.value.copy(sessions = sorted, loading = false)
60− )
77+ loadPreviews(sorted)
61 }
78 }
62 .onFailure { _state.value = SessionListState(loading = false, error = it.message ?: "Failed to load sessions") }
79 .onFailure { _state.value = SessionListState(loading = false, error = it.message ?: "Failed to load sessions") }
63 }
80 }
64 }
81 }
65
82
83+ /** Fetch the last text part of each session for list previews (best-effort, bounded). */
84+ private fun loadPreviews(sessions: List<Session>) {
85+ val conn = container.activeConnection.value ?: return
86+ val api = conn.api
87+ val toLoad = sessions.take(50)
88+ toLoad.forEach { session ->
89+ viewModelScope.launch {
90+ val preview = runCatching {
91+ api.listMessages(session.id).lastOrNull()?.let { msg ->
92+ msg.parts.filterIsInstance<TextPart>().lastOrNull()?.text
93+ }
94+ }.getOrNull()?.takeIf { p -> p.isNotBlank() } ?: return@launch
95+ _state.value = _state.value.copy(previews = _state.value.previews + (session.id to preview))
96+ }
97+ }
98+ }
99+
66 fun createSession(onCreated: (String) -> Unit) {
100 fun createSession(onCreated: (String) -> Unit) {
67 val conn = container.activeConnection.value ?: return
101 val conn = container.activeConnection.value ?: return
68 viewModelScope.launch {
102 viewModelScope.launch {
app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+66−4
@@ -1,12 +1,18 @@
1package soy.iko.opencode.ui.settings
1package soy.iko.opencode.ui.settings
2
2
3+import androidx.compose.foundation.clickable
3import androidx.compose.foundation.layout.Arrangement
4import androidx.compose.foundation.layout.Arrangement
4import androidx.compose.foundation.layout.Column
5import androidx.compose.foundation.layout.Column
6+import androidx.compose.foundation.layout.Row
7+import androidx.compose.foundation.layout.Spacer
5import androidx.compose.foundation.layout.fillMaxWidth
8import androidx.compose.foundation.layout.fillMaxWidth
6import androidx.compose.foundation.layout.padding
9import androidx.compose.foundation.layout.padding
10+import androidx.compose.foundation.layout.size
7import androidx.compose.foundation.selection.selectable
11import androidx.compose.foundation.selection.selectable
8import androidx.compose.material.icons.Icons
12import androidx.compose.material.icons.Icons
9import androidx.compose.material.icons.automirrored.filled.ArrowBack
13import androidx.compose.material.icons.automirrored.filled.ArrowBack
14+import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
15+import androidx.compose.material.icons.filled.Dns
10import androidx.compose.material3.ExperimentalMaterial3Api
16import androidx.compose.material3.ExperimentalMaterial3Api
11import androidx.compose.material3.HorizontalDivider
17import androidx.compose.material3.HorizontalDivider
12import androidx.compose.material3.Icon
18import androidx.compose.material3.Icon
@@ -14,13 +20,17 @@ import androidx.compose.material3.IconButton
14import androidx.compose.material3.MaterialTheme
20import androidx.compose.material3.MaterialTheme
15import androidx.compose.material3.RadioButton
21import androidx.compose.material3.RadioButton
16import androidx.compose.material3.Scaffold
22import androidx.compose.material3.Scaffold
23+import androidx.compose.material3.Switch
17import androidx.compose.material3.Text
24import androidx.compose.material3.Text
25+import androidx.compose.material3.TextButton
18import androidx.compose.material3.TopAppBar
26import androidx.compose.material3.TopAppBar
19import androidx.compose.runtime.Composable
27import androidx.compose.runtime.Composable
20import androidx.compose.runtime.getValue
28import androidx.compose.runtime.getValue
29+import androidx.compose.runtime.remember
21import androidx.compose.runtime.rememberCoroutineScope
30import androidx.compose.runtime.rememberCoroutineScope
22import androidx.compose.ui.Alignment
31import androidx.compose.ui.Alignment
23import androidx.compose.ui.Modifier
32import androidx.compose.ui.Modifier
33+import androidx.compose.ui.platform.LocalContext
24import androidx.compose.ui.unit.dp
34import androidx.compose.ui.unit.dp
25import androidx.lifecycle.compose.collectAsStateWithLifecycle
35import androidx.lifecycle.compose.collectAsStateWithLifecycle
26import kotlinx.coroutines.launch
36import kotlinx.coroutines.launch
@@ -29,10 +39,16 @@ import soy.iko.opencode.di.AppContainer
29
39
30@OptIn(ExperimentalMaterial3Api::class)
40@OptIn(ExperimentalMaterial3Api::class)
31@Composable
41@Composable
32−fun SettingsScreen(container: AppContainer, onBack: () -> Unit) {
42+fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers: () -> Unit) {
33 val scope = rememberCoroutineScope()
43 val scope = rememberCoroutineScope()
34 val themeMode by container.settingsStore.themeMode.collectAsStateWithLifecycle(initialValue = ThemeMode.SYSTEM)
44 val themeMode by container.settingsStore.themeMode.collectAsStateWithLifecycle(initialValue = ThemeMode.SYSTEM)
45+ val dynamicColor by container.settingsStore.dynamicColor.collectAsStateWithLifecycle(initialValue = true)
35 val activeProfile = container.activeConnection.collectAsStateWithLifecycle().value?.profile
46 val activeProfile = container.activeConnection.collectAsStateWithLifecycle().value?.profile
47+ val context = LocalContext.current
48+ val versionName = remember {
49+ runCatching { context.packageManager.getPackageInfo(context.packageName, 0).versionName }
50+ .getOrDefault("0.1.0")
51+ }
36
52
37 Scaffold(
53 Scaffold(
38 topBar = {
54 topBar = {
@@ -49,13 +65,32 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit) {
49 Column(modifier = Modifier.padding(padding).padding(16.dp)) {
65 Column(modifier = Modifier.padding(padding).padding(16.dp)) {
50 Text("Appearance", style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary)
66 Text("Appearance", style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary)
51 ThemeMode.entries.forEach { mode ->
67 ThemeMode.entries.forEach { mode ->
52− Row(
68+ ThemeRow(
53 mode = mode,
69 mode = mode,
54 selected = themeMode == mode,
70 selected = themeMode == mode,
55 onSelect = { scope.launch { container.settingsStore.setThemeMode(mode) } },
71 onSelect = { scope.launch { container.settingsStore.setThemeMode(mode) } },
56 )
72 )
57 }
73 }
58
74
75+ Spacer(Modifier.size(8.dp))
76+ Row(
77+ modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
78+ verticalAlignment = Alignment.CenterVertically,
79+ ) {
80+ Column(modifier = Modifier.weight(1f)) {
81+ Text("Dynamic color", style = MaterialTheme.typography.bodyLarge)
82+ Text(
83+ "Use Material You wallpaper colors (Android 12+)",
84+ style = MaterialTheme.typography.bodySmall,
85+ color = MaterialTheme.colorScheme.onSurfaceVariant,
86+ )
87+ }
88+ Switch(
89+ checked = dynamicColor,
90+ onCheckedChange = { scope.launch { container.settingsStore.setDynamicColor(it) } },
91+ )
92+ }
93+
59 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
94 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
60
95
61 Text("Connection", style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary)
96 Text("Connection", style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary)
@@ -73,13 +108,40 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit) {
73 color = MaterialTheme.colorScheme.onSurfaceVariant,
108 color = MaterialTheme.colorScheme.onSurfaceVariant,
74 )
109 )
75 }
110 }
111+ Row(
112+ modifier = Modifier
113+ .fillMaxWidth()
114+ .padding(top = 8.dp)
115+ .clickable { onManageServers() }
116+ .padding(vertical = 8.dp),
117+ verticalAlignment = Alignment.CenterVertically,
118+ ) {
119+ Icon(Icons.Filled.Dns, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
120+ Text(" Manage servers", modifier = Modifier.weight(1f))
121+ Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
122+ }
123+
124+ HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
125+
126+ Text("About", style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary)
127+ Text(
128+ "opencode-android $versionName",
129+ style = MaterialTheme.typography.bodyMedium,
130+ modifier = Modifier.padding(top = 8.dp),
131+ )
132+ Text(
133+ "A native Android client for the opencode serve HTTP API.",
134+ style = MaterialTheme.typography.bodySmall,
135+ color = MaterialTheme.colorScheme.onSurfaceVariant,
136+ modifier = Modifier.padding(top = 4.dp),
137+ )
76 }
138 }
77 }
139 }
78}
140}
79
141
80@Composable
142@Composable
81−private fun Row(mode: ThemeMode, selected: Boolean, onSelect: () -> Unit) {
143+private fun ThemeRow(mode: ThemeMode, selected: Boolean, onSelect: () -> Unit) {
82− androidx.compose.foundation.layout.Row(
144+ Row(
83 modifier = Modifier
145 modifier = Modifier
84 .fillMaxWidth()
146 .fillMaxWidth()
85 .selectable(selected = selected, onClick = onSelect)
147 .selectable(selected = selected, onClick = onSelect)
app/src/main/java/soy/iko/opencode/ui/theme/Theme.kt+12−1
@@ -1,14 +1,18 @@
1package soy.iko.opencode.ui.theme
1package soy.iko.opencode.ui.theme
2
2
3import android.app.Activity
3import android.app.Activity
4+import android.os.Build
4import androidx.compose.foundation.isSystemInDarkTheme
5import androidx.compose.foundation.isSystemInDarkTheme
5import androidx.compose.material3.MaterialTheme
6import androidx.compose.material3.MaterialTheme
6import androidx.compose.material3.darkColorScheme
7import androidx.compose.material3.darkColorScheme
8+import androidx.compose.material3.dynamicDarkColorScheme
9+import androidx.compose.material3.dynamicLightColorScheme
7import androidx.compose.material3.lightColorScheme
10import androidx.compose.material3.lightColorScheme
8import androidx.compose.runtime.Composable
11import androidx.compose.runtime.Composable
9import androidx.compose.runtime.SideEffect
12import androidx.compose.runtime.SideEffect
10import androidx.compose.ui.graphics.Color
13import androidx.compose.ui.graphics.Color
11import androidx.compose.ui.graphics.toArgb
14import androidx.compose.ui.graphics.toArgb
15+import androidx.compose.ui.platform.LocalContext
12import androidx.compose.ui.platform.LocalView
16import androidx.compose.ui.platform.LocalView
13import androidx.core.view.WindowCompat
17import androidx.core.view.WindowCompat
14
18
@@ -29,9 +33,16 @@ private val LightColors = lightColorScheme(
29@Composable
33@Composable
30fun OpencodeTheme(
34fun OpencodeTheme(
31 darkTheme: Boolean = isSystemInDarkTheme(),
35 darkTheme: Boolean = isSystemInDarkTheme(),
36+ dynamicColor: Boolean = false,
32 content: @Composable () -> Unit,
37 content: @Composable () -> Unit,
33) {
38) {
39+ val context = LocalContext.current
40+ val colorScheme = when {
41+ dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ->
42+ if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
43+ darkTheme -> DarkColors
34− val colorScheme = if (darkTheme) DarkColors else LightColors
44+ else -> LightColors
45+ }
35 val view = LocalView.current
46 val view = LocalView.current
36 if (!view.isInEditMode) {
47 if (!view.isInEditMode) {
37 SideEffect {
48 SideEffect {
app/src/test/java/soy/iko/opencode/UxTest.ktnew file+91
@@ -0,0 +1,91 @@
1+package soy.iko.opencode
2+
3+import org.junit.Assert.assertEquals
4+import org.junit.Assert.assertTrue
5+import org.junit.Test
6+import soy.iko.opencode.data.model.Session
7+import soy.iko.opencode.data.model.TimeInfo
8+import soy.iko.opencode.ui.components.relativeTime
9+import soy.iko.opencode.ui.session.SessionListState
10+import java.util.concurrent.TimeUnit
11+
12+class UxTest {
13+
14+ // --- relativeTime ---
15+
16+ @Test
17+ fun relativeTimeHandlesNullAndZero() {
18+ assertEquals("", relativeTime(null))
19+ assertEquals("", relativeTime(0))
20+ assertEquals("", relativeTime(-1))
21+ }
22+
23+ @Test
24+ fun relativeTimeFormatsMinutes() {
25+ val now = System.currentTimeMillis()
26+ assertEquals("now", relativeTime(now))
27+ assertEquals("5m", relativeTime(now - TimeUnit.MINUTES.toMillis(5)))
28+ }
29+
30+ @Test
31+ fun relativeTimeFormatsHoursAndDays() {
32+ val now = System.currentTimeMillis()
33+ assertEquals("3h", relativeTime(now - TimeUnit.HOURS.toMillis(3)))
34+ assertEquals("2d", relativeTime(now - TimeUnit.DAYS.toMillis(2)))
35+ assertEquals("1w", relativeTime(now - TimeUnit.DAYS.toMillis(7)))
36+ }
37+
38+ // --- Session search/filter ---
39+
40+ private fun session(id: String, title: String) =
41+ Session(id = id, title = title)
42+
43+ @Test
44+ fun emptyQueryReturnsAllSessions() {
45+ val state = SessionListState(
46+ sessions = listOf(session("1", "Alpha"), session("2", "Beta")),
47+ )
48+ assertEquals(2, state.filtered.size)
49+ }
50+
51+ @Test
52+ fun queryFiltersByTitle() {
53+ val state = SessionListState(
54+ sessions = listOf(session("1", "Refactor tests"), session("2", "Add docs")),
55+ query = "refactor",
56+ )
57+ val result = state.filtered
58+ assertEquals(1, result.size)
59+ assertEquals("1", result.first().id)
60+ }
61+
62+ @Test
63+ fun queryFiltersByPreviewText() {
64+ val state = SessionListState(
65+ sessions = listOf(session("1", "Alpha"), session("2", "Beta")),
66+ previews = mapOf("2" to "discuss the database migration"),
67+ query = "database",
68+ )
69+ val result = state.filtered
70+ assertEquals(1, result.size)
71+ assertEquals("2", result.first().id)
72+ }
73+
74+ @Test
75+ fun queryIsCaseInsensitive() {
76+ val state = SessionListState(
77+ sessions = listOf(session("1", "Build Pipeline")),
78+ query = "build",
79+ )
80+ assertEquals(1, state.filtered.size)
81+ }
82+
83+ @Test
84+ fun noMatchReturnsEmpty() {
85+ val state = SessionListState(
86+ sessions = listOf(session("1", "Alpha")),
87+ query = "zzz",
88+ )
89+ assertTrue(state.filtered.isEmpty())
90+ }
91+}