1use 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
28pub 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 tmux::create_session(session, "bash", &[], &work_dir)?;
54 tmux::rename_window(&format!("{session}:0"), "team")?;
55
56 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 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 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 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 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 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 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
318fn set_pane_title(_session: &str, pane_id: &str, title: &str) -> Result<()> {
324 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 let _ = std::process::Command::new("tmux")
337 .args(["set-option", "-p", "-t", pane_id, "@batty_role", title])
338 .output();
339
340 Ok(())
341}
342
343fn 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 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 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 if let Some(last) = zones.last_mut() {
427 last.members.extend(member_queue);
428 }
429
430 zones.retain(|zone| !zone.members.is_empty());
432 zones
433}
434
435fn 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); assert_eq!(zones[1].members.len(), 2); assert_eq!(zones[2].members.len(), 6); }
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 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 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 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 assert_eq!(zones.len(), 1);
778 assert_eq!(zones[0].members.len(), 4); }
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 #[test]
1095 fn split_off_current_member_pct_single_member() {
1096 assert_eq!(split_off_current_member_pct(1), 90);
1098 }
1099
1100 #[test]
1101 fn split_off_current_member_pct_large_count() {
1102 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 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 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 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 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 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 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 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 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 assert_eq!(zones[0].members.len(), 3);
1590 assert_eq!(zones[1].members.len(), 3);
1592 }
1593}