feat: gate chat send until session fully resumed (#1187)

This commit is contained in:
M57 authored and GitHub committed 2026-09-17 23:25:12 +03:00
1 parent 9690ce8dc6
commit a415445bbf
13 files changed
+310 -33

No files matched your search

@@ -24,7 +24,8 @@ object ChatInputPolicy {
text: String,
pendingAttachments: List<Any>,
isConnected: Boolean,
): Boolean = (text.isNotBlank() || pendingAttachments.isNotEmpty()) && isConnected
isSessionReady: Boolean,
): Boolean = (text.isNotBlank() || pendingAttachments.isNotEmpty()) && isConnected && isSessionReady
/**
* Whether the input placeholder should read "queued" rather than the plain
@@ -759,15 +759,18 @@ fun ChatScreen(
inputFieldValue = inputFieldValue,
onInputChange = { inputFieldValue = it },
onSend = {
viewModel.sendMessage(inputFieldValue.text)
inputFieldValue = TextFieldValue("")
// Jump to bottom after send (serialized through the controller).
scrollController.jumpToBottom(animated = true)
if (viewModel.sendMessage(inputFieldValue.text)) {
inputFieldValue = TextFieldValue("")
// Jump only after an accepted send. A readiness race keeps the draft intact.
scrollController.jumpToBottom(animated = true)
}
},
onMicTap = mediaLaunchers.onMicTap,
isListening = mediaLaunchers.isListening,
isAgentTyping = state.isAgentTyping,
isConnected = state.isConnected,
isSessionReady = state.isSessionReady,
sessionPreparationFailed = state.resumeError != null,
commandCatalog = state.commandCatalog,
slashUsageCounts = state.slashUsageCounts,
pendingAttachments = state.pendingAttachments,
@@ -181,6 +181,7 @@ data class ChatUiState(
/** Agent todo / plan items (issue #736). */
val todos: List<TodoItem> = emptyList(),
// Session resume recovery (desktop parity: bounded auto-retry + error UI)
val isSessionReady: Boolean = false,
val resumeError: String? = null,
val isResumeRetrying: Boolean = false,
/** Text staged to prefill the composer (e.g. from /undo). */
@@ -587,6 +588,13 @@ class ChatViewModel(
// fire with a stale id and 4001 "session not found";
// handleGatewayReady rebinds it on the re-resume.
runtimeSessionId = null
resumedGeneration = -1L
hydratedGeneration = -1L
activeResumeRequestSequence = ++resumeRequestSequence
activeHydrationRequestSequence = ++hydrationRequestSequence
// Preserve metadata so late results and errors are rejected as stale.
sessionGeneration++
_uiState.update { it.copy(isSessionReady = false) }
// Fail any in-flight awaited RPCs so callers don't hang
// across the disconnect (delegated to HermesWsClient, issue #526).
wsClient.rejectAllPending()
@@ -1250,8 +1258,14 @@ class ChatViewModel(
if (request != null && isStaleSessionRequest(request)) return
when (method) {
WsMethods.SESSION_CREATE -> {
val resultMap = result as? Map<String, Any?> ?: return
val runtimeId = resultMap["session_id"] as? String ?: return
val resultMap = result as? Map<String, Any?>
val runtimeId = (resultMap?.get("session_id") as? String)?.takeIf { it.isNotBlank() }
if (runtimeId == null) {
_uiState.update {
it.copy(isLoading = false, resumeError = "Invalid session creation response")
}
return
}
val storageId = resultMap["stored_session_id"] as? String ?: runtimeId
runtimeSessionId = runtimeId
// The gateway persists the row lazily on the first prompt —
@@ -1261,6 +1275,7 @@ class ChatViewModel(
_uiState.update {
it.copy(
currentSessionId = storageId,
isSessionReady = true,
isLoading = false,
messages = if (pendingInitialPrompt != null) it.messages else emptyList(),
chatTitle = "Hermes",
@@ -1308,7 +1323,7 @@ class ChatViewModel(
// must stay the storage key — storing the runtime id here made
// every later resume 4007 "session not found" (the DB lookup
// misses) and the REST transcript 404.
val runtimeId = resultMap["session_id"] as? String ?: return
val runtimeId = (resultMap["session_id"] as? String)?.takeIf { it.isNotBlank() } ?: return
val storageId = resultMap["stored_session_id"] as? String ?: runtimeId
val generation =
resetSessionState(
@@ -1317,6 +1332,7 @@ class ChatViewModel(
isLoading = false,
)
runtimeSessionId = runtimeId
resumedGeneration = generation
ActiveSessionHolder.set(runtimeId, storageId)
sessionHasServerPresence = false
sessionGoneRecoveryInFlight = false
@@ -1348,16 +1364,22 @@ class ChatViewModel(
WsMethods.SESSION_RESUME -> {
val resultMap = result as? Map<String, Any?>
runtimeSessionId = resultMap?.get("session_id") as? String
val runtimeId = (resultMap?.get("session_id") as? String)?.takeIf { it.isNotBlank() }
if (runtimeId == null) {
val sessionId = request?.sessionId ?: _uiState.value.currentSessionId ?: return
handleResumeFailure(sessionId, sessionGeneration, "Invalid session resume response")
return
}
runtimeSessionId = runtimeId
// Resume succeeded — the gateway confirmed the DB row.
sessionHasServerPresence = true
val sessionId =
request?.sessionId
?: (resultMap?.get("resumed") as? String)
?: (resultMap["resumed"] as? String)
?: _uiState.value.currentSessionId
// Parse session info from backend — model, provider, reasoning_effort
val infoMap = resultMap?.get("info") as? Map<String, Any?>
val infoMap = resultMap["info"] as? Map<String, Any?>
val model = infoMap?.get("model") as? String
val provider = infoMap?.get("provider") as? String
val reasoningEffort = infoMap?.get("reasoning_effort") as? String
@@ -1423,7 +1445,7 @@ class ChatViewModel(
// Reconnect replay: resume payload can carry `pending_approval`
// (server `_session_info_payload`); surface it, then ask for
// the full queue in case more are parked.
val pendingApproval = resultMap?.get("pending_approval") as? Map<*, *>
val pendingApproval = resultMap["pending_approval"] as? Map<*, *>
if (pendingApproval != null) {
approvalsDelegate.maybeSurfacePendingApproval(
pendingApproval,
@@ -1582,6 +1604,7 @@ class ChatViewModel(
it.copy(
isLoading = false,
errorMessage = "Error ($method): $errorMsg",
resumeError = if (method == WsMethods.SESSION_CREATE) errorMsg else it.resumeError,
)
}
}
@@ -1603,8 +1626,9 @@ class ChatViewModel(
* 6. For each file → await `file.attach` (requires session_id), collect @file: refs
* 7. Send `prompt.submit` with text + @file: refs — images auto-picked up by backend
*/
fun sendMessage(text: String) {
if (text.isBlank() && _uiState.value.pendingAttachments.isEmpty()) return
fun sendMessage(text: String): Boolean {
if (!canSubmitMessage()) return false
if (text.isBlank() && _uiState.value.pendingAttachments.isEmpty()) return false
val trimmed = text.trim()
if (trimmed.startsWith("/", ignoreCase = true)) {
@@ -1612,10 +1636,10 @@ class ChatViewModel(
// of requiring the user to hand-type the provider/model.
if (modelSwitchDelegate.isModelPickerCommand(trimmed)) {
openModelPicker()
return
return true
}
handleSlashCommand(trimmed)
return
return true
}
val oversizedAttachment =
@@ -1629,7 +1653,7 @@ class ChatViewModel(
composerTextToRestore = text,
)
}
return
return true
}
// Snapshot + clear attachments so the input bar empties immediately
@@ -1661,7 +1685,7 @@ class ChatViewModel(
// Issue #969: Session creation is still in-flight. Hold the prompt
// so it is dispatched automatically the moment SESSION_CREATE lands.
pendingInitialPrompt = PendingPrompt(text, attachments, wasStreaming, userMessage)
return
return true
}
dispatchPrompt(
@@ -1672,8 +1696,23 @@ class ChatViewModel(
agentSessionId = agentSessionId,
userMessage = userMessage,
)
return true
}
private fun canSubmitMessage(): Boolean =
wsClient.connectionStatus.value == ConnectionStatus.CONNECTED &&
(
(_uiState.value.isSessionReady && runtimeSessionId != null) ||
(
_uiState.value.currentSessionId == null &&
pendingInitialPrompt == null &&
idToMethod.any { (id, method) ->
method == WsMethods.SESSION_CREATE &&
sessionRequestById[id]?.let { !isStaleSessionRequest(it) } == true
}
)
)
private fun dispatchPrompt(
text: String,
attachments: List<Attachment>,
@@ -2867,6 +2906,7 @@ class ChatViewModel(
_uiState.update {
it.copy(
messages = emptyList(),
isSessionReady = false,
currentSessionId = sessionId,
chatTitle = title,
isAgentTyping = false,
@@ -2913,6 +2953,9 @@ class ChatViewModel(
sessionId: String,
generation: Long,
) {
resumedGeneration = -1L
hydratedGeneration = -1L
_uiState.update { it.copy(isSessionReady = false) }
val requestSequence = ++resumeRequestSequence
activeResumeRequestSequence = requestSequence
val profile = AuthManager.activeProfileId.value
@@ -2963,6 +3006,7 @@ class ChatViewModel(
isResumeRetrying = false,
resumeError = null,
errorMessage = null,
isSessionReady = runtimeSessionId != null,
)
}
}
@@ -3037,7 +3081,7 @@ class ChatViewModel(
) {
// Only handle if still on this session.
if (!isCurrentSessionRequest(sessionId, generation)) return
_uiState.update { it.copy(errorMessage = null) }
_uiState.update { it.copy(errorMessage = null, isSessionReady = false) }
// New session → reset the counter for a fresh backoff cycle.
if (resumeRetrySessionId != sessionId) {
@@ -3108,7 +3152,11 @@ class ChatViewModel(
* resumeSession: reconnect / reselect / Retry all reset the counter).
*/
fun retryResumeSession() {
val sessionId = _uiState.value.currentSessionId ?: return
val sessionId = _uiState.value.currentSessionId
if (sessionId == null) {
createNewSession()
return
}
val generation = sessionGeneration
cancelResumeRetry()
_uiState.update {
@@ -80,6 +80,8 @@ fun ChatInputBar(
isAgentTyping: Boolean,
isConnected: Boolean,
commandCatalog: CommandCatalog,
isSessionReady: Boolean,
sessionPreparationFailed: Boolean = false,
slashUsageCounts: Map<String, Int> = emptyMap(),
pendingAttachments: List<Attachment> = emptyList(),
onCameraTap: () -> Unit = {},
@@ -103,7 +105,7 @@ fun ChatInputBar(
// gateway's prompt.submit busy-input policy queues it as the next turn
// (tui_gateway/server.py:_handle_busy_submit), so the message is never
// dropped. Slash commands were already allowed; regular prompts now are too.
val canSend = ChatInputPolicy.canSend(inputFieldValue.text, pendingAttachments, isConnected)
val canSend = ChatInputPolicy.canSend(inputFieldValue.text, pendingAttachments, isConnected, isSessionReady)
// Attachment tray state
var showAttachmentTray by remember { mutableStateOf(false) }
@@ -203,6 +205,22 @@ fun ChatInputBar(
}
}
if (isConnected && !isSessionReady) {
Text(
text =
stringResource(
if (sessionPreparationFailed) {
R.string.chat_session_not_ready
} else {
R.string.chat_session_preparing
},
),
style = MaterialTheme.typography.labelSmall,
color = palette.placeholder,
modifier = Modifier.padding(horizontal = 20.dp).testTag("chat_session_preparing"),
)
}
// ── TOP ROW: Borderless input field ──
Row(
modifier =
@@ -296,6 +314,7 @@ fun ChatInputBar(
reasoningLevel = reasoningLevel,
isListening = isListening,
canSend = canSend,
showSend = inputFieldValue.text.isNotBlank() || pendingAttachments.isNotEmpty(),
onSend = onSend,
onAttachTap = { showAttachmentTray = !showAttachmentTray },
onModelTap = onModelTap,
@@ -95,6 +95,7 @@ fun ComposerToolbar(
onMicTap: () -> Unit,
modifier: Modifier = Modifier,
canSend: Boolean = false,
showSend: Boolean = canSend,
onSend: () -> Unit = {},
canDisableReasoning: Boolean? = null,
supportsReasoning: Boolean? = null,
@@ -360,7 +361,7 @@ fun ComposerToolbar(
// Flat mic / stop button — only while the action button is in send mode
AnimatedVisibility(
visible = canSend,
visible = showSend,
enter = fadeIn() + scaleIn(initialScale = 0.8f),
exit = fadeOut() + scaleOut(targetScale = 0.8f),
) {
@@ -382,10 +383,10 @@ fun ComposerToolbar(
// Action button — send when a send is possible, mic / stop otherwise
FilledIconButton(
onClick = if (canSend) onSend else onMicTap,
enabled = canSend || isConnected,
onClick = if (showSend) onSend else onMicTap,
enabled = if (showSend) canSend else isConnected,
colors =
if (!canSend && isListening) {
if (!showSend && isListening) {
listeningIconButtonColors()
} else {
IconButtonDefaults.filledIconButtonColors(
@@ -398,7 +399,7 @@ fun ComposerToolbar(
.size(ControlSize)
.testTag(
when {
canSend -> "send_button"
showSend -> "send_button"
isListening -> "mic_stop_button"
else -> "mic_button"
},
@@ -407,7 +408,7 @@ fun ComposerToolbar(
Crossfade(
targetState =
when {
canSend -> ActionGlyph.SEND
showSend -> ActionGlyph.SEND
isListening -> ActionGlyph.STOP
else -> ActionGlyph.VOICE
},