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

@ -231,6 +231,12 @@ pub struct TerminalSessionDto {
pub rows: u16,
/// Current cols.
pub cols: u16,
/// Conversation id **assigned** by this launch, when the agent's profile
/// supports session assignment and the hosting cell had none yet (T4b). The
/// front persists it on the leaf (`setCellConversation`) so the next open
/// resumes. `None` for a plain terminal, a resume, or a degraded launch.
#[serde(skip_serializing_if = "Option::is_none")]
pub assigned_conversation_id: Option<String>,
}
impl From<OpenTerminalOutput> for TerminalSessionDto {
@ -241,6 +247,7 @@ impl From<OpenTerminalOutput> for TerminalSessionDto {
cwd: s.cwd.as_str().to_owned(),
rows: s.pty_size.rows,
cols: s.pty_size.cols,
assigned_conversation_id: None,
}
}
}
@ -436,6 +443,15 @@ pub enum LayoutOperationDto {
#[serde(default)]
agent: Option<String>,
},
/// Record/clear the persistent CLI conversation id on a leaf (T4b).
#[serde(rename_all = "camelCase")]
SetCellConversation {
/// Hosting leaf.
target: String,
/// Conversation id, or `null` to clear.
#[serde(default)]
conversation_id: Option<String>,
},
}
impl LayoutOperationDto {
@ -479,6 +495,13 @@ impl LayoutOperationDto {
target: parse_node_id(&target)?,
agent: agent.as_deref().map(parse_agent_id).transpose()?,
},
Self::SetCellConversation {
target,
conversation_id,
} => LayoutOperation::SetCellConversation {
target: parse_node_id(&target)?,
conversation_id,
},
})
}
}
@ -832,7 +855,8 @@ pub fn parse_profile_id(raw: &str) -> Result<ProfileId, ErrorDto> {
// ---------------------------------------------------------------------------
use application::{
CreateAgentOutput, LaunchAgentOutput, ListAgentsOutput, ReadAgentContextOutput,
CreateAgentOutput, InspectConversationOutput, LaunchAgentOutput, ListAgentsOutput,
ReadAgentContextOutput,
};
use domain::Agent;
@ -915,16 +939,60 @@ pub struct LaunchAgentRequestDto {
pub rows: u16,
/// Initial terminal width in columns.
pub cols: u16,
/// Persistent CLI conversation id currently recorded on the hosting cell, if
/// any. `Some` ⇒ the launch resumes it; absent/`None` ⇒ a fresh cell (the
/// launch may assign a new id when the profile supports it).
#[serde(default)]
pub conversation_id: Option<String>,
}
impl From<LaunchAgentOutput> for TerminalSessionDto {
fn from(out: LaunchAgentOutput) -> Self {
let assigned_conversation_id = out.assigned_conversation_id;
let s = out.session;
Self {
session_id: s.id.to_string(),
cwd: s.cwd.as_str().to_owned(),
rows: s.pty_size.rows,
cols: s.pty_size.cols,
assigned_conversation_id,
}
}
}
/// Request DTO for `inspect_conversation` (T7).
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InspectConversationRequestDto {
/// Id of the owning project.
pub project_id: String,
/// Id of the agent whose conversation is inspected.
pub agent_id: String,
/// The conversation id recorded on the hosting cell.
pub conversation_id: String,
}
/// Response DTO for `inspect_conversation` (T7): the best-effort enriched
/// details for a resume popup. Both fields are optional and **omitted from the
/// wire when `None`** (`skip_serializing_if`), so the TypeScript side sees an
/// absent key — not `null` — for a degraded inspection.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ConversationDetailsDto {
/// A short, best-effort label for the conversation (last user message,
/// truncated). Absent when it could not be extracted.
#[serde(skip_serializing_if = "Option::is_none")]
pub last_topic: Option<String>,
/// A best-effort cumulative token count. Absent when no usage info exists.
#[serde(skip_serializing_if = "Option::is_none")]
pub token_count: Option<u64>,
}
impl From<InspectConversationOutput> for ConversationDetailsDto {
fn from(out: InspectConversationOutput) -> Self {
Self {
last_topic: out.details.last_topic,
token_count: out.details.token_count,
}
}
}