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.GlassSurface
|
||||||
import dev.kaizen.app.ui.effect.GlassTiers
|
import dev.kaizen.app.ui.effect.GlassTiers
|
||||||
import dev.kaizen.app.ui.effect.KaizenShadows
|
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.motion.Durations
|
||||||
import dev.kaizen.app.ui.shape.KaizenShapes
|
import dev.kaizen.app.ui.shape.KaizenShapes
|
||||||
import dev.kaizen.app.ui.theme.LocalKaizenAccent
|
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.material.icons.rounded.VideoFile
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.setValue
|
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.ExpandMore
|
||||||
import androidx.compose.material.icons.rounded.Psychology
|
import androidx.compose.material.icons.rounded.Psychology
|
||||||
import androidx.compose.material.icons.rounded.Tune
|
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
|
@Composable
|
||||||
fun MessageRow(message: Message) {
|
fun MessageRow(message: Message) {
|
||||||
val cs = MaterialTheme.colorScheme
|
val cs = MaterialTheme.colorScheme
|
||||||
|
|
@ -308,22 +345,46 @@ fun MessageRow(message: Message) {
|
||||||
if (message.role == Role.User) {
|
if (message.role == Role.User) {
|
||||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
|
||||||
val shape = KaizenShapes.lg
|
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, isDark) {
|
||||||
val bgBrush = remember(accent) { Brush.verticalGradient(listOf(accent.primary.copy(alpha = 0.18f), accent.primaryDeep.copy(alpha = 0.08f))) }
|
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(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.widthIn(max = 300.dp)
|
.widthIn(max = 300.dp)
|
||||||
|
.kaizenShadow(stack = KaizenShadows.level1, cornerRadius = 20.dp)
|
||||||
.clip(shape)
|
.clip(shape)
|
||||||
.background(bgBrush)
|
.background(bgBrush)
|
||||||
|
.background(innerHighlight)
|
||||||
.border(1.2.dp, borderBrush, shape),
|
.border(1.2.dp, borderBrush, shape),
|
||||||
) {
|
) {
|
||||||
Column {
|
Column {
|
||||||
if (hasAttachments) AttachmentGrid(message.attachments, Modifier.padding(6.dp))
|
if (hasAttachments) AttachmentGrid(message.attachments, Modifier.padding(6.dp))
|
||||||
if (message.content.isNotBlank()) {
|
if (message.content.isNotBlank()) {
|
||||||
Text(
|
Text(
|
||||||
message.content, color = cs.onBackground, fontSize = 16.sp, lineHeight = 22.sp,
|
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 11.dp, bottom = 11.dp),
|
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.items
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.material.icons.Icons
|
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.material.icons.rounded.Menu
|
||||||
import androidx.compose.material3.DrawerValue
|
import androidx.compose.material3.DrawerValue
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
|
@ -100,6 +102,7 @@ fun ChatScreen(
|
||||||
var activeMode by remember { mutableStateOf<Int?>(null) }
|
var activeMode by remember { mutableStateOf<Int?>(null) }
|
||||||
var reasoningPreset by remember { mutableStateOf(ReasoningPreset.Standard) }
|
var reasoningPreset by remember { mutableStateOf(ReasoningPreset.Standard) }
|
||||||
var samplingPrefs by remember { mutableStateOf(SamplingPrefs()) }
|
var samplingPrefs by remember { mutableStateOf(SamplingPrefs()) }
|
||||||
|
var usedTokens by remember { mutableStateOf(0) }
|
||||||
|
|
||||||
// Navigation State
|
// Navigation State
|
||||||
var currentScreen by remember { mutableStateOf(AppScreen.Chat) }
|
var currentScreen by remember { mutableStateOf(AppScreen.Chat) }
|
||||||
|
|
@ -362,6 +365,9 @@ fun ChatScreen(
|
||||||
query = state.query,
|
query = state.query,
|
||||||
sources = state.sources,
|
sources = state.sources,
|
||||||
)
|
)
|
||||||
|
if (state.inputTokens > 0 || state.outputTokens > 0) {
|
||||||
|
usedTokens = state.inputTokens + state.outputTokens
|
||||||
|
}
|
||||||
}
|
}
|
||||||
val idx = messages.indexOfLast { it.id == assistantId }
|
val idx = messages.indexOfLast { it.id == assistantId }
|
||||||
val finalMsg = if (idx >= 0) messages[idx] else null
|
val finalMsg = if (idx >= 0) messages[idx] else null
|
||||||
|
|
@ -538,6 +544,53 @@ fun ChatScreen(
|
||||||
) {
|
) {
|
||||||
items(messages, key = { it.id }) { message -> MessageRow(message) }
|
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
|
// 2. Floating Top Menu Button
|
||||||
|
|
@ -585,6 +638,10 @@ fun ChatScreen(
|
||||||
onClick = { haptics.tick(); showModelSheet = true },
|
onClick = { haptics.tick(); showModelSheet = true },
|
||||||
modifier = Modifier.weight(1f, fill = false),
|
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 = "",
|
val snippet: String = "",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class UsageJson(
|
||||||
|
val i: Int = 0,
|
||||||
|
val o: Int = 0,
|
||||||
|
)
|
||||||
|
|
||||||
data class StreamState(
|
data class StreamState(
|
||||||
val content: String = "",
|
val content: String = "",
|
||||||
val reasoning: String = "",
|
val reasoning: String = "",
|
||||||
val tools: List<ToolEvent> = emptyList(),
|
val tools: List<ToolEvent> = emptyList(),
|
||||||
val query: String = "",
|
val query: String = "",
|
||||||
val sources: List<SearchSource> = emptyList(),
|
val sources: List<SearchSource> = emptyList(),
|
||||||
|
val inputTokens: Int = 0,
|
||||||
|
val outputTokens: Int = 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
object StreamConsumer {
|
object StreamConsumer {
|
||||||
|
|
@ -101,12 +109,15 @@ object StreamConsumer {
|
||||||
private fun buildState(): StreamState {
|
private fun buildState(): StreamState {
|
||||||
val queryStr = parseTrailingField(QUERY)
|
val queryStr = parseTrailingField(QUERY)
|
||||||
val sourcesList = parseTrailingSources()
|
val sourcesList = parseTrailingSources()
|
||||||
|
val usage = parseTrailingUsage()
|
||||||
return StreamState(
|
return StreamState(
|
||||||
content = content.toString(),
|
content = content.toString(),
|
||||||
reasoning = reasoningBuf.toString(),
|
reasoning = reasoningBuf.toString(),
|
||||||
tools = tools.toList(),
|
tools = tools.toList(),
|
||||||
query = queryStr,
|
query = queryStr,
|
||||||
sources = sourcesList,
|
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) {
|
private fun parseToolEvents(buf: CharSequence, from: Int, to: Int) {
|
||||||
val s = buf.toString()
|
val s = buf.toString()
|
||||||
var pos = from
|
var pos = from
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue