From 87e3f8c3f6ee2db2fe61726c6242260f8269ec73 Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:35:29 +0000 Subject: [PATCH 01/13] ui: open a page once when its button is clicked again or double-clicked (#7725) * desktop: open a pending connection's "Learn more" and "Incognito" pages once On desktop the connection view stays clickable in the center pane, so each click on these rows pushed another modal onto the right pane. Close the right-pane modals first, as ChatView does when the connection opens. The page is closed and shown again in one event, so its content is keyed by its ModalData to start fresh. Desktop only: on Android the view can itself be the open modal of the shared manager ("Set contact name"), and the new page already covers the row. Fixes #6270 * ui: open a modal once when its button is double-clicked A modal slides in over 250 ms, and until it covers the button that opened it the button still takes clicks, so a double click opened two copies (left-pane pages and most stacked center and right-pane pages on desktop, animated modals on Android). When a modal opens, the page it covers draws an empty pointerInput box over itself while it slides out; when a pane's first modal slides in, the empty page sliding out covers the view under the pane. The page sliding in already takes clicks, and nothing is blocked when a page closes. * ui: add isTopModalOpen, which ignores modals that are sliding out A modal closed with animation stays in modalViews until it has slid out, and isLastModalOpen counts it. A check-then-closeModal() on it would close the modal under it. Uses the same index as isLastModal; isLastModalOpen and its callers are unchanged. * desktop: don't open a second badges page or profile form These buttons stay clickable on desktop while their page is shown in another pane, so each click opened another copy: - "Support SimpleX" in the chat's large-file and badge alerts (Badges on the right): the alerts skip while Badges is open, on desktop only, as on Android the page covers the button; - "+ Add profile" in Your chat profiles and "Create chat profile" in the picker (form in the center): skip while the form is on top, keeping what was typed, before asking for authentication; the picker row still hides the picker. * desktop: replace a page of the same kind on top instead of stacking another These buttons stay clickable on desktop while their page is shown in another pane, so each click pushed another copy: - the chat banner's bio "Read more" (right pane); - a markdown file clicked in a support chat (viewer in the center); - message "Info" in the main chat while a support or reports chat is open on the right. Their content depends on what was clicked and the pane can hold other pages (a support chat), so a page of the same kind on top is replaced. The new page is drawn where the old one was, so its content is keyed by its ModalData to start fresh. The markdown file and Info load on a background thread; their close and show now run on the main thread. The markdown check is desktop only, as on Android the viewer covers its file once it has loaded. * plans: a page opens again on every click of its button (#6270) * plans: D2 is valid at 80 ms only --- .../simplex/common/views/chat/ChatView.kt | 42 +++++----- .../simplex/common/views/chat/ComposeView.kt | 2 +- .../views/chat/item/MarkdownFileView.kt | 13 +-- .../common/views/chat/item/TextItemView.kt | 33 ++++---- .../common/views/chatlist/UserPicker.kt | 6 +- .../common/views/helpers/ChatInfoImage.kt | 2 +- .../simplex/common/views/helpers/ModalView.kt | 13 ++- .../newchat/ContactConnectionInfoView.kt | 8 +- .../views/usersettings/UserProfilesView.kt | 3 +- ...10-15-modal-opens-again-on-repeat-click.md | 80 +++++++++++++++++++ 10 files changed, 156 insertions(+), 46 deletions(-) create mode 100644 plans/2026-10-15-modal-opens-again-on-repeat-click.md diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index e9dbb3b157..960efd0350 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -723,35 +723,39 @@ fun ChatView( ciInfo } groupMembersJob.cancel() - groupMembersJob = scope.launch(Dispatchers.Default) { - var initialCiInfo = loadChatItemInfo() ?: return@launch + groupMembersJob = scope.launch { + var initialCiInfo = withContext(Dispatchers.Default) { loadChatItemInfo() } ?: return@launch if (!ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) { ModalManager.end.closeModals() + } else if (ModalManager.end.isTopModalOpen(ModalViewId.CHAT_ITEM_INFO)) { + ModalManager.end.closeModal() } - ModalManager.end.showModalCloseable(endButtons = { + ModalManager.end.showModalCloseable(id = ModalViewId.CHAT_ITEM_INFO, endButtons = { ShareButton { clipboard.shareText(itemInfoShareText(chatModel, initialCiInfo.first, initialCiInfo.second, chatModel.controller.appPrefs.developerTools.get())) } }) { close -> - var ciInfo by remember(cItem.id) { mutableStateOf(initialCiInfo) } - val (item, info) = ciInfo - ChatItemInfoView(chatRh, item, info, devTools = chatModel.controller.appPrefs.developerTools.get(), chatInfo) - LaunchedEffect(cItem.id) { - withContext(Dispatchers.Default) { - for (msg in controller.messagesChannel) { - if (msg.rhId == chatRh && - msg is API.Result && - msg.res is CR.ChatItemsStatusesUpdated && - msg.res.chatItems.any { it.chatItem.id == cItem.id } - ) { - ciInfo = loadChatItemInfo() ?: return@withContext - initialCiInfo = ciInfo + key(this) { + var ciInfo by remember(cItem.id) { mutableStateOf(initialCiInfo) } + val (item, info) = ciInfo + ChatItemInfoView(chatRh, item, info, devTools = chatModel.controller.appPrefs.developerTools.get(), chatInfo) + LaunchedEffect(cItem.id) { + withContext(Dispatchers.Default) { + for (msg in controller.messagesChannel) { + if (msg.rhId == chatRh && + msg is API.Result && + msg.res is CR.ChatItemsStatusesUpdated && + msg.res.chatItems.any { it.chatItem.id == cItem.id } + ) { + ciInfo = loadChatItemInfo() ?: return@withContext + initialCiInfo = ciInfo + } } } } - } - KeyChangeEffect(chatModel.chatId.value) { - close() + KeyChangeEffect(chatModel.chatId.value) { + close() + } } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt index 357a010d2c..2fb82679e7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt @@ -333,7 +333,7 @@ fun showLargeFileAlert(fileSize: Long, incognito: Boolean, senderProfile: LocalP text = text, confirmText = generalGetString(MR.strings.ok), dismissText = generalGetString(MR.strings.badges_support_simplex_title), - onDismiss = ::openBadgesView + onDismiss = { if (!appPlatform.isDesktop || !ModalManager.end.hasModalOpen(ModalViewId.BADGES)) openBadgesView() } ) } else { AlertManager.shared.showAlertMsg(title, text) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkdownFileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkdownFileView.kt index 189e69a925..15758a7296 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkdownFileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkdownFileView.kt @@ -96,11 +96,14 @@ fun showMarkdownFile(file: CIFile) { formatted, bidiControlChar.containsMatchIn(text) ) - ModalManager.center.showModal(endButtons = { - if (formatted != null) MarkdownViewSwitch() - MarkdownFileSaveButton(file, bytes) - }) { - MarkdownFileView(file.fileName, document) + withContext(Dispatchers.Main) { + if (appPlatform.isDesktop && ModalManager.center.isTopModalOpen(ModalViewId.MARKDOWN_FILE)) ModalManager.center.closeModal() + ModalManager.center.showModal(id = ModalViewId.MARKDOWN_FILE, endButtons = { + if (formatted != null) MarkdownViewSwitch() + MarkdownFileSaveButton(file, bytes) + }) { + key(this) { MarkdownFileView(file.fileName, document) } + } } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt index 9b3162744d..f28717e115 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt @@ -65,21 +65,24 @@ private fun openMarkdownModal(modal: Format.Modal) { } private fun showFullProfileDescription(description: String) { - ModalManager.end.showModalCloseable { _ -> - ColumnWithScrollBar { - AppBarTitle(generalGetString(MR.strings.profile_description__field)) - val parsed = parseToMarkdownText(description) - MarkdownText( - description, - parsed?.formattedText, - parsed?.markdownBlocks, - toggleSecrets = true, - style = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground, lineHeight = 22.sp), - uriHandler = LocalUriHandler.current, - linkMode = chatModel.simplexLinkMode.value, - modifier = Modifier.padding(horizontal = DEFAULT_PADDING).padding(bottom = DEFAULT_PADDING), - blockLayout = true, - ) + if (ModalManager.end.isTopModalOpen(ModalViewId.PROFILE_DESCRIPTION)) ModalManager.end.closeModal() + ModalManager.end.showModalCloseable(id = ModalViewId.PROFILE_DESCRIPTION) { _ -> + key(this) { + ColumnWithScrollBar { + AppBarTitle(generalGetString(MR.strings.profile_description__field)) + val parsed = parseToMarkdownText(description) + MarkdownText( + description, + parsed?.formattedText, + parsed?.markdownBlocks, + toggleSecrets = true, + style = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground, lineHeight = 22.sp), + uriHandler = LocalUriHandler.current, + linkMode = chatModel.simplexLinkMode.value, + modifier = Modifier.padding(horizontal = DEFAULT_PADDING).padding(bottom = DEFAULT_PADDING), + blockLayout = true, + ) + } } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt index ea567d7da6..e3f6b92be8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt @@ -274,8 +274,12 @@ fun UserPicker( painterResource(MR.images.ic_manage_accounts), generalGetString(MR.strings.create_chat_profile), { + if (ModalManager.center.isTopModalOpen(ModalViewId.CREATE_PROFILE)) { + userPickerState.value = AnimatedViewState.HIDING + return@UserPickerOptionRow + } doWithAuth(generalGetString(MR.strings.auth_open_chat_profiles), generalGetString(MR.strings.auth_log_in_using_credential)) { - ModalManager.center.showModalCloseable { close -> + ModalManager.center.showModalCloseable(id = ModalViewId.CREATE_PROFILE) { close -> LaunchedEffect(Unit) { userPickerState.value = AnimatedViewState.HIDING } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt index 036bcab68d..b09efe7d1d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt @@ -252,7 +252,7 @@ fun showBadgeInfoAlert(name: String, badge: LocalBadge, uriHandler: UriHandler) text = supports, confirmText = generalGetString(MR.strings.ok), dismissText = generalGetString(MR.strings.badges_support_simplex_title), - onDismiss = ::openBadgesView + onDismiss = { if (!appPlatform.isDesktop || !ModalManager.end.hasModalOpen(ModalViewId.BADGES)) openBadgesView() } ) } else { AlertManager.shared.showAlertMsg(title = title, text = supports) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt index 3655c4053d..7f6ee13d98 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt @@ -9,6 +9,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.platform.* import LocalCardScreen @@ -93,7 +94,11 @@ enum class ModalViewId { CONTEXT_USER_PICKER_INCOGNITO, BADGE_LINK, BADGE_REDEEM_CODE, - BADGES + BADGES, + CREATE_PROFILE, + PROFILE_DESCRIPTION, + MARKDOWN_FILE, + CHAT_ITEM_INFO } class ModalManager(private val placement: ModalPlacement? = null) { @@ -118,6 +123,8 @@ class ModalManager(private val placement: ModalPlacement? = null) { fun isLastModalOpen(id: ModalViewId): Boolean = modalViews.lastOrNull()?.id == id + fun isTopModalOpen(id: ModalViewId): Boolean = modalViews.getOrNull(modalViews.size - toRemove.size - 1)?.id == id + fun isLastModal(data: ModalData): Boolean = modalViews.getOrNull(modalViews.size - toRemove.size - 1)?.data === data fun showModal(settings: Boolean = false, showClose: Boolean = true, id: ModalViewId? = null, forceAnimated: Boolean = false, cardScreen: Boolean = false, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable ModalData.() -> Unit) { @@ -224,6 +231,10 @@ class ModalManager(private val placement: ModalPlacement? = null) { if (toRemove.isNotEmpty() && it == modalCount.value && transition.currentState == EnterExitState.Visible && !transition.isRunning) { runAtomically { toRemove.removeIf { elem -> modalViews.removeAt(elem); true } } } + // Until the opening modal covers the page that opened it, or the view under the first modal, a second click there would open it again + if (transition.targetState == EnterExitState.PostExit && it < modalCount.value) { + Box(Modifier.fillMaxSize().pointerInput(Unit) {}) + } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt index f6109778dc..82cffbb384 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt @@ -66,8 +66,9 @@ fun ContactConnectionInfoView( deleteConnection = { deleteContactConnectionAlert(rhId, contactConnection, chatModel, close) }, onLocalAliasChanged = { setContactAlias(rhId, contactConnection, it, chatModel) }, learnMore = { + if (appPlatform.isDesktop) ModalManager.end.closeModals() ModalManager.end.showModalCloseable { close -> - AddContactLearnMore(close) + key(this) { AddContactLearnMore(close) } } } ) @@ -90,7 +91,10 @@ private fun ContactConnectionInfoLayout( SettingsActionItemWithContent( icon = painterResource(MR.images.ic_theater_comedy_filled), text = null, - click = { ModalManager.end.showModal { IncognitoView() } }, + click = { + if (appPlatform.isDesktop) ModalManager.end.closeModals() + ModalManager.end.showModal { key(this) { IncognitoView() } } + }, iconColor = Indigo, extraPadding = false ) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt index e91d6c2ce0..5d8ecec60b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt @@ -47,8 +47,9 @@ fun UserProfilesView(m: ChatModel, search: MutableState, profileHidden: showHiddenProfilesNotice = m.controller.appPrefs.showHiddenProfilesNotice, visibleUsersCount = visibleUsersCount(m), addUser = { + if (ModalManager.center.isTopModalOpen(ModalViewId.CREATE_PROFILE)) return@UserProfilesLayout withAuth { - ModalManager.center.showModalCloseable { close -> + ModalManager.center.showModalCloseable(id = ModalViewId.CREATE_PROFILE) { close -> CreateProfile(m, close) } } diff --git a/plans/2026-10-15-modal-opens-again-on-repeat-click.md b/plans/2026-10-15-modal-opens-again-on-repeat-click.md new file mode 100644 index 0000000000..4360d4c288 --- /dev/null +++ b/plans/2026-10-15-modal-opens-again-on-repeat-click.md @@ -0,0 +1,80 @@ +# A page opens again on every click of the button that opened it + +Fixes #6270. + +## Problem + +Issue #6270 reports one case: on desktop (Linux), clicking "Learn more" repeatedly in a pending connection opens the page again on every click. Each copy then needs its own Back. The same bug has two causes, and both occur across the app: + +1. **The button stays clickable in another pane.** On desktop the window has a left pane (chat list, `ModalManager.start`), a center pane (chat or `ModalManager.center`) and a right pane (`ModalManager.end`). Each pane draws only the top modal of its own stack. When a button in one pane opens a page in another, the button stays visible and clickable, and every click pushes another copy. +2. **A fast double click during the slide-in.** A modal slides in over 250 ms. Until it covers the button that opened it, the button still takes clicks, so a double click opens two copies. Pages slide in on Android and in the desktop left pane; in the desktop center and right panes the first page never slides, the third and later do, and the second does only after an earlier page there has slid out. + +Reproduced on the master prebuild (a932c82), R8 on the stable prebuild (master has the same code): + +| # | Steps | Clicks | Backs needed | +|---|---|---|---| +| R1 | Pending connection: "Learn more" or "Incognito" | 3 | 3 | +| R2 | Your chat profiles: "+ Add profile" (form opens in the center) | 3 | 3 | +| R3 | "Chat with admins" open on the right (a reports chat opens the same way; not tried); in the group chat, message menu → "Info" | 3 | 3 | +| R4 | Direct chat banner: profile bio "Read more" | 3 | 3 | +| R5 | Attach a file over 1 GB; "Large file" alert → "Support SimpleX" | 3 | 3 | +| R6 | `.md` file clicked in a support chat on the right (viewer opens in the center) | 3 | 3 | +| R7 | What's New opened in the center after an update → "Create your address" → "Address settings" | 3 | 3 | +| R8 | No profile and no linked mobile: picker → "Create chat profile" | 3 | 3 | +| D1 | Settings: double-click "Appearance" (80 ms, 150 ms) | 2 | 2 | +| D2 | Settings: double-click "Network & servers" (80 ms) | 2 | 2 | +| D3 | User menu: double-click "Your chat profiles" (80 ms) | 2 | 2 | + +Controls that don't stack on master: message "Info" with nothing open on the right, a double click on "Group preferences" when group info is the only page on the right, a double click on the new-chat pencil. + +## Audit + +Every `ModalManager.{start,center,end,fullscreen}.show*` call on master (about 170) was classified by where its button is drawn on desktop and whether that button stays clickable after the page opens. Wrapper functions and every caller of each view were followed. The cases above are all those found. All other call sites are covered by the page they open, guarded already, or open a fullscreen modal, which covers every pane. + +Existing guards: close the target pane first (`ChatListNavLinkView` "Set contact name", the pending-connection branch of `ChatView`), skip if a page with that id is open (`BadgesSupportSimplexView`, `BADGE_REDEEM_CODE`), toggles (the chat info button, the profile picker's incognito info), and the desktop overlay in `DesktopScreen` that closes left-pane pages on a click in the center (it guards the onboarding cards there). + +## Fix + +### Double click: block clicks on the page being covered (`ModalManager.showInView`) + +`AnimatedContent` draws each page in a child with its own enter or exit transition. When a modal opens, the child sliding out (`targetState == PostExit`, below the new count) also draws an empty `pointerInput` box over itself. That child is the page being covered, which is the page that opened the new one when the button is in the same pane, or, for the first modal of a pane, the empty child, whose box covers the chat list or chat under the pane. The rest of the pane is under the page sliding in, whose `Surface` already takes clicks. Nothing is blocked when a page closes, as on master. + +This fixes the double click for every animated modal on both platforms, with no change at the call sites. + +### Buttons in another pane + +Each site uses an existing guard, or "replace" (an on-top check followed by a close and a show), chosen by where its button can be and what the page shows: + +| Site | Guard | Why this one | +|---|---|---| +| `ContactConnectionInfoView` "Learn more", "Incognito" (R1) | desktop: close the right pane first | The button is never in the right pane on desktop. On Android the view can itself be the open modal ("Set contact name"), so the call is desktop only. | +| The large-file and badge alerts' "Support SimpleX" (R5), which call `openBadgesView` | desktop: skip if `BADGES` is open | Badges can also be open under other right-pane pages, so the right pane can't be closed. `BADGES` is already set on this modal. Badges sub-pages stack above it, so the check is "open", not "on top": while an open Badges page is covered by another page, these two buttons do nothing. The check is at the two alerts, not in `openBadgesView`, so the "Larger files, longer" row in message Info, which its Badges page covers, is unchanged. On Android the page covers its button. | +| `UserProfilesView` "+ Add profile" (R2), `UserPicker` "Create chat profile" (R8) | skip if `CREATE_PROFILE` is on top | Closing the center would also close What's New when it is open there, and clear `chatModel.secondaryChatsContext` while a support chat is open on the right (`closeModals()` always clears it). Both clicks open the same empty form, so the open form, with anything already typed, is kept. Both check before asking for authentication, and the picker row also hides the picker, as the form does when it opens. "On top" rather than "open": with no chat selected the center stops drawing its page stack once its last page has closed, so a closed page can stay in its list. | +| `showFullProfileDescription` "Read more" (R4) | replace if `PROFILE_DESCRIPTION` is on top | Also opened from info pages in the right pane. The text depends on which link was clicked. | +| `showMarkdownFile` (R6) | desktop: replace if `MARKDOWN_FILE` is on top, on the main thread | The opener can be a support chat in the right pane, which must stay. Another file must show its own text. The file is read on a background thread, so the close and show run on the main thread, where the pane reads its stack. On Android the viewer covers its file once it has loaded; taps during the load are the async case under Not fixed. | +| `showItemDetails` message "Info" (R3) | when a secondary chat is open, replace if `CHAT_ITEM_INFO` is on top | Info already closes the right pane first unless a secondary chat is open there. In that case it was pushed over the support or reports chat on every click. The first Info over the support chat still opens over it, and Back still returns to it. The job now runs on the chat's UI scope with only the load on a background thread, so the close and show run on the main thread, as `showReports` does. | + +"Replace" closes and shows in one main-thread event, so the new page is drawn in the same place as the old one, from a lambda of the same call site, and Compose would keep the old page's state. That includes `AppBarTitle`'s `LaunchedEffect(title)`, so when the title is unchanged it would not reach the new page's app bar. Each replaced page, and each #6270 row, wraps its content in `key(this)`, the `ModalData` of that show, so it starts fresh. + +The "on top" checks use a new `isTopModalOpen`, which looks at the visible top modal (the same index as `isLastModal`). A modal closed with animation stays in `modalViews` until its slide-out ends, and `isLastModalOpen` still counts it: "replace" during those 250 ms would then call `closeModal()` on a page already closing, and `closeModal()` would close the page under it. `isLastModalOpen` and its callers are unchanged. + +### Not fixed + +- **R7, What's New "Create your address".** The address page opened from What's New in the center pushes its own pages (Address settings, SimpleX name, 1-time link, Learn more) to the left pane, beside it. Opening it in the left pane instead, where they would cover it, makes it disposed while covered: on Back it is composed again, its `autoCreateAddress` effect runs again (re-creating an address the user just deleted) and its "share with contacts" state resets. The Settings → What's New path and Android already have that flaw. Fixing it needs `UserAddressView` to keep that state across disposal, which a follow-up PR (#7756) does. +- Async openers that load data before showing a page, e.g. the reports and support bars, the add-members, channel-members and relays rows in group info, and on Android a `.md` file tapped twice while it loads. Two clicks within the load time can still push two pages. This is a different mechanism from the slide-in. It reproduces on master (reports bar, "Invite", "Invite members", "Member reports", "Chats with members" rows) and is fixed in a follow-up PR (#7757). +- On desktop, two different `.md` files clicked before either has loaded: the one that finishes loading last is shown, as on master; Back now returns to the chat instead of the other file. +- `OpenWhenReceivedEffect`: a file opened when its download finishes is not a repeated click. An image opens a second fullscreen viewer over an open one, as before. On desktop a `.md` file opened this way now replaces an open `.md` viewer, so Back returns to the chat instead of the other file. +- Keyboard and accessibility activation are not pointer input and are not blocked during a slide. +- The `BADGES` check (`hasModalOpen`) also counts a page that is sliding out, so on desktop a click within 250 ms of closing that page, if it slid out, does nothing; a click after the slide-out works. +- A different page opened from the same view stacks over the first one, for example bio "Read more" over contact info, and Back returns to the first page. That is normal navigation. Of the buttons this change touches, only #6270's two rows, "Incognito" and "Learn more", replace each other. + +## Effect + +- Desktop: each fixed button opens its page once. A repeated click keeps the open page or replaces it with what was clicked. The on-top check doesn't depend on which button opened the page, so a bio, `.md` viewer or Info opened from one place is also replaced by one opened from another (for example a member's bio by a business chat banner's "Read more"), and Back then returns to the page under it. Message Info that replaces Info with nothing else on the right also starts fresh now: master kept the previous page's selected tab and, when both messages had the same title, its app bar showed none. +- Both platforms: while a modal slides open, the page it covers in its pane, or the view under a pane's first modal, takes no new pointer input (clicks, drags, wheel scroll, hover) for 250 ms. On desktop the first page of the center and right panes appears without a slide, so it has no such window. +- Both platforms: a keyed page (bio, `.md` viewer, Info, #6270's pages) opened again while the previous one is still sliding out after Back now starts fresh; master reused the closing page's remembered state (for example Info's selected tab), and its app bar showed no title when the title was unchanged. +- Android: the slide window also covers modals the app opens itself (What's New at start), and a double-tapped toggle (chat info, the profile picker's incognito info, and the reports and support-chats bars when the second tap comes after their page is pushed) now stays open; a second tap during the reports or support-chat load still opens a second page (see Not fixed). Apart from these and the fresh state above, the per-site checks act only on desktop, where the button stays visible. + +## Verification + +Desktop: the same steps on the master prebuild and this branch, before and after, plus the areas the change touches (info and support chats in the right pane, profiles, badges, markdown viewer, Settings navigation and Back). Android (Waydroid): double tap on list rows, Back during a transition, and the touched flows. From cc3f49f79450fe65409eaea9994db609b82a507f Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:36:34 +0000 Subject: [PATCH 02/13] android, desktop: fix chat preview and unread counter updating wrong chat (#7072) * android, desktop: fix chat preview and unread counter updating wrong chat While "Chat with admins", a member support chat or the reports view was open, ChatsContext.addChatItem in the secondary context looked up the chat's index in its own list but read and wrote the main chat list at that index (chatsContext.chats[i]), so an unrelated chat in the main list got the message as its preview and its unread count went up. Use the context's own list at all four sites again. The main-list qualifier had also made a pending invitee's sent support messages reach the group's preview in the main list (at a secondary-list index), so send that update to the main list explicitly: addSentChatItem passes a sent item to the main chat list context as well when it is sent from a secondary view and belongs in the main list (inMainChatList, the same rule addChatItem uses for previews). Count unread stats only in the main chat list context, so the revert does not make the open secondary view count main-list messages in its own list. * plans: wrong-chat preview and unread fix --- .../chat/simplex/common/model/ChatModel.kt | 22 ++- .../simplex/common/views/chat/ComposeView.kt | 4 +- .../common/views/chat/item/FramedItemView.kt | 2 +- ...-06-13-chat-list-wrong-chat-preview-fix.md | 157 ++++++++++++++++++ 4 files changed, 176 insertions(+), 9 deletions(-) create mode 100644 plans/2026-06-13-chat-list-wrong-chat-preview-fix.md diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 09c494dc76..094615eee1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -414,6 +414,13 @@ object ChatModel { } } + suspend fun addSentChatItem(activeCtx: ChatsContext, rhId: Long?, cInfo: ChatInfo, cItem: ChatItem) { + activeCtx.addChatItem(rhId, cInfo, cItem) + if (activeCtx.secondaryContextFilter != null && cInfo.inMainChatList) { + chatsContext.addChatItem(rhId, cInfo, cItem) + } + } + // Spec: spec/state.md#ChatsContext class ChatsContext(val secondaryContextFilter: SecondaryContextFilter?) { val chats = mutableStateOf(SnapshotStateList()) @@ -584,9 +591,9 @@ object ChatModel { val i = getChatIndex(rhId, cInfo.id) val chat: Chat if (i >= 0) { - chat = chatsContext.chats[i] + chat = chats[i] // update preview (for chat from main scope to show new items for invitee in pending status) - if (cInfo.groupChatScope() == null || cInfo.groupInfo_?.membership?.memberPending == true) { + if (cInfo.inMainChatList) { val newPreviewItem = when (cInfo) { is ChatInfo.Group -> { val currentPreviewItem = chat.chatItems.firstOrNull() @@ -604,20 +611,20 @@ object ChatModel { else -> cItem } val wasUnread = chat.unreadTag - chatsContext.chats[i] = chat.copy( + chats[i] = chat.copy( chatItems = arrayListOf(newPreviewItem), chatStats = - if (cItem.meta.itemStatus is CIStatus.RcvNew) { + if (cItem.meta.itemStatus is CIStatus.RcvNew && secondaryContextFilter == null) { increaseUnreadCounter(rhId, currentUser.value!!) chat.chatStats.copy(unreadCount = chat.chatStats.unreadCount + 1, unreadMentions = if (cItem.meta.userMention) chat.chatStats.unreadMentions + 1 else chat.chatStats.unreadMentions) } else chat.chatStats ) - updateChatTagReadInPrimaryContext(chatsContext.chats[i], wasUnread) + updateChatTagReadInPrimaryContext(chats[i], wasUnread) } // pop chat if (appPlatform.isDesktop && cItem.chatDir.sent) { - reorderChat(chatsContext.chats[i], 0) + reorderChat(chats[i], 0) } else { popChatCollector.throttlePopChat(chat.remoteHostId, chat.id, currentPosition = i) } @@ -1872,6 +1879,9 @@ sealed class ChatInfo: SomeChat, NamedChat { val isChannel: Boolean get() = groupInfo_?.useRelays == true + + val inMainChatList: Boolean + get() = groupChatScope() == null || groupInfo_?.membership?.memberPending == true } @Serializable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt index 2fb82679e7..736bb919e5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt @@ -599,7 +599,7 @@ fun ComposeView( if (!chatItems.isNullOrEmpty()) { chatItems.forEach { aChatItem -> withContext(Dispatchers.Main) { - chatsCtx.addChatItem(chat.remoteHostId, aChatItem.chatInfo, aChatItem.chatItem) + chatModel.addSentChatItem(chatsCtx, chat.remoteHostId, aChatItem.chatInfo, aChatItem.chatItem) } } return chatItems.first().chatItem @@ -741,7 +741,7 @@ fun ComposeView( withContext(Dispatchers.Main) { chatItems?.forEach { chatItem -> - chatsCtx.addChatItem(rhId, chat.chatInfo, chatItem) + chatModel.addSentChatItem(chatsCtx, rhId, chat.chatInfo, chatItem) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt index ca370c1b52..d1b56add6b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt @@ -494,7 +494,7 @@ fun sendCommandMsg(chatsCtx: ChatModel.ChatsContext, chat: Chat, msg: String) { if (!chatItems.isNullOrEmpty()) { chatItems.forEach { aChatItem -> withContext(Dispatchers.Main) { - chatsCtx.addChatItem(chat.remoteHostId, aChatItem.chatInfo, aChatItem.chatItem) + chatModel.addSentChatItem(chatsCtx, chat.remoteHostId, aChatItem.chatInfo, aChatItem.chatItem) } } } diff --git a/plans/2026-06-13-chat-list-wrong-chat-preview-fix.md b/plans/2026-06-13-chat-list-wrong-chat-preview-fix.md new file mode 100644 index 0000000000..171612edc2 --- /dev/null +++ b/plans/2026-06-13-chat-list-wrong-chat-preview-fix.md @@ -0,0 +1,157 @@ +# Chat list: message preview and unread counter applied to the wrong chat + +**PR:** #7072 · **Files:** `ChatModel.kt` (`ChatsContext.addChatItem`, `ChatModel.addSentChatItem`, `ChatInfo.inMainChatList`), `ComposeView.kt`, `FramedItemView.kt` + +## Symptom + +While a secondary chat view is open — "Chat with admins", "chat with member", or a +group's reports view — an incoming message could land on an unrelated chat in the +main list: that chat took the message as its last-message preview and its unread +badge incremented. The wrong preview stays until that chat's next message or until the chat list is reloaded; the wrong +unread count until the chat is opened (opening reloads its stats). + +## Root cause + +`ChatsContext` is a nested class with two live instances: the primary chat list +(`ChatModel.chatsContext`, shown in the main list) and a secondary context created +per reports / member-support view (set up in `ChatListNavLinkView.kt`, `MemberSupportChatView.kt`, +`GroupReportsView.kt`). `SimpleXAPI` dispatches every incoming item to **both**. + +Inside `addChatItem`: + +```kotlin +val i = getChatIndex(rhId, cInfo.id) // searches THIS context's own list +... +chat = chatsContext.chats[i] // but reads the PRIMARY singleton's list +chatsContext.chats[i] = chat.copy(...) // ...and writes it +``` + +`getChatIndex` is a member of `ChatsContext`, so `chats` resolves to the receiver +context. `chatsContext` (unqualified) is a member of the enclosing `object ChatModel` +and always resolves to the **primary** instance. On the secondary context the two +lists hold different chats in a different order, so `i` — a secondary-list index — +points at an unrelated chat in the primary list, which then gets clobbered. + +### Why the indices diverge + +The secondary context's chat list starts empty (only the primary is seeded on load) and +grows by `addChat` at index 0 as chats receive messages. The two lists fall out of step +when a change reaches only one of them, for example: +- a message sent in "Chat with admins" creates the group's entry only in the secondary + list; +- a chat added to or removed from the primary list only (a group invitation, a contact + request); +- a burst of messages within 2 s: the primary list's pop is throttled, while the + secondary `addChat` inserts at index 0 at once. + +Until #7404 is merged, the secondary pop collector rebuilds the secondary list from the primary +one, which re-aligns them now and then, so the bug is intermittent. + +### How it was introduced + +Commit `b97e1e0f1` (#5909, "show new messages from support scope in main chat preview +when invitee is pending") changed `chats[i]` → `chatsContext.chats[i]` at four sites +while broadening the preview guard with `|| memberPending`. The guard change was the +feature for received items; the `chatsContext.` qualifier also made the invitee's sent +support messages reach the main list, but at an index from the secondary list, which +on the secondary context updates the wrong chat. + +## The fix + +Use the receiver context's own list at all four sites, keeping the broadened guard: + +```diff +- chat = chatsContext.chats[i] ++ chat = chats[i] +- chatsContext.chats[i] = chat.copy( ++ chats[i] = chat.copy( +- updateChatTagReadInPrimaryContext(chatsContext.chats[i], wasUnread) ++ updateChatTagReadInPrimaryContext(chats[i], wasUnread) +- reorderChat(chatsContext.chats[i], 0) ++ reorderChat(chats[i], 0) +``` + +This is a byte-for-byte revert of the qualifier from `b97e1e0f1`, with the feature's +guard preserved. `chats[i]` get/set is the idiom used elsewhere in the class. + +## The revert alone would break #5909 + +A pending invitee's own **sent** support message reaches only the **active** context — +`ComposeView` send/forward and `FramedItemView` command-send all call +`chatsCtx.addChatItem`, and in a member-support chat `chatsCtx` is the *secondary* +context. Before this change that call wrote `chatsContext.chats[i]`, i.e. the primary +list, so the group's main-list preview did update — but only as a side effect of the +bug, at whatever index happened to line up. + +Reverting the four sites therefore removes it: the secondary updates its own list and +the primary is never touched, so the invitee's sent support messages stop appearing as +the group's preview. Confirmed by testing during review. + +So the revert is paired with an explicit dispatch, which does the same thing on purpose +and at the right index: + +```kotlin +suspend fun addSentChatItem(activeCtx: ChatsContext, rhId: Long?, cInfo: ChatInfo, cItem: ChatItem) { + activeCtx.addChatItem(rhId, cInfo, cItem) + if (activeCtx.secondaryContextFilter != null && cInfo.inMainChatList) { + chatsContext.addChatItem(rhId, cInfo, cItem) + } +} +``` + +`ChatInfo.inMainChatList` (`groupChatScope() == null || membership.memberPending`) is +the rule `addChatItem` applies to decide whether an item updates the preview of a chat +already in the list; both places use the property. + +Cross-context dispatch stays with the **caller**, matching how `processReceivedMsg` +hands received items to both contexts and how `ChatView` handles deletes. No +`ChatsContext` method writes the other context — the invariant this fix restores +(the pop collector still reads the primary list until #7404). + +Passing the scoped `cInfo` to the primary is safe: when the group is in the main list +`updateChatInfo` strips the scope before storing it (when it isn't, `addChat` stores it +as received items already do), and `chatItemBelongsToScope` returns false in the primary context +for a scoped `cInfo`, so the item never enters the primary item list. + +## Why it is safe + +- The four-site revert changes nothing for the **primary** context: + `chats === chatsContext.chats` there, so both forms resolve to the same list. The + secondary context stops touching the wrong one. The primary context additionally + receives items sent from a secondary view that belong in the main list (the pending + invitee's support messages; also a bot command tapped in the reports view, which the + main chat now shows). +- `updateChatTagReadInPrimaryContext` and `increaseUnreadCounter` (through + `changeUnreadCounterInPrimaryContext`) already return early in a secondary context, + so they stay primary-only either way. +- `popChatCollector` is per-context (`PopChatCollector(this)`), so the pop/reorder + calls write the context they run in (until #7404 the collector reads the primary + list; see "A related defect" below). +- The unread stats increment is limited to the primary context + (`secondaryContextFilter == null`). The secondary context's own entry for the open + group is what `ChatView` reads for the unread button inside the open view; without + the limit it would count main-scope group messages that the view doesn't show. The + limit keeps increments out of that entry, as #7559 does for chats added to the list. +- iOS is unaffected — `ItemsModel` holds only chat items and the chat list is a single + `chats` array on `ChatModel`, so the mis-indexing cannot occur there. + +## Alternatives considered + +- **Guard the whole block to `secondaryContextFilter == null`.** Looked smaller, but + the secondary list would then stop getting the preview and order updates that + received items give it, and the block would branch on the context type. +- **Compute `i` against the primary list and keep the primary writes.** Would make the + secondary call double-update the primary preview and unread, and never update the + secondary list at all. + +## A related defect, fixed separately + +`PopChatCollector` is nested but not `inner`, so the unqualified `getChat` / `chats` +inside `popCollectedChats()` also resolve to `ChatModel` — the primary list — while +the result is written to `chatsCtx.chats`. Same class of defect, different symptom +(it corrupts the secondary context's own list, not the main one). Fixed in #7404. + +## Verification + +Desktop, master vs this PR, with "Chat with admins" open in a group the user does not +own: see the PR description for the reproduction and the before/after screenshots. From cfcce1b43c3fc298f5ff659ec2795b5c2e203e5e Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:48:32 +0000 Subject: [PATCH 03/13] ui: create one group on a double click of Create group (#7759) * desktop: open a pending connection's "Learn more" and "Incognito" pages once On desktop the connection view stays clickable in the center pane, so each click on these rows pushed another modal onto the right pane. Close the right-pane modals first, as ChatView does when the connection opens. The page is closed and shown again in one event, so its content is keyed by its ModalData to start fresh. Desktop only: on Android the view can itself be the open modal of the shared manager ("Set contact name"), and the new page already covers the row. Fixes #6270 * ui: open a modal once when its button is double-clicked A modal slides in over 250 ms, and until it covers the button that opened it the button still takes clicks, so a double click opened two copies (left-pane pages and most stacked center and right-pane pages on desktop, animated modals on Android). When a modal opens, the page it covers draws an empty pointerInput box over itself while it slides out; when a pane's first modal slides in, the empty page sliding out covers the view under the pane. The page sliding in already takes clicks, and nothing is blocked when a page closes. * ui: add isTopModalOpen, which ignores modals that are sliding out A modal closed with animation stays in modalViews until it has slid out, and isLastModalOpen counts it. A check-then-closeModal() on it would close the modal under it. Uses the same index as isLastModal; isLastModalOpen and its callers are unchanged. * desktop: don't open a second badges page or profile form These buttons stay clickable on desktop while their page is shown in another pane, so each click opened another copy: - "Support SimpleX" in the chat's large-file and badge alerts (Badges on the right): the alerts skip while Badges is open, on desktop only, as on Android the page covers the button; - "+ Add profile" in Your chat profiles and "Create chat profile" in the picker (form in the center): skip while the form is on top, keeping what was typed, before asking for authentication; the picker row still hides the picker. * desktop: replace a page of the same kind on top instead of stacking another These buttons stay clickable on desktop while their page is shown in another pane, so each click pushed another copy: - the chat banner's bio "Read more" (right pane); - a markdown file clicked in a support chat (viewer in the center); - message "Info" in the main chat while a support or reports chat is open on the right. Their content depends on what was clicked and the pane can hold other pages (a support chat), so a page of the same kind on top is replaced. The new page is drawn where the old one was, so its content is keyed by its ModalData to start fresh. The markdown file and Info load on a background thread; their close and show now run on the main thread. The markdown check is desktop only, as on Android the viewer covers its file once it has loaded. * plans: a page opens again on every click of its button (#6270) * ui: create one group on a double click of Create group "Create group" creates the group, opens it and only then closes its page, and the core does not deduplicate, so a double click created two groups. The page now ignores a click while its create is in progress, on desktop and Android. * plans: a double click on "create group" creates two groups * plans: D2 is valid at 80 ms only * ui: ignore a click on Create group until its page has closed after a create --- .../common/views/newchat/AddGroupView.kt | 5 +++++ plans/2026-10-18-create-group-twice.md | 20 +++++++++++++++++++ 2 files changed, 25 insertions(+) create mode 100644 plans/2026-10-18-create-group-twice.md diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt index e2c89940ab..f3c43b8a5c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt @@ -39,8 +39,11 @@ import java.net.URI fun AddGroupView(chatModel: ChatModel, rh: RemoteHostInfo?, close: () -> Unit, closeAll: () -> Unit) { val rhId = rh?.remoteHostId val view = LocalMultiplatformView() + val inProgress = remember { mutableStateOf(false) } AddGroupLayout( createGroup = { incognito, groupProfile -> + if (inProgress.value) return@AddGroupLayout + inProgress.value = true hideKeyboard(view) withBGApi { val groupInfo = chatModel.controller.apiNewGroup(rhId, incognito, groupProfile) @@ -61,6 +64,8 @@ fun AddGroupView(chatModel: ChatModel, rh: RemoteHostInfo?, close: () -> Unit, c GroupLinkView(chatModel, rhId, groupInfo, groupLink = null, onGroupLinkUpdated = null, creatingGroup = true, close = close) } } + } else { + inProgress.value = false } } }, diff --git a/plans/2026-10-18-create-group-twice.md b/plans/2026-10-18-create-group-twice.md new file mode 100644 index 0000000000..ed237f564a --- /dev/null +++ b/plans/2026-10-18-create-group-twice.md @@ -0,0 +1,20 @@ +# A double click on "Create group" creates two groups + +Follow-up to #7725. + +## Problem + +"Create group" creates the group (`apiNewGroup`), opens it, loads its members and only then closes its page and opens Invite members (or the group link when incognito). The button stays enabled meanwhile and the core does not deduplicate, so a double click created two groups (the second named with a suffix, for example "G2_1"). Reproduced on the master prebuild at 40, 80 and 150 ms. + +## Fix + +The page remembers whether its create is in progress, as `CIGroupInvitationView` does for joining: the create handler checks and sets it at the start, so a second click on that page while it is set is ignored. It is cleared only when the create fails. A successful create closes the page, but the page leaves the screen only after its job has ended (in testing the job ended about 0.7 s after the click and the page was gone 0.8 to 1.1 s after it), so clearing it at the end of the job let a second click in that gap create a second group. + +## Not fixed + +- The channel's "Continue" (desktop `onLinkReady`) was checked and not affected: a double click opened one link page in testing. +- A create that never returns, or that throws instead of returning an error, keeps that page's "Create group" inert; a page opened again creates as on master. + +## Effect + +Desktop and Android: one group per double click. After a failed create the button works again. Single clicks are unchanged. From d63906ad55a11d8101067b3c6359b03e76808b93 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Thu, 22 Oct 2026 21:33:25 +0100 Subject: [PATCH 04/13] mcp server: fixes (#7793) Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- docs/protocol/agent-protocols.md | 169 ++++++++++++++++-- packages/simplex-chat-mcp/PLAN.md | 60 ++++--- packages/simplex-chat-mcp/REFERENCE.md | 4 +- packages/simplex-chat-mcp/TESTING.md | 2 +- packages/simplex-chat-mcp/package-lock.json | 4 +- packages/simplex-chat-mcp/package.json | 2 +- packages/simplex-chat-mcp/spec/api.md | 24 ++- .../simplex-chat-mcp/spec/architecture.md | 60 ++++--- packages/simplex-chat-mcp/spec/flows.md | 8 +- packages/simplex-chat-mcp/src/actions.ts | 23 ++- packages/simplex-chat-mcp/src/cli.ts | 2 +- packages/simplex-chat-mcp/src/control.ts | 44 +++-- packages/simplex-chat-mcp/src/files.ts | 19 +- packages/simplex-chat-mcp/src/http.ts | 8 +- packages/simplex-chat-mcp/src/messages.ts | 16 +- packages/simplex-chat-mcp/src/register.ts | 14 ++ packages/simplex-chat-mcp/src/serve.ts | 17 +- packages/simplex-chat-mcp/src/tools.ts | 53 ++---- .../test/e2e/scenarios.test.ts | 15 ++ .../test/unit/actions.test.ts | 45 +++++ .../simplex-chat-mcp/test/unit/cards.test.ts | 18 ++ .../test/unit/commands.test.ts | 69 ++++++- .../simplex-chat-mcp/test/unit/files.test.ts | 14 +- .../simplex-chat-mcp/test/unit/fixtures.ts | 12 +- .../simplex-chat-mcp/test/unit/http.test.ts | 15 +- .../test/unit/messages.test.ts | 98 ++++++++-- .../test/unit/options.test.ts | 6 + .../test/unit/register.test.ts | 52 +++++- 28 files changed, 699 insertions(+), 174 deletions(-) diff --git a/docs/protocol/agent-protocols.md b/docs/protocol/agent-protocols.md index d4e828d677..b6048c3896 100644 --- a/docs/protocol/agent-protocols.md +++ b/docs/protocol/agent-protocols.md @@ -1,4 +1,4 @@ -Revision 1, 2026-10-19, draft for comments +Revision 2, 2026-10-21, draft for comments Evgeny Poberezkin @@ -28,6 +28,16 @@ Evgeny Poberezkin - [Messages](#messages) - [Versions and capabilities](#versions-and-capabilities) - [Examples](#examples) +- [A2A binding](#a2a-binding) + - [Identification](#identification) + - [A2A messages](#a2a-messages) + - [Service parameters](#service-parameters) + - [Streaming](#streaming) + - [Push notifications](#push-notifications) + - [Callers and authorization](#callers-and-authorization) + - [Service RPC](#service-rpc) + - [Agent Card](#agent-card) + - [A2A examples](#a2a-examples) - [Security considerations](#security-considerations) - [Privacy considerations](#privacy-considerations) - [Implementations](#implementations) @@ -41,7 +51,7 @@ This document defines how software agents find SimpleX addresses and call the to - declarations: the protocols a profile serves and their transports, the peer types a channel or a contact address is made for, and the update frequency of a channel; - the chat transport: the command `/_call` in a conversation; - the Service RPC transport: one request and one response, without a conversation, in a generic envelope; -- the binding of the Model Context Protocol (MCP) [MCP] to both transports. +- the bindings of the Model Context Protocol (MCP) [MCP] and of the Agent2Agent protocol (A2A) [A2A] to both transports. ## Conventions @@ -113,7 +123,7 @@ For SimpleX entries, the type `updates` requires a channel link, the type `group - [Acme news](https://smp12.simplex.im/c#…): SimpleX updates for people and agents, weekly. Product news and offers. - [Acme customers](https://smp12.simplex.im/g#…): SimpleX group for people. Questions and tips from customers. -- [Ask Acme](https://smp12.simplex.im/a#…): SimpleX business contact for agents, MCP. Orders and availability. +- [Ask Acme](https://smp12.simplex.im/a#…): SimpleX business contact for agents, MCP, A2A. Orders and availability. - [Acme support](mailto:support@acme.example): Email contact for people. ``` @@ -165,6 +175,10 @@ Example, the `preferences` of a contact profile: "mcp": { "versions": ["2026-07-28"], "transports": {"chat": true, "rpc": true} + }, + "a2a": { + "versions": ["1.0"], + "transports": {"chat": true, "rpc": true} } } } @@ -211,6 +225,7 @@ A binding defines the protocol's request and response messages, including how, a | Name | Binding | |---|---| | `mcp` | [MCP binding](#mcp-binding) | +| `a2a` | [A2A binding](#a2a-binding) | ## Chat transport @@ -230,10 +245,12 @@ A binding defines the protocol's request and response messages, including how, a ### Replies -- A bot sends one reply to each command it answers. The reply quotes the command, with a truncated quote. Its text is the protocol's response message, a JSON object. +- A bot answers a command with one or more replies. Each reply quotes the command, with a truncated quote. Its text is a message of the protocol, a JSON object. +- The binding defines the messages a bot sends before its last reply, and the message that ends its answer. - A caller matches each reply to its command by the quoted message. Replies can arrive in any order. -- In a group, a broadcast can receive several replies, one from each member that serves the protocol. A caller collects them all, each with its sender. -- In a direct chat, a caller accepts replies only from the recipient. In a group, a caller of a command with a mention accepts only the mentioned member's reply. +- A caller collects every reply until its wait ends, each with its sender, in arrival order. In a group, a broadcast receives the answers of every member that serves the protocol. +- A reply whose text is not a message of the protocol is collected as text, with its sender. +- In a direct chat, a caller accepts replies only from the recipient. In a group, a caller of a command with a mention accepts only the mentioned member's replies. - Commands and replies are ordinary messages: people see them in the conversation. ### Message size @@ -316,7 +333,7 @@ JSON Type Definition schema [RFC8927] of the response: ``` - A response has exactly one of `response` and `error`. -- `response`: the protocol's response message, the same as the reply in a conversation. +- `response`: the protocol's response message, the same as the last reply of the answer in a conversation. - `error`: the request was not passed to the protocol. - `message`: a description for the requester's logs. - `retryAfter`: the number of seconds after which a repeated request can succeed. @@ -347,9 +364,10 @@ Protocol name: `mcp`. ### Messages - The request is one JSON-RPC request of MCP revision 2026-07-28 or later, with its `_meta` envelope [MCP]. In a conversation, it follows `/_call mcp`. Over Service RPC, it is the envelope's `request`. -- The response is the JSON-RPC response to the request: a result or a JSON-RPC error. In a conversation, it is the text of the reply. Over Service RPC, it is the envelope's `response`. +- The bot's answer ends with the JSON-RPC response to the request: a result or a JSON-RPC error. In a conversation, it is the text of the last reply. Over Service RPC, it is the envelope's `response`. +- In a conversation, the bot MAY send notifications for the request before the response, each as a reply: for example `notifications/progress` when the request has a progress token [MCP]. - In a conversation, a caller matches a reply to its request by the quoted message. The response has the request's `id`, as JSON-RPC requires. -- A bot serves the methods answered with one response, for example `server/discover`, `tools/list` and `tools/call`. +- A bot serves the request-response methods, for example `server/discover`, `tools/list` and `tools/call`. - A multi-round-trip result (`resultType: "input_required"`) is answered with a new request, as MCP defines. - A response above the size limit is replaced with a JSON-RPC error with code -32603 in a conversation, and with the envelope error `too_large` over Service RPC. - Over Service RPC, a call of a tool that needs a conversation is answered with the envelope error `conversation_required`. @@ -431,19 +449,142 @@ The response: } ``` +## A2A binding + +Protocol name: `a2a`. This binding is a custom protocol binding of A2A 1.0 [A2A, sections 5.8 and 12]: it carries the messages of the A2A JSON-RPC binding [A2A, section 9] over both SimpleX transports. + +### Identification + +- Binding URI: `https://simplex.chat/protocol/a2a/v1`. A revision of this binding that breaks compatibility uses a new URI. +- An A2A Agent Card lists a SimpleX interface in `supportedInterfaces`, with `url` the bot's contact address link, `protocolBinding` the binding URI, and `protocolVersion` the A2A version. +- `versions` in the `protocols` declaration: A2A versions in the `Major.Minor` form, for example `1.0`. + +### A2A messages + +- The request is one JSON-RPC request of the A2A JSON-RPC binding, with its method name and parameters unchanged: `SendMessage`, `SendStreamingMessage`, `GetTask`, `ListTasks`, `CancelTask`, `SubscribeToTask`, the push notification methods and `GetExtendedAgentCard` [A2A, section 9.4]. In a conversation, it follows `/_call a2a`. Over Service RPC, it is the envelope's `request`. +- Responses and errors are those of the A2A JSON-RPC binding, with the JSON forms of its data types: timestamps, bytes in base64 and enumerations [A2A, section 12.2]. +- For every method other than the two streaming methods, the bot's answer is one JSON-RPC response. In a conversation, it is one reply. Over Service RPC, it is the envelope's `response`. +- A response above the size limit is replaced with a JSON-RPC error with code -32603 in a conversation, and with the envelope error `too_large` over Service RPC. The bytes of `raw` parts count towards the limit. + +### Service parameters + +A2A sends service parameters, such as `A2A-Version` and `A2A-Extensions`, as HTTP headers [A2A, section 9.2]. SimpleX messages have no headers, so this binding uses the field that A2A names for protocols without headers [A2A, section 12.3]: + +- `a2a-service-parameters`: a member of the JSON-RPC request object, beside `jsonrpc`, `method`, `params` and `id`. Its value is an object of service parameter names and string values. +- Names are case-insensitive. Several values of one parameter are joined by commas in one string, as in an HTTP header. +- Every request MUST carry `A2A-Version`. A bot answers a request of a version it does not serve with A2A's `VersionNotSupportedError`. +- A proxy to an HTTP interface sends each service parameter as an HTTP header and removes the member from the request. +- The envelope's `version` is omitted. + +### Streaming + +`SendStreamingMessage` and `SubscribeToTask` are served in a conversation [A2A, section 12.5]: + +- Each `StreamResponse` is one reply that quotes the command. Its text is a JSON-RPC response with the request's `id`, whose `result` is the `StreamResponse`, as in the events of the A2A JSON-RPC binding. +- Replies are sent in the order of the stream. +- The stream ends with the reply whose task or status update is in a terminal state (completed, failed, canceled, rejected) or an interrupted state (input required, auth required). +- A caller that missed replies resumes the stream with `SubscribeToTask`. +- Over Service RPC, both methods are answered with the envelope error `conversation_required`. + +### Push notifications + +A conversation delivers every update of a task to the caller, so push notification configurations are not used. A bot answers the push notification methods with A2A's `PushNotificationNotSupportedError`. + +### Callers and authorization + +- In a conversation, the caller is the contact or the group member that sent the command [A2A, section 12.6]. +- A context and its tasks are available only to their caller: in the conversation where they were created, and in a group to the member who created them. A request that names the context or the task of another caller is answered as a request for an unknown task. +- An agent whose Agent Card declares security schemes receives the credential as the service parameter of the scheme, for example `Authorization` for an HTTP scheme. A challenge is answered with the task state `auth required`. +- A request with a credential MUST NOT be sent in a group. + +### Service RPC + +Service RPC serves one-shot tasks: a request and its answer, without a conversation. + +- A bot answers `SendMessage` whose message has neither `contextId` nor `taskId`, and whose `configuration.returnImmediately` is absent or false. +- The response is the task in its state at the end of the work, or a message. +- A bot that cannot finish the task within 180 seconds answers with the envelope error `conversation_required`. +- Every other request is answered with the envelope error `conversation_required`. + +### Agent Card + +- The Agent Card of an agent with a website is published at the well-known URI of A2A [A2A, section 8.2], with the SimpleX interface among its interfaces. +- A bot answers `GetExtendedAgentCard` with its Agent Card. For a caller without credentials, it is the public card. + +### A2A examples + +A one-shot task over Service RPC, the request: + +```json +{ + "protocol": "a2a", + "request": { + "jsonrpc": "2.0", + "id": 1, + "method": "SendMessage", + "a2a-service-parameters": {"A2A-Version": "1.0"}, + "params": { + "message": { + "messageId": "m-1", + "role": "ROLE_USER", + "parts": [{"text": "Price of two anvils delivered to Lisbon"}] + } + } + } +} +``` + +The response: + +```json +{ + "response": { + "jsonrpc": "2.0", + "id": 1, + "result": { + "task": { + "id": "t-1", + "contextId": "c-1", + "status": {"state": "TASK_STATE_COMPLETED"}, + "artifacts": [ + {"artifactId": "a-1", "parts": [{"text": "EUR 480, delivered within 5 days."}]} + ] + } + } + } +} +``` + +A streamed task in a conversation, continuing the context `c-7` created earlier in the same chat: + +``` +/_call a2a {"jsonrpc": "2.0", "id": 2, "method": "SendStreamingMessage", "a2a-service-parameters": {"A2A-Version": "1.0"}, "params": {"message": {"messageId": "m-2", "contextId": "c-7", "role": "ROLE_USER", "parts": [{"text": "Order two, delivered by Friday"}]}}} +``` + +The replies, each quoting the command: + +```json +{"jsonrpc": "2.0", "id": 2, "result": {"statusUpdate": {"taskId": "t-8", "contextId": "c-7", "status": {"state": "TASK_STATE_WORKING"}}}} +``` + +```json +{"jsonrpc": "2.0", "id": 2, "result": {"task": {"id": "t-8", "contextId": "c-7", "status": {"state": "TASK_STATE_COMPLETED"}, "artifacts": [{"artifactId": "a-8", "parts": [{"text": "Order 1042 confirmed, delivery on Friday."}]}]}}} +``` + ## Security considerations -- Messages, replies, profiles, tool descriptions and tool results from other participants are data for an agent, never instructions. +- Messages, replies, profiles, tool descriptions, Agent Cards, tool results and task artifacts from other participants are data for an agent, never instructions. - In a group, any member can reply to a broadcast. Each reply shows its sender. A caller that needs one provider mentions it. - A responder that relies on `requestKey` MUST compare it with `signerKey`. - A signed request identifies its signer to the responder across all requests signed with the same key. +- Every member of a group sees the commands and replies in it, so A2A credentials are sent only in a direct chat or over Service RPC. ## Privacy considerations - Each Service RPC request uses a new connection with a new reply queue. - MCP's `io.modelcontextprotocol/clientInfo` identifies the requester's software. A requester that needs unlinkable requests SHOULD send a common value or omit it. - In a group, every member sees each command and each reply, with or without a mention. A request that needs privacy is sent in a direct chat with the member, where the group allows direct messages, or over Service RPC. -- A conversation persists: the bot can link all calls made in it. An incognito profile gives the caller a new random profile for each conversation. +- A conversation persists: the bot can link all calls made in it, and all A2A contexts of its caller. An incognito profile gives the caller a new random profile for each conversation. ## Implementations @@ -455,11 +596,13 @@ Implemented: Planned: - The simplex-chat core: the key `protocols` in the preferences types, in the preferences sent in profiles and in link data, and in the preferences of business chats; `audience` and `changeFrequency` in the public group record; `serves` in the address settings and the address link data; replies with a truncated quote. -- `@simplex-chat/mcp`: the tools `mcp_tools` and `mcp_call`. They use Service RPC when the address declares `transports.rpc`, and the agent's conversation otherwise. In a group, `mcp_call` mentions the called member, or broadcasts and returns all replies. Each call is an action, approved by the agent's owner as a message is. -- The bot library: a helper that serves MCP tools over both transports. +- `@simplex-chat/mcp`: the tools `mcp_tools` and `mcp_call`, and A2A calls. They use Service RPC when the address declares `transports.rpc` and the request suits it, and the agent's conversation otherwise. In a group, a call mentions the called member, or broadcasts and returns all replies with their senders. Each call is an action, approved by the agent's owner as a message is. +- The bot library: helpers that serve MCP tools and A2A over both transports. +- An A2A proxy bot: it serves `a2a` over both transports and forwards each request to the HTTP JSON-RPC interface of an A2A agent, with the service parameters as HTTP headers. ## References +- [A2A] Agent2Agent (A2A) protocol specification, version 1.0: https://a2a-protocol.org/latest/specification/ - [BUSINESS] Using SimpleX Chat in business: [BUSINESS.md](../BUSINESS.md) - [CHAT] SimpleX Chat protocol: [simplex-chat.md](./simplex-chat.md) - [LLMSTXT] The /llms.txt file: https://llmstxt.org diff --git a/packages/simplex-chat-mcp/PLAN.md b/packages/simplex-chat-mcp/PLAN.md index 841b76f2b3..22bcf02ddd 100644 --- a/packages/simplex-chat-mcp/PLAN.md +++ b/packages/simplex-chat-mcp/PLAN.md @@ -104,6 +104,7 @@ layer who what listener http.ts 127.0.0.1:PORT; Node limits lifted for hours-long calls: requestTimeout 0, timeout 0, headersTimeout 60 s, keepAliveTimeout 5 s bearer token http.ts, before the SDK Authorization: Bearer , compared in constant time; otherwise 401 path http.ts /mcp only; otherwise 404, a request target that is not a URL included +start http.ts until the owner setup at start ends: 503 with Retry-After: 1 Origin, Host SDK middleware localhostOriginValidation (no Origin, or a localhost origin on any port), localhostHostValidation; otherwise 403 protocol SDK both eras, header and version errors, 405, body limit, cache hints (ttlMs, cacheScope), JSON or SSE per call, SSE keep-alive cancellation SDK the client closing the response stream aborts ctx.mcpReq.signal (toNodeHandler ties the Request signal to res "close") @@ -214,7 +215,7 @@ NAME not ^[a-z0-9](-?[a-z0-9])*-?$, NAME: 1 to 32 lowercase letters, digits an $ npx -y @simplex-chat/mcp serve my-agent --display-name "My Agent" Downloading libsimplex from https://github.com/simplex-chat/simplex-chat-libs/releases/download/core-7.1.0.12/simplex-chat-libs-linux-x86_64.zip ... Downloading the Node.js add-on from https://github.com/simplex-chat/simplex-chat-libs/releases/download/... ... -SimpleX MCP server 0.1.0, agent "my-agent" +SimpleX MCP server 0.1.1, agent "my-agent" Data folder: /home/alice/.simplex-mcp/my-agent Approval mode: requests Agent control chat "My Agent" created. @@ -231,7 +232,7 @@ Listening on http://127.0.0.1:5226/mcp - large: two spaces with the module's background per module, one line per module: 74 columns × 37 lines. - The download lines are written to stderr by the `simplex-chat` package on first use (`libs.ts`, `installLibs`, `installAddon`). - `npx -y` answers npm's install prompt only; the server asks no questions. -- A second `serve` on the same data folder exits with code 1: `Another server uses /home/alice/.simplex-mcp/my-agent (PID 1234).` A port in use: exit code 1, `Port 5226 is in use. Start with --port N.` +- A second `serve` on the same data folder exits with code 1: `Another server uses /home/alice/.simplex-mcp/my-agent (PID 1234). If no server runs, remove /home/alice/.simplex-mcp/my-agent/lock.` A port in use: exit code 1, `Port 5226 is in use. Start with --port N.` ### Owner joins (no --owners) @@ -285,12 +286,13 @@ action created Action 1: connect to bot SimpleX Directory, as i action decided Action 2 approved by Alice: sent. | Action 1 approved by Alice: connected. | Action 5 approved by Alice: connection requested. | Action 2 approved by Alice: failed: . action decided Action 2 rejected by Alice. | Action 2 expired. mode host Action 2: performed without approval (mode host). | ...; failed: . +counter or card not written Action 2: failed: . owner added, --owners LIST Bob (member 7) is now an owner and can't approve actions yet. Restart the server with --owners 3:Alice,7:Bob owner gone, --owners LIST Alice (member 3) is no longer an owner. Restart the server with --owners 7:Bob (the human owners remaining) no human owner remains Alice (member 3) is no longer an owner. No owners remain: restart the server with --reset-owners --owners no approver, no --owners No owners in the agent control chat. (followed by a new link) control chat lost The agent lost its control chat. Restart the server with --reset-owners. -shutdown Cancelled 2 actions. Stopped. (the count only when actions were pending or performing) +shutdown Cancelled 2 actions. Stopped. (the count only when actions were pending or performing; a perform interrupted by the stop logs no line) ``` ## What the owner sees: the agent control chat @@ -319,7 +321,7 @@ contact | bot | agent | business | group | channel plan type; channel when gro SimpleX Directory the contact's or group's display name from the link (@directory.simplex) the target, when it is a SimpleX name as myself | incognito input.incognito -quoted lines short description and welcome message from the link +quoted lines short description and welcome message from the link, cut to fit the card (section Card size) "With the message:" and the message lines with message; after the quoted lines ``` @@ -417,6 +419,7 @@ N unknown, decided or from an earlier run Action 9 is not pending. CHAT not the action's chat Action 10 is not for #3. /approve CHAT all Approved for business Ridgeline (#9) by Alice. /'revoke #9' /approve CHAT all, CHAT not the agent's No chat #13. /list shows the agent's chats. +/approve CHAT all, mode host Approvals apply in mode requests; this server runs in mode host. /revoke CHAT Messages to business Ridgeline (#9) need approval again. /revoke CHAT, no standing approval Messages to #9 have no standing approval. /list, no chats No chats. @@ -424,7 +427,7 @@ other one-line text starting with "/" Commands: /approve, /reject, /revoke ``` - CHAT is optional in `/approve N` and `/reject N` as typed; the cards include it for sends and leaves. -- A standing approval covers sends only. It ends at `/revoke`, when the chat is deleted, or when member `approvedById` is no longer an approver. It applies in mode `requests` only: in mode `host`, `list` and `/list` show no approvals. +- A standing approval covers sends only. It ends at `/revoke`, when the chat is deleted, or when member `approvedById` is no longer an approver. It applies in mode `requests` only: in mode `host`, `list` and `/list` show no approvals, and `/approve CHAT all` stores none. ### Membership messages @@ -608,7 +611,9 @@ no approver, no --owners The agent has no owner. The person who runs i no approver, --owners The agent has no approving owner. The person who runs it has to restart it with the --owners options it printed. control chat lost The agent lost its control chat. The person who runs it has to restart it with --reset-owners. 20 actions pending 20 actions are pending; wait for the owner's decisions. +20 actions performing 20 actions are being performed; wait for their results. card over 15000 bytes (send) text: too long for its approval card; shorten it. +card over 15000 bytes (connect) message: too long for its approval card; shorten it. unknown chat No chat #13. list shows the agent's chats. not a link or name Not a SimpleX link or name: link not joinable can't be joined now: . @@ -616,7 +621,9 @@ message not for an address message: is not a contact or busines chat not connected (send) The agent is not connected to @2 and can't post; list shows connected: false. role below author The agent has role observer in #12 and can't post. reply_to not in chat reply_to: #15 has no item 999. +reply_to not a message reply_to: item 31 in #15 is not a message. mention not a member mention: #15 has no member 45. (in a contact chat: mention: @4 is a contact chat; mentions apply to groups and channels.) +mention of equal names mention: members 44 and 45 have the same name. SimpleX error worded by the apps the apps' text (table below), for example: Name not found: This SimpleX name is not registered. Please check the name. other SimpleX error SimpleX error: : for example: SimpleX error: error: groupUserRole: {"requiredRole":"author"} @@ -691,7 +698,6 @@ serve(NAME, options): create DIR, mode 0700 (set again when DIR exists, except on Windows); DIR/lock created exclusively, or exit 1 when its PID runs; token = DIR/token, created when missing or with --new-token; DIR/headers.json written with the token chat = ChatApi.init({type: "sqlite", filePrefix: DIR/simplex}) profile = the user, or created: /_create user {profile: {displayName: <--display-name or NAME>, fullName: "", peerType: agent}, pastTimestamp: false} - write DIR/server.json {name: NAME, port, expireHours} /network socks=off smp-proxy=always smp-proxy-fallback=protected # the apps' defaults, SimpleXAPI.kt:5427-5447 buffer events; chat.startChat() --display-name differs from profile.displayName, or peerType is not agent: /_profile {the profile with displayName and peerType agent} # APIUpdateProfile, parser Commands.hs:6399; before startChat the core returns chatNotStarted @@ -703,6 +709,8 @@ serve(NAME, options): lost unset, group.groupProfile.displayName differs from the profile's, or its commands from the menu of createControlChat: /_group_profile #group {the group profile with that displayName and menu} # APIUpdateGroupProfile, parser Commands.hs:6534 members = /_members #group, so approvers are known before the first call HTTP listen 127.0.0.1:PORT; port in use: chat.close(); remove DIR/lock; exit 1 "Port PORT is in use. Start with --port N." # before any link or QR code is printed + requests to /mcp with the token get 503 with Retry-After: 1 until ownerStart ends + write DIR/server.json {name: NAME, port, expireHours} lost unset: ownerStart(group) log "Listening on http://127.0.0.1:PORT/mcp" every 60 s, and at each owner command: expire the pending actions whose expires has passed (wall clock, so a sleep of the computer delays no expiry); @@ -714,12 +722,14 @@ SIGINT, SIGTERM (process.on; the second signal joins the stop in progress): close HTTP (open calls end); stop handling events, so no /approve starts a perform wait for the cards being posted each pending action: edit its card to the cancelled form - each performing action: mark it interrupted, so its perform's end edits no card; card set: edit it to the interrupted form; card unset (mode host): post the "host, interrupted" card + each performing action: mark it interrupted, so its perform's end edits no card and logs no line; card set: edit it to the interrupted form; card unset (mode host): post the "host, interrupted" card + abort the stop signal of the performs: a connect's wait for the connection ends wait for the outcome edits of performs that ended before the stop K = pending and performing actions; log "Cancelled K actions." ("1 action." for one) when K > 0 wait until the control chat's pending deliveries are 0, polled every 100 ms, at most 10 s: # an edit of a received card emits no status event (Subscriber.hs:4139-4146, 9711dcfc9) /sql chat SELECT hex(agent_conn_id) FROM connections WHERE group_member_id IN (SELECT group_member_id FROM group_members WHERE group_id = ) /sql agent SELECT count(*) FROM snd_message_deliveries WHERE failed = 0 AND hex(conn_id) IN () + an error of either query: log "delivery wait: " to stderr; the wait ends chat.close(); remove DIR/lock; log "Stopped."; exit 0 ``` @@ -755,7 +765,7 @@ ownerStart(group): no humans: openLink(observer); print the observer line else: log and post " joined (member ). Restart the server with --owners " --owners LIST: - each entry: a human with groupMemberId ID and displayName NAME; missing: exit 2 "--owners: member ID:NAME is not in the agent control chat. Start with --reset-owners --owners, or use the IDs of current members." + each entry: a human with groupMemberId ID and displayName NAME, folded to one line as the restart line prints it; missing: exit 2 "--owners: member ID:NAME is not in the agent control chat. Start with --reset-owners --owners, or use the IDs of current members." each entry without role owner: apiSetMembersRole(group, [ID], owner) # one member per call: Commands.hs:2990-2991 (9711dcfc9) each human not in LIST: apiRemoveMembers(group, [ID]) # one member per call: Commands.hs:3130 (9711dcfc9) deleteLink(); log "Owners: LIST."; print the register hint @@ -795,7 +805,7 @@ approve [CHAT] N | reject [CHAT] N, action N not pending in memory, or its card approve CHAT N | reject CHAT N, action N a connect or for another chat: reply "Action N is not for CHAT." approve [CHAT] N: decidedBy = sender; state performing; start perform(N) without awaiting it in the event handler; its end edits the card reject [CHAT] N: decidedBy = sender; state rejected; edit the card -approve CHAT all: CHAT not a chat of the agent: reply "No chat CHAT. ..."; otherwise set the chat's custom data simplexMcp = {approvedBy: sender name, approvedById: sender groupMemberId, approvedAt}, keeping its other keys +approve CHAT all: mode host: reply "Approvals apply in mode requests; this server runs in mode host."; CHAT not a chat of the agent: reply "No chat CHAT. ..."; otherwise set the chat's custom data simplexMcp = {approvedBy: sender name, approvedById: sender groupMemberId, approvedAt}, keeping its other keys (/_set custom @C or #G); each pending send of CHAT: approve as above; reply "Approved for ... /'revoke CHAT'" revoke CHAT: no approval in the chat's custom data: reply "Messages to CHAT have no standing approval."; otherwise remove simplexMcp from it; reply "Messages to ... need approval again." pending: reply with the pending actions @@ -867,7 +877,9 @@ action(tool, input, ctx): stopping: tool error "The server is stopping." lost: tool error "control chat lost" 20 actions pending: tool error "20 actions pending" - a send whose largest card exceeds 15000 bytes (section Card size): tool error "card over 15000 bytes" + 20 actions performing: tool error "20 actions performing" + a connect: the link texts on its card cut to fit 15000 bytes (section Card size) + a send or connect whose largest card exceeds 15000 bytes (section Card size): tool error "card over 15000 bytes" no approvers: tool error, the no-approver message N = nextAction; nextAction = N + 1; create action N; log "Action N: ..." /_set custom #group {simplexMcp: {control: true, nextAction}}, queued after earlier writes, awaited @@ -950,9 +962,9 @@ send(input, ctx): r = /_get chat count=0; a store error: tool error "unknown chat" r.chat.chatInfo not connected (as connected in list()): tool error "chat not connected" group or channel, r.chat.chatInfo.groupInfo.membership role below author: tool error "role below author" - reply_to i: /_get chat around= count=0 fails or has no item i: tool error "reply_to not in chat" + reply_to i: /_get chat around= count=0 fails or has no item i: tool error "reply_to not in chat"; item i not a message: tool error "reply_to not a message" mention, chat a contact chat: tool error "mention not a member" - mention m: /_members has no member m: tool error "mention not a member" + mention m: /_members has no member m: tool error "mention not a member"; two members with the same display name: tool error "mention of equal names" action(send, input, ctx) return responses(action, wait, idle, limit, ctx) with {action, status: "done"} @@ -966,7 +978,7 @@ perform send: `responses(action, wait, idle, limit, ctx)`: ``` -collected: action.responses, in arrival order: at most 100 items kept, later ones counted as overflow; an item updated by chatItemUpdated (a live message) is replaced, and idle does not end the wait while a collected item is live +collected: action.responses, in arrival order: at most 100 items kept, the IDs of later ones kept as overflow until they are deleted; an item updated by chatItemUpdated (a live message) is replaced, and idle does not end the wait while a collected item is live until: wait seconds | idle seconds after the last collected item | the call's signal; re-evaluated at each newChatItems of chat signal: return progress at once and every 60 s: " responses in ; waiting until