fix(desktop): highlight full multi-word display names in composer ove… (#163)

This commit is contained in:
Wes
2026-03-23 09:25:23 -07:00
committed by GitHub
parent 4603d37ae7
commit 035ff1d650
5 changed files with 121 additions and 53 deletions
@@ -3,20 +3,53 @@ import * as React from "react";
import { useManagedAgentsQuery } from "@/features/agents/hooks";
import { useChannelMembersQuery } from "@/features/channels/hooks";
import type { MentionSuggestion } from "@/features/messages/ui/MentionAutocomplete";
import { escapeRegExp } from "@/shared/lib/mentionPattern";
function detectMentionQuery(
value: string,
cursorPosition: number,
knownNamesLower: string[],
): { query: string; startIndex: number } | null {
const beforeCursor = value.slice(0, cursorPosition);
const match = beforeCursor.match(/(?:^|[\s])@([^\s]*)$/);
if (!match) {
return null;
// Fast path: single-word mention query (no spaces after @)
const simpleMatch = beforeCursor.match(/(?:^|[\s])@([^\s]*)$/);
if (simpleMatch) {
const query = simpleMatch[1];
const startIndex = beforeCursor.length - query.length - 1; // -1 for @
return { query, startIndex };
}
const query = match[1];
const startIndex = beforeCursor.length - query.length - 1; // -1 for @
return { query, startIndex };
// Multi-word path: scan backwards for an `@` and check if the text between
// `@` and the cursor is a prefix of any known multi-word display name.
const scanStart = Math.max(0, beforeCursor.length - 80);
for (let i = beforeCursor.length - 1; i >= scanStart; i--) {
const ch = beforeCursor[i];
if (ch === "@") {
// Ensure `@` is at start or preceded by whitespace
if (i > 0 && !/\s/.test(beforeCursor[i - 1])) {
continue;
}
const candidate = beforeCursor.slice(i + 1);
if (candidate.length === 0) {
break;
}
const lowerCandidate = candidate.toLowerCase();
const isPrefix = knownNamesLower.some((name) =>
name.startsWith(lowerCandidate),
);
if (isPrefix) {
return { query: candidate, startIndex: i };
}
break;
}
// Stop scanning if we hit a newline — mentions don't span lines
if (ch === "\n") {
break;
}
}
return null;
}
export function useMentions(channelId: string | null) {
@@ -26,7 +59,7 @@ export function useMentions(channelId: string | null) {
const mentionMapRef = React.useRef<Map<string, string>>(new Map());
const membersQuery = useChannelMembersQuery(channelId);
const members = membersQuery.data ?? [];
const members = membersQuery.data;
const managedAgentsQuery = useManagedAgentsQuery();
const managedAgentNamesByPubkey = React.useMemo(
() =>
@@ -39,13 +72,33 @@ export function useMentions(channelId: string | null) {
[managedAgentsQuery.data],
);
const knownNames = React.useMemo<string[]>(() => {
if (!members) return [];
const names: string[] = [];
for (const member of members) {
const name =
member.displayName ??
managedAgentNamesByPubkey.get(member.pubkey.toLowerCase());
if (name) {
names.push(name);
}
}
return names;
}, [members, managedAgentNamesByPubkey]);
/** Lower-cased version of knownNames, used for case-insensitive prefix matching in detectMentionQuery. */
const knownNamesLower = React.useMemo<string[]>(
() => knownNames.map((n) => n.toLowerCase()),
[knownNames],
);
const suggestions = React.useMemo<MentionSuggestion[]>(() => {
if (mentionQuery === null) {
return [];
}
const lowerQuery = mentionQuery.toLowerCase();
return members
return (members ?? [])
.map((member) => {
const fallbackName =
managedAgentNamesByPubkey.get(member.pubkey.toLowerCase()) ??
@@ -95,7 +148,11 @@ export function useMentions(channelId: string | null) {
const updateMentionQuery = React.useCallback(
(value: string, cursorPosition: number) => {
const mention = detectMentionQuery(value, cursorPosition);
const mention = detectMentionQuery(
value,
cursorPosition,
knownNamesLower,
);
if (mention) {
setMentionQuery(mention.query);
setMentionStartIndex(mention.startIndex);
@@ -104,7 +161,7 @@ export function useMentions(channelId: string | null) {
setMentionQuery(null);
}
},
[],
[knownNamesLower],
);
const extractMentionPubkeys = React.useCallback(
@@ -112,7 +169,7 @@ export function useMentions(channelId: string | null) {
const pubkeys: string[] = [];
const hasMention = (name: string): boolean => {
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const escaped = escapeRegExp(name);
const pattern = new RegExp(
`(?:^|\\s)@${escaped}(?=[\\s,;.!?:)\\]}]|$)`,
"i",
@@ -126,7 +183,7 @@ export function useMentions(channelId: string | null) {
}
}
for (const member of members) {
for (const member of members ?? []) {
if (pubkeys.includes(member.pubkey)) {
continue;
}
@@ -202,6 +259,7 @@ export function useMentions(channelId: string | null) {
handleMentionKeyDown,
insertMention,
isMentionOpen,
knownNames,
mentionSelectedIndex,
suggestions,
updateMentionQuery,
@@ -1,14 +1,25 @@
import type React from "react";
import React from "react";
import { buildMentionPattern } from "@/shared/lib/mentionPattern";
type Segment = { type: "text" | "mention" | "channel"; value: string };
const MENTION_OR_CHANNEL_RE = /@\S+|#[a-zA-Z0-9][\w-]*/g;
const CHANNEL_RE_PART = "#[a-zA-Z0-9][\\w-]*";
function parseSegments(text: string): Segment[] {
/**
* Extends the shared mention pattern to also match `#channel-name` references.
*/
function buildOverlayPattern(mentionNames: string[]): RegExp {
const mentionSource = buildMentionPattern(mentionNames).source;
return new RegExp(`${mentionSource}|${CHANNEL_RE_PART}`, "g");
}
function parseSegments(text: string, pattern: RegExp): Segment[] {
const segments: Segment[] = [];
let lastIndex = 0;
for (const match of text.matchAll(MENTION_OR_CHANNEL_RE)) {
pattern.lastIndex = 0;
for (const match of text.matchAll(pattern)) {
const matchStart = match.index;
if (matchStart > lastIndex) {
segments.push({ type: "text", value: text.slice(lastIndex, matchStart) });
@@ -31,14 +42,20 @@ function parseSegments(text: string): Segment[] {
type ComposerMentionOverlayProps = {
content: string;
mentionNames: string[];
scrollTop: number;
};
export function ComposerMentionOverlay({
content,
mentionNames,
scrollTop,
}: ComposerMentionOverlayProps) {
const segments = parseSegments(content);
const pattern = React.useMemo(
() => buildOverlayPattern(mentionNames),
[mentionNames],
);
const segments = parseSegments(content, pattern);
return (
<div
@@ -39,21 +39,6 @@ type MessageComposerProps = {
const MAX_TEXTAREA_ROWS = 4;
function detectMentionQuery(
value: string,
cursorPosition: number,
): { query: string; startIndex: number } | null {
const beforeCursor = value.slice(0, cursorPosition);
const match = beforeCursor.match(/(?:^|[\s])@([^\s]*)$/);
if (!match) {
return null;
}
const query = match[1];
const startIndex = beforeCursor.length - query.length - 1; // -1 for @
return { query, startIndex };
}
export function MessageComposer({
channelId = null,
channelName,
@@ -192,8 +177,9 @@ export function MessageComposer({
const currentContent = contentRef.current;
const cursorPosition = textarea.selectionStart ?? currentContent.length;
const existingMention = detectMentionQuery(currentContent, cursorPosition);
if (existingMention) {
// Quick check: is there already an @-query in progress?
const beforeCursor = currentContent.slice(0, cursorPosition);
if (/(?:^|[\s])@[^\s]*$/.test(beforeCursor)) {
mentions.updateMentionQuery(currentContent, cursorPosition);
textarea.focus();
return;
@@ -573,6 +559,7 @@ export function MessageComposer({
>
<ComposerMentionOverlay
content={content}
mentionNames={mentions.knownNames}
scrollTop={composerScrollTop}
/>
</div>
+23
View File
@@ -0,0 +1,23 @@
/**
* Escape special regex characters in a string.
*/
export function escapeRegExp(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/**
* Build a regex that matches @mentions, trying known multi-word names first
* (longest-first to avoid partial matches), then falling back to @\S+.
*/
export function buildMentionPattern(mentionNames: string[]): RegExp {
const sorted = [...new Set(mentionNames)]
.filter((name) => name.trim().length > 0)
.sort((a, b) => b.length - a.length);
if (sorted.length === 0) {
return /@\S+/g;
}
const nameAlternatives = sorted.map((name) => escapeRegExp(name)).join("|");
return new RegExp(`@(?:${nameAlternatives}|\\S+)`, "g");
}
+2 -19
View File
@@ -7,6 +7,8 @@
* falls back to the generic `@\S+` pattern for unknown mentions.
*/
import { buildMentionPattern } from "./mentionPattern";
type RemarkMentionsOptions = {
mentionNames?: string[];
};
@@ -42,25 +44,6 @@ function walkChildren(node: any, mentionPattern: RegExp) {
}
}
function escapeRegExp(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function buildMentionPattern(mentionNames: string[]): RegExp {
// Deduplicate and sort longest-first so "John Doe" is matched before "John"
const sorted = [...new Set(mentionNames)]
.filter((name) => name.trim().length > 0)
.sort((a, b) => b.length - a.length);
if (sorted.length === 0) {
return /@\S+/g;
}
// Build alternation: try known names first, then fall back to @\S+
const nameAlternatives = sorted.map((name) => escapeRegExp(name)).join("|");
return new RegExp(`@(?:${nameAlternatives}|\\S+)`, "g");
}
function splitMentions(text: string, mentionPattern: RegExp) {
// Reset lastIndex — the pattern is reused across text nodes with the `g` flag
mentionPattern.lastIndex = 0;