feat(desktop): :shortcode: emoji completions in both composers

A third trigger kind beside @ and / — same detection, same popover, same
commit path. :jo opens 😂/🤣/… fed by the bundled emojibase shortcode data
(search hits shortcodes first, then tags and labels); picking inserts the
emoji character as plain inline text, not a chip. Boundary-anchored with a
two-char minimum so localhost:8080, timestamps, and :D never trigger it.
Wired in the main composer and the edit composer's duplicated trigger loop.
This commit is contained in:
Brooklyn Nicholson 2026-07-29 21:04:30 -05:00
parent 6ec319f530
commit a90ccd46b7
6 changed files with 181 additions and 8 deletions

View file

@ -30,6 +30,8 @@ interface UseComposerTriggerOptions {
at: CompletionSource
draftRef: MutableRefObject<string>
editorRef: RefObject<HTMLDivElement | null>
/** `:joy` emoji completions — inserts the emoji character, never a chip. */
emoji?: CompletionSource
/** Bank the pre-commit state so a popover pick is a single undo step. */
recordUndoPoint?: () => void
requestMainFocus: () => void
@ -50,6 +52,7 @@ export function useComposerTrigger({
at,
draftRef,
editorRef,
emoji,
recordUndoPoint,
requestMainFocus,
setComposerText,
@ -91,7 +94,7 @@ export function useComposerTrigger({
// is present do we pay the cost of the full walk + DOM range work.
const rawText = editor.textContent ?? ''
if (!rawText.includes('@') && !rawText.includes('/')) {
if (!rawText.includes('@') && !rawText.includes('/') && !rawText.includes(':')) {
if (trigger) {
setTrigger(null)
resetTriggerActive()
@ -128,7 +131,13 @@ export function useComposerTrigger({
}, [editorRef, resetTriggerActive, trigger])
const triggerAdapter: Unstable_TriggerAdapter | null =
trigger?.kind === '@' ? at.adapter : trigger?.kind === '/' ? slash.adapter : null
trigger?.kind === '@'
? at.adapter
: trigger?.kind === '/'
? slash.adapter
: trigger?.kind === ':'
? (emoji?.adapter ?? null)
: null
useEffect(() => {
if (!trigger || !triggerAdapter?.search) {
@ -146,7 +155,14 @@ export function useComposerTrigger({
setTriggerItems(trigger.inline ? items.filter(isSkillItem) : items)
}, [trigger, triggerAdapter])
const triggerLoading = trigger?.kind === '@' ? at.loading : trigger?.kind === '/' ? slash.loading : false
const triggerLoading =
trigger?.kind === '@'
? at.loading
: trigger?.kind === '/'
? slash.loading
: trigger?.kind === ':'
? (emoji?.loading ?? false)
: false
// Suppress the "No matches" empty state once a slash command is past its name:
// a no-arg command has nothing to offer, and a fully-typed arg commits on

View file

@ -0,0 +1,122 @@
import { useCallback } from 'react'
import { type CompletionEntry, type CompletionPayload, useLiveCompletionAdapter } from './use-live-completion-adapter'
/**
* `:shortcode:` completions for the composers, Slack-style (`:joy` 😂).
*
* Draws from the same bundled emojibase-data the reaction picker uses (served
* at ./emojibase by the `hermes:emojibase-assets` vite plugin offline, no
* CDN). The index lazy-loads on the first `:` trigger, then every query is
* answered from memory, so `isCached` skips the debounce and loading state
* after that first load.
*
* A pick inserts the emoji CHARACTER as plain text not a chip. Directive
* chips exist to carry machine-readable references the backend resolves
* (@file:, /skill); a picked emoji is just text, so it rides the formatter's
* `rawText` path and lands inline.
*/
interface EmojiEntry {
emoji: string
/** Primary shortcode, e.g. "joy". */
code: string
/** Every shortcode, tag, and label that should match a search. */
haystack: string[]
}
let indexPromise: Promise<EmojiEntry[]> | null = null
let indexLoaded = false
async function loadIndex(): Promise<EmojiEntry[]> {
const [dataRes, codesRes] = await Promise.all([
fetch('./emojibase/en/data.json'),
fetch('./emojibase/en/shortcodes/emojibase.json')
])
const data: { emoji: string; hexcode: string; label: string; tags?: string[] }[] = await dataRes.json()
const codes: Record<string, string | string[]> = await codesRes.json()
const entries: EmojiEntry[] = []
for (const item of data) {
const raw = codes[item.hexcode]
if (!raw) {
continue
}
const shortcodes = Array.isArray(raw) ? raw : [raw]
entries.push({
emoji: item.emoji,
code: shortcodes[0],
haystack: [...shortcodes, ...(item.tags ?? []), item.label.toLowerCase()]
})
}
indexLoaded = true
return entries
}
/** Prefix matches on shortcodes rank first, then tag/label substring hits. */
async function searchEmoji(query: string, limit = 8): Promise<EmojiEntry[]> {
const index = await (indexPromise ??= loadIndex())
const q = query.toLowerCase()
const prefix: EmojiEntry[] = []
const loose: EmojiEntry[] = []
for (const entry of index) {
if (entry.code.startsWith(q) || entry.haystack.some(h => h.startsWith(q))) {
prefix.push(entry)
} else if (entry.haystack.some(h => h.includes(q))) {
loose.push(entry)
}
if (prefix.length >= limit) {
break
}
}
return [...prefix, ...loose].slice(0, limit)
}
export function useEmojiCompletions() {
const fetcher = useCallback(async (query: string): Promise<CompletionPayload> => {
const entries = await searchEmoji(query)
return {
query,
items: entries.map(entry => ({
text: entry.emoji,
display: `${entry.emoji} :${entry.code}:`,
meta: ''
}))
}
}, [])
const toItem = useCallback(
(entry: CompletionEntry, index: number) => ({
id: `${entry.text}|${index}`,
type: 'emoji',
label: typeof entry.display === 'string' ? entry.display : entry.text,
metadata: {
display: typeof entry.display === 'string' ? entry.display : entry.text,
// The formatter's serialize() returns rawText verbatim → the emoji
// character lands as plain inline text, no chip.
rawText: entry.text,
meta: '',
group: '',
action: ''
}
}),
[]
)
return useLiveCompletionAdapter({
enabled: true,
fetcher,
isCached: () => indexLoaded,
toItem
})
}

View file

@ -36,6 +36,7 @@ import { COMPOSER_DROP_ACTIVE_CLASS, COMPOSER_DROP_FADE_CLASS } from './drop-aff
import { markActiveComposer } from './focus'
import { HelpHint } from './help-hint'
import { useAtCompletions } from './hooks/use-at-completions'
import { useEmojiCompletions } from './hooks/use-emoji-completions'
import { useComposerBranch } from './hooks/use-composer-branch'
import { useComposerDraft } from './hooks/use-composer-draft'
import { useComposerDrop } from './hooks/use-composer-drop'
@ -184,6 +185,7 @@ export function ChatBar({
const { availableThemes, themeName } = useTheme()
const at = useAtCompletions({ gateway: gateway ?? null, sessionId: sessionId ?? null, cwd: cwd ?? null })
const slash = useSlashCompletions({ activeSkin: themeName, gateway: gateway ?? null, skinThemes: availableThemes })
const emoji = useEmojiCompletions()
const { t } = useI18n()
const gatewayState = useStore($gatewayState)
@ -346,7 +348,7 @@ export function ChatBar({
triggerItems,
triggerKeyConsumedRef,
triggerLoading
} = useComposerTrigger({ at, draftRef, editorRef, recordUndoPoint, requestMainFocus, setComposerText, slash })
} = useComposerTrigger({ at, draftRef, editorRef, emoji, recordUndoPoint, requestMainFocus, setComposerText, slash })
// Pull the live contentEditable text into draftRef + the AUI composer state
// (which drives `hasComposerPayload` → the send button). Shared by the input

View file

@ -4,7 +4,7 @@ export interface TriggerState {
/** True for a `/` typed mid-message — an inline skill/command reference in
* prose rather than a command invocation. Arg completion doesn't apply. */
inline?: boolean
kind: '@' | '/'
kind: '@' | '/' | ':'
query: string
tokenLength: number
}
@ -44,6 +44,10 @@ export interface TriggerState {
const AT_TRIGGER_RE = /(?:^|[\s\uFFFC])(@)([^\s@\uFFFC]*)$/
const SLASH_COMMAND_TRIGGER_RE = /^(\/)((?:[a-zA-Z][\w-]*(?:\s+\S*)*)?)$/
const SLASH_INLINE_TRIGGER_RE = /[\s\uFFFC](\/)([a-zA-Z][\w-]*)?$/
// `:joy` → emoji completions, Slack-style. Boundary-anchored so a mid-word
// colon (`localhost:8080`, `note:`) never fires; two chars minimum so a bare
// `:` or `:D` smiley doesn't open a popover the user didn't ask for.
const EMOJI_TRIGGER_RE = /(?:^|[\s\uFFFC])(:)([a-zA-Z0-9_+-]{2,})$/
/** Stable key for paste dedupe — `items` and `files` often mirror the same image as different objects. */
export function blobDedupeKey(blob: Blob): string {
@ -180,5 +184,12 @@ export function detectTrigger(textBefore: string): TriggerState | null {
return { kind: '@', query: at[2], tokenLength: 1 + at[2].length }
}
// After `@` so a directive starter's colon (`@file:`) stays an `@` query.
const emoji = EMOJI_TRIGGER_RE.exec(textBefore)
if (emoji) {
return { kind: ':', query: emoji[2], tokenLength: 1 + emoji[2].length }
}
return null
}

View file

@ -50,7 +50,7 @@ const ROW_BASE_CLASS = [
interface ComposerTriggerPopoverProps {
activeIndex: number
items: readonly Unstable_TriggerItem[]
kind: '@' | '/'
kind: '@' | '/' | ':'
loading: boolean
onHover: (index: number) => void
onPick: (item: Unstable_TriggerItem) => void
@ -93,6 +93,10 @@ export function ComposerTriggerPopover({
{copy.lookupTry} <span className="font-mono text-foreground/80">@file:</span> {copy.lookupOr}{' '}
<span className="font-mono text-foreground/80">@folder:</span>.
</>
) : kind === ':' ? (
<>
{copy.lookupTry} <span className="font-mono text-foreground/80">:joy:</span>.
</>
) : (
<>
{copy.lookupTry} <span className="font-mono text-foreground/80">/help</span>.
@ -154,6 +158,9 @@ export function ComposerTriggerPopover({
</span>
)}
</>
) : kind === ':' ? (
// Just the emoji + :shortcode:, Slack-style — no icon column.
<span className="min-w-0 shrink truncate leading-5 text-foreground">{display}</span>
) : (
<>
<span className="grid size-4 shrink-0 place-items-center text-(--ui-text-tertiary)">

View file

@ -24,6 +24,7 @@ import {
} from '@/app/chat/composer/focus'
import { useAtCompletions } from '@/app/chat/composer/hooks/use-at-completions'
import { useComposerUndo } from '@/app/chat/composer/hooks/use-composer-undo'
import { useEmojiCompletions } from '@/app/chat/composer/hooks/use-emoji-completions'
import { useSlashCompletions } from '@/app/chat/composer/hooks/use-slash-completions'
import {
dragHasAttachments,
@ -112,6 +113,7 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
const canSubmit = draft.trim().length > 0
const at = useAtCompletions({ cwd, gateway, sessionId })
const slash = useSlashCompletions({ gateway })
const emoji = useEmojiCompletions()
// This is the one composer that routinely unmounts, so it is where the focus
// bus leaks: confirming or cancelling an edit tears the composer down while
@ -282,7 +284,13 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
}, [])
const triggerAdapter: Unstable_TriggerAdapter | null =
trigger?.kind === '@' ? at.adapter : trigger?.kind === '/' ? slash.adapter : null
trigger?.kind === '@'
? at.adapter
: trigger?.kind === '/'
? slash.adapter
: trigger?.kind === ':'
? emoji.adapter
: null
useEffect(() => {
if (!trigger || !triggerAdapter?.search) {
@ -298,7 +306,14 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
setTriggerActive(idx => Math.min(idx, Math.max(0, triggerItems.length - 1)))
}, [triggerItems.length])
const triggerLoading = trigger?.kind === '@' ? at.loading : trigger?.kind === '/' ? slash.loading : false
const triggerLoading =
trigger?.kind === '@'
? at.loading
: trigger?.kind === '/'
? slash.loading
: trigger?.kind === ':'
? emoji.loading
: false
const replaceTriggerWithChip = useCallback(
(item: Unstable_TriggerItem) => {