feat(terminals): reprise de conversation par cellule + fix ordre d'écriture

Permet de recharger la conversation CLI précédente de chaque cellule à la
réouverture du projet, de façon universelle (indépendant du modèle/CLI).

- profil AgentRuntime: bloc déclaratif optionnel `session { assignFlag, resumeFlag }`
- LeafCell: `conversationId` (persistant, distinct du SessionId PTY) + `agentWasRunning`
- runtime: SessionPlan (None/Assign/Resume) + composition pure des args
- LaunchAgent: décide Assign vs Resume, génère l'UUID, remonte l'id assigné
  (persistance par l'appelant via setCellConversation — découplage SRP)
- close: SnapshotRunningAgents fige `agentWasRunning` avant le kill-all
  (statut clot/en cours universel, sans parsing CLI)
- SessionInspector: port optionnel best-effort + adapter ClaudeTranscriptInspector
- popup de reprise par cellule (statut + sujet/tokens si dispo), intercalée
  avant le Resume auto, jamais sur le chemin reattach

fix(terminals): sérialise les écritures PTY (file FIFO par handle) — corrige
les caractères mélangés/accents dus au réordonnancement des invoke Tauri concurrents

fix(layout): l'opération `move` préservait mal les champs du leaf (perdait `agent`)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 22:27:08 +02:00
parent d11eaaa8c0
commit 3ed0f6b45f
61 changed files with 5098 additions and 98 deletions

View File

@ -0,0 +1,142 @@
//! Best-effort conversation inspection use case (CONTEXT §T7, Part A).
//!
//! [`InspectConversation`] enriches a resume popup with the *last topic* and a
//! *token indicator* read from a CLI's on-disk transcript. It is **best-effort
//! by construction**: it routes the agent's [`AgentProfile`] to the first
//! injected [`SessionInspector`] that [`supports`](SessionInspector::supports)
//! it, and *any* miss — no inspector at all, an unsupported profile,
//! [`InspectError::NotFound`], or [`InspectError::Read`] — degrades to **empty
//! details** (`last_topic: None, token_count: None`) instead of an error. The
//! resume must never be blocked by inspection.
//!
//! Extensibility (Open/Closed): adding a new inspectable CLI is *pushing one
//! more adapter into the `Vec`* at the composition root — no change here.
//!
//! Like [`super::lifecycle::LaunchAgent`], it resolves the agent from the
//! project manifest and its profile from the [`ProfileStore`], and inspects
//! against the agent's **isolated run directory** (the very `cwd` the CLI was
//! launched with) so the inspector points at the right transcript folder.
use std::sync::Arc;
use domain::ports::{
AgentContextStore, ConversationDetails, InspectError, ProfileStore, SessionInspector,
};
use domain::{AgentId, Project};
use super::lifecycle::agent_run_dir;
use crate::error::AppError;
/// Input for [`InspectConversation::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InspectConversationInput {
/// The project owning the agent.
pub project: Project,
/// The agent whose conversation is being inspected.
pub agent_id: AgentId,
/// The persistent CLI conversation id recorded on the hosting cell.
pub conversation_id: String,
}
/// Output of [`InspectConversation::execute`]: the (possibly empty) best-effort
/// details. Never an inspection error — a miss surfaces as empty fields.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InspectConversationOutput {
/// Enriched, best-effort details (every field optional).
pub details: ConversationDetails,
}
/// Reads best-effort [`ConversationDetails`] for an agent's conversation.
///
/// Holds a (possibly empty) `Vec<Arc<dyn SessionInspector>>`: the agent's
/// profile is routed to the first inspector that supports it. The use case still
/// needs the context store (resolve the agent) and the profile store (resolve
/// the profile), exactly like [`super::lifecycle::LaunchAgent`].
pub struct InspectConversation {
contexts: Arc<dyn AgentContextStore>,
profiles: Arc<dyn ProfileStore>,
inspectors: Vec<Arc<dyn SessionInspector>>,
}
impl InspectConversation {
/// Builds the use case from its injected ports and the inspector list (which
/// may be empty: that path simply yields empty details).
#[must_use]
pub fn new(
contexts: Arc<dyn AgentContextStore>,
profiles: Arc<dyn ProfileStore>,
inspectors: Vec<Arc<dyn SessionInspector>>,
) -> Self {
Self {
contexts,
profiles,
inspectors,
}
}
/// Resolves the agent + profile + run dir, then asks the first supporting
/// inspector for details. Returns **empty** details when no inspector
/// matches or inspection misses (`NotFound`/`Read`); only genuine store
/// failures (loading the manifest / profiles) surface as an error.
///
/// # Errors
/// - [`AppError::NotFound`] if the agent or its profile is unknown,
/// - [`AppError::Invalid`] if a persisted manifest entry / run dir is invalid,
/// - [`AppError::Store`] on a manifest / profile store failure.
pub async fn execute(
&self,
input: InspectConversationInput,
) -> Result<InspectConversationOutput, AppError> {
// Resolve the agent from the manifest (for its profile + run dir).
let manifest = self.contexts.load_manifest(&input.project).await?;
let entry = manifest
.entries
.iter()
.find(|e| e.agent_id == input.agent_id)
.ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?;
let agent = entry
.to_agent()
.map_err(|e| AppError::Invalid(e.to_string()))?;
let profile = self
.profiles
.list()
.await?
.into_iter()
.find(|p| p.id == agent.profile_id)
.ok_or_else(|| AppError::NotFound(format!("profile {} for agent", agent.profile_id)))?;
// The CLI runs with its isolated run dir as cwd; the inspector keys its
// transcript lookup off that same cwd (same value LaunchAgent uses).
let run_dir = agent_run_dir(&input.project.root, &agent.id)
.map_err(|e| AppError::Invalid(e.to_string()))?;
// Route to the first inspector that supports this profile. No match ⇒
// empty details (best-effort).
let Some(inspector) = self.inspectors.iter().find(|i| i.supports(&profile)) else {
return Ok(InspectConversationOutput {
details: empty_details(),
});
};
// Any inspection miss (NotFound / Read) degrades to empty details — it
// must never block a resume.
let details = match inspector
.details(&profile, &input.conversation_id, &run_dir)
.await
{
Ok(details) => details,
Err(InspectError::NotFound | InspectError::Read(_)) => empty_details(),
};
Ok(InspectConversationOutput { details })
}
}
/// The empty, fully-degraded [`ConversationDetails`] (no topic, no tokens).
fn empty_details() -> ConversationDetails {
ConversationDetails {
last_topic: None,
token_count: None,
}
}