From e8a90a03dfad05b2d2f1c00626aa26968e880df4 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Tue, 19 May 2026 19:25:30 +0530 Subject: [PATCH] feat(android): add v2 command palette --- .../ai/openclaw/app/ui/V2CommandPalette.kt | 327 ++++++++++++++++++ .../java/ai/openclaw/app/ui/V2ShellScreen.kt | 62 +++- 2 files changed, 382 insertions(+), 7 deletions(-) create mode 100644 apps/android/app/src/main/java/ai/openclaw/app/ui/V2CommandPalette.kt diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/V2CommandPalette.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/V2CommandPalette.kt new file mode 100644 index 000000000000..3c5fb58160de --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/V2CommandPalette.kt @@ -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) { + 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, + 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, + models: List, +): 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" +} diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/V2ShellScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/V2ShellScreen.kt index 3dae1edda87e..d84bd07e7636 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/V2ShellScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/V2ShellScreen.kt @@ -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 = } @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 = {}) } }