Compare commits

..

No commits in common. "7be2f4eb617cf4d9da8a4cff298be939a291fbfa" and "a171d691c603cabb16c32acc303ca1b26b7eef2e" have entirely different histories.

14 changed files with 329 additions and 1345 deletions

View File

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

View File

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

View File

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

View File

@ -1,36 +0,0 @@
//! 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,15 +27,6 @@ const DEFAULT_MAX_CONCURRENT_WORKERS: usize = 1024;
static USER_MENTION_RE: LazyLock<regex_lite::Regex, fn() -> regex_lite::Regex> = static USER_MENTION_RE: LazyLock<regex_lite::Regex, fn() -> regex_lite::Regex> =
LazyLock::new(|| regex_lite::Regex::new(r"<user>\s*([^<]+?)\s*</user>").unwrap()); 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)] #[derive(Clone)]
pub struct RoomService { pub struct RoomService {
pub db: AppDatabase, pub db: AppDatabase,
@ -542,12 +533,8 @@ impl RoomService {
Ok(()) 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> { pub fn extract_mentions(content: &str) -> Vec<Uuid> {
let mut mentioned = Vec::new(); let mut mentioned = Vec::new();
// Legacy <user>uuid</user> format
for cap in USER_MENTION_RE.captures_iter(content) { for cap in USER_MENTION_RE.captures_iter(content) {
if let Some(inner) = cap.get(1) { if let Some(inner) = cap.get(1) {
let token = inner.as_str().trim(); let token = inner.as_str().trim();
@ -559,25 +546,9 @@ 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 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> { pub async fn resolve_mentions(&self, content: &str) -> Vec<Uuid> {
use models::users::User; use models::users::User;
use sea_orm::EntityTrait; use sea_orm::EntityTrait;
@ -585,7 +556,6 @@ impl RoomService {
let mut resolved: Vec<Uuid> = Vec::new(); let mut resolved: Vec<Uuid> = Vec::new();
let mut seen_usernames: Vec<String> = 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) { for cap in USER_MENTION_RE.captures_iter(content) {
if let Some(inner) = cap.get(1) { if let Some(inner) = cap.get(1) {
let token = inner.as_str().trim(); let token = inner.as_str().trim();
@ -617,46 +587,6 @@ 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 resolved
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,12 +1,10 @@
import type { ProjectRepositoryItem, RoomMemberResponse } from '@/client'; import { memo, useMemo } from 'react';
import type { RoomAiConfig } from '@/components/room/MentionPopover';
import { memo, useCallback, useMemo } from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { parse, type Node, type MentionMentionType } from '@/lib/mention-ast';
type MentionType = 'repository' | 'user' | 'ai' | 'notify'; type MentionType = 'repository' | 'user' | 'ai' | 'notify';
interface LegacyMentionToken { interface MentionToken {
full: string; full: string;
type: MentionType; type: MentionType;
name: string; name: string;
@ -16,17 +14,12 @@ function isMentionNameChar(ch: string): boolean {
return /[A-Za-z0-9._:\/-]/.test(ch); return /[A-Za-z0-9._:\/-]/.test(ch);
} }
/** function extractMentionTokens(text: string): MentionToken[] {
* Parses legacy mention formats for backward compatibility with old messages: const tokens: MentionToken[] = [];
* - `<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; let cursor = 0;
while (cursor < text.length) { while (cursor < text.length) {
// Find next potential mention: <user> or <user: or @user:
const angleOpen = text.indexOf('<', cursor); const angleOpen = text.indexOf('<', cursor);
const atOpen = text.indexOf('@', cursor); const atOpen = text.indexOf('@', cursor);
@ -43,10 +36,13 @@ function extractLegacyMentionTokens(text: string): LegacyMentionToken[] {
} }
const typeStart = next + 1; const typeStart = next + 1;
// Check for colon format: <type:name> or @type:name
const colon = text.indexOf(':', typeStart); const colon = text.indexOf(':', typeStart);
const validTypes: MentionType[] = ['repository', 'user', 'ai', 'notify']; const validTypes: MentionType[] = ['repository', 'user', 'ai', 'notify'];
if (colon >= 0 && colon - typeStart <= 20) { if (colon >= 0 && colon - typeStart <= 20) {
// Colon format: <type:name> or @type:name
const typeRaw = text.slice(typeStart, colon).toLowerCase(); const typeRaw = text.slice(typeStart, colon).toLowerCase();
if (!validTypes.includes(typeRaw as MentionType)) { if (!validTypes.includes(typeRaw as MentionType)) {
cursor = next + 1; cursor = next + 1;
@ -58,6 +54,7 @@ function extractLegacyMentionTokens(text: string): LegacyMentionToken[] {
end++; end++;
} }
// For angle style, require closing >
const closeBracket = style === 'angle' && text[end] === '>' ? end + 1 : end; const closeBracket = style === 'angle' && text[end] === '>' ? end + 1 : end;
const name = text.slice(colon + 1, style === 'angle' ? end : closeBracket); const name = text.slice(colon + 1, style === 'angle' ? end : closeBracket);
@ -68,7 +65,10 @@ function extractLegacyMentionTokens(text: string): LegacyMentionToken[] {
} }
} }
// Check for XML-like format: <type>name</type>
// Only for angle style
if (style === 'angle') { if (style === 'angle') {
// Find space or > to determine type end
let typeEnd = typeStart; let typeEnd = typeStart;
while (typeEnd < text.length && /[A-Za-z]/.test(text[typeEnd])) { while (typeEnd < text.length && /[A-Za-z]/.test(text[typeEnd])) {
typeEnd++; typeEnd++;
@ -76,8 +76,11 @@ function extractLegacyMentionTokens(text: string): LegacyMentionToken[] {
const typeCandidate = text.slice(typeStart, typeEnd); const typeCandidate = text.slice(typeStart, typeEnd);
if (validTypes.includes(typeCandidate as MentionType)) { if (validTypes.includes(typeCandidate as MentionType)) {
// Found opening tag like <user>
const closeTag = `</${typeCandidate}>`; const closeTag = `</${typeCandidate}>`;
const contentStart = typeEnd; const contentStart = typeEnd;
// Find the closing tag
const tagClose = text.indexOf(closeTag, cursor); const tagClose = text.indexOf(closeTag, cursor);
if (tagClose >= 0) { if (tagClose >= 0) {
const name = text.slice(contentStart, tagClose); const name = text.slice(contentStart, tagClose);
@ -96,118 +99,49 @@ function extractLegacyMentionTokens(text: string): LegacyMentionToken[] {
} }
function extractFirstMentionName(text: string, type: MentionType): string | null { function extractFirstMentionName(text: string, type: MentionType): string | null {
const token = extractLegacyMentionTokens(text).find((item) => item.type === type); const token = extractMentionTokens(text).find((item) => item.type === type);
return token?.name ?? null; return token?.name ?? null;
} }
interface MessageContentWithMentionsProps { interface MessageContentWithMentionsProps {
content: string; 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<string, string> = { const mentionStyles: Record<MentionType, 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', 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', 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', 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', 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',
}; };
function renderNode( /** Renders message content with @mention highlighting using styled spans */
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({ export const MessageContentWithMentions = memo(function MessageContentWithMentions({
content, content,
members = [],
repos = [],
aiConfigs = [],
}: MessageContentWithMentionsProps) { }: MessageContentWithMentionsProps) {
const nodes = useMemo(() => { const processed = useMemo(() => {
// Try the new AST parser first (handles <mention> and <ai> tags) const tokens = extractMentionTokens(content);
const ast = parse(content); if (tokens.length === 0) return [{ type: 'text' as const, content }];
if (ast.length > 0) return ast;
// Fall back to legacy parser for old-format messages const parts: Array<{ type: 'text'; content: string } | { type: 'mention'; mention: MentionToken }> = [];
const legacy = extractLegacyMentionTokens(content);
if (legacy.length === 0) return [{ type: 'text' as const, text: content }];
const parts: Node[] = [];
let cursor = 0; let cursor = 0;
for (const token of legacy) {
for (const token of tokens) {
const idx = content.indexOf(token.full, cursor); const idx = content.indexOf(token.full, cursor);
if (idx === -1) continue; if (idx === -1) continue;
if (idx > cursor) { if (idx > cursor) {
parts.push({ type: 'text', text: content.slice(cursor, idx) }); parts.push({ type: 'text', content: content.slice(cursor, idx) });
} }
parts.push({ parts.push({ type: 'mention', mention: token });
type: 'mention',
mentionType: token.type as MentionMentionType,
id: '',
label: token.name,
});
cursor = idx + token.full.length; cursor = idx + token.full.length;
} }
if (cursor < content.length) { if (cursor < content.length) {
parts.push({ type: 'text', text: content.slice(cursor) }); parts.push({ type: 'text', content: content.slice(cursor) });
} }
return parts; return parts;
}, [content]); }, [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 ( return (
<div <div
className={cn( className={cn(
@ -217,7 +151,18 @@ export const MessageContentWithMentions = memo(function MessageContentWithMentio
'[&_pre]:rounded-md [&_pre]:bg-muted [&_pre]:p-3 [&_pre]:overflow-x-auto', '[&_pre]:rounded-md [&_pre]:bg-muted [&_pre]:p-3 [&_pre]:overflow-x-auto',
)} )}
> >
{nodes.map((node, i) => renderNode(node, i, resolveName))} {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>
),
)}
</div> </div>
); );
}); });

View File

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

View File

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

View File

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

View File

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

View File

@ -11,7 +11,6 @@ import {
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { import {
type ProjectRepositoryItem,
type RoomCategoryResponse, type RoomCategoryResponse,
type RoomMemberResponse, type RoomMemberResponse,
type RoomMessageResponse, type RoomMessageResponse,
@ -40,11 +39,6 @@ import {
export type { RoomWsStatus, RoomWsClient } from '@/lib/room-ws-client'; export type { RoomWsStatus, RoomWsClient } from '@/lib/room-ws-client';
export interface RoomAiConfig {
model: string;
modelName?: string;
}
export interface ReactionGroup { export interface ReactionGroup {
emoji: string; emoji: string;
count: number; count: number;
@ -152,13 +146,6 @@ interface RoomContextValue {
updateRoom: (roomId: string, name?: string, isPublic?: boolean, categoryId?: string) => Promise<void>; updateRoom: (roomId: string, name?: string, isPublic?: boolean, categoryId?: string) => Promise<void>;
deleteRoom: (roomId: string) => Promise<void>; deleteRoom: (roomId: string) => Promise<void>;
streamingMessages: Map<string, string>; 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); const RoomContext = createContext<RoomContextValue | null>(null);
@ -428,15 +415,6 @@ export function RoomProvider({
const [streamingContent, setStreamingContent] = useState<Map<string, string>>(new Map()); 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(() => { useEffect(() => {
const baseUrl = import.meta.env.VITE_API_BASE_URL ?? window.location.origin; const baseUrl = import.meta.env.VITE_API_BASE_URL ?? window.location.origin;
const client = createRoomWsClient( const client = createRoomWsClient(
@ -1117,73 +1095,6 @@ export function RoomProvider({
[activeRoomId], [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( const createRoom = useCallback(
async (name: string, isPublic: boolean, categoryId?: string) => { async (name: string, isPublic: boolean, categoryId?: string) => {
const client = wsClientRef.current; const client = wsClientRef.current;
@ -1311,10 +1222,6 @@ export function RoomProvider({
updateRoom, updateRoom,
deleteRoom, deleteRoom,
streamingMessages: streamingContent, streamingMessages: streamingContent,
projectRepos,
reposLoading,
roomAiConfigs,
aiConfigsLoading,
}), }),
[ [
wsStatus, wsStatus,
@ -1363,10 +1270,6 @@ export function RoomProvider({
updateRoom, updateRoom,
deleteRoom, deleteRoom,
streamingContent, streamingContent,
projectRepos,
reposLoading,
roomAiConfigs,
aiConfigsLoading,
], ],
); );

View File

@ -1,165 +0,0 @@
/**
* 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>`;
}

View File

@ -1,9 +0,0 @@
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[] };