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 <steipete@gmail.com>
This commit is contained in:
WhatsSkiLL
2026-08-17 10:22:16 +02:00
committed by GitHub
parent 42a46250d4
commit 657b6e8f49
5 changed files with 252 additions and 226 deletions
+8
View File
@@ -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"
}
]
},
@@ -103,29 +103,23 @@ internal fun SessionsScreen(
var deleteSessionTarget by
rememberSaveable(stateSaver = SessionActionTargetSaver) { mutableStateOf<SessionActionTarget?>(null) }
var searchText by rememberSaveable { mutableStateOf("") }
var searchResults by remember { mutableStateOf<List<ChatSessionEntry>>(emptyList()) }
var searchLoading by remember { mutableStateOf(false) }
val searchQuery = searchText.trim()
var renameGroupName by rememberSaveable { mutableStateOf<String?>(null) }
var deleteGroupName by rememberSaveable { mutableStateOf<String?>(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<ChatSessionEntry>,
val loading: Boolean,
)
/** Canonical debounced, gateway-backed search state shared by session browser surfaces. */
@Composable
internal fun rememberSessionBrowserSearchState(
viewModel: MainViewModel,
sessions: List<ChatSessionEntry>,
query: String,
archived: Boolean,
): SessionBrowserSearchState {
val normalizedQuery = query.trim()
var searchResults by remember { mutableStateOf<List<ChatSessionEntry>>(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<ChatSessionEntry>,
currentSessionKey: String,
filter: SessionFilter,
recentFirst: Boolean,
): List<ChatSessionEntry> {
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,
@@ -279,6 +279,7 @@ fun ShellScreen(
onSelectDestination = selectSidebarDestination,
drawerContent = {
OpenClawSidebar(
viewModel = viewModel,
agents = gatewayAgents,
selectedAgentId = chatSessionOwnerAgentId ?: gatewayDefaultAgentId,
sessions = chatSessions,
@@ -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<ChatSessionEntry>,
query: String,
limit: Int = 8,
): List<ChatSessionEntry> {
val normalizedQuery = query.trim().lowercase()
return sessions
): List<ChatSessionEntry> =
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<ChatSessionEntry> { 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<GatewayAgentSummary>,
selectedAgentId: String?,
sessions: List<ChatSessionEntry>,
@@ -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)
@@ -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)