Skip to main content

batty_cli/team/
layout.rs

1//! tmux layout builder — creates zones and panes from team hierarchy.
2//!
3//! Zones are vertical columns in the tmux window. Within each zone, members
4//! are stacked vertically; engineer-heavy zones may first be partitioned into
5//! manager-aligned subcolumns to preserve the reporting hierarchy.
6
7use std::collections::HashMap;
8use std::fs::OpenOptions;
9use std::path::Path;
10
11use anyhow::{Result, bail};
12use tracing::{debug, info};
13
14use super::config::{LayoutConfig, OrchestratorPosition, RoleType, WorkflowMode};
15use super::hierarchy::MemberInstance;
16use crate::tmux;
17
18const ORCHESTRATOR_PANE_WIDTH_PCT: u32 = 20;
19const ORCHESTRATOR_ROLE: &str = "orchestrator";
20
21#[derive(Debug, Clone)]
22struct ZonePlan<'a> {
23    width_pct: u32,
24    members: Vec<&'a MemberInstance>,
25    horizontal_columns: usize,
26}
27
28/// Build the tmux layout for a team session.
29///
30/// Creates the session with the first member's pane, then splits to create
31/// all remaining panes. Returns a mapping of member name → tmux pane target.
32pub fn build_layout(
33    session: &str,
34    members: &[MemberInstance],
35    layout: &Option<LayoutConfig>,
36    project_root: &Path,
37    workflow_mode: WorkflowMode,
38    orchestrator_pane: bool,
39    orchestrator_position: OrchestratorPosition,
40) -> Result<HashMap<String, String>> {
41    let pane_members: Vec<_> = members
42        .iter()
43        .filter(|m| m.role_type != RoleType::User)
44        .collect();
45
46    if pane_members.is_empty() {
47        bail!("no pane members to create layout for");
48    }
49
50    let work_dir = project_root.to_string_lossy().to_string();
51
52    // Create session with the first member
53    tmux::create_session(session, "bash", &[], &work_dir)?;
54    tmux::rename_window(&format!("{session}:0"), "team")?;
55
56    // Enable pane borders with role labels using @batty_role (agent-proof)
57    let _ = std::process::Command::new("tmux")
58        .args(["set-option", "-t", session, "pane-border-status", "top"])
59        .output();
60    let _ = std::process::Command::new("tmux")
61        .args([
62            "set-option",
63            "-t",
64            session,
65            "pane-border-format",
66            " #[fg=green,bold]#{@batty_role}#[default] #{@batty_status} ",
67        ])
68        .output();
69
70    let mut pane_map: HashMap<String, String> = HashMap::new();
71    let orchestrator_enabled = should_launch_orchestrator_pane(workflow_mode, orchestrator_pane);
72    let initial_pane = tmux::pane_id(session)?;
73    let agent_root_pane = if orchestrator_enabled {
74        launch_orchestrator_pane(session, &initial_pane, project_root, orchestrator_position)?
75    } else {
76        initial_pane
77    };
78
79    if pane_members.len() == 1 {
80        // Single pane — just use the initial pane
81        set_pane_title(session, &agent_root_pane, &pane_members[0].name)?;
82        pane_map.insert(pane_members[0].name.clone(), agent_root_pane);
83        return Ok(pane_map);
84    }
85
86    // Group members by zone for layout
87    let zones = if let Some(layout_config) = layout {
88        build_zones_from_config(layout_config, &pane_members)
89    } else {
90        build_zones_auto(&pane_members)
91    };
92
93    // Create remaining zone columns by splitting the previous zone's pane.
94    // Left-to-right: each split carves the next zone off the right side.
95    //
96    // tmux `split -h -p N` gives the NEW pane N% of the source pane.
97    // Before each split, the source pane represents zones [i-1..N]. We want
98    // zone i-1 to keep its share and the new pane to get the rest.
99    //
100    // Example: zones [20%, 20%, 60%]:
101    //   Split 1: source = zones [0,1,2] = 100%. New pane gets (20+60)/100 = 80%.
102    //   Split 2: source = zones [1,2] = 80%.  New pane gets 60/80 = 75%.
103    let mut zone_panes: Vec<String> = vec![agent_root_pane.clone()];
104    let mut remaining_pct: u32 = zones.iter().map(|zone| zone.width_pct).sum();
105    for (i, _zone) in zones.iter().enumerate().skip(1) {
106        let right_side: u32 = zones[i..].iter().map(|zone| zone.width_pct).sum();
107        let split_pct = ((right_side as f64 / remaining_pct as f64) * 100.0).round() as u32;
108        let split_pct = split_pct.clamp(10, 90);
109        let split_from = zone_panes.last().unwrap();
110        let pane_id = tmux::split_window_horizontal(split_from, split_pct)?;
111        zone_panes.push(pane_id);
112        remaining_pct = right_side;
113        debug!(zone = i, split_pct, "created zone column");
114    }
115
116    // Within each zone, split vertically for members. Engineer zones with
117    // multiple managers are partitioned into per-manager subcolumns first.
118    for (zone_idx, zone) in zones.iter().enumerate() {
119        let zone_pane = &zone_panes[zone_idx];
120        let zone_members = &zone.members;
121
122        if zone_members.is_empty() {
123            continue;
124        }
125
126        let subgroups = split_zone_subgroups(zone_members);
127        if subgroups.len() == 1 {
128            let columns = split_members_into_columns(zone_members, zone.horizontal_columns);
129            if columns.len() == 1 {
130                stack_members_in_pane(session, zone_pane, &columns[0], &mut pane_map)?;
131            } else {
132                let column_panes = split_subgroup_columns(zone_pane, &columns)?;
133                for (column_pane, column_members) in column_panes.iter().zip(columns.iter()) {
134                    stack_members_in_pane(session, column_pane, column_members, &mut pane_map)?;
135                }
136            }
137            continue;
138        }
139
140        let subgroup_panes = split_subgroup_columns(zone_pane, &subgroups)?;
141        for (subgroup_pane, subgroup_members) in subgroup_panes.iter().zip(subgroups.iter()) {
142            stack_members_in_pane(session, subgroup_pane, subgroup_members, &mut pane_map)?;
143        }
144    }
145
146    info!(session, panes = pane_map.len(), "team layout created");
147
148    Ok(pane_map)
149}
150
151fn should_launch_orchestrator_pane(workflow_mode: WorkflowMode, orchestrator_pane: bool) -> bool {
152    workflow_mode.enables_runtime_surface() && orchestrator_pane
153}
154
155fn launch_orchestrator_pane(
156    session: &str,
157    initial_pane: &str,
158    project_root: &Path,
159    position: OrchestratorPosition,
160) -> Result<String> {
161    let plain_log_path = super::orchestrator_log_path(project_root);
162    let ansi_log_path = super::orchestrator_ansi_log_path(project_root);
163    ensure_orchestrator_log(&plain_log_path)?;
164    ensure_orchestrator_log(&ansi_log_path)?;
165
166    let (orchestrator_target, agent_root_pane) = match position {
167        OrchestratorPosition::Left => {
168            // Split horizontally: new pane (right) gets the remaining width for agents.
169            // The original pane (left) becomes the orchestrator column.
170            let agent_pane =
171                tmux::split_window_horizontal(initial_pane, 100 - ORCHESTRATOR_PANE_WIDTH_PCT)?;
172            (initial_pane.to_string(), agent_pane)
173        }
174        OrchestratorPosition::Bottom => {
175            // Split vertically: new pane (bottom) becomes the orchestrator.
176            // The original pane (top) remains the agent root.
177            let orch_pane = tmux::split_window_vertical_in_pane(
178                session,
179                initial_pane,
180                ORCHESTRATOR_PANE_WIDTH_PCT,
181            )?;
182            (orch_pane, initial_pane.to_string())
183        }
184    };
185
186    let tail_command = format!(
187        "bash -lc 'touch {path}; exec tail -n 200 -F {path}'",
188        path = shell_single_quote(ansi_log_path.to_string_lossy().as_ref())
189    );
190    tmux::respawn_pane(&orchestrator_target, &tail_command)?;
191    set_pane_title(session, &orchestrator_target, ORCHESTRATOR_ROLE)?;
192    let _ = std::process::Command::new("tmux")
193        .args([
194            "set-option",
195            "-p",
196            "-t",
197            orchestrator_target.as_str(),
198            "@batty_status",
199            "workflow stream",
200        ])
201        .output();
202    let _ = std::process::Command::new("tmux")
203        .args(["select-pane", "-t", agent_root_pane.as_str()])
204        .output();
205    Ok(agent_root_pane)
206}
207
208fn ensure_orchestrator_log(path: &Path) -> Result<()> {
209    if let Some(parent) = path.parent() {
210        std::fs::create_dir_all(parent)?;
211    }
212    OpenOptions::new().create(true).append(true).open(path)?;
213    Ok(())
214}
215
216fn shell_single_quote(value: &str) -> String {
217    value.replace('\'', "'\"'\"'")
218}
219
220fn split_off_current_member_pct(total_slots: usize) -> u32 {
221    (((1.0 / total_slots as f64) * 100.0).round() as u32).clamp(10, 90)
222}
223
224fn split_zone_subgroups<'a>(zone_members: &'a [&MemberInstance]) -> Vec<Vec<&'a MemberInstance>> {
225    let engineer_hierarchy = zone_members
226        .iter()
227        .all(|member| member.role_type == RoleType::Engineer && member.reports_to.is_some());
228    if !engineer_hierarchy {
229        return vec![zone_members.to_vec()];
230    }
231
232    let mut groups: Vec<(String, Vec<&MemberInstance>)> = Vec::new();
233    for member in zone_members {
234        let parent = member.reports_to.clone().unwrap_or_default();
235        if let Some((_, grouped)) = groups
236            .iter_mut()
237            .find(|(reports_to, _)| *reports_to == parent)
238        {
239            grouped.push(*member);
240        } else {
241            groups.push((parent, vec![*member]));
242        }
243    }
244
245    groups.into_iter().map(|(_, grouped)| grouped).collect()
246}
247
248fn split_members_into_columns<'a>(
249    members: &[&'a MemberInstance],
250    desired_columns: usize,
251) -> Vec<Vec<&'a MemberInstance>> {
252    let columns = desired_columns.clamp(1, members.len().max(1));
253    if columns == 1 {
254        return vec![members.to_vec()];
255    }
256
257    let mut groups = Vec::with_capacity(columns);
258    let mut start = 0;
259    for column_idx in 0..columns {
260        let remaining_members = members.len() - start;
261        let remaining_columns = columns - column_idx;
262        let take = remaining_members.div_ceil(remaining_columns);
263        groups.push(members[start..start + take].to_vec());
264        start += take;
265    }
266
267    groups
268}
269
270fn split_subgroup_columns(
271    zone_pane: &str,
272    subgroups: &[Vec<&MemberInstance>],
273) -> Result<Vec<String>> {
274    let mut panes = vec![zone_pane.to_string()];
275    let mut remaining_weight: usize = subgroups.iter().map(Vec::len).sum();
276
277    for subgroup_idx in 1..subgroups.len() {
278        let right_weight: usize = subgroups[subgroup_idx..].iter().map(Vec::len).sum();
279        let split_pct = ((right_weight as f64 / remaining_weight as f64) * 100.0).round() as u32;
280        let split_pct = split_pct.clamp(10, 90);
281        let split_from = panes.last().unwrap();
282        let pane_id = tmux::split_window_horizontal(split_from, split_pct)?;
283        panes.push(pane_id);
284        remaining_weight = right_weight;
285    }
286
287    Ok(panes)
288}
289
290fn stack_members_in_pane(
291    session: &str,
292    pane_id: &str,
293    members: &[&MemberInstance],
294    pane_map: &mut HashMap<String, String>,
295) -> Result<()> {
296    let remaining_pane = pane_id.to_string();
297
298    for member_idx in (1..members.len()).rev() {
299        let member = members[member_idx];
300        let pct = split_off_current_member_pct(member_idx + 1);
301        let member_pane = tmux::split_window_vertical_in_pane(session, &remaining_pane, pct)?;
302        set_pane_title(session, &member_pane, &member.name)?;
303        debug!(
304            member = %member.name,
305            pane = %member_pane,
306            split_pct = pct,
307            "created member pane"
308        );
309        pane_map.insert(member.name.clone(), member_pane);
310    }
311
312    tmux::select_layout_even(&remaining_pane)?;
313    set_pane_title(session, &remaining_pane, &members[0].name)?;
314    pane_map.insert(members[0].name.clone(), remaining_pane);
315    Ok(())
316}
317
318/// Set a pane's title and store the role name in a custom tmux option.
319///
320/// We set both `select-pane -T` (standard title) and a custom pane option
321/// `@batty_role` that agents like Claude Code cannot overwrite. The
322/// `pane-border-format` reads `@batty_role` for a stable label.
323fn set_pane_title(_session: &str, pane_id: &str, title: &str) -> Result<()> {
324    // Use select-pane -T to set pane title. Pane IDs (%N) are global in tmux.
325    let output = std::process::Command::new("tmux")
326        .args(["select-pane", "-t", pane_id, "-T", title])
327        .output()?;
328    if !output.status.success() {
329        debug!(
330            pane = pane_id,
331            title, "failed to set pane title (non-critical)"
332        );
333    }
334
335    // Store role name in a custom pane option that agents can't overwrite
336    let _ = std::process::Command::new("tmux")
337        .args(["set-option", "-p", "-t", pane_id, "@batty_role", title])
338        .output();
339
340    Ok(())
341}
342
343/// Group members into zones based on explicit layout config.
344fn build_zones_from_config<'a>(
345    config: &LayoutConfig,
346    members: &'a [&MemberInstance],
347) -> Vec<ZonePlan<'a>> {
348    let mut zones: Vec<ZonePlan<'a>> = config
349        .zones
350        .iter()
351        .map(|z| ZonePlan {
352            width_pct: z.width_pct,
353            members: Vec::new(),
354            horizontal_columns: z
355                .split
356                .as_ref()
357                .map(|split| split.horizontal as usize)
358                .unwrap_or(1)
359                .max(1),
360        })
361        .collect();
362
363    // Map members to zones by role type
364    let mut member_queue: Vec<&MemberInstance> = members.to_vec();
365
366    for (zone_idx, zone_def) in config.zones.iter().enumerate() {
367        let zone_name = zone_def.name.as_str();
368
369        let exact_matches: Vec<&MemberInstance> = member_queue
370            .iter()
371            .copied()
372            .filter(|member| member.name == zone_name || member.role_name == zone_name)
373            .collect();
374        if !exact_matches.is_empty() {
375            let mut selected_names: Vec<&str> = Vec::new();
376            for member in exact_matches {
377                if !selected_names.contains(&member.name.as_str()) {
378                    zones[zone_idx].members.push(member);
379                    selected_names.push(member.name.as_str());
380                }
381                if member.role_type == RoleType::Manager {
382                    for report in member_queue.iter().copied().filter(|candidate| {
383                        candidate.reports_to.as_deref() == Some(member.name.as_str())
384                    }) {
385                        if !selected_names.contains(&report.name.as_str()) {
386                            zones[zone_idx].members.push(report);
387                            selected_names.push(report.name.as_str());
388                        }
389                    }
390                }
391            }
392            member_queue.retain(|member| !selected_names.contains(&member.name.as_str()));
393            continue;
394        }
395
396        // Try to match zone name to role types
397        let target_types = match zone_name {
398            n if n.contains("architect") => vec![RoleType::Architect],
399            n if n.contains("manager") => vec![RoleType::Manager],
400            n if n.contains("engineer") => vec![RoleType::Engineer],
401            _ => continue,
402        };
403
404        let max_members = zone_def
405            .split
406            .as_ref()
407            .map(|s| s.horizontal as usize)
408            .unwrap_or(usize::MAX);
409
410        let mut taken = 0;
411        member_queue.retain(|m| {
412            if taken >= max_members {
413                return true;
414            }
415            if target_types.contains(&m.role_type) {
416                zones[zone_idx].members.push(m);
417                taken += 1;
418                false
419            } else {
420                true
421            }
422        });
423    }
424
425    // Put any unplaced members in the last zone
426    if let Some(last) = zones.last_mut() {
427        last.members.extend(member_queue);
428    }
429
430    // Remove empty zones
431    zones.retain(|zone| !zone.members.is_empty());
432    zones
433}
434
435/// Auto-generate zones from member role types.
436fn build_zones_auto<'a>(members: &'a [&MemberInstance]) -> Vec<ZonePlan<'a>> {
437    let architects: Vec<_> = members
438        .iter()
439        .filter(|m| m.role_type == RoleType::Architect)
440        .copied()
441        .collect();
442    let managers: Vec<_> = members
443        .iter()
444        .filter(|m| m.role_type == RoleType::Manager)
445        .copied()
446        .collect();
447    let engineers: Vec<_> = members
448        .iter()
449        .filter(|m| m.role_type == RoleType::Engineer)
450        .copied()
451        .collect();
452
453    let mut zones = Vec::new();
454    let total = members.len() as u32;
455
456    if !architects.is_empty() {
457        let pct = ((architects.len() as u32 * 100) / total).max(10);
458        zones.push(ZonePlan {
459            width_pct: pct,
460            members: architects,
461            horizontal_columns: 1,
462        });
463    }
464    if !managers.is_empty() {
465        let pct = ((managers.len() as u32 * 100) / total).max(15);
466        zones.push(ZonePlan {
467            width_pct: pct,
468            members: managers,
469            horizontal_columns: 1,
470        });
471    }
472    if !engineers.is_empty() {
473        let pct = ((engineers.len() as u32 * 100) / total).max(20);
474        zones.push(ZonePlan {
475            width_pct: pct,
476            members: engineers,
477            horizontal_columns: 1,
478        });
479    }
480
481    zones
482}
483
484#[cfg(test)]
485mod tests {
486    use super::super::config::TeamConfig;
487    use super::super::hierarchy;
488    use super::*;
489    use serial_test::serial;
490    use std::process::Command;
491
492    fn make_members(yaml: &str) -> Vec<MemberInstance> {
493        let config: TeamConfig = serde_yaml::from_str(yaml).unwrap();
494        hierarchy::resolve_hierarchy(&config).unwrap()
495    }
496
497    #[test]
498    fn auto_zones_group_by_role() {
499        let members = make_members(
500            r#"
501name: test
502roles:
503  - name: architect
504    role_type: architect
505    agent: claude
506    instances: 1
507  - name: manager
508    role_type: manager
509    agent: claude
510    instances: 2
511  - name: engineer
512    role_type: engineer
513    agent: codex
514    instances: 3
515"#,
516        );
517        let pane_members: Vec<_> = members
518            .iter()
519            .filter(|m| m.role_type != RoleType::User)
520            .collect();
521        let zones = build_zones_auto(&pane_members);
522        assert_eq!(zones.len(), 3);
523        assert_eq!(zones[0].members.len(), 1); // architect
524        assert_eq!(zones[1].members.len(), 2); // managers
525        assert_eq!(zones[2].members.len(), 6); // engineers (2 managers × 3 each)
526    }
527
528    #[test]
529    fn config_zones_assign_members() {
530        let members = make_members(
531            r#"
532name: test
533roles:
534  - name: architect
535    role_type: architect
536    agent: claude
537    instances: 1
538  - name: manager
539    role_type: manager
540    agent: claude
541    instances: 1
542  - name: engineer
543    role_type: engineer
544    agent: codex
545    instances: 3
546"#,
547        );
548        let layout = LayoutConfig {
549            zones: vec![
550                super::super::config::ZoneDef {
551                    name: "architect".to_string(),
552                    width_pct: 20,
553                    split: None,
554                },
555                super::super::config::ZoneDef {
556                    name: "managers".to_string(),
557                    width_pct: 30,
558                    split: None,
559                },
560                super::super::config::ZoneDef {
561                    name: "engineers".to_string(),
562                    width_pct: 50,
563                    split: None,
564                },
565            ],
566        };
567        let pane_members: Vec<_> = members
568            .iter()
569            .filter(|m| m.role_type != RoleType::User)
570            .collect();
571        let zones = build_zones_from_config(&layout, &pane_members);
572        assert_eq!(zones.len(), 3);
573        assert_eq!(zones[0].members[0].role_type, RoleType::Architect);
574        assert_eq!(zones[1].members[0].role_type, RoleType::Manager);
575        assert_eq!(zones[2].members.len(), 3);
576    }
577
578    #[test]
579    fn split_percentages_preserve_equal_zone_stack() {
580        let splits: Vec<_> = (2..=6).map(split_off_current_member_pct).collect();
581        assert_eq!(splits, vec![50, 33, 25, 20, 17]);
582    }
583
584    #[test]
585    #[serial]
586    #[cfg_attr(not(feature = "integration"), ignore)]
587    fn build_layout_supports_architect_two_managers_and_six_engineers() {
588        let session = "batty-test-team-layout-nine";
589        let _ = crate::tmux::kill_session(session);
590
591        let members = make_members(
592            r#"
593name: test
594roles:
595  - name: architect
596    role_type: architect
597    agent: codex
598  - name: manager
599    role_type: manager
600    agent: codex
601    instances: 2
602  - name: engineer
603    role_type: engineer
604    agent: codex
605    instances: 3
606    talks_to: [manager]
607"#,
608        );
609
610        let layout = Some(LayoutConfig {
611            zones: vec![
612                super::super::config::ZoneDef {
613                    name: "architect".to_string(),
614                    width_pct: 15,
615                    split: None,
616                },
617                super::super::config::ZoneDef {
618                    name: "managers".to_string(),
619                    width_pct: 25,
620                    split: Some(super::super::config::SplitDef { horizontal: 2 }),
621                },
622                super::super::config::ZoneDef {
623                    name: "engineers".to_string(),
624                    width_pct: 60,
625                    split: Some(super::super::config::SplitDef { horizontal: 6 }),
626                },
627            ],
628        });
629
630        let pane_map = build_layout(
631            session,
632            &members,
633            &layout,
634            Path::new("/tmp"),
635            WorkflowMode::Legacy,
636            true,
637            OrchestratorPosition::Bottom,
638        )
639        .unwrap();
640        assert_eq!(pane_map.len(), 9);
641
642        let pane_count_output = Command::new("tmux")
643            .args(["list-panes", "-t", session, "-F", "#{pane_id}"])
644            .output()
645            .unwrap();
646        assert!(pane_count_output.status.success());
647        let pane_count = String::from_utf8_lossy(&pane_count_output.stdout)
648            .lines()
649            .count();
650        assert_eq!(pane_count, 9);
651
652        let engineer_geometry_output = Command::new("tmux")
653            .args([
654                "list-panes",
655                "-t",
656                session,
657                "-F",
658                "#{pane_title} #{pane_left} #{pane_height}",
659            ])
660            .output()
661            .unwrap();
662        assert!(engineer_geometry_output.status.success());
663        let mut engineer_columns: HashMap<u32, Vec<u32>> = HashMap::new();
664        for line in String::from_utf8_lossy(&engineer_geometry_output.stdout).lines() {
665            let parts: Vec<_> = line.split_whitespace().collect();
666            if parts.len() != 3 || !parts[0].starts_with("eng-") {
667                continue;
668            }
669            let left: u32 = parts[1].parse().unwrap();
670            let height: u32 = parts[2].parse().unwrap();
671            engineer_columns.entry(left).or_default().push(height);
672        }
673        assert_eq!(engineer_columns.len(), 2);
674        assert!(engineer_columns.values().all(|heights| heights.len() == 3));
675        for heights in engineer_columns.values() {
676            assert!(heights.iter().all(|height| *height >= 4));
677            let min_height = heights.iter().min().copied().unwrap();
678            let max_height = heights.iter().max().copied().unwrap();
679            // tmux rounds pane sizes slightly differently across platforms
680            // once pane borders/status lines are enabled. We only need to
681            // ensure the engineer stacks stay materially balanced.
682            assert!(max_height - min_height <= 2);
683        }
684
685        crate::tmux::kill_session(session).unwrap();
686    }
687
688    #[test]
689    fn auto_zones_single_role_type_produces_one_zone() {
690        let members = make_members(
691            r#"
692name: test
693roles:
694  - name: engineer
695    role_type: engineer
696    agent: codex
697    instances: 4
698"#,
699        );
700        let pane_members: Vec<_> = members
701            .iter()
702            .filter(|m| m.role_type != RoleType::User)
703            .collect();
704        let zones = build_zones_auto(&pane_members);
705        assert_eq!(zones.len(), 1);
706        assert_eq!(zones[0].members.len(), 4);
707    }
708
709    #[test]
710    fn split_zone_subgroups_groups_engineers_by_manager() {
711        let members = make_members(
712            r#"
713name: test
714roles:
715  - name: manager
716    role_type: manager
717    agent: claude
718    instances: 2
719  - name: engineer
720    role_type: engineer
721    agent: codex
722    instances: 2
723    talks_to: [manager]
724"#,
725        );
726        let engineers: Vec<_> = members
727            .iter()
728            .filter(|m| m.role_type == RoleType::Engineer)
729            .collect();
730        let subgroups = split_zone_subgroups(&engineers);
731        assert_eq!(subgroups.len(), 2);
732        assert_eq!(subgroups[0].len(), 2);
733        assert_eq!(subgroups[1].len(), 2);
734        // Each subgroup should share the same reports_to
735        for group in &subgroups {
736            let parent = group[0].reports_to.as_ref().unwrap();
737            assert!(
738                group
739                    .iter()
740                    .all(|m| m.reports_to.as_ref().unwrap() == parent)
741            );
742        }
743    }
744
745    #[test]
746    fn config_zones_unplaced_members_go_to_last_zone() {
747        let members = make_members(
748            r#"
749name: test
750roles:
751  - name: architect
752    role_type: architect
753    agent: claude
754  - name: manager
755    role_type: manager
756    agent: claude
757  - name: engineer
758    role_type: engineer
759    agent: codex
760    instances: 2
761"#,
762        );
763        // Only define one zone — everything else should end up there
764        let layout = LayoutConfig {
765            zones: vec![super::super::config::ZoneDef {
766                name: "architect".to_string(),
767                width_pct: 100,
768                split: None,
769            }],
770        };
771        let pane_members: Vec<_> = members
772            .iter()
773            .filter(|m| m.role_type != RoleType::User)
774            .collect();
775        let zones = build_zones_from_config(&layout, &pane_members);
776        // Architect zone gets architect + leftover manager/engineers
777        assert_eq!(zones.len(), 1);
778        assert_eq!(zones[0].members.len(), 4); // 1 arch + 1 mgr + 2 eng
779    }
780
781    #[test]
782    fn config_zones_exact_manager_role_collects_direct_reports() {
783        let members = make_members(
784            r#"
785name: test
786roles:
787  - name: architect
788    role_type: architect
789    agent: claude
790  - name: scientist
791    role_type: architect
792    agent: claude
793  - name: black-lead
794    role_type: manager
795    agent: claude
796    talks_to: [architect, black-eng]
797  - name: red-lead
798    role_type: manager
799    agent: claude
800    talks_to: [architect, red-eng]
801  - name: black-eng
802    role_type: engineer
803    agent: codex
804    instances: 2
805    talks_to: [black-lead]
806  - name: red-eng
807    role_type: engineer
808    agent: codex
809    instances: 2
810    talks_to: [red-lead]
811"#,
812        );
813        let layout = LayoutConfig {
814            zones: vec![
815                super::super::config::ZoneDef {
816                    name: "scientist".to_string(),
817                    width_pct: 10,
818                    split: None,
819                },
820                super::super::config::ZoneDef {
821                    name: "architect".to_string(),
822                    width_pct: 10,
823                    split: None,
824                },
825                super::super::config::ZoneDef {
826                    name: "black-lead".to_string(),
827                    width_pct: 40,
828                    split: None,
829                },
830                super::super::config::ZoneDef {
831                    name: "red-lead".to_string(),
832                    width_pct: 40,
833                    split: None,
834                },
835            ],
836        };
837        let pane_members: Vec<_> = members
838            .iter()
839            .filter(|m| m.role_type != RoleType::User)
840            .collect();
841        let zones = build_zones_from_config(&layout, &pane_members);
842        assert_eq!(zones.len(), 4);
843        assert_eq!(
844            zones[0]
845                .members
846                .iter()
847                .map(|m| m.name.as_str())
848                .collect::<Vec<_>>(),
849            vec!["scientist"]
850        );
851        assert_eq!(
852            zones[1]
853                .members
854                .iter()
855                .map(|m| m.name.as_str())
856                .collect::<Vec<_>>(),
857            vec!["architect"]
858        );
859        assert_eq!(
860            zones[2]
861                .members
862                .iter()
863                .map(|m| m.name.as_str())
864                .collect::<Vec<_>>(),
865            vec!["black-lead", "black-eng-1-1", "black-eng-1-2"]
866        );
867        assert_eq!(
868            zones[3]
869                .members
870                .iter()
871                .map(|m| m.name.as_str())
872                .collect::<Vec<_>>(),
873            vec!["red-lead", "red-eng-1-1", "red-eng-1-2"]
874        );
875    }
876
877    #[test]
878    fn workflow_mode_controls_orchestrator_pane_launch() {
879        assert!(!should_launch_orchestrator_pane(WorkflowMode::Legacy, true));
880        assert!(should_launch_orchestrator_pane(WorkflowMode::Hybrid, true));
881        assert!(should_launch_orchestrator_pane(
882            WorkflowMode::WorkflowFirst,
883            true,
884        ));
885        assert!(!should_launch_orchestrator_pane(
886            WorkflowMode::Hybrid,
887            false
888        ));
889    }
890
891    #[test]
892    #[serial]
893    #[cfg_attr(not(feature = "integration"), ignore)]
894    fn build_layout_adds_orchestrator_pane_when_enabled() {
895        let session = "batty-test-team-layout-orchestrator";
896        let _ = crate::tmux::kill_session(session);
897        let tmp = tempfile::tempdir().unwrap();
898
899        let members = make_members(
900            r#"
901name: test
902roles:
903  - name: architect
904    role_type: architect
905    agent: claude
906  - name: engineer
907    role_type: engineer
908    agent: codex
909"#,
910        );
911
912        let pane_map = build_layout(
913            session,
914            &members,
915            &None,
916            tmp.path(),
917            WorkflowMode::Hybrid,
918            true,
919            OrchestratorPosition::Bottom,
920        )
921        .unwrap();
922        assert_eq!(pane_map.len(), 2);
923
924        let panes_output = Command::new("tmux")
925            .args([
926                "list-panes",
927                "-t",
928                session,
929                "-F",
930                "#{pane_id} #{@batty_role}",
931            ])
932            .output()
933            .unwrap();
934        assert!(panes_output.status.success());
935        let pane_roles = String::from_utf8_lossy(&panes_output.stdout);
936        assert!(
937            pane_roles
938                .lines()
939                .any(|line| line.ends_with(" orchestrator"))
940        );
941        assert_eq!(pane_roles.lines().count(), 3);
942        assert!(tmp.path().join(".batty").join("orchestrator.log").exists());
943
944        crate::tmux::kill_session(session).unwrap();
945    }
946
947    #[test]
948    #[serial]
949    #[cfg_attr(not(feature = "integration"), ignore)]
950    fn build_layout_skips_orchestrator_pane_when_disabled() {
951        let session = "batty-test-team-layout-no-orchestrator";
952        let _ = crate::tmux::kill_session(session);
953        let tmp = tempfile::tempdir().unwrap();
954
955        let members = make_members(
956            r#"
957name: test
958roles:
959  - name: architect
960    role_type: architect
961    agent: claude
962  - name: engineer
963    role_type: engineer
964    agent: codex
965"#,
966        );
967
968        let pane_map = build_layout(
969            session,
970            &members,
971            &None,
972            tmp.path(),
973            WorkflowMode::Hybrid,
974            false,
975            OrchestratorPosition::Bottom,
976        )
977        .unwrap();
978        assert_eq!(pane_map.len(), 2);
979
980        let pane_count_output = Command::new("tmux")
981            .args(["list-panes", "-t", session, "-F", "#{pane_id}"])
982            .output()
983            .unwrap();
984        assert!(pane_count_output.status.success());
985        let pane_count = String::from_utf8_lossy(&pane_count_output.stdout)
986            .lines()
987            .count();
988        assert_eq!(pane_count, 2);
989
990        crate::tmux::kill_session(session).unwrap();
991    }
992
993    #[test]
994    fn split_members_into_columns_balances_contiguous_groups() {
995        let members = make_members(
996            r#"
997name: test
998roles:
999  - name: architect
1000    role_type: architect
1001    agent: claude
1002    instances: 5
1003"#,
1004        );
1005        let pane_members: Vec<_> = members.iter().collect();
1006        let columns = split_members_into_columns(&pane_members, 2);
1007        assert_eq!(columns.len(), 2);
1008        assert_eq!(columns[0].len(), 3);
1009        assert_eq!(columns[1].len(), 2);
1010        assert_eq!(columns[0][0].name, pane_members[0].name);
1011        assert_eq!(columns[1][0].name, pane_members[3].name);
1012    }
1013
1014    #[test]
1015    #[serial]
1016    #[cfg_attr(not(feature = "integration"), ignore)]
1017    fn build_layout_honors_horizontal_split_for_architect_zone() {
1018        let session = "batty-test-team-layout-architect-pair";
1019        let _ = crate::tmux::kill_session(session);
1020
1021        let members = make_members(
1022            r#"
1023name: test
1024roles:
1025  - name: architect
1026    role_type: architect
1027    agent: claude
1028  - name: scientist
1029    role_type: architect
1030    agent: claude
1031"#,
1032        );
1033
1034        let layout = Some(LayoutConfig {
1035            zones: vec![super::super::config::ZoneDef {
1036                name: "architect".to_string(),
1037                width_pct: 100,
1038                split: Some(super::super::config::SplitDef { horizontal: 2 }),
1039            }],
1040        });
1041
1042        let pane_map = build_layout(
1043            session,
1044            &members,
1045            &layout,
1046            Path::new("/tmp"),
1047            WorkflowMode::Legacy,
1048            true,
1049            OrchestratorPosition::Bottom,
1050        )
1051        .unwrap();
1052        assert_eq!(pane_map.len(), 2);
1053
1054        let geometry_output = Command::new("tmux")
1055            .args([
1056                "list-panes",
1057                "-t",
1058                session,
1059                "-F",
1060                "#{pane_title}\t#{pane_left}\t#{pane_top}\t#{pane_width}\t#{pane_height}",
1061            ])
1062            .output()
1063            .unwrap();
1064        assert!(geometry_output.status.success());
1065
1066        let mut panes = HashMap::new();
1067        for line in String::from_utf8_lossy(&geometry_output.stdout).lines() {
1068            let parts: Vec<_> = line.split('\t').collect();
1069            if parts.len() != 5 {
1070                continue;
1071            }
1072            panes.insert(
1073                parts[0].to_string(),
1074                (
1075                    parts[1].parse::<u32>().unwrap(),
1076                    parts[2].parse::<u32>().unwrap(),
1077                    parts[3].parse::<u32>().unwrap(),
1078                    parts[4].parse::<u32>().unwrap(),
1079                ),
1080            );
1081        }
1082
1083        let architect = panes.get("architect").unwrap();
1084        let scientist = panes.get("scientist").unwrap();
1085        assert_ne!(architect.0, scientist.0, "expected side-by-side panes");
1086        assert_eq!(architect.1, scientist.1, "expected aligned top edges");
1087        assert!(architect.3 > 0 && scientist.3 > 0);
1088
1089        crate::tmux::kill_session(session).unwrap();
1090    }
1091
1092    // --- New tests for #255 ---
1093
1094    #[test]
1095    fn split_off_current_member_pct_single_member() {
1096        // 1 member = 100%, clamped to 90
1097        assert_eq!(split_off_current_member_pct(1), 90);
1098    }
1099
1100    #[test]
1101    fn split_off_current_member_pct_large_count() {
1102        // 20 members = 5%, clamped to 10
1103        assert_eq!(split_off_current_member_pct(20), 10);
1104    }
1105
1106    #[test]
1107    fn split_off_current_member_pct_boundary_values() {
1108        assert_eq!(split_off_current_member_pct(10), 10);
1109        assert_eq!(split_off_current_member_pct(3), 33);
1110        assert_eq!(split_off_current_member_pct(4), 25);
1111    }
1112
1113    #[test]
1114    fn shell_single_quote_no_quotes() {
1115        assert_eq!(shell_single_quote("hello world"), "hello world");
1116    }
1117
1118    #[test]
1119    fn shell_single_quote_with_single_quotes() {
1120        assert_eq!(shell_single_quote("it's"), "it'\"'\"'s");
1121    }
1122
1123    #[test]
1124    fn shell_single_quote_multiple_quotes() {
1125        assert_eq!(shell_single_quote("a'b'c"), "a'\"'\"'b'\"'\"'c");
1126    }
1127
1128    #[test]
1129    fn auto_zones_only_architects() {
1130        let members = make_members(
1131            r#"
1132name: test
1133roles:
1134  - name: architect
1135    role_type: architect
1136    agent: claude
1137    instances: 3
1138"#,
1139        );
1140        let pane_members: Vec<_> = members
1141            .iter()
1142            .filter(|m| m.role_type != RoleType::User)
1143            .collect();
1144        let zones = build_zones_auto(&pane_members);
1145        assert_eq!(zones.len(), 1);
1146        assert_eq!(zones[0].members.len(), 3);
1147        assert!(
1148            zones[0]
1149                .members
1150                .iter()
1151                .all(|m| m.role_type == RoleType::Architect)
1152        );
1153    }
1154
1155    #[test]
1156    fn auto_zones_only_managers() {
1157        let members = make_members(
1158            r#"
1159name: test
1160roles:
1161  - name: manager
1162    role_type: manager
1163    agent: claude
1164    instances: 2
1165"#,
1166        );
1167        let pane_members: Vec<_> = members
1168            .iter()
1169            .filter(|m| m.role_type != RoleType::User)
1170            .collect();
1171        let zones = build_zones_auto(&pane_members);
1172        assert_eq!(zones.len(), 1);
1173        assert_eq!(zones[0].members.len(), 2);
1174    }
1175
1176    #[test]
1177    fn auto_zones_architect_and_engineers_no_managers() {
1178        let members = make_members(
1179            r#"
1180name: test
1181roles:
1182  - name: architect
1183    role_type: architect
1184    agent: claude
1185  - name: engineer
1186    role_type: engineer
1187    agent: codex
1188    instances: 4
1189"#,
1190        );
1191        let pane_members: Vec<_> = members
1192            .iter()
1193            .filter(|m| m.role_type != RoleType::User)
1194            .collect();
1195        let zones = build_zones_auto(&pane_members);
1196        assert_eq!(zones.len(), 2);
1197        assert_eq!(zones[0].members[0].role_type, RoleType::Architect);
1198        assert_eq!(zones[1].members.len(), 4);
1199        assert!(
1200            zones[1]
1201                .members
1202                .iter()
1203                .all(|m| m.role_type == RoleType::Engineer)
1204        );
1205    }
1206
1207    #[test]
1208    fn auto_zones_width_pct_minimum_enforcement() {
1209        // With 1 arch, 1 mgr, 10 eng = 12 total
1210        // Architect raw = 1/12*100 = 8% → clamped to 10%
1211        // Manager raw = 1/12*100 = 8% → clamped to 15%
1212        // Engineer raw = 10/12*100 = 83% → stays at 83% (>20%)
1213        let members = make_members(
1214            r#"
1215name: test
1216roles:
1217  - name: architect
1218    role_type: architect
1219    agent: claude
1220  - name: manager
1221    role_type: manager
1222    agent: claude
1223  - name: engineer
1224    role_type: engineer
1225    agent: codex
1226    instances: 10
1227"#,
1228        );
1229        let pane_members: Vec<_> = members
1230            .iter()
1231            .filter(|m| m.role_type != RoleType::User)
1232            .collect();
1233        let zones = build_zones_auto(&pane_members);
1234        assert_eq!(zones.len(), 3);
1235        assert!(zones[0].width_pct >= 10, "architect zone min 10%");
1236        assert!(zones[1].width_pct >= 15, "manager zone min 15%");
1237        assert!(zones[2].width_pct >= 20, "engineer zone min 20%");
1238    }
1239
1240    #[test]
1241    fn split_zone_subgroups_mixed_roles_returns_single_group() {
1242        let members = make_members(
1243            r#"
1244name: test
1245roles:
1246  - name: architect
1247    role_type: architect
1248    agent: claude
1249  - name: manager
1250    role_type: manager
1251    agent: claude
1252"#,
1253        );
1254        let pane_members: Vec<_> = members
1255            .iter()
1256            .filter(|m| m.role_type != RoleType::User)
1257            .collect();
1258        let subgroups = split_zone_subgroups(&pane_members);
1259        assert_eq!(subgroups.len(), 1);
1260        assert_eq!(subgroups[0].len(), 2);
1261    }
1262
1263    #[test]
1264    fn split_zone_subgroups_single_manager_all_engineers_one_group() {
1265        let members = make_members(
1266            r#"
1267name: test
1268roles:
1269  - name: manager
1270    role_type: manager
1271    agent: claude
1272  - name: engineer
1273    role_type: engineer
1274    agent: codex
1275    instances: 3
1276    talks_to: [manager]
1277"#,
1278        );
1279        let engineers: Vec<_> = members
1280            .iter()
1281            .filter(|m| m.role_type == RoleType::Engineer)
1282            .collect();
1283        let subgroups = split_zone_subgroups(&engineers);
1284        // All engineers report to same manager → single subgroup
1285        assert_eq!(subgroups.len(), 1);
1286        assert_eq!(subgroups[0].len(), 3);
1287    }
1288
1289    #[test]
1290    fn split_members_into_columns_single_column() {
1291        let members = make_members(
1292            r#"
1293name: test
1294roles:
1295  - name: engineer
1296    role_type: engineer
1297    agent: codex
1298    instances: 4
1299"#,
1300        );
1301        let pane_members: Vec<_> = members.iter().collect();
1302        let columns = split_members_into_columns(&pane_members, 1);
1303        assert_eq!(columns.len(), 1);
1304        assert_eq!(columns[0].len(), 4);
1305    }
1306
1307    #[test]
1308    fn split_members_into_columns_more_columns_than_members() {
1309        let members = make_members(
1310            r#"
1311name: test
1312roles:
1313  - name: engineer
1314    role_type: engineer
1315    agent: codex
1316    instances: 2
1317"#,
1318        );
1319        let pane_members: Vec<_> = members.iter().collect();
1320        // Request 5 columns but only 2 members → clamped to 2
1321        let columns = split_members_into_columns(&pane_members, 5);
1322        assert_eq!(columns.len(), 2);
1323        assert_eq!(columns[0].len(), 1);
1324        assert_eq!(columns[1].len(), 1);
1325    }
1326
1327    #[test]
1328    fn split_members_into_columns_exact_division() {
1329        let members = make_members(
1330            r#"
1331name: test
1332roles:
1333  - name: engineer
1334    role_type: engineer
1335    agent: codex
1336    instances: 6
1337"#,
1338        );
1339        let pane_members: Vec<_> = members.iter().collect();
1340        let columns = split_members_into_columns(&pane_members, 3);
1341        assert_eq!(columns.len(), 3);
1342        assert_eq!(columns[0].len(), 2);
1343        assert_eq!(columns[1].len(), 2);
1344        assert_eq!(columns[2].len(), 2);
1345    }
1346
1347    #[test]
1348    fn split_members_into_columns_zero_desired_clamps_to_one() {
1349        let members = make_members(
1350            r#"
1351name: test
1352roles:
1353  - name: engineer
1354    role_type: engineer
1355    agent: codex
1356    instances: 3
1357"#,
1358        );
1359        let pane_members: Vec<_> = members.iter().collect();
1360        let columns = split_members_into_columns(&pane_members, 0);
1361        assert_eq!(columns.len(), 1);
1362        assert_eq!(columns[0].len(), 3);
1363    }
1364
1365    #[test]
1366    fn config_zones_role_type_keyword_matching() {
1367        let members = make_members(
1368            r#"
1369name: test
1370roles:
1371  - name: architect
1372    role_type: architect
1373    agent: claude
1374  - name: manager
1375    role_type: manager
1376    agent: claude
1377  - name: engineer
1378    role_type: engineer
1379    agent: codex
1380    instances: 2
1381"#,
1382        );
1383        // Zone names contain role-type keywords
1384        let layout = LayoutConfig {
1385            zones: vec![
1386                super::super::config::ZoneDef {
1387                    name: "my-architect-zone".to_string(),
1388                    width_pct: 20,
1389                    split: None,
1390                },
1391                super::super::config::ZoneDef {
1392                    name: "the-manager-area".to_string(),
1393                    width_pct: 30,
1394                    split: None,
1395                },
1396                super::super::config::ZoneDef {
1397                    name: "engineer-pool".to_string(),
1398                    width_pct: 50,
1399                    split: None,
1400                },
1401            ],
1402        };
1403        let pane_members: Vec<_> = members
1404            .iter()
1405            .filter(|m| m.role_type != RoleType::User)
1406            .collect();
1407        let zones = build_zones_from_config(&layout, &pane_members);
1408        assert_eq!(zones.len(), 3);
1409        assert_eq!(zones[0].members[0].role_type, RoleType::Architect);
1410        assert_eq!(zones[1].members[0].role_type, RoleType::Manager);
1411        assert_eq!(zones[2].members.len(), 2);
1412        assert!(
1413            zones[2]
1414                .members
1415                .iter()
1416                .all(|m| m.role_type == RoleType::Engineer)
1417        );
1418    }
1419
1420    #[test]
1421    fn config_zones_no_matching_zones_all_go_to_last() {
1422        let members = make_members(
1423            r#"
1424name: test
1425roles:
1426  - name: architect
1427    role_type: architect
1428    agent: claude
1429  - name: engineer
1430    role_type: engineer
1431    agent: codex
1432    instances: 2
1433"#,
1434        );
1435        let layout = LayoutConfig {
1436            zones: vec![super::super::config::ZoneDef {
1437                name: "unrelated-zone".to_string(),
1438                width_pct: 100,
1439                split: None,
1440            }],
1441        };
1442        let pane_members: Vec<_> = members
1443            .iter()
1444            .filter(|m| m.role_type != RoleType::User)
1445            .collect();
1446        let zones = build_zones_from_config(&layout, &pane_members);
1447        assert_eq!(zones.len(), 1);
1448        // All members land in last (only) zone
1449        assert_eq!(zones[0].members.len(), 3);
1450    }
1451
1452    #[test]
1453    fn config_zones_empty_zones_are_removed() {
1454        let members = make_members(
1455            r#"
1456name: test
1457roles:
1458  - name: architect
1459    role_type: architect
1460    agent: claude
1461"#,
1462        );
1463        let layout = LayoutConfig {
1464            zones: vec![
1465                super::super::config::ZoneDef {
1466                    name: "architect".to_string(),
1467                    width_pct: 30,
1468                    split: None,
1469                },
1470                super::super::config::ZoneDef {
1471                    name: "engineers".to_string(),
1472                    width_pct: 70,
1473                    split: None,
1474                },
1475            ],
1476        };
1477        let pane_members: Vec<_> = members
1478            .iter()
1479            .filter(|m| m.role_type != RoleType::User)
1480            .collect();
1481        let zones = build_zones_from_config(&layout, &pane_members);
1482        // Engineer zone is empty and should be removed
1483        assert_eq!(zones.len(), 1);
1484        assert_eq!(zones[0].members[0].name, "architect");
1485    }
1486
1487    #[test]
1488    fn config_zones_horizontal_columns_stored_from_split() {
1489        let layout = LayoutConfig {
1490            zones: vec![super::super::config::ZoneDef {
1491                name: "engineers".to_string(),
1492                width_pct: 100,
1493                split: Some(super::super::config::SplitDef { horizontal: 4 }),
1494            }],
1495        };
1496        let members = make_members(
1497            r#"
1498name: test
1499roles:
1500  - name: engineer
1501    role_type: engineer
1502    agent: codex
1503    instances: 8
1504"#,
1505        );
1506        let pane_members: Vec<_> = members
1507            .iter()
1508            .filter(|m| m.role_type != RoleType::User)
1509            .collect();
1510        let zones = build_zones_from_config(&layout, &pane_members);
1511        assert_eq!(zones.len(), 1);
1512        assert_eq!(zones[0].horizontal_columns, 4);
1513        assert_eq!(zones[0].members.len(), 8);
1514    }
1515
1516    #[test]
1517    fn auto_zones_user_role_excluded() {
1518        let members = make_members(
1519            r#"
1520name: test
1521roles:
1522  - name: human
1523    role_type: user
1524    talks_to: [architect]
1525  - name: architect
1526    role_type: architect
1527    agent: claude
1528  - name: engineer
1529    role_type: engineer
1530    agent: codex
1531"#,
1532        );
1533        let pane_members: Vec<_> = members
1534            .iter()
1535            .filter(|m| m.role_type != RoleType::User)
1536            .collect();
1537        let zones = build_zones_auto(&pane_members);
1538        // Only architect and engineer zones — no user zone
1539        assert_eq!(zones.len(), 2);
1540        assert!(
1541            zones
1542                .iter()
1543                .all(|z| z.members.iter().all(|m| m.role_type != RoleType::User))
1544        );
1545    }
1546
1547    #[test]
1548    fn workflow_mode_legacy_never_enables_orchestrator() {
1549        assert!(!should_launch_orchestrator_pane(WorkflowMode::Legacy, true));
1550        assert!(!should_launch_orchestrator_pane(
1551            WorkflowMode::Legacy,
1552            false
1553        ));
1554    }
1555
1556    #[test]
1557    fn config_zones_max_members_via_split_horizontal() {
1558        let members = make_members(
1559            r#"
1560name: test
1561roles:
1562  - name: engineer
1563    role_type: engineer
1564    agent: codex
1565    instances: 6
1566"#,
1567        );
1568        // Limit engineer zone to only 3 via split.horizontal
1569        let layout = LayoutConfig {
1570            zones: vec![
1571                super::super::config::ZoneDef {
1572                    name: "engineers".to_string(),
1573                    width_pct: 50,
1574                    split: Some(super::super::config::SplitDef { horizontal: 3 }),
1575                },
1576                super::super::config::ZoneDef {
1577                    name: "overflow".to_string(),
1578                    width_pct: 50,
1579                    split: None,
1580                },
1581            ],
1582        };
1583        let pane_members: Vec<_> = members
1584            .iter()
1585            .filter(|m| m.role_type != RoleType::User)
1586            .collect();
1587        let zones = build_zones_from_config(&layout, &pane_members);
1588        // First zone gets 3 engineers (capped by split.horizontal)
1589        assert_eq!(zones[0].members.len(), 3);
1590        // Remaining 3 go to last zone
1591        assert_eq!(zones[1].members.len(), 3);
1592    }
1593}