feat(bot-mode): support multiple groups per bot
This commit is contained in:
@@ -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}”` })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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\)/)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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\)/)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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(' '),
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user