1use serde::{Deserialize, Serialize};
17use std::collections::HashMap;
18use std::sync::Arc;
19use tokio::sync::{broadcast, RwLock};
20use uuid::Uuid;
21
22const BROADCAST_CAPACITY: usize = 256;
24
25#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
27#[serde(rename_all = "snake_case")]
28pub enum ParticipantType {
29 Human,
30 Claude,
31 Omni,
32 Grok,
33 Gemini,
34 LocalLlm,
35 SmartTree,
36 Unknown,
37}
38
39impl ParticipantType {
40 pub fn emoji(&self) -> &'static str {
41 match self {
42 Self::Human => "👤",
43 Self::Claude => "🤖",
44 Self::Omni => "🌀",
45 Self::Grok => "⚡",
46 Self::Gemini => "✨",
47 Self::LocalLlm => "🏠",
48 Self::SmartTree => "🌳",
49 Self::Unknown => "❓",
50 }
51 }
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct Participant {
57 pub id: String,
58 pub name: String,
59 pub participant_type: ParticipantType,
60 pub joined_at: chrono::DateTime<chrono::Utc>,
61 pub last_seen: chrono::DateTime<chrono::Utc>,
62 pub status: Option<String>,
64 pub working_on: Option<String>,
66 pub in_hot_tub: bool,
68}
69
70impl Participant {
71 pub fn new(name: impl Into<String>, participant_type: ParticipantType) -> Self {
72 let now = chrono::Utc::now();
73 Self {
74 id: Uuid::new_v4().to_string(),
75 name: name.into(),
76 participant_type,
77 joined_at: now,
78 last_seen: now,
79 status: None,
80 working_on: None,
81 in_hot_tub: false,
82 }
83 }
84
85 pub fn display_name(&self) -> String {
86 format!("{} {}", self.participant_type.emoji(), self.name)
87 }
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize)]
92#[serde(tag = "type", rename_all = "snake_case")]
93pub enum CollabMessage {
94 Join {
96 participant: Participant,
97 },
98 Leave {
100 participant_id: String,
101 name: String,
102 },
103 Chat {
105 from: String,
106 from_name: String,
107 message: String,
108 hot_tub: bool,
109 },
110 StatusUpdate {
112 participant_id: String,
113 status: Option<String>,
114 working_on: Option<String>,
115 },
116 FileActivity {
118 participant_id: String,
119 action: String,
120 path: String,
121 },
122 HotTubToggle {
124 participant_id: String,
125 name: String,
126 entering: bool,
127 },
128 System {
130 message: String,
131 },
132 Presence {
134 participants: Vec<ParticipantSummary>,
135 hot_tub_count: usize,
136 },
137 Prompt {
139 prompt_id: String,
140 question: String,
141 },
142}
143
144#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct ParticipantSummary {
147 pub id: String,
148 pub name: String,
149 pub participant_type: ParticipantType,
150 pub status: Option<String>,
151 pub in_hot_tub: bool,
152}
153
154impl From<&Participant> for ParticipantSummary {
155 fn from(p: &Participant) -> Self {
156 Self {
157 id: p.id.clone(),
158 name: p.name.clone(),
159 participant_type: p.participant_type.clone(),
160 status: p.status.clone(),
161 in_hot_tub: p.in_hot_tub,
162 }
163 }
164}
165
166#[derive(Debug)]
168pub struct CollaborationHub {
169 participants: HashMap<String, Participant>,
171 broadcast_tx: broadcast::Sender<CollabMessage>,
173 shared_files: HashMap<String, Vec<String>>, hot_tub_open: bool,
177}
178
179impl CollaborationHub {
180 pub fn new() -> Self {
181 let (broadcast_tx, _) = broadcast::channel(BROADCAST_CAPACITY);
182 Self {
183 participants: HashMap::new(),
184 broadcast_tx,
185 shared_files: HashMap::new(),
186 hot_tub_open: true, }
188 }
189
190 pub fn subscribe(&self) -> broadcast::Receiver<CollabMessage> {
192 self.broadcast_tx.subscribe()
193 }
194
195 pub fn join(&mut self, participant: Participant) -> String {
197 let id = participant.id.clone();
198 let msg = CollabMessage::Join {
199 participant: participant.clone(),
200 };
201 self.participants.insert(id.clone(), participant);
202 let _ = self.broadcast_tx.send(msg);
203 self.announce_presence();
204 id
205 }
206
207 pub fn leave(&mut self, participant_id: &str) {
209 if let Some(p) = self.participants.remove(participant_id) {
210 let msg = CollabMessage::Leave {
211 participant_id: participant_id.to_string(),
212 name: p.name,
213 };
214 let _ = self.broadcast_tx.send(msg);
215 self.announce_presence();
216 }
217 }
218
219 pub fn chat(&self, from_id: &str, message: String) {
221 if let Some(p) = self.participants.get(from_id) {
222 let msg = CollabMessage::Chat {
223 from: from_id.to_string(),
224 from_name: p.display_name(),
225 message,
226 hot_tub: p.in_hot_tub,
227 };
228 let _ = self.broadcast_tx.send(msg);
229 }
230 }
231
232 pub fn toggle_hot_tub(&mut self, participant_id: &str) -> bool {
234 if let Some(p) = self.participants.get_mut(participant_id) {
235 p.in_hot_tub = !p.in_hot_tub;
236 let entering = p.in_hot_tub;
237 let msg = CollabMessage::HotTubToggle {
238 participant_id: participant_id.to_string(),
239 name: p.display_name(),
240 entering,
241 };
242 let _ = self.broadcast_tx.send(msg);
243 self.announce_presence();
244 entering
245 } else {
246 false
247 }
248 }
249
250 pub fn update_status(&mut self, participant_id: &str, status: Option<String>, working_on: Option<String>) {
252 if let Some(p) = self.participants.get_mut(participant_id) {
253 p.status = status.clone();
254 p.working_on = working_on.clone();
255 p.last_seen = chrono::Utc::now();
256 let msg = CollabMessage::StatusUpdate {
257 participant_id: participant_id.to_string(),
258 status,
259 working_on,
260 };
261 let _ = self.broadcast_tx.send(msg);
262 }
263 }
264
265 pub fn file_activity(&mut self, participant_id: &str, action: &str, path: &str) {
267 self.shared_files
269 .entry(path.to_string())
270 .or_default()
271 .push(participant_id.to_string());
272
273 if self.participants.contains_key(participant_id) {
274 let msg = CollabMessage::FileActivity {
275 participant_id: participant_id.to_string(),
276 action: action.to_string(),
277 path: path.to_string(),
278 };
279 let _ = self.broadcast_tx.send(msg);
280 }
281 }
282
283 pub fn get_presence(&self) -> Vec<ParticipantSummary> {
285 self.participants.values().map(ParticipantSummary::from).collect()
286 }
287
288 pub fn get_hot_tub_participants(&self) -> Vec<&Participant> {
290 self.participants.values().filter(|p| p.in_hot_tub).collect()
291 }
292
293 fn announce_presence(&self) {
295 let participants: Vec<ParticipantSummary> = self.get_presence();
296 let hot_tub_count = participants.iter().filter(|p| p.in_hot_tub).count();
297 let msg = CollabMessage::Presence {
298 participants,
299 hot_tub_count,
300 };
301 let _ = self.broadcast_tx.send(msg);
302 }
303
304 pub fn announce(&self, message: impl Into<String>) {
306 let msg = CollabMessage::System {
307 message: message.into(),
308 };
309 let _ = self.broadcast_tx.send(msg);
310 }
311
312 pub fn announce_prompt(&self, prompt_id: String, question: String) {
314 let msg = CollabMessage::Prompt {
315 prompt_id,
316 question,
317 };
318 let _ = self.broadcast_tx.send(msg);
319 }
320
321 pub fn participant_count(&self) -> usize {
323 self.participants.len()
324 }
325
326 pub fn is_hot_tub_open(&self) -> bool {
328 self.hot_tub_open
329 }
330}
331
332impl Default for CollaborationHub {
333 fn default() -> Self {
334 Self::new()
335 }
336}
337
338pub type SharedCollabHub = Arc<RwLock<CollaborationHub>>;
340
341pub fn create_hub() -> SharedCollabHub {
343 Arc::new(RwLock::new(CollaborationHub::new()))
344}
345
346#[cfg(test)]
347mod tests {
348 use super::*;
349
350 #[test]
351 fn test_participant_creation() {
352 let p = Participant::new("Claude", ParticipantType::Claude);
353 assert_eq!(p.name, "Claude");
354 assert_eq!(p.participant_type, ParticipantType::Claude);
355 assert!(!p.in_hot_tub);
356 }
357
358 #[test]
359 fn test_hot_tub_toggle() {
360 let mut hub = CollaborationHub::new();
361 let p = Participant::new("Hue", ParticipantType::Human);
362 let id = hub.join(p);
363
364 assert!(!hub.participants.get(&id).unwrap().in_hot_tub);
365 hub.toggle_hot_tub(&id);
366 assert!(hub.participants.get(&id).unwrap().in_hot_tub);
367 }
368
369 #[test]
370 fn test_presence() {
371 let mut hub = CollaborationHub::new();
372 hub.join(Participant::new("Claude", ParticipantType::Claude));
373 hub.join(Participant::new("Omni", ParticipantType::Omni));
374
375 assert_eq!(hub.participant_count(), 2);
376 assert_eq!(hub.get_presence().len(), 2);
377 }
378}