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>
This commit is contained in:
Peter Steinberger
2026-07-18 06:15:30 +01:00
committed by GitHub
parent db7a5c6333
commit 330f36fb32
7 changed files with 626 additions and 503 deletions
+5 -5
View File
@@ -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",
@@ -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<ChatDraft?>(null)
internal val chatDraft: StateFlow<ChatDraft?> = chatDraftState
private val chatDraftLock = Any()
private val chatComposerAttachmentStore = ChatComposerAttachmentStore()
private val chatComposerMediaLock = Any()
private val chatComposerMediaOwners = linkedMapOf<String, ChatComposerOwner>()
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<Map<ChatComposerOwner, ChatComposerAttachmentNotice>>(emptyMap())
internal val chatComposerAttachmentNotices: StateFlow<Map<ChatComposerOwner, ChatComposerAttachmentNotice>> =
chatComposerAttachmentNoticesState.asStateFlow()
private val chatComposerSendLock = Any()
private val chatComposerSendSeq = AtomicLong()
private val recoveredChatComposerSends = chatComposerTextDrafts.pendingAdmissions()
private val chatComposerSendOwnersState =
MutableStateFlow<Set<ChatComposerOwner>>(
recoveredChatComposerSends.mapTo(linkedSetOf()) { pending -> pending.owner },
)
internal val chatComposerSendOwners: StateFlow<Set<ChatComposerOwner>> = chatComposerSendOwnersState.asStateFlow()
private val chatComposerSendAdmissionsState =
MutableStateFlow<Map<ChatComposerOwner, ChatComposerSendAdmission>>(emptyMap())
internal val chatComposerSendAdmissions: StateFlow<Map<ChatComposerOwner, ChatComposerSendAdmission>> =
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<PendingAttachment>,
): Int =
chatComposerAttachmentStore.add(owner, attachments).also { omitted ->
recordChatComposerAttachmentOmission(owner, omitted)
}
internal fun addChatComposerAttachments(
owner: ChatComposerOwner,
mediaAuthorizationId: String,
attachments: List<PendingAttachment>,
): 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<String>,
) {
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<PendingAttachment>,
) {
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<ChatComposerOwner>,
admissions: Map<ChatComposerOwner, ChatComposerSendAdmission>,
): Boolean = owner in sendOwners || owner in admissions
@@ -25,26 +25,6 @@ internal data class PendingChatComposerSend(
val inputSnapshot: String?,
)
internal data class ChatComposerSendPayload(
val inputSnapshot: String,
val message: String,
val attachments: List<PendingAttachment>,
)
/** 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<ChatComposerOwner, String> = emptyMap(),
val pendingSends: List<PendingChatComposerSend> = 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<ChatComposerOwnerCheckpoint, String>(
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<ChatComposerMediaCheckpoint, String>(
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<String> =
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,
@@ -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<PendingAttachment>,
)
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<String> = emptySet(),
val pendingAdmissionIds: Set<String> = 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<String>) -> Unit = {},
) {
private val lock = Any()
private val attachmentStore = ChatComposerAttachmentStore()
private val mediaOwners = linkedMapOf<String, ChatComposerOwner>()
val textDrafts =
ChatComposerTextDraftStore(
initial = initialDrafts,
onSnapshotChanged = onDraftSnapshotChanged,
)
val attachments = attachmentStore.attachments
private val attachmentNoticesState =
MutableStateFlow<Map<ChatComposerOwner, ChatComposerAttachmentNotice>>(emptyMap())
val attachmentNotices: StateFlow<Map<ChatComposerOwner, ChatComposerAttachmentNotice>> =
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<Map<ChatComposerOwner, ChatComposerSendState>> = sendStatesState.asStateFlow()
fun recoveredSends(): List<PendingChatComposerSend> = 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<PendingAttachment>,
): Int =
synchronized(lock) {
attachmentStore.add(owner, candidates).also { omitted ->
recordAttachmentOmissionLocked(owner, omitted, ChatComposerAttachmentNotice.Attachment)
}
}
fun addAuthorizedAttachments(
owner: ChatComposerOwner,
mediaAuthorizationId: String,
candidates: List<PendingAttachment>,
): 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<String>,
) = 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<PendingAttachment>,
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<ChatComposerOwner> =
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<ChatComposerOwner>,
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<ChatComposerOwner>): 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
}
}
@@ -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
@@ -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==")),
),
)
}
@@ -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())
}