feat(layout): supporte la création de layouts plugins (customPluginLayout)

Étend le flux de création de layout backend (DTO, usecases, store) et le
frontend (sélecteur, adaptateurs, LayoutTabs/LayoutGrid) pour permettre
d'ouvrir un layout déclaré par un plugin installé (ex. Android Health),
sans passer par le message bloquant "extension backend pas encore livrée".

Ticket #141 — QA vert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-03 18:34:21 +02:00
parent 168f93df78
commit 45617992ef
29 changed files with 907 additions and 99 deletions

View File

@ -1118,9 +1118,9 @@ pub fn parse_session_id(raw: &str) -> Result<SessionId, ErrorDto> {
use application::{
CreateLayoutOutput, DeleteLayoutOutput, LayoutInfo, LayoutOperation, ListLayoutsOutput,
LoadLayoutOutput, MutateLayoutOutput, SetActiveLayoutOutput,
LoadLayoutOutput, MutateLayoutOutput, PluginLayoutOrigin, SetActiveLayoutOutput,
};
use domain::{AgentId, Direction, LayoutId, LayoutTree, NodeId};
use domain::{AgentId, Direction, LayoutId, LayoutTree, NodeId, PluginId, PluginLayoutType};
/// Response DTO carrying a layout tree.
///
@ -1214,6 +1214,15 @@ pub enum LayoutOperationDto {
#[serde(default)]
conversation_id: Option<String>,
},
/// Persist opaque plugin layout state.
#[serde(rename_all = "camelCase")]
SetPluginLayoutState {
/// Custom plugin layout node.
target: String,
/// Opaque plugin-owned state.
#[serde(default)]
state: serde_json::Value,
},
}
impl LayoutOperationDto {
@ -1264,6 +1273,10 @@ impl LayoutOperationDto {
target: parse_node_id(&target)?,
conversation_id,
},
Self::SetPluginLayoutState { target, state } => LayoutOperation::SetPluginLayoutState {
target: parse_node_id(&target)?,
state,
},
})
}
}
@ -1306,7 +1319,7 @@ pub struct LayoutInfoDto {
pub id: String,
/// Display name.
pub name: String,
/// Layout kind: `"terminal"` or `"gitGraph"`.
/// Layout kind: `"terminal"`, `"gitGraph"` or `"plugin"`.
pub kind: String,
}
@ -1315,6 +1328,7 @@ impl From<LayoutInfo> for LayoutInfoDto {
let kind = match info.kind {
LayoutKind::Terminal => "terminal",
LayoutKind::GitGraph => "gitGraph",
LayoutKind::Plugin { .. } => "plugin",
}
.to_owned();
Self {
@ -1384,9 +1398,15 @@ pub struct CreateLayoutRequestDto {
pub project_id: String,
/// Display name for the new layout.
pub name: String,
/// Optional layout kind: `"terminal"` (default) or `"gitGraph"`.
/// Optional layout kind: `"terminal"` (default), `"gitGraph"` or `"plugin"`.
#[serde(default)]
pub kind: Option<String>,
/// Plugin origin for `"plugin"` layouts.
#[serde(default)]
pub plugin_origin: Option<PluginLayoutOriginDto>,
/// Opaque initial state for `"plugin"` layouts.
#[serde(default)]
pub state: serde_json::Value,
}
impl CreateLayoutRequestDto {
@ -1398,6 +1418,16 @@ impl CreateLayoutRequestDto {
match self.kind.as_deref() {
None | Some("terminal") => Ok(LayoutKind::Terminal),
Some("gitGraph") => Ok(LayoutKind::GitGraph),
Some("plugin") | Some("customPluginLayout") => {
let origin = self.plugin_origin.clone().ok_or_else(|| ErrorDto {
code: "INVALID".to_owned(),
message: "pluginOrigin is required for plugin layouts".to_owned(),
})?;
Ok(LayoutKind::Plugin {
plugin_origin: origin.try_into()?,
state: self.state.clone(),
})
}
Some(other) => Err(ErrorDto {
code: "INVALID".to_owned(),
message: format!("unknown layout kind: {other}"),
@ -1406,6 +1436,35 @@ impl CreateLayoutRequestDto {
}
}
/// Stable provider identity for a plugin layout creation request.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginLayoutOriginDto {
/// Provider plugin id.
pub plugin_id: String,
/// Layout type declared by the provider.
pub layout_type: String,
}
impl TryFrom<PluginLayoutOriginDto> for PluginLayoutOrigin {
type Error = ErrorDto;
fn try_from(value: PluginLayoutOriginDto) -> Result<Self, Self::Error> {
let plugin_id = PluginId::new(value.plugin_id).map_err(|e| ErrorDto {
code: "INVALID".to_owned(),
message: e.to_string(),
})?;
let layout_type = PluginLayoutType::new(value.layout_type).map_err(|e| ErrorDto {
code: "INVALID".to_owned(),
message: e.to_string(),
})?;
Ok(Self {
plugin_id,
layout_type,
})
}
}
/// Request DTO for `rename_layout`.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]

View File

@ -1536,6 +1536,9 @@ impl BackendCore {
Arc::clone(&fs_port),
Arc::clone(&ids) as Arc<dyn IdGenerator>,
Arc::clone(&events_port),
Arc::clone(&plugin_package_store),
Arc::clone(&plugin_registry_store),
Arc::clone(&plugin_manifest_validator),
));
let rename_layout = Arc::new(RenameLayout::new(
Arc::clone(&store_port),