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:
@@ -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)
|
||||
})
|
||||
Reference in New Issue
Block a user