104ec97595
`botHandle()` exists so that, per its own comment, "the word 'default'
never surfaces in the UI" — it presents the primary profile as `hermes`.
The roster rows, mention resolution and the group-chat prompt all route
through it. Two preview paths did not, and rendered the raw profile name:
- `GroupRow`'s room preview line built `@${last.from?.name}`, so a group
room read `@default: …` while the bot answers to `@hermes`.
- `previewKind()` returned the raw captured name from the bot-to-bot
delivery prefix, so the `🤖 @<name>` badge and its tooltip could show
`@default` too.
The mismatch is presentation-only, but it reads as a routing bug: the
room says the message came from `@default` while `@default` is not a
handle the mention resolver accepts, so users reasonably conclude
bot-to-bot addressing is broken when it is working correctly.
Both paths now map through `botHandle()`. `GroupRow` passes the matching
member so a bot with a custom handle keeps it; `previewKind` maps the
lowercased sender name, which leaves every non-primary profile unchanged.
Tests: the primary profile resolves to `hermes` and a named profile keeps
its own handle (behavioural, in the existing previewKind suite), plus a
source-shape assertion for the render path matching that file's
convention. Both new assertions fail against the pre-fix source.
Fixes #89484
10110 lines
373 KiB
JavaScript
10110 lines
373 KiB
JavaScript
/**
|
||
* Hermes Bot Mode — a "one chat per agent" roster for the Hermes desktop.
|
||
*
|
||
* Left pane "Bots": one row per Hermes profile (a bot = an agent profile) with
|
||
* a customizable avatar (shape + color + eyes, image, or pet). Click opens that
|
||
* bot's chat; right-click → Edit Profile (avatar, title, description).
|
||
* "New Agent" creates a profile — Name / Title / Description with an
|
||
* "Advanced" disclosure for full profile config.
|
||
*
|
||
* Right tile "Routines": scheduled tasks (Hermes cron jobs) scoped to the
|
||
* bot you're currently chatting with — follows the live gateway profile.
|
||
*
|
||
* Bots message each other straight into each bot's ONE canonical "Bot
|
||
* Chat" — @-mentions deliver over gateway RPCs (no CLI relay), and
|
||
* bot-initiated sends use `hermes -p <bot> chat --in ~ -c "Bot Chat"`.
|
||
*/
|
||
|
||
import * as sdk from '@hermes/plugin-sdk'
|
||
import {
|
||
atom,
|
||
Button,
|
||
Checkbox,
|
||
cn,
|
||
Codicon,
|
||
COMPOSER_AREAS,
|
||
ContextMenu,
|
||
ContextMenuContent,
|
||
ContextMenuItem,
|
||
ContextMenuSeparator,
|
||
ContextMenuTrigger,
|
||
ConfirmDialog,
|
||
CopyButton,
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
DropdownMenu,
|
||
DropdownMenuContent,
|
||
DropdownMenuItem,
|
||
DropdownMenuTrigger,
|
||
EmptyState,
|
||
GlyphSpinner,
|
||
haptic,
|
||
host,
|
||
Input,
|
||
PALETTE_AREA,
|
||
profileColor,
|
||
queryClient,
|
||
relativeTime,
|
||
ScrollArea,
|
||
SearchField,
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
Switch,
|
||
Textarea,
|
||
Tip,
|
||
useQuery,
|
||
useValue
|
||
} from '@hermes/plugin-sdk'
|
||
import { useEffect, useRef, useState } from 'react'
|
||
import { jsx, jsxs } from 'react/jsx-runtime'
|
||
|
||
const { McpTab, ToolsetConfigPanel } = sdk
|
||
// Keep optional exports feature-detected; test harnesses may strip the SDK namespace.
|
||
const SkillsView = typeof sdk === 'undefined' ? undefined : sdk.SkillsView
|
||
// TRUE only on builds whose SkillsView routes `fixedConnection` to the pinned
|
||
// registry connection's backend. Older builds export SkillsView WITHOUT the
|
||
// prop — rendering it for a remote-target draft there would read/write the
|
||
// ACTIVE gateway's skills under the remote bot's name (the wrong machine),
|
||
// so those builds keep the staged checklists for remote targets.
|
||
const skillsViewRoutesConnections = Boolean(SkillsView && SkillsView.supportsFixedConnection)
|
||
const Streamdown = typeof sdk === 'undefined' ? undefined : sdk.Streamdown
|
||
// Deterministic blob avatars (name → face). Feature-detected: older SDKs
|
||
// without the export fall back to the legacy math-face shapes below.
|
||
const blobatarSvg = typeof sdk === 'undefined' ? undefined : sdk.blobatarSvg
|
||
// Budgeted render loop (fps cap + observability pause + dormancy + teardown).
|
||
// Feature-detected: older desktops fall back to the hand-rolled clock below.
|
||
const createBudgetedLoop = typeof sdk === 'undefined' ? undefined : sdk.createBudgetedLoop
|
||
|
||
const ID = 'hermes-bots'
|
||
const ROSTER_KEY = [ID, 'roster']
|
||
const ROUTINES_KEY = [ID, 'routines']
|
||
const NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/
|
||
|
||
/** Captured in register() so components can reach plugin storage. */
|
||
let pluginCtx = null
|
||
|
||
/** Live roster snapshot for imperative handlers (context menus). */
|
||
const $lastRoster = atom([])
|
||
|
||
/** Bots with chat activity the user hasn't seen yet (name -> true).
|
||
* Fed by the roster poll's activity watermark, so it catches EVERY
|
||
* delivery path: RPC, CLI (bot-to-bot), cron runs, other machines. */
|
||
const $botUnread = atom({})
|
||
|
||
// last_active watermark per bot, seeded on first poll so a fresh mount
|
||
// doesn't mark ancient history unread.
|
||
const rosterWatermarks = new Map()
|
||
let watermarksSeeded = false
|
||
|
||
/** User pref: toast on every new bot activity. Default OFF — a busy roster
|
||
* (cron runs, bot-to-bot chatter) turns the toasts into a firehose, and the
|
||
* unread badge already carries the signal. Persisted via ctx.storage. */
|
||
const $activityToasts = atom(false)
|
||
|
||
/** Flip the activity-toast pref and persist it. */
|
||
function setActivityToasts(enabled) {
|
||
$activityToasts.set(enabled)
|
||
|
||
try {
|
||
Promise.resolve(pluginCtx?.storage?.set?.('activity-toasts', enabled)).catch(() => undefined)
|
||
} catch {
|
||
/* storage unavailable — pref holds for this window only */
|
||
}
|
||
}
|
||
|
||
/** Detect new inbound activity from a fresh roster: last_active moved past
|
||
* the watermark for a bot whose chat isn't on screen -> unread + toast. */
|
||
function trackInboundActivity(roster) {
|
||
const seeding = !watermarksSeeded
|
||
watermarksSeeded = true
|
||
|
||
for (const bot of roster) {
|
||
const ts = bot.last_session?.last_active || 0
|
||
const prev = rosterWatermarks.get(bot.name) || 0
|
||
rosterWatermarks.set(bot.name, Math.max(prev, ts))
|
||
|
||
if (seeding || ts <= prev) {
|
||
continue
|
||
}
|
||
|
||
// Activity in the bot the user is currently looking at is already
|
||
// visible — never badge the open chat.
|
||
if ($selectedBot.get() === bot.name) {
|
||
continue
|
||
}
|
||
|
||
$botUnread.set({ ...$botUnread.get(), [bot.name]: true })
|
||
|
||
// Roster-hidden bots stay quiet: the unread flag above accumulates
|
||
// silently (unhiding reveals the badge) but a hidden bot never toasts.
|
||
if ($botMeta.get()[bot.name]?.hidden) {
|
||
continue
|
||
}
|
||
|
||
// Toasts are opt-in: the unread badge is always set above, but the
|
||
// per-message notification fires only when the user enabled it.
|
||
if ($activityToasts.get()) {
|
||
const meta = $botMeta.get()[bot.name]
|
||
const label = displayName(bot, meta)
|
||
const preview = (bot.last_session?.preview || '').trim()
|
||
const inbound = /^Message from/i.test(preview)
|
||
|
||
host.notify({
|
||
kind: 'info',
|
||
title: inbound ? `\uD83E\uDD16 New message for ${label}` : `${label} has new activity`,
|
||
message: preview.slice(0, 140) || 'Open the chat to see it.'
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
/** Last good cron list, same idea as the roster snapshot. */
|
||
const $lastJobs = atom([])
|
||
|
||
// Bot Mode sessions are ALWAYS hidden from the global Sessions sidebar:
|
||
// canonical Bot Chats are plugin-owned forever-chats and group-chat member
|
||
// sessions are room plumbing — neither is a scratch conversation, and a
|
||
// 6-member room would otherwise dump six identical "Group: ..." rows into
|
||
// recents. Backed by the core generic `hidden` session flag (session.create
|
||
// hidden:true / session.set_hidden); the Bots pane browses them via
|
||
// session.list include_hidden. Older gateways ignore the flag and the
|
||
// sessions simply stay visible there.
|
||
|
||
/** Bot the Routines tile is scoped to. Follows the live gateway profile
|
||
* (the bot you're actually chatting with) and roster clicks. */
|
||
const $selectedBot = atom('default')
|
||
|
||
/** Owner profile of the chat the user is LOOKING AT. Newer desktops expose
|
||
* `host.state.focusedSessionProfile` (the focused session row's stamped
|
||
* owner, gateway profile for drafts); older builds fall back to the gateway
|
||
* profile atom — the socket's home — which is the previous behavior. The
|
||
* distinction matters because tab/tile focus moves WITHOUT swapping the
|
||
* gateway socket: with only the socket atom, opening another bot's chat in
|
||
* a tab left the roster highlight (and the Cronjobs tile) on whichever bot
|
||
* the socket happened to be homed on. */
|
||
const $focusedBotProfile = host.state.focusedSessionProfile || host.state.profile
|
||
|
||
/** Optional secondary navigation inside the Bots pane. Primary row clicks still
|
||
* open the bot's canonical chat; this state opens its stored-session browser. */
|
||
const $botSessionsWorkspace = atom(null)
|
||
const $botSelectedSessions = atom({})
|
||
const $sessionsGatewayGeneration = atom(0)
|
||
|
||
/** Group-chat rooms: { [group]: { log: [{from:{kind,name},text,at}], watermarks:{[member]:idx}, epoch, running } }.
|
||
* Log + watermarks persist via plugin storage; epoch/running are runtime-only. */
|
||
const $groupChats = atom({})
|
||
/** Group whose room view is open in the Bots pane (secondary navigation,
|
||
* same pattern as $botSessionsWorkspace). */
|
||
const $groupChatWorkspace = atom(null)
|
||
/** Groups whose latest room activity mentions @user — the needs-you badge. */
|
||
const $groupNeedsYou = atom({})
|
||
|
||
function handleSessionsGatewayTransition() {
|
||
$sessionsGatewayGeneration.set($sessionsGatewayGeneration.get() + 1)
|
||
$botSelectedSessions.set({})
|
||
// A gateway swap invalidates any in-flight room drive: bump every room's
|
||
// epoch so running loops bail at their next member boundary.
|
||
const rooms = { ...$groupChats.get() }
|
||
|
||
for (const name of Object.keys(rooms)) {
|
||
rooms[name] = { ...rooms[name], epoch: (rooms[name].epoch || 0) + 1, running: false }
|
||
}
|
||
|
||
$groupChats.set(rooms)
|
||
}
|
||
|
||
/** Per-bot appearance + display meta, persisted via ctx.storage:
|
||
* { [botName]: { shape, color, title } } */
|
||
const $botMeta = atom({})
|
||
|
||
async function saveBotMeta(name, patch) {
|
||
const prevMeta = $botMeta.get()[name] || {}
|
||
const next = { ...$botMeta.get(), [name]: { ...prevMeta, ...patch } }
|
||
$botMeta.set(next)
|
||
|
||
// Local plugin storage: instant, and the fallback for older gateways.
|
||
try {
|
||
Promise.resolve(pluginCtx?.storage?.set?.('bot-meta', next)).catch(() => undefined)
|
||
} catch {
|
||
/* storage unavailable — look persists for this window only */
|
||
}
|
||
|
||
// Server-side (source of truth when supported): profile.yaml ui_meta,
|
||
// namespaced under this plugin's id — every client machine sees the same
|
||
// roster. Return the outcome so user-initiated saves can distinguish a
|
||
// cross-machine save from a local-only fallback instead of reporting a
|
||
// false success. Data-URL fields are stripped from ui_meta (64KB cap,
|
||
// rides every profiles.list); the avatar IMAGE goes to the profile asset
|
||
// store instead (profiles.set_asset), which is server-side and uncapped by
|
||
// the list call — so pfps follow the profile across machines too.
|
||
let serverRequest = null
|
||
try {
|
||
const { image, pet, ...rest } = next[name] || {}
|
||
serverRequest = Promise.resolve(host.request('profiles.configure', { name, ui_meta: { 'hermes-bots': rest } }))
|
||
} catch {
|
||
/* older/unavailable gateway — the local fallback remains saved */
|
||
}
|
||
|
||
// Avatar image → profile asset store (feature-detected; local storage
|
||
// remains the fallback rendering source on older gateways) — but only when
|
||
// the image actually CHANGED. Every Edit Profile save sends the image key
|
||
// (changed or not); a no-op `clear` from one machine can race another
|
||
// machine's just-pushed avatar and wipe it server-side, and a no-op
|
||
// `data` push re-uploads the full data URL for nothing.
|
||
if ('image' in patch && patch.image !== (prevMeta.image ?? null)) {
|
||
try {
|
||
const req = patch.image
|
||
? host.request('profiles.set_asset', { name, asset: 'avatar', data: patch.image })
|
||
: host.request('profiles.set_asset', { name, asset: 'avatar', clear: true })
|
||
req.catch(() => undefined)
|
||
} catch {
|
||
/* older gateway */
|
||
}
|
||
}
|
||
|
||
// Three-way outcome so callers can tell a REAL remote failure from the
|
||
// documented legacy fallback ("older gateways reject the param shape;
|
||
// that's fine, local wins"):
|
||
// 'persisted' — gateway confirmed applied.ui_meta === true
|
||
// 'unsupported' — older gateway: request rejected, or response carries
|
||
// no `applied` contract at all. Silent local fallback;
|
||
// an error toast here would fire on EVERY save forever.
|
||
// 'failed' — gateway speaks the contract and explicitly reported
|
||
// the ui_meta write did NOT apply.
|
||
let serverOutcome = 'unsupported'
|
||
if (serverRequest) {
|
||
try {
|
||
const result = await serverRequest
|
||
if (result?.applied?.ui_meta === true) {
|
||
serverOutcome = 'persisted'
|
||
} else if (result && typeof result === 'object' && result.applied && typeof result.applied === 'object') {
|
||
serverOutcome = 'failed'
|
||
}
|
||
} catch {
|
||
/* older/unavailable gateway — the local fallback remains saved */
|
||
}
|
||
}
|
||
|
||
return { serverPersisted: serverOutcome === 'persisted', serverOutcome }
|
||
}
|
||
|
||
// ── hidden bots (right-click → Hide Bot) ────────────────────────────────────
|
||
// Hiding is a ROSTER-DISPLAY concern only: a hidden bot keeps working —
|
||
// @mentions still resolve, group-chat membership is untouched, its name
|
||
// still counts as taken, and an open chat stays open. The flag lives in bot
|
||
// meta (`hidden: true`), so it rides the same local-storage + server
|
||
// ui_meta pipeline as pins/titles and follows the profile across machines.
|
||
// Unhide writes `hidden: false` (never null): a null key survives the local
|
||
// `{ ...prev, ...patch }` merge while the server DELETES None keys, and
|
||
// that asymmetry lets mergeServerMeta resurrect a stale truthy copy. A
|
||
// literal false round-trips identically through both stores.
|
||
|
||
/** Session-only view toggle: reveal hidden bots (dimmed) in the roster. */
|
||
const $showHiddenBots = atom(false)
|
||
|
||
/** Hidden flag for a roster row. Thin remote-source rows never read local
|
||
* meta (botRosterMeta returns null for them), so hide is by NAME on the
|
||
* active source; remote rows of the same name stay visible. */
|
||
function isBotHidden(bot, metaByName) {
|
||
return Boolean(botRosterMeta(bot, metaByName)?.hidden)
|
||
}
|
||
|
||
/** Hiding the selected bot re-homes the selection (the Routines pane
|
||
* follows it): first visible bot wins, then 'default' — unless default is
|
||
* itself hidden with nothing else visible, in which case the selection
|
||
* stays put rather than pointing somewhere even less real. */
|
||
function fallbackSelectionAfterHide(name) {
|
||
if ($selectedBot.get() !== name) {
|
||
return
|
||
}
|
||
|
||
const meta = $botMeta.get()
|
||
const visible = $lastRoster
|
||
.get()
|
||
.filter(bot => !bot.remoteSource && bot.name !== name && !meta[bot.name]?.hidden)
|
||
|
||
if (visible.length) {
|
||
$selectedBot.set(visible[0].name)
|
||
return
|
||
}
|
||
|
||
if (name !== 'default' && !meta.default?.hidden) {
|
||
$selectedBot.set('default')
|
||
}
|
||
}
|
||
|
||
/** One-time reconciliation: Bot Mode sessions are always hidden, but rooms
|
||
* and Bot Chats created before this policy (or while the old pref was off)
|
||
* left visible rows behind. On every plugin load, sweep every session id we
|
||
* own — canonical chats from bot meta plus each group room's member
|
||
* sessions — through the core session.set_hidden RPC, then run the
|
||
* ownership-based sweep for the rows we DON'T know by id. Idempotent (the DB
|
||
* setter is a no-op on already-hidden rows) and feature-detected: older
|
||
* gateways lack session.set_hidden and simply keep the rows visible. */
|
||
function hideOwnedBotSessions() {
|
||
const canonical = Object.values($botMeta.get())
|
||
.map(m => m && m.chat)
|
||
.filter(Boolean)
|
||
const rooms = Object.values($groupChats.get())
|
||
.flatMap(room => Object.values(room?.sessions || {}))
|
||
.filter(sid => Boolean(sid) && sid !== true)
|
||
const ids = [...new Set([...canonical, ...rooms])]
|
||
|
||
const known = Promise.all(
|
||
ids.map(sid =>
|
||
Promise.resolve(host.request('session.set_hidden', { session_id: sid, hidden: true })).catch(() => undefined)
|
||
)
|
||
)
|
||
|
||
return Promise.all([known, sweepBotProfileSessions().catch(() => undefined)])
|
||
}
|
||
|
||
// Titles Bot Mode itself mints for its plumbing sessions. Bot-to-bot CLI
|
||
// handoffs (`hermes -p <bot> chat --in ~ -c "Bot Chat" --create-if-missing`)
|
||
// and mention handoffs create sessions with EXACTLY these titles; the
|
||
// "Group: " prefix is the member-session title ensureGroupChatSession has
|
||
// used since group chats shipped. Exact/prefix matching is deliberate — a
|
||
// user's real conversation inside a bot profile keeps whatever title the
|
||
// user gave it and is never touched.
|
||
const BOT_MODE_SWEEP_TITLES = new Set(['Bot Chat', 'Agent Inbox'])
|
||
|
||
function isBotModeSweepTitle(title) {
|
||
const t = String(title || '').trim()
|
||
return BOT_MODE_SWEEP_TITLES.has(t) || t.startsWith('Group: ')
|
||
}
|
||
|
||
/** Ownership-based sweep: the id-based sweep above only covers sessions the
|
||
* plugin recorded ($botMeta canonical chats, $groupChats member sids), but
|
||
* Bot Mode sessions are ALSO minted outside the plugin — bot-to-bot CLI
|
||
* handoffs ("Agent Inbox" / extra "Bot Chat" rows born visible in a bot's
|
||
* profile) — and those ids the plugin never learns. So: enumerate each
|
||
* roster bot's OWN profile sessions (only bot profiles — a non-bot profile
|
||
* is never listed, so its sessions are never touched) and hide any VISIBLE
|
||
* row whose title is Bot Mode plumbing. session.list without include_hidden
|
||
* returns only visible rows, which keeps the sweep naturally idempotent.
|
||
* Remote-source bots route to their own connection via requestForBot.
|
||
* Feature-detected + fire-and-forget: older gateways without per-profile
|
||
* session.list / session.set_hidden simply reject and the sweep no-ops. */
|
||
async function sweepBotProfileSessions() {
|
||
const cached = $lastRoster.get()
|
||
let roster = Array.isArray(cached) && cached.length ? cached : null
|
||
|
||
if (!roster) {
|
||
// Plugin load can run before the Bots pane hydrates $lastRoster — fall
|
||
// back to the active gateway's own profile list (local bots; remote
|
||
// sources get covered by the next sweep once the roster cache exists).
|
||
try {
|
||
const res = await host.request('profiles.list', {})
|
||
roster = Array.isArray(res?.profiles) ? res.profiles : []
|
||
} catch {
|
||
return
|
||
}
|
||
}
|
||
|
||
await Promise.all(
|
||
roster.map(async bot => {
|
||
const name = String(bot?.name || '').trim()
|
||
|
||
if (!name) {
|
||
return
|
||
}
|
||
|
||
try {
|
||
const res = await requestForBot(bot, 'session.list', { profile: name, limit: PROFILE_SESSION_LIST_LIMIT })
|
||
const rows = Array.isArray(res?.sessions) ? res.sessions : []
|
||
|
||
await Promise.all(
|
||
rows
|
||
.filter(row => row && row.id && isBotModeSweepTitle(row.title))
|
||
.map(row =>
|
||
Promise.resolve(
|
||
requestForBot(bot, 'session.set_hidden', { session_id: row.id, hidden: true, profile: name })
|
||
).catch(() => undefined)
|
||
)
|
||
)
|
||
} catch {
|
||
/* older gateway / unreachable source — leave this profile alone */
|
||
}
|
||
})
|
||
)
|
||
}
|
||
|
||
/** Fetch server-side avatars for roster rows flagged has_avatar when the
|
||
* local cache doesn't already have an image for them. Fire-and-forget. */
|
||
const avatarFetchInflight = new Set()
|
||
|
||
const avatarPushInflight = new Set()
|
||
|
||
/** Backfill: local meta has art the server lacks -> profiles.set_asset.
|
||
* Server-side avatars power the inter-agent notice pfp (core #85855) and
|
||
* cross-machine roster art, so local-only images are a bug, not a state. */
|
||
function pushLocalAvatars(roster) {
|
||
for (const bot of roster) {
|
||
if (bot.has_avatar || avatarPushInflight.has(bot.name)) {
|
||
continue
|
||
}
|
||
|
||
const image = $botMeta.get()[bot.name]?.image
|
||
|
||
if (image && typeof image === 'string' && image.startsWith('data:')) {
|
||
avatarPushInflight.add(bot.name)
|
||
host
|
||
.request('profiles.set_asset', { name: bot.name, asset: 'avatar', data: image })
|
||
.then(() => queryClient.invalidateQueries({ queryKey: ['hermes-bots', 'roster'] }))
|
||
.catch(() => avatarPushInflight.delete(bot.name))
|
||
continue
|
||
}
|
||
|
||
// Vector shape/color face: no image exists anywhere — rasterize the
|
||
// live SVG (tagged data-bot-face) to a PNG and push that, so the
|
||
// inter-agent notices (core #85855/#85888) can show the real pfp.
|
||
const svg = document.querySelector('svg[data-bot-face=' + JSON.stringify(bot.name) + ']')
|
||
|
||
if (!svg) {
|
||
continue
|
||
}
|
||
|
||
avatarPushInflight.add(bot.name)
|
||
rasterizeSvgToPng(svg, 160)
|
||
.then(png =>
|
||
png
|
||
? host
|
||
.request('profiles.set_asset', { name: bot.name, asset: 'avatar', data: png })
|
||
.then(() => queryClient.invalidateQueries({ queryKey: ['hermes-bots', 'roster'] }))
|
||
: Promise.reject(new Error('rasterize failed'))
|
||
)
|
||
.catch(() => avatarPushInflight.delete(bot.name))
|
||
}
|
||
}
|
||
|
||
/** Serialize an inline SVG and draw it to a canvas -> PNG data URL. */
|
||
function rasterizeSvgToPng(svgEl, size) {
|
||
return new Promise(resolve => {
|
||
try {
|
||
const clone = svgEl.cloneNode(true)
|
||
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg')
|
||
clone.setAttribute('width', String(size))
|
||
clone.setAttribute('height', String(size))
|
||
const markup = new XMLSerializer().serializeToString(clone)
|
||
const url = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(markup)
|
||
const img = new Image()
|
||
|
||
img.onload = () => {
|
||
try {
|
||
const canvas = document.createElement('canvas')
|
||
canvas.width = size
|
||
canvas.height = size
|
||
canvas.getContext('2d').drawImage(img, 0, 0, size, size)
|
||
resolve(canvas.toDataURL('image/png'))
|
||
} catch {
|
||
resolve(null)
|
||
}
|
||
}
|
||
img.onerror = () => resolve(null)
|
||
img.src = url
|
||
} catch {
|
||
resolve(null)
|
||
}
|
||
})
|
||
}
|
||
|
||
/** The roster backfill draws the live SVG at 160x160. Pets are 96x104
|
||
* and uploads are 256. Use that to tell a still face-copy from a real picture. */
|
||
function isBackfilledFacePng(dataUrl) {
|
||
if (!dataUrl || typeof dataUrl !== 'string' || !dataUrl.startsWith('data:image/png;base64,')) {
|
||
return false
|
||
}
|
||
|
||
try {
|
||
const bin = atob(dataUrl.slice('data:image/png;base64,'.length).slice(0, 48))
|
||
if (bin.length < 24) {
|
||
return false
|
||
}
|
||
const w = (bin.charCodeAt(16) << 24) | (bin.charCodeAt(17) << 16) | (bin.charCodeAt(18) << 8) | bin.charCodeAt(19)
|
||
const h = (bin.charCodeAt(20) << 24) | (bin.charCodeAt(21) << 16) | (bin.charCodeAt(22) << 8) | bin.charCodeAt(23)
|
||
return w === 160 && h === 160
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
function pullServerAvatars(roster) {
|
||
pushLocalAvatars(roster)
|
||
|
||
for (const bot of roster) {
|
||
if (!bot.has_avatar || avatarFetchInflight.has(bot.name)) {
|
||
continue
|
||
}
|
||
|
||
if ($botMeta.get()[bot.name]?.image) {
|
||
continue
|
||
}
|
||
|
||
avatarFetchInflight.add(bot.name)
|
||
host
|
||
.request('profiles.get_asset', { name: bot.name, asset: 'avatar' })
|
||
.then(res => {
|
||
if (res?.found && res.data) {
|
||
const current = $botMeta.get()
|
||
const mine = current[bot.name] || {}
|
||
// A 160px raster of the vector face is only for inter-agent
|
||
// notices. Do not park it on the roster or the live face dies.
|
||
if (isBackfilledFacePng(res.data) && mine.imageKind !== 'photo' && !mine.pet) {
|
||
return
|
||
}
|
||
$botMeta.set({ ...current, [bot.name]: { ...mine, image: res.data } })
|
||
|
||
try {
|
||
Promise.resolve(pluginCtx?.storage?.set?.('bot-meta', $botMeta.get())).catch(() => undefined)
|
||
} catch {
|
||
/* no storage */
|
||
}
|
||
}
|
||
})
|
||
.catch(() => undefined)
|
||
.finally(() => avatarFetchInflight.delete(bot.name))
|
||
}
|
||
}
|
||
|
||
/** Server ui_meta (per roster row) beats local storage for the compact
|
||
* fields it carries; local-only fields (avatar image data URL, extracted
|
||
* pet icon) are PRESERVED — the server copy never includes them, so a
|
||
* naive replace would wipe a just-saved image avatar on the next roster
|
||
* paint. When server bot metadata exists, an omitted chat is authoritative
|
||
* deletion; local still fills all gaps for older gateways with no metadata. */
|
||
function mergeServerMeta(roster) {
|
||
const local = $botMeta.get()
|
||
let changed = false
|
||
const next = { ...local }
|
||
|
||
for (const bot of roster) {
|
||
const server = bot.ui_meta?.['hermes-bots']
|
||
if (server && typeof server === 'object') {
|
||
const mine = next[bot.name] || {}
|
||
const merged = { ...mine, ...server }
|
||
|
||
// Local-only fields survive the server overlay.
|
||
if (mine.image) {
|
||
merged.image = mine.image
|
||
}
|
||
|
||
// Server metadata is authoritative for the canonical chat pointer.
|
||
// Without this deletion sync, ctx.storage resurrects stale sessions
|
||
// after the server pin is cleared and even after a full app restart.
|
||
if (
|
||
Object.prototype.hasOwnProperty.call(mine, 'chat') &&
|
||
!Object.prototype.hasOwnProperty.call(server, 'chat')
|
||
) {
|
||
delete merged.chat
|
||
}
|
||
|
||
// Canonical multi-group metadata is authoritative for the compatibility
|
||
// scalar too. A server-side `group: null` is represented by omission,
|
||
// so retaining the local scalar would resurrect a membership that another
|
||
// desktop just removed.
|
||
if (
|
||
Array.isArray(server.groups) &&
|
||
Object.prototype.hasOwnProperty.call(mine, 'group') &&
|
||
!Object.prototype.hasOwnProperty.call(server, 'group')
|
||
) {
|
||
delete merged.group
|
||
}
|
||
|
||
if (JSON.stringify(next[bot.name] || null) !== JSON.stringify(merged)) {
|
||
next[bot.name] = merged
|
||
changed = true
|
||
}
|
||
}
|
||
}
|
||
|
||
if (changed) {
|
||
$botMeta.set(next)
|
||
|
||
// Persist server reconciliation so a relaunch cannot rehydrate stale
|
||
// local fields that the server intentionally removed.
|
||
try {
|
||
Promise.resolve(pluginCtx?.storage?.set?.('bot-meta', next)).catch(() => undefined)
|
||
} catch {
|
||
/* storage unavailable — reconciliation lasts for this window only */
|
||
}
|
||
}
|
||
}
|
||
|
||
/** Clone a bot: profile (config/skills/SOUL/memory via clone_from) + look.
|
||
* Name is "<base>-2", "-3", … — first free slot against the live roster. */
|
||
async function duplicateBot(bot, roster) {
|
||
const base = bot.name
|
||
let name = null
|
||
for (let n = 2; n < 100; n++) {
|
||
// Truncate the BASE, never the suffix — slicing the joined string chops
|
||
// the "-2" off a max-length name and the candidate collides with the
|
||
// base forever (#19).
|
||
const suffix = `-${n}`
|
||
const candidate = base.slice(0, 64 - suffix.length) + suffix
|
||
if (!roster.some(b => b.name === candidate)) {
|
||
name = candidate
|
||
break
|
||
}
|
||
}
|
||
|
||
if (!name) {
|
||
throw new Error('No free name for the duplicate.')
|
||
}
|
||
|
||
await host.request('profiles.create', {
|
||
name,
|
||
clone_from: base,
|
||
description: bot.description || ''
|
||
})
|
||
|
||
// Same look: avatar shape/color/image and a "(copy)" title so the two
|
||
// are tellable apart in the roster until the user renames. Do not copy
|
||
// chat or created. Those belong to the original bot.
|
||
const meta = $botMeta.get()[base]
|
||
if (meta) {
|
||
const { chat, created, ...look } = meta
|
||
saveBotMeta(name, {
|
||
...look,
|
||
title: meta.title ? `${meta.title} (copy)` : ''
|
||
})
|
||
}
|
||
|
||
return name
|
||
}
|
||
|
||
/** Permanently delete a bot's Hermes profile, then remove plugin-local state
|
||
* that would otherwise leave stale appearance/unread data behind.
|
||
*
|
||
* Prefer the SDK's `host.deleteProfile` when this Desktop build ships it: it
|
||
* routes through the Electron-intercepted REST delete, which tears down the
|
||
* bot's pool backend FIRST and routes the next request away from it. The
|
||
* older `cli.exec` path bypasses that interception, so a backend that the
|
||
* roster's hover pre-warm just woke (right-click hovers the row!) holds the
|
||
* profile dir open — the CLI's rmtree races the live backend and the
|
||
* renderer's socket reconnect respawns it mid-delete, resurrecting the
|
||
* directory (hermes-agent#52279). That is the "can't delete a bot" error. */
|
||
async function deleteBot(bot) {
|
||
if (typeof host.deleteProfile === 'function') {
|
||
await host.deleteProfile(bot.name)
|
||
} else {
|
||
// Older desktop without the SDK verb — best effort via the CLI.
|
||
const result = await host.request('cli.exec', {
|
||
argv: ['profile', 'delete', bot.name, '--yes']
|
||
})
|
||
|
||
if (result?.blocked || result?.code !== 0) {
|
||
throw new Error(result?.hint || result?.output || `Could not delete profile ${bot.name}.`)
|
||
}
|
||
}
|
||
|
||
const meta = { ...$botMeta.get() }
|
||
delete meta[bot.name]
|
||
$botMeta.set(meta)
|
||
|
||
try {
|
||
await Promise.resolve(pluginCtx?.storage?.set?.('bot-meta', meta))
|
||
} catch {
|
||
/* profile is deleted; stale local appearance is harmless if storage fails */
|
||
}
|
||
|
||
const unread = { ...$botUnread.get() }
|
||
delete unread[bot.name]
|
||
$botUnread.set(unread)
|
||
rosterWatermarks.delete(bot.name)
|
||
avatarFetchInflight.delete(bot.name)
|
||
avatarPushInflight.delete(bot.name)
|
||
|
||
if ($selectedBot.get() === bot.name) {
|
||
$selectedBot.set('default')
|
||
}
|
||
|
||
queryClient.invalidateQueries({ queryKey: ROSTER_KEY })
|
||
|
||
if (host.state.profile.get?.() === bot.name && typeof host.newChat === 'function') {
|
||
host.newChat('default')
|
||
}
|
||
}
|
||
|
||
// ── avatars (shape + color + eyes) ──────────────────────────────────────────
|
||
|
||
// The original flat shapes. Sigils ('sigil-N') and platonic
|
||
// solids remain render-only so any bot that picked one during the experiments
|
||
// keeps its look.
|
||
// Radix ScrollArea's viewport wraps children in a display:table div that
|
||
// sizes to content — unbounded width means `truncate` below it never fires
|
||
// and previews run through the panel edge. Scope-limited corrective.
|
||
//
|
||
// A second Radix quirk bites in the dialogs: the viewport is height:100%,
|
||
// which computes to auto when the root only has max-height (no definite
|
||
// height anywhere up the chain) — the viewport grows to full content height,
|
||
// the root's overflow:hidden clips it, and NOTHING scrolls (#88). Capping
|
||
// the viewport itself (inheriting the root's max-height) makes it the real
|
||
// scroll container; lists shorter than the cap still shrink to fit.
|
||
if (typeof document !== 'undefined' && !document.getElementById('hermes-bots-roster-css')) {
|
||
const style = document.createElement('style')
|
||
style.id = 'hermes-bots-roster-css'
|
||
style.textContent =
|
||
'.hermes-bots-roster [data-radix-scroll-area-viewport] > div {' +
|
||
' display: block !important; width: 100%; min-width: 0; }' +
|
||
'.hermes-scroll-cap > [data-radix-scroll-area-viewport] { max-height: inherit; }' +
|
||
'@keyframes hermes-bots-pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.35; } }' +
|
||
'.hermes-bots-pulse { animation: hermes-bots-pulse 1.2s ease-in-out infinite; }'
|
||
document.head.appendChild(style)
|
||
}
|
||
|
||
const AVATAR_SHAPES = ['circle', 'squircle', 'pill', 'triangle', 'hexagon', 'cloud', 'drop']
|
||
const AVATAR_PICKER_SHAPES = ['circle', 'blob', 'squircle', 'pill', 'triangle', 'hexagon', 'cloud', 'drop']
|
||
|
||
/** xorshift PRNG seeded from a string — stable across sessions/platforms. */
|
||
function sigilRng(text) {
|
||
let h = 2166136261
|
||
for (const ch of text) {
|
||
h ^= ch.charCodeAt(0)
|
||
h = Math.imul(h, 16777619)
|
||
}
|
||
let state = h >>> 0 || 88675123
|
||
return () => {
|
||
state ^= state << 13
|
||
state ^= state >>> 17
|
||
state ^= state << 5
|
||
state >>>= 0
|
||
return state / 4294967296
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Angular hermetic sigil: strokes on the left half of a 5-column grid,
|
||
* mirrored right, plus a chance of a diamond ring. Returns SVG path strings.
|
||
*/
|
||
function sigilGeometry(name, seed) {
|
||
const rng = sigilRng(`${name}::${seed}`)
|
||
const gx = i => 6 + i * 7 // 5 cols: 6..34
|
||
const gy = j => 8 + j * 6 // 5 rows: 8..32
|
||
const strokes = []
|
||
const segments = 4 + Math.floor(rng() * 3)
|
||
|
||
for (let k = 0; k < segments; k++) {
|
||
const x1 = Math.floor(rng() * 3) // left half incl. center
|
||
const y1 = Math.floor(rng() * 5)
|
||
const x2 = Math.min(2, Math.max(0, x1 + (rng() > 0.5 ? 1 : -1)))
|
||
const y2 = Math.min(4, Math.max(0, y1 + Math.floor(rng() * 3) - 1))
|
||
|
||
strokes.push(`M${gx(x1)} ${gy(y1)} L${gx(x2)} ${gy(y2)}`)
|
||
// mirror (col i → col 4-i)
|
||
strokes.push(`M${gx(4 - x1)} ${gy(y1)} L${gx(4 - x2)} ${gy(y2)}`)
|
||
|
||
// occasional cross-tie through the axis for connectedness
|
||
if (rng() > 0.6) {
|
||
strokes.push(`M${gx(x2)} ${gy(y2)} L${gx(4 - x2)} ${gy(y2)}`)
|
||
}
|
||
}
|
||
|
||
// spine down the axis grounds every variant
|
||
strokes.push(`M20 ${gy(0)} L20 ${gy(4)}`)
|
||
|
||
const ring = rng() > 0.45 ? 'M20 4 L36 20 L20 36 L4 20 Z' : null
|
||
return { strokes: strokes.join(' '), ring }
|
||
}
|
||
|
||
const AVATAR_COLORS = [
|
||
'#f5f5f4', // white
|
||
'#8d6748', // brown
|
||
'#ef4444', // red
|
||
'#f97316', // orange
|
||
'#14b8a6', // teal
|
||
'#38bdf8', // cyan
|
||
'#3b40c8', // royal blue
|
||
'#8b5cf6', // violet
|
||
'#ec4899', // magenta
|
||
'#9ca3af' // silver
|
||
]
|
||
|
||
/** Perceptual luminance — eyes/pupils flip light on dark bodies (ink, oxblood). */
|
||
function isDarkColor(hex) {
|
||
try {
|
||
const n = parseInt(hex.slice(1), 16)
|
||
const r = (n >> 16) & 255
|
||
const g = (n >> 8) & 255
|
||
const b = n & 255
|
||
return 0.2126 * r + 0.7152 * g + 0.0722 * b < 110
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
function defaultShapeFor(name) {
|
||
let hash = 0
|
||
for (const ch of name) {
|
||
hash = (hash * 31 + ch.charCodeAt(0)) >>> 0
|
||
}
|
||
return AVATAR_SHAPES[hash % AVATAR_SHAPES.length]
|
||
}
|
||
|
||
// ── blobatar shapes mode (default for new agents) ───────────────────────────
|
||
// Deterministic soft-body faces drawn from a string. Shape strings:
|
||
// 'blobatar' — the face follows the bot's NAME (renaming the
|
||
// bot re-rolls the face, live in the dialog)
|
||
// 'blobatar:<seed>' — seed locked (the 🔒 lock / 🎲 randomize picks)
|
||
// 'blobatar:<seed>:<kind>' — plus one of the ten silhouettes pinned
|
||
// 'blobatar::<kind>' — silhouette pinned, seed still follows the name
|
||
// Bot names are slugs (NAME_RE) and generated seeds are base36, so ':' never
|
||
// appears inside a segment. Colors come from the library's own name-derived
|
||
// palette (contrast-guaranteed) — the classic color swatches don't apply.
|
||
|
||
const BLOB_KINDS = ['round', 'organic', 'boxy', 'capsule', 'nub', 'cloud', 'droplet', 'hexagon', 'sun', 'triangle']
|
||
|
||
// Trait positions at the center of each silhouette band. Band thresholds are
|
||
// frozen per blobatar major (gen2: 0.22 / 0.48 / 0.60 / 0.70 / 0.79 / 0.86 /
|
||
// 0.915 / 0.95 / 0.98).
|
||
const BLOB_KIND_TRAIT = {
|
||
round: 0.11, organic: 0.35, boxy: 0.54, capsule: 0.65, nub: 0.745,
|
||
cloud: 0.825, droplet: 0.8875, hexagon: 0.9325, sun: 0.965, triangle: 0.99
|
||
}
|
||
|
||
function isBlobShape(shape) {
|
||
return shape === 'blobatar' || (typeof shape === 'string' && shape.startsWith('blobatar:'))
|
||
}
|
||
|
||
function parseBlobShape(shape, name) {
|
||
const parts = typeof shape === 'string' ? shape.split(':') : []
|
||
const seedPart = parts[1] || ''
|
||
const kind = BLOB_KINDS.includes(parts[2]) ? parts[2] : ''
|
||
return { seed: seedPart || name || 'agent', seedPart, kind }
|
||
}
|
||
|
||
function blobShapeString(seedPart, kind) {
|
||
if (kind) {
|
||
return `blobatar:${seedPart}:${kind}`
|
||
}
|
||
return seedPart ? `blobatar:${seedPart}` : 'blobatar'
|
||
}
|
||
|
||
/** Static SVG markup for a blob face, tagged data-bot-face so the roster's
|
||
* PNG backfill (pushLocalAvatars → rasterizeSvgToPng) still finds it. */
|
||
function blobMarkup(shape, name, size) {
|
||
if (!blobatarSvg) {
|
||
return null
|
||
}
|
||
|
||
const { seed, kind } = parseBlobShape(shape, name)
|
||
const opts = { size }
|
||
|
||
if (kind) {
|
||
opts.traits = { shape: BLOB_KIND_TRAIT[kind] }
|
||
}
|
||
|
||
try {
|
||
return blobatarSvg(seed, opts).replace('<svg ', '<svg data-bot-face=' + JSON.stringify(name) + ' ')
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
/** The colored body of the avatar (no eyes). Platonic solids are a filled
|
||
* silhouette + translucent internal edge lines (the projected wireframe);
|
||
* legacy flat shapes keep their old geometry so stored picks still render. */
|
||
function shapeNode(shape, color, botName = 'agent') {
|
||
if (shape.startsWith('sigil-')) {
|
||
const seed = Number(shape.slice(6)) || 0
|
||
const { strokes, ring } = sigilGeometry(botName, seed)
|
||
const sw = { fill: 'none', stroke: color, strokeWidth: 2.2, strokeLinecap: 'round', strokeLinejoin: 'round' }
|
||
return jsxs('g', {
|
||
children: [
|
||
ring ? jsx('path', { d: ring, fill: 'none', stroke: color, strokeWidth: 1.2, opacity: 0.5 }) : null,
|
||
jsx('path', { d: strokes, ...sw })
|
||
]
|
||
})
|
||
}
|
||
|
||
const stroke = { fill: color, stroke: color, strokeWidth: 7, strokeLinejoin: 'round' }
|
||
const edge = { fill: 'none', stroke: 'rgba(0,0,0,0.4)', strokeWidth: 1.4, strokeLinejoin: 'round', strokeLinecap: 'round' }
|
||
const face = { fill: color, stroke: 'rgba(0,0,0,0.4)', strokeWidth: 1.4, strokeLinejoin: 'round' }
|
||
|
||
switch (shape) {
|
||
// ── platonic solids ──
|
||
case 'tetrahedron':
|
||
return jsxs('g', {
|
||
children: [
|
||
jsx('path', { d: 'M20 5 L36 33 L4 33 Z', ...face }),
|
||
jsx('path', { d: 'M20 5 L20 25 M4 33 L20 25 M36 33 L20 25', ...edge })
|
||
]
|
||
})
|
||
case 'cube':
|
||
return jsxs('g', {
|
||
children: [
|
||
jsx('path', { d: 'M20 4 L33 11 L33 29 L20 36 L7 29 L7 11 Z', ...face }),
|
||
jsx('path', { d: 'M7 11 L20 18 L33 11 M20 18 L20 36', ...edge })
|
||
]
|
||
})
|
||
case 'octahedron':
|
||
return jsxs('g', {
|
||
children: [
|
||
jsx('path', { d: 'M20 3 L36 20 L20 37 L4 20 Z', ...face }),
|
||
jsx('path', { d: 'M4 20 L36 20 M20 3 L20 37', ...edge })
|
||
]
|
||
})
|
||
case 'dodecahedron':
|
||
return jsxs('g', {
|
||
children: [
|
||
jsx('path', {
|
||
d: 'M20 3 L30 6.2 L36.2 14.7 L36.2 25.3 L30 33.8 L20 37 L10 33.8 L3.8 25.3 L3.8 14.7 L10 6.2 Z',
|
||
...face
|
||
}),
|
||
jsx('path', {
|
||
d:
|
||
'M20 12 L27.6 17.5 L24.7 26.5 L15.3 26.5 L12.4 17.5 Z ' +
|
||
'M20 12 L20 3 M27.6 17.5 L36.2 14.7 M24.7 26.5 L30 33.8 M15.3 26.5 L10 33.8 M12.4 17.5 L3.8 14.7',
|
||
...edge
|
||
})
|
||
]
|
||
})
|
||
case 'icosahedron':
|
||
return jsxs('g', {
|
||
children: [
|
||
jsx('path', { d: 'M20 3 L34.7 11.5 L34.7 28.5 L20 37 L5.3 28.5 L5.3 11.5 Z', ...face }),
|
||
jsx('path', {
|
||
d:
|
||
'M20 11 L27.8 24.5 L12.2 24.5 Z ' +
|
||
'M20 11 L20 3 M20 11 L34.7 11.5 M20 11 L5.3 11.5 ' +
|
||
'M27.8 24.5 L34.7 11.5 M27.8 24.5 L34.7 28.5 M27.8 24.5 L20 37 ' +
|
||
'M12.2 24.5 L5.3 11.5 M12.2 24.5 L5.3 28.5 M12.2 24.5 L20 37',
|
||
...edge
|
||
})
|
||
]
|
||
})
|
||
|
||
// ── legacy flat shapes (stored picks from earlier versions) ──
|
||
case 'squircle':
|
||
return jsx('rect', { x: 3, y: 3, width: 34, height: 34, rx: 11, fill: color })
|
||
case 'pill':
|
||
return jsx('rect', { x: 2, y: 7, width: 36, height: 26, rx: 13, fill: color })
|
||
case 'triangle':
|
||
return jsx('path', { d: 'M20 5.5 L36 33.5 L4 33.5 Z', ...stroke })
|
||
case 'hexagon':
|
||
return jsx('path', { d: 'M20 3.5 L34.5 11.75 L34.5 28.25 L20 36.5 L5.5 28.25 L5.5 11.75 Z', ...stroke })
|
||
case 'cloud':
|
||
return jsx('path', {
|
||
d: 'M11 32 a7.5 7.5 0 0 1 -1 -14.9 A9.5 9.5 0 0 1 29 12.5 A7 7 0 0 1 30 32 Z',
|
||
fill: color
|
||
})
|
||
case 'drop':
|
||
return jsx('path', { d: 'M20 3 C20 3 6 20 6 27 a14 13.5 0 0 0 28 0 C34 20 20 3 20 3 Z', fill: color })
|
||
default:
|
||
return jsx('circle', { cx: 20, cy: 20, r: 17.5, fill: color })
|
||
}
|
||
}
|
||
|
||
const EYE_Y = {
|
||
// solids: eyes sit on the upper face region, clear of the busiest edges
|
||
tetrahedron: 26,
|
||
cube: 22.5,
|
||
octahedron: 14.5,
|
||
dodecahedron: 20,
|
||
icosahedron: 17.5,
|
||
// legacy
|
||
circle: 17,
|
||
squircle: 17,
|
||
pill: 20,
|
||
triangle: 25,
|
||
hexagon: 17,
|
||
cloud: 22,
|
||
drop: 24
|
||
}
|
||
|
||
// Solids draw eyes slightly tighter so they read as ON a face.
|
||
const EYE_X = {
|
||
tetrahedron: [16.5, 23.5],
|
||
cube: [15, 25],
|
||
octahedron: [16, 24],
|
||
dodecahedron: [16.5, 23.5],
|
||
icosahedron: [16.5, 23.5]
|
||
}
|
||
|
||
function cubicAt(p0, p1, p2, p3, t) {
|
||
const u = 1 - t
|
||
return [
|
||
u * u * u * p0[0] + 3 * u * u * t * p1[0] + 3 * u * t * t * p2[0] + t * t * t * p3[0],
|
||
u * u * u * p0[1] + 3 * u * u * t * p1[1] + 3 * u * t * t * p2[1] + t * t * t * p3[1]
|
||
]
|
||
}
|
||
|
||
/** Same outline as the old GitHub drop path, so it stays a fat water drop. */
|
||
function sampleDropRing(steps) {
|
||
const pts = []
|
||
const n = Math.max(8, Math.floor(steps / 3))
|
||
|
||
for (let i = 0; i < n; i++) {
|
||
pts.push(cubicAt([20, 3], [20, 3], [6, 20], [6, 27], i / n))
|
||
}
|
||
|
||
for (let i = 0; i <= n; i++) {
|
||
const t = (i / n) * Math.PI
|
||
pts.push([20 - 14 * Math.cos(t), 27 + 13.5 * Math.sin(t)])
|
||
}
|
||
|
||
for (let i = 1; i <= n; i++) {
|
||
pts.push(cubicAt([34, 27], [34, 20], [20, 3], [20, 3], i / n))
|
||
}
|
||
|
||
return pts
|
||
}
|
||
|
||
function svgArc(x1, y1, rx, ry, fa, fs, x2, y2) {
|
||
const dx = (x1 - x2) / 2
|
||
const dy = (y1 - y2) / 2
|
||
let rx2 = rx * rx
|
||
let ry2 = ry * ry
|
||
const lam = (dx * dx) / rx2 + (dy * dy) / ry2
|
||
if (lam > 1) {
|
||
const s = Math.sqrt(lam)
|
||
rx *= s
|
||
ry *= s
|
||
rx2 = rx * rx
|
||
ry2 = ry * ry
|
||
}
|
||
const num = rx2 * ry2 - rx2 * dy * dy - ry2 * dx * dx
|
||
const den = rx2 * dy * dy + ry2 * dx * dx
|
||
let sq = Math.sqrt(Math.max(0, num / den))
|
||
if (fa === fs) {
|
||
sq = -sq
|
||
}
|
||
const cx = sq * (rx * dy / ry) + (x1 + x2) / 2
|
||
const cy = sq * (-ry * dx / rx) + (y1 + y2) / 2
|
||
const ang = (ux, uy, vx, vy) => {
|
||
const n = Math.hypot(ux, uy) * Math.hypot(vx, vy) || 1
|
||
let a = Math.acos(Math.max(-1, Math.min(1, (ux * vx + uy * vy) / n)))
|
||
if (ux * vy - uy * vx < 0) {
|
||
a = -a
|
||
}
|
||
return a
|
||
}
|
||
const theta1 = ang(1, 0, (x1 - cx) / rx, (y1 - cy) / ry)
|
||
let dtheta = ang((x1 - cx) / rx, (y1 - cy) / ry, (x2 - cx) / rx, (y2 - cy) / ry)
|
||
if (!fs && dtheta > 0) {
|
||
dtheta -= Math.PI * 2
|
||
}
|
||
if (fs && dtheta < 0) {
|
||
dtheta += Math.PI * 2
|
||
}
|
||
return { cx, cy, rx, ry, theta1, dtheta }
|
||
}
|
||
|
||
function sampleArc(arc, n) {
|
||
const pts = []
|
||
for (let i = 0; i < n; i++) {
|
||
const th = arc.theta1 + arc.dtheta * (i / n)
|
||
pts.push([arc.cx + arc.rx * Math.cos(th), arc.cy + arc.ry * Math.sin(th)])
|
||
}
|
||
return pts
|
||
}
|
||
|
||
/** Same outline as the old GitHub cloud path: three puffs and a flat floor. */
|
||
function sampleCloudRing(steps) {
|
||
const a1 = svgArc(11, 32, 7.5, 7.5, 0, 1, 10, 17.1)
|
||
const a2 = svgArc(10, 17.1, 9.5, 9.5, 0, 1, 29, 12.5)
|
||
const a3 = svgArc(29, 12.5, 7, 7, 0, 1, 30, 32)
|
||
const len1 = Math.abs(a1.dtheta) * a1.rx
|
||
const len2 = Math.abs(a2.dtheta) * a2.rx
|
||
const len3 = Math.abs(a3.dtheta) * a3.rx
|
||
const len4 = 19
|
||
const total = len1 + len2 + len3 + len4
|
||
const n = Math.max(64, steps)
|
||
const n1 = Math.max(8, Math.round(n * len1 / total))
|
||
const n2 = Math.max(10, Math.round(n * len2 / total))
|
||
const n3 = Math.max(10, Math.round(n * len3 / total))
|
||
const n4 = Math.max(4, n - n1 - n2 - n3)
|
||
const pts = []
|
||
pts.push(...sampleArc(a1, n1))
|
||
pts.push(...sampleArc(a2, n2))
|
||
pts.push(...sampleArc(a3, n3))
|
||
for (let i = 0; i < n4; i++) {
|
||
pts.push([30 + (11 - 30) * (i / n4), 32])
|
||
}
|
||
return pts
|
||
}
|
||
|
||
/** Outline of a face in a 40x40 box. Same family as Grok Bot
|
||
* (blob / squircle / pebble / \u2026) but sampled from formulas, not
|
||
* a dumped point cloud. */
|
||
function sampleFaceRing(shape, steps = 52) {
|
||
const kind = (shape || '').startsWith('sigil-') ? 'circle' : shape
|
||
|
||
if (kind === 'drop' || kind === 'teardrop') {
|
||
return sampleDropRing(steps)
|
||
}
|
||
if (kind === 'cloud') {
|
||
return sampleCloudRing(steps)
|
||
}
|
||
const pts = []
|
||
|
||
for (let i = 0; i < steps; i++) {
|
||
const a = (i / steps) * Math.PI * 2 - Math.PI / 2
|
||
const c = Math.cos(a)
|
||
const s = Math.sin(a)
|
||
let rx = 16
|
||
let ry = 16
|
||
if (kind === 'circle') {
|
||
rx = ry = 16.2
|
||
} else if (kind === 'blob') {
|
||
rx = ry = 16 + 1.7 * Math.sin(3 * a) + 0.7 * Math.cos(5 * a)
|
||
} else if (kind === 'squircle') {
|
||
const p = 5
|
||
const d = Math.pow(Math.abs(c) ** p + Math.abs(s) ** p, 1 / p) || 1
|
||
rx = ry = 16.2 / d
|
||
} else if (kind === 'pill') {
|
||
const d = Math.pow(Math.abs(c) ** 8 + Math.abs(s / 0.72) ** 8, 1 / 8) || 1
|
||
rx = ry = 16 / d
|
||
} else if (kind === 'triangle' || kind === 'tetrahedron' || kind === 'wedge') {
|
||
const u = (a + Math.PI / 2 + Math.PI * 2) % (Math.PI * 2)
|
||
const sector = (u / (Math.PI * 2 / 3)) % 1
|
||
rx = ry = 13.5 / Math.max(0.42, Math.cos((sector - 0.5) * 1.9))
|
||
} else if (kind === 'hexagon' || kind === 'hex' || kind === 'icosahedron' || kind === 'dodecahedron') {
|
||
const seg = Math.PI / 3
|
||
const hex = Math.cos(seg / 2) / Math.cos(a - seg * Math.round(a / seg))
|
||
rx = ry = 16.2 * hex
|
||
} else if (kind === 'cube' || kind === 'octahedron') {
|
||
const p = 3.1
|
||
const d = Math.pow(Math.abs(c) ** p + Math.abs(s) ** p, 1 / p) || 1
|
||
rx = ry = 16 / d
|
||
} else if (kind === 'pebble') {
|
||
rx = 16.4 * (1.04 - 0.14 * Math.cos(2 * a))
|
||
ry = 15.2 * (1.06 + 0.08 * Math.sin(2 * a))
|
||
} else {
|
||
rx = ry = 16.2
|
||
}
|
||
|
||
pts.push([20 + rx * c, 20 + ry * s])
|
||
}
|
||
|
||
return pts
|
||
}
|
||
|
||
function projectFacePoint(x, y, turn, tilt, roll) {
|
||
const dx = x - 20
|
||
const dy = y - 20
|
||
const r = (roll * Math.PI) / 180
|
||
const xr = dx * Math.cos(r) - dy * Math.sin(r)
|
||
const yr = dx * Math.sin(r) + dy * Math.cos(r)
|
||
const sx = 0.74 + 0.26 * Math.abs(Math.cos((turn * Math.PI) / 180))
|
||
const sy = 0.8 + 0.2 * Math.abs(Math.cos((tilt * Math.PI) / 180))
|
||
return [20 + xr * sx, 20 + yr * sy]
|
||
}
|
||
|
||
function ringToPath(pts) {
|
||
if (!pts.length) {
|
||
return ''
|
||
}
|
||
|
||
let d = `M${pts[0][0].toFixed(2)} ${pts[0][1].toFixed(2)}`
|
||
|
||
for (let i = 1; i < pts.length; i++) {
|
||
d += `L${pts[i][0].toFixed(2)} ${pts[i][1].toFixed(2)}`
|
||
}
|
||
|
||
return d + 'Z'
|
||
}
|
||
|
||
/** Grok-style pose. thinking/working lean and sway. idle is a small sine. */
|
||
function facePose(mood, t) {
|
||
if (mood === 'work') {
|
||
return {
|
||
turn: -11 + Math.sin(t * 0.48) * 8,
|
||
tilt: Math.sin(t * 0.42) * 8 + Math.sin(t * 1.1) * 1.6,
|
||
roll: Math.sin(t * 0.75) * 4.2,
|
||
gazeX: Math.sin(t * 0.55) * 3.6,
|
||
gazeY: -1.6 + Math.sin(t * 0.38) * 2,
|
||
blink: t % 1.45 > 1.26,
|
||
d0: 0.2 + 0.8 * Math.max(0, Math.sin(t * 2.6)),
|
||
d1: 0.2 + 0.8 * Math.max(0, Math.sin(t * 2.6 - 0.7)),
|
||
d2: 0.2 + 0.8 * Math.max(0, Math.sin(t * 2.6 - 1.4))
|
||
}
|
||
}
|
||
|
||
return {
|
||
turn: Math.sin(t * 0.5) * 1.5,
|
||
tilt: Math.sin(t * 0.27),
|
||
roll: Math.sin(t * 0.85) * 1.2,
|
||
gazeX: 0,
|
||
gazeY: 0,
|
||
blink: t % 3.2 > 3.02,
|
||
d0: 0,
|
||
d1: 0,
|
||
d2: 0
|
||
}
|
||
}
|
||
|
||
function paintMathFace(svg, t) {
|
||
const mood = svg.getAttribute('data-hb-mood') || 'idle'
|
||
const shape = svg.getAttribute('data-hb-shape') || 'circle'
|
||
const pose = facePose(mood, t)
|
||
const body = svg.querySelector('[data-hb-body]')
|
||
const open = svg.querySelector('[data-hb-open]')
|
||
const shut = svg.querySelector('[data-hb-shut]')
|
||
const el = svg.querySelector('[data-hb-el]')
|
||
const er = svg.querySelector('[data-hb-er]')
|
||
const dots = svg.querySelectorAll('[data-hb-dot]')
|
||
|
||
if (body) {
|
||
if (shape === 'cloud') {
|
||
body.setAttribute('d', 'M11 32 a7.5 7.5 0 0 1 -1 -14.9 A9.5 9.5 0 0 1 29 12.5 A7 7 0 0 1 30 32 Z')
|
||
} else {
|
||
const ring = sampleFaceRing(shape).map(([x, y]) => projectFacePoint(x, y, pose.turn, pose.tilt, pose.roll))
|
||
body.setAttribute('d', ringToPath(ring))
|
||
}
|
||
}
|
||
|
||
const eyeY = (shape === 'cloud' ? 22 : 17.2) + pose.gazeY
|
||
const eyeL = 15.4 + pose.gazeX
|
||
const eyeR = 24.6 + pose.gazeX
|
||
|
||
if (el) {
|
||
el.setAttribute('cx', eyeL)
|
||
el.setAttribute('cy', eyeY)
|
||
}
|
||
|
||
if (er) {
|
||
er.setAttribute('cx', eyeR)
|
||
er.setAttribute('cy', eyeY)
|
||
}
|
||
|
||
// Catchlights ride the pupils (upper-left offset) — without this they
|
||
// stay at the circle-face position and drift outside e.g. the cloud's
|
||
// lower-set eyes.
|
||
const hl = svg.querySelector('[data-hb-hl-l]')
|
||
const hr = svg.querySelector('[data-hb-hl-r]')
|
||
|
||
if (hl) {
|
||
hl.setAttribute('cx', eyeL - 0.6)
|
||
hl.setAttribute('cy', eyeY - 0.7)
|
||
}
|
||
|
||
if (hr) {
|
||
hr.setAttribute('cx', eyeR - 0.6)
|
||
hr.setAttribute('cy', eyeY - 0.7)
|
||
}
|
||
|
||
if (open) {
|
||
open.setAttribute('opacity', pose.blink ? '0' : '1')
|
||
}
|
||
|
||
if (shut) {
|
||
shut.setAttribute('d', `M${eyeL - 2.6} ${eyeY} L${eyeL + 2.6} ${eyeY} M${eyeR - 2.6} ${eyeY} L${eyeR + 2.6} ${eyeY}`)
|
||
shut.setAttribute('opacity', pose.blink ? '1' : '0')
|
||
}
|
||
|
||
dots.forEach((dot, i) => {
|
||
const o = i === 0 ? pose.d0 : i === 1 ? pose.d1 : pose.d2
|
||
dot.setAttribute('opacity', String(o))
|
||
})
|
||
|
||
svg.style.transform = `rotate(${pose.tilt}deg)`
|
||
svg.style.transformOrigin = '50% 70%'
|
||
}
|
||
|
||
function walkMathFaces(root, acc) {
|
||
if (!root || !root.querySelectorAll) {
|
||
return acc
|
||
}
|
||
|
||
root.querySelectorAll('svg[data-hb-math]').forEach(node => acc.push(node))
|
||
root.querySelectorAll('*').forEach(el => {
|
||
if (el.shadowRoot) {
|
||
walkMathFaces(el.shadowRoot, acc)
|
||
}
|
||
})
|
||
return acc
|
||
}
|
||
|
||
function startFaceClock() {
|
||
if (typeof window === 'undefined') {
|
||
return
|
||
}
|
||
|
||
if (window.__hbFaceClock) {
|
||
// Already initialized (possibly parked) — make sure it's awake. BotFace
|
||
// renders route here, so a face mounting is what wakes a dormant clock.
|
||
window.__hbFaceClock.wake()
|
||
|
||
return
|
||
}
|
||
|
||
const t0 = performance.now()
|
||
// A large roster can mount hundreds of faces. Observe the cached nodes so
|
||
// off-screen cards do not consume a full animation frame by themselves.
|
||
let faces = []
|
||
let lastScan = -Infinity
|
||
const visibleFaces = new Set()
|
||
const observedFaces = new Set()
|
||
const observer =
|
||
typeof IntersectionObserver === 'function'
|
||
? new IntersectionObserver(entries => {
|
||
let becameVisible = false
|
||
|
||
for (const entry of entries) {
|
||
if (entry.isIntersecting) {
|
||
visibleFaces.add(entry.target)
|
||
becameVisible = true
|
||
} else {
|
||
visibleFaces.delete(entry.target)
|
||
}
|
||
}
|
||
|
||
// A parked clock (no visible faces) resumes when one scrolls in.
|
||
if (becameVisible) {
|
||
window.__hbFaceClock?.wake()
|
||
}
|
||
})
|
||
: null
|
||
|
||
const scanFaces = () => {
|
||
faces = walkMathFaces(document, [])
|
||
|
||
if (!observer) {
|
||
return
|
||
}
|
||
|
||
const currentFaces = new Set(faces)
|
||
|
||
for (const svg of observedFaces) {
|
||
if (!currentFaces.has(svg)) {
|
||
observer.unobserve(svg)
|
||
observedFaces.delete(svg)
|
||
visibleFaces.delete(svg)
|
||
}
|
||
}
|
||
|
||
for (const svg of faces) {
|
||
if (!observedFaces.has(svg)) {
|
||
observedFaces.add(svg)
|
||
observer.observe(svg)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Shared painting body for both scheduling paths: 1Hz document rescans,
|
||
// paint only visible faces (all cached faces when IO is unavailable).
|
||
const paint = now => {
|
||
if (now - lastScan > 1000) {
|
||
scanFaces()
|
||
lastScan = now
|
||
}
|
||
const t = (now - t0) / 1000
|
||
const facesToPaint = observer ? visibleFaces : faces
|
||
|
||
for (const svg of facesToPaint) {
|
||
if (svg.isConnected) {
|
||
paintMathFace(svg, t)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Nothing worth animating: no faces mounted (BotFace wakes us on the next
|
||
// mount) or none visible (the observer wakes us when one scrolls in).
|
||
const idle = () => faces.length === 0 || (observer && visibleFaces.size === 0)
|
||
|
||
const teardownCaches = () => {
|
||
if (observer) {
|
||
observer.disconnect()
|
||
}
|
||
|
||
visibleFaces.clear()
|
||
observedFaces.clear()
|
||
faces = []
|
||
delete window.__hbFaceClock
|
||
}
|
||
|
||
// Newer desktops: the SDK's budgeted loop owns scheduling (15fps budget,
|
||
// hidden/minimized/unfocused pause, dormancy, teardown). typeof-guarded so
|
||
// older shells and the vm test harness use the hand-rolled path below.
|
||
if (typeof createBudgetedLoop === 'function' && createBudgetedLoop) {
|
||
const loop = createBudgetedLoop(paint, { fps: 15, idleWhen: idle })
|
||
|
||
window.__hbFaceClock = {
|
||
stop: () => {
|
||
loop.dispose()
|
||
teardownCaches()
|
||
},
|
||
wake: () => {
|
||
// Faces may have mounted/unmounted while parked — rescan on wake.
|
||
lastScan = -Infinity
|
||
loop.wake()
|
||
}
|
||
}
|
||
|
||
return
|
||
}
|
||
|
||
// Fallback scheduling for desktops whose SDK predates createBudgetedLoop.
|
||
let lastPaint = -Infinity
|
||
let rafId = 0
|
||
let dormant = false
|
||
let stopped = false
|
||
|
||
const tick = now => {
|
||
if (stopped) {
|
||
return
|
||
}
|
||
|
||
rafId = 0
|
||
// 15fps is smooth at avatar scale and bounds SVG/DOM churn. The clock
|
||
// still uses rAF so Chromium can pause it when the window is occluded.
|
||
if (!document.hidden && now - lastPaint >= 1000 / 15) {
|
||
paint(now)
|
||
lastPaint = now
|
||
}
|
||
|
||
// Park instead of burning frames + 1Hz whole-document shadow walks.
|
||
if (idle()) {
|
||
dormant = true
|
||
|
||
return
|
||
}
|
||
|
||
rafId = window.requestAnimationFrame(tick)
|
||
}
|
||
|
||
const wake = () => {
|
||
if (stopped || !dormant) {
|
||
return
|
||
}
|
||
|
||
dormant = false
|
||
// Faces may have mounted/unmounted while parked — rescan on first tick.
|
||
lastScan = -Infinity
|
||
rafId = window.requestAnimationFrame(tick)
|
||
}
|
||
|
||
const stop = () => {
|
||
stopped = true
|
||
|
||
if (rafId) {
|
||
window.cancelAnimationFrame(rafId)
|
||
rafId = 0
|
||
}
|
||
|
||
teardownCaches()
|
||
}
|
||
|
||
window.__hbFaceClock = { stop, wake }
|
||
rafId = window.requestAnimationFrame(tick)
|
||
}
|
||
|
||
/** Tear the face clock down (plugin disable/reload) — cancels the animation
|
||
* frame, disconnects the visibility observer, and drops all cached nodes. */
|
||
function stopFaceClock() {
|
||
if (typeof window !== 'undefined' && window.__hbFaceClock) {
|
||
window.__hbFaceClock.stop()
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Live math face. Photos still use <img>. Shape avatars stay SVG so
|
||
* the clock can move them (a baked PNG cannot).
|
||
*/
|
||
function BotFace({ shape, color, image, size = 36, name = 'agent', mood = 'idle' }) {
|
||
startFaceClock()
|
||
|
||
if (image) {
|
||
return jsx('img', {
|
||
src: image,
|
||
alt: '',
|
||
'aria-hidden': true,
|
||
style: { width: size, height: size, borderRadius: '22%', objectFit: 'cover', display: 'block' }
|
||
})
|
||
}
|
||
|
||
// Blobatar shapes: the library draws the whole face (body + eyes + its own
|
||
// name-derived palette). Inline SVG via innerHTML so the roster PNG
|
||
// backfill's `svg[data-bot-face=…]` query still finds it; the math clock
|
||
// ignores it (no data-hb-math). Falls back to the legacy math face when the
|
||
// SDK predates the export.
|
||
if (isBlobShape(shape)) {
|
||
const markup = blobMarkup(shape, name, size)
|
||
|
||
if (markup) {
|
||
return jsx('span', {
|
||
'aria-hidden': true,
|
||
style: { width: size, height: size, display: 'block', lineHeight: 0 },
|
||
dangerouslySetInnerHTML: { __html: markup }
|
||
})
|
||
}
|
||
|
||
// Older SDK without blobatar: legacy deterministic shape from the name.
|
||
shape = defaultShapeFor(name)
|
||
}
|
||
|
||
// Sigils are line art (no filled body) — the math clock rebuilds filled
|
||
// outlines, which would turn a stored sigil pick into a blank circle.
|
||
// Keep the legacy static render for them so old picks still draw.
|
||
if (shape.startsWith('sigil-')) {
|
||
const eyes = jsxs('g', {
|
||
children: [
|
||
jsx('circle', { cx: 16, cy: 14, r: 2.4, fill: color }),
|
||
jsx('circle', { cx: 24, cy: 14, r: 2.4, fill: color })
|
||
]
|
||
})
|
||
return jsxs('svg', {
|
||
'data-bot-face': name,
|
||
viewBox: '0 0 40 40',
|
||
width: size,
|
||
height: size,
|
||
'aria-hidden': true,
|
||
children: [shapeNode(shape, color, name), eyes]
|
||
})
|
||
}
|
||
|
||
const working = mood === 'work'
|
||
const eyeFill = isDarkColor(color) ? 'rgba(232,220,195,0.95)' : 'rgba(0,0,0,0.85)'
|
||
// Catchlight contrast follows the pupil, not the body: dark pupils get the
|
||
// white sparkle, light (cream) pupils on dark bodies get a dark one — a
|
||
// white dot on a cream pupil is invisible, which read as "no eye dots" on
|
||
// maroon/ink/oxblood avatars.
|
||
const hlFill = isDarkColor(color) ? 'rgba(0,0,0,0.6)' : 'rgba(255,255,255,0.85)'
|
||
const ring = sampleFaceRing(shape)
|
||
const rest = facePose(working ? 'work' : 'idle', 0)
|
||
// Shape-aware initial eye line — the cloud body sits lower, so its eyes
|
||
// (and their catchlights) start at the cloud position instead of jumping
|
||
// there on the first clock paint.
|
||
const eyeY0 = shape === 'cloud' ? 22 : 17.2
|
||
|
||
return jsxs('svg', {
|
||
'data-bot-face': name,
|
||
'data-hb-math': '1',
|
||
'data-hb-mood': working ? 'work' : 'idle',
|
||
'data-hb-shape': shape || 'circle',
|
||
viewBox: '0 0 40 44',
|
||
width: size,
|
||
height: size,
|
||
'aria-hidden': true,
|
||
style: { overflow: 'visible', display: 'block' },
|
||
children: [
|
||
jsx('path', {
|
||
'data-hb-body': '1',
|
||
d: shape === 'cloud'
|
||
? 'M11 32 a7.5 7.5 0 0 1 -1 -14.9 A9.5 9.5 0 0 1 29 12.5 A7 7 0 0 1 30 32 Z'
|
||
: ringToPath(ring),
|
||
fill: color
|
||
}),
|
||
jsxs('g', {
|
||
'data-hb-open': '1',
|
||
children: [
|
||
jsx('ellipse', { 'data-hb-el': '1', cx: 15.4, cy: eyeY0, rx: 2.2, ry: working ? 2.6 : 2.3, fill: eyeFill }),
|
||
jsx('ellipse', { 'data-hb-er': '1', cx: 24.6, cy: eyeY0, rx: 2.2, ry: working ? 2.6 : 2.3, fill: eyeFill }),
|
||
jsx('circle', { 'data-hb-hl-l': '1', cx: 14.8, cy: eyeY0 - 0.7, r: 0.65, fill: hlFill }),
|
||
jsx('circle', { 'data-hb-hl-r': '1', cx: 24, cy: eyeY0 - 0.7, r: 0.65, fill: hlFill })
|
||
]
|
||
}),
|
||
jsx('path', {
|
||
'data-hb-shut': '1',
|
||
d: `M12.8 ${eyeY0} L18 ${eyeY0} M22 ${eyeY0} L27.2 ${eyeY0}`,
|
||
stroke: eyeFill,
|
||
strokeWidth: 2,
|
||
strokeLinecap: 'round',
|
||
fill: 'none',
|
||
opacity: 0
|
||
}),
|
||
working
|
||
? jsxs('g', {
|
||
children: [
|
||
jsx('circle', { 'data-hb-dot': '1', cx: 16.4, cy: 41.2, r: 1.15, fill: color, opacity: rest.d0 }),
|
||
jsx('circle', { 'data-hb-dot': '1', cx: 20, cy: 41.2, r: 1.15, fill: color, opacity: rest.d1 }),
|
||
jsx('circle', { 'data-hb-dot': '1', cx: 23.6, cy: 41.2, r: 1.15, fill: color, opacity: rest.d2 })
|
||
]
|
||
})
|
||
: null
|
||
]
|
||
})
|
||
}
|
||
|
||
// -- inline MCP setup (per-profile), driven by the mcp.servers.* gateway RPCs --
|
||
// Feature-detected: if the gateway predates those RPCs the setup button hides
|
||
// and the row falls back to the "run hermes mcp / Settings" hint. profile is
|
||
// the target bot's profile name (its config is what we write).
|
||
|
||
async function mcpRpc(method, params) {
|
||
// Returns { ok, result } or { ok:false, unsupported:true } when the gateway
|
||
// doesn't know the method (older backend) vs a real error.
|
||
try {
|
||
const res = await host.request(method, params)
|
||
return { ok: true, result: res }
|
||
} catch (err) {
|
||
const msg = String((err && err.message) || err || '')
|
||
if (/unknown method/i.test(msg)) {
|
||
return { ok: false, unsupported: true }
|
||
}
|
||
return { ok: false, error: msg }
|
||
}
|
||
}
|
||
|
||
// Probe whether the new lifecycle RPCs exist on this gateway (cached per session).
|
||
let _mcpRpcSupported = null
|
||
async function mcpSetupSupported() {
|
||
if (_mcpRpcSupported !== null) {
|
||
return _mcpRpcSupported
|
||
}
|
||
const r = await mcpRpc('mcp.servers.list', {})
|
||
_mcpRpcSupported = !(r.ok === false && r.unsupported)
|
||
return _mcpRpcSupported
|
||
}
|
||
|
||
function McpSetupButton({ profile, entry, onDone, ensureProfile }) {
|
||
// entry: { name, requires:[env keys], auth?, fromCatalog, installed }
|
||
// profile may be null at first (New Agent: the profile isn't created yet).
|
||
// ensureProfile() lazily creates it on the first setup action and returns the
|
||
// slug, so OAuth / API-key setup works DURING creation, not only in Edit.
|
||
const [phase, setPhase] = useState('idle') // idle | keys | oauth | busy | done | error
|
||
const [supported, setSupported] = useState(null)
|
||
const [keyValues, setKeyValues] = useState({})
|
||
const [message, setMessage] = useState('')
|
||
const pollRef = useRef(null)
|
||
const profileRef = useRef(profile || null)
|
||
|
||
useEffect(() => {
|
||
if (profile) {
|
||
profileRef.current = profile
|
||
}
|
||
}, [profile])
|
||
|
||
// Resolve the target profile, creating it on demand for the New Agent flow.
|
||
const resolveProfile = async () => {
|
||
if (profileRef.current) {
|
||
return profileRef.current
|
||
}
|
||
if (ensureProfile) {
|
||
const created = await ensureProfile()
|
||
if (created) {
|
||
profileRef.current = created
|
||
}
|
||
return created
|
||
}
|
||
return null
|
||
}
|
||
|
||
useEffect(() => {
|
||
let alive = true
|
||
mcpSetupSupported().then(ok => {
|
||
if (alive) setSupported(ok)
|
||
})
|
||
return () => {
|
||
alive = false
|
||
if (pollRef.current) {
|
||
clearInterval(pollRef.current)
|
||
pollRef.current = null
|
||
}
|
||
}
|
||
}, [])
|
||
|
||
const isOAuth = (entry.auth || '').toLowerCase() === 'oauth'
|
||
const requires = entry.requires || []
|
||
|
||
const beginKeys = async () => {
|
||
// Ensure the server exists in the target profile first (add from catalog).
|
||
setPhase('busy')
|
||
setMessage('')
|
||
const profile = await resolveProfile()
|
||
if (!profile) {
|
||
setPhase('idle')
|
||
return
|
||
}
|
||
if (entry.fromCatalog && !entry.installed) {
|
||
const add = await mcpRpc('mcp.servers.add', { profile, name: entry.name, preset: entry.name })
|
||
if (!add.ok) {
|
||
setPhase('error')
|
||
setMessage(add.error || 'Could not add server')
|
||
return
|
||
}
|
||
}
|
||
setPhase(isOAuth ? 'oauth' : 'keys')
|
||
}
|
||
|
||
const submitKeys = async () => {
|
||
setPhase('busy')
|
||
const profile = profileRef.current
|
||
if (!profile) {
|
||
setPhase('error')
|
||
setMessage('No target profile')
|
||
return
|
||
}
|
||
for (const k of requires) {
|
||
const val = (keyValues[k] || '').trim()
|
||
if (!val) {
|
||
continue
|
||
}
|
||
const r = await mcpRpc('mcp.servers.set_api_key', { profile, name: entry.name, env_var: k, value: val })
|
||
if (!r.ok) {
|
||
setPhase('error')
|
||
setMessage(r.error || ('Failed to set ' + k))
|
||
return
|
||
}
|
||
}
|
||
// Verify via test.
|
||
const t = await mcpRpc('mcp.servers.test', { profile, name: entry.name })
|
||
if (t.ok && t.result && (t.result.ok || (t.result.result && t.result.result.ok))) {
|
||
setPhase('done')
|
||
host.notify({ kind: 'success', message: entry.name + ' configured' })
|
||
onDone && onDone()
|
||
} else {
|
||
setPhase('error')
|
||
setMessage((t.result && (t.result.error || (t.result.result && t.result.result.error))) || 'Server test failed after setup')
|
||
}
|
||
}
|
||
|
||
const beginOAuth = async () => {
|
||
// A second click (retry, impatient double-click) must not orphan the
|
||
// previous poll interval — an overwritten pollRef leaks a 2s poller that
|
||
// runs until unmount and can flip phase from a stale OAuth session.
|
||
if (pollRef.current) {
|
||
clearInterval(pollRef.current)
|
||
pollRef.current = null
|
||
}
|
||
setPhase('busy')
|
||
setMessage('')
|
||
const profile = await resolveProfile()
|
||
if (!profile) {
|
||
setPhase('idle')
|
||
return
|
||
}
|
||
if (entry.fromCatalog && !entry.installed) {
|
||
const add = await mcpRpc('mcp.servers.add', { profile, name: entry.name, preset: entry.name })
|
||
if (!add.ok) {
|
||
setPhase('error')
|
||
setMessage(add.error || 'Could not add server')
|
||
return
|
||
}
|
||
}
|
||
const start = await mcpRpc('mcp.servers.oauth.start', { profile, name: entry.name })
|
||
const payload = start.result && (start.result.result || start.result)
|
||
const authUrl = payload && (payload.auth_url || payload.verification_url)
|
||
const sessionId = payload && payload.session_id
|
||
if (!start.ok || !authUrl || !sessionId) {
|
||
setPhase('error')
|
||
setMessage((start.error) || 'Could not start OAuth')
|
||
return
|
||
}
|
||
// Open the auth URL in the native browser, same as provider OAuth.
|
||
try {
|
||
if (host.openExternal) {
|
||
host.openExternal(authUrl)
|
||
} else if (typeof window !== 'undefined' && window.hermesDesktop && window.hermesDesktop.openExternal) {
|
||
window.hermesDesktop.openExternal(authUrl)
|
||
} else {
|
||
window.open(authUrl, '_blank')
|
||
}
|
||
} catch {
|
||
/* fall through to poll; user can open the URL from the toast */
|
||
}
|
||
setPhase('oauth')
|
||
setMessage('Complete sign-in in your browser...')
|
||
pollRef.current = setInterval(async () => {
|
||
const poll = await mcpRpc('mcp.servers.oauth.poll', { profile, name: entry.name, session_id: sessionId })
|
||
const pd = poll.result && (poll.result.result || poll.result)
|
||
const status = pd && pd.status
|
||
if (status === 'approved') {
|
||
clearInterval(pollRef.current)
|
||
pollRef.current = null
|
||
setPhase('done')
|
||
host.notify({ kind: 'success', message: entry.name + ' authenticated' })
|
||
onDone && onDone()
|
||
} else if (status === 'error') {
|
||
clearInterval(pollRef.current)
|
||
pollRef.current = null
|
||
setPhase('error')
|
||
setMessage((pd && pd.error_message) || 'OAuth failed')
|
||
}
|
||
}, 2000)
|
||
}
|
||
|
||
if (supported === false) {
|
||
return jsx('span', {
|
||
className: 'ml-1.5 text-[0.65rem] text-(--ui-text-quaternary)',
|
||
children: 'needs setup (' + requires.join(', ') + ') \u2014 restart the gateway to enable in-app setup'
|
||
})
|
||
}
|
||
if (phase === 'done') {
|
||
return jsx('span', { className: 'ml-1.5 text-[0.65rem] text-(--ui-success,#22c55e)', children: 'set up \u2713' })
|
||
}
|
||
if (phase === 'keys') {
|
||
return jsxs('div', {
|
||
className: 'mt-1 grid gap-1',
|
||
children: [
|
||
...requires.map(k =>
|
||
jsx(Input, {
|
||
key: k,
|
||
type: 'password',
|
||
className: 'h-6 text-[0.7rem]',
|
||
placeholder: k,
|
||
value: keyValues[k] || '',
|
||
onChange: e => setKeyValues(prev => ({ ...prev, [k]: e.target.value }))
|
||
}, k)
|
||
),
|
||
jsxs('div', {
|
||
className: 'flex gap-1',
|
||
children: [
|
||
jsx(Button, { size: 'xs', variant: 'secondary', onClick: () => void submitKeys(), children: 'Save & test' }),
|
||
jsx(Button, { size: 'xs', variant: 'ghost', onClick: () => setPhase('idle'), children: 'Cancel' })
|
||
]
|
||
})
|
||
]
|
||
})
|
||
}
|
||
if (phase === 'oauth') {
|
||
return jsx('span', { className: 'ml-1.5 text-[0.65rem] text-(--ui-text-quaternary)', children: message || 'Authorizing\u2026' })
|
||
}
|
||
if (phase === 'busy') {
|
||
return jsx('span', { className: 'ml-1.5 text-[0.65rem] text-(--ui-text-quaternary)', children: 'Working\u2026' })
|
||
}
|
||
if (phase === 'error') {
|
||
return jsxs('span', {
|
||
className: 'ml-1.5 text-[0.65rem] text-(--ui-danger,#ef4444)',
|
||
children: [(message || 'Setup failed') + ' ', jsx('button', { className: 'underline', onClick: () => setPhase('idle'), children: 'retry' })]
|
||
})
|
||
}
|
||
// idle
|
||
return jsx('button', {
|
||
className: 'ml-1.5 text-[0.65rem] text-(--ui-accent,#4f9cf9) underline',
|
||
onClick: () => void (isOAuth ? beginOAuth() : beginKeys()),
|
||
children: isOAuth ? 'Sign in\u2026' : 'Set up\u2026'
|
||
})
|
||
}
|
||
|
||
function botAppearance(name, meta) {
|
||
// The primary profile is literally named "default"; the SDK's profileColor
|
||
// can hand it a near-black that renders as an ugly black square, and any
|
||
// auto-seeded color in local bot-meta would otherwise stick. Give the
|
||
// primary a nice fixed generic look (a friendly violet squircle). A user's
|
||
// EXPLICIT customization still wins: an uploaded/generated/pet image, or a
|
||
// shape/color they set via the editor (tracked by meta.custom === true).
|
||
const isPrimary = (name || '').trim().toLowerCase() === 'default'
|
||
const userCustomized = Boolean(meta?.custom)
|
||
if (isPrimary && !userCustomized) {
|
||
return { shape: 'squircle', color: '#8b5cf6', image: meta?.image || null }
|
||
}
|
||
return {
|
||
shape: meta?.shape || defaultShapeFor(name),
|
||
color: meta?.color || profileColor(name),
|
||
image: meta?.image || null
|
||
}
|
||
}
|
||
|
||
// ── image avatars: upload from device + generate via image.generate ─────────
|
||
|
||
/** Downscale to a small square so plugin storage stays light. */
|
||
function normalizeAvatarImage(dataUrl, edge = 256) {
|
||
return new Promise(resolve => {
|
||
const img = new Image()
|
||
img.onload = () => {
|
||
try {
|
||
const canvas = document.createElement('canvas')
|
||
canvas.width = edge
|
||
canvas.height = edge
|
||
const ctx2d = canvas.getContext('2d')
|
||
const side = Math.min(img.width, img.height)
|
||
ctx2d.drawImage(img, (img.width - side) / 2, (img.height - side) / 2, side, side, 0, 0, edge, edge)
|
||
resolve(canvas.toDataURL('image/png'))
|
||
} catch {
|
||
resolve(dataUrl)
|
||
}
|
||
}
|
||
img.onerror = () => resolve(dataUrl)
|
||
img.src = dataUrl
|
||
})
|
||
}
|
||
|
||
function pickImageFromDevice() {
|
||
return new Promise(resolve => {
|
||
const input = document.createElement('input')
|
||
input.type = 'file'
|
||
input.accept = 'image/png,image/jpeg,image/webp,image/gif'
|
||
input.onchange = () => {
|
||
const file = input.files?.[0]
|
||
|
||
if (!file) {
|
||
return resolve(null)
|
||
}
|
||
|
||
if (file.size > 15_000_000) {
|
||
host.notify({ kind: 'error', message: 'Image too large (max 15MB).' })
|
||
return resolve(null)
|
||
}
|
||
|
||
const reader = new FileReader()
|
||
reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : null)
|
||
reader.onerror = () => resolve(null)
|
||
reader.readAsDataURL(file)
|
||
}
|
||
input.click()
|
||
})
|
||
}
|
||
|
||
// ── group-chat attachments: pick/paste/drop files the room's members see ────
|
||
|
||
/** Classify a picked file for the group-attachment pipeline. */
|
||
function groupAttachmentKind(file) {
|
||
if (/^image\//.test(file.type || '')) {
|
||
return 'image'
|
||
}
|
||
|
||
if (file.type === 'application/pdf' || /\.pdf$/i.test(file.name || '')) {
|
||
return 'pdf'
|
||
}
|
||
|
||
return 'file'
|
||
}
|
||
|
||
/** File objects → [{ name, data, kind }] (data URLs), oversized files skipped
|
||
* with a toast. Images are downscaled; PDFs and other files ride as raw data
|
||
* URLs for the gateway's pdf.attach / file.attach staging. Shared by the
|
||
* picker button, the composer paste handler, and room drag & drop. */
|
||
async function filesToGroupAttachments(files) {
|
||
const picked = []
|
||
|
||
for (const file of [...(files || [])]) {
|
||
if (!file) {
|
||
continue
|
||
}
|
||
|
||
if (file.size > 15_000_000) {
|
||
host.notify({ kind: 'error', message: `${file.name || 'attachment'}: too large (max 15MB).` })
|
||
continue
|
||
}
|
||
|
||
const data = await new Promise(done => {
|
||
const reader = new FileReader()
|
||
reader.onload = () => done(typeof reader.result === 'string' ? reader.result : null)
|
||
reader.onerror = () => done(null)
|
||
reader.readAsDataURL(file)
|
||
})
|
||
|
||
if (!data) {
|
||
continue
|
||
}
|
||
|
||
const kind = groupAttachmentKind(file)
|
||
picked.push({
|
||
name: file.name || (kind === 'image' ? 'pasted image' : 'attachment'),
|
||
data: kind === 'image' ? await normalizeGroupAttachment(data) : data,
|
||
kind
|
||
})
|
||
}
|
||
|
||
return picked
|
||
}
|
||
|
||
/** Multi-file picker for the group composer — any file type; kind decides
|
||
* the staging RPC. Resolves to [{ name, data, kind }]. */
|
||
function pickGroupAttachments() {
|
||
return new Promise(resolve => {
|
||
const input = document.createElement('input')
|
||
input.type = 'file'
|
||
input.multiple = true
|
||
input.onchange = () => resolve(filesToGroupAttachments(input.files))
|
||
input.click()
|
||
})
|
||
}
|
||
|
||
/** Bound a group attachment's long edge so room logs (persisted with the
|
||
* plugin's other durable state) stay light while screenshots keep enough
|
||
* resolution for vision models to read text. No-op for small images or
|
||
* anything the canvas can't decode. */
|
||
function normalizeGroupAttachment(dataUrl, maxEdge = 1568) {
|
||
return new Promise(resolve => {
|
||
const img = new Image()
|
||
img.onload = () => {
|
||
try {
|
||
const long = Math.max(img.width, img.height)
|
||
|
||
if (!long || long <= maxEdge) {
|
||
return resolve(dataUrl)
|
||
}
|
||
|
||
const scale = maxEdge / long
|
||
const canvas = document.createElement('canvas')
|
||
canvas.width = Math.max(1, Math.round(img.width * scale))
|
||
canvas.height = Math.max(1, Math.round(img.height * scale))
|
||
canvas.getContext('2d').drawImage(img, 0, 0, canvas.width, canvas.height)
|
||
resolve(canvas.toDataURL('image/png'))
|
||
} catch {
|
||
resolve(dataUrl)
|
||
}
|
||
}
|
||
img.onerror = () => resolve(dataUrl)
|
||
img.src = dataUrl
|
||
})
|
||
}
|
||
|
||
/** Cached probe: does the gateway have an image backend? A `false` answer
|
||
* is re-checked on every dialog open — the gateway may have been restarted
|
||
* (picking up image.generate) or a backend enabled since the last probe.
|
||
* Only `true` is sticky. */
|
||
const $imagenAvailable = atom(null)
|
||
let imagenProbeInflight = null
|
||
|
||
function probeImagen() {
|
||
if (imagenProbeInflight) {
|
||
return imagenProbeInflight
|
||
}
|
||
|
||
imagenProbeInflight = host
|
||
.request('image.generate', { probe: true })
|
||
.then(res => $imagenAvailable.set(Boolean(res?.available)))
|
||
.catch(() => $imagenAvailable.set(false))
|
||
.finally(() => {
|
||
imagenProbeInflight = null
|
||
})
|
||
|
||
return imagenProbeInflight
|
||
}
|
||
|
||
async function generateAvatarImage(bot, title, description) {
|
||
const who = [title || bot, description].filter(Boolean).join(' — ')
|
||
const res = await host.request('image.generate', {
|
||
prompt:
|
||
`Cute minimal robot avatar for an AI agent named "${who}". ` +
|
||
'Friendly simple mascot face, bold flat vector style, solid color background, centered, no text.',
|
||
aspect_ratio: 'square'
|
||
})
|
||
|
||
if (!res?.success) {
|
||
throw new Error(res?.error || 'generation failed')
|
||
}
|
||
|
||
// image_data (data URL) works over local AND remote gateways; the raw
|
||
// backend URL is the fallback when the gateway couldn't inline it.
|
||
return res.image_data || res.image
|
||
}
|
||
|
||
/** Shape grid + color swatches, shared by Edit Profile and New Agent.
|
||
* Layout uses inline grid styles — arbitrary Tailwind classes like
|
||
* `grid-cols-7` are NOT in the app's precompiled CSS, which collapsed
|
||
* this into a single vertical column. */
|
||
function AvatarPicker({ shape, color, image, onShape, onColor, onImage, generateSeed }) {
|
||
const pickerName = generateSeed?.name || 'agent'
|
||
const imagen = useValue($imagenAvailable)
|
||
const [tab, setTab] = useState('bot')
|
||
const [describe, setDescribe] = useState('')
|
||
const [genBusy, setGenBusy] = useState(false)
|
||
|
||
if (imagen === null) {
|
||
void probeImagen()
|
||
}
|
||
|
||
// Re-check a stale "unavailable" whenever the user lands on the Generate
|
||
// tab — the gateway may have restarted with image.generate since.
|
||
const goTab = id => {
|
||
setTab(id)
|
||
|
||
if (id === 'generate' && $imagenAvailable.get() === false) {
|
||
$imagenAvailable.set(null)
|
||
void probeImagen()
|
||
}
|
||
}
|
||
|
||
const upload = async () => {
|
||
const raw = await pickImageFromDevice()
|
||
|
||
if (raw) {
|
||
onImage(await normalizeAvatarImage(raw))
|
||
}
|
||
}
|
||
|
||
const generate = async () => {
|
||
if (genBusy) {
|
||
return
|
||
}
|
||
|
||
setGenBusy(true)
|
||
|
||
try {
|
||
const custom = describe.trim()
|
||
const img = custom
|
||
? await (async () => {
|
||
const res = await host.request('image.generate', {
|
||
prompt: `${custom}. Avatar for an AI agent: centered, bold flat vector style, solid color background, no text.`,
|
||
aspect_ratio: 'square'
|
||
})
|
||
|
||
if (!res?.success) {
|
||
throw new Error(res?.error || 'generation failed')
|
||
}
|
||
|
||
return res.image_data || res.image
|
||
})()
|
||
: await generateAvatarImage(generateSeed?.name || 'agent', generateSeed?.title, generateSeed?.description)
|
||
|
||
if (img) {
|
||
onImage(await normalizeAvatarImage(img))
|
||
}
|
||
} catch (err) {
|
||
host.notifyError(err, 'Avatar generation failed')
|
||
} finally {
|
||
setGenBusy(false)
|
||
}
|
||
}
|
||
|
||
const tabButton = (id, label) =>
|
||
jsx(
|
||
'button',
|
||
{
|
||
type: 'button',
|
||
className: cn(
|
||
'rounded-full px-3 py-1 text-xs font-medium transition-colors',
|
||
tab === id
|
||
? 'bg-(--chrome-action-hover) text-foreground'
|
||
: 'text-(--ui-text-tertiary) hover:text-(--ui-text-secondary)'
|
||
),
|
||
onClick: () => goTab(id),
|
||
children: label
|
||
},
|
||
id
|
||
)
|
||
|
||
return jsxs('div', {
|
||
className: 'grid justify-items-center gap-3',
|
||
children: [
|
||
// Tab pills: Bot | Generate | Upload | Pet
|
||
jsxs('div', {
|
||
className: 'flex items-center gap-1',
|
||
children: [tabButton('bot', 'Bot'), tabButton('generate', 'Generate'), tabButton('upload', 'Upload'), tabButton('pet', 'Pet')]
|
||
}),
|
||
|
||
image && tab !== 'generate'
|
||
? jsx(Button, {
|
||
type: 'button',
|
||
variant: 'ghost',
|
||
size: 'sm',
|
||
onClick: () => onImage(null),
|
||
children: 'Remove image — use shape'
|
||
})
|
||
: null,
|
||
|
||
tab === 'bot'
|
||
? isBlobShape(shape) && blobatarSvg
|
||
? (() => {
|
||
const { seedPart, kind } = parseBlobShape(shape, pickerName)
|
||
const locked = Boolean(seedPart)
|
||
return jsxs('div', {
|
||
className: 'grid justify-items-center gap-3',
|
||
children: [
|
||
// Silhouette pins: Auto (name decides) + the six blob kinds.
|
||
jsx('div', {
|
||
style: {
|
||
display: 'grid',
|
||
gridTemplateColumns: 'repeat(4, minmax(0, 1fr))',
|
||
gap: '6px',
|
||
justifyItems: 'center'
|
||
},
|
||
children: ['', ...BLOB_KINDS].map(k =>
|
||
jsx(
|
||
'button',
|
||
{
|
||
type: 'button',
|
||
title: k || 'Auto — the name decides',
|
||
className: cn(
|
||
'flex items-center justify-center rounded-md transition-colors hover:bg-(--chrome-action-hover)',
|
||
k === kind && !image && 'ring-1 ring-(--ui-accent)'
|
||
),
|
||
style: { width: 44, height: 44 },
|
||
onClick: () => {
|
||
onImage(null)
|
||
onShape(blobShapeString(seedPart, k))
|
||
},
|
||
children: k
|
||
? jsx(BotFace, { shape: blobShapeString(seedPart, k), color, size: 32, name: pickerName })
|
||
: jsx('span', { className: 'text-[0.6rem] text-(--ui-text-tertiary)', children: 'Auto' })
|
||
},
|
||
k || 'auto'
|
||
)
|
||
)
|
||
}),
|
||
jsxs('div', {
|
||
className: 'flex items-center gap-1',
|
||
children: [
|
||
jsxs(Button, {
|
||
type: 'button',
|
||
variant: 'ghost',
|
||
size: 'sm',
|
||
onClick: () => {
|
||
onImage(null)
|
||
onShape(blobShapeString(Math.random().toString(36).slice(2, 10), kind))
|
||
},
|
||
children: [jsx(Codicon, { name: 'refresh', className: 'mr-1 text-[0.8rem]' }), 'Randomize']
|
||
}),
|
||
jsxs(Button, {
|
||
type: 'button',
|
||
variant: 'ghost',
|
||
size: 'sm',
|
||
title: locked
|
||
? 'Unlock — the face follows the agent\u2019s name again'
|
||
: 'Keep this exact face even if the name changes',
|
||
onClick: () => onShape(blobShapeString(locked ? '' : pickerName, kind)),
|
||
children: [
|
||
jsx(Codicon, { name: locked ? 'unlock' : 'lock', className: 'mr-1 text-[0.8rem]' }),
|
||
locked ? 'Unlock' : 'Lock face'
|
||
]
|
||
})
|
||
]
|
||
}),
|
||
jsx('div', {
|
||
className: 'text-center text-[0.65rem] text-(--ui-text-quaternary)',
|
||
children: locked ? 'Face locked — renaming won\u2019t change it.' : 'Face follows the name.'
|
||
}),
|
||
jsx(Button, {
|
||
type: 'button',
|
||
variant: 'ghost',
|
||
size: 'sm',
|
||
className: 'text-(--ui-text-tertiary)',
|
||
onClick: () => onShape(defaultShapeFor(pickerName)),
|
||
children: 'Classic shapes'
|
||
})
|
||
]
|
||
})
|
||
})()
|
||
: jsxs('div', {
|
||
className: 'grid justify-items-center gap-3',
|
||
children: [
|
||
jsx('div', {
|
||
style: {
|
||
display: 'grid',
|
||
gridTemplateColumns: 'repeat(4, minmax(0, 1fr))',
|
||
gap: '6px',
|
||
justifyItems: 'center'
|
||
},
|
||
children: (blobatarSvg ? ['blobatar', ...AVATAR_PICKER_SHAPES] : AVATAR_PICKER_SHAPES).map(s =>
|
||
jsx(
|
||
'button',
|
||
{
|
||
type: 'button',
|
||
title: s === 'blobatar' ? 'Blob face — drawn from the agent\u2019s name' : undefined,
|
||
className: cn(
|
||
'flex items-center justify-center rounded-md transition-colors hover:bg-(--chrome-action-hover)',
|
||
s === shape && !image && 'ring-1 ring-(--ui-accent)'
|
||
),
|
||
style: { width: 44, height: 44 },
|
||
onClick: () => {
|
||
onImage(null)
|
||
onShape(s)
|
||
},
|
||
children: jsx(BotFace, { shape: s, color, size: 32, name: pickerName })
|
||
},
|
||
s
|
||
)
|
||
)
|
||
}),
|
||
jsx('div', {
|
||
style: {
|
||
display: 'grid',
|
||
gridTemplateColumns: 'repeat(5, minmax(0, 1fr))',
|
||
gap: '8px',
|
||
justifyItems: 'center'
|
||
},
|
||
children: AVATAR_COLORS.map(c =>
|
||
jsx(
|
||
'button',
|
||
{
|
||
type: 'button',
|
||
className: cn(
|
||
'rounded-full transition-transform hover:scale-110',
|
||
c === color && 'ring-2 ring-(--ui-accent) ring-offset-1 ring-offset-(--ui-bg, transparent)'
|
||
),
|
||
style: { width: 22, height: 22, backgroundColor: c },
|
||
onClick: () => onColor(c)
|
||
},
|
||
c
|
||
)
|
||
)
|
||
})
|
||
]
|
||
})
|
||
: null,
|
||
|
||
tab === 'generate'
|
||
? imagen
|
||
? jsxs('div', {
|
||
className: 'grid w-full gap-2',
|
||
children: [
|
||
jsx(Textarea, {
|
||
className: 'min-h-16 text-xs',
|
||
placeholder: 'Describe your avatar…',
|
||
value: describe,
|
||
onChange: event => setDescribe(event.target.value)
|
||
}),
|
||
jsxs(Button, {
|
||
type: 'button',
|
||
variant: 'secondary',
|
||
className: 'w-full justify-center',
|
||
disabled: genBusy,
|
||
onClick: generate,
|
||
children: [
|
||
genBusy
|
||
? jsx(GlyphSpinner, { spinner: 'breathe', className: 'mr-1 text-[0.8rem]' })
|
||
: jsx(Codicon, { name: 'sparkle', className: 'mr-1 text-[0.8rem]' }),
|
||
genBusy ? 'Generating…' : 'Generate'
|
||
]
|
||
}),
|
||
describe.trim()
|
||
? null
|
||
: jsx('div', {
|
||
className: 'text-center text-[0.65rem] text-(--ui-text-quaternary)',
|
||
children: 'Leave blank to generate from the agent\u2019s name and description.'
|
||
})
|
||
]
|
||
})
|
||
: jsx('div', {
|
||
className: 'px-2 py-3 text-center text-xs leading-5 text-(--ui-text-tertiary)',
|
||
children:
|
||
imagen === false
|
||
? 'No image model available. If you just enabled one (or updated Hermes), restart the gateway: Ctrl+K → "Restart gateway".'
|
||
: 'Checking image backend…'
|
||
})
|
||
: null,
|
||
|
||
tab === 'upload'
|
||
? jsxs(Button, {
|
||
type: 'button',
|
||
variant: 'secondary',
|
||
className: 'w-full justify-center',
|
||
onClick: upload,
|
||
children: [jsx(Codicon, { name: 'device-camera', className: 'mr-1 text-[0.8rem]' }), 'Choose an image…']
|
||
})
|
||
: null,
|
||
|
||
tab === 'pet' ? jsx(PetTab, { image, onImage }) : null
|
||
]
|
||
})
|
||
}
|
||
|
||
// ── pet tab: attach a petdex companion that lives beside the avatar ─────────
|
||
|
||
// A petdex "spritesheet" is the FULL animation sheet (1536×1872 webp, ~2MB;
|
||
// 8×9 grid of 192×208 frames). Using it as an <img> both downloads megabytes
|
||
// per tile and shows the whole sheet squashed. Extract frame 0 once per slug
|
||
// via canvas, downscale to 96px, and cache the data URL. Concurrency-capped
|
||
// so opening the tab doesn't fire dozens of 2MB fetches at once.
|
||
const PET_FRAME_W = 192
|
||
const PET_FRAME_H = 208
|
||
const petFrameCache = new Map()
|
||
let petFetchActive = 0
|
||
const petFetchQueue = []
|
||
|
||
function pumpPetQueue() {
|
||
while (petFetchActive < 4 && petFetchQueue.length) {
|
||
const job = petFetchQueue.shift()
|
||
petFetchActive++
|
||
job().finally(() => {
|
||
petFetchActive--
|
||
pumpPetQueue()
|
||
})
|
||
}
|
||
}
|
||
|
||
function petFrameIcon(spriteUrl) {
|
||
if (!spriteUrl) {
|
||
return Promise.resolve(null)
|
||
}
|
||
|
||
if (!petFrameCache.has(spriteUrl)) {
|
||
petFrameCache.set(
|
||
spriteUrl,
|
||
new Promise(resolve => {
|
||
petFetchQueue.push(async () => {
|
||
try {
|
||
const resp = await fetch(spriteUrl, { signal: AbortSignal.timeout(15000) })
|
||
const blob = await resp.blob()
|
||
// Crop frame 0 during decode — never materialize the full sheet.
|
||
const bitmap = await createImageBitmap(blob, 0, 0, PET_FRAME_W, PET_FRAME_H)
|
||
const canvas = document.createElement('canvas')
|
||
canvas.width = 96
|
||
canvas.height = 104
|
||
canvas.getContext('2d').drawImage(bitmap, 0, 0, 96, 104)
|
||
bitmap.close()
|
||
resolve(canvas.toDataURL('image/png'))
|
||
} catch {
|
||
petFrameCache.delete(spriteUrl)
|
||
resolve(null)
|
||
}
|
||
})
|
||
pumpPetQueue()
|
||
})
|
||
)
|
||
}
|
||
|
||
return petFrameCache.get(spriteUrl)
|
||
}
|
||
|
||
/** One pet tile image: frame 0 only, resolved lazily through the cache. */
|
||
function PetThumb({ spriteUrl, size = 40 }) {
|
||
const [icon, setIcon] = useState(null)
|
||
|
||
useEffect(() => {
|
||
let alive = true
|
||
petFrameIcon(spriteUrl).then(url => {
|
||
if (alive) {
|
||
setIcon(url)
|
||
}
|
||
})
|
||
return () => {
|
||
alive = false
|
||
}
|
||
}, [spriteUrl])
|
||
|
||
if (!icon) {
|
||
return jsx('div', {
|
||
style: { width: size, height: size, borderRadius: 6, background: 'var(--chrome-action-hover, rgba(255,255,255,0.06))' }
|
||
})
|
||
}
|
||
|
||
return jsx('img', {
|
||
src: icon,
|
||
alt: '',
|
||
style: { width: size, height: size, objectFit: 'contain', imageRendering: 'pixelated', borderRadius: 6 }
|
||
})
|
||
}
|
||
|
||
function PetTab({ image, onImage }) {
|
||
// Selection is dialog-local: committed by the dialog's Save like any
|
||
// uploaded/generated image (a direct meta write here gets clobbered by
|
||
// Save's own image state).
|
||
const [selectedSlug, setSelectedSlug] = useState(null)
|
||
const { data, isLoading } = useQuery({
|
||
queryKey: [ID, 'pet-gallery'],
|
||
queryFn: () => host.request('pet.gallery', {}),
|
||
staleTime: 300000
|
||
})
|
||
const [query, setQuery] = useState('')
|
||
// Windowed rendering: the gallery is 4500+ pets — mounting an <img> per pet
|
||
// froze the dialog. Render `limit` at a time and grow on scroll-to-bottom.
|
||
const [limit, setLimit] = useState(24)
|
||
const pets = data?.pets ?? []
|
||
|
||
if (isLoading) {
|
||
return jsx('div', {
|
||
className: 'flex justify-center py-4',
|
||
children: jsx(GlyphSpinner, { spinner: 'breathe', className: 'text-(--ui-text-tertiary)' })
|
||
})
|
||
}
|
||
|
||
if (!pets.length) {
|
||
return jsx('div', {
|
||
className: 'px-2 py-3 text-center text-xs text-(--ui-text-tertiary)',
|
||
children: 'No pets in the petdex gallery. Run `hermes pets` to explore.'
|
||
})
|
||
}
|
||
|
||
const q = query.trim().toLowerCase()
|
||
const filtered = q
|
||
? pets.filter(pet => (pet.displayName || '').toLowerCase().includes(q) || (pet.slug || '').includes(q))
|
||
: pets
|
||
// Installed and curated pets surface first — they're the likeliest picks.
|
||
const ranked = filtered.slice().sort((a, b) => {
|
||
const rank = pet => (pet.installed ? 0 : pet.curated ? 1 : 2)
|
||
return rank(a) - rank(b)
|
||
})
|
||
const visible = ranked.slice(0, limit)
|
||
|
||
const onScroll = event => {
|
||
const el = event.currentTarget
|
||
|
||
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 120 && limit < ranked.length) {
|
||
setLimit(prev => Math.min(prev + 24, ranked.length))
|
||
}
|
||
}
|
||
|
||
return jsxs('div', {
|
||
className: 'grid w-full gap-2',
|
||
children: [
|
||
jsx('div', {
|
||
className: 'text-center text-[0.65rem] text-(--ui-text-quaternary)',
|
||
children: 'Pick a pet as this agent’s profile picture.'
|
||
}),
|
||
jsx(Input, {
|
||
className: 'h-7 text-xs',
|
||
placeholder: `Search ${pets.length} pets…`,
|
||
value: query,
|
||
onChange: event => {
|
||
setQuery(event.target.value)
|
||
setLimit(24)
|
||
}
|
||
}),
|
||
image && selectedSlug
|
||
? jsx(Button, {
|
||
type: 'button',
|
||
variant: 'ghost',
|
||
size: 'sm',
|
||
className: 'justify-center',
|
||
onClick: () => {
|
||
setSelectedSlug(null)
|
||
onImage(null)
|
||
},
|
||
children: 'Remove — back to shape avatar'
|
||
})
|
||
: null,
|
||
filtered.length === 0
|
||
? jsx('div', {
|
||
className: 'py-3 text-center text-xs text-(--ui-text-quaternary)',
|
||
children: 'No pets match.'
|
||
})
|
||
: jsxs('div', {
|
||
onScroll,
|
||
style: { maxHeight: 220, overflowY: 'auto' },
|
||
children: [
|
||
jsx('div', {
|
||
style: {
|
||
display: 'grid',
|
||
gridTemplateColumns: 'repeat(3, minmax(0, 1fr))',
|
||
gap: '6px'
|
||
},
|
||
children: visible.map(pet =>
|
||
jsxs(
|
||
'button',
|
||
{
|
||
type: 'button',
|
||
className: cn(
|
||
'grid justify-items-center gap-1 rounded-md p-1.5 transition-colors hover:bg-(--chrome-action-hover)',
|
||
selectedSlug === pet.slug && 'ring-1 ring-(--ui-accent)'
|
||
),
|
||
onClick: () => {
|
||
// The pet IS the profile picture: extract frame 0
|
||
// and hand it to the dialog as the avatar image.
|
||
// Persisted when the user hits Save.
|
||
setSelectedSlug(pet.slug)
|
||
void petFrameIcon(pet.spritesheetUrl).then(icon => {
|
||
if (icon) {
|
||
onImage(icon)
|
||
} else {
|
||
setSelectedSlug(null)
|
||
host.notify({ kind: 'error', message: 'Could not load that pet — try another.' })
|
||
}
|
||
})
|
||
},
|
||
children: [
|
||
jsx(PetThumb, { spriteUrl: pet.spritesheetUrl, size: 40 }),
|
||
jsx('span', {
|
||
className: 'w-full truncate text-center text-[0.6rem] text-(--ui-text-tertiary)',
|
||
children: pet.displayName
|
||
})
|
||
]
|
||
},
|
||
pet.slug
|
||
)
|
||
)
|
||
}),
|
||
limit < ranked.length
|
||
? jsx('div', {
|
||
className: 'py-2 text-center text-[0.65rem] text-(--ui-text-quaternary)',
|
||
children: `Scroll for more (${limit} of ${ranked.length})`
|
||
})
|
||
: null
|
||
]
|
||
})
|
||
]
|
||
})
|
||
}
|
||
|
||
// ── data ─────────────────────────────────────────────────────────────────────
|
||
|
||
/** True once profiles.list reports the backend injects the bot-to-bot
|
||
* protocol into the system prompt itself (hermes-agent bot_mode_probe).
|
||
* Gates every SOUL.md protocol append below. */
|
||
let serverInjectsProtocol = false
|
||
|
||
/** Pins to resolve precisely on the next roster poll: {profile: chatId}.
|
||
* The backend answers "what about THIS conversation" per entry
|
||
* (preferred_session), so a row's preview can describe the same session its
|
||
* click opens (hermes-agent#88200). Unknown params are ignored by older
|
||
* gateways, which simply omit the field. */
|
||
function preferredSessionIds(allMeta) {
|
||
const pins = {}
|
||
for (const [name, meta] of Object.entries(allMeta || {})) {
|
||
if (meta?.chat) {
|
||
pins[name] = meta.chat
|
||
}
|
||
}
|
||
return pins
|
||
}
|
||
|
||
function useRoster() {
|
||
const activeConnectionId = useValue(host.state.connectionId)
|
||
|
||
return useQuery({
|
||
queryKey: [...ROSTER_KEY, activeConnectionId],
|
||
queryFn: async () => {
|
||
// Rich rows (last_session, ui_meta, has_avatar) come from the ACTIVE
|
||
// gateway's profiles.list — unchanged single-source behavior.
|
||
const pins = preferredSessionIds($botMeta.get())
|
||
const local = await host.request(
|
||
'profiles.list',
|
||
Object.keys(pins).length ? { preferred_session_ids: pins } : {}
|
||
)
|
||
// Newer backends inject the teammate-messaging protocol into every
|
||
// session's system prompt (agent.bot_mode_protocol) — SOUL.md must not
|
||
// carry a second copy. Older gateways lack the flag: keep appending.
|
||
serverInjectsProtocol = Boolean(local?.bot_mode_protocol)
|
||
|
||
// Multi-source desktops (hermes-agent #86875) also expose the union
|
||
// agent roster across every registered connection. Merge agents from
|
||
// OTHER sources in as additional rows. Feature-detected + best-effort:
|
||
// an older Desktop build (no host.agents) or a roster hiccup leaves
|
||
// the local list exactly as it was.
|
||
if (typeof host.agents === 'function') {
|
||
try {
|
||
const union = await host.agents()
|
||
return mergeMultiSourceRoster(local, union, activeConnectionId, $lastRoster.get())
|
||
} catch {
|
||
/* older build or roster failure — single-source list stands */
|
||
}
|
||
}
|
||
|
||
return local
|
||
},
|
||
refetchInterval: 5000,
|
||
staleTime: 5000,
|
||
// Remote (SSH) gateways connect slowly and drop on sleep/wake; keep
|
||
// retrying instead of latching a terminal error card.
|
||
retry: true,
|
||
retryDelay: attempt => Math.min(15000, 1000 * 2 ** attempt)
|
||
})
|
||
}
|
||
|
||
/** Merge the union agent roster (host.agents) over the active gateway's
|
||
* profiles.list. Active-source rows — matched by the LIVE connection id,
|
||
* falling back to the roster's primaryConnectionId, then the legacy
|
||
* kind==='local' rule on older desktops — are the agents profiles.list
|
||
* already returned: they only ANNOTATE the rich rows (handle, connection
|
||
* fields); rich fields stay authoritative and they are NOT duplicated.
|
||
* Rows from other sources become new roster entries tagged with their
|
||
* source label so BotRow can badge them and route open/warm through
|
||
* ensureAgent/warmAgent. Pure — exercised directly by the tests. */
|
||
function mergeMultiSourceRoster(local, union, activeConnectionId, previous = []) {
|
||
const localProfiles = Array.isArray(local?.profiles) ? local.profiles : []
|
||
const agents = Array.isArray(union?.agents) ? union.agents : []
|
||
// A live id of null/'' means the window is on the unscoped local backend
|
||
// (legacy hosts reported null for mode:'local'; the SDK now reports
|
||
// 'local'). Do NOT fall back to registry primary when the third argument
|
||
// was passed — primary can still say "spark" after the user clicked a
|
||
// local bot, which skipped every Spark row as "active" and invented a
|
||
// This-device shadow of default.
|
||
const liveProvided = arguments.length >= 3
|
||
const liveId = String(activeConnectionId || '').trim()
|
||
let activeId = liveId || (liveProvided ? '' : String(union?.primaryConnectionId || '').trim())
|
||
|
||
// Migrated remote-primary windows can still expose a legacy remote
|
||
// descriptor without connectionId. That produces a null live id even
|
||
// though profiles.list is answering from the registry primary. Infer the
|
||
// primary only when its inventory matches the rich rows and the local
|
||
// inventory does not. A genuinely local window has a matching local row,
|
||
// so it keeps the null-is-local behavior used after clicking This device.
|
||
if (!activeId && liveProvided) {
|
||
const primaryId = String(union?.primaryConnectionId || '').trim()
|
||
const richNames = new Set(localProfiles.map(profile => String(profile?.name || '').trim()).filter(Boolean))
|
||
const localMatches = agents.some(
|
||
agent => agent?.connectionKind === 'local' && richNames.has(String(agent?.profile || '').trim())
|
||
)
|
||
const primaryMatches = agents.some(
|
||
agent => String(agent?.connectionId || '').trim() === primaryId && richNames.has(String(agent?.profile || '').trim())
|
||
)
|
||
|
||
if (!localMatches && primaryId && primaryMatches) {
|
||
activeId = primaryId
|
||
}
|
||
}
|
||
const activeByName = new Map()
|
||
|
||
// Treat the rich list as one row per active-source profile. Clone every
|
||
// row: some gateway clients reuse response objects, and annotating those in
|
||
// place made each five-second refresh feed the previous union back into the
|
||
// next merge, growing duplicate source rows indefinitely.
|
||
for (const profile of localProfiles) {
|
||
const name = String(profile?.name || '').trim()
|
||
|
||
if (!name || profile?.remoteSource) {
|
||
continue
|
||
}
|
||
|
||
if (profile?.sourceScoped && activeId && profile.connectionId !== activeId) {
|
||
continue
|
||
}
|
||
|
||
if (!activeByName.has(name)) {
|
||
activeByName.set(name, { ...profile, name })
|
||
}
|
||
}
|
||
|
||
const profiles = [...activeByName.values()]
|
||
|
||
// host.agents is an Electron/main-process capability. Defend the plugin
|
||
// boundary too: older shells or reconnect races can still hand us repeated
|
||
// identities even after the core roster deduplicates them.
|
||
const seenSources = new Set()
|
||
|
||
for (const agent of agents) {
|
||
const profile = String(agent?.profile || '').trim()
|
||
const connectionId = String(agent?.connectionId || '').trim()
|
||
const sourceKey = `${connectionId}::${profile || 'default'}`
|
||
|
||
if (!profile || seenSources.has(sourceKey)) {
|
||
continue
|
||
}
|
||
|
||
seenSources.add(sourceKey)
|
||
|
||
// The union enumerates EVERY registered connection, including the active
|
||
// gateway that already answered profiles.list. Without this the active
|
||
// gateway's own agents (connectionKind 'remote' on a remote-primary
|
||
// desktop) would be appended as phantom duplicates — every bot listed
|
||
// twice. Older Electron builds predate the connection ids; fall back to
|
||
// the legacy local-source rule so single-source behavior stays intact.
|
||
const isActiveSource = activeId ? connectionId === activeId : agent.connectionKind === 'local'
|
||
const row = isActiveSource ? activeByName.get(profile) : null
|
||
|
||
if (row) {
|
||
// Annotate in place: the @name-device handle only differs from the
|
||
// bare name when the profile exists on several sources.
|
||
row.handle = agent.handle
|
||
row.connectionId = agent.connectionId
|
||
row.connectionKind = agent.connectionKind
|
||
row.connectionLabel = agent.connectionLabel
|
||
row.sourceScoped = true
|
||
continue
|
||
}
|
||
|
||
if (isActiveSource) {
|
||
// Union saw an active-source profile profiles.list didn't return (older
|
||
// backend mid-refresh) — skip rather than invent a thin row.
|
||
continue
|
||
}
|
||
|
||
profiles.push({
|
||
name: profile,
|
||
handle: agent.handle,
|
||
connectionId,
|
||
connectionKind: agent.connectionKind,
|
||
connectionLabel: agent.connectionLabel,
|
||
remoteSource: true,
|
||
sourceScoped: true
|
||
})
|
||
}
|
||
|
||
// SSH sources drop to connect-on-demand the moment their tunnel is not
|
||
// the live gateway. Keep previously painted remote rows so clicking the
|
||
// local agent does not empty Bot Mode.
|
||
if (Array.isArray(previous) && previous.length > 0) {
|
||
const present = new Set(profiles.map(row => `${row.connectionId || ''}::${row.name}`))
|
||
const unionSourceIds = new Set(agents.map(agent => String(agent?.connectionId || '').trim()).filter(Boolean))
|
||
const omitted = new Set(
|
||
(Array.isArray(union?.sources) ? union.sources : [])
|
||
.filter(source => source?.error === 'connect-on-demand' || source?.reachable === false)
|
||
.map(source => String(source.connectionId || '').trim())
|
||
.filter(Boolean)
|
||
)
|
||
|
||
const registered = new Set(
|
||
(Array.isArray(union?.sources) ? union.sources : [])
|
||
.map(source => String(source?.connectionId || '').trim())
|
||
.filter(Boolean)
|
||
)
|
||
|
||
for (const row of previous) {
|
||
const connectionId = String(row?.connectionId || '').trim()
|
||
const name = String(row?.name || '').trim()
|
||
const key = `${connectionId}::${name || 'default'}`
|
||
|
||
if (!row?.remoteSource || !connectionId || !name || present.has(key)) {
|
||
continue
|
||
}
|
||
|
||
if (registered.size > 0 && !registered.has(connectionId)) {
|
||
continue
|
||
}
|
||
|
||
if (omitted.has(connectionId) || !unionSourceIds.has(connectionId)) {
|
||
profiles.push({ ...row, remoteSource: true, sourceScoped: true })
|
||
present.add(key)
|
||
}
|
||
}
|
||
}
|
||
|
||
return { ...local, profiles }
|
||
}
|
||
|
||
/** The @handle users tag a bot with. Multi-source rosters precompute the
|
||
* handle (bare name, or name-device when the profile exists on several
|
||
* registered sources) — prefer it when present. The primary profile's
|
||
* callable alias is 'hermes' — the mention middleware resolves it back to
|
||
* 'default' — so the word 'default' never surfaces in the UI. */
|
||
function botHandle(name, bot) {
|
||
if (bot?.handle && bot.handle !== name) {
|
||
return bot.handle
|
||
}
|
||
|
||
return (name || '').trim().toLowerCase() === 'default' ? 'hermes' : name
|
||
}
|
||
|
||
function isActiveRosterBot(bot, active) {
|
||
const activeName = String(active?.name || 'default').trim() || 'default'
|
||
const activeId = String(active?.connectionId || '').trim()
|
||
const botId = String(bot?.connectionId || '').trim()
|
||
const botName = String(bot?.name || '').trim() || 'default'
|
||
|
||
if (bot?.remoteSource) {
|
||
return Boolean(activeId) && activeId === botId && botName === activeName
|
||
}
|
||
|
||
if (activeId && activeId !== 'local' && botId && activeId !== botId) {
|
||
return false
|
||
}
|
||
|
||
return botName === activeName
|
||
}
|
||
|
||
/** Resolve @handles in prose against the Bot Mode roster (local + Connections).
|
||
* Skips the bot already speaking in this chat. Unique bare names match;
|
||
* duplicate names require the @name-device handle. */
|
||
function resolveRosterMentions(text, roster, active = {}) {
|
||
const members = Array.isArray(roster) ? roster : []
|
||
const prose = String(text || '').replace(/```[\s\S]*?```/g, ' ').replace(/`[^`\n]*`/g, ' ')
|
||
const byForm = new Map()
|
||
|
||
for (const bot of members) {
|
||
if (!bot?.name || isActiveRosterBot(bot, active)) {
|
||
continue
|
||
}
|
||
|
||
const handle = String(botHandle(bot.name, bot) || '').toLowerCase()
|
||
const name = String(bot.name || '').toLowerCase()
|
||
const forms = new Set([handle, name])
|
||
|
||
if (bot.handle) {
|
||
forms.add(String(bot.handle).toLowerCase())
|
||
}
|
||
|
||
for (const form of forms) {
|
||
if (!form) {
|
||
continue
|
||
}
|
||
|
||
const existing = byForm.get(form)
|
||
|
||
if (existing && existing !== bot) {
|
||
byForm.set(form, null)
|
||
continue
|
||
}
|
||
|
||
if (!existing) {
|
||
byForm.set(form, bot)
|
||
}
|
||
}
|
||
}
|
||
|
||
const mentioned = []
|
||
const seen = new Set()
|
||
|
||
for (const match of prose.matchAll(/(^|\s)@([a-z0-9][a-z0-9_-]*)/gi)) {
|
||
let token = match[2].toLowerCase()
|
||
|
||
if (token === 'hermes') {
|
||
token = byForm.has('hermes') ? 'hermes' : token
|
||
}
|
||
|
||
const bot = byForm.get(token)
|
||
|
||
if (!bot) {
|
||
continue
|
||
}
|
||
|
||
const key = botRosterKey(bot)
|
||
|
||
if (seen.has(key)) {
|
||
continue
|
||
}
|
||
|
||
seen.add(key)
|
||
mentioned.push(bot)
|
||
}
|
||
|
||
return mentioned
|
||
}
|
||
|
||
const REMOTE_DM_TIMEOUT_MS = 180000
|
||
const REMOTE_DM_POLL_MS = 2000
|
||
|
||
/** The remote bot's canonical Bot Chat: pinned stored-id from its profile's
|
||
* ui_meta first, then resume-by-title, then create. Mirrors
|
||
* ensureGroupChatSession so DMs land in the ONE forever-chat instead of
|
||
* minting a fresh "Bot Chat" per mention. */
|
||
async function ensureRemoteCanonicalChat(route, profile) {
|
||
let pinned = null
|
||
|
||
try {
|
||
const listed = await host.requestProfile(route, 'profiles.list', {})
|
||
const owner = listed?.profiles?.find(p => p.name === profile)
|
||
pinned = owner?.ui_meta?.['hermes-bots']?.chat || null
|
||
} catch {
|
||
/* older remote gateway — title lookup below still works */
|
||
}
|
||
|
||
for (const target of [pinned, 'Bot Chat']) {
|
||
if (!target) {
|
||
continue
|
||
}
|
||
|
||
try {
|
||
const res = await host.requestProfile(route, 'session.resume', {
|
||
session_id: target,
|
||
profile,
|
||
omit_messages: true
|
||
})
|
||
|
||
if (res?.session_id) {
|
||
return { runtime: res.session_id, stored: res.session_key || pinned }
|
||
}
|
||
} catch {
|
||
/* fall through */
|
||
}
|
||
}
|
||
|
||
const created = await host.requestProfile(route, 'session.create', {
|
||
profile,
|
||
title: 'Bot Chat',
|
||
// Bot Mode sessions are always hidden from the global sidebar.
|
||
hidden: true
|
||
})
|
||
|
||
return { runtime: created?.session_id || null, stored: created?.stored_session_id || null }
|
||
}
|
||
|
||
/** Bounded reply poll on the recipient's session — same shape as a group
|
||
* member turn: wait for a NEW assistant message after `before`, or time out. */
|
||
async function pollRemoteDmReply(route, profile, sessionRef, before) {
|
||
const deadline = Date.now() + REMOTE_DM_TIMEOUT_MS
|
||
|
||
while (Date.now() < deadline) {
|
||
await new Promise(resolve => setTimeout(resolve, REMOTE_DM_POLL_MS))
|
||
|
||
let state = null
|
||
|
||
try {
|
||
state = await host.requestProfile(route, 'session.resume', { session_id: sessionRef, profile })
|
||
} catch {
|
||
continue
|
||
}
|
||
|
||
const messages = Array.isArray(state?.messages) ? state.messages : []
|
||
const done = !state?.inflight && !state?.running
|
||
|
||
if (messages.length > before && done) {
|
||
for (let i = messages.length - 1; i >= 0; i--) {
|
||
const msg = messages[i]
|
||
|
||
if (msg?.role === 'assistant') {
|
||
const text = typeof msg.content === 'string'
|
||
? msg.content
|
||
: Array.isArray(msg.content)
|
||
? msg.content.map(p => (typeof p === 'string' ? p : p?.text || '')).join('')
|
||
: msg?.text || ''
|
||
|
||
return String(text).trim() || null
|
||
}
|
||
}
|
||
|
||
return null
|
||
}
|
||
}
|
||
|
||
return null
|
||
}
|
||
|
||
/** Deliver a user mention to bots on OTHER connections: into each bot's
|
||
* canonical Bot Chat, with the standard sender-attribution prefix (so the
|
||
* recipient's messaging protocol recognizes an agent-to-agent message), then
|
||
* relay the reply back as a notification. Sequential and fire-and-forget
|
||
* from the composer's perspective. */
|
||
async function deliverRemoteRosterMentions(bots, userText, sender) {
|
||
const text = String(userText || '').trim()
|
||
|
||
if (!text || typeof host.requestProfile !== 'function') {
|
||
return
|
||
}
|
||
|
||
const senderName = String(sender?.name || 'the user').trim()
|
||
const senderHandle = String(sender?.handle || senderName).trim()
|
||
|
||
for (const bot of bots) {
|
||
const connectionId = String(bot?.connectionId || '').trim()
|
||
const profile = String(bot?.name || '').trim() || 'default'
|
||
|
||
if (!connectionId || connectionId === 'local') {
|
||
continue
|
||
}
|
||
|
||
const route = { connectionId, mode: 'remote', profile, targetProfile: profile }
|
||
const label = bot.connectionLabel || connectionId
|
||
|
||
try {
|
||
const { runtime, stored } = await ensureRemoteCanonicalChat(route, profile)
|
||
|
||
if (!runtime) {
|
||
throw new Error('No remote session')
|
||
}
|
||
|
||
// Baseline before our submit, so the poll can spot the NEW reply.
|
||
let before = 0
|
||
|
||
try {
|
||
const pre = await host.requestProfile(route, 'session.resume', { session_id: stored || runtime, profile })
|
||
before = Array.isArray(pre?.messages) ? pre.messages.length : pre?.message_count || 0
|
||
} catch {
|
||
/* lazy session — zero messages */
|
||
}
|
||
|
||
// The delivery prefix is the recipient's cue that an agent (not its
|
||
// human) is talking — same contract as the local CLI handoff.
|
||
await host.requestProfile(route, 'prompt.submit', {
|
||
session_id: runtime,
|
||
text: `Message from \u{1F916} ${senderName} (@${senderHandle}): ${text}`
|
||
})
|
||
host.notify?.({
|
||
kind: 'info',
|
||
title: displayName(bot),
|
||
message: `Messaged @${botHandle(profile, bot)} on ${label} — will relay the reply here.`
|
||
})
|
||
|
||
const reply = await pollRemoteDmReply(route, profile, stored || runtime, before)
|
||
|
||
if (reply) {
|
||
host.notify?.({
|
||
kind: 'info',
|
||
title: `\u{1F916} ${displayName(bot)} (${label})`,
|
||
message: reply.slice(0, 500)
|
||
})
|
||
} else {
|
||
host.notify?.({
|
||
kind: 'info',
|
||
title: displayName(bot),
|
||
message: `No reply from @${botHandle(profile, bot)} yet — check its Bot Chat on ${label}.`
|
||
})
|
||
}
|
||
} catch (error) {
|
||
host.notifyError?.(error, `Could not reach ${label}`)
|
||
}
|
||
}
|
||
}
|
||
|
||
/** Source-qualified identity for a roster row — the React list key AND the
|
||
* cross-surface roster identity. Names alone are NOT unique in a
|
||
* multi-source roster (two connections can both expose 'default');
|
||
* duplicate keys make React reconciliation repeat whole blocks of the list
|
||
* on every poll repaint (the Aug 2026 dupe-bots smear). */
|
||
function botRosterKey(bot) {
|
||
return `${bot?.connectionId || 'legacy'}::${bot?.name || 'default'}`
|
||
}
|
||
|
||
// ── cross-connection routing ─────────────────────────────────────────────────
|
||
// A bot from another registered connection (remoteSource rows) is reached
|
||
// through host.requestProfile with a route descriptor; local bots keep the
|
||
// active-gateway door. Feature-detected: older desktops without
|
||
// requestProfile simply have no remote routes (callers fall back / disable).
|
||
|
||
/** Route descriptor for a bot on another connection, or null for the local /
|
||
* active source (or when the desktop can't route). */
|
||
function botConnectionRoute(bot) {
|
||
const id = String(bot?.connectionId || '').trim()
|
||
|
||
if (!bot?.remoteSource || !id || id === 'local' || typeof host.requestProfile !== 'function') {
|
||
return null
|
||
}
|
||
|
||
const profile = String(bot?.name || '').trim() || 'default'
|
||
|
||
return { connectionId: id, mode: 'remote', profile, targetProfile: profile }
|
||
}
|
||
|
||
/** Gateway RPC on the bot's OWN source: requestProfile for remote rows,
|
||
* the active gateway for local ones. Never activates/foregrounds. */
|
||
async function requestForBot(bot, method, params = {}) {
|
||
const route = botConnectionRoute(bot)
|
||
|
||
if (route) {
|
||
return host.requestProfile(route, method, params)
|
||
}
|
||
|
||
return host.request(method, params)
|
||
}
|
||
|
||
/** Stable per-member identity inside a group room. Local members keep their
|
||
* bare name (compat with rooms persisted before cross-connection groups);
|
||
* remote members get the source-qualified key so `dixie` on the Mini and a
|
||
* local `dixie` never share watermarks or sessions. */
|
||
function groupMemberKey(member) {
|
||
return member?.remoteSource ? botRosterKey(member) : member?.name
|
||
}
|
||
|
||
// Bot metadata is scoped to the active gateway until the server exposes a
|
||
// union of rich profile rows. Never paint that metadata onto a thin row from
|
||
// another source: two `default` agents must not borrow each other's title,
|
||
// pin, avatar, group, unread state, or canonical-chat pointer.
|
||
function botRosterMeta(bot, metaByName) {
|
||
return bot?.remoteSource ? null : metaByName?.[bot?.name]
|
||
}
|
||
|
||
function showsHandle(name, meta, bot) {
|
||
const display = displayName({ name }, meta)
|
||
return Boolean(name && display.toLowerCase() !== botHandle(name, bot).toLowerCase())
|
||
}
|
||
|
||
// ── canonical bot chat ───────────────────────────────────────────────────────
|
||
// Each bot has ONE forever chat, pinned by stored-session id in bot meta
|
||
// (meta.chat — synced server-side via ui_meta, so it follows the profile).
|
||
// Opening a bot ALWAYS lands there: never "most recent session", which
|
||
// drifts whenever the profile is used from the CLI, Sessions mode, or a
|
||
// cronjob. The pin only changes through explicit adoption:
|
||
// - grandfather: first open of a bot that already has history pins its
|
||
// current latest session, so continuity starts from the chat in use
|
||
// - fresh bot: opens a draft; when the first message persists a stored
|
||
// session, we adopt that id (empty sessions are pruned server-side, so
|
||
// pre-creating one at enable time is not possible)
|
||
// - recovery: if the pinned id vanishes from the DB (compaction rewrote
|
||
// the lineage), re-pin the newest session carrying the canonical title.
|
||
|
||
// In-flight creations, keyed by bot name — double-clicking a row must not
|
||
// mint two canonical chats.
|
||
const canonicalCreations = new Map()
|
||
let botOpenGeneration = 0
|
||
|
||
async function openStoredBotChat(name, storedId, summary) {
|
||
if (!storedId || typeof host.openSession !== 'function') {
|
||
throw new Error('This Hermes Desktop version cannot open stored sessions')
|
||
}
|
||
|
||
const hasAuthoritativeCount =
|
||
typeof summary?.message_count === 'number' && Number.isFinite(summary.message_count)
|
||
const expectHistory = hasAuthoritativeCount ? summary.message_count > 0 : true
|
||
|
||
await host.openSession(storedId, {
|
||
profile: name,
|
||
intent: 'main',
|
||
awaitHydration: true,
|
||
expectHistory
|
||
})
|
||
|
||
return storedId
|
||
}
|
||
|
||
/** Create the bot's ONE forever chat: a real session opened with a kickoff
|
||
* message (the gateway prunes zero-message sessions, so the chat is born
|
||
* with the bot introducing itself). Pins the stored id in bot meta and
|
||
* returns it. */
|
||
function createCanonicalChat(name) {
|
||
const inflight = canonicalCreations.get(name)
|
||
|
||
if (inflight) {
|
||
return inflight
|
||
}
|
||
|
||
const run = (async () => {
|
||
const res = await host.request('session.create', {
|
||
profile: name,
|
||
title: 'Bot Chat',
|
||
// Always born hidden from the global sidebar — Bot Mode sessions are
|
||
// plugin-owned. Core applies this via the generic `hidden` flag
|
||
// (deferred as pending_hidden until the row exists); older gateways
|
||
// ignore the unknown param and it stays visible.
|
||
hidden: true
|
||
})
|
||
const sid = res?.stored_session_id
|
||
const runtime = res?.session_id
|
||
|
||
if (sid) {
|
||
saveBotMeta(name, { chat: sid })
|
||
}
|
||
|
||
// Mount the session view FIRST, then send the kickoff — submitting into
|
||
// an unmounted session left the intro reply invisible until reopen.
|
||
let opened = false
|
||
|
||
if (sid && typeof host.openSession === 'function') {
|
||
try {
|
||
await host.openSession(sid, { profile: name, intent: 'main' })
|
||
opened = true
|
||
} catch {
|
||
// The stored row may not exist until the kickoff persists it. Retry
|
||
// after prompt.submit below instead of leaving the chat off-screen.
|
||
}
|
||
}
|
||
|
||
if (runtime) {
|
||
await new Promise(resolve => window.setTimeout(resolve, 400))
|
||
|
||
try {
|
||
await host.request('prompt.submit', { session_id: runtime, text: 'Hey, tell me about yourself!' })
|
||
|
||
if (!opened && sid && typeof host.openSession === 'function') {
|
||
await host.openSession(sid, { profile: name, intent: 'main' })
|
||
}
|
||
} catch {
|
||
// The chat already exists. Keep the pin so the next click
|
||
// opens it instead of making a second Bot Chat.
|
||
}
|
||
}
|
||
|
||
return sid || null
|
||
})().finally(() => canonicalCreations.delete(name))
|
||
|
||
canonicalCreations.set(name, run)
|
||
|
||
return run
|
||
}
|
||
|
||
/** Open the bot's ONE forever chat and return the opened id (or the pin).
|
||
*
|
||
* Identity rules (hermes-agent#88200 — the row must open the session its
|
||
* preview describes):
|
||
* - grandfather: no pin + existing history adopts the previewed session
|
||
* (`history`, the roster's last_session for this bot) instead of minting
|
||
* a new empty chat;
|
||
* - a live pin is verified through the backend's precise preferred_session
|
||
* resolver (hidden rows still resolve; compression lineages resolve to
|
||
* the live tip) — never inferred from a paginated, hidden-excluding
|
||
* session.list window, which misjudged real hidden pins as gone;
|
||
* - transient lookup failures keep the pin: try the stored id as-is, and
|
||
* only a rejected open enters recovery. */
|
||
async function openBotCanonicalChat(name, pinned, history) {
|
||
if (!pinned) {
|
||
// Grandfather: adopt the conversation the row already previews.
|
||
const adoptId = history?.id
|
||
if (adoptId && typeof host.openSession === 'function') {
|
||
await openStoredBotChat(name, adoptId, history)
|
||
saveBotMeta(name, { chat: adoptId })
|
||
return adoptId
|
||
}
|
||
return createCanonicalChat(name)
|
||
}
|
||
|
||
// Precise verification. An older gateway ignores the unknown param and
|
||
// omits the key — that reads as a lookup failure below, NOT as a missing
|
||
// session, so legacy backends keep the try-as-is escape hatch.
|
||
let preferred
|
||
let lookupFailed = false
|
||
try {
|
||
const res = await host.request('profiles.list', {
|
||
include_sessions: true,
|
||
preferred_session_ids: { [name]: pinned }
|
||
})
|
||
const row = (res?.profiles ?? []).find(p => p.name === name)
|
||
preferred = row?.preferred_session
|
||
if (preferred === undefined) {
|
||
lookupFailed = true
|
||
}
|
||
} catch {
|
||
lookupFailed = true
|
||
}
|
||
|
||
if (lookupFailed) {
|
||
// Transient gateway state (or an older backend): the pin is innocent
|
||
// until proven guilty — try it as-is. A rejected open is still ambiguous:
|
||
// it can be the same reconnect/hydration outage that broke this lookup, so
|
||
// preserve the forever-chat pin and surface Retry instead of forking it.
|
||
return openStoredBotChat(name, pinned, history)
|
||
}
|
||
|
||
if (preferred) {
|
||
try {
|
||
await openStoredBotChat(name, preferred.resolved_id || preferred.id, preferred)
|
||
return pinned
|
||
} catch (error) {
|
||
// The precise lookup JUST confirmed this session exists, so a failed
|
||
// open is transient (reconnect, backend restart). Clearing the pin or
|
||
// minting a replacement here would fork the bot's forever-chat on
|
||
// every hiccup — report and keep everything as it is.
|
||
throw error
|
||
}
|
||
}
|
||
|
||
// Definitively gone (db reset, or the lineage was rewritten past
|
||
// recovery): re-anchor on the previewed session when there is one.
|
||
const recoveryId = history?.id
|
||
if (recoveryId && typeof host.openSession === 'function') {
|
||
await openStoredBotChat(name, recoveryId, history)
|
||
saveBotMeta(name, { chat: recoveryId })
|
||
return recoveryId
|
||
}
|
||
saveBotMeta(name, { chat: null })
|
||
return createCanonicalChat(name)
|
||
}
|
||
|
||
async function prepareBotSource(bot, pinnedChat) {
|
||
if (!bot.sourceScoped) {
|
||
return pinnedChat
|
||
}
|
||
|
||
if (typeof host.ensureAgent !== 'function') {
|
||
throw new Error('Update Hermes Desktop to chat with agents on other connections.')
|
||
}
|
||
|
||
await host.ensureAgent(bot.connectionId, bot.name)
|
||
|
||
if (!bot.remoteSource) {
|
||
return pinnedChat
|
||
}
|
||
|
||
const liveId = String(typeof host.activeConnectionId === 'function' ? host.activeConnectionId() || '' : '').trim()
|
||
const targetId = String(bot.connectionId || '').trim()
|
||
|
||
if (targetId && targetId !== 'local' && liveId !== targetId) {
|
||
throw new Error(`Still on ${liveId || 'this device'}, not ${bot.connectionLabel || targetId}`)
|
||
}
|
||
|
||
// Thin rows deliberately omit metadata from the active source. Once their
|
||
// owner is active, recover that source's canonical-chat pointer so
|
||
// same-named agents never reuse or overwrite each other's pin.
|
||
try {
|
||
const refreshed = await host.request('profiles.list', {})
|
||
const owner = refreshed?.profiles?.find(profile => profile.name === bot.name)
|
||
|
||
return owner?.ui_meta?.['hermes-bots']?.chat || null
|
||
} catch {
|
||
// Metadata refresh is best-effort; canonical creation remains the fallback.
|
||
return null
|
||
}
|
||
}
|
||
|
||
function displayName(bot, meta) {
|
||
// Only THIN rows from another source trade the friendly name for their
|
||
// connection label — the active gateway's own default must keep reading
|
||
// "Hermes". Annotated active rows carry sourceScoped too, and keying this
|
||
// off sourceScoped renamed the user's main agent to an IP-derived label
|
||
// (community report, Aug 17 2026).
|
||
if (bot?.remoteSource && (bot.name || '').trim().toLowerCase() === 'default' && bot.connectionLabel) {
|
||
return bot.connectionLabel
|
||
}
|
||
|
||
if (meta?.title?.trim()) {
|
||
return meta.title.trim()
|
||
}
|
||
|
||
// Core-profile display name (profile.yaml, set via `hermes profile rename
|
||
// default <name>` or the dashboard) — the CLI-level equivalent of a Bot
|
||
// Mode title. Rides the profiles.list row; presentation-only.
|
||
if (typeof bot?.display_name === 'string' && bot.display_name.trim()) {
|
||
return bot.display_name.trim()
|
||
}
|
||
|
||
// The primary profile is literally named "default" — as a bot identity
|
||
// that reads like nobody bothered. Present it as Hermes (the agent it is)
|
||
// unless the user gives it a real title.
|
||
if ((bot.name || '').trim().toLowerCase() === 'default' && !bot.title) {
|
||
return 'Hermes'
|
||
}
|
||
|
||
const raw = (bot.title || bot.name || '').replace(/[-_]+/g, ' ').trim()
|
||
return raw.replace(/\b\w/g, ch => ch.toUpperCase())
|
||
}
|
||
|
||
/** Filter by the two stable identities rendered in every roster row: the
|
||
* customizable display name and the profile's @handle. Keep the current
|
||
* activity order — search narrows the roster, it never re-ranks it. */
|
||
function filterBots(roster, metaByName, query) {
|
||
const needle = query.trim().toLowerCase().replace(/^@/, '')
|
||
|
||
if (!needle) {
|
||
return roster
|
||
}
|
||
|
||
return roster.filter(bot => {
|
||
const display = displayName(bot, botRosterMeta(bot, metaByName)).toLowerCase()
|
||
const profile = (bot.name || '').toLowerCase()
|
||
const handle = botHandle(bot.name, bot).toLowerCase()
|
||
// Multi-source rows also match on their device name ("homelab" finds
|
||
// every bot living on the Homelab connection).
|
||
const sourceLabel = (bot.connectionLabel || '').toLowerCase()
|
||
return (
|
||
display.includes(needle) || profile.includes(needle) || handle.includes(needle) || sourceLabel.includes(needle)
|
||
)
|
||
})
|
||
}
|
||
|
||
function slugify(value) {
|
||
return value
|
||
.toLowerCase()
|
||
.replace(/[^a-z0-9_-]+/g, '-')
|
||
.replace(/^-+|-+$/g, '')
|
||
.slice(0, 64)
|
||
}
|
||
|
||
/** Flatten markdown syntax out of a one-line roster preview so rows read
|
||
* like Discord's — no raw **bold**, `code`, > quotes, or [link](url)
|
||
* characters in the preview line. */
|
||
function stripPreviewMarkdown(text) {
|
||
return String(text || '')
|
||
.replace(/```[\s\S]*?```/g, ' ')
|
||
.replace(/`([^`\n]*)`/g, '$1')
|
||
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
|
||
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
|
||
.replace(/(\*\*|__)(.*?)\1/g, '$2')
|
||
.replace(/(^|\s)[*_](\S(?:.*?\S)?)[*_](?=\s|$|[.,;:!?])/g, '$1$2')
|
||
.replace(/~~(.*?)~~/g, '$1')
|
||
.replace(/^\s{0,3}#{1,6}\s+/gm, '')
|
||
.replace(/^\s{0,3}>\s?/gm, '')
|
||
.replace(/\s+/g, ' ')
|
||
.trim()
|
||
}
|
||
|
||
/** Canonical multi-group read with legacy scalar compatibility. Profiles that
|
||
* predate `groups` still fall back to `group`; once the canonical array exists,
|
||
* it is authoritative. Writes keep `group` as a first-membership projection so
|
||
* older desktops can still display one room without corrupting the array. */
|
||
function botGroups(meta) {
|
||
const groups = []
|
||
const seen = new Set()
|
||
const values = Array.isArray(meta?.groups) ? meta.groups : [meta?.group]
|
||
|
||
for (const value of values) {
|
||
if (typeof value !== 'string') {
|
||
continue
|
||
}
|
||
|
||
const group = value.trim()
|
||
|
||
if (group && !seen.has(group)) {
|
||
seen.add(group)
|
||
groups.push(group)
|
||
}
|
||
}
|
||
|
||
return groups
|
||
}
|
||
|
||
function groupMembershipPatch(meta, group, enabled) {
|
||
const name = String(group || '').trim()
|
||
let groups = botGroups(meta)
|
||
|
||
if (enabled) {
|
||
if (name && !groups.includes(name)) {
|
||
groups = [...groups, name]
|
||
}
|
||
} else {
|
||
groups = groups.filter(existing => existing !== name)
|
||
}
|
||
|
||
return { groups, group: groups[0] || null }
|
||
}
|
||
|
||
/** Group chats that should hold a roster row: every group named in bot meta
|
||
* (local members) plus every room record that still has stored members or
|
||
* log — cross-connection rooms whose members can't ride bot-meta. */
|
||
function groupChatNames(metaByName, rooms) {
|
||
const names = new Set(knownGroups(metaByName))
|
||
|
||
for (const [name, room] of Object.entries(rooms || {})) {
|
||
if ((Array.isArray(room?.members) && room.members.length) || (Array.isArray(room?.log) && room.log.length)) {
|
||
names.add(name)
|
||
}
|
||
}
|
||
|
||
return [...names]
|
||
}
|
||
|
||
/** Millisecond timestamp of a room's newest log entry (0 for a silent room) —
|
||
* the group's recency key, competing in the same ordering as bot rows. */
|
||
function groupLastActivity(room) {
|
||
const log = Array.isArray(room?.log) ? room.log : []
|
||
|
||
return log.length ? log[log.length - 1].at || 0 : 0
|
||
}
|
||
|
||
/** Seat a group's member roster: local bots whose meta names the group, plus
|
||
* the room record's stored descriptors (remote members can't ride bot-meta).
|
||
* Prefers the LIVE roster row for a stored descriptor when present. */
|
||
function groupChatMemberBots(group, roster, metaByName) {
|
||
const local = (roster || []).filter(
|
||
bot => !bot.remoteSource && botGroups(botRosterMeta(bot, metaByName)).includes(group)
|
||
)
|
||
const stored = ($groupChats.get()[group] || {}).members || []
|
||
const seated = new Set(local.map(botRosterKey))
|
||
const remote = []
|
||
|
||
for (const descriptor of stored) {
|
||
const key = botRosterKey(descriptor)
|
||
|
||
if (seated.has(key)) {
|
||
continue
|
||
}
|
||
|
||
seated.add(key)
|
||
remote.push((roster || []).find(bot => botRosterKey(bot) === key) || descriptor)
|
||
}
|
||
|
||
return [...local, ...remote]
|
||
}
|
||
|
||
/** Persist source-qualified identities for every selected member. The active
|
||
* source's row may become remote after a connection switch, so retaining it
|
||
* here is what keeps the same room intact across machines. */
|
||
function durableGroupChatMembers(bots) {
|
||
return (bots || []).map(bot => ({
|
||
name: bot.name,
|
||
handle: bot.handle || bot.name,
|
||
connectionId: bot.connectionId,
|
||
connectionKind: bot.connectionKind,
|
||
connectionLabel: bot.connectionLabel,
|
||
remoteSource: true,
|
||
sourceScoped: true
|
||
}))
|
||
}
|
||
|
||
/** Existing group names, alphabetical — feeds the Manage-groups dialog. */
|
||
function knownGroups(metaByName) {
|
||
const names = new Set()
|
||
|
||
for (const meta of Object.values(metaByName || {})) {
|
||
for (const group of botGroups(meta)) {
|
||
names.add(group)
|
||
}
|
||
}
|
||
|
||
return [...names].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))
|
||
}
|
||
|
||
// ── group chats: bounded round-robin coordination over a shared room log ─────
|
||
//
|
||
// Behavioral model (clean-room): a group conversation is ONE ordered room log
|
||
// owned by the plugin. A user send triggers at most GROUP_CHAT_MAX_ROUNDS
|
||
// serial round-robin rounds over the member roster — never parallel, no LLM
|
||
// router. Who speaks each round is a deterministic @mention parse since the
|
||
// last user message (mentioned members only, else everyone); whether a member
|
||
// actually speaks is its own turn's choice — replying with exactly "(pass)"
|
||
// (or nothing, or failing) is silence. Hard caps end every turn; a round in
|
||
// which everyone passed means the conversation settled. Each member runs its
|
||
// turn in its OWN persistent per-group Hermes session and is fed only the
|
||
// room messages that are NEW since it last saw the room.
|
||
|
||
const GROUP_CHAT_MAX_ROUNDS = 3
|
||
const GROUP_CHAT_MAX_MESSAGES = 10
|
||
const GROUP_CHAT_HISTORY_LIMIT = 24
|
||
const GROUP_CHAT_MAX_MEMBERS = 6
|
||
|
||
/** "(pass)" (loosely: pass / (pass) / pass.) or empty = the member stayed silent. */
|
||
function isGroupPassText(text) {
|
||
const trimmed = String(text || '').trim()
|
||
|
||
if (!trimmed) {
|
||
return true
|
||
}
|
||
|
||
return /^\(?\s*pass\s*\)?\.?$/i.test(trimmed)
|
||
}
|
||
|
||
/** Deterministic @mention parse. Handles @name, @"two words" via display
|
||
* titles, and @everyone/@all. Names match case-insensitively against member
|
||
* profile names, display titles, and collapsed no-space forms. */
|
||
function parseGroupChatMentions(text, members) {
|
||
const source = String(text || '')
|
||
const mentioned = new Set()
|
||
let everyone = false
|
||
const handles = new Map()
|
||
|
||
for (const member of members) {
|
||
const title = String(member.title || '').trim()
|
||
// Cross-connection members are also addressable by their @name-device
|
||
// handle (the roster's disambiguated form) — same-named agents on two
|
||
// machines resolve to the right one.
|
||
const handle = String(member.handle || botHandle(member.name, member) || '').trim()
|
||
const forms = new Set([
|
||
member.name.toLowerCase(),
|
||
member.name.toLowerCase().replace(/[\s_-]+/g, ''),
|
||
...(handle ? [handle.toLowerCase(), handle.toLowerCase().replace(/[\s_-]+/g, '')] : []),
|
||
...(title
|
||
? [title.toLowerCase(), title.toLowerCase().replace(/[\s_-]+/g, ''), title.split(/\s+/)[0].toLowerCase()]
|
||
: [])
|
||
])
|
||
|
||
for (const form of forms) {
|
||
if (form) {
|
||
handles.set(form, groupMemberKey(member))
|
||
}
|
||
}
|
||
}
|
||
|
||
for (const match of source.matchAll(/@([a-z0-9][a-z0-9._-]*)/gi)) {
|
||
const handle = match[1].toLowerCase()
|
||
|
||
if (handle === 'everyone' || handle === 'all') {
|
||
everyone = true
|
||
continue
|
||
}
|
||
|
||
if (handle === 'user') {
|
||
continue
|
||
}
|
||
|
||
const resolved = handles.get(handle) || handles.get(handle.replace(/[._-]+/g, ''))
|
||
|
||
if (resolved) {
|
||
mentioned.add(resolved)
|
||
}
|
||
}
|
||
|
||
return { everyone, mentioned }
|
||
}
|
||
|
||
/** Members that should take a turn this round: everyone when no member is
|
||
* @-mentioned in messages since the last user entry (or @everyone appears),
|
||
* otherwise only the mentioned members. Recomputed every round so a member
|
||
* pulled in mid-conversation joins the next round. */
|
||
function resolveGroupResponders(log, members) {
|
||
let sinceLastUser = []
|
||
|
||
for (let i = log.length - 1; i >= 0; i--) {
|
||
if (log[i].from.kind === 'user') {
|
||
sinceLastUser = log.slice(i)
|
||
break
|
||
}
|
||
}
|
||
|
||
const mentioned = new Set()
|
||
let everyone = false
|
||
|
||
for (const entry of sinceLastUser) {
|
||
const parsed = parseGroupChatMentions(entry.text, members)
|
||
|
||
if (parsed.everyone) {
|
||
everyone = true
|
||
}
|
||
|
||
for (const name of parsed.mentioned) {
|
||
mentioned.add(name)
|
||
}
|
||
}
|
||
|
||
if (everyone || mentioned.size === 0) {
|
||
return members
|
||
}
|
||
|
||
return members.filter(member => mentioned.has(groupMemberKey(member)))
|
||
}
|
||
|
||
/** Rotate the roster so a different member leads each round. */
|
||
function rotateGroupSpeakers(members, round) {
|
||
if (members.length < 2) {
|
||
return members
|
||
}
|
||
|
||
const shift = round % members.length
|
||
|
||
return [...members.slice(shift), ...members.slice(0, shift)]
|
||
}
|
||
|
||
/** Transcript form of a room speaker's profile name. The primary profile is
|
||
* literally named "default" — render it as Hermes (matching displayName and
|
||
* the @hermes handle) so the main agent never loses its name in rooms. */
|
||
function groupSpeakerLabel(name) {
|
||
return (name || '').trim().toLowerCase() === 'default' ? 'Hermes' : name
|
||
}
|
||
|
||
/** Room-log line as a member sees it: `Name (user): …` / `Name: …` /
|
||
* `Name (you): …`. */
|
||
function formatGroupChatLine(entry, viewerName) {
|
||
// 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}`
|
||
}
|
||
|
||
/** 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. */
|
||
function buildGroupChatTurnPrompt({ groupName, members, viewer, deltaLines }) {
|
||
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')
|
||
}
|
||
|
||
/** Trim a room log + its watermarks to the retained window, keeping
|
||
* watermark indices consistent with the trimmed array. */
|
||
function trimGroupChatLog(log, watermarks, limit = GROUP_CHAT_HISTORY_LIMIT * 4) {
|
||
if (log.length <= limit) {
|
||
return { log, watermarks }
|
||
}
|
||
|
||
const drop = log.length - limit
|
||
const trimmed = {}
|
||
|
||
for (const [name, index] of Object.entries(watermarks || {})) {
|
||
trimmed[name] = Math.max(0, index - drop)
|
||
}
|
||
|
||
return { log: log.slice(drop), watermarks: trimmed }
|
||
}
|
||
|
||
/** Mutate one group's room state through the atom + persist the durable part. */
|
||
function updateGroupChat(group, mutate) {
|
||
const all = { ...$groupChats.get() }
|
||
const current = all[group] || { log: [], watermarks: {}, epoch: 0, running: false }
|
||
const next = mutate({ ...current, log: [...current.log], watermarks: { ...current.watermarks } })
|
||
const bounded = trimGroupChatLog(next.log, next.watermarks)
|
||
|
||
next.log = bounded.log
|
||
next.watermarks = bounded.watermarks
|
||
all[group] = next
|
||
$groupChats.set(all)
|
||
|
||
try {
|
||
const durable = {}
|
||
|
||
for (const [name, room] of Object.entries(all)) {
|
||
durable[name] = {
|
||
log: room.log,
|
||
watermarks: room.watermarks,
|
||
sessions: room.sessions || {},
|
||
// Timed-out turns awaiting a late reply — keyed by member, valued
|
||
// with the pre-turn message baseline. Survives reloads so finished
|
||
// work is still harvested after a window restart.
|
||
stranded: room.stranded || {},
|
||
// Source-qualified member descriptors keep the room whole when the
|
||
// active connection changes and today's local members become remote.
|
||
members: Array.isArray(room.members) ? room.members : [],
|
||
// Room picture (small data URL, same normalization as bot avatars).
|
||
image: room.image || null
|
||
}
|
||
}
|
||
|
||
Promise.resolve(pluginCtx?.storage?.set?.('group-chats', durable)).catch(() => undefined)
|
||
} catch {
|
||
/* storage unavailable — room survives for this window only */
|
||
}
|
||
|
||
return next
|
||
}
|
||
|
||
/** Soft-disband a group chat: remove only this group from every local member's
|
||
* membership list (the metadata syncs cross-machine via ui_meta), drop the
|
||
* room log from the atom + plugin storage, and close the room view if it's
|
||
* open. Other group memberships and the members' per-group gateway sessions
|
||
* ("Group: <name>") are intentionally KEPT. */
|
||
async function disbandGroupChat(group, members) {
|
||
// Invalidate any in-flight round-robin FIRST: bump the epoch so a running
|
||
// drive bails at its next member boundary instead of appending to a room
|
||
// the user just discarded.
|
||
const all = { ...$groupChats.get() }
|
||
const prior = all[group] || {}
|
||
|
||
delete all[group]
|
||
// Keep a runtime-only tombstone while a drive may still be mid-turn; it
|
||
// carries no log and is never persisted, so it can't rehydrate.
|
||
if (prior.running) {
|
||
all[group] = { log: [], watermarks: {}, sessions: {}, epoch: (prior.epoch || 0) + 1, running: false }
|
||
}
|
||
|
||
$groupChats.set(all)
|
||
|
||
if ($groupChatWorkspace.get() === group) {
|
||
$groupChatWorkspace.set(null)
|
||
}
|
||
|
||
// Retire the room's MAIN-window tab too (host.openWorkspace path).
|
||
closeGroupChatMainTab(group)
|
||
|
||
const needs = { ...$groupNeedsYou.get() }
|
||
|
||
delete needs[group]
|
||
$groupNeedsYou.set(needs)
|
||
|
||
// Persist the room map WITHOUT the disbanded room so it can't come back
|
||
// on the next window load.
|
||
try {
|
||
const durable = {}
|
||
|
||
for (const [name, room] of Object.entries($groupChats.get())) {
|
||
if (name !== group && Array.isArray(room.log)) {
|
||
durable[name] = {
|
||
log: room.log,
|
||
watermarks: room.watermarks,
|
||
sessions: room.sessions || {},
|
||
members: Array.isArray(room.members) ? room.members : [],
|
||
image: room.image || null
|
||
}
|
||
}
|
||
}
|
||
|
||
await Promise.resolve(pluginCtx?.storage?.set?.('group-chats', durable))
|
||
} catch {
|
||
/* storage unavailable — the atom reset above still empties the room */
|
||
}
|
||
|
||
// Remove this membership last. saveBotMeta never throws (local storage +
|
||
// best-effort profiles.configure per member), so a flaky gateway can't
|
||
// strand the disband halfway with the room log already gone.
|
||
for (const member of members) {
|
||
if (!member?.name || member.remoteSource) {
|
||
continue
|
||
}
|
||
|
||
const meta = $botMeta.get()[member.name] || {}
|
||
await saveBotMeta(member.name, groupMembershipPatch(meta, group, false))
|
||
}
|
||
}
|
||
|
||
/** Set or clear a group chat's room picture (small data URL, normalized by
|
||
* the same pipeline as bot avatars). Persists with the room record. */
|
||
function setGroupChatImage(group, image) {
|
||
updateGroupChat(group, room => {
|
||
room.image = image || null
|
||
return room
|
||
})
|
||
}
|
||
|
||
/** Rename a group chat. The group's NAME is its identity everywhere — the
|
||
* room-map key, each local member's ui_meta membership list, and derived
|
||
* state — so a rename re-keys all of them. Member gateway sessions are kept
|
||
* as-is: stored sids keep resuming, so no history is lost (only a member
|
||
* whose sid is later lost falls back to a fresh "Group: <new name>" title
|
||
* lookup). Returns the new name, or null when the target name is taken. */
|
||
async function renameGroupChat(oldName, newName, members) {
|
||
const next = String(newName || '').trim().slice(0, 64)
|
||
|
||
if (!next || next === oldName) {
|
||
return oldName
|
||
}
|
||
|
||
// Renames are explicit user intent: reject a collision honestly instead of
|
||
// silently suffixing like creation does.
|
||
const taken = new Set(Object.keys($groupChats.get()))
|
||
|
||
for (const meta of Object.values($botMeta.get() || {})) {
|
||
for (const existing of botGroups(meta)) {
|
||
taken.add(existing)
|
||
}
|
||
}
|
||
|
||
taken.delete(oldName)
|
||
|
||
if (taken.has(next)) {
|
||
host.notify({ kind: 'error', message: `A group named “${next}” already exists.` })
|
||
return null
|
||
}
|
||
|
||
// Move the room record wholesale — log, watermarks, sessions, members,
|
||
// picture, and runtime flags all belong to the same room under its new name.
|
||
const all = { ...$groupChats.get() }
|
||
const room = all[oldName]
|
||
|
||
delete all[oldName]
|
||
|
||
if (room) {
|
||
all[next] = room
|
||
}
|
||
|
||
$groupChats.set(all)
|
||
|
||
const needs = { ...$groupNeedsYou.get() }
|
||
|
||
if (oldName in needs) {
|
||
needs[next] = needs[oldName]
|
||
delete needs[oldName]
|
||
$groupNeedsYou.set(needs)
|
||
}
|
||
|
||
// Local memberships: swap the name inside each member's canonical groups
|
||
// list (syncs cross-machine via ui_meta). Remote members' seating lives in
|
||
// the room record we just moved.
|
||
for (const member of members || []) {
|
||
if (!member?.name || member.remoteSource) {
|
||
continue
|
||
}
|
||
|
||
const meta = $botMeta.get()[member.name] || {}
|
||
const groups = [...new Set(botGroups(meta).map(g => (g === oldName ? next : g)))]
|
||
|
||
await saveBotMeta(member.name, { groups, group: groups[0] || null })
|
||
}
|
||
|
||
// Persist the re-keyed map (updateGroupChat writes the whole durable map).
|
||
updateGroupChat(next, r => r)
|
||
|
||
// Follow the open views to the new identity.
|
||
if ($groupChatWorkspace.get() === oldName) {
|
||
$groupChatWorkspace.set(next)
|
||
}
|
||
|
||
if (groupChatMainTabs.has(oldName)) {
|
||
closeGroupChatMainTab(oldName)
|
||
openGroupChat(next)
|
||
}
|
||
|
||
return next
|
||
}
|
||
|
||
function appendGroupChatEntry(group, from, text, thread, images) {
|
||
const entry = { at: Date.now(), from, text: String(text).trim(), thread: thread || 'legacy' }
|
||
|
||
if (Array.isArray(images) && images.length) {
|
||
// [{ name, data }] — data URLs. Persisted with the room log so reloads
|
||
// keep showing what the members were shown.
|
||
entry.images = images
|
||
}
|
||
|
||
updateGroupChat(group, room => {
|
||
room.log.push(entry)
|
||
return room
|
||
})
|
||
|
||
// Needs-you: a member addressing @user badges the group header.
|
||
if (from.kind === 'member' && /@user\b/i.test(entry.text)) {
|
||
$groupNeedsYou.set({ ...$groupNeedsYou.get(), [group]: true })
|
||
}
|
||
|
||
return entry
|
||
}
|
||
|
||
/** Ensure the member's per-group session exists and return a LIVE runtime
|
||
* session id for it. Gateway-native: session.create mints the session
|
||
* (lazy until its first message), session.resume by stored id — or by
|
||
* title, which also covers rehydrated rooms whose sid was lost — reopens
|
||
* it after restarts. Cross-connection members route to their OWN source
|
||
* via requestForBot; the window's gateway never switches. */
|
||
async function ensureGroupChatSession(group, member) {
|
||
const title = `Group: ${group}`
|
||
const room = $groupChats.get()[group] || {}
|
||
const key = groupMemberKey(member)
|
||
const known = room.sessions && room.sessions[key]
|
||
|
||
// Try resuming what we know (stored sid first, then title lookup).
|
||
for (const target of [known, title]) {
|
||
if (!target || target === true) {
|
||
continue
|
||
}
|
||
|
||
try {
|
||
const res = await requestForBot(member, 'session.resume', {
|
||
session_id: target,
|
||
profile: member.name,
|
||
omit_messages: true
|
||
})
|
||
|
||
if (res?.session_id) {
|
||
return { runtime: res.session_id, stored: res.session_key || known }
|
||
}
|
||
} catch {
|
||
/* 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
|
||
})
|
||
const stored = created?.stored_session_id || null
|
||
|
||
if (stored) {
|
||
updateGroupChat(group, r => {
|
||
r.sessions = { ...(r.sessions || {}), [key]: stored }
|
||
return r
|
||
})
|
||
}
|
||
|
||
return { runtime: created?.session_id || null, stored }
|
||
}
|
||
|
||
const GROUP_TURN_TIMEOUT_MS = 180000
|
||
const GROUP_TURN_POLL_MS = 2000
|
||
// A member turn that is VISIBLY still working (session reports
|
||
// inflight/running) keeps its slot alive up to this hard cap. The base
|
||
// timeout alone silently dropped long real turns: a 7-minute research run
|
||
// timed out at 3 minutes, read as a pass, and its finished result never
|
||
// reached the room (db's Aug 2026 report).
|
||
const GROUP_TURN_HARD_CAP_MS = 20 * 60000
|
||
|
||
/** One member turn, gateway-native: submit the room delta as a prompt into
|
||
* the member's per-group session, then poll the session until a NEW
|
||
* assistant message lands (or timeout → pass). While the session visibly
|
||
* reports work in flight the deadline extends (bounded by the hard cap),
|
||
* so slow models aren't cut off mid-run. A turn that still times out
|
||
* records a stranded marker so the finished reply can be harvested into
|
||
* the room at the member's next turn instead of being lost. */
|
||
async function runGroupChatMemberTurn(group, member, prompt, thread, images) {
|
||
const { runtime, stored } = await ensureGroupChatSession(group, member)
|
||
|
||
if (!runtime) {
|
||
return null
|
||
}
|
||
|
||
// Baseline: how many messages exist before our submit.
|
||
let before = 0
|
||
|
||
try {
|
||
const pre = await requestForBot(member, 'session.resume', {
|
||
session_id: stored || runtime,
|
||
profile: member.name
|
||
})
|
||
before = Array.isArray(pre?.messages) ? pre.messages.length : pre?.message_count || 0
|
||
} catch {
|
||
/* lazy session — zero messages */
|
||
}
|
||
|
||
// Stage this delta's attachments into the member's session so the model
|
||
// receives the actual payload with the prompt — the same attach RPCs the
|
||
// 1:1 chat uses (they also work cross-connection, where the member's
|
||
// gateway can't see this machine's files). Images queue as vision tiles,
|
||
// PDFs render per-page via pdf.attach, and other files materialize in the
|
||
// session workspace (their @file: refs are appended to the prompt so the
|
||
// member's file tools can read them). A failed attach degrades that
|
||
// member to text-only; the transcript line still names the attachment so
|
||
// the member knows something was shared.
|
||
const fileRefs = []
|
||
|
||
for (const img of Array.isArray(images) ? images : []) {
|
||
if (!img || typeof img.data !== 'string' || !img.data) {
|
||
continue
|
||
}
|
||
|
||
try {
|
||
if (img.kind === 'pdf') {
|
||
await requestForBot(member, 'pdf.attach', {
|
||
session_id: runtime,
|
||
content_base64: img.data,
|
||
filename: img.name || 'attachment.pdf'
|
||
})
|
||
} else if (img.kind === 'file') {
|
||
const res = await requestForBot(member, 'file.attach', {
|
||
session_id: runtime,
|
||
data_url: img.data,
|
||
name: img.name || 'attachment'
|
||
})
|
||
|
||
if (res?.ref_text) {
|
||
fileRefs.push(`${img.name || 'attachment'} → ${res.ref_text}`)
|
||
}
|
||
} else {
|
||
await requestForBot(member, 'image.attach_bytes', {
|
||
session_id: runtime,
|
||
content_base64: img.data,
|
||
filename: img.name || 'attachment.png'
|
||
})
|
||
}
|
||
} catch {
|
||
/* text-only fallback for this member */
|
||
}
|
||
}
|
||
|
||
const turnText = fileRefs.length
|
||
? `${prompt}\n\nAttached files staged in your session workspace:\n${fileRefs.join('\n')}`
|
||
: prompt
|
||
|
||
await requestForBot(member, 'prompt.submit', { session_id: runtime, text: turnText })
|
||
|
||
const started = Date.now()
|
||
let deadline = started + GROUP_TURN_TIMEOUT_MS
|
||
|
||
while (Date.now() < deadline) {
|
||
await new Promise(resolve => setTimeout(resolve, GROUP_TURN_POLL_MS))
|
||
|
||
let state = null
|
||
|
||
try {
|
||
state = await requestForBot(member, 'session.resume', {
|
||
session_id: stored || runtime,
|
||
profile: member.name
|
||
})
|
||
} catch {
|
||
continue
|
||
}
|
||
|
||
const messages = Array.isArray(state?.messages) ? state.messages : []
|
||
const busy = Boolean(state?.inflight || state?.running)
|
||
const done = !busy
|
||
|
||
if (messages.length > before && done) {
|
||
for (let i = messages.length - 1; i >= 0; i--) {
|
||
const msg = messages[i]
|
||
|
||
if (msg?.role === 'assistant') {
|
||
const text = typeof msg.content === 'string'
|
||
? msg.content
|
||
: Array.isArray(msg.content)
|
||
? msg.content.map(p => (typeof p === 'string' ? p : p?.text || '')).join('')
|
||
: msg?.text || ''
|
||
|
||
return String(text).trim()
|
||
}
|
||
}
|
||
|
||
return null
|
||
}
|
||
|
||
// Still visibly working: extend the deadline (never past the hard cap).
|
||
if (busy) {
|
||
deadline = Math.min(started + GROUP_TURN_HARD_CAP_MS, Math.max(deadline, Date.now() + GROUP_TURN_TIMEOUT_MS))
|
||
}
|
||
}
|
||
|
||
// Timeout — reads 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.
|
||
updateGroupChat(group, r => {
|
||
r.stranded = { ...(r.stranded || {}), [groupMemberKey(member)]: { before, thread } }
|
||
return r
|
||
})
|
||
|
||
return null
|
||
}
|
||
|
||
/** 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. */
|
||
async function harvestStrandedGroupReply(group, member) {
|
||
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 = null
|
||
|
||
try {
|
||
const stored = room.sessions?.[memberKey]
|
||
state = await requestForBot(member, 'session.resume', {
|
||
session_id: stored || `Group: ${group}`,
|
||
profile: member.name
|
||
})
|
||
} catch {
|
||
return // source unreachable — leave the marker for the next boundary
|
||
}
|
||
|
||
if (state?.inflight || state?.running) {
|
||
return // still grinding — keep waiting
|
||
}
|
||
|
||
// Done (or dead): the marker is consumed either way.
|
||
updateGroupChat(group, r => {
|
||
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
|
||
}
|
||
|
||
for (let i = messages.length - 1; i >= 0; i--) {
|
||
const msg = messages[i]
|
||
|
||
if (msg?.role === 'assistant') {
|
||
const text = typeof msg.content === 'string'
|
||
? msg.content
|
||
: Array.isArray(msg.content)
|
||
? msg.content.map(p => (typeof p === 'string' ? p : p?.text || '')).join('')
|
||
: msg?.text || ''
|
||
const reply = String(text).trim()
|
||
|
||
if (reply && !isGroupPassText(reply)) {
|
||
appendGroupChatEntry(
|
||
group,
|
||
{ kind: 'member', name: member.name, ...(member.remoteSource ? { source: member.connectionLabel || member.connectionId } : {}) },
|
||
reply,
|
||
strandedThread
|
||
)
|
||
updateGroupChat(group, r => {
|
||
r.watermarks[`${strandedThread}::${memberKey}`] = r.log.length
|
||
return r
|
||
})
|
||
}
|
||
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
/** Drive one bounded round-robin turn for ONE THREAD. Serial — one member at
|
||
* a time. A newer user send bumps the room epoch; this loop notices at the
|
||
* next member boundary, bails, and the newest send's own loop takes over.
|
||
* Watermarks are per thread+member (`${thread}::${memberKey}`), so parallel
|
||
* topics never eat each other's deltas. */
|
||
async function runGroupChatRounds(group, members, thread) {
|
||
const startEpoch = ($groupChats.get()[group] || {}).epoch || 0
|
||
const isCurrent = () => (($groupChats.get()[group] || {}).epoch || 0) === startEpoch
|
||
let posted = 0
|
||
|
||
try {
|
||
for (let round = 0; round < GROUP_CHAT_MAX_ROUNDS; round++) {
|
||
// Deliver any replies that finished after their turn timed out —
|
||
// every member, not just this round's responders, so long work is
|
||
// late, never lost.
|
||
for (const member of members) {
|
||
if (!isCurrent()) {
|
||
return
|
||
}
|
||
|
||
await harvestStrandedGroupReply(group, member)
|
||
}
|
||
|
||
const roomLog = (($groupChats.get()[group] || {}).log || []).filter(e => groupThreadOf(e) === thread)
|
||
const responders = rotateGroupSpeakers(resolveGroupResponders(roomLog, members), round)
|
||
let spokeThisRound = 0
|
||
|
||
for (const member of responders) {
|
||
if (!isCurrent() || posted >= GROUP_CHAT_MAX_MESSAGES) {
|
||
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 => groupThreadOf(e) === thread)
|
||
|
||
if (!delta.length) {
|
||
continue
|
||
}
|
||
|
||
const prompt = buildGroupChatTurnPrompt({
|
||
groupName: group,
|
||
members,
|
||
viewer: member,
|
||
deltaLines: delta.slice(-GROUP_CHAT_HISTORY_LIMIT).map(e => 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 => (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 => {
|
||
r.turn = member.name
|
||
return r
|
||
})
|
||
|
||
let reply = null
|
||
|
||
try {
|
||
reply = await runGroupChatMemberTurn(group, member, prompt, thread, deltaImages)
|
||
} catch {
|
||
reply = null // a failed turn is a pass, never a room error
|
||
}
|
||
|
||
// The member has now seen everything up to the pre-reply log length.
|
||
updateGroupChat(group, r => {
|
||
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 => {
|
||
r.watermarks[markKey] = r.log.length
|
||
return r
|
||
})
|
||
posted += 1
|
||
spokeThisRound += 1
|
||
}
|
||
}
|
||
|
||
if (spokeThisRound === 0) {
|
||
return // everyone passed — the conversation settled
|
||
}
|
||
}
|
||
} finally {
|
||
if (isCurrent()) {
|
||
updateGroupChat(group, r => {
|
||
r.running = false
|
||
r.turn = null
|
||
return r
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
/** User send into a group room. `thread` continues that thread (its reply
|
||
* box); omitted/null mints a NEW thread — the main composer's Slack shape.
|
||
* Appends, bumps the room epoch (supersedes any running loop at its next
|
||
* member boundary), and starts the turn drive for the target thread.
|
||
* Returns the thread id the message landed in. */
|
||
function sendToGroupChat(group, members, text, thread, images) {
|
||
const trimmed = String(text || '').trim()
|
||
const attached = Array.isArray(images) ? images.filter(img => img && img.data) : []
|
||
|
||
if ((!trimmed && !attached.length) || !members.length) {
|
||
return null
|
||
}
|
||
|
||
const target = thread || mintGroupThreadId()
|
||
|
||
$groupNeedsYou.set({ ...$groupNeedsYou.get(), [group]: false })
|
||
appendGroupChatEntry(group, { kind: 'user', name: 'You' }, trimmed, target, attached)
|
||
|
||
const wasRunning = ($groupChats.get()[group] || {}).running === true
|
||
|
||
updateGroupChat(group, room => {
|
||
room.epoch = (room.epoch || 0) + 1
|
||
room.running = true
|
||
return room
|
||
})
|
||
|
||
if (!wasRunning) {
|
||
void runGroupChatRounds(group, members, target).catch(() => {
|
||
updateGroupChat(group, r => {
|
||
r.running = false
|
||
return r
|
||
})
|
||
})
|
||
} 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 => {
|
||
r.running = false
|
||
return r
|
||
})
|
||
})
|
||
}, 250)
|
||
}
|
||
|
||
return target
|
||
}
|
||
|
||
/** Share one in-flight async operation across concurrent callers. Failures
|
||
* clear the slot so a later attempt can retry. */
|
||
function singleFlight(ref, start) {
|
||
if (ref.current) {
|
||
return ref.current
|
||
}
|
||
|
||
let flight
|
||
try {
|
||
flight = Promise.resolve(start())
|
||
} catch (err) {
|
||
flight = Promise.reject(err)
|
||
}
|
||
ref.current = flight
|
||
flight.catch(() => {
|
||
if (ref.current === flight) {
|
||
ref.current = null
|
||
}
|
||
})
|
||
return flight
|
||
}
|
||
|
||
/** The agent-to-agent messaging protocol, reusable so a CUSTOM SOUL keeps
|
||
* the handoff protocol too — a custom SOUL used to silently drop it,
|
||
* breaking @mentions for customized bots (@wesleysimplicio, #16). */
|
||
function messagingProtocolSection(name, roster) {
|
||
const teammates = (roster || []).filter(b => b.name !== name)
|
||
const handle = botHandle(name)
|
||
|
||
return [
|
||
'## Messaging other agents',
|
||
'',
|
||
'You work alongside other named agents. Every agent (including you) has',
|
||
'ONE canonical conversation titled "Bot Chat" — created with the agent,',
|
||
'so it always exists. Agent-to-agent messages are delivered straight',
|
||
'into it, like a DM. To message a teammate, run:',
|
||
'',
|
||
'```',
|
||
'hermes -p <agent-name> chat --in ~ -c "Bot Chat" --create-if-missing -Q -q "Message from \uD83E\uDD16 ' + handle + ' (@' + handle + '): your message"',
|
||
'',
|
||
'Run the send with background=true and notify_on_complete=true on the',
|
||
'terminal tool, then finish your turn — the reply arrives later as a',
|
||
'background process notification. Never block waiting for it.',
|
||
'```',
|
||
'',
|
||
'(`--in ~ -c "Bot Chat" --create-if-missing` resumes their canonical',
|
||
'conversation in the home workspace, creating it if the target has no',
|
||
'"Bot Chat" yet. `-Q` keeps output clean. Always open with the',
|
||
'"Message from \uD83E\uDD16 ' + handle + ' (@' + handle + '):" prefix so they know',
|
||
'who is talking (the @handle lets the app show your avatar to them).',
|
||
'Their reply prints to stdout — relay the relevant part back to the',
|
||
'user, and say which agent it came from.)',
|
||
'',
|
||
'If a message in YOUR chat starts with "Message from \uD83E\uDD16 <name>", it is',
|
||
'a teammate messaging you, not the user. Answer it directly — your reply',
|
||
'reaches them via their own delivery — and use the same command if you',
|
||
'need to start a conversation yourself.',
|
||
'',
|
||
'When the user writes @<agent-name> or says "ask <name> to ..." /',
|
||
'"tell <name> ...", that is a handoff: message that agent, wait for the',
|
||
'reply, and report back.',
|
||
'',
|
||
'The roster grows over time — run `hermes profile list` for the LIVE',
|
||
'teammate list before a handoff. Teammates when you were created:',
|
||
...(teammates.length
|
||
? teammates.map(b => `- \`${b.name}\`${b.description ? ` — ${b.description}` : ''}`)
|
||
: ['- (none yet)'])
|
||
].join('\n')
|
||
}
|
||
|
||
/** True when SOUL.md already carries the Bot Mode handoff section.
|
||
* #16 appends this at create-time; pre-existing profiles (especially
|
||
* `default`) never went through composeSoul and silently lack it. */
|
||
function hasMessagingProtocol(soul) {
|
||
return /(^|\n)## Messaging other agents(\s|$)/.test(soul || '')
|
||
}
|
||
|
||
/** Idempotent: append the protocol once, never duplicate a custom SOUL
|
||
* that already has it (clone-from-default after a backfill, Edit save).
|
||
* No-op when the backend injects the protocol into the system prompt
|
||
* itself (bot_mode_protocol) — SOUL.md stays the user's identity text. */
|
||
function ensureMessagingProtocol(soul, name, roster) {
|
||
const text = (soul || '').trim()
|
||
if (serverInjectsProtocol || hasMessagingProtocol(text)) return text
|
||
const section = messagingProtocolSection(name, roster)
|
||
return text ? text + '\n\n' + section : section
|
||
}
|
||
|
||
const soulProtocolChecked = new Set()
|
||
const soulProtocolInflight = new Set()
|
||
|
||
/** One-shot per profile per session: if an existing SOUL has no protocol,
|
||
* append it. This is the install-time fix for default / pre-Bot-Mode
|
||
* personas that #16 never touched. Never overwrites identity text. */
|
||
function backfillMessagingProtocol(roster) {
|
||
// Newer backends teach the protocol via the system prompt — never touch
|
||
// user SOUL files when the server already covers every session.
|
||
if (serverInjectsProtocol) {
|
||
return
|
||
}
|
||
|
||
for (const bot of roster || []) {
|
||
const name = bot && bot.name
|
||
if (!name || soulProtocolChecked.has(name) || soulProtocolInflight.has(name)) {
|
||
continue
|
||
}
|
||
|
||
soulProtocolInflight.add(name)
|
||
host
|
||
.request('profiles.describe', { name })
|
||
.then(res => {
|
||
const soul = (res && res.soul) || ''
|
||
if (hasMessagingProtocol(soul)) {
|
||
soulProtocolChecked.add(name)
|
||
return null
|
||
}
|
||
return host
|
||
.request('profiles.configure', { name, soul: ensureMessagingProtocol(soul, name, roster) })
|
||
.then(() => {
|
||
soulProtocolChecked.add(name)
|
||
})
|
||
})
|
||
.catch(() => {
|
||
// Older gateway or a one-off describe/configure miss — do not hammer.
|
||
soulProtocolChecked.add(name)
|
||
})
|
||
.finally(() => {
|
||
soulProtocolInflight.delete(name)
|
||
})
|
||
}
|
||
}
|
||
|
||
/** SOUL.md for a new bot: identity (or the user's custom SOUL) + the
|
||
* messaging protocol — which ships UNLESS the backend injects it into the
|
||
* system prompt itself (bot_mode_protocol capability). */
|
||
function composeSoul({ name, title, description, roster, customSoul }) {
|
||
if (customSoul && customSoul.trim()) {
|
||
return ensureMessagingProtocol(customSoul, name, roster)
|
||
}
|
||
|
||
const lines = [
|
||
`# ${displayName({ name, title })}`,
|
||
'',
|
||
title ? `**Role:** ${title}` : null,
|
||
description ? `**Mission:** ${description}` : null,
|
||
'',
|
||
`You are ${displayName({ name, title })}, a persistent named agent (profile \`${name}\`) on this machine.`,
|
||
'You keep your own memory, skills, and conversation history across sessions.'
|
||
]
|
||
|
||
const identity = lines.filter(line => line !== null).join('\n')
|
||
|
||
return serverInjectsProtocol ? identity : identity + '\n\n' + messagingProtocolSection(name, roster)
|
||
}
|
||
|
||
// ── human-readable row helpers ───────────────────────────────────────────────
|
||
|
||
/** Bot-to-bot delivery prefix (see messagingProtocolSection): either the
|
||
* current "Message from 🤖 name (@handle):" form or the older
|
||
* "[Message from agent 'name']" shape. Captures the sender's handle. */
|
||
const A2A_RE = /^Message from (?:agent '([^']+)'|🤖\s*([^\s(@]+))/i
|
||
|
||
/** Strip the delivery prefix so a DM preview reads like a DM, not a log line. */
|
||
const A2A_PREFIX_RE = /^Message from (?:agent '[^']+'|🤖[^:]+):\s*/i
|
||
|
||
/** Classify a roster preview: `{ fromBot: handle|null }`. A preview that
|
||
* starts with the delivery prefix is a bot-to-bot message — the receiving
|
||
* bot's row should show WHO sent it, not present it as the human's chat. */
|
||
function previewKind(preview) {
|
||
const text = (preview || '').trim()
|
||
if (!text) {
|
||
return { fromBot: null }
|
||
}
|
||
const match = text.match(A2A_RE)
|
||
if (match) {
|
||
// The captured name is whatever the delivery prefix carried — a raw
|
||
// profile name. Map it the way every other surface does so the primary
|
||
// profile reads @hermes, never @default (#89484).
|
||
const sender = (match[1] || match[2] || '').trim().toLowerCase()
|
||
return { fromBot: sender ? botHandle(sender) : null }
|
||
}
|
||
return { fromBot: null }
|
||
}
|
||
|
||
/** Session titles the gateway auto-assigns that carry no information. */
|
||
const GENERIC_TITLES = new Set(['', 'bot chat', 'new chat', 'new conversation', 'conversation', 'chat', 'untitled'])
|
||
|
||
function isGenericTitle(title) {
|
||
return GENERIC_TITLES.has((title || '').trim().toLowerCase())
|
||
}
|
||
|
||
/** Title for the session chip: the real session title when it means
|
||
* something, otherwise a short label generated from the newest message
|
||
* (delivery prefixes stripped) so "Bot Chat" rows still say what the
|
||
* conversation is actually about. */
|
||
function generatedSessionTitle(session, preview) {
|
||
const raw = (session?.title || '').trim()
|
||
if (raw && !isGenericTitle(raw)) {
|
||
return raw
|
||
}
|
||
const cleaned = (preview || '').trim().replace(A2A_PREFIX_RE, '').trim()
|
||
if (!cleaned) {
|
||
return raw || 'Conversation'
|
||
}
|
||
const words = cleaned.split(/\s+/).slice(0, 5).join(' ').replace(/[,;:.]+$/, '')
|
||
if (!words) {
|
||
return raw || 'Conversation'
|
||
}
|
||
return words.length > 34 ? `${words.slice(0, 33)}…` : words
|
||
}
|
||
|
||
/** Roster liveness window: a bot whose last message landed within this many
|
||
* seconds is treated as "active now" (pulsing dot in its row). */
|
||
const ACTIVE_WINDOW_S = 90
|
||
|
||
/** Bots that are working right now: the profile the gateway is running a
|
||
* turn for (busy), plus any bot whose last message landed inside the
|
||
* liveness window. Pure — output follows the input roster's order, so
|
||
* presence never reorders or hides the normal list. */
|
||
function activeBots(roster, activeProfile, gatewayState, now = Date.now()) {
|
||
return (roster || []).filter(bot => {
|
||
const busyTurn = !bot.remoteSource && bot.name === activeProfile && gatewayState === 'busy'
|
||
const last = bot.last_session?.last_active || 0
|
||
const inWindow = Boolean(last && now / 1000 - last < ACTIVE_WINDOW_S)
|
||
|
||
return busyTurn || inWindow
|
||
})
|
||
}
|
||
|
||
// ── bot row ──────────────────────────────────────────────────────────────────
|
||
|
||
function BotRow({ bot, onDelete, onEdit, onGroup }) {
|
||
const activeProfile = useValue(host.state.profile)
|
||
const focusedProfile = useValue($focusedBotProfile)
|
||
const meta = botRosterMeta(bot, useValue($botMeta))
|
||
const groups = botGroups(meta)
|
||
const last = bot.last_session
|
||
// Highlight follows the chat on screen (focused session's owner), not the
|
||
// gateway socket's home — a focused tab doesn't swap the socket, and on the
|
||
// old keying the wrong bot stayed highlighted while you read another's chat.
|
||
const isActive = !bot.remoteSource && bot.name === focusedProfile
|
||
// Turn-busy is a SOCKET fact: only the gateway-home profile can be mid-turn.
|
||
const isGatewayHome = !bot.remoteSource && bot.name === activeProfile
|
||
const { shape, color, image } = botAppearance(bot.name, meta)
|
||
// Keep user photos/pets. Drop the 160px SVG backfill so the math face can move.
|
||
const photo = Boolean(image && !isBackfilledFacePng(image))
|
||
const gatewayState = useValue(host.state.gateway)
|
||
const activeNow = Boolean(last?.last_active && Date.now() / 1000 - last.last_active < ACTIVE_WINDOW_S)
|
||
// Work pose only when this bot is actually doing something: the active
|
||
// profile while the gateway is busy, or a bot that wrote within the
|
||
// liveness window. Not every bot whenever the gateway is busy.
|
||
const botMood = (isGatewayHome && gatewayState === 'busy') || activeNow ? 'work' : 'idle'
|
||
// Subscribe on every render. A source switch turns the same keyed row from
|
||
// thin to rich; conditionally calling useValue here breaks React hook order.
|
||
const unreadByName = useValue($botUnread)
|
||
const unread = !bot.remoteSource && Boolean(unreadByName[bot.name])
|
||
// WHO sent the last message (bot-to-bot DM vs human) — the full stored
|
||
// history lives in the Sessions workspace (context menu), not inline.
|
||
// Preview identity must match click identity (#88200): when the backend
|
||
// resolved the pinned canonical chat, preview THAT session — not the
|
||
// profile's most recent (but unrelated) activity. Liveness checks above
|
||
// keep last_session semantics: any recent activity means the bot is alive.
|
||
const previewSession = bot.preferred_session || last
|
||
const { fromBot } = previewKind(previewSession?.preview)
|
||
// DM previews read like DMs: strip the delivery prefix, keep the message.
|
||
const displayPreview = stripPreviewMarkdown(
|
||
fromBot
|
||
? (previewSession?.preview || '').replace(A2A_PREFIX_RE, '').trim() || '…'
|
||
: previewSession?.preview || bot.description || 'No conversations yet — say hi'
|
||
)
|
||
|
||
const warm = () => {
|
||
// Multi-source row: pre-dial the agent's OWN source (feature-detected).
|
||
if (bot.sourceScoped && typeof host.warmAgent === 'function') {
|
||
try {
|
||
host.warmAgent(bot.connectionId, bot.name)
|
||
} catch {
|
||
/* warm is best-effort */
|
||
}
|
||
|
||
return
|
||
}
|
||
|
||
if (typeof host.warmProfile !== 'function') {
|
||
return
|
||
}
|
||
|
||
try {
|
||
host.warmProfile(bot.name)
|
||
} catch {
|
||
/* warm is best-effort */
|
||
}
|
||
}
|
||
|
||
const open = async () => {
|
||
const generation = ++botOpenGeneration
|
||
haptic('tap')
|
||
$selectedBot.set(bot.name)
|
||
|
||
if (bot.remoteSource) {
|
||
const handle = botHandle(bot.name, bot)
|
||
host.notify?.({
|
||
kind: 'info',
|
||
title: displayName(bot),
|
||
message: `Stay in this chat and @${handle} to message them. Gateway stays on this device.`
|
||
})
|
||
return
|
||
}
|
||
|
||
let pinnedChat = meta?.chat
|
||
|
||
if (!bot.remoteSource && $botUnread.get()[bot.name]) {
|
||
const next = { ...$botUnread.get() }
|
||
delete next[bot.name]
|
||
$botUnread.set(next)
|
||
}
|
||
|
||
// Activate the owner first so every canonical-chat RPC lands on the
|
||
// backend that owns this bot's state database.
|
||
try {
|
||
pinnedChat = await prepareBotSource(bot, pinnedChat)
|
||
} catch (error) {
|
||
host.notifyError?.(error, `Could not reach ${bot.connectionLabel || 'the remote source'}`)
|
||
|
||
return
|
||
}
|
||
|
||
if (generation !== botOpenGeneration) {
|
||
return
|
||
}
|
||
|
||
try {
|
||
const id = await openBotCanonicalChat(bot.name, pinnedChat, previewSession)
|
||
|
||
if (generation === botOpenGeneration && id) {
|
||
return
|
||
}
|
||
} catch (error) {
|
||
if (generation === botOpenGeneration) {
|
||
host.notifyError?.(error, `Could not open ${displayName(bot, meta)}'s chat — try again`)
|
||
}
|
||
|
||
return
|
||
}
|
||
|
||
if (generation !== botOpenGeneration) {
|
||
return
|
||
}
|
||
|
||
if (typeof host.newChat === 'function') {
|
||
// Older gateway without profile-scoped session.create — plain draft.
|
||
host.newChat(bot.name)
|
||
} else {
|
||
host.navigate('/')
|
||
}
|
||
}
|
||
|
||
const row = jsxs('button', {
|
||
type: 'button',
|
||
onPointerEnter: warm,
|
||
onClick: open,
|
||
className: cn(
|
||
'flex w-full min-w-0 max-w-full items-center gap-2.5 overflow-hidden rounded-md px-2 py-2 text-left transition-colors',
|
||
'hover:bg-(--chrome-action-hover)',
|
||
isActive && 'bg-(--chrome-action-hover)',
|
||
// Hidden bots only render while the header eye toggle is on — dimmed,
|
||
// so the temporary reveal reads as a different state from the roster.
|
||
meta?.hidden && 'opacity-60'
|
||
),
|
||
children: [
|
||
jsx('div', {
|
||
className: 'shrink-0',
|
||
children: jsx(BotFace, { shape, color, image: photo ? image : null, size: 34, name: bot.name, mood: botMood })
|
||
}),
|
||
jsxs('div', {
|
||
className: 'min-w-0 flex-1',
|
||
children: [
|
||
jsxs('div', {
|
||
className: 'flex items-baseline justify-between gap-2',
|
||
children: [
|
||
jsxs('div', {
|
||
className: 'flex min-w-0 items-baseline gap-1.5 truncate',
|
||
children: [
|
||
meta?.pinned
|
||
? jsx('span', {
|
||
className: 'shrink-0 text-[0.6875rem] text-(--ui-text-quaternary)',
|
||
title: 'Pinned',
|
||
children: '📌'
|
||
})
|
||
: null,
|
||
meta?.hidden
|
||
? jsx(Codicon, {
|
||
name: 'eye-closed',
|
||
className: 'shrink-0 text-[0.6875rem] text-(--ui-text-quaternary)',
|
||
title: 'Hidden from the roster'
|
||
})
|
||
: null,
|
||
jsx('span', {
|
||
className: 'truncate text-[0.8125rem] font-medium',
|
||
children: displayName(bot, meta)
|
||
}),
|
||
showsHandle(bot.name, meta, bot)
|
||
? jsx('span', {
|
||
className: 'shrink-0 font-mono text-[0.6875rem] text-(--ui-text-quaternary)',
|
||
children: `@${botHandle(bot.name, bot)}`
|
||
})
|
||
: null,
|
||
bot.remoteSource
|
||
? jsx('span', {
|
||
className:
|
||
'shrink-0 rounded bg-(--chrome-action-hover) px-1 font-mono text-[0.625rem] text-(--ui-text-tertiary)',
|
||
title: `Lives on ${bot.connectionLabel}`,
|
||
children: bot.connectionLabel
|
||
})
|
||
: null
|
||
]
|
||
}),
|
||
unread
|
||
? jsx('span', {
|
||
className: 'size-2 shrink-0 rounded-full bg-(--ui-accent,#4f9cf9)',
|
||
'aria-label': 'unread'
|
||
})
|
||
: null,
|
||
activeNow
|
||
? jsx('span', {
|
||
className: 'hermes-bots-pulse size-1.5 shrink-0 rounded-full bg-(--ui-accent,#4f9cf9)',
|
||
title: 'Active in the last 90s'
|
||
})
|
||
: null,
|
||
last
|
||
? jsx('span', {
|
||
className: 'shrink-0 text-[0.6875rem] text-(--ui-text-quaternary)',
|
||
children: relativeTime(last.last_active * 1000)
|
||
})
|
||
: null
|
||
]
|
||
}),
|
||
jsxs('div', {
|
||
className: 'flex min-w-0 items-center gap-1',
|
||
children: [
|
||
jsx('div', {
|
||
className: fromBot
|
||
? 'min-w-0 truncate text-xs italic text-(--ui-accent,#4f9cf9)'
|
||
: 'min-w-0 truncate text-xs text-(--ui-text-tertiary)',
|
||
children: displayPreview
|
||
}),
|
||
fromBot
|
||
? jsxs('span', {
|
||
className:
|
||
'flex shrink-0 items-center gap-1 rounded-full bg-(--chrome-action-hover) px-1.5 py-px text-[0.625rem] font-medium text-(--ui-accent,#4f9cf9)',
|
||
title: `Last message came from @${fromBot} (bot-to-bot)`,
|
||
children: ['🤖', `@${fromBot}`]
|
||
})
|
||
: null
|
||
]
|
||
})
|
||
]
|
||
})
|
||
]
|
||
})
|
||
|
||
// Thin rows from another source are navigation targets only. Their profile
|
||
// metadata is not loaded yet, so edit/delete/pin/group actions would mutate
|
||
// whichever backend happens to be active. A normal click activates the
|
||
// owner; the refreshed rich row then exposes the full context menu.
|
||
if (bot.remoteSource) {
|
||
return row
|
||
}
|
||
|
||
return jsxs(ContextMenu, {
|
||
children: [
|
||
jsx(ContextMenuTrigger, { asChild: true, children: row }),
|
||
jsxs(ContextMenuContent, {
|
||
children: [
|
||
jsx(ContextMenuItem, {
|
||
onSelect: () => {
|
||
const pinned = Boolean($botMeta.get()[bot.name]?.pinned)
|
||
saveBotMeta(bot.name, { pinned: !pinned })
|
||
host.notify({
|
||
kind: 'info',
|
||
message: `${displayName(bot, meta)} ${pinned ? 'unpinned' : 'pinned to top'}`
|
||
})
|
||
},
|
||
children: meta?.pinned ? 'Unpin' : 'Pin to top'
|
||
}),
|
||
jsx(ContextMenuItem, {
|
||
onSelect: () => {
|
||
const hidden = Boolean($botMeta.get()[bot.name]?.hidden)
|
||
// `hidden: false` (not null) so unhide round-trips through the
|
||
// server ui_meta merge the same way the local merge sees it.
|
||
saveBotMeta(bot.name, { hidden: !hidden })
|
||
|
||
if (!hidden) {
|
||
fallbackSelectionAfterHide(bot.name)
|
||
}
|
||
|
||
host.notify({
|
||
kind: 'info',
|
||
message: hidden
|
||
? `${displayName(bot, meta)} is back in the roster`
|
||
: `${displayName(bot, meta)} hidden — use the eye button in the Bots header to see hidden bots`
|
||
})
|
||
},
|
||
children: meta?.hidden ? 'Unhide Bot' : 'Hide Bot'
|
||
}),
|
||
jsx(ContextMenuSeparator, {}),
|
||
jsx(ContextMenuItem, {
|
||
onSelect: () => openBotSessionsWorkspace(bot),
|
||
children: 'Sessions'
|
||
}),
|
||
jsx(ContextMenuItem, { onSelect: () => onEdit(bot), children: 'Edit Profile' }),
|
||
!bot.remoteSource
|
||
? jsx(ContextMenuItem, {
|
||
onSelect: () => onGroup(bot),
|
||
children: groups.length ? `Groups: ${groups.join(', ')}…` : 'Manage groups…'
|
||
})
|
||
: null,
|
||
jsx(ContextMenuItem, {
|
||
onSelect: () => {
|
||
host.notify({ kind: 'info', message: `Duplicating ${displayName(bot, meta)}…` })
|
||
duplicateBot(bot, $lastRoster.get().filter(candidate => !candidate.remoteSource))
|
||
.then(name => {
|
||
queryClient.invalidateQueries({ queryKey: ROSTER_KEY })
|
||
host.notify({ kind: 'success', message: `Created ${name} — full copy of ${bot.name}` })
|
||
})
|
||
.catch(err => host.notifyError(err, 'Duplicate failed'))
|
||
},
|
||
children: 'Duplicate'
|
||
}),
|
||
jsx(ContextMenuSeparator, {}),
|
||
jsx(ContextMenuItem, {
|
||
onSelect: () => {
|
||
$selectedBot.set(bot.name)
|
||
|
||
if (typeof host.newChat === 'function') {
|
||
host.newChat(bot.name)
|
||
}
|
||
},
|
||
children: 'New chat with this agent'
|
||
}),
|
||
bot.is_default ? null : jsx(ContextMenuSeparator, {}),
|
||
bot.is_default
|
||
? null
|
||
: jsx(ContextMenuItem, {
|
||
onSelect: () => onDelete(bot),
|
||
variant: 'destructive',
|
||
children: 'Delete'
|
||
})
|
||
]
|
||
})
|
||
]
|
||
})
|
||
}
|
||
|
||
// ── model picker (provider/model dropdowns via model.options) ───────────────
|
||
|
||
function useModelOptions() {
|
||
return useQuery({
|
||
queryKey: [ID, 'model-options'],
|
||
queryFn: () => host.request('model.options', { include_unconfigured: true, explicit_only: false, refresh: true }),
|
||
staleTime: 120000,
|
||
retry: false
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Provider + model dropdowns from the gateway's configured inventory — the
|
||
* same data the core model picker shows. `value = {provider, model}`;
|
||
* onChange receives the merged patch.
|
||
*/
|
||
function ModelPicker({ value, onChange, placeholderModel = 'gateway default' }) {
|
||
const { data, isLoading, error } = useModelOptions()
|
||
|
||
// Hooks are ALWAYS declared up front, before any conditional return.
|
||
// Declaring them after a return trips React error #310.
|
||
const NONE = '__default__'
|
||
const CUSTOM = '__custom__'
|
||
const providers = (data?.providers || []).filter(p => p && p.slug)
|
||
const isKnown =
|
||
!value.provider || value.provider === NONE || providers.some(p => p.slug === value.provider)
|
||
const [useFreeText, setUseFreeText] = useState(!isKnown)
|
||
|
||
if (isLoading) {
|
||
return jsx('div', {
|
||
className: 'flex justify-center py-2',
|
||
children: jsx(GlyphSpinner, { spinner: 'breathe', className: 'text-(--ui-text-tertiary)' })
|
||
})
|
||
}
|
||
|
||
if (error || !providers.length) {
|
||
// Fallback: free text (older gateway or empty inventory).
|
||
return jsxs('div', {
|
||
style: { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' },
|
||
children: [
|
||
labeled(
|
||
'Provider',
|
||
jsx(Input, {
|
||
placeholder: 'omnirouter / 9router / nous \u2026',
|
||
value: value.provider,
|
||
onChange: event => onChange({ provider: event.target.value })
|
||
})
|
||
),
|
||
labeled(
|
||
'Model',
|
||
jsx(Input, {
|
||
placeholder: 'antigravity/gemini-3.6-flash-high',
|
||
value: value.model,
|
||
onChange: event => onChange({ model: event.target.value })
|
||
})
|
||
)
|
||
]
|
||
})
|
||
}
|
||
|
||
if (useFreeText) {
|
||
return jsxs('div', {
|
||
style: { display: 'flex', flexDirection: 'column', gap: '8px' },
|
||
children: [
|
||
jsxs('div', {
|
||
style: { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' },
|
||
children: [
|
||
labeled(
|
||
'Provider (Custom)',
|
||
jsx(Input, {
|
||
placeholder: 'e.g. omnirouter, inferx, 9router',
|
||
value: value.provider,
|
||
onChange: event => onChange({ provider: event.target.value })
|
||
})
|
||
),
|
||
labeled(
|
||
'Model (Custom)',
|
||
jsx(Input, {
|
||
placeholder: 'e.g. antigravity/gemini-3.6-flash-high',
|
||
value: value.model,
|
||
onChange: event => onChange({ model: event.target.value })
|
||
})
|
||
)
|
||
]
|
||
}),
|
||
jsx(Button, {
|
||
variant: 'ghost',
|
||
size: 'sm',
|
||
className: 'h-6 self-start text-xs text-(--ui-text-tertiary)',
|
||
onClick: () => setUseFreeText(false),
|
||
children: '← Back to dropdowns'
|
||
})
|
||
]
|
||
})
|
||
}
|
||
|
||
const activeProvider = providers.find(p => p.slug === value.provider) || null
|
||
const models = activeProvider
|
||
? (activeProvider.models || []).map(m => (typeof m === 'string' ? m : m.id || m.name || ''))
|
||
: []
|
||
|
||
return jsxs('div', {
|
||
style: { display: 'grid', gridTemplateColumns: '1fr 1.4fr', gap: '10px' },
|
||
children: [
|
||
labeled(
|
||
'Provider',
|
||
jsxs(Select, {
|
||
value: value.provider || NONE,
|
||
onValueChange: v => {
|
||
if (v === NONE) {
|
||
onChange({ provider: '', model: '' })
|
||
} else if (v === CUSTOM) {
|
||
setUseFreeText(true)
|
||
} else {
|
||
const prov = providers.find(p => p.slug === v)
|
||
const provModels = (prov?.models || []).map(m =>
|
||
typeof m === 'string' ? m : m.id || m.name || ''
|
||
)
|
||
const first = provModels[0] || ''
|
||
onChange({
|
||
provider: v,
|
||
model: prov && provModels.includes(value.model) ? value.model : first
|
||
})
|
||
}
|
||
},
|
||
children: [
|
||
jsx(SelectTrigger, { className: 'h-8 rounded-md', children: jsx(SelectValue, {}) }),
|
||
jsxs(SelectContent, {
|
||
children: [
|
||
jsx(SelectItem, { value: NONE, children: 'Inherit (launch profile)' }),
|
||
...providers.map(p =>
|
||
jsx(
|
||
SelectItem,
|
||
{ value: p.slug, children: p.name ? `${p.name} (${p.slug})` : p.slug },
|
||
p.slug
|
||
)
|
||
),
|
||
jsx(SelectItem, { value: CUSTOM, children: '✏️ Enter manually…' })
|
||
]
|
||
})
|
||
]
|
||
})
|
||
),
|
||
labeled(
|
||
'Model',
|
||
activeProvider && models.length > 0
|
||
? jsxs(Select, {
|
||
value: value.model || (models[0] ?? ''),
|
||
onValueChange: v => onChange({ model: v }),
|
||
children: [
|
||
jsx(SelectTrigger, { className: 'h-8 rounded-md', children: jsx(SelectValue, {}) }),
|
||
jsx(SelectContent, {
|
||
children: models.map(m => jsx(SelectItem, { value: m, children: m }, m))
|
||
})
|
||
]
|
||
})
|
||
: jsx(Input, {
|
||
placeholder: placeholderModel || 'e.g. model name',
|
||
value: value.model,
|
||
onChange: event => onChange({ model: event.target.value })
|
||
})
|
||
)
|
||
]
|
||
})
|
||
}
|
||
|
||
// ── advanced profile config (skills / toolsets / model / SOUL) ──────────────
|
||
//
|
||
// Shared by Edit Profile and New Agent (edit mode only for skills/toolsets —
|
||
// a not-yet-created profile has nothing installed to toggle). Backed by
|
||
// profiles.describe / profiles.configure; feature-detects older gateways.
|
||
|
||
function CheckList({ items, onToggle, columns = 2 }) {
|
||
return jsx('div', {
|
||
style: {
|
||
display: 'grid',
|
||
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
|
||
gap: '2px 12px'
|
||
},
|
||
children: items.map(item =>
|
||
jsxs(
|
||
'label',
|
||
{
|
||
className: 'flex min-w-0 cursor-pointer items-center gap-1.5 py-0.5 text-xs text-(--ui-text-secondary)',
|
||
title: item.description || item.name,
|
||
children: [
|
||
jsx(Checkbox, {
|
||
checked: item.enabled,
|
||
onCheckedChange: value => onToggle(item.name, Boolean(value))
|
||
}),
|
||
jsx('span', { className: 'truncate', children: item.name }),
|
||
item.tool_count
|
||
? jsx('span', {
|
||
className: 'shrink-0 text-[0.6rem] text-(--ui-text-quaternary)',
|
||
children: `${item.tool_count}`
|
||
})
|
||
: null
|
||
]
|
||
},
|
||
item.name
|
||
)
|
||
)
|
||
})
|
||
}
|
||
|
||
function AdvancedProfileConfig({ bot, state, setState }) {
|
||
const [loaded, setLoaded] = useState(false)
|
||
const [unsupported, setUnsupported] = useState(false)
|
||
const [skillFilter, setSkillFilter] = useState('')
|
||
|
||
if (!loaded) {
|
||
setLoaded(true)
|
||
Promise.all([
|
||
host.request('profiles.describe', { name: bot }),
|
||
host.request('mcp.catalog', { profile: bot }).catch(() => null)
|
||
])
|
||
.then(([res, cat]) => {
|
||
const configured = res.mcp_servers || []
|
||
const have = new Set(configured.map(m => m.name))
|
||
const catalog = ((cat && cat.servers) || []).filter(s => !have.has(s.name))
|
||
setState(prev => ({
|
||
...prev,
|
||
provider: res.model?.provider || '',
|
||
model: res.model?.default || '',
|
||
soul: res.soul || '',
|
||
skills: res.skills || [],
|
||
toolsets: res.toolsets || [],
|
||
mcp: [
|
||
...configured.map(m => ({ ...m, enabled: m.enabled !== false })),
|
||
...catalog.map(s => ({
|
||
name: s.name,
|
||
enabled: false,
|
||
fromCatalog: true,
|
||
installed: s.installed,
|
||
auth: s.auth,
|
||
requires: s.requires || [],
|
||
description: s.description || ''
|
||
}))
|
||
],
|
||
loaded: true
|
||
}))
|
||
})
|
||
.catch(() => setUnsupported(true))
|
||
}
|
||
|
||
if (unsupported) {
|
||
return jsx('div', {
|
||
className: 'px-2 py-3 text-center text-xs text-(--ui-text-tertiary)',
|
||
children: 'Full configuration needs a newer gateway (restart it after updating Hermes).'
|
||
})
|
||
}
|
||
|
||
if (!state.loaded) {
|
||
return jsx('div', {
|
||
className: 'flex justify-center py-4',
|
||
children: jsx(GlyphSpinner, { spinner: 'breathe', className: 'text-(--ui-text-tertiary)' })
|
||
})
|
||
}
|
||
|
||
const visibleSkills = skillFilter.trim()
|
||
? state.skills.filter(s => s.name.toLowerCase().includes(skillFilter.trim().toLowerCase()))
|
||
: state.skills
|
||
|
||
const toggleSkill = (name, enabled) =>
|
||
setState(prev => ({
|
||
...prev,
|
||
dirtySkills: true,
|
||
skills: prev.skills.map(s => (s.name === name ? { ...s, enabled } : s))
|
||
}))
|
||
|
||
const toggleToolset = (name, enabled) =>
|
||
setState(prev => ({
|
||
...prev,
|
||
dirtyToolsets: true,
|
||
toolsets: prev.toolsets.map(t => (t.name === name ? { ...t, enabled } : t))
|
||
}))
|
||
|
||
const toggleMcp = (name, enabled) =>
|
||
setState(prev => ({
|
||
...prev,
|
||
dirtyMcp: true,
|
||
mcp: (prev.mcp || []).map(m => (m.name === name ? { ...m, enabled } : m))
|
||
}))
|
||
|
||
const enabledSkills = state.skills.filter(s => s.enabled).length
|
||
const enabledToolsets = state.toolsets.filter(t => t.enabled).length
|
||
const mcpList = state.mcp || []
|
||
const enabledMcp = mcpList.filter(m => m.enabled).length
|
||
|
||
// Newer desktop builds export the WHOLE core Capabilities surface
|
||
// (hermes-agent#87317): Skills (installed list + one-click hub installs +
|
||
// full-skill detail), Tools (per-toolset config), and MCP — pinned to this
|
||
// bot via fixedProfile, tab state kept out of the page router via embedded.
|
||
// Render THAT instead of the checkbox stand-ins; writes go straight to the
|
||
// bot's backend, so the dirty-section staging below only carries
|
||
// model + SOUL on these builds. Older builds keep the full checklist UI.
|
||
if (SkillsView) {
|
||
return jsxs('div', {
|
||
className: 'grid gap-4',
|
||
children: [
|
||
jsx(ModelPicker, {
|
||
value: { provider: state.provider, model: state.model },
|
||
onChange: patch => setState(prev => ({ ...prev, dirtyModel: true, ...patch }))
|
||
}),
|
||
labeled(
|
||
'Capabilities (applies immediately — skills, tools, MCP)',
|
||
jsx('div', {
|
||
className: 'overflow-hidden rounded-md border border-(--ui-stroke-secondary)',
|
||
style: { height: 460, minHeight: 300, resize: 'vertical', overflow: 'auto' },
|
||
children: jsx(SkillsView, { embedded: true, fixedProfile: bot })
|
||
})
|
||
),
|
||
labeled(
|
||
'SOUL.md (persona + agent-messaging protocol)',
|
||
jsx(Textarea, {
|
||
className: 'min-h-28 font-mono text-xs leading-5',
|
||
value: state.soul,
|
||
onChange: event => setState(prev => ({ ...prev, dirtySoul: true, soul: event.target.value }))
|
||
})
|
||
)
|
||
]
|
||
})
|
||
}
|
||
|
||
return jsxs('div', {
|
||
className: 'grid gap-4',
|
||
children: [
|
||
jsx(ModelPicker, {
|
||
value: { provider: state.provider, model: state.model },
|
||
onChange: patch => setState(prev => ({ ...prev, dirtyModel: true, ...patch }))
|
||
}),
|
||
labeled(
|
||
`Skills (${enabledSkills}/${state.skills.length} enabled)`,
|
||
jsxs('div', {
|
||
className: 'grid gap-1.5 rounded-md border border-(--ui-stroke-secondary) p-2',
|
||
children: [
|
||
jsx(Input, {
|
||
className: 'h-7 text-xs',
|
||
placeholder: 'Filter skills…',
|
||
value: skillFilter,
|
||
onChange: event => setSkillFilter(event.target.value)
|
||
}),
|
||
jsx(ScrollArea, {
|
||
className: 'hermes-scroll-cap',
|
||
style: { maxHeight: 180 },
|
||
children: jsx(CheckList, { items: visibleSkills, onToggle: toggleSkill, columns: 2 })
|
||
}),
|
||
jsx(HubSkillsSection, {
|
||
forProfile: bot,
|
||
onInstalled: name =>
|
||
setState(prev =>
|
||
prev.skills.some(s => s.name === name)
|
||
? prev
|
||
: { ...prev, skills: [...prev.skills, { name, enabled: true }] }
|
||
)
|
||
})
|
||
]
|
||
})
|
||
),
|
||
labeled(
|
||
`Toolsets (${enabledToolsets}/${state.toolsets.length} enabled — unchecking all restores the default)`,
|
||
jsx('div', {
|
||
className: 'rounded-md border border-(--ui-stroke-secondary) p-2',
|
||
children: jsx(ScrollArea, {
|
||
className: 'hermes-scroll-cap',
|
||
style: { maxHeight: 320 },
|
||
children: jsx('div', {
|
||
className: 'grid gap-1.5',
|
||
children: state.toolsets.map(tset =>
|
||
jsxs(
|
||
'div',
|
||
{
|
||
className: 'rounded-md border border-(--ui-stroke-secondary) p-2',
|
||
children: [
|
||
jsxs('label', {
|
||
className: 'flex items-center gap-2 text-xs font-medium text-(--ui-text-secondary)',
|
||
children: [
|
||
jsx(Checkbox, {
|
||
checked: !!tset.enabled,
|
||
onCheckedChange: value => toggleToolset(tset.name, Boolean(value))
|
||
}),
|
||
jsx('span', { children: tset.name })
|
||
]
|
||
}),
|
||
// The REAL per-toolset config (env vars / API keys / model
|
||
// picker / post-setup), scoped to THIS bot's profile, when
|
||
// the desktop build exposes it. Older builds: just the toggle.
|
||
ToolsetConfigPanel
|
||
? jsx('div', {
|
||
className: 'mt-1.5 border-t border-(--ui-stroke-secondary) pt-1.5',
|
||
children: jsx(ToolsetConfigPanel, { toolset: tset.name, profile: bot })
|
||
})
|
||
: null
|
||
]
|
||
},
|
||
tset.name
|
||
)
|
||
)
|
||
})
|
||
})
|
||
})
|
||
),
|
||
labeled(
|
||
'MCP servers',
|
||
jsx('div', {
|
||
className: 'overflow-hidden rounded-md border border-(--ui-stroke-secondary)',
|
||
// The REAL MCP tab core Settings renders — per-server enable + OAuth
|
||
// sign-in + API-key setup + live probes — scoped to this bot's profile.
|
||
// Feature-detected: older desktop builds without the SDK export fall
|
||
// back to the plugin's own checkbox list + inline setup buttons.
|
||
children: McpTab && typeof host.getGateway === 'function'
|
||
? jsx('div', {
|
||
style: { minHeight: 220, maxHeight: 360 },
|
||
children: jsx(McpTab, { gateway: host.getGateway(), profile: bot })
|
||
})
|
||
: mcpList.length === 0
|
||
? jsx('div', {
|
||
className: 'px-1 py-2 text-center text-xs text-(--ui-text-tertiary)',
|
||
children: 'No MCP servers configured or in the catalog.'
|
||
})
|
||
: jsx(ScrollArea, {
|
||
className: 'hermes-scroll-cap',
|
||
style: { maxHeight: 180 },
|
||
children: jsx('div', {
|
||
className: 'grid gap-1 p-2',
|
||
children: mcpList.map(m => {
|
||
const needsSetup = m.fromCatalog && !m.installed && ((m.requires || []).length > 0 || (m.auth || '').toLowerCase() === 'oauth')
|
||
return jsxs(
|
||
'label',
|
||
{
|
||
className: 'flex items-start gap-2 text-xs text-(--ui-text-secondary)',
|
||
children: [
|
||
jsx(Checkbox, {
|
||
checked: !!m.enabled,
|
||
disabled: needsSetup,
|
||
onCheckedChange: value => toggleMcp(m.name, Boolean(value))
|
||
}),
|
||
jsxs('span', {
|
||
className: 'min-w-0',
|
||
children: [
|
||
jsx('span', { children: m.name }),
|
||
m.fromCatalog && !needsSetup
|
||
? jsx('span', {
|
||
className: 'ml-1.5 text-[0.65rem] text-(--ui-text-quaternary)',
|
||
children: m.installed ? 'catalog · installed' : 'catalog'
|
||
})
|
||
: null,
|
||
needsSetup
|
||
? jsx(McpSetupButton, {
|
||
profile: bot,
|
||
entry: m,
|
||
onDone: () => toggleMcp(m.name, true)
|
||
})
|
||
: null,
|
||
m.description
|
||
? jsx('div', {
|
||
className: 'truncate text-[0.65rem] leading-4 text-(--ui-text-quaternary)',
|
||
children: m.description
|
||
})
|
||
: null
|
||
]
|
||
})
|
||
]
|
||
},
|
||
m.name
|
||
)
|
||
})
|
||
})
|
||
})
|
||
})
|
||
),
|
||
labeled(
|
||
'SOUL.md (persona + agent-messaging protocol)',
|
||
jsx(Textarea, {
|
||
className: 'min-h-28 font-mono text-xs leading-5',
|
||
value: state.soul,
|
||
onChange: event => setState(prev => ({ ...prev, dirtySoul: true, soul: event.target.value }))
|
||
})
|
||
)
|
||
]
|
||
})
|
||
}
|
||
|
||
// ── skills hub section: the REAL hub page (docs) embedded as a picker ──────
|
||
// https://hermes-agent.nousresearch.com/docs/skills?embed=picker hides the
|
||
// docs chrome and adds "+ Add to this Agent" per card, posting
|
||
// {type: 'hermes-skill-pick', ...} to us (hermes-agent#86243). We validate
|
||
// the origin, install via skills.manage, and bubble onInstalled so the
|
||
// checklist above gains the row. Search-box fallback kept for offline use.
|
||
|
||
const HUB_ORIGIN = 'https://hermes-agent.nousresearch.com'
|
||
const HUB_PICKER_URL = HUB_ORIGIN + '/docs/skills?embed=picker'
|
||
|
||
function HubSkillsSection({ forProfile, onInstalled }) {
|
||
const [query, setQuery] = useState('')
|
||
const [results, setResults] = useState(null)
|
||
const [searching, setSearching] = useState(false)
|
||
const [installing, setInstalling] = useState(null)
|
||
const [installed, setInstalled] = useState({})
|
||
const [browseHub, setBrowseHub] = useState(false)
|
||
const installRef = useRef(null)
|
||
const frameRef = useRef(null)
|
||
|
||
// Picker messages from the embedded hub page. Origin- AND source-checked —
|
||
// only OUR frame may ask for an install (the hub origin alone would let any
|
||
// other window on it, e.g. an OAuth popup, trigger installs too); installs
|
||
// route through the same install() the search fallback uses.
|
||
useEffect(() => {
|
||
if (!browseHub) {
|
||
return undefined
|
||
}
|
||
|
||
const onMessage = event => {
|
||
if (event.origin !== HUB_ORIGIN) {
|
||
return
|
||
}
|
||
|
||
if (!frameRef.current || event.source !== frameRef.current.contentWindow) {
|
||
return
|
||
}
|
||
|
||
const data = event.data
|
||
|
||
if (!data || data.type !== 'hermes-skill-pick' || !data.name) {
|
||
return
|
||
}
|
||
|
||
const target = String(data.identifier || data.name)
|
||
|
||
// Skill identifiers are slugs / owner-name paths — keep anything
|
||
// else out of skills.manage.
|
||
if (!/^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(target)) {
|
||
return
|
||
}
|
||
|
||
if (installRef.current) {
|
||
void installRef.current(target, String(data.name))
|
||
}
|
||
}
|
||
|
||
window.addEventListener('message', onMessage)
|
||
|
||
return () => window.removeEventListener('message', onMessage)
|
||
}, [browseHub])
|
||
|
||
const search = async () => {
|
||
const q = query.trim()
|
||
|
||
if (!q || searching) {
|
||
return
|
||
}
|
||
|
||
setSearching(true)
|
||
setResults(null)
|
||
|
||
try {
|
||
const res = await host.request('skills.manage', { action: 'search', query: q })
|
||
setResults(res.results || [])
|
||
} catch {
|
||
setResults([])
|
||
} finally {
|
||
setSearching(false)
|
||
}
|
||
}
|
||
|
||
const install = async (name, displayName) => {
|
||
const label = displayName || name
|
||
|
||
if (installing) {
|
||
return
|
||
}
|
||
|
||
setInstalling(label)
|
||
|
||
try {
|
||
// With forProfile the install lands in that bot's skills dir
|
||
// (gateway skills.manage profile scoping); null = launch profile,
|
||
// which is right at create time — the new bot clones/copies from it.
|
||
await host.request('skills.manage', {
|
||
action: 'install',
|
||
query: name,
|
||
...(forProfile ? { profile: forProfile } : {})
|
||
})
|
||
setInstalled(prev => ({ ...prev, [label]: true }))
|
||
host.notify({ kind: 'success', message: `Skill "${label}" installed` })
|
||
|
||
if (typeof onInstalled === 'function') {
|
||
onInstalled(label)
|
||
}
|
||
} catch (err) {
|
||
host.notifyError(err, `Installing "${label}" failed`)
|
||
} finally {
|
||
setInstalling(null)
|
||
}
|
||
}
|
||
|
||
installRef.current = install
|
||
|
||
return jsxs('div', {
|
||
className: 'grid gap-1.5 border-t border-(--ui-stroke-secondary) pt-2',
|
||
children: [
|
||
jsxs('div', {
|
||
className: 'flex items-baseline justify-between gap-2',
|
||
children: [
|
||
jsx('div', {
|
||
className: 'text-[0.7rem] font-medium text-(--ui-text-secondary)',
|
||
children: 'Skills Hub'
|
||
}),
|
||
jsx('button', {
|
||
type: 'button',
|
||
className: 'text-[0.65rem] text-(--ui-text-quaternary) hover:text-(--ui-text-secondary)',
|
||
onClick: () => setBrowseHub(v => !v),
|
||
children: browseHub ? 'hide the hub browser' : 'browse the full hub ▾'
|
||
})
|
||
]
|
||
}),
|
||
browseHub
|
||
? jsxs('div', {
|
||
className: 'grid gap-1',
|
||
children: [
|
||
// Resizable viewport: native CSS resize handle (bottom-right
|
||
// corner) lets the user drag it larger/smaller. The iframe
|
||
// inside is rendered oversized and scaled DOWN (133% × 0.75)
|
||
// so the hub page starts zoomed out — we can't style the
|
||
// cross-origin page itself, but scaling the frame is ours.
|
||
jsx('div', {
|
||
style: {
|
||
width: '100%',
|
||
height: 560,
|
||
minHeight: 240,
|
||
minWidth: 320,
|
||
maxWidth: '100%',
|
||
resize: 'both',
|
||
overflow: 'hidden',
|
||
border: '1px solid var(--ui-stroke-secondary)',
|
||
borderRadius: 8,
|
||
position: 'relative'
|
||
},
|
||
children: jsx('iframe', {
|
||
src: HUB_PICKER_URL,
|
||
title: 'Hermes Skills Hub',
|
||
ref: frameRef,
|
||
style: {
|
||
width: '133.34%',
|
||
height: '133.34%',
|
||
border: 'none',
|
||
background: 'transparent',
|
||
transform: 'scale(0.75)',
|
||
transformOrigin: 'top left'
|
||
},
|
||
sandbox: 'allow-scripts allow-same-origin'
|
||
})
|
||
}),
|
||
jsx('div', {
|
||
className: 'px-1 text-[0.65rem] leading-4 text-(--ui-text-quaternary)',
|
||
children:
|
||
installing
|
||
? `Installing "${installing}"…`
|
||
: 'Hit "+ Add to this Agent" on any skill — it installs and appears in the list above. Drag the corner to resize.'
|
||
})
|
||
]
|
||
})
|
||
: null,
|
||
jsxs('div', {
|
||
className: 'flex gap-1.5',
|
||
children: [
|
||
jsx(Input, {
|
||
className: 'h-7 flex-1 text-xs',
|
||
placeholder: 'Search the hub (community + well-known sources)…',
|
||
value: query,
|
||
onChange: event => setQuery(event.target.value),
|
||
onKeyDown: event => {
|
||
if (event.key === 'Enter') {
|
||
event.preventDefault()
|
||
void search()
|
||
}
|
||
}
|
||
}),
|
||
jsx(Button, {
|
||
size: 'sm',
|
||
variant: 'secondary',
|
||
disabled: searching || !query.trim(),
|
||
onClick: () => void search(),
|
||
children: searching ? 'Searching…' : 'Search'
|
||
})
|
||
]
|
||
}),
|
||
searching
|
||
? jsx('div', {
|
||
className: 'px-1 text-[0.65rem] text-(--ui-text-quaternary)',
|
||
children: 'Searching community + well-known sources — can take ~10s…'
|
||
})
|
||
: null,
|
||
results === null
|
||
? null
|
||
: results.length === 0
|
||
? jsx('div', {
|
||
className: 'px-1 py-1.5 text-[0.7rem] text-(--ui-text-quaternary)',
|
||
children: 'No hub skills matched.'
|
||
})
|
||
: jsx(ScrollArea, {
|
||
className: 'hermes-scroll-cap',
|
||
style: { maxHeight: 150 },
|
||
children: jsx('div', {
|
||
className: 'grid gap-1',
|
||
children: results.map(r =>
|
||
jsxs(
|
||
'div',
|
||
{
|
||
className: 'flex items-center gap-2 text-xs',
|
||
children: [
|
||
jsxs('div', {
|
||
className: 'min-w-0 flex-1',
|
||
children: [
|
||
jsx('div', { className: 'truncate font-medium', children: r.name }),
|
||
r.description
|
||
? jsx('div', {
|
||
className: 'truncate text-[0.65rem] text-(--ui-text-quaternary)',
|
||
children: r.description
|
||
})
|
||
: null
|
||
]
|
||
}),
|
||
installed[r.name]
|
||
? jsx('span', {
|
||
className: 'shrink-0 text-[0.65rem] text-(--ui-text-tertiary)',
|
||
children: '✓ added'
|
||
})
|
||
: jsx(Button, {
|
||
size: 'sm',
|
||
variant: 'ghost',
|
||
className: 'shrink-0 px-2 font-semibold',
|
||
disabled: installing !== null,
|
||
title: `Install "${r.name}" and add it to the list above`,
|
||
onClick: () => void install(r.name),
|
||
children: installing === r.name ? '…' : '+'
|
||
})
|
||
]
|
||
},
|
||
r.name
|
||
)
|
||
)
|
||
})
|
||
})
|
||
]
|
||
})
|
||
}
|
||
|
||
function emptyAdvancedState() {
|
||
return {
|
||
loaded: false,
|
||
provider: '',
|
||
model: '',
|
||
soul: '',
|
||
skills: [],
|
||
toolsets: [],
|
||
mcp: [],
|
||
dirtyModel: false,
|
||
dirtySoul: false,
|
||
dirtySkills: false,
|
||
dirtyToolsets: false,
|
||
dirtyMcp: false
|
||
}
|
||
}
|
||
|
||
/** Persist only the dirty sections of the advanced editor. */
|
||
async function applyAdvancedConfig(bot, state) {
|
||
const payload = { name: bot }
|
||
const applied = {}
|
||
|
||
if (state.dirtySoul) {
|
||
payload.soul = ensureMessagingProtocol(state.soul, bot, $lastRoster.get())
|
||
}
|
||
|
||
if (state.dirtyModel) {
|
||
const model = state.model.trim()
|
||
const provider = state.provider.trim()
|
||
|
||
if (model && provider) {
|
||
payload.model = model
|
||
payload.provider = provider
|
||
} else if (!model && !provider) {
|
||
try {
|
||
const result = await host.request('cli.exec', {
|
||
argv: ['--profile', bot, 'config', 'unset', 'model']
|
||
})
|
||
applied.model = result?.blocked !== true && result?.code === 0
|
||
} catch {
|
||
applied.model = false
|
||
}
|
||
} else {
|
||
applied.model = false
|
||
}
|
||
}
|
||
|
||
if (state.dirtySkills) {
|
||
payload.disabled_skills = state.skills.filter(s => !s.enabled).map(s => s.name)
|
||
}
|
||
|
||
if (state.dirtyToolsets) {
|
||
const all = state.toolsets.length
|
||
const enabled = state.toolsets.filter(t => t.enabled)
|
||
// All enabled (or none) = clear the pin; otherwise pin the checked set.
|
||
payload.enabled_toolsets = enabled.length === all || enabled.length === 0 ? [] : enabled.map(t => t.name)
|
||
}
|
||
|
||
if (state.dirtyMcp) {
|
||
payload.enabled_mcp_servers = (state.mcp || []).filter(m => m.enabled).map(m => m.name)
|
||
}
|
||
|
||
if (Object.keys(payload).length === 1) {
|
||
return { ok: Object.values(applied).every(Boolean), applied }
|
||
}
|
||
|
||
const result = await host.request('profiles.configure', payload)
|
||
const merged = { ...applied, ...(result?.applied || {}) }
|
||
|
||
return { ...result, ok: Object.values(merged).every(Boolean), applied: merged }
|
||
}
|
||
|
||
// ── edit profile dialog ──────────────────────────────────────────────────────
|
||
|
||
function labeled(label, control) {
|
||
return jsxs('div', {
|
||
className: 'grid gap-1.5',
|
||
children: [
|
||
jsx('label', {
|
||
className: 'text-xs font-medium text-(--ui-text-secondary)',
|
||
children: label
|
||
}),
|
||
control
|
||
]
|
||
})
|
||
}
|
||
|
||
function EditProfileDialog({ bot, open, onClose }) {
|
||
const metaAll = useValue($botMeta)
|
||
const meta = bot ? metaAll[bot.name] : null
|
||
const appearance = bot ? botAppearance(bot.name, meta) : { shape: 'circle', color: AVATAR_COLORS[3] }
|
||
const [shape, setShape] = useState(appearance.shape)
|
||
const [color, setColor] = useState(appearance.color)
|
||
const [image, setImage] = useState(appearance.image)
|
||
const [title, setTitle] = useState(meta?.title || '')
|
||
const [description, setDescription] = useState(bot?.description || '')
|
||
const [busy, setBusy] = useState(false)
|
||
const [advanced, setAdvanced] = useState(false)
|
||
const [adv, setAdv] = useState(emptyAdvancedState())
|
||
|
||
// Re-seed local state each time a different bot opens the dialog.
|
||
const [seedKey, setSeedKey] = useState(null)
|
||
const currentKey = bot ? `${bot.name}:${open}` : null
|
||
if (currentKey !== seedKey) {
|
||
setSeedKey(currentKey)
|
||
if (bot && open) {
|
||
setShape(appearance.shape)
|
||
setColor(appearance.color)
|
||
setImage(appearance.image)
|
||
setTitle(meta?.title || '')
|
||
setDescription(bot.description || '')
|
||
setBusy(false)
|
||
setAdvanced(false)
|
||
setAdv(emptyAdvancedState())
|
||
}
|
||
}
|
||
|
||
if (!bot) {
|
||
return null
|
||
}
|
||
|
||
const submit = async () => {
|
||
if (busy) {
|
||
return
|
||
}
|
||
|
||
setBusy(true)
|
||
let advancedFailed = false
|
||
const persistence = await saveBotMeta(bot.name, {
|
||
shape,
|
||
color,
|
||
image,
|
||
imageKind: image ? 'photo' : 'shape',
|
||
title: title.trim(),
|
||
custom: true
|
||
})
|
||
// Only an explicit remote failure is an error — 'unsupported' is the
|
||
// documented older-gateway fallback (local wins, silently), and toasting
|
||
// it would flag every save on every legacy setup forever.
|
||
const lookFailed = persistence.serverOutcome === 'failed'
|
||
|
||
if (lookFailed) {
|
||
host.notify({ kind: 'error', message: 'Saved look locally; remote persistence failed' })
|
||
}
|
||
if (persistence.serverOutcome === 'persisted') {
|
||
queryClient.invalidateQueries({ queryKey: ROSTER_KEY })
|
||
}
|
||
|
||
const desc = description.trim()
|
||
if (desc !== (bot.description || '').trim()) {
|
||
try {
|
||
await host.request('cli.exec', {
|
||
argv: ['profile', 'describe', bot.name, '--text', desc]
|
||
})
|
||
queryClient.invalidateQueries({ queryKey: ROSTER_KEY })
|
||
} catch (err) {
|
||
host.notifyError(err, 'Saved look locally; description update failed')
|
||
}
|
||
}
|
||
|
||
if (adv.loaded && (adv.dirtyModel || adv.dirtySoul || adv.dirtySkills || adv.dirtyToolsets || adv.dirtyMcp)) {
|
||
try {
|
||
const res = await applyAdvancedConfig(bot.name, adv)
|
||
const failed = Object.entries(res?.applied || {}).filter(([, ok]) => !ok)
|
||
|
||
if (failed.length) {
|
||
advancedFailed = true
|
||
host.notify({ kind: 'error', message: `Some sections failed: ${failed.map(([k]) => k).join(', ')}` })
|
||
}
|
||
} catch (err) {
|
||
advancedFailed = true
|
||
host.notifyError(err, 'Advanced configuration failed')
|
||
}
|
||
}
|
||
|
||
if (!advancedFailed && !lookFailed) {
|
||
host.notify({ kind: 'success', message: `${displayName(bot, { title })} updated` })
|
||
}
|
||
setBusy(false)
|
||
onClose()
|
||
}
|
||
|
||
return jsx(Dialog, {
|
||
open,
|
||
onOpenChange: value => !value && !busy && onClose(),
|
||
children: jsxs(DialogContent, {
|
||
className: advanced ? 'max-w-3xl' : 'max-w-sm',
|
||
// Same resizable-window treatment as the create dialog.
|
||
style: advanced
|
||
? { resize: 'both', overflow: 'auto', minWidth: 420, minHeight: 360, maxWidth: '95vw', maxHeight: '90vh' }
|
||
: undefined,
|
||
children: [
|
||
jsxs(DialogHeader, {
|
||
children: [
|
||
jsx(DialogTitle, { children: 'Edit Profile' }),
|
||
jsx(DialogDescription, { children: `Appearance and role for ${displayName(bot, null)} (${bot.name}).` })
|
||
]
|
||
}),
|
||
jsxs('div', {
|
||
className: 'grid gap-4',
|
||
children: [
|
||
jsx('div', {
|
||
className: 'flex justify-center py-1',
|
||
children: jsx(BotFace, { shape, color, image, size: 64, name: bot.name })
|
||
}),
|
||
jsx(AvatarPicker, {
|
||
shape,
|
||
color,
|
||
image,
|
||
onShape: setShape,
|
||
onColor: setColor,
|
||
onImage: setImage,
|
||
generateSeed: { name: bot.name, title, description }
|
||
}),
|
||
labeled(
|
||
'Title',
|
||
jsx(Input, {
|
||
placeholder: displayName(bot, null),
|
||
value: title,
|
||
onChange: event => setTitle(event.target.value)
|
||
})
|
||
),
|
||
labeled(
|
||
'Description',
|
||
jsx(Textarea, {
|
||
className: 'min-h-16',
|
||
placeholder: 'What should this agent help with?',
|
||
value: description,
|
||
onChange: event => setDescription(event.target.value)
|
||
})
|
||
),
|
||
jsxs('button', {
|
||
type: 'button',
|
||
className:
|
||
'flex items-center gap-1 text-xs font-medium text-(--ui-text-tertiary) hover:text-(--ui-text-secondary)',
|
||
onClick: () => setAdvanced(v => !v),
|
||
children: [
|
||
jsx(Codicon, { name: advanced ? 'chevron-down' : 'chevron-right', className: 'text-[0.8rem]' }),
|
||
'Advanced — model, skills, toolsets, SOUL.md'
|
||
]
|
||
}),
|
||
advanced
|
||
? jsx('div', {
|
||
className: 'rounded-md border border-(--ui-stroke-secondary) p-3',
|
||
children: jsx(AdvancedProfileConfig, { bot: bot.name, state: adv, setState: setAdv })
|
||
})
|
||
: null
|
||
]
|
||
}),
|
||
jsxs(DialogFooter, {
|
||
children: [
|
||
jsx(Button, { variant: 'ghost', disabled: busy, onClick: onClose, children: 'Cancel' }),
|
||
jsx(Button, { disabled: busy, onClick: submit, children: busy ? 'Saving…' : 'Save' })
|
||
]
|
||
})
|
||
]
|
||
})
|
||
})
|
||
}
|
||
|
||
// ── create dialog ────────────────────────────────────────────────────────────
|
||
|
||
function CreateAgentDialog({ open, onClose, roster }) {
|
||
const [name, setName] = useState('')
|
||
// Create mode: the profile is created LAZILY. Capability toggles are staged in
|
||
// component state; the profile is materialized either on Create (submit) or on
|
||
// the first MCP credential setup (ensureAgentCreated), whichever comes first —
|
||
// so OAuth / API-key setup works DURING creation, not only after in Edit.
|
||
const createdRef = useRef(null)
|
||
// In-flight profiles.create shared across concurrent triggers (Create
|
||
// button + MCP setup buttons). Distinct from createdRef on purpose:
|
||
// createdRef must stay a slug string for its sibling consumers.
|
||
const flightRef = useRef(null)
|
||
const [title, setTitle] = useState('')
|
||
const [description, setDescription] = useState('')
|
||
// Default shapes mode: deterministic blob face drawn from the agent's name
|
||
// (falls back to the legacy shape vocabulary on older SDKs).
|
||
const [shape, setShape] = useState(blobatarSvg ? 'blobatar' : 'circle')
|
||
const [color, setColor] = useState(AVATAR_COLORS[3])
|
||
const [image, setImage] = useState(null)
|
||
const [advanced, setAdvanced] = useState(false)
|
||
const [cloneFrom, setCloneFrom] = useState('default')
|
||
const [model, setModel] = useState('')
|
||
const [provider, setProvider] = useState('')
|
||
const [soul, setSoul] = useState('')
|
||
const [noSkills, setNoSkills] = useState(false)
|
||
const [shareAuth, setShareAuth] = useState(true)
|
||
const [advTab, setAdvTab] = useState('general')
|
||
// Where the profile is created: '' = the active gateway (unchanged default),
|
||
// else a registry connection id — the profiles.create lands on THAT
|
||
// machine's backend via host.requestProfile, no gateway switch. Only
|
||
// rendered when the desktop has a multi-connection registry.
|
||
const [targetConnection, setTargetConnection] = useState('')
|
||
const [connections, setConnections] = useState(null)
|
||
|
||
useEffect(() => {
|
||
if (!open || connections !== null || typeof host.connections !== 'function' || typeof host.requestProfile !== 'function') {
|
||
return
|
||
}
|
||
|
||
host
|
||
.connections()
|
||
.then(value => setConnections(Array.isArray(value) ? value : []))
|
||
.catch(() => setConnections([]))
|
||
}, [open, connections])
|
||
|
||
const activeConnectionId = String(host.state?.connectionId?.get?.() || '').trim()
|
||
// Remote target = an explicitly picked registry connection that is not the
|
||
// one this window is already on.
|
||
const remoteTarget = Boolean(targetConnection) && targetConnection !== (activeConnectionId || 'local')
|
||
const targetLabel = remoteTarget
|
||
? (connections || []).find(c => c.id === targetConnection)?.label || targetConnection
|
||
: ''
|
||
|
||
/** Gateway RPC on the create target: the picked connection's default
|
||
* backend for remote targets, the active gateway otherwise. */
|
||
const requestForTarget = (method, params = {}) =>
|
||
remoteTarget
|
||
? host.requestProfile(
|
||
{ connectionId: targetConnection, mode: 'remote', profile: 'default', targetProfile: 'default' },
|
||
method,
|
||
params
|
||
)
|
||
: host.request(method, params)
|
||
|
||
// Set once ensureAgentCreated() materializes the profile for the live
|
||
// Capabilities tab (SkillsView needs a real backend to point at). State —
|
||
// not just createdRef — because the render must flip when it lands.
|
||
const [createdForCaps, setCreatedForCaps] = useState(null)
|
||
const [caps, setCaps] = useState(null)
|
||
const [capsFailed, setCapsFailed] = useState(false)
|
||
const [dirtyCaps, setDirtyCaps] = useState({ skills: false, toolsets: false, mcp: false })
|
||
const [capFilter, setCapFilter] = useState('')
|
||
const [busy, setBusy] = useState(false)
|
||
const [error, setError] = useState(null)
|
||
|
||
const slug = slugify(name)
|
||
const valid = slug.length > 0 && NAME_RE.test(slug)
|
||
// Once the draft profile is materialized (Capabilities tab / MCP setup) it
|
||
// shows up in the roster — its OWN slug must not read as "taken".
|
||
// A remote-target create is gated by the TARGET machine's roster: a local
|
||
// name clash is fine there, and the remote's own duplicate check rejects
|
||
// real collisions at profiles.create time.
|
||
const taken = remoteTarget
|
||
? roster.some(b => b.remoteSource && b.connectionId === targetConnection && b.name === slug && b.name !== createdRef.current)
|
||
: roster.some(b => !b.remoteSource && b.name === slug && b.name !== createdRef.current)
|
||
|
||
// Draft semantics for the lazily-created profile: opening the Capabilities
|
||
// tab (or running MCP setup) materializes the profile so the LIVE config
|
||
// surfaces have a real backend to write to — but until the user hits
|
||
// Create Agent it is a DRAFT. Cancelling the dialog deletes it, so
|
||
// preconfigure-then-back-out leaves zero residue. Best-effort and
|
||
// fire-and-forget: a failed cleanup surfaces a toast, never blocks close.
|
||
const discardDraft = () => {
|
||
const draft = createdRef.current
|
||
|
||
if (!draft) {
|
||
return
|
||
}
|
||
|
||
createdRef.current = null
|
||
flightRef.current = null
|
||
const discard = remoteTarget
|
||
? requestForTarget('cli.exec', { argv: ['profile', 'delete', draft, '--yes'] })
|
||
: deleteBot({ name: draft })
|
||
void Promise.resolve(discard)
|
||
.then(() => host.notify({ kind: 'success', message: `Draft agent "${draft}" discarded` }))
|
||
.catch(err => host.notifyError(err, `Could not clean up draft profile "${draft}"`))
|
||
}
|
||
|
||
const reset = () => {
|
||
setName('')
|
||
setTitle('')
|
||
setDescription('')
|
||
setShape(blobatarSvg ? 'blobatar' : 'circle')
|
||
setColor(AVATAR_COLORS[3])
|
||
setImage(null)
|
||
setAdvanced(false)
|
||
// Same default as the initial useState — resetting to '__none__' made
|
||
// the second agent you create silently start from a fresh profile
|
||
// instead of cloning the main one like the first dialog open did.
|
||
setCloneFrom('default')
|
||
setModel('')
|
||
setProvider('')
|
||
setSoul('')
|
||
setNoSkills(false)
|
||
setShareAuth(true)
|
||
setAdvTab('general')
|
||
setCreatedForCaps(null)
|
||
setCaps(null)
|
||
setCapsFailed(false)
|
||
setDirtyCaps({ skills: false, toolsets: false, mcp: false })
|
||
setCapFilter('')
|
||
setTargetConnection('')
|
||
setBusy(false)
|
||
setError(null)
|
||
createdRef.current = null
|
||
flightRef.current = null
|
||
}
|
||
|
||
// Capability catalog for the tabs: the profile doesn't exist yet, so show
|
||
// what it WILL have — the clone source's catalog, else the main profile's.
|
||
const capSource = cloneFrom === '__none__' ? 'default' : cloneFrom
|
||
const ensureCaps = () => {
|
||
if ((caps && caps.source === capSource) || capsFailed) {
|
||
return
|
||
}
|
||
|
||
Promise.all([
|
||
requestForTarget('profiles.describe', { name: remoteTarget ? 'default' : capSource }),
|
||
requestForTarget('mcp.catalog', {}).catch(() => null)
|
||
])
|
||
.then(([res, cat]) => {
|
||
// Full MCP menu = the profile's configured servers + the bundled
|
||
// catalog (installable). Configured entries win on name clash.
|
||
const configured = res.mcp_servers || []
|
||
const have = new Set(configured.map(m => m.name))
|
||
const catalog = ((cat && cat.servers) || []).filter(s => !have.has(s.name))
|
||
|
||
setCaps({
|
||
source: capSource,
|
||
skills: res.skills || [],
|
||
toolsets: res.toolsets || [],
|
||
mcp: [
|
||
...configured,
|
||
...catalog.map(s => ({
|
||
name: s.name,
|
||
enabled: false,
|
||
fromCatalog: true,
|
||
installed: s.installed,
|
||
auth: s.auth,
|
||
requires: s.requires || [],
|
||
description: s.description || ''
|
||
}))
|
||
]
|
||
})
|
||
})
|
||
.catch(() => setCapsFailed(true))
|
||
}
|
||
|
||
const toggleCap = (kind, name, enabled) => {
|
||
setDirtyCaps(prev => ({ ...prev, [kind === 'mcp' ? 'mcp' : kind]: true }))
|
||
setCaps(prev =>
|
||
prev
|
||
? { ...prev, [kind]: prev[kind].map(x => (x.name === name ? { ...x, enabled } : x)) }
|
||
: prev
|
||
)
|
||
}
|
||
|
||
// Materialize the profile exactly once. createdRef stores the finished slug
|
||
// (its consumers — the taken check, draft discard on cancel, the MCP setup
|
||
// button's profile param — all read a string); flightRef shares the
|
||
// in-flight creation promise so simultaneous MCP setup / Create clicks fire
|
||
// ONE profiles.create. A settled flight clears its slot: failures retry,
|
||
// and a null result (form invalid at flight time) isn't sticky.
|
||
const ensureAgentCreated = () => {
|
||
// Renamed since the draft materialized? The old draft is orphaned —
|
||
// discard it and create fresh under the new slug.
|
||
if (createdRef.current && createdRef.current !== slug) {
|
||
discardDraft()
|
||
setCreatedForCaps(null)
|
||
}
|
||
|
||
if (createdRef.current) {
|
||
return Promise.resolve(createdRef.current)
|
||
}
|
||
|
||
const flight = singleFlight(flightRef, async () => {
|
||
if (!valid || taken) {
|
||
return null
|
||
}
|
||
|
||
const descriptionText = [title, description].filter(Boolean).join(' — ')
|
||
|
||
await requestForTarget('profiles.create', {
|
||
name: slug,
|
||
description: descriptionText,
|
||
// Clone sources are profiles of the TARGET backend. The picker's
|
||
// roster is the local one, so a remote create always starts from the
|
||
// remote machine's default (or fresh) — never a local profile name
|
||
// the remote box doesn't have.
|
||
clone_from: cloneFrom === '__none__' ? null : remoteTarget ? 'default' : cloneFrom,
|
||
no_skills: noSkills,
|
||
// Shared (not copied) auth keeps ONE OAuth/token pool with the main
|
||
// profile, so refreshes can't invalidate each other. Older gateways
|
||
// ignore the param and copy — still functional, just forked.
|
||
share_auth: shareAuth,
|
||
soul: composeSoul({ name: slug, title, description, roster, customSoul: soul }),
|
||
...(model.trim() && provider.trim() ? { model: model.trim(), provider: provider.trim() } : {})
|
||
})
|
||
|
||
createdRef.current = slug
|
||
|
||
// Apply capability picks from the Advanced tabs (best-effort; the
|
||
// profile exists either way and Edit Profile can finish the job).
|
||
try {
|
||
const capPayload = {}
|
||
|
||
if (dirtyCaps.skills && caps) {
|
||
capPayload.disabled_skills = caps.skills.filter(s => !s.enabled).map(s => s.name)
|
||
}
|
||
if (dirtyCaps.toolsets && caps) {
|
||
const en = caps.toolsets.filter(t => t.enabled)
|
||
capPayload.enabled_toolsets =
|
||
en.length === caps.toolsets.length || en.length === 0 ? [] : en.map(t => t.name)
|
||
}
|
||
if (dirtyCaps.mcp && caps) {
|
||
capPayload.enabled_mcp_servers = caps.mcp.filter(m => m.enabled).map(m => m.name)
|
||
}
|
||
if (Object.keys(capPayload).length) {
|
||
await requestForTarget('profiles.configure', { name: slug, ...capPayload })
|
||
}
|
||
} catch {
|
||
/* capability application is best-effort */
|
||
}
|
||
|
||
if (remoteTarget) {
|
||
// The bot lives on ANOTHER machine — local bot-meta is scoped to the
|
||
// active gateway, so write appearance/title into the remote
|
||
// profile's ui_meta (and asset store) directly. Best-effort: the
|
||
// profile exists either way.
|
||
const { image: avatarImage, ...look } = {
|
||
shape,
|
||
color,
|
||
image,
|
||
imageKind: image ? 'photo' : 'shape',
|
||
title: title.trim(),
|
||
created: Date.now()
|
||
}
|
||
|
||
try {
|
||
void requestForTarget('profiles.configure', { name: slug, ui_meta: { 'hermes-bots': look } }).catch(() => undefined)
|
||
|
||
if (avatarImage) {
|
||
void requestForTarget('profiles.set_asset', { name: slug, asset: 'avatar', data: avatarImage }).catch(() => undefined)
|
||
}
|
||
} catch {
|
||
/* older remote gateway */
|
||
}
|
||
} else {
|
||
saveBotMeta(slug, { shape, color, image, imageKind: image ? 'photo' : 'shape', title: title.trim(), created: Date.now() })
|
||
}
|
||
|
||
queryClient.invalidateQueries({ queryKey: ROSTER_KEY })
|
||
return slug
|
||
})
|
||
|
||
return flight
|
||
}
|
||
|
||
const submit = async () => {
|
||
if (!valid || taken || busy) {
|
||
return
|
||
}
|
||
|
||
setBusy(true)
|
||
setError(null)
|
||
|
||
try {
|
||
const slugCreated = await ensureAgentCreated()
|
||
if (!slugCreated) {
|
||
setBusy(false)
|
||
setError('Could not create the agent.')
|
||
return
|
||
}
|
||
|
||
host.notify({
|
||
kind: 'success',
|
||
message: remoteTarget
|
||
? `Agent "${displayName({ name: slug, title })}" created on ${targetLabel}`
|
||
: `Agent "${displayName({ name: slug, title })}" created`
|
||
})
|
||
const wasRemote = remoteTarget
|
||
reset()
|
||
onClose()
|
||
|
||
if (wasRemote) {
|
||
// The bot lives on another machine: it appears in the roster via the
|
||
// union enumeration; chat routes through its own source. No local
|
||
// canonical chat to birth here.
|
||
queryClient.invalidateQueries({ queryKey: ROSTER_KEY })
|
||
return
|
||
}
|
||
|
||
$selectedBot.set(slug)
|
||
|
||
// Birth the bot's forever chat right away: it introduces itself as
|
||
// the first thing the user sees, and the pin exists from minute one.
|
||
try {
|
||
// Creates, pins, opens, and kicks off the intro in one flow.
|
||
const sid = await createCanonicalChat(slug)
|
||
|
||
if (!sid && typeof host.newChat === 'function') {
|
||
host.newChat(slug)
|
||
}
|
||
} catch {
|
||
if (typeof host.newChat === 'function') {
|
||
host.newChat(slug)
|
||
}
|
||
}
|
||
} catch (err) {
|
||
setBusy(false)
|
||
setError(err instanceof Error ? err.message : String(err))
|
||
}
|
||
}
|
||
|
||
return jsx(Dialog, {
|
||
open,
|
||
onOpenChange: value => {
|
||
if (!value && !busy) {
|
||
// Cancel path (esc / overlay click): a materialized draft profile is
|
||
// discarded — preconfigure-then-back-out leaves nothing behind.
|
||
discardDraft()
|
||
reset()
|
||
onClose()
|
||
}
|
||
},
|
||
children: jsxs(DialogContent, {
|
||
className: advanced ? 'max-w-3xl' : 'max-w-md',
|
||
// Native resize handle (bottom-right corner): the dialog becomes a
|
||
// window the user can grow/shrink. overflow:auto is required for CSS
|
||
// resize to engage; caps keep it on screen.
|
||
style: advanced
|
||
? { resize: 'both', overflow: 'auto', minWidth: 420, minHeight: 360, maxWidth: '95vw', maxHeight: '90vh' }
|
||
: undefined,
|
||
children: [
|
||
jsxs(DialogHeader, {
|
||
children: [
|
||
jsx(DialogTitle, { children: 'New Agent' }),
|
||
jsx(DialogDescription, {
|
||
children: 'A named teammate with its own memory, skills, and chat. It can message your other agents.'
|
||
})
|
||
]
|
||
}),
|
||
jsxs('div', {
|
||
className: 'grid gap-3.5',
|
||
children: [
|
||
jsx('div', {
|
||
className: 'flex justify-center py-1',
|
||
children: jsx(BotFace, { shape, color, image, size: 56, name: slug || 'agent' })
|
||
}),
|
||
jsx(AvatarPicker, {
|
||
shape,
|
||
color,
|
||
image,
|
||
onShape: setShape,
|
||
onColor: setColor,
|
||
onImage: setImage,
|
||
generateSeed: { name: slug || 'agent', title, description }
|
||
}),
|
||
labeled(
|
||
'Name',
|
||
jsx(Input, {
|
||
autoFocus: true,
|
||
placeholder: 'inbox-triage',
|
||
value: name,
|
||
onChange: event => setName(event.target.value)
|
||
})
|
||
),
|
||
taken
|
||
? jsx('div', {
|
||
className: 'text-xs text-(--ui-accent)',
|
||
children: remoteTarget
|
||
? `An agent named "${slug}" already exists on ${targetLabel}.`
|
||
: `An agent named "${slug}" already exists.`
|
||
})
|
||
: null,
|
||
// Multi-connection desktops choose WHERE the agent lives. Hidden
|
||
// on single-connection setups — the active gateway is the only
|
||
// possible home, exactly the old behavior.
|
||
Array.isArray(connections) && connections.length > 1
|
||
? labeled(
|
||
'Create on',
|
||
jsxs(Select, {
|
||
value: targetConnection || activeConnectionId || 'local',
|
||
onValueChange: value => {
|
||
setTargetConnection(value === (activeConnectionId || 'local') ? '' : value)
|
||
// The capability catalog and clone list belong to the
|
||
// target backend — refetch for the new home. The live
|
||
// Capabilities tab re-pins to it via fixedConnection on
|
||
// builds that route it (staged checklists otherwise).
|
||
setCaps(null)
|
||
setCapsFailed(false)
|
||
setAdvTab('general')
|
||
},
|
||
children: [
|
||
jsx(SelectTrigger, {
|
||
className: 'h-8 rounded-md',
|
||
children: jsx(SelectValue, {})
|
||
}),
|
||
jsx(SelectContent, {
|
||
children: connections.map(connection =>
|
||
jsx(
|
||
SelectItem,
|
||
{
|
||
value: connection.id,
|
||
children:
|
||
connection.id === (activeConnectionId || 'local')
|
||
? `${connection.label || connection.id} (current)`
|
||
: connection.label || connection.id
|
||
},
|
||
connection.id
|
||
)
|
||
)
|
||
})
|
||
]
|
||
})
|
||
)
|
||
: null,
|
||
remoteTarget
|
||
? jsx('div', {
|
||
className: 'text-[0.7rem] leading-5 text-(--ui-text-tertiary)',
|
||
children: `The agent is created on ${targetLabel} and appears in the roster as a Connections bot. Chat routes to that machine.`
|
||
})
|
||
: null,
|
||
labeled(
|
||
'Title',
|
||
jsx(Input, {
|
||
placeholder: 'Inbox Triage',
|
||
value: title,
|
||
onChange: event => setTitle(event.target.value)
|
||
})
|
||
),
|
||
labeled(
|
||
'Description',
|
||
jsx(Textarea, {
|
||
className: 'min-h-16',
|
||
placeholder: 'What should this Bot help with?',
|
||
value: description,
|
||
onChange: event => setDescription(event.target.value)
|
||
})
|
||
),
|
||
jsxs('button', {
|
||
type: 'button',
|
||
className:
|
||
'flex items-center gap-1 text-xs font-medium text-(--ui-text-tertiary) hover:text-(--ui-text-secondary)',
|
||
onClick: () => {
|
||
setAdvanced(v => {
|
||
if (!v) {
|
||
ensureCaps()
|
||
}
|
||
return !v
|
||
})
|
||
},
|
||
children: [
|
||
jsx(Codicon, { name: advanced ? 'chevron-down' : 'chevron-right', className: 'text-[0.8rem]' }),
|
||
'Advanced'
|
||
]
|
||
}),
|
||
advanced
|
||
? jsxs('div', {
|
||
className: 'grid gap-3 rounded-md border border-(--ui-stroke-secondary) p-3',
|
||
children: [
|
||
jsx('div', {
|
||
className: 'flex gap-1',
|
||
// Newer desktops export the whole Capabilities surface —
|
||
// one live tab replaces the three staged checklists.
|
||
// The live Capabilities surface (SkillsView) binds to
|
||
// the ACTIVE gateway's backend unless this build routes
|
||
// fixedConnection (skillsViewRoutesConnections) — then a
|
||
// remote-target draft gets the live surface pinned to
|
||
// ITS machine. Builds without that routing keep the
|
||
// staged checklists for remote targets (their catalog
|
||
// reads already route to the target).
|
||
children: (SkillsView && (!remoteTarget || skillsViewRoutesConnections)
|
||
? [
|
||
['general', 'General'],
|
||
['capabilities', 'Capabilities']
|
||
]
|
||
: [
|
||
['general', 'General'],
|
||
['skills', 'Skills'],
|
||
['toolsets', 'Tools'],
|
||
['mcp', 'MCP']
|
||
]
|
||
).map(([id, label]) =>
|
||
jsx(
|
||
'button',
|
||
{
|
||
type: 'button',
|
||
className: cn(
|
||
'rounded-md px-2.5 py-1 text-xs font-medium transition-colors',
|
||
advTab === id
|
||
? 'bg-(--chrome-action-hover) text-(--ui-text-primary)'
|
||
: 'text-(--ui-text-tertiary) hover:text-(--ui-text-secondary)'
|
||
),
|
||
onClick: () => {
|
||
setAdvTab(id)
|
||
setCapFilter('')
|
||
if (id === 'capabilities') {
|
||
// The live surface needs a real profile —
|
||
// materialize it now (same lazy-create door
|
||
// the MCP setup buttons use).
|
||
void ensureAgentCreated()
|
||
.then(created => created && setCreatedForCaps(created))
|
||
.catch(err => host.notifyError(err, 'Could not create the profile yet'))
|
||
} else if (id !== 'general') {
|
||
ensureCaps()
|
||
}
|
||
},
|
||
children: label
|
||
},
|
||
id
|
||
)
|
||
)
|
||
}),
|
||
advTab === 'general'
|
||
? jsxs('div', {
|
||
className: 'grid gap-3.5',
|
||
children: [
|
||
labeled(
|
||
remoteTarget ? `Clone from profile (on ${targetLabel})` : 'Clone from profile',
|
||
jsxs(Select, {
|
||
disabled: remoteTarget,
|
||
value: remoteTarget ? 'default' : cloneFrom,
|
||
onValueChange: value => {
|
||
setCloneFrom(value)
|
||
setCaps(null)
|
||
setCapsFailed(false)
|
||
},
|
||
children: [
|
||
jsx(SelectTrigger, {
|
||
className: 'h-8 rounded-md',
|
||
children: jsx(SelectValue, {})
|
||
}),
|
||
jsxs(SelectContent, {
|
||
children: [
|
||
jsx(SelectItem, {
|
||
value: '__none__',
|
||
children: 'Fresh profile (bundled skills)'
|
||
}),
|
||
...roster.map(b => jsx(SelectItem, { value: b.name, children: b.name }, b.name))
|
||
]
|
||
})
|
||
]
|
||
})
|
||
),
|
||
jsx(ModelPicker, {
|
||
value: { provider, model },
|
||
onChange: patch => {
|
||
if ('provider' in patch) {
|
||
setProvider(patch.provider)
|
||
}
|
||
if ('model' in patch) {
|
||
setModel(patch.model)
|
||
}
|
||
},
|
||
placeholderModel: 'inherited from launch profile'
|
||
}),
|
||
labeled(
|
||
'SOUL.md (optional — replaces the generated persona)',
|
||
jsx(Textarea, {
|
||
className: 'min-h-24 font-mono text-xs leading-5',
|
||
placeholder:
|
||
'Leave blank to auto-generate from name/title/description + agent-messaging roster.',
|
||
value: soul,
|
||
onChange: event => setSoul(event.target.value)
|
||
})
|
||
),
|
||
jsxs('label', {
|
||
className: 'flex items-center gap-2 text-xs text-(--ui-text-secondary)',
|
||
children: [
|
||
jsx(Checkbox, {
|
||
checked: shareAuth,
|
||
onCheckedChange: value => setShareAuth(Boolean(value))
|
||
}),
|
||
'Share keys & accounts with the main profile'
|
||
]
|
||
}),
|
||
jsx('div', {
|
||
className: 'pl-6 pt-0.5 text-[0.7rem] leading-5 text-(--ui-text-tertiary)',
|
||
children:
|
||
'Subscriptions, OAuth logins, and API keys stay shared (not copied), so token refreshes never invalidate each other. Uncheck for an isolated snapshot copy.'
|
||
}),
|
||
jsxs('label', {
|
||
className: 'flex items-center gap-2 text-xs text-(--ui-text-secondary)',
|
||
children: [
|
||
jsx(Checkbox, {
|
||
checked: noSkills,
|
||
onCheckedChange: value => setNoSkills(Boolean(value))
|
||
}),
|
||
'Create empty (skip bundled skills)'
|
||
]
|
||
})
|
||
]
|
||
})
|
||
: advTab === 'capabilities'
|
||
? !valid || taken
|
||
? jsx('div', {
|
||
className: 'px-2 py-3 text-center text-xs text-(--ui-text-tertiary)',
|
||
children: taken
|
||
? 'That name is taken — pick another before configuring capabilities.'
|
||
: 'Name the agent first — a draft profile is created when you open this tab (discarded if you cancel).'
|
||
})
|
||
: !createdForCaps
|
||
? jsx('div', {
|
||
className: 'flex justify-center py-4',
|
||
children: jsx(GlyphSpinner, {
|
||
spinner: 'breathe',
|
||
className: 'text-(--ui-text-tertiary)'
|
||
})
|
||
})
|
||
: jsx('div', {
|
||
className: 'overflow-hidden rounded-md border border-(--ui-stroke-secondary)',
|
||
style: { height: 440, minHeight: 280, resize: 'vertical', overflow: 'auto' },
|
||
// The REAL core Capabilities surface (skills +
|
||
// one-click hub installs + tools + MCP), pinned
|
||
// to the just-created profile — and, for a
|
||
// remote-target draft, to the target machine's
|
||
// backend via fixedConnection. Writes land
|
||
// immediately — no staging needed.
|
||
children: jsx(SkillsView, {
|
||
embedded: true,
|
||
fixedProfile: createdForCaps,
|
||
...(remoteTarget ? { fixedConnection: targetConnection } : {})
|
||
})
|
||
})
|
||
: capsFailed
|
||
? jsx('div', {
|
||
className: 'px-2 py-3 text-center text-xs text-(--ui-text-tertiary)',
|
||
children:
|
||
'Capability catalog needs a newer gateway (restart it after updating Hermes).'
|
||
})
|
||
: !caps
|
||
? jsx('div', {
|
||
className: 'flex justify-center py-4',
|
||
children: jsx(GlyphSpinner, {
|
||
spinner: 'breathe',
|
||
className: 'text-(--ui-text-tertiary)'
|
||
})
|
||
})
|
||
: advTab === 'skills'
|
||
? noSkills
|
||
? jsx('div', {
|
||
className: 'px-2 py-3 text-center text-xs text-(--ui-text-tertiary)',
|
||
children: '“Create empty” is checked — no bundled skills will be installed.'
|
||
})
|
||
: jsxs('div', {
|
||
className: 'grid gap-1.5',
|
||
children: [
|
||
jsx(Input, {
|
||
className: 'h-7 text-xs',
|
||
placeholder: 'Filter skills…',
|
||
value: capFilter,
|
||
onChange: event => setCapFilter(event.target.value)
|
||
}),
|
||
jsx(ScrollArea, {
|
||
className: 'hermes-scroll-cap',
|
||
style: { maxHeight: 200 },
|
||
children: jsx(CheckList, {
|
||
items: capFilter.trim()
|
||
? caps.skills.filter(s =>
|
||
s.name.toLowerCase().includes(capFilter.trim().toLowerCase())
|
||
)
|
||
: caps.skills,
|
||
onToggle: (name, enabled) => toggleCap('skills', name, enabled),
|
||
columns: 2
|
||
})
|
||
}),
|
||
jsx('div', {
|
||
className: 'text-[0.65rem] leading-4 text-(--ui-text-quaternary)',
|
||
children: `Catalog from ${caps.source} — unchecked skills are disabled after creation.`
|
||
}),
|
||
jsx(HubSkillsSection, {
|
||
forProfile: null,
|
||
onInstalled: name =>
|
||
setCaps(prev =>
|
||
!prev || prev.skills.some(s => s.name === name)
|
||
? prev
|
||
: { ...prev, skills: [...prev.skills, { name, enabled: true }] }
|
||
)
|
||
})
|
||
]
|
||
})
|
||
: advTab === 'toolsets'
|
||
? jsxs('div', {
|
||
className: 'grid gap-1.5',
|
||
children: [
|
||
jsx(ScrollArea, {
|
||
className: 'hermes-scroll-cap',
|
||
style: { maxHeight: 200 },
|
||
children: jsx(CheckList, {
|
||
items: caps.toolsets,
|
||
onToggle: (name, enabled) => toggleCap('toolsets', name, enabled),
|
||
columns: 2
|
||
})
|
||
}),
|
||
jsx('div', {
|
||
className: 'text-[0.65rem] leading-4 text-(--ui-text-quaternary)',
|
||
children: 'Leaving all (or none) checked keeps the default toolset behavior.'
|
||
})
|
||
]
|
||
})
|
||
: caps.mcp.length === 0
|
||
? jsx('div', {
|
||
className: 'px-2 py-3 text-center text-xs text-(--ui-text-tertiary)',
|
||
children: 'No MCP servers configured or in the catalog.'
|
||
})
|
||
: jsxs('div', {
|
||
className: 'grid gap-1.5',
|
||
children: [
|
||
jsx(ScrollArea, {
|
||
className: 'hermes-scroll-cap',
|
||
style: { maxHeight: 200 },
|
||
children: jsx('div', {
|
||
className: 'grid gap-1',
|
||
children: caps.mcp.map(m => {
|
||
const needsSetup =
|
||
m.fromCatalog && !m.installed && ((m.requires || []).length > 0 || (m.auth || '').toLowerCase() === 'oauth')
|
||
|
||
return jsxs(
|
||
'label',
|
||
{
|
||
className: 'flex items-start gap-2 text-xs text-(--ui-text-secondary)',
|
||
children: [
|
||
jsx(Checkbox, {
|
||
checked: !!m.enabled,
|
||
disabled: needsSetup,
|
||
onCheckedChange: value => toggleCap('mcp', m.name, Boolean(value))
|
||
}),
|
||
jsxs('span', {
|
||
className: 'min-w-0',
|
||
children: [
|
||
jsx('span', { children: m.name }),
|
||
m.fromCatalog && !needsSetup
|
||
? jsx('span', {
|
||
className: 'ml-1.5 text-[0.65rem] text-(--ui-text-quaternary)',
|
||
children: m.installed
|
||
? 'catalog · installed'
|
||
: 'catalog'
|
||
})
|
||
: null,
|
||
needsSetup
|
||
? jsx(McpSetupButton, {
|
||
profile: createdRef.current,
|
||
entry: m,
|
||
ensureProfile: ensureAgentCreated,
|
||
onDone: () => {
|
||
// Setup done: mark installed so the row's
|
||
// checkbox un-disables, and enable it.
|
||
setCaps(prev =>
|
||
prev
|
||
? {
|
||
...prev,
|
||
mcp: prev.mcp.map(x =>
|
||
x.name === m.name
|
||
? { ...x, installed: true, enabled: true }
|
||
: x
|
||
)
|
||
}
|
||
: prev
|
||
)
|
||
setDirtyCaps(prev => ({ ...prev, mcp: true }))
|
||
}
|
||
})
|
||
: null,
|
||
m.description
|
||
? jsx('div', {
|
||
className:
|
||
'truncate text-[0.65rem] leading-4 text-(--ui-text-quaternary)',
|
||
children: m.description
|
||
})
|
||
: null
|
||
]
|
||
})
|
||
]
|
||
},
|
||
m.name
|
||
)
|
||
})
|
||
})
|
||
}),
|
||
jsx('div', {
|
||
className: 'text-[0.65rem] leading-4 text-(--ui-text-quaternary)',
|
||
children:
|
||
'Configured servers copy from the main profile; catalog entries are the bundled MCP menu. Entries needing API keys route through setup first (credentials follow the shared keys setting).'
|
||
})
|
||
]
|
||
})
|
||
]
|
||
})
|
||
: null,
|
||
error
|
||
? jsx('div', {
|
||
className: 'rounded-md border border-(--ui-stroke-secondary) px-3 py-2 text-xs text-(--ui-accent)',
|
||
children: error
|
||
})
|
||
: null
|
||
]
|
||
}),
|
||
jsxs(DialogFooter, {
|
||
children: [
|
||
jsx(Button, {
|
||
variant: 'ghost',
|
||
disabled: busy,
|
||
onClick: () => {
|
||
discardDraft()
|
||
reset()
|
||
onClose()
|
||
},
|
||
children: 'Cancel'
|
||
}),
|
||
jsx(Button, {
|
||
disabled: busy || !valid || taken,
|
||
onClick: submit,
|
||
children: busy ? 'Creating…' : 'Create Agent'
|
||
})
|
||
]
|
||
})
|
||
]
|
||
})
|
||
})
|
||
}
|
||
|
||
// ── routines (cron) ──────────────────────────────────────────────────────────
|
||
//
|
||
// Jobs are namespaced "[bot:<name>] <routine>". A job running in the active
|
||
// bot profile uses the plain instruction; a different profile keeps the
|
||
// hermes -p <bot> chat delegation wrapper so the run reaches that bot's
|
||
// history. The tile follows the bot you're chatting with (gateway profile).
|
||
const BOT_TAG_RE = /^\[bot:([a-z0-9][a-z0-9_-]*)\]\s*/i
|
||
const SAFE_ROUTINE_MARKER = '[bot-mode:routine:v2] '
|
||
const LEGACY_DELEGATED_ROUTINE_PREFIX = 'You are running the scheduled routine "'
|
||
|
||
function routineBot(job) {
|
||
const match = BOT_TAG_RE.exec(job?.name || '')
|
||
return match ? match[1].toLowerCase() : null
|
||
}
|
||
|
||
function routineTitle(job) {
|
||
return (job?.name || '').replace(BOT_TAG_RE, '') || 'Untitled cronjob'
|
||
}
|
||
|
||
function isLegacyDelegatedRoutine(job) {
|
||
const preview = typeof job?.prompt_preview === 'string' ? job.prompt_preview : job?.prompt
|
||
return Boolean(routineBot(job) && typeof preview === 'string' && preview.startsWith(LEGACY_DELEGATED_ROUTINE_PREFIX))
|
||
}
|
||
|
||
async function loadRoutines(profile) {
|
||
// profile scopes cron.manage to that bot's own cron store (core RPC gained an
|
||
// optional `profile` param). Older gateways ignore the unknown param and
|
||
// return the launch-profile store — the [bot:] tag filter in selectRoutineJobs
|
||
// remains the graceful fallback there.
|
||
const scope = profile ? { profile } : {}
|
||
const data = await host.request('cron.manage', { action: 'list', include_disabled: true, ...scope })
|
||
const jobs = Array.isArray(data?.jobs) ? data.jobs : []
|
||
const activeLegacyJobs = jobs.filter(
|
||
job => isLegacyDelegatedRoutine(job) && job.enabled !== false && job.state !== 'paused'
|
||
)
|
||
|
||
// A pause failing must not fail the LIST — the pane would report "could
|
||
// not load cronjobs" over data that loaded fine, and the 20s poll would
|
||
// re-attempt the failing pause inside a failing query forever. Each pause
|
||
// swallows its own error; the overlay only claims jobs the gateway
|
||
// actually paused, and the next poll retries the rest.
|
||
const pauses = await Promise.all(
|
||
activeLegacyJobs.map(job =>
|
||
host
|
||
.request('cron.manage', { action: 'pause', name: job.job_id, ...scope })
|
||
.then(() => true)
|
||
.catch(() => false)
|
||
)
|
||
)
|
||
|
||
if (!activeLegacyJobs.length) {
|
||
return data
|
||
}
|
||
|
||
const pausedIds = new Set(activeLegacyJobs.filter((job, index) => pauses[index]).map(job => job.job_id))
|
||
return {
|
||
...data,
|
||
jobs: jobs.map(job => (pausedIds.has(job.job_id) ? { ...job, enabled: false, state: 'paused' } : job))
|
||
}
|
||
}
|
||
|
||
function useRoutines(profile) {
|
||
return useQuery({
|
||
queryKey: [...ROUTINES_KEY, profile || ''],
|
||
queryFn: () => loadRoutines(profile),
|
||
refetchInterval: 20000,
|
||
staleTime: 8000
|
||
})
|
||
}
|
||
|
||
function routineCreateTarget(owner, activeBot) {
|
||
return owner || activeBot
|
||
}
|
||
|
||
async function invalidateRoutineOwner(profile) {
|
||
await queryClient.invalidateQueries({
|
||
queryKey: [...ROUTINES_KEY, profile || ''],
|
||
exact: true
|
||
})
|
||
}
|
||
|
||
/** Pick which cron jobs to show. A failed refresh keeps the last good list. */
|
||
function selectRoutineJobs(data, error, lastJobs, bot) {
|
||
const live = Array.isArray(data?.jobs) ? data.jobs : null
|
||
const all = live ?? (error ? lastJobs : [])
|
||
const scopedToBot = normalizedProfileName(data?.scoped) === normalizedProfileName(bot)
|
||
return {
|
||
live,
|
||
all,
|
||
jobs: scopedToBot ? all : all.filter(job => (routineBot(job) || 'default') === bot)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Why the Routines pane can be empty while the bot's cron store has jobs.
|
||
*
|
||
* On older gateways the pane only shows jobs namespaced `[bot:<name>]` for the
|
||
* active bot (plus untagged legacy jobs on the default bot). When jobs exist in
|
||
* the store but none surface for this bot, the user is left staring at the
|
||
* generic empty state with no hint that cronjobs are present but hidden.
|
||
* Return a short explanation string in that case, or null when the store is
|
||
* genuinely empty (or the active bot's jobs are already shown).
|
||
*/
|
||
function routineFilterHint(all, jobs) {
|
||
if (jobs.length !== 0 || !Array.isArray(all) || all.length === 0) {
|
||
return null
|
||
}
|
||
return 'Cronjobs exist in this profile but none are tagged for this bot. ' +
|
||
'Name a job "[bot:<name>] …" to show it here, or see them in Cron below.'
|
||
}
|
||
|
||
function normalizedProfileName(profile) {
|
||
return typeof profile === 'string' ? profile.trim().toLowerCase() : ''
|
||
}
|
||
|
||
function shellQuote(value) {
|
||
return `'${String(value).replaceAll("'", "'\"'\"'")}'`
|
||
}
|
||
|
||
/** Escape for interpolation INSIDE an existing double-quoted shell string:
|
||
* keeps ", `, $, and \ literal so free-text titles (which sync from ui_meta)
|
||
* and gateway profile names can't expand or break out of the quotes. */
|
||
function shellDoubleQuote(value) {
|
||
return String(value).replace(/[\\"`$]/g, ch => '\\' + ch)
|
||
}
|
||
|
||
function routineInputError(title, instruction) {
|
||
if (String(title).includes('\0')) {
|
||
return 'Cronjob name cannot contain NUL (U+0000).'
|
||
}
|
||
|
||
if (String(instruction).includes('\0')) {
|
||
return 'Cronjob instruction cannot contain NUL (U+0000).'
|
||
}
|
||
|
||
return null
|
||
}
|
||
|
||
function routinePrompt(bot, title, instruction, activeProfile) {
|
||
if (normalizedProfileName(bot) && normalizedProfileName(bot) === normalizedProfileName(activeProfile)) {
|
||
return instruction
|
||
}
|
||
|
||
return (
|
||
`${SAFE_ROUTINE_MARKER}You are running the scheduled routine "${title}" for agent '${bot}'. ` +
|
||
`Execute it AS that agent so the run lands in its own history: run this in the terminal and relay the output:\n\n` +
|
||
`hermes -p ${shellQuote(bot)} chat -c ${shellQuote(`Routine: ${title}`)} -q ${shellQuote(`[Scheduled routine] ${instruction}`)}\n\n` +
|
||
`If the command fails, report the error instead.`
|
||
)
|
||
}
|
||
function scheduleLabel(schedule) {
|
||
const once = /^once in (.+)$/.exec(schedule || '')
|
||
|
||
if (once) {
|
||
return `Once (${once[1]})`
|
||
}
|
||
|
||
const bare = /^(\d+)([mhd])$/.exec(schedule || '')
|
||
|
||
if (bare) {
|
||
return `Once (${bare[1]}${bare[2]})`
|
||
}
|
||
|
||
const match = /^every (\d+)m$/.exec(schedule || '')
|
||
|
||
if (match) {
|
||
const minutes = Number(match[1])
|
||
|
||
if (minutes % 1440 === 0) {
|
||
const d = minutes / 1440
|
||
return d === 1 ? 'Daily' : `Every ${d} days`
|
||
}
|
||
|
||
if (minutes % 60 === 0) {
|
||
const h = minutes / 60
|
||
return h === 1 ? 'Hourly' : `Every ${h}h`
|
||
}
|
||
|
||
return `Every ${minutes}m`
|
||
}
|
||
|
||
return schedule || ''
|
||
}
|
||
|
||
function RoutineRow({ job, profile }) {
|
||
const [busy, setBusy] = useState(false)
|
||
// Optimistic overlay: null = trust server state. Set immediately on
|
||
// toggle so the switch responds even before the refetch lands.
|
||
const [pendingActive, setPendingActive] = useState(null)
|
||
const legacyUnsafe = isLegacyDelegatedRoutine(job)
|
||
const serverActive = !legacyUnsafe && job.enabled !== false && job.state !== 'paused'
|
||
const active = pendingActive === null ? serverActive : pendingActive
|
||
|
||
if (pendingActive !== null && pendingActive === serverActive) {
|
||
setPendingActive(null) // server caught up
|
||
}
|
||
|
||
const act = async action => {
|
||
if (busy) {
|
||
return
|
||
}
|
||
|
||
setBusy(true)
|
||
|
||
if (action === 'pause' || action === 'resume') {
|
||
setPendingActive(action === 'resume')
|
||
}
|
||
|
||
try {
|
||
await host.request('cron.manage', { action, name: job.job_id, ...(profile ? { profile } : {}) })
|
||
await invalidateRoutineOwner(profile)
|
||
} catch (err) {
|
||
setPendingActive(null)
|
||
host.notifyError(err, 'Cronjob update failed')
|
||
} finally {
|
||
setBusy(false)
|
||
}
|
||
}
|
||
|
||
return jsxs('div', {
|
||
className: cn(
|
||
'group grid gap-1.5 rounded-lg border border-(--ui-stroke-secondary) p-2.5 transition-colors',
|
||
'hover:border-(--ui-stroke-primary, var(--ui-stroke-secondary))'
|
||
),
|
||
children: [
|
||
jsxs('div', {
|
||
className: 'flex items-center gap-2',
|
||
children: [
|
||
jsx('span', {
|
||
'aria-hidden': true,
|
||
className: cn('size-1.5 shrink-0 rounded-full', active ? 'bg-emerald-500' : 'bg-(--ui-text-quaternary)')
|
||
}),
|
||
jsx('span', {
|
||
className: cn('min-w-0 flex-1 truncate text-xs font-medium', !active && 'text-(--ui-text-tertiary)'),
|
||
children: routineTitle(job)
|
||
}),
|
||
jsx(Switch, {
|
||
checked: active,
|
||
disabled: busy || legacyUnsafe,
|
||
onCheckedChange: value => act(value ? 'resume' : 'pause')
|
||
}),
|
||
jsx(Tip, {
|
||
label: 'Delete cronjob',
|
||
children: jsx('button', {
|
||
type: 'button',
|
||
disabled: busy,
|
||
className:
|
||
'flex size-5 items-center justify-center rounded text-(--ui-text-quaternary) opacity-0 transition-opacity group-hover:opacity-100 hover:bg-(--chrome-action-hover) hover:text-foreground',
|
||
onClick: () => act('remove'),
|
||
children: jsx(Codicon, { name: 'trash', className: 'text-[0.75rem]' })
|
||
})
|
||
})
|
||
]
|
||
}),
|
||
jsxs('div', {
|
||
className: 'flex items-center justify-between gap-2 pl-3.5',
|
||
children: [
|
||
jsxs('span', {
|
||
className:
|
||
'inline-flex items-center gap-1 rounded-full border border-(--ui-stroke-secondary) px-1.5 py-0.5 text-[0.65rem] text-(--ui-text-tertiary)',
|
||
children: [jsx(Codicon, { name: 'calendar', className: 'text-[0.7rem]' }), scheduleLabel(job.schedule)]
|
||
}),
|
||
jsx('span', {
|
||
className: 'truncate text-[0.65rem] text-(--ui-text-quaternary)',
|
||
children: active && job.next_run_at ? `next ${relativeTime(new Date(job.next_run_at).getTime())}` : 'paused'
|
||
})
|
||
]
|
||
}),
|
||
legacyUnsafe
|
||
? jsx('div', {
|
||
className:
|
||
'rounded-md border border-(--ui-stroke-secondary) px-2 py-1.5 text-[0.65rem] leading-4 text-(--ui-accent)',
|
||
children: 'Paused for security: delete and recreate this legacy cronjob before running it again.'
|
||
})
|
||
: null
|
||
]
|
||
})
|
||
}
|
||
|
||
// Structured schedule picker: frequency first, then only the detail that
|
||
// frequency needs (time of day, weekday, day of month, interval). Emits a
|
||
// Hermes-native schedule string; Advanced exposes it raw.
|
||
const FREQUENCIES = [
|
||
{ id: 'once', label: 'Once, in\u2026' },
|
||
{ id: 'hourly', label: 'Every hour' },
|
||
{ id: 'daily', label: 'Every day' },
|
||
{ id: 'weekdays', label: 'Weekdays' },
|
||
{ id: 'weekly', label: 'Every week' },
|
||
{ id: 'monthly', label: 'Every month' },
|
||
{ id: 'interval', label: 'Interval' },
|
||
{ id: 'advanced', label: 'Advanced\u2026' }
|
||
]
|
||
|
||
const WEEKDAYS = [
|
||
{ id: '1', label: 'Monday' },
|
||
{ id: '2', label: 'Tuesday' },
|
||
{ id: '3', label: 'Wednesday' },
|
||
{ id: '4', label: 'Thursday' },
|
||
{ id: '5', label: 'Friday' },
|
||
{ id: '6', label: 'Saturday' },
|
||
{ id: '0', label: 'Sunday' }
|
||
]
|
||
|
||
const TIMES = (() => {
|
||
const out = []
|
||
for (let h = 0; h < 24; h++) {
|
||
for (const m of [0, 30]) {
|
||
const ampm = h < 12 ? 'AM' : 'PM'
|
||
const h12 = h % 12 === 0 ? 12 : h % 12
|
||
out.push({ id: `${h}:${m}`, label: `${h12}:${String(m).padStart(2, '0')} ${ampm}`, h, m })
|
||
}
|
||
}
|
||
return out
|
||
})()
|
||
|
||
/** Compose the Hermes schedule string from picker state. */
|
||
function composeSchedule(state) {
|
||
const [h, m] = (state.time || '9:0').split(':').map(Number)
|
||
|
||
switch (state.freq) {
|
||
case 'once': {
|
||
const n = Math.max(1, parseInt(state.onceN, 10) || 1)
|
||
return `${n}${state.onceUnit || 'h'}`
|
||
}
|
||
case 'hourly':
|
||
return 'every 1h'
|
||
case 'daily':
|
||
return `${m} ${h} * * *`
|
||
case 'weekdays':
|
||
return `${m} ${h} * * 1-5`
|
||
case 'weekly':
|
||
return `${m} ${h} * * ${state.weekday || '1'}`
|
||
case 'monthly':
|
||
return `${m} ${h} ${state.monthday || '1'} * *`
|
||
case 'interval': {
|
||
const n = Math.max(1, parseInt(state.intervalN, 10) || 1)
|
||
return `every ${n}${state.intervalUnit || 'h'}`
|
||
}
|
||
default:
|
||
return state.raw || ''
|
||
}
|
||
}
|
||
|
||
function scheduleSummary(state) {
|
||
const t = TIMES.find(x => x.id === state.time)
|
||
const tl = t ? t.label : '9:00 AM'
|
||
|
||
const unitWord = u => (u === 'm' ? 'minute(s)' : u === 'd' ? 'day(s)' : 'hour(s)')
|
||
const cap =
|
||
state.freq !== 'once' && String(state.repeatN || '').trim()
|
||
? `, ${Math.max(1, parseInt(state.repeatN, 10) || 1)} time(s) total`
|
||
: ''
|
||
|
||
switch (state.freq) {
|
||
case 'once':
|
||
return `Runs once, ${Math.max(1, parseInt(state.onceN, 10) || 1)} ${unitWord(state.onceUnit)} from now`
|
||
case 'hourly':
|
||
return 'Runs at the top of every hour' + cap
|
||
case 'daily':
|
||
return `Runs every day at ${tl}` + cap
|
||
case 'weekdays':
|
||
return `Runs Monday\u2013Friday at ${tl}` + cap
|
||
case 'weekly':
|
||
return `Runs every ${(WEEKDAYS.find(w => w.id === state.weekday) || WEEKDAYS[0]).label} at ${tl}` + cap
|
||
case 'monthly':
|
||
return `Runs on day ${state.monthday || '1'} of each month at ${tl}` + cap
|
||
case 'interval':
|
||
return `Runs every ${Math.max(1, parseInt(state.intervalN, 10) || 1)} ${unitWord(state.intervalUnit)}` + cap
|
||
default:
|
||
return 'Raw schedule \u2014 every Nm/Nh/Nd or 5-field cron'
|
||
}
|
||
}
|
||
|
||
function pickerSelect(value, onChange, options) {
|
||
return jsxs(Select, {
|
||
value,
|
||
onValueChange: onChange,
|
||
children: [
|
||
jsx(SelectTrigger, { className: 'h-8 rounded-md', children: jsx(SelectValue, {}) }),
|
||
jsx(SelectContent, {
|
||
children: options.map(o => jsx(SelectItem, { value: o.id, children: o.label }, o.id))
|
||
})
|
||
]
|
||
})
|
||
}
|
||
|
||
function SchedulePicker({ state, setState }) {
|
||
const upd = patch => setState(prev => ({ ...prev, ...patch }))
|
||
const needsTime = ['daily', 'weekdays', 'weekly', 'monthly'].includes(state.freq)
|
||
|
||
return jsxs('div', {
|
||
className: 'grid gap-2',
|
||
children: [
|
||
jsxs('div', {
|
||
style: { display: 'grid', gridTemplateColumns: needsTime ? '1fr 1fr' : '1fr', gap: '8px' },
|
||
children: [
|
||
pickerSelect(state.freq, v => upd({ freq: v }), FREQUENCIES),
|
||
needsTime ? pickerSelect(state.time, v => upd({ time: v }), TIMES) : null
|
||
]
|
||
}),
|
||
state.freq === 'once'
|
||
? jsxs('div', {
|
||
style: { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px' },
|
||
children: [
|
||
jsx(Input, {
|
||
className: 'h-8',
|
||
placeholder: '30',
|
||
value: state.onceN,
|
||
onChange: event => upd({ onceN: event.target.value.replace(/[^0-9]/g, '').slice(0, 4) })
|
||
}),
|
||
pickerSelect(state.onceUnit, v => upd({ onceUnit: v }), [
|
||
{ id: 'm', label: 'minutes from now' },
|
||
{ id: 'h', label: 'hours from now' },
|
||
{ id: 'd', label: 'days from now' }
|
||
])
|
||
]
|
||
})
|
||
: null,
|
||
state.freq === 'weekly'
|
||
? pickerSelect(state.weekday, v => upd({ weekday: v }), WEEKDAYS)
|
||
: null,
|
||
state.freq === 'monthly'
|
||
? labeled(
|
||
'Day of month',
|
||
jsx(Input, {
|
||
className: 'h-8',
|
||
placeholder: '1',
|
||
value: state.monthday,
|
||
onChange: event => upd({ monthday: event.target.value.replace(/[^0-9]/g, '').slice(0, 2) })
|
||
})
|
||
)
|
||
: null,
|
||
state.freq === 'interval'
|
||
? jsxs('div', {
|
||
style: { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px' },
|
||
children: [
|
||
jsx(Input, {
|
||
className: 'h-8',
|
||
placeholder: '2',
|
||
value: state.intervalN,
|
||
onChange: event => upd({ intervalN: event.target.value.replace(/[^0-9]/g, '').slice(0, 4) })
|
||
}),
|
||
pickerSelect(state.intervalUnit, v => upd({ intervalUnit: v }), [
|
||
{ id: 'm', label: 'minutes' },
|
||
{ id: 'h', label: 'hours' },
|
||
{ id: 'd', label: 'days' }
|
||
])
|
||
]
|
||
})
|
||
: null,
|
||
state.freq === 'advanced'
|
||
? jsx(Input, {
|
||
className: 'h-8 font-mono text-xs',
|
||
placeholder: 'every 1d \u00b7 every 2h \u00b7 0 9 * * * (cron)',
|
||
value: state.raw,
|
||
onChange: event => upd({ raw: event.target.value })
|
||
})
|
||
: null,
|
||
state.freq !== 'once' && state.freq !== 'advanced'
|
||
? jsxs('div', {
|
||
className: 'flex items-center gap-2',
|
||
children: [
|
||
jsx('span', { className: 'text-xs text-(--ui-text-tertiary)', children: 'Stop after' }),
|
||
jsx(Input, {
|
||
className: 'h-7 w-16 text-xs',
|
||
placeholder: '\u221e',
|
||
value: state.repeatN,
|
||
onChange: event => upd({ repeatN: event.target.value.replace(/[^0-9]/g, '').slice(0, 4) })
|
||
}),
|
||
jsx('span', { className: 'text-xs text-(--ui-text-tertiary)', children: 'runs (blank = forever)' })
|
||
]
|
||
})
|
||
: null,
|
||
jsx('div', {
|
||
className: 'text-[0.65rem] text-(--ui-text-quaternary)',
|
||
children: `${scheduleSummary(state)} \u00b7 ${composeSchedule(state) || '\u2014'}`
|
||
})
|
||
]
|
||
})
|
||
}
|
||
|
||
function defaultScheduleState() {
|
||
return { freq: 'daily', time: '9:0', weekday: '1', monthday: '1', intervalN: '2', intervalUnit: 'h', onceN: '30', onceUnit: 'm', repeatN: '', raw: '' }
|
||
}
|
||
|
||
function CreateRoutineDialog({ bot, open, onClose }) {
|
||
const [name, setName] = useState('')
|
||
const [instruction, setInstruction] = useState('')
|
||
const [sched, setSched] = useState(defaultScheduleState())
|
||
const [continuity, setContinuity] = useState(false)
|
||
const [busy, setBusy] = useState(false)
|
||
const [error, setError] = useState(null)
|
||
const activeProfile = useValue(host.state.profile)
|
||
const schedule = composeSchedule(sched)
|
||
|
||
const reset = () => {
|
||
setName('')
|
||
setInstruction('')
|
||
setSched(defaultScheduleState())
|
||
setContinuity(false)
|
||
setBusy(false)
|
||
setError(null)
|
||
}
|
||
|
||
const submit = async () => {
|
||
const title = name.trim()
|
||
const task = instruction.trim()
|
||
const inputError = routineInputError(title, task)
|
||
|
||
if (inputError) {
|
||
setError(inputError)
|
||
return
|
||
}
|
||
|
||
if (!title || !task || !schedule.trim() || busy) {
|
||
return
|
||
}
|
||
|
||
setBusy(true)
|
||
setError(null)
|
||
|
||
try {
|
||
const repeatN =
|
||
sched.freq !== 'once' && sched.freq !== 'advanced' && String(sched.repeatN || '').trim()
|
||
? Math.max(1, parseInt(sched.repeatN, 10) || 1)
|
||
: null
|
||
await host.request('cron.manage', {
|
||
action: 'add',
|
||
name: `[bot:${bot}] ${title}`,
|
||
schedule: schedule.trim(),
|
||
prompt: routinePrompt(bot, title, task, activeProfile),
|
||
...(bot ? { profile: bot } : {}),
|
||
...(repeatN ? { repeat: repeatN } : {}),
|
||
...(continuity ? { continuity: true } : {})
|
||
})
|
||
await invalidateRoutineOwner(bot)
|
||
host.notify({ kind: 'success', message: `Cronjob "${title}" scheduled` })
|
||
reset()
|
||
onClose()
|
||
} catch (err) {
|
||
setBusy(false)
|
||
setError(err instanceof Error ? err.message : String(err))
|
||
}
|
||
}
|
||
|
||
return jsx(Dialog, {
|
||
open,
|
||
onOpenChange: value => {
|
||
if (!value && !busy) {
|
||
reset()
|
||
onClose()
|
||
}
|
||
},
|
||
children: jsxs(DialogContent, {
|
||
className: 'max-w-md',
|
||
children: [
|
||
jsxs(DialogHeader, {
|
||
children: [
|
||
jsx(DialogTitle, { children: 'New Cronjob' }),
|
||
jsx(DialogDescription, {
|
||
children: `A recurring task ${displayName({ name: bot }, $botMeta.get()[bot])} runs on a schedule. Runs land in its own chat history.`
|
||
})
|
||
]
|
||
}),
|
||
jsxs('div', {
|
||
className: 'grid gap-3.5',
|
||
children: [
|
||
labeled(
|
||
'Name',
|
||
jsx(Input, {
|
||
autoFocus: true,
|
||
placeholder: 'Name this cronjob',
|
||
value: name,
|
||
onChange: event => setName(event.target.value)
|
||
})
|
||
),
|
||
labeled(
|
||
'Instruction',
|
||
jsx(Textarea, {
|
||
className: 'min-h-20',
|
||
placeholder: 'What should this cronjob do each time it runs?',
|
||
value: instruction,
|
||
onChange: event => setInstruction(event.target.value)
|
||
})
|
||
),
|
||
labeled('When to run', jsx(SchedulePicker, { state: sched, setState: setSched })),
|
||
jsxs('label', {
|
||
className: 'flex items-center gap-2 text-xs text-(--ui-text-tertiary) cursor-pointer select-none',
|
||
children: [
|
||
jsx('input', {
|
||
type: 'checkbox',
|
||
className: 'accent-(--ui-accent)',
|
||
checked: continuity,
|
||
onChange: event => setContinuity(event.target.checked)
|
||
}),
|
||
'Continuity: each run sees the previous run\u2019s output (dedupe, continue where it left off)'
|
||
]
|
||
}),
|
||
error
|
||
? jsx('div', {
|
||
className: 'rounded-md border border-(--ui-stroke-secondary) px-3 py-2 text-xs text-(--ui-accent)',
|
||
children: error
|
||
})
|
||
: null
|
||
]
|
||
}),
|
||
jsxs(DialogFooter, {
|
||
children: [
|
||
jsx(Button, {
|
||
variant: 'ghost',
|
||
disabled: busy,
|
||
onClick: () => {
|
||
reset()
|
||
onClose()
|
||
},
|
||
children: 'Cancel'
|
||
}),
|
||
jsx(Button, {
|
||
disabled: busy || !name.trim() || !instruction.trim() || !schedule.trim(),
|
||
onClick: submit,
|
||
children: busy ? 'Scheduling…' : 'Create Cronjob'
|
||
})
|
||
]
|
||
})
|
||
]
|
||
})
|
||
})
|
||
}
|
||
|
||
function RoutinesPane() {
|
||
const selected = useValue($selectedBot)
|
||
const focusedProfile = useValue($focusedBotProfile)
|
||
// The tile maps to the bot you're chatting with: the focused chat's owner
|
||
// profile is the truth once a chat opens (on older desktops without the
|
||
// focused-owner atom this is the live gateway profile, the previous
|
||
// behavior); $selectedBot covers the gap between a roster click and the
|
||
// focus/profile swap landing.
|
||
const bot = (focusedProfile || selected || 'default').trim() || 'default'
|
||
const meta = useValue($botMeta)[bot]
|
||
const { shape, color, image } = botAppearance(bot, meta)
|
||
const { data, error, isLoading, refetch } = useRoutines(bot)
|
||
const [createOpen, setCreateOpen] = useState(false)
|
||
const [createOwner, setCreateOwner] = useState(null)
|
||
const createTarget = routineCreateTarget(createOwner, bot)
|
||
|
||
const openCreate = () => {
|
||
setCreateOwner(bot)
|
||
setCreateOpen(true)
|
||
}
|
||
|
||
const view = selectRoutineJobs(data, error, $lastJobs.get(), bot)
|
||
if (view.live) {
|
||
$lastJobs.set(view.live)
|
||
}
|
||
const jobs = view.jobs
|
||
const staleNotice = error && !view.live && view.all.length
|
||
? 'Could not refresh cronjobs. Showing the last list we had.'
|
||
: null
|
||
const filterHint = routineFilterHint(view.all, jobs)
|
||
|
||
return jsxs('div', {
|
||
className: 'flex h-full flex-col',
|
||
children: [
|
||
jsxs('div', {
|
||
className: 'flex items-center gap-2 px-3 pt-3 pb-2',
|
||
children: [
|
||
jsx(BotFace, { shape, color, image, size: 22, name: bot }),
|
||
jsxs('div', {
|
||
className: 'min-w-0 flex-1',
|
||
children: [
|
||
jsxs('div', {
|
||
className: 'flex min-w-0 items-baseline gap-1.5 truncate',
|
||
children: [
|
||
jsx('div', {
|
||
className: 'truncate text-xs font-semibold',
|
||
children: displayName({ name: bot }, meta)
|
||
}),
|
||
showsHandle(bot, meta)
|
||
? jsx('span', {
|
||
className: 'shrink-0 font-mono text-[0.65rem] text-(--ui-text-quaternary)',
|
||
children: `@${botHandle(bot)}`
|
||
})
|
||
: null
|
||
]
|
||
}),
|
||
jsx('div', {
|
||
className: 'text-[0.65rem] uppercase tracking-wider text-(--ui-text-quaternary)',
|
||
children: 'Cronjobs'
|
||
})
|
||
]
|
||
}),
|
||
jsx(Tip, {
|
||
label: 'New Cronjob',
|
||
children: jsx('button', {
|
||
type: 'button',
|
||
className:
|
||
'flex size-6 shrink-0 items-center justify-center rounded-md text-(--ui-text-tertiary) transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground',
|
||
onClick: openCreate,
|
||
children: jsx(Codicon, { name: 'add' })
|
||
})
|
||
})
|
||
]
|
||
}),
|
||
jsx('div', { className: 'mx-3 border-t border-(--ui-stroke-secondary)' }),
|
||
staleNotice
|
||
? jsx('div', {
|
||
className: 'mx-3 mt-2 rounded-md bg-(--chrome-action-hover) px-2 py-1.5 text-[0.6875rem] text-(--ui-text-tertiary)',
|
||
children: staleNotice
|
||
})
|
||
: null,
|
||
isLoading && !view.all.length
|
||
? jsx('div', {
|
||
className: 'flex flex-1 items-center justify-center',
|
||
children: jsx(GlyphSpinner, { spinner: 'breathe', className: 'text-(--ui-text-tertiary)' })
|
||
})
|
||
: error && !view.all.length
|
||
? jsxs('div', {
|
||
className: 'flex flex-1 flex-col items-center justify-center gap-3 px-4 text-center',
|
||
children: [
|
||
jsx(Codicon, { name: 'warning', className: 'text-[1.6rem] text-(--ui-text-quaternary)' }),
|
||
jsx('div', {
|
||
className: 'text-xs leading-5 text-(--ui-text-tertiary)',
|
||
children: 'Could not load cronjobs. The list may still be there.'
|
||
}),
|
||
jsx(Button, {
|
||
variant: 'secondary',
|
||
size: 'sm',
|
||
onClick: () => void refetch(),
|
||
children: 'Retry'
|
||
})
|
||
]
|
||
})
|
||
: jobs.length === 0
|
||
? jsxs('div', {
|
||
className: 'flex flex-1 flex-col items-center justify-center gap-3 px-4 text-center',
|
||
children: [
|
||
// No generic placeholder here: an icon + "cronjobs are…" blurb and the
|
||
// create button both just said "empty" (Teknium, Aug 2026). The hint
|
||
// text stays only when jobs exist but are hidden by the bot filter —
|
||
// that carries real information, not an empty-state marker.
|
||
filterHint
|
||
? jsx('div', {
|
||
className: 'text-xs leading-5 text-(--ui-text-tertiary)',
|
||
children: filterHint
|
||
})
|
||
: null,
|
||
jsx(Button, {
|
||
variant: 'secondary',
|
||
size: 'sm',
|
||
onClick: openCreate,
|
||
children: filterHint ? 'Create a cronjob for this bot' : 'Create Cronjob'
|
||
})
|
||
]
|
||
})
|
||
: jsx(ScrollArea, {
|
||
className: 'min-h-0 flex-1',
|
||
children: jsx('div', {
|
||
className: 'grid gap-1.5 px-2.5 py-2',
|
||
children: jobs.map(job => jsx(RoutineRow, { job, profile: bot }, job.job_id))
|
||
})
|
||
}),
|
||
jsx(CreateRoutineDialog, {
|
||
bot: createTarget,
|
||
open: createOpen,
|
||
onClose: () => {
|
||
setCreateOpen(false)
|
||
setCreateOwner(null)
|
||
}
|
||
// key is the jsx() THIRD argument — as a prop it is silently ignored
|
||
// and the dialog kept stale per-bot form state when the target changed.
|
||
}, createTarget)
|
||
]
|
||
})
|
||
}
|
||
|
||
// ── profile session workspace ────────────────────────────────────────────────
|
||
|
||
const PROFILE_SESSION_LIST_LIMIT = 200
|
||
|
||
function openBotSessionsWorkspace(bot) {
|
||
if (bot?.name && NAME_RE.test(bot.name)) {
|
||
$botSessionsWorkspace.set(bot.name)
|
||
}
|
||
}
|
||
|
||
function filterProfileSessions(sessions, query) {
|
||
const needle = String(query || '').trim().toLowerCase()
|
||
const rows = Array.isArray(sessions) ? sessions : []
|
||
if (!needle) return rows
|
||
return rows.filter(session =>
|
||
`${session?.title || ''} ${session?.preview || ''} ${session?.source || ''}`.toLowerCase().includes(needle)
|
||
)
|
||
}
|
||
|
||
function useProfileSessions(botName, gatewayGeneration) {
|
||
return useQuery({
|
||
queryKey: [ID, 'profile-sessions', botName, gatewayGeneration],
|
||
enabled: Boolean(botName),
|
||
// include_hidden: this browser exists precisely to see the profile's own
|
||
// (always-hidden) Bot Mode sessions alongside its regular ones.
|
||
queryFn: () => host.request('session.list', { profile: botName, limit: PROFILE_SESSION_LIST_LIMIT, include_hidden: true }),
|
||
refetchInterval: 8000,
|
||
staleTime: 4000,
|
||
retry: false
|
||
})
|
||
}
|
||
|
||
async function openProfileSession(botName, session, gatewayGeneration) {
|
||
const profile = String(botName || '')
|
||
const id = String(session?.id || '')
|
||
if (!NAME_RE.test(profile) || !id || gatewayGeneration !== $sessionsGatewayGeneration.get()) return
|
||
if (typeof host.openSession !== 'function') {
|
||
throw new Error('This Hermes Desktop version cannot open stored sessions')
|
||
}
|
||
|
||
// Same hydration contract as canonical Bot Chats (#89206): a bare open can
|
||
// focus a main surface whose runtime/transcript silently vanished, leaving a
|
||
// blank pane while the row preview still shows the conversation. Waiting on
|
||
// hydration lets the SDK issue the explicit resume when the surface is stale.
|
||
const hasAuthoritativeCount =
|
||
typeof session?.message_count === 'number' && Number.isFinite(session.message_count)
|
||
const expectHistory = hasAuthoritativeCount ? session.message_count > 0 : Boolean(session?.preview)
|
||
|
||
await host.openSession(id, { profile, awaitHydration: true, expectHistory })
|
||
if (gatewayGeneration !== $sessionsGatewayGeneration.get()) return
|
||
$botSelectedSessions.set({ ...$botSelectedSessions.get(), [profile]: id })
|
||
}
|
||
|
||
function ProfileSessionRow({ session, botName, active, gatewayGeneration }) {
|
||
return jsxs('button', {
|
||
type: 'button',
|
||
'aria-current': active ? 'page' : undefined,
|
||
onClick: () => void openProfileSession(botName, session, gatewayGeneration).catch(err => host.notifyError(err, 'Could not open session')),
|
||
className: cn(
|
||
'flex w-full flex-col gap-0.5 overflow-hidden rounded-md px-2 py-1.5 text-left transition-colors',
|
||
'hover:bg-(--chrome-action-hover)',
|
||
active && 'bg-(--ui-row-active-background)'
|
||
),
|
||
children: [
|
||
jsx('span', {
|
||
className: 'truncate text-[0.8125rem] font-medium',
|
||
children: session.title || 'Untitled session'
|
||
}),
|
||
jsx('div', {
|
||
className: 'truncate text-[0.7rem] text-(--ui-text-tertiary)',
|
||
children: session.preview || session.source || 'No messages yet'
|
||
})
|
||
]
|
||
})
|
||
}
|
||
|
||
function ProfileSessionsWorkspace({ bot }) {
|
||
const gatewayGeneration = useValue($sessionsGatewayGeneration)
|
||
const { data, isLoading, error } = useProfileSessions(bot.name, gatewayGeneration)
|
||
const selectedByProfile = useValue($botSelectedSessions)
|
||
const [query, setQuery] = useState('')
|
||
const sourceSessions = data?.sessions || []
|
||
const sessions = filterProfileSessions(sourceSessions, query)
|
||
const inventoryBounded = sourceSessions.length >= PROFILE_SESSION_LIST_LIMIT
|
||
const selectedId = selectedByProfile[bot.name] || ''
|
||
|
||
const header = jsxs('div', {
|
||
className: 'flex items-center gap-2 px-2.5 pt-2.5 pb-2',
|
||
children: [
|
||
jsx(Button, {
|
||
variant: 'ghost',
|
||
size: 'sm',
|
||
onClick: () => $botSessionsWorkspace.set(null),
|
||
children: 'Back'
|
||
}),
|
||
jsx('div', {
|
||
className: 'min-w-0 flex-1 truncate text-sm font-semibold',
|
||
children: `${displayName(bot, $botMeta.get()[bot.name])} sessions`
|
||
})
|
||
]
|
||
})
|
||
|
||
return jsxs('div', {
|
||
className: 'flex h-full flex-col',
|
||
children: [
|
||
header,
|
||
jsx('div', {
|
||
className: 'px-2 pb-2',
|
||
children: jsx(Input, {
|
||
'aria-label': 'Filter sessions',
|
||
placeholder: 'Filter sessions…',
|
||
value: query,
|
||
onChange: event => setQuery(event.target.value)
|
||
})
|
||
}),
|
||
inventoryBounded
|
||
? jsx('div', {
|
||
className: 'px-2.5 pb-2 text-[0.65rem] text-(--ui-text-quaternary)',
|
||
children: `Showing the ${PROFILE_SESSION_LIST_LIMIT} most recent sessions.`
|
||
})
|
||
: null,
|
||
isLoading
|
||
? jsx('div', {
|
||
className: 'flex flex-1 items-center justify-center',
|
||
children: jsx(GlyphSpinner, { spinner: 'breathe' })
|
||
})
|
||
: error
|
||
? jsx('div', {
|
||
className: 'px-3 py-3 text-xs text-(--ui-text-tertiary)',
|
||
children: 'Could not load sessions for this profile.'
|
||
})
|
||
: jsx(ScrollArea, {
|
||
className: 'min-h-0 flex-1',
|
||
children: jsx('div', {
|
||
className: 'grid gap-0.5 px-1.5 pb-2',
|
||
children: sessions.length
|
||
? sessions.map(session => jsx(ProfileSessionRow, {
|
||
session,
|
||
botName: bot.name,
|
||
active: selectedId === session.id,
|
||
gatewayGeneration
|
||
}, session.id))
|
||
: jsx('div', {
|
||
className: 'px-2 py-3 text-center text-xs text-(--ui-text-tertiary)',
|
||
children: query.trim()
|
||
? inventoryBounded
|
||
? `No matching sessions in the ${PROFILE_SESSION_LIST_LIMIT} most recent.`
|
||
: 'No sessions match that filter.'
|
||
: 'No stored sessions yet.'
|
||
})
|
||
})
|
||
})
|
||
]
|
||
})
|
||
}
|
||
|
||
// ── roster pane ──────────────────────────────────────────────────────────────
|
||
|
||
/** "Active now" presence strip above the roster: chips for every bot that is
|
||
* working right now (the gateway-busy selected profile + bots whose last
|
||
* message landed inside the liveness window). Reuses the row avatar; each
|
||
* chip opens that bot's canonical Bot Chat. Omitted entirely when nothing
|
||
* is active, and never reorders the roster below it. */
|
||
function ActiveNowStrip({ roster, activeProfile, gatewayState, metaByName, onOpen }) {
|
||
const active = activeBots(roster, activeProfile, gatewayState)
|
||
|
||
if (!active.length) {
|
||
return null
|
||
}
|
||
|
||
return jsxs('div', {
|
||
role: 'status',
|
||
'aria-live': 'polite',
|
||
'aria-label': 'Active now',
|
||
className: 'flex flex-wrap items-center gap-1.5 px-2.5 pb-1.5',
|
||
children: [
|
||
jsx('span', {
|
||
className: 'text-[0.6875rem] font-semibold uppercase tracking-wider text-(--ui-text-quaternary)',
|
||
children: 'Active now'
|
||
}),
|
||
...active.map(bot => {
|
||
const meta = metaByName?.[bot.name]
|
||
const { shape, color, image } = botAppearance(bot.name, meta)
|
||
const photo = Boolean(image && !isBackfilledFacePng(image))
|
||
const label = displayName(bot, meta)
|
||
|
||
return jsx('button', {
|
||
type: 'button',
|
||
title: `Open ${label}'s chat`,
|
||
className: cn(
|
||
'flex items-center gap-1.5 rounded-md bg-(--chrome-action-hover) px-1.5 py-1 text-left transition-colors',
|
||
'hover:bg-(--chrome-action-hover) hover:text-foreground'
|
||
),
|
||
onClick: () => onOpen(bot),
|
||
children: [
|
||
jsx(BotFace, {
|
||
shape,
|
||
color,
|
||
image: photo ? image : null,
|
||
size: 24,
|
||
name: bot.name,
|
||
mood: 'work'
|
||
}),
|
||
jsx('span', {
|
||
className: 'max-w-28 truncate text-xs font-medium',
|
||
children: label
|
||
})
|
||
]
|
||
}, botRosterKey(bot))
|
||
})
|
||
]
|
||
})
|
||
}
|
||
|
||
/** Assign a bot to a group-chat membership without replacing its others.
|
||
* Existing groups are independent toggles; the input creates and joins a new
|
||
* one. Canonical groups + the legacy scalar projection ride ui_meta. */
|
||
function GroupDialog({ bot, onClose }) {
|
||
const meta = useValue($botMeta)
|
||
const [name, setName] = useState('')
|
||
const current = botGroups(meta[bot?.name])
|
||
const groups = knownGroups(meta)
|
||
|
||
const setMembership = (group, enabled) => {
|
||
saveBotMeta(bot.name, groupMembershipPatch(meta[bot.name], group, enabled))
|
||
host.notify({
|
||
kind: 'info',
|
||
message: enabled
|
||
? `${displayName(bot, meta[bot.name])} added to “${group}”`
|
||
: `${displayName(bot, meta[bot.name])} removed from “${group}”`
|
||
})
|
||
}
|
||
|
||
return jsx(Dialog, {
|
||
open: Boolean(bot),
|
||
onOpenChange: value => {
|
||
if (!value) {
|
||
onClose()
|
||
}
|
||
},
|
||
children: jsxs(DialogContent, {
|
||
className: 'max-w-sm',
|
||
children: [
|
||
jsxs(DialogHeader, {
|
||
children: [
|
||
jsx(DialogTitle, { children: 'Manage groups' }),
|
||
jsx(DialogDescription, {
|
||
children: 'A bot can join multiple group chats. Memberships sync to every machine.'
|
||
})
|
||
]
|
||
}),
|
||
groups.length
|
||
? jsx('div', {
|
||
className: 'grid gap-1.5',
|
||
children: groups.map(group => {
|
||
const enabled = current.includes(group)
|
||
|
||
return jsxs(
|
||
'label',
|
||
{
|
||
className:
|
||
'flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-(--chrome-action-hover)',
|
||
children: [
|
||
jsx(Checkbox, {
|
||
checked: enabled,
|
||
onCheckedChange: checked => setMembership(group, checked === true)
|
||
}),
|
||
jsx('span', { children: group })
|
||
]
|
||
},
|
||
group
|
||
)
|
||
})
|
||
})
|
||
: null,
|
||
jsxs('form', {
|
||
className: 'flex items-center gap-1.5',
|
||
onSubmit: event => {
|
||
event.preventDefault()
|
||
const trimmed = name.trim()
|
||
|
||
if (trimmed) {
|
||
setMembership(trimmed, true)
|
||
setName('')
|
||
}
|
||
},
|
||
children: [
|
||
jsx(Input, {
|
||
autoFocus: true,
|
||
placeholder: groups.length ? 'New group…' : 'Group name (e.g. Research)',
|
||
value: name,
|
||
onChange: event => setName(event.target.value)
|
||
}),
|
||
jsx(Button, { type: 'submit', size: 'sm', disabled: !name.trim(), children: 'Create & join' })
|
||
]
|
||
}),
|
||
current.length
|
||
? jsx(Button, {
|
||
variant: 'ghost',
|
||
size: 'sm',
|
||
className: 'justify-self-start',
|
||
onClick: () => saveBotMeta(bot.name, { groups: [], group: null }),
|
||
children: 'Remove from all groups'
|
||
})
|
||
: null
|
||
]
|
||
})
|
||
})
|
||
}
|
||
|
||
/** Compact picture controls shared by group-chat creation and settings:
|
||
* a live preview (image, else the organization glyph), Upload / Generate /
|
||
* Remove. Reuses the bot-avatar pipeline (device picker, 256px normalize,
|
||
* image.generate probe) so room pictures cost the same as bot avatars. */
|
||
function GroupImageControls({ image, onImage, seedName, seedMembers }) {
|
||
const imagen = useValue($imagenAvailable)
|
||
const [busy, setBusy] = useState(false)
|
||
|
||
if (imagen === null) {
|
||
void probeImagen()
|
||
}
|
||
|
||
const upload = async () => {
|
||
const raw = await pickImageFromDevice()
|
||
|
||
if (raw) {
|
||
onImage(await normalizeAvatarImage(raw))
|
||
}
|
||
}
|
||
|
||
const generate = async () => {
|
||
if (busy) {
|
||
return
|
||
}
|
||
|
||
setBusy(true)
|
||
|
||
try {
|
||
const who = [seedName, seedMembers?.length ? `a team of ${seedMembers.join(', ')}` : '']
|
||
.filter(Boolean)
|
||
.join(' — ')
|
||
const res = await host.request('image.generate', {
|
||
prompt:
|
||
`Group chat icon for an AI agent team called "${who || 'a bot team'}". ` +
|
||
'Friendly minimal emblem, bold flat vector style, solid color background, centered, no text.',
|
||
aspect_ratio: 'square'
|
||
})
|
||
|
||
if (!res?.success) {
|
||
throw new Error(res?.error || 'generation failed')
|
||
}
|
||
|
||
const img = res.image_data || res.image
|
||
|
||
if (img) {
|
||
onImage(await normalizeAvatarImage(img))
|
||
}
|
||
} catch (err) {
|
||
host.notifyError(err, 'Group picture generation failed')
|
||
} finally {
|
||
setBusy(false)
|
||
}
|
||
}
|
||
|
||
return jsxs('div', {
|
||
className: 'flex items-center gap-2',
|
||
children: [
|
||
jsx('div', {
|
||
className:
|
||
'flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-full bg-(--chrome-action-hover)',
|
||
children: image
|
||
? jsx('img', { src: image, alt: '', className: 'size-full object-cover' })
|
||
: jsx(Codicon, { name: 'organization', className: 'text-(--ui-text-tertiary)' })
|
||
}),
|
||
jsx(Button, { type: 'button', variant: 'secondary', size: 'sm', onClick: upload, children: 'Upload' }),
|
||
imagen
|
||
? jsx(Button, {
|
||
type: 'button',
|
||
variant: 'secondary',
|
||
size: 'sm',
|
||
disabled: busy,
|
||
onClick: generate,
|
||
children: busy ? 'Generating…' : 'Generate'
|
||
})
|
||
: null,
|
||
image
|
||
? jsx(Button, { type: 'button', variant: 'ghost', size: 'sm', onClick: () => onImage(null), children: 'Remove' })
|
||
: null
|
||
]
|
||
})
|
||
}
|
||
|
||
/** Edit an existing group chat's name and picture. Renames re-key the room
|
||
* and every local member's membership (renameGroupChat); the picture rides
|
||
* the room record. Both apply on Save so a cancelled dialog changes nothing. */
|
||
function GroupChatSettingsDialog({ group, members, open, onClose, onRenamed }) {
|
||
const rooms = useValue($groupChats)
|
||
const current = (rooms[group] || {}).image || null
|
||
const [name, setName] = useState(group)
|
||
const [image, setImage] = useState(current)
|
||
|
||
useEffect(() => {
|
||
if (open) {
|
||
setName(group)
|
||
setImage(current)
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [open, group])
|
||
|
||
const save = async () => {
|
||
const finalName = await renameGroupChat(group, name, members)
|
||
|
||
if (finalName === null) {
|
||
return // collision — dialog stays open for a different name
|
||
}
|
||
|
||
if (image !== current) {
|
||
setGroupChatImage(finalName, image)
|
||
}
|
||
|
||
onClose()
|
||
|
||
if (finalName !== group) {
|
||
onRenamed?.(finalName)
|
||
}
|
||
}
|
||
|
||
return jsx(Dialog, {
|
||
open,
|
||
onOpenChange: value => {
|
||
if (!value) {
|
||
onClose()
|
||
}
|
||
},
|
||
children: jsxs(DialogContent, {
|
||
className: 'max-w-sm',
|
||
children: [
|
||
jsxs(DialogHeader, {
|
||
children: [
|
||
jsx(DialogTitle, { children: 'Group settings' }),
|
||
jsx(DialogDescription, {
|
||
children: 'Rename the group or set a room picture. Members and history are kept.'
|
||
})
|
||
]
|
||
}),
|
||
jsx(GroupImageControls, {
|
||
image,
|
||
onImage: setImage,
|
||
seedName: name.trim() || group,
|
||
seedMembers: (members || []).map(b => b.name)
|
||
}),
|
||
jsx('form', {
|
||
onSubmit: event => {
|
||
event.preventDefault()
|
||
void save()
|
||
},
|
||
children: jsx(Input, {
|
||
'aria-label': 'Group name',
|
||
autoFocus: true,
|
||
maxLength: 64,
|
||
value: name,
|
||
onChange: event => setName(event.target.value)
|
||
})
|
||
}),
|
||
jsxs(DialogFooter, {
|
||
children: [
|
||
jsx(Button, { variant: 'secondary', onClick: onClose, children: 'Cancel' }),
|
||
jsx(Button, { disabled: !name.trim(), onClick: () => void save(), children: 'Save' })
|
||
]
|
||
})
|
||
]
|
||
})
|
||
})
|
||
}
|
||
|
||
/** Discord-style group chat creation: pick 2+ bots via checkboxes (with
|
||
* search), name the group, create. Assignment appends to each local bot's
|
||
* group membership list, so the room appears in the roster and syncs
|
||
* cross-machine via ui_meta without replacing its other groups. */
|
||
function CreateGroupChatDialog({ open, roster, onClose, onCreated }) {
|
||
const allMeta = useValue($botMeta)
|
||
const [query, setQuery] = useState('')
|
||
const [checked, setChecked] = useState({})
|
||
const [name, setName] = useState('')
|
||
const [image, setImage] = useState(null)
|
||
|
||
// Reset per open so a cancelled draft doesn't leak into the next one.
|
||
useEffect(() => {
|
||
if (open) {
|
||
setQuery('')
|
||
setChecked({})
|
||
setName('')
|
||
setImage(null)
|
||
}
|
||
}, [open])
|
||
|
||
const selected = roster.filter(bot => checked[botRosterKey(bot)])
|
||
const visible = filterBots(roster, allMeta, query)
|
||
const atCap = selected.length >= GROUP_CHAT_MAX_MEMBERS
|
||
const placeholder = selected.length
|
||
? selected.map(bot => displayName(bot, botRosterMeta(bot, allMeta))).join(', ')
|
||
: 'Group name'
|
||
const canCreate = selected.length >= 2 && Boolean(name.trim() || selected.length)
|
||
|
||
const create = () => {
|
||
let groupName = (name.trim() || placeholder).slice(0, 64)
|
||
|
||
if (selected.length < 2 || !groupName) {
|
||
return
|
||
}
|
||
|
||
// Creating a group is always a FRESH room. Without this, re-creating a
|
||
// group under an existing name (easy — the default name is just the
|
||
// member names) silently reopens the old room with its full log, which
|
||
// reads as "not a fresh group" (db's Aug 2026 report). Uniquify against
|
||
// both live rooms and any bot's current grouping.
|
||
const taken = new Set(Object.keys($groupChats.get()))
|
||
|
||
for (const meta of Object.values($botMeta.get() || {})) {
|
||
for (const existing of botGroups(meta)) {
|
||
taken.add(existing)
|
||
}
|
||
}
|
||
|
||
if (taken.has(groupName)) {
|
||
let n = 2
|
||
|
||
while (taken.has(`${groupName} ${n}`)) {
|
||
n += 1
|
||
}
|
||
|
||
groupName = `${groupName} ${n}`.slice(0, 64)
|
||
}
|
||
|
||
for (const bot of selected) {
|
||
if (!bot.remoteSource) {
|
||
void saveBotMeta(bot.name, groupMembershipPatch(botRosterMeta(bot, allMeta), groupName, true))
|
||
}
|
||
}
|
||
|
||
// Persist every machine identity, including today's active source. That
|
||
// member becomes remote after a source switch and cannot rely on the new
|
||
// gateway's name-keyed bot metadata to remain seated in this room.
|
||
const roomMembers = durableGroupChatMembers(selected)
|
||
|
||
updateGroupChat(groupName, room => {
|
||
room.members = roomMembers
|
||
|
||
if (image) {
|
||
room.image = image
|
||
}
|
||
|
||
return room
|
||
})
|
||
|
||
host.notify({ kind: 'info', message: `“${groupName}” created with ${selected.length} bots` })
|
||
onClose()
|
||
onCreated?.(groupName)
|
||
}
|
||
|
||
return jsx(Dialog, {
|
||
open,
|
||
onOpenChange: value => {
|
||
if (!value) {
|
||
onClose()
|
||
}
|
||
},
|
||
children: jsxs(DialogContent, {
|
||
className: 'max-w-md',
|
||
children: [
|
||
jsxs(DialogHeader, {
|
||
children: [
|
||
jsx(DialogTitle, { children: 'New Group Chat' }),
|
||
jsx(DialogDescription, {
|
||
children: `Pick 2–${GROUP_CHAT_MAX_MEMBERS} bots. Local memberships sync through each Bot profile; cross-machine members stay scoped to this room.`
|
||
})
|
||
]
|
||
}),
|
||
jsx(SearchField, {
|
||
'aria-label': 'Search bots to add',
|
||
autoFocus: true,
|
||
containerClassName: 'w-full',
|
||
inputClassName: 'w-full',
|
||
placeholder: 'Search bots to add…',
|
||
value: query,
|
||
onChange: setQuery
|
||
}),
|
||
selected.length
|
||
? jsx('div', {
|
||
className: 'flex flex-wrap gap-1',
|
||
children: selected.map(bot =>
|
||
jsxs('button', {
|
||
type: 'button',
|
||
className:
|
||
'flex items-center gap-1 rounded-full bg-(--chrome-action-hover) py-0.5 pl-2 pr-1.5 text-[0.6875rem] text-(--ui-text-secondary) transition-colors hover:text-foreground',
|
||
title: 'Remove from selection',
|
||
onClick: () => setChecked(prev => ({ ...prev, [botRosterKey(bot)]: false })),
|
||
children: [displayName(bot, botRosterMeta(bot, allMeta)), jsx(Codicon, { name: 'close', className: 'text-[0.6rem]' })]
|
||
}, botRosterKey(bot))
|
||
)
|
||
})
|
||
: null,
|
||
jsx(ScrollArea, {
|
||
className: 'max-h-64 min-h-0',
|
||
children: jsx('div', {
|
||
className: 'grid gap-0.5 pr-2',
|
||
children: visible.length
|
||
? visible.map(bot => {
|
||
const meta = botRosterMeta(bot, allMeta)
|
||
const { shape, color, image } = botAppearance(bot.name, meta)
|
||
const isChecked = Boolean(checked[botRosterKey(bot)])
|
||
const disabled = !isChecked && atCap
|
||
const currentGroups = botGroups(meta)
|
||
|
||
return jsxs('label', {
|
||
className: cn(
|
||
'flex cursor-pointer items-center gap-2 rounded-md px-1.5 py-1 transition-colors hover:bg-(--chrome-action-hover)',
|
||
disabled && 'cursor-not-allowed opacity-50'
|
||
),
|
||
children: [
|
||
jsx(BotFace, {
|
||
shape,
|
||
color,
|
||
image: image && !isBackfilledFacePng(image) ? image : null,
|
||
size: 24,
|
||
name: bot.name
|
||
}),
|
||
jsxs('div', {
|
||
className: 'min-w-0 flex-1',
|
||
children: [
|
||
jsx('div', { className: 'truncate text-xs text-foreground', children: displayName(bot, meta) }),
|
||
jsx('div', {
|
||
className: 'truncate text-[0.625rem] text-(--ui-text-quaternary)',
|
||
children: [
|
||
currentGroups.length
|
||
? `@${botHandle(bot.name, bot)} · in ${currentGroups.map(group => `“${group}”`).join(', ')}`
|
||
: `@${botHandle(bot.name, bot)}`,
|
||
bot.remoteSource && bot.connectionLabel ? ` · ${bot.connectionLabel}` : ''
|
||
].join('')
|
||
})
|
||
]
|
||
}),
|
||
jsx(Checkbox, {
|
||
checked: isChecked,
|
||
disabled,
|
||
onCheckedChange: value => setChecked(prev => ({ ...prev, [botRosterKey(bot)]: Boolean(value) }))
|
||
})
|
||
]
|
||
}, botRosterKey(bot))
|
||
})
|
||
: jsx('div', {
|
||
className: 'px-1.5 py-3 text-center text-xs text-(--ui-text-tertiary)',
|
||
children: query.trim() ? `No bots match “${query.trim()}”` : 'No bots yet — create agents first.'
|
||
})
|
||
})
|
||
}),
|
||
jsxs('div', {
|
||
className: 'grid gap-2',
|
||
children: [
|
||
jsx(GroupImageControls, {
|
||
image,
|
||
onImage: setImage,
|
||
seedName: name.trim() || (selected.length ? placeholder : ''),
|
||
seedMembers: selected.map(bot => displayName(bot, botRosterMeta(bot, allMeta)))
|
||
}),
|
||
jsx('form', {
|
||
onSubmit: event => {
|
||
event.preventDefault()
|
||
create()
|
||
},
|
||
children: jsx(Input, {
|
||
'aria-label': 'Group name',
|
||
maxLength: 64,
|
||
placeholder,
|
||
value: name,
|
||
onChange: event => setName(event.target.value)
|
||
})
|
||
})
|
||
]
|
||
}),
|
||
jsxs(DialogFooter, {
|
||
children: [
|
||
jsx(Button, { variant: 'secondary', onClick: onClose, children: 'Cancel' }),
|
||
jsx(Button, {
|
||
disabled: !canCreate,
|
||
title: selected.length < 2 ? 'Pick at least 2 bots' : undefined,
|
||
onClick: create,
|
||
children: `Create Group${selected.length ? ` (${selected.length})` : ''}`
|
||
})
|
||
]
|
||
})
|
||
]
|
||
})
|
||
})
|
||
}
|
||
|
||
// ── threads: the Slack/Discord shape ─────────────────────────────────────────
|
||
// Every room entry belongs to a THREAD. Messaging the room composer starts a
|
||
// new thread with the whole group; replying inside a thread continues that
|
||
// work. Member turns are scoped to the thread that triggered them — deltas,
|
||
// watermarks, and responder resolution all key on the thread id.
|
||
|
||
function groupThreadOf(entry) {
|
||
return entry?.thread || 'legacy'
|
||
}
|
||
|
||
function mintGroupThreadId() {
|
||
return `t${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`
|
||
}
|
||
|
||
// Pre-thread logs (hydrated from storage) get synthetic thread ids: a user
|
||
// entry after a real lull starts one, so multi-turn tasks stay whole instead
|
||
// of splitting on every follow-up.
|
||
const GROUP_THREAD_GAP_MS = 15 * 60000
|
||
|
||
function assignLegacyThreads(log) {
|
||
let current = null
|
||
let n = 0
|
||
|
||
return (log || []).map((entry, i) => {
|
||
if (entry?.thread) {
|
||
current = null
|
||
|
||
return entry
|
||
}
|
||
|
||
const prev = log[i - 1]
|
||
const lull = !prev || (entry.at || 0) - (prev.at || 0) > GROUP_THREAD_GAP_MS
|
||
|
||
if (!current || (entry.from?.kind === 'user' && lull)) {
|
||
current = `legacy-${n++}`
|
||
}
|
||
|
||
return { ...entry, thread: current }
|
||
})
|
||
}
|
||
|
||
/** Merged room view for one group: shared timeline with per-member
|
||
* attribution, a composer that drives the round-robin, and a working
|
||
* indicator while member turns run. Renders identically in the MAIN chat
|
||
* window (host.openWorkspace tile) and in the bots panel (older-desktop
|
||
* fallback); `onBack` is where the Back button routes — the main tile's
|
||
* closer, or clearing the in-panel workspace atom. */
|
||
/** The active @-token at the caret: text from the nearest '@' (that begins a
|
||
* word) up to the caret, or null when the caret isn't inside a mention. */
|
||
function mentionTokenAt(text, caret) {
|
||
const upto = String(text || '').slice(0, caret)
|
||
const match = /(^|\s)@([a-z0-9._-]*)$/i.exec(upto)
|
||
|
||
if (!match) {
|
||
return null
|
||
}
|
||
|
||
return { query: match[2].toLowerCase(), start: caret - match[2].length - 1 }
|
||
}
|
||
|
||
/** Mention-aware composer input for group rooms. The core composer's
|
||
* @-completion area doesn't mount inside workspace tiles (#89049), so this
|
||
* wraps the plain SDK Input with a member-scoped popover: @everyone/@all
|
||
* quick picks plus each seated member's handle. Insertion produces exactly
|
||
* the strings parseGroupChatMentions resolves. Keyboard: Up/Down navigate,
|
||
* Enter/Tab insert (Enter falls through to submit when the popover is
|
||
* closed), Escape dismisses. */
|
||
function GroupMentionInput({ members, onChange, value, ...inputProps }) {
|
||
const allMeta = useValue($botMeta)
|
||
const inputRef = useRef(null)
|
||
const [token, setToken] = useState(null)
|
||
const [selected, setSelected] = useState(0)
|
||
|
||
const options = []
|
||
|
||
if (token) {
|
||
for (const pick of ['everyone', 'all']) {
|
||
if (pick.startsWith(token.query)) {
|
||
options.push({ handle: pick, meta: 'Every bot in the room' })
|
||
}
|
||
}
|
||
|
||
for (const member of members) {
|
||
const handle = String(member.handle || botHandle(member.name, member) || '').trim()
|
||
|
||
if (!handle || (token.query && !handle.toLowerCase().startsWith(token.query))) {
|
||
continue
|
||
}
|
||
|
||
options.push({
|
||
handle,
|
||
meta: displayName(member, botRosterMeta(member, allMeta))
|
||
})
|
||
}
|
||
}
|
||
|
||
const open = Boolean(token) && options.length > 0
|
||
const active = open ? Math.min(selected, options.length - 1) : 0
|
||
|
||
const refreshToken = target => {
|
||
setToken(mentionTokenAt(target.value, target.selectionStart ?? target.value.length))
|
||
setSelected(0)
|
||
}
|
||
|
||
const insert = handle => {
|
||
if (!token) {
|
||
return
|
||
}
|
||
|
||
const caret = inputRef.current?.selectionStart ?? value.length
|
||
const next = `${value.slice(0, token.start)}@${handle} ${value.slice(caret)}`
|
||
onChange(next)
|
||
setToken(null)
|
||
|
||
// Restore focus with the caret after the inserted mention.
|
||
const pos = token.start + handle.length + 2
|
||
requestAnimationFrame(() => {
|
||
const el = inputRef.current
|
||
|
||
if (el) {
|
||
el.focus()
|
||
try {
|
||
el.setSelectionRange(pos, pos)
|
||
} catch {
|
||
/* input type without selection support */
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
return jsxs('div', {
|
||
className: 'relative min-w-0 flex-1',
|
||
children: [
|
||
open
|
||
? jsx('div', {
|
||
className:
|
||
'absolute bottom-full left-0 z-50 mb-1 max-h-48 w-64 overflow-y-auto rounded-md border border-(--ui-stroke-secondary) bg-(--ui-bg-primary,#111) py-1 shadow-lg',
|
||
children: options.map((option, index) =>
|
||
jsxs('button', {
|
||
type: 'button',
|
||
className: cn(
|
||
'flex w-full items-baseline gap-2 px-2 py-1 text-left text-xs',
|
||
index === active ? 'bg-(--ui-control-hover-background) text-foreground' : 'text-(--ui-text-secondary)'
|
||
),
|
||
// preventDefault on mousedown so the input keeps focus.
|
||
onMouseDown: event => {
|
||
event.preventDefault()
|
||
insert(option.handle)
|
||
},
|
||
onMouseEnter: () => setSelected(index),
|
||
children: [
|
||
jsx('span', { className: 'font-medium', children: `@${option.handle}` }),
|
||
jsx('span', { className: 'truncate text-[0.65rem] text-(--ui-text-quaternary)', children: option.meta })
|
||
]
|
||
}, option.handle)
|
||
)
|
||
})
|
||
: null,
|
||
jsx(Input, {
|
||
...inputProps,
|
||
ref: inputRef,
|
||
value,
|
||
onChange: event => {
|
||
onChange(event.target.value)
|
||
refreshToken(event.target)
|
||
},
|
||
onClick: event => refreshToken(event.target),
|
||
onKeyDown: event => {
|
||
if (!open) {
|
||
return
|
||
}
|
||
|
||
if (event.key === 'ArrowDown') {
|
||
event.preventDefault()
|
||
setSelected((active + 1) % options.length)
|
||
} else if (event.key === 'ArrowUp') {
|
||
event.preventDefault()
|
||
setSelected((active - 1 + options.length) % options.length)
|
||
} else if (event.key === 'Enter' || event.key === 'Tab') {
|
||
event.preventDefault()
|
||
insert(options[active].handle)
|
||
} else if (event.key === 'Escape') {
|
||
event.preventDefault()
|
||
setToken(null)
|
||
}
|
||
},
|
||
onBlur: () => setToken(null)
|
||
})
|
||
]
|
||
})
|
||
}
|
||
|
||
function GroupChatWorkspace({ group, members, onBack }) {
|
||
const rooms = useValue($groupChats)
|
||
const allMeta = useValue($botMeta)
|
||
const room = rooms[group] || { log: [], running: false }
|
||
const [draft, setDraft] = useState('')
|
||
const [confirmDisband, setConfirmDisband] = useState(false)
|
||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||
// Click-to-disambiguate: which log entry is showing its speaker's full
|
||
// @handle (the roster's name-device form when names collide across
|
||
// connections). Naturally every speaker just shows its display name.
|
||
const [revealedSpeaker, setRevealedSpeaker] = useState(null)
|
||
// Threads, the Slack/Discord shape: entries carry a thread id. The most
|
||
// recently active thread renders open; older ones collapse to summary rows.
|
||
// `openThreads` is the user's explicit expand/collapse overrides, and
|
||
// `replyThread` is the thread whose reply box currently owns the composer
|
||
// (null = the main composer, which STARTS a new thread).
|
||
const [openThreads, setOpenThreads] = useState({})
|
||
const [replyThread, setReplyThread] = useState(null)
|
||
const [replyDrafts, setReplyDrafts] = useState({})
|
||
// Pending image attachments per composer: `null` thread key = the main
|
||
// composer, otherwise the reply box of that thread. Data URLs, already
|
||
// downscaled — they ride the send into every responding member's session.
|
||
const [pendingImages, setPendingImages] = useState({})
|
||
|
||
const imagesFor = thread => pendingImages[thread ?? 'main'] || []
|
||
|
||
const addImages = (thread, picked) => {
|
||
if (!picked.length) {
|
||
return
|
||
}
|
||
|
||
const key = thread ?? 'main'
|
||
setPendingImages(prev => ({ ...prev, [key]: [...(prev[key] || []), ...picked] }))
|
||
}
|
||
|
||
const clearImages = thread => {
|
||
const key = thread ?? 'main'
|
||
setPendingImages(prev => ({ ...prev, [key]: [] }))
|
||
}
|
||
|
||
const removeImage = (thread, index) => {
|
||
const key = thread ?? 'main'
|
||
setPendingImages(prev => ({ ...prev, [key]: (prev[key] || []).filter((_, i) => i !== index) }))
|
||
}
|
||
|
||
// Ctrl/⌘-V a screenshot (or any file) into any composer in this room.
|
||
const pasteImages = (thread, event) => {
|
||
const files = [...(event.clipboardData?.files || [])]
|
||
|
||
if (!files.length) {
|
||
return
|
||
}
|
||
|
||
event.preventDefault()
|
||
void filesToGroupAttachments(files).then(picked => addImages(thread, picked))
|
||
}
|
||
|
||
// Drag & drop anywhere on the room drops into the ACTIVE composer — the
|
||
// open reply box when one owns the composer, else the main (new-thread)
|
||
// composer. Matches the 1:1 chat's drop affordance.
|
||
const [dragOver, setDragOver] = useState(false)
|
||
|
||
const dropFiles = event => {
|
||
const files = [...(event.dataTransfer?.files || [])]
|
||
|
||
setDragOver(false)
|
||
|
||
if (!files.length) {
|
||
return
|
||
}
|
||
|
||
event.preventDefault()
|
||
void filesToGroupAttachments(files).then(picked => addImages(replyThread, picked))
|
||
}
|
||
|
||
const header = jsxs('div', {
|
||
className: 'flex items-center gap-2 px-2.5 pt-2.5 pb-2',
|
||
children: [
|
||
jsx(Button, {
|
||
variant: 'ghost',
|
||
size: 'sm',
|
||
onClick: () => (onBack ? onBack() : $groupChatWorkspace.set(null)),
|
||
children: 'Back'
|
||
}),
|
||
// Room picture (set via Group settings) leads the title when present.
|
||
room.image
|
||
? jsx('img', {
|
||
src: room.image,
|
||
alt: '',
|
||
className: 'size-6 shrink-0 rounded-full object-cover ring-1 ring-(--ui-stroke-secondary)'
|
||
})
|
||
: null,
|
||
jsx('div', {
|
||
className: 'min-w-0 flex-1 truncate text-sm font-semibold',
|
||
children: `${group} — group chat`
|
||
}),
|
||
// Member faces: the room's roster at a glance, matching each bot's
|
||
// avatar in the sidebar. Falls back to the count for the title tooltip.
|
||
jsx('div', {
|
||
className: 'flex shrink-0 items-center -space-x-1.5',
|
||
title: members.map(b => displayName(b, botRosterMeta(b, allMeta))).join(', '),
|
||
children: members.slice(0, 6).map(b => {
|
||
const bMeta = botRosterMeta(b, allMeta)
|
||
const { shape, color, image } = botAppearance(b.name, bMeta)
|
||
const photo = Boolean(image && !isBackfilledFacePng(image))
|
||
|
||
return jsx('div', {
|
||
className: 'rounded-full ring-2 ring-(--ui-bg-primary,#111)',
|
||
children: jsx(BotFace, { shape, color, image: photo ? image : null, size: 20, name: b.name })
|
||
}, botRosterKey(b))
|
||
})
|
||
}),
|
||
jsx('span', {
|
||
className: 'shrink-0 text-[0.65rem] text-(--ui-text-quaternary)',
|
||
children: `${members.length} bots`
|
||
}),
|
||
jsx(Button, {
|
||
variant: 'ghost',
|
||
size: 'sm',
|
||
className: 'shrink-0 text-(--ui-text-tertiary) hover:text-foreground',
|
||
title: `Group settings — rename ${group} or set a room picture`,
|
||
onClick: () => setSettingsOpen(true),
|
||
children: jsx(Codicon, { name: 'gear' })
|
||
}),
|
||
jsx(Button, {
|
||
variant: 'ghost',
|
||
size: 'sm',
|
||
className: 'shrink-0 text-(--ui-text-tertiary) hover:text-destructive',
|
||
title: `Disband the ${group} group chat`,
|
||
onClick: () => setConfirmDisband(true),
|
||
children: jsx(Codicon, { name: 'trash' })
|
||
})
|
||
]
|
||
})
|
||
|
||
const memberDescriptors = () =>
|
||
members.map(b => ({
|
||
...b,
|
||
title: (b.remoteSource ? '' : allMeta[b.name]?.title) || b.title || ''
|
||
}))
|
||
|
||
const submit = () => {
|
||
const text = draft.trim()
|
||
const images = imagesFor(null)
|
||
|
||
if (!text && !images.length) {
|
||
return
|
||
}
|
||
|
||
setDraft('')
|
||
clearImages(null)
|
||
// Main composer = START A NEW THREAD with the whole group (Slack shape).
|
||
// Full descriptors ride into the turn loop: remote members keep their
|
||
// connection fields so their turns route to their own machines.
|
||
const minted = sendToGroupChat(group, memberDescriptors(), text, null, images)
|
||
|
||
if (minted) {
|
||
setOpenThreads(prev => ({ ...prev, [minted]: true }))
|
||
}
|
||
}
|
||
|
||
const submitReply = thread => {
|
||
const text = (replyDrafts[thread] || '').trim()
|
||
const images = imagesFor(thread)
|
||
|
||
if (!text && !images.length) {
|
||
return
|
||
}
|
||
|
||
setReplyDrafts(prev => ({ ...prev, [thread]: '' }))
|
||
clearImages(thread)
|
||
// Reply box = CONTINUE this thread; the member turns it triggers are
|
||
// scoped to it.
|
||
sendToGroupChat(group, memberDescriptors(), text, thread, images)
|
||
setOpenThreads(prev => ({ ...prev, [thread]: true }))
|
||
}
|
||
|
||
/** Pending-attachment chips + the picker for one composer (thread = null →
|
||
* main). Image chips preview the pixels; PDFs/files show a type icon.
|
||
* X removes it. */
|
||
const attachmentRow = thread => {
|
||
const images = imagesFor(thread)
|
||
|
||
if (!images.length) {
|
||
return null
|
||
}
|
||
|
||
return jsx('div', {
|
||
className: 'flex flex-wrap items-center gap-1.5 px-1 pb-1',
|
||
children: images.map((img, index) =>
|
||
jsxs('div', {
|
||
className:
|
||
'flex items-center gap-1 rounded-md border border-(--ui-stroke-secondary) bg-(--ui-bg-secondary,#181818) px-1 py-0.5',
|
||
children: [
|
||
img.kind === 'pdf' || img.kind === 'file'
|
||
? jsx(Codicon, {
|
||
name: img.kind === 'pdf' ? 'file-pdf' : 'file',
|
||
className: 'text-[0.9rem] text-(--ui-text-tertiary)'
|
||
})
|
||
: jsx('img', { src: img.data, alt: '', className: 'size-6 rounded object-cover' }),
|
||
jsx('span', {
|
||
className: 'max-w-32 truncate text-[0.65rem] text-(--ui-text-tertiary)',
|
||
children: img.name || 'image'
|
||
}),
|
||
jsx('button', {
|
||
type: 'button',
|
||
className: 'cursor-pointer border-0 bg-transparent p-0 text-(--ui-text-quaternary) hover:text-foreground',
|
||
title: 'Remove attachment',
|
||
onClick: () => removeImage(thread, index),
|
||
children: jsx(Codicon, { name: 'close', className: 'text-[0.65rem]' })
|
||
})
|
||
]
|
||
}, `${img.name || 'img'}:${index}`)
|
||
)
|
||
})
|
||
}
|
||
|
||
const attachButton = thread =>
|
||
jsx(Button, {
|
||
type: 'button',
|
||
variant: 'ghost',
|
||
size: 'sm',
|
||
className: 'shrink-0 text-(--ui-text-tertiary) hover:text-foreground',
|
||
title: 'Attach files — every responding bot sees them',
|
||
onClick: () => void pickGroupAttachments().then(picked => addImages(thread, picked)),
|
||
children: jsx(Codicon, { name: 'attach' })
|
||
})
|
||
|
||
// One log entry, rendered exactly as before conversation folding existed.
|
||
const renderEntry = (entry, index) => {
|
||
const isUser = entry.from.kind === 'user'
|
||
const meta = isUser || entry.from.source ? null : allMeta[entry.from.name]
|
||
// Match this speaker back to its member descriptor so display
|
||
// names and disambiguating handles come from the roster (the
|
||
// primary "default" profile renders as Hermes, remote dupes
|
||
// carry their @name-device handle) instead of raw profile ids.
|
||
const member = isUser
|
||
? null
|
||
: members.find(b =>
|
||
b.name === entry.from.name &&
|
||
(entry.from.source
|
||
? (b.connectionLabel || b.connectionId) === entry.from.source
|
||
: !b.remoteSource)
|
||
) || null
|
||
const display = isUser ? 'You' : displayName(member || { name: entry.from.name }, meta)
|
||
const entryKey = `${entry.at}:${index}`
|
||
const revealed = !isUser && revealedSpeaker === entryKey
|
||
// Clicked: append the gateway name so same-named agents on
|
||
// two connections are tellable apart on demand.
|
||
const label = isUser
|
||
? 'You'
|
||
: revealed
|
||
? `${display}${entry.from.source ? `-${entry.from.source}` : ''} (@${botHandle(entry.from.name, member || undefined)})`
|
||
: display
|
||
// Speaker avatar: same appearance pipeline as the roster
|
||
// (custom image/pet, else deterministic shape+color face).
|
||
// Remote speakers have no local meta and get the
|
||
// deterministic face for their name — stable per bot.
|
||
const { shape, color, image } = isUser
|
||
? { shape: null, color: null, image: null }
|
||
: botAppearance(entry.from.name, meta)
|
||
const photo = Boolean(image && !isBackfilledFacePng(image))
|
||
|
||
return jsxs('div', {
|
||
className: cn(
|
||
'group flex items-start gap-2',
|
||
isUser ? 'rounded-md bg-(--chrome-action-hover) px-2 py-1.5' : 'px-2 py-1'
|
||
),
|
||
children: [
|
||
isUser
|
||
? null
|
||
: jsx('div', {
|
||
className: 'mt-0.5 shrink-0',
|
||
children: jsx(BotFace, {
|
||
shape,
|
||
color,
|
||
image: photo ? image : null,
|
||
size: 24,
|
||
name: entry.from.name
|
||
})
|
||
}),
|
||
jsxs('div', {
|
||
className: 'min-w-0 flex-1',
|
||
children: [
|
||
jsxs('div', {
|
||
className: 'flex items-center gap-2',
|
||
children: [
|
||
isUser
|
||
? jsx('span', {
|
||
className: 'text-[0.7rem] font-semibold text-foreground',
|
||
children: label
|
||
})
|
||
: jsx('button', {
|
||
type: 'button',
|
||
className:
|
||
'cursor-pointer border-0 bg-transparent p-0 text-left text-[0.7rem] font-semibold text-(--ui-accent,#4f9cf9)',
|
||
title: revealed ? 'Hide full handle' : 'Show full handle',
|
||
onClick: () => setRevealedSpeaker(revealed ? null : entryKey),
|
||
children: label
|
||
}),
|
||
jsx('span', {
|
||
className: 'text-[0.625rem] text-(--ui-text-quaternary)',
|
||
children: relativeTime(entry.at)
|
||
}),
|
||
entry.text.trim()
|
||
? jsx('div', {
|
||
className:
|
||
'ml-auto shrink-0 opacity-0 pointer-events-none group-hover:pointer-events-auto group-hover:opacity-100 focus-within:pointer-events-auto focus-within:opacity-100',
|
||
children: jsx(CopyButton, {
|
||
appearance: 'icon',
|
||
buttonSize: 'icon',
|
||
stopPropagation: true,
|
||
text: entry.text
|
||
})
|
||
})
|
||
: null
|
||
]
|
||
}),
|
||
jsx('div', {
|
||
className:
|
||
'text-xs text-(--ui-text-secondary) [&_p]:mb-1 [&_p:last-child]:mb-0 [&_ul]:mb-1 [&_ul]:list-disc [&_ul]:pl-4 [&_ol]:mb-1 [&_ol]:list-decimal [&_ol]:pl-4 [&_pre]:overflow-x-auto',
|
||
// The app shell sets user-select: none globally; message bodies opt
|
||
// back in so drag-select and ⌘C work in group chat logs.
|
||
'data-selectable-text': 'true',
|
||
children: Streamdown ? jsx(Streamdown, { children: entry.text }) : entry.text
|
||
}),
|
||
// User attachments: what every responding bot was
|
||
// shown — image previews, or a named chip for
|
||
// PDFs/files.
|
||
Array.isArray(entry.images) && entry.images.length
|
||
? jsx('div', {
|
||
className: 'mt-1 flex flex-wrap items-center gap-1.5',
|
||
children: entry.images.map((img, imgIndex) =>
|
||
img.kind === 'pdf' || img.kind === 'file'
|
||
? jsxs('div', {
|
||
className:
|
||
'flex items-center gap-1 rounded-md border border-(--ui-stroke-secondary) px-1.5 py-1 text-[0.65rem] text-(--ui-text-tertiary)',
|
||
title: img.name || 'attached file',
|
||
children: [
|
||
jsx(Codicon, { name: img.kind === 'pdf' ? 'file-pdf' : 'file', className: 'text-[0.8rem]' }),
|
||
jsx('span', { className: 'max-w-48 truncate', children: img.name || 'attached file' })
|
||
]
|
||
}, `${entryKey}:img:${imgIndex}`)
|
||
: jsx('img', {
|
||
src: img.data,
|
||
alt: img.name || 'attached image',
|
||
title: img.name || 'attached image',
|
||
className:
|
||
'max-h-40 max-w-60 rounded-md border border-(--ui-stroke-secondary) object-contain'
|
||
}, `${entryKey}:img:${imgIndex}`)
|
||
)
|
||
})
|
||
: null
|
||
]
|
||
})
|
||
]
|
||
}, entryKey)
|
||
}
|
||
|
||
// Threads: group entries by thread id (hydration assigned legacy ids, but
|
||
// guard live pre-thread entries too), ordered by last activity — oldest
|
||
// first, so the busiest/newest thread sits at the bottom by the composer.
|
||
// The most recently ACTIVE thread renders open; older ones collapse to a
|
||
// Slack-style summary row unless explicitly opened. Every open thread gets
|
||
// its own reply box, which continues THAT thread.
|
||
const threadsById = new Map()
|
||
|
||
for (let i = 0; i < room.log.length; i++) {
|
||
const entry = room.log[i]
|
||
const id = groupThreadOf(entry)
|
||
let bucket = threadsById.get(id)
|
||
|
||
if (!bucket) {
|
||
bucket = { entries: [], id, startIndex: i }
|
||
threadsById.set(id, bucket)
|
||
}
|
||
|
||
bucket.entries.push({ entry, index: i })
|
||
}
|
||
|
||
const threads = [...threadsById.values()].sort(
|
||
(a, b) => (a.entries[a.entries.length - 1].entry.at || 0) - (b.entries[b.entries.length - 1].entry.at || 0)
|
||
)
|
||
const newestThread = threads.length ? threads[threads.length - 1].id : null
|
||
const logChildren = []
|
||
|
||
threads.forEach(threadBucket => {
|
||
const { entries, id } = threadBucket
|
||
const head = entries.find(({ entry }) => entry.from.kind === 'user')?.entry || entries[0].entry
|
||
const isNewest = id === newestThread
|
||
const expanded = openThreads[id] ?? isNewest
|
||
|
||
if (!expanded) {
|
||
const replies = entries.length - 1
|
||
const headText = stripPreviewMarkdown(head?.text || '').slice(0, 80)
|
||
|
||
logChildren.push(
|
||
jsxs('button', {
|
||
type: 'button',
|
||
className:
|
||
'flex w-full items-center gap-2 rounded-md border border-(--ui-stroke-secondary) px-2 py-1.5 text-left text-xs text-(--ui-text-tertiary) transition-colors hover:bg-(--chrome-action-hover)',
|
||
title: 'Open this thread',
|
||
onClick: () => setOpenThreads(prev => ({ ...prev, [id]: true })),
|
||
children: [
|
||
jsx(Codicon, { name: 'chevron-right', className: 'shrink-0 text-[0.65rem]' }),
|
||
jsx('span', { className: 'min-w-0 flex-1 truncate', children: headText || 'Thread' }),
|
||
jsx('span', {
|
||
className: 'shrink-0 text-[0.625rem] text-(--ui-text-quaternary)',
|
||
children: `${replies} ${replies === 1 ? 'reply' : 'replies'} · ${relativeTime(entries[entries.length - 1].entry.at)}`
|
||
})
|
||
]
|
||
}, `fold:${id}`)
|
||
)
|
||
|
||
return
|
||
}
|
||
|
||
// Open thread: a rail-indented block — collapse affordance, its entries,
|
||
// and its own reply box (Slack's "reply in thread").
|
||
const threadRows = []
|
||
|
||
if (!isNewest || openThreads[id] !== undefined) {
|
||
threadRows.push(
|
||
jsxs('button', {
|
||
type: 'button',
|
||
className:
|
||
'flex w-full items-center gap-1.5 px-2 pt-1 text-left text-[0.65rem] text-(--ui-text-quaternary) transition-colors hover:text-foreground',
|
||
title: 'Collapse this thread',
|
||
onClick: () => setOpenThreads(prev => ({ ...prev, [id]: false })),
|
||
children: [jsx(Codicon, { name: 'chevron-down', className: 'text-[0.6rem]' }), 'Collapse thread']
|
||
}, `unfold:${id}`)
|
||
)
|
||
}
|
||
|
||
for (const { entry, index } of entries) {
|
||
threadRows.push(renderEntry(entry, index))
|
||
}
|
||
|
||
// Reply-in-thread: the newest thread's continuation ALSO lives here, so
|
||
// the main composer below can stay "new thread" without ambiguity.
|
||
threadRows.push(
|
||
replyThread === id
|
||
? jsxs('form', {
|
||
className: 'grid gap-0 px-2 pb-1',
|
||
onSubmit: event => {
|
||
event.preventDefault()
|
||
submitReply(id)
|
||
},
|
||
children: [
|
||
attachmentRow(id),
|
||
jsxs('div', {
|
||
className: 'flex items-center gap-1.5',
|
||
children: [
|
||
jsx(GroupMentionInput, {
|
||
'aria-label': 'Reply in thread',
|
||
autoFocus: true,
|
||
placeholder: 'Reply in thread…',
|
||
members,
|
||
value: replyDrafts[id] || '',
|
||
onChange: text => setReplyDrafts(prev => ({ ...prev, [id]: text })),
|
||
onPaste: event => pasteImages(id, event)
|
||
}),
|
||
attachButton(id),
|
||
jsx(Button, {
|
||
type: 'submit',
|
||
size: 'sm',
|
||
disabled: !(replyDrafts[id] || '').trim() && !imagesFor(id).length,
|
||
children: 'Reply'
|
||
})
|
||
]
|
||
})
|
||
]
|
||
}, `replybox:${id}`)
|
||
: jsx('button', {
|
||
type: 'button',
|
||
className:
|
||
'w-fit px-2 pb-1 text-left text-[0.65rem] text-(--ui-accent,#4f9cf9) transition-colors hover:underline',
|
||
onClick: () => setReplyThread(id),
|
||
children: 'Reply in thread'
|
||
}, `replylink:${id}`)
|
||
)
|
||
|
||
logChildren.push(
|
||
jsx('div', {
|
||
className: 'grid gap-1.5 border-l-2 border-(--ui-stroke-secondary) pl-1.5',
|
||
children: threadRows
|
||
}, `thread:${id}`)
|
||
)
|
||
})
|
||
|
||
return jsxs('div', {
|
||
className: 'relative flex h-full flex-col',
|
||
onDragOver: event => {
|
||
if ([...(event.dataTransfer?.types || [])].includes('Files')) {
|
||
event.preventDefault()
|
||
setDragOver(true)
|
||
}
|
||
},
|
||
onDragLeave: event => {
|
||
// Only clear when leaving the room container itself, not when the
|
||
// cursor moves between its children.
|
||
if (!event.currentTarget.contains(event.relatedTarget)) {
|
||
setDragOver(false)
|
||
}
|
||
},
|
||
onDrop: dropFiles,
|
||
children: [
|
||
dragOver
|
||
? jsx('div', {
|
||
className:
|
||
'pointer-events-none absolute inset-0 z-40 flex items-center justify-center border-2 border-dashed border-(--ui-accent,#4f9cf9) text-sm font-medium text-(--ui-accent,#4f9cf9)',
|
||
children: replyThread ? 'Drop to attach to this thread reply' : 'Drop to attach — every responding bot sees it'
|
||
}, 'dropzone')
|
||
: null,
|
||
header,
|
||
jsx(ScrollArea, {
|
||
className: 'min-h-0 flex-1',
|
||
children: jsxs('div', {
|
||
className: 'grid gap-1.5 px-2.5 pb-2',
|
||
children: [
|
||
...(room.log.length
|
||
? logChildren
|
||
: [
|
||
jsx('div', {
|
||
className: 'px-2 py-4 text-center text-xs text-(--ui-text-tertiary)',
|
||
children: 'Say something — every bot in this group hears the room.'
|
||
}, 'empty')
|
||
]),
|
||
room.running
|
||
? jsx('div', {
|
||
className: 'px-2 py-1 text-[0.7rem] italic text-(--ui-text-quaternary)',
|
||
children: room.turn
|
||
? `${groupSpeakerLabel(room.turn)} is thinking…`
|
||
: 'The room is working…'
|
||
}, 'working')
|
||
: null
|
||
]
|
||
})
|
||
}),
|
||
jsx('div', {
|
||
className: 'border-t border-(--ui-stroke-secondary) p-2',
|
||
children: jsxs('form', {
|
||
className: 'grid gap-0',
|
||
onSubmit: event => {
|
||
event.preventDefault()
|
||
submit()
|
||
},
|
||
children: [
|
||
attachmentRow(null),
|
||
jsxs('div', {
|
||
className: 'flex items-center gap-1.5',
|
||
children: [
|
||
jsx(GroupMentionInput, {
|
||
'aria-label': `Message ${group}`,
|
||
placeholder: `New thread in ${group}… (@name to direct, @everyone for all)`,
|
||
members,
|
||
value: draft,
|
||
onChange: setDraft,
|
||
onPaste: event => pasteImages(null, event)
|
||
}),
|
||
attachButton(null),
|
||
jsx(Button, {
|
||
type: 'submit',
|
||
size: 'sm',
|
||
disabled: !draft.trim() && !imagesFor(null).length,
|
||
children: 'New Thread'
|
||
})
|
||
]
|
||
})
|
||
]
|
||
})
|
||
}),
|
||
jsx(GroupChatSettingsDialog, {
|
||
group,
|
||
members,
|
||
open: settingsOpen,
|
||
onClose: () => setSettingsOpen(false)
|
||
}),
|
||
jsx(ConfirmDialog, {
|
||
open: confirmDisband,
|
||
title: 'Disband group chat?',
|
||
description: jsxs('span', {
|
||
children: [
|
||
'This removes the ',
|
||
jsx('span', { className: 'font-medium text-foreground', children: group }),
|
||
' grouping from its ',
|
||
String(members.length),
|
||
' bots and clears the shared room log. The bots themselves and their “Group: ',
|
||
group,
|
||
'” sessions are kept — you can still open those from each bot’s session browser.'
|
||
]
|
||
}),
|
||
destructive: true,
|
||
confirmLabel: 'Disband',
|
||
busyLabel: 'Disbanding…',
|
||
doneLabel: 'Disbanded',
|
||
onClose: () => setConfirmDisband(false),
|
||
onConfirm: async () => {
|
||
await disbandGroupChat(group, members)
|
||
host.notify({ kind: 'success', message: `Disbanded “${group}”` })
|
||
}
|
||
})
|
||
]
|
||
})
|
||
}
|
||
|
||
/** Live closers for group-chat MAIN-window tabs, by group name — so a
|
||
* disband (or the room view's own Back) can retire the tab it opened. */
|
||
const groupChatMainTabs = new Map()
|
||
|
||
function closeGroupChatMainTab(group) {
|
||
const close = groupChatMainTabs.get(group)
|
||
|
||
groupChatMainTabs.delete(group)
|
||
|
||
if (typeof close === 'function') {
|
||
try {
|
||
close()
|
||
} catch {
|
||
/* tab already gone */
|
||
}
|
||
}
|
||
}
|
||
|
||
/** Main-window wrapper: seats the member roster reactively (live roster +
|
||
* bot meta + the room's stored cross-connection descriptors) so the room
|
||
* keeps working as members change while the tab is open. */
|
||
function GroupChatMainView({ group }) {
|
||
const allMeta = useValue($botMeta)
|
||
// Subscribe: membership changes ride bot meta AND the room record.
|
||
useValue($groupChats)
|
||
const roster = useValue($lastRoster)
|
||
const members = groupChatMemberBots(group, roster, allMeta)
|
||
|
||
return jsx(GroupChatWorkspace, { group, members, onBack: () => closeGroupChatMainTab(group) })
|
||
}
|
||
|
||
/** Open a group chat the Discord way: a tab taking over the MAIN chat window
|
||
* (host.openWorkspace, newer desktops), falling back to the in-panel room
|
||
* view on desktops whose SDK predates the main-area door. */
|
||
function openGroupChat(group) {
|
||
$groupNeedsYou.set({ ...$groupNeedsYou.get(), [group]: false })
|
||
|
||
if (typeof host.openWorkspace === 'function') {
|
||
try {
|
||
const close = host.openWorkspace(`${ID}:group:${slugify(group)}`, {
|
||
title: group,
|
||
minWidth: '24rem',
|
||
render: () => jsx(GroupChatMainView, { group }),
|
||
onClose: () => groupChatMainTabs.delete(group)
|
||
})
|
||
|
||
groupChatMainTabs.set(group, close)
|
||
|
||
return
|
||
} catch {
|
||
// Fall through to the in-panel room below.
|
||
}
|
||
}
|
||
|
||
$groupChatWorkspace.set(group)
|
||
}
|
||
|
||
/** One group chat as ONE roster row — the Discord shape: stacked member
|
||
* avatars, group name, member count, the newest room line as the preview
|
||
* (markdown flattened), relative time of the last activity, and the
|
||
* needs-you badge on the row itself. Sorts into the same recency ordering
|
||
* as bot rows; clicking opens the room in the main chat window. */
|
||
function GroupRow({ group, members, needsYou, onOpen }) {
|
||
const rooms = useValue($groupChats)
|
||
const allMeta = useValue($botMeta)
|
||
const room = rooms[group] || { log: [] }
|
||
const log = Array.isArray(room.log) ? room.log : []
|
||
const last = log.length ? log[log.length - 1] : null
|
||
const lastAt = groupLastActivity(room)
|
||
// Room previews speak the same handle vocabulary as the roster, mentions
|
||
// and the group prompt: the primary profile is @hermes, not @default.
|
||
const lastFrom = last?.from?.name || ''
|
||
const lastHandle = botHandle(lastFrom || 'bot', members.find(member => member?.name === lastFrom))
|
||
const preview = last
|
||
? `${last.from?.kind === 'user' ? 'You' : `@${lastHandle}`}: ${stripPreviewMarkdown(last.text) || '…'}`
|
||
: 'No messages yet — say hi to the room'
|
||
const faces = members.slice(0, 3)
|
||
|
||
return jsxs('button', {
|
||
type: 'button',
|
||
onClick: () => {
|
||
haptic('tap')
|
||
onOpen(group)
|
||
},
|
||
className: cn(
|
||
'flex w-full min-w-0 max-w-full items-center gap-2.5 overflow-hidden rounded-md px-2 py-2 text-left transition-colors',
|
||
'hover:bg-(--chrome-action-hover)'
|
||
),
|
||
children: [
|
||
// Room picture when the user set one; else a composite avatar of up to
|
||
// three member faces fanned like Discord's group-DM icon; a bare glyph
|
||
// when the room has no seated members.
|
||
jsx('div', {
|
||
className: 'flex w-[34px] shrink-0 items-center justify-center',
|
||
children: room.image
|
||
? jsx('img', {
|
||
src: room.image,
|
||
alt: '',
|
||
className: 'size-7 rounded-full object-cover ring-2 ring-(--ui-bg-primary,#111)'
|
||
})
|
||
: faces.length
|
||
? jsx('div', {
|
||
className: 'flex items-center -space-x-2.5',
|
||
children: faces.map(member => {
|
||
const meta = member.remoteSource ? null : allMeta[member.name]
|
||
const { shape, color, image } = botAppearance(member.name, meta)
|
||
|
||
return jsx(
|
||
'div',
|
||
{
|
||
className: 'rounded-full ring-2 ring-(--ui-bg-primary,#111)',
|
||
children: jsx(BotFace, {
|
||
shape,
|
||
color,
|
||
image: image && !isBackfilledFacePng(image) ? image : null,
|
||
size: 20,
|
||
name: member.name,
|
||
mood: 'idle'
|
||
})
|
||
},
|
||
botRosterKey(member)
|
||
)
|
||
})
|
||
})
|
||
: jsx(Codicon, { name: 'organization', className: 'text-(--ui-text-tertiary)' })
|
||
}),
|
||
jsxs('div', {
|
||
className: 'min-w-0 flex-1',
|
||
children: [
|
||
jsxs('div', {
|
||
className: 'flex items-baseline justify-between gap-2',
|
||
children: [
|
||
jsxs('div', {
|
||
className: 'flex min-w-0 items-baseline gap-1.5 truncate',
|
||
children: [
|
||
jsx('span', { className: 'truncate text-[0.8125rem] font-medium', children: group }),
|
||
jsx('span', {
|
||
className: 'shrink-0 text-[0.6875rem] text-(--ui-text-quaternary)',
|
||
children: `${members.length} bots`
|
||
})
|
||
]
|
||
}),
|
||
needsYou
|
||
? jsx('span', {
|
||
className:
|
||
'shrink-0 rounded-full bg-(--ui-accent,#4f9cf9) px-1.5 text-[0.6rem] font-semibold text-white',
|
||
title: 'A bot in this room needs your input',
|
||
children: 'needs you'
|
||
})
|
||
: null,
|
||
lastAt
|
||
? jsx('span', {
|
||
className: 'shrink-0 text-[0.6875rem] text-(--ui-text-quaternary)',
|
||
children: relativeTime(lastAt)
|
||
})
|
||
: null
|
||
]
|
||
}),
|
||
jsx('div', {
|
||
className: 'min-w-0 truncate text-xs text-(--ui-text-tertiary)',
|
||
children: preview
|
||
})
|
||
]
|
||
})
|
||
]
|
||
})
|
||
}
|
||
|
||
function BotsPane() {
|
||
const { data, error, isLoading, refetch } = useRoster()
|
||
const gatewayState = useValue(host.state.gateway)
|
||
const gatewayUp = gatewayState === 'open'
|
||
const activeProfile = (useValue(host.state.profile) || 'default').trim() || 'default'
|
||
const [createOpen, setCreateOpen] = useState(false)
|
||
const [groupCreateOpen, setGroupCreateOpen] = useState(false)
|
||
const [editing, setEditing] = useState(null)
|
||
const [deleting, setDeleting] = useState(null)
|
||
const [grouping, setGrouping] = useState(null)
|
||
const [query, setQuery] = useState('')
|
||
const activityToasts = useValue($activityToasts)
|
||
const sessionsWorkspaceName = useValue($botSessionsWorkspace)
|
||
const groupChatName = useValue($groupChatWorkspace)
|
||
const groupNeedsYou = useValue($groupNeedsYou)
|
||
const groupRooms = useValue($groupChats)
|
||
|
||
// The socket opening (boot, SSH reconnect, sleep/wake) is the signal to
|
||
// retry immediately instead of waiting out the poll interval.
|
||
useEffect(() => {
|
||
if (gatewayUp) {
|
||
void refetch()
|
||
}
|
||
}, [gatewayUp, refetch])
|
||
const allMeta = useValue($botMeta)
|
||
// Messaging-app order: most recent activity first, where "activity" is
|
||
// the newest of (bot created, last message in any of its sessions). A
|
||
// freshly created bot tops the list until another bot gets a message.
|
||
// No special slot for the primary bot — it competes on recency too.
|
||
const activityOf = bot => {
|
||
const created = botRosterMeta(bot, allMeta)?.created || bot.ui_meta?.['hermes-bots']?.created || 0
|
||
const lastMsg = (bot.last_session?.last_active || 0) * 1000
|
||
|
||
return Math.max(created, lastMsg)
|
||
}
|
||
// Pinned bots (right-click → Pin) float to the top as a group; within the
|
||
// pinned group and within the unpinned group, recency still rules. A
|
||
// plain boolean flag in bot-meta (rides ui_meta to every machine).
|
||
const isPinned = bot => Boolean(botRosterMeta(bot, allMeta)?.pinned)
|
||
// Resilience (@wesleysimplicio, #13): a failed refresh must not erase a
|
||
// roster the user already had — mixed local+cloud gateways and remotes
|
||
// waking from sleep fail transiently. Render the last good snapshot with
|
||
// a notice; the full error card is reserved for "never had a roster".
|
||
const live = Array.isArray(data?.profiles) ? data.profiles : null
|
||
const source = live ?? (error ? $lastRoster.get() : [])
|
||
const roster = source.slice().sort((a, b) => {
|
||
const pa = isPinned(a) ? 1 : 0
|
||
const pb = isPinned(b) ? 1 : 0
|
||
|
||
if (pa !== pb) {
|
||
return pb - pa
|
||
}
|
||
|
||
return activityOf(b) - activityOf(a)
|
||
})
|
||
const activeSourceRoster = roster.filter(bot => !bot.remoteSource)
|
||
// Hidden bots (right-click → Hide Bot) drop out of the roster list unless
|
||
// the header eye toggle reveals them. Display-only: every other consumer
|
||
// (mentions, group chats, name-collision checks, merge/avatar/activity
|
||
// sweeps) keeps the FULL roster.
|
||
const showHidden = useValue($showHiddenBots)
|
||
const unreadByName = useValue($botUnread)
|
||
const hiddenBots = roster.filter(bot => isBotHidden(bot, allMeta))
|
||
const hiddenUnread = hiddenBots.some(bot => !bot.remoteSource && unreadByName[bot.name])
|
||
const visibleRoster = showHidden ? roster : roster.filter(bot => !isBotHidden(bot, allMeta))
|
||
const filteredRoster = filterBots(visibleRoster, allMeta, query)
|
||
// Group chats are first-class roster rows (Discord-style): one standalone
|
||
// row per room, competing in the SAME recency ordering as bot rows — a
|
||
// group's activity is its newest room-log line. Pinned bots still lead;
|
||
// groups and unpinned bots interleave by recency below them.
|
||
const needle = query.trim().toLowerCase()
|
||
const groupRows = groupChatNames(allMeta, groupRooms)
|
||
.filter(name => !needle || name.toLowerCase().includes(needle))
|
||
.map(name => ({
|
||
kind: 'group',
|
||
name,
|
||
members: groupChatMemberBots(name, roster, allMeta),
|
||
activity: groupLastActivity(groupRooms[name])
|
||
}))
|
||
const rosterRows = [
|
||
...filteredRoster.map(bot => ({ kind: 'bot', bot, pinned: isPinned(bot), activity: activityOf(bot) })),
|
||
...groupRows
|
||
].sort((a, b) => {
|
||
const pa = a.pinned ? 1 : 0
|
||
const pb = b.pinned ? 1 : 0
|
||
|
||
if (pa !== pb) {
|
||
return pb - pa
|
||
}
|
||
|
||
return b.activity - a.activity
|
||
})
|
||
|
||
if (live) {
|
||
$lastRoster.set(roster)
|
||
mergeServerMeta(activeSourceRoster)
|
||
pullServerAvatars(activeSourceRoster)
|
||
trackInboundActivity(activeSourceRoster)
|
||
backfillMessagingProtocol(activeSourceRoster)
|
||
}
|
||
|
||
const staleNotice = error && !live && roster.length
|
||
? 'Roster refresh failed — showing the last good list.' + (gatewayUp ? '' : ' Waiting for the gateway to reconnect…')
|
||
: null
|
||
const sessionsWorkspaceBot = roster.find(bot => bot.name === sessionsWorkspaceName)
|
||
|
||
if (sessionsWorkspaceBot) {
|
||
return jsx(ProfileSessionsWorkspace, { bot: sessionsWorkspaceBot })
|
||
}
|
||
|
||
const groupChatMembers = groupChatName ? groupChatMemberBots(groupChatName, roster, allMeta) : []
|
||
|
||
if (groupChatName && groupChatMembers.length) {
|
||
return jsx(GroupChatWorkspace, { group: groupChatName, members: groupChatMembers })
|
||
}
|
||
|
||
return jsxs('div', {
|
||
className: 'flex h-full flex-col',
|
||
children: [
|
||
jsxs('div', {
|
||
className: 'flex items-center justify-between gap-2 px-2.5 pt-2.5 pb-1.5',
|
||
children: [
|
||
jsx('span', {
|
||
className: 'text-[0.6875rem] font-semibold uppercase tracking-wider text-(--ui-text-quaternary)',
|
||
children: 'Bots'
|
||
}),
|
||
jsxs('div', {
|
||
className: 'flex items-center gap-0.5',
|
||
children: [
|
||
jsx(Tip, {
|
||
label: activityToasts ? 'Activity toasts on — click to silence' : 'Activity toasts off — click to enable',
|
||
children: jsx('button', {
|
||
type: 'button',
|
||
className:
|
||
'flex size-6 items-center justify-center rounded-md text-(--ui-text-tertiary) transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground',
|
||
onClick: () => setActivityToasts(!activityToasts),
|
||
children: jsx(Codicon, { name: activityToasts ? 'bell' : 'bell-slash' })
|
||
})
|
||
}),
|
||
// Eye toggle appears only once something is hidden — zero
|
||
// hidden bots means zero extra chrome. It stays visible while
|
||
// hidden rows are revealed, so Unhide is always reachable.
|
||
hiddenBots.length
|
||
? jsx(Tip, {
|
||
label: showHidden
|
||
? 'Hide hidden bots again'
|
||
: `Show ${hiddenBots.length} hidden bot${hiddenBots.length === 1 ? '' : 's'}`,
|
||
children: jsxs('button', {
|
||
type: 'button',
|
||
'aria-label': showHidden ? 'Hide hidden bots' : 'Show hidden bots',
|
||
className: cn(
|
||
'relative flex size-6 items-center justify-center rounded-md transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground',
|
||
showHidden ? 'text-foreground' : 'text-(--ui-text-tertiary)'
|
||
),
|
||
onClick: () => $showHiddenBots.set(!showHidden),
|
||
children: [
|
||
jsx(Codicon, { name: showHidden ? 'eye' : 'eye-closed' }),
|
||
hiddenUnread && !showHidden
|
||
? jsx('span', {
|
||
className:
|
||
'absolute right-0.5 top-0.5 size-1.5 rounded-full bg-(--ui-accent,#4f9cf9)',
|
||
'aria-label': 'a hidden bot has unread activity'
|
||
})
|
||
: null
|
||
]
|
||
})
|
||
})
|
||
: null,
|
||
jsxs(DropdownMenu, {
|
||
children: [
|
||
jsx(Tip, {
|
||
label: 'New…',
|
||
children: jsx(DropdownMenuTrigger, {
|
||
asChild: true,
|
||
children: jsx('button', {
|
||
type: 'button',
|
||
'aria-label': 'New agent or group chat',
|
||
className:
|
||
'flex size-6 items-center justify-center rounded-md text-(--ui-text-tertiary) transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground',
|
||
children: jsx(Codicon, { name: 'add' })
|
||
})
|
||
})
|
||
}),
|
||
jsxs(DropdownMenuContent, {
|
||
align: 'end',
|
||
children: [
|
||
jsxs(DropdownMenuItem, {
|
||
onSelect: () => setCreateOpen(true),
|
||
children: [jsx(Codicon, { name: 'hubot', className: 'mr-1.5' }), 'New Agent']
|
||
}),
|
||
jsxs(DropdownMenuItem, {
|
||
disabled: activeSourceRoster.length < 2,
|
||
onSelect: () => setGroupCreateOpen(true),
|
||
children: [jsx(Codicon, { name: 'organization', className: 'mr-1.5' }), 'New Group Chat']
|
||
})
|
||
]
|
||
})
|
||
]
|
||
})
|
||
]
|
||
})
|
||
]
|
||
}),
|
||
jsx(ActiveNowStrip, {
|
||
roster: visibleRoster,
|
||
activeProfile,
|
||
gatewayState,
|
||
metaByName: allMeta,
|
||
onOpen: bot => {
|
||
const generation = ++botOpenGeneration
|
||
haptic('tap')
|
||
$selectedBot.set(bot.name)
|
||
|
||
if (bot.remoteSource) {
|
||
const handle = botHandle(bot.name, bot)
|
||
host.notify?.({
|
||
kind: 'info',
|
||
title: displayName(bot),
|
||
message: `Stay in this chat and @${handle} to message them. Gateway stays on this device.`
|
||
})
|
||
return
|
||
}
|
||
|
||
if ($botUnread.get()[bot.name]) {
|
||
const next = { ...$botUnread.get() }
|
||
delete next[bot.name]
|
||
$botUnread.set(next)
|
||
}
|
||
|
||
void (async () => {
|
||
let pinnedChat = botRosterMeta(bot, allMeta)?.chat
|
||
|
||
try {
|
||
pinnedChat = await prepareBotSource(bot, pinnedChat)
|
||
} catch (error) {
|
||
host.notifyError?.(error, `Could not reach ${bot.connectionLabel || 'the remote source'}`)
|
||
|
||
return
|
||
}
|
||
|
||
if (generation !== botOpenGeneration) {
|
||
return
|
||
}
|
||
|
||
try {
|
||
const id = await openBotCanonicalChat(
|
||
bot.name,
|
||
pinnedChat,
|
||
bot.preferred_session || bot.last_session
|
||
)
|
||
|
||
if (generation === botOpenGeneration && id) {
|
||
return
|
||
}
|
||
} catch (error) {
|
||
if (generation === botOpenGeneration) {
|
||
host.notifyError?.(error, `Could not open ${displayName(bot)}'s chat — try again`)
|
||
}
|
||
|
||
return
|
||
}
|
||
|
||
if (generation !== botOpenGeneration) {
|
||
return
|
||
}
|
||
|
||
if (typeof host.newChat === 'function') {
|
||
host.newChat(bot.name)
|
||
} else {
|
||
host.navigate('/')
|
||
}
|
||
})()
|
||
}
|
||
}),
|
||
roster.length
|
||
? jsx('div', {
|
||
className: 'px-2.5 pb-1.5',
|
||
children: jsx(SearchField, {
|
||
'aria-label': 'Search bots',
|
||
containerClassName: 'w-full',
|
||
inputClassName: 'w-full',
|
||
placeholder: 'Search bots…',
|
||
value: query,
|
||
onChange: setQuery
|
||
})
|
||
})
|
||
: null,
|
||
staleNotice
|
||
? jsx('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)',
|
||
children: staleNotice
|
||
})
|
||
: null,
|
||
isLoading && !roster.length
|
||
? jsx('div', {
|
||
className: 'flex flex-1 items-center justify-center',
|
||
children: jsx(GlyphSpinner, { spinner: 'breathe', className: 'text-(--ui-text-tertiary)' })
|
||
})
|
||
: error && !roster.length
|
||
? jsxs('div', {
|
||
className: 'grid gap-2 px-3 py-4 text-xs text-(--ui-text-tertiary)',
|
||
children: [
|
||
jsx('div', {
|
||
children: gatewayUp
|
||
? `Roster unavailable: ${error instanceof Error ? error.message : 'gateway error'}. If your gateway predates profiles.list, update Hermes and restart the gateway.`
|
||
: 'Waiting for the gateway connection… (remote gateways can take a few seconds; retries automatically)'
|
||
}),
|
||
jsx(Button, {
|
||
variant: 'secondary',
|
||
size: 'sm',
|
||
className: 'justify-self-start',
|
||
onClick: () => void refetch(),
|
||
children: 'Retry now'
|
||
})
|
||
]
|
||
})
|
||
: roster.length === 0
|
||
? jsx(EmptyState, {
|
||
icon: 'hubot',
|
||
title: 'No agents yet',
|
||
description: 'Create your first teammate.'
|
||
})
|
||
: filteredRoster.length === 0 && rosterRows.length === 0
|
||
? jsx('div', {
|
||
'aria-live': 'polite',
|
||
className:
|
||
'flex flex-1 items-center justify-center px-4 text-center text-xs text-(--ui-text-tertiary)',
|
||
role: 'status',
|
||
children: query.trim()
|
||
? `No bots match “${query.trim()}”`
|
||
: 'All bots are hidden — use the eye button above to show them.'
|
||
})
|
||
: jsx(ScrollArea, {
|
||
className: 'hermes-bots-roster min-h-0 flex-1',
|
||
children: jsx('div', {
|
||
className: 'grid w-full min-w-0 gap-0.5 px-1.5 pb-2',
|
||
// Flat, Discord-style list: bot rows and group rows
|
||
// interleaved by recency — no section headers.
|
||
children: rosterRows.map(row =>
|
||
row.kind === 'group'
|
||
? jsx(
|
||
GroupRow,
|
||
{
|
||
group: row.name,
|
||
members: row.members,
|
||
needsYou: Boolean(groupNeedsYou[row.name]),
|
||
onOpen: openGroupChat
|
||
},
|
||
`group:${row.name}`
|
||
)
|
||
: jsx(
|
||
BotRow,
|
||
{ bot: row.bot, onDelete: setDeleting, onEdit: setEditing, onGroup: setGrouping },
|
||
botRosterKey(row.bot)
|
||
)
|
||
)
|
||
})
|
||
}),
|
||
jsx('div', {
|
||
className: 'border-t border-(--ui-stroke-secondary) p-2',
|
||
children: jsxs(Button, {
|
||
className: 'w-full justify-center gap-1.5',
|
||
variant: 'secondary',
|
||
onClick: () => setCreateOpen(true),
|
||
children: [jsx(Codicon, { name: 'add' }), 'New Agent']
|
||
})
|
||
}),
|
||
jsx(CreateAgentDialog, {
|
||
open: createOpen,
|
||
onClose: () => {
|
||
setCreateOpen(false)
|
||
void refetch()
|
||
},
|
||
roster: activeSourceRoster
|
||
}),
|
||
jsx(CreateGroupChatDialog, {
|
||
open: groupCreateOpen,
|
||
// Full multi-source roster: group chats can seat bots from other
|
||
// registered connections — their turns route to their own machines.
|
||
roster,
|
||
onClose: () => setGroupCreateOpen(false),
|
||
onCreated: groupName => openGroupChat(groupName)
|
||
}),
|
||
jsx(EditProfileDialog, {
|
||
bot: editing,
|
||
open: Boolean(editing),
|
||
onClose: () => {
|
||
setEditing(null)
|
||
void refetch()
|
||
}
|
||
}),
|
||
grouping ? jsx(GroupDialog, { bot: grouping, onClose: () => setGrouping(null) }) : null,
|
||
jsx(ConfirmDialog, {
|
||
open: Boolean(deleting),
|
||
title: 'Delete bot and profile?',
|
||
description: deleting
|
||
? jsxs('span', {
|
||
children: [
|
||
'This will permanently delete the bot ',
|
||
jsx('span', { className: 'font-medium text-foreground', children: deleting.name }),
|
||
' and its associated Hermes profile at ',
|
||
jsx('span', { className: 'font-mono text-xs', children: deleting.path }),
|
||
'. This cannot be undone.'
|
||
]
|
||
})
|
||
: null,
|
||
destructive: true,
|
||
confirmLabel: 'Delete',
|
||
busyLabel: 'Deleting…',
|
||
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}` })
|
||
}
|
||
})
|
||
]
|
||
})
|
||
}
|
||
|
||
// ── plugin ───────────────────────────────────────────────────────────────────
|
||
|
||
export default {
|
||
id: ID,
|
||
name: 'Bots',
|
||
description: 'Bot Mode — a one-chat-per-agent roster with avatars, routines, group chats, and bot-to-bot messaging. Ships with the app; disable here if unwanted.',
|
||
register(ctx) {
|
||
pluginCtx = ctx
|
||
startFaceClock()
|
||
// Disabling the plugin (or a hot reload) must actually stop the clock —
|
||
// before this, the rAF loop + 1Hz document scan ran until app restart.
|
||
if (typeof ctx.onDispose === 'function') {
|
||
ctx.onDispose(stopFaceClock)
|
||
}
|
||
|
||
// @-mention autocomplete: typing "@rese…" in ANY composer offers the
|
||
// roster's handles (issue #88060). Reads the roster straight from the
|
||
// query cache — useRoster keeps it ≤5s stale and the popover must answer
|
||
// synchronously per keystroke. Multi-source rosters contribute their
|
||
// precomputed @name-device handles via botHandle. The active profile is
|
||
// excluded (a bot doesn't @ itself); 'default' surfaces as @hermes.
|
||
ctx.register({
|
||
id: 'mention-completions',
|
||
area: COMPOSER_AREAS.atCompletions,
|
||
data: {
|
||
provide: query => {
|
||
const roster = queryClient.getQueryData(ROSTER_KEY)
|
||
const profiles = Array.isArray(roster?.profiles) ? roster.profiles : []
|
||
|
||
if (!profiles.length) {
|
||
return []
|
||
}
|
||
|
||
const active = (host.state.profile.get() || 'default').trim() || 'default'
|
||
const q = (query || '').toLowerCase()
|
||
const items = []
|
||
const live = {
|
||
name: active,
|
||
connectionId: String(host.state.connectionId?.get?.() || host.activeConnectionId?.() || 'local')
|
||
}
|
||
|
||
for (const profile of profiles) {
|
||
if (!profile?.name || isActiveRosterBot(profile, live)) {
|
||
continue
|
||
}
|
||
|
||
const handle = botHandle(profile.name, profile)
|
||
|
||
if (q && !handle.toLowerCase().startsWith(q)) {
|
||
continue
|
||
}
|
||
|
||
const display = displayName(profile, $botMeta.get()[profile.name])
|
||
const source = profile.connectionLabel ? ` · ${profile.connectionLabel}` : ''
|
||
|
||
items.push({
|
||
insert: `@${handle}`,
|
||
display: `@${handle}`,
|
||
meta: `Bot · ${display}${source}`
|
||
})
|
||
}
|
||
|
||
return items.slice(0, 8)
|
||
}
|
||
}
|
||
})
|
||
|
||
// Keyframes for the pet bob — injected because plugin classes aren't in
|
||
// the app's precompiled CSS. Idempotent across hot reloads.
|
||
if (!document.getElementById('hermes-bots-keyframes')) {
|
||
const style = document.createElement('style')
|
||
style.id = 'hermes-bots-keyframes'
|
||
style.textContent = '@keyframes hermes-bots-bob { from { transform: translateY(0); } to { transform: translateY(-3px); } }'
|
||
document.head.appendChild(style)
|
||
}
|
||
|
||
// Hydrate persisted avatars/titles. Storage may be sync, async, or
|
||
// absent depending on shell version — normalize through Promise.resolve
|
||
// inside a try so a storage quirk can NEVER fail the plugin load.
|
||
try {
|
||
Promise.resolve(ctx.storage?.get?.('bot-meta'))
|
||
.then(value => {
|
||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||
const live = $botMeta.get()
|
||
const next = { ...value }
|
||
for (const name of Object.keys(live)) {
|
||
next[name] = { ...(value[name] || {}), ...live[name] }
|
||
}
|
||
$botMeta.set(next)
|
||
}
|
||
})
|
||
.catch(() => undefined)
|
||
} catch {
|
||
/* no storage on this shell — defaults stay */
|
||
}
|
||
|
||
// Bot Mode sessions are always hidden now — the old "hide Bot Chats"
|
||
// pref is gone (its stored key is simply ignored). The reconciliation
|
||
// sweep below hides any rows born visible under the old pref.
|
||
|
||
// Hydrate the activity-toast pref (default OFF).
|
||
try {
|
||
Promise.resolve(ctx.storage?.get?.('activity-toasts'))
|
||
.then(value => {
|
||
if (typeof value === 'boolean') {
|
||
$activityToasts.set(value)
|
||
}
|
||
})
|
||
.catch(() => undefined)
|
||
} catch {
|
||
/* no storage — default (silent) stays */
|
||
}
|
||
|
||
// Hydrate persisted group-chat room logs (epoch/running are runtime-only
|
||
// and always reset — a loop can't survive a window reload anyway).
|
||
try {
|
||
Promise.resolve(ctx.storage?.get?.('group-chats'))
|
||
.then(value => {
|
||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||
const rooms = {}
|
||
|
||
for (const [name, room] of Object.entries(value)) {
|
||
if (room && Array.isArray(room.log)) {
|
||
rooms[name] = {
|
||
// Pre-thread entries get synthetic thread ids on hydrate so
|
||
// every UI/engine path can assume entry.thread exists.
|
||
log: assignLegacyThreads(room.log),
|
||
watermarks: room.watermarks && typeof room.watermarks === 'object' ? room.watermarks : {},
|
||
sessions: room.sessions && typeof room.sessions === 'object' ? room.sessions : {},
|
||
stranded: room.stranded && typeof room.stranded === 'object' ? room.stranded : {},
|
||
members: Array.isArray(room.members) ? room.members : [],
|
||
image: typeof room.image === 'string' && room.image ? room.image : null,
|
||
epoch: 0,
|
||
running: false
|
||
}
|
||
}
|
||
}
|
||
|
||
$groupChats.set({ ...rooms, ...$groupChats.get() })
|
||
}
|
||
})
|
||
.catch(() => undefined)
|
||
} catch {
|
||
/* no storage — rooms start empty */
|
||
}
|
||
|
||
// Routines follow the chat you're in: track the focused chat's owner
|
||
// profile (falls back to the live gateway profile on older desktops —
|
||
// see $focusedBotProfile). Keying this off the socket's home alone left
|
||
// the unread-suppression and Routines scope on the wrong bot whenever a
|
||
// focused tab showed another profile's chat.
|
||
// Capture the unbinds: without them a disable → re-enable cycle stacks a
|
||
// duplicate listener per cycle (same survives-disable class as the face
|
||
// clock before its onDispose hook — these kept firing until app restart).
|
||
const unbindProfileListener = $focusedBotProfile.listen(profile => {
|
||
if (profile && typeof profile === 'string') {
|
||
$selectedBot.set(profile)
|
||
}
|
||
})
|
||
const unbindGatewayListener = host.state.gateway.listen(handleSessionsGatewayTransition)
|
||
|
||
if (typeof ctx.onDispose === 'function') {
|
||
ctx.onDispose(() => {
|
||
if (typeof unbindProfileListener === 'function') {
|
||
unbindProfileListener()
|
||
}
|
||
if (typeof unbindGatewayListener === 'function') {
|
||
unbindGatewayListener()
|
||
}
|
||
})
|
||
}
|
||
|
||
// Reconciliation sweep: hide every Bot Mode session we know about, on
|
||
// load and again on each reconnect (a swap can land on a gateway whose
|
||
// rows were created before the always-hidden policy). Deferred a tick so
|
||
// the meta/room storage hydrates above have landed; idempotent after that.
|
||
// (Feature-guarded: bare vm test harnesses have no setTimeout global.)
|
||
const scheduleHideSweep = () => {
|
||
try {
|
||
setTimeout(() => void hideOwnedBotSessions(), 0)
|
||
} catch {
|
||
void hideOwnedBotSessions()
|
||
}
|
||
}
|
||
host.state.gateway.listen(state => {
|
||
if (state === 'open') {
|
||
scheduleHideSweep()
|
||
}
|
||
})
|
||
scheduleHideSweep()
|
||
|
||
ctx.register({
|
||
id: 'pane',
|
||
area: 'panes',
|
||
title: 'Bots',
|
||
// dock: explicit adoption gesture — CENTER-STACK into the sessions zone
|
||
// so the sidebar grows a SESSIONS | BOTS tab strip instead of splitting
|
||
// two cramped panes down the column. Center is safe now: insertAtGroup
|
||
// pins the zone's header explicitly shown on a center gain (and it
|
||
// stays shown once the zone has stacked), so the sessions pane can
|
||
// never vanish behind a stripless Bots tab — the lone-pane auto-hide
|
||
// trap this dock used to work around with a 'bottom' split.
|
||
// enforce: standing invariant, not a one-shot migration — the pane
|
||
// re-homes into the sessions strip at EVERY boot it isn't already
|
||
// there, whatever tokens or user placement an older install persisted.
|
||
// The one-time heal ('sessions-tab-v1') burned its token even when its
|
||
// guards skipped the move, so exactly the users who had fought the old
|
||
// stacked layout (dragged panes → $userPlacedPanes) stayed stacked
|
||
// forever. Owner's order: SESSIONS | BOTS is always a tab strip.
|
||
// An intra-session drag still sticks until the next launch (the
|
||
// invariant runs at adoption time only — see enforceDockedPanes in the
|
||
// tree store).
|
||
// collapsible: the pane lives in the sessions zone, so it must LEAVE
|
||
// the grid with that zone below the sidebar-collapse breakpoint. The
|
||
// sessions pane collapses alone without this flag. The zone then keeps
|
||
// a stranded BOTS tab on screen. The narrow edge overlay mirrors the
|
||
// zone's tab strip, so the pane stays reachable while collapsed.
|
||
data: { placement: 'left', width: '260px', collapsible: true, showCloseButton: false, hideOnly: true, dock: { pane: 'sessions', pos: 'center', enforce: true } },
|
||
render: () => jsx(BotsPane, {})
|
||
})
|
||
|
||
// Routines — its OWN tiling pane splitting the workspace's right edge
|
||
// (NOT the collapsible right sidebar; placement 'right' is that sidebar's
|
||
// role and hides the pane until "Show Right Sidebar").
|
||
//
|
||
// Registered ONLY while Bot Mode is on screen: the pane exists while the
|
||
// Bots pane is visible (its zone's active tab, or a lone pane in a
|
||
// stacked pre-heal layout) and unregisters when the user tabs back to
|
||
// Sessions — no Cronjobs tile squatting beside the chat outside Bot Mode.
|
||
// `ctx.register` returns the disposer that makes this cheap; the tree
|
||
// keeps the pane's spot, so re-registering re-adopts it where it was.
|
||
// host.paneVisibility is feature-detected: older desktops without the SDK
|
||
// export keep the always-registered behavior.
|
||
const registerRoutinesPane = () =>
|
||
ctx.register({
|
||
id: 'routines',
|
||
area: 'panes',
|
||
title: 'Cronjobs',
|
||
data: {
|
||
placement: 'main',
|
||
dock: { pane: 'workspace', pos: 'right' },
|
||
width: '250px'
|
||
},
|
||
render: () => jsx(RoutinesPane, {})
|
||
})
|
||
|
||
if (typeof host.paneVisibility === 'function') {
|
||
// The contribution-scoped pane id (`register` prefixes `${ID}:`).
|
||
const $botsPaneVisible = host.paneVisibility(`${ID}:pane`)
|
||
let unregisterRoutines = null
|
||
|
||
const syncRoutinesPane = visible => {
|
||
if (visible) {
|
||
unregisterRoutines ??= registerRoutinesPane()
|
||
} else if (unregisterRoutines) {
|
||
unregisterRoutines()
|
||
unregisterRoutines = null
|
||
}
|
||
}
|
||
|
||
const stopRoutinesSync = $botsPaneVisible.listen(syncRoutinesPane)
|
||
syncRoutinesPane($botsPaneVisible.get())
|
||
|
||
if (typeof ctx.onDispose === 'function') {
|
||
// The registration disposer is already tracked by ctx.register; only
|
||
// the listener needs explicit teardown or it survives plugin disable.
|
||
ctx.onDispose(stopRoutinesSync)
|
||
}
|
||
} else {
|
||
registerRoutinesPane()
|
||
}
|
||
|
||
ctx.register({
|
||
id: 'new-agent',
|
||
area: PALETTE_AREA,
|
||
data: {
|
||
id: `${ID}.new-agent`,
|
||
label: 'New Agent…',
|
||
keywords: ['bot', 'agent', 'profile', 'teammate', 'create'],
|
||
run: () => {
|
||
host.notify({ kind: 'info', message: 'Open the Bots pane and hit “New Agent”.' })
|
||
}
|
||
}
|
||
})
|
||
|
||
// @-mention middleware: "@<bot> do the thing" in any chat becomes an
|
||
// explicit handoff instruction the active agent's SOUL.md knows how to
|
||
// execute. Names are validated against the LIVE roster so
|
||
// "user@example.com" or an unknown @ passes through untouched.
|
||
ctx.register({
|
||
id: 'mention-middleware',
|
||
area: COMPOSER_AREAS.middleware,
|
||
data: {
|
||
handler: async draft => {
|
||
const text = draft.text || ''
|
||
|
||
// /new inside a bot's canonical forever-chat would fork the
|
||
// relationship into a scratch session — the one thing Bots mode
|
||
// promises never happens. Reroute to /compact (same felt effect:
|
||
// fresh working context, SAME conversation) and say so. Only
|
||
// guards the canonical chat: Sessions-mode scratchpads on the
|
||
// same profile keep full /new freedom.
|
||
const slashNew = /^\/(new|reset)\s*$/.exec(text.trim())
|
||
|
||
if (slashNew) {
|
||
const activeBot = $selectedBot.get()
|
||
const meta = activeBot ? $botMeta.get()[activeBot] : null
|
||
const pinnedId = meta?.chat || null
|
||
const currentId = host.activeSessionId?.get?.() ?? null
|
||
|
||
if (activeBot && pinnedId && currentId && String(currentId) === String(pinnedId)) {
|
||
host.notify({
|
||
kind: 'info',
|
||
title: 'This chat never resets',
|
||
message:
|
||
'Bot chats are one continuous conversation — compacting instead. ' +
|
||
'For a throwaway session with this agent, use Sessions mode.'
|
||
})
|
||
|
||
return { ...draft, text: '/compact' }
|
||
}
|
||
}
|
||
|
||
if (!/(^|\s)@[a-z0-9][a-z0-9_-]*/i.test(text)) {
|
||
return draft
|
||
}
|
||
|
||
const live = {
|
||
name: (host.state.profile.get() || 'default').trim() || 'default',
|
||
connectionId: String(host.state.connectionId?.get?.() || host.activeConnectionId?.() || 'local')
|
||
}
|
||
const cached = typeof queryClient !== 'undefined' && queryClient && typeof queryClient.getQueryData === 'function'
|
||
? queryClient.getQueryData(ROSTER_KEY)
|
||
: null
|
||
const roster = Array.isArray(cached?.profiles) ? cached.profiles : null
|
||
let mentionedBots = roster ? resolveRosterMentions(text, roster, live) : []
|
||
|
||
if (!roster) {
|
||
let names = []
|
||
try {
|
||
const res = await host.request('profiles.list', { include_sessions: false })
|
||
names = (res?.profiles ?? []).map(p => p.name)
|
||
} catch {
|
||
return draft
|
||
}
|
||
|
||
const prose = text.replace(/```[\s\S]*?```/g, ' ').replace(/`[^`\n]*`/g, ' ')
|
||
const mentioned = []
|
||
|
||
for (const match of prose.matchAll(/(^|\s)@([a-z0-9][a-z0-9_-]*)/gi)) {
|
||
let name = match[2].toLowerCase()
|
||
|
||
if (name === 'hermes' && !names.includes('hermes') && names.includes('default')) {
|
||
name = 'default'
|
||
}
|
||
|
||
if (names.includes(name) && name !== live.name && !mentioned.includes(name)) {
|
||
mentioned.push(name)
|
||
}
|
||
}
|
||
|
||
mentionedBots = mentioned.map(name => ({ name }))
|
||
}
|
||
|
||
if (!mentionedBots.length) {
|
||
return draft
|
||
}
|
||
|
||
const localMentions = mentionedBots.filter(bot => !bot.remoteSource)
|
||
const remoteMentions = mentionedBots.filter(bot => bot.remoteSource)
|
||
|
||
const activeMeta = $botMeta.get()[live.name]
|
||
const senderName = displayName({ name: live.name, title: activeMeta?.title }, activeMeta)
|
||
|
||
if (remoteMentions.length && typeof host.requestProfile === 'function') {
|
||
void deliverRemoteRosterMentions(remoteMentions, text, {
|
||
name: senderName,
|
||
handle: botHandle(live.name)
|
||
})
|
||
}
|
||
let note = ''
|
||
|
||
if (localMentions.length) {
|
||
note +=
|
||
'\n\n[@mention handoff — for each mentioned agent (' + localMentions.map(bot => botHandle(bot.name, bot)).join(', ') + '): ' +
|
||
'COMPOSE a message from you (' + senderName + ') to that agent conveying what the user wants — do not forward this text verbatim (avoid double quotes in your composed message). Send it with exactly one terminal call, run with background=true AND notify_on_complete=true (the recipient may take minutes; the user must not be blocked):\n' +
|
||
localMentions.map(bot => '`hermes -p ' + shellQuote(bot.name) + ' chat --in ~ -c "Bot Chat" --create-if-missing -Q -q "Message from 🤖 ' + shellDoubleQuote(senderName) + ' (@' + shellDoubleQuote(botHandle(live.name)) + '): <your composed message>"`').join('\n') +
|
||
'\nAfter dispatching, tell the user the message was sent and END YOUR TURN — do not wait or poll; when the background process completes, its notification carries the reply — relay it then, attributed to that agent. ' +
|
||
'Relay the reply back to the user, attributed to that agent.]'
|
||
}
|
||
|
||
if (remoteMentions.length) {
|
||
const labels = remoteMentions.map(bot => `@${botHandle(bot.name, bot)} (${bot.connectionLabel || bot.connectionId})`).join(', ')
|
||
note +=
|
||
'\n\n[@mention — stay on this device. Desktop is delivering to ' + labels +
|
||
' over Connections in the background. Do not run hermes -p for them and do not switch Gateway. Tell the user they were messaged here; when a reply lands, relay it attributed to that agent.]'
|
||
}
|
||
|
||
return { ...draft, text: text + note }
|
||
} }
|
||
})
|
||
}
|
||
}
|