feat(chat): desktop-parity session resume — warm cache + bounded retry (#766)
Bring the desktop's session reconnection UX to mobile: - Warm-cache fast-path: switchSession paints the cached Room transcript immediately instead of blanking the screen, then refreshes from the server in parallel (stale cache can't clobber a fresher server page) - Bounded auto-retry: a failed session.resume (WS reject or REST transcript failure) retries with exponential backoff 1s->2s->4s->8s, capped at 4 attempts; WS + REST failures in the same cycle count as one - Explicit error recovery: after exhaustion the chat shows an error card with a manual Retry instead of latching the spinner forever; manual retry resets the backoff cycle - Reconnect-aware resume: gateway (re)connect clears stale error state and re-resumes the current session on the new socket - Unit tests: warm-cache paint, bounded retry exhaustion, WS+REST dedupe, manual retry reset, reconnect restart Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
982f8aec4e
commit
5e1ee0b4e5
5 files changed
+498
-6
No files matched your search
@@ -78,6 +78,7 @@ import com.m57.hermescontrol.ui.chat.components.ChatInputBar
|
||||
import com.m57.hermescontrol.ui.chat.components.ChatLifecycleEffects
|
||||
import com.m57.hermescontrol.ui.chat.components.ChatLoadingOverlay
|
||||
import com.m57.hermescontrol.ui.chat.components.ChatMessageList
|
||||
import com.m57.hermescontrol.ui.chat.components.ChatResumeErrorOverlay
|
||||
import com.m57.hermescontrol.ui.chat.components.ChatScrollToBottomFab
|
||||
import com.m57.hermescontrol.ui.chat.components.ContextDetailSheet
|
||||
import com.m57.hermescontrol.ui.chat.components.ContextUsageChip
|
||||
@@ -517,7 +518,14 @@ fun ChatScreen(
|
||||
)
|
||||
|
||||
// Loading overlay
|
||||
ChatLoadingOverlay(isLoading = state.isLoading)
|
||||
ChatLoadingOverlay(isLoading = state.isLoading && state.resumeError == null)
|
||||
|
||||
// Resume-exhausted overlay — explicit error + Retry instead
|
||||
// of an infinite spinner (desktop parity).
|
||||
ChatResumeErrorOverlay(
|
||||
errorMessage = state.resumeError,
|
||||
onRetry = viewModel::retryResumeSession,
|
||||
)
|
||||
|
||||
// Scroll-to-bottom FAB (issue #682): shows while follow is
|
||||
// paused and renders the unseen-message badge.
|
||||
|
||||
@@ -122,6 +122,9 @@ data class ChatUiState(
|
||||
val subagentIndicators: List<SubagentIndicator> = emptyList(),
|
||||
/** Agent todo / plan items (issue #736). */
|
||||
val todos: List<TodoItem> = emptyList(),
|
||||
// Session resume recovery (desktop parity: bounded auto-retry + error UI)
|
||||
val resumeError: String? = null,
|
||||
val isResumeRetrying: Boolean = false,
|
||||
) {
|
||||
/** Convenience — derived from [connectionStatus]. */
|
||||
val isConnected: Boolean get() = connectionStatus == ConnectionStatus.CONNECTED
|
||||
@@ -205,6 +208,15 @@ class ChatViewModel(
|
||||
private var runtimeSessionId: String? = null
|
||||
private var loadedMessageOffset = 0
|
||||
private var isSyncingMessages = false
|
||||
|
||||
// ── Session resume recovery (desktop parity) ────────────────────────
|
||||
// Bounded auto-retry with exponential backoff, mirroring the desktop's
|
||||
// use-route-resume: a failed session.resume retries 1s→2s→4s→8s up to
|
||||
// MAX_RESUME_RETRIES, then surfaces an explicit error + manual Retry
|
||||
// instead of latching the spinner forever.
|
||||
private var resumeRetrySessionId: String? = null
|
||||
private var resumeRetryAttempt = 0
|
||||
private var resumeRetryJob: Job? = null
|
||||
val streamingState: StateFlow<StreamingState> = _streamingState.asStateFlow()
|
||||
|
||||
/** Tracks the auto-clear coroutine for reaction animations. */
|
||||
@@ -354,7 +366,12 @@ class ChatViewModel(
|
||||
// ── WS Event Handling ────────────────────────────────────────────────
|
||||
|
||||
private fun handleGatewayReady() {
|
||||
_uiState.update { it.copy(isLoading = false) }
|
||||
// A (re)connect is a fresh start: clear any stale resume error and
|
||||
// cancel a pending retry — the re-resume below rebinds the session
|
||||
// on the new socket (desktop parity: gatewayBecameOpen re-resumes
|
||||
// even when the route looks already active).
|
||||
_uiState.update { it.copy(isLoading = false, resumeError = null, isResumeRetrying = false) }
|
||||
cancelResumeRetry()
|
||||
addSystemMessage("Connected to Hermes")
|
||||
loadSessions()
|
||||
fetchCommandCatalog()
|
||||
@@ -682,9 +699,16 @@ class ChatViewModel(
|
||||
// the WS round-trip. Calling loadCachedMessages() here would
|
||||
// overwrite any message the user sent between switchSession() and
|
||||
// the server ack, making the chat appear to go blank.
|
||||
// A successful resume also retires any pending retry job and
|
||||
// clears the retry indicators (a reconnect re-resume landing
|
||||
// while a backoff timer was armed must not double-fire).
|
||||
resumeRetryJob?.cancel()
|
||||
resumeRetryJob = null
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
isResumeRetrying = false,
|
||||
resumeError = null,
|
||||
currentSessionId = sessionId,
|
||||
currentSessionModel =
|
||||
if (model != null && provider != null) {
|
||||
@@ -787,6 +811,19 @@ class ChatViewModel(
|
||||
else -> error.toString()
|
||||
}
|
||||
|
||||
// Session resume failures go through the bounded retry (desktop
|
||||
// parity) instead of a one-shot snackbar — the session may be
|
||||
// mid-flush on the gateway or the WS may have just rebound, and a
|
||||
// brief backoff usually clears it. Persistent failure ends in the
|
||||
// explicit error + Retry state.
|
||||
if (method == WsMethods.SESSION_RESUME) {
|
||||
val sessionId = _uiState.value.currentSessionId
|
||||
if (sessionId != null) {
|
||||
handleResumeFailure(sessionId, errorMsg)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Surface error in UI (these are server-pushed RpcError for
|
||||
// fire-and-forget RPCs — awaited RPCs handle their own failure
|
||||
// via the HermesWsClient.request() deferred).
|
||||
@@ -1452,7 +1489,9 @@ class ChatViewModel(
|
||||
fun switchSession(sessionId: String) {
|
||||
if (sessionId == _uiState.value.currentSessionId) return
|
||||
|
||||
// Reset streaming and pagination state before resuming the Desktop session.
|
||||
// Reset streaming, pagination, and any pending resume retry before
|
||||
// resuming the Desktop session.
|
||||
cancelResumeRetry()
|
||||
runtimeSessionId = null
|
||||
loadedMessageOffset = 0
|
||||
streamingController.resetStreaming()
|
||||
@@ -1471,12 +1510,19 @@ class ChatViewModel(
|
||||
fullContextTokens = null,
|
||||
contextBreakdown = null,
|
||||
compressionCount = null,
|
||||
resumeError = null,
|
||||
isResumeRetrying = false,
|
||||
)
|
||||
}
|
||||
// Mirror the active session id app-wide (issue #532).
|
||||
ActiveSessionHolder.set(sessionId)
|
||||
_streamingState.update { StreamingState() }
|
||||
viewModelScope.launch {
|
||||
// Warm-cache fast-path (desktop parity): paint the cached Room
|
||||
// transcript immediately so the screen never sits blank, then load
|
||||
// the fresh server transcript in parallel. If the cache is empty
|
||||
// the spinner stays up until the server page lands.
|
||||
loadCachedMessages(sessionId)
|
||||
// Resume the selected desktop session, then load its complete transcript.
|
||||
launch(Dispatchers.IO) {
|
||||
wsClient.send(
|
||||
@@ -1494,8 +1540,10 @@ class ChatViewModel(
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val cachedMessages = repo.loadMessages(sessionId)
|
||||
_uiState.update { state ->
|
||||
// Only replace if still showing this session
|
||||
if (state.currentSessionId == sessionId) {
|
||||
// Only paint if still showing this session AND no fresher server
|
||||
// page has landed yet (a fast REST fetch must not be clobbered
|
||||
// by a stale cache read that loses the race).
|
||||
if (state.currentSessionId == sessionId && state.messages.isEmpty()) {
|
||||
state.copy(messages = cachedMessages, isLoading = false)
|
||||
} else {
|
||||
state
|
||||
@@ -1533,14 +1581,133 @@ class ChatViewModel(
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
isLoadingOlder = false,
|
||||
errorMessage = "Failed to load messages: ${result.error.message}",
|
||||
)
|
||||
}
|
||||
// Route the transcript failure through the bounded resume
|
||||
// retry (desktop parity) instead of a one-shot snackbar —
|
||||
// a transient backend/network blip recovers on its own,
|
||||
// and a persistent failure ends in an explicit Retry.
|
||||
handleResumeFailure(sessionId, "Failed to load messages: ${result.error.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Session resume recovery (desktop parity) ─────────────────────────
|
||||
|
||||
private fun cancelResumeRetry() {
|
||||
resumeRetryJob?.cancel()
|
||||
resumeRetryJob = null
|
||||
resumeRetrySessionId = null
|
||||
resumeRetryAttempt = 0
|
||||
}
|
||||
|
||||
private fun resumeRetryDelayMs(attempt: Int): Long =
|
||||
minOf(RESUME_RETRY_MAX_MS, RESUME_RETRY_BASE_MS * (1L shl attempt))
|
||||
|
||||
/**
|
||||
* Bounded auto-retry for a failed session resume (mirrors the desktop's
|
||||
* use-route-resume). A failed resume — gateway RPC reject or REST
|
||||
* transcript failure — retries with exponential backoff (1s→2s→4s→8s),
|
||||
* capped at [MAX_RESUME_RETRIES]. After exhaustion the UI gets an
|
||||
* explicit error + manual Retry ([retryResumeSession]) instead of an
|
||||
* infinite spinner.
|
||||
*/
|
||||
private fun handleResumeFailure(
|
||||
sessionId: String,
|
||||
errorMessage: String,
|
||||
) {
|
||||
// Only handle if still on this session.
|
||||
if (_uiState.value.currentSessionId != sessionId) return
|
||||
|
||||
// New session → reset the counter for a fresh backoff cycle.
|
||||
if (resumeRetrySessionId != sessionId) {
|
||||
resumeRetrySessionId = sessionId
|
||||
resumeRetryAttempt = 0
|
||||
}
|
||||
|
||||
if (resumeRetryAttempt >= MAX_RESUME_RETRIES) {
|
||||
// Exhausted — surface the error + manual Retry affordance.
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
isResumeRetrying = false,
|
||||
resumeError = errorMessage,
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// A WS RPC reject and the REST transcript failure for the same resume
|
||||
// land close together — treat them as ONE failure: if a retry is
|
||||
// already armed for this session, don't double-count or re-schedule.
|
||||
if (resumeRetrySessionId == sessionId && resumeRetryJob?.isActive == true) {
|
||||
return
|
||||
}
|
||||
|
||||
val delayMs = resumeRetryDelayMs(resumeRetryAttempt)
|
||||
resumeRetryAttempt++
|
||||
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
isResumeRetrying = true,
|
||||
resumeError = null,
|
||||
)
|
||||
}
|
||||
|
||||
resumeRetryJob?.cancel()
|
||||
resumeRetryJob =
|
||||
viewModelScope.launch {
|
||||
delay(delayMs)
|
||||
// Re-check liveness at fire time: the user may have switched
|
||||
// sessions or the gateway may have reconnected meanwhile.
|
||||
if (_uiState.value.currentSessionId != sessionId) return@launch
|
||||
|
||||
_uiState.update { it.copy(isResumeRetrying = false) }
|
||||
if (_uiState.value.messages.isEmpty()) {
|
||||
_uiState.update { it.copy(isLoading = true) }
|
||||
}
|
||||
// Retry the full resume: rebind the runtime via WS + refresh
|
||||
// the transcript via REST. Both are idempotent.
|
||||
launch(Dispatchers.IO) {
|
||||
wsClient.send(
|
||||
WsMethods.SESSION_RESUME,
|
||||
mapOf("session_id" to sessionId),
|
||||
onSent = { id -> trackRequest(id, WsMethods.SESSION_RESUME) },
|
||||
)
|
||||
}
|
||||
loadSessionMessages(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual retry after the bounded auto-retry exhausted. Clears the
|
||||
* exhausted latch and starts a fresh backoff cycle (mirrors the desktop's
|
||||
* resumeSession: reconnect / reselect / Retry all reset the counter).
|
||||
*/
|
||||
fun retryResumeSession() {
|
||||
val sessionId = _uiState.value.currentSessionId ?: return
|
||||
cancelResumeRetry()
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
resumeError = null,
|
||||
isResumeRetrying = false,
|
||||
isLoading = true,
|
||||
)
|
||||
}
|
||||
viewModelScope.launch {
|
||||
launch(Dispatchers.IO) {
|
||||
wsClient.send(
|
||||
WsMethods.SESSION_RESUME,
|
||||
mapOf("session_id" to sessionId),
|
||||
onSent = { id -> trackRequest(id, WsMethods.SESSION_RESUME) },
|
||||
)
|
||||
}
|
||||
loadSessionMessages(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
fun loadOlderMessages() {
|
||||
val state = _uiState.value
|
||||
val sessionId = state.currentSessionId ?: return
|
||||
@@ -2505,5 +2672,13 @@ class ChatViewModel(
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Max auto-retry attempts for a failed session.resume (desktop parity). */
|
||||
const val MAX_RESUME_RETRIES = 4
|
||||
|
||||
/** Base backoff for resume retries — doubles per attempt, capped at 8s. */
|
||||
const val RESUME_RETRY_BASE_MS = 1_000L
|
||||
|
||||
/** Upper bound for the resume retry backoff delay. */
|
||||
const val RESUME_RETRY_MAX_MS = 8_000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.m57.hermescontrol.R
|
||||
|
||||
/**
|
||||
* Full-screen overlay shown when a session resume has exhausted its bounded
|
||||
* auto-retries (desktop parity). Gives the user an explicit error + manual
|
||||
* Retry instead of an infinite spinner.
|
||||
*/
|
||||
@Composable
|
||||
fun ChatResumeErrorOverlay(
|
||||
errorMessage: String?,
|
||||
onRetry: () -> Unit,
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = errorMessage != null,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background.copy(alpha = 0.7f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Card(
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(4.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Warning,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(40.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.chat_resume_error_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = errorMessage.orEmpty(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Button(onClick = onRetry) {
|
||||
Text(stringResource(R.string.action_retry))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user