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', () => {