wire search mode, single-select pills, remove Standard pill

Mode pills are now single-select (tap to activate, tap again to
deactivate) matching the web frontend behavior. Removed the
"Standard" pill — it's the implicit default when no mode is active.

Search mode sends webSearch: true in the ChatRequest body, which
triggers the backend's agentive web search (openrouter:web_search
or Google Search grounding). Sources and query are already parsed
by StreamConsumer and rendered via SourcesBlock/SourceChip.

Active pills now highlighted with accent color + accent border
instead of generic surface alpha, making the active state
visually obvious.
This commit is contained in:
Bruno Deanoz 2026-06-22 00:10:14 +02:00
parent a16dfffd5c
commit 30d3f2807d
3 changed files with 19 additions and 14 deletions

View file

@ -142,14 +142,10 @@ fun EmptyHero(userName: String, onPick: (String) -> Unit, modifier: Modifier = M
} }
@Composable @Composable
fun ModePillsRow(selected: Set<Int>, onToggle: (Int) -> Unit, modifier: Modifier = Modifier) { fun ModePillsRow(activeMode: Int?, onToggle: (Int) -> Unit, modifier: Modifier = Modifier) {
val cs = MaterialTheme.colorScheme val cs = MaterialTheme.colorScheme
val isDark = isSystemInDarkTheme() val isDark = isSystemInDarkTheme()
val accent = LocalKaizenAccent.current
val glassBorder = remember(isDark) {
if (isDark) Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.14f), Color.White.copy(alpha = 0.04f)))
else Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.50f), Color.Black.copy(alpha = 0.06f)))
}
Row( Row(
modifier modifier
@ -158,25 +154,30 @@ fun ModePillsRow(selected: Set<Int>, onToggle: (Int) -> Unit, modifier: Modifier
.padding(horizontal = 14.dp), .padding(horizontal = 14.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
chatModes.forEach { mode -> chatModes.filter { it.labelRes != R.string.mode_standard }.forEach { mode ->
val label = stringResource(mode.labelRes) val label = stringResource(mode.labelRes)
val active = mode.labelRes in selected val active = mode.labelRes == activeMode
val backgroundFill = if (active) { val backgroundFill = if (active) {
if (isDark) cs.surface.copy(alpha = 0.8f) else cs.surface.copy(alpha = 0.88f) accent.primary.copy(alpha = if (isDark) 0.18f else 0.14f)
} else { } else {
if (isDark) cs.surface.copy(alpha = 0.35f) else cs.surface.copy(alpha = 0.55f) if (isDark) cs.surface.copy(alpha = 0.35f) else cs.surface.copy(alpha = 0.55f)
} }
val borderColor = if (active) {
accent.primary.copy(alpha = 0.4f)
} else {
if (isDark) Color.White.copy(alpha = 0.08f) else Color.Black.copy(alpha = 0.06f)
}
Row( Row(
Modifier Modifier
.clip(KaizenShapes.pill) .clip(KaizenShapes.pill)
.background(backgroundFill) .background(backgroundFill)
.border(1.2.dp, glassBorder, KaizenShapes.pill) .border(1.2.dp, borderColor, KaizenShapes.pill)
.clickable { onToggle(mode.labelRes) } .clickable { onToggle(mode.labelRes) }
.padding(horizontal = 13.dp, vertical = 8.dp), .padding(horizontal = 13.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Icon(mode.icon, null, tint = if (active) cs.onBackground else cs.onSurfaceVariant, modifier = Modifier.size(16.dp)) Icon(mode.icon, null, tint = if (active) accent.primary else cs.onSurfaceVariant, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(6.dp)) Spacer(Modifier.width(6.dp))
Text(label, color = if (active) cs.onBackground else cs.onSurfaceVariant, fontSize = 13.sp, fontWeight = if (active) FontWeight.SemiBold else FontWeight.Medium) Text(label, color = if (active) cs.onBackground else cs.onSurfaceVariant, fontSize = 13.sp, fontWeight = if (active) FontWeight.SemiBold else FontWeight.Medium)
} }

View file

@ -97,7 +97,7 @@ fun ChatScreen(
var input by remember { mutableStateOf("") } var input by remember { mutableStateOf("") }
var isStreaming by remember { mutableStateOf(false) } var isStreaming by remember { mutableStateOf(false) }
var nextId by remember { mutableStateOf(0L) } var nextId by remember { mutableStateOf(0L) }
var selectedModes by remember { mutableStateOf(setOf(R.string.mode_standard)) } var activeMode by remember { mutableStateOf<Int?>(null) }
// Navigation State // Navigation State
var currentScreen by remember { mutableStateOf(AppScreen.Chat) } var currentScreen by remember { mutableStateOf(AppScreen.Chat) }
@ -339,6 +339,7 @@ fun ChatScreen(
KaizenApi.chat( KaizenApi.chat(
cfg.baseUrl, cfg.token, cfg.model, history, cfg.speed, cfg.baseUrl, cfg.token, cfg.model, history, cfg.speed,
attachments = uploadedAtts.takeIf { it.isNotEmpty() }, attachments = uploadedAtts.takeIf { it.isNotEmpty() },
webSearch = activeMode == R.string.mode_search,
).collect { state -> ).collect { state ->
val idx = messages.indexOfLast { it.id == assistantId } val idx = messages.indexOfLast { it.id == assistantId }
if (idx < 0) return@collect if (idx < 0) return@collect
@ -621,9 +622,9 @@ fun ChatScreen(
) { ) {
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
ModePillsRow( ModePillsRow(
selected = selectedModes, activeMode = activeMode,
onToggle = { mode -> onToggle = { mode ->
selectedModes = if (mode in selectedModes) selectedModes - mode else selectedModes + mode activeMode = if (activeMode == mode) null else mode
haptics.tick() haptics.tick()
}, },
) )

View file

@ -35,6 +35,7 @@ import java.util.concurrent.TimeUnit
val reasoningEffort: String? = null, val reasoningEffort: String? = null,
val preferThroughput: Boolean? = null, val preferThroughput: Boolean? = null,
val attachments: List<Attachment>? = null, val attachments: List<Attachment>? = null,
val webSearch: Boolean? = null,
) )
/** One entry from GET /api/v1/models — the picker's row. `provider == "vertex"` drives the hoster badge. */ /** One entry from GET /api/v1/models — the picker's row. `provider == "vertex"` drives the hoster badge. */
@ -440,6 +441,7 @@ object KaizenApi {
messages: List<WireMessage>, messages: List<WireMessage>,
speed: ChatSpeed = ChatSpeed.Normal, speed: ChatSpeed = ChatSpeed.Normal,
attachments: List<Attachment>? = null, attachments: List<Attachment>? = null,
webSearch: Boolean = false,
): Flow<StreamState> = flow { ): Flow<StreamState> = flow {
val payload = json.encodeToString( val payload = json.encodeToString(
ChatRequest( ChatRequest(
@ -448,6 +450,7 @@ object KaizenApi {
reasoningEffort = speed.reasoningEffort, reasoningEffort = speed.reasoningEffort,
preferThroughput = if (speed.preferThroughput) true else null, preferThroughput = if (speed.preferThroughput) true else null,
attachments = attachments?.takeIf { it.isNotEmpty() }, attachments = attachments?.takeIf { it.isNotEmpty() },
webSearch = if (webSearch) true else null,
), ),
).toRequestBody(jsonMedia) ).toRequestBody(jsonMedia)
val req = Request.Builder() val req = Request.Builder()