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 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(
|
||||
|
||||
Reference in new issue
Block a user