From 4180dd68086b59c1d5fde8067d091dc5cb1dd0a3 Mon Sep 17 00:00:00 2001 From: Mojtaba Chenani Date: Thu, 13 Aug 2026 23:43:19 +0200 Subject: [PATCH] feat: add is_active conversaiton --- bin/chat-cli/src/app.rs | 92 +++++++++++++++++++++---------- bin/chat-cli/src/ui.rs | 9 ++- core/conversations/src/core.rs | 5 ++ crates/generic-chat/src/client.rs | 5 ++ 4 files changed, 80 insertions(+), 31 deletions(-) diff --git a/bin/chat-cli/src/app.rs b/bin/chat-cli/src/app.rs index a04e49f..d04f033 100644 --- a/bin/chat-cli/src/app.rs +++ b/bin/chat-cli/src/app.rs @@ -94,6 +94,7 @@ where pub client: ChatClient, events: Receiver, pub state: AppState, + is_active: bool, /// Ephemeral command output — not persisted, cleared on chat switch. command_output: Vec, pub input: String, @@ -120,24 +121,29 @@ where let state = Self::load_state(&state_path); let chat_count = state.chats.len(); - let status = if chat_count > 0 { - format!( - "Welcome back, {user_name}! {chat_count} chat(s) loaded. Type /help for commands." - ) - } else { + let status = if chat_count == 0 { format!("Welcome, {user_name}! Type /help for commands.") + } else { + format!( + "Welcome back, {user_name}! {chat_count} chat(s) loaded — read-only from a previous \ + session; start a new /dm or /new to chat. Type /help." + ) }; - Ok(Self { + let mut app = Self { client, events, state, + is_active: false, command_output: Vec::new(), input: String::new(), status, user_name: user_name.to_string(), state_path, - }) + }; + app.state.active_chat = None; + app.show_chats_list(); + Ok(app) } fn load_state(path: &Path) -> AppState { @@ -172,6 +178,10 @@ where } fn set_active_chat(&mut self, chat_id: Option) { + self.is_active = chat_id + .as_deref() + .map(|id| self.client.has_conversation(id)) + .unwrap_or(false); self.state.active_chat = chat_id; self.command_output.clear(); } @@ -190,6 +200,39 @@ where self.set_active_chat(Some(chat_id)); } + pub fn is_active(&self) -> bool { + self.is_active + } + + /// Render the chat list; restored (dead) chats are flagged read-only. + fn show_chats_list(&mut self) { + self.command_output.clear(); + let sessions: Vec<_> = self.state.chats.values().cloned().collect(); + if sessions.is_empty() { + self.add_system_message("No chats yet. Use /dm or /new to start one."); + return; + } + self.add_system_message(&format!("── Your Chats ({}) ──", sessions.len())); + for s in &sessions { + let active = self.state.active_chat.as_deref() == Some(&s.chat_id); + let read_only = !self.client.has_conversation(&s.chat_id); + let mut tags = String::new(); + if active { + tags.push_str(" (active)"); + } + if read_only { + tags.push_str(" (read-only)"); + } + let label = format!( + " • [{}] {} ({}){tags}", + s.kind.badge(), + s.display_name(), + &s.chat_id[..8.min(s.chat_id.len())] + ); + self.add_system_message(&label); + } + } + /// Find a chat_id by nickname (exact) or chat_id prefix. fn resolve_chat_id(&self, query: &str) -> Option<&str> { // Exact nickname match first. @@ -273,6 +316,13 @@ where .clone() .ok_or_else(|| anyhow::anyhow!("No active chat. Use /dm or /new first."))?; + if !self.is_active { + anyhow::bail!( + "This conversation is from a previous session and can't receive messages yet \ + — chats don't persist across restart. Start a new one with /dm or /new." + ); + } + self.client .send_message(&chat_id, content.as_bytes()) .map_err(|e| anyhow::anyhow!("{e:?}"))?; @@ -473,29 +523,13 @@ where Ok(Some(format!("Nickname set to '{args}'"))) } "/chats" => { - let sessions: Vec<_> = self.state.chats.values().cloned().collect(); - if sessions.is_empty() { - Ok(Some( - "No chats yet. Use /dm or /new to start one.".to_string(), - )) + self.show_chats_list(); + let n = self.state.chats.len(); + Ok(Some(if n == 0 { + "No chats yet".to_string() } else { - self.add_system_message(&format!("── Your Chats ({}) ──", sessions.len())); - for s in &sessions { - let marker = if self.state.active_chat.as_deref() == Some(&s.chat_id) { - " (active)" - } else { - "" - }; - let label = format!( - " • [{}] {} ({}){marker}", - s.kind.badge(), - s.display_name(), - &s.chat_id[..8.min(s.chat_id.len())] - ); - self.add_system_message(&label); - } - Ok(Some(format!("{} chat(s)", sessions.len()))) - } + format!("{n} chat(s)") + })) } "/switch" => { if args.is_empty() { diff --git a/bin/chat-cli/src/ui.rs b/bin/chat-cli/src/ui.rs index f0ab65b..1ca1ad1 100644 --- a/bin/chat-cli/src/ui.rs +++ b/bin/chat-cli/src/ui.rs @@ -69,9 +69,14 @@ where let title = match app.current_session() { Some(session) => { let id = &session.chat_id[..8.min(session.chat_id.len())]; + let ro = if app.is_active() { + "" + } else { + " [read-only]" + }; match &session.nickname { - Some(name) => format!(" 💬 Chat: {} ↔ {name} ({id}) ", app.user_name), - None => format!(" 💬 Chat: {} ↔ ({id}) ", app.user_name), + Some(name) => format!(" 💬 Chat: {} ↔ {name} ({id}){ro} ", app.user_name), + None => format!(" 💬 Chat: {} ↔ ({id}){ro} ", app.user_name), } } None => format!(" 💬 {} — no active chat ", app.user_name), diff --git a/core/conversations/src/core.rs b/core/conversations/src/core.rs index b696594..b9b794c 100644 --- a/core/conversations/src/core.rs +++ b/core/conversations/src/core.rs @@ -325,6 +325,11 @@ impl<'a, S: ExternalServices + 'static> Core { Ok(convos) } + /// Whether the conversation is loaded and usable. + pub fn is_conversation_active(&self, convo_id: &str) -> bool { + self.cached_convos.contains_key(convo_id) + } + pub fn take_missing_messages(&self) -> Vec { self.services.causal.take_missing() } diff --git a/crates/generic-chat/src/client.rs b/crates/generic-chat/src/client.rs index f4e0df4..0fe4bc5 100644 --- a/crates/generic-chat/src/client.rs +++ b/crates/generic-chat/src/client.rs @@ -273,6 +273,11 @@ where self.core.lock().list_conversations().map_err(Into::into) } + /// Whether the conversation is live and usable this session. + pub fn has_conversation(&self, convo_id: &str) -> bool { + self.core.lock().is_conversation_active(convo_id) + } + /// Encrypt and send `content` to an existing conversation. The core /// publishes the outbound envelope. pub fn send_message(&mut self, convo_id: &str, content: &[u8]) -> Result<(), ClientError> {