Skip to main content

autom8/ui/gui/
app.rs

1//! GUI application entry point.
2//!
3//! This module contains the eframe application setup and main window
4//! configuration for the autom8 GUI.
5
6use crate::error::{Autom8Error, Result};
7use crate::state::{IterationStatus, MachineState, SessionStatus, StateManager};
8use crate::ui::gui::components::{
9    badge_background_color, format_relative_time, format_run_duration, format_state,
10    is_terminal_state, state_to_color, strip_worktree_prefix, truncate_with_ellipsis,
11    CollapsibleSection, MAX_BRANCH_LENGTH,
12};
13use crate::ui::gui::config::{
14    BoolFieldChanges, ConfigBoolField, ConfigEditorActions, ConfigScope, ConfigTabState,
15    ConfigTextField, TextFieldChanges, CONFIG_SCOPE_ROW_HEIGHT, CONFIG_SCOPE_ROW_PADDING_H,
16    CONFIG_SCOPE_ROW_PADDING_V,
17};
18use crate::ui::gui::modal::{Modal, ModalAction, ModalButton};
19use crate::ui::gui::theme::{self, colors, rounding, spacing};
20use crate::ui::gui::typography::{self, FontSize, FontWeight};
21use crate::ui::shared::{
22    load_project_run_history, load_session_by_id, load_ui_data, ProjectData, RunHistoryEntry,
23    SessionData,
24};
25use eframe::egui::{self, Color32, Key, Order, Pos2, Rect, Rounding, Sense, Stroke, Vec2};
26use std::sync::Arc;
27use std::time::{Duration, Instant};
28
29/// Default window width in pixels.
30const DEFAULT_WIDTH: f32 = 1200.0;
31
32/// Default window height in pixels.
33const DEFAULT_HEIGHT: f32 = 800.0;
34
35/// Minimum window width in pixels.
36const MIN_WIDTH: f32 = 400.0;
37
38/// Minimum window height in pixels.
39const MIN_HEIGHT: f32 = 300.0;
40
41/// Height of the header/tab bar area (48px = 3 * LG spacing).
42/// Note: Used by tests. The content header uses CONTENT_TAB_BAR_HEIGHT (36px).
43#[allow(dead_code)]
44const HEADER_HEIGHT: f32 = 48.0;
45
46// ============================================================================
47// Title Bar Constants (Custom Title Bar - US-002)
48// ============================================================================
49
50/// Height of the title bar area.
51const TITLE_BAR_HEIGHT: f32 = 48.0;
52
53/// Horizontal offset from the left edge for title bar content.
54const TITLE_BAR_LEFT_OFFSET: f32 = 72.0;
55
56/// Tab indicator underline height.
57const TAB_UNDERLINE_HEIGHT: f32 = 2.0;
58
59/// Tab horizontal padding (uses LG from spacing scale).
60const TAB_PADDING_H: f32 = 16.0; // spacing::LG
61
62/// Default refresh interval for data loading (500ms for GUI, less aggressive than TUI).
63pub const DEFAULT_REFRESH_INTERVAL_MS: u64 = 500;
64
65// ============================================================================
66// Active Runs View Constants
67// ============================================================================
68
69/// Number of output lines to display when getting output for a session.
70/// Used by `get_output_for_session()` to limit output from both live and iteration sources.
71const OUTPUT_LINES_TO_SHOW: usize = 50;
72
73/// Freshness threshold for live output in seconds.
74/// Live output older than this is considered stale and we fall back to iteration output.
75/// This matches the TUI's behavior for consistent user experience.
76const LIVE_OUTPUT_FRESHNESS_SECS: i64 = 5;
77
78// MAX_BRANCH_LENGTH is imported from components module.
79
80// ============================================================================
81// Projects View Constants (using spacing scale)
82// ============================================================================
83
84/// Height of each row in the project list.
85const PROJECT_ROW_HEIGHT: f32 = 56.0;
86
87/// Horizontal padding within project rows (uses MD from spacing scale).
88const PROJECT_ROW_PADDING_H: f32 = 12.0; // spacing::MD
89
90/// Vertical padding within project rows (uses MD from spacing scale).
91const PROJECT_ROW_PADDING_V: f32 = 12.0; // spacing::MD
92
93/// Size of the status indicator dot in the project list.
94const PROJECT_STATUS_DOT_RADIUS: f32 = 5.0;
95
96// ============================================================================
97// Split View Constants (Visual Polish - US-007)
98// ============================================================================
99
100/// Width of the visual divider between split panels.
101const SPLIT_DIVIDER_WIDTH: f32 = 1.0;
102
103/// Spacing around the divider (creates padding between content and divider).
104const SPLIT_DIVIDER_MARGIN: f32 = 12.0; // spacing::MD
105
106/// Minimum width for either panel in the split view.
107const SPLIT_PANEL_MIN_WIDTH: f32 = 200.0;
108
109// ============================================================================
110// Sidebar Constants (Sidebar Navigation - US-003)
111// ============================================================================
112
113/// Width of the sidebar when expanded.
114/// Based on Claude desktop reference (~200-220px).
115const SIDEBAR_WIDTH: f32 = 220.0;
116
117/// Width of the sidebar when collapsed (fully hidden).
118/// The sidebar completely hides when collapsed, maximizing content area.
119const SIDEBAR_COLLAPSED_WIDTH: f32 = 0.0;
120
121// ============================================================================
122// Sidebar Toggle Constants (Collapsible Sidebar - US-004)
123// ============================================================================
124
125/// Size of the sidebar toggle button.
126const SIDEBAR_TOGGLE_SIZE: f32 = 34.0;
127
128/// Horizontal padding before the toggle button.
129const SIDEBAR_TOGGLE_PADDING: f32 = 8.0;
130
131/// Height of each navigation item in the sidebar.
132const SIDEBAR_ITEM_HEIGHT: f32 = 40.0;
133
134/// Horizontal padding for sidebar items.
135const SIDEBAR_ITEM_PADDING_H: f32 = 16.0; // spacing::LG
136
137/// Vertical padding for sidebar items.
138/// Note: Used by tests, available for future refinement.
139#[allow(dead_code)]
140const SIDEBAR_ITEM_PADDING_V: f32 = 8.0; // spacing::SM
141
142/// Width of the accent bar indicator for active items.
143const SIDEBAR_ACTIVE_INDICATOR_WIDTH: f32 = 3.0;
144
145/// Corner rounding for sidebar item backgrounds.
146const SIDEBAR_ITEM_ROUNDING: f32 = 6.0;
147
148/// Size of the sidebar mascot icon in pixels (US-005).
149/// Sized for strong visual presence in the sidebar.
150const SIDEBAR_ICON_SIZE: f32 = 120.0;
151
152// ============================================================================
153// Context Menu Constants (Right-Click Context Menu - US-002)
154// ============================================================================
155
156/// Minimum width for the context menu (US-001).
157const CONTEXT_MENU_MIN_WIDTH: f32 = 100.0;
158
159/// Maximum width for the context menu (US-001).
160const CONTEXT_MENU_MAX_WIDTH: f32 = 300.0;
161
162/// Height of each menu item.
163const CONTEXT_MENU_ITEM_HEIGHT: f32 = 32.0;
164
165/// Horizontal padding for menu items.
166const CONTEXT_MENU_PADDING_H: f32 = 12.0; // spacing::MD
167
168/// Vertical padding for menu items.
169const CONTEXT_MENU_PADDING_V: f32 = 6.0;
170
171/// Size of the submenu arrow indicator.
172const CONTEXT_MENU_ARROW_SIZE: f32 = 8.0;
173
174/// Offset from cursor for menu positioning.
175const CONTEXT_MENU_CURSOR_OFFSET: f32 = 2.0;
176
177/// Horizontal gap between submenu and parent menu.
178const CONTEXT_MENU_SUBMENU_GAP: f32 = 2.0;
179
180/// Response from rendering a context menu item.
181///
182/// Contains information about user interaction with the item.
183struct ContextMenuItemResponse {
184    /// Whether the item was clicked.
185    clicked: bool,
186    /// Whether the item is currently hovered (only true for enabled items).
187    hovered: bool,
188    /// Whether the item is hovered regardless of enabled state (US-006: for tooltips).
189    hovered_raw: bool,
190    /// The screen-space rect of the item (for positioning submenus).
191    rect: Rect,
192}
193
194// ============================================================================
195// Helper Functions
196// ============================================================================
197
198/// Calculate the final menu width from a measured text width (US-001).
199///
200/// Applies padding and clamping to the max text width to get the final menu width.
201/// This is separated from the text measurement for testability.
202///
203/// # Arguments
204/// * `max_text_width` - The maximum text width among all menu item labels
205/// * `has_submenu` - Whether any items have submenus (adds extra space for arrow)
206fn calculate_menu_width_from_text_width(max_text_width: f32) -> f32 {
207    // Total width = text width + left padding (24px) + right padding (24px)
208    // The padding is ~48px total (CONTEXT_MENU_PADDING_H * 4)
209    let padding = CONTEXT_MENU_PADDING_H * 2.0 + CONTEXT_MENU_PADDING_H * 2.0;
210    let calculated_width = max_text_width + padding;
211
212    // Clamp to min/max bounds
213    calculated_width.clamp(CONTEXT_MENU_MIN_WIDTH, CONTEXT_MENU_MAX_WIDTH)
214}
215
216/// Calculate the dynamic width for a context menu based on its items (US-001).
217///
218/// The width is determined by:
219/// 1. Measuring the text width of each label using the Body font
220/// 2. Adding horizontal padding (24px each side = 48px total)
221/// 3. Adding submenu arrow space for items with submenus (arrow size + padding)
222/// 4. Clamping to min/max bounds (100px-300px)
223fn calculate_context_menu_width(ctx: &egui::Context, items: &[ContextMenuItem]) -> f32 {
224    let font_id = typography::font(FontSize::Body, FontWeight::Regular);
225
226    let max_text_width = items
227        .iter()
228        .filter_map(|item| {
229            match item {
230                ContextMenuItem::Action { label, .. } => {
231                    // Measure text width using egui's font system
232                    let galley = ctx.fonts(|fonts| {
233                        fonts.layout_no_wrap(label.clone(), font_id.clone(), Color32::WHITE)
234                    });
235                    Some(galley.rect.width())
236                }
237                ContextMenuItem::Submenu { label, .. } => {
238                    // Submenu items need extra space for the arrow indicator
239                    let galley = ctx.fonts(|fonts| {
240                        fonts.layout_no_wrap(label.clone(), font_id.clone(), Color32::WHITE)
241                    });
242                    // Add space for the arrow: arrow_size + padding between text and arrow
243                    Some(galley.rect.width() + CONTEXT_MENU_ARROW_SIZE + CONTEXT_MENU_PADDING_H)
244                }
245                ContextMenuItem::Separator => None, // Separators don't contribute to width
246            }
247        })
248        .fold(0.0_f32, |max, width| max.max(width));
249
250    calculate_menu_width_from_text_width(max_text_width)
251}
252
253// ============================================================================
254// Output Display Helpers (US-002: Improve Output Display Update Mechanism)
255// ============================================================================
256
257/// Result of determining which output to display for a session.
258///
259/// This enum represents the source of output to display in the session card,
260/// providing clear semantics for the rendering code.
261#[derive(Debug, Clone, PartialEq)]
262enum OutputSource {
263    /// Fresh live output from Claude (within freshness threshold).
264    /// Contains the lines to display (already limited to OUTPUT_LINES_TO_SHOW).
265    Live(Vec<String>),
266    /// Archived iteration output (fallback when live output is stale).
267    /// Contains the lines to display (already limited to OUTPUT_LINES_TO_SHOW).
268    Iteration(Vec<String>),
269    /// Status message fallback when no output is available.
270    /// Contains a descriptive message based on the machine state (e.g., "Waiting for output...").
271    StatusMessage(String),
272    /// No live output data available (session may not be actively running).
273    NoData,
274}
275
276/// Get the appropriate output to display for a session.
277///
278/// This function implements intelligent output source selection, matching
279/// the TUI's `get_output_snippet` behavior for consistent user experience.
280///
281/// ## Priority (matches TUI implementation):
282/// 1. **Fresh live output** - If `machine_state == RunningClaude` AND live output exists
283///    AND output is fresh (< 5 seconds old) AND `output_lines` is not empty
284/// 2. **Iteration output** - If the latest iteration has an `output_snippet`
285/// 3. **Status message** - Fallback based on the current machine state
286///
287/// ## Freshness Check:
288/// Live output is considered "fresh" if updated within `LIVE_OUTPUT_FRESHNESS_SECS` (5 seconds).
289/// This prevents showing stale output when Claude pauses or transitions between phases,
290/// providing a smoother experience without flickering or jarring transitions.
291///
292/// ## Arguments
293/// * `session` - The session data to get output for
294///
295/// ## Returns
296/// An `OutputSource` variant indicating which output to display and its content.
297fn get_output_for_session(session: &SessionData) -> OutputSource {
298    // Get the machine state (default to Idle if no run)
299    let machine_state = session
300        .run
301        .as_ref()
302        .map(|r| r.machine_state)
303        .unwrap_or(MachineState::Idle);
304
305    // Priority 1: Check for fresh live output when Claude is running
306    if machine_state == MachineState::RunningClaude {
307        if let Some(ref live) = session.live_output {
308            // Check if live output is fresh (within freshness threshold)
309            let age = chrono::Utc::now().signed_duration_since(live.updated_at);
310            if age.num_seconds() < LIVE_OUTPUT_FRESHNESS_SECS && !live.output_lines.is_empty() {
311                // Take last OUTPUT_LINES_TO_SHOW lines from live output
312                let take_count = OUTPUT_LINES_TO_SHOW.min(live.output_lines.len());
313                let start = live.output_lines.len().saturating_sub(take_count);
314                let lines: Vec<String> = live.output_lines[start..].to_vec();
315                return OutputSource::Live(lines);
316            }
317        }
318    }
319
320    // Priority 2: Get output from iterations (US-005: check all iterations, not just last)
321    // During state transitions or when a new iteration starts, the current iteration may
322    // have empty output. We fall back to previous iterations to prevent flickering.
323    if let Some(ref run) = session.run {
324        // Check iterations in reverse order (most recent first)
325        for iter in run.iterations.iter().rev() {
326            if !iter.output_snippet.is_empty() {
327                // Take last OUTPUT_LINES_TO_SHOW lines of output
328                let lines: Vec<String> = iter
329                    .output_snippet
330                    .lines()
331                    .collect::<Vec<_>>()
332                    .into_iter()
333                    .rev()
334                    .take(OUTPUT_LINES_TO_SHOW)
335                    .collect::<Vec<_>>()
336                    .into_iter()
337                    .rev()
338                    .map(|s| s.to_string())
339                    .collect();
340                return OutputSource::Iteration(lines);
341            }
342        }
343    }
344
345    // Priority 3: Fall back to status message based on machine state
346    // If there's no live output at all, show NoData (session not actively running)
347    if session.live_output.is_none() {
348        return OutputSource::NoData;
349    }
350
351    // Fall back to status message for other states
352    let message = match machine_state {
353        MachineState::Idle => "Waiting to start...",
354        MachineState::LoadingSpec => "Loading spec file...",
355        MachineState::GeneratingSpec => "Generating spec from markdown...",
356        MachineState::Initializing => "Initializing run...",
357        MachineState::PickingStory => "Selecting next story...",
358        // US-004: For RunningClaude, show "Waiting" only when there's no iteration output
359        // (i.e., we got here because there's no output at all to show).
360        // This ensures previous iteration output remains visible until new output arrives.
361        MachineState::RunningClaude => "Waiting for output...",
362        MachineState::Reviewing => "Reviewing changes...",
363        MachineState::Correcting => "Applying corrections...",
364        MachineState::Committing => "Committing changes...",
365        MachineState::CreatingPR => "Creating pull request...",
366        MachineState::Completed => "Run completed successfully!",
367        MachineState::Failed => "Run failed.",
368    };
369    OutputSource::StatusMessage(message.to_string())
370}
371
372/// Check if a session is resumable.
373///
374/// A session is resumable if:
375/// - It's not stale (worktree still exists)
376/// - It's marked as running, OR
377/// - It has a machine state that's not Idle or Completed
378fn is_resumable_session(session: &SessionStatus) -> bool {
379    // Can't resume stale sessions (deleted worktrees)
380    if session.is_stale {
381        return false;
382    }
383
384    // Running sessions are resumable
385    if session.metadata.is_running {
386        return true;
387    }
388
389    // Check if the machine state indicates a resumable run
390    if let Some(state) = &session.machine_state {
391        match state {
392            MachineState::Completed | MachineState::Idle => false,
393            _ => true, // Any other state is resumable
394        }
395    } else {
396        false
397    }
398}
399
400/// Format session status data as plain text lines for display.
401///
402/// US-002: This replaces the CLI output formatting from status.rs for GUI display.
403/// Shows: session ID, branch, state, current story, started time.
404fn format_sessions_as_text(sessions: &[SessionStatus]) -> Vec<String> {
405    let mut lines = Vec::new();
406
407    if sessions.is_empty() {
408        lines.push("No sessions found for this project.".to_string());
409        return lines;
410    }
411
412    lines.push("Sessions for this project:".to_string());
413    lines.push(String::new());
414
415    for session in sessions {
416        let metadata = &session.metadata;
417
418        // Session indicator based on state
419        let indicator = if session.is_stale {
420            "✗"
421        } else if session.is_current {
422            "→"
423        } else if metadata.is_running {
424            "●"
425        } else {
426            "○"
427        };
428
429        // Build session header line
430        let mut header = format!("{} {}", indicator, metadata.session_id);
431        if session.is_current {
432            header.push_str(" (current)");
433        }
434        if session.is_stale {
435            header.push_str(" [stale]");
436        }
437        lines.push(header);
438
439        // Branch
440        lines.push(format!("  Branch:  {}", metadata.branch_name));
441
442        // State
443        if let Some(state) = &session.machine_state {
444            let state_str = format_machine_state_text(state);
445            lines.push(format!("  State:   {}", state_str));
446        }
447
448        // Current story (if any)
449        if let Some(story) = &session.current_story {
450            lines.push(format!("  Story:   {}", story));
451        }
452
453        // Started time
454        lines.push(format!(
455            "  Started: {}",
456            metadata.created_at.format("%Y-%m-%d %H:%M")
457        ));
458
459        lines.push(String::new());
460    }
461
462    // Summary line
463    let running_count = sessions
464        .iter()
465        .filter(|s| s.metadata.is_running && !s.is_stale)
466        .count();
467    let stale_count = sessions.iter().filter(|s| s.is_stale).count();
468
469    let mut summary = format!(
470        "({} session{}",
471        sessions.len(),
472        if sessions.len() == 1 { "" } else { "s" }
473    );
474    if running_count > 0 {
475        summary.push_str(&format!(", {} running", running_count));
476    }
477    if stale_count > 0 {
478        summary.push_str(&format!(", {} stale", stale_count));
479    }
480    summary.push(')');
481    lines.push(summary);
482
483    lines
484}
485
486/// Format machine state for text display.
487fn format_machine_state_text(state: &MachineState) -> &'static str {
488    match state {
489        MachineState::Idle => "Idle",
490        MachineState::LoadingSpec => "Loading Spec",
491        MachineState::GeneratingSpec => "Generating Spec",
492        MachineState::Initializing => "Initializing",
493        MachineState::PickingStory => "Picking Story",
494        MachineState::RunningClaude => "Running Claude",
495        MachineState::Reviewing => "Reviewing",
496        MachineState::Correcting => "Correcting",
497        MachineState::Committing => "Committing",
498        MachineState::CreatingPR => "Creating PR",
499        MachineState::Completed => "Completed",
500        MachineState::Failed => "Failed",
501    }
502}
503
504/// Format resume session information as plain text lines for display.
505///
506/// US-005: Shows session info instead of spawning subprocess.
507/// Info includes: session ID, branch, worktree path, current state.
508/// Shows message with instructions on how to resume in terminal.
509fn format_resume_info_as_text(session: &ResumableSessionInfo) -> Vec<String> {
510    let mut lines = Vec::new();
511
512    lines.push("Resume Session Information".to_string());
513    lines.push(String::new());
514    lines.push(format!("Session ID:    {}", session.session_id));
515    lines.push(format!("Branch:        {}", session.branch_name));
516    lines.push(format!(
517        "Worktree Path: {}",
518        session.worktree_path.display()
519    ));
520    lines.push(format!(
521        "Current State: {}",
522        format_machine_state_text(&session.machine_state)
523    ));
524    lines.push(String::new());
525    lines.push(format!(
526        "To resume, run `autom8 resume --session {}` in terminal",
527        session.session_id
528    ));
529
530    lines
531}
532
533/// Format project description as plain text lines for display.
534///
535/// US-003: This replaces the CLI output formatting from print_project_description() for GUI display.
536/// Shows: project name, path, status, specs with progress, file counts.
537fn format_project_description_as_text(desc: &crate::config::ProjectDescription) -> Vec<String> {
538    use crate::state::RunStatus;
539
540    let mut lines = Vec::new();
541
542    // Project header
543    lines.push(format!("Project: {}", desc.name));
544    lines.push(format!("Path: {}", desc.path.display()));
545    lines.push(String::new());
546
547    // Status
548    let status_text = match desc.run_status {
549        Some(RunStatus::Running) => "[running]",
550        Some(RunStatus::Failed) => "[failed]",
551        Some(RunStatus::Interrupted) => "[interrupted]",
552        Some(RunStatus::Completed) => "[completed]",
553        None => "[idle]",
554    };
555    lines.push(format!("Status: {}", status_text));
556
557    // Branch (if any)
558    if let Some(branch) = &desc.current_branch {
559        lines.push(format!("Branch: {}", branch));
560    }
561
562    // Current story (if any)
563    if let Some(story) = &desc.current_story {
564        lines.push(format!("Current Story: {}", story));
565    }
566    lines.push(String::new());
567
568    // Specs
569    if desc.specs.is_empty() {
570        lines.push("No specs found.".to_string());
571    } else {
572        lines.push(format!("Specs: ({} total)", desc.specs.len()));
573        lines.push(String::new());
574
575        for spec in &desc.specs {
576            lines.extend(format_spec_summary_as_text(spec));
577        }
578    }
579
580    // File counts summary
581    lines.push("─────────────────────────────────────────────────────────".to_string());
582    lines.push(format!(
583        "Files: {} spec md, {} spec json, {} archived runs",
584        desc.spec_md_count,
585        desc.specs.len(),
586        desc.runs_count
587    ));
588
589    lines
590}
591
592/// Format a single spec summary as plain text lines.
593///
594/// Shows full details (with user stories) only for the active spec.
595/// All other specs (including when no spec is active) show condensed view.
596fn format_spec_summary_as_text(spec: &crate::config::SpecSummary) -> Vec<String> {
597    let mut lines = Vec::new();
598
599    // Show "(Active)" indicator for the active spec
600    let active_label = if spec.is_active { " (Active)" } else { "" };
601    lines.push(format!("━━━ {}{}", spec.filename, active_label));
602
603    // Only show full details for the active spec
604    // All other specs (or when no spec is active) show condensed view
605    if !spec.is_active {
606        let desc_preview = if spec.description.len() > 80 {
607            format!("{}...", &spec.description[..80])
608        } else {
609            spec.description.clone()
610        };
611        let first_line = desc_preview.lines().next().unwrap_or(&desc_preview);
612        lines.push(first_line.to_string());
613        lines.push(format!(
614            "({}/{} stories complete)",
615            spec.completed_count, spec.total_count
616        ));
617        lines.push(String::new());
618        return lines;
619    }
620
621    // Full display for active spec only
622    lines.push(format!("Project: {}", spec.project_name));
623    lines.push(format!("Branch:  {}", spec.branch_name));
624
625    // Description preview (first line, truncated to 100 chars)
626    let desc_preview = if spec.description.len() > 100 {
627        format!("{}...", &spec.description[..100])
628    } else {
629        spec.description.clone()
630    };
631    let first_line = desc_preview.lines().next().unwrap_or(&desc_preview);
632    lines.push(format!("Description: {}", first_line));
633    lines.push(String::new());
634
635    // Progress bar (simple text version)
636    let progress_bar = make_progress_bar_text(spec.completed_count, spec.total_count, 12);
637    lines.push(format!(
638        "Progress: [{}] {}/{} stories complete",
639        progress_bar, spec.completed_count, spec.total_count
640    ));
641    lines.push(String::new());
642
643    // User stories
644    lines.push("User Stories:".to_string());
645    for story in &spec.stories {
646        let status_icon = if story.passes { "✓" } else { "○" };
647        lines.push(format!("  {} {}: {}", status_icon, story.id, story.title));
648    }
649    lines.push(String::new());
650
651    lines
652}
653
654/// Create a simple text progress bar.
655fn make_progress_bar_text(completed: usize, total: usize, width: usize) -> String {
656    if total == 0 {
657        return " ".repeat(width);
658    }
659    let filled = (completed * width) / total;
660    let empty = width - filled;
661    format!("{}{}", "█".repeat(filled), "░".repeat(empty))
662}
663
664/// Format cleanup summary as plain text lines for display.
665///
666/// US-004: This formats CleanupSummary from direct clean operations for GUI display.
667/// Shows: sessions removed, worktrees removed, bytes freed, skipped sessions, errors.
668fn format_cleanup_summary_as_text(
669    summary: &crate::commands::CleanupSummary,
670    operation: &str,
671) -> Vec<String> {
672    use crate::commands::format_bytes_display;
673
674    let mut lines = Vec::new();
675
676    lines.push(format!("Cleanup Operation: {}", operation));
677    lines.push(String::new());
678
679    // Results section
680    if summary.sessions_removed == 0 && summary.worktrees_removed == 0 {
681        lines.push("No sessions or worktrees were removed.".to_string());
682    } else {
683        let freed_str = format_bytes_display(summary.bytes_freed);
684        lines.push(format!(
685            "Removed {} session{}, {} worktree{}, freed {}",
686            summary.sessions_removed,
687            if summary.sessions_removed == 1 {
688                ""
689            } else {
690                "s"
691            },
692            summary.worktrees_removed,
693            if summary.worktrees_removed == 1 {
694                ""
695            } else {
696                "s"
697            },
698            freed_str
699        ));
700    }
701
702    // Skipped sessions
703    if !summary.sessions_skipped.is_empty() {
704        lines.push(String::new());
705        lines.push(format!(
706            "Skipped {} session{}:",
707            summary.sessions_skipped.len(),
708            if summary.sessions_skipped.len() == 1 {
709                ""
710            } else {
711                "s"
712            }
713        ));
714        for skipped in &summary.sessions_skipped {
715            lines.push(format!("  - {}: {}", skipped.session_id, skipped.reason));
716        }
717    }
718
719    // Errors
720    if !summary.errors.is_empty() {
721        lines.push(String::new());
722        lines.push("Errors during cleanup:".to_string());
723        for error in &summary.errors {
724            lines.push(format!("  - {}", error));
725        }
726    }
727
728    lines
729}
730
731/// Format data cleanup summary as plain text lines for display.
732///
733/// US-003: This formats DataCleanupSummary from clean_data_direct() for GUI display.
734/// Shows: specs removed, runs removed, bytes freed, errors.
735fn format_data_cleanup_summary_as_text(
736    summary: &crate::commands::DataCleanupSummary,
737) -> Vec<String> {
738    use crate::commands::format_bytes_display;
739
740    let mut lines = Vec::new();
741
742    lines.push("Cleanup Operation: Clean Data".to_string());
743    lines.push(String::new());
744
745    // Results section
746    if summary.specs_removed == 0 && summary.runs_removed == 0 {
747        lines.push("No specs or runs were removed.".to_string());
748    } else {
749        let freed_str = format_bytes_display(summary.bytes_freed);
750        lines.push(format!(
751            "Removed {} spec{}, {} run{}, freed {}",
752            summary.specs_removed,
753            if summary.specs_removed == 1 { "" } else { "s" },
754            summary.runs_removed,
755            if summary.runs_removed == 1 { "" } else { "s" },
756            freed_str
757        ));
758    }
759
760    // Errors
761    if !summary.errors.is_empty() {
762        lines.push(String::new());
763        lines.push("Errors during cleanup:".to_string());
764        for error in &summary.errors {
765            lines.push(format!("  - {}", error));
766        }
767    }
768
769    lines
770}
771
772/// Format removal summary as plain text lines for display.
773///
774/// US-004: This formats RemovalSummary from remove_project_direct() for GUI display.
775/// Shows: worktrees removed, config deleted, bytes freed, skipped worktrees, errors.
776fn format_removal_summary_as_text(
777    summary: &crate::commands::RemovalSummary,
778    project_name: &str,
779) -> Vec<String> {
780    use crate::commands::format_bytes_display;
781
782    let mut lines = Vec::new();
783
784    lines.push(format!("Remove Project: {}", project_name));
785    lines.push(String::new());
786
787    // Results section
788    if summary.worktrees_removed == 0 && !summary.config_deleted {
789        if summary.errors.is_empty() {
790            lines.push("Nothing was removed.".to_string());
791        } else {
792            lines.push("Failed to remove project.".to_string());
793        }
794    } else {
795        let freed_str = format_bytes_display(summary.bytes_freed);
796        let mut results = Vec::new();
797
798        if summary.worktrees_removed > 0 {
799            results.push(format!(
800                "{} worktree{}",
801                summary.worktrees_removed,
802                if summary.worktrees_removed == 1 {
803                    ""
804                } else {
805                    "s"
806                }
807            ));
808        }
809
810        if summary.config_deleted {
811            results.push("config directory".to_string());
812        }
813
814        lines.push(format!("Removed: {}", results.join(", ")));
815        lines.push(format!("Freed: {}", freed_str));
816    }
817
818    // Skipped worktrees
819    if !summary.worktrees_skipped.is_empty() {
820        lines.push(String::new());
821        lines.push(format!(
822            "Skipped {} worktree{} (active runs):",
823            summary.worktrees_skipped.len(),
824            if summary.worktrees_skipped.len() == 1 {
825                ""
826            } else {
827                "s"
828            }
829        ));
830        for skipped in &summary.worktrees_skipped {
831            lines.push(format!(
832                "  - {}: {}",
833                skipped.path.display(),
834                skipped.reason
835            ));
836        }
837    }
838
839    // Errors
840    if !summary.errors.is_empty() {
841        lines.push(String::new());
842        lines.push("Errors during removal:".to_string());
843        for error in &summary.errors {
844            lines.push(format!("  - {}", error));
845        }
846    }
847
848    // Success message
849    if summary.errors.is_empty() && (summary.worktrees_removed > 0 || summary.config_deleted) {
850        lines.push(String::new());
851        lines.push(format!(
852            "Project '{}' has been removed from autom8.",
853            project_name
854        ));
855    }
856
857    lines
858}
859
860// ============================================================================
861// Context Menu Types (Right-Click Context Menu - US-002)
862// ============================================================================
863
864/// Menu item in the context menu.
865#[derive(Debug, Clone, PartialEq, Eq)]
866pub enum ContextMenuItem {
867    /// A simple action item.
868    Action {
869        /// Display label for the menu item.
870        label: String,
871        /// Unique identifier for the action.
872        action: ContextMenuAction,
873        /// Whether the item is enabled.
874        enabled: bool,
875    },
876    /// A separator line between items.
877    Separator,
878    /// An item that opens a submenu.
879    Submenu {
880        /// Display label for the submenu trigger.
881        label: String,
882        /// Unique identifier for the submenu.
883        id: String,
884        /// Whether the submenu is enabled.
885        enabled: bool,
886        /// Items in the submenu (built lazily when opened).
887        items: Vec<ContextMenuItem>,
888        /// Optional hint/tooltip shown when disabled (US-006).
889        hint: Option<String>,
890    },
891}
892
893impl ContextMenuItem {
894    /// Create a new action menu item.
895    pub fn action(label: impl Into<String>, action: ContextMenuAction) -> Self {
896        Self::Action {
897            label: label.into(),
898            action,
899            enabled: true,
900        }
901    }
902
903    /// Create a disabled action menu item.
904    pub fn action_disabled(label: impl Into<String>, action: ContextMenuAction) -> Self {
905        Self::Action {
906            label: label.into(),
907            action,
908            enabled: false,
909        }
910    }
911
912    /// Create a separator.
913    pub fn separator() -> Self {
914        Self::Separator
915    }
916
917    /// Create a submenu item.
918    pub fn submenu(
919        label: impl Into<String>,
920        id: impl Into<String>,
921        items: Vec<ContextMenuItem>,
922    ) -> Self {
923        let items_vec = items;
924        Self::Submenu {
925            label: label.into(),
926            id: id.into(),
927            enabled: !items_vec.is_empty(),
928            items: items_vec,
929            hint: None,
930        }
931    }
932
933    /// Create a disabled submenu item with an optional hint/tooltip (US-006).
934    pub fn submenu_disabled(
935        label: impl Into<String>,
936        id: impl Into<String>,
937        hint: impl Into<String>,
938    ) -> Self {
939        Self::Submenu {
940            label: label.into(),
941            id: id.into(),
942            enabled: false,
943            items: Vec::new(),
944            hint: Some(hint.into()),
945        }
946    }
947}
948
949/// Actions that can be triggered from the context menu.
950#[derive(Debug, Clone, PartialEq, Eq)]
951pub enum ContextMenuAction {
952    /// Run the status command for the project.
953    Status,
954    /// Run the describe command for the project.
955    Describe,
956    /// Resume a specific session (with session ID).
957    Resume(Option<String>),
958    /// Clean worktrees for the project.
959    CleanWorktrees,
960    /// Clean orphaned sessions for the project.
961    CleanOrphaned,
962    /// Clean data (specs and archived runs) for the project.
963    CleanData,
964    /// Remove the project from autom8 entirely.
965    RemoveProject,
966}
967
968/// Information about a resumable session for display in the context menu.
969/// This is a simplified view of SessionStatus for the GUI.
970#[derive(Debug, Clone)]
971pub struct ResumableSessionInfo {
972    /// The session ID (e.g., "main" or 8-char hash).
973    pub session_id: String,
974    /// The branch name being worked on.
975    pub branch_name: String,
976    /// The worktree path where this session is running.
977    pub worktree_path: std::path::PathBuf,
978    /// The current machine state (e.g., RunningClaude, Reviewing).
979    pub machine_state: MachineState,
980}
981
982impl ResumableSessionInfo {
983    /// Create a new resumable session info.
984    pub fn new(
985        session_id: impl Into<String>,
986        branch_name: impl Into<String>,
987        worktree_path: std::path::PathBuf,
988        machine_state: MachineState,
989    ) -> Self {
990        Self {
991            session_id: session_id.into(),
992            branch_name: branch_name.into(),
993            worktree_path,
994            machine_state,
995        }
996    }
997
998    /// Returns a truncated version of the session ID (first 8 chars).
999    pub fn truncated_id(&self) -> &str {
1000        if self.session_id.len() > 8 {
1001            &self.session_id[..8]
1002        } else {
1003            &self.session_id
1004        }
1005    }
1006
1007    /// Returns the menu label for this session.
1008    /// Format: "branch-name (session-id-truncated)"
1009    pub fn menu_label(&self) -> String {
1010        format!("{} ({})", self.branch_name, self.truncated_id())
1011    }
1012}
1013
1014/// Information about cleanable sessions for the Clean context menu.
1015/// Contains counts for worktrees, orphaned sessions, specs, and runs.
1016#[derive(Debug, Clone, Default)]
1017pub struct CleanableInfo {
1018    /// Number of cleanable worktrees (non-main sessions with existing worktrees and no active runs).
1019    /// US-006: Counts any worktree that can be cleaned, not just completed sessions.
1020    pub cleanable_worktrees: usize,
1021    /// Number of orphaned sessions (worktree deleted but session state remains).
1022    pub orphaned_sessions: usize,
1023    /// Number of cleanable spec files (pairs of .json/.md counted as 1).
1024    /// US-002: Specs used by active sessions are excluded.
1025    pub cleanable_specs: usize,
1026    /// Number of cleanable archived runs in the runs/ directory.
1027    /// US-002: Runs used by active sessions are excluded.
1028    pub cleanable_runs: usize,
1029}
1030
1031impl CleanableInfo {
1032    /// Returns true if there's anything to clean.
1033    /// US-002: Now also considers specs and runs.
1034    pub fn has_cleanable(&self) -> bool {
1035        self.cleanable_worktrees > 0
1036            || self.orphaned_sessions > 0
1037            || self.cleanable_specs > 0
1038            || self.cleanable_runs > 0
1039    }
1040}
1041
1042/// Check if a session is cleanable.
1043///
1044/// US-006: Updated to consider any non-running session as cleanable.
1045/// A session is cleanable if it doesn't have an active run (is_running=false).
1046/// This makes the Clean menu more useful by enabling it for any worktree.
1047#[allow(dead_code)] // Keep for potential future use and tests
1048fn is_cleanable_session(session: &SessionStatus) -> bool {
1049    // US-006: Simply check if the session has an active run
1050    // Any session without an active run can be cleaned
1051    !session.metadata.is_running
1052}
1053
1054/// Count cleanable spec files in the spec directory.
1055///
1056/// US-002: Spec files come in pairs (.json and .md with the same base name).
1057/// Each pair is counted as a single spec, not duplicated.
1058/// Specs whose paths are in `active_spec_paths` are excluded.
1059fn count_cleanable_specs(
1060    spec_dir: &std::path::Path,
1061    active_spec_paths: &std::collections::HashSet<std::path::PathBuf>,
1062) -> usize {
1063    if !spec_dir.exists() {
1064        return 0;
1065    }
1066
1067    // Collect all .json spec files (we use .json as the canonical file for counting)
1068    let mut cleanable_count = 0;
1069
1070    if let Ok(entries) = std::fs::read_dir(spec_dir) {
1071        for entry in entries.flatten() {
1072            let path = entry.path();
1073            if path.extension().map(|e| e == "json").unwrap_or(false) {
1074                // Check if this spec is used by an active session
1075                if !active_spec_paths.contains(&path) {
1076                    cleanable_count += 1;
1077                }
1078            }
1079        }
1080    }
1081
1082    cleanable_count
1083}
1084
1085/// Count cleanable archived run files in the runs directory.
1086///
1087/// US-002: All files in the runs/ directory are considered cleanable.
1088fn count_cleanable_runs(runs_dir: &std::path::Path) -> usize {
1089    if !runs_dir.exists() {
1090        return 0;
1091    }
1092
1093    std::fs::read_dir(runs_dir)
1094        .map(|entries| entries.filter_map(|e| e.ok()).count())
1095        .unwrap_or(0)
1096}
1097
1098/// State for the context menu overlay.
1099#[derive(Debug, Clone)]
1100pub struct ContextMenuState {
1101    /// Screen position where the menu should appear.
1102    pub position: Pos2,
1103    /// Name of the project this menu is for.
1104    pub project_name: String,
1105    /// The menu items to display.
1106    pub items: Vec<ContextMenuItem>,
1107    /// Currently open submenu ID (if any).
1108    pub open_submenu: Option<String>,
1109    /// Position of the open submenu (if any).
1110    pub submenu_position: Option<Pos2>,
1111}
1112
1113impl ContextMenuState {
1114    /// Create a new context menu state.
1115    pub fn new(position: Pos2, project_name: String, items: Vec<ContextMenuItem>) -> Self {
1116        Self {
1117            position,
1118            project_name,
1119            items,
1120            open_submenu: None,
1121            submenu_position: None,
1122        }
1123    }
1124
1125    /// Open a submenu at the given position.
1126    pub fn open_submenu(&mut self, id: String, position: Pos2) {
1127        self.open_submenu = Some(id);
1128        self.submenu_position = Some(position);
1129    }
1130
1131    /// Close any open submenu.
1132    pub fn close_submenu(&mut self) {
1133        self.open_submenu = None;
1134        self.submenu_position = None;
1135    }
1136}
1137
1138/// Result of a project row interaction.
1139/// Contains information about both left-click and right-click events.
1140#[derive(Debug, Clone, Default)]
1141pub struct ProjectRowInteraction {
1142    /// True if the row was left-clicked (select project).
1143    pub clicked: bool,
1144    /// If right-clicked, contains the screen position for context menu.
1145    pub right_click_pos: Option<Pos2>,
1146}
1147
1148impl ProjectRowInteraction {
1149    /// Create a new interaction with no events.
1150    pub fn none() -> Self {
1151        Self::default()
1152    }
1153
1154    /// Create a left-click interaction.
1155    pub fn click() -> Self {
1156        Self {
1157            clicked: true,
1158            right_click_pos: None,
1159        }
1160    }
1161
1162    /// Create a right-click interaction at the given position.
1163    pub fn right_click(pos: Pos2) -> Self {
1164        Self {
1165            clicked: false,
1166            right_click_pos: Some(pos),
1167        }
1168    }
1169}
1170
1171// ============================================================================
1172// Command Output Types (Command Output Tab - US-007)
1173// ============================================================================
1174
1175/// Status of a command execution.
1176#[derive(Debug, Clone, PartialEq, Eq)]
1177pub enum CommandStatus {
1178    /// Command is currently running.
1179    Running,
1180    /// Command completed successfully (exit code 0).
1181    Completed,
1182    /// Command failed (non-zero exit code or error).
1183    Failed,
1184}
1185
1186/// Identifier for a command output, used for tab matching and cache lookup.
1187#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1188pub struct CommandOutputId {
1189    /// Name of the project the command was run for.
1190    pub project: String,
1191    /// Name of the command (e.g., "status", "describe").
1192    pub command: String,
1193    /// Unique identifier for this command execution (UUID).
1194    pub id: String,
1195}
1196
1197impl CommandOutputId {
1198    /// Create a new command output ID.
1199    pub fn new(project: impl Into<String>, command: impl Into<String>) -> Self {
1200        Self {
1201            project: project.into(),
1202            command: command.into(),
1203            id: uuid::Uuid::new_v4().to_string(),
1204        }
1205    }
1206
1207    /// Create a command output ID with a specific ID (for testing).
1208    #[cfg(test)]
1209    pub fn with_id(
1210        project: impl Into<String>,
1211        command: impl Into<String>,
1212        id: impl Into<String>,
1213    ) -> Self {
1214        Self {
1215            project: project.into(),
1216            command: command.into(),
1217            id: id.into(),
1218        }
1219    }
1220
1221    /// Returns the cache key for this command output.
1222    pub fn cache_key(&self) -> String {
1223        format!("{}:{}:{}", self.project, self.command, self.id)
1224    }
1225
1226    /// Returns the tab label for this command output.
1227    pub fn tab_label(&self) -> String {
1228        // Capitalize first letter of command
1229        let command_display = if self.command.is_empty() {
1230            "Command".to_string()
1231        } else {
1232            let mut chars = self.command.chars();
1233            match chars.next() {
1234                None => "Command".to_string(),
1235                Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
1236            }
1237        };
1238        format!("{}: {}", command_display, self.project)
1239    }
1240}
1241
1242/// State of a command execution for display in a tab.
1243#[derive(Debug, Clone)]
1244pub struct CommandExecution {
1245    /// The command output identifier.
1246    pub id: CommandOutputId,
1247    /// Current status of the command.
1248    pub status: CommandStatus,
1249    /// Lines of stdout output.
1250    pub stdout: Vec<String>,
1251    /// Lines of stderr output.
1252    pub stderr: Vec<String>,
1253    /// Exit code if the command has finished.
1254    pub exit_code: Option<i32>,
1255    /// Whether auto-scroll is enabled (scroll to bottom on new output).
1256    pub auto_scroll: bool,
1257}
1258
1259impl CommandExecution {
1260    /// Create a new command execution in the running state.
1261    pub fn new(id: CommandOutputId) -> Self {
1262        Self {
1263            id,
1264            status: CommandStatus::Running,
1265            stdout: Vec::new(),
1266            stderr: Vec::new(),
1267            exit_code: None,
1268            auto_scroll: true,
1269        }
1270    }
1271
1272    /// Add a line to stdout.
1273    pub fn add_stdout(&mut self, line: String) {
1274        self.stdout.push(line);
1275    }
1276
1277    /// Add a line to stderr.
1278    pub fn add_stderr(&mut self, line: String) {
1279        self.stderr.push(line);
1280    }
1281
1282    /// Mark the command as completed with the given exit code.
1283    pub fn complete(&mut self, exit_code: i32) {
1284        self.exit_code = Some(exit_code);
1285        self.status = if exit_code == 0 {
1286            CommandStatus::Completed
1287        } else {
1288            CommandStatus::Failed
1289        };
1290    }
1291
1292    /// Mark the command as failed (e.g., spawn error).
1293    pub fn fail(&mut self, error_message: String) {
1294        self.stderr.push(error_message);
1295        self.status = CommandStatus::Failed;
1296    }
1297
1298    /// Returns true if the command is still running.
1299    pub fn is_running(&self) -> bool {
1300        self.status == CommandStatus::Running
1301    }
1302
1303    /// Returns true if the command has completed (successfully or not).
1304    pub fn is_finished(&self) -> bool {
1305        self.status != CommandStatus::Running
1306    }
1307
1308    /// Returns the combined output (stdout + stderr interleaved would require timestamps,
1309    /// so we return stdout followed by stderr).
1310    pub fn combined_output(&self) -> Vec<&str> {
1311        let mut output: Vec<&str> = self.stdout.iter().map(|s| s.as_str()).collect();
1312        if !self.stderr.is_empty() {
1313            output.extend(self.stderr.iter().map(|s| s.as_str()));
1314        }
1315        output
1316    }
1317}
1318
1319// ============================================================================
1320// Command Message Types (for async command execution)
1321// ============================================================================
1322
1323/// Message sent from background command execution threads to the UI.
1324#[derive(Debug, Clone)]
1325pub enum CommandMessage {
1326    /// A line of stdout output.
1327    Stdout { cache_key: String, line: String },
1328    /// A line of stderr output.
1329    Stderr { cache_key: String, line: String },
1330    /// Command completed with exit code.
1331    Completed { cache_key: String, exit_code: i32 },
1332    /// Command failed to spawn or encountered an error.
1333    Failed { cache_key: String, error: String },
1334    /// Project was successfully removed (US-005: remove from sidebar).
1335    ProjectRemoved { project_name: String },
1336    /// Cleanup operation completed with result (US-007: show result modal).
1337    CleanupCompleted { result: CleanupResult },
1338}
1339
1340// ============================================================================
1341// Confirmation Dialog Types (US-004)
1342// ============================================================================
1343
1344/// Type of clean operation pending confirmation.
1345#[derive(Debug, Clone, PartialEq)]
1346pub enum PendingCleanOperation {
1347    /// Clean worktrees for a project.
1348    Worktrees { project_name: String },
1349    /// Clean orphaned sessions for a project.
1350    Orphaned { project_name: String },
1351    /// Clean data (specs and archived runs) for a project.
1352    Data {
1353        project_name: String,
1354        specs_count: usize,
1355        runs_count: usize,
1356    },
1357    /// Remove a project from autom8 entirely.
1358    RemoveProject { project_name: String },
1359}
1360
1361impl PendingCleanOperation {
1362    /// Get the title for the confirmation dialog.
1363    fn title(&self) -> &'static str {
1364        match self {
1365            Self::Worktrees { .. } => "Clean Worktrees",
1366            Self::Orphaned { .. } => "Clean Orphaned Sessions",
1367            // US-004: Modal title is "Clean Project Data"
1368            Self::Data { .. } => "Clean Project Data",
1369            Self::RemoveProject { .. } => "Remove Project",
1370        }
1371    }
1372
1373    /// Get the label for the confirm button.
1374    /// US-004: Data cleanup uses "Delete" as the destructive action label.
1375    fn confirm_button_label(&self) -> &'static str {
1376        match self {
1377            Self::Data { .. } => "Delete",
1378            _ => "Confirm",
1379        }
1380    }
1381
1382    /// Get the message for the confirmation dialog.
1383    fn message(&self) -> String {
1384        match self {
1385            Self::Worktrees { project_name } => {
1386                format!(
1387                    "This will remove completed worktrees and their session state for '{}'.\n\n\
1388                     Are you sure you want to continue?",
1389                    project_name
1390                )
1391            }
1392            Self::Orphaned { project_name } => {
1393                format!(
1394                    "This will remove session state for orphaned sessions (where the worktree \
1395                     has been deleted) for '{}'.\n\n\
1396                     Are you sure you want to continue?",
1397                    project_name
1398                )
1399            }
1400            Self::Data {
1401                project_name,
1402                specs_count,
1403                runs_count,
1404            } => {
1405                // US-004: List archived runs first, then specs (per acceptance criteria)
1406                let mut items = Vec::new();
1407                if *runs_count > 0 {
1408                    items.push(format!(
1409                        "{} archived run{}",
1410                        runs_count,
1411                        if *runs_count == 1 { "" } else { "s" }
1412                    ));
1413                }
1414                if *specs_count > 0 {
1415                    items.push(format!(
1416                        "{} spec{}",
1417                        specs_count,
1418                        if *specs_count == 1 { "" } else { "s" }
1419                    ));
1420                }
1421                let items_str = items.join(", ");
1422                format!(
1423                    "This will delete {} for '{}'.\n\n\
1424                     Are you sure you want to continue?",
1425                    items_str, project_name
1426                )
1427            }
1428            Self::RemoveProject { project_name } => {
1429                format!(
1430                    "This will remove all worktrees (except those with active runs) and delete \
1431                     the autom8 configuration for '{}'.\n\n\
1432                     This cannot be undone.",
1433                    project_name
1434                )
1435            }
1436        }
1437    }
1438
1439    /// Get the project name.
1440    fn project_name(&self) -> &str {
1441        match self {
1442            Self::Worktrees { project_name }
1443            | Self::Orphaned { project_name }
1444            | Self::Data { project_name, .. }
1445            | Self::RemoveProject { project_name } => project_name,
1446        }
1447    }
1448}
1449
1450// ============================================================================
1451// Result Modal Types (US-007)
1452// ============================================================================
1453
1454/// Result of a cleanup operation to display in a modal.
1455///
1456/// US-007: After clean or remove operations complete, show a result summary modal.
1457/// This enum stores the summary data from the cleanup operation so it can be
1458/// displayed in a modal after the operation completes.
1459#[derive(Debug, Clone)]
1460pub enum CleanupResult {
1461    /// Result from a worktree cleanup operation.
1462    Worktrees {
1463        project_name: String,
1464        worktrees_removed: usize,
1465        sessions_removed: usize,
1466        bytes_freed: u64,
1467        skipped_count: usize,
1468        error_count: usize,
1469    },
1470    /// Result from an orphaned session cleanup operation.
1471    Orphaned {
1472        project_name: String,
1473        sessions_removed: usize,
1474        bytes_freed: u64,
1475        error_count: usize,
1476    },
1477    /// Result from a project removal operation.
1478    RemoveProject {
1479        project_name: String,
1480        worktrees_removed: usize,
1481        config_deleted: bool,
1482        bytes_freed: u64,
1483        skipped_count: usize,
1484        error_count: usize,
1485    },
1486    /// Result from a data cleanup operation (specs and archived runs).
1487    Data {
1488        project_name: String,
1489        specs_removed: usize,
1490        runs_removed: usize,
1491        bytes_freed: u64,
1492        error_count: usize,
1493    },
1494}
1495
1496impl CleanupResult {
1497    /// Get the title for the result modal.
1498    pub fn title(&self) -> &'static str {
1499        match self {
1500            Self::Worktrees { .. } => "Cleanup Complete",
1501            Self::Orphaned { .. } => "Cleanup Complete",
1502            Self::Data { .. } => "Cleanup Complete",
1503            Self::RemoveProject { .. } => "Project Removed",
1504        }
1505    }
1506
1507    /// Get the message for the result modal.
1508    pub fn message(&self) -> String {
1509        use crate::commands::format_bytes_display;
1510
1511        match self {
1512            Self::Worktrees {
1513                worktrees_removed,
1514                sessions_removed,
1515                bytes_freed,
1516                skipped_count,
1517                error_count,
1518                ..
1519            } => {
1520                let mut parts = Vec::new();
1521
1522                if *worktrees_removed > 0 || *sessions_removed > 0 {
1523                    let freed = format_bytes_display(*bytes_freed);
1524                    parts.push(format!(
1525                        "Removed {} worktree{} and {} session{}, freed {}.",
1526                        worktrees_removed,
1527                        if *worktrees_removed == 1 { "" } else { "s" },
1528                        sessions_removed,
1529                        if *sessions_removed == 1 { "" } else { "s" },
1530                        freed
1531                    ));
1532                } else {
1533                    parts.push("No worktrees or sessions were removed.".to_string());
1534                }
1535
1536                if *skipped_count > 0 {
1537                    parts.push(format!(
1538                        "{} session{} skipped (active runs or uncommitted changes).",
1539                        skipped_count,
1540                        if *skipped_count == 1 {
1541                            " was"
1542                        } else {
1543                            "s were"
1544                        }
1545                    ));
1546                }
1547
1548                if *error_count > 0 {
1549                    parts.push(format!(
1550                        "{} error{} occurred. Check the command output tab for details.",
1551                        error_count,
1552                        if *error_count == 1 { "" } else { "s" }
1553                    ));
1554                }
1555
1556                parts.join("\n\n")
1557            }
1558            Self::Orphaned {
1559                sessions_removed,
1560                bytes_freed,
1561                error_count,
1562                ..
1563            } => {
1564                let mut parts = Vec::new();
1565
1566                if *sessions_removed > 0 {
1567                    let freed = format_bytes_display(*bytes_freed);
1568                    parts.push(format!(
1569                        "Removed {} orphaned session{}, freed {}.",
1570                        sessions_removed,
1571                        if *sessions_removed == 1 { "" } else { "s" },
1572                        freed
1573                    ));
1574                } else {
1575                    parts.push("No orphaned sessions were found.".to_string());
1576                }
1577
1578                if *error_count > 0 {
1579                    parts.push(format!(
1580                        "{} error{} occurred. Check the command output tab for details.",
1581                        error_count,
1582                        if *error_count == 1 { "" } else { "s" }
1583                    ));
1584                }
1585
1586                parts.join("\n\n")
1587            }
1588            Self::RemoveProject {
1589                project_name,
1590                worktrees_removed,
1591                config_deleted,
1592                bytes_freed,
1593                skipped_count,
1594                error_count,
1595            } => {
1596                let mut parts = Vec::new();
1597
1598                if *config_deleted {
1599                    let freed = format_bytes_display(*bytes_freed);
1600                    let mut summary = format!("Project '{}' has been removed.", project_name);
1601                    if *worktrees_removed > 0 {
1602                        summary.push_str(&format!(
1603                            "\n\nRemoved {} worktree{}, freed {}.",
1604                            worktrees_removed,
1605                            if *worktrees_removed == 1 { "" } else { "s" },
1606                            freed
1607                        ));
1608                    }
1609                    parts.push(summary);
1610                } else {
1611                    parts.push(format!(
1612                        "Failed to fully remove project '{}'.",
1613                        project_name
1614                    ));
1615                }
1616
1617                if *skipped_count > 0 {
1618                    parts.push(format!(
1619                        "{} worktree{} skipped (active runs).",
1620                        skipped_count,
1621                        if *skipped_count == 1 {
1622                            " was"
1623                        } else {
1624                            "s were"
1625                        }
1626                    ));
1627                }
1628
1629                if *error_count > 0 {
1630                    parts.push(format!(
1631                        "{} error{} occurred. Check the command output tab for details.",
1632                        error_count,
1633                        if *error_count == 1 { "" } else { "s" }
1634                    ));
1635                }
1636
1637                parts.join("\n\n")
1638            }
1639            Self::Data {
1640                specs_removed,
1641                runs_removed,
1642                bytes_freed,
1643                error_count,
1644                ..
1645            } => {
1646                let mut parts = Vec::new();
1647
1648                if *specs_removed > 0 || *runs_removed > 0 {
1649                    let freed = format_bytes_display(*bytes_freed);
1650                    let mut items = Vec::new();
1651                    if *specs_removed > 0 {
1652                        items.push(format!(
1653                            "{} spec{}",
1654                            specs_removed,
1655                            if *specs_removed == 1 { "" } else { "s" }
1656                        ));
1657                    }
1658                    if *runs_removed > 0 {
1659                        items.push(format!(
1660                            "{} archived run{}",
1661                            runs_removed,
1662                            if *runs_removed == 1 { "" } else { "s" }
1663                        ));
1664                    }
1665                    parts.push(format!("Removed {}, freed {}.", items.join(" and "), freed));
1666                } else {
1667                    parts.push("No data was removed.".to_string());
1668                }
1669
1670                if *error_count > 0 {
1671                    parts.push(format!(
1672                        "{} error{} occurred. Check the command output tab for details.",
1673                        error_count,
1674                        if *error_count == 1 { "" } else { "s" }
1675                    ));
1676                }
1677
1678                parts.join("\n\n")
1679            }
1680        }
1681    }
1682
1683    /// Returns true if the operation had errors.
1684    pub fn has_errors(&self) -> bool {
1685        match self {
1686            Self::Worktrees { error_count, .. }
1687            | Self::Orphaned { error_count, .. }
1688            | Self::RemoveProject { error_count, .. }
1689            | Self::Data { error_count, .. } => *error_count > 0,
1690        }
1691    }
1692}
1693
1694// ============================================================================
1695// GUI-specific Extensions
1696// ============================================================================
1697
1698/// Extension trait for GUI-specific methods on RunHistoryEntry.
1699pub trait RunHistoryEntryExt {
1700    /// Get the status color for display (GUI-specific).
1701    fn status_color(&self) -> Color32;
1702}
1703
1704impl RunHistoryEntryExt for RunHistoryEntry {
1705    fn status_color(&self) -> Color32 {
1706        match self.status {
1707            crate::state::RunStatus::Completed => colors::STATUS_SUCCESS,
1708            crate::state::RunStatus::Failed => colors::STATUS_ERROR,
1709            crate::state::RunStatus::Running => colors::STATUS_RUNNING,
1710            crate::state::RunStatus::Interrupted => colors::STATUS_WARNING,
1711        }
1712    }
1713}
1714
1715// Time formatting utilities (format_duration, format_relative_time) and
1716// text utilities (truncate_with_ellipsis, format_state) are now in the
1717// components module and re-exported for use here.
1718
1719// ============================================================================
1720// Tab Types
1721// ============================================================================
1722
1723/// Unique identifier for tabs.
1724/// Static tabs use well-known IDs, dynamic tabs use unique generated IDs.
1725#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
1726pub enum TabId {
1727    /// The Active Runs tab (permanent).
1728    #[default]
1729    ActiveRuns,
1730    /// The Projects tab (permanent).
1731    Projects,
1732    /// The Config tab (permanent).
1733    Config,
1734    /// The Create Spec tab (permanent).
1735    CreateSpec,
1736    /// A dynamic tab for viewing run details.
1737    /// Contains the run_id as identifier.
1738    RunDetail(String),
1739    /// A dynamic tab for viewing command output.
1740    /// Contains the cache key (project:command:id) as identifier.
1741    CommandOutput(String),
1742}
1743
1744/// Information about a tab displayed in the tab bar.
1745#[derive(Debug, Clone)]
1746pub struct TabInfo {
1747    /// Unique identifier for this tab.
1748    pub id: TabId,
1749    /// Display label shown in the tab bar.
1750    pub label: String,
1751    /// Whether this tab can be closed (permanent tabs cannot be closed).
1752    pub closable: bool,
1753}
1754
1755impl TabInfo {
1756    /// Create a new permanent (non-closable) tab.
1757    pub fn permanent(id: TabId, label: impl Into<String>) -> Self {
1758        Self {
1759            id,
1760            label: label.into(),
1761            closable: false,
1762        }
1763    }
1764
1765    /// Create a new closable dynamic tab.
1766    pub fn closable(id: TabId, label: impl Into<String>) -> Self {
1767        Self {
1768            id,
1769            label: label.into(),
1770            closable: true,
1771        }
1772    }
1773}
1774
1775/// The available tabs in the application.
1776/// This is kept for backward compatibility and used internally.
1777#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1778pub enum Tab {
1779    /// View of currently active runs.
1780    #[default]
1781    ActiveRuns,
1782    /// View of projects.
1783    Projects,
1784    /// View of configuration settings.
1785    Config,
1786    /// View for creating new specs.
1787    CreateSpec,
1788}
1789
1790impl Tab {
1791    /// Returns the display label for this tab.
1792    pub fn label(self) -> &'static str {
1793        match self {
1794            Tab::ActiveRuns => "Active Runs",
1795            Tab::Projects => "Projects",
1796            Tab::Config => "Config",
1797            Tab::CreateSpec => "Create Spec",
1798        }
1799    }
1800
1801    /// Returns all available tabs.
1802    pub fn all() -> &'static [Tab] {
1803        &[Tab::ActiveRuns, Tab::Projects, Tab::Config, Tab::CreateSpec]
1804    }
1805
1806    /// Convert to TabId.
1807    pub fn to_tab_id(self) -> TabId {
1808        match self {
1809            Tab::ActiveRuns => TabId::ActiveRuns,
1810            Tab::Projects => TabId::Projects,
1811            Tab::Config => TabId::Config,
1812            Tab::CreateSpec => TabId::CreateSpec,
1813        }
1814    }
1815}
1816
1817/// Maximum width for the tab bar scroll area.
1818const TAB_BAR_MAX_SCROLL_WIDTH: f32 = 800.0;
1819
1820/// Width of the close button area on closable tabs.
1821const TAB_CLOSE_BUTTON_SIZE: f32 = 16.0;
1822
1823/// Padding around the close button.
1824const TAB_CLOSE_PADDING: f32 = 4.0;
1825
1826/// Gap between tab label text and close button (US-005).
1827/// Provides visual separation to improve readability.
1828const TAB_LABEL_CLOSE_GAP: f32 = 8.0;
1829
1830/// Height of the content header tab bar (only shown when dynamic tabs exist).
1831/// Sized to fit the text tightly without extra vertical gaps.
1832const CONTENT_TAB_BAR_HEIGHT: f32 = 32.0;
1833
1834// ============================================================================
1835// Chat Display Constants (US-003: Chat-Style Message Display Area)
1836// ============================================================================
1837
1838/// Maximum width of chat message bubbles as a fraction of available width.
1839const CHAT_BUBBLE_MAX_WIDTH_RATIO: f32 = 0.75;
1840
1841/// Padding inside chat bubbles.
1842const CHAT_BUBBLE_PADDING: f32 = 12.0;
1843
1844/// Corner rounding for chat bubbles.
1845const CHAT_BUBBLE_ROUNDING: f32 = 16.0;
1846
1847/// Vertical spacing between chat messages.
1848const CHAT_MESSAGE_SPACING: f32 = 12.0;
1849
1850/// User message bubble background color (warm beige, slightly darker than surface).
1851const USER_BUBBLE_COLOR: Color32 = Color32::from_rgb(238, 235, 229);
1852
1853/// Claude message bubble background color (white surface).
1854const CLAUDE_BUBBLE_COLOR: Color32 = Color32::from_rgb(255, 255, 255);
1855
1856// ============================================================================
1857// Chat Input Bar Constants (US-004: Text Input with Send Button)
1858// ============================================================================
1859
1860/// Height of the input bar area.
1861const INPUT_BAR_HEIGHT: f32 = 56.0;
1862
1863/// Corner rounding for the text input field.
1864const INPUT_FIELD_ROUNDING: f32 = 12.0;
1865
1866/// Send button background color - using theme accent color.
1867const SEND_BUTTON_COLOR: Color32 = Color32::from_rgb(0, 122, 255);
1868
1869/// Send button hover color - slightly darker accent.
1870const SEND_BUTTON_HOVER_COLOR: Color32 = Color32::from_rgb(0, 100, 210);
1871
1872/// Send button disabled color - muted gray.
1873const SEND_BUTTON_DISABLED_COLOR: Color32 = Color32::from_rgb(200, 200, 200);
1874
1875/// Size of the send button (square).
1876const SEND_BUTTON_SIZE: f32 = 36.0;
1877
1878// ============================================================================
1879// Claude Process Types (US-005: Claude Process Integration)
1880// ============================================================================
1881
1882/// Message sent from the Claude background thread to the UI.
1883#[derive(Debug, Clone)]
1884pub enum ClaudeMessage {
1885    /// Claude subprocess is being spawned (US-009: Loading and Error States).
1886    /// Sent immediately before attempting to spawn, so UI can show "Starting Claude..." indicator.
1887    Spawning,
1888    /// Claude has started successfully and is ready to receive input.
1889    Started,
1890    /// A chunk of text output from Claude.
1891    Output(String),
1892    /// Claude has paused (no output for a while, likely waiting for user input).
1893    /// This is used to turn off the typing indicator in multi-turn conversations.
1894    ResponsePaused,
1895    /// Claude has finished (successfully or with an error).
1896    /// This is only sent when the process actually terminates.
1897    Finished {
1898        /// Whether Claude exited successfully.
1899        success: bool,
1900        /// Error message if applicable.
1901        error: Option<String>,
1902    },
1903    /// Claude subprocess encountered an error during spawn.
1904    SpawnError(String),
1905}
1906
1907/// Handle to the stdin writer for the Claude subprocess.
1908///
1909/// This allows sending user messages to Claude's stdin from the main thread.
1910pub struct ClaudeStdinHandle {
1911    /// The stdin writer wrapped in a Mutex for thread-safe access.
1912    writer: std::sync::Mutex<Option<std::process::ChildStdin>>,
1913}
1914
1915impl ClaudeStdinHandle {
1916    /// Create a new stdin handle.
1917    pub fn new(stdin: std::process::ChildStdin) -> Self {
1918        Self {
1919            writer: std::sync::Mutex::new(Some(stdin)),
1920        }
1921    }
1922
1923    /// Send a message to Claude's stdin.
1924    ///
1925    /// Returns true if the message was sent successfully.
1926    pub fn send(&self, message: &str) -> bool {
1927        use std::io::Write;
1928        if let Ok(mut guard) = self.writer.lock() {
1929            if let Some(ref mut stdin) = *guard {
1930                // Write the message followed by a newline
1931                if let Err(e) = writeln!(stdin, "{}", message) {
1932                    eprintln!("Failed to write to Claude stdin: {}", e);
1933                    return false;
1934                }
1935                if let Err(e) = stdin.flush() {
1936                    eprintln!("Failed to flush Claude stdin: {}", e);
1937                    return false;
1938                }
1939                return true;
1940            }
1941        }
1942        false
1943    }
1944
1945    /// Close the stdin handle (signals EOF to Claude).
1946    pub fn close(&self) {
1947        if let Ok(mut guard) = self.writer.lock() {
1948            // Drop the stdin handle to close it
1949            *guard = None;
1950        }
1951    }
1952}
1953
1954// ============================================================================
1955// Chat Message Types (US-003: Chat-Style Message Display Area)
1956// ============================================================================
1957
1958/// Represents who sent a chat message.
1959#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1960pub enum ChatMessageSender {
1961    /// Message sent by the user.
1962    User,
1963    /// Message sent by Claude.
1964    Claude,
1965}
1966
1967/// A single message in the chat conversation.
1968#[derive(Debug, Clone)]
1969pub struct ChatMessage {
1970    /// Who sent this message.
1971    pub sender: ChatMessageSender,
1972    /// The message content (may contain multiple lines).
1973    pub content: String,
1974    /// Timestamp when the message was created.
1975    pub timestamp: Instant,
1976}
1977
1978impl ChatMessage {
1979    /// Create a new chat message.
1980    pub fn new(sender: ChatMessageSender, content: impl Into<String>) -> Self {
1981        Self {
1982            sender,
1983            content: content.into(),
1984            timestamp: Instant::now(),
1985        }
1986    }
1987
1988    /// Create a new user message.
1989    pub fn user(content: impl Into<String>) -> Self {
1990        Self::new(ChatMessageSender::User, content)
1991    }
1992
1993    /// Create a new Claude message.
1994    pub fn claude(content: impl Into<String>) -> Self {
1995        Self::new(ChatMessageSender::Claude, content)
1996    }
1997}
1998
1999// ============================================================================
2000// Story Progress Types (US-002: Story Progress Timeline)
2001// ============================================================================
2002
2003/// Status of a story in the story progress timeline.
2004#[derive(Debug, Clone, Copy, PartialEq)]
2005enum StoryStatus {
2006    /// Story has been completed successfully.
2007    Completed,
2008    /// Story is currently being worked on.
2009    Active,
2010    /// Story is pending (not yet started).
2011    Pending,
2012    /// Story failed during implementation.
2013    Failed,
2014}
2015
2016impl StoryStatus {
2017    /// Returns the color for this story status.
2018    fn color(self) -> Color32 {
2019        match self {
2020            StoryStatus::Completed => colors::STATUS_SUCCESS,
2021            StoryStatus::Active => colors::STATUS_RUNNING,
2022            StoryStatus::Pending => colors::TEXT_MUTED,
2023            StoryStatus::Failed => colors::STATUS_ERROR,
2024        }
2025    }
2026
2027    /// Returns the background color for this story status.
2028    fn background(self) -> Color32 {
2029        match self {
2030            StoryStatus::Completed => colors::STATUS_SUCCESS_BG,
2031            StoryStatus::Active => colors::STATUS_RUNNING_BG,
2032            StoryStatus::Pending => colors::SURFACE_HOVER,
2033            StoryStatus::Failed => colors::STATUS_ERROR_BG,
2034        }
2035    }
2036
2037    /// Returns the status indicator text.
2038    fn indicator(self) -> &'static str {
2039        match self {
2040            StoryStatus::Completed => "[done]",
2041            StoryStatus::Active => "[...]",
2042            StoryStatus::Pending => "[ ]",
2043            StoryStatus::Failed => "[x]",
2044        }
2045    }
2046}
2047
2048/// A story item for display in the story progress timeline.
2049#[derive(Debug, Clone)]
2050struct StoryItem {
2051    /// Story ID (e.g., "US-001").
2052    id: String,
2053    /// Story title.
2054    title: String,
2055    /// Current status of the story.
2056    status: StoryStatus,
2057    /// Work summary for completed stories (from most recent successful iteration).
2058    work_summary: Option<String>,
2059}
2060
2061/// Load story items from a session's cached user stories and run state.
2062///
2063/// Returns a list of story items ordered by status: Active first, then Completed,
2064/// then Failed, then Pending. The current story is marked as Active, completed
2065/// stories are marked based on the spec's `passes` field, and the rest are Pending.
2066///
2067/// Work summaries from successful iterations are attached to completed stories.
2068///
2069/// This function uses cached user stories from `SessionData` to avoid file I/O
2070/// on every render frame. The cache is populated during `load_ui_data()`.
2071fn load_story_items(session: &SessionData) -> Vec<StoryItem> {
2072    let Some(ref run) = session.run else {
2073        return Vec::new();
2074    };
2075
2076    // Use cached user stories instead of loading from disk
2077    let Some(ref user_stories) = session.cached_user_stories else {
2078        return Vec::new();
2079    };
2080
2081    let current_story_id = run.current_story.as_deref();
2082
2083    // Build set of failed story IDs from iterations
2084    let failed_stories: std::collections::HashSet<&str> = run
2085        .iterations
2086        .iter()
2087        .filter(|iter| iter.status == IterationStatus::Failed)
2088        .map(|iter| iter.story_id.as_str())
2089        .collect();
2090
2091    // Build map of story_id -> work_summary from successful iterations
2092    // Use the most recent (highest iteration number) work summary for each story
2093    let mut work_summaries: std::collections::HashMap<&str, &str> =
2094        std::collections::HashMap::new();
2095    for iter in &run.iterations {
2096        if iter.status == IterationStatus::Success {
2097            if let Some(ref summary) = iter.work_summary {
2098                work_summaries.insert(&iter.story_id, summary);
2099            }
2100        }
2101    }
2102
2103    // Build story items from cached user stories
2104    let mut items: Vec<StoryItem> = user_stories
2105        .iter()
2106        .map(|story| {
2107            let status = if Some(story.id.as_str()) == current_story_id {
2108                StoryStatus::Active
2109            } else if story.passes {
2110                StoryStatus::Completed
2111            } else if failed_stories.contains(story.id.as_str()) {
2112                StoryStatus::Failed
2113            } else {
2114                StoryStatus::Pending
2115            };
2116
2117            // Attach work summary for completed stories
2118            let work_summary = if status == StoryStatus::Completed {
2119                work_summaries.get(story.id.as_str()).map(|s| s.to_string())
2120            } else {
2121                None
2122            };
2123
2124            StoryItem {
2125                id: story.id.clone(),
2126                title: story.title.clone(),
2127                status,
2128                work_summary,
2129            }
2130        })
2131        .collect();
2132
2133    // Sort: Active first, then by original priority (which is the order in spec)
2134    // Actually, acceptance criteria says "most recent/current at the top"
2135    // so we put active first, then completed (most recently), then pending
2136    items.sort_by(|a, b| {
2137        let order = |s: &StoryItem| match s.status {
2138            StoryStatus::Active => 0,
2139            StoryStatus::Completed => 1,
2140            StoryStatus::Failed => 2,
2141            StoryStatus::Pending => 3,
2142        };
2143        order(a).cmp(&order(b))
2144    });
2145
2146    items
2147}
2148
2149/// The main GUI application state.
2150///
2151/// This struct holds all UI state and loaded data, similar to the TUI's `MonitorApp`.
2152/// Data is refreshed at a configurable interval (default 500ms).
2153pub struct Autom8App {
2154    /// Currently selected tab (legacy, for backward compatibility).
2155    current_tab: Tab,
2156
2157    // ========================================================================
2158    // Dynamic Tab System
2159    // ========================================================================
2160    /// All open tabs in order. The first two are always ActiveRuns and Projects.
2161    tabs: Vec<TabInfo>,
2162    /// The currently active tab ID.
2163    active_tab_id: TabId,
2164    /// The previously active tab ID (for returning after closing a tab).
2165    /// Cleared if the previous tab itself is closed.
2166    previous_tab_id: Option<TabId>,
2167
2168    // ========================================================================
2169    // Data Layer
2170    // ========================================================================
2171    /// Cached project data (used for Project List view).
2172    projects: Vec<ProjectData>,
2173    /// Cached session data for Active Runs view.
2174    /// Contains only running sessions (is_running=true and not stale).
2175    sessions: Vec<SessionData>,
2176    /// Whether there are any active runs.
2177    has_active_runs: bool,
2178
2179    // ========================================================================
2180    // Selection State
2181    // ========================================================================
2182    /// Currently selected project name in the Projects tab.
2183    /// Used for the master-detail split view.
2184    selected_project: Option<String>,
2185
2186    // ========================================================================
2187    // Run History Cache
2188    // ========================================================================
2189    /// Cached run history for the selected project.
2190    /// Loaded when a project is selected, cleared when deselected.
2191    run_history: Vec<RunHistoryEntry>,
2192
2193    /// Cached run details for open detail tabs.
2194    /// Maps run_id to the full RunState for rendering detail views.
2195    run_detail_cache: std::collections::HashMap<String, crate::state::RunState>,
2196
2197    /// Loading state for run history.
2198    /// True while run history is being loaded from disk.
2199    run_history_loading: bool,
2200
2201    /// Error message if run history failed to load.
2202    run_history_error: Option<String>,
2203
2204    // ========================================================================
2205    // Loading State
2206    // ========================================================================
2207    /// Whether the initial data load has completed.
2208    /// Used to show a brief loading state on first render.
2209    initial_load_complete: bool,
2210
2211    // ========================================================================
2212    // Refresh Timing
2213    // ========================================================================
2214    /// Time of the last data refresh.
2215    last_refresh: Instant,
2216    /// Refresh interval for data loading.
2217    refresh_interval: Duration,
2218
2219    // ========================================================================
2220    // Sidebar State (Collapsible Sidebar - US-004)
2221    // ========================================================================
2222    /// Whether the sidebar is collapsed.
2223    /// When collapsed, the sidebar is fully hidden to maximize content area.
2224    /// State persists during the session (not persisted across restarts).
2225    sidebar_collapsed: bool,
2226
2227    // ========================================================================
2228    // Active Runs Tab State (Tab Bar)
2229    // ========================================================================
2230    /// Session ID of the currently selected session tab in the Active Runs view.
2231    /// None means no session is selected (will auto-select first if available).
2232    selected_session_id: Option<String>,
2233
2234    /// Session IDs that have been manually closed by the user.
2235    /// These sessions won't auto-reopen even if still running.
2236    closed_session_tabs: std::collections::HashSet<String>,
2237
2238    /// Cached session data for sessions seen during this GUI lifetime.
2239    /// Persists session data so tabs remain visible after a run completes.
2240    /// Cleared when user explicitly closes the tab.
2241    seen_sessions: std::collections::HashMap<String, SessionData>,
2242
2243    // ========================================================================
2244    // Config Tab State
2245    // ========================================================================
2246    /// State for the Config tab (scope selection, cached configs, etc.).
2247    /// Public for test access.
2248    pub config_state: ConfigTabState,
2249
2250    // ========================================================================
2251    // Context Menu State (Right-Click Context Menu - US-002)
2252    // ========================================================================
2253    /// State for the right-click context menu overlay.
2254    /// When Some, a context menu is displayed at the specified position.
2255    /// Only one context menu can be open at a time.
2256    context_menu: Option<ContextMenuState>,
2257
2258    // ========================================================================
2259    // Command Execution State (Command Output Tab - US-007)
2260    // ========================================================================
2261    /// Cached command executions for open command output tabs.
2262    /// Maps cache_key (project:command:id) to the command execution state.
2263    command_executions: std::collections::HashMap<String, CommandExecution>,
2264
2265    // ========================================================================
2266    // Command Channel (for async command execution - US-003)
2267    // ========================================================================
2268    /// Receiver for command execution messages from background threads.
2269    /// The sender is cloned and moved to each background thread.
2270    command_rx: std::sync::mpsc::Receiver<CommandMessage>,
2271    /// Sender for command execution messages.
2272    /// Cloned for each background command thread.
2273    command_tx: std::sync::mpsc::Sender<CommandMessage>,
2274
2275    // ========================================================================
2276    // Confirmation Dialog State (US-004)
2277    // ========================================================================
2278    /// Pending clean operation awaiting user confirmation.
2279    /// When Some, a confirmation dialog is displayed.
2280    pending_clean_confirmation: Option<PendingCleanOperation>,
2281
2282    // ========================================================================
2283    // Result Modal State (US-007)
2284    // ========================================================================
2285    /// Cleanup result to display in a modal after operation completes.
2286    /// When Some, a result modal is displayed with the cleanup summary.
2287    pending_result_modal: Option<CleanupResult>,
2288
2289    // ========================================================================
2290    // Detail Panel Section State (Active Runs Detail Panel - US-005)
2291    // ========================================================================
2292    /// Collapsed state for collapsible sections in the detail panel.
2293    /// Maps section ID to collapsed state (true = collapsed, false = expanded).
2294    /// State persists during the session but not across restarts.
2295    section_collapsed_state: std::collections::HashMap<String, bool>,
2296
2297    // ========================================================================
2298    // Create Spec Tab State (US-002, US-003, US-004)
2299    // ========================================================================
2300    /// Selected project name in the Create Spec tab.
2301    /// None when no project is selected (initial state).
2302    create_spec_selected_project: Option<String>,
2303    /// Chat messages in the Create Spec conversation (US-003).
2304    /// Stores the full conversation history between user and Claude.
2305    chat_messages: Vec<ChatMessage>,
2306    /// Flag to trigger auto-scroll to the bottom of the chat (US-003).
2307    /// Set to true when new messages are added.
2308    chat_scroll_to_bottom: bool,
2309    /// Text input content for the chat input bar (US-004).
2310    /// Stores the current text being typed by the user.
2311    chat_input_text: String,
2312    /// Flag indicating whether we're waiting for Claude's response (US-004).
2313    /// When true, the input bar is disabled and shows a loading state.
2314    is_waiting_for_claude: bool,
2315
2316    // ========================================================================
2317    // Claude Process State (US-005: Claude Process Integration)
2318    // ========================================================================
2319    /// Channel receiver for messages from the Claude background thread.
2320    claude_rx: std::sync::mpsc::Receiver<ClaudeMessage>,
2321    /// Channel sender for messages from the Claude background thread.
2322    /// Cloned and passed to background threads.
2323    claude_tx: std::sync::mpsc::Sender<ClaudeMessage>,
2324    /// Handle to Claude's stdin for sending user messages.
2325    /// None when no Claude process is running.
2326    claude_stdin: Option<Arc<ClaudeStdinHandle>>,
2327    /// Handle to the Claude child process for termination.
2328    /// Stored in Arc<Mutex> so it can be killed from the main thread while
2329    /// the background thread is reading output.
2330    claude_child: Arc<std::sync::Mutex<Option<std::process::Child>>>,
2331    /// Buffer for accumulating Claude's current response.
2332    /// Text chunks are accumulated here until the response is complete.
2333    claude_response_buffer: String,
2334    /// Error message from the last Claude operation, if any.
2335    /// Displayed in the chat area with a retry option.
2336    claude_error: Option<String>,
2337    /// Whether Claude subprocess is currently being spawned (US-009: Loading and Error States).
2338    /// True from when spawn is initiated until either Started or SpawnError is received.
2339    /// Used to show "Starting Claude..." loading indicator distinct from typing indicator.
2340    claude_starting: bool,
2341    /// Timestamp of the last output received from Claude.
2342    /// Used to detect when Claude has paused (finished a response).
2343    last_claude_output_time: Option<Instant>,
2344    /// Whether Claude is actively streaming output (response in progress).
2345    /// Distinct from is_waiting_for_claude which tracks if we expect more output.
2346    claude_response_in_progress: bool,
2347
2348    // ========================================================================
2349    // Spec Completion State (US-007: Spec Completion and Confirmation)
2350    // ========================================================================
2351    /// Path to the generated spec file, detected from Claude's output.
2352    /// None until Claude writes a spec file to ~/.config/autom8/<project>/spec/
2353    generated_spec_path: Option<std::path::PathBuf>,
2354    /// Whether the user has confirmed the generated spec.
2355    /// When true, shows the "run command" instructions and "Close" button.
2356    spec_confirmed: bool,
2357    /// Whether Claude has finished (process exited) after generating a spec.
2358    /// Used to show the confirmation UI when spec generation is complete.
2359    claude_finished: bool,
2360
2361    // ========================================================================
2362    // Session Management State (US-008: Session State Management)
2363    // ========================================================================
2364    /// Pending project name to switch to, awaiting confirmation.
2365    /// When Some, a confirmation modal is displayed asking user to confirm
2366    /// abandoning the current session to start a new one.
2367    pending_project_change: Option<String>,
2368    /// Whether a "Close" confirmation modal should be shown.
2369    /// When true, displays a modal reminding users to save their spec before resetting.
2370    pending_start_new_spec: bool,
2371}
2372
2373impl Default for Autom8App {
2374    fn default() -> Self {
2375        Self::new()
2376    }
2377}
2378
2379impl Autom8App {
2380    /// Create a new application instance.
2381    pub fn new() -> Self {
2382        Self::with_refresh_interval(Duration::from_millis(DEFAULT_REFRESH_INTERVAL_MS))
2383    }
2384
2385    /// Create a new application instance with a custom refresh interval.
2386    ///
2387    /// # Arguments
2388    ///
2389    /// * `refresh_interval` - How often to refresh data from disk
2390    pub fn with_refresh_interval(refresh_interval: Duration) -> Self {
2391        // Initialize permanent tabs
2392        let tabs = vec![
2393            TabInfo::permanent(TabId::ActiveRuns, "Active Runs"),
2394            TabInfo::permanent(TabId::Projects, "Projects"),
2395            TabInfo::permanent(TabId::Config, "Config"),
2396        ];
2397
2398        // Create channel for command execution messages
2399        let (command_tx, command_rx) = std::sync::mpsc::channel();
2400
2401        // Create channel for Claude messages (US-005)
2402        let (claude_tx, claude_rx) = std::sync::mpsc::channel();
2403
2404        let mut app = Self {
2405            current_tab: Tab::default(),
2406            tabs,
2407            active_tab_id: TabId::default(),
2408            previous_tab_id: None,
2409            projects: Vec::new(),
2410            sessions: Vec::new(),
2411            has_active_runs: false,
2412            selected_project: None,
2413            run_history: Vec::new(),
2414            run_detail_cache: std::collections::HashMap::new(),
2415            run_history_loading: false,
2416            run_history_error: None,
2417            initial_load_complete: false,
2418            last_refresh: Instant::now(),
2419            refresh_interval,
2420            sidebar_collapsed: false,
2421            selected_session_id: None,
2422            closed_session_tabs: std::collections::HashSet::new(),
2423            seen_sessions: std::collections::HashMap::new(),
2424            config_state: ConfigTabState::new(),
2425            context_menu: None,
2426            command_executions: std::collections::HashMap::new(),
2427            command_rx,
2428            command_tx,
2429            pending_clean_confirmation: None,
2430            pending_result_modal: None,
2431            section_collapsed_state: std::collections::HashMap::new(),
2432            create_spec_selected_project: None,
2433            chat_messages: Vec::new(),
2434            chat_scroll_to_bottom: false,
2435            chat_input_text: String::new(),
2436            is_waiting_for_claude: false,
2437            claude_rx,
2438            claude_tx,
2439            claude_stdin: None,
2440            claude_child: Arc::new(std::sync::Mutex::new(None)),
2441            claude_response_buffer: String::new(),
2442            claude_error: None,
2443            claude_starting: false,
2444            last_claude_output_time: None,
2445            claude_response_in_progress: false,
2446            generated_spec_path: None,
2447            spec_confirmed: false,
2448            claude_finished: false,
2449            pending_project_change: None,
2450            pending_start_new_spec: false,
2451        };
2452        // Initial data load
2453        app.refresh_data();
2454        app.initial_load_complete = true;
2455        app
2456    }
2457
2458    /// Returns whether the initial data load has completed.
2459    pub fn is_initial_load_complete(&self) -> bool {
2460        self.initial_load_complete
2461    }
2462
2463    /// Returns the currently selected tab.
2464    pub fn current_tab(&self) -> Tab {
2465        self.current_tab
2466    }
2467
2468    /// Returns the loaded projects.
2469    pub fn projects(&self) -> &[ProjectData] {
2470        &self.projects
2471    }
2472
2473    /// Returns the active sessions.
2474    pub fn sessions(&self) -> &[SessionData] {
2475        &self.sessions
2476    }
2477
2478    /// Returns whether there are any active runs.
2479    pub fn has_active_runs(&self) -> bool {
2480        self.has_active_runs
2481    }
2482
2483    /// Returns the current refresh interval.
2484    pub fn refresh_interval(&self) -> Duration {
2485        self.refresh_interval
2486    }
2487
2488    /// Sets the refresh interval.
2489    pub fn set_refresh_interval(&mut self, interval: Duration) {
2490        self.refresh_interval = interval;
2491    }
2492
2493    // ========================================================================
2494    // Sidebar State (Collapsible Sidebar - US-004)
2495    // ========================================================================
2496
2497    /// Returns whether the sidebar is collapsed.
2498    pub fn is_sidebar_collapsed(&self) -> bool {
2499        self.sidebar_collapsed
2500    }
2501
2502    /// Sets the sidebar collapsed state.
2503    pub fn set_sidebar_collapsed(&mut self, collapsed: bool) {
2504        self.sidebar_collapsed = collapsed;
2505    }
2506
2507    /// Toggles the sidebar collapsed state.
2508    pub fn toggle_sidebar(&mut self) {
2509        self.sidebar_collapsed = !self.sidebar_collapsed;
2510    }
2511
2512    // ========================================================================
2513    // Config Tab State (delegating to ConfigTabState)
2514    // ========================================================================
2515
2516    /// Returns the currently selected config scope.
2517    pub fn selected_config_scope(&self) -> &ConfigScope {
2518        self.config_state.selected_scope()
2519    }
2520
2521    /// Sets the selected config scope.
2522    pub fn set_selected_config_scope(&mut self, scope: ConfigScope) {
2523        self.config_state.set_selected_scope(scope);
2524    }
2525
2526    /// Returns the cached list of project names for config scope selection.
2527    pub fn config_scope_projects(&self) -> &[String] {
2528        self.config_state.scope_projects()
2529    }
2530
2531    /// Returns whether a project has its own config file.
2532    pub fn project_has_config(&self, project_name: &str) -> bool {
2533        self.config_state.project_has_config(project_name)
2534    }
2535
2536    /// Refresh the config scope data (project list and config file status).
2537    fn refresh_config_scope_data(&mut self) {
2538        self.config_state.refresh_scope_data();
2539    }
2540
2541    /// Returns the cached global config, if loaded.
2542    pub fn cached_global_config(&self) -> Option<&crate::config::Config> {
2543        self.config_state.cached_global_config()
2544    }
2545
2546    /// Returns the global config error, if any.
2547    pub fn global_config_error(&self) -> Option<&str> {
2548        self.config_state.global_config_error()
2549    }
2550
2551    /// Returns the cached project config for a specific project, if loaded.
2552    pub fn cached_project_config(&self, project_name: &str) -> Option<&crate::config::Config> {
2553        self.config_state.cached_project_config(project_name)
2554    }
2555
2556    /// Returns the project config error, if any.
2557    pub fn project_config_error(&self) -> Option<&str> {
2558        self.config_state.project_config_error()
2559    }
2560
2561    /// Create a project config file from the current global configuration.
2562    fn create_project_config_from_global(
2563        &mut self,
2564        project_name: &str,
2565    ) -> std::result::Result<(), String> {
2566        self.config_state
2567            .create_project_config_from_global(project_name)
2568    }
2569
2570    /// Apply boolean field changes to the config and save immediately (US-006).
2571    fn apply_config_bool_changes(
2572        &mut self,
2573        is_global: bool,
2574        project_name: Option<&str>,
2575        changes: &[(ConfigBoolField, bool)],
2576    ) {
2577        self.config_state
2578            .apply_bool_changes(is_global, project_name, changes);
2579    }
2580
2581    /// Apply text field changes to the config (US-007).
2582    fn apply_config_text_changes(
2583        &mut self,
2584        is_global: bool,
2585        project_name: Option<&str>,
2586        changes: &[(ConfigTextField, String)],
2587    ) {
2588        self.config_state
2589            .apply_text_changes(is_global, project_name, changes);
2590    }
2591
2592    /// Reset config to application defaults (US-009).
2593    fn reset_config_to_defaults(&mut self, is_global: bool, project_name: Option<&str>) {
2594        self.config_state.reset_to_defaults(is_global, project_name);
2595    }
2596
2597    // ========================================================================
2598    // Context Menu State (Right-Click Context Menu - US-002)
2599    // ========================================================================
2600
2601    /// Returns whether the context menu is currently open.
2602    pub fn is_context_menu_open(&self) -> bool {
2603        self.context_menu.is_some()
2604    }
2605
2606    /// Returns a reference to the context menu state, if open.
2607    pub fn context_menu(&self) -> Option<&ContextMenuState> {
2608        self.context_menu.as_ref()
2609    }
2610
2611    /// Open the context menu for a project at the given position.
2612    pub fn open_context_menu(&mut self, position: Pos2, project_name: String) {
2613        // Build the menu items for this project
2614        let items = self.build_context_menu_items(&project_name);
2615
2616        self.context_menu = Some(ContextMenuState::new(position, project_name, items));
2617    }
2618
2619    /// Close the context menu.
2620    pub fn close_context_menu(&mut self) {
2621        self.context_menu = None;
2622    }
2623
2624    /// Get resumable sessions for a project.
2625    ///
2626    /// Queries the StateManager for sessions that can be resumed:
2627    /// - Session is not stale (worktree still exists)
2628    /// - Session is_running, OR
2629    /// - Session has a machine state that's not Idle/Completed
2630    ///
2631    /// Returns sessions sorted by last_active_at descending.
2632    fn get_resumable_sessions(&self, project_name: &str) -> Vec<ResumableSessionInfo> {
2633        // Try to get the state manager for this project
2634        let sm = match StateManager::for_project(project_name) {
2635            Ok(sm) => sm,
2636            Err(_) => return Vec::new(),
2637        };
2638
2639        // Get all sessions with status
2640        let sessions = match sm.list_sessions_with_status() {
2641            Ok(sessions) => sessions,
2642            Err(_) => return Vec::new(),
2643        };
2644
2645        // Filter to resumable sessions and convert to ResumableSessionInfo
2646        sessions
2647            .into_iter()
2648            .filter(is_resumable_session)
2649            .filter_map(|s| {
2650                // machine_state is required for ResumableSessionInfo, and is_resumable_session
2651                // already ensures it exists and is not Idle/Completed
2652                let machine_state = s.machine_state?;
2653                Some(ResumableSessionInfo::new(
2654                    s.metadata.session_id,
2655                    s.metadata.branch_name,
2656                    s.metadata.worktree_path,
2657                    machine_state,
2658                ))
2659            })
2660            .collect()
2661    }
2662
2663    /// Get a specific resumable session by ID.
2664    ///
2665    /// US-005: Used to look up session details when user clicks a resume option.
2666    fn get_resumable_session_by_id(
2667        &self,
2668        project_name: &str,
2669        session_id: &str,
2670    ) -> Option<ResumableSessionInfo> {
2671        self.get_resumable_sessions(project_name)
2672            .into_iter()
2673            .find(|s| s.session_id == session_id)
2674    }
2675
2676    /// Get cleanable session information for a project.
2677    ///
2678    /// US-006: Updated to count any worktrees that can be cleaned, not just completed sessions.
2679    /// US-002: Now also counts cleanable specs and runs.
2680    ///
2681    /// Returns counts for:
2682    /// - cleanable_worktrees: non-main sessions with existing worktrees and no active runs
2683    /// - orphaned_sessions: sessions where the worktree was deleted but state remains
2684    /// - cleanable_specs: spec files (pairs counted as 1) not used by active sessions
2685    /// - cleanable_runs: archived run files in runs/ directory
2686    ///
2687    /// Safety: Sessions with active runs (is_running=true) are NOT counted as cleanable.
2688    /// Specs used by active sessions are excluded from the count.
2689    fn get_cleanable_info(&self, project_name: &str) -> CleanableInfo {
2690        // Try to get the state manager for this project
2691        let sm = match StateManager::for_project(project_name) {
2692            Ok(sm) => sm,
2693            Err(_) => return CleanableInfo::default(),
2694        };
2695
2696        // Get all sessions with status
2697        let sessions = match sm.list_sessions_with_status() {
2698            Ok(sessions) => sessions,
2699            Err(_) => return CleanableInfo::default(),
2700        };
2701
2702        let mut info = CleanableInfo::default();
2703
2704        // US-002: Collect spec paths used by active (running) sessions
2705        let mut active_spec_paths: std::collections::HashSet<std::path::PathBuf> =
2706            std::collections::HashSet::new();
2707
2708        for session in &sessions {
2709            // Skip main session - it's not a worktree created by autom8
2710            if session.metadata.session_id == "main" {
2711                // Still need to check if main session has active run with spec
2712                if session.metadata.is_running {
2713                    if let Some(session_sm) = sm.get_session(&session.metadata.session_id) {
2714                        if let Ok(Some(state)) = session_sm.load_current() {
2715                            active_spec_paths.insert(state.spec_json_path.clone());
2716                            if let Some(md_path) = &state.spec_md_path {
2717                                active_spec_paths.insert(md_path.clone());
2718                            }
2719                        }
2720                    }
2721                }
2722                continue;
2723            }
2724
2725            if session.is_stale {
2726                // Orphaned session: worktree was deleted
2727                info.orphaned_sessions += 1;
2728            } else if !session.metadata.is_running {
2729                // US-006: Count any worktree that exists and doesn't have an active run
2730                // The actual clean operation will also skip active runs
2731                info.cleanable_worktrees += 1;
2732            } else {
2733                // US-002: Session is running - collect its spec paths to exclude
2734                if let Some(session_sm) = sm.get_session(&session.metadata.session_id) {
2735                    if let Ok(Some(state)) = session_sm.load_current() {
2736                        active_spec_paths.insert(state.spec_json_path.clone());
2737                        if let Some(md_path) = &state.spec_md_path {
2738                            active_spec_paths.insert(md_path.clone());
2739                        }
2740                    }
2741                }
2742            }
2743        }
2744
2745        // US-002: Count cleanable specs (pairs counted as 1)
2746        info.cleanable_specs = count_cleanable_specs(&sm.spec_dir(), &active_spec_paths);
2747
2748        // US-002: Count cleanable runs
2749        info.cleanable_runs = count_cleanable_runs(&sm.runs_dir());
2750
2751        info
2752    }
2753
2754    /// Build the context menu items for a project.
2755    /// This creates the menu structure with Status, Describe, Resume, and Clean options.
2756    fn build_context_menu_items(&self, project_name: &str) -> Vec<ContextMenuItem> {
2757        // Get resumable sessions for this project
2758        let resumable_sessions = self.get_resumable_sessions(project_name);
2759
2760        // Build the Resume menu item based on number of sessions
2761        let resume_item = match resumable_sessions.len() {
2762            0 => {
2763                // No resumable sessions - disabled menu item
2764                ContextMenuItem::action_disabled("Resume", ContextMenuAction::Resume(None))
2765            }
2766            1 => {
2767                // Single session - direct action with branch name
2768                let session = &resumable_sessions[0];
2769                let label = format!("Resume ({})", session.branch_name);
2770                ContextMenuItem::action(
2771                    label,
2772                    ContextMenuAction::Resume(Some(session.session_id.clone())),
2773                )
2774            }
2775            _ => {
2776                // Multiple sessions - submenu
2777                let submenu_items: Vec<ContextMenuItem> = resumable_sessions
2778                    .iter()
2779                    .map(|session| {
2780                        ContextMenuItem::action(
2781                            session.menu_label(),
2782                            ContextMenuAction::Resume(Some(session.session_id.clone())),
2783                        )
2784                    })
2785                    .collect();
2786                ContextMenuItem::submenu("Resume", "resume", submenu_items)
2787            }
2788        };
2789
2790        // Get cleanable info for this project (US-006)
2791        let cleanable_info = self.get_cleanable_info(project_name);
2792
2793        // Build the Clean menu item based on cleanable info
2794        let clean_item = if !cleanable_info.has_cleanable() {
2795            // Nothing to clean - disabled menu item with tooltip hint (US-006)
2796            ContextMenuItem::submenu_disabled("Clean", "clean", "Nothing to clean")
2797        } else {
2798            // Build submenu with only applicable options (showing counts)
2799            let mut submenu_items = Vec::new();
2800
2801            if cleanable_info.cleanable_worktrees > 0 {
2802                let label = format!("Worktrees ({})", cleanable_info.cleanable_worktrees);
2803                submenu_items.push(ContextMenuItem::action(
2804                    label,
2805                    ContextMenuAction::CleanWorktrees,
2806                ));
2807            }
2808
2809            if cleanable_info.orphaned_sessions > 0 {
2810                let label = format!("Orphaned ({})", cleanable_info.orphaned_sessions);
2811                submenu_items.push(ContextMenuItem::action(
2812                    label,
2813                    ContextMenuAction::CleanOrphaned,
2814                ));
2815            }
2816
2817            // US-003: Add Data option when specs or runs exist
2818            let data_count = cleanable_info.cleanable_specs + cleanable_info.cleanable_runs;
2819            if data_count > 0 {
2820                let label = format!("Data ({})", data_count);
2821                submenu_items.push(ContextMenuItem::action(label, ContextMenuAction::CleanData));
2822            }
2823
2824            ContextMenuItem::submenu("Clean", "clean", submenu_items)
2825        };
2826
2827        vec![
2828            ContextMenuItem::action("Status", ContextMenuAction::Status),
2829            ContextMenuItem::action("Describe", ContextMenuAction::Describe),
2830            ContextMenuItem::Separator,
2831            resume_item,
2832            ContextMenuItem::Separator,
2833            clean_item,
2834            ContextMenuItem::Separator,
2835            ContextMenuItem::action("Remove Project", ContextMenuAction::RemoveProject),
2836        ]
2837    }
2838
2839    /// Returns the currently selected project name.
2840    pub fn selected_project(&self) -> Option<&str> {
2841        self.selected_project.as_deref()
2842    }
2843
2844    /// Toggles the selection of a project.
2845    /// If the project is already selected, it becomes deselected.
2846    /// If a different project is selected, it becomes the new selection.
2847    /// Also loads/clears run history for the selected project.
2848    pub fn toggle_project_selection(&mut self, project_name: &str) {
2849        if self.selected_project.as_deref() == Some(project_name) {
2850            // Deselect: clear selection, history, and error state
2851            self.selected_project = None;
2852            self.run_history.clear();
2853            self.run_history_loading = false;
2854            self.run_history_error = None;
2855        } else {
2856            // Select new project: update selection and load history
2857            self.selected_project = Some(project_name.to_string());
2858            self.load_run_history(project_name);
2859        }
2860    }
2861
2862    /// Load run history for a specific project.
2863    /// Populates self.run_history with archived runs, sorted newest first.
2864    /// Sets loading and error states appropriately.
2865    fn load_run_history(&mut self, project_name: &str) {
2866        self.run_history.clear();
2867        self.run_history_error = None;
2868        self.run_history_loading = true;
2869
2870        // Use shared function to load run history
2871        match load_project_run_history(project_name) {
2872            Ok(history) => {
2873                self.run_history = history;
2874            }
2875            Err(e) => {
2876                self.run_history_error = Some(format!("Failed to load run history: {}", e));
2877            }
2878        }
2879
2880        self.run_history_loading = false;
2881    }
2882
2883    /// Returns the run history for the selected project.
2884    pub fn run_history(&self) -> &[RunHistoryEntry] {
2885        &self.run_history
2886    }
2887
2888    /// Returns whether run history is currently loading.
2889    pub fn is_run_history_loading(&self) -> bool {
2890        self.run_history_loading
2891    }
2892
2893    /// Returns the run history error message, if any.
2894    pub fn run_history_error(&self) -> Option<&str> {
2895        self.run_history_error.as_deref()
2896    }
2897
2898    /// Returns whether a project is currently selected.
2899    pub fn is_project_selected(&self, project_name: &str) -> bool {
2900        self.selected_project.as_deref() == Some(project_name)
2901    }
2902
2903    // ========================================================================
2904    // Tab Management
2905    // ========================================================================
2906
2907    /// Returns all open tabs.
2908    pub fn tabs(&self) -> &[TabInfo] {
2909        &self.tabs
2910    }
2911
2912    /// Returns the currently active tab ID.
2913    pub fn active_tab_id(&self) -> &TabId {
2914        &self.active_tab_id
2915    }
2916
2917    /// Returns the number of open tabs.
2918    pub fn tab_count(&self) -> usize {
2919        self.tabs.len()
2920    }
2921
2922    /// Returns the number of closable (dynamic) tabs.
2923    pub fn closable_tab_count(&self) -> usize {
2924        self.tabs.iter().filter(|t| t.closable).count()
2925    }
2926
2927    /// Set the active tab by ID.
2928    /// Also updates the legacy current_tab field for backward compatibility.
2929    /// Tracks the previous tab for returning after closing the new tab.
2930    pub fn set_active_tab(&mut self, tab_id: TabId) {
2931        // Store current tab as previous before switching (if different)
2932        if self.active_tab_id != tab_id {
2933            self.previous_tab_id = Some(self.active_tab_id.clone());
2934        }
2935
2936        // Update legacy field for backward compatibility
2937        match &tab_id {
2938            TabId::ActiveRuns => self.current_tab = Tab::ActiveRuns,
2939            TabId::Projects => self.current_tab = Tab::Projects,
2940            TabId::Config => self.current_tab = Tab::Config,
2941            TabId::CreateSpec => self.current_tab = Tab::CreateSpec,
2942            TabId::RunDetail(_) | TabId::CommandOutput(_) => {
2943                // Dynamic tabs don't have a legacy equivalent,
2944                // but we keep the last static tab for backward compat
2945            }
2946        }
2947        self.active_tab_id = tab_id;
2948    }
2949
2950    /// Check if a tab with the given ID exists.
2951    pub fn has_tab(&self, tab_id: &TabId) -> bool {
2952        self.tabs.iter().any(|t| t.id == *tab_id)
2953    }
2954
2955    /// Open a new dynamic tab for run details.
2956    /// If a tab with this run_id already exists, switches to it instead of creating a duplicate.
2957    /// Returns true if a new tab was created, false if an existing tab was activated.
2958    pub fn open_run_detail_tab(&mut self, run_id: &str, run_label: &str) -> bool {
2959        let tab_id = TabId::RunDetail(run_id.to_string());
2960
2961        // Check if tab already exists
2962        if self.has_tab(&tab_id) {
2963            self.set_active_tab(tab_id);
2964            return false;
2965        }
2966
2967        // Create new tab
2968        let tab = TabInfo::closable(tab_id.clone(), run_label);
2969        self.tabs.push(tab);
2970        self.set_active_tab(tab_id);
2971        true
2972    }
2973
2974    /// Open a run detail tab from a RunHistoryEntry.
2975    /// Caches the run state for rendering and opens the tab.
2976    pub fn open_run_detail_from_entry(
2977        &mut self,
2978        entry: &RunHistoryEntry,
2979        run_state: Option<crate::state::RunState>,
2980    ) {
2981        let label = format!(
2982            "Run - {}",
2983            entry
2984                .started_at
2985                .with_timezone(&chrono::Local)
2986                .format("%Y-%m-%d %I:%M %p")
2987        );
2988
2989        // Cache the run state if provided
2990        if let Some(state) = run_state {
2991            self.run_detail_cache.insert(entry.run_id.clone(), state);
2992        }
2993
2994        self.open_run_detail_tab(&entry.run_id, &label);
2995    }
2996
2997    /// Open a new command output tab.
2998    /// Creates a new CommandExecution and opens a tab for it.
2999    /// Returns the CommandOutputId for the new execution (to be used for updates).
3000    pub fn open_command_output_tab(&mut self, project: &str, command: &str) -> CommandOutputId {
3001        let id = CommandOutputId::new(project, command);
3002        let cache_key = id.cache_key();
3003        let tab_id = TabId::CommandOutput(cache_key.clone());
3004        let label = id.tab_label();
3005
3006        // Create the command execution
3007        let execution = CommandExecution::new(id.clone());
3008        self.command_executions.insert(cache_key, execution);
3009
3010        // Create and activate the tab
3011        let tab = TabInfo::closable(tab_id.clone(), label);
3012        self.tabs.push(tab);
3013        self.set_active_tab(tab_id);
3014
3015        id
3016    }
3017
3018    /// Get a command execution by cache key.
3019    pub fn get_command_execution(&self, cache_key: &str) -> Option<&CommandExecution> {
3020        self.command_executions.get(cache_key)
3021    }
3022
3023    /// Get a mutable command execution by cache key.
3024    pub fn get_command_execution_mut(&mut self, cache_key: &str) -> Option<&mut CommandExecution> {
3025        self.command_executions.get_mut(cache_key)
3026    }
3027
3028    /// Update a command execution with new stdout output.
3029    pub fn add_command_stdout(&mut self, cache_key: &str, line: String) {
3030        if let Some(exec) = self.command_executions.get_mut(cache_key) {
3031            exec.add_stdout(line);
3032        }
3033    }
3034
3035    /// Update a command execution with new stderr output.
3036    pub fn add_command_stderr(&mut self, cache_key: &str, line: String) {
3037        if let Some(exec) = self.command_executions.get_mut(cache_key) {
3038            exec.add_stderr(line);
3039        }
3040    }
3041
3042    /// Mark a command execution as completed.
3043    pub fn complete_command(&mut self, cache_key: &str, exit_code: i32) {
3044        if let Some(exec) = self.command_executions.get_mut(cache_key) {
3045            exec.complete(exit_code);
3046        }
3047    }
3048
3049    /// Mark a command execution as failed.
3050    pub fn fail_command(&mut self, cache_key: &str, error_message: String) {
3051        if let Some(exec) = self.command_executions.get_mut(cache_key) {
3052            exec.fail(error_message);
3053        }
3054    }
3055
3056    /// Load status for a project by calling the data layer directly.
3057    /// Opens a new command output tab and populates it with session data.
3058    ///
3059    /// US-002: Replaces subprocess spawning with direct StateManager calls.
3060    pub fn spawn_status_command(&mut self, project_name: &str) {
3061        // Open the tab first to get the cache key
3062        let id = self.open_command_output_tab(project_name, "status");
3063        let cache_key = id.cache_key();
3064        let tx = self.command_tx.clone();
3065        let project = project_name.to_string();
3066
3067        std::thread::spawn(move || {
3068            // Call data layer directly instead of spawning subprocess
3069            match StateManager::for_project(&project) {
3070                Ok(state_manager) => {
3071                    match state_manager.list_sessions_with_status() {
3072                        Ok(sessions) => {
3073                            // Format session data as plain text
3074                            let lines = format_sessions_as_text(&sessions);
3075                            for line in lines {
3076                                let _ = tx.send(CommandMessage::Stdout {
3077                                    cache_key: cache_key.clone(),
3078                                    line,
3079                                });
3080                            }
3081                            let _ = tx.send(CommandMessage::Completed {
3082                                cache_key,
3083                                exit_code: 0,
3084                            });
3085                        }
3086                        Err(e) => {
3087                            let _ = tx.send(CommandMessage::Failed {
3088                                cache_key,
3089                                error: format!("Failed to list sessions: {}", e),
3090                            });
3091                        }
3092                    }
3093                }
3094                Err(e) => {
3095                    let _ = tx.send(CommandMessage::Failed {
3096                        cache_key,
3097                        error: format!("Failed to load project: {}", e),
3098                    });
3099                }
3100            }
3101        });
3102    }
3103
3104    /// Get project description and display in a command output tab.
3105    ///
3106    /// US-003: Calls data layer directly instead of spawning subprocess.
3107    /// Opens a new command output tab and formats ProjectDescription as plain text.
3108    pub fn spawn_describe_command(&mut self, project_name: &str) {
3109        // Open the tab first to get the cache key
3110        let id = self.open_command_output_tab(project_name, "describe");
3111        let cache_key = id.cache_key();
3112        let tx = self.command_tx.clone();
3113        let project = project_name.to_string();
3114
3115        std::thread::spawn(move || {
3116            // Call data layer directly instead of spawning subprocess
3117            match crate::config::get_project_description(&project) {
3118                Ok(Some(desc)) => {
3119                    // Format project description as plain text
3120                    let lines = format_project_description_as_text(&desc);
3121                    for line in lines {
3122                        let _ = tx.send(CommandMessage::Stdout {
3123                            cache_key: cache_key.clone(),
3124                            line,
3125                        });
3126                    }
3127                    let _ = tx.send(CommandMessage::Completed {
3128                        cache_key,
3129                        exit_code: 0,
3130                    });
3131                }
3132                Ok(None) => {
3133                    let _ = tx.send(CommandMessage::Stdout {
3134                        cache_key: cache_key.clone(),
3135                        line: format!("Project '{}' not found.", project),
3136                    });
3137                    let _ = tx.send(CommandMessage::Completed {
3138                        cache_key,
3139                        exit_code: 1,
3140                    });
3141                }
3142                Err(e) => {
3143                    let _ = tx.send(CommandMessage::Failed {
3144                        cache_key,
3145                        error: format!("Failed to get project description: {}", e),
3146                    });
3147                }
3148            }
3149        });
3150    }
3151
3152    /// Show resume session information in the output tab.
3153    ///
3154    /// US-005: Shows session info instead of spawning subprocess.
3155    /// Info includes: session ID, branch, worktree path, current state.
3156    /// Shows message with instructions on how to resume in terminal.
3157    pub fn show_resume_info(&mut self, project_name: &str, session_id: &str) {
3158        // Open the tab first to get the cache key
3159        let id = self.open_command_output_tab(project_name, "resume");
3160        let cache_key = id.cache_key();
3161        let tx = self.command_tx.clone();
3162
3163        // Look up the session info
3164        match self.get_resumable_session_by_id(project_name, session_id) {
3165            Some(session) => {
3166                // Format session info as plain text
3167                let lines = format_resume_info_as_text(&session);
3168                for line in lines {
3169                    let _ = tx.send(CommandMessage::Stdout {
3170                        cache_key: cache_key.clone(),
3171                        line,
3172                    });
3173                }
3174                let _ = tx.send(CommandMessage::Completed {
3175                    cache_key,
3176                    exit_code: 0,
3177                });
3178            }
3179            None => {
3180                let _ = tx.send(CommandMessage::Stdout {
3181                    cache_key: cache_key.clone(),
3182                    line: format!("Session '{}' not found or no longer resumable.", session_id),
3183                });
3184                let _ = tx.send(CommandMessage::Completed {
3185                    cache_key,
3186                    exit_code: 1,
3187                });
3188            }
3189        }
3190    }
3191
3192    /// Clean completed/failed sessions with worktrees by calling the data layer directly.
3193    /// Opens a new command output tab and populates it with cleanup results.
3194    ///
3195    /// US-004: Replaces subprocess spawning with direct clean_worktrees_direct() call.
3196    /// Note: The clean operation respects safety filters - only Completed/Failed/Interrupted
3197    /// sessions are cleaned, not Running/InProgress ones.
3198    pub fn spawn_clean_worktrees_command(&mut self, project_name: &str) {
3199        // Open the tab first to get the cache key
3200        let id = self.open_command_output_tab(project_name, "clean-worktrees");
3201        let cache_key = id.cache_key();
3202        let tx = self.command_tx.clone();
3203        let project = project_name.to_string();
3204
3205        std::thread::spawn(move || {
3206            use crate::commands::{clean_worktrees_direct, DirectCleanOptions};
3207
3208            // Call data layer directly instead of spawning subprocess
3209            let options = DirectCleanOptions {
3210                worktrees: true,
3211                force: false,
3212            };
3213
3214            match clean_worktrees_direct(&project, options) {
3215                Ok(summary) => {
3216                    // Format cleanup summary as plain text
3217                    let lines = format_cleanup_summary_as_text(&summary, "Clean Worktrees");
3218                    for line in lines {
3219                        let _ = tx.send(CommandMessage::Stdout {
3220                            cache_key: cache_key.clone(),
3221                            line,
3222                        });
3223                    }
3224                    let exit_code = if summary.errors.is_empty() { 0 } else { 1 };
3225                    let _ = tx.send(CommandMessage::Completed {
3226                        cache_key,
3227                        exit_code,
3228                    });
3229
3230                    // US-007: Send cleanup result for modal display
3231                    let _ = tx.send(CommandMessage::CleanupCompleted {
3232                        result: CleanupResult::Worktrees {
3233                            project_name: project,
3234                            worktrees_removed: summary.worktrees_removed,
3235                            sessions_removed: summary.sessions_removed,
3236                            bytes_freed: summary.bytes_freed,
3237                            skipped_count: summary.sessions_skipped.len(),
3238                            error_count: summary.errors.len(),
3239                        },
3240                    });
3241                }
3242                Err(e) => {
3243                    let _ = tx.send(CommandMessage::Failed {
3244                        cache_key,
3245                        error: format!("Failed to clean sessions: {}", e),
3246                    });
3247                }
3248            }
3249        });
3250    }
3251
3252    /// Clean orphaned sessions by calling the data layer directly.
3253    /// Orphaned sessions are those where the worktree has been deleted but the
3254    /// session state remains.
3255    /// Opens a new command output tab and populates it with cleanup results.
3256    ///
3257    /// US-004: Replaces subprocess spawning with direct clean_orphaned_direct() call.
3258    pub fn spawn_clean_orphaned_command(&mut self, project_name: &str) {
3259        // Open the tab first to get the cache key
3260        let id = self.open_command_output_tab(project_name, "clean-orphaned");
3261        let cache_key = id.cache_key();
3262        let tx = self.command_tx.clone();
3263        let project = project_name.to_string();
3264
3265        std::thread::spawn(move || {
3266            use crate::commands::clean_orphaned_direct;
3267
3268            // Call data layer directly instead of spawning subprocess
3269            match clean_orphaned_direct(&project) {
3270                Ok(summary) => {
3271                    // Format cleanup summary as plain text
3272                    let lines = format_cleanup_summary_as_text(&summary, "Clean Orphaned");
3273                    for line in lines {
3274                        let _ = tx.send(CommandMessage::Stdout {
3275                            cache_key: cache_key.clone(),
3276                            line,
3277                        });
3278                    }
3279                    let exit_code = if summary.errors.is_empty() { 0 } else { 1 };
3280                    let _ = tx.send(CommandMessage::Completed {
3281                        cache_key,
3282                        exit_code,
3283                    });
3284
3285                    // US-007: Send cleanup result for modal display
3286                    let _ = tx.send(CommandMessage::CleanupCompleted {
3287                        result: CleanupResult::Orphaned {
3288                            project_name: project,
3289                            sessions_removed: summary.sessions_removed,
3290                            bytes_freed: summary.bytes_freed,
3291                            error_count: summary.errors.len(),
3292                        },
3293                    });
3294                }
3295                Err(e) => {
3296                    let _ = tx.send(CommandMessage::Failed {
3297                        cache_key,
3298                        error: format!("Failed to clean orphaned sessions: {}", e),
3299                    });
3300                }
3301            }
3302        });
3303    }
3304
3305    /// Clean data (specs and archived runs) for a project by calling the data layer directly.
3306    /// Opens a new command output tab and populates it with cleanup results.
3307    ///
3308    /// US-003: Implements the clean data action for specs and archived runs.
3309    pub fn spawn_clean_data_command(&mut self, project_name: &str) {
3310        // Open the tab first to get the cache key
3311        let id = self.open_command_output_tab(project_name, "clean-data");
3312        let cache_key = id.cache_key();
3313        let tx = self.command_tx.clone();
3314        let project = project_name.to_string();
3315
3316        std::thread::spawn(move || {
3317            use crate::commands::clean_data_direct;
3318
3319            // Call data layer directly instead of spawning subprocess
3320            match clean_data_direct(&project) {
3321                Ok(summary) => {
3322                    // Format cleanup summary as plain text
3323                    let lines = format_data_cleanup_summary_as_text(&summary);
3324                    for line in lines {
3325                        let _ = tx.send(CommandMessage::Stdout {
3326                            cache_key: cache_key.clone(),
3327                            line,
3328                        });
3329                    }
3330                    let exit_code = if summary.errors.is_empty() { 0 } else { 1 };
3331                    let _ = tx.send(CommandMessage::Completed {
3332                        cache_key,
3333                        exit_code,
3334                    });
3335
3336                    // US-007: Send cleanup result for modal display
3337                    let _ = tx.send(CommandMessage::CleanupCompleted {
3338                        result: CleanupResult::Data {
3339                            project_name: project,
3340                            specs_removed: summary.specs_removed,
3341                            runs_removed: summary.runs_removed,
3342                            bytes_freed: summary.bytes_freed,
3343                            error_count: summary.errors.len(),
3344                        },
3345                    });
3346                }
3347                Err(e) => {
3348                    let _ = tx.send(CommandMessage::Failed {
3349                        cache_key,
3350                        error: format!("Failed to clean data: {}", e),
3351                    });
3352                }
3353            }
3354        });
3355    }
3356
3357    /// Remove a project from autom8 entirely by calling the data layer directly.
3358    /// This removes all worktrees (except active runs), session state, specs, and project configuration.
3359    /// Opens a new command output tab and populates it with removal results.
3360    ///
3361    /// US-004: Implements the actual removal logic using remove_project_direct().
3362    pub fn spawn_remove_project_command(&mut self, project_name: &str) {
3363        // Open the tab first to get the cache key
3364        let id = self.open_command_output_tab(project_name, "remove-project");
3365        let cache_key = id.cache_key();
3366        let tx = self.command_tx.clone();
3367        let project = project_name.to_string();
3368
3369        std::thread::spawn(move || {
3370            use crate::commands::remove_project_direct;
3371
3372            match remove_project_direct(&project) {
3373                Ok(summary) => {
3374                    // Format removal summary as plain text
3375                    let lines = format_removal_summary_as_text(&summary, &project);
3376                    for line in lines {
3377                        let _ = tx.send(CommandMessage::Stdout {
3378                            cache_key: cache_key.clone(),
3379                            line,
3380                        });
3381                    }
3382                    let exit_code = if summary.errors.is_empty() { 0 } else { 1 };
3383                    let _ = tx.send(CommandMessage::Completed {
3384                        cache_key: cache_key.clone(),
3385                        exit_code,
3386                    });
3387
3388                    // US-005: Remove project from sidebar after successful removal.
3389                    // Only remove if config was deleted (project fully removed).
3390                    // If removal fails entirely, keep project in sidebar.
3391                    if summary.config_deleted {
3392                        let _ = tx.send(CommandMessage::ProjectRemoved {
3393                            project_name: project.clone(),
3394                        });
3395                    }
3396
3397                    // US-007: Send cleanup result for modal display
3398                    let _ = tx.send(CommandMessage::CleanupCompleted {
3399                        result: CleanupResult::RemoveProject {
3400                            project_name: project,
3401                            worktrees_removed: summary.worktrees_removed,
3402                            config_deleted: summary.config_deleted,
3403                            bytes_freed: summary.bytes_freed,
3404                            skipped_count: summary.worktrees_skipped.len(),
3405                            error_count: summary.errors.len(),
3406                        },
3407                    });
3408                }
3409                Err(e) => {
3410                    let _ = tx.send(CommandMessage::Failed {
3411                        cache_key,
3412                        error: format!("Failed to remove project: {}", e),
3413                    });
3414                }
3415            }
3416        });
3417    }
3418
3419    /// Poll for command execution messages and update state.
3420    /// This should be called in the update loop to process messages from background threads.
3421    fn poll_command_messages(&mut self) {
3422        // Process all pending messages (non-blocking)
3423        while let Ok(msg) = self.command_rx.try_recv() {
3424            match msg {
3425                CommandMessage::Stdout { cache_key, line } => {
3426                    self.add_command_stdout(&cache_key, line);
3427                }
3428                CommandMessage::Stderr { cache_key, line } => {
3429                    self.add_command_stderr(&cache_key, line);
3430                }
3431                CommandMessage::Completed {
3432                    cache_key,
3433                    exit_code,
3434                } => {
3435                    self.complete_command(&cache_key, exit_code);
3436                }
3437                CommandMessage::Failed { cache_key, error } => {
3438                    self.fail_command(&cache_key, error);
3439                }
3440                CommandMessage::ProjectRemoved { project_name } => {
3441                    // US-005: Remove project from sidebar after successful removal.
3442                    self.remove_project_from_sidebar(&project_name);
3443                }
3444                CommandMessage::CleanupCompleted { result } => {
3445                    // US-007: Show result modal after cleanup operation completes.
3446                    self.pending_result_modal = Some(result);
3447                    // US-005: Refresh data immediately after cleanup to update UI
3448                    // (e.g., cleanable counts in menu).
3449                    self.refresh_data();
3450                }
3451            }
3452        }
3453    }
3454
3455    /// Remove a project from the sidebar projects list.
3456    ///
3457    /// US-005: Called after a project is successfully removed via remove_project_direct().
3458    /// Removes the project from the in-memory list so it disappears from the sidebar.
3459    fn remove_project_from_sidebar(&mut self, project_name: &str) {
3460        self.projects.retain(|p| p.info.name != project_name);
3461    }
3462
3463    /// Close a tab by ID.
3464    /// Returns true if the tab was closed, false if the tab doesn't exist or is not closable.
3465    /// If the closed tab was active, switches to the previous tab (if available and still open),
3466    /// otherwise falls back to adjacent tab logic or Projects tab.
3467    pub fn close_tab(&mut self, tab_id: &TabId) -> bool {
3468        // Find the tab index
3469        let tab_index = match self.tabs.iter().position(|t| t.id == *tab_id) {
3470            Some(idx) => idx,
3471            None => return false,
3472        };
3473
3474        // Check if the tab is closable
3475        if !self.tabs[tab_index].closable {
3476            return false;
3477        }
3478
3479        // Check if this is the active tab
3480        let was_active = self.active_tab_id == *tab_id;
3481
3482        // Clear previous_tab_id if the previous tab itself is being closed
3483        if self.previous_tab_id.as_ref() == Some(tab_id) {
3484            self.previous_tab_id = None;
3485        }
3486
3487        // Remove the tab
3488        self.tabs.remove(tab_index);
3489
3490        // Clean up cached run state if it's a run detail tab
3491        if let TabId::RunDetail(run_id) = tab_id {
3492            self.run_detail_cache.remove(run_id);
3493        }
3494
3495        // Clean up command execution state if it's a command output tab
3496        if let TabId::CommandOutput(cache_key) = tab_id {
3497            self.command_executions.remove(cache_key);
3498        }
3499
3500        // If the closed tab was active, switch to another tab
3501        if was_active {
3502            // Try to switch to the previous tab (if it exists and is still open)
3503            if let Some(prev_id) = self.previous_tab_id.take() {
3504                if self.has_tab(&prev_id) {
3505                    self.set_active_tab(prev_id);
3506                    return true;
3507                }
3508            }
3509
3510            // Fall back to adjacent tab logic
3511            if tab_index > 0 && tab_index <= self.tabs.len() {
3512                self.set_active_tab(self.tabs[tab_index - 1].id.clone());
3513            } else if !self.tabs.is_empty() {
3514                // Fall back to Projects tab
3515                self.set_active_tab(TabId::Projects);
3516            }
3517        }
3518
3519        true
3520    }
3521
3522    /// Close all closable (dynamic) tabs.
3523    /// Returns the number of tabs closed.
3524    pub fn close_all_dynamic_tabs(&mut self) -> usize {
3525        let to_close: Vec<TabId> = self
3526            .tabs
3527            .iter()
3528            .filter(|t| t.closable)
3529            .map(|t| t.id.clone())
3530            .collect();
3531
3532        let count = to_close.len();
3533        for tab_id in to_close {
3534            self.close_tab(&tab_id);
3535        }
3536        count
3537    }
3538
3539    /// Get cached run state for a run detail tab.
3540    pub fn get_cached_run_state(&self, run_id: &str) -> Option<&crate::state::RunState> {
3541        self.run_detail_cache.get(run_id)
3542    }
3543
3544    // ========================================================================
3545    // Data Loading
3546    // ========================================================================
3547
3548    /// Refresh data from disk if the refresh interval has elapsed.
3549    ///
3550    /// This method is called on every frame and only performs actual
3551    /// file I/O when the refresh interval has passed.
3552    pub fn maybe_refresh(&mut self) {
3553        if self.last_refresh.elapsed() >= self.refresh_interval {
3554            self.refresh_data();
3555        }
3556    }
3557
3558    /// Refresh all data from disk.
3559    ///
3560    /// Loads project and session data, handling errors gracefully.
3561    /// Missing or corrupted files are captured as `load_error` strings
3562    /// rather than causing failures.
3563    pub fn refresh_data(&mut self) {
3564        self.last_refresh = Instant::now();
3565
3566        // Use shared data loading function (swallow errors, use defaults)
3567        // No project filter - always show all projects
3568        let ui_data = load_ui_data(None).unwrap_or_default();
3569
3570        self.projects = ui_data.projects;
3571        self.sessions = ui_data.sessions;
3572        self.has_active_runs = ui_data.has_active_runs;
3573
3574        // Build set of currently running session IDs
3575        let current_ids: std::collections::HashSet<&str> = self
3576            .sessions
3577            .iter()
3578            .map(|s| s.metadata.session_id.as_str())
3579            .collect();
3580
3581        // Cache all running sessions we see (so tabs persist after completion)
3582        for session in &self.sessions {
3583            let session_id = &session.metadata.session_id;
3584            if !self.closed_session_tabs.contains(session_id) {
3585                self.seen_sessions
3586                    .insert(session_id.clone(), session.clone());
3587            }
3588        }
3589
3590        // Reload sessions that are no longer running (to get final state)
3591        // These are sessions in seen_sessions that are not in current running sessions
3592        let to_reload: Vec<(String, String)> = self
3593            .seen_sessions
3594            .iter()
3595            .filter(|(id, _)| !current_ids.contains(id.as_str()))
3596            .filter(|(id, _)| !self.closed_session_tabs.contains(*id))
3597            .map(|(id, s)| (s.project_name.clone(), id.clone()))
3598            .collect();
3599
3600        for (project_name, session_id) in to_reload {
3601            if let Some(updated) = load_session_by_id(&project_name, &session_id) {
3602                // Session still exists, update with current state
3603                self.seen_sessions.insert(session_id, updated);
3604            } else {
3605                // Session files deleted - check archives for final state
3606                if let Some(existing) = self.seen_sessions.get(&session_id).cloned() {
3607                    if let Some(ref run) = existing.run {
3608                        if let Some(archived_run) =
3609                            crate::ui::shared::load_archived_run(&project_name, &run.run_id)
3610                        {
3611                            // Update the session with archived final state
3612                            let mut updated = existing;
3613                            updated.run = Some(archived_run);
3614                            updated.metadata.is_running = false;
3615                            self.seen_sessions.insert(session_id, updated);
3616                        }
3617                    }
3618                }
3619            }
3620        }
3621
3622        // Refresh run history for the currently selected project
3623        if let Some(ref project) = self.selected_project {
3624            let project_name = project.clone();
3625            self.load_run_history(&project_name);
3626        }
3627    }
3628
3629    /// Get all visible sessions (sessions seen during this GUI lifetime, not closed).
3630    fn get_visible_sessions(&self) -> Vec<SessionData> {
3631        self.seen_sessions
3632            .values()
3633            .filter(|s| !self.closed_session_tabs.contains(&s.metadata.session_id))
3634            .cloned()
3635            .collect()
3636    }
3637
3638    /// Find a session by ID from seen sessions.
3639    fn find_session_by_id(&self, session_id: &str) -> Option<SessionData> {
3640        self.seen_sessions.get(session_id).cloned()
3641    }
3642}
3643
3644impl eframe::App for Autom8App {
3645    fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
3646        // Refresh data from disk if interval has elapsed
3647        self.maybe_refresh();
3648
3649        // Poll for command execution messages from background threads
3650        self.poll_command_messages();
3651
3652        // Poll for Claude messages from background thread (US-005)
3653        self.poll_claude_messages();
3654
3655        // Request repaint at refresh interval to ensure timely updates
3656        ctx.request_repaint_after(self.refresh_interval);
3657
3658        // Custom title bar area (provides draggable area for window)
3659        self.render_title_bar(ctx);
3660
3661        // Sidebar navigation (replaces horizontal tab bar - US-003)
3662        // Sidebar can be collapsed via toggle button in title bar (US-004)
3663        let sidebar_width = if self.sidebar_collapsed {
3664            SIDEBAR_COLLAPSED_WIDTH
3665        } else {
3666            SIDEBAR_WIDTH
3667        };
3668
3669        // Only show sidebar panel when not collapsed
3670        // When collapsed (width=0), the content area expands to fill the space
3671        if !self.sidebar_collapsed {
3672            egui::SidePanel::left("sidebar")
3673                .exact_width(sidebar_width)
3674                .resizable(false)
3675                .frame(
3676                    egui::Frame::none()
3677                        .fill(colors::BACKGROUND)
3678                        .inner_margin(egui::Margin {
3679                            left: spacing::MD,
3680                            right: spacing::MD,
3681                            top: spacing::LG,
3682                            bottom: spacing::LG,
3683                        })
3684                        .stroke(Stroke::new(1.0, colors::SEPARATOR)),
3685                )
3686                .show(ctx, |ui| {
3687                    self.render_sidebar(ui);
3688                });
3689        }
3690
3691        // Content area fills remaining space
3692        egui::CentralPanel::default()
3693            .frame(
3694                egui::Frame::none()
3695                    .fill(colors::BACKGROUND)
3696                    .inner_margin(egui::Margin::same(spacing::LG)),
3697            )
3698            .show(ctx, |ui| {
3699                self.render_content(ui);
3700            });
3701
3702        // Handle global keyboard shortcuts for context menu
3703        if self.context_menu.is_some() {
3704            // Close context menu on Escape key
3705            if ctx.input(|i| i.key_pressed(Key::Escape)) {
3706                self.close_context_menu();
3707            }
3708        }
3709
3710        // Render context menu overlay (must be after content to appear on top)
3711        self.render_context_menu(ctx);
3712
3713        // Render confirmation dialog if pending (must be after context menu to appear on top)
3714        self.render_confirmation_dialog(ctx);
3715
3716        // Render result modal if cleanup operation completed (US-007)
3717        self.render_result_modal(ctx);
3718
3719        // US-008: Render project change confirmation modal
3720        self.render_project_change_confirmation(ctx);
3721
3722        // Render start new spec confirmation modal
3723        self.render_start_new_spec_confirmation(ctx);
3724    }
3725}
3726
3727// ============================================================================
3728// Drop Implementation (US-008: Clean subprocess termination)
3729// ============================================================================
3730
3731/// Implement Drop to ensure Claude subprocess is terminated when GUI closes.
3732///
3733/// US-008: Closing GUI terminates any active Claude subprocess cleanly.
3734/// This prevents orphaned Claude processes from lingering after the GUI exits.
3735impl Drop for Autom8App {
3736    fn drop(&mut self) {
3737        // Kill any running Claude subprocess
3738        if let Ok(mut guard) = self.claude_child.lock() {
3739            if let Some(mut child) = guard.take() {
3740                let _ = child.kill();
3741                let _ = child.wait();
3742            }
3743        }
3744        // Also close stdin handle if it exists (legacy cleanup)
3745        if let Some(ref stdin_handle) = self.claude_stdin {
3746            stdin_handle.close();
3747        }
3748    }
3749}
3750
3751impl Autom8App {
3752    // ========================================================================
3753    // Title Bar (Custom Title Bar - US-002)
3754    // ========================================================================
3755
3756    /// Render the custom title bar area.
3757    ///
3758    /// This creates a panel at the top of the window that:
3759    /// - Uses the app's background color for seamless visual integration
3760    /// - Provides a draggable area for window movement
3761    /// - Contains the sidebar toggle button
3762    fn render_title_bar(&mut self, ctx: &egui::Context) {
3763        egui::TopBottomPanel::top("title_bar")
3764            .exact_height(TITLE_BAR_HEIGHT)
3765            .frame(
3766                egui::Frame::none()
3767                    .fill(colors::SURFACE)
3768                    .inner_margin(egui::Margin::ZERO),
3769            )
3770            .show(ctx, |ui| {
3771                // Make the entire title bar area draggable for window movement
3772                let title_bar_rect = ui.max_rect();
3773                let response = ui.interact(
3774                    title_bar_rect,
3775                    ui.id().with("title_bar_drag"),
3776                    Sense::click_and_drag(),
3777                );
3778
3779                // Enable window dragging when the title bar is dragged
3780                if response.drag_started() {
3781                    ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag);
3782                }
3783
3784                // Support double-click to maximize/restore
3785                if response.double_clicked() {
3786                    ui.ctx().send_viewport_cmd(egui::ViewportCommand::Maximized(
3787                        !ui.ctx().input(|i| i.viewport().maximized.unwrap_or(false)),
3788                    ));
3789                }
3790
3791                // Position content to align with window control buttons (fixed offset from top)
3792                ui.add_space(5.0);
3793                ui.horizontal(|ui| {
3794                    // Left offset for title bar content
3795                    ui.add_space(TITLE_BAR_LEFT_OFFSET);
3796
3797                    // Vertical separator between window controls and toggle button
3798                    let separator_height = SIDEBAR_TOGGLE_SIZE;
3799                    let (separator_rect, _) =
3800                        ui.allocate_exact_size(egui::vec2(1.0, separator_height), Sense::hover());
3801                    ui.painter().vline(
3802                        separator_rect.center().x,
3803                        separator_rect.y_range(),
3804                        Stroke::new(1.0, colors::SEPARATOR),
3805                    );
3806
3807                    // Add some padding before the toggle button
3808                    ui.add_space(SIDEBAR_TOGGLE_PADDING);
3809
3810                    // Sidebar toggle button
3811                    let toggle_response =
3812                        self.render_sidebar_toggle_button(ui, self.sidebar_collapsed);
3813                    if toggle_response.clicked() {
3814                        self.sidebar_collapsed = !self.sidebar_collapsed;
3815                    }
3816                });
3817            });
3818    }
3819
3820    // ========================================================================
3821    // Context Menu Rendering (Right-Click Context Menu - US-002)
3822    // ========================================================================
3823
3824    /// Render the context menu overlay.
3825    ///
3826    /// This method renders the context menu as a floating panel at the stored position.
3827    /// The menu is rendered on top of all other content using `Order::Foreground`.
3828    /// Handles click-outside-to-close and menu item interactions.
3829    /// Also handles submenu rendering for items like Resume and Clean (US-005, US-006).
3830    fn render_context_menu(&mut self, ctx: &egui::Context) {
3831        // Early return if no context menu is open
3832        let menu_state = match &self.context_menu {
3833            Some(state) => state.clone(),
3834            None => return,
3835        };
3836
3837        // Get screen rect for bounds checking
3838        let screen_rect = ctx.screen_rect();
3839
3840        // Calculate menu dimensions (US-001: Dynamic width based on content)
3841        let menu_width = calculate_context_menu_width(ctx, &menu_state.items);
3842        let item_count = menu_state
3843            .items
3844            .iter()
3845            .filter(|item| !matches!(item, ContextMenuItem::Separator))
3846            .count();
3847        let separator_count = menu_state
3848            .items
3849            .iter()
3850            .filter(|item| matches!(item, ContextMenuItem::Separator))
3851            .count();
3852        let menu_height = (item_count as f32 * CONTEXT_MENU_ITEM_HEIGHT)
3853            + (separator_count as f32 * (spacing::SM + 1.0))
3854            + (CONTEXT_MENU_PADDING_V * 2.0);
3855
3856        // Constrain menu position within window bounds
3857        let mut menu_pos = menu_state.position;
3858        menu_pos.x += CONTEXT_MENU_CURSOR_OFFSET;
3859        menu_pos.y += CONTEXT_MENU_CURSOR_OFFSET;
3860
3861        // Ensure menu doesn't go off the right edge
3862        if menu_pos.x + menu_width > screen_rect.max.x - spacing::SM {
3863            menu_pos.x = screen_rect.max.x - menu_width - spacing::SM;
3864        }
3865
3866        // Ensure menu doesn't go off the bottom edge
3867        if menu_pos.y + menu_height > screen_rect.max.y - spacing::SM {
3868            menu_pos.y = screen_rect.max.y - menu_height - spacing::SM;
3869        }
3870
3871        // Ensure menu doesn't go off the left or top edge
3872        menu_pos.x = menu_pos.x.max(spacing::SM);
3873        menu_pos.y = menu_pos.y.max(spacing::SM);
3874
3875        // Track if we should close the menu
3876        let mut should_close = false;
3877        let mut selected_action: Option<ContextMenuAction> = None;
3878
3879        // Track submenu hover state: (submenu_id, items, trigger_rect)
3880        let mut hovered_submenu: Option<(String, Vec<ContextMenuItem>, Rect)> = None;
3881
3882        // Check for click outside the menu
3883        let pointer_pos = ctx.input(|i| i.pointer.hover_pos());
3884        let primary_clicked = ctx.input(|i| i.pointer.primary_clicked());
3885
3886        // Render the main menu using an Area overlay
3887        egui::Area::new(egui::Id::new("context_menu"))
3888            .order(Order::Foreground)
3889            .fixed_pos(menu_pos)
3890            .show(ctx, |ui| {
3891                egui::Frame::none()
3892                    .fill(colors::SURFACE)
3893                    .rounding(Rounding::same(rounding::CARD))
3894                    .shadow(crate::ui::gui::theme::shadow::elevated())
3895                    .stroke(Stroke::new(1.0, colors::BORDER))
3896                    .inner_margin(egui::Margin::symmetric(0.0, CONTEXT_MENU_PADDING_V))
3897                    .show(ui, |ui| {
3898                        ui.set_min_width(menu_width);
3899                        ui.set_max_width(menu_width);
3900
3901                        for item in &menu_state.items {
3902                            match item {
3903                                ContextMenuItem::Action {
3904                                    label,
3905                                    action,
3906                                    enabled,
3907                                } => {
3908                                    let response =
3909                                        self.render_context_menu_item(ui, label, *enabled, false);
3910                                    if response.clicked {
3911                                        selected_action = Some(action.clone());
3912                                        should_close = true;
3913                                    }
3914                                }
3915                                ContextMenuItem::Separator => {
3916                                    ui.add_space(spacing::XS);
3917                                    let rect = ui.available_rect_before_wrap();
3918                                    let separator_rect =
3919                                        Rect::from_min_size(rect.min, Vec2::new(menu_width, 1.0));
3920                                    ui.painter().rect_filled(
3921                                        separator_rect,
3922                                        Rounding::ZERO,
3923                                        colors::SEPARATOR,
3924                                    );
3925                                    ui.allocate_space(Vec2::new(menu_width, 1.0));
3926                                    ui.add_space(spacing::XS);
3927                                }
3928                                ContextMenuItem::Submenu {
3929                                    label,
3930                                    id,
3931                                    enabled,
3932                                    items,
3933                                    hint,
3934                                } => {
3935                                    // Render submenu trigger with arrow indicator
3936                                    let response =
3937                                        self.render_context_menu_item(ui, label, *enabled, true);
3938                                    if response.hovered && *enabled && !items.is_empty() {
3939                                        // Track this submenu as hovered for rendering
3940                                        hovered_submenu =
3941                                            Some((id.clone(), items.clone(), response.rect));
3942                                    }
3943                                    // US-006: Show tooltip when hovering a disabled submenu with a hint
3944                                    if response.hovered_raw && !*enabled {
3945                                        if let Some(hint_text) = hint {
3946                                            egui::show_tooltip_at_pointer(
3947                                                ui.ctx(),
3948                                                ui.layer_id(),
3949                                                egui::Id::new("submenu_hint").with(id),
3950                                                |ui| {
3951                                                    ui.label(hint_text);
3952                                                },
3953                                            );
3954                                        }
3955                                    }
3956                                }
3957                            }
3958                        }
3959                    });
3960            });
3961
3962        // Calculate the main menu rect for click-outside detection
3963        let menu_rect = Rect::from_min_size(menu_pos, Vec2::new(menu_width, menu_height));
3964
3965        // Track submenu rect for click-outside detection
3966        let mut submenu_rect: Option<Rect> = None;
3967
3968        // Render submenu if one is hovered or already open
3969        // Priority: currently hovered submenu > previously open submenu
3970        let submenu_to_render = if let Some((id, items, trigger_rect)) = hovered_submenu {
3971            // Update the open_submenu state with the hovered submenu
3972            if let Some(menu) = &mut self.context_menu {
3973                let submenu_pos = Pos2::new(
3974                    menu_pos.x + menu_width + CONTEXT_MENU_SUBMENU_GAP,
3975                    trigger_rect.min.y,
3976                );
3977                menu.open_submenu(id.clone(), submenu_pos);
3978            }
3979            Some((items, trigger_rect))
3980        } else if let (Some(open_id), Some(open_pos)) =
3981            (&menu_state.open_submenu, menu_state.submenu_position)
3982        {
3983            // Find the items for the currently open submenu
3984            let items = menu_state.items.iter().find_map(|item| {
3985                if let ContextMenuItem::Submenu { id, items, .. } = item {
3986                    if id == open_id {
3987                        return Some(items.clone());
3988                    }
3989                }
3990                None
3991            });
3992            // Find the trigger rect (approximate from stored position)
3993            let trigger_rect = Rect::from_min_size(
3994                Pos2::new(menu_pos.x, open_pos.y),
3995                Vec2::new(menu_width, CONTEXT_MENU_ITEM_HEIGHT),
3996            );
3997            items.map(|i| (i, trigger_rect))
3998        } else {
3999            // No submenu to render, close any open submenu
4000            if let Some(menu) = &mut self.context_menu {
4001                menu.close_submenu();
4002            }
4003            None
4004        };
4005
4006        // Render the submenu if we have one
4007        if let Some((submenu_items, trigger_rect)) = submenu_to_render {
4008            if !submenu_items.is_empty() {
4009                // Calculate submenu dimensions (US-001: Dynamic width for submenus too)
4010                let submenu_width = calculate_context_menu_width(ctx, &submenu_items);
4011                let submenu_item_count = submenu_items
4012                    .iter()
4013                    .filter(|item| !matches!(item, ContextMenuItem::Separator))
4014                    .count();
4015                let submenu_separator_count = submenu_items
4016                    .iter()
4017                    .filter(|item| matches!(item, ContextMenuItem::Separator))
4018                    .count();
4019                let submenu_height = (submenu_item_count as f32 * CONTEXT_MENU_ITEM_HEIGHT)
4020                    + (submenu_separator_count as f32 * (spacing::SM + 1.0))
4021                    + (CONTEXT_MENU_PADDING_V * 2.0);
4022
4023                // Position submenu to the right of the main menu
4024                let mut submenu_pos = Pos2::new(
4025                    menu_pos.x + menu_width + CONTEXT_MENU_SUBMENU_GAP,
4026                    trigger_rect.min.y - CONTEXT_MENU_PADDING_V,
4027                );
4028
4029                // Ensure submenu doesn't go off the right edge
4030                if submenu_pos.x + submenu_width > screen_rect.max.x - spacing::SM {
4031                    // Position to the left of the main menu instead
4032                    submenu_pos.x = menu_pos.x - submenu_width - CONTEXT_MENU_SUBMENU_GAP;
4033                }
4034
4035                // Ensure submenu doesn't go off the bottom edge
4036                if submenu_pos.y + submenu_height > screen_rect.max.y - spacing::SM {
4037                    submenu_pos.y = screen_rect.max.y - submenu_height - spacing::SM;
4038                }
4039
4040                // Ensure submenu doesn't go off the top edge
4041                submenu_pos.y = submenu_pos.y.max(spacing::SM);
4042
4043                // Store submenu rect for click-outside detection
4044                submenu_rect = Some(Rect::from_min_size(
4045                    submenu_pos,
4046                    Vec2::new(submenu_width, submenu_height),
4047                ));
4048
4049                // Render the submenu
4050                egui::Area::new(egui::Id::new("context_submenu"))
4051                    .order(Order::Foreground)
4052                    .fixed_pos(submenu_pos)
4053                    .show(ctx, |ui| {
4054                        egui::Frame::none()
4055                            .fill(colors::SURFACE)
4056                            .rounding(Rounding::same(rounding::CARD))
4057                            .shadow(crate::ui::gui::theme::shadow::elevated())
4058                            .stroke(Stroke::new(1.0, colors::BORDER))
4059                            .inner_margin(egui::Margin::symmetric(0.0, CONTEXT_MENU_PADDING_V))
4060                            .show(ui, |ui| {
4061                                ui.set_min_width(submenu_width);
4062                                ui.set_max_width(submenu_width);
4063
4064                                for item in &submenu_items {
4065                                    match item {
4066                                        ContextMenuItem::Action {
4067                                            label,
4068                                            action,
4069                                            enabled,
4070                                        } => {
4071                                            let response = self.render_context_menu_item(
4072                                                ui, label, *enabled, false,
4073                                            );
4074                                            if response.clicked {
4075                                                selected_action = Some(action.clone());
4076                                                should_close = true;
4077                                            }
4078                                        }
4079                                        ContextMenuItem::Separator => {
4080                                            ui.add_space(spacing::XS);
4081                                            let rect = ui.available_rect_before_wrap();
4082                                            let separator_rect = Rect::from_min_size(
4083                                                rect.min,
4084                                                Vec2::new(submenu_width, 1.0),
4085                                            );
4086                                            ui.painter().rect_filled(
4087                                                separator_rect,
4088                                                Rounding::ZERO,
4089                                                colors::SEPARATOR,
4090                                            );
4091                                            ui.allocate_space(Vec2::new(submenu_width, 1.0));
4092                                            ui.add_space(spacing::XS);
4093                                        }
4094                                        ContextMenuItem::Submenu { .. } => {
4095                                            // Nested submenus not supported (not needed for current use cases)
4096                                        }
4097                                    }
4098                                }
4099                            });
4100                    });
4101            }
4102        }
4103
4104        // Check if click was outside both the menu and submenu areas
4105        if primary_clicked {
4106            if let Some(pos) = pointer_pos {
4107                let in_menu = menu_rect.contains(pos);
4108                let in_submenu = submenu_rect.map(|r| r.contains(pos)).unwrap_or(false);
4109                if !in_menu && !in_submenu {
4110                    should_close = true;
4111                }
4112            }
4113        }
4114
4115        // Handle the selected action
4116        if let Some(action) = selected_action {
4117            let project_name = menu_state.project_name.clone();
4118            match action {
4119                ContextMenuAction::Status => {
4120                    // Spawn the status command (US-003)
4121                    self.spawn_status_command(&project_name);
4122                }
4123                ContextMenuAction::Describe => {
4124                    // Spawn the describe command (US-004)
4125                    self.spawn_describe_command(&project_name);
4126                }
4127                ContextMenuAction::Resume(session_id) => {
4128                    // US-005: Show session info in output tab instead of spawning subprocess
4129                    if let Some(id) = session_id {
4130                        self.show_resume_info(&project_name, &id);
4131                    }
4132                    // If session_id is None, the menu item should have been disabled,
4133                    // so this case shouldn't happen in normal operation
4134                }
4135                ContextMenuAction::CleanWorktrees => {
4136                    // US-004: Show confirmation dialog before executing clean operation
4137                    self.pending_clean_confirmation = Some(PendingCleanOperation::Worktrees {
4138                        project_name: project_name.clone(),
4139                    });
4140                }
4141                ContextMenuAction::CleanOrphaned => {
4142                    // US-004: Show confirmation dialog before executing clean operation
4143                    self.pending_clean_confirmation = Some(PendingCleanOperation::Orphaned {
4144                        project_name: project_name.clone(),
4145                    });
4146                }
4147                ContextMenuAction::CleanData => {
4148                    // US-003: Show confirmation dialog before cleaning data
4149                    let cleanable_info = self.get_cleanable_info(&project_name);
4150                    self.pending_clean_confirmation = Some(PendingCleanOperation::Data {
4151                        project_name: project_name.clone(),
4152                        specs_count: cleanable_info.cleanable_specs,
4153                        runs_count: cleanable_info.cleanable_runs,
4154                    });
4155                }
4156                ContextMenuAction::RemoveProject => {
4157                    // US-002: Show confirmation dialog before removing project (modal implemented in US-003)
4158                    self.pending_clean_confirmation = Some(PendingCleanOperation::RemoveProject {
4159                        project_name: project_name.clone(),
4160                    });
4161                }
4162            }
4163        }
4164
4165        // Close the menu if needed
4166        if should_close {
4167            self.close_context_menu();
4168        }
4169    }
4170
4171    /// Render a single context menu item.
4172    ///
4173    /// Returns a `ContextMenuItemResponse` with click/hover state and item rect.
4174    fn render_context_menu_item(
4175        &self,
4176        ui: &mut egui::Ui,
4177        label: &str,
4178        enabled: bool,
4179        has_submenu: bool,
4180    ) -> ContextMenuItemResponse {
4181        let item_size = Vec2::new(ui.available_width(), CONTEXT_MENU_ITEM_HEIGHT);
4182        let (rect, response) = ui.allocate_exact_size(item_size, Sense::click());
4183
4184        let is_hovered = response.hovered() && enabled;
4185        let painter = ui.painter();
4186
4187        // Draw hover background
4188        if is_hovered {
4189            painter.rect_filled(rect, Rounding::ZERO, colors::SURFACE_HOVER);
4190        }
4191
4192        // Calculate text position with padding
4193        let text_x = rect.min.x + CONTEXT_MENU_PADDING_H;
4194        let text_color = if enabled {
4195            colors::TEXT_PRIMARY
4196        } else {
4197            colors::TEXT_DISABLED
4198        };
4199
4200        // Draw label
4201        let galley = painter.layout_no_wrap(
4202            label.to_string(),
4203            typography::font(FontSize::Body, FontWeight::Regular),
4204            text_color,
4205        );
4206        let text_y = rect.center().y - galley.rect.height() / 2.0;
4207        painter.galley(Pos2::new(text_x, text_y), galley, Color32::TRANSPARENT);
4208
4209        // Draw submenu arrow indicator if this item has a submenu
4210        if has_submenu {
4211            let arrow_x = rect.max.x - CONTEXT_MENU_PADDING_H - CONTEXT_MENU_ARROW_SIZE;
4212            let arrow_y = rect.center().y;
4213            let arrow_color = if enabled {
4214                colors::TEXT_SECONDARY
4215            } else {
4216                colors::TEXT_DISABLED
4217            };
4218
4219            // Draw a simple right-pointing chevron
4220            let arrow_points = [
4221                Pos2::new(arrow_x, arrow_y - CONTEXT_MENU_ARROW_SIZE / 2.0),
4222                Pos2::new(arrow_x + CONTEXT_MENU_ARROW_SIZE / 2.0, arrow_y),
4223                Pos2::new(arrow_x, arrow_y + CONTEXT_MENU_ARROW_SIZE / 2.0),
4224            ];
4225            painter.line_segment(
4226                [arrow_points[0], arrow_points[1]],
4227                Stroke::new(1.5, arrow_color),
4228            );
4229            painter.line_segment(
4230                [arrow_points[1], arrow_points[2]],
4231                Stroke::new(1.5, arrow_color),
4232            );
4233        }
4234
4235        // Convert local rect to screen rect for submenu positioning
4236        let screen_rect = ui.clip_rect();
4237        let screen_item_rect = Rect::from_min_max(
4238            Pos2::new(screen_rect.min.x, rect.min.y),
4239            Pos2::new(screen_rect.min.x + ui.available_width(), rect.max.y),
4240        );
4241
4242        // Set pointer cursor for enabled items on hover
4243        if enabled && response.hovered() {
4244            ui.ctx()
4245                .output_mut(|o| o.cursor_icon = egui::CursorIcon::PointingHand);
4246        }
4247
4248        ContextMenuItemResponse {
4249            clicked: response.clicked() && enabled,
4250            hovered: is_hovered,
4251            hovered_raw: response.hovered(),
4252            rect: screen_item_rect,
4253        }
4254    }
4255
4256    // ========================================================================
4257    // Confirmation Dialog (US-004)
4258    // ========================================================================
4259
4260    /// Render the confirmation dialog overlay for clean operations.
4261    ///
4262    /// This method renders a modal dialog when `pending_clean_confirmation` is Some.
4263    /// Uses the reusable Modal component with a semi-transparent backdrop and
4264    /// Cancel/Confirm buttons.
4265    fn render_confirmation_dialog(&mut self, ctx: &egui::Context) {
4266        // Early return if no confirmation is pending
4267        let pending = match &self.pending_clean_confirmation {
4268            Some(op) => op.clone(),
4269            None => return,
4270        };
4271
4272        // Create the modal using the reusable component
4273        // US-004: Data cleanup uses "Delete" button, others use "Confirm"
4274        let modal = Modal::new(pending.title())
4275            .id("clean_confirmation")
4276            .message(pending.message())
4277            .cancel_button(ModalButton::secondary("Cancel"))
4278            .confirm_button(ModalButton::destructive(pending.confirm_button_label()));
4279
4280        // Show the modal and handle the action
4281        match modal.show(ctx) {
4282            ModalAction::Confirmed => {
4283                // Execute the clean operation
4284                let project_name = pending.project_name().to_string();
4285                match pending {
4286                    PendingCleanOperation::Worktrees { .. } => {
4287                        self.spawn_clean_worktrees_command(&project_name);
4288                    }
4289                    PendingCleanOperation::Orphaned { .. } => {
4290                        self.spawn_clean_orphaned_command(&project_name);
4291                    }
4292                    PendingCleanOperation::Data { .. } => {
4293                        // US-003: Clean specs and archived runs
4294                        self.spawn_clean_data_command(&project_name);
4295                    }
4296                    PendingCleanOperation::RemoveProject { .. } => {
4297                        // US-004: Remove project entirely (worktrees + config)
4298                        self.spawn_remove_project_command(&project_name);
4299                    }
4300                }
4301                self.pending_clean_confirmation = None;
4302            }
4303            ModalAction::Cancelled => {
4304                self.pending_clean_confirmation = None;
4305            }
4306            ModalAction::None => {
4307                // Modal is still open, do nothing
4308            }
4309        }
4310    }
4311
4312    // ========================================================================
4313    // Result Modal (US-007)
4314    // ========================================================================
4315
4316    /// Render the result modal overlay after cleanup operations.
4317    ///
4318    /// US-007: After clean or remove operations complete, show a result summary modal.
4319    /// This method renders a modal dialog when `pending_result_modal` is Some.
4320    /// Uses the reusable Modal component with a single "OK" button to dismiss.
4321    fn render_result_modal(&mut self, ctx: &egui::Context) {
4322        // Early return if no result is pending
4323        let result = match &self.pending_result_modal {
4324            Some(r) => r.clone(),
4325            None => return,
4326        };
4327
4328        // Create the modal using the reusable component
4329        // US-007: Result modals only have a single "OK" button (no cancel)
4330        let modal = Modal::new(result.title())
4331            .id("cleanup_result")
4332            .message(result.message())
4333            .no_cancel_button()
4334            .confirm_button(ModalButton::new("OK"));
4335
4336        // Show the modal and handle dismissal
4337        // OK button or backdrop click/Escape all dismiss the modal
4338        match modal.show(ctx) {
4339            ModalAction::Confirmed | ModalAction::Cancelled => {
4340                self.pending_result_modal = None;
4341            }
4342            ModalAction::None => {
4343                // Modal is still open, do nothing
4344            }
4345        }
4346    }
4347
4348    // ========================================================================
4349    // Project Change Confirmation Modal (US-008)
4350    // ========================================================================
4351
4352    /// Render confirmation modal when user tries to change project with an active session.
4353    ///
4354    /// US-008: Only one spec creation session can be active at a time. If user tries
4355    /// to switch projects while a session is active, show a warning/confirmation modal.
4356    fn render_project_change_confirmation(&mut self, ctx: &egui::Context) {
4357        // Early return if no project change is pending
4358        let pending_project = match &self.pending_project_change {
4359            Some(name) => name.clone(),
4360            None => return,
4361        };
4362
4363        // Create the modal using the reusable component
4364        let modal = Modal::new("Switch Project?")
4365            .id("project_change_confirmation")
4366            .message(
4367                "You have an active spec creation session. \
4368                 Switching projects will discard your current conversation and any unsaved work.\n\n\
4369                 Do you want to continue?",
4370            )
4371            .cancel_button(ModalButton::secondary("Cancel"))
4372            .confirm_button(ModalButton::destructive("Switch Project"));
4373
4374        // Show the modal and handle the action
4375        match modal.show(ctx) {
4376            ModalAction::Confirmed => {
4377                // Reset current session and switch to the new project
4378                self.reset_create_spec_session();
4379                self.create_spec_selected_project = Some(pending_project);
4380                self.pending_project_change = None;
4381            }
4382            ModalAction::Cancelled => {
4383                // Cancel the project switch
4384                self.pending_project_change = None;
4385            }
4386            ModalAction::None => {
4387                // Modal is still open, do nothing
4388            }
4389        }
4390    }
4391
4392    /// Render confirmation modal when user clicks "Close".
4393    ///
4394    /// Shows a modal reminding users that the spec has been saved and where to find it,
4395    /// asking for confirmation before clearing the session.
4396    fn render_start_new_spec_confirmation(&mut self, ctx: &egui::Context) {
4397        // Early return if not pending
4398        if !self.pending_start_new_spec {
4399            return;
4400        }
4401
4402        // Build message with spec path if available
4403        let message = if let Some(ref spec_path) = self.generated_spec_path {
4404            format!(
4405                "Your spec has been saved to:\n\n{}\n\n\
4406                 Make sure you've copied the run command or noted the file location before starting a new spec.\n\n\
4407                 Do you want to start a new spec?",
4408                spec_path.display()
4409            )
4410        } else {
4411            "Make sure you've saved any important information from this session.\n\n\
4412             Do you want to start a new spec?"
4413                .to_string()
4414        };
4415
4416        // Create the modal
4417        let modal = Modal::new("Close?")
4418            .id("start_new_spec_confirmation")
4419            .message(&message)
4420            .cancel_button(ModalButton::secondary("Cancel"))
4421            .confirm_button(ModalButton::new("Close"));
4422
4423        // Show the modal and handle the action
4424        match modal.show(ctx) {
4425            ModalAction::Confirmed => {
4426                // Reset the session
4427                self.reset_create_spec_session();
4428                self.pending_start_new_spec = false;
4429            }
4430            ModalAction::Cancelled => {
4431                // Cancel - keep the current session
4432                self.pending_start_new_spec = false;
4433            }
4434            ModalAction::None => {
4435                // Modal is still open, do nothing
4436            }
4437        }
4438    }
4439
4440    /// Render the sidebar toggle button in the title bar.
4441    ///
4442    /// The button uses a hamburger icon (☰) when collapsed (to expand)
4443    /// and a sidebar icon (⊏) when expanded (to collapse).
4444    /// Supports hover states for visual feedback.
4445    ///
4446    /// # Arguments
4447    /// * `ui` - The UI context
4448    /// * `is_collapsed` - Whether the sidebar is currently collapsed
4449    ///
4450    /// # Returns
4451    /// The egui Response for click detection
4452    fn render_sidebar_toggle_button(
4453        &self,
4454        ui: &mut egui::Ui,
4455        is_collapsed: bool,
4456    ) -> egui::Response {
4457        let button_size = egui::vec2(SIDEBAR_TOGGLE_SIZE, SIDEBAR_TOGGLE_SIZE);
4458        let (rect, response) = ui.allocate_exact_size(button_size, Sense::click());
4459        let is_hovered = response.hovered();
4460
4461        // Draw background on hover
4462        if is_hovered {
4463            ui.painter().rect_filled(
4464                rect,
4465                Rounding::same(rounding::BUTTON),
4466                colors::SURFACE_HOVER,
4467            );
4468        }
4469
4470        // Draw the icon
4471        // When collapsed: hamburger icon (three horizontal lines) to indicate "show sidebar"
4472        // When expanded: sidebar icon (panel + lines) to indicate "hide sidebar"
4473        let icon_color = if is_hovered {
4474            colors::TEXT_PRIMARY
4475        } else {
4476            colors::TEXT_SECONDARY
4477        };
4478
4479        let painter = ui.painter();
4480        let center = rect.center();
4481
4482        if is_collapsed {
4483            // Hamburger icon (three horizontal lines) - indicates "expand/show"
4484            let line_width = 12.0;
4485            let line_spacing = 4.0;
4486            let half_width = line_width / 2.0;
4487
4488            for i in -1..=1 {
4489                let y = center.y + (i as f32) * line_spacing;
4490                painter.line_segment(
4491                    [
4492                        egui::pos2(center.x - half_width, y),
4493                        egui::pos2(center.x + half_width, y),
4494                    ],
4495                    Stroke::new(1.5, icon_color),
4496                );
4497            }
4498        } else {
4499            // Sidebar icon (left panel with lines) - indicates "collapse/hide"
4500            // Draw a rectangle representing the sidebar
4501            let icon_rect = Rect::from_center_size(center, egui::vec2(14.0, 12.0));
4502
4503            // Outer frame
4504            painter.rect_stroke(icon_rect, Rounding::same(1.0), Stroke::new(1.5, icon_color));
4505
4506            // Vertical divider (sidebar edge)
4507            let divider_x = icon_rect.left() + 5.0;
4508            painter.line_segment(
4509                [
4510                    egui::pos2(divider_x, icon_rect.top() + 1.0),
4511                    egui::pos2(divider_x, icon_rect.bottom() - 1.0),
4512                ],
4513                Stroke::new(1.0, icon_color),
4514            );
4515
4516            // Content lines on the right side
4517            let line_start_x = divider_x + 2.0;
4518            let line_end_x = icon_rect.right() - 2.0;
4519            for i in 0..2 {
4520                let y = icon_rect.top() + 4.0 + (i as f32) * 4.0;
4521                painter.line_segment(
4522                    [egui::pos2(line_start_x, y), egui::pos2(line_end_x, y)],
4523                    Stroke::new(1.0, icon_color),
4524                );
4525            }
4526        }
4527
4528        // Add tooltip
4529        let tooltip_text = if is_collapsed {
4530            "Show sidebar"
4531        } else {
4532            "Hide sidebar"
4533        };
4534        response
4535            .on_hover_text(tooltip_text)
4536            .on_hover_cursor(egui::CursorIcon::PointingHand)
4537    }
4538
4539    // ========================================================================
4540    // Sidebar Navigation (US-003)
4541    // ========================================================================
4542
4543    /// Render the sidebar navigation panel.
4544    ///
4545    /// The sidebar contains permanent navigation items (Active Runs, Projects)
4546    /// as a vertical list with visual indicators for the active item.
4547    /// A decorative animation is displayed at the bottom.
4548    fn render_sidebar(&mut self, ui: &mut egui::Ui) {
4549        // Use a layout that puts nav at top, animation at bottom
4550        ui.with_layout(egui::Layout::top_down_justified(egui::Align::LEFT), |ui| {
4551            // Add some top spacing to align with content area
4552            ui.add_space(spacing::SM);
4553
4554            // Render permanent navigation items
4555            let mut tab_to_activate: Option<TabId> = None;
4556
4557            // Snapshot of permanent tabs (ActiveRuns, Projects, Config, and CreateSpec)
4558            let permanent_tabs: Vec<(TabId, &'static str)> = vec![
4559                (TabId::ActiveRuns, "Active Runs"),
4560                (TabId::Projects, "Projects"),
4561                (TabId::Config, "Config"),
4562                (TabId::CreateSpec, "Create Spec"),
4563            ];
4564
4565            for (tab_id, label) in permanent_tabs {
4566                let is_active = self.active_tab_id == tab_id;
4567                if self.render_sidebar_item(ui, label, is_active) {
4568                    tab_to_activate = Some(tab_id);
4569                }
4570                ui.add_space(spacing::XS);
4571            }
4572
4573            // Process tab activation after render loop
4574            if let Some(tab_id) = tab_to_activate {
4575                self.set_active_tab(tab_id);
4576            }
4577
4578            // Fill remaining space, leaving room for icon and animation
4579            // Icon positioned higher in the sidebar for better visual balance
4580            let animation_height = 150.0;
4581            let icon_section_height = SIDEBAR_ICON_SIZE + spacing::LG * 2.0; // Icon + generous padding
4582            ui.add_space(ui.available_height() - animation_height - icon_section_height);
4583
4584            // Decorative mascot icon (US-005)
4585            // Centered between the tabs and the animation
4586            ui.add_space(spacing::LG);
4587            ui.horizontal(|ui| {
4588                let sidebar_width = ui.available_width();
4589                let icon_offset = (sidebar_width - SIDEBAR_ICON_SIZE) / 2.0;
4590                ui.add_space(icon_offset);
4591                ui.add(
4592                    egui::Image::new(egui::include_image!("../../../assets/icon.png"))
4593                        .fit_to_exact_size(egui::vec2(SIDEBAR_ICON_SIZE, SIDEBAR_ICON_SIZE)),
4594                );
4595            });
4596            ui.add_space(spacing::LG);
4597
4598            // Decorative animation at the bottom of sidebar
4599            // Uses full sidebar width, particles rise from bottom
4600            let sidebar_width = ui.available_width();
4601            super::animation::render_rising_particles(ui, sidebar_width, animation_height);
4602
4603            // Schedule next animation frame (handles all animations)
4604            super::animation::schedule_frame(ui.ctx());
4605        });
4606    }
4607
4608    /// Render a single sidebar navigation item.
4609    ///
4610    /// Returns true if the item was clicked.
4611    fn render_sidebar_item(&self, ui: &mut egui::Ui, label: &str, is_active: bool) -> bool {
4612        // Calculate item dimensions
4613        let available_width = ui.available_width();
4614        let item_size = egui::vec2(available_width, SIDEBAR_ITEM_HEIGHT);
4615
4616        // Allocate space and create interaction response
4617        let (rect, response) = ui.allocate_exact_size(item_size, Sense::click());
4618        let is_hovered = response.hovered();
4619
4620        // Determine background color based on state
4621        let bg_color = if is_active {
4622            colors::SURFACE_SELECTED
4623        } else if is_hovered {
4624            colors::SURFACE_HOVER
4625        } else {
4626            Color32::TRANSPARENT
4627        };
4628
4629        // Draw background
4630        if bg_color != Color32::TRANSPARENT {
4631            ui.painter()
4632                .rect_filled(rect, Rounding::same(SIDEBAR_ITEM_ROUNDING), bg_color);
4633        }
4634
4635        // Draw active indicator (accent bar on the left)
4636        if is_active {
4637            let indicator_rect = Rect::from_min_size(
4638                rect.min,
4639                egui::vec2(SIDEBAR_ACTIVE_INDICATOR_WIDTH, rect.height()),
4640            );
4641            ui.painter().rect_filled(
4642                indicator_rect,
4643                Rounding {
4644                    nw: SIDEBAR_ITEM_ROUNDING,
4645                    sw: SIDEBAR_ITEM_ROUNDING,
4646                    ne: 0.0,
4647                    se: 0.0,
4648                },
4649                colors::ACCENT,
4650            );
4651        }
4652
4653        // Determine text color based on state
4654        let text_color = if is_active {
4655            colors::TEXT_PRIMARY
4656        } else {
4657            colors::TEXT_SECONDARY
4658        };
4659
4660        // Draw text label
4661        let text_pos = egui::pos2(rect.left() + SIDEBAR_ITEM_PADDING_H, rect.center().y);
4662
4663        ui.painter().text(
4664            text_pos,
4665            egui::Align2::LEFT_CENTER,
4666            label,
4667            typography::font(
4668                FontSize::Body,
4669                if is_active {
4670                    FontWeight::SemiBold
4671                } else {
4672                    FontWeight::Medium
4673                },
4674            ),
4675            text_color,
4676        );
4677
4678        response
4679            .on_hover_cursor(egui::CursorIcon::PointingHand)
4680            .clicked()
4681    }
4682
4683    // ========================================================================
4684    // Header / Tab Bar (preserved for US-005: Dynamic Tabs in Content Header)
4685    // ========================================================================
4686
4687    /// Render the header area with tab bar.
4688    /// Note: Will be repurposed for US-005 (Dynamic Tabs in Content Header).
4689    #[allow(dead_code)]
4690    fn render_header(&mut self, ui: &mut egui::Ui) {
4691        // Use horizontal scroll for tab bar if there are many tabs
4692        let scroll_width = ui.available_width().min(TAB_BAR_MAX_SCROLL_WIDTH);
4693
4694        ui.horizontal_centered(|ui| {
4695            ui.add_space(spacing::XS);
4696
4697            egui::ScrollArea::horizontal()
4698                .max_width(scroll_width)
4699                .auto_shrink([false, false])
4700                .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::VisibleWhenNeeded)
4701                .show(ui, |ui| {
4702                    ui.horizontal(|ui| {
4703                        // Collect tab actions to process after render loop
4704                        let mut tab_to_activate: Option<TabId> = None;
4705                        let mut tab_to_close: Option<TabId> = None;
4706
4707                        // Clone tabs to avoid borrow issues
4708                        let tabs_snapshot: Vec<(TabId, String, bool)> = self
4709                            .tabs
4710                            .iter()
4711                            .map(|t| (t.id.clone(), t.label.clone(), t.closable))
4712                            .collect();
4713
4714                        for (tab_id, label, closable) in &tabs_snapshot {
4715                            let is_active = self.active_tab_id == *tab_id;
4716                            let (clicked, close_clicked) =
4717                                self.render_dynamic_tab(ui, label, *closable, is_active);
4718
4719                            if clicked {
4720                                tab_to_activate = Some(tab_id.clone());
4721                            }
4722                            if close_clicked {
4723                                tab_to_close = Some(tab_id.clone());
4724                            }
4725                            ui.add_space(spacing::XS);
4726                        }
4727
4728                        // Process actions after render loop
4729                        if let Some(tab_id) = tab_to_close {
4730                            self.close_tab(&tab_id);
4731                        } else if let Some(tab_id) = tab_to_activate {
4732                            self.set_active_tab(tab_id);
4733                        }
4734                    });
4735                });
4736        });
4737
4738        // Draw bottom border for header
4739        let rect = ui.max_rect();
4740        ui.painter().hline(
4741            rect.x_range(),
4742            rect.bottom(),
4743            Stroke::new(1.0, colors::BORDER),
4744        );
4745    }
4746
4747    /// Render a single tab button with optional close button.
4748    /// Returns (tab_clicked, close_clicked).
4749    /// Note: Will be used for US-005 (Dynamic Tabs in Content Header).
4750    #[allow(dead_code)]
4751    fn render_dynamic_tab(
4752        &self,
4753        ui: &mut egui::Ui,
4754        label: &str,
4755        closable: bool,
4756        is_active: bool,
4757    ) -> (bool, bool) {
4758        // Calculate text size
4759        let text_galley = ui.fonts(|f| {
4760            f.layout_no_wrap(
4761                label.to_string(),
4762                typography::font(FontSize::Body, FontWeight::Medium),
4763                colors::TEXT_PRIMARY,
4764            )
4765        });
4766        let text_size = text_galley.size();
4767
4768        // Calculate tab width including close button if closable
4769        let close_button_space = if closable {
4770            TAB_CLOSE_BUTTON_SIZE + TAB_CLOSE_PADDING
4771        } else {
4772            0.0
4773        };
4774        let tab_width = text_size.x + TAB_PADDING_H * 2.0 + close_button_space;
4775        let tab_size = egui::vec2(tab_width, HEADER_HEIGHT - TAB_UNDERLINE_HEIGHT);
4776
4777        // Allocate space for the entire tab
4778        let (rect, response) = ui.allocate_exact_size(tab_size, Sense::click());
4779
4780        let is_hovered = response.hovered();
4781
4782        // Draw tab background on hover (subtle)
4783        if is_hovered && !is_active {
4784            ui.painter().rect_filled(
4785                rect,
4786                Rounding::same(rounding::BUTTON),
4787                colors::SURFACE_HOVER,
4788            );
4789        }
4790
4791        // Draw text (offset left if closable to make room for close button)
4792        let text_color = if is_active {
4793            colors::TEXT_PRIMARY
4794        } else if is_hovered {
4795            colors::TEXT_SECONDARY
4796        } else {
4797            colors::TEXT_MUTED
4798        };
4799
4800        let text_x = if closable {
4801            rect.left() + TAB_PADDING_H
4802        } else {
4803            rect.center().x - text_size.x / 2.0
4804        };
4805        let text_pos = egui::pos2(text_x, rect.center().y - text_size.y / 2.0);
4806
4807        ui.painter().galley(
4808            text_pos,
4809            ui.fonts(|f| {
4810                f.layout_no_wrap(
4811                    label.to_string(),
4812                    typography::font(
4813                        FontSize::Body,
4814                        if is_active {
4815                            FontWeight::SemiBold
4816                        } else {
4817                            FontWeight::Medium
4818                        },
4819                    ),
4820                    text_color,
4821                )
4822            }),
4823            Color32::TRANSPARENT,
4824        );
4825
4826        // Draw close button for closable tabs
4827        let mut close_clicked = false;
4828        if closable {
4829            let close_rect = Rect::from_min_size(
4830                egui::pos2(
4831                    rect.right() - TAB_PADDING_H - TAB_CLOSE_BUTTON_SIZE,
4832                    rect.center().y - TAB_CLOSE_BUTTON_SIZE / 2.0,
4833                ),
4834                egui::vec2(TAB_CLOSE_BUTTON_SIZE, TAB_CLOSE_BUTTON_SIZE),
4835            );
4836
4837            // Check if mouse is over the close button
4838            let close_hovered = ui
4839                .ctx()
4840                .input(|i| i.pointer.hover_pos())
4841                .is_some_and(|pos| close_rect.contains(pos));
4842
4843            // Draw close button background on hover
4844            if close_hovered {
4845                ui.painter().rect_filled(
4846                    close_rect,
4847                    Rounding::same(rounding::SMALL),
4848                    colors::SURFACE_HOVER,
4849                );
4850                // Set pointer cursor when hovering close button
4851                ui.ctx()
4852                    .output_mut(|o| o.cursor_icon = egui::CursorIcon::PointingHand);
4853            }
4854
4855            // Draw X icon
4856            let x_color = if close_hovered {
4857                colors::TEXT_PRIMARY
4858            } else {
4859                colors::TEXT_MUTED
4860            };
4861            let x_center = close_rect.center();
4862            let x_size = TAB_CLOSE_BUTTON_SIZE * 0.35 * if close_hovered { 1.15 } else { 1.0 };
4863            ui.painter().line_segment(
4864                [
4865                    egui::pos2(x_center.x - x_size, x_center.y - x_size),
4866                    egui::pos2(x_center.x + x_size, x_center.y + x_size),
4867                ],
4868                Stroke::new(1.5, x_color),
4869            );
4870            ui.painter().line_segment(
4871                [
4872                    egui::pos2(x_center.x + x_size, x_center.y - x_size),
4873                    egui::pos2(x_center.x - x_size, x_center.y + x_size),
4874                ],
4875                Stroke::new(1.5, x_color),
4876            );
4877
4878            // Check for close button click
4879            if response.clicked() && close_hovered {
4880                close_clicked = true;
4881            }
4882        }
4883
4884        // Draw underline indicator for active tab
4885        if is_active {
4886            let underline_rect = egui::Rect::from_min_size(
4887                egui::pos2(rect.left(), rect.bottom() - TAB_UNDERLINE_HEIGHT),
4888                egui::vec2(rect.width(), TAB_UNDERLINE_HEIGHT),
4889            );
4890            ui.painter()
4891                .rect_filled(underline_rect, Rounding::ZERO, colors::ACCENT);
4892        }
4893
4894        // Tab was clicked if response.clicked() and NOT close button clicked
4895        let tab_clicked = response.clicked() && !close_clicked;
4896
4897        (tab_clicked, close_clicked)
4898    }
4899
4900    /// Render the content area based on the current tab.
4901    ///
4902    /// When dynamic tabs are open, a tab bar header appears at the top of the
4903    /// content area showing the closable tabs. Clicking a tab switches to it,
4904    /// and closing the last dynamic tab returns to the permanent view.
4905    fn render_content(&mut self, ui: &mut egui::Ui) {
4906        // Check if there are dynamic tabs to show the content header tab bar
4907        let has_dynamic_tabs = self.closable_tab_count() > 0;
4908
4909        if has_dynamic_tabs {
4910            // Render the content header with dynamic tabs
4911            self.render_content_tab_bar(ui);
4912
4913            // Add a subtle separator line
4914            let separator_rect = ui.available_rect_before_wrap();
4915            ui.painter().hline(
4916                separator_rect.x_range(),
4917                separator_rect.top(),
4918                Stroke::new(1.0, colors::SEPARATOR),
4919            );
4920
4921            ui.add_space(spacing::SM);
4922        }
4923
4924        // Render the main content based on the active tab
4925        match &self.active_tab_id {
4926            TabId::ActiveRuns => self.render_active_runs(ui),
4927            TabId::Projects => self.render_projects(ui),
4928            TabId::Config => self.render_config(ui),
4929            TabId::CreateSpec => self.render_create_spec(ui),
4930            TabId::RunDetail(run_id) => {
4931                let run_id = run_id.clone();
4932                self.render_run_detail(ui, &run_id);
4933            }
4934            TabId::CommandOutput(cache_key) => {
4935                let cache_key = cache_key.clone();
4936                self.render_command_output(ui, &cache_key);
4937            }
4938        }
4939    }
4940
4941    /// Render the content header tab bar with dynamic tabs only.
4942    ///
4943    /// This tab bar appears in the content area header when dynamic tabs (like
4944    /// Run Detail views) are open. The permanent tabs (Active Runs, Projects)
4945    /// are handled by the sidebar navigation, not shown here.
4946    ///
4947    /// Features:
4948    /// - Each tab has a close button (X)
4949    /// - Clicking a tab switches to that content
4950    /// - Closing the last dynamic tab returns to the last permanent view
4951    /// - Tab bar uses horizontal scrolling if many tabs are open
4952    fn render_content_tab_bar(&mut self, ui: &mut egui::Ui) {
4953        // Allocate fixed height for the tab bar
4954        let available_width = ui.available_width();
4955        let scroll_width = available_width.min(TAB_BAR_MAX_SCROLL_WIDTH);
4956
4957        ui.allocate_ui_with_layout(
4958            egui::vec2(available_width, CONTENT_TAB_BAR_HEIGHT),
4959            egui::Layout::left_to_right(egui::Align::Center),
4960            |ui| {
4961                // Collect tab actions to process after render loop
4962                let mut tab_to_activate: Option<TabId> = None;
4963                let mut tab_to_close: Option<TabId> = None;
4964
4965                egui::ScrollArea::horizontal()
4966                    .max_width(scroll_width)
4967                    .auto_shrink([false, false])
4968                    .scroll_bar_visibility(
4969                        egui::scroll_area::ScrollBarVisibility::VisibleWhenNeeded,
4970                    )
4971                    .show(ui, |ui| {
4972                        ui.horizontal_centered(|ui| {
4973                            ui.add_space(spacing::XS);
4974
4975                            // Only show closable (dynamic) tabs in the content header
4976                            let dynamic_tabs: Vec<(TabId, String)> = self
4977                                .tabs
4978                                .iter()
4979                                .filter(|t| t.closable)
4980                                .map(|t| (t.id.clone(), t.label.clone()))
4981                                .collect();
4982
4983                            for (tab_id, label) in &dynamic_tabs {
4984                                let is_active = self.active_tab_id == *tab_id;
4985                                let (clicked, close_clicked) =
4986                                    self.render_content_tab(ui, label, is_active);
4987
4988                                if clicked {
4989                                    tab_to_activate = Some(tab_id.clone());
4990                                }
4991                                if close_clicked {
4992                                    tab_to_close = Some(tab_id.clone());
4993                                }
4994                                ui.add_space(spacing::XS);
4995                            }
4996                        });
4997                    });
4998
4999                // Process actions after render loop
5000                if let Some(tab_id) = tab_to_close {
5001                    self.close_tab(&tab_id);
5002                } else if let Some(tab_id) = tab_to_activate {
5003                    self.set_active_tab(tab_id);
5004                }
5005            },
5006        );
5007    }
5008
5009    /// Render a single tab in the content header tab bar.
5010    ///
5011    /// Each tab displays its label and a close button (X).
5012    /// Returns (tab_clicked, close_clicked).
5013    fn render_content_tab(&self, ui: &mut egui::Ui, label: &str, is_active: bool) -> (bool, bool) {
5014        // Calculate text size
5015        let text_galley = ui.fonts(|f| {
5016            f.layout_no_wrap(
5017                label.to_string(),
5018                typography::font(FontSize::Body, FontWeight::Medium),
5019                colors::TEXT_PRIMARY,
5020            )
5021        });
5022        let text_size = text_galley.size();
5023
5024        // Calculate tab width including close button
5025        let close_button_space = TAB_CLOSE_BUTTON_SIZE + TAB_CLOSE_PADDING;
5026        let tab_width = text_size.x + TAB_PADDING_H * 2.0 + close_button_space;
5027        let tab_height = CONTENT_TAB_BAR_HEIGHT - TAB_UNDERLINE_HEIGHT - spacing::XS;
5028        let tab_size = egui::vec2(tab_width, tab_height);
5029
5030        // Allocate space for the entire tab
5031        let (rect, response) = ui.allocate_exact_size(tab_size, Sense::click());
5032        let is_hovered = response.hovered();
5033
5034        // Draw tab background
5035        let bg_color = if is_active {
5036            colors::SURFACE_SELECTED
5037        } else if is_hovered {
5038            colors::SURFACE_HOVER
5039        } else {
5040            Color32::TRANSPARENT
5041        };
5042
5043        if bg_color != Color32::TRANSPARENT {
5044            ui.painter()
5045                .rect_filled(rect, Rounding::same(rounding::BUTTON), bg_color);
5046        }
5047
5048        // Draw text
5049        let text_color = if is_active {
5050            colors::TEXT_PRIMARY
5051        } else if is_hovered {
5052            colors::TEXT_SECONDARY
5053        } else {
5054            colors::TEXT_MUTED
5055        };
5056
5057        let text_x = rect.left() + TAB_PADDING_H;
5058        let text_pos = egui::pos2(text_x, rect.center().y - text_size.y / 2.0);
5059
5060        ui.painter().galley(
5061            text_pos,
5062            ui.fonts(|f| {
5063                f.layout_no_wrap(
5064                    label.to_string(),
5065                    typography::font(
5066                        FontSize::Body,
5067                        if is_active {
5068                            FontWeight::SemiBold
5069                        } else {
5070                            FontWeight::Medium
5071                        },
5072                    ),
5073                    text_color,
5074                )
5075            }),
5076            Color32::TRANSPARENT,
5077        );
5078
5079        // Draw close button
5080        let close_rect = Rect::from_min_size(
5081            egui::pos2(
5082                rect.right() - TAB_PADDING_H - TAB_CLOSE_BUTTON_SIZE,
5083                rect.center().y - TAB_CLOSE_BUTTON_SIZE / 2.0,
5084            ),
5085            egui::vec2(TAB_CLOSE_BUTTON_SIZE, TAB_CLOSE_BUTTON_SIZE),
5086        );
5087
5088        // Check if mouse is over the close button
5089        let close_hovered = ui
5090            .ctx()
5091            .input(|i| i.pointer.hover_pos())
5092            .is_some_and(|pos| close_rect.contains(pos));
5093
5094        // Draw close button background on hover
5095        if close_hovered {
5096            ui.painter().rect_filled(
5097                close_rect,
5098                Rounding::same(rounding::SMALL),
5099                colors::SURFACE_HOVER,
5100            );
5101            // Set pointer cursor when hovering close button
5102            ui.ctx()
5103                .output_mut(|o| o.cursor_icon = egui::CursorIcon::PointingHand);
5104        }
5105
5106        // Draw X icon
5107        let x_color = if close_hovered {
5108            colors::TEXT_PRIMARY
5109        } else {
5110            colors::TEXT_MUTED
5111        };
5112        let x_center = close_rect.center();
5113        let x_size = TAB_CLOSE_BUTTON_SIZE * 0.3 * if close_hovered { 1.15 } else { 1.0 };
5114
5115        ui.painter().line_segment(
5116            [
5117                egui::pos2(x_center.x - x_size, x_center.y - x_size),
5118                egui::pos2(x_center.x + x_size, x_center.y + x_size),
5119            ],
5120            Stroke::new(1.5, x_color),
5121        );
5122        ui.painter().line_segment(
5123            [
5124                egui::pos2(x_center.x + x_size, x_center.y - x_size),
5125                egui::pos2(x_center.x - x_size, x_center.y + x_size),
5126            ],
5127            Stroke::new(1.5, x_color),
5128        );
5129
5130        // Draw underline indicator for active tab
5131        if is_active {
5132            let underline_rect = egui::Rect::from_min_size(
5133                egui::pos2(rect.left(), rect.bottom()),
5134                egui::vec2(rect.width(), TAB_UNDERLINE_HEIGHT),
5135            );
5136            ui.painter()
5137                .rect_filled(underline_rect, Rounding::ZERO, colors::ACCENT);
5138        }
5139
5140        // Close button click takes precedence over tab click
5141        let close_clicked = response.clicked() && close_hovered;
5142        let tab_clicked = response.clicked() && !close_hovered;
5143
5144        (tab_clicked, close_clicked)
5145    }
5146
5147    /// Render the Config view with split-panel layout.
5148    ///
5149    /// Uses the same split-panel pattern as the Projects tab:
5150    /// - Left panel: Scope selector (Global + projects)
5151    /// - Right panel: Config editor for the selected scope
5152    fn render_config(&mut self, ui: &mut egui::Ui) {
5153        // Refresh config scope data before rendering
5154        self.refresh_config_scope_data();
5155
5156        // Track actions that need to be processed after rendering (US-005, US-006)
5157        let mut editor_actions = ConfigEditorActions::default();
5158
5159        // Use horizontal layout for split view
5160        let available_width = ui.available_width();
5161        let available_height = ui.available_height();
5162
5163        // Calculate panel widths: 50/50 split with divider in the middle
5164        // Subtract the divider width and margins from the total width
5165        let divider_total_width = SPLIT_DIVIDER_WIDTH + SPLIT_DIVIDER_MARGIN * 2.0;
5166        let panel_width =
5167            ((available_width - divider_total_width) / 2.0).max(SPLIT_PANEL_MIN_WIDTH);
5168
5169        ui.horizontal(|ui| {
5170            // Left panel: Scope selector
5171            ui.allocate_ui_with_layout(
5172                Vec2::new(panel_width, available_height),
5173                egui::Layout::top_down(egui::Align::LEFT),
5174                |ui| {
5175                    self.render_config_left_panel(ui);
5176                },
5177            );
5178
5179            // Visual divider between panels with appropriate margin
5180            ui.add_space(SPLIT_DIVIDER_MARGIN);
5181
5182            // Draw a custom vertical divider line using the SEPARATOR color
5183            let divider_rect = ui.available_rect_before_wrap();
5184            let divider_line_rect = Rect::from_min_size(
5185                divider_rect.min,
5186                Vec2::new(SPLIT_DIVIDER_WIDTH, available_height),
5187            );
5188            ui.painter()
5189                .rect_filled(divider_line_rect, Rounding::ZERO, colors::SEPARATOR);
5190            ui.add_space(SPLIT_DIVIDER_WIDTH);
5191
5192            ui.add_space(SPLIT_DIVIDER_MARGIN);
5193
5194            // Right panel: Config editor for selected scope
5195            // Returns actions including create project config (US-005) and bool changes (US-006)
5196            let actions_response = ui.allocate_ui_with_layout(
5197                Vec2::new(ui.available_width(), available_height),
5198                egui::Layout::top_down(egui::Align::LEFT),
5199                |ui| self.render_config_right_panel(ui),
5200            );
5201
5202            editor_actions = actions_response.inner;
5203        });
5204
5205        // Process the create config action outside of the closure (US-005)
5206        if let Some(project_name) = editor_actions.create_project_config {
5207            if let Err(e) = self.create_project_config_from_global(&project_name) {
5208                self.config_state.project_config_error = Some(e);
5209            }
5210        }
5211
5212        // Process boolean field changes (US-006)
5213        if !editor_actions.bool_changes.is_empty() {
5214            self.apply_config_bool_changes(
5215                editor_actions.is_global,
5216                editor_actions.project_name.as_deref(),
5217                &editor_actions.bool_changes,
5218            );
5219        }
5220
5221        // Process text field changes (US-007)
5222        if !editor_actions.text_changes.is_empty() {
5223            self.apply_config_text_changes(
5224                editor_actions.is_global,
5225                editor_actions.project_name.as_deref(),
5226                &editor_actions.text_changes,
5227            );
5228        }
5229
5230        // Process reset to defaults action (US-009)
5231        if editor_actions.reset_to_defaults {
5232            self.reset_config_to_defaults(
5233                editor_actions.is_global,
5234                editor_actions.project_name.as_deref(),
5235            );
5236        }
5237    }
5238
5239    /// Render the left panel of the Config view (scope selector).
5240    ///
5241    /// Shows "Global" at the top, followed by all discovered projects.
5242    /// Projects without their own config file are shown greyed out with "(global)" suffix.
5243    fn render_config_left_panel(&mut self, ui: &mut egui::Ui) {
5244        // Header section
5245        ui.label(
5246            egui::RichText::new("Scope")
5247                .font(typography::font(FontSize::Title, FontWeight::SemiBold))
5248                .color(colors::TEXT_PRIMARY),
5249        );
5250
5251        ui.add_space(spacing::SM);
5252
5253        // Scrollable scope list
5254        egui::ScrollArea::vertical()
5255            .id_salt("config_scope_list")
5256            .auto_shrink([false, false])
5257            .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::VisibleWhenNeeded)
5258            .show(ui, |ui| {
5259                // Global scope item (always first, always has config)
5260                if self.render_config_scope_item(ui, ConfigScope::Global, true) {
5261                    self.config_state.selected_scope = ConfigScope::Global;
5262                }
5263
5264                ui.add_space(spacing::SM);
5265
5266                // Project scope items
5267                let projects: Vec<String> = self.config_state.scope_projects.clone();
5268                for project in projects {
5269                    let has_config = self.project_has_config(&project);
5270                    let scope = ConfigScope::Project(project.clone());
5271                    if self.render_config_scope_item(ui, scope.clone(), has_config) {
5272                        self.config_state.selected_scope = scope;
5273                    }
5274                    ui.add_space(spacing::XS);
5275                }
5276            });
5277    }
5278
5279    /// Render a single config scope item in the scope selector.
5280    ///
5281    /// Returns true if the item was clicked.
5282    fn render_config_scope_item(
5283        &self,
5284        ui: &mut egui::Ui,
5285        scope: ConfigScope,
5286        has_config: bool,
5287    ) -> bool {
5288        let is_selected = self.config_state.selected_scope == scope;
5289
5290        // Determine display text and styling
5291        let (display_text, text_color) = match &scope {
5292            ConfigScope::Global => ("Global".to_string(), colors::TEXT_PRIMARY),
5293            ConfigScope::Project(name) => {
5294                if has_config {
5295                    (name.clone(), colors::TEXT_PRIMARY)
5296                } else {
5297                    // Projects without config file: greyed out with "(global)" suffix
5298                    (format!("{} (global)", name), colors::TEXT_MUTED)
5299                }
5300            }
5301        };
5302
5303        // Allocate space for the row
5304        let (rect, response) = ui.allocate_exact_size(
5305            Vec2::new(ui.available_width(), CONFIG_SCOPE_ROW_HEIGHT),
5306            Sense::click(),
5307        );
5308
5309        // Draw background on hover or selection
5310        if ui.is_rect_visible(rect) {
5311            let bg_color = if is_selected {
5312                colors::SURFACE_SELECTED
5313            } else if response.hovered() {
5314                colors::SURFACE_HOVER
5315            } else {
5316                Color32::TRANSPARENT
5317            };
5318
5319            ui.painter()
5320                .rect_filled(rect, Rounding::same(SIDEBAR_ITEM_ROUNDING), bg_color);
5321
5322            // Draw selection indicator on the left edge for selected items
5323            if is_selected {
5324                let indicator_rect = Rect::from_min_size(
5325                    rect.min,
5326                    Vec2::new(SIDEBAR_ACTIVE_INDICATOR_WIDTH, rect.height()),
5327                );
5328                ui.painter().rect_filled(
5329                    indicator_rect,
5330                    Rounding::same(SIDEBAR_ACTIVE_INDICATOR_WIDTH / 2.0),
5331                    colors::ACCENT,
5332                );
5333            }
5334
5335            // Draw the scope name with appropriate styling
5336            let text_rect = rect.shrink2(Vec2::new(
5337                CONFIG_SCOPE_ROW_PADDING_H
5338                    + (if is_selected {
5339                        SIDEBAR_ACTIVE_INDICATOR_WIDTH + 4.0
5340                    } else {
5341                        0.0
5342                    }),
5343                CONFIG_SCOPE_ROW_PADDING_V,
5344            ));
5345
5346            let font_weight = if is_selected {
5347                FontWeight::SemiBold
5348            } else {
5349                FontWeight::Regular
5350            };
5351
5352            ui.painter().text(
5353                text_rect.left_center(),
5354                egui::Align2::LEFT_CENTER,
5355                &display_text,
5356                typography::font(FontSize::Body, font_weight),
5357                text_color,
5358            );
5359        }
5360
5361        response.clicked()
5362    }
5363
5364    /// Render the right panel of the Config view (config editor).
5365    ///
5366    /// Shows the config editor for the currently selected scope.
5367    /// For US-003: Global Config Editor with all 6 fields grouped logically.
5368    /// For US-005: Returns project name if "Create Project Config" button was clicked.
5369    /// For US-006: Returns boolean field changes for immediate save.
5370    ///
5371    /// # Returns
5372    ///
5373    /// `ConfigEditorActions` containing any actions that need to be processed:
5374    /// - `create_project_config`: Project name if "Create Project Config" was clicked
5375    /// - `bool_changes`: Vector of (field, new_value) for toggled boolean fields
5376    fn render_config_right_panel(&self, ui: &mut egui::Ui) -> ConfigEditorActions {
5377        let mut actions = ConfigEditorActions::default();
5378
5379        // Header showing the selected scope with tooltip for config path
5380        let (header_text, tooltip_text) = match &self.config_state.selected_scope {
5381            ConfigScope::Global => {
5382                actions.is_global = true;
5383                let path = crate::config::global_config_path()
5384                    .map(|p| p.display().to_string())
5385                    .unwrap_or_else(|_| "~/.config/autom8/config.toml".to_string());
5386                ("Global Config".to_string(), path)
5387            }
5388            ConfigScope::Project(name) => {
5389                actions.is_global = false;
5390                actions.project_name = Some(name.clone());
5391                let path = crate::config::project_config_path_for(name)
5392                    .map(|p| p.display().to_string())
5393                    .unwrap_or_else(|_| format!("~/.config/autom8/{}/config.toml", name));
5394                if self.project_has_config(name) {
5395                    (format!("Project Config: {}", name), path)
5396                } else {
5397                    (format!("Project Config: {} (using global)", name), path)
5398                }
5399            }
5400        };
5401
5402        // Header with tooltip
5403        let header_response = ui.label(
5404            egui::RichText::new(&header_text)
5405                .font(typography::font(FontSize::Title, FontWeight::SemiBold))
5406                .color(colors::TEXT_PRIMARY),
5407        );
5408        header_response.on_hover_text(&tooltip_text);
5409
5410        ui.add_space(spacing::MD);
5411
5412        // Render content based on scope
5413        match &self.config_state.selected_scope {
5414            ConfigScope::Global => {
5415                let (bool_changes, text_changes, reset_clicked) =
5416                    self.render_global_config_editor(ui);
5417                actions.bool_changes = bool_changes;
5418                actions.text_changes = text_changes;
5419                actions.reset_to_defaults = reset_clicked;
5420            }
5421            ConfigScope::Project(name) => {
5422                // Project config editor (US-004, US-007, US-009)
5423                // Check if the project has its own config file
5424                if self.project_has_config(name) {
5425                    let (bool_changes, text_changes, reset_clicked) =
5426                        self.render_project_config_editor(ui, name);
5427                    actions.bool_changes = bool_changes;
5428                    actions.text_changes = text_changes;
5429                    actions.reset_to_defaults = reset_clicked;
5430                } else {
5431                    // Project doesn't have its own config - show message and button (US-005)
5432                    let project_name = name.clone();
5433                    egui::ScrollArea::vertical()
5434                        .id_salt("config_editor")
5435                        .auto_shrink([false, false])
5436                        .show(ui, |ui| {
5437                            ui.add_space(spacing::XXL);
5438                            ui.vertical_centered(|ui| {
5439                                // Information message
5440                                ui.label(
5441                                    egui::RichText::new(
5442                                        "This project does not have a config file.\nIt uses the global configuration.",
5443                                    )
5444                                    .font(typography::font(FontSize::Body, FontWeight::Regular))
5445                                    .color(colors::TEXT_MUTED),
5446                                );
5447
5448                                ui.add_space(spacing::LG);
5449
5450                                // Create Project Config button (US-005)
5451                                if self.render_create_config_button(ui) {
5452                                    actions.create_project_config = Some(project_name.clone());
5453                                }
5454                            });
5455                        });
5456                }
5457            }
5458        }
5459
5460        // Show "Changes take effect on next run" notice if config was recently modified (US-006)
5461        if self.config_state.last_modified.is_some() {
5462            ui.add_space(spacing::MD);
5463            ui.label(
5464                egui::RichText::new("Changes take effect on next run")
5465                    .font(typography::font(FontSize::Small, FontWeight::Regular))
5466                    .color(colors::TEXT_MUTED),
5467            );
5468        }
5469
5470        actions
5471    }
5472
5473    /// Render the "Create Project Config" button (US-005).
5474    ///
5475    /// Returns true if the button was clicked.
5476    fn render_create_config_button(&self, ui: &mut egui::Ui) -> bool {
5477        let button_text = "Create Project Config";
5478        let text_galley = ui.fonts(|f| {
5479            f.layout_no_wrap(
5480                button_text.to_string(),
5481                typography::font(FontSize::Body, FontWeight::Medium),
5482                colors::TEXT_PRIMARY,
5483            )
5484        });
5485        let text_size = text_galley.size();
5486
5487        // Button dimensions with padding
5488        let button_padding_h = spacing::LG;
5489        let button_padding_v = spacing::SM;
5490        let button_size = Vec2::new(
5491            text_size.x + button_padding_h * 2.0,
5492            text_size.y + button_padding_v * 2.0,
5493        );
5494
5495        // Allocate space and get response
5496        let (rect, response) = ui.allocate_exact_size(button_size, Sense::click());
5497        let is_hovered = response.hovered();
5498
5499        // Draw button background
5500        let bg_color = if is_hovered {
5501            colors::ACCENT
5502        } else {
5503            colors::ACCENT_SUBTLE
5504        };
5505        ui.painter()
5506            .rect_filled(rect, Rounding::same(rounding::BUTTON), bg_color);
5507
5508        // Draw button text
5509        let text_color = if is_hovered {
5510            colors::TEXT_PRIMARY
5511        } else {
5512            colors::ACCENT
5513        };
5514        let text_pos = rect.center() - text_size / 2.0;
5515        ui.painter().galley(
5516            text_pos,
5517            ui.fonts(|f| {
5518                f.layout_no_wrap(
5519                    button_text.to_string(),
5520                    typography::font(FontSize::Body, FontWeight::Medium),
5521                    text_color,
5522                )
5523            }),
5524            text_color,
5525        );
5526
5527        response.clicked()
5528    }
5529
5530    /// Render the "Reset to Defaults" button (US-009).
5531    ///
5532    /// Styled as a secondary/subtle action - uses muted colors and smaller weight.
5533    /// Returns true if the button was clicked.
5534    fn render_reset_to_defaults_button(&self, ui: &mut egui::Ui) -> bool {
5535        let button_text = "Reset to Defaults";
5536        let text_galley = ui.fonts(|f| {
5537            f.layout_no_wrap(
5538                button_text.to_string(),
5539                typography::font(FontSize::Small, FontWeight::Regular),
5540                colors::TEXT_MUTED,
5541            )
5542        });
5543        let text_size = text_galley.size();
5544
5545        // Button dimensions with modest padding (subtle button)
5546        let button_padding_h = spacing::MD;
5547        let button_padding_v = spacing::XS;
5548        let button_size = Vec2::new(
5549            text_size.x + button_padding_h * 2.0,
5550            text_size.y + button_padding_v * 2.0,
5551        );
5552
5553        // Allocate space and get response
5554        let (rect, response) = ui.allocate_exact_size(button_size, Sense::click());
5555        let is_hovered = response.hovered();
5556
5557        // Draw subtle button background (only visible on hover)
5558        if is_hovered {
5559            ui.painter()
5560                .rect_filled(rect, Rounding::same(rounding::BUTTON), colors::SURFACE);
5561        }
5562
5563        // Draw button text (slightly brighter on hover)
5564        let text_color = if is_hovered {
5565            colors::TEXT_SECONDARY
5566        } else {
5567            colors::TEXT_MUTED
5568        };
5569        let text_pos = rect.center() - text_size / 2.0;
5570        ui.painter().galley(
5571            text_pos,
5572            ui.fonts(|f| {
5573                f.layout_no_wrap(
5574                    button_text.to_string(),
5575                    typography::font(FontSize::Small, FontWeight::Regular),
5576                    text_color,
5577                )
5578            }),
5579            text_color,
5580        );
5581
5582        response.clicked()
5583    }
5584
5585    /// Render the global config editor with all fields (US-003, US-006, US-009).
5586    ///
5587    /// Displays all 6 config fields grouped logically:
5588    /// - Pipeline group: review, commit, pull_request
5589    /// - Worktree group: worktree, worktree_path_pattern, worktree_cleanup
5590    ///
5591    /// Boolean fields are rendered as interactive toggle switches (US-006).
5592    /// Text fields are rendered as editable inputs with real-time validation (US-007).
5593    /// Includes "Reset to Defaults" button at the bottom (US-009).
5594    /// Returns tuples of (bool_changes, text_changes, reset_clicked) to be processed by the caller.
5595    fn render_global_config_editor(
5596        &self,
5597        ui: &mut egui::Ui,
5598    ) -> (BoolFieldChanges, TextFieldChanges, bool) {
5599        let mut bool_changes: Vec<(ConfigBoolField, bool)> = Vec::new();
5600        let mut text_changes: Vec<(ConfigTextField, String)> = Vec::new();
5601        let mut reset_clicked = false;
5602
5603        // Show error if config failed to load
5604        if let Some(error) = &self.config_state.global_config_error {
5605            ui.add_space(spacing::MD);
5606            ui.label(
5607                egui::RichText::new(error)
5608                    .font(typography::font(FontSize::Body, FontWeight::Regular))
5609                    .color(colors::STATUS_ERROR),
5610            );
5611            return (bool_changes, text_changes, reset_clicked);
5612        }
5613
5614        // Show loading state or editor
5615        let Some(config) = &self.config_state.cached_global_config else {
5616            ui.add_space(spacing::MD);
5617            ui.label(
5618                egui::RichText::new("Loading configuration...")
5619                    .font(typography::font(FontSize::Body, FontWeight::Regular))
5620                    .color(colors::TEXT_MUTED),
5621            );
5622            return (bool_changes, text_changes, reset_clicked);
5623        };
5624
5625        // Create mutable copies of boolean fields for toggle interaction (US-006)
5626        let mut review = config.review;
5627        let mut commit = config.commit;
5628        let mut pull_request = config.pull_request;
5629        let mut pull_request_draft = config.pull_request_draft;
5630        let mut worktree = config.worktree;
5631        let mut worktree_cleanup = config.worktree_cleanup;
5632
5633        // Create mutable copy of text field for editing (US-007)
5634        let mut worktree_path_pattern = config.worktree_path_pattern.clone();
5635
5636        // ScrollArea for config fields
5637        egui::ScrollArea::vertical()
5638            .id_salt("config_editor")
5639            .auto_shrink([false, false])
5640            .show(ui, |ui| {
5641                // Pipeline Settings Group
5642                self.render_config_group_header(ui, "Pipeline");
5643                ui.add_space(spacing::SM);
5644
5645                if self.render_config_bool_field(
5646                    ui,
5647                    "review",
5648                    &mut review,
5649                    "Code review before committing. When enabled, changes are reviewed for quality before being committed.",
5650                ) {
5651                    bool_changes.push((ConfigBoolField::Review, review));
5652                }
5653
5654                ui.add_space(spacing::SM);
5655
5656                // Commit toggle - when disabling commit while pull_request is true,
5657                // cascade by also disabling pull_request (US-008)
5658                if self.render_config_bool_field(
5659                    ui,
5660                    "commit",
5661                    &mut commit,
5662                    "Automatic git commits. When enabled, changes are automatically committed after implementation.",
5663                ) {
5664                    bool_changes.push((ConfigBoolField::Commit, commit));
5665                    // Cascade: if commit is now false and pull_request was true, disable pull_request too
5666                    if !commit && pull_request {
5667                        pull_request = false;
5668                        bool_changes.push((ConfigBoolField::PullRequest, false));
5669                        // Also cascade to pull_request_draft
5670                        if pull_request_draft {
5671                            pull_request_draft = false;
5672                            bool_changes.push((ConfigBoolField::PullRequestDraft, false));
5673                        }
5674                    }
5675                }
5676
5677                ui.add_space(spacing::SM);
5678
5679                // Pull request toggle - disabled when commit is false (US-008)
5680                // Shows tooltip explaining why it's disabled
5681                if self.render_config_bool_field_with_disabled(
5682                    ui,
5683                    "pull_request",
5684                    &mut pull_request,
5685                    "Automatic PR creation. When enabled, a pull request is created after committing. Requires commit to be enabled.",
5686                    !commit, // disabled when commit is false
5687                    Some("Pull requests require commits to be enabled"),
5688                ) {
5689                    bool_changes.push((ConfigBoolField::PullRequest, pull_request));
5690                    // Cascade: if pull_request is now false and pull_request_draft was true, disable it too
5691                    if !pull_request && pull_request_draft {
5692                        pull_request_draft = false;
5693                        bool_changes.push((ConfigBoolField::PullRequestDraft, false));
5694                    }
5695                }
5696
5697                ui.add_space(spacing::SM);
5698
5699                // Pull request draft toggle - disabled when pull_request is false
5700                // Shows tooltip explaining why it's disabled
5701                if self.render_config_bool_field_with_disabled(
5702                    ui,
5703                    "pull_request_draft",
5704                    &mut pull_request_draft,
5705                    "Create PRs as drafts. When enabled, PRs are created in draft mode (not ready for review). Requires pull_request to be enabled.",
5706                    !pull_request, // disabled when pull_request is false
5707                    Some("Draft PRs require pull requests to be enabled"),
5708                ) {
5709                    bool_changes.push((ConfigBoolField::PullRequestDraft, pull_request_draft));
5710                }
5711
5712                ui.add_space(spacing::XL);
5713
5714                // Worktree Settings Group
5715                self.render_config_group_header(ui, "Worktree");
5716                ui.add_space(spacing::SM);
5717
5718                if self.render_config_bool_field(
5719                    ui,
5720                    "worktree",
5721                    &mut worktree,
5722                    "Automatic worktree creation. When enabled, creates a dedicated worktree for each run, enabling parallel sessions.",
5723                ) {
5724                    bool_changes.push((ConfigBoolField::Worktree, worktree));
5725                }
5726
5727                ui.add_space(spacing::SM);
5728
5729                // Editable text field with real-time validation (US-007)
5730                if let Some(new_value) = self.render_config_text_field(
5731                    ui,
5732                    "worktree_path_pattern",
5733                    &mut worktree_path_pattern,
5734                    "Pattern for worktree directory names. Placeholders: {repo} = repository name, {branch} = branch name.",
5735                ) {
5736                    text_changes.push((ConfigTextField::WorktreePathPattern, new_value));
5737                }
5738
5739                ui.add_space(spacing::SM);
5740
5741                if self.render_config_bool_field(
5742                    ui,
5743                    "worktree_cleanup",
5744                    &mut worktree_cleanup,
5745                    "Automatic worktree cleanup. When enabled, removes worktrees after successful completion. Failed runs keep their worktrees.",
5746                ) {
5747                    bool_changes.push((ConfigBoolField::WorktreeCleanup, worktree_cleanup));
5748                }
5749
5750                // Add some padding before the reset button
5751                ui.add_space(spacing::XXL);
5752
5753                // Reset to Defaults button (US-009)
5754                // Styled as a secondary/subtle action at the bottom of the editor
5755                if self.render_reset_to_defaults_button(ui) {
5756                    reset_clicked = true;
5757                }
5758
5759                // Add some padding at the bottom
5760                ui.add_space(spacing::XL);
5761            });
5762
5763        (bool_changes, text_changes, reset_clicked)
5764    }
5765
5766    /// Render the project config editor with all fields (US-004, US-006, US-007, US-008, US-009).
5767    ///
5768    /// Uses the same field layout and controls as the global config editor.
5769    /// The UI is identical but operates on the project-specific config file.
5770    /// Boolean fields are rendered as interactive toggle switches (US-006).
5771    /// Text fields are rendered as editable inputs with real-time validation (US-007).
5772    /// Includes "Reset to Defaults" button at the bottom (US-009).
5773    fn render_project_config_editor(
5774        &self,
5775        ui: &mut egui::Ui,
5776        project_name: &str,
5777    ) -> (BoolFieldChanges, TextFieldChanges, bool) {
5778        let mut bool_changes: Vec<(ConfigBoolField, bool)> = Vec::new();
5779        let mut text_changes: Vec<(ConfigTextField, String)> = Vec::new();
5780        let mut reset_clicked = false;
5781
5782        // Show error if config failed to load
5783        if let Some(error) = &self.config_state.project_config_error {
5784            ui.add_space(spacing::MD);
5785            ui.label(
5786                egui::RichText::new(error)
5787                    .font(typography::font(FontSize::Body, FontWeight::Regular))
5788                    .color(colors::STATUS_ERROR),
5789            );
5790            return (bool_changes, text_changes, reset_clicked);
5791        }
5792
5793        // Show loading state or editor
5794        let Some(config) = self.cached_project_config(project_name) else {
5795            ui.add_space(spacing::MD);
5796            ui.label(
5797                egui::RichText::new("Loading configuration...")
5798                    .font(typography::font(FontSize::Body, FontWeight::Regular))
5799                    .color(colors::TEXT_MUTED),
5800            );
5801            return (bool_changes, text_changes, reset_clicked);
5802        };
5803
5804        // Create mutable copies of boolean fields for toggle interaction (US-006)
5805        let mut review = config.review;
5806        let mut commit = config.commit;
5807        let mut pull_request = config.pull_request;
5808        let mut pull_request_draft = config.pull_request_draft;
5809        let mut worktree = config.worktree;
5810        let mut worktree_cleanup = config.worktree_cleanup;
5811
5812        // Create mutable copy of text field for editing (US-007)
5813        let mut worktree_path_pattern = config.worktree_path_pattern.clone();
5814
5815        // ScrollArea for config fields
5816        egui::ScrollArea::vertical()
5817            .id_salt("project_config_editor")
5818            .auto_shrink([false, false])
5819            .show(ui, |ui| {
5820                // Pipeline Settings Group
5821                self.render_config_group_header(ui, "Pipeline");
5822                ui.add_space(spacing::SM);
5823
5824                if self.render_config_bool_field(
5825                    ui,
5826                    "review",
5827                    &mut review,
5828                    "Code review before committing. When enabled, changes are reviewed for quality before being committed.",
5829                ) {
5830                    bool_changes.push((ConfigBoolField::Review, review));
5831                }
5832
5833                ui.add_space(spacing::SM);
5834
5835                // Commit toggle - when disabling commit while pull_request is true,
5836                // cascade by also disabling pull_request (US-008)
5837                if self.render_config_bool_field(
5838                    ui,
5839                    "commit",
5840                    &mut commit,
5841                    "Automatic git commits. When enabled, changes are automatically committed after implementation.",
5842                ) {
5843                    bool_changes.push((ConfigBoolField::Commit, commit));
5844                    // Cascade: if commit is now false and pull_request was true, disable pull_request too
5845                    if !commit && pull_request {
5846                        pull_request = false;
5847                        bool_changes.push((ConfigBoolField::PullRequest, false));
5848                        // Also cascade to pull_request_draft
5849                        if pull_request_draft {
5850                            pull_request_draft = false;
5851                            bool_changes.push((ConfigBoolField::PullRequestDraft, false));
5852                        }
5853                    }
5854                }
5855
5856                ui.add_space(spacing::SM);
5857
5858                // Pull request toggle - disabled when commit is false (US-008)
5859                // Shows tooltip explaining why it's disabled
5860                if self.render_config_bool_field_with_disabled(
5861                    ui,
5862                    "pull_request",
5863                    &mut pull_request,
5864                    "Automatic PR creation. When enabled, a pull request is created after committing. Requires commit to be enabled.",
5865                    !commit, // disabled when commit is false
5866                    Some("Pull requests require commits to be enabled"),
5867                ) {
5868                    bool_changes.push((ConfigBoolField::PullRequest, pull_request));
5869                    // Cascade: if pull_request is now false and pull_request_draft was true, disable it too
5870                    if !pull_request && pull_request_draft {
5871                        pull_request_draft = false;
5872                        bool_changes.push((ConfigBoolField::PullRequestDraft, false));
5873                    }
5874                }
5875
5876                ui.add_space(spacing::SM);
5877
5878                // Pull request draft toggle - disabled when pull_request is false
5879                // Shows tooltip explaining why it's disabled
5880                if self.render_config_bool_field_with_disabled(
5881                    ui,
5882                    "pull_request_draft",
5883                    &mut pull_request_draft,
5884                    "Create PRs as drafts. When enabled, PRs are created in draft mode (not ready for review). Requires pull_request to be enabled.",
5885                    !pull_request, // disabled when pull_request is false
5886                    Some("Draft PRs require pull requests to be enabled"),
5887                ) {
5888                    bool_changes.push((ConfigBoolField::PullRequestDraft, pull_request_draft));
5889                }
5890
5891                ui.add_space(spacing::XL);
5892
5893                // Worktree Settings Group
5894                self.render_config_group_header(ui, "Worktree");
5895                ui.add_space(spacing::SM);
5896
5897                if self.render_config_bool_field(
5898                    ui,
5899                    "worktree",
5900                    &mut worktree,
5901                    "Automatic worktree creation. When enabled, creates a dedicated worktree for each run, enabling parallel sessions.",
5902                ) {
5903                    bool_changes.push((ConfigBoolField::Worktree, worktree));
5904                }
5905
5906                ui.add_space(spacing::SM);
5907
5908                // Editable text field with real-time validation (US-007)
5909                if let Some(new_value) = self.render_config_text_field(
5910                    ui,
5911                    "worktree_path_pattern",
5912                    &mut worktree_path_pattern,
5913                    "Pattern for worktree directory names. Placeholders: {repo} = repository name, {branch} = branch name.",
5914                ) {
5915                    text_changes.push((ConfigTextField::WorktreePathPattern, new_value));
5916                }
5917
5918                ui.add_space(spacing::SM);
5919
5920                if self.render_config_bool_field(
5921                    ui,
5922                    "worktree_cleanup",
5923                    &mut worktree_cleanup,
5924                    "Automatic worktree cleanup. When enabled, removes worktrees after successful completion. Failed runs keep their worktrees.",
5925                ) {
5926                    bool_changes.push((ConfigBoolField::WorktreeCleanup, worktree_cleanup));
5927                }
5928
5929                // Add some padding before the reset button
5930                ui.add_space(spacing::XXL);
5931
5932                // Reset to Defaults button (US-009)
5933                // Styled as a secondary/subtle action at the bottom of the editor
5934                if self.render_reset_to_defaults_button(ui) {
5935                    reset_clicked = true;
5936                }
5937
5938                // Add some padding at the bottom
5939                ui.add_space(spacing::XL);
5940            });
5941
5942        (bool_changes, text_changes, reset_clicked)
5943    }
5944
5945    /// Render a config group header.
5946    fn render_config_group_header(&self, ui: &mut egui::Ui, title: &str) {
5947        ui.label(
5948            egui::RichText::new(title)
5949                .font(typography::font(FontSize::Heading, FontWeight::SemiBold))
5950                .color(colors::TEXT_PRIMARY),
5951        );
5952    }
5953
5954    /// Render a boolean config field with an interactive toggle switch (US-006, US-008).
5955    ///
5956    /// Displays the field with a toggle switch (not a checkbox) that can be clicked
5957    /// to change the value. The toggle provides visual feedback matching the app's style.
5958    /// Returns `true` if the toggle was clicked (value changed).
5959    ///
5960    /// # Arguments
5961    ///
5962    /// * `ui` - The egui UI context
5963    /// * `name` - The field name to display
5964    /// * `value` - The current boolean value (mutable reference for toggle_value)
5965    /// * `help_text` - Descriptive help text shown below the field
5966    ///
5967    /// # Returns
5968    ///
5969    /// `true` if the toggle was clicked and the value changed, `false` otherwise.
5970    fn render_config_bool_field(
5971        &self,
5972        ui: &mut egui::Ui,
5973        name: &str,
5974        value: &mut bool,
5975        help_text: &str,
5976    ) -> bool {
5977        self.render_config_bool_field_with_disabled(ui, name, value, help_text, false, None)
5978    }
5979
5980    /// Render a boolean config field with optional disabled state and tooltip (US-008).
5981    ///
5982    /// When disabled, the toggle is greyed out, non-interactive, and shows a tooltip
5983    /// explaining why. This is used for validation constraints like `pull_request`
5984    /// requiring `commit` to be enabled.
5985    ///
5986    /// # Arguments
5987    ///
5988    /// * `ui` - The egui UI context
5989    /// * `name` - The field name to display
5990    /// * `value` - The current boolean value (mutable reference for toggle_value)
5991    /// * `help_text` - Descriptive help text shown below the field
5992    /// * `disabled` - If true, the toggle is greyed out and non-interactive
5993    /// * `disabled_tooltip` - Tooltip text shown when hovering over a disabled toggle
5994    ///
5995    /// # Returns
5996    ///
5997    /// `true` if the toggle was clicked and the value changed, `false` otherwise.
5998    fn render_config_bool_field_with_disabled(
5999        &self,
6000        ui: &mut egui::Ui,
6001        name: &str,
6002        value: &mut bool,
6003        help_text: &str,
6004        disabled: bool,
6005        disabled_tooltip: Option<&str>,
6006    ) -> bool {
6007        let original_value = *value;
6008
6009        ui.horizontal(|ui| {
6010            // Field name - use disabled color if disabled
6011            let text_color = if disabled {
6012                colors::TEXT_DISABLED
6013            } else {
6014                colors::TEXT_PRIMARY
6015            };
6016            ui.label(
6017                egui::RichText::new(name)
6018                    .font(typography::font(FontSize::Body, FontWeight::Medium))
6019                    .color(text_color),
6020            );
6021
6022            ui.add_space(spacing::SM);
6023
6024            // Interactive toggle switch (US-006) or disabled toggle (US-008)
6025            if disabled {
6026                let response = ui.add(Self::toggle_switch_disabled(*value));
6027                // Show tooltip on hover when disabled (US-008)
6028                if let Some(tooltip) = disabled_tooltip {
6029                    response.on_hover_text(tooltip);
6030                }
6031            } else {
6032                ui.add(Self::toggle_switch(value));
6033            }
6034        });
6035
6036        // Help text below the field - use disabled color if disabled
6037        let help_color = if disabled {
6038            colors::TEXT_DISABLED
6039        } else {
6040            colors::TEXT_MUTED
6041        };
6042        ui.label(
6043            egui::RichText::new(help_text)
6044                .font(typography::font(FontSize::Small, FontWeight::Regular))
6045                .color(help_color),
6046        );
6047
6048        // Return whether the value changed
6049        *value != original_value
6050    }
6051
6052    /// Create an iOS/macOS style toggle switch widget (US-006).
6053    ///
6054    /// This creates a toggle switch that looks like a slider/pill shape rather
6055    /// than a checkbox, matching modern UI conventions.
6056    fn toggle_switch(on: &mut bool) -> impl egui::Widget + '_ {
6057        move |ui: &mut egui::Ui| -> egui::Response {
6058            // Toggle dimensions - slightly smaller than standard for config fields
6059            let desired_size = Vec2::new(36.0, 20.0);
6060
6061            // Allocate space and handle interaction
6062            let (rect, mut response) = ui.allocate_exact_size(desired_size, Sense::click());
6063
6064            // Handle click
6065            if response.clicked() {
6066                *on = !*on;
6067                response.mark_changed();
6068            }
6069
6070            // Draw the toggle
6071            if ui.is_rect_visible(rect) {
6072                let how_on = ui.ctx().animate_bool_responsive(response.id, *on);
6073                let visuals = ui.style().interact_selectable(&response, *on);
6074
6075                // Background pill shape
6076                let rect = rect.expand(visuals.expansion);
6077                let radius = 0.5 * rect.height();
6078
6079                // Use accent color when on, muted when off
6080                let bg_color = if *on {
6081                    colors::ACCENT_SUBTLE
6082                } else {
6083                    colors::SURFACE_HOVER
6084                };
6085                ui.painter()
6086                    .rect_filled(rect, Rounding::same(radius), bg_color);
6087
6088                // Border
6089                let border_color = if *on { colors::ACCENT } else { colors::BORDER };
6090                ui.painter().rect_stroke(
6091                    rect,
6092                    Rounding::same(radius),
6093                    Stroke::new(1.0, border_color),
6094                );
6095
6096                // Circle knob
6097                let circle_x = egui::lerp((rect.left() + radius)..=(rect.right() - radius), how_on);
6098                let center = egui::pos2(circle_x, rect.center().y);
6099                let knob_radius = radius * 0.75;
6100
6101                // Knob shadow for depth
6102                ui.painter().circle_filled(
6103                    center + egui::vec2(0.5, 0.5),
6104                    knob_radius,
6105                    Color32::from_black_alpha(30),
6106                );
6107
6108                // Knob
6109                ui.painter()
6110                    .circle_filled(center, knob_radius, colors::TEXT_PRIMARY);
6111            }
6112
6113            response
6114        }
6115    }
6116
6117    /// Create a disabled iOS/macOS style toggle switch widget (US-008).
6118    ///
6119    /// This creates a non-interactive toggle that displays the current value
6120    /// but cannot be clicked. It uses greyed-out colors to indicate the disabled state.
6121    /// Used for validation constraints (e.g., pull_request requires commit to be enabled).
6122    fn toggle_switch_disabled(on: bool) -> impl egui::Widget {
6123        move |ui: &mut egui::Ui| -> egui::Response {
6124            // Toggle dimensions - same as regular toggle
6125            let desired_size = Vec2::new(36.0, 20.0);
6126
6127            // Allocate space but with hover sense only (no click)
6128            // This allows the tooltip to work
6129            let (rect, response) = ui.allocate_exact_size(desired_size, Sense::hover());
6130
6131            // Draw the toggle in disabled state
6132            if ui.is_rect_visible(rect) {
6133                // Animate based on current value (but won't change)
6134                let how_on = ui.ctx().animate_bool_responsive(response.id, on);
6135
6136                // Background pill shape
6137                let radius = 0.5 * rect.height();
6138
6139                // Use very muted colors for disabled state
6140                let bg_color = colors::SURFACE_HOVER;
6141                ui.painter()
6142                    .rect_filled(rect, Rounding::same(radius), bg_color);
6143
6144                // Border - use disabled/muted color
6145                ui.painter().rect_stroke(
6146                    rect,
6147                    Rounding::same(radius),
6148                    Stroke::new(1.0, colors::BORDER),
6149                );
6150
6151                // Circle knob - positioned based on value but greyed out
6152                let circle_x = egui::lerp((rect.left() + radius)..=(rect.right() - radius), how_on);
6153                let center = egui::pos2(circle_x, rect.center().y);
6154                let knob_radius = radius * 0.75;
6155
6156                // No shadow for disabled state (flatter appearance)
6157
6158                // Knob - use disabled color
6159                ui.painter()
6160                    .circle_filled(center, knob_radius, colors::TEXT_DISABLED);
6161            }
6162
6163            response
6164        }
6165    }
6166
6167    /// Render a text config field with label, editable input, and help text (US-007).
6168    ///
6169    /// The text input allows inline editing with real-time validation.
6170    /// For `worktree_path_pattern`, warns if `{repo}` or `{branch}` placeholders are missing.
6171    /// Invalid patterns are still saved (warning only, not blocking).
6172    ///
6173    /// Returns `Some(new_value)` if the text was changed, `None` otherwise.
6174    fn render_config_text_field(
6175        &self,
6176        ui: &mut egui::Ui,
6177        name: &str,
6178        value: &mut String,
6179        help_text: &str,
6180    ) -> Option<String> {
6181        let mut changed_value: Option<String> = None;
6182
6183        ui.horizontal(|ui| {
6184            // Field name
6185            ui.label(
6186                egui::RichText::new(name)
6187                    .font(typography::font(FontSize::Body, FontWeight::Medium))
6188                    .color(colors::TEXT_PRIMARY),
6189            );
6190
6191            ui.add_space(spacing::SM);
6192
6193            // Editable text input (US-007)
6194            let text_edit = egui::TextEdit::singleline(value)
6195                .font(typography::mono(FontSize::Body))
6196                .text_color(colors::TEXT_SECONDARY)
6197                .desired_width(250.0);
6198
6199            let response = ui.add(text_edit);
6200            if response.changed() {
6201                changed_value = Some(value.clone());
6202            }
6203        });
6204
6205        // Help text below the field
6206        ui.label(
6207            egui::RichText::new(help_text)
6208                .font(typography::font(FontSize::Small, FontWeight::Regular))
6209                .color(colors::TEXT_MUTED),
6210        );
6211
6212        // Real-time validation for worktree_path_pattern (US-007)
6213        if name == "worktree_path_pattern" {
6214            let mut warnings: Vec<&str> = Vec::new();
6215
6216            if !value.contains("{repo}") {
6217                warnings.push("Missing {repo} placeholder");
6218            }
6219            if !value.contains("{branch}") {
6220                warnings.push("Missing {branch} placeholder");
6221            }
6222
6223            // Display validation warnings in amber/warning color
6224            if !warnings.is_empty() {
6225                ui.add_space(spacing::XS);
6226                for warning in warnings {
6227                    ui.horizontal(|ui| {
6228                        ui.label(
6229                            egui::RichText::new("⚠")
6230                                .font(typography::font(FontSize::Small, FontWeight::Regular))
6231                                .color(colors::STATUS_WARNING),
6232                        );
6233                        ui.add_space(spacing::XS);
6234                        ui.label(
6235                            egui::RichText::new(warning)
6236                                .font(typography::font(FontSize::Small, FontWeight::Regular))
6237                                .color(colors::STATUS_WARNING),
6238                        );
6239                    });
6240                }
6241            }
6242        }
6243
6244        changed_value
6245    }
6246
6247    // ========================================================================
6248    // Create Spec View (US-001: Add Create Spec Tab to Sidebar Navigation)
6249    // ========================================================================
6250
6251    /// Render the Create Spec view.
6252    ///
6253    /// This view provides a conversational interface for creating specification
6254    /// files with Claude. Users select a project from a dropdown, then interact
6255    /// with Claude to define their feature specification.
6256    ///
6257    /// Implementation status:
6258    /// - US-002: Project selection dropdown at the top
6259    /// - US-003: Chat-style message display area
6260    /// - US-004: Text input with send button at the bottom
6261    fn render_create_spec(&mut self, ui: &mut egui::Ui) {
6262        // Header
6263        ui.label(
6264            egui::RichText::new("Create Spec")
6265                .font(typography::font(FontSize::Title, FontWeight::SemiBold))
6266                .color(colors::TEXT_PRIMARY),
6267        );
6268
6269        ui.add_space(spacing::MD);
6270
6271        // Project selection dropdown (US-002)
6272        self.render_create_spec_project_dropdown(ui);
6273
6274        ui.add_space(spacing::LG);
6275
6276        // Main content area - varies based on state
6277        if self.projects.is_empty() {
6278            // No projects registered - show in scroll area
6279            egui::ScrollArea::vertical()
6280                .auto_shrink([false, false])
6281                .show(ui, |ui| {
6282                    ui.add_space(spacing::LG);
6283                    self.render_create_spec_no_projects(ui);
6284                });
6285        } else if self.create_spec_selected_project.is_none() {
6286            // Projects exist but none selected
6287            egui::ScrollArea::vertical()
6288                .auto_shrink([false, false])
6289                .show(ui, |ui| {
6290                    ui.add_space(spacing::LG);
6291                    self.render_create_spec_select_prompt(ui);
6292                });
6293        } else {
6294            // Project selected - show chat interface with input bar (US-003, US-004)
6295            // Use a vertical layout with the chat area taking remaining space
6296            // and the input bar fixed at the bottom with generous padding
6297            let available_height = ui.available_height();
6298
6299            // Reserve space for: separator + input bar + bottom padding
6300            let bottom_padding = spacing::XXL + spacing::XL; // 56px
6301            let separator_height = spacing::SM * 2.0 + 1.0; // spacing before + after + line
6302            let input_bar_height = INPUT_BAR_HEIGHT + spacing::MD;
6303            let reserved_bottom = input_bar_height + separator_height + bottom_padding;
6304
6305            // Chat area takes available height minus reserved bottom space
6306            ui.allocate_ui(
6307                egui::vec2(ui.available_width(), available_height - reserved_bottom),
6308                |ui| {
6309                    self.render_create_spec_chat_area(ui);
6310                },
6311            );
6312
6313            // Separator before input bar
6314            ui.add_space(spacing::SM);
6315            ui.separator();
6316            ui.add_space(spacing::SM);
6317
6318            // Input bar at the bottom (US-004)
6319            self.render_create_spec_input_bar(ui);
6320
6321            // Bottom padding (space already reserved above)
6322            ui.add_space(bottom_padding);
6323        }
6324    }
6325
6326    /// Render the project selection dropdown for the Create Spec tab (US-002, US-008).
6327    ///
6328    /// US-008: If user tries to change project while a session is active,
6329    /// show a confirmation modal before switching.
6330    fn render_create_spec_project_dropdown(&mut self, ui: &mut egui::Ui) {
6331        ui.horizontal(|ui| {
6332            ui.label(
6333                egui::RichText::new("Project:")
6334                    .font(typography::font(FontSize::Body, FontWeight::Medium))
6335                    .color(colors::TEXT_PRIMARY),
6336            );
6337
6338            ui.add_space(spacing::SM);
6339
6340            // Determine the display text for the dropdown
6341            let selected_text = self
6342                .create_spec_selected_project
6343                .as_deref()
6344                .unwrap_or("Select a project...");
6345
6346            // Create the ComboBox
6347            let combo_id = ui.make_persistent_id("create_spec_project_dropdown");
6348            egui::ComboBox::from_id_salt(combo_id)
6349                .selected_text(selected_text)
6350                .width(250.0)
6351                .show_ui(ui, |ui| {
6352                    // List all available projects
6353                    for project in &self.projects {
6354                        let project_name = &project.info.name;
6355                        let is_selected =
6356                            self.create_spec_selected_project.as_ref() == Some(project_name);
6357
6358                        if ui.selectable_label(is_selected, project_name).clicked() {
6359                            // US-008: Check if changing project while session is active
6360                            let is_different_project =
6361                                self.create_spec_selected_project.as_ref() != Some(project_name);
6362
6363                            if is_different_project && self.has_active_spec_session() {
6364                                // Store pending project change and show confirmation modal
6365                                self.pending_project_change = Some(project_name.clone());
6366                            } else {
6367                                // No active session or same project - just switch
6368                                self.create_spec_selected_project = Some(project_name.clone());
6369                            }
6370                        }
6371                    }
6372                });
6373
6374            // US-008: "Start Over" button when session is active
6375            if self.has_active_spec_session() {
6376                ui.add_space(spacing::MD);
6377                let start_over_btn = egui::Button::new(
6378                    egui::RichText::new("Start Over")
6379                        .font(typography::font(FontSize::Small, FontWeight::Medium))
6380                        .color(colors::TEXT_SECONDARY),
6381                )
6382                .fill(colors::SURFACE_ELEVATED)
6383                .stroke(Stroke::new(1.0, colors::BORDER))
6384                .rounding(Rounding::same(rounding::BUTTON));
6385
6386                if ui.add(start_over_btn).clicked() {
6387                    self.reset_create_spec_session();
6388                }
6389            }
6390        });
6391
6392        // Show selected project info if one is selected
6393        if let Some(ref project_name) = self.create_spec_selected_project {
6394            ui.add_space(spacing::XS);
6395            ui.label(
6396                egui::RichText::new(format!("Selected: {}", project_name))
6397                    .font(typography::font(FontSize::Small, FontWeight::Regular))
6398                    .color(colors::TEXT_SECONDARY),
6399            );
6400        }
6401    }
6402
6403    /// Render message when no projects are registered (US-002).
6404    fn render_create_spec_no_projects(&self, ui: &mut egui::Ui) {
6405        ui.vertical_centered(|ui| {
6406            ui.add_space(spacing::XL);
6407
6408ui.label(
6409                egui::RichText::new("No Projects Registered")
6410                    .font(typography::font(FontSize::Heading, FontWeight::SemiBold))
6411                    .color(colors::TEXT_PRIMARY),
6412            );
6413
6414            ui.add_space(spacing::SM);
6415
6416            let message = "No projects registered. Run `autom8` at least once in any repository to register it.";
6417            ui.label(
6418                egui::RichText::new(message)
6419                    .font(typography::font(FontSize::Body, FontWeight::Regular))
6420                    .color(colors::TEXT_SECONDARY),
6421            );
6422        });
6423    }
6424
6425    /// Render prompt to select a project (US-002).
6426    fn render_create_spec_select_prompt(&self, ui: &mut egui::Ui) {
6427        ui.vertical_centered(|ui| {
6428            ui.add_space(spacing::XXL);
6429
6430            ui.label(
6431                egui::RichText::new("Create a New Specification")
6432                    .font(typography::font(FontSize::Heading, FontWeight::SemiBold))
6433                    .color(colors::TEXT_PRIMARY),
6434            );
6435
6436            ui.add_space(spacing::SM);
6437
6438            ui.label(
6439                egui::RichText::new("Select a project to begin creating a spec")
6440                    .font(typography::font(FontSize::Body, FontWeight::Regular))
6441                    .color(colors::TEXT_SECONDARY),
6442            );
6443
6444            ui.add_space(spacing::SM);
6445
6446            ui.label(
6447                egui::RichText::new("Note that this is in beta, the more reliable way is to use the CLI by simply running autom8 in your project directory.")
6448                    .font(typography::font(FontSize::Body, FontWeight::Regular))
6449                    .color(colors::TEXT_SECONDARY),
6450            );
6451
6452            ui.add_space(spacing::LG);
6453
6454            // Registration hint
6455            ui.label(
6456                egui::RichText::new(
6457                    "To register a new project, run `autom8` from the project directory",
6458                )
6459                .font(typography::font(FontSize::Caption, FontWeight::Regular))
6460                .color(colors::TEXT_MUTED),
6461            );
6462        });
6463    }
6464
6465    /// Render the chat area for the Create Spec tab (US-003, US-005).
6466    ///
6467    /// Displays a scrollable message area with:
6468    /// - User messages aligned to the right in rounded bubbles
6469    /// - Claude messages aligned to the left with clean typography
6470    /// - Empty state prompt when no messages exist
6471    /// - Auto-scroll to bottom when new messages arrive
6472    /// - Typing indicator when Claude is processing (US-005)
6473    /// - Error message with retry button if Claude fails (US-005)
6474    fn render_create_spec_chat_area(&mut self, ui: &mut egui::Ui) {
6475        // Calculate available width for chat bubbles
6476        let available_width = ui.available_width();
6477        let max_bubble_width = available_width * CHAT_BUBBLE_MAX_WIDTH_RATIO;
6478
6479        // Create scroll area for messages
6480        let scroll_id = ui.make_persistent_id("create_spec_chat_scroll");
6481        let mut scroll_area = egui::ScrollArea::vertical()
6482            .id_salt(scroll_id)
6483            .auto_shrink([false, false])
6484            .stick_to_bottom(true);
6485
6486        // Handle auto-scroll to bottom
6487        if self.chat_scroll_to_bottom {
6488            scroll_area = scroll_area
6489                .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysVisible);
6490        }
6491
6492        // Track if we need to trigger a retry action
6493        let mut should_retry = false;
6494        // Track if we need to trigger confirm or start new actions (US-007)
6495        let mut should_confirm_spec = false;
6496        let mut should_start_new = false;
6497
6498        scroll_area.show(ui, |ui| {
6499            ui.add_space(spacing::LG);
6500
6501            if self.chat_messages.is_empty() && self.claude_error.is_none() {
6502                // Empty state - show prompt
6503                self.render_chat_empty_state(ui);
6504            } else {
6505                // Render all messages
6506                for (index, message) in self.chat_messages.iter().enumerate() {
6507                    self.render_chat_message(ui, message, max_bubble_width, index);
6508                    ui.add_space(CHAT_MESSAGE_SPACING);
6509                }
6510
6511                // US-009: Show starting indicator when Claude is being spawned
6512                if self.claude_starting {
6513                    ui.add_space(CHAT_MESSAGE_SPACING);
6514                    self.render_starting_indicator(ui);
6515                } else if self.is_waiting_for_claude {
6516                    // Show typing indicator when Claude is processing (US-005)
6517                    ui.add_space(CHAT_MESSAGE_SPACING);
6518                    self.render_typing_indicator(ui);
6519                }
6520
6521                // Show error message with retry button if Claude failed (US-005)
6522                if let Some(ref error) = self.claude_error {
6523                    ui.add_space(CHAT_MESSAGE_SPACING);
6524                    should_retry = self.render_claude_error(ui, error, max_bubble_width);
6525                }
6526
6527                // US-007: Show spec completion UI when Claude finishes and spec was detected
6528                if self.claude_finished && self.generated_spec_path.is_some() {
6529                    ui.add_space(CHAT_MESSAGE_SPACING);
6530                    let (confirm, start_new) = self.render_spec_completion_ui(ui, max_bubble_width);
6531                    should_confirm_spec = confirm;
6532                    should_start_new = start_new;
6533                }
6534            }
6535
6536            // Add some bottom padding
6537            ui.add_space(spacing::XL);
6538        });
6539
6540        // Handle retry action outside the scroll area closure
6541        if should_retry {
6542            self.retry_claude();
6543        }
6544
6545        // Handle spec confirmation actions outside the scroll area closure (US-007)
6546        if should_confirm_spec {
6547            self.confirm_spec();
6548        }
6549        if should_start_new {
6550            // Show confirmation modal instead of immediately resetting
6551            self.pending_start_new_spec = true;
6552        }
6553
6554        // Reset scroll flag after rendering
6555        if self.chat_scroll_to_bottom {
6556            self.chat_scroll_to_bottom = false;
6557        }
6558    }
6559
6560    /// Render typing indicator when Claude is processing (US-005).
6561    ///
6562    /// Shows an animated indicator on the left side (Claude's side)
6563    /// to indicate that Claude is thinking/generating a response.
6564    fn render_typing_indicator(&self, ui: &mut egui::Ui) {
6565        ui.with_layout(egui::Layout::left_to_right(egui::Align::TOP), |ui| {
6566            // Create a bubble-like frame for the indicator
6567            let frame = egui::Frame::none()
6568                .fill(CLAUDE_BUBBLE_COLOR)
6569                .rounding(Rounding::same(CHAT_BUBBLE_ROUNDING))
6570                .inner_margin(egui::Margin::symmetric(CHAT_BUBBLE_PADDING, spacing::SM))
6571                .stroke(Stroke::new(1.0, colors::BORDER));
6572
6573            frame.show(ui, |ui| {
6574                ui.horizontal(|ui| {
6575                    // Animated dots indicator
6576                    ui.spinner();
6577                    ui.add_space(spacing::SM);
6578                    ui.label(
6579                        egui::RichText::new("Claude is thinking...")
6580                            .font(typography::font(FontSize::Body, FontWeight::Regular))
6581                            .color(colors::TEXT_MUTED),
6582                    );
6583                });
6584            });
6585        });
6586    }
6587
6588    /// Render starting indicator when Claude subprocess is being spawned (US-009).
6589    ///
6590    /// Shows an animated spinner on the left side with "Starting Claude..."
6591    /// to indicate that the Claude process is being initialized.
6592    fn render_starting_indicator(&self, ui: &mut egui::Ui) {
6593        ui.with_layout(egui::Layout::left_to_right(egui::Align::TOP), |ui| {
6594            // Create a bubble-like frame for the indicator (same style as typing indicator)
6595            let frame = egui::Frame::none()
6596                .fill(CLAUDE_BUBBLE_COLOR)
6597                .rounding(Rounding::same(CHAT_BUBBLE_ROUNDING))
6598                .inner_margin(egui::Margin::symmetric(CHAT_BUBBLE_PADDING, spacing::SM))
6599                .stroke(Stroke::new(1.0, colors::BORDER));
6600
6601            frame.show(ui, |ui| {
6602                ui.horizontal(|ui| {
6603                    // Animated spinner
6604                    ui.spinner();
6605                    ui.add_space(spacing::SM);
6606                    ui.label(
6607                        egui::RichText::new("Starting Claude...")
6608                            .font(typography::font(FontSize::Body, FontWeight::Regular))
6609                            .color(colors::TEXT_MUTED),
6610                    );
6611                });
6612            });
6613        });
6614    }
6615
6616    /// Render Claude error message with retry button (US-005).
6617    ///
6618    /// Shows the error in a red-tinted bubble on the left side with
6619    /// a retry button that allows the user to try again.
6620    ///
6621    /// Returns true if the retry button was clicked.
6622    fn render_claude_error(&self, ui: &mut egui::Ui, error: &str, _max_bubble_width: f32) -> bool {
6623        let mut should_retry = false;
6624
6625        ui.with_layout(egui::Layout::left_to_right(egui::Align::TOP), |ui| {
6626            // Create an error-styled frame
6627            let frame = egui::Frame::none()
6628                .fill(colors::STATUS_ERROR_BG)
6629                .rounding(Rounding::same(CHAT_BUBBLE_ROUNDING))
6630                .inner_margin(egui::Margin::same(CHAT_BUBBLE_PADDING))
6631                .stroke(Stroke::new(1.0, colors::STATUS_ERROR));
6632
6633            frame.show(ui, |ui| {
6634                ui.vertical(|ui| {
6635                    // Error title
6636                    ui.label(
6637                        egui::RichText::new("Error")
6638                            .font(typography::font(FontSize::Body, FontWeight::SemiBold))
6639                            .color(colors::STATUS_ERROR),
6640                    );
6641
6642                    ui.add_space(spacing::XS);
6643
6644                    // Error message
6645                    ui.label(
6646                        egui::RichText::new(error)
6647                            .font(typography::font(FontSize::Body, FontWeight::Regular))
6648                            .color(colors::TEXT_PRIMARY),
6649                    );
6650
6651                    ui.add_space(spacing::SM);
6652
6653                    // Retry button
6654                    let retry_button = egui::Button::new(
6655                        egui::RichText::new("Retry")
6656                            .font(typography::font(FontSize::Body, FontWeight::Medium))
6657                            .color(colors::SURFACE),
6658                    )
6659                    .fill(colors::STATUS_ERROR)
6660                    .rounding(Rounding::same(spacing::SM));
6661
6662                    if ui.add(retry_button).clicked() {
6663                        should_retry = true;
6664                    }
6665                });
6666            });
6667        });
6668
6669        should_retry
6670    }
6671
6672    /// Render the input bar for the Create Spec tab (US-004).
6673    ///
6674    /// Features:
6675    /// - Rounded text input field with dynamic placeholder
6676    /// - Coral/orange send button on the right
6677    /// - Send button disabled when input is empty or waiting for Claude
6678    /// - Enter key sends message (Shift+Enter for newline)
6679    /// - Input clears after sending
6680    /// - Input disabled while waiting for Claude's response
6681    fn render_create_spec_input_bar(&mut self, ui: &mut egui::Ui) {
6682        // Determine placeholder text based on conversation state
6683        let placeholder = if self.chat_messages.is_empty() {
6684            "Describe the feature you want to build..."
6685        } else {
6686            "Reply..."
6687        };
6688
6689        // Check if send should be enabled
6690        let input_not_empty = !self.chat_input_text.trim().is_empty();
6691        let can_send = input_not_empty && !self.is_waiting_for_claude;
6692
6693        // Track if we should send (set by Enter key or button click)
6694        let mut should_send = false;
6695
6696        // Calculate the width for the text input area
6697        // Reserve space for: gap + button + right margin
6698        let total_width = ui.available_width();
6699        let button_area_width = spacing::SM + SEND_BUTTON_SIZE;
6700        let input_frame_width = total_width - button_area_width;
6701
6702        ui.horizontal(|ui| {
6703            // Input frame with fixed width
6704            let input_frame = egui::Frame::none()
6705                .fill(colors::SURFACE)
6706                .rounding(Rounding::same(INPUT_FIELD_ROUNDING))
6707                .stroke(Stroke::new(1.0, colors::BORDER))
6708                .inner_margin(egui::Margin::symmetric(spacing::MD, spacing::SM));
6709
6710            let frame_response = input_frame.show(ui, |ui| {
6711                // Set the frame to use the calculated width
6712                ui.set_width(input_frame_width - spacing::MD * 2.0 - 2.0);
6713
6714                // Scrollable text area with max height
6715                let max_input_height = 100.0;
6716
6717                egui::ScrollArea::vertical()
6718                    .max_height(max_input_height)
6719                    .show(ui, |ui| {
6720                        // Text input - use full available width
6721                        let text_edit = egui::TextEdit::multiline(&mut self.chat_input_text)
6722                            .hint_text(
6723                                egui::RichText::new(placeholder)
6724                                    .color(colors::TEXT_MUTED)
6725                                    .font(typography::font(FontSize::Body, FontWeight::Regular)),
6726                            )
6727                            .font(typography::font(FontSize::Body, FontWeight::Regular))
6728                            .text_color(colors::TEXT_PRIMARY)
6729                            .frame(false)
6730                            .desired_width(f32::INFINITY)
6731                            .desired_rows(1)
6732                            .lock_focus(true)
6733                            .interactive(!self.is_waiting_for_claude);
6734
6735                        let response = ui.add(text_edit);
6736
6737                        // Handle Enter key to send (Shift+Enter for newline)
6738                        if response.has_focus() && !self.is_waiting_for_claude {
6739                            let modifiers = ui.input(|i| i.modifiers);
6740                            let enter_pressed = ui.input(|i| i.key_pressed(egui::Key::Enter));
6741
6742                            if enter_pressed && !modifiers.shift && can_send {
6743                                should_send = true;
6744                            }
6745                        }
6746                    });
6747            });
6748
6749            // Get the height of the input frame for vertical centering of button
6750            let frame_height = frame_response.response.rect.height();
6751
6752            ui.add_space(spacing::SM);
6753
6754            // Send button - vertically centered with the input
6755            ui.vertical(|ui| {
6756                // Center the button vertically
6757                let button_vertical_offset = (frame_height - SEND_BUTTON_SIZE) / 2.0;
6758                if button_vertical_offset > 0.0 {
6759                    ui.add_space(button_vertical_offset);
6760                }
6761
6762                let (rect, response) = ui.allocate_exact_size(
6763                    egui::vec2(SEND_BUTTON_SIZE, SEND_BUTTON_SIZE),
6764                    egui::Sense::click(),
6765                );
6766
6767                if ui.is_rect_visible(rect) {
6768                    // Determine button color based on state
6769                    let actual_color = if !can_send {
6770                        SEND_BUTTON_DISABLED_COLOR
6771                    } else if response.hovered() {
6772                        SEND_BUTTON_HOVER_COLOR
6773                    } else {
6774                        SEND_BUTTON_COLOR
6775                    };
6776
6777                    // Draw circular button background
6778                    ui.painter().rect_filled(
6779                        rect,
6780                        Rounding::same(SEND_BUTTON_SIZE / 2.0),
6781                        actual_color,
6782                    );
6783
6784                    // Draw send arrow icon
6785                    let icon_color = Color32::WHITE;
6786                    let center = rect.center();
6787
6788                    let arrow_points = vec![
6789                        egui::pos2(center.x - 6.0, center.y - 5.0),
6790                        egui::pos2(center.x + 6.0, center.y),
6791                        egui::pos2(center.x - 6.0, center.y + 5.0),
6792                        egui::pos2(center.x - 3.0, center.y),
6793                    ];
6794                    ui.painter().add(egui::Shape::convex_polygon(
6795                        arrow_points,
6796                        icon_color,
6797                        Stroke::NONE,
6798                    ));
6799                }
6800
6801                if response.clicked() && can_send {
6802                    should_send = true;
6803                }
6804            });
6805
6806            // Show loading indicator when waiting for Claude
6807            if self.is_waiting_for_claude {
6808                ui.add_space(spacing::SM);
6809                ui.spinner();
6810            }
6811        });
6812
6813        // Handle sending the message
6814        if should_send {
6815            self.send_chat_message();
6816        }
6817    }
6818
6819    /// Send the current chat input as a user message (US-004, US-005).
6820    ///
6821    /// This method:
6822    /// 1. Takes the current input text
6823    /// 2. Adds it as a user message to the chat
6824    /// 3. Clears the input field
6825    /// 4. Triggers scroll to bottom
6826    /// 5. If this is the first message, spawns Claude subprocess
6827    /// 6. If Claude is already running, sends to Claude's stdin
6828    fn send_chat_message(&mut self) {
6829        let message = self.chat_input_text.trim().to_string();
6830        if message.is_empty() {
6831            return;
6832        }
6833
6834        // Don't send if already waiting for Claude
6835        if self.is_waiting_for_claude {
6836            return;
6837        }
6838
6839        // Add user message to chat
6840        self.add_user_message(&message);
6841
6842        // Clear input field
6843        self.chat_input_text.clear();
6844
6845        // Check if this is the first message (no Claude messages yet)
6846        let has_claude_response = self
6847            .chat_messages
6848            .iter()
6849            .any(|m| m.sender == ChatMessageSender::Claude);
6850
6851        // Spawn Claude for this message (new process each time with full context)
6852        self.spawn_claude_for_message(&message, !has_claude_response);
6853    }
6854
6855    /// Render the empty state for the chat area (US-003).
6856    ///
6857    /// Shows a subtle prompt encouraging the user to describe their feature.
6858    fn render_chat_empty_state(&self, ui: &mut egui::Ui) {
6859        ui.vertical_centered(|ui| {
6860            ui.add_space(spacing::XXL);
6861            ui.add_space(spacing::XXL);
6862
6863            ui.label(
6864                egui::RichText::new("Describe the feature you want to build...")
6865                    .font(typography::font(FontSize::Large, FontWeight::Regular))
6866                    .color(colors::TEXT_MUTED),
6867            );
6868
6869            ui.add_space(spacing::MD);
6870
6871            ui.label(
6872                egui::RichText::new("Claude will help you create a detailed specification")
6873                    .font(typography::font(FontSize::Body, FontWeight::Regular))
6874                    .color(colors::TEXT_DISABLED),
6875            );
6876        });
6877    }
6878
6879    /// Render a single chat message (US-003).
6880    ///
6881    /// User messages appear on the right in warm beige bubbles.
6882    /// Claude messages appear on the left in white bubbles with subtle shadow.
6883    fn render_chat_message(
6884        &self,
6885        ui: &mut egui::Ui,
6886        message: &ChatMessage,
6887        max_bubble_width: f32,
6888        message_index: usize,
6889    ) {
6890        let is_user = message.sender == ChatMessageSender::User;
6891
6892        // Layout direction based on sender
6893        if is_user {
6894            // User messages: right-aligned
6895            ui.with_layout(egui::Layout::right_to_left(egui::Align::TOP), |ui| {
6896                self.render_message_bubble(ui, message, max_bubble_width, message_index, true);
6897            });
6898        } else {
6899            // Claude messages: left-aligned
6900            ui.with_layout(egui::Layout::left_to_right(egui::Align::TOP), |ui| {
6901                self.render_message_bubble(ui, message, max_bubble_width, message_index, false);
6902            });
6903        }
6904    }
6905
6906    /// Render a message bubble with proper styling (US-003).
6907    fn render_message_bubble(
6908        &self,
6909        ui: &mut egui::Ui,
6910        message: &ChatMessage,
6911        max_bubble_width: f32,
6912        message_index: usize,
6913        is_user: bool,
6914    ) {
6915        let bubble_color = if is_user {
6916            USER_BUBBLE_COLOR
6917        } else {
6918            CLAUDE_BUBBLE_COLOR
6919        };
6920
6921        let text_color = colors::TEXT_PRIMARY;
6922
6923        // Calculate the actual text width needed for shrink-wrapping
6924        let font_id = typography::font(FontSize::Body, FontWeight::Regular);
6925        let content_max_width = max_bubble_width - CHAT_BUBBLE_PADDING * 2.0;
6926
6927        // Create a layout job to measure the text
6928        let mut job = egui::text::LayoutJob::single_section(
6929            message.content.clone(),
6930            egui::TextFormat {
6931                font_id: font_id.clone(),
6932                color: text_color,
6933                ..Default::default()
6934            },
6935        );
6936        job.wrap = egui::text::TextWrapping {
6937            max_width: content_max_width,
6938            ..Default::default()
6939        };
6940
6941        // Measure the galley to get actual text dimensions
6942        let galley = ui.fonts(|f| f.layout_job(job.clone()));
6943        let text_size = galley.rect.size();
6944
6945        // Calculate bubble dimensions - shrink to fit content
6946        let min_bubble_width = 50.0;
6947        let bubble_content_width = text_size.x.max(min_bubble_width).min(content_max_width);
6948
6949        // Also measure the "#N" indicator
6950        let order_text = format!("#{}", message_index + 1);
6951        let order_galley = ui.fonts(|f| {
6952            f.layout_no_wrap(
6953                order_text.clone(),
6954                typography::font(FontSize::Caption, FontWeight::Regular),
6955                colors::TEXT_DISABLED,
6956            )
6957        });
6958        let order_height = order_galley.rect.height();
6959
6960        // Total content height: text + spacing + order indicator
6961        let total_content_height = text_size.y + spacing::XS + order_height;
6962
6963        // Total bubble size including padding
6964        let bubble_width = bubble_content_width + CHAT_BUBBLE_PADDING * 2.0;
6965        let bubble_height = total_content_height + CHAT_BUBBLE_PADDING * 2.0;
6966
6967        // Allocate exact size for the bubble, then draw frame manually
6968        let (rect, _response) = ui.allocate_exact_size(
6969            egui::vec2(bubble_width, bubble_height),
6970            egui::Sense::hover(),
6971        );
6972
6973        if ui.is_rect_visible(rect) {
6974            let painter = ui.painter();
6975
6976            // Draw shadow for Claude messages
6977            if !is_user {
6978                let shadow = theme::shadow::subtle();
6979                let shadow_rect = rect.translate(shadow.offset);
6980                painter.rect_filled(
6981                    shadow_rect.expand(shadow.spread),
6982                    Rounding::same(CHAT_BUBBLE_ROUNDING),
6983                    shadow.color,
6984                );
6985            }
6986
6987            // Draw bubble background
6988            painter.rect_filled(rect, Rounding::same(CHAT_BUBBLE_ROUNDING), bubble_color);
6989
6990            // Draw border for Claude messages
6991            if !is_user {
6992                painter.rect_stroke(
6993                    rect,
6994                    Rounding::same(CHAT_BUBBLE_ROUNDING),
6995                    Stroke::new(1.0, colors::BORDER),
6996                );
6997            }
6998
6999            // Draw the text content
7000            let text_pos = rect.min + egui::vec2(CHAT_BUBBLE_PADDING, CHAT_BUBBLE_PADDING);
7001            painter.galley(text_pos, galley, text_color);
7002
7003            // Draw the order indicator
7004            let order_y = text_pos.y + text_size.y + spacing::XS;
7005            let order_x = if is_user {
7006                // Right-aligned for user
7007                rect.max.x - CHAT_BUBBLE_PADDING - order_galley.rect.width()
7008            } else {
7009                // Left-aligned for Claude
7010                text_pos.x
7011            };
7012            painter.galley(
7013                egui::pos2(order_x, order_y),
7014                order_galley,
7015                colors::TEXT_DISABLED,
7016            );
7017        }
7018    }
7019
7020    /// Add a message to the chat and trigger scroll to bottom (US-003).
7021    ///
7022    /// This method is used by other parts of the system to add messages
7023    /// to the conversation.
7024    #[allow(dead_code)]
7025    pub fn add_chat_message(&mut self, message: ChatMessage) {
7026        self.chat_messages.push(message);
7027        self.chat_scroll_to_bottom = true;
7028    }
7029
7030    /// Add a user message to the chat (US-003).
7031    #[allow(dead_code)]
7032    pub fn add_user_message(&mut self, content: impl Into<String>) {
7033        self.add_chat_message(ChatMessage::user(content));
7034    }
7035
7036    /// Add a Claude message to the chat (US-003).
7037    #[allow(dead_code)]
7038    pub fn add_claude_message(&mut self, content: impl Into<String>) {
7039        self.add_chat_message(ChatMessage::claude(content));
7040    }
7041
7042    /// Clear all chat messages (US-003).
7043    #[allow(dead_code)]
7044    pub fn clear_chat_messages(&mut self) {
7045        self.chat_messages.clear();
7046    }
7047
7048    // ========================================================================
7049    // Claude Process Integration (US-005)
7050    // ========================================================================
7051
7052    /// Spawn Claude to process a message and get a response.
7053    ///
7054    /// This method:
7055    /// 1. Builds a prompt with conversation context (for multi-turn)
7056    /// 2. Spawns `claude` CLI with proper arguments (--print, --output-format stream-json)
7057    /// 3. Writes prompt to stdin and closes it (required for Claude to process)
7058    /// 4. Sets up a background thread to stream and parse stdout
7059    /// 5. Each call spawns a new process (Claude CLI doesn't support persistent sessions)
7060    fn spawn_claude_for_message(&mut self, user_message: &str, is_first_message: bool) {
7061        use crate::claude::extract_text_from_stream_line;
7062        use std::io::{BufRead, BufReader, Write};
7063        use std::process::{Command, Stdio};
7064
7065        // Clear any previous error and reset state
7066        self.claude_error = None;
7067        self.claude_response_in_progress = false;
7068        self.last_claude_output_time = None;
7069        self.claude_finished = false;
7070
7071        // US-009: Mark that we're starting Claude (show "Starting Claude..." indicator)
7072        self.claude_starting = true;
7073        self.is_waiting_for_claude = true;
7074
7075        let tx = self.claude_tx.clone();
7076
7077        // Build the prompt with conversation context
7078        let prompt = if is_first_message {
7079            // First message: include system prompt
7080            format!(
7081                "{}\n\n---\n\nUser's request:\n\n{}\n",
7082                crate::prompts::SPEC_SKILL_PROMPT,
7083                user_message
7084            )
7085        } else {
7086            // Subsequent messages: include conversation history
7087            let mut context = format!(
7088                "{}\n\n---\n\nConversation so far:\n\n",
7089                crate::prompts::SPEC_SKILL_PROMPT
7090            );
7091            for msg in &self.chat_messages {
7092                match msg.sender {
7093                    ChatMessageSender::User => {
7094                        context.push_str(&format!("User: {}\n\n", msg.content));
7095                    }
7096                    ChatMessageSender::Claude => {
7097                        context.push_str(&format!("Assistant: {}\n\n", msg.content));
7098                    }
7099                }
7100            }
7101            context.push_str(&format!(
7102                "User: {}\n\nPlease continue the conversation and help refine the specification.",
7103                user_message
7104            ));
7105            context
7106        };
7107
7108        // Spawn the Claude CLI process with correct arguments
7109        let child_result = Command::new("claude")
7110            .args(["--print", "--output-format", "stream-json", "--verbose"])
7111            .stdin(Stdio::piped())
7112            .stdout(Stdio::piped())
7113            .stderr(Stdio::piped())
7114            .spawn();
7115
7116        let mut child = match child_result {
7117            Ok(child) => child,
7118            Err(e) => {
7119                let error_msg = if e.kind() == std::io::ErrorKind::NotFound {
7120                    "Claude CLI not found. Please install it from https://github.com/anthropics/claude-code".to_string()
7121                } else {
7122                    format!("Failed to spawn Claude: {}", e)
7123                };
7124                self.claude_error = Some(error_msg);
7125                self.is_waiting_for_claude = false;
7126                self.claude_starting = false;
7127                return;
7128            }
7129        };
7130
7131        // Write prompt to stdin and close it (required for Claude to start processing)
7132        if let Some(mut stdin) = child.stdin.take() {
7133            if let Err(e) = stdin.write_all(prompt.as_bytes()) {
7134                self.claude_error = Some(format!("Failed to write prompt to Claude: {}", e));
7135                self.is_waiting_for_claude = false;
7136                self.claude_starting = false;
7137                return;
7138            }
7139            // stdin is dropped here, closing the pipe - this signals Claude to process
7140        }
7141
7142        // Clear the stdin handle since we're not keeping it open
7143        self.claude_stdin = None;
7144
7145        // Take stdout and stderr handles before storing child
7146        let stdout = child.stdout.take();
7147        let stderr = child.stderr.take();
7148
7149        // Store the child process so it can be killed if needed
7150        let child_handle = self.claude_child.clone();
7151        {
7152            let mut guard = child_handle.lock().unwrap();
7153            *guard = Some(child);
7154        }
7155
7156        // Spawn background thread to read and parse output
7157        std::thread::spawn(move || {
7158            // Signal that Claude has started
7159            let _ = tx.send(ClaudeMessage::Started);
7160
7161            // Read stdout and parse stream-json format
7162            if let Some(stdout) = stdout {
7163                let reader = BufReader::new(stdout);
7164                for line in reader.lines() {
7165                    match line {
7166                        Ok(json_line) => {
7167                            // Parse stream-json and extract text content
7168                            if let Some(text) = extract_text_from_stream_line(&json_line) {
7169                                let _ = tx.send(ClaudeMessage::Output(text));
7170                            }
7171                        }
7172                        Err(_) => {
7173                            // Error reading - process may have been killed
7174                            break;
7175                        }
7176                    }
7177                }
7178            }
7179
7180            // Collect stderr for error reporting (don't send as output)
7181            let mut stderr_content = String::new();
7182            if let Some(stderr) = stderr {
7183                let reader = BufReader::new(stderr);
7184                for text in reader.lines().take(10).flatten() {
7185                    if !text.is_empty() {
7186                        stderr_content.push_str(&text);
7187                        stderr_content.push('\n');
7188                    }
7189                }
7190            }
7191
7192            // Wait for the process to finish (take it from the mutex)
7193            let mut guard = child_handle.lock().unwrap();
7194            if let Some(mut child) = guard.take() {
7195                match child.wait() {
7196                    Ok(status) => {
7197                        let success = status.success();
7198                        let error = if !success {
7199                            if stderr_content.is_empty() {
7200                                Some(format!("Claude exited with status: {}", status))
7201                            } else {
7202                                Some(format!("Claude error: {}", stderr_content.trim()))
7203                            }
7204                        } else {
7205                            None
7206                        };
7207                        let _ = tx.send(ClaudeMessage::Finished { success, error });
7208                    }
7209                    Err(e) => {
7210                        let _ = tx.send(ClaudeMessage::Finished {
7211                            success: false,
7212                            error: Some(format!("Failed to wait for Claude: {}", e)),
7213                        });
7214                    }
7215                }
7216            }
7217            // If child was already taken (killed), we just exit silently
7218        });
7219    }
7220
7221    /// Legacy wrapper for spawn_claude_for_message (for compatibility).
7222    fn spawn_claude_interactive(&mut self, initial_message: &str) {
7223        self.spawn_claude_for_message(initial_message, true);
7224    }
7225    /// Poll for Claude messages and update state.
7226    ///
7227    /// This should be called in the update loop to process messages from the
7228    /// Claude background thread.
7229    fn poll_claude_messages(&mut self) {
7230        // Timeout for detecting when Claude has paused (finished a response)
7231        const RESPONSE_PAUSE_TIMEOUT: Duration = Duration::from_millis(1500);
7232
7233        // Process all pending messages (non-blocking)
7234        while let Ok(msg) = self.claude_rx.try_recv() {
7235            match msg {
7236                ClaudeMessage::Spawning => {
7237                    // US-009: Claude is being spawned - already handled by spawn functions
7238                    // This message exists for consistency but state is set synchronously
7239                }
7240                ClaudeMessage::Started => {
7241                    // Claude has started - mark as receiving response and clear starting state
7242                    self.claude_starting = false;
7243                    self.claude_response_in_progress = true;
7244                }
7245                ClaudeMessage::Output(text) => {
7246                    // Append to the response buffer
7247                    if !self.claude_response_buffer.is_empty() {
7248                        self.claude_response_buffer.push('\n');
7249                    }
7250                    self.claude_response_buffer.push_str(&text);
7251
7252                    // US-007: Detect spec file path in Claude's output
7253                    self.detect_spec_path_in_output(&text);
7254
7255                    // Update last output time and mark response as in progress
7256                    self.last_claude_output_time = Some(Instant::now());
7257                    self.claude_response_in_progress = true;
7258                }
7259                ClaudeMessage::ResponsePaused => {
7260                    // Claude has paused - flush the buffer and allow user input
7261                    self.flush_claude_response_buffer();
7262                    self.is_waiting_for_claude = false;
7263                    self.claude_response_in_progress = false;
7264                }
7265                ClaudeMessage::Finished { success, error } => {
7266                    // Process has terminated - flush any remaining output
7267                    self.flush_claude_response_buffer();
7268
7269                    // Handle completion - process has exited
7270                    self.is_waiting_for_claude = false;
7271                    self.claude_starting = false;
7272                    self.claude_response_in_progress = false;
7273                    self.last_claude_output_time = None;
7274                    // Clear stdin handle since the process has terminated
7275                    self.claude_stdin = None;
7276
7277                    if success {
7278                        // US-007: Mark Claude as finished (for spec completion UI)
7279                        self.claude_finished = true;
7280                    } else if let Some(err) = error {
7281                        self.claude_error = Some(err);
7282                    }
7283                }
7284                ClaudeMessage::SpawnError(error) => {
7285                    // Show the error and clear starting state (US-009)
7286                    self.claude_error = Some(error);
7287                    self.is_waiting_for_claude = false;
7288                    self.claude_starting = false;
7289                    self.claude_response_in_progress = false;
7290                    self.claude_stdin = None;
7291                }
7292            }
7293        }
7294
7295        // Check for response pause timeout (Claude finished responding, waiting for user input)
7296        // Only check if we have an active stdin handle and received output recently
7297        if self.claude_stdin.is_some() && self.claude_response_in_progress {
7298            if let Some(last_output) = self.last_claude_output_time {
7299                if last_output.elapsed() >= RESPONSE_PAUSE_TIMEOUT {
7300                    // Claude has paused - flush buffer and allow user to respond
7301                    self.flush_claude_response_buffer();
7302                    self.is_waiting_for_claude = false;
7303                    self.claude_response_in_progress = false;
7304                }
7305            }
7306        }
7307    }
7308
7309    /// Flush the Claude response buffer to a chat message.
7310    ///
7311    /// This is called when we detect Claude has paused or finished responding.
7312    fn flush_claude_response_buffer(&mut self) {
7313        if !self.claude_response_buffer.is_empty() {
7314            let response = std::mem::take(&mut self.claude_response_buffer);
7315            self.add_claude_message(response);
7316        }
7317    }
7318
7319    /// Detect spec file path in Claude's output (US-007).
7320    ///
7321    /// Looks for patterns like:
7322    /// - `~/.config/autom8/<project>/spec/spec-<feature>.md`
7323    /// - Absolute paths like `/Users/.../autom8/<project>/spec/spec-<feature>.md`
7324    ///
7325    /// When detected, stores the path in `generated_spec_path`.
7326    fn detect_spec_path_in_output(&mut self, text: &str) {
7327        // Already found a spec path - don't overwrite
7328        if self.generated_spec_path.is_some() {
7329            return;
7330        }
7331
7332        // Helper to validate a potential spec path
7333        let is_valid_spec_path = |path_str: &str| -> bool {
7334            // Must contain the expected path structure
7335            if !path_str.contains("/spec/spec-") || !path_str.ends_with(".md") {
7336                return false;
7337            }
7338            // Must not contain control characters or be too long
7339            if path_str.chars().any(|c| c.is_control()) || path_str.len() > 500 {
7340                return false;
7341            }
7342            // Must look like a filesystem path (no spaces in filename, reasonable chars)
7343            let filename = path_str.rsplit('/').next().unwrap_or("");
7344            if filename.contains(' ') || filename.is_empty() {
7345                return false;
7346            }
7347            true
7348        };
7349
7350        // Pattern 1: Tilde-based path (~/.config/autom8/...)
7351        if let Some(start) = text.find("~/.config/autom8/") {
7352            if let Some(rel_end) = text[start..].find(".md") {
7353                let path_str = &text[start..start + rel_end + 3];
7354                if is_valid_spec_path(path_str) {
7355                    if let Some(home) = dirs::home_dir() {
7356                        let expanded = path_str.replacen("~", &home.to_string_lossy(), 1);
7357                        self.generated_spec_path = Some(std::path::PathBuf::from(expanded));
7358                        return;
7359                    }
7360                }
7361            }
7362        }
7363
7364        // Pattern 2: Absolute paths containing .config/autom8
7365        for word in text.split_whitespace() {
7366            // Clean up the word (remove quotes, backticks, punctuation)
7367            let cleaned = word.trim_matches(|c: char| {
7368                c == '"' || c == '\'' || c == '`' || c == '(' || c == ')' || c == ',' || c == ':'
7369            });
7370
7371            if cleaned.contains(".config/autom8/") && is_valid_spec_path(cleaned) {
7372                let path = std::path::PathBuf::from(cleaned);
7373                if path.is_absolute() {
7374                    self.generated_spec_path = Some(path);
7375                    return;
7376                } else if cleaned.starts_with('~') {
7377                    if let Some(home) = dirs::home_dir() {
7378                        let expanded = cleaned.replacen("~", &home.to_string_lossy(), 1);
7379                        self.generated_spec_path = Some(std::path::PathBuf::from(expanded));
7380                        return;
7381                    }
7382                }
7383            }
7384        }
7385    }
7386
7387    /// Check if Claude subprocess is currently running.
7388    ///
7389    /// Note: This method is prepared for US-006 (User Response Handling).
7390    #[allow(dead_code)]
7391    fn is_claude_running(&self) -> bool {
7392        self.is_waiting_for_claude
7393    }
7394
7395    /// Retry the last Claude operation after an error.
7396    ///
7397    /// Clears the error and respawns Claude with the first user message.
7398    /// Uses spawn_claude_interactive() to ensure multi-turn conversations work
7399    /// (stdin handle is retained for subsequent user messages).
7400    fn retry_claude(&mut self) {
7401        self.claude_error = None;
7402
7403        // Find the first user message to restart the conversation from the beginning
7404        let first_user_message = self
7405            .chat_messages
7406            .iter()
7407            .find(|m| m.sender == ChatMessageSender::User)
7408            .map(|m| m.content.clone());
7409
7410        if let Some(message) = first_user_message {
7411            // Always use interactive mode so stdin handle is retained for multi-turn
7412            self.spawn_claude_interactive(&message);
7413        }
7414    }
7415
7416    // ========================================================================
7417    // Spec Completion UI (US-007: Spec Completion and Confirmation)
7418    // ========================================================================
7419
7420    /// Render the spec completion UI when Claude finishes generating a spec.
7421    ///
7422    /// Shows:
7423    /// - Success message with spec file path
7424    /// - Green checkmark button to confirm
7425    /// - After confirmation: copy-able command to run the spec
7426    /// - "Close" button to reset the session
7427    ///
7428    /// Returns (should_confirm, should_start_new) to handle button clicks outside the closure.
7429    fn render_spec_completion_ui(&self, ui: &mut egui::Ui, _max_bubble_width: f32) -> (bool, bool) {
7430        let mut should_confirm = false;
7431        let mut should_start_new = false;
7432
7433        ui.with_layout(egui::Layout::left_to_right(egui::Align::TOP), |ui| {
7434            // Success-styled frame
7435            let frame = egui::Frame::none()
7436                .fill(colors::STATUS_SUCCESS_BG)
7437                .rounding(Rounding::same(CHAT_BUBBLE_ROUNDING))
7438                .inner_margin(egui::Margin::same(CHAT_BUBBLE_PADDING))
7439                .stroke(Stroke::new(1.0, colors::STATUS_SUCCESS));
7440
7441            frame.show(ui, |ui| {
7442                ui.vertical(|ui| {
7443                    if self.spec_confirmed {
7444                        // Post-confirmation: Show command to run
7445                        self.render_spec_run_command(ui);
7446
7447                        ui.add_space(spacing::MD);
7448
7449                        // "Close" button
7450                        let start_new_button = egui::Button::new(
7451                            egui::RichText::new("Close")
7452                                .font(typography::font(FontSize::Body, FontWeight::Medium))
7453                                .color(colors::SURFACE),
7454                        )
7455                        .fill(colors::ACCENT)
7456                        .rounding(Rounding::same(spacing::SM));
7457
7458                        if ui.add(start_new_button).clicked() {
7459                            should_start_new = true;
7460                        }
7461                    } else {
7462                        // Pre-confirmation: Show spec path and confirm button
7463                        ui.label(
7464                            egui::RichText::new("Spec Generated!")
7465                                .font(typography::font(FontSize::Body, FontWeight::SemiBold))
7466                                .color(colors::STATUS_SUCCESS),
7467                        );
7468
7469                        ui.add_space(spacing::SM);
7470
7471                        // Show spec file path
7472                        if let Some(ref spec_path) = self.generated_spec_path {
7473                            let path_display = spec_path.display().to_string();
7474                            ui.horizontal(|ui| {
7475                                ui.label(
7476                                    egui::RichText::new("File:")
7477                                        .font(typography::font(FontSize::Body, FontWeight::Medium))
7478                                        .color(colors::TEXT_PRIMARY),
7479                                );
7480                                ui.add_space(spacing::XS);
7481                                // Selectable text for the path
7482                                let mut path_text = path_display.clone();
7483                                ui.add(
7484                                    egui::TextEdit::singleline(&mut path_text)
7485                                        .font(typography::font(
7486                                            FontSize::Small,
7487                                            FontWeight::Regular,
7488                                        ))
7489                                        .text_color(colors::TEXT_SECONDARY)
7490                                        .frame(false)
7491                                        .interactive(true)
7492                                        .desired_width(f32::INFINITY),
7493                                );
7494                            });
7495                        }
7496
7497                        ui.add_space(spacing::MD);
7498
7499                        // Green confirm button
7500                        let confirm_button = egui::Button::new(
7501                            egui::RichText::new("Confirm & Get Run Command")
7502                                .font(typography::font(FontSize::Body, FontWeight::Medium))
7503                                .color(colors::SURFACE),
7504                        )
7505                        .fill(colors::STATUS_SUCCESS)
7506                        .rounding(Rounding::same(spacing::SM));
7507
7508                        if ui.add(confirm_button).clicked() {
7509                            should_confirm = true;
7510                        }
7511
7512                        // Hint that users can continue refining
7513                        ui.add_space(spacing::MD);
7514                        ui.label(
7515                            egui::RichText::new(
7516                                "Want changes? Keep chatting below to refine the spec.",
7517                            )
7518                            .font(typography::font(FontSize::Small, FontWeight::Regular))
7519                            .color(colors::TEXT_MUTED),
7520                        );
7521                    }
7522                });
7523            });
7524        });
7525
7526        (should_confirm, should_start_new)
7527    }
7528
7529    /// Render the command to run the spec (shown after confirmation).
7530    fn render_spec_run_command(&self, ui: &mut egui::Ui) {
7531        // Title
7532        ui.label(
7533            egui::RichText::new("Ready to Run!")
7534                .font(typography::font(FontSize::Body, FontWeight::SemiBold))
7535                .color(colors::STATUS_SUCCESS),
7536        );
7537
7538        ui.add_space(spacing::SM);
7539
7540        // Instructions
7541        ui.label(
7542            egui::RichText::new("Open your terminal and run:")
7543                .font(typography::font(FontSize::Body, FontWeight::Regular))
7544                .color(colors::TEXT_PRIMARY),
7545        );
7546
7547        ui.add_space(spacing::SM);
7548
7549        // Build the command
7550        let command = self.build_spec_run_command();
7551
7552        // Command display with copy button
7553        ui.horizontal(|ui| {
7554            // Code-styled frame for the command
7555            let cmd_frame = egui::Frame::none()
7556                .fill(colors::SURFACE)
7557                .rounding(Rounding::same(spacing::XS))
7558                .inner_margin(egui::Margin::symmetric(spacing::SM, spacing::XS))
7559                .stroke(Stroke::new(1.0, colors::BORDER));
7560
7561            cmd_frame.show(ui, |ui| {
7562                // Make the command text selectable
7563                let mut cmd_text = command.clone();
7564                ui.add(
7565                    egui::TextEdit::singleline(&mut cmd_text)
7566                        .font(egui::FontId::monospace(12.0))
7567                        .text_color(colors::TEXT_PRIMARY)
7568                        .frame(false)
7569                        .interactive(true)
7570                        .desired_width(400.0),
7571                );
7572            });
7573
7574            ui.add_space(spacing::SM);
7575
7576            // Copy button
7577            let copy_button = egui::Button::new(
7578                egui::RichText::new("Copy")
7579                    .font(typography::font(FontSize::Small, FontWeight::Medium)),
7580            )
7581            .fill(colors::SURFACE)
7582            .stroke(Stroke::new(1.0, colors::BORDER))
7583            .rounding(Rounding::same(spacing::XS));
7584
7585            if ui
7586                .add(copy_button)
7587                .on_hover_text("Copy to clipboard")
7588                .clicked()
7589            {
7590                ui.output_mut(|o| o.copied_text = command);
7591            }
7592        });
7593    }
7594
7595    /// Build the command to run the spec.
7596    ///
7597    /// Format: `cd "<project-root>" && autom8 "<spec-path>"`
7598    /// Paths are quoted to handle spaces correctly.
7599    fn build_spec_run_command(&self) -> String {
7600        let spec_path = self
7601            .generated_spec_path
7602            .as_ref()
7603            .map(|p| p.display().to_string())
7604            .unwrap_or_else(|| "<spec-path>".to_string());
7605
7606        // Try to find the project root from project metadata
7607        let project_root = self.find_project_root_for_selected_project();
7608
7609        match project_root {
7610            Some(root) => format!("cd \"{}\" && autom8 \"{}\"", root.display(), spec_path),
7611            None => format!("autom8 \"{}\"", spec_path),
7612        }
7613    }
7614
7615    /// Find the project root directory for the selected project.
7616    ///
7617    /// Uses the project metadata (project.json) which stores the repo path.
7618    fn find_project_root_for_selected_project(&self) -> Option<std::path::PathBuf> {
7619        let selected_project = self.create_spec_selected_project.as_ref()?;
7620        crate::config::get_project_repo_path(selected_project)
7621    }
7622
7623    /// Confirm the spec and show the run command.
7624    fn confirm_spec(&mut self) {
7625        self.spec_confirmed = true;
7626        self.chat_scroll_to_bottom = true;
7627    }
7628
7629    /// Check if there is an active spec creation session (US-008).
7630    ///
7631    /// A session is considered "active" if any of the following are true:
7632    /// - There are chat messages (conversation has started)
7633    /// - Claude subprocess is running (stdin handle exists)
7634    /// - Waiting for Claude response
7635    /// - A spec has been generated (even if not confirmed yet)
7636    fn has_active_spec_session(&self) -> bool {
7637        !self.chat_messages.is_empty()
7638            || self.claude_stdin.is_some()
7639            || self.is_waiting_for_claude
7640            || self.generated_spec_path.is_some()
7641    }
7642
7643    /// Reset the Create Spec session to start fresh (US-008).
7644    ///
7645    /// Clears all state related to the current spec creation session:
7646    /// - Chat messages
7647    /// - Generated spec path
7648    /// - Confirmation status
7649    /// - Claude process state
7650    ///
7651    /// Also terminates any running Claude subprocess by closing its stdin.
7652    fn reset_create_spec_session(&mut self) {
7653        // Kill any running Claude subprocess
7654        if let Ok(mut guard) = self.claude_child.lock() {
7655            if let Some(mut child) = guard.take() {
7656                // Kill the process - ignore errors (process may have already exited)
7657                let _ = child.kill();
7658                // Wait to avoid zombie process
7659                let _ = child.wait();
7660            }
7661        }
7662
7663        // Close stdin handle if any (legacy, but keep for safety)
7664        if let Some(ref stdin_handle) = self.claude_stdin {
7665            stdin_handle.close();
7666        }
7667
7668        // Clear chat state
7669        self.chat_messages.clear();
7670        self.chat_input_text.clear();
7671        self.chat_scroll_to_bottom = false;
7672
7673        // Clear Claude process state
7674        self.claude_stdin = None;
7675        self.claude_response_buffer.clear();
7676        self.claude_error = None;
7677        self.is_waiting_for_claude = false;
7678        self.claude_starting = false;
7679        self.last_claude_output_time = None;
7680        self.claude_response_in_progress = false;
7681
7682        // Clear spec completion state
7683        self.generated_spec_path = None;
7684        self.spec_confirmed = false;
7685        self.claude_finished = false;
7686    }
7687
7688    /// Render the run detail view for a specific run.
7689    fn render_run_detail(&self, ui: &mut egui::Ui, run_id: &str) {
7690        // Header (fixed, not scrollable)
7691        ui.label(
7692            egui::RichText::new(format!("Run Details: {}", run_id))
7693                .font(typography::font(FontSize::Title, FontWeight::SemiBold))
7694                .color(colors::TEXT_PRIMARY),
7695        );
7696
7697        ui.add_space(spacing::MD);
7698
7699        // Check if we have cached run state
7700        if let Some(run_state) = self.run_detail_cache.get(run_id) {
7701            // Render run details in a ScrollArea that fills remaining space
7702            self.render_run_state_details(ui, run_state);
7703        } else {
7704            // No cached state - show placeholder (also in ScrollArea for consistency)
7705            egui::ScrollArea::vertical()
7706                .auto_shrink([false, false])
7707                .show(ui, |ui| {
7708                    ui.add_space(spacing::XXL);
7709                    ui.vertical_centered(|ui| {
7710                        ui.label(
7711                            egui::RichText::new("Run details not available")
7712                                .font(typography::font(FontSize::Heading, FontWeight::Medium))
7713                                .color(colors::TEXT_MUTED),
7714                        );
7715
7716                        ui.add_space(spacing::SM);
7717
7718                        ui.label(
7719                            egui::RichText::new(
7720                                "This run may have been archived or the data is unavailable.",
7721                            )
7722                            .font(typography::font(FontSize::Body, FontWeight::Regular))
7723                            .color(colors::TEXT_MUTED),
7724                        );
7725                    });
7726                });
7727        }
7728    }
7729
7730    /// Render the command output view for a specific command execution.
7731    fn render_command_output(&self, ui: &mut egui::Ui, cache_key: &str) {
7732        // Get the command execution state
7733        let execution = match self.command_executions.get(cache_key) {
7734            Some(exec) => exec,
7735            None => {
7736                // No execution found - show placeholder
7737                egui::ScrollArea::vertical()
7738                    .auto_shrink([false, false])
7739                    .show(ui, |ui| {
7740                        ui.add_space(spacing::XXL);
7741                        ui.vertical_centered(|ui| {
7742                            ui.label(
7743                                egui::RichText::new("Command output not available")
7744                                    .font(typography::font(FontSize::Heading, FontWeight::Medium))
7745                                    .color(colors::TEXT_MUTED),
7746                            );
7747                        });
7748                    });
7749                return;
7750            }
7751        };
7752
7753        // Header with command info
7754        self.render_command_output_header(ui, execution);
7755
7756        ui.add_space(spacing::MD);
7757
7758        // Output content with auto-scroll
7759        self.render_command_output_content(ui, execution, cache_key);
7760    }
7761
7762    /// Render the header for command output (status indicator, project, command).
7763    fn render_command_output_header(&self, ui: &mut egui::Ui, execution: &CommandExecution) {
7764        ui.horizontal(|ui| {
7765            // Status badge
7766            let (status_text, status_color) = match execution.status {
7767                CommandStatus::Running => ("Running", colors::STATUS_RUNNING),
7768                CommandStatus::Completed => ("Completed", colors::STATUS_SUCCESS),
7769                CommandStatus::Failed => ("Failed", colors::STATUS_ERROR),
7770            };
7771
7772            let badge_galley = ui.fonts(|f| {
7773                f.layout_no_wrap(
7774                    status_text.to_string(),
7775                    typography::font(FontSize::Body, FontWeight::Medium),
7776                    colors::TEXT_PRIMARY,
7777                )
7778            });
7779            let badge_width = badge_galley.rect.width() + spacing::MD * 2.0;
7780            let badge_height = badge_galley.rect.height() + spacing::XS * 2.0;
7781
7782            let (badge_rect, _) =
7783                ui.allocate_exact_size(Vec2::new(badge_width, badge_height), Sense::hover());
7784
7785            ui.painter().rect_filled(
7786                badge_rect,
7787                Rounding::same(rounding::SMALL),
7788                badge_background_color(status_color),
7789            );
7790
7791            let text_pos = badge_rect.center() - badge_galley.rect.center().to_vec2();
7792            ui.painter().galley(text_pos, badge_galley, status_color);
7793
7794            ui.add_space(spacing::MD);
7795
7796            // Spinner for running state
7797            if execution.status == CommandStatus::Running {
7798                self.render_inline_spinner(ui);
7799                ui.add_space(spacing::SM);
7800            }
7801
7802            // Title: "Command: project"
7803            ui.label(
7804                egui::RichText::new(execution.id.tab_label())
7805                    .font(typography::font(FontSize::Title, FontWeight::SemiBold))
7806                    .color(colors::TEXT_PRIMARY),
7807            );
7808        });
7809
7810        // Show exit code if completed
7811        if let Some(exit_code) = execution.exit_code {
7812            ui.add_space(spacing::SM);
7813            ui.horizontal(|ui| {
7814                ui.label(
7815                    egui::RichText::new("Exit code:")
7816                        .font(typography::font(FontSize::Body, FontWeight::Medium))
7817                        .color(colors::TEXT_SECONDARY),
7818                );
7819                ui.add_space(spacing::XS);
7820
7821                let exit_color = if exit_code == 0 {
7822                    colors::STATUS_SUCCESS
7823                } else {
7824                    colors::STATUS_ERROR
7825                };
7826                ui.label(
7827                    egui::RichText::new(exit_code.to_string())
7828                        .font(typography::mono(FontSize::Body))
7829                        .color(exit_color),
7830                );
7831            });
7832        }
7833    }
7834
7835    /// Render an inline spinner for loading states.
7836    fn render_inline_spinner(&self, ui: &mut egui::Ui) {
7837        let spinner_size = 16.0;
7838        let (rect, _) = ui.allocate_exact_size(Vec2::splat(spinner_size), Sense::hover());
7839
7840        if ui.is_rect_visible(rect) {
7841            let center = rect.center();
7842            let radius = spinner_size / 2.0 - 2.0;
7843            let time = ui.input(|i| i.time);
7844            let start_angle = (time * 2.0) as f32 % std::f32::consts::TAU;
7845            let arc_length = std::f32::consts::PI * 1.5;
7846
7847            let n_points = 32;
7848            let points: Vec<_> = (0..=n_points)
7849                .map(|i| {
7850                    let angle = start_angle + arc_length * (i as f32 / n_points as f32);
7851                    egui::pos2(
7852                        center.x + radius * angle.cos(),
7853                        center.y + radius * angle.sin(),
7854                    )
7855                })
7856                .collect();
7857
7858            ui.painter()
7859                .add(egui::Shape::line(points, Stroke::new(2.0, colors::ACCENT)));
7860
7861            // Request repaint for animation
7862            ui.ctx().request_repaint();
7863        }
7864    }
7865
7866    /// Render the command output content in a scrollable area.
7867    fn render_command_output_content(
7868        &self,
7869        ui: &mut egui::Ui,
7870        execution: &CommandExecution,
7871        _cache_key: &str,
7872    ) {
7873        // Calculate a unique ID for scroll state
7874        let scroll_id = egui::Id::new("command_output_scroll").with(execution.id.cache_key());
7875
7876        // Build scroll area - auto-scroll to bottom when auto_scroll is enabled
7877        let scroll_area = egui::ScrollArea::vertical()
7878            .id_salt(scroll_id)
7879            .auto_shrink([false, false])
7880            .stick_to_bottom(execution.auto_scroll);
7881
7882        // If running, request repaint to show spinner animation
7883        if execution.is_running() {
7884            ui.ctx().request_repaint();
7885        }
7886
7887        scroll_area.show(ui, |ui| {
7888            // Background for output area
7889            let available_rect = ui.available_rect_before_wrap();
7890            ui.painter().rect_filled(
7891                available_rect,
7892                Rounding::same(rounding::BUTTON),
7893                colors::SURFACE_HOVER,
7894            );
7895
7896            ui.add_space(spacing::SM);
7897
7898            egui::Frame::none()
7899                .inner_margin(spacing::MD)
7900                .show(ui, |ui| {
7901                    // Render stdout
7902                    if !execution.stdout.is_empty() {
7903                        for line in &execution.stdout {
7904                            // Use selectable_label for copy/paste support
7905                            ui.add(
7906                                egui::Label::new(
7907                                    egui::RichText::new(line)
7908                                        .font(typography::mono(FontSize::Small))
7909                                        .color(colors::TEXT_PRIMARY),
7910                                )
7911                                .selectable(true)
7912                                .wrap_mode(egui::TextWrapMode::Wrap),
7913                            );
7914                        }
7915                    }
7916
7917                    // Render stderr (in error color)
7918                    if !execution.stderr.is_empty() {
7919                        if !execution.stdout.is_empty() {
7920                            ui.add_space(spacing::SM);
7921                            ui.separator();
7922                            ui.add_space(spacing::SM);
7923                            ui.label(
7924                                egui::RichText::new("Errors:")
7925                                    .font(typography::font(FontSize::Small, FontWeight::Medium))
7926                                    .color(colors::STATUS_ERROR),
7927                            );
7928                            ui.add_space(spacing::XS);
7929                        }
7930
7931                        for line in &execution.stderr {
7932                            ui.add(
7933                                egui::Label::new(
7934                                    egui::RichText::new(line)
7935                                        .font(typography::mono(FontSize::Small))
7936                                        .color(colors::STATUS_ERROR),
7937                                )
7938                                .selectable(true)
7939                                .wrap_mode(egui::TextWrapMode::Wrap),
7940                            );
7941                        }
7942                    }
7943
7944                    // Show "no output yet" if empty and still running
7945                    if execution.stdout.is_empty()
7946                        && execution.stderr.is_empty()
7947                        && execution.is_running()
7948                    {
7949                        ui.label(
7950                            egui::RichText::new("Waiting for output...")
7951                                .font(typography::font(FontSize::Body, FontWeight::Regular))
7952                                .color(colors::TEXT_MUTED)
7953                                .italics(),
7954                        );
7955                    }
7956
7957                    // Show completion message if no output and completed
7958                    if execution.stdout.is_empty()
7959                        && execution.stderr.is_empty()
7960                        && execution.is_finished()
7961                    {
7962                        ui.label(
7963                            egui::RichText::new("Command completed with no output.")
7964                                .font(typography::font(FontSize::Body, FontWeight::Regular))
7965                                .color(colors::TEXT_MUTED)
7966                                .italics(),
7967                        );
7968                    }
7969                });
7970        });
7971    }
7972
7973    /// Render detailed information about a run state.
7974    fn render_run_state_details(&self, ui: &mut egui::Ui, run_state: &crate::state::RunState) {
7975        egui::ScrollArea::vertical()
7976            .auto_shrink([false, false])
7977            .show(ui, |ui| {
7978                // ================================================================
7979                // RUN SUMMARY SECTION
7980                // ================================================================
7981                self.render_run_summary_card(ui, run_state);
7982
7983                ui.add_space(spacing::LG);
7984                ui.separator();
7985                ui.add_space(spacing::MD);
7986
7987                // ================================================================
7988                // STORIES SECTION
7989                // ================================================================
7990                ui.label(
7991                    egui::RichText::new("Stories")
7992                        .font(typography::font(FontSize::Heading, FontWeight::SemiBold))
7993                        .color(colors::TEXT_PRIMARY),
7994                );
7995
7996                ui.add_space(spacing::SM);
7997
7998                if run_state.iterations.is_empty() {
7999                    ui.label(
8000                        egui::RichText::new("No stories processed yet")
8001                            .font(typography::font(FontSize::Body, FontWeight::Regular))
8002                            .color(colors::TEXT_MUTED),
8003                    );
8004                } else {
8005                    // Group iterations by story_id while preserving order
8006                    let mut story_order: Vec<String> = Vec::new();
8007                    let mut story_iterations: std::collections::HashMap<
8008                        String,
8009                        Vec<&crate::state::IterationRecord>,
8010                    > = std::collections::HashMap::new();
8011
8012                    for iter in &run_state.iterations {
8013                        if !story_iterations.contains_key(&iter.story_id) {
8014                            story_order.push(iter.story_id.clone());
8015                        }
8016                        story_iterations
8017                            .entry(iter.story_id.clone())
8018                            .or_default()
8019                            .push(iter);
8020                    }
8021
8022                    // Render each story in order
8023                    for story_id in &story_order {
8024                        let iterations = story_iterations.get(story_id).unwrap();
8025                        self.render_story_detail_card(ui, story_id, iterations);
8026                        ui.add_space(spacing::MD);
8027                    }
8028                }
8029            });
8030    }
8031
8032    /// Render the run summary card with status, timing, and metadata.
8033    fn render_run_summary_card(&self, ui: &mut egui::Ui, run_state: &crate::state::RunState) {
8034        // Status badge and run ID row
8035        ui.horizontal(|ui| {
8036            // Status badge
8037            let status_text = match run_state.status {
8038                crate::state::RunStatus::Completed => "Completed",
8039                crate::state::RunStatus::Failed => "Failed",
8040                crate::state::RunStatus::Running => "Running",
8041                crate::state::RunStatus::Interrupted => "Interrupted",
8042            };
8043            let status_color = match run_state.status {
8044                crate::state::RunStatus::Completed => colors::STATUS_SUCCESS,
8045                crate::state::RunStatus::Failed => colors::STATUS_ERROR,
8046                crate::state::RunStatus::Running => colors::STATUS_RUNNING,
8047                crate::state::RunStatus::Interrupted => colors::STATUS_WARNING,
8048            };
8049
8050            let badge_galley = ui.fonts(|f| {
8051                f.layout_no_wrap(
8052                    status_text.to_string(),
8053                    typography::font(FontSize::Body, FontWeight::Medium),
8054                    colors::TEXT_PRIMARY,
8055                )
8056            });
8057            let badge_width = badge_galley.rect.width() + spacing::MD * 2.0;
8058            let badge_height = badge_galley.rect.height() + spacing::XS * 2.0;
8059
8060            let (badge_rect, _) =
8061                ui.allocate_exact_size(Vec2::new(badge_width, badge_height), Sense::hover());
8062
8063            ui.painter().rect_filled(
8064                badge_rect,
8065                Rounding::same(rounding::SMALL),
8066                badge_background_color(status_color),
8067            );
8068
8069            let text_pos = badge_rect.center() - badge_galley.rect.center().to_vec2();
8070            ui.painter().galley(text_pos, badge_galley, status_color);
8071
8072            ui.add_space(spacing::MD);
8073
8074            // Run ID (smaller, muted)
8075            ui.label(
8076                egui::RichText::new(format!(
8077                    "Run ID: {}",
8078                    &run_state.run_id[..8.min(run_state.run_id.len())]
8079                ))
8080                .font(typography::font(FontSize::Small, FontWeight::Regular))
8081                .color(colors::TEXT_MUTED),
8082            );
8083        });
8084
8085        ui.add_space(spacing::MD);
8086
8087        // Grid layout for timing information
8088        egui::Grid::new("run_timing_grid")
8089            .num_columns(2)
8090            .spacing([spacing::LG, spacing::XS])
8091            .show(ui, |ui| {
8092                // Start time
8093                ui.label(
8094                    egui::RichText::new("Start Time:")
8095                        .font(typography::font(FontSize::Body, FontWeight::Medium))
8096                        .color(colors::TEXT_SECONDARY),
8097                );
8098                ui.label(
8099                    egui::RichText::new(
8100                        run_state
8101                            .started_at
8102                            .with_timezone(&chrono::Local)
8103                            .format("%Y-%m-%d %I:%M:%S %p")
8104                            .to_string(),
8105                    )
8106                    .font(typography::font(FontSize::Body, FontWeight::Regular))
8107                    .color(colors::TEXT_PRIMARY),
8108                );
8109                ui.end_row();
8110
8111                // End time
8112                ui.label(
8113                    egui::RichText::new("End Time:")
8114                        .font(typography::font(FontSize::Body, FontWeight::Medium))
8115                        .color(colors::TEXT_SECONDARY),
8116                );
8117                if let Some(finished) = run_state.finished_at {
8118                    ui.label(
8119                        egui::RichText::new(
8120                            finished
8121                                .with_timezone(&chrono::Local)
8122                                .format("%Y-%m-%d %I:%M:%S %p")
8123                                .to_string(),
8124                        )
8125                        .font(typography::font(FontSize::Body, FontWeight::Regular))
8126                        .color(colors::TEXT_PRIMARY),
8127                    );
8128                } else {
8129                    ui.label(
8130                        egui::RichText::new("In progress...")
8131                            .font(typography::font(FontSize::Body, FontWeight::Regular))
8132                            .color(colors::STATUS_RUNNING),
8133                    );
8134                }
8135                ui.end_row();
8136
8137                // Duration
8138                ui.label(
8139                    egui::RichText::new("Duration:")
8140                        .font(typography::font(FontSize::Body, FontWeight::Medium))
8141                        .color(colors::TEXT_SECONDARY),
8142                );
8143                let duration_str = if let Some(finished) = run_state.finished_at {
8144                    let duration = finished - run_state.started_at;
8145                    Self::format_duration_detailed(duration)
8146                } else {
8147                    let duration = chrono::Utc::now() - run_state.started_at;
8148                    format!("{} (ongoing)", Self::format_duration_detailed(duration))
8149                };
8150                ui.label(
8151                    egui::RichText::new(duration_str)
8152                        .font(typography::font(FontSize::Body, FontWeight::Regular))
8153                        .color(colors::TEXT_PRIMARY),
8154                );
8155                ui.end_row();
8156
8157                // Branch
8158                ui.label(
8159                    egui::RichText::new("Branch:")
8160                        .font(typography::font(FontSize::Body, FontWeight::Medium))
8161                        .color(colors::TEXT_SECONDARY),
8162                );
8163                ui.label(
8164                    egui::RichText::new(&run_state.branch)
8165                        .font(typography::font(FontSize::Body, FontWeight::Regular))
8166                        .color(colors::ACCENT),
8167                );
8168                ui.end_row();
8169
8170                // Story summary
8171                let completed_count = run_state
8172                    .iterations
8173                    .iter()
8174                    .filter(|i| i.status == crate::state::IterationStatus::Success)
8175                    .map(|i| &i.story_id)
8176                    .collect::<std::collections::HashSet<_>>()
8177                    .len();
8178                let total_stories = run_state
8179                    .iterations
8180                    .iter()
8181                    .map(|i| &i.story_id)
8182                    .collect::<std::collections::HashSet<_>>()
8183                    .len();
8184
8185                if total_stories > 0 {
8186                    ui.label(
8187                        egui::RichText::new("Stories:")
8188                            .font(typography::font(FontSize::Body, FontWeight::Medium))
8189                            .color(colors::TEXT_SECONDARY),
8190                    );
8191                    ui.label(
8192                        egui::RichText::new(format!(
8193                            "{}/{} completed",
8194                            completed_count, total_stories
8195                        ))
8196                        .font(typography::font(FontSize::Body, FontWeight::Regular))
8197                        .color(colors::TEXT_PRIMARY),
8198                    );
8199                    ui.end_row();
8200                }
8201
8202                // Token usage section
8203                ui.label(
8204                    egui::RichText::new("Total Tokens:")
8205                        .font(typography::font(FontSize::Body, FontWeight::Medium))
8206                        .color(colors::TEXT_SECONDARY),
8207                );
8208                if let Some(ref usage) = run_state.total_usage {
8209                    let total = Self::format_tokens(usage.total_tokens());
8210                    let input = Self::format_tokens(usage.input_tokens);
8211                    let output = Self::format_tokens(usage.output_tokens);
8212                    ui.label(
8213                        egui::RichText::new(format!("{} ({} in / {} out)", total, input, output))
8214                            .font(typography::font(FontSize::Body, FontWeight::Regular))
8215                            .color(colors::TEXT_PRIMARY),
8216                    );
8217                } else {
8218                    ui.label(
8219                        egui::RichText::new("N/A")
8220                            .font(typography::font(FontSize::Body, FontWeight::Regular))
8221                            .color(colors::TEXT_MUTED),
8222                    );
8223                }
8224                ui.end_row();
8225
8226                // Cache stats (only if we have usage data)
8227                if let Some(ref usage) = run_state.total_usage {
8228                    if usage.cache_read_tokens > 0 || usage.cache_creation_tokens > 0 {
8229                        ui.label(
8230                            egui::RichText::new("Cache:")
8231                                .font(typography::font(FontSize::Body, FontWeight::Medium))
8232                                .color(colors::TEXT_SECONDARY),
8233                        );
8234                        let cache_read = Self::format_tokens(usage.cache_read_tokens);
8235                        let cache_created = Self::format_tokens(usage.cache_creation_tokens);
8236                        ui.label(
8237                            egui::RichText::new(format!(
8238                                "{} read / {} created",
8239                                cache_read, cache_created
8240                            ))
8241                            .font(typography::font(FontSize::Body, FontWeight::Regular))
8242                            .color(colors::TEXT_PRIMARY),
8243                        );
8244                        ui.end_row();
8245                    }
8246
8247                    // Model name
8248                    if let Some(ref model) = usage.model {
8249                        ui.label(
8250                            egui::RichText::new("Model:")
8251                                .font(typography::font(FontSize::Body, FontWeight::Medium))
8252                                .color(colors::TEXT_SECONDARY),
8253                        );
8254                        ui.label(
8255                            egui::RichText::new(model)
8256                                .font(typography::font(FontSize::Body, FontWeight::Regular))
8257                                .color(colors::TEXT_PRIMARY),
8258                        );
8259                        ui.end_row();
8260                    }
8261                }
8262            });
8263
8264        // Pseudo-phase breakdown (only show phases that have usage data)
8265        let pseudo_phases = ["Planning", "Final Review", "PR & Commit"];
8266        let has_pseudo_phase_usage = pseudo_phases
8267            .iter()
8268            .any(|phase| run_state.phase_usage.contains_key(*phase));
8269
8270        if has_pseudo_phase_usage {
8271            ui.add_space(spacing::SM);
8272
8273            ui.label(
8274                egui::RichText::new("Phase Breakdown")
8275                    .font(typography::font(FontSize::Small, FontWeight::SemiBold))
8276                    .color(colors::TEXT_SECONDARY),
8277            );
8278
8279            ui.add_space(spacing::XS);
8280
8281            egui::Grid::new("phase_usage_grid")
8282                .num_columns(2)
8283                .spacing([spacing::LG, spacing::XS])
8284                .show(ui, |ui| {
8285                    for phase in pseudo_phases {
8286                        if let Some(usage) = run_state.phase_usage.get(phase) {
8287                            ui.label(
8288                                egui::RichText::new(format!("{}:", phase))
8289                                    .font(typography::font(FontSize::Small, FontWeight::Regular))
8290                                    .color(colors::TEXT_SECONDARY),
8291                            );
8292                            ui.label(
8293                                egui::RichText::new(format!(
8294                                    "{} tokens",
8295                                    Self::format_tokens(usage.total_tokens())
8296                                ))
8297                                .font(typography::font(FontSize::Small, FontWeight::Regular))
8298                                .color(colors::TEXT_PRIMARY),
8299                            );
8300                            ui.end_row();
8301                        }
8302                    }
8303                });
8304        }
8305    }
8306
8307    /// Render a detailed card for a single story with all its iterations.
8308    fn render_story_detail_card(
8309        &self,
8310        ui: &mut egui::Ui,
8311        story_id: &str,
8312        iterations: &[&crate::state::IterationRecord],
8313    ) {
8314        let last_iter = iterations.last().unwrap();
8315        let status_color = match last_iter.status {
8316            crate::state::IterationStatus::Success => colors::STATUS_SUCCESS,
8317            crate::state::IterationStatus::Failed => colors::STATUS_ERROR,
8318            crate::state::IterationStatus::Running => colors::STATUS_RUNNING,
8319        };
8320
8321        // Story card background
8322        let available_width = ui.available_width();
8323        egui::Frame::none()
8324            .fill(colors::SURFACE_HOVER)
8325            .rounding(Rounding::same(rounding::CARD))
8326            .inner_margin(egui::Margin::same(spacing::MD))
8327            .show(ui, |ui| {
8328                ui.set_min_width(available_width - spacing::MD * 2.0);
8329
8330                // Story header row
8331                ui.horizontal(|ui| {
8332                    // Status dot
8333                    let (dot_rect, _) =
8334                        ui.allocate_exact_size(Vec2::splat(spacing::MD), Sense::hover());
8335                    ui.painter()
8336                        .circle_filled(dot_rect.center(), 5.0, status_color);
8337
8338                    // Story ID
8339                    ui.label(
8340                        egui::RichText::new(story_id)
8341                            .font(typography::font(FontSize::Body, FontWeight::SemiBold))
8342                            .color(colors::TEXT_PRIMARY),
8343                    );
8344
8345                    // Status text badge
8346                    let status_text = match last_iter.status {
8347                        crate::state::IterationStatus::Success => "Success",
8348                        crate::state::IterationStatus::Failed => "Failed",
8349                        crate::state::IterationStatus::Running => "Running",
8350                    };
8351
8352                    ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
8353                        let badge_galley = ui.fonts(|f| {
8354                            f.layout_no_wrap(
8355                                status_text.to_string(),
8356                                typography::font(FontSize::Small, FontWeight::Medium),
8357                                status_color,
8358                            )
8359                        });
8360                        let badge_width = badge_galley.rect.width() + spacing::SM * 2.0;
8361                        let badge_height = badge_galley.rect.height() + spacing::XS * 2.0;
8362
8363                        let (badge_rect, _) = ui.allocate_exact_size(
8364                            Vec2::new(badge_width, badge_height),
8365                            Sense::hover(),
8366                        );
8367
8368                        ui.painter().rect_filled(
8369                            badge_rect,
8370                            Rounding::same(rounding::SMALL),
8371                            badge_background_color(status_color),
8372                        );
8373
8374                        let text_pos = badge_rect.center() - badge_galley.rect.center().to_vec2();
8375                        ui.painter().galley(text_pos, badge_galley, status_color);
8376                    });
8377                });
8378
8379                // Work summary if available (from the last successful iteration)
8380                let work_summary = iterations
8381                    .iter()
8382                    .rev()
8383                    .find_map(|iter| iter.work_summary.as_ref());
8384
8385                if let Some(summary) = work_summary {
8386                    ui.add_space(spacing::SM);
8387                    ui.label(
8388                        egui::RichText::new(truncate_with_ellipsis(summary, 200))
8389                            .font(typography::font(FontSize::Small, FontWeight::Regular))
8390                            .color(colors::TEXT_SECONDARY),
8391                    );
8392                }
8393
8394                // Iteration details section (shown if there are multiple iterations)
8395                if iterations.len() > 1 {
8396                    ui.add_space(spacing::SM);
8397                    ui.separator();
8398                    ui.add_space(spacing::SM);
8399
8400                    ui.label(
8401                        egui::RichText::new(format!("Iterations ({} total)", iterations.len()))
8402                            .font(typography::font(FontSize::Small, FontWeight::SemiBold))
8403                            .color(colors::TEXT_SECONDARY),
8404                    );
8405
8406                    ui.add_space(spacing::XS);
8407
8408                    // Show each iteration in a compact format
8409                    for (idx, iter) in iterations.iter().enumerate() {
8410                        let iter_status_color = match iter.status {
8411                            crate::state::IterationStatus::Success => colors::STATUS_SUCCESS,
8412                            crate::state::IterationStatus::Failed => colors::STATUS_ERROR,
8413                            crate::state::IterationStatus::Running => colors::STATUS_RUNNING,
8414                        };
8415
8416                        ui.horizontal(|ui| {
8417                            // Small status indicator
8418                            let (dot_rect, _) =
8419                                ui.allocate_exact_size(Vec2::splat(spacing::SM), Sense::hover());
8420                            ui.painter()
8421                                .circle_filled(dot_rect.center(), 3.0, iter_status_color);
8422
8423                            // Iteration number
8424                            ui.label(
8425                                egui::RichText::new(format!("#{}", idx + 1))
8426                                    .font(typography::font(FontSize::Caption, FontWeight::Medium))
8427                                    .color(colors::TEXT_PRIMARY),
8428                            );
8429
8430                            // Status
8431                            let status_str = match iter.status {
8432                                crate::state::IterationStatus::Success => "Success",
8433                                crate::state::IterationStatus::Failed => "Failed (review cycle)",
8434                                crate::state::IterationStatus::Running => "Running",
8435                            };
8436                            ui.label(
8437                                egui::RichText::new(status_str)
8438                                    .font(typography::font(FontSize::Caption, FontWeight::Regular))
8439                                    .color(iter_status_color),
8440                            );
8441
8442                            // Duration if available
8443                            if let Some(finished) = iter.finished_at {
8444                                let duration = finished - iter.started_at;
8445                                let duration_str = Self::format_duration_short(duration);
8446                                ui.label(
8447                                    egui::RichText::new(format!("({})", duration_str))
8448                                        .font(typography::font(
8449                                            FontSize::Caption,
8450                                            FontWeight::Regular,
8451                                        ))
8452                                        .color(colors::TEXT_MUTED),
8453                                );
8454                            }
8455
8456                            // Token usage for this iteration (if available)
8457                            if let Some(ref usage) = iter.usage {
8458                                ui.label(
8459                                    egui::RichText::new(format!(
8460                                        "• {} tokens",
8461                                        Self::format_tokens(usage.total_tokens())
8462                                    ))
8463                                    .font(typography::font(FontSize::Caption, FontWeight::Regular))
8464                                    .color(colors::TEXT_MUTED),
8465                                );
8466                            }
8467                        });
8468                    }
8469                } else {
8470                    // Single iteration - show duration and tokens
8471                    let iter = iterations[0];
8472                    ui.add_space(spacing::XS);
8473
8474                    // Build the info string with duration and tokens
8475                    let mut info_parts = Vec::new();
8476
8477                    if let Some(finished) = iter.finished_at {
8478                        let duration = finished - iter.started_at;
8479                        info_parts.push(format!(
8480                            "Duration: {}",
8481                            Self::format_duration_detailed(duration)
8482                        ));
8483                    }
8484
8485                    if let Some(ref usage) = iter.usage {
8486                        info_parts.push(format!(
8487                            "Tokens: {}",
8488                            Self::format_tokens(usage.total_tokens())
8489                        ));
8490                    }
8491
8492                    if !info_parts.is_empty() {
8493                        ui.label(
8494                            egui::RichText::new(info_parts.join(" • "))
8495                                .font(typography::font(FontSize::Small, FontWeight::Regular))
8496                                .color(colors::TEXT_MUTED),
8497                        );
8498                    }
8499                }
8500            });
8501    }
8502
8503    /// Format a chrono Duration as a detailed string (e.g., "1h 23m 45s").
8504    fn format_duration_detailed(duration: chrono::Duration) -> String {
8505        let total_seconds = duration.num_seconds().max(0);
8506        let hours = total_seconds / 3600;
8507        let minutes = (total_seconds % 3600) / 60;
8508        let seconds = total_seconds % 60;
8509
8510        if hours > 0 {
8511            format!("{}h {}m {}s", hours, minutes, seconds)
8512        } else if minutes > 0 {
8513            format!("{}m {}s", minutes, seconds)
8514        } else {
8515            format!("{}s", seconds)
8516        }
8517    }
8518
8519    /// Format a chrono Duration as a short string (e.g., "2m 30s").
8520    fn format_duration_short(duration: chrono::Duration) -> String {
8521        let total_seconds = duration.num_seconds().max(0);
8522        let hours = total_seconds / 3600;
8523        let minutes = (total_seconds % 3600) / 60;
8524        let seconds = total_seconds % 60;
8525
8526        if hours > 0 {
8527            format!("{}h{}m", hours, minutes)
8528        } else if minutes > 0 {
8529            format!("{}m{}s", minutes, seconds)
8530        } else {
8531            format!("{}s", seconds)
8532        }
8533    }
8534
8535    /// Format a token count with thousands separators (e.g., 1234567 -> "1,234,567").
8536    fn format_tokens(tokens: u64) -> String {
8537        let s = tokens.to_string();
8538        let mut result = String::new();
8539        for (i, c) in s.chars().rev().enumerate() {
8540            if i > 0 && i % 3 == 0 {
8541                result.push(',');
8542            }
8543            result.push(c);
8544        }
8545        result.chars().rev().collect()
8546    }
8547
8548    /// Render the Active Runs view with tab bar for session selection.
8549    ///
8550    /// When there are active sessions, displays a horizontal tab bar where each
8551    /// session gets its own tab (labeled with branch name). Clicking a tab switches
8552    /// to that session's expanded view with full output display.
8553    ///
8554    /// US-003: Tabs have close buttons and follow a lifecycle:
8555    /// - Tabs auto-appear for new active runs
8556    /// - Tabs persist after completion until manually closed
8557    /// - Closing all tabs returns to empty state
8558    fn render_active_runs(&mut self, ui: &mut egui::Ui) {
8559        // US-002: Fill available height so detail view expands to use vertical space
8560        let available_width = ui.available_width();
8561        let available_height = ui.available_height();
8562
8563        ui.allocate_ui_with_layout(
8564            egui::vec2(available_width, available_height),
8565            egui::Layout::top_down(egui::Align::LEFT),
8566            |ui| {
8567                // Header section with consistent spacing
8568                ui.label(
8569                    egui::RichText::new("Active Runs")
8570                        .font(typography::font(FontSize::Title, FontWeight::SemiBold))
8571                        .color(colors::TEXT_PRIMARY),
8572                );
8573
8574                ui.add_space(spacing::SM);
8575
8576                // US-001, US-003: Get visible sessions (active + cached completed)
8577                let visible_sessions = self.get_visible_sessions();
8578
8579                // Empty state if no sessions or all tabs closed
8580                if visible_sessions.is_empty() {
8581                    self.render_empty_active_runs(ui);
8582                } else {
8583                    // Ensure valid selection: auto-select first visible session if none selected
8584                    // or if current selection is not visible (US-005: robust to session order changes)
8585                    let current_selection_valid =
8586                        self.selected_session_id.as_ref().is_some_and(|id| {
8587                            visible_sessions
8588                                .iter()
8589                                .any(|s| s.metadata.session_id == *id)
8590                        });
8591
8592                    if !current_selection_valid {
8593                        // Select first visible session by ID
8594                        self.selected_session_id = visible_sessions
8595                            .first()
8596                            .map(|s| s.metadata.session_id.clone());
8597                    }
8598
8599                    // Render tab bar for session selection
8600                    self.render_active_session_tab_bar(ui);
8601
8602                    ui.add_space(spacing::SM);
8603
8604                    // Render expanded view for selected session (find by ID, robust to reordering)
8605                    // US-001: Uses find_session_by_id to check both active and cached sessions
8606                    if let Some(selected_id) = self.selected_session_id.clone() {
8607                        if let Some(session) = self.find_session_by_id(&selected_id) {
8608                            self.render_expanded_session_view(ui, &session);
8609                        }
8610                    }
8611                }
8612            },
8613        );
8614    }
8615
8616    /// Render the horizontal tab bar for active session selection.
8617    ///
8618    /// Each session gets a tab with its branch name (worktree prefix stripped).
8619    /// The tab bar scrolls horizontally if there are many tabs (US-005).
8620    ///
8621    /// US-003: Tabs have close buttons. Only sessions with open tabs are shown.
8622    /// US-005: Uses session ID instead of index for robust selection during rapid changes.
8623    fn render_active_session_tab_bar(&mut self, ui: &mut egui::Ui) {
8624        let available_width = ui.available_width();
8625        let scroll_width = available_width.min(TAB_BAR_MAX_SCROLL_WIDTH);
8626
8627        // Track which tab to select and which to close (by session ID for robustness)
8628        let mut tab_to_select: Option<String> = None;
8629        let mut tab_to_close: Option<String> = None;
8630
8631        // Collect visible sessions (those with open tabs) with their status
8632        // US-001: Include both active and cached completed sessions
8633        let visible_sessions: Vec<(String, String, Option<MachineState>)> = self
8634            .get_visible_sessions()
8635            .iter()
8636            .map(|s| {
8637                let branch_label = strip_worktree_prefix(&s.metadata.branch_name, &s.project_name);
8638                let state = s.run.as_ref().map(|r| r.machine_state);
8639                (s.metadata.session_id.clone(), branch_label, state)
8640            })
8641            .collect();
8642
8643        ui.allocate_ui_with_layout(
8644            egui::vec2(available_width, CONTENT_TAB_BAR_HEIGHT),
8645            egui::Layout::left_to_right(egui::Align::Center),
8646            |ui| {
8647                egui::ScrollArea::horizontal()
8648                    .max_width(scroll_width)
8649                    .auto_shrink([false, false])
8650                    .scroll_bar_visibility(
8651                        egui::scroll_area::ScrollBarVisibility::VisibleWhenNeeded,
8652                    )
8653                    .show(ui, |ui| {
8654                        ui.horizontal_centered(|ui| {
8655                            ui.add_space(spacing::XS);
8656
8657                            for (session_id, branch_label, state) in &visible_sessions {
8658                                let is_active = self
8659                                    .selected_session_id
8660                                    .as_ref()
8661                                    .is_some_and(|id| id == session_id);
8662
8663                                let (tab_clicked, close_clicked) = self.render_active_session_tab(
8664                                    ui,
8665                                    branch_label,
8666                                    is_active,
8667                                    *state,
8668                                );
8669
8670                                if tab_clicked {
8671                                    tab_to_select = Some(session_id.clone());
8672                                }
8673                                if close_clicked {
8674                                    tab_to_close = Some(session_id.clone());
8675                                }
8676                                ui.add_space(spacing::XS);
8677                            }
8678                        });
8679                    });
8680            },
8681        );
8682
8683        // Apply selection after render loop (by ID, robust to reordering)
8684        if let Some(session_id) = tab_to_select {
8685            self.selected_session_id = Some(session_id);
8686        }
8687
8688        // US-003: Handle tab close
8689        if let Some(session_id) = tab_to_close {
8690            self.close_session_tab(&session_id);
8691        }
8692    }
8693
8694    /// Close a session tab and remove it from view.
8695    fn close_session_tab(&mut self, session_id: &str) {
8696        // Mark as closed (prevents showing and auto-reopen)
8697        self.closed_session_tabs.insert(session_id.to_string());
8698
8699        // Remove from seen sessions
8700        self.seen_sessions.remove(session_id);
8701
8702        // If the closed tab was selected, clear selection
8703        if self
8704            .selected_session_id
8705            .as_ref()
8706            .is_some_and(|id| id == session_id)
8707        {
8708            self.selected_session_id = None;
8709        }
8710    }
8711
8712    /// Render a single tab in the active session tab bar.
8713    ///
8714    /// US-001: Tabs show a status dot to distinguish running vs completed sessions.
8715    /// US-002: Close button is only visible when the run has finished (terminal state).
8716    /// US-003: Tabs have close buttons (X) for closing completed sessions.
8717    /// Returns (tab_clicked, close_clicked).
8718    fn render_active_session_tab(
8719        &self,
8720        ui: &mut egui::Ui,
8721        label: &str,
8722        is_active: bool,
8723        state: Option<MachineState>,
8724    ) -> (bool, bool) {
8725        // US-002: Determine if close button should be shown
8726        // Close button is only visible when the run has finished (terminal state)
8727        let show_close_button = state.is_none_or(is_terminal_state);
8728
8729        // Calculate text size
8730        let text_galley = ui.fonts(|f| {
8731            f.layout_no_wrap(
8732                label.to_string(),
8733                typography::font(FontSize::Body, FontWeight::Medium),
8734                colors::TEXT_PRIMARY,
8735            )
8736        });
8737        let text_size = text_galley.size();
8738
8739        // US-001: Add space for status dot before label
8740        let status_dot_radius = 4.0;
8741        let status_dot_spacing = spacing::SM;
8742        let status_dot_space = status_dot_radius * 2.0 + status_dot_spacing;
8743
8744        // Calculate tab dimensions including status dot
8745        // US-002: Only include close button space when button is visible
8746        // US-005: Include gap between label text and close button
8747        let close_button_space = if show_close_button {
8748            TAB_LABEL_CLOSE_GAP + TAB_CLOSE_BUTTON_SIZE + TAB_CLOSE_PADDING
8749        } else {
8750            0.0
8751        };
8752        let tab_width = status_dot_space + text_size.x + TAB_PADDING_H * 2.0 + close_button_space;
8753        let tab_height = CONTENT_TAB_BAR_HEIGHT - TAB_UNDERLINE_HEIGHT - spacing::XS;
8754        let tab_size = egui::vec2(tab_width, tab_height);
8755
8756        // Allocate space for the tab
8757        let (rect, response) = ui.allocate_exact_size(tab_size, Sense::click());
8758        let is_hovered = response.hovered();
8759
8760        // Draw tab background
8761        let bg_color = if is_active {
8762            colors::SURFACE_SELECTED
8763        } else if is_hovered {
8764            colors::SURFACE_HOVER
8765        } else {
8766            Color32::TRANSPARENT
8767        };
8768
8769        if bg_color != Color32::TRANSPARENT {
8770            ui.painter()
8771                .rect_filled(rect, Rounding::same(rounding::BUTTON), bg_color);
8772        }
8773
8774        // US-001, US-003: Draw status indicator to indicate session state
8775        // Running states show a colored dot; terminal states show a checkmark
8776        let status_color = state.map(state_to_color).unwrap_or(colors::STATUS_IDLE);
8777        let indicator_center = egui::pos2(
8778            rect.left() + TAB_PADDING_H + status_dot_radius,
8779            rect.center().y,
8780        );
8781
8782        // US-003: Use checkmark for terminal states, dot for running states
8783        let is_terminal = state.is_none_or(is_terminal_state);
8784        if is_terminal {
8785            // Draw checkmark for completed/failed/idle states
8786            // Size the checkmark to have similar visual weight to the dot (radius 4.0)
8787            let check_size = status_dot_radius * 0.9; // ~3.6px, fits within dot bounds
8788            let stroke = Stroke::new(2.0, status_color);
8789
8790            // Checkmark path: short line going down-left, then longer line going up-right
8791            // Centered at indicator_center
8792            let start = egui::pos2(indicator_center.x - check_size, indicator_center.y);
8793            let mid = egui::pos2(
8794                indicator_center.x - check_size * 0.3,
8795                indicator_center.y + check_size * 0.7,
8796            );
8797            let end = egui::pos2(
8798                indicator_center.x + check_size,
8799                indicator_center.y - check_size * 0.6,
8800            );
8801
8802            ui.painter().line_segment([start, mid], stroke);
8803            ui.painter().line_segment([mid, end], stroke);
8804        } else {
8805            // Draw dot for running states
8806            ui.painter()
8807                .circle_filled(indicator_center, status_dot_radius, status_color);
8808        }
8809
8810        // Draw text
8811        let text_color = if is_active {
8812            colors::TEXT_PRIMARY
8813        } else if is_hovered {
8814            colors::TEXT_SECONDARY
8815        } else {
8816            colors::TEXT_MUTED
8817        };
8818
8819        let text_x = rect.left() + TAB_PADDING_H + status_dot_space;
8820        let text_pos = egui::pos2(text_x, rect.center().y - text_size.y / 2.0);
8821
8822        ui.painter().galley(
8823            text_pos,
8824            ui.fonts(|f| {
8825                f.layout_no_wrap(
8826                    label.to_string(),
8827                    typography::font(
8828                        FontSize::Body,
8829                        if is_active {
8830                            FontWeight::SemiBold
8831                        } else {
8832                            FontWeight::Medium
8833                        },
8834                    ),
8835                    text_color,
8836                )
8837            }),
8838            Color32::TRANSPARENT,
8839        );
8840
8841        // US-002, US-003: Draw close button only when run has finished
8842        let close_hovered = if show_close_button {
8843            let close_rect = Rect::from_min_size(
8844                egui::pos2(
8845                    rect.right() - TAB_PADDING_H - TAB_CLOSE_BUTTON_SIZE,
8846                    rect.center().y - TAB_CLOSE_BUTTON_SIZE / 2.0,
8847                ),
8848                egui::vec2(TAB_CLOSE_BUTTON_SIZE, TAB_CLOSE_BUTTON_SIZE),
8849            );
8850
8851            // Check if mouse is over the close button
8852            let hovered = ui
8853                .ctx()
8854                .input(|i| i.pointer.hover_pos())
8855                .is_some_and(|pos| close_rect.contains(pos));
8856
8857            // Draw close button background on hover
8858            if hovered {
8859                ui.painter().rect_filled(
8860                    close_rect,
8861                    Rounding::same(rounding::SMALL),
8862                    colors::SURFACE_HOVER,
8863                );
8864            }
8865
8866            // Draw X icon
8867            let x_color = if hovered {
8868                colors::TEXT_PRIMARY
8869            } else {
8870                colors::TEXT_MUTED
8871            };
8872            let x_center = close_rect.center();
8873            let x_size = TAB_CLOSE_BUTTON_SIZE * 0.3;
8874
8875            ui.painter().line_segment(
8876                [
8877                    egui::pos2(x_center.x - x_size, x_center.y - x_size),
8878                    egui::pos2(x_center.x + x_size, x_center.y + x_size),
8879                ],
8880                Stroke::new(1.5, x_color),
8881            );
8882            ui.painter().line_segment(
8883                [
8884                    egui::pos2(x_center.x + x_size, x_center.y - x_size),
8885                    egui::pos2(x_center.x - x_size, x_center.y + x_size),
8886                ],
8887                Stroke::new(1.5, x_color),
8888            );
8889
8890            hovered
8891        } else {
8892            // US-002: When close button is hidden, area does not respond to clicks
8893            false
8894        };
8895
8896        // Draw underline indicator for active tab
8897        if is_active {
8898            let underline_rect = egui::Rect::from_min_size(
8899                egui::pos2(rect.left(), rect.bottom()),
8900                egui::vec2(rect.width(), TAB_UNDERLINE_HEIGHT),
8901            );
8902            ui.painter()
8903                .rect_filled(underline_rect, Rounding::ZERO, colors::ACCENT);
8904        }
8905
8906        // Close button click takes precedence over tab click
8907        // US-002: close_hovered is always false when button is hidden, so close_clicked will be false
8908        let close_clicked = response.clicked() && close_hovered;
8909        let tab_clicked = response.clicked() && !close_hovered;
8910
8911        (tab_clicked, close_clicked)
8912    }
8913
8914    /// Render the expanded view for a selected session.
8915    ///
8916    /// This view takes most of the available space and shows:
8917    /// - Session metadata (project, branch, state, progress) at top
8918    /// - Output section with scrolling content
8919    /// - Stories section below (with integrated work summaries)
8920    ///
8921    /// Uses a single outer scroll area so the whole view is scrollable.
8922    /// Styled to match RunDetail view with Title-sized header and consistent padding.
8923    fn render_expanded_session_view(&mut self, ui: &mut egui::Ui, session: &SessionData) {
8924        let available_width = ui.available_width();
8925        let available_height = ui.available_height();
8926
8927        // Use the full available area with padding matching RunDetail style
8928        let content_padding = spacing::LG;
8929        let content_width = available_width - content_padding * 2.0;
8930        let section_gap = spacing::LG;
8931
8932        // Single outer scroll area for the whole view
8933        egui::ScrollArea::vertical()
8934            .id_salt(format!("expanded_view_{}", session.metadata.session_id))
8935            .auto_shrink([false, false])
8936            .show(ui, |ui| {
8937                ui.add_space(content_padding);
8938
8939                ui.horizontal(|ui| {
8940                    ui.add_space(content_padding);
8941
8942                    ui.vertical(|ui| {
8943                        ui.set_width(content_width);
8944
8945                        // === HEADER: Branch name (primary identifier) with session badge ===
8946                        let branch_display = strip_worktree_prefix(
8947                            &session.metadata.branch_name,
8948                            &session.project_name,
8949                        );
8950
8951                        ui.horizontal(|ui| {
8952                            // Branch name as the prominent title (matches RunDetail header size)
8953                            ui.label(
8954                                egui::RichText::new(&branch_display)
8955                                    .font(typography::font(FontSize::Title, FontWeight::SemiBold))
8956                                    .color(colors::TEXT_PRIMARY),
8957                            );
8958
8959                            ui.add_space(spacing::MD);
8960
8961                            // Session badge
8962                            let badge_text = if session.is_main_session {
8963                                "main"
8964                            } else {
8965                                &session.metadata.session_id
8966                            };
8967                            let badge_color = if session.is_main_session {
8968                                colors::ACCENT
8969                            } else {
8970                                colors::TEXT_SECONDARY
8971                            };
8972                            let badge_bg = if session.is_main_session {
8973                                colors::ACCENT_SUBTLE
8974                            } else {
8975                                colors::SURFACE_HOVER
8976                            };
8977
8978                            egui::Frame::none()
8979                                .fill(badge_bg)
8980                                .rounding(rounding::SMALL)
8981                                .inner_margin(egui::Margin::symmetric(spacing::SM, spacing::XS))
8982                                .show(ui, |ui| {
8983                                    ui.label(
8984                                        egui::RichText::new(badge_text)
8985                                            .font(typography::font(
8986                                                FontSize::Small,
8987                                                FontWeight::Medium,
8988                                            ))
8989                                            .color(badge_color),
8990                                    );
8991                                });
8992                        });
8993
8994                        ui.add_space(spacing::XS);
8995
8996                        // === PROJECT NAME (secondary info) ===
8997                        ui.label(
8998                            egui::RichText::new(&session.project_name)
8999                                .font(typography::font(FontSize::Body, FontWeight::Regular))
9000                                .color(colors::TEXT_MUTED),
9001                        );
9002
9003                        ui.add_space(spacing::MD);
9004
9005                        // === STATUS ROW ===
9006                        let appears_stuck = session.appears_stuck();
9007                        let (state, state_color) = if let Some(ref run) = session.run {
9008                            let base_color = state_to_color(run.machine_state);
9009                            let color = if appears_stuck {
9010                                colors::STATUS_WARNING
9011                            } else {
9012                                base_color
9013                            };
9014                            (run.machine_state, color)
9015                        } else {
9016                            (MachineState::Idle, colors::STATUS_IDLE)
9017                        };
9018
9019                        ui.horizontal(|ui| {
9020                            // Status dot
9021                            let dot_size = 8.0;
9022                            let (rect, _) = ui.allocate_exact_size(
9023                                egui::vec2(dot_size, dot_size),
9024                                Sense::hover(),
9025                            );
9026                            ui.painter()
9027                                .circle_filled(rect.center(), dot_size / 2.0, state_color);
9028
9029                            ui.add_space(spacing::SM);
9030
9031                            // State text
9032                            let state_text = if appears_stuck {
9033                                format!("{} (Not responding)", format_state(state))
9034                            } else {
9035                                format_state(state).to_string()
9036                            };
9037                            ui.label(
9038                                egui::RichText::new(state_text)
9039                                    .font(typography::font(FontSize::Body, FontWeight::Medium))
9040                                    .color(colors::TEXT_PRIMARY),
9041                            );
9042
9043                            // Progress info
9044                            if let Some(ref progress) = session.progress {
9045                                ui.add_space(spacing::MD);
9046                                ui.label(
9047                                    egui::RichText::new(progress.as_fraction())
9048                                        .font(typography::font(FontSize::Body, FontWeight::Regular))
9049                                        .color(colors::TEXT_SECONDARY),
9050                                );
9051
9052                                // Current story
9053                                if let Some(ref run) = session.run {
9054                                    if let Some(ref story_id) = run.current_story {
9055                                        ui.add_space(spacing::SM);
9056                                        ui.label(
9057                                            egui::RichText::new(story_id)
9058                                                .font(typography::font(
9059                                                    FontSize::Body,
9060                                                    FontWeight::Regular,
9061                                                ))
9062                                                .color(colors::TEXT_MUTED),
9063                                        );
9064                                    }
9065                                }
9066                            }
9067
9068                            // Duration
9069                            if let Some(ref run) = session.run {
9070                                ui.add_space(spacing::MD);
9071                                ui.label(
9072                                    egui::RichText::new(format_run_duration(
9073                                        run.started_at,
9074                                        run.finished_at,
9075                                    ))
9076                                    .font(typography::font(FontSize::Body, FontWeight::Regular))
9077                                    .color(colors::TEXT_MUTED),
9078                                );
9079                            }
9080
9081                            // Infinity animation for non-idle, non-terminal states with progress
9082                            if state != MachineState::Idle
9083                                && !is_terminal_state(state)
9084                                && session.progress.is_some()
9085                            {
9086                                ui.add_space(spacing::MD);
9087
9088                                // Animation is 1/3 of content width, capped at 150px
9089                                let max_animation_width = (content_width / 3.0).min(150.0);
9090
9091                                if max_animation_width > 30.0 {
9092                                    let animation_height = 12.0;
9093                                    let (rect, _) = ui.allocate_exact_size(
9094                                        egui::vec2(max_animation_width, animation_height),
9095                                        Sense::hover(),
9096                                    );
9097                                    let time = ui.ctx().input(|i| i.time) as f32;
9098                                    super::animation::render_infinity(
9099                                        ui.painter(),
9100                                        time,
9101                                        rect,
9102                                        state_color,
9103                                        1.0,
9104                                    );
9105                                    super::animation::schedule_frame(ui.ctx());
9106                                }
9107                            }
9108                        });
9109
9110                        ui.add_space(spacing::LG);
9111
9112                        // === OUTPUT SECTION ===
9113                        // Section header
9114                        ui.label(
9115                            egui::RichText::new("Output")
9116                                .font(typography::font(FontSize::Body, FontWeight::Medium))
9117                                .color(colors::TEXT_SECONDARY),
9118                        );
9119
9120                        ui.add_space(spacing::SM);
9121
9122                        // Output content with fixed height and internal scrolling
9123                        let output_height = (available_height * 0.4).max(200.0);
9124                        egui::Frame::none()
9125                            .fill(colors::SURFACE_HOVER)
9126                            .rounding(rounding::CARD)
9127                            .inner_margin(egui::Margin::same(spacing::MD))
9128                            .show(ui, |ui| {
9129                                ui.set_min_height(output_height);
9130                                ui.set_max_height(output_height);
9131                                ui.set_width(content_width - spacing::MD * 2.0);
9132
9133                                egui::ScrollArea::vertical()
9134                                    .id_salt(format!("output_{}", session.metadata.session_id))
9135                                    .auto_shrink([false, false])
9136                                    .stick_to_bottom(true)
9137                                    .show(ui, |ui| {
9138                                        let output_source = get_output_for_session(session);
9139                                        Self::render_output_content(ui, &output_source);
9140                                    });
9141                            });
9142
9143                        // Gap between sections
9144                        ui.add_space(section_gap);
9145
9146                        // === STORIES SECTION ===
9147                        let story_items = load_story_items(session);
9148                        Self::render_stories_section(
9149                            ui,
9150                            &session.metadata.session_id,
9151                            &story_items,
9152                            content_width,
9153                            &mut self.section_collapsed_state,
9154                        );
9155
9156                        // Bottom padding
9157                        ui.add_space(content_padding);
9158                    });
9159                });
9160            });
9161    }
9162
9163    /// Render story items content (extracted for reuse in both layouts).
9164    fn render_story_items_content(ui: &mut egui::Ui, story_items: &[StoryItem]) {
9165        if story_items.is_empty() {
9166            ui.label(
9167                egui::RichText::new("No stories found")
9168                    .font(typography::font(FontSize::Body, FontWeight::Regular))
9169                    .color(colors::TEXT_DISABLED),
9170            );
9171        } else {
9172            for (index, story) in story_items.iter().enumerate() {
9173                if index > 0 {
9174                    ui.add_space(spacing::SM);
9175                }
9176
9177                let is_active = story.status == StoryStatus::Active;
9178
9179                egui::Frame::none()
9180                    .fill(story.status.background())
9181                    .rounding(rounding::SMALL)
9182                    .inner_margin(egui::Margin::symmetric(spacing::SM, spacing::XS))
9183                    .show(ui, |ui| {
9184                        ui.horizontal(|ui| {
9185                            ui.label(
9186                                egui::RichText::new(story.status.indicator())
9187                                    .font(typography::font(FontSize::Body, FontWeight::Medium))
9188                                    .color(story.status.color()),
9189                            );
9190
9191                            ui.add_space(spacing::SM);
9192
9193                            let id_weight = if is_active {
9194                                FontWeight::SemiBold
9195                            } else {
9196                                FontWeight::Medium
9197                            };
9198                            let id_color = if is_active {
9199                                colors::ACCENT
9200                            } else {
9201                                colors::TEXT_PRIMARY
9202                            };
9203                            ui.label(
9204                                egui::RichText::new(&story.id)
9205                                    .font(typography::font(FontSize::Small, id_weight))
9206                                    .color(id_color),
9207                            );
9208                        });
9209
9210                        let title_weight = if is_active {
9211                            FontWeight::Medium
9212                        } else {
9213                            FontWeight::Regular
9214                        };
9215                        let title_color = if is_active {
9216                            colors::TEXT_PRIMARY
9217                        } else {
9218                            colors::TEXT_SECONDARY
9219                        };
9220                        ui.label(
9221                            egui::RichText::new(&story.title)
9222                                .font(typography::font(FontSize::Small, title_weight))
9223                                .color(title_color),
9224                        );
9225
9226                        // Show work summary for completed stories
9227                        if let Some(ref summary) = story.work_summary {
9228                            ui.add_space(spacing::XS);
9229                            ui.label(
9230                                egui::RichText::new(summary)
9231                                    .font(typography::font(FontSize::Small, FontWeight::Regular))
9232                                    .color(colors::TEXT_MUTED),
9233                            );
9234                        }
9235                    });
9236            }
9237        }
9238    }
9239
9240    /// Render output content from an OutputSource (extracted for reuse in both layouts).
9241    fn render_output_content(ui: &mut egui::Ui, output_source: &OutputSource) {
9242        match output_source {
9243            OutputSource::Live(lines) | OutputSource::Iteration(lines) => {
9244                for line in lines {
9245                    ui.label(
9246                        egui::RichText::new(line.trim())
9247                            .font(typography::mono(FontSize::Small))
9248                            .color(colors::TEXT_SECONDARY),
9249                    );
9250                }
9251            }
9252            OutputSource::StatusMessage(message) => {
9253                ui.label(
9254                    egui::RichText::new(message)
9255                        .font(typography::mono(FontSize::Small))
9256                        .color(colors::TEXT_DISABLED),
9257                );
9258            }
9259            OutputSource::NoData => {
9260                ui.label(
9261                    egui::RichText::new("No live output")
9262                        .font(typography::mono(FontSize::Small))
9263                        .color(colors::TEXT_DISABLED),
9264                );
9265            }
9266        }
9267    }
9268
9269    /// Render detail sections (Stories with integrated work summaries) with collapsible headers.
9270    fn render_stories_section(
9271        ui: &mut egui::Ui,
9272        session_id: &str,
9273        story_items: &[StoryItem],
9274        panel_width: f32,
9275        collapsed_state: &mut std::collections::HashMap<String, bool>,
9276    ) {
9277        // Use session-specific ID to avoid shared state across sessions
9278        let stories_id = format!("{}_stories", session_id);
9279
9280        // === Stories Section ===
9281        CollapsibleSection::new(&stories_id, "Stories")
9282            .default_expanded(true)
9283            .show(ui, collapsed_state, |ui| {
9284                egui::Frame::none()
9285                    .fill(colors::SURFACE_HOVER)
9286                    .rounding(rounding::CARD)
9287                    .inner_margin(egui::Margin::same(spacing::MD))
9288                    .show(ui, |ui| {
9289                        ui.set_width(panel_width - spacing::MD * 2.0);
9290                        Self::render_story_items_content(ui, story_items);
9291                    });
9292            });
9293    }
9294
9295    /// Render the empty state for Active Runs view.
9296    fn render_empty_active_runs(&self, ui: &mut egui::Ui) {
9297        ui.add_space(spacing::XXL);
9298
9299        // Center the empty state message
9300        ui.vertical_centered(|ui| {
9301            ui.add_space(spacing::XXL + spacing::LG);
9302
9303            ui.label(
9304                egui::RichText::new("No active runs")
9305                    .font(typography::font(FontSize::Heading, FontWeight::Medium))
9306                    .color(colors::TEXT_MUTED),
9307            );
9308
9309            ui.add_space(spacing::SM);
9310
9311            ui.label(
9312                egui::RichText::new("Run autom8 to start implementing a feature")
9313                    .font(typography::font(FontSize::Body, FontWeight::Regular))
9314                    .color(colors::TEXT_MUTED),
9315            );
9316        });
9317    }
9318
9319    // state_to_color is now imported from the components module.
9320
9321    /// Render the Projects view with split layout.
9322    /// Left half shows the compact project list, right half is reserved for detail panel.
9323    fn render_projects(&mut self, ui: &mut egui::Ui) {
9324        // Use horizontal layout for split view
9325        let available_width = ui.available_width();
9326        let available_height = ui.available_height();
9327
9328        // Calculate panel widths: 50/50 split with divider in the middle
9329        // Subtract the divider width and margins from the total width
9330        let divider_total_width = SPLIT_DIVIDER_WIDTH + SPLIT_DIVIDER_MARGIN * 2.0;
9331        let panel_width =
9332            ((available_width - divider_total_width) / 2.0).max(SPLIT_PANEL_MIN_WIDTH);
9333
9334        // We need to collect the clicked_run_id outside the closure
9335        let mut clicked_run_id: Option<String> = None;
9336
9337        ui.horizontal(|ui| {
9338            // Left panel: Project list
9339            ui.allocate_ui_with_layout(
9340                Vec2::new(panel_width, available_height),
9341                egui::Layout::top_down(egui::Align::LEFT),
9342                |ui| {
9343                    self.render_projects_left_panel(ui);
9344                },
9345            );
9346
9347            // Visual divider between panels with appropriate margin
9348            ui.add_space(SPLIT_DIVIDER_MARGIN);
9349
9350            // Draw a custom vertical divider line using the SEPARATOR color
9351            let divider_rect = ui.available_rect_before_wrap();
9352            let divider_line_rect = Rect::from_min_size(
9353                divider_rect.min,
9354                Vec2::new(SPLIT_DIVIDER_WIDTH, available_height),
9355            );
9356            ui.painter()
9357                .rect_filled(divider_line_rect, Rounding::ZERO, colors::SEPARATOR);
9358            ui.add_space(SPLIT_DIVIDER_WIDTH);
9359
9360            ui.add_space(SPLIT_DIVIDER_MARGIN);
9361
9362            // Right panel: Run history for selected project
9363            ui.allocate_ui_with_layout(
9364                Vec2::new(ui.available_width(), available_height),
9365                egui::Layout::top_down(egui::Align::LEFT),
9366                |ui| {
9367                    clicked_run_id = self.render_projects_right_panel(ui);
9368                },
9369            );
9370        });
9371
9372        // Handle click on run history entry - open detail tab
9373        if let Some(run_id) = clicked_run_id {
9374            // Find the entry in run_history to get the label
9375            if let Some(entry) = self.run_history.iter().find(|e| e.run_id == run_id) {
9376                let entry_clone = entry.clone();
9377
9378                // Try to load the full run state for the detail view
9379                if let Some(ref project_name) = self.selected_project {
9380                    let run_state = StateManager::for_project(project_name).ok().and_then(|sm| {
9381                        sm.list_archived()
9382                            .ok()
9383                            .and_then(|runs| runs.into_iter().find(|r| r.run_id == run_id))
9384                    });
9385
9386                    self.open_run_detail_from_entry(&entry_clone, run_state);
9387                }
9388            }
9389        }
9390    }
9391
9392    /// Render the left panel of the Projects view (project list).
9393    fn render_projects_left_panel(&mut self, ui: &mut egui::Ui) {
9394        // Header section with consistent spacing
9395        ui.label(
9396            egui::RichText::new("Projects")
9397                .font(typography::font(FontSize::Title, FontWeight::SemiBold))
9398                .color(colors::TEXT_PRIMARY),
9399        );
9400
9401        ui.add_space(spacing::SM);
9402
9403        // Empty state or list
9404        if self.projects.is_empty() {
9405            self.render_empty_projects(ui);
9406        } else {
9407            self.render_projects_list(ui);
9408        }
9409    }
9410
9411    /// Render the right panel of the Projects view.
9412    /// Shows hint text when no project is selected, or run history when selected.
9413    /// Returns the run_id of a clicked entry, if any.
9414    fn render_projects_right_panel(&self, ui: &mut egui::Ui) -> Option<String> {
9415        let mut clicked_run_id: Option<String> = None;
9416
9417        if let Some(ref selected_name) = self.selected_project {
9418            // Header: Project name
9419            ui.label(
9420                egui::RichText::new(format!("Run History: {}", selected_name))
9421                    .font(typography::font(FontSize::Title, FontWeight::SemiBold))
9422                    .color(colors::TEXT_PRIMARY),
9423            );
9424
9425            ui.add_space(spacing::MD);
9426
9427            // Check for error state first
9428            if let Some(ref error) = self.run_history_error {
9429                self.render_run_history_error(ui, error);
9430            } else if self.run_history_loading {
9431                // Show loading indicator
9432                self.render_run_history_loading(ui);
9433            } else if self.run_history.is_empty() {
9434                // Empty state for no run history
9435                self.render_run_history_empty(ui);
9436            } else {
9437                // Scrollable run history list
9438                egui::ScrollArea::vertical()
9439                    .id_salt("projects_right_panel")
9440                    .auto_shrink([false, false])
9441                    .scroll_bar_visibility(
9442                        egui::scroll_area::ScrollBarVisibility::VisibleWhenNeeded,
9443                    )
9444                    .show(ui, |ui| {
9445                        for entry in &self.run_history {
9446                            if self.render_run_history_entry(ui, entry) {
9447                                clicked_run_id = Some(entry.run_id.clone());
9448                            }
9449                            ui.add_space(spacing::SM);
9450                        }
9451                    });
9452            }
9453        } else {
9454            // Empty state when no project is selected
9455            self.render_no_project_selected(ui);
9456        }
9457
9458        clicked_run_id
9459    }
9460
9461    /// Render loading indicator for run history.
9462    fn render_run_history_loading(&self, ui: &mut egui::Ui) {
9463        ui.add_space(spacing::LG);
9464        ui.vertical_centered(|ui| {
9465            // Custom spinner using theme accent color for visual consistency
9466            let spinner_size = 24.0;
9467            let (rect, _) = ui.allocate_exact_size(Vec2::splat(spinner_size), egui::Sense::hover());
9468
9469            if ui.is_rect_visible(rect) {
9470                // Draw a simple animated arc spinner in accent color
9471                let center = rect.center();
9472                let radius = spinner_size / 2.0 - 2.0;
9473                let time = ui.input(|i| i.time);
9474                let start_angle = (time * 2.0) as f32 % std::f32::consts::TAU;
9475                let arc_length = std::f32::consts::PI * 1.5;
9476
9477                // Draw the spinner arc
9478                let n_points = 32;
9479                let points: Vec<_> = (0..=n_points)
9480                    .map(|i| {
9481                        let angle = start_angle + (i as f32 / n_points as f32) * arc_length;
9482                        egui::pos2(
9483                            center.x + radius * angle.cos(),
9484                            center.y + radius * angle.sin(),
9485                        )
9486                    })
9487                    .collect();
9488
9489                ui.painter()
9490                    .add(egui::Shape::line(points, Stroke::new(2.5, colors::ACCENT)));
9491
9492                // Request repaint for animation
9493                ui.ctx().request_repaint();
9494            }
9495
9496            ui.add_space(spacing::SM);
9497
9498            ui.label(
9499                egui::RichText::new("Loading run history...")
9500                    .font(typography::font(FontSize::Body, FontWeight::Regular))
9501                    .color(colors::TEXT_MUTED),
9502            );
9503        });
9504    }
9505
9506    /// Render error state for run history.
9507    fn render_run_history_error(&self, ui: &mut egui::Ui, error: &str) {
9508        ui.add_space(spacing::LG);
9509        ui.vertical_centered(|ui| {
9510            ui.label(
9511                egui::RichText::new("Failed to load run history")
9512                    .font(typography::font(FontSize::Body, FontWeight::Medium))
9513                    .color(colors::STATUS_ERROR),
9514            );
9515
9516            ui.add_space(spacing::XS);
9517
9518            ui.label(
9519                egui::RichText::new(truncate_with_ellipsis(error, 60))
9520                    .font(typography::font(FontSize::Small, FontWeight::Regular))
9521                    .color(colors::TEXT_MUTED),
9522            );
9523        });
9524    }
9525
9526    /// Render empty state when run history has no entries.
9527    fn render_run_history_empty(&self, ui: &mut egui::Ui) {
9528        ui.add_space(spacing::XXL);
9529        ui.vertical_centered(|ui| {
9530            ui.add_space(spacing::LG);
9531
9532            ui.label(
9533                egui::RichText::new("No run history")
9534                    .font(typography::font(FontSize::Heading, FontWeight::Medium))
9535                    .color(colors::TEXT_MUTED),
9536            );
9537
9538            ui.add_space(spacing::SM);
9539
9540            ui.label(
9541                egui::RichText::new("Completed runs will appear here")
9542                    .font(typography::font(FontSize::Body, FontWeight::Regular))
9543                    .color(colors::TEXT_MUTED),
9544            );
9545        });
9546    }
9547
9548    /// Render empty state when no project is selected.
9549    fn render_no_project_selected(&self, ui: &mut egui::Ui) {
9550        ui.add_space(spacing::XXL);
9551        ui.vertical_centered(|ui| {
9552            ui.label(
9553                egui::RichText::new("Select a project")
9554                    .font(typography::font(FontSize::Heading, FontWeight::Medium))
9555                    .color(colors::TEXT_MUTED),
9556            );
9557
9558            ui.add_space(spacing::SM);
9559
9560            ui.label(
9561                egui::RichText::new("Click on a project to view its run history")
9562                    .font(typography::font(FontSize::Body, FontWeight::Regular))
9563                    .color(colors::TEXT_MUTED),
9564            );
9565        });
9566    }
9567
9568    /// Render a single run history entry as a card.
9569    /// Returns true if the entry was clicked.
9570    fn render_run_history_entry(&self, ui: &mut egui::Ui, entry: &RunHistoryEntry) -> bool {
9571        // Card background - use consistent height from constants
9572        let available_width = ui.available_width();
9573        let card_height = 72.0; // Fixed height for history cards
9574
9575        let (rect, response) =
9576            ui.allocate_exact_size(Vec2::new(available_width, card_height), Sense::click());
9577
9578        let is_hovered = response.hovered();
9579
9580        // Draw card background with hover state - consistent with project row pattern
9581        // Uses SURFACE as default, SURFACE_HOVER on hover, and border feedback
9582        let bg_color = if is_hovered {
9583            colors::SURFACE_HOVER
9584        } else {
9585            colors::SURFACE
9586        };
9587
9588        // Border changes on hover for visual feedback - consistent with project rows
9589        let border = if is_hovered {
9590            Stroke::new(1.0, colors::BORDER_FOCUSED)
9591        } else {
9592            Stroke::new(1.0, colors::BORDER)
9593        };
9594
9595        ui.painter()
9596            .rect(rect, Rounding::same(rounding::CARD), bg_color, border);
9597
9598        // Card content
9599        let inner_rect = rect.shrink(spacing::MD);
9600        let mut child_ui = ui.new_child(
9601            egui::UiBuilder::new()
9602                .max_rect(inner_rect)
9603                .layout(egui::Layout::top_down(egui::Align::LEFT)),
9604        );
9605
9606        // Top row: Date/time and status
9607        child_ui.horizontal(|ui| {
9608            // Date/time (left)
9609            let datetime_text = entry
9610                .started_at
9611                .with_timezone(&chrono::Local)
9612                .format("%Y-%m-%d %I:%M %p")
9613                .to_string();
9614            ui.label(
9615                egui::RichText::new(datetime_text)
9616                    .font(typography::font(FontSize::Body, FontWeight::Medium))
9617                    .color(colors::TEXT_PRIMARY),
9618            );
9619
9620            ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
9621                // Status badge (right)
9622                let status_color = entry.status_color();
9623                let status_text = entry.status_text();
9624
9625                // Draw status badge background
9626                let badge_galley = ui.fonts(|f| {
9627                    f.layout_no_wrap(
9628                        status_text.to_string(),
9629                        typography::font(FontSize::Small, FontWeight::Medium),
9630                        colors::TEXT_PRIMARY,
9631                    )
9632                });
9633                let badge_width = badge_galley.rect.width() + spacing::MD * 2.0;
9634                let badge_height = badge_galley.rect.height() + spacing::XS * 2.0;
9635
9636                let (badge_rect, _) =
9637                    ui.allocate_exact_size(Vec2::new(badge_width, badge_height), Sense::hover());
9638
9639                ui.painter().rect_filled(
9640                    badge_rect,
9641                    Rounding::same(rounding::SMALL),
9642                    badge_background_color(status_color),
9643                );
9644
9645                // Center the text in the badge
9646                let text_pos = badge_rect.center() - badge_galley.rect.center().to_vec2();
9647                ui.painter().galley(text_pos, badge_galley, status_color);
9648            });
9649        });
9650
9651        child_ui.add_space(spacing::XS);
9652
9653        // Bottom row: Story count and branch
9654        child_ui.horizontal(|ui| {
9655            // Story count
9656            ui.label(
9657                egui::RichText::new(entry.story_count_text())
9658                    .font(typography::font(FontSize::Small, FontWeight::Regular))
9659                    .color(colors::TEXT_SECONDARY),
9660            );
9661
9662            ui.add_space(spacing::MD);
9663
9664            // Branch name (truncated)
9665            let branch_display = truncate_with_ellipsis(&entry.branch, MAX_BRANCH_LENGTH);
9666            ui.label(
9667                egui::RichText::new(format!("⎇ {}", branch_display))
9668                    .font(typography::font(FontSize::Small, FontWeight::Regular))
9669                    .color(colors::TEXT_MUTED),
9670            );
9671        });
9672
9673        response.clicked()
9674    }
9675
9676    /// Render the empty state for Projects view.
9677    fn render_empty_projects(&self, ui: &mut egui::Ui) {
9678        ui.add_space(spacing::XXL);
9679
9680        // Center the empty state message
9681        ui.vertical_centered(|ui| {
9682            ui.add_space(spacing::XXL + spacing::LG);
9683
9684            ui.label(
9685                egui::RichText::new("No projects found")
9686                    .font(typography::font(FontSize::Heading, FontWeight::Medium))
9687                    .color(colors::TEXT_MUTED),
9688            );
9689
9690            ui.add_space(spacing::SM);
9691
9692            ui.label(
9693                egui::RichText::new("Projects will appear here after running autom8")
9694                    .font(typography::font(FontSize::Body, FontWeight::Regular))
9695                    .color(colors::TEXT_MUTED),
9696            );
9697        });
9698    }
9699
9700    /// Render the projects list with scrolling.
9701    fn render_projects_list(&mut self, ui: &mut egui::Ui) {
9702        // Clone project names to avoid borrow issues when handling clicks
9703        let project_names: Vec<String> =
9704            self.projects.iter().map(|p| p.info.name.clone()).collect();
9705        let selected = self.selected_project.clone();
9706
9707        // Collect interactions to handle after rendering (to avoid borrow issues)
9708        let mut interactions: Vec<(String, ProjectRowInteraction)> = Vec::new();
9709
9710        egui::ScrollArea::vertical()
9711            .id_salt("projects_left_panel")
9712            .auto_shrink([false, false])
9713            .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::VisibleWhenNeeded)
9714            .show(ui, |ui| {
9715                for (idx, project_name) in project_names.iter().enumerate() {
9716                    let project = &self.projects[idx];
9717                    let is_selected = selected.as_deref() == Some(project_name.as_str());
9718                    let interaction = self.render_project_row(ui, project, is_selected);
9719                    if interaction.clicked || interaction.right_click_pos.is_some() {
9720                        interactions.push((project_name.clone(), interaction));
9721                    }
9722                    ui.add_space(spacing::XS);
9723                }
9724            });
9725
9726        // Handle interactions after rendering
9727        for (project_name, interaction) in interactions {
9728            if interaction.clicked {
9729                // Left-click: toggle selection and load history
9730                self.toggle_project_selection(&project_name);
9731            } else if let Some(pos) = interaction.right_click_pos {
9732                // Right-click: open context menu
9733                self.open_context_menu(pos, project_name);
9734            }
9735        }
9736    }
9737
9738    /// Count active sessions for a given project name.
9739    fn count_active_sessions_for_project(&self, project_name: &str) -> usize {
9740        self.sessions
9741            .iter()
9742            .filter(|s| s.project_name == project_name && !s.is_stale)
9743            .count()
9744    }
9745
9746    /// Get the project status indicator color.
9747    /// Green = running, Red = error, Gray = idle
9748    fn project_status_color(&self, project: &ProjectData) -> Color32 {
9749        if let Some(ref error) = project.load_error {
9750            // Has an error
9751            if !error.is_empty() {
9752                return colors::STATUS_ERROR;
9753            }
9754        }
9755
9756        if project.info.has_active_run {
9757            colors::STATUS_RUNNING
9758        } else {
9759            colors::STATUS_IDLE
9760        }
9761    }
9762
9763    /// Get the status text for a project.
9764    /// Returns "Running", "N sessions active", "Idle", or "Last run: X ago"
9765    fn project_status_text(&self, project: &ProjectData) -> String {
9766        // Check for errors first
9767        if let Some(ref error) = project.load_error {
9768            if !error.is_empty() {
9769                return truncate_with_ellipsis(error, 30);
9770            }
9771        }
9772
9773        // Count active sessions for this project
9774        let active_count = self.count_active_sessions_for_project(&project.info.name);
9775
9776        if active_count > 1 {
9777            format!("{} sessions active", active_count)
9778        } else if project.info.has_active_run || active_count == 1 {
9779            "Running".to_string()
9780        } else if let Some(last_run) = project.info.last_run_date {
9781            format!("Last run: {}", format_relative_time(last_run))
9782        } else {
9783            "Idle".to_string()
9784        }
9785    }
9786
9787    /// Render a single project row.
9788    /// Returns interaction information (left-click and right-click).
9789    fn render_project_row(
9790        &self,
9791        ui: &mut egui::Ui,
9792        project: &ProjectData,
9793        is_selected: bool,
9794    ) -> ProjectRowInteraction {
9795        let row_size = Vec2::new(ui.available_width(), PROJECT_ROW_HEIGHT);
9796
9797        // Allocate space for the row with click interaction (both primary and secondary)
9798        let (rect, response) = ui.allocate_exact_size(row_size, Sense::click());
9799
9800        // Skip if not visible (optimization for scrolling)
9801        if !ui.is_rect_visible(rect) {
9802            return ProjectRowInteraction::none();
9803        }
9804
9805        let painter = ui.painter();
9806        let is_hovered = response.hovered();
9807        let was_clicked = response.clicked();
9808        let was_secondary_clicked = response.secondary_clicked();
9809
9810        // Set pointer cursor for project rows
9811        response.on_hover_cursor(egui::CursorIcon::PointingHand);
9812
9813        // Draw row background with hover and selected states
9814        let bg_color = if is_selected {
9815            colors::SURFACE_SELECTED
9816        } else if is_hovered {
9817            colors::SURFACE_HOVER
9818        } else {
9819            colors::SURFACE
9820        };
9821
9822        // Use accent color border for selected state, stronger border for hover
9823        let border_color = if is_selected {
9824            colors::ACCENT
9825        } else if is_hovered {
9826            colors::BORDER_FOCUSED
9827        } else {
9828            colors::BORDER
9829        };
9830
9831        let border_width = if is_selected { 2.0 } else { 1.0 };
9832
9833        painter.rect(
9834            rect,
9835            Rounding::same(rounding::BUTTON),
9836            bg_color,
9837            Stroke::new(border_width, border_color),
9838        );
9839
9840        // Content layout within the row
9841        let content_rect = rect.shrink2(Vec2::new(PROJECT_ROW_PADDING_H, PROJECT_ROW_PADDING_V));
9842        let mut cursor_x = content_rect.min.x;
9843        let center_y = content_rect.center().y;
9844
9845        // ====================================================================
9846        // STATUS INDICATOR DOT
9847        // ====================================================================
9848        let status_color = self.project_status_color(project);
9849        let dot_center = egui::pos2(cursor_x + PROJECT_STATUS_DOT_RADIUS, center_y);
9850        painter.circle_filled(dot_center, PROJECT_STATUS_DOT_RADIUS, status_color);
9851        cursor_x += PROJECT_STATUS_DOT_RADIUS * 2.0 + spacing::MD;
9852
9853        // ====================================================================
9854        // PROJECT NAME
9855        // ====================================================================
9856        let name_text = truncate_with_ellipsis(&project.info.name, 30);
9857        let name_galley = painter.layout_no_wrap(
9858            name_text,
9859            typography::font(FontSize::Body, FontWeight::SemiBold),
9860            colors::TEXT_PRIMARY,
9861        );
9862        let name_y = center_y - name_galley.rect.height() / 2.0 - 6.0;
9863        painter.galley(
9864            egui::pos2(cursor_x, name_y),
9865            name_galley.clone(),
9866            Color32::TRANSPARENT,
9867        );
9868
9869        // ====================================================================
9870        // STATUS TEXT (below project name)
9871        // ====================================================================
9872        let status_text = self.project_status_text(project);
9873        let status_text_color = if project.load_error.is_some() {
9874            colors::STATUS_ERROR
9875        } else if project.info.has_active_run
9876            || self.count_active_sessions_for_project(&project.info.name) > 0
9877        {
9878            colors::STATUS_RUNNING
9879        } else {
9880            colors::TEXT_MUTED
9881        };
9882        let status_galley = painter.layout_no_wrap(
9883            status_text,
9884            typography::font(FontSize::Caption, FontWeight::Regular),
9885            status_text_color,
9886        );
9887        let status_y = name_y + name_galley.rect.height() + spacing::XS;
9888        painter.galley(
9889            egui::pos2(cursor_x, status_y),
9890            status_galley,
9891            Color32::TRANSPARENT,
9892        );
9893
9894        // ====================================================================
9895        // LAST ACTIVITY (right-aligned)
9896        // ====================================================================
9897        if let Some(last_run) = project.info.last_run_date {
9898            let activity_text = format_relative_time(last_run);
9899            let activity_galley = painter.layout_no_wrap(
9900                activity_text,
9901                typography::font(FontSize::Caption, FontWeight::Regular),
9902                colors::TEXT_MUTED,
9903            );
9904            let activity_x = content_rect.max.x - activity_galley.rect.width();
9905            let activity_y = center_y - activity_galley.rect.height() / 2.0;
9906            painter.galley(
9907                egui::pos2(activity_x, activity_y),
9908                activity_galley,
9909                Color32::TRANSPARENT,
9910            );
9911        }
9912
9913        // Return interaction info
9914        if was_secondary_clicked {
9915            // Right-click: return position for context menu
9916            // Use the pointer position if available, otherwise center of the row
9917            let menu_pos = ui
9918                .ctx()
9919                .input(|i| i.pointer.hover_pos())
9920                .unwrap_or(rect.center());
9921            ProjectRowInteraction::right_click(menu_pos)
9922        } else if was_clicked {
9923            ProjectRowInteraction::click()
9924        } else {
9925            ProjectRowInteraction::none()
9926        }
9927    }
9928}
9929
9930// ============================================================================
9931// Viewport Configuration (Custom Title Bar - US-002)
9932// ============================================================================
9933
9934/// Load the application window icon from the embedded PNG asset.
9935///
9936/// The icon is embedded at compile time from `assets/icon.png` and decoded
9937/// to RGBA pixel data for use with the window manager.
9938///
9939/// # Returns
9940///
9941/// `IconData` containing the RGBA pixels and dimensions for the window icon.
9942/// Returns `None` if the icon fails to load (graceful degradation to default).
9943fn load_window_icon() -> Option<Arc<egui::IconData>> {
9944    // Embed the icon PNG at compile time
9945    let icon_bytes = include_bytes!("../../../assets/icon.png");
9946
9947    // Use eframe's built-in PNG decoder for proper icon loading
9948    match eframe::icon_data::from_png_bytes(icon_bytes) {
9949        Ok(icon_data) => Some(Arc::new(icon_data)),
9950        Err(_) => {
9951            // Graceful degradation: return None to use default icon
9952            None
9953        }
9954    }
9955}
9956
9957/// Build the viewport configuration for the native window.
9958///
9959/// Configures a custom title bar that blends with the app's background color,
9960/// and sets the application window icon (US-006).
9961fn build_viewport() -> egui::ViewportBuilder {
9962    let mut builder = egui::ViewportBuilder::default()
9963        .with_title("autom8")
9964        .with_inner_size([DEFAULT_WIDTH, DEFAULT_HEIGHT])
9965        .with_min_inner_size([MIN_WIDTH, MIN_HEIGHT])
9966        .with_fullsize_content_view(true)
9967        .with_titlebar_shown(false)
9968        .with_title_shown(false);
9969
9970    // Set the window/dock icon if loading succeeds
9971    if let Some(icon) = load_window_icon() {
9972        builder = builder.with_icon(icon);
9973    }
9974
9975    builder
9976}
9977
9978/// Launch the native GUI application.
9979///
9980/// Opens a native window using eframe with the specified configuration.
9981///
9982/// # Returns
9983///
9984/// * `Ok(())` when the user closes the window
9985/// * `Err(Autom8Error)` if the GUI fails to initialize
9986pub fn run_gui() -> Result<()> {
9987    let options = eframe::NativeOptions {
9988        viewport: build_viewport(),
9989        ..Default::default()
9990    };
9991
9992    eframe::run_native(
9993        "autom8",
9994        options,
9995        Box::new(|cc| {
9996            // Initialize image loaders for embedded images (US-005)
9997            egui_extras::install_image_loaders(&cc.egui_ctx);
9998            // Initialize custom typography (fonts and text styles)
9999            typography::init(&cc.egui_ctx);
10000            // Initialize theme (colors, visuals, and style)
10001            theme::init(&cc.egui_ctx);
10002            Ok(Box::new(Autom8App::new()))
10003        }),
10004    )
10005    .map_err(|e| Autom8Error::GuiError(e.to_string()))
10006}
10007
10008#[cfg(test)]
10009mod tests {
10010    use super::*;
10011    use chrono::Utc;
10012    use std::path::PathBuf;
10013
10014    // =========================================================================
10015    // Test Helpers
10016    // =========================================================================
10017
10018    fn make_test_session_data(
10019        run: Option<crate::state::RunState>,
10020        live_output: Option<crate::state::LiveState>,
10021    ) -> SessionData {
10022        use crate::state::SessionMetadata;
10023
10024        SessionData {
10025            project_name: "test-project".to_string(),
10026            metadata: SessionMetadata {
10027                session_id: "main".to_string(),
10028                worktree_path: PathBuf::from("/test/path"),
10029                branch_name: "test-branch".to_string(),
10030                created_at: Utc::now(),
10031                last_active_at: Utc::now(),
10032                is_running: true,
10033                spec_json_path: None,
10034            },
10035            run,
10036            progress: None,
10037            load_error: None,
10038            is_main_session: true,
10039            is_stale: false,
10040            live_output,
10041            cached_user_stories: None,
10042        }
10043    }
10044
10045    fn make_test_run_state(machine_state: MachineState) -> crate::state::RunState {
10046        crate::state::RunState {
10047            run_id: "test-run".to_string(),
10048            status: crate::state::RunStatus::Running,
10049            machine_state,
10050            spec_json_path: PathBuf::from("/test/spec.json"),
10051            spec_md_path: None,
10052            branch: "test-branch".to_string(),
10053            current_story: None,
10054            iteration: 1,
10055            review_iteration: 0,
10056            started_at: Utc::now(),
10057            finished_at: None,
10058            iterations: vec![],
10059            config: None,
10060            knowledge: Default::default(),
10061            pre_story_commit: None,
10062            session_id: Some("main".to_string()),
10063            total_usage: None,
10064            phase_usage: std::collections::HashMap::new(),
10065        }
10066    }
10067
10068    // =========================================================================
10069    // App Initialization
10070    // =========================================================================
10071
10072    #[test]
10073    fn test_app_initialization() {
10074        let app = Autom8App::new();
10075        assert_eq!(app.current_tab(), Tab::ActiveRuns);
10076        assert_eq!(app.tab_count(), 3); // ActiveRuns, Projects, Config
10077
10078        let interval = Duration::from_millis(100);
10079        let app2 = Autom8App::with_refresh_interval(interval);
10080        assert_eq!(app2.refresh_interval(), interval);
10081    }
10082
10083    // =========================================================================
10084    // Tab System
10085    // =========================================================================
10086
10087    #[test]
10088    fn test_tab_open_close() {
10089        let mut app = Autom8App::new();
10090
10091        // Open tabs
10092        assert!(app.open_run_detail_tab("run-1", "Run 1"));
10093        assert!(!app.open_run_detail_tab("run-1", "Run 1")); // No duplicate
10094        app.open_run_detail_tab("run-2", "Run 2");
10095
10096        assert_eq!(app.tab_count(), 5); // 3 permanent + 2 dynamic
10097        assert_eq!(app.closable_tab_count(), 2);
10098
10099        // Close dynamic tab
10100        assert!(app.close_tab(&TabId::RunDetail("run-1".to_string())));
10101        assert_eq!(app.closable_tab_count(), 1);
10102
10103        // Can't close permanent tabs
10104        assert!(!app.close_tab(&TabId::ActiveRuns));
10105        assert!(!app.close_tab(&TabId::Config));
10106    }
10107
10108    // =========================================================================
10109    // Run History
10110    // =========================================================================
10111
10112    #[test]
10113    fn test_run_history_entry_creation() {
10114        use crate::state::{IterationRecord, IterationStatus, RunState, RunStatus};
10115
10116        let mut run = RunState::new(PathBuf::from("test.json"), "feature/test".to_string());
10117        run.status = RunStatus::Completed;
10118        run.iterations.push(IterationRecord {
10119            number: 1,
10120            story_id: "US-001".to_string(),
10121            started_at: Utc::now(),
10122            finished_at: Some(Utc::now()),
10123            status: IterationStatus::Success,
10124            output_snippet: String::new(),
10125            work_summary: None,
10126            usage: None,
10127        });
10128        run.iterations.push(IterationRecord {
10129            number: 2,
10130            story_id: "US-002".to_string(),
10131            started_at: Utc::now(),
10132            finished_at: None,
10133            status: IterationStatus::Failed,
10134            output_snippet: String::new(),
10135            work_summary: None,
10136            usage: None,
10137        });
10138
10139        let entry = RunHistoryEntry::from_run_state("test-project".to_string(), &run);
10140        assert_eq!(entry.branch, "feature/test");
10141        assert_eq!(entry.completed_stories, 1);
10142        assert_eq!(entry.total_stories, 2);
10143        assert_eq!(entry.story_count_text(), "1/2 stories");
10144        assert_eq!(entry.status_color(), colors::STATUS_SUCCESS);
10145    }
10146
10147    // =========================================================================
10148    // Config Scope
10149    // =========================================================================
10150
10151    #[test]
10152    fn test_config_scope_display() {
10153        assert_eq!(ConfigScope::Global.display_name(), "Global");
10154        assert_eq!(
10155            ConfigScope::Project("my-project".to_string()).display_name(),
10156            "my-project"
10157        );
10158        assert!(ConfigScope::Global.is_global());
10159        assert!(!ConfigScope::Project("test".to_string()).is_global());
10160    }
10161
10162    // =========================================================================
10163    // Output Source Priority
10164    // =========================================================================
10165
10166    #[test]
10167    fn test_output_source_fresh_live_preferred() {
10168        let mut live = crate::state::LiveState::new(MachineState::RunningClaude);
10169        live.output_lines = vec!["Line 1".to_string(), "Line 2".to_string()];
10170
10171        let run = make_test_run_state(MachineState::RunningClaude);
10172        let session = make_test_session_data(Some(run), Some(live));
10173        let output = get_output_for_session(&session);
10174
10175        assert!(matches!(output, OutputSource::Live(_)));
10176        if let OutputSource::Live(lines) = output {
10177            assert_eq!(lines.len(), 2);
10178        }
10179    }
10180
10181    #[test]
10182    fn test_output_source_no_live_returns_no_data() {
10183        let run = make_test_run_state(MachineState::RunningClaude);
10184        let session = make_test_session_data(Some(run), None);
10185        let output = get_output_for_session(&session);
10186
10187        assert!(matches!(output, OutputSource::NoData));
10188    }
10189
10190    #[test]
10191    fn test_output_source_enum_variants() {
10192        let live = OutputSource::Live(vec!["test".to_string()]);
10193        let iter = OutputSource::Iteration(vec!["test".to_string()]);
10194        let status = OutputSource::StatusMessage("test".to_string());
10195        let no_data = OutputSource::NoData;
10196
10197        assert_ne!(live, iter);
10198        assert_ne!(status.clone(), no_data.clone());
10199        assert_eq!(status, OutputSource::StatusMessage("test".to_string()));
10200    }
10201
10202    /// US-004: Tests that iteration output is preserved when live output is empty.
10203    /// This ensures "Waiting for output..." doesn't replace valid previous output.
10204    #[test]
10205    fn test_iteration_output_preserved_when_live_empty() {
10206        use crate::state::{IterationRecord, IterationStatus, LiveState};
10207
10208        // Create a run state with RunningClaude state and iteration with output
10209        let mut run = make_test_run_state(MachineState::RunningClaude);
10210        run.iterations.push(IterationRecord {
10211            number: 1,
10212            story_id: "US-001".to_string(),
10213            started_at: Utc::now(),
10214            finished_at: None,
10215            status: IterationStatus::Running,
10216            output_snippet: "Previous iteration output\nLine 2\nLine 3".to_string(),
10217            work_summary: None,
10218            usage: None,
10219        });
10220
10221        // Create live output with EMPTY output_lines (new invocation just started)
10222        let live = LiveState {
10223            output_lines: vec![], // Empty - this is the bug scenario
10224            updated_at: Utc::now(),
10225            machine_state: MachineState::RunningClaude,
10226            last_heartbeat: Utc::now(),
10227        };
10228
10229        let session = make_test_session_data(Some(run), Some(live));
10230
10231        let output = get_output_for_session(&session);
10232
10233        // Should fall through to iteration output, NOT return "Waiting for output..."
10234        match output {
10235            OutputSource::Iteration(lines) => {
10236                assert!(!lines.is_empty());
10237                assert!(lines
10238                    .iter()
10239                    .any(|l| l.contains("Previous iteration output")));
10240            }
10241            OutputSource::StatusMessage(msg) => {
10242                // This is the bug we're fixing - it should NOT show "Waiting for output..."
10243                panic!(
10244                    "Bug: Should have shown iteration output, not status message: {}",
10245                    msg
10246                );
10247            }
10248            other => panic!("Unexpected output source: {:?}", other),
10249        }
10250    }
10251
10252    /// US-004: When there's no iteration output AND no live output, show status message.
10253    #[test]
10254    fn test_waiting_shown_only_when_no_output() {
10255        use crate::state::LiveState;
10256
10257        // Create a run state with RunningClaude state but NO iterations
10258        let run = make_test_run_state(MachineState::RunningClaude);
10259
10260        // Create live output with empty output_lines
10261        let live = LiveState {
10262            output_lines: vec![],
10263            updated_at: Utc::now(),
10264            machine_state: MachineState::RunningClaude,
10265            last_heartbeat: Utc::now(),
10266        };
10267
10268        let session = make_test_session_data(Some(run), Some(live));
10269
10270        let output = get_output_for_session(&session);
10271
10272        // With no iteration output and no live output, should show status message
10273        match output {
10274            OutputSource::StatusMessage(msg) => {
10275                assert_eq!(msg, "Waiting for output...");
10276            }
10277            other => panic!("Expected StatusMessage, got {:?}", other),
10278        }
10279    }
10280
10281    /// US-005: Tests that output persists across state transitions.
10282    /// When transitioning from RunningClaude to Reviewing (or other states),
10283    /// the previous iteration output should remain visible, not flicker to "Waiting for output...".
10284    #[test]
10285    fn test_output_persists_across_state_transitions() {
10286        use crate::state::{IterationRecord, IterationStatus, LiveState};
10287
10288        // Scenario: Transitioning from RunningClaude to Reviewing
10289        // - Previous iteration has output_snippet populated
10290        // - Current iteration is starting (empty output_snippet)
10291        // - Live output may be stale or empty
10292        let mut run = make_test_run_state(MachineState::Reviewing);
10293
10294        // Previous iteration - completed with output
10295        run.iterations.push(IterationRecord {
10296            number: 1,
10297            story_id: "US-001".to_string(),
10298            started_at: Utc::now(),
10299            finished_at: Some(Utc::now()),
10300            status: IterationStatus::Success,
10301            output_snippet: "Previous iteration completed\nImplemented feature X".to_string(),
10302            work_summary: Some("Implemented feature X".to_string()),
10303            usage: None,
10304        });
10305
10306        // Live output exists but is stale (older than freshness threshold)
10307        let mut live = LiveState::new(MachineState::RunningClaude);
10308        live.updated_at = Utc::now() - chrono::Duration::seconds(10); // Stale
10309
10310        let session = make_test_session_data(Some(run), Some(live));
10311
10312        let output = get_output_for_session(&session);
10313
10314        // Should show previous iteration output, NOT "Reviewing changes..."
10315        match output {
10316            OutputSource::Iteration(lines) => {
10317                assert!(!lines.is_empty());
10318                assert!(lines
10319                    .iter()
10320                    .any(|l| l.contains("Previous iteration completed")));
10321            }
10322            OutputSource::StatusMessage(msg) => {
10323                panic!(
10324                    "Bug: Should have shown iteration output during state transition, not: {}",
10325                    msg
10326                );
10327            }
10328            other => panic!("Unexpected output source: {:?}", other),
10329        }
10330    }
10331
10332    /// US-005: Tests that when a new iteration starts with no output yet,
10333    /// the previous iteration's output should be shown as fallback.
10334    #[test]
10335    fn test_previous_iteration_shown_when_current_has_no_output() {
10336        use crate::state::{IterationRecord, IterationStatus, LiveState};
10337
10338        // Scenario: New iteration just started
10339        // - Previous iteration has output
10340        // - Current iteration is running but has no output yet
10341        let mut run = make_test_run_state(MachineState::RunningClaude);
10342
10343        // Previous iteration - completed with output
10344        run.iterations.push(IterationRecord {
10345            number: 1,
10346            story_id: "US-001".to_string(),
10347            started_at: Utc::now(),
10348            finished_at: Some(Utc::now()),
10349            status: IterationStatus::Success,
10350            output_snippet: "First iteration output\nDid something useful".to_string(),
10351            work_summary: Some("Did something useful".to_string()),
10352            usage: None,
10353        });
10354
10355        // Current iteration - just started, no output yet
10356        run.iterations.push(IterationRecord {
10357            number: 2,
10358            story_id: "US-002".to_string(),
10359            started_at: Utc::now(),
10360            finished_at: None,
10361            status: IterationStatus::Running,
10362            output_snippet: String::new(), // No output yet
10363            work_summary: None,
10364            usage: None,
10365        });
10366
10367        // Live output exists but empty (new invocation just started)
10368        let live = LiveState {
10369            output_lines: vec![], // Empty
10370            updated_at: Utc::now(),
10371            machine_state: MachineState::RunningClaude,
10372            last_heartbeat: Utc::now(),
10373        };
10374
10375        let session = make_test_session_data(Some(run), Some(live));
10376
10377        let output = get_output_for_session(&session);
10378
10379        // Should show previous iteration output as fallback
10380        match output {
10381            OutputSource::Iteration(lines) => {
10382                assert!(!lines.is_empty());
10383                assert!(lines.iter().any(|l| l.contains("First iteration output")));
10384            }
10385            OutputSource::StatusMessage(msg) => {
10386                panic!(
10387                    "Bug: Should have shown previous iteration output, not: {}",
10388                    msg
10389                );
10390            }
10391            other => panic!("Unexpected output source: {:?}", other),
10392        }
10393    }
10394}