diff --git a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt index 5903ce181d04..339fc9c2a7cc 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt @@ -443,6 +443,7 @@ class NodeRuntime( updateStatus() micCapture.onGatewayConnectionChanged(true) scope.launch { + subscribeOperatorSessionEvents() refreshHomeCanvasOverviewIfConnected() if (voiceReplySpeakerLazy.isInitialized()) { voiceReplySpeaker.refreshConfig() @@ -485,6 +486,14 @@ class NodeRuntime( }, ) + private suspend fun subscribeOperatorSessionEvents() { + try { + operatorSession.request("sessions.subscribe", null) + } catch (err: Throwable) { + Log.d("OpenClawRuntime", "sessions.subscribe failed: ${err.message ?: err::class.java.simpleName}") + } + } + private val nodeSession = GatewaySession( scope = scope, diff --git a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt index acbdfc5561f8..a90a96c43c47 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt @@ -332,6 +332,17 @@ class ChatController( if (payloadJson.isNullOrBlank()) return handleChatEvent(payloadJson) } + "sessions.changed" -> { + if (payloadJson.isNullOrBlank()) { + scope.launch { fetchSessions(limit = _sessions.value.size.takeIf { it > 0 } ?: 100) } + } else { + handleSessionsChangedEvent(payloadJson) + } + } + "session.message" -> { + if (payloadJson.isNullOrBlank()) return + handleSessionMessageEvent(payloadJson) + } "agent" -> { if (payloadJson.isNullOrBlank()) return handleAgentEvent(payloadJson) @@ -353,6 +364,7 @@ class ChatController( ) if (!isCurrentHistoryLoad(sessionKey, _sessionKey.value, generation, historyLoadGeneration.get())) return val history = parseHistory(historyJson, sessionKey = sessionKey, previousMessages = _messages.value) + updateSessionFromHistory(history) prunePersistedOptimisticMessages(history.messages) _messages.value = mergeOptimisticMessages(incoming = history.messages, optimistic = optimisticMessagesByRunId.values) _sessionId.value = history.sessionId @@ -457,6 +469,7 @@ class ChatController( sessionKey = currentSessionKey, previousMessages = _messages.value, ) + updateSessionFromHistory(history) prunePersistedOptimisticMessages(history.messages) _messages.value = mergeOptimisticMessages(incoming = history.messages, optimistic = optimisticMessagesByRunId.values) _sessionId.value = history.sessionId @@ -472,6 +485,28 @@ class ChatController( } } + private fun handleSessionsChangedEvent(payloadJson: String) { + val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return + if (payload["reason"].asStringOrNull() == "delete") { + removeSessionEntry(payload["sessionKey"].asStringOrNull() ?: payload["key"].asStringOrNull()) + return + } + val entry = payload["session"].asObjectOrNull()?.let(::parseSessionEntry) ?: parseSessionEntry(payload) + if (entry != null) { + upsertSessionEntry(entry) + } else { + scope.launch { fetchSessions(limit = _sessions.value.size.takeIf { it > 0 } ?: 100) } + } + } + + private fun handleSessionMessageEvent(payloadJson: String) { + val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return + val entry = payload["session"].asObjectOrNull()?.let(::parseSessionEntry) ?: parseSessionEntry(payload) + if (entry != null) { + upsertSessionEntry(entry) + } + } + private fun handleAgentEvent(payloadJson: String) { val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return val sessionKey = payload["sessionKey"].asStringOrNull()?.trim() @@ -600,6 +635,7 @@ class ChatController( val root = json.parseToJsonElement(historyJson).asObjectOrNull() ?: return ChatHistory(sessionKey, null, null, emptyList()) val sid = root["sessionId"].asStringOrNull() val thinkingLevel = root["thinkingLevel"].asStringOrNull() + val sessionInfo = root["sessionInfo"].asObjectOrNull()?.let { parseSessionEntry(it, fallbackKey = sessionKey) } val array = root["messages"].asArrayOrNull() ?: JsonArray(emptyList()) val messages = @@ -622,22 +658,69 @@ class ChatController( sessionId = sid, thinkingLevel = thinkingLevel, messages = reconcileMessageIds(previous = previousMessages, incoming = messages), + sessionInfo = sessionInfo, ) } private fun parseSessions(jsonString: String): List { val root = json.parseToJsonElement(jsonString).asObjectOrNull() ?: return emptyList() val sessions = root["sessions"].asArrayOrNull() ?: return emptyList() - return sessions.mapNotNull { item -> - val obj = item.asObjectOrNull() ?: return@mapNotNull null - val key = obj["key"].asStringOrNull()?.trim().orEmpty() - if (key.isEmpty()) return@mapNotNull null - val updatedAt = obj["updatedAt"].asLongOrNull() - val displayName = obj["displayName"].asStringOrNull()?.trim() - ChatSessionEntry(key = key, updatedAtMs = updatedAt, displayName = displayName) - } + return sessions.mapNotNull { item -> parseSessionEntry(item.asObjectOrNull()) } } + private fun parseSessionEntry( + obj: JsonObject?, + fallbackKey: String? = null, + ): ChatSessionEntry? { + if (obj == null) return null + val key = + obj["key"].asStringOrNull()?.trim().orEmpty() + .ifEmpty { obj["sessionKey"].asStringOrNull()?.trim().orEmpty() } + .ifEmpty { fallbackKey?.trim().orEmpty() } + if (key.isEmpty()) return null + return ChatSessionEntry( + key = key, + updatedAtMs = obj["updatedAt"].asLongOrNull(), + displayName = obj["displayName"].asStringOrNull()?.trim(), + totalTokens = obj["totalTokens"].asLongOrNull(), + totalTokensFresh = obj["totalTokensFresh"].asBooleanOrNull(), + contextTokens = obj["contextTokens"].asLongOrNull(), + ) + } + + private fun updateSessionFromHistory(history: ChatHistory) { + val info = history.sessionInfo ?: return + upsertSessionEntry(info) + } + + private fun upsertSessionEntry(entry: ChatSessionEntry) { + val current = _sessions.value + val index = current.indexOfFirst { it.key == entry.key } + _sessions.value = + if (index >= 0) { + current.toMutableList().also { it[index] = mergeSessionEntry(it[index], entry) } + } else { + listOf(entry) + current + } + } + + private fun removeSessionEntry(sessionKey: String?) { + val key = sessionKey?.trim()?.takeIf { it.isNotEmpty() } ?: return + _sessions.value = _sessions.value.filterNot { it.key == key } + } + + private fun mergeSessionEntry( + existing: ChatSessionEntry, + next: ChatSessionEntry, + ): ChatSessionEntry = + existing.copy( + updatedAtMs = next.updatedAtMs ?: existing.updatedAtMs, + displayName = next.displayName ?: existing.displayName, + totalTokens = next.totalTokens ?: existing.totalTokens, + totalTokensFresh = next.totalTokensFresh ?: existing.totalTokensFresh, + contextTokens = next.contextTokens ?: existing.contextTokens, + ) + private fun parseRunId(resJson: String): String? = try { json @@ -857,3 +940,9 @@ private fun JsonElement?.asLongOrNull(): Long? = is JsonPrimitive -> content.toLongOrNull() else -> null } + +private fun JsonElement?.asBooleanOrNull(): Boolean? = + when (this) { + is JsonPrimitive -> content.toBooleanStrictOrNull() + else -> null + } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatModels.kt b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatModels.kt index f2341da13767..c43b1c44d51e 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatModels.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatModels.kt @@ -40,6 +40,9 @@ data class ChatSessionEntry( val key: String, val updatedAtMs: Long?, val displayName: String? = null, + val totalTokens: Long? = null, + val totalTokensFresh: Boolean? = null, + val contextTokens: Long? = null, ) /** @@ -50,6 +53,7 @@ data class ChatHistory( val sessionId: String?, val thinkingLevel: String?, val messages: List, + val sessionInfo: ChatSessionEntry? = null, ) /** diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt index 29d192437864..8cf00a8eb31f 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt @@ -74,6 +74,7 @@ import kotlinx.coroutines.withContext import java.text.DateFormat import java.util.Date import java.util.Locale +import kotlin.math.roundToInt /** Full chat surface that wires MainViewModel state to messages, attachments, voice, and composer actions. */ @Composable @@ -95,6 +96,7 @@ fun ChatScreen( val sessions by viewModel.chatSessions.collectAsState() val chatDraft by viewModel.chatDraft.collectAsState() val pendingAssistantAutoSend by viewModel.pendingAssistantAutoSend.collectAsState() + val contextUsage = resolveChatContextUsage(sessionKey = sessionKey, mainSessionKey = mainSessionKey, sessions = sessions) val context = LocalContext.current val resolver = context.contentResolver val scope = rememberCoroutineScope() @@ -196,6 +198,7 @@ fun ChatScreen( onValueChange = { input = it }, attachments = attachments, thinkingLevel = thinkingLevel, + contextUsage = contextUsage, healthOk = healthOk, pendingRunCount = pendingRunCount, onThinkingLevelChange = viewModel::setChatThinkingLevel, @@ -685,6 +688,7 @@ private fun ChatComposer( onValueChange: (String) -> Unit, attachments: List, thinkingLevel: String, + contextUsage: ChatContextUsage, healthOk: Boolean, pendingRunCount: Int, onThinkingLevelChange: (String) -> Unit, @@ -699,7 +703,11 @@ private fun ChatComposer( AttachmentStrip(attachments = attachments, onRemoveAttachment = onRemoveAttachment) } - ChatContextMeter(thinkingLevel = thinkingLevel, onClick = { onThinkingLevelChange(nextThinkingValue(thinkingLevel)) }) + ChatContextMeter( + thinkingLevel = thinkingLevel, + contextUsage = contextUsage, + onClick = { onThinkingLevelChange(nextThinkingValue(thinkingLevel)) }, + ) Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { ChatInputPill(value = value, onValueChange = onValueChange, onPickImages = onPickImages, onVoice = onVoice, modifier = Modifier.weight(1f)) @@ -735,8 +743,10 @@ private fun ChatComposer( @Composable private fun ChatContextMeter( thinkingLevel: String, + contextUsage: ChatContextUsage, onClick: () -> Unit, ) { + val contextFraction = contextMeterWidth(contextUsage) ?: 0f Row( modifier = Modifier.width(178.dp), verticalAlignment = Alignment.CenterVertically, @@ -755,7 +765,13 @@ private fun ChatContextMeter( horizontalArrangement = Arrangement.spacedBy(6.dp), ) { Icon(imageVector = Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(12.dp), tint = ClawTheme.colors.textSubtle) - Text(text = "Context ${contextPercent(thinkingLevel)}%", style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted) + Text( + text = contextMeterLabel(contextUsage, thinkingLevel), + style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), + color = ClawTheme.colors.textMuted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) } } Box( @@ -768,7 +784,7 @@ private fun ChatContextMeter( Box( modifier = Modifier - .fillMaxWidth(thinkingMeterWidth(thinkingLevel)) + .fillMaxWidth(contextFraction) .height(3.dp) .background(ClawTheme.colors.primary, RoundedCornerShape(999.dp)), ) @@ -902,6 +918,32 @@ private fun isActiveSessionChoice( return choiceKey == current } +internal data class ChatContextUsage( + val totalTokens: Long?, + val totalTokensFresh: Boolean?, + val contextTokens: Long?, +) + +internal fun resolveChatContextUsage( + sessionKey: String, + mainSessionKey: String, + sessions: List, +): ChatContextUsage { + val entry = + sessions.firstOrNull { + isActiveSessionChoice( + choiceKey = it.key, + sessionKey = sessionKey, + mainSessionKey = mainSessionKey, + ) + } + return ChatContextUsage( + totalTokens = entry?.totalTokens, + totalTokensFresh = entry?.totalTokensFresh, + contextTokens = entry?.contextTokens, + ) +} + @Composable private fun SendButton( enabled: Boolean, @@ -958,16 +1000,28 @@ private fun nextThinkingValue(value: String): String = else -> "off" } -/** Maps thinking presets to the visual context meter fill fraction. */ -private fun thinkingMeterWidth(value: String): Float = - when (value.lowercase(Locale.US)) { - "low" -> 0.34f - "medium" -> 0.58f - "high" -> 0.82f - else -> 0.18f - } +internal fun contextMeterWidth(usage: ChatContextUsage): Float? { + if (usage.totalTokensFresh == false) return null + val total = usage.totalTokens?.takeIf { it >= 0L } ?: return null + val context = usage.contextTokens?.takeIf { it > 0L } ?: return null + return (total.toDouble() / context.toDouble()).coerceIn(0.0, 1.0).toFloat() +} -private fun contextPercent(value: String): Int = (thinkingMeterWidth(value) * 100).toInt() +internal fun contextMeterLabel( + usage: ChatContextUsage, + thinkingLevel: String, +): String { + val contextLabel = contextMeterWidth(usage)?.let { "Context ${(it * 100).roundToInt()}%" } ?: "Context --" + return "$contextLabel · ${contextMeterThinkingLabel(thinkingLevel)}" +} + +internal fun contextMeterThinkingLabel(value: String): String = + when (value.lowercase(Locale.US)) { + "low" -> "low" + "medium" -> "medium" + "high" -> "high" + else -> "off" + } private fun formatChatTimestamp(timestampMs: Long): String = DateFormat.getTimeInstance(DateFormat.SHORT, Locale.getDefault()).format(Date(timestampMs)) diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatContextMeterTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatContextMeterTest.kt new file mode 100644 index 000000000000..32bb338b91b3 --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatContextMeterTest.kt @@ -0,0 +1,84 @@ +package ai.openclaw.app.ui.chat + +import ai.openclaw.app.chat.ChatSessionEntry +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class ChatContextMeterTest { + @Test + fun contextMeterUsesActiveSessionTokenBudget() { + val sessions = + listOf( + ChatSessionEntry(key = "main", updatedAtMs = 1L, displayName = "Main", totalTokens = 8_000L, totalTokensFresh = true, contextTokens = 10_000L), + ChatSessionEntry( + key = "agent:main:mobile:test-device", + updatedAtMs = 2L, + displayName = "Phone", + totalTokens = 1_250L, + totalTokensFresh = true, + contextTokens = 5_000L, + ), + ) + + val usage = + resolveChatContextUsage( + sessionKey = "agent:main:mobile:test-device", + mainSessionKey = "main", + sessions = sessions, + ) + + assertEquals(ChatContextUsage(totalTokens = 1_250L, totalTokensFresh = true, contextTokens = 5_000L), usage) + assertEquals(0.25f, contextMeterWidth(usage)) + assertEquals("Context 25% · high", contextMeterLabel(usage, "high")) + } + + @Test + fun contextMeterResolvesCanonicalMainAlias() { + val sessions = + listOf( + ChatSessionEntry( + key = "agent:main:node-phone", + updatedAtMs = 1L, + displayName = "Main", + totalTokens = 41_000L, + totalTokensFresh = true, + contextTokens = 100_000L, + ), + ) + + val usage = + resolveChatContextUsage( + sessionKey = "main", + mainSessionKey = "agent:main:node-phone", + sessions = sessions, + ) + + assertEquals(ChatContextUsage(totalTokens = 41_000L, totalTokensFresh = true, contextTokens = 100_000L), usage) + assertEquals("Context 41% · off", contextMeterLabel(usage, "off")) + } + + @Test + fun contextMeterDoesNotInventPercentWhenBudgetIsMissing() { + val usage = ChatContextUsage(totalTokens = 8_200L, totalTokensFresh = true, contextTokens = null) + + assertNull(contextMeterWidth(usage)) + assertEquals("Context -- · medium", contextMeterLabel(usage, "medium")) + } + + @Test + fun contextMeterClampsOverfullSessions() { + val usage = ChatContextUsage(totalTokens = 150_000L, totalTokensFresh = true, contextTokens = 100_000L) + + assertEquals(1.0f, contextMeterWidth(usage)) + assertEquals("Context 100% · low", contextMeterLabel(usage, "low")) + } + + @Test + fun contextMeterDoesNotDisplayStaleTokenUsage() { + val usage = ChatContextUsage(totalTokens = 82_000L, totalTokensFresh = false, contextTokens = 100_000L) + + assertNull(contextMeterWidth(usage)) + assertEquals("Context -- · high", contextMeterLabel(usage, "high")) + } +}