Compare commits

..

No commits in common. "a171d691c603cabb16c32acc303ca1b26b7eef2e" and "5256e72be75ff21a534ccc990ba4846eb9512d72" have entirely different histories.

23 changed files with 1368 additions and 589 deletions

View File

@ -118,11 +118,6 @@ pub fn init_room_routes(cfg: &mut web::ServiceConfig) {
"/rooms/{room_id}/messages/{message_id}/reactions",
web::get().to(reaction::reaction_get),
)
// batch reactions
.route(
"/rooms/{room_id}/messages/reactions/batch",
web::get().to(reaction::reaction_batch),
)
// message search
.route(
"/rooms/{room_id}/messages/search",

View File

@ -18,12 +18,6 @@ pub struct MessageSearchQuery {
pub offset: Option<u64>,
}
#[derive(Debug, serde::Deserialize, IntoParams)]
pub struct ReactionBatchQuery {
/// Comma-separated list of message IDs
pub message_ids: String,
}
#[utoipa::path(
post,
path = "/api/rooms/{room_id}/messages/{message_id}/reactions",
@ -125,43 +119,6 @@ pub async fn reaction_get(
Ok(ApiResponse::ok(resp).to_response())
}
#[utoipa::path(
get,
path = "/api/rooms/{room_id}/messages/reactions/batch",
params(
("room_id" = Uuid, Path),
("message_ids" = Vec<Uuid>, Query, description = "List of message IDs to fetch reactions for"),
),
responses(
(status = 200, description = "Batch get reactions", body = ApiResponse<Vec<room::MessageReactionsResponse>>),
(status = 401, description = "Unauthorized"),
),
tag = "Room"
)]
pub async fn reaction_batch(
service: web::Data<AppService>,
session: Session,
path: web::Path<Uuid>,
query: web::Query<ReactionBatchQuery>,
) -> Result<HttpResponse, ApiError> {
let room_id = path.into_inner();
let user_id = session
.user()
.ok_or_else(|| ApiError::from(service::error::AppError::Unauthorized))?;
let ctx = WsUserContext::new(user_id);
let message_ids: Vec<Uuid> = query
.message_ids
.split(',')
.filter_map(|s| Uuid::parse_str(s.trim()).ok())
.collect();
let resp = service
.room
.message_reactions_batch(room_id, message_ids, &ctx)
.await
.map_err(ApiError::from)?;
Ok(ApiResponse::ok(resp).to_response())
}
#[utoipa::path(
get,
path = "/api/rooms/{room_id}/messages/search",

View File

@ -449,9 +449,9 @@ impl From<room::MessageReactionsResponse> for ReactionListData {
#[derive(Debug, Clone, Serialize)]
pub struct ReactionItem {
pub emoji: String,
pub count: i32,
pub count: i64,
pub reacted_by_me: bool,
pub users: Vec<String>,
pub users: Vec<Uuid>,
}
#[derive(Debug, Clone, Serialize)]

View File

@ -196,7 +196,7 @@ pub async fn ws_universal(
let _ = session.close(Some(actix_ws::CloseCode::Normal.into())).await;
break;
}
push_event = poll_push_streams(&mut push_streams, &manager, &handler.service(), user_id) => {
push_event = poll_push_streams(&mut push_streams, &manager, user_id) => {
match push_event {
Some(WsPushEvent::RoomMessage { room_id, event }) => {
let payload = serde_json::json!({
@ -372,7 +372,6 @@ pub async fn ws_universal(
async fn poll_push_streams(
streams: &mut PushStreams,
manager: &Arc<RoomConnectionManager>,
service: &Arc<AppService>,
user_id: Uuid,
) -> Option<WsPushEvent> {
loop {
@ -413,21 +412,16 @@ async fn poll_push_streams(
}
}
// Re-subscribe dead rooms so we don't permanently lose events.
// Re-check access in case the user's permissions were revoked while the
// stream was dead.
// Re-subscribe dead rooms so we don't permanently lose events
for room_id in dead_rooms {
if streams.remove(&room_id).is_some() {
if service.room.check_room_access(room_id, user_id).await.is_ok() {
if let Ok(rx) = manager.subscribe(room_id, user_id).await {
let stream_rx = manager.subscribe_room_stream(room_id).await;
streams.insert(room_id, (
BroadcastStream::new(rx),
BroadcastStream::new(stream_rx),
));
}
if let Ok(rx) = manager.subscribe(room_id, user_id).await {
let stream_rx = manager.subscribe_room_stream(room_id).await;
streams.insert(room_id, (
BroadcastStream::new(rx),
BroadcastStream::new(stream_rx),
));
}
// If access check fails, silently skip re-subscribe (user was removed)
}
}

View File

@ -40,10 +40,9 @@ pub struct RoomMessageEvent {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReactionGroup {
pub emoji: String,
pub count: i32,
pub count: i64,
pub reacted_by_me: bool,
/// Stored as strings (UUIDs) to match the frontend's `users: string[]` type.
pub users: Vec<String>,
pub users: Vec<Uuid>,
}
impl From<RoomMessageEnvelope> for RoomMessageEvent {

View File

@ -590,12 +590,6 @@ impl RoomConnectionManager {
}
pub async fn broadcast_stream_chunk(&self, event: RoomMessageStreamChunkEvent) {
// Update activity tracker to prevent idle cleanup during active streaming
{
let mut activity = self.room_last_activity.write().await;
activity.insert(event.room_id, Instant::now());
}
let event = Arc::new(event);
let is_final_chunk = event.done;

View File

@ -124,73 +124,44 @@ impl RoomService {
.all(&self.db)
.await?;
// Batch fetch related users to avoid N+1 queries
let related_user_ids: Vec<Uuid> = notifications
.iter()
.filter_map(|n| n.related_user_id)
.collect();
let users: std::collections::HashMap<Uuid, String> = if !related_user_ids.is_empty() {
user_model::Entity::find()
.filter(user_model::Column::Uid.is_in(related_user_ids))
.all(&self.db)
.await?
.into_iter()
.map(|u| (u.uid, u.display_name.unwrap_or(u.username)))
.collect()
} else {
std::collections::HashMap::new()
};
let mut result = Vec::new();
for notification in notifications {
let mentioned_by =
user_model::Entity::find_by_id(notification.related_user_id.unwrap_or_default())
.one(&self.db)
.await?;
// Batch fetch room names to avoid N+1 queries
let room_ids: Vec<Uuid> = notifications
.iter()
.filter_map(|n| n.room)
.collect();
let rooms: std::collections::HashMap<Uuid, String> = if !room_ids.is_empty() {
models::rooms::room::Entity::find()
.filter(models::rooms::room::Column::Id.is_in(room_ids))
.all(&self.db)
.await?
.into_iter()
.map(|r| (r.id, r.room_name))
.collect()
} else {
std::collections::HashMap::new()
};
let room_name = if let Some(room_id) = notification.room {
models::rooms::room::Entity::find_by_id(room_id)
.one(&self.db)
.await?
.map(|r| r.room_name)
.unwrap_or_else(|| "Unknown Room".to_string())
} else {
"Unknown Room".to_string()
};
let result = notifications
.into_iter()
.map(|notification| {
let mentioned_by_name = notification
.related_user_id
.and_then(|uid| users.get(&uid))
.cloned()
.unwrap_or_else(|| "Unknown User".to_string());
let mentioned_by_name = mentioned_by
.map(|u| u.display_name.unwrap_or(u.username))
.unwrap_or_else(|| "Unknown User".to_string());
let room_name = notification
.room
.and_then(|rid| rooms.get(&rid))
.cloned()
.unwrap_or_else(|| "Unknown Room".to_string());
let content_preview = notification
.content
.unwrap_or_default()
.chars()
.take(100)
.collect();
let content_preview = notification
.content
.unwrap_or_default()
.chars()
.take(100)
.collect();
MentionNotificationResponse {
message_id: notification.related_message_id.unwrap_or_default(),
mentioned_by: notification.related_user_id.unwrap_or_default(),
mentioned_by_name,
content_preview,
room_id: notification.room.unwrap_or_default(),
room_name,
created_at: notification.created_at,
}
})
.collect();
result.push(MentionNotificationResponse {
message_id: notification.related_message_id.unwrap_or_default(),
mentioned_by: notification.related_user_id.unwrap_or_default(),
mentioned_by_name,
content_preview,
room_id: notification.room.unwrap_or_default(),
room_name,
created_at: notification.created_at,
});
}
Ok(result)
}

View File

@ -257,6 +257,24 @@ impl RoomService {
}
}
pub(crate) async fn next_room_message_seq<C>(
&self,
room_id: Uuid,
db: &C,
) -> Result<i64, RoomError>
where
C: ConnectionTrait,
{
let max_seq: Option<Option<i64>> = room_message::Entity::find()
.filter(room_message::Column::Room.eq(room_id))
.select_only()
.column_as(room_message::Column::Seq.max(), "max_seq")
.into_tuple::<Option<i64>>()
.one(db)
.await?;
Ok(max_seq.flatten().unwrap_or(0) + 1)
}
pub async fn utils_find_project_by_name(
&self,
name: String,

View File

@ -134,7 +134,7 @@ impl RoomService {
}
}
let seq = Self::next_room_message_seq_internal(room_id, &self.db, &self.cache).await?;
let seq = self.next_room_message_seq(room_id, &self.db).await?;
let now = Utc::now();
let id = Uuid::now_v7();
let project_id = room_model.project;
@ -207,17 +207,6 @@ impl RoomService {
.await;
let mentioned_users = self.resolve_mentions(&request.content).await;
// Look up sender display name once for all mention notifications
let sender_display_name = {
let user = user_model::Entity::find()
.filter(user_model::Column::Uid.eq(user_id))
.one(&self.db)
.await
.ok()
.flatten();
user.map(|u| u.display_name.unwrap_or_else(|| u.username))
.unwrap_or_else(|| user_id.to_string())
};
for mentioned_user_id in mentioned_users {
if mentioned_user_id == user_id {
continue;
@ -226,7 +215,7 @@ impl RoomService {
.notification_create(super::NotificationCreateRequest {
notification_type: super::NotificationType::Mention,
user_id: mentioned_user_id,
title: format!("{} 在 {} 中提到了你", sender_display_name, room_model.room_name),
title: format!("{} 在 {} 中提到了你", user_id, room_model.room_name),
content: Some(content.clone()),
room_id: Some(room_id),
project_id,
@ -239,13 +228,7 @@ impl RoomService {
.await;
}
let should_respond = match self.should_ai_respond(room_id).await {
Ok(v) => v,
Err(e) => {
slog::warn!(self.log, "should_ai_respond failed for room {}: {}", room_id, e);
false
}
};
let should_respond = self.should_ai_respond(room_id).await.unwrap_or(false);
let is_text_message = request
.content_type
.as_ref()
@ -260,13 +243,23 @@ impl RoomService {
}
}
let display_name = {
let user = user_model::Entity::find()
.filter(user_model::Column::Uid.eq(user_id))
.one(&self.db)
.await
.ok()
.flatten();
user.map(|u| u.display_name.unwrap_or_else(|| u.username))
};
Ok(super::RoomMessageResponse {
id,
seq,
room: room_id,
sender_type: "member".to_string(),
sender_id: Some(user_id),
display_name: Some(sender_display_name),
display_name,
thread: thread_id,
in_reply_to,
content: request.content,

View File

@ -6,14 +6,15 @@ use models::rooms::room_message_reaction;
use models::users::user as user_model;
use queue::ReactionGroup;
use sea_orm::*;
use sea_query::OnConflict;
use uuid::Uuid;
#[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)]
pub struct ReactionGroupResponse {
pub emoji: String,
pub count: i32,
pub count: i64,
pub reacted_by_me: bool,
pub users: Vec<String>,
pub users: Vec<Uuid>,
}
#[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)]
@ -44,21 +45,6 @@ impl RoomService {
let now = Utc::now();
// Use a transaction to atomically check-and-insert, preventing race conditions
let txn = self.db.begin().await?;
let existing = room_message_reaction::Entity::find()
.filter(room_message_reaction::Column::Message.eq(message_id))
.filter(room_message_reaction::Column::User.eq(user_id))
.filter(room_message_reaction::Column::Emoji.eq(&emoji))
.one(&txn)
.await?;
if existing.is_some() {
txn.commit().await?;
return self.get_message_reactions(message_id, Some(user_id)).await;
}
let reaction = room_message_reaction::ActiveModel {
id: Set(Uuid::now_v7()),
room: Set(message.room),
@ -68,30 +54,38 @@ impl RoomService {
created_at: Set(now),
};
room_message_reaction::Entity::insert(reaction)
.exec(&txn)
.await?;
txn.commit().await?;
// Only publish if we actually inserted a new reaction
let reactions = self
.get_message_reactions(message_id, Some(user_id))
.await?;
let reaction_groups = reactions
.reactions
.into_iter()
.map(|g| ReactionGroup {
emoji: g.emoji,
count: g.count as i32,
reacted_by_me: g.reacted_by_me,
users: g.users.into_iter().map(|u| u.to_string()).collect(),
})
.collect();
self.queue
.publish_reaction_event(message.room, message_id, reaction_groups)
let result = room_message_reaction::Entity::insert(reaction)
.on_conflict(
OnConflict::columns([
room_message_reaction::Column::Message,
room_message_reaction::Column::User,
room_message_reaction::Column::Emoji,
])
.do_nothing()
.to_owned(),
)
.exec(&self.db)
.await;
if result.is_ok() {
let reactions = self
.get_message_reactions(message_id, Some(user_id))
.await?;
let reaction_groups = reactions
.reactions
.into_iter()
.map(|g| ReactionGroup {
emoji: g.emoji,
count: g.count,
reacted_by_me: g.reacted_by_me,
users: g.users,
})
.collect();
self.queue
.publish_reaction_event(message.room, message_id, reaction_groups)
.await;
}
self.get_message_reactions(message_id, Some(user_id)).await
}
@ -121,9 +115,9 @@ impl RoomService {
.into_iter()
.map(|g| ReactionGroup {
emoji: g.emoji,
count: g.count as i32,
count: g.count,
reacted_by_me: g.reacted_by_me,
users: g.users.into_iter().map(|u| u.to_string()).collect(),
users: g.users,
})
.collect();
self.queue
@ -258,15 +252,11 @@ impl RoomService {
grouped
.into_iter()
.map(|(emoji, user_reactions)| {
let count = user_reactions.len() as i32;
let count = user_reactions.len() as i64;
let reacted_by_me = current_user_id
.map(|uid| user_reactions.iter().any(|r| r.user == uid))
.unwrap_or(false);
let users = user_reactions
.iter()
.take(3)
.map(|r| r.user.to_string())
.collect();
let users = user_reactions.iter().take(3).map(|r| r.user).collect();
ReactionGroupResponse {
emoji,

View File

@ -273,19 +273,6 @@ impl RoomService {
self.room_manager.shutdown_room(room_id).await;
// Clean up Redis seq key so re-creating the room starts fresh
let seq_key = format!("room:seq:{}", room_id);
if let Ok(mut conn) = self.cache.conn().await {
let _: Option<String> = redis::cmd("DEL")
.arg(&seq_key)
.query_async(&mut conn)
.await
.inspect_err(|e| {
slog::warn!(self.log, "room_delete: failed to DEL seq key {}: {}", seq_key, e);
})
.ok();
}
let event = ProjectRoomEvent {
event_type: super::RoomEventType::RoomDeleted.as_str().into(),
project_id,

View File

@ -256,7 +256,7 @@ impl RoomService {
.await?
.ok_or_else(|| RoomError::NotFound("You are not a member of this room".to_string()))?;
if member.role == models::rooms::RoomMemberRole::Owner {
if member.role.to_string() == "owner" {
return Err(RoomError::BadRequest(
"Owner cannot leave the room. Transfer ownership first.".to_string(),
));

View File

@ -1112,7 +1112,7 @@ impl RoomService {
Vec::new()
}
pub(crate) async fn next_room_message_seq_internal(
async fn next_room_message_seq_internal(
room_id: Uuid,
db: &AppDatabase,
cache: &AppCache,

View File

@ -1,7 +1,128 @@
import { memo, useMemo } from 'react';
import { memo, useMemo, useEffect } from 'react';
import { cn } from '@/lib/utils';
// Register web components for mentions
function registerMentionComponents() {
if (typeof window === 'undefined') return;
if (customElements.get('mention-user')) return;
class MentionUser extends HTMLElement {
connectedCallback() {
const name = this.getAttribute('name') || '';
this.attachShadow({ mode: 'open' }).innerHTML = `
<style>
:host {
display: inline-flex;
align-items: center;
background: rgba(59, 130, 246, 0.15);
color: #3b82f6;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-weight: 500;
cursor: pointer;
font-size: 0.875rem;
line-height: 1.25rem;
transition: background 0.15s;
}
:host(:hover) {
background: rgba(59, 130, 246, 0.25);
}
.icon {
width: 14px;
height: 14px;
margin-right: 4px;
flex-shrink: 0;
}
</style>
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
<span>@${name}</span>
`;
}
}
class MentionRepo extends HTMLElement {
connectedCallback() {
const name = this.getAttribute('name') || '';
this.attachShadow({ mode: 'open' }).innerHTML = `
<style>
:host {
display: inline-flex;
align-items: center;
background: rgba(168, 85, 247, 0.15);
color: #a855f7;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-weight: 500;
cursor: pointer;
font-size: 0.875rem;
line-height: 1.25rem;
transition: background 0.15s;
}
:host(:hover) {
background: rgba(168, 85, 247, 0.25);
}
.icon {
width: 14px;
height: 14px;
margin-right: 4px;
flex-shrink: 0;
}
</style>
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
</svg>
<span>@${name}</span>
`;
}
}
class MentionAi extends HTMLElement {
connectedCallback() {
const name = this.getAttribute('name') || '';
this.attachShadow({ mode: 'open' }).innerHTML = `
<style>
:host {
display: inline-flex;
align-items: center;
background: rgba(34, 197, 94, 0.15);
color: #22c55e;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-weight: 500;
cursor: pointer;
font-size: 0.875rem;
line-height: 1.25rem;
transition: background 0.15s;
}
:host(:hover) {
background: rgba(34, 197, 94, 0.25);
}
.icon {
width: 14px;
height: 14px;
margin-right: 4px;
flex-shrink: 0;
}
</style>
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2a2 2 0 0 1 2 2c0 .74-.4 1.39-1 1.73V7h1a7 7 0 0 1 7 7h1a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-1v1a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-1H2a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1h1a7 7 0 0 1 7-7h1V5.73c-.6-.34-1-.99-1-1.73a2 2 0 0 1 2-2z"/>
<circle cx="8" cy="14" r="1"/>
<circle cx="16" cy="14" r="1"/>
</svg>
<span>@${name}</span>
`;
}
}
customElements.define('mention-user', MentionUser);
customElements.define('mention-repo', MentionRepo);
customElements.define('mention-ai', MentionAi);
}
type MentionType = 'repository' | 'user' | 'ai' | 'notify';
interface MentionToken {
@ -107,17 +228,15 @@ interface MessageContentWithMentionsProps {
content: 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',
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 */
/** Renders message content with @mention highlighting using web components */
export const MessageContentWithMentions = memo(function MessageContentWithMentions({
content,
}: MessageContentWithMentionsProps) {
// Register web components on first render
useEffect(() => {
registerMentionComponents();
}, []);
const processed = useMemo(() => {
const tokens = extractMentionTokens(content);
if (tokens.length === 0) return [{ type: 'text' as const, content }];
@ -153,12 +272,23 @@ export const MessageContentWithMentions = memo(function MessageContentWithMentio
>
{processed.map((part, i) =>
part.type === 'mention' ? (
<span
key={i}
className={mentionStyles[part.mention.type]}
>
@{part.mention.name}
</span>
part.mention.type === 'user' ? (
// @ts-ignore custom element
<mention-user key={i} name={part.mention.name} />
) : part.mention.type === 'repository' ? (
// @ts-ignore custom element
<mention-repo key={i} name={part.mention.name} />
) : part.mention.type === 'ai' ? (
// @ts-ignore custom element
<mention-ai key={i} name={part.mention.name} />
) : (
<span
key={i}
className="inline-flex items-center rounded bg-blue-100 px-1 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"
>
@{part.mention.name}
</span>
)
) : (
<span key={i}>{part.content}</span>
),

View File

@ -22,6 +22,7 @@ import { RoomMessageEditHistoryDialog } from './RoomMessageEditHistoryDialog';
import { RoomMessageList } from './RoomMessageList';
import { RoomParticipantsPanel } from './RoomParticipantsPanel';
import { RoomSettingsPanel } from './RoomSettingsPanel';
import { RoomPerformanceMonitor } from './RoomPerformanceMonitor';
import { RoomMessageSearch } from './RoomMessageSearch';
import { RoomMentionPanel } from './RoomMentionPanel';
import { RoomThreadPanel } from './RoomThreadPanel';
@ -130,7 +131,7 @@ const ChatInputArea = memo(function ChatInputArea({
{replyingTo && (
<div className="mb-2 flex items-center gap-2 rounded-md bg-muted/50 px-3 py-2 text-xs">
<span className="font-medium text-foreground">Replying to {replyingTo.display_name}</span>
<span className="truncate text-muted-foreground" title={replyingTo.content}>{replyingTo.content.length > 80 ? replyingTo.content.slice(0, 80) + '…' : replyingTo.content}</span>
<span className="truncate text-muted-foreground">{replyingTo.content}</span>
<button onClick={onCancelReply} className="ml-auto text-muted-foreground hover:text-foreground">
<X className="h-3 w-3" />
</button>
@ -261,6 +262,7 @@ export function RoomChatPanel({ room, isAdmin, onClose, onDelete }: RoomChatPane
const [showMentions, setShowMentions] = useState(false);
const [isUpdatingRoom, setIsUpdatingRoom] = useState(false);
const [activeThread, setActiveThread] = useState<{ thread: RoomThreadResponse; parentMessage: MessageWithMeta } | null>(null);
const [renderedMessageCount, setRenderedMessageCount] = useState<number | undefined>(undefined);
// Draft management
const { draft, setDraft, clearDraft } = useRoomDraft(room.id);
@ -287,7 +289,6 @@ export function RoomChatPanel({ room, isAdmin, onClose, onDelete }: RoomChatPane
sendMessage(content, 'text', replyingTo?.id ?? undefined);
setReplyingTo(null);
},
// sendMessage from useRoom is already stable; replyingTo changes trigger handleSend rebuild (acceptable)
[sendMessage, replyingTo],
);
@ -309,8 +310,7 @@ export function RoomChatPanel({ room, isAdmin, onClose, onDelete }: RoomChatPane
setEditingMessage(null);
toast.success('Message updated');
},
// Only rebuild when editingMessage.id actually changes, not on every new message
[editingMessage?.id, editMessage],
[editingMessage, editMessage],
);
const handleRevoke = useCallback(
@ -321,7 +321,6 @@ export function RoomChatPanel({ room, isAdmin, onClose, onDelete }: RoomChatPane
[revokeMessage],
);
// Stable: chatInputRef is stable, no deps that change on message updates
const handleMention = useCallback((name: string, type: 'user' | 'ai') => {
chatInputRef.current?.insertMention(name, type);
}, []);
@ -521,6 +520,7 @@ export function RoomChatPanel({ room, isAdmin, onClose, onDelete }: RoomChatPane
onMention={handleMention}
onOpenThread={handleOpenThread}
onCreateThread={handleCreateThread}
onRenderedCountChange={setRenderedMessageCount}
/>
</div>
@ -604,6 +604,7 @@ export function RoomChatPanel({ room, isAdmin, onClose, onDelete }: RoomChatPane
roomId={room.id}
/>
<RoomPerformanceMonitor messageCount={messages.length} renderedCount={renderedMessageCount} />
</section>
);
}

View File

@ -6,10 +6,9 @@ import { parseFunctionCalls, type FunctionCall } from '@/lib/functionCallParser'
import { cn } from '@/lib/utils';
import { AlertCircle, AlertTriangle, ChevronDown, ChevronUp, Copy, Edit2, Reply as ReplyIcon, Trash2, History, MoreHorizontal, MessageSquare } from 'lucide-react';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { SmilePlus } from 'lucide-react';
import { useUser, useRoom } from '@/contexts';
import { memo, useMemo, useState, useCallback, useRef } from 'react';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { toast } from 'sonner';
import { ModelIcon } from './icon-match';
import { FunctionCallBadge } from './FunctionCallBadge';
@ -81,7 +80,10 @@ export const RoomMessageBubble = memo(function RoomMessageBubble({
const [editContent, setEditContent] = useState(message.content);
const [isSavingEdit, setIsSavingEdit] = useState(false);
const [showReactionPicker, setShowReactionPicker] = useState(false);
const [isNarrow, setIsNarrow] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const reactionButtonRef = useRef<HTMLButtonElement>(null);
const [reactionPickerPosition, setReactionPickerPosition] = useState<{ top: number; left: number } | null>(null);
const isAi = ['ai', 'system', 'tool'].includes(message.sender_type);
const isSystem = message.sender_type === 'system';
@ -105,8 +107,19 @@ export const RoomMessageBubble = memo(function RoomMessageBubble({
? streamingMessages.get(message.id)!
: message.content;
// Detect narrow container width using CSS container query instead of ResizeObserver
// The .group/narrow class on the container enables CSS container query support
// Detect narrow container width
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
// Collapse toolbar when container < 300px
setIsNarrow(entry.contentRect.width < 300);
}
});
observer.observe(el);
return () => observer.disconnect();
}, []);
const handleReaction = useCallback(async (emoji: string) => {
if (!wsClient) return;
@ -123,6 +136,17 @@ export const RoomMessageBubble = memo(function RoomMessageBubble({
setShowReactionPicker(false);
}, [roomId, message.id, message.reactions, wsClient]);
const handleOpenReactionPicker = useCallback(() => {
if (reactionButtonRef.current) {
const rect = reactionButtonRef.current.getBoundingClientRect();
setReactionPickerPosition({
top: rect.bottom + 8, // 8px below the button
left: rect.left + rect.width / 2,
});
}
setShowReactionPicker(true);
}, []);
const functionCalls = useMemo<FunctionCall[]>(
() =>
message.content_type === 'text' || message.content_type === 'Text'
@ -392,116 +416,212 @@ export const RoomMessageBubble = memo(function RoomMessageBubble({
</div>
)}
{/* Action toolbar - inline icon buttons */}
{/* Action toolbar - inline icons when wide, collapsed to dropdown when narrow */}
{!isEditing && !isRevoked && !isPending && (
<div className="flex items-start gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
{/* Add reaction */}
<Popover open={showReactionPicker} onOpenChange={setShowReactionPicker}>
<PopoverTrigger
render={
<Button
variant="ghost"
size="sm"
className="size-7 p-0 text-muted-foreground hover:bg-accent hover:text-foreground"
title="Add reaction"
>
<SmilePlus className="size-3.5" />
</Button>
}
/>
<PopoverContent className="w-auto p-2" align="start" sideOffset={4}>
<p className="mb-2 text-xs font-medium text-muted-foreground">Select emoji</p>
<div className="grid grid-cols-8 gap-1">
{COMMON_EMOJIS.map((emoji) => (
{isNarrow ? (
/* Narrow: all actions in dropdown */
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
key={emoji}
variant="ghost"
size="sm"
onClick={() => handleReaction(emoji)}
className="size-7 p-0 text-base hover:bg-accent"
title={emoji}
className="size-7 p-0 text-muted-foreground hover:bg-accent hover:text-foreground"
title="More actions"
>
{emoji}
<MoreHorizontal className="size-3.5" />
</Button>
))}
</div>
</PopoverContent>
</Popover>
{/* Reply */}
{onReply && (
<Button
variant="ghost"
size="sm"
className="size-7 p-0 text-muted-foreground hover:bg-accent hover:text-foreground"
onClick={() => onReply(message)}
title="Reply"
>
<ReplyIcon className="size-3.5" />
</Button>
)}
{/* Copy */}
{message.content_type === 'text' && (
<Button
variant="ghost"
size="sm"
className="size-7 p-0 text-muted-foreground hover:bg-accent hover:text-foreground"
onClick={async () => {
try {
await navigator.clipboard.writeText(message.content);
toast.success('Message copied');
} catch {
toast.error('Failed to copy');
}
}}
title="Copy"
>
<Copy className="size-3.5" />
</Button>
)}
{/* More menu */}
<DropdownMenu>
<DropdownMenuTrigger
render={
/>
<DropdownMenuContent align="end">
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault();
setShowReactionPicker(true);
}}
>
<SmilePlus className="mr-2 size-4" />
Add reaction
</DropdownMenuItem>
{onReply && (
<DropdownMenuItem onClick={() => onReply(message)}>
<ReplyIcon className="mr-2 size-4" />
Reply
</DropdownMenuItem>
)}
{onCreateThread && !message.thread_id && (
<DropdownMenuItem onClick={() => onCreateThread(message)}>
<MessageSquare className="mr-2 size-4" />
Create thread
</DropdownMenuItem>
)}
{message.content_type === 'text' && (
<DropdownMenuItem
onClick={async () => {
try {
await navigator.clipboard.writeText(message.content);
toast.success('Message copied');
} catch {
toast.error('Failed to copy');
}
}}
>
<Copy className="mr-2 size-4" />
Copy
</DropdownMenuItem>
)}
{message.edited_at && onViewHistory && (
<DropdownMenuItem onClick={() => onViewHistory(message)}>
<History className="mr-2 size-4" />
View edit history
</DropdownMenuItem>
)}
{isOwner && message.content_type === 'text' && (
<DropdownMenuItem onClick={handleStartEdit}>
<Edit2 className="mr-2 size-4" />
Edit
</DropdownMenuItem>
)}
{isOwner && onRevoke && (
<DropdownMenuItem onClick={() => onRevoke(message)} className="text-destructive focus:text-destructive">
<Trash2 className="mr-2 size-4" />
Delete
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
) : (
/* Wide: inline icon buttons */
<>
{/* Add reaction */}
<Button
variant="ghost"
size="sm"
ref={reactionButtonRef}
className="size-7 p-0 text-muted-foreground hover:bg-accent hover:text-foreground"
onClick={handleOpenReactionPicker}
title="Add reaction"
>
<SmilePlus className="size-3.5" />
</Button>
{/* Reply */}
{onReply && (
<Button
variant="ghost"
size="sm"
className="size-7 p-0 text-muted-foreground hover:bg-accent hover:text-foreground"
title="More"
onClick={() => onReply(message)}
title="Reply"
>
<MoreHorizontal className="size-3.5" />
<ReplyIcon className="size-3.5" />
</Button>
}
/>
<DropdownMenuContent align="end">
{onCreateThread && !message.thread_id && (
<DropdownMenuItem onClick={() => onCreateThread(message)}>
<MessageSquare className="mr-2 size-4" />
Create thread
</DropdownMenuItem>
)}
{message.edited_at && onViewHistory && (
<DropdownMenuItem onClick={() => onViewHistory(message)}>
<History className="mr-2 size-4" />
View edit history
</DropdownMenuItem>
{/* Copy */}
{message.content_type === 'text' && (
<Button
variant="ghost"
size="sm"
className="size-7 p-0 text-muted-foreground hover:bg-accent hover:text-foreground"
onClick={async () => {
try {
await navigator.clipboard.writeText(message.content);
toast.success('Message copied');
} catch {
toast.error('Failed to copy');
}
}}
title="Copy"
>
<Copy className="size-3.5" />
</Button>
)}
{isOwner && message.content_type === 'text' && (
<DropdownMenuItem onClick={handleStartEdit}>
<Edit2 className="mr-2 size-4" />
Edit
</DropdownMenuItem>
)}
{isOwner && onRevoke && (
<DropdownMenuItem onClick={() => onRevoke(message)} className="text-destructive focus:text-destructive">
<Trash2 className="mr-2 size-4" />
Delete
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
{/* More menu */}
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
size="sm"
className="size-7 p-0 text-muted-foreground hover:bg-accent hover:text-foreground"
title="More"
>
<MoreHorizontal className="size-3.5" />
</Button>
}
/>
<DropdownMenuContent align="end">
{onCreateThread && !message.thread_id && (
<DropdownMenuItem onClick={() => onCreateThread(message)}>
<MessageSquare className="mr-2 size-4" />
Create thread
</DropdownMenuItem>
)}
{message.edited_at && onViewHistory && (
<DropdownMenuItem onClick={() => onViewHistory(message)}>
<History className="mr-2 size-4" />
View edit history
</DropdownMenuItem>
)}
{isOwner && message.content_type === 'text' && (
<DropdownMenuItem onClick={handleStartEdit}>
<Edit2 className="mr-2 size-4" />
Edit
</DropdownMenuItem>
)}
{isOwner && onRevoke && (
<DropdownMenuItem onClick={() => onRevoke(message)} className="text-destructive focus:text-destructive">
<Trash2 className="mr-2 size-4" />
Delete
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</>
)}
</div>
)}
{/* Emoji picker overlay - positioned relative to the reaction button */}
{showReactionPicker && (
<>
<div className="fixed inset-0 z-40" onClick={() => setShowReactionPicker(false)} />
<div
className="fixed z-50"
style={{
top: reactionPickerPosition?.top ?? '50%',
left: reactionPickerPosition?.left ?? '50%',
transform: reactionPickerPosition ? 'translateX(-50%)' : 'translate(-50%, -50%)',
}}
>
<div className="rounded-lg border border-border bg-popover p-3 shadow-xl">
<p className="mb-2 text-xs font-medium text-muted-foreground">Select emoji</p>
<EmojiPicker onEmojiSelect={(emoji) => {
handleReaction(emoji);
setShowReactionPicker(false);
}} />
</div>
</div>
</>
)}
</div>
);
});
function EmojiPicker({ onEmojiSelect }: { onEmojiSelect: (emoji: string) => void }) {
return (
<div className="grid grid-cols-8 gap-1">
{COMMON_EMOJIS.map((emoji) => (
<Button
key={emoji}
variant="ghost"
size="sm"
onClick={() => onEmojiSelect(emoji)}
className="size-7 p-0 text-base hover:bg-accent"
>
{emoji}
</Button>
))}
</div>
);
}

View File

@ -1,7 +1,7 @@
import type { MessageWithMeta } from '@/contexts';
import { Button } from '@/components/ui/button';
import { ArrowDown, Loader2 } from 'lucide-react';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { memo, useCallback, useEffect, useMemo, useRef, useState, useTransition, useDeferredValue } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { RoomMessageBubble } from './RoomMessageBubble';
import { getSenderModelId } from './sender';
@ -27,6 +27,8 @@ interface RoomMessageListProps {
isLoadingMore?: boolean;
onOpenThread?: (message: MessageWithMeta) => void;
onCreateThread?: (message: MessageWithMeta) => void;
/** Called with the count of currently rendered (visible) rows whenever it changes. */
onRenderedCountChange?: (count: number) => void;
}
interface MessageRow {
@ -35,6 +37,7 @@ interface MessageRow {
message?: MessageWithMeta;
grouped?: boolean;
replyMessage?: MessageWithMeta | null;
/** Unique key for the virtualizer */
key: string;
}
@ -71,20 +74,12 @@ function getSenderKey(message: MessageWithMeta): string {
return `sender:${message.sender_type}`;
}
/** Estimate message row height based on content characteristics */
function estimateMessageRowHeight(message: MessageWithMeta): number {
const lineCount = message.content.split(/\r?\n/).reduce((total, line) => {
return total + Math.max(1, Math.ceil(line.trim().length / 90));
}, 0);
const baseHeight = 24; // avatar + padding
const lineHeight = 20;
const replyHeight = message.in_reply_to ? 36 : 0;
return baseHeight + Math.min(lineCount, 5) * lineHeight + replyHeight;
}
/** Estimated height for a message row in pixels (used as initial guess before measurement) */
const ESTIMATED_ROW_HEIGHT = 40;
/** Estimated height for a date divider row in pixels */
const ESTIMATED_DIVIDER_HEIGHT = 30;
const RoomMessageListInner = memo(function RoomMessageListInner({
export const RoomMessageList = memo(function RoomMessageList({
roomId,
messages,
messagesEndRef,
@ -99,32 +94,39 @@ const RoomMessageListInner = memo(function RoomMessageListInner({
isLoadingMore = false,
onOpenThread,
onCreateThread,
onRenderedCountChange,
}: RoomMessageListProps) {
const scrollContainerRef = useRef<HTMLDivElement>(null);
const topSentinelRef = useRef<HTMLDivElement>(null);
const prevScrollHeightRef = useRef<number | null>(null);
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
const [isUserScrolling, setIsUserScrolling] = useState(false);
const scrollTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isRestoringScrollRef = useRef(false);
const [, startScrollTransition] = useTransition();
// Record the ID of the first visible message before loading, for more precise scroll position restoration
const firstVisibleMessageIdRef = useRef<string | null>(null);
// Build reply lookup map (stable reference, recomputes only when messages change)
const replyMap = useMemo(() => {
// Defer messages so React can prioritize scroll/interaction state updates.
// When messages arrive rapidly (e.g. WS stream), React renders the deferred
// version in a lower-priority work window, preventing scroll jank.
const deferredMessages = useDeferredValue(messages);
const messageMap = useMemo(() => {
const map = new Map<string, MessageWithMeta>();
messages.forEach((m) => {
if (m.id) map.set(m.id, m);
});
deferredMessages.forEach((message) => map.set(message.id, message));
return map;
}, [messages]);
}, [deferredMessages]);
// Build rows: date dividers + messages
// Use a separate Map to avoid rows depending on replyMap (which changes reference)
// Uses deferredMessages so row computation is deprioritized during rapid message updates
const rows = useMemo<MessageRow[]>(() => {
const result: MessageRow[] = [];
let lastDateKey: string | null = null;
let lastSenderKey: string | null = null;
for (const message of messages) {
for (const message of deferredMessages) {
const dateKey = getDateKey(message.send_at);
const senderKey = getSenderKey(message);
@ -143,42 +145,36 @@ const RoomMessageListInner = memo(function RoomMessageListInner({
type: 'message',
message,
grouped,
replyMessage: message.in_reply_to ? replyMap.get(message.in_reply_to) ?? null : null,
replyMessage: message.in_reply_to ? messageMap.get(message.in_reply_to) ?? null : null,
key: message.id,
});
lastSenderKey = senderKey;
}
return result;
}, [messages, replyMap]);
}, [deferredMessages, messageMap]);
const scrollToBottom = useCallback((smooth = true) => {
messagesEndRef.current?.scrollIntoView({ behavior: smooth ? 'smooth' : 'auto' });
}, [messagesEndRef]);
// Lightweight scroll handler: only update the "show" flag, decoupled from layout reads
// Reads scroll position synchronously but defers state updates so browser can paint first.
// Track user scroll to detect if user is at bottom.
// Wrapped in startTransition so React knows these state updates are non-urgent
// and can be interrupted if a higher-priority update (e.g., new message) comes in.
const handleScroll = useCallback(() => {
const container = scrollContainerRef.current;
if (!container) return;
// Synchronous read of scroll position (triggers layout, unavoidable)
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
const nearBottom = distanceFromBottom < 100;
// Update state asynchronously — browser can process other frames before committing
requestAnimationFrame(() => {
startScrollTransition(() => {
setShowScrollToBottom(!nearBottom);
setIsUserScrolling(true);
});
// Reset user-scrolling flag after delay
// Reset user scrolling flag after a delay
if (scrollTimeoutRef.current) clearTimeout(scrollTimeoutRef.current);
scrollTimeoutRef.current = setTimeout(() => {
// Only clear if still at bottom
const c = scrollContainerRef.current;
if (c) {
const dist = c.scrollHeight - c.scrollTop - c.clientHeight;
if (dist < 100) setShowScrollToBottom(false);
}
setIsUserScrolling(false);
}, 500);
}, []);
@ -192,34 +188,30 @@ const RoomMessageListInner = memo(function RoomMessageListInner({
};
}, [handleScroll]);
// Auto-scroll when new messages arrive (only if user was already at bottom)
// Auto-scroll to bottom when new messages arrive (only if user was already at bottom).
// Uses deferredMessages.length so auto-scroll waits for the deferred render to settle.
useEffect(() => {
if (messages.length === 0) return;
// Check if near bottom before scheduling scroll
const container = scrollContainerRef.current;
if (!container) return;
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
if (distanceFromBottom < 100) {
requestAnimationFrame(() => scrollToBottom(false));
if (!isUserScrolling && deferredMessages.length > 0) {
scrollToBottom(false);
}
}, [messages.length, scrollToBottom]);
}, [deferredMessages.length, isUserScrolling, scrollToBottom]);
// Virtualizer
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => scrollContainerRef.current,
estimateSize: (index) => {
const row = rows[index];
if (row?.type === 'divider') return ESTIMATED_DIVIDER_HEIGHT;
if (row?.type === 'message' && row.message) return estimateMessageRowHeight(row.message);
return 60;
return ESTIMATED_ROW_HEIGHT;
},
overscan: 30,
overscan: 5,
gap: 0,
});
const virtualItems = virtualizer.getVirtualItems();
// IntersectionObserver for load more
// IntersectionObserver for load more (only when scrolled to top)
useEffect(() => {
const sentinel = topSentinelRef.current;
const container = scrollContainerRef.current;
@ -228,12 +220,16 @@ const RoomMessageListInner = memo(function RoomMessageListInner({
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting && !isLoadingMore && hasMore) {
prevScrollHeightRef.current = container.scrollHeight;
const items = virtualizer.getVirtualItems();
if (items.length > 0) {
const firstVisibleRow = rows[items[0].index];
if (firstVisibleRow?.type === 'message' && firstVisibleRow.message) {
firstVisibleMessageIdRef.current = firstVisibleRow.message.id;
const scrollContainer = scrollContainerRef.current;
if (scrollContainer) {
prevScrollHeightRef.current = scrollContainer.scrollHeight;
// Record the ID of the first visible message
const virtualItems = virtualizer.getVirtualItems();
if (virtualItems.length > 0) {
const firstVisibleRow = rows[virtualItems[0].index];
if (firstVisibleRow?.type === 'message' && firstVisibleRow.message) {
firstVisibleMessageIdRef.current = firstVisibleRow.message.id;
}
}
}
isRestoringScrollRef.current = true;
@ -245,23 +241,28 @@ const RoomMessageListInner = memo(function RoomMessageListInner({
observer.observe(sentinel);
return () => observer.disconnect();
}, [onLoadMore, hasMore, isLoadingMore, rows, virtualizer]);
}, [onLoadMore, hasMore, isLoadingMore, rows]);
// Maintain scroll position after loading more messages
useEffect(() => {
// Only run this effect when we're restoring scroll from a load-more operation
if (!isRestoringScrollRef.current) return;
const container = scrollContainerRef.current;
if (!container || prevScrollHeightRef.current === null) {
isRestoringScrollRef.current = false;
return;
}
const delta = container.scrollHeight - prevScrollHeightRef.current;
const newScrollHeight = container.scrollHeight;
const delta = newScrollHeight - prevScrollHeightRef.current;
// Method 1: Try to find the previously recorded first visible message
if (firstVisibleMessageIdRef.current) {
const el = container.querySelector(`[data-message-id="${firstVisibleMessageIdRef.current}"]`);
if (el) {
el.scrollIntoView({ block: 'start' });
const messageElement = container.querySelector(`[data-message-id="${firstVisibleMessageIdRef.current}"]`);
if (messageElement) {
// Use scrollIntoView to precisely scroll to the previously visible message
messageElement.scrollIntoView({ block: 'start' });
prevScrollHeightRef.current = null;
firstVisibleMessageIdRef.current = null;
isRestoringScrollRef.current = false;
@ -269,13 +270,22 @@ const RoomMessageListInner = memo(function RoomMessageListInner({
}
}
if (delta > 0) container.scrollTop += delta;
// Method 2: Fallback to the previous scrollHeight delta method
if (delta > 0) {
container.scrollTop += delta;
}
prevScrollHeightRef.current = null;
firstVisibleMessageIdRef.current = null;
isRestoringScrollRef.current = false;
}, [messages.length]);
}, [deferredMessages.length]);
// Report rendered count to parent (for performance monitor)
useEffect(() => {
onRenderedCountChange?.(virtualItems.length);
}, [virtualItems.length, onRenderedCountChange]);
// Empty state
if (messages.length === 0) {
return (
<div className="flex flex-1 items-center justify-center px-6 py-12">
@ -298,6 +308,7 @@ const RoomMessageListInner = memo(function RoomMessageListInner({
width: '100%',
}}
>
{/* Top sentinel for load more */}
<div ref={topSentinelRef} className="absolute top-0 h-1 w-full" />
{isLoadingMore && (
@ -334,8 +345,12 @@ const RoomMessageListInner = memo(function RoomMessageListInner({
return (
<div
key={row.key}
className="absolute left-0 top-0 w-full"
className="absolute left-0 w-full"
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualRow.start}px)`,
}}
>
@ -366,6 +381,7 @@ const RoomMessageListInner = memo(function RoomMessageListInner({
);
})}
{/* Bottom sentinel for auto-scroll */}
<div
ref={messagesEndRef}
className="absolute left-0 w-full"
@ -391,6 +407,3 @@ const RoomMessageListInner = memo(function RoomMessageListInner({
</div>
);
});
export { RoomMessageListInner };
export const RoomMessageList = RoomMessageListInner;

View File

@ -0,0 +1,153 @@
import { useState, useMemo, useEffect, useRef, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
interface PerformanceStats {
totalMessages: number;
renderedMessages: number;
virtualizationEnabled: boolean;
}
interface RoomPerformanceMonitorProps {
messageCount: number;
renderedCount?: number;
}
const AUTO_CLOSE_DELAY = 5000; // auto-close after 5 seconds when stats are shown
export function RoomPerformanceMonitor({ messageCount, renderedCount }: RoomPerformanceMonitorProps) {
const [showStats, setShowStats] = useState(false);
const autoCloseTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Auto-close after AUTO_CLOSE_DELAY when stats are visible
useEffect(() => {
if (!showStats) return;
autoCloseTimerRef.current = setTimeout(() => {
setShowStats(false);
}, AUTO_CLOSE_DELAY);
return () => {
if (autoCloseTimerRef.current) clearTimeout(autoCloseTimerRef.current);
};
}, [showStats]);
const stats = useMemo<PerformanceStats>(() => ({
totalMessages: messageCount,
renderedMessages: renderedCount ?? messageCount,
virtualizationEnabled: renderedCount !== undefined && renderedCount < messageCount,
}), [messageCount, renderedCount]);
// --- Drag state ---
const panelRef = useRef<HTMLDivElement>(null);
const draggingRef = useRef(false);
const dragStartRef = useRef({ x: 0, y: 0 });
const handleDragStart = useCallback((e: React.MouseEvent) => {
// Don't start drag if clicking the close button
if ((e.target as HTMLElement).closest('button')) return;
draggingRef.current = true;
dragStartRef.current = { x: e.clientX, y: e.clientY };
e.preventDefault();
}, []);
useEffect(() => {
const onMouseMove = (e: MouseEvent) => {
if (!draggingRef.current) return;
const panel = panelRef.current;
if (!panel) return;
const dx = e.clientX - dragStartRef.current.x;
const dy = e.clientY - dragStartRef.current.y;
const rect = panel.getBoundingClientRect();
panel.style.left = `${rect.left + dx}px`;
panel.style.top = `${rect.top + dy}px`;
panel.style.right = 'auto';
panel.style.bottom = 'auto';
dragStartRef.current = { x: e.clientX, y: e.clientY };
};
const onMouseUp = () => {
draggingRef.current = false;
};
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
return () => {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
};
}, []);
if (!showStats && import.meta.env.PROD) {
return (
<Button
variant="ghost"
size="sm"
className="fixed bottom-4 left-4 z-50 h-7 w-7 rounded-full p-0 opacity-50 hover:opacity-100"
onClick={() => setShowStats(true)}
title="Show performance stats"
>
📊
</Button>
);
}
return (
<div
ref={panelRef}
className="fixed z-50 rounded-lg border bg-background/95 p-3 shadow-lg backdrop-blur-sm select-none"
style={{ bottom: '1rem', left: '1rem' }}
>
{/* Draggable header */}
<div
className="mb-2 flex items-center justify-between cursor-grab active:cursor-grabbing select-none"
onMouseDown={handleDragStart}
>
<h4 className="text-xs font-semibold text-foreground">Performance Stats</h4>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0"
onClick={() => setShowStats(false)}
>
</Button>
</div>
<div className="space-y-1.5 text-xs">
<div className="flex items-center justify-between gap-4">
<span className="text-muted-foreground">Total messages:</span>
<Badge variant="secondary" className="font-mono">{stats.totalMessages}</Badge>
</div>
<div className="flex items-center justify-between gap-4">
<span className="text-muted-foreground">Rendered:</span>
<Badge variant="secondary" className="font-mono">{stats.renderedMessages}</Badge>
</div>
{stats.virtualizationEnabled && (
<div className="flex items-center justify-between gap-4">
<span className="text-muted-foreground">Skipped:</span>
<Badge variant="outline" className="font-mono text-green-600">
{stats.totalMessages - stats.renderedMessages}
</Badge>
</div>
)}
<div className="flex items-center justify-between gap-4">
<span className="text-muted-foreground">Virtualization:</span>
<Badge
variant={stats.virtualizationEnabled ? 'default' : 'destructive'}
className="text-[10px]"
>
{stats.virtualizationEnabled ? '✓ Enabled' : '✗ Disabled'}
</Badge>
</div>
{stats.virtualizationEnabled && (
<div className="pt-1 border-t border-border">
<span className="text-[10px] text-green-600">
Rendering {((stats.renderedMessages / stats.totalMessages) * 100).toFixed(0)}% of messages
</span>
</div>
)}
</div>
</div>
);
}

View File

@ -25,7 +25,6 @@ import {
type RoomMessagePayload,
type RoomCategoryResponse as WsRoomCategoryResponse,
type RoomReactionUpdatedPayload,
type ReactionListData,
} from '@/lib/room-ws-client';
import { requestWsToken } from '@/lib/ws-token';
import { useUser } from '@/contexts';
@ -33,8 +32,8 @@ import {
saveMessage,
saveMessages,
loadMessages as loadMessagesFromIdb,
loadOlderMessagesFromIdb,
deleteMessage as deleteMessageFromIdb,
clearRoomMessages,
} from '@/lib/storage/indexed-db';
export type { RoomWsStatus, RoomWsClient } from '@/lib/room-ws-client';
@ -222,6 +221,7 @@ export function RoomProvider({
useEffect(() => {
if (prevRoomIdRef.current !== activeRoomId) {
const oldRoomId = prevRoomIdRef.current;
prevRoomIdRef.current = activeRoomId;
loadMessagesAbortRef.current?.abort();
loadMessagesAbortRef.current = null;
@ -231,68 +231,37 @@ export function RoomProvider({
setMessages([]);
setIsHistoryLoaded(false);
setNextCursor(null);
// NOTE: intentionally NOT clearing IndexedDB — keeping it enables instant
// load when the user returns to this room without waiting for API.
// Clear old room's IDB cache asynchronously (fire and forget)
if (oldRoomId) {
clearRoomMessages(oldRoomId).catch(() => {});
}
}
}, [activeRoomId]);
const loadMoreRef = useRef<((cursor?: number | null) => Promise<void>) | null>(null);
useEffect(() => {
const client = wsClientRef.current;
if (!activeRoomId || !client) return;
const setup = async () => {
// IDB load does NOT need WS — show cached messages immediately.
// loadMore checks IDB first, then falls back to API (WS-first + HTTP).
loadMore(null);
// Connect WS in parallel for real-time push + reactions batch-fetch.
// connect() is idempotent — no-op if already connecting/open.
// subscribeRoom uses WS-first request() with HTTP fallback.
await client.connect();
if (activeRoomIdRef.current !== activeRoomId) return;
client.subscribeRoom(activeRoomId).catch(() => {});
if (client.getStatus() !== 'open') {
await client.connect();
}
// Re-check: activeRoomId may have changed while we were waiting for connect.
// Use activeRoomIdRef to get the *current* room, not the stale closure value.
const roomId = activeRoomIdRef.current;
if (!roomId) return;
await client.subscribeRoom(roomId);
loadMoreRef.current?.(null);
};
setup().catch(() => {});
setup();
return () => {
client.unsubscribeRoom(activeRoomId).catch(() => {});
};
}, [activeRoomId, wsClient]);
/**
* Fetch reactions for a batch of messages via WS (with HTTP fallback),
* then merge them into the messages state. Fires-and-forgets so it
* does not block the caller.
*/
const thisLoadReactions = (
roomId: string,
client: RoomWsClient,
msgs: MessageWithMeta[],
) => {
const msgIds = msgs.map((m) => m.id);
if (msgIds.length === 0) return;
client
.reactionListBatch(roomId, msgIds)
.then((reactionResults: ReactionListData[]) => {
const reactionMap = new Map<string, ReactionListData['reactions']>();
for (const result of reactionResults) {
if (result.reactions.length > 0) {
reactionMap.set(result.message_id, result.reactions);
}
}
if (reactionMap.size > 0) {
setMessages((prev) =>
prev.map((m) =>
reactionMap.has(m.id) ? { ...m, reactions: reactionMap.get(m.id) } : m,
),
);
}
})
.catch(() => {
// Non-fatal: WS push will keep reactions up to date
});
};
const loadMore = useCallback(
async (cursor?: number | null) => {
const client = wsClientRef.current;
@ -305,60 +274,31 @@ export function RoomProvider({
setIsLoadingMore(true);
try {
const isInitial = cursor === null || cursor === undefined;
const limit = isInitial ? 200 : 50;
// --- Initial load: try IndexedDB first for instant render ---
if (isInitial) {
// Initial load: check IndexedDB first for fast render
if (cursor === null || cursor === undefined) {
const cached = await loadMessagesFromIdb(activeRoomId);
if (cached.length > 0) {
setMessages(cached);
setIsTransitioningRoom(false);
// Derive cursor from IDB data (oldest message's seq = cursor)
const minSeq = cached[0].seq;
setNextCursor(minSeq > 0 ? minSeq - 1 : null);
// If IDB has data, skip API call — WS will push live updates
// Still set isLoadingMore to false and return
setIsLoadingMore(false);
// No API call needed — WS will push any new messages that arrived while away.
// Fetch reactions via WS (with HTTP fallback) so reactions appear without extra latency.
thisLoadReactions(activeRoomId, client, cached);
return;
}
}
// --- Load older history: try IDB first, then fall back to API ---
if (!isInitial && cursor != null) {
const idbMessages = await loadOlderMessagesFromIdb(activeRoomId, cursor, limit);
if (idbMessages.length > 0) {
setMessages((prev) => {
if (abortController.signal.aborted) return prev;
const existingIds = new Set(prev.map((m) => m.id));
const filtered = idbMessages.filter((m) => !existingIds.has(m.id));
let merged = [...filtered, ...prev];
merged.sort((a, b) => a.seq - b.seq);
if (merged.length > MAX_MESSAGES_IN_MEMORY) {
merged = merged.slice(-MAX_MESSAGES_IN_MEMORY);
}
return merged;
});
const oldest = idbMessages[0];
setNextCursor(oldest.seq > 0 ? oldest.seq - 1 : null);
if (idbMessages.length < limit) {
setIsHistoryLoaded(true);
}
setIsLoadingMore(false);
// Also fetch reactions for the IDB-loaded history messages.
thisLoadReactions(activeRoomId, client, idbMessages);
return;
}
// IDB empty for this range — fall through to API
}
// --- API fetch ---
// Call API (IDB was empty on initial load, or user is loading older history)
const resp = await client.messageList(activeRoomId, {
beforeSeq: cursor ?? undefined,
limit,
limit: 50,
});
if (abortController.signal.aborted) return;
if (abortController.signal.aborted) {
return;
}
const newMessages = resp.messages.map((m) => ({
...m,
@ -368,11 +308,17 @@ export function RoomProvider({
}));
setMessages((prev) => {
if (abortController.signal.aborted) return prev;
if (isInitial) {
// Double-check room hasn't changed
if (abortController.signal.aborted) {
return prev;
}
// If initial load (cursor=null), replace instead of merge (room switching)
if (cursor === null || cursor === undefined) {
// Clear transitioning state
setIsTransitioningRoom(false);
return newMessages;
}
// loadMore: prepend older messages before existing
const existingIds = new Set(prev.map((m) => m.id));
const filtered = newMessages.filter((m) => !existingIds.has(m.id));
let merged = [...filtered, ...prev];
@ -383,19 +329,44 @@ export function RoomProvider({
return merged;
});
// Persist new messages to IndexedDB
if (newMessages.length > 0) {
saveMessages(activeRoomId, newMessages).catch(() => {});
}
if (resp.messages.length < limit) {
if (resp.messages.length < 50) {
setIsHistoryLoaded(true);
}
// messages are in ascending order (oldest first), so [length-1] is newest
setNextCursor(resp.messages.length > 0 ? resp.messages[resp.messages.length - 1].seq : null);
// Fetch reactions for all loaded messages (WS-first with HTTP fallback)
thisLoadReactions(activeRoomId, client, newMessages);
// Fetch reactions for all loaded messages (backend may not support this yet)
const msgIds = newMessages.map((m) => m.id);
if (msgIds.length > 0) {
try {
const reactionResults = await client.reactionListBatch(activeRoomId, msgIds);
const reactionMap = new Map<string, import('@/lib/room-ws-client').ReactionItem[]>();
for (const result of reactionResults) {
if (result.reactions.length > 0) {
reactionMap.set(result.message_id, result.reactions);
}
}
if (reactionMap.size > 0) {
setMessages((prev) =>
prev.map((m) =>
reactionMap.has(m.id) ? { ...m, reactions: reactionMap.get(m.id) } : m,
),
);
}
} catch {
// Reactions will be loaded via WebSocket updates if backend supports it
}
}
} catch (error) {
if (abortController.signal.aborted) return;
// Ignore abort errors
if (abortController.signal.aborted) {
return;
}
handleRoomError('Load messages', error);
} finally {
setIsLoadingMore(false);
@ -405,6 +376,10 @@ export function RoomProvider({
[activeRoomId],
);
useEffect(() => {
loadMoreRef.current = loadMore;
}, [loadMore]);
const [members, setMembers] = useState<RoomMemberResponse[]>([]);
const [membersLoading, setMembersLoading] = useState(false);
@ -424,20 +399,8 @@ export function RoomProvider({
// Use ref to get current activeRoomId to avoid stale closure
if (payload.room_id === activeRoomIdRef.current) {
setMessages((prev) => {
// Check if this is a reaction-update event (same ID, different reactions).
// publish_reaction_event sends RoomMessageEvent with reactions field set.
const existingIdx = prev.findIndex((m) => m.id === payload.id);
if (existingIdx !== -1) {
// Message already exists — update reactions if provided.
// Reaction events have empty content/sender_type.
if (payload.reactions !== undefined) {
const updated = [...prev];
updated[existingIdx] = { ...updated[existingIdx], reactions: payload.reactions };
const msg = updated[existingIdx];
saveMessage(msg).catch(() => {});
return updated;
}
// Duplicate of a real message — ignore
// Deduplicate by both ID (for normal) and seq (for optimistic replacement)
if (prev.some((m) => m.id === payload.id)) {
return prev;
}
// Also check if there's an optimistic message with the same seq that should be replaced
@ -517,18 +480,15 @@ export function RoomProvider({
}
},
onRoomReactionUpdated: (payload: RoomReactionUpdatedPayload) => {
// Guard: ignore events for rooms that are no longer active.
// Without this, a WS event arriving after room switch could update
// the wrong room's message list (same message ID, different room).
if (!activeRoomIdRef.current) return;
setMessages((prev) => {
const existingIdx = prev.findIndex((m) => m.id === payload.message_id);
if (existingIdx === -1) return prev;
const updated = [...prev];
updated[existingIdx] = { ...updated[existingIdx], reactions: payload.reactions };
const updated = prev.map((m) =>
m.id === payload.message_id
? { ...m, reactions: payload.reactions }
: m,
);
// Persist reaction update to IndexedDB
saveMessage(updated[existingIdx]).catch(() => {});
const msg = updated.find((m) => m.id === payload.message_id);
if (msg) saveMessage(msg).catch(() => {});
return updated;
});
},

553
src/hooks/useRoomWs.ts Normal file
View File

@ -0,0 +1,553 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { toast } from 'sonner';
import {
type AiStreamChunkPayload,
type RoomMessagePayload,
type RoomWsStatus,
type WsOutEvent,
} from '@/lib/room';
import { requestWsToken, buildWsUrlWithToken } from '@/lib/ws-token';
import { client } from '@/client/client.gen';
import type { AxiosResponse } from 'axios';
import type { RoomMemberResponse } from '@/client';
const RECONNECT_BASE_DELAY = 1_000;
const RECONNECT_MAX_DELAY = 15_000;
/** A message as held in the UI state */
export type UiMessage = RoomMessagePayload & {
/** Set while the server is streaming an AI reply into this message */
is_streaming?: boolean;
/** Accumulated streaming content; flushed to content on `done: true` */
streaming_content?: string;
/** Display name resolved from sender_id; undefined if not yet resolved */
display_name?: string;
/** Avatar URL resolved from members list */
avatar_url?: string;
/** For optimistic UI: message failed to send */
isOptimisticError?: boolean;
/** Reply to message ID */
in_reply_to?: string | null;
/** Edited timestamp */
edited_at?: string | null;
/** Revoked timestamp */
revoked?: string | null;
/** Revoked by user ID */
revoked_by?: string | null;
};
type RoomMessageCacheEntry = {
messages: UiMessage[];
isHistoryLoaded: boolean;
/** seq of the latest message, used as cursor for pagination */
nextCursor: number | null;
};
interface MessageListResponse {
code: number;
message: string;
data: { messages: RestMessage[]; total: number };
}
/** REST message shape (matches RoomMessageResponse from the SDK) */
interface RestMessage {
id: string;
seq: number;
room: string;
sender_type: string;
sender_id?: string | null;
display_name?: string | null;
thread?: string | null;
in_reply_to?: string | null;
content: string;
content_type: string;
edited_at?: string | null;
send_at: string;
revoked?: string | null;
revoked_by?: string | null;
}
/** Display name and avatar URL resolved from a message's sender */
interface SenderInfo {
displayName: string;
avatarUrl: string | undefined;
}
/** Resolve displayName and avatar URL for a message sender.
* - AI messages: use sender_id (the model UUID)
* - User messages: look up user_info from members list, fall back to sender_id */
function resolveSender(payload: RoomMessagePayload, members: RoomMemberResponse[]): SenderInfo {
if (payload.sender_type === 'ai') {
return { displayName: payload.sender_id ?? 'AI', avatarUrl: undefined };
}
if (payload.sender_id) {
const member = members.find((m) => m.user === payload.sender_id);
if (member) {
const username = member.user_info?.username ?? member.user;
return { displayName: username, avatarUrl: member.user_info?.avatar_url ?? undefined };
}
}
if (payload.sender_type === 'system') return { displayName: 'System', avatarUrl: undefined };
return { displayName: payload.sender_type, avatarUrl: undefined };
}
function compareMessages(a: UiMessage, b: UiMessage): number {
const timeDiff = new Date(a.send_at).getTime() - new Date(b.send_at).getTime();
return timeDiff !== 0 ? timeDiff : a.id.localeCompare(b.id);
}
function insertSorted(arr: UiMessage[], msg: UiMessage): UiMessage[] {
const result = [...arr];
let lo = 0;
let hi = result.length;
while (lo < hi) {
const mid = (lo + hi) >>> 1;
if (compareMessages(result[mid], msg) < 0) lo = mid + 1;
else hi = mid;
}
result.splice(lo, 0, msg);
return result;
}
export interface UseRoomWsOptions {
/** VITE_API_BASE_URL value (without /ws suffix) */
baseUrl: string;
/** Currently open room ID */
roomId: string | null;
/** Limit for initial history load */
historyLimit?: number;
/** Room members, used to resolve display_name for user messages */
members?: RoomMemberResponse[];
/** Called when the AI streaming chunk arrives */
onAiStreamChunk?: (payload: AiStreamChunkPayload) => void;
}
export interface UseRoomWsReturn {
messages: UiMessage[];
status: RoomWsStatus;
errorMessage: string | null;
isHistoryLoaded: boolean;
isLoadingMore: boolean;
nextCursor: number | null;
/** Load older messages (called when user scrolls to top) */
loadMore: (cursor?: number | null) => void;
}
/**
* Manages a WebSocket connection for a single room.
*
* Features:
* - Auto-reconnect with exponential back-off
* - Per-room message cache so switching rooms preserves scroll position
* - AI streaming chunk accumulation via `streaming_content`
* - `loadMore` for cursor-based history pagination
*/
export function useRoomWs({
baseUrl,
roomId,
historyLimit = 50,
members = [],
onAiStreamChunk,
}: UseRoomWsOptions): UseRoomWsReturn {
const [messages, setMessages] = useState<UiMessage[]>([]);
const [status, setStatus] = useState<RoomWsStatus>('idle');
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isHistoryLoaded, setIsHistoryLoaded] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [nextCursor, setNextCursor] = useState<number | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const activeRoomIdRef = useRef<string | null>(null);
const shouldReconnectRef = useRef(true);
const reconnectAttemptRef = useRef(0);
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const wsTokenRef = useRef<string | null>(null);
const roomCacheRef = useRef<Map<string, RoomMessageCacheEntry>>(new Map());
/** Ref to current messages for use inside event handlers */
const messagesRef = useRef<UiMessage[]>([]);
messagesRef.current = messages;
/** Ref to current nextCursor */
const nextCursorRef = useRef<number | null>(null);
nextCursorRef.current = nextCursor;
/** Ref to members, used for display_name resolution */
const membersRef = useRef<RoomMemberResponse[]>([]);
membersRef.current = members;
/** Ref for AI streaming RAF batch */
const streamingBatchRef = useRef<Map<string, { content: string; done: boolean; room_id: string }>>(new Map());
const streamingRafRef = useRef<number | null>(null);
/** Flush streaming batch to state */
const flushStreamingBatch = useCallback(() => {
const batch = streamingBatchRef.current;
if (batch.size === 0) return;
setMessages((prev) => {
const next = [...prev];
let changed = false;
for (const [messageId, chunk] of batch) {
const idx = next.findIndex((m) => m.id === messageId);
if (idx === -1) {
const placeholder: UiMessage = {
id: messageId,
room_id: chunk.room_id ?? next.find(() => true)?.room_id ?? '',
sender_type: 'ai',
content: chunk.done ? chunk.content : '',
content_type: 'text',
send_at: new Date().toISOString(),
seq: 0,
display_name: 'AI',
is_streaming: !chunk.done,
streaming_content: chunk.done ? undefined : chunk.content,
};
next.push(placeholder);
changed = true;
} else {
const updated = { ...next[idx] };
if (chunk.done) {
updated.is_streaming = false;
updated.content = chunk.content;
updated.streaming_content = undefined;
} else {
updated.is_streaming = true;
updated.streaming_content = (updated.streaming_content ?? updated.content) + chunk.content;
}
next[idx] = updated;
changed = true;
}
}
return changed ? next : prev;
});
streamingBatchRef.current.clear();
streamingRafRef.current = null;
}, []);
useEffect(() => {
const room = activeRoomIdRef.current;
if (!room) return;
roomCacheRef.current.set(room, {
messages,
isHistoryLoaded,
nextCursor,
});
}, [messages, isHistoryLoaded, nextCursor]);
const connectWs = useCallback(
async (roomUid: string) => {
if (!shouldReconnectRef.current || activeRoomIdRef.current !== roomUid) return;
// Build URL with token if available
const url = buildWsUrlWithToken(baseUrl, `/ws/rooms/${roomUid}`, wsTokenRef.current);
console.debug('[useRoomWs] connecting to', url, { baseUrl, roomUid });
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
setStatus('connecting');
setErrorMessage(null);
const ws = new WebSocket(url);
wsRef.current = ws;
ws.onopen = () => {
if (activeRoomIdRef.current !== roomUid) return;
reconnectAttemptRef.current = 0;
setStatus('open');
setErrorMessage(null);
console.debug('[useRoomWs] ws opened for room', roomUid);
};
ws.onmessage = (ev: MessageEvent<string>) => {
if (activeRoomIdRef.current !== roomUid) return;
let event: WsOutEvent;
try {
event = JSON.parse(ev.data) as WsOutEvent;
} catch {
console.warn('[useRoomWs] parse error, data:', ev.data);
setErrorMessage('Invalid WebSocket message');
return;
}
if ('error' in event && event.error) {
console.warn('[useRoomWs] error event:', event.error);
setErrorMessage(event.error);
return;
}
if (!('event' in event) || !event.event) {
console.warn('[useRoomWs] no event field, raw:', event);
return;
}
console.debug('[useRoomWs] received event type:', event.event.type, event.event);
switch (event.event.type) {
case 'room_message': {
// Backend sends payload flat on event.event (no data wrapper); also support nested data
const raw = (event.event as any);
const incoming: RoomMessagePayload = raw.data ?? raw;
console.debug('[useRoomWs] room_message:', incoming.id, incoming.content);
// Use Set for O(1) duplicate check instead of O(n) Array.some
const existingIds = new Set(messagesRef.current.map((m) => m.id));
if (existingIds.has(incoming.id)) {
console.debug('[useRoomWs] duplicate message, skipping');
return;
}
const sender = resolveSender(incoming, membersRef.current);
const display_name = incoming.display_name ?? sender.displayName;
const avatar_url = sender.avatarUrl;
setMessages((prev) =>
insertSorted(prev, { ...incoming, display_name, avatar_url }),
);
break;
}
case 'ai_stream_chunk': {
const raw = event.event as any;
const chunk: AiStreamChunkPayload = raw.data ?? raw;
onAiStreamChunk?.(chunk);
// Batch streaming chunks using RAF to reduce re-render frequency
streamingBatchRef.current.set(chunk.message_id, {
content: chunk.content,
done: chunk.done,
room_id: chunk.room_id,
});
if (streamingRafRef.current == null) {
streamingRafRef.current = requestAnimationFrame(() => {
flushStreamingBatch();
});
}
break;
}
default:
break;
}
};
ws.onclose = (ev: CloseEvent) => {
console.debug('[useRoomWs] WebSocket closed', { code: ev.code, reason: ev.reason, wasClean: ev.wasClean });
const activeSocket = wsRef.current;
if (activeSocket !== ws) return;
wsRef.current = null;
if (activeRoomIdRef.current !== roomUid) return;
setStatus('closed');
if (shouldReconnectRef.current) {
const attempt = ++reconnectAttemptRef.current;
const delay = Math.min(RECONNECT_BASE_DELAY * 2 ** (attempt - 1), RECONNECT_MAX_DELAY);
reconnectTimeoutRef.current = setTimeout(() => connectWs(roomUid), delay);
}
};
ws.onerror = (ev: Event) => {
console.error('[useRoomWs] WebSocket error', ev);
if (activeRoomIdRef.current !== roomUid) return;
setErrorMessage('WebSocket error');
};
},
[baseUrl, onAiStreamChunk],
);
useEffect(() => {
const prevRoom = activeRoomIdRef.current;
if (!roomId) {
// Disconnect
activeRoomIdRef.current = null;
shouldReconnectRef.current = false;
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
setMessages([]);
setStatus('idle');
setErrorMessage(null);
setIsHistoryLoaded(false);
setNextCursor(null);
return;
}
// Save previous room's state
if (prevRoom && prevRoom !== roomId) {
roomCacheRef.current.set(prevRoom, {
messages: messagesRef.current,
isHistoryLoaded,
nextCursor: nextCursorRef.current,
});
}
activeRoomIdRef.current = roomId;
shouldReconnectRef.current = true;
reconnectAttemptRef.current = 0;
// Fetch WS token before connecting
const connectWithToken = async () => {
try {
const token = await requestWsToken();
wsTokenRef.current = token;
} catch (error) {
console.warn('[useRoomWs] Failed to fetch WS token, falling back to cookie auth:', error);
wsTokenRef.current = null;
}
// Restore from cache or start fresh
const cached = roomCacheRef.current.get(roomId);
if (cached) {
setMessages(cached.messages);
setIsHistoryLoaded(cached.isHistoryLoaded);
setNextCursor(cached.nextCursor);
} else {
setMessages([]);
setIsHistoryLoaded(false);
setNextCursor(null);
// Load initial history via REST (WS is push-only, can't request history)
if (roomId) {
client
.get({ url: `/api/rooms/${roomId}/messages`, params: { limit: historyLimit } })
.then((resp) => {
const r = resp as AxiosResponse<MessageListResponse>;
if (activeRoomIdRef.current !== roomId) return;
const msgs = (r.data?.data?.messages ?? []).map((m) => {
const sender = resolveSender({ ...m, room_id: m.room } as RoomMessagePayload, members);
const display_name = m.display_name ?? sender.displayName;
const avatar_url = sender.avatarUrl;
return {
...m,
room_id: m.room,
thread_id: m.thread ?? null,
display_name,
avatar_url,
};
});
setMessages(msgs);
setNextCursor(msgs.length > 0 ? msgs[msgs.length - 1].seq : null);
setIsHistoryLoaded(msgs.length < historyLimit);
})
.catch(() => {
if (activeRoomIdRef.current !== roomId) return;
toast.error('Failed to load message history');
setIsHistoryLoaded(true);
});
}
}
// Close other connections (shouldn't be any in practice)
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
connectWs(roomId);
};
connectWithToken();
return () => {
// Save state before unmounting
const room = activeRoomIdRef.current;
if (room) {
roomCacheRef.current.set(room, {
messages: messagesRef.current,
isHistoryLoaded: isHistoryLoaded,
nextCursor: nextCursorRef.current,
});
}
shouldReconnectRef.current = false;
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
if (streamingRafRef.current != null) {
cancelAnimationFrame(streamingRafRef.current);
streamingRafRef.current = null;
}
activeRoomIdRef.current = null;
};
}, [roomId, connectWs]);
const loadMore = useCallback(
async (cursor?: number | null) => {
if (!roomId || isLoadingMore) return;
// Use REST API for history pagination — WS is push-only
const effectiveCursor = cursor ?? nextCursorRef.current;
if (effectiveCursor == null) return;
setIsLoadingMore(true);
try {
const resp = await client.get({ url: `/api/rooms/${roomId}/messages`, params: { before_seq: effectiveCursor, limit: historyLimit } }) as AxiosResponse<MessageListResponse>;
const older = (resp.data?.data?.messages ?? []).map((m) => {
const sender = resolveSender({ ...m, room_id: m.room } as RoomMessagePayload, membersRef.current);
const display_name = m.display_name ?? sender.displayName;
const avatar_url = sender.avatarUrl;
return {
...m,
room_id: m.room,
thread_id: m.thread ?? null,
display_name,
avatar_url,
};
});
if (older.length === 0) {
setIsHistoryLoaded(true);
return;
}
// Prepend older messages (they arrive in ascending seq order)
setMessages((prev) => {
const existingIds = new Set(prev.map((m) => m.id));
const newOnes = older.filter((m) => !existingIds.has(m.id));
if (newOnes.length === 0) {
setIsHistoryLoaded(true);
return prev;
}
// New cursor = smallest seq among loaded messages
const newCursor = newOnes[newOnes.length - 1].seq;
setNextCursor(newCursor > 0 ? newCursor : null);
return [...newOnes, ...prev];
});
} catch {
// Non-critical — show toast so user knows the load failed
toast.error('Failed to load more messages');
setIsHistoryLoaded(true);
} finally {
setIsLoadingMore(false);
}
},
[roomId, historyLimit, isLoadingMore],
);
return useMemo(
() => ({
messages,
status,
errorMessage,
isHistoryLoaded,
isLoadingMore,
nextCursor,
loadMore,
}),
[messages, status, errorMessage, isHistoryLoaded, isLoadingMore, nextCursor, loadMore],
);
}

View File

@ -133,7 +133,7 @@ export class RoomWsClient {
return new Set(this.subscribedProjects);
}
async connect(forceNewToken = false): Promise<void> {
async connect(): Promise<void> {
if (this.ws && this.status === 'open') {
return;
}
@ -141,31 +141,28 @@ export class RoomWsClient {
this.shouldReconnect = true;
this.setStatus('connecting');
// Fetch a fresh token unless we have a valid existing one and not forcing.
// When forceNewToken=false (reconnect path), try existing token first.
if (forceNewToken || !this.wsToken) {
try {
const tokenResp = await fetch(`${this.baseUrl}/api/ws/token`, {
method: 'POST',
credentials: 'include',
});
if (!tokenResp.ok) {
const text = await tokenResp.text().catch(() => '');
console.error(`[RoomWs] Token fetch failed: ${tokenResp.status} ${tokenResp.statusText}${text}`);
throw new Error(`Token fetch failed: ${tokenResp.status}`);
}
const tokenData = await tokenResp.json();
this.wsToken = tokenData.data?.token || null;
if (!this.wsToken) {
console.error('[RoomWs] Token is empty — not logged in?');
throw new Error('No WS token received');
}
} catch (err) {
console.error('[RoomWs] Failed to fetch WS token:', err);
this.setStatus('error');
this.callbacks.onError?.(err instanceof Error ? err : new Error(String(err)));
throw err;
// Fetch a fresh token for each connection attempt (backend consumes token on use)
try {
const tokenResp = await fetch(`${this.baseUrl}/api/ws/token`, {
method: 'POST',
credentials: 'include',
});
if (!tokenResp.ok) {
const text = await tokenResp.text().catch(() => '');
console.error(`[RoomWs] Token fetch failed: ${tokenResp.status} ${tokenResp.statusText}${text}`);
throw new Error(`Token fetch failed: ${tokenResp.status}`);
}
const tokenData = await tokenResp.json();
this.wsToken = tokenData.data?.token || null;
if (!this.wsToken) {
console.error('[RoomWs] Token is empty — not logged in?');
throw new Error('No WS token received');
}
} catch (err) {
console.error('[RoomWs] Failed to fetch WS token:', err);
this.setStatus('error');
this.callbacks.onError?.(err instanceof Error ? err : new Error(String(err)));
throw err;
}
const wsUrl = this.buildWsUrl();
@ -175,12 +172,6 @@ export class RoomWsClient {
// Guard: if ws is closed before handlers are set, skip
if (this.ws.readyState === WebSocket.CLOSED || this.ws.readyState === WebSocket.CLOSING) {
console.warn('[RoomWs] WebSocket closed immediately');
// If we used an existing token and it was immediately rejected, retry with a new token
if (!forceNewToken && this.wsToken) {
console.debug('[RoomWs] Existing token rejected — fetching new token and retrying');
this.wsToken = null;
return this.connect(true);
}
return;
}

View File

@ -183,44 +183,6 @@ export async function clearRoomMessages(roomId: string): Promise<void> {
}
}
/** Load older messages from IDB (seq < beforeSeq), sorted ascending, up to `limit` */
export async function loadOlderMessagesFromIdb(
roomId: string,
beforeSeq: number,
limit = 50,
): Promise<MessageWithMeta[]> {
try {
const db = await openDB();
const tx = db.transaction(STORE_MESSAGES, 'readonly');
const index = tx.objectStore(STORE_MESSAGES).index('by_room_seq');
// Compound key range: roomId + any seq less than beforeSeq
const range = IDBKeyRange.bound([roomId, 0], [roomId, beforeSeq - 1]);
const request = index.openCursor(range, 'prev'); // 'prev' = descending seq (newest first)
// We want oldest before `beforeSeq`, so after getting `limit` items in 'prev' order,
// reverse to ascending seq.
const collected: StoredMessage[] = [];
return new Promise((resolve, reject) => {
request.onsuccess = () => {
const cursor = request.result;
if (cursor && collected.length < limit) {
collected.push(cursor.value);
cursor.continue();
} else {
// Reverse back to ascending order (oldest first)
const msgs = collected.reverse().map(storedToMsg);
resolve(msgs);
}
};
request.onerror = () => reject(request.error);
});
} catch (err) {
console.warn('[IDB] loadOlderMessagesFromIdb failed:', err);
return [];
}
}
/** Get the highest seq number for a room (for dedup) */
export async function getMaxSeq(roomId: string): Promise<number> {
try {

View File

@ -166,8 +166,6 @@ export interface RoomMessagePayload {
send_at: string;
seq: number;
display_name?: string;
/** Present when this event carries reaction updates for the message */
reactions?: ReactionItem[];
}
export interface ProjectEventPayload {