From 657b6e8f49a7f4e309dfd6ca1c90ad20503023ae Mon Sep 17 00:00:00 2001 From: WhatsSkiLL Date: Mon, 17 Aug 2026 10:22:16 +0200 Subject: [PATCH] feat(android): pin gateway-backed session search in the sidebar (#124338) * fix-android-chat-session-picker * fix-search-all-android-sessions * fix-native-i18n-inventory * fix-android-share-session-browser-policy * fix(android): show loading during session search * refactor(android): move gateway-backed session search into the pinned sidebar The sidebar header and search field no longer scroll away: they sit above the scrolling sections, and the search field is always visible instead of hidden behind a toggle. Queries now run through the shared session-browser search state (debounced gateway search with offline fallback) instead of a local filter over cached rows, and matching threads replace the section list while a query is active. The in-chat bottom-sheet picker is removed: the compact switcher's All button navigates straight to the Sessions screen again, and the sidebar owns in-context session search. The shared rememberSessionBrowserSearchState extraction from the Sessions screen is kept and gains the sidebar as its second consumer. * style(android): fix sidebar import ordering for ktlint --------- Co-authored-by: IWhatsskill <284122573+IWhatsskill@users.noreply.github.com> Co-authored-by: Peter Steinberger --- apps/.i18n/native-source.json | 8 + .../java/ai/openclaw/app/ui/SessionsScreen.kt | 130 +++++--- .../java/ai/openclaw/app/ui/ShellScreen.kt | 1 + .../java/ai/openclaw/app/ui/SidebarContent.kt | 304 +++++++++--------- .../openclaw/app/ui/SidebarShellLogicTest.kt | 35 -- 5 files changed, 252 insertions(+), 226 deletions(-) diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index c8cd93197032..e900cbd91c36 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -9922,6 +9922,10 @@ { "kind": "ui-call", "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt" + }, + { + "kind": "ui-call", + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SidebarContent.kt" } ] }, @@ -14861,6 +14865,10 @@ { "kind": "ui-call", "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt" + }, + { + "kind": "ui-call", + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SidebarContent.kt" } ] }, diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt index 0f16c802d2b8..823dc6165ba5 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt @@ -103,29 +103,23 @@ internal fun SessionsScreen( var deleteSessionTarget by rememberSaveable(stateSaver = SessionActionTargetSaver) { mutableStateOf(null) } var searchText by rememberSaveable { mutableStateOf("") } - var searchResults by remember { mutableStateOf>(emptyList()) } - var searchLoading by remember { mutableStateOf(false) } - val searchQuery = searchText.trim() var renameGroupName by rememberSaveable { mutableStateOf(null) } var deleteGroupName by rememberSaveable { mutableStateOf(null) } var newGroupDialogVisible by rememberSaveable { mutableStateOf(false) } + val searchState = + rememberSessionBrowserSearchState( + viewModel = viewModel, + sessions = sessions, + query = searchText, + archived = filter == SessionFilter.Archived, + ) val visibleSessions = - (if (searchQuery.isEmpty()) sessions else searchResults) - .let { rows -> - when (filter) { - SessionFilter.Recent -> rows.filter { it.archived != true } - SessionFilter.Current -> rows.filter { it.key == chatSessionKey && it.archived != true } - // Gate on the entry's own archived flag so the pre-toggle active list can - // never render with archived-only actions while the refetch is in flight. - SessionFilter.Archived -> rows.filter { it.archived == true } - } - }.let { rows -> - if (recentFirst) { - rows.sortedByDescending { it.lastActivityAt ?: it.updatedAtMs ?: 0L } - } else { - rows.sortedBy { it.lastActivityAt ?: it.updatedAtMs ?: 0L } - } - } + resolveSessionBrowserEntries( + entries = searchState.entries, + currentSessionKey = chatSessionKey, + filter = filter, + recentFirst = recentFirst, + ) val storedGroups by viewModel.sessionCustomGroups.collectAsState() val sections = groupSessionEntries(visibleSessions, knownGroups = storedGroups) // Stored group names stay offered as move targets even while they have no members. @@ -146,30 +140,6 @@ internal fun SessionsScreen( } } - // Keyed on the live session list too: row actions (pin/rename/archive/delete) - // refresh live state, which re-runs the search so results never go stale. - LaunchedEffect(searchQuery, filter, sessions) { - if (searchQuery.isEmpty()) { - searchResults = emptyList() - searchLoading = false - return@LaunchedEffect - } - searchResults = emptyList() - searchLoading = true - try { - // Debounce keystrokes; the key change cancels superseded fetches, and the - // controller falls back to local filtering when the gateway is unreachable. - delay(250) - searchResults = - viewModel.fetchChatSessionList( - search = searchQuery, - archived = filter == SessionFilter.Archived, - ) - } finally { - searchLoading = false - } - } - ClawScaffold( contentPadding = PaddingValues(start = 16.dp, top = 10.dp, end = 16.dp, bottom = 4.dp), contentWindowInsets = WindowInsets.safeDrawing, @@ -306,7 +276,7 @@ internal fun SessionsScreen( modifier = Modifier.fillParentMaxHeight(0.56f).fillMaxWidth(), contentAlignment = Alignment.Center, ) { - when (sessionEmptyMode(searchQuery, searchLoading)) { + when (sessionEmptyMode(searchState.query, searchState.loading)) { SessionEmptyMode.SearchLoading -> ClawLoadingState(title = nativeString("Searching threads")) SessionEmptyMode.SearchNoMatches -> ClawEmptyState( @@ -874,12 +844,82 @@ private fun SessionMiniTag(text: String) { } } -private enum class SessionFilter { +internal enum class SessionFilter { Recent, Current, Archived, } +internal data class SessionBrowserSearchState( + val query: String, + val entries: List, + val loading: Boolean, +) + +/** Canonical debounced, gateway-backed search state shared by session browser surfaces. */ +@Composable +internal fun rememberSessionBrowserSearchState( + viewModel: MainViewModel, + sessions: List, + query: String, + archived: Boolean, +): SessionBrowserSearchState { + val normalizedQuery = query.trim() + var searchResults by remember { mutableStateOf>(emptyList()) } + var searchLoading by remember { mutableStateOf(false) } + + // Keyed on the live list too: row mutations refresh sessions and re-run an active search. + LaunchedEffect(normalizedQuery, archived, sessions) { + if (normalizedQuery.isEmpty()) { + searchResults = emptyList() + searchLoading = false + return@LaunchedEffect + } + searchResults = emptyList() + searchLoading = true + try { + // Key changes cancel superseded debounce/fetch work. The controller owns + // gateway search plus the offline local-filter fallback. + delay(250) + searchResults = + viewModel.fetchChatSessionList( + search = normalizedQuery, + archived = archived, + ) + } finally { + searchLoading = false + } + } + + return SessionBrowserSearchState( + query = normalizedQuery, + entries = if (normalizedQuery.isEmpty()) sessions else searchResults, + loading = searchLoading, + ) +} + +/** Applies the canonical active/current/archived filter and chronological ordering. */ +internal fun resolveSessionBrowserEntries( + entries: List, + currentSessionKey: String, + filter: SessionFilter, + recentFirst: Boolean, +): List { + val filtered = + when (filter) { + SessionFilter.Recent -> entries.filter { it.archived != true } + SessionFilter.Current -> entries.filter { it.key == currentSessionKey && it.archived != true } + // Gate on the entry's own archived flag so a pre-toggle active list can + // never render with archived-only actions while a refetch is in flight. + SessionFilter.Archived -> entries.filter { it.archived == true } + } + return if (recentFirst) { + filtered.sortedByDescending { it.lastActivityAt ?: it.updatedAtMs ?: 0L } + } else { + filtered.sortedBy { it.lastActivityAt ?: it.updatedAtMs ?: 0L } + } +} + internal fun sessionListSubtitle( session: ChatSessionEntry, fallback: String, diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt index 82d7af6a84da..13754f43204b 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt @@ -279,6 +279,7 @@ fun ShellScreen( onSelectDestination = selectSidebarDestination, drawerContent = { OpenClawSidebar( + viewModel = viewModel, agents = gatewayAgents, selectedAgentId = chatSessionOwnerAgentId ?: gatewayDefaultAgentId, sessions = chatSessions, diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/SidebarContent.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/SidebarContent.kt index f7553b2e969a..d507a1c977eb 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/SidebarContent.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/SidebarContent.kt @@ -2,6 +2,7 @@ package ai.openclaw.app.ui import ai.openclaw.app.GatewayAgentSummary import ai.openclaw.app.GatewayConnectionDisplay +import ai.openclaw.app.MainViewModel import ai.openclaw.app.chat.ChatSessionEntry import ai.openclaw.app.i18n.nativeString import ai.openclaw.app.selectableAgents @@ -27,7 +28,6 @@ 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.Search import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Storage import androidx.compose.material.icons.outlined.AccessTime @@ -40,7 +40,6 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -49,12 +48,9 @@ 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.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.luminance import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.semantics @@ -115,24 +111,17 @@ internal fun sidebarAgentRoster( internal fun sidebarRecentSessions( sessions: List, - query: String, limit: Int = 8, -): List { - val normalizedQuery = query.trim().lowercase() - return sessions +): List = + 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( + .sortedWith( compareByDescending { it.pinned == true } .thenByDescending { it.lastActivityAt ?: it.updatedAtMs ?: 0L } .thenBy { it.key }, - ).let { if (normalizedQuery.isEmpty()) it.take(limit.coerceAtLeast(0)) else it } + ).take(limit.coerceAtLeast(0)) .toList() -} internal fun sidebarSessionTitle(session: ChatSessionEntry): String = session.displayName?.trim()?.takeIf(String::isNotEmpty) @@ -176,6 +165,7 @@ private fun sidebarPalette(): SidebarPalette { @Composable internal fun OpenClawSidebar( + viewModel: MainViewModel, agents: List, selectedAgentId: String?, sessions: List, @@ -192,12 +182,25 @@ internal fun OpenClawSidebar( val palette = sidebarPalette() val roster = sidebarAgentRoster(agents, selectedAgentId) var query by rememberSaveable { mutableStateOf("") } - var isSearchActive by rememberSaveable { mutableStateOf(false) } var agentsExpanded by remember { mutableStateOf(false) } - val searchFocusRequester = remember { FocusRequester() } - val focusManager = LocalFocusManager.current - val recentSessions = sidebarRecentSessions(sessions, query) + val recentSessions = sidebarRecentSessions(sessions) val connectionLabel = gatewayStatusLabel(connection) + // Canonical debounced gateway search shared with the Sessions browser; the + // controller falls back to filtering cached rows when the gateway is offline. + val searchState = + rememberSessionBrowserSearchState( + viewModel = viewModel, + sessions = sessions, + query = query, + archived = false, + ) + val searchResults = + resolveSessionBrowserEntries( + entries = searchState.entries, + currentSessionKey = activeSessionKey, + filter = SessionFilter.Recent, + recentFirst = true, + ) Column( modifier = @@ -207,6 +210,47 @@ internal fun OpenClawSidebar( .windowInsetsPadding(WindowInsets.safeDrawing) .padding(horizontal = 14.dp, vertical = 10.dp), ) { + // Header and search stay pinned above the scrolling sections so search is + // always reachable no matter how far the section list scrolls. + 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 = 10.dp), + ) + Column( modifier = Modifier @@ -214,143 +258,111 @@ internal fun OpenClawSidebar( .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 = { - isSearchActive = !isSearchActive - if (!isSearchActive) { - query = "" - focusManager.clearFocus() - } - }, - modifier = Modifier.size(48.dp).testTag("sidebar-search-toggle"), - ) { - Icon( - imageVector = Icons.Default.Search, - contentDescription = sidebarSearchLabel(), - tint = palette.text, - modifier = Modifier.size(20.dp), - ) - } - 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), + if (searchState.query.isNotEmpty()) { + SidebarSectionTitle(nativeString("Threads"), palette) + when (sessionEmptyMode(searchState.query, searchState.loading)) { + SessionEmptyMode.SearchLoading -> + Text( + text = nativeString("Searching threads"), + style = ClawTheme.type.caption, + color = palette.muted, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 12.dp), ) + else -> + if (searchResults.isEmpty()) { + Text( + text = nativeString("No matching threads"), + style = ClawTheme.type.caption, + color = palette.muted, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 12.dp), + ) + } else { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + searchResults.forEach { session -> + SidebarSessionRow( + session = session, + selected = session.key == activeSessionKey, + palette = palette, + onClick = { onSelectSession(session) }, + ) + } + } + } + } + } else { + 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) + }, + ) + } + } } } - } - if (isSearchActive) { - LaunchedEffect(searchFocusRequester) { - searchFocusRequester.requestFocus() - } - SidebarSearchField( - query = query, - onQueryChange = { query = it }, - palette = palette, - modifier = - Modifier - .focusRequester(searchFocusRequester) - .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, + SidebarSectionTitle(nativeString("Pages"), palette, modifier = Modifier.padding(top = 12.dp)) + SidebarDestination.entries.forEach { destination -> + SidebarNavigationRow( + destination = destination, + selected = destination == activeDestination, palette = palette, - onClick = { agentsExpanded = true }, + onClick = { onSelectDestination(destination) }, ) - 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("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) }, ) } } } } - - 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) diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/SidebarShellLogicTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/SidebarShellLogicTest.kt index c33d3d7f9c53..28395a2905be 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/ui/SidebarShellLogicTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/SidebarShellLogicTest.kt @@ -118,51 +118,16 @@ class SidebarShellLogicTest { 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 recentSessionSearchCoversTitleLabelKeyAndOwnerWithoutApplyingRecentLimit() { - val rows = - sidebarRecentSessions( - sessions = - listOf( - session("agent:main:title", activity = 1, displayName = "Ops planning"), - session("agent:main:label", activity = 2, label = "Ops handoff"), - session("agent:ops:key", activity = 3), - session("agent:main:owner", activity = 4, owner = "ops"), - session("agent:main:other", activity = 5, displayName = "Product notes"), - ), - query = "ops", - limit = 1, - ) - - assertEquals( - listOf("agent:main:owner", "agent:ops:key", "agent:main:label", "agent:main:title"), - rows.map(ChatSessionEntry::key), - ) - } - - @Test - fun activeSearchReturnsAllMatchesBeyondTheRecentSessionLimit() { - val rows = - sidebarRecentSessions( - sessions = (1L..12L).map { activity -> session("ops-session-$activity", activity = activity) }, - query = "ops", - ) - - assertEquals(12, rows.size) - } - @Test fun recentSessionsStayBoundedInsideTheSharedScrollableSidebar() { val rows = sidebarRecentSessions( sessions = (1L..12L).map { activity -> session("session-$activity", activity = activity) }, - query = "", ) assertEquals(8, rows.size)