feat(android): add v2 command palette

This commit is contained in:
Ayaan Zaidi
2026-05-19 19:25:30 +05:30
parent c842f542cd
commit e8a90a03df
2 changed files with 382 additions and 7 deletions
@@ -0,0 +1,327 @@
package ai.openclaw.app.ui
import ai.openclaw.app.GatewayModelProviderSummary
import ai.openclaw.app.GatewayModelSummary
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.ui.design.ClawEmptyState
import ai.openclaw.app.ui.design.ClawPanel
import ai.openclaw.app.ui.design.ClawScaffold
import ai.openclaw.app.ui.design.ClawTextField
import ai.openclaw.app.ui.design.ClawTheme
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.outlined.AccessTime
import androidx.compose.material.icons.outlined.ChatBubbleOutline
import androidx.compose.material.icons.outlined.Inventory2
import androidx.compose.material.icons.outlined.MicNone
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
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.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@Composable
internal fun V2CommandPalette(
viewModel: MainViewModel,
onDismiss: () -> Unit,
onOpenChat: () -> Unit,
onOpenVoice: () -> Unit,
onOpenSessions: () -> Unit,
onOpenProviders: () -> Unit,
onOpenSettings: () -> Unit,
onOpenSession: (String) -> Unit,
) {
val isConnected by viewModel.isConnected.collectAsState()
val sessions by viewModel.chatSessions.collectAsState()
val models by viewModel.modelCatalog.collectAsState()
val providers by viewModel.modelAuthProviders.collectAsState()
val pendingRunCount by viewModel.pendingRunCount.collectAsState()
var query by rememberSaveable { mutableStateOf("") }
val normalizedQuery = query.trim().lowercase()
val quickActions =
listOf(
V2CommandItem("Open Chat", "Start or continue a conversation", Icons.Outlined.ChatBubbleOutline, onOpenChat),
V2CommandItem("Start Voice", "Talk or dictate with OpenClaw", Icons.Outlined.MicNone, onOpenVoice),
V2CommandItem("Browse Sessions", "Find previous conversations", Icons.Outlined.AccessTime, onOpenSessions),
V2CommandItem("Providers & Models", providerCommandSubtitle(isConnected, providers, models), Icons.Outlined.Inventory2, onOpenProviders),
V2CommandItem("Settings", "Gateway, voice, notifications, privacy", Icons.Outlined.Settings, onOpenSettings),
)
val actionRows = quickActions.filter { it.matches(normalizedQuery) }
val sessionRows =
sessions
.filter { session ->
val title = commandSessionTitle(session.displayName)
normalizedQuery.isEmpty() || title.lowercase().contains(normalizedQuery)
}.take(5)
Surface(modifier = Modifier.fillMaxSize(), color = ClawTheme.colors.canvas, contentColor = ClawTheme.colors.text) {
ClawScaffold(contentPadding = PaddingValues(start = 20.dp, top = 14.dp, end = 20.dp, bottom = 20.dp)) {
LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) {
item {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(9.dp),
) {
V2CommandIconButton(icon = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Close search", onClick = onDismiss)
Text(text = "Search", style = ClawTheme.type.title, color = ClawTheme.colors.text, modifier = Modifier.weight(1f), textAlign = TextAlign.Center)
V2CommandAvatar(text = "OC")
}
}
item {
ClawTextField(value = query, onValueChange = { query = it }, placeholder = "Search OpenClaw")
}
item {
V2CommandSectionLabel(title = "Quick actions")
}
if (actionRows.isEmpty()) {
item {
ClawEmptyState(title = "No actions found", body = "Try Chat, Voice, Sessions, Providers, or Settings.")
}
} else {
item {
V2CommandActionList(rows = actionRows)
}
}
item {
V2CommandSectionLabel(title = "Sessions")
}
if (sessionRows.isEmpty()) {
item {
ClawPanel {
Text(
text = if (isConnected) "No matching sessions yet." else "Connect the Gateway to search sessions.",
style = ClawTheme.type.body,
color = ClawTheme.colors.textMuted,
)
}
}
} else {
item {
V2CommandSessionList(
rows =
sessionRows.map { session ->
V2CommandSessionRow(
key = session.key,
title = commandSessionTitle(session.displayName),
subtitle = if (pendingRunCount > 0) "Assistant working" else "OpenClaw session",
metadata = session.updatedAtMs?.let(::commandRelativeTime) ?: "now",
)
},
onOpen = onOpenSession,
)
}
}
}
}
}
}
private data class V2CommandItem(
val title: String,
val subtitle: String,
val icon: ImageVector,
val onClick: () -> Unit,
) {
fun matches(query: String): Boolean = query.isEmpty() || title.lowercase().contains(query) || subtitle.lowercase().contains(query)
}
private data class V2CommandSessionRow(
val key: String,
val title: String,
val subtitle: String,
val metadata: String,
)
@Composable
private fun V2CommandActionList(rows: List<V2CommandItem>) {
ClawPanel(contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp)) {
Column {
rows.forEachIndexed { index, row ->
V2CommandActionRow(row = row)
if (index != rows.lastIndex) {
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
}
}
}
}
}
@Composable
private fun V2CommandActionRow(row: V2CommandItem) {
Surface(color = Color.Transparent, contentColor = ClawTheme.colors.text) {
Row(
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(ClawTheme.radii.row))
.clickable(onClick = row.onClick)
.padding(vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(9.dp),
) {
Icon(imageVector = row.icon, contentDescription = null, modifier = Modifier.size(15.dp), tint = ClawTheme.colors.text)
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) {
Text(text = row.title, style = ClawTheme.type.body, color = ClawTheme.colors.text, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(text = row.subtitle, style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = "Open ${row.title}",
modifier = Modifier.size(14.dp),
tint = ClawTheme.colors.textMuted,
)
}
}
}
@Composable
private fun V2CommandSessionList(
rows: List<V2CommandSessionRow>,
onOpen: (String) -> Unit,
) {
ClawPanel(contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp)) {
Column {
rows.forEachIndexed { index, row ->
V2CommandSessionListRow(row = row, onClick = { onOpen(row.key) })
if (index != rows.lastIndex) {
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
}
}
}
}
}
@Composable
private fun V2CommandSessionListRow(
row: V2CommandSessionRow,
onClick: () -> Unit,
) {
Surface(color = ClawTheme.colors.canvas, contentColor = ClawTheme.colors.text) {
Row(
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(ClawTheme.radii.row))
.clickable(onClick = onClick)
.padding(vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Surface(
modifier = Modifier.size(24.dp),
shape = CircleShape,
color = ClawTheme.colors.canvas,
border = BorderStroke(1.dp, ClawTheme.colors.borderStrong),
) {
Box(contentAlignment = Alignment.Center) {
Icon(imageVector = Icons.Outlined.ChatBubbleOutline, contentDescription = null, modifier = Modifier.size(12.dp), tint = ClawTheme.colors.text)
}
}
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) {
Text(text = row.title, style = ClawTheme.type.body, color = ClawTheme.colors.text, maxLines = 1)
Text(text = row.subtitle, style = ClawTheme.type.caption, color = ClawTheme.colors.textSubtle, maxLines = 1)
}
Text(text = row.metadata, style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted)
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = "Open session",
modifier = Modifier.size(14.dp),
tint = ClawTheme.colors.textMuted,
)
}
}
}
@Composable
private fun V2CommandIconButton(
icon: ImageVector,
contentDescription: String,
onClick: () -> Unit,
) {
Surface(onClick = onClick, modifier = Modifier.size(30.dp), shape = CircleShape, color = Color.Transparent, contentColor = ClawTheme.colors.text) {
Box(contentAlignment = Alignment.Center) {
Icon(imageVector = icon, contentDescription = contentDescription, modifier = Modifier.size(18.dp))
}
}
}
@Composable
private fun V2CommandAvatar(text: String) {
Surface(
modifier = Modifier.size(28.dp),
shape = CircleShape,
color = ClawTheme.colors.surfaceRaised,
contentColor = ClawTheme.colors.text,
border = BorderStroke(1.dp, ClawTheme.colors.border),
) {
Box(contentAlignment = Alignment.Center) {
Text(text = text.take(2).uppercase(), style = ClawTheme.type.label)
}
}
}
@Composable
private fun V2CommandSectionLabel(title: String) {
Row(modifier = Modifier.fillMaxWidth()) {
Text(text = title.uppercase(), style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted)
}
}
private fun providerCommandSubtitle(
isConnected: Boolean,
providers: List<GatewayModelProviderSummary>,
models: List<GatewayModelSummary>,
): String {
if (!isConnected) return "Connect Gateway to load models"
val readyProviderCount = providers.count { modelProviderReady(it.status) }
if (readyProviderCount > 0) return "$readyProviderCount providers ready"
if (models.isNotEmpty()) return "${models.size} models available"
return "Configure model access"
}
private fun commandSessionTitle(displayName: String?): String = displayName?.takeIf { it.isNotBlank() } ?: "Main session"
private fun commandRelativeTime(updatedAtMs: Long): String {
val deltaMs = (System.currentTimeMillis() - updatedAtMs).coerceAtLeast(0L)
val minutes = deltaMs / 60_000L
if (minutes < 1) return "now"
if (minutes < 60) return "${minutes}m"
val hours = minutes / 60
if (hours < 24) return "${hours}h"
return "${hours / 24}d"
}
@@ -97,6 +97,7 @@ fun V2ShellScreen(
) {
ClawDesignTheme {
var activeTab by rememberSaveable { mutableStateOf(V2Tab.Overview) }
var commandOpen by rememberSaveable { mutableStateOf(false) }
val requestedHomeDestination by viewModel.requestedHomeDestination.collectAsState()
LaunchedEffect(requestedHomeDestination) {
@@ -120,9 +121,18 @@ fun V2ShellScreen(
activeTab = V2Tab.Overview
}
BackHandler(enabled = commandOpen) {
commandOpen = false
}
Box(modifier = modifier.fillMaxSize()) {
when (activeTab) {
V2Tab.Overview -> V2OverviewScreen(viewModel = viewModel, onSelectTab = { activeTab = it })
V2Tab.Overview ->
V2OverviewScreen(
viewModel = viewModel,
onSelectTab = { activeTab = it },
onOpenCommand = { commandOpen = true },
)
V2Tab.Chat ->
V2ChatShellScreen(
viewModel = viewModel,
@@ -139,9 +149,42 @@ fun V2ShellScreen(
V2Tab.Sessions ->
V2SessionsScreen(
viewModel = viewModel,
onOpenCommand = { commandOpen = true },
onOpenChat = { activeTab = V2Tab.Chat },
)
V2Tab.Settings -> V2SettingsShellScreen(viewModel = viewModel)
V2Tab.Settings -> V2SettingsShellScreen(viewModel = viewModel, onOpenCommand = { commandOpen = true })
}
if (commandOpen) {
V2CommandPalette(
viewModel = viewModel,
onDismiss = { commandOpen = false },
onOpenChat = {
activeTab = V2Tab.Chat
commandOpen = false
},
onOpenVoice = {
activeTab = V2Tab.Voice
commandOpen = false
},
onOpenSessions = {
activeTab = V2Tab.Sessions
commandOpen = false
},
onOpenProviders = {
activeTab = V2Tab.ProvidersModels
commandOpen = false
},
onOpenSettings = {
activeTab = V2Tab.Settings
commandOpen = false
},
onOpenSession = { sessionKey ->
viewModel.switchChatSession(sessionKey)
activeTab = V2Tab.Chat
commandOpen = false
},
)
}
}
}
@@ -151,6 +194,7 @@ fun V2ShellScreen(
private fun V2OverviewScreen(
viewModel: MainViewModel,
onSelectTab: (V2Tab) -> Unit,
onOpenCommand: () -> Unit,
) {
val isConnected by viewModel.isConnected.collectAsState()
val sessions by viewModel.chatSessions.collectAsState()
@@ -181,7 +225,7 @@ private fun V2OverviewScreen(
color = ClawTheme.colors.text,
modifier = Modifier.weight(1f),
)
V2PlainIconButton(icon = Icons.Default.Search, contentDescription = "Search", onClick = {})
V2PlainIconButton(icon = Icons.Default.Search, contentDescription = "Search", onClick = onOpenCommand)
V2OverviewAvatar(text = "OC")
}
}
@@ -602,7 +646,7 @@ private fun providerRows(
return (authRows + missingAuthRows).sortedWith(compareBy(::providerPriority, { it.name.lowercase() }))
}
private fun modelProviderReady(status: String): Boolean {
internal fun modelProviderReady(status: String): Boolean {
val normalized = status.trim().lowercase()
return normalized == "ok" || normalized == "ready" || normalized == "healthy" || normalized == "configured"
}
@@ -746,7 +790,10 @@ private fun modelCapabilityLabels(model: GatewayModelSummary): List<String> =
}
@Composable
private fun V2SettingsShellScreen(viewModel: MainViewModel) {
private fun V2SettingsShellScreen(
viewModel: MainViewModel,
onOpenCommand: () -> Unit,
) {
val displayName by viewModel.displayName.collectAsState()
val isConnected by viewModel.isConnected.collectAsState()
val statusText by viewModel.statusText.collectAsState()
@@ -763,7 +810,7 @@ private fun V2SettingsShellScreen(viewModel: MainViewModel) {
horizontalArrangement = Arrangement.spacedBy(9.dp),
) {
Text(text = "Settings", style = ClawTheme.type.title.copy(fontSize = 16.sp, lineHeight = 20.sp), color = ClawTheme.colors.text, modifier = Modifier.weight(1f))
V2SettingsSearchButton(onClick = {})
V2SettingsSearchButton(onClick = onOpenCommand)
}
}
@@ -1088,6 +1135,7 @@ private fun V2MiniTag(text: String) {
@Composable
private fun V2SessionsScreen(
viewModel: MainViewModel,
onOpenCommand: () -> Unit,
onOpenChat: () -> Unit,
) {
val sessions by viewModel.chatSessions.collectAsState()
@@ -1109,7 +1157,7 @@ private fun V2SessionsScreen(
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(text = "Sessions", style = ClawTheme.type.display.copy(fontSize = 17.4.sp, lineHeight = 21.sp), color = ClawTheme.colors.text, modifier = Modifier.weight(1f))
V2PlainIconButton(icon = Icons.Default.Search, contentDescription = "Search sessions", onClick = {})
V2PlainIconButton(icon = Icons.Default.Search, contentDescription = "Search sessions", onClick = onOpenCommand)
V2PlainIconButton(icon = Icons.Default.MoreVert, contentDescription = "Session options", onClick = {})
}
}