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-view.tsx b/apps/desktop/src/plugins/hermes-bots/group-chat-view.tsx index 776576e34b..c93ca55793 100644 --- a/apps/desktop/src/plugins/hermes-bots/group-chat-view.tsx +++ b/apps/desktop/src/plugins/hermes-bots/group-chat-view.tsx @@ -94,7 +94,7 @@ import { } from './group-panes' import type { GroupComposerDraft, GroupDraftSetter } from './group-panes' import { sendToGroupChat, stopGroupThread } from './group-rounds' -import { clearGroupClarify } from './group-turns' +import { clearGroupClarify, renameGroupClarify } from './group-turns' import { botsText, useBots } from './i18n' import { displayName, slugify } from './labels' import { botRosterMeta, setBotsWorkspaceOwner } from './routing' @@ -238,7 +238,7 @@ export async function disbandGroupChat(group: string, members: RosterRow[]) { * rename, so even a member whose sid is later lost falls back to the same * "Group: " title lookup instead of a fresh "Group: ". * Returns the new name, or null when the target name is taken. */ -async function renameGroupChat(oldName: string, newName: string, members: GroupMember[] | null | undefined) { +export async function renameGroupChat(oldName: string, newName: string, members: GroupMember[] | null | undefined) { const next = String(newName || '') .trim() .slice(0, 64) @@ -299,9 +299,9 @@ async function renameGroupChat(oldName: string, newName: string, members: GroupM $groupNeedsYou.set(needs) } - // Mirrored clarify cards key by group name; drop the old room's — the - // next poll re-mirrors any still-blocking question under the new name. - clearGroupClarify(oldName) + // Mirrored clarify cards key by group name; a pending prompt's attention + // must follow the room to its new name, not disappear. + renameGroupClarify(oldName, next) // Local memberships: swap the name inside each member's canonical groups // list (syncs cross-machine via ui_meta). Remote members' seating lives in 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-membership.ts b/apps/desktop/src/plugins/hermes-bots/group-membership.ts index 8d8e8a00e4..bfbe5814f2 100644 --- a/apps/desktop/src/plugins/hermes-bots/group-membership.ts +++ b/apps/desktop/src/plugins/hermes-bots/group-membership.ts @@ -9,6 +9,42 @@ import { $groupChats, groupChatRoomKey } from './group-chat' import { botConnectionRoute, botRosterMeta, resolveBotConnectionRoute } from './routing' import type { BotMeta, GroupChat, GroupMember, RosterRow } from './types' +/** Follow the authoritative room record for one async operation. Rename moves + * the record wholesale (including legacy rooms without a roomId); disband + * retires this binding permanently, even if the same display name is reused. */ +export function followGroupChat(group: string, onRename: (name: string) => void) { + let live = !$groupChats.get()[group]?.tombstone + + const dispose = $groupChats.listen((rooms, previous) => { + const prior = previous?.[group] + + if (!live || !prior) { + return + } + + const current = rooms[group] + + if (current && !current.tombstone && current.roomId === prior.roomId) { + return + } + + const moved = Object.entries(rooms).find(([, room]) => + !room.tombstone && (prior.roomId ? room.roomId === prior.roomId : room === prior) + ) + + if (!moved) { + live = false + + return + } + + group = moved[0] + onRename(group) + }) + + return { dispose, isLive: () => live } +} + export function groupWorkspaceOwnerKey(group: string) { return `group:${groupChatRoomKey(group, $groupChats.get()[group])}` } 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 ff65ff2745..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, groupMemberKey } from './group-membership' -import { harvestStrandedGroupReply, isGroupPassText, runGroupChatMemberTurn } from './group-turns' +import { durableGroupChatMembers, followGroupChat, groupMemberKey } from './group-membership' +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,8 +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 startEpoch = ($groupChats.get()[group] || {}).epoch || 0 - const isCurrent = () => (($groupChats.get()[group] || {}).epoch || 0) === startEpoch + 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 @@ -519,6 +461,10 @@ export async function runGroupChatRounds(group: string, members: GroupMember[], } await harvestStrandedGroupReply(group, member) + + if (!binding.isLive()) { + return + } } const roomLog = (($groupChats.get()[group] || {}).log || []).filter( @@ -560,175 +506,13 @@ export async function runGroupChatRounds(group: string, members: GroupMember[], return } - const room = $groupChats.get()[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) { - 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) { - 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. - 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 - }) + const result = await runGroupRoundMember(context, member) + if (!binding.isLive() || result === null) { 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 } @@ -749,122 +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) { - 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 @@ -911,6 +588,8 @@ export async function runGroupChatRounds(group: string, members: GroupMember[], void harvestStrandedUntilSettled(group, members, thread) } } + + binding.dispose() } } @@ -919,39 +598,55 @@ 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 HARVEST_INTERVAL_MS = 5000 - const HARVEST_MAX_TRIES = 60 + const binding = followGroupChat(group, name => { + group = name + }) - for (let attempt = 0; attempt < HARVEST_MAX_TRIES; attempt++) { - await new Promise(resolve => window.setTimeout(resolve, HARVEST_INTERVAL_MS)) - const room = $groupChats.get()[group] + try { + const HARVEST_INTERVAL_MS = 5000 + const HARVEST_MAX_TRIES = 60 - if (!room || room.running) { - return - } + for (let attempt = 0; attempt < HARVEST_MAX_TRIES; attempt++) { + await new Promise(resolve => window.setTimeout(resolve, HARVEST_INTERVAL_MS)) + const room = $groupChats.get()[group] - const stranded = room.stranded || {} + if (!binding.isLive() || !room || room.running) { + return + } - if (!Object.keys(stranded).length) { - return - } + const stranded = room.stranded || {} - for (const member of members) { - if (Object.prototype.hasOwnProperty.call(stranded, groupMemberKey(member))) { - try { - await harvestStrandedGroupReply(group, member) - } catch { - // Best-effort: the next tick retries; the bound stops runaways. + if (!Object.keys(stranded).length) { + return + } + + for (const member of members) { + if (!binding.isLive()) { + return + } + + if (Object.prototype.hasOwnProperty.call(stranded, groupMemberKey(member))) { + try { + await harvestStrandedGroupReply(group, member) + } catch { + // Best-effort: the next tick retries; the bound stops runaways. + } } } } - } - recordGroupActivity(group, { - kind: 'failed', - member: null, - thread - }) + if (!binding.isLive()) { + return + } + + recordGroupActivity(group, { + kind: 'failed', + member: null, + thread + }) + } finally { + binding.dispose() + } } /** User send into a group room. `thread` continues that thread (its reply @@ -1026,26 +721,35 @@ export function sendToGroupChat( thread: target }) + const binding = followGroupChat(group, name => { + group = name + }) + + const drive = () => { + if (!binding.isLive()) { + binding.dispose() + + return + } + + void runGroupChatRounds(group, members, target) + .catch(() => { + if (binding.isLive()) { + updateGroupChat(group, (r: GroupChatRoom) => { + r.running = false + + return r + }) + } + }) + .finally(binding.dispose) + } + if (!wasRunning) { - void runGroupChatRounds(group, members, target).catch(() => { - updateGroupChat(group, (r: GroupChatRoom) => { - r.running = false - - return r - }) - }) + drive() } else { - // A loop is live; it bails at its next boundary. Chain the fresh loop - // after a short settle so exactly one drive owns the room. - setTimeout(() => { - void runGroupChatRounds(group, members, target).catch(() => { - updateGroupChat(group, (r: GroupChatRoom) => { - r.running = false - - return r - }) - }) - }, 250) + // Preserve the existing newer-send handoff delay, without pinning its name. + setTimeout(drive, 250) } return target diff --git a/apps/desktop/src/plugins/hermes-bots/group-turns.test.ts b/apps/desktop/src/plugins/hermes-bots/group-turns.test.ts index b956c4883c..bf85c51a62 100644 --- a/apps/desktop/src/plugins/hermes-bots/group-turns.test.ts +++ b/apps/desktop/src/plugins/hermes-bots/group-turns.test.ts @@ -359,11 +359,23 @@ describe('clarify and approvals (#90694)', () => { } it('holds the turn open while a member is blocked on clarify, then lands the reply', async () => { + let live: Awaited> | null = null + let sawPendingAttention = false + const room = await loadRoom({ clarifyUntil: { research: { payload: CLARIFY, until: 3 } }, + // The mirror pass runs while the question is still blocking — this is + // the observable proof the gate inspected pending_clarify. Asserting + // on $groupNeedsYou/$groupClarify AFTER the turn lands proves nothing: + // the clarify has already resolved and its mirror is gone by then. + onResumePoll: () => { + sawPendingAttention = sawPendingAttention || live!.turns.groupHasPendingClarify(live!.chat.$groupClarify.get(), 'Core') + }, turn: () => 'targeting staging' }) + live = room + const thread = room.rounds.sendToGroupChat( 'Core', [{ name: 'research', title: '' }], @@ -379,10 +391,9 @@ describe('clarify and approvals (#90694)', () => { expect(replies).toHaveLength(1) expect(replies[0].text).toBe('targeting staging') expect(Object.keys(room.chat.$groupClarify.get())).toHaveLength(0) - // The mirror pass ran while the question was blocking, badging the room. - // A poll that never inspects pending_clarify leaves this unset — it is the - // observable proof the gate executed. - expect(room.chat.$groupNeedsYou.get().Core).toBe(true) + expect(sawPendingAttention).toBe(true) + // Resolved and mirrored away — nothing left to badge. + expect(room.turns.groupHasPendingClarify(room.chat.$groupClarify.get(), 'Core')).toBe(false) }) it('mirrors a question, badges needs-you, and is idempotent per request', async () => { @@ -397,16 +408,20 @@ describe('clarify and approvals (#90694)', () => { expect(mirrored[0].requestId).toBe('req-clarify-1') expect(mirrored[0].question).toBe('Which env should I target?') expect(mirrored[0].choices).toEqual(['staging', 'prod']) - expect(chat.$groupNeedsYou.get().Core).toBe(true) + // Badge is derived from $groupClarify, not a copy — nothing writes + // $groupNeedsYou here, so there is nothing to keep in sync. + expect(turns.groupHasPendingClarify(chat.$groupClarify.get(), 'Core')).toBe(true) // Same request again: no new entry, identity preserved. turns.syncGroupClarify('Core', member, { pending_clarify: CLARIFY }) expect(Object.values(chat.$groupClarify.get())[0]).toBe(mirrored[0]) - // Question resolved server-side: the mirror clears. + // Question resolved server-side: the mirror clears, and so does the + // derived badge — no separate cleanup path required. expect(turns.syncGroupClarify('Core', member, {})).toBe(false) expect(Object.keys(chat.$groupClarify.get())).toHaveLength(0) + expect(turns.groupHasPendingClarify(chat.$groupClarify.get(), 'Core')).toBe(false) }) it('never mirrors a question for older backends without pending_clarify', async () => { @@ -465,14 +480,256 @@ describe('clarify and approvals (#90694)', () => { expect(remaining).toHaveLength(1) expect(remaining[0].group).toBe('Other') + // The derived badge follows $groupClarify with no separate cleanup step. + expect(room.turns.groupHasPendingClarify(room.chat.$groupClarify.get(), 'Core')).toBe(false) + expect(room.turns.groupHasPendingClarify(room.chat.$groupClarify.get(), 'Other')).toBe(true) + }) + + it('keeps pending prompts independent from mention attention through their lifecycle', async () => { + const { chat, turns } = await loadRoom() + const member = { name: 'research', title: '' } + turns.syncGroupClarify('Core', member, { pending_clarify: CLARIFY }) + expect(chat.$groupNeedsYou.get().Core).toBeFalsy() + turns.syncGroupClarify('Core', { name: 'ops' }, { pending_approval: APPROVAL }) + expect(Object.values(chat.$groupClarify.get())).toHaveLength(2) + turns.syncGroupClarify('Core', member, {}) + expect(turns.groupHasPendingClarify(chat.$groupClarify.get(), 'Core')).toBe(true) + turns.syncGroupClarify('Core', { name: 'ops' }, {}) + expect(turns.groupHasPendingClarify(chat.$groupClarify.get(), 'Core')).toBe(false) + chat.appendGroupChatEntry('Core', { kind: 'member', name: 'research' }, '@user please review') + turns.syncGroupClarify('Core', member, { pending_approval: APPROVAL }) + await turns.answerGroupClarify(Object.values(chat.$groupClarify.get())[0], member, 'deny') + expect(Object.values(chat.$groupClarify.get())).toHaveLength(0) + expect(chat.$groupNeedsYou.get().Core).toBe(true) + }) + + it('keeps late prompt snapshots on the live room and never revives a disbanded room', async ({ onTestFinished }) => { + for (const roomId of ['stable-room', undefined]) { + for (const disband of [false, true]) { + const room = await loadRoom({ turn: ({ n }) => n === 1 ? 'Completed reply' : '(pass)' }) + const view = await import('./group-chat-view') + const member = { name: 'research', title: '' } + room.chat.updateGroupChat('Core', current => ({ + ...current, roomId, running: true, epoch: 1, + log: [{ id: 'input', at: 1, from: { kind: 'user', name: 'You' }, text: '@research check', thread: 'thread' }] + })) + let entered!: () => void + let release!: () => void + const polled = new Promise(resolve => { entered = resolve }) + const held = new Promise(resolve => { release = resolve }) + const original = host.request as (method: string, params: Record) => Promise + let submitted = false + let answered = false + let polls = 0 + + host.request = async (method: string, params: Record) => { + const result = await original(method, params) + + if (method === 'prompt.submit') {submitted = true} + + if (method === 'clarify.respond') {answered = true} + + if (method === 'session.resume' && submitted && !answered) { + if (++polls === 1) { entered(); await held } + + return { ...result, pending_clarify: CLARIFY } + } + + return result + } + + const drive = room.rounds.runGroupChatRounds('Core', [member], 'thread') + await polled + + if (disband) { + await view.disbandGroupChat('Core', []) + release() + await drive + expect(Object.values(room.chat.$groupClarify.get())).toHaveLength(0) + expect(room.chat.$groupChats.get().Core === undefined || room.chat.$groupChats.get().Core.tombstone).toBe(true) + expect(room.chat.$groupChats.get().Core?.log || []).toHaveLength(0) + } else { + await view.renameGroupChat('Core', 'Renamed', []) + + const mirrored = new Promise(resolve => { + const stop = room.chat.$groupClarify.listen(entries => { + if (Object.keys(entries).length) { stop(); resolve() } + }) + }) + + release() + await mirrored + const [prompt] = Object.values(room.chat.$groupClarify.get()) + const correctRoom = prompt.group + await room.turns.answerGroupClarify(prompt, member, 'staging') + await drive + expect(correctRoom).toBe('Renamed') + expect(Object.keys(room.chat.$groupChats.get())).toEqual(['Renamed']) + expect(room.chat.$groupChats.get().Renamed.running).toBe(false) + expect(room.chat.$groupChats.get().Renamed.log.filter(entry => entry.from.kind === 'member').map(entry => entry.text)).toEqual(['Completed reply']) + expect(Object.values(room.chat.$groupClarify.get())).toHaveLength(0) + } + } + } + + // Rejected member setup/submit must not publish failure cues into a new room. + for (const continuation of [false, true]) { + for (const rejectedMethod of ['session.resume', 'prompt.submit']) { + const room = await loadRoom() + const view = await import('./group-chat-view') + const activity = await import('./group-activity') + const data = await import('./data') + const members = [{ name: 'research' }, { name: 'ops' }] + room.chat.updateGroupChat('Core', current => ({ + ...current, roomId: 'old-rejection-room', running: true, + log: continuation + ? [{ id: 'handoff', at: 1, from: { kind: 'member', name: 'research' }, text: '@ops check', thread: 'thread' }, + { id: 'input', at: 2, from: { kind: 'user', name: 'You' }, text: '@research check', thread: 'thread' }] + : [{ id: 'input', at: 1, from: { kind: 'user', name: 'You' }, text: 'check', thread: 'thread' }], + watermarks: { 'thread::research': continuation ? 2 : 0 } + })) + // The normal responder has no delta; the earlier unanswered @ops + // handoff is driven by the continuation phase. + let phaseEntered!: () => void + let release!: () => void + const entered = new Promise(resolve => { phaseEntered = resolve }) + const held = new Promise(resolve => { release = resolve }) + const original = host.request as (method: string, params: Record) => Promise + host.request = async (method: string, params: Record) => { + if (method === rejectedMethod) { phaseEntered(); await held; throw new Error('401 unauthorized late rejection') } + + return original(method, params) + } + const drive = room.rounds.runGroupChatRounds('Core', members, 'thread') + await entered + await view.disbandGroupChat('Core', []) + room.chat.updateGroupChat('Core', current => ({ ...current, roomId: 'replacement-rejection-room', tombstone: false })) + room.chat.appendGroupChatEntry('Core', { kind: 'member', name: 'research' }, '@user replacement needs you') + const before = structuredClone({ rooms: room.chat.$groupChats.get(), activity: activity.$groupActivity.get(), attention: data.$botAttention.get(), needsYou: room.chat.$groupNeedsYou.get() }) + release() + await drive + expect({ rooms: room.chat.$groupChats.get(), activity: activity.$groupActivity.get(), attention: data.$botAttention.get(), needsYou: room.chat.$groupNeedsYou.get() }).toEqual(before) + expect(room.gateway.rpcFor('prompt.submit')).toHaveLength(0) + } + } + + const clock = vi.spyOn(Date, 'now').mockReturnValue(0) + onTestFinished(() => clock.mockRestore()) + + for (const recreate of [false, true]) { + clock.mockReturnValue(0) + const room = await loadRoom() + const view = await import('./group-chat-view') + const member = { name: 'research', title: '' } + room.chat.updateGroupChat('Core', current => ({ ...current, roomId: 'retired-room' })) + let entered!: () => void + let release!: () => void + const polled = new Promise(resolve => { entered = resolve }) + const held = new Promise(resolve => { release = resolve }) + const original = host.request as (method: string, params: Record) => Promise + let submitted = false + + host.request = async (method: string, params: Record) => { + if (method === 'session.resume' && submitted) { + entered() + await held + throw new Error('poll rejected after deadline') + } + + const result = await original(method, params) + + if (method === 'prompt.submit') { submitted = true } + + return result + } + + const turn = room.turns.runGroupChatMemberTurn('Core', member, 'check', 'thread', []) + await polled + await view.disbandGroupChat('Core', []) + + if (recreate) { + room.chat.updateGroupChat('Core', current => ({ ...current, roomId: 'replacement-room' })) + room.turns.syncGroupClarify('Core', member, { pending_clarify: CLARIFY }) + } + + const roomsBefore = structuredClone(room.chat.$groupChats.get()) + const promptsBefore = structuredClone(room.chat.$groupClarify.get()) + // Cross even the hard cap while the rejected poll is still in flight. + clock.mockReturnValue(24 * 60 * 60 * 1000) + release() + expect(await turn).toBeNull() + expect(room.chat.$groupChats.get()).toEqual(roomsBefore) + expect(room.chat.$groupClarify.get()).toEqual(promptsBefore) + } + + // Retirement during one background harvest must fence the next member too. + const room = await loadRoom() + const view = await import('./group-chat-view') + const members = [{ name: 'research' }, { name: 'ops' }] + room.chat.updateGroupChat('Core', current => ({ + ...current, roomId: 'old-harvest-room', running: true, + stranded: { research: 0, ops: 0 } + })) + let tick!: () => void + const previousWindow = globalThis.window + vi.stubGlobal('window', { setTimeout: (callback: () => void) => { tick = callback; + + return 0 } }) + onTestFinished(() => { vi.stubGlobal('window', previousWindow) }) + let entered!: () => void + let release!: () => void + const polled = new Promise(resolve => { entered = resolve }) + const held = new Promise(resolve => { release = resolve }) + let background = false + const backgroundProfiles: unknown[] = [] + const original = host.request as (method: string, params: Record) => Promise + + host.request = async (method: string, params: Record) => { + if (method !== 'session.resume') { return original(method, params) } + + if (background) { + backgroundProfiles.push(params.profile) + + if (params.profile === 'research') { entered(); await held } + } + + return { running: true, pending_clarify: CLARIFY } + } + + await room.rounds.runGroupChatRounds('Core', members, 'thread') + background = true + tick() + await polled + await view.disbandGroupChat('Core', []) + const { setImmediate } = await import('node:timers/promises') + await setImmediate() + room.chat.updateGroupChat('Core', current => ({ + ...current, roomId: 'new-harvest-room', stranded: { research: 0, ops: 0 } + })) + const roomsBefore = structuredClone(room.chat.$groupChats.get()) + const promptsBefore = structuredClone(room.chat.$groupClarify.get()) + release() + // Let the released RPC and its background caller finish their microtasks. + await setImmediate() + expect(backgroundProfiles).toEqual(['research']) + expect(room.chat.$groupChats.get()).toEqual(roomsBefore) + expect(room.chat.$groupClarify.get()).toEqual(promptsBefore) }) it('holds the turn open on a command approval too', async () => { + let live: Awaited> | null = null + let sawPendingAttention = false + const room = await loadRoom({ approvalUntil: { research: { payload: APPROVAL, until: 3 } }, + onResumePoll: () => { + sawPendingAttention = sawPendingAttention || live!.turns.groupHasPendingClarify(live!.chat.$groupClarify.get(), 'Core') + }, turn: () => 'build cleaned' }) + live = room + const thread = room.rounds.sendToGroupChat( 'Core', [{ name: 'research', title: '' }], @@ -488,7 +745,8 @@ describe('clarify and approvals (#90694)', () => { expect(replies).toHaveLength(1) expect(replies[0].text).toBe('build cleaned') expect(Object.keys(room.chat.$groupClarify.get())).toHaveLength(0) - expect(room.chat.$groupNeedsYou.get().Core).toBe(true) + expect(sawPendingAttention).toBe(true) + expect(room.turns.groupHasPendingClarify(room.chat.$groupClarify.get(), 'Core')).toBe(false) }) it('mirrors an approval with its kind, command and server choices', async () => { diff --git a/apps/desktop/src/plugins/hermes-bots/group-turns.ts b/apps/desktop/src/plugins/hermes-bots/group-turns.ts index 7a79a37c14..e2cc962203 100644 --- a/apps/desktop/src/plugins/hermes-bots/group-turns.ts +++ b/apps/desktop/src/plugins/hermes-bots/group-turns.ts @@ -9,9 +9,9 @@ import { host } from '@hermes/plugin-sdk' import { recordGroupActivity } from './group-activity' -import { $groupChats, $groupClarify, $groupNeedsYou, appendGroupChatEntry, updateGroupChat } from './group-chat' +import { $groupChats, $groupClarify, appendGroupChatEntry, updateGroupChat } from './group-chat' import type { GroupChatRoom } from './group-chat' -import { groupMemberKey, groupSessionOwner } from './group-membership' +import { followGroupChat, groupMemberKey, groupSessionOwner } from './group-membership' import { botConnectionRoute, requestForBot } from './routing' import type { Attachment, GroupMember, GroupPrompt, GroupPromptQuestion, ProfileRoute } from './types' @@ -123,110 +123,132 @@ 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 room = $groupChats.get()[group] || {} - // New rooms title member sessions by their immutable roomId so a - // same-name recreate never resumes the old room's sessions by title; - // legacy rooms without a roomId fall back to the display name. - const title = `Group: ${room.roomId || group}` - const key = groupMemberKey(member) - const known = room.sessions && room.sessions[key] + const binding = followGroupChat(group, name => { + group = name + }) - // Try resuming what we know (stored sid first, then title lookup). - // - // FAIL CLOSED on a transient lookup failure — mirrors the sibling fix in - // findExistingCanonicalChat (87b645f52c). session.resume signals "this - // target genuinely doesn't exist" with JSON-RPC code 4007; every other - // failure (network blip, the backend still warming up after a restart, - // an oversized-resume refusal) means the real session might still be - // there and must not be read as "no session, mint a new one" — that - // forks the member's real history, and the fork silently overwrites - // room.sessions[key] so the old session becomes unreachable from the - // room. Only a genuine 4007 on BOTH targets means there truly is nothing - // to resume yet, so the loop falls through to session.create below. - for (const target of [known, title]) { - if (!target || target === true) { - continue - } + try { + const room = $groupChats.get()[group] || {} + // New rooms title member sessions by their immutable roomId so a + // same-name recreate never resumes the old room's sessions by title; + // legacy rooms without a roomId fall back to the display name. + const title = `Group: ${room.roomId || group}` + const key = groupMemberKey(member) + const known = room.sessions && room.sessions[key] - try { - const res = (await requestForBot(member, 'session.resume', { - session_id: target, - profile: member.name, - omit_messages: true - })) as GroupSessionSnapshot + // Try resuming what we know (stored sid first, then title lookup). + // + // FAIL CLOSED on a transient lookup failure — mirrors the sibling fix in + // findExistingCanonicalChat (87b645f52c). session.resume signals "this + // target genuinely doesn't exist" with JSON-RPC code 4007; every other + // failure (network blip, the backend still warming up after a restart, + // an oversized-resume refusal) means the real session might still be + // there and must not be read as "no session, mint a new one" — that + // forks the member's real history, and the fork silently overwrites + // room.sessions[key] so the old session becomes unreachable from the + // room. Only a genuine 4007 on BOTH targets means there truly is nothing + // to resume yet, so the loop falls through to session.create below. + for (const target of [known, title]) { + if (!target || target === true) { + continue + } - if (res?.session_id) { - // TODO(bot-mode-types): `known` is `room.sessions[key]`, which the - // domain model types `string | true` — and the `target === true` skip - // above shows the legacy `true` sentinel is expected here. A backend - // that answers the title resume without a `session_key` therefore - // stores `true` back into room.sessions and hands `true` on as the - // durable id, which later rides into `session_id` on the recovery - // resume and on session.interrupt. Typed as-written. - const stored = res.session_key || known + try { + const res = (await requestForBot(member, 'session.resume', { + session_id: target, + profile: member.name, + omit_messages: true + })) as GroupSessionSnapshot - if (stored) { - updateGroupChat(group, (current: GroupChatRoom) => { - current.sessions = { - ...(current.sessions || {}), - [key]: stored - } - current.sessionOwners = { - ...(current.sessionOwners || {}), - [key]: groupSessionOwner(member) - } - - return current - }) + if (!binding.isLive()) { + return { runtime: null } } - return { - runtime: res.session_id, - stored + if (res?.session_id) { + // TODO(bot-mode-types): `known` is `room.sessions[key]`, which the + // domain model types `string | true` — and the `target === true` skip + // above shows the legacy `true` sentinel is expected here. A backend + // that answers the title resume without a `session_key` therefore + // stores `true` back into room.sessions and hands `true` on as the + // durable id, which later rides into `session_id` on the recovery + // resume and on session.interrupt. Typed as-written. + const stored = res.session_key || known + + if (stored) { + updateGroupChat(group, (current: GroupChatRoom) => { + current.sessions = { + ...(current.sessions || {}), + [key]: stored + } + current.sessionOwners = { + ...(current.sessionOwners || {}), + [key]: groupSessionOwner(member) + } + + return current + }) + } + + return { + runtime: res.session_id, + stored + } } + } 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` + ) + } + /* genuinely doesn't exist (4007) — try the next target / fall through to create */ } - } 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`) - } - /* genuinely doesn't exist (4007) — try the next target / fall through to create */ } - } - const created = (await requestForBot(member, 'session.create', { - profile: member.name, - title, - // Room member sessions are plumbing — always hidden from the sidebar. - hidden: true, - // Explicit contracts (PR #97008): room plumbing sessions always rebuild - // from the member profile's CURRENT config on resume, never a stale - // stored model/provider pin. Older gateways ignore the unknown params; - // the server's hidden + "Group: " title fallback then covers legacy. - room_plumbing: true, - follow_profile_config: true - })) as { session_id?: string; stored_session_id?: string } + if (!binding.isLive()) { + return { runtime: null } + } - const stored = created?.stored_session_id || null + const created = (await requestForBot(member, 'session.create', { + profile: member.name, + title, + // Room member sessions are plumbing — always hidden from the sidebar. + hidden: true, + // Explicit contracts (PR #97008): room plumbing sessions always rebuild + // from the member profile's CURRENT config on resume, never a stale + // stored model/provider pin. Older gateways ignore the unknown params; + // the server's hidden + "Group: " title fallback then covers legacy. + room_plumbing: true, + follow_profile_config: true + })) as { session_id?: string; stored_session_id?: string } - if (stored) { - updateGroupChat(group, (r: GroupChatRoom) => { - r.sessions = { - ...(r.sessions || {}), - [key]: stored - } - r.sessionOwners = { - ...(r.sessionOwners || {}), - [key]: groupSessionOwner(member) - } + if (!binding.isLive()) { + return { runtime: null } + } - return r - }) - } + const stored = created?.stored_session_id || null - return { - runtime: created?.session_id || null, - stored + if (stored) { + updateGroupChat(group, (r: GroupChatRoom) => { + r.sessions = { + ...(r.sessions || {}), + [key]: stored + } + r.sessionOwners = { + ...(r.sessionOwners || {}), + [key]: groupSessionOwner(member) + } + + return r + }) + } + + return { + runtime: created?.session_id || null, + stored + } + } finally { + binding.dispose() } } @@ -493,16 +515,24 @@ export function syncGroupClarify(group: string, member: GroupMember, state: Grou questions: null } }) - // A blocked member is a question for the human — badge the room. - $groupNeedsYou.set({ - ...$groupNeedsYou.get(), - [group]: true - }) return true } -/** Drop every mirrored clarify belonging to `group` (disband/rename). */ +/** Whether `group` has any member currently blocked on a clarify or + * approval, given a $groupClarify snapshot. Pure by design: the caller + * (roster-pane) subscribes to $groupClarify itself via useValue and passes + * the live snapshot in, so the subscription actually drives the + * recalculation instead of existing only to force a re-render. $groupClarify + * is the single source of truth for this kind of attention — nothing copies + * it into a second boolean, so there is nothing to keep in sync when a + * prompt resolves, is answered, or the room is disbanded/renamed. */ +export function groupHasPendingClarify(clarifies: Record, group: string): boolean { + return Object.values(clarifies).some(entry => entry?.group === group) +} + +/** Drop every mirrored clarify belonging to `group` (disband — the room is + * gone, nothing to move the attention to). */ export function clearGroupClarify(group: string) { const all = $groupClarify.get() const next: Record = {} @@ -521,6 +551,41 @@ export function clearGroupClarify(group: string) { } } +/** Move existing prompts with the renamed room; active operations follow + * the same room through their scoped followGroupChat binding. */ +export function renameGroupClarify(oldName: string, newName: string) { + const all = $groupClarify.get() + const next: Record = {} + let changed = false + + // Preserve every mirror that isn't being renamed. Iteration order matters: + // a single pass keyed by insertion order can let a STALE mirror already + // stranded at newName (left behind by the in-flight-poll race noted + // below) clobber the just-migrated CURRENT mirror if the stale entry + // happens to iterate after it. Copying unrelated entries first and + // writing the migrated ones last guarantees the live room's prompt + // always wins its destination key. + for (const [key, value] of Object.entries(all)) { + if (value?.group !== oldName) { + next[key] = value + } + } + + for (const value of Object.values(all)) { + if (value?.group === oldName) { + changed = true + // Rebuild the key from the mirror's own memberKey rather than + // string-replacing oldName in place — a group name that happens to + // be a substring of the member key must not corrupt the rekey. + next[`${newName}::${value.memberKey}`] = { ...value, group: newName } + } + } + + if (changed) { + $groupClarify.set(next) + } +} + /** Answer a member's pending prompt from the room. Routes to the member's * OWN source (requestForBot), so cross-connection members work. * - clarify: `clarify.respond`; batch questions send one respond per @@ -535,40 +600,54 @@ export async function answerGroupClarify( member: GroupMember, answers: Record | string | undefined ) { - if (entry.kind === 'approval') { - await requestForBot(member, 'approval.respond', { - session_id: entry.sessionId || undefined, - request_id: entry.requestId, - choice: typeof answers === 'string' && answers ? answers : 'deny' - }) - } else if (entry.questions && entry.questions.length) { - for (const question of entry.questions) { - // Question ids are opaque on the wire (`GroupPrompt.questions` types - // them `unknown`); the batch card keys its answer bag by exactly them. - const qid = (question?.qid ?? question?.id) as string + let group = entry.group + + const binding = followGroupChat(group, name => { + group = name + }) + + try { + if (entry.kind === 'approval') { + await requestForBot(member, 'approval.respond', { + session_id: entry.sessionId || undefined, + request_id: entry.requestId, + choice: typeof answers === 'string' && answers ? answers : 'deny' + }) + } else if (entry.questions && entry.questions.length) { + for (const question of entry.questions) { + // Question ids are opaque on the wire (`GroupPrompt.questions` types + // them `unknown`); the batch card keys its answer bag by exactly them. + const qid = (question?.qid ?? question?.id) as string + await requestForBot(member, 'clarify.respond', { + request_id: entry.requestId, + question_id: qid, + answer: (answers as Record)?.[qid] ?? '' + }) + } + } else { await requestForBot(member, 'clarify.respond', { request_id: entry.requestId, - question_id: qid, - answer: (answers as Record)?.[qid] ?? '' + answer: typeof answers === 'string' ? answers : '' }) } - } else { - await requestForBot(member, 'clarify.respond', { - request_id: entry.requestId, - answer: typeof answers === 'string' ? answers : '' - }) - } - const all = $groupClarify.get() - const key = `${entry.group}::${entry.memberKey}` - - if (all[key]?.requestId === entry.requestId) { - const next = { - ...all + if (!binding.isLive()) { + return } - delete next[key] - $groupClarify.set(next) + const all = $groupClarify.get() + const key = `${group}::${entry.memberKey}` + + if (all[key]?.requestId === entry.requestId) { + const next = { + ...all + } + + delete next[key] + $groupClarify.set(next) + } + } finally { + binding.dispose() } } @@ -590,59 +669,23 @@ 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 releaseTurnLease = await retainGroupTurnRoute(member) + const binding = followGroupChat(group, name => { + group = name + }) + + let releaseTurnLease: (() => void) | undefined try { - return await runGroupChatMemberTurnLeased(group, member, prompt, thread, images) + releaseTurnLease = await retainGroupTurnRoute(member) + + return binding.isLive() ? await runGroupChatMemberTurnLeased(group, member, prompt, thread, images) : null } finally { - releaseTurnLease() + releaseTurnLease?.() + binding.dispose() } } -async function runGroupChatMemberTurnLeased( - group: string, - member: GroupMember, - prompt: string, - thread: string, - images?: Attachment[] -): Promise { - const { runtime, stored } = await ensureGroupChatSession(group, member) - - if (!runtime) { - return null - } - - // #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]) - - 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 */ - } - +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 @@ -688,15 +731,24 @@ async function runGroupChatMemberTurnLeased( } } - const turnText = fileRefs.length - ? `${prompt}\n\nAttached files staged in your session workspace:\n${fileRefs.join('\n')}` - : prompt + return fileRefs +} - // #93602: one-shot recovery when the runtime session was reaped between - // minting and submitting. Tracks the runtime id the submit landed on so - // the poll fallback below targets a live session. - const liveRuntime = await submitGroupTurnPrompt(member, runtime, stored, turnText) - runtimeIds.add(liveRuntime) +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 @@ -717,7 +769,11 @@ async function runGroupChatMemberTurnLeased( // 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). - const roomDuringPoll = $groupChats.get()[group] || {} + if (!binding.isLive()) { + return null + } + + const roomDuringPoll = $groupChats.get()[context.group] || {} if ((roomDuringPoll.epoch || 0) !== dispatchEpoch && (roomDuringPoll.holds || {})[memberKey]) { return null @@ -734,6 +790,10 @@ async function runGroupChatMemberTurnLeased( continue } + if (!binding.isLive()) { + return null + } + if (state?.session_id) { runtimeIds.add(state.session_id) } @@ -743,14 +803,14 @@ async function runGroupChatMemberTurnLeased( // 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 awaitingUser = syncGroupClarify(context.group, member, state) const done = !busy && !awaitingUser if (messages.length > before && done) { const replyText = pickGroupTurnReply(messages, before) if (replyText !== null) { - recordGroupActivity(group, { + recordGroupActivity(context.group, { kind: isGroupPassText(replyText) ? 'passed' : 'replied', member: member.name, thread @@ -759,7 +819,7 @@ async function runGroupChatMemberTurnLeased( return replyText } - recordGroupActivity(group, { + recordGroupActivity(context.group, { kind: 'passed', member: member.name, thread @@ -776,17 +836,21 @@ async function runGroupChatMemberTurnLeased( } } + 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, { + recordGroupActivity(context.group, { kind: 'timed-out', member: member.name, thread }) - syncGroupClarify(group, member, null) - updateGroupChat(group, (r: GroupChatRoom) => { + syncGroupClarify(context.group, member, null) + updateGroupChat(context.group, (r: GroupChatRoom) => { r.stranded = { ...(r.stranded || {}), [groupMemberKey(member)]: { @@ -801,86 +865,192 @@ async function runGroupChatMemberTurnLeased( 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, + prompt: string, + thread: string, + images?: Attachment[] +): Promise { + const binding = followGroupChat(group, name => { + group = name + }) + + try { + const { runtime, stored } = await ensureGroupChatSession(group, member) + + if (!runtime || !binding.isLive()) { + return null + } + + // #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 + recordGroupActivity(group, { + kind: 'working', + member: member.name, + thread + }) + + const { before, runtimeIds } = await prepareGroupTurnBaseline(member, runtime, stored) + + const fileRefs = await stageGroupTurnAttachments(member, runtime, images) + + if (!binding.isLive()) { + return null + } + + const turnText = fileRefs.length + ? `${prompt}\n\nAttached files staged in your session workspace:\n${fileRefs.join('\n')}` + : prompt + + // #93602: one-shot recovery when the runtime session was reaped between + // minting and submitting. Tracks the runtime id the submit landed on so + // the poll fallback below targets a live session. + const liveRuntime = await submitGroupTurnPrompt(member, runtime, stored, turnText) + + if (!binding.isLive()) { + return null + } + + runtimeIds.add(liveRuntime) + + return await pollGroupMemberTurn({ + get group() { + return group + }, + member, + thread, + dispatchEpoch, + stored, + liveRuntime, + runtimeIds, + before, + binding + }) + } finally { + binding.dispose() + } +} + /** Post a timed-out member's finished reply into the room, if it landed * 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 memberKey = groupMemberKey(member) - const room = $groupChats.get()[group] || {} - const marker = room.stranded?.[memberKey] - // Markers were a bare number before threads; normalize both shapes. - const strandedBefore = typeof marker === 'number' ? marker : marker?.before - const strandedThread = (typeof marker === 'object' && marker?.thread) || 'legacy' - - if (typeof strandedBefore !== 'number') { - return - } - - let state: GroupSessionSnapshot | null = null + const binding = followGroupChat(group, name => { + group = name + }) try { - const stored = room.sessions?.[memberKey] - state = (await requestForBot(member, 'session.resume', { - session_id: stored || `Group: ${room.roomId || group}`, - profile: member.name - })) as GroupSessionSnapshot - } catch { - return // source unreachable — leave the marker for the next boundary - } + const memberKey = groupMemberKey(member) + const room = $groupChats.get()[group] || {} + const marker = room.stranded?.[memberKey] + // Markers were a bare number before threads; normalize both shapes. + const strandedBefore = typeof marker === 'number' ? marker : marker?.before + const strandedThread = (typeof marker === 'object' && marker?.thread) || 'legacy' - if (state?.inflight || state?.running) { - return // still grinding — keep waiting - } - - // A stranded member blocked on a clarify is not "grinding" — surface the - // question card (#90694) and keep the marker until it resolves. - if (syncGroupClarify(group, member, state)) { - return - } - - // Done (or dead): the marker is consumed either way. - updateGroupChat(group, (r: GroupChatRoom) => { - const next = { - ...(r.stranded || {}) + if (typeof strandedBefore !== 'number') { + return } - delete next[memberKey] - r.stranded = next + let state: GroupSessionSnapshot | null = null - return r - }) - const messages = Array.isArray(state?.messages) ? state.messages : [] + try { + const stored = room.sessions?.[memberKey] + state = (await requestForBot(member, 'session.resume', { + session_id: stored || `Group: ${room.roomId || group}`, + profile: member.name + })) as GroupSessionSnapshot + } catch { + return // source unreachable — leave the marker for the next boundary + } - if (messages.length <= strandedBefore) { - return - } + if (!binding.isLive()) { + return + } - const reply = pickGroupTurnReply(messages, strandedBefore) + // Pending prompts are authoritative even while the session is running. + const awaitingUser = syncGroupClarify(group, member, state) - if (reply && !isGroupPassText(reply)) { - recordGroupActivity(group, { - kind: 'delivered', - member: member.name, - thread: strandedThread - }) - appendGroupChatEntry( - group, - { - kind: 'member', - name: member.name, - ...(member.remoteSource - ? { - source: member.connectionLabel || member.connectionId - } - : {}) - }, - reply, - strandedThread - ) + if (state?.inflight || state?.running || awaitingUser) { + return + } + + // Done (or dead): the marker is consumed either way. updateGroupChat(group, (r: GroupChatRoom) => { - r.watermarks[`${strandedThread}::${memberKey}`] = r.log.length + const next = { + ...(r.stranded || {}) + } + + delete next[memberKey] + r.stranded = next return r }) + const messages = Array.isArray(state?.messages) ? state.messages : [] + + if (messages.length <= strandedBefore) { + return + } + + const reply = pickGroupTurnReply(messages, strandedBefore) + + if (reply && !isGroupPassText(reply)) { + recordGroupActivity(group, { + kind: 'delivered', + member: member.name, + thread: strandedThread + }) + appendGroupChatEntry( + group, + { + kind: 'member', + name: member.name, + ...(member.remoteSource + ? { + source: member.connectionLabel || member.connectionId + } + : {}) + }, + reply, + strandedThread + ) + updateGroupChat(group, (r: GroupChatRoom) => { + r.watermarks[`${strandedThread}::${memberKey}`] = r.log.length + + return r + }) + } + } finally { + binding.dispose() } } 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..94681c52da --- /dev/null +++ b/apps/desktop/src/plugins/hermes-bots/roster-pane-derivation.ts @@ -0,0 +1,237 @@ +import { botActivitySession } from './data' +import { botRosterKey, filterBots, preferReachableSameNameRows } from './data' +import type { $groupChats } from './group-chat' +import { groupChatMemberBots, groupChatNames, groupLastActivity } from './group-membership' +import { sortGroupRosterRows } from './group-order' +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 rosterRows = sortGroupRosterRows([...botRows, ...groupRows], groupRooms) + const sortedGroupRows = sortGroupRosterRows(groupRows, groupRooms) + 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-groups.tsx b/apps/desktop/src/plugins/hermes-bots/roster-pane-groups.tsx new file mode 100644 index 0000000000..417aa65666 --- /dev/null +++ b/apps/desktop/src/plugins/hermes-bots/roster-pane-groups.tsx @@ -0,0 +1,76 @@ +import { Button, Codicon, Tip } from '@hermes/plugin-sdk' +import type { ComponentProps } from 'react' + +import { GroupRow } from './bot-row' +import { $botMeta } from './data' +import { $groupChats, updateGroupChat } from './group-chat' +import type { $groupClarify, $groupNeedsYou } from './group-chat' +import { groupChatNames, groupLastActivity } from './group-membership' +import { reorderGroupRows, sortGroupRosterRows } from './group-order' +import { groupHasPendingClarify } from './group-turns' +import type { useBots } from './i18n' +import type { RosterGroupRow } from './roster-pane-derivation' + +interface RosterGroupRowViewProps extends Omit, 'needsYou'> { + b: ReturnType + groupClarify: ReturnType + groupNeedsYou: ReturnType + groupRooms: ReturnType + sortedGroupRows: RosterGroupRow[] +} + +export function RosterGroupRowView({ + b, + groupClarify, + groupNeedsYou, + groupRooms, + sortedGroupRows, + ...rowProps +}: RosterGroupRowViewProps) { + const moveRoom = (name: string, delta: -1 | 1) => { + // Read at the gesture, not the last render: a sync or disband may have + // replaced this room in the meantime. Ordering never writes bot metadata. + const current = $groupChats.get() + + if (current[name]?.roomId !== groupRooms[name]?.roomId || current[name]?.tombstone) { + return + } + + const rows = groupChatNames($botMeta.get(), current).map(name => ({ + kind: 'group' as const, + name, + pinned: Boolean(current[name]?.pinned), + activity: groupLastActivity(current[name]) + })) + + const order = reorderGroupRows(sortGroupRosterRows(rows, current), name, delta, sortedGroupRows.map(row => row.name)) + + order?.forEach((name, rosterOrder) => { + updateGroupChat(name, room => ({ ...room, rosterOrder }), { sync: false }) + }) + } + + return ( +
+ +
+ {([-1, 1] as const).map(delta => ( + + + + ))} +
+
+ ) +} 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 e8f7738973..ceaeae3145 100644 --- a/apps/desktop/src/plugins/hermes-bots/roster-pane.tsx +++ b/apps/desktop/src/plugins/hermes-bots/roster-pane.tsx @@ -1,36 +1,7 @@ -/** - * 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' +import { BotRow } from './bot-row' import { $botChatFocused, $botsPaneVisible, @@ -44,60 +15,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, $groupNeedsYou, updateGroupChat } from './group-chat' -import { disbandGroupChat, GroupChatWorkspace, openGroupChat } from './group-chat-view' -import { groupChatMemberBots, groupChatNames, groupLastActivity } from './group-membership' -import { reorderGroupRows, sortGroupRosterRows } from './group-order' +import { $groupChats, $groupChatWorkspace, $groupClarify, $groupNeedsYou } from './group-chat' +import { GroupChatWorkspace, openGroupChat } from './group-chat-view' +import { groupChatMemberBots } from './group-membership' import { $groupMainTabsRev, shouldRenderGroupChatInPane } from './group-panes' -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, useTurnBusy } 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 { RosterGroupRowView } from './roster-pane-groups' +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, useTurnBusy } 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 ────────────────────────────────────────────────────────────── @@ -236,21 +190,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() { @@ -295,6 +263,7 @@ export function BotsPane() { // room beside a live main tab and stick). useValue($groupMainTabsRev) const groupNeedsYou = useValue($groupNeedsYou) + const groupClarify = useValue($groupClarify) const groupRooms = useValue($groupChats) const rememberedSources = useValue($lastSources) const rosterHydrated = useValue($rosterHydrated) @@ -310,19 +279,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 @@ -334,16 +290,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 @@ -358,129 +305,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 rosterRows = sortGroupRosterRows([...botRows, ...groupRows], groupRooms) - const sortedGroupRows = sortGroupRosterRows(groupRows, groupRooms) - - const moveRoom = (name: string, delta: -1 | 1) => { - // Read at the gesture, not the last render: a sync or disband may have - // replaced this room in the meantime. Ordering never writes bot metadata. - const current = $groupChats.get() - - if (current[name]?.roomId !== groupRooms[name]?.roomId || current[name]?.tombstone) { - return - } - - const rows = groupChatNames($botMeta.get(), current).map(name => ({ - kind: 'group' as const, - name, - pinned: Boolean(current[name]?.pinned), - activity: groupLastActivity(current[name]) - })) - - const order = reorderGroupRows(sortGroupRosterRows(rows, current), name, delta, sortedGroupRows.map(row => row.name)) - - order?.forEach((name, rosterOrder) => { - updateGroupChat(name, room => ({ ...room, rosterOrder }), { sync: false }) - }) - } - - 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 => { @@ -509,56 +385,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 @@ -585,536 +414,110 @@ export function BotsPane() { ) const renderGroupRow = (row: { members: GroupMember[]; name: string }) => ( -
- -
- {([-1, 1] as const).map(delta => ( - - - - ))} -
-
+ ) - 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 + })}
) } diff --git a/website/docs/user-guide/bot-mode.md b/website/docs/user-guide/bot-mode.md index 4ed32b7672..7c086535b0 100644 --- a/website/docs/user-guide/bot-mode.md +++ b/website/docs/user-guide/bot-mode.md @@ -98,7 +98,7 @@ Use the **Move up** and **Move down** arrows beside a room to choose its positio - **One visible conversation.** Public messages and each member's reply stay readable in arrival order, with the speaker's name and timestamp. Starting another topic does not collapse earlier replies. **Reply in thread** continues that topic without reordering the room; **Activity** is a secondary status view, not a replacement for messages. Private Bot Chats remain separate. - Your message triggers up to **three serial rounds** of member turns. @-mentioned Bots respond (everyone responds when nobody is mentioned); each Bot replies briefly or passes, and the room settles when a full round stays silent. -- Bots pull each other in with `@name`, and escalate real judgment calls to you with `@user` — the group row shows a **needs you** badge when that happens. +- Bots pull each other in with `@name`, and escalate real judgment calls to you with `@user` — the group row shows a **needs you** badge when that happens. Pending questions and command approvals also light that badge; resolving the last prompt clears only prompt attention, not an independent mention. Prompts follow a renamed room, while disbanding retires them even if a member's in-flight poll arrives later. - Hard caps (10 messages per send, 3 rounds) keep rooms from spinning. - Each member keeps its own persistent `Group: ` session, so room context survives like any other conversation. - **Not every Bot replies to every message.** Speaking is each member's own choice — a Bot replies only when it has something new to add and passes otherwise, and @-mentioning specific members scopes the round to them. Expect the members you addressed (or whoever has something to say) to speak, and the rest to stay quiet.