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

@ -22,9 +22,10 @@ use std::sync::Arc;
use async_trait::async_trait;
use domain::ports::{
AgentRuntime, ContextInjectionPlan, PreparedContext, ProcessSpawner, RuntimeError, SpawnSpec,
AgentRuntime, ContextInjectionPlan, PreparedContext, ProcessSpawner, RuntimeError, SessionPlan,
SpawnSpec,
};
use domain::profile::{AgentProfile, ContextInjection};
use domain::profile::{AgentProfile, ContextInjection, SessionStrategy};
use domain::project::ProjectPath;
/// The single generic AI-runtime adapter. Holds a [`ProcessSpawner`] (used only
@ -140,6 +141,40 @@ impl CliAgentRuntime {
ContextInjection::Env { var } => ContextInjectionPlan::Env { var: var.clone() },
}
}
/// Composes the session resume/assign arguments for a launch. **Pure** — the
/// testable heart of T3, mirroring [`injection_plan`](Self::injection_plan).
///
/// The profile's optional [`SessionStrategy`] crossed with the per-launch
/// [`SessionPlan`] yields the args to append (exhaustive truth table):
///
/// | `profile.session` | `SessionPlan` | Args added |
/// |----------------------------------------|----------------|------------|
/// | `None` | any | `[]` |
/// | `Some{assign_flag: Some(f), ..}` | `Assign{id}` | `[f, id]` |
/// | `Some{resume_flag: r, ..}` | `Resume{id}` | `[r, id]` |
/// | `Some{assign_flag: None, resume_flag: r}` | `Resume{id}` | `[r]` (degraded) |
/// | `Some{..}` | `None` | `[]` |
/// | `Some{assign_flag: None, ..}` | `Assign{id}` | `[]` (no flag) |
fn session_args(session: Option<&SessionStrategy>, plan: &SessionPlan) -> Vec<String> {
let Some(strategy) = session else {
return Vec::new();
};
match plan {
SessionPlan::None => Vec::new(),
SessionPlan::Assign { conversation_id } => match &strategy.assign_flag {
Some(flag) => vec![flag.clone(), conversation_id.clone()],
None => Vec::new(),
},
SessionPlan::Resume { conversation_id } => {
let mut args = vec![strategy.resume_flag.clone()];
if strategy.assign_flag.is_some() {
args.push(conversation_id.clone());
}
args
}
}
}
}
#[async_trait]
@ -161,6 +196,7 @@ impl AgentRuntime for CliAgentRuntime {
profile: &AgentProfile,
ctx: &PreparedContext,
cwd: &ProjectPath,
session: &SessionPlan,
) -> Result<SpawnSpec, RuntimeError> {
let resolved_cwd = Self::resolve_cwd(profile, cwd)?;
let plan = Self::injection_plan(&profile.context_injection, ctx);
@ -174,6 +210,10 @@ impl AgentRuntime for CliAgentRuntime {
args.extend(extra.iter().cloned());
}
// Session resume/assign args come *after* the static + context-injection
// args, so re-opening a conversation never disturbs context delivery.
args.extend(Self::session_args(profile.session.as_ref(), session));
Ok(SpawnSpec {
command: profile.command.clone(),
args,