From 35a289b64a1d2cb8f7d7321262f43aa30af94204 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?=
<35082514+alezmad@users.noreply.github.com>
Date: Sat, 2 May 2026 19:21:19 +0100
Subject: [PATCH] feat(web): @-mention autocomplete + highlight in topic chat
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Typing `@` in the compose box opens a dropdown of matching mesh
members fed by /v1/members. Filters live by displayName prefix
(case-insensitive); online members rank above offline; shorter
names rank higher; capped at 8 entries.
Keyboard: ArrowUp/Down to navigate, Enter or Tab to insert,
Escape to dismiss. Mouse hover updates the selection; mousedown
inserts (mousedown so the textarea doesn't lose focus first).
Rendered messages now highlight @mentions in clay so they're
visually distinct from plain text — same regex the autocomplete
uses, so the round trip is consistent.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
.../web/src/modules/mesh/topic-chat-panel.tsx | 222 +++++++++++++++++-
1 file changed, 219 insertions(+), 3 deletions(-)
diff --git a/apps/web/src/modules/mesh/topic-chat-panel.tsx b/apps/web/src/modules/mesh/topic-chat-panel.tsx
index 04cb4d8..721f094 100644
--- a/apps/web/src/modules/mesh/topic-chat-panel.tsx
+++ b/apps/web/src/modules/mesh/topic-chat-panel.tsx
@@ -1,5 +1,6 @@
"use client";
+import type React from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Button } from "@turbostarter/ui-web/button";
@@ -68,6 +69,38 @@ function decodeIncoming(ciphertext: string): string {
}
}
+/**
+ * Render plaintext with @mentions highlighted in clay. We split on the
+ * mention regex and rebuild as alternating spans so React can reconcile
+ * keys cleanly. URL/markdown parsing is out of scope for v0.2.0.
+ */
+function renderWithMentions(text: string): React.ReactNode[] {
+ const parts: React.ReactNode[] = [];
+ const re = /(^|\s)(@[A-Za-z0-9_-]+)/g;
+ let lastIndex = 0;
+ let match: RegExpExecArray | null;
+ let key = 0;
+ while ((match = re.exec(text)) !== null) {
+ const [, lead, mention] = match;
+ const matchStart = match.index + (lead?.length ?? 0);
+ if (matchStart > lastIndex) {
+ parts.push(
+ {text.slice(lastIndex, matchStart)},
+ );
+ }
+ parts.push(
+
+ {mention}
+ ,
+ );
+ lastIndex = matchStart + (mention?.length ?? 0);
+ }
+ if (lastIndex < text.length) {
+ parts.push({text.slice(lastIndex)});
+ }
+ return parts;
+}
+
function fmtTime(iso: string): string {
try {
return new Date(iso).toLocaleTimeString([], {
@@ -142,7 +175,13 @@ export function TopicChatPanel({
"connecting" | "live" | "reconnecting" | "stopped"
>("connecting");
const [lastEventAt, setLastEventAt] = useState(null);
+ const [mentionState, setMentionState] = useState<{
+ query: string;
+ start: number;
+ selected: number;
+ } | null>(null);
const scrollRef = useRef(null);
+ const textareaRef = useRef(null);
const seenIdsRef = useRef>(new Set());
const lastMarkReadAtRef = useRef(0);
@@ -311,6 +350,65 @@ export function TopicChatPanel({
});
}, [messages.length]);
+ // Member name lookup for autocomplete. Filtered by case-insensitive
+ // prefix match on displayName; shorter names rank higher so e.g. "@al"
+ // surfaces "Alice" above "Alejandro" if both exist. Capped at 8.
+ const mentionMatches = useMemo(() => {
+ if (!mentionState) return [];
+ const q = mentionState.query.toLowerCase();
+ return members
+ .filter((m) => m.displayName.toLowerCase().startsWith(q))
+ .sort((a, b) => {
+ if (a.online !== b.online) return a.online ? -1 : 1;
+ return a.displayName.length - b.displayName.length;
+ })
+ .slice(0, 8);
+ }, [members, mentionState]);
+
+ // Re-evaluate the @-mention context whenever the textarea changes —
+ // we look at the substring before the cursor and check whether it
+ // ends in `@` with no whitespace between the @ and the cursor.
+ const updateMentionFromCursor = useCallback(
+ (value: string, cursor: number) => {
+ const before = value.slice(0, cursor);
+ const m = before.match(/(^|\s)@([A-Za-z0-9_-]*)$/);
+ if (!m) {
+ setMentionState(null);
+ return;
+ }
+ const query = m[2] ?? "";
+ const start = before.length - query.length - 1; // index of '@'
+ setMentionState((prev) =>
+ prev && prev.start === start && prev.query === query
+ ? prev
+ : { query, start, selected: 0 },
+ );
+ },
+ [],
+ );
+
+ const insertMention = useCallback(
+ (memberName: string) => {
+ if (!mentionState) return;
+ const ta = textareaRef.current;
+ if (!ta) return;
+ const before = draft.slice(0, mentionState.start);
+ const after = draft.slice(ta.selectionStart);
+ const replacement = `@${memberName} `;
+ const next = before + replacement + after;
+ const nextCursor = before.length + replacement.length;
+ setDraft(next);
+ setMentionState(null);
+ // Restore cursor + focus on the next tick — React schedules the
+ // value update, so we can't mutate selection in the same frame.
+ requestAnimationFrame(() => {
+ ta.focus();
+ ta.setSelectionRange(nextCursor, nextCursor);
+ });
+ },
+ [draft, mentionState],
+ );
+
const send = async () => {
const text = draft.trim();
if (!text) return;
@@ -407,7 +505,7 @@ export function TopicChatPanel({
- {decodeIncoming(m.ciphertext)}
+ {renderWithMentions(decodeIncoming(m.ciphertext))}
))}
@@ -513,19 +611,137 @@ export function TopicChatPanel({
) : null}