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:
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 preview: String? = null,
|
||||||
val started_at: Double? = null,
|
val started_at: Double? = null,
|
||||||
val source: String? = null,
|
val source: String? = null,
|
||||||
|
val parent_session_id: String? = null,
|
||||||
|
val display_name: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@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).
|
// 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).
|
// Contract: The server-generated sessionId must only contain URL-safe characters (no ?, #, or spaces).
|
||||||
@Path("id", encoded = true) sessionId: String,
|
@Path("id", encoded = true) sessionId: String,
|
||||||
|
@Query("limit") limit: Int? = null,
|
||||||
|
@Query("offset") offset: Int = 0,
|
||||||
): Response<SessionMessagesResponse>
|
): Response<SessionMessagesResponse>
|
||||||
|
|
||||||
@GET("api/sessions/stats")
|
@GET("api/sessions/stats")
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||||||
import androidx.compose.runtime.rememberUpdatedState
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
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.HermesScaffold
|
||||||
import com.m57.hermescontrol.ui.common.NavIcon
|
import com.m57.hermescontrol.ui.common.NavIcon
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
|
private const val SESSION_SYNC_INTERVAL_MS = 5_000L
|
||||||
|
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatScreen(
|
fun ChatScreen(
|
||||||
@@ -147,6 +152,27 @@ fun ChatScreen(
|
|||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val streamingState by viewModel.streamingState.collectAsStateWithLifecycle()
|
val streamingState by viewModel.streamingState.collectAsStateWithLifecycle()
|
||||||
val listState = rememberLazyListState()
|
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 {
|
val showScrollToBottom by remember {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
state.messages.isNotEmpty() && listState.canScrollForward
|
state.messages.isNotEmpty() && listState.canScrollForward
|
||||||
@@ -395,6 +421,7 @@ fun ChatScreen(
|
|||||||
typingEffectEnabled = state.typingEffectEnabled,
|
typingEffectEnabled = state.typingEffectEnabled,
|
||||||
typingEffectDelayMs = state.typingEffectDelayMs,
|
typingEffectDelayMs = state.typingEffectDelayMs,
|
||||||
isLoading = state.isLoading,
|
isLoading = state.isLoading,
|
||||||
|
isLoadingOlder = state.isLoadingOlder,
|
||||||
isDark = isDark,
|
isDark = isDark,
|
||||||
listState = listState,
|
listState = listState,
|
||||||
lastAnimatedMessageId = lastAnimatedMessageId,
|
lastAnimatedMessageId = lastAnimatedMessageId,
|
||||||
@@ -1664,6 +1691,7 @@ private fun ChatMessageList(
|
|||||||
typingEffectEnabled: Boolean,
|
typingEffectEnabled: Boolean,
|
||||||
typingEffectDelayMs: Int,
|
typingEffectDelayMs: Int,
|
||||||
isLoading: Boolean,
|
isLoading: Boolean,
|
||||||
|
isLoadingOlder: Boolean,
|
||||||
isDark: Boolean,
|
isDark: Boolean,
|
||||||
listState: LazyListState,
|
listState: LazyListState,
|
||||||
lastAnimatedMessageId: String?,
|
lastAnimatedMessageId: String?,
|
||||||
@@ -1717,6 +1745,16 @@ private fun ChatMessageList(
|
|||||||
modifier = Modifier.fillMaxWidth().weight(1f),
|
modifier = Modifier.fillMaxWidth().weight(1f),
|
||||||
contentPadding = PaddingValues(vertical = 8.dp),
|
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(
|
itemsIndexed(
|
||||||
items = messages,
|
items = messages,
|
||||||
key = { _, message -> message.id },
|
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.AuthManager
|
||||||
import com.m57.hermescontrol.data.local.HermesDatabase
|
import com.m57.hermescontrol.data.local.HermesDatabase
|
||||||
import com.m57.hermescontrol.data.model.Attachment
|
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.ApiClient
|
||||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||||
@@ -43,6 +44,7 @@ import java.io.ByteArrayOutputStream
|
|||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
private const val TAG = "ChatViewModel"
|
private const val TAG = "ChatViewModel"
|
||||||
|
private const val MESSAGE_PAGE_SIZE = 150
|
||||||
|
|
||||||
data class ChatUiState(
|
data class ChatUiState(
|
||||||
val messages: List<ChatMessage> = emptyList(),
|
val messages: List<ChatMessage> = emptyList(),
|
||||||
@@ -54,6 +56,8 @@ data class ChatUiState(
|
|||||||
val isThinking: Boolean = false,
|
val isThinking: Boolean = false,
|
||||||
val thinkingText: String = "",
|
val thinkingText: String = "",
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
|
val isLoadingOlder: Boolean = false,
|
||||||
|
val hasOlderMessages: Boolean = false,
|
||||||
/** Standalone streaming message — rendered after the main list. */
|
/** Standalone streaming message — rendered after the main list. */
|
||||||
val streamingMessage: ChatMessage? = null,
|
val streamingMessage: ChatMessage? = null,
|
||||||
val errorMessage: String? = null,
|
val errorMessage: String? = null,
|
||||||
@@ -89,6 +93,8 @@ data class SessionUi(
|
|||||||
val id: String,
|
val id: String,
|
||||||
val title: String,
|
val title: String,
|
||||||
val messageCount: Int = 0,
|
val messageCount: Int = 0,
|
||||||
|
val parentSessionId: String? = null,
|
||||||
|
val depth: Int = 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class ClarifyUi(
|
data class ClarifyUi(
|
||||||
@@ -123,6 +129,14 @@ class ChatViewModel(
|
|||||||
private val _uiState = MutableStateFlow(ChatUiState())
|
private val _uiState = MutableStateFlow(ChatUiState())
|
||||||
|
|
||||||
private val _streamingState = MutableStateFlow(StreamingState())
|
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()
|
val streamingState: StateFlow<StreamingState> = _streamingState.asStateFlow()
|
||||||
|
|
||||||
/** Tracks the auto-clear coroutine for reaction animations. */
|
/** Tracks the auto-clear coroutine for reaction animations. */
|
||||||
@@ -279,7 +293,7 @@ class ChatViewModel(
|
|||||||
_uiState.value,
|
_uiState.value,
|
||||||
_streamingState.value,
|
_streamingState.value,
|
||||||
event,
|
event,
|
||||||
_uiState.value.currentSessionId,
|
runtimeSessionId ?: _uiState.value.currentSessionId,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Apply the new state
|
// Apply the new state
|
||||||
@@ -418,7 +432,7 @@ class ChatViewModel(
|
|||||||
private fun isCurrentSession(eventSessionId: String?): Boolean {
|
private fun isCurrentSession(eventSessionId: String?): Boolean {
|
||||||
// If the event has no session ID, process it (legacy compatibility)
|
// If the event has no session ID, process it (legacy compatibility)
|
||||||
if (eventSessionId == null) return true
|
if (eventSessionId == null) return true
|
||||||
return eventSessionId == _uiState.value.currentSessionId
|
return eventSessionId == runtimeSessionId || eventSessionId == _uiState.value.currentSessionId
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── RPC response handling ────────────────────────────────────────────
|
// ── RPC response handling ────────────────────────────────────────────
|
||||||
@@ -432,10 +446,12 @@ class ChatViewModel(
|
|||||||
when (method) {
|
when (method) {
|
||||||
WsMethods.SESSION_CREATE -> {
|
WsMethods.SESSION_CREATE -> {
|
||||||
val resultMap = result as? Map<String, Any?> ?: return
|
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 {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
currentSessionId = sessionId,
|
currentSessionId = storageId,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
messages = emptyList(),
|
messages = emptyList(),
|
||||||
chatTitle = "Hermes",
|
chatTitle = "Hermes",
|
||||||
@@ -444,7 +460,7 @@ class ChatViewModel(
|
|||||||
// Mirror the active session id app-wide so session-scoped
|
// Mirror the active session id app-wide so session-scoped
|
||||||
// drawer screens (e.g. Processes, issue #532) can issue
|
// drawer screens (e.g. Processes, issue #532) can issue
|
||||||
// session-scoped RPCs. See ActiveSessionHolder.
|
// session-scoped RPCs. See ActiveSessionHolder.
|
||||||
ActiveSessionHolder.set(sessionId)
|
ActiveSessionHolder.set(runtimeId)
|
||||||
_streamingState.update { StreamingState() }
|
_streamingState.update { StreamingState() }
|
||||||
addSystemMessage("Session created", persist = true)
|
addSystemMessage("Session created", persist = true)
|
||||||
loadSessions()
|
loadSessions()
|
||||||
@@ -472,8 +488,9 @@ class ChatViewModel(
|
|||||||
|
|
||||||
WsMethods.SESSION_RESUME -> {
|
WsMethods.SESSION_RESUME -> {
|
||||||
val resultMap = result as? Map<String, Any?>
|
val resultMap = result as? Map<String, Any?>
|
||||||
|
runtimeSessionId = resultMap?.get("session_id") as? String
|
||||||
val sessionId =
|
val sessionId =
|
||||||
(resultMap?.get("session_id") as? String)
|
(resultMap?.get("resumed") as? String)
|
||||||
?: _uiState.value.currentSessionId
|
?: _uiState.value.currentSessionId
|
||||||
|
|
||||||
// B8 (Jun 20 2026, kanban t_session_resume): do NOT reload
|
// B8 (Jun 20 2026, kanban t_session_resume): do NOT reload
|
||||||
@@ -487,8 +504,8 @@ class ChatViewModel(
|
|||||||
currentSessionId = sessionId,
|
currentSessionId = sessionId,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Mirror the active session id app-wide (issue #532).
|
// Mirror the active runtime session id app-wide (issue #532).
|
||||||
ActiveSessionHolder.set(sessionId)
|
ActiveSessionHolder.set(runtimeSessionId ?: sessionId)
|
||||||
addSystemMessage("Session resumed")
|
addSystemMessage("Session resumed")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -599,7 +616,8 @@ class ChatViewModel(
|
|||||||
*/
|
*/
|
||||||
fun sendMessage(text: String) {
|
fun sendMessage(text: String) {
|
||||||
if (text.isBlank() && _uiState.value.pendingAttachments.isEmpty()) return
|
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()
|
val trimmed = text.trim()
|
||||||
if (trimmed.startsWith("/", ignoreCase = true)) {
|
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) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
repo.persistMessage(userMessage, sessionId)
|
repo.persistMessage(userMessage, storageSessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upload attachments then submit prompt
|
// Upload attachments then submit prompt
|
||||||
@@ -689,7 +707,7 @@ class ChatViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
wsClient.sendMessage(
|
wsClient.sendMessage(
|
||||||
sessionId,
|
agentSessionId,
|
||||||
fullText,
|
fullText,
|
||||||
onSent = { id -> trackRequest(id, WsMethods.PROMPT_SUBMIT) },
|
onSent = { id -> trackRequest(id, WsMethods.PROMPT_SUBMIT) },
|
||||||
)
|
)
|
||||||
@@ -779,7 +797,7 @@ class ChatViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun dispatchViaRpc(command: String) {
|
private fun dispatchViaRpc(command: String) {
|
||||||
val sessionId = _uiState.value.currentSessionId
|
val sessionId = runtimeSessionId
|
||||||
if (sessionId == null) {
|
if (sessionId == null) {
|
||||||
addAssistantMessage("No active session. Use `/new` to create one.")
|
addAssistantMessage("No active session. Use `/new` to create one.")
|
||||||
return
|
return
|
||||||
@@ -803,7 +821,7 @@ class ChatViewModel(
|
|||||||
*/
|
*/
|
||||||
private fun submitPrompt(text: String) {
|
private fun submitPrompt(text: String) {
|
||||||
if (text.isBlank()) return
|
if (text.isBlank()) return
|
||||||
val sessionId = _uiState.value.currentSessionId ?: return
|
val sessionId = runtimeSessionId ?: return
|
||||||
_uiState.update { it.copy(isAgentTyping = true) }
|
_uiState.update { it.copy(isAgentTyping = true) }
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
wsClient.sendMessage(
|
wsClient.sendMessage(
|
||||||
@@ -830,7 +848,7 @@ class ChatViewModel(
|
|||||||
// ── Session management ───────────────────────────────────────────────
|
// ── Session management ───────────────────────────────────────────────
|
||||||
|
|
||||||
fun interruptSession() {
|
fun interruptSession() {
|
||||||
val sessionId = _uiState.value.currentSessionId ?: return
|
val sessionId = runtimeSessionId ?: return
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
wsClient.send(
|
wsClient.send(
|
||||||
WsMethods.SESSION_INTERRUPT,
|
WsMethods.SESSION_INTERRUPT,
|
||||||
@@ -853,6 +871,7 @@ class ChatViewModel(
|
|||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
wsClient.send(
|
wsClient.send(
|
||||||
WsMethods.SESSION_CREATE,
|
WsMethods.SESSION_CREATE,
|
||||||
|
params = mapOf("source" to "desktop"),
|
||||||
onSent = { id -> trackRequest(id, WsMethods.SESSION_CREATE) },
|
onSent = { id -> trackRequest(id, WsMethods.SESSION_CREATE) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -878,15 +897,7 @@ class ChatViewModel(
|
|||||||
|
|
||||||
fun refreshCurrentSession() {
|
fun refreshCurrentSession() {
|
||||||
val sessionId = _uiState.value.currentSessionId ?: return
|
val sessionId = _uiState.value.currentSessionId ?: return
|
||||||
viewModelScope.launch {
|
loadSessionMessages(sessionId)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refreshSettings() {
|
fun refreshSettings() {
|
||||||
@@ -911,12 +922,16 @@ class ChatViewModel(
|
|||||||
fun switchSession(sessionId: String) {
|
fun switchSession(sessionId: String) {
|
||||||
if (sessionId == _uiState.value.currentSessionId) return
|
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()
|
streamingController.resetStreaming()
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
val title = it.sessions.find { s -> s.id == sessionId }?.title ?: "Hermes"
|
val title = it.sessions.find { s -> s.id == sessionId }?.title ?: "Hermes"
|
||||||
it.copy(
|
it.copy(
|
||||||
isLoading = true,
|
isLoading = true,
|
||||||
|
isLoadingOlder = false,
|
||||||
|
hasOlderMessages = false,
|
||||||
currentSessionId = sessionId,
|
currentSessionId = sessionId,
|
||||||
messages = emptyList(),
|
messages = emptyList(),
|
||||||
chatTitle = title,
|
chatTitle = title,
|
||||||
@@ -928,9 +943,7 @@ class ChatViewModel(
|
|||||||
ActiveSessionHolder.set(sessionId)
|
ActiveSessionHolder.set(sessionId)
|
||||||
_streamingState.update { StreamingState() }
|
_streamingState.update { StreamingState() }
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
// Step 1: Load cached messages first — instant display
|
// Resume the selected desktop session, then load its complete transcript.
|
||||||
loadCachedMessages(sessionId).join()
|
|
||||||
// Step 2: Resume session on server
|
|
||||||
launch(Dispatchers.IO) {
|
launch(Dispatchers.IO) {
|
||||||
wsClient.send(
|
wsClient.send(
|
||||||
WsMethods.SESSION_RESUME,
|
WsMethods.SESSION_RESUME,
|
||||||
@@ -938,9 +951,7 @@ class ChatViewModel(
|
|||||||
onSent = { id -> trackRequest(id, WsMethods.SESSION_RESUME) },
|
onSent = { id -> trackRequest(id, WsMethods.SESSION_RESUME) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Step 3: Load fresh messages from REST and merge
|
|
||||||
loadSessionMessages(sessionId)
|
loadSessionMessages(sessionId)
|
||||||
// Step 4: Refresh session list to get latest titles
|
|
||||||
loadSessions()
|
loadSessions()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -960,62 +971,33 @@ class ChatViewModel(
|
|||||||
|
|
||||||
private fun loadSessionMessages(sessionId: String) {
|
private fun loadSessionMessages(sessionId: String) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val result =
|
val messageCount = fetchServerMessageCount(sessionId)
|
||||||
withContext(Dispatchers.IO) {
|
val offset = (messageCount - MESSAGE_PAGE_SIZE).coerceAtLeast(0)
|
||||||
safeApiCall { ApiClient.hermesApi.getSessionMessages(sessionId) }
|
val result = fetchMessagePage(sessionId, offset, MESSAGE_PAGE_SIZE)
|
||||||
}
|
|
||||||
when (result) {
|
when (result) {
|
||||||
is NetworkResult.Success -> {
|
is NetworkResult.Success -> {
|
||||||
val messagesList = result.data.messages.orEmpty()
|
val chatMessages = mapServerMessages(sessionId, result.data.messages.orEmpty(), offset)
|
||||||
val chatMessages =
|
loadedMessageOffset = offset
|
||||||
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
|
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
repo.persistMessages(chatMessages, sessionId)
|
repo.persistMessages(chatMessages, sessionId)
|
||||||
}
|
}
|
||||||
_uiState.update { state ->
|
_uiState.update { state ->
|
||||||
// Only update if still on the same session
|
|
||||||
if (state.currentSessionId != sessionId) return@update state
|
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(
|
state.copy(
|
||||||
messages = chatMessages + localOnly,
|
messages = chatMessages,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
|
hasOlderMessages = offset > 0,
|
||||||
|
isLoadingOlder = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is NetworkResult.Failure -> {
|
is NetworkResult.Failure -> {
|
||||||
// Don't overwrite messages on REST failure — cached messages
|
|
||||||
// are still valid.
|
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
if (it.currentSessionId != sessionId) return@update it
|
if (it.currentSessionId != sessionId) return@update it
|
||||||
it.copy(
|
it.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
|
isLoadingOlder = false,
|
||||||
errorMessage = "Failed to load messages: ${result.error.message}",
|
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 ───────────────────────────────────────────────────────
|
// ── UI actions ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
fun toggleSessionPicker() {
|
fun toggleSessionPicker() {
|
||||||
@@ -1363,9 +1509,6 @@ class ChatViewModel(
|
|||||||
|
|
||||||
// ── Pending request tracking ─────────────────────────────────────────
|
// ── 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(
|
private fun trackRequest(
|
||||||
id: String,
|
id: String,
|
||||||
method: String,
|
method: String,
|
||||||
|
|||||||
@@ -76,6 +76,8 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
|||||||
import com.m57.hermescontrol.ChatScreen
|
import com.m57.hermescontrol.ChatScreen
|
||||||
import com.m57.hermescontrol.NavigationController
|
import com.m57.hermescontrol.NavigationController
|
||||||
import com.m57.hermescontrol.R
|
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.LocalHermesStatusColors
|
||||||
import com.m57.hermescontrol.theme.LocalSpacing
|
import com.m57.hermescontrol.theme.LocalSpacing
|
||||||
import com.m57.hermescontrol.ui.common.EmptyState
|
import com.m57.hermescontrol.ui.common.EmptyState
|
||||||
@@ -170,11 +172,17 @@ fun SessionsScreen(
|
|||||||
var query by remember { mutableStateOf("") }
|
var query by remember { mutableStateOf("") }
|
||||||
var pruneDays by remember { mutableStateOf("7") }
|
var pruneDays by remember { mutableStateOf("7") }
|
||||||
|
|
||||||
|
val sessionTree = remember(state.sessions) { flattenSessionTree(state.sessions) }
|
||||||
val filteredSessions =
|
val filteredSessions =
|
||||||
remember(query, state.sessions) {
|
remember(query, sessionTree) {
|
||||||
state.sessions.filter { session ->
|
if (query.isBlank()) {
|
||||||
session.title?.contains(query, ignoreCase = true) == true ||
|
sessionTree
|
||||||
session.status?.contains(query, ignoreCase = true) == true
|
} 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,
|
contentPadding = listContentPadding,
|
||||||
verticalArrangement = listItemSpacing,
|
verticalArrangement = listItemSpacing,
|
||||||
) {
|
) {
|
||||||
items(filteredSessions, key = { it.id }) { session ->
|
items(filteredSessions, key = { it.session.id }) { item ->
|
||||||
|
val session = item.session
|
||||||
SessionCard(
|
SessionCard(
|
||||||
session = session,
|
session = session,
|
||||||
|
displayTitle = item.displayTitle,
|
||||||
|
depth = item.depth,
|
||||||
|
branchStem = item.branchStem,
|
||||||
query = query,
|
query = query,
|
||||||
isSelecting = state.isSelecting,
|
isSelecting = state.isSelecting,
|
||||||
isSelected = session.id in state.selectedIds,
|
isSelected = session.id in state.selectedIds,
|
||||||
@@ -589,7 +601,10 @@ fun SessionsScreen(
|
|||||||
@OptIn(ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun SessionCard(
|
private fun SessionCard(
|
||||||
session: com.m57.hermescontrol.data.model.SessionInfo,
|
session: SessionInfo,
|
||||||
|
displayTitle: String,
|
||||||
|
depth: Int,
|
||||||
|
branchStem: String?,
|
||||||
query: String,
|
query: String,
|
||||||
isSelecting: Boolean,
|
isSelecting: Boolean,
|
||||||
isSelected: Boolean,
|
isSelected: Boolean,
|
||||||
@@ -603,7 +618,6 @@ private fun SessionCard(
|
|||||||
) {
|
) {
|
||||||
val spacing = LocalSpacing.current
|
val spacing = LocalSpacing.current
|
||||||
val statusColors = LocalHermesStatusColors.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 isActive = session.status?.lowercase() == "active" || session.status?.lowercase() == "streaming"
|
||||||
val srcIcon = sourceIcon(session.source)
|
val srcIcon = sourceIcon(session.source)
|
||||||
|
|
||||||
@@ -611,6 +625,7 @@ private fun SessionCard(
|
|||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
.padding(start = (depth * 16).dp)
|
||||||
.testTag("session_card_${session.id}")
|
.testTag("session_card_${session.id}")
|
||||||
.combinedClickable(
|
.combinedClickable(
|
||||||
onClick = onCardClick,
|
onClick = onCardClick,
|
||||||
@@ -643,6 +658,16 @@ private fun SessionCard(
|
|||||||
Spacer(modifier = Modifier.width(spacing.sm))
|
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
|
// Source icon
|
||||||
if (srcIcon != null && !isSelecting) {
|
if (srcIcon != null && !isSelecting) {
|
||||||
Icon(
|
Icon(
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package com.m57.hermescontrol.data.model
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class SessionTreeTest {
|
||||||
|
@Test
|
||||||
|
fun nestsBranchesAndUsesDesktopFallbackNames() {
|
||||||
|
val sessions =
|
||||||
|
listOf(
|
||||||
|
SessionInfo(id = "unrelated", title = "Recent root"),
|
||||||
|
SessionInfo(id = "branch-b", parent_session_id = "parent"),
|
||||||
|
SessionInfo(id = "parent", title = "Original"),
|
||||||
|
SessionInfo(id = "branch-a", parent_session_id = "parent", preview = "copied prompt"),
|
||||||
|
SessionInfo(id = "nested", parent_session_id = "branch-a", title = "Named nested branch"),
|
||||||
|
)
|
||||||
|
|
||||||
|
val tree = flattenSessionTree(sessions)
|
||||||
|
|
||||||
|
assertEquals(listOf("unrelated", "parent", "branch-b", "branch-a", "nested"), tree.map { it.session.id })
|
||||||
|
assertEquals(listOf(0, 0, 1, 1, 2), tree.map { it.depth })
|
||||||
|
assertEquals(
|
||||||
|
listOf("Recent root", "Original", "branch 1", "branch 2", "Named nested branch"),
|
||||||
|
tree.map {
|
||||||
|
it.displayTitle
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assertEquals(listOf(null, null, "├─", "└─", "└─"), tree.map { it.branchStem })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun keepsMissingParentsAndCyclesVisible() {
|
||||||
|
val sessions =
|
||||||
|
listOf(
|
||||||
|
SessionInfo(id = "orphan", parent_session_id = "missing", preview = "orphan preview"),
|
||||||
|
SessionInfo(id = "a", parent_session_id = "b"),
|
||||||
|
SessionInfo(id = "b", parent_session_id = "a"),
|
||||||
|
)
|
||||||
|
|
||||||
|
val tree = flattenSessionTree(sessions)
|
||||||
|
|
||||||
|
assertEquals(setOf("orphan", "a", "b"), tree.map { it.session.id }.toSet())
|
||||||
|
assertEquals("orphan preview", tree.first { it.session.id == "orphan" }.displayTitle)
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
-1
@@ -141,7 +141,8 @@ class HermesApiServiceMockWebServerTest {
|
|||||||
"created_at": "2026-06-20T14:30:00Z",
|
"created_at": "2026-06-20T14:30:00Z",
|
||||||
"message_count": 7,
|
"message_count": 7,
|
||||||
"status": "idle",
|
"status": "idle",
|
||||||
"source": "web"
|
"source": "web",
|
||||||
|
"parent_session_id": "abc-123"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"total": 2,
|
"total": 2,
|
||||||
@@ -173,6 +174,7 @@ class HermesApiServiceMockWebServerTest {
|
|||||||
assertEquals(7, second.message_count)
|
assertEquals(7, second.message_count)
|
||||||
assertEquals("idle", second.status)
|
assertEquals("idle", second.status)
|
||||||
assertEquals("web", second.source)
|
assertEquals("web", second.source)
|
||||||
|
assertEquals("abc-123", second.parent_session_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -254,6 +256,24 @@ class HermesApiServiceMockWebServerTest {
|
|||||||
assertEquals("tool_execution", messages[2].type)
|
assertEquals("tool_execution", messages[2].type)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun getSessionMessages_sendsPaginationQuery() =
|
||||||
|
runBlocking {
|
||||||
|
mockServer.enqueue(
|
||||||
|
MockResponse()
|
||||||
|
.setResponseCode(200)
|
||||||
|
.setBody("{\"messages\": []}"),
|
||||||
|
)
|
||||||
|
|
||||||
|
val response = api.getSessionMessages("desktop/session", limit = 150, offset = 300)
|
||||||
|
|
||||||
|
assertTrue(response.isSuccessful)
|
||||||
|
assertEquals(
|
||||||
|
"/api/sessions/desktop/session/messages?limit=150&offset=300",
|
||||||
|
mockServer.takeRequest().path,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun getSessionMessages_withNullRole_doesNotCrash() =
|
fun getSessionMessages_withNullRole_doesNotCrash() =
|
||||||
runBlocking {
|
runBlocking {
|
||||||
|
|||||||
Reference in new issue
Block a user