feat: text selection, modern sidebar profile, markdown spacing

Text selection: SelectionContainer wraps both user and assistant
messages — long-press to select, copy, share.

Sidebar profile: avatar 30→38dp with radial gradient, rounded lg
shape, icon buttons with subtle backgrounds, bigger text (15sp W400).

Markdown: heading spacing 18→24/20dp, list items better aligned
(8dp gap, bullet muted), blockquote bar 4dp inset + 12dp gap.
This commit is contained in:
Bruno Deanoz 2026-06-23 13:45:23 +02:00
parent 95672d3124
commit 1c9491e3dc
3 changed files with 48 additions and 33 deletions

View file

@ -502,6 +502,7 @@ fun MessageRow(
.background(innerHighlight) .background(innerHighlight)
.border(1.2.dp, borderBrush, shape), .border(1.2.dp, borderBrush, shape),
) { ) {
androidx.compose.foundation.text.selection.SelectionContainer {
Column { Column {
if (isVoiceOnly) { if (isVoiceOnly) {
Row( Row(
@ -529,6 +530,7 @@ fun MessageRow(
} }
} }
} }
}
if (!message.streaming) { if (!message.streaming) {
Row( Row(
Modifier.padding(top = 6.dp, end = 4.dp), Modifier.padding(top = 6.dp, end = 4.dp),
@ -567,6 +569,7 @@ fun MessageRow(
KaizenOrb(28.dp, streaming = message.streaming) KaizenOrb(28.dp, streaming = message.streaming)
Spacer(Modifier.width(10.dp)) Spacer(Modifier.width(10.dp))
Box(Modifier.weight(1f).padding(top = 3.dp)) { Box(Modifier.weight(1f).padding(top = 3.dp)) {
androidx.compose.foundation.text.selection.SelectionContainer {
Column { Column {
if (message.reasoning.isNotBlank()) { if (message.reasoning.isNotBlank()) {
ReasoningBlock(message.reasoning, streaming = message.streaming) ReasoningBlock(message.reasoning, streaming = message.streaming)
@ -589,6 +592,7 @@ fun MessageRow(
} }
} }
} }
}
if (!message.streaming && !message.thinking && message.content.isNotBlank()) { if (!message.streaming && !message.thinking && message.content.isNotBlank()) {
Row( Row(
Modifier.padding(start = 38.dp, top = 6.dp), Modifier.padding(start = 38.dp, top = 6.dp),

View file

@ -459,14 +459,14 @@ fun MarkdownText(text: String, streaming: Boolean = false, modifier: Modifier =
val isLast = idx == blocks.lastIndex val isLast = idx == blocks.lastIndex
when (block) { when (block) {
is MdBlock.Heading -> { is MdBlock.Heading -> {
if (idx > 0) Spacer(Modifier.height(18.dp)) if (idx > 0) Spacer(Modifier.height(if (block.level == 1) 24.dp else 20.dp))
val rendered = inlineMarkdown(block.text + if (isLast) cursor else "", cs.onBackground, inlineCodeColor, inlineCodeBg, linkColor) val rendered = inlineMarkdown(block.text + if (isLast) cursor else "", cs.onBackground, inlineCodeColor, inlineCodeBg, linkColor)
ClickableMarkdownText(rendered, context, cs.onBackground, ClickableMarkdownText(rendered, context, cs.onBackground,
fontSize = when (block.level) { 1 -> 26.sp; 2 -> 22.sp; else -> 18.sp }, fontSize = when (block.level) { 1 -> 26.sp; 2 -> 22.sp; else -> 18.sp },
fontWeight = when (block.level) { 1 -> FontWeight.W600; 2 -> FontWeight.W600; else -> FontWeight.W500 }, fontWeight = when (block.level) { 1 -> FontWeight.W600; 2 -> FontWeight.W600; else -> FontWeight.W500 },
lineHeight = when (block.level) { 1 -> 34.sp; 2 -> 30.sp; else -> 26.sp }, lineHeight = when (block.level) { 1 -> 34.sp; 2 -> 30.sp; else -> 26.sp },
) )
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(10.dp))
} }
is MdBlock.CodeFence -> { is MdBlock.CodeFence -> {
@ -573,21 +573,22 @@ fun MarkdownText(text: String, streaming: Boolean = false, modifier: Modifier =
} }
is MdBlock.ListBlock -> { is MdBlock.ListBlock -> {
if (idx > 0) Spacer(Modifier.height(8.dp)) if (idx > 0) Spacer(Modifier.height(10.dp))
block.items.forEachIndexed { itemIdx, item -> block.items.forEachIndexed { itemIdx, item ->
Row(Modifier.fillMaxWidth().padding(start = 6.dp, bottom = 6.dp)) { Row(Modifier.fillMaxWidth().padding(start = 4.dp, bottom = 8.dp)) {
if (item.checked != null) { if (item.checked != null) {
val checkColor = if (item.checked) linkColor else cs.onSurfaceVariant.copy(alpha = 0.4f) val checkColor = if (item.checked) linkColor else cs.onSurfaceVariant.copy(alpha = 0.4f)
val checkIcon = if (item.checked) KaizenIcons.Check else KaizenIcons.Square val checkIcon = if (item.checked) KaizenIcons.Check else KaizenIcons.Square
Icon(checkIcon, null, tint = checkColor, modifier = Modifier.size(18.dp).padding(top = 3.dp)) Icon(checkIcon, null, tint = checkColor, modifier = Modifier.size(18.dp).padding(top = 4.dp))
Spacer(Modifier.width(6.dp)) Spacer(Modifier.width(8.dp))
} else { } else {
val bullet = if (item.ordered) "${item.index}." else "" val bullet = if (item.ordered) "${item.index}." else ""
Text( Text(
bullet, bullet,
color = cs.onSurfaceVariant, color = cs.onSurfaceVariant.copy(alpha = 0.7f),
fontSize = 16.sp, fontSize = 16.sp,
modifier = Modifier.width(if (item.ordered) 24.dp else 16.dp), fontWeight = FontWeight.W300,
modifier = Modifier.width(if (item.ordered) 24.dp else 18.dp).padding(top = 1.dp),
) )
} }
val appendCursor = isLast && itemIdx == block.items.lastIndex val appendCursor = isLast && itemIdx == block.items.lastIndex
@ -598,7 +599,7 @@ fun MarkdownText(text: String, streaming: Boolean = false, modifier: Modifier =
) )
} }
} }
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(10.dp))
} }
is MdBlock.HorizontalRule -> { is MdBlock.HorizontalRule -> {
@ -613,21 +614,21 @@ fun MarkdownText(text: String, streaming: Boolean = false, modifier: Modifier =
} }
is MdBlock.Blockquote -> { is MdBlock.Blockquote -> {
if (idx > 0) Spacer(Modifier.height(12.dp)) if (idx > 0) Spacer(Modifier.height(14.dp))
Row( Row(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.height(IntrinsicSize.Min) .height(IntrinsicSize.Min)
.padding(start = 2.dp) .padding(start = 4.dp)
) { ) {
Box( Box(
Modifier Modifier
.width(3.dp) .width(3.dp)
.fillMaxHeight() .fillMaxHeight()
.clip(RoundedCornerShape(1.5.dp)) .clip(RoundedCornerShape(1.5.dp))
.background(cs.primary.copy(alpha = 0.5f)) .background(cs.primary.copy(alpha = 0.45f))
) )
Spacer(Modifier.width(10.dp)) Spacer(Modifier.width(12.dp))
val rendered = inlineMarkdown(block.text + if (isLast) cursor else "", cs.onSurfaceVariant, inlineCodeColor, inlineCodeBg, linkColor) val rendered = inlineMarkdown(block.text + if (isLast) cursor else "", cs.onSurfaceVariant, inlineCodeColor, inlineCodeBg, linkColor)
ClickableMarkdownText(rendered, context, cs.onSurfaceVariant, ClickableMarkdownText(rendered, context, cs.onSurfaceVariant,
fontSize = 15.sp, lineHeight = 24.sp, fontSize = 15.sp, lineHeight = 24.sp,

View file

@ -286,58 +286,68 @@ fun KaizenSidebar(
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(KaizenShapes.md) .clip(KaizenShapes.lg)
.kaizenShadow(stack = KaizenShadows.level1, cornerRadius = 12.dp) .kaizenShadow(stack = KaizenShadows.level1, cornerRadius = 20.dp)
.background(if (isDark) Color(0x14FFFFFF) else Color(0x0A000000)) .background(if (isDark) Color(0x14FFFFFF) else Color(0x0A000000))
.border(0.8.dp, cardBorder, KaizenShapes.md) .border(0.8.dp, cardBorder, KaizenShapes.lg)
.innerTopHighlight() .innerTopHighlight()
.clickable { onOpenSettings() } .clickable { onOpenSettings() }
.padding(horizontal = 12.dp, vertical = 10.dp), .padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
.size(30.dp) .size(38.dp)
.clip(KaizenShapes.circle) .clip(KaizenShapes.circle)
.background( .background(
Brush.verticalGradient( Brush.radialGradient(
listOf(accent.primary.copy(alpha = 0.8f), accent.primary.copy(alpha = 0.6f)) listOf(accent.primary.copy(alpha = 0.9f), accent.primaryDeep.copy(alpha = 0.75f))
) )
), ),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text( Text(
userName.firstOrNull()?.toString()?.uppercase() ?: "A", userName.firstOrNull()?.toString()?.uppercase() ?: "A",
color = Color.White, fontSize = 13.sp, fontWeight = FontWeight.Bold color = Color.White, fontSize = 15.sp, fontWeight = FontWeight.W600
) )
} }
Spacer(Modifier.width(10.dp)) Spacer(Modifier.width(12.dp))
Text( Text(
userName, userName,
color = cs.onBackground, color = cs.onBackground,
fontSize = 14.sp, fontSize = 15.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.W400,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
Box(
modifier = Modifier
.size(32.dp)
.clip(KaizenShapes.circle)
.background(if (isDark) Color.White.copy(alpha = 0.06f) else Color.Black.copy(alpha = 0.04f))
.clickable { onOpenSettings() },
contentAlignment = Alignment.Center,
) {
Icon( Icon(
KaizenIcons.Settings, KaizenIcons.Settings,
stringResource(R.string.sidebar_settings), stringResource(R.string.sidebar_settings),
tint = cs.onSurfaceVariant.copy(alpha = 0.45f), tint = cs.onSurfaceVariant.copy(alpha = 0.55f),
modifier = Modifier.size(17.dp), modifier = Modifier.size(16.dp),
) )
}
Spacer(Modifier.width(6.dp)) Spacer(Modifier.width(6.dp))
Box( Box(
modifier = Modifier modifier = Modifier
.size(28.dp) .size(32.dp)
.clip(KaizenShapes.circle) .clip(KaizenShapes.circle)
.background(if (isDark) Color.White.copy(alpha = 0.06f) else Color.Black.copy(alpha = 0.04f))
.clickable { onLogout() }, .clickable { onLogout() },
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Icon( Icon(
KaizenIcons.LogOut, KaizenIcons.LogOut,
stringResource(R.string.sidebar_logout), stringResource(R.string.sidebar_logout),
tint = cs.onSurfaceVariant.copy(alpha = 0.45f), tint = cs.onSurfaceVariant.copy(alpha = 0.55f),
modifier = Modifier.size(15.dp), modifier = Modifier.size(16.dp),
) )
} }
} }