From a90ccd46b733e0964b71fe2e32ebd1116e681f4b Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 29 Jul 2026 21:04:30 -0500 Subject: [PATCH] feat(desktop): :shortcode: emoji completions in both composers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../composer/hooks/use-composer-trigger.ts | 22 +++- .../composer/hooks/use-emoji-completions.ts | 122 ++++++++++++++++++ apps/desktop/src/app/chat/composer/index.tsx | 4 +- .../src/app/chat/composer/text-utils.ts | 13 +- .../src/app/chat/composer/trigger-popover.tsx | 9 +- .../thread/user-edit-composer.tsx | 19 ++- 6 files changed, 181 insertions(+), 8 deletions(-) create mode 100644 apps/desktop/src/app/chat/composer/hooks/use-emoji-completions.ts diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.ts index c59bcda6d7f..b178369c4c1 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.ts @@ -30,6 +30,8 @@ interface UseComposerTriggerOptions { at: CompletionSource draftRef: MutableRefObject editorRef: RefObject + /** `: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 diff --git a/apps/desktop/src/app/chat/composer/hooks/use-emoji-completions.ts b/apps/desktop/src/app/chat/composer/hooks/use-emoji-completions.ts new file mode 100644 index 00000000000..0fb03526234 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-emoji-completions.ts @@ -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 | null = null +let indexLoaded = false + +async function loadIndex(): Promise { + 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 = 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 { + 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 => { + 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 + }) +} diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 478e40c281f..5062e1c2939 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -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 diff --git a/apps/desktop/src/app/chat/composer/text-utils.ts b/apps/desktop/src/app/chat/composer/text-utils.ts index e471daecf5e..bd029de28f3 100644 --- a/apps/desktop/src/app/chat/composer/text-utils.ts +++ b/apps/desktop/src/app/chat/composer/text-utils.ts @@ -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 } diff --git a/apps/desktop/src/app/chat/composer/trigger-popover.tsx b/apps/desktop/src/app/chat/composer/trigger-popover.tsx index da52f1dd088..0cf57700c14 100644 --- a/apps/desktop/src/app/chat/composer/trigger-popover.tsx +++ b/apps/desktop/src/app/chat/composer/trigger-popover.tsx @@ -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} @file: {copy.lookupOr}{' '} @folder:. + ) : kind === ':' ? ( + <> + {copy.lookupTry} :joy:. + ) : ( <> {copy.lookupTry} /help. @@ -154,6 +158,9 @@ export function ComposerTriggerPopover({ )} + ) : kind === ':' ? ( + // Just the emoji + :shortcode:, Slack-style — no icon column. + {display} ) : ( <> diff --git a/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx b/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx index 72e9b8e1ddb..f27c5358af6 100644 --- a/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx @@ -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 = ({ 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 = ({ 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 = ({ 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) => {