From 330f36fb32eae4fbf712b50baaf7b12f8191d3ee Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 18 Jul 2026 06:15:30 +0100 Subject: [PATCH] refactor(android): centralize composer owner state (#110315) * refactor(android): centralize composer owner state Co-authored-by: IWhatsskill <284122573+IWhatsskill@users.noreply.github.com> * chore(i18n): refresh Android source inventory * fix(android): update attachment test call sites * style(android): wrap nullable attachment assertions * fix(android): preserve send identity through aliases * style(android): format composer state store * style(android): inline omission cleanup --------- Co-authored-by: IWhatsskill <284122573+IWhatsskill@users.noreply.github.com> --- apps/.i18n/native-source.json | 10 +- .../java/ai/openclaw/app/MainViewModel.kt | 367 +++-------------- .../ai/openclaw/app/ui/chat/ChatComposer.kt | 120 ++---- .../app/ui/chat/ChatComposerStateStore.kt | 379 ++++++++++++++++++ .../ai/openclaw/app/ui/chat/ChatScreen.kt | 62 +-- .../java/ai/openclaw/app/MainViewModelTest.kt | 103 ++--- .../app/ui/chat/ChatComposerDraftTest.kt | 88 ++-- 7 files changed, 626 insertions(+), 503 deletions(-) create mode 100644 apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposerStateStore.kt diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index 5a8857e75cc5..b503a7944040 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -539,7 +539,7 @@ }, { "kind": "ui-state-text", - "line": 501, + "line": 463, "path": "apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt", "source": "Searching…", "surface": "android", @@ -547,7 +547,7 @@ }, { "kind": "ui-state-text", - "line": 623, + "line": 585, "path": "apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt", "source": "Mic off", "surface": "android", @@ -555,7 +555,7 @@ }, { "kind": "ui-state-text", - "line": 637, + "line": 599, "path": "apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt", "source": "Off", "surface": "android", @@ -11747,7 +11747,7 @@ }, { "kind": "ui-call", - "line": 290, + "line": 289, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Model", "surface": "android", @@ -11755,7 +11755,7 @@ }, { "kind": "ui-call", - "line": 375, + "line": 374, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Some shared images were omitted or could not be added.", "surface": "android", diff --git a/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt b/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt index 65311dc97e4a..50cf40ca9a3e 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt @@ -25,11 +25,9 @@ import ai.openclaw.app.node.SmsManager import ai.openclaw.app.ui.GatewayConnectPlan import ai.openclaw.app.ui.GatewaySavedAuthAction import ai.openclaw.app.ui.SettingsRoute -import ai.openclaw.app.ui.chat.CHAT_COMPOSER_MAX_SEND_CHARS -import ai.openclaw.app.ui.chat.ChatComposerAttachmentStore -import ai.openclaw.app.ui.chat.ChatComposerTextDraftStore +import ai.openclaw.app.ui.chat.ChatComposerSendStartResult +import ai.openclaw.app.ui.chat.ChatComposerStateStore import ai.openclaw.app.ui.chat.PendingAttachment -import ai.openclaw.app.ui.chat.captureChatComposerSendPayload import ai.openclaw.app.ui.chat.chatComposerTextDraftsFromSnapshot import ai.openclaw.app.ui.chat.matchesSession import ai.openclaw.app.ui.chat.shouldMigrateComposerDraft @@ -83,11 +81,6 @@ internal fun claimChatDraftForOwner( return draft.copy(owner = owner) } -internal data class ChatComposerSendAdmission( - val id: Long, - val owner: ChatComposerOwner, -) - internal data class PendingAssistantAutoSend( val prompt: String, val owner: ChatComposerOwner, @@ -97,6 +90,7 @@ internal data class PendingAssistantAutoSend( private data class AssistantAutoSendOperation( var owner: ChatComposerOwner, val pendingId: String, + val composerSendId: String, ) internal fun clearCompletedAssistantAutoSend( @@ -114,18 +108,6 @@ internal fun retainRefusedAssistantPrompt( else -> "$prompt\n\n$existing" } -internal enum class ChatComposerAttachmentNotice { - Attachment, - Image, -} - -internal enum class ChatComposerSendStartResult { - Started, - Unavailable, - MessageTooLong, - CheckpointFull, -} - data class ChatShareDraft( val id: Long, val text: String?, @@ -135,7 +117,6 @@ data class ChatShareDraft( internal const val MAX_PENDING_CHAT_SHARES = 16 private const val CHAT_COMPOSER_DRAFTS_STATE_KEY = "chat-composer-text-drafts" -private const val CHAT_COMPOSER_MAX_MEDIA_AUTHORIZATIONS = 32 /** Bounded process-local queue whose stable head survives Activity recreation with the ViewModel. */ internal class ChatShareDraftQueue( @@ -322,40 +303,20 @@ class MainViewModel private constructor( private val chatDraftState = MutableStateFlow(null) internal val chatDraft: StateFlow = chatDraftState private val chatDraftLock = Any() - private val chatComposerAttachmentStore = ChatComposerAttachmentStore() - private val chatComposerMediaLock = Any() - private val chatComposerMediaOwners = linkedMapOf() private var attachedComposerRuntime: NodeRuntime? = null private var removeChatSessionDeletionListener: (() -> Unit)? = null // SavedStateHandle preserves a bounded set of complete owner-scoped drafts through process // recreation. Durable admission clears only the accepted snapshot, so later edits survive. - internal val chatComposerTextDrafts = - ChatComposerTextDraftStore( - initial = chatComposerTextDraftsFromSnapshot(savedStateHandle[CHAT_COMPOSER_DRAFTS_STATE_KEY]), - onSnapshotChanged = { snapshot -> savedStateHandle[CHAT_COMPOSER_DRAFTS_STATE_KEY] = snapshot }, + internal val chatComposerState = + ChatComposerStateStore( + initialDrafts = chatComposerTextDraftsFromSnapshot(savedStateHandle[CHAT_COMPOSER_DRAFTS_STATE_KEY]), + onDraftSnapshotChanged = { snapshot -> savedStateHandle[CHAT_COMPOSER_DRAFTS_STATE_KEY] = snapshot }, ) - - internal val chatComposerAttachments = chatComposerAttachmentStore.attachments - private val chatComposerAttachmentNoticeLock = Any() - private val chatComposerAttachmentNoticesState = - MutableStateFlow>(emptyMap()) - internal val chatComposerAttachmentNotices: StateFlow> = - chatComposerAttachmentNoticesState.asStateFlow() - private val chatComposerSendLock = Any() - private val chatComposerSendSeq = AtomicLong() - private val recoveredChatComposerSends = chatComposerTextDrafts.pendingAdmissions() - private val chatComposerSendOwnersState = - MutableStateFlow>( - recoveredChatComposerSends.mapTo(linkedSetOf()) { pending -> pending.owner }, - ) - internal val chatComposerSendOwners: StateFlow> = chatComposerSendOwnersState.asStateFlow() - private val chatComposerSendAdmissionsState = - MutableStateFlow>(emptyMap()) - internal val chatComposerSendAdmissions: StateFlow> = - chatComposerSendAdmissionsState.asStateFlow() + private val assistantAutoSendLock = Any() init { + val recoveredChatComposerSends = chatComposerState.recoveredSends() if (recoveredChatComposerSends.isNotEmpty()) { // A pending checkpoint is hidden until the durable outbox gives a definitive answer. // Database errors leave it parked instead of exposing text that may already be sending. @@ -364,10 +325,11 @@ class MainViewModel private constructor( recoveredChatComposerSends.forEach { pending -> val admitted = runCatching { runtime.wasChatOutboxCommandAdmitted(pending.commandId) }.getOrNull() ?: return@forEach - synchronized(chatComposerSendLock) { - val resolvedOwner = chatComposerTextDrafts.resolveAdmission(pending.commandId, admitted)?.owner ?: pending.owner - chatComposerSendOwnersState.value = chatComposerSendOwnersState.value - pending.owner - resolvedOwner - } + chatComposerState.resolveRecoveredSend( + commandId = pending.commandId, + fallbackOwner = pending.owner, + admitted = admitted, + ) } } } @@ -777,17 +739,12 @@ class MainViewModel private constructor( } private suspend fun clearChatComposerOwners(matches: (ChatComposerOwner) -> Boolean) { - clearChatComposerMediaOwners(matches) + chatComposerState.removeMediaOwners(matches) chatShareDraftQueue.removeOwners(matches) - synchronized(chatComposerSendLock) { - chatComposerTextDrafts.removeOwners(matches) - val retainedSendOwners = chatComposerSendOwnersState.value.filterTo(linkedSetOf()) { !matches(it) } - // A dispatched request keeps its gate until its own finally block runs. Cleanup retires its - // visible state, but cannot make a second auto-send overlap the still-suspended operation. - assistantAutoSendOperation?.owner?.let(retainedSendOwners::add) - chatComposerSendOwnersState.value = retainedSendOwners - chatComposerSendAdmissionsState.value = - chatComposerSendAdmissionsState.value.filterKeys { !matches(it) } + synchronized(assistantAutoSendLock) { + // Read the live operation id while its start/finally paths are excluded so cleanup retains + // exactly that gate after removing other state owned by the retired identity. + chatComposerState.removeOwners(matches, assistantAutoSendOperation?.composerSendId) } pendingAssistantAutoSendMutable.update { pending -> pending?.takeIf { !matches(it.owner) } @@ -795,20 +752,9 @@ class MainViewModel private constructor( synchronized(chatDraftLock) { chatDraftState.value = chatDraftState.value?.takeIf { draft -> draft.owner?.let(matches) != true } } - synchronized(chatComposerAttachmentNoticeLock) { - chatComposerAttachmentNoticesState.value = - chatComposerAttachmentNoticesState.value.filterKeys { !matches(it) } - } // Repeat after suspending share cleanup. Any callback that raced the first tombstone is // serialized with this final token-and-attachment purge before cleanup returns. - clearChatComposerMediaOwners(matches) - } - - private fun clearChatComposerMediaOwners(matches: (ChatComposerOwner) -> Boolean) { - synchronized(chatComposerMediaLock) { - chatComposerMediaOwners.entries.removeAll { matches(it.value) } - chatComposerAttachmentStore.removeOwners(matches) - } + chatComposerState.removeMediaOwners(matches) } internal fun saveGatewayConfigAndConnect(plan: GatewayConnectPlan) { @@ -1076,24 +1022,24 @@ class MainViewModel private constructor( if (!isCurrentChatComposerOwner(pending.owner)) return if (runtimeRef.value?.canSendForOwner(pending.owner) != true) return val operation = - synchronized(chatComposerSendLock) { - if ( - chatComposerOwnerHasActiveSend( - owner = pending.owner, - sendOwners = chatComposerSendOwnersState.value, - admissions = chatComposerSendAdmissionsState.value, - ) - ) { - return - } + synchronized(assistantAutoSendLock) { if (!_assistantAutoSendInFlight.compareAndSet(false, true)) return if (pendingAssistantAutoSendMutable.value != pending) { _assistantAutoSendInFlight.value = false return } - val started = AssistantAutoSendOperation(owner = pending.owner, pendingId = pending.id) + val composerSendId = chatComposerState.tryBeginTrackedSend(pending.owner) + if (composerSendId == null) { + _assistantAutoSendInFlight.value = false + return + } + val started = + AssistantAutoSendOperation( + owner = pending.owner, + pendingId = pending.id, + composerSendId = composerSendId, + ) assistantAutoSendOperation = started - chatComposerSendOwnersState.value = chatComposerSendOwnersState.value + pending.owner started } viewModelScope.launch { @@ -1115,14 +1061,14 @@ class MainViewModel private constructor( if (current?.id == operation.pendingId && pendingAssistantAutoSendMutable.compareAndSet(current, null)) { // Refusal can mean owner validation changed before admission. Preserve the one-shot // prompt as editable text, using the operation owner that alias migration updates. - val currentDraft = chatComposerTextDrafts[operation.owner] - chatComposerTextDrafts[operation.owner] = retainRefusedAssistantPrompt(current.prompt, currentDraft) + val currentDraft = chatComposerState.textDrafts[operation.owner] + chatComposerState.textDrafts[operation.owner] = retainRefusedAssistantPrompt(current.prompt, currentDraft) } } } finally { - synchronized(chatComposerSendLock) { + synchronized(assistantAutoSendLock) { if (assistantAutoSendOperation === operation) { - chatComposerSendOwnersState.value = chatComposerSendOwnersState.value - operation.owner + chatComposerState.finishTrackedSend(operation.composerSendId) assistantAutoSendOperation = null // Observable releases wake a prompt blocked by this or a manual send admission. _assistantAutoSendInFlight.value = false @@ -1613,89 +1559,21 @@ class MainViewModel private constructor( currentChatComposerOwner() ?: currentOrProvisionalChatComposerOwner() ) == expected - internal fun beginChatComposerMediaAcquisition(owner: ChatComposerOwner): String? { - owner.gatewayStableId?.trim()?.takeIf { it.isNotEmpty() } ?: return null - return synchronized(chatComposerMediaLock) { - while (chatComposerMediaOwners.size >= CHAT_COMPOSER_MAX_MEDIA_AUTHORIZATIONS) { - val oldest = chatComposerMediaOwners.keys.firstOrNull() ?: break - chatComposerMediaOwners.remove(oldest) - } - UUID.randomUUID().toString().also { id -> chatComposerMediaOwners[id] = owner } - } - } - - internal fun isChatComposerMediaAcquisitionActive(id: String): Boolean = synchronized(chatComposerMediaLock) { id in chatComposerMediaOwners } - - internal fun cancelChatComposerMediaAcquisition(id: String) { - synchronized(chatComposerMediaLock) { chatComposerMediaOwners.remove(id) } - } - - internal fun addChatComposerAttachments( - owner: ChatComposerOwner, - attachments: List, - ): Int = - chatComposerAttachmentStore.add(owner, attachments).also { omitted -> - recordChatComposerAttachmentOmission(owner, omitted) - } - - internal fun addChatComposerAttachments( - owner: ChatComposerOwner, - mediaAuthorizationId: String, - attachments: List, - ): Int? = - synchronized(chatComposerMediaLock) { - if (chatComposerMediaOwners.remove(mediaAuthorizationId) != owner) return@synchronized null - chatComposerAttachmentStore.add(owner, attachments).also { omitted -> - recordChatComposerAttachmentOmission(owner, omitted) - } - } - - internal fun removeChatComposerAttachments( - owner: ChatComposerOwner, - ids: Set, - ) { - chatComposerAttachmentStore.remove(owner, ids) - } - internal fun resolveChatComposerOwnerAliases( to: ChatComposerOwner, mainSessionKey: String, ) { - val mediaSources = - synchronized(chatComposerMediaLock) { - val sources = - chatComposerMediaOwners.values - .filterTo(linkedSetOf()) { source -> shouldMigrateComposerDraft(source, to, mainSessionKey) } - if (sources.isNotEmpty()) { - for ((id, owner) in chatComposerMediaOwners.toMap()) { - if (owner in sources) chatComposerMediaOwners[id] = to - } - } - sources - } - val (composerSources, attachmentMigration) = - synchronized(chatComposerSendLock) { - val textSources = chatComposerTextDrafts.migrateMatching(to = to, mainSessionKey = mainSessionKey) - val attachments = chatComposerAttachmentStore.migrateMatching(to = to, mainSessionKey = mainSessionKey) - val stateSources = - (chatComposerSendOwnersState.value + chatComposerSendAdmissionsState.value.keys) - .filterTo(linkedSetOf()) { source -> shouldMigrateComposerDraft(source, to, mainSessionKey) } - val sources = textSources + attachments.sources + stateSources - if (sources.isNotEmpty()) { - chatComposerSendOwnersState.value = - chatComposerSendOwnersState.value - .mapTo(linkedSetOf()) { owner -> if (owner in sources) to else owner } + val (composerSources, operationSource) = + synchronized(assistantAutoSendLock) { + // The gate and operation owner must move together so finally releases the migrated key. + val sources = chatComposerState.resolveAliases(to = to, mainSessionKey = mainSessionKey) + val operationSource = assistantAutoSendOperation?.let { operation -> - if (operation.owner in sources) operation.owner = to + operation.owner.takeIf { source -> shouldMigrateComposerDraft(source, to, mainSessionKey) }?.also { + operation.owner = to + } } - val migratedAdmissions = - chatComposerSendAdmissionsState.value.values - .map { admission -> if (admission.owner in sources) admission.copy(owner = to) else admission } - .groupBy(ChatComposerSendAdmission::owner) - .mapValues { (_, admissions) -> admissions.maxBy(ChatComposerSendAdmission::id) } - chatComposerSendAdmissionsState.value = migratedAdmissions - } - sources to attachments + sources to operationSource } val pendingAutoSend = pendingAssistantAutoSendMutable.value val pendingAutoSendSource = @@ -1705,56 +1583,8 @@ class MainViewModel private constructor( if (pendingAutoSendSource != null) { pendingAssistantAutoSendMutable.compareAndSet(pendingAutoSend, pendingAutoSend.copy(owner = to)) } - val noticeSources = - synchronized(chatComposerAttachmentNoticeLock) { - chatComposerAttachmentNoticesState.value.keys.filterTo(linkedSetOf()) { source -> - shouldMigrateComposerDraft(source, to, mainSessionKey) - } - } - val sources = composerSources + mediaSources + noticeSources + listOfNotNull(pendingAutoSendSource) + val sources = composerSources + listOfNotNull(pendingAutoSendSource, operationSource) sources.forEach { source -> chatShareDraftQueue.migrateOwner(from = source, to = to) } - synchronized(chatComposerAttachmentNoticeLock) { - val current = chatComposerAttachmentNoticesState.value - val sourceNotices = sources.mapNotNull(current::get) - var next = current - sources - val nextNotice = - when { - attachmentMigration.omittedCount > 0 || - current[to] == ChatComposerAttachmentNotice.Attachment || - ChatComposerAttachmentNotice.Attachment in sourceNotices -> ChatComposerAttachmentNotice.Attachment - current[to] == ChatComposerAttachmentNotice.Image || - ChatComposerAttachmentNotice.Image in sourceNotices -> ChatComposerAttachmentNotice.Image - else -> null - } - if (nextNotice != null) next += (to to nextNotice) - chatComposerAttachmentNoticesState.value = next - } - } - - internal fun clearChatComposerAttachmentOmission(owner: ChatComposerOwner) { - synchronized(chatComposerAttachmentNoticeLock) { - chatComposerAttachmentNoticesState.value = chatComposerAttachmentNoticesState.value - owner - } - } - - internal fun reportChatComposerImageOmission( - owner: ChatComposerOwner, - omitted: Int, - ) { - recordChatComposerAttachmentOmission(owner, omitted, ChatComposerAttachmentNotice.Image) - } - - private fun recordChatComposerAttachmentOmission( - owner: ChatComposerOwner, - omitted: Int, - notice: ChatComposerAttachmentNotice = ChatComposerAttachmentNotice.Attachment, - ) { - if (omitted <= 0) return - synchronized(chatComposerAttachmentNoticeLock) { - val current = chatComposerAttachmentNoticesState.value[owner] - val resolved = if (current == ChatComposerAttachmentNotice.Attachment) current else notice - chatComposerAttachmentNoticesState.value = chatComposerAttachmentNoticesState.value + (owner to resolved) - } } /** The ViewModel owns image decoding so Activity recreation cannot cancel an accepted picker result. */ @@ -1766,11 +1596,7 @@ class MainViewModel private constructor( load: suspend () -> List, ) { val importId = - synchronized(chatComposerMediaLock) { - val authorizedOwner = chatComposerMediaOwners.remove(mediaAuthorizationId) ?: return - if (authorizedOwner != owner && !shouldMigrateComposerDraft(authorizedOwner, owner, mainSessionKey)) return - chatComposerAttachmentStore.beginImport(owner) - } + chatComposerState.beginMediaImport(owner, mediaAuthorizationId, mainSessionKey) ?: return viewModelScope.launch(Dispatchers.IO) { try { val loaded = @@ -1781,16 +1607,13 @@ class MainViewModel private constructor( } catch (_: Throwable) { emptyList() } - chatComposerAttachmentStore.completeImport(importId, loaded)?.let { (commitOwner, omitted) -> - val failed = (expectedCount - loaded.size).coerceAtLeast(0) - recordChatComposerAttachmentOmission( - commitOwner, - omitted + failed, - ChatComposerAttachmentNotice.Image, - ) - } + chatComposerState.completeMediaImport( + importId = importId, + candidates = loaded, + failedCount = expectedCount - loaded.size, + ) } catch (err: CancellationException) { - chatComposerAttachmentStore.cancelImport(importId) + chatComposerState.cancelMediaImport(importId) throw err } } @@ -1858,81 +1681,26 @@ class MainViewModel private constructor( thinking: String, ): ChatComposerSendStartResult { if (!isCurrentChatComposerOwner(owner)) return ChatComposerSendStartResult.Unavailable - val admissionId = chatComposerSendSeq.incrementAndGet() - val commandId = UUID.randomUUID().toString() - val payload = - synchronized(chatComposerSendLock) { - if ( - chatComposerOwnerHasActiveSend( - owner = owner, - sendOwners = chatComposerSendOwnersState.value, - admissions = chatComposerSendAdmissionsState.value, - ) - ) { - return ChatComposerSendStartResult.Unavailable - } - val current = - captureChatComposerSendPayload( - owner = owner, - textDrafts = chatComposerTextDrafts, - attachmentStore = chatComposerAttachmentStore, - ) - if (current.message.isEmpty() && current.attachments.isEmpty()) { - return ChatComposerSendStartResult.Unavailable - } - if (current.inputSnapshot.length > CHAT_COMPOSER_MAX_SEND_CHARS) { - return ChatComposerSendStartResult.MessageTooLong - } - if (!chatComposerTextDrafts.beginAdmission(commandId, owner, current.inputSnapshot)) { - return ChatComposerSendStartResult.CheckpointFull - } - chatComposerSendOwnersState.value = chatComposerSendOwnersState.value + owner - // One SavedState checkpoint now binds this exact draft to the outbox id that will own it. - // Process recreation hides the draft until Room proves whether admission committed. - current - } - val outgoing = payload.attachments.map(PendingAttachment::toOutgoingAttachment) - val attachmentIds = payload.attachments.mapTo(linkedSetOf()) { it.id } + val start = chatComposerState.beginSend(owner) + val request = start.request ?: return start.result + val outgoing = request.attachments.map(PendingAttachment::toOutgoingAttachment) viewModelScope.launch { var accepted: Boolean? = null try { accepted = sendChatForOwnerAwaitAcceptance( - owner = owner, - message = payload.message, + owner = request.owner, + message = request.message, thinking = thinking, attachments = outgoing, - idempotencyKey = commandId, + idempotencyKey = request.commandId, ) } catch (err: CancellationException) { throw err } catch (_: Throwable) { accepted = false } finally { - synchronized(chatComposerSendLock) { - val result = accepted - if (result == null) { - val currentOwner = chatComposerTextDrafts.pendingAdmission(commandId)?.owner ?: owner - chatComposerSendOwnersState.value = chatComposerSendOwnersState.value - currentOwner - } else { - val pending = chatComposerTextDrafts.resolveAdmission(commandId, result) - val resolvedOwner = pending?.owner ?: owner - if (pending == null) { - chatComposerSendOwnersState.value = chatComposerSendOwnersState.value - owner - } else { - if (result) chatComposerAttachmentStore.remove(resolvedOwner, attachmentIds) - chatComposerSendAdmissionsState.value = - (chatComposerSendAdmissionsState.value - owner - resolvedOwner) + - ( - resolvedOwner to - ChatComposerSendAdmission( - id = admissionId, - owner = resolvedOwner, - ) - ) - } - } - } + chatComposerState.completeSend(request, accepted) } } return ChatComposerSendStartResult.Started @@ -1940,14 +1708,9 @@ class MainViewModel private constructor( internal fun acknowledgeChatComposerSendAdmission( owner: ChatComposerOwner, - id: Long, + id: String, ) { - synchronized(chatComposerSendLock) { - if (chatComposerSendAdmissionsState.value[owner]?.id == id) { - chatComposerSendAdmissionsState.value = chatComposerSendAdmissionsState.value - owner - chatComposerSendOwnersState.value = chatComposerSendOwnersState.value - owner - } - } + chatComposerState.acknowledgeSendAdmission(owner, id) } suspend fun sendChatAwaitAcceptance( @@ -1961,9 +1724,3 @@ class MainViewModel private constructor( attachments = attachments, ) } - -internal fun chatComposerOwnerHasActiveSend( - owner: ChatComposerOwner, - sendOwners: Set, - admissions: Map, -): Boolean = owner in sendOwners || owner in admissions diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt index 9890952d049e..f7ce47a82cb7 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt @@ -25,26 +25,6 @@ internal data class PendingChatComposerSend( val inputSnapshot: String?, ) -internal data class ChatComposerSendPayload( - val inputSnapshot: String, - val message: String, - val attachments: List, -) - -/** Captures the owner stores at admission time; Compose values may lag a synchronous edit. */ -internal fun captureChatComposerSendPayload( - owner: ChatComposerOwner, - textDrafts: ChatComposerTextDraftStore, - attachmentStore: ChatComposerAttachmentStore, -): ChatComposerSendPayload { - val inputSnapshot = textDrafts[owner] - return ChatComposerSendPayload( - inputSnapshot = inputSnapshot, - message = inputSnapshot.trim(), - attachments = attachmentStore.get(owner), - ) -} - internal data class ChatComposerDraftSnapshot( val drafts: Map = emptyMap(), val pendingSends: List = emptyList(), @@ -266,74 +246,24 @@ internal fun ChatComposerOwner.matchesSession( return ownerKey == deletedKey && (this.agentId == agentId || !routingVerified) } -internal class ChatComposerOwnerCheckpoint( +/** One-shot owner lease for picker and voice results; requestId distinguishes recordings. */ +internal class ChatComposerMediaCheckpoint( var owner: ChatComposerOwner? = null, + private var requestId: String? = null, private var mediaAuthorizationId: String? = null, ) { fun begin( owner: ChatComposerOwner, mediaAuthorizationId: String, + requestId: String? = null, ) { this.owner = owner + this.requestId = requestId this.mediaAuthorizationId = mediaAuthorizationId } - fun consume(): ChatComposerMediaLease? { - val capturedOwner = owner ?: return null - val capturedAuthorizationId = mediaAuthorizationId ?: return null - return ChatComposerMediaLease(capturedOwner, capturedAuthorizationId).also { clear() } - } - - fun clear() { - owner = null - mediaAuthorizationId = null - } - - companion object { - val Saver = - listSaver( - save = { checkpoint -> - val capturedOwner = checkpoint.owner - val capturedAuthorizationId = checkpoint.mediaAuthorizationId - if (capturedOwner == null || capturedAuthorizationId == null) { - emptyList() - } else { - capturedOwner.toCheckpointValues() + capturedAuthorizationId - } - }, - restore = { values -> - ChatComposerOwnerCheckpoint( - owner = chatComposerOwnerFromCheckpointValues(values.take(5)), - mediaAuthorizationId = values.getOrNull(5), - ) - }, - ) - } -} - -internal data class ChatComposerMediaLease( - val owner: ChatComposerOwner, - val authorizationId: String, -) - -/** Binds an asynchronous voice-note preparation to the recording that started it. */ -internal class ChatVoiceNoteCommitCheckpoint { - var owner: ChatComposerOwner? = null - private var recordingId: String? = null - private var mediaAuthorizationId: String? = null - - fun begin( - owner: ChatComposerOwner, - recordingId: String, - mediaAuthorizationId: String, - ) { - this.owner = owner - this.recordingId = recordingId - this.mediaAuthorizationId = mediaAuthorizationId - } - - fun consume(recordingId: String): ChatComposerMediaLease? { - if (this.recordingId != recordingId) return null + fun consume(requestId: String? = null): ChatComposerMediaLease? { + if (this.requestId != requestId) return null val capturedOwner = owner ?: return null val capturedAuthorizationId = mediaAuthorizationId ?: return null return ChatComposerMediaLease(capturedOwner, capturedAuthorizationId).also { clear() } @@ -347,12 +277,39 @@ internal class ChatVoiceNoteCommitCheckpoint { } } owner = null - recordingId = null + requestId = null mediaAuthorizationId = null return lease } + + companion object { + val Saver = + listSaver( + save = { checkpoint -> + val capturedOwner = checkpoint.owner + val capturedAuthorizationId = checkpoint.mediaAuthorizationId + if (capturedOwner == null || capturedAuthorizationId == null) { + emptyList() + } else { + capturedOwner.toCheckpointValues() + capturedAuthorizationId + checkpoint.requestId.orEmpty() + } + }, + restore = { values -> + ChatComposerMediaCheckpoint( + owner = chatComposerOwnerFromCheckpointValues(values.take(5)), + mediaAuthorizationId = values.getOrNull(5), + requestId = values.getOrNull(6)?.takeIf(String::isNotEmpty), + ) + }, + ) + } } +internal data class ChatComposerMediaLease( + val owner: ChatComposerOwner, + val authorizationId: String, +) + internal fun ChatComposerOwner.toCheckpointValues(): List = listOf( if (gatewayStableId == null) "0" else "1", @@ -399,11 +356,6 @@ internal fun shouldMigrateComposerDraft( return previous.agentId == current.agentId && mainAliasResolved } -internal fun canCommitComposerResult( - ownerSnapshot: ChatComposerOwner, - currentOwner: ChatComposerOwner, -): Boolean = ownerSnapshot == currentOwner - internal fun mergeChatDraft( draft: ChatDraft?, currentInput: String, @@ -520,7 +472,7 @@ internal fun canCommitStagedChatShare( currentOwner: ChatComposerOwner, ): Boolean = currentHead?.id == stagedId && - canCommitComposerResult(ownerSnapshot = ownerSnapshot, currentOwner = currentOwner) + ownerSnapshot == currentOwner internal fun chatComposerSendEnabled( voiceNoteState: VoiceNoteRecorderState, diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposerStateStore.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposerStateStore.kt new file mode 100644 index 000000000000..f6c4a036aac9 --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposerStateStore.kt @@ -0,0 +1,379 @@ +package ai.openclaw.app.ui.chat + +import ai.openclaw.app.chat.ChatComposerOwner +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.util.UUID + +internal enum class ChatComposerAttachmentNotice { + Attachment, + Image, +} + +internal enum class ChatComposerSendStartResult { + Started, + Unavailable, + MessageTooLong, + CheckpointFull, +} + +internal data class ChatComposerSendRequest( + val commandId: String, + val owner: ChatComposerOwner, + val inputSnapshot: String, + val message: String, + val attachments: List, +) + +internal data class ChatComposerSendStart( + val result: ChatComposerSendStartResult, + val request: ChatComposerSendRequest? = null, +) + +internal data class ChatComposerSendState( + // Stable ids let each completion release its own operation after owner aliases merge. + val activeOperationIds: Set = emptySet(), + val pendingAdmissionIds: Set = emptySet(), +) { + val isEmpty: Boolean get() = activeOperationIds.isEmpty() && pendingAdmissionIds.isEmpty() +} + +/** Owns all mutable state keyed by a composer owner and resolves aliases as one transaction. */ +internal class ChatComposerStateStore( + initialDrafts: ChatComposerDraftSnapshot = ChatComposerDraftSnapshot(), + onDraftSnapshotChanged: (ArrayList) -> Unit = {}, +) { + private val lock = Any() + private val attachmentStore = ChatComposerAttachmentStore() + private val mediaOwners = linkedMapOf() + + val textDrafts = + ChatComposerTextDraftStore( + initial = initialDrafts, + onSnapshotChanged = onDraftSnapshotChanged, + ) + val attachments = attachmentStore.attachments + + private val attachmentNoticesState = + MutableStateFlow>(emptyMap()) + val attachmentNotices: StateFlow> = + attachmentNoticesState.asStateFlow() + + private val recoveredSends = textDrafts.pendingAdmissions() + private val sendStatesState = + MutableStateFlow( + recoveredSends + .groupBy(PendingChatComposerSend::owner, PendingChatComposerSend::commandId) + .mapValues { (_, commandIds) -> ChatComposerSendState(activeOperationIds = commandIds.toSet()) }, + ) + val sendStates: StateFlow> = sendStatesState.asStateFlow() + + fun recoveredSends(): List = recoveredSends + + fun resolveRecoveredSend( + commandId: String, + fallbackOwner: ChatComposerOwner, + admitted: Boolean, + ) { + synchronized(lock) { + val resolvedOwner = textDrafts.resolveAdmission(commandId, admitted)?.owner ?: fallbackOwner + finishActiveSendLocked(setOf(fallbackOwner, resolvedOwner), resolvedOwner, commandId) + } + } + + fun tryBeginTrackedSend(owner: ChatComposerOwner): String? = + synchronized(lock) { + if (hasSendGateLocked(owner)) return@synchronized null + UUID.randomUUID().toString().also { id -> + sendStatesState.value = + sendStatesState.value + (owner to ChatComposerSendState(activeOperationIds = setOf(id))) + } + } + + fun finishTrackedSend(id: String) { + synchronized(lock) { + val (owner, current) = + sendStatesState.value.entries.firstOrNull { (_, state) -> id in state.activeOperationIds } ?: return + val next = current.copy(activeOperationIds = current.activeOperationIds - id) + sendStatesState.value = + if (next.isEmpty) sendStatesState.value - owner else sendStatesState.value + (owner to next) + } + } + + fun beginSend(owner: ChatComposerOwner): ChatComposerSendStart = + synchronized(lock) { + if (hasSendGateLocked(owner)) { + return@synchronized ChatComposerSendStart(ChatComposerSendStartResult.Unavailable) + } + val inputSnapshot = textDrafts[owner] + val attachments = attachmentStore.get(owner) + if (inputSnapshot.isBlank() && attachments.isEmpty()) { + return@synchronized ChatComposerSendStart(ChatComposerSendStartResult.Unavailable) + } + if (inputSnapshot.length > CHAT_COMPOSER_MAX_SEND_CHARS) { + return@synchronized ChatComposerSendStart(ChatComposerSendStartResult.MessageTooLong) + } + val commandId = UUID.randomUUID().toString() + if (!textDrafts.beginAdmission(commandId, owner, inputSnapshot)) { + return@synchronized ChatComposerSendStart(ChatComposerSendStartResult.CheckpointFull) + } + sendStatesState.value = + sendStatesState.value + (owner to ChatComposerSendState(activeOperationIds = setOf(commandId))) + ChatComposerSendStart( + result = ChatComposerSendStartResult.Started, + request = ChatComposerSendRequest(commandId, owner, inputSnapshot, inputSnapshot.trim(), attachments), + ) + } + + fun completeSend( + request: ChatComposerSendRequest, + accepted: Boolean?, + ) { + synchronized(lock) { + if (accepted == null) { + val currentOwner = textDrafts.pendingAdmission(request.commandId)?.owner ?: request.owner + finishActiveSendLocked(setOf(request.owner, currentOwner), currentOwner, request.commandId) + return + } + val pending = textDrafts.resolveAdmission(request.commandId, accepted) + val resolvedOwner = pending?.owner ?: request.owner + if (pending == null) { + finishActiveSendLocked(setOf(request.owner), request.owner, request.commandId) + return + } + if (accepted) { + attachmentStore.remove( + resolvedOwner, + request.attachments.mapTo(linkedSetOf()) { attachment -> attachment.id }, + ) + } + finishActiveSendLocked( + owners = setOf(request.owner, resolvedOwner), + resolvedOwner = resolvedOwner, + activeOperationId = request.commandId, + pendingAdmissionId = request.commandId, + ) + } + } + + fun acknowledgeSendAdmission( + owner: ChatComposerOwner, + id: String, + ) { + synchronized(lock) { + val current = sendStatesState.value[owner] ?: return + if (id !in current.pendingAdmissionIds) return + val next = current.copy(pendingAdmissionIds = current.pendingAdmissionIds - id) + sendStatesState.value = + if (next.isEmpty) sendStatesState.value - owner else sendStatesState.value + (owner to next) + } + } + + fun beginMediaAcquisition(owner: ChatComposerOwner): String? { + owner.gatewayStableId?.trim()?.takeIf { it.isNotEmpty() } ?: return null + return synchronized(lock) { + while (mediaOwners.size >= CHAT_COMPOSER_MAX_MEDIA_AUTHORIZATIONS) { + mediaOwners.remove(mediaOwners.keys.firstOrNull() ?: break) + } + UUID.randomUUID().toString().also { id -> mediaOwners[id] = owner } + } + } + + fun isMediaAcquisitionActive(id: String): Boolean = synchronized(lock) { id in mediaOwners } + + fun cancelMediaAcquisition(id: String) = synchronized(lock) { mediaOwners.remove(id) } + + fun addAttachments( + owner: ChatComposerOwner, + candidates: List, + ): Int = + synchronized(lock) { + attachmentStore.add(owner, candidates).also { omitted -> + recordAttachmentOmissionLocked(owner, omitted, ChatComposerAttachmentNotice.Attachment) + } + } + + fun addAuthorizedAttachments( + owner: ChatComposerOwner, + mediaAuthorizationId: String, + candidates: List, + ): Int? = + synchronized(lock) { + if (mediaOwners.remove(mediaAuthorizationId) != owner) return@synchronized null + attachmentStore.add(owner, candidates).also { omitted -> + recordAttachmentOmissionLocked(owner, omitted, ChatComposerAttachmentNotice.Attachment) + } + } + + fun removeAttachments( + owner: ChatComposerOwner, + ids: Set, + ) = synchronized(lock) { attachmentStore.remove(owner, ids) } + + fun beginMediaImport( + owner: ChatComposerOwner, + mediaAuthorizationId: String, + mainSessionKey: String, + ): Long? = + synchronized(lock) { + val authorizedOwner = mediaOwners.remove(mediaAuthorizationId) ?: return@synchronized null + if (authorizedOwner != owner && !shouldMigrateComposerDraft(authorizedOwner, owner, mainSessionKey)) { + return@synchronized null + } + attachmentStore.beginImport(owner) + } + + fun completeMediaImport( + importId: Long, + candidates: List, + failedCount: Int, + ) { + synchronized(lock) { + attachmentStore.completeImport(importId, candidates)?.let { (owner, omitted) -> + recordAttachmentOmissionLocked( + owner, + omitted + failedCount.coerceAtLeast(0), + ChatComposerAttachmentNotice.Image, + ) + } + } + } + + fun cancelMediaImport(importId: Long) = synchronized(lock) { attachmentStore.cancelImport(importId) } + + fun clearAttachmentOmission(owner: ChatComposerOwner) = synchronized(lock) { attachmentNoticesState.value = attachmentNoticesState.value - owner } + + fun reportImageOmission( + owner: ChatComposerOwner, + omitted: Int, + ) = synchronized(lock) { recordAttachmentOmissionLocked(owner, omitted, ChatComposerAttachmentNotice.Image) } + + /** Migrates every state surface and returns aliases owned by external queues. */ + fun resolveAliases( + to: ChatComposerOwner, + mainSessionKey: String, + ): Set = + synchronized(lock) { + val mediaSources = + mediaOwners.values.filterTo(linkedSetOf()) { source -> + shouldMigrateComposerDraft(source, to, mainSessionKey) + } + if (mediaSources.isNotEmpty()) { + for ((id, owner) in mediaOwners.toMap()) { + if (owner in mediaSources) mediaOwners[id] = to + } + } + + val textSources = textDrafts.migrateMatching(to = to, mainSessionKey = mainSessionKey) + val attachmentMigration = attachmentStore.migrateMatching(to = to, mainSessionKey = mainSessionKey) + val sendSources = sendStatesState.value.keys.filterTo(linkedSetOf()) { source -> shouldMigrateComposerDraft(source, to, mainSessionKey) } + val noticeSources = + attachmentNoticesState.value.keys.filterTo(linkedSetOf()) { source -> + shouldMigrateComposerDraft(source, to, mainSessionKey) + } + val sources = textSources + attachmentMigration.sources + sendSources + mediaSources + noticeSources + + if (sendSources.isNotEmpty()) { + val owners = sendSources + to + val merged = mergeSendStatesLocked(owners) + sendStatesState.value = (sendStatesState.value - owners) + (to to merged) + } + + val currentNotices = attachmentNoticesState.value + val sourceNotices = sources.mapNotNull(currentNotices::get) + var nextNotices = currentNotices - sources + val nextNotice = + when { + attachmentMigration.omittedCount > 0 || + currentNotices[to] == ChatComposerAttachmentNotice.Attachment || + ChatComposerAttachmentNotice.Attachment in sourceNotices -> ChatComposerAttachmentNotice.Attachment + currentNotices[to] == ChatComposerAttachmentNotice.Image || + ChatComposerAttachmentNotice.Image in sourceNotices -> ChatComposerAttachmentNotice.Image + else -> null + } + if (nextNotice != null) nextNotices += (to to nextNotice) + attachmentNoticesState.value = nextNotices + sources + } + + fun removeMediaOwners(matches: (ChatComposerOwner) -> Boolean) { + synchronized(lock) { removeMediaOwnersLocked(matches) } + } + + fun removeOwners( + matches: (ChatComposerOwner) -> Boolean, + retainedSendId: String? = null, + ) { + synchronized(lock) { + removeMediaOwnersLocked(matches) + textDrafts.removeOwners(matches) + val currentSendStates = sendStatesState.value + var nextSendStates = currentSendStates.filterKeys { !matches(it) } + val retainedEntry = + retainedSendId?.let { id -> + currentSendStates.entries.firstOrNull { (_, state) -> id in state.activeOperationIds } + } + if (retainedEntry != null && matches(retainedEntry.key)) { + val retainedState = + ChatComposerSendState(activeOperationIds = setOf(requireNotNull(retainedSendId))) + nextSendStates += retainedEntry.key to retainedState + } + sendStatesState.value = nextSendStates + attachmentNoticesState.value = attachmentNoticesState.value.filterKeys { !matches(it) } + } + } + + private fun hasSendGateLocked(owner: ChatComposerOwner): Boolean = owner in sendStatesState.value + + private fun finishActiveSendLocked( + owners: Set, + resolvedOwner: ChatComposerOwner, + activeOperationId: String, + pendingAdmissionId: String? = null, + ) { + val sources = owners + resolvedOwner + val merged = mergeSendStatesLocked(sources) + val pendingAdmissionIds = + pendingAdmissionId?.let { merged.pendingAdmissionIds + it } ?: merged.pendingAdmissionIds + val next = + merged.copy( + activeOperationIds = merged.activeOperationIds - activeOperationId, + pendingAdmissionIds = pendingAdmissionIds, + ) + sendStatesState.value = + (sendStatesState.value - sources).let { retained -> + if (next.isEmpty) retained else retained + (resolvedOwner to next) + } + } + + private fun mergeSendStatesLocked(owners: Set): ChatComposerSendState = + owners + .mapNotNull(sendStatesState.value::get) + .fold(ChatComposerSendState()) { merged, current -> + ChatComposerSendState( + activeOperationIds = merged.activeOperationIds + current.activeOperationIds, + pendingAdmissionIds = merged.pendingAdmissionIds + current.pendingAdmissionIds, + ) + } + + private fun removeMediaOwnersLocked(matches: (ChatComposerOwner) -> Boolean) { + mediaOwners.entries.removeAll { matches(it.value) } + attachmentStore.removeOwners(matches) + } + + private fun recordAttachmentOmissionLocked( + owner: ChatComposerOwner, + omitted: Int, + notice: ChatComposerAttachmentNotice, + ) { + if (omitted <= 0) return + val current = attachmentNoticesState.value[owner] + val resolved = if (current == ChatComposerAttachmentNotice.Attachment) current else notice + attachmentNoticesState.value = attachmentNoticesState.value + (owner to resolved) + } + + private companion object { + const val CHAT_COMPOSER_MAX_MEDIA_AUTHORIZATIONS = 32 + } +} diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt index a33d62ca8fe3..4db62416e4a7 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt @@ -1,6 +1,5 @@ package ai.openclaw.app.ui.chat -import ai.openclaw.app.ChatComposerSendStartResult import ai.openclaw.app.GatewayAgentSummary import ai.openclaw.app.GatewayModelSummary import ai.openclaw.app.MainViewModel @@ -236,16 +235,16 @@ fun ChatScreen( val lifecycleState by lifecycleOwner.lifecycle.currentStateFlow.collectAsState() val resolver = context.applicationContext.contentResolver val scope = rememberCoroutineScope() - val inputDrafts = remember(viewModel) { viewModel.chatComposerTextDrafts } + val composerState = remember(viewModel) { viewModel.chatComposerState } + val inputDrafts = composerState.textDrafts val imagePickerOwnerCheckpoint = - rememberSaveable(saver = ChatComposerOwnerCheckpoint.Saver) { ChatComposerOwnerCheckpoint() } - val voiceNoteCommitCheckpoint = remember { ChatVoiceNoteCommitCheckpoint() } + rememberSaveable(saver = ChatComposerMediaCheckpoint.Saver) { ChatComposerMediaCheckpoint() } + val voiceNoteCommitCheckpoint = remember { ChatComposerMediaCheckpoint() } val input = inputDrafts[composerOwner] - val attachmentsByOwner by viewModel.chatComposerAttachments.collectAsState() + val attachmentsByOwner by composerState.attachments.collectAsState() val attachments = attachmentsByOwner[composerOwner].orEmpty() - val sendOwnersInFlight by viewModel.chatComposerSendOwners.collectAsState() - val sendAdmissions by viewModel.chatComposerSendAdmissions.collectAsState() - val attachmentNotices by viewModel.chatComposerAttachmentNotices.collectAsState() + val sendStates by composerState.sendStates.collectAsState() + val attachmentNotices by composerState.attachmentNotices.collectAsState() val shareOwnerRevision by viewModel.chatShareDraftOwnerRevision.collectAsState() val chatShareDraft = remember(chatShareDrafts, composerOwner, mainSessionKey, shareOwnerRevision) { @@ -255,10 +254,10 @@ fun ChatScreen( } val shareStaging = chatShareDraft?.let { viewModel.chatShareDraftTargetsOwner(it.id, composerOwner, mainSessionKey) } == true - val sendAdmission = sendAdmissions[composerOwner] + val pendingSendAdmissionIds = sendStates[composerOwner]?.pendingAdmissionIds.orEmpty() val currentPickerOwner by rememberUpdatedState(composerOwner) val currentPickerMainSessionKey by rememberUpdatedState(mainSessionKey) - val sendInFlight = composerOwner in sendOwnersInFlight + val sendInFlight = composerOwner in sendStates var showModelPicker by rememberSaveable { mutableStateOf(false) } var showBackgroundTasks by rememberSaveable { mutableStateOf(false) } var sendMessageTooLong by rememberSaveable(composerOwner) { mutableStateOf(false) } @@ -296,7 +295,7 @@ fun ChatScreen( mainSessionKey = mainSessionKey, onFinished = { recordingId, attachment -> val lease = voiceNoteCommitCheckpoint.consume(recordingId) ?: return@rememberVoiceNoteRecorderController - viewModel.addChatComposerAttachments(lease.owner, lease.authorizationId, listOf(attachment)) + composerState.addAuthorizedAttachments(lease.owner, lease.authorizationId, listOf(attachment)) }, ) val voiceNoteState by voiceNoteRecorder.state.collectAsState() @@ -306,7 +305,7 @@ fun ChatScreen( rememberLauncherForActivityResult(ActivityResultContracts.GetMultipleContents()) { uris -> val lease = imagePickerOwnerCheckpoint.consume() ?: return@rememberLauncherForActivityResult if (uris.isNullOrEmpty()) { - viewModel.cancelChatComposerMediaAcquisition(lease.authorizationId) + composerState.cancelMediaAcquisition(lease.authorizationId) return@rememberLauncherForActivityResult } val importOwner = @@ -347,7 +346,7 @@ fun ChatScreen( LaunchedEffect( pendingAssistantAutoSend, assistantAutoSendInFlight, - sendOwnersInFlight, + sendStates, composerOwner, healthOk, pendingRunCount, @@ -369,9 +368,9 @@ fun ChatScreen( val shareImportNotice = when (attachmentNotices[composerOwner]) { - ai.openclaw.app.ChatComposerAttachmentNotice.Attachment -> + ChatComposerAttachmentNotice.Attachment -> NativeText.Resource(source = "Could not stage an attachment for sending.", formatArgs = emptyList()) - ai.openclaw.app.ChatComposerAttachmentNotice.Image -> + ChatComposerAttachmentNotice.Image -> nativeText("Some shared images were omitted or could not be added.") null -> when { @@ -401,9 +400,10 @@ fun ChatScreen( mergeChatDraft(draft = claimed, currentInput = input, currentOwner = composerOwner) ?: return@LaunchedEffect } - LaunchedEffect(composerOwner, sendAdmission) { - val admission = sendAdmission ?: return@LaunchedEffect - viewModel.acknowledgeChatComposerSendAdmission(composerOwner, admission.id) + LaunchedEffect(composerOwner, pendingSendAdmissionIds) { + pendingSendAdmissionIds.forEach { admissionId -> + viewModel.acknowledgeChatComposerSendAdmission(composerOwner, admissionId) + } } // The process queue remembers the first owner; only an explicit alias/identity resolution @@ -439,8 +439,8 @@ fun ChatScreen( // have been merged together, and disposal before this point leaves the head for retry. inputDrafts[ownerSnapshot] = mergeSharedChatText(sharedText = staged.text, currentInput = inputDrafts[ownerSnapshot]) - val admissionOmissions = viewModel.addChatComposerAttachments(ownerSnapshot, staged.attachments) - viewModel.reportChatComposerImageOmission( + val admissionOmissions = composerState.addAttachments(ownerSnapshot, staged.attachments) + composerState.reportImageOmission( ownerSnapshot, staged.failedImageCount + staged.droppedImageCount + admissionOmissions, ) @@ -576,18 +576,18 @@ fun ChatScreen( onDismissShareImportNotice = { sendMessageTooLong = false sendCheckpointFull = false - viewModel.clearChatComposerAttachmentOmission(composerOwner) + composerState.clearAttachmentOmission(composerOwner) }, commands = chatCommands, onThinkingLevelChange = viewModel::setChatThinkingLevel, onOpenModelPicker = { showModelPicker = true }, onPickImages = { if (!viewModel.isCurrentChatComposerOwner(composerOwner)) return@ChatComposer - val authorizationId = viewModel.beginChatComposerMediaAcquisition(composerOwner) ?: return@ChatComposer + val authorizationId = composerState.beginMediaAcquisition(composerOwner) ?: return@ChatComposer imagePickerOwnerCheckpoint.begin(composerOwner, authorizationId) pickImages.launch("image/*") }, - onRemoveAttachment = { id -> viewModel.removeChatComposerAttachments(composerOwner, setOf(id)) }, + onRemoveAttachment = { id -> composerState.removeAttachments(composerOwner, setOf(id)) }, voiceNoteState = voiceNoteState, voiceNoteElapsedMs = voiceNoteElapsedMs, voiceNoteLevel = voiceNoteLevel, @@ -595,30 +595,30 @@ fun ChatScreen( onStartVoiceNote = { scope.launch { val ownerSnapshot = composerOwner - val mediaAuthorizationId = viewModel.beginChatComposerMediaAcquisition(ownerSnapshot) ?: return@launch + val mediaAuthorizationId = composerState.beginMediaAcquisition(ownerSnapshot) ?: return@launch val recordingId = UUID.randomUUID().toString() if (!viewModel.isCurrentChatComposerOwner(ownerSnapshot)) { - viewModel.cancelChatComposerMediaAcquisition(mediaAuthorizationId) + composerState.cancelMediaAcquisition(mediaAuthorizationId) return@launch } if (voiceNoteRecorder.start(recordingId)) { if ( viewModel.isCurrentChatComposerOwner(ownerSnapshot) && - viewModel.isChatComposerMediaAcquisitionActive(mediaAuthorizationId) + composerState.isMediaAcquisitionActive(mediaAuthorizationId) ) { - voiceNoteCommitCheckpoint.begin(ownerSnapshot, recordingId, mediaAuthorizationId) + voiceNoteCommitCheckpoint.begin(ownerSnapshot, mediaAuthorizationId, recordingId) } else { voiceNoteRecorder.cancel() - viewModel.cancelChatComposerMediaAcquisition(mediaAuthorizationId) + composerState.cancelMediaAcquisition(mediaAuthorizationId) } } else { - viewModel.cancelChatComposerMediaAcquisition(mediaAuthorizationId) + composerState.cancelMediaAcquisition(mediaAuthorizationId) } } }, onCancelVoiceNote = { voiceNoteCommitCheckpoint.clear()?.let { lease -> - viewModel.cancelChatComposerMediaAcquisition(lease.authorizationId) + composerState.cancelMediaAcquisition(lease.authorizationId) } voiceNoteRecorder.cancel() }, @@ -637,7 +637,7 @@ fun ChatScreen( onSend = { // Re-read the ViewModel so a stale click callback cannot beat StateFlow recomposition. val currentShare = viewModel.chatShareDraftForOwner(composerOwner, mainSessionKey) - if (currentShare != null || composerOwner in sendOwnersInFlight) { + if (currentShare != null || composerOwner in sendStates) { return@ChatComposer } val ownerSnapshot = composerOwner diff --git a/apps/android/app/src/test/java/ai/openclaw/app/MainViewModelTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/MainViewModelTest.kt index 2b560439898c..8c101c64a4f2 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/MainViewModelTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/MainViewModelTest.kt @@ -3,6 +3,7 @@ package ai.openclaw.app import ai.openclaw.app.chat.ChatComposerOwner import ai.openclaw.app.gateway.GatewayRegistryEntry import ai.openclaw.app.gateway.GatewayRegistryEntryKind +import ai.openclaw.app.ui.chat.ChatComposerStateStore import ai.openclaw.app.ui.chat.PendingAttachment import android.content.Context import android.content.Intent @@ -179,19 +180,23 @@ class MainViewModelTest { val (viewModel, _) = createViewModel() val provisional = ChatComposerOwner("gateway", "main", "main", routingVerified = false) val resolved = ChatComposerOwner("gateway", "work", "agent:work:device") - val authorizationId = requireNotNull(viewModel.beginChatComposerMediaAcquisition(provisional)) + val authorizationId = requireNotNull(viewModel.chatComposerState.beginMediaAcquisition(provisional)) viewModel.resolveChatComposerOwnerAliases(to = resolved, mainSessionKey = resolved.sessionKey) assertEquals( 0, - viewModel.addChatComposerAttachments( + viewModel.chatComposerState.addAuthorizedAttachments( owner = resolved, mediaAuthorizationId = authorizationId, - attachments = listOf(PendingAttachment("migrated", "photo.jpg", "image/jpeg", "YQ==")), + candidates = listOf(PendingAttachment("migrated", "photo.jpg", "image/jpeg", "YQ==")), ), ) - assertEquals(1, viewModel.chatComposerAttachments.value[resolved]?.size) + assertEquals( + 1, + viewModel.chatComposerState.attachments.value[resolved] + ?.size, + ) } @Test @@ -217,25 +222,13 @@ class MainViewModelTest { @Test fun assistantAutoSendSharesTheManualComposerAdmissionGate() { - val owner = - ai.openclaw.app.chat - .ChatComposerOwner("gateway", "main", "agent:main:device") + val owner = ChatComposerOwner("gateway", "main", "agent:main:device") + val state = ChatComposerStateStore() - assertTrue(chatComposerOwnerHasActiveSend(owner, setOf(owner), emptyMap())) - assertTrue( - chatComposerOwnerHasActiveSend( - owner, - emptySet(), - mapOf( - owner to - ChatComposerSendAdmission( - id = 1, - owner = owner, - ), - ), - ), - ) - assertFalse(chatComposerOwnerHasActiveSend(owner, emptySet(), emptyMap())) + val sendId = requireNotNull(state.tryBeginTrackedSend(owner)) + assertNull(state.tryBeginTrackedSend(owner)) + state.finishTrackedSend(sendId) + assertNotNull(state.tryBeginTrackedSend(owner)) } @Test @@ -248,19 +241,19 @@ class MainViewModelTest { val retained = ai.openclaw.app.chat .ChatComposerOwner("gateway-b", "main", "main") - viewModel.chatComposerTextDrafts[removed] = "private a" - viewModel.chatComposerTextDrafts[retained] = "private b" + viewModel.chatComposerState.textDrafts[removed] = "private a" + viewModel.chatComposerState.textDrafts[retained] = "private b" val removedAttachment = PendingAttachment("a", "a.txt", "text/plain", "YQ==") val retainedAttachment = PendingAttachment("b", "b.txt", "text/plain", "Yg==") - viewModel.addChatComposerAttachments(removed, listOf(removedAttachment)) - viewModel.addChatComposerAttachments(retained, listOf(retainedAttachment)) + viewModel.chatComposerState.addAttachments(removed, listOf(removedAttachment)) + viewModel.chatComposerState.addAttachments(retained, listOf(retainedAttachment)) viewModel.clearChatComposerGateway("gateway-a") - assertEquals("", viewModel.chatComposerTextDrafts[removed]) - assertEquals("private b", viewModel.chatComposerTextDrafts[retained]) - assertEquals(null, viewModel.chatComposerAttachments.value[removed]) - assertEquals(listOf(retainedAttachment), viewModel.chatComposerAttachments.value[retained]) + assertEquals("", viewModel.chatComposerState.textDrafts[removed]) + assertEquals("private b", viewModel.chatComposerState.textDrafts[retained]) + assertEquals(null, viewModel.chatComposerState.attachments.value[removed]) + assertEquals(listOf(retainedAttachment), viewModel.chatComposerState.attachments.value[retained]) } @Test @@ -268,17 +261,17 @@ class MainViewModelTest { runBlocking { val (viewModel, _) = createViewModel() val owner = ChatComposerOwner("gateway-a", "main", "main") - val authorizationId = requireNotNull(viewModel.beginChatComposerMediaAcquisition(owner)) + val authorizationId = requireNotNull(viewModel.chatComposerState.beginMediaAcquisition(owner)) var imageLoaderCalled = false viewModel.clearChatComposerGateway("gateway-a") - assertFalse(viewModel.isChatComposerMediaAcquisitionActive(authorizationId)) + assertFalse(viewModel.chatComposerState.isMediaAcquisitionActive(authorizationId)) assertNull( - viewModel.addChatComposerAttachments( + viewModel.chatComposerState.addAuthorizedAttachments( owner = owner, mediaAuthorizationId = authorizationId, - attachments = listOf(PendingAttachment("late", "late.txt", "text/plain", "YQ==")), + candidates = listOf(PendingAttachment("late", "late.txt", "text/plain", "YQ==")), ), ) viewModel.importChatComposerAttachments(owner, authorizationId, mainSessionKey = "main", expectedCount = 1) { @@ -286,7 +279,7 @@ class MainViewModelTest { listOf(PendingAttachment("late-image", "late.jpg", "image/jpeg", "YQ==")) } assertFalse(imageLoaderCalled) - assertNull(viewModel.chatComposerAttachments.value[owner]) + assertNull(viewModel.chatComposerState.attachments.value[owner]) } @Test @@ -308,10 +301,10 @@ class MainViewModelTest { val otherAgent = ai.openclaw.app.chat .ChatComposerOwner("gateway-a", "work", "agent:main:device") - val mediaAuthorizationId = requireNotNull(viewModel.beginChatComposerMediaAcquisition(canonical)) + val mediaAuthorizationId = requireNotNull(viewModel.chatComposerState.beginMediaAcquisition(canonical)) listOf(alias, canonical, provisional, sibling, otherAgent).forEach { owner -> - viewModel.chatComposerTextDrafts[owner] = owner.toString() - viewModel.addChatComposerAttachments( + viewModel.chatComposerState.textDrafts[owner] = owner.toString() + viewModel.chatComposerState.addAttachments( owner, listOf(PendingAttachment(owner.toString(), "draft.txt", "text/plain", "YQ==")), ) @@ -324,22 +317,30 @@ class MainViewModelTest { mainSessionKey = "agent:main:device", ) - assertEquals("", viewModel.chatComposerTextDrafts[alias]) - assertEquals("", viewModel.chatComposerTextDrafts[canonical]) - assertEquals("", viewModel.chatComposerTextDrafts[provisional]) - assertEquals(sibling.toString(), viewModel.chatComposerTextDrafts[sibling]) - assertEquals(otherAgent.toString(), viewModel.chatComposerTextDrafts[otherAgent]) - assertEquals(null, viewModel.chatComposerAttachments.value[alias]) - assertEquals(null, viewModel.chatComposerAttachments.value[canonical]) - assertEquals(null, viewModel.chatComposerAttachments.value[provisional]) - assertEquals(1, viewModel.chatComposerAttachments.value[sibling]?.size) - assertEquals(1, viewModel.chatComposerAttachments.value[otherAgent]?.size) - assertFalse(viewModel.isChatComposerMediaAcquisitionActive(mediaAuthorizationId)) + assertEquals("", viewModel.chatComposerState.textDrafts[alias]) + assertEquals("", viewModel.chatComposerState.textDrafts[canonical]) + assertEquals("", viewModel.chatComposerState.textDrafts[provisional]) + assertEquals(sibling.toString(), viewModel.chatComposerState.textDrafts[sibling]) + assertEquals(otherAgent.toString(), viewModel.chatComposerState.textDrafts[otherAgent]) + assertEquals(null, viewModel.chatComposerState.attachments.value[alias]) + assertEquals(null, viewModel.chatComposerState.attachments.value[canonical]) + assertEquals(null, viewModel.chatComposerState.attachments.value[provisional]) + assertEquals( + 1, + viewModel.chatComposerState.attachments.value[sibling] + ?.size, + ) + assertEquals( + 1, + viewModel.chatComposerState.attachments.value[otherAgent] + ?.size, + ) + assertFalse(viewModel.chatComposerState.isMediaAcquisitionActive(mediaAuthorizationId)) assertNull( - viewModel.addChatComposerAttachments( + viewModel.chatComposerState.addAuthorizedAttachments( owner = canonical, mediaAuthorizationId = mediaAuthorizationId, - attachments = listOf(PendingAttachment("late", "late.txt", "text/plain", "YQ==")), + candidates = listOf(PendingAttachment("late", "late.txt", "text/plain", "YQ==")), ), ) } diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatComposerDraftTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatComposerDraftTest.kt index f949fd52e0c6..171953a759a2 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatComposerDraftTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatComposerDraftTest.kt @@ -15,6 +15,8 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull import org.junit.Assert.assertThrows import org.junit.Assert.assertTrue import org.junit.Test @@ -41,22 +43,21 @@ class ChatComposerDraftTest { @Test fun sendPayloadReadsCurrentOwnerStoresAfterEditsAndRemovals() { val owner = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "main", sessionKey = "agent:main:first") - val textDrafts = ChatComposerTextDraftStore() - val attachments = ChatComposerAttachmentStore() + val state = ChatComposerStateStore() val removed = PendingAttachment("removed", "removed.jpg", "image/jpeg", "YQ==") val retained = PendingAttachment("retained", "retained.jpg", "image/jpeg", "Yg==") - textDrafts[owner] = "old text" - attachments.add(owner, listOf(removed)) + state.textDrafts[owner] = "old text" + state.addAttachments(owner, listOf(removed)) - textDrafts[owner] = " edited text " - attachments.remove(owner, setOf(removed.id)) - attachments.add(owner, listOf(retained)) + state.textDrafts[owner] = " edited text " + state.removeAttachments(owner, setOf(removed.id)) + state.addAttachments(owner, listOf(retained)) - val payload = captureChatComposerSendPayload(owner, textDrafts, attachments) + val request = requireNotNull(state.beginSend(owner).request) - assertEquals(" edited text ", payload.inputSnapshot) - assertEquals("edited text", payload.message) - assertEquals(listOf(retained), payload.attachments) + assertEquals(" edited text ", request.inputSnapshot) + assertEquals("edited text", request.message) + assertEquals(listOf(retained), request.attachments) } @Test @@ -326,6 +327,49 @@ class ChatComposerDraftTest { assertEquals("saved canonical draft\n\ntyped while connecting", store[canonical]) } + @Test + fun aliasResolutionPreservesEveryActiveSendAndPendingAcknowledgement() { + val alias = ChatComposerOwner("gateway-a", "main", "main") + val provisional = ChatComposerOwner("gateway-a", "main", "main", routingVerified = false) + val canonical = ChatComposerOwner("gateway-a", "main", "agent:main:device") + val state = ChatComposerStateStore() + state.textDrafts[alias] = "manual send" + val manualRequest = requireNotNull(state.beginSend(alias).request) + state.completeSend(manualRequest, accepted = true) + val trackedSendId = requireNotNull(state.tryBeginTrackedSend(provisional)) + state.textDrafts[canonical] = "second manual send" + val activeManualRequest = requireNotNull(state.beginSend(canonical).request) + + state.resolveAliases(canonical, canonical.sessionKey) + + assertEquals( + ChatComposerSendState( + activeOperationIds = setOf(trackedSendId, activeManualRequest.commandId), + pendingAdmissionIds = setOf(manualRequest.commandId), + ), + state.sendStates.value[canonical], + ) + state.acknowledgeSendAdmission(canonical, manualRequest.commandId) + assertEquals( + ChatComposerSendState(activeOperationIds = setOf(trackedSendId, activeManualRequest.commandId)), + state.sendStates.value[canonical], + ) + assertNull(state.tryBeginTrackedSend(canonical)) + + state.finishTrackedSend(trackedSendId) + assertEquals( + ChatComposerSendState(activeOperationIds = setOf(activeManualRequest.commandId)), + state.sendStates.value[canonical], + ) + state.completeSend(activeManualRequest, accepted = true) + assertEquals( + ChatComposerSendState(pendingAdmissionIds = setOf(activeManualRequest.commandId)), + state.sendStates.value[canonical], + ) + state.acknowledgeSendAdmission(canonical, activeManualRequest.commandId) + assertNotNull(state.tryBeginTrackedSend(canonical)) + } + @Test fun gatewayBoundProvisionalDraftMovesToItsVerifiedOwner() { val store = ChatComposerTextDraftStore() @@ -620,16 +664,6 @@ class ChatComposerDraftTest { assertFalse(canCommitStagedChatShare(current.id, null, owner, owner)) } - @Test - fun asyncComposerResultsCommitOnlyToTheirOriginalOwner() { - val owner = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "agent-a", sessionKey = "session-a") - - assertTrue(canCommitComposerResult(owner, owner)) - assertFalse(canCommitComposerResult(owner, owner.copy(gatewayStableId = "gateway-b"))) - assertFalse(canCommitComposerResult(owner, owner.copy(agentId = "agent-b"))) - assertFalse(canCommitComposerResult(owner, owner.copy(sessionKey = "session-b"))) - } - @Test fun pendingAttachmentsRemainKeyedAcrossComposerNavigationAndOwnerResolution() { val ownerA = ChatComposerOwner(gatewayStableId = "gateway", agentId = "agent-a", sessionKey = "session-a") @@ -750,10 +784,10 @@ class ChatComposerDraftTest { fun voiceNoteCompletionMustMatchTheRecordingThatStartedIt() { val ownerA = ChatComposerOwner("gateway", "agent-a", "session-a") val ownerB = ChatComposerOwner("gateway", "agent-b", "session-b") - val checkpoint = ChatVoiceNoteCommitCheckpoint() + val checkpoint = ChatComposerMediaCheckpoint() - checkpoint.begin(ownerA, "recording-a", mediaAuthorizationId = "auth-a") - checkpoint.begin(ownerB, "recording-b", mediaAuthorizationId = "auth-b") + checkpoint.begin(ownerA, mediaAuthorizationId = "auth-a", requestId = "recording-a") + checkpoint.begin(ownerB, mediaAuthorizationId = "auth-b", requestId = "recording-b") assertEquals(null, checkpoint.consume("recording-a")) assertEquals(ownerB, checkpoint.owner) @@ -764,14 +798,14 @@ class ChatComposerDraftTest { @Test fun imagePickerCheckpointCarriesTheCredentialGenerationThroughRecreation() { val owner = ChatComposerOwner("gateway", "agent", "session") - val checkpoint = ChatComposerOwnerCheckpoint() + val checkpoint = ChatComposerMediaCheckpoint() checkpoint.begin(owner, mediaAuthorizationId = "media-auth") val saverScope = SaverScope { true } val saved = - with(ChatComposerOwnerCheckpoint.Saver) { + with(ChatComposerMediaCheckpoint.Saver) { saverScope.save(checkpoint) } - val restored = requireNotNull(ChatComposerOwnerCheckpoint.Saver.restore(requireNotNull(saved))) + val restored = requireNotNull(ChatComposerMediaCheckpoint.Saver.restore(requireNotNull(saved))) assertEquals(ChatComposerMediaLease(owner, "media-auth"), restored.consume()) }