feat: add room member list, AI agent naming, and fix invite flow

- Add clickable member count in room header that shows a dropdown with
  all room members (AI agent + humans) with role badges
- Give each room's AI agent a random name from 10 options (Nova, Atlas,
  Sage, etc.), editable when creating the room
- Replace AI text avatar with a robot SVG icon across all components
- Fix broken invite system: add client-side URL routing for /invite/:token,
  handle post-login invite acceptance via sessionStorage, and return
  room_id from accept_invite endpoint for auto-navigation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jason Tudisco 2026-03-07 07:27:17 -06:00
parent 4a002c85d4
commit df59accb81
10 changed files with 276 additions and 19 deletions

View File

@ -168,6 +168,9 @@
if (user && isAuthenticated()) { if (user && isAuthenticated()) {
this.update({ user }) this.update({ user })
this.initChat() this.initChat()
} else {
// Not logged in — store invite token so we can accept after login
this.checkPendingInvite()
} }
}, },
@ -216,7 +219,7 @@
id: this._streamMsgId, id: this._streamMsgId,
room_id: msg.room_id, room_id: msg.room_id,
sender_id: 'ai-assistant', sender_id: 'ai-assistant',
sender_name: 'AI Assistant', sender_name: this.state.activeRoom?.ai_name || 'AI Assistant',
content: this._streamContent, content: this._streamContent,
mentions: [], mentions: [],
is_ai: true, is_ai: true,
@ -301,6 +304,9 @@
} catch (e) { } catch (e) {
console.error('Failed to load rooms:', e) console.error('Failed to load rooms:', e)
} }
// Process any pending invite token
await this.processInviteToken()
}, },
handleLogin(data) { handleLogin(data) {
@ -381,6 +387,45 @@
this.update({ messages: [], showClearModal: false }) this.update({ messages: [], showClearModal: false })
}, },
/** Check URL for /invite/:token and stash it for after login if needed */
checkPendingInvite() {
const match = window.location.pathname.match(/^\/invite\/(.+)$/)
if (match) {
sessionStorage.setItem('pendingInvite', match[1])
// Clean URL so it doesn't confuse things
window.history.replaceState(null, '', '/')
}
},
/** Accept an invite token — called after login + rooms load */
async processInviteToken() {
// Check URL first, then sessionStorage (for post-login flow)
let token = null
const urlMatch = window.location.pathname.match(/^\/invite\/(.+)$/)
if (urlMatch) {
token = urlMatch[1]
window.history.replaceState(null, '', '/')
} else {
token = sessionStorage.getItem('pendingInvite')
}
if (!token) return
sessionStorage.removeItem('pendingInvite')
try {
const result = await api.acceptInvite(token)
// Refresh room list — the accepted room should now appear
const rooms = await api.listRooms()
this.update({ rooms })
// Auto-select the room the user just joined
if (result?.room_id) {
this.selectRoom(result.room_id)
}
} catch (e) {
console.error('Failed to accept invite:', e)
}
},
scrollToBottom() { scrollToBottom() {
requestAnimationFrame(() => { requestAnimationFrame(() => {
const container = document.querySelector('.messages-list') const container = document.querySelector('.messages-list')

View File

@ -4,9 +4,18 @@
<header class="room-header"> <header class="room-header">
<div class="room-header-info"> <div class="room-header-info">
<h3>{props.room?.name}</h3> <h3>{props.room?.name}</h3>
<span class="room-meta"> <button class="members-toggle" onclick={toggleMembers}>
{props.room?.model_id?.split('/').pop()} &middot; {props.room?.members?.length || 0} members <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
</span> <path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
{(props.room?.members?.length || 0) + 1} members
<svg class={'members-chevron ' + (state.showMembers ? 'open' : '')} width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
</div> </div>
<div class="room-actions"> <div class="room-actions">
<button class="btn btn-ghost btn-icon" onclick={props.cbInvite} title="Invite"> <button class="btn btn-ghost btn-icon" onclick={props.cbInvite} title="Invite">
@ -28,6 +37,28 @@
</svg> </svg>
</button> </button>
</div> </div>
<!-- Member list dropdown -->
<div if={state.showMembers} class="members-dropdown">
<div class="member-item ai-member">
<div class="member-avatar ai-avatar">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="4" y="8" width="16" height="12" rx="2"/>
<line x1="12" y1="2" x2="12" y2="8"/>
<circle cx="12" cy="2" r="1" fill="currentColor"/>
<circle cx="9" cy="14" r="1.5" fill="currentColor"/>
<circle cx="15" cy="14" r="1.5" fill="currentColor"/>
</svg>
</div>
<span class="member-name">{props.room?.ai_name || 'AI Assistant'}</span>
<span class="member-role ai-role">AI</span>
</div>
<div each={member in props.room?.members} key={member.id} class="member-item">
<div class="member-avatar">{member.display_name?.charAt(0).toUpperCase()}</div>
<span class="member-name">{member.display_name}</span>
<span if={member.id === props.room?.created_by} class="member-role">Owner</span>
</div>
</div>
</header> </header>
<!-- Messages --> <!-- Messages -->
@ -51,7 +82,15 @@
<!-- AI typing indicator (only when NOT streaming content) --> <!-- AI typing indicator (only when NOT streaming content) -->
<div if={props.aiTyping && !props.streamingMessage} class="typing-indicator ai-typing"> <div if={props.aiTyping && !props.streamingMessage} class="typing-indicator ai-typing">
<div class="typing-avatar ai-avatar">AI</div> <div class="typing-avatar ai-avatar">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="4" y="8" width="16" height="12" rx="2"/>
<line x1="12" y1="2" x2="12" y2="8"/>
<circle cx="12" cy="2" r="1" fill="currentColor"/>
<circle cx="9" cy="14" r="1.5" fill="currentColor"/>
<circle cx="15" cy="14" r="1.5" fill="currentColor"/>
</svg>
</div>
<template if={props.aiToolStatus}> <template if={props.aiToolStatus}>
<span class="tool-status-text"> <span class="tool-status-text">
{props.aiToolStatus.tool === 'brave_search' ? '🔍 Searching...' : props.aiToolStatus.tool === 'web_fetch' ? '🌐 Reading page...' : '⚙️ Using tool...'} {props.aiToolStatus.tool === 'brave_search' ? '🔍 Searching...' : props.aiToolStatus.tool === 'web_fetch' ? '🌐 Reading page...' : '⚙️ Using tool...'}
@ -75,7 +114,7 @@
<textarea <textarea
ref="input" ref="input"
class="message-input" class="message-input"
placeholder="Type a message... (use @ai to mention the AI)" placeholder={'Type a message... (use @ai or @' + (props.room?.ai_name || 'AI') + ')'}
rows="1" rows="1"
oninput={handleInput} oninput={handleInput}
onkeydown={handleKeydown} onkeydown={handleKeydown}
@ -109,6 +148,7 @@
height: var(--header-height); height: var(--header-height);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
background: var(--bg-secondary); background: var(--bg-secondary);
position: relative;
} }
.room-header-info h3 { .room-header-info h3 {
@ -116,9 +156,99 @@
font-weight: 600; font-weight: 600;
} }
.room-meta { .members-toggle {
font-size: var(--text-xs); background: none;
border: none;
color: var(--text-muted); color: var(--text-muted);
font-size: var(--text-xs);
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 6px;
border-radius: var(--radius-sm);
transition: all var(--transition-fast);
}
.members-toggle:hover {
color: var(--text-secondary);
background: var(--bg-hover);
}
.members-chevron {
transition: transform 0.15s;
}
.members-chevron.open {
transform: rotate(180deg);
}
.members-dropdown {
position: absolute;
top: 100%;
left: var(--space-lg);
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: var(--space-xs) 0;
min-width: 200px;
max-width: 280px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
z-index: 20;
animation: fadeIn 0.15s ease;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.member-item {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-xs) var(--space-md);
}
.member-avatar {
width: 28px;
height: 28px;
border-radius: var(--radius-full);
background: var(--bg-elevated);
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: var(--text-xs);
color: var(--text-secondary);
flex-shrink: 0;
}
.member-item .ai-avatar {
background: var(--accent);
color: white;
}
.member-name {
font-size: var(--text-sm);
color: var(--text-primary);
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.member-role {
font-size: 11px;
color: var(--text-muted);
background: var(--bg-elevated);
padding: 1px 6px;
border-radius: var(--radius-sm);
}
.ai-role {
color: var(--accent);
background: rgba(108, 92, 231, 0.15);
} }
.room-actions { .room-actions {
@ -288,6 +418,25 @@
state: { state: {
inputValue: '', inputValue: '',
typingDisplay: '', typingDisplay: '',
showMembers: false,
},
onMounted() {
this._closeMembers = (e) => {
if (this.state.showMembers && !this.$('.members-toggle')?.contains(e.target) && !this.$('.members-dropdown')?.contains(e.target)) {
this.update({ showMembers: false })
}
}
document.addEventListener('click', this._closeMembers)
},
onUnmounted() {
document.removeEventListener('click', this._closeMembers)
},
toggleMembers(e) {
e.stopPropagation()
this.update({ showMembers: !this.state.showMembers })
}, },
onUpdated() { onUpdated() {
@ -343,9 +492,11 @@
return return
} }
// Extract mentions (simple @ai detection) // Extract mentions (@ai or @{aiName} detection)
const mentions = [] const mentions = []
if (content.toLowerCase().includes('@ai')) { const lc = content.toLowerCase()
const aiName = this.props.room?.ai_name?.toLowerCase() || ''
if (lc.includes('@ai') || (aiName && lc.includes(`@${aiName}`))) {
mentions.push('ai-assistant') mentions.push('ai-assistant')
} }

View File

@ -95,6 +95,19 @@
></textarea> ></textarea>
</div> </div>
<div class="form-group">
<label for="ai-name">AI Agent Name</label>
<input
type="text"
id="ai-name"
placeholder="e.g., Nova, Atlas, Sage..."
value={state.ai_name}
oninput={e => update({ ai_name: e.target.value })}
maxlength="20"
/>
<span class="help-text">The AI's display name in this room</span>
</div>
<div class="modal-actions"> <div class="modal-actions">
<button type="button" class="btn btn-ghost" onclick={props.cbClose}>Cancel</button> <button type="button" class="btn btn-ghost" onclick={props.cbClose}>Cancel</button>
<button type="submit" class="btn btn-primary">Create Room</button> <button type="submit" class="btn btn-primary">Create Room</button>
@ -343,6 +356,7 @@
model_id: 'anthropic/claude-sonnet-4', model_id: 'anthropic/claude-sonnet-4',
ai_always_respond: false, ai_always_respond: false,
system_prompt: 'You are a helpful AI assistant participating in a group chat. Be conversational, helpful, and concise.', system_prompt: 'You are a helpful AI assistant participating in a group chat. Be conversational, helpful, and concise.',
ai_name: '',
models: [], models: [],
loadingModels: true, loadingModels: true,
showPicker: false, showPicker: false,
@ -350,6 +364,8 @@
}, },
onMounted() { onMounted() {
const aiNames = ['Nova', 'Atlas', 'Sage', 'Echo', 'Pixel', 'Cosmo', 'Ember', 'Flux', 'Lyra', 'Onyx']
this.update({ ai_name: aiNames[Math.floor(Math.random() * aiNames.length)] })
this.fetchModels() this.fetchModels()
// Close picker on outside click // Close picker on outside click
this._onDocClick = (e) => { this._onDocClick = (e) => {
@ -430,6 +446,7 @@
model_id: this.state.model_id, model_id: this.state.model_id,
ai_always_respond: this.state.ai_always_respond, ai_always_respond: this.state.ai_always_respond,
system_prompt: this.state.system_prompt, system_prompt: this.state.system_prompt,
ai_name: this.state.ai_name,
}) })
}, },
} }

View File

@ -2,7 +2,14 @@
<div class={'message ' + (props.message?.is_ai ? 'ai-message' : '') + (props.isOwn ? ' own-message' : '')}> <div class={'message ' + (props.message?.is_ai ? 'ai-message' : '') + (props.isOwn ? ' own-message' : '')}>
<div if={!props.isOwn} class="message-avatar-col"> <div if={!props.isOwn} class="message-avatar-col">
<div class={'message-avatar ' + (props.message?.is_ai ? 'ai-avatar' : '')}> <div class={'message-avatar ' + (props.message?.is_ai ? 'ai-avatar' : '')}>
{props.message?.is_ai ? 'AI' : props.message?.sender_name?.charAt(0).toUpperCase()} <svg if={props.message?.is_ai} width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="4" y="8" width="16" height="12" rx="2"/>
<line x1="12" y1="2" x2="12" y2="8"/>
<circle cx="12" cy="2" r="1" fill="currentColor"/>
<circle cx="9" cy="14" r="1.5" fill="currentColor"/>
<circle cx="15" cy="14" r="1.5" fill="currentColor"/>
</svg>
<template if={!props.message?.is_ai}>{props.message?.sender_name?.charAt(0).toUpperCase()}</template>
</div> </div>
</div> </div>
<div class="message-body"> <div class="message-body">

View File

@ -0,0 +1,2 @@
-- Add AI agent name to rooms
ALTER TABLE rooms ADD COLUMN ai_name TEXT NOT NULL DEFAULT 'AI Assistant';

View File

@ -63,11 +63,16 @@ pub async fn create_invite(
})) }))
} }
#[derive(serde::Serialize)]
pub struct AcceptInviteResponse {
pub room_id: String,
}
pub async fn accept_invite( pub async fn accept_invite(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
auth: AuthUser, auth: AuthUser,
Path(token): Path<String>, Path(token): Path<String>,
) -> Result<StatusCode, (StatusCode, String)> { ) -> Result<Json<AcceptInviteResponse>, (StatusCode, String)> {
let invite = sqlx::query_as::<_, (String, String, bool)>( let invite = sqlx::query_as::<_, (String, String, bool)>(
"SELECT id, room_id, used FROM invites WHERE token = ?", "SELECT id, room_id, used FROM invites WHERE token = ?",
) )
@ -111,5 +116,5 @@ pub async fn accept_invite(
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(StatusCode::OK) Ok(Json(AcceptInviteResponse { room_id }))
} }

View File

@ -20,7 +20,7 @@ pub async fn create_room(
let room_id = Uuid::new_v4().to_string(); let room_id = Uuid::new_v4().to_string();
sqlx::query( sqlx::query(
"INSERT INTO rooms (id, name, model_id, created_by, ai_always_respond, system_prompt) VALUES (?, ?, ?, ?, ?, ?)", "INSERT INTO rooms (id, name, model_id, created_by, ai_always_respond, system_prompt, ai_name) VALUES (?, ?, ?, ?, ?, ?, ?)",
) )
.bind(&room_id) .bind(&room_id)
.bind(&body.name) .bind(&body.name)
@ -28,6 +28,7 @@ pub async fn create_room(
.bind(&auth.user_id) .bind(&auth.user_id)
.bind(body.ai_always_respond) .bind(body.ai_always_respond)
.bind(&body.system_prompt) .bind(&body.system_prompt)
.bind(&body.ai_name)
.execute(&state.db) .execute(&state.db)
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
@ -47,6 +48,7 @@ pub async fn create_room(
created_by: auth.user_id.clone(), created_by: auth.user_id.clone(),
ai_always_respond: body.ai_always_respond, ai_always_respond: body.ai_always_respond,
system_prompt: body.system_prompt, system_prompt: body.system_prompt,
ai_name: body.ai_name,
created_at: chrono::Utc::now().to_rfc3339(), created_at: chrono::Utc::now().to_rfc3339(),
members: vec![UserPublic { members: vec![UserPublic {
id: auth.user_id, id: auth.user_id,
@ -85,6 +87,7 @@ pub async fn list_rooms(
created_by: room.created_by, created_by: room.created_by,
ai_always_respond: room.ai_always_respond, ai_always_respond: room.ai_always_respond,
system_prompt: room.system_prompt, system_prompt: room.system_prompt,
ai_name: room.ai_name,
created_at: room.created_at, created_at: room.created_at,
members: members members: members
.into_iter() .into_iter()
@ -141,6 +144,7 @@ pub async fn get_room(
created_by: room.created_by, created_by: room.created_by,
ai_always_respond: room.ai_always_respond, ai_always_respond: room.ai_always_respond,
system_prompt: room.system_prompt, system_prompt: room.system_prompt,
ai_name: room.ai_name,
created_at: room.created_at, created_at: room.created_at,
members: members members: members
.into_iter() .into_iter()

View File

@ -197,14 +197,14 @@ async fn handle_send_message(
let should_respond = mentions.contains(&ai_user_id.to_string()); let should_respond = mentions.contains(&ai_user_id.to_string());
// Also check room settings for ai_always_respond // Also check room settings for ai_always_respond
let room = sqlx::query_as::<_, (String, bool, String)>( let room = sqlx::query_as::<_, (String, bool, String, String)>(
"SELECT model_id, ai_always_respond, system_prompt FROM rooms WHERE id = ? AND deleted_at IS NULL", "SELECT model_id, ai_always_respond, system_prompt, ai_name FROM rooms WHERE id = ? AND deleted_at IS NULL",
) )
.bind(room_id) .bind(room_id)
.fetch_optional(&state.db) .fetch_optional(&state.db)
.await; .await;
let (model_id, always_respond, system_prompt) = match room { let (model_id, always_respond, system_prompt, ai_name) = match room {
Ok(Some(r)) => r, Ok(Some(r)) => r,
_ => return, _ => return,
}; };
@ -390,7 +390,7 @@ async fn handle_send_message(
.bind(&ai_msg_id) .bind(&ai_msg_id)
.bind(room_id) .bind(room_id)
.bind(ai_user_id) .bind(ai_user_id)
.bind("AI Assistant") .bind(&ai_name)
.bind(&ai_response) .bind(&ai_response)
.bind(&ai_now) .bind(&ai_now)
.bind(&ai_meta_json) .bind(&ai_meta_json)
@ -402,7 +402,7 @@ async fn handle_send_message(
id: ai_msg_id, id: ai_msg_id,
room_id: room_id.to_string(), room_id: room_id.to_string(),
sender_id: ai_user_id.to_string(), sender_id: ai_user_id.to_string(),
sender_name: "AI Assistant".to_string(), sender_name: ai_name.clone(),
content: ai_response, content: ai_response,
mentions: vec![], mentions: vec![],
is_ai: true, is_ai: true,

View File

@ -70,6 +70,16 @@ async fn main() {
Err(e) => panic!("Failed to run migration 003: {}", e), Err(e) => panic!("Failed to run migration 003: {}", e),
} }
// Run migration 004 - ai_name on rooms
let migration_004 = include_str!("../migrations/004_ai_name.sql");
match sqlx::raw_sql(migration_004).execute(&db).await {
Ok(_) => tracing::info!("Migration 004 applied"),
Err(e) if e.to_string().contains("duplicate column") => {
tracing::debug!("Migration 004 already applied, skipping");
}
Err(e) => panic!("Failed to run migration 004: {}", e),
}
tracing::info!("Database initialized"); tracing::info!("Database initialized");
let (tx, _rx) = broadcast::channel::<models::BroadcastEvent>(4096); let (tx, _rx) = broadcast::channel::<models::BroadcastEvent>(4096);

View File

@ -19,6 +19,7 @@ pub struct Room {
pub created_by: String, pub created_by: String,
pub ai_always_respond: bool, pub ai_always_respond: bool,
pub system_prompt: String, pub system_prompt: String,
pub ai_name: String,
pub created_at: String, pub created_at: String,
pub deleted_at: Option<String>, pub deleted_at: Option<String>,
} }
@ -83,6 +84,20 @@ pub struct CreateRoomRequest {
pub ai_always_respond: bool, pub ai_always_respond: bool,
#[serde(default = "default_system_prompt")] #[serde(default = "default_system_prompt")]
pub system_prompt: String, pub system_prompt: String,
#[serde(default = "default_ai_name")]
pub ai_name: String,
}
fn default_ai_name() -> String {
let names = [
"Nova", "Atlas", "Sage", "Echo", "Pixel",
"Cosmo", "Ember", "Flux", "Lyra", "Onyx",
];
let idx = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as usize % names.len())
.unwrap_or(0);
names[idx].to_string()
} }
fn default_system_prompt() -> String { fn default_system_prompt() -> String {
@ -97,6 +112,7 @@ pub struct RoomResponse {
pub created_by: String, pub created_by: String,
pub ai_always_respond: bool, pub ai_always_respond: bool,
pub system_prompt: String, pub system_prompt: String,
pub ai_name: String,
pub created_at: String, pub created_at: String,
pub members: Vec<UserPublic>, pub members: Vec<UserPublic>,
} }