scroll buttons, token counter, 3D user bubble
Scroll: glass arrow buttons at center-right edge, appear when
scrolled away from top/bottom. Animate scroll on tap.
Token counter: parse usage sentinel (input+output tokens) from
stream, show "⚡ Xk / 1M" badge next to model pill in top bar.
StreamState extended with inputTokens/outputTokens.
User bubble: multi-layer 3D effect — higher opacity gradient bg,
inner top highlight (accent glow), kaizenShadow level1 for depth,
thicker border gradient. Increased padding and line-height (26sp)
for better readability.
This commit is contained in:
parent
f6dc5db363
commit
3ca1d2c18e
3 changed files with 142 additions and 4 deletions
|
|
@ -68,6 +68,7 @@ import dev.kaizen.app.net.Attachment
|
|||
import dev.kaizen.app.ui.effect.GlassSurface
|
||||
import dev.kaizen.app.ui.effect.GlassTiers
|
||||
import dev.kaizen.app.ui.effect.KaizenShadows
|
||||
import dev.kaizen.app.ui.effect.kaizenShadow
|
||||
import dev.kaizen.app.ui.motion.Durations
|
||||
import dev.kaizen.app.ui.shape.KaizenShapes
|
||||
import dev.kaizen.app.ui.theme.LocalKaizenAccent
|
||||
|
|
@ -87,6 +88,7 @@ import androidx.compose.material.icons.rounded.PictureAsPdf
|
|||
import androidx.compose.material.icons.rounded.VideoFile
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.material.icons.rounded.Bolt
|
||||
import androidx.compose.material.icons.rounded.ExpandMore
|
||||
import androidx.compose.material.icons.rounded.Psychology
|
||||
import androidx.compose.material.icons.rounded.Tune
|
||||
|
|
@ -298,6 +300,41 @@ private fun SamplingRow(label: String, param: SamplingParam, min: Float, max: Fl
|
|||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TokenBadge(used: Int, contextLength: Int, modifier: Modifier = Modifier) {
|
||||
val cs = MaterialTheme.colorScheme
|
||||
val isDark = isSystemInDarkTheme()
|
||||
val accent = LocalKaizenAccent.current
|
||||
|
||||
fun formatK(n: Int): String = when {
|
||||
n >= 1_000_000 -> "${n / 1_000_000}M"
|
||||
n >= 1_000 -> "${n / 1_000}k"
|
||||
else -> "$n"
|
||||
}
|
||||
|
||||
GlassSurface(
|
||||
modifier = modifier,
|
||||
shape = KaizenShapes.pill,
|
||||
tintAlpha = GlassTiers.pill(isDark),
|
||||
shadowStack = KaizenShadows.level1,
|
||||
cornerRadius = 16.dp,
|
||||
) {
|
||||
Row(
|
||||
Modifier.padding(horizontal = 10.dp, vertical = 7.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(Icons.Rounded.Bolt, null, tint = accent.primary, modifier = Modifier.size(14.dp))
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(
|
||||
"${formatK(used)} / ${formatK(contextLength)}",
|
||||
color = cs.onSurfaceVariant,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MessageRow(message: Message) {
|
||||
val cs = MaterialTheme.colorScheme
|
||||
|
|
@ -308,22 +345,46 @@ fun MessageRow(message: Message) {
|
|||
if (message.role == Role.User) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
|
||||
val shape = KaizenShapes.lg
|
||||
val borderBrush = remember(accent) { Brush.verticalGradient(listOf(accent.primary.copy(alpha = 0.45f), accent.primaryDeep.copy(alpha = 0.15f))) }
|
||||
val bgBrush = remember(accent) { Brush.verticalGradient(listOf(accent.primary.copy(alpha = 0.18f), accent.primaryDeep.copy(alpha = 0.08f))) }
|
||||
val bgBrush = remember(accent, isDark) {
|
||||
Brush.verticalGradient(
|
||||
listOf(
|
||||
if (isDark) accent.primary.copy(alpha = 0.28f) else accent.primary.copy(alpha = 0.16f),
|
||||
if (isDark) accent.primaryDeep.copy(alpha = 0.22f) else accent.primaryDeep.copy(alpha = 0.10f),
|
||||
)
|
||||
)
|
||||
}
|
||||
val borderBrush = remember(accent, isDark) {
|
||||
Brush.verticalGradient(
|
||||
listOf(
|
||||
accent.primary.copy(alpha = if (isDark) 0.55f else 0.40f),
|
||||
accent.primaryDeep.copy(alpha = if (isDark) 0.15f else 0.10f),
|
||||
)
|
||||
)
|
||||
}
|
||||
val innerHighlight = remember(accent, isDark) {
|
||||
Brush.verticalGradient(
|
||||
listOf(
|
||||
accent.primary.copy(alpha = if (isDark) 0.12f else 0.08f),
|
||||
Color.Transparent,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.widthIn(max = 300.dp)
|
||||
.kaizenShadow(stack = KaizenShadows.level1, cornerRadius = 20.dp)
|
||||
.clip(shape)
|
||||
.background(bgBrush)
|
||||
.background(innerHighlight)
|
||||
.border(1.2.dp, borderBrush, shape),
|
||||
) {
|
||||
Column {
|
||||
if (hasAttachments) AttachmentGrid(message.attachments, Modifier.padding(6.dp))
|
||||
if (message.content.isNotBlank()) {
|
||||
Text(
|
||||
message.content, color = cs.onBackground, fontSize = 16.sp, lineHeight = 22.sp,
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = if (hasAttachments) 4.dp else 11.dp, bottom = 11.dp),
|
||||
message.content, color = cs.onBackground, fontSize = 16.sp, lineHeight = 26.sp,
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = if (hasAttachments) 4.dp else 13.dp, bottom = 13.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ import androidx.compose.foundation.lazy.LazyColumn
|
|||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.KeyboardArrowDown
|
||||
import androidx.compose.material.icons.rounded.KeyboardArrowUp
|
||||
import androidx.compose.material.icons.rounded.Menu
|
||||
import androidx.compose.material3.DrawerValue
|
||||
import androidx.compose.material3.Icon
|
||||
|
|
@ -100,6 +102,7 @@ fun ChatScreen(
|
|||
var activeMode by remember { mutableStateOf<Int?>(null) }
|
||||
var reasoningPreset by remember { mutableStateOf(ReasoningPreset.Standard) }
|
||||
var samplingPrefs by remember { mutableStateOf(SamplingPrefs()) }
|
||||
var usedTokens by remember { mutableStateOf(0) }
|
||||
|
||||
// Navigation State
|
||||
var currentScreen by remember { mutableStateOf(AppScreen.Chat) }
|
||||
|
|
@ -362,6 +365,9 @@ fun ChatScreen(
|
|||
query = state.query,
|
||||
sources = state.sources,
|
||||
)
|
||||
if (state.inputTokens > 0 || state.outputTokens > 0) {
|
||||
usedTokens = state.inputTokens + state.outputTokens
|
||||
}
|
||||
}
|
||||
val idx = messages.indexOfLast { it.id == assistantId }
|
||||
val finalMsg = if (idx >= 0) messages[idx] else null
|
||||
|
|
@ -538,6 +544,53 @@ fun ChatScreen(
|
|||
) {
|
||||
items(messages, key = { it.id }) { message -> MessageRow(message) }
|
||||
}
|
||||
|
||||
// Scroll buttons
|
||||
val showScrollUp by remember { derivedStateOf { listState.firstVisibleItemIndex > 2 } }
|
||||
val showScrollDown by remember { derivedStateOf {
|
||||
val info = listState.layoutInfo
|
||||
info.visibleItemsInfo.lastOrNull()?.index != info.totalItemsCount - 1
|
||||
}}
|
||||
|
||||
if (showScrollUp || showScrollDown) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterEnd)
|
||||
.padding(end = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
if (showScrollUp) {
|
||||
GlassSurface(
|
||||
shape = KaizenShapes.circle,
|
||||
tintAlpha = GlassTiers.pill(isSystemInDarkTheme()),
|
||||
shadowStack = KaizenShadows.level1,
|
||||
cornerRadius = 18.dp,
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.clickable { scope.launch { listState.animateScrollToItem(0) } },
|
||||
) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Icon(Icons.Rounded.KeyboardArrowUp, null, tint = MaterialTheme.colorScheme.onBackground, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
if (showScrollDown) {
|
||||
GlassSurface(
|
||||
shape = KaizenShapes.circle,
|
||||
tintAlpha = GlassTiers.pill(isSystemInDarkTheme()),
|
||||
shadowStack = KaizenShadows.level1,
|
||||
cornerRadius = 18.dp,
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.clickable { scope.launch { listState.animateScrollToItem(messages.size - 1) } },
|
||||
) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Icon(Icons.Rounded.KeyboardArrowDown, null, tint = MaterialTheme.colorScheme.onBackground, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Floating Top Menu Button
|
||||
|
|
@ -585,6 +638,10 @@ fun ChatScreen(
|
|||
onClick = { haptics.tick(); showModelSheet = true },
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
if (usedTokens > 0 || messages.isNotEmpty()) {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
TokenBadge(used = usedTokens, contextLength = 1_000_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -28,12 +28,20 @@ data class SearchSource(
|
|||
val snippet: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class UsageJson(
|
||||
val i: Int = 0,
|
||||
val o: Int = 0,
|
||||
)
|
||||
|
||||
data class StreamState(
|
||||
val content: String = "",
|
||||
val reasoning: String = "",
|
||||
val tools: List<ToolEvent> = emptyList(),
|
||||
val query: String = "",
|
||||
val sources: List<SearchSource> = emptyList(),
|
||||
val inputTokens: Int = 0,
|
||||
val outputTokens: Int = 0,
|
||||
)
|
||||
|
||||
object StreamConsumer {
|
||||
|
|
@ -101,12 +109,15 @@ object StreamConsumer {
|
|||
private fun buildState(): StreamState {
|
||||
val queryStr = parseTrailingField(QUERY)
|
||||
val sourcesList = parseTrailingSources()
|
||||
val usage = parseTrailingUsage()
|
||||
return StreamState(
|
||||
content = content.toString(),
|
||||
reasoning = reasoningBuf.toString(),
|
||||
tools = tools.toList(),
|
||||
query = queryStr,
|
||||
sources = sourcesList,
|
||||
inputTokens = usage?.i ?: 0,
|
||||
outputTokens = usage?.o ?: 0,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -135,6 +146,15 @@ object StreamConsumer {
|
|||
}
|
||||
}
|
||||
|
||||
private fun parseTrailingUsage(): UsageJson? {
|
||||
val full = trailingBuf.toString()
|
||||
val idx = full.indexOf(USAGE)
|
||||
if (idx == -1) return null
|
||||
val json = full.substring(idx + 1).trim()
|
||||
if (json.isEmpty()) return null
|
||||
return try { lenientJson.decodeFromString<UsageJson>(json) } catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
private fun parseToolEvents(buf: CharSequence, from: Int, to: Int) {
|
||||
val s = buf.toString()
|
||||
var pos = from
|
||||
|
|
|
|||
Loading…
Reference in a new issue