Android: add adaptive app navigation sidebar (#113908)

* feat(android): add adaptive navigation sidebar

* fix(android): satisfy native i18n checks

* fix(android): use adaptive Material navigation

* fix(android): preserve adaptive navigation state

* chore(i18n): refresh native source baseline

* fix(android): keep compact navigation labels on one line

* chore(android): refresh native i18n source lines

* fix(android): preserve sidebar gateway status

* fix(android): satisfy status label checks

---------

Co-authored-by: Colin <colin@solvely.net>
This commit is contained in:
WhatsSkiLL
2026-07-30 23:27:45 +02:00
committed by GitHub
parent b7bfe21348
commit 47f8c1d6b0
18 changed files with 2172 additions and 809 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,16 @@
AndroidX Compose
Artifacts:
- androidx.compose.material3:material3:1.4.0
- androidx.compose.material3.adaptive:adaptive:1.2.0
- androidx.compose.material3:material3-adaptive-navigation-suite:1.4.0
Copyright 2020 The Android Open Source Project
Licensed under the Apache License, Version 2.0.
You may obtain a copy of the License at:
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+1
View File
@@ -318,6 +318,7 @@ dependencies {
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material3.adaptive.navigation.suite)
// material-icons-extended pulled in full icon set (~20 MB DEX). Only ~18 icons used.
// R8 will tree-shake unused icons when minify is enabled on release builds.
implementation(libs.androidx.compose.material.icons.extended)
@@ -1,6 +1,7 @@
package ai.openclaw.app.ui
import ai.openclaw.app.BuildConfig
import ai.openclaw.app.GatewayConnectionDisplay
import ai.openclaw.app.GatewayConnectionProblem
import ai.openclaw.app.GatewayNodeApprovalState
import ai.openclaw.app.GatewayNodeCapabilityApproval
@@ -26,6 +27,31 @@ internal fun openClawAndroidVersionLabel(): String {
/** Normalizes blank gateway status text for display and diagnostics copy. */
internal fun gatewayStatusForDisplay(statusText: String): String = gatewayConnectionStatusForDisplay(statusText)
/** Converts raw gateway connection state into a stable compact label for status surfaces. */
internal fun gatewayStatusLabel(
statusText: String,
isConnected: Boolean,
gatewayConnectionProblem: GatewayConnectionProblem? = null,
): String {
val status = statusText.trim().lowercase()
return when {
status == "connected (node offline)" -> nativeString("Connected (node offline)")
status == "connected (operator offline)" -> nativeString("Connected (operator offline)")
isConnected -> nativeString("Ready")
status.contains("connecting") || status.contains("reconnecting") -> nativeString("Connecting...")
status.contains("pair") -> nativeString("Pairing needed")
status.contains("auth") || status.contains("device identity") -> gatewayAuthRecoveryLabel(gatewayConnectionProblem) ?: nativeString("Authentication needed")
status.contains("fingerprint verification timed out") -> nativeString("TLS timed out")
status.contains("no tls endpoint") -> nativeString("No TLS endpoint")
status.contains("certificate") || status.contains("tls") -> nativeString("Certificate review needed")
status.contains("failed") || status.contains("error") || status.contains("offline") || status.contains("not connected") -> nativeString("Cannot reach gateway")
status.isBlank() -> nativeString("Not connected")
else -> nativeString("Not connected")
}
}
internal fun gatewayStatusLabel(display: GatewayConnectionDisplay): String = gatewayStatusLabel(display.statusText, display.isConnected, display.problem)
/** Resolves the best non-secret endpoint label available to diagnostics surfaces. */
internal fun gatewayDiagnosticsEndpoint(
remoteAddress: String?,
@@ -18,11 +18,9 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
@@ -35,6 +33,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.StarBorder
@@ -71,6 +70,7 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -81,6 +81,8 @@ import kotlinx.coroutines.launch
@Composable
internal fun SessionsScreen(
viewModel: MainViewModel,
showSidebarButton: Boolean,
onOpenSidebar: () -> Unit,
onOpenChat: () -> Unit,
) {
val sessions by viewModel.chatSessions.collectAsState()
@@ -170,7 +172,7 @@ internal fun SessionsScreen(
ClawScaffold(
contentPadding = PaddingValues(start = 16.dp, top = 10.dp, end = 16.dp, bottom = 4.dp),
contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
contentWindowInsets = WindowInsets.safeDrawing,
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
@@ -183,6 +185,14 @@ internal fun SessionsScreen(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
if (showSidebarButton) {
ClawPlainIconButton(
icon = Icons.Default.Menu,
contentDescription = nativeString("Show Sidebar"),
onClick = onOpenSidebar,
modifier = Modifier.testTag("sidebar-open-sessions"),
)
}
Text(text = nativeString("Threads"), style = ClawTheme.type.display.copy(fontSize = 24.sp, lineHeight = 28.sp), color = ClawTheme.colors.text, modifier = Modifier.weight(1f))
ClawPlainIconButton(
icon = Icons.Default.Search,
@@ -6,8 +6,6 @@ import ai.openclaw.app.AppearanceThemeMode
import ai.openclaw.app.BuildConfig
import ai.openclaw.app.CronEditorDraftState
import ai.openclaw.app.GatewayAgentSummary
import ai.openclaw.app.GatewayConnectionDisplay
import ai.openclaw.app.GatewayConnectionProblem
import ai.openclaw.app.GatewayCronActionState
import ai.openclaw.app.GatewayCronJobDetail
import ai.openclaw.app.GatewayCronJobDetailState
@@ -2103,29 +2101,6 @@ private val LocationMode.displayLabel: String
LocationMode.Always -> nativeString("Always")
}
/** Converts raw gateway connection text into stable settings metric labels. */
internal fun gatewayStatusLabel(
statusText: String,
isConnected: Boolean,
gatewayConnectionProblem: GatewayConnectionProblem? = null,
): String {
if (isConnected) return nativeString("Ready")
val status = statusText.trim().lowercase()
return when {
status.contains("connecting") || status.contains("reconnecting") -> nativeString("Connecting...")
status.contains("pair") -> nativeString("Pairing needed")
status.contains("auth") || status.contains("device identity") -> gatewayAuthRecoveryLabel(gatewayConnectionProblem) ?: nativeString("Authentication needed")
status.contains("fingerprint verification timed out") -> nativeString("TLS timed out")
status.contains("no tls endpoint") -> nativeString("No TLS endpoint")
status.contains("certificate") || status.contains("tls") -> nativeString("Certificate review needed")
status.contains("failed") || status.contains("error") || status.contains("offline") || status.contains("not connected") -> nativeString("Cannot reach gateway")
status.isBlank() -> nativeString("Not connected")
else -> nativeString("Not connected")
}
}
internal fun gatewayStatusLabel(display: GatewayConnectionDisplay): String = gatewayStatusLabel(display.statusText, display.isConnected, display.problem)
@Composable
private fun AboutSettingsScreen(
viewModel: MainViewModel,
@@ -28,11 +28,9 @@ import ai.openclaw.app.node.CanvasController
import ai.openclaw.app.systemagent.SystemAgentChatAccess
import ai.openclaw.app.ui.design.AgentAvatarSource
import ai.openclaw.app.ui.design.ClawAgentAvatar
import ai.openclaw.app.ui.design.ClawBottomNav
import ai.openclaw.app.ui.design.ClawDesignTheme
import ai.openclaw.app.ui.design.ClawEmptyState
import ai.openclaw.app.ui.design.ClawIconButton
import ai.openclaw.app.ui.design.ClawNavItem
import ai.openclaw.app.ui.design.ClawPanel
import ai.openclaw.app.ui.design.ClawPlainIconButton
import ai.openclaw.app.ui.design.ClawPrimaryButton
@@ -70,7 +68,6 @@ 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.ExitToApp
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.automirrored.filled.ScreenShare
@@ -83,6 +80,7 @@ import androidx.compose.material.icons.filled.GraphicEq
import androidx.compose.material.icons.filled.Groups
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Palette
@@ -101,19 +99,21 @@ import androidx.compose.material.icons.outlined.MicNone
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.Terminal
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -122,11 +122,13 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import java.util.Locale
internal enum class Tab(
@@ -144,10 +146,8 @@ internal enum class Tab(
Dashboard(key = "dashboard", label = nativeText("Dashboard"), icon = Icons.Outlined.Dashboard),
}
private val shellNavTabs = listOf(Tab.Overview, Tab.Chat, Tab.Settings)
private val shellContentInsets: WindowInsets
@Composable get() = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)
@Composable get() = WindowInsets.safeDrawing
private val overviewMetricTileMinHeight = 96.dp
private val overviewTalkPanelMinHeight = 72.dp
@@ -173,11 +173,19 @@ fun ShellScreen(
val nav = rememberSaveable(saver = ShellNavigation.Saver) { ShellNavigation() }
var commandOpen by rememberSaveable { mutableStateOf(false) }
var conversationScreenWasActive by rememberSaveable { mutableStateOf(false) }
val sidebarDrawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
val coroutineScope = rememberCoroutineScope()
val requestedHomeDestination by viewModel.requestedHomeDestination.collectAsState()
val pendingTrust by viewModel.pendingGatewayTrust.collectAsState()
val runtimeInitialized by viewModel.runtimeInitialized.collectAsState()
val canvasPresentationState by viewModel.canvasPresentationState.collectAsState()
val canvasVisible = canvasPresentationState == CanvasController.PresentationState.Visible
val gatewayAgents by viewModel.gatewayAgents.collectAsState()
val gatewayDefaultAgentId by viewModel.gatewayDefaultAgentId.collectAsState()
val chatSessionOwnerAgentId by viewModel.chatSessionOwnerAgentId.collectAsState()
val chatSessions by viewModel.chatSessions.collectAsState()
val chatSessionKey by viewModel.chatSessionKey.collectAsState()
val gatewayConnectionDisplay by viewModel.gatewayConnectionDisplay.collectAsState()
LaunchedEffect(requestedHomeDestination) {
val destination = requestedHomeDestination ?: return@LaunchedEffect
@@ -202,6 +210,7 @@ fun ShellScreen(
nav.openSettingsRoute(route)
viewModel.clearRequestedSettingsRoute()
}
sidebarDrawerState.close()
viewModel.clearRequestedHomeDestination()
}
@@ -213,7 +222,12 @@ fun ShellScreen(
conversationScreenWasActive = conversationScreenActive
}
BackHandler(enabled = nav.activeTab != Tab.Overview) {
BackHandler(
enabled =
sidebarDrawerState.currentValue == DrawerValue.Closed &&
sidebarDrawerState.targetValue == DrawerValue.Closed &&
nav.activeTab != Tab.Overview,
) {
nav.back()
}
@@ -221,35 +235,81 @@ fun ShellScreen(
commandOpen = false
}
LaunchedEffect(commandOpen, canvasVisible, pendingTrust) {
if (commandOpen || canvasVisible || pendingTrust != null) sidebarDrawerState.close()
}
val density = LocalDensity.current
val keyboardVisible = WindowInsets.ime.getBottom(density) > 0
val showBottomNav =
val compactNavigationVisible =
shellBottomNavVisible(keyboardVisible = keyboardVisible, commandOpen = commandOpen) && !canvasVisible
Scaffold(
modifier = modifier.fillMaxSize(),
containerColor = ClawTheme.colors.canvas,
contentWindowInsets = WindowInsets(0, 0, 0, 0),
bottomBar = {
if (showBottomNav) {
ClawBottomNav(
items =
shellNavTabs.map {
ClawNavItem(key = it.key, label = it.label.resolveNativeTextResource(), icon = it.icon)
},
selectedKey = if (nav.activeTab in shellNavTabs) nav.activeTab.key else Tab.Overview.key,
onSelect = { key ->
nav.selectTab(shellNavTabs.firstOrNull { it.key == key } ?: Tab.Overview)
val activeSidebarDestination =
when {
nav.activeTab == Tab.Chat -> SidebarDestination.Home
nav.activeTab == Tab.Overview -> SidebarDestination.Overview
nav.activeTab == Tab.Sessions -> SidebarDestination.Sessions
nav.activeTab == Tab.Settings && nav.settingsRoute == SettingsRoute.Usage -> SidebarDestination.Usage
nav.activeTab == Tab.Settings && nav.settingsRoute == SettingsRoute.CronJobs -> SidebarDestination.Automations
else -> null
}
val openSidebar: () -> Unit = {
coroutineScope.launch { sidebarDrawerState.open() }
}
val closeSidebar: () -> Unit = {
coroutineScope.launch { sidebarDrawerState.close() }
}
val selectSidebarDestination: (SidebarDestination) -> Unit = { destination ->
when (destination) {
SidebarDestination.Home -> nav.selectTab(Tab.Chat)
SidebarDestination.Overview -> nav.selectTab(Tab.Overview)
SidebarDestination.Usage -> nav.openSettingsRoute(SettingsRoute.Usage)
SidebarDestination.Automations -> nav.openSettingsRoute(SettingsRoute.CronJobs)
SidebarDestination.Sessions -> nav.selectTab(Tab.Sessions)
}
closeSidebar()
}
Box(modifier = modifier.fillMaxSize().background(ClawTheme.colors.canvas)) {
AdaptiveNavigationShell(
drawerState = sidebarDrawerState,
compactNavigationVisible = compactNavigationVisible,
activeDestination = activeSidebarDestination,
onSelectDestination = selectSidebarDestination,
drawerContent = {
OpenClawSidebar(
agents = gatewayAgents,
selectedAgentId = chatSessionOwnerAgentId ?: gatewayDefaultAgentId,
sessions = chatSessions,
activeSessionKey = chatSessionKey,
activeDestination = activeSidebarDestination,
connection = gatewayConnectionDisplay,
showCloseButton = true,
onClose = closeSidebar,
onOpenSettings = {
nav.selectTab(Tab.Settings)
closeSidebar()
},
onSelectAgent = { agentId ->
viewModel.selectChatAgent(agentId)
nav.selectTab(Tab.Chat)
closeSidebar()
},
onSelectSession = { session ->
viewModel.switchChatSession(session.key, session.ownerAgentId)
nav.selectTab(Tab.Chat)
closeSidebar()
},
onSelectDestination = selectSidebarDestination,
)
}
},
) { shellPadding ->
Box(modifier = Modifier.fillMaxSize().padding(shellPadding)) {
},
) {
when (nav.activeTab) {
Tab.Overview ->
OverviewScreen(
viewModel = viewModel,
showSidebarButton = true,
onOpenSidebar = openSidebar,
onSelectTab = nav::selectTab,
onOpenSettingsRoute = nav::openSettingsRoute,
onOpenCommand = { commandOpen = true },
@@ -257,6 +317,8 @@ fun ShellScreen(
Tab.Chat ->
UnifiedChatShellScreen(
viewModel = viewModel,
showSidebarButton = true,
onOpenSidebar = openSidebar,
onOpenSessions = { nav.openDetailTab(Tab.Sessions) },
onOpenDashboard = nav::openSessionDashboard,
onOpenGatewaySettings = { nav.openSettingsRoute(SettingsRoute.Gateway) },
@@ -276,6 +338,8 @@ fun ShellScreen(
Tab.Sessions ->
SessionsScreen(
viewModel = viewModel,
showSidebarButton = true,
onOpenSidebar = openSidebar,
onOpenChat = { nav.selectTab(Tab.Chat) },
)
Tab.Files ->
@@ -293,62 +357,64 @@ fun ShellScreen(
SettingsShellScreen(
viewModel = viewModel,
route = nav.settingsRoute,
showSidebarButton = true,
onOpenSidebar = openSidebar,
onRouteChange = nav::openSettingsRouteFromHome,
onBack = nav::back,
onOpenCommand = { commandOpen = true },
)
}
}
if (commandOpen) {
CommandPalette(
viewModel = viewModel,
onDismiss = { commandOpen = false },
onOpenChat = {
nav.selectTab(Tab.Chat)
commandOpen = false
},
onOpenVoice = {
nav.selectTab(Tab.Chat)
commandOpen = false
},
onOpenSessions = {
nav.openDetailTab(Tab.Sessions)
commandOpen = false
},
onOpenProviders = {
nav.openDetailTab(Tab.ProvidersModels)
commandOpen = false
},
onOpenSettings = {
nav.openSettingsRoute(SettingsRoute.Home)
commandOpen = false
},
onOpenSession = { sessionKey, ownerAgentId ->
viewModel.switchChatSession(sessionKey, ownerAgentId)
nav.selectTab(Tab.Chat)
commandOpen = false
},
)
}
if (commandOpen) {
CommandPalette(
viewModel = viewModel,
onDismiss = { commandOpen = false },
onOpenChat = {
nav.selectTab(Tab.Chat)
commandOpen = false
},
onOpenVoice = {
nav.selectTab(Tab.Chat)
commandOpen = false
},
onOpenSessions = {
nav.openDetailTab(Tab.Sessions)
commandOpen = false
},
onOpenProviders = {
nav.openDetailTab(Tab.ProvidersModels)
commandOpen = false
},
onOpenSettings = {
nav.openSettingsRoute(SettingsRoute.Home)
commandOpen = false
},
onOpenSession = { sessionKey, ownerAgentId ->
viewModel.switchChatSession(sessionKey, ownerAgentId)
nav.selectTab(Tab.Chat)
commandOpen = false
},
)
}
if (canvasPresentationState != CanvasController.PresentationState.Unmounted) {
CanvasOverlay(
viewModel = viewModel,
visible = canvasVisible,
onClose = viewModel::hideCanvas,
)
}
if (canvasPresentationState != CanvasController.PresentationState.Unmounted) {
CanvasOverlay(
viewModel = viewModel,
visible = canvasVisible,
onClose = viewModel::hideCanvas,
)
}
pendingTrust?.let { prompt ->
// Gateway certificate trust is modal across the shell so navigation
// cannot hide a changed TLS identity prompt.
GatewayTrustDialog(
prompt = prompt,
onAccept = viewModel::acceptGatewayTrustPrompt,
onUseSystemTrust = viewModel::useSystemGatewayTrustPrompt,
onDecline = viewModel::declineGatewayTrustPrompt,
)
}
pendingTrust?.let { prompt ->
// Gateway certificate trust is modal across the shell so navigation
// cannot hide a changed TLS identity prompt.
GatewayTrustDialog(
prompt = prompt,
onAccept = viewModel::acceptGatewayTrustPrompt,
onUseSystemTrust = viewModel::useSystemGatewayTrustPrompt,
onDecline = viewModel::declineGatewayTrustPrompt,
)
}
}
}
@@ -474,6 +540,8 @@ private fun GatewayTrustDialog(
@Composable
private fun OverviewScreen(
viewModel: MainViewModel,
showSidebarButton: Boolean,
onOpenSidebar: () -> Unit,
onSelectTab: (Tab) -> Unit,
onOpenSettingsRoute: (SettingsRoute) -> Unit,
onOpenCommand: () -> Unit,
@@ -560,7 +628,13 @@ private fun OverviewScreen(
Box(modifier = Modifier.fillMaxSize()) {
LazyColumn(modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(10.dp), contentPadding = PaddingValues(bottom = 6.dp)) {
item {
OverviewHeader(status = headerState, onOpenStatus = { onOpenSettingsRoute(headerRoute) }, onOpenCommand = onOpenCommand)
OverviewHeader(
status = headerState,
showSidebarButton = showSidebarButton,
onOpenSidebar = onOpenSidebar,
onOpenStatus = { onOpenSettingsRoute(headerRoute) },
onOpenCommand = onOpenCommand,
)
}
item {
@@ -649,6 +723,8 @@ private data class ModuleRow(
@Composable
private fun OverviewHeader(
status: OverviewHeaderState,
showSidebarButton: Boolean,
onOpenSidebar: () -> Unit,
onOpenStatus: () -> Unit,
onOpenCommand: () -> Unit,
) {
@@ -657,6 +733,14 @@ private fun OverviewHeader(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
if (showSidebarButton) {
ClawPlainIconButton(
icon = Icons.Default.Menu,
contentDescription = nativeString("Show Sidebar"),
onClick = onOpenSidebar,
modifier = Modifier.testTag("sidebar-open-overview"),
)
}
OpenClawMascot(modifier = Modifier.size(25.dp))
Text(
text = nativeString("OpenClaw"),
@@ -1575,6 +1659,8 @@ private fun VoiceShellScreen(
private fun SettingsShellScreen(
viewModel: MainViewModel,
route: SettingsRoute,
showSidebarButton: Boolean,
onOpenSidebar: () -> Unit,
onRouteChange: (SettingsRoute) -> Unit,
onBack: () -> Unit,
onOpenCommand: () -> Unit,
@@ -1640,11 +1726,14 @@ private fun SettingsShellScreen(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(9.dp),
) {
ClawPlainIconButton(
icon = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = nativeString("Back"),
onClick = onBack,
)
if (showSidebarButton) {
ClawPlainIconButton(
icon = Icons.Default.Menu,
contentDescription = nativeString("Show Sidebar"),
onClick = onOpenSidebar,
modifier = Modifier.testTag("sidebar-open-settings"),
)
}
Text(text = nativeString("Settings"), style = ClawTheme.type.display.copy(fontSize = 24.sp, lineHeight = 28.sp), color = ClawTheme.colors.text, modifier = Modifier.weight(1f))
ClawPlainIconButton(
icon = Icons.Default.Search,
@@ -0,0 +1,299 @@
package ai.openclaw.app.ui
import ai.openclaw.app.GatewayAgentSummary
import ai.openclaw.app.chat.ChatSessionEntry
import ai.openclaw.app.i18n.nativeString
import ai.openclaw.app.ui.design.ClawAgentAvatar
import ai.openclaw.app.ui.design.ClawTheme
import ai.openclaw.app.ui.design.agentAvatarSource
import androidx.compose.foundation.background
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.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.material3.NavigationDrawerItemDefaults
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.platform.testTag
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
internal fun SidebarSearchField(
query: String,
onQueryChange: (String) -> Unit,
palette: SidebarPalette,
modifier: Modifier = Modifier,
) {
OutlinedTextField(
value = query,
onValueChange = onQueryChange,
modifier = modifier.fillMaxWidth().testTag("sidebar-search"),
singleLine = true,
label = { Text(nativeString("Search sessions")) },
leadingIcon = {
Icon(
imageVector = Icons.Default.Search,
contentDescription = null,
)
},
trailingIcon = {
if (query.isNotEmpty()) {
IconButton(onClick = { onQueryChange("") }, modifier = Modifier.size(48.dp)) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = nativeString("Clear session search"),
)
}
}
},
colors =
OutlinedTextFieldDefaults.colors(
focusedTextColor = palette.text,
unfocusedTextColor = palette.text,
focusedContainerColor = palette.elevated,
unfocusedContainerColor = palette.elevated,
cursorColor = ClawTheme.colors.primary,
focusedBorderColor = ClawTheme.colors.primary,
unfocusedBorderColor = palette.hairline,
focusedLabelColor = ClawTheme.colors.primary,
unfocusedLabelColor = palette.muted,
focusedLeadingIconColor = palette.text,
unfocusedLeadingIconColor = palette.muted,
focusedTrailingIconColor = palette.text,
unfocusedTrailingIconColor = palette.muted,
),
)
}
@Composable
internal fun SidebarSectionTitle(
label: String,
palette: SidebarPalette,
modifier: Modifier = Modifier,
) {
Text(
text = label,
style = ClawTheme.type.caption.copy(fontWeight = FontWeight.SemiBold, fontSize = 12.sp),
color = palette.muted,
modifier = modifier.semantics { heading() }.padding(horizontal = 12.dp, vertical = 6.dp),
maxLines = 1,
)
}
@Composable
internal fun SidebarAgentRow(
agent: GatewayAgentSummary,
selected: Boolean,
palette: SidebarPalette,
onClick: () -> Unit,
) {
SidebarRowSurface(
selected = selected,
stateDescription = if (selected) nativeString("Selected") else null,
palette = palette,
onClick = onClick,
) {
ClawAgentAvatar(source = agentAvatarSource(agent), size = 28.dp) {
Box(
modifier = Modifier.size(28.dp).clip(CircleShape).background(palette.elevated),
contentAlignment = Alignment.Center,
) {
Text(
text = agent.emoji?.takeIf(String::isNotBlank) ?: sidebarAgentName(agent).take(1).uppercase(),
style = ClawTheme.type.caption,
color = palette.text,
)
}
}
Text(
text = sidebarAgentName(agent),
style = ClawTheme.type.body,
color = palette.text,
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (selected) {
Text(
text = nativeString("Selected"),
style = ClawTheme.type.caption.copy(fontSize = 11.sp),
color = palette.muted,
)
}
}
}
@Composable
internal fun SidebarActionRow(
label: String,
icon: ImageVector,
palette: SidebarPalette,
onClick: () -> Unit,
) {
SidebarRowSurface(selected = null, palette = palette, onClick = onClick) {
Spacer(modifier = Modifier.size(28.dp))
Text(
text = label,
style = ClawTheme.type.body,
color = palette.muted,
modifier = Modifier.weight(1f),
maxLines = 1,
)
Icon(imageVector = icon, contentDescription = null, tint = palette.muted, modifier = Modifier.size(18.dp))
}
}
@Composable
internal fun SidebarNavigationRow(
destination: SidebarDestination,
selected: Boolean,
palette: SidebarPalette,
onClick: () -> Unit,
) {
NavigationDrawerItem(
label = {
Text(
text = destination.localizedLabel(),
style = ClawTheme.type.body,
maxLines = 1,
)
},
selected = selected,
onClick = onClick,
icon = {
Icon(
imageVector = destination.icon,
contentDescription = null,
modifier = Modifier.size(20.dp),
)
},
modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp),
shape = RoundedCornerShape(10.dp),
colors =
NavigationDrawerItemDefaults.colors(
selectedContainerColor = palette.selection,
unselectedContainerColor = Color.Transparent,
selectedIconColor = palette.text,
unselectedIconColor = palette.text,
selectedTextColor = palette.text,
unselectedTextColor = palette.text,
),
)
}
@Composable
internal fun SidebarSessionRow(
session: ChatSessionEntry,
selected: Boolean,
palette: SidebarPalette,
onClick: () -> Unit,
) {
val sessionStateDescription =
when {
session.hasActiveRun == true -> nativeString("Working")
session.unread == true -> nativeString("Needs attention")
selected -> nativeString("Selected")
else -> null
}
SidebarRowSurface(
selected = selected,
stateDescription = sessionStateDescription,
palette = palette,
onClick = onClick,
) {
Box(
modifier =
Modifier
.size(7.dp)
.clip(CircleShape)
.background(
when {
session.hasActiveRun == true -> ClawTheme.colors.warning
session.unread == true -> ClawTheme.colors.primary
else -> palette.muted.copy(alpha = 0.45f)
},
).clearAndSetSemantics {},
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = sidebarSessionTitle(session),
style = ClawTheme.type.body.copy(fontSize = 13.sp),
color = palette.text,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = sessionSourceLabel(session.key),
style = ClawTheme.type.caption.copy(fontSize = 11.sp),
color = palette.muted,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@Composable
private fun SidebarRowSurface(
selected: Boolean?,
stateDescription: String? = null,
palette: SidebarPalette,
onClick: () -> Unit,
content: @Composable RowScope.() -> Unit,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.heightIn(min = 48.dp)
.clip(RoundedCornerShape(10.dp))
.background(if (selected == true) palette.selection else Color.Transparent)
.then(
if (selected == null) {
Modifier.clickable(role = Role.Button, onClick = onClick)
} else {
Modifier.selectable(selected = selected, role = Role.Button, onClick = onClick)
},
).then(
if (stateDescription == null) {
Modifier
} else {
Modifier.semantics { this.stateDescription = stateDescription }
},
).padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
content = content,
)
}
@@ -0,0 +1,351 @@
package ai.openclaw.app.ui
import ai.openclaw.app.GatewayAgentSummary
import ai.openclaw.app.GatewayConnectionDisplay
import ai.openclaw.app.chat.ChatSessionEntry
import ai.openclaw.app.i18n.nativeString
import ai.openclaw.app.selectableAgents
import ai.openclaw.app.ui.design.ClawTheme
import ai.openclaw.app.ui.design.OpenClawMascot
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material.icons.outlined.AccessTime
import androidx.compose.material.icons.outlined.ChatBubbleOutline
import androidx.compose.material.icons.outlined.Dashboard
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.luminance
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
internal enum class SidebarDestination(
val icon: ImageVector,
) {
Home(icon = Icons.Outlined.ChatBubbleOutline),
Overview(icon = Icons.Default.Home),
Usage(icon = Icons.Default.Storage),
Automations(icon = Icons.Outlined.AccessTime),
Sessions(icon = Icons.Outlined.Dashboard),
}
internal fun SidebarDestination.localizedLabel(): String =
when (this) {
SidebarDestination.Home -> nativeString("Home")
SidebarDestination.Overview -> nativeString("Overview")
SidebarDestination.Usage -> nativeString("Usage")
SidebarDestination.Automations -> nativeString("Automations")
SidebarDestination.Sessions -> nativeString("Threads")
}
internal val SidebarDestination.compactLabelSource: String
get() =
when (this) {
SidebarDestination.Home -> "Chat"
SidebarDestination.Overview -> "Status"
SidebarDestination.Usage -> "Usage"
SidebarDestination.Automations -> "Cron"
SidebarDestination.Sessions -> "Threads"
}
internal fun SidebarDestination.compactLocalizedLabel(): String = nativeString(compactLabelSource)
internal data class SidebarAgentRoster(
val selected: GatewayAgentSummary?,
val others: List<GatewayAgentSummary>,
)
internal fun sidebarAgentRoster(
agents: List<GatewayAgentSummary>,
selectedAgentId: String?,
): SidebarAgentRoster {
val selectable = agents.selectableAgents().distinctBy(GatewayAgentSummary::id)
val selected =
selectable.firstOrNull { it.id == selectedAgentId?.trim() }
?: selectable.firstOrNull()
return SidebarAgentRoster(
selected = selected,
others = selectable.filterNot { it.id == selected?.id },
)
}
internal fun sidebarRecentSessions(
sessions: List<ChatSessionEntry>,
query: String,
limit: Int = 8,
): List<ChatSessionEntry> {
val normalizedQuery = query.trim().lowercase()
return sessions
.asSequence()
.filter { it.archived != true }
.filter { session ->
normalizedQuery.isEmpty() ||
listOfNotNull(session.displayName, session.label, session.key, session.ownerAgentId)
.any { it.lowercase().contains(normalizedQuery) }
}.sortedWith(
compareByDescending<ChatSessionEntry> { it.pinned == true }
.thenByDescending { it.lastActivityAt ?: it.updatedAtMs ?: 0L }
.thenBy { it.key },
).take(limit.coerceAtLeast(0))
.toList()
}
internal fun sidebarSessionTitle(session: ChatSessionEntry): String =
session.displayName?.trim()?.takeIf(String::isNotEmpty)
?: session.label?.trim()?.takeIf(String::isNotEmpty)
?: session.key
internal fun sidebarAgentName(agent: GatewayAgentSummary): String = agent.name?.trim()?.takeIf(String::isNotEmpty) ?: agent.id
internal data class SidebarPalette(
val background: Color,
val elevated: Color,
val selection: Color,
val text: Color,
val muted: Color,
val hairline: Color,
)
@Composable
private fun sidebarPalette(): SidebarPalette {
val dark = ClawTheme.colors.canvas.luminance() < 0.5f
return if (dark) {
SidebarPalette(
background = Color.Black,
elevated = Color(0xFF1A1A1A),
selection = Color(0xFF232327),
text = Color(0xFFEDEDED),
muted = Color(0xFF8F8F8F),
hairline = Color.White.copy(alpha = 0.14f),
)
} else {
SidebarPalette(
background = Color(0xFFFAFAFA),
elevated = Color(0xFFF2F2F2),
selection = Color(0xFFEDEDED),
text = Color(0xFF171717),
muted = Color(0xFF8F8F8F),
hairline = Color.Black.copy(alpha = 0.08f),
)
}
}
@Composable
internal fun OpenClawSidebar(
agents: List<GatewayAgentSummary>,
selectedAgentId: String?,
sessions: List<ChatSessionEntry>,
activeSessionKey: String,
activeDestination: SidebarDestination?,
connection: GatewayConnectionDisplay,
showCloseButton: Boolean,
onClose: () -> Unit,
onOpenSettings: () -> Unit,
onSelectAgent: (String) -> Unit,
onSelectSession: (ChatSessionEntry) -> Unit,
onSelectDestination: (SidebarDestination) -> Unit,
) {
val palette = sidebarPalette()
val roster = sidebarAgentRoster(agents, selectedAgentId)
var query by rememberSaveable { mutableStateOf("") }
var agentsExpanded by remember { mutableStateOf(false) }
val recentSessions = sidebarRecentSessions(sessions, query)
val connectionLabel = gatewayStatusLabel(connection)
Column(
modifier =
Modifier
.fillMaxSize()
.background(palette.background)
.windowInsetsPadding(WindowInsets.safeDrawing)
.padding(horizontal = 14.dp, vertical = 10.dp),
) {
Column(
modifier =
Modifier
.weight(1f)
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
) {
Row(
modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
OpenClawMascot(modifier = Modifier.size(28.dp))
Text(
text = "OpenClaw",
style = ClawTheme.type.title.copy(fontSize = 18.sp, lineHeight = 22.sp),
color = palette.text,
modifier = Modifier.weight(1f),
maxLines = 1,
)
IconButton(onClick = onOpenSettings, modifier = Modifier.size(48.dp)) {
Icon(
imageVector = Icons.Default.Settings,
contentDescription = nativeString("Open Settings"),
tint = palette.text,
modifier = Modifier.size(20.dp),
)
}
if (showCloseButton) {
IconButton(onClick = onClose, modifier = Modifier.size(48.dp).testTag("sidebar-close")) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = nativeString("Hide Sidebar"),
tint = palette.text,
modifier = Modifier.size(20.dp),
)
}
}
}
SidebarSearchField(
query = query,
onQueryChange = { query = it },
palette = palette,
modifier = Modifier.padding(top = 4.dp, bottom = 12.dp),
)
SidebarSectionTitle(nativeString("Agents"), palette)
roster.selected?.let { selected ->
SidebarAgentRow(
agent = selected,
selected = true,
palette = palette,
onClick = { onSelectAgent(selected.id) },
)
}
if (roster.others.isNotEmpty()) {
Box {
SidebarActionRow(
label = nativeString("More Agents"),
icon = Icons.Default.KeyboardArrowDown,
palette = palette,
onClick = { agentsExpanded = true },
)
DropdownMenu(
expanded = agentsExpanded,
onDismissRequest = { agentsExpanded = false },
containerColor = palette.elevated,
) {
roster.others.forEach { agent ->
DropdownMenuItem(
text = {
Text(
text = sidebarAgentName(agent),
color = palette.text,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
onClick = {
agentsExpanded = false
onSelectAgent(agent.id)
},
)
}
}
}
}
SidebarSectionTitle(nativeString("Pages"), palette, modifier = Modifier.padding(top = 12.dp))
SidebarDestination.entries.forEach { destination ->
SidebarNavigationRow(
destination = destination,
selected = destination == activeDestination,
palette = palette,
onClick = { onSelectDestination(destination) },
)
}
SidebarSectionTitle(nativeString("Recent sessions"), palette, modifier = Modifier.padding(top = 12.dp))
if (recentSessions.isEmpty()) {
Text(
text = nativeString("No recent sessions"),
style = ClawTheme.type.caption,
color = palette.muted,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 12.dp),
)
} else {
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
recentSessions.forEach { session ->
SidebarSessionRow(
session = session,
selected = session.key == activeSessionKey,
palette = palette,
onClick = { onSelectSession(session) },
)
}
}
}
}
HorizontalDivider(color = palette.hairline)
Row(
modifier =
Modifier
.fillMaxWidth()
.heightIn(min = 48.dp)
.semantics(mergeDescendants = true) {
stateDescription = connectionLabel
}.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(9.dp),
) {
Box(
modifier =
Modifier
.size(8.dp)
.clip(CircleShape)
.background(if (connection.isConnected) ClawTheme.colors.success else palette.muted)
.clearAndSetSemantics {},
)
Text(
text = connectionLabel,
style = ClawTheme.type.caption,
color = palette.muted,
maxLines = 1,
)
}
}
}
@@ -0,0 +1,170 @@
package ai.openclaw.app.ui
import ai.openclaw.app.ui.design.ClawTheme
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.DrawerState
import androidx.compose.material3.Icon
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.Text
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.window.core.layout.WindowSizeClass.Companion.HEIGHT_DP_MEDIUM_LOWER_BOUND
import androidx.window.core.layout.WindowSizeClass.Companion.WIDTH_DP_EXPANDED_LOWER_BOUND
import androidx.window.core.layout.WindowSizeClass.Companion.WIDTH_DP_MEDIUM_LOWER_BOUND
internal const val adaptiveMediumWidthLowerBoundDp = 600f
internal const val adaptiveExpandedWidthLowerBoundDp = 840f
internal const val adaptiveMediumHeightLowerBoundDp = 480f
internal enum class AdaptiveNavigationMode {
Bar,
Rail,
Drawer,
}
internal fun adaptiveNavigationMode(
availableWidthDp: Float,
availableHeightDp: Float,
tabletop: Boolean = false,
): AdaptiveNavigationMode =
adaptiveNavigationMode(
widthAtLeastMedium = availableWidthDp >= adaptiveMediumWidthLowerBoundDp,
widthAtLeastExpanded = availableWidthDp >= adaptiveExpandedWidthLowerBoundDp,
heightAtLeastMedium = availableHeightDp >= adaptiveMediumHeightLowerBoundDp,
tabletop = tabletop,
)
private fun adaptiveNavigationMode(
widthAtLeastMedium: Boolean,
widthAtLeastExpanded: Boolean,
heightAtLeastMedium: Boolean,
tabletop: Boolean,
): AdaptiveNavigationMode =
when {
tabletop || !widthAtLeastMedium || !heightAtLeastMedium ->
AdaptiveNavigationMode.Bar
!widthAtLeastExpanded -> AdaptiveNavigationMode.Rail
else -> AdaptiveNavigationMode.Drawer
}
private fun AdaptiveNavigationMode.toNavigationSuiteType(): NavigationSuiteType =
when (this) {
AdaptiveNavigationMode.Bar -> NavigationSuiteType.NavigationBar
AdaptiveNavigationMode.Rail -> NavigationSuiteType.NavigationRail
AdaptiveNavigationMode.Drawer -> NavigationSuiteType.NavigationDrawer
}
internal fun adaptiveNavigationSuiteType(
navigationMode: AdaptiveNavigationMode,
compactNavigationVisible: Boolean,
): NavigationSuiteType =
if (navigationMode == AdaptiveNavigationMode.Bar && !compactNavigationVisible) {
NavigationSuiteType.None
} else {
navigationMode.toNavigationSuiteType()
}
internal fun alwaysShowAdaptiveNavigationLabel(navigationMode: AdaptiveNavigationMode): Boolean = navigationMode != AdaptiveNavigationMode.Bar
/**
* Material-owned adaptive navigation for every top-level destination.
*
* The primary destinations stay in one [NavigationSuiteScaffold] content slot as
* the window changes between bar, rail, and permanent drawer. Rich session and
* agent controls live in a native modal drawer so Material owns edge gestures,
* scrim dismissal, back handling, focus containment, and RTL behavior.
*/
@Composable
internal fun AdaptiveNavigationShell(
drawerState: DrawerState,
compactNavigationVisible: Boolean,
activeDestination: SidebarDestination?,
onSelectDestination: (SidebarDestination) -> Unit,
drawerContent: @Composable () -> Unit,
content: @Composable () -> Unit,
) {
val adaptiveInfo = currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true)
val navigationMode =
adaptiveNavigationMode(
widthAtLeastMedium =
adaptiveInfo.windowSizeClass.isWidthAtLeastBreakpoint(WIDTH_DP_MEDIUM_LOWER_BOUND),
widthAtLeastExpanded =
adaptiveInfo.windowSizeClass.isWidthAtLeastBreakpoint(WIDTH_DP_EXPANDED_LOWER_BOUND),
heightAtLeastMedium =
adaptiveInfo.windowSizeClass.isHeightAtLeastBreakpoint(HEIGHT_DP_MEDIUM_LOWER_BOUND),
tabletop = adaptiveInfo.windowPosture.isTabletop,
)
ModalNavigationDrawer(
drawerState = drawerState,
gesturesEnabled = true,
drawerContent = {
ModalDrawerSheet(
drawerState = drawerState,
modifier = Modifier.widthIn(max = 360.dp).testTag("adaptive-secondary-drawer"),
) {
drawerContent()
}
},
) {
NavigationSuiteScaffold(
navigationSuiteItems = {
SidebarDestination.entries.forEach { destination ->
val fullLabel = destination.localizedLabel()
val displayLabel =
if (navigationMode == AdaptiveNavigationMode.Bar) {
destination.compactLocalizedLabel()
} else {
fullLabel
}
item(
selected = destination == activeDestination,
onClick = { onSelectDestination(destination) },
icon = {
// Compact bars omit inactive labels, so their icons retain the full accessible name.
// Material clears this duplicate icon semantic when the selected label is composed.
Icon(
imageVector = destination.icon,
contentDescription =
fullLabel.takeIf {
navigationMode == AdaptiveNavigationMode.Bar
},
)
},
label = {
Text(
text = displayLabel,
modifier =
Modifier.clearAndSetSemantics {
contentDescription = fullLabel
},
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
alwaysShowLabel = alwaysShowAdaptiveNavigationLabel(navigationMode),
)
}
},
modifier = Modifier.fillMaxSize().testTag("adaptive-navigation-suite"),
layoutType =
adaptiveNavigationSuiteType(
navigationMode = navigationMode,
compactNavigationVisible = compactNavigationVisible,
),
containerColor = ClawTheme.colors.canvas,
contentColor = ClawTheme.colors.text,
content = content,
)
}
}
@@ -6,8 +6,6 @@ import ai.openclaw.app.ui.chat.rememberChatRealtimeTalkLauncher
import ai.openclaw.app.ui.design.ClawScaffold
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -18,6 +16,8 @@ import androidx.compose.ui.unit.dp
@Composable
internal fun UnifiedChatShellScreen(
viewModel: MainViewModel,
showSidebarButton: Boolean,
onOpenSidebar: () -> Unit,
onOpenSessions: () -> Unit,
onOpenDashboard: (String) -> Unit,
onOpenGatewaySettings: () -> Unit,
@@ -28,11 +28,13 @@ internal fun UnifiedChatShellScreen(
ClawScaffold(
contentPadding = PaddingValues(start = 0.dp, top = 8.dp, end = 0.dp, bottom = 0.dp),
contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
contentWindowInsets = WindowInsets.safeDrawing,
) {
ChatScreen(
viewModel = viewModel,
talkActive = talkModeEnabled,
showSidebarButton = showSidebarButton,
onOpenSidebar = onOpenSidebar,
onToggleTalk = {
if (talkModeEnabled) {
viewModel.setTalkModeEnabled(false)
@@ -112,6 +112,7 @@ import androidx.compose.material.icons.filled.GraphicEq
import androidx.compose.material.icons.filled.HourglassEmpty
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.MoreHoriz
import androidx.compose.material.icons.filled.MoreVert
@@ -252,6 +253,8 @@ internal fun shouldUseUserMessageDisclosure(
fun ChatScreen(
viewModel: MainViewModel,
talkActive: Boolean,
showSidebarButton: Boolean,
onOpenSidebar: () -> Unit,
onToggleTalk: () -> Unit,
onOpenSessions: () -> Unit,
onOpenDashboard: (String) -> Unit,
@@ -637,6 +640,8 @@ fun ChatScreen(
) {
ChatHeader(
sessionTitle = currentSessionTitle(sessionKey = sessionKey, sessions = sessions),
showSidebarButton = showSidebarButton,
onOpenSidebar = onOpenSidebar,
healthOk = healthOk,
pendingRunCount = pendingRunCount,
newChatEnabled = newChatEnabled,
@@ -1083,6 +1088,8 @@ internal fun canStartNewChat(
@Composable
private fun ChatHeader(
sessionTitle: String,
showSidebarButton: Boolean,
onOpenSidebar: () -> Unit,
healthOk: Boolean,
pendingRunCount: Int,
newChatEnabled: Boolean,
@@ -1105,6 +1112,13 @@ private fun ChatHeader(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
if (showSidebarButton) {
HeaderIcon(
icon = Icons.Default.Menu,
contentDescription = nativeString("Show Sidebar"),
onClick = onOpenSidebar,
)
}
OpenClawMascot(modifier = Modifier.size(25.dp))
Text(
text = nativeString("OpenClaw"),
@@ -193,10 +193,11 @@ internal fun ClawPlainIconButton(
icon: ImageVector,
contentDescription: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Surface(
onClick = onClick,
modifier = Modifier.size(ClawTheme.spacing.touchTarget),
modifier = modifier.size(ClawTheme.spacing.touchTarget),
shape = CircleShape,
color = Color.Transparent,
contentColor = ClawTheme.colors.text,
@@ -36,6 +36,7 @@ class AndroidLicenseNoticesTest {
assertEquals(
listOf(
"AndroidX Compose",
"AndroidX Media3",
"AndroidX Room",
"AndroidX Wear",
@@ -1,5 +1,6 @@
package ai.openclaw.app.ui
import ai.openclaw.app.GatewayConnectionDisplay
import ai.openclaw.app.GatewayConnectionProblem
import ai.openclaw.app.GatewayCronJobSummary
import ai.openclaw.app.GatewayExecApprovalSummary
@@ -133,6 +134,30 @@ class SettingsScreensTest {
assertEquals("Cannot reach gateway", gatewayStatusLabel("Connection failed", isConnected = false, gatewayConnectionProblem = problem))
}
@Test
fun gatewayStatusLabelPreservesPartialConnectivity() {
assertEquals(
"Connected (node offline)",
gatewayStatusLabel(
GatewayConnectionDisplay(
isConnected = true,
statusText = "Connected (node offline)",
problem = null,
),
),
)
assertEquals(
"Connected (operator offline)",
gatewayStatusLabel(
GatewayConnectionDisplay(
isConnected = false,
statusText = "Connected (operator offline)",
problem = null,
),
),
)
}
@Test
fun gatewaySetupResetCopyExplainsCredentialAndApprovalImpact() {
val text = gatewaySettingsSetupResetConfirmationText()
@@ -0,0 +1,185 @@
package ai.openclaw.app.ui
import ai.openclaw.app.GatewayAgentSummary
import ai.openclaw.app.chat.ChatSessionEntry
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class SidebarShellLogicTest {
@Test
fun compactWidthUsesNavigationBarAcrossTheSixHundredDpBoundary() {
assertEquals(AdaptiveNavigationMode.Bar, adaptiveNavigationMode(599f, 800f))
assertEquals(AdaptiveNavigationMode.Rail, adaptiveNavigationMode(600f, 800f))
}
@Test
fun expandedWidthUsesPermanentDrawerAcrossTheEightHundredFortyDpBoundary() {
assertEquals(AdaptiveNavigationMode.Rail, adaptiveNavigationMode(839f, 800f))
assertEquals(AdaptiveNavigationMode.Drawer, adaptiveNavigationMode(840f, 800f))
}
@Test
fun compactHeightUsesNavigationBarAcrossTheFourHundredEightyDpBoundary() {
assertEquals(AdaptiveNavigationMode.Bar, adaptiveNavigationMode(840f, 479f))
assertEquals(AdaptiveNavigationMode.Drawer, adaptiveNavigationMode(840f, 480f))
}
@Test
fun representativeAndroidWindowSizesMapToMaterialPatterns() {
assertEquals(AdaptiveNavigationMode.Bar, adaptiveNavigationMode(360f, 800f))
assertEquals(AdaptiveNavigationMode.Bar, adaptiveNavigationMode(800f, 360f))
assertEquals(AdaptiveNavigationMode.Rail, adaptiveNavigationMode(600f, 480f))
assertEquals(AdaptiveNavigationMode.Rail, adaptiveNavigationMode(839f, 899f))
assertEquals(AdaptiveNavigationMode.Drawer, adaptiveNavigationMode(841f, 701f))
assertEquals(AdaptiveNavigationMode.Drawer, adaptiveNavigationMode(1024f, 640f))
assertEquals(AdaptiveNavigationMode.Drawer, adaptiveNavigationMode(1280f, 800f))
assertEquals(AdaptiveNavigationMode.Drawer, adaptiveNavigationMode(1600f, 900f))
}
@Test
fun tabletopPostureAlwaysUsesReachableBottomNavigation() {
assertEquals(AdaptiveNavigationMode.Bar, adaptiveNavigationMode(1280f, 800f, tabletop = true))
}
@Test
fun hiddenCompactNavigationDoesNotHideRailOrPermanentDrawer() {
assertEquals(
NavigationSuiteType.None,
adaptiveNavigationSuiteType(AdaptiveNavigationMode.Bar, compactNavigationVisible = false),
)
assertEquals(
NavigationSuiteType.NavigationBar,
adaptiveNavigationSuiteType(AdaptiveNavigationMode.Bar, compactNavigationVisible = true),
)
assertEquals(
NavigationSuiteType.NavigationRail,
adaptiveNavigationSuiteType(AdaptiveNavigationMode.Rail, compactNavigationVisible = false),
)
assertEquals(
NavigationSuiteType.NavigationDrawer,
adaptiveNavigationSuiteType(AdaptiveNavigationMode.Drawer, compactNavigationVisible = false),
)
}
@Test
fun compactNavigationUsesShortDistinctLabels() {
val labels = SidebarDestination.entries.map(SidebarDestination::compactLabelSource)
assertEquals(listOf("Chat", "Status", "Usage", "Cron", "Threads"), labels)
assertEquals(labels.size, labels.distinct().size)
assertTrue(labels.all { it.length <= 7 })
}
@Test
fun compactNavigationOnlyShowsTheSelectedLabel() {
assertFalse(alwaysShowAdaptiveNavigationLabel(AdaptiveNavigationMode.Bar))
assertTrue(alwaysShowAdaptiveNavigationLabel(AdaptiveNavigationMode.Rail))
assertTrue(alwaysShowAdaptiveNavigationLabel(AdaptiveNavigationMode.Drawer))
}
@Test
fun agentRosterExcludesSystemAgentsAndKeepsTheSelectedAgentFirst() {
val roster =
sidebarAgentRoster(
agents =
listOf(
agent("main"),
agent("system", kind = "system"),
agent("ops"),
agent("main"),
),
selectedAgentId = "ops",
)
assertEquals("ops", roster.selected?.id)
assertEquals(listOf("main"), roster.others.map(GatewayAgentSummary::id))
}
@Test
fun emptySelectableAgentRosterHasNoSyntheticSelection() {
val roster = sidebarAgentRoster(listOf(agent("system", kind = "system")), selectedAgentId = "main")
assertNull(roster.selected)
assertEquals(emptyList<String>(), roster.others.map(GatewayAgentSummary::id))
}
@Test
fun recentSessionsExcludeArchivedRowsAndPrioritizePinsThenActivity() {
val rows =
sidebarRecentSessions(
sessions =
listOf(
session("old-pinned", activity = 1, pinned = true),
session("fresh", activity = 30),
session("archived", activity = 50, archived = true),
session("fresh-pinned", activity = 20, pinned = true),
),
query = "",
)
assertEquals(listOf("fresh-pinned", "old-pinned", "fresh"), rows.map(ChatSessionEntry::key))
}
@Test
fun recentSessionSearchCoversTitleLabelKeyAndOwnerBeforeApplyingLimit() {
val rows =
sidebarRecentSessions(
sessions =
listOf(
session("agent:ops:one", activity = 1, displayName = "Release planning", owner = "ops"),
session("agent:main:two", activity = 2, displayName = "Product notes", owner = "main"),
session("agent:main:three", activity = 3, label = "Ops handoff", owner = "main"),
),
query = "ops",
limit = 1,
)
assertEquals(listOf("agent:main:three"), rows.map(ChatSessionEntry::key))
}
@Test
fun recentSessionsStayBoundedInsideTheSharedScrollableSidebar() {
val rows =
sidebarRecentSessions(
sessions = (1L..12L).map { activity -> session("session-$activity", activity = activity) },
query = "",
)
assertEquals(8, rows.size)
}
private fun agent(
id: String,
kind: String? = null,
): GatewayAgentSummary =
GatewayAgentSummary(
id = id,
name = id,
emoji = null,
kind = kind,
)
private fun session(
key: String,
activity: Long,
pinned: Boolean = false,
archived: Boolean = false,
displayName: String? = null,
label: String? = null,
owner: String? = null,
): ChatSessionEntry =
ChatSessionEntry(
key = key,
updatedAtMs = activity,
lastActivityAt = activity,
pinned = pinned,
archived = archived,
displayName = displayName,
label = label,
ownerAgentId = owner,
)
}
+1
View File
@@ -49,6 +49,7 @@ androidx-camera-video = { module = "androidx.camera:camera-video", version.ref =
androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "androidx-compose-bom" }
androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" }
androidx-compose-material3 = { module = "androidx.compose.material3:material3" }
androidx-compose-material3-adaptive-navigation-suite = { module = "androidx.compose.material3:material3-adaptive-navigation-suite" }
androidx-compose-ui = { module = "androidx.compose.ui:ui" }
androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" }
androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
+5
View File
@@ -533,6 +533,11 @@ const ALLOWED_UI_LITERALS = new Map<string, ReadonlySet<string>>([
"apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceScreen.kt",
new Set(["${normalized.takeUtf16Safe(87)}..."]),
],
[
"apps/android/app/src/main/java/ai/openclaw/app/ui/SidebarShell.kt",
// Compose animation labels are tooling identifiers, not rendered copy.
new Set(["sidebar-content-translation"]),
],
[
"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatCommandControls.kt",
new Set(["/$name", "help"]),