Merge pull request #105960 from NousResearch/fix/group-attention-salvage
fix(bot-mode): clear resolved Group Chat attention and bind prompts across rename
This commit is contained in:
@@ -43,8 +43,8 @@ vi.mock('@hermes/plugin-sdk', async importOriginal => {
|
||||
const { botBackendProfileScope, botConnectionRoute, requestForBot } = await import('./routing')
|
||||
const { groupMemberKey } = await import('./group-membership')
|
||||
|
||||
const { buildGroupChatTurnPrompt, formatGroupChatLine, parseGroupChatMentions, resolveGroupResponders } =
|
||||
await import('./group-rounds')
|
||||
const { buildGroupChatTurnPrompt, formatGroupChatLine } = await import('./group-round-prompt')
|
||||
const { parseGroupChatMentions, resolveGroupResponders } = await import('./group-rounds')
|
||||
|
||||
const aliasBot = {
|
||||
name: 'worker',
|
||||
|
||||
@@ -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: <roomId>" title lookup instead of a fresh "Group: <new name>".
|
||||
* 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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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])}`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
import { clearBotAttention, noteBotAttention } from './data'
|
||||
import { recordGroupActivity } from './group-activity'
|
||||
import {
|
||||
$groupChats,
|
||||
appendGroupChatEntry,
|
||||
GROUP_CHAT_HISTORY_LIMIT,
|
||||
GROUP_CHAT_MAX_CONTINUATIONS,
|
||||
GROUP_CHAT_MAX_MESSAGES,
|
||||
groupThreadOf,
|
||||
shouldCommitMemberTurn,
|
||||
updateGroupChat
|
||||
} from './group-chat'
|
||||
import type { GroupChatRoom } from './group-chat'
|
||||
import { groupMemberKey } from './group-membership'
|
||||
import { buildGroupChatTurnPrompt, formatGroupChatLine } from './group-round-prompt'
|
||||
import { isGroupPassText, runGroupChatMemberTurn } from './group-turns'
|
||||
import type { GroupMember, GroupMessage } from './types'
|
||||
|
||||
export interface GroupRoundMemberContext {
|
||||
group: string
|
||||
members: GroupMember[]
|
||||
thread: string
|
||||
startEpoch: number
|
||||
binding: { isLive(): boolean }
|
||||
isCurrent(): boolean
|
||||
}
|
||||
|
||||
/** #93129: a held member's skip must consume its delta exactly once —
|
||||
* advance the watermark past the current log so the same entries never
|
||||
* re-trigger the skip. Null = nothing to consume (no write, no spin). */
|
||||
export function heldMemberWatermarkAdvance(seen: number | undefined, logLength: number): null | number {
|
||||
return logLength > (seen || 0) ? logLength : null
|
||||
}
|
||||
|
||||
function prepareGroupRoundMember(context: GroupRoundMemberContext, member: GroupMember) {
|
||||
const { members, thread } = context
|
||||
|
||||
const room = $groupChats.get()[context.group] || {
|
||||
log: [],
|
||||
watermarks: {}
|
||||
}
|
||||
|
||||
const memberKey = groupMemberKey(member)
|
||||
const markKey = `${thread}::${memberKey}`
|
||||
const seen = room.watermarks[markKey] || 0
|
||||
// Delta: NEW room entries, narrowed to this thread — the member's
|
||||
// turn sees only the conversation it's part of.
|
||||
const delta = room.log.slice(seen).filter((e: GroupMessage) => groupThreadOf(e) === thread)
|
||||
|
||||
if (!delta.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
// #93129: a member the user told to stop is HELD — no turn until an
|
||||
// explicit release (resume / @all resume / a direct non-stop
|
||||
// mention). Consume the delta exactly once (watermark past the
|
||||
// current log) so the same entries never re-trigger this skip, and
|
||||
// surface WHY the bot is silent in the activity feed the first time.
|
||||
const heldEntry = (room.holds || {})[memberKey]
|
||||
|
||||
if (heldEntry) {
|
||||
const advance = heldMemberWatermarkAdvance(seen, room.log.length)
|
||||
updateGroupChat(context.group, (r: GroupChatRoom) => {
|
||||
if (advance !== null) {
|
||||
r.watermarks[markKey] = advance
|
||||
}
|
||||
|
||||
if (r.holds?.[memberKey] && !r.holds[memberKey].noted) {
|
||||
r.holds = {
|
||||
...r.holds,
|
||||
[memberKey]: {
|
||||
...r.holds[memberKey],
|
||||
noted: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return r
|
||||
})
|
||||
|
||||
if (!heldEntry.noted) {
|
||||
recordGroupActivity(context.group, {
|
||||
kind: 'held',
|
||||
member: member.name,
|
||||
thread
|
||||
})
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const prompt = buildGroupChatTurnPrompt({
|
||||
groupName: context.group,
|
||||
members,
|
||||
viewer: member,
|
||||
deltaLines: delta.slice(-GROUP_CHAT_HISTORY_LIMIT).map((e: GroupMessage) => formatGroupChatLine(e, member.name))
|
||||
})
|
||||
|
||||
// Images riding this delta (user attachments — member entries don't
|
||||
// carry images today, but flatMap keeps this future-proof) get staged
|
||||
// into the member's session so the model sees the pixels, not just
|
||||
// the transcript's [attached image: …] marker.
|
||||
const deltaImages = delta.flatMap((e: GroupMessage) => (Array.isArray(e.images) ? e.images : []))
|
||||
|
||||
return { room, memberKey, markKey, prompt, deltaImages }
|
||||
}
|
||||
|
||||
export async function runGroupRoundMember(
|
||||
context: GroupRoundMemberContext,
|
||||
member: GroupMember
|
||||
): Promise<boolean | null> {
|
||||
const { thread, startEpoch, binding } = context
|
||||
const prepared = prepareGroupRoundMember(context, member)
|
||||
|
||||
if (!prepared) {
|
||||
return false
|
||||
}
|
||||
|
||||
const { room, markKey, prompt, deltaImages } = prepared
|
||||
// Surface WHO is on turn (runtime-only, like running/epoch) so the
|
||||
// room shows "Radar is thinking…" instead of a generic working line —
|
||||
// long model turns otherwise read as the room being stuck.
|
||||
updateGroupChat(context.group, (r: GroupChatRoom) => {
|
||||
r.turn = member.name
|
||||
|
||||
return r
|
||||
})
|
||||
let reply: null | string = null
|
||||
|
||||
try {
|
||||
reply = await runGroupChatMemberTurn(context.group, member, prompt, thread, deltaImages)
|
||||
|
||||
// Needs-attention hook (#93091 item 3): a turn that produced a real
|
||||
// reply (or an explicit pass) is a good turn — clear the badge.
|
||||
// A timed-out turn also returns null but never threw; leaving any
|
||||
// prior badge in place there is the conservative choice.
|
||||
if (reply !== null) {
|
||||
clearBotAttention(groupMemberKey(member))
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (!binding.isLive()) {
|
||||
return null
|
||||
}
|
||||
|
||||
const reason = String(error?.data?.reason || '').trim()
|
||||
recordGroupActivity(context.group, {
|
||||
kind: 'failed',
|
||||
member: member.name,
|
||||
thread,
|
||||
...(reason
|
||||
? {
|
||||
reason
|
||||
}
|
||||
: {})
|
||||
})
|
||||
noteBotAttention(groupMemberKey(member), reason || error?.message || error)
|
||||
reply = null // a failed turn is a pass, never a room error
|
||||
}
|
||||
|
||||
// #93127: the turn may have finished AFTER a newer user send bumped
|
||||
// the room epoch. That newer send's loop re-drives this member with
|
||||
// the full delta, so committing this stale result (watermark advance
|
||||
// + append) would double-deliver the same reply. Drop it here —
|
||||
// BEFORE the watermark advance and BEFORE the append. Only a newer
|
||||
// USER entry in THIS thread makes the re-drive premise true: a
|
||||
// cross-thread send bumps the epoch too, but its loop filters this
|
||||
// thread out and would never regenerate the finished reply. The
|
||||
// during-turn tail is anchored by entry id, not index — the history
|
||||
// trim drops entries from the FRONT, so an index slice could
|
||||
// overshoot after a mid-turn trim and silently commit a stale turn.
|
||||
if (!binding.isLive()) {
|
||||
return null
|
||||
}
|
||||
|
||||
const roomNow = $groupChats.get()[context.group] || {
|
||||
log: []
|
||||
}
|
||||
|
||||
const epochNow = roomNow.epoch || 0
|
||||
const anchorId = room.log.length ? room.log[room.log.length - 1].id : null
|
||||
const anchorIdx = anchorId === null ? -1 : roomNow.log.findIndex((e: GroupMessage) => e.id === anchorId)
|
||||
// Anchor trimmed away ⇒ every pre-turn entry was dropped, so every
|
||||
// surviving entry is newer — scanning the whole log stays exact.
|
||||
const turnTail = anchorIdx >= 0 ? roomNow.log.slice(anchorIdx + 1) : roomNow.log
|
||||
|
||||
const newerUserEntryInThread = turnTail.some(
|
||||
(e: GroupMessage) => e.from?.kind === 'user' && groupThreadOf(e) === thread
|
||||
)
|
||||
|
||||
if (!shouldCommitMemberTurn(startEpoch, epochNow, newerUserEntryInThread)) {
|
||||
recordGroupActivity(context.group, {
|
||||
kind: 'cancelled',
|
||||
member: member.name,
|
||||
thread
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// The member has now seen everything up to the pre-reply log length.
|
||||
updateGroupChat(context.group, (r: GroupChatRoom) => {
|
||||
r.watermarks[markKey] = r.log.length
|
||||
|
||||
return r
|
||||
})
|
||||
|
||||
if (reply !== null && !isGroupPassText(reply)) {
|
||||
appendGroupChatEntry(
|
||||
context.group,
|
||||
{
|
||||
kind: 'member',
|
||||
name: member.name,
|
||||
...(member.remoteSource
|
||||
? {
|
||||
source: member.connectionLabel || member.connectionId
|
||||
}
|
||||
: {})
|
||||
},
|
||||
reply,
|
||||
thread
|
||||
)
|
||||
// Its own message counts as seen too.
|
||||
updateGroupChat(context.group, (r: GroupChatRoom) => {
|
||||
r.watermarks[markKey] = r.log.length
|
||||
|
||||
return r
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
async function runGroupContinuationMember(
|
||||
context: GroupRoundMemberContext,
|
||||
member: GroupMember
|
||||
): Promise<boolean | null> {
|
||||
const { members, thread, binding, isCurrent } = context
|
||||
|
||||
const room = $groupChats.get()[context.group] || {
|
||||
log: [],
|
||||
watermarks: {}
|
||||
}
|
||||
|
||||
const memberKey = groupMemberKey(member)
|
||||
const markKey = `${thread}::${memberKey}`
|
||||
const seen = room.watermarks[markKey] || 0
|
||||
const delta = room.log.slice(seen).filter((e: GroupMessage) => groupThreadOf(e) === thread)
|
||||
|
||||
// A cited member always has delta here (the citing reply IS in
|
||||
// its tail); skip defensively anyway so an empty prompt never
|
||||
// fires.
|
||||
if (!delta.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
const heldEntry = (room.holds || {})[memberKey]
|
||||
|
||||
if (heldEntry) {
|
||||
return false // holds still apply to continuation turns (#93129)
|
||||
}
|
||||
|
||||
const prompt = buildGroupChatTurnPrompt({
|
||||
groupName: context.group,
|
||||
members,
|
||||
viewer: member,
|
||||
// The continuation prompt centers on what the member missed:
|
||||
// everything since its watermark, which includes the reply
|
||||
// that cites it.
|
||||
deltaLines: delta.slice(-GROUP_CHAT_HISTORY_LIMIT).map((e: GroupMessage) => formatGroupChatLine(e, member.name))
|
||||
})
|
||||
|
||||
updateGroupChat(context.group, (r: GroupChatRoom) => {
|
||||
r.turn = member.name
|
||||
|
||||
return r
|
||||
})
|
||||
let continuationReply: null | string = null
|
||||
|
||||
try {
|
||||
continuationReply = await runGroupChatMemberTurn(context.group, member, prompt, thread)
|
||||
|
||||
if (continuationReply !== null) {
|
||||
clearBotAttention(memberKey)
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (!binding.isLive()) {
|
||||
return null
|
||||
}
|
||||
|
||||
recordGroupActivity(context.group, {
|
||||
kind: 'failed',
|
||||
member: member.name,
|
||||
thread
|
||||
})
|
||||
noteBotAttention(memberKey, error?.message || error)
|
||||
continuationReply = null
|
||||
}
|
||||
|
||||
if (!isCurrent()) {
|
||||
return null
|
||||
}
|
||||
|
||||
updateGroupChat(context.group, (r: GroupChatRoom) => {
|
||||
r.watermarks[markKey] = r.log.length
|
||||
|
||||
return r
|
||||
})
|
||||
|
||||
if (continuationReply !== null && !isGroupPassText(continuationReply)) {
|
||||
appendGroupChatEntry(
|
||||
context.group,
|
||||
{
|
||||
kind: 'member',
|
||||
name: member.name,
|
||||
...(member.remoteSource
|
||||
? {
|
||||
source: member.connectionLabel || member.connectionId
|
||||
}
|
||||
: {})
|
||||
},
|
||||
continuationReply,
|
||||
thread
|
||||
)
|
||||
updateGroupChat(context.group, (r: GroupChatRoom) => {
|
||||
r.watermarks[markKey] = r.log.length
|
||||
|
||||
return r
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export async function runGroupContinuationMembers(
|
||||
context: GroupRoundMemberContext,
|
||||
pendingKeys: string[],
|
||||
continuations: number,
|
||||
posted: number
|
||||
): Promise<number | null> {
|
||||
const { members, isCurrent } = context
|
||||
let spokeThisRound = 0
|
||||
|
||||
if (pendingKeys.length && continuations <= GROUP_CHAT_MAX_CONTINUATIONS) {
|
||||
const citedMembers = members.filter((member: GroupMember) => pendingKeys.includes(groupMemberKey(member)))
|
||||
|
||||
if (citedMembers.length && posted < GROUP_CHAT_MAX_MESSAGES) {
|
||||
const strandedNow = ($groupChats.get()[context.group] || {}).stranded || {}
|
||||
|
||||
const continuationResponders = citedMembers.filter(
|
||||
(member: GroupMember) => !Object.prototype.hasOwnProperty.call(strandedNow, groupMemberKey(member))
|
||||
)
|
||||
|
||||
for (const member of continuationResponders) {
|
||||
if (!isCurrent() || posted >= GROUP_CHAT_MAX_MESSAGES || continuations > GROUP_CHAT_MAX_CONTINUATIONS) {
|
||||
break
|
||||
}
|
||||
|
||||
const result = await runGroupContinuationMember(context, member)
|
||||
|
||||
if (result === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (result) {
|
||||
posted += 1
|
||||
spokeThisRound += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return spokeThisRound
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { botHandle } from './data'
|
||||
import { groupSpeakerLabel } from './group-chat'
|
||||
import { groupMemberKey } from './group-membership'
|
||||
import type { GroupMember, GroupMessage } from './types'
|
||||
|
||||
/** Room-log line as a member sees it: `Name (user): …` / `Name: …` /
|
||||
* `Name (you): …`. */
|
||||
export function formatGroupChatLine(entry: GroupMessage, viewerName: string) {
|
||||
// Attachments are staged into each member's session as real payloads; the
|
||||
// transcript line names them so the delta text and the bytes line up.
|
||||
const attached =
|
||||
Array.isArray(entry.images) && entry.images.length
|
||||
? ` ${entry.images
|
||||
.map(img => {
|
||||
const label = img.kind === 'pdf' ? 'attached PDF' : img.kind === 'file' ? 'attached file' : 'attached image'
|
||||
|
||||
return `[${label}: ${img.name || 'image'}]`
|
||||
})
|
||||
.join(' ')}`
|
||||
: ''
|
||||
|
||||
if (entry.from.kind === 'user') {
|
||||
return `${entry.from.name || 'User'} (user): ${entry.text}${attached}`
|
||||
}
|
||||
|
||||
const suffix = entry.from.name === viewerName ? ' (you)' : ''
|
||||
// Cross-connection speakers carry their device so same-named agents on
|
||||
// two machines stay tellable apart in every member's transcript.
|
||||
const source = entry.from.source ? ` [${entry.from.source}]` : ''
|
||||
|
||||
return `${groupSpeakerLabel(entry.from.name)}${suffix}${source}: ${entry.text}${attached}`
|
||||
}
|
||||
|
||||
interface GroupChatTurnPromptInput {
|
||||
deltaLines: string[]
|
||||
groupName: string
|
||||
members: GroupMember[]
|
||||
viewer: GroupMember
|
||||
}
|
||||
|
||||
/** The full per-turn payload for one member: participation rules + the room
|
||||
* delta. Rules travel in the turn payload (not SOUL) so every existing bot
|
||||
* can join a group chat without a profile migration. */
|
||||
export function buildGroupChatTurnPrompt({ groupName, members, viewer, deltaLines }: GroupChatTurnPromptInput) {
|
||||
const viewerKey = groupMemberKey(viewer)
|
||||
const peers = members.filter(m => groupMemberKey(m) !== viewerKey)
|
||||
|
||||
const peerNames = peers
|
||||
.map(m => {
|
||||
const handle = m.title ? `${m.title} (@${botHandle(m.name, m)})` : `@${botHandle(m.name, m)}`
|
||||
|
||||
return m.remoteSource ? `${handle} [on ${m.connectionLabel || m.connectionId}]` : handle
|
||||
})
|
||||
.join(', ')
|
||||
|
||||
return [
|
||||
`[Group chat: "${groupName}"] You are @${botHandle(viewer.name, viewer)}, one participant in a group chat with ${peerNames || 'no one else yet'} and the user.`,
|
||||
'',
|
||||
'New messages in the room since your last turn (oldest first):',
|
||||
...deltaLines.map(line => ` ${line}`),
|
||||
'',
|
||||
'Rules for this room:',
|
||||
'- Reply with ONE conversational message ONLY if you have something new worth adding: build on what was just said, claim or hand off work, answer a question aimed at you, or report a real result. Keep chatter short (1-3 sentences) — but when you are delivering a result, an answer the user asked for, or substantive work, give it at full quality and length; never thin out real content to fit the room.',
|
||||
'- If you have nothing new to add, reply with exactly "(pass)". Passing is good — it lets the conversation settle.',
|
||||
'- Mention a teammate as @name to pull them in; mention @user only for a judgment call or a result the user needs. Do not repeat points already made.',
|
||||
'- Never reveal content from your private 1:1 chats. Your reply text goes to the room verbatim — no preamble, no meta-commentary.'
|
||||
].join('\n')
|
||||
}
|
||||
@@ -349,13 +349,14 @@ describe('threads', () => {
|
||||
describe('turn prompt', () => {
|
||||
it('addresses the default profile as @hermes', async () => {
|
||||
const { rounds } = await loadRoom()
|
||||
const { buildGroupChatTurnPrompt } = await import('./group-round-prompt')
|
||||
|
||||
const members: GroupMember[] = [
|
||||
{ name: 'default', title: '' },
|
||||
{ name: 'builder', title: '' }
|
||||
]
|
||||
|
||||
const own = rounds.buildGroupChatTurnPrompt({
|
||||
const own = buildGroupChatTurnPrompt({
|
||||
deltaLines: [],
|
||||
groupName: 'Core',
|
||||
members,
|
||||
@@ -365,7 +366,7 @@ describe('turn prompt', () => {
|
||||
expect(own).toMatch(/You are @hermes,/)
|
||||
expect(own).not.toMatch(/@default\b/)
|
||||
|
||||
const peer = rounds.buildGroupChatTurnPrompt({
|
||||
const peer = buildGroupChatTurnPrompt({
|
||||
deltaLines: [],
|
||||
groupName: 'Core',
|
||||
members,
|
||||
@@ -377,8 +378,9 @@ describe('turn prompt', () => {
|
||||
|
||||
it('asks for full-quality results and short chatter, not short results', async () => {
|
||||
const { rounds } = await loadRoom()
|
||||
const { buildGroupChatTurnPrompt } = await import('./group-round-prompt')
|
||||
|
||||
const prompt = rounds.buildGroupChatTurnPrompt({
|
||||
const prompt = buildGroupChatTurnPrompt({
|
||||
deltaLines: [],
|
||||
groupName: 'Core',
|
||||
members: [
|
||||
@@ -560,9 +562,10 @@ describe('attachments', () => {
|
||||
|
||||
it('names attachments in the transcript line, labelling PDFs and files distinctly', async () => {
|
||||
const { rounds } = await loadRoom()
|
||||
const { formatGroupChatLine } = await import('./group-round-prompt')
|
||||
const pdf: Attachment = { data: 'data:application/pdf;base64,JVBERi0=', kind: 'pdf', name: 'spec.pdf' }
|
||||
const doc: Attachment = { data: 'data:text/plain;base64,aGVsbG8=', kind: 'file', name: 'notes.txt' }
|
||||
const line = (entry: Partial<GroupMessage>) => rounds.formatGroupChatLine(entry as GroupMessage, 'research')
|
||||
const line = (entry: Partial<GroupMessage>) => formatGroupChatLine(entry as GroupMessage, 'research')
|
||||
|
||||
expect(line({ from: { kind: 'user', name: 'You' }, images: [IMG], text: 'see attached' })).toBe(
|
||||
'You (user): see attached [attached image: screenshot.png]'
|
||||
@@ -692,14 +695,15 @@ describe('member holds (#93129)', () => {
|
||||
|
||||
it('consumes a held skip exactly once so the loop cannot spin', async () => {
|
||||
const { rounds } = await loadRoom()
|
||||
const { heldMemberWatermarkAdvance } = await import('./group-round-members')
|
||||
|
||||
// Fresh delta → advance to log length.
|
||||
expect(rounds.heldMemberWatermarkAdvance(3, 7)).toBe(7)
|
||||
expect(heldMemberWatermarkAdvance(3, 7)).toBe(7)
|
||||
// Already consumed → no write, no spin.
|
||||
expect(rounds.heldMemberWatermarkAdvance(7, 7)).toBeNull()
|
||||
expect(rounds.heldMemberWatermarkAdvance(9, 7)).toBeNull()
|
||||
expect(heldMemberWatermarkAdvance(7, 7)).toBeNull()
|
||||
expect(heldMemberWatermarkAdvance(9, 7)).toBeNull()
|
||||
// Unset watermark treated as 0.
|
||||
expect(rounds.heldMemberWatermarkAdvance(undefined, 2)).toBe(2)
|
||||
expect(heldMemberWatermarkAdvance(undefined, 2)).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,28 +1,25 @@
|
||||
import { botFriendlyNames, botHandle, mentionNameForms } from './data'
|
||||
/**
|
||||
* Room-level coordination: who speaks, in what order, for how long — the
|
||||
* @mention parse, the round-robin driver, the #93129 member holds, the stop
|
||||
* path, and the user send that starts it all.
|
||||
*/
|
||||
|
||||
import { botFriendlyNames, botHandle, clearBotAttention, mentionNameForms, noteBotAttention } from './data'
|
||||
import { recordGroupActivity } from './group-activity'
|
||||
import {
|
||||
$groupChats,
|
||||
$groupNeedsYou,
|
||||
appendGroupChatEntry,
|
||||
GROUP_CHAT_HISTORY_LIMIT,
|
||||
GROUP_CHAT_MAX_CONTINUATIONS,
|
||||
GROUP_CHAT_MAX_MESSAGES,
|
||||
GROUP_CHAT_MAX_ROUNDS,
|
||||
groupSpeakerLabel,
|
||||
groupThreadOf,
|
||||
mintGroupThreadId,
|
||||
shouldCommitMemberTurn,
|
||||
updateGroupChat
|
||||
} from './group-chat'
|
||||
import type { GroupChatRoom, GroupHoldStamp } from './group-chat'
|
||||
import { durableGroupChatMembers, 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
|
||||
|
||||
@@ -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<ReturnType<typeof loadRoom>> | 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<void>(resolve => { entered = resolve })
|
||||
const held = new Promise<void>(resolve => { release = resolve })
|
||||
const original = host.request as (method: string, params: Record<string, unknown>) => Promise<any>
|
||||
let submitted = false
|
||||
let answered = false
|
||||
let polls = 0
|
||||
|
||||
host.request = async (method: string, params: Record<string, unknown>) => {
|
||||
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<void>(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<void>(resolve => { phaseEntered = resolve })
|
||||
const held = new Promise<void>(resolve => { release = resolve })
|
||||
const original = host.request as (method: string, params: Record<string, unknown>) => Promise<any>
|
||||
host.request = async (method: string, params: Record<string, unknown>) => {
|
||||
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<void>(resolve => { entered = resolve })
|
||||
const held = new Promise<void>(resolve => { release = resolve })
|
||||
const original = host.request as (method: string, params: Record<string, unknown>) => Promise<any>
|
||||
let submitted = false
|
||||
|
||||
host.request = async (method: string, params: Record<string, unknown>) => {
|
||||
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<void>(resolve => { entered = resolve })
|
||||
const held = new Promise<void>(resolve => { release = resolve })
|
||||
let background = false
|
||||
const backgroundProfiles: unknown[] = []
|
||||
const original = host.request as (method: string, params: Record<string, unknown>) => Promise<any>
|
||||
|
||||
host.request = async (method: string, params: Record<string, unknown>) => {
|
||||
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<ReturnType<typeof loadRoom>> | 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 () => {
|
||||
|
||||
@@ -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<GroupMemberSessionHandle> {
|
||||
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<string, GroupPrompt>, 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<string, GroupPrompt> = {}
|
||||
@@ -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<string, GroupPrompt> = {}
|
||||
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<GroupPrompt>(all)) {
|
||||
if (value?.group !== oldName) {
|
||||
next[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
for (const value of Object.values<GroupPrompt>(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, string> | 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<string, string>)?.[qid] ?? ''
|
||||
})
|
||||
}
|
||||
} else {
|
||||
await requestForBot(member, 'clarify.respond', {
|
||||
request_id: entry.requestId,
|
||||
question_id: qid,
|
||||
answer: (answers as Record<string, string>)?.[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<null | string> {
|
||||
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<string>([runtime])
|
||||
|
||||
try {
|
||||
const pre = (await requestForBot(member, 'session.resume', {
|
||||
session_id: stored || runtime,
|
||||
profile: member.name
|
||||
})) as GroupSessionSnapshot
|
||||
|
||||
before = Array.isArray(pre?.messages) ? pre.messages.length : pre?.message_count || 0
|
||||
|
||||
if (pre?.session_id) {
|
||||
runtimeIds.add(pre.session_id)
|
||||
}
|
||||
} catch {
|
||||
/* lazy session — zero messages */
|
||||
}
|
||||
|
||||
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<string>
|
||||
before: number
|
||||
binding: { isLive(): boolean }
|
||||
}
|
||||
|
||||
async function pollGroupMemberTurn(context: GroupTurnPollContext): Promise<null | string> {
|
||||
const { member, thread, dispatchEpoch, stored, liveRuntime, runtimeIds, before, binding } = context
|
||||
const memberKey = groupMemberKey(member)
|
||||
const started = Date.now()
|
||||
let deadline = started + GROUP_TURN_TIMEOUT_MS
|
||||
// After the terminal frame fires, the gateway still has to flip
|
||||
@@ -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<string>([runtime])
|
||||
|
||||
try {
|
||||
const pre = (await requestForBot(member, 'session.resume', {
|
||||
session_id: stored || runtime,
|
||||
profile: member.name
|
||||
})) as GroupSessionSnapshot
|
||||
|
||||
before = Array.isArray(pre?.messages) ? pre.messages.length : pre?.message_count || 0
|
||||
|
||||
if (pre?.session_id) {
|
||||
runtimeIds.add(pre.session_id)
|
||||
}
|
||||
} catch {
|
||||
/* lazy session — zero messages */
|
||||
}
|
||||
|
||||
return { before, runtimeIds }
|
||||
}
|
||||
|
||||
async function runGroupChatMemberTurnLeased(
|
||||
group: string,
|
||||
member: GroupMember,
|
||||
prompt: string,
|
||||
thread: string,
|
||||
images?: Attachment[]
|
||||
): Promise<null | string> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { Button, Codicon, DisclosureCaret, GlyphSpinner, PanelEmpty, RowButton } from '@hermes/plugin-sdk'
|
||||
import type { ReactNode, RefObject } from 'react'
|
||||
|
||||
import type { useRoster } from './data'
|
||||
import { $showHiddenBots } from './hidden-bots'
|
||||
import type { useBots } from './i18n'
|
||||
import type { deriveRosterPresentation, deriveRosterRows } from './roster-pane-derivation'
|
||||
import type { rosterSectionRenderers } from './roster-pane-sections'
|
||||
import type { rosterGatewayOptions } from './roster-sections'
|
||||
import type { RosterRow } from './types'
|
||||
|
||||
interface RosterContentProps {
|
||||
b: ReturnType<typeof useBots>
|
||||
staleNotice: string | number | null
|
||||
isLoading: boolean
|
||||
initialRosterLoading: boolean
|
||||
roster: RosterRow[]
|
||||
error: ReturnType<typeof useRoster>['error']
|
||||
gatewayUp: boolean
|
||||
refetch: ReturnType<typeof useRoster>['refetch']
|
||||
allBotsHidden: boolean
|
||||
hiddenExpanded: boolean
|
||||
rosterRows: ReturnType<typeof deriveRosterRows>['rosterRows']
|
||||
matchingHiddenBots: RosterRow[]
|
||||
query: string
|
||||
selectedGateway: ReturnType<typeof rosterGatewayOptions>[number] | undefined
|
||||
showGatewaySections: boolean
|
||||
sortedGroupRows: ReturnType<typeof deriveRosterRows>['sortedGroupRows']
|
||||
gatewaySections: ReturnType<typeof deriveRosterRows>['gatewaySections']
|
||||
showHiddenSection: boolean
|
||||
hiddenSectionRef: RefObject<HTMLDivElement | null>
|
||||
hasRosterConstraint: boolean
|
||||
hiddenBots: RosterRow[]
|
||||
showHiddenRows: boolean
|
||||
hiddenGatewaySections: ReturnType<typeof deriveRosterPresentation>['hiddenGatewaySections']
|
||||
renderBotRow: (bot: RosterRow, keyPrefix?: string) => ReactNode
|
||||
renderGroupChatSection: ReturnType<typeof rosterSectionRenderers>['renderGroupChatSection']
|
||||
renderGatewaySection: ReturnType<typeof rosterSectionRenderers>['renderGatewaySection']
|
||||
renderUserSections: ReturnType<typeof rosterSectionRenderers>['renderUserSections']
|
||||
renderHiddenGatewaySection: ReturnType<typeof rosterSectionRenderers>['renderHiddenGatewaySection']
|
||||
}
|
||||
|
||||
export function renderRosterContent({
|
||||
b,
|
||||
staleNotice,
|
||||
isLoading,
|
||||
initialRosterLoading,
|
||||
roster,
|
||||
error,
|
||||
gatewayUp,
|
||||
refetch,
|
||||
allBotsHidden,
|
||||
hiddenExpanded,
|
||||
rosterRows,
|
||||
matchingHiddenBots,
|
||||
query,
|
||||
selectedGateway,
|
||||
showGatewaySections,
|
||||
sortedGroupRows,
|
||||
gatewaySections,
|
||||
showHiddenSection,
|
||||
hiddenSectionRef,
|
||||
hasRosterConstraint,
|
||||
hiddenBots,
|
||||
showHiddenRows,
|
||||
hiddenGatewaySections,
|
||||
renderBotRow,
|
||||
renderGroupChatSection,
|
||||
renderGatewaySection,
|
||||
renderUserSections,
|
||||
renderHiddenGatewaySection
|
||||
}: RosterContentProps) {
|
||||
return (
|
||||
<>
|
||||
{staleNotice ? (
|
||||
<div className="mx-2.5 mb-1 rounded-md bg-(--chrome-action-hover) px-2 py-1.5 text-[0.6875rem] text-(--ui-text-tertiary)">
|
||||
{staleNotice}
|
||||
</div>
|
||||
) : null}
|
||||
{(isLoading || initialRosterLoading) && !roster.length ? (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<GlyphSpinner className="text-(--ui-text-tertiary)" spinner="breathe" />
|
||||
</div>
|
||||
) : error && !roster.length ? (
|
||||
<div className="grid gap-2 px-3 py-4 text-xs text-(--ui-text-tertiary)">
|
||||
<div>
|
||||
{gatewayUp
|
||||
? b.roster.rosterUnavailable(error instanceof Error ? error.message : 'gateway error')
|
||||
: b.roster.waitingForGateway}
|
||||
</div>
|
||||
<Button className="justify-self-start" onClick={() => void refetch()} size="sm" variant="secondary">
|
||||
{b.roster.retryNow}
|
||||
</Button>
|
||||
</div>
|
||||
) : roster.length === 0 ? (
|
||||
<PanelEmpty description={b.roster.emptyDesc} icon="hubot" title={b.roster.emptyTitle} />
|
||||
) : allBotsHidden && !hiddenExpanded ? (
|
||||
<div className="grid content-start gap-2 px-3 py-4 text-xs text-(--ui-text-tertiary)">
|
||||
<div className="flex items-center gap-1.5 font-medium text-(--ui-text-secondary)">
|
||||
<Codicon className="text-(--ui-text-quaternary)" name="eye-closed" />
|
||||
{b.roster.allHidden}
|
||||
</div>
|
||||
<p className="leading-relaxed">{b.roster.allHiddenDesc}</p>
|
||||
<Button
|
||||
className="justify-self-start"
|
||||
onClick={() => $showHiddenBots.set(true)}
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
>
|
||||
{b.roster.showHidden}
|
||||
</Button>
|
||||
</div>
|
||||
) : rosterRows.length === 0 && matchingHiddenBots.length === 0 ? (
|
||||
<div aria-live="polite" className="flex min-h-0 flex-1 flex-col" role="status">
|
||||
<PanelEmpty
|
||||
description={
|
||||
query.trim()
|
||||
? selectedGateway
|
||||
? b.roster.noMatchQueryOn(query.trim(), String(selectedGateway.label))
|
||||
: b.roster.noMatchQuery(query.trim())
|
||||
: selectedGateway
|
||||
? b.roster.noMatchFiltersOn(String(selectedGateway.label))
|
||||
: b.roster.noMatchFilters
|
||||
}
|
||||
icon="search"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain" data-slot="bots-roster">
|
||||
<div className="grid w-full min-w-0 gap-0.5 px-1.5 pb-2">
|
||||
{showGatewaySections
|
||||
? [
|
||||
sortedGroupRows.length ? renderGroupChatSection() : null,
|
||||
...gatewaySections.sections.map(renderGatewaySection)
|
||||
].filter(Boolean)
|
||||
: renderUserSections(rosterRows)}
|
||||
{showHiddenSection ? (
|
||||
<div
|
||||
className="mt-1 border-t border-(--ui-stroke-tertiary) pt-1"
|
||||
key={'hidden-section'}
|
||||
ref={hiddenSectionRef}
|
||||
>
|
||||
{hasRosterConstraint ? (
|
||||
<div className="flex w-full items-center gap-1 px-2 py-1.5 text-[0.6875rem] font-medium text-(--ui-text-tertiary)">
|
||||
<Codicon name="eye-closed" />
|
||||
<span>Hidden</span>
|
||||
<span className="text-(--ui-text-quaternary)">{matchingHiddenBots.length}</span>
|
||||
</div>
|
||||
) : (
|
||||
<RowButton
|
||||
aria-expanded={hiddenExpanded}
|
||||
className="flex w-full items-center gap-1 rounded-md px-2 py-1.5 text-left text-[0.6875rem] font-medium text-(--ui-text-tertiary) transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground"
|
||||
onClick={() => $showHiddenBots.set(!hiddenExpanded)}
|
||||
>
|
||||
<DisclosureCaret open={hiddenExpanded} />
|
||||
<span>Hidden</span>
|
||||
<span className="text-(--ui-text-quaternary)">{hiddenBots.length}</span>
|
||||
</RowButton>
|
||||
)}
|
||||
{showHiddenRows ? (
|
||||
matchingHiddenBots.length ? (
|
||||
hiddenGatewaySections.sectioned ? (
|
||||
hiddenGatewaySections.sections.map(renderHiddenGatewaySection)
|
||||
) : (
|
||||
matchingHiddenBots.map((bot: RosterRow) => renderBotRow(bot, 'hidden:'))
|
||||
)
|
||||
) : (
|
||||
<div className="px-2 py-2 text-xs text-(--ui-text-quaternary)">{b.roster.noHiddenMatch}</div>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,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<string, BotMeta>
|
||||
gatewayFilter: string
|
||||
query: string
|
||||
activityFilter: RosterActivityFilter
|
||||
rowKindFilter: RosterKindFilter
|
||||
groupRooms: ReturnType<typeof $groupChats.get>
|
||||
activeRosterKeys: Set<string>
|
||||
gatewayOptions: ReturnType<typeof rosterGatewayOptions>
|
||||
activityOf: (bot: RosterRow) => number
|
||||
isPinned: (bot: RosterRow) => boolean
|
||||
}
|
||||
|
||||
export function deriveRosterRows({
|
||||
roster,
|
||||
allMeta,
|
||||
gatewayFilter,
|
||||
query,
|
||||
activityFilter,
|
||||
rowKindFilter,
|
||||
groupRooms,
|
||||
activeRosterKeys,
|
||||
gatewayOptions,
|
||||
activityOf,
|
||||
isPinned
|
||||
}: RosterRowsInput) {
|
||||
const activeSourceRoster = roster.filter(bot => !bot.remoteSource)
|
||||
// Hidden rows remain fully alive and recoverable at the bottom. Every
|
||||
// non-display consumer continues to receive the complete roster.
|
||||
const hiddenBots = roster.filter(bot => isBotHidden(bot, allMeta))
|
||||
const visibleRoster = roster.filter(bot => !isBotHidden(bot, allMeta))
|
||||
const gatewayRoster = filterBotsByGateway(visibleRoster, gatewayFilter)
|
||||
|
||||
const filteredRoster = filterBots(gatewayRoster, allMeta, query).filter((bot: RosterRow) =>
|
||||
rosterActivityMatches(
|
||||
{
|
||||
activity: activityOf(bot),
|
||||
active: activeRosterKeys.has(botRosterKey(bot))
|
||||
},
|
||||
activityFilter
|
||||
)
|
||||
)
|
||||
|
||||
const filteredHiddenBots = filterBots(filterBotsByGateway(hiddenBots, gatewayFilter), allMeta, query).filter(
|
||||
(bot: RosterRow) =>
|
||||
rosterActivityMatches(
|
||||
{
|
||||
activity: activityOf(bot),
|
||||
active: activeRosterKeys.has(botRosterKey(bot))
|
||||
},
|
||||
activityFilter
|
||||
)
|
||||
)
|
||||
|
||||
const groupNames = groupChatNames(allMeta, groupRooms)
|
||||
|
||||
const groupRows = groupNames
|
||||
.map(name => ({
|
||||
name,
|
||||
members: groupChatMemberBots(name, roster, allMeta)
|
||||
}))
|
||||
.filter(row => groupMatchesRosterFilters(row.name, row.members, allMeta, query, gatewayFilter))
|
||||
.map((row): RosterGroupRow => ({
|
||||
kind: 'group',
|
||||
name: row.name,
|
||||
members: row.members,
|
||||
pinned: Boolean(groupRooms[row.name]?.pinned),
|
||||
activity: groupLastActivity(groupRooms[row.name]),
|
||||
active:
|
||||
Boolean(
|
||||
groupLastActivity(groupRooms[row.name]) &&
|
||||
Date.now() - groupLastActivity(groupRooms[row.name]) <= ACTIVE_WINDOW_S * 1000
|
||||
) || row.members.some(member => activeRosterKeys.has(botRosterKey(member)))
|
||||
}))
|
||||
.filter(row => rowKindFilter !== 'bots' && rosterActivityMatches(row, activityFilter))
|
||||
|
||||
const botRows =
|
||||
rowKindFilter === 'groups'
|
||||
? []
|
||||
: preferReachableSameNameRows(filteredRoster).map((bot): RosterBotRow => ({
|
||||
kind: 'bot',
|
||||
bot,
|
||||
pinned: isPinned(bot),
|
||||
activity: activityOf(bot),
|
||||
active: activeRosterKeys.has(botRosterKey(bot))
|
||||
}))
|
||||
|
||||
const 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<typeof rosterGatewayOptions>
|
||||
}
|
||||
|
||||
export function deriveRosterPresentation({
|
||||
rowKindFilter,
|
||||
activityFilter,
|
||||
gatewayFilter,
|
||||
query,
|
||||
filteredHiddenBots,
|
||||
hiddenBots,
|
||||
hiddenExpanded,
|
||||
roster,
|
||||
groupNames,
|
||||
visibleRoster,
|
||||
gatewayOptions
|
||||
}: RosterPresentationInput) {
|
||||
const activeFilterCount =
|
||||
(rowKindFilter === 'all' ? 0 : 1) + (activityFilter === 'all' ? 0 : 1) + (gatewayFilter === 'all' ? 0 : 1)
|
||||
|
||||
const hasRosterConstraint = Boolean(query.trim()) || activeFilterCount > 0
|
||||
const matchingHiddenBots = rowKindFilter === 'groups' ? [] : filteredHiddenBots
|
||||
const showHiddenSection = hiddenBots.length > 0 && (!hasRosterConstraint || matchingHiddenBots.length > 0)
|
||||
const showHiddenRows = hiddenExpanded || hasRosterConstraint
|
||||
const rosterItemCount = roster.length + groupNames.length
|
||||
|
||||
const allBotsHidden =
|
||||
!hasRosterConstraint && visibleRoster.length === 0 && groupNames.length === 0 && hiddenBots.length > 0
|
||||
|
||||
const showRosterSearch =
|
||||
gatewayOptions.length > 1 || rosterItemCount >= BOT_ROSTER_SEARCH_THRESHOLD || Boolean(query.trim())
|
||||
|
||||
const showRosterFilters =
|
||||
gatewayOptions.length > 1 ||
|
||||
groupNames.length > 0 ||
|
||||
rosterItemCount >= BOT_ROSTER_SEARCH_THRESHOLD ||
|
||||
activeFilterCount > 0
|
||||
|
||||
const showRosterTools = showRosterSearch || showRosterFilters
|
||||
|
||||
const hiddenGatewaySections = rosterGatewaySections(
|
||||
matchingHiddenBots.map((bot: RosterRow) => ({
|
||||
kind: 'bot',
|
||||
bot
|
||||
})),
|
||||
gatewayOptions,
|
||||
gatewayFilter
|
||||
)
|
||||
|
||||
return {
|
||||
activeFilterCount,
|
||||
hasRosterConstraint,
|
||||
matchingHiddenBots,
|
||||
showHiddenSection,
|
||||
showHiddenRows,
|
||||
allBotsHidden,
|
||||
showRosterSearch,
|
||||
showRosterFilters,
|
||||
showRosterTools,
|
||||
hiddenGatewaySections
|
||||
}
|
||||
}
|
||||
|
||||
export function sortRosterBots(sourceWithSelectedOwner: RosterRow[], allMeta: Record<string, BotMeta>) {
|
||||
// Messaging-app order: most recent activity first, where "activity" is
|
||||
// the newest of (bot created, last message in any of its sessions). A
|
||||
// freshly created bot tops the list until another bot gets a message.
|
||||
// No special slot for the primary bot — it competes on recency too.
|
||||
const activityOf = (bot: RosterRow): number => {
|
||||
const created = botRosterMeta(bot, allMeta)?.created || bot.ui_meta?.['hermes-bots']?.created || 0
|
||||
const lastMsg = (botActivitySession(bot)?.last_active || 0) * 1000
|
||||
|
||||
return Math.max(created, lastMsg)
|
||||
}
|
||||
|
||||
// Pin is a source-qualified Desktop preference, not gateway profile state.
|
||||
const isPinned = (bot: RosterRow): boolean => isBotPinned(bot, allMeta)
|
||||
|
||||
const roster = sourceWithSelectedOwner.slice().sort((a, b) => {
|
||||
const pa = isPinned(a) ? 1 : 0
|
||||
const pb = isPinned(b) ? 1 : 0
|
||||
|
||||
if (pa !== pb) {
|
||||
return pb - pa
|
||||
}
|
||||
|
||||
return activityOf(b) - activityOf(a)
|
||||
})
|
||||
|
||||
return { roster, activityOf, isPinned }
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { ConfirmDialog, host } from '@hermes/plugin-sdk'
|
||||
import type { useI18n } from '@hermes/plugin-sdk'
|
||||
|
||||
import { CreateAgentDialog, CreateGroupChatDialog, GroupDialog } from './create-dialog'
|
||||
import type { useRoster } from './data'
|
||||
import { EditProfileDialog } from './edit-profile-dialog'
|
||||
import { disbandGroupChat, openGroupChat } from './group-chat-view'
|
||||
import type { useBots } from './i18n'
|
||||
import { deleteBot } from './profile-ops'
|
||||
import type { GroupMember, RosterRow } from './types'
|
||||
import { createBotSection, renameBotSection } from './user-sections'
|
||||
import { SectionNameDialog } from './user-sections-ui'
|
||||
|
||||
interface renderRosterDialogsProps {
|
||||
b: ReturnType<typeof useBots>
|
||||
t: ReturnType<typeof useI18n>['t']
|
||||
createOpen: boolean
|
||||
setCreateOpen: (value: boolean) => void
|
||||
groupCreateOpen: boolean
|
||||
setGroupCreateOpen: (value: boolean) => void
|
||||
editing: RosterRow | null
|
||||
setEditing: (value: RosterRow | null) => void
|
||||
deleting: (RosterRow & { path?: string }) | null
|
||||
setDeleting: (value: (RosterRow & { path?: string }) | null) => void
|
||||
deletingGroup: { members: GroupMember[]; name: string } | null
|
||||
setDeletingGroup: (value: { members: GroupMember[]; name: string } | null) => void
|
||||
grouping: RosterRow | null
|
||||
setGrouping: (value: RosterRow | null) => void
|
||||
sectionDialog: null | { bot?: RosterRow; mode: 'create' } | { id: string; mode: 'rename'; name: string }
|
||||
setSectionDialog: (
|
||||
value: null | { bot?: RosterRow; mode: 'create' } | { id: string; mode: 'rename'; name: string }
|
||||
) => void
|
||||
roster: RosterRow[]
|
||||
activeSourceRoster: RosterRow[]
|
||||
refetch: ReturnType<typeof useRoster>['refetch']
|
||||
}
|
||||
|
||||
export function renderRosterDialogs({
|
||||
b,
|
||||
t,
|
||||
createOpen,
|
||||
setCreateOpen,
|
||||
groupCreateOpen,
|
||||
setGroupCreateOpen,
|
||||
editing,
|
||||
setEditing,
|
||||
deleting,
|
||||
setDeleting,
|
||||
deletingGroup,
|
||||
setDeletingGroup,
|
||||
grouping,
|
||||
setGrouping,
|
||||
sectionDialog,
|
||||
setSectionDialog,
|
||||
roster,
|
||||
activeSourceRoster,
|
||||
refetch
|
||||
}: renderRosterDialogsProps) {
|
||||
return (
|
||||
<>
|
||||
<CreateAgentDialog
|
||||
onClose={() => {
|
||||
setCreateOpen(false)
|
||||
void refetch()
|
||||
}}
|
||||
open={createOpen}
|
||||
roster={activeSourceRoster}
|
||||
/>
|
||||
<CreateGroupChatDialog
|
||||
onClose={() => setGroupCreateOpen(false)}
|
||||
onCreated={groupName => openGroupChat(groupName)}
|
||||
open={groupCreateOpen} // Full multi-source roster: group chats can seat bots from other
|
||||
// registered connections — their turns route to their own machines.
|
||||
roster={roster}
|
||||
/>
|
||||
<SectionNameDialog
|
||||
initialName={sectionDialog?.mode === 'rename' ? sectionDialog.name : ''}
|
||||
mode={sectionDialog?.mode === 'rename' ? 'rename' : 'create'}
|
||||
onOpenChange={open => {
|
||||
if (!open) {
|
||||
setSectionDialog(null)
|
||||
}
|
||||
}}
|
||||
onSubmit={name => {
|
||||
if (sectionDialog?.mode === 'rename') {
|
||||
renameBotSection(sectionDialog.id, name)
|
||||
} else {
|
||||
createBotSection(name, sectionDialog?.bot ? [sectionDialog.bot] : [])
|
||||
}
|
||||
}}
|
||||
open={Boolean(sectionDialog)}
|
||||
/>
|
||||
<EditProfileDialog
|
||||
bot={editing}
|
||||
onClose={() => {
|
||||
setEditing(null)
|
||||
void refetch()
|
||||
}}
|
||||
open={Boolean(editing)}
|
||||
/>
|
||||
{grouping ? <GroupDialog bot={grouping} onClose={() => setGrouping(null)} /> : null}
|
||||
<ConfirmDialog
|
||||
busyLabel="Deleting…"
|
||||
confirmLabel={t.common.delete}
|
||||
description={
|
||||
deleting ? (
|
||||
<span>
|
||||
{'This will permanently delete the bot '}
|
||||
<span className="font-medium text-foreground">{deleting.name}</span>
|
||||
{' and its associated Hermes profile at '}
|
||||
<span className="font-mono text-xs">{deleting.path}</span>. This cannot be undone.
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
destructive
|
||||
doneLabel="Deleted"
|
||||
onClose={() => setDeleting(null)}
|
||||
onConfirm={async () => {
|
||||
if (!deleting) {
|
||||
return
|
||||
}
|
||||
|
||||
const name = deleting.name
|
||||
await deleteBot(deleting)
|
||||
await refetch()
|
||||
host.notify({
|
||||
kind: 'success',
|
||||
message: `Deleted profile ${name}`
|
||||
})
|
||||
}}
|
||||
open={Boolean(deleting)}
|
||||
title={b.bot.deleteTitle}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
busyLabel="Deleting…"
|
||||
confirmLabel={b.group.deleteAction}
|
||||
description={
|
||||
deletingGroup
|
||||
? `This removes “${deletingGroup.name}” from its bots and clears the shared room log. The bots and their individual chats are kept.`
|
||||
: null
|
||||
}
|
||||
destructive
|
||||
doneLabel="Deleted"
|
||||
onClose={() => setDeletingGroup(null)}
|
||||
onConfirm={async () => {
|
||||
if (!deletingGroup) {
|
||||
return
|
||||
}
|
||||
|
||||
await disbandGroupChat(deletingGroup.name, deletingGroup.members)
|
||||
host.notify({
|
||||
kind: 'success',
|
||||
message: `Deleted group “${deletingGroup.name}”`
|
||||
})
|
||||
}}
|
||||
open={Boolean(deletingGroup)}
|
||||
title={b.group.deleteTitle}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,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<ComponentProps<typeof GroupRow>, 'needsYou'> {
|
||||
b: ReturnType<typeof useBots>
|
||||
groupClarify: ReturnType<typeof $groupClarify.get>
|
||||
groupNeedsYou: ReturnType<typeof $groupNeedsYou.get>
|
||||
groupRooms: ReturnType<typeof $groupChats.get>
|
||||
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 (
|
||||
<div className="grid min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center">
|
||||
<GroupRow
|
||||
{...rowProps}
|
||||
needsYou={Boolean(groupNeedsYou[rowProps.group]) || groupHasPendingClarify(groupClarify, rowProps.group)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
{([-1, 1] as const).map(delta => (
|
||||
<Tip key={delta} label={delta === -1 ? b.sections.moveUp : b.sections.moveDown}>
|
||||
<Button
|
||||
aria-label={`${rowProps.group}: ${delta === -1 ? b.sections.moveUp : b.sections.moveDown}`}
|
||||
disabled={!reorderGroupRows(sortedGroupRows, rowProps.group, delta)}
|
||||
onClick={() => moveRoom(rowProps.group, delta)}
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name={delta === -1 ? 'chevron-up' : 'chevron-down'} />
|
||||
</Button>
|
||||
</Tip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { atom, host } from '@hermes/plugin-sdk'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import { $lastRoster } from './data'
|
||||
import type { useRoster } from './data'
|
||||
import { displayName } from './labels'
|
||||
import { mergeServerMeta, pullServerAvatars } from './profile-ops'
|
||||
import { trackInboundActivity } from './roster-actions'
|
||||
import { botRosterMeta, botWorkspaceOwnerKey } from './routing'
|
||||
import { backfillMessagingProtocol } from './soul'
|
||||
import type { GatewaySource } from './types'
|
||||
import type { BotMeta, RosterRow } from './types'
|
||||
|
||||
/** Last source inventory returned by the desktop-wide agent roster. */
|
||||
export const $lastSources = atom<GatewaySource[]>([])
|
||||
|
||||
interface RosterSnapshotInput {
|
||||
data: ReturnType<typeof useRoster>['data']
|
||||
live: RosterRow[] | null
|
||||
roster: RosterRow[]
|
||||
allMeta: Record<string, BotMeta>
|
||||
activeSourceRoster: RosterRow[]
|
||||
}
|
||||
|
||||
export function usePublishRosterSnapshot({ data, live, roster, allMeta, activeSourceRoster }: RosterSnapshotInput) {
|
||||
useEffect(() => {
|
||||
if (!live) {
|
||||
return
|
||||
}
|
||||
|
||||
// Offline-owner ghosts belong only to this render. Shared roster state
|
||||
// feeds merge caching, group membership, creation, and durable sync. These
|
||||
// writes must settle after render: other subscribers of the same atoms
|
||||
// would otherwise be updated while BotsPane was still rendering.
|
||||
$lastRoster.set(roster.filter(row => !row?.ghost))
|
||||
// Tabs caption a bot chat by its bot (#99152); republished with the
|
||||
// roster so a rename follows and tiles restored at boot resolve.
|
||||
roster.forEach(bot => {
|
||||
host.setWorkspaceOwnerLabel?.(botWorkspaceOwnerKey(bot), displayName(bot, botRosterMeta(bot, allMeta)))
|
||||
})
|
||||
|
||||
if (Array.isArray(data?.sources)) {
|
||||
$lastSources.set(data.sources)
|
||||
}
|
||||
|
||||
mergeServerMeta(activeSourceRoster, data?.fetchedAt || 0)
|
||||
pullServerAvatars(activeSourceRoster)
|
||||
trackInboundActivity(roster)
|
||||
backfillMessagingProtocol(activeSourceRoster)
|
||||
// React Query owns the stable server snapshot; derived arrays intentionally
|
||||
// follow that snapshot rather than retriggering on their own atom writes.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data])
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { host } from '@hermes/plugin-sdk'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { botRosterKey } from './data'
|
||||
import type { useBots } from './i18n'
|
||||
import type { RosterGroupRow } from './roster-pane-derivation'
|
||||
import { GatewayKindGlyph, GatewaySectionHeading, RosterSectionHeader } from './roster-sections'
|
||||
import type { ResolvedRosterGatewaySection } from './roster-sections'
|
||||
import type { BotMeta, GroupMember, RosterRow } from './types'
|
||||
import type { $botSections } from './user-sections'
|
||||
import {
|
||||
deleteBotSection,
|
||||
groupRowsBySection,
|
||||
moveBotSection,
|
||||
moveBotsToSection,
|
||||
UNASSIGNED_SECTION_KEY
|
||||
} from './user-sections'
|
||||
import { SectionDropZone, UserSectionHeader } from './user-sections-ui'
|
||||
|
||||
interface RosterSectionRenderersProps {
|
||||
b: ReturnType<typeof useBots>
|
||||
userSections: ReturnType<typeof $botSections.get>
|
||||
roster: RosterRow[]
|
||||
allMeta: Record<string, BotMeta>
|
||||
dragging: string | null
|
||||
rosterSectionCollapsed: (id: string) => boolean
|
||||
toggleRosterSection: (id: string) => void
|
||||
setSectionDialog: (
|
||||
value: null | { bot?: RosterRow; mode: 'create' } | { id: string; mode: 'rename'; name: string }
|
||||
) => void
|
||||
renderBotRow: (bot: RosterRow, keyPrefix?: string) => ReactNode
|
||||
renderGroupRow: (row: { members: GroupMember[]; name: string }) => ReactNode
|
||||
sortedGroupRows: RosterGroupRow[]
|
||||
}
|
||||
|
||||
export function rosterSectionRenderers({
|
||||
b,
|
||||
userSections,
|
||||
roster,
|
||||
allMeta,
|
||||
dragging,
|
||||
rosterSectionCollapsed,
|
||||
toggleRosterSection,
|
||||
setSectionDialog,
|
||||
renderBotRow,
|
||||
renderGroupRow,
|
||||
sortedGroupRows
|
||||
}: RosterSectionRenderersProps) {
|
||||
const removeSection = (id: string) => {
|
||||
const name = userSections.find(section => section.id === id)?.name || ''
|
||||
const { members, undo } = deleteBotSection(id, roster)
|
||||
|
||||
// No confirmation: nothing is lost (the bots fall back to Unassigned) and
|
||||
// the toast's Undo puts the section and its members back.
|
||||
host.notify({
|
||||
action: { label: b.sections.undo, onClick: undo },
|
||||
durationMs: 8_000,
|
||||
kind: 'info',
|
||||
message: b.sections.deleted(name, members.length)
|
||||
})
|
||||
}
|
||||
|
||||
// USER SECTIONS — composed with the gateway sections, not instead of them.
|
||||
// The gateway headings own the top level whenever the roster shows more
|
||||
// than one connection (that axis answers "where does this run", which no
|
||||
// folder name can, and a bot's membership lives in its profile on THAT
|
||||
// gateway); user sections group the rows INSIDE each connection bucket,
|
||||
// indented under it, and group the flat list when there is only one.
|
||||
// `keyPrefix` keeps row keys unique across the gateway buckets.
|
||||
type UserSectionRow = { bot: RosterRow; kind?: 'bot' } | RosterGroupRow
|
||||
|
||||
const renderUserSections = (rows: UserSectionRow[], keyPrefix = '') => {
|
||||
// No sections made: the plain list, exactly as before this feature.
|
||||
if (!userSections.length) {
|
||||
return rows.map(row => (row.kind === 'group' ? renderGroupRow(row) : renderBotRow(row.bot, keyPrefix)))
|
||||
}
|
||||
|
||||
const nested = Boolean(keyPrefix)
|
||||
const blocks = groupRowsBySection(rows, userSections, allMeta)
|
||||
|
||||
return (
|
||||
blocks
|
||||
// An empty Unassigned is not worth a heading; an empty NAMED section
|
||||
// is, because it is somewhere the user made and is about to drop into.
|
||||
// Inside a gateway bucket the same empty section would repeat under
|
||||
// every connection, so there it only appears while a drag is in flight
|
||||
// (as the drop target it exists for); the row menu files into it
|
||||
// regardless.
|
||||
.filter(block => block.rows.length || (block.id && (!nested || dragging)))
|
||||
.map(block => {
|
||||
const key = `${keyPrefix}${block.id ? `user-section:${block.id}` : UNASSIGNED_SECTION_KEY}`
|
||||
const collapsed = rosterSectionCollapsed(key)
|
||||
const order = userSections.findIndex(section => section.id === block.id)
|
||||
|
||||
return (
|
||||
<SectionDropZone
|
||||
isSource={
|
||||
Boolean(dragging) && block.rows.some(row => row.kind !== 'group' && botRosterKey(row.bot) === dragging)
|
||||
}
|
||||
key={key}
|
||||
nested={nested}
|
||||
onDropBot={rosterKey => {
|
||||
const bot = roster.find(row => botRosterKey(row) === rosterKey)
|
||||
|
||||
// `block.id` is null for Unassigned, which is exactly the value
|
||||
// moveBotsToSection wants for "clear the assignment".
|
||||
if (bot) {
|
||||
void moveBotsToSection([bot], block.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<UserSectionHeader
|
||||
canMoveDown={order >= 0 && order < userSections.length - 1}
|
||||
canMoveUp={order > 0}
|
||||
collapsed={collapsed}
|
||||
count={block.rows.length}
|
||||
id={block.id}
|
||||
name={block.name}
|
||||
onDelete={() => block.id && removeSection(block.id)}
|
||||
onMove={delta => block.id && moveBotSection(block.id, delta)}
|
||||
onRename={() => block.id && setSectionDialog({ id: block.id, mode: 'rename', name: block.name })}
|
||||
onToggle={() => toggleRosterSection(key)}
|
||||
/>
|
||||
{collapsed ? null : block.rows.length ? (
|
||||
<div className="grid min-w-0 gap-0.5">
|
||||
{block.rows.map(row =>
|
||||
row.kind === 'group' ? renderGroupRow(row) : renderBotRow(row.bot, `${key}:`)
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
// Empty section: a quiet dashed slot that says what it is for,
|
||||
// and doubles as a roomy drop target.
|
||||
<div className="mx-1 mb-1 rounded-md border border-dashed border-(--ui-stroke-secondary) px-2 py-2 text-center text-[0.6875rem] text-(--ui-text-quaternary)">
|
||||
{b.sections.emptyHint}
|
||||
</div>
|
||||
)}
|
||||
</SectionDropZone>
|
||||
)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const renderGatewaySection = (section: ResolvedRosterGatewaySection) => {
|
||||
const sectionId = `gateway:${section.id}`
|
||||
const collapsed = rosterSectionCollapsed(sectionId)
|
||||
|
||||
return (
|
||||
<div className="min-w-0" key={sectionId}>
|
||||
<GatewaySectionHeading
|
||||
collapsed={collapsed}
|
||||
count={section.rows.length}
|
||||
onToggle={() => toggleRosterSection(sectionId)}
|
||||
option={section.option}
|
||||
/>
|
||||
{collapsed ? null : (
|
||||
<div className="grid min-w-0 gap-0.5">{renderUserSections(section.rows, `${section.id}:`)}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderGroupChatSection = () => {
|
||||
const sectionId = 'group-chats'
|
||||
const collapsed = rosterSectionCollapsed(sectionId)
|
||||
|
||||
return (
|
||||
<div className="min-w-0" key={sectionId}>
|
||||
<RosterSectionHeader
|
||||
collapsed={collapsed}
|
||||
count={sortedGroupRows.length}
|
||||
icon="organization"
|
||||
label={b.roster.groupChats}
|
||||
onToggle={() => toggleRosterSection(sectionId)}
|
||||
tip={`${sortedGroupRows.length} global group chat${sortedGroupRows.length === 1 ? '' : 's'}`}
|
||||
/>
|
||||
{collapsed ? null : <div className="grid min-w-0 gap-0.5">{sortedGroupRows.map(renderGroupRow)}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderHiddenGatewaySection = (section: ResolvedRosterGatewaySection) => (
|
||||
<div className="min-w-0" key={`hidden-gateway:${section.id}`}>
|
||||
<div className="flex min-w-0 items-center gap-1.5 px-2 py-1 text-[0.625rem] font-semibold uppercase tracking-wider text-(--ui-text-quaternary)">
|
||||
<GatewayKindGlyph kind={section.option?.kind} />
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{section.option?.label || section.option?.connectionId || 'Current gateway'}
|
||||
</span>
|
||||
<span className="shrink-0 font-normal tabular-nums">{section.rows.length}</span>
|
||||
</div>
|
||||
{section.rows.map(row => renderBotRow(row.bot, `hidden:${section.id}:`))}
|
||||
</div>
|
||||
)
|
||||
|
||||
return { renderUserSections, renderGatewaySection, renderGroupChatSection, renderHiddenGatewaySection }
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import {
|
||||
Button,
|
||||
cn,
|
||||
Codicon,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
SearchField,
|
||||
Tip
|
||||
} from '@hermes/plugin-sdk'
|
||||
|
||||
import { botSourceStatus } from './data'
|
||||
import type { useBots } from './i18n'
|
||||
import { setActivityToasts } from './roster-actions'
|
||||
import { GatewayKindGlyph } from './roster-sections'
|
||||
import type { rosterGatewayOptions } from './roster-sections'
|
||||
import type { RosterActivityFilter, RosterKindFilter, RosterRow } from './types'
|
||||
|
||||
interface renderRosterToolbarProps {
|
||||
b: ReturnType<typeof useBots>
|
||||
activityToasts: boolean
|
||||
activeSourceRoster: RosterRow[]
|
||||
setCreateOpen: (value: boolean) => void
|
||||
setGroupCreateOpen: (value: boolean) => void
|
||||
setSectionDialog: (
|
||||
value: null | { bot?: RosterRow; mode: 'create' } | { id: string; mode: 'rename'; name: string }
|
||||
) => void
|
||||
showRosterTools: boolean
|
||||
showRosterSearch: boolean
|
||||
showRosterFilters: boolean
|
||||
query: string
|
||||
setQuery: (value: string) => void
|
||||
activeFilterCount: number
|
||||
gatewayOptions: ReturnType<typeof rosterGatewayOptions>
|
||||
rowKindFilter: RosterKindFilter
|
||||
setRowKindFilter: (value: RosterKindFilter) => void
|
||||
activityFilter: RosterActivityFilter
|
||||
setActivityFilter: (value: RosterActivityFilter) => void
|
||||
gatewayFilter: string
|
||||
setGatewayFilter: (value: string) => void
|
||||
}
|
||||
|
||||
export function renderRosterToolbar({
|
||||
b,
|
||||
activityToasts,
|
||||
activeSourceRoster,
|
||||
setCreateOpen,
|
||||
setGroupCreateOpen,
|
||||
setSectionDialog,
|
||||
showRosterTools,
|
||||
showRosterSearch,
|
||||
showRosterFilters,
|
||||
query,
|
||||
setQuery,
|
||||
activeFilterCount,
|
||||
gatewayOptions,
|
||||
rowKindFilter,
|
||||
setRowKindFilter,
|
||||
activityFilter,
|
||||
setActivityFilter,
|
||||
gatewayFilter,
|
||||
setGatewayFilter
|
||||
}: renderRosterToolbarProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-2 px-2.5 pt-2.5 pb-1.5">
|
||||
<span className="text-[0.6875rem] font-semibold uppercase tracking-wider text-(--ui-text-quaternary)">
|
||||
Bots
|
||||
</span>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Tip
|
||||
label={activityToasts ? 'Activity toasts on — click to silence' : 'Activity toasts off — click to enable'}
|
||||
>
|
||||
<Button
|
||||
className="rounded-md text-(--ui-text-tertiary) hover:text-foreground"
|
||||
onClick={() => setActivityToasts(!activityToasts)}
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name={activityToasts ? 'bell' : 'bell-slash'} />
|
||||
</Button>
|
||||
</Tip>
|
||||
<DropdownMenu>
|
||||
<Tip label="New…">
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label={b.roster.newBotOrGroup}
|
||||
className="rounded-md text-(--ui-text-tertiary) hover:text-foreground"
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="add" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</Tip>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onSelect={() => setCreateOpen(true)}>
|
||||
<Codicon className="mr-1.5" name="hubot" />
|
||||
{b.bot.newTitle}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled={activeSourceRoster.length < 2} onSelect={() => setGroupCreateOpen(true)}>
|
||||
<Codicon className="mr-1.5" name="organization" />
|
||||
{b.group.newTitle}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={() => setSectionDialog({ mode: 'create' })}>
|
||||
<Codicon className="mr-1.5" name="new-folder" />
|
||||
{b.sections.newSection}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
{showRosterTools ? (
|
||||
<div className="flex min-w-0 items-center gap-1 px-2.5 pb-1.5">
|
||||
{showRosterSearch ? (
|
||||
<SearchField
|
||||
aria-label={b.roster.search}
|
||||
containerClassName={cn('min-w-0 flex-1', query ? 'opacity-100!' : 'opacity-50 focus-within:opacity-100')}
|
||||
inputClassName="w-full text-[0.75rem] placeholder:text-(--ui-text-tertiary)"
|
||||
key={'roster-search'}
|
||||
onChange={setQuery}
|
||||
placeholder={b.roster.searchPlaceholder}
|
||||
value={query}
|
||||
/>
|
||||
) : (
|
||||
<span className="min-w-0 flex-1" key={'roster-search-spacer'} />
|
||||
)}
|
||||
{showRosterFilters ? (
|
||||
<DropdownMenu key={'roster-filters'}>
|
||||
<Tip label={activeFilterCount ? `Filters (${activeFilterCount} active)` : 'Filter roster'}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label={activeFilterCount ? `Filter roster, ${activeFilterCount} active` : 'Filter roster'}
|
||||
className={cn(
|
||||
'size-7 shrink-0 rounded-md text-(--ui-text-tertiary) hover:text-foreground',
|
||||
activeFilterCount && 'text-(--ui-accent)'
|
||||
)}
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="list-filter" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</Tip>
|
||||
<DropdownMenuContent align="end">
|
||||
{(
|
||||
[
|
||||
['all', b.roster.botsAndGroups],
|
||||
['bots', b.roster.botsOnly],
|
||||
['groups', b.roster.groupsOnly]
|
||||
] as [RosterKindFilter, string][]
|
||||
).map(([value, label]) => (
|
||||
<DropdownMenuItem key={`kind:${value}`} onSelect={() => setRowKindFilter(value)}>
|
||||
<span className="min-w-0 flex-1">{label}</span>
|
||||
{rowKindFilter === value ? <Codicon name="check" /> : null}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
{(
|
||||
[
|
||||
['all', b.roster.anyActivity],
|
||||
['active', b.roster.activeNow],
|
||||
['recent', b.roster.recentlyActive],
|
||||
['older', b.roster.older]
|
||||
] as [RosterActivityFilter, string][]
|
||||
).map(([value, label]) => (
|
||||
<DropdownMenuItem key={`activity:${value}`} onSelect={() => setActivityFilter(value)}>
|
||||
<span className="min-w-0 flex-1">{label}</span>
|
||||
{activityFilter === value ? <Codicon name="check" /> : null}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{gatewayOptions.length > 1 ? <DropdownMenuSeparator /> : null}
|
||||
{gatewayOptions.length > 1 ? (
|
||||
<DropdownMenuItem onSelect={() => setGatewayFilter('all')}>
|
||||
<Codicon className="mr-1.5" name="globe" />
|
||||
<span className="min-w-0 flex-1">All gateways</span>
|
||||
{gatewayFilter === 'all' ? <Codicon name="check" /> : null}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{gatewayOptions.length > 1
|
||||
? gatewayOptions.map(option => {
|
||||
const status = botSourceStatus({
|
||||
sourceError: option.error,
|
||||
sourceReachable: option.reachable
|
||||
})
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={option.connectionId}
|
||||
onSelect={() => setGatewayFilter(option.connectionId)}
|
||||
>
|
||||
<GatewayKindGlyph
|
||||
className={cn('mr-1.5', !status.available && 'text-amber-600 dark:text-amber-300')}
|
||||
kind={option.kind}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate">{option.label || option.connectionId}</span>
|
||||
<span className="text-[0.625rem] tabular-nums text-(--ui-text-quaternary)">
|
||||
{option.count}
|
||||
</span>
|
||||
{gatewayFilter === option.connectionId ? <Codicon name="check" /> : null}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})
|
||||
: []}
|
||||
{activeFilterCount ? <DropdownMenuSeparator /> : null}
|
||||
{activeFilterCount ? (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
setRowKindFilter('all')
|
||||
setActivityFilter('all')
|
||||
setGatewayFilter('all')
|
||||
}}
|
||||
>
|
||||
{b.roster.clearFilters}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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: <name>` session, so room context survives like any other conversation.
|
||||
- **Not every Bot replies to every message.** Speaking is each member's own choice — a Bot replies only when it has something new to add and passes otherwise, and @-mentioning specific members scopes the round to them. Expect the members you addressed (or whoever has something to say) to speak, and the rest to stay quiet.
|
||||
|
||||
Reference in New Issue
Block a user