@@ -3,13 +3,14 @@ package soy.iko.opencode.ui.chat
33import androidx.compose.animation.AnimatedVisibility
44import androidx.compose.foundation.background
55import androidx.compose.foundation.clickable
6+import androidx.compose.foundation.combinedClickable
67import androidx.compose.foundation.layout.Column
78import androidx.compose.foundation.layout.Row
89import androidx.compose.foundation.layout.defaultMinSize
910import androidx.compose.foundation.layout.fillMaxWidth
1011import androidx.compose.foundation.layout.padding
1112import androidx.compose.foundation.layout.size
12−import androidx.compose.foundation.shape.RoundedCornerShape
13+import androidx.compose.foundation.text.selection.SelectionContainer
1314import androidx.compose.material.icons.Icons
1415import androidx.compose.material.icons.automirrored.filled.HelpOutline
1516import androidx.compose.material.icons.filled.Bolt
@@ -93,9 +94,19 @@ private fun ToolState.inputJson(): JsonElement? = when (this) {
9394/**
9495 * Renders a single message [Part]. The exhaustive `when` over the sealed type gives
9596 * compile-time coverage; the [UnknownPart] arm keeps the UI forward-compatible.
97+ *
98+ * [onOpenFile] is invoked when the user taps a [FilePart] chip that references a
99+ * source path the viewer can open; null leaves the chip as a copy-path affordance
100+ * (used when no file navigation is wired, e.g. in tests).
96101 */
97102@Composable
98−fun PartView(part: Part, modifier: Modifier = Modifier, isRunning: Boolean = false, imageContext: ImageLoadContext? = null) {
103+fun PartView(
104+ part: Part,
105+ modifier: Modifier = Modifier,
106+ isRunning: Boolean = false,
107+ imageContext: ImageLoadContext? = null,
108+ onOpenFile: ((path: String) -> Unit)? = null,
109+) {
99110 when (part) {
100111 is TextPart -> if (!part.ignored && part.text.isNotEmpty()) {
101112 MarkdownText(part.text, modifier = modifier, streaming = isRunning)
@@ -105,7 +116,7 @@ fun PartView(part: Part, modifier: Modifier = Modifier, isRunning: Boolean = fal
105116 is FilePart -> if (part.isImage && imageContext != null && (part.source != null || !part.url.isNullOrBlank())) {
106117 RemoteImage(part, imageContext, modifier)
107118 } else {
108− FileChip(part, modifier)
119+ FileChip(part, modifier, onOpenFile)
109120 }
110121 is StepStartPart -> {} // boundary marker — nothing to draw
111122 is StepFinishPart -> {} // metrics handled at message level
@@ -184,11 +195,16 @@ private fun ReasoningBlock(text: String, streaming: Boolean, modifier: Modifier)
184195 }
185196 AnimatedVisibility(visible = expanded) {
186197 Column {
187− Text(
188− text,
189− style = MaterialTheme.typography.bodySmall,
190− color = MaterialTheme.colorScheme.onSurfaceVariant,
191− )
198+ // Wrap in SelectionContainer so the user can select a portion of the
199+ // reasoning (e.g. a single step) instead of the all-or-nothing copy
200+ // button below. Matches the markdown text's selectability.
201+ SelectionContainer {
202+ Text(
203+ text,
204+ style = MaterialTheme.typography.bodySmall,
205+ color = MaterialTheme.colorScheme.onSurfaceVariant,
206+ )
207+ }
192208 TextButton(
193209 onClick = { copyToClipboard(context, "reasoning", text) },
194210 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
@@ -209,7 +225,7 @@ private fun ToolCallView(part: ToolPart, modifier: Modifier) {
209225 Column(
210226 modifier = modifier
211227 .fillMaxWidth()
212− .clip(RoundedCornerShape(8.dp))
228+ .clip(MaterialTheme.shapes.small)
213229 .background(MaterialTheme.colorScheme.surfaceVariant)
214230 .padding(10.dp),
215231 ) {
@@ -325,11 +341,15 @@ private fun CollapsibleDetail(
325341 if (isDiff) {
326342 DiffView(detail)
327343 } else {
328− Text(
329− detail,
330− style = MaterialTheme.typography.bodySmall,
331− fontFamily = FontFamily.Monospace,
332− )
344+ // SelectionContainer so the user can select a portion of the output (e.g.
345+ // a single line of stdout) instead of only copy-all via the button below.
346+ SelectionContainer {
347+ Text(
348+ detail,
349+ style = MaterialTheme.typography.bodySmall,
350+ fontFamily = FontFamily.Monospace,
351+ )
352+ }
333353 }
334354 if (onToggleExpand != null) {
335355 TextButton(
@@ -395,22 +415,46 @@ private fun ToolStatusIcon(state: ToolState) {
395415 }
396416}
397417
418+@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
398419@Composable
399−private fun FileChip(part: FilePart, modifier: Modifier) {
420+private fun FileChip(part: FilePart, modifier: Modifier, onOpenFile: ((String) -> Unit)?) {
400421 val context = LocalContext.current
401422 val path = part.source ?: part.url ?: part.filename
402423 val copyLabel = stringResource(R.string.copy_path)
424+ val openLabel = stringResource(R.string.open_file)
425+ // When a source path is available and a navigation callback is wired, tapping the
426+ // chip opens the file in the viewer (the action a user tapping a file reference
427+ // most likely expects). Long-press still copies the path to the clipboard, so the
428+ // copy affordance is preserved without being the default tap action. When no
429+ // navigation is wired (e.g. tests), falls back to copy-path on tap.
430+ val source = part.source
431+ val opener = onOpenFile
432+ val openPath: String? = if (opener != null && !source.isNullOrBlank()) source else null
433+ val canOpen = openPath != null
434+ val semanticsLabel = if (canOpen) openLabel else copyLabel
403435 Row(
404436 modifier = modifier
405− .clip(RoundedCornerShape(12.dp))
437+ .clip(MaterialTheme.shapes.small)
406438 .background(MaterialTheme.colorScheme.surfaceVariant)
407− .clickable(role = Role.Button) {
408− if (!path.isNullOrBlank()) {
409− copyToClipboard(context, "path", path)
410− showToast(context, context.getString(R.string.path_copied))
411− }
412− }
413− .semantics { contentDescription = copyLabel }
439+ .combinedClickable(
440+ role = Role.Button,
441+ onClick = {
442+ val toOpen = openPath
443+ if (toOpen != null) {
444+ opener?.invoke(toOpen)
445+ } else if (!path.isNullOrBlank()) {
446+ copyToClipboard(context, "path", path)
447+ showToast(context, context.getString(R.string.path_copied))
448+ }
449+ },
450+ onLongClick = {
451+ if (!path.isNullOrBlank()) {
452+ copyToClipboard(context, "path", path)
453+ showToast(context, context.getString(R.string.path_copied))
454+ }
455+ },
456+ )
457+ .semantics { contentDescription = semanticsLabel }
414458 .padding(horizontal = 12.dp, vertical = 6.dp),
415459 verticalAlignment = Alignment.CenterVertically,
416460 ) {