Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0509e122ff | ||
|
|
57cc10835f | ||
|
|
8b12a056eb |
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
+99
@@ -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 |
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user