package com.example.aiapp import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CornerSize import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp /** * A message another agent sent this session, closed until somebody asks. * * Closed by default, like a tool call and for the same reason: these are long, there can be several * in a row, and what a reader scanning the transcript needs from one is that it happened and who * sent it. The first line comes with the heading because a name alone does not say which message * this was. * * Drawn as its own kind rather than as the reader's own bubble. They did not say this, and a * transcript that puts it in their voice is making a claim about who asked for the work that * follows. * * Opened, the card is drawn in *pieces* -- this heading and one [PeerBlockRow] per markdown block, * each its own item of the transcript list. See [TranscriptUnit.PeerHead] for what that bought; * what matters here is that the pieces have to add up to the card that was there before, so the * fill, the corner radius and the padding all live in [peerSurface]. */ @Composable fun PeerHeadRow( item: TranscriptItem.PeerNote, open: Boolean, onToggle: () -> Unit, modifier: Modifier = Modifier, ) { Column( modifier.cardPiece( top = true, bottom = !open, fill = CardDefaults.cardColors().containerColor, onPress = onToggle, ) ) { Row(verticalAlignment = Alignment.CenterVertically) { Text("Message from ${item.from}", style = MaterialTheme.typography.titleSmall) if (!open) { Spacer(Modifier.width(8.dp)) Text( item.text.lineSequence().firstOrNull { it.isNotBlank() }.orEmpty(), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, // The head, not the tail: a message is identified by how it opens. overflow = TextOverflow.Ellipsis, ) } } } } /** * One block of an opened peer message, on the same card the heading started. * * Clickable like the heading, so the card still shuts wherever it is pressed -- it was one control * before it was several items, and which piece the finger lands on is not something the reader * chose. */ @Composable fun PeerBlockRow(unit: TranscriptUnit.PeerBlock, replies: ParsedReplies, onToggle: () -> Unit) { Column( Modifier.cardPiece( top = false, bottom = unit.last, fill = CardDefaults.cardColors().containerColor, onPress = onToggle, ) ) { // The words shut the card too, and have to do it themselves -- see [LocalMarkdownTap]. // Without this the card closes everywhere except on the text, which is most of it. CompositionLocalProvider(LocalMarkdownTap provides rememberMarkdownTap(onToggle)) { // The gap the card's own column used to provide between its heading and its prose, and // between one block and the next -- inside the piece, so the card's fill runs through // it. MarkdownPiece(unit.text, unit.piece, replies, Modifier.padding(top = unit.spacing)) } } } /** * One piece of a card drawn in slices: the fill, the corners it owns, and the room inside it. * * A filled Material card is elevation zero, so there is no shadow that a seam would show through -- * which is the whole reason a card can be cut up at all. Each piece paints the caller's container * colour and rounds only the corners at the ends of the message, so the pieces abut into one * continuous card. Shared by the two rows cut this way -- an opened peer message and a long user * message -- because two copies of the corner logic is how one of them grows a seam. * * The padding is the other half: 12dp all round was the card's own, so the top piece keeps the top * of it, the bottom piece the bottom, and the middle pieces neither. */ @Composable fun Modifier.cardPiece( top: Boolean, bottom: Boolean, fill: Color, onPress: (() -> Unit)? = null, ): Modifier { val square = CornerSize(0.dp) val shape = MaterialTheme.shapes.medium.copy( topStart = if (top) MaterialTheme.shapes.medium.topStart else square, topEnd = if (top) MaterialTheme.shapes.medium.topEnd else square, bottomStart = if (bottom) MaterialTheme.shapes.medium.bottomStart else square, bottomEnd = if (bottom) MaterialTheme.shapes.medium.bottomEnd else square, ) return fillMaxWidth() .clip(shape) .background(fill) .then(if (onPress == null) Modifier else Modifier.clickable(onClick = onPress)) .padding( start = CARD_PADDING, end = CARD_PADDING, top = if (top) CARD_PADDING else 0.dp, bottom = if (bottom) CARD_PADDING else 0.dp, ) } /** The room inside a sliced card, which was `Card { Column(padding(12.dp)) }`. */ private val CARD_PADDING = 12.dp