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

@ -21,6 +21,15 @@ pub enum Direction {
Column,
}
/// Returns `true` when a boolean is `false`. Used as a `skip_serializing_if`
/// predicate so that default (`false`) flags are omitted from the serialized
/// form, preserving backward/forward compatibility with leaves that predate the
/// field.
#[allow(clippy::trivially_copy_pass_by_ref)]
fn is_false(b: &bool) -> bool {
!*b
}
/// A leaf cell hosting zero or one terminal session.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@ -33,6 +42,15 @@ pub struct LeafCell {
/// The agent to launch automatically in this cell, if any.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent: Option<AgentId>,
/// Opaque CLI conversation id, **persistent** across the cell's lifetime
/// (distinct from the ephemeral PTY [`SessionId`]). Enables resuming a CLI
/// conversation after the terminal/PTY has been closed and reopened.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub conversation_id: Option<String>,
/// Whether the cell's agent process was running at the moment the cell was
/// last closed. Used to decide whether to auto-resume the agent on reopen.
#[serde(default, skip_serializing_if = "is_false")]
pub agent_was_running: bool,
}
/// A weighted child within a [`SplitContainer`]. The `weight` is a *relative*
@ -349,6 +367,8 @@ impl LayoutTree {
id: leaf.id,
session: None,
agent: leaf.agent,
conversation_id: leaf.conversation_id.clone(),
agent_was_running: leaf.agent_was_running,
});
}
if leaf.id == to {
@ -356,6 +376,8 @@ impl LayoutTree {
id: leaf.id,
session: Some(session),
agent: leaf.agent,
conversation_id: leaf.conversation_id.clone(),
agent_was_running: leaf.agent_was_running,
});
}
}
@ -392,6 +414,8 @@ impl LayoutTree {
id: leaf.id,
session,
agent: leaf.agent,
conversation_id: leaf.conversation_id.clone(),
agent_was_running: leaf.agent_was_running,
});
}
}
@ -427,6 +451,8 @@ impl LayoutTree {
id: leaf.id,
session: leaf.session,
agent,
conversation_id: leaf.conversation_id.clone(),
agent_was_running: leaf.agent_was_running,
});
}
}
@ -440,6 +466,113 @@ impl LayoutTree {
Ok(tree)
}
/// Sets (or, with `None`, clears) the persistent CLI `conversation_id` on
/// the leaf `target`.
///
/// Unlike [`Self::set_session`] (the ephemeral PTY binding), the conversation
/// id survives PTY close/reopen and is what lets the agent CLI *resume* its
/// previous conversation.
///
/// Pure: returns a new validated tree.
///
/// # Errors
/// - [`LayoutError::NodeNotFound`] if `target` is not a leaf in the tree.
pub fn set_cell_conversation(
&self,
target: NodeId,
conversation_id: Option<String>,
) -> Result<Self, LayoutError> {
let mut found = false;
let root = map_node(&self.root, &mut |node| {
if let LayoutNode::Leaf(leaf) = node {
if leaf.id == target {
found = true;
return LayoutNode::Leaf(LeafCell {
id: leaf.id,
session: leaf.session,
agent: leaf.agent,
conversation_id: conversation_id.clone(),
agent_was_running: leaf.agent_was_running,
});
}
}
node.clone()
});
if !found {
return Err(LayoutError::NodeNotFound(target));
}
let tree = Self { root };
tree.validate()?;
Ok(tree)
}
/// Records whether the cell's agent process was `running` at close time, on
/// the leaf `target`.
///
/// Pure: returns a new validated tree.
///
/// # Errors
/// - [`LayoutError::NodeNotFound`] if `target` is not a leaf in the tree.
pub fn set_agent_running(
&self,
target: NodeId,
running: bool,
) -> Result<Self, LayoutError> {
let mut found = false;
let root = map_node(&self.root, &mut |node| {
if let LayoutNode::Leaf(leaf) = node {
if leaf.id == target {
found = true;
return LayoutNode::Leaf(LeafCell {
id: leaf.id,
session: leaf.session,
agent: leaf.agent,
conversation_id: leaf.conversation_id.clone(),
agent_was_running: running,
});
}
}
node.clone()
});
if !found {
return Err(LayoutError::NodeNotFound(target));
}
let tree = Self { root };
tree.validate()?;
Ok(tree)
}
/// Collects every leaf that carries an agent, as `(leaf id, agent id)` pairs.
///
/// Used by the close-time snapshot of running agents: the application walks
/// these leaves and records, on each, whether the agent's PTY was still live
/// (`agent_was_running`) before the global PTY kill. Pure read-only traversal.
#[must_use]
pub fn agent_leaves(&self) -> Vec<(NodeId, AgentId)> {
fn walk(node: &LayoutNode, out: &mut Vec<(NodeId, AgentId)>) {
match node {
LayoutNode::Leaf(leaf) => {
if let Some(agent) = leaf.agent {
out.push((leaf.id, agent));
}
}
LayoutNode::Split(split) => {
for child in &split.children {
walk(&child.node, out);
}
}
LayoutNode::Grid(grid) => {
for cell in &grid.cells {
walk(&cell.node, out);
}
}
}
}
let mut out = Vec::new();
walk(&self.root, &mut out);
out
}
/// Returns `Ok(Some(session))` / `Ok(None)` for the session held by the leaf
/// `id`, or [`LayoutError::NodeNotFound`] if no such leaf exists.
fn session_in_leaf(&self, id: NodeId) -> Result<Option<SessionId>, LayoutError> {