diff --git a/apps/desktop/src/plugins/hermes-bots/plugin.js b/apps/desktop/src/plugins/hermes-bots/plugin.js index f8b8a1d58a..02e0aa4417 100644 --- a/apps/desktop/src/plugins/hermes-bots/plugin.js +++ b/apps/desktop/src/plugins/hermes-bots/plugin.js @@ -2151,8 +2151,10 @@ function PetTab({ image, onImage }) { let serverInjectsProtocol = false function useRoster() { + const activeConnectionId = useValue(host.state.connectionId) + return useQuery({ - queryKey: ROSTER_KEY, + queryKey: [...ROSTER_KEY, activeConnectionId], queryFn: async () => { // Rich rows (last_session, ui_meta, has_avatar) come from the ACTIVE // gateway's profiles.list — unchanged single-source behavior. @@ -2170,7 +2172,7 @@ function useRoster() { if (typeof host.agents === 'function') { try { const union = await host.agents() - return mergeMultiSourceRoster(local, union, liveActiveConnectionId()) + return mergeMultiSourceRoster(local, union, activeConnectionId) } catch { /* older build or roster failure — single-source list stands */ } @@ -2187,53 +2189,42 @@ function useRoster() { }) } -/** Registry connection id of the LIVE active gateway (feature-detected; - * null on older desktops or the local/legacy primary path). Live beats the - * roster's primaryConnectionId when the user has activated a non-primary - * source's agent — profiles.list answers from THAT source, so the merge - * must classify against it, not the registry's primary. */ -function liveActiveConnectionId() { - if (typeof host.activeConnectionId !== 'function') { - return null - } - - try { - return host.activeConnectionId() - } catch { - return null - } -} - /** Merge the union agent roster (host.agents) over the active gateway's - * profiles.list. Rows from the ACTIVE gateway are matched by connection id - * and only ANNOTATED (handle, connectionId) — their rich fields stay - * authoritative and they are NOT duplicated. The active id prefers the LIVE - * host.activeConnectionId() (the gateway may be a non-primary source after - * the user opens another connection's agent), falls back to the roster's - * primaryConnectionId, then to the legacy kind==='local' rule on older - * desktops. Rows from other sources become new roster entries tagged with - * their source label so BotRow can badge them and route open/warm through + * profiles.list. Active-source rows — matched by the LIVE connection id, + * falling back to the roster's primaryConnectionId, then the legacy + * kind==='local' rule on older desktops — are the agents profiles.list + * already returned: they only ANNOTATE the rich rows (handle, connection + * fields); rich fields stay authoritative and they are NOT duplicated. + * Rows from other sources become new roster entries tagged with their + * source label so BotRow can badge them and route open/warm through * ensureAgent/warmAgent. Pure — exercised directly by the tests. */ -function mergeMultiSourceRoster(local, union, liveActiveId = null) { - // Keep the first rich local row for every profile. profiles.list is normally - // unique, but a duplicated backend response must not turn one agent into an - // unbounded list of visually identical Bots rows. - const profiles = [] - const localByName = new Map() +function mergeMultiSourceRoster(local, union, activeConnectionId = null) { + const localProfiles = Array.isArray(local?.profiles) ? local.profiles : [] + const agents = Array.isArray(union?.agents) ? union.agents : [] + const activeId = String(activeConnectionId || union?.primaryConnectionId || '').trim() + const activeByName = new Map() - for (const profile of Array.isArray(local?.profiles) ? local.profiles : []) { + // Treat the rich list as one row per active-source profile. Clone every + // row: some gateway clients reuse response objects, and annotating those in + // place made each five-second refresh feed the previous union back into the + // next merge, growing duplicate source rows indefinitely. + for (const profile of localProfiles) { const name = String(profile?.name || '').trim() - if (!name || localByName.has(name)) { + if (!name || profile?.remoteSource) { continue } - localByName.set(name, profile) - profiles.push(profile) + if (profile?.sourceScoped && activeId && profile.connectionId !== activeId) { + continue + } + + if (!activeByName.has(name)) { + activeByName.set(name, { ...profile, name }) + } } - const agents = Array.isArray(union?.agents) ? union.agents : [] - const primaryId = String(liveActiveId || union?.primaryConnectionId || '').trim() + const profiles = [...activeByName.values()] if (!agents.length) { return { ...local, profiles } @@ -2242,39 +2233,42 @@ function mergeMultiSourceRoster(local, union, liveActiveId = null) { // host.agents is an Electron/main-process capability. Defend the plugin // boundary too: older shells or reconnect races can still hand us repeated // identities even after the core roster deduplicates them. - const seenAgentIdentities = new Set() + const seenSources = new Set() for (const agent of agents) { const profile = String(agent?.profile || '').trim() const connectionId = String(agent?.connectionId || '').trim() - const identity = `${connectionId}\0${profile}` + const sourceKey = `${connectionId}::${profile || 'default'}` - if (!profile || seenAgentIdentities.has(identity)) { + if (!profile || seenSources.has(sourceKey)) { continue } - seenAgentIdentities.add(identity) + seenSources.add(sourceKey) // The union enumerates EVERY registered connection, including the active // gateway that already answered profiles.list. Without this the active // gateway's own agents (connectionKind 'remote' on a remote-primary // desktop) would be appended as phantom duplicates — every bot listed - // twice. Older Electron builds predate primaryConnectionId; fall back to + // twice. Older Electron builds predate the connection ids; fall back to // the legacy local-source rule so single-source behavior stays intact. - const isPrimarySource = primaryId ? connectionId === primaryId : agent.connectionKind === 'local' - const row = isPrimarySource ? localByName.get(profile) : null + const isActiveSource = activeId ? connectionId === activeId : agent.connectionKind === 'local' + const row = isActiveSource ? activeByName.get(profile) : null if (row) { // Annotate in place: the @name-device handle only differs from the // bare name when the profile exists on several sources. row.handle = agent.handle row.connectionId = agent.connectionId + row.connectionKind = agent.connectionKind + row.connectionLabel = agent.connectionLabel + row.sourceScoped = true continue } - if (isPrimarySource) { - // Union saw a primary-source profile profiles.list didn't return - // (older backend mid-refresh) — skip rather than invent a thin row. + if (isActiveSource) { + // Union saw an active-source profile profiles.list didn't return (older + // backend mid-refresh) — skip rather than invent a thin row. continue } @@ -2284,22 +2278,14 @@ function mergeMultiSourceRoster(local, union, liveActiveId = null) { connectionId, connectionKind: agent.connectionKind, connectionLabel: agent.connectionLabel, - remoteSource: true + remoteSource: true, + sourceScoped: true }) } return { ...local, profiles } } -/** React list key for a roster row. Names alone are NOT unique in a - * multi-source roster (two connections can both expose 'default'); duplicate - * keys make React reconciliation repeat whole blocks of the list on every - * poll repaint. Annotated ACTIVE-source rows keep the plain-name key so - * existing rows don't remount when a desktop gains the union roster. */ -function botRowKey(bot) { - return `${bot.remoteSource ? bot.connectionId ?? '' : ''}:${bot.name}` -} - /** The @handle users tag a bot with. Multi-source rosters precompute the * handle (bare name, or name-device when the profile exists on several * registered sources) — prefer it when present. The primary profile's @@ -2313,6 +2299,23 @@ function botHandle(name, bot) { return (name || '').trim().toLowerCase() === 'default' ? 'hermes' : name } +/** Source-qualified identity for a roster row — the React list key AND the + * cross-surface roster identity. Names alone are NOT unique in a + * multi-source roster (two connections can both expose 'default'); + * duplicate keys make React reconciliation repeat whole blocks of the list + * on every poll repaint (the Aug 2026 dupe-bots smear). */ +function botRosterKey(bot) { + return `${bot?.connectionId || 'legacy'}::${bot?.name || 'default'}` +} + +// Bot metadata is scoped to the active gateway until the server exposes a +// union of rich profile rows. Never paint that metadata onto a thin row from +// another source: two `default` agents must not borrow each other's title, +// pin, avatar, group, unread state, or canonical-chat pointer. +function botRosterMeta(bot, metaByName) { + return bot?.remoteSource ? null : metaByName?.[bot?.name] +} + function showsHandle(name, meta, bot) { const display = displayName({ name }, meta) return Boolean(name && display.toLowerCase() !== botHandle(name, bot).toLowerCase()) @@ -2448,7 +2451,40 @@ async function openBotCanonicalChat(name, pinned) { } } +async function prepareBotSource(bot, pinnedChat) { + if (!bot.sourceScoped) { + return pinnedChat + } + + if (typeof host.ensureAgent !== 'function') { + throw new Error('Update Hermes Desktop to chat with agents on other connections.') + } + + await host.ensureAgent(bot.connectionId, bot.name) + + if (!bot.remoteSource) { + return pinnedChat + } + + // Thin rows deliberately omit metadata from the active source. Once their + // owner is active, recover that source's canonical-chat pointer so + // same-named agents never reuse or overwrite each other's pin. + try { + const refreshed = await host.request('profiles.list', {}) + const owner = refreshed?.profiles?.find(profile => profile.name === bot.name) + + return owner?.ui_meta?.['hermes-bots']?.chat || null + } catch { + // Metadata refresh is best-effort; canonical creation remains the fallback. + return null + } +} + function displayName(bot, meta) { + if (bot?.sourceScoped && (bot.name || '').trim().toLowerCase() === 'default' && bot.connectionLabel) { + return bot.connectionLabel + } + if (meta?.title?.trim()) { return meta.title.trim() } @@ -2475,7 +2511,7 @@ function filterBots(roster, metaByName, query) { } return roster.filter(bot => { - const display = displayName(bot, metaByName[bot.name]).toLowerCase() + const display = displayName(bot, botRosterMeta(bot, metaByName)).toLowerCase() const profile = (bot.name || '').toLowerCase() const handle = botHandle(bot.name, bot).toLowerCase() // Multi-source rows also match on their device name ("homelab" finds @@ -2507,7 +2543,7 @@ function groupRoster(roster, metaByName) { const byGroup = new Map() for (const bot of roster) { - const group = (metaByName[bot.name]?.group || '').trim() + const group = (botRosterMeta(bot, metaByName)?.group || '').trim() if (!group) { ungrouped.push(bot) @@ -3211,7 +3247,7 @@ const ACTIVE_WINDOW_S = 90 * presence never reorders or hides the normal list. */ function activeBots(roster, activeProfile, gatewayState, now = Date.now()) { return (roster || []).filter(bot => { - const busyTurn = bot.name === activeProfile && gatewayState === 'busy' + const busyTurn = !bot.remoteSource && bot.name === activeProfile && gatewayState === 'busy' const last = bot.last_session?.last_active || 0 const inWindow = Boolean(last && now / 1000 - last < ACTIVE_WINDOW_S) @@ -3223,9 +3259,9 @@ function activeBots(roster, activeProfile, gatewayState, now = Date.now()) { function BotRow({ bot, onDelete, onEdit, onGroup }) { const activeProfile = useValue(host.state.profile) - const meta = useValue($botMeta)[bot.name] + const meta = botRosterMeta(bot, useValue($botMeta)) const last = bot.last_session - const isActive = bot.name === activeProfile + const isActive = !bot.remoteSource && bot.name === activeProfile const { shape, color, image } = botAppearance(bot.name, meta) // Keep user photos/pets. Drop the 160px SVG backfill so the math face can move. const photo = Boolean(image && !isBackfilledFacePng(image)) @@ -3235,7 +3271,10 @@ function BotRow({ bot, onDelete, onEdit, onGroup }) { // profile while the gateway is busy, or a bot that wrote within the // liveness window. Not every bot whenever the gateway is busy. const botMood = (isActive && gatewayState === 'busy') || activeNow ? 'work' : 'idle' - const unread = Boolean(useValue($botUnread)[bot.name]) + // Subscribe on every render. A source switch turns the same keyed row from + // thin to rich; conditionally calling useValue here breaks React hook order. + const unreadByName = useValue($botUnread) + const unread = !bot.remoteSource && Boolean(unreadByName[bot.name]) // WHO sent the last message (bot-to-bot DM vs human) — the full stored // history lives in the Sessions workspace (context menu), not inline. const { fromBot } = previewKind(last?.preview) @@ -3246,7 +3285,7 @@ function BotRow({ bot, onDelete, onEdit, onGroup }) { const warm = () => { // Multi-source row: pre-dial the agent's OWN source (feature-detected). - if (bot.remoteSource && typeof host.warmAgent === 'function') { + if (bot.sourceScoped && typeof host.warmAgent === 'function') { try { host.warmAgent(bot.connectionId, bot.name) } catch { @@ -3270,38 +3309,26 @@ function BotRow({ bot, onDelete, onEdit, onGroup }) { const open = async () => { haptic('tap') $selectedBot.set(bot.name) + let pinnedChat = meta?.chat - if ($botUnread.get()[bot.name]) { + if (!bot.remoteSource && $botUnread.get()[bot.name]) { const next = { ...$botUnread.get() } delete next[bot.name] $botUnread.set(next) } - // Multi-source row: activate the agent's source gateway FIRST so the - // canonical-chat RPCs (session.list / session.create / openSession) - // land on the backend that actually owns this bot's state.db. Same - // canonical-chat flow after that — one forever chat per bot, per source. - if (bot.remoteSource) { - if (typeof host.ensureAgent !== 'function') { - host.notifyError?.( - new Error('Update Hermes Desktop to chat with agents on other connections.'), - bot.connectionLabel || 'Remote source' - ) + // Activate the owner first so every canonical-chat RPC lands on the + // backend that owns this bot's state database. + try { + pinnedChat = await prepareBotSource(bot, pinnedChat) + } catch (error) { + host.notifyError?.(error, `Could not reach ${bot.connectionLabel || 'the remote source'}`) - return - } - - try { - await host.ensureAgent(bot.connectionId, bot.name) - } catch (error) { - host.notifyError?.(error, `Could not reach ${bot.connectionLabel || 'the remote source'}`) - - return - } + return } try { - const id = await openBotCanonicalChat(bot.name, meta?.chat) + const id = await openBotCanonicalChat(bot.name, pinnedChat) if (id) { return @@ -3412,6 +3439,14 @@ function BotRow({ bot, onDelete, onEdit, onGroup }) { ] }) + // Thin rows from another source are navigation targets only. Their profile + // metadata is not loaded yet, so edit/delete/pin/group actions would mutate + // whichever backend happens to be active. A normal click activates the + // owner; the refreshed rich row then exposes the full context menu. + if (bot.remoteSource) { + return row + } + return jsxs(ContextMenu, { children: [ jsx(ContextMenuTrigger, { asChild: true, children: row }), @@ -3441,7 +3476,7 @@ function BotRow({ bot, onDelete, onEdit, onGroup }) { jsx(ContextMenuItem, { onSelect: () => { host.notify({ kind: 'info', message: `Duplicating ${displayName(bot, meta)}…` }) - duplicateBot(bot, $lastRoster.get()) + duplicateBot(bot, $lastRoster.get().filter(candidate => !candidate.remoteSource)) .then(name => { queryClient.invalidateQueries({ queryKey: ROSTER_KEY }) host.notify({ kind: 'success', message: `Created ${name} — full copy of ${bot.name}` }) @@ -6124,7 +6159,7 @@ function ActiveNowStrip({ roster, activeProfile, gatewayState, metaByName, onOpe children: label }) ] - }, botRowKey(bot)) + }, botRosterKey(bot)) }) ] }) @@ -6296,7 +6331,7 @@ function CreateGroupChatDialog({ open, roster, onClose, onCreated }) { title: 'Remove from selection', onClick: () => setChecked(prev => ({ ...prev, [bot.name]: false })), children: [displayName(bot, allMeta[bot.name]), jsx(Codicon, { name: 'close', className: 'text-[0.6rem]' })] - }, botRowKey(bot)) + }, botRosterKey(bot)) ) }) : null, @@ -6341,7 +6376,7 @@ function CreateGroupChatDialog({ open, roster, onClose, onCreated }) { onCheckedChange: value => setChecked(prev => ({ ...prev, [bot.name]: Boolean(value) })) }) ] - }, botRowKey(bot)) + }, botRosterKey(bot)) }) : jsx('div', { className: 'px-1.5 py-3 text-center text-xs text-(--ui-text-tertiary)', @@ -6530,7 +6565,7 @@ function BotsPane() { // freshly created bot tops the list until another bot gets a message. // No special slot for the primary bot — it competes on recency too. const activityOf = bot => { - const created = allMeta[bot.name]?.created || bot.ui_meta?.['hermes-bots']?.created || 0 + const created = botRosterMeta(bot, allMeta)?.created || bot.ui_meta?.['hermes-bots']?.created || 0 const lastMsg = (bot.last_session?.last_active || 0) * 1000 return Math.max(created, lastMsg) @@ -6538,7 +6573,7 @@ function BotsPane() { // Pinned bots (right-click → Pin) float to the top as a group; within the // pinned group and within the unpinned group, recency still rules. A // plain boolean flag in bot-meta (rides ui_meta to every machine). - const isPinned = bot => Boolean(allMeta[bot.name]?.pinned) + const isPinned = bot => Boolean(botRosterMeta(bot, allMeta)?.pinned) // Resilience (@wesleysimplicio, #13): a failed refresh must not erase a // roster the user already had — mixed local+cloud gateways and remotes // waking from sleep fail transiently. Render the last good snapshot with @@ -6555,14 +6590,15 @@ function BotsPane() { return activityOf(b) - activityOf(a) }) + const activeSourceRoster = roster.filter(bot => !bot.remoteSource) const filteredRoster = filterBots(roster, allMeta, query) if (live) { $lastRoster.set(roster) - mergeServerMeta(live) - pullServerAvatars(live) - trackInboundActivity(live) - backfillMessagingProtocol(live) + mergeServerMeta(activeSourceRoster) + pullServerAvatars(activeSourceRoster) + trackInboundActivity(activeSourceRoster) + backfillMessagingProtocol(activeSourceRoster) } const staleNotice = error && !live && roster.length @@ -6575,7 +6611,7 @@ function BotsPane() { } const groupChatMembers = groupChatName - ? roster.filter(bot => (allMeta[bot.name]?.group || '').trim() === groupChatName) + ? activeSourceRoster.filter(bot => (botRosterMeta(bot, allMeta)?.group || '').trim() === groupChatName) : [] if (groupChatName && groupChatMembers.length) { @@ -6638,7 +6674,7 @@ function BotsPane() { children: [jsx(Codicon, { name: 'hubot', className: 'mr-1.5' }), 'New Agent'] }), jsxs(DropdownMenuItem, { - disabled: roster.length < 2, + disabled: activeSourceRoster.length < 2, onSelect: () => setGroupCreateOpen(true), children: [jsx(Codicon, { name: 'organization', className: 'mr-1.5' }), 'New Group Chat'] }) @@ -6665,11 +6701,33 @@ function BotsPane() { $botUnread.set(next) } - void openBotCanonicalChat(bot.name, allMeta[bot.name]?.chat).catch(() => { + void (async () => { + let pinnedChat = botRosterMeta(bot, allMeta)?.chat + + try { + pinnedChat = await prepareBotSource(bot, pinnedChat) + } catch (error) { + host.notifyError?.(error, `Could not reach ${bot.connectionLabel || 'the remote source'}`) + + return + } + + try { + const id = await openBotCanonicalChat(bot.name, pinnedChat) + + if (id) { + return + } + } catch { + // Fall through to the older-gateway draft below. + } + if (typeof host.newChat === 'function') { host.newChat(bot.name) + } else { + host.navigate('/') } - }) + })() } }), roster.length @@ -6768,7 +6826,11 @@ function BotsPane() { }, `group:${section.group}`) : null, ...section.bots.map(bot => - jsx(BotRow, { bot, onDelete: setDeleting, onEdit: setEditing, onGroup: setGrouping }, botRowKey(bot)) + jsx( + BotRow, + { bot, onDelete: setDeleting, onEdit: setEditing, onGroup: setGrouping }, + botRosterKey(bot) + ) ) ]) }) @@ -6788,11 +6850,11 @@ function BotsPane() { setCreateOpen(false) void refetch() }, - roster + roster: activeSourceRoster }), jsx(CreateGroupChatDialog, { open: groupCreateOpen, - roster, + roster: activeSourceRoster, onClose: () => setGroupCreateOpen(false), onCreated: groupName => $groupChatWorkspace.set(groupName) }), diff --git a/apps/desktop/src/plugins/hermes-bots/tests/active-now-strip.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/active-now-strip.test.mjs index 243554af6a..ae8a816ca1 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/active-now-strip.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/active-now-strip.test.mjs @@ -90,7 +90,9 @@ test('ActiveNowStrip renders above the roster, is a live region, and is click-ac assert.match(source, /jsx\('button', \{\s*type: 'button',\s*title: `Open \$\{label\}'s chat`/) // The key rides as jsx()'s third argument — the ONLY form React treats as // a list key; a `key:` prop leaves chips unkeyed (index identity). - assert.match(source, /\}, botRowKey\(bot\)\)\s*\}\)\s*\]\s*\}\)\s*\}\s*\/\*\* Assign a bot to a group/s) + assert.match(source, /\}, botRosterKey\(bot\)\)\s*\}\)\s*\]\s*\}\)\s*\}\s*\/\*\* Assign a bot to a group/s) assert.match(source, /jsx\(BotFace,\s*\{[\s\S]*?mood: 'work'/) - assert.match(source, /openBotCanonicalChat\(bot\.name, allMeta\[bot\.name\]\?\.chat\)/) + assert.match(source, /let pinnedChat = botRosterMeta\(bot, allMeta\)\?\.chat/) + assert.match(source, /await prepareBotSource\(bot, pinnedChat\)/) + assert.match(source, /openBotCanonicalChat\(bot\.name, pinnedChat\)/) }) diff --git a/apps/desktop/src/plugins/hermes-bots/tests/multi-source-roster.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/multi-source-roster.test.mjs index 6e80aee52d..643df47633 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/multi-source-roster.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/multi-source-roster.test.mjs @@ -16,7 +16,13 @@ function runtime() { useValue: value => (value?.get ? value.get() : value), useState: value => [value, () => undefined], document: { getElementById: () => null, createElement: () => ({}), head: { appendChild: () => undefined } }, - host: { state: { profile: { get: () => 'ops', listen: () => undefined } }, request: () => undefined }, + host: { + state: { + connectionId: { get: () => 'local', listen: () => undefined }, + profile: { get: () => 'ops', listen: () => undefined } + }, + request: () => undefined + }, sdk: new Proxy({}, { get: () => undefined }) } const code = source @@ -26,7 +32,7 @@ function runtime() { .replace(/^import .* from 'react\/jsx-runtime'\r?\n/m, '') .replace('export default {', 'globalThis.plugin = {') .concat( - '\nglobalThis.__mergeMultiSourceRoster = mergeMultiSourceRoster;\nglobalThis.__botHandle = botHandle;\nglobalThis.__filterBots = filterBots;\nglobalThis.__botRowKey = botRowKey;' + '\nglobalThis.__mergeMultiSourceRoster = mergeMultiSourceRoster;\nglobalThis.__botHandle = botHandle;\nglobalThis.__botRosterKey = botRosterKey;\nglobalThis.__botRosterMeta = botRosterMeta;\nglobalThis.__displayName = displayName;\nglobalThis.__filterBots = filterBots;' ) vm.runInNewContext(code, context) return context @@ -71,13 +77,14 @@ test('merge: local rows are annotated, remote rows appended with source tags', ( ] } - const out = merge(local, union) + const out = merge(local, union, 'local') assert.equal(out.profiles.length, 3) const localRow = out.profiles.find(p => p.name === 'research' && !p.remoteSource) // Annotated in place — rich fields survive, handle attached. assert.equal(localRow.last_session.id, 's1') assert.equal(localRow.handle, 'research-this-device') + assert.equal(localRow.sourceScoped, true) assert.equal(localRow.remoteSource, undefined) const remoteRow = out.profiles.find(p => p.name === 'research' && p.remoteSource) @@ -90,7 +97,7 @@ test('merge: local rows are annotated, remote rows appended with source tags', ( assert.equal(coder.handle, 'coder') }) -test('merge: union-only local profiles are NOT invented as thin rows', () => { +test('merge: union-only active profiles are NOT invented as thin rows', () => { const { __mergeMultiSourceRoster: merge } = runtime() const local = { profiles: [{ name: 'default' }] } const union = { @@ -105,7 +112,7 @@ test('merge: union-only local profiles are NOT invented as thin rows', () => { ] } - const out = merge(local, union) + const out = merge(local, union, 'local') assert.equal(out.profiles.length, 1) assert.equal(out.profiles[0].name, 'default') }) @@ -139,7 +146,7 @@ test('merge: duplicate source and local identities render once', () => { ] } - const out = merge(local, union) + const out = merge(local, union, 'local') assert.equal(out.profiles.length, 2) assert.equal(out.profiles[0].last_session.id, 'newest') @@ -147,6 +154,96 @@ test('merge: duplicate source and local identities render once', () => { assert.equal(out.profiles[1].connectionId, 'homelab') }) +test('merge: rich rows follow the active remote source, not the local source', () => { + const { __mergeMultiSourceRoster: merge } = runtime() + const active = { profiles: [{ name: 'default', last_session: { id: 'remote-session' } }] } + const union = { + agents: [ + { + connectionId: 'local', + connectionKind: 'local', + connectionLabel: 'This device', + profile: 'default', + handle: 'default-this-device' + }, + { + connectionId: 'work', + connectionKind: 'remote', + connectionLabel: 'Work', + profile: 'default', + handle: 'default-work' + } + ] + } + + const out = merge(active, union, 'work') + const remote = out.profiles.find(p => p.connectionId === 'work') + const local = out.profiles.find(p => p.connectionId === 'local') + + assert.equal(remote.last_session.id, 'remote-session') + assert.equal(remote.sourceScoped, true) + assert.equal(remote.remoteSource, undefined) + assert.equal(local.remoteSource, true) + assert.equal(local.sourceScoped, true) +}) + +test('merge: repeated refreshes stay idempotent and do not mutate gateway rows', () => { + const { __mergeMultiSourceRoster: merge } = runtime() + const rich = { name: 'default', last_session: { id: 'remote-session' } } + const local = { profiles: [rich, rich] } + const union = { + agents: [ + { + connectionId: 'local', + connectionKind: 'local', + connectionLabel: 'This device', + profile: 'default', + handle: 'default-this-device' + }, + { + connectionId: 'work', + connectionKind: 'remote', + connectionLabel: 'Work', + profile: 'default', + handle: 'default-work' + }, + { + connectionId: 'work', + connectionKind: 'remote', + connectionLabel: 'Work', + profile: 'default', + handle: 'default-work' + } + ] + } + + const once = merge(local, union, 'work') + const twice = merge(once, union, 'work') + const identities = twice.profiles.map(row => `${row.connectionId}:${row.name}`) + + assert.equal(identities.join(','), 'work:default,local:default') + assert.equal(new Set(identities).size, identities.length) + assert.equal(rich.connectionId, undefined) +}) + +test('default rows use source identity without borrowing another source title', () => { + const { __botRosterKey: key, __botRosterMeta: metaFor, __displayName: name } = runtime() + const remote = { + name: 'default', + connectionId: 'personal', + connectionLabel: 'Personal', + remoteSource: true, + sourceScoped: true + } + const active = { ...remote, remoteSource: undefined } + const metadata = { default: { title: 'Active workspace' } } + + assert.equal(metaFor(remote, metadata), null) + assert.equal(name(remote, metaFor(remote, metadata)), 'Personal') + assert.equal(name(active, metadata.default), 'Personal') + assert.equal(key(remote), 'personal::default') +}) + test('botHandle: precomputed multi-source handle wins; default stays hermes', () => { const { __botHandle: botHandle } = runtime() @@ -261,15 +358,15 @@ test('merge: live active id beats primaryConnectionId for active-source matching assert.equal(out.profiles.find(p => p.remoteSource).connectionId, 'local') }) -test('botRowKey: same name on two sources yields distinct keys; local rows stay stable', () => { - const { __botRowKey: botRowKey } = runtime() +test('botRosterKey: same name on two sources yields distinct React keys', () => { + const { __botRosterKey: botRosterKey } = runtime() - const localRow = botRowKey({ name: 'default' }) - const remoteRow = botRowKey({ name: 'default', remoteSource: true, connectionId: 'homelab' }) + const legacyRow = botRosterKey({ name: 'default' }) + const remoteRow = botRosterKey({ name: 'default', remoteSource: true, connectionId: 'homelab' }) + const activeRow = botRosterKey({ name: 'default', connectionId: 'vps' }) - assert.notEqual(localRow, remoteRow) - // Annotated ACTIVE-source rows (connectionId set, no remoteSource) keep the - // plain key — annotation must not remount every row on desktops that gain - // the union roster mid-session. - assert.equal(botRowKey({ name: 'default', connectionId: 'vps' }), localRow) + assert.notEqual(legacyRow, remoteRow) + assert.notEqual(activeRow, remoteRow) + // Single-source desktops (no connection ids anywhere) keep a stable key. + assert.equal(legacyRow, 'legacy::default') }) 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 0311240c8b..1185358eb2 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 @@ -15,8 +15,13 @@ function sourceBetween(start, end) { return source.slice(from, to) } -function renderBotRow(name = 'alpha') { +function renderBotRow(input = 'alpha') { + const bot = typeof input === 'string' ? { name: input } : input + const name = bot.name + const prepareSource = sourceBetween('async function prepareBotSource(', 'function displayName(') const botRowSource = sourceBetween('function BotRow(', '// ── model picker') + const ensured = [] + const opened = [] const warmed = [] const atom = value => ({ get: () => value, @@ -39,6 +44,7 @@ function renderBotRow(name = 'alpha') { $selectedBot: atom('default'), botAppearance: () => ({ shape: 'round', color: '#000', image: null }), botHandle: value => value, + botRosterMeta: (_bot, metaByName) => metaByName?.[_bot.name] ?? null, cn: (...values) => values.filter(Boolean).join(' '), createCanonicalChat: async () => null, displayName: bot => bot.name, @@ -47,14 +53,23 @@ function renderBotRow(name = 'alpha') { // #49 session-aware-row helpers referenced inside BotRow. previewKind: () => ({ fromBot: false, sender: null }), generatedSessionTitle: () => null, + openBotCanonicalChat: async (...args) => { + opened.push(args) + return 'stored-chat' + }, ACTIVE_WINDOW_S: 90, A2A_PREFIX_RE: /^$/, useEffect: () => undefined, useState: initial => [typeof initial === 'function' ? initial() : initial, () => undefined], host: { state: { gateway: atom('open'), profile: atom('default') }, + ensureAgent: async (connectionId, profile) => ensured.push([connectionId, profile]), + warmAgent: (connectionId, profile) => warmed.push([connectionId, profile]), warmProfile: profile => warmed.push(profile), - request: async () => ({ sessions: [] }), + request: async method => + method === 'profiles.list' + ? { profiles: [{ name, ui_meta: { 'hermes-bots': { chat: 'owner-chat' } } }] } + : { sessions: [] }, notify: () => undefined, notifyError: () => undefined }, @@ -68,12 +83,12 @@ function renderBotRow(name = 'alpha') { useValue: store => store.get() } - vm.runInNewContext(`${botRowSource}\nglobalThis.BotRow = BotRow`, context) + vm.runInNewContext(`${prepareSource}\n${botRowSource}\nglobalThis.BotRow = BotRow`, context) - const tree = context.BotRow({ bot: { name }, onEdit: context.onEdit }) - const row = tree.props.children[0].props.children + const tree = context.BotRow({ bot, onEdit: context.onEdit }) + const row = tree.type === 'button' ? tree : tree.props.children[0].props.children - return { row, warmed } + return { ensured, opened, row, warmed } } test('regression: rendering BotsPane does not prewarm the entire roster', () => { @@ -82,6 +97,13 @@ test('regression: rendering BotsPane does not prewarm the entire roster', () => assert.doesNotMatch(botsPaneSource, /host\.warmProfile/) }) +test('regression: source transitions keep BotRow hook order stable', () => { + const botRowSource = sourceBetween('function BotRow(', '// ── model picker') + + assert.match(botRowSource, /const unreadByName = useValue\(\$botUnread\)/) + assert.doesNotMatch(botRowSource, /remoteSource && Boolean\(useValue/) +}) + test('behavior: pointer entry prewarms only the hovered bot', () => { const { row, warmed } = renderBotRow('alpha') @@ -90,3 +112,20 @@ test('behavior: pointer entry prewarms only the hovered bot', () => { row.props.onPointerEnter() assert.deepEqual(warmed, ['alpha']) }) + +test('behavior: a thin source row activates its owner and uses that owner\'s chat pin', async () => { + const { ensured, opened, row, warmed } = renderBotRow({ + connectionId: 'work', + connectionLabel: 'Work', + name: 'research', + remoteSource: true, + sourceScoped: true + }) + + row.props.onPointerEnter() + assert.deepEqual(warmed, [['work', 'research']]) + + await row.props.onClick() + assert.deepEqual(ensured, [['work', 'research']]) + assert.deepEqual(opened, [['research', 'owner-chat']]) +}) diff --git a/apps/desktop/src/plugins/hermes-bots/tests/session-workspace.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/session-workspace.test.mjs index 5aca6dd7f1..1cad2156a6 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/session-workspace.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/session-workspace.test.mjs @@ -122,8 +122,12 @@ test('sessions workspace: an in-flight open cannot restore selection after gatew assert.deepEqual(plain(runtime.__sessions.$botSelectedSessions.get()), {}) }) -test('source contract: the primary bot click keeps canonical chat while Sessions is secondary', () => { - assert.match(pluginSource, /const open = async \(\) => \{[\s\S]*openBotCanonicalChat\(bot\.name, meta\?\.chat\)/) +test('source contract: bot rows and Active now activate the owner before canonical chat', () => { + assert.match(pluginSource, /async function prepareBotSource\(bot, pinnedChat\)/) + assert.match(pluginSource, /await host\.ensureAgent\(bot\.connectionId, bot\.name\)/) + assert.match(pluginSource, /host\.request\('profiles\.list', \{\}\)/) + assert.match(pluginSource, /const open = async \(\) => \{[\s\S]*await prepareBotSource\(bot, pinnedChat\)[\s\S]*openBotCanonicalChat\(bot\.name, pinnedChat\)/) + assert.match(pluginSource, /onOpen: bot => \{[\s\S]*await prepareBotSource\(bot, pinnedChat\)[\s\S]*openBotCanonicalChat\(bot\.name, pinnedChat\)/) assert.match(pluginSource, /openBotSessionsWorkspace\(bot\)[\s\S]*children: 'Sessions'/) }) @@ -141,4 +145,3 @@ test('source contract: session controls expose filter and selection state to ass assert.match(pluginSource, /'aria-label': 'Filter sessions'/) assert.match(pluginSource, /'aria-current': active \? 'page' : undefined/) }) - diff --git a/apps/desktop/src/sdk/host-state.test.ts b/apps/desktop/src/sdk/host-state.test.ts index 00a94931aa..efa1cb9ffb 100644 --- a/apps/desktop/src/sdk/host-state.test.ts +++ b/apps/desktop/src/sdk/host-state.test.ts @@ -58,6 +58,17 @@ describe('host.state focused-session atoms', () => { expect(host.state.focusedUsage.get()).toBe(focused?.usage ?? null) }) + it('exposes the registry source that owns the active gateway', async () => { + const { host, session } = await setup() + + session.setConnection({ connectionId: 'work', mode: 'remote' } as never) + expect(host.state.connectionId.get()).toBe('work') + + session.setConnection({ mode: 'local' } as never) + expect(host.state.connectionId.get()).toBeNull() + session.setConnection(null) + }) + it('follows the interacted tile while the primary-only atom stays put', async () => { const { host, session, states } = await setup() const tree = await import('@/components/pane-shell/tree/store') diff --git a/apps/desktop/src/sdk/index.ts b/apps/desktop/src/sdk/index.ts index 6e0d0c4245..25e231aee8 100644 --- a/apps/desktop/src/sdk/index.ts +++ b/apps/desktop/src/sdk/index.ts @@ -47,7 +47,14 @@ import { setActiveProfile, setShowAllProfiles } from '@/store/profile' -import { $activeSessionId, $currentCwd, $currentModel, $gatewayState, $selectedStoredSessionId } from '@/store/session' +import { + $activeSessionId, + $connection, + $currentCwd, + $currentModel, + $gatewayState, + $selectedStoredSessionId +} from '@/store/session' import { $focusedRuntimeId, $focusedSessionState, @@ -162,6 +169,7 @@ if (typeof window !== 'undefined') { /** Live usage of the FOCUSED session, projected out of the streamed session * state — the same readout the core statusbar's context chip paints. */ const $focusedUsage = computed($focusedSessionState, state => state?.usage ?? null) +const $activeConnectionId = computed($connection, connection => connection?.connectionId ?? null) export const host = { state: { @@ -178,6 +186,8 @@ export const host = { busy: readonlyAtom($focusedBusy), /** Runtime session id → mid-turn. Not socket state; see `gateway`. */ busyBySession: readonlyAtom>($busyBySession), + /** Registry source that owns the active gateway, when source-scoped. */ + connectionId: readonlyAtom($activeConnectionId), /** Active workspace cwd ('' when detached). */ cwd: readonlyAtom($currentCwd), /** Runtime id of the FOCUSED chat session — the interacted tile, else the diff --git a/apps/desktop/src/sdk/profile-routing.test.ts b/apps/desktop/src/sdk/profile-routing.test.ts index ffcace23f2..bcf21feb02 100644 --- a/apps/desktop/src/sdk/profile-routing.test.ts +++ b/apps/desktop/src/sdk/profile-routing.test.ts @@ -22,6 +22,7 @@ vi.mock('@/store/session', async () => { return { $activeSessionId: atom(null), + $connection: atom(null), $currentCwd: atom(''), $currentModel: atom(''), $gatewayState: atom('open'),