From f5c8dabe581436f404dab17e72c47d5d8adef22c Mon Sep 17 00:00:00 2001 From: oso95 Date: Thu, 12 Mar 2026 21:49:43 -0400 Subject: [PATCH 1/9] Fix hidden session access --- crates/codirigent-ui/src/layout/state.rs | 33 ++++ crates/codirigent-ui/src/workspace/core.rs | 155 +++++++++++++-- .../src/workspace/drawer_render.rs | 30 ++- crates/codirigent-ui/src/workspace/tests.rs | 108 ++++++++++- docs/hidden-session-plan.md | 131 +++++++++++++ docs/resizable-split-pane-plan.md | 162 ++++++++++++++++ docs/session-shell-selection-plan.md | 162 ++++++++++++++++ docs/tab-grouping-plan.md | 180 ++++++++++++++++++ 8 files changed, 944 insertions(+), 17 deletions(-) create mode 100644 docs/hidden-session-plan.md create mode 100644 docs/resizable-split-pane-plan.md create mode 100644 docs/session-shell-selection-plan.md create mode 100644 docs/tab-grouping-plan.md diff --git a/crates/codirigent-ui/src/layout/state.rs b/crates/codirigent-ui/src/layout/state.rs index ddaf0f56..15d4ac3b 100644 --- a/crates/codirigent-ui/src/layout/state.rs +++ b/crates/codirigent-ui/src/layout/state.rs @@ -106,6 +106,18 @@ impl LayoutState { } } + /// Append a hidden session after the visible grid assignments. + /// + /// Hidden sessions stay in assignment order so they can be swapped back + /// into a visible cell later without losing workspace ordering. + pub fn append_hidden_session(&mut self, session_id: SessionId) -> bool { + if self.assignments.contains(&session_id) { + return false; + } + self.assignments.push(session_id); + true + } + /// Remove a session from the layout. /// /// # Returns @@ -399,6 +411,27 @@ impl SplitLayoutState { false } + /// Replace the session assigned to a slot. + /// + /// Returns the previous session in the slot, if any. + pub fn replace_session_in_slot( + &mut self, + slot: SlotId, + session_id: SessionId, + ) -> Option> { + if self.assignments.iter().any(|(_, s)| *s == Some(session_id)) { + return None; + } + + let entry = self + .assignments + .iter_mut() + .find(|(current, _)| *current == slot)?; + let previous = entry.1.replace(session_id); + self.focused_slot = Some(slot); + Some(previous) + } + /// Remove a session from its slot. pub fn remove_session(&mut self, session_id: SessionId) -> bool { for entry in &mut self.assignments { diff --git a/crates/codirigent-ui/src/workspace/core.rs b/crates/codirigent-ui/src/workspace/core.rs index 427fc52a..31a12c66 100644 --- a/crates/codirigent-ui/src/workspace/core.rs +++ b/crates/codirigent-ui/src/workspace/core.rs @@ -201,6 +201,17 @@ impl Workspace { } } + if profile != LayoutProfile::Single { + let visible_len = grid_state.assignments().len().min(profile.max_sessions()); + if grid_state + .focused_index() + .is_some_and(|index| index >= visible_len) + && visible_len > 0 + { + grid_state.focus_index(0); + } + } + grid_state } @@ -299,8 +310,18 @@ impl Workspace { return false; } - // Try to add to layout - if !self.layout_state.add_session(id) { + let added_to_visible_layout = match &mut self.layout_state { + WorkspaceLayoutState::Grid(s) => { + if s.add_session(id) { + true + } else { + s.append_hidden_session(id) + } + } + WorkspaceLayoutState::SplitTree(s) => s.add_session(id), + }; + + if !added_to_visible_layout && self.layout_state.is_grid() { return false; } @@ -389,20 +410,42 @@ impl Workspace { /// Get the visible sessions (those assigned to cells/slots). pub fn visible_sessions(&self) -> Vec<&Session> { - self.layout_state - .assigned_sessions() - .iter() - .filter_map(|id| self.session(*id)) + self.visible_session_ids() + .into_iter() + .filter_map(|id| self.session(id)) .collect() } + /// Get the IDs of sessions currently visible in rendered panes. + pub fn visible_session_ids(&self) -> Vec { + match &self.layout_state { + WorkspaceLayoutState::Grid(s) => { + if s.profile() == LayoutProfile::Single { + s.focused_session().into_iter().collect() + } else { + s.assignments() + .iter() + .take(s.profile().max_sessions()) + .copied() + .collect() + } + } + WorkspaceLayoutState::SplitTree(s) => s.assigned_sessions(), + } + } + + /// Returns whether a session is currently visible in the active layout. + pub fn is_session_visible(&self, session_id: SessionId) -> bool { + self.visible_session_ids().contains(&session_id) + } + /// Get the number of sessions that can still be added. pub fn available_slots(&self) -> usize { match &self.layout_state { WorkspaceLayoutState::Grid(s) => s .profile() .max_sessions() - .saturating_sub(s.assignments().len()), + .saturating_sub(s.assignments().len().min(s.profile().max_sessions())), WorkspaceLayoutState::SplitTree(s) => s.available_slots(), } } @@ -425,7 +468,64 @@ impl Workspace { /// /// `true` if the session was found and focused. pub fn focus_session(&mut self, id: SessionId) -> bool { - self.layout_state.focus_session(id) + if self.session(id).is_none() { + return false; + } + + match &mut self.layout_state { + WorkspaceLayoutState::Grid(s) => { + let Some(target_index) = s.assignments().iter().position(|&sid| sid == id) else { + return false; + }; + + if s.profile() == LayoutProfile::Single { + s.focus_index(target_index); + return true; + } + + let visible_len = s.assignments().len().min(s.profile().max_sessions()); + if target_index < visible_len { + s.focus_index(target_index); + return true; + } + + let replacement_index = s + .focused_index() + .filter(|&index| index < visible_len) + .or_else(|| visible_len.checked_sub(1).map(|_| 0)); + + let Some(replacement_index) = replacement_index else { + return false; + }; + + if !s.swap_assignments(target_index, replacement_index) { + return false; + } + + s.focus_index(replacement_index); + true + } + WorkspaceLayoutState::SplitTree(s) => { + if s.focus_session(id) { + return true; + } + + let target_slot = s + .focused_slot() + .and_then(|slot| (s.session_at_slot(slot).is_some()).then_some(slot)) + .or_else(|| { + s.assignments() + .iter() + .find_map(|(slot, session)| session.map(|_| *slot)) + }); + + let Some(target_slot) = target_slot else { + return false; + }; + + s.replace_session_in_slot(target_slot, id).is_some() + } + } } /// Focus a session by grid index (1-based, for keyboard shortcuts). @@ -436,7 +536,13 @@ impl Workspace { pub fn focus_session_number(&mut self, number: usize) -> bool { match &mut self.layout_state { WorkspaceLayoutState::Grid(s) => { - if number == 0 || number > s.assignments().len() { + let visible_len = if s.profile() == LayoutProfile::Single { + s.assignments().len() + } else { + s.assignments().len().min(s.profile().max_sessions()) + }; + + if number == 0 || number > visible_len { return false; } s.focus_index(number - 1); @@ -454,12 +560,39 @@ impl Workspace { /// Focus the next session. pub fn focus_next(&mut self) { - self.layout_state.focus_next(); + match &mut self.layout_state { + WorkspaceLayoutState::Grid(s) if s.profile() != LayoutProfile::Single => { + let visible_len = s.assignments().len().min(s.profile().max_sessions()); + if visible_len == 0 { + return; + } + let next = match s.focused_index().filter(|&index| index < visible_len) { + Some(index) => (index + 1) % visible_len, + None => 0, + }; + s.focus_index(next); + } + _ => self.layout_state.focus_next(), + } } /// Focus the previous session. pub fn focus_previous(&mut self) { - self.layout_state.focus_previous(); + match &mut self.layout_state { + WorkspaceLayoutState::Grid(s) if s.profile() != LayoutProfile::Single => { + let visible_len = s.assignments().len().min(s.profile().max_sessions()); + if visible_len == 0 { + return; + } + let prev = match s.focused_index().filter(|&index| index < visible_len) { + Some(index) if index > 0 => index - 1, + Some(_) => visible_len - 1, + None => 0, + }; + s.focus_index(prev); + } + _ => self.layout_state.focus_previous(), + } } /// Focus in a direction (for arrow key navigation). diff --git a/crates/codirigent-ui/src/workspace/drawer_render.rs b/crates/codirigent-ui/src/workspace/drawer_render.rs index 371c2ec5..bb104269 100644 --- a/crates/codirigent-ui/src/workspace/drawer_render.rs +++ b/crates/codirigent-ui/src/workspace/drawer_render.rs @@ -124,6 +124,8 @@ impl WorkspaceView { let sessions: Vec = self.workspace().sessions().to_vec(); let focused_id = self.workspace().focused_session_id(); + let visible_session_ids: std::collections::HashSet = + self.workspace().visible_session_ids().into_iter().collect(); let session_count = sessions.len(); // Separate ungrouped and grouped sessions @@ -143,7 +145,13 @@ impl WorkspaceView { // Render ungrouped sessions first for session in &ungrouped { - content = content.child(self.render_session_row(session, focused_id, &theme, cx)); + content = content.child(self.render_session_row( + session, + focused_id, + visible_session_ids.contains(&session.id), + &theme, + cx, + )); } // Render grouped sessions with headers @@ -163,8 +171,13 @@ impl WorkspaceView { if expanded { for session in group_sessions { - content = - content.child(self.render_session_row(session, focused_id, &theme, cx)); + content = content.child(self.render_session_row( + session, + focused_id, + visible_session_ids.contains(&session.id), + &theme, + cx, + )); } } } @@ -1033,6 +1046,7 @@ impl WorkspaceView { &mut self, session: &Session, focused_id: Option, + is_visible: bool, theme: &CodirigentTheme, cx: &mut Context, ) -> impl IntoElement { @@ -1040,6 +1054,7 @@ impl WorkspaceView { let fg: gpui::Hsla = theme.foreground.into(); let status_color: gpui::Hsla = theme.status_color(session.status).into(); let is_focused = focused_id == Some(session.id); + let is_hidden = !is_visible; let row_bg = if is_focused { theme.active.into() } else { @@ -1088,6 +1103,15 @@ impl WorkspaceView { .text_color(if is_focused { fg } else { muted }) .child(session_name), ) + .when(is_hidden, |el| { + el.child( + div() + .text_xs() + .text_color(muted.opacity(0.75)) + .flex_shrink_0() + .child("Hidden"), + ) + }) // Git branch (compact) - between name and context% .when_some(session.git_info.as_ref(), |el, gi| { let mut branch = gi.branch.clone(); diff --git a/crates/codirigent-ui/src/workspace/tests.rs b/crates/codirigent-ui/src/workspace/tests.rs index 37023c7b..48b48231 100644 --- a/crates/codirigent-ui/src/workspace/tests.rs +++ b/crates/codirigent-ui/src/workspace/tests.rs @@ -69,9 +69,11 @@ fn test_workspace_add_session_full() { assert!(ws.add_session(make_session(i, &format!("Session {}", i)))); } - // 5th should fail - assert!(!ws.add_session(make_session(5, "Session 5"))); - assert_eq!(ws.sessions().len(), 4); + // 5th should be retained as a hidden session + assert!(ws.add_session(make_session(5, "Session 5"))); + assert_eq!(ws.sessions().len(), 5); + assert_eq!(ws.visible_sessions().len(), 4); + assert!(!ws.is_session_visible(SessionId(5))); } #[test] @@ -158,6 +160,17 @@ fn test_workspace_focus_session_number() { assert!(!ws.focus_session_number(10)); } +#[test] +fn test_workspace_focus_session_number_ignores_hidden_multi_pane_grid_sessions() { + let mut ws = Workspace::with_profile(LayoutProfile::Grid2x2); + for i in 1..=5 { + assert!(ws.add_session(make_session(i, &format!("Session {}", i)))); + } + + assert!(!ws.focus_session_number(5)); + assert_eq!(ws.focused_session_id(), Some(SessionId(1))); +} + #[test] fn test_workspace_focus_navigation() { let mut ws = Workspace::new(); @@ -455,6 +468,64 @@ fn test_workspace_single_layout_preserves_order_on_exit() { assert_eq!(ws.focused_session_id(), Some(SessionId(3))); } +#[test] +fn test_workspace_focus_hidden_grid_session_swaps_into_focused_pane() { + let mut ws = Workspace::with_profile(LayoutProfile::Grid2x2); + ws.set_bounds(Bounds::from_size(1000.0, 800.0)); + + for i in 1..=5 { + assert!(ws.add_session(make_session(i, &format!("S{}", i)))); + } + + assert_eq!( + ws.cell_info() + .iter() + .map(|cell| cell.session_id) + .collect::>(), + vec![SessionId(1), SessionId(2), SessionId(3), SessionId(4)] + ); + assert!(!ws.is_session_visible(SessionId(5))); + + assert!(ws.focus_session(SessionId(2))); + assert!(ws.focus_session(SessionId(5))); + + let cells = ws.cell_info(); + assert_eq!( + cells.iter().map(|cell| cell.session_id).collect::>(), + vec![SessionId(1), SessionId(5), SessionId(3), SessionId(4)] + ); + assert_eq!(ws.focused_session_id(), Some(SessionId(5))); + assert!(!ws.is_session_visible(SessionId(2))); + assert!(ws.is_session_visible(SessionId(5))); +} + +#[test] +fn test_workspace_focus_hidden_grid_session_uses_first_visible_pane_when_focus_is_hidden() { + let mut ws = Workspace::with_profile(LayoutProfile::Grid2x2); + ws.set_bounds(Bounds::from_size(1000.0, 800.0)); + + for i in 1..=5 { + assert!(ws.add_session(make_session(i, &format!("S{}", i)))); + } + + ws.set_layout(LayoutProfile::Single); + assert!(ws.focus_session(SessionId(5))); + assert_eq!(ws.focused_session_id(), Some(SessionId(5))); + + ws.set_layout(LayoutProfile::Grid2x2); + assert_eq!(ws.focused_session_id(), Some(SessionId(1))); + + assert!(ws.focus_session(SessionId(2))); + assert!(ws.focus_session(SessionId(5))); + assert_eq!( + ws.cell_info() + .iter() + .map(|cell| cell.session_id) + .collect::>(), + vec![SessionId(1), SessionId(5), SessionId(3), SessionId(4)] + ); +} + #[test] fn test_workspace_restores_hidden_sessions_after_returning_from_smaller_split_layout() { let mut ws = Workspace::with_profile(LayoutProfile::Grid2x2); @@ -568,6 +639,37 @@ fn test_workspace_split_pane_promotes_next_hidden_session() { ); } +#[test] +fn test_workspace_focus_hidden_split_session_replaces_focused_visible_session() { + let mut ws = Workspace::with_profile(LayoutProfile::Grid2x2); + for i in 1..=4 { + assert!(ws.add_session(make_session(i, &format!("S{}", i)))); + } + + ws.set_split_tree(LayoutNode::from_grid(1, 2)); + assert!(ws.is_split_tree_mode()); + assert_eq!( + ws.cell_info() + .iter() + .map(|cell| cell.session_id) + .collect::>(), + vec![SessionId(1), SessionId(2)] + ); + + assert!(ws.focus_session(SessionId(2))); + assert!(ws.focus_session(SessionId(4))); + + assert_eq!( + ws.cell_info() + .iter() + .map(|cell| cell.session_id) + .collect::>(), + vec![SessionId(1), SessionId(4)] + ); + assert_eq!(ws.focused_session_id(), Some(SessionId(4))); + assert!(!ws.is_session_visible(SessionId(2))); +} + #[test] fn test_workspace_remove_session_promotes_hidden_split_session() { let mut ws = Workspace::with_profile(LayoutProfile::Grid2x2); diff --git a/docs/hidden-session-plan.md b/docs/hidden-session-plan.md new file mode 100644 index 00000000..f37d3ea4 --- /dev/null +++ b/docs/hidden-session-plan.md @@ -0,0 +1,131 @@ +# Hidden Session Access Plan + +## Purpose + +Define the first-pass fix for GitHub issue `#13`: when the active layout shows fewer sessions than exist in the workspace, every session must remain reachable without forcing a layout change. + +This plan intentionally does not solve tab grouping. Tab grouping remains a separate enhancement. + +## Problem + +Today, a layout such as `2x2` can only display four panes at once. If the workspace has a fifth session, that session exists but may not be reachable from the current layout unless the user changes the layout and repositions panes. + +That behavior is a UX bug because the session list shows the workspace contains the session, but the user cannot directly bring it into view. + +## Goals + +- Keep compact layouts such as `2x2` and `3x3` viable even when more sessions exist. +- Preserve visible pane positions unless the user explicitly rearranges them. +- Reuse an interaction users already understand from single/focus layout behavior. +- Fix discoverability through the existing Sessions drawer instead of introducing a new modal or overflow manager. + +## Non-Goals + +- No automatic tab creation. +- No layout auto-expansion. +- No pane reflow or global session reshuffle. +- No new overflow tray, modal picker, or separate hidden-session panel. + +## Proposed UX + +Use the Sessions drawer as the source of truth for all sessions: + +- If the user clicks a session that is already visible in the current layout, focus it. +- If the user clicks a session that is not currently visible, show it in the currently focused pane. +- The session that was previously displayed in the focused pane becomes hidden. +- No other visible panes move. + +This makes the interaction consistent with focus mode: + +- Click a session to view it in the current visible context. + +## Visibility Model + +The workspace should treat sessions as one of two states: + +- Visible: assigned to a currently rendered pane/slot. +- Hidden: exists in the workspace but is not assigned to a visible pane because the layout capacity is smaller than the total session count. + +The Sessions drawer should continue to show all sessions, not only visible ones. + +## Interaction Rules + +### Clicking From The Sessions Drawer + +- Visible session row: + Focus that session normally. +- Hidden session row: + Replace the session in the currently focused pane with the clicked hidden session. + +### Focus Requirement + +- The replacement target is always the currently focused pane. +- If there is no focused pane but at least one visible session exists, fall back to the layout's current focused session semantics. +- If there are no visible sessions, do nothing. + +### Ordering Rule + +- Hidden-session reveal should behave as a true swap between: + - the clicked hidden session, and + - the session currently shown in the focused pane. +- This keeps ordering stable and avoids silently re-packing the workspace. + +## UI Expectations + +The Sessions drawer should expose visibility clearly: + +- Visible sessions render as normal. +- Hidden sessions should display a subtle `Hidden` indicator, dimmed styling, or equivalent compact affordance. + +No confirmation dialog should appear for the swap. The action should be immediate. + +## Implementation Outline + +### Workspace/Core + +Add explicit support for swapping a hidden session with a visible session without changing the layout structure. + +Expected core behavior: + +- Detect whether a clicked session is currently visible. +- If hidden, replace the focused visible assignment with the hidden session. +- Move the replaced session into the hidden set while preserving stable ordering. + +The layout structure must remain unchanged. + +### Drawer/UI + +Update the Sessions drawer row interaction: + +- Visible row click keeps current focus behavior. +- Hidden row click triggers the hidden-to-focused swap behavior. + +Add visual differentiation for hidden rows. + +### Derived State + +Any session reveal/swap must refresh: + +- focused session state +- drawer selection state +- file tree synchronization +- terminal header focus state +- cached layout-derived UI state + +## Testing Plan + +Add or update tests for: + +- Hidden sessions remain listed in the Sessions drawer. +- Clicking a hidden session swaps it into the focused pane. +- The replaced visible session becomes hidden. +- Other visible panes remain unchanged. +- Focus follows the revealed session. +- Single layout behavior is unchanged. +- Split-tree layouts use the same focused-pane replacement rule. + +## Rollout Notes + +This should land as the narrow fix for issue `#13`. + +Tab grouping can build on top of this later, but should not be coupled to this change. Keeping them separate reduces risk and keeps the hidden-session behavior understandable on its own. diff --git a/docs/resizable-split-pane-plan.md b/docs/resizable-split-pane-plan.md new file mode 100644 index 00000000..cb8f3de9 --- /dev/null +++ b/docs/resizable-split-pane-plan.md @@ -0,0 +1,162 @@ +# Resizable Split Pane Plan + +## Purpose + +Define the implementation plan for resizable split panes so users can adjust split proportions beyond the current fixed `50/50` behavior, such as `75/25`, by dragging split dividers directly in the workspace. + +## Problem + +The current split-tree layout model supports ratios internally, but the primary user-facing split actions create panes at `0.5` and there is no direct workspace interaction for changing that proportion afterward. + +Users want to keep compact custom layouts while controlling how much space each pane gets. A common example is making one pane take roughly `3/4` of the available height or width. + +## Goals + +- Let users resize split-tree panes by dragging dividers. +- Support arbitrary split ratios within reasonable limits. +- Preserve the existing header drag behavior for session movement/reordering. +- Persist custom split ratios so session resume restores the same layout proportions. +- Keep the behavior predictable and visually clear. + +## Non-Goals + +- No divider resizing for fixed grid layouts. +- No keyboard-only resize controls in this change. +- No preset-ratio-only solution; direct dragging is required. +- No refactor of the existing pane header drag model beyond what is needed to coordinate drag modes safely. + +## Current State + +The codebase already contains most of the underlying ratio support: + +- split-tree nodes already store a `ratio: f32` +- the layout tree already supports `set_ratio_for_slot()` +- split layout state already supports `resize_split()` +- divider hit-testing already exists in the split layout calculator +- layout persistence already serializes `LayoutMode::SplitTree { root }` + +This means the missing feature is primarily the workspace interaction layer and drag-state coordination, not the core layout math. + +## Proposed UX + +### Divider Drag + +- When the workspace is in split-tree mode, the divider between two panes should be draggable. +- Hovering a divider should show the correct resize cursor: + - horizontal split divider: vertical resize cursor + - vertical split divider: horizontal resize cursor +- Mouse down on a divider enters split-resize drag mode. +- Dragging updates the ratio continuously as the pointer moves. +- Mouse up commits the new ratio. + +### Pane/Header Drag Compatibility + +Header drag and divider drag must remain separate interactions: + +- pane header drag starts only from header bounds +- divider drag starts only from divider bounds +- terminal selection starts only from terminal content + +Once one drag mode has started, the others must be suppressed until mouse-up. + +## Interaction Model + +The workspace should treat pointer interactions as mutually exclusive modes. + +Recommended conceptual state: + +- `None` +- `SessionReorderDrag` +- `SplitResizeDrag` + +`SplitResizeDrag` should carry enough information to update the correct split ratio as the pointer moves, including: + +- the divider/split being resized +- the drag start point +- the original ratio +- the relevant layout bounds + +## Ratio Behavior + +- Ratios should remain clamped to safe limits. +- The existing core clamp behavior should remain authoritative. +- Dragging should feel continuous, not snap to a few preset percentages. + +The first implementation should preserve minimum pane sizes through the existing layout clamp behavior and any current split-tree minimum-cell logic. + +## Layout Scope + +Resizable dividers should apply only to split-tree layouts. + +Grid layouts should remain fixed-profile layouts such as `2x2`, `2x3`, and `3x3`. If users want custom uneven pane sizing, they should be in split-tree mode. + +## Persistence And Resume + +Custom split ratios must survive app restart and session restore. + +This should work through the existing layout persistence path: + +- current layout is saved as `LayoutMode::SplitTree { root }` +- split ratios are stored within the layout tree +- restore re-applies the saved split tree + +This feature must validate that resized split ratios are correctly restored, not only that the split tree shape is restored. + +## Implementation Outline + +### Workspace Interaction State + +Extend the workspace pointer interaction model to support split-resize dragging alongside existing pane/header drag behavior. + +Expected responsibilities: + +- detect divider hover/hit in split-tree mode +- start resize drag on divider mouse down +- update ratio during pointer move +- finish resize drag on mouse up +- prevent interaction overlap with header drag and terminal selection + +### Divider Hit Testing + +Use the existing split layout divider hit-testing to determine whether the pointer is over a divider and which split should be resized. + +The divider hit area should be explicit and reliable so users can easily discover and use it. + +### Ratio Update Path + +Translate pointer movement into a new ratio for the affected split and apply it through the existing split-tree resize API. + +This should update the layout in real time during drag so users can see the effect immediately. + +### Cursor Feedback + +Add cursor feedback on divider hover and drag so users understand when a resize gesture will occur instead of a header move or terminal selection. + +### Persistence + +Ensure ratio changes trigger the normal layout persistence path so resized layouts are included in saved state without requiring separate persistence logic. + +## Testing Plan + +Add or update tests for: + +- divider hit-testing identifies the correct divider in split-tree layouts +- dragging a divider updates split ratio away from `0.5` +- ratio updates are clamped correctly at the allowed bounds +- header drag does not start when the pointer begins on a divider +- divider drag does not start when the pointer begins on a pane header +- terminal selection does not interfere with an active divider drag +- resized split layouts persist to saved state with the updated ratio +- restored split layouts preserve the saved ratio, not only the split-tree shape +- nested split trees resize the intended parent split rather than an unrelated branch + +## Rollout Notes + +This feature should be implemented as a split-tree enhancement, not as a general grid-layout resize system. + +The finished behavior should be: + +- create split layout +- drag divider to any practical ratio such as `3/4` +- keep using the workspace normally +- quit and resume later with the same split proportions intact diff --git a/docs/session-shell-selection-plan.md b/docs/session-shell-selection-plan.md new file mode 100644 index 00000000..01692a68 --- /dev/null +++ b/docs/session-shell-selection-plan.md @@ -0,0 +1,162 @@ +# Session Shell Selection Plan + +## Purpose + +Define the full implementation scope for GitHub issue `#12`: allow users to choose which shell environment a session opens with, such as `bash`, `zsh`, `pwsh`, `powershell`, or `cmd`, and preserve that choice across session persistence and restore. + +## Problem + +Users may work across multiple shell environments depending on project or platform needs. The application already supports a global default shell, but that is not sufficient when users want different sessions to run in different shells at the same time. + +The product requirement is per-session shell choice at creation time, with persistence and restore behavior that keeps sessions consistent across restarts. + +## Goals + +- Let users choose a shell when creating a session. +- Support all session creation entry points consistently. +- Persist the chosen shell as part of session state. +- Restore sessions using their original shell choice. +- Show the selected shell in the UI. +- Handle missing shells on restore in a predictable way. + +## Non-Goals + +- No live in-place shell mutation for an already running PTY. +- No freeform shell command text entry in the first implementation. +- No separate duplicate-session or clone-session behavior. + +## Creation Entry Points + +Shell selection should be available anywhere a new session can be created: + +- clicking an empty pane +- clicking the pane-level `+` button in a tabbed pane + +Both entry points should use the same create-session flow so behavior stays consistent. + +## Shell Selection UX + +The create-session flow should include a shell selector populated from detected available shells. + +Expected options: + +- `Auto` +- detected installed shells for the current platform + +Examples: + +- macOS/Linux: `bash`, `zsh`, `sh` +- Windows: `pwsh`, `powershell`, `cmd` + +The selector should use friendly labels where possible, while still storing the exact shell identifier needed by the session manager. + +## Behavior Model + +### Auto + +- `Auto` means the session should use the application's existing default-shell behavior. +- This should continue to respect the global default shell setting, or platform default behavior if the global setting is unset. + +### Explicit Shell + +- If the user selects a shell explicitly, that choice applies only to the new session being created. +- It overrides `Auto` for that session. + +## Persistence + +The selected shell must be stored as part of persistent session state. + +This should be represented on: + +- `Session` +- `PersistentSession` + +It should not live only in transient UI state or be inferred only from the launch path. + +## Restore Behavior + +On restore: + +- if the saved shell is still available, restore the session with that shell +- if the saved shell is unavailable, restore the session using `Auto` + +The application should surface a clear warning that the originally requested shell was unavailable and that `Auto` was used instead. + +Restore should not silently swap shells without feedback, and it should not drop the session entirely because the requested shell is missing. + +## UI Visibility + +The selected shell should be visible in the session UI so users can verify environment at a glance. + +Recommended places: + +- session details or session menu +- compact session/pane header indicator where space permits + +The shell display should distinguish between: + +- `Auto` +- explicit shell selections such as `bash` or `pwsh` + +## Changing Shell After Creation + +Shell choice should be treated as a session launch property, not a live mutable terminal property. + +That means: + +- changing shell for an existing session should not attempt to mutate the running PTY in place +- if the product later exposes a shell-change action, it should be modeled as reopening or recreating the session with a different shell + +This issue does not require implementing that action now, but the underlying data model should not imply live shell mutation is supported. + +## Implementation Outline + +### Session Model + +Extend the session domain model so a session can carry its shell choice explicitly. + +Expected responsibilities: + +- represent `Auto` versus explicit shell choice +- persist the chosen value +- restore it faithfully + +### Create Flow + +Update the create-session UI flow used by empty-pane creation and pane `+` creation so it includes shell selection and passes the chosen shell through to session bootstrap. + +### Restore Flow + +Update restore planning and bootstrap so restored sessions use their stored shell value, with fallback-to-`Auto` if the shell is missing. + +### Availability Detection + +Use the existing shell detection mechanism as the source of available shell choices and restore validation. + +### Warning Surface + +When restore falls back to `Auto`, surface a warning in a user-visible way so the mismatch is not silent. + +## Testing Plan + +Add or update tests for: + +- creating a session with `Auto` +- creating a session with an explicit shell +- persisting the selected shell into saved state +- restoring a session with the same shell when available +- restoring a session with fallback to `Auto` when the saved shell is unavailable +- warning generation for unavailable saved shells +- consistent shell-selection behavior across both creation entry points + +## Rollout Notes + +This feature should be implemented as a full per-session shell-selection workflow, not only as a one-time creation override. + +The expected finished behavior is: + +- user chooses shell at creation time +- shell is stored with the session +- restore uses the same shell when possible +- restore falls back to `Auto` with clear warning when necessary +- the UI shows what shell the session is using diff --git a/docs/tab-grouping-plan.md b/docs/tab-grouping-plan.md new file mode 100644 index 00000000..5791719f --- /dev/null +++ b/docs/tab-grouping-plan.md @@ -0,0 +1,180 @@ +# Tab Grouping Plan + +## Purpose + +Define the first-pass design for GitHub issue `#14`: allow users to group multiple sessions into tabs within a single pane. + +This feature is separate from the hidden-session fix. Hidden-session access remains the fallback for reaching sessions outside the current visible working set. Tab grouping is a manual layout tool for keeping more sessions active while preserving a compact visible layout such as `2x2` or `3x3`. + +## Problem + +Users may run more sessions than they want to display as panes at one time. The current workspace supports compact custom layouts, but each visible pane can only host one session. That forces users to either increase pane count or keep reshuffling layouts. + +Tabs should let users keep a stable layout while intentionally compressing related sessions into the same pane. + +## Goals + +- Keep visible layouts compact and stable. +- Let a pane hold multiple sessions as tabs. +- Make tab grouping a direct workspace interaction, not a menu-only action. +- Provide a pane-local way to create a new session once the grid is full. + +## Non-Goals + +- No automatic overflow-to-tabs behavior. +- No layout auto-expansion. +- No global session reshuffle. +- No tab tear-off or drag-out in v1. +- No cross-pane tab reordering UI in v1. +- No changes to the existing logical session grouping feature in the menu. + +## Key Distinction + +Two features named "grouping" must remain separate: + +- Existing session grouping: + Logical organization and color/group metadata in the session menu and drawer. +- New tab grouping: + Multiple sessions sharing a single visible pane. + +The existing menu grouping should not be repurposed for tabs. + +## Proposed UX + +### Tab Creation By Drag And Drop + +- Drag a pane header onto another pane header to group the dragged session into the target pane. +- Dropping on the target pane header creates or extends a tab stack in that pane. +- The dropped session becomes the active tab immediately. +- The target pane keeps its layout position. +- The source pane is removed from visible assignment if it becomes empty. + +### Existing Pane Drag Behavior + +- Drop on pane body: + Keep current swap/move behavior. +- Drop on pane header: + Group into tabs. + +This keeps the interaction explicit and avoids conflict with current reordering behavior. + +### Tab Switching + +- Clicking a tab in a pane header switches the visible session for that pane. +- Switching tabs does not change layout structure. +- Focus remains in the same pane. + +### Pane-Level New Session Button + +- Each pane header should include a small `+` button, similar to a browser tab strip. +- Clicking `+` creates a new session in that pane as a new tab. +- The new session becomes the active tab immediately. +- The pane keeps its current visible position. + +This addresses the current gap where adding a session is awkward once the visible layout is already full. + +## Initial Tab Rules + +- When dropping session `A` onto a pane currently showing session `B`, the resulting tab order is `[B, A]`. +- Session `A` becomes the active tab immediately after grouping. +- If a pane already has tabs, the dropped session is appended to the target tab stack and becomes active. +- If a pane contains only one session, the header still supports grouping and `+`. + +## Session Creation Rules + +For the pane-level `+` action in v1: + +- The new session should inherit the current pane's working directory/context. +- The new session should use the existing default new-session settings. +- This does not include per-session shell selection yet. That remains part of issue `#12`. + +## Close Behavior + +- Closing the active tab in a multi-tab pane reveals the next available tab in that pane. +- Closing a non-active tab removes it without affecting the active tab. +- Closing the last remaining tab behaves like closing the pane's only session today. + +## Layout Model + +Tabs should be modeled as a real part of workspace state, not as a render-only illusion. + +Recommended conceptual state: + +- each visible slot/pane owns an ordered tab stack of session IDs +- each slot/pane tracks which tab is active + +This is a better long-term fit than the current one-session-per-slot assumption and will make switching, persistence, closing, and future enhancements more coherent. + +## Sessions Drawer + +For v1, the Sessions drawer should continue to list sessions normally. + +Deferred for later: + +- showing tab membership in the drawer +- dragging from the drawer into tabs +- any drawer-specific tab-management UI + +The primary interaction surface for tabs should be the pane header itself. + +## Visual Expectations + +- A pane with one session can keep the current header look with a subtle `+` affordance added. +- A pane with multiple sessions should render a tab strip in its header. +- Active tab styling should remain visually aligned with the current workspace theme. +- The `+` affordance should be compact and clearly separate from existing session actions. + +## Implementation Outline + +### Workspace/Core + +Refactor pane assignment state so a visible pane can host multiple sessions and track one active session. + +Expected responsibilities: + +- create a tab stack in a target pane +- append sessions to an existing tab stack +- switch the active tab for a pane +- create a new session directly into a pane/tab stack +- close tabs while preserving pane stability + +### Drag And Drop + +Extend the existing drag system to distinguish between: + +- header-target drop for tab grouping +- body-target drop for swap/move + +This will likely require a more precise drop-target model than the current single target-index state. + +### Header Rendering + +Update pane header rendering so it can display: + +- a single-session header state +- a multi-tab strip state +- a pane-level `+` button + +### Persistence + +Tab stacks and active-tab selection should be persisted as part of workspace state so layout restoration preserves tab grouping. + +## Testing Plan + +Add or update tests for: + +- dragging a session onto another pane header creates a tab stack +- dropped session becomes active +- tab order matches `[target-existing..., dropped]` +- dropping on pane body preserves current swap behavior +- clicking a tab switches the visible session in place +- clicking `+` creates a new session as a tab in that pane +- closing tabs preserves the remaining tab stack correctly +- focus remains stable within the pane during tab switching +- persistence restores tab stacks and active-tab state + +## Rollout Notes + +This should land as a focused manual-grouping feature for issue `#14`. + +It should not absorb hidden-session overflow policy and should not depend on issue `#12`. Keeping those concerns separate will make the first version of tab grouping easier to reason about and lower-risk to ship. From afc8bd7e0eef53302009b0f92489cf5ce37964ae Mon Sep 17 00:00:00 2001 From: oso95 Date: Thu, 12 Mar 2026 23:07:40 -0400 Subject: [PATCH 2/9] Add pane tab grouping --- crates/codirigent-core/src/types/mod.rs | 2 +- crates/codirigent-core/src/types/state.rs | 45 +- crates/codirigent-ui/src/integration.rs | 4 + crates/codirigent-ui/src/layout/state.rs | 266 ++++- crates/codirigent-ui/src/workspace/core.rs | 947 +++++++++++++++--- crates/codirigent-ui/src/workspace/gpui.rs | 64 +- .../src/workspace/grid_render.rs | 212 ++-- .../src/workspace/impl_action_handlers.rs | 14 +- .../src/workspace/impl_session_lifecycle.rs | 154 ++- crates/codirigent-ui/src/workspace/tests.rs | 271 ++++- crates/codirigent-ui/src/workspace/types.rs | 39 +- 11 files changed, 1730 insertions(+), 288 deletions(-) diff --git a/crates/codirigent-core/src/types/mod.rs b/crates/codirigent-core/src/types/mod.rs index b151ce0a..9f91fd50 100644 --- a/crates/codirigent-core/src/types/mod.rs +++ b/crates/codirigent-core/src/types/mod.rs @@ -24,7 +24,7 @@ pub use git::{GitChangeKind, GitChangedFile, GitRepoInfo}; pub use ids::{SessionId, TaskId}; pub use layout::{GridPosition, LayoutMode, LayoutNode, SlotId, SplitDirection}; pub use session::{CodexExecutionMode, Session}; -pub use state::{AppState, QueueState, WindowState}; +pub use state::{AppState, PaneId, PaneStackState, PaneTabGroup, QueueState, WindowState}; pub use status::{ContextThresholdState, SessionStatus, ShellState, TaskPriority, TaskStatus}; pub use task::{RetryConfig, Task, VerificationConfig}; pub use verification::{TestFailure, TestResults, VerificationResult}; diff --git a/crates/codirigent-core/src/types/state.rs b/crates/codirigent-core/src/types/state.rs index 0630b4d9..936e33cc 100644 --- a/crates/codirigent-core/src/types/state.rs +++ b/crates/codirigent-core/src/types/state.rs @@ -3,10 +3,45 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; -use super::ids::TaskId; -use super::layout::LayoutMode; +use super::ids::{SessionId, TaskId}; +use super::layout::{LayoutMode, SlotId}; use super::session::Session; +/// Persistent identifier for a visible pane. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub enum PaneId { + /// Grid-layout pane by stable cell index. + GridCell { + /// Zero-based grid cell index in row-major order. + index: usize, + }, + /// Split-tree pane by slot identifier. + SplitSlot { + /// Stable split-tree slot identifier. + slot: SlotId, + }, +} + +/// Persistent tab state for a visible pane. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct PaneTabGroup { + /// Pane that owns the tab stack. + pub pane: PaneId, + /// Ordered session IDs in the tab strip. + pub session_ids: Vec, + /// Active session currently rendered in the pane. + pub active_session_id: SessionId, +} + +/// Persisted ordered pane stack state, including hidden stacks. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct PaneStackState { + /// Ordered session IDs in the stack. + pub session_ids: Vec, + /// Active session currently rendered when the stack is visible. + pub active_session_id: SessionId, +} + /// Persisted window position and size. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WindowState { @@ -29,6 +64,12 @@ pub struct AppState { pub sessions: Vec, /// Current layout mode. pub layout: LayoutMode, + /// Persisted per-pane tab stacks. + #[serde(default)] + pub pane_tab_groups: Vec, + /// Persisted pane stacks in workspace order, including hidden stacks. + #[serde(default)] + pub pane_stacks: Vec, /// Last updated timestamp. pub updated_at: Option>, /// Saved window position and size. diff --git a/crates/codirigent-ui/src/integration.rs b/crates/codirigent-ui/src/integration.rs index e3c0c9b5..1f679f03 100644 --- a/crates/codirigent-ui/src/integration.rs +++ b/crates/codirigent-ui/src/integration.rs @@ -355,6 +355,8 @@ impl CodirigentIntegration { let state = AppState { sessions, layout: codirigent_core::LayoutMode::default(), + pane_tab_groups: Vec::new(), + pane_stacks: Vec::new(), updated_at: Some(chrono::Utc::now()), window_bounds: None, }; @@ -562,6 +564,8 @@ impl CodirigentIntegration { let state = AppState { sessions, layout: codirigent_core::LayoutMode::default(), + pane_tab_groups: Vec::new(), + pane_stacks: Vec::new(), updated_at: Some(chrono::Utc::now()), window_bounds: None, }; diff --git a/crates/codirigent-ui/src/layout/state.rs b/crates/codirigent-ui/src/layout/state.rs index 15d4ac3b..170f3e81 100644 --- a/crates/codirigent-ui/src/layout/state.rs +++ b/crates/codirigent-ui/src/layout/state.rs @@ -29,8 +29,10 @@ pub enum FocusDirection { pub struct LayoutState { /// Current layout profile. profile: LayoutProfile, - /// Session assignments to grid positions. - assignments: Vec, + /// Session assignments to visible grid positions. + assignments: Vec>, + /// Sessions that exist but are not currently visible in a grid cell. + overflow: Vec, /// Currently focused session index. focused_index: Option, } @@ -46,7 +48,8 @@ impl LayoutState { pub fn new() -> Self { Self { profile: LayoutProfile::default(), - assignments: Vec::new(), + assignments: vec![None; LayoutProfile::default().max_sessions()], + overflow: Vec::new(), focused_index: None, } } @@ -55,7 +58,8 @@ impl LayoutState { pub fn with_profile(profile: LayoutProfile) -> Self { Self { profile, - assignments: Vec::new(), + assignments: vec![None; profile.max_sessions()], + overflow: Vec::new(), focused_index: None, } } @@ -67,7 +71,15 @@ impl LayoutState { /// Set the layout profile. pub fn set_profile(&mut self, profile: LayoutProfile) { + if self.profile == profile { + return; + } + + let ordered = self.ordered_sessions(); self.profile = profile; + self.assignments = vec![None; profile.max_sessions()]; + self.overflow.clear(); + self.set_assignments(ordered); } /// Cycle to the next layout profile. @@ -81,13 +93,44 @@ impl LayoutState { } /// Get the session assignments. - pub fn assignments(&self) -> &[SessionId] { + pub fn assignments(&self) -> &[Option] { &self.assignments } + /// Get the overflow sessions. + pub fn overflow(&self) -> &[SessionId] { + &self.overflow + } + + /// Return all sessions in workspace order: visible cells first, then overflow. + pub fn ordered_sessions(&self) -> Vec { + self.assignments + .iter() + .flatten() + .copied() + .chain(self.overflow.iter().copied()) + .collect() + } + /// Set the session assignments. pub fn set_assignments(&mut self, assignments: Vec) { - self.assignments = assignments; + self.assignments.fill(None); + self.overflow.clear(); + + for session_id in assignments { + if let Some(cell) = self.assignments.iter_mut().find(|cell| cell.is_none()) { + *cell = Some(session_id); + } else { + self.overflow.push(session_id); + } + } + + if self + .focused_index + .is_some_and(|index| self.session_at(index).is_none()) + { + self.focused_index = self.first_occupied_index(); + } } /// Add a session to the layout. @@ -98,11 +141,22 @@ impl LayoutState { /// /// `true` if the session was added, `false` if the layout is full. pub fn add_session(&mut self, session_id: SessionId) -> bool { - if self.assignments.len() < self.profile.max_sessions() { - self.assignments.push(session_id); + if self + .assignments + .iter() + .flatten() + .any(|&id| id == session_id) + || self.overflow.contains(&session_id) + { + return false; + } + + if let Some(cell) = self.assignments.iter_mut().find(|cell| cell.is_none()) { + *cell = Some(session_id); true } else { - false + self.overflow.push(session_id); + true } } @@ -111,10 +165,16 @@ impl LayoutState { /// Hidden sessions stay in assignment order so they can be swapped back /// into a visible cell later without losing workspace ordering. pub fn append_hidden_session(&mut self, session_id: SessionId) -> bool { - if self.assignments.contains(&session_id) { + if self + .assignments + .iter() + .flatten() + .any(|&id| id == session_id) + || self.overflow.contains(&session_id) + { return false; } - self.assignments.push(session_id); + self.overflow.push(session_id); true } @@ -124,17 +184,21 @@ impl LayoutState { /// /// `true` if the session was removed, `false` if not found. pub fn remove_session(&mut self, session_id: SessionId) -> bool { - if let Some(pos) = self.assignments.iter().position(|&id| id == session_id) { - self.assignments.remove(pos); - // Adjust focused index if needed + if let Some(pos) = self + .assignments + .iter() + .position(|cell| *cell == Some(session_id)) + { + self.assignments[pos] = None; if let Some(focused) = self.focused_index { if focused == pos { - self.focused_index = self.assignments.first().map(|_| 0); - } else if focused > pos { - self.focused_index = Some(focused - 1); + self.focused_index = self.first_occupied_index(); } } true + } else if let Some(pos) = self.overflow.iter().position(|&id| id == session_id) { + self.overflow.remove(pos); + true } else { false } @@ -142,7 +206,7 @@ impl LayoutState { /// Get the session at a given index. pub fn session_at(&self, index: usize) -> Option { - self.assignments.get(index).copied() + self.assignments.get(index).and_then(|session| *session) } /// Get the session at a given grid position. @@ -167,7 +231,7 @@ impl LayoutState { /// Set the focused session by index. pub fn focus_index(&mut self, index: usize) { - if index < self.assignments.len() { + if self.session_at(index).is_some() { self.focused_index = Some(index); } } @@ -178,7 +242,11 @@ impl LayoutState { /// /// `true` if the session was found and focused. pub fn focus_session(&mut self, session_id: SessionId) -> bool { - if let Some(index) = self.assignments.iter().position(|&id| id == session_id) { + if let Some(index) = self + .assignments + .iter() + .position(|cell| *cell == Some(session_id)) + { self.focused_index = Some(index); true } else { @@ -188,27 +256,35 @@ impl LayoutState { /// Focus the next session in the layout. pub fn focus_next(&mut self) { - if self.assignments.is_empty() { + let occupied = self.occupied_indices(); + if occupied.is_empty() { return; } - let next = match self.focused_index { - Some(i) => (i + 1) % self.assignments.len(), + let next = match self + .focused_index + .and_then(|index| occupied.iter().position(|¤t| current == index)) + { + Some(i) => (i + 1) % occupied.len(), None => 0, }; - self.focused_index = Some(next); + self.focused_index = Some(occupied[next]); } /// Focus the previous session in the layout. pub fn focus_previous(&mut self) { - if self.assignments.is_empty() { + let occupied = self.occupied_indices(); + if occupied.is_empty() { return; } - let prev = match self.focused_index { + let prev = match self + .focused_index + .and_then(|index| occupied.iter().position(|¤t| current == index)) + { Some(i) if i > 0 => i - 1, - Some(_) => self.assignments.len() - 1, + Some(_) => occupied.len() - 1, None => 0, }; - self.focused_index = Some(prev); + self.focused_index = Some(occupied[prev]); } /// Focus the session in the given direction from current focus. @@ -218,8 +294,8 @@ impl LayoutState { /// * `direction` - Direction to move focus (Up, Down, Left, Right) pub fn focus_direction(&mut self, direction: FocusDirection) { let Some(current_index) = self.focused_index else { - if !self.assignments.is_empty() { - self.focused_index = Some(0); + if let Some(index) = self.first_occupied_index() { + self.focused_index = Some(index); } return; }; @@ -260,7 +336,7 @@ impl LayoutState { }; let new_index = (new_row * cols + new_col) as usize; - if new_index < self.assignments.len() { + if self.session_at(new_index).is_some() { self.focused_index = Some(new_index); } } @@ -272,7 +348,12 @@ impl LayoutState { /// /// Returns `true` if both indices are valid and different. pub fn swap_assignments(&mut self, a: usize, b: usize) -> bool { - if a == b || a >= self.assignments.len() || b >= self.assignments.len() { + if a == b + || a >= self.assignments.len() + || b >= self.assignments.len() + || self.assignments[a].is_none() + || self.assignments[b].is_none() + { return false; } self.assignments.swap(a, b); @@ -286,6 +367,114 @@ impl LayoutState { } true } + + /// Get the index of a hidden session in overflow order. + pub fn overflow_index_of(&self, session_id: SessionId) -> Option { + self.overflow.iter().position(|&id| id == session_id) + } + + /// Replace the session in a visible cell with a hidden overflow session. + /// + /// Returns the previous visible session when the swap succeeds. + pub fn swap_hidden_into_index( + &mut self, + hidden_session_id: SessionId, + index: usize, + ) -> Option { + let hidden_index = self.overflow_index_of(hidden_session_id)?; + let previous = self + .assignments + .get_mut(index)? + .replace(hidden_session_id)?; + self.overflow[hidden_index] = previous; + self.focused_index = Some(index); + Some(previous) + } + + /// Place a session into a specific grid cell. + pub fn assign_session_to_index(&mut self, session_id: SessionId, index: usize) -> bool { + if index >= self.assignments.len() { + return false; + } + + if self + .assignments + .iter() + .flatten() + .any(|&id| id == session_id) + { + return false; + } + + if let Some(hidden_index) = self.overflow_index_of(session_id) { + self.overflow.remove(hidden_index); + } + + if let Some(previous) = self.assignments[index].replace(session_id) { + self.overflow.insert(0, previous); + } + + true + } + + /// Replace the session in a visible cell without moving the previous one + /// into overflow. Used for pane-local tab activation where both sessions + /// remain members of the same pane stack. + pub fn replace_session_in_index( + &mut self, + index: usize, + session_id: SessionId, + ) -> Option> { + if index >= self.assignments.len() { + return None; + } + + if self + .assignments + .iter() + .enumerate() + .any(|(current_index, cell)| current_index != index && *cell == Some(session_id)) + { + return None; + } + + if let Some(hidden_index) = self.overflow_index_of(session_id) { + self.overflow.remove(hidden_index); + } + + Some(self.assignments[index].replace(session_id)) + } + + /// Remove a session from a visible cell without promoting overflow. + pub fn clear_index(&mut self, index: usize) -> Option { + if index >= self.assignments.len() { + return None; + } + + let removed = self.assignments[index].take(); + if self.focused_index == Some(index) && removed.is_some() { + self.focused_index = self.first_occupied_index(); + } + removed + } + + /// Get the first visible empty cell. + pub fn first_empty_index(&self) -> Option { + self.assignments.iter().position(|cell| cell.is_none()) + } + + /// Return all occupied cell indices in order. + pub fn occupied_indices(&self) -> Vec { + self.assignments + .iter() + .enumerate() + .filter_map(|(index, session)| session.map(|_| index)) + .collect() + } + + fn first_occupied_index(&self) -> Option { + self.occupied_indices().into_iter().next() + } } /// Split layout state manager. @@ -787,7 +976,7 @@ impl WorkspaceLayoutState { /// Get all assigned session IDs in order. pub fn assigned_sessions(&self) -> Vec { match self { - WorkspaceLayoutState::Grid(s) => s.assignments().to_vec(), + WorkspaceLayoutState::Grid(s) => s.ordered_sessions(), WorkspaceLayoutState::SplitTree(s) => s.assigned_sessions(), } } @@ -803,7 +992,7 @@ mod tests { fn test_layout_state_new() { let state = LayoutState::new(); assert_eq!(state.profile(), LayoutProfile::Grid2x2); - assert!(state.assignments().is_empty()); + assert_eq!(state.assignments(), &[None, None, None, None]); assert!(state.focused_index().is_none()); } @@ -836,9 +1025,10 @@ mod tests { assert!(state.add_session(SessionId(2))); assert!(state.add_session(SessionId(3))); assert!(state.add_session(SessionId(4))); - assert!(!state.add_session(SessionId(5))); // Full + assert!(state.add_session(SessionId(5))); // Overflow hidden session assert_eq!(state.assignments().len(), 4); + assert_eq!(state.overflow(), &[SessionId(5)]); } #[test] @@ -849,8 +1039,10 @@ mod tests { state.focus_index(1); assert!(state.remove_session(SessionId(1))); - assert_eq!(state.assignments().len(), 1); - assert_eq!(state.focused_index(), Some(0)); // Adjusted + assert_eq!(state.assignments().len(), 4); + assert_eq!(state.session_at(0), None); + assert_eq!(state.session_at(1), Some(SessionId(2))); + assert_eq!(state.focused_index(), Some(1)); // Stable grid index assert!(!state.remove_session(SessionId(99))); // Not found } diff --git a/crates/codirigent-ui/src/workspace/core.rs b/crates/codirigent-ui/src/workspace/core.rs index 31a12c66..e54e29e5 100644 --- a/crates/codirigent-ui/src/workspace/core.rs +++ b/crates/codirigent-ui/src/workspace/core.rs @@ -31,8 +31,17 @@ use crate::layout::{ SplitLayoutState, WorkspaceLayoutState, TOP_BAR_HEIGHT, }; use crate::theme::CodirigentTheme; -use codirigent_core::{LayoutNode, Session, SessionId, SessionStatus, SlotId, SplitDirection}; -use std::collections::HashSet; +use codirigent_core::{ + LayoutNode, PaneId, PaneStackState, PaneTabGroup, Session, SessionId, SessionStatus, SlotId, + SplitDirection, +}; +use std::collections::{HashMap, HashSet}; + +#[derive(Debug, Clone)] +struct PaneStack { + session_ids: Vec, + active_session_id: SessionId, +} /// Main workspace containing the grid of sessions. /// @@ -45,6 +54,10 @@ use std::collections::HashSet; pub struct Workspace { /// Unified layout state supporting both grid and split tree modes. layout_state: WorkspaceLayoutState, + /// Pane-local tab stacks keyed by visible pane identifier. + pane_tab_groups: HashMap, + /// Ordered tab stacks that no longer fit in the current visible layout. + hidden_pane_stacks: Vec, /// Sessions in the workspace. sessions: Vec, /// Theme configuration. @@ -70,6 +83,8 @@ impl Workspace { pub fn new() -> Self { Self { layout_state: WorkspaceLayoutState::default(), + pane_tab_groups: HashMap::new(), + hidden_pane_stacks: Vec::new(), sessions: Vec::new(), theme: CodirigentTheme::default(), show_sidebar: true, @@ -83,6 +98,8 @@ impl Workspace { pub fn with_profile(profile: LayoutProfile) -> Self { Self { layout_state: WorkspaceLayoutState::with_profile(profile), + pane_tab_groups: HashMap::new(), + hidden_pane_stacks: Vec::new(), sessions: Vec::new(), theme: CodirigentTheme::default(), show_sidebar: true, @@ -110,7 +127,9 @@ impl Workspace { /// When switching from split tree to grid, sessions are re-assigned /// in their current order to the new grid. pub fn set_layout(&mut self, profile: LayoutProfile) { - self.layout_state = WorkspaceLayoutState::Grid(self.rebuild_grid_state(profile)); + let stacks = self.current_pane_stacks_in_order(); + self.layout_state = WorkspaceLayoutState::Grid(self.rebuild_grid_state(profile, &stacks)); + self.apply_pane_stacks_to_current_layout(stacks); } /// Cycle to the next layout profile. @@ -168,58 +187,419 @@ impl Workspace { &self.layout_state } - /// Return all workspace sessions in the current pane order, with any - /// temporarily hidden sessions appended in workspace order. - fn ordered_session_ids(&self) -> Vec { - let mut ordered = self.layout_state.assigned_sessions(); - if ordered.len() == self.sessions.len() { - return ordered; + /// Get persisted pane tab groups for the current layout. + pub fn pane_tab_groups(&self) -> Vec { + let mut groups = self.pane_tab_groups.values().cloned().collect::>(); + groups.sort_by_key(|group| match group.pane { + PaneId::GridCell { index } => (0u8, index), + PaneId::SplitSlot { slot } => (1u8, slot.0 as usize), + }); + groups + } + + /// Get persisted pane stacks for the entire workspace, including hidden stacks. + pub fn pane_stacks(&self) -> Vec { + self.current_pane_stacks_in_order() + .into_iter() + .map(|stack| PaneStackState { + session_ids: stack.session_ids, + active_session_id: stack.active_session_id, + }) + .collect() + } + + fn visible_pane_ids(&self) -> Vec { + Self::visible_pane_ids_for_state(&self.layout_state) + } + + fn visible_pane_ids_for_state(layout_state: &WorkspaceLayoutState) -> Vec { + match layout_state { + WorkspaceLayoutState::Grid(state) => (0..state.assignments().len()) + .map(|index| PaneId::GridCell { index }) + .collect(), + WorkspaceLayoutState::SplitTree(state) => state + .assignments() + .iter() + .map(|(slot, _)| PaneId::SplitSlot { slot: *slot }) + .collect(), } + } - let assigned: HashSet = ordered.iter().copied().collect(); - ordered.extend( + fn active_session_for_pane(&self, pane_id: PaneId) -> Option { + if let Some(group) = self.pane_tab_groups.get(&pane_id) { + return Some(group.active_session_id); + } + + match pane_id { + PaneId::GridCell { index } => self + .layout_state + .as_grid() + .and_then(|state| state.session_at(index)), + PaneId::SplitSlot { slot } => self + .layout_state + .as_split_tree() + .and_then(|state| state.session_at_slot(slot)), + } + } + + fn pane_id_for_session(&self, session_id: SessionId) -> Option { + if let Some((pane_id, _)) = self + .pane_tab_groups + .iter() + .find(|(_, group)| group.session_ids.contains(&session_id)) + { + return Some(pane_id.clone()); + } + + match &self.layout_state { + WorkspaceLayoutState::Grid(state) => state + .assignments() + .iter() + .enumerate() + .find(|(_, assigned)| **assigned == Some(session_id)) + .map(|(index, _)| PaneId::GridCell { index }), + WorkspaceLayoutState::SplitTree(state) => state + .slot_for_session(session_id) + .map(|slot| PaneId::SplitSlot { slot }), + } + } + + fn pane_sessions(&self, pane_id: PaneId) -> Vec { + if let Some(group) = self.pane_tab_groups.get(&pane_id) { + return group.session_ids.clone(); + } + + self.active_session_for_pane(pane_id).into_iter().collect() + } + + fn current_pane_stacks_in_order(&self) -> Vec { + if self + .layout_state + .as_grid() + .is_some_and(|state| state.profile() == LayoutProfile::Single) + && self.pane_tab_groups.is_empty() + && self + .hidden_pane_stacks + .iter() + .all(|stack| stack.session_ids.len() == 1) + { + return self + .sessions + .iter() + .map(|session| PaneStack { + session_ids: vec![session.id], + active_session_id: session.id, + }) + .collect(); + } + + let mut stacks = self + .visible_pane_ids() + .into_iter() + .filter_map(|pane_id| { + let session_ids = self.pane_sessions(pane_id.clone()); + let active_session_id = self.active_session_for_pane(pane_id)?; + (!session_ids.is_empty()).then_some(PaneStack { + session_ids, + active_session_id, + }) + }) + .collect::>(); + + let mut assigned: HashSet = stacks + .iter() + .flat_map(|stack| stack.session_ids.iter().copied()) + .collect(); + stacks.extend(self.hidden_pane_stacks.iter().filter_map(|stack| { + let session_ids = stack + .session_ids + .iter() + .copied() + .filter(|session_id| { + self.session(*session_id).is_some() && !assigned.contains(session_id) + }) + .collect::>(); + if session_ids.is_empty() { + return None; + } + + assigned.extend(session_ids.iter().copied()); + Some(PaneStack { + active_session_id: if session_ids.contains(&stack.active_session_id) { + stack.active_session_id + } else { + session_ids[0] + }, + session_ids, + }) + })); + stacks.extend( self.sessions .iter() .map(|session| session.id) - .filter(|session_id| !assigned.contains(session_id)), + .filter(|session_id| !assigned.contains(session_id)) + .map(|session_id| PaneStack { + session_ids: vec![session_id], + active_session_id: session_id, + }), ); + + stacks + } + + fn apply_pane_stacks_to_current_layout(&mut self, stacks: Vec) { + let pane_ids = self.visible_pane_ids(); + self.pane_tab_groups.clear(); + self.hidden_pane_stacks.clear(); + + for (index, stack) in stacks.into_iter().enumerate() { + if let Some(pane_id) = pane_ids.get(index).cloned() { + if stack.session_ids.len() > 1 { + self.pane_tab_groups.insert( + pane_id.clone(), + PaneTabGroup { + pane: pane_id, + session_ids: stack.session_ids, + active_session_id: stack.active_session_id, + }, + ); + } + } else if !stack.session_ids.is_empty() { + self.hidden_pane_stacks.push(stack); + } + } + + self.cleanup_pane_tab_groups(); + } + + fn stack_session_order(stack: &PaneStack) -> Vec { + let mut ordered = vec![stack.active_session_id]; + ordered.extend( + stack + .session_ids + .iter() + .copied() + .filter(|session_id| *session_id != stack.active_session_id), + ); + ordered + } + + fn layout_session_order(stacks: &[PaneStack], visible_panes: usize) -> Vec { + let mut ordered = Vec::new(); + let visible_len = visible_panes.min(stacks.len()); + + ordered.extend( + stacks + .iter() + .take(visible_len) + .map(|stack| stack.active_session_id), + ); + for stack in stacks.iter().take(visible_len) { + ordered.extend( + stack + .session_ids + .iter() + .copied() + .filter(|session_id| *session_id != stack.active_session_id), + ); + } + for stack in stacks.iter().skip(visible_len) { + ordered.extend(Self::stack_session_order(stack)); + } + ordered } - fn rebuild_grid_state(&self, profile: LayoutProfile) -> LayoutState { + fn pane_exists(&self, pane_id: &PaneId) -> bool { + match pane_id { + PaneId::GridCell { index } => self + .layout_state + .as_grid() + .is_some_and(|state| *index < state.assignments().len()), + PaneId::SplitSlot { slot } => self + .layout_state + .as_split_tree() + .is_some_and(|state| state.tree().contains_slot(*slot)), + } + } + + fn cleanup_pane_tab_groups(&mut self) { + let valid_sessions: HashSet = + self.sessions.iter().map(|session| session.id).collect(); + let valid_panes: HashSet = self.visible_pane_ids().into_iter().collect(); + self.pane_tab_groups.retain(|pane_id, group| { + if !valid_panes.contains(pane_id) { + return false; + } + + group + .session_ids + .retain(|session_id| valid_sessions.contains(session_id)); + if !group.session_ids.contains(&group.active_session_id) { + if let Some(session_id) = group.session_ids.first().copied() { + group.active_session_id = session_id; + } + } + + group.session_ids.len() > 1 + }); + + let mut claimed_sessions: HashSet = self + .visible_pane_ids() + .into_iter() + .flat_map(|pane_id| self.pane_sessions(pane_id).into_iter()) + .collect(); + self.hidden_pane_stacks.retain_mut(|stack| { + stack.session_ids.retain(|session_id| { + valid_sessions.contains(session_id) && !claimed_sessions.contains(session_id) + }); + if stack.session_ids.is_empty() { + return false; + } + if !stack.session_ids.contains(&stack.active_session_id) { + stack.active_session_id = stack.session_ids[0]; + } + claimed_sessions.extend(stack.session_ids.iter().copied()); + true + }); + } + + fn set_pane_active_session(&mut self, pane_id: PaneId, session_id: SessionId) -> bool { + match pane_id { + PaneId::GridCell { index } => self + .layout_state + .as_grid_mut() + .and_then(|state| state.replace_session_in_index(index, session_id)) + .is_some(), + PaneId::SplitSlot { slot } => self + .layout_state + .as_split_tree_mut() + .and_then(|state| state.replace_session_in_slot(slot, session_id)) + .is_some(), + } + } + + fn remove_session_from_pane_group(&mut self, pane_id: PaneId, session_id: SessionId) { + let Some(mut group) = self.pane_tab_groups.remove(&pane_id) else { + return; + }; + + let was_active = group.active_session_id == session_id; + group.session_ids.retain(|current| *current != session_id); + + if group.session_ids.len() < 2 { + if was_active { + if let Some(next_active) = group.session_ids.first().copied() { + let _ = self.set_pane_active_session(pane_id.clone(), next_active); + } + } + return; + } + + if was_active { + if let Some(next_active) = group.session_ids.first().copied() { + if self.set_pane_active_session(pane_id.clone(), next_active) { + group.active_session_id = next_active; + } + } + } + + if group.session_ids.len() > 1 { + self.pane_tab_groups.insert(pane_id, group); + } + } + + fn insert_session_into_pane_group( + &mut self, + pane_id: PaneId, + session_id: SessionId, + make_active: bool, + ) -> bool { + let mut group = self + .pane_tab_groups + .remove(&pane_id) + .unwrap_or(PaneTabGroup { + pane: pane_id.clone(), + session_ids: self.pane_sessions(pane_id.clone()), + active_session_id: self + .active_session_for_pane(pane_id.clone()) + .unwrap_or(session_id), + }); + + if group.session_ids.is_empty() { + group.session_ids.push(session_id); + group.active_session_id = session_id; + } else if !group.session_ids.contains(&session_id) { + group.session_ids.push(session_id); + } + + if make_active { + if !self.set_pane_active_session(pane_id.clone(), session_id) { + self.pane_tab_groups.insert(pane_id, group); + return false; + } + group.active_session_id = session_id; + } + + if group.session_ids.len() > 1 { + self.pane_tab_groups.insert(pane_id, group); + } + true + } + + fn swap_pane_groups(&mut self, pane_a: PaneId, pane_b: PaneId) { + let group_a = self.pane_tab_groups.remove(&pane_a); + let group_b = self.pane_tab_groups.remove(&pane_b); + + if let Some(mut group) = group_a { + group.pane = pane_b.clone(); + self.pane_tab_groups.insert(pane_b, group); + } + + if let Some(mut group) = group_b { + group.pane = pane_a.clone(); + self.pane_tab_groups.insert(pane_a, group); + } + } + + fn rebuild_grid_state(&self, profile: LayoutProfile, stacks: &[PaneStack]) -> LayoutState { let focused = self.layout_state.focused_session(); let mut grid_state = LayoutState::with_profile(profile); - grid_state.set_assignments(self.ordered_session_ids()); + grid_state.set_assignments(Self::layout_session_order(stacks, profile.max_sessions())); if let Some(session_id) = focused { - grid_state.focus_session(session_id); + if !grid_state.focus_session(session_id) && profile == LayoutProfile::Single { + let _ = grid_state.swap_hidden_into_index(session_id, 0); + } } if grid_state.focused_session().is_none() { - if let Some(session_id) = grid_state.assignments().first().copied() { + if let Some(session_id) = grid_state.assignments().iter().flatten().copied().next() { grid_state.focus_session(session_id); } } if profile != LayoutProfile::Single { - let visible_len = grid_state.assignments().len().min(profile.max_sessions()); + let visible_len = grid_state.occupied_indices().len(); if grid_state .focused_index() - .is_some_and(|index| index >= visible_len) + .is_some_and(|index| index >= profile.max_sessions()) && visible_len > 0 { - grid_state.focus_index(0); + if let Some(index) = grid_state.occupied_indices().into_iter().next() { + grid_state.focus_index(index); + } } } grid_state } - fn rebuild_split_state(&self, tree: LayoutNode) -> SplitLayoutState { + fn rebuild_split_state(&self, tree: LayoutNode, stacks: &[PaneStack]) -> SplitLayoutState { let focused = self.layout_state.focused_session(); let mut split_state = SplitLayoutState::new(tree); - for session_id in self.ordered_session_ids() { + for session_id in Self::layout_session_order(stacks, split_state.slot_count()) { if !split_state.add_session(session_id) { break; } @@ -310,21 +690,11 @@ impl Workspace { return false; } - let added_to_visible_layout = match &mut self.layout_state { - WorkspaceLayoutState::Grid(s) => { - if s.add_session(id) { - true - } else { - s.append_hidden_session(id) - } - } + match &mut self.layout_state { + WorkspaceLayoutState::Grid(s) => s.add_session(id), WorkspaceLayoutState::SplitTree(s) => s.add_session(id), }; - if !added_to_visible_layout && self.layout_state.is_grid() { - return false; - } - self.sessions.push(session); // Focus the new session if it's the first one @@ -351,18 +721,68 @@ impl Workspace { true } + /// Add a session to a specific visible pane as a new active tab. + pub fn add_session_to_pane(&mut self, session: Session, pane_id: PaneId) -> bool { + let id = session.id; + if self.sessions.iter().any(|existing| existing.id == id) { + return false; + } + + self.sessions.push(session); + + if self.active_session_for_pane(pane_id.clone()).is_none() { + match pane_id { + PaneId::GridCell { index } => { + let Some(state) = self.layout_state.as_grid_mut() else { + self.sessions.retain(|session| session.id != id); + return false; + }; + if !state.assign_session_to_index(id, index) { + self.sessions.retain(|session| session.id != id); + return false; + } + state.focus_index(index); + } + PaneId::SplitSlot { slot } => { + let Some(state) = self.layout_state.as_split_tree_mut() else { + self.sessions.retain(|session| session.id != id); + return false; + }; + if !state.assign_session_to_slot(id, slot) { + self.sessions.retain(|session| session.id != id); + return false; + } + state.focus_slot(slot); + } + } + return true; + } + + if !self.insert_session_into_pane_group(pane_id, id, true) { + self.sessions.retain(|session| session.id != id); + return false; + } + + true + } + /// Remove a session from the workspace. /// /// # Returns /// /// The removed session, if found. pub fn remove_session(&mut self, id: SessionId) -> Option { + if let Some(pane_id) = self.pane_id_for_session(id) { + self.remove_session_from_pane_group(pane_id, id); + } + // Remove from layout self.layout_state.remove_session(id); // Remove from sessions list if let Some(pos) = self.sessions.iter().position(|s| s.id == id) { let removed = self.sessions.remove(pos); + self.cleanup_pane_tab_groups(); self.promote_hidden_sessions_into_split_slots(); Some(removed) } else { @@ -418,34 +838,23 @@ impl Workspace { /// Get the IDs of sessions currently visible in rendered panes. pub fn visible_session_ids(&self) -> Vec { - match &self.layout_state { - WorkspaceLayoutState::Grid(s) => { - if s.profile() == LayoutProfile::Single { - s.focused_session().into_iter().collect() - } else { - s.assignments() - .iter() - .take(s.profile().max_sessions()) - .copied() - .collect() - } - } - WorkspaceLayoutState::SplitTree(s) => s.assigned_sessions(), - } + self.visible_pane_ids() + .into_iter() + .filter_map(|pane_id| self.active_session_for_pane(pane_id)) + .collect() } /// Returns whether a session is currently visible in the active layout. pub fn is_session_visible(&self, session_id: SessionId) -> bool { - self.visible_session_ids().contains(&session_id) + self.pane_id_for_session(session_id).is_some() } /// Get the number of sessions that can still be added. pub fn available_slots(&self) -> usize { match &self.layout_state { - WorkspaceLayoutState::Grid(s) => s - .profile() - .max_sessions() - .saturating_sub(s.assignments().len().min(s.profile().max_sessions())), + WorkspaceLayoutState::Grid(s) => { + s.assignments().iter().filter(|cell| cell.is_none()).count() + } WorkspaceLayoutState::SplitTree(s) => s.available_slots(), } } @@ -462,6 +871,25 @@ impl Workspace { self.focused_session_id().and_then(|id| self.session(id)) } + /// Get the currently focused visible pane. + pub fn focused_pane_id(&self) -> Option { + match &self.layout_state { + WorkspaceLayoutState::Grid(state) => state + .focused_index() + .map(|index| PaneId::GridCell { index }), + WorkspaceLayoutState::SplitTree(state) => { + state.focused_slot().map(|slot| PaneId::SplitSlot { slot }) + } + } + } + + /// Get all sessions currently attached to the focused visible pane. + pub fn focused_pane_session_ids(&self) -> Vec { + self.focused_pane_id() + .map(|pane_id| self.pane_sessions(pane_id)) + .unwrap_or_default() + } + /// Focus a session by ID. /// /// # Returns @@ -472,60 +900,94 @@ impl Workspace { return false; } - match &mut self.layout_state { - WorkspaceLayoutState::Grid(s) => { - let Some(target_index) = s.assignments().iter().position(|&sid| sid == id) else { - return false; - }; + if let Some(pane_id) = self.pane_id_for_session(id) { + let should_activate = self + .pane_tab_groups + .get(&pane_id) + .is_some_and(|group| group.session_ids.contains(&id)) + && self.active_session_for_pane(pane_id.clone()) != Some(id); - if s.profile() == LayoutProfile::Single { - s.focus_index(target_index); - return true; + if should_activate { + if !self.set_pane_active_session(pane_id.clone(), id) { + return false; } - - let visible_len = s.assignments().len().min(s.profile().max_sessions()); - if target_index < visible_len { - s.focus_index(target_index); - return true; + if let Some(group) = self.pane_tab_groups.get_mut(&pane_id) { + group.active_session_id = id; } + } + } - let replacement_index = s - .focused_index() - .filter(|&index| index < visible_len) - .or_else(|| visible_len.checked_sub(1).map(|_| 0)); + let focused = match &mut self.layout_state { + WorkspaceLayoutState::Grid(s) => { + if s.profile() == LayoutProfile::Single { + if let Some(visible_index) = + s.assignments().iter().position(|cell| *cell == Some(id)) + { + s.focus_index(visible_index); + true + } else { + let replacement_index = s.focused_index().or(Some(0)); + let Some(replacement_index) = replacement_index else { + return false; + }; + s.swap_hidden_into_index(id, replacement_index).is_some() + } + } else if let Some(target_index) = + s.assignments().iter().position(|cell| *cell == Some(id)) + { + s.focus_index(target_index); + true + } else { + let replacement_index = s + .focused_index() + .or_else(|| s.occupied_indices().into_iter().next()) + .or_else(|| s.first_empty_index()); - let Some(replacement_index) = replacement_index else { - return false; - }; + let Some(replacement_index) = replacement_index else { + return false; + }; - if !s.swap_assignments(target_index, replacement_index) { - return false; + s.swap_hidden_into_index(id, replacement_index).is_some() } - - s.focus_index(replacement_index); - true } WorkspaceLayoutState::SplitTree(s) => { if s.focus_session(id) { - return true; + true + } else { + let target_slot = s + .focused_slot() + .or_else(|| { + s.assignments().iter().find_map(|(slot, session)| { + if session.is_some() + || self + .pane_tab_groups + .contains_key(&PaneId::SplitSlot { slot: *slot }) + { + Some(*slot) + } else { + None + } + }) + }) + .or_else(|| { + s.assignments() + .iter() + .find_map(|(slot, session)| session.is_none().then_some(*slot)) + }); + + let Some(target_slot) = target_slot else { + return false; + }; + + s.replace_session_in_slot(target_slot, id).is_some() } - - let target_slot = s - .focused_slot() - .and_then(|slot| (s.session_at_slot(slot).is_some()).then_some(slot)) - .or_else(|| { - s.assignments() - .iter() - .find_map(|(slot, session)| session.map(|_| *slot)) - }); - - let Some(target_slot) = target_slot else { - return false; - }; - - s.replace_session_in_slot(target_slot, id).is_some() } + }; + + if focused { + self.cleanup_pane_tab_groups(); } + focused } /// Focus a session by grid index (1-based, for keyboard shortcuts). @@ -536,16 +998,11 @@ impl Workspace { pub fn focus_session_number(&mut self, number: usize) -> bool { match &mut self.layout_state { WorkspaceLayoutState::Grid(s) => { - let visible_len = if s.profile() == LayoutProfile::Single { - s.assignments().len() - } else { - s.assignments().len().min(s.profile().max_sessions()) - }; - - if number == 0 || number > visible_len { + let visible_indices = s.occupied_indices(); + if number == 0 || number > visible_indices.len() { return false; } - s.focus_index(number - 1); + s.focus_index(visible_indices[number - 1]); true } WorkspaceLayoutState::SplitTree(s) => { @@ -562,15 +1019,18 @@ impl Workspace { pub fn focus_next(&mut self) { match &mut self.layout_state { WorkspaceLayoutState::Grid(s) if s.profile() != LayoutProfile::Single => { - let visible_len = s.assignments().len().min(s.profile().max_sessions()); - if visible_len == 0 { + let occupied = s.occupied_indices(); + if occupied.is_empty() { return; } - let next = match s.focused_index().filter(|&index| index < visible_len) { - Some(index) => (index + 1) % visible_len, + let next = match s + .focused_index() + .and_then(|index| occupied.iter().position(|¤t| current == index)) + { + Some(index) => (index + 1) % occupied.len(), None => 0, }; - s.focus_index(next); + s.focus_index(occupied[next]); } _ => self.layout_state.focus_next(), } @@ -580,16 +1040,19 @@ impl Workspace { pub fn focus_previous(&mut self) { match &mut self.layout_state { WorkspaceLayoutState::Grid(s) if s.profile() != LayoutProfile::Single => { - let visible_len = s.assignments().len().min(s.profile().max_sessions()); - if visible_len == 0 { + let occupied = s.occupied_indices(); + if occupied.is_empty() { return; } - let prev = match s.focused_index().filter(|&index| index < visible_len) { + let prev = match s + .focused_index() + .and_then(|index| occupied.iter().position(|¤t| current == index)) + { Some(index) if index > 0 => index - 1, - Some(_) => visible_len - 1, + Some(_) => occupied.len() - 1, None => 0, }; - s.focus_index(prev); + s.focus_index(occupied[prev]); } _ => self.layout_state.focus_previous(), } @@ -613,7 +1076,10 @@ impl Workspace { /// /// Transfers current sessions and focus to the new tree. pub fn set_split_tree(&mut self, tree: LayoutNode) { - self.layout_state = WorkspaceLayoutState::SplitTree(self.rebuild_split_state(tree)); + let stacks = self.current_pane_stacks_in_order(); + self.layout_state = + WorkspaceLayoutState::SplitTree(self.rebuild_split_state(tree, &stacks)); + self.apply_pane_stacks_to_current_layout(stacks); } // --- Split Pane Operations --- @@ -649,6 +1115,8 @@ impl Workspace { let (result, should_switch_to_single) = if let WorkspaceLayoutState::SplitTree(s) = &mut self.layout_state { if let Some(target) = s.focused_slot() { + self.pane_tab_groups + .remove(&PaneId::SplitSlot { slot: target }); let result = s.close_slot(target); let should_switch = result && s.slot_count() == 1; (result, should_switch) @@ -660,8 +1128,10 @@ impl Workspace { }; if should_switch_to_single { + let stacks = self.current_pane_stacks_in_order(); self.layout_state = - WorkspaceLayoutState::Grid(self.rebuild_grid_state(LayoutProfile::Single)); + WorkspaceLayoutState::Grid(self.rebuild_grid_state(LayoutProfile::Single, &stacks)); + self.apply_pane_stacks_to_current_layout(stacks); } result @@ -679,6 +1149,7 @@ impl Workspace { /// Convert the current grid layout to an equivalent split tree. fn convert_to_split_tree(&mut self) { + let stacks = self.current_pane_stacks_in_order(); let tree = if let WorkspaceLayoutState::Grid(grid_state) = &self.layout_state { let (rows, cols) = grid_state.profile().dimensions(); Some(LayoutNode::from_grid(rows, cols)) @@ -687,7 +1158,9 @@ impl Workspace { }; if let Some(tree) = tree { - self.layout_state = WorkspaceLayoutState::SplitTree(self.rebuild_split_state(tree)); + self.layout_state = + WorkspaceLayoutState::SplitTree(self.rebuild_split_state(tree, &stacks)); + self.apply_pane_stacks_to_current_layout(stacks); } } @@ -813,19 +1286,15 @@ impl Workspace { /// Get the bounds for a session's cell. pub fn session_cell_bounds(&self, id: SessionId) -> Option { - match &self.layout_state { - WorkspaceLayoutState::Grid(s) => { - let index = s.assignments().iter().position(|&sid| sid == id)?; - self.grid_layout().cell_bounds_for_index(index) - } - WorkspaceLayoutState::SplitTree(s) => { - let slot = s.slot_for_session(id)?; + match self.pane_id_for_session(id)? { + PaneId::GridCell { index } => self.grid_layout().cell_bounds_for_index(index), + PaneId::SplitSlot { slot } => { let layout = self.split_layout()?; layout .leaf_bounds() .into_iter() - .find(|(sid, _)| *sid == slot) - .map(|(_, b)| b) + .find(|(current, _)| *current == slot) + .map(|(_, bounds)| bounds) } } } @@ -869,8 +1338,37 @@ impl Workspace { /// Returns `true` if the swap was performed. pub fn swap_sessions(&mut self, index_a: usize, index_b: usize) -> bool { match &mut self.layout_state { - WorkspaceLayoutState::Grid(s) => s.swap_assignments(index_a, index_b), - WorkspaceLayoutState::SplitTree(s) => s.swap_assignments(index_a, index_b), + WorkspaceLayoutState::Grid(s) => { + let swapped = s.swap_assignments(index_a, index_b); + if swapped { + self.swap_pane_groups( + PaneId::GridCell { index: index_a }, + PaneId::GridCell { index: index_b }, + ); + } + swapped + } + WorkspaceLayoutState::SplitTree(s) => { + let Some(pane_a) = s + .assignments() + .get(index_a) + .map(|(slot, _)| PaneId::SplitSlot { slot: *slot }) + else { + return false; + }; + let Some(pane_b) = s + .assignments() + .get(index_b) + .map(|(slot, _)| PaneId::SplitSlot { slot: *slot }) + else { + return false; + }; + let swapped = s.swap_assignments(index_a, index_b); + if swapped { + self.swap_pane_groups(pane_a, pane_b); + } + swapped + } } } @@ -897,6 +1395,7 @@ impl Workspace { if self.session(focused_session_id).is_some() { if let Some(bounds) = layout.cell_bounds_for_index(0) { return vec![CellInfo { + pane_id: PaneId::GridCell { index: 0 }, session_id: focused_session_id, index: 0, bounds, @@ -913,10 +1412,12 @@ impl Workspace { .assignments() .iter() .enumerate() - .filter_map(|(index, &session_id)| { + .filter_map(|(index, session_id)| { + let session_id = (*session_id)?; let bounds = layout.cell_bounds_for_index(index)?; self.session(session_id)?; Some(CellInfo { + pane_id: PaneId::GridCell { index }, session_id, index, bounds, @@ -938,6 +1439,7 @@ impl Workspace { let session_id = state.session_at_slot(slot)?; self.session(session_id)?; Some(CellInfo { + pane_id: PaneId::SplitSlot { slot }, session_id, index, bounds, @@ -945,11 +1447,202 @@ impl Workspace { }) .collect() } + + /// Get all tab sessions for a pane in display order. + pub fn pane_tab_session_ids(&self, pane_id: PaneId) -> Vec { + self.pane_sessions(pane_id) + } + + /// Get the active session rendered in a pane. + pub fn pane_active_session_id(&self, pane_id: PaneId) -> Option { + self.active_session_for_pane(pane_id) + } + + /// Activate a tab within a visible pane. + pub fn activate_pane_tab(&mut self, pane_id: PaneId, session_id: SessionId) -> bool { + let Some(group) = self.pane_tab_groups.get(&pane_id) else { + return false; + }; + if !group.session_ids.contains(&session_id) { + return false; + } + if !self.set_pane_active_session(pane_id.clone(), session_id) { + return false; + } + if let Some(group) = self.pane_tab_groups.get_mut(&pane_id) { + group.active_session_id = session_id; + } + self.focus_session(session_id) + } + + /// Group a session into the target pane as an active tab. + pub fn group_session_into_pane(&mut self, session_id: SessionId, target_pane: PaneId) -> bool { + let Some(source_pane) = self.pane_id_for_session(session_id) else { + return false; + }; + if source_pane == target_pane { + return false; + } + + if !self.pane_exists(&target_pane) { + return false; + } + + let removed_from_source = match source_pane.clone() { + PaneId::GridCell { index } => { + if self.pane_tab_groups.contains_key(&source_pane) { + self.remove_session_from_pane_group(source_pane.clone(), session_id); + true + } else { + self.layout_state + .as_grid_mut() + .and_then(|state| state.clear_index(index)) + == Some(session_id) + } + } + PaneId::SplitSlot { .. } => { + if self.pane_tab_groups.contains_key(&source_pane) { + self.remove_session_from_pane_group(source_pane.clone(), session_id); + true + } else { + self.layout_state.remove_session(session_id) + } + } + }; + + if !removed_from_source { + return false; + } + + if self.active_session_for_pane(target_pane.clone()).is_none() { + let assigned = match target_pane.clone() { + PaneId::GridCell { index } => self + .layout_state + .as_grid_mut() + .is_some_and(|state| state.assign_session_to_index(session_id, index)), + PaneId::SplitSlot { slot } => self + .layout_state + .as_split_tree_mut() + .is_some_and(|state| state.assign_session_to_slot(session_id, slot)), + }; + if assigned { + self.cleanup_pane_tab_groups(); + return self.focus_session(session_id); + } + return false; + } + + let grouped = self.insert_session_into_pane_group(target_pane, session_id, true); + self.cleanup_pane_tab_groups(); + grouped && self.focus_session(session_id) + } + + /// Restore persisted pane stacks after sessions have been recreated. + pub fn restore_pane_stacks( + &mut self, + saved_stacks: &[PaneStackState], + restored_session_ids: &HashMap, + ) { + let stacks = saved_stacks + .iter() + .filter_map(|saved_stack| { + let session_ids = saved_stack + .session_ids + .iter() + .filter_map(|saved_id| restored_session_ids.get(saved_id).copied()) + .collect::>(); + if session_ids.is_empty() { + return None; + } + + Some(PaneStack { + active_session_id: restored_session_ids + .get(&saved_stack.active_session_id) + .copied() + .filter(|session_id| session_ids.contains(session_id)) + .unwrap_or(session_ids[0]), + session_ids, + }) + }) + .collect::>(); + self.restore_runtime_pane_stacks(stacks); + } + + fn restore_runtime_pane_stacks(&mut self, mut stacks: Vec) { + let assigned: HashSet = stacks + .iter() + .flat_map(|stack| stack.session_ids.iter().copied()) + .collect(); + stacks.extend( + self.sessions + .iter() + .map(|session| session.id) + .filter(|session_id| !assigned.contains(session_id)) + .map(|session_id| PaneStack { + session_ids: vec![session_id], + active_session_id: session_id, + }), + ); + + let rebuilt_layout = match &self.layout_state { + WorkspaceLayoutState::Grid(state) => { + WorkspaceLayoutState::Grid(self.rebuild_grid_state(state.profile(), &stacks)) + } + WorkspaceLayoutState::SplitTree(state) => WorkspaceLayoutState::SplitTree( + self.rebuild_split_state(state.tree().clone(), &stacks), + ), + }; + self.layout_state = rebuilt_layout; + self.apply_pane_stacks_to_current_layout(stacks); + } + + /// Restore legacy persisted pane tab groups after sessions have been recreated. + pub fn restore_pane_tab_groups( + &mut self, + saved_groups: &[PaneTabGroup], + restored_session_ids: &HashMap, + ) { + for group in saved_groups { + if !self.pane_exists(&group.pane) { + continue; + } + let session_ids = group + .session_ids + .iter() + .filter_map(|saved_id| restored_session_ids.get(saved_id).copied()) + .collect::>(); + if session_ids.is_empty() { + continue; + } + + let active_session_id = restored_session_ids + .get(&group.active_session_id) + .copied() + .filter(|session_id| session_ids.contains(session_id)) + .unwrap_or(session_ids[0]); + + for session_id in &session_ids { + if self.pane_id_for_session(*session_id) != Some(group.pane.clone()) { + let _ = self.group_session_into_pane(*session_id, group.pane.clone()); + } + } + + if let Some(restored_group) = self.pane_tab_groups.get_mut(&group.pane) { + restored_group.session_ids = session_ids.clone(); + restored_group.active_session_id = active_session_id; + } + let _ = self.activate_pane_tab(group.pane.clone(), active_session_id); + } + + self.cleanup_pane_tab_groups(); + } } /// Information about a grid cell for rendering. -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone)] pub struct CellInfo { + /// Visible pane identifier. + pub pane_id: PaneId, /// Session ID. pub session_id: SessionId, /// Grid index (0-based). diff --git a/crates/codirigent-ui/src/workspace/gpui.rs b/crates/codirigent-ui/src/workspace/gpui.rs index 4f7a176f..1dd96b09 100644 --- a/crates/codirigent-ui/src/workspace/gpui.rs +++ b/crates/codirigent-ui/src/workspace/gpui.rs @@ -791,6 +791,8 @@ impl WorkspaceView { this.persistence.storage.clone(), this.session_manager.clone(), this.persisted_layout_mode(), + this.workspace.pane_tab_groups(), + this.workspace.pane_stacks(), this.cache.last_window_state.clone(), )) }) { @@ -798,7 +800,8 @@ impl WorkspaceView { Ok(None) | Err(_) => return, }; - let (storage, session_manager, layout, window_state) = save_inputs; + let (storage, session_manager, layout, pane_tab_groups, pane_stacks, window_state) = + save_inputs; let result = cx .background_executor() .spawn(async move { @@ -809,6 +812,8 @@ impl WorkspaceView { let state = codirigent_core::AppState { sessions, layout, + pane_tab_groups, + pane_stacks, updated_at: Some(chrono::Utc::now()), window_bounds: window_state, }; @@ -1022,15 +1027,22 @@ impl WorkspaceView { let (rows, cols) = self.workspace.layout_profile().dimensions(); let occupied: Vec = self .workspace - .sessions() - .iter() - .enumerate() - .map(|(i, _)| { - let row = i as u32 / cols; - let col = i as u32 % cols; - GridPosition { row, col } + .layout_state() + .as_grid() + .map(|state| { + state + .assignments() + .iter() + .enumerate() + .filter_map(|(index, session_id)| { + session_id.map(|_| GridPosition { + row: index as u32 / cols, + col: index as u32 % cols, + }) + }) + .collect() }) - .collect(); + .unwrap_or_default(); self.empty_cells.setup_for_grid(rows, cols, &occupied); } @@ -1225,7 +1237,9 @@ impl WorkspaceView { EmptySessionEvent::CreateSessionClicked { position } => { info!(?position, "Create session at position"); if self.should_create_session_at(position) { - self.create_session(cx); + let cols = self.workspace.layout_profile().dimensions().1; + let index = (position.row * cols + position.col) as usize; + self.create_session_in_pane(codirigent_core::PaneId::GridCell { index }, cx); } } } @@ -1372,7 +1386,7 @@ impl WorkspaceView { // Layout constants from types.rs: HEADER_HEIGHT, TERMINAL_CONTENT_PADDING, CELL_BORDER_WIDTH let mut resized_any = false; - for &info in &self.cache.render_cell_info { + for info in &self.cache.render_cell_info { if let Some(terminal_view) = self.terminals.get_mut(&info.session_id) { // Subtract all chrome between the grid cell bounds and the // actual terminal canvas drawing area: @@ -2159,11 +2173,29 @@ impl Render for WorkspaceView { cx.listener(|this, _event: &MouseUpEvent, _window, cx| { if let Some(drag) = this.selection.drag.take() { if drag.active { - if let Some(target) = drag.target_index { - this.workspace.swap_sessions(drag.source_index, target); - this.mark_layout_cache_dirty(); - this.sync_layout_derived_state(); - this.save_state_to_disk(cx); + if let Some(target) = drag.target { + let changed = match target.kind { + super::types::DragTargetKind::PaneBody => this + .workspace + .swap_sessions(drag.source_index, target.index), + super::types::DragTargetKind::PaneHeader => this + .cache + .render_cell_info + .iter() + .find(|info| info.index == target.index) + .cloned() + .is_some_and(|info| { + this.workspace.group_session_into_pane( + drag.source_session_id, + info.pane_id, + ) + }), + }; + if changed { + this.mark_layout_cache_dirty(); + this.sync_layout_derived_state(); + this.save_state_to_disk(cx); + } } } cx.notify(); diff --git a/crates/codirigent-ui/src/workspace/grid_render.rs b/crates/codirigent-ui/src/workspace/grid_render.rs index d4ae03af..a7b406b8 100644 --- a/crates/codirigent-ui/src/workspace/grid_render.rs +++ b/crates/codirigent-ui/src/workspace/grid_render.rs @@ -76,7 +76,13 @@ impl WorkspaceView { let index = (row * cols + col) as usize; let position = codirigent_core::GridPosition { row, col }; - let cell_div = if let Some(info) = self.cache.render_cell_info.get(index).copied() { + let cell_div = if let Some(info) = self + .cache + .render_cell_info + .iter() + .find(|info| info.index == index) + .cloned() + { // Get or create terminal header hints let header_hints = if let Some(header) = self.get_terminal_header(info.session_id) { @@ -106,6 +112,7 @@ impl WorkspaceView { // Render session cell with actual terminal content self.render_session_cell_with_terminal( + info.pane_id.clone(), info.session_id, &header_hints, &theme, @@ -210,6 +217,7 @@ impl WorkspaceView { }; self.render_session_cell_with_terminal( + codirigent_core::PaneId::SplitSlot { slot: *slot }, session_id, &header_hints, theme, @@ -332,8 +340,10 @@ impl WorkspaceView { } /// Render a session cell with terminal header and actual terminal content. + #[allow(clippy::too_many_arguments)] fn render_session_cell_with_terminal( &mut self, + pane_id: codirigent_core::PaneId, session_id: SessionId, hints: &TerminalHeaderRenderHints, theme: &CodirigentTheme, @@ -370,7 +380,7 @@ impl WorkspaceView { } if drag.source_index == drag_logical_index.unwrap_or(usize::MAX) { Some(DragVisual::Source) - } else if drag.target_index == drag_logical_index { + } else if drag.target.map(|target| target.index) == drag_logical_index { Some(DragVisual::Target) } else { None @@ -391,6 +401,11 @@ impl WorkspaceView { // Color indicator bar let color_indicator: gpui::Hsla = hints.color_indicator.into(); let status_color: gpui::Hsla = hints.status.color.into(); + let pane_tab_ids = self.workspace().pane_tab_session_ids(pane_id.clone()); + let show_plus_button = self + .workspace() + .pane_active_session_id(pane_id.clone()) + .is_some(); let mut header = div() .id(SharedString::from(format!( @@ -414,15 +429,114 @@ impl WorkspaceView { .bg(color_indicator), ) .child(div().w(px(8.0)).h(px(8.0)).rounded_full().bg(status_color)) - .child( - div() - .text_xs() - .font_weight(FontWeight::MEDIUM) - .text_color(fg) - .overflow_hidden() - .text_ellipsis() - .child(hints.name.clone()), - ); + .child({ + let mut tab_strip = div().flex().items_center().gap_1().overflow_hidden(); + + for tab_session_id in pane_tab_ids { + let tab_is_active = tab_session_id == session_id; + let tab_name = self + .workspace() + .session(tab_session_id) + .map(|session| session.name.clone()) + .unwrap_or_else(|| hints.name.clone()); + let tab_bg = if tab_is_active { + theme.active.into() + } else { + border_color.opacity(0.35) + }; + let tab_fg = if tab_is_active { + fg + } else { + muted.opacity(0.9) + }; + + let mut tab = div() + .id(SharedString::from(format!( + "terminal-tab-{}-{}", + session_id.0, tab_session_id.0 + ))) + .px_2() + .h(px(22.0)) + .rounded_md() + .bg(tab_bg) + .flex() + .items_center() + .gap_1() + .overflow_hidden() + .cursor_pointer() + .on_click(cx.listener({ + let pane_id = pane_id.clone(); + move |this, _: &ClickEvent, _window, cx| { + if this + .workspace + .activate_pane_tab(pane_id.clone(), tab_session_id) + { + this.select_session_with_cx(tab_session_id, cx); + this.mark_layout_cache_dirty(); + this.sync_layout_derived_state(); + this.save_state_to_disk(cx); + cx.notify(); + } + } + })) + .child( + div() + .text_xs() + .font_weight(if tab_is_active { + FontWeight::SEMIBOLD + } else { + FontWeight::MEDIUM + }) + .text_color(tab_fg) + .overflow_hidden() + .text_ellipsis() + .child(tab_name), + ); + + if tab_is_active { + tab = tab + .cursor_grab() + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, event: &MouseDownEvent, _window, cx| { + let pos = crate::layout::Point::new( + event.position.x.into(), + event.position.y.into(), + ); + this.selection.drag = Some(super::types::DragState { + source_session_id: tab_session_id, + source_index: drag_logical_index.unwrap_or(0), + start_position: pos, + current_position: pos, + active: false, + target: None, + }); + cx.notify(); + }), + ) + .on_mouse_move(cx.listener( + move |this, event: &MouseMoveEvent, _window, cx| { + let Some(drag) = &mut this.selection.drag else { + return; + }; + if drag.source_session_id != tab_session_id { + return; + } + let pos = crate::layout::Point::new( + event.position.x.into(), + event.position.y.into(), + ); + drag.update_pointer(pos, &this.cache.render_cell_info); + cx.notify(); + }, + )); + } + + tab_strip = tab_strip.child(tab); + } + + tab_strip + }); // Project/directory name (after session name) if let Some(project) = &hints.project_name { @@ -509,59 +623,43 @@ impl WorkspaceView { ); } + if show_plus_button { + header = header.child( + div() + .id(SharedString::from(format!("pane-add-tab-{}", session_id.0))) + .w(px(20.0)) + .h(px(20.0)) + .rounded_md() + .bg(border_color.opacity(0.25)) + .flex() + .items_center() + .justify_center() + .cursor_pointer() + .hover(|style| style.bg(border_color.opacity(0.45))) + .on_click(cx.listener({ + let pane_id = pane_id.clone(); + move |this, _: &ClickEvent, _window, cx| { + this.create_session_in_pane(pane_id.clone(), cx); + } + })) + .child( + div() + .text_xs() + .font_family(icons::LUCIDE_FONT_FAMILY) + .text_color(fg) + .child(icons::plus()), + ), + ); + } + // Set cursor for draggable header header = if matches!(drag_visual, Some(DragVisual::Source)) { header.cursor_grabbing() - } else if drag_logical_index.is_some() && self.cache.render_cell_info.len() > 1 { - header.cursor_grab() } else { header }; - // --- Drag-and-drop handlers on header --- - // Use logical index (CellInfo.index) for swap operations, not Vec position. - if let Some(logical_index) = drag_logical_index { - // Don't allow drag in single-pane layout - if self.cache.render_cell_info.len() > 1 { - header = header - .on_mouse_down( - MouseButton::Left, - cx.listener(move |this, event: &MouseDownEvent, _window, cx| { - let pos = crate::layout::Point::new( - event.position.x.into(), - event.position.y.into(), - ); - this.selection.drag = Some(super::types::DragState { - source_session_id: session_id, - source_index: logical_index, - start_position: pos, - current_position: pos, - active: false, - target_index: None, - }); - cx.notify(); - }), - ) - .on_mouse_move(cx.listener( - move |this, event: &MouseMoveEvent, _window, cx| { - let Some(drag) = &mut this.selection.drag else { - return; - }; - if drag.source_session_id != session_id { - return; - } - let pos = crate::layout::Point::new( - event.position.x.into(), - event.position.y.into(), - ); - drag.update_pointer(pos, &this.cache.render_cell_info); - cx.notify(); - }, - )); - // Note: mouse-up swap is handled by the global handler on - // workspace-container (gpui.rs) to catch releases anywhere. - } - } + // Mouse-up handling for active-tab drags lives on the workspace root. // Render terminal content before building the div tree so the // mutable borrow on `self` is released before `cx.listener()`. diff --git a/crates/codirigent-ui/src/workspace/impl_action_handlers.rs b/crates/codirigent-ui/src/workspace/impl_action_handlers.rs index 6b59208a..ddf41075 100644 --- a/crates/codirigent-ui/src/workspace/impl_action_handlers.rs +++ b/crates/codirigent-ui/src/workspace/impl_action_handlers.rs @@ -72,17 +72,19 @@ impl WorkspaceView { cx: &mut Context, ) { info!("ClosePane action triggered"); - // Get the session in the focused slot BEFORE closing the pane - let session_to_close = self.workspace.focused_session_id(); + // Capture every tab in the focused pane before the layout removes it. + let sessions_to_close = self.workspace.focused_pane_session_ids(); if self.workspace.close_pane() { info!("Closed focused pane"); - // If the closed pane had a session, clean it up fully - if let Some(id) = session_to_close { - self.close_session(id, cx); - } else { + // Close every session that belonged to the removed pane. + if sessions_to_close.is_empty() { self.mark_layout_cache_dirty(); cx.notify(); + } else { + for id in sessions_to_close { + self.close_session(id, cx); + } } } } diff --git a/crates/codirigent-ui/src/workspace/impl_session_lifecycle.rs b/crates/codirigent-ui/src/workspace/impl_session_lifecycle.rs index 9896538b..c9738d5a 100644 --- a/crates/codirigent-ui/src/workspace/impl_session_lifecycle.rs +++ b/crates/codirigent-ui/src/workspace/impl_session_lifecycle.rs @@ -13,8 +13,9 @@ use crate::terminal::Terminal; use crate::terminal_header::TerminalHeader; use crate::terminal_view::TerminalView; use codirigent_core::{ - CodexExecutionMode, CodirigentEvent, EventBus, GridPosition, LayoutMode, ProcessMonitor, - Session, SessionId, SessionManager, SessionStatus, SlotId, + CodexExecutionMode, CodirigentEvent, EventBus, GridPosition, LayoutMode, PaneId, + PaneStackState, PaneTabGroup, ProcessMonitor, Session, SessionId, SessionManager, + SessionStatus, SlotId, }; use codirigent_session::DefaultSessionManager; use gpui::Context; @@ -27,8 +28,9 @@ use std::sync::{Arc, Mutex}; use std::time::{Duration, SystemTime}; use tracing::{info, warn}; -#[derive(Debug)] +#[derive(Debug, Clone)] struct RestoreSessionPlan { + original_session_id: SessionId, session_name: String, working_dir: PathBuf, group: Option, @@ -40,9 +42,10 @@ struct RestoreSessionPlan { gemini_resume: Option, } -#[derive(Debug)] +#[derive(Debug, Clone)] struct RestorePlan { layout: LayoutMode, + pane_stacks: Vec, sessions: Vec, } @@ -67,6 +70,58 @@ struct CompletedRestoreBootstrap { result: Result, } +fn legacy_pane_stacks_from_groups( + saved_sessions: &[Session], + pane_tab_groups: &[PaneTabGroup], +) -> Vec { + let mut groups = pane_tab_groups.to_vec(); + groups.sort_by_key(|group| match group.pane { + PaneId::GridCell { index } => (0u8, index), + PaneId::SplitSlot { slot } => (1u8, slot.0 as usize), + }); + + let valid_sessions: HashSet = + saved_sessions.iter().map(|session| session.id).collect(); + let mut assigned = HashSet::new(); + let mut stacks = Vec::new(); + + for group in groups { + let session_ids = group + .session_ids + .into_iter() + .filter(|session_id| { + valid_sessions.contains(session_id) && assigned.insert(*session_id) + }) + .collect::>(); + if session_ids.is_empty() { + continue; + } + + let active_session_id = if session_ids.contains(&group.active_session_id) { + group.active_session_id + } else { + session_ids[0] + }; + stacks.push(PaneStackState { + session_ids, + active_session_id, + }); + } + + stacks.extend( + saved_sessions + .iter() + .map(|session| session.id) + .filter(|session_id| !assigned.contains(session_id)) + .map(|session_id| PaneStackState { + session_ids: vec![session_id], + active_session_id: session_id, + }), + ); + + stacks +} + fn next_available_session_number( existing_sessions: &[Session], reserved_numbers: &HashSet, @@ -547,6 +602,7 @@ mod tests { #[test] fn restore_resume_commands_preserve_cli_order() { let plan = RestoreSessionPlan { + original_session_id: SessionId(1), session_name: "Session 1".to_string(), working_dir: sample_working_dir(), group: None, @@ -676,6 +732,8 @@ mod tests { fallback_dir.clone(), )], layout: LayoutMode::Grid { rows: 1, cols: 4 }, + pane_tab_groups: Vec::new(), + pane_stacks: Vec::new(), updated_at: None, window_bounds: None, }; @@ -702,6 +760,8 @@ mod tests { layout: LayoutMode::SplitTree { root: split_tree.clone(), }, + pane_tab_groups: Vec::new(), + pane_stacks: Vec::new(), updated_at: None, window_bounds: None, }; @@ -747,12 +807,12 @@ impl WorkspaceView { fn release_session_create_reservation( &mut self, reserved_number: u64, - target_slot: Option, + target_pane: Option, ) { self.polling .pending_session_bootstrap_numbers .remove(&reserved_number); - if let Some(slot) = target_slot { + if let Some(PaneId::SplitSlot { slot }) = target_pane { self.polling.pending_session_bootstrap_slots.remove(&slot); } } @@ -819,22 +879,25 @@ impl WorkspaceView { &mut self, session: Session, session_name: &str, - target_slot: Option, + target_pane: Option, group: Option<&String>, color: Option<&String>, ) -> bool { let session_id = session.id; self.create_terminal_view_for_session(session_id); - let added = match target_slot { - Some(slot) => { - if self.workspace.add_session_to_slot(session.clone(), slot) { + let added = match target_pane.clone() { + Some(pane_id) => { + if self + .workspace + .add_session_to_pane(session.clone(), pane_id.clone()) + { true } else { warn!( ?session_id, - ?slot, - "Reserved slot unavailable when session bootstrap completed; falling back" + ?pane_id, + "Reserved pane unavailable when session bootstrap completed; falling back" ); self.workspace.add_session(session.clone()) } @@ -880,7 +943,7 @@ impl WorkspaceView { fn finalize_created_session_bootstrap( &mut self, bootstrapped: SessionBootstrapResult, - target_slot: Option, + target_pane: Option, cx: &mut Context, ) { self.start_bootstrapped_session_monitoring(bootstrapped.session_id, bootstrapped.child_pid); @@ -888,7 +951,7 @@ impl WorkspaceView { if !self.attach_bootstrapped_session( bootstrapped.session.clone(), &bootstrapped.request.session_name, - target_slot, + target_pane.clone(), None, None, ) { @@ -897,11 +960,11 @@ impl WorkspaceView { self.select_session_with_cx(bootstrapped.session_id, cx); - if let Some(slot) = target_slot { + if let Some(pane_id) = target_pane { info!( name = %bootstrapped.request.session_name, - ?slot, - "Created new session in slot via background bootstrap" + ?pane_id, + "Created new session in pane via background bootstrap" ); } else { info!( @@ -977,7 +1040,7 @@ impl WorkspaceView { &mut self, request: SessionBootstrapRequest, reserved_number: u64, - target_slot: Option, + target_pane: Option, cx: &mut Context, ) { let session_manager = self.session_manager.clone(); @@ -990,10 +1053,14 @@ impl WorkspaceView { .await; let _ = this.update(cx, |this, cx| { - this.release_session_create_reservation(reserved_number, target_slot); + this.release_session_create_reservation(reserved_number, target_pane.clone()); match result { Ok(bootstrapped) => { - this.finalize_created_session_bootstrap(bootstrapped, target_slot, cx); + this.finalize_created_session_bootstrap( + bootstrapped, + target_pane.clone(), + cx, + ); } Err(error) => { warn!( @@ -1015,6 +1082,8 @@ impl WorkspaceView { let codirigent_core::AppState { sessions: saved_sessions, layout, + pane_stacks, + pane_tab_groups, .. } = state; @@ -1022,6 +1091,12 @@ impl WorkspaceView { return None; } + let pane_stacks = if pane_stacks.is_empty() { + legacy_pane_stacks_from_groups(&saved_sessions, &pane_tab_groups) + } else { + pane_stacks + }; + let mut used_names = std::collections::HashSet::new(); let mut used_claude_ids: std::collections::HashSet = std::collections::HashSet::new(); @@ -1095,6 +1170,7 @@ impl WorkspaceView { .and_then(|gemini_id| build_resume_command("gemini", gemini_id, &[])); sessions.push(RestoreSessionPlan { + original_session_id: saved.id, session_name, working_dir, group: saved.group, @@ -1107,7 +1183,11 @@ impl WorkspaceView { }); } - Some(RestorePlan { layout, sessions }) + Some(RestorePlan { + layout, + pane_stacks, + sessions, + }) } fn apply_restore_plan( @@ -1125,6 +1205,7 @@ impl WorkspaceView { let session_count = plan.sessions.len(); let desired_layout = plan.layout.clone(); + let desired_pane_stacks = plan.pane_stacks.clone(); let staging_layout = staging_layout_for_restore(&desired_layout, session_count); let reapply_saved_layout = staging_layout != desired_layout; self.apply_restored_layout_mode(&staging_layout); @@ -1132,6 +1213,7 @@ impl WorkspaceView { let restore_sessions = plan.sessions; let session_manager = self.session_manager.clone(); cx.spawn(async move |this: gpui::WeakEntity, cx| { + let mut restored_session_ids = std::collections::HashMap::new(); let mut remaining = restore_sessions.into_iter().peekable(); while remaining.peek().is_some() { let batch = remaining.by_ref().take(2).collect::>(); @@ -1164,10 +1246,14 @@ impl WorkspaceView { for completion in completions { match completion.result { Ok(bootstrapped) => { + let restored_session_id = bootstrapped.session_id; + let original_session_id = completion.plan.original_session_id; this.finalize_restored_session_bootstrap( bootstrapped, completion.plan, ); + restored_session_ids + .insert(original_session_id, restored_session_id); } Err(error) => { warn!( @@ -1183,8 +1269,13 @@ impl WorkspaceView { if reapply_saved_layout { this.apply_restored_layout_mode(&desired_layout); } - if let Some(first_id) = this.workspace.sessions().first().map(|s| s.id) { - this.select_session_with_cx(first_id, cx); + this.workspace + .restore_pane_stacks(&desired_pane_stacks, &restored_session_ids); + if let Some(focused_id) = this.workspace.focused_session_id() { + this.selection.selected_session_id = Some(focused_id); + this.drawer.set_selected_session(Some(focused_id)); + this.sync_layout_derived_state(); + this.sync_file_tree_to_focused_session(cx); } this.polling.restore_in_flight = false; info!("Session restoration complete"); @@ -1237,17 +1328,22 @@ impl WorkspaceView { self.create_session_inner(None, cx); } + /// Create a new session in a specific visible pane. + pub fn create_session_in_pane(&mut self, pane_id: PaneId, cx: &mut Context) { + self.create_session_inner(Some(pane_id), cx); + } + /// Create a new session in a specific split tree slot. pub fn create_session_in_slot(&mut self, slot: SlotId, cx: &mut Context) { - self.create_session_inner(Some(slot), cx); + self.create_session_inner(Some(PaneId::SplitSlot { slot }), cx); } /// Shared implementation for session creation. - /// When `target_slot` is `None`, adds to the first available slot; - /// when `Some(slot)`, adds to that specific slot. - fn create_session_inner(&mut self, target_slot: Option, cx: &mut Context) { + /// When `target_pane` is `None`, adds to the next available pane; + /// when `Some(pane)`, adds to that specific pane. + fn create_session_inner(&mut self, target_pane: Option, cx: &mut Context) { // Find the lowest available session number (reuse gaps from closed sessions) - if let Some(slot) = target_slot { + if let Some(PaneId::SplitSlot { slot }) = target_pane { if self.polling.pending_session_bootstrap_slots.contains(&slot) { warn!(?slot, "Session creation already pending for slot"); return; @@ -1279,7 +1375,7 @@ impl WorkspaceView { working_dir, shell, }; - self.spawn_create_session_bootstrap(request, num, target_slot, cx); + self.spawn_create_session_bootstrap(request, num, target_pane, cx); } /// Restore sessions from disk on startup without blocking the UI thread. diff --git a/crates/codirigent-ui/src/workspace/tests.rs b/crates/codirigent-ui/src/workspace/tests.rs index 48b48231..12dc7f16 100644 --- a/crates/codirigent-ui/src/workspace/tests.rs +++ b/crates/codirigent-ui/src/workspace/tests.rs @@ -3,7 +3,10 @@ use super::core::*; use crate::layout::{Bounds, FocusDirection, LayoutProfile, Point}; use crate::theme::CodirigentTheme; -use codirigent_core::{LayoutNode, Session, SessionId, SessionStatus, SlotId, SplitDirection}; +use codirigent_core::{ + LayoutNode, PaneId, PaneStackState, PaneTabGroup, Session, SessionId, SessionStatus, SlotId, + SplitDirection, +}; use std::path::PathBuf; fn make_session(id: u64, name: &str) -> Session { @@ -360,11 +363,13 @@ fn test_workspace_cell_info() { #[test] fn test_cell_info_fields() { let info = CellInfo { + pane_id: PaneId::GridCell { index: 0 }, session_id: SessionId(1), index: 0, bounds: Bounds::from_size(100.0, 100.0), }; + assert_eq!(info.pane_id, PaneId::GridCell { index: 0 }); assert_eq!(info.session_id, SessionId(1)); assert_eq!(info.index, 0); assert_eq!(info.bounds.size.width, 100.0); @@ -785,6 +790,29 @@ fn test_close_pane_removes_session_from_workspace() { assert!(ws.session(SessionId(2)).is_some()); } +#[test] +fn test_close_tabbed_split_pane_requires_closing_all_pane_sessions() { + let mut ws = Workspace::new(); + let tree = LayoutNode::from_grid(1, 2); + ws.set_split_tree(tree); + + ws.add_session(make_session(1, "S1")); + ws.add_session(make_session(2, "S2")); + assert!(ws.group_session_into_pane(SessionId(1), PaneId::SplitSlot { slot: SlotId(1) })); + assert!(ws.focus_session(SessionId(1))); + + let pane_session_ids = ws.focused_pane_session_ids(); + assert_eq!(pane_session_ids, vec![SessionId(2), SessionId(1)]); + + assert!(ws.close_pane()); + for id in pane_session_ids { + ws.remove_session(id); + } + + assert!(ws.sessions().is_empty()); + assert!(ws.pane_tab_groups().is_empty()); +} + #[test] fn test_string_truncation_no_allocation_when_short() { use std::borrow::Cow; @@ -930,16 +958,235 @@ fn test_workspace_swap_sessions_split_tree_after_split_respects_visual_order() { ); } +#[test] +fn test_workspace_group_session_into_grid_pane_creates_tabs_without_reflow() { + let mut ws = Workspace::with_profile(LayoutProfile::Grid2x2); + for i in 1..=4 { + assert!(ws.add_session(make_session(i, &format!("S{}", i)))); + } + + assert!(ws.group_session_into_pane(SessionId(1), PaneId::GridCell { index: 1 })); + + let cells = ws.cell_info(); + assert_eq!(cells.len(), 3); + assert!(cells.iter().all(|cell| cell.index != 0)); + assert_eq!( + ws.pane_tab_session_ids(PaneId::GridCell { index: 1 }), + vec![SessionId(2), SessionId(1)] + ); + assert_eq!( + ws.pane_active_session_id(PaneId::GridCell { index: 1 }), + Some(SessionId(1)) + ); + assert!(ws.is_session_visible(SessionId(2))); +} + +#[test] +fn test_workspace_activate_pane_tab_switches_active_session() { + let mut ws = Workspace::with_profile(LayoutProfile::Grid2x2); + for i in 1..=3 { + assert!(ws.add_session(make_session(i, &format!("S{}", i)))); + } + + assert!(ws.group_session_into_pane(SessionId(1), PaneId::GridCell { index: 1 })); + assert!(ws.activate_pane_tab(PaneId::GridCell { index: 1 }, SessionId(2))); + + assert_eq!(ws.focused_session_id(), Some(SessionId(2))); + assert_eq!( + ws.pane_active_session_id(PaneId::GridCell { index: 1 }), + Some(SessionId(2)) + ); +} + +#[test] +fn test_workspace_add_session_to_existing_pane_creates_active_tab() { + let mut ws = Workspace::with_profile(LayoutProfile::Grid2x2); + assert!(ws.add_session(make_session(1, "S1"))); + assert!(ws.add_session(make_session(2, "S2"))); + + assert!(ws.add_session_to_pane(make_session(3, "S3"), PaneId::GridCell { index: 0 })); + + assert_eq!( + ws.pane_tab_session_ids(PaneId::GridCell { index: 0 }), + vec![SessionId(1), SessionId(3)] + ); + assert_eq!( + ws.pane_active_session_id(PaneId::GridCell { index: 0 }), + Some(SessionId(3)) + ); + assert_eq!(ws.focused_session_id(), Some(SessionId(3))); +} + +#[test] +fn test_workspace_remove_active_tab_promotes_next_tab() { + let mut ws = Workspace::with_profile(LayoutProfile::Grid2x2); + for i in 1..=3 { + assert!(ws.add_session(make_session(i, &format!("S{}", i)))); + } + + assert!(ws.group_session_into_pane(SessionId(1), PaneId::GridCell { index: 1 })); + assert_eq!( + ws.pane_active_session_id(PaneId::GridCell { index: 1 }), + Some(SessionId(1)) + ); + + let removed = ws.remove_session(SessionId(1)); + assert!(removed.is_some()); + assert_eq!( + ws.pane_active_session_id(PaneId::GridCell { index: 1 }), + Some(SessionId(2)) + ); + assert_eq!( + ws.pane_tab_session_ids(PaneId::GridCell { index: 1 }), + vec![SessionId(2)] + ); +} + +#[test] +fn test_workspace_restore_pane_tab_groups_rehydrates_active_tabs() { + let mut ws = Workspace::with_profile(LayoutProfile::Grid2x2); + assert!(ws.add_session(make_session(11, "S11"))); + assert!(ws.add_session(make_session(12, "S12"))); + assert!(ws.add_session(make_session(13, "S13"))); + + let saved_groups = vec![PaneTabGroup { + pane: PaneId::GridCell { index: 1 }, + session_ids: vec![SessionId(2), SessionId(1)], + active_session_id: SessionId(1), + }]; + let restored_ids = std::collections::HashMap::from([ + (SessionId(1), SessionId(11)), + (SessionId(2), SessionId(12)), + ]); + + ws.restore_pane_tab_groups(&saved_groups, &restored_ids); + + assert_eq!( + ws.pane_tab_session_ids(PaneId::GridCell { index: 1 }), + vec![SessionId(12), SessionId(11)] + ); + assert_eq!( + ws.pane_active_session_id(PaneId::GridCell { index: 1 }), + Some(SessionId(11)) + ); +} + +#[test] +fn test_layout_changes_preserve_hidden_pane_stacks_and_active_tabs() { + let mut ws = Workspace::with_profile(LayoutProfile::Grid2x2); + assert!(ws.add_session(make_session(1, "S1"))); + assert!(ws.add_session(make_session(2, "S2"))); + assert!(ws.add_session(make_session(3, "S3"))); + assert!(ws.add_session(make_session(4, "S4"))); + assert!(ws.group_session_into_pane(SessionId(2), PaneId::GridCell { index: 0 })); + assert!(ws.group_session_into_pane(SessionId(4), PaneId::GridCell { index: 2 })); + + ws.set_layout(LayoutProfile::Single); + + assert_eq!( + ws.pane_tab_session_ids(PaneId::GridCell { index: 0 }), + vec![SessionId(1), SessionId(2)] + ); + assert_eq!( + ws.pane_active_session_id(PaneId::GridCell { index: 0 }), + Some(SessionId(2)) + ); + assert_eq!( + ws.pane_stacks(), + vec![ + PaneStackState { + session_ids: vec![SessionId(1), SessionId(2)], + active_session_id: SessionId(2), + }, + PaneStackState { + session_ids: vec![SessionId(3), SessionId(4)], + active_session_id: SessionId(4), + }, + ] + ); + + ws.set_layout(LayoutProfile::Grid2x2); + + assert_eq!( + ws.pane_tab_session_ids(PaneId::GridCell { index: 0 }), + vec![SessionId(1), SessionId(2)] + ); + assert_eq!( + ws.pane_active_session_id(PaneId::GridCell { index: 0 }), + Some(SessionId(2)) + ); + assert_eq!( + ws.pane_tab_session_ids(PaneId::GridCell { index: 1 }), + vec![SessionId(3), SessionId(4)] + ); + assert_eq!( + ws.pane_active_session_id(PaneId::GridCell { index: 1 }), + Some(SessionId(4)) + ); +} + +#[test] +fn test_workspace_restore_pane_stacks_preserves_hidden_stack_order() { + let mut ws = Workspace::with_profile(LayoutProfile::Single); + assert!(ws.add_session(make_session(11, "S11"))); + assert!(ws.add_session(make_session(12, "S12"))); + assert!(ws.add_session(make_session(13, "S13"))); + assert!(ws.add_session(make_session(14, "S14"))); + + let saved_stacks = vec![ + PaneStackState { + session_ids: vec![SessionId(2), SessionId(1)], + active_session_id: SessionId(1), + }, + PaneStackState { + session_ids: vec![SessionId(4), SessionId(3)], + active_session_id: SessionId(4), + }, + ]; + let restored_ids = std::collections::HashMap::from([ + (SessionId(1), SessionId(11)), + (SessionId(2), SessionId(12)), + (SessionId(3), SessionId(13)), + (SessionId(4), SessionId(14)), + ]); + + ws.restore_pane_stacks(&saved_stacks, &restored_ids); + + assert_eq!( + ws.pane_tab_session_ids(PaneId::GridCell { index: 0 }), + vec![SessionId(12), SessionId(11)] + ); + assert_eq!( + ws.pane_active_session_id(PaneId::GridCell { index: 0 }), + Some(SessionId(11)) + ); + assert_eq!( + ws.pane_stacks(), + vec![ + PaneStackState { + session_ids: vec![SessionId(12), SessionId(11)], + active_session_id: SessionId(11), + }, + PaneStackState { + session_ids: vec![SessionId(14), SessionId(13)], + active_session_id: SessionId(14), + }, + ] + ); +} + #[cfg(feature = "gpui-full")] #[test] fn test_drag_state_updates_target_after_leaving_source_header() { let cells = vec![ CellInfo { + pane_id: PaneId::GridCell { index: 0 }, session_id: SessionId(1), index: 0, bounds: Bounds::new(0.0, 0.0, 100.0, 100.0), }, CellInfo { + pane_id: PaneId::GridCell { index: 1 }, session_id: SessionId(2), index: 1, bounds: Bounds::new(120.0, 0.0, 100.0, 100.0), @@ -951,21 +1198,28 @@ fn test_drag_state_updates_target_after_leaving_source_header() { start_position: Point::new(10.0, 10.0), current_position: Point::new(10.0, 10.0), active: false, - target_index: None, + target: None, }; drag.update_pointer(Point::new(20.0, 20.0), &cells); assert!(drag.active); - assert_eq!(drag.target_index, None); + assert_eq!(drag.target, None); drag.update_pointer(Point::new(140.0, 20.0), &cells); - assert_eq!(drag.target_index, Some(1)); + assert_eq!( + drag.target, + Some(super::types::DragTarget { + index: 1, + kind: super::types::DragTargetKind::PaneHeader, + }) + ); } #[cfg(feature = "gpui-full")] #[test] fn test_drag_state_does_not_target_source_or_activate_too_early() { let cells = vec![CellInfo { + pane_id: PaneId::GridCell { index: 0 }, session_id: SessionId(1), index: 0, bounds: Bounds::new(0.0, 0.0, 100.0, 100.0), @@ -976,14 +1230,17 @@ fn test_drag_state_does_not_target_source_or_activate_too_early() { start_position: Point::new(10.0, 10.0), current_position: Point::new(10.0, 10.0), active: false, - target_index: Some(0), + target: Some(super::types::DragTarget { + index: 0, + kind: super::types::DragTargetKind::PaneHeader, + }), }; drag.update_pointer(Point::new(12.0, 12.0), &cells); assert!(!drag.active); - assert_eq!(drag.target_index, None); + assert_eq!(drag.target, None); drag.update_pointer(Point::new(20.0, 20.0), &cells); assert!(drag.active); - assert_eq!(drag.target_index, None); + assert_eq!(drag.target, None); } diff --git a/crates/codirigent-ui/src/workspace/types.rs b/crates/codirigent-ui/src/workspace/types.rs index e1c37110..f472b138 100644 --- a/crates/codirigent-ui/src/workspace/types.rs +++ b/crates/codirigent-ui/src/workspace/types.rs @@ -287,6 +287,21 @@ pub(super) struct SelectionState { /// /// Tracks an in-progress drag operation where the user is moving a session /// from one pane to another by dragging its header bar. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum DragTargetKind { + PaneBody, + PaneHeader, +} + +/// Current drop target under the pointer. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct DragTarget { + /// Grid or split logical cell index. + pub index: usize, + /// Whether the pointer is over the header or body region. + pub kind: DragTargetKind, +} + #[derive(Debug, Clone, Copy)] pub(super) struct DragState { /// Session being dragged. @@ -299,8 +314,8 @@ pub(super) struct DragState { pub current_position: crate::layout::Point, /// Whether the drag threshold (5px) has been exceeded. pub active: bool, - /// Index of the cell currently under the cursor (drop target), if any. - pub target_index: Option, + /// Cell currently under the cursor (drop target), if any. + pub target: Option, } const DRAG_ACTIVATION_DISTANCE_SQUARED: f32 = 25.0; @@ -318,17 +333,29 @@ impl DragState { let dx = position.x - self.start_position.x; let dy = position.y - self.start_position.y; if (dx * dx + dy * dy) <= DRAG_ACTIVATION_DISTANCE_SQUARED { - self.target_index = None; + self.target = None; return; } self.active = true; } - self.target_index = cells + self.target = cells .iter() .find(|cell| cell.bounds.contains(position)) - .map(|cell| cell.index) - .filter(|&target| target != self.source_index); + .and_then(|cell| { + (cell.index != self.source_index).then(|| { + let header_bottom = cell.bounds.origin.y + HEADER_HEIGHT; + let kind = if position.y <= header_bottom { + DragTargetKind::PaneHeader + } else { + DragTargetKind::PaneBody + }; + DragTarget { + index: cell.index, + kind, + } + }) + }); } } From 6445c04ec29429c4fc59240c70977f6e88857e8f Mon Sep 17 00:00:00 2001 From: oso95 Date: Thu, 12 Mar 2026 23:57:08 -0400 Subject: [PATCH 3/9] Add per-session shell selection --- crates/codirigent-core/src/persistence.rs | 7 + crates/codirigent-core/src/types/session.rs | 4 + .../tests/persistence_tests.rs | 6 + crates/codirigent-session/src/manager.rs | 1 + crates/codirigent-ui/src/sidebar/tests.rs | 4 + crates/codirigent-ui/src/terminal_header.rs | 36 ++ crates/codirigent-ui/src/workspace/core.rs | 1 + .../src/workspace/drawer_render.rs | 25 ++ crates/codirigent-ui/src/workspace/gpui.rs | 23 ++ .../src/workspace/grid_render.rs | 38 ++ .../src/workspace/impl_modals.rs | 114 +++++- .../src/workspace/impl_session_lifecycle.rs | 384 +++++++++++++++--- .../src/workspace/modal_render.rs | 286 +++++++++++++ crates/codirigent-ui/src/workspace/render.rs | 38 +- crates/codirigent-ui/src/workspace/types.rs | 38 +- tests/integration_tests.rs | 5 + 16 files changed, 946 insertions(+), 64 deletions(-) diff --git a/crates/codirigent-core/src/persistence.rs b/crates/codirigent-core/src/persistence.rs index 34592bdd..5e7c5813 100644 --- a/crates/codirigent-core/src/persistence.rs +++ b/crates/codirigent-core/src/persistence.rs @@ -45,6 +45,9 @@ pub struct PersistentSession { pub status: SessionStatus, /// Working directory. pub working_directory: PathBuf, + /// Requested shell for this session. `None` means Auto. + #[serde(default)] + pub shell: Option, /// Current task if any. pub current_task: Option, /// Git worktree path if using worktrees. @@ -108,6 +111,7 @@ impl PersistentSession { name: session.name.clone(), status: session.status, working_directory: session.working_directory.clone(), + shell: session.shell.clone(), current_task: session.current_task.clone(), worktree_path: None, context_usage: session.context_usage, @@ -183,6 +187,7 @@ impl PersistentSession { name: self.name.clone(), status: SessionStatus::Idle, // Reset status on restore working_directory: self.working_directory.clone(), + shell: self.shell.clone(), current_task: self.current_task.clone(), context_usage: None, // Reset on restore created_at: self.started_at, @@ -569,6 +574,7 @@ mod tests { #[test] fn test_persistent_session_roundtrip() { let mut session = Session::new(SessionId(1), "Test".to_string(), PathBuf::from("/tmp")); + session.shell = Some("bash".to_string()); session.group = Some("backend".to_string()); session.color = Some("#FF0000".to_string()); @@ -578,6 +584,7 @@ mod tests { assert_eq!(restored.id, session.id); assert_eq!(restored.name, session.name); assert_eq!(restored.working_directory, session.working_directory); + assert_eq!(restored.shell, Some("bash".to_string())); assert_eq!(restored.group, session.group); assert_eq!(restored.color, session.color); // Status should be reset to Idle diff --git a/crates/codirigent-core/src/types/session.rs b/crates/codirigent-core/src/types/session.rs index e06f9e86..6b03c054 100644 --- a/crates/codirigent-core/src/types/session.rs +++ b/crates/codirigent-core/src/types/session.rs @@ -34,6 +34,9 @@ pub struct Session { pub status: SessionStatus, /// Working directory for this session. pub working_directory: PathBuf, + /// Requested shell for this session. `None` means Auto. + #[serde(default)] + pub shell: Option, /// Currently assigned task, if any. pub current_task: Option, /// Context window usage (0.0 - 1.0), if available. @@ -73,6 +76,7 @@ impl Session { name, status: SessionStatus::default(), working_directory, + shell: None, current_task: None, context_usage: None, created_at: chrono::Utc::now(), diff --git a/crates/codirigent-core/tests/persistence_tests.rs b/crates/codirigent-core/tests/persistence_tests.rs index 16ce258f..bb925ba4 100644 --- a/crates/codirigent-core/tests/persistence_tests.rs +++ b/crates/codirigent-core/tests/persistence_tests.rs @@ -20,6 +20,7 @@ fn test_save_and_load_state() { name: "Test Session".to_string(), status: SessionStatus::Idle, working_directory: temp.path().to_path_buf(), + shell: None, current_task: None, context_usage: None, created_at: chrono::Utc::now(), @@ -90,6 +91,7 @@ fn test_overwrite_state() { name: "Session 1".to_string(), status: SessionStatus::Idle, working_directory: temp.path().to_path_buf(), + shell: None, current_task: None, context_usage: None, created_at: chrono::Utc::now(), @@ -115,6 +117,7 @@ fn test_overwrite_state() { name: "Session 2".to_string(), status: SessionStatus::Idle, working_directory: temp.path().to_path_buf(), + shell: None, current_task: None, context_usage: None, created_at: chrono::Utc::now(), @@ -256,6 +259,7 @@ fn test_multiple_checkpoints_independent() { name: "State 1".to_string(), status: SessionStatus::Idle, working_directory: temp.path().to_path_buf(), + shell: None, current_task: None, context_usage: None, created_at: chrono::Utc::now(), @@ -278,6 +282,7 @@ fn test_multiple_checkpoints_independent() { name: "State 2".to_string(), status: SessionStatus::Idle, working_directory: temp.path().to_path_buf(), + shell: None, current_task: None, context_usage: None, created_at: chrono::Utc::now(), @@ -370,6 +375,7 @@ fn test_session_to_persistent_conversion() { name: "Test".to_string(), status: SessionStatus::Working, working_directory: PathBuf::from("/tmp"), + shell: None, current_task: None, context_usage: Some(0.5), created_at: chrono::Utc::now(), diff --git a/crates/codirigent-session/src/manager.rs b/crates/codirigent-session/src/manager.rs index 672b2ad6..84746da7 100644 --- a/crates/codirigent-session/src/manager.rs +++ b/crates/codirigent-session/src/manager.rs @@ -538,6 +538,7 @@ impl SessionManager for DefaultSessionManager { // Create session metadata let mut session = Session::new(id, name, working_dir.clone()); + session.shell = shell.filter(|value| !value.is_empty()); // Detect git info for the working directory session.git_info = self diff --git a/crates/codirigent-ui/src/sidebar/tests.rs b/crates/codirigent-ui/src/sidebar/tests.rs index b64813f2..a6e96049 100644 --- a/crates/codirigent-ui/src/sidebar/tests.rs +++ b/crates/codirigent-ui/src/sidebar/tests.rs @@ -9,6 +9,7 @@ fn create_test_session(id: u64, name: &str, status: SessionStatus) -> Session { name: name.to_string(), status, working_directory: PathBuf::from("/tmp"), + shell: None, current_task: None, context_usage: None, created_at: chrono::Utc::now(), @@ -35,6 +36,7 @@ fn create_grouped_session( name: name.to_string(), status, working_directory: PathBuf::from("/tmp"), + shell: None, current_task: None, context_usage: None, created_at: chrono::Utc::now(), @@ -458,6 +460,7 @@ fn create_session_with_context( name: name.to_string(), status, working_directory: PathBuf::from("/tmp"), + shell: None, current_task: None, context_usage, created_at: chrono::Utc::now(), @@ -479,6 +482,7 @@ fn create_session_with_task(id: u64, name: &str, status: SessionStatus, task: &s name: name.to_string(), status, working_directory: PathBuf::from("/tmp"), + shell: None, current_task: Some(TaskId::from(task)), context_usage: None, created_at: chrono::Utc::now(), diff --git a/crates/codirigent-ui/src/terminal_header.rs b/crates/codirigent-ui/src/terminal_header.rs index 44cee926..23f7cb9c 100644 --- a/crates/codirigent-ui/src/terminal_header.rs +++ b/crates/codirigent-ui/src/terminal_header.rs @@ -27,6 +27,10 @@ pub struct TerminalHeader { pub project_name: Option, /// CLI engine name (e.g., "Claude", "Gemini 2.0"). pub cli_name: Option, + /// Effective shell label shown in the header (e.g. "Auto", "bash"). + pub shell_label: Option, + /// Warning shown when the requested shell could not be honored on restore. + pub shell_warning: Option, /// Whether the session needs user attention. pub needs_attention: bool, /// AI-generated summary of current activity. @@ -50,6 +54,8 @@ impl Default for TerminalHeader { is_focused: false, project_name: None, cli_name: None, + shell_label: None, + shell_warning: None, needs_attention: false, ai_summary: None, git_branch: None, @@ -105,6 +111,13 @@ impl TerminalHeader { self } + /// Set the shell label and optional warning. + pub fn with_shell(mut self, label: impl Into, warning: Option) -> Self { + self.shell_label = Some(label.into()); + self.shell_warning = warning; + self + } + /// Set the AI summary. pub fn with_ai_summary(mut self, summary: impl Into) -> Self { self.ai_summary = Some(summary.into()); @@ -327,6 +340,10 @@ pub struct TerminalHeaderRenderHints { pub project_name: Option, /// CLI engine name. pub cli_name: Option, + /// Effective shell label shown in the header. + pub shell_label: Option, + /// Warning shown when the requested shell could not be honored on restore. + pub shell_warning: Option, /// Whether the session needs user attention. pub needs_attention: bool, /// AI-generated summary. @@ -355,6 +372,8 @@ impl TerminalHeader { height: Self::DEFAULT_HEIGHT, project_name: self.project_name.clone(), cli_name: self.cli_name.clone(), + shell_label: self.shell_label.clone(), + shell_warning: self.shell_warning.clone(), needs_attention: self.needs_attention, ai_summary: self.ai_summary.clone(), git_branch: self.git_branch.clone(), @@ -577,6 +596,23 @@ mod tests { assert_eq!(header.cli_name, Some("Gemini 2.0".to_string())); } + #[test] + fn test_terminal_header_shell_label() { + let header = TerminalHeader::new("S1", SessionStatus::Working) + .with_shell("bash", Some("Requested shell unavailable".to_string())); + assert_eq!(header.shell_label, Some("bash".to_string())); + assert_eq!( + header.shell_warning, + Some("Requested shell unavailable".to_string()) + ); + let hints = header.render_hints(); + assert_eq!(hints.shell_label, Some("bash".to_string())); + assert_eq!( + hints.shell_warning, + Some("Requested shell unavailable".to_string()) + ); + } + #[test] fn test_terminal_header_ai_summary() { let header = TerminalHeader::new("S1", SessionStatus::Working) diff --git a/crates/codirigent-ui/src/workspace/core.rs b/crates/codirigent-ui/src/workspace/core.rs index e54e29e5..58938e94 100644 --- a/crates/codirigent-ui/src/workspace/core.rs +++ b/crates/codirigent-ui/src/workspace/core.rs @@ -812,6 +812,7 @@ impl Workspace { if let Some(dst) = self.session_mut(src.id) { dst.name = src.name.clone(); dst.working_directory = src.working_directory.clone(); + dst.shell = src.shell.clone(); dst.current_task = src.current_task.clone(); dst.context_usage = src.context_usage; dst.group = src.group.clone(); diff --git a/crates/codirigent-ui/src/workspace/drawer_render.rs b/crates/codirigent-ui/src/workspace/drawer_render.rs index bb104269..60533969 100644 --- a/crates/codirigent-ui/src/workspace/drawer_render.rs +++ b/crates/codirigent-ui/src/workspace/drawer_render.rs @@ -1061,10 +1061,13 @@ impl WorkspaceView { gpui::Hsla::transparent_black() }; let hover_bg: gpui::Hsla = theme.active.into(); + let orange: gpui::Hsla = theme.orange.into(); let session_id = session.id; let session_name = session.name.clone(); let context_pct = session.context_usage; + let (shell_label, shell_warning) = + self.session_shell_display(session_id, session.shell.as_deref()); div() .id(SharedString::from(format!("session-row-{}", session_id.0))) @@ -1112,6 +1115,28 @@ impl WorkspaceView { .child("Hidden"), ) }) + .child( + div() + .flex_shrink_0() + .px(px(4.0)) + .py_px() + .rounded_sm() + .bg(if shell_warning.is_some() { + orange.opacity(0.12) + } else { + muted.opacity(0.12) + }) + .child( + div() + .text_xs() + .text_color(if shell_warning.is_some() { + orange + } else { + muted.opacity(0.75) + }) + .child(shell_label), + ), + ) // Git branch (compact) - between name and context% .when_some(session.git_info.as_ref(), |el, gi| { let mut branch = gi.branch.clone(); diff --git a/crates/codirigent-ui/src/workspace/gpui.rs b/crates/codirigent-ui/src/workspace/gpui.rs index 1dd96b09..857e4372 100644 --- a/crates/codirigent-ui/src/workspace/gpui.rs +++ b/crates/codirigent-ui/src/workspace/gpui.rs @@ -994,6 +994,8 @@ impl WorkspaceView { .map(crate::sidebar::Color::from_hex) .unwrap_or_else(|| crate::sidebar::Color::from_hex("#6366f1")); let task = self.task_title_for_session(session, task_titles); + let (shell_label, shell_warning) = + self.session_shell_display(session.id, session.shell.as_deref()); if let Some(header) = self.terminal_headers.get_mut(&session.id) { if header.session_name != session.name { header.session_name = session.name.clone(); @@ -1019,6 +1021,12 @@ impl WorkspaceView { if header.task != task { header.task = task; } + if header.shell_label.as_deref() != Some(shell_label.as_str()) { + header.shell_label = Some(shell_label.clone()); + } + if header.shell_warning != shell_warning { + header.shell_warning = shell_warning.clone(); + } } } } @@ -1084,6 +1092,8 @@ impl WorkspaceView { .map(crate::sidebar::Color::from_hex) .unwrap_or_else(|| crate::sidebar::Color::from_hex("#6366f1")); let task = self.task_title_for_session(session, None); + let (shell_label, shell_warning) = + self.session_shell_display(session.id, session.shell.as_deref()); if let Some(header) = self.terminal_headers.get_mut(&session_id) { header.status = session.status; @@ -1113,6 +1123,12 @@ impl WorkspaceView { if header.task != task { header.task = task; } + if header.shell_label.as_deref() != Some(shell_label.as_str()) { + header.shell_label = Some(shell_label); + } + if header.shell_warning != shell_warning { + header.shell_warning = shell_warning; + } } } @@ -1693,6 +1709,9 @@ impl WorkspaceView { if let Some(modal) = self.render_session_action_modal(cx) { container = container.child(modal); } + if let Some(modal) = self.render_session_creation_modal(cx) { + container = container.child(modal); + } if let Some(modal) = self.render_task_creation_modal(cx) { container = container.child(modal); } @@ -1861,6 +1880,9 @@ impl WorkspaceView { if self.handle_session_action_key_down(event, cx) { return true; } + if self.handle_session_creation_key_down(event, cx) { + return true; + } if self.handle_task_creation_key_down(event, cx) { return true; } @@ -1874,6 +1896,7 @@ impl WorkspaceView { pub(super) fn has_blocking_modal(&self) -> bool { self.custom_picker.is_open || self.modals.session_action.is_some() + || self.modals.session_creation.is_some() || self.modals.task_creation.is_some() || self.modals.pending_profile_deletion.is_some() } diff --git a/crates/codirigent-ui/src/workspace/grid_render.rs b/crates/codirigent-ui/src/workspace/grid_render.rs index a7b406b8..d7af54d0 100644 --- a/crates/codirigent-ui/src/workspace/grid_render.rs +++ b/crates/codirigent-ui/src/workspace/grid_render.rs @@ -592,6 +592,44 @@ impl WorkspaceView { header = header.child(git_badge); } + if let Some(shell_label) = &hints.shell_label { + let shell_warning = hints.shell_warning.is_some(); + let shell_fg = if shell_warning { + orange + } else { + muted.opacity(0.8) + }; + let shell_bg = if shell_warning { + orange.opacity(0.12) + } else { + border_color.opacity(0.25) + }; + header = header.child( + div() + .px(px(4.0)) + .py_px() + .rounded_sm() + .bg(shell_bg) + .flex() + .flex_shrink_0() + .items_center() + .gap_1() + .child( + div() + .text_xs() + .text_color(shell_fg) + .font_family(icons::LUCIDE_FONT_FAMILY) + .child(icons::terminal()), + ) + .child( + div() + .text_xs() + .text_color(shell_fg) + .child(shell_label.clone()), + ), + ); + } + header = header.child(div().flex_1()); // Task badge (if any) diff --git a/crates/codirigent-ui/src/workspace/impl_modals.rs b/crates/codirigent-ui/src/workspace/impl_modals.rs index d9e5c43b..93090fbe 100644 --- a/crates/codirigent-ui/src/workspace/impl_modals.rs +++ b/crates/codirigent-ui/src/workspace/impl_modals.rs @@ -6,8 +6,11 @@ //! - Modal keyboard input handling use super::gpui::WorkspaceView; -use super::types::{SessionActionKind, SessionActionModal, TaskCreationModal, GROUP_COLOR_PALETTE}; -use codirigent_core::{SessionId, SessionManager, Task, TaskId}; +use super::types::{ + SessionActionKind, SessionActionModal, SessionCreationModal, TaskCreationModal, + GROUP_COLOR_PALETTE, +}; +use codirigent_core::{PaneId, SessionId, SessionManager, Task, TaskId}; use gpui::{Context, KeyDownEvent}; use std::path::Path; use tracing::{info, warn}; @@ -43,6 +46,20 @@ impl WorkspaceView { self.modals.session_action = None; } + pub(super) fn open_session_creation_modal(&mut self, target_pane: Option) { + self.modals.session_creation = Some(SessionCreationModal { + target_pane, + shell_options: self.detected_shell_options(), + selected_shell_index: 0, + pending: false, + error: None, + }); + } + + pub(super) fn close_session_creation_modal(&mut self) { + self.modals.session_creation = None; + } + /// Pick the next unused group color from the palette. pub(super) fn next_group_color(&self) -> String { let used_colors: std::collections::HashSet<&str> = self @@ -278,6 +295,29 @@ impl WorkspaceView { cx.notify(); } + pub(super) fn apply_session_creation_modal(&mut self, cx: &mut Context) { + let Some(modal) = self.modals.session_creation.clone() else { + return; + }; + if modal.pending { + return; + } + + let requested_shell = modal + .shell_options + .get(modal.selected_shell_index) + .cloned() + .filter(|shell| !shell.is_empty()); + let target_pane = modal.target_pane.clone(); + + if let Some(active) = self.modals.session_creation.as_mut() { + active.pending = true; + active.error = None; + } + self.create_session_with_shell(target_pane, requested_shell, cx); + cx.notify(); + } + pub(super) fn handle_session_action_key_down( &mut self, event: &KeyDownEvent, @@ -339,6 +379,76 @@ impl WorkspaceView { true } + pub(super) fn handle_session_creation_key_down( + &mut self, + event: &KeyDownEvent, + cx: &mut Context, + ) -> bool { + let Some(modal) = self.modals.session_creation.as_mut() else { + return false; + }; + + let key = event.keystroke.key.to_lowercase(); + match key.as_str() { + "escape" => { + if modal.pending { + cx.notify(); + return true; + } + self.close_session_creation_modal(); + cx.notify(); + return true; + } + "enter" => { + self.apply_session_creation_modal(cx); + return true; + } + "up" | "left" | "k" => { + if modal.pending { + return true; + } + if !modal.shell_options.is_empty() { + modal.selected_shell_index = modal + .selected_shell_index + .checked_sub(1) + .unwrap_or(modal.shell_options.len().saturating_sub(1)); + cx.notify(); + } + return true; + } + "down" | "right" | "j" => { + if modal.pending { + return true; + } + if !modal.shell_options.is_empty() { + modal.selected_shell_index = + (modal.selected_shell_index + 1) % modal.shell_options.len(); + cx.notify(); + } + return true; + } + "tab" => { + if modal.pending { + return true; + } + if !modal.shell_options.is_empty() { + let len = modal.shell_options.len(); + let step = if event.keystroke.modifiers.shift { + len.saturating_sub(1) + } else { + 1 + }; + modal.selected_shell_index = (modal.selected_shell_index + step) % len; + cx.notify(); + } + return true; + } + _ => {} + } + + true + } + fn char_count(text: &str) -> usize { text.chars().count() } diff --git a/crates/codirigent-ui/src/workspace/impl_session_lifecycle.rs b/crates/codirigent-ui/src/workspace/impl_session_lifecycle.rs index c9738d5a..ccf5336b 100644 --- a/crates/codirigent-ui/src/workspace/impl_session_lifecycle.rs +++ b/crates/codirigent-ui/src/workspace/impl_session_lifecycle.rs @@ -8,7 +8,7 @@ use super::cli_helpers::is_safe_cli_session_id; use super::gpui::WorkspaceView; -use super::types::SESSION_NAME_PREFIX; +use super::types::{RestoreShellFallback, SESSION_NAME_PREFIX, SESSION_SHELL_AUTO_LABEL}; use crate::terminal::Terminal; use crate::terminal_header::TerminalHeader; use crate::terminal_view::TerminalView; @@ -33,6 +33,7 @@ struct RestoreSessionPlan { original_session_id: SessionId, session_name: String, working_dir: PathBuf, + shell: Option, group: Option, color: Option, claude_resume: Option, @@ -53,7 +54,9 @@ struct RestorePlan { struct SessionBootstrapRequest { session_name: String, working_dir: PathBuf, - shell: Option, + requested_shell: Option, + launch_shell: Option, + shell_warning: Option, } #[derive(Debug)] @@ -166,18 +169,19 @@ fn bootstrap_session( .create_session( request.session_name.clone(), request.working_dir.clone(), - request.shell.clone(), + request.launch_shell.clone(), ) .map_err(|error| error.to_string())?; let child_pid = manager.get_child_pid(session_id); - let session = manager.get_session(session_id).unwrap_or_else(|| { + let mut session = manager.get_session(session_id).unwrap_or_else(|| { Session::new( session_id, request.session_name.clone(), request.working_dir.clone(), ) }); + session.shell = request.requested_shell.clone(); Ok(SessionBootstrapResult { request, @@ -187,6 +191,37 @@ fn bootstrap_session( }) } +fn resolve_restore_shell_choice( + requested_shell: Option<&str>, + available_shells: &[String], + configured_shell: Option<&str>, +) -> (Option, Option) { + let requested_shell = requested_shell + .map(str::trim) + .filter(|shell| !shell.is_empty()) + .map(str::to_string); + let configured_shell = configured_shell + .map(str::trim) + .filter(|shell| !shell.is_empty()) + .map(str::to_string); + + match requested_shell { + Some(shell) if available_shells.iter().any(|candidate| candidate == &shell) => { + (Some(shell), None) + } + Some(shell) => (configured_shell, Some(shell)), + None => (configured_shell, None), + } +} + +fn restore_shell_fallback_message(fallback: &RestoreShellFallback) -> String { + format!( + "Requested shell '{}' was unavailable, so this session was opened with {}.", + fallback.requested_shell, + WorkspaceView::shell_display_label(fallback.effective_shell.as_deref()) + ) +} + fn layout_profile_for_restore(layout: &LayoutMode) -> Option { if let Some(profile) = crate::layout::LayoutProfile::from_mode(layout) { return Some(profile); @@ -605,6 +640,7 @@ mod tests { original_session_id: SessionId(1), session_name: "Session 1".to_string(), working_dir: sample_working_dir(), + shell: None, group: None, color: None, claude_resume: Some("claude --resume abc\r".to_string()), @@ -631,7 +667,9 @@ mod tests { let request = SessionBootstrapRequest { session_name: "Session 1".to_string(), working_dir: temp.path().to_path_buf(), - shell: None, + requested_shell: None, + launch_shell: None, + shell_warning: None, }; let result = bootstrap_session(session_manager.clone(), request).unwrap(); @@ -650,6 +688,34 @@ mod tests { assert!(manager.get_session(result.session_id).is_some()); } + #[test] + fn bootstrap_session_preserves_requested_shell() { + let session_manager = create_test_session_manager(); + let temp = TempDir::new().unwrap(); + let shell = codirigent_session::detect_available_shells() + .into_iter() + .find(|shell| !shell.is_empty()) + .expect("at least one shell should be detected in test environments"); + let request = SessionBootstrapRequest { + session_name: "Session 1".to_string(), + working_dir: temp.path().to_path_buf(), + requested_shell: Some(shell.clone()), + launch_shell: Some(shell.clone()), + shell_warning: None, + }; + + let result = bootstrap_session(session_manager.clone(), request).unwrap(); + + assert_eq!(result.session.shell, Some(shell.clone())); + let manager = session_manager.lock().unwrap_or_else(|p| p.into_inner()); + assert_eq!( + manager + .get_session(result.session_id) + .and_then(|session| session.shell), + Some(shell) + ); + } + #[test] fn bootstrap_session_invalid_working_directory_returns_error_without_creating_session() { let session_manager = create_test_session_manager(); @@ -657,7 +723,9 @@ mod tests { let request = SessionBootstrapRequest { session_name: "Session 1".to_string(), working_dir: temp.path().join("missing-session-bootstrap"), - shell: None, + requested_shell: None, + launch_shell: None, + shell_warning: None, }; let result = bootstrap_session(session_manager.clone(), request); @@ -725,12 +793,10 @@ mod tests { #[test] fn build_restore_plan_preserves_saved_custom_grid_layout() { let fallback_dir = sample_working_dir(); + let mut session = Session::new(SessionId(1), "Session 1".to_string(), fallback_dir.clone()); + session.shell = Some("bash".to_string()); let state = AppState { - sessions: vec![Session::new( - SessionId(1), - "Session 1".to_string(), - fallback_dir.clone(), - )], + sessions: vec![session], layout: LayoutMode::Grid { rows: 1, cols: 4 }, pane_tab_groups: Vec::new(), pane_stacks: Vec::new(), @@ -740,6 +806,7 @@ mod tests { let plan = WorkspaceView::build_restore_plan(state, fallback_dir).unwrap(); assert_eq!(plan.layout, LayoutMode::Grid { rows: 1, cols: 4 }); + assert_eq!(plan.sessions[0].shell, Some("bash".to_string())); } #[test] @@ -801,9 +868,175 @@ mod tests { Some(crate::layout::LayoutProfile::Custom { rows: 2, cols: 3 }) ); } + + #[test] + fn resolve_restore_shell_choice_uses_requested_shell_when_available() { + let available_shells = vec!["".to_string(), "bash".to_string(), "zsh".to_string()]; + assert_eq!( + resolve_restore_shell_choice(Some("zsh"), &available_shells, Some("bash")), + (Some("zsh".to_string()), None) + ); + } + + #[test] + fn resolve_restore_shell_choice_falls_back_to_auto_when_requested_shell_missing() { + let available_shells = vec!["".to_string(), "bash".to_string(), "zsh".to_string()]; + assert_eq!( + resolve_restore_shell_choice(Some("pwsh"), &available_shells, Some("bash")), + (Some("bash".to_string()), Some("pwsh".to_string())) + ); + } + + #[test] + fn resolve_restore_shell_choice_preserves_auto_behavior_for_configured_default_shell() { + let available_shells = vec!["".to_string(), "bash".to_string()]; + assert_eq!( + resolve_restore_shell_choice(Some("pwsh"), &available_shells, Some("missing")), + (Some("missing".to_string()), Some("pwsh".to_string())) + ); + assert_eq!( + resolve_restore_shell_choice(None, &available_shells, Some("missing")), + (Some("missing".to_string()), None) + ); + } + + #[test] + fn restore_shell_fallback_message_uses_effective_shell_label() { + let fallback = RestoreShellFallback { + requested_shell: "pwsh".to_string(), + effective_shell: Some("bash".to_string()), + }; + + assert_eq!( + restore_shell_fallback_message(&fallback), + "Requested shell 'pwsh' was unavailable, so this session was opened with bash." + ); + } + + #[test] + fn restore_shell_fallback_message_uses_auto_label_without_effective_shell() { + let fallback = RestoreShellFallback { + requested_shell: "pwsh".to_string(), + effective_shell: None, + }; + + assert_eq!( + restore_shell_fallback_message(&fallback), + "Requested shell 'pwsh' was unavailable, so this session was opened with Auto." + ); + } } impl WorkspaceView { + pub(super) fn detected_shell_options(&self) -> Vec { + let mut shells = self + .cache + .detected_shells + .clone() + .unwrap_or_else(codirigent_session::detect_available_shells); + shells.retain(|shell| !shell.trim().is_empty()); + shells.sort(); + shells.dedup(); + shells.insert(0, String::new()); + shells + } + + fn configured_shell(&self) -> Option { + let shell = self + .effective_user_settings() + .general + .default_shell + .trim() + .to_string(); + (!shell.is_empty()).then_some(shell) + } + + fn auto_launch_shell(&self) -> Option { + self.configured_shell() + } + + fn launch_shell_for_requested_shell(&self, requested_shell: Option<&str>) -> Option { + requested_shell + .filter(|shell| !shell.is_empty()) + .map(str::to_string) + .or_else(|| self.auto_launch_shell()) + } + + fn restore_launch_shell( + &self, + requested_shell: Option<&str>, + ) -> (Option, Option) { + resolve_restore_shell_choice( + requested_shell, + &self.detected_shell_options(), + self.configured_shell().as_deref(), + ) + } + + pub(super) fn shell_display_label(shell: Option<&str>) -> String { + shell + .filter(|value| !value.is_empty()) + .unwrap_or(SESSION_SHELL_AUTO_LABEL) + .to_string() + } + + pub(super) fn session_shell_warning_message(&self, session_id: SessionId) -> Option { + self.cache + .restore_shell_fallbacks + .get(&session_id) + .map(restore_shell_fallback_message) + } + + pub(super) fn session_shell_display( + &self, + session_id: SessionId, + requested_shell: Option<&str>, + ) -> (String, Option) { + if let Some(fallback) = self.cache.restore_shell_fallbacks.get(&session_id) { + ( + Self::shell_display_label(fallback.effective_shell.as_deref()), + Some(restore_shell_fallback_message(fallback)), + ) + } else { + ( + Self::shell_display_label(requested_shell), + self.session_shell_warning_message(session_id), + ) + } + } + + fn sync_manager_session_shell( + &mut self, + session_id: SessionId, + requested_shell: Option, + ) { + if let Ok(manager) = self.session_manager.lock() { + let requested_shell = requested_shell.clone(); + manager.with_session_state_mut(session_id, move |state| { + state.session.shell = requested_shell.clone(); + }); + } + } + + fn record_restored_shell_warning( + &mut self, + session_id: SessionId, + shell_warning: Option, + effective_shell: Option, + ) { + if let Some(requested_shell) = shell_warning { + self.cache.restore_shell_fallbacks.insert( + session_id, + RestoreShellFallback { + requested_shell, + effective_shell, + }, + ); + } else { + self.cache.restore_shell_fallbacks.remove(&session_id); + } + } + fn release_session_create_reservation( &mut self, reserved_number: u64, @@ -837,6 +1070,9 @@ impl WorkspaceView { .and_then(|name| name.to_str()) .unwrap_or("unknown"); header = header.with_project_name(dir_name); + let (shell_label, shell_warning) = + self.session_shell_display(session.id, session.shell.as_deref()); + header = header.with_shell(shell_label, shell_warning); if let Some(group) = group { header.group_name = Some(group.clone()); @@ -861,6 +1097,7 @@ impl WorkspaceView { self.terminals.remove(&session_id); self.pty_write_receivers.remove(&session_id); self.terminal_headers.remove(&session_id); + self.cache.restore_shell_fallbacks.remove(&session_id); self.output_dispatcher.remove_session(session_id); self.polling.output_prepare_in_flight.remove(&session_id); self.with_detector(|detector| detector.stop_monitoring(session_id)); @@ -947,9 +1184,21 @@ impl WorkspaceView { cx: &mut Context, ) { self.start_bootstrapped_session_monitoring(bootstrapped.session_id, bootstrapped.child_pid); + self.sync_manager_session_shell( + bootstrapped.session_id, + bootstrapped.request.requested_shell.clone(), + ); + self.record_restored_shell_warning( + bootstrapped.session_id, + bootstrapped.request.shell_warning.clone(), + bootstrapped.request.launch_shell.clone(), + ); + + let mut session = bootstrapped.session.clone(); + session.shell = bootstrapped.request.requested_shell.clone(); if !self.attach_bootstrapped_session( - bootstrapped.session.clone(), + session, &bootstrapped.request.session_name, target_pane.clone(), None, @@ -984,6 +1233,15 @@ impl WorkspaceView { plan: RestoreSessionPlan, ) { self.start_bootstrapped_session_monitoring(bootstrapped.session_id, bootstrapped.child_pid); + self.sync_manager_session_shell( + bootstrapped.session_id, + bootstrapped.request.requested_shell.clone(), + ); + self.record_restored_shell_warning( + bootstrapped.session_id, + bootstrapped.request.shell_warning.clone(), + bootstrapped.request.launch_shell.clone(), + ); if plan.codex_execution_mode.is_some() || plan.codex_started_at.is_some() { let codex_execution_mode = plan.codex_execution_mode; @@ -997,6 +1255,7 @@ impl WorkspaceView { } let mut session = bootstrapped.session; + session.shell = bootstrapped.request.requested_shell.clone(); session.group = plan.group.clone(); session.color = plan.color.clone(); session.codex_execution_mode = plan.codex_execution_mode; @@ -1056,6 +1315,7 @@ impl WorkspaceView { this.release_session_create_reservation(reserved_number, target_pane.clone()); match result { Ok(bootstrapped) => { + this.close_session_creation_modal(); this.finalize_created_session_bootstrap( bootstrapped, target_pane.clone(), @@ -1063,11 +1323,16 @@ impl WorkspaceView { ); } Err(error) => { + if let Some(modal) = this.modals.session_creation.as_mut() { + modal.pending = false; + modal.error = Some(format!("Failed to create session: {}", error)); + } warn!( name = %session_name, %error, "Failed to create session via background bootstrap" ); + cx.notify(); } } }); @@ -1173,6 +1438,7 @@ impl WorkspaceView { original_session_id: saved.id, session_name, working_dir, + shell: saved.shell, group: saved.group, color: saved.color, claude_resume, @@ -1190,12 +1456,7 @@ impl WorkspaceView { }) } - fn apply_restore_plan( - &mut self, - plan: RestorePlan, - shell: Option, - cx: &mut Context, - ) { + fn apply_restore_plan(&mut self, plan: RestorePlan, cx: &mut Context) { if plan.sessions.is_empty() { self.polling.restore_in_flight = false; return; @@ -1208,17 +1469,41 @@ impl WorkspaceView { let desired_pane_stacks = plan.pane_stacks.clone(); let staging_layout = staging_layout_for_restore(&desired_layout, session_count); let reapply_saved_layout = staging_layout != desired_layout; + let restore_batches = { + let mut remaining = plan.sessions.into_iter(); + let mut batches = Vec::new(); + loop { + let batch = remaining + .by_ref() + .take(2) + .map(|plan| { + let (launch_shell, shell_warning) = + self.restore_launch_shell(plan.shell.as_deref()); + let request = SessionBootstrapRequest { + session_name: plan.session_name.clone(), + working_dir: plan.working_dir.clone(), + requested_shell: plan.shell.clone(), + launch_shell, + shell_warning, + }; + (plan, request) + }) + .collect::>(); + if batch.is_empty() { + break; + } + batches.push(batch); + } + batches + }; self.apply_restored_layout_mode(&staging_layout); self.polling.restore_in_flight = true; - let restore_sessions = plan.sessions; let session_manager = self.session_manager.clone(); cx.spawn(async move |this: gpui::WeakEntity, cx| { let mut restored_session_ids = std::collections::HashMap::new(); - let mut remaining = restore_sessions.into_iter().peekable(); - while remaining.peek().is_some() { - let batch = remaining.by_ref().take(2).collect::>(); - let is_last_batch = remaining.peek().is_none(); - let shell = shell.clone(); + let total_batches = restore_batches.len(); + for (batch_index, batch) in restore_batches.into_iter().enumerate() { + let is_last_batch = batch_index + 1 == total_batches; let desired_layout = desired_layout.clone(); let session_manager = session_manager.clone(); @@ -1227,16 +1512,9 @@ impl WorkspaceView { .spawn(async move { batch .into_iter() - .map(|plan| { - let request = SessionBootstrapRequest { - session_name: plan.session_name.clone(), - working_dir: plan.working_dir.clone(), - shell: shell.clone(), - }; - CompletedRestoreBootstrap { - plan, - result: bootstrap_session(session_manager.clone(), request), - } + .map(|(plan, request)| CompletedRestoreBootstrap { + plan, + result: bootstrap_session(session_manager.clone(), request), }) .collect::>() }) @@ -1325,23 +1603,28 @@ impl WorkspaceView { /// Create a new terminal session in the focused pane. pub fn create_session(&mut self, cx: &mut Context) { - self.create_session_inner(None, cx); + self.open_session_creation_modal(None); + cx.notify(); } /// Create a new session in a specific visible pane. pub fn create_session_in_pane(&mut self, pane_id: PaneId, cx: &mut Context) { - self.create_session_inner(Some(pane_id), cx); + self.open_session_creation_modal(Some(pane_id)); + cx.notify(); } /// Create a new session in a specific split tree slot. pub fn create_session_in_slot(&mut self, slot: SlotId, cx: &mut Context) { - self.create_session_inner(Some(PaneId::SplitSlot { slot }), cx); + self.open_session_creation_modal(Some(PaneId::SplitSlot { slot })); + cx.notify(); } - /// Shared implementation for session creation. - /// When `target_pane` is `None`, adds to the next available pane; - /// when `Some(pane)`, adds to that specific pane. - fn create_session_inner(&mut self, target_pane: Option, cx: &mut Context) { + pub(super) fn create_session_with_shell( + &mut self, + target_pane: Option, + requested_shell: Option, + cx: &mut Context, + ) { // Find the lowest available session number (reuse gaps from closed sessions) if let Some(PaneId::SplitSlot { slot }) = target_pane { if self.polling.pending_session_bootstrap_slots.contains(&slot) { @@ -1369,11 +1652,12 @@ impl WorkspaceView { .or_else(|| std::env::current_dir().ok()) .unwrap_or_else(std::env::temp_dir); - let shell = self.configured_shell(); let request = SessionBootstrapRequest { session_name: name, working_dir, - shell, + launch_shell: self.launch_shell_for_requested_shell(requested_shell.as_deref()), + requested_shell, + shell_warning: None, }; self.spawn_create_session_bootstrap(request, num, target_pane, cx); } @@ -1386,7 +1670,6 @@ impl WorkspaceView { self.polling.restore_in_flight = true; let storage = self.persistence.storage.clone(); - let shell = self.configured_shell(); let fallback_dir = self .project .project_root @@ -1411,7 +1694,7 @@ impl WorkspaceView { let _ = this.update(cx, |this, cx| { if let Some(plan) = restore_plan { - this.apply_restore_plan(plan, shell.clone(), cx); + this.apply_restore_plan(plan, cx); } else { this.polling.restore_in_flight = false; } @@ -1420,16 +1703,6 @@ impl WorkspaceView { .detach(); } - /// Return the configured shell, or `None` to use the system default. - fn configured_shell(&self) -> Option { - let shell = self.effective_user_settings().general.default_shell.clone(); - if shell.is_empty() { - None - } else { - Some(shell) - } - } - /// Close the focused session. pub fn close_focused_session(&mut self, cx: &mut Context) { if let Some(id) = self.workspace.focused_session_id() { @@ -1464,6 +1737,7 @@ impl WorkspaceView { readers.cached_status.remove(&id); } self.polling.shell_input_buffers.remove(&id); + self.cache.restore_shell_fallbacks.remove(&id); // Remove from output dispatcher tracking (ready/in-flight sets) self.output_dispatcher.remove_session(id); diff --git a/crates/codirigent-ui/src/workspace/modal_render.rs b/crates/codirigent-ui/src/workspace/modal_render.rs index 1d1665ec..cd65a79f 100644 --- a/crates/codirigent-ui/src/workspace/modal_render.rs +++ b/crates/codirigent-ui/src/workspace/modal_render.rs @@ -919,4 +919,290 @@ impl WorkspaceView { ), ) } + + /// Render the session creation modal with per-session shell selection. + pub(super) fn render_session_creation_modal( + &mut self, + cx: &mut Context, + ) -> Option { + let modal = self.modals.session_creation.clone()?; + + let theme = self.workspace().theme(); + let panel_bg: gpui::Hsla = theme.panel_background.into(); + let border_color: gpui::Hsla = theme.border.into(); + let fg: gpui::Hsla = theme.foreground.into(); + let muted: gpui::Hsla = theme.muted.into(); + let primary: gpui::Hsla = theme.primary.into(); + let warning: gpui::Hsla = theme.orange.into(); + let row_hover: gpui::Hsla = theme.hover.into(); + let error_color: gpui::Hsla = gpui::Hsla::red(); + let modal_pending = modal.pending; + + Some( + div() + .id("session-create-overlay") + .absolute() + .inset_0() + .flex() + .items_center() + .justify_center() + .bg(gpui::Hsla::black().opacity(0.5)) + .on_click(cx.listener(move |this, _: &ClickEvent, _window, cx| { + if !modal_pending { + this.close_session_creation_modal(); + cx.notify(); + } + })) + .child( + div() + .id("session-create-modal") + .w(px(460.0)) + .bg(panel_bg) + .border_1() + .border_color(border_color) + .rounded_lg() + .flex() + .flex_col() + .on_click(cx.listener(|_this, _: &ClickEvent, _window, cx| { + cx.stop_propagation(); + })) + .child( + div() + .h(px(48.0)) + .px_4() + .border_b_1() + .border_color(border_color) + .flex() + .items_center() + .child(self.aligned_icon_label_row_with_offset( + icons::terminal(), + fg, + 16.0, + "Create Session", + fg, + 16.0, + FontWeight::SEMIBOLD, + 20.0, + 8.0, + 3.0, + )), + ) + .child( + div() + .p_4() + .flex() + .flex_col() + .gap_3() + .child( + div() + .text_sm() + .text_color(muted) + .child("Choose which shell to use for this session."), + ) + .child( + div() + .text_sm() + .font_weight(FontWeight::MEDIUM) + .text_color(fg) + .child("Shell"), + ) + .child({ + let mut list = div().flex().flex_col().gap_2().max_h(px(220.0)); + + for (index, option) in modal.shell_options.iter().enumerate() { + let is_selected = index == modal.selected_shell_index; + let option_label = + WorkspaceView::shell_display_label(Some(option)); + let option_hint = if option.is_empty() { + "Use the default shell setting or the platform default." + } else { + "Open the session in this shell." + }; + let option_border = + if is_selected { primary } else { border_color }; + let option_bg = if is_selected { + primary.opacity(0.12) + } else { + gpui::Hsla::transparent_black() + }; + + list = list.child( + div() + .id(SharedString::from(format!( + "session-shell-option-{}", + index + ))) + .w_full() + .p_3() + .border_1() + .border_color(option_border) + .rounded_md() + .bg(option_bg) + .cursor_pointer() + .hover(|style| style.bg(row_hover)) + .on_click(cx.listener({ + move |this, _: &ClickEvent, _window, cx| { + if modal_pending { + return; + } + if let Some(active) = + this.modals.session_creation.as_mut() + { + active.selected_shell_index = index; + active.error = None; + } + cx.notify(); + } + })) + .child( + div() + .flex() + .items_start() + .gap_3() + .child( + div() + .mt_px() + .w(px(14.0)) + .h(px(14.0)) + .rounded_full() + .border_1() + .border_color(if is_selected { + primary + } else { + muted + }) + .bg(if is_selected { + primary + } else { + gpui::Hsla::transparent_black() + }), + ) + .child( + div() + .flex_1() + .flex() + .flex_col() + .gap_1() + .child( + div() + .text_sm() + .font_weight( + FontWeight::MEDIUM, + ) + .text_color(fg) + .child(option_label), + ) + .child( + div() + .text_xs() + .text_color( + if option.is_empty() { + warning.opacity(0.9) + } else { + muted + }, + ) + .child(option_hint), + ), + ), + ), + ); + } + + list + }) + .when_some(modal.error.clone(), |this, error| { + this.child(div().text_sm().text_color(error_color).child(error)) + }), + ) + .child( + div() + .h(px(60.0)) + .px_4() + .border_t_1() + .border_color(border_color) + .flex() + .items_center() + .justify_end() + .gap_2() + .child( + div() + .id("session-create-cancel") + .px_4() + .py_2() + .border_1() + .border_color(border_color) + .rounded_md() + .text_sm() + .text_color(fg) + .when(!modal_pending, |this| this.cursor_pointer()) + .when(!modal_pending, |this| { + this.hover(|style| style.bg(border_color.opacity(0.1))) + }) + .on_click(cx.listener( + move |this, _: &ClickEvent, _window, cx| { + if !modal_pending { + this.close_session_creation_modal(); + cx.notify(); + } + }, + )) + .child(self.aligned_icon_label_row_with_offset( + icons::x(), + fg, + 12.0, + "Cancel", + fg, + 14.0, + FontWeight::MEDIUM, + 16.0, + 4.0, + 3.0, + )), + ) + .child( + div() + .id("session-create-apply") + .px_4() + .py_2() + .bg(if modal_pending { + primary.opacity(0.6) + } else { + primary + }) + .rounded_md() + .text_sm() + .text_color(gpui::Hsla::white()) + .when(!modal_pending, |this| this.cursor_pointer()) + .when(!modal_pending, |this| { + this.hover(|style| style.bg(primary.opacity(0.8))) + }) + .on_click(cx.listener( + move |this, _: &ClickEvent, _window, cx| { + if !modal_pending { + this.apply_session_creation_modal(cx); + } + }, + )) + .child(self.aligned_icon_label_row_with_offset( + icons::plus(), + gpui::Hsla::white(), + 12.0, + if modal_pending { + "Creating..." + } else { + "Create" + }, + gpui::Hsla::white(), + 14.0, + FontWeight::MEDIUM, + 16.0, + 4.0, + 3.0, + )), + ), + ), + ), + ) + } } diff --git a/crates/codirigent-ui/src/workspace/render.rs b/crates/codirigent-ui/src/workspace/render.rs index 01e10506..cf6499e2 100644 --- a/crates/codirigent-ui/src/workspace/render.rs +++ b/crates/codirigent-ui/src/workspace/render.rs @@ -19,8 +19,9 @@ use super::gpui::WorkspaceView; use crate::icons; use crate::title_bar::TitleBar; use gpui::{ - div, px, ClickEvent, Context, FontWeight, InteractiveElement, IntoElement, ParentElement, - SharedString, StatefulInteractiveElement, Styled, Window, WindowControlArea, + div, prelude::FluentBuilder, px, ClickEvent, Context, FontWeight, InteractiveElement, + IntoElement, ParentElement, SharedString, StatefulInteractiveElement, Styled, Window, + WindowControlArea, }; use tracing::info; @@ -260,13 +261,16 @@ impl WorkspaceView { let muted: gpui::Hsla = theme.muted.into(); let hover_bg: gpui::Hsla = theme.active.into(); let destructive = super::types::DESTRUCTIVE_ITEM_COLOR; + let orange: gpui::Hsla = theme.orange.into(); // Check if this session has a group - let session_group = self - .workspace() - .session(session_id) - .and_then(|s| s.group.clone()); + let (session_group, session_shell) = { + let session = self.workspace().session(session_id)?; + (session.group.clone(), session.shell.clone()) + }; let has_group = session_group.is_some(); + let (shell_label, shell_warning) = + self.session_shell_display(session_id, session_shell.as_deref()); // Collect existing group names (deduplicated, sorted) let existing_groups: Vec = { @@ -316,6 +320,28 @@ impl WorkspaceView { .flex_col() .py_1(); + dropdown = dropdown + .child( + div() + .px_3() + .pt_2() + .pb_1() + .flex() + .flex_col() + .gap_1() + .child( + div() + .text_xs() + .text_color(muted.opacity(0.6)) + .child("SHELL"), + ) + .child(div().text_sm().text_color(fg).child(shell_label)) + .when_some(shell_warning, |el, warning| { + el.child(div().text_xs().text_color(orange).child(warning)) + }), + ) + .child(div().h(px(1.0)).mx_2().my_1().bg(border_color)); + // Rename dropdown = dropdown.child(self.render_menu_item( "Rename", diff --git a/crates/codirigent-ui/src/workspace/types.rs b/crates/codirigent-ui/src/workspace/types.rs index f472b138..eecfe2a0 100644 --- a/crates/codirigent-ui/src/workspace/types.rs +++ b/crates/codirigent-ui/src/workspace/types.rs @@ -4,7 +4,7 @@ //! implementation, including modal states and UI component data. use super::CellInfo; -use codirigent_core::{SessionId, SessionStatus, SlotId, TaskId}; +use codirigent_core::{PaneId, SessionId, SessionStatus, SlotId, TaskId}; use codirigent_session::codex_session_reader::CodexSessionReader; use codirigent_session::gemini_session_reader::GeminiSessionReader; use gpui::Hsla; @@ -37,6 +37,9 @@ pub(super) const DRAWER_HEADER_HEIGHT: f32 = 40.0; /// and for reverse-parsing the session number (`strip_prefix(SESSION_NAME_PREFIX)`). pub(super) const SESSION_NAME_PREFIX: &str = "Session "; +/// Label shown when a session uses the default shell resolution path. +pub(super) const SESSION_SHELL_AUTO_LABEL: &str = "Auto"; + /// Height of session and group rows in the Sessions drawer panel. pub(super) const SESSION_ROW_HEIGHT: f32 = 28.0; @@ -226,6 +229,33 @@ pub(super) struct TaskCreationModal { pub(super) editing_task_id: Option, } +/// Session creation modal state. +/// +/// This modal is used for every session creation entry point so shell selection +/// stays consistent whether the user clicks an empty pane, a pane-local `+`, +/// or the generic create action. +#[derive(Debug, Clone)] +pub(super) struct SessionCreationModal { + /// Pane that should receive the created session, if any. + pub(super) target_pane: Option, + /// Stored shell values; empty string means Auto. + pub(super) shell_options: Vec, + /// Currently selected option index. + pub(super) selected_shell_index: usize, + /// Whether a background create request is currently in flight. + pub(super) pending: bool, + /// Optional validation or creation error. + pub(super) error: Option, +} + +#[derive(Debug, Clone)] +pub(super) struct RestoreShellFallback { + /// Shell originally requested by the saved session. + pub(super) requested_shell: String, + /// Shell actually launched for the restored session. `None` means Auto. + pub(super) effective_shell: Option, +} + /// Context menu state for file tree right-click. /// /// Captures the position and target of a file tree context menu invocation. @@ -244,6 +274,8 @@ pub(super) struct FileTreeContextMenu { pub(super) struct ModalState { /// Session action modal state (rename/group). pub session_action: Option, + /// Session creation modal state. + pub session_creation: Option, /// Task creation modal state. pub task_creation: Option, /// Pending layout profile deletion: (tab_index, profile_name) awaiting confirmation. @@ -256,6 +288,7 @@ impl ModalState { pub fn new() -> Self { Self { session_action: None, + session_creation: None, task_creation: None, pending_profile_deletion: None, cursor_blink_on: true, @@ -528,6 +561,8 @@ pub(super) struct CacheState { pub detected_editors: Option>, /// Cached available shells detected from the system (populated in background on init). pub detected_shells: Option>, + /// Sessions restored with a fallback shell because their requested shell was unavailable. + pub restore_shell_fallbacks: HashMap, /// Last PTY-resized dimensions per session, used to skip redundant resize calls. pub pty_sizes: HashMap, /// Sessions that have received at least one manual task assignment. @@ -561,6 +596,7 @@ impl CacheState { monospace_fonts: None, detected_editors: None, detected_shells: None, + restore_shell_fallbacks: HashMap::new(), pty_sizes: HashMap::new(), manually_assigned_sessions: HashSet::new(), compaction_start_times: HashMap::new(), diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 31536087..ea22dd9f 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -667,6 +667,7 @@ fn test_session_idle_to_working() { name: "Test Session".to_string(), status: SessionStatus::Idle, working_directory: PathBuf::from("/tmp"), + shell: None, current_task: None, context_usage: None, created_at: chrono::Utc::now(), @@ -701,6 +702,7 @@ fn test_session_working_to_needs_attention() { name: "Test Session".to_string(), status: SessionStatus::Working, working_directory: PathBuf::from("/tmp"), + shell: None, current_task: None, context_usage: None, created_at: chrono::Utc::now(), @@ -735,6 +737,7 @@ fn test_session_needs_attention_to_idle() { name: "Test Session".to_string(), status: SessionStatus::NeedsAttention, working_directory: PathBuf::from("/tmp"), + shell: None, current_task: None, context_usage: None, created_at: chrono::Utc::now(), @@ -769,6 +772,7 @@ fn test_session_to_error_state() { name: "Test Session".to_string(), status: SessionStatus::Working, working_directory: PathBuf::from("/tmp"), + shell: None, current_task: None, context_usage: None, created_at: chrono::Utc::now(), @@ -800,6 +804,7 @@ fn test_session_state_invariants() { name: "Test Session".to_string(), status: SessionStatus::Idle, working_directory: PathBuf::from("/tmp"), + shell: None, current_task: None, context_usage: None, created_at: chrono::Utc::now(), From 47d4f6f519b8e82a4421b94dc995ca740c1d92de Mon Sep 17 00:00:00 2001 From: oso95 Date: Fri, 13 Mar 2026 00:35:58 -0400 Subject: [PATCH 4/9] Add draggable split resizing --- crates/codirigent-core/src/types/layout.rs | 50 ++++++ crates/codirigent-ui/src/layout/split.rs | 23 +++ crates/codirigent-ui/src/layout/state.rs | 72 ++++++++ crates/codirigent-ui/src/workspace/core.rs | 14 ++ crates/codirigent-ui/src/workspace/gpui.rs | 60 ++++++- .../src/workspace/grid_render.rs | 165 +++++++++++++++++- crates/codirigent-ui/src/workspace/tests.rs | 33 ++++ crates/codirigent-ui/src/workspace/types.rs | 21 +++ 8 files changed, 430 insertions(+), 8 deletions(-) diff --git a/crates/codirigent-core/src/types/layout.rs b/crates/codirigent-core/src/types/layout.rs index 62435c31..94f4d61a 100644 --- a/crates/codirigent-core/src/types/layout.rs +++ b/crates/codirigent-core/src/types/layout.rs @@ -280,6 +280,56 @@ impl LayoutNode { } } + /// Adjust the split ratio for the divider between two child subtrees. + /// + /// Finds the split node whose first subtree contains `first_slot` and whose + /// second subtree contains `second_slot`, then updates that split's ratio. + /// Returns `None` if no such split exists. + pub fn set_ratio_for_divider( + &self, + first_slot: SlotId, + second_slot: SlotId, + new_ratio: f32, + ) -> Option { + let clamped = new_ratio.clamp(0.1, 0.9); + match self { + LayoutNode::Leaf { .. } => None, + LayoutNode::Split { + direction, + ratio, + first, + second, + } => { + if first.contains_slot(first_slot) && second.contains_slot(second_slot) { + Some(LayoutNode::Split { + direction: *direction, + ratio: clamped, + first: first.clone(), + second: second.clone(), + }) + } else if let Some(new_first) = + first.set_ratio_for_divider(first_slot, second_slot, new_ratio) + { + Some(LayoutNode::Split { + direction: *direction, + ratio: *ratio, + first: Box::new(new_first), + second: second.clone(), + }) + } else { + second + .set_ratio_for_divider(first_slot, second_slot, new_ratio) + .map(|new_second| LayoutNode::Split { + direction: *direction, + ratio: *ratio, + first: first.clone(), + second: Box::new(new_second), + }) + } + } + } + } + fn direct_child_has_slot(&self, child: &LayoutNode, target: SlotId) -> bool { matches!(child, LayoutNode::Leaf { slot } if *slot == target) } diff --git a/crates/codirigent-ui/src/layout/split.rs b/crates/codirigent-ui/src/layout/split.rs index 67c7be59..b45c97fe 100644 --- a/crates/codirigent-ui/src/layout/split.rs +++ b/crates/codirigent-ui/src/layout/split.rs @@ -435,6 +435,29 @@ mod tests { assert!(layout.divider_at_point(Point::new(100.0, 400.0)).is_none()); } + #[test] + fn test_split_layout_divider_at_point_finds_nested_parent_divider() { + let root = LayoutNode::Split { + direction: SplitDirection::Horizontal, + ratio: 0.5, + first: Box::new(LayoutNode::Split { + direction: SplitDirection::Vertical, + ratio: 0.5, + first: Box::new(LayoutNode::Leaf { slot: SlotId(0) }), + second: Box::new(LayoutNode::Leaf { slot: SlotId(1) }), + }), + second: Box::new(LayoutNode::Leaf { slot: SlotId(2) }), + }; + let layout = SplitLayout::new(root, Bounds::from_size(1000.0, 800.0), 4.0); + + let divider = layout + .divider_at_point(Point::new(499.0, 400.0)) + .expect("expected nested parent divider"); + assert_eq!(divider.first_slot, SlotId(0)); + assert_eq!(divider.second_slot, SlotId(2)); + assert_eq!(divider.direction, SplitDirection::Horizontal); + } + #[test] fn test_split_layout_asymmetric() { // 2 stacked on left + 1 full-height on right diff --git a/crates/codirigent-ui/src/layout/state.rs b/crates/codirigent-ui/src/layout/state.rs index 170f3e81..07fd9355 100644 --- a/crates/codirigent-ui/src/layout/state.rs +++ b/crates/codirigent-ui/src/layout/state.rs @@ -815,6 +815,24 @@ impl SplitLayoutState { } } + /// Resize the split identified by a visible divider between two subtrees. + pub fn resize_divider( + &mut self, + first_slot: SlotId, + second_slot: SlotId, + new_ratio: f32, + ) -> bool { + if let Some(new_tree) = self + .tree + .set_ratio_for_divider(first_slot, second_slot, new_ratio) + { + self.tree = new_tree; + true + } else { + false + } + } + /// Get the number of leaf slots. pub fn slot_count(&self) -> usize { self.tree.leaf_count() @@ -1361,6 +1379,60 @@ mod tests { assert!(state.resize_split(SlotId(0), 0.3)); } + #[test] + fn test_split_layout_state_resize_divider_updates_nested_parent_split() { + let tree = LayoutNode::Split { + direction: SplitDirection::Horizontal, + ratio: 0.5, + first: Box::new(LayoutNode::Split { + direction: SplitDirection::Vertical, + ratio: 0.5, + first: Box::new(LayoutNode::Leaf { slot: SlotId(0) }), + second: Box::new(LayoutNode::Leaf { slot: SlotId(1) }), + }), + second: Box::new(LayoutNode::Leaf { slot: SlotId(2) }), + }; + let mut state = SplitLayoutState::new(tree); + + assert!(state.resize_divider(SlotId(0), SlotId(2), 0.75)); + assert_eq!( + state.tree(), + &LayoutNode::Split { + direction: SplitDirection::Horizontal, + ratio: 0.75, + first: Box::new(LayoutNode::Split { + direction: SplitDirection::Vertical, + ratio: 0.5, + first: Box::new(LayoutNode::Leaf { slot: SlotId(0) }), + second: Box::new(LayoutNode::Leaf { slot: SlotId(1) }), + }), + second: Box::new(LayoutNode::Leaf { slot: SlotId(2) }), + } + ); + } + + #[test] + fn test_split_layout_state_resize_divider_clamps_ratio() { + let tree = LayoutNode::Split { + direction: SplitDirection::Horizontal, + ratio: 0.5, + first: Box::new(LayoutNode::Leaf { slot: SlotId(0) }), + second: Box::new(LayoutNode::Leaf { slot: SlotId(1) }), + }; + let mut state = SplitLayoutState::new(tree); + + assert!(state.resize_divider(SlotId(0), SlotId(1), 0.01)); + assert_eq!( + state.tree(), + &LayoutNode::Split { + direction: SplitDirection::Horizontal, + ratio: 0.1, + first: Box::new(LayoutNode::Leaf { slot: SlotId(0) }), + second: Box::new(LayoutNode::Leaf { slot: SlotId(1) }), + } + ); + } + #[test] fn test_split_layout_state_assigned_sessions() { let mut state = SplitLayoutState::from_grid(2, 2); diff --git a/crates/codirigent-ui/src/workspace/core.rs b/crates/codirigent-ui/src/workspace/core.rs index 58938e94..e00c6ba5 100644 --- a/crates/codirigent-ui/src/workspace/core.rs +++ b/crates/codirigent-ui/src/workspace/core.rs @@ -1148,6 +1148,20 @@ impl Workspace { false } + /// Resize a specific split divider identified by representative slots on + /// either side of the split. + pub fn resize_split_divider( + &mut self, + first_slot: SlotId, + second_slot: SlotId, + new_ratio: f32, + ) -> bool { + if let WorkspaceLayoutState::SplitTree(s) = &mut self.layout_state { + return s.resize_divider(first_slot, second_slot, new_ratio); + } + false + } + /// Convert the current grid layout to an equivalent split tree. fn convert_to_split_tree(&mut self) { let stacks = self.current_pane_stacks_in_order(); diff --git a/crates/codirigent-ui/src/workspace/gpui.rs b/crates/codirigent-ui/src/workspace/gpui.rs index 857e4372..36d3cac8 100644 --- a/crates/codirigent-ui/src/workspace/gpui.rs +++ b/crates/codirigent-ui/src/workspace/gpui.rs @@ -737,6 +737,40 @@ impl WorkspaceView { self.cache.pending_resize_signature = None; } + pub(super) fn update_split_resize(&mut self, position: crate::layout::Point) -> bool { + let Some(resize) = self.selection.split_resize.as_ref().copied() else { + return false; + }; + + let total = match resize.direction { + codirigent_core::SplitDirection::Horizontal => resize.bounds.size.width - resize.gap, + codirigent_core::SplitDirection::Vertical => resize.bounds.size.height - resize.gap, + }; + if total <= 0.0 { + return false; + } + + let offset = match resize.direction { + codirigent_core::SplitDirection::Horizontal => { + position.x - resize.bounds.origin.x - resize.grab_offset + } + codirigent_core::SplitDirection::Vertical => { + position.y - resize.bounds.origin.y - resize.grab_offset + } + }; + let ratio = offset / total; + let changed = + self.workspace + .resize_split_divider(resize.first_slot, resize.second_slot, ratio); + if changed { + if let Some(active_resize) = self.selection.split_resize.as_mut() { + active_resize.changed = true; + } + self.mark_layout_cache_dirty(); + } + changed + } + fn current_resize_signature( &self, cell_width: f32, @@ -2181,12 +2215,27 @@ impl Render for WorkspaceView { this.handle_key_down(event, window, cx); })) .on_mouse_move(cx.listener(|this, event: &MouseMoveEvent, _window, cx| { + let pos = + crate::layout::Point::new(event.position.x.into(), event.position.y.into()); + if this.selection.split_resize.is_some() { + if !event.dragging() { + if let Some(resize) = this.selection.split_resize.take() { + if resize.changed { + this.save_state_to_disk(cx); + } + } + cx.notify(); + return; + } + if this.update_split_resize(pos) { + cx.notify(); + } + return; + } let Some(drag) = &mut this.selection.drag else { return; }; - let pos = - crate::layout::Point::new(event.position.x.into(), event.position.y.into()); drag.update_pointer(pos, &this.cache.render_cell_info); cx.notify(); })) @@ -2194,6 +2243,13 @@ impl Render for WorkspaceView { .on_mouse_up( MouseButton::Left, cx.listener(|this, _event: &MouseUpEvent, _window, cx| { + if let Some(resize) = this.selection.split_resize.take() { + if resize.changed { + this.save_state_to_disk(cx); + } + cx.notify(); + return; + } if let Some(drag) = this.selection.drag.take() { if drag.active { if let Some(target) = drag.target { diff --git a/crates/codirigent-ui/src/workspace/grid_render.rs b/crates/codirigent-ui/src/workspace/grid_render.rs index d7af54d0..be5a5044 100644 --- a/crates/codirigent-ui/src/workspace/grid_render.rs +++ b/crates/codirigent-ui/src/workspace/grid_render.rs @@ -13,9 +13,9 @@ use crate::workspace::gpui::WorkspaceView; use crate::workspace::types::HEADER_HEIGHT; use codirigent_core::{LayoutNode, SessionId, SlotId, SplitDirection}; use gpui::{ - div, px, relative, ClickEvent, Context, Focusable, FontWeight, InteractiveElement, IntoElement, - MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement, ScrollWheelEvent, - SharedString, StatefulInteractiveElement, Styled, Window, + div, prelude::FluentBuilder, px, relative, ClickEvent, Context, Focusable, FontWeight, + InteractiveElement, IntoElement, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, + ParentElement, ScrollWheelEvent, SharedString, StatefulInteractiveElement, Styled, Window, }; use std::rc::Rc; use tracing::info; @@ -151,6 +151,7 @@ impl WorkspaceView { ) -> impl IntoElement { let theme = self.workspace().theme().clone(); let grid_gap = theme.grid_gap; + let grid_bounds = self.workspace().grid_bounds(); // Collect split tree state needed for rendering let tree = match self.workspace().layout_state() { @@ -165,6 +166,7 @@ impl WorkspaceView { self.render_split_node( &tree, + grid_bounds, &theme, grid_gap, panel_bg, @@ -180,6 +182,7 @@ impl WorkspaceView { fn render_split_node( &mut self, node: &LayoutNode, + available: crate::layout::Bounds, theme: &CodirigentTheme, gap: f32, panel_bg: gpui::Hsla, @@ -238,9 +241,21 @@ impl WorkspaceView { first, second, } => { - // Render children recursively (pass pre-computed colors to avoid per-call conversion) + let (first_bounds, second_bounds, divider_bounds) = + split_child_bounds(*direction, *ratio, available, gap); + let first_slot = first.slots_in_order().first().copied().unwrap_or(SlotId(0)); + let second_slot = second + .slots_in_order() + .first() + .copied() + .unwrap_or(SlotId(0)); + let is_resizing = self.selection.split_resize.as_ref().is_some_and(|resize| { + resize.first_slot == first_slot && resize.second_slot == second_slot + }); + let first_elem = self.render_split_node( first, + first_bounds, theme, gap, panel_bg, @@ -251,6 +266,7 @@ impl WorkspaceView { ); let second_elem = self.render_split_node( second, + second_bounds, theme, gap, panel_bg, @@ -261,7 +277,7 @@ impl WorkspaceView { ); // Use flex ratio to distribute space: first gets `ratio`, second gets `1 - ratio` - // Multiply by 1000 for precision in flex-grow values + // Multiply by 1000 for precision in flex-grow values. let first_flex = *ratio * 1000.0; let second_flex = (1.0 - *ratio) * 1000.0; @@ -282,7 +298,77 @@ impl WorkspaceView { d.child(elem) }; - let mut container = div().flex_1().flex().gap(px(gap)); + let primary: gpui::Hsla = theme.primary.into(); + let divider_bg = if is_resizing { + primary.opacity(0.35) + } else { + border_color.opacity(0.45) + }; + let divider_hover = if is_resizing { + primary.opacity(0.45) + } else { + primary.opacity(0.22) + }; + + let divider = { + let direction = *direction; + let resize_bounds = available; + let divider_origin = match direction { + SplitDirection::Horizontal => divider_bounds.origin.x, + SplitDirection::Vertical => divider_bounds.origin.y, + }; + div() + .id(SharedString::from(format!( + "split-divider-{}-{}", + first_slot.0, second_slot.0 + ))) + .flex_shrink_0() + .bg(divider_bg) + .when(direction == SplitDirection::Horizontal, |this| { + this.w(px(divider_bounds.size.width)) + .h_full() + .cursor_col_resize() + }) + .when(direction == SplitDirection::Vertical, |this| { + this.h(px(divider_bounds.size.height)) + .w_full() + .cursor_row_resize() + }) + .hover(|style| style.bg(divider_hover)) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, event: &MouseDownEvent, _window, cx| { + cx.stop_propagation(); + let pos = crate::layout::Point::new( + event.position.x.into(), + event.position.y.into(), + ); + this.selection.drag = None; + this.selection.split_resize = + Some(super::types::SplitResizeState { + first_slot, + second_slot, + direction, + bounds: resize_bounds, + gap, + grab_offset: match direction { + SplitDirection::Horizontal => { + (pos.x - divider_origin).max(0.0) + } + SplitDirection::Vertical => { + (pos.y - divider_origin).max(0.0) + } + }, + changed: false, + }); + this.selection.is_selecting = false; + this.selection.selecting_session_id = None; + cx.notify(); + }), + ) + }; + + let mut container = div().flex_1().flex(); container = if is_horizontal { container.flex_row() } else { @@ -290,6 +376,7 @@ impl WorkspaceView { }; let container = container .child(make_child_div(first_elem, first_flex)) + .child(divider) .child(make_child_div(second_elem, second_flex)); container.into_any_element() @@ -873,3 +960,69 @@ impl WorkspaceView { ) } } + +fn split_child_bounds( + direction: SplitDirection, + ratio: f32, + available: crate::layout::Bounds, + gap: f32, +) -> ( + crate::layout::Bounds, + crate::layout::Bounds, + crate::layout::Bounds, +) { + match direction { + SplitDirection::Horizontal => { + let total_w = (available.size.width - gap).max(0.0); + let first_w = (total_w * ratio).max(0.0); + let second_w = (total_w - first_w).max(0.0); + let divider_x = available.origin.x + first_w; + ( + crate::layout::Bounds::new( + available.origin.x, + available.origin.y, + first_w, + available.size.height, + ), + crate::layout::Bounds::new( + divider_x + gap, + available.origin.y, + second_w, + available.size.height, + ), + crate::layout::Bounds::new( + divider_x, + available.origin.y, + gap, + available.size.height, + ), + ) + } + SplitDirection::Vertical => { + let total_h = (available.size.height - gap).max(0.0); + let first_h = (total_h * ratio).max(0.0); + let second_h = (total_h - first_h).max(0.0); + let divider_y = available.origin.y + first_h; + ( + crate::layout::Bounds::new( + available.origin.x, + available.origin.y, + available.size.width, + first_h, + ), + crate::layout::Bounds::new( + available.origin.x, + divider_y + gap, + available.size.width, + second_h, + ), + crate::layout::Bounds::new( + available.origin.x, + divider_y, + available.size.width, + gap, + ), + ) + } + } +} diff --git a/crates/codirigent-ui/src/workspace/tests.rs b/crates/codirigent-ui/src/workspace/tests.rs index 12dc7f16..68ec9dde 100644 --- a/crates/codirigent-ui/src/workspace/tests.rs +++ b/crates/codirigent-ui/src/workspace/tests.rs @@ -694,6 +694,39 @@ fn test_workspace_remove_session_promotes_hidden_split_session() { ); } +#[test] +fn test_workspace_resize_split_divider_updates_nested_layout_ratio() { + let mut ws = Workspace::new(); + let tree = LayoutNode::Split { + direction: SplitDirection::Horizontal, + ratio: 0.5, + first: Box::new(LayoutNode::Split { + direction: SplitDirection::Vertical, + ratio: 0.5, + first: Box::new(LayoutNode::Leaf { slot: SlotId(0) }), + second: Box::new(LayoutNode::Leaf { slot: SlotId(1) }), + }), + second: Box::new(LayoutNode::Leaf { slot: SlotId(2) }), + }; + ws.set_split_tree(tree); + + assert!(ws.resize_split_divider(SlotId(0), SlotId(2), 0.75)); + assert_eq!( + ws.layout_state().as_split_tree().unwrap().tree(), + &LayoutNode::Split { + direction: SplitDirection::Horizontal, + ratio: 0.75, + first: Box::new(LayoutNode::Split { + direction: SplitDirection::Vertical, + ratio: 0.5, + first: Box::new(LayoutNode::Leaf { slot: SlotId(0) }), + second: Box::new(LayoutNode::Leaf { slot: SlotId(1) }), + }), + second: Box::new(LayoutNode::Leaf { slot: SlotId(2) }), + } + ); +} + #[test] fn test_set_split_tree_from_existing_split() { let mut ws = Workspace::new(); diff --git a/crates/codirigent-ui/src/workspace/types.rs b/crates/codirigent-ui/src/workspace/types.rs index eecfe2a0..7711b177 100644 --- a/crates/codirigent-ui/src/workspace/types.rs +++ b/crates/codirigent-ui/src/workspace/types.rs @@ -314,6 +314,8 @@ pub(super) struct SelectionState { pub last_click_position: Option<(codirigent_core::GridPosition, Instant)>, /// Active drag-and-drop state for session reordering (None when not dragging). pub drag: Option, + /// Active split-divider resize gesture (None when not resizing). + pub split_resize: Option, } /// State for drag-and-drop session reordering. @@ -351,6 +353,24 @@ pub(super) struct DragState { pub target: Option, } +#[derive(Debug, Clone, Copy)] +pub(super) struct SplitResizeState { + /// Representative slot from the first subtree of the resized split. + pub first_slot: SlotId, + /// Representative slot from the second subtree of the resized split. + pub second_slot: SlotId, + /// Direction of the split being resized. + pub direction: codirigent_core::SplitDirection, + /// Global bounds of the split container. + pub bounds: crate::layout::Bounds, + /// Gap thickness used for the divider. + pub gap: f32, + /// Pointer offset within the divider handle at drag start. + pub grab_offset: f32, + /// Whether the drag produced at least one ratio change. + pub changed: bool, +} + const DRAG_ACTIVATION_DISTANCE_SQUARED: f32 = 25.0; impl DragState { @@ -402,6 +422,7 @@ impl SelectionState { file_tree_context_menu: None, last_click_position: None, drag: None, + split_resize: None, } } } From d7fa94192d6803af9a8c8910467a8e7c3345bd6b Mon Sep 17 00:00:00 2001 From: oso95 Date: Fri, 13 Mar 2026 01:34:03 -0400 Subject: [PATCH 5/9] Extract split workspace rendering --- crates/codirigent-ui/src/workspace/README.md | 17 +- .../src/workspace/grid_render.rs | 366 +---------------- crates/codirigent-ui/src/workspace/mod.rs | 3 + .../src/workspace/split_render.rs | 374 ++++++++++++++++++ 4 files changed, 397 insertions(+), 363 deletions(-) create mode 100644 crates/codirigent-ui/src/workspace/split_render.rs diff --git a/crates/codirigent-ui/src/workspace/README.md b/crates/codirigent-ui/src/workspace/README.md index 6fb78285..0a222c15 100644 --- a/crates/codirigent-ui/src/workspace/README.md +++ b/crates/codirigent-ui/src/workspace/README.md @@ -23,11 +23,17 @@ The workspace rendering is organized into specialized modules: - Module coordination ### Component Renderers -- **`grid_render.rs`** (729 lines) - Grid and split layouts +- **`grid_render.rs`** - Grid layout dispatcher and shared session cells - Traditional NxM grid layout - - Split tree (binary tree) layout + - Dispatch to split-tree rendering - Session cells with terminals - - Empty cell placeholders + - Empty grid cell placeholders + +- **`split_render.rs`** - Split-tree layout rendering + - Split tree (binary tree) layout + - Recursive split-node rendering + - Divider rendering and drag setup + - Empty split-slot placeholders - **`task_board_render.rs`** (1,334 lines) - Task management UI - Right sidebar task board @@ -89,7 +95,7 @@ The workspace supports three layout modes: 2. **Split Tree Layout** - Binary tree of horizontal/vertical splits - Recursive pane subdivision - - Rendered by `grid_render.rs` + - Rendered by `split_render.rs` 3. **Single Layout** - Focused single session view @@ -178,7 +184,8 @@ workspace/ │ ├── Uses: grid_render, icon_rail_render, task_board_render │ ├── Uses: top_bar_render, modal_render │ └── Uses: icon_utils -├── grid_render.rs # Grid/split layouts +├── grid_render.rs # Grid layout + shared session cells +├── split_render.rs # Split-tree layout rendering │ └── Uses: icon_utils ├── task_board_render.rs # Task board UI │ └── Uses: icon_utils diff --git a/crates/codirigent-ui/src/workspace/grid_render.rs b/crates/codirigent-ui/src/workspace/grid_render.rs index be5a5044..effedc1e 100644 --- a/crates/codirigent-ui/src/workspace/grid_render.rs +++ b/crates/codirigent-ui/src/workspace/grid_render.rs @@ -2,23 +2,22 @@ //! //! This module handles rendering of the workspace grid layout, including: //! - Traditional NxM grid layout -//! - Split tree (binary tree) layout +//! - Dispatch to split-tree rendering //! - Session cells with terminals -//! - Empty cells and placeholders +//! - Empty grid cells and placeholders use crate::icons; use crate::terminal_header::TerminalHeaderRenderHints; use crate::theme::CodirigentTheme; use crate::workspace::gpui::WorkspaceView; use crate::workspace::types::HEADER_HEIGHT; -use codirigent_core::{LayoutNode, SessionId, SlotId, SplitDirection}; +use codirigent_core::SessionId; use gpui::{ - div, prelude::FluentBuilder, px, relative, ClickEvent, Context, Focusable, FontWeight, - InteractiveElement, IntoElement, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, - ParentElement, ScrollWheelEvent, SharedString, StatefulInteractiveElement, Styled, Window, + div, px, ClickEvent, Context, Focusable, FontWeight, InteractiveElement, IntoElement, + MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement, ScrollWheelEvent, + SharedString, StatefulInteractiveElement, Styled, Window, }; use std::rc::Rc; -use tracing::info; /// Visual state of a cell during drag-and-drop. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -37,7 +36,7 @@ impl WorkspaceView { cx: &mut Context, ) -> gpui::AnyElement { if self.workspace().is_split_tree_mode() { - self.render_split_tree_layout(window, cx).into_any_element() + self.render_split_tree_layout(window, cx) } else { self.render_grid_layout(window, cx).into_any_element() } @@ -143,292 +142,9 @@ impl WorkspaceView { grid } - /// Render the split tree layout using recursive binary tree traversal. - fn render_split_tree_layout( - &mut self, - window: &mut Window, - cx: &mut Context, - ) -> impl IntoElement { - let theme = self.workspace().theme().clone(); - let grid_gap = theme.grid_gap; - let grid_bounds = self.workspace().grid_bounds(); - - // Collect split tree state needed for rendering - let tree = match self.workspace().layout_state() { - crate::layout::WorkspaceLayoutState::SplitTree(s) => s.tree().clone(), - _ => return div().flex_1().into_any_element(), - }; - - // Pre-compute colors once to avoid redundant conversions on every recursive call - let panel_bg: gpui::Hsla = theme.panel_background.into(); - let border_color: gpui::Hsla = theme.border.into(); - let muted: gpui::Hsla = theme.muted.into(); - - self.render_split_node( - &tree, - grid_bounds, - &theme, - grid_gap, - panel_bg, - border_color, - muted, - window, - cx, - ) - } - - /// Recursively render a layout node in the split tree. - #[allow(clippy::too_many_arguments)] - fn render_split_node( - &mut self, - node: &LayoutNode, - available: crate::layout::Bounds, - theme: &CodirigentTheme, - gap: f32, - panel_bg: gpui::Hsla, - border_color: gpui::Hsla, - muted: gpui::Hsla, - window: &mut Window, - cx: &mut Context, - ) -> gpui::AnyElement { - match node { - LayoutNode::Leaf { slot } => { - let focused_session = self.workspace().focused_session_id(); - let session_data = self - .workspace() - .layout_state() - .as_split_tree() - .and_then(|state| state.session_at_slot(*slot)) - .and_then(|session_id| { - self.workspace().session(session_id).map(|session| { - ( - session_id, - focused_session == Some(session_id), - session.name.clone(), - session.status, - ) - }) - }); - - if let Some((session_id, is_focused, name, status)) = session_data { - let header_hints = if let Some(header) = self.get_terminal_header(session_id) { - header.render_hints() - } else { - crate::terminal_header::TerminalHeader::new(name, status) - .with_focused(is_focused) - .render_hints() - }; - - self.render_session_cell_with_terminal( - codirigent_core::PaneId::SplitSlot { slot: *slot }, - session_id, - &header_hints, - theme, - None, - window, - cx, - ) - .into_any_element() - } else { - // Empty slot - self.render_split_empty_slot(*slot, panel_bg, border_color, muted, cx) - .into_any_element() - } - } - LayoutNode::Split { - direction, - ratio, - first, - second, - } => { - let (first_bounds, second_bounds, divider_bounds) = - split_child_bounds(*direction, *ratio, available, gap); - let first_slot = first.slots_in_order().first().copied().unwrap_or(SlotId(0)); - let second_slot = second - .slots_in_order() - .first() - .copied() - .unwrap_or(SlotId(0)); - let is_resizing = self.selection.split_resize.as_ref().is_some_and(|resize| { - resize.first_slot == first_slot && resize.second_slot == second_slot - }); - - let first_elem = self.render_split_node( - first, - first_bounds, - theme, - gap, - panel_bg, - border_color, - muted, - window, - cx, - ); - let second_elem = self.render_split_node( - second, - second_bounds, - theme, - gap, - panel_bg, - border_color, - muted, - window, - cx, - ); - - // Use flex ratio to distribute space: first gets `ratio`, second gets `1 - ratio` - // Multiply by 1000 for precision in flex-grow values. - let first_flex = *ratio * 1000.0; - let second_flex = (1.0 - *ratio) * 1000.0; - - // Horizontal: children are flex-col, container is flex-row - // Vertical: children are flex-row, container is flex-col - let is_horizontal = *direction == SplitDirection::Horizontal; - - let make_child_div = |elem: gpui::AnyElement, flex: f32| -> gpui::Div { - let mut d = div().flex().size_full(); - d = if is_horizontal { - d.flex_col() - } else { - d.flex_row() - }; - d.style().flex_grow = Some(flex); - d.style().flex_shrink = Some(1.0); - d.style().flex_basis = Some(relative(0.).into()); - d.child(elem) - }; - - let primary: gpui::Hsla = theme.primary.into(); - let divider_bg = if is_resizing { - primary.opacity(0.35) - } else { - border_color.opacity(0.45) - }; - let divider_hover = if is_resizing { - primary.opacity(0.45) - } else { - primary.opacity(0.22) - }; - - let divider = { - let direction = *direction; - let resize_bounds = available; - let divider_origin = match direction { - SplitDirection::Horizontal => divider_bounds.origin.x, - SplitDirection::Vertical => divider_bounds.origin.y, - }; - div() - .id(SharedString::from(format!( - "split-divider-{}-{}", - first_slot.0, second_slot.0 - ))) - .flex_shrink_0() - .bg(divider_bg) - .when(direction == SplitDirection::Horizontal, |this| { - this.w(px(divider_bounds.size.width)) - .h_full() - .cursor_col_resize() - }) - .when(direction == SplitDirection::Vertical, |this| { - this.h(px(divider_bounds.size.height)) - .w_full() - .cursor_row_resize() - }) - .hover(|style| style.bg(divider_hover)) - .on_mouse_down( - MouseButton::Left, - cx.listener(move |this, event: &MouseDownEvent, _window, cx| { - cx.stop_propagation(); - let pos = crate::layout::Point::new( - event.position.x.into(), - event.position.y.into(), - ); - this.selection.drag = None; - this.selection.split_resize = - Some(super::types::SplitResizeState { - first_slot, - second_slot, - direction, - bounds: resize_bounds, - gap, - grab_offset: match direction { - SplitDirection::Horizontal => { - (pos.x - divider_origin).max(0.0) - } - SplitDirection::Vertical => { - (pos.y - divider_origin).max(0.0) - } - }, - changed: false, - }); - this.selection.is_selecting = false; - this.selection.selecting_session_id = None; - cx.notify(); - }), - ) - }; - - let mut container = div().flex_1().flex(); - container = if is_horizontal { - container.flex_row() - } else { - container.flex_col() - }; - let container = container - .child(make_child_div(first_elem, first_flex)) - .child(divider) - .child(make_child_div(second_elem, second_flex)); - - container.into_any_element() - } - } - } - - /// Render an empty slot in split tree mode. - fn render_split_empty_slot( - &mut self, - slot: SlotId, - panel_bg: gpui::Hsla, - border_color: gpui::Hsla, - muted: gpui::Hsla, - cx: &mut Context, - ) -> gpui::Stateful { - div() - .id(SharedString::from(format!("empty-slot-{}", slot.0))) - .size_full() - .bg(panel_bg) - .border_1() - .border_color(border_color) - .rounded_lg() - .border_dashed() - .flex() - .flex_col() - .items_center() - .justify_center() - .gap_2() - .cursor_pointer() - .on_click(cx.listener(move |this, _: &ClickEvent, _window, cx| { - info!(?slot, "Empty split slot clicked — creating session"); - this.create_session_in_slot(slot, cx); - })) - .child( - div() - .text_xl() - .text_color(muted) - .font_family(icons::LUCIDE_FONT_FAMILY) - .child(icons::circle_plus()), - ) - .child( - div() - .text_xs() - .text_color(muted) - .child(super::types::EMPTY_CELL_MESSAGE), - ) - } - /// Render a session cell with terminal header and actual terminal content. #[allow(clippy::too_many_arguments)] - fn render_session_cell_with_terminal( + pub(super) fn render_session_cell_with_terminal( &mut self, pane_id: codirigent_core::PaneId, session_id: SessionId, @@ -960,69 +676,3 @@ impl WorkspaceView { ) } } - -fn split_child_bounds( - direction: SplitDirection, - ratio: f32, - available: crate::layout::Bounds, - gap: f32, -) -> ( - crate::layout::Bounds, - crate::layout::Bounds, - crate::layout::Bounds, -) { - match direction { - SplitDirection::Horizontal => { - let total_w = (available.size.width - gap).max(0.0); - let first_w = (total_w * ratio).max(0.0); - let second_w = (total_w - first_w).max(0.0); - let divider_x = available.origin.x + first_w; - ( - crate::layout::Bounds::new( - available.origin.x, - available.origin.y, - first_w, - available.size.height, - ), - crate::layout::Bounds::new( - divider_x + gap, - available.origin.y, - second_w, - available.size.height, - ), - crate::layout::Bounds::new( - divider_x, - available.origin.y, - gap, - available.size.height, - ), - ) - } - SplitDirection::Vertical => { - let total_h = (available.size.height - gap).max(0.0); - let first_h = (total_h * ratio).max(0.0); - let second_h = (total_h - first_h).max(0.0); - let divider_y = available.origin.y + first_h; - ( - crate::layout::Bounds::new( - available.origin.x, - available.origin.y, - available.size.width, - first_h, - ), - crate::layout::Bounds::new( - available.origin.x, - divider_y + gap, - available.size.width, - second_h, - ), - crate::layout::Bounds::new( - available.origin.x, - divider_y, - available.size.width, - gap, - ), - ) - } - } -} diff --git a/crates/codirigent-ui/src/workspace/mod.rs b/crates/codirigent-ui/src/workspace/mod.rs index ce9f0659..72c88a80 100644 --- a/crates/codirigent-ui/src/workspace/mod.rs +++ b/crates/codirigent-ui/src/workspace/mod.rs @@ -104,6 +104,9 @@ mod modal_render; #[cfg(feature = "gpui-full")] mod grid_render; +#[cfg(feature = "gpui-full")] +mod split_render; + #[cfg(feature = "gpui-full")] mod settings_panels; diff --git a/crates/codirigent-ui/src/workspace/split_render.rs b/crates/codirigent-ui/src/workspace/split_render.rs new file mode 100644 index 00000000..5e670100 --- /dev/null +++ b/crates/codirigent-ui/src/workspace/split_render.rs @@ -0,0 +1,374 @@ +//! Split-tree workspace rendering. +//! +//! This module owns the recursive rendering path for split-tree layouts, +//! including divider hit targets and empty split slots. + +use crate::icons; +use crate::theme::CodirigentTheme; +use crate::workspace::gpui::WorkspaceView; +use codirigent_core::{LayoutNode, SlotId, SplitDirection}; +use gpui::{ + div, prelude::FluentBuilder, px, relative, ClickEvent, Context, InteractiveElement, + IntoElement, MouseButton, MouseDownEvent, ParentElement, SharedString, + StatefulInteractiveElement, Styled, Window, +}; +use tracing::info; + +impl WorkspaceView { + /// Render the split tree layout using recursive binary tree traversal. + pub(super) fn render_split_tree_layout( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> gpui::AnyElement { + let theme = self.workspace().theme().clone(); + let grid_gap = theme.grid_gap; + let grid_bounds = self.workspace().grid_bounds(); + + let tree = match self.workspace().layout_state() { + crate::layout::WorkspaceLayoutState::SplitTree(s) => s.tree().clone(), + _ => return div().flex_1().into_any_element(), + }; + + let panel_bg: gpui::Hsla = theme.panel_background.into(); + let border_color: gpui::Hsla = theme.border.into(); + let muted: gpui::Hsla = theme.muted.into(); + + self.render_split_node( + &tree, + grid_bounds, + &theme, + grid_gap, + panel_bg, + border_color, + muted, + window, + cx, + ) + } + + /// Recursively render a layout node in the split tree. + #[allow(clippy::too_many_arguments)] + fn render_split_node( + &mut self, + node: &LayoutNode, + available: crate::layout::Bounds, + theme: &CodirigentTheme, + gap: f32, + panel_bg: gpui::Hsla, + border_color: gpui::Hsla, + muted: gpui::Hsla, + window: &mut Window, + cx: &mut Context, + ) -> gpui::AnyElement { + match node { + LayoutNode::Leaf { slot } => { + let focused_session = self.workspace().focused_session_id(); + let session_data = self + .workspace() + .layout_state() + .as_split_tree() + .and_then(|state| state.session_at_slot(*slot)) + .and_then(|session_id| { + self.workspace().session(session_id).map(|session| { + ( + session_id, + focused_session == Some(session_id), + session.name.clone(), + session.status, + ) + }) + }); + + if let Some((session_id, is_focused, name, status)) = session_data { + let header_hints = if let Some(header) = self.get_terminal_header(session_id) { + header.render_hints() + } else { + crate::terminal_header::TerminalHeader::new(name, status) + .with_focused(is_focused) + .render_hints() + }; + + self.render_session_cell_with_terminal( + codirigent_core::PaneId::SplitSlot { slot: *slot }, + session_id, + &header_hints, + theme, + None, + window, + cx, + ) + .into_any_element() + } else { + self.render_split_empty_slot(*slot, panel_bg, border_color, muted, cx) + .into_any_element() + } + } + LayoutNode::Split { + direction, + ratio, + first, + second, + } => { + let (first_bounds, second_bounds, divider_bounds) = + split_child_bounds(*direction, *ratio, available, gap); + let first_slot = first.slots_in_order().first().copied().unwrap_or(SlotId(0)); + let second_slot = second + .slots_in_order() + .first() + .copied() + .unwrap_or(SlotId(0)); + let is_resizing = self.selection.split_resize.as_ref().is_some_and(|resize| { + resize.first_slot == first_slot && resize.second_slot == second_slot + }); + + let first_elem = self.render_split_node( + first, + first_bounds, + theme, + gap, + panel_bg, + border_color, + muted, + window, + cx, + ); + let second_elem = self.render_split_node( + second, + second_bounds, + theme, + gap, + panel_bg, + border_color, + muted, + window, + cx, + ); + + let first_flex = *ratio * 1000.0; + let second_flex = (1.0 - *ratio) * 1000.0; + let is_horizontal = *direction == SplitDirection::Horizontal; + + let make_child_div = |elem: gpui::AnyElement, flex: f32| -> gpui::Div { + let mut child = div().flex().size_full(); + child = if is_horizontal { + child.flex_col() + } else { + child.flex_row() + }; + child.style().flex_grow = Some(flex); + child.style().flex_shrink = Some(1.0); + child.style().flex_basis = Some(relative(0.).into()); + child.child(elem) + }; + + let divider = self.render_split_divider( + *direction, + available, + divider_bounds, + gap, + first_slot, + second_slot, + theme, + border_color, + is_resizing, + cx, + ); + + let mut container = div().flex_1().flex(); + container = if is_horizontal { + container.flex_row() + } else { + container.flex_col() + }; + container + .child(make_child_div(first_elem, first_flex)) + .child(divider) + .child(make_child_div(second_elem, second_flex)) + .into_any_element() + } + } + } + + #[allow(clippy::too_many_arguments)] + fn render_split_divider( + &mut self, + direction: SplitDirection, + resize_bounds: crate::layout::Bounds, + divider_bounds: crate::layout::Bounds, + gap: f32, + first_slot: SlotId, + second_slot: SlotId, + theme: &CodirigentTheme, + border_color: gpui::Hsla, + is_resizing: bool, + cx: &mut Context, + ) -> gpui::Stateful { + let primary: gpui::Hsla = theme.primary.into(); + let divider_bg = if is_resizing { + primary.opacity(0.35) + } else { + border_color.opacity(0.45) + }; + let divider_hover = if is_resizing { + primary.opacity(0.45) + } else { + primary.opacity(0.22) + }; + let divider_origin = match direction { + SplitDirection::Horizontal => divider_bounds.origin.x, + SplitDirection::Vertical => divider_bounds.origin.y, + }; + + div() + .id(SharedString::from(format!( + "split-divider-{}-{}", + first_slot.0, second_slot.0 + ))) + .flex_shrink_0() + .bg(divider_bg) + .when(direction == SplitDirection::Horizontal, |this| { + this.w(px(divider_bounds.size.width)) + .h_full() + .cursor_col_resize() + }) + .when(direction == SplitDirection::Vertical, |this| { + this.h(px(divider_bounds.size.height)) + .w_full() + .cursor_row_resize() + }) + .hover(|style| style.bg(divider_hover)) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, event: &MouseDownEvent, _window, cx| { + cx.stop_propagation(); + let pos = + crate::layout::Point::new(event.position.x.into(), event.position.y.into()); + this.selection.drag = None; + this.selection.split_resize = Some(super::types::SplitResizeState { + first_slot, + second_slot, + direction, + bounds: resize_bounds, + gap, + grab_offset: match direction { + SplitDirection::Horizontal => (pos.x - divider_origin).max(0.0), + SplitDirection::Vertical => (pos.y - divider_origin).max(0.0), + }, + changed: false, + }); + this.selection.is_selecting = false; + this.selection.selecting_session_id = None; + cx.notify(); + }), + ) + } + + /// Render an empty slot in split tree mode. + fn render_split_empty_slot( + &mut self, + slot: SlotId, + panel_bg: gpui::Hsla, + border_color: gpui::Hsla, + muted: gpui::Hsla, + cx: &mut Context, + ) -> gpui::Stateful { + div() + .id(SharedString::from(format!("empty-slot-{}", slot.0))) + .size_full() + .bg(panel_bg) + .border_1() + .border_color(border_color) + .rounded_lg() + .border_dashed() + .flex() + .flex_col() + .items_center() + .justify_center() + .gap_2() + .cursor_pointer() + .on_click(cx.listener(move |this, _: &ClickEvent, _window, cx| { + info!(?slot, "Empty split slot clicked — creating session"); + this.create_session_in_slot(slot, cx); + })) + .child( + div() + .text_xl() + .text_color(muted) + .font_family(icons::LUCIDE_FONT_FAMILY) + .child(icons::circle_plus()), + ) + .child( + div() + .text_xs() + .text_color(muted) + .child(super::types::EMPTY_CELL_MESSAGE), + ) + } +} + +fn split_child_bounds( + direction: SplitDirection, + ratio: f32, + available: crate::layout::Bounds, + gap: f32, +) -> ( + crate::layout::Bounds, + crate::layout::Bounds, + crate::layout::Bounds, +) { + match direction { + SplitDirection::Horizontal => { + let total_w = (available.size.width - gap).max(0.0); + let first_w = (total_w * ratio).max(0.0); + let second_w = (total_w - first_w).max(0.0); + let divider_x = available.origin.x + first_w; + ( + crate::layout::Bounds::new( + available.origin.x, + available.origin.y, + first_w, + available.size.height, + ), + crate::layout::Bounds::new( + divider_x + gap, + available.origin.y, + second_w, + available.size.height, + ), + crate::layout::Bounds::new( + divider_x, + available.origin.y, + gap, + available.size.height, + ), + ) + } + SplitDirection::Vertical => { + let total_h = (available.size.height - gap).max(0.0); + let first_h = (total_h * ratio).max(0.0); + let second_h = (total_h - first_h).max(0.0); + let divider_y = available.origin.y + first_h; + ( + crate::layout::Bounds::new( + available.origin.x, + available.origin.y, + available.size.width, + first_h, + ), + crate::layout::Bounds::new( + available.origin.x, + divider_y + gap, + available.size.width, + second_h, + ), + crate::layout::Bounds::new( + available.origin.x, + divider_y, + available.size.width, + gap, + ), + ) + } + } +} From a88b3e54089fb2ad8f546237c5c20c593289809b Mon Sep 17 00:00:00 2001 From: oso95 Date: Fri, 13 Mar 2026 01:57:59 -0400 Subject: [PATCH 6/9] Extract pane header rendering --- crates/codirigent-ui/src/workspace/README.md | 8 + .../src/workspace/grid_render.rs | 321 +------------- crates/codirigent-ui/src/workspace/mod.rs | 3 + .../src/workspace/pane_header_render.rs | 397 ++++++++++++++++++ 4 files changed, 425 insertions(+), 304 deletions(-) create mode 100644 crates/codirigent-ui/src/workspace/pane_header_render.rs diff --git a/crates/codirigent-ui/src/workspace/README.md b/crates/codirigent-ui/src/workspace/README.md index 0a222c15..02c65b19 100644 --- a/crates/codirigent-ui/src/workspace/README.md +++ b/crates/codirigent-ui/src/workspace/README.md @@ -27,6 +27,7 @@ The workspace rendering is organized into specialized modules: - Traditional NxM grid layout - Dispatch to split-tree rendering - Session cells with terminals + - Delegation to pane-header rendering - Empty grid cell placeholders - **`split_render.rs`** - Split-tree layout rendering @@ -35,6 +36,12 @@ The workspace rendering is organized into specialized modules: - Divider rendering and drag setup - Empty split-slot placeholders +- **`pane_header_render.rs`** - Pane header and tab-strip rendering + - Pane-local tabs + - Header badges (project, git, shell, task, context) + - Active-tab drag affordance + - Pane-local `+` session creation + - **`task_board_render.rs`** (1,334 lines) - Task management UI - Right sidebar task board - Task creation and editing modals @@ -185,6 +192,7 @@ workspace/ │ ├── Uses: top_bar_render, modal_render │ └── Uses: icon_utils ├── grid_render.rs # Grid layout + shared session cells +├── pane_header_render.rs # Pane header + tab-strip rendering ├── split_render.rs # Split-tree layout rendering │ └── Uses: icon_utils ├── task_board_render.rs # Task board UI diff --git a/crates/codirigent-ui/src/workspace/grid_render.rs b/crates/codirigent-ui/src/workspace/grid_render.rs index effedc1e..289f7928 100644 --- a/crates/codirigent-ui/src/workspace/grid_render.rs +++ b/crates/codirigent-ui/src/workspace/grid_render.rs @@ -6,16 +6,14 @@ //! - Session cells with terminals //! - Empty grid cells and placeholders -use crate::icons; use crate::terminal_header::TerminalHeaderRenderHints; use crate::theme::CodirigentTheme; use crate::workspace::gpui::WorkspaceView; use crate::workspace::types::HEADER_HEIGHT; use codirigent_core::SessionId; use gpui::{ - div, px, ClickEvent, Context, Focusable, FontWeight, InteractiveElement, IntoElement, - MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement, ScrollWheelEvent, - SharedString, StatefulInteractiveElement, Styled, Window, + div, px, Context, Focusable, InteractiveElement, IntoElement, MouseButton, MouseDownEvent, + MouseMoveEvent, MouseUpEvent, ParentElement, ScrollWheelEvent, SharedString, Styled, Window, }; use std::rc::Rc; @@ -199,306 +197,21 @@ impl WorkspaceView { _ => cell_border, }; - let header_border = cell_border; - - // Color indicator bar - let color_indicator: gpui::Hsla = hints.color_indicator.into(); - let status_color: gpui::Hsla = hints.status.color.into(); - let pane_tab_ids = self.workspace().pane_tab_session_ids(pane_id.clone()); - let show_plus_button = self - .workspace() - .pane_active_session_id(pane_id.clone()) - .is_some(); - - let mut header = div() - .id(SharedString::from(format!( - "terminal-header-{}", - session_id.0 - ))) - .h(px(hints.height)) - .w_full() - .bg(panel_bg) - .border_b_1() - .border_color(header_border) - .flex() - .items_center() - .px_2() - .gap_2() - .child( - div() - .w(px(3.0)) - .h(px(16.0)) - .rounded_sm() - .bg(color_indicator), - ) - .child(div().w(px(8.0)).h(px(8.0)).rounded_full().bg(status_color)) - .child({ - let mut tab_strip = div().flex().items_center().gap_1().overflow_hidden(); - - for tab_session_id in pane_tab_ids { - let tab_is_active = tab_session_id == session_id; - let tab_name = self - .workspace() - .session(tab_session_id) - .map(|session| session.name.clone()) - .unwrap_or_else(|| hints.name.clone()); - let tab_bg = if tab_is_active { - theme.active.into() - } else { - border_color.opacity(0.35) - }; - let tab_fg = if tab_is_active { - fg - } else { - muted.opacity(0.9) - }; - - let mut tab = div() - .id(SharedString::from(format!( - "terminal-tab-{}-{}", - session_id.0, tab_session_id.0 - ))) - .px_2() - .h(px(22.0)) - .rounded_md() - .bg(tab_bg) - .flex() - .items_center() - .gap_1() - .overflow_hidden() - .cursor_pointer() - .on_click(cx.listener({ - let pane_id = pane_id.clone(); - move |this, _: &ClickEvent, _window, cx| { - if this - .workspace - .activate_pane_tab(pane_id.clone(), tab_session_id) - { - this.select_session_with_cx(tab_session_id, cx); - this.mark_layout_cache_dirty(); - this.sync_layout_derived_state(); - this.save_state_to_disk(cx); - cx.notify(); - } - } - })) - .child( - div() - .text_xs() - .font_weight(if tab_is_active { - FontWeight::SEMIBOLD - } else { - FontWeight::MEDIUM - }) - .text_color(tab_fg) - .overflow_hidden() - .text_ellipsis() - .child(tab_name), - ); - - if tab_is_active { - tab = tab - .cursor_grab() - .on_mouse_down( - MouseButton::Left, - cx.listener(move |this, event: &MouseDownEvent, _window, cx| { - let pos = crate::layout::Point::new( - event.position.x.into(), - event.position.y.into(), - ); - this.selection.drag = Some(super::types::DragState { - source_session_id: tab_session_id, - source_index: drag_logical_index.unwrap_or(0), - start_position: pos, - current_position: pos, - active: false, - target: None, - }); - cx.notify(); - }), - ) - .on_mouse_move(cx.listener( - move |this, event: &MouseMoveEvent, _window, cx| { - let Some(drag) = &mut this.selection.drag else { - return; - }; - if drag.source_session_id != tab_session_id { - return; - } - let pos = crate::layout::Point::new( - event.position.x.into(), - event.position.y.into(), - ); - drag.update_pointer(pos, &this.cache.render_cell_info); - cx.notify(); - }, - )); - } - - tab_strip = tab_strip.child(tab); - } - - tab_strip - }); - - // Project/directory name (after session name) - if let Some(project) = &hints.project_name { - header = header.child( - div() - .text_xs() - .text_color(muted.opacity(0.7)) - .overflow_hidden() - .text_ellipsis() - .child(project.clone()), - ); - } - - // Git branch badge (after session name) - if let Some(branch) = &hints.git_branch { - let git_fg = muted.opacity(0.8); - let git_badge_bg = border_color.opacity(0.25); - let branch_label = if branch.chars().count() > 16 { - let truncated: String = branch.chars().take(13).collect(); - format!("{}...", truncated) - } else { - branch.clone() - }; - let mut git_badge = div() - .px(px(4.0)) - .py_px() - .rounded_sm() - .bg(git_badge_bg) - .flex() - .flex_shrink_0() - .items_center() - .gap_1() - .child( - div() - .text_xs() - .text_color(git_fg) - .font_family(icons::LUCIDE_FONT_FAMILY) - .child(icons::git_branch()), - ) - .child(div().text_xs().text_color(git_fg).child(branch_label)); - - if let Some(count) = hints.git_dirty_count { - if count > 0 { - git_badge = git_badge.child( - div() - .text_xs() - .text_color(orange) - .child(format!("+{}", count)), - ); - } - } - - header = header.child(git_badge); - } - - if let Some(shell_label) = &hints.shell_label { - let shell_warning = hints.shell_warning.is_some(); - let shell_fg = if shell_warning { - orange - } else { - muted.opacity(0.8) - }; - let shell_bg = if shell_warning { - orange.opacity(0.12) - } else { - border_color.opacity(0.25) - }; - header = header.child( - div() - .px(px(4.0)) - .py_px() - .rounded_sm() - .bg(shell_bg) - .flex() - .flex_shrink_0() - .items_center() - .gap_1() - .child( - div() - .text_xs() - .text_color(shell_fg) - .font_family(icons::LUCIDE_FONT_FAMILY) - .child(icons::terminal()), - ) - .child( - div() - .text_xs() - .text_color(shell_fg) - .child(shell_label.clone()), - ), - ); - } - - header = header.child(div().flex_1()); - - // Task badge (if any) - if let Some(task) = &hints.task { - let task_bg: gpui::Hsla = task.bg_color.into(); - let task_color: gpui::Hsla = task.text_color.into(); - header = header.child( - div() - .px_2() - .py_px() - .rounded_sm() - .bg(task_bg) - .text_xs() - .text_color(task_color) - .overflow_hidden() - .text_ellipsis() - .child(task.display_text.clone()), - ); - } - - // Context usage (if any) - if let Some(context) = &hints.context { - let context_color: gpui::Hsla = context.color.into(); - header = header.child( - div() - .text_xs() - .text_color(context_color) - .child(context.text().to_string()), - ); - } - - if show_plus_button { - header = header.child( - div() - .id(SharedString::from(format!("pane-add-tab-{}", session_id.0))) - .w(px(20.0)) - .h(px(20.0)) - .rounded_md() - .bg(border_color.opacity(0.25)) - .flex() - .items_center() - .justify_center() - .cursor_pointer() - .hover(|style| style.bg(border_color.opacity(0.45))) - .on_click(cx.listener({ - let pane_id = pane_id.clone(); - move |this, _: &ClickEvent, _window, cx| { - this.create_session_in_pane(pane_id.clone(), cx); - } - })) - .child( - div() - .text_xs() - .font_family(icons::LUCIDE_FONT_FAMILY) - .text_color(fg) - .child(icons::plus()), - ), - ); - } - - // Set cursor for draggable header - header = if matches!(drag_visual, Some(DragVisual::Source)) { - header.cursor_grabbing() - } else { - header - }; + let header = self.render_pane_header( + pane_id.clone(), + session_id, + hints, + theme, + panel_bg, + border_color, + cell_border, + fg, + muted, + orange, + drag_logical_index, + matches!(drag_visual, Some(DragVisual::Source)), + cx, + ); // Mouse-up handling for active-tab drags lives on the workspace root. diff --git a/crates/codirigent-ui/src/workspace/mod.rs b/crates/codirigent-ui/src/workspace/mod.rs index 72c88a80..58f9b1de 100644 --- a/crates/codirigent-ui/src/workspace/mod.rs +++ b/crates/codirigent-ui/src/workspace/mod.rs @@ -107,6 +107,9 @@ mod grid_render; #[cfg(feature = "gpui-full")] mod split_render; +#[cfg(feature = "gpui-full")] +mod pane_header_render; + #[cfg(feature = "gpui-full")] mod settings_panels; diff --git a/crates/codirigent-ui/src/workspace/pane_header_render.rs b/crates/codirigent-ui/src/workspace/pane_header_render.rs new file mode 100644 index 00000000..7d72da23 --- /dev/null +++ b/crates/codirigent-ui/src/workspace/pane_header_render.rs @@ -0,0 +1,397 @@ +//! Pane header and tab-strip rendering. +//! +//! This module owns the header UI for workspace panes, including pane-local +//! tabs, session badges, and the pane-local session creation affordance. + +use crate::icons; +use crate::terminal_header::TerminalHeaderRenderHints; +use crate::theme::CodirigentTheme; +use crate::workspace::gpui::WorkspaceView; +use codirigent_core::{PaneId, SessionId}; +use gpui::{ + div, px, ClickEvent, Context, FontWeight, InteractiveElement, MouseButton, MouseDownEvent, + MouseMoveEvent, ParentElement, SharedString, StatefulInteractiveElement, Styled, +}; + +impl WorkspaceView { + #[allow(clippy::too_many_arguments)] + pub(super) fn render_pane_header( + &mut self, + pane_id: PaneId, + session_id: SessionId, + hints: &TerminalHeaderRenderHints, + theme: &CodirigentTheme, + panel_bg: gpui::Hsla, + border_color: gpui::Hsla, + header_border: gpui::Hsla, + fg: gpui::Hsla, + muted: gpui::Hsla, + orange: gpui::Hsla, + drag_logical_index: Option, + is_drag_source: bool, + cx: &mut Context, + ) -> gpui::Stateful { + let color_indicator: gpui::Hsla = hints.color_indicator.into(); + let status_color: gpui::Hsla = hints.status.color.into(); + let show_plus_button = self + .workspace() + .pane_active_session_id(pane_id.clone()) + .is_some(); + + let mut header = div() + .id(SharedString::from(format!( + "terminal-header-{}", + session_id.0 + ))) + .h(px(hints.height)) + .w_full() + .bg(panel_bg) + .border_b_1() + .border_color(header_border) + .flex() + .items_center() + .px_2() + .gap_2() + .child( + div() + .w(px(3.0)) + .h(px(16.0)) + .rounded_sm() + .bg(color_indicator), + ) + .child(div().w(px(8.0)).h(px(8.0)).rounded_full().bg(status_color)) + .child(self.render_pane_tab_strip( + pane_id.clone(), + session_id, + hints, + theme, + border_color, + fg, + muted, + drag_logical_index, + cx, + )); + + if let Some(project) = &hints.project_name { + header = header.child( + div() + .text_xs() + .text_color(muted.opacity(0.7)) + .overflow_hidden() + .text_ellipsis() + .child(project.clone()), + ); + } + + if let Some(branch) = &hints.git_branch { + header = header.child(self.render_git_branch_badge( + branch, + hints, + border_color, + muted, + orange, + )); + } + + if let Some(shell_label) = &hints.shell_label { + header = header.child(self.render_shell_badge( + shell_label, + hints, + border_color, + muted, + orange, + )); + } + + header = header.child(div().flex_1()); + + if let Some(task) = &hints.task { + let task_bg: gpui::Hsla = task.bg_color.into(); + let task_color: gpui::Hsla = task.text_color.into(); + header = header.child( + div() + .px_2() + .py_px() + .rounded_sm() + .bg(task_bg) + .text_xs() + .text_color(task_color) + .overflow_hidden() + .text_ellipsis() + .child(task.display_text.clone()), + ); + } + + if let Some(context) = &hints.context { + let context_color: gpui::Hsla = context.color.into(); + header = header.child( + div() + .text_xs() + .text_color(context_color) + .child(context.text().to_string()), + ); + } + + if show_plus_button { + header = header.child(self.render_pane_add_button( + &pane_id, + session_id, + border_color, + fg, + cx, + )); + } + + if is_drag_source { + header.cursor_grabbing() + } else { + header + } + } + + #[allow(clippy::too_many_arguments)] + fn render_pane_tab_strip( + &mut self, + pane_id: PaneId, + session_id: SessionId, + hints: &TerminalHeaderRenderHints, + theme: &CodirigentTheme, + border_color: gpui::Hsla, + fg: gpui::Hsla, + muted: gpui::Hsla, + drag_logical_index: Option, + cx: &mut Context, + ) -> gpui::Div { + let pane_tab_ids = self.workspace().pane_tab_session_ids(pane_id.clone()); + let mut tab_strip = div().flex().items_center().gap_1().overflow_hidden(); + + for tab_session_id in pane_tab_ids { + let tab_is_active = tab_session_id == session_id; + let tab_name = self + .workspace() + .session(tab_session_id) + .map(|session| session.name.clone()) + .unwrap_or_else(|| hints.name.clone()); + let tab_bg = if tab_is_active { + theme.active.into() + } else { + border_color.opacity(0.35) + }; + let tab_fg = if tab_is_active { + fg + } else { + muted.opacity(0.9) + }; + + let mut tab = div() + .id(SharedString::from(format!( + "terminal-tab-{}-{}", + session_id.0, tab_session_id.0 + ))) + .px_2() + .h(px(22.0)) + .rounded_md() + .bg(tab_bg) + .flex() + .items_center() + .gap_1() + .overflow_hidden() + .cursor_pointer() + .on_click(cx.listener({ + let pane_id = pane_id.clone(); + move |this, _: &ClickEvent, _window, cx| { + if this + .workspace + .activate_pane_tab(pane_id.clone(), tab_session_id) + { + this.select_session_with_cx(tab_session_id, cx); + this.mark_layout_cache_dirty(); + this.sync_layout_derived_state(); + this.save_state_to_disk(cx); + cx.notify(); + } + } + })) + .child( + div() + .text_xs() + .font_weight(if tab_is_active { + FontWeight::SEMIBOLD + } else { + FontWeight::MEDIUM + }) + .text_color(tab_fg) + .overflow_hidden() + .text_ellipsis() + .child(tab_name), + ); + + if tab_is_active { + tab = tab + .cursor_grab() + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, event: &MouseDownEvent, _window, cx| { + let pos = crate::layout::Point::new( + event.position.x.into(), + event.position.y.into(), + ); + this.selection.drag = Some(super::types::DragState { + source_session_id: tab_session_id, + source_index: drag_logical_index.unwrap_or(0), + start_position: pos, + current_position: pos, + active: false, + target: None, + }); + cx.notify(); + }), + ) + .on_mouse_move(cx.listener( + move |this, event: &MouseMoveEvent, _window, cx| { + let Some(drag) = &mut this.selection.drag else { + return; + }; + if drag.source_session_id != tab_session_id { + return; + } + let pos = crate::layout::Point::new( + event.position.x.into(), + event.position.y.into(), + ); + drag.update_pointer(pos, &this.cache.render_cell_info); + cx.notify(); + }, + )); + } + + tab_strip = tab_strip.child(tab); + } + + tab_strip + } + + fn render_git_branch_badge( + &mut self, + branch: &str, + hints: &TerminalHeaderRenderHints, + border_color: gpui::Hsla, + muted: gpui::Hsla, + orange: gpui::Hsla, + ) -> gpui::Div { + let git_fg = muted.opacity(0.8); + let git_badge_bg = border_color.opacity(0.25); + let branch_label = if branch.chars().count() > 16 { + let truncated: String = branch.chars().take(13).collect(); + format!("{}...", truncated) + } else { + branch.to_owned() + }; + let mut git_badge = div() + .px(px(4.0)) + .py_px() + .rounded_sm() + .bg(git_badge_bg) + .flex() + .flex_shrink_0() + .items_center() + .gap_1() + .child( + div() + .text_xs() + .text_color(git_fg) + .font_family(icons::LUCIDE_FONT_FAMILY) + .child(icons::git_branch()), + ) + .child(div().text_xs().text_color(git_fg).child(branch_label)); + + if let Some(count) = hints.git_dirty_count.filter(|count| *count > 0) { + git_badge = git_badge.child( + div() + .text_xs() + .text_color(orange) + .child(format!("+{}", count)), + ); + } + + git_badge + } + + fn render_shell_badge( + &mut self, + shell_label: &str, + hints: &TerminalHeaderRenderHints, + border_color: gpui::Hsla, + muted: gpui::Hsla, + orange: gpui::Hsla, + ) -> gpui::Div { + let shell_warning = hints.shell_warning.is_some(); + let shell_fg = if shell_warning { + orange + } else { + muted.opacity(0.8) + }; + let shell_bg = if shell_warning { + orange.opacity(0.12) + } else { + border_color.opacity(0.25) + }; + + div() + .px(px(4.0)) + .py_px() + .rounded_sm() + .bg(shell_bg) + .flex() + .flex_shrink_0() + .items_center() + .gap_1() + .child( + div() + .text_xs() + .text_color(shell_fg) + .font_family(icons::LUCIDE_FONT_FAMILY) + .child(icons::terminal()), + ) + .child( + div() + .text_xs() + .text_color(shell_fg) + .child(shell_label.to_owned()), + ) + } + + fn render_pane_add_button( + &mut self, + pane_id: &PaneId, + session_id: SessionId, + border_color: gpui::Hsla, + fg: gpui::Hsla, + cx: &mut Context, + ) -> gpui::Stateful { + div() + .id(SharedString::from(format!("pane-add-tab-{}", session_id.0))) + .w(px(20.0)) + .h(px(20.0)) + .rounded_md() + .bg(border_color.opacity(0.25)) + .flex() + .items_center() + .justify_center() + .cursor_pointer() + .hover(|style| style.bg(border_color.opacity(0.45))) + .on_click(cx.listener({ + let pane_id = pane_id.clone(); + move |this, _: &ClickEvent, _window, cx| { + this.create_session_in_pane(pane_id.clone(), cx); + } + })) + .child( + div() + .text_xs() + .font_family(icons::LUCIDE_FONT_FAMILY) + .text_color(fg) + .child(icons::plus()), + ) + } +} From 70fe470839137b79a03e538e85b2a0b88a12477d Mon Sep 17 00:00:00 2001 From: oso95 Date: Fri, 13 Mar 2026 02:09:32 -0400 Subject: [PATCH 7/9] Extract workspace pointer interactions --- crates/codirigent-ui/src/workspace/README.md | 9 ++ crates/codirigent-ui/src/workspace/gpui.rs | 97 +------------- .../workspace/impl_pointer_interactions.rs | 124 ++++++++++++++++++ crates/codirigent-ui/src/workspace/mod.rs | 3 + 4 files changed, 139 insertions(+), 94 deletions(-) create mode 100644 crates/codirigent-ui/src/workspace/impl_pointer_interactions.rs diff --git a/crates/codirigent-ui/src/workspace/README.md b/crates/codirigent-ui/src/workspace/README.md index 02c65b19..aadb5a20 100644 --- a/crates/codirigent-ui/src/workspace/README.md +++ b/crates/codirigent-ui/src/workspace/README.md @@ -42,6 +42,11 @@ The workspace rendering is organized into specialized modules: - Active-tab drag affordance - Pane-local `+` session creation +- **`impl_pointer_interactions.rs`** - Workspace pointer gesture reducers + - Session drag move/update/finish + - Split divider resize move/update/finish + - Gesture completion and cancellation handling + - **`task_board_render.rs`** (1,334 lines) - Task management UI - Right sidebar task board - Task creation and editing modals @@ -150,6 +155,7 @@ The workspace processes events through dedicated handlers: - **Icon Rail Events** - Navigation, drawer toggle - **Keyboard Shortcuts** - Session switching, layout changes - **Terminal Events** - Mouse/keyboard input, scrolling +- **Pointer Interactions** - Workspace-global pane drag and split resize coordination ## Testing @@ -187,11 +193,14 @@ This keeps methods accessible via `self` without changing the public API. workspace/ ├── core.rs # Core logic (no GPUI dependencies) ├── gpui.rs # GPUI view (depends on core) +│ ├── Wires: impl_pointer_interactions +│ └── Wires: render modules + lifecycle helpers ├── render.rs # Main coordinator │ ├── Uses: grid_render, icon_rail_render, task_board_render │ ├── Uses: top_bar_render, modal_render │ └── Uses: icon_utils ├── grid_render.rs # Grid layout + shared session cells +├── impl_pointer_interactions.rs # Mouse drag/resize reducers ├── pane_header_render.rs # Pane header + tab-strip rendering ├── split_render.rs # Split-tree layout rendering │ └── Uses: icon_utils diff --git a/crates/codirigent-ui/src/workspace/gpui.rs b/crates/codirigent-ui/src/workspace/gpui.rs index 36d3cac8..0586caa0 100644 --- a/crates/codirigent-ui/src/workspace/gpui.rs +++ b/crates/codirigent-ui/src/workspace/gpui.rs @@ -737,40 +737,6 @@ impl WorkspaceView { self.cache.pending_resize_signature = None; } - pub(super) fn update_split_resize(&mut self, position: crate::layout::Point) -> bool { - let Some(resize) = self.selection.split_resize.as_ref().copied() else { - return false; - }; - - let total = match resize.direction { - codirigent_core::SplitDirection::Horizontal => resize.bounds.size.width - resize.gap, - codirigent_core::SplitDirection::Vertical => resize.bounds.size.height - resize.gap, - }; - if total <= 0.0 { - return false; - } - - let offset = match resize.direction { - codirigent_core::SplitDirection::Horizontal => { - position.x - resize.bounds.origin.x - resize.grab_offset - } - codirigent_core::SplitDirection::Vertical => { - position.y - resize.bounds.origin.y - resize.grab_offset - } - }; - let ratio = offset / total; - let changed = - self.workspace - .resize_split_divider(resize.first_slot, resize.second_slot, ratio); - if changed { - if let Some(active_resize) = self.selection.split_resize.as_mut() { - active_resize.changed = true; - } - self.mark_layout_cache_dirty(); - } - changed - } - fn current_resize_signature( &self, cell_width: f32, @@ -2215,70 +2181,13 @@ impl Render for WorkspaceView { this.handle_key_down(event, window, cx); })) .on_mouse_move(cx.listener(|this, event: &MouseMoveEvent, _window, cx| { - let pos = - crate::layout::Point::new(event.position.x.into(), event.position.y.into()); - if this.selection.split_resize.is_some() { - if !event.dragging() { - if let Some(resize) = this.selection.split_resize.take() { - if resize.changed { - this.save_state_to_disk(cx); - } - } - cx.notify(); - return; - } - if this.update_split_resize(pos) { - cx.notify(); - } - return; - } - let Some(drag) = &mut this.selection.drag else { - return; - }; - - drag.update_pointer(pos, &this.cache.render_cell_info); - cx.notify(); + this.handle_workspace_mouse_move(event, cx); })) // Global mouse-up: catch drag releases anywhere in workspace .on_mouse_up( MouseButton::Left, - cx.listener(|this, _event: &MouseUpEvent, _window, cx| { - if let Some(resize) = this.selection.split_resize.take() { - if resize.changed { - this.save_state_to_disk(cx); - } - cx.notify(); - return; - } - if let Some(drag) = this.selection.drag.take() { - if drag.active { - if let Some(target) = drag.target { - let changed = match target.kind { - super::types::DragTargetKind::PaneBody => this - .workspace - .swap_sessions(drag.source_index, target.index), - super::types::DragTargetKind::PaneHeader => this - .cache - .render_cell_info - .iter() - .find(|info| info.index == target.index) - .cloned() - .is_some_and(|info| { - this.workspace.group_session_into_pane( - drag.source_session_id, - info.pane_id, - ) - }), - }; - if changed { - this.mark_layout_cache_dirty(); - this.sync_layout_derived_state(); - this.save_state_to_disk(cx); - } - } - } - cx.notify(); - } + cx.listener(|this, event: &MouseUpEvent, _window, cx| { + this.handle_workspace_mouse_up_left(event, cx); }), ) .bg(bg) diff --git a/crates/codirigent-ui/src/workspace/impl_pointer_interactions.rs b/crates/codirigent-ui/src/workspace/impl_pointer_interactions.rs new file mode 100644 index 00000000..aacd70b0 --- /dev/null +++ b/crates/codirigent-ui/src/workspace/impl_pointer_interactions.rs @@ -0,0 +1,124 @@ +//! Workspace pointer interaction reducers. +//! +//! This module owns workspace-global pointer gesture coordination for pane drag +//! and split resize interactions. The top-level GPUI view wires mouse events +//! into these methods, while the gesture state transitions live here. + +use crate::workspace::gpui::WorkspaceView; +use gpui::{Context, MouseMoveEvent, MouseUpEvent}; + +impl WorkspaceView { + pub(super) fn handle_workspace_mouse_move( + &mut self, + event: &MouseMoveEvent, + cx: &mut Context, + ) { + let pos = crate::layout::Point::new(event.position.x.into(), event.position.y.into()); + + if self.selection.split_resize.is_some() { + if !event.dragging() { + self.finish_split_resize(cx); + cx.notify(); + return; + } + + if self.update_split_resize(pos) { + cx.notify(); + } + return; + } + + let Some(drag) = &mut self.selection.drag else { + return; + }; + + drag.update_pointer(pos, &self.cache.render_cell_info); + cx.notify(); + } + + pub(super) fn handle_workspace_mouse_up_left( + &mut self, + _event: &MouseUpEvent, + cx: &mut Context, + ) { + if self.selection.split_resize.is_some() { + self.finish_split_resize(cx); + cx.notify(); + return; + } + + self.finish_session_drag(cx); + } + + pub(super) fn update_split_resize(&mut self, position: crate::layout::Point) -> bool { + let Some(resize) = self.selection.split_resize.as_ref().copied() else { + return false; + }; + + let total = match resize.direction { + codirigent_core::SplitDirection::Horizontal => resize.bounds.size.width - resize.gap, + codirigent_core::SplitDirection::Vertical => resize.bounds.size.height - resize.gap, + }; + if total <= 0.0 { + return false; + } + + let offset = match resize.direction { + codirigent_core::SplitDirection::Horizontal => { + position.x - resize.bounds.origin.x - resize.grab_offset + } + codirigent_core::SplitDirection::Vertical => { + position.y - resize.bounds.origin.y - resize.grab_offset + } + }; + let ratio = offset / total; + let changed = + self.workspace + .resize_split_divider(resize.first_slot, resize.second_slot, ratio); + if changed { + if let Some(active_resize) = self.selection.split_resize.as_mut() { + active_resize.changed = true; + } + self.mark_layout_cache_dirty(); + } + changed + } + + fn finish_split_resize(&mut self, cx: &mut Context) { + if let Some(resize) = self.selection.split_resize.take() { + if resize.changed { + self.save_state_to_disk(cx); + } + } + } + + fn finish_session_drag(&mut self, cx: &mut Context) { + if let Some(drag) = self.selection.drag.take() { + if drag.active { + if let Some(target) = drag.target { + let changed = match target.kind { + super::types::DragTargetKind::PaneBody => self + .workspace + .swap_sessions(drag.source_index, target.index), + super::types::DragTargetKind::PaneHeader => self + .cache + .render_cell_info + .iter() + .find(|info| info.index == target.index) + .cloned() + .is_some_and(|info| { + self.workspace + .group_session_into_pane(drag.source_session_id, info.pane_id) + }), + }; + if changed { + self.mark_layout_cache_dirty(); + self.sync_layout_derived_state(); + self.save_state_to_disk(cx); + } + } + } + cx.notify(); + } + } +} diff --git a/crates/codirigent-ui/src/workspace/mod.rs b/crates/codirigent-ui/src/workspace/mod.rs index 58f9b1de..e136dc4a 100644 --- a/crates/codirigent-ui/src/workspace/mod.rs +++ b/crates/codirigent-ui/src/workspace/mod.rs @@ -77,6 +77,9 @@ mod impl_settings; #[cfg(feature = "gpui-full")] mod impl_ui_operations; +#[cfg(feature = "gpui-full")] +mod impl_pointer_interactions; + #[cfg(feature = "gpui-full")] pub(crate) mod render; From dcfca14e7a7a40007b2b8b39399189bd1adb8f09 Mon Sep 17 00:00:00 2001 From: oso95 Date: Fri, 13 Mar 2026 02:22:17 -0400 Subject: [PATCH 8/9] Refresh workspace architecture docs --- .gitignore | 2 + crates/codirigent-ui/src/workspace/README.md | 3 + docs/architecture/overview.md | 2 + .../ui-thread-offload-refactor-plan.md | 1188 ----------------- docs/architecture/workspace/README.md | 113 ++ docs/architecture/workspace/gpui.md | 260 ++++ docs/architecture/workspace/module-map.md | 291 ++++ docs/architecture/workspace/output-polling.md | 227 ++++ docs/hidden-session-plan.md | 131 -- docs/resizable-split-pane-plan.md | 162 --- docs/session-shell-selection-plan.md | 162 --- docs/tab-grouping-plan.md | 180 --- 12 files changed, 898 insertions(+), 1823 deletions(-) delete mode 100644 docs/architecture/ui-thread-offload-refactor-plan.md create mode 100644 docs/architecture/workspace/README.md create mode 100644 docs/architecture/workspace/gpui.md create mode 100644 docs/architecture/workspace/module-map.md create mode 100644 docs/architecture/workspace/output-polling.md delete mode 100644 docs/hidden-session-plan.md delete mode 100644 docs/resizable-split-pane-plan.md delete mode 100644 docs/session-shell-selection-plan.md delete mode 100644 docs/tab-grouping-plan.md diff --git a/.gitignore b/.gitignore index ea10126e..5c9e629e 100644 --- a/.gitignore +++ b/.gitignore @@ -134,3 +134,5 @@ coverage/ # Git worktrees .worktrees/ worktrees/ +vibehq-hub/ +.docx \ No newline at end of file diff --git a/crates/codirigent-ui/src/workspace/README.md b/crates/codirigent-ui/src/workspace/README.md index aadb5a20..b23c104d 100644 --- a/crates/codirigent-ui/src/workspace/README.md +++ b/crates/codirigent-ui/src/workspace/README.md @@ -2,6 +2,9 @@ The workspace module manages the main application window with grid layout, session panes, and UI controls. +For the longer-form architecture reference, see +[`docs/architecture/workspace/`](../../../../docs/architecture/workspace/). + ## Architecture The workspace is split into two main components: diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 7bfa3c0d..be239332 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -154,5 +154,7 @@ All data stored in `.codirigent/` directory: ## Next Steps - Read [Data Flow](data-flow.md) for detailed flow diagrams +- Read [Workspace Architecture](workspace/README.md) for the current + `codirigent-ui::workspace` module map and UI/render ownership boundaries - Read [Crate Dependencies](crate-dependencies.md) for dependency graph - Read [Event Bus](event-bus.md) for event system details diff --git a/docs/architecture/ui-thread-offload-refactor-plan.md b/docs/architecture/ui-thread-offload-refactor-plan.md deleted file mode 100644 index eeba0619..00000000 --- a/docs/architecture/ui-thread-offload-refactor-plan.md +++ /dev/null @@ -1,1188 +0,0 @@ -# UI-Thread Offload Refactor Plan - -## Status - -Substantially complete. This document defines the planned refactor for the remaining UI-thread-bound session workflow in Codirigent, and now reflects implemented progress through Phase 5. Phase 0 instrumentation remains deferred. - -## Progress Snapshot - -| Phase | Status | Notes | -| --- | --- | --- | -| Phase 0: Instrumentation And Baseline | Pending | Planned first in the roadmap, but not yet implemented. | -| Phase 1: PTY Command Queue | Complete | Queue-backed PTY writes/resizes landed with a dedicated worker, tests, and full verification gate. | -| Phase 2: Async Session Bootstrap | Complete | Session create/restore bootstrap now runs on the background executor, with UI-side attach/finalization only. | -| Phase 3: Terminal Runtime Offload | Complete | Terminal parsing/state mutation now runs in a background runtime and the UI consumes snapshots only. | -| Phase 4: Detector Worker | Complete | Detector tick and stale cached-status collection now run on the background executor; UI only applies targeted status deltas. | -| Phase 5: Derived UI State Cleanup | Complete | Render-time fallback recomputation is removed; derived UI state is now updated from explicit mutation paths and targeted reducers. | - -### Phase 1 Completion Notes - -Implemented: - -- Added a dedicated PTY I/O worker in `crates/codirigent-session/src/session_io.rs`. -- Moved per-session write and resize operations behind a queue-backed handle in `SessionState`. -- Updated `DefaultSessionManager::send_input()` and `DefaultSessionManager::resize()` to enqueue commands instead of performing synchronous PTY I/O. -- Added worker-level tests for write ordering, contiguous resize coalescing, and shutdown behavior. -- Added a manager-level test that verifies ordered command delivery through the real PTY path. - -Additional fixes made while validating the phase: - -- Initialized the cached cursor position earlier in `TerminalView` so the all-features UI test suite remains green. -- Fixed terminal-editor detection for Windows-style paths in `editor_detection.rs`. - -Verification completed successfully with: - -```bash -cargo clean -cargo fmt --all -- --check -cargo check --workspace --all-targets --all-features -cargo build --workspace --all-features -cargo test --all --all-targets --all-features -cargo clippy --all --all-targets --all-features -- -D warnings -cargo check -p codirigent-ui --features gpui-full -``` - -### Phase 2 Completion Notes - -Implemented: - -- Added a background bootstrap path in `crates/codirigent-ui/src/workspace/impl_session_lifecycle.rs` that performs `DefaultSessionManager::create_session()` off the UI thread and returns a structured bootstrap result. -- Refactored `WorkspaceView::create_session_inner()` so the UI thread only reserves the target slot/session number, submits the bootstrap task, and later attaches the completed session. -- Added reservation tracking in `crates/codirigent-ui/src/workspace/types.rs` for in-flight session numbers and split-tree slots so repeated create requests cannot race into duplicate names or duplicate slot claims. -- Refactored restore so saved sessions are bootstrapped on the background executor in small batches, then finalized on the UI thread with header creation, detector monitoring, resume-command replay, and layout reapplication. -- Added cleanup paths that discard a bootstrapped manager session if the workspace can no longer attach it, preventing session-manager leaks when late results cannot be placed in the workspace. -- Preserved restore responsiveness by keeping layout staging/focus updates on the UI thread while moving PTY spawn, working-directory validation, and session registration off-thread. - -Phase 2 tests added: - -- Unit test for session-number allocation that skips both existing and reserved values. -- Unit test for restore resume-command ordering across Claude, Codex, and Gemini. -- Unit test for successful bootstrap result assembly, including normalized working directory and child PID capture. -- Unit test for invalid working directory failure with no orphaned session-manager state. - -Additional fixes made while validating the phase: - -- Updated the bootstrap metadata test to compare normalized working-directory paths, matching runtime semantics. -- Scoped the macOS clipboard pasteboard probe helper to tests so the all-features warning-free gate remains green under `clippy -D warnings`. -- Replaced Unix-only `"/tmp"` usage in the new lifecycle tests and the session-create fallback path with `std::env::temp_dir()` so the Phase 2 change set does not hardcode Unix filesystem assumptions. -- Removed the remaining production-path `unwrap()` from the macOS clipboard URL-decoding helper to keep the branch aligned with the no-`unwrap()` rule for shipped code. - -Verification completed successfully with: - -```bash -cargo clean -cargo fmt --all -- --check -cargo check --workspace --all-targets --all-features -cargo build --workspace --all-features -cargo test --all --all-targets --all-features -cargo clippy --all --all-targets --all-features -- -D warnings -cargo check -p codirigent-ui --features gpui-full -``` - -Post-phase follow-up verification: - -```bash -cargo fmt --all -- --check -cargo test -p codirigent-ui --lib -cargo check -p codirigent-ui --features gpui-full -``` - -Windows-target follow-up: - -- Attempted `cargo check -p codirigent-ui --lib --tests --target x86_64-pc-windows-msvc --features gpui-full`. -- The check was blocked on this macOS host before reaching project code because the Windows target C toolchain/dependencies were unavailable (`ring`/`libz-sys` failed due to missing SDK headers and `vcpkg` setup). -- The project-side portability fixes for Phase 2 were still applied: Unix-only fallback/test paths were removed from the new lifecycle code, so the branch no longer embeds those assumptions. - -### Phase 3 Completion Notes - -Implemented: - -- Added `crates/codirigent-ui/src/terminal_runtime.rs` with a background-owned `TerminalRuntimeHandle` that owns terminal parsing/state mutation and publishes immutable `TerminalRenderSnapshot` values. -- Refactored `crates/codirigent-ui/src/terminal_view.rs` so `TerminalView` now holds the latest committed snapshot plus UI-only caches instead of a live mutable `Terminal`. -- Moved `Terminal::process_output()` off the UI thread by changing `crates/codirigent-ui/src/workspace/impl_output_polling.rs` to apply PTY bytes inside the background output-preparation step and send only render snapshots plus metadata back to the UI. -- Updated `crates/codirigent-ui/src/workspace/terminal_render.rs` to render selection as a UI overlay on top of snapshot-backed row caches, avoiding selection-triggered terminal-state work on the UI thread. -- Updated workspace call sites in `gpui.rs` and `impl_clipboard.rs` to use snapshot-backed terminal accessors (`rows`, `cols`, `mode`, `bracketed_paste_mode`) instead of reaching into a live terminal. -- Propagated theme changes into terminal runtimes in `settings_panels.rs` so snapshot colors stay aligned with the active workspace theme. - -Phase 3 tests added or updated: - -- Runtime tests for generation advancement, resize propagation, and background selection extraction in `terminal_runtime.rs`. -- Terminal view tests updated to use runtime-backed output application instead of direct terminal mutation. -- Added a stale-snapshot rejection test to verify generation guards in `TerminalView`. -- Added a scrollback-selection overlay test to verify viewport-relative selection rendering on top of snapshots. - -Verification completed successfully with: - -```bash -cargo clean -cargo fmt --all -- --check -cargo check --workspace --all-targets --all-features -cargo build --workspace --all-features -cargo test --all --all-targets --all-features -cargo clippy --all --all-targets --all-features -- -D warnings -cargo check -p codirigent-ui --features gpui-full -``` - -Targeted phase validation run before the full gate: - -```bash -cargo check -p codirigent-ui --features gpui-full -cargo test -p codirigent-ui --lib --features gpui-full -``` - -Manual Phase 3 focus-mode validation is still pending on a live app run. The automated gate is green, but the specific “single-session focus mode under sustained output” interaction still needs hands-on review. - -### Phase 4 Completion Notes - -Implemented: - -- Split detector maintenance out of the UI-side maintenance poll by adding a dedicated detector-maintenance polling loop in `crates/codirigent-ui/src/workspace/gpui.rs`. -- Added a background detector-maintenance batch collector in `crates/codirigent-ui/src/workspace/impl_output_polling.rs` that performs `detector.tick()` and stale cached-status enumeration off the UI thread. -- Added `detector_maintenance_in_flight` tracking in `crates/codirigent-ui/src/workspace/types.rs` so the new maintenance loop cannot enqueue overlapping detector jobs. -- Kept status reconciliation, header refreshes, task transitions, and notifications on the UI thread, but reduced the UI work to applying a precomputed list of affected session IDs. -- Removed detector tick work from `WorkspaceView::poll_maintenance()`, leaving hook/JSONL scans, compaction cleanup, git refresh scheduling, and clipboard preview updates on the existing maintenance loop. - -Phase 4 tests added: - -- Unit test for detector-maintenance ID merging to ensure changed detector results keep priority while duplicate session IDs are removed. -- Unit test for detector-maintenance batch collection to ensure stale cached-status sessions are still reconciled even when the detector itself reports no changes. - -Cross-platform and branch-hygiene checks performed during Phase 4: - -- The new tests use platform-neutral fixtures only and do not introduce Unix-only filesystem assumptions. -- Touched production source files remain free of new `unwrap()` calls under the repo's CI unwrap-count rule. - -Verification completed successfully with: - -```bash -cargo clean -cargo fmt --all -- --check -cargo check --workspace --all-targets --all-features -cargo build --workspace --all-features -cargo test --all --all-targets --all-features -cargo clippy --all --all-targets --all-features -- -D warnings -cargo check -p codirigent-ui --features gpui-full -``` - -Targeted phase validation run before the full gate: - -```bash -cargo fmt --all -- --check -cargo check -p codirigent-ui --features gpui-full -cargo test -p codirigent-ui --lib --all-features -``` - -### Phase 5 Completion Notes - -Implemented: - -- Removed the render-time derived-state fallback from `WorkspaceView::render()` in `crates/codirigent-ui/src/workspace/gpui.rs`, including the old `ui_sync_dirty` / `last_ui_sync` polling fields from `crates/codirigent-ui/src/workspace/types.rs`. -- Split the old `sync_ui_state()` sweep into explicit reducers in `gpui.rs`: - - `sync_task_board_state()` - - `sync_all_session_headers()` - - `sync_empty_cells_state()` - - `sync_layout_derived_state()` - - `sync_task_derived_state()` - - `refresh_derived_ui_state()` -- Expanded `sync_session_header()` so the hot path now updates `project_name`, `task`, git metadata, focus, and session color without relying on a later full recompute. -- Replaced former `mark_ui_sync_dirty()` mutation sites across the workspace implementation files with explicit reducer calls matched to the type of mutation: - - layout/focus changes use `sync_layout_derived_state()` - - task transitions use `sync_task_derived_state()` - - broad structural session/project mutations use `refresh_derived_ui_state()` -- Seeded derived UI state during `WorkspaceView::new()` so initial render starts from committed reducer output rather than waiting for a future render repair pass. - -Phase 5 tests added: - -- Unit tests for `session_project_name()` covering git-root preference and working-directory fallback. -- Unit tests for cached task-title resolution fallback behavior in the new reducer helpers. - -Branch-hygiene and portability checks performed during Phase 5: - -- Confirmed no stale references remain to `mark_ui_sync_dirty`, `ui_sync_dirty`, `last_ui_sync`, or `sync_ui_state()` in the workspace code. -- Confirmed no new production `unwrap()` calls were introduced under the repo's CI unwrap-count rule (`138 -> 138` versus `origin/main`). -- Scanned the touched Phase 5 workspace files for new Unix-only path literals and found none. - -Verification completed successfully with: - -```bash -cargo clean -cargo fmt --all -- --check -cargo check --workspace --all-targets --all-features -cargo build --workspace --all-features -cargo test --all --all-targets --all-features -cargo clippy --all --all-targets --all-features -- -D warnings -cargo check -p codirigent-ui --features gpui-full -``` - -Targeted phase validation run before the full gate: - -```bash -cargo test -p codirigent-ui gpui::tests --features gpui-full -cargo check -p codirigent-ui --features gpui-full -``` - -Manual Phase 5 validation is still pending on a live app run. The render-path fallback is removed and the automated gate is green, but the final interactive review for task-board/header freshness under real session activity still needs hands-on confirmation. - -## Problem Statement - -Focus mode with a single visible session exposes the current architecture's weakest path: - -1. PTY output is drained on a background task. -2. The prepared output is handed back to `WorkspaceView`. -3. `WorkspaceView` applies terminal output on the UI thread. -4. Rendering then rebuilds or reshapes terminal rows on the same thread. -5. The same thread is also responsible for click handling, keyboard handling, layout, and paint submission. - -Under sustained output, the UI thread becomes both the terminal state mutator and the renderer. That is the core architectural issue. Poll frequency tuning can change how often the problem appears, but it does not change ownership of the hot path. - -## Goals - -- Move the remaining terminal/session workflow off the UI thread where practical. -- Make the UI thread responsible only for event dispatch, state application, layout, and paint. -- Preserve visible behavior during the migration. -- Reduce worst-case frame time under sustained PTY output. -- Make focused single-session rendering no worse than multi-session rendering in terms of responsiveness. -- Replace render-time recomputation with mutation-driven derived UI state. - -## Non-Goals - -- Redesigning the visual terminal renderer. -- Replacing GPUI. -- Rewriting the detector heuristics from scratch. -- Extracting the terminal engine into a new crate in this first pass. -- Perfectly eliminating all background work from `WorkspaceView`; the target is to remove heavy and blocking work from the UI thread, not every mutation. - -## Scope - -This plan covers the five remaining migration items: - -1. Move terminal output application and terminal damage generation off the UI thread. -2. Move PTY writes and PTY resizes off the UI thread. -3. Move session creation and restore PTY spawn flow off the UI thread. -4. Move detector ticking and process-state polling off the UI thread. -5. Remove full `sync_ui_state()` recomputation from the render path. - -## Current Hotspots - -### 1. Terminal output apply on the UI thread - -- `crates/codirigent-ui/src/workspace/impl_output_polling.rs` - - `apply_prepared_session_output()` -- `crates/codirigent-ui/src/terminal.rs` - - `Terminal::process_output()` - -Current behavior: - -- PTY bytes are drained in the background. -- The bytes are sent back into `WorkspaceView::update(...)`. -- `Terminal::process_output()` is called synchronously on the UI thread. -- Status reconciliation and header updates then run on the same thread. - -### 2. Terminal row cache and shaping work in the render path - -- `crates/codirigent-ui/src/workspace/terminal_render.rs` - - `render_terminal_content()` -- `crates/codirigent-ui/src/terminal_view.rs` - - `render_rows()` - - `shaped_rows()` - -Current behavior: - -- Rendering can trigger row-cache rebuilds. -- Dirty rows can trigger shape rebuilds. -- In worst-case output, render cost grows with viewport size and damage size. - -### 3. PTY writes and resizes called synchronously from UI callbacks - -- `crates/codirigent-session/src/manager.rs` - - `send_input()` - - `resize()` -- Representative call sites: - - `crates/codirigent-ui/src/workspace/gpui.rs` - - `crates/codirigent-ui/src/workspace/impl_output_polling.rs` - - `crates/codirigent-ui/src/workspace/impl_task_board.rs` - -Current behavior: - -- Keyboard handlers, deferred-enter processing, VTE response forwarding, task assignment, and layout resize propagation can all call into PTY I/O synchronously. - -### 4. Session creation and restore still do PTY spawn on the UI thread - -- `crates/codirigent-ui/src/workspace/impl_session_lifecycle.rs` - - `create_session_inner()` - - `restore_session_from_plan()` -- `crates/codirigent-session/src/manager.rs` - - `create_session()` - -Current behavior: - -- Working directory validation, shell resolution, PTY spawn, session registration, and some restore replay happen synchronously from UI mutation paths. - -### 5. Detector maintenance work still runs from the UI polling loop - -- `crates/codirigent-ui/src/workspace/impl_output_polling.rs` - - `poll_maintenance()` - - `tick_detector_statuses()` -- `crates/codirigent-detector/src/detector.rs` - - `tick()` - -Current behavior: - -- Detector ticking, stale cache sweep, and related status reconciliation are still initiated from the UI thread on the maintenance cadence. - -### 6. Full UI model recomputation still happens from `render()` - -- `crates/codirigent-ui/src/workspace/gpui.rs` - - `sync_ui_state()` - - `render()` - -Current behavior: - -- `render()` still has a fallback path that rebuilds task board snapshots and other derived UI metadata. -- This violates a clean render contract and can turn missed invalidations into visible hitches. - -## Existing Background Work To Preserve - -The following areas are already backgrounded and should stay that way: - -- File tree construction and worktree enumeration. -- App state load/save. -- Settings load/save. -- JSONL readers. -- Hook signal scanning. -- Git refresh work. -- Clipboard preview image processing. - -This refactor should not regress those paths by reintroducing UI-thread blocking. - -## Target Architecture - -The target model is event-driven, snapshot-based, and actor-owned. - -### High-Level Ownership - -- `SessionManager` - - Owns session metadata registration. - - Owns PTY bootstrap. - - Owns PTY reader and PTY command channel lifecycle. - -- `SessionIoWorker` - - Owns PTY write and resize commands after creation. - - Serializes PTY mutations. - - Coalesces resizes. - -- `TerminalRuntime` - - Owns terminal parser state for each live session. - - Consumes drained PTY bytes off the UI thread. - - Produces immutable render snapshots or deltas. - - Emits metadata updates derived from output: - - shell state - - cwd changes - - CLI detection hints - - output activity markers - -- `DetectorWorker` - - Owns detector ticks and process-state polling. - - Produces status deltas instead of requiring the UI thread to poll detector state. - -- `UiStateReducer` - - Runs on the UI thread. - - Applies already-prepared mutations. - - Maintains derived UI state incrementally. - - Never performs O(all tasks) or O(all sessions) recomputation inside `render()`. - -### Required Data Contracts - -The refactor depends on explicit message boundaries. - -#### PTY command path - -```rust -enum SessionIoCommand { - Write { bytes: Vec }, - Resize { rows: u16, cols: u16 }, - Shutdown, -} -``` - -Requirements: - -- `Write` must preserve ordering. -- `Resize` must be latest-wins when multiple resizes queue up during drag. -- Commands must be fire-and-forget from the UI thread. - -#### Terminal runtime path - -```rust -struct SessionRenderSnapshot { - session_id: SessionId, - generation: u64, - rows: Arc<[RenderRow]>, - dirty_rows: Arc<[usize]>, - cursor: Option, - viewport: ViewportSnapshot, -} - -struct SessionMetadataDelta { - session_id: SessionId, - cwd: Option, - detected_cli_type: Option, - shell_state: Option, - has_more_output: bool, -} -``` - -Requirements: - -- Snapshots must be immutable once published. -- The UI should always be able to swap to the latest snapshot without reparsing PTY bytes. -- Snapshot publication must not require holding UI state locks. - -#### Detector path - -```rust -struct SessionStatusDelta { - session_id: SessionId, - detector_status: Option, - idle_time: Option, - stale_cache_candidates: bool, -} -``` - -Requirements: - -- Detector work should return changed sessions only. -- The UI thread should apply targeted status deltas, not scan all sessions each maintenance tick. - -## Module-By-Module Change Map - -This section maps the planned refactor onto the current code layout so the implementation can be split into reviewable PRs. - -| Area | Current modules | Planned changes | Notes | -| --- | --- | --- | --- | -| PTY command queue | `crates/codirigent-session/src/manager.rs` | Add a per-session command sender, worker bootstrap, queue-backed `send_input()`, queue-backed `resize()` | First low-risk slice. | -| Session bootstrap | `crates/codirigent-ui/src/workspace/impl_session_lifecycle.rs`, `crates/codirigent-session/src/manager.rs` | Extract blocking creation into background bootstrap job and completion messages | Needed before full terminal runtime offload. | -| Terminal runtime | `crates/codirigent-ui/src/workspace/impl_output_polling.rs`, `crates/codirigent-ui/src/terminal.rs`, `crates/codirigent-ui/src/terminal_view.rs` | Introduce background terminal runtime ownership and immutable snapshots | Main performance workstream. | -| Render path | `crates/codirigent-ui/src/workspace/terminal_render.rs`, `crates/codirigent-ui/src/terminal_view.rs` | Stop render-triggered state mutation; render committed snapshots only | Final text shaping may remain UI-owned. | -| Detector maintenance | `crates/codirigent-ui/src/workspace/impl_output_polling.rs`, `crates/codirigent-detector/src/detector.rs` | Move detector tick and stale cache sweep into a worker | Can be staged after terminal runtime work. | -| Derived UI state | `crates/codirigent-ui/src/workspace/gpui.rs` | Split `sync_ui_state()` into reducers and remove full recompute from `render()` | Final cleanup phase. | - -### Suggested New Modules - -- `crates/codirigent-ui/src/workspace/terminal_runtime.rs` - - background terminal parser ownership - - snapshot publication - - generation tracking - -- `crates/codirigent-session/src/session_io.rs` - - PTY command worker - - write ordering - - resize coalescing - -- `crates/codirigent-ui/src/workspace/session_bootstrap.rs` - - async session creation and restore bootstrap jobs - -- `crates/codirigent-ui/src/workspace/status_worker.rs` - - detector cadence and status-delta publication - -- `crates/codirigent-ui/src/workspace/ui_reducer.rs` - - derived task-board and session-summary reducers - -These names are recommendations, not requirements. The important part is separating worker-owned logic from UI-owned mutation and render logic. - -## Thread Ownership After Refactor - -### Must stay on the UI thread - -- GPUI event handling. -- Focus changes. -- Input routing decisions. -- Layout calculation. -- Final element tree creation. -- Final text shaping if GPUI text APIs remain UI-thread-bound. -- Paint submission. -- Applying already-prepared background deltas to UI-visible state. - -### Must move off the UI thread - -- PTY spawn and restore bootstrapping. -- PTY writes. -- PTY resizes. -- Terminal output parsing and terminal-state mutation. -- Terminal damage generation. -- Detector ticking and stale-status polling. -- Any session-wide or task-wide derived-state recomputation that does not require GPUI APIs. - -### Split ownership - -- Terminal rendering data preparation - - Move terminal-state mutation, damage extraction, and row materialization off-thread. - - Keep only the final GPUI text shaping step on the UI thread unless GPUI exposes a safe background shaping path. - -This split is important. We should not block this refactor on moving text shaping off-thread if the framework does not support it. - -## Workstream 1: Move Terminal Output Apply Off The UI Thread - -### Current Entry Points - -- `WorkspaceView::apply_prepared_session_output()` -- `Terminal::process_output()` -- `TerminalView::render_rows()` - -### Target End State - -- PTY bytes are parsed by a background `TerminalRuntime`. -- `WorkspaceView` receives ready-to-apply snapshots and metadata deltas. -- The UI thread no longer calls `Terminal::process_output()`. -- `TerminalView` becomes a lightweight snapshot holder plus view-specific caches. - -### Design - -Introduce one `TerminalRuntimeHandle` per session. - -Responsibilities: - -- Consume PTY bytes from the current prepared-output path. -- Apply `Terminal::process_output()` off-thread. -- Track terminal damage. -- Materialize render rows or row deltas. -- Emit `SessionRenderSnapshot` plus `SessionMetadataDelta`. - -Initial implementation should reuse the current output preparation pipeline rather than rewriting session output delivery and terminal rendering in one step. The migration should change the owner of `process_output()`, not the entire output transport in phase one. - -### Detailed Steps - -1. Add a new runtime module in `codirigent-ui` for terminal background work. -2. Move the mutable terminal parser state out of `TerminalView`. -3. Convert `TerminalView` into: - - latest committed snapshot - - font/theme settings - - UI-only caches - - cursor/IME view data -4. Replace `apply_prepared_session_output()` so it publishes drained bytes to the session runtime instead of mutating terminal state directly. -5. Add a UI-facing channel for `SessionRenderSnapshot` and `SessionMetadataDelta`. -6. Apply snapshots inside `WorkspaceView::update(...)` with no parsing work. -7. Remove `Terminal::process_output()` from all UI-thread code paths. - -### Damage Strategy - -- Use damage ranges or dirty row lists from the terminal runtime. -- Publish full snapshots only as a fallback. -- Prefer latest-snapshot-wins semantics for rendering. -- Intermediate paint requests may be dropped; terminal state must never be dropped. - -### Acceptance Criteria - -- No call to `Terminal::process_output()` from any `WorkspaceView` UI update path. -- Focused single-session mode remains interactive under sustained PTY output. -- Snapshot application on the UI thread is bounded and does not parse bytes. -- Existing session status behavior still updates correctly. - -### Risks - -- Snapshot payloads can become too large if they clone entire viewports too often. -- TerminalView may still do too much UI-side shaping work if snapshots are too raw. -- Output-derived metadata must remain ordered relative to rendered content. - -### Risk Mitigations - -- Start with dirty-row snapshots. -- Use generation counters to discard stale updates. -- Benchmark snapshot size and clone frequency before widening the contract. - -## Workstream 2: Move PTY Writes And Resizes Off The UI Thread - -### Current Entry Points - -- `DefaultSessionManager::send_input()` -- `DefaultSessionManager::resize()` -- UI call sites in keyboard handlers, deferred-enter handling, task assignment, VTE response forwarding, and resize sync. - -### Target End State - -- The UI thread enqueues PTY commands and returns immediately. -- A per-session `SessionIoWorker` owns PTY writes and resizes. -- PTY command ordering is explicit. - -### Design - -At session creation time, split PTY responsibilities: - -- PTY output reader remains background-owned. -- PTY write/resize path moves behind a `SessionIoCommand` sender. - -`SessionState` should retain: - -- session metadata -- child pid -- command sender -- output reader handle metadata - -It should no longer require a UI-thread caller to lock the manager and directly touch the PTY for every input event. - -### Detailed Steps - -1. Add a per-session command channel when the PTY is created. -2. Spawn a blocking worker that owns the PTY write/resize operations. -3. Change `send_input()` to enqueue `SessionIoCommand::Write`. -4. Change `resize()` to enqueue `SessionIoCommand::Resize`. -5. Add resize coalescing in the worker: - - collapse multiple queued resizes into the last seen size - - avoid replaying obsolete geometry during window drags -6. Audit all synchronous PTY call sites and convert them to the queue-based API. -7. Add observability: - - command queue depth - - resize collapse count - - write failures - -### Acceptance Criteria - -- No UI handler directly performs PTY I/O. -- Window drag/resize does not trigger synchronous PTY calls on the UI thread. -- Keyboard and VTE response paths remain ordered and correct. - -### Risks - -- Write ordering bugs can corrupt terminal interaction. -- Unbounded queues can hide overload until memory grows. - -### Risk Mitigations - -- Preserve per-session ordering with one queue per session. -- Consider bounded queues with explicit logging if pressure appears. -- Keep resize latest-wins while preserving write ordering. - -## Workstream 3: Move Session Creation And Restore Off The UI Thread - -### Current Entry Points - -- `WorkspaceView::create_session_inner()` -- `WorkspaceView::restore_session_from_plan()` -- `DefaultSessionManager::create_session()` - -### Target End State - -- Session creation becomes a background bootstrap job. -- The UI thread only initiates creation and applies the result. -- Restore queues multiple background bootstrap jobs without blocking render. - -### Design - -Introduce a `SessionBootstrapJob` with a completion message: - -```rust -struct SessionBootstrapResult { - session_id: SessionId, - session: Session, - child_pid: Option, - io_sender: SessionIoSender, - output_handle: SessionOutputHandle, - pending_restore_commands: Vec>, -} -``` - -The UI thread should be able to: - -- reserve a slot or pending placeholder -- kick off the job -- receive success or failure -- attach the finished session to workspace state - -### Detailed Steps - -1. Extract synchronous creation logic from `create_session_inner()` into a background bootstrap function. -2. Validate working directory and resolve shell in the bootstrap job. -3. Spawn the PTY and IO worker in the bootstrap job. -4. Return session metadata and handles to the UI thread. -5. Only after success: - - create the `TerminalRuntime` - - attach the session to the workspace - - start detector monitoring - - replay resume commands through the command queue -6. For restore: - - keep restore plans immutable - - queue one bootstrap job per saved session - - attach sessions incrementally as results arrive -7. Add a visible pending state so the UI does not look frozen during slow shell startup. - -### Acceptance Criteria - -- Creating or restoring sessions never blocks `render()` or input handlers. -- Slow shell startup or bad working directory validation does not freeze the UI. -- Restore replay preserves ordering of resume commands after the PTY is ready. - -### Risks - -- Session slot assignment can race with late-arriving bootstrap results. -- Failed creation jobs can leave orphaned placeholder state. - -### Risk Mitigations - -- Use a creation token per pending session slot. -- Only commit bootstrap results if the token still matches. -- Define explicit cleanup for failed bootstrap jobs. - -## Workstream 4: Move Detector Tick And Process-State Polling Off The UI Thread - -### Current Entry Points - -- `WorkspaceView::poll_maintenance()` -- `WorkspaceView::tick_detector_statuses()` -- `Detector::tick()` - -### Target End State - -- Detector maintenance runs independently of UI frame work. -- The UI thread receives targeted `SessionStatusDelta` messages. -- Stale cache reconciliation no longer requires a UI-driven sweep across session caches. - -### Design - -Introduce a dedicated maintenance worker that owns: - -- detector tick cadence -- stale cache review cadence -- per-session process-state updates - -The worker should emit: - -- changed detector status -- idle-time updates when needed -- stale-cache reconciliation requests or already-reconciled status results - -The cleanest version is to move both detector ticking and status reconciliation into the worker and send a final `SessionStatusPatch` to the UI thread. If that is too large for phase one, move detector ticking first and keep UI-side targeted reconcile as an intermediate state. - -### Detailed Steps - -1. Create a background maintenance task that ticks the detector at the current cadence. -2. Return changed session IDs and any needed status metadata over a channel. -3. Move stale cached-status sweep out of the UI path. -4. Decide the final ownership of `status_engine::reconcile()`: - - preferred: background worker - - acceptable intermediate step: UI applies only the changed patches -5. Keep notifications, task transitions, and UI header refreshes on the UI thread, but only as a reaction to precomputed status changes. -6. Remove detector polling from `poll_maintenance()`. - -### Acceptance Criteria - -- `poll_maintenance()` no longer performs detector tick work on the UI thread. -- Idle-to-working and working-to-idle transitions still behave correctly. -- Sessions without OSC integration still decay back to idle correctly. - -### Risks - -- Status ordering bugs can produce flicker or stale headers. -- Split ownership between detector and reconciler can create temporary inconsistency. - -### Risk Mitigations - -- Include sequence numbers or timestamps in status deltas. -- Prefer moving full reconciliation with the detector when feasible. - -## Workstream 5: Remove `sync_ui_state()` From The Render Path - -### Current Entry Points - -- `WorkspaceView::render()` -- `WorkspaceView::sync_ui_state()` - -### Target End State - -- `render()` becomes read-only with respect to derived UI state. -- Task board state, counts, pending assignments, and similar aggregates are rebuilt on mutation, not as a fallback in the hot render path. - -### Design - -Introduce a dedicated `UiDerivedState` struct that is updated incrementally from mutation sources: - -- task manager changes -- session header changes -- layout changes -- workspace session add/remove -- settings changes - -`render()` should only: - -- read the already-prepared state -- update layout-dependent caches that strictly require current window metrics -- build the element tree - -### Detailed Steps - -1. Split `sync_ui_state()` into smaller reducers: - - task-board reducer - - session-list reducer - - layout-derived reducer -2. Identify the mutation sources that currently rely on fallback sync. -3. Trigger the appropriate reducer directly from those mutation paths. -4. Replace the `render()` fallback sync with debug assertions or lightweight diagnostics. -5. Keep a temporary kill-switch during rollout in case an invalidation path is missed. -6. Once stable, remove the fallback interval entirely. - -### Acceptance Criteria - -- `render()` no longer calls a full derived-state recomputation function. -- Task board and session metadata remain correct after all existing mutation paths. -- Missed invalidations can be detected in debug builds. - -### Risks - -- Missing invalidation hooks can leave stale UI sections. -- Partial reducers can drift apart if ownership is unclear. - -### Risk Mitigations - -- Add explicit reducer tests. -- Use narrow reducer APIs with clear callers. -- Keep temporary diagnostics that compare reducer output against old full recompute in debug mode. - -## Recommended Rollout Order - -The five workstreams are coupled. The order below minimizes architectural churn. - -### Phase 0: Instrumentation And Baseline - -Before behavior changes: - -- add tracing spans around: - - output apply - - render_terminal_content - - shaped_rows - - send_input - - resize - - create_session - - tick_detector_statuses - - sync_ui_state -- capture frame time and input latency under a synthetic high-output session -- record baseline CPU use in single-session focus mode - -Deliverable: - -- reproducible before/after benchmark script or manual test recipe - -### Phase 1: PTY Command Queue - -Do Workstream 2 first. - -Why first: - -- It is self-contained. -- It removes synchronous PTY writes/resizes from multiple hot input paths. -- It provides the command infrastructure needed by async session creation and restore. - -### Phase 2: Async Session Bootstrap - -Do Workstream 3 second. - -Why second: - -- It builds on the PTY command queue. -- It removes another blocking class from UI mutation paths. -- It creates the right lifecycle hook for the terminal runtime. - -### Phase 3: Terminal Runtime Offload - -Do Workstream 1 third. - -Why third: - -- It is the biggest performance win. -- It depends on stable session bootstrap and PTY ownership boundaries. -- It changes the data flow between session output and rendering. - -### Phase 4: Detector Worker - -Do Workstream 4 fourth. - -Why fourth: - -- It is logically separate once session runtime ownership is clear. -- It simplifies maintenance polling after the terminal path is no longer UI-bound. - -### Phase 5: Derived UI State Cleanup - -Do Workstream 5 last. - -Why last: - -- It benefits from the new event boundaries created in earlier phases. -- It should be done after background result application paths are stable. - -## Per-Phase Implementation Verification - -Each phase needs its own implementation test plan. A phase is not complete when the code compiles; it is complete when the new ownership boundary is tested directly, the affected UX path is manually verified, and the full repo verification gate passes. - -### Phase 0: Instrumentation And Baseline - -Implementation checks: - -- Confirm new tracing spans compile and are emitted in debug logs. -- Verify baseline metrics can be collected for: - - frame duration - - terminal render duration - - output apply duration - - detector tick duration - -Manual checks: - -- Record a reproducible single-session focus-mode high-output scenario. -- Record a reproducible session-create / restore scenario. -- Record a reproducible window-drag / terminal-resize scenario. - -Phase exit criteria: - -- Baseline numbers are written down and can be compared after each later phase. -- There is a repeatable manual test recipe for every hot path being refactored. - -### Phase 1: PTY Command Queue - -Implementation tests: - -- Unit tests for per-session write ordering. -- Unit tests for resize coalescing with interleaved writes. -- Unit tests for worker shutdown and channel teardown. -- Unit tests for command send failure behavior after session close. - -Integration tests: - -- Session input path still reaches the PTY in order. -- Deferred-enter and VTE response forwarding still reach the PTY in order. -- Resize storms collapse to the latest geometry without dropping writes. - -Manual checks: - -- Type continuously into an active session while output is streaming. -- Drag-resize the window and verify terminal size keeps up without UI stalls. -- Trigger task assignment and confirm the queued task prompt still arrives correctly. - -Phase exit criteria: - -- No direct synchronous PTY write/resize path remains in UI handlers. -- Input ordering is preserved. -- Resize behavior is stable under rapid window drag. - -### Phase 2: Async Session Bootstrap - -Implementation tests: - -- Unit tests for successful bootstrap result assembly. -- Unit tests for invalid working directory failure. -- Unit tests for shell resolution failure and cleanup. -- Unit tests for placeholder token matching and stale result rejection. - -Integration tests: - -- Create-session path attaches a live session after background bootstrap succeeds. -- Restore-session path replays resume commands after PTY readiness. -- Failed bootstrap does not leave orphaned workspace/session state. - -Manual checks: - -- Create a new session while another session is producing heavy output. -- Restore multiple sessions and verify the UI remains responsive while they appear incrementally. -- Test a deliberately bad working directory and confirm the UI reports failure without freezing. - -Phase exit criteria: - -- Session creation and restore do not block UI interaction. -- Placeholder and cleanup behavior is correct on both success and failure paths. - -### Phase 3: Terminal Runtime Offload - -Implementation tests: - -- Unit tests for output chunk ingest into the background runtime. -- Unit tests for damage generation and dirty-row snapshot publication. -- Unit tests for snapshot generation ordering and stale-generation drop behavior. -- Unit tests for metadata extraction ordering relative to rendered content. - -Integration tests: - -- High-output sessions produce snapshots without calling `Terminal::process_output()` from the UI path. -- CLI detection, cwd updates, and shell-state updates still arrive correctly. -- Focused session and non-focused session output both render correctly. - -Manual checks: - -- Run a high-output producer in single-session focus mode and verify clicks and typing remain responsive. -- Switch focus between noisy and quiet sessions and verify no stale frame artifacts. -- Confirm terminal selection, cursor rendering, and IME behavior still work. - -Phase exit criteria: - -- PTY output parsing is off the UI thread. -- UI-side snapshot application is bounded and lightweight. -- The original freeze symptom is materially reduced or eliminated in the baseline scenario. - -### Phase 4: Detector Worker - -Implementation tests: - -- Unit tests for detector tick result publication. -- Unit tests for sessions without OSC integration returning to idle. -- Unit tests for stale cached-status sweep behavior. -- Unit tests for out-of-order or duplicate detector events. - -Integration tests: - -- Detector changes reach session headers and status caches correctly. -- Notifications and task state changes still trigger from detector-driven transitions. -- Hook-driven sessions still prefer hook status over detector fallbacks. - -Manual checks: - -- Exercise generic shell sessions that rely on detector decay. -- Exercise hook-capable sessions and confirm there is no status regression. -- Leave sessions idle long enough to verify stale-state cleanup behavior. - -Phase exit criteria: - -- `poll_maintenance()` no longer performs detector tick work on the UI thread. -- Detector-driven transitions remain correct across supported session types. - -### Phase 5: Derived UI State Cleanup - -Implementation tests: - -- Unit tests for task-board reducer updates across all task-status transitions. -- Unit tests for session-summary reducer updates on add/remove/focus/group changes. -- Unit tests for layout-derived state invalidation. -- Debug-only parity checks between reducer output and legacy full recompute while the fallback still exists. - -Integration tests: - -- Task board remains correct during rapid session and task updates. -- Session headers, counts, and pending-assignment state stay synchronized. -- No mutation path depends on `render()` to repair stale UI state. - -Manual checks: - -- Exercise task creation, assignment, verification, and completion flows. -- Rapidly switch layouts and focus while sessions update in the background. -- Verify no stale sidebar or task-board state appears after large batches of updates. - -Phase exit criteria: - -- `render()` is read-only with respect to full derived UI state. -- The fallback recompute path is removed or reduced to debug-only diagnostics. - -## Required Verification Gate After Each Phase - -Every implementation phase must end with a full local verification pass. The fast targeted tests above are not enough by themselves. - -Required commands: - -```bash -cargo clean -cargo fmt --all -- --check -cargo check --workspace --all-targets --all-features -cargo build --workspace --all-features -cargo test --all --all-targets --all-features -cargo clippy --all --all-targets --all-features -- -D warnings -``` - -Additional CI-parity check: - -```bash -cargo check -p codirigent-ui --features gpui-full -``` - -Notes: - -- `cargo fmt --all -- --check` is the formatting gate. This repo does not have a separate lint tool beyond formatting and clippy. -- `cargo clippy --all --all-targets --all-features -- -D warnings` is the local lint gate. -- `cargo clean` is required at phase completion, not just at the very end of the entire refactor. -- If local platform constraints prevent full cross-platform verification, the local gate must still pass on the active platform and CI must be allowed to validate the Windows/macOS matrix. - -## Detailed Validation Plan - -### Automated Tests - -- Session creation tests - - async bootstrap success - - invalid working directory failure - - restore replay order - -- PTY command queue tests - - write ordering - - resize coalescing - - shutdown cleanup - -- Terminal runtime tests - - output parsing produces correct row snapshots - - dirty-row updates do not require full snapshot rebuild - - stale generation snapshots are dropped - -- Detector tests - - sessions without OSC integration return to idle - - stale cached status transitions still occur - - out-of-order detector events do not regress state - -- UI reducer tests - - task-board counts update on each task status transition - - session list updates on add/remove/focus/group changes - - no render-path recomputation dependency remains - -### Manual Verification - -- Focus mode, single session, high-output producer - - clicks remain responsive - - keyboard input remains responsive - - render remains visually correct - -- Multi-session fairness - - one noisy session does not starve others - -- Window drag/resize - - no visible stalls - - terminal size catches up correctly - -- Session restore - - many-session restore does not freeze the app - -- Codex / Claude / generic shell sessions - - status transitions still behave as expected - - cwd and git refresh still update correctly - -### Instrumentation Metrics - -- UI thread frame duration percentile -- time spent applying output on UI thread -- time spent inside `render_terminal_content` -- terminal snapshot publish rate -- terminal snapshot size -- PTY command queue depth -- detector tick duration -- derived UI reducer duration - -## Migration Invariants - -These invariants must hold throughout the refactor: - -1. Terminal data ordering per session must remain correct. -2. PTY write ordering per session must remain correct. -3. A session must not accept output updates after shutdown. -4. Resize commands may collapse, but writes may not reorder around each other. -5. UI state must only display committed session snapshots. -6. Status updates must remain monotonic with respect to the latest known event timestamp or generation. - -## Open Questions - -1. Can GPUI text shaping be safely performed off-thread, or must shaped rows remain UI-owned for now? -2. Should `Terminal` remain in `codirigent-ui`, or is a later extraction into a non-UI crate desirable after this refactor? -3. Should status reconciliation move fully into the detector worker, or is a staged split acceptable long term? -4. Do we want bounded PTY command queues with backpressure, or unbounded queues with diagnostics in the first pass? -5. Should session bootstrap create a visible placeholder pane immediately, or only attach the pane after PTY creation succeeds? - -## Recommended First Implementation Slice - -The first implementation PR should not attempt all five workstreams at once. - -Recommended first slice: - -1. Add instrumentation. -2. Add the per-session PTY command queue and worker. -3. Convert `send_input()` and `resize()` to queued commands. -4. Land tests for ordering and resize coalescing. - -This slice is low-risk, directly reduces UI-thread blocking, and creates the foundation needed for the remaining four workstreams. diff --git a/docs/architecture/workspace/README.md b/docs/architecture/workspace/README.md new file mode 100644 index 00000000..6251a225 --- /dev/null +++ b/docs/architecture/workspace/README.md @@ -0,0 +1,113 @@ +# Workspace Architecture + +This directory documents the current `codirigent-ui::workspace` module +structure. The goal is to let a future developer or coding agent answer +"where does this behavior live?" without opening every file in +`crates/codirigent-ui/src/workspace/`. + +## Read This First + +- [Module Map](module-map.md) + - Top-level ownership boundaries. + - Which files are roots, helpers, renderers, or state containers. + +- [GPUI And Rendering](gpui.md) + - `WorkspaceView`, render orchestration, pointer interactions, UI event + translation, layout sync. + +- [Output Polling And Status](output-polling.md) + - PTY output flow, status reconciliation, JSONL polling, hook signals. + +## Workspace In One Screen + +`workspace/mod.rs` exposes two conceptual roots: + +- `core.rs` + - Canonical workspace state and layout logic. + - No GPUI-specific rendering concerns. + +- `gpui.rs` + - `WorkspaceView` and the GPUI-facing shell around `Workspace`. + - Renders the UI, owns UI-scoped state, and coordinates helper modules. + +The second major internal root is: + +- `impl_output_polling.rs` + - Output polling, status refresh, detector maintenance, background checks, + and compaction/task follow-up. + +Everything else in `workspace/` either: + +- extends `WorkspaceView` with a focused behavior cluster +- renders a specific UI region +- stores grouped sub-state used by the roots above + +The key render and interaction helpers added by the recent refactors are: + +- `grid_render.rs` + - Grid-layout composition and shared session-cell rendering. + +- `split_render.rs` + - Recursive split-tree rendering, divider setup, and empty split slots. + +- `pane_header_render.rs` + - Pane-header tabs, badges, and pane-local session creation controls. + +- `impl_pointer_interactions.rs` + - Workspace-global drag/resize reducers used by the GPUI root. + +## Quick Lookup + +If you need to change: + +- layout switching, focus movement, terminal resize: + - [gpui.md](gpui.md) + - `gpui/layout_sync.rs` + +- split-tree rendering or divider behavior: + - [gpui.md](gpui.md) + - `split_render.rs` + - `impl_pointer_interactions.rs` + +- pane tabs, header badges, pane `+` behavior: + - [gpui.md](gpui.md) + - `pane_header_render.rs` + +- grid cells and split/grid render dispatch: + - [gpui.md](gpui.md) + - `grid_render.rs` + +- task board counts, header badges, empty cell sync: + - [gpui.md](gpui.md) + - `gpui/derived_state.rs` + +- top bar, icon rail, empty-cell event translation: + - [gpui.md](gpui.md) + - `gpui/ui_events.rs` + +- PTY output draining, terminal runtime application, output scheduling: + - [output-polling.md](output-polling.md) + - `impl_output_polling/output_runtime.rs` + +- session status decisions, stale cache clearing, auto-assign/compaction follow-up: + - [output-polling.md](output-polling.md) + - `impl_output_polling/status_reconcile.rs` + +- JSONL-based Codex/Gemini status ingestion: + - [output-polling.md](output-polling.md) + - `impl_output_polling/cli_pollers.rs` + +- hook-signal ingestion: + - [output-polling.md](output-polling.md) + - `impl_output_polling/hook_signals.rs` + +## Related Docs + +- [../overview.md](../overview.md) + - Crate-level architecture and high-level system view. + +- [../../hook-and-status-system.md](../../hook-and-status-system.md) + - Lower-level hook file format and end-to-end status semantics. + +- [../../session-resume.md](../../session-resume.md) + - Session resume behavior and restore-oriented details. diff --git a/docs/architecture/workspace/gpui.md b/docs/architecture/workspace/gpui.md new file mode 100644 index 00000000..5490ffe8 --- /dev/null +++ b/docs/architecture/workspace/gpui.md @@ -0,0 +1,260 @@ +# GPUI And Rendering + +This document explains the `WorkspaceView` side of the workspace module. + +## What `gpui.rs` Owns + +`crates/codirigent-ui/src/workspace/gpui.rs` is still the UI root even after +the split. It owns: + +- the `WorkspaceView` type +- constructor wiring in `WorkspaceView::new` +- grouped UI state fields +- GPUI trait impls (`Render`, focus, IME/input handling) +- high-level render orchestration +- root event wiring for workspace-global pointer gestures +- keyboard and IME behavior that still benefits from staying close to the root + +The split was about moving lower-coupling helper clusters out of the root, not +about hiding the root. + +## `WorkspaceView` State Layout + +The struct is easiest to understand in four groups: + +### Canonical services and shared backends + +- `workspace` +- `event_bus` +- `session_manager` +- `detector` +- `task_manager` + +These tie the UI to the core/session/detector layers. + +### Rendered child components + +- `top_bar` +- `icon_rail` +- `drawer` +- `task_board` +- `empty_cells` +- `terminal_headers` +- `terminals` + +These are the long-lived UI components or terminal render surfaces. + +### Grouped UI sub-state + +- `project` +- `clipboard` +- `settings` +- `persistence` +- `modals` +- `selection` +- `polling` +- `cache` + +These are intentionally split into dedicated state structs so the root does not +become a flat bag of dozens of unrelated fields. + +### Output/status plumbing shared with polling + +- `output_dispatcher` +- `update_rx` +- `update_tx` +- `cli_readers` +- `notification_manager` + +These are UI-owned because the polling system ultimately mutates visible state. + +## Child Modules Under `workspace/gpui/` + +### `session_metadata.rs` + +Small pure helpers: + +- `session_project_name()` +- `resolved_task_title()` + +This is the leaf module used by reducers when they need human-readable session +or task labels. + +### `derived_state.rs` + +Converts canonical session/task state into cached UI state: + +- task board counts and snapshots +- terminal header state +- empty-grid-cell state + +Key rule: + +- derived state is refreshed from explicit mutation paths +- it should not be rebuilt as a silent render fallback + +That rule keeps render cheap and prevents "render fixed my stale state" bugs. + +### `ui_events.rs` + +Drains component event queues and translates them into workspace mutations: + +- task board events +- empty-cell create-session clicks +- top bar layout requests +- icon rail drawer/settings requests + +This file should stay focused on translation, not business logic. + +### `layout_sync.rs` + +Owns the follow-up work after layout or selection changes: + +- mark layout caches dirty +- focus-sensitive layout signatures +- selection helpers +- terminal cell metrics +- PTY resize throttling + +This is the main place where UI layout changes meet terminal runtime behavior. + +## Render-Facing Helper Modules + +The GPUI root now delegates most visual composition into smaller render +modules: + +### `grid_render.rs` + +Owns: + +- grid-layout composition +- split-vs-grid dispatch +- shared session-cell rendering used by both grid and split layouts + +### `split_render.rs` + +Owns: + +- recursive split-tree rendering +- divider visuals and drag-start wiring +- empty split-slot rendering + +### `pane_header_render.rs` + +Owns: + +- pane-local tab strips +- header badges and title metadata +- active-tab drag affordances +- pane-local `+` session creation affordance + +These modules keep `gpui.rs` as the UI root without forcing it to inline every +render detail. + +## Pointer Interaction Flow + +Workspace-global pointer gestures are now split between: + +- `gpui.rs` + - root GPUI event hooks (`on_mouse_move`, `on_mouse_up`) + - wiring from GPUI events into workspace reducers + +- `impl_pointer_interactions.rs` + - split-resize move/update/finalize + - session-drag move/finalize + - cancellation when a drag ends outside the normal path + +Design rule: + +- `gpui.rs` should stay as the place where root GPUI hooks are discoverable +- gesture-specific mutation logic should live in focused helper modules + +## Render Path + +The high-level render flow is: + +1. `Render::render()` in `gpui.rs` +2. drain pending UI component events +3. update cached layout signatures and render cell info +4. synchronize terminal dimensions and schedule PTY resizes +5. delegate UI composition into render-focused modules such as: + - `render.rs` + - `grid_render.rs` + - `split_render.rs` + - `pane_header_render.rs` + - `drawer_render.rs` + - `task_board_render.rs` + +Important design choice: + +- the root keeps the render entry point so a reader can still find the UI + lifecycle without jumping through many files first + +## Mutation Path Rules + +When a workspace mutation changes visible state, the usual follow-up pattern is: + +1. mutate canonical workspace state +2. call `mark_layout_cache_dirty()` if structure/bounds changed +3. call `sync_layout_derived_state()` or `sync_task_derived_state()` +4. run any file-tree or session-selection follow-up +5. `cx.notify()` if the UI should repaint + +Examples that follow this pattern: + +- next layout +- toggle sidebar +- focus/select session +- top bar layout selection + +## Where To Edit + +If you need to change: + +- terminal header fields: + - `gpui/derived_state.rs` + +- task board counters or task snapshot contents: + - `gpui/derived_state.rs` + +- top bar event behavior: + - `gpui/ui_events.rs` + - `top_bar_render.rs` + +- icon rail event behavior: + - `gpui/ui_events.rs` + - `icon_rail_render.rs` + +- empty-cell click behavior: + - `gpui/ui_events.rs` + +- session selection side effects: + - `gpui/layout_sync.rs` + +- resize throttling or collapsed-resize guards: + - `gpui/layout_sync.rs` + - `impl_pointer_interactions.rs` + +- split divider drag behavior: + - `split_render.rs` + - `impl_pointer_interactions.rs` + +- pane tabs, badges, or pane `+` behavior: + - `pane_header_render.rs` + +- key handling or IME behavior: + - `gpui.rs` + - `impl_keyboard.rs` + +## Cross-Platform Notes + +The most platform-sensitive GPUI behavior in this area is terminal resize and +text input: + +- collapsed intermediate layouts must not force PTYs to `1x1` +- IME and key handling must preserve correct behavior on macOS and Windows +- terminal font metrics are cached because font-system behavior differs by + platform and is too expensive to recompute on every frame + +When changing layout or input behavior, run the full gate and then manually +check both macOS and Windows UI behavior. diff --git a/docs/architecture/workspace/module-map.md b/docs/architecture/workspace/module-map.md new file mode 100644 index 00000000..4b1a09ad --- /dev/null +++ b/docs/architecture/workspace/module-map.md @@ -0,0 +1,291 @@ +# Workspace Module Map + +This file maps the workspace source tree to responsibilities. Use it when you +need to find the right module quickly. + +## Entry Points + +### `workspace/mod.rs` + +Owns module declarations and the public surface: + +- `pub use core::{CellInfo, Workspace}` +- `pub use gpui::WorkspaceView` behind `gpui-full` + +This file is intentionally boring. If behavior changes require touching +`workspace/mod.rs`, the change is probably architectural rather than local. + +### `core.rs` + +Canonical workspace model: + +- session placement and removal +- grid/split-tree layout state +- focus movement +- cell bounds and visible session calculation + +`core.rs` should stay free of GPUI rendering details. + +### `gpui.rs` + +Primary UI root: + +- defines `WorkspaceView` +- owns constructor wiring and grouped UI state +- keeps GPUI trait impls easy to find +- coordinates render-time orchestration + +Helper clusters that extend the root now live under `workspace/gpui/`. + +### `impl_output_polling.rs` + +Primary runtime/polling root: + +- shared polling constants and helper types +- adaptive maintenance cadence +- detector maintenance orchestration +- clipboard preview updates +- kill-switch / shadow-mode toggles for the output pipeline transition + +Helper clusters that extend the root now live under +`workspace/impl_output_polling/`. + +## `workspace/gpui/` Submodules + +### `session_metadata.rs` + +Leaf helpers used by higher-level reducers: + +- derive project names from session metadata +- resolve task titles from cached or fallback inputs + +This module should remain dependency-light. + +### `derived_state.rs` + +Mutation-driven UI reducers: + +- task board snapshot/count refresh +- terminal header synchronization +- empty-cell synchronization +- explicit derived-state refresh entry points + +This module converts canonical session/task state into UI-facing cached state. + +### `ui_events.rs` + +Component event translation: + +- task board and empty-cell event draining +- top bar events -> workspace mutations +- icon rail events -> drawer/settings actions + +This is where component-local UI events become `WorkspaceView` actions. + +### `layout_sync.rs` + +Layout and focus follow-up: + +- layout cache invalidation +- focus-sensitive layout refresh +- session selection helpers +- terminal cell metrics and PTY resize coordination + +This module is the main bridge between UI layout changes and terminal runtime +effects. + +## `workspace/impl_output_polling/` Submodules + +### `output_runtime.rs` + +Hot path for terminal output: + +- event-driven output scheduling +- focused-session prioritization +- background output preparation +- prepared output application back on the UI thread + +If output appears late or the wrong session gets priority, start here. + +### `status_reconcile.rs` + +Status application and side effects: + +- reconciles detector and cached CLI hints via `status_engine` +- clears stale cached status +- applies task-state side effects +- triggers compaction completion and auto-assign follow-up + +If status changes are correct but the UI/task side effects are wrong, start +here. + +### `cli_pollers.rs` + +Background polling for log-backed CLIs: + +- Codex/Gemini JSONL reads +- rollout / execution-mode inference +- CLI-type detection fallback +- cache updates and notifications from JSONL snapshots + +### `hook_signals.rs` + +Background polling for hook signal files: + +- signal-file scanning +- stale signal guards +- session-id resolution +- hook-derived status and CLI metadata updates + +### `git_refresh.rs` + +Background git refresh coordination: + +- bulk git refresh scheduling +- apply refreshed git info to headers and session snapshots + +### `terminal_input.rs` + +Terminal follow-up helpers: + +- deferred Enter handling +- VTE DSR/DA response forwarding +- compaction timeout cleanup + +## Other Important Workspace Modules + +### Operational `impl_*` files + +These extend `WorkspaceView` outside the two split roots: + +- `impl_session_lifecycle.rs` + - create, restore, close, bootstrap, resume + +- `impl_keyboard.rs` + - keyboard shortcuts and keybinding-driven actions + +- `impl_task_board.rs` + - task board mutations and modal/task operations + +- `impl_modals.rs` + - modal state transitions + +- `impl_file_tree.rs` + - file tree / focused-session path sync + +- `impl_clipboard.rs` + - clipboard actions and session clipboard integration + +- `impl_settings.rs` + - settings page behavior + +- `impl_action_handlers.rs` + - GPUI action callbacks that delegate into higher-level helpers + +- `impl_ui_operations.rs` + - broader UI helpers that do not fit cleanly into render or polling roots + +### Rendering files + +These mostly build UI elements rather than owning long-lived behavior: + +- `render.rs` + - top-level composition for the workspace body + +- `grid_render.rs` + - grid-layout composition + - split-vs-grid render dispatch + - shared session-cell rendering + +- `drawer_render.rs` + - drawer panels and left-side content + +- `split_render.rs` + - split-tree recursion + - divider rendering and drag hit areas + - empty split-slot rendering + +- `pane_header_render.rs` + - pane-header tabs + - header badges and title rows + - pane-local `+` session creation affordance + +- `task_board_render.rs` + - task board UI and task cards + +- `top_bar_render.rs` + - top bar layout/profile UI + +- `icon_rail_render.rs` + - icon rail chrome and click surfaces + +- `modal_render.rs` + - modal composition + +- `terminal_render.rs` + - terminal-specific render helpers + +### Pointer interaction helpers + +- `impl_pointer_interactions.rs` + - split-resize drag reducers + - session-drag move/finalize reducers + - workspace-global gesture completion/cancellation + +### State containers + +These group related state to keep `WorkspaceView` readable: + +- `clipboard_state.rs` +- `project_state.rs` +- `settings_state.rs` +- `persistence_state.rs` +- `types.rs` + +## Dependency Rules + +The current structure tries to preserve these rules: + +- `core.rs` remains the canonical layout/session model. +- `gpui.rs` stays readable as the main UI root. +- `impl_output_polling.rs` stays readable as the main polling root. +- child helper modules extend a root through `impl WorkspaceView` rather than + introducing new public entry points. +- sibling helper modules coordinate through root-owned methods on + `WorkspaceView`, not by importing one another's private helpers. + +## Where To Start Reading + +For common tasks: + +- "How is the whole workspace assembled?" + - `workspace/mod.rs` + - `gpui.rs` + - `render.rs` + +- "Why did a layout or selection change affect terminal sizing?" + - `gpui/layout_sync.rs` + - `grid_render.rs` + - `split_render.rs` + +- "Why did a header drag or divider drag behave strangely?" + - `impl_pointer_interactions.rs` + - `pane_header_render.rs` + - `split_render.rs` + +- "Where do pane tabs or pane-header badges come from?" + - `pane_header_render.rs` + - `gpui/derived_state.rs` + +- "Why is a session badge wrong?" + - `impl_output_polling/status_reconcile.rs` + - `status_engine.rs` + - `gpui/derived_state.rs` + +- "Why is output delayed or missing?" + - `impl_output_polling/output_runtime.rs` + - `output_dispatcher.rs` + +- "Why didn't a task board/header update happen?" + - `gpui/derived_state.rs` + - `impl_task_board.rs` diff --git a/docs/architecture/workspace/output-polling.md b/docs/architecture/workspace/output-polling.md new file mode 100644 index 00000000..0847a555 --- /dev/null +++ b/docs/architecture/workspace/output-polling.md @@ -0,0 +1,227 @@ +# Output Polling And Status + +This document explains the runtime side of the workspace module: PTY output, +status updates, background polling, and related side effects. + +## What `impl_output_polling.rs` Owns + +`crates/codirigent-ui/src/workspace/impl_output_polling.rs` is the polling +root. It keeps: + +- shared constants and helper types +- adaptive polling cadence +- detector maintenance orchestration +- clipboard preview maintenance +- stale proposal cleanup +- output-pipeline feature toggles (`LEGACY_PIPELINE`, `SHADOW_STATUS`) + +It delegates narrower responsibility clusters into child modules under +`workspace/impl_output_polling/`. + +## Polling Model + +There are two cadences: + +### Fast output cadence + +Used when terminals are actively producing output. + +Main responsibilities: + +- drain PTY output +- apply terminal runtime snapshots +- keep focused sessions responsive + +Key entry point: + +- `poll_output()` in `output_runtime.rs` + +### Slower maintenance cadence + +Used for work that does not need to run every active frame. + +Main responsibilities: + +- hook signal scanning +- JSONL checks +- git refresh +- detector maintenance +- clipboard preview updates +- compaction timeout cleanup + +Key entry point: + +- `poll_maintenance()` in `impl_output_polling.rs` + +## Child Modules + +### `output_runtime.rs` + +Hot-path output scheduling and application. + +Owns: + +- event-driven session readiness via `output_dispatcher` +- focused-session prioritization +- legacy fallback drain of `sessions_with_pending_output()` +- background preparation of drained PTY output +- UI-thread application of prepared output + +Start here when: + +- output is delayed +- the wrong session is prioritized +- a session without a terminal runtime gets dropped instead of retried + +### `status_reconcile.rs` + +Applies session status and all major side effects. + +Inputs: + +- detector state +- cached hook/JSONL status +- prior workspace session status + +Owns: + +- call into `status_engine::reconcile` +- expire stale cached status +- update workspace session status +- task transition side effects +- context clear / compaction follow-up +- auto-assign follow-up on returning to idle + +Start here when: + +- a status badge is wrong even though raw detector/log inputs seem correct +- status transitions do not trigger the right task or compaction behavior + +### `cli_pollers.rs` + +Background JSONL and rollout polling for Codex and Gemini. + +Owns: + +- JSONL input collection +- process-tree CLI detection fallback +- Codex session-id and execution-mode inference +- ambiguity guards when multiple Codex sessions share a working directory +- cache updates and notifications from JSONL results + +Start here when: + +- Codex/Gemini status does not update +- execution mode inference is wrong +- multiple Codex sessions in one directory interfere with one another + +### `hook_signals.rs` + +Background hook-signal ingestion. + +Owns: + +- signal-file scanning +- process-start epoch guard +- stale-signal rejection +- CLI session-id backfill from hook metadata +- hook-derived status and CLI metadata updates + +Start here when: + +- Claude Code hook updates are missing +- a hook signal is applied to the wrong session +- hook-derived `cli_session_id` or execution mode is wrong + +### `git_refresh.rs` + +Background git refresh. + +Owns: + +- refresh scheduling +- applying refreshed git info to headers and cached sessions + +### `terminal_input.rs` + +Terminal follow-up helpers not directly tied to output draining. + +Owns: + +- deferred Enter handling +- VTE response forwarding +- compaction timeout cleanup + +This is the place to look when the shell is waiting for an expected terminal +response or when post-command follow-up input timing is wrong. + +## Status Data Sources + +Status is not driven by one source. The system combines multiple hints: + +- detector state from `InputDetector` +- hook-derived status for Claude Code +- JSONL-derived status for Codex/Gemini +- stale-cache handling rules + +The actual arbitration happens in: + +- `status_engine.rs` +- `status_providers.rs` +- `impl_output_polling/status_reconcile.rs` + +Practical rule: + +- if the wrong raw data is entering the system, fix `hook_signals.rs` or + `cli_pollers.rs` +- if the raw data is correct but the chosen status is wrong, fix + `status_engine.rs` / `status_reconcile.rs` + +## Output Flow + +The normal output path is: + +1. background session runtime marks output ready +2. `SessionUpdate` events are drained into `output_dispatcher` +3. ready sessions are prioritized, focused first +4. output is prepared in the background +5. terminal runtime snapshots are applied on the UI thread +6. status/header follow-up is synchronized + +The legacy broad-scan path still exists behind `CODIRIGENT_LEGACY_PIPELINE` as +a transition fallback. + +## Cross-Platform Notes + +The polling layer has real platform sensitivity: + +- Windows terminal behavior is more sensitive to missed PTY resizes and DSR + responses. +- Hook signal and JSONL path handling must tolerate Windows and macOS path + conventions. +- Process detection and child-pid behavior can vary between platforms. + +When changing output or status behavior, validate: + +- normal interactive output +- Claude hook-driven status +- Codex/Gemini JSONL status +- compaction follow-up +- terminal response forwarding on shells that expect DSR/DA replies + +## Related Files Outside The Split + +- `output_dispatcher.rs` + - ready/in-flight session scheduling state + +- `status_engine.rs` + - pure-ish reconciliation logic + +- `status_providers.rs` + - status hint source types and stale actions + +- `types.rs` + - cached CLI status structures and shared polling types + +- `project_state.rs` + - working-directory and project-root behavior used by polling follow-up diff --git a/docs/hidden-session-plan.md b/docs/hidden-session-plan.md deleted file mode 100644 index f37d3ea4..00000000 --- a/docs/hidden-session-plan.md +++ /dev/null @@ -1,131 +0,0 @@ -# Hidden Session Access Plan - -## Purpose - -Define the first-pass fix for GitHub issue `#13`: when the active layout shows fewer sessions than exist in the workspace, every session must remain reachable without forcing a layout change. - -This plan intentionally does not solve tab grouping. Tab grouping remains a separate enhancement. - -## Problem - -Today, a layout such as `2x2` can only display four panes at once. If the workspace has a fifth session, that session exists but may not be reachable from the current layout unless the user changes the layout and repositions panes. - -That behavior is a UX bug because the session list shows the workspace contains the session, but the user cannot directly bring it into view. - -## Goals - -- Keep compact layouts such as `2x2` and `3x3` viable even when more sessions exist. -- Preserve visible pane positions unless the user explicitly rearranges them. -- Reuse an interaction users already understand from single/focus layout behavior. -- Fix discoverability through the existing Sessions drawer instead of introducing a new modal or overflow manager. - -## Non-Goals - -- No automatic tab creation. -- No layout auto-expansion. -- No pane reflow or global session reshuffle. -- No new overflow tray, modal picker, or separate hidden-session panel. - -## Proposed UX - -Use the Sessions drawer as the source of truth for all sessions: - -- If the user clicks a session that is already visible in the current layout, focus it. -- If the user clicks a session that is not currently visible, show it in the currently focused pane. -- The session that was previously displayed in the focused pane becomes hidden. -- No other visible panes move. - -This makes the interaction consistent with focus mode: - -- Click a session to view it in the current visible context. - -## Visibility Model - -The workspace should treat sessions as one of two states: - -- Visible: assigned to a currently rendered pane/slot. -- Hidden: exists in the workspace but is not assigned to a visible pane because the layout capacity is smaller than the total session count. - -The Sessions drawer should continue to show all sessions, not only visible ones. - -## Interaction Rules - -### Clicking From The Sessions Drawer - -- Visible session row: - Focus that session normally. -- Hidden session row: - Replace the session in the currently focused pane with the clicked hidden session. - -### Focus Requirement - -- The replacement target is always the currently focused pane. -- If there is no focused pane but at least one visible session exists, fall back to the layout's current focused session semantics. -- If there are no visible sessions, do nothing. - -### Ordering Rule - -- Hidden-session reveal should behave as a true swap between: - - the clicked hidden session, and - - the session currently shown in the focused pane. -- This keeps ordering stable and avoids silently re-packing the workspace. - -## UI Expectations - -The Sessions drawer should expose visibility clearly: - -- Visible sessions render as normal. -- Hidden sessions should display a subtle `Hidden` indicator, dimmed styling, or equivalent compact affordance. - -No confirmation dialog should appear for the swap. The action should be immediate. - -## Implementation Outline - -### Workspace/Core - -Add explicit support for swapping a hidden session with a visible session without changing the layout structure. - -Expected core behavior: - -- Detect whether a clicked session is currently visible. -- If hidden, replace the focused visible assignment with the hidden session. -- Move the replaced session into the hidden set while preserving stable ordering. - -The layout structure must remain unchanged. - -### Drawer/UI - -Update the Sessions drawer row interaction: - -- Visible row click keeps current focus behavior. -- Hidden row click triggers the hidden-to-focused swap behavior. - -Add visual differentiation for hidden rows. - -### Derived State - -Any session reveal/swap must refresh: - -- focused session state -- drawer selection state -- file tree synchronization -- terminal header focus state -- cached layout-derived UI state - -## Testing Plan - -Add or update tests for: - -- Hidden sessions remain listed in the Sessions drawer. -- Clicking a hidden session swaps it into the focused pane. -- The replaced visible session becomes hidden. -- Other visible panes remain unchanged. -- Focus follows the revealed session. -- Single layout behavior is unchanged. -- Split-tree layouts use the same focused-pane replacement rule. - -## Rollout Notes - -This should land as the narrow fix for issue `#13`. - -Tab grouping can build on top of this later, but should not be coupled to this change. Keeping them separate reduces risk and keeps the hidden-session behavior understandable on its own. diff --git a/docs/resizable-split-pane-plan.md b/docs/resizable-split-pane-plan.md deleted file mode 100644 index cb8f3de9..00000000 --- a/docs/resizable-split-pane-plan.md +++ /dev/null @@ -1,162 +0,0 @@ -# Resizable Split Pane Plan - -## Purpose - -Define the implementation plan for resizable split panes so users can adjust split proportions beyond the current fixed `50/50` behavior, such as `75/25`, by dragging split dividers directly in the workspace. - -## Problem - -The current split-tree layout model supports ratios internally, but the primary user-facing split actions create panes at `0.5` and there is no direct workspace interaction for changing that proportion afterward. - -Users want to keep compact custom layouts while controlling how much space each pane gets. A common example is making one pane take roughly `3/4` of the available height or width. - -## Goals - -- Let users resize split-tree panes by dragging dividers. -- Support arbitrary split ratios within reasonable limits. -- Preserve the existing header drag behavior for session movement/reordering. -- Persist custom split ratios so session resume restores the same layout proportions. -- Keep the behavior predictable and visually clear. - -## Non-Goals - -- No divider resizing for fixed grid layouts. -- No keyboard-only resize controls in this change. -- No preset-ratio-only solution; direct dragging is required. -- No refactor of the existing pane header drag model beyond what is needed to coordinate drag modes safely. - -## Current State - -The codebase already contains most of the underlying ratio support: - -- split-tree nodes already store a `ratio: f32` -- the layout tree already supports `set_ratio_for_slot()` -- split layout state already supports `resize_split()` -- divider hit-testing already exists in the split layout calculator -- layout persistence already serializes `LayoutMode::SplitTree { root }` - -This means the missing feature is primarily the workspace interaction layer and drag-state coordination, not the core layout math. - -## Proposed UX - -### Divider Drag - -- When the workspace is in split-tree mode, the divider between two panes should be draggable. -- Hovering a divider should show the correct resize cursor: - - horizontal split divider: vertical resize cursor - - vertical split divider: horizontal resize cursor -- Mouse down on a divider enters split-resize drag mode. -- Dragging updates the ratio continuously as the pointer moves. -- Mouse up commits the new ratio. - -### Pane/Header Drag Compatibility - -Header drag and divider drag must remain separate interactions: - -- pane header drag starts only from header bounds -- divider drag starts only from divider bounds -- terminal selection starts only from terminal content - -Once one drag mode has started, the others must be suppressed until mouse-up. - -## Interaction Model - -The workspace should treat pointer interactions as mutually exclusive modes. - -Recommended conceptual state: - -- `None` -- `SessionReorderDrag` -- `SplitResizeDrag` - -`SplitResizeDrag` should carry enough information to update the correct split ratio as the pointer moves, including: - -- the divider/split being resized -- the drag start point -- the original ratio -- the relevant layout bounds - -## Ratio Behavior - -- Ratios should remain clamped to safe limits. -- The existing core clamp behavior should remain authoritative. -- Dragging should feel continuous, not snap to a few preset percentages. - -The first implementation should preserve minimum pane sizes through the existing layout clamp behavior and any current split-tree minimum-cell logic. - -## Layout Scope - -Resizable dividers should apply only to split-tree layouts. - -Grid layouts should remain fixed-profile layouts such as `2x2`, `2x3`, and `3x3`. If users want custom uneven pane sizing, they should be in split-tree mode. - -## Persistence And Resume - -Custom split ratios must survive app restart and session restore. - -This should work through the existing layout persistence path: - -- current layout is saved as `LayoutMode::SplitTree { root }` -- split ratios are stored within the layout tree -- restore re-applies the saved split tree - -This feature must validate that resized split ratios are correctly restored, not only that the split tree shape is restored. - -## Implementation Outline - -### Workspace Interaction State - -Extend the workspace pointer interaction model to support split-resize dragging alongside existing pane/header drag behavior. - -Expected responsibilities: - -- detect divider hover/hit in split-tree mode -- start resize drag on divider mouse down -- update ratio during pointer move -- finish resize drag on mouse up -- prevent interaction overlap with header drag and terminal selection - -### Divider Hit Testing - -Use the existing split layout divider hit-testing to determine whether the pointer is over a divider and which split should be resized. - -The divider hit area should be explicit and reliable so users can easily discover and use it. - -### Ratio Update Path - -Translate pointer movement into a new ratio for the affected split and apply it through the existing split-tree resize API. - -This should update the layout in real time during drag so users can see the effect immediately. - -### Cursor Feedback - -Add cursor feedback on divider hover and drag so users understand when a resize gesture will occur instead of a header move or terminal selection. - -### Persistence - -Ensure ratio changes trigger the normal layout persistence path so resized layouts are included in saved state without requiring separate persistence logic. - -## Testing Plan - -Add or update tests for: - -- divider hit-testing identifies the correct divider in split-tree layouts -- dragging a divider updates split ratio away from `0.5` -- ratio updates are clamped correctly at the allowed bounds -- header drag does not start when the pointer begins on a divider -- divider drag does not start when the pointer begins on a pane header -- terminal selection does not interfere with an active divider drag -- resized split layouts persist to saved state with the updated ratio -- restored split layouts preserve the saved ratio, not only the split-tree shape -- nested split trees resize the intended parent split rather than an unrelated branch - -## Rollout Notes - -This feature should be implemented as a split-tree enhancement, not as a general grid-layout resize system. - -The finished behavior should be: - -- create split layout -- drag divider to any practical ratio such as `3/4` -- keep using the workspace normally -- quit and resume later with the same split proportions intact diff --git a/docs/session-shell-selection-plan.md b/docs/session-shell-selection-plan.md deleted file mode 100644 index 01692a68..00000000 --- a/docs/session-shell-selection-plan.md +++ /dev/null @@ -1,162 +0,0 @@ -# Session Shell Selection Plan - -## Purpose - -Define the full implementation scope for GitHub issue `#12`: allow users to choose which shell environment a session opens with, such as `bash`, `zsh`, `pwsh`, `powershell`, or `cmd`, and preserve that choice across session persistence and restore. - -## Problem - -Users may work across multiple shell environments depending on project or platform needs. The application already supports a global default shell, but that is not sufficient when users want different sessions to run in different shells at the same time. - -The product requirement is per-session shell choice at creation time, with persistence and restore behavior that keeps sessions consistent across restarts. - -## Goals - -- Let users choose a shell when creating a session. -- Support all session creation entry points consistently. -- Persist the chosen shell as part of session state. -- Restore sessions using their original shell choice. -- Show the selected shell in the UI. -- Handle missing shells on restore in a predictable way. - -## Non-Goals - -- No live in-place shell mutation for an already running PTY. -- No freeform shell command text entry in the first implementation. -- No separate duplicate-session or clone-session behavior. - -## Creation Entry Points - -Shell selection should be available anywhere a new session can be created: - -- clicking an empty pane -- clicking the pane-level `+` button in a tabbed pane - -Both entry points should use the same create-session flow so behavior stays consistent. - -## Shell Selection UX - -The create-session flow should include a shell selector populated from detected available shells. - -Expected options: - -- `Auto` -- detected installed shells for the current platform - -Examples: - -- macOS/Linux: `bash`, `zsh`, `sh` -- Windows: `pwsh`, `powershell`, `cmd` - -The selector should use friendly labels where possible, while still storing the exact shell identifier needed by the session manager. - -## Behavior Model - -### Auto - -- `Auto` means the session should use the application's existing default-shell behavior. -- This should continue to respect the global default shell setting, or platform default behavior if the global setting is unset. - -### Explicit Shell - -- If the user selects a shell explicitly, that choice applies only to the new session being created. -- It overrides `Auto` for that session. - -## Persistence - -The selected shell must be stored as part of persistent session state. - -This should be represented on: - -- `Session` -- `PersistentSession` - -It should not live only in transient UI state or be inferred only from the launch path. - -## Restore Behavior - -On restore: - -- if the saved shell is still available, restore the session with that shell -- if the saved shell is unavailable, restore the session using `Auto` - -The application should surface a clear warning that the originally requested shell was unavailable and that `Auto` was used instead. - -Restore should not silently swap shells without feedback, and it should not drop the session entirely because the requested shell is missing. - -## UI Visibility - -The selected shell should be visible in the session UI so users can verify environment at a glance. - -Recommended places: - -- session details or session menu -- compact session/pane header indicator where space permits - -The shell display should distinguish between: - -- `Auto` -- explicit shell selections such as `bash` or `pwsh` - -## Changing Shell After Creation - -Shell choice should be treated as a session launch property, not a live mutable terminal property. - -That means: - -- changing shell for an existing session should not attempt to mutate the running PTY in place -- if the product later exposes a shell-change action, it should be modeled as reopening or recreating the session with a different shell - -This issue does not require implementing that action now, but the underlying data model should not imply live shell mutation is supported. - -## Implementation Outline - -### Session Model - -Extend the session domain model so a session can carry its shell choice explicitly. - -Expected responsibilities: - -- represent `Auto` versus explicit shell choice -- persist the chosen value -- restore it faithfully - -### Create Flow - -Update the create-session UI flow used by empty-pane creation and pane `+` creation so it includes shell selection and passes the chosen shell through to session bootstrap. - -### Restore Flow - -Update restore planning and bootstrap so restored sessions use their stored shell value, with fallback-to-`Auto` if the shell is missing. - -### Availability Detection - -Use the existing shell detection mechanism as the source of available shell choices and restore validation. - -### Warning Surface - -When restore falls back to `Auto`, surface a warning in a user-visible way so the mismatch is not silent. - -## Testing Plan - -Add or update tests for: - -- creating a session with `Auto` -- creating a session with an explicit shell -- persisting the selected shell into saved state -- restoring a session with the same shell when available -- restoring a session with fallback to `Auto` when the saved shell is unavailable -- warning generation for unavailable saved shells -- consistent shell-selection behavior across both creation entry points - -## Rollout Notes - -This feature should be implemented as a full per-session shell-selection workflow, not only as a one-time creation override. - -The expected finished behavior is: - -- user chooses shell at creation time -- shell is stored with the session -- restore uses the same shell when possible -- restore falls back to `Auto` with clear warning when necessary -- the UI shows what shell the session is using diff --git a/docs/tab-grouping-plan.md b/docs/tab-grouping-plan.md deleted file mode 100644 index 5791719f..00000000 --- a/docs/tab-grouping-plan.md +++ /dev/null @@ -1,180 +0,0 @@ -# Tab Grouping Plan - -## Purpose - -Define the first-pass design for GitHub issue `#14`: allow users to group multiple sessions into tabs within a single pane. - -This feature is separate from the hidden-session fix. Hidden-session access remains the fallback for reaching sessions outside the current visible working set. Tab grouping is a manual layout tool for keeping more sessions active while preserving a compact visible layout such as `2x2` or `3x3`. - -## Problem - -Users may run more sessions than they want to display as panes at one time. The current workspace supports compact custom layouts, but each visible pane can only host one session. That forces users to either increase pane count or keep reshuffling layouts. - -Tabs should let users keep a stable layout while intentionally compressing related sessions into the same pane. - -## Goals - -- Keep visible layouts compact and stable. -- Let a pane hold multiple sessions as tabs. -- Make tab grouping a direct workspace interaction, not a menu-only action. -- Provide a pane-local way to create a new session once the grid is full. - -## Non-Goals - -- No automatic overflow-to-tabs behavior. -- No layout auto-expansion. -- No global session reshuffle. -- No tab tear-off or drag-out in v1. -- No cross-pane tab reordering UI in v1. -- No changes to the existing logical session grouping feature in the menu. - -## Key Distinction - -Two features named "grouping" must remain separate: - -- Existing session grouping: - Logical organization and color/group metadata in the session menu and drawer. -- New tab grouping: - Multiple sessions sharing a single visible pane. - -The existing menu grouping should not be repurposed for tabs. - -## Proposed UX - -### Tab Creation By Drag And Drop - -- Drag a pane header onto another pane header to group the dragged session into the target pane. -- Dropping on the target pane header creates or extends a tab stack in that pane. -- The dropped session becomes the active tab immediately. -- The target pane keeps its layout position. -- The source pane is removed from visible assignment if it becomes empty. - -### Existing Pane Drag Behavior - -- Drop on pane body: - Keep current swap/move behavior. -- Drop on pane header: - Group into tabs. - -This keeps the interaction explicit and avoids conflict with current reordering behavior. - -### Tab Switching - -- Clicking a tab in a pane header switches the visible session for that pane. -- Switching tabs does not change layout structure. -- Focus remains in the same pane. - -### Pane-Level New Session Button - -- Each pane header should include a small `+` button, similar to a browser tab strip. -- Clicking `+` creates a new session in that pane as a new tab. -- The new session becomes the active tab immediately. -- The pane keeps its current visible position. - -This addresses the current gap where adding a session is awkward once the visible layout is already full. - -## Initial Tab Rules - -- When dropping session `A` onto a pane currently showing session `B`, the resulting tab order is `[B, A]`. -- Session `A` becomes the active tab immediately after grouping. -- If a pane already has tabs, the dropped session is appended to the target tab stack and becomes active. -- If a pane contains only one session, the header still supports grouping and `+`. - -## Session Creation Rules - -For the pane-level `+` action in v1: - -- The new session should inherit the current pane's working directory/context. -- The new session should use the existing default new-session settings. -- This does not include per-session shell selection yet. That remains part of issue `#12`. - -## Close Behavior - -- Closing the active tab in a multi-tab pane reveals the next available tab in that pane. -- Closing a non-active tab removes it without affecting the active tab. -- Closing the last remaining tab behaves like closing the pane's only session today. - -## Layout Model - -Tabs should be modeled as a real part of workspace state, not as a render-only illusion. - -Recommended conceptual state: - -- each visible slot/pane owns an ordered tab stack of session IDs -- each slot/pane tracks which tab is active - -This is a better long-term fit than the current one-session-per-slot assumption and will make switching, persistence, closing, and future enhancements more coherent. - -## Sessions Drawer - -For v1, the Sessions drawer should continue to list sessions normally. - -Deferred for later: - -- showing tab membership in the drawer -- dragging from the drawer into tabs -- any drawer-specific tab-management UI - -The primary interaction surface for tabs should be the pane header itself. - -## Visual Expectations - -- A pane with one session can keep the current header look with a subtle `+` affordance added. -- A pane with multiple sessions should render a tab strip in its header. -- Active tab styling should remain visually aligned with the current workspace theme. -- The `+` affordance should be compact and clearly separate from existing session actions. - -## Implementation Outline - -### Workspace/Core - -Refactor pane assignment state so a visible pane can host multiple sessions and track one active session. - -Expected responsibilities: - -- create a tab stack in a target pane -- append sessions to an existing tab stack -- switch the active tab for a pane -- create a new session directly into a pane/tab stack -- close tabs while preserving pane stability - -### Drag And Drop - -Extend the existing drag system to distinguish between: - -- header-target drop for tab grouping -- body-target drop for swap/move - -This will likely require a more precise drop-target model than the current single target-index state. - -### Header Rendering - -Update pane header rendering so it can display: - -- a single-session header state -- a multi-tab strip state -- a pane-level `+` button - -### Persistence - -Tab stacks and active-tab selection should be persisted as part of workspace state so layout restoration preserves tab grouping. - -## Testing Plan - -Add or update tests for: - -- dragging a session onto another pane header creates a tab stack -- dropped session becomes active -- tab order matches `[target-existing..., dropped]` -- dropping on pane body preserves current swap behavior -- clicking a tab switches the visible session in place -- clicking `+` creates a new session as a tab in that pane -- closing tabs preserves the remaining tab stack correctly -- focus remains stable within the pane during tab switching -- persistence restores tab stacks and active-tab state - -## Rollout Notes - -This should land as a focused manual-grouping feature for issue `#14`. - -It should not absorb hidden-session overflow policy and should not depend on issue `#12`. Keeping those concerns separate will make the first version of tab grouping easier to reason about and lower-risk to ship. From d23e03676f599c1ccc33ee42dc169f2d70acad7a Mon Sep 17 00:00:00 2001 From: oso95 Date: Fri, 13 Mar 2026 02:59:14 -0400 Subject: [PATCH 9/9] Fix workspace test unwrap audit --- crates/codirigent-ui/src/workspace/tests.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/codirigent-ui/src/workspace/tests.rs b/crates/codirigent-ui/src/workspace/tests.rs index 68ec9dde..252b5490 100644 --- a/crates/codirigent-ui/src/workspace/tests.rs +++ b/crates/codirigent-ui/src/workspace/tests.rs @@ -711,8 +711,12 @@ fn test_workspace_resize_split_divider_updates_nested_layout_ratio() { ws.set_split_tree(tree); assert!(ws.resize_split_divider(SlotId(0), SlotId(2), 0.75)); + let split_tree = match ws.layout_state().as_split_tree() { + Some(split) => split, + None => panic!("expected split-tree layout"), + }; assert_eq!( - ws.layout_state().as_split_tree().unwrap().tree(), + split_tree.tree(), &LayoutNode::Split { direction: SplitDirection::Horizontal, ratio: 0.75, @@ -938,7 +942,10 @@ fn test_workspace_swap_sessions_split_tree_with_empty_slot() { // Swap S1 from slot 0 to empty slot 2 assert!(ws.swap_sessions(0, 2)); - let split = ws.layout_state().as_split_tree().unwrap(); + let split = match ws.layout_state().as_split_tree() { + Some(split) => split, + None => panic!("expected split-tree layout"), + }; assert_eq!(split.assignments()[0].1, None); assert_eq!(split.assignments()[2].1, Some(SessionId(1))); }