Compare commits

..
Author SHA1 Message Date
Bailey Dixon 0509e122ff docs(android): include Sessions sidebar review capture 2026-09-25 17:01:01 -04:00
Bailey Dixon 57cc10835f fix(android): complete wide Sessions sidebar behavior 2026-09-23 19:28:15 -04:00
Devin 8b12a056eb feat(sessions): optional pinned Sessions sidebar on wide layouts
Port of 85322931 onto android-v1.17.0 architecture:

- Add persisted sessions_sidebar_pinned preference (DataStore) in
  ConnectionViewModel.
- Share the sessions panel between presentations: SessionDrawerContent
  now renders the identical panel either as the modal ModalDrawerSheet
  or as a pinned 320dp Surface sidebar (asSidebar), with a header
  pin/unpin control; filters, search, rows, and dialogs stay defined
  once.
- Wide layouts (available width >= 840dp via BoxWithConstraints) render
  the sidebar at the leading edge when pinned; compact layouts keep the
  unchanged modal drawer and hide the pin affordance.
- Pinning closes an open modal drawer and refreshes sessions; drawer
  gestures are disabled while pinned (resolveDrawerGesturesEnabled,
  composed with the supervised history rule) so a swipe cannot stack a
  second sessions surface.
- resolveSessionSidebarLayout and resolveDrawerGesturesEnabled are pure
  functions covered by 9 JVM tests.
2026-09-23 18:34:26 -04:00
16 changed files with 400 additions and 24 deletions
@@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.height
@@ -34,6 +35,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.shape.CircleShape
@@ -53,6 +55,7 @@ import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Groups
import androidx.compose.material.icons.filled.Code
import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Star
@@ -102,6 +105,7 @@ import androidx.compose.ui.platform.LocalLocale
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
@@ -199,6 +203,43 @@ internal fun resolveSessionDrawerFilter(
else -> filter
}
/**
* Where the Sessions surface renders for a given available width and pin
* intent and history policy. Pure function so these boundaries are JVM-testable.
*
* - Pinned intent only takes effect at wide widths (>= [SESSIONS_SIDEBAR_WIDTH_THRESHOLD_DP]).
* - Below the threshold the modal drawer renders (and the pin affordance is
* hidden) while the persisted intent survives for the next wide layout.
* - Supervised history restrictions also suspend the sidebar without clearing
* the saved preference.
*/
enum class SessionSidebarLayout { Sidebar, Modal }
const val SESSIONS_SIDEBAR_WIDTH_THRESHOLD_DP = 840
const val SESSIONS_SIDEBAR_WIDTH_DP = 320
fun resolveSessionSidebarLayout(
availableWidthDp: Int,
pinned: Boolean,
historyAllowed: Boolean = true,
): SessionSidebarLayout =
if (pinned && historyAllowed && availableWidthDp >= SESSIONS_SIDEBAR_WIDTH_THRESHOLD_DP) {
SessionSidebarLayout.Sidebar
} else {
SessionSidebarLayout.Modal
}
/**
* Whether the modal drawer's edge-swipe gestures should be active. Pure so the
* supervised rule stays JVM-testable. Edge-swipe opens the modal drawer, so it
* must be off when the pinned sidebar is already the sessions surface —
* otherwise a swipe stacks a second sessions UI on top of the sidebar.
*/
fun resolveDrawerGesturesEnabled(
supervisedHistoryAllowed: Boolean,
pinnedSidebar: Boolean,
): Boolean = supervisedHistoryAllowed && !pinnedSidebar
internal enum class SessionDrawerLoadPresentation {
Loading,
Unavailable,
@@ -269,6 +310,16 @@ fun SessionDrawerContent(
onRenameProfileSession: ((String, String, String) -> Unit)? = null,
onSetProfileSessionPinned: ((String, String, Boolean) -> Unit)? = null,
onSetProfileSessionArchived: ((String, String, Boolean) -> Unit)? = null,
/**
* True renders the sessions UI as a persistent 320dp sidebar (wide layout);
* false keeps the existing modal drawer sheet presentation. Default false
* preserves the compact drawer behavior and every existing call site.
*/
asSidebar: Boolean = false,
/** Pin/unpin affordance below the panel header. Null hides the control. */
onTogglePin: (() -> Unit)? = null,
/** Whether the sidebar pin is currently active (drives the control's state). */
pinned: Boolean = false,
) {
var renameDialogTarget by remember { mutableStateOf<Pair<ProfileSessionRow, Boolean>?>(null) }
var newThreadDialog by remember { mutableStateOf(false) }
@@ -434,11 +485,7 @@ fun SessionDrawerContent(
}
}
ModalDrawerSheet(
modifier = Modifier.width(320.dp),
drawerContainerColor = RelayRefresh.Background,
drawerContentColor = RelayRefresh.Ink,
) {
val panel: @Composable () -> Unit = {
Column(modifier = Modifier.padding(16.dp)) {
// Header
Row(
@@ -563,6 +610,31 @@ fun SessionDrawerContent(
}
}
}
// Keep the presentation control on its own 48dp row so the title
// and existing actions still fit within a 320dp panel at large text.
onTogglePin?.let { togglePin ->
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 48.dp)
.toggleable(value = pinned, role = Role.Switch, onValueChange = { togglePin() })
.padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Filled.PushPin,
contentDescription = null,
tint = if (pinned) RelayRefresh.Amber else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(if (pinned) R.string.drawer_unpin_sidebar else R.string.drawer_pin_sidebar),
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.labelLarge,
)
}
}
drawerSubtitle?.takeIf { it.isNotBlank() }?.let { subtitle ->
Spacer(modifier = Modifier.height(2.dp))
Text(
@@ -962,6 +1034,30 @@ fun SessionDrawerContent(
}
}
// Present the identical sessions panel either as the modal drawer sheet
// (compact layouts, unchanged) or as a persistent 320dp leading sidebar
// (wide layouts with the pin active). Filters, search, rows, and dialogs
// are defined once above so both presentations stay behaviorally identical.
if (asSidebar) {
Surface(
modifier = Modifier.width(320.dp).fillMaxHeight(),
color = RelayRefresh.Background,
contentColor = RelayRefresh.Ink,
) {
// ModalDrawerSheet supplies a ColumnScope. Surface does not, so
// stack the shared header and list explicitly in this presentation.
Column(modifier = Modifier.fillMaxHeight()) { panel() }
}
} else {
ModalDrawerSheet(
modifier = Modifier.width(320.dp),
drawerContainerColor = RelayRefresh.Background,
drawerContentColor = RelayRefresh.Ink,
) {
panel()
}
}
if (customizeOpen) {
SessionDrawerOptionsDialog(
options = viewOptions,
@@ -22,6 +22,7 @@ import com.hermesandroid.relay.ui.components.ChatDebugOverlay
import com.hermesandroid.relay.ui.components.chatDebugHeaderGesture
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
@@ -264,6 +265,11 @@ import com.hermesandroid.relay.ui.components.ThinkingIndicatorStyle
import com.hermesandroid.relay.ui.components.ThinkingMatrixColor
import com.hermesandroid.relay.ui.components.ThinkingMatrixPattern
import com.hermesandroid.relay.ui.components.SessionDrawerContent
import com.hermesandroid.relay.ui.components.SESSIONS_SIDEBAR_WIDTH_DP
import com.hermesandroid.relay.ui.components.SESSIONS_SIDEBAR_WIDTH_THRESHOLD_DP
import com.hermesandroid.relay.ui.components.SessionSidebarLayout
import com.hermesandroid.relay.ui.components.resolveDrawerGesturesEnabled
import com.hermesandroid.relay.ui.components.resolveSessionSidebarLayout
import com.hermesandroid.relay.ui.components.ProfileSessionRow
import com.hermesandroid.relay.ui.components.ProvisionalThreadRow
import com.hermesandroid.relay.ui.components.ProfileDisplayManagerDialog
@@ -1531,6 +1537,9 @@ fun ChatScreen(
val listState = rememberLazyListState()
val userScrolledAwayState = remember(currentSessionId) { mutableStateOf(false) }
val drawerState = rememberDrawerState(DrawerValue.Closed)
// Pinned Sessions sidebar intent (persisted). Only takes effect when the
// available chat width is wide enough; see resolveSessionSidebarLayout.
val sessionsSidebarPinned by connectionViewModel.sessionsSidebarPinned.collectAsState()
LaunchedEffect(chatViewModel, drawerState) {
chatViewModel.sessionDirectoryRefreshRequests.collect {
if (drawerState.isOpen || allProfileSessions.isNotEmpty()) {
@@ -2474,14 +2483,27 @@ fun ChatScreen(
val hasLiveConversationSurface = messages.isNotEmpty() || isStreaming
val isChatConnecting = chatConnectState == ChatConnectState.Connecting &&
!hasLiveConversationSurface
val sessionsHistoryAllowed = !supervised || supervisedPolicy.capabilities.conversationHistory
val toggleSessionsSidebarPin: () -> Unit = {
scope.launch {
if (!sessionsSidebarPinned) drawerState.close()
connectionViewModel.setSessionsSidebarPinned(!sessionsSidebarPinned)
}
}
ModalNavigationDrawer(
drawerState = drawerState,
// Material routes scrim taps through the drawer's gesture handler.
// Keep it enabled so tapping outside always dismisses the drawer; the
// voice overlay already owns input while voice mode is visible.
gesturesEnabled = !supervised || supervisedPolicy.capabilities.conversationHistory,
drawerContent = {
BoxWithConstraints {
// Available chat width in dp drives the wide-layout threshold; the
// pure resolver decides Sidebar vs Modal from it + the pin intent.
val availableWidthDp = maxWidth.value.toInt()
val isWideLayout =
availableWidthDp >= SESSIONS_SIDEBAR_WIDTH_THRESHOLD_DP
val sidebarLayout = resolveSessionSidebarLayout(
availableWidthDp,
sessionsSidebarPinned,
historyAllowed = sessionsHistoryAllowed,
)
val isPinnedSidebar = sidebarLayout == SessionSidebarLayout.Sidebar
val sessionsDrawerContent: @Composable (Boolean) -> Unit = { renderAsSidebar ->
val drawerProfileName = explicitBindingProfileName ?: effectiveProfile?.name
val drawerTitle = if (drawerProfileName != null) {
stringResource(R.string.chat_profile_sessions, agentDisplayName)
@@ -2556,7 +2578,7 @@ fun ChatScreen(
isLoadingMore = isLoadingMoreSessions,
hasMore = hasMoreSessions,
loadMoreFailed = sessionPageLoadFailed,
isOpen = drawerState.isOpen,
isOpen = drawerState.isOpen || isPinnedSidebar,
activityStates = sessionActivityStates,
animationEnabled = animationEnabled,
autoTitlesSupported = serverAutoTitles,
@@ -2748,8 +2770,51 @@ fun ChatScreen(
}
}
},
asSidebar = renderAsSidebar,
// Pin affordance lives below the panel header; shown only
// where the pin can take effect (wide layout).
onTogglePin = if (isWideLayout && sessionsHistoryAllowed) toggleSessionsSidebarPin else null,
pinned = isPinnedSidebar,
)
}
// A pinned sidebar behaves as an always-open drawer: entering the
// wide pinned layout re-syncs the session list the same way opening
// the modal drawer does. It also replaces the modal drawer as THE
// sessions surface, so a drawer left open at pin time is closed —
// otherwise it overlays a duplicate sessions panel.
LaunchedEffect(isPinnedSidebar) {
// Unpinning returns to the closed, on-demand drawer as well.
drawerState.close()
if (isPinnedSidebar) {
chatViewModel.setSessionActivityDrawerOpen(true)
chatViewModel.refreshSessionsIfStale()
}
}
Box(modifier = Modifier.fillMaxSize()) {
if (isPinnedSidebar) {
Box(modifier = Modifier.width(SESSIONS_SIDEBAR_WIDTH_DP.dp)) {
sessionsDrawerContent(true)
}
}
Box(
modifier = Modifier.fillMaxSize()
.padding(start = if (isPinnedSidebar) SESSIONS_SIDEBAR_WIDTH_DP.dp else 0.dp)
) {
ModalNavigationDrawer(
drawerState = drawerState,
// Material routes scrim taps through the drawer's gesture handler.
// Keep it enabled so tapping outside always dismisses the drawer; the
// voice overlay already owns input while voice mode is visible.
// Edge-swipe is also disabled while the pinned sidebar owns the
// leading edge, so a swipe can't open a second sessions surface.
gesturesEnabled = resolveDrawerGesturesEnabled(
supervisedHistoryAllowed = sessionsHistoryAllowed,
pinnedSidebar = isPinnedSidebar,
),
// Do not compose a second session browser behind the sidebar.
drawerContent = {
if (!isPinnedSidebar) sessionsDrawerContent(false)
},
) {
val isDarkTheme = LocalBrand.current.isDark
@@ -2766,11 +2831,15 @@ fun ChatScreen(
TopAppBar(
modifier = Modifier.onSizeChanged { chatHeaderHeightPx = it.height },
navigationIcon = {
if (!supervised || supervisedPolicy.capabilities.conversationHistory) {
// While the pinned sidebar is the sessions surface the
// hamburger would only stack a second modal sessions
// drawer over it — the pin/unpin affordance lives in the
// sidebar header instead, so the control is omitted.
if (!isPinnedSidebar && sessionsHistoryAllowed) {
IconButton(onClick = { scope.launch { drawerState.open() } }) {
Icon(Icons.Filled.Menu, contentDescription = stringResource(R.string.cd_sessions))
}
} else if (supervisedPolicy.capabilities.newChat) {
} else if (supervised && !sessionsHistoryAllowed && supervisedPolicy.capabilities.newChat) {
IconButton(onClick = { chatViewModel.createNewChat() }) {
Icon(Icons.Filled.Edit, contentDescription = "New chat")
}
@@ -4910,7 +4979,11 @@ fun ChatScreen(
}
}
// Voice mode overlay — covers the whole Box when voiceUiState.voiceMode
} // end chat Box
} // end ModalNavigationDrawer
} // end padded chat Box
// Voice mode covers both Chat and the pinned Sessions sidebar.
AnimatedVisibility(
visible = voiceUiState.voiceMode,
enter = fadeIn(),
@@ -4981,7 +5054,7 @@ fun ChatScreen(
// === END v0.4.1 ===
)
}
} // end Box
}
}
// Command palette bottom sheet
@@ -1041,6 +1041,8 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
// Chat scroll behavior
private val KEY_SMOOTH_AUTO_SCROLL = booleanPreferencesKey("smooth_auto_scroll")
private val KEY_CLOSE_DRAWER_ON_SEND = booleanPreferencesKey("close_drawer_on_send")
private val KEY_SESSIONS_SIDEBAR_PINNED =
booleanPreferencesKey("sessions_sidebar_pinned")
private val KEY_KEEP_COMPOSER_FOCUSED_ON_SEND =
booleanPreferencesKey("keep_composer_focused_on_send")
@@ -3158,6 +3160,22 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
}
}
// Pinned Sessions sidebar (wide layout only). Persisted intent: when the
// chat surface is >= 840dp wide the drawer renders as an always-visible
// 320dp sidebar instead of a modal sheet; below the threshold the modal
// drawer behavior applies and the intent waits for the next wide layout.
val sessionsSidebarPinned: StateFlow<Boolean> = application.relayDataStore.data
.map { it[KEY_SESSIONS_SIDEBAR_PINNED] ?: false }
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
fun setSessionsSidebarPinned(pinned: Boolean) {
viewModelScope.launch {
getApplication<Application>().relayDataStore.edit { prefs ->
prefs[KEY_SESSIONS_SIDEBAR_PINNED] = pinned
}
}
}
// Keep the text composer focused after send. Default ON matches mobile
// chat convention: quick follow-up messages should not require retapping
// the input. Turning it off drops the keyboard after a successful send.
@@ -944,6 +944,8 @@
<string name="drawer_new_chat">Novo Chat</string>
<string name="drawer_search_placeholder">Pesquisar sessões ou id...</string>
<string name="drawer_search_sessions">Pesquisar sessões</string>
<string name="drawer_pin_sidebar">Fixar barra lateral de sessões</string>
<string name="drawer_unpin_sidebar">Desfixar barra lateral de sessões</string>
<string name="drawer_activity_working">Em andamento</string>
<string name="drawer_activity_needs_input">Precisa de resposta</string>
<string name="drawer_activity_starting">Iniciando</string>
@@ -987,6 +987,8 @@
<string name="drawer_new_chat">新对话</string>
<string name="drawer_search_placeholder">搜索会话或 ID…</string>
<string name="drawer_search_sessions">搜索会话</string>
<string name="drawer_pin_sidebar">固定会话侧边栏</string>
<string name="drawer_unpin_sidebar">取消固定会话侧边栏</string>
<string name="drawer_activity_working">正在处理</string>
<string name="drawer_activity_needs_input">需要输入</string>
<string name="drawer_activity_starting">正在启动</string>
+2
View File
@@ -993,6 +993,8 @@
<string name="drawer_new_chat">Neuer Chat</string>
<string name="drawer_search_placeholder">Sitzungen oder ID suchen...</string>
<string name="drawer_search_sessions">Sitzungen durchsuchen</string>
<string name="drawer_pin_sidebar">Sitzungsleiste anheften</string>
<string name="drawer_unpin_sidebar">Sitzungsleiste lösen</string>
<string name="drawer_activity_working">Wird bearbeitet</string>
<string name="drawer_activity_needs_input">Eingabe erforderlich</string>
<string name="drawer_activity_starting">Wird gestartet</string>
+2
View File
@@ -908,6 +908,8 @@
<string name="drawer_new_chat">Nuevo chat</string>
<string name="drawer_search_placeholder">Buscar sesiones o id...</string>
<string name="drawer_search_sessions">Buscar sesiones</string>
<string name="drawer_pin_sidebar">Fijar barra lateral de sesiones</string>
<string name="drawer_unpin_sidebar">Desfijar barra lateral de sesiones</string>
<string name="drawer_activity_working">En curso</string>
<string name="drawer_activity_needs_input">Requiere intervención</string>
<string name="drawer_activity_starting">Iniciando</string>
+2
View File
@@ -1003,6 +1003,8 @@
<string name="drawer_new_chat">新しいチャット</string>
<string name="drawer_search_placeholder">セッションまたは ID を検索...</string>
<string name="drawer_search_sessions">セッションを検索</string>
<string name="drawer_pin_sidebar">セッションサイドバーをピン留め</string>
<string name="drawer_unpin_sidebar">セッションサイドバーのピン留めを解除</string>
<string name="drawer_activity_working">処理中</string>
<string name="drawer_activity_needs_input">入力が必要</string>
<string name="drawer_activity_starting">開始中</string>
+2
View File
@@ -1022,6 +1022,8 @@
<string name="drawer_new_chat">Новый чат</string>
<string name="drawer_search_placeholder">Поиск сессий или id...</string>
<string name="drawer_search_sessions">Поиск сессий</string>
<string name="drawer_pin_sidebar">Закрепить боковую панель сессий</string>
<string name="drawer_unpin_sidebar">Открепить боковую панель сессий</string>
<string name="drawer_activity_working">Выполняется</string>
<string name="drawer_activity_needs_input">Требуется ввод</string>
<string name="drawer_activity_starting">Запуск</string>
+2
View File
@@ -1186,6 +1186,8 @@
<string name="drawer_new_chat">New Chat</string>
<string name="drawer_search_placeholder">Search sessions or id...</string>
<string name="drawer_search_sessions">Search sessions</string>
<string name="drawer_pin_sidebar">Pin sessions sidebar</string>
<string name="drawer_unpin_sidebar">Unpin sessions sidebar</string>
<string name="drawer_activity_working">Working</string>
<string name="drawer_activity_needs_input">Needs input</string>
<string name="drawer_activity_starting">Starting</string>
@@ -1,6 +1,7 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
@@ -12,14 +13,19 @@ import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onAllNodesWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performScrollToNode
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Density
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.hermesandroid.relay.data.ChatSession
import com.hermesandroid.relay.data.SessionActivityState
import com.hermesandroid.relay.data.SupervisedSessionActions
import com.hermesandroid.relay.ui.theme.ProfileAccentSwatches
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
import com.github.takahirom.roborazzi.captureRoboImage
import org.junit.Rule
import org.junit.Test
import org.junit.Assert.assertEquals
@@ -37,6 +43,70 @@ class SessionDrawerTest {
@get:Rule
val compose = createComposeRule()
@Test
fun `pinned sidebar can be hidden and loads older sessions`() {
var pinToggles = 0
var loadMoreCalls = 0
compose.setContent {
HermesRelayTheme(appThemeId = "hermes-relay", themePreference = "dark") {
SessionDrawerContent(
sessions = List(30) { index ->
ChatSession("session-$index", "Session $index", null, lastActivityAt = 1_000L - index)
},
currentSessionId = null,
isOpen = true,
hasMore = true,
asSidebar = true,
pinned = true,
onTogglePin = { pinToggles++ },
onLoadMore = { loadMoreCalls++ },
onNewChat = {},
onSelectSession = {},
onDeleteSession = {},
onRenameSession = { _, _ -> },
)
}
}
compose.onNodeWithText("Unpin sessions sidebar").assertIsDisplayed()
assertTrue(
compose.onNodeWithText("Unpin sessions sidebar")
.fetchSemanticsNode().boundsInRoot.height >= 96f,
)
compose.onRoot().captureRoboImage("build/ui-regression/session-sidebar-pinned.png")
compose.onNodeWithTag(SESSION_DRAWER_LIST_TAG)
.performScrollToNode(hasText("Session 29"))
compose.waitUntil(5_000) { loadMoreCalls > 0 }
compose.onNodeWithText("Unpin sessions sidebar").performClick()
assertEquals(1, pinToggles)
}
@Test
fun `pinned sidebar keeps its hide control at large text`() {
compose.setContent {
HermesRelayTheme(appThemeId = "hermes-relay", themePreference = "dark") {
val density = LocalDensity.current
CompositionLocalProvider(LocalDensity provides Density(density.density, 1.6f)) {
SessionDrawerContent(
sessions = listOf(ChatSession("session", "Recent session", null)),
currentSessionId = null,
isOpen = true,
asSidebar = true,
pinned = true,
onTogglePin = {},
onNewChat = {},
onSelectSession = {},
onDeleteSession = {},
onRenameSession = { _, _ -> },
)
}
}
}
compose.onNodeWithText("Unpin sessions sidebar").assertIsDisplayed()
compose.onRoot().captureRoboImage("build/ui-regression/session-sidebar-large-text.png")
}
@Test
fun `failed first load is unavailable and retryable not empty`() {
var refreshes = 0
@@ -0,0 +1,99 @@
package com.hermesandroid.relay.ui.components
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class SessionSidebarLayoutResolverTest {
@Test
fun pinnedIntent_rendersSidebarAtThreshold() {
assertEquals(
SessionSidebarLayout.Sidebar,
resolveSessionSidebarLayout(840, pinned = true),
)
}
@Test
fun pinnedIntent_rendersSidebarAboveThreshold() {
assertEquals(
SessionSidebarLayout.Sidebar,
resolveSessionSidebarLayout(1280, pinned = true),
)
}
@Test
fun pinnedIntent_fallsBackToModalBelowThreshold() {
assertEquals(
SessionSidebarLayout.Modal,
resolveSessionSidebarLayout(839, pinned = true),
)
}
@Test
fun unpinnedIntent_rendersModalEvenWhenWide() {
assertEquals(
SessionSidebarLayout.Modal,
resolveSessionSidebarLayout(1024, pinned = false),
)
}
@Test
fun pinnedIntent_survivesNarrowWidthForNextWideLayout() {
// Fold: modal at narrow widths. The intent itself is persisted at the
// DataStore layer; the resolver keeps returning Modal until wide again.
assertEquals(
SessionSidebarLayout.Modal,
resolveSessionSidebarLayout(360, pinned = true),
)
assertEquals(
SessionSidebarLayout.Sidebar,
resolveSessionSidebarLayout(900, pinned = true),
)
}
@Test
fun pinnedIntent_waitsWhileSupervisedHistoryIsBlocked() {
assertEquals(
SessionSidebarLayout.Modal,
resolveSessionSidebarLayout(1024, pinned = true, historyAllowed = false),
)
assertEquals(
SessionSidebarLayout.Sidebar,
resolveSessionSidebarLayout(1024, pinned = true, historyAllowed = true),
)
}
// resolveDrawerGesturesEnabled — edge-swipe must not open the modal
// drawer when supervised mode gates conversation history or when the
// pinned sidebar already renders as the persistent sessions surface.
@Test
fun gestures_enabledInDefaultState() {
assertTrue(
resolveDrawerGesturesEnabled(supervisedHistoryAllowed = true, pinnedSidebar = false),
)
}
@Test
fun gestures_disabledWhenSupervisedHistoryBlocked() {
assertFalse(
resolveDrawerGesturesEnabled(supervisedHistoryAllowed = false, pinnedSidebar = false),
)
}
@Test
fun gestures_disabledWhilePinnedSidebarActive() {
assertFalse(
resolveDrawerGesturesEnabled(supervisedHistoryAllowed = true, pinnedSidebar = true),
)
}
@Test
fun gestures_disabledWhenSupervisedAndPinnedBothActive() {
assertFalse(
resolveDrawerGesturesEnabled(supervisedHistoryAllowed = false, pinnedSidebar = true),
)
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 464 KiB

+6 -6
View File
@@ -13,7 +13,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "87e9f0e2fe07b89cb2647413eff8a6b4f600666d23cc96ab72d145ef55544f9e",
"main": "0fddb0f9b739b6b25140f4a003715a0520b484215f710c3d09d3d49db4203c07",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -48,7 +48,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "87e9f0e2fe07b89cb2647413eff8a6b4f600666d23cc96ab72d145ef55544f9e",
"main": "0fddb0f9b739b6b25140f4a003715a0520b484215f710c3d09d3d49db4203c07",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -72,7 +72,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "87e9f0e2fe07b89cb2647413eff8a6b4f600666d23cc96ab72d145ef55544f9e",
"main": "0fddb0f9b739b6b25140f4a003715a0520b484215f710c3d09d3d49db4203c07",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -96,7 +96,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "87e9f0e2fe07b89cb2647413eff8a6b4f600666d23cc96ab72d145ef55544f9e",
"main": "0fddb0f9b739b6b25140f4a003715a0520b484215f710c3d09d3d49db4203c07",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -120,7 +120,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "87e9f0e2fe07b89cb2647413eff8a6b4f600666d23cc96ab72d145ef55544f9e",
"main": "0fddb0f9b739b6b25140f4a003715a0520b484215f710c3d09d3d49db4203c07",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -135,7 +135,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "87e9f0e2fe07b89cb2647413eff8a6b4f600666d23cc96ab72d145ef55544f9e",
"main": "0fddb0f9b739b6b25140f4a003715a0520b484215f710c3d09d3d49db4203c07",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
+1 -1
View File
@@ -637,7 +637,7 @@ Bottom navigation bar with 4 tabs:
- **Bot Mode workspace** — the session drawer exposes one entry into a separate full-screen messenger surface; it does not add Bot or group rows to the ordinary session taxonomy. Android refreshes every saved Dashboard/Gateway with bounded concurrency, preserves last-good rows as visibly offline, and collapses duplicate routes by upstream `install_id` before assigning source-qualified handles. Every Bot carries an immutable `(connectionId, profile)` owner; labels, installation metadata, and the currently resolved URL are presentation/routing data rather than identity. All gateways and one-gateway filters never mutate the foreground connection.
- **Canonical Bot Chat** — each individual row resolves the exact hidden session titled `Bot Chat` on its owning Gateway. Lookup failure is not absence, so Android creates and materializes the lazy row with `session.title` only after an authoritative empty exact-title result. The dedicated Bot Chat destination retains that route's pooled Gateway client, loads history through the same connection/profile Dashboard, sends only through Gateway, and returns directly to Bot Mode without rebinding Standard Chat or the global connection. `/new` or `/reset` compacts the canonical conversation instead of forking it. The route pool mints a fresh WebSocket ticket per dial, includes the immutable profile in the WebSocket URL, isolates credentials by exact trusted connection origin, and tears down only the removed connection's clients.
- **Bot group projection** — Android merges the bounded `ui_meta["hermes-bots-groups"]` v3 projection across gateways by durable room identity and newest revision. Rooms and recent messages are visibly read-only; Android does not create, rename, disband, join, send, coordinate member turns, or become a second room-log authority. Binary room images are ignored at this metadata boundary.
- **Session drawer** (swipe from left or hamburger icon) — session list with title, timestamp, message count. Create, switch, rename, delete, pin/unpin, and archive/restore. A profile switch marks the replacement list loading before clearing the previous profile's rows and keeps that state until the exact-profile fetch settles, so an empty-state claim never flashes before server truth arrives. The process-owned conversation binding is the single connection/profile/session identity for Chat; selecting an All Profiles row atomically makes its owner the selected agent and persists that profile/session, while merely browsing All Profiles changes no agent state. Lifecycle or locale-driven Activity recreation cannot replace an explicit binding with stale persisted state, and asynchronous list/history/mutation work is accepted only for the binding's exact namespace. A profile lock hides All Profiles and rejects stale/deep-linked cross-profile opens. The All Profiles browser mode otherwise survives Activity state restoration and refetches its rows after recreation. Pin and archive are durable upstream session fields loaded and patched through the owning connection/profile's Dashboard session API; Android does not keep a second local flag registry. Archived rows are requested explicitly so they remain restorable after recreation. Failed mutations roll back the optimistic row, while refresh and deletion reconcile from server truth. When a persisted title is absent, use upstream's first-user-message `preview`, matching the Hermes Desktop session picker; show "Untitled" only when neither value exists.
- **Session drawer** (swipe from left or hamburger icon) — session list with title, timestamp, message count. On wide Chat layouts (at least 840 dp available), a saved opt-in presentation choice can keep the same browser visible in a 320 dp sidebar; unpinning returns it to the on-demand drawer. Compact layouts keep the drawer, and supervised history restrictions suppress the sidebar. Create, switch, rename, delete, pin/unpin, and archive/restore. A profile switch marks the replacement list loading before clearing the previous profile's rows and keeps that state until the exact-profile fetch settles, so an empty-state claim never flashes before server truth arrives. The process-owned conversation binding is the single connection/profile/session identity for Chat; selecting an All Profiles row atomically makes its owner the selected agent and persists that profile/session, while merely browsing All Profiles changes no agent state. Lifecycle or locale-driven Activity recreation cannot replace an explicit binding with stale persisted state, and asynchronous list/history/mutation work is accepted only for the binding's exact namespace. A profile lock hides All Profiles and rejects stale/deep-linked cross-profile opens. The All Profiles browser mode otherwise survives Activity state restoration and refetches its rows after recreation. Pin and archive are durable upstream session fields loaded and patched through the owning connection/profile's Dashboard session API; Android does not keep a second local flag registry. Archived rows are requested explicitly so they remain restorable after recreation. Failed mutations roll back the optimistic row, while refresh and deletion reconcile from server truth. When a persisted title is absent, use upstream's first-user-message `preview`, matching the Hermes Desktop session picker; show "Untitled" only when neither value exists.
- **Cold profile hydration** — a persisted named profile scopes its Dashboard session directory and last-session restore immediately, before `/api/profiles` metadata is available. Server-default selection waits for the lightweight active-profile scope. Roster, avatars, pets, skills, and model metadata never precede the first directory result. The startup sphere releases after route selection; Chat keeps identity and cached rows mounted while its existing animated status surfaces show Gateway wake, session restore, and directory loading.
- **Authoritative session activity** — one composite registry keyed by connection, normalized profile, and durable session id drives the drawer, filters, grouping, animation, accessibility, and the visible composer. Exact pending approval/clarify/sudo/secret/MCP requests produce **Needs input**; the Gateway's process-wide `session.active_list` supplies **Starting**, **Working**, and **Idle**; an exact terminal, `session.info {running:false}`, or an exact live/durable active-list row reporting Idle can settle only the matching Android-owned turn and progress generation. Because active-list rows normally have no profile metadata, Android assigns a row through exact foreground/detached ownership already held by that client, explicit profile metadata if a future upstream sends it, or the currently selected passive session when its durable id has exactly one owner in the current connection directory. Duplicate same-id owners across profiles remain unresolved and create no status. Resolved rows from a partial snapshot may update their exact owners, but disappearance settles a scope only when the successful process-wide snapshot was completely and unambiguously resolved for it. Restart/checkpoint recovery is **Checking**; a failed or unsupported live refresh is **Unavailable**, never inferred Idle. REST `is_active` remains recency metadata only. `process.list` may add a separate **Background work** indicator and never keeps the parent conversation Working. Old socket generations, ambiguous bare session ids, delayed snapshots, and snapshots crossed by newer turn events cannot settle or revive a newer generation.
- **Concurrent Gateway chats** — switching sessions, profiles, drafts, or Threads detaches the visible Android-owned turn without sending `session.interrupt`; each Android-owned running chat keeps a connection/profile/session-scoped checkpoint and reattaches to its live Gateway session when reopened. Opening, foregrounding, or selecting a saved session without that exact checkpoint is read-only observation: Android warms only the socket, reads profile-scoped history, and polls `session.active_list` without `session.resume`, `session.activate`, `prompt.submit`, or `session.interrupt`. A Desktop/TUI-owned turn therefore remains owned by its producing client; Android refreshes persisted progress and performs one final history read when the runtime settles. Explicit send/config actions may resume the destination session, explicit Stop still interrupts, and Direct API compatibility chat stays single-stream and cancels on navigation.
+6
View File
@@ -7,6 +7,12 @@ Swipe from the left edge or tap the hamburger menu icon to open the session draw
- Last activity timestamp
- Message count
On an unfolded foldable or other wide Chat layout, use **Pin sessions sidebar**
in the drawer to keep Sessions visible beside the conversation. Use **Unpin
sessions sidebar** to hide it; the hamburger menu then opens the drawer when
needed. Compact layouts always use the drawer. Your pin choice is saved and
takes effect again when the Chat layout becomes wide enough.
## Creating a New Chat
Tap **New Chat** in the session drawer, or just start typing — a session is automatically created on your first message.