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:
2026-08-11 18:49:12 +02:00
parent bc9a034249
commit ee0ffd4e2d
16 changed files with 654 additions and 61 deletions

View File

@ -156,6 +156,7 @@ pub fn parse_event(line: &str) -> Result<ParsedLine, AgentSessionError> {
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<String>,
) -> 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<String> {
.or_else(|| nested_non_blank(item, &["content", "text"]))
}
fn item_preview(item: &Value) -> Option<String> {
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<String> {
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<String> {
let mut cursor = root;
for key in path {