From b7d1087e3593372bf14075d923b0ea4dfc6b6bb1 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Thu, 16 Apr 2026 08:38:18 +0200 Subject: [PATCH] feat: add live discussion archiving --- src-tauri/src/commands/live_agent.rs | 29 ++++ src-tauri/src/lib.rs | 1 + src-tauri/src/models/live_agent.rs | 39 ++++- src/components/projects/ProjectLiveAgent.tsx | 173 ++++++++++++++++--- src/lib/api.ts | 7 + 5 files changed, 223 insertions(+), 26 deletions(-) diff --git a/src-tauri/src/commands/live_agent.rs b/src-tauri/src/commands/live_agent.rs index df90759..5742a60 100644 --- a/src-tauri/src/commands/live_agent.rs +++ b/src-tauri/src/commands/live_agent.rs @@ -112,6 +112,29 @@ pub fn list_live_messages( Ok(messages) } +#[tauri::command] +pub fn set_live_session_archived( + state: State<'_, AppState>, + session_id: String, + archived: bool, +) -> Result { + let db = state + .db + .lock() + .map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?; + + let session = LiveSession::get_by_id(&db, &session_id)?; + let enabled = ProjectModule::is_enabled(&db, &session.project_id, MODULE_AI_LIVE_CHAT)?; + if !enabled { + return Err(AppError::from( + "Le module de live chat est désactivé pour ce projet".to_string(), + )); + } + + let updated = LiveSession::set_archived(&db, &session_id, archived)?; + Ok(updated) +} + #[tauri::command] pub async fn send_live_message( state: State<'_, AppState>, @@ -137,6 +160,12 @@ pub async fn send_live_message( "Le module de live chat est désactivé pour ce projet".to_string(), )); } + if session.status == "archived" { + return Err(AppError::from( + "Cette discussion live est archivée et ne peut plus recevoir de message." + .to_string(), + )); + } let project = Project::get_by_id(&db, &session.project_id)?; let agent = Agent::get_by_id(&db, &session.agent_id)?; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2222d59..21e3184 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -88,6 +88,7 @@ pub fn run() { commands::live_agent::create_live_session, commands::live_agent::list_live_sessions, commands::live_agent::list_live_messages, + commands::live_agent::set_live_session_archived, commands::live_agent::send_live_message, commands::task::create_agent_task, commands::task::list_agent_tasks, diff --git a/src-tauri/src/models/live_agent.rs b/src-tauri/src/models/live_agent.rs index 48ec800..c9c8cce 100644 --- a/src-tauri/src/models/live_agent.rs +++ b/src-tauri/src/models/live_agent.rs @@ -76,7 +76,7 @@ impl LiveSession { "SELECT id, project_id, agent_id, title, status, created_at, updated_at FROM project_live_sessions WHERE project_id = ?1 - ORDER BY updated_at DESC", + ORDER BY CASE status WHEN 'active' THEN 0 ELSE 1 END, updated_at DESC", )?; let rows = stmt.query_map(params![project_id], session_from_row)?; rows.collect() @@ -100,6 +100,22 @@ impl LiveSession { )?; Ok(()) } + + pub fn set_archived(conn: &Connection, id: &str, archived: bool) -> Result { + let now = chrono::Utc::now().to_rfc3339(); + let status = if archived { "archived" } else { "active" }; + + let affected = conn.execute( + "UPDATE project_live_sessions SET status = ?1, updated_at = ?2 WHERE id = ?3", + params![status, now, id], + )?; + + if affected == 0 { + return Err(rusqlite::Error::QueryReturnedNoRows); + } + + Self::get_by_id(conn, id) + } } impl LiveMessage { @@ -242,4 +258,25 @@ mod tests { LiveMessage::list_by_session(&conn, &session.id).expect("message list should work"); assert!(messages_after_delete.is_empty()); } + + #[test] + fn test_archive_session_updates_status_and_sort_order() { + let (conn, project_id, agent_id) = setup(); + let archived = + LiveSession::create(&conn, &project_id, &agent_id, "Archivee").expect("session create"); + let active = + LiveSession::create(&conn, &project_id, &agent_id, "Active").expect("session create"); + + let archived_session = + LiveSession::set_archived(&conn, &archived.id, true).expect("archive should work"); + assert_eq!(archived_session.status, "archived"); + + let sessions = + LiveSession::list_by_project(&conn, &project_id).expect("session list should work"); + assert_eq!(sessions.len(), 2); + assert_eq!(sessions[0].id, active.id); + assert_eq!(sessions[0].status, "active"); + assert_eq!(sessions[1].id, archived.id); + assert_eq!(sessions[1].status, "archived"); + } } diff --git a/src/components/projects/ProjectLiveAgent.tsx b/src/components/projects/ProjectLiveAgent.tsx index f4f41af..20a918c 100644 --- a/src/components/projects/ProjectLiveAgent.tsx +++ b/src/components/projects/ProjectLiveAgent.tsx @@ -7,6 +7,7 @@ import { listLiveMessages, listProjectModules, listLiveSessions, + setLiveSessionArchived, sendLiveMessage, } from "../../lib/api"; import { getErrorMessage } from "../../lib/errors"; @@ -70,6 +71,7 @@ export default function ProjectLiveAgent() { const [draft, setDraft] = useState(""); const [sending, setSending] = useState(false); const [creatingSession, setCreatingSession] = useState(false); + const [updatingSessionStatus, setUpdatingSessionStatus] = useState(false); const [moduleEnabled, setModuleEnabled] = useState(true); const [streamingAgentResponse, setStreamingAgentResponse] = useState(null); const [error, setError] = useState(null); @@ -78,6 +80,18 @@ export default function ProjectLiveAgent() { () => agents.filter((agent) => agent.role === "analyst" || agent.role === "developer"), [agents] ); + const selectedSession = useMemo( + () => sessions.find((session) => session.id === selectedSessionId) ?? null, + [sessions, selectedSessionId] + ); + const activeSessions = useMemo( + () => sessions.filter((session) => session.status !== "archived"), + [sessions] + ); + const archivedSessions = useMemo( + () => sessions.filter((session) => session.status === "archived"), + [sessions] + ); async function refreshSessions(defaultSessionId?: string) { if (!projectId) return; @@ -235,6 +249,10 @@ export default function ProjectLiveAgent() { setError("Le module Live chat agent est désactivé pour ce projet."); return; } + if (selectedSession?.status === "archived") { + setError("Cette discussion live est archivée et ne peut plus recevoir de message."); + return; + } const content = draft.trim(); setDraft(""); @@ -252,6 +270,22 @@ export default function ProjectLiveAgent() { } } + async function handleToggleArchive(archived: boolean) { + if (!selectedSession) return; + + setUpdatingSessionStatus(true); + setError(null); + setStreamingAgentResponse(null); + try { + await setLiveSessionArchived(selectedSession.id, archived); + await refreshSessions(selectedSession.id); + } catch (err: unknown) { + setError(getErrorMessage(err)); + } finally { + setUpdatingSessionStatus(false); + } + } + async function handleSessionChange(sessionId: string) { setSelectedSessionId(sessionId); setStreamingAgentResponse(null); @@ -319,33 +353,112 @@ export default function ProjectLiveAgent() {
Sessions
-
- {sessions.map((session) => ( - - ))} +
+
+
+ Actives +
+
+ {activeSessions.map((session) => ( + + ))} + {activeSessions.length === 0 && sessions.length > 0 && ( +
Aucune session active.
+ )} +
+
- {sessions.length === 0 && ( -
Aucune session.
- )} +
+
+ Archivées +
+
+ {archivedSessions.map((session) => ( + + ))} + {archivedSessions.length === 0 && sessions.length > 0 && ( +
Aucune session archivée.
+ )} +
+
+ + {sessions.length === 0 &&
Aucune session.
}
-
Discussion
+
+
+
Discussion
+ {selectedSession && ( +
+ {selectedSession.title} + {selectedSession.status === "archived" && ( + + Archivée + + )} +
+ )} +
+ {selectedSession && ( + + )} +
+ {selectedSession?.status === "archived" && ( +
+ Cette discussion est archivée. Elle reste consultable, mais l'envoi de nouveaux + messages est désactivé. +
+ )}
{messages .filter((msg) => !(msg.sender === "agent" && msg.content.trim() === "")) @@ -379,13 +492,23 @@ export default function ProjectLiveAgent() { type="text" value={draft} onChange={(e) => setDraft(e.target.value)} - placeholder="Ton message..." - disabled={!selectedSessionId || sending} + placeholder={ + selectedSession?.status === "archived" + ? "Discussion archivée en lecture seule" + : "Ton message..." + } + disabled={!selectedSessionId || sending || selectedSession?.status === "archived"} className="flex-1 rounded border border-gray-300 px-3 py-2 text-sm" />