1package soy.iko.opencode.ui.chat
3import androidx.compose.foundation.background
4import androidx.compose.foundation.clickable
5import androidx.compose.foundation.gestures.detectTapGestures
6import androidx.compose.foundation.layout.Arrangement
7import androidx.compose.foundation.layout.Box
8import androidx.compose.foundation.layout.BoxWithConstraints
9import androidx.compose.foundation.layout.Column
10import androidx.compose.foundation.layout.Row
11import androidx.compose.foundation.layout.fillMaxWidth
12import androidx.compose.foundation.layout.padding
13import androidx.compose.foundation.layout.size
14import androidx.compose.foundation.layout.widthIn
15import androidx.compose.material.icons.Icons
16import androidx.compose.material.icons.automirrored.filled.HelpOutline
17import androidx.compose.material.icons.automirrored.filled.PlaylistAdd
18import androidx.compose.material.icons.automirrored.filled.VolumeUp
19import androidx.compose.material.icons.filled.Close
20import androidx.compose.material.icons.filled.ContentCopy
21import androidx.compose.material.icons.filled.Edit
22import androidx.compose.material.icons.filled.Error
23import androidx.compose.material.icons.filled.FormatQuote
24import androidx.compose.material.icons.filled.MoreVert
25import androidx.compose.material.icons.filled.Pause
26import androidx.compose.material.icons.filled.PlayArrow
27import androidx.compose.material.icons.filled.Refresh
28import androidx.compose.material.icons.filled.Restore
29import androidx.compose.material.icons.filled.Share
30import androidx.compose.material.icons.filled.SmartToy
31import androidx.compose.material3.DropdownMenu
32import androidx.compose.material3.DropdownMenuItem
33import androidx.compose.material3.CircularProgressIndicator
34import androidx.compose.material3.Icon
35import androidx.compose.material3.IconButton
36import androidx.compose.material3.MaterialTheme
37import androidx.compose.material3.ripple
38import androidx.compose.material3.Text
39import androidx.compose.runtime.Composable
40import androidx.compose.runtime.getValue
41import androidx.compose.runtime.key
42import androidx.compose.runtime.mutableStateOf
43import androidx.compose.runtime.remember
44import androidx.compose.runtime.setValue
45import androidx.compose.ui.Alignment
46import androidx.compose.ui.Modifier
47import androidx.compose.ui.draw.clip
48import androidx.compose.ui.hapticfeedback.HapticFeedbackType
49import androidx.compose.ui.input.pointer.pointerInput
50import androidx.compose.ui.platform.LocalContext
51import androidx.compose.ui.platform.LocalHapticFeedback
52import androidx.compose.ui.res.stringResource
53import androidx.compose.ui.semantics.Role
54import androidx.compose.ui.semantics.contentDescription
55import androidx.compose.ui.semantics.semantics
56import androidx.compose.ui.text.style.TextOverflow
57import androidx.compose.ui.unit.dp
58import androidx.compose.foundation.interaction.MutableInteractionSource
59import soy.iko.opencode.R
60import soy.iko.opencode.data.model.AssistantMessage
61import soy.iko.opencode.data.model.MessageWithParts
62import soy.iko.opencode.data.model.ModelOption
63import soy.iko.opencode.data.model.FilePart
64import soy.iko.opencode.data.model.TextPart
65import soy.iko.opencode.data.model.UnknownMessage
66import soy.iko.opencode.data.model.UserMessage
67import soy.iko.opencode.data.network.NetworkConfig
68import soy.iko.opencode.ui.components.ImageLoadContext
69import soy.iko.opencode.ui.components.copyToClipboard
70import soy.iko.opencode.ui.components.showToast
71import soy.iko.opencode.ui.components.RelativeTimeText
74 * Resolve an assistant message's model id to a friendly label from the loaded catalog,
75 * falling back to the raw id (so a model not in the catalog still shows something
76 * meaningful instead of being hidden). Returns null only when the message has no model.
78fun resolveModelLabel(info: AssistantMessage, models: List<ModelOption>): String? {
79 val id = info.modelID ?: return null
80 if (models.isEmpty()) return id
81 val byPair = models.firstOrNull { it.providerID == info.providerID && it.modelID == id }
82 return byPair?.modelLabel
83 ?: models.firstOrNull { it.modelID == id }?.modelLabel
87/** Status of an outgoing message, shown as a small indicator on the user bubble. */
88enum class MessageSendStatus { SENDING, FAILED }
90/** A single message: user prompts right-aligned in a bubble, assistant output full-width. */
91@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
94 message: MessageWithParts,
95 modifier: Modifier = Modifier,
96 isRunning: Boolean = false,
97 imageContext: ImageLoadContext? = null,
98 modelLabel: String? = null,
99 onOpenFile: ((String) -> Unit)? = null,
100 onRevert: (() -> Unit)? = null,
101 onEdit: ((String) -> Unit)? = null,
102 onSpeak: ((String) -> Unit)? = null,
103 isSpeaking: Boolean = false,
104 ttsState: TtsState = TtsState.IDLE,
105 onPause: (() -> Unit)? = null,
106 onResume: (() -> Unit)? = null,
107 onQuote: ((String) -> Unit)? = null,
108 onBranch: ((String) -> Unit)? = null,
109 onRegenerate: (() -> Unit)? = null,
110 sendStatus: MessageSendStatus? = null,
111 isEdited: Boolean = false,
112 onRetry: (() -> Unit)? = null,
113 onDismiss: (() -> Unit)? = null,
114 onShare: (() -> Unit)? = null,
116 when (message.info) {
117 is UserMessage -> UserBubble(message, imageContext, modifier, onOpenFile, onRevert, onEdit, onQuote, onBranch, sendStatus, isEdited, onRetry, onDismiss, onShare)
118 is UnknownMessage -> UnknownMessageBlock(message, imageContext, modifier, onOpenFile)
119 else -> AssistantBlock(message, isRunning, imageContext, modifier, modelLabel, onOpenFile, onRevert, onSpeak, isSpeaking, ttsState, onPause, onResume, onQuote, onBranch, onRegenerate, isEdited, onShare)
123/** A long-press context menu consolidating all per-message actions (copy, quote, branch,
124 * revert). Surfacing these via long-press matches the conventional Android pattern so the
125 * actions are discoverable without spotting the small 18dp inline icon row. The menu is
126 * anchored to the long-press position via [offset], and only items with non-null callbacks
127 * (and text to act on) are shown. */
129private fun MessageLongPressMenu(
131 onDismiss: () -> Unit,
132 offset: androidx.compose.ui.unit.DpOffset,
134 onCopy: (() -> Unit)?,
135 onQuote: ((String) -> Unit)?,
136 onBranch: ((String) -> Unit)?,
137 onRevert: (() -> Unit)?,
138 onShare: (() -> Unit)?,
140 DropdownMenu(expanded = expanded, onDismissRequest = onDismiss, offset = offset) {
141 if (text != null && onCopy != null) {
143 leadingIcon = { Icon(Icons.Filled.ContentCopy, contentDescription = null) },
144 text = { Text(stringResource(R.string.copy)) },
145 onClick = { onDismiss(); onCopy() },
148 // Per-message share: fire an ACTION_SEND with this message's Markdown, so a user can
149 // forward a single reply (e.g. a code snippet) without exporting the whole transcript.
150 // Gated on text so an image-only prompt doesn't show a no-op Share entry.
151 if (text != null && onShare != null) {
153 leadingIcon = { Icon(Icons.Filled.Share, contentDescription = null) },
154 text = { Text(stringResource(R.string.share_message)) },
155 onClick = { onDismiss(); onShare() },
158 if (text != null && onQuote != null) {
160 leadingIcon = { Icon(Icons.Filled.FormatQuote, contentDescription = null) },
161 text = { Text(stringResource(R.string.quote_reply)) },
162 onClick = { onDismiss(); onQuote(text) },
165 if (text != null && onBranch != null) {
167 leadingIcon = { Icon(Icons.AutoMirrored.Filled.PlaylistAdd, contentDescription = null) },
168 text = { Text(stringResource(R.string.branch_session)) },
169 onClick = { onDismiss(); onBranch(text) },
172 if (onRevert != null) {
174 leadingIcon = { Icon(Icons.Filled.Restore, contentDescription = null) },
175 text = { Text(stringResource(R.string.revert_to_here)) },
176 onClick = { onDismiss(); onRevert() },
182/** Overflow menu of secondary per-message actions (quote-reply, branch-a-new-session).
183 * Shown when at least one action is available and there's text to act on. Keeps the
184 * inline action row (copy/edit/revert/speak) uncluttered while still surfacing the extras. */
186private fun MessageOverflow(
188 onQuote: ((String) -> Unit)?,
189 onBranch: ((String) -> Unit)?,
191 if (onQuote == null && onBranch == null) return
192 var expanded by remember { mutableStateOf(false) }
193 val moreLabel = stringResource(R.string.message_actions)
194 val haptics = LocalHapticFeedback.current
195 androidx.compose.foundation.layout.Box {
196 IconButton(onClick = { expanded = true }) {
198 Icons.Filled.MoreVert,
199 contentDescription = moreLabel,
200 modifier = Modifier.size(18.dp),
201 tint = MaterialTheme.colorScheme.onSurfaceVariant,
204 DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
205 onQuote?.let { quote ->
207 leadingIcon = { Icon(Icons.Filled.FormatQuote, contentDescription = null) },
208 text = { Text(stringResource(R.string.quote_reply)) },
211 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
216 onBranch?.let { branch ->
218 leadingIcon = { Icon(Icons.AutoMirrored.Filled.PlaylistAdd, contentDescription = null) },
219 text = { Text(stringResource(R.string.branch_session)) },
222 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
231/** A small "revert to before this message" icon button, shown next to Copy when a revert
232 * handler is supplied. Reverting is undoable (see the chat's revert banner), so it acts
233 * immediately without a confirmation dialog. */
235private fun RevertButton(onRevert: () -> Unit) {
236 val label = stringResource(R.string.revert_to_here)
237 val haptics = LocalHapticFeedback.current
238 IconButton(onClick = {
239 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
243 Icons.Filled.Restore,
244 contentDescription = label,
245 modifier = Modifier.size(18.dp),
246 tint = MaterialTheme.colorScheme.onSurfaceVariant,
252private fun UnknownMessageBlock(
253 message: MessageWithParts,
254 imageContext: ImageLoadContext?,
256 onOpenFile: ((String) -> Unit)? = null,
258 // Forward-compat: a role the client doesn't model. Render a muted note so the user
259 // sees *something* rather than an unlabeled block, plus any parts (e.g. text) the
260 // server attached, so content isn't silently dropped.
262 modifier = modifier.fillMaxWidth(),
263 verticalArrangement = Arrangement.spacedBy(8.dp),
265 Row(verticalAlignment = Alignment.CenterVertically) {
267 Icons.AutoMirrored.Filled.HelpOutline,
268 contentDescription = null,
269 modifier = Modifier.size(14.dp),
270 tint = MaterialTheme.colorScheme.onSurfaceVariant,
273 stringResource(R.string.unknown_message),
274 style = MaterialTheme.typography.labelSmall,
275 color = MaterialTheme.colorScheme.onSurfaceVariant,
276 modifier = Modifier.padding(start = 6.dp),
279 // key() per part so each PartView gets a distinct saveable registry slot: rememberSaveable
280 // inside PartView keys its RESET on part.id but its registry key is the positional
281 // compositeKeyHash, which collides for sibling parts in a keyless loop — mis-restoring
282 // expand/collapse state across parts after process death. (index disambiguates blank ids.)
283 message.parts.forEachIndexed { index, part ->
284 key(part.id, index) {
285 PartView(part, imageContext = imageContext, onOpenFile = onOpenFile)
288 MessageTimestampText(message.info)
292@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
294private fun UserBubble(
295 message: MessageWithParts,
296 imageContext: ImageLoadContext?,
298 onOpenFile: ((String) -> Unit)? = null,
299 onRevert: (() -> Unit)? = null,
300 onEdit: ((String) -> Unit)? = null,
301 onQuote: ((String) -> Unit)? = null,
302 onBranch: ((String) -> Unit)? = null,
303 sendStatus: MessageSendStatus? = null,
304 isEdited: Boolean = false,
305 onRetry: (() -> Unit)? = null,
306 onDismiss: (() -> Unit)? = null,
307 onShare: (() -> Unit)? = null,
309 val context = LocalContext.current
310 val haptics = LocalHapticFeedback.current
311 val density = androidx.compose.ui.platform.LocalDensity.current
312 val copyLabel = stringResource(R.string.copy)
313 val editLabel = stringResource(R.string.edit_message)
314 // Long-press context menu state: consolidates copy/quote/branch/revert into a standard
315 // Android long-press menu so the actions are discoverable without spotting the 18dp icons.
316 // The menu anchors at the actual touch point rather than a fixed corner.
317 var longPressMenu by remember { mutableStateOf(false) }
318 var longPressOffset by remember { mutableStateOf(androidx.compose.ui.unit.DpOffset(0.dp, 0.dp)) }
319 // Collect text from all TextParts for copying, so a user can reuse/repost their
320 // own prompt. Memoized so a scroll-induced recomposition doesn't re-scan the list.
321 val textToCopy = remember(message.parts) {
323 .filterIsInstance<TextPart>()
324 .joinToString("\n\n") { it.text }
325 .takeIf { it.isNotBlank() }
327 val onCopy: (() -> Unit)? = textToCopy?.let { text ->
329 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
330 copyToClipboard(context, context.getString(R.string.clip_label_message), text)
333 // Wrap-content bubble capped at the width fraction (end-aligned), so a one-word prompt is
334 // snug instead of a wide empty box while long prompts still cap at the readable fraction.
335 BoxWithConstraints(modifier = modifier.fillMaxWidth()) {
338 .align(Alignment.TopEnd)
339 .widthIn(max = maxWidth * NetworkConfig.userBubbleWidthFraction)
340 .clip(MaterialTheme.shapes.medium)
341 .background(MaterialTheme.colorScheme.primaryContainer)
343 verticalArrangement = Arrangement.spacedBy(6.dp),
345 // key() per part so each PartView gets its own saveable registry slot (see AssistantBlock).
346 message.parts.forEachIndexed { index, part ->
347 key(part.id, index) { PartView(part, imageContext = imageContext, onOpenFile = onOpenFile) }
349 // Footer row with timestamp + inline actions. Long-press opens a context menu
350 // consolidating copy/quote/branch/revert — the conventional Android pattern — so
351 // the actions are discoverable without spotting the 18dp inline icons. Uses
352 // pointerInput/detectTapGestures (not combinedClickable) so no misleading "Button"
353 // semantics are announced to TalkBack for an area whose tap does nothing, and the
354 // menu anchors at the actual long-press position instead of a fixed corner.
359 .pointerInput(Unit) {
360 detectTapGestures(onLongPress = { offset ->
361 haptics.performHapticFeedback(HapticFeedbackType.LongPress)
362 longPressOffset = androidx.compose.ui.unit.DpOffset(
363 with(density) { offset.x.toDp() },
364 with(density) { offset.y.toDp() },
369 horizontalArrangement = Arrangement.SpaceBetween,
370 verticalAlignment = Alignment.CenterVertically,
372 Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
374 MessageSendStatus.SENDING -> {
375 CircularProgressIndicator(
376 Modifier.size(12.dp),
377 strokeWidth = 1.5.dp,
378 color = MaterialTheme.colorScheme.onSurfaceVariant,
381 stringResource(R.string.message_sending),
382 style = MaterialTheme.typography.labelSmall,
383 color = MaterialTheme.colorScheme.onSurfaceVariant,
386 MessageSendStatus.FAILED -> {
387 // Tap-to-retry: the whole failed label is tappable to re-send (far more
388 // discoverable than relying on the transient snackbar). A dismiss (×) button
389 // removes the abandoned message entirely, since its text will never match a
390 // real server message and would otherwise linger forever.
391 val retryModifier = if (onRetry != null) {
393 interactionSource = remember { MutableInteractionSource() },
394 indication = ripple(),
397 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
400 ).padding(vertical = 2.dp)
402 Modifier.padding(vertical = 2.dp)
405 verticalAlignment = Alignment.CenterVertically,
406 modifier = retryModifier.semantics(mergeDescendants = true) {
407 contentDescription = context.getString(R.string.message_send_failed) +
408 ". " + context.getString(R.string.tap_to_retry)
413 contentDescription = null,
414 modifier = Modifier.size(12.dp),
415 tint = MaterialTheme.colorScheme.error,
418 stringResource(R.string.message_send_failed),
419 style = MaterialTheme.typography.labelSmall,
420 color = MaterialTheme.colorScheme.error,
421 modifier = Modifier.padding(start = 4.dp),
424 if (onDismiss != null) {
425 // No size override on the IconButton: it keeps the default 48dp
426 // touch target (only the inner Icon is shrunk to 14dp). An earlier
427 // `.size(20.dp)` here clamped the target to 20dp — well under the
428 // M3 minimum — which made the discard action hard to hit. Matches
429 // the pattern used by every other inline IconButton in this file.
432 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
438 contentDescription = stringResource(R.string.outbox_discard),
439 modifier = Modifier.size(14.dp),
440 tint = MaterialTheme.colorScheme.onSurfaceVariant,
447 if (isEdited && sendStatus == null) {
449 stringResource(R.string.message_edited),
450 style = MaterialTheme.typography.labelSmall,
451 color = MaterialTheme.colorScheme.onSurfaceVariant,
454 MessageTimestampText(message.info)
456 Row(verticalAlignment = Alignment.CenterVertically) {
457 // Edit affordance: reload this prompt into the composer, reverting the
458 // conversation to before it so a Send replaces it. Only when there's text
459 // to edit (an image-only prompt has nothing to reload).
460 if (onEdit != null && textToCopy != null) {
461 val dropsAttachmentsMsg = stringResource(R.string.edit_drops_attachments)
462 val hasAttachments = message.parts.any { it is FilePart }
463 IconButton(onClick = {
465 // Editing reloads only the text; if this prompt carried images or
466 // files, tell the user they won't be reattached rather than
467 // silently dropping them.
468 if (hasAttachments) showToast(context, dropsAttachmentsMsg)
472 contentDescription = editLabel,
473 modifier = Modifier.size(18.dp),
474 tint = MaterialTheme.colorScheme.onSurfaceVariant,
478 onRevert?.let { RevertButton(it) }
479 // Copy affordance for user prompts, mirroring the assistant block.
480 // Without it, reusing a prior prompt requires discovering the
481 // long-press on the markdown text (and only TextParts support that).
482 if (textToCopy != null) {
485 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
486 copyToClipboard(context, context.getString(R.string.clip_label_message), textToCopy)
490 Icons.Filled.ContentCopy,
491 contentDescription = copyLabel,
492 modifier = Modifier.size(18.dp),
493 tint = MaterialTheme.colorScheme.onSurfaceVariant,
497 // Quote-reply / branch-a-new-session, tucked behind an overflow so the
498 // inline row stays compact. Only when there's prompt text to act on.
499 if (textToCopy != null) {
500 MessageOverflow(text = textToCopy, onQuote = onQuote, onBranch = onBranch)
504 MessageLongPressMenu(
505 expanded = longPressMenu,
506 onDismiss = { longPressMenu = false },
507 offset = longPressOffset,
520@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
522private fun AssistantBlock(
523 message: MessageWithParts,
525 imageContext: ImageLoadContext?,
527 modelLabel: String? = null,
528 onOpenFile: ((String) -> Unit)? = null,
529 onRevert: (() -> Unit)? = null,
530 onSpeak: ((String) -> Unit)? = null,
531 isSpeaking: Boolean = false,
532 ttsState: TtsState = TtsState.IDLE,
533 onPause: (() -> Unit)? = null,
534 onResume: (() -> Unit)? = null,
535 onQuote: ((String) -> Unit)? = null,
536 onBranch: ((String) -> Unit)? = null,
537 onRegenerate: (() -> Unit)? = null,
538 isEdited: Boolean = false,
539 onShare: (() -> Unit)? = null,
542 modifier = modifier.fillMaxWidth(),
543 verticalArrangement = Arrangement.spacedBy(8.dp),
545 val info = message.info
546 if (info is AssistantMessage) {
547 // Avatar + model label row: the robot icon gives the assistant a consistent visual
548 // identity (left-aligned, mirroring the user's right-aligned bubble) so on a long
549 // scroll the speaker is unambiguous without reading the label.
550 AssistantHeader(label = modelLabel ?: info.modelID)
552 message.parts.forEachIndexed { index, part ->
553 // Only the final part of a running message is actively streaming; earlier parts
554 // (a finished reasoning block, a completed tool call) are already done, so passing
555 // isRunning to all of them would keep a completed reasoning block showing the
556 // "Thinking…" spinner until the whole message finishes.
557 val partStreaming = isRunning && index == message.parts.lastIndex
558 // key() per part so each PartView gets a distinct saveable registry slot: without it,
559 // rememberSaveable expand/collapse state (keyed only positionally) is shared across
560 // sibling parts and mis-restored after process death. (index disambiguates blank ids.)
561 key(part.id, index) {
562 PartView(part, isRunning = partStreaming, modifier = Modifier.fillMaxWidth(), imageContext = imageContext, onOpenFile = onOpenFile)
565 // Agent-reported error: the server sets AssistantMessage.error when the run finished
566 // with a model/agent error (distinct from a transport failure, which surfaces as a
567 // snackbar). Render it as an error-tinted note above the footer so a reader can tell
568 // the reply is incomplete/failed by glancing at the bubble, not just from the absence
569 // of content. Folded into the same `info is AssistantMessage` branch as the cost
570 // summary below to avoid an extra conditional raising this function's complexity.
571 if (info is AssistantMessage) {
572 info.error?.let { err -> AgentErrorNote(err) }
574 val tokens = info.tokens
575 val tokenFormat = stringResource(R.string.tokens_in_out)
576 val reasoningFormat = stringResource(R.string.tokens_reasoning_format)
577 val cacheReadFormat = stringResource(R.string.tokens_cache_read_format)
578 val cacheWriteFormat = stringResource(R.string.tokens_cache_write_format)
579 val costShort = stringResource(R.string.cost_format_short)
580 val costLong = stringResource(R.string.cost_format_long)
581 // Memoize the formatted cost/tokens line so a scroll-induced or unrelated
582 // state-flip recomposition doesn't re-run NumberFormat + buildList +
583 // joinToString for every visible assistant bubble. The assembly logic lives in
584 // [buildCostSummary] (extracted to keep this function's complexity in check).
586 // Reasoning and cache figures are only added when non-zero, so a model that
587 // doesn't report them (or a request that didn't use them) shows the same compact
588 // "1.2k in · 540 out • $0.012" line as before; a reasoning-model reply gains a
589 // "3.1k reasoning" and/or "8.0k cache read" segment. The single-line cap and
590 // ellipsis below keep the line tidy at narrow widths even with all four segments.
591 val costSummary = remember(info.isComplete, tokens, cost, tokenFormat, reasoningFormat, cacheReadFormat, cacheWriteFormat, costShort, costLong) {
592 buildCostSummary(info.isComplete, tokens, cost, tokenFormat, reasoningFormat, cacheReadFormat, cacheWriteFormat, costShort, costLong)
594 // Long-press context menu state (see UserBubble for rationale).
595 var longPressMenu by remember { mutableStateOf(false) }
596 var longPressOffset by remember { mutableStateOf(androidx.compose.ui.unit.DpOffset(0.dp, 0.dp)) }
597 // Collect text from all TextParts for copy/read-aloud. Memoized so a
598 // scroll-induced recomposition doesn't re-scan the parts list.
599 val textToCopy = remember(message.parts) {
601 .filterIsInstance<TextPart>()
602 .joinToString("\n\n") { it.text }
603 .takeIf { it.isNotBlank() }
605 val assistantHaptics = LocalHapticFeedback.current
606 val assistantContext = LocalContext.current
607 val assistantDensity = androidx.compose.ui.platform.LocalDensity.current
608 val onCopy: (() -> Unit)? = textToCopy?.let { text ->
610 assistantHaptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
611 copyToClipboard(assistantContext, assistantContext.getString(R.string.clip_label_message), text)
618 .pointerInput(Unit) {
619 detectTapGestures(onLongPress = { offset ->
620 assistantHaptics.performHapticFeedback(HapticFeedbackType.LongPress)
621 longPressOffset = androidx.compose.ui.unit.DpOffset(
622 with(assistantDensity) { offset.x.toDp() },
623 with(assistantDensity) { offset.y.toDp() },
628 horizontalArrangement = Arrangement.SpaceBetween,
629 verticalAlignment = Alignment.CenterVertically,
631 Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
635 style = MaterialTheme.typography.labelSmall,
636 color = MaterialTheme.colorScheme.onSurfaceVariant,
637 // Cap to a single line so a long "1.2k in · 540 out • $0.012"
638 // summary doesn't wrap the "•" awkwardly at narrow widths.
640 overflow = TextOverflow.Ellipsis,
645 stringResource(R.string.message_edited),
646 style = MaterialTheme.typography.labelSmall,
647 color = MaterialTheme.colorScheme.onSurfaceVariant,
650 MessageTimestampText(message.info)
653 textToCopy = textToCopy,
654 isSpeaking = isSpeaking,
662 onRegenerate = onRegenerate,
665 MessageLongPressMenu(
666 expanded = longPressMenu,
667 onDismiss = { longPressMenu = false },
668 offset = longPressOffset,
678 MessageTimestampText(message.info)
683/** Avatar + model label row for an assistant message. Extracted from [AssistantBlock] to keep
684 * that function's cyclomatic complexity under the detekt threshold. */
686private fun AssistantHeader(label: String?) {
687 Row(verticalAlignment = Alignment.CenterVertically) {
691 .clip(MaterialTheme.shapes.extraSmall)
692 .background(MaterialTheme.colorScheme.secondaryContainer),
693 contentAlignment = Alignment.Center,
696 Icons.Filled.SmartToy,
697 contentDescription = null,
698 modifier = Modifier.size(18.dp),
699 tint = MaterialTheme.colorScheme.onSecondaryContainer,
705 style = MaterialTheme.typography.labelSmall,
706 color = MaterialTheme.colorScheme.onSurfaceVariant,
708 overflow = TextOverflow.Ellipsis,
710 .padding(start = 6.dp)
711 .weight(1f, fill = false),
717/** Trailing action buttons for an assistant message: read-aloud (TTS), revert, copy.
718 * Extracted from [AssistantBlock] to keep that function under the complexity threshold. */
720private fun AssistantActions(
724 onSpeak: ((String) -> Unit)?,
725 onPause: (() -> Unit)?,
726 onResume: (() -> Unit)?,
727 onRevert: (() -> Unit)?,
728 onQuote: ((String) -> Unit)? = null,
729 onBranch: ((String) -> Unit)? = null,
730 onRegenerate: (() -> Unit)? = null,
732 val context = LocalContext.current
733 val haptics = LocalHapticFeedback.current
734 val copyLabel = stringResource(R.string.copy)
735 Row(verticalAlignment = Alignment.CenterVertically) {
736 // Regenerate: re-run the preceding user prompt to get a fresh reply. Only meaningful
737 // for a completed, non-streaming message, so the caller gates it on !isRunning.
738 if (onRegenerate != null) {
739 val regenerateLabel = stringResource(R.string.regenerate)
740 IconButton(onClick = {
741 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
745 Icons.Filled.Refresh,
746 contentDescription = regenerateLabel,
747 modifier = Modifier.size(18.dp),
748 tint = MaterialTheme.colorScheme.onSurfaceVariant,
752 // Read-aloud control with pause/resume. Tapping cycles through play → pause → resume,
753 // so a user listening to a long reply can step away and continue from roughly where
754 // they stopped without re-playing the whole message. Extracted to [TtsButton] to keep
755 // this function's cyclomatic complexity under the detekt threshold.
756 if (onSpeak != null && textToCopy != null) {
759 isSpeaking = isSpeaking,
766 onRevert?.let { RevertButton(it) }
767 if (textToCopy != null) {
768 IconButton(onClick = {
769 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
770 copyToClipboard(context, context.getString(R.string.clip_label_message), textToCopy)
773 Icons.Filled.ContentCopy,
774 contentDescription = copyLabel,
775 modifier = Modifier.size(18.dp),
776 tint = MaterialTheme.colorScheme.onSurfaceVariant,
780 // Assistant replies can be quoted into the composer or branched into a new session.
781 if (textToCopy != null && (onQuote != null || onBranch != null)) {
782 MessageOverflow(text = textToCopy, onQuote = onQuote, onBranch = onBranch)
787/** Read-aloud button with play/pause/resume states. Extracted from [AssistantActions] to keep
788 * that function's cyclomatic complexity under the detekt threshold. The icon reflects the
789 * next action: VolumeUp (play) when idle, Pause when playing, PlayArrow (resume) when paused. */
791private fun TtsButton(
795 onSpeak: (String) -> Unit,
796 onPause: (() -> Unit)?,
797 onResume: (() -> Unit)?,
799 val haptics = LocalHapticFeedback.current
801 isSpeaking && ttsState == TtsState.PAUSED -> stringResource(R.string.resume_reading)
802 isSpeaking -> stringResource(R.string.pause_reading)
803 else -> stringResource(R.string.read_aloud)
805 IconButton(onClick = {
806 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
808 isSpeaking && ttsState == TtsState.PAUSED -> onResume?.invoke()
809 isSpeaking -> onPause?.invoke()
810 else -> onSpeak(text)
814 isSpeaking && ttsState == TtsState.PAUSED -> Icons.Filled.PlayArrow
815 isSpeaking -> Icons.Filled.Pause
816 else -> Icons.AutoMirrored.Filled.VolumeUp
818 val tint = if (isSpeaking) MaterialTheme.colorScheme.primary
819 else MaterialTheme.colorScheme.onSurfaceVariant
822 contentDescription = label,
823 modifier = Modifier.size(18.dp),
830private fun MessageTimestampText(info: soy.iko.opencode.data.model.MessageInfo) {
831 // Compact relative label by default; long-press reveals the full absolute timestamp
832 // (RelativeTimeText renders nothing when the time is absent, so no isNotEmpty guard).
833 RelativeTimeText(info.time?.created)
837 * Render an [AssistantMessage.error] as an error-tinted note on the bubble. The error is a
838 * tolerant-decoded [JsonElement]; we extract a readable string from it (a primitive's content
839 * for a bare string, a pretty-printed object otherwise, trimmed to a reasonable length so a
840 * verbose error object doesn't dominate the bubble).
843private fun AgentErrorNote(error: kotlinx.serialization.json.JsonElement) {
844 // Extract a readable string from the tolerant-decoded JsonElement: a bare string
845 // primitive returns its content (the common `{"error": "..."}` case); an object/array
846 // is pretty-printed and trimmed so a verbose error doesn't dominate the bubble.
847 val text = remember(error) {
848 val raw = if (error is kotlinx.serialization.json.JsonPrimitive) {
852 soy.iko.opencode.data.network.OpencodeJson.encodeToString(
853 kotlinx.serialization.json.JsonElement.serializer(),
856 }.getOrDefault(error.toString())
858 if (raw.length <= 500) raw else raw.take(500) + "…"
859 }.takeIf { it.isNotBlank() } ?: return
860 val label = stringResource(R.string.agent_error_label)
864 .clip(MaterialTheme.shapes.small)
865 .background(MaterialTheme.colorScheme.errorContainer)
867 verticalAlignment = Alignment.Top,
871 contentDescription = null,
872 tint = MaterialTheme.colorScheme.onErrorContainer,
873 modifier = Modifier.size(16.dp),
875 Column(modifier = Modifier.padding(start = 8.dp)) {
878 style = MaterialTheme.typography.labelMedium,
879 color = MaterialTheme.colorScheme.onErrorContainer,
883 style = MaterialTheme.typography.bodySmall,
884 color = MaterialTheme.colorScheme.onErrorContainer,
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list