Implémente la vue chat structurée par cellule agent (toggle TUI/CLI custom, préférence persistée `preferred_view`, reattach live, composer + pièces jointes) avec le socle backend AgentSession/ChatBridge (UserPrompt, cancel_current_turn, routage interrupt_agent, commande cancel_agent_chat). Corrige le bug bloquant relevé par QA : le bouton Cancel de CustomAgentChatView interrompait tout le tour via closeAgentChat au lieu de n'annuler que le tour courant via cancelAgentChat, ce qui tuait la session contrairement au contrat produit validé. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
278 lines
9.9 KiB
Rust
278 lines
9.9 KiB
Rust
//! [`LoadLayout`] and [`MutateLayout`] (ARCHITECTURE §6, §7; L4 + #4).
|
|
//!
|
|
//! A project owns **several named layouts** (see [`super::store`]). These two use
|
|
//! cases operate on **one** layout — the one identified by `layout_id`, or the
|
|
//! active layout when `layout_id` is `None`. They stay thin orchestrators: the
|
|
//! mutating operations are the domain's pure `LayoutTree` functions.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use domain::ports::{EventBus, FileSystem, ProjectStore};
|
|
use domain::{
|
|
AgentId, Direction, DomainEvent, LayoutError, LayoutId, LayoutTree, LeafCell, NodeId,
|
|
PreferredView, ProjectId, SessionId,
|
|
};
|
|
|
|
use crate::error::AppError;
|
|
|
|
use super::store::{persist_doc, resolve_doc};
|
|
|
|
/// Maps a [`LayoutError`] to the application error type.
|
|
fn map_layout_err(e: LayoutError) -> AppError {
|
|
match e {
|
|
LayoutError::NodeNotFound(id) => AppError::NotFound(format!("layout node {id}")),
|
|
other => AppError::Invalid(other.to_string()),
|
|
}
|
|
}
|
|
|
|
/// A layout mutation expressed in terms of the pure domain operations.
|
|
///
|
|
/// Each variant maps 1:1 to a pure `LayoutTree` method
|
|
/// (`split`/`merge`/`resize`/`move`/`set_session`). Decoupling the *operation*
|
|
/// from the *tree* keeps the use case a thin orchestrator and lets the
|
|
/// presentation layer (and undo/redo) speak in intentions.
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub enum LayoutOperation {
|
|
/// Split a leaf into a two-child split (original + a new empty leaf).
|
|
Split {
|
|
/// The leaf to split.
|
|
target: NodeId,
|
|
/// Row (columns) or Column (rows).
|
|
direction: Direction,
|
|
/// Id for the new sibling leaf.
|
|
new_leaf: NodeId,
|
|
/// Id for the wrapping split container.
|
|
container: NodeId,
|
|
},
|
|
/// Collapse a split container back to one of its children.
|
|
Merge {
|
|
/// The split container to collapse.
|
|
container: NodeId,
|
|
/// Index of the child to keep.
|
|
keep_index: usize,
|
|
},
|
|
/// Reassign the relative weights of a split's children.
|
|
Resize {
|
|
/// The split container to resize.
|
|
container: NodeId,
|
|
/// New weights (one per child, all `> 0`).
|
|
weights: Vec<f32>,
|
|
},
|
|
/// Move the session hosted by one leaf to another (empty) leaf.
|
|
Move {
|
|
/// Source leaf (left empty).
|
|
from: NodeId,
|
|
/// Target leaf (must be empty).
|
|
to: NodeId,
|
|
},
|
|
/// Attach or detach a session to/from a leaf (cell ↔ terminal binding).
|
|
SetSession {
|
|
/// The hosting leaf.
|
|
target: NodeId,
|
|
/// Session to host, or `None` to clear.
|
|
session: Option<SessionId>,
|
|
},
|
|
/// Attach an existing live/background session to a leaf, clearing any
|
|
/// previous visible host of the same session.
|
|
AttachSession {
|
|
/// The hosting leaf.
|
|
target: NodeId,
|
|
/// Session to make visible in this leaf.
|
|
session: SessionId,
|
|
},
|
|
/// Attach or detach an agent to/from a leaf (per-cell agent, feature #3).
|
|
SetCellAgent {
|
|
/// The hosting leaf.
|
|
target: NodeId,
|
|
/// Agent to associate, or `None` to clear.
|
|
agent: Option<AgentId>,
|
|
},
|
|
/// Record (or clear) the persistent CLI conversation id on a leaf (T4b).
|
|
///
|
|
/// Persisting the id minted at first launch is what makes session resume
|
|
/// effective: on the next open, the leaf carries the id and the launch
|
|
/// resumes the conversation instead of assigning a new one.
|
|
SetCellConversation {
|
|
/// The hosting leaf.
|
|
target: NodeId,
|
|
/// Conversation id to record, or `None` to clear.
|
|
conversation_id: Option<String>,
|
|
},
|
|
/// Persist the preferred live view for an agent leaf.
|
|
SetCellPreferredView {
|
|
/// The hosting leaf.
|
|
target: NodeId,
|
|
/// Preferred live view.
|
|
preferred_view: PreferredView,
|
|
},
|
|
/// Persist opaque state for a plugin-provided custom layout leaf.
|
|
SetPluginLayoutState {
|
|
/// The custom plugin layout node.
|
|
target: NodeId,
|
|
/// Opaque plugin-owned state.
|
|
state: serde_json::Value,
|
|
},
|
|
}
|
|
|
|
impl LayoutOperation {
|
|
/// Applies this operation to `tree`, returning the new validated tree.
|
|
fn apply(&self, tree: &LayoutTree) -> Result<LayoutTree, AppError> {
|
|
let result = match self {
|
|
Self::Split {
|
|
target,
|
|
direction,
|
|
new_leaf,
|
|
container,
|
|
} => tree.split(*target, *direction, LeafCell::new(*new_leaf), *container),
|
|
Self::Merge {
|
|
container,
|
|
keep_index,
|
|
} => tree.merge(*container, *keep_index),
|
|
Self::Resize { container, weights } => tree.resize(*container, weights),
|
|
Self::Move { from, to } => tree.move_session(*from, *to),
|
|
Self::SetSession { target, session } => tree.set_session(*target, *session),
|
|
Self::AttachSession { target, session } => tree.attach_session(*target, *session),
|
|
Self::SetCellAgent { target, agent } => tree.set_cell_agent(*target, *agent),
|
|
Self::SetCellConversation {
|
|
target,
|
|
conversation_id,
|
|
} => tree.set_cell_conversation(*target, conversation_id.clone()),
|
|
Self::SetCellPreferredView {
|
|
target,
|
|
preferred_view,
|
|
} => tree.set_cell_preferred_view(*target, *preferred_view),
|
|
Self::SetPluginLayoutState { target, state } => {
|
|
tree.set_plugin_layout_state(*target, state.clone())
|
|
}
|
|
};
|
|
result.map_err(map_layout_err)
|
|
}
|
|
}
|
|
|
|
/// Input for [`LoadLayout::execute`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct LoadLayoutInput {
|
|
/// Project whose layout to load.
|
|
pub project_id: ProjectId,
|
|
/// Which named layout to load; `None` loads the active one.
|
|
pub layout_id: Option<LayoutId>,
|
|
}
|
|
|
|
/// Output of [`LoadLayout::execute`].
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct LoadLayoutOutput {
|
|
/// The id of the layout that was loaded (resolved from active when omitted).
|
|
pub layout_id: LayoutId,
|
|
/// The loaded layout tree.
|
|
pub layout: LayoutTree,
|
|
}
|
|
|
|
/// Loads one named layout (the active one by default), self-healing / migrating
|
|
/// the layouts store as needed (see [`super::store::resolve_doc`]).
|
|
pub struct LoadLayout {
|
|
store: Arc<dyn ProjectStore>,
|
|
fs: Arc<dyn FileSystem>,
|
|
}
|
|
|
|
impl LoadLayout {
|
|
/// Builds the use case from its injected ports.
|
|
#[must_use]
|
|
pub fn new(store: Arc<dyn ProjectStore>, fs: Arc<dyn FileSystem>) -> Self {
|
|
Self { store, fs }
|
|
}
|
|
|
|
/// Executes the load.
|
|
///
|
|
/// # Errors
|
|
/// - [`AppError::NotFound`] if the project or the requested layout is unknown,
|
|
/// - [`AppError::FileSystem`] if seeding the default layouts fails to persist,
|
|
/// - [`AppError::Store`] on registry I/O failure.
|
|
pub async fn execute(&self, input: LoadLayoutInput) -> Result<LoadLayoutOutput, AppError> {
|
|
let project = self.store.load_project(input.project_id).await?;
|
|
let doc = resolve_doc(self.fs.as_ref(), &project).await?;
|
|
// Self-healing (I3): a stale requested id degrades to the valid active
|
|
// id instead of hard-erroring. `find` is now infallible in practice;
|
|
// the `ok_or` is kept as a purely defensive guard.
|
|
let id = doc.resolve_existing_id(input.layout_id);
|
|
let named = doc
|
|
.find(id)
|
|
.ok_or_else(|| AppError::NotFound(format!("layout {id}")))?;
|
|
Ok(LoadLayoutOutput {
|
|
layout_id: id,
|
|
layout: named.tree.clone(),
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Input for [`MutateLayout::execute`].
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct MutateLayoutInput {
|
|
/// Project whose layout to mutate.
|
|
pub project_id: ProjectId,
|
|
/// Which named layout to mutate; `None` mutates the active one.
|
|
pub layout_id: Option<LayoutId>,
|
|
/// The operation to apply.
|
|
pub operation: LayoutOperation,
|
|
}
|
|
|
|
/// Output of [`MutateLayout::execute`].
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct MutateLayoutOutput {
|
|
/// The id of the mutated layout.
|
|
pub layout_id: LayoutId,
|
|
/// The new, persisted layout tree.
|
|
pub layout: LayoutTree,
|
|
}
|
|
|
|
/// Applies a pure layout operation to one named layout, persists the whole
|
|
/// layouts store and publishes [`DomainEvent::LayoutChanged`].
|
|
pub struct MutateLayout {
|
|
store: Arc<dyn ProjectStore>,
|
|
fs: Arc<dyn FileSystem>,
|
|
events: Arc<dyn EventBus>,
|
|
}
|
|
|
|
impl MutateLayout {
|
|
/// Builds the use case from its injected ports.
|
|
#[must_use]
|
|
pub fn new(
|
|
store: Arc<dyn ProjectStore>,
|
|
fs: Arc<dyn FileSystem>,
|
|
events: Arc<dyn EventBus>,
|
|
) -> Self {
|
|
Self { store, fs, events }
|
|
}
|
|
|
|
/// Executes the mutation.
|
|
///
|
|
/// # Errors
|
|
/// - [`AppError::NotFound`] if the project, layout or a referenced node is unknown,
|
|
/// - [`AppError::Invalid`] if the operation violates a layout invariant,
|
|
/// - [`AppError::FileSystem`] on persistence failure,
|
|
/// - [`AppError::Store`] on registry I/O failure.
|
|
pub async fn execute(&self, input: MutateLayoutInput) -> Result<MutateLayoutOutput, AppError> {
|
|
let project = self.store.load_project(input.project_id).await?;
|
|
let mut doc = resolve_doc(self.fs.as_ref(), &project).await?;
|
|
// Self-healing (I3): a stale requested id degrades to the valid active
|
|
// id instead of hard-erroring. `find_mut` is now infallible in
|
|
// practice; the `ok_or` is kept as a purely defensive guard.
|
|
let id = doc.resolve_existing_id(input.layout_id);
|
|
|
|
let named = doc
|
|
.find_mut(id)
|
|
.ok_or_else(|| AppError::NotFound(format!("layout {id}")))?;
|
|
let next = input.operation.apply(&named.tree)?;
|
|
named.tree = next.clone();
|
|
|
|
persist_doc(self.fs.as_ref(), &project, &doc).await?;
|
|
self.events.publish(DomainEvent::LayoutChanged {
|
|
project_id: input.project_id,
|
|
});
|
|
|
|
Ok(MutateLayoutOutput {
|
|
layout_id: id,
|
|
layout: next,
|
|
})
|
|
}
|
|
}
|