fix(desktop): preserve Bot Mode source routing
This commit is contained in:
committed by
Teknium
parent
ce1f5dd30d
commit
ed20a6f01a
@@ -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/)
|
||||
})
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user