mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
db7a5c6333
commit
330f36fb32
@@ -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())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user