fix(desktop): preserve Bot Mode source routing

This commit is contained in:
David Dudok de Wit
2026-08-17 11:44:25 +02:00
committed by Teknium
parent ce1f5dd30d
commit ed20a6f01a
8 changed files with 357 additions and 132 deletions
+167 -105
View File
@@ -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)
}),
@@ -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\)/)
})
@@ -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')
})
@@ -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']])
})
@@ -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/)
})
+11
View File
@@ -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')
+11 -1
View File
@@ -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<boolean>($focusedBusy),
/** Runtime session id → mid-turn. Not socket state; see `gateway`. */
busyBySession: readonlyAtom<Record<string, boolean>>($busyBySession),
/** Registry source that owns the active gateway, when source-scoped. */
connectionId: readonlyAtom<null | string>($activeConnectionId),
/** Active workspace cwd ('' when detached). */
cwd: readonlyAtom<string>($currentCwd),
/** Runtime id of the FOCUSED chat session — the interacted tile, else the
@@ -22,6 +22,7 @@ vi.mock('@/store/session', async () => {
return {
$activeSessionId: atom(null),
$connection: atom(null),
$currentCwd: atom(''),
$currentModel: atom(''),
$gatewayState: atom('open'),