diff --git a/apps/desktop/src/plugins/hermes-bots/plugin.js b/apps/desktop/src/plugins/hermes-bots/plugin.js index 5aa7ca5a21..c4601b5fd7 100644 --- a/apps/desktop/src/plugins/hermes-bots/plugin.js +++ b/apps/desktop/src/plugins/hermes-bots/plugin.js @@ -4659,13 +4659,16 @@ function activeBots(roster, activeProfile, gatewayState, now = Date.now()) { function BotRow({ bot, onDelete, onEdit, onGroup }) { const activeProfile = useValue(host.state.profile) const focusedProfile = useValue($focusedBotProfile) + const activeGroup = useValue($groupChatWorkspace) const meta = botRosterMeta(bot, useValue($botMeta)) const groups = botGroups(meta) const last = bot.last_session // Highlight follows the chat on screen (focused session's owner), not the // gateway socket's home — a focused tab doesn't swap the socket, and on the // old keying the wrong bot stayed highlighted while you read another's chat. - const isActive = !bot.remoteSource && bot.name === focusedProfile + // A selected group chat suppresses every bot-row highlight: the group row + // owns the selection then (#88979). + const isActive = !activeGroup && !bot.remoteSource && bot.name === focusedProfile // Turn-busy is a SOCKET fact: only the gateway-home profile can be mid-turn. const isGatewayHome = !bot.remoteSource && bot.name === activeProfile const { shape, color, image } = botAppearance(bot.name, meta) @@ -4722,6 +4725,7 @@ function BotRow({ bot, onDelete, onEdit, onGroup }) { const open = async () => { const generation = ++botOpenGeneration haptic('tap') + $groupChatWorkspace.set(null) $selectedBot.set(bot.name) if (bot.remoteSource) { @@ -9137,6 +9141,10 @@ function closeGroupChatMainTab(group) { groupChatMainTabs.delete(group) + if ($groupChatWorkspace.get() === group) { + $groupChatWorkspace.set(null) + } + if (typeof close === 'function') { try { close() @@ -9164,6 +9172,7 @@ function GroupChatMainView({ group }) { * view on desktops whose SDK predates the main-area door. */ function openGroupChat(group) { $groupNeedsYou.set({ ...$groupNeedsYou.get(), [group]: false }) + $groupChatWorkspace.set(group) if (typeof host.openWorkspace === 'function') { try { @@ -9171,7 +9180,13 @@ function openGroupChat(group) { title: group, minWidth: '24rem', render: () => jsx(GroupChatMainView, { group }), - onClose: () => groupChatMainTabs.delete(group) + onClose: () => { + groupChatMainTabs.delete(group) + + if ($groupChatWorkspace.get() === group) { + $groupChatWorkspace.set(null) + } + } }) groupChatMainTabs.set(group, close) @@ -9182,7 +9197,8 @@ function openGroupChat(group) { } } - $groupChatWorkspace.set(group) + // The selected-group atom was set before trying the main-window door, so + // older desktops naturally render the in-panel room as the fallback. } /** One group chat as ONE roster row — the Discord shape: stacked member @@ -9190,7 +9206,7 @@ function openGroupChat(group) { * (markdown flattened), relative time of the last activity, and the * needs-you badge on the row itself. Sorts into the same recency ordering * as bot rows; clicking opens the room in the main chat window. */ -function GroupRow({ group, members, needsYou, onOpen }) { +function GroupRow({ active, group, members, needsYou, onOpen }) { const rooms = useValue($groupChats) const allMeta = useValue($botMeta) const room = rooms[group] || { log: [] } @@ -9214,7 +9230,8 @@ function GroupRow({ group, members, needsYou, onOpen }) { }, className: cn( 'flex w-full min-w-0 max-w-full items-center gap-2.5 overflow-hidden rounded-md px-2 py-2 text-left transition-colors', - 'hover:bg-(--chrome-action-hover)' + 'hover:bg-(--chrome-action-hover)', + active && 'bg-(--ui-row-active-background)' ), children: [ // Room picture when the user set one; else a composite avatar of up to @@ -9639,6 +9656,7 @@ function BotsPane() { ? jsx( GroupRow, { + active: groupChatName === row.name, group: row.name, members: row.members, needsYou: Boolean(groupNeedsYou[row.name]), 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 8f0e3f9741..f7e2411983 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 @@ -93,7 +93,7 @@ function load(turnScript) { .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, $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, $groupChats, $groupNeedsYou, $groupChatWorkspace, $botMeta, GROUP_CHAT_MAX_ROUNDS, GROUP_CHAT_MAX_MESSAGES };\n' ) vm.runInNewContext(source, context, { filename: 'plugin.js' }) const storageWrites = new Map() @@ -101,7 +101,7 @@ function load(turnScript) { storage: { get: () => null, set: (key, value) => storageWrites.set(key, value) }, register: () => undefined }) - return { ...context.__gc, calls, sessions, storageWrites } + return { ...context.__gc, calls, host: context.host, sessions, storageWrites } } const MEMBERS = [{ name: 'research', title: '' }, { name: 'builder', title: '' }, { name: 'ops', title: 'The Ops' }] @@ -344,6 +344,39 @@ test('source contract: workspace + main-window door + prompt rules are wired', ( assert.match(pluginSource, /\[Group chat: "\$\{groupName\}"\]/) }) +test('group selection follows main-window open and close', () => { + const gc = load(() => '(pass)') + let onClose + + gc.host.openWorkspace = (_id, options) => { + onClose = options.onClose + return () => onClose() + } + + gc.openGroupChat('Core') + assert.equal(gc.$groupChatWorkspace.get(), 'Core') + + onClose() + assert.equal(gc.$groupChatWorkspace.get(), null) +}) + +test('closing an older selected group does not clear the newer selection', () => { + const gc = load(() => '(pass)') + + gc.host.openWorkspace = () => () => undefined + gc.openGroupChat('Core') + gc.openGroupChat('Ops') + gc.closeGroupChatMainTab('Core') + + assert.equal(gc.$groupChatWorkspace.get(), 'Ops') +}) + +test('source contract: active group styling suppresses bot styling', () => { + assert.match(pluginSource, /const isActive = !activeGroup && !bot\.remoteSource && bot\.name === focusedProfile/) + assert.match(pluginSource, /active && 'bg-\(--ui-row-active-background\)'/) + assert.match(pluginSource, /active: groupChatName === row\.name/) +}) + test('disband: removes only this membership, room log, workspace, and needs-you state', async () => { const gc = load(() => '(pass)') diff --git a/apps/desktop/src/plugins/hermes-bots/tests/profile-prewarm.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/profile-prewarm.test.mjs index 9cff637fb5..57ade31cd8 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/profile-prewarm.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/profile-prewarm.test.mjs @@ -42,6 +42,7 @@ function renderBotRow(input = 'alpha') { $botMeta: atom({}), $botUnread: atom({}), $focusedBotProfile: atom('default'), + $groupChatWorkspace: atom(null), $lastRoster: atom([]), $selectedBot: atom('default'), botAppearance: () => ({ shape: 'round', color: '#000', image: null }), @@ -170,6 +171,7 @@ test('behavior: remote default does not open this-device chat when the source di $botMeta: atom({ default: { chat: 'this-device-chat' } }), $botUnread: atom({}), $focusedBotProfile: atom('default'), + $groupChatWorkspace: atom(null), $lastRoster: atom([]), $selectedBot: atom('default'), botAppearance: () => ({ shape: 'round', color: '#000', image: null }),