diff --git a/libs/room/src/service.rs b/libs/room/src/service.rs index bb775f7..e39df2d 100644 --- a/libs/room/src/service.rs +++ b/libs/room/src/service.rs @@ -27,6 +27,15 @@ const DEFAULT_MAX_CONCURRENT_WORKERS: usize = 1024; static USER_MENTION_RE: LazyLock regex_lite::Regex> = LazyLock::new(|| regex_lite::Regex::new(r"\s*([^<]+?)\s*").unwrap()); +/// Matches label +static MENTION_TAG_RE: LazyLock regex_lite::Regex> = + LazyLock::new(|| { + regex_lite::Regex::new( + r#"]*>\s*([^<]*?)\s*"#, + ) + .unwrap() + }); + #[derive(Clone)] pub struct RoomService { pub db: AppDatabase, @@ -533,8 +542,12 @@ impl RoomService { Ok(()) } + /// Extracts user UUIDs from both the legacy `uuid` format + /// and the new `label` format. pub fn extract_mentions(content: &str) -> Vec { let mut mentioned = Vec::new(); + + // Legacy uuid 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 label 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 `...` format and the + /// new `label` format. + /// Repository and AI mention types are accepted but produce no user UUIDs. pub async fn resolve_mentions(&self, content: &str) -> Vec { use models::users::User; use sea_orm::EntityTrait; @@ -556,6 +585,7 @@ impl RoomService { let mut resolved: Vec = Vec::new(); let mut seen_usernames: Vec = Vec::new(); + // Legacy uuid or username 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 label 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 } diff --git a/src/components/room/MentionPopover.tsx b/src/components/room/MentionPopover.tsx index b9f0873..d0ccf9c 100644 --- a/src/components/room/MentionPopover.tsx +++ b/src/components/room/MentionPopover.tsx @@ -1,15 +1,25 @@ -import type { RoomMemberResponse } from '@/client'; +import type { ProjectRepositoryItem, RoomMemberResponse } from '@/client'; +import { buildMentionHtml, type MentionMentionType } from '@/lib/mention-ast'; import { cn } from '@/lib/utils'; -import { Check, CornerDownLeft, Keyboard, SearchX } from 'lucide-react'; +import { Bot, Check, CornerDownLeft, Keyboard, SearchX } from 'lucide-react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { SetStateAction } from 'react'; import { ModelIcon } from './icon-match'; +/** Room AI config — the configured model for this room */ +export interface RoomAiConfig { + model: string; // model UID / model ID + modelName?: string; +} + interface MentionSuggestion { type: 'category' | 'item'; - category?: 'repository' | 'user' | 'ai'; + category?: MentionMentionType; label: string; + /** Raw value stored in suggestion (for display) */ value: string; + /** ID used when building HTML mention (absent for category headers) */ + mentionId?: string; avatar?: string | null; } @@ -27,10 +37,12 @@ interface PopoverPosition { } interface MentionPopoverProps { - /** Available members for user/ai mention suggestions */ + /** Available members for @user: mention suggestions */ members: RoomMemberResponse[]; - /** Repository names available for @repository: mention */ - repos?: string[]; + /** Available repositories for @repository: mention suggestions */ + repos?: ProjectRepositoryItem[]; + /** Room AI configs for @ai: mention suggestions */ + aiConfigs?: RoomAiConfig[]; inputValue: string; cursorPosition: number; onSelect: (newValue: string, newCursorPosition: number) => void; @@ -41,6 +53,7 @@ interface MentionPopoverProps { export function MentionPopover({ members, repos = [], + aiConfigs = [], inputValue, cursorPosition, onSelect, @@ -109,12 +122,13 @@ export function MentionPopover({ if (category === 'repository') { return repos - .filter((repo) => !item || repo.toLowerCase().includes(item)) + .filter((repo) => !item || repo.repo_name.toLowerCase().includes(item)) .map((repo) => ({ type: 'item' as const, category: 'repository' as const, - label: repo, - value: `@repository:${repo}`, + label: repo.repo_name, + value: `@repository:${repo.repo_name}`, + mentionId: repo.uid, avatar: null, })); } @@ -132,33 +146,34 @@ export function MentionPopover({ type: 'item' as const, category: 'user' as const, label: username, - value: `@user:${m.user}`, + value: `@user:${username}`, + mentionId: m.user, avatar: m.user_info?.avatar_url ?? null, }; }); } if (category === 'ai') { - return members - .filter((m) => m.role === 'ai') - .filter((m) => { - const username = m.user_info?.username ?? m.user; - return !item || username.toLowerCase().includes(item); + return aiConfigs + .filter((cfg) => { + const name = cfg.modelName ?? cfg.model; + return !item || name.toLowerCase().includes(item); }) - .map((m) => { - const username = m.user_info?.username ?? m.user; + .map((cfg) => { + const label = cfg.modelName ?? cfg.model; return { type: 'item' as const, category: 'ai' as const, - label: username, - value: `@ai:${m.user}`, - avatar: m.user_info?.avatar_url ?? null, + label, + value: `@ai:${label}`, + mentionId: cfg.model, + avatar: null, }; }); } return []; - }, [mentionState, members, repos]); + }, [mentionState, members, repos, aiConfigs]); const visibleSuggestions = useMemo(() => suggestions.slice(0, 8), [suggestions]); const validSelectedIndex = selectedIndex >= visibleSuggestions.length ? 0 : selectedIndex; @@ -173,16 +188,27 @@ export function MentionPopover({ const before = inputValue.slice(0, mentionState.startPos); const after = inputValue.slice(cursorPosition); - const spacer = suggestion.type === 'item' ? ' ' : ''; - const newValue = before + suggestion.value + spacer + after; - const newCursorPos = mentionState.startPos + suggestion.value.length + spacer.length; - onSelect(newValue, newCursorPos); + let newValue: string; + let newCursorPos: number; - if (suggestion.type === 'item') { - closePopover(); - } else { + if (suggestion.type === 'category') { + // User selected a category header (e.g., @user:) — keep the partial + newValue = before + suggestion.value + after; + newCursorPos = mentionState.startPos + suggestion.value.length; onOpenChange(true); + } else { + // Build new HTML mention string + const html = buildMentionHtml( + suggestion.category!, + suggestion.mentionId!, + suggestion.label, + ); + const spacer = ' '; + newValue = before + html + spacer + after; + newCursorPos = mentionState.startPos + html.length + spacer.length; + onSelect(newValue, newCursorPos); + closePopover(); } }; }, [mentionState, inputValue, cursorPosition, onSelect, closePopover, onOpenChange]); @@ -351,7 +377,11 @@ export function MentionPopover({ {suggestion.type === 'category' ? ( '>' ) : suggestion.category === 'ai' ? ( - + suggestion.avatar ? ( + + ) : ( + + ) ) : ( suggestion.label[0]?.toUpperCase() )} diff --git a/src/components/room/MessageMentions.tsx b/src/components/room/MessageMentions.tsx index 84d32ca..4ef0950 100644 --- a/src/components/room/MessageMentions.tsx +++ b/src/components/room/MessageMentions.tsx @@ -1,10 +1,10 @@ import { memo, 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 +14,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: + * - `name` (old backend format) + * - `@type:name` (old colon format) + * - `name` (old XML format) + */ +function extractLegacyMentionTokens(text: string): LegacyMentionToken[] { + const tokens: LegacyMentionToken[] = []; let cursor = 0; while (cursor < text.length) { - // Find next potential mention: or or @type:name const colon = text.indexOf(':', typeStart); const validTypes: MentionType[] = ['repository', 'user', 'ai', 'notify']; if (colon >= 0 && colon - typeStart <= 20) { - // Colon format: or @type:name const typeRaw = text.slice(typeStart, colon).toLowerCase(); if (!validTypes.includes(typeRaw as MentionType)) { cursor = next + 1; @@ -54,7 +56,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 +66,7 @@ function extractMentionTokens(text: string): MentionToken[] { } } - // Check for XML-like format: name - // 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 +74,8 @@ function extractMentionTokens(text: string): MentionToken[] { const typeCandidate = text.slice(typeStart, typeEnd); if (validTypes.includes(typeCandidate as MentionType)) { - // Found opening tag like const closeTag = ``; const contentStart = typeEnd; - - // Find the closing tag const tagClose = text.indexOf(closeTag, cursor); if (tagClose >= 0) { const name = text.slice(contentStart, tagClose); @@ -99,7 +94,7 @@ 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; } @@ -107,38 +102,73 @@ interface MessageContentWithMentionsProps { content: string; } -const mentionStyles: Record = { +const mentionStyles: Record = { 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): React.ReactNode { + if (node.type === 'text') { + return {node.text}; + } + if (node.type === 'mention') { + return ( + + @{node.label} + + ); + } + if (node.type === 'ai_action') { + return ( + + /{node.action} + {node.args ? ` ${node.args}` : ''} + + ); + } + return null; +} + +/** Renders message content with @mention highlighting using styled spans. + * Supports the new `label` format + * and legacy formats for backward compatibility with old messages. + */ export const MessageContentWithMentions = memo(function MessageContentWithMentions({ content, }: 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 and 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]); @@ -151,18 +181,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' ? ( - - @{part.mention.name} - - ) : ( - {part.content} - ), - )} + {nodes.map((node, i) => renderNode(node, i))} ); }); diff --git a/src/components/room/RoomChatPanel.tsx b/src/components/room/RoomChatPanel.tsx index 961cc84..09094a5 100644 --- a/src/components/room/RoomChatPanel.tsx +++ b/src/components/room/RoomChatPanel.tsx @@ -1,5 +1,6 @@ -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'; @@ -30,7 +31,7 @@ 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; } interface ChatInputAreaProps { @@ -38,6 +39,8 @@ interface ChatInputAreaProps { onSend: (content: string) => void; isSending: boolean; members: RoomMemberResponse[]; + repos?: ProjectRepositoryItem[]; + aiConfigs?: RoomAiConfig[]; replyingTo?: { id: string; display_name?: string; content: string } | null; onCancelReply?: () => void; draft: string; @@ -51,6 +54,8 @@ const ChatInputArea = memo(function ChatInputArea({ onSend, isSending, members, + repos, + aiConfigs, replyingTo, onCancelReply, draft, @@ -75,11 +80,14 @@ const ChatInputArea = memo(function ChatInputArea({ }, [onDraftChange]); 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 = `${name} `; + // Build new HTML mention: label + const escapedLabel = label.replace(//g, '>'); + const escapedId = id.replace(/"/g, '"'); + const mentionText = `${escapedLabel} `; const before = value.substring(0, cursorPos); const after = value.substring(cursorPos); const newValue = before + mentionText + after; @@ -169,6 +177,8 @@ const ChatInputArea = memo(function ChatInputArea({ {showMentionPopover && ( (null); @@ -322,8 +334,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 +542,8 @@ export function RoomChatPanel({ room, isAdmin, onClose, onDelete }: RoomChatPane onSend={handleSend} isSending={false} members={members} + repos={projectRepos} + aiConfigs={roomAiConfigs} replyingTo={replyingTo ? { id: replyingTo.id, display_name: replyingTo.display_name ?? undefined, content: replyingTo.content } : null} onCancelReply={() => setReplyingTo(null)} draft={draft} diff --git a/src/components/room/RoomParticipantsPanel.tsx b/src/components/room/RoomParticipantsPanel.tsx index 2893419..5a675f7 100644 --- a/src/components/room/RoomParticipantsPanel.tsx +++ b/src/components/room/RoomParticipantsPanel.tsx @@ -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} > diff --git a/src/components/room/RoomSettingsPanel.tsx b/src/components/room/RoomSettingsPanel.tsx index 9008cb6..947edf9 100644 --- a/src/components/room/RoomSettingsPanel.tsx +++ b/src/components/room/RoomSettingsPanel.tsx @@ -1,5 +1,5 @@ import { memo, useState, useEffect, useCallback } from 'react'; -import type { ModelResponse, 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; diff --git a/src/contexts/room-context.tsx b/src/contexts/room-context.tsx index ccf63a3..9412b84 100644 --- a/src/contexts/room-context.tsx +++ b/src/contexts/room-context.tsx @@ -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,11 @@ interface RoomContextValue { updateRoom: (roomId: string, name?: string, isPublic?: boolean, categoryId?: string) => Promise; deleteRoom: (roomId: string) => Promise; streamingMessages: Map; + + /** Project repositories for @repository: mention suggestions */ + projectRepos: ProjectRepositoryItem[]; + /** Room AI configs for @ai: mention suggestions */ + roomAiConfigs: RoomAiConfig[]; } const RoomContext = createContext(null); @@ -415,6 +426,13 @@ export function RoomProvider({ const [streamingContent, setStreamingContent] = useState>(new Map()); + // Project repos for @repository: mention suggestions + const [projectRepos, setProjectRepos] = useState([]); + // Room AI configs for @ai: mention suggestions + const [roomAiConfigs, setRoomAiConfigs] = useState([]); + // 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 +1113,67 @@ export function RoomProvider({ [activeRoomId], ); + // Fetch project repos for @repository: mention suggestions + const fetchProjectRepos = useCallback(async () => { + if (!projectName) { + setProjectRepos([]); + return; + } + 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([]); + } + }, [projectName]); + + // Fetch room AI configs for @ai: mention suggestions + const fetchRoomAiConfigs = useCallback(async () => { + const client = wsClientRef.current; + if (!activeRoomId || !client) { + setRoomAiConfigs([]); + return; + } + 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([]); + } + }, [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 +1301,8 @@ export function RoomProvider({ updateRoom, deleteRoom, streamingMessages: streamingContent, + projectRepos, + roomAiConfigs, }), [ wsStatus, @@ -1270,6 +1351,8 @@ export function RoomProvider({ updateRoom, deleteRoom, streamingContent, + projectRepos, + roomAiConfigs, ], ); diff --git a/src/lib/mention-ast.ts b/src/lib/mention-ast.ts new file mode 100644 index 0000000..e798ab1 --- /dev/null +++ b/src/lib/mention-ast.ts @@ -0,0 +1,165 @@ +/** + * Mention AST system — see architecture spec. + * + * Node types: + * text — plain text + * mention — @user:, @repository:, @ai: mentions + * ai_action — structured AI commands (future) + * + * HTML serialization: + * label + * repo_name + * model_name + * + * + * 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 `${node.args ?? ''}`; + // mention node + const escapedId = node.id.replace(/"/g, '"'); + const escapedLabel = node.label.replace(//g, '>'); + return `${escapedLabel}`; +} + +/** + * 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 label +// Works whether attributes are on one line or spread across lines. +const MENTION_RE = + /]*>\s*([^<]*?)\s*<\/mention>/gi; + +// Regex to match args +const AI_ACTION_RE = /]*>\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, '"'); + const escapedLabel = label.replace(//g, '>'); + return `${escapedLabel}`; +}