feat(android): port the working claw indicator, wait phrases, and turn recap (#112221)

* feat(android): decode session run metadata

* feat(android): animate the working claw

* feat(android): show settled turn recaps

* fix(android): preserve legacy cache migration

* fix(android): expire hidden recap watches

* chore(i18n): refresh native source anchors

* fix(android): drop unsettled hidden recaps

* fix(android): keep claw timing local

* fix(android): anchor settled turn recaps

* chore(i18n): refresh native source anchors after rebase

* fix(android): route chat status copy through native i18n
This commit is contained in:
Peter Steinberger
2026-07-21 04:43:27 -07:00
committed by GitHub
parent 8481c69cbe
commit aeeff149b9
19 changed files with 2073 additions and 302 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,177 @@
{
"formatVersion": 1,
"database": {
"version": 2,
"identityHash": "6faa4088ccfb9dd4dd8bc3e6619de163",
"entities": [
{
"tableName": "cached_sessions",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`gatewayId` TEXT NOT NULL, `agentId` TEXT NOT NULL, `sessionKey` TEXT NOT NULL, `displayName` TEXT, `updatedAtMs` INTEGER, `status` TEXT, `startedAt` INTEGER, `endedAt` INTEGER, `runtimeMs` INTEGER, `outputTokens` INTEGER, `hasRunMetadata` INTEGER NOT NULL, `rowOrder` INTEGER NOT NULL, PRIMARY KEY(`gatewayId`, `agentId`, `sessionKey`))",
"fields": [
{
"fieldPath": "gatewayId",
"columnName": "gatewayId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "agentId",
"columnName": "agentId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sessionKey",
"columnName": "sessionKey",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT"
},
{
"fieldPath": "updatedAtMs",
"columnName": "updatedAtMs",
"affinity": "INTEGER"
},
{
"fieldPath": "status",
"columnName": "status",
"affinity": "TEXT"
},
{
"fieldPath": "startedAt",
"columnName": "startedAt",
"affinity": "INTEGER"
},
{
"fieldPath": "endedAt",
"columnName": "endedAt",
"affinity": "INTEGER"
},
{
"fieldPath": "runtimeMs",
"columnName": "runtimeMs",
"affinity": "INTEGER"
},
{
"fieldPath": "outputTokens",
"columnName": "outputTokens",
"affinity": "INTEGER"
},
{
"fieldPath": "hasRunMetadata",
"columnName": "hasRunMetadata",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "rowOrder",
"columnName": "rowOrder",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"gatewayId",
"agentId",
"sessionKey"
]
}
},
{
"tableName": "cached_messages",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`gatewayId` TEXT NOT NULL, `agentId` TEXT NOT NULL, `sessionKey` TEXT NOT NULL, `rowOrder` INTEGER NOT NULL, `role` TEXT NOT NULL, `textPartsJson` TEXT NOT NULL, `timestampMs` INTEGER, `idempotencyKey` TEXT, PRIMARY KEY(`gatewayId`, `agentId`, `sessionKey`, `rowOrder`))",
"fields": [
{
"fieldPath": "gatewayId",
"columnName": "gatewayId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "agentId",
"columnName": "agentId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sessionKey",
"columnName": "sessionKey",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "rowOrder",
"columnName": "rowOrder",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "role",
"columnName": "role",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "textPartsJson",
"columnName": "textPartsJson",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "timestampMs",
"columnName": "timestampMs",
"affinity": "INTEGER"
},
{
"fieldPath": "idempotencyKey",
"columnName": "idempotencyKey",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"gatewayId",
"agentId",
"sessionKey",
"rowOrder"
]
}
},
{
"tableName": "cached_gateway_owners",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`gatewayId` TEXT NOT NULL, `agentId` TEXT NOT NULL, PRIMARY KEY(`gatewayId`))",
"fields": [
{
"fieldPath": "gatewayId",
"columnName": "gatewayId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "agentId",
"columnName": "agentId",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"gatewayId"
]
}
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '6faa4088ccfb9dd4dd8bc3e6619de163')"
]
}
}

View File

@@ -10,6 +10,7 @@ import ai.openclaw.app.chat.ChatPlanStep
import ai.openclaw.app.chat.ChatQuestionPrompt
import ai.openclaw.app.chat.ChatSessionEntry
import ai.openclaw.app.chat.ChatThinkingLevelSelection
import ai.openclaw.app.chat.ChatTranscriptAnchorState
import ai.openclaw.app.chat.ChatWidgetResource
import ai.openclaw.app.chat.GatewayDefaultAgentOwner
import ai.openclaw.app.chat.MessageSpeechState
@@ -617,6 +618,8 @@ class MainViewModel private constructor(
val chatSessionOwnerAgentId: StateFlow<String?> = runtimeState(initial = null) { it.chatSessionOwnerAgentId }
val chatSessionId: StateFlow<String?> = runtimeState(initial = null) { it.chatSessionId }
val chatMessages: StateFlow<List<ChatMessage>> = runtimeState(initial = emptyList()) { it.chatMessages }
val chatTranscriptAnchor: StateFlow<ChatTranscriptAnchorState?> =
runtimeState(initial = null) { it.chatTranscriptAnchor }
val chatHistoryLoading: StateFlow<Boolean> = runtimeState(initial = false) { it.chatHistoryLoading }
val chatError: StateFlow<String?> = runtimeState(initial = null) { it.chatError }
val chatHealthOk: StateFlow<Boolean> = runtimeState(initial = false) { it.chatHealthOk }

View File

@@ -15,6 +15,7 @@ import ai.openclaw.app.chat.ChatQuestionPrompt
import ai.openclaw.app.chat.ChatSessionDeletion
import ai.openclaw.app.chat.ChatSessionEntry
import ai.openclaw.app.chat.ChatThinkingLevelSelection
import ai.openclaw.app.chat.ChatTranscriptAnchorState
import ai.openclaw.app.chat.ChatTranscriptCache
import ai.openclaw.app.chat.ChatWidgetResource
import ai.openclaw.app.chat.ChatWidgetSurface
@@ -2728,6 +2729,7 @@ class NodeRuntime private constructor(
internal val gatewayComposerDefaultAgentOwner: StateFlow<GatewayDefaultAgentOwner?> = chat.composerDefaultAgentOwner
val chatSessionId: StateFlow<String?> = chat.sessionId
val chatMessages: StateFlow<List<ChatMessage>> = chat.messages
val chatTranscriptAnchor: StateFlow<ChatTranscriptAnchorState?> = chat.transcriptAnchor
val chatHistoryLoading: StateFlow<Boolean> = chat.historyLoading
val chatError: StateFlow<String?> = chat.errorText
val chatHealthOk: StateFlow<Boolean> = chat.healthOk

View File

@@ -192,6 +192,9 @@ class ChatController internal constructor(
private val _messages = MutableStateFlow<List<ChatMessage>>(emptyList())
val messages: StateFlow<List<ChatMessage>> = _messages.asStateFlow()
private val _transcriptAnchor = MutableStateFlow<ChatTranscriptAnchorState?>(null)
val transcriptAnchor: StateFlow<ChatTranscriptAnchorState?> = _transcriptAnchor.asStateFlow()
// True while the transcript shown came from the offline cache and no live history replaced it yet.
private val _messagesFromCache = MutableStateFlow(false)
val messagesFromCache: StateFlow<Boolean> = _messagesFromCache.asStateFlow()
@@ -2608,6 +2611,7 @@ class ChatController internal constructor(
generation: Long,
updateSessionInfo: Boolean,
runIdsToReconcile: Set<String> = emptySet(),
markCompletedTranscript: Boolean = false,
): HistoryRefreshResult {
val requestSequence = historyRequestSequence.incrementAndGet()
val runIdsOwnedAtRequest = synchronized(pendingRuns) { pendingRuns.toSet() }
@@ -2702,8 +2706,23 @@ class ChatController internal constructor(
!unresolvedRepliesByRunId.containsKey(it)
}.forEach(::clearPendingRun)
}
val nextMessages = mergeOptimisticMessages(incoming = history.messages, optimistic = optimisticMessagesByRunId.values)
_messagesFromCache.value = false
_messages.value = mergeOptimisticMessages(incoming = history.messages, optimistic = optimisticMessagesByRunId.values)
_messages.value = nextMessages
val previousAnchor = _transcriptAnchor.value?.takeIf { it.sessionKey == sessionKey }
val completionSettled =
markCompletedTranscript &&
runIdsToReconcile.none(unresolvedRepliesByRunId::containsKey) &&
history.sessionInfo?.endedAt != null
_transcriptAnchor.value =
ChatTranscriptAnchorState(
sessionKey = sessionKey,
newestItemId = nextMessages.lastOrNull()?.id,
completedEndedAt =
if (completionSettled) history.sessionInfo.endedAt else previousAnchor?.completedEndedAt,
completedNewestItemId =
if (completionSettled) history.messages.lastOrNull()?.id else previousAnchor?.completedNewestItemId,
)
_sessionId.value = history.sessionId
markLiveHistoryApplied(sessionKey = sessionKey, sessionId = history.sessionId, generation = generation)
_historyLoading.value = false
@@ -4454,6 +4473,7 @@ class ChatController internal constructor(
generation,
updateSessionInfo = true,
runIdsToReconcile = runIdsToReconcile,
markCompletedTranscript = runIdsToReconcile.isNotEmpty(),
)
} catch (err: CancellationException) {
throw err
@@ -4475,6 +4495,7 @@ class ChatController internal constructor(
generation = generation,
updateSessionInfo = updateSessionInfo,
runIdsToReconcile = runIdsToReconcile,
markCompletedTranscript = runIdsToReconcile.isNotEmpty(),
)
} catch (_: Throwable) {
// best-effort
@@ -4613,6 +4634,17 @@ class ChatController internal constructor(
obj["activeRunIds"]
.asArrayOrNull()
?.mapNotNull { it.asStringOrNull()?.trim()?.takeIf(String::isNotEmpty) },
status = obj["status"].asStringOrNull()?.trim(),
startedAt = obj["startedAt"].asLongOrNull(),
endedAt = obj["endedAt"].asLongOrNull(),
runtimeMs = obj["runtimeMs"].asLongOrNull(),
outputTokens = obj["outputTokens"].asLongOrNull(),
hasRunMetadata =
"status" in obj ||
"startedAt" in obj ||
"endedAt" in obj ||
"runtimeMs" in obj ||
"outputTokens" in obj,
)
}
@@ -5379,6 +5411,12 @@ internal fun mergeChatSessionEntry(
preserveExistingContextUsage -> existing.hasContextUsageMetadata || next.contextTokens != null
else -> next.hasContextUsageMetadata
},
status = if (next.hasRunMetadata) next.status else existing.status,
startedAt = if (next.hasRunMetadata) next.startedAt else existing.startedAt,
endedAt = if (next.hasRunMetadata) next.endedAt else existing.endedAt,
runtimeMs = if (next.hasRunMetadata) next.runtimeMs else existing.runtimeMs,
outputTokens = if (next.hasRunMetadata) next.outputTokens else existing.outputTokens,
hasRunMetadata = existing.hasRunMetadata || next.hasRunMetadata,
)
}

View File

@@ -26,6 +26,13 @@ data class ChatMessage(
val idempotencyKey: String? = null,
)
data class ChatTranscriptAnchorState(
val sessionKey: String,
val newestItemId: String?,
val completedEndedAt: Long?,
val completedNewestItemId: String?,
)
/**
* One content part in a chat message; binary parts carry base64 plus their MIME metadata.
*/
@@ -165,6 +172,13 @@ data class ChatSessionEntry(
val hasContextUsageMetadata: Boolean = totalTokens != null || totalTokensFresh != null || contextTokens != null,
val hasActiveRun: Boolean? = null,
val activeRunIds: List<String>? = null,
val status: String? = null,
val startedAt: Long? = null,
val endedAt: Long? = null,
val runtimeMs: Long? = null,
val outputTokens: Long? = null,
val hasRunMetadata: Boolean =
status != null || startedAt != null || endedAt != null || runtimeMs != null || outputTokens != null,
)
/** Local fallback for server-side `sessions.list` search over cached entries. */

View File

@@ -75,6 +75,12 @@ internal data class CachedSessionEntity(
val sessionKey: String,
val displayName: String?,
val updatedAtMs: Long?,
val status: String?,
val startedAt: Long?,
val endedAt: Long?,
val runtimeMs: Long?,
val outputTokens: Long?,
val hasRunMetadata: Boolean,
// Preserves gateway list order so offline session rows render in the familiar order.
val rowOrder: Int,
)
@@ -258,6 +264,12 @@ class RoomChatTranscriptCache internal constructor(
updatedAtMs = row.updatedAtMs,
ownerAgentId = agent,
displayName = row.displayName,
status = row.status,
startedAt = row.startedAt,
endedAt = row.endedAt,
runtimeMs = row.runtimeMs,
outputTokens = row.outputTokens,
hasRunMetadata = row.hasRunMetadata,
)
}
}
@@ -305,6 +317,12 @@ class RoomChatTranscriptCache internal constructor(
sessionKey = entry.key,
displayName = entry.displayName,
updatedAtMs = entry.updatedAtMs,
status = entry.status,
startedAt = entry.startedAt,
endedAt = entry.endedAt,
runtimeMs = entry.runtimeMs,
outputTokens = entry.outputTokens,
hasRunMetadata = entry.hasRunMetadata,
rowOrder = 0,
)
} ?: dao.session(gateway, agent, retainedKey)
@@ -320,6 +338,12 @@ class RoomChatTranscriptCache internal constructor(
sessionKey = session.key,
displayName = session.displayName,
updatedAtMs = session.updatedAtMs,
status = session.status,
startedAt = session.startedAt,
endedAt = session.endedAt,
runtimeMs = session.runtimeMs,
outputTokens = session.outputTokens,
hasRunMetadata = session.hasRunMetadata,
rowOrder = index,
)
}
@@ -380,6 +404,12 @@ class RoomChatTranscriptCache internal constructor(
sessionKey = key,
displayName = null,
updatedAtMs = null,
status = null,
startedAt = null,
endedAt = null,
runtimeMs = null,
outputTokens = null,
hasRunMetadata = false,
rowOrder = dao.nextSessionRowOrder(gateway, agent),
),
),

View File

@@ -76,7 +76,7 @@ internal interface ClientStateControlDao {
/** Disposable gateway-derived projections. Schema mismatches and corruption rebuild this file. */
@Database(
entities = [CachedSessionEntity::class, CachedMessageEntity::class, CachedGatewayOwnerEntity::class],
version = 1,
version = 2,
exportSchema = true,
)
internal abstract class GatewayCacheDatabase : RoomDatabase() {
@@ -151,9 +151,19 @@ internal abstract class ClientStateDatabase : RoomDatabase() {
*
* Runtime reads and writes never use this type after [AndroidClientDatabases.start] completes.
*/
@Entity(tableName = "cached_sessions", primaryKeys = ["gatewayId", "agentId", "sessionKey"])
internal data class LegacyCachedSessionEntity(
val gatewayId: String,
val agentId: String,
val sessionKey: String,
val displayName: String?,
val updatedAtMs: Long?,
val rowOrder: Int,
)
@Database(
entities = [
CachedSessionEntity::class,
LegacyCachedSessionEntity::class,
CachedMessageEntity::class,
OutboxCommandEntity::class,
OutboxAttachmentEntity::class,
@@ -165,8 +175,6 @@ internal abstract class ClientStateDatabase : RoomDatabase() {
exportSchema = false,
)
internal abstract class LegacyChatDatabase : RoomDatabase() {
abstract fun dao(): ChatCacheDao
abstract fun outboxDao(): ChatOutboxDao
companion object {

View File

@@ -9,6 +9,7 @@ import ai.openclaw.app.chat.MessageSpeechPhase
import ai.openclaw.app.chat.MessageSpeechState
import ai.openclaw.app.chat.normalizeVisibleChatMessageRole
import ai.openclaw.app.i18n.nativeString
import ai.openclaw.app.i18n.nativeStringResource
import ai.openclaw.app.tools.ToolDisplayRegistry
import ai.openclaw.app.ui.MobileColorsAccessor
import ai.openclaw.app.ui.design.ClawTheme
@@ -63,7 +64,6 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
@@ -71,6 +71,8 @@ import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
@@ -363,17 +365,29 @@ private fun linkPreviewDomain(url: String): String =
/** Assistant placeholder shown while a run is active but no text has streamed yet. */
@Composable
fun ChatTypingIndicatorBubble() {
fun ChatTypingIndicatorBubble(
runKey: String,
observedAtElapsedMs: Long,
) {
val elapsedMs = rememberWorkingElapsedMs(observedAtElapsedMs)
val phrase = workingPhraseText(seed = runKey, elapsedMs = elapsedMs)
ChatBubbleContainer(
style = bubbleStyle("assistant"),
roleLabel = roleLabel("assistant"),
) {
Row(
modifier = Modifier.clearAndSetSemantics { contentDescription = nativeString("Working") },
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
DotPulse(color = mobileTextSecondary)
Text(nativeString("Thinking..."), style = mobileCallout, color = mobileTextSecondary)
WorkingClawIcon(runKey = runKey, color = mobileAccent)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(formatLocalizedChatDurationCompact(elapsedMs), style = mobileCallout, color = mobileTextSecondary)
phrase?.let { Text(nativeStringResource("· \$phrase", it), style = mobileCallout, color = mobileTextSecondary) }
}
}
}
}
@@ -625,27 +639,6 @@ internal fun ChatBase64Image(
}
}
@Composable
private fun DotPulse(color: Color) {
Row(horizontalArrangement = Arrangement.spacedBy(5.dp), verticalAlignment = Alignment.CenterVertically) {
PulseDot(alpha = 0.38f, color = color)
PulseDot(alpha = 0.62f, color = color)
PulseDot(alpha = 0.90f, color = color)
}
}
@Composable
private fun PulseDot(
alpha: Float,
color: Color,
) {
Surface(
modifier = Modifier.size(6.dp).alpha(alpha),
shape = CircleShape,
color = color,
) {}
}
/** Shared code block renderer used by chat Markdown. */
@Composable
fun ChatCodeBlock(

View File

@@ -18,6 +18,7 @@ import ai.openclaw.app.chat.ChatQuestionPrompt
import ai.openclaw.app.chat.ChatSessionEntry
import ai.openclaw.app.chat.ChatThinkingLevelOption
import ai.openclaw.app.chat.ChatThinkingLevelSelection
import ai.openclaw.app.chat.ChatTranscriptAnchorState
import ai.openclaw.app.chat.ChatWidgetResource
import ai.openclaw.app.chat.MessageSpeechPhase
import ai.openclaw.app.chat.MessageSpeechState
@@ -53,6 +54,7 @@ import ai.openclaw.app.ui.gatewayDiagnosticsEndpoint
import ai.openclaw.app.ui.gatewayStatusForDisplay
import ai.openclaw.app.ui.localizedUppercase
import ai.openclaw.app.ui.mobileCallout
import android.os.SystemClock
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.core.LinearEasing
@@ -240,6 +242,7 @@ fun ChatScreen(
onOpenGatewaySettings: () -> Unit,
) {
val messages by viewModel.chatMessages.collectAsState()
val transcriptAnchor by viewModel.chatTranscriptAnchor.collectAsState()
val historyLoading by viewModel.chatHistoryLoading.collectAsState()
val errorText by viewModel.chatError.collectAsState()
val pendingRunCount by viewModel.pendingRunCount.collectAsState()
@@ -285,6 +288,14 @@ fun ChatScreen(
fallbackSupported = thinkingSupportedForSelection(selectedModelRef, modelCatalog),
)
val contextUsage = resolveChatContextUsage(sessionKey = sessionKey, mainSessionKey = mainSessionKey, sessions = sessions)
val activeSession =
sessions.firstOrNull {
isActiveSessionChoice(
choiceKey = it.key,
sessionKey = sessionKey,
mainSessionKey = mainSessionKey,
)
}
val gatewayAddress = gatewayDiagnosticsEndpoint(remoteAddress = remoteAddress, manualHost = manualHost, manualPort = manualPort, manualTls = manualTls)
val gatewayProblemMessage = gatewayConnectionDisplay.problem?.message?.takeIf { it.isNotBlank() }
val offlineStatus = gatewayStatusForDisplay(gatewayProblemMessage ?: gatewayConnectionDisplay.statusText)
@@ -632,7 +643,9 @@ fun ChatScreen(
ChatMessageList(
sessionKey = sessionKey,
session = activeSession,
messages = messages,
transcriptAnchor = transcriptAnchor,
historyLoading = historyLoading,
pendingRunCount = pendingRunCount,
pendingToolCalls = pendingToolCalls,
@@ -1120,7 +1133,9 @@ private fun HeaderIcon(
@Composable
private fun ChatMessageList(
sessionKey: String,
session: ChatSessionEntry?,
messages: List<ChatMessage>,
transcriptAnchor: ChatTranscriptAnchorState?,
historyLoading: Boolean,
pendingRunCount: Int,
pendingToolCalls: List<ChatPendingToolCall>,
@@ -1141,7 +1156,7 @@ private fun ChatMessageList(
resolveInlineWidgetResource: suspend (String, ChatWidgetResource?) -> ChatWidgetResource?,
modifier: Modifier = Modifier,
) {
val timeline =
val baseTimeline =
remember(messages, pendingRunCount, pendingToolCalls, questions, streamingAssistantText, outboxItems, recoveryOutboxItems) {
buildChatTimeline(
messages = messages,
@@ -1153,12 +1168,33 @@ private fun ChatMessageList(
questions = questions,
)
}
val indicatorVisible = pendingRunCount > 0
val workingRunTracker = remember(sessionKey) { ChatWorkingRunTracker(sessionKey) }
val workingRun = workingRunTracker.resolve(indicatorVisible, session, SystemClock.elapsedRealtime())
val turnRecapResolver = remember { TurnRecapResolver() }
val turnRecap =
turnRecapResolver.resolve(
sessionKey = sessionKey,
indicatorVisible = indicatorVisible,
row = session,
transcript =
TurnRecapTranscriptState(
sessionKey = transcriptAnchor?.sessionKey,
newestItemId = transcriptAnchor?.newestItemId,
completedEndedAt = transcriptAnchor?.completedEndedAt,
completedNewestItemId = transcriptAnchor?.completedNewestItemId,
),
)
val timeline = remember(baseTimeline, turnRecap) { baseTimeline.withTurnRecap(turnRecap) }
val readerScroll =
rememberChatReaderScrollController(
sessionKey = sessionKey,
timeline = timeline,
historyLoading = historyLoading,
)
DisposableEffect(sessionKey, turnRecapResolver) {
onDispose { turnRecapResolver.abandonActiveWatch(sessionKey) }
}
Box(modifier = modifier.fillMaxWidth()) {
LazyColumn(
@@ -1208,6 +1244,7 @@ private fun ChatMessageList(
is ChatTimelineItem.PendingTools -> ToolBubble(toolCalls = item.toolCalls)
is ChatTimelineItem.QuestionPrompt ->
ChatQuestionCard(prompt = item.prompt, onSubmit = onResolveQuestion, onSkip = onSkipQuestion)
is ChatTimelineItem.TurnRecapSummary -> ChatTurnRecapRow(item.recap)
is ChatTimelineItem.StreamingAssistant ->
ChatBubble(
messageId = null,
@@ -1221,7 +1258,12 @@ private fun ChatMessageList(
inlineWidgetResolverReady = healthOk,
resolveInlineWidgetResource = resolveInlineWidgetResource,
)
ChatTimelineItem.Thinking -> ChatThinkingBubble()
ChatTimelineItem.Thinking -> {
val run = workingRun
if (run != null) {
ChatTypingIndicatorBubble(runKey = run.key, observedAtElapsedMs = run.observedAtElapsedMs)
}
}
}
}
}
@@ -1272,6 +1314,55 @@ private fun ChatMessageList(
}
}
internal data class ChatWorkingRun(
val key: String,
val observedAtElapsedMs: Long,
val authoritativeRunId: String?,
val authoritativeStartedAtMs: Long?,
)
internal class ChatWorkingRunTracker(
private val sessionKey: String,
) {
private var current: ChatWorkingRun? = null
fun resolve(
indicatorVisible: Boolean,
session: ChatSessionEntry?,
nowElapsedMs: Long,
): ChatWorkingRun? {
if (!indicatorVisible) {
current = null
return null
}
val runId = session?.activeRunIds?.lastOrNull()
val startedAt = session?.startedAt?.takeIf { session.endedAt == null }
val previous = current
val replacementByRunId = previous?.authoritativeRunId != null && runId != null && previous.authoritativeRunId != runId
val replacementByStart =
previous?.authoritativeStartedAtMs != null && startedAt != null && previous.authoritativeStartedAtMs != startedAt
val replacement = replacementByRunId || replacementByStart
if (previous == null || replacement) {
return ChatWorkingRun(
key = runId ?: "$sessionKey:${startedAt ?: nowElapsedMs}",
observedAtElapsedMs = nowElapsedMs,
authoritativeRunId = runId,
authoritativeStartedAtMs = startedAt,
).also { current = it }
}
val adoptsRunId = previous.authoritativeRunId == null && runId != null
val adoptsStartedAt = previous.authoritativeStartedAtMs == null && startedAt != null
if (adoptsRunId || adoptsStartedAt) {
current =
previous.copy(
authoritativeRunId = runId ?: previous.authoritativeRunId,
authoritativeStartedAtMs = startedAt ?: previous.authoritativeStartedAtMs,
)
}
return current
}
}
internal fun showChatLoadingPlaceholder(
historyLoading: Boolean,
healthOk: Boolean,
@@ -1620,16 +1711,6 @@ private fun ToolBubble(toolCalls: List<ChatPendingToolCall>) {
}
}
@Composable
private fun ChatThinkingBubble() {
ClawPanel {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
ClawStatusPill(text = nativeString("Thinking"), status = ClawStatus.Warning)
Text(text = nativeString("OpenClaw is preparing a response."), style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
}
}
@Composable
private fun ChatNotice(
title: String,

View File

@@ -39,6 +39,10 @@ internal sealed class ChatTimelineItem {
val prompt: ChatQuestionPrompt,
) : ChatTimelineItem()
data class TurnRecapSummary(
val recap: TurnRecap,
) : ChatTimelineItem()
object Thinking : ChatTimelineItem()
}
@@ -194,6 +198,18 @@ internal fun ChatTimeline.containsUserMessageVersion(version: String): Boolean =
message.role.trim().equals("user", ignoreCase = true) && stableMessageVersion(message) == version
}
internal fun ChatTimeline.withTurnRecap(recap: TurnRecap?): ChatTimeline {
if (recap == null) return this
// reverseLayout makes index 0 the newest visual edge. The recap replaces the terminal
// thinking slot there, while shifting the saved user-message anchor to the same row.
return copy(
items = listOf(ChatTimelineItem.TurnRecapSummary(recap)) + items,
readAnchorIndex = readAnchorIndex?.plus(1),
latestContentIndex = 0,
latestContentVersion = "$latestContentVersion:recap=${recap.runtimeMs}:${recap.outputTokens ?: ""}",
)
}
// Reader restoration only needs to detect changes at the live edge. Avoid hashing
// the full transcript whenever a streamed response updates.
private fun latestContentVersion(
@@ -273,6 +289,7 @@ internal fun chatTimelineItemKey(item: ChatTimelineItem): String =
is ChatTimelineItem.OutboxRecoveryHeader -> "outbox-recovery-header"
is ChatTimelineItem.PendingTools -> "tools"
is ChatTimelineItem.QuestionPrompt -> "question:${item.prompt.record.id}"
is ChatTimelineItem.TurnRecapSummary -> "turn-recap"
is ChatTimelineItem.StreamingAssistant -> "stream"
ChatTimelineItem.Thinking -> "thinking"
}

View File

@@ -0,0 +1,257 @@
package ai.openclaw.app.ui.chat
import ai.openclaw.app.chat.ChatSessionEntry
import ai.openclaw.app.i18n.nativeStringResource
import ai.openclaw.app.ui.design.ClawTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import java.util.Locale
internal data class TurnRecap(
val runtimeMs: Long,
val outputTokens: Long?,
)
internal data class TurnRecapTranscriptState(
val sessionKey: String?,
val newestItemId: String?,
val completedEndedAt: Long?,
val completedNewestItemId: String?,
)
internal data class TurnRecapTokenFormat(
val singular: Boolean,
val count: String,
)
/**
* [baselineEndedAt] is the session row's endedAt when the working indicator appeared: the
* previous run's terminal stamp, or null once the run-start patch cleared it. Only a row whose
* endedAt moved past that baseline belongs to the run this pane watched. [settled] freezes the
* first recap while [settledTranscriptItemId] still identifies the transcript's newest item.
*/
private data class TurnRecapWatch(
var watching: Boolean,
/** False until a session row was observed; without a baseline, a later terminal is ambiguous. */
var baselineKnown: Boolean,
var baselineEndedAt: Long?,
/** A stamp changed while the claw was still up, so later stamps cannot be attributed safely. */
var absorbedTerminal: Boolean,
/** First idle render after the indicator cleared; canceled queued sends must expire promptly. */
var settleStartedAt: Long?,
var pendingTerminal: TurnRecap?,
var pendingTerminalEndedAt: Long?,
val settled: TurnRecap?,
val settledTranscriptItemId: String?,
val tracksTranscript: Boolean,
)
/**
* Session rows have no run identity. The watched terminal normally arrives moments after the
* indicator clears, so an unresolved watch expires after this window instead of matching an
* unrelated later completion. An unrelated completion inside the window remains an accepted,
* cosmetic ambiguity until the gateway supplies a terminal-row run id.
*/
internal const val TURN_RECAP_SETTLE_WINDOW_MS = 30_000L
internal class TurnRecapResolver(
private val nowMs: () -> Long = System::currentTimeMillis,
) {
private val watches = mutableMapOf<String, TurnRecapWatch>()
/** Leaving before settlement destroys attribution; settled recaps remain until superseded. */
fun abandonActiveWatch(sessionKey: String) {
val watch = watches[sessionKey] ?: return
if (watch.settled == null) watches.remove(sessionKey)
}
/**
* Watches while the indicator is visible, then resolves the first fresh terminal row. Only a
* clean `done` with runtime data produces a recap; every other fresh terminal consumes quietly.
*/
fun resolve(
sessionKey: String,
indicatorVisible: Boolean,
row: ChatSessionEntry?,
): TurnRecap? =
resolveInternal(
sessionKey = sessionKey,
indicatorVisible = indicatorVisible,
row = row,
transcript = null,
)
fun resolve(
sessionKey: String,
indicatorVisible: Boolean,
row: ChatSessionEntry?,
transcript: TurnRecapTranscriptState,
): TurnRecap? =
resolveInternal(
sessionKey = sessionKey,
indicatorVisible = indicatorVisible,
row = row,
transcript = transcript,
)
private fun resolveInternal(
sessionKey: String,
indicatorVisible: Boolean,
row: ChatSessionEntry?,
transcript: TurnRecapTranscriptState?,
): TurnRecap? {
val watch = watches[sessionKey]
val rowEndedAt = row?.endedAt
if (indicatorVisible) {
if (watch == null || !watch.watching) {
watches[sessionKey] =
TurnRecapWatch(
watching = true,
baselineKnown = row != null,
baselineEndedAt = rowEndedAt,
absorbedTerminal = false,
settleStartedAt = null,
pendingTerminal = null,
pendingTerminalEndedAt = null,
settled = null,
settledTranscriptItemId = null,
tracksTranscript = transcript != null,
)
} else if (!watch.baselineKnown) {
if (row != null) {
watch.baselineKnown = true
watch.baselineEndedAt = rowEndedAt
}
} else if (rowEndedAt != null && rowEndedAt != watch.baselineEndedAt) {
watch.baselineEndedAt = rowEndedAt
watch.absorbedTerminal = true
}
return null
}
if (watch == null) return null
watch.watching = false
watch.settled?.let { settled ->
if (
!watch.tracksTranscript ||
transcript?.sessionKey != sessionKey ||
watch.settledTranscriptItemId == transcript.newestItemId
) {
return settled
}
// A newer transcript turn has replaced the content this recap summarized. Session rows do
// not expose enough run identity to reposition it safely, so discard it.
watches.remove(sessionKey)
return null
}
if (watch.absorbedTerminal || !watch.baselineKnown) {
// Attribution is ambiguous, so fail quiet instead of freezing another run's numbers.
watches.remove(sessionKey)
return null
}
if (watch.settleStartedAt == null) {
watch.settleStartedAt = nowMs()
} else if (nowMs() - checkNotNull(watch.settleStartedAt) > TURN_RECAP_SETTLE_WINDOW_MS) {
watches.remove(sessionKey)
return null
}
val isStale =
rowEndedAt == null ||
(watch.baselineEndedAt != null && rowEndedAt <= checkNotNull(watch.baselineEndedAt))
if (isStale) {
// No watched terminal yet. Stamps never regress, so <= stays stale until the bounded expiry.
return null
}
// Any fresh non-success concludes the watch. Waiting past it could attach a later unrelated
// success to this turn.
val runtimeMs = row.runtimeMs
if (row.status != "done" || runtimeMs == null) {
watches.remove(sessionKey)
return null
}
val terminal = TurnRecap(runtimeMs = runtimeMs, outputTokens = row.outputTokens)
if (watch.pendingTerminalEndedAt != null && watch.pendingTerminalEndedAt != rowEndedAt) {
// Session rows have no run id. Once another terminal replaces the candidate, attribution is
// gone even if a history refresh completes inside the settlement window.
watches.remove(sessionKey)
return null
}
if (
watch.tracksTranscript &&
(
transcript?.sessionKey != sessionKey ||
transcript.completedEndedAt != rowEndedAt
)
) {
// The terminal session row can arrive before the terminal-triggered chat.history snapshot.
// Keep waiting so its final item becomes the recap anchor, not an intermediate tool row.
watch.pendingTerminal = terminal
watch.pendingTerminalEndedAt = rowEndedAt
return null
}
if (watch.tracksTranscript && transcript?.newestItemId != transcript?.completedNewestItemId) {
// Newer transcript content already superseded the completed snapshot before this pane could
// settle it, so there is no safe recap attribution left to display.
watches.remove(sessionKey)
return null
}
watches.remove(sessionKey)
val settled = watch.pendingTerminal ?: terminal
watches[sessionKey] =
watch.copy(
settled = settled,
settledTranscriptItemId = transcript?.completedNewestItemId,
)
return settled
}
}
@Composable
internal fun ChatTurnRecapRow(recap: TurnRecap) {
val duration = formatLocalizedChatDurationCompact(recap.runtimeMs.coerceAtLeast(1_000L))
val tokens =
recap.outputTokens?.let { count ->
val format = turnRecapTokenFormat(count)
if (format.singular) {
nativeStringResource("1 token")
} else {
nativeStringResource("\$count tokens", format.count)
}
}
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(7.dp),
) {
WorkingClawIcon(runKey = "turn-recap", color = ClawTheme.colors.primary, parked = true)
Text(
text = nativeStringResource("Done in \$duration", duration),
style = ClawTheme.type.caption,
color = ClawTheme.colors.textMuted,
)
tokens?.let {
Text(text = nativeStringResource("·"), style = ClawTheme.type.caption, color = ClawTheme.colors.textSubtle)
Text(text = it, style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted)
}
}
}
internal fun turnRecapTokenFormat(count: Long): TurnRecapTokenFormat = TurnRecapTokenFormat(singular = count == 1L, count = formatCompactTokenCount(count))
internal fun formatCompactTokenCount(count: Long): String {
fun decimal(value: Double): String = String.format(Locale.US, "%.1f", value).removeSuffix(".0")
return when {
count >= 1_000_000L -> "${decimal(count / 1_000_000.0)}M"
count >= 1_000L -> {
val thousands = decimal(count / 1_000.0)
if (thousands == "1000") "${decimal(count / 1_000_000.0)}M" else "${thousands}k"
}
else -> count.toString()
}
}

View File

@@ -0,0 +1,412 @@
package ai.openclaw.app.ui.chat
import ai.openclaw.app.i18n.nativeString
import ai.openclaw.app.i18n.nativeStringResource
import android.database.ContentObserver
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import android.provider.Settings
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.withTransform
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.PathParser
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.delay
import kotlin.math.roundToLong
import kotlin.random.Random
private const val DEFAULT_CLAW_CYCLE_MS = 2_400L
private const val FLURRY_CLAW_CYCLE_MS = 1_300L
private const val SPIN_CLAW_CYCLE_MS = 3_600L
internal const val WORKING_PHRASE_SHOW_AFTER_MS = 30_000L
internal const val WORKING_PHRASE_ROTATE_EVERY_MS = 45_000L
private val clawBodyPath by lazy {
PathParser()
.parsePathString(
"M9.6 9.2 A5.6 5.6 0 1 0 9.6 20.4 A5.6 5.6 0 0 0 9.6 9.2 Z " +
"M10 20 C14 20.9 17.9 19.5 20.1 16.1 C20.6 15.4 20.05 14.5 19.25 14.65 " +
"C17.1 15 14.9 14.4 13.2 13 L10.6 16 Z",
).toPath()
}
private val clawJawPath by lazy {
PathParser()
.parsePathString(
"M6 10.6 C6.6 4.4 12.4 0.8 17.6 2.8 C20.8 4 22.8 6.8 23 9.8 " +
"C23.07 10.9 21.9 11.4 21.1 10.7 C19.4 9.2 16.9 8.7 14.7 9.5 " +
"C13.4 10 12.3 10.9 11.6 12.1 L7.2 12.4 Z",
).toPath()
}
internal enum class WorkingClawStance {
Default,
Southpaw,
Flurry,
Spin,
Shadowbox,
Backflip,
}
private val stanceWeights =
listOf(
WorkingClawStance.Default to 66,
WorkingClawStance.Southpaw to 20,
WorkingClawStance.Flurry to 5,
WorkingClawStance.Spin to 4,
WorkingClawStance.Shadowbox to 3,
WorkingClawStance.Backflip to 2,
)
private val processStanceSalt = Random.nextInt()
internal fun workingClawHash(value: String): Int {
var hash = 0x811c9dc5.toInt()
value.forEach { character ->
hash = (hash xor character.code) * 0x01000193
}
return hash
}
internal fun pickWorkingClawStance(
runKey: String,
salt: Int = processStanceSalt,
): WorkingClawStance {
var roll = ((workingClawHash(runKey) xor salt).toUInt().toLong() % 1_000L).toInt()
stanceWeights.forEach { (stance, weight) ->
val buckets = weight * 10
if (roll < buckets) return stance
roll -= buckets
}
return WorkingClawStance.Default
}
private data class ClawKeyframe(
val phase: Float,
val value: Float,
)
private val easeOut = CubicBezierEasing(0f, 0f, 0.58f, 1f)
private val flexFrames =
frames(0f to 0f, 0.06f to 0f, 0.10f to -4f, 0.16f to 3f, 0.22f to -4f, 0.26f to -4f, 0.32f to 3f, 0.42f to 0f, 1f to 0f)
private val snipFrames =
frames(0f to -10f, 0.06f to -10f, 0.10f to -26f, 0.16f to 4f, 0.22f to -24f, 0.26f to -24f, 0.32f to 4f, 0.42f to -10f, 1f to -10f)
private val comboXFrames =
frames(0f to 0f, 0.08f to 0f, 0.12f to -2f, 0.16f to 5f, 0.22f to -2f, 0.26f to -2f, 0.30f to 5f, 0.38f to 0f, 0.46f to -3f, 0.52f to 8f, 0.62f to 0f, 1f to 0f)
private val comboRotationFrames =
frames(0f to 0f, 0.38f to 0f, 0.46f to -6f, 0.52f to 4f, 0.62f to 0f, 1f to 0f)
private val comboJawFrames =
frames(0f to -10f, 0.08f to -10f, 0.12f to -16f, 0.16f to 4f, 0.22f to -16f, 0.26f to -16f, 0.30f to 4f, 0.38f to -10f, 0.46f to -18f, 0.52f to 6f, 0.62f to -10f, 1f to -10f)
private val backflipRotationFrames =
frames(0f to 0f, 0.55f to 0f, 0.62f to -120f, 0.70f to -240f, 0.78f to -360f, 1f to -360f)
private val backflipYFrames = frames(0f to 0f, 0.55f to 0f, 0.62f to -3f, 0.70f to -3f, 0.78f to 0f, 1f to 0f)
private val powAlphaFrames = frames(0f to 0f, 0.46f to 0f, 0.52f to 1f, 0.58f to 0f, 1f to 0f)
private val powScaleFrames = frames(0f to 0.4f, 0.46f to 0.4f, 0.52f to 1.2f, 0.58f to 1.5f, 1f to 1.5f)
private fun frames(vararg values: Pair<Float, Float>): List<ClawKeyframe> = values.map { (phase, value) -> ClawKeyframe(phase, value) }
private fun sampleFrames(
keyframes: List<ClawKeyframe>,
phase: Float,
): Float {
val bounded = phase.coerceIn(0f, 1f)
val nextIndex = keyframes.indexOfFirst { it.phase >= bounded }.takeIf { it >= 0 } ?: keyframes.lastIndex
if (nextIndex == 0) return keyframes.first().value
val previous = keyframes[nextIndex - 1]
val next = keyframes[nextIndex]
if (next.phase == previous.phase) return next.value
val progress = easeOut.transform((bounded - previous.phase) / (next.phase - previous.phase))
return previous.value + (next.value - previous.value) * progress
}
private data class WorkingClawPose(
val rotationZ: Float = 0f,
val rotationY: Float = 0f,
val translationXDp: Float = 0f,
val translationYDp: Float = 0f,
val jawRotation: Float = -10f,
val powAlpha: Float = 0f,
val powScale: Float = 0.4f,
)
private fun workingClawPose(
stance: WorkingClawStance,
phase: Float,
): WorkingClawPose =
when (stance) {
WorkingClawStance.Spin ->
WorkingClawPose(
rotationY = phase * 360f,
jawRotation = sampleFrames(snipFrames, phase),
)
WorkingClawStance.Shadowbox ->
WorkingClawPose(
rotationZ = sampleFrames(comboRotationFrames, phase),
translationXDp = sampleFrames(comboXFrames, phase),
jawRotation = sampleFrames(comboJawFrames, phase),
powAlpha = sampleFrames(powAlphaFrames, phase),
powScale = sampleFrames(powScaleFrames, phase),
)
WorkingClawStance.Backflip ->
WorkingClawPose(
rotationZ = sampleFrames(backflipRotationFrames, phase),
translationYDp = sampleFrames(backflipYFrames, phase),
jawRotation = sampleFrames(snipFrames, phase),
)
WorkingClawStance.Default,
WorkingClawStance.Southpaw,
WorkingClawStance.Flurry,
->
WorkingClawPose(
rotationZ = sampleFrames(flexFrames, phase),
jawRotation = sampleFrames(snipFrames, phase),
)
}
@Composable
internal fun WorkingClawIcon(
runKey: String,
color: Color,
modifier: Modifier = Modifier,
parked: Boolean = false,
) {
val stance = remember(runKey, parked) { if (parked) WorkingClawStance.Default else pickWorkingClawStance(runKey) }
val density = LocalDensity.current
val animationsEnabled = rememberSystemAnimationsEnabled() && !parked
val cycleMs =
when (stance) {
WorkingClawStance.Flurry -> FLURRY_CLAW_CYCLE_MS
WorkingClawStance.Spin -> SPIN_CLAW_CYCLE_MS
else -> DEFAULT_CLAW_CYCLE_MS
}
var phase by remember(runKey) { mutableFloatStateOf(0f) }
LaunchedEffect(animationsEnabled, runKey, cycleMs) {
if (!animationsEnabled) {
phase = 0f
return@LaunchedEffect
}
var bornNanos = Long.MIN_VALUE
while (true) {
withFrameNanos { frameNanos ->
if (bornNanos == Long.MIN_VALUE) bornNanos = frameNanos
val cycleNanos = cycleMs * 1_000_000L
phase = ((frameNanos - bornNanos) % cycleNanos).toFloat() / cycleNanos.toFloat()
}
}
}
val pose =
when {
parked -> WorkingClawPose(rotationZ = 8f, jawRotation = -4f)
animationsEnabled -> workingClawPose(stance, phase)
else -> WorkingClawPose()
}
val iconWidth = if (stance == WorkingClawStance.Shadowbox) 30.dp else 18.dp
Box(modifier = modifier.size(width = iconWidth, height = 20.dp), contentAlignment = Alignment.CenterStart) {
Canvas(
modifier =
Modifier
.size(18.dp)
.graphicsLayer {
rotationZ = pose.rotationZ
rotationY = pose.rotationY
translationX = with(density) { pose.translationXDp.dp.toPx() }
translationY = with(density) { pose.translationYDp.dp.toPx() }
scaleX = if (stance == WorkingClawStance.Southpaw) -1f else 1f
cameraDistance = with(density) { 60.dp.toPx() }
},
) {
val scale = size.minDimension / 24f
withTransform({ scale(scale, scale, pivot = Offset.Zero) }) {
drawPath(path = clawBodyPath, color = color)
withTransform({ rotate(pose.jawRotation, pivot = Offset(8.6f, 11f)) }) {
drawPath(path = clawJawPath, color = color)
}
}
}
if (stance == WorkingClawStance.Shadowbox && animationsEnabled) {
Text(
text = nativeStringResource(""),
color = color,
fontSize = 11.sp,
lineHeight = 11.sp,
modifier =
Modifier
.offset(x = 19.dp, y = (-4).dp)
.graphicsLayer {
alpha = pose.powAlpha
scaleX = pose.powScale
scaleY = pose.powScale
},
)
}
}
}
@Composable
private fun rememberSystemAnimationsEnabled(): Boolean {
val context = LocalContext.current
val resolver = context.contentResolver
var scale by remember(resolver) { mutableFloatStateOf(readAnimatorDurationScale(resolver)) }
DisposableEffect(resolver) {
val observer =
object : ContentObserver(Handler(Looper.getMainLooper())) {
override fun onChange(selfChange: Boolean) {
scale = readAnimatorDurationScale(resolver)
}
}
resolver.registerContentObserver(Settings.Global.getUriFor(Settings.Global.ANIMATOR_DURATION_SCALE), false, observer)
scale = readAnimatorDurationScale(resolver)
onDispose { resolver.unregisterContentObserver(observer) }
}
return scale > 0f
}
private fun readAnimatorDurationScale(resolver: android.content.ContentResolver): Float = Settings.Global.getFloat(resolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f)
@Composable
internal fun rememberWorkingElapsedMs(observedAtElapsedMs: Long): Long {
var nowElapsedMs by remember(observedAtElapsedMs) { mutableLongStateOf(SystemClock.elapsedRealtime()) }
LaunchedEffect(observedAtElapsedMs) {
while (true) {
nowElapsedMs = SystemClock.elapsedRealtime()
delay(1_000L)
}
}
return (nowElapsedMs - observedAtElapsedMs).coerceAtLeast(0L)
}
internal enum class ChatDurationUnit {
Day,
Hour,
Minute,
Second,
}
internal fun formatChatDurationCompact(
durationMs: Long,
formatPart: (Long, ChatDurationUnit) -> String = ::formatEnglishChatDurationPart,
): String {
var remaining = (durationMs.coerceAtLeast(1_000L) / 1_000.0).roundToLong().coerceAtLeast(1L)
val units =
listOf(
86_400L to ChatDurationUnit.Day,
3_600L to ChatDurationUnit.Hour,
60L to ChatDurationUnit.Minute,
1L to ChatDurationUnit.Second,
)
val parts = mutableListOf<String>()
units.forEach { (seconds, unit) ->
if (parts.size == 2) return@forEach
val count = remaining / seconds
if (count > 0L) {
parts += formatPart(count, unit)
remaining %= seconds
}
}
return parts.joinToString(" ")
}
private fun formatEnglishChatDurationPart(
count: Long,
unit: ChatDurationUnit,
): String =
when (unit) {
ChatDurationUnit.Day -> "${count}d"
ChatDurationUnit.Hour -> "${count}h"
ChatDurationUnit.Minute -> "${count}m"
ChatDurationUnit.Second -> "${count}s"
}
internal fun formatLocalizedChatDurationCompact(durationMs: Long): String =
formatChatDurationCompact(durationMs) { count, unit ->
when (unit) {
ChatDurationUnit.Day -> {
val days = count
nativeString("\${days}d", days)
}
ChatDurationUnit.Hour -> {
val hours = count
nativeString("\${hours}h", hours)
}
ChatDurationUnit.Minute -> {
val minutes = count
nativeString("\${minutes}m", minutes)
}
ChatDurationUnit.Second -> {
val seconds = count
nativeString("\${seconds}s", seconds)
}
}
}
internal fun workingPhraseIndex(
seed: String,
bucket: Long,
): Int {
val length = WORKING_PHRASE_COUNT
val offset = workingClawHash("$seed:offset").toUInt().toLong() % length
val stride = 1L + (workingClawHash("$seed:stride").toUInt().toLong() % (length - 1))
return ((offset + (bucket % length) * stride) % length).toInt()
}
internal fun workingPhraseIndexForElapsed(
seed: String,
elapsedMs: Long,
): Int? {
if (elapsedMs < WORKING_PHRASE_SHOW_AFTER_MS) return null
val bucket = (elapsedMs - WORKING_PHRASE_SHOW_AFTER_MS) / WORKING_PHRASE_ROTATE_EVERY_MS
return workingPhraseIndex(seed, bucket)
}
@Composable
internal fun workingPhraseText(
seed: String,
elapsedMs: Long,
): String? = workingPhraseIndexForElapsed(seed, elapsedMs)?.let { localizedWorkingPhrase(it) + "" }
private const val WORKING_PHRASE_COUNT = 19
@Composable
private fun localizedWorkingPhrase(index: Int): String =
when (index) {
0 -> nativeStringResource("Shelling")
1 -> nativeStringResource("Scuttling")
2 -> nativeStringResource("Clawing")
3 -> nativeStringResource("Pinching")
4 -> nativeStringResource("Molting")
5 -> nativeStringResource("Bubbling")
6 -> nativeStringResource("Tiding")
7 -> nativeStringResource("Reefing")
8 -> nativeStringResource("Cracking")
9 -> nativeStringResource("Sifting")
10 -> nativeStringResource("Brining")
11 -> nativeStringResource("Nautiling")
12 -> nativeStringResource("Krilling")
13 -> nativeStringResource("Barnacling")
14 -> nativeStringResource("Lobstering")
15 -> nativeStringResource("Tidepooling")
16 -> nativeStringResource("Pearling")
17 -> nativeStringResource("Snapping")
18 -> nativeStringResource("Surfacing")
else -> error("working phrase index out of range: $index")
}

View File

@@ -179,4 +179,34 @@ class ChatControllerSessionPolicyTest {
assertEquals(10L, merged.lastReadAt)
assertEquals(20L, merged.lastActivityAt)
}
@Test
fun sessionMergeReplacesRunMetadataAsOneSnapshot() {
val existing =
ChatSessionEntry(
key = "agent:main:phone",
updatedAtMs = 1L,
status = "done",
startedAt = 100L,
endedAt = 200L,
runtimeMs = 100L,
outputTokens = 12L,
)
val running =
ChatSessionEntry(
key = "agent:main:phone",
updatedAtMs = 2L,
status = "running",
startedAt = 300L,
hasRunMetadata = true,
)
val merged = mergeChatSessionEntry(existing, running)
assertEquals("running", merged.status)
assertEquals(300L, merged.startedAt)
assertEquals(null, merged.endedAt)
assertEquals(null, merged.runtimeMs)
assertEquals(null, merged.outputTokens)
}
}

View File

@@ -24,7 +24,7 @@ class ClientDatabasesTest {
val databases = open(names, registeredGatewayIds = setOf("gateway-test"))
try {
assertEquals(
1,
2,
databases
.gatewayCacheDatabase()
.openHelper.writableDatabase.version,

View File

@@ -148,6 +148,37 @@ class RoomChatTranscriptCacheTest {
assertEquals(emptyList<ChatMessage>(), store.loadTranscript("gateway-a", "main", "session-55"))
}
@Test
fun sessionRoundTripKeepsRunMetadata() =
runTest {
val store = cache()
store.saveSessions(
gatewayId = "gateway-a",
agentId = "main",
sessions =
listOf(
ChatSessionEntry(
key = "main",
updatedAtMs = 20L,
status = "done",
startedAt = 1_000L,
endedAt = 5_000L,
runtimeMs = 4_000L,
outputTokens = 485L,
),
),
)
val loaded = store.loadSessions("gateway-a", "main").single()
assertEquals("done", loaded.status)
assertEquals(1_000L, loaded.startedAt)
assertEquals(5_000L, loaded.endedAt)
assertEquals(4_000L, loaded.runtimeMs)
assertEquals(485L, loaded.outputTokens)
assertTrue(loaded.hasRunMetadata)
}
@Test
fun transcriptForSessionOutsideFullCachedListSurvivesEviction() =
runTest {

View File

@@ -76,6 +76,29 @@ class ChatTimelineTest {
assertEquals("user-1", timeline.latestUserMessageId)
}
@Test
fun finishedTurnRecapUsesNewestSlotWithoutChangingReaderAnchorRow() {
val user = textMessage(id = "user-1", role = "user", text = "hello")
val assistant = textMessage(id = "assistant-1", role = "assistant", text = "done")
val timeline =
buildChatTimeline(
messages = listOf(user, assistant),
pendingRunCount = 0,
pendingToolCalls = emptyList(),
streamingAssistantText = null,
)
val withRecap = timeline.withTurnRecap(TurnRecap(runtimeMs = 2_000L, outputTokens = 10L))
assertEquals(
listOf("turn-recap", "message:assistant-1", "message:user-1"),
withRecap.items.map(::chatTimelineItemKey),
)
assertEquals(0, withRecap.latestContentIndex)
assertEquals(2, withRecap.readAnchorIndex)
assertEquals("user-1", withRecap.latestUserMessageId)
}
@Test
fun emptyTimelineHasNoScrollTarget() {
val timeline =

View File

@@ -0,0 +1,311 @@
package ai.openclaw.app.ui.chat
import ai.openclaw.app.chat.ChatSessionEntry
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Test
class ChatTurnRecapResolverTest {
private val session = "agent:main:main"
private val previousEndedAt = 900_000L
private val runEndedAt = 1_000_000L
private fun row(
status: String,
endedAt: Long? = null,
runtimeMs: Long? = null,
outputTokens: Long? = null,
): ChatSessionEntry =
ChatSessionEntry(
key = session,
updatedAtMs = endedAt,
status = status,
endedAt = endedAt,
runtimeMs = runtimeMs,
outputTokens = outputTokens,
hasRunMetadata = true,
)
private fun done(
endedAt: Long,
runtimeMs: Long? = 51_000L,
outputTokens: Long? = null,
): ChatSessionEntry = row("done", endedAt, runtimeMs, outputTokens)
@Test
fun resolvesOnceAFreshTerminalStampLandsThenSticks() {
val resolver = TurnRecapResolver()
assertNull(resolver.resolve(session, true, done(previousEndedAt)))
assertNull(resolver.resolve(session, false, done(previousEndedAt)))
val terminal = done(runEndedAt, runtimeMs = 51_000L, outputTokens = 485L)
val expected = TurnRecap(runtimeMs = 51_000L, outputTokens = 485L)
assertEquals(expected, resolver.resolve(session, false, terminal))
assertEquals(expected, resolver.resolve(session, false, terminal))
}
@Test
fun rejectsPreviousTurnAndRegressedStamps() {
val resolver = TurnRecapResolver()
resolver.resolve(session, true, done(previousEndedAt))
assertNull(resolver.resolve(session, false, done(previousEndedAt)))
assertNull(resolver.resolve(session, false, done(previousEndedAt - 5_000L)))
}
@Test
fun expiresAnUnresolvedWatchInsteadOfMatchingALaterRun() {
var nowMs = 1_000_000L
val resolver = TurnRecapResolver { nowMs }
resolver.resolve(session, true, done(previousEndedAt))
assertNull(resolver.resolve(session, false, done(previousEndedAt)))
nowMs += 31_000L
assertNull(resolver.resolve(session, false, done(previousEndedAt)))
assertNull(resolver.resolve(session, false, done(runEndedAt)))
}
@Test
fun freshDoneWithoutRuntimeConsumesTheWatch() {
val resolver = TurnRecapResolver()
resolver.resolve(session, true, done(previousEndedAt))
assertNull(resolver.resolve(session, false, done(runEndedAt, runtimeMs = null)))
assertNull(resolver.resolve(session, false, done(runEndedAt + 1_000L)))
}
@Test
fun clearedRunStartBaselineIsStaleFree() {
val resolver = TurnRecapResolver()
assertNull(resolver.resolve(session, true, row(status = "running")))
assertEquals(
TurnRecap(runtimeMs = 2_000L, outputTokens = null),
resolver.resolve(session, false, done(runEndedAt, runtimeMs = 2_000L)),
)
}
@Test
fun neverResolvesWithoutWatchingAnIndicator() {
assertNull(TurnRecapResolver().resolve(session, false, done(runEndedAt)))
}
@Test
fun consumesAWatchWhoseBaselineRowWasNeverObserved() {
val resolver = TurnRecapResolver()
assertNull(resolver.resolve(session, true, null))
assertNull(resolver.resolve(session, false, done(previousEndedAt)))
assertNull(resolver.resolve(session, false, done(runEndedAt)))
}
@Test
fun adoptsTheFirstRowObservedMidWatchAsBaseline() {
val resolver = TurnRecapResolver()
assertNull(resolver.resolve(session, true, null))
assertNull(resolver.resolve(session, true, done(previousEndedAt)))
assertNull(resolver.resolve(session, false, done(previousEndedAt)))
assertEquals(
TurnRecap(runtimeMs = 6_000L, outputTokens = null),
resolver.resolve(session, false, done(runEndedAt, runtimeMs = 6_000L)),
)
}
@Test
fun forfeitsWhenATerminalStampChangesMidWatch() {
val resolver = TurnRecapResolver()
assertNull(resolver.resolve(session, true, row(status = "running")))
assertNull(resolver.resolve(session, true, done(previousEndedAt)))
assertNull(resolver.resolve(session, false, done(previousEndedAt)))
assertNull(resolver.resolve(session, false, done(runEndedAt, runtimeMs = 4_000L)))
}
@Test
fun forfeitsAFailedTurnWhoseTerminalRacedTheIndicator() {
val resolver = TurnRecapResolver()
resolver.resolve(session, true, row(status = "running"))
resolver.resolve(session, true, row(status = "failed", endedAt = runEndedAt))
assertNull(resolver.resolve(session, false, row(status = "failed", endedAt = runEndedAt)))
assertNull(resolver.resolve(session, false, done(runEndedAt + 60_000L)))
}
@Test
fun freezesTheFirstRecapAgainstLaterUnwatchedTerminals() {
val resolver = TurnRecapResolver()
resolver.resolve(session, true, done(previousEndedAt))
val settled = resolver.resolve(session, false, done(runEndedAt, runtimeMs = 51_000L, outputTokens = 485L))
assertNotNull(settled)
assertEquals(settled, resolver.resolve(session, false, done(runEndedAt + 90_000L, runtimeMs = 7_000L, outputTokens = 42L)))
}
@Test
fun settledRecapSticksOnlyWhileItsTranscriptAnchorIsNewest() {
val resolver = TurnRecapResolver()
resolver.resolve(session, true, done(previousEndedAt), transcript("user-1"))
val terminal = done(runEndedAt, runtimeMs = 51_000L, outputTokens = 485L)
val expected = TurnRecap(runtimeMs = 51_000L, outputTokens = 485L)
assertNull(
resolver.resolve(session, false, terminal, transcript("assistant-tool")),
)
assertEquals(
expected,
resolver.resolve(session, false, terminal, transcript("assistant-1", completedEndedAt = runEndedAt)),
)
assertEquals(
expected,
resolver.resolve(session, false, terminal, transcript("assistant-1", completedEndedAt = runEndedAt)),
)
assertNull(
resolver.resolve(
session,
false,
terminal,
transcript("assistant-2", completedEndedAt = runEndedAt + 1_000L),
),
)
}
@Test
fun emptyTranscriptWaitsForTheCompletedItemBeforeSettling() {
val resolver = TurnRecapResolver()
resolver.resolve(session, true, done(previousEndedAt), transcript(null))
val terminal = done(runEndedAt, runtimeMs = 2_000L)
assertNull(resolver.resolve(session, false, terminal, transcript(null)))
assertEquals(
TurnRecap(runtimeMs = 2_000L, outputTokens = null),
resolver.resolve(session, false, terminal, transcript("assistant-1", completedEndedAt = runEndedAt)),
)
}
@Test
fun newerContentAlreadyPresentWhenHistoryCompletesDropsTheRecap() {
val resolver = TurnRecapResolver()
resolver.resolve(session, true, done(previousEndedAt), transcript("user-1"))
val terminal = done(runEndedAt, runtimeMs = 2_000L)
assertNull(
resolver.resolve(
session,
false,
terminal,
transcript(
newestItemId = "user-2",
completedEndedAt = runEndedAt,
completedNewestItemId = "assistant-1",
),
),
)
}
@Test
fun changedTerminalWhileWaitingForHistoryDestroysAttribution() {
val resolver = TurnRecapResolver()
resolver.resolve(session, true, done(previousEndedAt), transcript("user-1"))
assertNull(
resolver.resolve(session, false, done(runEndedAt), transcript("user-1")),
)
assertNull(
resolver.resolve(
session,
false,
done(runEndedAt + 1_000L, runtimeMs = 9_000L),
transcript("assistant-2", completedEndedAt = runEndedAt + 1_000L),
),
)
}
@Test
fun terminalWaitingForHistoryStillExpires() {
var nowMs = 1_000_000L
val resolver = TurnRecapResolver { nowMs }
resolver.resolve(session, true, done(previousEndedAt), transcript("user-1"))
assertNull(
resolver.resolve(session, false, done(runEndedAt), transcript("user-1")),
)
nowMs += TURN_RECAP_SETTLE_WINDOW_MS + 1L
assertNull(
resolver.resolve(session, false, done(runEndedAt), transcript("assistant-1", completedEndedAt = runEndedAt)),
)
}
@Test
fun hidesTheRecapAsSoonAsTheNextIndicatorAppears() {
val resolver = TurnRecapResolver()
resolver.resolve(session, true, done(previousEndedAt))
assertNotNull(resolver.resolve(session, false, done(runEndedAt)))
assertNull(resolver.resolve(session, true, done(runEndedAt)))
assertNull(resolver.resolve(session, false, done(runEndedAt)))
}
@Test
fun ignoresAStaleFailedRowThenResolvesAFreshDone() {
val resolver = TurnRecapResolver()
resolver.resolve(session, true, row(status = "failed", endedAt = previousEndedAt))
assertNull(resolver.resolve(session, false, row(status = "failed", endedAt = previousEndedAt)))
assertEquals(
TurnRecap(runtimeMs = 3_000L, outputTokens = null),
resolver.resolve(session, false, done(runEndedAt, runtimeMs = 3_000L)),
)
}
@Test
fun freshFailedRowConsumesTheWatch() {
val resolver = TurnRecapResolver()
resolver.resolve(session, true, done(previousEndedAt))
assertNull(resolver.resolve(session, false, row(status = "failed", endedAt = runEndedAt)))
assertNull(resolver.resolve(session, false, done(runEndedAt + 1_000L)))
}
@Test
fun leavingTheSessionAbandonsUnsettledButKeepsSettled() {
val resolver = TurnRecapResolver()
resolver.resolve(session, true, done(previousEndedAt))
assertNull(resolver.resolve(session, false, done(previousEndedAt)))
resolver.abandonActiveWatch(session)
assertNull(resolver.resolve(session, false, done(runEndedAt)))
resolver.resolve(session, true, done(previousEndedAt))
val settled = resolver.resolve(session, false, done(runEndedAt))
resolver.abandonActiveWatch(session)
assertEquals(settled, resolver.resolve(session, false, done(runEndedAt + 1_000L)))
}
@Test
fun everyNonDoneTerminalConsumesQuietly() {
listOf("failed", "killed", "timeout").forEach { status ->
val resolver = TurnRecapResolver()
resolver.resolve(session, true, done(previousEndedAt))
assertNull(resolver.resolve(session, false, row(status = status, endedAt = runEndedAt)))
assertNull(resolver.resolve(session, false, done(runEndedAt + 1_000L)))
}
}
@Test
fun formatsZeroOneAndCompactTokenCounts() {
assertEquals(TurnRecapTokenFormat(singular = false, count = "0"), turnRecapTokenFormat(0L))
assertEquals(TurnRecapTokenFormat(singular = true, count = "1"), turnRecapTokenFormat(1L))
assertEquals("1.2k", formatCompactTokenCount(1_234L))
assertEquals("1M", formatCompactTokenCount(999_999L))
}
private fun transcript(
newestItemId: String?,
completedEndedAt: Long? = null,
transcriptSessionKey: String? = session,
completedNewestItemId: String? = newestItemId.takeIf { completedEndedAt != null },
): TurnRecapTranscriptState =
TurnRecapTranscriptState(
sessionKey = transcriptSessionKey,
newestItemId = newestItemId,
completedEndedAt = completedEndedAt,
completedNewestItemId = completedNewestItemId,
)
}

View File

@@ -0,0 +1,128 @@
package ai.openclaw.app.ui.chat
import ai.openclaw.app.chat.ChatSessionEntry
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class ChatWorkingIndicatorTest {
@Test
fun stanceSelectionIsDeterministicForRunAndSalt() {
val first = pickWorkingClawStance("run-123", salt = 42)
repeat(20) {
assertEquals(first, pickWorkingClawStance("run-123", salt = 42))
}
}
@Test
fun stanceSelectionUsesConfiguredWeights() {
val key = "run-weight-check"
val hash = workingClawHash(key)
val counts = mutableMapOf<WorkingClawStance, Int>()
repeat(1_000) { bucket ->
val stance = pickWorkingClawStance(key, salt = hash xor bucket)
counts[stance] = counts.getOrDefault(stance, 0) + 1
}
assertEquals(660, counts[WorkingClawStance.Default])
assertEquals(200, counts[WorkingClawStance.Southpaw])
assertEquals(50, counts[WorkingClawStance.Flurry])
assertEquals(40, counts[WorkingClawStance.Spin])
assertEquals(30, counts[WorkingClawStance.Shadowbox])
assertEquals(20, counts[WorkingClawStance.Backflip])
}
@Test
fun phraseStrideVisitsEveryPhraseWithoutAdjacentRepeats() {
val indexes = (0L until 19L).map { bucket -> workingPhraseIndex("run-phrase", bucket) }
assertEquals(19, indexes.toSet().size)
indexes.zipWithNext().forEach { (previous, next) -> assertNotEquals(previous, next) }
assertNotEquals(indexes.last(), workingPhraseIndex("run-phrase", 19L))
}
@Test
fun phraseWaitsThirtySecondsAndRotatesEveryFortyFive() {
assertEquals(null, workingPhraseIndexForElapsed("run-phrase", WORKING_PHRASE_SHOW_AFTER_MS - 1L))
val first = workingPhraseIndexForElapsed("run-phrase", WORKING_PHRASE_SHOW_AFTER_MS)
assertEquals(first, workingPhraseIndexForElapsed("run-phrase", WORKING_PHRASE_SHOW_AFTER_MS + 44_999L))
assertNotEquals(first, workingPhraseIndexForElapsed("run-phrase", WORKING_PHRASE_SHOW_AFTER_MS + 45_000L))
}
@Test
fun compactDurationClampsToOneSecond() {
assertEquals("1s", formatChatDurationCompact(0L))
assertEquals("1m 30s", formatChatDurationCompact(90_000L))
assertTrue(formatChatDurationCompact(3_600_000L).startsWith("1h"))
}
@Test
fun provisionalRunAdoptsAuthoritativeIdentityWithoutChangingLocalStart() {
val tracker = ChatWorkingRunTracker("agent:main:main")
val provisional = requireNotNull(tracker.resolve(indicatorVisible = true, session = null, nowElapsedMs = 5_000L))
val authoritative =
requireNotNull(
tracker.resolve(
indicatorVisible = true,
session =
ChatSessionEntry(
key = "agent:main:main",
updatedAtMs = 6_000L,
status = "running",
startedAt = 4_000L,
activeRunIds = listOf("run-1"),
),
nowElapsedMs = 6_000L,
),
)
assertEquals(provisional.key, authoritative.key)
assertEquals(5_000L, authoritative.observedAtElapsedMs)
assertEquals("run-1", authoritative.authoritativeRunId)
assertEquals(4_000L, authoritative.authoritativeStartedAtMs)
}
@Test
fun authoritativeReplacementGetsANewRunIdentity() {
val tracker = ChatWorkingRunTracker("agent:main:main")
val first =
requireNotNull(
tracker.resolve(
indicatorVisible = true,
session =
ChatSessionEntry(
key = "agent:main:main",
updatedAtMs = 1L,
status = "running",
startedAt = 1_000L,
activeRunIds = listOf("run-1"),
),
nowElapsedMs = 7_000L,
),
)
val replacement =
requireNotNull(
tracker.resolve(
indicatorVisible = true,
session =
ChatSessionEntry(
key = "agent:main:main",
updatedAtMs = 2L,
status = "running",
startedAt = 2_000L,
activeRunIds = listOf("run-2"),
),
nowElapsedMs = 9_000L,
),
)
assertEquals("run-1", first.key)
assertEquals("run-2", replacement.key)
assertEquals(9_000L, replacement.observedAtElapsedMs)
assertEquals(2_000L, replacement.authoritativeStartedAtMs)
}
}