From f9993d5be25e664ef802b5fcd3c012a732eff2a7 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 8 Sep 2026 12:24:09 -0700 Subject: [PATCH] refactor: extract group member phases and roster presentation --- .../hermes-bots/cross-connection-bots.test.ts | 4 +- .../plugins/hermes-bots/group-chat.test.ts | 10 +- .../hermes-bots/group-round-members.ts | 377 +++++++ .../plugins/hermes-bots/group-round-prompt.ts | 68 ++ .../plugins/hermes-bots/group-rounds.test.ts | 20 +- .../src/plugins/hermes-bots/group-rounds.ts | 422 +------- .../src/plugins/hermes-bots/group-turns.ts | 426 ++++---- .../hermes-bots/roster-pane-content.tsx | 178 ++++ .../hermes-bots/roster-pane-derivation.ts | 248 +++++ .../hermes-bots/roster-pane-dialogs.tsx | 161 +++ .../hermes-bots/roster-pane-lifecycle.ts | 54 + .../hermes-bots/roster-pane-sections.tsx | 195 ++++ .../hermes-bots/roster-pane-toolbar.tsx | 227 +++++ .../src/plugins/hermes-bots/roster-pane.tsx | 954 ++++-------------- 14 files changed, 2005 insertions(+), 1339 deletions(-) create mode 100644 apps/desktop/src/plugins/hermes-bots/group-round-members.ts create mode 100644 apps/desktop/src/plugins/hermes-bots/group-round-prompt.ts create mode 100644 apps/desktop/src/plugins/hermes-bots/roster-pane-content.tsx create mode 100644 apps/desktop/src/plugins/hermes-bots/roster-pane-derivation.ts create mode 100644 apps/desktop/src/plugins/hermes-bots/roster-pane-dialogs.tsx create mode 100644 apps/desktop/src/plugins/hermes-bots/roster-pane-lifecycle.ts create mode 100644 apps/desktop/src/plugins/hermes-bots/roster-pane-sections.tsx create mode 100644 apps/desktop/src/plugins/hermes-bots/roster-pane-toolbar.tsx diff --git a/apps/desktop/src/plugins/hermes-bots/cross-connection-bots.test.ts b/apps/desktop/src/plugins/hermes-bots/cross-connection-bots.test.ts index 2c075e8348..b241c223a4 100644 --- a/apps/desktop/src/plugins/hermes-bots/cross-connection-bots.test.ts +++ b/apps/desktop/src/plugins/hermes-bots/cross-connection-bots.test.ts @@ -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', diff --git a/apps/desktop/src/plugins/hermes-bots/group-chat.test.ts b/apps/desktop/src/plugins/hermes-bots/group-chat.test.ts index b88c7056d1..4c25e67e9b 100644 --- a/apps/desktop/src/plugins/hermes-bots/group-chat.test.ts +++ b/apps/desktop/src/plugins/hermes-bots/group-chat.test.ts @@ -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). diff --git a/apps/desktop/src/plugins/hermes-bots/group-round-members.ts b/apps/desktop/src/plugins/hermes-bots/group-round-members.ts new file mode 100644 index 0000000000..65838ada67 --- /dev/null +++ b/apps/desktop/src/plugins/hermes-bots/group-round-members.ts @@ -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 { + 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 { + 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 { + 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 +} diff --git a/apps/desktop/src/plugins/hermes-bots/group-round-prompt.ts b/apps/desktop/src/plugins/hermes-bots/group-round-prompt.ts new file mode 100644 index 0000000000..1ee31a6fa1 --- /dev/null +++ b/apps/desktop/src/plugins/hermes-bots/group-round-prompt.ts @@ -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') +} diff --git a/apps/desktop/src/plugins/hermes-bots/group-rounds.test.ts b/apps/desktop/src/plugins/hermes-bots/group-rounds.test.ts index fd9ec778bf..eb99b09e32 100644 --- a/apps/desktop/src/plugins/hermes-bots/group-rounds.test.ts +++ b/apps/desktop/src/plugins/hermes-bots/group-rounds.test.ts @@ -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) => rounds.formatGroupChatLine(entry as GroupMessage, 'research') + const line = (entry: Partial) => 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) }) }) diff --git a/apps/desktop/src/plugins/hermes-bots/group-rounds.ts b/apps/desktop/src/plugins/hermes-bots/group-rounds.ts index 78a9e90c2a..da258c7474 100644 --- a/apps/desktop/src/plugins/hermes-bots/group-rounds.ts +++ b/apps/desktop/src/plugins/hermes-bots/group-rounds.ts @@ -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) { diff --git a/apps/desktop/src/plugins/hermes-bots/group-turns.ts b/apps/desktop/src/plugins/hermes-bots/group-turns.ts index 67f737ba87..e2cc962203 100644 --- a/apps/desktop/src/plugins/hermes-bots/group-turns.ts +++ b/apps/desktop/src/plugins/hermes-bots/group-turns.ts @@ -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 { - 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 | 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 + before: number + binding: { isLive(): boolean } +} + +async function pollGroupMemberTurn(context: GroupTurnPollContext): Promise { + 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([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 { - 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([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) diff --git a/apps/desktop/src/plugins/hermes-bots/roster-pane-content.tsx b/apps/desktop/src/plugins/hermes-bots/roster-pane-content.tsx new file mode 100644 index 0000000000..69ff4580e1 --- /dev/null +++ b/apps/desktop/src/plugins/hermes-bots/roster-pane-content.tsx @@ -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 + staleNotice: string | number | null + isLoading: boolean + initialRosterLoading: boolean + roster: RosterRow[] + error: ReturnType['error'] + gatewayUp: boolean + refetch: ReturnType['refetch'] + allBotsHidden: boolean + hiddenExpanded: boolean + rosterRows: ReturnType['rosterRows'] + matchingHiddenBots: RosterRow[] + query: string + selectedGateway: ReturnType[number] | undefined + showGatewaySections: boolean + sortedGroupRows: ReturnType['sortedGroupRows'] + gatewaySections: ReturnType['gatewaySections'] + showHiddenSection: boolean + hiddenSectionRef: RefObject + hasRosterConstraint: boolean + hiddenBots: RosterRow[] + showHiddenRows: boolean + hiddenGatewaySections: ReturnType['hiddenGatewaySections'] + renderBotRow: (bot: RosterRow, keyPrefix?: string) => ReactNode + renderGroupChatSection: ReturnType['renderGroupChatSection'] + renderGatewaySection: ReturnType['renderGatewaySection'] + renderUserSections: ReturnType['renderUserSections'] + renderHiddenGatewaySection: ReturnType['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 ? ( +
+ {staleNotice} +
+ ) : null} + {(isLoading || initialRosterLoading) && !roster.length ? ( +
+ +
+ ) : error && !roster.length ? ( +
+
+ {gatewayUp + ? b.roster.rosterUnavailable(error instanceof Error ? error.message : 'gateway error') + : b.roster.waitingForGateway} +
+ +
+ ) : roster.length === 0 ? ( + + ) : allBotsHidden && !hiddenExpanded ? ( +
+
+ + {b.roster.allHidden} +
+

{b.roster.allHiddenDesc}

+ +
+ ) : rosterRows.length === 0 && matchingHiddenBots.length === 0 ? ( +
+ +
+ ) : ( +
+
+ {showGatewaySections + ? [ + sortedGroupRows.length ? renderGroupChatSection() : null, + ...gatewaySections.sections.map(renderGatewaySection) + ].filter(Boolean) + : renderUserSections(rosterRows)} + {showHiddenSection ? ( +
+ {hasRosterConstraint ? ( +
+ + Hidden + {matchingHiddenBots.length} +
+ ) : ( + $showHiddenBots.set(!hiddenExpanded)} + > + + Hidden + {hiddenBots.length} + + )} + {showHiddenRows ? ( + matchingHiddenBots.length ? ( + hiddenGatewaySections.sectioned ? ( + hiddenGatewaySections.sections.map(renderHiddenGatewaySection) + ) : ( + matchingHiddenBots.map((bot: RosterRow) => renderBotRow(bot, 'hidden:')) + ) + ) : ( +
{b.roster.noHiddenMatch}
+ ) + ) : null} +
+ ) : null} +
+
+ )} + + ) +} diff --git a/apps/desktop/src/plugins/hermes-bots/roster-pane-derivation.ts b/apps/desktop/src/plugins/hermes-bots/roster-pane-derivation.ts new file mode 100644 index 0000000000..d83f1677de --- /dev/null +++ b/apps/desktop/src/plugins/hermes-bots/roster-pane-derivation.ts @@ -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 + gatewayFilter: string + query: string + activityFilter: RosterActivityFilter + rowKindFilter: RosterKindFilter + groupRooms: ReturnType + activeRosterKeys: Set + gatewayOptions: ReturnType + 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 = (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 +} + +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) { + // 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 } +} diff --git a/apps/desktop/src/plugins/hermes-bots/roster-pane-dialogs.tsx b/apps/desktop/src/plugins/hermes-bots/roster-pane-dialogs.tsx new file mode 100644 index 0000000000..8e1f25b161 --- /dev/null +++ b/apps/desktop/src/plugins/hermes-bots/roster-pane-dialogs.tsx @@ -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 + t: ReturnType['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['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 ( + <> + { + setCreateOpen(false) + void refetch() + }} + open={createOpen} + roster={activeSourceRoster} + /> + 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} + /> + { + if (!open) { + setSectionDialog(null) + } + }} + onSubmit={name => { + if (sectionDialog?.mode === 'rename') { + renameBotSection(sectionDialog.id, name) + } else { + createBotSection(name, sectionDialog?.bot ? [sectionDialog.bot] : []) + } + }} + open={Boolean(sectionDialog)} + /> + { + setEditing(null) + void refetch() + }} + open={Boolean(editing)} + /> + {grouping ? setGrouping(null)} /> : null} + + {'This will permanently delete the bot '} + {deleting.name} + {' and its associated Hermes profile at '} + {deleting.path}. This cannot be undone. + + ) : 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} + /> + 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} + /> + + ) +} diff --git a/apps/desktop/src/plugins/hermes-bots/roster-pane-lifecycle.ts b/apps/desktop/src/plugins/hermes-bots/roster-pane-lifecycle.ts new file mode 100644 index 0000000000..680cd76b68 --- /dev/null +++ b/apps/desktop/src/plugins/hermes-bots/roster-pane-lifecycle.ts @@ -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([]) + +interface RosterSnapshotInput { + data: ReturnType['data'] + live: RosterRow[] | null + roster: RosterRow[] + allMeta: Record + 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]) +} diff --git a/apps/desktop/src/plugins/hermes-bots/roster-pane-sections.tsx b/apps/desktop/src/plugins/hermes-bots/roster-pane-sections.tsx new file mode 100644 index 0000000000..3c63207871 --- /dev/null +++ b/apps/desktop/src/plugins/hermes-bots/roster-pane-sections.tsx @@ -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 + userSections: ReturnType + roster: RosterRow[] + allMeta: Record + 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 ( + 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) + } + }} + > + = 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 ? ( +
+ {block.rows.map(row => + row.kind === 'group' ? renderGroupRow(row) : renderBotRow(row.bot, `${key}:`) + )} +
+ ) : ( + // Empty section: a quiet dashed slot that says what it is for, + // and doubles as a roomy drop target. +
+ {b.sections.emptyHint} +
+ )} +
+ ) + }) + ) + } + + const renderGatewaySection = (section: ResolvedRosterGatewaySection) => { + const sectionId = `gateway:${section.id}` + const collapsed = rosterSectionCollapsed(sectionId) + + return ( +
+ toggleRosterSection(sectionId)} + option={section.option} + /> + {collapsed ? null : ( +
{renderUserSections(section.rows, `${section.id}:`)}
+ )} +
+ ) + } + + const renderGroupChatSection = () => { + const sectionId = 'group-chats' + const collapsed = rosterSectionCollapsed(sectionId) + + return ( +
+ toggleRosterSection(sectionId)} + tip={`${sortedGroupRows.length} global group chat${sortedGroupRows.length === 1 ? '' : 's'}`} + /> + {collapsed ? null :
{sortedGroupRows.map(renderGroupRow)}
} +
+ ) + } + + const renderHiddenGatewaySection = (section: ResolvedRosterGatewaySection) => ( +
+
+ + + {section.option?.label || section.option?.connectionId || 'Current gateway'} + + {section.rows.length} +
+ {section.rows.map(row => renderBotRow(row.bot, `hidden:${section.id}:`))} +
+ ) + + return { renderUserSections, renderGatewaySection, renderGroupChatSection, renderHiddenGatewaySection } +} diff --git a/apps/desktop/src/plugins/hermes-bots/roster-pane-toolbar.tsx b/apps/desktop/src/plugins/hermes-bots/roster-pane-toolbar.tsx new file mode 100644 index 0000000000..ca36666486 --- /dev/null +++ b/apps/desktop/src/plugins/hermes-bots/roster-pane-toolbar.tsx @@ -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 + 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 + 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 ( + <> +
+ + Bots + +
+ + + + + + + + + + + setCreateOpen(true)}> + + {b.bot.newTitle} + + setGroupCreateOpen(true)}> + + {b.group.newTitle} + + + setSectionDialog({ mode: 'create' })}> + + {b.sections.newSection} + + + +
+
+ {showRosterTools ? ( +
+ {showRosterSearch ? ( + + ) : ( + + )} + {showRosterFilters ? ( + + + + + + + + {( + [ + ['all', b.roster.botsAndGroups], + ['bots', b.roster.botsOnly], + ['groups', b.roster.groupsOnly] + ] as [RosterKindFilter, string][] + ).map(([value, label]) => ( + setRowKindFilter(value)}> + {label} + {rowKindFilter === value ? : null} + + ))} + + {( + [ + ['all', b.roster.anyActivity], + ['active', b.roster.activeNow], + ['recent', b.roster.recentlyActive], + ['older', b.roster.older] + ] as [RosterActivityFilter, string][] + ).map(([value, label]) => ( + setActivityFilter(value)}> + {label} + {activityFilter === value ? : null} + + ))} + {gatewayOptions.length > 1 ? : null} + {gatewayOptions.length > 1 ? ( + setGatewayFilter('all')}> + + All gateways + {gatewayFilter === 'all' ? : null} + + ) : null} + {gatewayOptions.length > 1 + ? gatewayOptions.map(option => { + const status = botSourceStatus({ + sourceError: option.error, + sourceReachable: option.reachable + }) + + return ( + setGatewayFilter(option.connectionId)} + > + + {option.label || option.connectionId} + + {option.count} + + {gatewayFilter === option.connectionId ? : null} + + ) + }) + : []} + {activeFilterCount ? : null} + {activeFilterCount ? ( + { + setRowKindFilter('all') + setActivityFilter('all') + setGatewayFilter('all') + }} + > + {b.roster.clearFilters} + + ) : null} + + + ) : null} +
+ ) : null} + + ) +} diff --git a/apps/desktop/src/plugins/hermes-bots/roster-pane.tsx b/apps/desktop/src/plugins/hermes-bots/roster-pane.tsx index cd94ee134a..1401c35042 100644 --- a/apps/desktop/src/plugins/hermes-bots/roster-pane.tsx +++ b/apps/desktop/src/plugins/hermes-bots/roster-pane.tsx @@ -1,33 +1,4 @@ -/** - * The Bots pane itself: the roster's selection reconciliation, the - * workspace-ownership reads its lifecycle keys off, and the pane that lists - * every bot and group chat. - * - * The top of the roster stack. It composes the rows, the section headings and - * the dialogs; nothing in Bot Mode imports it except the plugin entry point. - */ - -import { - atom, - Button, - cn, - Codicon, - ConfirmDialog, - DisclosureCaret, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, - GlyphSpinner, - host, - PanelEmpty, - RowButton, - SearchField, - Tip, - useI18n, - useValue -} from '@hermes/plugin-sdk' +import { host, useI18n, useValue } from '@hermes/plugin-sdk' import { useEffect, useRef, useState } from 'react' import { BotRow, GroupRow } from './bot-row' @@ -42,60 +13,43 @@ import { parseRosterKey, saveSelectedRosterBot } from './bot-state' -import { CreateAgentDialog, CreateGroupChatDialog, GroupDialog } from './create-dialog' +/** + * The Bots pane itself: the roster's selection reconciliation, the + * workspace-ownership reads its lifecycle keys off, and the pane that lists + * every bot and group chat. + * + * The top of the roster stack. It composes the rows, the section headings and + * the dialogs; nothing in Bot Mode imports it except the plugin entry point. + */ import { $botMeta, $lastRoster, annotateBotSource, - botActivitySession, botRosterKey, botSourceStatus, - filterBots, - preferReachableSameNameRows, sourceByConnection, useRoster } from './data' -import { EditProfileDialog } from './edit-profile-dialog' import { $groupChats, $groupChatWorkspace, $groupClarify, $groupNeedsYou } from './group-chat' -import { disbandGroupChat, GroupChatWorkspace, openGroupChat } from './group-chat-view' -import { groupChatMemberBots, groupChatNames, groupLastActivity } from './group-membership' +import { GroupChatWorkspace, openGroupChat } from './group-chat-view' +import { groupChatMemberBots } from './group-membership' import { $groupMainTabsRev, shouldRenderGroupChatInPane } from './group-panes' import { groupHasPendingClarify } from './group-turns' -import { $showHiddenBots, isBotHidden, isBotPinned } from './hidden-bots' +import { $showHiddenBots, isBotHidden } from './hidden-bots' import { useBots } from './i18n' -import { displayName } from './labels' -import { deleteBot, mergeServerMeta, pullServerAvatars } from './profile-ops' -import { $activityToasts, setActivityToasts, trackInboundActivity } from './roster-actions' -import { - botNeedsHandleLabel, - filterBotsByGateway, - GatewayKindGlyph, - GatewaySectionHeading, - groupMatchesRosterFilters, - rosterGatewayOptions, - rosterGatewaySections, - RosterSectionHeader -} from './roster-sections' -import type { ResolvedRosterGatewaySection } from './roster-sections' -import { botRosterMeta, botWorkspaceOwnerKey, setBotsWorkspaceOwner } from './routing' -import { ACTIVE_WINDOW_S, activeBots, BOT_ROSTER_SEARCH_THRESHOLD, rosterActivityMatches } from './row-helpers' -import { backfillMessagingProtocol } from './soul' +import { $activityToasts } from './roster-actions' +import { renderRosterContent } from './roster-pane-content' +import { deriveRosterPresentation, deriveRosterRows, sortRosterBots } from './roster-pane-derivation' +import { renderRosterDialogs } from './roster-pane-dialogs' +import { $lastSources, usePublishRosterSnapshot } from './roster-pane-lifecycle' +import { rosterSectionRenderers } from './roster-pane-sections' +import { renderRosterToolbar } from './roster-pane-toolbar' +import { botNeedsHandleLabel, rosterGatewayOptions } from './roster-sections' +import { botWorkspaceOwnerKey, setBotsWorkspaceOwner } from './routing' +import { activeBots } from './row-helpers' import type { BotMeta, GatewaySource, GroupMember, RosterActivityFilter, RosterKindFilter, RosterRow } from './types' -import { - $botSections, - $draggingBot, - createBotSection, - deleteBotSection, - groupRowsBySection, - moveBotSection, - moveBotsToSection, - renameBotSection, - UNASSIGNED_SECTION_KEY -} from './user-sections' -import { SectionDropZone, SectionNameDialog, useEscapeCancelsBotDrag, UserSectionHeader } from './user-sections-ui' - -/** Last source inventory returned by the desktop-wide agent roster. */ -const $lastSources = atom([]) +import { $botSections, $draggingBot } from './user-sections' +import { useEscapeCancelsBotDrag } from './user-sections-ui' // ── roster pane ────────────────────────────────────────────────────────────── @@ -234,21 +188,35 @@ export function releaseStaleOpenBotChat(focusedStoredId: null | string | undefin } } -/** The two row shapes the roster sorts together — `kind` is the discriminant. */ -interface RosterBotRow { - active: boolean - activity: number - bot: RosterRow - kind: 'bot' - pinned: boolean -} -interface RosterGroupRow { - active: boolean - activity: number - kind: 'group' - members: GroupMember[] - name: string - pinned: boolean +function useReconcileRosterOwner( + data: ReturnType['data'], + error: ReturnType['error'], + selectionHydrated: boolean, + roster: RosterRow[], + sourceSnapshot: GatewaySource[], + allMeta: Record +) { + // The roster has ANSWERED once data or a terminal error exists — that, not + // row count, is what lets this pane stop showing its loading state (an empty + // answer is a real answer; a pending one must not flash "No bots"). Keep the + // persisted-selection writes out of render: React may replay a render, but + // an abandoned render must never become a storage mutation. + useEffect(() => { + if (!data && !error) { + return + } + + $rosterHydrated.set(true) + + if (selectionHydrated) { + reconcileRosterSelection(roster, sourceSnapshot, allMeta) + const selected = selectedRosterBot(roster, $selectedRosterKey.get()) + + if ($botsPaneVisible.get() && !$groupChatWorkspace.get() && selected) { + setBotsWorkspaceOwner(botWorkspaceOwnerKey(selected), selected) + } + } + }, [data, error, selectionHydrated, roster, sourceSnapshot, allMeta]) } export function BotsPane() { @@ -306,19 +274,6 @@ export function BotsPane() { }, [gatewayUp, refetch]) const allMeta = useValue($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) // Resilience (@wesleysimplicio, #13): a failed refresh must not erase a // roster the user already had — mixed local+cloud gateways and remotes // waking from sleep fail transiently. Render the last good snapshot with @@ -330,16 +285,7 @@ export function BotsPane() { const sourceWithSelectedOwner = selectionHydrated && rosterHydrated ? rosterWithSelectedOwner(source, sourceSnapshot, selectedRosterKey) : source - 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) - }) + const { roster, activityOf, isPinned } = sortRosterBots(sourceWithSelectedOwner, allMeta) // React Query can briefly report neither loading nor data while the plugin // and the persisted connection registry hydrate. Keep that transition in a @@ -354,117 +300,58 @@ export function BotsPane() { setGatewayFilter('all') } }, [gatewayFilterExists]) - 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 hiddenExpanded = useValue($showHiddenBots) - 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 = (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 - - 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 rosterSectionCollapsed = (id: string): boolean => !hasRosterConstraint && collapsedRosterSections.has(id) - - const hiddenGatewaySections = rosterGatewaySections( - matchingHiddenBots.map((bot: RosterRow) => ({ - kind: 'bot', - bot - })), + const { + activeSourceRoster, + hiddenBots, + visibleRoster, + filteredHiddenBots, + groupNames, + rosterRows, + sortedGroupRows, + gatewaySections, + showGatewaySections + } = deriveRosterRows({ + roster, + allMeta, + gatewayFilter, + query, + activityFilter, + rowKindFilter, + groupRooms, + activeRosterKeys, gatewayOptions, - gatewayFilter - ) + activityOf, + isPinned + }) + + const { + activeFilterCount, + hasRosterConstraint, + matchingHiddenBots, + showHiddenSection, + showHiddenRows, + allBotsHidden, + showRosterSearch, + showRosterFilters, + showRosterTools, + hiddenGatewaySections + } = deriveRosterPresentation({ + rowKindFilter, + activityFilter, + gatewayFilter, + query, + filteredHiddenBots, + hiddenBots, + hiddenExpanded, + roster, + groupNames, + visibleRoster, + gatewayOptions + }) + + const rosterSectionCollapsed = (id: string): boolean => !hasRosterConstraint && collapsedRosterSections.has(id) const toggleRosterSection = (id: string): void => { setCollapsedRosterSections(previous => { @@ -493,56 +380,9 @@ export function BotsPane() { return () => cancelAnimationFrame(frame) }, [hiddenExpanded, hasRosterConstraint]) - useEffect(() => { - if (!live) { - return - } + usePublishRosterSnapshot({ data, live, roster, allMeta, activeSourceRoster }) - // 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]) - - // The roster has ANSWERED once data or a terminal error exists — that, not - // row count, is what lets this pane stop showing its loading state (an empty - // answer is a real answer; a pending one must not flash "No bots"). Keep the - // persisted-selection writes out of render: React may replay a render, but - // an abandoned render must never become a storage mutation. - useEffect(() => { - if (!data && !error) { - return - } - - $rosterHydrated.set(true) - - if (selectionHydrated) { - reconcileRosterSelection(roster, sourceSnapshot, allMeta) - const selected = selectedRosterBot(roster, $selectedRosterKey.get()) - - if ($botsPaneVisible.get() && !$groupChatWorkspace.get() && selected) { - setBotsWorkspaceOwner(botWorkspaceOwnerKey(selected), selected) - } - } - }, [data, error, selectionHydrated, roster, sourceSnapshot, allMeta]) + useReconcileRosterOwner(data, error, selectionHydrated, roster, sourceSnapshot, allMeta) const staleNotice = error && !live && roster.length @@ -580,509 +420,95 @@ export function BotsPane() { /> ) - 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) + const { renderUserSections, renderGatewaySection, renderGroupChatSection, renderHiddenGatewaySection } = + rosterSectionRenderers({ + b, + userSections, + roster, + allMeta, + dragging, + rosterSectionCollapsed, + toggleRosterSection, + setSectionDialog, + renderBotRow, + renderGroupRow, + sortedGroupRows }) - } - - // 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 ( - 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) - } - }} - > - = 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 ? ( -
- {block.rows.map(row => - row.kind === 'group' ? renderGroupRow(row) : renderBotRow(row.bot, `${key}:`) - )} -
- ) : ( - // Empty section: a quiet dashed slot that says what it is for, - // and doubles as a roomy drop target. -
- {b.sections.emptyHint} -
- )} -
- ) - }) - ) - } - - const renderGatewaySection = (section: ResolvedRosterGatewaySection) => { - const sectionId = `gateway:${section.id}` - const collapsed = rosterSectionCollapsed(sectionId) - - return ( -
- toggleRosterSection(sectionId)} - option={section.option} - /> - {collapsed ? null : ( -
{renderUserSections(section.rows, `${section.id}:`)}
- )} -
- ) - } - - const renderGroupChatSection = () => { - const sectionId = 'group-chats' - const collapsed = rosterSectionCollapsed(sectionId) - - return ( -
- toggleRosterSection(sectionId)} - tip={`${sortedGroupRows.length} global group chat${sortedGroupRows.length === 1 ? '' : 's'}`} - /> - {collapsed ? null :
{sortedGroupRows.map(renderGroupRow)}
} -
- ) - } - - const renderHiddenGatewaySection = (section: ResolvedRosterGatewaySection) => ( -
-
- - - {section.option?.label || section.option?.connectionId || 'Current gateway'} - - {section.rows.length} -
- {section.rows.map(row => renderBotRow(row.bot, `hidden:${section.id}:`))} -
- ) return (
-
- - Bots - -
- - - - - - - - - - - setCreateOpen(true)}> - - {b.bot.newTitle} - - setGroupCreateOpen(true)}> - - {b.group.newTitle} - - - setSectionDialog({ mode: 'create' })}> - - {b.sections.newSection} - - - -
-
- {showRosterTools ? ( -
- {showRosterSearch ? ( - - ) : ( - - )} - {showRosterFilters ? ( - - - - - - - - {( - [ - ['all', b.roster.botsAndGroups], - ['bots', b.roster.botsOnly], - ['groups', b.roster.groupsOnly] - ] as [RosterKindFilter, string][] - ).map(([value, label]) => ( - setRowKindFilter(value)}> - {label} - {rowKindFilter === value ? : null} - - ))} - - {( - [ - ['all', b.roster.anyActivity], - ['active', b.roster.activeNow], - ['recent', b.roster.recentlyActive], - ['older', b.roster.older] - ] as [RosterActivityFilter, string][] - ).map(([value, label]) => ( - setActivityFilter(value)}> - {label} - {activityFilter === value ? : null} - - ))} - {gatewayOptions.length > 1 ? : null} - {gatewayOptions.length > 1 ? ( - setGatewayFilter('all')}> - - All gateways - {gatewayFilter === 'all' ? : null} - - ) : null} - {gatewayOptions.length > 1 - ? gatewayOptions.map(option => { - const status = botSourceStatus({ - sourceError: option.error, - sourceReachable: option.reachable - }) - - return ( - setGatewayFilter(option.connectionId)} - > - - {option.label || option.connectionId} - - {option.count} - - {gatewayFilter === option.connectionId ? : null} - - ) - }) - : []} - {activeFilterCount ? : null} - {activeFilterCount ? ( - { - setRowKindFilter('all') - setActivityFilter('all') - setGatewayFilter('all') - }} - > - {b.roster.clearFilters} - - ) : null} - - - ) : null} -
- ) : null} - {staleNotice ? ( -
- {staleNotice} -
- ) : null} - {(isLoading || initialRosterLoading) && !roster.length ? ( -
- -
- ) : error && !roster.length ? ( -
-
- {gatewayUp - ? b.roster.rosterUnavailable(error instanceof Error ? error.message : 'gateway error') - : b.roster.waitingForGateway} -
- -
- ) : roster.length === 0 ? ( - - ) : allBotsHidden && !hiddenExpanded ? ( -
-
- - {b.roster.allHidden} -
-

{b.roster.allHiddenDesc}

- -
- ) : rosterRows.length === 0 && matchingHiddenBots.length === 0 ? ( -
- -
- ) : ( -
-
- {showGatewaySections - ? [ - sortedGroupRows.length ? renderGroupChatSection() : null, - ...gatewaySections.sections.map(renderGatewaySection) - ].filter(Boolean) - : renderUserSections(rosterRows)} - {showHiddenSection ? ( -
- {hasRosterConstraint ? ( -
- - Hidden - {matchingHiddenBots.length} -
- ) : ( - $showHiddenBots.set(!hiddenExpanded)} - > - - Hidden - {hiddenBots.length} - - )} - {showHiddenRows ? ( - matchingHiddenBots.length ? ( - hiddenGatewaySections.sectioned ? ( - hiddenGatewaySections.sections.map(renderHiddenGatewaySection) - ) : ( - matchingHiddenBots.map((bot: RosterRow) => renderBotRow(bot, 'hidden:')) - ) - ) : ( -
{b.roster.noHiddenMatch}
- ) - ) : null} -
- ) : null} -
-
- )} - { - setCreateOpen(false) - void refetch() - }} - open={createOpen} - roster={activeSourceRoster} - /> - 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} - /> - { - if (!open) { - setSectionDialog(null) - } - }} - onSubmit={name => { - if (sectionDialog?.mode === 'rename') { - renameBotSection(sectionDialog.id, name) - } else { - createBotSection(name, sectionDialog?.bot ? [sectionDialog.bot] : []) - } - }} - open={Boolean(sectionDialog)} - /> - { - setEditing(null) - void refetch() - }} - open={Boolean(editing)} - /> - {grouping ? setGrouping(null)} /> : null} - - {'This will permanently delete the bot '} - {deleting.name} - {' and its associated Hermes profile at '} - {deleting.path}. This cannot be undone. - - ) : 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} - /> - 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} - /> + {renderRosterToolbar({ + b, + activityToasts, + activeSourceRoster, + setCreateOpen, + setGroupCreateOpen, + setSectionDialog, + showRosterTools, + showRosterSearch, + showRosterFilters, + query, + setQuery, + activeFilterCount, + gatewayOptions, + rowKindFilter, + setRowKindFilter, + activityFilter, + setActivityFilter, + gatewayFilter, + setGatewayFilter + })} + {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 + })} + {renderRosterDialogs({ + b, + t, + createOpen, + setCreateOpen, + groupCreateOpen, + setGroupCreateOpen, + editing, + setEditing, + deleting, + setDeleting, + deletingGroup, + setDeletingGroup, + grouping, + setGrouping, + sectionDialog, + setSectionDialog, + roster, + activeSourceRoster, + refetch + })}
) }