git.iko.soy/opencode-android
on master help sign in
Add copy button to assistant messages, search filters to picker sheets, and make session cards fully clickable
commita40d3a203e7ab4663d3d2e8ddf5cea7068919af5 authoriko <mail@iko.soy> dateWed 1 Jul 2026, 08:10 (3 months ago) parentf50f967
6 files changed+127−22
app/src/main/java/soy/iko/opencode/ui/chat/AgentPickerSheet.kt+28−1 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt−1 app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+54−8 app/src/main/java/soy/iko/opencode/ui/chat/ModelPickerSheet.kt+38−2 app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+4−10 app/src/main/res/values/strings.xml+3
app/src/main/java/soy/iko/opencode/ui/chat/AgentPickerSheet.kt+28−1
@@ -9,15 +9,23 @@ import androidx.compose.foundation.layout.padding
99import androidx.compose.foundation.layout.size
1010import androidx.compose.foundation.lazy.LazyColumn
1111import androidx.compose.foundation.lazy.items
12+import androidx.compose.foundation.text.KeyboardOptions
1213import androidx.compose.material.icons.Icons
1314import androidx.compose.material.icons.filled.Check
15+import androidx.compose.material.icons.filled.Search
1416import androidx.compose.material3.CircularProgressIndicator
1517import androidx.compose.material3.ExperimentalMaterial3Api
1618import androidx.compose.material3.Icon
1719import androidx.compose.material3.MaterialTheme
1820import androidx.compose.material3.ModalBottomSheet
21+import androidx.compose.material3.OutlinedTextField
1922import androidx.compose.material3.Text
2023import androidx.compose.runtime.Composable
24+import androidx.compose.runtime.getValue
25+import androidx.compose.runtime.mutableStateOf
26+import androidx.compose.runtime.remember
27+import androidx.compose.runtime.saveable.rememberSaveable
28+import androidx.compose.runtime.setValue
2129import androidx.compose.ui.Alignment
2230import androidx.compose.ui.Modifier
2331import androidx.compose.ui.res.stringResource
@@ -26,6 +34,7 @@ import androidx.compose.ui.semantics.contentDescription
2634import androidx.compose.ui.semantics.role
2735import androidx.compose.ui.semantics.selected
2836import androidx.compose.ui.semantics.semantics
37+import androidx.compose.ui.text.input.ImeAction
2938import androidx.compose.ui.unit.dp
3039import androidx.compose.foundation.layout.Box
3140import soy.iko.opencode.data.model.Agent
@@ -75,6 +84,24 @@ fun AgentPickerSheet(
7584 modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
7685 )
7786 } else {
87+ var query by rememberSaveable { mutableStateOf("") }
88+ OutlinedTextField(
89+ value = query,
90+ onValueChange = { query = it },
91+ modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 8.dp),
92+ placeholder = { Text(stringResource(R.string.search_agents)) },
93+ leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
94+ singleLine = true,
95+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
96+ )
97+ val filtered = remember(agents, query) {
98+ val q = query.trim()
99+ if (q.isEmpty()) agents
100+ else agents.filter {
101+ it.name.contains(q, ignoreCase = true) ||
102+ it.displayDescription.contains(q, ignoreCase = true)
103+ }
104+ }
78105 LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
79106 item(key = "__default") {
80107 Column(
@@ -109,7 +136,7 @@ fun AgentPickerSheet(
109136 )
110137 }
111138 }
112− items(agents, key = { it.name }) { agent ->
139+ items(filtered, key = { it.name }) { agent ->
113140 val isSelected = agent.name == selected
114141 Column(
115142 modifier = Modifier
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt−1
@@ -486,7 +486,6 @@ private fun ChatInputBar(
486486 }
487487 },
488488 placeholder = { Text(stringResource(R.string.message_placeholder)) },
489− label = { Text(stringResource(R.string.message_placeholder)) },
490489 enabled = enabled,
491490 maxLines = 6,
492491 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+54−8
@@ -6,7 +6,12 @@ import androidx.compose.foundation.layout.Column
66import androidx.compose.foundation.layout.Row
77import androidx.compose.foundation.layout.fillMaxWidth
88import androidx.compose.foundation.layout.padding
9+import androidx.compose.foundation.layout.size
910import androidx.compose.foundation.shape.RoundedCornerShape
11+import androidx.compose.material.icons.Icons
12+import androidx.compose.material.icons.filled.ContentCopy
13+import androidx.compose.material3.Icon
14+import androidx.compose.material3.IconButton
1015import androidx.compose.material3.MaterialTheme
1116import androidx.compose.material3.Text
1217import androidx.compose.runtime.Composable
@@ -14,15 +19,18 @@ import androidx.compose.runtime.remember
1419import androidx.compose.ui.Alignment
1520import androidx.compose.ui.Modifier
1621import androidx.compose.ui.draw.clip
22+import androidx.compose.ui.platform.LocalContext
1723import androidx.compose.ui.res.stringResource
1824import androidx.compose.ui.unit.dp
1925import soy.iko.opencode.R
2026import soy.iko.opencode.data.model.AssistantMessage
2127import soy.iko.opencode.data.model.MessageWithParts
28+import soy.iko.opencode.data.model.TextPart
2229import soy.iko.opencode.data.model.Tokens
2330import soy.iko.opencode.data.model.UserMessage
2431import soy.iko.opencode.data.network.NetworkConfig
2532import soy.iko.opencode.ui.components.ImageLoadContext
33+import soy.iko.opencode.ui.components.copyToClipboard
2634import soy.iko.opencode.ui.components.rememberRelativeTime
2735
2836/** A single message: user prompts right-aligned in a bubble, assistant output full-width. */
@@ -87,20 +95,53 @@ private fun AssistantBlock(message: MessageWithParts, isRunning: Boolean, imageC
8795 cost?.takeIf { it > 0 }?.let { add(formatCost(it)) }
8896 }.takeIf { it.isNotEmpty() }?.joinToString(" • ")
8997 }
90− costSummary?.let {
91− Text(
92− it,
93− style = MaterialTheme.typography.labelSmall,
94− color = MaterialTheme.colorScheme.onSurfaceVariant,
95− )
98+ Row(
99+ modifier = Modifier.fillMaxWidth(),
100+ horizontalArrangement = Arrangement.SpaceBetween,
101+ verticalAlignment = Alignment.CenterVertically,
102+ ) {
103+ Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
104+ costSummary?.let {
105+ Text(
106+ it,
107+ style = MaterialTheme.typography.labelSmall,
108+ color = MaterialTheme.colorScheme.onSurfaceVariant,
109+ )
110+ }
111+ MessageTimestampText(message.info)
112+ }
113+ val context = LocalContext.current
114+ val copyLabel = stringResource(R.string.copy)
115+ // Collect text from all TextParts for copying. Memoized so a
116+ // scroll-induced recomposition doesn't re-scan the parts list.
117+ val textToCopy = remember(message.parts) {
118+ message.parts
119+ .filterIsInstance<TextPart>()
120+ .joinToString("\n\n") { it.text }
121+ .takeIf { it.isNotBlank() }
122+ }
123+ if (textToCopy != null) {
124+ IconButton(
125+ onClick = { copyToClipboard(context, "message", textToCopy) },
126+ modifier = Modifier.size(28.dp),
127+ ) {
128+ Icon(
129+ Icons.Filled.ContentCopy,
130+ contentDescription = copyLabel,
131+ modifier = Modifier.size(14.dp),
132+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
133+ )
134+ }
135+ }
96136 }
137+ } else {
138+ MessageTimestampText(message.info)
97139 }
98− MessageTimestamp(message.info)
99140 }
100141}
101142
102143@Composable
103−private fun MessageTimestamp(info: soy.iko.opencode.data.model.MessageInfo) {
144+private fun MessageTimestampText(info: soy.iko.opencode.data.model.MessageInfo) {
104145 val t = rememberRelativeTime(info.time?.created)
105146 if (t.isNotEmpty()) {
106147 Text(
@@ -111,6 +152,11 @@ private fun MessageTimestamp(info: soy.iko.opencode.data.model.MessageInfo) {
111152 }
112153}
113154
155+@Composable
156+private fun MessageTimestamp(info: soy.iko.opencode.data.model.MessageInfo) {
157+ MessageTimestampText(info)
158+}
159+
114160// NumberFormat.getNumberInstance performs an expensive ICU locale lookup + object
115161// construction on every call. Reuse a thread-local instance so repeated calls (e.g.
116162// when the message list re-seeds after a reconnect and every visible assistant bubble
app/src/main/java/soy/iko/opencode/ui/chat/ModelPickerSheet.kt+38−2
@@ -9,15 +9,23 @@ import androidx.compose.foundation.layout.padding
99import androidx.compose.foundation.layout.size
1010import androidx.compose.foundation.lazy.LazyColumn
1111import androidx.compose.foundation.lazy.items
12+import androidx.compose.foundation.text.KeyboardOptions
1213import androidx.compose.material.icons.Icons
1314import androidx.compose.material.icons.filled.Check
15+import androidx.compose.material.icons.filled.Search
1416import androidx.compose.material3.CircularProgressIndicator
1517import androidx.compose.material3.ExperimentalMaterial3Api
1618import androidx.compose.material3.Icon
1719import androidx.compose.material3.MaterialTheme
1820import androidx.compose.material3.ModalBottomSheet
21+import androidx.compose.material3.OutlinedTextField
1922import androidx.compose.material3.Text
2023import androidx.compose.runtime.Composable
24+import androidx.compose.runtime.getValue
25+import androidx.compose.runtime.mutableStateOf
26+import androidx.compose.runtime.remember
27+import androidx.compose.runtime.saveable.rememberSaveable
28+import androidx.compose.runtime.setValue
2129import androidx.compose.ui.Alignment
2230import androidx.compose.ui.Modifier
2331import androidx.compose.ui.res.stringResource
@@ -26,6 +34,7 @@ import androidx.compose.ui.semantics.contentDescription
2634import androidx.compose.ui.semantics.role
2735import androidx.compose.ui.semantics.selected
2836import androidx.compose.ui.semantics.semantics
37+import androidx.compose.ui.text.input.ImeAction
2938import androidx.compose.ui.unit.dp
3039import androidx.compose.foundation.layout.Box
3140import soy.iko.opencode.data.model.ModelOption
@@ -75,8 +84,34 @@ fun ModelPickerSheet(
7584 modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
7685 )
7786 } else {
78− LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
79− items(options, key = { it.providerID to it.modelID }) { option ->
87+ var query by rememberSaveable { mutableStateOf("") }
88+ OutlinedTextField(
89+ value = query,
90+ onValueChange = { query = it },
91+ modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 8.dp),
92+ placeholder = { Text(stringResource(R.string.search_models)) },
93+ leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
94+ singleLine = true,
95+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
96+ )
97+ val filtered = remember(options, query) {
98+ val q = query.trim()
99+ if (q.isEmpty()) options
100+ else options.filter {
101+ it.modelLabel.contains(q, ignoreCase = true) ||
102+ it.providerLabel.contains(q, ignoreCase = true)
103+ }
104+ }
105+ if (filtered.isEmpty()) {
106+ Text(
107+ stringResource(R.string.no_models_match),
108+ style = MaterialTheme.typography.bodyMedium,
109+ color = MaterialTheme.colorScheme.onSurfaceVariant,
110+ modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
111+ )
112+ } else {
113+ LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
114+ items(filtered, key = { it.providerID to it.modelID }) { option ->
80115 val isSelected = option.providerID == selected?.providerID &&
81116 option.modelID == selected?.modelID
82117 Column(
@@ -116,5 +151,6 @@ fun ModelPickerSheet(
116151 }
117152 }
118153 }
154+ }
119155 }
120156}
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+4−10
@@ -353,21 +353,16 @@ private fun SessionCard(
353353 modifier: Modifier = Modifier,
354354) {
355355 Card(
356− modifier = modifier.fillMaxWidth(),
356+ modifier = modifier
357+ .fillMaxWidth()
358+ .clickable(role = Role.Button) { onClick() },
357359 ) {
358360 Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
359361 Row(
360362 modifier = Modifier.fillMaxWidth(),
361363 verticalAlignment = Alignment.CenterVertically,
362364 ) {
363− // Title area is the primary click target; the action buttons below
364− // remain separate accessibility nodes so screen readers can activate
365− // them independently.
366− Column(
367− modifier = Modifier
368− .weight(1f)
369− .clickable(role = Role.Button) { onClick() },
370− ) {
365+ Column(modifier = Modifier.weight(1f)) {
371366 Row(verticalAlignment = Alignment.CenterVertically) {
372367 if (unread) {
373368 val unreadLabel = stringResource(R.string.unread)
@@ -413,7 +408,6 @@ private fun SessionCard(
413408 color = if (unread) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
414409 maxLines = 2,
415410 overflow = TextOverflow.Ellipsis,
416− modifier = Modifier.clickable(role = Role.Button) { onClick() },
417411 )
418412 }
419413 }
app/src/main/res/values/strings.xml+3
@@ -132,6 +132,9 @@
132132 <string name="load_failed">Failed to load. Tap retry.</string>
133133 <string name="saving">Saving…</string>
134134 <string name="image_failed">Failed to load image</string>
135+ <string name="search_models">Search models…</string>
136+ <string name="search_agents">Search agents…</string>
137+ <string name="no_models_match">No models match "%1$s".</string>
135138
136139 <!-- File browser -->
137140 <string name="search_files">Search files…</string>