From 6851841112e921537eb7195ef6e8be7d2ca2d2f6 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 19 Aug 2026 21:12:25 -0700 Subject: [PATCH] fix(bot-mode): group chat opens as one room pane, not two (#89788) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening a Bot Mode group chat painted the room twice — once as a main-window workspace tab (host.openWorkspace) and once as the in-panel fallback, because the Bots pane rendered off $groupChatWorkspace alone. Two live panes with independent drafts drove one shared engine, and the roster disappeared behind the duplicate. The in-panel room is the fallback surface, not a second copy: it now renders only while no main tab owns the group. The selection atom stays set either way so the roster row still highlights, and desktops without the door — or whose door throws — keep the in-pane room. Consolidates #89881, #90274 and #90398, which fixed the same bug. Closes #89788 Co-authored-by: helix4u --- .../desktop/src/plugins/hermes-bots/plugin.js | 12 +++++++++- .../hermes-bots/tests/group-chat.test.mjs | 23 ++++++++++++++++++- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/plugins/hermes-bots/plugin.js b/apps/desktop/src/plugins/hermes-bots/plugin.js index c747cf6c94..88401d2a94 100644 --- a/apps/desktop/src/plugins/hermes-bots/plugin.js +++ b/apps/desktop/src/plugins/hermes-bots/plugin.js @@ -9448,6 +9448,16 @@ function GroupChatWorkspace({ group, members, onBack }) { * disband (or the room view's own Back) can retire the tab it opened. */ const groupChatMainTabs = new Map() +/** The in-panel room is the FALLBACK surface, not a second copy: it renders + * only while no main-window tab owns the group. On desktops with the door + * the room already lives in a main tab, and painting it here too produced + * two live panes with independent drafts driving one shared engine (#89788). + * The selection atom stays set either way so the roster row still + * highlights. */ +function shouldRenderGroupChatInPane(group) { + return Boolean(group && !groupChatMainTabs.has(group)) +} + function closeGroupChatMainTab(group) { const close = groupChatMainTabs.get(group) @@ -9737,7 +9747,7 @@ function BotsPane() { const groupChatMembers = groupChatName ? groupChatMemberBots(groupChatName, roster, allMeta) : [] - if (groupChatName && groupChatMembers.length) { + if (shouldRenderGroupChatInPane(groupChatName) && groupChatMembers.length) { return jsx(GroupChatWorkspace, { group: groupChatName, members: groupChatMembers }) } diff --git a/apps/desktop/src/plugins/hermes-bots/tests/group-chat.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/group-chat.test.mjs index 1bacfca9ba..51780eedc4 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/group-chat.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/group-chat.test.mjs @@ -104,7 +104,7 @@ function load(turnScript, { busyUntilResumeCall } = {}) { .replace(/^import .* from 'react\/jsx-runtime'\r?\n/m, '') .replace('export default {', 'globalThis.plugin = {') .concat( - '\nglobalThis.__gc = { sendToGroupChat, runGroupChatRounds, harvestStrandedGroupReply, resolveGroupResponders, parseGroupChatMentions, rotateGroupSpeakers, isGroupPassText, formatGroupChatLine, buildGroupChatTurnPrompt, trimGroupChatLog, disbandGroupChat, updateGroupChat, openGroupChat, closeGroupChatMainTab, $groupChats, $groupNeedsYou, $groupChatWorkspace, $botMeta, GROUP_CHAT_MAX_ROUNDS, GROUP_CHAT_MAX_MESSAGES };\n' + '\nglobalThis.__gc = { sendToGroupChat, runGroupChatRounds, harvestStrandedGroupReply, resolveGroupResponders, parseGroupChatMentions, rotateGroupSpeakers, isGroupPassText, formatGroupChatLine, buildGroupChatTurnPrompt, trimGroupChatLog, disbandGroupChat, updateGroupChat, openGroupChat, closeGroupChatMainTab, shouldRenderGroupChatInPane, $groupChats, $groupNeedsYou, $groupChatWorkspace, $botMeta, GROUP_CHAT_MAX_ROUNDS, GROUP_CHAT_MAX_MESSAGES };\n' ) vm.runInNewContext(source, context, { filename: 'plugin.js' }) const storageWrites = new Map() @@ -366,9 +366,30 @@ test('group selection follows main-window open and close', () => { gc.openGroupChat('Core') assert.equal(gc.$groupChatWorkspace.get(), 'Core') + // #89788: the main tab owns the room, so the pane keeps its roster. + assert.equal(gc.shouldRenderGroupChatInPane('Core'), false) onClose() assert.equal(gc.$groupChatWorkspace.get(), null) + assert.equal(gc.shouldRenderGroupChatInPane('Core'), true) +}) + +test('older and failed workspace hosts keep the in-pane group fallback', () => { + const older = load(() => '(pass)') + + older.openGroupChat('Core') + assert.equal(older.$groupChatWorkspace.get(), 'Core') + assert.equal(older.shouldRenderGroupChatInPane('Core'), true) + + const failed = load(() => '(pass)') + + failed.host.openWorkspace = () => { + throw new Error('workspace unavailable') + } + + failed.openGroupChat('Ops') + assert.equal(failed.$groupChatWorkspace.get(), 'Ops') + assert.equal(failed.shouldRenderGroupChatInPane('Ops'), true) }) test('closing an older selected group does not clear the newer selection', () => {