mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 08:11:34 +00:00
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:
committed by
GitHub
parent
8481c69cbe
commit
aeeff149b9
File diff suppressed because it is too large
Load Diff
@@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ class ClientDatabasesTest {
|
||||
val databases = open(names, registeredGatewayIds = setOf("gateway-test"))
|
||||
try {
|
||||
assertEquals(
|
||||
1,
|
||||
2,
|
||||
databases
|
||||
.gatewayCacheDatabase()
|
||||
.openHelper.writableDatabase.version,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user