refactor(desktop): rebuild Bot Mode on the app's design system
Bot Mode arrived as a 16,933-line plugin.js that reimplemented most of the app: its own scroll container, color palette, status dots, empty states, buttons, time formatting and cron surface, none of which could follow the theme. Split it into 41 focused modules and route every one of those through the primitives core already ships, so a Bot row now renders the same SessionStatusDot, swatches and age labels as the session row beside it. User-facing strings move into a plugin locale bundle instead of sitting inline, and the UI settles on "bot" as the noun (model-facing prompt text still says "agent"). The codemod scaffolding that drove the jsx() -> TSX conversion retires with the conversion.
This commit is contained in:
@@ -1,251 +0,0 @@
|
||||
/**
|
||||
* Inverse-compile `jsx()` / `jsxs()` runtime calls back into JSX syntax.
|
||||
*
|
||||
* hermes-bots/plugin.js is hand-written JSX compiler output: it imports
|
||||
* { jsx, jsxs } from 'react/jsx-runtime' and calls them directly. That makes
|
||||
* the conversion to real JSX a deterministic AST transform rather than a
|
||||
* rewrite.
|
||||
*
|
||||
* Usage: node dejsx.mjs <in.js> <out.tsx>
|
||||
*/
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
|
||||
import generate from '@babel/generator'
|
||||
import { parse } from '@babel/parser'
|
||||
import traverse from '@babel/traverse'
|
||||
import * as t from '@babel/types'
|
||||
|
||||
const gen = generate.default ?? generate
|
||||
const walk = traverse.default ?? traverse
|
||||
|
||||
const [, , inPath, outPath] = process.argv
|
||||
|
||||
const source = readFileSync(inPath, 'utf8')
|
||||
const ast = parse(source, {
|
||||
sourceType: 'module',
|
||||
plugins: ['jsx'],
|
||||
attachComment: true
|
||||
})
|
||||
|
||||
const stats = { jsx: 0, jsxs: 0, skipped: [] }
|
||||
|
||||
/** `'div'` → intrinsic; `Foo` → component; `Foo.Bar` → member. */
|
||||
function toJsxName(node) {
|
||||
if (t.isStringLiteral(node)) {
|
||||
// Only bare tag names are expressible as JSX intrinsics.
|
||||
return /^[a-z][a-z0-9]*$/i.test(node.value) ? t.jsxIdentifier(node.value) : null
|
||||
}
|
||||
|
||||
if (t.isIdentifier(node)) {
|
||||
return t.jsxIdentifier(node.name)
|
||||
}
|
||||
|
||||
if (t.isMemberExpression(node) && !node.computed) {
|
||||
const object = toJsxName(node.object)
|
||||
const property = t.isIdentifier(node.property) ? t.jsxIdentifier(node.property.name) : null
|
||||
|
||||
return object && property ? t.jsxMemberExpression(object, property) : null
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/** JSX attribute names permit dashes and colons, so most keys pass through. */
|
||||
function toAttrName(key, computed) {
|
||||
if (computed) {
|
||||
return null
|
||||
}
|
||||
|
||||
const raw = t.isIdentifier(key) ? key.name : t.isStringLiteral(key) ? key.value : null
|
||||
|
||||
return raw && /^[A-Za-z_$][-:A-Za-z0-9_$]*$/.test(raw) ? t.jsxIdentifier(raw) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* A string child is safe as bare JSXText only when it survives a round trip:
|
||||
* no JSX metacharacters, and no leading/trailing whitespace (which JSX trims).
|
||||
*/
|
||||
function textIsSafe(value) {
|
||||
return value.length > 0 && !/[{}<>]/.test(value) && value === value.trim() && !/\n/.test(value)
|
||||
}
|
||||
|
||||
function toChild(node) {
|
||||
if (t.isJSXElement(node) || t.isJSXFragment(node)) {
|
||||
return node
|
||||
}
|
||||
|
||||
if (t.isStringLiteral(node) && textIsSafe(node.value)) {
|
||||
return t.jsxText(node.value)
|
||||
}
|
||||
|
||||
// `null` / `false` children are pure noise once inlined, but they carry
|
||||
// meaning inside conditionals, so only drop the standalone literals.
|
||||
if (t.isNullLiteral(node) || (t.isBooleanLiteral(node) && node.value === false)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return t.jsxExpressionContainer(node)
|
||||
}
|
||||
|
||||
/**
|
||||
* Comments between children have to become `{/* … *␟/}` — a bare `//` in JSX
|
||||
* children position is literal text, not a comment. Line comments are
|
||||
* rewritten as block comments so they survive the move.
|
||||
*/
|
||||
function commentChild(comments) {
|
||||
const empty = t.jsxEmptyExpression()
|
||||
|
||||
empty.innerComments = comments.map(comment => ({
|
||||
type: 'CommentBlock',
|
||||
value: comment.type === 'CommentLine' ? ` ${comment.value.trim()} ` : comment.value
|
||||
}))
|
||||
|
||||
return t.jsxExpressionContainer(empty)
|
||||
}
|
||||
|
||||
function childrenFrom(node) {
|
||||
const items = t.isArrayExpression(node) ? node.elements : [node]
|
||||
const children = []
|
||||
|
||||
for (const item of items) {
|
||||
if (!item) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (item.leadingComments?.length) {
|
||||
children.push(commentChild(item.leadingComments))
|
||||
item.leadingComments = null
|
||||
}
|
||||
|
||||
const child = t.isSpreadElement(item) ? t.jsxExpressionContainer(item.argument) : toChild(item)
|
||||
|
||||
if (child) {
|
||||
children.push(child)
|
||||
}
|
||||
|
||||
if (item.trailingComments?.length) {
|
||||
children.push(commentChild(item.trailingComments))
|
||||
item.trailingComments = null
|
||||
}
|
||||
}
|
||||
|
||||
return children
|
||||
}
|
||||
|
||||
function convert(path) {
|
||||
const { node } = path
|
||||
const [type, props, key] = node.arguments
|
||||
|
||||
const name = toJsxName(type)
|
||||
|
||||
if (!name) {
|
||||
stats.skipped.push(`${node.loc?.start.line}: dynamic element type`)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (!t.isObjectExpression(props)) {
|
||||
stats.skipped.push(`${node.loc?.start.line}: non-literal props`)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const attributes = []
|
||||
let children = []
|
||||
|
||||
if (key) {
|
||||
attributes.push(t.jsxAttribute(t.jsxIdentifier('key'), t.jsxExpressionContainer(key)))
|
||||
}
|
||||
|
||||
for (const prop of props.properties) {
|
||||
if (t.isSpreadElement(prop)) {
|
||||
attributes.push(t.jsxSpreadAttribute(prop.argument))
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (!t.isObjectProperty(prop)) {
|
||||
stats.skipped.push(`${node.loc?.start.line}: object method in props`)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const attrName = toAttrName(prop.key, prop.computed)
|
||||
|
||||
if (!attrName) {
|
||||
stats.skipped.push(`${node.loc?.start.line}: unexpressible prop key`)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (attrName.name === 'children') {
|
||||
children = childrenFrom(prop.value)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
// `foo={true}` is idiomatic as a bare `foo`; string values print unquoted.
|
||||
const value =
|
||||
t.isStringLiteral(prop.value) && !/[\n"]/.test(prop.value.value)
|
||||
? t.stringLiteral(prop.value.value)
|
||||
: t.isBooleanLiteral(prop.value) && prop.value.value === true
|
||||
? null
|
||||
: t.jsxExpressionContainer(prop.value)
|
||||
|
||||
const attribute = t.jsxAttribute(attrName, value)
|
||||
|
||||
// Carry the explanatory comments that sit above props — this file's
|
||||
// comments are most of its documentation.
|
||||
if (prop.leadingComments?.length) {
|
||||
attribute.leadingComments = prop.leadingComments
|
||||
}
|
||||
|
||||
attributes.push(attribute)
|
||||
}
|
||||
|
||||
const selfClosing = children.length === 0
|
||||
const element = t.jsxElement(
|
||||
t.jsxOpeningElement(name, attributes, selfClosing),
|
||||
selfClosing ? null : t.jsxClosingElement(name),
|
||||
children,
|
||||
selfClosing
|
||||
)
|
||||
|
||||
t.inherits(element, node)
|
||||
path.replaceWith(element)
|
||||
|
||||
stats[node.callee.name] += 1
|
||||
}
|
||||
|
||||
walk(ast, {
|
||||
CallExpression: {
|
||||
// Post-order: inner calls are already JSXElements by the time we rebuild
|
||||
// the parent, so they slot straight in as children.
|
||||
exit(path) {
|
||||
const callee = path.node.callee
|
||||
|
||||
if (t.isIdentifier(callee) && (callee.name === 'jsx' || callee.name === 'jsxs')) {
|
||||
convert(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// The runtime import is what we just eliminated.
|
||||
walk(ast, {
|
||||
ImportDeclaration(path) {
|
||||
if (path.node.source.value === 'react/jsx-runtime') {
|
||||
path.remove()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const output = gen(ast, { jsescOption: { minimal: true }, retainLines: false, comments: true }, source)
|
||||
|
||||
writeFileSync(outPath, output.code)
|
||||
|
||||
console.log(`jsx: ${stats.jsx} jsxs: ${stats.jsxs} skipped: ${stats.skipped.length}`)
|
||||
|
||||
for (const skip of stats.skipped.slice(0, 40)) {
|
||||
console.log(` skip ${skip}`)
|
||||
}
|
||||
@@ -1,267 +0,0 @@
|
||||
/**
|
||||
* Prove the de-JSX codemod was lossless.
|
||||
*
|
||||
* Recompiles the generated .tsx back down to jsx-runtime calls with esbuild,
|
||||
* then compares it against the original hand-written source. Both sides are
|
||||
* normalized through the same printer so the diff reflects semantics, not
|
||||
* formatting.
|
||||
*
|
||||
* jsx/jsxs selection is deliberately collapsed: the pair differ only in React's
|
||||
* dev-mode static-children key warning, and esbuild picks between them on its
|
||||
* own rules rather than the ones the file was hand-written with.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import generate from '@babel/generator'
|
||||
import { parse } from '@babel/parser'
|
||||
import traverse from '@babel/traverse'
|
||||
import * as t from '@babel/types'
|
||||
import { transformSync } from 'esbuild'
|
||||
|
||||
const gen = generate.default ?? generate
|
||||
const walk = traverse.default ?? traverse
|
||||
|
||||
const [, , originalPath, convertedPath] = process.argv
|
||||
|
||||
const allowed = { spreadChildren: 0, redundantKeyProp: 0 }
|
||||
|
||||
function normalize(code, plugins) {
|
||||
const ast = parse(code, { sourceType: 'module', plugins })
|
||||
|
||||
walk(ast, {
|
||||
/**
|
||||
* JSX has no spread-children syntax: `children: [...items.map(f), x]` can
|
||||
* only be written `{items.map(f)}<X/>`, which compiles to
|
||||
* `children: [items.map(f), x]`. React flattens nested array children and
|
||||
* the mapped elements keep their explicit keys, so the two are equivalent
|
||||
* — this is the idiom the whole ecosystem writes. Fold the spread away on
|
||||
* the original side so the comparison doesn't flag it, and count it.
|
||||
*/
|
||||
ObjectProperty(path) {
|
||||
if (!t.isIdentifier(path.node.key, { name: 'children' }) || !t.isArrayExpression(path.node.value)) {
|
||||
return
|
||||
}
|
||||
|
||||
path.node.value.elements = path.node.value.elements.map(element => {
|
||||
if (!t.isSpreadElement(element)) {
|
||||
return element
|
||||
}
|
||||
|
||||
allowed.spreadChildren += 1
|
||||
|
||||
return element.argument
|
||||
})
|
||||
},
|
||||
// Drop captured raw text so both sides re-print literals canonically —
|
||||
// one quote style, and `48000` rather than esbuild's `48e3`.
|
||||
'StringLiteral|NumericLiteral'(path) {
|
||||
delete path.node.extra
|
||||
},
|
||||
// Template chunks keep their raw text, so an emoji written as a surrogate
|
||||
// pair on one side and a code point on the other reads as a diff.
|
||||
TemplateElement(path) {
|
||||
const { cooked } = path.node.value
|
||||
|
||||
if (typeof cooked === 'string') {
|
||||
path.node.value.raw = cooked.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${')
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Some call sites pass `key` twice — inside props AND as the third
|
||||
* argument. React's jsx runtime takes the key from the third argument and
|
||||
* never copies `key` into props, so the props copy is dead either way.
|
||||
*/
|
||||
CallExpression(path) {
|
||||
const callee = path.node.callee
|
||||
const [, props] = path.node.arguments
|
||||
|
||||
if (!t.isIdentifier(callee) || callee.name !== 'jsx' || !t.isObjectExpression(props)) {
|
||||
return
|
||||
}
|
||||
|
||||
props.properties = props.properties.filter(prop => {
|
||||
if (t.isObjectProperty(prop) && !prop.computed && t.isIdentifier(prop.key, { name: 'key' })) {
|
||||
allowed.redundantKeyProp += 1
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
},
|
||||
// esbuild folds adjacent string/template concatenation into one literal.
|
||||
BinaryExpression: {
|
||||
exit(path) {
|
||||
const { operator, left, right } = path.node
|
||||
|
||||
if (operator !== '+') {
|
||||
return
|
||||
}
|
||||
|
||||
if (t.isStringLiteral(left) && t.isStringLiteral(right)) {
|
||||
path.replaceWith(t.stringLiteral(left.value + right.value))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// `` `a${x}b` + 'c' `` → `` `a${x}bc` ``
|
||||
if (t.isTemplateLiteral(left) && t.isStringLiteral(right)) {
|
||||
const quasis = left.quasis.map(quasi => t.cloneNode(quasi))
|
||||
const last = quasis[quasis.length - 1]
|
||||
|
||||
last.value = { raw: last.value.cooked + right.value, cooked: last.value.cooked + right.value }
|
||||
path.replaceWith(t.templateLiteral(quasis, left.expressions))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// `'a' + `b${x}` ` → `` `ab${x}` ``
|
||||
if (t.isStringLiteral(left) && t.isTemplateLiteral(right)) {
|
||||
const quasis = right.quasis.map(quasi => t.cloneNode(quasi))
|
||||
const first = quasis[0]
|
||||
|
||||
first.value = { raw: left.value + first.value.cooked, cooked: left.value + first.value.cooked }
|
||||
path.replaceWith(t.templateLiteral(quasis, right.expressions))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// `` `a${x}` + `b${y}` `` → `` `a${x}b${y}` `` — the seam quasis merge.
|
||||
if (t.isTemplateLiteral(left) && t.isTemplateLiteral(right)) {
|
||||
const leftQuasis = left.quasis.map(quasi => t.cloneNode(quasi))
|
||||
const rightQuasis = right.quasis.map(quasi => t.cloneNode(quasi))
|
||||
const seam = leftQuasis.pop()
|
||||
const head = rightQuasis.shift()
|
||||
const merged = (seam.value.cooked ?? '') + (head.value.cooked ?? '')
|
||||
|
||||
seam.value = { raw: merged, cooked: merged }
|
||||
path.replaceWith(
|
||||
t.templateLiteral([...leftQuasis, seam, ...rightQuasis], [...left.expressions, ...right.expressions])
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
// esbuild prefers a template literal when it saves escaping.
|
||||
TemplateLiteral: {
|
||||
exit(path) {
|
||||
if (path.node.expressions.length === 0) {
|
||||
path.replaceWith(t.stringLiteral(path.node.quasis[0].value.cooked ?? ''))
|
||||
}
|
||||
}
|
||||
},
|
||||
// esbuild rewrites `undefined` to `void 0`.
|
||||
UnaryExpression(path) {
|
||||
if (path.node.operator === 'void' && t.isNumericLiteral(path.node.argument, { value: 0 })) {
|
||||
path.replaceWith(t.identifier('undefined'))
|
||||
}
|
||||
},
|
||||
// esbuild emits `import { jsx as _jsx }`; the original imports it bare.
|
||||
Identifier(path) {
|
||||
if (path.node.name === '_jsx' || path.node.name === '_jsxs') {
|
||||
path.node.name = 'jsx'
|
||||
}
|
||||
|
||||
if (path.node.name === 'jsxs') {
|
||||
path.node.name = 'jsx'
|
||||
}
|
||||
},
|
||||
ImportDeclaration(path) {
|
||||
if (path.node.source.value === 'react/jsx-runtime') {
|
||||
path.remove()
|
||||
}
|
||||
},
|
||||
// esbuild hoists the spread helper for `{...props}` on intrinsics.
|
||||
VariableDeclarator(path) {
|
||||
if (t.isIdentifier(path.node.id) && /^__(spread|assign|objRest)/.test(path.node.id.name)) {
|
||||
path.remove()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// esbuild hoists `export default {…}` into
|
||||
// `var X = {…}; export { X as default }`.
|
||||
const body = ast.program.body
|
||||
const exportIndex = body.findIndex(
|
||||
node =>
|
||||
(t.isExportDefaultDeclaration(node) && t.isIdentifier(node.declaration)) ||
|
||||
(t.isExportNamedDeclaration(node) &&
|
||||
!node.declaration &&
|
||||
node.specifiers.length === 1 &&
|
||||
t.isExportSpecifier(node.specifiers[0]) &&
|
||||
t.isIdentifier(node.specifiers[0].exported, { name: 'default' }))
|
||||
)
|
||||
|
||||
if (exportIndex !== -1) {
|
||||
const exported = body[exportIndex]
|
||||
const name = t.isExportDefaultDeclaration(exported)
|
||||
? exported.declaration.name
|
||||
: exported.specifiers[0].local.name
|
||||
const declIndex = body.findIndex(
|
||||
node =>
|
||||
t.isVariableDeclaration(node) &&
|
||||
node.declarations.length === 1 &&
|
||||
t.isIdentifier(node.declarations[0].id, { name })
|
||||
)
|
||||
|
||||
if (declIndex !== -1) {
|
||||
body[exportIndex] = t.exportDefaultDeclaration(body[declIndex].declarations[0].init)
|
||||
body.splice(declIndex, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// esbuild renames locals that shadow an outer binding (`displayName` →
|
||||
// `displayName2`). Canonically rename every binding in declaration order on
|
||||
// both sides so the comparison tests alpha-equivalence rather than spelling.
|
||||
// Numbered per scope, not globally: one extra binding on one side then
|
||||
// shifts only its own scope instead of desynchronizing the whole file.
|
||||
let scopeId = 0
|
||||
|
||||
walk(ast, {
|
||||
Scopable(path) {
|
||||
const bindings = Object.values(path.scope.bindings).sort(
|
||||
(a, b) => (a.identifier.start ?? 0) - (b.identifier.start ?? 0)
|
||||
)
|
||||
const scope = (scopeId += 1)
|
||||
|
||||
bindings.forEach((binding, index) => {
|
||||
path.scope.rename(binding.identifier.name, `__s${scope}_${index}`)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return gen(ast, { comments: false, compact: true, jsescOption: { minimal: true } }).code
|
||||
}
|
||||
|
||||
const original = normalize(readFileSync(originalPath, 'utf8'), [])
|
||||
const recompiled = normalize(
|
||||
transformSync(readFileSync(convertedPath, 'utf8'), {
|
||||
loader: 'tsx',
|
||||
jsx: 'automatic',
|
||||
format: 'esm',
|
||||
target: 'esnext'
|
||||
}).code,
|
||||
[]
|
||||
)
|
||||
|
||||
console.log(
|
||||
`allowed rewrites — spread children folded: ${allowed.spreadChildren}, redundant key props dropped: ${allowed.redundantKeyProp}`
|
||||
)
|
||||
|
||||
if (original === recompiled) {
|
||||
console.log(`IDENTICAL — ${original.length} chars of normalized output match`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
console.log(`DIFFER — original ${original.length} chars, recompiled ${recompiled.length} chars`)
|
||||
|
||||
// Report the first divergence with surrounding context so it can be chased.
|
||||
let i = 0
|
||||
while (i < original.length && original[i] === recompiled[i]) {
|
||||
i += 1
|
||||
}
|
||||
|
||||
const window = 260
|
||||
console.log(`\nfirst divergence at char ${i}:`)
|
||||
console.log(`\n--- original ---\n${original.slice(Math.max(0, i - window), i + window)}`)
|
||||
console.log(`\n--- recompiled ---\n${recompiled.slice(Math.max(0, i - window), i + window)}`)
|
||||
process.exit(1)
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Image avatars: the device-upload path and the gateway's `image.generate`
|
||||
* backend (probe + generation). Kept apart from `avatar.tsx` so the roster's
|
||||
* render path doesn't drag the picker's network surface along with it.
|
||||
*/
|
||||
|
||||
import { atom, host } from '@hermes/plugin-sdk'
|
||||
|
||||
import { getPluginCtx } from './shared'
|
||||
|
||||
// ── image avatars: upload from device + generate via image.generate ─────────
|
||||
|
||||
/** Downscale to a small square so plugin storage stays light. */
|
||||
export function normalizeAvatarImage(dataUrl: string, edge = 256): Promise<string> {
|
||||
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
|
||||
})
|
||||
}
|
||||
export function pickImageFromDevice(): Promise<null | string> {
|
||||
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: getPluginCtx()?.i18n.t('avatar.imageTooLarge') ?? '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()
|
||||
})
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
export const $imagenAvailable = atom<boolean | null>(null)
|
||||
let imagenProbeInflight: Promise<void> | null = null
|
||||
export function probeImagen() {
|
||||
if (imagenProbeInflight) {
|
||||
return imagenProbeInflight
|
||||
}
|
||||
imagenProbeInflight = host
|
||||
.request<{ available?: boolean }>('image.generate', {
|
||||
probe: true
|
||||
})
|
||||
.then(res => $imagenAvailable.set(Boolean(res?.available)))
|
||||
.catch(() => $imagenAvailable.set(false))
|
||||
.finally(() => {
|
||||
imagenProbeInflight = null
|
||||
})
|
||||
return imagenProbeInflight
|
||||
}
|
||||
/** `image.generate`'s reply. `image_data` is a data URL (works over remote
|
||||
* gateways); `image` is the raw backend URL fallback. */
|
||||
export interface GeneratedImage {
|
||||
error?: string
|
||||
image?: string
|
||||
image_data?: string
|
||||
success?: boolean
|
||||
}
|
||||
|
||||
export async function generateAvatarImage(bot: string, title?: string, description?: string): Promise<string | undefined> {
|
||||
const who = [title || bot, description].filter(Boolean).join(' — ')
|
||||
const res = await host.request<GeneratedImage>('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
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
export function isBackfilledFacePng(dataUrl: null | string | undefined) {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* The avatar editor shared by Edit Profile and New Bot: shape grid + color
|
||||
* swatches, the Generate and Upload tabs, and the petdex Pet tab.
|
||||
*/
|
||||
|
||||
import {
|
||||
Button,
|
||||
cn,
|
||||
Codicon,
|
||||
ColorSwatches,
|
||||
GlyphSpinner,
|
||||
host,
|
||||
PROFILE_SWATCHES,
|
||||
RowButton,
|
||||
SegmentedControl,
|
||||
Textarea,
|
||||
useValue
|
||||
} from '@hermes/plugin-sdk'
|
||||
import { useState } from 'react'
|
||||
|
||||
import {
|
||||
AVATAR_PICKER_SHAPES,
|
||||
avatarColor,
|
||||
BLOB_KINDS,
|
||||
blobatarSvg,
|
||||
blobShapeString,
|
||||
BotFace,
|
||||
defaultShapeFor,
|
||||
isBlobShape,
|
||||
parseBlobShape
|
||||
} from './avatar'
|
||||
import {
|
||||
$imagenAvailable,
|
||||
generateAvatarImage,
|
||||
type GeneratedImage,
|
||||
normalizeAvatarImage,
|
||||
pickImageFromDevice,
|
||||
probeImagen
|
||||
} from './avatar-image'
|
||||
import { useBots } from './i18n'
|
||||
import { PetTab } from './pet'
|
||||
|
||||
interface AvatarPickerProps {
|
||||
/** `null` = no explicit pick, i.e. the name's deterministic hue. */
|
||||
color: null | string
|
||||
/** Feeds the Generate tab when the user leaves the description blank. */
|
||||
generateSeed?: { description?: string; name?: string; title?: string } | null
|
||||
image: null | string
|
||||
onColor: (color: null | string) => void
|
||||
onImage: (image: null | string) => void
|
||||
onShape: (shape: string) => void
|
||||
shape: string
|
||||
}
|
||||
|
||||
/** Shape grid + color swatches, shared by Edit Profile and New Bot. */
|
||||
export function AvatarPicker({ shape, color, image, onShape, onColor, onImage, generateSeed }: AvatarPickerProps) {
|
||||
const b = useBots()
|
||||
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: string) => {
|
||||
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<GeneratedImage>('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, b.avatar.generationFailed)
|
||||
} finally {
|
||||
setGenBusy(false)
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="grid justify-items-center gap-3">
|
||||
{/* Tab pills: Bot | Generate | Upload | Pet */}
|
||||
<SegmentedControl
|
||||
options={[
|
||||
{ id: 'bot', label: 'Bot' },
|
||||
{ id: 'generate', label: 'Generate' },
|
||||
{ id: 'upload', label: b.avatar.upload },
|
||||
{ id: 'pet', label: 'Pet' }
|
||||
]}
|
||||
value={tab}
|
||||
onChange={goTab}
|
||||
/>
|
||||
{image && tab !== 'generate' ? (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => onImage(null)}>
|
||||
{b.avatar.removeImage}
|
||||
</Button>
|
||||
) : null}
|
||||
{tab === 'bot' ? (
|
||||
isBlobShape(shape) && blobatarSvg ? (
|
||||
(() => {
|
||||
const { seedPart, kind } = parseBlobShape(shape, pickerName)
|
||||
const locked = Boolean(seedPart)
|
||||
return (
|
||||
<div className="grid justify-items-center gap-3">
|
||||
{/* Silhouette pins: Auto (name decides) + the six blob kinds. */}
|
||||
<div className="grid grid-cols-4 justify-items-center gap-1.5">
|
||||
{['', ...BLOB_KINDS].map(k => (
|
||||
<RowButton
|
||||
key={k || 'auto'}
|
||||
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))
|
||||
}}
|
||||
>
|
||||
{k ? (
|
||||
<BotFace shape={blobShapeString(seedPart, k)} color={avatarColor(color, pickerName)} size={32} name={pickerName} />
|
||||
) : (
|
||||
<span className="text-[0.6rem] text-(--ui-text-tertiary)">Auto</span>
|
||||
)}
|
||||
</RowButton>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onImage(null)
|
||||
onShape(blobShapeString(Math.random().toString(36).slice(2, 10), kind))
|
||||
}}
|
||||
>
|
||||
<Codicon name="refresh" className="mr-1 text-[0.8rem]" />
|
||||
{b.avatar.randomize}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
title={
|
||||
locked ? b.avatar.unlockFollowsName : 'Keep this exact face even if the name changes'
|
||||
}
|
||||
onClick={() => onShape(blobShapeString(locked ? '' : pickerName, kind))}
|
||||
>
|
||||
<Codicon name={locked ? 'unlock' : 'lock'} className="mr-1 text-[0.8rem]" />
|
||||
{locked ? 'Unlock' : 'Lock face'}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-center text-[0.65rem] text-(--ui-text-quaternary)">
|
||||
{locked ? 'Face locked — renaming won\u2019t change it.' : 'Face follows the name.'}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-(--ui-text-tertiary)"
|
||||
onClick={() => onShape(defaultShapeFor(pickerName))}
|
||||
>
|
||||
{b.avatar.classicShapes}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
})()
|
||||
) : (
|
||||
<div className="grid justify-items-center gap-3">
|
||||
<div className="grid grid-cols-4 justify-items-center gap-1.5">
|
||||
{(blobatarSvg ? ['blobatar', ...AVATAR_PICKER_SHAPES] : AVATAR_PICKER_SHAPES).map(s => (
|
||||
<RowButton
|
||||
key={s}
|
||||
title={s === 'blobatar' ? b.avatar.blobFromName : 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)
|
||||
}}
|
||||
>
|
||||
<BotFace shape={s} color={avatarColor(color, pickerName)} size={32} name={pickerName} />
|
||||
</RowButton>
|
||||
))}
|
||||
</div>
|
||||
<ColorSwatches
|
||||
clearLabel={b.avatar.matchTheName}
|
||||
onChange={onColor}
|
||||
swatches={PROFILE_SWATCHES}
|
||||
value={color}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
{tab === 'generate' ? (
|
||||
imagen ? (
|
||||
<div className="grid w-full gap-2">
|
||||
<Textarea
|
||||
className="min-h-16 text-xs"
|
||||
placeholder={b.avatar.describePlaceholder}
|
||||
value={describe}
|
||||
onChange={event => setDescribe(event.target.value)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="w-full justify-center"
|
||||
disabled={genBusy}
|
||||
onClick={generate}
|
||||
>
|
||||
{genBusy ? (
|
||||
<GlyphSpinner spinner="breathe" className="mr-1 text-[0.8rem]" />
|
||||
) : (
|
||||
<Codicon name="sparkle" className="mr-1 text-[0.8rem]" />
|
||||
)}
|
||||
{genBusy ? 'Generating…' : 'Generate'}
|
||||
</Button>
|
||||
{describe.trim() ? null : (
|
||||
<div className="text-center text-[0.65rem] text-(--ui-text-quaternary)">
|
||||
{b.bot.descriptionHint}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-2 py-3 text-center text-xs leading-5 text-(--ui-text-tertiary)">
|
||||
{imagen === false
|
||||
? 'No image model available. If you just enabled one (or updated Hermes), restart the gateway: Ctrl+K → "Restart gateway".'
|
||||
: 'Checking image backend…'}
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
{tab === 'upload' ? (
|
||||
<Button type="button" variant="secondary" className="w-full justify-center" onClick={upload}>
|
||||
<Codicon name="device-camera" className="mr-1 text-[0.8rem]" />
|
||||
Choose an image…
|
||||
</Button>
|
||||
) : null}
|
||||
{tab === 'pet' ? <PetTab image={image} onImage={onImage} /> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,480 @@
|
||||
/**
|
||||
* The two rows the Bots rail lists: one bot, and one group chat.
|
||||
*
|
||||
* Rows only. Both take their bot or room and their context-menu callbacks as
|
||||
* props and own no roster state, so the pane above them decides what to list
|
||||
* and the row decides only how it reads.
|
||||
*/
|
||||
|
||||
import {
|
||||
cn,
|
||||
coarseElapsed,
|
||||
Codicon,
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
haptic,
|
||||
host,
|
||||
queryClient,
|
||||
RowButton,
|
||||
SessionStatusDot,
|
||||
Tip,
|
||||
useI18n,
|
||||
useValue
|
||||
} from '@hermes/plugin-sdk'
|
||||
|
||||
import { avatarColor, botAppearance, BotFace } from './avatar'
|
||||
import { isBackfilledFacePng } from './avatar-image'
|
||||
import {
|
||||
$botChatFocused,
|
||||
$focusedBotOwner,
|
||||
$selectedRosterKey,
|
||||
focusedRosterOwner,
|
||||
saveSelectedRosterBot
|
||||
} from './bot-state'
|
||||
import { ensureBotMetadata } from './canonical-chat'
|
||||
import {
|
||||
$botAttention,
|
||||
$botMeta,
|
||||
$lastRoster,
|
||||
BOT_ATTENTION_HINTS,
|
||||
botActivitySession,
|
||||
botHandle,
|
||||
botRosterKey,
|
||||
botSelectionKey,
|
||||
botSourceStatus,
|
||||
isActiveRosterBot,
|
||||
isDefaultBot,
|
||||
newBotChat,
|
||||
ROSTER_KEY,
|
||||
saveBotMeta
|
||||
} from './data'
|
||||
import { $groupChats, $groupChatWorkspace } from './group-chat'
|
||||
import { botGroups, groupLastActivity } from './group-membership'
|
||||
import { fallbackSelectionAfterHide, isBotHidden, isBotPinned } from './hidden-bots'
|
||||
import { useBots } from './i18n'
|
||||
import { displayName, stripPreviewMarkdown } from './labels'
|
||||
import { duplicateBot } from './profile-ops'
|
||||
import { openRosterBot } from './roster-actions'
|
||||
import { botRosterMeta, botWorkspaceOwnerKey, setBotsWorkspaceOwner } from './routing'
|
||||
import { A2A_PREFIX_RE, botCanonicalSessionId, botRowOwnsWorkspace, previewKind, workerActiveAt } from './row-helpers'
|
||||
import type { GroupMember, RosterRow, SidebarRowLabels } from './types'
|
||||
|
||||
// ── bot row ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Row age in the sidebar's compact form ("now", "52m", "3h", "18d").
|
||||
* Deliberately the same `coarseElapsed` + suffix pair the session rows
|
||||
* directly above use, so the two lists in one rail don't disagree about how
|
||||
* an age is spelled. Not `relativeTime` — that's the bidirectional Intl form
|
||||
* ("in 14 hr"), which belongs on a scheduled next-run. */
|
||||
function rowAge(ms: number, r: SidebarRowLabels): string {
|
||||
const { unit, value } = coarseElapsed(Date.now() - ms)
|
||||
|
||||
return unit === 'second' ? r.ageNow : `${value}${unit === 'day' ? r.ageDay : unit === 'hour' ? r.ageHour : r.ageMin}`
|
||||
}
|
||||
|
||||
interface BotRowProps {
|
||||
bot: RosterRow
|
||||
onDelete: (bot: RosterRow) => void
|
||||
onEdit: (bot: RosterRow) => void
|
||||
onGroup: (bot: RosterRow) => void
|
||||
showHandle?: boolean
|
||||
}
|
||||
export function BotRow({ bot, onDelete, onEdit, onGroup, showHandle }: BotRowProps) {
|
||||
const { t } = useI18n()
|
||||
const b = useBots()
|
||||
const activeProfile = useValue(host.state.profile)
|
||||
const focusedOwner = focusedRosterOwner(useValue($focusedBotOwner))
|
||||
const selectedRosterKey = useValue($selectedRosterKey)
|
||||
const botChatFocused = useValue($botChatFocused)
|
||||
const activeGroup = useValue($groupChatWorkspace)
|
||||
const allMeta = useValue($botMeta)
|
||||
const meta = botRosterMeta(bot, allMeta)
|
||||
const hidden = isBotHidden(bot, allMeta)
|
||||
const pinned = isBotPinned(bot, allMeta)
|
||||
const sourceStatus = botSourceStatus(bot)
|
||||
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.
|
||||
// A selected group chat suppresses every bot-row highlight: the group row
|
||||
// owns the selection then (#88979).
|
||||
const activeConnectionId = String(host.state.connectionId?.get?.() || 'local').trim()
|
||||
// The highlight follows whoever owns the MAIN workspace. While a chat owns
|
||||
// it, that chat's profile wins (a stale roster click must not key the
|
||||
// highlight to a bot you are not reading). With no focused chat, the
|
||||
// source-qualified selection is the owner — and it is the only rule that
|
||||
// can highlight a remote row, which has no focusable local chat.
|
||||
const isActive = botRowOwnsWorkspace(bot, activeGroup, botChatFocused, focusedOwner, selectedRosterKey)
|
||||
// Turn-busy is a SOCKET fact: only the gateway-home profile can be mid-turn.
|
||||
const isGatewayHome =
|
||||
!bot.remoteSource &&
|
||||
bot.name === activeProfile &&
|
||||
isActiveRosterBot(bot, {
|
||||
name: activeProfile,
|
||||
connectionId: activeConnectionId
|
||||
})
|
||||
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)
|
||||
// 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. Activity signals
|
||||
// (age label, pulse dot) follow the same rule via botActivitySession:
|
||||
// the canonical Bot Chat is hidden from last_session, so keying age off
|
||||
// last_session alone shows "6d ago" on a bot you just messaged.
|
||||
const previewSession = bot.canonical_session || last
|
||||
const activitySession = botActivitySession(bot)
|
||||
// A live kanban/tool worker counts as activity (#90268): fresh age while it
|
||||
// runs, falling back to chat activity when it ends.
|
||||
const workerActive = workerActiveAt(bot)
|
||||
const rowAgeTs = workerActive
|
||||
? Math.max(activitySession?.last_active || 0, bot.worker_session?.last_active || 0)
|
||||
: activitySession?.last_active || 0
|
||||
const botMood = workerActive || (isGatewayHome && gatewayState === 'busy') ? 'work' : 'idle'
|
||||
// Status keys off the canonical Bot Chat — the very session this row opens,
|
||||
// so the dot and the click can never describe different conversations.
|
||||
const canonicalSessionId = botCanonicalSessionId(bot)
|
||||
// Needs-attention badge (#93091 item 3): background failures record under
|
||||
// the selection key (group turns) or the route key (relay deliveries) —
|
||||
// check both. Local/unannotated rows carry no connectionId, so their relay
|
||||
// failures live under `<activeConnectionId>::<name>` — resolve that shape
|
||||
// too or active-gateway bots never badge. Hidden bots keep their entry;
|
||||
// hiding is display-only.
|
||||
const attentionByKey = useValue($botAttention)
|
||||
const attention =
|
||||
attentionByKey[botSelectionKey(bot)] ||
|
||||
attentionByKey[botRosterKey(bot)] ||
|
||||
attentionByKey[`${bot?.connectionId || activeConnectionId}::${bot?.name || 'default'}`] ||
|
||||
null
|
||||
// WHO sent the last message (bot-to-bot DM vs human) — the full stored
|
||||
// history lives in the canonical chat, 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 { 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 || ''
|
||||
)
|
||||
const handle = botHandle(bot.name, bot)
|
||||
const gatewayLabel = bot.connectionLabel || (bot.connectionId === 'local' ? 'This device' : '')
|
||||
const showDetailsRow = Boolean(showHandle || displayPreview || fromBot)
|
||||
const rowTooltip = [displayName(bot, meta), `@${handle}`, gatewayLabel, sourceStatus.label]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
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 */
|
||||
}
|
||||
}
|
||||
|
||||
// Rows and Active Now share the exact-owner open path; only that path may
|
||||
// activate a source and resolve the canonical Bot Chat.
|
||||
const open = () => void openRosterBot(bot)
|
||||
const row = (
|
||||
<RowButton
|
||||
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-(--ui-row-active-background)'
|
||||
)}
|
||||
aria-label={rowTooltip}
|
||||
>
|
||||
<div className={cn('shrink-0', !sourceStatus.available && 'grayscale opacity-60')}>
|
||||
<BotFace shape={shape} color={avatarColor(color, bot.name)} image={photo ? image : null} size={34} name={bot.name} mood={botMood} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
{/* Lead slot, same geometry as the session row's: a 3.5 grid cell
|
||||
ahead of the title so every row's name shares one left edge. */}
|
||||
<span className="grid size-3.5 shrink-0 place-items-center">
|
||||
<SessionStatusDot storedSessionId={canonicalSessionId} />
|
||||
</span>
|
||||
{pinned ? (
|
||||
<Tip label={b.roster.pinned}>
|
||||
<Codicon name="pinned" className="shrink-0 text-[0.6875rem] text-(--ui-text-quaternary)" />
|
||||
</Tip>
|
||||
) : null}
|
||||
{hidden ? (
|
||||
<Tip label={b.roster.hiddenFromRoster}>
|
||||
<Codicon name="eye-closed" className="shrink-0 text-[0.6875rem] text-(--ui-text-quaternary)" />
|
||||
</Tip>
|
||||
) : null}
|
||||
<Tip label={rowTooltip}>
|
||||
<span className="min-w-0 truncate text-[0.8125rem] font-medium">{displayName(bot, meta)}</span>
|
||||
</Tip>
|
||||
</div>
|
||||
{attention ? (
|
||||
<Tip label={BOT_ATTENTION_HINTS[attention.reason] || 'Needs attention'}>
|
||||
<Codicon
|
||||
name="warning"
|
||||
className="shrink-0 text-[0.6875rem] text-amber-600 dark:text-amber-300"
|
||||
aria-label={b.roster.needsAttention}
|
||||
/>
|
||||
</Tip>
|
||||
) : null}
|
||||
{rowAgeTs ? (
|
||||
<span className="shrink-0 text-[0.6875rem] text-(--ui-text-quaternary)">
|
||||
{rowAge(rowAgeTs * 1000, t.sidebar.row)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{showDetailsRow ? (
|
||||
<div className="flex min-w-0 items-center gap-1.5 text-xs text-(--ui-text-tertiary)">
|
||||
{showHandle ? (
|
||||
<span className="shrink-0 font-mono text-[0.6875rem] text-(--ui-text-quaternary)">{`@${handle}`}</span>
|
||||
) : null}
|
||||
{showHandle && displayPreview ? <span className="shrink-0 text-(--ui-text-quaternary)">·</span> : null}
|
||||
{displayPreview ? (
|
||||
<span className={cn('min-w-0 truncate', fromBot && 'italic')}>{displayPreview}</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</RowButton>
|
||||
)
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>{row}</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem
|
||||
onSelect={() => {
|
||||
void ensureBotMetadata(bot)
|
||||
.then(current => {
|
||||
const pinned = Boolean(current.pinned)
|
||||
void saveBotMeta(bot, {
|
||||
pinned: !pinned
|
||||
})
|
||||
host.notify({
|
||||
kind: 'info',
|
||||
message: `${displayName(bot, current)} ${pinned ? 'unpinned' : 'pinned to top'}`
|
||||
})
|
||||
})
|
||||
.catch(error => host.notifyError?.(error, 'Could not load bot metadata'))
|
||||
}}
|
||||
>
|
||||
{pinned ? 'Unpin' : 'Pin to top'}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onSelect={() => {
|
||||
void ensureBotMetadata(bot)
|
||||
.then(current => {
|
||||
const hidden = Boolean(current.hidden)
|
||||
void saveBotMeta(bot, {
|
||||
hidden: !hidden
|
||||
})
|
||||
if (!hidden) {
|
||||
fallbackSelectionAfterHide(botSelectionKey(bot))
|
||||
}
|
||||
host.notify({
|
||||
kind: 'info',
|
||||
message: hidden
|
||||
? `${displayName(bot, current)} is back in the roster`
|
||||
: `${displayName(bot, current)} hidden — use the eye button in the Bots header to see hidden bots`
|
||||
})
|
||||
})
|
||||
.catch(error => host.notifyError?.(error, 'Could not load bot metadata'))
|
||||
}}
|
||||
>
|
||||
{hidden ? 'Unhide' : 'Hide'}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
onSelect={() =>
|
||||
void ensureBotMetadata(bot)
|
||||
.then(() => onEdit(bot))
|
||||
.catch(error => host.notifyError?.(error, 'Could not load bot'))
|
||||
}
|
||||
>
|
||||
{b.bot.editTitle}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onSelect={() =>
|
||||
void ensureBotMetadata(bot)
|
||||
.then(() => onGroup(bot))
|
||||
.catch(error => host.notifyError?.(error, 'Could not load bot groups'))
|
||||
}
|
||||
>
|
||||
{groups.length ? `Groups: ${groups.join(', ')}…` : 'Manage groups…'}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onSelect={() => {
|
||||
host.notify({
|
||||
kind: 'info',
|
||||
message: `Duplicating ${displayName(bot, meta)}…`
|
||||
})
|
||||
duplicateBot(bot, $lastRoster.get())
|
||||
.then(name => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ROSTER_KEY
|
||||
})
|
||||
host.notify({
|
||||
kind: 'success',
|
||||
message: `Created ${name} — full copy of ${bot.name}`
|
||||
})
|
||||
})
|
||||
.catch(err => host.notifyError(err, b.bot.duplicateFailed))
|
||||
}}
|
||||
>
|
||||
{b.bot.duplicate}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
onSelect={() => {
|
||||
saveSelectedRosterBot(bot)
|
||||
setBotsWorkspaceOwner(botWorkspaceOwnerKey(bot), bot)
|
||||
newBotChat(bot)
|
||||
}}
|
||||
>
|
||||
{b.bot.newChatWith}
|
||||
</ContextMenuItem>
|
||||
{isDefaultBot(bot) ? null : <ContextMenuSeparator />}
|
||||
{isDefaultBot(bot) ? null : (
|
||||
<ContextMenuItem onSelect={() => onDelete(bot)} variant="destructive">
|
||||
{t.common.delete}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
)
|
||||
}
|
||||
|
||||
/** One group chat as one quiet roster row. The room owns one visual identity;
|
||||
* member details stay in its tooltip and workspace instead of competing
|
||||
* with bot avatars in the narrow sidebar. */
|
||||
interface GroupRowProps {
|
||||
active: boolean
|
||||
group: string
|
||||
members: GroupMember[]
|
||||
needsYou: boolean
|
||||
onDisband: (room: { members: GroupMember[]; name: string }) => void
|
||||
onOpen: (group: string) => void
|
||||
}
|
||||
export function GroupRow({ active, group, members, needsYou, onOpen, onDisband }: GroupRowProps) {
|
||||
const { t } = useI18n()
|
||||
const b = useBots()
|
||||
const rooms = useValue($groupChats)
|
||||
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) || '…'}`
|
||||
: `${members.length} bots`
|
||||
const availableMembers = members.filter(member => botSourceStatus(member).available).length
|
||||
const availabilityLabel = `${availableMembers} of ${members.length} available`
|
||||
const row = (
|
||||
<RowButton
|
||||
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)',
|
||||
active && 'bg-(--ui-row-active-background)'
|
||||
)}
|
||||
aria-label={`${group}, ${members.length} bots, ${availabilityLabel}`}
|
||||
>
|
||||
<div className="relative flex w-[34px] shrink-0 items-center justify-center">
|
||||
{room.image ? (
|
||||
<img
|
||||
src={room.image}
|
||||
alt=""
|
||||
className={cn(
|
||||
'size-8 rounded-md object-cover ring-1 ring-(--ui-stroke-tertiary)',
|
||||
availableMembers === 0 && 'grayscale opacity-60'
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className={cn(
|
||||
'flex size-8 items-center justify-center rounded-md bg-(--chrome-action-hover) text-(--ui-text-tertiary)',
|
||||
availableMembers === 0 && 'opacity-60'
|
||||
)}
|
||||
>
|
||||
<Codicon name="organization" />
|
||||
</span>
|
||||
)}
|
||||
{members.length > 0 && availableMembers < members.length ? (
|
||||
<Tip label={availabilityLabel}>
|
||||
<span
|
||||
className="absolute -bottom-0.5 -right-0.5 flex size-4 items-center justify-center rounded-full bg-(--ui-bg-primary) text-[0.625rem] text-amber-600 ring-1 ring-(--ui-stroke-tertiary) dark:text-amber-300"
|
||||
aria-label={availabilityLabel}
|
||||
>
|
||||
<Codicon name="debug-disconnect" />
|
||||
</span>
|
||||
</Tip>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="min-w-0 flex-1 truncate text-[0.8125rem] font-medium">{group}</span>
|
||||
{needsYou ? (
|
||||
<Tip label={b.group.needsYourInput}>
|
||||
<Codicon name="question" className="shrink-0 text-(--ui-accent)" aria-label={b.roster.needsInput} />
|
||||
</Tip>
|
||||
) : null}
|
||||
{lastAt ? (
|
||||
<span className="shrink-0 text-[0.6875rem] text-(--ui-text-quaternary)">
|
||||
{rowAge(lastAt, t.sidebar.row)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="min-w-0 truncate text-xs text-(--ui-text-tertiary)">{preview}</div>
|
||||
</div>
|
||||
</RowButton>
|
||||
)
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>{row}</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onSelect={() => onOpen(group)}>Open Group Chat</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onSelect={() =>
|
||||
onDisband({
|
||||
name: group,
|
||||
members
|
||||
})
|
||||
}
|
||||
>
|
||||
{b.group.deleteAction}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* The window-local state Bot Mode's surfaces share: which roster row is
|
||||
* selected, which owner's chat is on screen, and the per-bot activity
|
||||
* watermarks the unread poll compares against.
|
||||
*
|
||||
* A leaf by design. The roster, the routines tile, the create dialog and the
|
||||
* delete path all read and write this, and it reads none of them — so no
|
||||
* surface has to import a sibling surface to know what is selected.
|
||||
*/
|
||||
|
||||
import { atom, host } from '@hermes/plugin-sdk'
|
||||
|
||||
import { botRosterKey, botSelectionKey } from './data'
|
||||
import { getPluginCtx } from './shared'
|
||||
import type { RosterRow } from './types'
|
||||
|
||||
// last_active watermark per source-qualified bot, seeded on first poll so a
|
||||
// fresh mount doesn't mark ancient history unread.
|
||||
export const rosterWatermarks = new Map<string, number>()
|
||||
|
||||
// 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). 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. */
|
||||
export const $selectedBot = atom('default')
|
||||
|
||||
/** Owner of the chat the user is LOOKING AT. Newer desktops expose a
|
||||
* connection-qualified owner. Older builds synthesize the previous
|
||||
* profile/gateway fallback and listen to both atoms when available. */
|
||||
/** Source-qualified Bot Mode selection. Restoring it is presentation-only:
|
||||
* it never activates a gateway or creates a session. */
|
||||
export const $selectedRosterKey = atom('')
|
||||
export const $selectedRosterHydrated = atom(false)
|
||||
export const $rosterHydrated = atom(false)
|
||||
/** Mirrors host.paneVisibility('hermes-bots:pane') — wired in register(). */
|
||||
export const $botsPaneVisible = atom(false)
|
||||
/** An explicit open landed: {key, openedRegistryId}. This transient view
|
||||
* observation is empty only for the legacy newChat draft fallback. */
|
||||
export const $openBotChat = atom<{ key: string; openedRegistryId: string; openedSessionId?: string } | null>(null)
|
||||
/** A session owns the main workspace. The roster highlight and the Cronjobs
|
||||
* lifecycle both key off this rather than reading host.state conditionally
|
||||
* from render. */
|
||||
export const $botChatFocused = atom(false)
|
||||
export function saveSelectedRosterBot(bot: RosterRow) {
|
||||
const key = botRosterKey(bot)
|
||||
$selectedBot.set(botSelectionKey(bot))
|
||||
$selectedRosterKey.set(key)
|
||||
try {
|
||||
Promise.resolve(getPluginCtx()?.storage?.set?.('selected-roster-bot-v1', key)).catch(() => undefined)
|
||||
} catch {
|
||||
/* storage unavailable — selection lasts for this window */
|
||||
}
|
||||
}
|
||||
export function clearSelectedRosterBot(bot: RosterRow) {
|
||||
clearSelectedRosterKey(botRosterKey(bot))
|
||||
}
|
||||
|
||||
/** Drop the persisted selection when it is exactly this key — the caller has
|
||||
* proven the owner is gone, not merely unreachable. An unreachable source
|
||||
* KEEPS its key so the selection reconciles when the gateway returns. */
|
||||
export function clearSelectedRosterKey(key: string) {
|
||||
if ($selectedRosterKey.get() !== key) {
|
||||
return
|
||||
}
|
||||
$selectedRosterKey.set('')
|
||||
try {
|
||||
Promise.resolve(getPluginCtx()?.storage?.set?.('selected-roster-bot-v1', '')).catch(() => undefined)
|
||||
} catch {
|
||||
/* storage unavailable — selection is cleared for this window */
|
||||
}
|
||||
}
|
||||
|
||||
/** Split a roster key back into its owner parts. Profile names cannot contain
|
||||
* ':' (NAME_RE), so the first '::' is unambiguous. */
|
||||
export function parseRosterKey(key: null | string | undefined) {
|
||||
const raw = String(key || '')
|
||||
const at = raw.indexOf('::')
|
||||
if (at < 0) {
|
||||
return {
|
||||
connectionId: '',
|
||||
name: ''
|
||||
}
|
||||
}
|
||||
return {
|
||||
connectionId: raw.slice(0, at),
|
||||
name: raw.slice(at + 2)
|
||||
}
|
||||
}
|
||||
const $focusedBotProfile = host.state.focusedSessionProfile || host.state.profile
|
||||
|
||||
/** Profile that owns the chat currently on screen. Bot Mode opens another
|
||||
* profile's session without moving the gateway socket, so mention filtering
|
||||
* and sender identity must follow focus rather than host.state.profile. */
|
||||
export function focusedMentionProfile() {
|
||||
return String($focusedBotProfile.get?.() || '').trim() || 'default'
|
||||
}
|
||||
function fallbackFocusedBotOwner(profile: string = $focusedBotProfile.get?.()) {
|
||||
const focusedProfile = String(profile || 'default').trim() || 'default'
|
||||
const activeProfile = String(host.state.profile?.get?.() || 'default').trim() || 'default'
|
||||
|
||||
// focusedSessionProfile without focusedSessionOwner is a legacy half-shape:
|
||||
// it carries no source identity. Only reuse the active connection when the
|
||||
// focused profile is also the active profile; otherwise fail closed rather
|
||||
// than manufacturing a cross-source owner from unrelated atoms.
|
||||
if (host.state.focusedSessionProfile && focusedProfile !== activeProfile) {
|
||||
return null
|
||||
}
|
||||
const connectionId = String(
|
||||
host.state.connectionId?.get?.() ||
|
||||
(typeof host.activeConnectionId === 'function' ? host.activeConnectionId() : '') ||
|
||||
''
|
||||
).trim()
|
||||
return {
|
||||
authoritative: false,
|
||||
connectionId,
|
||||
profile: focusedProfile
|
||||
}
|
||||
}
|
||||
export const $focusedBotOwner = host.state.focusedSessionOwner || {
|
||||
get: () => fallbackFocusedBotOwner(),
|
||||
listen: (listener: (value: ReturnType<typeof fallbackFocusedBotOwner>) => void) => {
|
||||
const emit = (profile: string) => listener(fallbackFocusedBotOwner(profile))
|
||||
const unbindProfile = $focusedBotProfile.listen(emit)
|
||||
const unbindConnection = host.state.connectionId?.listen?.(() => emit($focusedBotProfile.get?.()))
|
||||
return () => {
|
||||
unbindProfile?.()
|
||||
unbindConnection?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
export function focusedRosterOwner(
|
||||
owner: {
|
||||
authoritative?: boolean
|
||||
connectionId?: string
|
||||
name?: string
|
||||
profile?: string
|
||||
} | null
|
||||
) {
|
||||
// TODO(bot-mode-types): `owner.name` cannot exist. Every caller passes
|
||||
// $focusedBotOwner, whose two shapes (host.state.focusedSessionOwner and
|
||||
// fallbackFocusedBotOwner) both key the profile as `profile`, so the
|
||||
// `owner?.name` arm is unreachable and a name-only owner would be dropped.
|
||||
const name = String(owner?.profile || owner?.name || '').trim()
|
||||
if (!owner || !name) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
authoritative: owner.authoritative !== false,
|
||||
connectionId: String(owner.connectionId || '').trim(),
|
||||
name
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,494 @@
|
||||
/**
|
||||
* The canonical Bot Chat: one bot, one forever-chat, resolved by exact title.
|
||||
*
|
||||
* Read the Bot Mode section of the repo root AGENTS.md before touching any of
|
||||
* this — the identity contract below is settled and has been regressed
|
||||
* repeatedly. The click-path orchestration that drives these lives in
|
||||
* plugin.tsx, which owns the workspace and group state a bot open competes
|
||||
* with.
|
||||
*/
|
||||
|
||||
import * as sdk from '@hermes/plugin-sdk'
|
||||
import { host } from '@hermes/plugin-sdk'
|
||||
|
||||
import { $botMeta, botMetaKey, botOwner, persistBotMetaSnapshot } from './data'
|
||||
import { backendTargetProfile, botConnectionRoute, botRosterMeta, botWorkspaceOwnerKey, requestForBot } from './routing'
|
||||
import type { RpcErrorLike } from './routing'
|
||||
import { getPluginCtx } from './shared'
|
||||
import type { BotMeta, CanonicalSession, RosterRow } from './types'
|
||||
|
||||
// ── canonical bot chat ───────────────────────────────────────────────────────
|
||||
// Each bot has ONE forever chat, identified by NAME, never by pointer: the
|
||||
// session titled exactly "Bot Chat" on that bot's profile. The core
|
||||
// UNIQUE(title) index makes (profile, "Bot Chat") an exact registry, so every
|
||||
// open consults that registry directly — there is nothing to verify, re-pin,
|
||||
// grandfather, or recover. Stored-id pins (ui_meta['hermes-bots'].chat) were
|
||||
// the previous identity and are REMOVED: every lost-chat incident traced to a
|
||||
// dangled or stolen pointer that later guards then welded in. Legacy
|
||||
// ui_meta.chat keys are simply ignored.
|
||||
|
||||
interface CanonicalCreation {
|
||||
/** Null only between the holder literal and the assignment two statements
|
||||
* later; a flight is published to the map already carrying its promise. */
|
||||
run: null | Promise<null | string>
|
||||
}
|
||||
|
||||
// In-flight creations, keyed by bot name — double-clicking a row must not
|
||||
// mint two canonical chats.
|
||||
const canonicalCreations = new Map<string, CanonicalCreation>()
|
||||
|
||||
/** Upper bound for per-profile session.list scans (hide sweep, canonical-chat
|
||||
* adoption, stored-session lookups). */
|
||||
export const PROFILE_SESSION_LIST_LIMIT = 200
|
||||
|
||||
/** The one canonical title. (profile, CANONICAL_CHAT_TITLE) IS the bot's
|
||||
* forever-chat identity — see the header above. */
|
||||
const CANONICAL_CHAT_TITLE = 'Bot Chat'
|
||||
|
||||
/** A `session.list` row as the registry lookup reads it. CanonicalSession
|
||||
* models the roster's `canonical_session` field, which carries no
|
||||
* `message_count` — the listing row does. `readonly` because the count is
|
||||
* read through an aliased guard, which TS only narrows for immutable
|
||||
* properties. */
|
||||
interface CanonicalChatRow extends CanonicalSession {
|
||||
readonly message_count?: number
|
||||
}
|
||||
async function openStoredBotChat(
|
||||
owner: RosterRow | string,
|
||||
storedId: string,
|
||||
summary: CanonicalChatRow
|
||||
): Promise<string> {
|
||||
if (!storedId || typeof host.openSession !== 'function') {
|
||||
throw new Error('This Hermes Desktop version cannot open stored sessions')
|
||||
}
|
||||
const { bot, name, route } = botOwner(owner)
|
||||
const ownerKey = botWorkspaceOwnerKey(bot)
|
||||
const hasAuthoritativeCount = typeof summary?.message_count === 'number' && Number.isFinite(summary.message_count)
|
||||
const expectHistory = hasAuthoritativeCount ? summary.message_count > 0 : true
|
||||
// Current SDKs export the Bot-specific budget. The fallback preserves
|
||||
// compatibility with older hosts and isolated plugin test harnesses.
|
||||
const hydrationTimeoutMs =
|
||||
typeof sdk !== 'undefined' && Number.isFinite(sdk.BOT_CHAT_SESSION_HYDRATION_TIMEOUT_MS)
|
||||
? sdk.BOT_CHAT_SESSION_HYDRATION_TIMEOUT_MS
|
||||
: 60_000
|
||||
|
||||
// A profile backend that just woke up can lose the hydration-timeout race
|
||||
// even though the session is fine (hermes-agent#89617) — clicking Retry
|
||||
// succeeds because the backend is warm by then. retryHydrationTimeoutOnce
|
||||
// asks the SDK layer to retry that same wait internally, BEFORE it arms the
|
||||
// core stranded-session overlay: a plugin-side retry can't do this because
|
||||
// only host.openSession sees the resume-exhausted latch that overlay reads.
|
||||
//
|
||||
// forceResume: an explicit bot switch must never trust a cached transcript.
|
||||
// The SDK's surface-health check passes whenever ANY non-empty transcript is
|
||||
// painted, including a stale snapshot the session-states cache kept from the
|
||||
// previous time this bot was open — which left the pane showing old messages
|
||||
// until an app restart (hermes-agent#93604). A resume is cheap and
|
||||
// idempotent, so on this explicit user navigation we always request one.
|
||||
await host.openSession(storedId, {
|
||||
...(route
|
||||
? {
|
||||
route
|
||||
}
|
||||
: {}),
|
||||
profile: name,
|
||||
// Same intent a session row click uses. `tab` stacked a fresh tile every
|
||||
// time focusOpenSession missed, so bot chats piled up beside each other and
|
||||
// beside the untouched "New session" draft, which then kept focus —
|
||||
// clicking a bot appeared to do nothing. `in-place` still fronts an
|
||||
// already-open tile first, so Bot tabs survive owner lifecycles (#a81854a2,
|
||||
// the reason this stopped being `main`); it just loads into main instead of
|
||||
// minting a second tab when there is nothing to front.
|
||||
intent: 'in-place',
|
||||
awaitHydration: true,
|
||||
expectHistory,
|
||||
forceResume: true,
|
||||
hydrationTimeoutMs,
|
||||
keepAllProfilesScope: true,
|
||||
workspaceMode: 'bots',
|
||||
workspaceOwnerKey: ownerKey,
|
||||
retryHydrationTimeoutOnce: true,
|
||||
tabTitle: CANONICAL_CHAT_TITLE
|
||||
})
|
||||
return storedId
|
||||
}
|
||||
|
||||
/** True when a session summary IS the canonical registry row. root_title is
|
||||
* the durable lineage-root title reported by exact-lookup gateways; plain
|
||||
* title covers windowed listings. */
|
||||
function isCanonicalBotChatHistory(history: CanonicalChatRow) {
|
||||
const rootTitle = String(history?.root_title || '').trim()
|
||||
const title = String(history?.title || '').trim()
|
||||
return rootTitle === CANONICAL_CHAT_TITLE || (!rootTitle && title === CANONICAL_CHAT_TITLE)
|
||||
}
|
||||
function botModeGatewayNeedsUpdate(error: unknown) {
|
||||
const message = String((error as RpcErrorLike)?.message || error || '')
|
||||
return /(?:method not found|no handler for|unknown method|unsupported rpc)/i.test(message)
|
||||
}
|
||||
export function notifyBotOpenFailure(error: unknown, bot: RosterRow, fallbackMessage: string) {
|
||||
if (botModeGatewayNeedsUpdate(error)) {
|
||||
const gateway = bot.connectionLabel || bot.connectionId || 'this gateway'
|
||||
host.notify?.({
|
||||
kind: 'error',
|
||||
title: 'Update this gateway to use Bot Mode',
|
||||
message: `Update ${gateway}, then try again.`
|
||||
})
|
||||
return
|
||||
}
|
||||
host.notifyError?.(error, fallbackMessage)
|
||||
}
|
||||
|
||||
/** THE identity lookup: the profile's session titled exactly "Bot Chat",
|
||||
* consulted on the bot's OWN source. The core UNIQUE title index guarantees
|
||||
* at most ONE such row per profile db — Profile → Named Session is an exact
|
||||
* registry, so consult it exactly: `title` asks the gateway for an indexed
|
||||
* WHERE title = ? lookup (window-free; a busy profile can push the
|
||||
* forever-chat past any recency window). include_hidden is required
|
||||
* (canonical chats are always hidden). Remote bots route via requestForBot
|
||||
* on the immutable captured owner — activation is a UI concern and never
|
||||
* authorizes this RPC. */
|
||||
async function findExistingCanonicalChat(owner: RosterRow | string): Promise<CanonicalChatRow | null> {
|
||||
const { bot, name, route } = botOwner(owner)
|
||||
// FAIL CLOSED. A failed registry lookup MUST NOT read as "no Bot Chat
|
||||
// exists" — that is the one remaining way to fork a bot's forever chat.
|
||||
// The failure lives exactly in the post-update window: the desktop
|
||||
// restarts every profile backend, the first bot click races the warm-up,
|
||||
// the lookup RPC fails transiently, and a swallowed error here sent
|
||||
// createCanonicalChat() straight to session.create — minting a fresh
|
||||
// "Bot Chat" while the real one (data intact, hidden) still held the
|
||||
// canonical title. Users read that as "my bot lost all context after the
|
||||
// update". Cross-connection lookups fail MORE often (network), so this
|
||||
// matters doubly for remote bots. Both open paths catch and toast "try
|
||||
// again", which is the correct outcome: retry, never mint.
|
||||
let res: { sessions?: CanonicalChatRow[] }
|
||||
try {
|
||||
res = await requestForBot<{ sessions?: CanonicalChatRow[] }>(bot, 'session.list', {
|
||||
profile: backendTargetProfile(route, name),
|
||||
title: CANONICAL_CHAT_TITLE,
|
||||
limit: PROFILE_SESSION_LIST_LIMIT,
|
||||
include_hidden: true
|
||||
})
|
||||
} catch (error) {
|
||||
// Plugin tests and host bridges can return Error-like values from another
|
||||
// JS realm, where `instanceof Error` is false. Preserve the provider/RPC
|
||||
// message so update-required classification and diagnostics still work.
|
||||
const message = typeof (error as RpcErrorLike)?.message === 'string' ? (error as { message: string }).message : ''
|
||||
const detail = message ? ` (${message})` : ''
|
||||
throw new Error(`Could not check ${name}'s Bot Chat registry${detail} — not starting a new chat`)
|
||||
}
|
||||
const rows = res?.sessions ?? []
|
||||
return rows.find(row => isCanonicalBotChatHistory(row)) || null
|
||||
}
|
||||
|
||||
interface CreateCanonicalChatOptions {
|
||||
kickoff?: boolean
|
||||
openingStillCurrent?: (() => boolean) | null
|
||||
}
|
||||
|
||||
/** Create the bot's ONE forever chat: a real session titled "Bot Chat".
|
||||
* Adopts the existing "Bot Chat" row instead of creating when the profile
|
||||
* already has one — minting while a "Bot Chat" row exists is always wrong
|
||||
* twice over: it forks the forever-chat AND the new row can never take the
|
||||
* (already held) canonical title. Creates on the bot's own source via
|
||||
* requestForBot.
|
||||
*
|
||||
* `kickoff` (New Agent creation ONLY): submit the self-introduction prompt
|
||||
* so a brand-new bot greets its owner once. Every other caller — the bot
|
||||
* row's click-path canonical resolution above all — must NOT pass it: a
|
||||
* resolution miss (retitled row, hidden-listing gap, post-update skew)
|
||||
* re-mints the session, and re-firing the intro there burned a model turn
|
||||
* and stamped a user-attributed "Hey, tell me about yourself!" into the
|
||||
* chat on every click (ScottFive report). The kickoff's original session-
|
||||
* persistence job is done by the eager session.title write below on modern
|
||||
* gateways; older gateways that reject the eager write keep a narrow
|
||||
* compat kickoff, else the pruner reaps the empty lazy session and the
|
||||
* chat never survives its own creation.
|
||||
*
|
||||
* `openingStillCurrent` (click-path opens): a staleness probe consulted
|
||||
* before every navigation — when the user has already moved on (opened a
|
||||
* group, clicked another bot), the create still completes registry-side
|
||||
* but never steals the workspace (#89834 family). */
|
||||
export function createCanonicalChat(
|
||||
owner: RosterRow | string,
|
||||
{ kickoff = false, openingStillCurrent = null }: CreateCanonicalChatOptions = {}
|
||||
): Promise<null | string> {
|
||||
const { bot, name, key, route } = botOwner(owner)
|
||||
const inflight = canonicalCreations.get(key)
|
||||
if (inflight) {
|
||||
if (!openingStillCurrent) {
|
||||
return inflight.run!
|
||||
}
|
||||
return inflight.run!.then(async sid => {
|
||||
if (sid && openingStillCurrent() && typeof host.openSession === 'function') {
|
||||
await host.openSession(sid, {
|
||||
...(route
|
||||
? {
|
||||
route
|
||||
}
|
||||
: {}),
|
||||
profile: name,
|
||||
intent: 'main',
|
||||
keepAllProfilesScope: route ? true : false,
|
||||
workspaceMode: 'bots',
|
||||
workspaceOwnerKey: botWorkspaceOwnerKey(bot),
|
||||
tabTitle: CANONICAL_CHAT_TITLE
|
||||
})
|
||||
}
|
||||
return sid
|
||||
})
|
||||
}
|
||||
const flight: CanonicalCreation = {
|
||||
run: null
|
||||
}
|
||||
const canNavigate = () => !openingStillCurrent || openingStillCurrent()
|
||||
const run = (async () => {
|
||||
const existing = await findExistingCanonicalChat(owner)
|
||||
if (existing?.id) {
|
||||
if (typeof host.openSession === 'function' && canNavigate()) {
|
||||
// The exact-lookup gateway reports the compression-lineage tip as
|
||||
// resolved_id; open the tip, the registry row stays the identity.
|
||||
await openStoredBotChat(owner, existing.resolved_id || existing.id, existing)
|
||||
}
|
||||
return existing.id
|
||||
}
|
||||
const res = await requestForBot<{ session_id?: string; stored_session_id?: string }>(bot, 'session.create', {
|
||||
profile: backendTargetProfile(route, name),
|
||||
title: CANONICAL_CHAT_TITLE,
|
||||
// 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
|
||||
|
||||
// session.create is intentionally lazy: its stored row does not exist until
|
||||
// the first prompt. Mounting `sid` immediately therefore emits a noisy REST
|
||||
// 404 ("Session not found"), and the turn-start auto-titler can win the race
|
||||
// against the deferred `title: 'Bot Chat'` — under name-identity that is an
|
||||
// identity outage: until the row is titled, the registry has no "Bot Chat"
|
||||
// entry, so a second click during the intro turn mints a duplicate.
|
||||
// session.title materializes the row now and records a user-authority title
|
||||
// before either the open or kickoff, closing both the 404 race and the
|
||||
// untitled window. Older gateways may not support the eager write; retain
|
||||
// the kickoff-and-retry fallback below.
|
||||
let titled = false
|
||||
if (runtime) {
|
||||
try {
|
||||
await requestForBot(bot, 'session.title', {
|
||||
session_id: runtime,
|
||||
title: CANONICAL_CHAT_TITLE
|
||||
})
|
||||
titled = true
|
||||
} catch (error) {
|
||||
// ADOPT-BEFORE-MINT: a title-uniqueness rejection is not an old
|
||||
// gateway — it means another writer took the canonical title between
|
||||
// our registry miss and this write (peer dm minting server-side, a
|
||||
// second machine, cross-connection sync). Falling through to the
|
||||
// compat path would prompt into OUR stray session and fork the
|
||||
// forever chat. Re-consult the registry and adopt the winner; the
|
||||
// stray lazy session holds zero messages and is simply abandoned
|
||||
// (the gateway prunes it).
|
||||
if (/already in use/i.test(String((error as RpcErrorLike)?.message || ''))) {
|
||||
const winner = await findExistingCanonicalChat(owner)
|
||||
if (winner?.id) {
|
||||
if (typeof host.openSession === 'function') {
|
||||
await openStoredBotChat(owner, winner.resolved_id || winner.id, winner)
|
||||
}
|
||||
return winner.id
|
||||
}
|
||||
}
|
||||
/* compatibility fallback: prompt.submit will persist the lazy row */
|
||||
}
|
||||
}
|
||||
|
||||
// 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' && canNavigate()) {
|
||||
try {
|
||||
await host.openSession(sid, {
|
||||
...(route
|
||||
? {
|
||||
route
|
||||
}
|
||||
: {}),
|
||||
profile: name,
|
||||
intent: 'main',
|
||||
keepAllProfilesScope: route ? true : false,
|
||||
...(openingStillCurrent
|
||||
? {
|
||||
workspaceMode: 'bots',
|
||||
workspaceOwnerKey: botWorkspaceOwnerKey(bot),
|
||||
tabTitle: CANONICAL_CHAT_TITLE
|
||||
}
|
||||
: {})
|
||||
})
|
||||
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) {
|
||||
// Intro turn: only on genuine New Agent creation (`kickoff`), or as the
|
||||
// COMPAT persistence write when the eager title failed — an old gateway
|
||||
// prunes the zero-message lazy session, so without some first prompt
|
||||
// the chat never survives its own creation. A titled row needs neither:
|
||||
// the user speaks first.
|
||||
const submitIntro = kickoff || !titled
|
||||
if (submitIntro) {
|
||||
await new Promise(resolve => window.setTimeout(resolve, 400))
|
||||
try {
|
||||
await requestForBot(bot, 'prompt.submit', {
|
||||
session_id: runtime,
|
||||
text: 'Hey, tell me about yourself!'
|
||||
})
|
||||
if (!opened && sid && typeof host.openSession === 'function' && canNavigate()) {
|
||||
await host.openSession(sid, {
|
||||
...(route
|
||||
? {
|
||||
route
|
||||
}
|
||||
: {}),
|
||||
profile: name,
|
||||
intent: 'main',
|
||||
keepAllProfilesScope: route ? true : false,
|
||||
...(openingStillCurrent
|
||||
? {
|
||||
workspaceMode: 'bots',
|
||||
workspaceOwnerKey: botWorkspaceOwnerKey(bot),
|
||||
tabTitle: CANONICAL_CHAT_TITLE
|
||||
}
|
||||
: {})
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// The chat already exists under the canonical title — the next click
|
||||
// finds it by name instead of making a second Bot Chat.
|
||||
}
|
||||
} else if (!opened && sid && typeof host.openSession === 'function' && canNavigate()) {
|
||||
// No intro turn: still finish mounting the chat when the first open
|
||||
// raced the (now titled) row.
|
||||
try {
|
||||
await host.openSession(sid, {
|
||||
...(route
|
||||
? {
|
||||
route
|
||||
}
|
||||
: {}),
|
||||
profile: name,
|
||||
intent: 'main',
|
||||
keepAllProfilesScope: route ? true : false,
|
||||
...(openingStillCurrent
|
||||
? {
|
||||
workspaceMode: 'bots',
|
||||
workspaceOwnerKey: botWorkspaceOwnerKey(bot),
|
||||
tabTitle: CANONICAL_CHAT_TITLE
|
||||
}
|
||||
: {})
|
||||
})
|
||||
} catch {
|
||||
/* row is titled and persistent — the next click opens it by name */
|
||||
}
|
||||
}
|
||||
}
|
||||
return sid || null
|
||||
})().finally(() => {
|
||||
if (canonicalCreations.get(key) === flight) {
|
||||
canonicalCreations.delete(key)
|
||||
}
|
||||
})
|
||||
flight.run = run
|
||||
canonicalCreations.set(key, flight)
|
||||
return run
|
||||
}
|
||||
|
||||
/** Open the bot's ONE forever chat and return the opened registry id.
|
||||
*
|
||||
* The whole resolution is one registry consultation ON THE BOT'S OWN
|
||||
* SOURCE: the profile's session titled "Bot Chat" exists → open it
|
||||
* (lineage tip); it doesn't → create it. No id pointer is read or written
|
||||
* anywhere in this path — remote bots included. The owner route rides
|
||||
* every RPC (requestForBot) and the open (openStoredBotChat), so a remote
|
||||
* bot's chat opens without re-homing Desktop's chrome. */
|
||||
export async function openBotCanonicalChat(
|
||||
owner: RosterRow | string,
|
||||
openingStillCurrent: (() => boolean) | null = null
|
||||
): Promise<{ openedId: string; registryId: string } | null> {
|
||||
const existing = await findExistingCanonicalChat(owner)
|
||||
if (existing?.id && typeof host.openSession === 'function') {
|
||||
if (openingStillCurrent && !openingStillCurrent()) {
|
||||
return null
|
||||
}
|
||||
const openedId = existing.resolved_id || existing.id
|
||||
await openStoredBotChat(owner, openedId, existing)
|
||||
// Both identities matter downstream: the durable registry row names the
|
||||
// chat; the resolved lineage tip is what actually takes session focus.
|
||||
// Callers matching focus against only the registry id mistook every
|
||||
// compressed Bot Chat for a stale open (first click bounced to the home).
|
||||
return {
|
||||
registryId: String(existing.id),
|
||||
openedId: String(openedId)
|
||||
}
|
||||
}
|
||||
const created = await createCanonicalChat(owner, {
|
||||
openingStillCurrent
|
||||
})
|
||||
return created
|
||||
? {
|
||||
registryId: String(created),
|
||||
openedId: String(created)
|
||||
}
|
||||
: null
|
||||
}
|
||||
export async function prepareBotSource(bot: RosterRow) {
|
||||
if (!bot.sourceScoped) {
|
||||
return
|
||||
}
|
||||
|
||||
// Cross-connection RPCs ride the immutable captured route (requestForBot →
|
||||
// host.requestProfile) — Desktop's active connection does not move, and
|
||||
// activation is a UI concern that never authorizes the calls. All this
|
||||
// gate does is refuse when the desktop predates routed profile requests.
|
||||
const route = botConnectionRoute(bot)
|
||||
if (route && typeof host.requestProfile !== 'function') {
|
||||
throw new Error(
|
||||
getPluginCtx()?.i18n.t('bot.remoteConnectionsUnsupported') ??
|
||||
'Update Hermes Desktop to chat with bots on other connections.'
|
||||
)
|
||||
}
|
||||
if (!route && typeof host.ensureAgent === 'function') {
|
||||
// Source-annotated row on the ACTIVE connection (no captured route):
|
||||
// legacy activation path, unchanged. An absent connectionId is fine —
|
||||
// ensureGatewayAgent normalizes it with `(connectionId ?? '').trim() || null`.
|
||||
await host.ensureAgent(bot.connectionId, bot.name)
|
||||
}
|
||||
}
|
||||
export async function ensureBotMetadata(bot: RosterRow): Promise<BotMeta> {
|
||||
if (!bot?.sourceScoped) {
|
||||
return botRosterMeta(bot, $botMeta.get()) || {}
|
||||
}
|
||||
const route = botConnectionRoute(bot)
|
||||
const backendProfile = backendTargetProfile(route, bot.name)
|
||||
const result = await requestForBot<{ profiles?: Array<Pick<RosterRow, 'name' | 'ui_meta'>> }>(
|
||||
bot,
|
||||
'profiles.list',
|
||||
{}
|
||||
)
|
||||
const row = (result?.profiles || []).find(profile => profile?.name === backendProfile)
|
||||
const server = row?.ui_meta?.['hermes-bots']
|
||||
if (server && typeof server === 'object') {
|
||||
const key = botMetaKey(bot)
|
||||
$botMeta.set({
|
||||
...$botMeta.get(),
|
||||
[key]: {
|
||||
...($botMeta.get()[key] || {}),
|
||||
...server
|
||||
}
|
||||
})
|
||||
persistBotMetaSnapshot($botMeta.get(), true)
|
||||
}
|
||||
return botRosterMeta(bot, $botMeta.get()) || {}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* The one presentation leaf every Bot Mode dialog shares: a labelled control.
|
||||
*
|
||||
* It sits below the dialogs rather than inside any one of them — the model
|
||||
* picker, the advanced editor, Edit Profile, New Bot and the routines dialogs
|
||||
* all render the same label-over-control pair, and none of them can own it
|
||||
* without the others importing a sibling surface.
|
||||
*/
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
export function labeled(label: ReactNode, control: ReactNode) {
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
<label className="text-xs font-medium text-(--ui-text-secondary)">{label}</label>
|
||||
{control}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* Edit Profile: appearance, title, description, and the advanced profile
|
||||
* config disclosure for an existing bot.
|
||||
*/
|
||||
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DisclosureCaret,
|
||||
host,
|
||||
Input,
|
||||
queryClient,
|
||||
Textarea,
|
||||
useI18n,
|
||||
useValue
|
||||
} from '@hermes/plugin-sdk'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { avatarColor, botAppearance, BotFace } from './avatar'
|
||||
import { AvatarPicker } from './avatar-picker'
|
||||
import { $botMeta, botSelectionKey, ROSTER_KEY, saveBotMeta } from './data'
|
||||
import { labeled } from './dialog-parts'
|
||||
import { useBots } from './i18n'
|
||||
import { displayName } from './labels'
|
||||
import { AdvancedProfileConfig, applyAdvancedConfig, emptyAdvancedState } from './profile-config'
|
||||
import { botRosterMeta, requestForBot } from './routing'
|
||||
import type { AvatarAppearance, RosterRow } from './types'
|
||||
|
||||
// ── edit profile dialog ──────────────────────────────────────────────────────
|
||||
|
||||
/** AvatarAppearance, minus the `image` guarantee — the dialog's no-bot fallback
|
||||
* literal doesn't supply one. See the TODO in EditProfileDialog. */
|
||||
interface EditProfileAppearance extends Omit<AvatarAppearance, 'image'> {
|
||||
image?: null | string
|
||||
}
|
||||
interface EditProfileDialogProps {
|
||||
bot: null | RosterRow
|
||||
onClose: () => void
|
||||
open: boolean
|
||||
}
|
||||
export function EditProfileDialog({ bot, open, onClose }: EditProfileDialogProps) {
|
||||
const { t } = useI18n()
|
||||
const b = useBots()
|
||||
const metaAll = useValue($botMeta)
|
||||
const meta = bot ? botRosterMeta(bot, metaAll) : null
|
||||
// TODO(bot-mode-types): the no-bot fallback omits `image`, which the state
|
||||
// seeding below reads — `appearance.image` is undefined on that branch, so
|
||||
// this is NOT an AvatarAppearance. Harmless today only because the component
|
||||
// returns null a few lines down when `bot` is null, so the seeded value is
|
||||
// thrown away before anything renders.
|
||||
const appearance: EditProfileAppearance = bot
|
||||
? botAppearance(bot.name, meta)
|
||||
: {
|
||||
shape: 'circle',
|
||||
color: null
|
||||
}
|
||||
const [shape, setShape] = useState(appearance.shape)
|
||||
const [color, setColor] = useState<null | string>(appearance.color)
|
||||
const [image, setImage] = useState<null | string>(appearance.image ?? null)
|
||||
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 | string>(null)
|
||||
const currentKey = bot ? `${botSelectionKey(bot)}:${open}` : null
|
||||
if (currentKey !== seedKey) {
|
||||
setSeedKey(currentKey)
|
||||
if (bot && open) {
|
||||
setShape(appearance.shape)
|
||||
setColor(appearance.color)
|
||||
setImage(appearance.image ?? null)
|
||||
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, {
|
||||
shape,
|
||||
color: color ?? undefined,
|
||||
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: b.avatar.savedLocally
|
||||
})
|
||||
}
|
||||
if (persistence.serverOutcome === 'persisted') {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ROSTER_KEY
|
||||
})
|
||||
}
|
||||
const desc = description.trim()
|
||||
if (desc !== (bot.description || '').trim()) {
|
||||
try {
|
||||
await requestForBot(bot, 'cli.exec', {
|
||||
argv: ['profile', 'describe', bot.name, '--text', desc]
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ROSTER_KEY
|
||||
})
|
||||
} catch (err) {
|
||||
host.notifyError(err, b.avatar.savedLocallyDescriptionFailed)
|
||||
}
|
||||
}
|
||||
if (adv.loaded && (adv.dirtyModel || adv.dirtySoul || adv.dirtySkills || adv.dirtyToolsets || adv.dirtyMcp)) {
|
||||
try {
|
||||
const res = await applyAdvancedConfig(bot, 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, b.bot.advancedFailed)
|
||||
}
|
||||
}
|
||||
if (!advancedFailed && !lookFailed) {
|
||||
host.notify({
|
||||
kind: 'success',
|
||||
message: `${displayName(bot, {
|
||||
title
|
||||
})} updated`
|
||||
})
|
||||
}
|
||||
setBusy(false)
|
||||
onClose()
|
||||
}
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={value => !value && !busy && onClose()}>
|
||||
<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
|
||||
}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{b.bot.editTitle}</DialogTitle>
|
||||
<DialogDescription>{`Appearance and role for ${displayName(bot, null)} (${bot.name}).`}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4">
|
||||
<div className="flex justify-center py-1">
|
||||
<BotFace shape={shape} color={avatarColor(color, bot.name)} image={image} size={64} name={bot.name} />
|
||||
</div>
|
||||
<AvatarPicker
|
||||
shape={shape}
|
||||
color={color}
|
||||
image={image}
|
||||
onShape={setShape}
|
||||
onColor={setColor}
|
||||
onImage={setImage}
|
||||
generateSeed={{
|
||||
name: bot.name,
|
||||
title,
|
||||
description
|
||||
}}
|
||||
/>
|
||||
{labeled(
|
||||
'Title',
|
||||
<Input
|
||||
placeholder={displayName(bot, null)}
|
||||
value={title}
|
||||
onChange={event => setTitle(event.target.value)}
|
||||
/>
|
||||
)}
|
||||
{labeled(
|
||||
'Description',
|
||||
<Textarea
|
||||
className="min-h-16"
|
||||
placeholder={b.bot.helpPromptPlaceholder}
|
||||
value={description}
|
||||
onChange={event => setDescription(event.target.value)}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
className="flex items-center gap-1 text-xs font-medium text-(--ui-text-tertiary) hover:text-(--ui-text-secondary)"
|
||||
onClick={() => setAdvanced(v => !v)}
|
||||
size="inline"
|
||||
variant="text"
|
||||
>
|
||||
<DisclosureCaret open={advanced} />
|
||||
{b.bot.advancedHint}
|
||||
</Button>
|
||||
{advanced ? (
|
||||
<div className="rounded-md border border-(--ui-stroke-secondary) p-3">
|
||||
<AdvancedProfileConfig bot={bot} state={adv} setState={setAdv} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" disabled={busy} onClick={onClose}>
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button disabled={busy} onClick={submit}>
|
||||
{busy ? t.common.saving : t.common.save}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* The per-room activity feed: a bounded, runtime-only record of turn events
|
||||
* for the room view's collapsible Activity list.
|
||||
*
|
||||
* Depends on the room store (for the epoch it tags events with and the
|
||||
* speaker label it renders) and on nothing else, so the coordination engine
|
||||
* can record into it without a cycle.
|
||||
*/
|
||||
|
||||
import { atom } from '@hermes/plugin-sdk'
|
||||
|
||||
import { $groupChats, groupSpeakerLabel } from './group-chat'
|
||||
import type { GroupActivityEvent, GroupActivityKind } from './types'
|
||||
|
||||
// ── group activity feed ─────────────────────────────────────────────────────
|
||||
// Runtime-only, bounded per-room record of turn events that feeds the
|
||||
// collapsible Activity view. Never persisted — it is presentation state like
|
||||
// running/epoch, and the room transcript (log) stays the only durable record.
|
||||
// Every event is tagged with the room epoch it belongs to, so the view shows
|
||||
// only the CURRENT run: a newer send bumps the epoch (old-run events drop
|
||||
// away), and a rename re-keys the room (the feed starts clean under the new
|
||||
// name — stale events under the old key simply have no room to attach to).
|
||||
const GROUP_ACTIVITY_LIMIT = 50
|
||||
|
||||
/** A recorded activity row: the caller's event tagged with the room epoch.
|
||||
* Deliberately not `GroupActivityEvent` — the recorder never stamps `group`
|
||||
* (the atom is already keyed by it) and callers carry a `thread`. */
|
||||
export interface GroupActivityEntry extends Omit<GroupActivityEvent, 'group' | 'member'> {
|
||||
epoch: number
|
||||
member?: null | string
|
||||
thread?: null | string
|
||||
}
|
||||
export const $groupActivity = atom<Record<string, { events: GroupActivityEntry[] }>>({})
|
||||
export function recordGroupActivity(group: string, event: Omit<GroupActivityEntry, 'at' | 'epoch'>) {
|
||||
const room = $groupChats.get()[group]
|
||||
if (!room) {
|
||||
return null
|
||||
}
|
||||
const current = $groupActivity.get()[group] || {
|
||||
events: []
|
||||
}
|
||||
const entry = {
|
||||
at: Date.now(),
|
||||
epoch: room.epoch || 0,
|
||||
...event
|
||||
}
|
||||
const events = [...current.events, entry].slice(-GROUP_ACTIVITY_LIMIT)
|
||||
$groupActivity.set({
|
||||
...$groupActivity.get(),
|
||||
[group]: {
|
||||
...current,
|
||||
events
|
||||
}
|
||||
})
|
||||
return entry
|
||||
}
|
||||
|
||||
/** Events for the room's CURRENT run — superseded runs (epoch moved on)
|
||||
* are dropped from view instead of describing work that already ended. */
|
||||
export function currentGroupActivity(group: string) {
|
||||
const epoch = ($groupChats.get()[group] || {}).epoch || 0
|
||||
return ($groupActivity.get()[group] || {}).events?.filter(event => (event.epoch || 0) === epoch) || []
|
||||
}
|
||||
|
||||
/** Human label for one activity event, used by the collapsed summary and
|
||||
* the expanded rows. */
|
||||
export function groupActivityLabel(event: GroupActivityEntry) {
|
||||
const kind = event?.kind
|
||||
const base = GROUP_ACTIVITY_LABELS[kind] || kind || 'did something'
|
||||
if (kind === 'cancelled' || kind === 'settled' || kind === 'capped') {
|
||||
return base
|
||||
}
|
||||
const who = event?.member === 'You' ? 'You' : groupSpeakerLabel(event?.member || 'A bot')
|
||||
return `${who} ${base}`
|
||||
}
|
||||
const GROUP_ACTIVITY_LABELS: Record<GroupActivityKind, string> = {
|
||||
queued: 'sent a message',
|
||||
working: 'is working…',
|
||||
replied: 'replied',
|
||||
passed: 'passed',
|
||||
'timed-out': 'took too long',
|
||||
failed: 'hit an error',
|
||||
cancelled: 'turn interrupted by a newer message',
|
||||
settled: 'turn settled',
|
||||
capped: 'turn stopped at the round/message cap',
|
||||
delivered: 'delivered a late reply',
|
||||
held: 'is held (stopped by you) — @mention it or say resume to release',
|
||||
stopped: 'stopped the room — remaining turns are held until resumed'
|
||||
}
|
||||
export const GROUP_ACTIVITY_GLYPHS: Record<GroupActivityKind, string> = {
|
||||
queued: 'comment',
|
||||
working: 'sync',
|
||||
replied: 'check',
|
||||
passed: 'circle-outline',
|
||||
'timed-out': 'clock',
|
||||
failed: 'error',
|
||||
cancelled: 'close',
|
||||
settled: 'check-all',
|
||||
capped: 'debug-step-over',
|
||||
delivered: 'mail-read',
|
||||
held: 'debug-pause',
|
||||
stopped: 'debug-stop'
|
||||
}
|
||||
|
||||
/** Text tone for an activity row: quiet for pass/cancel/settle, accent for
|
||||
* work and real replies, destructive for failures and timeouts. */
|
||||
export function groupActivityTone(kind: GroupActivityKind) {
|
||||
if (kind === 'failed' || kind === 'timed-out') {
|
||||
return 'text-destructive'
|
||||
}
|
||||
if (kind === 'working' || kind === 'replied' || kind === 'delivered') {
|
||||
return 'text-(--ui-accent)'
|
||||
}
|
||||
return 'text-(--ui-text-tertiary)'
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Group-chat attachments: turning picked, pasted, or dropped files into the
|
||||
* data-URL payloads a room's members are shown.
|
||||
*
|
||||
* A leaf module — it talks to the DOM and the toast host, nothing else in Bot
|
||||
* Mode.
|
||||
*/
|
||||
|
||||
import { host } from '@hermes/plugin-sdk'
|
||||
|
||||
import type { Attachment, AttachmentKind } from './types'
|
||||
|
||||
// ── group-chat attachments: pick/paste/drop files the room's members see ────
|
||||
|
||||
/** Classify a picked file for the group-attachment pipeline. */
|
||||
function groupAttachmentKind(file: File): AttachmentKind {
|
||||
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. */
|
||||
export async function filesToGroupAttachments(files: File[] | FileList | null | undefined): Promise<Attachment[]> {
|
||||
const picked: Attachment[] = []
|
||||
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<null | string>(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 }]. */
|
||||
export function pickGroupAttachments(): Promise<Attachment[]> {
|
||||
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: string, maxEdge = 1568): Promise<string> {
|
||||
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
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,503 @@
|
||||
/**
|
||||
* The leaf components a group-chat room composes: the room picture controls,
|
||||
* the mention-aware composer input, and the card a member's pending
|
||||
* clarify/approval prompt renders as.
|
||||
*
|
||||
* None of them reaches back into the room surface, so they sit below
|
||||
* `group-chat-view.tsx` — and the group-creation dialog can share the picture
|
||||
* controls without either surface importing the other.
|
||||
*/
|
||||
|
||||
import { Button, cn, Codicon, host, Input, RowButton, Textarea, useValue } from '@hermes/plugin-sdk'
|
||||
import type { ClipboardEvent } from 'react'
|
||||
import { useRef, useState } from 'react'
|
||||
|
||||
import { $imagenAvailable, normalizeAvatarImage, pickImageFromDevice, probeImagen } from './avatar-image'
|
||||
import { $botMeta, botHandle, botMentionTag } from './data'
|
||||
import { appendGroupChatEntry } from './group-chat'
|
||||
import { groupMemberKey } from './group-membership'
|
||||
import { answerGroupClarify } from './group-turns'
|
||||
import { useBots } from './i18n'
|
||||
import { displayName } from './labels'
|
||||
import { botRosterMeta } from './routing'
|
||||
import type { BotMeta, GroupMember, GroupPrompt } from './types'
|
||||
|
||||
/** The `image.generate` reply. Older gateways answer `image`, newer ones
|
||||
* `image_data`; both are data URLs. */
|
||||
interface ImageGenerateResponse {
|
||||
error?: string
|
||||
image?: string
|
||||
image_data?: string
|
||||
success?: boolean
|
||||
}
|
||||
|
||||
interface GroupImageControlsProps {
|
||||
image: null | string
|
||||
onImage: (image: null | string) => void
|
||||
seedMembers?: string[]
|
||||
seedName: string
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
export function GroupImageControls({ image, onImage, seedName, seedMembers }: GroupImageControlsProps) {
|
||||
const b = useBots()
|
||||
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<ImageGenerateResponse>('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, b.group.pictureGenerationFailed)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-full bg-(--chrome-action-hover)">
|
||||
{image ? (
|
||||
<img src={image} alt="" className="size-full object-cover" />
|
||||
) : (
|
||||
<Codicon name="organization" className="text-(--ui-text-tertiary)" />
|
||||
)}
|
||||
</div>
|
||||
<Button type="button" variant="secondary" size="sm" onClick={upload}>
|
||||
{b.avatar.upload}
|
||||
</Button>
|
||||
{imagen ? (
|
||||
<Button type="button" variant="secondary" size="sm" disabled={busy} onClick={generate}>
|
||||
{busy ? 'Generating…' : 'Generate'}
|
||||
</Button>
|
||||
) : null}
|
||||
{image ? (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => onImage(null)}>
|
||||
Remove
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
interface MentionToken {
|
||||
query: string
|
||||
/** Index of the '@' the token starts at. */
|
||||
start: number
|
||||
}
|
||||
|
||||
/** 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: string, caret: number): MentionToken | null {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/** One row of the mention popover: the handle inserted, plus its subtitle. */
|
||||
interface MentionOption {
|
||||
handle: string
|
||||
meta: string
|
||||
}
|
||||
|
||||
interface GroupMentionInputProps {
|
||||
'aria-label'?: string
|
||||
autoFocus?: boolean
|
||||
className?: string
|
||||
members: GroupMember[]
|
||||
onChange: (value: string) => void
|
||||
onPaste?: (event: ClipboardEvent<HTMLTextAreaElement>) => void
|
||||
onSubmitDraft?: () => void
|
||||
placeholder?: string
|
||||
value: string
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
export function GroupMentionInput({ members, onChange, onSubmitDraft, value, ...inputProps }: GroupMentionInputProps) {
|
||||
const allMeta: Record<string, BotMeta> = useValue($botMeta)
|
||||
const inputRef = useRef<HTMLTextAreaElement | null>(null)
|
||||
const [token, setToken] = useState<MentionToken | null>(null)
|
||||
const [selected, setSelected] = useState(0)
|
||||
const options: MentionOption[] = []
|
||||
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()
|
||||
const display = displayName(member, botRosterMeta(member, allMeta))
|
||||
// Renamed members complete on their friendly tag; parser resolves both.
|
||||
const tag = String(botMentionTag(member) || handle).trim()
|
||||
if (!tag) {
|
||||
continue
|
||||
}
|
||||
if (
|
||||
token.query &&
|
||||
!tag.toLowerCase().startsWith(token.query) &&
|
||||
!(handle && handle.toLowerCase().startsWith(token.query)) &&
|
||||
!display.toLowerCase().startsWith(token.query)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
options.push({
|
||||
handle: tag,
|
||||
meta: display
|
||||
})
|
||||
}
|
||||
}
|
||||
const open = Boolean(token) && options.length > 0
|
||||
const active = open ? Math.min(selected, options.length - 1) : 0
|
||||
// The onClick below passes `event.target`, which React types as a bare
|
||||
// EventTarget even though a click on this textarea always targets it.
|
||||
const refreshToken = (target: HTMLTextAreaElement) => {
|
||||
setToken(mentionTokenAt(target.value, target.selectionStart ?? target.value.length))
|
||||
setSelected(0)
|
||||
}
|
||||
const insert = (handle: string) => {
|
||||
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 (
|
||||
<div className="relative min-w-0 flex-1">
|
||||
{open ? (
|
||||
<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) py-1 shadow-lg">
|
||||
{options.map((option, index) => (
|
||||
<RowButton
|
||||
key={option.handle}
|
||||
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)}
|
||||
>
|
||||
<span className="font-medium">{`@${option.handle}`}</span>
|
||||
<span className="truncate text-[0.65rem] text-(--ui-text-quaternary)">{option.meta}</span>
|
||||
</RowButton>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<Textarea
|
||||
{...inputProps}
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
rows={1} // Multi-line room prompts (#89884): the composer was a single-line
|
||||
// Input whose form submitted on every Enter — newlines were
|
||||
// impossible. Enter (no Shift) still submits via onSubmitDraft;
|
||||
// Shift+Enter falls through to the textarea's native newline.
|
||||
className={cn('max-h-40 min-h-9 resize-none', inputProps.className)}
|
||||
onChange={event => {
|
||||
onChange(event.target.value)
|
||||
refreshToken(event.target)
|
||||
}}
|
||||
onClick={event => refreshToken(event.target as HTMLTextAreaElement)}
|
||||
onKeyDown={event => {
|
||||
// IME composition guard (same as the core composer): Enter here
|
||||
// confirms the composed Chinese/Japanese/Korean text — it must not
|
||||
// insert a mention nor submit the draft. nativeEvent.isComposing
|
||||
// covers Chromium; keyCode 229 covers macOS Chinese IMEs that fire
|
||||
// Enter after compositionend with isComposing already false.
|
||||
if (event.nativeEvent?.isComposing || event.keyCode === 229) {
|
||||
return
|
||||
}
|
||||
if (open) {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
setSelected((active + 1) % options.length)
|
||||
return
|
||||
}
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
setSelected((active - 1 + options.length) % options.length)
|
||||
return
|
||||
}
|
||||
if (event.key === 'Enter' || event.key === 'Tab') {
|
||||
event.preventDefault()
|
||||
insert(options[active].handle)
|
||||
return
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
setToken(null)
|
||||
return
|
||||
}
|
||||
}
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
onSubmitDraft?.()
|
||||
}
|
||||
}}
|
||||
onBlur={() => setToken(null)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** A pending prompt as the room renders it. */
|
||||
export interface GroupRoomPrompt extends GroupPrompt {
|
||||
/** TODO(bot-mode-types): nothing ever sets this — syncGroupClarify builds
|
||||
* every entry without a thread — so the answer GroupClarifyCard echoes back
|
||||
* into the room always lands in the 'legacy' thread instead of the thread
|
||||
* the member asked from. */
|
||||
thread?: string
|
||||
}
|
||||
|
||||
/** A sub-question normalized for rendering: one card row, one answer. */
|
||||
interface GroupClarifyQuestion {
|
||||
choices: string[]
|
||||
multiSelect: boolean
|
||||
qid: string
|
||||
question: string
|
||||
}
|
||||
|
||||
interface GroupClarifyCardProps {
|
||||
entry: GroupRoomPrompt
|
||||
members: GroupMember[]
|
||||
}
|
||||
|
||||
/** One member's pending prompt, rendered in the room (#90694).
|
||||
* - clarify: choices as tap buttons (multi-select stages; single-select
|
||||
* stages one), free text always available, batch sub-questions each get
|
||||
* their own input.
|
||||
* - approval: the command in a code row plus the server's choice set
|
||||
* (once/session/always/deny) as buttons — no free text; approvals are a
|
||||
* closed choice. Answer sends via the member's own source. */
|
||||
export function GroupClarifyCard({ entry, members }: GroupClarifyCardProps) {
|
||||
const { group } = entry
|
||||
const isApproval = entry.kind === 'approval'
|
||||
const member = members.find(m => groupMemberKey(m) === entry.memberKey) || members.find(m => m.name === entry.member)
|
||||
const [drafts, setDrafts] = useState<Record<string, string>>({})
|
||||
const [picked, setPicked] = useState<Record<string, string[]>>({})
|
||||
const [sending, setSending] = useState(false)
|
||||
const questions: GroupClarifyQuestion[] =
|
||||
entry.questions && entry.questions.length
|
||||
? entry.questions.map((q, i) => ({
|
||||
qid: q?.qid ?? q?.id ?? `q${i}`,
|
||||
question: typeof q?.question === 'string' ? q.question : '',
|
||||
choices: Array.isArray(q?.choices) ? q.choices.filter(c => typeof c === 'string' && c) : [],
|
||||
multiSelect: Boolean(q?.multi_select ?? q?.multiSelect)
|
||||
}))
|
||||
: [
|
||||
{
|
||||
qid: '__single__',
|
||||
question: entry.question,
|
||||
choices: entry.choices,
|
||||
multiSelect: entry.multiSelect
|
||||
}
|
||||
]
|
||||
const answerFor = (q: GroupClarifyQuestion) => {
|
||||
const chosen = picked[q.qid] || []
|
||||
if (chosen.length) {
|
||||
return q.multiSelect ? JSON.stringify(chosen) : chosen[0]
|
||||
}
|
||||
return isApproval ? '' : (drafts[q.qid] || '').trim()
|
||||
}
|
||||
const allAnswered = questions.every(q => answerFor(q))
|
||||
const submit = async () => {
|
||||
if (!member || sending || !allAnswered) {
|
||||
return
|
||||
}
|
||||
setSending(true)
|
||||
try {
|
||||
if (isApproval || !(entry.questions && entry.questions.length)) {
|
||||
await answerGroupClarify(entry, member, answerFor(questions[0]))
|
||||
} else {
|
||||
const answers: Record<string, string> = {}
|
||||
for (const q of questions) {
|
||||
answers[q.qid] = answerFor(q)
|
||||
}
|
||||
await answerGroupClarify(entry, member, answers)
|
||||
}
|
||||
|
||||
// Echo the exchange into the room log so the thread reads complete.
|
||||
const summary = isApproval
|
||||
? `${answerFor(questions[0])} — ${entry.command || entry.question || 'command approval'}`
|
||||
: questions.map(q => (questions.length > 1 ? `${q.question}: ${answerFor(q)}` : answerFor(q))).join('\n')
|
||||
appendGroupChatEntry(
|
||||
group,
|
||||
{
|
||||
kind: 'user',
|
||||
name: 'You'
|
||||
},
|
||||
summary,
|
||||
entry.thread || 'legacy'
|
||||
)
|
||||
} catch (err: any) {
|
||||
host.notify({
|
||||
kind: 'error',
|
||||
message: `Could not send the answer to @${botHandle(entry.member, member)}: ${err?.message || err}`
|
||||
})
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="grid gap-1.5 rounded-md border border-(--ui-accent)/50 bg-(--ui-accent)/5 px-2.5 py-2">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium">
|
||||
<Codicon name={isApproval ? 'shield' : 'question'} className="shrink-0 text-(--ui-accent)" />
|
||||
{isApproval
|
||||
? `@${botHandle(entry.member, member)} wants to run a command:`
|
||||
: `@${botHandle(entry.member, member)} asks:`}
|
||||
</div>
|
||||
{isApproval && entry.command ? (
|
||||
<code className="block overflow-x-auto rounded bg-(--ui-bg-secondary,rgba(0,0,0,0.25)) px-2 py-1 font-mono text-[0.7rem] whitespace-pre-wrap break-all">
|
||||
{entry.command}
|
||||
</code>
|
||||
) : null}
|
||||
{questions.map(q => (
|
||||
<div key={`q:${q.qid}`} className="grid gap-1">
|
||||
{q.question ? <div className="text-xs whitespace-pre-wrap">{q.question}</div> : null}
|
||||
{q.choices.length ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{q.choices.map(choice => {
|
||||
const chosen = (picked[q.qid] || []).includes(choice)
|
||||
return (
|
||||
<Button
|
||||
key={`choice:${q.qid}:${choice}`}
|
||||
size="sm"
|
||||
variant={chosen ? 'default' : 'secondary'}
|
||||
className={cn(
|
||||
'h-6 px-2 text-[0.7rem]',
|
||||
isApproval && choice === 'deny' && !chosen && 'text-destructive'
|
||||
)}
|
||||
onClick={() => {
|
||||
setDrafts(prev => ({
|
||||
...prev,
|
||||
[q.qid]: ''
|
||||
}))
|
||||
setPicked(prev => {
|
||||
const current = prev[q.qid] || []
|
||||
const next = q.multiSelect
|
||||
? chosen
|
||||
? current.filter(c => c !== choice)
|
||||
: [...current, choice]
|
||||
: chosen
|
||||
? []
|
||||
: [choice]
|
||||
return {
|
||||
...prev,
|
||||
[q.qid]: next
|
||||
}
|
||||
})
|
||||
}}
|
||||
>
|
||||
{choice}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
{/* Approvals are a closed choice set — no free-text input. */}
|
||||
{isApproval ? null : (
|
||||
<Input
|
||||
key={`input:${q.qid}`}
|
||||
aria-label={`Answer @${entry.member}`}
|
||||
placeholder={q.choices.length ? 'Or type your own answer…' : 'Type your answer…'}
|
||||
value={drafts[q.qid] || ''}
|
||||
onChange={event => {
|
||||
const value = event.target.value
|
||||
setPicked(prev => ({
|
||||
...prev,
|
||||
[q.qid]: []
|
||||
}))
|
||||
setDrafts(prev => ({
|
||||
...prev,
|
||||
[q.qid]: value
|
||||
}))
|
||||
}}
|
||||
onKeyDown={event => {
|
||||
// IME guard: Enter confirming a composed word must not submit.
|
||||
if (event.nativeEvent?.isComposing || event.keyCode === 229) {
|
||||
return
|
||||
}
|
||||
if (event.key === 'Enter' && questions.length === 1) {
|
||||
event.preventDefault()
|
||||
void submit()
|
||||
}
|
||||
}}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" disabled={sending || !allAnswered || !member} onClick={() => void submit()}>
|
||||
{sending ? 'Sending…' : isApproval ? 'Respond' : 'Answer'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* Who is in a room: the membership keys, the ui_meta group lists each bot
|
||||
* carries, and the roster→member descriptor derivation the room engine and
|
||||
* the roster UI both read.
|
||||
*/
|
||||
|
||||
import { $botMeta, botFriendlyNames, botMetaKey, botRosterKey } from './data'
|
||||
import { $groupChats, groupChatRoomKey } from './group-chat'
|
||||
import { botConnectionRoute, botRosterMeta, resolveBotConnectionRoute } from './routing'
|
||||
import type { BotMeta, GroupChat, GroupMember, RosterRow } from './types'
|
||||
|
||||
export function groupWorkspaceOwnerKey(group: string) {
|
||||
return `group:${groupChatRoomKey(group, $groupChats.get()[group])}`
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
export function groupMemberKey(member: GroupMember): string {
|
||||
return member?.sourceScoped || member?.remoteSource ? botRosterKey(member) : member?.name
|
||||
}
|
||||
|
||||
/** Serializable immutable owner captured beside every group plumbing session. */
|
||||
export function groupSessionOwner(member: RosterRow) {
|
||||
const route = botConnectionRoute(member)
|
||||
const name = String(route?.profile || member?.name || '').trim() || 'default'
|
||||
if (!route) {
|
||||
return {
|
||||
name
|
||||
}
|
||||
}
|
||||
return {
|
||||
connectionId: route.connectionId,
|
||||
name,
|
||||
sourceScoped: true,
|
||||
remoteSource: route.mode !== 'local',
|
||||
route: {
|
||||
...route
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
export function botGroups(meta?: BotMeta | null) {
|
||||
const groups: string[] = []
|
||||
const seen = new Set<string>()
|
||||
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
|
||||
}
|
||||
interface GroupMembershipPatch {
|
||||
/** Legacy single-group scalar, kept as a first-membership projection. */
|
||||
group: null | string
|
||||
groups: string[]
|
||||
}
|
||||
|
||||
export function groupMembershipPatch(meta: BotMeta | null | undefined, group: string, enabled: boolean): GroupMembershipPatch {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the exact metadata cleanup for a room disband. The rendered member
|
||||
* list is only a presentation snapshot and can already be empty on a remote
|
||||
* connection while bot metadata still names the room. Durable room members
|
||||
* and the full roster recover source-qualified owners; every remaining
|
||||
* metadata record is still cleared locally, but an unresolved scoped key is
|
||||
* never guessed into a server route. */
|
||||
export function groupDisbandMetadataPlan(
|
||||
group: string,
|
||||
members: RosterRow[],
|
||||
room: GroupChat,
|
||||
roster: RosterRow[],
|
||||
metaByName: Record<string, BotMeta>
|
||||
) {
|
||||
const owners = new Map<string, RosterRow>()
|
||||
const patches = new Map<string, GroupMembershipPatch>()
|
||||
const rememberOwner = (owner: RosterRow, required = false) => {
|
||||
if (!owner?.name) {
|
||||
return
|
||||
}
|
||||
let key: string
|
||||
try {
|
||||
key = botMetaKey(owner)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
const meta = metaByName?.[key] || botRosterMeta(owner, metaByName) || {}
|
||||
if (!required && !botGroups(meta).includes(group)) {
|
||||
return
|
||||
}
|
||||
if (!owners.has(key)) {
|
||||
owners.set(key, owner)
|
||||
}
|
||||
patches.set(key, groupMembershipPatch(meta, group, false))
|
||||
}
|
||||
for (const owner of members || []) {
|
||||
rememberOwner(owner, true)
|
||||
}
|
||||
for (const owner of room?.members || []) {
|
||||
rememberOwner(owner, true)
|
||||
}
|
||||
for (const owner of roster || []) {
|
||||
rememberOwner(owner)
|
||||
}
|
||||
|
||||
// Metadata itself is the final source of a metadata-only `0 bots` row.
|
||||
// Clear every record that names the room even when its exact server owner is
|
||||
// temporarily absent from the roster. Known owners still get a routed
|
||||
// profiles.configure write below; unknown scoped records remain local-only.
|
||||
for (const [key, meta] of Object.entries(metaByName || {})) {
|
||||
if (botGroups(meta).includes(group)) {
|
||||
patches.set(key, groupMembershipPatch(meta, group, false))
|
||||
}
|
||||
}
|
||||
return {
|
||||
owners,
|
||||
patches
|
||||
}
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
export function groupChatNames(metaByName: Record<string, BotMeta>, rooms: Record<string, GroupChat>) {
|
||||
const names = new Set(knownGroups(metaByName))
|
||||
for (const [name, room] of Object.entries(rooms || {})) {
|
||||
if (room?.tombstone) {
|
||||
continue
|
||||
}
|
||||
if ((Array.isArray(room?.members) && room.members.length) || (Array.isArray(room?.log) && room.log.length)) {
|
||||
names.add(name)
|
||||
}
|
||||
}
|
||||
return [...names]
|
||||
}
|
||||
|
||||
/** Names of REAL rooms in the atom — disband tombstones excluded. Feeds the
|
||||
* create/rename collision sets so a just-disbanded name is immediately
|
||||
* reusable even while an in-flight drive's tombstone still holds its key. */
|
||||
export function liveGroupChatNames() {
|
||||
return Object.entries($groupChats.get())
|
||||
.filter(([, room]) => !room?.tombstone)
|
||||
.map(([name]) => name)
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
export function groupLastActivity(room?: GroupChat | null) {
|
||||
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. */
|
||||
export function groupChatMemberBots(group: string, roster: RosterRow[], metaByName: Record<string, BotMeta>): RosterRow[] {
|
||||
const local = (roster || []).filter(bot => botGroups(botRosterMeta(bot, metaByName)).includes(group))
|
||||
const stored = ($groupChats.get()[group] || {}).members || []
|
||||
const seated = new Set(local.map(botRosterKey))
|
||||
const remote: RosterRow[] = []
|
||||
for (const descriptor of stored) {
|
||||
// Legacy descriptors can carry a FRIENDLY name as `name` (older builds
|
||||
// persisted display names — #92794: `name: '大司命'` for slug `taiyi`).
|
||||
// Key-matching alone then seats the descriptor as a ghost NEXT TO its own
|
||||
// live row ("4 bots" in a 2-bot room), and anything that passes ghost
|
||||
// identity onward targets a profile that does not exist on disk.
|
||||
// Normalize first: a same-connection roster row whose friendly names
|
||||
// include the descriptor's name IS this member. The next persistence
|
||||
// pass (durableGroupChatMembers writes from the seated roster) rewrites
|
||||
// the stored descriptor to the slug, so the repair is self-healing.
|
||||
const resolved = resolveLegacyMemberDescriptor(descriptor, roster)
|
||||
const key = botRosterKey(resolved)
|
||||
if (seated.has(key)) {
|
||||
continue
|
||||
}
|
||||
seated.add(key)
|
||||
// A selected-but-offline ghost intentionally carries only enough identity
|
||||
// to paint the roster. Never let it replace the room's durable descriptor,
|
||||
// which owns the full handle/title used by mentions and remote sync.
|
||||
remote.push((roster || []).find(bot => !bot?.ghost && botRosterKey(bot) === key) || resolved)
|
||||
}
|
||||
return [...local, ...remote]
|
||||
}
|
||||
|
||||
/** A stored member descriptor, resolved against the live roster when its
|
||||
* `name` is not a real slug (#92794). Exact key matches pass through
|
||||
* untouched; only a descriptor whose key matches NO roster row is re-tried
|
||||
* by friendly name against rows on the same connection. Unresolvable
|
||||
* descriptors return as-is — they stay visible-but-degraded ghosts and must
|
||||
* never be used as a `profile:` target. */
|
||||
function resolveLegacyMemberDescriptor(descriptor: RosterRow, roster: RosterRow[]): RosterRow {
|
||||
const rows = roster || []
|
||||
if (rows.some(bot => botRosterKey(bot) === botRosterKey(descriptor))) {
|
||||
return descriptor
|
||||
}
|
||||
const wanted = String(descriptor?.name || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
if (!wanted) {
|
||||
return descriptor
|
||||
}
|
||||
|
||||
// Connection scope: a descriptor WITH a connectionId only matches rows on
|
||||
// that connection (two `default`s on different machines must never merge).
|
||||
// A descriptor WITHOUT one predates connection scoping entirely — those
|
||||
// rooms only ever contained this machine's bots, so local (non-remote)
|
||||
// rows are the legal candidate set.
|
||||
const descriptorConnection = String(descriptor?.connectionId || '')
|
||||
const candidates = rows.filter(bot => {
|
||||
if (bot?.ghost) {
|
||||
return false
|
||||
}
|
||||
return descriptorConnection ? String(bot?.connectionId || '') === descriptorConnection : !bot?.remoteSource
|
||||
})
|
||||
const match = candidates.find(
|
||||
bot =>
|
||||
// Case-drifted slug ('Testbot' persisted for profile 'testbot') …
|
||||
String(bot?.name || '')
|
||||
.trim()
|
||||
.toLowerCase() === wanted ||
|
||||
// … or a friendly name persisted as `name` ('大司命' for 'taiyi').
|
||||
botFriendlyNames(bot).some(
|
||||
name =>
|
||||
String(name || '')
|
||||
.trim()
|
||||
.toLowerCase() === wanted
|
||||
)
|
||||
)
|
||||
return match || descriptor
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
export function durableGroupChatMembers(bots: RosterRow[]): GroupMember[] {
|
||||
return (bots || []).map(bot => {
|
||||
// Persistence pass over the whole seated roster: an orphaned member
|
||||
// (connection deleted) keeps its identity and simply persists with no
|
||||
// route — the same degraded shape the hydrate annotate produces. The
|
||||
// strict throw here would lose the entire room update over one row.
|
||||
const route = resolveBotConnectionRoute(bot).route
|
||||
// Keep the friendly identity on the stored descriptor: after a
|
||||
// connection switch the live roster row may be gone, and renamed-tag
|
||||
// mentions must still resolve against the persisted member.
|
||||
const title = String(
|
||||
botRosterMeta(bot, $botMeta.get())?.title || bot.ui_meta?.['hermes-bots']?.title || bot.title || ''
|
||||
).trim()
|
||||
return {
|
||||
name: bot.name,
|
||||
handle: bot.handle || bot.name,
|
||||
...(title
|
||||
? {
|
||||
title
|
||||
}
|
||||
: {}),
|
||||
...(bot.display_name
|
||||
? {
|
||||
display_name: bot.display_name
|
||||
}
|
||||
: {}),
|
||||
connectionId: bot.connectionId,
|
||||
connectionKind: bot.connectionKind,
|
||||
connectionLabel: bot.connectionLabel,
|
||||
...(route
|
||||
? {
|
||||
route,
|
||||
targetProfile: route.targetProfile
|
||||
}
|
||||
: {}),
|
||||
// A swept/annotated member keeps its degraded mark across the rebuild —
|
||||
// otherwise the next room send would silently un-mark an orphaned row.
|
||||
...(bot.sourceMissing
|
||||
? {
|
||||
sourceMissing: true,
|
||||
sourceReachable: false
|
||||
}
|
||||
: {}),
|
||||
remoteSource: true,
|
||||
sourceScoped: Boolean(route)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Existing group names, alphabetical — feeds the Manage-groups dialog. */
|
||||
export function knownGroups(metaByName: Record<string, BotMeta>) {
|
||||
const names = new Set<string>()
|
||||
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'
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* The window-local state a group-chat room needs but never renders: the
|
||||
* per-composer drafts, and the registry of MAIN-window tabs a room is open in.
|
||||
*
|
||||
* A leaf by design. The room surface and the roster pane both read it and it
|
||||
* reads neither, so a disband, a rename or a Back button can retire a room's
|
||||
* tab without any of those paths importing a view.
|
||||
*/
|
||||
|
||||
import { atom } from '@hermes/plugin-sdk'
|
||||
|
||||
import { $groupChatWorkspace, groupChatRoomKey } from './group-chat'
|
||||
import type { GroupChatRoom } from './group-chat'
|
||||
import type { Attachment } from './types'
|
||||
|
||||
// Group composer drafts are window-local UI state. They must survive pane
|
||||
// parking/re-registration and owner switches, but must never enter shared room
|
||||
// metadata (where another Desktop would see half-typed text or attachment
|
||||
// bytes). Current rooms key by immutable roomId; legacy rooms fall back to the
|
||||
// display name until they are upgraded.
|
||||
export interface GroupComposerDraft {
|
||||
activeReplyThread: null | string
|
||||
main: string
|
||||
/** Keyed by thread id; the main composer parks under 'main'. */
|
||||
pendingAttachments: Record<string, Attachment[]>
|
||||
replies: Record<string, string>
|
||||
revision: number
|
||||
}
|
||||
const groupComposerDrafts = new Map<string, GroupComposerDraft | undefined>()
|
||||
function emptyGroupComposerDraft(): GroupComposerDraft {
|
||||
return {
|
||||
activeReplyThread: null,
|
||||
main: '',
|
||||
pendingAttachments: {},
|
||||
replies: {},
|
||||
revision: 0
|
||||
}
|
||||
}
|
||||
export function groupComposerDraftKey(group: string, room: GroupChatRoom): string {
|
||||
return groupChatRoomKey(group, room)
|
||||
}
|
||||
export function groupComposerDraftSnapshot(key: string): GroupComposerDraft {
|
||||
return groupComposerDrafts.get(key) || emptyGroupComposerDraft()
|
||||
}
|
||||
export function updateGroupComposerDraft(key: string, mutate: (draft: GroupComposerDraft) => GroupComposerDraft) {
|
||||
const current = groupComposerDraftSnapshot(key)
|
||||
const next = mutate({
|
||||
...current,
|
||||
pendingAttachments: Object.fromEntries(
|
||||
Object.entries(current.pendingAttachments || {}).map(([thread, attachments]) => [
|
||||
thread,
|
||||
[...(attachments || [])]
|
||||
])
|
||||
),
|
||||
replies: {
|
||||
...(current.replies || {})
|
||||
}
|
||||
})
|
||||
next.revision = current.revision + 1
|
||||
groupComposerDrafts.delete(key)
|
||||
groupComposerDrafts.set(key, next)
|
||||
return next
|
||||
}
|
||||
export function restoreGroupComposerDraft(key: string, expectedRevision: number, snapshot: GroupComposerDraft) {
|
||||
const current = groupComposerDraftSnapshot(key)
|
||||
if (current.revision !== expectedRevision) {
|
||||
return null
|
||||
}
|
||||
const restored = {
|
||||
...snapshot,
|
||||
pendingAttachments: Object.fromEntries(
|
||||
Object.entries(snapshot.pendingAttachments || {}).map(([thread, attachments]) => [
|
||||
thread,
|
||||
[...(attachments || [])]
|
||||
])
|
||||
),
|
||||
replies: {
|
||||
...(snapshot.replies || {})
|
||||
},
|
||||
revision: current.revision + 1
|
||||
}
|
||||
groupComposerDrafts.set(key, restored)
|
||||
return restored
|
||||
}
|
||||
export function clearGroupComposerDraft(key: string) {
|
||||
groupComposerDrafts.delete(key)
|
||||
}
|
||||
export function migrateGroupComposerDraft(oldKey: string, newKey: string) {
|
||||
if (oldKey === newKey || !groupComposerDrafts.has(oldKey)) {
|
||||
return
|
||||
}
|
||||
if (!groupComposerDrafts.has(newKey)) {
|
||||
groupComposerDrafts.set(newKey, groupComposerDrafts.get(oldKey))
|
||||
}
|
||||
groupComposerDrafts.delete(oldKey)
|
||||
}
|
||||
/** React-style setter argument — the next value, or a function of the current. */
|
||||
export type GroupDraftSetter<T> = T | ((current: T) => T)
|
||||
|
||||
/** 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. */
|
||||
export const groupChatMainTabs = new Map<string, () => void>()
|
||||
|
||||
/** Reactive shadow of `groupChatMainTabs` membership. The Map itself can't
|
||||
* notify React, and #89788's first fix read it non-reactively: a BotsPane
|
||||
* render that landed between selecting the group and recording its main
|
||||
* tab kept the in-pane room on screen forever (the map write repaints
|
||||
* nothing). Every map mutation goes through the two helpers below so the
|
||||
* rev bump re-evaluates the gate. */
|
||||
export const $groupMainTabsRev = atom(0)
|
||||
export function recordGroupMainTab(group: string, close: () => void) {
|
||||
groupChatMainTabs.set(group, close)
|
||||
$groupMainTabsRev.set($groupMainTabsRev.get() + 1)
|
||||
}
|
||||
export function dropGroupMainTab(group: string) {
|
||||
if (groupChatMainTabs.delete(group)) {
|
||||
$groupMainTabsRev.set($groupMainTabsRev.get() + 1)
|
||||
}
|
||||
}
|
||||
|
||||
/** The in-panel room is the FALLBACK surface, not a second copy: it renders
|
||||
* only while no main-window tab owns the group. On desktops with the door
|
||||
* the room already lives in a main tab, and painting it here too produced
|
||||
* two live panes with independent drafts driving one shared engine (#89788).
|
||||
* The selection atom stays set either way so the roster row still
|
||||
* highlights. Callers must subscribe to `$groupMainTabsRev` (BotsPane does)
|
||||
* so ownership changes re-run this gate. */
|
||||
export function shouldRenderGroupChatInPane(group: null | string): group is string {
|
||||
return Boolean(group && !groupChatMainTabs.has(group))
|
||||
}
|
||||
export function closeGroupChatMainTab(group: string) {
|
||||
const close = groupChatMainTabs.get(group)
|
||||
dropGroupMainTab(group)
|
||||
if ($groupChatWorkspace.get() === group) {
|
||||
$groupChatWorkspace.set(null)
|
||||
}
|
||||
if (typeof close === 'function') {
|
||||
try {
|
||||
close()
|
||||
} catch {
|
||||
/* tab already gone */
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,933 @@
|
||||
/**
|
||||
* Room-level coordination: who speaks, in what order, for how long — the
|
||||
* @mention parse, the round-robin driver, the #93129 member holds, the stop
|
||||
* path, and the user send that starts it all.
|
||||
*/
|
||||
|
||||
import { botFriendlyNames, botHandle, clearBotAttention, mentionNameForms, noteBotAttention } from './data'
|
||||
import { recordGroupActivity } from './group-activity'
|
||||
import {
|
||||
$groupChats,
|
||||
$groupNeedsYou,
|
||||
appendGroupChatEntry,
|
||||
GROUP_CHAT_HISTORY_LIMIT,
|
||||
GROUP_CHAT_MAX_CONTINUATIONS,
|
||||
GROUP_CHAT_MAX_MESSAGES,
|
||||
GROUP_CHAT_MAX_ROUNDS,
|
||||
groupSpeakerLabel,
|
||||
groupThreadOf,
|
||||
mintGroupThreadId,
|
||||
shouldCommitMemberTurn,
|
||||
updateGroupChat
|
||||
} from './group-chat'
|
||||
import type { GroupChatRoom, GroupHoldStamp } from './group-chat'
|
||||
import { durableGroupChatMembers, groupMemberKey } from './group-membership'
|
||||
import { harvestStrandedGroupReply, isGroupPassText, runGroupChatMemberTurn } from './group-turns'
|
||||
import { requestForBot } from './routing'
|
||||
import type { Attachment, GroupMember, GroupMessage } from './types'
|
||||
|
||||
// ── 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.
|
||||
|
||||
/** 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: unknown, members: GroupMember[]) {
|
||||
const source = String(text || '')
|
||||
const mentioned = new Set<string>()
|
||||
let everyone = false
|
||||
const handles = new Map<string, string>()
|
||||
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()]
|
||||
: [])
|
||||
])
|
||||
|
||||
// Renamed members answer to their friendly names too (profile
|
||||
// display_name and Bot Mode title), in slugged and collapsed forms —
|
||||
// the same tags the roster autocomplete inserts.
|
||||
for (const friendly of botFriendlyNames(member)) {
|
||||
for (const form of mentionNameForms(friendly)) {
|
||||
forms.add(form)
|
||||
}
|
||||
}
|
||||
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: GroupMessage[], members: GroupMember[]) {
|
||||
let sinceLastUser: GroupMessage[] = []
|
||||
for (let i = log.length - 1; i >= 0; i--) {
|
||||
if (log[i].from.kind === 'user') {
|
||||
sinceLastUser = log.slice(i)
|
||||
break
|
||||
}
|
||||
}
|
||||
const mentioned = new Set<string>()
|
||||
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: GroupMember[], round: number) {
|
||||
if (members.length < 2) {
|
||||
return members
|
||||
}
|
||||
const shift = round % members.length
|
||||
return [...members.slice(shift), ...members.slice(0, shift)]
|
||||
}
|
||||
|
||||
/** Room-log line as a member sees it: `Name (user): …` / `Name: …` /
|
||||
* `Name (you): …`. */
|
||||
function formatGroupChatLine(entry: GroupMessage, viewerName: string) {
|
||||
// Attachments are staged into each member's session as real payloads; the
|
||||
// transcript line names them so the delta text and the bytes line up.
|
||||
const attached =
|
||||
Array.isArray(entry.images) && entry.images.length
|
||||
? ` ${entry.images
|
||||
.map(img => {
|
||||
const label = img.kind === 'pdf' ? 'attached PDF' : img.kind === 'file' ? 'attached file' : 'attached image'
|
||||
return `[${label}: ${img.name || 'image'}]`
|
||||
})
|
||||
.join(' ')}`
|
||||
: ''
|
||||
if (entry.from.kind === 'user') {
|
||||
return `${entry.from.name || 'User'} (user): ${entry.text}${attached}`
|
||||
}
|
||||
const suffix = entry.from.name === viewerName ? ' (you)' : ''
|
||||
// Cross-connection speakers carry their device so same-named agents on
|
||||
// two machines stay tellable apart in every member's transcript.
|
||||
const source = entry.from.source ? ` [${entry.from.source}]` : ''
|
||||
return `${groupSpeakerLabel(entry.from.name)}${suffix}${source}: ${entry.text}${attached}`
|
||||
}
|
||||
|
||||
interface GroupChatTurnPromptInput {
|
||||
deltaLines: string[]
|
||||
groupName: string
|
||||
members: GroupMember[]
|
||||
viewer: GroupMember
|
||||
}
|
||||
|
||||
/** The full per-turn payload for one member: participation rules + the room
|
||||
* delta. Rules travel in the turn payload (not SOUL) so every existing bot
|
||||
* can join a group chat without a profile migration. */
|
||||
function buildGroupChatTurnPrompt({ groupName, members, viewer, deltaLines }: GroupChatTurnPromptInput) {
|
||||
const viewerKey = groupMemberKey(viewer)
|
||||
const peers = members.filter(m => groupMemberKey(m) !== viewerKey)
|
||||
const peerNames = peers
|
||||
.map(m => {
|
||||
const handle = m.title ? `${m.title} (@${botHandle(m.name, m)})` : `@${botHandle(m.name, m)}`
|
||||
return m.remoteSource ? `${handle} [on ${m.connectionLabel || m.connectionId}]` : handle
|
||||
})
|
||||
.join(', ')
|
||||
return [
|
||||
`[Group chat: "${groupName}"] You are @${botHandle(viewer.name, viewer)}, one participant in a group chat with ${peerNames || 'no one else yet'} and the user.`,
|
||||
'',
|
||||
'New messages in the room since your last turn (oldest first):',
|
||||
...deltaLines.map(line => ` ${line}`),
|
||||
'',
|
||||
'Rules for this room:',
|
||||
'- Reply with ONE conversational message ONLY if you have something new worth adding: build on what was just said, claim or hand off work, answer a question aimed at you, or report a real result. Keep chatter short (1-3 sentences) — but when you are delivering a result, an answer the user asked for, or substantive work, give it at full quality and length; never thin out real content to fit the room.',
|
||||
'- If you have nothing new to add, reply with exactly "(pass)". Passing is good — it lets the conversation settle.',
|
||||
'- Mention a teammate as @name to pull them in; mention @user only for a judgment call or a result the user needs. Do not repeat points already made.',
|
||||
'- Never reveal content from your private 1:1 chats. Your reply text goes to the room verbatim — no preamble, no meta-commentary.'
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
// --- member-hold helpers (#93129) — pure, vm-sliced by tests ---
|
||||
|
||||
/** #93129: classify a USER room message's effect on member holds. Only user
|
||||
* sends ever reach this (bot replies are appended by the round loop, never
|
||||
* through sendToGroupChat), so a bot saying "stopped working on it" can
|
||||
* never set a hold. Conservative on purpose: any standalone stop/halt/pause
|
||||
* word next to a mention holds those members — "don't stop @x" therefore
|
||||
* also holds, which errs toward the bot staying quiet until re-addressed
|
||||
* (a wrongly-held bot is one mention away from release; a wrongly-running
|
||||
* one keeps doing work it was told to stop). A non-stop direct mention
|
||||
* releases the mentioned members — the user addressing a bot directly
|
||||
* overrides its hold. */
|
||||
function classifyGroupHoldDirective(
|
||||
text: string,
|
||||
mentionedKeys: Iterable<string> | null | undefined,
|
||||
everyone: boolean
|
||||
) {
|
||||
const value = String(text || '')
|
||||
const mentioned = [...(mentionedKeys || [])]
|
||||
const stop = /\b(stop|halt|pause)\b/i.test(value)
|
||||
const resume = /\b(resume|continue|go|proceed)\b/i.test(value)
|
||||
if (stop) {
|
||||
// "@all stop" holds every member — symmetric with "@all resume".
|
||||
return {
|
||||
hold: mentioned,
|
||||
holdAll: Boolean(everyone),
|
||||
release: [],
|
||||
releaseAll: false
|
||||
}
|
||||
}
|
||||
if (resume) {
|
||||
return {
|
||||
hold: [],
|
||||
holdAll: false,
|
||||
release: mentioned,
|
||||
releaseAll: Boolean(everyone)
|
||||
}
|
||||
}
|
||||
return {
|
||||
hold: [],
|
||||
holdAll: false,
|
||||
release: mentioned,
|
||||
releaseAll: false
|
||||
}
|
||||
}
|
||||
|
||||
/** What `parseGroupChatMentions` reports for one room message. */
|
||||
interface GroupMentionParse {
|
||||
everyone?: boolean
|
||||
mentioned?: Iterable<string>
|
||||
}
|
||||
|
||||
/** #93129: next holds map after one user message. Holds are keyed by
|
||||
* memberKey at ROOM scope (not thread scope): every main-composer send
|
||||
* mints a NEW thread, so a thread-scoped hold would never block the next
|
||||
* send's turns and the stop would not stick. Returns the same object when
|
||||
* nothing changed. */
|
||||
function applyGroupHoldDirective(
|
||||
holds: Record<string, GroupHoldStamp> | null | undefined,
|
||||
mentions: GroupMentionParse | null | undefined,
|
||||
text: string,
|
||||
stamp: GroupHoldStamp | null | undefined,
|
||||
allMemberKeys: string[] = []
|
||||
): Record<string, GroupHoldStamp> {
|
||||
const prior: Record<string, GroupHoldStamp> = holds && typeof holds === 'object' ? holds : {}
|
||||
const action = classifyGroupHoldDirective(text, mentions?.mentioned || [], Boolean(mentions?.everyone))
|
||||
if (action.releaseAll) {
|
||||
return Object.keys(prior).length ? {} : prior
|
||||
}
|
||||
|
||||
// "@all stop": expand to every member key the caller knows about.
|
||||
const toHold = action.holdAll ? [...allMemberKeys] : action.hold
|
||||
let next = prior
|
||||
for (const key of toHold) {
|
||||
if (next === prior) {
|
||||
next = {
|
||||
...prior
|
||||
}
|
||||
}
|
||||
next[key] = {
|
||||
at: stamp?.at || Date.now(),
|
||||
byMessageId: stamp?.byMessageId || null,
|
||||
thread: stamp?.thread || null
|
||||
}
|
||||
}
|
||||
for (const key of action.release) {
|
||||
if (Object.prototype.hasOwnProperty.call(next, key)) {
|
||||
if (next === prior) {
|
||||
next = {
|
||||
...prior
|
||||
}
|
||||
}
|
||||
delete next[key]
|
||||
}
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
/** #93129: a held member's skip must consume its delta exactly once —
|
||||
* advance the watermark past the current log so the same entries never
|
||||
* re-trigger the skip. Null = nothing to consume (no write, no spin). */
|
||||
function heldMemberWatermarkAdvance(seen: number | undefined, logLength: number): null | number {
|
||||
return logLength > (seen || 0) ? logLength : null
|
||||
}
|
||||
|
||||
// --- end member-hold helpers ---
|
||||
|
||||
/** Members cited by @mention in a thread who have not posted any entry after
|
||||
* the citing one — the unresolved-handoff detector for #94478. A mention
|
||||
* inside a member reply is visible to the NEXT round's responder selection,
|
||||
* but the round loop exits first when nobody has new delta to read
|
||||
* (`spokeThisRound === 0`) or a cap lands, so the room settles while a
|
||||
* called bot never answers. Returns member keys still owed a turn. */
|
||||
function unaddressedGroupMentions(group: string, members: GroupMember[], thread: string) {
|
||||
const room = $groupChats.get()[group] || {
|
||||
log: []
|
||||
}
|
||||
const log = (room.log || []).filter((e: GroupMessage) => groupThreadOf(e) === thread)
|
||||
|
||||
// key → log INDEX of the entry that most recently cited this member.
|
||||
// Entry ids are UUIDs (groupChatEntryId), NOT monotonic — index order is
|
||||
// the only guaranteed ordering, and it is what "answered after the citing
|
||||
// entry" actually means. (#94478 review)
|
||||
const citedAt = new Map()
|
||||
for (const entry of log) {
|
||||
const parsed = parseGroupChatMentions(entry.text || '', members)
|
||||
|
||||
// A user send re-drives everyone anyway; only member-to-member handoffs
|
||||
// can strand here.
|
||||
if (entry.from.kind !== 'member') {
|
||||
continue
|
||||
}
|
||||
for (const key of parsed.mentioned) {
|
||||
const citingMemberKey = (() => {
|
||||
const m = members.find((mm: GroupMember) => mm.name === entry.from?.name)
|
||||
return m ? groupMemberKey(m) : null
|
||||
})()
|
||||
|
||||
// Never count a bot citing itself as a pending handoff.
|
||||
if (citingMemberKey && citingMemberKey !== key) {
|
||||
citedAt.set(key, log.indexOf(entry))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A citation is answered when the cited member posts any entry after the
|
||||
// citing one (its turn, whatever the content).
|
||||
const lastPostAt = new Map()
|
||||
for (const entry of log) {
|
||||
if (entry.from.kind !== 'member') {
|
||||
continue
|
||||
}
|
||||
const speakerKey = (() => {
|
||||
const m = members.find((mm: GroupMember) => mm.name === entry.from?.name)
|
||||
return m ? groupMemberKey(m) : null
|
||||
})()
|
||||
if (speakerKey) {
|
||||
lastPostAt.set(speakerKey, log.indexOf(entry))
|
||||
}
|
||||
}
|
||||
return [...citedAt.keys()].filter(key => {
|
||||
const citedIdx = citedAt.get(key)
|
||||
const answeredIdx = lastPostAt.get(key)
|
||||
return answeredIdx === undefined || answeredIdx <= citedIdx
|
||||
})
|
||||
}
|
||||
|
||||
/** #91868/#94569: the REAL stop path for a group round. The round loop's only
|
||||
* cancellation primitives were the epoch bump (checked at member boundaries)
|
||||
* and #93129 holds (skip FUTURE turns) — neither touches the member whose
|
||||
* model call is in flight RIGHT NOW, so "stop" meant "finish this turn
|
||||
* first". This primitive does all three legs atomically enough to matter:
|
||||
*
|
||||
* 1. Bumps the room epoch — the driving loop bails at its next boundary and
|
||||
* never selects another member (`isCurrent()` in runGroupChatRounds).
|
||||
* 2. Sets a #93129 hold for EVERY member — future turns stay skipped until
|
||||
* the user explicitly releases (resume / @all resume / direct mention),
|
||||
* the exact contract user-typed "@all stop" already has.
|
||||
* 3. Sends session.interrupt to the member currently ON TURN (room.turn,
|
||||
* runtime-only) via its own route, so the in-flight model call actually
|
||||
* dies instead of grinding to completion in the background. Best-effort:
|
||||
* an unreachable member still leaves the room stopped — the poll loop's
|
||||
* staleness check (epoch moved AND member held) abandons the turn.
|
||||
*
|
||||
* `members` is the live roster when the caller has one (the workspace);
|
||||
* falls back to the room's durable roster so a two-arg call still works. */
|
||||
export async function stopGroupThread(group: string, thread: null | string, members: GroupMember[] | null = null) {
|
||||
const room = $groupChats.get()[group] || {}
|
||||
const roster = Array.isArray(members) && members.length ? members : room.members || []
|
||||
const turnName = room.turn || null
|
||||
const stamp: GroupHoldStamp = {
|
||||
at: Date.now(),
|
||||
byMessageId: null,
|
||||
thread: thread || null
|
||||
}
|
||||
updateGroupChat(group, (r: GroupChatRoom) => {
|
||||
r.epoch = (r.epoch || 0) + 1
|
||||
r.running = false
|
||||
r.turn = null
|
||||
|
||||
// Same hold shape applyGroupHoldDirective mints for "@all stop" — the
|
||||
// held-skip path (watermark consume + 'held' activity note) and every
|
||||
// release gesture apply unchanged. An existing hold keeps its stamp.
|
||||
const holds: Record<string, GroupHoldStamp> = {
|
||||
...(r.holds || {})
|
||||
}
|
||||
for (const member of roster) {
|
||||
const key = groupMemberKey(member)
|
||||
if (key && !holds[key]) {
|
||||
holds[key] = {
|
||||
...stamp
|
||||
}
|
||||
}
|
||||
}
|
||||
r.holds = holds
|
||||
return r
|
||||
})
|
||||
|
||||
// Recorded AFTER the bump so the event is tagged with the new epoch — it
|
||||
// stays visible as the current run's outcome instead of dropping out of
|
||||
// view with the superseded run's events.
|
||||
recordGroupActivity(group, {
|
||||
kind: 'stopped',
|
||||
member: 'You',
|
||||
thread: thread || null
|
||||
})
|
||||
|
||||
// Interrupt the member actually mid-turn. room.turn is runtime-only and
|
||||
// names exactly one member (the loop is serial); a settled room has none.
|
||||
const onTurn = turnName ? roster.find((member: GroupMember) => member?.name === turnName) : null
|
||||
const sessionId = onTurn ? (room.sessions || {})[groupMemberKey(onTurn)] : null
|
||||
if (onTurn && sessionId) {
|
||||
try {
|
||||
await requestForBot(onTurn, 'session.interrupt', {
|
||||
session_id: sessionId
|
||||
})
|
||||
} catch {
|
||||
/* best-effort — the epoch/hold legs above already stopped the room;
|
||||
the abandoned poll loop exits on its staleness check */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 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: string, members: GroupMember[], thread: string) {
|
||||
const startEpoch = ($groupChats.get()[group] || {}).epoch || 0
|
||||
const isCurrent = () => (($groupChats.get()[group] || {}).epoch || 0) === startEpoch
|
||||
let posted = 0
|
||||
let continuations = 0
|
||||
// #94478: how this drive ended. 'settled' means quiet consensus (everyone
|
||||
// passed with nothing pending); 'capped' means a round/message/continuation
|
||||
// cap forced the exit — the activity feed must tell those apart.
|
||||
let exitKind: 'capped' | 'settled' = 'settled'
|
||||
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()) {
|
||||
recordGroupActivity(group, {
|
||||
kind: 'cancelled',
|
||||
member: null,
|
||||
thread
|
||||
})
|
||||
return
|
||||
}
|
||||
await harvestStrandedGroupReply(group, member)
|
||||
}
|
||||
const roomLog = (($groupChats.get()[group] || {}).log || []).filter(
|
||||
(e: GroupMessage) => groupThreadOf(e) === thread
|
||||
)
|
||||
// Exclude members the harvest pass just above confirmed are STILL
|
||||
// running (their stranded marker survived harvest because
|
||||
// state.inflight/running was true). Re-selecting one here would
|
||||
// prompt.submit into their live session — the gateway's default busy
|
||||
// policy redirects or hard-interrupts that turn (tui_gateway's
|
||||
// _handle_busy_submit), killing exactly the long-running work this
|
||||
// stranded/harvest mechanism exists to protect. Skip them; the next
|
||||
// harvest pass picks the reply up once it actually lands. A marker's
|
||||
// mere presence means "still stranded" (harvestStrandedGroupReply
|
||||
// deletes it once the member is confirmed done/dead) — presence, not
|
||||
// value shape, since markers are a bare number pre-thread or
|
||||
// {before, thread} post-thread.
|
||||
const strandedNow = ($groupChats.get()[group] || {}).stranded || {}
|
||||
const responders = rotateGroupSpeakers(resolveGroupResponders(roomLog, members), round).filter(
|
||||
(member: GroupMember) => !Object.prototype.hasOwnProperty.call(strandedNow, groupMemberKey(member))
|
||||
)
|
||||
let spokeThisRound = 0
|
||||
for (const member of responders) {
|
||||
if (!isCurrent() || posted >= GROUP_CHAT_MAX_MESSAGES) {
|
||||
if (!isCurrent()) {
|
||||
recordGroupActivity(group, {
|
||||
kind: 'cancelled',
|
||||
member: null,
|
||||
thread
|
||||
})
|
||||
} else {
|
||||
exitKind = 'capped' // message cap, not consensus (#94478)
|
||||
}
|
||||
return
|
||||
}
|
||||
const room = $groupChats.get()[group] || {
|
||||
log: [],
|
||||
watermarks: {}
|
||||
}
|
||||
const memberKey = groupMemberKey(member)
|
||||
const markKey = `${thread}::${memberKey}`
|
||||
const seen = room.watermarks[markKey] || 0
|
||||
// Delta: NEW room entries, narrowed to this thread — the member's
|
||||
// turn sees only the conversation it's part of.
|
||||
const delta = room.log.slice(seen).filter((e: GroupMessage) => groupThreadOf(e) === thread)
|
||||
if (!delta.length) {
|
||||
continue
|
||||
}
|
||||
|
||||
// #93129: a member the user told to stop is HELD — no turn until an
|
||||
// explicit release (resume / @all resume / a direct non-stop
|
||||
// mention). Consume the delta exactly once (watermark past the
|
||||
// current log) so the same entries never re-trigger this skip, and
|
||||
// surface WHY the bot is silent in the activity feed the first time.
|
||||
const heldEntry = (room.holds || {})[memberKey]
|
||||
if (heldEntry) {
|
||||
const advance = heldMemberWatermarkAdvance(seen, room.log.length)
|
||||
updateGroupChat(group, (r: GroupChatRoom) => {
|
||||
if (advance !== null) {
|
||||
r.watermarks[markKey] = advance
|
||||
}
|
||||
if (r.holds?.[memberKey] && !r.holds[memberKey].noted) {
|
||||
r.holds = {
|
||||
...r.holds,
|
||||
[memberKey]: {
|
||||
...r.holds[memberKey],
|
||||
noted: true
|
||||
}
|
||||
}
|
||||
}
|
||||
return r
|
||||
})
|
||||
if (!heldEntry.noted) {
|
||||
recordGroupActivity(group, {
|
||||
kind: 'held',
|
||||
member: member.name,
|
||||
thread
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
const prompt = buildGroupChatTurnPrompt({
|
||||
groupName: group,
|
||||
members,
|
||||
viewer: member,
|
||||
deltaLines: delta
|
||||
.slice(-GROUP_CHAT_HISTORY_LIMIT)
|
||||
.map((e: GroupMessage) => formatGroupChatLine(e, member.name))
|
||||
})
|
||||
|
||||
// Images riding this delta (user attachments — member entries don't
|
||||
// carry images today, but flatMap keeps this future-proof) get staged
|
||||
// into the member's session so the model sees the pixels, not just
|
||||
// the transcript's [attached image: …] marker.
|
||||
const deltaImages = delta.flatMap((e: GroupMessage) => (Array.isArray(e.images) ? e.images : []))
|
||||
|
||||
// Surface WHO is on turn (runtime-only, like running/epoch) so the
|
||||
// room shows "Radar is thinking…" instead of a generic working line —
|
||||
// long model turns otherwise read as the room being stuck.
|
||||
updateGroupChat(group, (r: GroupChatRoom) => {
|
||||
r.turn = member.name
|
||||
return r
|
||||
})
|
||||
let reply: null | string = null
|
||||
try {
|
||||
reply = await runGroupChatMemberTurn(group, member, prompt, thread, deltaImages)
|
||||
|
||||
// Needs-attention hook (#93091 item 3): a turn that produced a real
|
||||
// reply (or an explicit pass) is a good turn — clear the badge.
|
||||
// A timed-out turn also returns null but never threw; leaving any
|
||||
// prior badge in place there is the conservative choice.
|
||||
if (reply !== null) {
|
||||
clearBotAttention(groupMemberKey(member))
|
||||
}
|
||||
} catch (error: any) {
|
||||
const reason = String(error?.data?.reason || '').trim()
|
||||
recordGroupActivity(group, {
|
||||
kind: 'failed',
|
||||
member: member.name,
|
||||
thread,
|
||||
...(reason
|
||||
? {
|
||||
reason
|
||||
}
|
||||
: {})
|
||||
})
|
||||
noteBotAttention(groupMemberKey(member), reason || error?.message || error)
|
||||
reply = null // a failed turn is a pass, never a room error
|
||||
}
|
||||
|
||||
// #93127: the turn may have finished AFTER a newer user send bumped
|
||||
// the room epoch. That newer send's loop re-drives this member with
|
||||
// the full delta, so committing this stale result (watermark advance
|
||||
// + append) would double-deliver the same reply. Drop it here —
|
||||
// BEFORE the watermark advance and BEFORE the append. Only a newer
|
||||
// USER entry in THIS thread makes the re-drive premise true: a
|
||||
// cross-thread send bumps the epoch too, but its loop filters this
|
||||
// thread out and would never regenerate the finished reply. The
|
||||
// during-turn tail is anchored by entry id, not index — the history
|
||||
// trim drops entries from the FRONT, so an index slice could
|
||||
// overshoot after a mid-turn trim and silently commit a stale turn.
|
||||
const roomNow = $groupChats.get()[group] || {
|
||||
log: []
|
||||
}
|
||||
const epochNow = roomNow.epoch || 0
|
||||
const anchorId = room.log.length ? room.log[room.log.length - 1].id : null
|
||||
const anchorIdx = anchorId === null ? -1 : roomNow.log.findIndex((e: GroupMessage) => e.id === anchorId)
|
||||
// Anchor trimmed away ⇒ every pre-turn entry was dropped, so every
|
||||
// surviving entry is newer — scanning the whole log stays exact.
|
||||
const turnTail = anchorIdx >= 0 ? roomNow.log.slice(anchorIdx + 1) : roomNow.log
|
||||
const newerUserEntryInThread = turnTail.some(
|
||||
(e: GroupMessage) => e.from?.kind === 'user' && groupThreadOf(e) === thread
|
||||
)
|
||||
if (!shouldCommitMemberTurn(startEpoch, epochNow, newerUserEntryInThread)) {
|
||||
recordGroupActivity(group, {
|
||||
kind: 'cancelled',
|
||||
member: member.name,
|
||||
thread
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// The member has now seen everything up to the pre-reply log length.
|
||||
updateGroupChat(group, (r: GroupChatRoom) => {
|
||||
r.watermarks[markKey] = r.log.length
|
||||
return r
|
||||
})
|
||||
if (reply !== null && !isGroupPassText(reply)) {
|
||||
appendGroupChatEntry(
|
||||
group,
|
||||
{
|
||||
kind: 'member',
|
||||
name: member.name,
|
||||
...(member.remoteSource
|
||||
? {
|
||||
source: member.connectionLabel || member.connectionId
|
||||
}
|
||||
: {})
|
||||
},
|
||||
reply,
|
||||
thread
|
||||
)
|
||||
// Its own message counts as seen too.
|
||||
updateGroupChat(group, (r: GroupChatRoom) => {
|
||||
r.watermarks[markKey] = r.log.length
|
||||
return r
|
||||
})
|
||||
posted += 1
|
||||
spokeThisRound += 1
|
||||
}
|
||||
}
|
||||
if (spokeThisRound === 0) {
|
||||
// #94478: "everyone passed" is NOT the only way a round can go quiet —
|
||||
// responders can be narrowed to members with no new delta while the
|
||||
// thread's tail carries an @mention handoff that was never answered.
|
||||
// Before settling, check for cited members still owed a turn and run
|
||||
// one bounded continuation round for exactly those members. If none
|
||||
// exist (or the continuation also goes quiet), the room genuinely
|
||||
// settled.
|
||||
const pendingKeys = unaddressedGroupMentions(group, members, thread)
|
||||
|
||||
// #94478 review: bound continuation rounds independently of the
|
||||
// message cap so a pathological mention chain can't consume the
|
||||
// room's entire budget on back-and-forth handoffs.
|
||||
continuations += 1
|
||||
if (pendingKeys.length && continuations <= GROUP_CHAT_MAX_CONTINUATIONS) {
|
||||
const citedMembers = members.filter((member: GroupMember) => pendingKeys.includes(groupMemberKey(member)))
|
||||
if (citedMembers.length && posted < GROUP_CHAT_MAX_MESSAGES) {
|
||||
const strandedNow = ($groupChats.get()[group] || {}).stranded || {}
|
||||
const continuationResponders = citedMembers.filter(
|
||||
(member: GroupMember) => !Object.prototype.hasOwnProperty.call(strandedNow, groupMemberKey(member))
|
||||
)
|
||||
for (const member of continuationResponders) {
|
||||
if (!isCurrent() || posted >= GROUP_CHAT_MAX_MESSAGES || continuations > GROUP_CHAT_MAX_CONTINUATIONS) {
|
||||
break
|
||||
}
|
||||
const room = $groupChats.get()[group] || {
|
||||
log: [],
|
||||
watermarks: {}
|
||||
}
|
||||
const memberKey = groupMemberKey(member)
|
||||
const markKey = `${thread}::${memberKey}`
|
||||
const seen = room.watermarks[markKey] || 0
|
||||
const delta = room.log.slice(seen).filter((e: GroupMessage) => groupThreadOf(e) === thread)
|
||||
|
||||
// A cited member always has delta here (the citing reply IS in
|
||||
// its tail); skip defensively anyway so an empty prompt never
|
||||
// fires.
|
||||
if (!delta.length) {
|
||||
continue
|
||||
}
|
||||
const heldEntry = (room.holds || {})[memberKey]
|
||||
if (heldEntry) {
|
||||
continue // holds still apply to continuation turns (#93129)
|
||||
}
|
||||
const prompt = buildGroupChatTurnPrompt({
|
||||
groupName: group,
|
||||
members,
|
||||
viewer: member,
|
||||
// The continuation prompt centers on what the member missed:
|
||||
// everything since its watermark, which includes the reply
|
||||
// that cites it.
|
||||
deltaLines: delta
|
||||
.slice(-GROUP_CHAT_HISTORY_LIMIT)
|
||||
.map((e: GroupMessage) => formatGroupChatLine(e, member.name))
|
||||
})
|
||||
updateGroupChat(group, (r: GroupChatRoom) => {
|
||||
r.turn = member.name
|
||||
return r
|
||||
})
|
||||
let continuationReply: null | string = null
|
||||
try {
|
||||
continuationReply = await runGroupChatMemberTurn(group, member, prompt, thread)
|
||||
if (continuationReply !== null) {
|
||||
clearBotAttention(memberKey)
|
||||
}
|
||||
} catch (error: any) {
|
||||
recordGroupActivity(group, {
|
||||
kind: 'failed',
|
||||
member: member.name,
|
||||
thread
|
||||
})
|
||||
noteBotAttention(memberKey, error?.message || error)
|
||||
continuationReply = null
|
||||
}
|
||||
if (!isCurrent()) {
|
||||
return
|
||||
}
|
||||
updateGroupChat(group, (r: GroupChatRoom) => {
|
||||
r.watermarks[markKey] = r.log.length
|
||||
return r
|
||||
})
|
||||
if (continuationReply !== null && !isGroupPassText(continuationReply)) {
|
||||
appendGroupChatEntry(
|
||||
group,
|
||||
{
|
||||
kind: 'member',
|
||||
name: member.name,
|
||||
...(member.remoteSource
|
||||
? {
|
||||
source: member.connectionLabel || member.connectionId
|
||||
}
|
||||
: {})
|
||||
},
|
||||
continuationReply,
|
||||
thread
|
||||
)
|
||||
updateGroupChat(group, (r: GroupChatRoom) => {
|
||||
r.watermarks[markKey] = r.log.length
|
||||
return r
|
||||
})
|
||||
posted += 1
|
||||
|
||||
// The continuation's own reply may cite someone else — fall
|
||||
// through to the normal loop so the next round handles it via
|
||||
// the same responder machinery. Reaching here means the loop
|
||||
// continues rather than settling; the outer for-loop's next
|
||||
// iteration re-evaluates everything.
|
||||
spokeThisRound += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (spokeThisRound === 0) {
|
||||
// Genuinely nothing left to say — including after the continuation
|
||||
// attempt above produced no spoken turns. Settle honestly, but if
|
||||
// cited members are STILL owed a turn and only the continuation /
|
||||
// message caps stopped us from driving them, this is a capped
|
||||
// exit, not consensus. (#94478)
|
||||
if (
|
||||
pendingKeys.length &&
|
||||
(continuations > GROUP_CHAT_MAX_CONTINUATIONS || posted >= GROUP_CHAT_MAX_MESSAGES)
|
||||
) {
|
||||
exitKind = 'capped'
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// All GROUP_CHAT_MAX_ROUNDS rounds ran with someone still speaking —
|
||||
// the round cap ended the drive, not consensus. (#94478)
|
||||
exitKind = 'capped'
|
||||
} finally {
|
||||
if (isCurrent()) {
|
||||
recordGroupActivity(group, {
|
||||
kind: exitKind,
|
||||
member: null,
|
||||
thread
|
||||
})
|
||||
updateGroupChat(group, (r: GroupChatRoom) => {
|
||||
r.running = false
|
||||
r.turn = null
|
||||
return r
|
||||
})
|
||||
|
||||
// #89545: the loop's harvest pass only ran at the top of each round of
|
||||
// an ACTIVE loop — a member whose turn timed out after the final round
|
||||
// stayed stranded until the user's NEXT send. Poll for the late reply
|
||||
// in the background (bounded) so long work is late, never lost.
|
||||
// (window feature-detect: the engine also runs under node in tests.)
|
||||
const strandedLeft = Object.keys(($groupChats.get()[group] || {}).stranded || {})
|
||||
if (strandedLeft.length && typeof window !== 'undefined') {
|
||||
void harvestStrandedUntilSettled(group, members, thread)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Bounded background harvest for members whose replies outlived the turn
|
||||
* loop. Polls every 5s for up to 5 minutes; stops early when nothing is
|
||||
* stranded, a new loop takes the room over (it harvests on its own), or the
|
||||
* room record disappears (disband). */
|
||||
async function harvestStrandedUntilSettled(group: string, members: GroupMember[], thread: string) {
|
||||
const HARVEST_INTERVAL_MS = 5000
|
||||
const HARVEST_MAX_TRIES = 60
|
||||
for (let attempt = 0; attempt < HARVEST_MAX_TRIES; attempt++) {
|
||||
await new Promise(resolve => window.setTimeout(resolve, HARVEST_INTERVAL_MS))
|
||||
const room = $groupChats.get()[group]
|
||||
if (!room || room.running) {
|
||||
return
|
||||
}
|
||||
const stranded = room.stranded || {}
|
||||
if (!Object.keys(stranded).length) {
|
||||
return
|
||||
}
|
||||
for (const member of members) {
|
||||
if (Object.prototype.hasOwnProperty.call(stranded, groupMemberKey(member))) {
|
||||
try {
|
||||
await harvestStrandedGroupReply(group, member)
|
||||
} catch {
|
||||
// Best-effort: the next tick retries; the bound stops runaways.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
recordGroupActivity(group, {
|
||||
kind: 'failed',
|
||||
member: null,
|
||||
thread
|
||||
})
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
export function sendToGroupChat(
|
||||
group: string,
|
||||
members: GroupMember[],
|
||||
text: string,
|
||||
thread?: null | string,
|
||||
images?: Attachment[]
|
||||
): null | string {
|
||||
const trimmed = String(text || '').trim()
|
||||
const attached = Array.isArray(images) ? images.filter((img: Attachment) => img && img.data) : []
|
||||
if ((!trimmed && !attached.length) || !members.length) {
|
||||
return null
|
||||
}
|
||||
const target = thread || mintGroupThreadId()
|
||||
$groupNeedsYou.set({
|
||||
...$groupNeedsYou.get(),
|
||||
[group]: false
|
||||
})
|
||||
// Refresh the durable room roster on every send. This backfills rooms made
|
||||
// by older Desktop builds and keeps the gateway mirror complete even when
|
||||
// members overlap across multiple groups.
|
||||
updateGroupChat(group, (room: GroupChatRoom) => {
|
||||
room.members = durableGroupChatMembers(members)
|
||||
return room
|
||||
})
|
||||
const sent = appendGroupChatEntry(
|
||||
group,
|
||||
{
|
||||
kind: 'user',
|
||||
name: 'You'
|
||||
},
|
||||
trimmed,
|
||||
target,
|
||||
attached
|
||||
)
|
||||
const wasRunning = ($groupChats.get()[group] || {}).running === true
|
||||
updateGroupChat(group, (room: GroupChatRoom) => {
|
||||
room.epoch = (room.epoch || 0) + 1
|
||||
room.running = true
|
||||
// #93129: user text is the ONLY input that changes member holds. An
|
||||
// explicit "stop @member" sets a sticky hold; "@member resume" (or
|
||||
// @all resume, or any direct non-stop mention of the held member)
|
||||
// releases it. Bot replies never flow through this function.
|
||||
room.holds = applyGroupHoldDirective(
|
||||
room.holds,
|
||||
parseGroupChatMentions(trimmed, members),
|
||||
trimmed,
|
||||
{
|
||||
at: sent?.at,
|
||||
byMessageId: sent?.id,
|
||||
thread: target
|
||||
},
|
||||
members.map((member: GroupMember) => groupMemberKey(member))
|
||||
)
|
||||
return room
|
||||
})
|
||||
recordGroupActivity(group, {
|
||||
kind: 'queued',
|
||||
member: 'You',
|
||||
thread: target
|
||||
})
|
||||
if (!wasRunning) {
|
||||
void runGroupChatRounds(group, members, target).catch(() => {
|
||||
updateGroupChat(group, (r: GroupChatRoom) => {
|
||||
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: GroupChatRoom) => {
|
||||
r.running = false
|
||||
return r
|
||||
})
|
||||
})
|
||||
}, 250)
|
||||
}
|
||||
return target
|
||||
}
|
||||
@@ -0,0 +1,719 @@
|
||||
/**
|
||||
* One member's turn: its hidden per-group session, the submit/poll loop that
|
||||
* runs it, the pending clarify/approval prompts mirrored out of it, and the
|
||||
* late-reply harvest for a turn that timed out.
|
||||
*
|
||||
* Room-level sequencing lives in group-rounds.ts, which drives these.
|
||||
*/
|
||||
|
||||
import { host } from '@hermes/plugin-sdk'
|
||||
|
||||
import { recordGroupActivity } from './group-activity'
|
||||
import { $groupChats, $groupClarify, $groupNeedsYou, appendGroupChatEntry, updateGroupChat } from './group-chat'
|
||||
import type { GroupChatRoom } from './group-chat'
|
||||
import { groupMemberKey, groupSessionOwner } from './group-membership'
|
||||
import { botConnectionRoute, requestForBot } from './routing'
|
||||
import type { Attachment, GroupMember, GroupPrompt, GroupPromptQuestion, ProfileRoute } from './types'
|
||||
|
||||
/** "(pass)" (loosely: pass / (pass) / pass.) or empty = the member stayed silent. */
|
||||
export function isGroupPassText(text: unknown) {
|
||||
const trimmed = String(text || '').trim()
|
||||
if (!trimmed) {
|
||||
return true
|
||||
}
|
||||
return /^\(?\s*pass\s*\)?\.?$/i.test(trimmed)
|
||||
}
|
||||
|
||||
/** One transcript entry in a `session.resume` snapshot, as the turn harvester
|
||||
* reads it — the session's own message shape, not the plugin's GroupMessage.
|
||||
* `content` is a plain string on most providers and a part array on the rest. */
|
||||
interface GroupTurnTranscriptMessage {
|
||||
content?: string | Array<string | { text?: string }>
|
||||
role?: string
|
||||
text?: string
|
||||
}
|
||||
|
||||
/** #94376: pick the reply a finished turn should surface among the messages
|
||||
* appended since `before`. Scans newest-first and prefers the last
|
||||
* substantive (non-pass) assistant answer over a trailing pass — a Codex
|
||||
* intent-ack continuation nudge can land a complete answer and then get a
|
||||
* synthetic "(pass)" to the nudge itself, which must not hide the answer.
|
||||
* When only pass text exists in range, returns the newest (last
|
||||
* chronological) one rather than the oldest. Returns null only when no
|
||||
* assistant message appears in that range. */
|
||||
function pickGroupTurnReply(messages: GroupTurnTranscriptMessage[], before: number): null | string {
|
||||
let passText: null | string = null
|
||||
for (let i = messages.length - 1; i >= before; i--) {
|
||||
const msg = messages[i]
|
||||
if (msg?.role !== 'assistant') {
|
||||
continue
|
||||
}
|
||||
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 replyText = String(text).trim()
|
||||
if (isGroupPassText(replyText)) {
|
||||
if (passText === null) {
|
||||
passText = replyText
|
||||
}
|
||||
continue
|
||||
}
|
||||
return replyText
|
||||
}
|
||||
return passText
|
||||
}
|
||||
|
||||
/** A clarify question blocking inside a member's session, as `session.resume`
|
||||
* reports it. Older backends omit the field entirely. */
|
||||
interface GroupPendingClarify {
|
||||
choices?: string[]
|
||||
multi_select?: unknown
|
||||
question?: unknown
|
||||
questions?: GroupPromptQuestion[]
|
||||
request_id?: string
|
||||
}
|
||||
|
||||
/** A command approval blocking inside a member's session, same wire as the
|
||||
* 1:1 approval card. */
|
||||
interface GroupPendingApproval {
|
||||
choices?: string[]
|
||||
command?: unknown
|
||||
description?: unknown
|
||||
request_id?: string
|
||||
}
|
||||
|
||||
/** The `session.resume` fields the room engine reads off a member's hidden
|
||||
* per-group session. */
|
||||
interface GroupSessionSnapshot {
|
||||
inflight?: boolean
|
||||
message_count?: number
|
||||
messages?: GroupTurnTranscriptMessage[]
|
||||
pending_approval?: GroupPendingApproval
|
||||
pending_clarify?: GroupPendingClarify
|
||||
running?: boolean
|
||||
session_id?: string
|
||||
session_key?: string
|
||||
}
|
||||
|
||||
/** A member's per-group session, resolved for one turn. */
|
||||
interface GroupMemberSessionHandle {
|
||||
/** Live runtime id every RPC in this turn targets. */
|
||||
runtime: null | string
|
||||
/** Durable id persisted in `room.sessions`; `true` is the legacy sentinel. */
|
||||
stored?: null | string | true
|
||||
}
|
||||
|
||||
/** 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: string, member: GroupMember): Promise<GroupMemberSessionHandle> {
|
||||
const room = $groupChats.get()[group] || {}
|
||||
// New rooms title member sessions by their immutable roomId so a
|
||||
// same-name recreate never resumes the old room's sessions by title;
|
||||
// legacy rooms without a roomId fall back to the display name.
|
||||
const title = `Group: ${room.roomId || group}`
|
||||
const key = groupMemberKey(member)
|
||||
const known = room.sessions && room.sessions[key]
|
||||
|
||||
// Try resuming what we know (stored sid first, then title lookup).
|
||||
//
|
||||
// FAIL CLOSED on a transient lookup failure — mirrors the sibling fix in
|
||||
// findExistingCanonicalChat (87b645f52c). session.resume signals "this
|
||||
// target genuinely doesn't exist" with JSON-RPC code 4007; every other
|
||||
// failure (network blip, the backend still warming up after a restart,
|
||||
// an oversized-resume refusal) means the real session might still be
|
||||
// there and must not be read as "no session, mint a new one" — that
|
||||
// forks the member's real history, and the fork silently overwrites
|
||||
// room.sessions[key] so the old session becomes unreachable from the
|
||||
// room. Only a genuine 4007 on BOTH targets means there truly is nothing
|
||||
// to resume yet, so the loop falls through to session.create below.
|
||||
for (const target of [known, title]) {
|
||||
if (!target || target === true) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
const res = (await requestForBot(member, 'session.resume', {
|
||||
session_id: target,
|
||||
profile: member.name,
|
||||
omit_messages: true
|
||||
})) as GroupSessionSnapshot
|
||||
if (res?.session_id) {
|
||||
// TODO(bot-mode-types): `known` is `room.sessions[key]`, which the
|
||||
// domain model types `string | true` — and the `target === true` skip
|
||||
// above shows the legacy `true` sentinel is expected here. A backend
|
||||
// that answers the title resume without a `session_key` therefore
|
||||
// stores `true` back into room.sessions and hands `true` on as the
|
||||
// durable id, which later rides into `session_id` on the recovery
|
||||
// resume and on session.interrupt. Typed as-written.
|
||||
const stored = res.session_key || known
|
||||
if (stored) {
|
||||
updateGroupChat(group, (current: GroupChatRoom) => {
|
||||
current.sessions = {
|
||||
...(current.sessions || {}),
|
||||
[key]: stored
|
||||
}
|
||||
current.sessionOwners = {
|
||||
...(current.sessionOwners || {}),
|
||||
[key]: groupSessionOwner(member)
|
||||
}
|
||||
return current
|
||||
})
|
||||
}
|
||||
return {
|
||||
runtime: res.session_id,
|
||||
stored
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error?.code !== 4007) {
|
||||
const detail = error instanceof Error && error.message ? ` (${error.message})` : ''
|
||||
throw new Error(`Could not check ${member?.name || 'member'}'s group session${detail} — not starting a new one`)
|
||||
}
|
||||
/* genuinely doesn't exist (4007) — try the next target / fall through to create */
|
||||
}
|
||||
}
|
||||
const created = (await requestForBot(member, 'session.create', {
|
||||
profile: member.name,
|
||||
title,
|
||||
// Room member sessions are plumbing — always hidden from the sidebar.
|
||||
hidden: true
|
||||
})) as { session_id?: string; stored_session_id?: string }
|
||||
const stored = created?.stored_session_id || null
|
||||
if (stored) {
|
||||
updateGroupChat(group, (r: GroupChatRoom) => {
|
||||
r.sessions = {
|
||||
...(r.sessions || {}),
|
||||
[key]: stored
|
||||
}
|
||||
r.sessionOwners = {
|
||||
...(r.sessionOwners || {}),
|
||||
[key]: groupSessionOwner(member)
|
||||
}
|
||||
return r
|
||||
})
|
||||
}
|
||||
return {
|
||||
runtime: created?.session_id || null,
|
||||
stored
|
||||
}
|
||||
}
|
||||
const GROUP_TURN_TIMEOUT_MS = 180000
|
||||
const GROUP_TURN_POLL_MS = 2000
|
||||
|
||||
// --- group-turn session-lease helpers (#93602) ------------------------------
|
||||
// A member turn is a session-scoped RPC SEQUENCE (resume → attach → submit →
|
||||
// poll) issued with the runtime id its first RPC minted. requestForBot routes
|
||||
// each RPC through a per-request socket lease (retained:false secondaries in
|
||||
// store/gateway), so between two RPCs the refcount can hit 0, the leased
|
||||
// socket closes, the gateway detaches the runtime session on WS disconnect,
|
||||
// and the orphan reaper frees it — the next RPC then fails 4001 "not in
|
||||
// memory" and the bot goes silent in the room.
|
||||
|
||||
/** A gateway rejection as it reaches the room engine: an `Error`, a raw
|
||||
* JSON-RPC error object, or (across a realm boundary) a bare string. */
|
||||
interface GatewayErrorLike {
|
||||
code?: number
|
||||
data?: { reason?: unknown }
|
||||
message?: unknown
|
||||
}
|
||||
|
||||
/** 4001-class "the runtime session was reaped" failure. Distinct from 4007
|
||||
* ("genuinely never existed"), which must keep flowing to session.create. */
|
||||
function isSessionGoneError(error: GatewayErrorLike | null | undefined): boolean {
|
||||
if (!error || error.code === 4007) {
|
||||
return false
|
||||
}
|
||||
if (error.code === 4001) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Duck-typed (not instanceof): gateway errors can cross realm boundaries.
|
||||
const message = typeof error?.message === 'string' ? error.message : typeof error === 'string' ? error : ''
|
||||
return message.includes('not in memory') || /session not found/i.test(message)
|
||||
}
|
||||
// --- end group-turn session-lease helpers ---
|
||||
|
||||
/** Hold the member's pooled socket open for the WHOLE turn. Feature-detected:
|
||||
* hosts without retainProfile (or members on the active gateway, which never
|
||||
* closes mid-turn) get a no-op release. A failed acquire must not kill the
|
||||
* turn — the catch-retry on submit still covers the race. */
|
||||
async function retainGroupTurnRoute(member: GroupMember): Promise<() => void> {
|
||||
const noop = () => undefined
|
||||
let route: ProfileRoute | null = null
|
||||
try {
|
||||
route = botConnectionRoute(member)
|
||||
} catch {
|
||||
return noop
|
||||
}
|
||||
if (!route || typeof host.retainProfile !== 'function') {
|
||||
return noop
|
||||
}
|
||||
try {
|
||||
const release = await host.retainProfile(route)
|
||||
return typeof release === 'function' ? release : noop
|
||||
} catch {
|
||||
return noop
|
||||
}
|
||||
}
|
||||
|
||||
/** prompt.submit with one belt-and-braces retry: when the runtime session was
|
||||
* reaped between minting and submitting (4001 class), re-resume via the
|
||||
* STORED id — the durable identity — to mint a fresh runtime id, and submit
|
||||
* exactly once more. Returns the runtime id the submit actually landed on so
|
||||
* the poll loop keeps a live fallback target. */
|
||||
async function submitGroupTurnPrompt(
|
||||
member: GroupMember,
|
||||
runtime: string,
|
||||
stored: null | string | true | undefined,
|
||||
text: string
|
||||
): Promise<string> {
|
||||
try {
|
||||
await requestForBot(member, 'prompt.submit', {
|
||||
session_id: runtime,
|
||||
text
|
||||
})
|
||||
return runtime
|
||||
} catch (error: any) {
|
||||
if (!isSessionGoneError(error) || !stored) {
|
||||
throw error
|
||||
}
|
||||
const res = (await requestForBot(member, 'session.resume', {
|
||||
session_id: stored,
|
||||
profile: member.name,
|
||||
omit_messages: true
|
||||
})) as GroupSessionSnapshot
|
||||
const fresh = res?.session_id
|
||||
if (!fresh) {
|
||||
throw error
|
||||
}
|
||||
await requestForBot(member, 'prompt.submit', {
|
||||
session_id: fresh,
|
||||
text
|
||||
})
|
||||
return fresh
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
/** Mirror a member's pending prompt — clarify question OR command approval —
|
||||
* from its resume snapshot into the room store, keyed
|
||||
* `${group}::${memberKey}` (#90694). Returns true while a prompt is
|
||||
* blocking, so the turn poll can extend its deadline — a waiting prompt
|
||||
* must not be eaten by the group-turn timeout. Feature-detected: older
|
||||
* backends without `pending_clarify`/`pending_approval` in the resume
|
||||
* payload always sync to "no prompt". Clarify wins when both are somehow
|
||||
* present (approvals resolve inside tool batches; clarify is the outer
|
||||
* blocker). */
|
||||
function syncGroupClarify(group: string, member: GroupMember, state: GroupSessionSnapshot | null): boolean {
|
||||
const key = `${group}::${groupMemberKey(member)}`
|
||||
const clarify = state && typeof state.pending_clarify === 'object' ? state.pending_clarify : null
|
||||
// The `!requestId` bail below is what makes the approval branch reachable,
|
||||
// so an approval read there is never the null arm of this ternary — a fact
|
||||
// control-flow analysis can't carry across the two separate locals.
|
||||
const approval = (
|
||||
state && typeof state.pending_approval === 'object' ? state.pending_approval : null
|
||||
) as GroupPendingApproval
|
||||
const pending = clarify || approval
|
||||
const requestId = pending?.request_id || null
|
||||
const all = $groupClarify.get()
|
||||
const current = all[key]
|
||||
if (!requestId) {
|
||||
if (current) {
|
||||
const next = {
|
||||
...all
|
||||
}
|
||||
delete next[key]
|
||||
$groupClarify.set(next)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Same request already mirrored — keep the object identity so the card
|
||||
// doesn't lose its draft to a re-render.
|
||||
if (current?.requestId === requestId) {
|
||||
return true
|
||||
}
|
||||
const base = {
|
||||
requestId,
|
||||
group,
|
||||
member: member.name,
|
||||
memberKey: groupMemberKey(member),
|
||||
// approval.respond keys on the session, not just the request — carry the
|
||||
// runtime id the snapshot came from.
|
||||
sessionId: state?.session_id || null,
|
||||
at: Date.now()
|
||||
}
|
||||
$groupClarify.set({
|
||||
...all,
|
||||
[key]: clarify
|
||||
? {
|
||||
...base,
|
||||
kind: 'clarify',
|
||||
question: typeof clarify.question === 'string' ? clarify.question : '',
|
||||
choices: Array.isArray(clarify.choices) ? clarify.choices.filter(c => typeof c === 'string' && c) : [],
|
||||
multiSelect: Boolean(clarify.multi_select),
|
||||
// Batch clarifies carry `questions`; the room card answers them
|
||||
// one wire call per question, mirroring the 1:1 batch contract.
|
||||
questions: Array.isArray(clarify.questions) ? clarify.questions : null
|
||||
}
|
||||
: {
|
||||
...base,
|
||||
kind: 'approval',
|
||||
question: typeof approval.description === 'string' ? approval.description : '',
|
||||
command: typeof approval.command === 'string' ? approval.command : '',
|
||||
// The server precomputes the choice set from allow_permanent
|
||||
// (once/session/always/deny); fall back to the minimal pair.
|
||||
choices:
|
||||
Array.isArray(approval.choices) && approval.choices.length
|
||||
? approval.choices.filter(c => typeof c === 'string' && c)
|
||||
: ['once', 'deny'],
|
||||
multiSelect: false,
|
||||
questions: null
|
||||
}
|
||||
})
|
||||
// A blocked member is a question for the human — badge the room.
|
||||
$groupNeedsYou.set({
|
||||
...$groupNeedsYou.get(),
|
||||
[group]: true
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
/** Drop every mirrored clarify belonging to `group` (disband/rename). */
|
||||
export function clearGroupClarify(group: string) {
|
||||
const all = $groupClarify.get()
|
||||
const next: Record<string, GroupPrompt> = {}
|
||||
let changed = false
|
||||
for (const [key, value] of Object.entries<GroupPrompt>(all)) {
|
||||
if (value?.group === group) {
|
||||
changed = true
|
||||
} else {
|
||||
next[key] = value
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
$groupClarify.set(next)
|
||||
}
|
||||
}
|
||||
|
||||
/** Answer a member's pending prompt from the room. Routes to the member's
|
||||
* OWN source (requestForBot), so cross-connection members work.
|
||||
* - clarify: `clarify.respond`; batch questions send one respond per
|
||||
* question, sequentially — the LAST lock resolves the blocked tool
|
||||
* server-side (same contract as the 1:1 batch card). allow_expired
|
||||
* server-side makes racing the timeout harmless.
|
||||
* - approval: `approval.respond` with the choice (once/session/always/deny),
|
||||
* keyed by session + request_id — the same wire the 1:1 approval card
|
||||
* and native notifications use. */
|
||||
export async function answerGroupClarify(
|
||||
entry: GroupPrompt,
|
||||
member: GroupMember,
|
||||
answers: Record<string, string> | string | undefined
|
||||
) {
|
||||
if (entry.kind === 'approval') {
|
||||
await requestForBot(member, 'approval.respond', {
|
||||
session_id: entry.sessionId || undefined,
|
||||
request_id: entry.requestId,
|
||||
choice: typeof answers === 'string' && answers ? answers : 'deny'
|
||||
})
|
||||
} else if (entry.questions && entry.questions.length) {
|
||||
for (const question of entry.questions) {
|
||||
// Question ids are opaque on the wire (`GroupPrompt.questions` types
|
||||
// them `unknown`); the batch card keys its answer bag by exactly them.
|
||||
const qid = (question?.qid ?? question?.id) as string
|
||||
await requestForBot(member, 'clarify.respond', {
|
||||
request_id: entry.requestId,
|
||||
question_id: qid,
|
||||
answer: (answers as Record<string, string>)?.[qid] ?? ''
|
||||
})
|
||||
}
|
||||
} else {
|
||||
await requestForBot(member, 'clarify.respond', {
|
||||
request_id: entry.requestId,
|
||||
answer: typeof answers === 'string' ? answers : ''
|
||||
})
|
||||
}
|
||||
const all = $groupClarify.get()
|
||||
const key = `${entry.group}::${entry.memberKey}`
|
||||
if (all[key]?.requestId === entry.requestId) {
|
||||
const next = {
|
||||
...all
|
||||
}
|
||||
delete next[key]
|
||||
$groupClarify.set(next)
|
||||
}
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
export async function runGroupChatMemberTurn(
|
||||
group: string,
|
||||
member: GroupMember,
|
||||
prompt: string,
|
||||
thread: string,
|
||||
images?: Attachment[]
|
||||
): Promise<null | string> {
|
||||
// #93602: hold the member's route socket for the whole turn. Without the
|
||||
// lease, every RPC below rides its own request-scoped socket lease; the
|
||||
// socket that minted `runtime` can close between RPCs, the gateway reaps
|
||||
// the runtime session, and prompt.submit dies 4001 — the bot goes silent.
|
||||
const releaseTurnLease = await retainGroupTurnRoute(member)
|
||||
try {
|
||||
return await runGroupChatMemberTurnLeased(group, member, prompt, thread, images)
|
||||
} finally {
|
||||
releaseTurnLease()
|
||||
}
|
||||
}
|
||||
async function runGroupChatMemberTurnLeased(
|
||||
group: string,
|
||||
member: GroupMember,
|
||||
prompt: string,
|
||||
thread: string,
|
||||
images?: Attachment[]
|
||||
): Promise<null | string> {
|
||||
const { runtime, stored } = await ensureGroupChatSession(group, member)
|
||||
if (!runtime) {
|
||||
return null
|
||||
}
|
||||
|
||||
// #91868/#94569: remember the epoch this turn was dispatched under so the
|
||||
// poll loop below can tell an explicit stop from ordinary room churn.
|
||||
const dispatchEpoch = ($groupChats.get()[group] || {}).epoch || 0
|
||||
const memberKey = groupMemberKey(member)
|
||||
recordGroupActivity(group, {
|
||||
kind: 'working',
|
||||
member: member.name,
|
||||
thread
|
||||
})
|
||||
|
||||
// Baseline: how many messages exist before our submit.
|
||||
let before = 0
|
||||
try {
|
||||
const pre = (await requestForBot(member, 'session.resume', {
|
||||
session_id: stored || runtime,
|
||||
profile: member.name
|
||||
})) as GroupSessionSnapshot
|
||||
before = Array.isArray(pre?.messages) ? pre.messages.length : pre?.message_count || 0
|
||||
} catch {
|
||||
/* lazy session — zero messages */
|
||||
}
|
||||
|
||||
// Stage this delta's attachments into the member's session so the model
|
||||
// receives the actual payload with the prompt — the same attach RPCs the
|
||||
// 1:1 chat uses (they also work cross-connection, where the member's
|
||||
// gateway can't see this machine's files). Images queue as vision tiles,
|
||||
// PDFs render per-page via pdf.attach, and other files materialize in the
|
||||
// session workspace (their @file: refs are appended to the prompt so the
|
||||
// member's file tools can read them). A failed attach degrades that
|
||||
// member to text-only; the transcript line still names the attachment so
|
||||
// the member knows something was shared.
|
||||
const fileRefs: string[] = []
|
||||
for (const img of Array.isArray(images) ? images : []) {
|
||||
if (!img || typeof img.data !== 'string' || !img.data) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
if (img.kind === 'pdf') {
|
||||
await requestForBot(member, 'pdf.attach', {
|
||||
session_id: runtime,
|
||||
content_base64: img.data,
|
||||
filename: img.name || 'attachment.pdf'
|
||||
})
|
||||
} else if (img.kind === 'file') {
|
||||
const res = (await requestForBot(member, 'file.attach', {
|
||||
session_id: runtime,
|
||||
data_url: img.data,
|
||||
name: img.name || 'attachment'
|
||||
})) as { ref_text?: string }
|
||||
if (res?.ref_text) {
|
||||
fileRefs.push(`${img.name || 'attachment'} → ${res.ref_text}`)
|
||||
}
|
||||
} else {
|
||||
await requestForBot(member, 'image.attach_bytes', {
|
||||
session_id: runtime,
|
||||
content_base64: img.data,
|
||||
filename: img.name || 'attachment.png'
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
/* text-only fallback for this member */
|
||||
}
|
||||
}
|
||||
const turnText = fileRefs.length
|
||||
? `${prompt}\n\nAttached files staged in your session workspace:\n${fileRefs.join('\n')}`
|
||||
: prompt
|
||||
|
||||
// #93602: one-shot recovery when the runtime session was reaped between
|
||||
// minting and submitting. Tracks the runtime id the submit landed on so
|
||||
// the poll fallback below targets a live session.
|
||||
const liveRuntime = await submitGroupTurnPrompt(member, runtime, stored, turnText)
|
||||
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))
|
||||
|
||||
// #91868/#94569: an explicit stop (stopGroupThread) bumped the epoch AND
|
||||
// held this member — the member's session was interrupted, so nothing is
|
||||
// coming; abandon the poll instead of grinding until the deadline. Both
|
||||
// conditions on purpose: an ordinary newer send bumps the epoch WITHOUT
|
||||
// a hold, and that turn must keep polling so finished work can still be
|
||||
// delivered (the #93127 commit check decides its fate, not this loop).
|
||||
const roomDuringPoll = $groupChats.get()[group] || {}
|
||||
if ((roomDuringPoll.epoch || 0) !== dispatchEpoch && (roomDuringPoll.holds || {})[memberKey]) {
|
||||
return null
|
||||
}
|
||||
let state: GroupSessionSnapshot | null = null
|
||||
try {
|
||||
state = (await requestForBot(member, 'session.resume', {
|
||||
session_id: stored || liveRuntime,
|
||||
profile: member.name
|
||||
})) as GroupSessionSnapshot
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
const messages = Array.isArray(state?.messages) ? state.messages : []
|
||||
const busy = Boolean(state?.inflight || state?.running)
|
||||
// A clarify blocking inside the member's session is a question for the
|
||||
// HUMAN (#90694) — mirror it into the room store so a card renders, and
|
||||
// hold the turn open: the member isn't stalling, it's waiting on us.
|
||||
const awaitingUser = syncGroupClarify(group, member, state)
|
||||
const done = !busy && !awaitingUser
|
||||
if (messages.length > before && done) {
|
||||
const replyText = pickGroupTurnReply(messages, before)
|
||||
if (replyText !== null) {
|
||||
recordGroupActivity(group, {
|
||||
kind: isGroupPassText(replyText) ? 'passed' : 'replied',
|
||||
member: member.name,
|
||||
thread
|
||||
})
|
||||
return replyText
|
||||
}
|
||||
recordGroupActivity(group, {
|
||||
kind: 'passed',
|
||||
member: member.name,
|
||||
thread
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
// Still visibly working — or waiting on the user's answer to a clarify:
|
||||
// extend the deadline (never past the hard cap). A pending question must
|
||||
// outlive the base turn timeout or it dies unanswered at 3 minutes.
|
||||
if (busy || awaitingUser) {
|
||||
deadline = Math.min(started + GROUP_TURN_HARD_CAP_MS, Math.max(deadline, Date.now() + GROUP_TURN_TIMEOUT_MS))
|
||||
}
|
||||
}
|
||||
|
||||
// Timeout — clear any still-mirrored question card (the server-side
|
||||
// clarify timeout runs its own course) and read as a pass, but remember the baseline + thread
|
||||
// (runtime-only) so the finished reply can be posted late into the RIGHT
|
||||
// thread instead of vanishing.
|
||||
recordGroupActivity(group, {
|
||||
kind: 'timed-out',
|
||||
member: member.name,
|
||||
thread
|
||||
})
|
||||
syncGroupClarify(group, member, null)
|
||||
updateGroupChat(group, (r: GroupChatRoom) => {
|
||||
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. */
|
||||
export async function harvestStrandedGroupReply(group: string, member: GroupMember) {
|
||||
const memberKey = groupMemberKey(member)
|
||||
const room = $groupChats.get()[group] || {}
|
||||
const marker = room.stranded?.[memberKey]
|
||||
// Markers were a bare number before threads; normalize both shapes.
|
||||
const strandedBefore = typeof marker === 'number' ? marker : marker?.before
|
||||
const strandedThread = (typeof marker === 'object' && marker?.thread) || 'legacy'
|
||||
if (typeof strandedBefore !== 'number') {
|
||||
return
|
||||
}
|
||||
let state: GroupSessionSnapshot | null = null
|
||||
try {
|
||||
const stored = room.sessions?.[memberKey]
|
||||
state = (await requestForBot(member, 'session.resume', {
|
||||
session_id: stored || `Group: ${room.roomId || group}`,
|
||||
profile: member.name
|
||||
})) as GroupSessionSnapshot
|
||||
} catch {
|
||||
return // source unreachable — leave the marker for the next boundary
|
||||
}
|
||||
if (state?.inflight || state?.running) {
|
||||
return // still grinding — keep waiting
|
||||
}
|
||||
|
||||
// A stranded member blocked on a clarify is not "grinding" — surface the
|
||||
// question card (#90694) and keep the marker until it resolves.
|
||||
if (syncGroupClarify(group, member, state)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Done (or dead): the marker is consumed either way.
|
||||
updateGroupChat(group, (r: GroupChatRoom) => {
|
||||
const next = {
|
||||
...(r.stranded || {})
|
||||
}
|
||||
delete next[memberKey]
|
||||
r.stranded = next
|
||||
return r
|
||||
})
|
||||
const messages = Array.isArray(state?.messages) ? state.messages : []
|
||||
if (messages.length <= strandedBefore) {
|
||||
return
|
||||
}
|
||||
const reply = pickGroupTurnReply(messages, strandedBefore)
|
||||
if (reply && !isGroupPassText(reply)) {
|
||||
recordGroupActivity(group, {
|
||||
kind: 'delivered',
|
||||
member: member.name,
|
||||
thread: strandedThread
|
||||
})
|
||||
appendGroupChatEntry(
|
||||
group,
|
||||
{
|
||||
kind: 'member',
|
||||
name: member.name,
|
||||
...(member.remoteSource
|
||||
? {
|
||||
source: member.connectionLabel || member.connectionId
|
||||
}
|
||||
: {})
|
||||
},
|
||||
reply,
|
||||
strandedThread
|
||||
)
|
||||
updateGroupChat(group, (r: GroupChatRoom) => {
|
||||
r.watermarks[`${strandedThread}::${memberKey}`] = r.log.length
|
||||
return r
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Roster-display hiding: the session-only reveal toggle, the two per-row
|
||||
* predicates every surface reads, and the selection re-home a hide performs.
|
||||
*
|
||||
* A leaf by design. Hiding is presentation, so it knows nothing about the
|
||||
* rows that render it — the bot row, the roster pane and selection
|
||||
* reconciliation all read these and none of them owns the state.
|
||||
*/
|
||||
|
||||
import { atom } from '@hermes/plugin-sdk'
|
||||
|
||||
import { $selectedBot } from './bot-state'
|
||||
import { $botMeta, $lastRoster, botSelectionKey, isDefaultBot } from './data'
|
||||
import type { BotMetaSnapshot } from './data'
|
||||
import { botRosterMeta } from './routing'
|
||||
import type { RosterRow } from './types'
|
||||
|
||||
// ── hidden bots (right-click → Hide Bot) ────────────────────────────────────
|
||||
// Hiding is a ROSTER-DISPLAY concern only: a hidden bot keeps working,
|
||||
// remains mentionable, keeps group membership, and any open chat stays open.
|
||||
|
||||
/** Session-only view toggle: reveal hidden bots (dimmed) in the roster. */
|
||||
export const $showHiddenBots = atom(false)
|
||||
export function isBotHidden(bot: RosterRow, metaByName: BotMetaSnapshot) {
|
||||
return Boolean(botRosterMeta(bot, metaByName)?.hidden)
|
||||
}
|
||||
export function isBotPinned(bot: RosterRow, metaByName: BotMetaSnapshot) {
|
||||
return Boolean(botRosterMeta(bot, metaByName)?.pinned)
|
||||
}
|
||||
|
||||
/** Hiding the selected bot re-homes the selection to the next visible owner. */
|
||||
export function fallbackSelectionAfterHide(name: string) {
|
||||
if ($selectedBot.get() !== name) {
|
||||
return
|
||||
}
|
||||
const meta = $botMeta.get()
|
||||
const visible = $lastRoster.get().filter(bot => botSelectionKey(bot) !== name && !botRosterMeta(bot, meta)?.hidden)
|
||||
if (visible.length) {
|
||||
$selectedBot.set(botSelectionKey(visible[0]))
|
||||
return
|
||||
}
|
||||
const defaultBot = $lastRoster.get().find(bot => isDefaultBot(bot) && !botRosterMeta(bot, meta)?.hidden)
|
||||
if (defaultBot && botSelectionKey(defaultBot) !== name) {
|
||||
$selectedBot.set(botSelectionKey(defaultBot))
|
||||
} else if (!$lastRoster.get().some(isDefaultBot)) {
|
||||
// Legacy sole-local rosters can transiently omit the default row. Keep the
|
||||
// historic fallback rather than leaving Routines attached to a hidden bot.
|
||||
$selectedBot.set('default')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Deleted-connection roster hygiene: the pure half of the #93492 sweep.
|
||||
*
|
||||
* Marking a group-chat member descriptor whose connection is gone does not
|
||||
* depend on where the rooms live, so it sits below the room store — the sweep
|
||||
* in plugin.tsx drives these against the live atom.
|
||||
*/
|
||||
|
||||
import type { GroupChat, GroupMember } from './types'
|
||||
|
||||
// ── deleted-connection roster hygiene (#93492 root cause) ───────────────────
|
||||
// Deleting a cloud/remote connection used to leave every persisted group-chat
|
||||
// member descriptor that referenced it behind untouched. Those orphaned rows
|
||||
// (remoteSource: true, connection gone) are exactly the shape that made
|
||||
// render-path route lookups throw "Bot X has no connection owner" on every
|
||||
// group open, permanently — the poisoned row lives in plugin storage. The
|
||||
// sweep below runs on the registry's 'removed' push (and its annotate helper
|
||||
// again at hydrate for rows orphaned before this build). It never hard-deletes
|
||||
// user data: the member row is kept and marked, so panes render the existing
|
||||
// degraded 'Gateway removed' botSourceStatus state instead of crashing.
|
||||
|
||||
/** Keep the member's identity; mark it so botSourceStatus reads
|
||||
* 'Gateway removed' and no render-path route lookup can throw on it. */
|
||||
export function markOrphanedGroupMemberDescriptor(member: GroupMember): GroupMember {
|
||||
return {
|
||||
...member,
|
||||
sourceMissing: true,
|
||||
sourceReachable: false
|
||||
}
|
||||
}
|
||||
export function groupMemberReferencesConnection(member: GroupMember, connectionId: string) {
|
||||
const id = String(connectionId || '').trim()
|
||||
if (!id) {
|
||||
return false
|
||||
}
|
||||
return String(member?.connectionId || '').trim() === id || String(member?.route?.connectionId || '').trim() === id
|
||||
}
|
||||
|
||||
/** Hydrate-time pass for rows orphaned BEFORE this build (the poisoned rows
|
||||
* that made #93492 survive app restarts). Two shapes are annotated, never
|
||||
* deleted:
|
||||
* - a descriptor that already lost its connectionId (route unresolvable —
|
||||
* exactly what a stale row looks like once its connection was deleted);
|
||||
* - a descriptor whose connectionId is absent from the live registry, when
|
||||
* the caller could obtain one (liveConnectionIds === null means "registry
|
||||
* unavailable", which must NOT read as "everything is orphaned").
|
||||
* Pure on the rooms map; returns { rooms, changed }. */
|
||||
export function annotateOrphanedGroupChatMembers(
|
||||
rooms: Record<string, GroupChat>,
|
||||
liveConnectionIds: ReadonlySet<string> | null = null
|
||||
) {
|
||||
// Duck-typed, not instanceof: callers (and vm-based tests) may hand a Set
|
||||
// constructed in another realm.
|
||||
const live = liveConnectionIds && typeof liveConnectionIds.has === 'function' ? liveConnectionIds : null
|
||||
const next: Record<string, GroupChat> = {}
|
||||
let changed = false
|
||||
for (const [name, room] of Object.entries(rooms || {})) {
|
||||
const members = Array.isArray(room?.members) ? room.members : []
|
||||
const orphaned = (member: GroupMember) => {
|
||||
if (!member || member.sourceMissing) {
|
||||
return false
|
||||
}
|
||||
if (!member.sourceScoped && !member.remoteSource) {
|
||||
return false
|
||||
}
|
||||
const id = String(member.route?.connectionId || member.connectionId || '').trim()
|
||||
if (!id) {
|
||||
// Route unresolvable: this is the row shape that threw on render.
|
||||
return true
|
||||
}
|
||||
return live ? !live.has(id) : false
|
||||
}
|
||||
if (!members.some(orphaned)) {
|
||||
next[name] = room
|
||||
continue
|
||||
}
|
||||
changed = true
|
||||
next[name] = {
|
||||
...room,
|
||||
members: members.map(member => (orphaned(member) ? markOrphanedGroupMemberDescriptor(member) : member))
|
||||
}
|
||||
}
|
||||
return {
|
||||
rooms: next,
|
||||
changed
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* Plugin-scoped i18n for Bot Mode — bundles registered under the plugin id via
|
||||
* `ctx.i18n.register`, never touching core `en.ts`. Mirrors the kanban plugin:
|
||||
* `usePluginI18n` returns a stringly-typed `t(key, …)`, and `useBots()` binds it
|
||||
* to the message SHAPE so components keep typed `b.roster.search` access.
|
||||
*
|
||||
* Only strings Bot Mode OWNS live here. Generic verbs (Cancel, Delete, Retry,
|
||||
* Close, Loading…) and shared concepts (Scheduled jobs) resolve against core's
|
||||
* `useI18n()` instead — core already carries them in every locale, so
|
||||
* duplicating them here would be a second, worse translation.
|
||||
*
|
||||
* Non-English locales are deliberately absent for now: these are ~100
|
||||
* Bot-Mode-specific strings, and the resolution chain (active locale → this
|
||||
* plugin's `en` → the key) already falls back cleanly. Adding `ja` / `zh` /
|
||||
* `zh-hant` is a translation pass, not a code change — drop them in below and
|
||||
* add them to BOTS_LOCALES.
|
||||
*/
|
||||
|
||||
import { type PluginLocaleBundles, type PluginTranslate, usePluginI18n } from '@hermes/plugin-sdk'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
type BotsMessages = {
|
||||
/** Left rail: the bot + group-chat roster. */
|
||||
roster: {
|
||||
search: string
|
||||
searchPlaceholder: string
|
||||
newBotOrGroup: string
|
||||
groupChats: string
|
||||
emptyTitle: string
|
||||
emptyDesc: string
|
||||
noMatchQuery: (query: string) => string
|
||||
noMatchQueryOn: (query: string, gateway: string) => string
|
||||
noMatchFiltersOn: (gateway: string) => string
|
||||
noMatchFilters: string
|
||||
clearFilters: string
|
||||
allHidden: string
|
||||
allHiddenDesc: string
|
||||
showHidden: string
|
||||
noHiddenMatch: string
|
||||
hiddenFromRoster: string
|
||||
pinned: string
|
||||
needsAttention: string
|
||||
needsInput: string
|
||||
activeNow: string
|
||||
unavailable: string
|
||||
retryNow: string
|
||||
rosterUnavailable: (reason: string) => string
|
||||
waitingForGateway: string
|
||||
}
|
||||
/** Creating, editing and removing a bot. */
|
||||
bot: {
|
||||
newTitle: string
|
||||
editTitle: string
|
||||
helpPromptPlaceholder: string
|
||||
descriptionHint: string
|
||||
newChatWith: string
|
||||
duplicate: string
|
||||
duplicateFailed: string
|
||||
deleteTitle: string
|
||||
removeFromAllGroups: string
|
||||
createFirstHint: string
|
||||
createFailed: string
|
||||
advanced: string
|
||||
advancedHint: string
|
||||
advancedFailed: string
|
||||
openAnotherChatUnsupported: string
|
||||
remoteConnectionsUnsupported: string
|
||||
}
|
||||
/** Avatar picker: shapes, blobs, pets, uploads, generation. */
|
||||
avatar: {
|
||||
classicShapes: string
|
||||
blobFromName: string
|
||||
unlockFollowsName: string
|
||||
randomize: string
|
||||
upload: string
|
||||
removeImage: string
|
||||
removeBackToShape: string
|
||||
describePlaceholder: string
|
||||
describeHint: string
|
||||
matchTheName: string
|
||||
pickPet: string
|
||||
petLoadFailed: string
|
||||
imageTooLarge: string
|
||||
generationFailed: string
|
||||
savedLocally: string
|
||||
savedLocallyDescriptionFailed: string
|
||||
}
|
||||
/** Group chats: the room, its composer, threads and activity feed. */
|
||||
group: {
|
||||
newTitle: string
|
||||
newDesc: string
|
||||
manageTitle: string
|
||||
settingsTitle: string
|
||||
settingsDesc: string
|
||||
nameLabel: string
|
||||
searchToAdd: string
|
||||
searchToAddPlaceholder: string
|
||||
removeFromSelection: string
|
||||
disbandTitle: string
|
||||
deleteTitle: string
|
||||
deleteAction: string
|
||||
composerPlaceholder: string
|
||||
attachHint: string
|
||||
newThread: string
|
||||
reply: string
|
||||
replyInThread: string
|
||||
replyInThreadPlaceholder: string
|
||||
openThread: string
|
||||
collapseThread: string
|
||||
collapseThreadLabel: string
|
||||
activity: string
|
||||
noActivityYet: string
|
||||
showActivity: string
|
||||
hideActivity: string
|
||||
stop: string
|
||||
stopHint: string
|
||||
needsYourInput: string
|
||||
pictureGenerationFailed: string
|
||||
}
|
||||
/** Skills hub + MCP setup surfaces embedded in the bot editor. */
|
||||
tools: {
|
||||
skillsHub: string
|
||||
filterSkills: string
|
||||
searchHub: string
|
||||
noMcpServers: string
|
||||
}
|
||||
}
|
||||
|
||||
const en: BotsMessages = {
|
||||
roster: {
|
||||
search: 'Search bots and group chats',
|
||||
searchPlaceholder: 'Search bots and group chats…',
|
||||
newBotOrGroup: 'New bot or group chat',
|
||||
groupChats: 'Group chats',
|
||||
emptyTitle: 'No bots yet',
|
||||
emptyDesc: 'Create your first bot.',
|
||||
noMatchQuery: query => `No bots or group chats match “${query}”`,
|
||||
noMatchQueryOn: (query, gateway) => `No bots or group chats match “${query}” on ${gateway}`,
|
||||
noMatchFiltersOn: gateway => `No bots or group chats match these filters on ${gateway}`,
|
||||
noMatchFilters: 'No bots or group chats match these filters.',
|
||||
clearFilters: 'Clear filters',
|
||||
allHidden: 'All bots are hidden',
|
||||
allHiddenDesc: 'They keep working and retain their history.',
|
||||
showHidden: 'Show hidden bots',
|
||||
noHiddenMatch: 'No hidden bots match these filters.',
|
||||
hiddenFromRoster: 'Hidden from the roster',
|
||||
pinned: 'Pinned',
|
||||
needsAttention: 'needs attention',
|
||||
needsInput: 'Needs your input',
|
||||
activeNow: 'Active now',
|
||||
unavailable: 'Unavailable',
|
||||
retryNow: 'Retry now',
|
||||
rosterUnavailable: reason =>
|
||||
`Roster unavailable: ${reason}. If your gateway predates profiles.list, update Hermes and restart the gateway.`,
|
||||
waitingForGateway:
|
||||
'Waiting for the gateway connection… (remote gateways can take a few seconds; retries automatically)'
|
||||
},
|
||||
bot: {
|
||||
newTitle: 'New Bot',
|
||||
editTitle: 'Edit Profile',
|
||||
helpPromptPlaceholder: 'What should this bot help with?',
|
||||
descriptionHint: 'Leave blank to generate from the bot’s name and description.',
|
||||
newChatWith: 'New chat with this bot',
|
||||
duplicate: 'Duplicate',
|
||||
duplicateFailed: 'Duplicate failed',
|
||||
deleteTitle: 'Delete bot and profile?',
|
||||
removeFromAllGroups: 'Remove from all groups',
|
||||
createFirstHint: 'Open the Bots pane and hit “New Bot”.',
|
||||
createFailed: 'Could not create the profile yet',
|
||||
advanced: 'Advanced',
|
||||
advancedHint: 'Advanced — model, skills, toolsets, SOUL.md',
|
||||
advancedFailed: 'Advanced configuration failed',
|
||||
openAnotherChatUnsupported: 'Update Hermes Desktop to open another Bot chat.',
|
||||
remoteConnectionsUnsupported: 'Update Hermes Desktop to chat with bots on other connections.'
|
||||
},
|
||||
avatar: {
|
||||
classicShapes: 'Classic shapes',
|
||||
blobFromName: 'Blob face — drawn from the bot’s name',
|
||||
unlockFollowsName: 'Unlock — the face follows the bot’s name again',
|
||||
randomize: 'Randomize',
|
||||
upload: 'Upload',
|
||||
removeImage: 'Remove image — use shape',
|
||||
removeBackToShape: 'Remove — back to shape avatar',
|
||||
describePlaceholder: 'Describe your avatar…',
|
||||
describeHint:
|
||||
'Leave blank to auto-generate from name/title/description + agent-messaging roster.',
|
||||
matchTheName: 'Match the name',
|
||||
pickPet: 'Pick a pet as this bot’s profile picture.',
|
||||
petLoadFailed: 'Could not load that pet — try another.',
|
||||
imageTooLarge: 'Image too large (max 15MB).',
|
||||
generationFailed: 'Avatar generation failed',
|
||||
savedLocally: 'Saved look locally; remote persistence failed',
|
||||
savedLocallyDescriptionFailed: 'Saved look locally; description update failed'
|
||||
},
|
||||
group: {
|
||||
newTitle: 'New Group Chat',
|
||||
newDesc: 'A bot can join multiple group chats. Memberships sync to every machine.',
|
||||
manageTitle: 'Manage groups',
|
||||
settingsTitle: 'Group settings',
|
||||
settingsDesc: 'Rename the group or set a room picture. Members and history are kept.',
|
||||
nameLabel: 'Group name',
|
||||
searchToAdd: 'Search bots to add',
|
||||
searchToAddPlaceholder: 'Search bots to add…',
|
||||
removeFromSelection: 'Remove from selection',
|
||||
disbandTitle: 'Disband group chat?',
|
||||
deleteTitle: 'Delete group chat?',
|
||||
deleteAction: 'Delete Group',
|
||||
composerPlaceholder: 'Say something — every bot in this group hears the room.',
|
||||
attachHint: 'Attach files — every responding bot sees them',
|
||||
newThread: 'New Thread',
|
||||
reply: 'Reply',
|
||||
replyInThread: 'Reply in thread',
|
||||
replyInThreadPlaceholder: 'Reply in thread…',
|
||||
openThread: 'Open this thread',
|
||||
collapseThread: 'Collapse thread',
|
||||
collapseThreadLabel: 'Collapse this thread',
|
||||
activity: 'Activity',
|
||||
noActivityYet: 'No activity in this turn yet.',
|
||||
showActivity: 'Show room activity',
|
||||
hideActivity: 'Hide room activity',
|
||||
stop: 'Stop',
|
||||
stopHint: 'Stop this run — interrupts the member on turn and holds the rest',
|
||||
needsYourInput: 'A bot in this group chat needs your input',
|
||||
pictureGenerationFailed: 'Group picture generation failed'
|
||||
},
|
||||
tools: {
|
||||
skillsHub: 'Hermes Skills Hub',
|
||||
filterSkills: 'Filter skills…',
|
||||
searchHub: 'Search the hub (community + well-known sources)…',
|
||||
noMcpServers: 'No MCP servers configured or in the catalog.'
|
||||
}
|
||||
}
|
||||
|
||||
/** Registered via `ctx.i18n.register` at plugin load (disposer tracked). */
|
||||
export const BOTS_LOCALES: PluginLocaleBundles = { en }
|
||||
|
||||
// Bind the message SHAPE to a plugin translator: string leaves resolve now,
|
||||
// function leaves forward their args through t(path, …).
|
||||
type Bound<T> = {
|
||||
[K in keyof T]: T[K] extends (...args: infer A) => string
|
||||
? (...args: A) => string
|
||||
: T[K] extends object
|
||||
? Bound<T[K]>
|
||||
: string
|
||||
}
|
||||
|
||||
function bind<T extends object>(t: PluginTranslate, template: T, prefix = ''): Bound<T> {
|
||||
const out = {} as Record<string, unknown>
|
||||
|
||||
for (const [key, value] of Object.entries(template)) {
|
||||
const path = prefix ? `${prefix}.${key}` : key
|
||||
out[key] =
|
||||
typeof value === 'function'
|
||||
? (...args: unknown[]) => t(path, ...args)
|
||||
: value && typeof value === 'object'
|
||||
? bind(t, value as object, path)
|
||||
: t(path)
|
||||
}
|
||||
|
||||
return out as Bound<T>
|
||||
}
|
||||
|
||||
export type BotsText = Bound<BotsMessages>
|
||||
|
||||
/** The Bot Mode strings for the active locale — one hook every component reads. */
|
||||
export function useBots(): BotsText {
|
||||
const t = usePluginI18n('hermes-bots')
|
||||
|
||||
return useMemo(() => bind(t, en), [t])
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* The presentation leaves every Bot Mode surface reads: the name a row shows,
|
||||
* the flattened one-line preview beside it, and the slug that identifies a
|
||||
* workspace tile.
|
||||
*
|
||||
* They sit below the surfaces rather than inside any one of them — the roster,
|
||||
* the bot row and the group-chat room all render the same identity, and none
|
||||
* of them can own it without the others importing a sibling surface.
|
||||
*/
|
||||
|
||||
import { aliasIdentityFor } from './routing'
|
||||
import type { BotMeta, RosterRow } from './types'
|
||||
|
||||
export function displayName(bot: Partial<RosterRow>, meta?: BotMeta | null): string {
|
||||
// A configured alias route claiming this row overrides source-derived
|
||||
// identity: the friendly alias name must survive hosted-session
|
||||
// activation and Cloud-only rosters (#89131).
|
||||
const alias = aliasIdentityFor(bot)
|
||||
|
||||
// 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 &&
|
||||
!alias &&
|
||||
!meta?.title?.trim()
|
||||
) {
|
||||
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()
|
||||
}
|
||||
|
||||
// An untitled backend row claimed by an alias reads as the alias name —
|
||||
// never generic "Hermes" or a hostname-derived label.
|
||||
if (alias) {
|
||||
const raw = alias.name.replace(/[-_]+/g, ' ').trim()
|
||||
return raw.replace(/\b\w/g, ch => ch.toUpperCase())
|
||||
}
|
||||
|
||||
// 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())
|
||||
}
|
||||
export function slugify(value: string) {
|
||||
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. */
|
||||
export function stripPreviewMarkdown(text: unknown) {
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
/**
|
||||
* Inline per-profile MCP setup: the `mcp.servers.*` RPC wrapper, its
|
||||
* feature-detect, and the button a capability row renders.
|
||||
*
|
||||
* Shared leaf: the advanced profile editor and the create dialog both render
|
||||
* the button, so it lives below both.
|
||||
*/
|
||||
|
||||
import { Button, host, Input, useI18n } from '@hermes/plugin-sdk'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
// -- 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).
|
||||
|
||||
/** Body of an `mcp.servers.*` reply. Some gateway builds wrap it in a second
|
||||
* `result` envelope, which every call site below unwraps — hence the
|
||||
* self-reference. */
|
||||
interface McpServerPayload {
|
||||
auth_url?: string
|
||||
error?: string
|
||||
error_message?: string
|
||||
ok?: boolean
|
||||
result?: McpServerPayload
|
||||
session_id?: string
|
||||
status?: string
|
||||
verification_url?: string
|
||||
}
|
||||
|
||||
/** `mcpRpc`'s outcome. `unsupported` separates an older gateway that doesn't
|
||||
* know the method from a real failure. */
|
||||
interface McpRpcResult {
|
||||
error?: string
|
||||
ok: boolean
|
||||
result?: McpServerPayload
|
||||
unsupported?: boolean
|
||||
}
|
||||
|
||||
async function mcpRpc(method: string, params: Record<string, unknown>): Promise<McpRpcResult> {
|
||||
// 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<McpServerPayload>(method, params)
|
||||
return {
|
||||
ok: true,
|
||||
result: res
|
||||
}
|
||||
} catch (err: any) {
|
||||
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: boolean | null = null
|
||||
async function mcpSetupSupported(): Promise<boolean> {
|
||||
if (_mcpRpcSupported !== null) {
|
||||
return _mcpRpcSupported
|
||||
}
|
||||
const r = await mcpRpc('mcp.servers.list', {})
|
||||
_mcpRpcSupported = !(r.ok === false && r.unsupported)
|
||||
return _mcpRpcSupported
|
||||
}
|
||||
|
||||
/** One row of the capability pane's MCP list (catalog entry or installed server). */
|
||||
interface McpCatalogEntry {
|
||||
auth?: null | string
|
||||
fromCatalog?: boolean
|
||||
installed?: boolean
|
||||
name: string
|
||||
requires?: string[]
|
||||
}
|
||||
|
||||
/** The capability scope the Edit Profile / New Bot panes hand down — the SDK's
|
||||
* `ProfileScope`: a bare profile name, or a connection-qualified scope for a
|
||||
* source-scoped bot. */
|
||||
type McpSetupScope = null | string | undefined | { connectionId?: null | string; profile?: null | string }
|
||||
|
||||
interface McpSetupButtonProps {
|
||||
ensureProfile?: () => Promise<null | string>
|
||||
entry: McpCatalogEntry
|
||||
onDone?: () => void
|
||||
// TODO(bot-mode-types): Edit Profile passes `botBackendProfileScope(...)`, which
|
||||
// is a `{ connectionId, profile }` OBJECT for every source-scoped bot. That
|
||||
// object is forwarded verbatim as the `profile` param of mcp.servers.add /
|
||||
// set_api_key / test / oauth.*, where the gateway expects a profile NAME
|
||||
// string — and mcpRpc goes through host.request, so the connection isn't
|
||||
// routed either. Typed as-written.
|
||||
profile: McpSetupScope
|
||||
}
|
||||
|
||||
export function McpSetupButton({ profile, entry, onDone, ensureProfile }: McpSetupButtonProps) {
|
||||
const { t } = useI18n()
|
||||
// entry: { name, requires:[env keys], auth?, fromCatalog, installed }
|
||||
// profile may be null at first (New Bot: 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<'busy' | 'done' | 'error' | 'idle' | 'keys' | 'oauth'>('idle') // idle | keys | oauth | busy | done | error
|
||||
const [supported, setSupported] = useState<boolean | null>(null)
|
||||
const [keyValues, setKeyValues] = useState<Record<string, string>>({})
|
||||
const [message, setMessage] = useState('')
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const profileRef = useRef<McpSetupScope>(profile || null)
|
||||
useEffect(() => {
|
||||
if (profile) {
|
||||
profileRef.current = profile
|
||||
}
|
||||
}, [profile])
|
||||
|
||||
// Resolve the target profile, creating it on demand for the New Bot 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.
|
||||
// TODO(bot-mode-types): the plugin SDK's `host` has no `openExternal`, so this
|
||||
// branch is dead and the window bridge / window.open fallbacks are the only
|
||||
// live paths. `ctx.os.openExternal` is the real verb. Kept as-written under
|
||||
// ts-expect-error, which reports itself as unused the day the SDK grows one.
|
||||
try {
|
||||
// @ts-expect-error TODO(bot-mode-types): not on the SDK host, branch is dead
|
||||
if (host.openExternal) {
|
||||
// @ts-expect-error TODO(bot-mode-types): not on the SDK host, branch is dead
|
||||
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 (
|
||||
<span className="ml-1.5 text-[0.65rem] text-(--ui-text-quaternary)">
|
||||
{'needs setup (' + requires.join(', ') + ') \u2014 restart the gateway to enable in-app setup'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (phase === 'done') {
|
||||
return <span className="ml-1.5 text-[0.65rem] text-(--ui-success)">set up ✓</span>
|
||||
}
|
||||
if (phase === 'keys') {
|
||||
return (
|
||||
<div className="mt-1 grid gap-1">
|
||||
{requires.map(k => (
|
||||
<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
|
||||
}))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<div className="flex gap-1">
|
||||
<Button size="xs" variant="secondary" onClick={() => void submitKeys()}>
|
||||
Save & test
|
||||
</Button>
|
||||
<Button size="xs" variant="ghost" onClick={() => setPhase('idle')}>
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (phase === 'oauth') {
|
||||
return <span className="ml-1.5 text-[0.65rem] text-(--ui-text-quaternary)">{message || 'Authorizing\u2026'}</span>
|
||||
}
|
||||
if (phase === 'busy') {
|
||||
return <span className="ml-1.5 text-[0.65rem] text-(--ui-text-quaternary)">Working…</span>
|
||||
}
|
||||
if (phase === 'error') {
|
||||
return (
|
||||
<span className="ml-1.5 text-[0.65rem] text-(--ui-danger,#f87171)">
|
||||
{(message || 'Setup failed') + ' '}
|
||||
<Button className="underline" onClick={() => setPhase('idle')} size="inline" variant="link">
|
||||
retry
|
||||
</Button>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
// idle
|
||||
return (
|
||||
<Button
|
||||
className="ml-1.5 text-[0.65rem] text-(--ui-accent) underline"
|
||||
onClick={() => void (isOAuth ? beginOAuth() : beginKeys())}
|
||||
size="inline"
|
||||
variant="link"
|
||||
>
|
||||
{isOAuth ? 'Sign in\u2026' : 'Set up\u2026'}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* Provider + model dropdowns backed by the gateway's `model.options`
|
||||
* inventory, plus the bounded fetch that keeps a wedged bot socket from
|
||||
* spinning the picker forever.
|
||||
*
|
||||
* Shared by the advanced profile editor and the create dialog.
|
||||
*/
|
||||
|
||||
import {
|
||||
Button,
|
||||
GlyphSpinner,
|
||||
Input,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
useQuery
|
||||
} from '@hermes/plugin-sdk'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { labeled } from './dialog-parts'
|
||||
import { botRouteKey, requestForBot, resolveBotConnectionRoute } from './routing'
|
||||
import { ID } from './shared'
|
||||
import type { RosterRow } from './types'
|
||||
|
||||
// ── model picker (provider/model dropdowns via model.options) ───────────────
|
||||
|
||||
// #95279: the picker's catalog read rides the BOT's own socket — a lazily
|
||||
// dialed second backend that can wedge (cold pool spawn, dropped remote hop)
|
||||
// without the primary socket ever noticing. An unbounded RPC there left the
|
||||
// query pending forever and the picker spinning ("never settles"). Bound every
|
||||
// attempt: past the budget the query rejects and ModelPicker falls back to its
|
||||
// free-text inputs instead of an eternal GlyphSpinner.
|
||||
const MODEL_OPTIONS_SETTLE_MS = 20000
|
||||
function boundedModelOptionsFetch<T>(fetch: Promise<T>, settleMs = MODEL_OPTIONS_SETTLE_MS): Promise<T> {
|
||||
// window.setTimeout (not bare setTimeout): bare vm test harnesses expose
|
||||
// timers only through the window shim. With no scheduler at all, degrade to
|
||||
// the old unbounded behavior rather than not fetching.
|
||||
const scope = typeof window === 'undefined' ? null : window
|
||||
if (!scope || typeof scope.setTimeout !== 'function') {
|
||||
return fetch
|
||||
}
|
||||
// `any`: the timer id is assigned synchronously by the executor below, but
|
||||
// it is still `null` on the declaration TypeScript sees from the closure,
|
||||
// and clearTimeout's signature takes `number | undefined`.
|
||||
let timerId: any = null
|
||||
const deadline = new Promise<never>((_, reject) => {
|
||||
timerId = scope.setTimeout(() => {
|
||||
reject(new Error(`model.options did not answer within ${Math.round(settleMs / 1000)}s (#95279 settle guard)`))
|
||||
}, settleMs)
|
||||
})
|
||||
return Promise.race([fetch, deadline]).finally(() => scope.clearTimeout(timerId))
|
||||
}
|
||||
/** One provider row of the gateway's `model.options` inventory. Entries in
|
||||
* `models` are bare slugs on current gateways and objects on older ones. */
|
||||
interface ModelProviderOption {
|
||||
models?: Array<string | { id?: string; name?: string }>
|
||||
name?: string
|
||||
slug: string
|
||||
}
|
||||
interface ModelOptionsResponse {
|
||||
providers?: ModelProviderOption[]
|
||||
}
|
||||
function useModelOptions(bot: null | RosterRow = null) {
|
||||
// Hook body runs during render: an orphaned row must paint the picker
|
||||
// disabled/erroring, not throw into the pane's error boundary.
|
||||
const resolved = bot ? resolveBotConnectionRoute(bot) : null
|
||||
const route = resolved?.status === 'resolved' ? resolved.route : null
|
||||
const orphaned = resolved?.status === 'owner_removed'
|
||||
return useQuery<ModelOptionsResponse>({
|
||||
queryKey: [ID, 'model-options', route ? botRouteKey(route) : 'active'],
|
||||
// No forced `refresh`: forcing a network read on EVERY mount bypassed the
|
||||
// staleTime cache, so each Bots view remount (tab re-front, dialog reopen,
|
||||
// pane visibility flip) knocked the picker back into its loading state and
|
||||
// discarded the user's staged selection mid-edit (#95279). The cached read
|
||||
// still refreshes per staleTime like every other surface's catalog.
|
||||
queryFn: () =>
|
||||
boundedModelOptionsFetch(
|
||||
requestForBot(bot, 'model.options', {
|
||||
include_unconfigured: true,
|
||||
explicit_only: false
|
||||
}) as Promise<ModelOptionsResponse>
|
||||
),
|
||||
enabled: !orphaned,
|
||||
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.
|
||||
*/
|
||||
/** The two fields a profile pins for its model. */
|
||||
interface ModelSelection {
|
||||
model: string
|
||||
provider: string
|
||||
}
|
||||
/** ModelPicker only ever emits the field(s) it just changed, so consumers can
|
||||
* test membership with `in` and leave the rest of their state untouched. */
|
||||
type ModelSelectionPatch = { model: string } | { model: string; provider: string } | { provider: string }
|
||||
interface ModelPickerProps {
|
||||
bot?: null | RosterRow
|
||||
onChange: (patch: ModelSelectionPatch) => void
|
||||
placeholderModel?: string
|
||||
value: ModelSelection
|
||||
}
|
||||
export function ModelPicker({ bot = null, value, onChange, placeholderModel = 'gateway default' }: ModelPickerProps) {
|
||||
const { data, isLoading, error } = useModelOptions(bot)
|
||||
|
||||
// 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 (
|
||||
<div className="flex justify-center py-2">
|
||||
<GlyphSpinner spinner="breathe" className="text-(--ui-text-tertiary)" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (error || !providers.length) {
|
||||
// Fallback: free text (older gateway or empty inventory).
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-2.5">
|
||||
{labeled(
|
||||
'Provider',
|
||||
<Input
|
||||
placeholder="omnirouter / 9router / nous …"
|
||||
value={value.provider}
|
||||
onChange={event =>
|
||||
onChange({
|
||||
provider: event.target.value
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{labeled(
|
||||
'Model',
|
||||
<Input
|
||||
placeholder="antigravity/gemini-3.6-flash-high"
|
||||
value={value.model}
|
||||
onChange={event =>
|
||||
onChange({
|
||||
model: event.target.value
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (useFreeText) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px'
|
||||
}}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-2.5">
|
||||
{labeled(
|
||||
'Provider (Custom)',
|
||||
<Input
|
||||
placeholder="e.g. omnirouter, inferx, 9router"
|
||||
value={value.provider}
|
||||
onChange={event =>
|
||||
onChange({
|
||||
provider: event.target.value
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{labeled(
|
||||
'Model (Custom)',
|
||||
<Input
|
||||
placeholder="e.g. antigravity/gemini-3.6-flash-high"
|
||||
value={value.model}
|
||||
onChange={event =>
|
||||
onChange({
|
||||
model: event.target.value
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 self-start text-xs text-(--ui-text-tertiary)"
|
||||
onClick={() => setUseFreeText(false)}
|
||||
>
|
||||
← Back to dropdowns
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
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 (
|
||||
<div className="grid grid-cols-[1fr_1.4fr] gap-2.5">
|
||||
{labeled(
|
||||
'Provider',
|
||||
<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
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 rounded-md">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE}>Inherit (launch profile)</SelectItem>
|
||||
{providers.map(p => (
|
||||
<SelectItem key={p.slug} value={p.slug}>
|
||||
{p.name ? `${p.name} (${p.slug})` : p.slug}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value={CUSTOM}>✏️ Enter manually…</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{labeled(
|
||||
'Model',
|
||||
activeProvider && models.length > 0 ? (
|
||||
<Select
|
||||
value={value.model || (models[0] ?? '')}
|
||||
onValueChange={v =>
|
||||
onChange({
|
||||
model: v
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 rounded-md">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{models.map(m => (
|
||||
<SelectItem key={m} value={m}>
|
||||
{m}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
placeholder={placeholderModel || 'e.g. model name'}
|
||||
value={value.model}
|
||||
onChange={event =>
|
||||
onChange({
|
||||
model: event.target.value
|
||||
})
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* The Pet tab: browse the petdex gallery and use a companion's first sprite
|
||||
* frame as the bot's profile picture.
|
||||
*/
|
||||
|
||||
import { Button, cn, GlyphSpinner, host, Input, RowButton, useQuery } from '@hermes/plugin-sdk'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { useBots } from './i18n'
|
||||
import { ID } from './shared'
|
||||
|
||||
// ── 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<string, Promise<null | string>>()
|
||||
let petFetchActive = 0
|
||||
const petFetchQueue: Array<() => Promise<void>> = []
|
||||
function pumpPetQueue() {
|
||||
while (petFetchActive < 4 && petFetchQueue.length) {
|
||||
const job = petFetchQueue.shift()!
|
||||
petFetchActive++
|
||||
job().finally(() => {
|
||||
petFetchActive--
|
||||
pumpPetQueue()
|
||||
})
|
||||
}
|
||||
}
|
||||
function petFrameIcon(spriteUrl: null | string | undefined): Promise<null | string> {
|
||||
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)!
|
||||
}
|
||||
|
||||
interface PetThumbProps {
|
||||
size?: number
|
||||
spriteUrl?: null | string
|
||||
}
|
||||
|
||||
/** One pet tile image: frame 0 only, resolved lazily through the cache. */
|
||||
function PetThumb({ spriteUrl, size = 40 }: PetThumbProps) {
|
||||
const [icon, setIcon] = useState<null | string>(null)
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
petFrameIcon(spriteUrl).then(url => {
|
||||
if (alive) {
|
||||
setIcon(url)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [spriteUrl])
|
||||
if (!icon) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: 6,
|
||||
background: 'var(--chrome-action-hover, rgba(255,255,255,0.06))'
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<img
|
||||
src={icon}
|
||||
alt=""
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
objectFit: 'contain',
|
||||
imageRendering: 'pixelated',
|
||||
borderRadius: 6
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** One petdex companion as `pet.gallery` reports it. */
|
||||
interface PetGalleryEntry {
|
||||
curated?: boolean
|
||||
displayName?: string
|
||||
installed?: boolean
|
||||
slug: string
|
||||
/** Full animation sheet (1536×1872 webp); frame 0 is cropped out of it. */
|
||||
spritesheetUrl?: null | string
|
||||
}
|
||||
|
||||
interface PetTabProps {
|
||||
image: null | string
|
||||
onImage: (image: null | string) => void
|
||||
}
|
||||
|
||||
export function PetTab({ image, onImage }: PetTabProps) {
|
||||
const b = useBots()
|
||||
// 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 | string>(null)
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: [ID, 'pet-gallery'],
|
||||
queryFn: () => host.request<{ pets?: PetGalleryEntry[] }>('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 (
|
||||
<div className="flex justify-center py-4">
|
||||
<GlyphSpinner spinner="breathe" className="text-(--ui-text-tertiary)" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (!pets.length) {
|
||||
return (
|
||||
<div className="px-2 py-3 text-center text-xs text-(--ui-text-tertiary)">
|
||||
No pets in the petdex gallery. Run `hermes pets` to explore.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
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: PetGalleryEntry) => (pet.installed ? 0 : pet.curated ? 1 : 2)
|
||||
return rank(a) - rank(b)
|
||||
})
|
||||
const visible = ranked.slice(0, limit)
|
||||
const onScroll = (event: { currentTarget: HTMLDivElement }) => {
|
||||
const el = event.currentTarget
|
||||
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 120 && limit < ranked.length) {
|
||||
setLimit(prev => Math.min(prev + 24, ranked.length))
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="grid w-full gap-2">
|
||||
<div className="text-center text-[0.65rem] text-(--ui-text-quaternary)">
|
||||
{b.avatar.pickPet}
|
||||
</div>
|
||||
<Input
|
||||
className="h-7 text-xs"
|
||||
placeholder={`Search ${pets.length} pets…`}
|
||||
value={query}
|
||||
onChange={event => {
|
||||
setQuery(event.target.value)
|
||||
setLimit(24)
|
||||
}}
|
||||
/>
|
||||
{image && selectedSlug ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="justify-center"
|
||||
onClick={() => {
|
||||
setSelectedSlug(null)
|
||||
onImage(null)
|
||||
}}
|
||||
>
|
||||
{b.avatar.removeBackToShape}
|
||||
</Button>
|
||||
) : null}
|
||||
{filtered.length === 0 ? (
|
||||
<div className="py-3 text-center text-xs text-(--ui-text-quaternary)">No pets match.</div>
|
||||
) : (
|
||||
<div
|
||||
onScroll={onScroll}
|
||||
style={{
|
||||
maxHeight: 220,
|
||||
overflowY: 'auto'
|
||||
}}
|
||||
>
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
{visible.map(pet => (
|
||||
<RowButton
|
||||
key={pet.slug}
|
||||
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: b.avatar.petLoadFailed
|
||||
})
|
||||
}
|
||||
})
|
||||
}}
|
||||
>
|
||||
<PetThumb spriteUrl={pet.spritesheetUrl} size={40} />
|
||||
<span className="w-full truncate text-center text-[0.6rem] text-(--ui-text-tertiary)">
|
||||
{pet.displayName}
|
||||
</span>
|
||||
</RowButton>
|
||||
))}
|
||||
</div>
|
||||
{limit < ranked.length ? (
|
||||
<div className="py-2 text-center text-[0.65rem] text-(--ui-text-quaternary)">{`Scroll for more (${limit} of ${ranked.length})`}</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,623 @@
|
||||
/**
|
||||
* The advanced profile editor: skills, toolsets, MCP servers, model and SOUL,
|
||||
* backed by `profiles.describe` / `profiles.configure`.
|
||||
*
|
||||
* The optional-SDK feature detects live here because this is the surface that
|
||||
* needs them, and the create dialog — which stages the same capabilities
|
||||
* before a profile exists — reads them from here rather than duplicating the
|
||||
* detection.
|
||||
*/
|
||||
|
||||
import * as sdk from '@hermes/plugin-sdk'
|
||||
import {
|
||||
Checkbox,
|
||||
GlyphSpinner,
|
||||
host,
|
||||
Input,
|
||||
queryClient,
|
||||
surfaceModelSwitchConfirm,
|
||||
Textarea
|
||||
} from '@hermes/plugin-sdk'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { $lastRoster, ROSTER_KEY } from './data'
|
||||
import { labeled } from './dialog-parts'
|
||||
import { useBots } from './i18n'
|
||||
import { McpSetupButton } from './mcp-setup'
|
||||
import { ModelPicker } from './model-picker'
|
||||
import { botBackendProfileScope, requestForBot, resolveBotConnectionRoute } from './routing'
|
||||
import { HubSkillsSection } from './skills-hub'
|
||||
import { ensureMessagingProtocol } from './soul'
|
||||
import type { RosterRow } from './types'
|
||||
|
||||
// Keep optional exports feature-detected; test harnesses may strip the SDK namespace.
|
||||
// The Partial is the point: both are guarded at every use site because an older
|
||||
// build (or a stripped harness namespace) simply doesn't export them.
|
||||
const { McpTab, ToolsetConfigPanel }: Partial<Pick<typeof sdk, 'McpTab' | 'ToolsetConfigPanel'>> = sdk
|
||||
export 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.
|
||||
export const skillsViewRoutesConnections = Boolean(SkillsView && SkillsView.supportsFixedConnection)
|
||||
|
||||
// ── advanced profile config (skills / toolsets / model / SOUL) ──────────────
|
||||
//
|
||||
// Shared by Edit Profile and New Bot (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.
|
||||
|
||||
/** One toggleable capability row. Skills, toolsets, and MCP servers all
|
||||
* arrive from `profiles.describe` / `mcp.catalog` in this shape and run
|
||||
* through the same toggle handlers, so they share one entry type. */
|
||||
export interface CapabilityEntry {
|
||||
auth?: string
|
||||
description?: string
|
||||
enabled?: boolean
|
||||
fromCatalog?: boolean
|
||||
installed?: boolean
|
||||
name: string
|
||||
requires?: string[]
|
||||
tool_count?: number
|
||||
}
|
||||
interface CheckListProps {
|
||||
columns?: number
|
||||
items: CapabilityEntry[]
|
||||
onToggle: (name: string, enabled: boolean) => void
|
||||
}
|
||||
export function CheckList({ items, onToggle, columns = 2 }: CheckListProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
|
||||
gap: '2px 12px'
|
||||
}}
|
||||
>
|
||||
{items.map(item => (
|
||||
<label
|
||||
key={item.name}
|
||||
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}
|
||||
>
|
||||
<Checkbox checked={item.enabled} onCheckedChange={value => onToggle(item.name, Boolean(value))} />
|
||||
<span className="truncate">{item.name}</span>
|
||||
{item.tool_count ? (
|
||||
<span className="shrink-0 text-[0.6rem] text-(--ui-text-quaternary)">{`${item.tool_count}`}</span>
|
||||
) : null}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/** `profiles.describe` as the advanced editor reads it. */
|
||||
export interface ProfileDescribeResponse {
|
||||
mcp_servers?: CapabilityEntry[]
|
||||
model?: { default?: string; provider?: string }
|
||||
skills?: CapabilityEntry[]
|
||||
soul?: string
|
||||
toolsets?: CapabilityEntry[]
|
||||
}
|
||||
/** `mcp.catalog` — the bundled, installable MCP menu. */
|
||||
export interface McpCatalogResponse {
|
||||
servers?: CapabilityEntry[]
|
||||
}
|
||||
/** Staged advanced-config edits. Each `dirty*` flag gates one section of the
|
||||
* `profiles.configure` payload, so an untouched section is never written. */
|
||||
interface AdvancedConfigState {
|
||||
dirtyMcp: boolean
|
||||
dirtyModel: boolean
|
||||
dirtySkills: boolean
|
||||
dirtySoul: boolean
|
||||
dirtyToolsets: boolean
|
||||
loaded: boolean
|
||||
mcp: CapabilityEntry[]
|
||||
model: string
|
||||
provider: string
|
||||
skills: CapabilityEntry[]
|
||||
soul: string
|
||||
toolsets: CapabilityEntry[]
|
||||
}
|
||||
interface AdvancedProfileConfigProps {
|
||||
bot: RosterRow
|
||||
setState: (update: (prev: AdvancedConfigState) => AdvancedConfigState) => void
|
||||
state: AdvancedConfigState
|
||||
}
|
||||
export function AdvancedProfileConfig({ bot, state, setState }: AdvancedProfileConfigProps) {
|
||||
const b = useBots()
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [unsupported, setUnsupported] = useState(false)
|
||||
const [skillFilter, setSkillFilter] = useState('')
|
||||
// Component body = render path: degrade an orphaned row to the bot's own
|
||||
// name scope instead of throwing into the dialog's error boundary.
|
||||
const botRoute = resolveBotConnectionRoute(bot).route
|
||||
const backendProfile = botRoute?.targetProfile || botRoute?.profile || bot.name
|
||||
const backendScope = botBackendProfileScope(botRoute, bot.name)
|
||||
if (!loaded) {
|
||||
setLoaded(true)
|
||||
Promise.all([
|
||||
requestForBot(bot, 'profiles.describe', {
|
||||
name: bot.name
|
||||
}) as Promise<ProfileDescribeResponse>,
|
||||
(
|
||||
requestForBot(bot, 'mcp.catalog', {
|
||||
profile: bot.name
|
||||
}) as Promise<McpCatalogResponse>
|
||||
).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 (
|
||||
<div className="px-2 py-3 text-center text-xs text-(--ui-text-tertiary)">
|
||||
Full configuration needs a newer gateway (restart it after updating Hermes).
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (!state.loaded) {
|
||||
return (
|
||||
<div className="flex justify-center py-4">
|
||||
<GlyphSpinner spinner="breathe" className="text-(--ui-text-tertiary)" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const visibleSkills = skillFilter.trim()
|
||||
? state.skills.filter(s => s.name.toLowerCase().includes(skillFilter.trim().toLowerCase()))
|
||||
: state.skills
|
||||
const toggleSkill = (name: string, enabled: boolean) =>
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
dirtySkills: true,
|
||||
skills: prev.skills.map(s =>
|
||||
s.name === name
|
||||
? {
|
||||
...s,
|
||||
enabled
|
||||
}
|
||||
: s
|
||||
)
|
||||
}))
|
||||
const toggleToolset = (name: string, enabled: boolean) =>
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
dirtyToolsets: true,
|
||||
toolsets: prev.toolsets.map(t =>
|
||||
t.name === name
|
||||
? {
|
||||
...t,
|
||||
enabled
|
||||
}
|
||||
: t
|
||||
)
|
||||
}))
|
||||
const toggleMcp = (name: string, enabled: boolean) =>
|
||||
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 && (!botRoute || skillsViewRoutesConnections)) {
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<ModelPicker
|
||||
bot={bot}
|
||||
value={{
|
||||
provider: state.provider,
|
||||
model: state.model
|
||||
}}
|
||||
onChange={patch =>
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
dirtyModel: true,
|
||||
...patch
|
||||
}))
|
||||
}
|
||||
/>
|
||||
{labeled(
|
||||
'Capabilities (applies immediately — skills, tools, MCP)',
|
||||
<div
|
||||
className="overflow-hidden rounded-md border border-(--ui-stroke-secondary)"
|
||||
style={{
|
||||
height: 460,
|
||||
minHeight: 300,
|
||||
resize: 'vertical',
|
||||
overflow: 'auto'
|
||||
}}
|
||||
>
|
||||
<SkillsView
|
||||
embedded
|
||||
fixedProfile={backendProfile}
|
||||
{...(botRoute
|
||||
? {
|
||||
fixedConnection: botRoute.connectionId
|
||||
}
|
||||
: {})}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{labeled(
|
||||
'SOUL.md (persona + agent-messaging protocol)',
|
||||
<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
|
||||
}))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (bot?.sourceScoped && botRoute?.mode === 'remote' && !skillsViewRoutesConnections) {
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<ModelPicker
|
||||
bot={bot}
|
||||
value={{
|
||||
provider: state.provider,
|
||||
model: state.model
|
||||
}}
|
||||
onChange={patch =>
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
dirtyModel: true,
|
||||
...patch
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<div className="rounded-md border border-(--ui-stroke-secondary) px-3 py-2 text-xs text-(--ui-text-tertiary)">
|
||||
Remote capabilities require a newer desktop. Model and SOUL changes remain staged until you save.
|
||||
</div>
|
||||
{labeled(
|
||||
'SOUL.md (persona + agent-messaging protocol)',
|
||||
<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
|
||||
}))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<ModelPicker
|
||||
bot={bot}
|
||||
value={{
|
||||
provider: state.provider,
|
||||
model: state.model
|
||||
}}
|
||||
onChange={patch =>
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
dirtyModel: true,
|
||||
...patch
|
||||
}))
|
||||
}
|
||||
/>
|
||||
{labeled(
|
||||
`Skills (${enabledSkills}/${state.skills.length} enabled)`,
|
||||
<div className="grid gap-1.5 rounded-md border border-(--ui-stroke-secondary) p-2">
|
||||
<Input
|
||||
className="h-7 text-xs"
|
||||
placeholder={b.tools.filterSkills}
|
||||
value={skillFilter}
|
||||
onChange={event => setSkillFilter(event.target.value)}
|
||||
/>
|
||||
<div
|
||||
className="overflow-y-auto overscroll-contain"
|
||||
style={{
|
||||
maxHeight: 180
|
||||
}}
|
||||
>
|
||||
<CheckList items={visibleSkills} onToggle={toggleSkill} columns={2} />
|
||||
</div>
|
||||
<HubSkillsSection
|
||||
forProfile={backendScope}
|
||||
onInstalled={name =>
|
||||
setState(prev =>
|
||||
prev.skills.some(s => s.name === name)
|
||||
? prev
|
||||
: {
|
||||
...prev,
|
||||
skills: [
|
||||
...prev.skills,
|
||||
{
|
||||
name,
|
||||
enabled: true
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{labeled(
|
||||
`Toolsets (${enabledToolsets}/${state.toolsets.length} enabled — unchecking all restores the default)`,
|
||||
<div className="rounded-md border border-(--ui-stroke-secondary) p-2">
|
||||
<div
|
||||
className="overflow-y-auto overscroll-contain"
|
||||
style={{
|
||||
maxHeight: 320
|
||||
}}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
{state.toolsets.map(tset => (
|
||||
<div key={tset.name} className="rounded-md border border-(--ui-stroke-secondary) p-2">
|
||||
<label className="flex items-center gap-2 text-xs font-medium text-(--ui-text-secondary)">
|
||||
<Checkbox
|
||||
checked={!!tset.enabled}
|
||||
onCheckedChange={value => toggleToolset(tset.name, Boolean(value))}
|
||||
/>
|
||||
<span>{tset.name}</span>
|
||||
</label>
|
||||
{/* 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 ? (
|
||||
<div className="mt-1.5 border-t border-(--ui-stroke-secondary) pt-1.5">
|
||||
<ToolsetConfigPanel toolset={tset.name} profile={backendScope} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{labeled(
|
||||
'MCP servers',
|
||||
<div className="overflow-hidden rounded-md border border-(--ui-stroke-secondary)">
|
||||
{McpTab && typeof host.getGateway === 'function' ? (
|
||||
<div
|
||||
style={{
|
||||
minHeight: 220,
|
||||
maxHeight: 360
|
||||
}}
|
||||
>
|
||||
<McpTab gateway={host.getGateway()} profile={backendScope} />
|
||||
</div>
|
||||
) : mcpList.length === 0 ? (
|
||||
<div className="px-1 py-2 text-center text-xs text-(--ui-text-tertiary)">{b.tools.noMcpServers}</div>
|
||||
) : (
|
||||
<div
|
||||
className="overflow-y-auto overscroll-contain"
|
||||
style={{
|
||||
maxHeight: 180
|
||||
}}
|
||||
>
|
||||
<div className="grid gap-1 p-2">
|
||||
{mcpList.map(m => {
|
||||
const needsSetup =
|
||||
m.fromCatalog &&
|
||||
!m.installed &&
|
||||
((m.requires || []).length > 0 || (m.auth || '').toLowerCase() === 'oauth')
|
||||
return (
|
||||
<label key={m.name} className="flex items-start gap-2 text-xs text-(--ui-text-secondary)">
|
||||
<Checkbox
|
||||
checked={!!m.enabled}
|
||||
disabled={needsSetup}
|
||||
onCheckedChange={value => toggleMcp(m.name, Boolean(value))}
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span>{m.name}</span>
|
||||
{m.fromCatalog && !needsSetup ? (
|
||||
<span className="ml-1.5 text-[0.65rem] text-(--ui-text-quaternary)">
|
||||
{m.installed ? 'catalog · installed' : 'catalog'}
|
||||
</span>
|
||||
) : null}
|
||||
{needsSetup ? (
|
||||
<McpSetupButton profile={backendScope} entry={m} onDone={() => toggleMcp(m.name, true)} />
|
||||
) : null}
|
||||
{m.description ? (
|
||||
<div className="truncate text-[0.65rem] leading-4 text-(--ui-text-quaternary)">
|
||||
{m.description}
|
||||
</div>
|
||||
) : null}
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{labeled(
|
||||
'SOUL.md (persona + agent-messaging protocol)',
|
||||
<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
|
||||
}))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export function emptyAdvancedState(): AdvancedConfigState {
|
||||
return {
|
||||
loaded: false,
|
||||
provider: '',
|
||||
model: '',
|
||||
soul: '',
|
||||
skills: [],
|
||||
toolsets: [],
|
||||
mcp: [],
|
||||
dirtyModel: false,
|
||||
dirtySoul: false,
|
||||
dirtySkills: false,
|
||||
dirtyToolsets: false,
|
||||
dirtyMcp: false
|
||||
}
|
||||
}
|
||||
|
||||
/** The `profiles.configure` body this editor writes — one key per dirty
|
||||
* section. A type alias, not an interface: it is handed straight to
|
||||
* `requestForBot`, whose params are a `Record<string, unknown>`, and only
|
||||
* aliases pick up the implicit index signature that requires. */
|
||||
export type ProfileConfigurePayload = {
|
||||
disabled_skills?: string[]
|
||||
enabled_mcp_servers?: string[]
|
||||
enabled_toolsets?: string[]
|
||||
model?: string
|
||||
name: string
|
||||
provider?: string
|
||||
soul?: string
|
||||
}
|
||||
/** What `profiles.configure` answers: per-section success, plus the #95293
|
||||
* expensive-model / data-policy confirmation gate. */
|
||||
interface ProfileConfigureResult {
|
||||
applied?: Record<string, boolean>
|
||||
confirm_message?: string
|
||||
confirm_required?: boolean
|
||||
}
|
||||
|
||||
/** Persist only the dirty sections of the advanced editor. */
|
||||
export async function applyAdvancedConfig(bot: RosterRow, state: AdvancedConfigState) {
|
||||
const payload: ProfileConfigurePayload = {
|
||||
name: bot.name
|
||||
}
|
||||
const applied: Record<string, boolean> = {}
|
||||
if (state.dirtySoul) {
|
||||
payload.soul = ensureMessagingProtocol(state.soul, bot.name, $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 requestForBot(bot, 'cli.exec', {
|
||||
argv: ['--profile', bot.name, 'config', 'unset', 'model']
|
||||
})) as { blocked?: boolean; code?: number }
|
||||
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 requestForBot(bot, 'profiles.configure', payload)) as ProfileConfigureResult
|
||||
const merged = {
|
||||
...applied,
|
||||
...(result?.applied || {})
|
||||
}
|
||||
|
||||
// #95293 remainder: the gateway now guards data-policy / expensive models
|
||||
// on THIS surface too — `confirm_required` means the model section is
|
||||
// PENDING the user's confirmation, not failed. Route it through the SAME
|
||||
// shared confirm handler the core picker uses (one applier, no forked
|
||||
// confirm logic per surface): the Confirm action resends ONLY the model
|
||||
// section with `confirm_expensive_model: true`.
|
||||
if (result?.confirm_required && payload.model && payload.provider) {
|
||||
delete merged.model
|
||||
surfaceModelSwitchConfirm({
|
||||
confirmLabel: 'Confirm',
|
||||
confirmMessage: result.confirm_message,
|
||||
failureMessage: 'Model switch failed',
|
||||
finish: () =>
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ROSTER_KEY
|
||||
}),
|
||||
requestConfirmed: () =>
|
||||
requestForBot(bot, 'profiles.configure', {
|
||||
name: bot.name,
|
||||
model: payload.model,
|
||||
provider: payload.provider,
|
||||
confirm_expensive_model: true
|
||||
}) as Promise<ProfileConfigureResult>
|
||||
})
|
||||
}
|
||||
return {
|
||||
...result,
|
||||
ok: Object.values(merged).every(Boolean),
|
||||
applied: merged
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
/**
|
||||
* The profile operations that reach the gateway rather than the screen:
|
||||
* two-way avatar sync, duplicate, and delete.
|
||||
*
|
||||
* They sit below every surface because both the roster and the create dialog
|
||||
* drive them — the dialog deletes its own draft profile on cancel, the roster
|
||||
* deletes for real — and neither can own them without the other importing a
|
||||
* surface.
|
||||
*/
|
||||
|
||||
import { forgetSessionUnread, host, queryClient } from '@hermes/plugin-sdk'
|
||||
|
||||
import { isBackfilledFacePng } from './avatar-image'
|
||||
import {
|
||||
$focusedBotOwner,
|
||||
$openBotChat,
|
||||
$selectedBot,
|
||||
clearSelectedRosterBot,
|
||||
focusedRosterOwner,
|
||||
rosterWatermarks
|
||||
} from './bot-state'
|
||||
import { ensureBotMetadata } from './canonical-chat'
|
||||
import {
|
||||
$botMeta,
|
||||
BOT_META_V1_KEY,
|
||||
botMetaKey,
|
||||
botMetaWriteAt,
|
||||
botRosterKey,
|
||||
botSelectionKey,
|
||||
commitBotMetaV2,
|
||||
isDefaultBot,
|
||||
persistBotMetaSnapshot,
|
||||
ROSTER_KEY,
|
||||
saveBotMeta
|
||||
} from './data'
|
||||
import { botConnectionRoute, botRouteKey, requestForBot } from './routing'
|
||||
import { getPluginCtx } from './shared'
|
||||
import type { RosterRow } from './types'
|
||||
|
||||
/** 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<string>()
|
||||
const avatarPushInflight = new Set<string>()
|
||||
|
||||
/** 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: RosterRow[]) {
|
||||
for (const bot of roster) {
|
||||
const key = botMetaKey(bot)
|
||||
if (bot.has_avatar || avatarPushInflight.has(key)) {
|
||||
continue
|
||||
}
|
||||
const image = $botMeta.get()[key]?.image
|
||||
if (image && typeof image === 'string' && image.startsWith('data:')) {
|
||||
avatarPushInflight.add(key)
|
||||
const request = bot.sourceScoped
|
||||
? requestForBot(bot, 'profiles.set_asset', {
|
||||
name: bot.name,
|
||||
asset: 'avatar',
|
||||
data: image
|
||||
})
|
||||
: host.request('profiles.set_asset', {
|
||||
name: bot.name,
|
||||
asset: 'avatar',
|
||||
data: image
|
||||
})
|
||||
Promise.resolve(request)
|
||||
.then(() =>
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['hermes-bots', 'roster']
|
||||
})
|
||||
)
|
||||
.catch(() => avatarPushInflight.delete(key))
|
||||
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(key)
|
||||
rasterizeSvgToPng(svg, 160)
|
||||
.then(png =>
|
||||
png
|
||||
? (bot.sourceScoped
|
||||
? requestForBot(bot, 'profiles.set_asset', {
|
||||
name: bot.name,
|
||||
asset: 'avatar',
|
||||
data: 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(key))
|
||||
}
|
||||
}
|
||||
|
||||
/** Serialize an inline SVG and draw it to a canvas -> PNG data URL. */
|
||||
function rasterizeSvgToPng(svgEl: Element, size: number): Promise<null | string> {
|
||||
return new Promise(resolve => {
|
||||
try {
|
||||
// `Node.cloneNode` is declared to return `Node`, which has no
|
||||
// setAttribute — the clone of an Element is always an Element.
|
||||
const clone = svgEl.cloneNode(true) as Element
|
||||
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
|
||||
// A null 2d context throws here on purpose: the catch below resolves
|
||||
// null, which is the same "no raster" answer the caller expects.
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
/** `profiles.get_asset` reply for the `avatar` asset. */
|
||||
interface ProfilesGetAssetResult {
|
||||
/** Data URL. */
|
||||
data?: string
|
||||
found?: boolean
|
||||
}
|
||||
export function pullServerAvatars(roster: RosterRow[]) {
|
||||
pushLocalAvatars(roster)
|
||||
for (const bot of roster) {
|
||||
const key = botMetaKey(bot)
|
||||
if (!bot.has_avatar || avatarFetchInflight.has(key)) {
|
||||
continue
|
||||
}
|
||||
if ($botMeta.get()[key]?.image) {
|
||||
continue
|
||||
}
|
||||
avatarFetchInflight.add(key)
|
||||
const assetRequest = bot.sourceScoped
|
||||
? requestForBot(bot, 'profiles.get_asset', {
|
||||
name: bot.name,
|
||||
asset: 'avatar'
|
||||
})
|
||||
: host.request('profiles.get_asset', {
|
||||
name: bot.name,
|
||||
asset: 'avatar'
|
||||
})
|
||||
Promise.resolve(assetRequest as Promise<ProfilesGetAssetResult>)
|
||||
.then(res => {
|
||||
if (res?.found && res.data) {
|
||||
const current = $botMeta.get()
|
||||
const mine = current[key] || {}
|
||||
// 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,
|
||||
[key]: {
|
||||
...mine,
|
||||
image: res.data
|
||||
}
|
||||
})
|
||||
persistBotMetaSnapshot($botMeta.get(), Boolean(bot.sourceScoped))
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => avatarFetchInflight.delete(key))
|
||||
}
|
||||
}
|
||||
|
||||
/** 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.
|
||||
*
|
||||
* `fetchedAt` (the snapshot's issue time) fences the overlay: a bot whose
|
||||
* local meta was written AFTER the snapshot was fetched is skipped — that
|
||||
* snapshot's ui_meta predates the write, and spreading it back over local
|
||||
* meta resurrects state the user just changed (a disbanded group's
|
||||
* membership reappearing as an empty roster row, a rename reverting, an
|
||||
* unpin undoing). The next roster fetch post-dates the write and overlays
|
||||
* normally, so server truth still gets the last word. */
|
||||
export function mergeServerMeta(roster: RosterRow[], fetchedAt = 0) {
|
||||
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 key = botMetaKey(bot)
|
||||
if (fetchedAt && fetchedAt < (botMetaWriteAt.get(key) || 0)) {
|
||||
continue
|
||||
}
|
||||
const mine = next[key] || {}
|
||||
const merged = {
|
||||
...mine,
|
||||
...server
|
||||
}
|
||||
|
||||
// Local-only fields survive the server overlay.
|
||||
if (mine.image) {
|
||||
merged.image = mine.image
|
||||
}
|
||||
|
||||
// Legacy canonical-chat pointers (meta.chat) are dead: identity is the
|
||||
// profile's "Bot Chat" registry row, resolved by name. Drop the key on
|
||||
// sight so old ui_meta can never look meaningful again.
|
||||
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[key] || null) !== JSON.stringify(merged)) {
|
||||
next[key] = 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 {
|
||||
persistBotMetaSnapshot(
|
||||
next,
|
||||
roster.some(bot => bot.sourceScoped)
|
||||
)
|
||||
} 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. */
|
||||
export async function duplicateBot(bot: RosterRow, roster: RosterRow[]) {
|
||||
await ensureBotMetadata(bot)
|
||||
const base = bot.name
|
||||
const ownerRoute = botConnectionRoute(bot)
|
||||
const ownerKey = ownerRoute ? botRouteKey(ownerRoute) : null
|
||||
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(
|
||||
// A truthy ownerKey is minted from ownerRoute, so the route is present
|
||||
// on every path that reads it — a correlation TS can't follow.
|
||||
b => b.name === candidate && (!ownerKey || botMetaKey(b)?.startsWith(`${ownerRoute!.connectionId}::`))
|
||||
)
|
||||
) {
|
||||
name = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!name) {
|
||||
throw new Error('No free name for the duplicate.')
|
||||
}
|
||||
await requestForBot(bot, '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()[botMetaKey(bot)]
|
||||
if (meta) {
|
||||
const { chat, created, ...look } = meta
|
||||
await saveBotMeta(
|
||||
{
|
||||
...bot,
|
||||
name,
|
||||
// TODO(bot-mode-types): botConnectionRoute() returns null for an unrouted bot, so this
|
||||
// synthesized row can carry `route: null`, which RosterRow['route'] (ProfileRoute |
|
||||
// undefined) does not admit. Benign at runtime — every read of it is optional-chained —
|
||||
// but the assertion below is covering for a domain type that is too narrow.
|
||||
route: ownerRoute,
|
||||
sourceScoped: Boolean(ownerRoute)
|
||||
} as RosterRow,
|
||||
{
|
||||
...look,
|
||||
title: meta.title ? `${meta.title} (copy)` : ''
|
||||
}
|
||||
)
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
/** `cli.exec` reply, as the legacy delete path reads it. */
|
||||
interface CliExecResult {
|
||||
blocked?: boolean
|
||||
code?: number
|
||||
hint?: string
|
||||
output?: string
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
export async function deleteBot(bot: RosterRow) {
|
||||
const route = botConnectionRoute(bot)
|
||||
if (isDefaultBot(bot) || String(route?.targetProfile || '').toLowerCase() === 'default') {
|
||||
throw new Error('The default profile cannot be deleted.')
|
||||
}
|
||||
if (typeof host.deleteProfile === 'function') {
|
||||
if (route) {
|
||||
await host.deleteProfile(route)
|
||||
} else {
|
||||
await host.deleteProfile(bot.name)
|
||||
}
|
||||
} else {
|
||||
// Older desktop without the SDK verb — source-scoped rows fail closed.
|
||||
if (route) {
|
||||
throw new Error('Source-scoped profile deletion requires host.deleteProfile.')
|
||||
}
|
||||
const result: CliExecResult = 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[botMetaKey(bot)]
|
||||
$botMeta.set(meta)
|
||||
try {
|
||||
if (route) {
|
||||
await commitBotMetaV2(getPluginCtx()?.storage, meta)
|
||||
} else {
|
||||
await Promise.resolve(getPluginCtx()?.storage?.set?.(BOT_META_V1_KEY, meta))
|
||||
}
|
||||
} catch {
|
||||
/* profile is deleted; stale local appearance is harmless if storage fails */
|
||||
}
|
||||
// Both ids: the marker may have been filed under either tip of the chat's
|
||||
// compression lineage.
|
||||
forgetSessionUnread([bot.canonical_session?.id, bot.canonical_session?.resolved_id], bot.name)
|
||||
rosterWatermarks.delete(botSelectionKey(bot))
|
||||
avatarFetchInflight.delete(botMetaKey(bot))
|
||||
avatarPushInflight.delete(botMetaKey(bot))
|
||||
if ($selectedBot.get() === botSelectionKey(bot)) {
|
||||
$selectedBot.set('default')
|
||||
}
|
||||
clearSelectedRosterBot(bot)
|
||||
if ($openBotChat.get()?.key === botRosterKey(bot)) {
|
||||
$openBotChat.set(null)
|
||||
}
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ROSTER_KEY
|
||||
})
|
||||
const activeOwner = focusedRosterOwner($focusedBotOwner.get?.())
|
||||
const deletedOwnerIsActive = route
|
||||
? activeOwner?.connectionId === route.connectionId && activeOwner?.name === route.profile
|
||||
: activeOwner?.name === bot.name
|
||||
if (deletedOwnerIsActive && typeof host.newChat === 'function') {
|
||||
host.newChat('default')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
/**
|
||||
* The cross-connection bot relay: the Desktop-as-router loops that let a bot
|
||||
* on one gateway reach a bot on another.
|
||||
*
|
||||
* Every `let` below is module state mutated only by these loops. plugin.tsx
|
||||
* drives the two lifecycle doors, startBotRelay / stopBotRelay.
|
||||
*/
|
||||
|
||||
import { host } from '@hermes/plugin-sdk'
|
||||
|
||||
import { botHandle, clearBotAttention, noteBotAttention } from './data'
|
||||
import type { ProfileRoute, RosterRow } from './types'
|
||||
|
||||
// ── cross-connection bot relay ────────────────────────────────────────────
|
||||
// Connections ARE the peer set: every gateway this Desktop holds a socket
|
||||
// to (local, remote URL, SSH, Hermes Cloud, docker) must be able to find
|
||||
// every other connection's agents and message them via message_agent. The
|
||||
// Desktop is the relay — it owns every socket. Two loops:
|
||||
// - roster loop: pushes each gateway the union roster of agents on the
|
||||
// OTHER connections (bot_relay.roster.sync), so message_agent resolves
|
||||
// cross-connection targets and Bot Chat prompts list them;
|
||||
// - drain loop: collects queued envelopes from every gateway
|
||||
// (bot_relay.outbox.drain), delivers each on the target connection's
|
||||
// own socket (bot_relay.deliver), and posts the reply back to the
|
||||
// sender gateway (bot_relay.reply) where a waiter wakes the sender.
|
||||
// Older backends without the RPCs fail per-call and are skipped — the
|
||||
// relay degrades to whatever subset of connections supports it.
|
||||
const RELAY_ROSTER_INTERVAL_MS = 60_000
|
||||
// Backstop cadence only (#93594): the push path below carries envelope latency,
|
||||
// so the interval poll exists for older backends and missed events — 30s
|
||||
// matches LIVE_SESSION_STATUS_BACKSTOP_INTERVAL_MS. It was 4s back when the
|
||||
// poll WAS the delivery path, which (before route retention) also meant a
|
||||
// fresh WebSocket dial + teardown per registered connection every 4s.
|
||||
const RELAY_DRAIN_INTERVAL_MS = 30_000
|
||||
// Push path (#93091): the gateway broadcasts `bot_relay.outbox.pending` when
|
||||
// an envelope lands on disk; a burst of signals inside this window collapses
|
||||
// to ONE drain. The interval poll above stays as the backstop for older
|
||||
// backends (and connections whose events don't reach the tap).
|
||||
const RELAY_PUSH_DEBOUNCE_MS = 250
|
||||
let relayDisposed = false
|
||||
let relayRosterTimer: ReturnType<typeof setInterval> | null = null
|
||||
let relayDrainTimer: ReturnType<typeof setInterval> | null = null
|
||||
let relayRosterBusy = false
|
||||
let relayDrainBusy = false
|
||||
let relayPushUnsub: (() => void) | null = null
|
||||
let relayPushDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
// A push landing while a drain is ALREADY running would be lost forever —
|
||||
// the gateway signature is monotone (one event per new envelope, never
|
||||
// re-broadcast) — so remember it and re-schedule after the drain finishes.
|
||||
let relayDrainRerun = false
|
||||
// Relay-route socket retention (#93594): connection id → release fn. While
|
||||
// the relay is active each registered connection's pooled socket is pinned
|
||||
// open (host.retainProfileSocket) so drain RPCs reuse ONE persistent
|
||||
// WebSocket instead of dialing and tearing down a fresh one per tick.
|
||||
// Feature-detected — older shells lack the door and fall back to per-call
|
||||
// leases. Local routes get a no-op release inside the host (idle-reaper
|
||||
// exemption). stopBotRelay releases everything.
|
||||
const relayRouteRetentions = new Map<string, () => void>()
|
||||
|
||||
/** One reachable gateway plus a representative route onto it. The route comes
|
||||
* from `host.profileRoutes()`, which carries identity only — the optional
|
||||
* label fields are read defensively in relayAgentsOn and never arrive. */
|
||||
interface RelayConnection {
|
||||
id: string
|
||||
route: ProfileRoute & { connectionLabel?: string; label?: string }
|
||||
}
|
||||
|
||||
/** One agent as pushed to a peer gateway's relay roster. */
|
||||
interface RelayAgentRow {
|
||||
connection_id: string
|
||||
connection_label: string
|
||||
description: string
|
||||
handle: string
|
||||
profile: string
|
||||
title: string
|
||||
}
|
||||
|
||||
/** A queued cross-connection message drained from a gateway's outbox. */
|
||||
interface RelayEnvelope {
|
||||
id?: string
|
||||
message?: string
|
||||
target_connection?: string
|
||||
target_profile?: string
|
||||
}
|
||||
|
||||
/** Reconcile retention with the CURRENT connection set: pin new connections,
|
||||
* release removed ones. Runs on every drain/roster connection fetch. */
|
||||
function syncRelayRetention(connections: RelayConnection[]) {
|
||||
if (typeof host.retainProfileSocket !== 'function') {
|
||||
return
|
||||
}
|
||||
const live = new Set(connections.map(connection => connection.id))
|
||||
for (const [id, release] of [...relayRouteRetentions]) {
|
||||
if (!live.has(id)) {
|
||||
relayRouteRetentions.delete(id)
|
||||
try {
|
||||
release()
|
||||
} catch {
|
||||
// Never let a release failure break the relay loop.
|
||||
}
|
||||
}
|
||||
}
|
||||
if (relayDisposed) {
|
||||
return
|
||||
}
|
||||
for (const connection of connections) {
|
||||
if (!relayRouteRetentions.has(connection.id)) {
|
||||
relayRouteRetentions.set(connection.id, host.retainProfileSocket(connection.route))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop every relay pin — stop/dispose path. */
|
||||
function releaseRelayRetention() {
|
||||
for (const release of relayRouteRetentions.values()) {
|
||||
try {
|
||||
release()
|
||||
} catch {
|
||||
// Disposer from an older shell shape — never break teardown.
|
||||
}
|
||||
}
|
||||
relayRouteRetentions.clear()
|
||||
}
|
||||
|
||||
/** One representative route per reachable connection id. */
|
||||
async function relayConnections(): Promise<RelayConnection[]> {
|
||||
if (typeof host.profileRoutes !== 'function' || typeof host.requestProfile !== 'function') {
|
||||
return []
|
||||
}
|
||||
try {
|
||||
const routes = await host.profileRoutes()
|
||||
const byConnection = new Map<string, ProfileRoute>()
|
||||
for (const route of Array.isArray(routes) ? routes : []) {
|
||||
const id = String(route?.connectionId || '')
|
||||
if (id && !byConnection.has(id)) {
|
||||
byConnection.set(id, route)
|
||||
}
|
||||
}
|
||||
return [...byConnection.entries()].map(([id, route]) => ({
|
||||
id,
|
||||
route
|
||||
}))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/** The agents living on one connection, as relay roster rows.
|
||||
* Returns null on FAILURE (transient RPC blip, slow socket) — distinct from
|
||||
* a genuine empty profile list. Conflating the two would push a fresh union
|
||||
* roster missing a LIVE connection's agents, and the gateway-side liveness
|
||||
* check (bot_relay._target_liveness) reads "absent from a fresh roster" as
|
||||
* definitively offline → false runtime_offline refusals (#93091 item 2). */
|
||||
async function relayAgentsOn(connection: RelayConnection): Promise<RelayAgentRow[] | null> {
|
||||
try {
|
||||
const res = await host.requestProfile<{ profiles?: RosterRow[] }>(connection.route, 'profiles.list', {
|
||||
include_sessions: false
|
||||
})
|
||||
const profiles = Array.isArray(res?.profiles) ? res.profiles : []
|
||||
// TODO(bot-mode-types): neither `connectionLabel` nor `label` can exist on
|
||||
// a `host.profileRoutes()` route (connectionId / mode / profile /
|
||||
// targetProfile only), so this always falls through to the raw connection
|
||||
// id and peer gateways list agents by id instead of the human label.
|
||||
const label = String(connection.route?.connectionLabel || connection.route?.label || connection.id)
|
||||
return profiles
|
||||
.map(profile => ({
|
||||
profile: String(profile?.name || ''),
|
||||
handle: botHandle(profile?.name, profile),
|
||||
connection_id: connection.id,
|
||||
connection_label: label,
|
||||
title: String(profile?.ui_meta?.['hermes-bots']?.title || profile?.display_name || ''),
|
||||
description: String(profile?.description || '')
|
||||
}))
|
||||
.filter(row => row.profile)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Last good agent rows per connection id — reused when a fetch blips so a
|
||||
* transient failure never reads as "everyone on that machine went away". */
|
||||
const relayAgentsCache = new Map<string, RelayAgentRow[]>()
|
||||
|
||||
/** Push every gateway the union roster of agents on the OTHER connections. */
|
||||
async function syncRelayRosters() {
|
||||
if (relayDisposed || relayRosterBusy) {
|
||||
return
|
||||
}
|
||||
relayRosterBusy = true
|
||||
try {
|
||||
const connections = await relayConnections()
|
||||
if (connections.length < 2) {
|
||||
return
|
||||
}
|
||||
const agentsByConnection = new Map<string, RelayAgentRow[]>()
|
||||
await Promise.all(
|
||||
connections.map(async connection => {
|
||||
const agents = await relayAgentsOn(connection)
|
||||
if (agents === null) {
|
||||
// Transient fetch failure: reuse the last good rows for this
|
||||
// connection (or contribute nothing this cycle) so the pushed
|
||||
// roster never drops a live machine's agents — absence from a
|
||||
// fresh roster means offline to the gateway-side fail-fast.
|
||||
agentsByConnection.set(connection.id, relayAgentsCache.get(connection.id) || [])
|
||||
} else {
|
||||
relayAgentsCache.set(connection.id, agents)
|
||||
agentsByConnection.set(connection.id, agents)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
// Connections gone from profileRoutes are genuinely disconnected — drop
|
||||
// their cache so a later reconnect starts from live data.
|
||||
const liveIds = new Set(connections.map(connection => connection.id))
|
||||
for (const id of [...relayAgentsCache.keys()]) {
|
||||
if (!liveIds.has(id)) {
|
||||
relayAgentsCache.delete(id)
|
||||
}
|
||||
}
|
||||
await Promise.all(
|
||||
connections.map(async connection => {
|
||||
const others: RelayAgentRow[] = []
|
||||
for (const [id, agents] of agentsByConnection) {
|
||||
if (id !== connection.id) {
|
||||
others.push(...agents)
|
||||
}
|
||||
}
|
||||
try {
|
||||
await host.requestProfile(connection.route, 'bot_relay.roster.sync', {
|
||||
agents: others
|
||||
})
|
||||
} catch {
|
||||
// Older backend without the relay RPCs — skip this connection.
|
||||
}
|
||||
})
|
||||
)
|
||||
} finally {
|
||||
relayRosterBusy = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Drain every gateway's outbox and deliver each envelope on the target
|
||||
* connection's own socket; the reply (or error) is posted back to the
|
||||
* sender gateway for its waiter. */
|
||||
async function drainRelayOutboxes() {
|
||||
if (relayDisposed) {
|
||||
return
|
||||
}
|
||||
if (relayDrainBusy) {
|
||||
// A push signal raced an in-flight drain. The gateway never re-sends it
|
||||
// (monotone signature), so without this flag the envelope would wait out
|
||||
// the full poll interval — exactly the latency the push path removes.
|
||||
relayDrainRerun = true
|
||||
return
|
||||
}
|
||||
relayDrainBusy = true
|
||||
try {
|
||||
const connections = await relayConnections()
|
||||
|
||||
// Retention follows the relay-eligible set: with fewer than two
|
||||
// connections there is nothing to relay, so nothing stays pinned.
|
||||
syncRelayRetention(connections.length >= 2 ? connections : [])
|
||||
if (connections.length < 2) {
|
||||
return
|
||||
}
|
||||
const byId = new Map(connections.map(connection => [connection.id, connection]))
|
||||
for (const sender of connections) {
|
||||
let envelopes: RelayEnvelope[] = []
|
||||
try {
|
||||
const res = await host.requestProfile<{ envelopes?: RelayEnvelope[] }>(
|
||||
sender.route,
|
||||
'bot_relay.outbox.drain',
|
||||
{}
|
||||
)
|
||||
envelopes = Array.isArray(res?.envelopes) ? res.envelopes : []
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
for (const envelope of envelopes) {
|
||||
if (relayDisposed) {
|
||||
return
|
||||
}
|
||||
const envelopeId = String(envelope?.id || '')
|
||||
const target = byId.get(String(envelope?.target_connection || ''))
|
||||
const postReply = async (payload: { error?: string; reason?: string; reply?: string }) => {
|
||||
try {
|
||||
await host.requestProfile(sender.route, 'bot_relay.reply', {
|
||||
id: envelopeId,
|
||||
...payload
|
||||
})
|
||||
} catch {
|
||||
// Sender gateway unreachable — its waiter times out with guidance.
|
||||
}
|
||||
}
|
||||
if (!envelopeId) {
|
||||
continue
|
||||
}
|
||||
if (!target) {
|
||||
await postReply({
|
||||
error: `connection '${envelope?.target_connection}' is not connected to this Desktop right now`
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Needs-attention hook (#93091 item 3): a delivered background DM is
|
||||
// this bot's "good turn"; a classified delivery failure badges it.
|
||||
const attentionKey = `${target.id}::${String(envelope?.target_profile || '')}`
|
||||
try {
|
||||
const res = await host.requestProfile<{ reply?: string }>(target.route, 'bot_relay.deliver', {
|
||||
profile: String(envelope?.target_profile || ''),
|
||||
message: String(envelope?.message || '')
|
||||
})
|
||||
clearBotAttention(attentionKey)
|
||||
await postReply({
|
||||
reply: String(res?.reply || '')
|
||||
})
|
||||
} catch (error: any) {
|
||||
// #93091: bot_relay.deliver classifies the failed turn and ships the
|
||||
// typed code in the JSON-RPC error's `data.reason`; forward it into
|
||||
// the sender-side reply file so the waiter (and the sending agent)
|
||||
// get the machine-readable cause, and prefer it for the badge —
|
||||
// classified codes beat free-text re-parsing.
|
||||
const reason = String(error?.data?.reason || '').trim()
|
||||
noteBotAttention(attentionKey, reason || error?.message || error)
|
||||
await postReply({
|
||||
error: String(error?.message || error || 'delivery failed'),
|
||||
...(reason
|
||||
? {
|
||||
reason
|
||||
}
|
||||
: {})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
relayDrainBusy = false
|
||||
if (relayDrainRerun && !relayDisposed) {
|
||||
// Envelopes signaled mid-drain: schedule one follow-up pass (debounced)
|
||||
// instead of leaving them to the interval poll.
|
||||
relayDrainRerun = false
|
||||
scheduleRelayPushDrain()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Push-notified drain (#93091): collapse a burst of pending signals into
|
||||
* one drain call ~RELAY_PUSH_DEBOUNCE_MS after the first signal. */
|
||||
function scheduleRelayPushDrain() {
|
||||
if (relayDisposed || typeof setTimeout !== 'function') {
|
||||
return
|
||||
}
|
||||
if (relayPushDebounceTimer !== null) {
|
||||
return
|
||||
}
|
||||
relayPushDebounceTimer = setTimeout(() => {
|
||||
relayPushDebounceTimer = null
|
||||
void drainRelayOutboxes()
|
||||
}, RELAY_PUSH_DEBOUNCE_MS)
|
||||
}
|
||||
export function startBotRelay() {
|
||||
relayDisposed = false
|
||||
|
||||
// Source-shape test harnesses evaluate plugin.js without DOM timers —
|
||||
// the relay only runs where a real event loop exists.
|
||||
if (typeof setInterval !== 'function' || typeof clearInterval !== 'function') {
|
||||
return
|
||||
}
|
||||
if (relayRosterTimer === null) {
|
||||
relayRosterTimer = setInterval(() => void syncRelayRosters(), RELAY_ROSTER_INTERVAL_MS)
|
||||
void syncRelayRosters()
|
||||
}
|
||||
if (relayDrainTimer === null) {
|
||||
relayDrainTimer = setInterval(() => void drainRelayOutboxes(), RELAY_DRAIN_INTERVAL_MS)
|
||||
}
|
||||
|
||||
// Push path: the gateway change watcher broadcasts when an envelope hits
|
||||
// the outbox; drain immediately (debounced) instead of waiting the poll
|
||||
// out. Feature-detected — older shells have no host.onEvent — and the 4s
|
||||
// poll above stays untouched as the backstop either way.
|
||||
if (relayPushUnsub === null && typeof host.onEvent === 'function') {
|
||||
relayPushUnsub = host.onEvent('bot_relay.outbox.pending', () => scheduleRelayPushDrain())
|
||||
}
|
||||
}
|
||||
export function stopBotRelay() {
|
||||
relayDisposed = true
|
||||
// A rerun remembered mid-drain must not leak into the next start —
|
||||
// it would fire one stale drain after restart.
|
||||
relayDrainRerun = false
|
||||
// Unpin every relay-retained socket (#93594): with the relay stopped the
|
||||
// pooled entries return to dispose-at-refcount-0 semantics.
|
||||
releaseRelayRetention()
|
||||
if (relayRosterTimer !== null) {
|
||||
clearInterval(relayRosterTimer)
|
||||
relayRosterTimer = null
|
||||
}
|
||||
if (relayDrainTimer !== null) {
|
||||
clearInterval(relayDrainTimer)
|
||||
relayDrainTimer = null
|
||||
}
|
||||
if (relayPushDebounceTimer !== null) {
|
||||
clearTimeout(relayPushDebounceTimer)
|
||||
relayPushDebounceTimer = null
|
||||
}
|
||||
if (relayPushUnsub !== null) {
|
||||
try {
|
||||
relayPushUnsub()
|
||||
} catch {
|
||||
// Disposer from an older shell shape — never break teardown.
|
||||
}
|
||||
relayPushUnsub = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* The two roster actions that reach outside a component: the unread/toast
|
||||
* poll every roster refresh feeds, and the click path that fronts one exact
|
||||
* bot's canonical chat.
|
||||
*
|
||||
* They sit beside the surfaces rather than inside them — the bot row, the
|
||||
* roster pane and the plugin's own lifecycle all invoke them, and none of
|
||||
* them can own an action the others call without importing a sibling surface.
|
||||
*/
|
||||
|
||||
import { ackStoredSessionId, atom, haptic, host, markSessionUnreadFinished } from '@hermes/plugin-sdk'
|
||||
|
||||
import { $openBotChat, $selectedBot, rosterWatermarks, saveSelectedRosterBot } from './bot-state'
|
||||
import { notifyBotOpenFailure, openBotCanonicalChat, prepareBotSource } from './canonical-chat'
|
||||
import { $botMeta, botActivitySession, botRosterKey, botSelectionKey, newBotChat } from './data'
|
||||
import { $groupChats, $groupChatWorkspace } from './group-chat'
|
||||
import { openGroupChat } from './group-chat-view'
|
||||
import { liveGroupChatNames } from './group-membership'
|
||||
import { closeGroupChatMainTab } from './group-panes'
|
||||
import { displayName } from './labels'
|
||||
import { botRosterMeta, botWorkspaceOwnerKey, setBotsWorkspaceOwner } from './routing'
|
||||
import { botCanonicalSessionId } from './row-helpers'
|
||||
import { bumpBotOpenGeneration, getBotOpenGeneration, getPluginCtx } from './shared'
|
||||
import type { RosterRow } from './types'
|
||||
|
||||
// last_active watermark per source-qualified bot, seeded on first poll so a
|
||||
// fresh mount doesn't mark ancient history unread.
|
||||
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. */
|
||||
export const $activityToasts = atom(false)
|
||||
|
||||
/** Flip the activity-toast pref and persist it. */
|
||||
export function setActivityToasts(enabled: boolean) {
|
||||
$activityToasts.set(enabled)
|
||||
try {
|
||||
Promise.resolve(getPluginCtx()?.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.
|
||||
* Watermarks follow botActivitySession (canonical Bot Chat included) —
|
||||
* last_session alone never sees the hidden Bot Chat, so DMs delivered
|
||||
* there would neither badge nor toast.
|
||||
*
|
||||
* This poll is the ONLY unread signal a canonical Bot Chat can have: it is
|
||||
* unconditionally hidden, so it never reaches the session list the backend's
|
||||
* own unread watermark iterates, and deliveries from the CLI, cron, another
|
||||
* bot, or another machine never touch this window's live turn edge either. */
|
||||
export function trackInboundActivity(roster: RosterRow[]) {
|
||||
const seeding = !watermarksSeeded
|
||||
watermarksSeeded = true
|
||||
for (const bot of roster) {
|
||||
const key = botSelectionKey(bot)
|
||||
const activity = botActivitySession(bot)
|
||||
const ts = activity?.last_active || 0
|
||||
const prev = rosterWatermarks.get(key) || 0
|
||||
rosterWatermarks.set(key, Math.max(prev, ts))
|
||||
if (seeding || ts <= prev) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Activity in the exact bot owner the user is currently looking at is
|
||||
// already visible — never badge the open chat or its same-named twin.
|
||||
if ($selectedBot.get() === key) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Straight into core's unread store, keyed by the same canonical id the
|
||||
// row's SessionStatusDot reads — a parallel map here would be a second
|
||||
// badge that drifts from the dot.
|
||||
const canonicalSessionId = botCanonicalSessionId(bot)
|
||||
if (canonicalSessionId) {
|
||||
markSessionUnreadFinished(canonicalSessionId, bot.name)
|
||||
}
|
||||
|
||||
// Roster-hidden bots stay quiet: the mark above accumulates silently
|
||||
// (unhiding reveals the dot) but a hidden bot never toasts.
|
||||
if (botRosterMeta(bot, $botMeta.get())?.hidden) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Toasts are opt-in: the unread mark is recorded above regardless, but the
|
||||
// per-message notification fires only when the user enabled it.
|
||||
if ($activityToasts.get()) {
|
||||
const meta = botRosterMeta(bot, $botMeta.get())
|
||||
const label = displayName(bot, meta)
|
||||
const preview = (activity?.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.'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Select one exact roster owner, then open its named canonical chat only when
|
||||
* the current Desktop can route that owner without guessing. The workspace
|
||||
* remembers only this transient opened-view observation; it never stores or
|
||||
* resolves a canonical-chat id. */
|
||||
export async function openRosterBot(bot: RosterRow): Promise<boolean> {
|
||||
const generation = bumpBotOpenGeneration()
|
||||
const key = botRosterKey(bot)
|
||||
const meta = botRosterMeta(bot, $botMeta.get())
|
||||
// Keep the currently visible group as a fallback until this explicit action
|
||||
// has actually fronted a new owner; a failed open must not steal the center
|
||||
// from a group the user was reading.
|
||||
const previousGroup = $groupChatWorkspace.get()
|
||||
const previousGroupRef = previousGroup
|
||||
? {
|
||||
group: previousGroup,
|
||||
roomId: String($groupChats.get()[previousGroup]?.roomId || '')
|
||||
}
|
||||
: null
|
||||
haptic('tap')
|
||||
saveSelectedRosterBot(bot)
|
||||
setBotsWorkspaceOwner(botWorkspaceOwnerKey(bot), bot)
|
||||
const dismissedGroup = bot.remoteSource ? null : dismissGroupChatForLocalBotOpen()
|
||||
if (!dismissedGroup) {
|
||||
$groupChatWorkspace.set(null)
|
||||
}
|
||||
const restorePreviousGroup = () => {
|
||||
if (!previousGroupRef || $groupChatWorkspace.get()) {
|
||||
return
|
||||
}
|
||||
const restoreRef = dismissedGroup || previousGroupRef
|
||||
const rooms = $groupChats.get()
|
||||
const currentGroup = restoreRef.roomId
|
||||
? Object.keys(rooms).find(
|
||||
name => !rooms[name]?.tombstone && String(rooms[name]?.roomId || '') === restoreRef.roomId
|
||||
)
|
||||
: liveGroupChatNames().includes(restoreRef.group)
|
||||
? restoreRef.group
|
||||
: null
|
||||
if (!currentGroup) {
|
||||
return
|
||||
}
|
||||
openGroupChat(currentGroup)
|
||||
}
|
||||
// The persisted half of clear-on-open. The transient dot is retired by
|
||||
// core's own selection path once the chat lands; this retires the marker,
|
||||
// which the selection listener alone would file against the wrong profile —
|
||||
// a bot open deliberately leaves the gateway on the launch profile.
|
||||
ackStoredSessionId(botCanonicalSessionId(bot), bot.name)
|
||||
try {
|
||||
// Activation selects this row's source only. Canonical identity is resolved
|
||||
// after that by the owner profile's "Bot Chat" title registry.
|
||||
await prepareBotSource(bot)
|
||||
} catch (error) {
|
||||
if (generation === getBotOpenGeneration()) {
|
||||
$openBotChat.set(null)
|
||||
restorePreviousGroup()
|
||||
notifyBotOpenFailure(error, bot, `Could not reach ${bot.connectionLabel || 'the gateway'}`)
|
||||
}
|
||||
return false
|
||||
}
|
||||
if (generation !== getBotOpenGeneration()) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
const opened = await openBotCanonicalChat(bot, () => generation === getBotOpenGeneration())
|
||||
if (generation !== getBotOpenGeneration()) {
|
||||
return false
|
||||
}
|
||||
if (opened) {
|
||||
// This is not an identity preference: opening already completed through
|
||||
// the name registry. Keep only enough ephemeral state to release the
|
||||
// claim if another tab later claims the center. Track BOTH identities —
|
||||
// session focus reports the compression-lineage tip (openedId), not the
|
||||
// durable registry row, and matching focus against the registry id
|
||||
// alone released this claim on the first click of every compressed
|
||||
// Bot Chat.
|
||||
$openBotChat.set({
|
||||
key,
|
||||
openedRegistryId: opened.registryId,
|
||||
openedSessionId: opened.openedId
|
||||
})
|
||||
return true
|
||||
}
|
||||
} catch (error) {
|
||||
if (generation === getBotOpenGeneration()) {
|
||||
$openBotChat.set(null)
|
||||
restorePreviousGroup()
|
||||
notifyBotOpenFailure(error, bot, `Could not open ${displayName(bot, meta)}'s chat — try again`)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// An older Desktop without the profile-scoped draft API has no safe fallback:
|
||||
// do not navigate the current workspace or create a draft on the wrong owner.
|
||||
if (typeof host.newChat !== 'function') {
|
||||
$openBotChat.set(null)
|
||||
restorePreviousGroup()
|
||||
return false
|
||||
}
|
||||
$openBotChat.set({
|
||||
key,
|
||||
openedRegistryId: ''
|
||||
})
|
||||
newBotChat(bot)
|
||||
return true
|
||||
}
|
||||
|
||||
/** Bot-open handoff: capture the selected group and retire its registered
|
||||
* main tab (or clear the in-panel selection) before async source prep /
|
||||
* canonical open. */
|
||||
function dismissGroupChatForLocalBotOpen(): null | { group: string; roomId: string } {
|
||||
const group = $groupChatWorkspace.get()
|
||||
if (!group) {
|
||||
return null
|
||||
}
|
||||
const roomId = String($groupChats.get()[group]?.roomId || '')
|
||||
closeGroupChatMainTab(group)
|
||||
return {
|
||||
group,
|
||||
roomId
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,869 @@
|
||||
/**
|
||||
* The Bots pane itself: the roster's selection reconciliation, the
|
||||
* workspace-ownership reads its lifecycle keys off, and the pane that lists
|
||||
* every bot and group chat.
|
||||
*
|
||||
* The top of the roster stack. It composes the rows, the section headings and
|
||||
* the dialogs; nothing in Bot Mode imports it except the plugin entry point.
|
||||
*/
|
||||
|
||||
import {
|
||||
atom,
|
||||
Button,
|
||||
cn,
|
||||
Codicon,
|
||||
ConfirmDialog,
|
||||
DisclosureCaret,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
GlyphSpinner,
|
||||
host,
|
||||
PanelEmpty,
|
||||
RowButton,
|
||||
SearchField,
|
||||
Tip,
|
||||
useI18n,
|
||||
useValue
|
||||
} from '@hermes/plugin-sdk'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { BotRow, GroupRow } from './bot-row'
|
||||
import {
|
||||
$botChatFocused,
|
||||
$botsPaneVisible,
|
||||
$openBotChat,
|
||||
$rosterHydrated,
|
||||
$selectedRosterHydrated,
|
||||
$selectedRosterKey,
|
||||
clearSelectedRosterKey,
|
||||
parseRosterKey,
|
||||
saveSelectedRosterBot
|
||||
} from './bot-state'
|
||||
import { CreateAgentDialog, CreateGroupChatDialog, GroupDialog } from './create-dialog'
|
||||
import {
|
||||
$botMeta,
|
||||
$lastRoster,
|
||||
annotateBotSource,
|
||||
botActivitySession,
|
||||
botRosterKey,
|
||||
botSourceStatus,
|
||||
filterBots,
|
||||
preferReachableSameNameRows,
|
||||
sourceByConnection,
|
||||
useRoster
|
||||
} from './data'
|
||||
import { EditProfileDialog } from './edit-profile-dialog'
|
||||
import { $groupChats, $groupChatWorkspace, $groupNeedsYou } from './group-chat'
|
||||
import { disbandGroupChat, GroupChatWorkspace, openGroupChat } from './group-chat-view'
|
||||
import { groupChatMemberBots, groupChatNames, groupLastActivity } from './group-membership'
|
||||
import { $groupMainTabsRev, shouldRenderGroupChatInPane } from './group-panes'
|
||||
import { $showHiddenBots, isBotHidden, isBotPinned } from './hidden-bots'
|
||||
import { useBots } from './i18n'
|
||||
import { deleteBot, mergeServerMeta, pullServerAvatars } from './profile-ops'
|
||||
import { $activityToasts, setActivityToasts, trackInboundActivity } from './roster-actions'
|
||||
import {
|
||||
botNeedsHandleLabel,
|
||||
filterBotsByGateway,
|
||||
GatewayKindGlyph,
|
||||
GatewaySectionHeading,
|
||||
groupMatchesRosterFilters,
|
||||
rosterGatewayOptions,
|
||||
rosterGatewaySections,
|
||||
RosterSectionHeader
|
||||
} from './roster-sections'
|
||||
import type { ResolvedRosterGatewaySection } from './roster-sections'
|
||||
import { botRosterMeta, botWorkspaceOwnerKey, setBotsWorkspaceOwner } from './routing'
|
||||
import { ACTIVE_WINDOW_S, activeBots, BOT_ROSTER_SEARCH_THRESHOLD, rosterActivityMatches } from './row-helpers'
|
||||
import { backfillMessagingProtocol } from './soul'
|
||||
import type { BotMeta, GatewaySource, GroupMember, RosterActivityFilter, RosterKindFilter, RosterRow } from './types'
|
||||
|
||||
/** Last source inventory returned by the desktop-wide agent roster. */
|
||||
const $lastSources = atom<GatewaySource[]>([])
|
||||
|
||||
// ── roster pane ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function selectedRosterBot(roster: RosterRow[], key: string): RosterRow | null {
|
||||
return (Array.isArray(roster) ? roster : []).find(bot => botRosterKey(bot) === key) || null
|
||||
}
|
||||
|
||||
/** A selected owner whose roster row is absent because its SOURCE is down —
|
||||
* not because the bot is gone. Identity comes from the key itself, so the
|
||||
* selection survives a relaunch with that gateway offline and reconciles
|
||||
* onto the live row (same key) when it returns, without duplicating it.
|
||||
*
|
||||
* Returns null when the selection is provably invalid instead: a reachable
|
||||
* source that no longer lists the bot, or a source that left the registry
|
||||
* while other sources are live. Unknown (no sources yet) is NOT proof. */
|
||||
function ghostRosterOwner(key: string, sources: GatewaySource[]): RosterRow | null {
|
||||
const { connectionId, name } = parseRosterKey(key)
|
||||
if (!name) {
|
||||
return null
|
||||
}
|
||||
const list = Array.isArray(sources) ? sources : []
|
||||
const source = sourceByConnection(list).get(connectionId)
|
||||
if (source ? source.reachable === true : list.length > 0) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
name,
|
||||
connectionId,
|
||||
ghost: true,
|
||||
remoteSource: connectionId !== 'local',
|
||||
connectionKind: source?.kind,
|
||||
connectionLabel: source?.label,
|
||||
sourceError: source?.error || null,
|
||||
sourceMissing: false,
|
||||
sourceReachable: false
|
||||
}
|
||||
}
|
||||
|
||||
/** Keep the exact selected owner visible through a cold-start outage without
|
||||
* persisting the whole remote roster. The source registry supplies the
|
||||
* gateway identity/status; the source-qualified selection supplies the bot
|
||||
* identity. Once that source answers again, the live row replaces the ghost
|
||||
* (or reconciliation clears it when the bot was actually removed). */
|
||||
function rosterWithSelectedOwner(roster: RosterRow[], sources: GatewaySource[], key: string): RosterRow[] {
|
||||
const rows = Array.isArray(roster) ? roster : []
|
||||
if (!key || selectedRosterBot(rows, key)) {
|
||||
return rows
|
||||
}
|
||||
const ghost = ghostRosterOwner(key, sources)
|
||||
return ghost ? [...rows, ghost] : rows
|
||||
}
|
||||
|
||||
/** Keep the persisted selection honest against the live roster and seat a
|
||||
* first selection when there is none. PRESENTATION ONLY: it never opens,
|
||||
* prepares, activates, or creates anything — an unreachable owner keeps its
|
||||
* selection rather than falling back onto some other gateway's bot. */
|
||||
function reconcileRosterSelection(roster: RosterRow[], sources: GatewaySource[], metaByName: Record<string, BotMeta>) {
|
||||
if (!$rosterHydrated.get() || !$selectedRosterHydrated.get()) {
|
||||
return
|
||||
}
|
||||
const key = $selectedRosterKey.get()
|
||||
if (key) {
|
||||
if (selectedRosterBot(roster, key) || ghostRosterOwner(key, sources)) {
|
||||
return
|
||||
}
|
||||
clearSelectedRosterKey(key)
|
||||
}
|
||||
const first = (Array.isArray(roster) ? roster : []).find(
|
||||
bot => !isBotHidden(bot, metaByName) && botSourceStatus(annotateBotSource(bot, sources)).available
|
||||
)
|
||||
if (first) {
|
||||
saveSelectedRosterBot(first)
|
||||
}
|
||||
}
|
||||
/** True when a session owns the main workspace. Prefers the focused STORED
|
||||
* session (tab focus moves without swapping the gateway socket); bare test
|
||||
* harnesses with neither atom drive $botChatFocused directly. */
|
||||
export function sessionOwnsWorkspace(): boolean {
|
||||
const focused = host.state?.focusedStoredSessionId?.get?.()
|
||||
if (focused !== undefined) {
|
||||
return Boolean(focused)
|
||||
}
|
||||
const active = host.state?.activeSessionId?.get?.()
|
||||
return active === undefined ? $botChatFocused.get() : Boolean(active)
|
||||
}
|
||||
|
||||
/** A real bot chat owns the center. Cronjobs are BOT-scoped, so this — not
|
||||
* mere Bot Mode visibility — is what may seat the Cronjobs tile: beside a
|
||||
* group chat it would describe whichever profile the socket happens to be
|
||||
* homed on. */
|
||||
export function botChatOwnsWorkspace(): boolean {
|
||||
return $botsPaneVisible.get() && !$groupChatWorkspace.get() && Boolean($openBotChat.get() || sessionOwnsWorkspace())
|
||||
}
|
||||
|
||||
/** An opened bot chat stops owning the center once focus leaves it (closed,
|
||||
* or another session took over). Without this $openBotChat would keep
|
||||
* claiming ownership for a chat nobody is reading, and the bot-scoped
|
||||
* Cronjobs tile would stay seated beside an unrelated surface.
|
||||
*
|
||||
* The legacy newChat fallback has no registry id to compare — a draft with no
|
||||
* focused session is still that bot's draft, so it only yields once some
|
||||
* session actually takes focus. */
|
||||
export function releaseStaleOpenBotChat(focusedStoredId: null | string | undefined): void {
|
||||
const open = $openBotChat.get()
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
const focused = focusedStoredId === null || focusedStoredId === undefined ? '' : String(focusedStoredId)
|
||||
// The focused stored id is the compression-lineage TIP; the claim carries
|
||||
// both the durable registry id and the tip it actually opened. Either
|
||||
// match keeps the claim — comparing only the registry id released it on
|
||||
// the very focus edge the open itself caused.
|
||||
const owned = [open.openedSessionId, open.openedRegistryId].filter(Boolean)
|
||||
const stale = owned.length ? !owned.includes(focused) : Boolean(focused)
|
||||
if (stale) {
|
||||
$openBotChat.set(null)
|
||||
}
|
||||
}
|
||||
/** The two row shapes the roster sorts together — `kind` is the discriminant. */
|
||||
interface RosterBotRow {
|
||||
active: boolean
|
||||
activity: number
|
||||
bot: RosterRow
|
||||
kind: 'bot'
|
||||
pinned: boolean
|
||||
}
|
||||
interface RosterGroupRow {
|
||||
active: boolean
|
||||
activity: number
|
||||
kind: 'group'
|
||||
members: GroupMember[]
|
||||
name: string
|
||||
pinned: boolean
|
||||
}
|
||||
export function BotsPane() {
|
||||
const { t } = useI18n()
|
||||
const b = useBots()
|
||||
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 | RosterRow>(null)
|
||||
// `path` is the profile directory the gateway reports on a profiles.list row;
|
||||
// it is not part of the shared RosterRow model, so it rides as an extra here.
|
||||
const [deleting, setDeleting] = useState<null | (RosterRow & { path?: string })>(null)
|
||||
const [deletingGroup, setDeletingGroup] = useState<null | { members: GroupMember[]; name: string }>(null)
|
||||
const [grouping, setGrouping] = useState<null | RosterRow>(null)
|
||||
const [query, setQuery] = useState('')
|
||||
const [rowKindFilter, setRowKindFilter] = useState<RosterKindFilter>('all')
|
||||
const [activityFilter, setActivityFilter] = useState<RosterActivityFilter>('all')
|
||||
const [gatewayFilter, setGatewayFilter] = useState('all')
|
||||
const [collapsedRosterSections, setCollapsedRosterSections] = useState<Set<string>>(() => new Set())
|
||||
const hiddenSectionRef = useRef<null | HTMLDivElement>(null)
|
||||
const activityToasts = useValue($activityToasts)
|
||||
const groupChatName = useValue($groupChatWorkspace)
|
||||
// Main-tab ownership is a module Map; this rev subscription makes the
|
||||
// shouldRenderGroupChatInPane gate below reactive to tab open/close
|
||||
// (#89788 follow-up — without it a stale render could paint the in-pane
|
||||
// room beside a live main tab and stick).
|
||||
useValue($groupMainTabsRev)
|
||||
const groupNeedsYou = useValue($groupNeedsYou)
|
||||
const groupRooms = useValue($groupChats)
|
||||
const rememberedSources = useValue($lastSources)
|
||||
const rosterHydrated = useValue($rosterHydrated)
|
||||
const selectionHydrated = useValue($selectedRosterHydrated)
|
||||
const selectedRosterKey = useValue($selectedRosterKey)
|
||||
|
||||
// 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: RosterRow): number => {
|
||||
const created = botRosterMeta(bot, allMeta)?.created || bot.ui_meta?.['hermes-bots']?.created || 0
|
||||
const lastMsg = (botActivitySession(bot)?.last_active || 0) * 1000
|
||||
return Math.max(created, lastMsg)
|
||||
}
|
||||
// Pin is a source-qualified Desktop preference, not gateway profile state.
|
||||
const isPinned = (bot: RosterRow): boolean => isBotPinned(bot, allMeta)
|
||||
// Resilience (@wesleysimplicio, #13): a failed refresh must not erase a
|
||||
// roster the user already had — mixed local+cloud gateways and remotes
|
||||
// waking from sleep fail transiently. Render the last good snapshot with
|
||||
// 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 sourceSnapshot = Array.isArray(data?.sources) ? data.sources : rememberedSources
|
||||
const sourceWithSelectedOwner =
|
||||
selectionHydrated && rosterHydrated ? rosterWithSelectedOwner(source, sourceSnapshot, selectedRosterKey) : source
|
||||
const roster = sourceWithSelectedOwner.slice().sort((a, b) => {
|
||||
const pa = isPinned(a) ? 1 : 0
|
||||
const pb = isPinned(b) ? 1 : 0
|
||||
if (pa !== pb) {
|
||||
return pb - pa
|
||||
}
|
||||
return activityOf(b) - activityOf(a)
|
||||
})
|
||||
// React Query can briefly report neither loading nor data while the plugin
|
||||
// and the persisted connection registry hydrate. Keep that transition in a
|
||||
// neutral loading state instead of flashing the first-run "No bots" copy.
|
||||
const initialRosterLoading = !data && !error && roster.length === 0
|
||||
const activeRosterKeys = new Set(activeBots(roster, activeProfile, gatewayState).map(botRosterKey))
|
||||
const gatewayOptions = rosterGatewayOptions(sourceSnapshot, roster)
|
||||
const selectedGateway = gatewayOptions.find(option => option.connectionId === gatewayFilter)
|
||||
const gatewayFilterExists = gatewayFilter === 'all' || Boolean(selectedGateway)
|
||||
useEffect(() => {
|
||||
if (!gatewayFilterExists) {
|
||||
setGatewayFilter('all')
|
||||
}
|
||||
}, [gatewayFilterExists])
|
||||
const activeSourceRoster = roster.filter(bot => !bot.remoteSource)
|
||||
// Hidden rows remain fully alive and recoverable at the bottom. Every
|
||||
// non-display consumer continues to receive the complete roster.
|
||||
const hiddenExpanded = useValue($showHiddenBots)
|
||||
const hiddenBots = roster.filter(bot => isBotHidden(bot, allMeta))
|
||||
const visibleRoster = roster.filter(bot => !isBotHidden(bot, allMeta))
|
||||
const gatewayRoster = filterBotsByGateway(visibleRoster, gatewayFilter)
|
||||
const filteredRoster = filterBots(gatewayRoster, allMeta, query).filter((bot: RosterRow) =>
|
||||
rosterActivityMatches(
|
||||
{
|
||||
activity: activityOf(bot),
|
||||
active: activeRosterKeys.has(botRosterKey(bot))
|
||||
},
|
||||
activityFilter
|
||||
)
|
||||
)
|
||||
const filteredHiddenBots = filterBots(filterBotsByGateway(hiddenBots, gatewayFilter), allMeta, query).filter(
|
||||
(bot: RosterRow) =>
|
||||
rosterActivityMatches(
|
||||
{
|
||||
activity: activityOf(bot),
|
||||
active: activeRosterKeys.has(botRosterKey(bot))
|
||||
},
|
||||
activityFilter
|
||||
)
|
||||
)
|
||||
const groupNames = groupChatNames(allMeta, groupRooms)
|
||||
const groupRows = groupNames
|
||||
.map(name => ({
|
||||
name,
|
||||
members: groupChatMemberBots(name, roster, allMeta)
|
||||
}))
|
||||
.filter(row => groupMatchesRosterFilters(row.name, row.members, allMeta, query, gatewayFilter))
|
||||
.map((row): RosterGroupRow => ({
|
||||
kind: 'group',
|
||||
name: row.name,
|
||||
members: row.members,
|
||||
pinned: Boolean(groupRooms[row.name]?.pinned),
|
||||
activity: groupLastActivity(groupRooms[row.name]),
|
||||
active:
|
||||
Boolean(
|
||||
groupLastActivity(groupRooms[row.name]) &&
|
||||
Date.now() - groupLastActivity(groupRooms[row.name]) <= ACTIVE_WINDOW_S * 1000
|
||||
) || row.members.some(member => activeRosterKeys.has(botRosterKey(member)))
|
||||
}))
|
||||
.filter(row => rowKindFilter !== 'bots' && rosterActivityMatches(row, activityFilter))
|
||||
const botRows =
|
||||
rowKindFilter === 'groups'
|
||||
? []
|
||||
: preferReachableSameNameRows(filteredRoster).map((bot): RosterBotRow => ({
|
||||
kind: 'bot',
|
||||
bot,
|
||||
pinned: isPinned(bot),
|
||||
activity: activityOf(bot),
|
||||
active: activeRosterKeys.has(botRosterKey(bot))
|
||||
}))
|
||||
const sortRosterRows = <T extends { activity: number; pinned: boolean }>(rows: T[]): T[] =>
|
||||
rows.slice().sort((a, b) => {
|
||||
const pa = a.pinned ? 1 : 0
|
||||
const pb = b.pinned ? 1 : 0
|
||||
if (pa !== pb) {
|
||||
return pb - pa
|
||||
}
|
||||
return b.activity - a.activity
|
||||
})
|
||||
const rosterRows = sortRosterRows([...botRows, ...groupRows])
|
||||
const sortedGroupRows = sortRosterRows(groupRows)
|
||||
const gatewaySections = rosterGatewaySections(botRows, gatewayOptions, gatewayFilter)
|
||||
const showGatewaySections = gatewaySections.sectioned && botRows.length > 0
|
||||
const activeFilterCount =
|
||||
(rowKindFilter === 'all' ? 0 : 1) + (activityFilter === 'all' ? 0 : 1) + (gatewayFilter === 'all' ? 0 : 1)
|
||||
const hasRosterConstraint = Boolean(query.trim()) || activeFilterCount > 0
|
||||
const matchingHiddenBots = rowKindFilter === 'groups' ? [] : filteredHiddenBots
|
||||
const showHiddenSection = hiddenBots.length > 0 && (!hasRosterConstraint || matchingHiddenBots.length > 0)
|
||||
const showHiddenRows = hiddenExpanded || hasRosterConstraint
|
||||
const rosterItemCount = roster.length + groupNames.length
|
||||
const allBotsHidden =
|
||||
!hasRosterConstraint && visibleRoster.length === 0 && groupNames.length === 0 && hiddenBots.length > 0
|
||||
const showRosterSearch =
|
||||
gatewayOptions.length > 1 || rosterItemCount >= BOT_ROSTER_SEARCH_THRESHOLD || Boolean(query.trim())
|
||||
const showRosterFilters =
|
||||
gatewayOptions.length > 1 ||
|
||||
groupNames.length > 0 ||
|
||||
rosterItemCount >= BOT_ROSTER_SEARCH_THRESHOLD ||
|
||||
activeFilterCount > 0
|
||||
const showRosterTools = showRosterSearch || showRosterFilters
|
||||
const rosterSectionCollapsed = (id: string): boolean => !hasRosterConstraint && collapsedRosterSections.has(id)
|
||||
const hiddenGatewaySections = rosterGatewaySections(
|
||||
matchingHiddenBots.map((bot: RosterRow) => ({
|
||||
kind: 'bot',
|
||||
bot
|
||||
})),
|
||||
gatewayOptions,
|
||||
gatewayFilter
|
||||
)
|
||||
const toggleRosterSection = (id: string): void => {
|
||||
setCollapsedRosterSections(previous => {
|
||||
const next = new Set(previous)
|
||||
if (next.has(id)) {
|
||||
next.delete(id)
|
||||
} else {
|
||||
next.add(id)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!hiddenExpanded || hasRosterConstraint) {
|
||||
return
|
||||
}
|
||||
const frame = requestAnimationFrame(() =>
|
||||
hiddenSectionRef.current?.scrollIntoView({
|
||||
block: 'nearest'
|
||||
})
|
||||
)
|
||||
return () => cancelAnimationFrame(frame)
|
||||
}, [hiddenExpanded, hasRosterConstraint])
|
||||
useEffect(() => {
|
||||
if (!live) {
|
||||
return
|
||||
}
|
||||
|
||||
// Offline-owner ghosts belong only to this render. Shared roster state
|
||||
// feeds merge caching, group membership, creation, and durable sync. These
|
||||
// writes must settle after render: other subscribers of the same atoms
|
||||
// would otherwise be updated while BotsPane was still rendering.
|
||||
$lastRoster.set(roster.filter(row => !row?.ghost))
|
||||
if (Array.isArray(data?.sources)) {
|
||||
$lastSources.set(data.sources)
|
||||
}
|
||||
mergeServerMeta(activeSourceRoster, data?.fetchedAt || 0)
|
||||
pullServerAvatars(activeSourceRoster)
|
||||
trackInboundActivity(roster)
|
||||
backfillMessagingProtocol(activeSourceRoster)
|
||||
// React Query owns the stable server snapshot; derived arrays intentionally
|
||||
// follow that snapshot rather than retriggering on their own atom writes.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data])
|
||||
|
||||
// The roster has ANSWERED once data or a terminal error exists — that, not
|
||||
// row count, is what lets this pane stop showing its loading state (an empty
|
||||
// answer is a real answer; a pending one must not flash "No bots"). Keep the
|
||||
// persisted-selection writes out of render: React may replay a render, but
|
||||
// an abandoned render must never become a storage mutation.
|
||||
useEffect(() => {
|
||||
if (!data && !error) {
|
||||
return
|
||||
}
|
||||
$rosterHydrated.set(true)
|
||||
if (selectionHydrated) {
|
||||
reconcileRosterSelection(roster, sourceSnapshot, allMeta)
|
||||
const selected = selectedRosterBot(roster, $selectedRosterKey.get())
|
||||
if ($botsPaneVisible.get() && !$groupChatWorkspace.get() && selected) {
|
||||
setBotsWorkspaceOwner(botWorkspaceOwnerKey(selected), selected)
|
||||
}
|
||||
}
|
||||
}, [data, error, selectionHydrated, roster, sourceSnapshot, allMeta])
|
||||
const staleNotice =
|
||||
error && !live && roster.length
|
||||
? 'Roster refresh failed — showing the last good list.' +
|
||||
(gatewayUp ? '' : ' Waiting for the gateway to reconnect…')
|
||||
: null
|
||||
const groupChatMembers = groupChatName ? groupChatMemberBots(groupChatName, roster, allMeta) : []
|
||||
if (shouldRenderGroupChatInPane(groupChatName) && groupChatMembers.length) {
|
||||
return <GroupChatWorkspace group={groupChatName} members={groupChatMembers} />
|
||||
}
|
||||
const renderBotRow = (bot: RosterRow, keyPrefix = '') => (
|
||||
<BotRow
|
||||
key={`${keyPrefix}${botRosterKey(bot)}`}
|
||||
bot={bot}
|
||||
onDelete={setDeleting}
|
||||
onEdit={setEditing}
|
||||
onGroup={setGrouping}
|
||||
showHandle={botNeedsHandleLabel(bot, roster, allMeta)}
|
||||
/>
|
||||
)
|
||||
const renderGroupRow = (row: { members: GroupMember[]; name: string }) => (
|
||||
<GroupRow
|
||||
key={`group:${row.name}`}
|
||||
active={groupChatName === row.name}
|
||||
group={row.name}
|
||||
members={row.members}
|
||||
needsYou={Boolean(groupNeedsYou[row.name])}
|
||||
onOpen={openGroupChat}
|
||||
onDisband={setDeletingGroup}
|
||||
/>
|
||||
)
|
||||
const renderGatewaySection = (section: ResolvedRosterGatewaySection) => {
|
||||
const sectionId = `gateway:${section.id}`
|
||||
const collapsed = rosterSectionCollapsed(sectionId)
|
||||
return (
|
||||
<div key={sectionId} className="min-w-0">
|
||||
<GatewaySectionHeading
|
||||
collapsed={collapsed}
|
||||
count={section.rows.length}
|
||||
onToggle={() => toggleRosterSection(sectionId)}
|
||||
option={section.option}
|
||||
/>
|
||||
{collapsed ? null : (
|
||||
<div className="grid min-w-0 gap-0.5">{section.rows.map(row => renderBotRow(row.bot, `${section.id}:`))}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const renderGroupChatSection = () => {
|
||||
const sectionId = 'group-chats'
|
||||
const collapsed = rosterSectionCollapsed(sectionId)
|
||||
return (
|
||||
<div key={sectionId} className="min-w-0">
|
||||
<RosterSectionHeader
|
||||
collapsed={collapsed}
|
||||
count={sortedGroupRows.length}
|
||||
icon="organization"
|
||||
label={b.roster.groupChats}
|
||||
onToggle={() => toggleRosterSection(sectionId)}
|
||||
tip={`${sortedGroupRows.length} global group chat${sortedGroupRows.length === 1 ? '' : 's'}`}
|
||||
/>
|
||||
{collapsed ? null : <div className="grid min-w-0 gap-0.5">{sortedGroupRows.map(renderGroupRow)}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const renderHiddenGatewaySection = (section: ResolvedRosterGatewaySection) => (
|
||||
<div key={`hidden-gateway:${section.id}`} className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-1.5 px-2 py-1 text-[0.625rem] font-semibold uppercase tracking-wider text-(--ui-text-quaternary)">
|
||||
<GatewayKindGlyph kind={section.option?.kind} />
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{section.option?.label || section.option?.connectionId || 'Current gateway'}
|
||||
</span>
|
||||
<span className="shrink-0 font-normal tabular-nums">{section.rows.length}</span>
|
||||
</div>
|
||||
{section.rows.map(row => renderBotRow(row.bot, `hidden:${section.id}:`))}
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center justify-between gap-2 px-2.5 pt-2.5 pb-1.5">
|
||||
<span className="text-[0.6875rem] font-semibold uppercase tracking-wider text-(--ui-text-quaternary)">
|
||||
Bots
|
||||
</span>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Tip
|
||||
label={activityToasts ? 'Activity toasts on — click to silence' : 'Activity toasts off — click to enable'}
|
||||
>
|
||||
<Button
|
||||
className="rounded-md text-(--ui-text-tertiary) hover:text-foreground"
|
||||
onClick={() => setActivityToasts(!activityToasts)}
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name={activityToasts ? 'bell' : 'bell-slash'} />
|
||||
</Button>
|
||||
</Tip>
|
||||
<DropdownMenu>
|
||||
<Tip label="New…">
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label={b.roster.newBotOrGroup}
|
||||
className="rounded-md text-(--ui-text-tertiary) hover:text-foreground"
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="add" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</Tip>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onSelect={() => setCreateOpen(true)}>
|
||||
<Codicon name="hubot" className="mr-1.5" />
|
||||
{b.bot.newTitle}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled={activeSourceRoster.length < 2} onSelect={() => setGroupCreateOpen(true)}>
|
||||
<Codicon name="organization" className="mr-1.5" />
|
||||
{b.group.newTitle}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
{showRosterTools ? (
|
||||
<div className="flex min-w-0 items-center gap-1 px-2.5 pb-1.5">
|
||||
{showRosterSearch ? (
|
||||
<SearchField
|
||||
key={'roster-search'}
|
||||
aria-label={b.roster.search}
|
||||
containerClassName={cn('min-w-0 flex-1', query ? 'opacity-100!' : 'opacity-50 focus-within:opacity-100')}
|
||||
inputClassName="w-full text-[0.75rem] placeholder:text-(--ui-text-tertiary)"
|
||||
placeholder={b.roster.searchPlaceholder}
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
/>
|
||||
) : (
|
||||
<span key={'roster-search-spacer'} className="min-w-0 flex-1" />
|
||||
)}
|
||||
{showRosterFilters ? (
|
||||
<DropdownMenu key={'roster-filters'}>
|
||||
<Tip label={activeFilterCount ? `Filters (${activeFilterCount} active)` : 'Filter roster'}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label={activeFilterCount ? `Filter roster, ${activeFilterCount} active` : 'Filter roster'}
|
||||
className={cn(
|
||||
'size-7 shrink-0 rounded-md text-(--ui-text-tertiary) hover:text-foreground',
|
||||
activeFilterCount && 'text-(--ui-accent)'
|
||||
)}
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="list-filter" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</Tip>
|
||||
<DropdownMenuContent align="end">
|
||||
{(
|
||||
[
|
||||
['all', 'Bots and group chats'],
|
||||
['bots', 'Bots only'],
|
||||
['groups', 'Group chats only']
|
||||
] as [RosterKindFilter, string][]
|
||||
).map(([value, label]) => (
|
||||
<DropdownMenuItem key={`kind:${value}`} onSelect={() => setRowKindFilter(value)}>
|
||||
<span className="min-w-0 flex-1">{label}</span>
|
||||
{rowKindFilter === value ? <Codicon name="check" /> : null}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
{(
|
||||
[
|
||||
['all', 'Any activity'],
|
||||
['active', b.roster.activeNow],
|
||||
['recent', 'Recently active'],
|
||||
['older', 'Older']
|
||||
] as [RosterActivityFilter, string][]
|
||||
).map(([value, label]) => (
|
||||
<DropdownMenuItem key={`activity:${value}`} onSelect={() => setActivityFilter(value)}>
|
||||
<span className="min-w-0 flex-1">{label}</span>
|
||||
{activityFilter === value ? <Codicon name="check" /> : null}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{gatewayOptions.length > 1 ? <DropdownMenuSeparator /> : null}
|
||||
{gatewayOptions.length > 1 ? (
|
||||
<DropdownMenuItem onSelect={() => setGatewayFilter('all')}>
|
||||
<Codicon name="globe" className="mr-1.5" />
|
||||
<span className="min-w-0 flex-1">All gateways</span>
|
||||
{gatewayFilter === 'all' ? <Codicon name="check" /> : null}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{gatewayOptions.length > 1
|
||||
? gatewayOptions.map(option => {
|
||||
const status = botSourceStatus({
|
||||
sourceError: option.error,
|
||||
sourceReachable: option.reachable
|
||||
})
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={option.connectionId}
|
||||
onSelect={() => setGatewayFilter(option.connectionId)}
|
||||
>
|
||||
<GatewayKindGlyph
|
||||
kind={option.kind}
|
||||
className={cn('mr-1.5', !status.available && 'text-amber-600 dark:text-amber-300')}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate">{option.label || option.connectionId}</span>
|
||||
<span className="text-[0.625rem] tabular-nums text-(--ui-text-quaternary)">
|
||||
{option.count}
|
||||
</span>
|
||||
{gatewayFilter === option.connectionId ? <Codicon name="check" /> : null}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})
|
||||
: []}
|
||||
{activeFilterCount ? <DropdownMenuSeparator /> : null}
|
||||
{activeFilterCount ? (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
setRowKindFilter('all')
|
||||
setActivityFilter('all')
|
||||
setGatewayFilter('all')
|
||||
}}
|
||||
>
|
||||
{b.roster.clearFilters}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{staleNotice ? (
|
||||
<div className="mx-2.5 mb-1 rounded-md bg-(--chrome-action-hover) px-2 py-1.5 text-[0.6875rem] text-(--ui-text-tertiary)">
|
||||
{staleNotice}
|
||||
</div>
|
||||
) : null}
|
||||
{(isLoading || initialRosterLoading) && !roster.length ? (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<GlyphSpinner spinner="breathe" className="text-(--ui-text-tertiary)" />
|
||||
</div>
|
||||
) : error && !roster.length ? (
|
||||
<div className="grid gap-2 px-3 py-4 text-xs text-(--ui-text-tertiary)">
|
||||
<div>
|
||||
{gatewayUp
|
||||
? b.roster.rosterUnavailable(error instanceof Error ? error.message : 'gateway error')
|
||||
: b.roster.waitingForGateway}
|
||||
</div>
|
||||
<Button variant="secondary" size="sm" className="justify-self-start" onClick={() => void refetch()}>
|
||||
{b.roster.retryNow}
|
||||
</Button>
|
||||
</div>
|
||||
) : roster.length === 0 ? (
|
||||
<PanelEmpty description={b.roster.emptyDesc} icon="hubot" title={b.roster.emptyTitle} />
|
||||
) : allBotsHidden && !hiddenExpanded ? (
|
||||
<div className="grid content-start gap-2 px-3 py-4 text-xs text-(--ui-text-tertiary)">
|
||||
<div className="flex items-center gap-1.5 font-medium text-(--ui-text-secondary)">
|
||||
<Codicon name="eye-closed" className="text-(--ui-text-quaternary)" />
|
||||
{b.roster.allHidden}
|
||||
</div>
|
||||
<p className="leading-relaxed">{b.roster.allHiddenDesc}</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="justify-self-start"
|
||||
onClick={() => $showHiddenBots.set(true)}
|
||||
>
|
||||
{b.roster.showHidden}
|
||||
</Button>
|
||||
</div>
|
||||
) : rosterRows.length === 0 && matchingHiddenBots.length === 0 ? (
|
||||
<div aria-live="polite" className="flex min-h-0 flex-1 flex-col" role="status">
|
||||
<PanelEmpty
|
||||
description={
|
||||
query.trim()
|
||||
? selectedGateway
|
||||
? b.roster.noMatchQueryOn(query.trim(), String(selectedGateway.label))
|
||||
: b.roster.noMatchQuery(query.trim())
|
||||
: selectedGateway
|
||||
? b.roster.noMatchFiltersOn(String(selectedGateway.label))
|
||||
: b.roster.noMatchFilters
|
||||
}
|
||||
icon="search"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
|
||||
<div className="grid w-full min-w-0 gap-0.5 px-1.5 pb-2">
|
||||
{showGatewaySections
|
||||
? [
|
||||
sortedGroupRows.length ? renderGroupChatSection() : null,
|
||||
...gatewaySections.sections.map(renderGatewaySection)
|
||||
].filter(Boolean)
|
||||
: rosterRows.map(row => (row.kind === 'group' ? renderGroupRow(row) : renderBotRow(row.bot)))}
|
||||
{showHiddenSection ? (
|
||||
<div
|
||||
key={'hidden-section'}
|
||||
ref={hiddenSectionRef}
|
||||
className="mt-1 border-t border-(--ui-stroke-tertiary) pt-1"
|
||||
>
|
||||
{hasRosterConstraint ? (
|
||||
<div className="flex w-full items-center gap-1 px-2 py-1.5 text-[0.6875rem] font-medium text-(--ui-text-tertiary)">
|
||||
<Codicon name="eye-closed" />
|
||||
<span>Hidden</span>
|
||||
<span className="text-(--ui-text-quaternary)">{matchingHiddenBots.length}</span>
|
||||
</div>
|
||||
) : (
|
||||
<RowButton
|
||||
aria-expanded={hiddenExpanded}
|
||||
className="flex w-full items-center gap-1 rounded-md px-2 py-1.5 text-left text-[0.6875rem] font-medium text-(--ui-text-tertiary) transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground"
|
||||
onClick={() => $showHiddenBots.set(!hiddenExpanded)}
|
||||
>
|
||||
<DisclosureCaret open={hiddenExpanded} />
|
||||
<span>Hidden</span>
|
||||
<span className="text-(--ui-text-quaternary)">{hiddenBots.length}</span>
|
||||
</RowButton>
|
||||
)}
|
||||
{showHiddenRows ? (
|
||||
matchingHiddenBots.length ? (
|
||||
hiddenGatewaySections.sectioned ? (
|
||||
hiddenGatewaySections.sections.map(renderHiddenGatewaySection)
|
||||
) : (
|
||||
matchingHiddenBots.map((bot: RosterRow) => renderBotRow(bot, 'hidden:'))
|
||||
)
|
||||
) : (
|
||||
<div className="px-2 py-2 text-xs text-(--ui-text-quaternary)">{b.roster.noHiddenMatch}</div>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<CreateAgentDialog
|
||||
open={createOpen}
|
||||
onClose={() => {
|
||||
setCreateOpen(false)
|
||||
void refetch()
|
||||
}}
|
||||
roster={activeSourceRoster}
|
||||
/>
|
||||
<CreateGroupChatDialog
|
||||
open={groupCreateOpen} // Full multi-source roster: group chats can seat bots from other
|
||||
// registered connections — their turns route to their own machines.
|
||||
roster={roster}
|
||||
onClose={() => setGroupCreateOpen(false)}
|
||||
onCreated={groupName => openGroupChat(groupName)}
|
||||
/>
|
||||
<EditProfileDialog
|
||||
bot={editing}
|
||||
open={Boolean(editing)}
|
||||
onClose={() => {
|
||||
setEditing(null)
|
||||
void refetch()
|
||||
}}
|
||||
/>
|
||||
{grouping ? <GroupDialog bot={grouping} onClose={() => setGrouping(null)} /> : null}
|
||||
<ConfirmDialog
|
||||
open={Boolean(deleting)}
|
||||
title={b.bot.deleteTitle}
|
||||
description={
|
||||
deleting ? (
|
||||
<span>
|
||||
{'This will permanently delete the bot '}
|
||||
<span className="font-medium text-foreground">{deleting.name}</span>
|
||||
{' and its associated Hermes profile at '}
|
||||
<span className="font-mono text-xs">{deleting.path}</span>. This cannot be undone.
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
destructive
|
||||
confirmLabel={t.common.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}`
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={Boolean(deletingGroup)}
|
||||
title={b.group.deleteTitle}
|
||||
description={
|
||||
deletingGroup
|
||||
? `This removes “${deletingGroup.name}” from its bots and clears the shared room log. The bots and their individual chats are kept.`
|
||||
: null
|
||||
}
|
||||
destructive
|
||||
confirmLabel={b.group.deleteAction}
|
||||
busyLabel="Deleting…"
|
||||
doneLabel="Deleted"
|
||||
onClose={() => setDeletingGroup(null)}
|
||||
onConfirm={async () => {
|
||||
if (!deletingGroup) return
|
||||
await disbandGroupChat(deletingGroup.name, deletingGroup.members)
|
||||
host.notify({
|
||||
kind: 'success',
|
||||
message: `Deleted group “${deletingGroup.name}”`
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* How the roster groups and labels what it lists: the gateway filter, the
|
||||
* per-connection bucketing, and the foldable headings those buckets render.
|
||||
*
|
||||
* Presentation below the pane. The headings take everything they draw as
|
||||
* props and the bucketing is pure, so the roster pane can compose them
|
||||
* without either half knowing about a bot row.
|
||||
*/
|
||||
|
||||
import * as sdk from '@hermes/plugin-sdk'
|
||||
import { cn, Codicon, DisclosureCaret, RowButton, Tip } from '@hermes/plugin-sdk'
|
||||
|
||||
import { botHandle, botRosterKey, botSourceStatus, filterBots } from './data'
|
||||
import { displayName } from './labels'
|
||||
import { botRosterMeta } from './routing'
|
||||
import type { BotMeta, GatewaySource, RosterRow } from './types'
|
||||
|
||||
export function filterBotsByGateway(roster: RosterRow[], connectionId: string) {
|
||||
if (!connectionId || connectionId === 'all') {
|
||||
return roster
|
||||
}
|
||||
return (roster || []).filter(bot => String(bot?.connectionId || '') === connectionId)
|
||||
}
|
||||
export function botNeedsHandleLabel(bot: RosterRow, roster: RosterRow[], metaByName: Record<string, BotMeta>) {
|
||||
const identity = displayName(bot, botRosterMeta(bot, metaByName)).trim().toLowerCase()
|
||||
const connectionId = String(bot?.connectionId || '')
|
||||
return (roster || []).some(
|
||||
candidate =>
|
||||
botRosterKey(candidate) !== botRosterKey(bot) &&
|
||||
String(candidate?.connectionId || '') === connectionId &&
|
||||
displayName(candidate, botRosterMeta(candidate, metaByName)).trim().toLowerCase() === identity &&
|
||||
botHandle(candidate.name, candidate) !== botHandle(bot.name, bot)
|
||||
)
|
||||
}
|
||||
export function groupMatchesRosterFilters(
|
||||
name: string,
|
||||
members: RosterRow[],
|
||||
metaByName: Record<string, BotMeta>,
|
||||
query: string,
|
||||
connectionId: string
|
||||
) {
|
||||
const inGateway = filterBotsByGateway(members, connectionId)
|
||||
if (connectionId && connectionId !== 'all' && inGateway.length === 0) {
|
||||
return false
|
||||
}
|
||||
const needle = String(query || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^@/, '')
|
||||
return (
|
||||
!needle ||
|
||||
String(name || '')
|
||||
.toLowerCase()
|
||||
.includes(needle) ||
|
||||
filterBots(inGateway, metaByName, needle).length > 0
|
||||
)
|
||||
}
|
||||
|
||||
/** A gateway source as the roster's picker carries it: a running row count,
|
||||
* and `reachable` widened to the roster row's null ("status unknown"), which
|
||||
* GatewaySource does not model. */
|
||||
interface RosterGatewayOption extends Omit<GatewaySource, 'reachable'> {
|
||||
count?: number
|
||||
reachable?: boolean | null
|
||||
}
|
||||
|
||||
/** A sidebar roster row (`{ kind, bot, … }`), or a bare roster row for
|
||||
* callers that hand the bots over directly. */
|
||||
interface RosterGatewayRow extends Partial<RosterRow> {
|
||||
bot?: RosterRow
|
||||
}
|
||||
|
||||
interface RosterGatewaySection<TRow extends RosterGatewayRow = RosterGatewayRow> {
|
||||
id: string
|
||||
option: null | RosterGatewayOption
|
||||
rows: TRow[]
|
||||
}
|
||||
|
||||
/** Sections built from rows that always carry their resolved roster row. */
|
||||
export type ResolvedRosterGatewaySection = RosterGatewaySection<RosterGatewayRow & { bot: RosterRow }>
|
||||
export function rosterGatewayOptions(sources: GatewaySource[], roster: RosterRow[]) {
|
||||
const byId = new Map<string, RosterGatewayOption & { count: number }>()
|
||||
for (const source of Array.isArray(sources) ? sources : []) {
|
||||
const id = String(source?.connectionId || '').trim()
|
||||
if (id) {
|
||||
byId.set(id, {
|
||||
...source,
|
||||
connectionId: id,
|
||||
count: 0
|
||||
})
|
||||
}
|
||||
}
|
||||
for (const bot of roster || []) {
|
||||
const id = String(bot?.connectionId || '').trim()
|
||||
if (!id) {
|
||||
continue
|
||||
}
|
||||
const source = byId.get(id) || {
|
||||
connectionId: id,
|
||||
kind: bot.connectionKind,
|
||||
label: bot.connectionLabel || id,
|
||||
reachable: bot.sourceReachable,
|
||||
error: bot.sourceError,
|
||||
count: 0
|
||||
}
|
||||
source.count += 1
|
||||
byId.set(id, source)
|
||||
}
|
||||
return [...byId.values()].sort((a, b) =>
|
||||
String(a.label || a.connectionId).localeCompare(String(b.label || b.connectionId), undefined, {
|
||||
sensitivity: 'base'
|
||||
})
|
||||
)
|
||||
}
|
||||
export function rosterGatewaySections<TRow extends RosterGatewayRow>(
|
||||
botRows: TRow[],
|
||||
gatewayOptions: RosterGatewayOption[],
|
||||
gatewayFilter = 'all'
|
||||
): { sectioned: boolean; sections: RosterGatewaySection<TRow>[] } {
|
||||
const rows = Array.isArray(botRows) ? botRows : []
|
||||
const options = Array.isArray(gatewayOptions) ? gatewayOptions : []
|
||||
if (gatewayFilter !== 'all' || options.length <= 1) {
|
||||
return {
|
||||
sectioned: false,
|
||||
sections: [
|
||||
{
|
||||
id: 'all',
|
||||
option: null,
|
||||
rows
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
const byId = new Map<string, TRow[]>()
|
||||
for (const row of rows) {
|
||||
const bot = row?.bot || row
|
||||
const id = String(bot?.connectionId || 'legacy').trim() || 'legacy'
|
||||
const bucket = byId.get(id) || []
|
||||
bucket.push(row)
|
||||
byId.set(id, bucket)
|
||||
}
|
||||
const known = new Set<string>()
|
||||
const sections: RosterGatewaySection<TRow>[] = []
|
||||
for (const option of options) {
|
||||
const id = String(option?.connectionId || '').trim()
|
||||
const sectionRows = byId.get(id)
|
||||
if (!id || !sectionRows?.length) {
|
||||
continue
|
||||
}
|
||||
known.add(id)
|
||||
sections.push({
|
||||
id,
|
||||
option,
|
||||
rows: sectionRows
|
||||
})
|
||||
}
|
||||
for (const [id, sectionRows] of byId) {
|
||||
if (known.has(id)) {
|
||||
continue
|
||||
}
|
||||
const bot = sectionRows[0]?.bot || sectionRows[0]
|
||||
sections.push({
|
||||
id,
|
||||
option: {
|
||||
connectionId: id,
|
||||
kind: bot?.connectionKind || 'remote',
|
||||
label: bot?.connectionLabel || (id === 'legacy' ? 'Current gateway' : id),
|
||||
reachable: bot?.sourceReachable,
|
||||
error: bot?.sourceError
|
||||
},
|
||||
rows: sectionRows
|
||||
})
|
||||
}
|
||||
return {
|
||||
sectioned: true,
|
||||
sections
|
||||
}
|
||||
}
|
||||
function gatewayKindIcon(kind?: string) {
|
||||
const icons: Partial<typeof sdk.icons> = (typeof sdk === 'undefined' ? null : sdk.icons) || {}
|
||||
if (kind === 'local') return icons.Monitor
|
||||
if (kind === 'cloud') return icons.Cloud
|
||||
if (kind === 'ssh') return icons.Terminal
|
||||
return icons.Network
|
||||
}
|
||||
function gatewayKindCodicon(kind?: string) {
|
||||
if (kind === 'local') return 'device-desktop'
|
||||
if (kind === 'cloud') return 'cloud'
|
||||
if (kind === 'ssh') return 'terminal'
|
||||
return 'remote-explorer'
|
||||
}
|
||||
|
||||
interface GatewayKindGlyphProps {
|
||||
className?: string
|
||||
kind?: string
|
||||
}
|
||||
|
||||
/** Match the gateway switcher's Tabler glyphs while keeping older SDK shells
|
||||
* usable until they expose the shared icon namespace. */
|
||||
export function GatewayKindGlyph({ className, kind }: GatewayKindGlyphProps) {
|
||||
const Icon = gatewayKindIcon(kind)
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn('grid size-3.5 shrink-0 place-items-center', className)}
|
||||
data-connection-kind={kind || 'remote'}
|
||||
data-slot="connection-glyph"
|
||||
>
|
||||
{Icon ? <Icon className="size-3" /> : <Codicon name={gatewayKindCodicon(kind)} className="text-[0.75rem]" />}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** Foldable roster heading. It organizes rows visually but never supplies or
|
||||
* reconstructs ownership; every action still receives the full bot row. */
|
||||
interface RosterSectionHeaderProps {
|
||||
collapsed: boolean
|
||||
count: number
|
||||
gatewayKind?: string
|
||||
icon?: string
|
||||
label: string
|
||||
onToggle: () => void
|
||||
status?: { available: boolean; label: string }
|
||||
tip?: string
|
||||
}
|
||||
export function RosterSectionHeader({
|
||||
collapsed,
|
||||
count,
|
||||
gatewayKind,
|
||||
icon,
|
||||
label,
|
||||
onToggle,
|
||||
status,
|
||||
tip
|
||||
}: RosterSectionHeaderProps) {
|
||||
const button = (
|
||||
<RowButton
|
||||
aria-expanded={!collapsed}
|
||||
className="mt-1 flex w-full min-w-0 items-center gap-1.5 rounded-md px-2 py-1.5 text-left text-[0.6875rem] font-semibold uppercase tracking-wider text-(--ui-text-quaternary) transition-colors hover:bg-(--chrome-action-hover) hover:text-(--ui-text-secondary)"
|
||||
onClick={onToggle}
|
||||
>
|
||||
<DisclosureCaret open={!collapsed} />
|
||||
{gatewayKind ? (
|
||||
<GatewayKindGlyph kind={gatewayKind} />
|
||||
) : (
|
||||
// TODO(bot-mode-types): neither `gatewayKind` nor `icon` is required, so a header
|
||||
// given neither renders `codicon-undefined`. Both current callers pass exactly one.
|
||||
// @ts-expect-error `icon` is optional here; Codicon's `name` is required.
|
||||
<Codicon name={icon} className="shrink-0" />
|
||||
)}
|
||||
<span className="flex min-w-0 items-center gap-1">
|
||||
<span className="min-w-0 truncate">{label}</span>
|
||||
{status && !status.available ? <span className="sr-only">{status.label}</span> : null}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1" aria-hidden />
|
||||
<span className="shrink-0 font-normal tabular-nums text-(--ui-text-quaternary)">{count}</span>
|
||||
{status && !status.available ? (
|
||||
<Codicon name="debug-disconnect" className="shrink-0 text-amber-600 dark:text-amber-300" aria-hidden />
|
||||
) : null}
|
||||
</RowButton>
|
||||
)
|
||||
return tip ? <Tip label={tip}>{button}</Tip> : button
|
||||
}
|
||||
interface GatewaySectionHeadingProps {
|
||||
collapsed: boolean
|
||||
count: number
|
||||
onToggle: () => void
|
||||
option?: RosterGatewayOption | null
|
||||
}
|
||||
export function GatewaySectionHeading({ collapsed, count, onToggle, option }: GatewaySectionHeadingProps) {
|
||||
const status = botSourceStatus({
|
||||
sourceError: option?.error,
|
||||
sourceReachable: option?.reachable
|
||||
})
|
||||
const label = option?.label || option?.connectionId || 'Current gateway'
|
||||
const kind = option?.kind || 'remote'
|
||||
return (
|
||||
<RosterSectionHeader
|
||||
collapsed={collapsed}
|
||||
count={count}
|
||||
gatewayKind={kind}
|
||||
label={label}
|
||||
onToggle={onToggle}
|
||||
status={status}
|
||||
tip={`${label} · ${kind} · ${status.label}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
/**
|
||||
* Cross-connection routing: resolving a roster row's owning connection, the
|
||||
* RPC door that rides that owner, and the alias identity that keeps a
|
||||
* configured Desktop alias attached to the backend row it activates into.
|
||||
*
|
||||
* The bottom of the Bot Mode module graph — it imports no sibling module, so
|
||||
* everything else can dispatch through it without a cycle.
|
||||
*/
|
||||
|
||||
import { host } from '@hermes/plugin-sdk'
|
||||
|
||||
import type { BotMeta, ProfileRoute, RosterRow } from './types'
|
||||
|
||||
export function botRouteKey(route: ProfileRoute): string {
|
||||
return `${route.connectionId}::${route.profile}`
|
||||
}
|
||||
|
||||
// ── 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).
|
||||
|
||||
/** Outcome of resolving a row's owning connection. `profile` is only carried
|
||||
* on the `owner_removed` branch, where the strict wrapper needs it for the
|
||||
* error message. */
|
||||
interface BotRouteResolution {
|
||||
profile?: string
|
||||
route: ProfileRoute | null
|
||||
status: 'not_scoped' | 'owner_removed' | 'resolved'
|
||||
}
|
||||
|
||||
/** Non-throwing resolver behind botConnectionRoute(). Returns a typed status
|
||||
* instead of throwing, so passive callers (display/meta lookups) can branch
|
||||
* on `resolved | owner_removed | not_scoped` rather than catching whatever
|
||||
* exception the strict wrapper below happens to throw. */
|
||||
export function resolveBotConnectionRoute(bot: Partial<RosterRow> | null | undefined): BotRouteResolution {
|
||||
if (!bot?.sourceScoped && !bot?.remoteSource) {
|
||||
return {
|
||||
status: 'not_scoped',
|
||||
route: null
|
||||
}
|
||||
}
|
||||
const candidate = bot.route || {
|
||||
connectionId: bot.connectionId,
|
||||
mode: bot.connectionKind === 'local' ? 'local' : 'remote',
|
||||
profile: bot.name,
|
||||
targetProfile: bot.targetProfile || bot.name
|
||||
}
|
||||
const connectionId = String(candidate?.connectionId || '').trim()
|
||||
const profile = String(candidate?.profile || bot?.name || '').trim() || 'default'
|
||||
const targetProfile = String(candidate?.targetProfile || profile).trim() || profile
|
||||
if (!connectionId) {
|
||||
return {
|
||||
status: 'owner_removed',
|
||||
route: null,
|
||||
profile
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: 'resolved',
|
||||
route: Object.freeze({
|
||||
connectionId,
|
||||
mode: candidate.mode === 'local' || connectionId === 'local' ? 'local' : 'remote',
|
||||
profile,
|
||||
targetProfile
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** Immutable owner descriptor for every source-scoped row. The active
|
||||
* gateway is presentation state and is never consulted here. Strict: throws
|
||||
* when the owning connection is gone -- correct for real dispatch
|
||||
* (requestForBot, session creation, etc.), covered by
|
||||
* remote-routing-races.test.mjs. Passive lookups (rendering, meta) must call
|
||||
* resolveBotConnectionRoute() directly instead of catching this throw. */
|
||||
export function botConnectionRoute(bot: Partial<RosterRow> | null | undefined): null | ProfileRoute {
|
||||
const resolved = resolveBotConnectionRoute(bot)
|
||||
if (resolved.status === 'owner_removed') {
|
||||
throw new Error(`Bot ${resolved.profile} has no connection owner`)
|
||||
}
|
||||
return resolved.route
|
||||
}
|
||||
export function botWorkspaceOwnerKey(bot: RosterRow) {
|
||||
// Render-reachable (sidebar sync, context menus): an orphaned row must
|
||||
// yield a stable degraded key, never the dispatch throw.
|
||||
const route = resolveBotConnectionRoute(bot).route
|
||||
return `bot:${route ? botRouteKey(route) : String(bot?.name || 'default')}`
|
||||
}
|
||||
export function setBotsWorkspaceOwner(
|
||||
ownerKey: string,
|
||||
bot: null | RosterRow = null,
|
||||
blockedMessage = 'Select a Bot or group first.'
|
||||
) {
|
||||
// Render-reachable (sidebar listener fires on visibility flips). An
|
||||
// orphaned row degrades to the blocked target instead of throwing.
|
||||
const route = bot ? resolveBotConnectionRoute(bot).route : null
|
||||
const target: { kind: 'blocked'; message: string } | { kind: 'route'; route: ProfileRoute } = route
|
||||
? {
|
||||
kind: 'route',
|
||||
route
|
||||
}
|
||||
: {
|
||||
kind: 'blocked',
|
||||
message: blockedMessage
|
||||
}
|
||||
host.setWorkspaceScope?.('bots', ownerKey, target)
|
||||
}
|
||||
export function backendTargetProfile(route: null | ProfileRoute | undefined, fallbackProfile = 'default') {
|
||||
if (!route) {
|
||||
return fallbackProfile
|
||||
}
|
||||
return route.targetProfile || route.profile
|
||||
}
|
||||
function rewriteCliProfileOperands(argv: string[], logical: string, target: string) {
|
||||
const next = [...argv]
|
||||
for (let index = 0; index < next.length; index += 1) {
|
||||
if (next[index] === '--profile' && next[index + 1] === logical) {
|
||||
next[index + 1] = target
|
||||
index += 1
|
||||
} else if (next[index] === `--profile=${logical}`) {
|
||||
next[index] = `--profile=${target}`
|
||||
}
|
||||
}
|
||||
const profileCommand = next.indexOf('profile')
|
||||
const operand = profileCommand >= 0 ? profileCommand + 2 : -1
|
||||
if (operand < next.length && next[operand] === logical) {
|
||||
next[operand] = target
|
||||
}
|
||||
return next
|
||||
}
|
||||
function scopedBotParams(route: ProfileRoute, method: string, params: Record<string, unknown>) {
|
||||
const logical = route.profile
|
||||
const target = backendTargetProfile(route, logical)
|
||||
let next = params
|
||||
if (Object.prototype.hasOwnProperty.call(params, 'profile')) {
|
||||
next = {
|
||||
...next,
|
||||
profile: target
|
||||
}
|
||||
}
|
||||
if (method.startsWith('profiles.') && method !== 'profiles.create' && params.name === logical) {
|
||||
next = {
|
||||
...next,
|
||||
name: target
|
||||
}
|
||||
}
|
||||
if (params.clone_from === logical) {
|
||||
next = {
|
||||
...next,
|
||||
clone_from: target
|
||||
}
|
||||
}
|
||||
if (method === 'cli.exec' && Array.isArray(params.argv)) {
|
||||
next = {
|
||||
...next,
|
||||
argv: rewriteCliProfileOperands(params.argv, logical, target)
|
||||
}
|
||||
}
|
||||
return next
|
||||
}
|
||||
export function botBackendProfileScope(route: null | ProfileRoute | undefined, fallbackProfile = 'default') {
|
||||
if (!route) {
|
||||
return fallbackProfile
|
||||
}
|
||||
return {
|
||||
connectionId: route.connectionId,
|
||||
profile: backendTargetProfile(route, fallbackProfile)
|
||||
}
|
||||
}
|
||||
|
||||
/** Gateway RPC on the bot's OWN source. Source-scoped rows always use the
|
||||
* explicit descriptor, including a registered local source. */
|
||||
export async function requestForBot<T = unknown>(
|
||||
bot: Partial<RosterRow> | null | undefined,
|
||||
method: string,
|
||||
params: Record<string, unknown> = {}
|
||||
): Promise<T> {
|
||||
const route = botConnectionRoute(bot)
|
||||
if (route) {
|
||||
if (typeof host.requestProfile !== 'function') {
|
||||
throw new Error(`Cannot route ${method} for ${route.connectionId}::${route.profile}`)
|
||||
}
|
||||
try {
|
||||
return await host.requestProfile(route, method, scopedBotParams(route, method, params))
|
||||
} catch (error) {
|
||||
// React 19 formats query errors with `(error.name || '').trim()`. IPC /
|
||||
// JSON-RPC rejections are often plain objects whose `name` is a number,
|
||||
// which crashes the Routines pane and hides the original failure (#94471).
|
||||
throw asRpcError(error, `Gateway request ${method} failed`)
|
||||
}
|
||||
}
|
||||
try {
|
||||
return await host.request(method, params)
|
||||
} catch (error) {
|
||||
throw asRpcError(error, `Gateway request ${method} failed`)
|
||||
}
|
||||
}
|
||||
|
||||
/** A rejection duck-typed across realms: an Error-like whose fields are only
|
||||
* conventionally typed, so every read stays `unknown` until it is checked. */
|
||||
export interface RpcErrorLike {
|
||||
message?: unknown
|
||||
name?: unknown
|
||||
stack?: unknown
|
||||
}
|
||||
|
||||
/** Coerce an IPC/JSON-RPC rejection into an Error with a string `name`.
|
||||
*
|
||||
* React Query stores whatever the queryFn throws. React 19 then formats it
|
||||
* with `(e.name || '').trim()`, which throws TypeError when `name` is a
|
||||
* number (JSON-RPC codes) or another non-string — the Routines pane crash
|
||||
* in #94471. Real Error instances are returned as-is when already safe.
|
||||
*/
|
||||
function asRpcError(value: unknown, fallback: string): unknown {
|
||||
// Duck-type across realms (plugin tests run the source in `vm`, and IPC
|
||||
// can deliver Error-like objects whose prototype is not this realm's
|
||||
// Error). React 19 only needs a string `name`. Never mutate the rejection:
|
||||
// frozen/sealed objects make `name = 'Error'` a silent no-op in sloppy
|
||||
// mode, so a non-string name always becomes a fresh Error with cause.
|
||||
const isObject = value != null && typeof value === 'object'
|
||||
const name = isObject ? (value as RpcErrorLike).name : undefined
|
||||
const message = isObject ? (value as RpcErrorLike).message : undefined
|
||||
const hasStringName = typeof name === 'string'
|
||||
const hasStringMessage = typeof message === 'string'
|
||||
const hasStack = isObject && typeof (value as RpcErrorLike).stack === 'string'
|
||||
if (isObject && hasStringName && (hasStack || hasStringMessage)) {
|
||||
return value
|
||||
}
|
||||
if (isObject) {
|
||||
const text = hasStringMessage && String(message).trim() ? String(message) : fallback
|
||||
const error = new Error(text)
|
||||
error.cause = value
|
||||
return error
|
||||
}
|
||||
return new Error(value == null || value === '' ? fallback : String(value))
|
||||
}
|
||||
|
||||
// ── alias identity for connection rows (#89131) ─────────────────────────────
|
||||
// A Desktop per-profile alias (profile `moxie` with a Cloud/URL/SSH override)
|
||||
// routes to a remote backend's root profile: its route reads
|
||||
// { connectionId: C, profile: 'moxie', targetProfile: 'default' }. Once that
|
||||
// backend answers the roster itself, the row's identity is (C, 'default') —
|
||||
// a DIFFERENT key than the alias meta (C::moxie / 'moxie') — so the friendly
|
||||
// name fell off after source/session activation: the row regressed to the
|
||||
// raw Cloud hostname, or to generic 'Hermes' in Cloud-only mode.
|
||||
//
|
||||
// aliasRouteIndex bridges the backend row identity back to its configured
|
||||
// alias. It is keyed by (connectionId, targetProfile), so two same-named
|
||||
// `default` rows on different connections can never share a title, and it
|
||||
// fails closed when two aliases claim the same backend row (mirroring the
|
||||
// fail-closed route resolution). This is the one sanctioned exception to
|
||||
// "remote rows never borrow local meta": the alias IS the local identity of
|
||||
// exactly this connection row, proven by the configured route — never by a
|
||||
// bare name match.
|
||||
interface AliasIdentity {
|
||||
/** Both the source-qualified v2 key and the bare v1 name key. */
|
||||
metaKeys: string[]
|
||||
name: string
|
||||
}
|
||||
|
||||
/** Keyed `${connectionId}::${targetProfile}`; null marks an ambiguous claim. */
|
||||
let aliasRouteIndex = new Map<string, AliasIdentity | null>()
|
||||
|
||||
/** Rebuild the alias index from the credential-free route inventory. Only
|
||||
* genuine aliases (route.profile !== route.targetProfile) participate. */
|
||||
export function indexAliasRoutes(routes: ProfileRoute[]) {
|
||||
const next = new Map<string, AliasIdentity | null>()
|
||||
for (const route of Array.isArray(routes) ? routes : []) {
|
||||
const connectionId = String(route?.connectionId || '').trim()
|
||||
const profile = String(route?.profile || '').trim()
|
||||
const target = String(route?.targetProfile || '').trim()
|
||||
if (!connectionId || !profile || !target || profile === target) {
|
||||
continue
|
||||
}
|
||||
const key = `${connectionId}::${target}`
|
||||
|
||||
// Two aliases pointing at the same backend row are ambiguous — neither
|
||||
// may claim the identity.
|
||||
next.set(
|
||||
key,
|
||||
next.has(key)
|
||||
? null
|
||||
: {
|
||||
name: profile,
|
||||
// Alias meta can live under the source-qualified v2 key or the bare
|
||||
// v1 name key (aliases predate the v2 migration on mixed setups).
|
||||
metaKeys: [`${connectionId}::${profile}`, profile]
|
||||
}
|
||||
)
|
||||
}
|
||||
aliasRouteIndex = next
|
||||
}
|
||||
|
||||
/** The configured alias identity claiming this roster row, or null. Matches
|
||||
* strictly by (connectionId, backend target profile); the alias row itself
|
||||
* keeps resolving its own meta directly. */
|
||||
export function aliasIdentityFor(bot: Partial<RosterRow> | null | undefined): AliasIdentity | null {
|
||||
if (!aliasRouteIndex.size) {
|
||||
return null
|
||||
}
|
||||
const connectionId = String(
|
||||
bot?.connectionId ||
|
||||
bot?.route?.connectionId ||
|
||||
// Unannotated rich rows (no host.agents on this build) still belong to
|
||||
// the ACTIVE gateway — Cloud-only mode must resolve the alias too.
|
||||
(!bot?.remoteSource && !bot?.sourceScoped ? host.state.connectionId?.get?.() || '' : '')
|
||||
).trim()
|
||||
if (!connectionId) {
|
||||
return null
|
||||
}
|
||||
const target = String(bot?.targetProfile || bot?.route?.targetProfile || bot?.name || '').trim() || 'default'
|
||||
const entry = aliasRouteIndex.get(`${connectionId}::${target}`) || null
|
||||
return entry && entry.name !== String(bot?.name || '').trim() ? entry : null
|
||||
}
|
||||
|
||||
// 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. The ONE
|
||||
// exception is a configured alias route claiming the row — see
|
||||
// aliasRouteIndex above — which is connection-exact, never name-based.
|
||||
export function botRosterMeta(bot: RosterRow, metaByName: Record<string, BotMeta>) {
|
||||
if (bot?.sourceScoped || bot?.remoteSource) {
|
||||
// Passive meta lookup: branch on the typed status instead of catching
|
||||
// botConnectionRoute's throw, so an owner_removed row (e.g. a stale
|
||||
// persisted group roster after its connection was deleted) reads as "no
|
||||
// route" without masking an unrelated failure under the same catch.
|
||||
const resolved = resolveBotConnectionRoute(bot)
|
||||
const route = resolved.status === 'resolved' ? resolved.route : null
|
||||
const direct = route ? metaByName?.[botRouteKey(route)] : null
|
||||
if (direct) {
|
||||
return direct
|
||||
}
|
||||
const alias = aliasIdentityFor(bot)
|
||||
if (alias) {
|
||||
for (const key of alias.metaKeys) {
|
||||
if (metaByName?.[key]) {
|
||||
return metaByName[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
return direct
|
||||
}
|
||||
const own = metaByName?.[bot?.name]
|
||||
if (own) {
|
||||
return own
|
||||
}
|
||||
const alias = aliasIdentityFor(bot)
|
||||
if (alias) {
|
||||
for (const key of alias.metaKeys) {
|
||||
if (metaByName?.[key]) {
|
||||
return metaByName[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
return own
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* The row-level reads a roster row is assembled from: who sent the last
|
||||
* message, what to call a generically-titled session, whether a bot counts as
|
||||
* live, and whether its row owns the highlight.
|
||||
*
|
||||
* Pure below the surfaces. Every one of these takes a roster row and returns
|
||||
* a value, so the bot row, the roster pane and the open path can share one
|
||||
* answer instead of each deriving its own.
|
||||
*/
|
||||
|
||||
import { botActivitySession, botHandle, botRosterKey, isActiveRosterBot } from './data'
|
||||
import type { CanonicalSession, RosterActivityFilter, RosterRow } from './types'
|
||||
|
||||
// ── 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. */
|
||||
export 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. */
|
||||
export function previewKind(preview: null | string | undefined): { fromBot: null | string } {
|
||||
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: null | string | undefined): boolean {
|
||||
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. */
|
||||
export function generatedSessionTitle(session: CanonicalSession | null | undefined, preview: null | string | undefined) {
|
||||
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). */
|
||||
export const ACTIVE_WINDOW_S = 90
|
||||
const RECENT_ACTIVITY_WINDOW_S = 7 * 24 * 60 * 60
|
||||
export const BOT_ROSTER_SEARCH_THRESHOLD = 8
|
||||
|
||||
/** The stored session id this bot's canonical Bot Chat answers to — the
|
||||
* compression-lineage tip the live-state atoms are keyed by, falling back to
|
||||
* the durable registry id. THE id for anything core-keyed: the row's status
|
||||
* dot reads it and the unread writes below key by it, so the two can never
|
||||
* describe different conversations. Deliberately NOT botActivitySession —
|
||||
* that may answer with `last_session`, a bare preview with no id belonging to
|
||||
* another conversation entirely. */
|
||||
export function botCanonicalSessionId(bot: null | RosterRow | undefined): null | string {
|
||||
return bot?.canonical_session?.resolved_id ?? bot?.canonical_session?.id ?? null
|
||||
}
|
||||
|
||||
/** Worker liveness window: kanban/tool workers heartbeat last_activity_at
|
||||
* at least every 60s while running (agent/session_activity.py), so a
|
||||
* worker whose stamp is older than this is finished or stalled. Wider
|
||||
* than ACTIVE_WINDOW_S to bridge one missed heartbeat. */
|
||||
const WORKER_ACTIVE_WINDOW_S = 150
|
||||
|
||||
/** True while this bot's freshest kanban/tool worker looks alive. Workers
|
||||
* never surface in conversation lists, so without this a profile grinding
|
||||
* through a 30-minute kanban task reads idle ("3 hr ago") the whole time
|
||||
* (hermes-agent#90268). Older gateways omit worker_session — always false. */
|
||||
export function workerActiveAt(bot: null | RosterRow | undefined, now = Date.now()): boolean {
|
||||
const ts = bot?.worker_session?.last_active || 0
|
||||
return Boolean(ts && now / 1000 - ts < WORKER_ACTIVE_WINDOW_S)
|
||||
}
|
||||
|
||||
/** Bots that are working right now: the profile the gateway is running a
|
||||
* turn for (busy), any bot whose last message landed inside the liveness
|
||||
* window, plus any bot with a live kanban/tool worker. Pure — output
|
||||
* follows the input roster's order, so presence never reorders or hides
|
||||
* the normal list. */
|
||||
export function activeBots(
|
||||
roster: null | RosterRow[] | undefined,
|
||||
activeProfile: string,
|
||||
gatewayState: string,
|
||||
now = Date.now()
|
||||
): RosterRow[] {
|
||||
return (roster || []).filter(bot => {
|
||||
const busyTurn = !bot.remoteSource && bot.name === activeProfile && gatewayState === 'busy'
|
||||
const last = botActivitySession(bot)?.last_active || 0
|
||||
const inWindow = Boolean(last && now / 1000 - last < ACTIVE_WINDOW_S)
|
||||
return busyTurn || inWindow || workerActiveAt(bot, now)
|
||||
})
|
||||
}
|
||||
/** What the roster list wraps around a bot or group before filtering:
|
||||
* `activity` is a millisecond stamp, `active` the live pulse. */
|
||||
interface RosterActivityRow {
|
||||
active?: boolean
|
||||
activity?: number
|
||||
}
|
||||
export function rosterActivityMatches(
|
||||
row: null | RosterActivityRow | undefined,
|
||||
filter: null | RosterActivityFilter | undefined,
|
||||
now = Date.now()
|
||||
): boolean {
|
||||
if (!filter || filter === 'all') {
|
||||
return true
|
||||
}
|
||||
if (filter === 'active') {
|
||||
return Boolean(row?.active)
|
||||
}
|
||||
const activity = Number(row?.activity || 0)
|
||||
const recent = Boolean(activity && now - activity <= RECENT_ACTIVITY_WINDOW_S * 1000)
|
||||
return filter === 'recent' ? recent : !recent
|
||||
}
|
||||
export function botRowOwnsWorkspace(
|
||||
bot: RosterRow,
|
||||
activeGroup: null | string,
|
||||
botChatFocused: boolean,
|
||||
focusedOwner: null | { authoritative: boolean; connectionId: string; name: string },
|
||||
selectedRosterKey: string
|
||||
): boolean {
|
||||
if (activeGroup) {
|
||||
return false
|
||||
}
|
||||
if (!botChatFocused) {
|
||||
return selectedRosterKey === botRosterKey(bot)
|
||||
}
|
||||
return isActiveRosterBot(bot, focusedOwner)
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* The always-hidden reconciliation sweep: everything that walks Bot Mode's
|
||||
* own sessions on load and on each reconnect and pushes them back to hidden.
|
||||
*
|
||||
* Durable-visibility plumbing, not a surface. It reads the room store and the
|
||||
* roster cache and renders nothing, so the plugin's lifecycle can start the
|
||||
* scheduler without pulling a view in.
|
||||
*/
|
||||
|
||||
import { host } from '@hermes/plugin-sdk'
|
||||
|
||||
import { PROFILE_SESSION_LIST_LIMIT } from './canonical-chat'
|
||||
import { $lastRoster } from './data'
|
||||
import { $groupChats } from './group-chat'
|
||||
import { groupMemberKey } from './group-membership'
|
||||
import { backendTargetProfile, botConnectionRoute, requestForBot } from './routing'
|
||||
import type { GroupMember, RosterRow } from './types'
|
||||
|
||||
/** The slice of the plugin context the scheduler needs to park its timer. */
|
||||
interface HideSweepContext {
|
||||
onDispose?: (fn: () => void) => void
|
||||
}
|
||||
|
||||
/** 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 the session ids we
|
||||
* own by id (each group room's member sessions) through the core
|
||||
* session.set_hidden RPC, then run the TITLE-based ownership sweep for
|
||||
* everything else — canonical Bot Chats are identified by name (the
|
||||
* registry row titled "Bot Chat"), so the title sweep is what hides them;
|
||||
* no stored-id pointer is consulted. Idempotent (the DB setter is a no-op
|
||||
* on already-hidden rows) and feature-detected: older Desktop hosts defer
|
||||
* reconciliation rather than activating an absent profile backend. */
|
||||
export function startHideSweepScheduler(ctx: HideSweepContext) {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
let inflight: Promise<unknown> | null = null
|
||||
let pending = false
|
||||
let disposed = false
|
||||
const run = () => {
|
||||
timer = null
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
if (inflight) {
|
||||
pending = true
|
||||
return
|
||||
}
|
||||
inflight = Promise.resolve()
|
||||
.then(() => hideOwnedBotSessions())
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
inflight = null
|
||||
if (pending && !disposed) {
|
||||
pending = false
|
||||
schedule()
|
||||
}
|
||||
})
|
||||
}
|
||||
const schedule = () => {
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (timer !== null) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
timer = setTimeout(run, 0)
|
||||
} catch {
|
||||
run()
|
||||
}
|
||||
}
|
||||
const stopGatewayListener = host.state.gateway.listen(state => {
|
||||
if (state === 'open') {
|
||||
schedule()
|
||||
}
|
||||
})
|
||||
const teardown = () => {
|
||||
disposed = true
|
||||
stopGatewayListener()
|
||||
if (timer !== null) {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
}
|
||||
if (typeof ctx.onDispose === 'function') {
|
||||
ctx.onDispose(teardown)
|
||||
}
|
||||
schedule()
|
||||
}
|
||||
/** One (owner, session id) pair the sweep will hide, plus the key it dedupes
|
||||
* on when the same member session is seated in several rooms. */
|
||||
interface RoomSessionEntry {
|
||||
dedupe: string
|
||||
id: string
|
||||
owner: RosterRow
|
||||
}
|
||||
function hideOwnedBotSessions() {
|
||||
// `.filter(Boolean)` doesn't narrow away the nulls the map returns, so the
|
||||
// element type is restated here rather than at every read below.
|
||||
const roomEntries = Object.values($groupChats.get()).flatMap(room =>
|
||||
Object.entries(room?.sessions || {})
|
||||
.map(([key, id]) => {
|
||||
if (!id || id === true) {
|
||||
return null
|
||||
}
|
||||
const persisted = room?.sessionOwners?.[key]
|
||||
const derived = (room?.members || []).find((member: GroupMember) => groupMemberKey(member) === key)
|
||||
// Bare keys are legacy local rooms. A source-qualified key without its
|
||||
// immutable owner is unsafe: never let it fall through ambient routing.
|
||||
const owner =
|
||||
persisted ||
|
||||
derived ||
|
||||
(!key.includes('::')
|
||||
? {
|
||||
name: key
|
||||
}
|
||||
: null)
|
||||
if (key.includes('::')) {
|
||||
const route = owner?.route
|
||||
const sourceMarked = owner?.sourceScoped || owner?.remoteSource
|
||||
const routeKey = route?.connectionId && route?.profile ? `${route.connectionId}::${route.profile}` : ''
|
||||
if (!sourceMarked || !route?.targetProfile || routeKey !== key) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
return owner
|
||||
? {
|
||||
owner,
|
||||
id,
|
||||
dedupe: `${key}\u0000${id}`
|
||||
}
|
||||
: null
|
||||
})
|
||||
.filter(Boolean)
|
||||
) as RoomSessionEntry[]
|
||||
|
||||
// The same member session can appear in several rooms (and legacy rooms can
|
||||
// share ids) — hide each (owner, id) pair exactly once.
|
||||
const rooms = [...new Map(roomEntries.map(entry => [entry.dedupe, entry])).values()]
|
||||
const known = Promise.all(rooms.map(({ owner, id }) => hidePersistedBotSession(owner, id).catch(() => undefined)))
|
||||
return Promise.all([known, sweepBotProfileSessions().catch(() => undefined)])
|
||||
}
|
||||
|
||||
/** Reconcile durable visibility through the source's primary REST backend.
|
||||
* Never fall back to requestForBot: that compatibility path activates an
|
||||
* absent profile backend, which is worse than deferring this best-effort sweep. */
|
||||
function hidePersistedBotSession(bot: RosterRow, sessionId: string, profileOverride = '') {
|
||||
if (typeof host.setPersistedSessionHidden !== 'function') {
|
||||
return Promise.resolve()
|
||||
}
|
||||
const route = botConnectionRoute(bot)
|
||||
const fallback = String(bot?.name || '').trim() || 'default'
|
||||
const profile = profileOverride || backendTargetProfile(route, fallback)
|
||||
return Promise.resolve(
|
||||
host.setPersistedSessionHidden(route, {
|
||||
sessionId,
|
||||
profile,
|
||||
hidden: true
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// 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`)
|
||||
// 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'])
|
||||
const BOT_MODE_SWEEP_MIN_AGE_SECONDS = 5 * 60
|
||||
function isBotModeSweepTitle(title: null | string | undefined) {
|
||||
const t = String(title || '').trim()
|
||||
return BOT_MODE_SWEEP_TITLES.has(t) || t.startsWith('Group: ')
|
||||
}
|
||||
|
||||
/** A persisted session row as the sweep reads it. Structural so both the REST
|
||||
* list rows and a raw `session.list` payload satisfy it. */
|
||||
interface SweepSessionRow {
|
||||
id: string
|
||||
/** Epoch seconds. */
|
||||
started_at?: number
|
||||
title?: null | string
|
||||
}
|
||||
function isBotModeSweepCandidate(row: SweepSessionRow | null | undefined, nowSeconds = Date.now() / 1000) {
|
||||
const startedAt = Number(row?.started_at)
|
||||
return (
|
||||
row &&
|
||||
row.id &&
|
||||
isBotModeSweepTitle(row.title) &&
|
||||
Number.isFinite(startedAt) &&
|
||||
startedAt > 0 &&
|
||||
nowSeconds - startedAt >= BOT_MODE_SWEEP_MIN_AGE_SECONDS
|
||||
)
|
||||
}
|
||||
|
||||
/** `profiles.list` as Bot Mode reads it. */
|
||||
interface ProfilesListResult {
|
||||
profiles?: RosterRow[]
|
||||
}
|
||||
|
||||
/** 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 and whose creation grace period has
|
||||
* elapsed. The grace period protects a new desktop draft while its first-turn
|
||||
* title is pending; after five minutes an unchanged plumbing title is treated
|
||||
* as Bot Mode-owned. session.list supplies epoch seconds; missing, malformed,
|
||||
* millisecond, or future timestamps fail closed and stay visible. session.list
|
||||
* without include_hidden returns only visible rows, which keeps the sweep
|
||||
* naturally idempotent.
|
||||
* Reads and writes go through the owning source's primary REST backend, which
|
||||
* opens persisted state directly and never starts an inactive profile backend.
|
||||
* Feature-detected + fire-and-forget: older Desktop hosts defer the sweep. */
|
||||
async function sweepBotProfileSessions(nowSeconds = Date.now() / 1000) {
|
||||
if (typeof host.listPersistedSessions !== 'function' || typeof host.setPersistedSessionHidden !== 'function') {
|
||||
return
|
||||
}
|
||||
const cached = $lastRoster.get()
|
||||
let roster: RosterRow[] | null = 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 activeBot = {
|
||||
name: String(host.state.profile?.get?.() || 'default').trim() || 'default'
|
||||
}
|
||||
const res = (await requestForBot(activeBot, 'profiles.list', {})) as ProfilesListResult
|
||||
roster = Array.isArray(res?.profiles) ? res.profiles : []
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
await Promise.all(
|
||||
roster.map(async (bot: RosterRow) => {
|
||||
const name = String(bot?.name || '').trim()
|
||||
if (!name) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const route = botConnectionRoute(bot)
|
||||
const profile = backendTargetProfile(route, name)
|
||||
const res = await host.listPersistedSessions(route, {
|
||||
profile,
|
||||
limit: PROFILE_SESSION_LIST_LIMIT
|
||||
})
|
||||
const rows = Array.isArray(res?.sessions) ? res.sessions : []
|
||||
await Promise.all(
|
||||
rows
|
||||
.filter(row => isBotModeSweepCandidate(row, nowSeconds))
|
||||
.map(row => Promise.resolve(hidePersistedBotSession(bot, row.id, profile)).catch(() => undefined))
|
||||
)
|
||||
} catch {
|
||||
/* older gateway / unreachable source — leave this profile alone */
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* The pieces every Bot Mode module needs and no single module can own: the
|
||||
* plugin id, the `PluginContext` `register()` captures, and the open
|
||||
* generation the bot-open and group-open click paths race on.
|
||||
*
|
||||
* The mutable ones live behind accessor pairs because an imported binding
|
||||
* cannot be reassigned — `register()` publishes the context once with
|
||||
* `setPluginCtx`, and every reader goes through `getPluginCtx()`.
|
||||
*/
|
||||
|
||||
import type { PluginContext } from '@hermes/plugin-sdk'
|
||||
|
||||
export const ID = 'hermes-bots'
|
||||
|
||||
/** Captured in register() so components can reach plugin storage. */
|
||||
let pluginCtx: PluginContext | null = null
|
||||
|
||||
export function getPluginCtx(): PluginContext | null {
|
||||
return pluginCtx
|
||||
}
|
||||
|
||||
export function setPluginCtx(ctx: PluginContext | null) {
|
||||
pluginCtx = ctx
|
||||
}
|
||||
|
||||
/** Monotonic open generation. A bot open and a group open supersede each
|
||||
* other, so both bump it and both compare against it — and they no longer
|
||||
* live in the same module. */
|
||||
let botOpenGeneration = 0
|
||||
|
||||
export function getBotOpenGeneration() {
|
||||
return botOpenGeneration
|
||||
}
|
||||
|
||||
/** Invalidate every in-flight open; returns the generation that now owns it. */
|
||||
export function bumpBotOpenGeneration() {
|
||||
return ++botOpenGeneration
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* The real skills hub page embedded as a picker, plus its offline search
|
||||
* fallback.
|
||||
*
|
||||
* A leaf: the advanced profile editor and the create dialog both mount it, and
|
||||
* it reaches back into neither.
|
||||
*/
|
||||
|
||||
import { Button, host, Input } from '@hermes/plugin-sdk'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { useBots } from './i18n'
|
||||
|
||||
// ── 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'
|
||||
/** One `skills.manage action=search` hit. */
|
||||
interface HubSkillResult {
|
||||
description?: string
|
||||
name: string
|
||||
}
|
||||
interface HubSkillsSectionProps {
|
||||
/** Install target: a bare profile name, a connection-scoped descriptor for a
|
||||
* bot on another gateway, or null for the launch profile (create time). */
|
||||
forProfile: null | string | { connectionId?: null | string; profile?: null | string }
|
||||
onInstalled?: (name: string) => void
|
||||
}
|
||||
export function HubSkillsSection({ forProfile, onInstalled }: HubSkillsSectionProps) {
|
||||
const b = useBots()
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState<HubSkillResult[] | null>(null)
|
||||
const [searching, setSearching] = useState(false)
|
||||
const [installing, setInstalling] = useState<null | string>(null)
|
||||
const [installed, setInstalled] = useState<Record<string, boolean>>({})
|
||||
const [browseHub, setBrowseHub] = useState(false)
|
||||
const installRef = useRef<((name: string, displayName?: string) => Promise<void>) | null>(null)
|
||||
const frameRef = useRef<HTMLIFrameElement | null>(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: MessageEvent) => {
|
||||
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: { results?: HubSkillResult[] } = await host.request('skills.manage', {
|
||||
action: 'search',
|
||||
query: q
|
||||
})
|
||||
setResults(res.results || [])
|
||||
} catch {
|
||||
setResults([])
|
||||
} finally {
|
||||
setSearching(false)
|
||||
}
|
||||
}
|
||||
const install = async (name: string, displayName?: string) => {
|
||||
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 (
|
||||
<div className="grid gap-1.5 border-t border-(--ui-stroke-secondary) pt-2">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<div className="text-[0.7rem] font-medium text-(--ui-text-secondary)">Skills Hub</div>
|
||||
<Button
|
||||
className="text-[0.65rem] text-(--ui-text-quaternary) hover:text-(--ui-text-secondary)"
|
||||
onClick={() => setBrowseHub(v => !v)}
|
||||
size="inline"
|
||||
variant="text"
|
||||
>
|
||||
{browseHub ? 'hide the hub browser' : 'browse the full hub ▾'}
|
||||
</Button>
|
||||
</div>
|
||||
{browseHub ? (
|
||||
<div className="grid gap-1">
|
||||
{/* 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. */}
|
||||
<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'
|
||||
}}
|
||||
>
|
||||
<iframe
|
||||
src={HUB_PICKER_URL}
|
||||
title={b.tools.skillsHub}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div className="px-1 text-[0.65rem] leading-4 text-(--ui-text-quaternary)">
|
||||
{installing
|
||||
? `Installing "${installing}"…`
|
||||
: 'Hit "+ Add to this Agent" on any skill — it installs and appears in the list above. Drag the corner to resize.'}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex gap-1.5">
|
||||
<Input
|
||||
className="h-7 flex-1 text-xs"
|
||||
placeholder={b.tools.searchHub}
|
||||
value={query}
|
||||
onChange={event => setQuery(event.target.value)}
|
||||
onKeyDown={event => {
|
||||
// IME guard: Enter confirming a composed word must not search.
|
||||
if (event.nativeEvent?.isComposing || event.keyCode === 229) {
|
||||
return
|
||||
}
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
void search()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button size="sm" variant="secondary" disabled={searching || !query.trim()} onClick={() => void search()}>
|
||||
{searching ? 'Searching…' : 'Search'}
|
||||
</Button>
|
||||
</div>
|
||||
{searching ? (
|
||||
<div className="px-1 text-[0.65rem] text-(--ui-text-quaternary)">
|
||||
Searching community + well-known sources — can take ~10s…
|
||||
</div>
|
||||
) : null}
|
||||
{results === null ? null : results.length === 0 ? (
|
||||
<div className="px-1 py-1.5 text-[0.7rem] text-(--ui-text-quaternary)">No hub skills matched.</div>
|
||||
) : (
|
||||
<div
|
||||
className="overflow-y-auto overscroll-contain"
|
||||
style={{
|
||||
maxHeight: 150
|
||||
}}
|
||||
>
|
||||
<div className="grid gap-1">
|
||||
{results.map(r => (
|
||||
<div key={r.name} className="flex items-center gap-2 text-xs">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium">{r.name}</div>
|
||||
{r.description ? (
|
||||
<div className="truncate text-[0.65rem] text-(--ui-text-quaternary)">{r.description}</div>
|
||||
) : null}
|
||||
</div>
|
||||
{installed[r.name] ? (
|
||||
<span className="shrink-0 text-[0.65rem] text-(--ui-text-tertiary)">✓ added</span>
|
||||
) : (
|
||||
<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)}
|
||||
>
|
||||
{installing === r.name ? '…' : '+'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* The SOUL a bot is born with, and the agent-to-agent messaging protocol
|
||||
* section every SOUL has to keep.
|
||||
*
|
||||
* Three surfaces need a piece of this and none of them can own it: the create
|
||||
* dialog composes a new SOUL, the advanced editor re-appends the protocol when
|
||||
* a custom SOUL is saved, and the roster backfills profiles that predate it.
|
||||
*/
|
||||
|
||||
import { host } from '@hermes/plugin-sdk'
|
||||
|
||||
import { botHandle, serverInjectsProtocol } from './data'
|
||||
import { displayName } from './labels'
|
||||
import type { RosterRow } from './types'
|
||||
|
||||
/** 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: string, roster: RosterRow[] | null | undefined): string {
|
||||
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: null | string | undefined): boolean {
|
||||
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. */
|
||||
export function ensureMessagingProtocol(
|
||||
soul: null | string | undefined,
|
||||
name: string,
|
||||
roster: RosterRow[] | null | undefined
|
||||
) {
|
||||
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<string>()
|
||||
const soulProtocolInflight = new Set<string>()
|
||||
|
||||
/** 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. */
|
||||
export function backfillMessagingProtocol(roster: RosterRow[] | null | undefined) {
|
||||
// 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<{ soul?: string }>('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). */
|
||||
interface ComposeSoulOptions {
|
||||
/** The user's own SOUL text, when the create form supplied one. */
|
||||
customSoul?: null | string
|
||||
description?: null | string
|
||||
name: string
|
||||
roster?: RosterRow[] | null
|
||||
title?: null | string
|
||||
}
|
||||
export function composeSoul({ name, title, description, roster, customSoul }: ComposeSoulOptions): string {
|
||||
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)
|
||||
}
|
||||
@@ -159,35 +159,4 @@ test('activeBots ignores a finished worker outside the liveness window', () => {
|
||||
}
|
||||
]
|
||||
assert.deepEqual(activeBots(bots, 'other', 'open', NOW), [])
|
||||
})
|
||||
|
||||
test('ActiveNowStrip renders above the roster, is a live region, and is click-accessible', () => {
|
||||
// Strip is placed between the pane header and the search field.
|
||||
const headerEnd = source.indexOf("children: 'Bots'")
|
||||
const searchField = source.indexOf("placeholder: 'Search bots and group chats…'")
|
||||
assert.ok(headerEnd >= 0 && searchField > headerEnd)
|
||||
|
||||
const stripStart = source.indexOf('jsx(ActiveNowStrip')
|
||||
assert.ok(stripStart > headerEnd && stripStart < searchField, 'strip sits between header and search')
|
||||
|
||||
// Live region announces membership changes politely.
|
||||
assert.match(source, /'aria-live': 'polite'/)
|
||||
// Chips use the shared Tip component, remain keyboard/click accessible,
|
||||
// and open the canonical chat via the same path as roster rows.
|
||||
assert.match(source, /label: `Open \$\{label\}'s chat`/)
|
||||
assert.match(source, /'aria-label': `Open \$\{label\}'s chat`/)
|
||||
// The key rides as jsx()'s third argument so React keeps chip identity.
|
||||
assert.match(source, /botRosterKey\(bot\)\s*\)\s*\}\)\s*\]\s*\}\)\s*\}\s*\/\*\* Assign a bot to a group/s)
|
||||
assert.match(source, /jsx\(BotFace,\s*\{[\s\S]*?mood: 'work'/)
|
||||
// Chips share the exact-owner route with roster rows. That route activates the
|
||||
// owner source, then resolves the profile's canonical name registry; no
|
||||
// renderer pointer or preview-derived session id participates.
|
||||
assert.match(source, /onOpen: bot => void openRosterBot\(bot\)/)
|
||||
|
||||
const openStart = source.indexOf('async function openRosterBot(')
|
||||
assert.ok(openStart >= 0)
|
||||
const open = source.slice(openStart, openStart + 3200)
|
||||
|
||||
assert.match(open, /await prepareBotSource\(bot\)/)
|
||||
assert.match(open, /await openBotCanonicalChat\(bot, \(\) => generation === botOpenGeneration\)/)
|
||||
})
|
||||
})
|
||||
@@ -1,218 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import vm from 'node:vm'
|
||||
|
||||
// Re-fronting the Bots home is a CLOSE followed by an OPEN of the workspace
|
||||
// tab, which tears down and rebuilds the whole Bots view. The re-front branch
|
||||
// runs whenever a passive reconcile finds the tab open but not holding its
|
||||
// zone's active slot — and it is completely unbounded: it re-fronts again on
|
||||
// every pass, for as long as that condition holds.
|
||||
//
|
||||
// The condition is not always transient. `revealTreePane` returns early for a
|
||||
// pane in `$hiddenTreePanes` WITHOUT activating it, `isPaneVisible` is false
|
||||
// for a minimized zone, and a pane the tree never adopted has no group at all.
|
||||
// Pinned in that state, every surface sync the plugin runs — sidebar
|
||||
// visibility flips, focus churn, group changes all call syncWorkspaceSurfaces
|
||||
// — costs one full remount of the Bots view. That is the strobe in the bug
|
||||
// report's screen recording: successive frames differ only by text
|
||||
// re-rasterization across the whole view, the signature of a remount rather
|
||||
// than a repaint.
|
||||
//
|
||||
// The invariant: a PASSIVE reconcile gets one re-front attempt. If the shell
|
||||
// does not grant visibility, the plugin stops rather than remounting on every
|
||||
// subsequent signal. An explicit user gesture is still always honored.
|
||||
|
||||
const pluginSource = readFileSync(new URL('../plugin.js', import.meta.url), 'utf8')
|
||||
|
||||
/** A shell whose reveal does NOT hand the tab its zone's active slot, modelled
|
||||
* on `revealTreePane`'s hidden-pane early return. `grantsVisibility: true`
|
||||
* restores the cooperative shell so the same harness covers both directions. */
|
||||
function load({ grantsVisibility = false } = {}) {
|
||||
const values = new Map()
|
||||
const atom = initial => {
|
||||
const slot = {
|
||||
get: () => values.get(slot),
|
||||
set: value => {
|
||||
values.set(slot, value)
|
||||
for (const fn of slot.__listeners || []) {
|
||||
fn(value)
|
||||
}
|
||||
},
|
||||
listen: fn => {
|
||||
slot.__listeners = [...(slot.__listeners || []), fn]
|
||||
return () => {
|
||||
slot.__listeners = (slot.__listeners || []).filter(entry => entry !== fn)
|
||||
}
|
||||
}
|
||||
}
|
||||
values.set(slot, initial)
|
||||
return slot
|
||||
}
|
||||
|
||||
const opened = []
|
||||
const closed = []
|
||||
const visible = new Map()
|
||||
const watchers = new Map()
|
||||
|
||||
// Synchronous notification, like a nanostores computed over $layoutTree.
|
||||
const setVisible = (paneId, value) => {
|
||||
if (visible.get(paneId) === value) {
|
||||
return
|
||||
}
|
||||
|
||||
visible.set(paneId, value)
|
||||
for (const fn of watchers.get(paneId) || []) {
|
||||
fn(value)
|
||||
}
|
||||
}
|
||||
|
||||
const host = {
|
||||
state: {
|
||||
profile: { get: () => 'default', listen: () => undefined },
|
||||
gateway: { get: () => 'open', listen: () => undefined },
|
||||
focusedStoredSessionId: atom(null)
|
||||
},
|
||||
request: async () => ({}),
|
||||
requestProfile: async () => ({}),
|
||||
openSession: async () => undefined,
|
||||
setWorkspaceScope: () => true,
|
||||
notify: () => undefined,
|
||||
notifyError: () => undefined,
|
||||
ensureAgent: async () => undefined,
|
||||
activeConnectionId: () => 'local',
|
||||
openWorkspace: (id, options) => {
|
||||
const paneId = `plugin-workspace:${id}`
|
||||
const entry = { id, options }
|
||||
opened.push(entry)
|
||||
setVisible(paneId, grantsVisibility)
|
||||
|
||||
return () => {
|
||||
closed.push(entry)
|
||||
setVisible(paneId, false)
|
||||
options.onClose?.()
|
||||
}
|
||||
},
|
||||
paneVisibility: paneId => ({
|
||||
get: () => visible.get(paneId) ?? false,
|
||||
listen: fn => {
|
||||
watchers.set(paneId, [...(watchers.get(paneId) || []), fn])
|
||||
return () => watchers.set(paneId, (watchers.get(paneId) || []).filter(entry => entry !== fn))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const context = {
|
||||
atom,
|
||||
haptic: () => undefined,
|
||||
PALETTE_AREA: 'palette',
|
||||
COMPOSER_AREAS: { middleware: 'middleware' },
|
||||
document: { getElementById: () => null, createElement: () => ({}), head: { appendChild: () => undefined } },
|
||||
host,
|
||||
queryClient: { invalidateQueries: () => undefined },
|
||||
navigator: { clipboard: { writeText: async () => undefined } },
|
||||
sdk: new Proxy({}, { get: () => undefined })
|
||||
}
|
||||
|
||||
const source = pluginSource
|
||||
.replace(/^import\s+\*\s+as\s+sdk\s+from '@hermes\/plugin-sdk'\r?\n/m, '')
|
||||
.replace(/^import\s+\{[\s\S]*?\}\s+from '@hermes\/plugin-sdk'\r?\n/m, '')
|
||||
.replace(/^const \{ McpTab, ToolsetConfigPanel \} = sdk\r?\n/m, '')
|
||||
.replace(/^import .* from 'react'\r?\n/m, '')
|
||||
.replace(/^import .* from 'react\/jsx-runtime'\r?\n/m, '')
|
||||
.replace('export default {', 'globalThis.plugin = {')
|
||||
.concat(`
|
||||
globalThis.__refront = {
|
||||
syncBotsHomeWorkspace,
|
||||
openBotsHomeWorkspace,
|
||||
closeBotsHomeWorkspace,
|
||||
botsHomeVisible,
|
||||
BOTS_HOME_PANE_ID,
|
||||
$botsPaneVisible,
|
||||
$openBotChat
|
||||
};
|
||||
`)
|
||||
|
||||
vm.runInNewContext(source, context, { filename: 'plugin.js' })
|
||||
|
||||
return { ...context.__refront, closed, host, opened, setVisible }
|
||||
}
|
||||
|
||||
test('a passive reconcile re-fronts the home once, not forever', () => {
|
||||
const t = load({ grantsVisibility: false })
|
||||
t.$botsPaneVisible.set(true)
|
||||
|
||||
// First pass opens the tab. The shell does not front it.
|
||||
t.syncBotsHomeWorkspace()
|
||||
assert.equal(t.opened.length, 1)
|
||||
assert.equal(t.botsHomeVisible(), false, 'the shell withheld the active slot')
|
||||
|
||||
// Every later passive pass sees "open but not visible" and takes the
|
||||
// re-front branch. Before the fix each pass closed and re-opened the tab,
|
||||
// remounting the whole Bots view — 20 passes, 20 remounts.
|
||||
for (let i = 0; i < 20; i++) {
|
||||
t.syncBotsHomeWorkspace()
|
||||
}
|
||||
|
||||
assert.equal(t.opened.length, 2, 'exactly one re-front attempt, then the plugin stops')
|
||||
assert.equal(t.closed.length, 1)
|
||||
})
|
||||
|
||||
test('giving up on the re-front still leaves the tab open', () => {
|
||||
const t = load({ grantsVisibility: false })
|
||||
t.$botsPaneVisible.set(true)
|
||||
|
||||
t.syncBotsHomeWorkspace()
|
||||
for (let i = 0; i < 5; i++) {
|
||||
t.syncBotsHomeWorkspace()
|
||||
}
|
||||
|
||||
// Bounding the retries must not degrade into closing the home: the Bots tab
|
||||
// would fall through to the ownerless Sessions composer, which is the exact
|
||||
// hole the home exists to plug. Stop re-fronting, keep the surface.
|
||||
assert.equal(t.closed.length, 1, 'one close, from the single re-front — not a teardown')
|
||||
assert.equal(t.opened.length, 2)
|
||||
})
|
||||
|
||||
test('a cooperative shell still gets its one legitimate re-front', () => {
|
||||
const t = load({ grantsVisibility: true })
|
||||
t.$botsPaneVisible.set(true)
|
||||
|
||||
t.syncBotsHomeWorkspace()
|
||||
assert.equal(t.opened.length, 1)
|
||||
assert.equal(t.botsHomeVisible(), true)
|
||||
|
||||
// A persisted layout can restore the tab BEHIND the core draft pane. The
|
||||
// home must still reclaim the active slot — the fix bounds retries, it does
|
||||
// not remove the re-front (bots-home.test.mjs pins this case too).
|
||||
t.setVisible(t.BOTS_HOME_PANE_ID, false)
|
||||
t.syncBotsHomeWorkspace()
|
||||
|
||||
assert.equal(t.opened.length, 2, 're-opened to reclaim the active slot')
|
||||
assert.equal(t.botsHomeVisible(), true)
|
||||
|
||||
// And having converged, the plugin is free to re-front again the next time
|
||||
// the tab is genuinely backgrounded: the bound is per-attempt, not one-shot.
|
||||
t.setVisible(t.BOTS_HOME_PANE_ID, false)
|
||||
t.syncBotsHomeWorkspace()
|
||||
|
||||
assert.equal(t.opened.length, 3)
|
||||
})
|
||||
|
||||
test('an explicit gesture is never blocked by the passive bound', () => {
|
||||
const t = load({ grantsVisibility: false })
|
||||
t.$botsPaneVisible.set(true)
|
||||
|
||||
t.syncBotsHomeWorkspace()
|
||||
for (let i = 0; i < 5; i++) {
|
||||
t.syncBotsHomeWorkspace()
|
||||
}
|
||||
const passive = t.opened.length
|
||||
|
||||
// The user clicked a bot whose owner is unavailable: the home IS the
|
||||
// destination, so the gesture must re-front even though passive reconciles
|
||||
// have given up on this shell.
|
||||
t.openBotsHomeWorkspace(true)
|
||||
|
||||
assert.equal(t.opened.length, passive + 1, 'an explicit open always re-fronts')
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -77,7 +77,7 @@ test('RoutinesPane scopes the Cronjobs tile to the focused chat owner', () => {
|
||||
const pane = source.slice(paneStart, paneStart + 1200)
|
||||
|
||||
assert.match(pane, /const focusedOwner = focusedRosterOwner\(useValue\(\$focusedBotOwner\)\)/)
|
||||
// The roster read must be a SUBSCRIPTION, not a bare .get(): BotsHomeView
|
||||
// The roster read must be a SUBSCRIPTION, not a bare .get(): BotsPane
|
||||
// owns the fetch and can hydrate after this pane mounted, so a bare snapshot
|
||||
// pinned the tile on "unavailable" forever (#94483). Scoping intent is
|
||||
// unchanged — it still keys off the focused owner, never the socket-home
|
||||
|
||||
@@ -41,7 +41,6 @@ vi.mock('@hermes/plugin-sdk', async importOriginal => {
|
||||
})
|
||||
|
||||
const { createPluginContext } = await import('@/contrib/plugin')
|
||||
// @ts-expect-error The bundled Bot Mode entry intentionally remains plain ESM JavaScript for older runtime loaders.
|
||||
const { default: plugin } = await import('../plugin.js')
|
||||
|
||||
const flushSweep = async () => {
|
||||
|
||||
@@ -13,7 +13,7 @@ import test from 'node:test'
|
||||
// through the contribution disposer, driven by the feature-detected
|
||||
// host.paneVisibility SDK export, with the always-registered fallback kept
|
||||
// for older desktops. Cronjobs are bot-scoped, so the tile must not sit
|
||||
// beside the ownerless Bots home or a group chat.
|
||||
// beside a group chat.
|
||||
|
||||
const source = readFileSync(new URL('../plugin.js', import.meta.url), 'utf8')
|
||||
|
||||
|
||||
@@ -55,7 +55,6 @@ function renderBotRow(input = 'alpha') {
|
||||
$botAttention: atom({}),
|
||||
BOT_ATTENTION_HINTS: {},
|
||||
$botChatFocused: atom(false),
|
||||
$botsHomeFronted: atom(false),
|
||||
$focusedBotOwner: atom({ connectionId: 'local', profile: 'default' }),
|
||||
$focusedBotProfile: atom('default'),
|
||||
$groupChatWorkspace: atom(null),
|
||||
@@ -254,7 +253,6 @@ test('behavior: remote default never opens the same-name local chat', async () =
|
||||
$botAttention: atom({}),
|
||||
BOT_ATTENTION_HINTS: {},
|
||||
$botChatFocused: atom(false),
|
||||
$botsHomeFronted: atom(false),
|
||||
$focusedBotOwner: atom({ connectionId: 'mac-mini', profile: 'default' }),
|
||||
$focusedBotProfile: atom('default'),
|
||||
$groupChatWorkspace: atom(null),
|
||||
|
||||
@@ -4,7 +4,7 @@ import test from 'node:test'
|
||||
import vm from 'node:vm'
|
||||
|
||||
// The Cronjobs pane read the shared roster with a bare `$lastRoster.get()`
|
||||
// while rendering. BotsHomeView owns the roster fetch, so whenever this pane
|
||||
// while rendering. BotsPane owns the roster fetch, so whenever this pane
|
||||
// mounted before the roster hydrated (fresh boot ordering, renderer reload
|
||||
// resetting the atoms) it captured an empty snapshot forever: the pane stayed
|
||||
// pinned on "Cronjobs are unavailable until this agent appears in the
|
||||
@@ -220,7 +220,7 @@ test('regression: the pane follows the roster hydrating after mount', () => {
|
||||
jobs: [{ job_id: 'j-1', name: 'Report', schedule: 'every 1h', state: 'scheduled', enabled: true }]
|
||||
})
|
||||
|
||||
// Mount order: pane first, roster fetch lands afterwards (BotsHomeView owns
|
||||
// Mount order: pane first, roster fetch lands afterwards (BotsPane owns
|
||||
// the fetch). First paint must be the fail-closed placeholder...
|
||||
const before = collectStrings(render()).join(' ')
|
||||
assert.ok(before.includes(UNAVAILABLE))
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* Bot Mode domain model.
|
||||
*
|
||||
* Derived from how the gateway's payloads are actually consumed, not from a
|
||||
* published schema — so almost everything is optional. A roster row can arrive
|
||||
* three ways (a rich `profiles.list` row from the active gateway, a thin
|
||||
* `host.agents()` union row from another registered connection, or an offline
|
||||
* "ghost" twin), and older gateways omit whole fields. Widening a field to
|
||||
* required is a claim that every one of those paths supplies it.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The compact age suffixes the sidebar's session rows render ("now", "m", "h",
|
||||
* "d"). Structural rather than an import of core's `Translations`, which the
|
||||
* plugin fence puts out of reach — `t.sidebar.row` satisfies it.
|
||||
*/
|
||||
export interface SidebarRowLabels {
|
||||
ageDay: string
|
||||
ageHour: string
|
||||
ageMin: string
|
||||
ageNow: string
|
||||
}
|
||||
|
||||
/** Where a row came from when several connections contribute to one roster. */
|
||||
export interface ProfileRoute {
|
||||
connectionId: string
|
||||
mode: 'local' | 'remote'
|
||||
profile: string
|
||||
targetProfile: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A bot's one canonical forever-chat: the profile's session titled exactly
|
||||
* "Bot Chat". Resolved server-side by title and reported on every roster row —
|
||||
* there is deliberately no stored session-id pointer (see AGENTS.md).
|
||||
*/
|
||||
export interface CanonicalSession {
|
||||
/** Durable id, stable across reloads. */
|
||||
id?: string
|
||||
/** Compression-lineage tip — the live session a durable id currently maps to. */
|
||||
resolved_id?: string
|
||||
last_active?: number
|
||||
preview?: string
|
||||
root_title?: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
export interface SessionPreview {
|
||||
/** Unix seconds, not milliseconds. */
|
||||
last_active?: number
|
||||
preview?: string
|
||||
}
|
||||
|
||||
/** Per-bot presentation state, persisted in the profile's `ui_meta`. */
|
||||
export interface BotMeta {
|
||||
color?: string
|
||||
/** Set when the user has customized the avatar, so defaults stop applying. */
|
||||
custom?: boolean
|
||||
description?: string
|
||||
groups?: string[]
|
||||
hidden?: boolean
|
||||
/** Data URL. Stripped before `profiles.configure`; travels via `set_asset`. */
|
||||
image?: null | string
|
||||
imageKind?: 'photo' | 'shape'
|
||||
/** Legacy single-group scalar, projected alongside `groups`. */
|
||||
group?: null | string
|
||||
pinned?: boolean
|
||||
shape?: string
|
||||
title?: string
|
||||
/** Creation timestamp in ms. Deliberately not copied when duplicating a bot. */
|
||||
created?: number
|
||||
}
|
||||
|
||||
export interface RosterRow {
|
||||
name: string
|
||||
canonical_session?: CanonicalSession | null
|
||||
connectionId?: string
|
||||
connectionKind?: string
|
||||
connectionLabel?: string
|
||||
description?: string
|
||||
display_name?: string
|
||||
/** An offline twin of a selected bot, kept visible so the row doesn't vanish. */
|
||||
ghost?: boolean
|
||||
handle?: string
|
||||
has_avatar?: boolean
|
||||
last_session?: SessionPreview | null
|
||||
remoteSource?: boolean
|
||||
route?: ProfileRoute
|
||||
sourceError?: null | string
|
||||
sourceMissing?: boolean
|
||||
sourceReachable?: boolean | null
|
||||
sourceScoped?: boolean
|
||||
targetProfile?: string
|
||||
/** Nullable: the gateway sends `null` for a profile with no configured role,
|
||||
* and the create form threads its own optional title through the same shape. */
|
||||
title?: null | string
|
||||
ui_meta?: Record<string, unknown> & { 'hermes-bots'?: BotMeta }
|
||||
/** Compare-and-swap revisions, per ui_meta key. */
|
||||
ui_meta_revisions?: Record<string, number>
|
||||
worker_session?: { last_active?: number } | null
|
||||
}
|
||||
|
||||
/** A roster row reduced to what a group room needs to seat a member. */
|
||||
export type GroupMember = Pick<
|
||||
RosterRow,
|
||||
| 'connectionId'
|
||||
| 'connectionKind'
|
||||
| 'connectionLabel'
|
||||
| 'display_name'
|
||||
| 'ghost'
|
||||
| 'handle'
|
||||
| 'name'
|
||||
| 'remoteSource'
|
||||
| 'route'
|
||||
| 'sourceMissing'
|
||||
| 'sourceReachable'
|
||||
| 'sourceScoped'
|
||||
| 'targetProfile'
|
||||
| 'title'
|
||||
>
|
||||
|
||||
export type AttachmentKind = 'file' | 'image' | 'pdf'
|
||||
|
||||
export interface Attachment {
|
||||
/** Data URL. */
|
||||
data: string
|
||||
kind: AttachmentKind
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface GroupMessageAuthor {
|
||||
kind: 'member' | 'user'
|
||||
name: string
|
||||
/** Connection label, present when the speaker lives on another machine. */
|
||||
source?: string
|
||||
}
|
||||
|
||||
export interface GroupMessage {
|
||||
/** Milliseconds. */
|
||||
at: number
|
||||
from: GroupMessageAuthor
|
||||
id?: string
|
||||
images?: Attachment[]
|
||||
text: string
|
||||
/** Messages predating threading carry the sentinel thread `'legacy'`. */
|
||||
thread?: string
|
||||
}
|
||||
|
||||
export interface GroupHold {
|
||||
at?: number
|
||||
noted?: boolean
|
||||
}
|
||||
|
||||
export interface GroupChat {
|
||||
/** Bumped to abandon in-flight member turns from a previous round. */
|
||||
epoch?: number
|
||||
holds?: Record<string, GroupHold>
|
||||
image?: null | string
|
||||
log: GroupMessage[]
|
||||
members?: GroupMember[]
|
||||
/** Immutable identity, so a rename doesn't fork the room. */
|
||||
roomId?: null | string
|
||||
running?: boolean
|
||||
/** The immutable owner descriptor captured beside each plumbing session,
|
||||
* keyed the same way as `sessions`. Partial: legacy records hold a bare
|
||||
* `{ name }`, and the sweep re-validates the route before trusting one. */
|
||||
sessionOwners?: Record<string, Partial<RosterRow>>
|
||||
sessions?: Record<string, string | true>
|
||||
stranded?: Record<string, number | { before: number; thread?: string }>
|
||||
syncRevision?: number
|
||||
/** Left behind when a room is disbanded, so sync can't resurrect it. */
|
||||
tombstone?: boolean
|
||||
/** Read when ordering rooms; no write site in the plugin today. */
|
||||
pinned?: boolean
|
||||
/** How far each `<thread>::<member>` has read into `log`. Required: unlike
|
||||
* the gateway-sourced shapes above, a room record is plugin-owned — every
|
||||
* writer (hydrate, server-sync merge, updateGroupChat, room reset) seeds
|
||||
* the map, and the turn engine indexes it unguarded. */
|
||||
watermarks: Record<string, number>
|
||||
}
|
||||
|
||||
export type GroupPromptKind = 'approval' | 'clarify'
|
||||
|
||||
/**
|
||||
* One sub-question of a batch clarify, straight off the wire. `choices` and
|
||||
* `question` stay unknown because the card re-validates them; the two id
|
||||
* spellings are the keys it maps drafts and answers by.
|
||||
*/
|
||||
export interface GroupPromptQuestion {
|
||||
choices?: unknown
|
||||
id?: string
|
||||
multi_select?: boolean
|
||||
multiSelect?: boolean
|
||||
qid?: string
|
||||
question?: unknown
|
||||
}
|
||||
|
||||
export interface GroupPrompt {
|
||||
at: number
|
||||
choices: string[]
|
||||
command?: string
|
||||
group: string
|
||||
kind: GroupPromptKind
|
||||
member: string
|
||||
memberKey: string
|
||||
multiSelect: boolean
|
||||
question: string
|
||||
questions?: GroupPromptQuestion[] | null
|
||||
requestId: string
|
||||
sessionId?: null | string
|
||||
}
|
||||
|
||||
export type GroupActivityKind =
|
||||
| 'cancelled'
|
||||
| 'capped'
|
||||
| 'delivered'
|
||||
| 'failed'
|
||||
| 'held'
|
||||
| 'passed'
|
||||
| 'queued'
|
||||
| 'replied'
|
||||
| 'settled'
|
||||
| 'stopped'
|
||||
| 'timed-out'
|
||||
| 'working'
|
||||
|
||||
export interface GroupActivityEvent {
|
||||
at: number
|
||||
group: string
|
||||
kind: GroupActivityKind
|
||||
member?: string
|
||||
preview?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A cron job as Bot Mode reads it. Deliberately NOT the core `CronJob` type:
|
||||
* the gateway's `cron.manage` payload keys the id as `job_id`, carries the
|
||||
* schedule as a plain string rather than a structured object, and splits the
|
||||
* error into three separate fields. Reusing the core interface here would
|
||||
* typecheck against fields that never arrive.
|
||||
*/
|
||||
export interface RoutineJob {
|
||||
deliver?: string
|
||||
enabled?: boolean
|
||||
job_id: string
|
||||
last_delivery_error?: string
|
||||
last_fire_error?: string
|
||||
last_run_at?: string
|
||||
last_status?: string
|
||||
model?: string
|
||||
/** Prefixed `[bot:<slug>]` so the job can be scoped back to its bot. */
|
||||
name?: string
|
||||
next_run_at?: string
|
||||
paused_reason?: string
|
||||
prompt?: string
|
||||
prompt_preview?: string
|
||||
repeat?: number | string
|
||||
schedule?: string
|
||||
state?: string
|
||||
workdir?: string
|
||||
}
|
||||
|
||||
export interface ConnectionRow {
|
||||
id: string
|
||||
label?: string
|
||||
primary?: boolean
|
||||
}
|
||||
|
||||
export interface GatewaySource {
|
||||
connectionId: string
|
||||
count?: number
|
||||
error?: null | string
|
||||
kind?: string
|
||||
label?: string
|
||||
reachable?: boolean
|
||||
}
|
||||
|
||||
export type AvatarShape = 'circle' | 'cloud' | 'drop' | 'hexagon' | 'pill' | 'squircle' | 'triangle'
|
||||
|
||||
export type BlobKind =
|
||||
'boxy' | 'capsule' | 'cloud' | 'droplet' | 'hexagon' | 'nub' | 'organic' | 'round' | 'sun' | 'triangle'
|
||||
|
||||
export type FaceMood = 'idle' | 'work'
|
||||
|
||||
export interface AvatarAppearance {
|
||||
/** `null` when nothing is picked — the name's deterministic hue stands in.
|
||||
* `profileColor` returns null for the unnamed/default profile, so this has
|
||||
* always been nullable in practice; `avatarColor` is what resolves it. */
|
||||
color: null | string
|
||||
image: null | string
|
||||
/** Free-form: a bare shape, `sigil-<n>`, a platonic solid, or `blobatar:<seed>:<kind>`. */
|
||||
shape: string
|
||||
}
|
||||
|
||||
export type AttentionClass = 'agent_blocked' | 'missing_config' | 'provider_auth_or_access' | 'provider_quota_limit'
|
||||
|
||||
export type RosterKindFilter = 'all' | 'bots' | 'groups'
|
||||
export type RosterActivityFilter = 'active' | 'all' | 'older' | 'recent'
|
||||
Reference in New Issue
Block a user