use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::{broadcast, RwLock};
use uuid::Uuid;
const BROADCAST_CAPACITY: usize = 256;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum ParticipantType {
Human,
Claude,
Omni,
Grok,
Gemini,
LocalLlm,
SmartTree,
Unknown,
}
impl ParticipantType {
pub fn emoji(&self) -> &'static str {
match self {
Self::Human => "👤",
Self::Claude => "🤖",
Self::Omni => "🌀",
Self::Grok => "⚡",
Self::Gemini => "✨",
Self::LocalLlm => "🏠",
Self::SmartTree => "🌳",
Self::Unknown => "❓",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Participant {
pub id: String,
pub name: String,
pub participant_type: ParticipantType,
pub joined_at: chrono::DateTime<chrono::Utc>,
pub last_seen: chrono::DateTime<chrono::Utc>,
pub status: Option<String>,
pub working_on: Option<String>,
pub in_hot_tub: bool,
}
impl Participant {
pub fn new(name: impl Into<String>, participant_type: ParticipantType) -> Self {
let now = chrono::Utc::now();
Self {
id: Uuid::new_v4().to_string(),
name: name.into(),
participant_type,
joined_at: now,
last_seen: now,
status: None,
working_on: None,
in_hot_tub: false,
}
}
pub fn display_name(&self) -> String {
format!("{} {}", self.participant_type.emoji(), self.name)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum CollabMessage {
Join {
participant: Participant,
},
Leave {
participant_id: String,
name: String,
},
Chat {
from: String,
from_name: String,
message: String,
hot_tub: bool,
},
StatusUpdate {
participant_id: String,
status: Option<String>,
working_on: Option<String>,
},
FileActivity {
participant_id: String,
action: String,
path: String,
},
HotTubToggle {
participant_id: String,
name: String,
entering: bool,
},
System {
message: String,
},
Presence {
participants: Vec<ParticipantSummary>,
hot_tub_count: usize,
},
Prompt {
prompt_id: String,
question: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ParticipantSummary {
pub id: String,
pub name: String,
pub participant_type: ParticipantType,
pub status: Option<String>,
pub in_hot_tub: bool,
}
impl From<&Participant> for ParticipantSummary {
fn from(p: &Participant) -> Self {
Self {
id: p.id.clone(),
name: p.name.clone(),
participant_type: p.participant_type.clone(),
status: p.status.clone(),
in_hot_tub: p.in_hot_tub,
}
}
}
#[derive(Debug)]
pub struct CollaborationHub {
participants: HashMap<String, Participant>,
broadcast_tx: broadcast::Sender<CollabMessage>,
shared_files: HashMap<String, Vec<String>>, hot_tub_open: bool,
}
impl CollaborationHub {
pub fn new() -> Self {
let (broadcast_tx, _) = broadcast::channel(BROADCAST_CAPACITY);
Self {
participants: HashMap::new(),
broadcast_tx,
shared_files: HashMap::new(),
hot_tub_open: true, }
}
pub fn subscribe(&self) -> broadcast::Receiver<CollabMessage> {
self.broadcast_tx.subscribe()
}
pub fn join(&mut self, participant: Participant) -> String {
let id = participant.id.clone();
let msg = CollabMessage::Join {
participant: participant.clone(),
};
self.participants.insert(id.clone(), participant);
let _ = self.broadcast_tx.send(msg);
self.announce_presence();
id
}
pub fn leave(&mut self, participant_id: &str) {
if let Some(p) = self.participants.remove(participant_id) {
let msg = CollabMessage::Leave {
participant_id: participant_id.to_string(),
name: p.name,
};
let _ = self.broadcast_tx.send(msg);
self.announce_presence();
}
}
pub fn chat(&self, from_id: &str, message: String) {
if let Some(p) = self.participants.get(from_id) {
let msg = CollabMessage::Chat {
from: from_id.to_string(),
from_name: p.display_name(),
message,
hot_tub: p.in_hot_tub,
};
let _ = self.broadcast_tx.send(msg);
}
}
pub fn toggle_hot_tub(&mut self, participant_id: &str) -> bool {
if let Some(p) = self.participants.get_mut(participant_id) {
p.in_hot_tub = !p.in_hot_tub;
let entering = p.in_hot_tub;
let msg = CollabMessage::HotTubToggle {
participant_id: participant_id.to_string(),
name: p.display_name(),
entering,
};
let _ = self.broadcast_tx.send(msg);
self.announce_presence();
entering
} else {
false
}
}
pub fn update_status(&mut self, participant_id: &str, status: Option<String>, working_on: Option<String>) {
if let Some(p) = self.participants.get_mut(participant_id) {
p.status = status.clone();
p.working_on = working_on.clone();
p.last_seen = chrono::Utc::now();
let msg = CollabMessage::StatusUpdate {
participant_id: participant_id.to_string(),
status,
working_on,
};
let _ = self.broadcast_tx.send(msg);
}
}
pub fn file_activity(&mut self, participant_id: &str, action: &str, path: &str) {
self.shared_files
.entry(path.to_string())
.or_default()
.push(participant_id.to_string());
if self.participants.contains_key(participant_id) {
let msg = CollabMessage::FileActivity {
participant_id: participant_id.to_string(),
action: action.to_string(),
path: path.to_string(),
};
let _ = self.broadcast_tx.send(msg);
}
}
pub fn get_presence(&self) -> Vec<ParticipantSummary> {
self.participants.values().map(ParticipantSummary::from).collect()
}
pub fn get_hot_tub_participants(&self) -> Vec<&Participant> {
self.participants.values().filter(|p| p.in_hot_tub).collect()
}
fn announce_presence(&self) {
let participants: Vec<ParticipantSummary> = self.get_presence();
let hot_tub_count = participants.iter().filter(|p| p.in_hot_tub).count();
let msg = CollabMessage::Presence {
participants,
hot_tub_count,
};
let _ = self.broadcast_tx.send(msg);
}
pub fn announce(&self, message: impl Into<String>) {
let msg = CollabMessage::System {
message: message.into(),
};
let _ = self.broadcast_tx.send(msg);
}
pub fn announce_prompt(&self, prompt_id: String, question: String) {
let msg = CollabMessage::Prompt {
prompt_id,
question,
};
let _ = self.broadcast_tx.send(msg);
}
pub fn participant_count(&self) -> usize {
self.participants.len()
}
pub fn is_hot_tub_open(&self) -> bool {
self.hot_tub_open
}
}
impl Default for CollaborationHub {
fn default() -> Self {
Self::new()
}
}
pub type SharedCollabHub = Arc<RwLock<CollaborationHub>>;
pub fn create_hub() -> SharedCollabHub {
Arc::new(RwLock::new(CollaborationHub::new()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_participant_creation() {
let p = Participant::new("Claude", ParticipantType::Claude);
assert_eq!(p.name, "Claude");
assert_eq!(p.participant_type, ParticipantType::Claude);
assert!(!p.in_hot_tub);
}
#[test]
fn test_hot_tub_toggle() {
let mut hub = CollaborationHub::new();
let p = Participant::new("Hue", ParticipantType::Human);
let id = hub.join(p);
assert!(!hub.participants.get(&id).unwrap().in_hot_tub);
hub.toggle_hot_tub(&id);
assert!(hub.participants.get(&id).unwrap().in_hot_tub);
}
#[test]
fn test_presence() {
let mut hub = CollaborationHub::new();
hub.join(Participant::new("Claude", ParticipantType::Claude));
hub.join(Participant::new("Omni", ParticipantType::Omni));
assert_eq!(hub.participant_count(), 2);
assert_eq!(hub.get_presence().len(), 2);
}
}