diff --git a/crates/app-tauri/src/chat.rs b/crates/app-tauri/src/chat.rs index b5e866f3..05dd8808 100644 --- a/crates/app-tauri/src/chat.rs +++ b/crates/app-tauri/src/chat.rs @@ -34,7 +34,7 @@ use domain::ports::{ ReplyEvent, ReplyProgress, ReplyProgressKind, ReplyProgressSource, ReplyProgressStage, }; -use crate::dto::ReplyChunk; +use crate::dto::{ReplyChunk, ReplyProgressDto, ReplyProgressKindDto, ReplyProgressStageDto}; use crate::stream::TauriChannelSink; /// Maximum number of recent chat chunks retained for transport reattach replay. @@ -110,6 +110,13 @@ impl ChatBridge { self.inner.unregister(session); } + /// Detaches the current output channel while preserving the live session's + /// retained scrollback. This is the view-unmount path; it is intentionally + /// distinct from [`unregister`](Self::unregister), which is the real close. + pub fn detach(&self, session: &SessionId) { + self.inner.detach(session); + } + /// Detaches a session's channel **only if** `gen` is still the current /// generation, leaving the scrollback intact. A pump calls this when its turn /// stream ends: if the session was re-attached meanwhile (newer generation), @@ -136,6 +143,22 @@ impl ChatBridge { self.inner.send_output(session, chunk) } + /// Sends `chunk`, first completing any previous running progress entity when + /// the next chunk proves that another process/tool/MCP has taken over. + pub fn send_output_settled( + &self, + session: &SessionId, + settler: &mut ProgressSettler, + chunk: ReplyChunk, + ) -> bool { + let mut delivered = true; + if let Some(completed) = settler.before(&chunk) { + delivered &= self.send_output(session, completed); + } + let current_delivered = self.send_output(session, chunk); + delivered && current_delivered + } + /// Number of currently-tracked sessions (handy for tests/diagnostics). #[must_use] pub fn active_sessions(&self) -> usize { @@ -143,6 +166,86 @@ impl ChatBridge { } } +/// Tracks one running progress entity for a chat stream and emits a synthetic +/// `completed` update when subsequent activity makes the previous spinner stale. +#[derive(Default)] +pub struct ProgressSettler { + active: Option, +} + +impl ProgressSettler { + /// Completes the currently active progress entity, if any. + pub fn finish(&mut self) -> Option { + self.complete_active() + } + + fn before(&mut self, chunk: &ReplyChunk) -> Option { + match chunk { + ReplyChunk::Progress { progress } => self.before_progress(progress), + ReplyChunk::TextDelta { .. } + | ReplyChunk::ToolActivity { .. } + | ReplyChunk::Final { .. } + | ReplyChunk::Error { .. } + | ReplyChunk::UserPrompt { .. } => self.complete_active(), + } + } + + fn before_progress(&mut self, progress: &ReplyProgressDto) -> Option { + if !is_settleable_progress(progress) { + return self.complete_active(); + } + match progress.stage { + ReplyProgressStageDto::Started | ReplyProgressStageDto::Delta => { + let next_key = progress_entity_key(progress); + let previous = self.active.as_ref().and_then(progress_entity_key); + if previous.as_deref() == next_key.as_deref() { + self.active = Some(progress.clone()); + None + } else { + let completed = self.complete_active(); + self.active = Some(progress.clone()); + completed + } + } + ReplyProgressStageDto::Completed => { + let next_key = progress_entity_key(progress); + let previous = self.active.as_ref().and_then(progress_entity_key); + if previous.as_deref() == next_key.as_deref() { + self.active = None; + } + None + } + ReplyProgressStageDto::Info => self.complete_active(), + } + } + + fn complete_active(&mut self) -> Option { + let mut progress = self.active.take()?; + progress.stage = ReplyProgressStageDto::Completed; + Some(ReplyChunk::Progress { progress }) + } +} + +fn is_settleable_progress(progress: &ReplyProgressDto) -> bool { + matches!( + progress.kind, + ReplyProgressKindDto::Tool | ReplyProgressKindDto::Mcp | ReplyProgressKindDto::Message + ) +} + +fn progress_entity_key(progress: &ReplyProgressDto) -> Option { + if !is_settleable_progress(progress) { + return None; + } + let name = progress + .tool_name + .as_ref() + .filter(|name| !name.trim().is_empty()) + .unwrap_or(&progress.label); + let name = name.trim().to_ascii_lowercase(); + (!name.is_empty()).then(|| format!("{:?}:{name}", progress.kind)) +} + fn reply_chunk_bytes(chunk: &ReplyChunk) -> usize { match chunk { ReplyChunk::UserPrompt { text, attachments } => { diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index a1354c39..2bd9405c 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -2379,6 +2379,7 @@ pub async fn agent_send( let tap_bridge = std::sync::Arc::clone(&state.chat_bridge); let tap_sid = sid; let tap_pump = std::thread::spawn(move || { + let mut settler = crate::chat::ProgressSettler::default(); for event in tap_rx { let Some(chunk) = crate::chat::chunk_from_event(event) else { continue; @@ -2386,7 +2387,10 @@ pub async fn agent_send( if matches!(chunk, ReplyChunk::Final { .. } | ReplyChunk::Error { .. }) { continue; } - let _ = tap_bridge.send_output(&tap_sid, chunk); + let _ = tap_bridge.send_output_settled(&tap_sid, &mut settler, chunk); + } + if let Some(completed) = settler.finish() { + let _ = tap_bridge.send_output(&tap_sid, completed); } }); @@ -2411,6 +2415,7 @@ pub async fn agent_send( let meta = state.structured_sessions.meta_for_session(&sid); std::thread::spawn(move || { let mut terminal_visible = false; + let mut settler = crate::chat::ProgressSettler::default(); for event in stream { // Non-terminal, sans contenu chat : un signal de limite alimente le service // (le badge UI vient du bus `AgentRateLimited`, pas du flux chat) puis on @@ -2447,11 +2452,12 @@ pub async fn agent_send( // boolean only reflects live delivery. If the view navigated away // (no channel at this generation) we keep draining so the turn still // completes and the scrollback stays whole for the next re-attach. - let _ = bridge.send_output(&sid, chunk); + let _ = bridge.send_output_settled(&sid, &mut settler, chunk); } if !terminal_visible { - let _ = bridge.send_output( + let _ = bridge.send_output_settled( &sid, + &mut settler, ReplyChunk::Error { message: "Le modèle n'a renvoyé aucune réponse.".to_owned(), }, @@ -2967,6 +2973,28 @@ pub fn reattach_agent_chat( }) } +/// `detach_agent_chat` — detach only the custom chat view transport from a live +/// structured session, preserving the agent process and retained scrollback. +/// +/// This is the backend distinction between a disappearing view +/// (layout/tab/project switch, cell unmount) and [`close_agent_session`], which is +/// an explicit destructive close. +/// +/// # Errors +/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if no live +/// structured session owns the id). +#[tauri::command] +pub fn detach_agent_chat(session_id: String, state: State<'_, AppState>) -> Result<(), ErrorDto> { + let sid = parse_session_id(&session_id)?; + if state.structured_sessions.session(&sid).is_none() { + return Err(ErrorDto::from(AppError::NotFound(format!( + "structured session {sid}" + )))); + } + state.chat_bridge.detach(&sid); + Ok(()) +} + /// `close_agent_session` — shut a live structured session down and tear its /// transport (channel + conversation scrollback) down (ARCHITECTURE §17.7). /// diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index b413d8a0..882d894a 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -354,6 +354,7 @@ pub fn run() { commands::delegation_delivered, commands::set_front_attached, commands::reattach_agent_chat, + commands::detach_agent_chat, commands::close_agent_session, commands::list_resumable_agents, commands::inspect_conversation, diff --git a/crates/app-tauri/tests/chat_bridge.rs b/crates/app-tauri/tests/chat_bridge.rs index 112b4df1..f774b466 100644 --- a/crates/app-tauri/tests/chat_bridge.rs +++ b/crates/app-tauri/tests/chat_bridge.rs @@ -14,9 +14,13 @@ use std::sync::{Arc, Mutex}; use tauri::ipc::{Channel, InvokeResponseBody}; use app_tauri_lib::chat::{ - chunk_from_event, ChatBridge, MAX_CHAT_SCROLLBACK_BYTES, MAX_CHAT_SCROLLBACK_CHUNKS, + chunk_from_event, ChatBridge, ProgressSettler, MAX_CHAT_SCROLLBACK_BYTES, + MAX_CHAT_SCROLLBACK_CHUNKS, +}; +use app_tauri_lib::dto::{ + ReplyChunk, ReplyProgressDto, ReplyProgressKindDto, ReplyProgressSourceDto, + ReplyProgressStageDto, }; -use app_tauri_lib::dto::ReplyChunk; use domain::ids::SessionId; use domain::ports::{ ReplyEvent, ReplyProgress, ReplyProgressKind, ReplyProgressSource, ReplyProgressStage, @@ -59,6 +63,25 @@ fn error_chunk(s: &str) -> ReplyChunk { } } +fn progress_chunk( + kind: ReplyProgressKindDto, + stage: ReplyProgressStageDto, + label: &str, +) -> ReplyChunk { + ReplyChunk::Progress { + progress: ReplyProgressDto { + source: ReplyProgressSourceDto::ProviderNative, + kind, + stage, + label: label.to_owned(), + text: None, + provider: None, + native_event: None, + tool_name: Some(label.to_owned()), + }, + } +} + fn chunk_bytes(chunk: &ReplyChunk) -> usize { match chunk { ReplyChunk::UserPrompt { text, attachments } => { @@ -458,6 +481,47 @@ fn reattach_repaints_scrollback_and_preserves_it_without_resend() { ); } +#[test] +fn detach_view_keeps_session_scrollback_and_later_reattach_replays_absent_chunks() { + let bridge = ChatBridge::new(); + let session = sid(); + let first_sink = Arc::new(Mutex::new(Vec::new())); + let _gen = bridge.register(session, capturing_channel(Arc::clone(&first_sink))); + + assert!(bridge.send_output(&session, delta("before"))); + bridge.detach(&session); + + assert!( + !bridge.send_output(&session, delta("during")), + "detached view receives nothing live" + ); + assert_eq!( + bridge.active_sessions(), + 1, + "detach keeps session registered" + ); + assert_eq!( + bridge.scrollback(&session), + vec![delta("before"), delta("during")], + "chunks produced while detached are retained for reattach" + ); + assert_eq!(first_sink.lock().unwrap().as_slice(), &[delta("before")]); + + let reattach_sink = Arc::new(Mutex::new(Vec::new())); + bridge.register(session, capturing_channel(Arc::clone(&reattach_sink))); + assert!(bridge.send_output(&session, final_chunk("done"))); + + assert_eq!( + bridge.scrollback(&session), + vec![delta("before"), delta("during"), final_chunk("done")] + ); + assert_eq!( + reattach_sink.lock().unwrap().as_slice(), + &[final_chunk("done")], + "reattach receives only new live chunks; returned scrollback replays history" + ); +} + #[test] fn scrollback_of_unknown_session_is_empty() { let bridge = ChatBridge::new(); @@ -625,6 +689,71 @@ fn unregister_purges_channel_and_scrollback() { assert_eq!(sink.lock().unwrap().as_slice(), &[final_chunk("bye")]); } +#[test] +fn send_output_settled_completes_stale_tool_progress_before_next_activity() { + let bridge = ChatBridge::new(); + let session = sid(); + let sink = Arc::new(Mutex::new(Vec::new())); + bridge.register(session, capturing_channel(Arc::clone(&sink))); + let mut settler = ProgressSettler::default(); + + let started = progress_chunk( + ReplyProgressKindDto::Tool, + ReplyProgressStageDto::Started, + "read_file", + ); + let next = progress_chunk( + ReplyProgressKindDto::Mcp, + ReplyProgressStageDto::Started, + "idea_ask_agent", + ); + + assert!(bridge.send_output_settled(&session, &mut settler, started.clone())); + assert!(bridge.send_output_settled(&session, &mut settler, next.clone())); + assert!(bridge.send_output_settled(&session, &mut settler, delta("answer"))); + + let received = sink.lock().unwrap().clone(); + assert_eq!(received.len(), 5); + assert_eq!(received[0], started); + assert!(matches!( + &received[1], + ReplyChunk::Progress { progress } + if progress.label == "read_file" + && progress.stage == ReplyProgressStageDto::Completed + )); + assert_eq!(received[2], next); + assert!(matches!( + &received[3], + ReplyChunk::Progress { progress } + if progress.label == "idea_ask_agent" + && progress.stage == ReplyProgressStageDto::Completed + )); + assert_eq!(received[4], delta("answer")); +} + +#[test] +fn progress_settler_finish_completes_active_progress_at_stream_end() { + let bridge = ChatBridge::new(); + let session = sid(); + let sink = Arc::new(Mutex::new(Vec::new())); + bridge.register(session, capturing_channel(Arc::clone(&sink))); + let mut settler = ProgressSettler::default(); + let started = progress_chunk( + ReplyProgressKindDto::Tool, + ReplyProgressStageDto::Started, + "write_file", + ); + + assert!(bridge.send_output_settled(&session, &mut settler, started)); + + let Some(ReplyChunk::Progress { progress: done }) = settler.finish() else { + panic!("finish must complete the active progress") + }; + assert_eq!(done.label, "write_file"); + assert_eq!(done.stage, ReplyProgressStageDto::Completed); + assert!(settler.finish().is_none()); +} + // --------------------------------------------------------------------------- // Registry basics, mirrored from pty_bridge.rs for parity // --------------------------------------------------------------------------- diff --git a/crates/backend/src/stream.rs b/crates/backend/src/stream.rs index 75a028ba..c348ea8f 100644 --- a/crates/backend/src/stream.rs +++ b/crates/backend/src/stream.rs @@ -176,6 +176,15 @@ where } } + pub fn detach(&self, key: &K) { + if let Ok(mut map) = self.entries.lock() { + if let Some(entry) = map.get_mut(key) { + entry.sink = None; + entry.generation = entry.generation.wrapping_add(1); + } + } + } + pub fn detach_if(&self, key: &K, gen: u64) { if let Ok(mut map) = self.entries.lock() { if let Some(entry) = map.get_mut(key) { diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index 1967de46..9d366f4a 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -688,7 +688,7 @@ impl ReplyProgress { /// Ajoute un extrait textuel. #[must_use] pub fn with_text(mut self, text: impl Into) -> Self { - self.text = Some(text.into()); + self.text = bounded_reply_progress_preview(text.into()); self } @@ -712,6 +712,57 @@ impl ReplyProgress { } } +fn bounded_reply_progress_preview(text: String) -> Option { + let mut lines = Vec::new(); + let normalized = text.replace("\r\n", "\n").replace('\r', "\n"); + for line in normalized.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + lines.push(trimmed.to_owned()); + if lines.len() == 4 { + break; + } + } + if lines.is_empty() { + None + } else { + Some(lines.join("\n")) + } +} + +#[cfg(test)] +mod reply_progress_tests { + use super::{ReplyProgress, ReplyProgressKind, ReplyProgressSource, ReplyProgressStage}; + + #[test] + fn with_text_keeps_first_four_non_blank_lines() { + let progress = ReplyProgress::new( + ReplyProgressSource::ProviderNative, + ReplyProgressKind::Tool, + ReplyProgressStage::Started, + "tool", + ) + .with_text(" one \n\n two\r\n three\r four\n five "); + + assert_eq!(progress.text.as_deref(), Some("one\ntwo\nthree\nfour")); + } + + #[test] + fn with_text_drops_blank_preview() { + let progress = ReplyProgress::new( + ReplyProgressSource::ProviderNative, + ReplyProgressKind::Tool, + ReplyProgressStage::Started, + "tool", + ) + .with_text(" \n\t\r\n "); + + assert_eq!(progress.text, None); + } +} + /// Un événement incrémental d'un tour de réponse d'un agent IA (ARCHITECTURE §17.1). /// /// Universel : l'adapter (Claude/Codex) traduit SON format structuré documenté diff --git a/crates/infrastructure/src/session/claude.rs b/crates/infrastructure/src/session/claude.rs index 26271c7f..0220b520 100644 --- a/crates/infrastructure/src/session/claude.rs +++ b/crates/infrastructure/src/session/claude.rs @@ -153,15 +153,17 @@ fn assistant_events(value: &Value) -> Vec { .and_then(Value::as_str) .unwrap_or("outil") .to_owned(); - events.push(ReplyEvent::Progress { - progress: provider_progress( - ReplyProgressKind::Tool, - ReplyProgressStage::Started, - label.clone(), - "tool_use", - ) - .with_tool_name(label.clone()), - }); + let mut progress = provider_progress( + ReplyProgressKind::Tool, + ReplyProgressStage::Started, + label.clone(), + "tool_use", + ) + .with_tool_name(label.clone()); + if let Some(preview) = tool_use_preview(block) { + progress = progress.with_text(preview); + } + events.push(ReplyEvent::Progress { progress }); events.push(ReplyEvent::ToolActivity { label }); } _ => {} @@ -170,6 +172,22 @@ fn assistant_events(value: &Value) -> Vec { events } +fn tool_use_preview(block: &Value) -> Option { + block + .get("input") + .or_else(|| block.get("arguments")) + .or_else(|| block.get("parameters")) + .map(preview_value) + .filter(|text| !text.trim().is_empty()) +} + +fn preview_value(value: &Value) -> String { + value + .as_str() + .map(str::to_owned) + .unwrap_or_else(|| value.to_string()) +} + fn provider_progress( kind: ReplyProgressKind, stage: ReplyProgressStage, diff --git a/crates/infrastructure/src/session/codex.rs b/crates/infrastructure/src/session/codex.rs index ec04224b..38d1e0d4 100644 --- a/crates/infrastructure/src/session/codex.rs +++ b/crates/infrastructure/src/session/codex.rs @@ -156,6 +156,7 @@ pub fn parse_event(line: &str) -> Result { let label = item_label(item).unwrap_or_else(|| kind.to_owned()); events.push(ReplyEvent::Progress { progress: item_progress( + item, &label, ReplyProgressStage::Completed, value @@ -190,11 +191,12 @@ fn provider_progress( } fn item_progress( + item: &Value, label: &str, stage: ReplyProgressStage, native_event: impl Into, ) -> ReplyProgress { - if label.starts_with("idea_") { + let progress = if label.starts_with("idea_") { ReplyProgress::new( ReplyProgressSource::IdeaLocal, ReplyProgressKind::Mcp, @@ -211,6 +213,11 @@ fn item_progress( native_event, ) .with_tool_name(label.to_owned()) + }; + if let Some(preview) = item_preview(item) { + progress.with_text(preview) + } else { + progress } } @@ -228,7 +235,7 @@ fn append_item_progress( } let label = item_label(item).unwrap_or(kind); events.push(ReplyEvent::Progress { - progress: item_progress(&label, stage, native_event), + progress: item_progress(item, &label, stage, native_event), }); } @@ -255,6 +262,26 @@ fn item_text(item: &Value) -> Option { .or_else(|| nested_non_blank(item, &["content", "text"])) } +fn item_preview(item: &Value) -> Option { + non_blank_string(item.get("arguments")) + .or_else(|| non_blank_string(item.get("input"))) + .or_else(|| non_blank_string(item.get("command"))) + .or_else(|| non_blank_string(item.get("text"))) + .or_else(|| non_blank_string(item.get("message"))) + .or_else(|| nested_non_blank(item, &["function", "arguments"])) + .or_else(|| nested_non_blank(item, &["function_call", "arguments"])) + .or_else(|| nested_non_blank(item, &["call", "arguments"])) + .or_else(|| value_preview(item.get("arguments"))) + .or_else(|| value_preview(item.get("input"))) +} + +fn value_preview(value: Option<&Value>) -> Option { + value + .filter(|value| value.is_object() || value.is_array()) + .map(Value::to_string) + .filter(|text| !text.trim().is_empty()) +} + fn nested_non_blank(root: &Value, path: &[&str]) -> Option { let mut cursor = root; for key in path { diff --git a/crates/infrastructure/src/session/openai_compat.rs b/crates/infrastructure/src/session/openai_compat.rs index a65999fa..d330a971 100644 --- a/crates/infrastructure/src/session/openai_compat.rs +++ b/crates/infrastructure/src/session/openai_compat.rs @@ -466,6 +466,7 @@ impl OpenAiCompatibleSession { ReplyProgressStage::Started, format!("appel MCP {}", call.name), &call.name, + Some(call.arguments.as_str()), ); send_tap(tap, &started); events.push(started); @@ -485,6 +486,7 @@ impl OpenAiCompatibleSession { ReplyProgressStage::Completed, format!("MCP {} terminé", call.name), &call.name, + Some(result.as_str()), ); send_tap(tap, &completed); events.push(completed); @@ -658,16 +660,19 @@ fn local_tool_progress( stage: ReplyProgressStage, label: impl Into, tool_name: &str, + preview: Option<&str>, ) -> ReplyEvent { - ReplyEvent::Progress { - progress: ReplyProgress::new( - ReplyProgressSource::IdeaLocal, - ReplyProgressKind::Mcp, - stage, - label, - ) - .with_tool_name(tool_name.to_owned()), + let mut progress = ReplyProgress::new( + ReplyProgressSource::IdeaLocal, + ReplyProgressKind::Mcp, + stage, + label, + ) + .with_tool_name(tool_name.to_owned()); + if let Some(preview) = preview { + progress = progress.with_text(preview); } + ReplyEvent::Progress { progress } } fn should_retry_without_tools(err: &AgentSessionError, using_tools: bool) -> bool { diff --git a/crates/infrastructure/src/session/opencode.rs b/crates/infrastructure/src/session/opencode.rs index 0251e374..8dad5951 100644 --- a/crates/infrastructure/src/session/opencode.rs +++ b/crates/infrastructure/src/session/opencode.rs @@ -31,7 +31,12 @@ pub enum ParsedEvent { /// Fragment texte assistant. Text(String), /// Activité outil OpenCode. - ToolActivity(String), + ToolActivity { + /// Nom d'outil affichable. + label: String, + /// Aperçu borné du payload d'appel outil, quand OpenCode l'expose. + preview: Option, + }, /// Fin d'étape OpenCode. StepFinish, /// Erreur terminale remontée par OpenCode. @@ -110,7 +115,10 @@ fn parse_jsonl_record(line: &str) -> Result { .and_then(Value::as_str) .map(|text| ParsedEvent::Text(text.to_owned())) .unwrap_or(ParsedEvent::Ignored), - Some("tool_use" | "tool.use" | "tool") => ParsedEvent::ToolActivity(tool_label(&value)), + Some("tool_use" | "tool.use" | "tool") => ParsedEvent::ToolActivity { + label: tool_label(&value), + preview: tool_preview(&value), + }, Some("error" | "session.error") => ParsedEvent::Error(opencode_error_message(&value)), _ => ParsedEvent::Ignored, }; @@ -168,6 +176,39 @@ fn tool_label(value: &Value) -> String { .unwrap_or_else(|| "outil OpenCode".to_owned()) } +fn tool_preview(value: &Value) -> Option { + let part = value.get("part").unwrap_or(value); + non_blank_string(part.get("arguments")) + .or_else(|| non_blank_string(part.get("input"))) + .or_else(|| non_blank_string(part.get("params"))) + .or_else(|| non_blank_string(part.get("command"))) + .or_else(|| non_blank_string(value.get("arguments"))) + .or_else(|| non_blank_string(value.get("input"))) + .or_else(|| non_blank_string(value.get("params"))) + .or_else(|| non_blank_string(value.get("command"))) + .or_else(|| value_preview(part.get("arguments"))) + .or_else(|| value_preview(part.get("input"))) + .or_else(|| value_preview(part.get("params"))) + .or_else(|| value_preview(value.get("arguments"))) + .or_else(|| value_preview(value.get("input"))) + .or_else(|| value_preview(value.get("params"))) +} + +fn non_blank_string(value: Option<&Value>) -> Option { + value + .and_then(Value::as_str) + .map(str::trim) + .filter(|text| !text.is_empty()) + .map(str::to_owned) +} + +fn value_preview(value: Option<&Value>) -> Option { + value + .filter(|value| value.is_object() || value.is_array()) + .map(Value::to_string) + .filter(|text| !text.trim().is_empty()) +} + /// Convertit des lignes JSONL OpenCode en événements domaine. /// /// Le `Final` est la concaténation ordonnée des événements `text` du tour courant. @@ -226,10 +267,12 @@ fn append_record_events( }); events.push(ReplyEvent::Heartbeat); } - ParsedEvent::ToolActivity(label) => { - events.push(ReplyEvent::Progress { - progress: tool_progress(&label), - }); + ParsedEvent::ToolActivity { label, preview } => { + let mut progress = tool_progress(&label); + if let Some(preview) = preview { + progress = progress.with_text(preview); + } + events.push(ReplyEvent::Progress { progress }); events.push(ReplyEvent::ToolActivity { label }); } ParsedEvent::Text(text) => { @@ -396,7 +439,7 @@ fn current_turn_records( matches!( record.event, ParsedEvent::Text(_) - | ParsedEvent::ToolActivity(_) + | ParsedEvent::ToolActivity { .. } | ParsedEvent::StepStart | ParsedEvent::StepFinish | ParsedEvent::Error(_) @@ -871,7 +914,13 @@ mod tests { #[test] fn parse_jsonl_event_accepts_tool_use() { let event = parse_jsonl_event(r#"{"type":"tool_use","part":{"tool":"bash"}}"#).unwrap(); - assert_eq!(event, ParsedEvent::ToolActivity("bash".to_owned())); + assert_eq!( + event, + ParsedEvent::ToolActivity { + label: "bash".to_owned(), + preview: None, + } + ); } #[test] diff --git a/frontend/src/adapters/agent.test.ts b/frontend/src/adapters/agent.test.ts index 52aca821..4deb627a 100644 --- a/frontend/src/adapters/agent.test.ts +++ b/frontend/src/adapters/agent.test.ts @@ -171,6 +171,15 @@ describe("TauriAgentGateway invoke payloads", () => { expect(invoke).not.toHaveBeenCalledWith("close_agent_session", expect.anything()); }); + it("detachAgentChat invokes detach_agent_chat without closing the session", async () => { + await new TauriAgentGateway().detachAgentChat("chat-session-1"); + + expect(invoke).toHaveBeenCalledWith("detach_agent_chat", { + sessionId: "chat-session-1", + }); + expect(invoke).not.toHaveBeenCalledWith("close_agent_session", expect.anything()); + }); + it("sendAgentChat forwards structured clipboard attachments to agent_send", async () => { await new TauriAgentGateway().sendAgentChat( "chat-session-1", diff --git a/frontend/src/adapters/agent.ts b/frontend/src/adapters/agent.ts index 3aaca1e6..1a80a22f 100644 --- a/frontend/src/adapters/agent.ts +++ b/frontend/src/adapters/agent.ts @@ -262,6 +262,10 @@ export class TauriAgentGateway implements AgentGateway { }); } + async detachAgentChat(sessionId: string): Promise { + await invoke("detach_agent_chat", { sessionId }); + } + async sendAgentChat( sessionId: string, prompt: string, diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index 01f7e499..b3f55c1f 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -1026,6 +1026,15 @@ export class MockAgentGateway implements AgentGateway { } } + async detachAgentChat(sessionId: string): Promise { + if (!this.chatScrollback.has(sessionId)) { + throw { + code: "NOT_FOUND", + message: `agent chat session ${sessionId} not found`, + } as GatewayError; + } + } + async cancelAgentChat(sessionId: string): Promise { if (!this.chatScrollback.has(sessionId)) { throw { diff --git a/frontend/src/features/agents/CustomAgentChatView.test.tsx b/frontend/src/features/agents/CustomAgentChatView.test.tsx index 216165fa..b1f281dd 100644 --- a/frontend/src/features/agents/CustomAgentChatView.test.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.test.tsx @@ -1,9 +1,9 @@ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { useState } from "react"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { DIProvider } from "@/app/di"; -import type { AgentProfile } from "@/domain"; +import type { AgentProfile, ReplyChunk } from "@/domain"; import { PluginRuntimeProvider } from "@/features/plugins"; import { PluginCommandRegistry, @@ -12,7 +12,10 @@ import { PluginRuntimeRegistry, } from "@/plugins/runtime"; import type { ChatDraftScope, Gateways } from "@/ports"; -import { CustomAgentChatView } from "./CustomAgentChatView"; +import { + CustomAgentChatView, + __clearCustomAgentChatViewCacheForTests, +} from "./CustomAgentChatView"; const profile: AgentProfile = { id: "structured", @@ -57,6 +60,11 @@ function addPluginCommand( } describe("CustomAgentChatView", () => { + beforeEach(() => { + vi.clearAllMocks(); + __clearCustomAgentChatViewCacheForTests(); + }); + it("repaints attachment labels from user prompt scrollback", async () => { const agent = { launchAgentChat: vi.fn(), @@ -101,6 +109,75 @@ describe("CustomAgentChatView", () => { expect(screen.getByText("Fichier: clipboard.png")).toBeTruthy(); }); + it("detaches on unmount then reattaches with missed scrollback chunks", async () => { + let scrollback: ReplyChunk[] = [ + { kind: "userPrompt" as const, text: "Initial prompt" }, + { kind: "textDelta" as const, text: "Initial reply" }, + ]; + const agent = { + launchAgentChat: vi.fn(), + reattachAgentChat: vi.fn(async (sessionId: string) => ({ + sessionId, + scrollback, + })), + detachAgentChat: vi.fn(async () => {}), + sendAgentChat: vi.fn(async () => {}), + cancelAgentChat: vi.fn(async () => {}), + closeAgentChat: vi.fn(async () => {}), + }; + const props = { + projectId: "project-1", + agentId: "agent-1", + agentName: "Worker", + profile, + cwd: "/repo", + nodeId: "node-1", + sessionId: "chat-session-1", + conversationId: "conversation-1", + onSessionId: vi.fn(), + onConversationId: vi.fn(), + }; + + const first = render( + null) }, + } as unknown as Gateways} + > + + , + ); + + expect(await screen.findByText("Initial reply")).toBeTruthy(); + first.unmount(); + await waitFor(() => expect(agent.detachAgentChat).toHaveBeenCalledWith("chat-session-1")); + + scrollback = [ + ...scrollback, + { kind: "toolActivity" as const, label: "Background tool" }, + { kind: "final" as const, content: "Completed away" }, + ]; + + render( + null) }, + } as unknown as Gateways} + > + + , + ); + + expect(screen.getByText("Initial reply")).toBeTruthy(); + expect(await screen.findByText("Background tool")).toBeTruthy(); + expect(screen.getByText("Completed away")).toBeTruthy(); + expect( + screen.getByText("Reprise effectuée: 2 événements rattrapés pendant l'absence."), + ).toBeTruthy(); + }); + it("stops the live agent with cascade feedback and releases the structured session", async () => { let resolveStop: (value: { agentId: string; @@ -614,7 +691,7 @@ describe("CustomAgentChatView", () => { expect(screen.getByText("Worker → QA")).toBeTruthy(); expect(screen.getByText("Worker → QA").parentElement?.textContent).toContain("started"); - expect(screen.getAllByRole("status", { name: "Loading" }).length).toBeGreaterThan(0); + expect(screen.queryByRole("status", { name: "Loading" })).toBeNull(); act(() => { emitChunk?.(progress("providerNative", "completed")); @@ -638,7 +715,7 @@ describe("CustomAgentChatView", () => { ); }); - it("stops animating running progress rows once the turn reaches a final chunk", async () => { + it("renders running progress without spinner and marks it done on final", async () => { let emitChunk: ((chunk: unknown) => void) | null = null; const agent = { launchAgentChat: vi.fn(), @@ -707,7 +784,7 @@ describe("CustomAgentChatView", () => { }); }); - expect(screen.getAllByRole("status", { name: "Loading" }).length).toBeGreaterThan(0); + expect(screen.queryByRole("status", { name: "Loading" })).toBeNull(); expect(screen.getByText("Progress").parentElement?.textContent).toContain("running"); act(() => { diff --git a/frontend/src/features/agents/CustomAgentChatView.tsx b/frontend/src/features/agents/CustomAgentChatView.tsx index 2001bcc2..5b7d5dec 100644 --- a/frontend/src/features/agents/CustomAgentChatView.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.tsx @@ -53,6 +53,17 @@ type ChatTurn = | { role: "error"; text: string } | { role: "unknown"; text: string }; +interface CachedChatView { + turns: ChatTurn[]; + chunkCount: number; +} + +const chatSessionViewCache = new Map(); + +export function __clearCustomAgentChatViewCacheForTests() { + chatSessionViewCache.clear(); +} + interface AttachmentDraft { id: string; label: string; @@ -288,6 +299,17 @@ function appendProgress(turns: ChatTurn[], progress: ReplyProgress): ChatTurn[] return [...turns, { role: "progress", progress }]; } +function reattachNotice(missedChunks: number): ChatTurn | null { + if (missedChunks <= 0) return null; + return { + role: "tool", + label: + missedChunks === 1 + ? "Reprise effectuée: 1 événement rattrapé pendant l'absence." + : `Reprise effectuée: ${missedChunks} événements rattrapés pendant l'absence.`, + }; +} + function mergeCompletedProgress( running: ReplyProgress, completed: ReplyProgress, @@ -522,7 +544,9 @@ export function CustomAgentChatView({ }: CustomAgentChatViewProps) { const { agent, profile: profileGateway, system } = useGateways(); const pluginRuntime = usePluginRuntime(); - const [turns, setTurns] = useState([]); + const [turns, setTurns] = useState( + () => (sessionId ? (chatSessionViewCache.get(sessionId)?.turns ?? []) : []), + ); const [currentSession, setCurrentSession] = useState(sessionId); const [externalSessionId, setExternalSessionId] = useState(sessionId); const [draft, setDraft] = useState(""); @@ -540,6 +564,11 @@ export function CustomAgentChatView({ const composerRef = useRef(null); const sessionRef = useRef(sessionId); sessionRef.current = currentSession; + const scrollbackChunkCountRef = useRef( + sessionId ? (chatSessionViewCache.get(sessionId)?.chunkCount ?? 0) : 0, + ); + const detachAgentChatRef = useRef(agent.detachAgentChat); + detachAgentChatRef.current = agent.detachAgentChat; const slashQuerySeqRef = useRef(0); const draftSaveSeqRef = useRef(0); const draftHydratedRef = useRef(false); @@ -593,14 +622,30 @@ export function CustomAgentChatView({ mountedRef.current = true; return () => { mountedRef.current = false; + const sid = sessionRef.current; + if (!sid) return; + void detachAgentChatRef.current?.(sid).catch((e) => { + if (!isNotFound(e)) { + console.warn("[ticket188] detachAgentChat failed", instrumentationError(e)); + } + }); }; }, []); const receive = useCallback((chunk: ReplyChunk) => { + scrollbackChunkCountRef.current += 1; setTurns((prev) => foldChunk(prev, chunk)); if (chunk.kind === "final" || chunk.kind === "error") setBusy(false); }, []); + useEffect(() => { + if (!currentSession) return; + chatSessionViewCache.set(currentSession, { + turns, + chunkCount: scrollbackChunkCountRef.current, + }); + }, [currentSession, turns]); + useEffect(() => { draftSaveSeqRef.current += 1; draftHydratedRef.current = false; @@ -748,7 +793,14 @@ export function CustomAgentChatView({ if (!mountedRef.current) return reattached.sessionId; setCurrentSession(reattached.sessionId); if (options.applyScrollback) { - setTurns(reattached.scrollback.reduce(foldChunk, [] as ChatTurn[])); + const cachedCount = chatSessionViewCache.get(sid)?.chunkCount ?? 0; + scrollbackChunkCountRef.current = Math.max( + scrollbackChunkCountRef.current, + reattached.scrollback.length, + ); + const replayed = reattached.scrollback.reduce(foldChunk, [] as ChatTurn[]); + const notice = reattachNotice(reattached.scrollback.length - cachedCount); + setTurns(notice ? [...replayed, notice] : replayed); } return reattached.sessionId; }, @@ -810,6 +862,7 @@ export function CustomAgentChatView({ launched, }); if (!mountedRef.current) return launched.sessionId; + scrollbackChunkCountRef.current = 0; setCurrentSession(launched.sessionId); publishSessionId(launched.sessionId); if (launched.assignedConversationId) { @@ -888,6 +941,11 @@ export function CustomAgentChatView({ setError(null); try { if (externalSessionId) { + const cached = chatSessionViewCache.get(externalSessionId); + if (cached) { + scrollbackChunkCountRef.current = cached.chunkCount; + setTurns(cached.turns); + } try { await reattachStructuredSession(externalSessionId, { applyScrollback: true }); if (cancelled) return; @@ -1385,7 +1443,9 @@ export function CustomAgentChatView({ {opening && turns.length === 0 ? (
- Ouverture de la session structurée… + + {externalSessionId ? "Reprise en cours..." : "Ouverture de la session structurée…"} +
) : turns.length === 0 ? (

@@ -1572,7 +1632,6 @@ function ChatBubble({ ); } @@ -1683,9 +1742,20 @@ function compactToolName(progress: ReplyProgress): string { return progress.toolName ?? progress.label; } -function truncateText(text: string, max = 140): string { - const flat = text.replace(/\s+/g, " ").trim(); - return flat.length > max ? `${flat.slice(0, max - 3)}...` : flat; +function previewText(text: string, maxLineLength = 160): string { + return text + .replace(/\r\n/g, "\n") + .replace(/\r/g, "\n") + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .slice(0, 4) + .map((line) => + line.length > maxLineLength + ? `${line.slice(0, Math.max(0, maxLineLength - 3))}...` + : line, + ) + .join("\n"); } function parseKeyValue(text: string, key: string): string | null { @@ -1710,9 +1780,9 @@ function delegationSummary(progress: ReplyProgress): { requester, target, excerpt: task - ? truncateText(task, 92) + ? previewText(task, 92) : fallback - ? truncateText(fallback, 92) + ? previewText(fallback, 92) : null, }; } @@ -1743,16 +1813,13 @@ function TurnMarker({ progress }: { progress: ReplyProgress }) { function ToolProgressCard({ progress, requesterName, - settled, }: { progress: ReplyProgress; requesterName: string; - settled: boolean; }) { - const running = !settled && (progress.stage === "started" || progress.stage === "delta"); const stage = progressStageLabel(progress.stage); const detail = - progress.text && !isDelegationProgress(progress) ? truncateText(progress.text) : null; + progress.text && !isDelegationProgress(progress) ? previewText(progress.text) : null; const delegation = isDelegationProgress(progress) ? delegationSummary(progress) : null; const title = delegation ? delegation.target @@ -1770,7 +1837,6 @@ function ToolProgressCard({ )} >

- {running && } {title} {!delegation && ( {compactToolName(progress)} @@ -1787,8 +1853,19 @@ function ToolProgressCard({ {compactToolName(progress)}

)} + {(progress.source || progress.nativeEvent || progress.provider) && ( +
+ {progressSourceLabel(progress)} + {progress.nativeEvent && ( + event: {progress.nativeEvent} + )} +
+ )} {detail && ( -

+

{detail}

)} @@ -1800,11 +1877,9 @@ function ToolProgressCard({ function ProgressBubble({ progress, requesterName, - settled = false, }: { progress: ReplyProgress; requesterName: string; - settled?: boolean; }) { if (isTimelineProgress(progress)) return ; if (isCenteredToolProgress(progress)) { @@ -1812,15 +1887,13 @@ function ProgressBubble({ ); } const title = progressTitle(progress); const stage = progressStageLabel(progress.stage); const source = progressSourceLabel(progress); - const detail = progress.text ? truncateText(progress.text, 260) : null; - const running = !settled && (progress.stage === "started" || progress.stage === "delta"); + const detail = progress.text ? previewText(progress.text, 180) : null; return (
- {running && } {title} {progress.label} · {stage} diff --git a/frontend/src/ports/index.ts b/frontend/src/ports/index.ts index 38f87838..87f8b10f 100644 --- a/frontend/src/ports/index.ts +++ b/frontend/src/ports/index.ts @@ -311,6 +311,8 @@ export interface AgentGateway { sessionId: string, onChunk: (chunk: ReplyChunk) => void, ): Promise; + /** Detaches the current custom chat view transport while keeping the session alive. */ + detachAgentChat?(sessionId: string): Promise; /** Sends one user prompt to a live structured session. */ sendAgentChat?( sessionId: string,