refactor: extract group member phases and roster presentation
This commit is contained in:
@@ -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
Reference in New Issue
Block a user