diff --git a/apps/desktop/src/plugins/hermes-bots/plugin.js b/apps/desktop/src/plugins/hermes-bots/plugin.js index 0dab6d76b7..8202751fd7 100644 --- a/apps/desktop/src/plugins/hermes-bots/plugin.js +++ b/apps/desktop/src/plugins/hermes-bots/plugin.js @@ -5648,6 +5648,24 @@ const canonicalCreations = new Map() * adoption, stored-session lookups). */ const PROFILE_SESSION_LIST_LIMIT = 200 let botOpenGeneration = 0 +let botOpenInFlight = 0 + +function beginBotOpen() { + const generation = ++botOpenGeneration + botOpenInFlight = generation + return generation +} + +function finishBotOpen(generation) { + if (botOpenInFlight === generation) { + botOpenInFlight = 0 + } +} + +function cancelBotOpen() { + botOpenGeneration += 1 + botOpenInFlight = 0 +} /** The one canonical title. (profile, CANONICAL_CHAT_TITLE) IS the bot's * forever-chat identity — see the header above. */ @@ -6050,7 +6068,7 @@ async function ensureBotMetadata(bot) { * remembers only this transient opened-view observation; it never stores or * resolves a canonical-chat id. */ async function openRosterBot(bot) { - const generation = ++botOpenGeneration + const generation = beginBotOpen() const key = botRosterKey(bot) const meta = botRosterMeta(bot, $botMeta.get()) // Keep the currently visible group as a fallback until this explicit action @@ -6103,6 +6121,7 @@ async function openRosterBot(bot) { await prepareBotSource(bot) } catch (error) { if (generation === botOpenGeneration) { + finishBotOpen(generation) $openBotChat.set(null) restorePreviousGroup() syncBotsHomeWorkspace() @@ -6114,6 +6133,7 @@ async function openRosterBot(bot) { } if (generation !== botOpenGeneration) { + finishBotOpen(generation) return false } @@ -6121,6 +6141,7 @@ async function openRosterBot(bot) { const opened = await openBotCanonicalChat(bot, () => generation === botOpenGeneration) if (generation !== botOpenGeneration) { + finishBotOpen(generation) return false } @@ -6138,11 +6159,13 @@ async function openRosterBot(bot) { openedRegistryId: opened.registryId, openedSessionId: opened.openedId }) + finishBotOpen(generation) closeBotsHomeWorkspace() return true } } catch (error) { if (generation === botOpenGeneration) { + finishBotOpen(generation) $openBotChat.set(null) restorePreviousGroup() syncBotsHomeWorkspace() @@ -6156,6 +6179,7 @@ async function openRosterBot(bot) { // An older Desktop without the profile-scoped draft API has no safe fallback: // do not navigate the current workspace or create a draft on the wrong owner. if (typeof host.newChat !== 'function') { + finishBotOpen(generation) $openBotChat.set(null) restorePreviousGroup() syncBotsHomeWorkspace() @@ -6163,6 +6187,7 @@ async function openRosterBot(bot) { } $openBotChat.set({ key, openedRegistryId: '' }) + finishBotOpen(generation) closeBotsHomeWorkspace() newBotChat(bot) return true @@ -14436,6 +14461,7 @@ function botsHomeMayOpen(explicit) { $botsPaneVisible.get() && !$groupChatWorkspace.get() && !$openBotChat.get() && + !botOpenInFlight && (explicit || !sessionOwnsWorkspace()) ) } @@ -14604,7 +14630,7 @@ function openGroupChat(group) { // A room selection supersedes any bot-open transition still hydrating. // The in-flight host navigation may complete underneath this workspace, // but it may not later close or visually steal the room the user chose. - botOpenGeneration += 1 + cancelBotOpen() $groupNeedsYou.set({ ...$groupNeedsYou.get(), [group]: false }) const ownerKey = groupWorkspaceOwnerKey(group) setBotsWorkspaceOwner(ownerKey, null, 'New group conversations start in the group composer.') @@ -15958,7 +15984,7 @@ export default { // workspace token too; this plugin generation prevents that expected // cancellation from repainting Bots home or showing an error after // the user deliberately returned to Sessions. - botOpenGeneration += 1 + cancelBotOpen() host.setWorkspaceScope?.('sessions') } // A generic composer has no stored-session owner, so passive sync diff --git a/apps/desktop/src/plugins/hermes-bots/tests/bots-home.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/bots-home.test.mjs index e1174c0dc8..c29a8c34e9 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/bots-home.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/bots-home.test.mjs @@ -715,6 +715,51 @@ test('a failed local open cannot re-register a group tombstoned after retirement assert.equal(t.$openBotChat.get(), null) }) +test('switching bots does not let the home cover the current chat while the next chat opens', async () => { + const t = load({ focusedStoredSessionId: 'chat-alpha' }) + t.setPluginCtx({ storage: { set: () => undefined } }) + t.$botsPaneVisible.set(true) + t.$openBotChat.set({ + key: 'local::alpha', + openedRegistryId: 'chat-alpha', + openedSessionId: 'chat-alpha' + }) + t.host.request = async method => { + if (method === 'session.list') { + return { sessions: [{ id: 'chat-beta', title: 'Bot Chat', message_count: 4 }] } + } + + return {} + } + + let finishOpen + let markOpenStarted + const openStarted = new Promise(resolve => { + markOpenStarted = resolve + }) + t.host.openSession = () => + new Promise(resolve => { + finishOpen = resolve + markOpenStarted() + }) + + const opening = t.openRosterBot({ connectionId: 'local', name: 'beta' }) + await openStarted + + // The destination focus edge releases the old chat claim before the async + // open has returned its replacement claim. Passive workspace reconciliation + // must not use that handoff gap to put Bots home in front. + t.focused.set(null) + t.releaseStaleOpenBotChat(null) + t.syncBotsHomeWorkspace() + + assert.equal(t.opened.some(entry => entry.id === 'hermes-bots:home'), false) + + finishOpen() + assert.equal(await opening, true) + assert.equal(t.$openBotChat.get()?.openedRegistryId, 'chat-beta') +}) + test('choosing a group prevents a stale canonical-chat open from closing it later', async () => { const t = load() t.setPluginCtx({ storage: { set: () => undefined } })