Compare commits

...

19 Commits

Author SHA1 Message Date
ZhenYi
a171d691c6 fix(room): align ReactionGroup types with frontend and guard reaction update handler
Some checks are pending
CI / Rust Lint & Check (push) Waiting to run
CI / Rust Tests (push) Waiting to run
CI / Frontend Lint & Type Check (push) Waiting to run
CI / Frontend Build (push) Blocked by required conditions
- Fix ReactionGroup.count: i64 -> i32 and users: Vec<Uuid> -> Vec<String>
  to match frontend ReactionItem (count: number, users: string[]).
  Mismatched types caused the WS reaction update to silently fail.
  Also update ReactionItem in api/ws_types.rs to match.
- Add activeRoomIdRef guard in onRoomReactionUpdated to prevent stale
  room state from processing outdated events after room switch.
- Switch from prev.map() to targeted findIndex+spread in onRoomReactionUpdated
  to avoid unnecessary array recreation.
2026-04-17 23:00:52 +08:00
ZhenYi
047782e585 fix(room): handle reaction updates in onRoomMessage WS handler
Root cause: publish_reaction_event sends a RoomMessageEvent (not
reaction_added) with reactions field set. The onRoomMessage handler
previously returned prev immediately when a duplicate ID was found,
skipping the reaction update entirely.

Fix:
- Add reactions field to RoomMessagePayload so TypeScript knows it's there
- When a duplicate message ID is found AND payload carries reactions,
  update the existing message's reactions field instead of ignoring
- ReactionItem and ReactionGroup have identical shapes, so assignment works
2026-04-17 22:37:20 +08:00
ZhenYi
0cbf6d6aa1 fix(room): accept comma-separated message_ids string in batch reaction endpoint 2026-04-17 22:28:24 +08:00
ZhenYi
7152346be8 fix(room): make reaction Popover controlled so it closes after select 2026-04-17 22:16:40 +08:00
ZhenYi
a1ddb5d5bc fix(room): add HTTP batch reactions endpoint and clean up dead code
Backend:
- Add reaction_batch handler: GET /api/rooms/{room_id}/messages/reactions/batch?message_ids=...
- Register route in libs/api/room/mod.rs
- Backend already had message_reactions_batch service method, just needed HTTP exposure

Frontend:
- Add ReactionListData import to room-context.tsx
- Fix thisLoadReactions client type (was using broken NonNullable<ReturnType<>>)
- Remove unused oldRoomId variable
- Delete unused useRoomWs.ts hook (RoomWsClient has no on/off methods)
- Remove unused EmojiPicker function and old manual overlay picker from RoomMessageBubble
- Remove unused savedToken variable in room-ws-client
2026-04-17 22:12:10 +08:00
ZhenYi
2f1ed69b31 fix(room): restore useState import removed in refactor 2026-04-17 22:02:55 +08:00
ZhenYi
ef1adb663d fix(room): replace manual emoji picker positioning with Popover
Manual getBoundingClientRect positioning caused the picker to appear at
the far right of the room and shift content. Replaced with shadcn
Popover which handles anchor positioning, flipping, and portal rendering
automatically.
2026-04-17 21:50:50 +08:00
ZhenYi
4767e1d692 fix(room): IDB load no longer waits for WS connection
loadMore(null) fires immediately — cached messages render instantly.
WS connect runs in parallel; subscribeRoom and reaction batch-fetch
use WS-first request() which falls back to HTTP transparently.
2026-04-17 21:45:55 +08:00
ZhenYi
44c9f2c772 fix(room): ensure WS connect before subscribe; reactions load for IDB path
- setup() now awaits client.connect() before subscribeRoom and loadMore,
  ensuring the connection is truly open so WS is used for both
- subscribeRoom / reactionListBatch: already WS-first via RoomWsClient.request()
- IDB paths (initial + loadMore) now call thisLoadReactions to batch-fetch
  reactions via WS with HTTP fallback, fixing the missing reactions bug
2026-04-17 21:44:45 +08:00
ZhenYi
50f9cc40fe fix(room): load reactions for IDB-cached messages via WS-first fallback
- thisLoadReactions helper: batch-fetches reactions for loaded messages
  via WS (RoomWsClient.request() does WS-first → HTTP fallback automatically)
- Called after both IDB paths (initial load + loadMore) so reactions are
  populated even when messages come from IndexedDB cache
- Also deduplicated API-path reaction loading to use the same helper
2026-04-17 21:43:47 +08:00
ZhenYi
b70d91866c fix(room-ws): try reconnect with existing token before requesting new
On auto-reconnect (scheduleReconnect), attempt connection with the stored
wsToken first. If the WS closes immediately (server rejected the token),
fall back to fetching a fresh token and retrying. Only requests a new
token when the existing one fails or when connect() is called manually
with forceNewToken=true.
2026-04-17 21:40:07 +08:00
ZhenYi
309bc50e86 perf(room): prioritize IndexedDB cache for instant loads
- Remove clearRoomMessages on room switch: IDB cache now persists across
  visits, enabling instant re-entry without API round-trip
- Increase initial API limit from 50 → 200: more messages cached upfront
- Add loadOlderMessagesFromIdb: uses 'by_room_seq' compound index to
  serve scroll-back history from IDB without API call
- loadMore now tries IDB first before falling back to API
2026-04-17 21:36:55 +08:00
ZhenYi
bab675cf60 perf(room): increase virtualizer overscan to 30 for smoother scrolling 2026-04-17 21:31:36 +08:00
ZhenYi
5cd4c66445 perf(room): simplify scroll handler and stabilize callback refs
- Remove useTransition/useDeferredValue from RoomMessageList
- Wrap component in memo to prevent unnecessary re-renders
- Use requestAnimationFrame to defer scroll state updates
- Remove isUserScrolling state (no longer needed)
- Simplify auto-scroll effect: sync distance check + RAF deferred scroll
- Add replyMap memo to decouple reply lookup from row computation
- Stabilize handleEditConfirm to depend on editingMessage?.id only
- Remove Performance Stats panel (RoomPerformanceMonitor)
2026-04-17 21:28:58 +08:00
ZhenYi
991d86237b fix: remove stale onRenderedCountChange prop from RoomMessageList usage 2026-04-17 21:23:03 +08:00
ZhenYi
70381006cf chore(room): remove Performance Stats panel
Unused debug overlay that was tracking virtualized row counts.
2026-04-17 21:21:59 +08:00
ZhenYi
f2a2ae5d7f fix(room): use WS for message fetching, eliminate duplicate WS connection
- Fix initial room load being skipped: `setup()` called `loadMoreRef.current`
  which was null on first mount (ref assigned in later effect). Call `loadMore`
  directly so the initial fetch always fires. WS message.list used when
  connected, HTTP fallback otherwise.
- Rewrite useRoomWs to use shared RoomWsClient instead of creating its own
  raw WebSocket, eliminating duplicate WS connection per room.
- Remove dead loadMoreRef now that setup calls loadMore directly.
2026-04-17 21:18:56 +08:00
ZhenYi
cf5c728286 fix(room): fix scrolling lag, N+1 queries, and multiple WS token requests
Frontend:
- P0: Replace constant estimateSize(40px) with content-based estimation
  using line count and reply presence for accurate virtual list scroll
- P1: Replace Shadow DOM custom elements with styled spans for @mentions,
  eliminating expensive attachShadow calls per mention instance
- P1: Remove per-message ResizeObserver (one per bubble), replace with
  static inline toolbar layout to avoid observer overhead
- P2: Fix WS token re-fetch on every room switch by preserving token
  across navigation and not clearing activeRoomIdRef on cleanup

Backend:
- P1: Fix reaction check+insert race condition by moving into transaction
  instead of separate query + on-conflict insert
- P2: Fix N+1 queries in get_mention_notifications with batch fetch
  for users and rooms using IN clauses
- P2: Update room_last_activity in broadcast_stream_chunk to prevent
  idle room cleanup during active AI streaming
- P3: Use enum comparison instead of to_string() in room_member_leave
2026-04-17 21:08:40 +08:00
ZhenYi
60d8c3a617 fix(room): resolve remaining defects from second review
- reaction.rs: query before insert to detect new vs duplicate reactions,
  only publish Redis event when a reaction was actually added
- room.rs: delete Redis seq key on room deletion to prevent seq
  collision on re-creation
- message.rs: use Redis-atomic next_room_message_seq_internal for
  concurrent safety; look up sender display name once for both
  mention notifications and response body; add warn log when
  should_ai_respond fails instead of silent unwrap_or(false)
- ws_universal.rs: re-check room access permission when re-subscribing
  dead streams after error to prevent revoked permissions being bypassed
- RoomChatPanel.tsx: truncate reply preview content to 80 chars
- RoomMessageList.tsx: remove redundant inline style on message row div
2026-04-17 20:28:45 +08:00
23 changed files with 597 additions and 1376 deletions

View File

@ -118,6 +118,11 @@ 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,6 +18,12 @@ 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",
@ -119,6 +125,43 @@ 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: i64,
pub count: i32,
pub reacted_by_me: bool,
pub users: Vec<Uuid>,
pub users: Vec<String>,
}
#[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, user_id) => {
push_event = poll_push_streams(&mut push_streams, &manager, &handler.service(), user_id) => {
match push_event {
Some(WsPushEvent::RoomMessage { room_id, event }) => {
let payload = serde_json::json!({
@ -372,6 +372,7 @@ 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 {
@ -412,16 +413,21 @@ async fn poll_push_streams(
}
}
// Re-subscribe dead rooms so we don't permanently lose events
// 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.
for room_id in dead_rooms {
if streams.remove(&room_id).is_some() {
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 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 access check fails, silently skip re-subscribe (user was removed)
}
}

View File

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

View File

@ -590,6 +590,12 @@ 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,44 +124,73 @@ impl RoomService {
.all(&self.db)
.await?;
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 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 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()
};
// 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 mentioned_by_name = mentioned_by
.map(|u| u.display_name.unwrap_or(u.username))
.unwrap_or_else(|| "Unknown User".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 content_preview = notification
.content
.unwrap_or_default()
.chars()
.take(100)
.collect();
let room_name = notification
.room
.and_then(|rid| rooms.get(&rid))
.cloned()
.unwrap_or_else(|| "Unknown Room".to_string());
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,
});
}
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();
Ok(result)
}

View File

@ -257,24 +257,6 @@ 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(room_id, &self.db).await?;
let seq = Self::next_room_message_seq_internal(room_id, &self.db, &self.cache).await?;
let now = Utc::now();
let id = Uuid::now_v7();
let project_id = room_model.project;
@ -207,6 +207,17 @@ 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;
@ -215,7 +226,7 @@ impl RoomService {
.notification_create(super::NotificationCreateRequest {
notification_type: super::NotificationType::Mention,
user_id: mentioned_user_id,
title: format!("{} 在 {} 中提到了你", user_id, room_model.room_name),
title: format!("{} 在 {} 中提到了你", sender_display_name, room_model.room_name),
content: Some(content.clone()),
room_id: Some(room_id),
project_id,
@ -228,7 +239,13 @@ impl RoomService {
.await;
}
let should_respond = self.should_ai_respond(room_id).await.unwrap_or(false);
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 is_text_message = request
.content_type
.as_ref()
@ -243,23 +260,13 @@ 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,
display_name: Some(sender_display_name),
thread: thread_id,
in_reply_to,
content: request.content,

View File

@ -6,15 +6,14 @@ 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: i64,
pub count: i32,
pub reacted_by_me: bool,
pub users: Vec<Uuid>,
pub users: Vec<String>,
}
#[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)]
@ -45,6 +44,21 @@ 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),
@ -54,37 +68,29 @@ impl RoomService {
created_at: Set(now),
};
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;
room_message_reaction::Entity::insert(reaction)
.exec(&txn)
.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;
}
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)
.await;
self.get_message_reactions(message_id, Some(user_id)).await
}
@ -115,9 +121,9 @@ impl RoomService {
.into_iter()
.map(|g| ReactionGroup {
emoji: g.emoji,
count: g.count,
count: g.count as i32,
reacted_by_me: g.reacted_by_me,
users: g.users,
users: g.users.into_iter().map(|u| u.to_string()).collect(),
})
.collect();
self.queue
@ -252,11 +258,15 @@ impl RoomService {
grouped
.into_iter()
.map(|(emoji, user_reactions)| {
let count = user_reactions.len() as i64;
let count = user_reactions.len() as i32;
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).collect();
let users = user_reactions
.iter()
.take(3)
.map(|r| r.user.to_string())
.collect();
ReactionGroupResponse {
emoji,

View File

@ -273,6 +273,19 @@ 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.to_string() == "owner" {
if member.role == models::rooms::RoomMemberRole::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()
}
async fn next_room_message_seq_internal(
pub(crate) async fn next_room_message_seq_internal(
room_id: Uuid,
db: &AppDatabase,
cache: &AppCache,

View File

@ -1,128 +1,7 @@
import { memo, useMemo, useEffect } from 'react';
import { memo, useMemo } 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 {
@ -228,15 +107,17 @@ interface MessageContentWithMentionsProps {
content: string;
}
/** Renders message content with @mention highlighting using web components */
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 */
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 }];
@ -272,23 +153,12 @@ export const MessageContentWithMentions = memo(function MessageContentWithMentio
>
{processed.map((part, i) =>
part.type === 'mention' ? (
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}
className={mentionStyles[part.mention.type]}
>
@{part.mention.name}
</span>
) : (
<span key={i}>{part.content}</span>
),

View File

@ -22,7 +22,6 @@ 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';
@ -131,7 +130,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">{replyingTo.content}</span>
<span className="truncate text-muted-foreground" title={replyingTo.content}>{replyingTo.content.length > 80 ? replyingTo.content.slice(0, 80) + '…' : replyingTo.content}</span>
<button onClick={onCancelReply} className="ml-auto text-muted-foreground hover:text-foreground">
<X className="h-3 w-3" />
</button>
@ -262,7 +261,6 @@ 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);
@ -289,6 +287,7 @@ 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],
);
@ -310,7 +309,8 @@ export function RoomChatPanel({ room, isAdmin, onClose, onDelete }: RoomChatPane
setEditingMessage(null);
toast.success('Message updated');
},
[editingMessage, editMessage],
// Only rebuild when editingMessage.id actually changes, not on every new message
[editingMessage?.id, editMessage],
);
const handleRevoke = useCallback(
@ -321,6 +321,7 @@ 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);
}, []);
@ -520,7 +521,6 @@ export function RoomChatPanel({ room, isAdmin, onClose, onDelete }: RoomChatPane
onMention={handleMention}
onOpenThread={handleOpenThread}
onCreateThread={handleCreateThread}
onRenderedCountChange={setRenderedMessageCount}
/>
</div>
@ -604,7 +604,6 @@ export function RoomChatPanel({ room, isAdmin, onClose, onDelete }: RoomChatPane
roomId={room.id}
/>
<RoomPerformanceMonitor messageCount={messages.length} renderedCount={renderedMessageCount} />
</section>
);
}

View File

@ -6,9 +6,10 @@ 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, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { memo, useMemo, useState, useCallback, useRef } from 'react';
import { toast } from 'sonner';
import { ModelIcon } from './icon-match';
import { FunctionCallBadge } from './FunctionCallBadge';
@ -80,10 +81,7 @@ 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';
@ -107,19 +105,8 @@ export const RoomMessageBubble = memo(function RoomMessageBubble({
? streamingMessages.get(message.id)!
: message.content;
// 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();
}, []);
// Detect narrow container width using CSS container query instead of ResizeObserver
// The .group/narrow class on the container enables CSS container query support
const handleReaction = useCallback(async (emoji: string) => {
if (!wsClient) return;
@ -136,17 +123,6 @@ 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'
@ -416,212 +392,116 @@ export const RoomMessageBubble = memo(function RoomMessageBubble({
</div>
)}
{/* Action toolbar - inline icons when wide, collapsed to dropdown when narrow */}
{/* Action toolbar - inline icon buttons */}
{!isEditing && !isRevoked && !isPending && (
<div className="flex items-start gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
{isNarrow ? (
/* Narrow: all actions in dropdown */
<DropdownMenu>
<DropdownMenuTrigger
render={
{/* 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) => (
<Button
key={emoji}
variant="ghost"
size="sm"
className="size-7 p-0 text-muted-foreground hover:bg-accent hover:text-foreground"
title="More actions"
onClick={() => handleReaction(emoji)}
className="size-7 p-0 text-base hover:bg-accent"
title={emoji}
>
<MoreHorizontal className="size-3.5" />
{emoji}
</Button>
}
/>
<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"
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={
<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>
</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={
<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, useTransition, useDeferredValue } from 'react';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { RoomMessageBubble } from './RoomMessageBubble';
import { getSenderModelId } from './sender';
@ -27,8 +27,6 @@ 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 {
@ -37,7 +35,6 @@ interface MessageRow {
message?: MessageWithMeta;
grouped?: boolean;
replyMessage?: MessageWithMeta | null;
/** Unique key for the virtualizer */
key: string;
}
@ -74,12 +71,20 @@ function getSenderKey(message: MessageWithMeta): string {
return `sender:${message.sender_type}`;
}
/** 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 */
/** 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;
}
const ESTIMATED_DIVIDER_HEIGHT = 30;
export const RoomMessageList = memo(function RoomMessageList({
const RoomMessageListInner = memo(function RoomMessageListInner({
roomId,
messages,
messagesEndRef,
@ -94,39 +99,32 @@ export const RoomMessageList = memo(function RoomMessageList({
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);
// 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(() => {
// Build reply lookup map (stable reference, recomputes only when messages change)
const replyMap = useMemo(() => {
const map = new Map<string, MessageWithMeta>();
deferredMessages.forEach((message) => map.set(message.id, message));
messages.forEach((m) => {
if (m.id) map.set(m.id, m);
});
return map;
}, [deferredMessages]);
}, [messages]);
// Build rows: date dividers + messages
// Uses deferredMessages so row computation is deprioritized during rapid message updates
// Use a separate Map to avoid rows depending on replyMap (which changes reference)
const rows = useMemo<MessageRow[]>(() => {
const result: MessageRow[] = [];
let lastDateKey: string | null = null;
let lastSenderKey: string | null = null;
for (const message of deferredMessages) {
for (const message of messages) {
const dateKey = getDateKey(message.send_at);
const senderKey = getSenderKey(message);
@ -145,36 +143,42 @@ export const RoomMessageList = memo(function RoomMessageList({
type: 'message',
message,
grouped,
replyMessage: message.in_reply_to ? messageMap.get(message.in_reply_to) ?? null : null,
replyMessage: message.in_reply_to ? replyMap.get(message.in_reply_to) ?? null : null,
key: message.id,
});
lastSenderKey = senderKey;
}
return result;
}, [deferredMessages, messageMap]);
}, [messages, replyMap]);
const scrollToBottom = useCallback((smooth = true) => {
messagesEndRef.current?.scrollIntoView({ behavior: smooth ? 'smooth' : 'auto' });
}, [messagesEndRef]);
// 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.
// 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.
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;
startScrollTransition(() => {
// Update state asynchronously — browser can process other frames before committing
requestAnimationFrame(() => {
setShowScrollToBottom(!nearBottom);
setIsUserScrolling(true);
});
// Reset user scrolling flag after a delay
// Reset user-scrolling flag after delay
if (scrollTimeoutRef.current) clearTimeout(scrollTimeoutRef.current);
scrollTimeoutRef.current = setTimeout(() => {
setIsUserScrolling(false);
// 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);
}
}, 500);
}, []);
@ -188,30 +192,34 @@ export const RoomMessageList = memo(function RoomMessageList({
};
}, [handleScroll]);
// 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.
// Auto-scroll when new messages arrive (only if user was already at bottom)
useEffect(() => {
if (!isUserScrolling && deferredMessages.length > 0) {
scrollToBottom(false);
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));
}
}, [deferredMessages.length, isUserScrolling, scrollToBottom]);
}, [messages.length, 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;
return ESTIMATED_ROW_HEIGHT;
if (row?.type === 'message' && row.message) return estimateMessageRowHeight(row.message);
return 60;
},
overscan: 5,
overscan: 30,
gap: 0,
});
const virtualItems = virtualizer.getVirtualItems();
// IntersectionObserver for load more (only when scrolled to top)
// IntersectionObserver for load more
useEffect(() => {
const sentinel = topSentinelRef.current;
const container = scrollContainerRef.current;
@ -220,16 +228,12 @@ export const RoomMessageList = memo(function RoomMessageList({
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting && !isLoadingMore && hasMore) {
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;
}
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;
}
}
isRestoringScrollRef.current = true;
@ -241,28 +245,23 @@ export const RoomMessageList = memo(function RoomMessageList({
observer.observe(sentinel);
return () => observer.disconnect();
}, [onLoadMore, hasMore, isLoadingMore, rows]);
}, [onLoadMore, hasMore, isLoadingMore, rows, virtualizer]);
// 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 newScrollHeight = container.scrollHeight;
const delta = newScrollHeight - prevScrollHeightRef.current;
const delta = container.scrollHeight - prevScrollHeightRef.current;
// Method 1: Try to find the previously recorded first visible message
if (firstVisibleMessageIdRef.current) {
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' });
const el = container.querySelector(`[data-message-id="${firstVisibleMessageIdRef.current}"]`);
if (el) {
el.scrollIntoView({ block: 'start' });
prevScrollHeightRef.current = null;
firstVisibleMessageIdRef.current = null;
isRestoringScrollRef.current = false;
@ -270,22 +269,13 @@ export const RoomMessageList = memo(function RoomMessageList({
}
}
// Method 2: Fallback to the previous scrollHeight delta method
if (delta > 0) {
container.scrollTop += delta;
}
if (delta > 0) container.scrollTop += delta;
prevScrollHeightRef.current = null;
firstVisibleMessageIdRef.current = null;
isRestoringScrollRef.current = false;
}, [deferredMessages.length]);
}, [messages.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">
@ -308,7 +298,6 @@ export const RoomMessageList = memo(function RoomMessageList({
width: '100%',
}}
>
{/* Top sentinel for load more */}
<div ref={topSentinelRef} className="absolute top-0 h-1 w-full" />
{isLoadingMore && (
@ -345,12 +334,8 @@ export const RoomMessageList = memo(function RoomMessageList({
return (
<div
key={row.key}
className="absolute left-0 w-full"
className="absolute left-0 top-0 w-full"
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualRow.start}px)`,
}}
>
@ -381,7 +366,6 @@ export const RoomMessageList = memo(function RoomMessageList({
);
})}
{/* Bottom sentinel for auto-scroll */}
<div
ref={messagesEndRef}
className="absolute left-0 w-full"
@ -407,3 +391,6 @@ export const RoomMessageList = memo(function RoomMessageList({
</div>
);
});
export { RoomMessageListInner };
export const RoomMessageList = RoomMessageListInner;

View File

@ -1,153 +0,0 @@
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,6 +25,7 @@ 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';
@ -32,8 +33,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';
@ -221,7 +222,6 @@ export function RoomProvider({
useEffect(() => {
if (prevRoomIdRef.current !== activeRoomId) {
const oldRoomId = prevRoomIdRef.current;
prevRoomIdRef.current = activeRoomId;
loadMessagesAbortRef.current?.abort();
loadMessagesAbortRef.current = null;
@ -231,37 +231,68 @@ export function RoomProvider({
setMessages([]);
setIsHistoryLoaded(false);
setNextCursor(null);
// Clear old room's IDB cache asynchronously (fire and forget)
if (oldRoomId) {
clearRoomMessages(oldRoomId).catch(() => {});
}
// NOTE: intentionally NOT clearing IndexedDB — keeping it enables instant
// load when the user returns to this room without waiting for API.
}
}, [activeRoomId]);
const loadMoreRef = useRef<((cursor?: number | null) => Promise<void>) | null>(null);
useEffect(() => {
const client = wsClientRef.current;
if (!activeRoomId || !client) return;
const setup = async () => {
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);
// 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(() => {});
};
setup();
setup().catch(() => {});
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;
@ -274,31 +305,60 @@ export function RoomProvider({
setIsLoadingMore(true);
try {
// Initial load: check IndexedDB first for fast render
if (cursor === null || cursor === undefined) {
const isInitial = cursor === null || cursor === undefined;
const limit = isInitial ? 200 : 50;
// --- Initial load: try IndexedDB first for instant render ---
if (isInitial) {
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;
}
}
// Call API (IDB was empty on initial load, or user is loading older history)
// --- 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 ---
const resp = await client.messageList(activeRoomId, {
beforeSeq: cursor ?? undefined,
limit: 50,
limit,
});
if (abortController.signal.aborted) {
return;
}
if (abortController.signal.aborted) return;
const newMessages = resp.messages.map((m) => ({
...m,
@ -308,17 +368,11 @@ export function RoomProvider({
}));
setMessages((prev) => {
// 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
if (abortController.signal.aborted) return prev;
if (isInitial) {
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];
@ -329,44 +383,19 @@ export function RoomProvider({
return merged;
});
// Persist new messages to IndexedDB
if (newMessages.length > 0) {
saveMessages(activeRoomId, newMessages).catch(() => {});
}
if (resp.messages.length < 50) {
if (resp.messages.length < limit) {
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 (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
}
}
// Fetch reactions for all loaded messages (WS-first with HTTP fallback)
thisLoadReactions(activeRoomId, client, newMessages);
} catch (error) {
// Ignore abort errors
if (abortController.signal.aborted) {
return;
}
if (abortController.signal.aborted) return;
handleRoomError('Load messages', error);
} finally {
setIsLoadingMore(false);
@ -376,10 +405,6 @@ export function RoomProvider({
[activeRoomId],
);
useEffect(() => {
loadMoreRef.current = loadMore;
}, [loadMore]);
const [members, setMembers] = useState<RoomMemberResponse[]>([]);
const [membersLoading, setMembersLoading] = useState(false);
@ -399,8 +424,20 @@ export function RoomProvider({
// Use ref to get current activeRoomId to avoid stale closure
if (payload.room_id === activeRoomIdRef.current) {
setMessages((prev) => {
// Deduplicate by both ID (for normal) and seq (for optimistic replacement)
if (prev.some((m) => m.id === payload.id)) {
// 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
return prev;
}
// Also check if there's an optimistic message with the same seq that should be replaced
@ -480,15 +517,18 @@ 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 updated = prev.map((m) =>
m.id === payload.message_id
? { ...m, reactions: payload.reactions }
: m,
);
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 };
// Persist reaction update to IndexedDB
const msg = updated.find((m) => m.id === payload.message_id);
if (msg) saveMessage(msg).catch(() => {});
saveMessage(updated[existingIdx]).catch(() => {});
return updated;
});
},

View File

@ -1,553 +0,0 @@
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(): Promise<void> {
async connect(forceNewToken = false): Promise<void> {
if (this.ws && this.status === 'open') {
return;
}
@ -141,28 +141,31 @@ export class RoomWsClient {
this.shouldReconnect = true;
this.setStatus('connecting');
// 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}`);
// 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;
}
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();
@ -172,6 +175,12 @@ 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,6 +183,44 @@ 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,6 +166,8 @@ 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 {