agentmux_common/layout_types.rs
1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Shared layout tree types used in both the srv wire protocol (ipc.rs
5//! Command/Event variants) and the srv object store (obj.rs LayoutState).
6//!
7//! Living in agentmux-common so agentmux-srv, agentmux-cef, and agentmux-
8//! launcher can all reference the same type definitions without circular
9//! dependencies or duplication.
10//!
11//! Part of srv Phase E.4.B (Phase 3 — wire types).
12//! See docs/specs/srv-phase-e4b-formal-spec-2026-05-03.md §4.1, §5, §6.
13
14use serde::{Deserialize, Serialize};
15
16// ── Core tree types ─────────────────────────────────────────────────────────
17
18/// Direction children flow within a layout node (row = horizontal split,
19/// column = vertical split). Defaults to `Row` when absent in JSON for
20/// tolerance of older blobs.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
22#[serde(rename_all = "lowercase")]
23pub enum FlexDirection {
24 #[default]
25 Row,
26 Column,
27}
28
29/// Leaf-only payload — references the block this layout leaf renders.
30/// Group nodes (those with `children`) carry no `data`.
31#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
32pub struct LayoutNodeData {
33 /// The currently-rendered block for this leaf. Always kept in sync with
34 /// `active_block_id` when `block_stack` is non-empty — this field is
35 /// what every existing (pre-tabs) reader/pruner cares about, so a leaf
36 /// with no stack behaves identically to before this field existed.
37 #[serde(rename = "blockId")]
38 pub block_id: String,
39 /// In-pane tabs (SPEC_PANE_TAB_STRIP_AGENT_TERMINAL_2026_07_20.md §4.3):
40 /// every blockId hosted by this leaf, ordered; empty/absent means "no
41 /// stack, just `block_id`" — 100% back-compat with every layout written
42 /// before this field existed. When non-empty, `block_id` MUST equal
43 /// `active_block_id` and MUST be a member of this list.
44 #[serde(rename = "blockStack", default, skip_serializing_if = "Vec::is_empty")]
45 pub block_stack: Vec<String>,
46 /// The active member of `block_stack`. Empty/unused when `block_stack`
47 /// is empty. Kept as an explicit field (rather than only ever reading
48 /// `block_id`) so intent is unambiguous in the wire format.
49 #[serde(rename = "activeBlockId", default, skip_serializing_if = "String::is_empty")]
50 pub active_block_id: String,
51 /// Catch-all for unknown fields — preserves forward-compat when the
52 /// frontend writes additional fields we don't yet model. Uses
53 /// `serde_json::Map` (insertion-ordered) for deterministic round-trips.
54 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
55 pub extra: serde_json::Map<String, serde_json::Value>,
56}
57
58/// One node in the layout tree. Stable UUID-keyed; `size` is a relative
59/// flex unit; `children` form the recursive structure (empty for leaves).
60///
61/// JSON shape (matches frontend `LayoutNode` in `frontend/layout/lib/types.ts`):
62/// ```json
63/// { "id": "...", "flexDirection": "row", "size": 1, "children": [...], "data": { "blockId": "..." } }
64/// ```
65///
66/// Note: `Default::size` is 0.0 (Rust f32 default), while the serde
67/// deserialization default is 1.0. The `Default` derive is only for the
68/// `..Default::default()` spread trick at construction sites — size is
69/// always set explicitly in practice.
70#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
71pub struct LayoutNode {
72 pub id: String,
73 #[serde(rename = "flexDirection", default)]
74 pub flex_direction: FlexDirection,
75 /// Flex size — relative units within the parent's children array.
76 /// Custom serializer emits whole-number sizes as integers (`10` not
77 /// `10.0`) to preserve byte-equal compat with prior JSON output.
78 #[serde(
79 default = "default_layout_size",
80 serialize_with = "serialize_size_smallest",
81 )]
82 pub size: f32,
83 #[serde(default, skip_serializing_if = "Vec::is_empty")]
84 pub children: Vec<LayoutNode>,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub data: Option<LayoutNodeData>,
87 /// Catch-all for unknown top-level fields. Uses `serde_json::Map`
88 /// (insertion-ordered) for deterministic round-trips.
89 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
90 pub extra: serde_json::Map<String, serde_json::Value>,
91}
92
93fn default_layout_size() -> f32 {
94 1.0
95}
96
97fn serialize_size_smallest<S>(value: &f32, serializer: S) -> Result<S::Ok, S::Error>
98where
99 S: serde::Serializer,
100{
101 if value.fract() == 0.0
102 && value.is_finite()
103 && (i64::MIN as f32..=i64::MAX as f32).contains(value)
104 {
105 serializer.serialize_i64(*value as i64)
106 } else {
107 serializer.serialize_f32(*value)
108 }
109}
110
111// ── Command helper types ────────────────────────────────────────────────────
112
113/// SPEC_864 Phase 2 — the frontend-owned `LayoutState` slices carried by a
114/// full-row-replace push (the `UpdateObject`→`LayoutSetTree` route). The
115/// reducer applies `focused_node_id`/`magnified_node_id` to its `TabRecord`;
116/// `leaforder` and `pending_backend_actions` are opaque pass-through JSON the
117/// persist subscriber writes verbatim (no algebra re-run in the subscriber —
118/// that would be a new divergence surface).
119///
120/// Semantics are REPLACE, mirroring the legacy `update_raw` whole-row write
121/// this route retires: an absent/`null` `leaforder` or
122/// `pending_backend_actions` CLEARS the persisted column (pushing the row
123/// without processed actions is how the frontend acks its backend-action
124/// queue); an empty `focused_node_id`/`magnified_node_id` clears focus/
125/// magnify. A `LayoutSetTree` with `slices: None` (granular/tree-only
126/// callers) leaves all four columns untouched.
127#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
128pub struct LayoutClientSlices {
129 /// Frontend-recomputed geometry cache (`getLeafOrder`). Not read on
130 /// reproject; persisted only for legacy readers. `None` = clear.
131 #[serde(default, skip_serializing_if = "Option::is_none")]
132 pub leaforder: Option<serde_json::Value>,
133 /// Focused layout-node id; empty = clear.
134 #[serde(default, skip_serializing_if = "String::is_empty")]
135 pub focused_node_id: String,
136 /// Magnified layout-node id; empty = clear.
137 #[serde(default, skip_serializing_if = "String::is_empty")]
138 pub magnified_node_id: String,
139 /// The backend→frontend action queue as the client sees it after
140 /// processing. `None` = clear (the ack path).
141 #[serde(default, skip_serializing_if = "Option::is_none")]
142 pub pending_backend_actions: Option<serde_json::Value>,
143}
144
145/// A single resize operation: target node + new flex size (0–100 relative units).
146#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
147pub struct ResizeOp {
148 pub node_id: String,
149 /// New flex size. Must be in 0.0..=100.0; reducer validates.
150 pub size: f32,
151}
152
153/// Position for split commands: where to insert the new node relative to the target.
154#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
155#[serde(rename_all = "lowercase")]
156pub enum SplitPosition {
157 Before,
158 After,
159}
160
161/// Position for `LayoutStackMove`: where `block_id` lands relative to
162/// `target_block_id` in the destination leaf's stack (or its own leaf's
163/// stack, for a same-leaf reorder). `End` ignores `target_block_id`'s own
164/// position and appends after every existing member — `target_block_id` is
165/// still used to locate which leaf.
166/// SPEC_PANE_TAB_DRAG_AND_DROP_2026_09_19.md §4.1.
167#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
168#[serde(rename_all = "lowercase")]
169pub enum StackMovePosition {
170 Before,
171 After,
172 End,
173}
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178
179 #[test]
180 fn layout_node_data_old_json_without_stack_fields_deserializes() {
181 // A layout leaf written before blockStack/activeBlockId existed —
182 // must still deserialize cleanly, with the new fields defaulting to
183 // "no stack".
184 let json = r#"{"blockId":"blk-1"}"#;
185 let data: LayoutNodeData = serde_json::from_str(json).unwrap();
186 assert_eq!(data.block_id, "blk-1");
187 assert!(data.block_stack.is_empty());
188 assert!(data.active_block_id.is_empty());
189 }
190
191 #[test]
192 fn layout_node_data_old_json_without_stack_fields_reserializes_without_them() {
193 // Round-tripping a non-stacked leaf must not introduce blockStack/
194 // activeBlockId into the JSON — byte-equal-compat with prior output
195 // for the overwhelming majority (every non-tabbed pane) case.
196 let json = r#"{"blockId":"blk-1"}"#;
197 let data: LayoutNodeData = serde_json::from_str(json).unwrap();
198 let out = serde_json::to_string(&data).unwrap();
199 assert_eq!(out, json);
200 }
201
202 #[test]
203 fn layout_node_data_with_stack_round_trips() {
204 let data = LayoutNodeData {
205 block_id: "blk-2".to_string(),
206 block_stack: vec!["blk-1".to_string(), "blk-2".to_string()],
207 active_block_id: "blk-2".to_string(),
208 extra: serde_json::Map::new(),
209 };
210 let json = serde_json::to_string(&data).unwrap();
211 let parsed: LayoutNodeData = serde_json::from_str(&json).unwrap();
212 assert_eq!(parsed, data);
213 assert!(json.contains("\"blockStack\":[\"blk-1\",\"blk-2\"]"));
214 assert!(json.contains("\"activeBlockId\":\"blk-2\""));
215 }
216
217 #[test]
218 fn layout_node_data_preserves_unknown_extra_fields() {
219 let json = r#"{"blockId":"blk-1","someFutureField":42}"#;
220 let data: LayoutNodeData = serde_json::from_str(json).unwrap();
221 assert_eq!(data.extra.get("someFutureField").and_then(|v| v.as_i64()), Some(42));
222 let out = serde_json::to_string(&data).unwrap();
223 assert!(out.contains("\"someFutureField\":42"));
224 }
225}