feat: paginate, sync, and display branched sessions (#563)

* mobile: 以 Desktop Sessions 作为聊天历史源

* mobile: 修复快速连接崩溃与无标题会话隐藏

* mobile: 对齐 Desktop 标题并恢复完整历史

* mobile: 分离 Desktop 存储 ID 与运行 Session ID

* mobile: 将新对话持久化为 Desktop Session

* mobile: 渲染 Desktop 本地媒体

* feat: 分页加载并同步当前会话

* fix: 显示分支会话名称与层级

* fix: align PR with upstream app contract

* fix: preserve upstream session RPC lifecycle
This commit is contained in:
Genesi599 authored and GitHub committed 2026-07-12 12:53:23 +03:00
1 parent 306861a222
commit 3052db7d65
8 files changed
+405 -79

No files matched your search

@@ -19,6 +19,8 @@ data class SessionInfo(
val preview: String? = null,
val started_at: Double? = null,
val source: String? = null,
val parent_session_id: String? = null,
val display_name: String? = null,
)
@Serializable
@@ -0,0 +1,51 @@
package com.m57.hermescontrol.data.model
data class SessionTreeItem(
val session: SessionInfo,
val depth: Int,
val branchStem: String?,
val displayTitle: String,
)
fun flattenSessionTree(sessions: List<SessionInfo>): List<SessionTreeItem> {
val ids = sessions.mapTo(mutableSetOf()) { it.id }
val children = sessions.groupBy { it.parent_session_id?.takeIf(ids::contains) }
val result = mutableListOf<SessionTreeItem>()
val visited = mutableSetOf<String>()
fun append(
session: SessionInfo,
depth: Int,
branchStem: String?,
siblingIndex: Int,
) {
if (!visited.add(session.id)) return
val isBranch = session.parent_session_id?.takeIf(ids::contains) != null
val title =
session.title?.takeIf(String::isNotBlank)
?: session.display_name?.takeIf(String::isNotBlank)
?: if (isBranch) {
"branch ${siblingIndex + 1}"
} else {
session.preview?.takeIf(String::isNotBlank)?.take(80) ?: "Untitled"
}
result += SessionTreeItem(session, depth, branchStem, title)
val descendants = children[session.id].orEmpty()
descendants.forEachIndexed { index, child ->
append(
session = child,
depth = depth + 1,
branchStem = if (index == descendants.lastIndex) "└─" else "├─",
siblingIndex = index,
)
}
}
children[null].orEmpty().forEachIndexed { index, root ->
append(root, depth = 0, branchStem = null, siblingIndex = index)
}
sessions.filterNot { it.id in visited }.forEachIndexed { index, orphan ->
append(orphan, depth = 0, branchStem = null, siblingIndex = index)
}
return result
}
@@ -131,6 +131,8 @@ interface HermesApiService {
// Preserve slashes in session IDs — backend generates IDs containing '/' characters (issue #468).
// Contract: The server-generated sessionId must only contain URL-safe characters (no ?, #, or spaces).
@Path("id", encoded = true) sessionId: String,
@Query("limit") limit: Int? = null,
@Query("offset") offset: Int = 0,
): Response<SessionMessagesResponse>
@GET("api/sessions/stats")
@@ -95,6 +95,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -130,12 +131,16 @@ import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.NavIcon
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
private const val SESSION_SYNC_INTERVAL_MS = 5_000L
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ChatScreen(
@@ -147,6 +152,27 @@ fun ChatScreen(
val state by viewModel.uiState.collectAsStateWithLifecycle()
val streamingState by viewModel.streamingState.collectAsStateWithLifecycle()
val listState = rememberLazyListState()
var isOlderPagingArmed by remember(state.currentSessionId) { mutableStateOf(false) }
LaunchedEffect(state.currentSessionId, state.connectionStatus) {
while (state.currentSessionId != null && state.connectionStatus == ConnectionStatus.CONNECTED) {
delay(SESSION_SYNC_INTERVAL_MS)
viewModel.syncCurrentSession()
}
}
LaunchedEffect(listState, state.currentSessionId, state.hasOlderMessages, state.isLoadingOlder) {
if (!state.hasOlderMessages || state.isLoadingOlder) return@LaunchedEffect
snapshotFlow { listState.firstVisibleItemIndex }
.distinctUntilChanged()
.collectLatest { firstVisibleIndex ->
if (firstVisibleIndex > 2) {
isOlderPagingArmed = true
} else if (isOlderPagingArmed) {
viewModel.loadOlderMessages()
}
}
}
val showScrollToBottom by remember {
derivedStateOf {
state.messages.isNotEmpty() && listState.canScrollForward
@@ -395,6 +421,7 @@ fun ChatScreen(
typingEffectEnabled = state.typingEffectEnabled,
typingEffectDelayMs = state.typingEffectDelayMs,
isLoading = state.isLoading,
isLoadingOlder = state.isLoadingOlder,
isDark = isDark,
listState = listState,
lastAnimatedMessageId = lastAnimatedMessageId,
@@ -1664,6 +1691,7 @@ private fun ChatMessageList(
typingEffectEnabled: Boolean,
typingEffectDelayMs: Int,
isLoading: Boolean,
isLoadingOlder: Boolean,
isDark: Boolean,
listState: LazyListState,
lastAnimatedMessageId: String?,
@@ -1717,6 +1745,16 @@ private fun ChatMessageList(
modifier = Modifier.fillMaxWidth().weight(1f),
contentPadding = PaddingValues(vertical = 8.dp),
) {
if (isLoadingOlder) {
item(key = "loading-older") {
Box(
modifier = Modifier.fillMaxWidth().padding(12.dp),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
}
}
}
itemsIndexed(
items = messages,
key = { _, message -> message.id },
@@ -10,6 +10,7 @@ import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.local.HermesDatabase
import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.data.model.SessionMessage
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.OkHttpProvider
@@ -43,6 +44,7 @@ import java.io.ByteArrayOutputStream
import java.util.concurrent.ConcurrentHashMap
private const val TAG = "ChatViewModel"
private const val MESSAGE_PAGE_SIZE = 150
data class ChatUiState(
val messages: List<ChatMessage> = emptyList(),
@@ -54,6 +56,8 @@ data class ChatUiState(
val isThinking: Boolean = false,
val thinkingText: String = "",
val isLoading: Boolean = false,
val isLoadingOlder: Boolean = false,
val hasOlderMessages: Boolean = false,
/** Standalone streaming message — rendered after the main list. */
val streamingMessage: ChatMessage? = null,
val errorMessage: String? = null,
@@ -89,6 +93,8 @@ data class SessionUi(
val id: String,
val title: String,
val messageCount: Int = 0,
val parentSessionId: String? = null,
val depth: Int = 0,
)
data class ClarifyUi(
@@ -123,6 +129,14 @@ class ChatViewModel(
private val _uiState = MutableStateFlow(ChatUiState())
private val _streamingState = MutableStateFlow(StreamingState())
/** Maps an in-flight RPC id to its method for UI error labeling. */
private val idToMethod = ConcurrentHashMap<String, String>()
/** Runtime TUI session returned by session.resume; Desktop storage keeps the original ID. */
private var runtimeSessionId: String? = null
private var loadedMessageOffset = 0
private var isSyncingMessages = false
val streamingState: StateFlow<StreamingState> = _streamingState.asStateFlow()
/** Tracks the auto-clear coroutine for reaction animations. */
@@ -279,7 +293,7 @@ class ChatViewModel(
_uiState.value,
_streamingState.value,
event,
_uiState.value.currentSessionId,
runtimeSessionId ?: _uiState.value.currentSessionId,
)
// Apply the new state
@@ -418,7 +432,7 @@ class ChatViewModel(
private fun isCurrentSession(eventSessionId: String?): Boolean {
// If the event has no session ID, process it (legacy compatibility)
if (eventSessionId == null) return true
return eventSessionId == _uiState.value.currentSessionId
return eventSessionId == runtimeSessionId || eventSessionId == _uiState.value.currentSessionId
}
// ── RPC response handling ────────────────────────────────────────────
@@ -432,10 +446,12 @@ class ChatViewModel(
when (method) {
WsMethods.SESSION_CREATE -> {
val resultMap = result as? Map<String, Any?> ?: return
val sessionId = resultMap["session_id"] as? String ?: return
val runtimeId = resultMap["session_id"] as? String ?: return
val storageId = resultMap["stored_session_id"] as? String ?: runtimeId
runtimeSessionId = runtimeId
_uiState.update {
it.copy(
currentSessionId = sessionId,
currentSessionId = storageId,
isLoading = false,
messages = emptyList(),
chatTitle = "Hermes",
@@ -444,7 +460,7 @@ class ChatViewModel(
// Mirror the active session id app-wide so session-scoped
// drawer screens (e.g. Processes, issue #532) can issue
// session-scoped RPCs. See ActiveSessionHolder.
ActiveSessionHolder.set(sessionId)
ActiveSessionHolder.set(runtimeId)
_streamingState.update { StreamingState() }
addSystemMessage("Session created", persist = true)
loadSessions()
@@ -472,8 +488,9 @@ class ChatViewModel(
WsMethods.SESSION_RESUME -> {
val resultMap = result as? Map<String, Any?>
runtimeSessionId = resultMap?.get("session_id") as? String
val sessionId =
(resultMap?.get("session_id") as? String)
(resultMap?.get("resumed") as? String)
?: _uiState.value.currentSessionId
// B8 (Jun 20 2026, kanban t_session_resume): do NOT reload
@@ -487,8 +504,8 @@ class ChatViewModel(
currentSessionId = sessionId,
)
}
// Mirror the active session id app-wide (issue #532).
ActiveSessionHolder.set(sessionId)
// Mirror the active runtime session id app-wide (issue #532).
ActiveSessionHolder.set(runtimeSessionId ?: sessionId)
addSystemMessage("Session resumed")
}
@@ -599,7 +616,8 @@ class ChatViewModel(
*/
fun sendMessage(text: String) {
if (text.isBlank() && _uiState.value.pendingAttachments.isEmpty()) return
val sessionId = _uiState.value.currentSessionId ?: return
val storageSessionId = _uiState.value.currentSessionId ?: return
val agentSessionId = runtimeSessionId ?: return
val trimmed = text.trim()
if (trimmed.startsWith("/", ignoreCase = true)) {
@@ -626,9 +644,9 @@ class ChatViewModel(
)
}
// Persist to Room
// Persist under the original Desktop session ID.
viewModelScope.launch(Dispatchers.IO) {
repo.persistMessage(userMessage, sessionId)
repo.persistMessage(userMessage, storageSessionId)
}
// Upload attachments then submit prompt
@@ -689,7 +707,7 @@ class ChatViewModel(
}
wsClient.sendMessage(
sessionId,
agentSessionId,
fullText,
onSent = { id -> trackRequest(id, WsMethods.PROMPT_SUBMIT) },
)
@@ -779,7 +797,7 @@ class ChatViewModel(
}
private fun dispatchViaRpc(command: String) {
val sessionId = _uiState.value.currentSessionId
val sessionId = runtimeSessionId
if (sessionId == null) {
addAssistantMessage("No active session. Use `/new` to create one.")
return
@@ -803,7 +821,7 @@ class ChatViewModel(
*/
private fun submitPrompt(text: String) {
if (text.isBlank()) return
val sessionId = _uiState.value.currentSessionId ?: return
val sessionId = runtimeSessionId ?: return
_uiState.update { it.copy(isAgentTyping = true) }
viewModelScope.launch(Dispatchers.IO) {
wsClient.sendMessage(
@@ -830,7 +848,7 @@ class ChatViewModel(
// ── Session management ───────────────────────────────────────────────
fun interruptSession() {
val sessionId = _uiState.value.currentSessionId ?: return
val sessionId = runtimeSessionId ?: return
viewModelScope.launch(Dispatchers.IO) {
wsClient.send(
WsMethods.SESSION_INTERRUPT,
@@ -853,6 +871,7 @@ class ChatViewModel(
viewModelScope.launch(Dispatchers.IO) {
wsClient.send(
WsMethods.SESSION_CREATE,
params = mapOf("source" to "desktop"),
onSent = { id -> trackRequest(id, WsMethods.SESSION_CREATE) },
)
}
@@ -878,15 +897,7 @@ class ChatViewModel(
fun refreshCurrentSession() {
val sessionId = _uiState.value.currentSessionId ?: return
viewModelScope.launch {
loadCachedMessages(sessionId).join()
// Skip the REST call if we already have messages — the WebSocket keeps
// the UI current. The REST endpoint can 404 on tab switch-back when the
// WS session ID doesn't round-trip through the resolver (issue #366),
// showing a misleading error banner while the connection is fine.
if (_uiState.value.messages.isNotEmpty()) return@launch
loadSessionMessages(sessionId)
}
loadSessionMessages(sessionId)
}
fun refreshSettings() {
@@ -911,12 +922,16 @@ class ChatViewModel(
fun switchSession(sessionId: String) {
if (sessionId == _uiState.value.currentSessionId) return
// Reset streaming state
// Reset streaming and pagination state before resuming the Desktop session.
runtimeSessionId = null
loadedMessageOffset = 0
streamingController.resetStreaming()
_uiState.update {
val title = it.sessions.find { s -> s.id == sessionId }?.title ?: "Hermes"
it.copy(
isLoading = true,
isLoadingOlder = false,
hasOlderMessages = false,
currentSessionId = sessionId,
messages = emptyList(),
chatTitle = title,
@@ -928,9 +943,7 @@ class ChatViewModel(
ActiveSessionHolder.set(sessionId)
_streamingState.update { StreamingState() }
viewModelScope.launch {
// Step 1: Load cached messages first — instant display
loadCachedMessages(sessionId).join()
// Step 2: Resume session on server
// Resume the selected desktop session, then load its complete transcript.
launch(Dispatchers.IO) {
wsClient.send(
WsMethods.SESSION_RESUME,
@@ -938,9 +951,7 @@ class ChatViewModel(
onSent = { id -> trackRequest(id, WsMethods.SESSION_RESUME) },
)
}
// Step 3: Load fresh messages from REST and merge
loadSessionMessages(sessionId)
// Step 4: Refresh session list to get latest titles
loadSessions()
}
}
@@ -960,62 +971,33 @@ class ChatViewModel(
private fun loadSessionMessages(sessionId: String) {
viewModelScope.launch {
val result =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.getSessionMessages(sessionId) }
}
val messageCount = fetchServerMessageCount(sessionId)
val offset = (messageCount - MESSAGE_PAGE_SIZE).coerceAtLeast(0)
val result = fetchMessagePage(sessionId, offset, MESSAGE_PAGE_SIZE)
when (result) {
is NetworkResult.Success -> {
val messagesList = result.data.messages.orEmpty()
val chatMessages =
messagesList.mapIndexed { index, msg ->
val role =
when (msg.role?.lowercase()) {
"user" -> MessageRole.USER
"system" -> MessageRole.SYSTEM
"tool" -> MessageRole.TOOL
else -> MessageRole.ASSISTANT
}
// Use stable IDs based on session + index to prevent
// Room duplicates when re-loading the same session.
val stableId = "rest-$sessionId-$index"
// Preserve original timestamp from the API when available
val ts = msg.timestampText?.toLongOrNull() ?: System.currentTimeMillis()
ChatMessage(
id = stableId,
role = role,
content = msg.content.orEmpty(),
timestamp = ts,
isStreaming = false,
)
}
// Persist loaded messages to Room for offline access
val chatMessages = mapServerMessages(sessionId, result.data.messages.orEmpty(), offset)
loadedMessageOffset = offset
withContext(Dispatchers.IO) {
repo.persistMessages(chatMessages, sessionId)
}
_uiState.update { state ->
// Only update if still on the same session
if (state.currentSessionId != sessionId) return@update state
// Merge: keep any user messages sent between cache load
// and REST response (they won't be in the REST response
// yet), plus preserve any active streaming message.
val existingIds = chatMessages.mapTo(HashSet(chatMessages.size)) { it.id }
val localOnly = state.messages.filter { it.id !in existingIds }
state.copy(
messages = chatMessages + localOnly,
messages = chatMessages,
isLoading = false,
hasOlderMessages = offset > 0,
isLoadingOlder = false,
)
}
}
is NetworkResult.Failure -> {
// Don't overwrite messages on REST failure — cached messages
// are still valid.
_uiState.update {
if (it.currentSessionId != sessionId) return@update it
it.copy(
isLoading = false,
isLoadingOlder = false,
errorMessage = "Failed to load messages: ${result.error.message}",
)
}
@@ -1024,6 +1006,170 @@ class ChatViewModel(
}
}
fun loadOlderMessages() {
val state = _uiState.value
val sessionId = state.currentSessionId ?: return
if (!state.hasOlderMessages || state.isLoadingOlder || loadedMessageOffset <= 0) return
val oldOffset = loadedMessageOffset
val newOffset = (oldOffset - MESSAGE_PAGE_SIZE).coerceAtLeast(0)
val limit = oldOffset - newOffset
_uiState.update { it.copy(isLoadingOlder = true) }
viewModelScope.launch {
when (val result = fetchMessagePage(sessionId, newOffset, limit)) {
is NetworkResult.Success -> {
val older = mapServerMessages(sessionId, result.data.messages.orEmpty(), newOffset)
loadedMessageOffset = newOffset
withContext(Dispatchers.IO) { repo.persistMessages(older, sessionId) }
_uiState.update { current ->
if (current.currentSessionId != sessionId) return@update current
current.copy(
messages = (older + current.messages).distinctBy { it.id },
isLoadingOlder = false,
hasOlderMessages = newOffset > 0,
)
}
}
is NetworkResult.Failure -> {
_uiState.update { it.copy(isLoadingOlder = false) }
}
}
}
}
fun syncCurrentSession() {
val state = _uiState.value
val sessionId = state.currentSessionId ?: return
if (isSyncingMessages || state.isLoading || state.isLoadingOlder || state.isAgentTyping ||
_streamingState.value.streamingMessage != null
) {
return
}
val nextOffset =
state.messages
.mapNotNull { serverMessageIndex(it.id, sessionId) }
.maxOrNull()
?.plus(1)
?: loadedMessageOffset
isSyncingMessages = true
viewModelScope.launch {
try {
when (val result = fetchMessagePage(sessionId, nextOffset, MESSAGE_PAGE_SIZE)) {
is NetworkResult.Success -> {
val incoming = mapServerMessages(sessionId, result.data.messages.orEmpty(), nextOffset)
if (incoming.isEmpty()) return@launch
withContext(Dispatchers.IO) { repo.persistMessages(incoming, sessionId) }
_uiState.update { current ->
if (current.currentSessionId != sessionId) return@update current
val unmatched = incoming.map { it.role to it.content }.toMutableList()
val retained =
current.messages.filter { existing ->
if (serverMessageIndex(existing.id, sessionId) != null) {
true
} else {
val duplicate =
unmatched.indexOfFirst { (role, content) ->
role == existing.role && content == existing.content
}
if (duplicate >= 0) unmatched.removeAt(duplicate)
duplicate < 0
}
}
val merged = (retained + incoming).distinctBy { it.id }
if (sameMessages(current.messages, merged)) current else current.copy(messages = merged)
}
}
is NetworkResult.Failure -> Unit
}
} finally {
isSyncingMessages = false
}
}
}
private suspend fun fetchServerMessageCount(sessionId: String): Int {
val known = _uiState.value.sessions.find { it.id == sessionId }?.messageCount
if (known != null) return known
val result =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.getSessions(limit = 500, offset = 0, order = "recent") }
}
if (result is NetworkResult.Success) {
val sessions = result.data.sessions.orEmpty()
val count = sessions.find { it.id == sessionId }?.message_count
if (count != null) {
_uiState.update { current ->
current.copy(
sessions =
current.sessions.map {
if (it.id == sessionId) {
it.copy(
messageCount = count,
)
} else {
it
}
},
)
}
return count
}
}
return known ?: _uiState.value.messages.size
}
private suspend fun fetchMessagePage(
sessionId: String,
offset: Int,
limit: Int,
) = withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.getSessionMessages(sessionId, limit = limit, offset = offset) }
}
private fun mapServerMessages(
sessionId: String,
messages: List<SessionMessage>,
offset: Int,
): List<ChatMessage> =
messages.mapIndexed { index, msg ->
val role =
when (msg.role?.lowercase()) {
"user" -> MessageRole.USER
"system" -> MessageRole.SYSTEM
"tool" -> MessageRole.TOOL
else -> MessageRole.ASSISTANT
}
val globalIndex = offset + index
val timestamp =
msg.timestampText
?.toDoubleOrNull()
?.times(1000)
?.toLong()
?: System.currentTimeMillis()
ChatMessage(
id = "rest-$sessionId-$globalIndex",
role = role,
content = msg.content.orEmpty(),
timestamp = timestamp,
isStreaming = false,
)
}
private fun serverMessageIndex(
id: String,
sessionId: String,
): Int? = id.removePrefix("rest-$sessionId-").takeIf { it != id }?.toIntOrNull()
private fun sameMessages(
left: List<ChatMessage>,
right: List<ChatMessage>,
): Boolean =
left.size == right.size &&
left.zip(right).all { (a, b) ->
a.id == b.id && a.role == b.role && a.content == b.content
}
// ── UI actions ───────────────────────────────────────────────────────
fun toggleSessionPicker() {
@@ -1363,9 +1509,6 @@ class ChatViewModel(
// ── Pending request tracking ─────────────────────────────────────────
/** Maps an in-flight RPC id → its method, purely for UI error labeling. The deferred/timeout lives in [HermesWsClient.request] (issue #526). */
private val idToMethod = ConcurrentHashMap<String, String>()
private fun trackRequest(
id: String,
method: String,
@@ -76,6 +76,8 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.ChatScreen
import com.m57.hermescontrol.NavigationController
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.SessionInfo
import com.m57.hermescontrol.data.model.flattenSessionTree
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.theme.LocalSpacing
import com.m57.hermescontrol.ui.common.EmptyState
@@ -170,11 +172,17 @@ fun SessionsScreen(
var query by remember { mutableStateOf("") }
var pruneDays by remember { mutableStateOf("7") }
val sessionTree = remember(state.sessions) { flattenSessionTree(state.sessions) }
val filteredSessions =
remember(query, state.sessions) {
state.sessions.filter { session ->
session.title?.contains(query, ignoreCase = true) == true ||
session.status?.contains(query, ignoreCase = true) == true
remember(query, sessionTree) {
if (query.isBlank()) {
sessionTree
} else {
sessionTree.filter { item ->
item.displayTitle.contains(query, ignoreCase = true) ||
item.session.preview?.contains(query, ignoreCase = true) == true ||
item.session.status?.contains(query, ignoreCase = true) == true
}
}
}
@@ -430,9 +438,13 @@ fun SessionsScreen(
contentPadding = listContentPadding,
verticalArrangement = listItemSpacing,
) {
items(filteredSessions, key = { it.id }) { session ->
items(filteredSessions, key = { it.session.id }) { item ->
val session = item.session
SessionCard(
session = session,
displayTitle = item.displayTitle,
depth = item.depth,
branchStem = item.branchStem,
query = query,
isSelecting = state.isSelecting,
isSelected = session.id in state.selectedIds,
@@ -589,7 +601,10 @@ fun SessionsScreen(
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun SessionCard(
session: com.m57.hermescontrol.data.model.SessionInfo,
session: SessionInfo,
displayTitle: String,
depth: Int,
branchStem: String?,
query: String,
isSelecting: Boolean,
isSelected: Boolean,
@@ -603,7 +618,6 @@ private fun SessionCard(
) {
val spacing = LocalSpacing.current
val statusColors = LocalHermesStatusColors.current
val displayTitle = session.title?.takeIf { it.isNotBlank() } ?: stringResource(R.string.history_untitled)
val isActive = session.status?.lowercase() == "active" || session.status?.lowercase() == "streaming"
val srcIcon = sourceIcon(session.source)
@@ -611,6 +625,7 @@ private fun SessionCard(
modifier =
Modifier
.fillMaxWidth()
.padding(start = (depth * 16).dp)
.testTag("session_card_${session.id}")
.combinedClickable(
onClick = onCardClick,
@@ -643,6 +658,16 @@ private fun SessionCard(
Spacer(modifier = Modifier.width(spacing.sm))
}
// Branch tree stem
if (branchStem != null && !isSelecting) {
Text(
text = branchStem,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.outline,
)
Spacer(modifier = Modifier.width(spacing.sm))
}
// Source icon
if (srcIcon != null && !isSelecting) {
Icon(