fix(chat): réattache persistante CLI custom et rendu tools/MCP/progress enrichi (#188)
Corrige la perte de session à la navigation: le démontage du CustomAgentChatView appelait close_agent_session (destructeur) au lieu de simplement détacher le transport. Désormais: - nouvelle commande Tauri detach_agent_chat (+ ChatBridge::detach) qui libère le canal sans tuer la session ni le scrollback retenu; distinct de close_agent_session qui reste le close destructeur explicite; - le frontend appelle detachAgentChat à l'unmount puis restore sa vue depuis un cache de session (turns + chunkCount) à la réattache; - message « Reprise effectuée: N événements rattrapés… » quand la réattache rejoue plus de chunks que le cache local n'en avait conservé. Enrichit le rendu tools/MCP/progress: - ProgressSettler (chat bridge) émet un progress completed synthétique quand un spinner reste ouvert (nouvelle entité progress, chunk text/final/error): fini les spinners fantômes; - les adapters claude/codex/opencode/openai_compat extraient un aperçu du payload (arguments/input/command/message/params) attaché au progress; - bounded_reply_progress_preview (domain) garde les 4 premières lignes non vides, normalisation CR/LF; - previewText (frontend) multiligne whitespace-pre-wrap, source/nativeEvent affichés sous le titre; suppression du flag settled et du Spinner inline devenus redondants (l'état est porté par le stage + le settler). Tests: detach_agent_chat gateway (Tauri + mock), ProgressSettler, bornage reply_progress, item_preview/tool_preview par adapter, réattache avec cache et notice de rattrapage.
This commit is contained in:
@ -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<ReplyProgressDto>,
|
||||
}
|
||||
|
||||
impl ProgressSettler {
|
||||
/// Completes the currently active progress entity, if any.
|
||||
pub fn finish(&mut self) -> Option<ReplyChunk> {
|
||||
self.complete_active()
|
||||
}
|
||||
|
||||
fn before(&mut self, chunk: &ReplyChunk) -> Option<ReplyChunk> {
|
||||
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<ReplyChunk> {
|
||||
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<ReplyChunk> {
|
||||
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<String> {
|
||||
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 } => {
|
||||
|
||||
Reference in New Issue
Block a user