Compare commits

..

18 Commits

Author SHA1 Message Date
ZhenYi
7be2f4eb61 fix(deploy): simplify ingress by removing static paths
Some checks are pending
CI / Rust Lint & Check (push) Waiting to run
CI / Rust Tests (push) Waiting to run
CI / Frontend Lint & Type Check (push) Waiting to run
CI / Frontend Build (push) Blocked by required conditions
The SPA assets are embedded in the app binary, so no separate
static file serving is needed.
2026-04-18 00:51:52 +08:00
ZhenYi
17e878c8b8 fix(room): fix Enter-on-category via React state update instead of DOM manipulation
Problem: dispatchEvent('input') doesn't trigger React's onChange in React 17+.

Solution: pass onCategoryEnter callback from ChatInputArea to MentionPopover.
When Enter is pressed on a category, MentionPopover calls onCategoryEnter(category)
which directly calls React setState (onDraftChange, setCursorPosition,
setShowMentionPopover) inside ChatInputArea, properly triggering re-render.
2026-04-18 00:51:14 +08:00
ZhenYi
14de80b24b fix(room): Enter on category navigates into it, not out of popover
When a category (Repository/User/AI) is selected and Enter is pressed,
append ':' to the textarea value to trigger the next-level item list.
E.g. '@ai' + Enter → '@ai:' → shows AI model items.
2026-04-18 00:42:08 +08:00
ZhenYi
245384ef50 fix(room): handle Enter/Tab mention selection directly in ChatInputArea.handleKeyDown
Previous approach used a native addEventListener in MentionPopover to handle
Enter, but it wasn't firing reliably due to complex event ordering.

New approach: ChatInputArea.handleKeyDown detects @ mention directly from
the DOM (not React state), reads the selected item from module-level
mentionVisibleRef/mentionSelectedIdxRef, and performs the insertion directly.
This completely bypasses the native listener timing issues.
2026-04-18 00:39:06 +08:00
ZhenYi
b8a61b0802 fix(room): make handleSelect read DOM directly instead of stale props
Root cause: MentionPopover's native keydown listener fires before React
state updates, so handleSelect read stale inputValue/cursorPosition props
and silently returned early.

Fix: handleSelect now reads textarea.value/selectionStart directly from
the DOM, matching the approach already used in ChatInputArea's
handleMentionSelect. No more stale closure.
2026-04-18 00:35:21 +08:00
ZhenYi
3cd5b3003c fix(room): fix mention Enter key by reading textarea DOM directly
Problem: showMentionPopover state is stale when handleKeyDown fires
immediately after handleChange (both in same event loop), causing Enter
to be silently swallowed.

Solution:
- Read textarea.value directly in handleKeyDown to detect @ mentions
- Module-level refs (mentionSelectedIdxRef, mentionVisibleRef) share
  selection state between MentionPopover and ChatInputArea
- handleMentionSelect reads DOM instead of relying on props state
2026-04-18 00:25:03 +08:00
ZhenYi
13f5ff328c fix(room): hoist mentionConfirmRef to module scope to fix TDZ error
ChatInputArea is defined before RoomChatPanel in the file, so its JSX
runs before mentionConfirmRef is declared inside RoomChatPanel. Moving the
ref to module level ensures it's initialized before either component renders.
2026-04-18 00:17:47 +08:00
ZhenYi
14bcc04991 fix(room): update keyboard shortcuts — Ctrl+Enter sends, Shift+Enter newlines, Enter only for mention select
- Ctrl+Enter: send message (was plain Enter)
- Shift+Enter: insert newline (textarea default, passes through)
- Enter alone: only triggers mention selection when popover is open, otherwise does nothing
- Update MentionPopover footer/header hints to reflect new shortcuts
2026-04-18 00:11:13 +08:00
ZhenYi
9b966789fd fix(room): resolve mention IDs to display names when rendering messages
- Pass members/repos/aiConfigs lists to MessageContentWithMentions
- Add resolveName() that looks up ID → display name per mention type
- RoomMessageBubble now resolves user/repository/AI mention UIDs to real names
2026-04-18 00:10:12 +08:00
ZhenYi
a9fc6f9937 feat(room): redesign MentionPopover with modern UI/UX
Visual improvements:
- Glassmorphism backdrop blur and refined shadows
- Color-coded categories: user(sky), repository(violet), AI(emerald)
- Gradient backgrounds and smooth transitions
- Custom avatar icons for each mention type

Interaction enhancements:
- Search text highlighting with yellow background
- Auto-scroll selected item into view
- Selection indicator dot and left border accent
- Keyboard navigation (↑↓) with visual feedback

Components:
- CategoryHeader: icon + label with color theme
- SuggestionItem: avatar + highlighted text + category badge
- LoadingSkeleton: Shimmer loading state
- EmptyState: Illustrated empty/loading states

Uses ScrollArea, Avatar, Skeleton from design system
2026-04-18 00:02:09 +08:00
ZhenYi
aacd9572d1 fix(room): replace useMemo+categories with plain const to fix SWC parser error 2026-04-17 23:53:31 +08:00
ZhenYi
9246a9e6ab fix(room): move eslint-disable comment inside array to fix SWC syntax error 2026-04-17 23:52:27 +08:00
ZhenYi
f9a3b51406 perf(room): optimize MentionPopover with caching, stable refs, and loading states
- Position caching: skip recalculation when text+cursor unchanged
- TempDiv reuse: cached DOM element on textarea, created once
- Stable refs pattern: avoid stale closures in keyboard handler
- Auto-selection: reset to first item on category/list change
- Loading states: reposLoading + aiConfigsLoading wired from context
2026-04-17 23:48:26 +08:00
ZhenYi
26682973e7 feat(room): redesign mention system with AST-based format
Backend:
- Add MENTION_TAG_RE matching new `<mention type="..." id="...">label</mention>` format
- Extend extract_mentions() and resolve_mentions() to parse new format (legacy backward-compatible)

Frontend:
- New src/lib/mention-ast.ts: AST types (TextNode, MentionNode, AiActionNode),
  parse() and serialize() functions for AST↔HTML conversion
- MentionPopover: load @repository: from project repos, @ai: from room_ai configs
  (not room members); output new HTML format with ID instead of label
- MessageMentions: use AST parse() for rendering (falls back to legacy parser)
- ChatInputArea: insertMention now produces `<mention type="user" id="...">label</mention>`
- RoomParticipantsPanel: onMention passes member UUID to insertMention
- RoomContext: add projectRepos and roomAiConfigs for mention data sources
2026-04-17 23:43:26 +08:00
ZhenYi
0ea6440ea3 fix(deploy): add proxy-http-version annotation for WebSocket support 2026-04-17 23:18:00 +08:00
ZhenYi
6431709669 fix(room): show model name instead of UID in settings panel
- Load model list on settings panel mount so names are always available.
- SelectValue now displays the selected model's name by looking up
  availableModels, falling back to UID if name not found.
- Existing AI configs list also shows model name instead of raw UID.
2026-04-17 23:17:56 +08:00
ZhenYi
5ff45770ec fix(room): fix model/ai list response parsing in RoomSettingsPanel
The SDK wraps API responses as { data: { code, message, data: [...] } }.
Code was incorrectly accessing resp.data['200'] which doesn't exist.
Fix to use resp.data.data to reach the actual array.
2026-04-17 23:15:14 +08:00
ZhenYi
4f1ea95b58 fix(room): add missing stream column to room_ai table
Create migration m20260417_000001_add_stream_to_room_ai that adds
the `stream BOOLEAN NOT NULL DEFAULT true` column to room_ai.
The model definition includes this field but the original migration
was missing it.

Also format libs/api/room/ws.rs and add gitdata.ai to allowed
WS origins.
2026-04-17 23:09:55 +08:00
14 changed files with 1350 additions and 334 deletions

View File

@ -1,4 +1,3 @@
{{- /* Single unified Ingress for all services */ -}}
{{- $fullName := include "gitdata.fullname" . -}}
{{- $ns := include "gitdata.namespace" . -}}
apiVersion: networking.k8s.io/v1
@ -12,6 +11,7 @@ metadata:
annotations:
cert-manager.io/cluster-issuer: cloudflare-acme-cluster-issuer
nginx.ingress.kubernetes.io/proxy-body-size: "0"
nginx.ingress.kubernetes.io/proxy-http-version: "1.1"
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
spec:
@ -24,24 +24,9 @@ spec:
- static.gitdata.ai
secretName: {{ $fullName }}-tls
rules:
# SPA (embedded in app), with /api and /ws
- host: gitdata.ai
http:
paths:
- path: /api
pathType: Prefix
backend:
service:
name: {{ $fullName }}-app
port:
number: {{ .Values.app.service.port }}
- path: /ws
pathType: Prefix
backend:
service:
name: {{ $fullName }}-app
port:
number: {{ .Values.app.service.port }}
- path: /
pathType: Prefix
backend:

View File

@ -1,7 +1,7 @@
use std::sync::{Arc, LazyLock};
use std::time::{Duration, Instant};
use actix_web::{HttpMessage, HttpRequest, HttpResponse, web};
use actix_web::{web, HttpMessage, HttpRequest, HttpResponse};
use actix_ws::Message as WsMessage;
use serde::Serialize;
use uuid::Uuid;
@ -232,30 +232,42 @@ pub struct WsOutEvent {
pub(crate) fn validate_origin(req: &HttpRequest) -> bool {
static ALLOWED_ORIGINS: LazyLock<Vec<String>> = LazyLock::new(|| {
// Build default origins from localhost + APP_DOMAIN_URL
let domain = std::env::var("APP_DOMAIN_URL")
.unwrap_or_else(|_| "http://127.0.0.1".to_string());
let domain =
std::env::var("APP_DOMAIN_URL").unwrap_or_else(|_| "http://127.0.0.1".to_string());
// Normalize: strip trailing slash, derive https/wss variants
let domain = domain.trim_end_matches('/');
let https_domain = domain.replace("http://", "https://").replace("ws://", "wss://");
let ws_domain = domain.replace("https://", "ws://").replace("http://", "ws://");
let https_domain = domain
.replace("http://", "https://")
.replace("ws://", "wss://");
let ws_domain = domain
.replace("https://", "ws://")
.replace("http://", "ws://");
let mut defaults = vec![
"http://localhost".to_string(),
"https://localhost".to_string(),
"http://127.0.0.1".to_string(),
"http://gitdata.ai".to_string(),
"https://gitdata.ai".to_string(),
"https://127.0.0.1".to_string(),
"ws://localhost".to_string(),
"wss://localhost".to_string(),
"ws://127.0.0.1".to_string(),
"ws://gitdata.ai".to_string(),
"wss://gitdata.ai".to_string(),
"wss://127.0.0.1".to_string(),
];
// Always include APP_DOMAIN_URL and APP_STATIC_DOMAIN origins
let mut add_origin = |origin: &str| {
let origin = origin.trim_end_matches('/');
let https_v = origin.replace("http://", "https://").replace("ws://", "wss://");
let ws_v = origin.replace("https://", "ws://").replace("http://", "ws://");
let https_v = origin
.replace("http://", "https://")
.replace("ws://", "wss://");
let ws_v = origin
.replace("https://", "ws://")
.replace("http://", "ws://");
for v in [origin, &https_v, &ws_v] {
if !defaults.contains(&v.to_string()) && v != domain {
defaults.push(v.to_string());

View File

@ -81,6 +81,7 @@ impl MigratorTrait for Migrator {
Box::new(m20260414_000001_create_agent_task::Migration),
Box::new(m20260415_000001_add_issue_id_to_agent_task::Migration),
Box::new(m20260416_000001_add_retry_count_to_agent_task::Migration),
Box::new(m20260417_000001_add_stream_to_room_ai::Migration),
// Repo tables
Box::new(m20250628_000028_create_repo::Migration),
Box::new(m20250628_000029_create_repo_branch::Migration),
@ -252,3 +253,4 @@ pub mod m20260413_000001_add_skill_commit_blob;
pub mod m20260414_000001_create_agent_task;
pub mod m20260415_000001_add_issue_id_to_agent_task;
pub mod m20260416_000001_add_retry_count_to_agent_task;
pub mod m20260417_000001_add_stream_to_room_ai;

View File

@ -0,0 +1,36 @@
//! SeaORM migration: add `stream` column to `room_ai` table
use sea_orm_migration::prelude::*;
pub struct Migration;
impl MigrationName for Migration {
fn name(&self) -> &str {
"m20260417_000001_add_stream_to_room_ai"
}
}
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.get_connection()
.execute_raw(sea_orm::Statement::from_string(
sea_orm::DbBackend::Postgres,
"ALTER TABLE room_ai ADD COLUMN IF NOT EXISTS stream BOOLEAN NOT NULL DEFAULT true;",
))
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.get_connection()
.execute_raw(sea_orm::Statement::from_string(
sea_orm::DbBackend::Postgres,
"ALTER TABLE room_ai DROP COLUMN IF EXISTS stream;",
))
.await?;
Ok(())
}
}

View File

@ -27,6 +27,15 @@ const DEFAULT_MAX_CONCURRENT_WORKERS: usize = 1024;
static USER_MENTION_RE: LazyLock<regex_lite::Regex, fn() -> regex_lite::Regex> =
LazyLock::new(|| regex_lite::Regex::new(r"<user>\s*([^<]+?)\s*</user>").unwrap());
/// Matches <mention type="..." id="...">label</mention>
static MENTION_TAG_RE: LazyLock<regex_lite::Regex, fn() -> regex_lite::Regex> =
LazyLock::new(|| {
regex_lite::Regex::new(
r#"<mention\s+type="([^"]+)"\s+id="([^"]+)"[^>]*>\s*([^<]*?)\s*</mention>"#,
)
.unwrap()
});
#[derive(Clone)]
pub struct RoomService {
pub db: AppDatabase,
@ -533,8 +542,12 @@ impl RoomService {
Ok(())
}
/// Extracts user UUIDs from both the legacy `<user>uuid</user>` format
/// and the new `<mention type="user" id="uuid">label</mention>` format.
pub fn extract_mentions(content: &str) -> Vec<Uuid> {
let mut mentioned = Vec::new();
// Legacy <user>uuid</user> format
for cap in USER_MENTION_RE.captures_iter(content) {
if let Some(inner) = cap.get(1) {
let token = inner.as_str().trim();
@ -546,9 +559,25 @@ impl RoomService {
}
}
// New <mention type="user" id="...">label</mention> format
for cap in MENTION_TAG_RE.captures_iter(content) {
if let (Some(type_m), Some(id_m)) = (cap.get(1), cap.get(2)) {
if type_m.as_str() == "user" {
if let Ok(uuid) = Uuid::parse_str(id_m.as_str().trim()) {
if !mentioned.contains(&uuid) {
mentioned.push(uuid);
}
}
}
}
}
mentioned
}
/// Resolves user mentions from both the legacy `<user>...` format and the
/// new `<mention type="user" id="uuid">label</mention>` format.
/// Repository and AI mention types are accepted but produce no user UUIDs.
pub async fn resolve_mentions(&self, content: &str) -> Vec<Uuid> {
use models::users::User;
use sea_orm::EntityTrait;
@ -556,6 +585,7 @@ impl RoomService {
let mut resolved: Vec<Uuid> = Vec::new();
let mut seen_usernames: Vec<String> = Vec::new();
// Legacy <user>uuid</user> or <user>username</user> format
for cap in USER_MENTION_RE.captures_iter(content) {
if let Some(inner) = cap.get(1) {
let token = inner.as_str().trim();
@ -587,6 +617,46 @@ impl RoomService {
}
}
// New <mention type="user" id="uuid">label</mention> format
for cap in MENTION_TAG_RE.captures_iter(content) {
if let (Some(type_m), Some(id_m)) = (cap.get(1), cap.get(2)) {
if type_m.as_str() == "user" {
let id = id_m.as_str().trim();
if let Ok(uuid) = Uuid::parse_str(id) {
if !resolved.contains(&uuid) {
resolved.push(uuid);
}
} else {
// Fall back to label-based username lookup
if let Some(label_m) = cap.get(3) {
let label = label_m.as_str().trim();
if !label.is_empty() {
let label_lower = label.to_lowercase();
if seen_usernames.contains(&label_lower) {
continue;
}
seen_usernames.push(label_lower.clone());
if let Some(user) = User::find()
.filter(models::users::user::Column::Username.eq(label_lower))
.one(&self.db)
.await
.ok()
.flatten()
{
if !resolved.contains(&user.uid) {
resolved.push(user.uid);
}
}
}
}
}
}
// `repository` and `ai` mention types are accepted but do not
// produce user notification UUIDs — no-op here.
}
}
resolved
}

File diff suppressed because it is too large Load Diff

View File

@ -1,10 +1,12 @@
import { memo, useMemo } from 'react';
import type { ProjectRepositoryItem, RoomMemberResponse } from '@/client';
import type { RoomAiConfig } from '@/components/room/MentionPopover';
import { memo, useCallback, useMemo } from 'react';
import { cn } from '@/lib/utils';
import { parse, type Node, type MentionMentionType } from '@/lib/mention-ast';
type MentionType = 'repository' | 'user' | 'ai' | 'notify';
interface MentionToken {
interface LegacyMentionToken {
full: string;
type: MentionType;
name: string;
@ -14,12 +16,17 @@ function isMentionNameChar(ch: string): boolean {
return /[A-Za-z0-9._:\/-]/.test(ch);
}
function extractMentionTokens(text: string): MentionToken[] {
const tokens: MentionToken[] = [];
/**
* Parses legacy mention formats for backward compatibility with old messages:
* - `<user>name</user>` (old backend format)
* - `@type:name` (old colon format)
* - `<type>name</type>` (old XML format)
*/
function extractLegacyMentionTokens(text: string): LegacyMentionToken[] {
const tokens: LegacyMentionToken[] = [];
let cursor = 0;
while (cursor < text.length) {
// Find next potential mention: <user> or <user: or @user:
const angleOpen = text.indexOf('<', cursor);
const atOpen = text.indexOf('@', cursor);
@ -36,13 +43,10 @@ function extractMentionTokens(text: string): MentionToken[] {
}
const typeStart = next + 1;
// Check for colon format: <type:name> or @type:name
const colon = text.indexOf(':', typeStart);
const validTypes: MentionType[] = ['repository', 'user', 'ai', 'notify'];
if (colon >= 0 && colon - typeStart <= 20) {
// Colon format: <type:name> or @type:name
const typeRaw = text.slice(typeStart, colon).toLowerCase();
if (!validTypes.includes(typeRaw as MentionType)) {
cursor = next + 1;
@ -54,7 +58,6 @@ function extractMentionTokens(text: string): MentionToken[] {
end++;
}
// For angle style, require closing >
const closeBracket = style === 'angle' && text[end] === '>' ? end + 1 : end;
const name = text.slice(colon + 1, style === 'angle' ? end : closeBracket);
@ -65,10 +68,7 @@ function extractMentionTokens(text: string): MentionToken[] {
}
}
// Check for XML-like format: <type>name</type>
// Only for angle style
if (style === 'angle') {
// Find space or > to determine type end
let typeEnd = typeStart;
while (typeEnd < text.length && /[A-Za-z]/.test(text[typeEnd])) {
typeEnd++;
@ -76,11 +76,8 @@ function extractMentionTokens(text: string): MentionToken[] {
const typeCandidate = text.slice(typeStart, typeEnd);
if (validTypes.includes(typeCandidate as MentionType)) {
// Found opening tag like <user>
const closeTag = `</${typeCandidate}>`;
const contentStart = typeEnd;
// Find the closing tag
const tagClose = text.indexOf(closeTag, cursor);
if (tagClose >= 0) {
const name = text.slice(contentStart, tagClose);
@ -99,49 +96,118 @@ function extractMentionTokens(text: string): MentionToken[] {
}
function extractFirstMentionName(text: string, type: MentionType): string | null {
const token = extractMentionTokens(text).find((item) => item.type === type);
const token = extractLegacyMentionTokens(text).find((item) => item.type === type);
return token?.name ?? null;
}
interface MessageContentWithMentionsProps {
content: string;
/** Members list for resolving user mention IDs to display names */
members?: RoomMemberResponse[];
/** Repository list for resolving repository mention IDs to display names */
repos?: ProjectRepositoryItem[];
/** AI configs for resolving AI mention IDs to display names */
aiConfigs?: RoomAiConfig[];
}
const mentionStyles: Record<MentionType, string> = {
const mentionStyles: Record<string, string> = {
user: 'inline-flex items-center rounded bg-blue-100/80 px-1.5 py-0.5 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300 font-medium cursor-pointer hover:bg-blue-200 dark:hover:bg-blue-900/60 transition-colors text-sm leading-5',
repository: 'inline-flex items-center rounded bg-purple-100/80 px-1.5 py-0.5 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300 font-medium cursor-pointer hover:bg-purple-200 dark:hover:bg-purple-900/60 transition-colors text-sm leading-5',
ai: 'inline-flex items-center rounded bg-green-100/80 px-1.5 py-0.5 text-green-700 dark:bg-green-900/40 dark:text-green-300 font-medium cursor-pointer hover:bg-green-200 dark:hover:bg-green-900/60 transition-colors text-sm leading-5',
notify: 'inline-flex items-center rounded bg-yellow-100/80 px-1.5 py-0.5 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300 font-medium cursor-pointer hover:bg-yellow-200 dark:hover:bg-yellow-900/60 transition-colors text-sm leading-5',
};
/** Renders message content with @mention highlighting using styled spans */
function renderNode(
node: Node,
index: number,
resolveName: (type: string, id: string, label: string) => string,
): React.ReactNode {
if (node.type === 'text') {
return <span key={index}>{node.text}</span>;
}
if (node.type === 'mention') {
const displayName = resolveName(node.mentionType, node.id, node.label);
return (
<span key={index} className={mentionStyles[node.mentionType] ?? mentionStyles.user}>
@{displayName}
</span>
);
}
if (node.type === 'ai_action') {
return (
<span
key={index}
className="inline-flex items-center rounded bg-green-100/80 px-1.5 py-0.5 text-green-700 dark:bg-green-900/40 dark:text-green-300 font-mono text-xs"
>
/{node.action}
{node.args ? ` ${node.args}` : ''}
</span>
);
}
return null;
}
/** Renders message content with @mention highlighting using styled spans.
* Supports the new `<mention type="..." id="...">label</mention>` format
* and legacy formats for backward compatibility with old messages.
*/
export const MessageContentWithMentions = memo(function MessageContentWithMentions({
content,
members = [],
repos = [],
aiConfigs = [],
}: MessageContentWithMentionsProps) {
const processed = useMemo(() => {
const tokens = extractMentionTokens(content);
if (tokens.length === 0) return [{ type: 'text' as const, content }];
const nodes = useMemo(() => {
// Try the new AST parser first (handles <mention> and <ai> tags)
const ast = parse(content);
if (ast.length > 0) return ast;
const parts: Array<{ type: 'text'; content: string } | { type: 'mention'; mention: MentionToken }> = [];
// Fall back to legacy parser for old-format messages
const legacy = extractLegacyMentionTokens(content);
if (legacy.length === 0) return [{ type: 'text' as const, text: content }];
const parts: Node[] = [];
let cursor = 0;
for (const token of tokens) {
for (const token of legacy) {
const idx = content.indexOf(token.full, cursor);
if (idx === -1) continue;
if (idx > cursor) {
parts.push({ type: 'text', content: content.slice(cursor, idx) });
parts.push({ type: 'text', text: content.slice(cursor, idx) });
}
parts.push({ type: 'mention', mention: token });
parts.push({
type: 'mention',
mentionType: token.type as MentionMentionType,
id: '',
label: token.name,
});
cursor = idx + token.full.length;
}
if (cursor < content.length) {
parts.push({ type: 'text', content: content.slice(cursor) });
parts.push({ type: 'text', text: content.slice(cursor) });
}
return parts;
}, [content]);
// Resolve ID → display name for each mention type
const resolveName = useCallback(
(type: string, id: string, label: string): string => {
if (type === 'user') {
const member = members.find((m) => m.user === id);
return member?.user_info?.username ?? member?.user ?? label;
}
if (type === 'repository') {
const repo = repos.find((r) => r.uid === id);
return repo?.repo_name ?? label;
}
if (type === 'ai') {
const cfg = aiConfigs.find((c) => c.model === id);
return cfg?.modelName ?? cfg?.model ?? label;
}
return label;
},
[members, repos, aiConfigs],
);
return (
<div
className={cn(
@ -151,18 +217,7 @@ export const MessageContentWithMentions = memo(function MessageContentWithMentio
'[&_pre]:rounded-md [&_pre]:bg-muted [&_pre]:p-3 [&_pre]:overflow-x-auto',
)}
>
{processed.map((part, i) =>
part.type === 'mention' ? (
<span
key={i}
className={mentionStyles[part.mention.type]}
>
@{part.mention.name}
</span>
) : (
<span key={i}>{part.content}</span>
),
)}
{nodes.map((node, i) => renderNode(node, i, resolveName))}
</div>
);
});

View File

@ -1,9 +1,12 @@
import type { RoomResponse, RoomMemberResponse, RoomMessageResponse, RoomThreadResponse } from '@/client';
import type { ProjectRepositoryItem, RoomResponse, RoomMemberResponse, RoomMessageResponse, RoomThreadResponse } from '@/client';
import { useRoom, type MessageWithMeta } from '@/contexts';
import { type RoomAiConfig } from '@/contexts/room-context';
import { useRoomDraft } from '@/hooks/useRoomDraft';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils';
import { buildMentionHtml } from '@/lib/mention-ast';
import { mentionSelectedIdxRef, mentionVisibleRef } from '@/lib/mention-refs';
import { ChevronLeft, Hash, Send, Settings, Timer, Trash2, Users, X, Search, Bell } from 'lucide-react';
import {
memo,
@ -29,8 +32,11 @@ import { RoomThreadPanel } from './RoomThreadPanel';
const MENTION_PATTERN = /@([^:@\s]*)(:([^\s]*))?$/;
const MENTION_POPOVER_KEYS = ['Enter', 'Tab', 'ArrowUp', 'ArrowDown'];
export interface ChatInputAreaHandle {
insertMention: (name: string, type: 'user' | 'ai') => void;
insertMention: (id: string, label: string, type: 'user' | 'ai') => void;
/** Insert a category prefix (e.g. 'ai') into the textarea and trigger React onChange */
insertCategory: (category: string) => void;
}
interface ChatInputAreaProps {
@ -38,6 +44,10 @@ interface ChatInputAreaProps {
onSend: (content: string) => void;
isSending: boolean;
members: RoomMemberResponse[];
repos?: ProjectRepositoryItem[];
reposLoading?: boolean;
aiConfigs?: RoomAiConfig[];
aiConfigsLoading?: boolean;
replyingTo?: { id: string; display_name?: string; content: string } | null;
onCancelReply?: () => void;
draft: string;
@ -51,6 +61,10 @@ const ChatInputArea = memo(function ChatInputArea({
onSend,
isSending,
members,
repos,
reposLoading,
aiConfigs,
aiConfigsLoading,
replyingTo,
onCancelReply,
draft,
@ -62,7 +76,32 @@ const ChatInputArea = memo(function ChatInputArea({
const [cursorPosition, setCursorPosition] = useState(0);
const [showMentionPopover, setShowMentionPopover] = useState(false);
const handleMentionSelect = useCallback((newValue: string, newCursorPos: number) => {
const handleMentionSelect = useCallback((_newValue: string, _newCursorPos: number) => {
if (!textareaRef.current) return;
const textarea = textareaRef.current;
const cursorPos = textarea.selectionStart;
const textBefore = textarea.value.substring(0, cursorPos);
const atMatch = textBefore.match(MENTION_PATTERN);
if (!atMatch) return;
const [fullMatch] = atMatch;
const startPos = cursorPos - fullMatch.length;
const before = textarea.value.substring(0, startPos);
const after = textarea.value.substring(cursorPos);
const suggestion = mentionVisibleRef.current[mentionSelectedIdxRef.current];
if (!suggestion || suggestion.type !== 'item') return;
const html = buildMentionHtml(
suggestion.category!,
suggestion.mentionId!,
suggestion.label,
);
const spacer = ' ';
const newValue = before + html + spacer + after;
const newCursorPos = startPos + html.length + spacer.length;
onDraftChange(newValue);
setShowMentionPopover(false);
setTimeout(() => {
@ -72,14 +111,17 @@ const ChatInputArea = memo(function ChatInputArea({
textareaRef.current.focus();
}
}, 0);
}, [onDraftChange]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Intentionally use refs, not state
useImperativeHandle(ref, () => ({
insertMention: (name: string) => {
insertMention: (id: string, label: string) => {
if (!textareaRef.current) return;
const value = textareaRef.current.value;
const cursorPos = textareaRef.current.selectionStart;
const mentionText = `<user>${name}</user> `;
const escapedLabel = label.replace(/</g, '&lt;').replace(/>/g, '&gt;');
const escapedId = id.replace(/"/g, '&quot;');
const mentionText = `<mention type="user" id="${escapedId}">${escapedLabel}</mention> `;
const before = value.substring(0, cursorPos);
const after = value.substring(cursorPos);
const newValue = before + mentionText + after;
@ -91,6 +133,26 @@ const ChatInputArea = memo(function ChatInputArea({
}
}, 0);
},
insertCategory: (category: string) => {
// Enter a category: e.g. @ai → @ai:
if (!textareaRef.current) return;
const textarea = textareaRef.current;
const textBefore = textarea.value.substring(0, textarea.selectionStart);
const atMatch = textBefore.match(MENTION_PATTERN);
if (!atMatch) return;
const [fullMatch] = atMatch;
const startPos = textarea.selectionStart - fullMatch.length;
const before = textarea.value.substring(0, startPos);
const afterPartial = textarea.value.substring(startPos + fullMatch.length);
const newValue = before + '@' + category + ':' + afterPartial;
const newCursorPos = startPos + 1 + category.length + 1; // after '@ai:'
// Directly update React state to trigger mention detection
onDraftChange(newValue);
setCursorPosition(newCursorPos);
const textBefore2 = newValue.substring(0, newCursorPos);
const match = textBefore2.match(MENTION_PATTERN);
setShowMentionPopover(!!match);
},
}));
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
@ -110,12 +172,49 @@ const ChatInputArea = memo(function ChatInputArea({
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (showMentionPopover && MENTION_POPOVER_KEYS.includes(e.key)) {
// Detect mention directly from DOM — avoids stale React state between handleChange and handleKeyDown
const hasMention = textareaRef.current
? textareaRef.current.value.substring(0, textareaRef.current.selectionStart).match(MENTION_PATTERN) !== null
: false;
if (hasMention && MENTION_POPOVER_KEYS.includes(e.key) && !e.ctrlKey && !e.shiftKey) {
e.preventDefault();
// Enter/Tab: do mention selection directly here using module-level refs
if ((e.key === 'Enter' || e.key === 'Tab')) {
const suggestion = mentionVisibleRef.current[mentionSelectedIdxRef.current];
if (suggestion && suggestion.type === 'item') {
const textarea = textareaRef.current;
if (!textarea) return;
const cursorPos = textarea.selectionStart;
const textBefore = textarea.value.substring(0, cursorPos);
const atMatch = textBefore.match(MENTION_PATTERN);
if (!atMatch) return;
const [fullMatch] = atMatch;
const startPos = cursorPos - fullMatch.length;
const before = textarea.value.substring(0, startPos);
const after = textarea.value.substring(cursorPos);
const html = buildMentionHtml(suggestion.category!, suggestion.mentionId!, suggestion.label);
const spacer = ' ';
const newValue = before + html + spacer + after;
const newCursorPos = startPos + html.length + spacer.length;
onDraftChange(newValue);
setShowMentionPopover(false);
setTimeout(() => {
if (textareaRef.current) {
textareaRef.current.value = newValue;
textareaRef.current.setSelectionRange(newCursorPos, newCursorPos);
textareaRef.current.focus();
}
}, 0);
}
}
return;
}
if (e.key === 'Enter' && !e.shiftKey) {
// Shift+Enter → let textarea naturally insert newline (pass through)
// Ctrl+Enter → send message
if (e.key === 'Enter' && e.ctrlKey) {
e.preventDefault();
const content = e.currentTarget.value.trim();
if (content && !isSending) {
@ -123,6 +222,12 @@ const ChatInputArea = memo(function ChatInputArea({
onClearDraft();
}
}
// Plain Enter (no modifiers) → only trigger mention select; otherwise do nothing
if (e.key === 'Enter' && !e.ctrlKey && !e.shiftKey) {
e.preventDefault();
// Do nothing — Shift+Enter falls through to let textarea insert newline
}
};
return (
@ -169,11 +274,33 @@ const ChatInputArea = memo(function ChatInputArea({
{showMentionPopover && (
<MentionPopover
members={members}
repos={repos}
reposLoading={reposLoading}
aiConfigs={aiConfigs}
aiConfigsLoading={aiConfigsLoading}
inputValue={draft}
cursorPosition={cursorPosition}
onSelect={handleMentionSelect}
textareaRef={textareaRef}
onOpenChange={setShowMentionPopover}
onCategoryEnter={(category: string) => {
// Enter a category: @ai → @ai: to show next-level items
if (!textareaRef.current) return;
const textarea = textareaRef.current;
const textBefore = textarea.value.substring(0, textarea.selectionStart);
const atMatch = textBefore.match(MENTION_PATTERN);
if (!atMatch) return;
const [fullMatch] = atMatch;
const startPos = textarea.selectionStart - fullMatch.length;
const before = textarea.value.substring(0, startPos);
const afterPartial = textarea.value.substring(startPos + fullMatch.length);
const newValue = before + '@' + category + ':' + afterPartial;
const newCursorPos = startPos + 1 + category.length + 1;
onDraftChange(newValue);
setCursorPosition(newCursorPos);
const match2 = newValue.substring(0, newCursorPos).match(MENTION_PATTERN);
setShowMentionPopover(!!match2);
}}
/>
)}
</div>
@ -244,6 +371,10 @@ export function RoomChatPanel({ room, isAdmin, onClose, onDelete }: RoomChatPane
wsClient,
threads,
refreshThreads,
projectRepos,
reposLoading,
roomAiConfigs,
aiConfigsLoading,
} = useRoom();
const messagesEndRef = useRef<HTMLDivElement>(null);
@ -322,8 +453,8 @@ export function RoomChatPanel({ room, isAdmin, onClose, onDelete }: RoomChatPane
);
// Stable: chatInputRef is stable, no deps that change on message updates
const handleMention = useCallback((name: string, type: 'user' | 'ai') => {
chatInputRef.current?.insertMention(name, type);
const handleMention = useCallback((id: string, label: string) => {
chatInputRef.current?.insertMention(id, label, 'user');
}, []);
const handleSelectSearchResult = useCallback((message: RoomMessageResponse) => {
@ -530,6 +661,10 @@ export function RoomChatPanel({ room, isAdmin, onClose, onDelete }: RoomChatPane
onSend={handleSend}
isSending={false}
members={members}
repos={projectRepos}
reposLoading={reposLoading}
aiConfigs={roomAiConfigs}
aiConfigsLoading={aiConfigsLoading}
replyingTo={replyingTo ? { id: replyingTo.id, display_name: replyingTo.display_name ?? undefined, content: replyingTo.content } : null}
onCancelReply={() => setReplyingTo(null)}
draft={draft}

View File

@ -92,7 +92,7 @@ export const RoomMessageBubble = memo(function RoomMessageBubble({
const isStreaming = !!message.is_streaming;
const isEdited = !!message.edited_at;
const { user } = useUser();
const { wsClient, streamingMessages } = useRoom();
const { wsClient, streamingMessages, members, projectRepos, roomAiConfigs } = useRoom();
const isOwner = user?.uid === getSenderUserUid(message);
const isRevoked = !!message.revoked;
const isFailed = message.isOptimisticError === true;
@ -315,7 +315,12 @@ export const RoomMessageBubble = memo(function RoomMessageBubble({
))
) : (
<div className="max-w-full min-w-0 overflow-hidden whitespace-pre-wrap break-words">
<MessageContentWithMentions content={displayContent} />
<MessageContentWithMentions
content={displayContent}
members={members}
repos={projectRepos}
aiConfigs={roomAiConfigs}
/>
</div>
)}

View File

@ -9,7 +9,7 @@ import type { ReactNode } from 'react';
interface RoomParticipantsPanelProps {
members: RoomMemberResponse[];
membersLoading: boolean;
onMention?: (name: string, type: 'user' | 'ai') => void;
onMention?: (id: string, label: string, type: 'user' | 'ai') => void;
}
export const RoomParticipantsPanel = memo(function RoomParticipantsPanel({
@ -97,7 +97,7 @@ function ParticipantRow({
onMention,
}: {
member: RoomMemberResponse;
onMention?: (name: string, type: 'user' | 'ai') => void;
onMention?: (id: string, label: string, type: 'user' | 'ai') => void;
}) {
const username = member.user_info?.username ?? member.user;
const avatarUrl = member.user_info?.avatar_url;
@ -110,7 +110,7 @@ function ParticipantRow({
className={cn(
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-muted/60',
)}
onClick={() => onMention?.(username, 'user')}
onClick={() => onMention?.(member.user, username, 'user')}
disabled={!onMention}
>
<Avatar className="h-7 w-7">

View File

@ -1,5 +1,5 @@
import { memo, useState, useEffect, useCallback } from 'react';
import type { RoomResponse } from '@/client';
import type { ModelResponse, RoomResponse, RoomAiResponse, RoomAiUpsertRequest } from '@/client';
import { aiList, aiUpsert, aiDelete, modelList } from '@/client';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@ -15,7 +15,6 @@ import {
} from '@/components/ui/dialog';
import { Loader2, Plus, Trash2, Bot, ChevronDown, ChevronRight } from 'lucide-react';
import { toast } from 'sonner';
import type { ModelResponse, RoomAiResponse, RoomAiUpsertRequest } from '@/client';
interface RoomSettingsPanelProps {
room: RoomResponse;
@ -68,8 +67,8 @@ export const RoomSettingsPanel = memo(function RoomSettingsPanel({
setAiConfigsLoading(true);
try {
const resp = await aiList({ path: { room_id: room.id } });
const inner = (resp.data as Record<string, unknown>)?.['200'] as Record<string, unknown> | undefined;
setAiConfigs(Array.isArray(inner?.['data']) ? (inner['data'] as RoomAiResponse[]) : []);
const inner = resp.data as { data?: RoomAiResponse[] } | undefined;
setAiConfigs(Array.isArray(inner?.data) ? inner.data : []);
} catch {
// ignore
} finally {
@ -79,6 +78,7 @@ export const RoomSettingsPanel = memo(function RoomSettingsPanel({
useEffect(() => {
loadAiConfigs();
loadModels();
}, [loadAiConfigs]);
// Load available models
@ -86,8 +86,8 @@ export const RoomSettingsPanel = memo(function RoomSettingsPanel({
setModelsLoading(true);
try {
const resp = await modelList({});
const raw = resp.data as Record<string, unknown> | undefined;
setAvailableModels(Array.isArray(raw?.['200']) ? (raw['200'] as ModelResponse[]) : []);
const inner = resp.data as { data?: ModelResponse[] } | undefined;
setAvailableModels(Array.isArray(inner?.data) ? inner.data : []);
} catch {
toast.error('Failed to load models');
} finally {
@ -215,8 +215,8 @@ export const RoomSettingsPanel = memo(function RoomSettingsPanel({
>
<div className="flex items-center gap-2 min-w-0">
<Bot className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="text-sm truncate font-mono text-foreground">
{config.model}
<span className="text-sm truncate text-foreground">
{availableModels.find((m) => m.id === config.model)?.name ?? config.model}
</span>
{config.stream && (
<span className="rounded bg-green-500/10 px-1 py-0.5 text-[10px] text-green-600 dark:text-green-400 shrink-0">
@ -266,7 +266,11 @@ export const RoomSettingsPanel = memo(function RoomSettingsPanel({
) : (
<Select value={selectedModelId} onValueChange={(v) => { if (v !== null) setSelectedModelId(v); }}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a model..." />
<SelectValue placeholder="Select a model...">
{selectedModelId
? availableModels.find((m) => m.id === selectedModelId)?.name ?? selectedModelId
: null}
</SelectValue>
</SelectTrigger>
<SelectContent>
{availableModels.map((model) => (

View File

@ -11,6 +11,7 @@ import {
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import {
type ProjectRepositoryItem,
type RoomCategoryResponse,
type RoomMemberResponse,
type RoomMessageResponse,
@ -39,6 +40,11 @@ import {
export type { RoomWsStatus, RoomWsClient } from '@/lib/room-ws-client';
export interface RoomAiConfig {
model: string;
modelName?: string;
}
export interface ReactionGroup {
emoji: string;
count: number;
@ -146,6 +152,13 @@ interface RoomContextValue {
updateRoom: (roomId: string, name?: string, isPublic?: boolean, categoryId?: string) => Promise<void>;
deleteRoom: (roomId: string) => Promise<void>;
streamingMessages: Map<string, string>;
/** Project repositories for @repository: mention suggestions */
projectRepos: ProjectRepositoryItem[];
reposLoading?: boolean;
/** Room AI configs for @ai: mention suggestions */
roomAiConfigs: RoomAiConfig[];
aiConfigsLoading?: boolean;
}
const RoomContext = createContext<RoomContextValue | null>(null);
@ -415,6 +428,15 @@ export function RoomProvider({
const [streamingContent, setStreamingContent] = useState<Map<string, string>>(new Map());
// Project repos for @repository: mention suggestions
const [projectRepos, setProjectRepos] = useState<ProjectRepositoryItem[]>([]);
const [reposLoading, setReposLoading] = useState(false);
// Room AI configs for @ai: mention suggestions
const [roomAiConfigs, setRoomAiConfigs] = useState<RoomAiConfig[]>([]);
const [aiConfigsLoading, setAiConfigsLoading] = useState(false);
// Available models (for looking up AI model names)
const [availableModels, setAvailableModels] = useState<{ id: string; name: string }[]>([]);
useEffect(() => {
const baseUrl = import.meta.env.VITE_API_BASE_URL ?? window.location.origin;
const client = createRoomWsClient(
@ -1095,6 +1117,73 @@ export function RoomProvider({
[activeRoomId],
);
// Fetch project repos for @repository: mention suggestions
const fetchProjectRepos = useCallback(async () => {
if (!projectName) {
setProjectRepos([]);
return;
}
setReposLoading(true);
try {
const baseUrl = import.meta.env.VITE_API_BASE_URL ?? window.location.origin;
const resp = await fetch(`${baseUrl}/api/projects/${encodeURIComponent(projectName)}/repos`);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const json: { data?: { items?: ProjectRepositoryItem[] } } = await resp.json();
setProjectRepos(json.data?.items ?? []);
} catch {
setProjectRepos([]);
} finally {
setReposLoading(false);
}
}, [projectName]);
// Fetch room AI configs for @ai: mention suggestions
const fetchRoomAiConfigs = useCallback(async () => {
const client = wsClientRef.current;
if (!activeRoomId || !client) {
setRoomAiConfigs([]);
return;
}
setAiConfigsLoading(true);
try {
const configs = await client.aiList(activeRoomId);
// Look up model names from the available models list
setRoomAiConfigs(
configs.map((cfg) => ({
model: cfg.model,
modelName: availableModels.find((m) => m.id === cfg.model)?.name,
})),
);
} catch {
setRoomAiConfigs([]);
} finally {
setAiConfigsLoading(false);
}
}, [activeRoomId, availableModels]);
// Fetch available models (for AI model name lookup)
const fetchAvailableModels = useCallback(async () => {
try {
const resp = await (await import('@/client')).modelList({});
const inner = (resp.data as { data?: { data?: { id: string; name: string }[] } } | undefined);
setAvailableModels(inner?.data?.data ?? []);
} catch {
// Non-fatal
}
}, []);
useEffect(() => {
fetchProjectRepos();
}, [fetchProjectRepos]);
useEffect(() => {
fetchRoomAiConfigs();
}, [fetchRoomAiConfigs]);
useEffect(() => {
fetchAvailableModels();
}, [fetchAvailableModels]);
const createRoom = useCallback(
async (name: string, isPublic: boolean, categoryId?: string) => {
const client = wsClientRef.current;
@ -1222,6 +1311,10 @@ export function RoomProvider({
updateRoom,
deleteRoom,
streamingMessages: streamingContent,
projectRepos,
reposLoading,
roomAiConfigs,
aiConfigsLoading,
}),
[
wsStatus,
@ -1270,6 +1363,10 @@ export function RoomProvider({
updateRoom,
deleteRoom,
streamingContent,
projectRepos,
reposLoading,
roomAiConfigs,
aiConfigsLoading,
],
);

165
src/lib/mention-ast.ts Normal file
View File

@ -0,0 +1,165 @@
/**
* Mention AST system see architecture spec.
*
* Node types:
* text plain text
* mention @user:, @repository:, @ai: mentions
* ai_action <ai action="..."> structured AI commands (future)
*
* HTML serialization:
* <mention type="user" id="uuid-or-username">label</mention>
* <mention type="repository" id="repo-uid">repo_name</mention>
* <mention type="ai" id="model-uid">model_name</mention>
* <ai action="..."></ai>
*
* Key principle: always use ID for logic, label only for display.
*/
// ─── AST Node Types ──────────────────────────────────────────────────────────
export type MentionMentionType = 'user' | 'repository' | 'ai';
export type TextNode = {
type: 'text';
text: string;
};
export type MentionNode = {
type: 'mention';
mentionType: MentionMentionType;
id: string;
label: string;
};
export type AiActionNode = {
type: 'ai_action';
action: string;
args?: string;
};
export type Node = TextNode | MentionNode | AiActionNode;
export type Document = Node[];
// ─── Serialization (AST → HTML) ───────────────────────────────────────────────
/**
* Serialize a single AST node to HTML string.
*/
function serializeNode(node: Node): string {
if (node.type === 'text') return node.text;
if (node.type === 'ai_action') return `<ai action="${node.action}">${node.args ?? ''}</ai>`;
// mention node
const escapedId = node.id.replace(/"/g, '&quot;');
const escapedLabel = node.label.replace(/</g, '&lt;').replace(/>/g, '&gt;');
return `<mention type="${node.mentionType}" id="${escapedId}">${escapedLabel}</mention>`;
}
/**
* Serialize a document (list of AST nodes) to HTML string.
*/
export function serialize(doc: Document): string {
return doc.map(serializeNode).join('');
}
// ─── Parsing (HTML → AST) ────────────────────────────────────────────────────
// Regex to match <mention type="..." id="...">label</mention>
// Works whether attributes are on one line or spread across lines.
const MENTION_RE =
/<mention\s+type="([^"]+)"\s+id="([^"]+)"[^>]*>\s*([^<]*?)\s*<\/mention>/gi;
// Regex to match <ai action="...">args</ai>
const AI_ACTION_RE = /<ai\s+action="([^"]+)"[^>]*>\s*([^<]*?)\s*<\/ai>/gi;
/**
* Parse an HTML string into an AST document.
* Falls back to a single text node if no structured tags are found.
*/
export function parse(html: string): Document {
if (!html) return [];
const nodes: Document = [];
let lastIndex = 0;
// We interleave all three patterns to find the earliest match.
const matchers: Array<{
re: RegExp;
type: 'mention' | 'ai_action';
}> = [
{ re: MENTION_RE, type: 'mention' },
{ re: AI_ACTION_RE, type: 'ai_action' },
];
// Reset regex lastIndex
for (const m of matchers) m.re.lastIndex = 0;
while (true) {
let earliest: { match: RegExpExecArray; type: 'mention' | 'ai_action' } | null = null;
for (const m of matchers) {
m.re.lastIndex = lastIndex;
const match = m.re.exec(html);
if (match) {
if (!earliest || match.index < earliest.match.index) {
earliest = { match, type: m.type };
}
}
}
if (!earliest) break;
const { match, type } = earliest;
// Text before this match
if (match.index > lastIndex) {
const text = html.slice(lastIndex, match.index);
if (text) nodes.push({ type: 'text', text });
}
if (type === 'mention') {
const mentionType = match[1] as MentionMentionType;
const id = match[2];
const label = match[3] ?? '';
if (
mentionType === 'user' ||
mentionType === 'repository' ||
mentionType === 'ai'
) {
nodes.push({ type: 'mention', mentionType, id, label });
} else {
// Unknown mention type — treat as text
nodes.push({ type: 'text', text: match[0] });
}
} else if (type === 'ai_action') {
const action = match[1];
const args = match[2] ?? '';
nodes.push({ type: 'ai_action', action, args });
}
lastIndex = match.index + match[0].length;
}
// Trailing text
if (lastIndex < html.length) {
const text = html.slice(lastIndex);
if (text) nodes.push({ type: 'text', text });
}
return nodes;
}
// ─── Suggestion value builders ───────────────────────────────────────────────
/**
* Build the HTML mention string from a suggestion selection.
*/
export function buildMentionHtml(
mentionType: MentionMentionType,
id: string,
label: string,
): string {
const escapedId = id.replace(/"/g, '&quot;');
const escapedLabel = label.replace(/</g, '&lt;').replace(/>/g, '&gt;');
return `<mention type="${mentionType}" id="${escapedId}">${escapedLabel}</mention>`;
}

9
src/lib/mention-refs.ts Normal file
View File

@ -0,0 +1,9 @@
import type { MentionSuggestion } from '@/components/room/MentionPopover';
/**
* Module-level refs shared between ChatInputArea and MentionPopover.
* Avoids stale closure / TDZ issues when components are defined in different
* parts of the same file.
*/
export const mentionSelectedIdxRef = { current: 0 };
export const mentionVisibleRef = { current: [] as MentionSuggestion[] };