refactor: extract group member phases and roster presentation

This commit is contained in:
Teknium
2026-09-08 12:24:09 -07:00
parent e1c8764d18
commit f9993d5be2
14 changed files with 2005 additions and 1339 deletions
@@ -43,8 +43,8 @@ vi.mock('@hermes/plugin-sdk', async importOriginal => {
const { botBackendProfileScope, botConnectionRoute, requestForBot } = await import('./routing')
const { groupMemberKey } = await import('./group-membership')
const { buildGroupChatTurnPrompt, formatGroupChatLine, parseGroupChatMentions, resolveGroupResponders } =
await import('./group-rounds')
const { buildGroupChatTurnPrompt, formatGroupChatLine } = await import('./group-round-prompt')
const { parseGroupChatMentions, resolveGroupResponders } = await import('./group-rounds')
const aliasBot = {
name: 'worker',
@@ -102,8 +102,9 @@ describe('room naming', () => {
describe('speaker labels', () => {
it('the default profile speaks as Hermes in transcripts, not @default', async () => {
const { rounds } = await loadRoom()
const { formatGroupChatLine } = await import('./group-round-prompt')
const line = rounds.formatGroupChatLine(
const line = formatGroupChatLine(
{ from: { kind: 'member', name: 'default' }, text: 'hello room' } as GroupMessage,
'builder'
)
@@ -112,15 +113,16 @@ describe('speaker labels', () => {
// Other members keep their profile name; the (you) suffix survives.
expect(
rounds.formatGroupChatLine({ from: { kind: 'member', name: 'default' }, text: 'hi' } as GroupMessage, 'default')
formatGroupChatLine({ from: { kind: 'member', name: 'default' }, text: 'hi' } as GroupMessage, 'default')
).toBe('Hermes (you): hi')
expect(
rounds.formatGroupChatLine({ from: { kind: 'member', name: 'builder' }, text: 'yo' } as GroupMessage, 'research')
formatGroupChatLine({ from: { kind: 'member', name: 'builder' }, text: 'yo' } as GroupMessage, 'research')
).toBe('builder: yo')
})
it('honor friendly identity: Bot Mode title, then display_name, never a stale Hermes', async () => {
const { chat, rounds } = await loadRoom()
const { formatGroupChatLine } = await import('./group-round-prompt')
const data = await import('./data')
// A renamed default (core display_name via `hermes profile rename`) must
@@ -130,7 +132,7 @@ describe('speaker labels', () => {
expect(chat.groupSpeakerLabel('default')).toBe('Lucy')
expect(
rounds.formatGroupChatLine({ from: { kind: 'member', name: 'default' }, text: 'hi' } as GroupMessage, 'builder')
formatGroupChatLine({ from: { kind: 'member', name: 'default' }, text: 'hi' } as GroupMessage, 'builder')
).toBe('Lucy: hi')
// A Bot Mode title outranks display_name (same precedence as displayName).
@@ -0,0 +1,377 @@
import { clearBotAttention, noteBotAttention } from './data'
import { recordGroupActivity } from './group-activity'
import {
$groupChats,
appendGroupChatEntry,
GROUP_CHAT_HISTORY_LIMIT,
GROUP_CHAT_MAX_CONTINUATIONS,
GROUP_CHAT_MAX_MESSAGES,
groupThreadOf,
shouldCommitMemberTurn,
updateGroupChat
} from './group-chat'
import type { GroupChatRoom } from './group-chat'
import { groupMemberKey } from './group-membership'
import { buildGroupChatTurnPrompt, formatGroupChatLine } from './group-round-prompt'
import { isGroupPassText, runGroupChatMemberTurn } from './group-turns'
import type { GroupMember, GroupMessage } from './types'
export interface GroupRoundMemberContext {
group: string
members: GroupMember[]
thread: string
startEpoch: number
binding: { isLive(): boolean }
isCurrent(): boolean
}
/** #93129: a held member's skip must consume its delta exactly once —
* advance the watermark past the current log so the same entries never
* re-trigger the skip. Null = nothing to consume (no write, no spin). */
export function heldMemberWatermarkAdvance(seen: number | undefined, logLength: number): null | number {
return logLength > (seen || 0) ? logLength : null
}
function prepareGroupRoundMember(context: GroupRoundMemberContext, member: GroupMember) {
const { members, thread } = context
const room = $groupChats.get()[context.group] || {
log: [],
watermarks: {}
}
const memberKey = groupMemberKey(member)
const markKey = `${thread}::${memberKey}`
const seen = room.watermarks[markKey] || 0
// Delta: NEW room entries, narrowed to this thread — the member's
// turn sees only the conversation it's part of.
const delta = room.log.slice(seen).filter((e: GroupMessage) => groupThreadOf(e) === thread)
if (!delta.length) {
return null
}
// #93129: a member the user told to stop is HELD — no turn until an
// explicit release (resume / @all resume / a direct non-stop
// mention). Consume the delta exactly once (watermark past the
// current log) so the same entries never re-trigger this skip, and
// surface WHY the bot is silent in the activity feed the first time.
const heldEntry = (room.holds || {})[memberKey]
if (heldEntry) {
const advance = heldMemberWatermarkAdvance(seen, room.log.length)
updateGroupChat(context.group, (r: GroupChatRoom) => {
if (advance !== null) {
r.watermarks[markKey] = advance
}
if (r.holds?.[memberKey] && !r.holds[memberKey].noted) {
r.holds = {
...r.holds,
[memberKey]: {
...r.holds[memberKey],
noted: true
}
}
}
return r
})
if (!heldEntry.noted) {
recordGroupActivity(context.group, {
kind: 'held',
member: member.name,
thread
})
}
return null
}
const prompt = buildGroupChatTurnPrompt({
groupName: context.group,
members,
viewer: member,
deltaLines: delta.slice(-GROUP_CHAT_HISTORY_LIMIT).map((e: GroupMessage) => formatGroupChatLine(e, member.name))
})
// Images riding this delta (user attachments — member entries don't
// carry images today, but flatMap keeps this future-proof) get staged
// into the member's session so the model sees the pixels, not just
// the transcript's [attached image: …] marker.
const deltaImages = delta.flatMap((e: GroupMessage) => (Array.isArray(e.images) ? e.images : []))
return { room, memberKey, markKey, prompt, deltaImages }
}
export async function runGroupRoundMember(
context: GroupRoundMemberContext,
member: GroupMember
): Promise<boolean | null> {
const { thread, startEpoch, binding } = context
const prepared = prepareGroupRoundMember(context, member)
if (!prepared) {
return false
}
const { room, markKey, prompt, deltaImages } = prepared
// Surface WHO is on turn (runtime-only, like running/epoch) so the
// room shows "Radar is thinking…" instead of a generic working line —
// long model turns otherwise read as the room being stuck.
updateGroupChat(context.group, (r: GroupChatRoom) => {
r.turn = member.name
return r
})
let reply: null | string = null
try {
reply = await runGroupChatMemberTurn(context.group, member, prompt, thread, deltaImages)
// Needs-attention hook (#93091 item 3): a turn that produced a real
// reply (or an explicit pass) is a good turn — clear the badge.
// A timed-out turn also returns null but never threw; leaving any
// prior badge in place there is the conservative choice.
if (reply !== null) {
clearBotAttention(groupMemberKey(member))
}
} catch (error: any) {
if (!binding.isLive()) {
return null
}
const reason = String(error?.data?.reason || '').trim()
recordGroupActivity(context.group, {
kind: 'failed',
member: member.name,
thread,
...(reason
? {
reason
}
: {})
})
noteBotAttention(groupMemberKey(member), reason || error?.message || error)
reply = null // a failed turn is a pass, never a room error
}
// #93127: the turn may have finished AFTER a newer user send bumped
// the room epoch. That newer send's loop re-drives this member with
// the full delta, so committing this stale result (watermark advance
// + append) would double-deliver the same reply. Drop it here —
// BEFORE the watermark advance and BEFORE the append. Only a newer
// USER entry in THIS thread makes the re-drive premise true: a
// cross-thread send bumps the epoch too, but its loop filters this
// thread out and would never regenerate the finished reply. The
// during-turn tail is anchored by entry id, not index — the history
// trim drops entries from the FRONT, so an index slice could
// overshoot after a mid-turn trim and silently commit a stale turn.
if (!binding.isLive()) {
return null
}
const roomNow = $groupChats.get()[context.group] || {
log: []
}
const epochNow = roomNow.epoch || 0
const anchorId = room.log.length ? room.log[room.log.length - 1].id : null
const anchorIdx = anchorId === null ? -1 : roomNow.log.findIndex((e: GroupMessage) => e.id === anchorId)
// Anchor trimmed away ⇒ every pre-turn entry was dropped, so every
// surviving entry is newer — scanning the whole log stays exact.
const turnTail = anchorIdx >= 0 ? roomNow.log.slice(anchorIdx + 1) : roomNow.log
const newerUserEntryInThread = turnTail.some(
(e: GroupMessage) => e.from?.kind === 'user' && groupThreadOf(e) === thread
)
if (!shouldCommitMemberTurn(startEpoch, epochNow, newerUserEntryInThread)) {
recordGroupActivity(context.group, {
kind: 'cancelled',
member: member.name,
thread
})
return null
}
// The member has now seen everything up to the pre-reply log length.
updateGroupChat(context.group, (r: GroupChatRoom) => {
r.watermarks[markKey] = r.log.length
return r
})
if (reply !== null && !isGroupPassText(reply)) {
appendGroupChatEntry(
context.group,
{
kind: 'member',
name: member.name,
...(member.remoteSource
? {
source: member.connectionLabel || member.connectionId
}
: {})
},
reply,
thread
)
// Its own message counts as seen too.
updateGroupChat(context.group, (r: GroupChatRoom) => {
r.watermarks[markKey] = r.log.length
return r
})
return true
}
return false
}
async function runGroupContinuationMember(
context: GroupRoundMemberContext,
member: GroupMember
): Promise<boolean | null> {
const { members, thread, binding, isCurrent } = context
const room = $groupChats.get()[context.group] || {
log: [],
watermarks: {}
}
const memberKey = groupMemberKey(member)
const markKey = `${thread}::${memberKey}`
const seen = room.watermarks[markKey] || 0
const delta = room.log.slice(seen).filter((e: GroupMessage) => groupThreadOf(e) === thread)
// A cited member always has delta here (the citing reply IS in
// its tail); skip defensively anyway so an empty prompt never
// fires.
if (!delta.length) {
return false
}
const heldEntry = (room.holds || {})[memberKey]
if (heldEntry) {
return false // holds still apply to continuation turns (#93129)
}
const prompt = buildGroupChatTurnPrompt({
groupName: context.group,
members,
viewer: member,
// The continuation prompt centers on what the member missed:
// everything since its watermark, which includes the reply
// that cites it.
deltaLines: delta.slice(-GROUP_CHAT_HISTORY_LIMIT).map((e: GroupMessage) => formatGroupChatLine(e, member.name))
})
updateGroupChat(context.group, (r: GroupChatRoom) => {
r.turn = member.name
return r
})
let continuationReply: null | string = null
try {
continuationReply = await runGroupChatMemberTurn(context.group, member, prompt, thread)
if (continuationReply !== null) {
clearBotAttention(memberKey)
}
} catch (error: any) {
if (!binding.isLive()) {
return null
}
recordGroupActivity(context.group, {
kind: 'failed',
member: member.name,
thread
})
noteBotAttention(memberKey, error?.message || error)
continuationReply = null
}
if (!isCurrent()) {
return null
}
updateGroupChat(context.group, (r: GroupChatRoom) => {
r.watermarks[markKey] = r.log.length
return r
})
if (continuationReply !== null && !isGroupPassText(continuationReply)) {
appendGroupChatEntry(
context.group,
{
kind: 'member',
name: member.name,
...(member.remoteSource
? {
source: member.connectionLabel || member.connectionId
}
: {})
},
continuationReply,
thread
)
updateGroupChat(context.group, (r: GroupChatRoom) => {
r.watermarks[markKey] = r.log.length
return r
})
return true
}
return false
}
export async function runGroupContinuationMembers(
context: GroupRoundMemberContext,
pendingKeys: string[],
continuations: number,
posted: number
): Promise<number | null> {
const { members, isCurrent } = context
let spokeThisRound = 0
if (pendingKeys.length && continuations <= GROUP_CHAT_MAX_CONTINUATIONS) {
const citedMembers = members.filter((member: GroupMember) => pendingKeys.includes(groupMemberKey(member)))
if (citedMembers.length && posted < GROUP_CHAT_MAX_MESSAGES) {
const strandedNow = ($groupChats.get()[context.group] || {}).stranded || {}
const continuationResponders = citedMembers.filter(
(member: GroupMember) => !Object.prototype.hasOwnProperty.call(strandedNow, groupMemberKey(member))
)
for (const member of continuationResponders) {
if (!isCurrent() || posted >= GROUP_CHAT_MAX_MESSAGES || continuations > GROUP_CHAT_MAX_CONTINUATIONS) {
break
}
const result = await runGroupContinuationMember(context, member)
if (result === null) {
return null
}
if (result) {
posted += 1
spokeThisRound += 1
}
}
}
}
return spokeThisRound
}
@@ -0,0 +1,68 @@
import { botHandle } from './data'
import { groupSpeakerLabel } from './group-chat'
import { groupMemberKey } from './group-membership'
import type { GroupMember, GroupMessage } from './types'
/** Room-log line as a member sees it: `Name (user): …` / `Name: …` /
* `Name (you): …`. */
export function formatGroupChatLine(entry: GroupMessage, viewerName: string) {
// Attachments are staged into each member's session as real payloads; the
// transcript line names them so the delta text and the bytes line up.
const attached =
Array.isArray(entry.images) && entry.images.length
? ` ${entry.images
.map(img => {
const label = img.kind === 'pdf' ? 'attached PDF' : img.kind === 'file' ? 'attached file' : 'attached image'
return `[${label}: ${img.name || 'image'}]`
})
.join(' ')}`
: ''
if (entry.from.kind === 'user') {
return `${entry.from.name || 'User'} (user): ${entry.text}${attached}`
}
const suffix = entry.from.name === viewerName ? ' (you)' : ''
// Cross-connection speakers carry their device so same-named agents on
// two machines stay tellable apart in every member's transcript.
const source = entry.from.source ? ` [${entry.from.source}]` : ''
return `${groupSpeakerLabel(entry.from.name)}${suffix}${source}: ${entry.text}${attached}`
}
interface GroupChatTurnPromptInput {
deltaLines: string[]
groupName: string
members: GroupMember[]
viewer: GroupMember
}
/** The full per-turn payload for one member: participation rules + the room
* delta. Rules travel in the turn payload (not SOUL) so every existing bot
* can join a group chat without a profile migration. */
export function buildGroupChatTurnPrompt({ groupName, members, viewer, deltaLines }: GroupChatTurnPromptInput) {
const viewerKey = groupMemberKey(viewer)
const peers = members.filter(m => groupMemberKey(m) !== viewerKey)
const peerNames = peers
.map(m => {
const handle = m.title ? `${m.title} (@${botHandle(m.name, m)})` : `@${botHandle(m.name, m)}`
return m.remoteSource ? `${handle} [on ${m.connectionLabel || m.connectionId}]` : handle
})
.join(', ')
return [
`[Group chat: "${groupName}"] You are @${botHandle(viewer.name, viewer)}, one participant in a group chat with ${peerNames || 'no one else yet'} and the user.`,
'',
'New messages in the room since your last turn (oldest first):',
...deltaLines.map(line => ` ${line}`),
'',
'Rules for this room:',
'- Reply with ONE conversational message ONLY if you have something new worth adding: build on what was just said, claim or hand off work, answer a question aimed at you, or report a real result. Keep chatter short (1-3 sentences) — but when you are delivering a result, an answer the user asked for, or substantive work, give it at full quality and length; never thin out real content to fit the room.',
'- If you have nothing new to add, reply with exactly "(pass)". Passing is good — it lets the conversation settle.',
'- Mention a teammate as @name to pull them in; mention @user only for a judgment call or a result the user needs. Do not repeat points already made.',
'- Never reveal content from your private 1:1 chats. Your reply text goes to the room verbatim — no preamble, no meta-commentary.'
].join('\n')
}
@@ -349,13 +349,14 @@ describe('threads', () => {
describe('turn prompt', () => {
it('addresses the default profile as @hermes', async () => {
const { rounds } = await loadRoom()
const { buildGroupChatTurnPrompt } = await import('./group-round-prompt')
const members: GroupMember[] = [
{ name: 'default', title: '' },
{ name: 'builder', title: '' }
]
const own = rounds.buildGroupChatTurnPrompt({
const own = buildGroupChatTurnPrompt({
deltaLines: [],
groupName: 'Core',
members,
@@ -365,7 +366,7 @@ describe('turn prompt', () => {
expect(own).toMatch(/You are @hermes,/)
expect(own).not.toMatch(/@default\b/)
const peer = rounds.buildGroupChatTurnPrompt({
const peer = buildGroupChatTurnPrompt({
deltaLines: [],
groupName: 'Core',
members,
@@ -377,8 +378,9 @@ describe('turn prompt', () => {
it('asks for full-quality results and short chatter, not short results', async () => {
const { rounds } = await loadRoom()
const { buildGroupChatTurnPrompt } = await import('./group-round-prompt')
const prompt = rounds.buildGroupChatTurnPrompt({
const prompt = buildGroupChatTurnPrompt({
deltaLines: [],
groupName: 'Core',
members: [
@@ -560,9 +562,10 @@ describe('attachments', () => {
it('names attachments in the transcript line, labelling PDFs and files distinctly', async () => {
const { rounds } = await loadRoom()
const { formatGroupChatLine } = await import('./group-round-prompt')
const pdf: Attachment = { data: 'data:application/pdf;base64,JVBERi0=', kind: 'pdf', name: 'spec.pdf' }
const doc: Attachment = { data: 'data:text/plain;base64,aGVsbG8=', kind: 'file', name: 'notes.txt' }
const line = (entry: Partial<GroupMessage>) => rounds.formatGroupChatLine(entry as GroupMessage, 'research')
const line = (entry: Partial<GroupMessage>) => formatGroupChatLine(entry as GroupMessage, 'research')
expect(line({ from: { kind: 'user', name: 'You' }, images: [IMG], text: 'see attached' })).toBe(
'You (user): see attached [attached image: screenshot.png]'
@@ -692,14 +695,15 @@ describe('member holds (#93129)', () => {
it('consumes a held skip exactly once so the loop cannot spin', async () => {
const { rounds } = await loadRoom()
const { heldMemberWatermarkAdvance } = await import('./group-round-members')
// Fresh delta → advance to log length.
expect(rounds.heldMemberWatermarkAdvance(3, 7)).toBe(7)
expect(heldMemberWatermarkAdvance(3, 7)).toBe(7)
// Already consumed → no write, no spin.
expect(rounds.heldMemberWatermarkAdvance(7, 7)).toBeNull()
expect(rounds.heldMemberWatermarkAdvance(9, 7)).toBeNull()
expect(heldMemberWatermarkAdvance(7, 7)).toBeNull()
expect(heldMemberWatermarkAdvance(9, 7)).toBeNull()
// Unset watermark treated as 0.
expect(rounds.heldMemberWatermarkAdvance(undefined, 2)).toBe(2)
expect(heldMemberWatermarkAdvance(undefined, 2)).toBe(2)
})
})
@@ -1,28 +1,25 @@
import { botFriendlyNames, botHandle, mentionNameForms } from './data'
/**
* Room-level coordination: who speaks, in what order, for how long — the
* @mention parse, the round-robin driver, the #93129 member holds, the stop
* path, and the user send that starts it all.
*/
import { botFriendlyNames, botHandle, clearBotAttention, mentionNameForms, noteBotAttention } from './data'
import { recordGroupActivity } from './group-activity'
import {
$groupChats,
$groupNeedsYou,
appendGroupChatEntry,
GROUP_CHAT_HISTORY_LIMIT,
GROUP_CHAT_MAX_CONTINUATIONS,
GROUP_CHAT_MAX_MESSAGES,
GROUP_CHAT_MAX_ROUNDS,
groupSpeakerLabel,
groupThreadOf,
mintGroupThreadId,
shouldCommitMemberTurn,
updateGroupChat
} from './group-chat'
import type { GroupChatRoom, GroupHoldStamp } from './group-chat'
import { durableGroupChatMembers, followGroupChat, groupMemberKey } from './group-membership'
import { harvestStrandedGroupReply, isGroupPassText, runGroupChatMemberTurn } from './group-turns'
import { runGroupContinuationMembers, runGroupRoundMember } from './group-round-members'
import { harvestStrandedGroupReply } from './group-turns'
import { requestForBot } from './routing'
import type { Attachment, GroupMember, GroupMessage } from './types'
@@ -154,70 +151,6 @@ export function rotateGroupSpeakers(members: GroupMember[], round: number) {
return [...members.slice(shift), ...members.slice(0, shift)]
}
/** Room-log line as a member sees it: `Name (user): …` / `Name: …` /
* `Name (you): …`. */
export function formatGroupChatLine(entry: GroupMessage, viewerName: string) {
// Attachments are staged into each member's session as real payloads; the
// transcript line names them so the delta text and the bytes line up.
const attached =
Array.isArray(entry.images) && entry.images.length
? ` ${entry.images
.map(img => {
const label = img.kind === 'pdf' ? 'attached PDF' : img.kind === 'file' ? 'attached file' : 'attached image'
return `[${label}: ${img.name || 'image'}]`
})
.join(' ')}`
: ''
if (entry.from.kind === 'user') {
return `${entry.from.name || 'User'} (user): ${entry.text}${attached}`
}
const suffix = entry.from.name === viewerName ? ' (you)' : ''
// Cross-connection speakers carry their device so same-named agents on
// two machines stay tellable apart in every member's transcript.
const source = entry.from.source ? ` [${entry.from.source}]` : ''
return `${groupSpeakerLabel(entry.from.name)}${suffix}${source}: ${entry.text}${attached}`
}
interface GroupChatTurnPromptInput {
deltaLines: string[]
groupName: string
members: GroupMember[]
viewer: GroupMember
}
/** The full per-turn payload for one member: participation rules + the room
* delta. Rules travel in the turn payload (not SOUL) so every existing bot
* can join a group chat without a profile migration. */
export function buildGroupChatTurnPrompt({ groupName, members, viewer, deltaLines }: GroupChatTurnPromptInput) {
const viewerKey = groupMemberKey(viewer)
const peers = members.filter(m => groupMemberKey(m) !== viewerKey)
const peerNames = peers
.map(m => {
const handle = m.title ? `${m.title} (@${botHandle(m.name, m)})` : `@${botHandle(m.name, m)}`
return m.remoteSource ? `${handle} [on ${m.connectionLabel || m.connectionId}]` : handle
})
.join(', ')
return [
`[Group chat: "${groupName}"] You are @${botHandle(viewer.name, viewer)}, one participant in a group chat with ${peerNames || 'no one else yet'} and the user.`,
'',
'New messages in the room since your last turn (oldest first):',
...deltaLines.map(line => ` ${line}`),
'',
'Rules for this room:',
'- Reply with ONE conversational message ONLY if you have something new worth adding: build on what was just said, claim or hand off work, answer a question aimed at you, or report a real result. Keep chatter short (1-3 sentences) — but when you are delivering a result, an answer the user asked for, or substantive work, give it at full quality and length; never thin out real content to fit the room.',
'- If you have nothing new to add, reply with exactly "(pass)". Passing is good — it lets the conversation settle.',
'- Mention a teammate as @name to pull them in; mention @user only for a judgment call or a result the user needs. Do not repeat points already made.',
'- Never reveal content from your private 1:1 chats. Your reply text goes to the room verbatim — no preamble, no meta-commentary.'
].join('\n')
}
// --- member-hold helpers (#93129) — pure, unit-tested ---
/** #93129: classify a USER room message's effect on member holds. Only user
@@ -325,13 +258,6 @@ export function applyGroupHoldDirective(
return next
}
/** #93129: a held member's skip must consume its delta exactly once —
* advance the watermark past the current log so the same entries never
* re-trigger the skip. Null = nothing to consume (no write, no spin). */
export function heldMemberWatermarkAdvance(seen: number | undefined, logLength: number): null | number {
return logLength > (seen || 0) ? logLength : null
}
// --- end member-hold helpers ---
/** Members cited by @mention in a thread who have not posted any entry after
@@ -493,9 +419,24 @@ export async function stopGroupThread(group: string, thread: null | string, memb
* Watermarks are per thread+member (`${thread}::${memberKey}`), so parallel
* topics never eat each other's deltas. */
export async function runGroupChatRounds(group: string, members: GroupMember[], thread: string) {
const binding = followGroupChat(group, name => { group = name })
const binding = followGroupChat(group, name => {
group = name
})
const startEpoch = ($groupChats.get()[group] || {}).epoch || 0
const isCurrent = () => binding.isLive() && (($groupChats.get()[group] || {}).epoch || 0) === startEpoch
const context = {
get group() {
return group
},
members,
thread,
startEpoch,
binding,
isCurrent
}
let posted = 0
let continuations = 0
// #94478: how this drive ended. 'settled' means quiet consensus (everyone
@@ -565,183 +506,13 @@ export async function runGroupChatRounds(group: string, members: GroupMember[],
return
}
const room = $groupChats.get()[group] || {
log: [],
watermarks: {}
}
const result = await runGroupRoundMember(context, member)
const memberKey = groupMemberKey(member)
const markKey = `${thread}::${memberKey}`
const seen = room.watermarks[markKey] || 0
// Delta: NEW room entries, narrowed to this thread — the member's
// turn sees only the conversation it's part of.
const delta = room.log.slice(seen).filter((e: GroupMessage) => groupThreadOf(e) === thread)
if (!delta.length) {
continue
}
// #93129: a member the user told to stop is HELD — no turn until an
// explicit release (resume / @all resume / a direct non-stop
// mention). Consume the delta exactly once (watermark past the
// current log) so the same entries never re-trigger this skip, and
// surface WHY the bot is silent in the activity feed the first time.
const heldEntry = (room.holds || {})[memberKey]
if (heldEntry) {
const advance = heldMemberWatermarkAdvance(seen, room.log.length)
updateGroupChat(group, (r: GroupChatRoom) => {
if (advance !== null) {
r.watermarks[markKey] = advance
}
if (r.holds?.[memberKey] && !r.holds[memberKey].noted) {
r.holds = {
...r.holds,
[memberKey]: {
...r.holds[memberKey],
noted: true
}
}
}
return r
})
if (!heldEntry.noted) {
recordGroupActivity(group, {
kind: 'held',
member: member.name,
thread
})
}
continue
}
const prompt = buildGroupChatTurnPrompt({
groupName: group,
members,
viewer: member,
deltaLines: delta
.slice(-GROUP_CHAT_HISTORY_LIMIT)
.map((e: GroupMessage) => formatGroupChatLine(e, member.name))
})
// Images riding this delta (user attachments — member entries don't
// carry images today, but flatMap keeps this future-proof) get staged
// into the member's session so the model sees the pixels, not just
// the transcript's [attached image: …] marker.
const deltaImages = delta.flatMap((e: GroupMessage) => (Array.isArray(e.images) ? e.images : []))
// Surface WHO is on turn (runtime-only, like running/epoch) so the
// room shows "Radar is thinking…" instead of a generic working line —
// long model turns otherwise read as the room being stuck.
updateGroupChat(group, (r: GroupChatRoom) => {
r.turn = member.name
return r
})
let reply: null | string = null
try {
reply = await runGroupChatMemberTurn(group, member, prompt, thread, deltaImages)
// Needs-attention hook (#93091 item 3): a turn that produced a real
// reply (or an explicit pass) is a good turn — clear the badge.
// A timed-out turn also returns null but never threw; leaving any
// prior badge in place there is the conservative choice.
if (reply !== null) {
clearBotAttention(groupMemberKey(member))
}
} catch (error: any) {
if (!binding.isLive()) {
return
}
const reason = String(error?.data?.reason || '').trim()
recordGroupActivity(group, {
kind: 'failed',
member: member.name,
thread,
...(reason
? {
reason
}
: {})
})
noteBotAttention(groupMemberKey(member), reason || error?.message || error)
reply = null // a failed turn is a pass, never a room error
}
// #93127: the turn may have finished AFTER a newer user send bumped
// the room epoch. That newer send's loop re-drives this member with
// the full delta, so committing this stale result (watermark advance
// + append) would double-deliver the same reply. Drop it here —
// BEFORE the watermark advance and BEFORE the append. Only a newer
// USER entry in THIS thread makes the re-drive premise true: a
// cross-thread send bumps the epoch too, but its loop filters this
// thread out and would never regenerate the finished reply. The
// during-turn tail is anchored by entry id, not index — the history
// trim drops entries from the FRONT, so an index slice could
// overshoot after a mid-turn trim and silently commit a stale turn.
if (!binding.isLive()) {
if (!binding.isLive() || result === null) {
return
}
const roomNow = $groupChats.get()[group] || {
log: []
}
const epochNow = roomNow.epoch || 0
const anchorId = room.log.length ? room.log[room.log.length - 1].id : null
const anchorIdx = anchorId === null ? -1 : roomNow.log.findIndex((e: GroupMessage) => e.id === anchorId)
// Anchor trimmed away ⇒ every pre-turn entry was dropped, so every
// surviving entry is newer — scanning the whole log stays exact.
const turnTail = anchorIdx >= 0 ? roomNow.log.slice(anchorIdx + 1) : roomNow.log
const newerUserEntryInThread = turnTail.some(
(e: GroupMessage) => e.from?.kind === 'user' && groupThreadOf(e) === thread
)
if (!shouldCommitMemberTurn(startEpoch, epochNow, newerUserEntryInThread)) {
recordGroupActivity(group, {
kind: 'cancelled',
member: member.name,
thread
})
return
}
// The member has now seen everything up to the pre-reply log length.
updateGroupChat(group, (r: GroupChatRoom) => {
r.watermarks[markKey] = r.log.length
return r
})
if (reply !== null && !isGroupPassText(reply)) {
appendGroupChatEntry(
group,
{
kind: 'member',
name: member.name,
...(member.remoteSource
? {
source: member.connectionLabel || member.connectionId
}
: {})
},
reply,
thread
)
// Its own message counts as seen too.
updateGroupChat(group, (r: GroupChatRoom) => {
r.watermarks[markKey] = r.log.length
return r
})
if (result) {
posted += 1
spokeThisRound += 1
}
@@ -762,126 +533,15 @@ export async function runGroupChatRounds(group: string, members: GroupMember[],
// room's entire budget on back-and-forth handoffs.
continuations += 1
if (pendingKeys.length && continuations <= GROUP_CHAT_MAX_CONTINUATIONS) {
const citedMembers = members.filter((member: GroupMember) => pendingKeys.includes(groupMemberKey(member)))
const continued = await runGroupContinuationMembers(context, pendingKeys, continuations, posted)
if (citedMembers.length && posted < GROUP_CHAT_MAX_MESSAGES) {
const strandedNow = ($groupChats.get()[group] || {}).stranded || {}
const continuationResponders = citedMembers.filter(
(member: GroupMember) => !Object.prototype.hasOwnProperty.call(strandedNow, groupMemberKey(member))
)
for (const member of continuationResponders) {
if (!isCurrent() || posted >= GROUP_CHAT_MAX_MESSAGES || continuations > GROUP_CHAT_MAX_CONTINUATIONS) {
break
}
const room = $groupChats.get()[group] || {
log: [],
watermarks: {}
}
const memberKey = groupMemberKey(member)
const markKey = `${thread}::${memberKey}`
const seen = room.watermarks[markKey] || 0
const delta = room.log.slice(seen).filter((e: GroupMessage) => groupThreadOf(e) === thread)
// A cited member always has delta here (the citing reply IS in
// its tail); skip defensively anyway so an empty prompt never
// fires.
if (!delta.length) {
continue
}
const heldEntry = (room.holds || {})[memberKey]
if (heldEntry) {
continue // holds still apply to continuation turns (#93129)
}
const prompt = buildGroupChatTurnPrompt({
groupName: group,
members,
viewer: member,
// The continuation prompt centers on what the member missed:
// everything since its watermark, which includes the reply
// that cites it.
deltaLines: delta
.slice(-GROUP_CHAT_HISTORY_LIMIT)
.map((e: GroupMessage) => formatGroupChatLine(e, member.name))
})
updateGroupChat(group, (r: GroupChatRoom) => {
r.turn = member.name
return r
})
let continuationReply: null | string = null
try {
continuationReply = await runGroupChatMemberTurn(group, member, prompt, thread)
if (continuationReply !== null) {
clearBotAttention(memberKey)
}
} catch (error: any) {
if (!binding.isLive()) {
return
}
recordGroupActivity(group, {
kind: 'failed',
member: member.name,
thread
})
noteBotAttention(memberKey, error?.message || error)
continuationReply = null
}
if (!isCurrent()) {
return
}
updateGroupChat(group, (r: GroupChatRoom) => {
r.watermarks[markKey] = r.log.length
return r
})
if (continuationReply !== null && !isGroupPassText(continuationReply)) {
appendGroupChatEntry(
group,
{
kind: 'member',
name: member.name,
...(member.remoteSource
? {
source: member.connectionLabel || member.connectionId
}
: {})
},
continuationReply,
thread
)
updateGroupChat(group, (r: GroupChatRoom) => {
r.watermarks[markKey] = r.log.length
return r
})
posted += 1
// The continuation's own reply may cite someone else — fall
// through to the normal loop so the next round handles it via
// the same responder machinery. Reaching here means the loop
// continues rather than settling; the outer for-loop's next
// iteration re-evaluates everything.
spokeThisRound += 1
}
}
}
if (!binding.isLive() || continued === null) {
return
}
posted += continued
spokeThisRound += continued
if (spokeThisRound === 0) {
// Genuinely nothing left to say — including after the continuation
// attempt above produced no spoken turns. Settle honestly, but if
@@ -938,7 +598,9 @@ export async function runGroupChatRounds(group: string, members: GroupMember[],
* stranded, a new loop takes the room over (it harvests on its own), or the
* room record disappears (disband). */
async function harvestStrandedUntilSettled(group: string, members: GroupMember[], thread: string) {
const binding = followGroupChat(group, name => { group = name })
const binding = followGroupChat(group, name => {
group = name
})
try {
const HARVEST_INTERVAL_MS = 5000
@@ -1059,7 +721,9 @@ export function sendToGroupChat(
thread: target
})
const binding = followGroupChat(group, name => { group = name })
const binding = followGroupChat(group, name => {
group = name
})
const drive = () => {
if (!binding.isLive()) {
@@ -1068,15 +732,17 @@ export function sendToGroupChat(
return
}
void runGroupChatRounds(group, members, target).catch(() => {
if (binding.isLive()) {
updateGroupChat(group, (r: GroupChatRoom) => {
r.running = false
void runGroupChatRounds(group, members, target)
.catch(() => {
if (binding.isLive()) {
updateGroupChat(group, (r: GroupChatRoom) => {
r.running = false
return r
})
}
}).finally(binding.dispose)
return r
})
}
})
.finally(binding.dispose)
}
if (!wasRunning) {
@@ -123,7 +123,9 @@ interface GroupMemberSessionHandle {
* it after restarts. Cross-connection members route to their OWN source
* via requestForBot; the window's gateway never switches. */
export async function ensureGroupChatSession(group: string, member: GroupMember): Promise<GroupMemberSessionHandle> {
const binding = followGroupChat(group, name => { group = name })
const binding = followGroupChat(group, name => {
group = name
})
try {
const room = $groupChats.get()[group] || {}
@@ -195,7 +197,9 @@ export async function ensureGroupChatSession(group: string, member: GroupMember)
} catch (error: any) {
if (error?.code !== 4007) {
const detail = error instanceof Error && error.message ? ` (${error.message})` : ''
throw new Error(`Could not check ${member?.name || 'member'}'s group session${detail} — not starting a new one`)
throw new Error(
`Could not check ${member?.name || 'member'}'s group session${detail} — not starting a new one`
)
}
/* genuinely doesn't exist (4007) — try the next target / fall through to create */
}
@@ -597,7 +601,10 @@ export async function answerGroupClarify(
answers: Record<string, string> | string | undefined
) {
let group = entry.group
const binding = followGroupChat(group, name => { group = name })
const binding = followGroupChat(group, name => {
group = name
})
try {
if (entry.kind === 'approval') {
@@ -662,7 +669,10 @@ export async function runGroupChatMemberTurn(
// lease, every RPC below rides its own request-scoped socket lease; the
// socket that minted `runtime` can close between RPCs, the gateway reaps
// the runtime session, and prompt.submit dies 4001 — the bot goes silent.
const binding = followGroupChat(group, name => { group = name })
const binding = followGroupChat(group, name => {
group = name
})
let releaseTurnLease: (() => void) | undefined
try {
@@ -675,6 +685,215 @@ export async function runGroupChatMemberTurn(
}
}
async function stageGroupTurnAttachments(member: GroupMember, runtime: string, images?: Attachment[]) {
// Stage this delta's attachments into the member's session so the model
// receives the actual payload with the prompt — the same attach RPCs the
// 1:1 chat uses (they also work cross-connection, where the member's
// gateway can't see this machine's files). Images queue as vision tiles,
// PDFs render per-page via pdf.attach, and other files materialize in the
// session workspace (their @file: refs are appended to the prompt so the
// member's file tools can read them). A failed attach degrades that
// member to text-only; the transcript line still names the attachment so
// the member knows something was shared.
const fileRefs: string[] = []
for (const img of Array.isArray(images) ? images : []) {
if (!img || typeof img.data !== 'string' || !img.data) {
continue
}
try {
if (img.kind === 'pdf') {
await requestForBot(member, 'pdf.attach', {
session_id: runtime,
content_base64: img.data,
filename: img.name || 'attachment.pdf'
})
} else if (img.kind === 'file') {
const res = (await requestForBot(member, 'file.attach', {
session_id: runtime,
data_url: img.data,
name: img.name || 'attachment'
})) as { ref_text?: string }
if (res?.ref_text) {
fileRefs.push(`${img.name || 'attachment'} → ${res.ref_text}`)
}
} else {
await requestForBot(member, 'image.attach_bytes', {
session_id: runtime,
content_base64: img.data,
filename: img.name || 'attachment.png'
})
}
} catch {
/* text-only fallback for this member */
}
}
return fileRefs
}
interface GroupTurnPollContext {
group: string
member: GroupMember
thread: string
dispatchEpoch: number
stored: GroupMemberSessionHandle['stored']
liveRuntime: string
runtimeIds: Set<string>
before: number
binding: { isLive(): boolean }
}
async function pollGroupMemberTurn(context: GroupTurnPollContext): Promise<null | string> {
const { member, thread, dispatchEpoch, stored, liveRuntime, runtimeIds, before, binding } = context
const memberKey = groupMemberKey(member)
const started = Date.now()
let deadline = started + GROUP_TURN_TIMEOUT_MS
// After the terminal frame fires, the gateway still has to flip
// session.running off in its turn `finally` — re-check quickly for a few
// beats instead of falling back to the slow backstop cadence.
let quickRechecks = 0
while (Date.now() < deadline) {
const signalled = quickRechecks
? await waitForTurnSignal([], GROUP_TURN_SETTLE_RECHECK_MS)
: await waitForTurnSignal([...runtimeIds], GROUP_TURN_POLL_MS)
quickRechecks = signalled ? GROUP_TURN_SETTLE_RECHECKS : Math.max(0, quickRechecks - 1)
// #91868/#94569: an explicit stop (stopGroupThread) bumped the epoch AND
// held this member — the member's session was interrupted, so nothing is
// coming; abandon the poll instead of grinding until the deadline. Both
// conditions on purpose: an ordinary newer send bumps the epoch WITHOUT
// a hold, and that turn must keep polling so finished work can still be
// delivered (the #93127 commit check decides its fate, not this loop).
if (!binding.isLive()) {
return null
}
const roomDuringPoll = $groupChats.get()[context.group] || {}
if ((roomDuringPoll.epoch || 0) !== dispatchEpoch && (roomDuringPoll.holds || {})[memberKey]) {
return null
}
let state: GroupSessionSnapshot | null = null
try {
state = (await requestForBot(member, 'session.resume', {
session_id: stored || liveRuntime,
profile: member.name
})) as GroupSessionSnapshot
} catch {
continue
}
if (!binding.isLive()) {
return null
}
if (state?.session_id) {
runtimeIds.add(state.session_id)
}
const messages = Array.isArray(state?.messages) ? state.messages : []
const busy = Boolean(state?.inflight || state?.running)
// A clarify blocking inside the member's session is a question for the
// HUMAN (#90694) — mirror it into the room store so a card renders, and
// hold the turn open: the member isn't stalling, it's waiting on us.
const awaitingUser = syncGroupClarify(context.group, member, state)
const done = !busy && !awaitingUser
if (messages.length > before && done) {
const replyText = pickGroupTurnReply(messages, before)
if (replyText !== null) {
recordGroupActivity(context.group, {
kind: isGroupPassText(replyText) ? 'passed' : 'replied',
member: member.name,
thread
})
return replyText
}
recordGroupActivity(context.group, {
kind: 'passed',
member: member.name,
thread
})
return null
}
// Still visibly working — or waiting on the user's answer to a clarify:
// extend the deadline (never past the hard cap). A pending question must
// outlive the base turn timeout or it dies unanswered at 3 minutes.
if (busy || awaitingUser) {
deadline = Math.min(started + GROUP_TURN_HARD_CAP_MS, Math.max(deadline, Date.now() + GROUP_TURN_TIMEOUT_MS))
}
}
if (!binding.isLive()) {
return null
}
// Timeout — clear any still-mirrored question card (the server-side
// clarify timeout runs its own course) and read as a pass, but remember the baseline + thread
// (runtime-only) so the finished reply can be posted late into the RIGHT
// thread instead of vanishing.
recordGroupActivity(context.group, {
kind: 'timed-out',
member: member.name,
thread
})
syncGroupClarify(context.group, member, null)
updateGroupChat(context.group, (r: GroupChatRoom) => {
r.stranded = {
...(r.stranded || {}),
[groupMemberKey(member)]: {
before,
thread
}
}
return r
})
return null
}
async function prepareGroupTurnBaseline(
member: GroupMember,
runtime: string,
stored: GroupMemberSessionHandle['stored']
) {
// Baseline: how many messages exist before our submit.
let before = 0
// Every runtime id this turn has seen for the member's session. Terminal
// frames are keyed by runtime id, and a resume can hand back a fresh one.
const runtimeIds = new Set<string>([runtime])
try {
const pre = (await requestForBot(member, 'session.resume', {
session_id: stored || runtime,
profile: member.name
})) as GroupSessionSnapshot
before = Array.isArray(pre?.messages) ? pre.messages.length : pre?.message_count || 0
if (pre?.session_id) {
runtimeIds.add(pre.session_id)
}
} catch {
/* lazy session — zero messages */
}
return { before, runtimeIds }
}
async function runGroupChatMemberTurnLeased(
group: string,
member: GroupMember,
@@ -682,7 +901,9 @@ async function runGroupChatMemberTurnLeased(
thread: string,
images?: Attachment[]
): Promise<null | string> {
const binding = followGroupChat(group, name => { group = name })
const binding = followGroupChat(group, name => {
group = name
})
try {
const { runtime, stored } = await ensureGroupChatSession(group, member)
@@ -694,78 +915,15 @@ async function runGroupChatMemberTurnLeased(
// #91868/#94569: remember the epoch this turn was dispatched under so the
// poll loop below can tell an explicit stop from ordinary room churn.
const dispatchEpoch = ($groupChats.get()[group] || {}).epoch || 0
const memberKey = groupMemberKey(member)
recordGroupActivity(group, {
kind: 'working',
member: member.name,
thread
})
// Baseline: how many messages exist before our submit.
let before = 0
// Every runtime id this turn has seen for the member's session. Terminal
// frames are keyed by runtime id, and a resume can hand back a fresh one.
const runtimeIds = new Set<string>([runtime])
const { before, runtimeIds } = await prepareGroupTurnBaseline(member, runtime, stored)
try {
const pre = (await requestForBot(member, 'session.resume', {
session_id: stored || runtime,
profile: member.name
})) as GroupSessionSnapshot
before = Array.isArray(pre?.messages) ? pre.messages.length : pre?.message_count || 0
if (pre?.session_id) {
runtimeIds.add(pre.session_id)
}
} catch {
/* lazy session — zero messages */
}
// Stage this delta's attachments into the member's session so the model
// receives the actual payload with the prompt — the same attach RPCs the
// 1:1 chat uses (they also work cross-connection, where the member's
// gateway can't see this machine's files). Images queue as vision tiles,
// PDFs render per-page via pdf.attach, and other files materialize in the
// session workspace (their @file: refs are appended to the prompt so the
// member's file tools can read them). A failed attach degrades that
// member to text-only; the transcript line still names the attachment so
// the member knows something was shared.
const fileRefs: string[] = []
for (const img of Array.isArray(images) ? images : []) {
if (!img || typeof img.data !== 'string' || !img.data) {
continue
}
try {
if (img.kind === 'pdf') {
await requestForBot(member, 'pdf.attach', {
session_id: runtime,
content_base64: img.data,
filename: img.name || 'attachment.pdf'
})
} else if (img.kind === 'file') {
const res = (await requestForBot(member, 'file.attach', {
session_id: runtime,
data_url: img.data,
name: img.name || 'attachment'
})) as { ref_text?: string }
if (res?.ref_text) {
fileRefs.push(`${img.name || 'attachment'} → ${res.ref_text}`)
}
} else {
await requestForBot(member, 'image.attach_bytes', {
session_id: runtime,
content_base64: img.data,
filename: img.name || 'attachment.png'
})
}
} catch {
/* text-only fallback for this member */
}
}
const fileRefs = await stageGroupTurnAttachments(member, runtime, images)
if (!binding.isLive()) {
return null
@@ -785,120 +943,20 @@ async function runGroupChatMemberTurnLeased(
}
runtimeIds.add(liveRuntime)
const started = Date.now()
let deadline = started + GROUP_TURN_TIMEOUT_MS
// After the terminal frame fires, the gateway still has to flip
// session.running off in its turn `finally` — re-check quickly for a few
// beats instead of falling back to the slow backstop cadence.
let quickRechecks = 0
while (Date.now() < deadline) {
const signalled = quickRechecks
? await waitForTurnSignal([], GROUP_TURN_SETTLE_RECHECK_MS)
: await waitForTurnSignal([...runtimeIds], GROUP_TURN_POLL_MS)
quickRechecks = signalled ? GROUP_TURN_SETTLE_RECHECKS : Math.max(0, quickRechecks - 1)
// #91868/#94569: an explicit stop (stopGroupThread) bumped the epoch AND
// held this member — the member's session was interrupted, so nothing is
// coming; abandon the poll instead of grinding until the deadline. Both
// conditions on purpose: an ordinary newer send bumps the epoch WITHOUT
// a hold, and that turn must keep polling so finished work can still be
// delivered (the #93127 commit check decides its fate, not this loop).
if (!binding.isLive()) {
return null
}
const roomDuringPoll = $groupChats.get()[group] || {}
if ((roomDuringPoll.epoch || 0) !== dispatchEpoch && (roomDuringPoll.holds || {})[memberKey]) {
return null
}
let state: GroupSessionSnapshot | null = null
try {
state = (await requestForBot(member, 'session.resume', {
session_id: stored || liveRuntime,
profile: member.name
})) as GroupSessionSnapshot
} catch {
continue
}
if (!binding.isLive()) {
return null
}
if (state?.session_id) {
runtimeIds.add(state.session_id)
}
const messages = Array.isArray(state?.messages) ? state.messages : []
const busy = Boolean(state?.inflight || state?.running)
// A clarify blocking inside the member's session is a question for the
// HUMAN (#90694) — mirror it into the room store so a card renders, and
// hold the turn open: the member isn't stalling, it's waiting on us.
const awaitingUser = syncGroupClarify(group, member, state)
const done = !busy && !awaitingUser
if (messages.length > before && done) {
const replyText = pickGroupTurnReply(messages, before)
if (replyText !== null) {
recordGroupActivity(group, {
kind: isGroupPassText(replyText) ? 'passed' : 'replied',
member: member.name,
thread
})
return replyText
}
recordGroupActivity(group, {
kind: 'passed',
member: member.name,
thread
})
return null
}
// Still visibly working — or waiting on the user's answer to a clarify:
// extend the deadline (never past the hard cap). A pending question must
// outlive the base turn timeout or it dies unanswered at 3 minutes.
if (busy || awaitingUser) {
deadline = Math.min(started + GROUP_TURN_HARD_CAP_MS, Math.max(deadline, Date.now() + GROUP_TURN_TIMEOUT_MS))
}
}
if (!binding.isLive()) {
return null
}
// Timeout — clear any still-mirrored question card (the server-side
// clarify timeout runs its own course) and read as a pass, but remember the baseline + thread
// (runtime-only) so the finished reply can be posted late into the RIGHT
// thread instead of vanishing.
recordGroupActivity(group, {
kind: 'timed-out',
member: member.name,
thread
return await pollGroupMemberTurn({
get group() {
return group
},
member,
thread,
dispatchEpoch,
stored,
liveRuntime,
runtimeIds,
before,
binding
})
syncGroupClarify(group, member, null)
updateGroupChat(group, (r: GroupChatRoom) => {
r.stranded = {
...(r.stranded || {}),
[groupMemberKey(member)]: {
before,
thread
}
}
return r
})
return null
} finally {
binding.dispose()
}
@@ -908,7 +966,9 @@ async function runGroupChatMemberTurnLeased(
* after we stopped waiting. Called at the member's next turn boundary and
* on user sends, so long-running work is delivered late rather than lost. */
export async function harvestStrandedGroupReply(group: string, member: GroupMember) {
const binding = followGroupChat(group, name => { group = name })
const binding = followGroupChat(group, name => {
group = name
})
try {
const memberKey = groupMemberKey(member)
@@ -0,0 +1,178 @@
import { Button, Codicon, DisclosureCaret, GlyphSpinner, PanelEmpty, RowButton } from '@hermes/plugin-sdk'
import type { ReactNode, RefObject } from 'react'
import type { useRoster } from './data'
import { $showHiddenBots } from './hidden-bots'
import type { useBots } from './i18n'
import type { deriveRosterPresentation, deriveRosterRows } from './roster-pane-derivation'
import type { rosterSectionRenderers } from './roster-pane-sections'
import type { rosterGatewayOptions } from './roster-sections'
import type { RosterRow } from './types'
interface RosterContentProps {
b: ReturnType<typeof useBots>
staleNotice: string | number | null
isLoading: boolean
initialRosterLoading: boolean
roster: RosterRow[]
error: ReturnType<typeof useRoster>['error']
gatewayUp: boolean
refetch: ReturnType<typeof useRoster>['refetch']
allBotsHidden: boolean
hiddenExpanded: boolean
rosterRows: ReturnType<typeof deriveRosterRows>['rosterRows']
matchingHiddenBots: RosterRow[]
query: string
selectedGateway: ReturnType<typeof rosterGatewayOptions>[number] | undefined
showGatewaySections: boolean
sortedGroupRows: ReturnType<typeof deriveRosterRows>['sortedGroupRows']
gatewaySections: ReturnType<typeof deriveRosterRows>['gatewaySections']
showHiddenSection: boolean
hiddenSectionRef: RefObject<HTMLDivElement | null>
hasRosterConstraint: boolean
hiddenBots: RosterRow[]
showHiddenRows: boolean
hiddenGatewaySections: ReturnType<typeof deriveRosterPresentation>['hiddenGatewaySections']
renderBotRow: (bot: RosterRow, keyPrefix?: string) => ReactNode
renderGroupChatSection: ReturnType<typeof rosterSectionRenderers>['renderGroupChatSection']
renderGatewaySection: ReturnType<typeof rosterSectionRenderers>['renderGatewaySection']
renderUserSections: ReturnType<typeof rosterSectionRenderers>['renderUserSections']
renderHiddenGatewaySection: ReturnType<typeof rosterSectionRenderers>['renderHiddenGatewaySection']
}
export function renderRosterContent({
b,
staleNotice,
isLoading,
initialRosterLoading,
roster,
error,
gatewayUp,
refetch,
allBotsHidden,
hiddenExpanded,
rosterRows,
matchingHiddenBots,
query,
selectedGateway,
showGatewaySections,
sortedGroupRows,
gatewaySections,
showHiddenSection,
hiddenSectionRef,
hasRosterConstraint,
hiddenBots,
showHiddenRows,
hiddenGatewaySections,
renderBotRow,
renderGroupChatSection,
renderGatewaySection,
renderUserSections,
renderHiddenGatewaySection
}: RosterContentProps) {
return (
<>
{staleNotice ? (
<div className="mx-2.5 mb-1 rounded-md bg-(--chrome-action-hover) px-2 py-1.5 text-[0.6875rem] text-(--ui-text-tertiary)">
{staleNotice}
</div>
) : null}
{(isLoading || initialRosterLoading) && !roster.length ? (
<div className="flex flex-1 items-center justify-center">
<GlyphSpinner className="text-(--ui-text-tertiary)" spinner="breathe" />
</div>
) : error && !roster.length ? (
<div className="grid gap-2 px-3 py-4 text-xs text-(--ui-text-tertiary)">
<div>
{gatewayUp
? b.roster.rosterUnavailable(error instanceof Error ? error.message : 'gateway error')
: b.roster.waitingForGateway}
</div>
<Button className="justify-self-start" onClick={() => void refetch()} size="sm" variant="secondary">
{b.roster.retryNow}
</Button>
</div>
) : roster.length === 0 ? (
<PanelEmpty description={b.roster.emptyDesc} icon="hubot" title={b.roster.emptyTitle} />
) : allBotsHidden && !hiddenExpanded ? (
<div className="grid content-start gap-2 px-3 py-4 text-xs text-(--ui-text-tertiary)">
<div className="flex items-center gap-1.5 font-medium text-(--ui-text-secondary)">
<Codicon className="text-(--ui-text-quaternary)" name="eye-closed" />
{b.roster.allHidden}
</div>
<p className="leading-relaxed">{b.roster.allHiddenDesc}</p>
<Button
className="justify-self-start"
onClick={() => $showHiddenBots.set(true)}
size="sm"
variant="secondary"
>
{b.roster.showHidden}
</Button>
</div>
) : rosterRows.length === 0 && matchingHiddenBots.length === 0 ? (
<div aria-live="polite" className="flex min-h-0 flex-1 flex-col" role="status">
<PanelEmpty
description={
query.trim()
? selectedGateway
? b.roster.noMatchQueryOn(query.trim(), String(selectedGateway.label))
: b.roster.noMatchQuery(query.trim())
: selectedGateway
? b.roster.noMatchFiltersOn(String(selectedGateway.label))
: b.roster.noMatchFilters
}
icon="search"
/>
</div>
) : (
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain" data-slot="bots-roster">
<div className="grid w-full min-w-0 gap-0.5 px-1.5 pb-2">
{showGatewaySections
? [
sortedGroupRows.length ? renderGroupChatSection() : null,
...gatewaySections.sections.map(renderGatewaySection)
].filter(Boolean)
: renderUserSections(rosterRows)}
{showHiddenSection ? (
<div
className="mt-1 border-t border-(--ui-stroke-tertiary) pt-1"
key={'hidden-section'}
ref={hiddenSectionRef}
>
{hasRosterConstraint ? (
<div className="flex w-full items-center gap-1 px-2 py-1.5 text-[0.6875rem] font-medium text-(--ui-text-tertiary)">
<Codicon name="eye-closed" />
<span>Hidden</span>
<span className="text-(--ui-text-quaternary)">{matchingHiddenBots.length}</span>
</div>
) : (
<RowButton
aria-expanded={hiddenExpanded}
className="flex w-full items-center gap-1 rounded-md px-2 py-1.5 text-left text-[0.6875rem] font-medium text-(--ui-text-tertiary) transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground"
onClick={() => $showHiddenBots.set(!hiddenExpanded)}
>
<DisclosureCaret open={hiddenExpanded} />
<span>Hidden</span>
<span className="text-(--ui-text-quaternary)">{hiddenBots.length}</span>
</RowButton>
)}
{showHiddenRows ? (
matchingHiddenBots.length ? (
hiddenGatewaySections.sectioned ? (
hiddenGatewaySections.sections.map(renderHiddenGatewaySection)
) : (
matchingHiddenBots.map((bot: RosterRow) => renderBotRow(bot, 'hidden:'))
)
) : (
<div className="px-2 py-2 text-xs text-(--ui-text-quaternary)">{b.roster.noHiddenMatch}</div>
)
) : null}
</div>
) : null}
</div>
</div>
)}
</>
)
}
@@ -0,0 +1,248 @@
import { botActivitySession } from './data'
import { botRosterKey, filterBots, preferReachableSameNameRows } from './data'
import type { $groupChats } from './group-chat'
import { groupChatMemberBots, groupChatNames, groupLastActivity } from './group-membership'
import { isBotPinned } from './hidden-bots'
import { isBotHidden } from './hidden-bots'
import { filterBotsByGateway, groupMatchesRosterFilters, rosterGatewaySections } from './roster-sections'
import type { rosterGatewayOptions } from './roster-sections'
import { botRosterMeta } from './routing'
import { BOT_ROSTER_SEARCH_THRESHOLD } from './row-helpers'
import { ACTIVE_WINDOW_S, rosterActivityMatches } from './row-helpers'
import type { BotMeta, GroupMember, RosterActivityFilter, RosterKindFilter, RosterRow } from './types'
/** The two row shapes the roster sorts together — `kind` is the discriminant. */
interface RosterBotRow {
active: boolean
activity: number
bot: RosterRow
kind: 'bot'
pinned: boolean
}
export interface RosterGroupRow {
active: boolean
activity: number
kind: 'group'
members: GroupMember[]
name: string
pinned: boolean
}
interface RosterRowsInput {
roster: RosterRow[]
allMeta: Record<string, BotMeta>
gatewayFilter: string
query: string
activityFilter: RosterActivityFilter
rowKindFilter: RosterKindFilter
groupRooms: ReturnType<typeof $groupChats.get>
activeRosterKeys: Set<string>
gatewayOptions: ReturnType<typeof rosterGatewayOptions>
activityOf: (bot: RosterRow) => number
isPinned: (bot: RosterRow) => boolean
}
export function deriveRosterRows({
roster,
allMeta,
gatewayFilter,
query,
activityFilter,
rowKindFilter,
groupRooms,
activeRosterKeys,
gatewayOptions,
activityOf,
isPinned
}: RosterRowsInput) {
const activeSourceRoster = roster.filter(bot => !bot.remoteSource)
// Hidden rows remain fully alive and recoverable at the bottom. Every
// non-display consumer continues to receive the complete roster.
const hiddenBots = roster.filter(bot => isBotHidden(bot, allMeta))
const visibleRoster = roster.filter(bot => !isBotHidden(bot, allMeta))
const gatewayRoster = filterBotsByGateway(visibleRoster, gatewayFilter)
const filteredRoster = filterBots(gatewayRoster, allMeta, query).filter((bot: RosterRow) =>
rosterActivityMatches(
{
activity: activityOf(bot),
active: activeRosterKeys.has(botRosterKey(bot))
},
activityFilter
)
)
const filteredHiddenBots = filterBots(filterBotsByGateway(hiddenBots, gatewayFilter), allMeta, query).filter(
(bot: RosterRow) =>
rosterActivityMatches(
{
activity: activityOf(bot),
active: activeRosterKeys.has(botRosterKey(bot))
},
activityFilter
)
)
const groupNames = groupChatNames(allMeta, groupRooms)
const groupRows = groupNames
.map(name => ({
name,
members: groupChatMemberBots(name, roster, allMeta)
}))
.filter(row => groupMatchesRosterFilters(row.name, row.members, allMeta, query, gatewayFilter))
.map((row): RosterGroupRow => ({
kind: 'group',
name: row.name,
members: row.members,
pinned: Boolean(groupRooms[row.name]?.pinned),
activity: groupLastActivity(groupRooms[row.name]),
active:
Boolean(
groupLastActivity(groupRooms[row.name]) &&
Date.now() - groupLastActivity(groupRooms[row.name]) <= ACTIVE_WINDOW_S * 1000
) || row.members.some(member => activeRosterKeys.has(botRosterKey(member)))
}))
.filter(row => rowKindFilter !== 'bots' && rosterActivityMatches(row, activityFilter))
const botRows =
rowKindFilter === 'groups'
? []
: preferReachableSameNameRows(filteredRoster).map((bot): RosterBotRow => ({
kind: 'bot',
bot,
pinned: isPinned(bot),
activity: activityOf(bot),
active: activeRosterKeys.has(botRosterKey(bot))
}))
const sortRosterRows = <T extends { activity: number; pinned: boolean }>(rows: T[]): T[] =>
rows.slice().sort((a, b) => {
const pa = a.pinned ? 1 : 0
const pb = b.pinned ? 1 : 0
if (pa !== pb) {
return pb - pa
}
return b.activity - a.activity
})
const rosterRows = sortRosterRows([...botRows, ...groupRows])
const sortedGroupRows = sortRosterRows(groupRows)
const gatewaySections = rosterGatewaySections(botRows, gatewayOptions, gatewayFilter)
const showGatewaySections = gatewaySections.sectioned && botRows.length > 0
return {
activeSourceRoster,
hiddenBots,
visibleRoster,
filteredHiddenBots,
groupNames,
rosterRows,
sortedGroupRows,
gatewaySections,
showGatewaySections
}
}
interface RosterPresentationInput {
rowKindFilter: RosterKindFilter
activityFilter: RosterActivityFilter
gatewayFilter: string
query: string
filteredHiddenBots: RosterRow[]
hiddenBots: RosterRow[]
hiddenExpanded: boolean
roster: RosterRow[]
groupNames: string[]
visibleRoster: RosterRow[]
gatewayOptions: ReturnType<typeof rosterGatewayOptions>
}
export function deriveRosterPresentation({
rowKindFilter,
activityFilter,
gatewayFilter,
query,
filteredHiddenBots,
hiddenBots,
hiddenExpanded,
roster,
groupNames,
visibleRoster,
gatewayOptions
}: RosterPresentationInput) {
const activeFilterCount =
(rowKindFilter === 'all' ? 0 : 1) + (activityFilter === 'all' ? 0 : 1) + (gatewayFilter === 'all' ? 0 : 1)
const hasRosterConstraint = Boolean(query.trim()) || activeFilterCount > 0
const matchingHiddenBots = rowKindFilter === 'groups' ? [] : filteredHiddenBots
const showHiddenSection = hiddenBots.length > 0 && (!hasRosterConstraint || matchingHiddenBots.length > 0)
const showHiddenRows = hiddenExpanded || hasRosterConstraint
const rosterItemCount = roster.length + groupNames.length
const allBotsHidden =
!hasRosterConstraint && visibleRoster.length === 0 && groupNames.length === 0 && hiddenBots.length > 0
const showRosterSearch =
gatewayOptions.length > 1 || rosterItemCount >= BOT_ROSTER_SEARCH_THRESHOLD || Boolean(query.trim())
const showRosterFilters =
gatewayOptions.length > 1 ||
groupNames.length > 0 ||
rosterItemCount >= BOT_ROSTER_SEARCH_THRESHOLD ||
activeFilterCount > 0
const showRosterTools = showRosterSearch || showRosterFilters
const hiddenGatewaySections = rosterGatewaySections(
matchingHiddenBots.map((bot: RosterRow) => ({
kind: 'bot',
bot
})),
gatewayOptions,
gatewayFilter
)
return {
activeFilterCount,
hasRosterConstraint,
matchingHiddenBots,
showHiddenSection,
showHiddenRows,
allBotsHidden,
showRosterSearch,
showRosterFilters,
showRosterTools,
hiddenGatewaySections
}
}
export function sortRosterBots(sourceWithSelectedOwner: RosterRow[], allMeta: Record<string, BotMeta>) {
// Messaging-app order: most recent activity first, where "activity" is
// the newest of (bot created, last message in any of its sessions). A
// 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: RosterRow): number => {
const created = botRosterMeta(bot, allMeta)?.created || bot.ui_meta?.['hermes-bots']?.created || 0
const lastMsg = (botActivitySession(bot)?.last_active || 0) * 1000
return Math.max(created, lastMsg)
}
// Pin is a source-qualified Desktop preference, not gateway profile state.
const isPinned = (bot: RosterRow): boolean => isBotPinned(bot, allMeta)
const roster = sourceWithSelectedOwner.slice().sort((a, b) => {
const pa = isPinned(a) ? 1 : 0
const pb = isPinned(b) ? 1 : 0
if (pa !== pb) {
return pb - pa
}
return activityOf(b) - activityOf(a)
})
return { roster, activityOf, isPinned }
}
@@ -0,0 +1,161 @@
import { ConfirmDialog, host } from '@hermes/plugin-sdk'
import type { useI18n } from '@hermes/plugin-sdk'
import { CreateAgentDialog, CreateGroupChatDialog, GroupDialog } from './create-dialog'
import type { useRoster } from './data'
import { EditProfileDialog } from './edit-profile-dialog'
import { disbandGroupChat, openGroupChat } from './group-chat-view'
import type { useBots } from './i18n'
import { deleteBot } from './profile-ops'
import type { GroupMember, RosterRow } from './types'
import { createBotSection, renameBotSection } from './user-sections'
import { SectionNameDialog } from './user-sections-ui'
interface renderRosterDialogsProps {
b: ReturnType<typeof useBots>
t: ReturnType<typeof useI18n>['t']
createOpen: boolean
setCreateOpen: (value: boolean) => void
groupCreateOpen: boolean
setGroupCreateOpen: (value: boolean) => void
editing: RosterRow | null
setEditing: (value: RosterRow | null) => void
deleting: (RosterRow & { path?: string }) | null
setDeleting: (value: (RosterRow & { path?: string }) | null) => void
deletingGroup: { members: GroupMember[]; name: string } | null
setDeletingGroup: (value: { members: GroupMember[]; name: string } | null) => void
grouping: RosterRow | null
setGrouping: (value: RosterRow | null) => void
sectionDialog: null | { bot?: RosterRow; mode: 'create' } | { id: string; mode: 'rename'; name: string }
setSectionDialog: (
value: null | { bot?: RosterRow; mode: 'create' } | { id: string; mode: 'rename'; name: string }
) => void
roster: RosterRow[]
activeSourceRoster: RosterRow[]
refetch: ReturnType<typeof useRoster>['refetch']
}
export function renderRosterDialogs({
b,
t,
createOpen,
setCreateOpen,
groupCreateOpen,
setGroupCreateOpen,
editing,
setEditing,
deleting,
setDeleting,
deletingGroup,
setDeletingGroup,
grouping,
setGrouping,
sectionDialog,
setSectionDialog,
roster,
activeSourceRoster,
refetch
}: renderRosterDialogsProps) {
return (
<>
<CreateAgentDialog
onClose={() => {
setCreateOpen(false)
void refetch()
}}
open={createOpen}
roster={activeSourceRoster}
/>
<CreateGroupChatDialog
onClose={() => setGroupCreateOpen(false)}
onCreated={groupName => openGroupChat(groupName)}
open={groupCreateOpen} // Full multi-source roster: group chats can seat bots from other
// registered connections — their turns route to their own machines.
roster={roster}
/>
<SectionNameDialog
initialName={sectionDialog?.mode === 'rename' ? sectionDialog.name : ''}
mode={sectionDialog?.mode === 'rename' ? 'rename' : 'create'}
onOpenChange={open => {
if (!open) {
setSectionDialog(null)
}
}}
onSubmit={name => {
if (sectionDialog?.mode === 'rename') {
renameBotSection(sectionDialog.id, name)
} else {
createBotSection(name, sectionDialog?.bot ? [sectionDialog.bot] : [])
}
}}
open={Boolean(sectionDialog)}
/>
<EditProfileDialog
bot={editing}
onClose={() => {
setEditing(null)
void refetch()
}}
open={Boolean(editing)}
/>
{grouping ? <GroupDialog bot={grouping} onClose={() => setGrouping(null)} /> : null}
<ConfirmDialog
busyLabel="Deleting…"
confirmLabel={t.common.delete}
description={
deleting ? (
<span>
{'This will permanently delete the bot '}
<span className="font-medium text-foreground">{deleting.name}</span>
{' and its associated Hermes profile at '}
<span className="font-mono text-xs">{deleting.path}</span>. This cannot be undone.
</span>
) : null
}
destructive
doneLabel="Deleted"
onClose={() => setDeleting(null)}
onConfirm={async () => {
if (!deleting) {
return
}
const name = deleting.name
await deleteBot(deleting)
await refetch()
host.notify({
kind: 'success',
message: `Deleted profile ${name}`
})
}}
open={Boolean(deleting)}
title={b.bot.deleteTitle}
/>
<ConfirmDialog
busyLabel="Deleting…"
confirmLabel={b.group.deleteAction}
description={
deletingGroup
? `This removes “${deletingGroup.name}” from its bots and clears the shared room log. The bots and their individual chats are kept.`
: null
}
destructive
doneLabel="Deleted"
onClose={() => setDeletingGroup(null)}
onConfirm={async () => {
if (!deletingGroup) {
return
}
await disbandGroupChat(deletingGroup.name, deletingGroup.members)
host.notify({
kind: 'success',
message: `Deleted group “${deletingGroup.name}”`
})
}}
open={Boolean(deletingGroup)}
title={b.group.deleteTitle}
/>
</>
)
}
@@ -0,0 +1,54 @@
import { atom, host } from '@hermes/plugin-sdk'
import { useEffect } from 'react'
import { $lastRoster } from './data'
import type { useRoster } from './data'
import { displayName } from './labels'
import { mergeServerMeta, pullServerAvatars } from './profile-ops'
import { trackInboundActivity } from './roster-actions'
import { botRosterMeta, botWorkspaceOwnerKey } from './routing'
import { backfillMessagingProtocol } from './soul'
import type { GatewaySource } from './types'
import type { BotMeta, RosterRow } from './types'
/** Last source inventory returned by the desktop-wide agent roster. */
export const $lastSources = atom<GatewaySource[]>([])
interface RosterSnapshotInput {
data: ReturnType<typeof useRoster>['data']
live: RosterRow[] | null
roster: RosterRow[]
allMeta: Record<string, BotMeta>
activeSourceRoster: RosterRow[]
}
export function usePublishRosterSnapshot({ data, live, roster, allMeta, activeSourceRoster }: RosterSnapshotInput) {
useEffect(() => {
if (!live) {
return
}
// Offline-owner ghosts belong only to this render. Shared roster state
// feeds merge caching, group membership, creation, and durable sync. These
// writes must settle after render: other subscribers of the same atoms
// would otherwise be updated while BotsPane was still rendering.
$lastRoster.set(roster.filter(row => !row?.ghost))
// Tabs caption a bot chat by its bot (#99152); republished with the
// roster so a rename follows and tiles restored at boot resolve.
roster.forEach(bot => {
host.setWorkspaceOwnerLabel?.(botWorkspaceOwnerKey(bot), displayName(bot, botRosterMeta(bot, allMeta)))
})
if (Array.isArray(data?.sources)) {
$lastSources.set(data.sources)
}
mergeServerMeta(activeSourceRoster, data?.fetchedAt || 0)
pullServerAvatars(activeSourceRoster)
trackInboundActivity(roster)
backfillMessagingProtocol(activeSourceRoster)
// React Query owns the stable server snapshot; derived arrays intentionally
// follow that snapshot rather than retriggering on their own atom writes.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data])
}
@@ -0,0 +1,195 @@
import { host } from '@hermes/plugin-sdk'
import type { ReactNode } from 'react'
import { botRosterKey } from './data'
import type { useBots } from './i18n'
import type { RosterGroupRow } from './roster-pane-derivation'
import { GatewayKindGlyph, GatewaySectionHeading, RosterSectionHeader } from './roster-sections'
import type { ResolvedRosterGatewaySection } from './roster-sections'
import type { BotMeta, GroupMember, RosterRow } from './types'
import type { $botSections } from './user-sections'
import {
deleteBotSection,
groupRowsBySection,
moveBotSection,
moveBotsToSection,
UNASSIGNED_SECTION_KEY
} from './user-sections'
import { SectionDropZone, UserSectionHeader } from './user-sections-ui'
interface RosterSectionRenderersProps {
b: ReturnType<typeof useBots>
userSections: ReturnType<typeof $botSections.get>
roster: RosterRow[]
allMeta: Record<string, BotMeta>
dragging: string | null
rosterSectionCollapsed: (id: string) => boolean
toggleRosterSection: (id: string) => void
setSectionDialog: (
value: null | { bot?: RosterRow; mode: 'create' } | { id: string; mode: 'rename'; name: string }
) => void
renderBotRow: (bot: RosterRow, keyPrefix?: string) => ReactNode
renderGroupRow: (row: { members: GroupMember[]; name: string }) => ReactNode
sortedGroupRows: RosterGroupRow[]
}
export function rosterSectionRenderers({
b,
userSections,
roster,
allMeta,
dragging,
rosterSectionCollapsed,
toggleRosterSection,
setSectionDialog,
renderBotRow,
renderGroupRow,
sortedGroupRows
}: RosterSectionRenderersProps) {
const removeSection = (id: string) => {
const name = userSections.find(section => section.id === id)?.name || ''
const { members, undo } = deleteBotSection(id, roster)
// No confirmation: nothing is lost (the bots fall back to Unassigned) and
// the toast's Undo puts the section and its members back.
host.notify({
action: { label: b.sections.undo, onClick: undo },
durationMs: 8_000,
kind: 'info',
message: b.sections.deleted(name, members.length)
})
}
// USER SECTIONS — composed with the gateway sections, not instead of them.
// The gateway headings own the top level whenever the roster shows more
// than one connection (that axis answers "where does this run", which no
// folder name can, and a bot's membership lives in its profile on THAT
// gateway); user sections group the rows INSIDE each connection bucket,
// indented under it, and group the flat list when there is only one.
// `keyPrefix` keeps row keys unique across the gateway buckets.
type UserSectionRow = { bot: RosterRow; kind?: 'bot' } | RosterGroupRow
const renderUserSections = (rows: UserSectionRow[], keyPrefix = '') => {
// No sections made: the plain list, exactly as before this feature.
if (!userSections.length) {
return rows.map(row => (row.kind === 'group' ? renderGroupRow(row) : renderBotRow(row.bot, keyPrefix)))
}
const nested = Boolean(keyPrefix)
const blocks = groupRowsBySection(rows, userSections, allMeta)
return (
blocks
// An empty Unassigned is not worth a heading; an empty NAMED section
// is, because it is somewhere the user made and is about to drop into.
// Inside a gateway bucket the same empty section would repeat under
// every connection, so there it only appears while a drag is in flight
// (as the drop target it exists for); the row menu files into it
// regardless.
.filter(block => block.rows.length || (block.id && (!nested || dragging)))
.map(block => {
const key = `${keyPrefix}${block.id ? `user-section:${block.id}` : UNASSIGNED_SECTION_KEY}`
const collapsed = rosterSectionCollapsed(key)
const order = userSections.findIndex(section => section.id === block.id)
return (
<SectionDropZone
isSource={
Boolean(dragging) && block.rows.some(row => row.kind !== 'group' && botRosterKey(row.bot) === dragging)
}
key={key}
nested={nested}
onDropBot={rosterKey => {
const bot = roster.find(row => botRosterKey(row) === rosterKey)
// `block.id` is null for Unassigned, which is exactly the value
// moveBotsToSection wants for "clear the assignment".
if (bot) {
void moveBotsToSection([bot], block.id)
}
}}
>
<UserSectionHeader
canMoveDown={order >= 0 && order < userSections.length - 1}
canMoveUp={order > 0}
collapsed={collapsed}
count={block.rows.length}
id={block.id}
name={block.name}
onDelete={() => block.id && removeSection(block.id)}
onMove={delta => block.id && moveBotSection(block.id, delta)}
onRename={() => block.id && setSectionDialog({ id: block.id, mode: 'rename', name: block.name })}
onToggle={() => toggleRosterSection(key)}
/>
{collapsed ? null : block.rows.length ? (
<div className="grid min-w-0 gap-0.5">
{block.rows.map(row =>
row.kind === 'group' ? renderGroupRow(row) : renderBotRow(row.bot, `${key}:`)
)}
</div>
) : (
// Empty section: a quiet dashed slot that says what it is for,
// and doubles as a roomy drop target.
<div className="mx-1 mb-1 rounded-md border border-dashed border-(--ui-stroke-secondary) px-2 py-2 text-center text-[0.6875rem] text-(--ui-text-quaternary)">
{b.sections.emptyHint}
</div>
)}
</SectionDropZone>
)
})
)
}
const renderGatewaySection = (section: ResolvedRosterGatewaySection) => {
const sectionId = `gateway:${section.id}`
const collapsed = rosterSectionCollapsed(sectionId)
return (
<div className="min-w-0" key={sectionId}>
<GatewaySectionHeading
collapsed={collapsed}
count={section.rows.length}
onToggle={() => toggleRosterSection(sectionId)}
option={section.option}
/>
{collapsed ? null : (
<div className="grid min-w-0 gap-0.5">{renderUserSections(section.rows, `${section.id}:`)}</div>
)}
</div>
)
}
const renderGroupChatSection = () => {
const sectionId = 'group-chats'
const collapsed = rosterSectionCollapsed(sectionId)
return (
<div className="min-w-0" key={sectionId}>
<RosterSectionHeader
collapsed={collapsed}
count={sortedGroupRows.length}
icon="organization"
label={b.roster.groupChats}
onToggle={() => toggleRosterSection(sectionId)}
tip={`${sortedGroupRows.length} global group chat${sortedGroupRows.length === 1 ? '' : 's'}`}
/>
{collapsed ? null : <div className="grid min-w-0 gap-0.5">{sortedGroupRows.map(renderGroupRow)}</div>}
</div>
)
}
const renderHiddenGatewaySection = (section: ResolvedRosterGatewaySection) => (
<div className="min-w-0" key={`hidden-gateway:${section.id}`}>
<div className="flex min-w-0 items-center gap-1.5 px-2 py-1 text-[0.625rem] font-semibold uppercase tracking-wider text-(--ui-text-quaternary)">
<GatewayKindGlyph kind={section.option?.kind} />
<span className="min-w-0 flex-1 truncate">
{section.option?.label || section.option?.connectionId || 'Current gateway'}
</span>
<span className="shrink-0 font-normal tabular-nums">{section.rows.length}</span>
</div>
{section.rows.map(row => renderBotRow(row.bot, `hidden:${section.id}:`))}
</div>
)
return { renderUserSections, renderGatewaySection, renderGroupChatSection, renderHiddenGatewaySection }
}
@@ -0,0 +1,227 @@
import {
Button,
cn,
Codicon,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
SearchField,
Tip
} from '@hermes/plugin-sdk'
import { botSourceStatus } from './data'
import type { useBots } from './i18n'
import { setActivityToasts } from './roster-actions'
import { GatewayKindGlyph } from './roster-sections'
import type { rosterGatewayOptions } from './roster-sections'
import type { RosterActivityFilter, RosterKindFilter, RosterRow } from './types'
interface renderRosterToolbarProps {
b: ReturnType<typeof useBots>
activityToasts: boolean
activeSourceRoster: RosterRow[]
setCreateOpen: (value: boolean) => void
setGroupCreateOpen: (value: boolean) => void
setSectionDialog: (
value: null | { bot?: RosterRow; mode: 'create' } | { id: string; mode: 'rename'; name: string }
) => void
showRosterTools: boolean
showRosterSearch: boolean
showRosterFilters: boolean
query: string
setQuery: (value: string) => void
activeFilterCount: number
gatewayOptions: ReturnType<typeof rosterGatewayOptions>
rowKindFilter: RosterKindFilter
setRowKindFilter: (value: RosterKindFilter) => void
activityFilter: RosterActivityFilter
setActivityFilter: (value: RosterActivityFilter) => void
gatewayFilter: string
setGatewayFilter: (value: string) => void
}
export function renderRosterToolbar({
b,
activityToasts,
activeSourceRoster,
setCreateOpen,
setGroupCreateOpen,
setSectionDialog,
showRosterTools,
showRosterSearch,
showRosterFilters,
query,
setQuery,
activeFilterCount,
gatewayOptions,
rowKindFilter,
setRowKindFilter,
activityFilter,
setActivityFilter,
gatewayFilter,
setGatewayFilter
}: renderRosterToolbarProps) {
return (
<>
<div className="flex items-center justify-between gap-2 px-2.5 pt-2.5 pb-1.5">
<span className="text-[0.6875rem] font-semibold uppercase tracking-wider text-(--ui-text-quaternary)">
Bots
</span>
<div className="flex items-center gap-0.5">
<Tip
label={activityToasts ? 'Activity toasts on — click to silence' : 'Activity toasts off — click to enable'}
>
<Button
className="rounded-md text-(--ui-text-tertiary) hover:text-foreground"
onClick={() => setActivityToasts(!activityToasts)}
size="icon-xs"
variant="ghost"
>
<Codicon name={activityToasts ? 'bell' : 'bell-slash'} />
</Button>
</Tip>
<DropdownMenu>
<Tip label="New…">
<DropdownMenuTrigger asChild>
<Button
aria-label={b.roster.newBotOrGroup}
className="rounded-md text-(--ui-text-tertiary) hover:text-foreground"
size="icon-xs"
variant="ghost"
>
<Codicon name="add" />
</Button>
</DropdownMenuTrigger>
</Tip>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => setCreateOpen(true)}>
<Codicon className="mr-1.5" name="hubot" />
{b.bot.newTitle}
</DropdownMenuItem>
<DropdownMenuItem disabled={activeSourceRoster.length < 2} onSelect={() => setGroupCreateOpen(true)}>
<Codicon className="mr-1.5" name="organization" />
{b.group.newTitle}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => setSectionDialog({ mode: 'create' })}>
<Codicon className="mr-1.5" name="new-folder" />
{b.sections.newSection}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{showRosterTools ? (
<div className="flex min-w-0 items-center gap-1 px-2.5 pb-1.5">
{showRosterSearch ? (
<SearchField
aria-label={b.roster.search}
containerClassName={cn('min-w-0 flex-1', query ? 'opacity-100!' : 'opacity-50 focus-within:opacity-100')}
inputClassName="w-full text-[0.75rem] placeholder:text-(--ui-text-tertiary)"
key={'roster-search'}
onChange={setQuery}
placeholder={b.roster.searchPlaceholder}
value={query}
/>
) : (
<span className="min-w-0 flex-1" key={'roster-search-spacer'} />
)}
{showRosterFilters ? (
<DropdownMenu key={'roster-filters'}>
<Tip label={activeFilterCount ? `Filters (${activeFilterCount} active)` : 'Filter roster'}>
<DropdownMenuTrigger asChild>
<Button
aria-label={activeFilterCount ? `Filter roster, ${activeFilterCount} active` : 'Filter roster'}
className={cn(
'size-7 shrink-0 rounded-md text-(--ui-text-tertiary) hover:text-foreground',
activeFilterCount && 'text-(--ui-accent)'
)}
size="icon-xs"
variant="ghost"
>
<Codicon name="list-filter" />
</Button>
</DropdownMenuTrigger>
</Tip>
<DropdownMenuContent align="end">
{(
[
['all', b.roster.botsAndGroups],
['bots', b.roster.botsOnly],
['groups', b.roster.groupsOnly]
] as [RosterKindFilter, string][]
).map(([value, label]) => (
<DropdownMenuItem key={`kind:${value}`} onSelect={() => setRowKindFilter(value)}>
<span className="min-w-0 flex-1">{label}</span>
{rowKindFilter === value ? <Codicon name="check" /> : null}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
{(
[
['all', b.roster.anyActivity],
['active', b.roster.activeNow],
['recent', b.roster.recentlyActive],
['older', b.roster.older]
] as [RosterActivityFilter, string][]
).map(([value, label]) => (
<DropdownMenuItem key={`activity:${value}`} onSelect={() => setActivityFilter(value)}>
<span className="min-w-0 flex-1">{label}</span>
{activityFilter === value ? <Codicon name="check" /> : null}
</DropdownMenuItem>
))}
{gatewayOptions.length > 1 ? <DropdownMenuSeparator /> : null}
{gatewayOptions.length > 1 ? (
<DropdownMenuItem onSelect={() => setGatewayFilter('all')}>
<Codicon className="mr-1.5" name="globe" />
<span className="min-w-0 flex-1">All gateways</span>
{gatewayFilter === 'all' ? <Codicon name="check" /> : null}
</DropdownMenuItem>
) : null}
{gatewayOptions.length > 1
? gatewayOptions.map(option => {
const status = botSourceStatus({
sourceError: option.error,
sourceReachable: option.reachable
})
return (
<DropdownMenuItem
key={option.connectionId}
onSelect={() => setGatewayFilter(option.connectionId)}
>
<GatewayKindGlyph
className={cn('mr-1.5', !status.available && 'text-amber-600 dark:text-amber-300')}
kind={option.kind}
/>
<span className="min-w-0 flex-1 truncate">{option.label || option.connectionId}</span>
<span className="text-[0.625rem] tabular-nums text-(--ui-text-quaternary)">
{option.count}
</span>
{gatewayFilter === option.connectionId ? <Codicon name="check" /> : null}
</DropdownMenuItem>
)
})
: []}
{activeFilterCount ? <DropdownMenuSeparator /> : null}
{activeFilterCount ? (
<DropdownMenuItem
onSelect={() => {
setRowKindFilter('all')
setActivityFilter('all')
setGatewayFilter('all')
}}
>
{b.roster.clearFilters}
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
) : null}
</>
)
}
File diff suppressed because it is too large Load Diff