fix(desktop): @-mentions now autocomplete in Bot Mode group composers (#89049)

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.
This commit is contained in:
Teknium
2026-08-18 02:50:05 -07:00
parent 7cae03b8c0
commit daca386967
2 changed files with 219 additions and 4 deletions
+151 -4
View File
@@ -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' })
]
@@ -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)
})