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

@ -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<String>,
},
/// Fin d'étape OpenCode.
StepFinish,
/// Erreur terminale remontée par OpenCode.
@ -110,7 +115,10 @@ fn parse_jsonl_record(line: &str) -> Result<ParsedRecord, AgentSessionError> {
.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<String> {
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<String> {
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<String> {
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]