mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
+888
-696
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.
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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" }
|
||||
|
||||
@@ -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"]),
|
||||
|
||||
Reference in New Issue
Block a user