From daca38696738524ffdb901c18dbdbef64c1a97a9 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 18 Aug 2026 02:50:05 -0700 Subject: [PATCH] fix(desktop): @-mentions now autocomplete in Bot Mode group composers (#89049) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The group room's composer (new-thread box and reply-in-thread box) rendered a plain SDK Input. The plugin's mention provider is registered against COMPOSER_AREAS.atCompletions, which only mounts in the main chat composer — workspace tiles never see it, so typing @ or / in a group chat did nothing despite the placeholder advertising "@name to direct, @everyone for all". Fix: a member-scoped GroupMentionInput wrapper in the plugin itself — caret-aware @-token detection, popover offering @everyone/@all plus each seated member's handle (same botHandle the parser uses), keyboard nav (Up/Down/Enter/Tab/Escape), and insertion that produces exactly the "@handle " strings parseGroupChatMentions resolves. Both group composers (GroupChatWorkspace main box + thread reply box) mount it; the per-bot composer path is untouched. Root cause traced by the reporter of #89049 — confirmed against source. --- .../desktop/src/plugins/hermes-bots/plugin.js | 155 +++++++++++++++++- .../tests/group-mention-composer.test.mjs | 68 ++++++++ 2 files changed, 219 insertions(+), 4 deletions(-) create mode 100644 apps/desktop/src/plugins/hermes-bots/tests/group-mention-composer.test.mjs diff --git a/apps/desktop/src/plugins/hermes-bots/plugin.js b/apps/desktop/src/plugins/hermes-bots/plugin.js index 8c2cfca413..c3f3d2465a 100644 --- a/apps/desktop/src/plugins/hermes-bots/plugin.js +++ b/apps/desktop/src/plugins/hermes-bots/plugin.js @@ -7679,6 +7679,151 @@ function assignLegacyThreads(log) { * window (host.openWorkspace tile) and in the bots panel (older-desktop * fallback); `onBack` is where the Back button routes — the main tile's * closer, or clearing the in-panel workspace atom. */ +/** The active @-token at the caret: text from the nearest '@' (that begins a + * word) up to the caret, or null when the caret isn't inside a mention. */ +function mentionTokenAt(text, caret) { + const upto = String(text || '').slice(0, caret) + const match = /(^|\s)@([a-z0-9._-]*)$/i.exec(upto) + + if (!match) { + return null + } + + return { query: match[2].toLowerCase(), start: caret - match[2].length - 1 } +} + +/** Mention-aware composer input for group rooms. The core composer's + * @-completion area doesn't mount inside workspace tiles (#89049), so this + * wraps the plain SDK Input with a member-scoped popover: @everyone/@all + * quick picks plus each seated member's handle. Insertion produces exactly + * the strings parseGroupChatMentions resolves. Keyboard: Up/Down navigate, + * Enter/Tab insert (Enter falls through to submit when the popover is + * closed), Escape dismisses. */ +function GroupMentionInput({ members, onChange, value, ...inputProps }) { + const allMeta = useValue($botMeta) + const inputRef = useRef(null) + const [token, setToken] = useState(null) + const [selected, setSelected] = useState(0) + + const options = [] + + if (token) { + for (const pick of ['everyone', 'all']) { + if (pick.startsWith(token.query)) { + options.push({ handle: pick, meta: 'Every bot in the room' }) + } + } + + for (const member of members) { + const handle = String(member.handle || botHandle(member.name, member) || '').trim() + + if (!handle || (token.query && !handle.toLowerCase().startsWith(token.query))) { + continue + } + + options.push({ + handle, + meta: displayName(member, botRosterMeta(member, allMeta)) + }) + } + } + + const open = Boolean(token) && options.length > 0 + const active = open ? Math.min(selected, options.length - 1) : 0 + + const refreshToken = target => { + setToken(mentionTokenAt(target.value, target.selectionStart ?? target.value.length)) + setSelected(0) + } + + const insert = handle => { + if (!token) { + return + } + + const caret = inputRef.current?.selectionStart ?? value.length + const next = `${value.slice(0, token.start)}@${handle} ${value.slice(caret)}` + onChange(next) + setToken(null) + + // Restore focus with the caret after the inserted mention. + const pos = token.start + handle.length + 2 + requestAnimationFrame(() => { + const el = inputRef.current + + if (el) { + el.focus() + try { + el.setSelectionRange(pos, pos) + } catch { + /* input type without selection support */ + } + } + }) + } + + return jsxs('div', { + className: 'relative min-w-0 flex-1', + children: [ + open + ? jsx('div', { + className: + 'absolute bottom-full left-0 z-50 mb-1 max-h-48 w-64 overflow-y-auto rounded-md border border-(--ui-stroke-secondary) bg-(--ui-bg-primary,#111) py-1 shadow-lg', + children: options.map((option, index) => + jsxs('button', { + type: 'button', + className: cn( + 'flex w-full items-baseline gap-2 px-2 py-1 text-left text-xs', + index === active ? 'bg-(--ui-control-hover-background) text-foreground' : 'text-(--ui-text-secondary)' + ), + // preventDefault on mousedown so the input keeps focus. + onMouseDown: event => { + event.preventDefault() + insert(option.handle) + }, + onMouseEnter: () => setSelected(index), + children: [ + jsx('span', { className: 'font-medium', children: `@${option.handle}` }), + jsx('span', { className: 'truncate text-[0.65rem] text-(--ui-text-quaternary)', children: option.meta }) + ] + }, option.handle) + ) + }) + : null, + jsx(Input, { + ...inputProps, + ref: inputRef, + value, + onChange: event => { + onChange(event.target.value) + refreshToken(event.target) + }, + onClick: event => refreshToken(event.target), + onKeyDown: event => { + if (!open) { + return + } + + if (event.key === 'ArrowDown') { + event.preventDefault() + setSelected((active + 1) % options.length) + } else if (event.key === 'ArrowUp') { + event.preventDefault() + setSelected((active - 1 + options.length) % options.length) + } else if (event.key === 'Enter' || event.key === 'Tab') { + event.preventDefault() + insert(options[active].handle) + } else if (event.key === 'Escape') { + event.preventDefault() + setToken(null) + } + }, + onBlur: () => setToken(null) + }) + ] + }) +} + function GroupChatWorkspace({ group, members, onBack }) { const rooms = useValue($groupChats) const allMeta = useValue($botMeta) @@ -7959,12 +8104,13 @@ function GroupChatWorkspace({ group, members, onBack }) { submitReply(id) }, children: [ - jsx(Input, { + jsx(GroupMentionInput, { 'aria-label': 'Reply in thread', autoFocus: true, placeholder: 'Reply in thread…', + members, value: replyDrafts[id] || '', - onChange: event => setReplyDrafts(prev => ({ ...prev, [id]: event.target.value })) + onChange: text => setReplyDrafts(prev => ({ ...prev, [id]: text })) }), jsx(Button, { type: 'submit', size: 'sm', disabled: !(replyDrafts[id] || '').trim(), children: 'Reply' }) ] @@ -8023,11 +8169,12 @@ function GroupChatWorkspace({ group, members, onBack }) { submit() }, children: [ - jsx(Input, { + jsx(GroupMentionInput, { 'aria-label': `Message ${group}`, placeholder: `New thread in ${group}… (@name to direct, @everyone for all)`, + members, value: draft, - onChange: event => setDraft(event.target.value) + onChange: setDraft }), jsx(Button, { type: 'submit', size: 'sm', disabled: !draft.trim(), children: 'New Thread' }) ] diff --git a/apps/desktop/src/plugins/hermes-bots/tests/group-mention-composer.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/group-mention-composer.test.mjs new file mode 100644 index 0000000000..b3402cd605 --- /dev/null +++ b/apps/desktop/src/plugins/hermes-bots/tests/group-mention-composer.test.mjs @@ -0,0 +1,68 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' +import vm from 'node:vm' + +// Group-composer mentions (#89049): the room's "new thread" composer and the +// reply-in-thread box mount a member-scoped @-mention popover instead of a +// dead plain Input. Source-shape assertions plus a functional check of the +// caret tokenizer, extracted and run in isolation. + +const source = readFileSync(new URL('../plugin.js', import.meta.url), 'utf8') + +test('group room composers mount GroupMentionInput, not bare Input', () => { + // Main composer (new thread) and thread reply box both use the wrapper. + const mainComposer = source.match(/jsx\(GroupMentionInput, \{\s*'aria-label': `Message \$\{group\}`/) + const replyComposer = source.match(/jsx\(GroupMentionInput, \{\s*'aria-label': 'Reply in thread'/) + assert.ok(mainComposer, 'main group composer must render GroupMentionInput') + assert.ok(replyComposer, 'thread reply box must render GroupMentionInput') + + // Neither composer renders a dead plain Input anymore. + assert.ok(!/jsx\(Input, \{\s*'aria-label': `Message \$\{group\}`/.test(source)) + assert.ok(!/jsx\(Input, \{\s*'aria-label': 'Reply in thread'/.test(source)) + + // Both receive the seated members for the popover scope. + assert.ok(/GroupMentionInput\(\{ members, onChange, value/.test(source)) +}) + +test('popover offers @everyone/@all and inserts parser-compatible strings', () => { + const component = source.slice( + source.indexOf('function GroupMentionInput'), + source.indexOf('function GroupChatWorkspace') + ) + assert.ok(component.includes("['everyone', 'all']"), 'quick picks for the room-wide broadcast') + // Insertion writes exactly "@handle " — the shape parseGroupChatMentions resolves. + assert.ok(component.includes('`${value.slice(0, token.start)}@${handle} ${value.slice(caret)}`')) + // Member handles come from the same botHandle used by the parser. + assert.ok(component.includes('botHandle(member.name, member)')) + // mousedown insertion must preventDefault so the input keeps focus. + assert.ok(/onMouseDown: event => \{\s*event\.preventDefault\(\)/.test(component)) +}) + +test('mentionTokenAt finds the active @-token at the caret', () => { + const start = source.indexOf('function mentionTokenAt') + const end = source.indexOf('function GroupMentionInput') + assert.ok(start > 0 && end > start, 'mentionTokenAt must precede GroupMentionInput') + + const ctx = {} + vm.createContext(ctx) + vm.runInContext(source.slice(start, end) + '\nthis.fn = mentionTokenAt', ctx) + const fn = ctx.fn + + // Mid-word @ tokens resolve with the right query + start offset. + // (Field-wise compare: vm-context objects have a foreign Object prototype, + // which strict deepEqual rejects.) + const eq = (actual, expected) => { + assert.equal(actual?.query, expected?.query) + assert.equal(actual?.start, expected?.start) + } + eq(fn('hey @al', 7), { query: 'al', start: 4 }) + eq(fn('@', 1), { query: '', start: 0 }) + eq(fn('ping @every', 11), { query: 'every', start: 5 }) + + // Not a mention context: no token → popover stays closed. + assert.equal(fn('email me a@b', 12), null) + assert.equal(fn('plain text', 10), null) + // Caret before the @ is not inside the token. + assert.equal(fn('hey @al', 3), null) +})