feat(bot-mode): support multiple groups per bot

This commit is contained in:
Goktug Vatandas
2026-08-18 05:11:40 +03:00
committed by Teknium
parent 7e05e9080b
commit 9bea439189
8 changed files with 254 additions and 80 deletions

View File

@@ -585,6 +585,18 @@ function mergeServerMeta(roster) {
delete merged.chat
}
// Canonical multi-group metadata is authoritative for the compatibility
// scalar too. A server-side `group: null` is represented by omission,
// so retaining the local scalar would resurrect a membership that another
// desktop just removed.
if (
Array.isArray(server.groups) &&
Object.prototype.hasOwnProperty.call(mine, 'group') &&
!Object.prototype.hasOwnProperty.call(server, 'group')
) {
delete merged.group
}
if (JSON.stringify(next[bot.name] || null) !== JSON.stringify(merged)) {
next[bot.name] = merged
changed = true
@@ -3151,6 +3163,46 @@ function stripPreviewMarkdown(text) {
.trim()
}
/** Canonical multi-group read with legacy scalar compatibility. Profiles that
* predate `groups` still fall back to `group`; once the canonical array exists,
* it is authoritative. Writes keep `group` as a first-membership projection so
* older desktops can still display one room without corrupting the array. */
function botGroups(meta) {
const groups = []
const seen = new Set()
const values = Array.isArray(meta?.groups) ? meta.groups : [meta?.group]
for (const value of values) {
if (typeof value !== 'string') {
continue
}
const group = value.trim()
if (group && !seen.has(group)) {
seen.add(group)
groups.push(group)
}
}
return groups
}
function groupMembershipPatch(meta, group, enabled) {
const name = String(group || '').trim()
let groups = botGroups(meta)
if (enabled) {
if (name && !groups.includes(name)) {
groups = [...groups, name]
}
} else {
groups = groups.filter(existing => existing !== name)
}
return { groups, group: groups[0] || null }
}
/** Group chats that should hold a roster row: every group named in bot meta
* (local members) plus every room record that still has stored members or
* log — cross-connection rooms whose members can't ride bot-meta. */
@@ -3179,7 +3231,7 @@ function groupLastActivity(room) {
* Prefers the LIVE roster row for a stored descriptor when present. */
function groupChatMemberBots(group, roster, metaByName) {
const local = (roster || []).filter(
bot => !bot.remoteSource && (botRosterMeta(bot, metaByName)?.group || '').trim() === group
bot => !bot.remoteSource && botGroups(botRosterMeta(bot, metaByName)).includes(group)
)
const stored = ($groupChats.get()[group] || {}).members || []
const seated = new Set(local.map(botRosterKey))
@@ -3214,14 +3266,12 @@ function durableGroupChatMembers(bots) {
}))
}
/** Existing group names, alphabetical — feeds the Move-to-group dialog. */
/** Existing group names, alphabetical — feeds the Manage-groups dialog. */
function knownGroups(metaByName) {
const names = new Set()
for (const meta of Object.values(metaByName || {})) {
const group = (meta?.group || '').trim()
if (group) {
for (const group of botGroups(meta)) {
names.add(group)
}
}
@@ -3462,13 +3512,12 @@ function updateGroupChat(group, mutate) {
return next
}
/** Soft-disband a group chat: clear every member's group assignment (the
* grouping is bot-meta, so the disband syncs cross-machine via ui_meta),
* drop the room log from the atom + plugin storage, and close the room view
* if it's open. The members' per-group gateway sessions ("Group: <name>")
* are intentionally KEPT — they stay reachable through each bot's session
* browser. */
async function disbandGroupChat(group, memberNames) {
/** Soft-disband a group chat: remove only this group from every local member's
* membership list (the metadata syncs cross-machine via ui_meta), drop the
* room log from the atom + plugin storage, and close the room view if it's
* open. Other group memberships and the members' per-group gateway sessions
* ("Group: <name>") are intentionally KEPT. */
async function disbandGroupChat(group, members) {
// Invalidate any in-flight round-robin FIRST: bump the epoch so a running
// drive bails at its next member boundary instead of appending to a room
// the user just discarded.
@@ -3503,7 +3552,12 @@ async function disbandGroupChat(group, memberNames) {
for (const [name, room] of Object.entries($groupChats.get())) {
if (name !== group && Array.isArray(room.log)) {
durable[name] = { log: room.log, watermarks: room.watermarks, sessions: room.sessions || {} }
durable[name] = {
log: room.log,
watermarks: room.watermarks,
sessions: room.sessions || {},
members: Array.isArray(room.members) ? room.members : []
}
}
}
@@ -3512,11 +3566,16 @@ async function disbandGroupChat(group, memberNames) {
/* storage unavailable — the atom reset above still empties the room */
}
// Ungroup the members last. saveBotMeta never throws (local storage +
// Remove this membership last. saveBotMeta never throws (local storage +
// best-effort profiles.configure per member), so a flaky gateway can't
// strand the disband halfway with the room log already gone.
for (const name of memberNames) {
await saveBotMeta(name, { group: null })
for (const member of members) {
if (!member?.name || member.remoteSource) {
continue
}
const meta = $botMeta.get()[member.name] || {}
await saveBotMeta(member.name, groupMembershipPatch(meta, group, false))
}
}
@@ -4121,6 +4180,7 @@ function activeBots(roster, activeProfile, gatewayState, now = Date.now()) {
function BotRow({ bot, onDelete, onEdit, onGroup }) {
const activeProfile = useValue(host.state.profile)
const meta = botRosterMeta(bot, useValue($botMeta))
const groups = botGroups(meta)
const last = bot.last_session
const isActive = !bot.remoteSource && bot.name === activeProfile
const { shape, color, image } = botAppearance(bot.name, meta)
@@ -4378,10 +4438,12 @@ function BotRow({ bot, onDelete, onEdit, onGroup }) {
children: 'Sessions'
}),
jsx(ContextMenuItem, { onSelect: () => onEdit(bot), children: 'Edit Profile' }),
jsx(ContextMenuItem, {
onSelect: () => onGroup(bot),
children: meta?.group ? `Group: ${meta.group}…` : 'Move to group…'
}),
!bot.remoteSource
? jsx(ContextMenuItem, {
onSelect: () => onGroup(bot),
children: groups.length ? `Groups: ${groups.join(', ')}…` : 'Manage groups…'
})
: null,
jsx(ContextMenuItem, {
onSelect: () => {
host.notify({ kind: 'info', message: `Duplicating ${displayName(bot, meta)}…` })
@@ -7247,24 +7309,23 @@ function ActiveNowStrip({ roster, activeProfile, gatewayState, metaByName, onOpe
})
}
/** Assign a bot to a group (or clear it). Existing groups are one-click;
* the input creates a new one. The group is a bot-meta field, so it syncs
* cross-machine via ui_meta like pin/title. */
/** Manage a bot's group-chat memberships. Existing groups are independent
* toggles; the input creates and joins a new one. The canonical groups array
* and legacy scalar projection both ride ui_meta like pin/title. */
function GroupDialog({ bot, onClose }) {
const meta = useValue($botMeta)
const [name, setName] = useState('')
const current = (meta[bot?.name]?.group || '').trim()
const current = botGroups(meta[bot?.name])
const groups = knownGroups(meta)
const assign = group => {
saveBotMeta(bot.name, { group: group || null })
const setMembership = (group, enabled) => {
saveBotMeta(bot.name, groupMembershipPatch(meta[bot.name], group, enabled))
host.notify({
kind: 'info',
message: group
? `${displayName(bot, meta[bot.name])} moved to “${group}”`
: `${displayName(bot, meta[bot.name])} removed from its group`
message: enabled
? `${displayName(bot, meta[bot.name])} added to “${group}”`
: `${displayName(bot, meta[bot.name])} removed from “${group}”`
})
onClose()
}
return jsx(Dialog, {
@@ -7279,23 +7340,34 @@ function GroupDialog({ bot, onClose }) {
children: [
jsxs(DialogHeader, {
children: [
jsx(DialogTitle, { children: 'Move to group' }),
jsx(DialogTitle, { children: 'Manage groups' }),
jsx(DialogDescription, {
children: 'Groups render as labeled sections in the Bots roster and sync to every machine.'
children: 'A bot can join multiple group chats. Memberships sync to every machine.'
})
]
}),
groups.length
? jsx('div', {
className: 'flex flex-wrap gap-1.5',
children: groups.map(group =>
jsx(Button, {
variant: group === current ? 'default' : 'secondary',
size: 'sm',
onClick: () => assign(group),
children: group
}, group)
)
className: 'grid gap-1.5',
children: groups.map(group => {
const enabled = current.includes(group)
return jsxs(
'label',
{
className:
'flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-(--chrome-action-hover)',
children: [
jsx(Checkbox, {
checked: enabled,
onCheckedChange: checked => setMembership(group, checked === true)
}),
jsx('span', { children: group })
]
},
group
)
})
})
: null,
jsxs('form', {
@@ -7305,7 +7377,8 @@ function GroupDialog({ bot, onClose }) {
const trimmed = name.trim()
if (trimmed) {
assign(trimmed)
setMembership(trimmed, true)
setName('')
}
},
children: [
@@ -7315,16 +7388,16 @@ function GroupDialog({ bot, onClose }) {
value: name,
onChange: event => setName(event.target.value)
}),
jsx(Button, { type: 'submit', size: 'sm', disabled: !name.trim(), children: 'Create' })
jsx(Button, { type: 'submit', size: 'sm', disabled: !name.trim(), children: 'Create & join' })
]
}),
current
current.length
? jsx(Button, {
variant: 'ghost',
size: 'sm',
className: 'justify-self-start',
onClick: () => assign(null),
children: `Remove from “${current}”`
onClick: () => saveBotMeta(bot.name, { groups: [], group: null }),
children: 'Remove from all groups'
})
: null
]
@@ -7333,9 +7406,9 @@ function GroupDialog({ bot, onClose }) {
}
/** Discord-style group chat creation: pick 2+ bots via checkboxes (with
* search), name the group, create. Assignment is the existing per-bot
* `group` meta field, so the room appears in the roster and syncs
* cross-machine via ui_meta exactly like Move-to-group. */
* search), name the group, create. Assignment appends to each local bot's
* group membership list, so the room appears in the roster and syncs
* cross-machine via ui_meta without replacing its other groups. */
function CreateGroupChatDialog({ open, roster, onClose, onCreated }) {
const allMeta = useValue($botMeta)
const [query, setQuery] = useState('')
@@ -7393,7 +7466,7 @@ function CreateGroupChatDialog({ open, roster, onClose, onCreated }) {
for (const bot of selected) {
if (!bot.remoteSource) {
void saveBotMeta(bot.name, { group: groupName })
void saveBotMeta(bot.name, groupMembershipPatch(botRosterMeta(bot, allMeta), groupName, true))
}
}
@@ -7426,7 +7499,7 @@ function CreateGroupChatDialog({ open, roster, onClose, onCreated }) {
children: [
jsx(DialogTitle, { children: 'New Group Chat' }),
jsx(DialogDescription, {
children: `Pick 2–${GROUP_CHAT_MAX_MEMBERS} bots. The room lives in the Bots roster and syncs to every machine.`
children: `Pick 2–${GROUP_CHAT_MAX_MEMBERS} bots. Local memberships sync through each Bot profile; cross-machine members stay scoped to this room.`
})
]
}),
@@ -7464,7 +7537,7 @@ function CreateGroupChatDialog({ open, roster, onClose, onCreated }) {
const { shape, color, image } = botAppearance(bot.name, meta)
const isChecked = Boolean(checked[botRosterKey(bot)])
const disabled = !isChecked && atCap
const currentGroup = (meta?.group || '').trim()
const currentGroups = botGroups(meta)
return jsxs('label', {
className: cn(
@@ -7486,8 +7559,8 @@ function CreateGroupChatDialog({ open, roster, onClose, onCreated }) {
jsx('div', {
className: 'truncate text-[0.625rem] text-(--ui-text-quaternary)',
children: [
currentGroup
? `@${botHandle(bot.name, bot)} · in “${currentGroup}”`
currentGroups.length
? `@${botHandle(bot.name, bot)} · in ${currentGroups.map(group => `“${group}”`).join(', ')}`
: `@${botHandle(bot.name, bot)}`,
bot.remoteSource && bot.connectionLabel ? ` · ${bot.connectionLabel}` : ''
].join('')
@@ -7771,7 +7844,7 @@ function GroupChatWorkspace({ group, members, onBack }) {
doneLabel: 'Disbanded',
onClose: () => setConfirmDisband(false),
onConfirm: async () => {
await disbandGroupChat(group, members.map(bot => bot.name))
await disbandGroupChat(group, members)
host.notify({ kind: 'success', message: `Disbanded “${group}”` })
}
})

View File

@@ -90,7 +90,7 @@ 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, /\}, botRosterKey\(bot\)\)\s*\}\)\s*\]\s*\}\)\s*\}\s*\/\*\* Assign a bot to a group/s)
assert.match(source, /\}, botRosterKey\(bot\)\)\s*\}\)\s*\]\s*\}\)\s*\}\s*\/\*\* Manage a bot's group-chat memberships/s)
assert.match(source, /jsx\(BotFace,\s*\{[\s\S]*?mood: 'work'/)
assert.match(source, /let pinnedChat = botRosterMeta\(bot, allMeta\)\?\.chat/)
assert.match(source, /await prepareBotSource\(bot, pinnedChat\)/)

View File

@@ -68,6 +68,25 @@ test('regression: authoritative server metadata removes stale local canonical ch
assert.equal(Object.hasOwn(writes.at(-1).value.default, 'chat'), false)
})
test('regression: authoritative groups remove a stale local legacy group projection', () => {
const writes = []
const sync = load()
sync.set({ researcher: { groups: ['Old'], group: 'Old', title: 'Researcher' } })
sync.setPluginCtx({ storage: { set: (key, value) => writes.push({ key, value }) } })
sync.mergeServerMeta([
{
name: 'researcher',
ui_meta: { 'hermes-bots': { groups: [], title: 'Researcher' } }
}
])
const current = sync.get().researcher
assert.deepEqual(current.groups, [])
assert.equal(Object.hasOwn(current, 'group'), false)
assert.equal(Object.hasOwn(writes.at(-1).value.researcher, 'group'), false)
})
test('compatibility: local canonical chat survives when gateway has no server bot metadata', () => {
const writes = []
const sync = load()

View File

@@ -21,10 +21,16 @@ test('source contract: create-group modal has search, checkboxes, name, create',
assert.match(pluginSource, /const visible = filterBots\(roster, allMeta, query\)/)
// Selection is checkbox-driven and capped at the room member limit.
assert.match(pluginSource, /const atCap = selected\.length >= GROUP_CHAT_MAX_MEMBERS/)
// Create requires 2+ members and writes the existing group meta field,
// so the room rides the ui_meta sync path (no new persistence).
// Create requires 2+ members and appends to the canonical multi-group field
// without replacing existing memberships.
assert.match(pluginSource, /selected\.length >= 2/)
assert.match(pluginSource, /saveBotMeta\(bot\.name, \{ group: groupName \}\)/)
assert.match(
pluginSource,
/groupMembershipPatch\(botRosterMeta\(bot, allMeta\), groupName, true\)/
)
// Existing membership labels enumerate every canonical group.
assert.match(pluginSource, /const currentGroups = botGroups\(meta\)/)
assert.match(pluginSource, /currentGroups\.map\(group => `“\$\{group\}”`\)\.join\(', '\)/)
// Creating drops the user straight into the room (main window when the
// desktop offers host.openWorkspace, in-panel fallback otherwise).
assert.match(pluginSource, /onCreated: groupName => openGroupChat\(groupName\)/)

View File

@@ -252,7 +252,7 @@ test('source contract: workspace + main-window door + prompt rules are wired', (
assert.match(pluginSource, /\[Group chat: "\$\{groupName\}"\]/)
})
test('disband: clears grouping meta, room log, workspace, needs-you; keeps sessions in storage map only for other rooms', async () => {
test('disband: removes only this membership, room log, workspace, and needs-you state', async () => {
const gc = load(() => '(pass)')
// Two rooms; disband one.
@@ -265,11 +265,21 @@ test('disband: clears grouping meta, room log, workspace, needs-you; keeps sessi
await new Promise(resolve => setImmediate(resolve))
}
gc.$botMeta.set({ builder: { group: 'Gone' }, research: { group: 'Keep' } })
const rooms = { ...gc.$groupChats.get() }
rooms.Keep = {
...rooms.Keep,
members: [{ name: 'remote', remoteSource: true, sourceScoped: true, connectionId: 'remote-1' }]
}
gc.$groupChats.set(rooms)
gc.$botMeta.set({
builder: { groups: ['Gone', 'Keep'], group: 'Gone' },
research: { groups: ['Keep'], group: 'Keep' }
})
gc.$groupChatWorkspace.set('Gone')
gc.$groupNeedsYou.set({ Gone: true, Keep: true })
await gc.disbandGroupChat('Gone', ['builder'])
await gc.disbandGroupChat('Gone', [{ name: 'builder' }])
// Room state: gone from the atom (no running drive, so no tombstone).
assert.equal(gc.$groupChats.get().Gone, undefined)
@@ -278,13 +288,30 @@ test('disband: clears grouping meta, room log, workspace, needs-you; keeps sessi
assert.equal(gc.$groupChatWorkspace.get(), null)
assert.equal(gc.$groupNeedsYou.get().Gone, undefined)
assert.equal(gc.$groupNeedsYou.get().Keep, true)
// Members ungrouped; other bots keep their group.
assert.equal(gc.$botMeta.get().builder.group, null)
// Disband removes only this membership; other groups survive.
assert.equal(JSON.stringify(gc.$botMeta.get().builder.groups), JSON.stringify(['Keep']))
assert.equal(gc.$botMeta.get().builder.group, 'Keep')
assert.equal(JSON.stringify(gc.$botMeta.get().research.groups), JSON.stringify(['Keep']))
assert.equal(gc.$botMeta.get().research.group, 'Keep')
// Persisted room map no longer carries the room.
const durable = gc.storageWrites.get('group-chats')
assert.ok(durable && !('Gone' in durable), 'disbanded room not persisted')
assert.ok('Keep' in durable, 'surviving room still persisted')
assert.equal(durable.Keep.members.length, 1, 'surviving room keeps remote member descriptors')
assert.equal(durable.Keep.members[0].connectionId, 'remote-1')
})
test('disband: skips source-qualified remote members instead of mutating same-named local metadata', async () => {
const gc = load(() => '(pass)')
gc.$botMeta.set({ builder: { groups: ['Keep'], group: 'Keep' } })
await gc.disbandGroupChat('Remote', [
{ name: 'builder', remoteSource: true, sourceScoped: true, connectionId: 'remote-1' }
])
assert.equal(JSON.stringify(gc.$botMeta.get().builder.groups), JSON.stringify(['Keep']))
assert.equal(gc.$botMeta.get().builder.group, 'Keep')
assert.equal(gc.$botMeta.get()['[object Object]'], undefined)
})
test('disband: a running room leaves an epoch-bumped empty tombstone so in-flight turns bail', async () => {
@@ -300,7 +327,7 @@ test('disband: a running room leaves an epoch-bumped empty tombstone so in-fligh
rooms.Live = { ...rooms.Live, running: true, epoch: 3 }
gc.$groupChats.set(rooms)
await gc.disbandGroupChat('Live', ['research'])
await gc.disbandGroupChat('Live', [{ name: 'research' }])
const tomb = gc.$groupChats.get().Live
assert.ok(tomb, 'tombstone present while a drive is mid-turn')

View File

@@ -44,6 +44,7 @@ function renderBotRow(input = 'alpha') {
$lastRoster: atom([]),
$selectedBot: atom('default'),
botAppearance: () => ({ shape: 'round', color: '#000', image: null }),
botGroups: () => [],
botHandle: value => value,
botRosterMeta: (_bot, metaByName) => metaByName?.[_bot.name] ?? null,
cn: (...values) => values.filter(Boolean).join(' '),
@@ -169,6 +170,7 @@ test('behavior: remote default does not open this-device chat when the source di
$lastRoster: atom([]),
$selectedBot: atom('default'),
botAppearance: () => ({ shape: 'round', color: '#000', image: null }),
botGroups: () => [],
botHandle: value => value,
botRosterMeta: () => null,
cn: (...values) => values.filter(Boolean).join(' '),

View File

@@ -30,15 +30,54 @@ function load() {
.replace(/^import .* from 'react\/jsx-runtime'\r?\n/m, '')
.replace('export default {', 'globalThis.plugin = {')
.concat(
'\nglobalThis.__groups = { groupChatNames, groupLastActivity, groupChatMemberBots, durableGroupChatMembers, knownGroups, stripPreviewMarkdown, $groupChats };\n'
'\nglobalThis.__groups = { botGroups, groupMembershipPatch, groupChatNames, groupLastActivity, groupChatMemberBots, durableGroupChatMembers, knownGroups, stripPreviewMarkdown, $groupChats };\n'
)
vm.runInNewContext(source, context, { filename: 'plugin.js' })
return context.__groups
}
test('botGroups: normalizes canonical and legacy membership without duplicates', () => {
const { botGroups } = load()
assert.equal(
JSON.stringify(
botGroups({ groups: [' Engineering ', '', 'Research', 'Engineering', null, 7, { name: 'Nope' }], group: 'Operations' })
),
JSON.stringify(['Engineering', 'Research'])
)
assert.equal(JSON.stringify(botGroups({ group: 'Legacy' })), JSON.stringify(['Legacy']))
assert.equal(JSON.stringify(botGroups({ groups: [] })), JSON.stringify([]))
})
test('groupMembershipPatch: toggles one membership and keeps the legacy projection compatible', () => {
const { groupMembershipPatch } = load()
const meta = { groups: ['Engineering', 'Research'], group: 'Engineering' }
assert.equal(
JSON.stringify(groupMembershipPatch(meta, 'Engineering', true)),
JSON.stringify({ groups: ['Engineering', 'Research'], group: 'Engineering' })
)
assert.equal(
JSON.stringify(groupMembershipPatch(meta, 'Operations', true)),
JSON.stringify({ groups: ['Engineering', 'Research', 'Operations'], group: 'Engineering' })
)
assert.equal(
JSON.stringify(groupMembershipPatch(meta, 'Engineering', false)),
JSON.stringify({ groups: ['Research'], group: 'Research' })
)
assert.equal(
JSON.stringify(groupMembershipPatch({ group: 'Legacy' }, 'Legacy', false)),
JSON.stringify({ groups: [], group: null })
)
})
test('groupChatNames: unions bot-meta groups with room records that carry members or log', () => {
const { groupChatNames } = load()
const meta = { researcher: { group: 'Research' }, pm: { group: 'Ops' } }
const meta = {
researcher: { group: 'Research' },
pm: { groups: ['Ops', 'Research'], group: 'Ops' },
scout: { groups: ['External'], group: 'Stale' }
}
const rooms = {
Research: { log: [], members: [] }, // already known via meta
Remote: { log: [], members: [{ name: 'spark', remoteSource: true }] },
@@ -48,7 +87,10 @@ test('groupChatNames: unions bot-meta groups with room records that carry member
const names = groupChatNames(meta, rooms)
assert.equal(JSON.stringify([...names].sort()), JSON.stringify(['Chatty', 'Ops', 'Remote', 'Research']))
assert.equal(
JSON.stringify([...names].sort()),
JSON.stringify(['Chatty', 'External', 'Ops', 'Remote', 'Research'])
)
})
test('groupLastActivity: newest room-log timestamp, 0 for silence', () => {
@@ -75,12 +117,12 @@ test('groupChatMemberBots: seats local meta members plus stored remote descripto
const members = groupChatMemberBots('Research', roster, {
researcher: { group: 'Research' },
builder: { group: 'Ops' }
builder: { groups: ['Ops', 'Research'], group: 'Ops' }
})
assert.equal(JSON.stringify(members.map(m => m.name)), JSON.stringify(['researcher', 'spark']))
assert.equal(JSON.stringify(members.map(m => m.name)), JSON.stringify(['researcher', 'builder', 'spark']))
// The LIVE roster row was preferred over the stored descriptor.
assert.equal(members[1], roster[2])
assert.equal(members[2], roster[2])
})
test('durableGroupChatMembers: retains active and remote source identities', () => {
@@ -128,13 +170,13 @@ test('knownGroups: unique, trimmed, alphabetical', () => {
const groups = knownGroups({
a: { group: 'research' },
b: { group: 'Ops' },
c: { group: 'research' },
b: { groups: ['Ops', 'research'], group: 'Ops' },
c: { groups: ['Design'] },
d: { group: '' },
e: {}
})
assert.equal(JSON.stringify(groups), JSON.stringify(['Ops', 'research']))
assert.equal(JSON.stringify(groups), JSON.stringify(['Design', 'Ops', 'research']))
})
test('stripPreviewMarkdown: flattens bold, quotes, code, and links out of previews', () => {
@@ -147,13 +189,16 @@ test('stripPreviewMarkdown: flattens bold, quotes, code, and links out of previe
assert.equal(stripPreviewMarkdown(''), '')
})
test('source contract: the roster is a flat list of bot + group rows and the row menu offers grouping', () => {
// Flat Discord-style list — the sectioned groupRoster presentation is gone.
test('source contract: the roster stays flat while the row menu manages multiple groups', () => {
// Ordering is deliberately unchanged in this PR; sectioned ordering follows separately.
assert.doesNotMatch(pluginSource, /function groupRoster\(/)
assert.match(pluginSource, /rosterRows\.map\(row =>/)
assert.match(pluginSource, /function GroupRow\(/)
assert.match(pluginSource, /onGroup: setGrouping/)
assert.match(pluginSource, /'Move to group…'/)
assert.match(pluginSource, /'Manage groups…'/)
assert.match(pluginSource, /!bot\.remoteSource\s*\? jsx\(ContextMenuItem, \{\s*onSelect: \(\) => onGroup\(bot\)/)
assert.match(pluginSource, /botGroups\(meta\[bot\?\.name\]\)/)
assert.match(pluginSource, /groupMembershipPatch\(meta\[bot\.name\], group, enabled\)/)
})
test('source contract: group rows carry the needs-you badge and open via openGroupChat', () => {

View File

@@ -75,12 +75,14 @@ Routines are plain [Hermes cron jobs](./features/cron.md) namespaced `[bot:<name
## Groups and group chats
Right-click a Bot → **Move to group** to organize the roster into labeled sections — pick an existing group or create one inline. Ungrouped Bots stay on top; groups follow alphabetically, and a group disappears when its last member leaves.
Right-click a local Bot → **Manage groups** to add or remove it from any number of group chats. Pick existing groups independently or create one inline. Local membership is stored in the Bot's backend-synced profile metadata, so it follows that profile across desktops; older profiles with one legacy group continue to work. Connections Bots join through the New Group Chat picker and remain source-qualified in that room's local Desktop state.
**Open chat** on any group header (2–6 Bots) opens a shared room where the whole group coordinates:
Groups are standalone rows in the same activity-ordered roster as Bot DMs. A Bot keeps one DM row even when it belongs to several groups, while every group gets its own room row with member count, latest-message preview, timestamp, and needs-you state.
**Open chat** on any group row (2–6 Bots) opens a shared room where the whole group coordinates:
- Your message triggers up to **three serial rounds** of member turns. @-mentioned Bots respond (everyone responds when nobody is mentioned); each Bot replies briefly or passes, and the room settles when a full round stays silent.
- Bots pull each other in with `@name`, and escalate real judgment calls to you with `@user` — the group header shows a **needs you** badge when that happens.
- Bots pull each other in with `@name`, and escalate real judgment calls to you with `@user` — the group row shows a **needs you** badge when that happens.
- Hard caps (10 messages per send, 3 rounds) keep rooms from spinning.
- Each member keeps its own persistent `Group: <name>` session, so room context survives like any other conversation.
- **Not every Bot replies to every message.** Speaking is each member's own choice — a Bot replies only when it has something new to add and passes otherwise, and @-mentioning specific members scopes the round to them. Expect the members you addressed (or whoever has something to say) to speak, and the rest to stay quiet.