mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-23 10:55:31 -06:00
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:
Generated
+8
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user