Skip to main content

st/
collaboration.rs

1//! Collaboration Station - Multi-AI Real-time Collaboration
2//!
3//! "Oh Tai, let's invite Omni to the hot tub!" - Hue
4//!
5//! This module enables real-time collaboration between:
6//! - Humans (you!)
7//! - AIs (Claude, Omni, Grok, etc.)
8//! - Multiple Smart Tree instances
9//!
10//! Features:
11//! - Session tracking with presence
12//! - Message broadcasting
13//! - Shared workspace context
14//! - Hot Tub Mode (relaxed multi-AI chat)
15
16use serde::{Deserialize, Serialize};
17use std::collections::HashMap;
18use std::sync::Arc;
19use tokio::sync::{broadcast, RwLock};
20use uuid::Uuid;
21
22/// Maximum number of messages to keep in broadcast history
23const BROADCAST_CAPACITY: usize = 256;
24
25/// Participant types in a collaboration session
26#[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/// A participant in the collaboration
55#[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    /// Current status message
63    pub status: Option<String>,
64    /// What they're working on
65    pub working_on: Option<String>,
66    /// In hot tub mode?
67    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/// Messages that can be broadcast
91#[derive(Debug, Clone, Serialize, Deserialize)]
92#[serde(tag = "type", rename_all = "snake_case")]
93pub enum CollabMessage {
94    /// Someone joined
95    Join {
96        participant: Participant,
97    },
98    /// Someone left
99    Leave {
100        participant_id: String,
101        name: String,
102    },
103    /// Chat message
104    Chat {
105        from: String,
106        from_name: String,
107        message: String,
108        hot_tub: bool,
109    },
110    /// Status update
111    StatusUpdate {
112        participant_id: String,
113        status: Option<String>,
114        working_on: Option<String>,
115    },
116    /// File activity
117    FileActivity {
118        participant_id: String,
119        action: String,
120        path: String,
121    },
122    /// Hot tub mode toggle
123    HotTubToggle {
124        participant_id: String,
125        name: String,
126        entering: bool,
127    },
128    /// System announcement
129    System {
130        message: String,
131    },
132    /// Presence update (periodic)
133    Presence {
134        participants: Vec<ParticipantSummary>,
135        hot_tub_count: usize,
136    },
137    /// AI Prompt Request
138    Prompt {
139        prompt_id: String,
140        question: String,
141    },
142}
143
144/// Lightweight participant info for presence updates
145#[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/// The collaboration hub - manages all sessions and broadcasting
167#[derive(Debug)]
168pub struct CollaborationHub {
169    /// All connected participants
170    participants: HashMap<String, Participant>,
171    /// Broadcast channel for messages
172    broadcast_tx: broadcast::Sender<CollabMessage>,
173    /// Shared files being worked on
174    shared_files: HashMap<String, Vec<String>>, // path -> participant_ids
175    /// Hot tub mode enabled globally?
176    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, // Hot tub is always open! 🛁
187        }
188    }
189
190    /// Subscribe to collaboration messages
191    pub fn subscribe(&self) -> broadcast::Receiver<CollabMessage> {
192        self.broadcast_tx.subscribe()
193    }
194
195    /// Add a participant
196    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    /// Remove a participant
208    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    /// Send a chat message
220    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    /// Toggle hot tub mode for a participant
233    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    /// Update participant status
251    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    /// Record file activity
266    pub fn file_activity(&mut self, participant_id: &str, action: &str, path: &str) {
267        // Track who's working on what
268        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    /// Get current presence
284    pub fn get_presence(&self) -> Vec<ParticipantSummary> {
285        self.participants.values().map(ParticipantSummary::from).collect()
286    }
287
288    /// Get hot tub participants
289    pub fn get_hot_tub_participants(&self) -> Vec<&Participant> {
290        self.participants.values().filter(|p| p.in_hot_tub).collect()
291    }
292
293    /// Broadcast presence update
294    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    /// System announcement
305    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    /// Broadcast an AI prompt
313    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    /// Get participant count
322    pub fn participant_count(&self) -> usize {
323        self.participants.len()
324    }
325
326    /// Check if hot tub is open
327    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
338/// Thread-safe collaboration hub
339pub type SharedCollabHub = Arc<RwLock<CollaborationHub>>;
340
341/// Create a new shared collaboration hub
342pub 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}