diff --git a/src/CodeShellManager/MainWindow.xaml b/src/CodeShellManager/MainWindow.xaml
index 543f13a..2fa5cce 100644
--- a/src/CodeShellManager/MainWindow.xaml
+++ b/src/CodeShellManager/MainWindow.xaml
@@ -228,23 +228,49 @@
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/CodeShellManager/MainWindow.xaml.cs b/src/CodeShellManager/MainWindow.xaml.cs
index 8dee9dc..6738057 100644
--- a/src/CodeShellManager/MainWindow.xaml.cs
+++ b/src/CodeShellManager/MainWindow.xaml.cs
@@ -46,7 +46,11 @@ public partial class MainWindow : Window
// Sidebar items for dormant (asleep) sessions — kept here so RebuildSidebarOrder
// can re-append them to the bottom of the list after rebuilding active items.
private readonly Dictionary _dormantSidebarItems = [];
-
+ // Anchor for shift-click range selection in the sidebar.
+ private string? _selectionAnchorId;
+ // Group-tab notification indicators (badge + text), keyed by group id (or "__ALL__"
+ // / GroupFilter.Ungrouped sentinels). Repopulated on every RebuildGroupStrip.
+ private readonly Dictionary _groupTabIndicators = [];
private SqliteConnection? _db;
private SearchService? _searchService;
private LayoutMode _currentLayout = LayoutMode.Single;
@@ -78,6 +82,38 @@ public MainWindow()
_layoutViewportOffset = 0;
RefreshTerminalLayout();
}
+ else if (args.PropertyName == nameof(MainViewModel.ActiveGroupId))
+ {
+ RebuildSidebarOrder();
+ UpdateGroupStripActiveState();
+ }
+ };
+
+ _vm.GroupsChanged += () => Dispatcher.Invoke(() =>
+ {
+ RebuildGroupStrip();
+ UpdateGroupStripVisibility();
+ RebuildSidebarOrder();
+ });
+ _vm.SelectionChanged += () => Dispatcher.Invoke(UpdateSidebarActiveState);
+ // Re-filter the sidebar when a session's GroupId changes — otherwise the current
+ // filter view stays stale until the user clicks a different tab. Also refresh the
+ // group-tab indicators since session-to-group membership just shifted.
+ _vm.SessionMembershipChanged += () => Dispatcher.Invoke(() =>
+ {
+ RebuildSidebarOrder();
+ UpdateGroupTabIndicators();
+ });
+ _vm.Sessions.CollectionChanged += (_, e) =>
+ {
+ // Skip on Move so the in-place reordering done by RecomputeWorktreeSiblings
+ // (and user drag-to-reorder) doesn't recurse back into itself or fight the user.
+ // Add/Remove/Reset are the cases that genuinely shift the sibling landscape.
+ if (e.Action != System.Collections.Specialized.NotifyCollectionChangedAction.Move)
+ {
+ RecomputeWorktreeSiblings();
+ UpdateGroupTabIndicators();
+ }
};
Loaded += OnLoaded;
@@ -99,6 +135,7 @@ public MainWindow()
BuildShortcutPanel();
SetupSidebarDrop();
+ SetupGroupStripDrop();
}
private void OnWindowBoundsChanged()
@@ -124,6 +161,10 @@ private async void OnLoaded(object sender, RoutedEventArgs e)
RestoreWindowState();
_windowStateReady = true;
+ // Build the group strip (it'll only show once there are groups + the setting is on).
+ RebuildGroupStrip();
+ UpdateGroupStripVisibility();
+
// Prune indexed output per retention policy (runs once at startup, after settings load)
if (_searchService != null)
{
@@ -247,27 +288,65 @@ private void BroadcastRemoteControl_Click(object sender, RoutedEventArgs e)
}
private void OpenNewSessionDialog(string defaultFolder = "")
+ => OpenNewSessionDialogCore(defaultFolder, parent: null);
+
+ ///
+ /// Opens the New Session dialog pre-filled with the parent session's folder, command, args.
+ /// The new session lands immediately after the parent in the sidebar and inherits its
+ /// GroupId + profile overrides (issue #27).
+ ///
+ private void OpenNewSessionDialogFromParent(SessionViewModel parent)
+ => OpenNewSessionDialogCore(parent.WorkingFolder, parent);
+
+ private void OpenNewSessionDialogCore(string defaultFolder, SessionViewModel? parent)
{
var profiles = _vm.Settings.ImportWindowsTerminalProfiles
? Services.WindowsTerminalProfileService.GetProfiles()
: null;
+ string folder = !string.IsNullOrEmpty(defaultFolder)
+ ? defaultFolder
+ : _vm.Settings.DefaultWorkingFolder;
+
var dialog = new NewSessionDialog(
- string.IsNullOrEmpty(defaultFolder) ? _vm.Settings.DefaultWorkingFolder : defaultFolder,
+ folder,
_vm.Settings.LaunchCommands,
- profiles)
+ profiles,
+ defaultCommand: parent?.Session.Command,
+ defaultArgs: parent?.Session.Args)
{
Owner = this
};
if (dialog.ShowDialog() != true) return;
+ // Group resolution priority:
+ // 1. Explicit selection from the dialog (currently unused — no group picker there)
+ // 2. Inherited from a parent session (spawn-near-parent flows)
+ // 3. The active group filter, when the user is currently looking at a real group
+ // (not All / not Ungrouped) — FilterStrip mode lands new sessions where the
+ // user is currently filtered.
+ // 4. The active session's group — InlineHeaders/None mode has no filter concept,
+ // so fall back to the group of the session the user was just working in.
+ // 5. Ungrouped
+ string? groupId = !string.IsNullOrEmpty(dialog.SelectedGroupId)
+ ? dialog.SelectedGroupId
+ : !string.IsNullOrEmpty(parent?.Session.GroupId)
+ ? parent!.Session.GroupId
+ : (_vm.ActiveGroupId != null && _vm.ActiveGroupId != GroupFilter.Ungrouped
+ ? _vm.ActiveGroupId
+ : !string.IsNullOrEmpty(_vm.ActiveSession?.GroupId)
+ ? _vm.ActiveSession!.GroupId
+ : null);
+
var session = _sessionManager.CreateSession(
dialog.SessionName,
dialog.SelectedFolder,
dialog.SelectedCommand,
dialog.SelectedArgs,
- dialog.SelectedGroupId);
+ groupId,
+ colorOverride: null,
+ afterSessionId: parent?.Id);
if (dialog.IsRemote)
{
@@ -278,19 +357,152 @@ private void OpenNewSessionDialog(string defaultFolder = "")
session.SshRemoteFolder = dialog.SshRemoteFolder;
}
- // Copy any profile-driven overrides onto the session so they persist + apply on launch
- session.ProfileFontFamily = dialog.ProfileFontFamily;
- session.ProfileFontSize = dialog.ProfileFontSize;
- session.ProfileFontWeight = dialog.ProfileFontWeight;
- session.ProfileFontLigatures = dialog.ProfileFontLigatures;
- session.ProfileCursorShape = dialog.ProfileCursorShape;
- session.ProfileCursorBlink = dialog.ProfileCursorBlink;
- session.ProfilePadding = dialog.ProfilePadding;
- session.ProfileBackgroundOpacity = dialog.ProfileBackgroundOpacity;
- session.ProfileRetroEffect = dialog.ProfileRetroEffect;
- session.ProfileColorSchemeJson = dialog.ProfileColorSchemeJson;
+ // Profile overrides come from the dialog (which may have copied from a Windows Terminal
+ // profile). When the dialog left them blank and we have a parent, inherit the parent's.
+ session.ProfileFontFamily = dialog.ProfileFontFamily ?? parent?.Session.ProfileFontFamily;
+ session.ProfileFontSize = dialog.ProfileFontSize ?? parent?.Session.ProfileFontSize;
+ session.ProfileFontWeight = dialog.ProfileFontWeight ?? parent?.Session.ProfileFontWeight;
+ session.ProfileFontLigatures = dialog.ProfileFontLigatures ?? parent?.Session.ProfileFontLigatures;
+ session.ProfileCursorShape = dialog.ProfileCursorShape ?? parent?.Session.ProfileCursorShape;
+ session.ProfileCursorBlink = dialog.ProfileCursorBlink ?? parent?.Session.ProfileCursorBlink;
+ session.ProfilePadding = dialog.ProfilePadding ?? parent?.Session.ProfilePadding;
+ session.ProfileBackgroundOpacity = dialog.ProfileBackgroundOpacity ?? parent?.Session.ProfileBackgroundOpacity;
+ session.ProfileRetroEffect = dialog.ProfileRetroEffect ?? parent?.Session.ProfileRetroEffect;
+ session.ProfileColorSchemeJson = dialog.ProfileColorSchemeJson ?? parent?.Session.ProfileColorSchemeJson;
+
+ _ = LaunchAndFollowUpWorktreesAsync(session, dialog.AdditionalWorktreePaths);
+ }
- _ = LaunchSessionAsync(session);
+ ///
+ /// Launches the primary session, then any opt-in sibling worktrees from the dialog —
+ /// each inheriting the primary's command, group, and profile overrides, and inserted
+ /// immediately after it so they cluster in the sidebar.
+ ///
+ private async Task LaunchAndFollowUpWorktreesAsync(ShellSession primary, IReadOnlyList additionalPaths)
+ {
+ await LaunchSessionAsync(primary);
+ if (additionalPaths.Count == 0) return;
+
+ // Stagger consecutive claude launches for the same reason the boot path does
+ // (see commit 59a7067): claude's CLI does an unlocked read-modify-write on
+ // ~/.claude.json at startup, and back-to-back launches can corrupt it.
+ string anchorId = primary.Id;
+ bool lastWasClaude = ClaudeSessionService.IsClaudeCommand(primary.Command);
+ foreach (var path in additionalPaths)
+ {
+ if (!System.IO.Directory.Exists(path)) continue;
+ bool isClaude = ClaudeSessionService.IsClaudeCommand(primary.Command);
+ if (isClaude && lastWasClaude) await Task.Delay(2000);
+ var sibling = _sessionManager.CreateSession(
+ System.IO.Path.GetFileName(path.TrimEnd('/', '\\')) ?? primary.Command,
+ path,
+ primary.Command,
+ primary.Args,
+ string.IsNullOrEmpty(primary.GroupId) ? null : primary.GroupId,
+ colorOverride: null,
+ afterSessionId: anchorId);
+ // Inherit profile so siblings look identical.
+ sibling.ProfileFontFamily = primary.ProfileFontFamily;
+ sibling.ProfileFontSize = primary.ProfileFontSize;
+ sibling.ProfileFontWeight = primary.ProfileFontWeight;
+ sibling.ProfileFontLigatures = primary.ProfileFontLigatures;
+ sibling.ProfileCursorShape = primary.ProfileCursorShape;
+ sibling.ProfileCursorBlink = primary.ProfileCursorBlink;
+ sibling.ProfilePadding = primary.ProfilePadding;
+ sibling.ProfileBackgroundOpacity = primary.ProfileBackgroundOpacity;
+ sibling.ProfileRetroEffect = primary.ProfileRetroEffect;
+ sibling.ProfileColorSchemeJson = primary.ProfileColorSchemeJson;
+ await LaunchSessionAsync(sibling);
+ anchorId = sibling.Id;
+ lastWasClaude = isClaude;
+ }
+ }
+
+ ///
+ /// Duplicates a session without a dialog: same folder, command, args, group, and
+ /// profile overrides; new GUID; a derived name like " (2)". Lands after parent.
+ ///
+ private async Task DuplicateSessionAsync(SessionViewModel parent)
+ {
+ var p = parent.Session;
+ string baseName = string.IsNullOrEmpty(p.Name) ? parent.DisplayName : p.Name;
+ var clone = _sessionManager.CreateSession(
+ DeriveDuplicateName(baseName),
+ p.WorkingFolder,
+ p.Command,
+ p.Args,
+ string.IsNullOrEmpty(p.GroupId) ? null : p.GroupId,
+ colorOverride: null,
+ afterSessionId: parent.Id);
+ if (p.IsRemote)
+ {
+ clone.IsRemote = true;
+ clone.SshUser = p.SshUser;
+ clone.SshHost = p.SshHost;
+ clone.SshPort = p.SshPort;
+ clone.SshRemoteFolder = p.SshRemoteFolder;
+ }
+ clone.ProfileFontFamily = p.ProfileFontFamily;
+ clone.ProfileFontSize = p.ProfileFontSize;
+ clone.ProfileFontWeight = p.ProfileFontWeight;
+ clone.ProfileFontLigatures = p.ProfileFontLigatures;
+ clone.ProfileCursorShape = p.ProfileCursorShape;
+ clone.ProfileCursorBlink = p.ProfileCursorBlink;
+ clone.ProfilePadding = p.ProfilePadding;
+ clone.ProfileBackgroundOpacity = p.ProfileBackgroundOpacity;
+ clone.ProfileRetroEffect = p.ProfileRetroEffect;
+ clone.ProfileColorSchemeJson = p.ProfileColorSchemeJson;
+ await LaunchSessionAsync(clone);
+ }
+
+ private string DeriveDuplicateName(string baseName)
+ {
+ // If baseName already ends with " (N)", increment; otherwise append " (2)".
+ var match = System.Text.RegularExpressions.Regex.Match(baseName, @"^(.*) \((\d+)\)$");
+ string stem = match.Success ? match.Groups[1].Value : baseName;
+ int start = match.Success ? int.Parse(match.Groups[2].Value) + 1 : 2;
+ var existing = new HashSet(
+ _vm.Sessions.Select(s => s.DisplayName), StringComparer.OrdinalIgnoreCase);
+ for (int n = start; n < start + 100; n++)
+ {
+ string candidate = $"{stem} ({n})";
+ if (!existing.Contains(candidate)) return candidate;
+ }
+ return $"{stem} ({start})";
+ }
+
+ ///
+ /// Launches a new session in an existing sibling worktree (path resolved via
+ /// `git worktree list`). Inherits the source session's command, group, and profile.
+ ///
+ private async Task LaunchSessionInSiblingWorktreeAsync(SessionViewModel parent, string worktreePath)
+ {
+ if (!System.IO.Directory.Exists(worktreePath))
+ {
+ MessageBox.Show(this, $"Worktree folder '{worktreePath}' does not exist.",
+ "Worktree missing", MessageBoxButton.OK, MessageBoxImage.Warning);
+ return;
+ }
+ var p = parent.Session;
+ var sibling = _sessionManager.CreateSession(
+ System.IO.Path.GetFileName(worktreePath.TrimEnd('/', '\\')) ?? p.Command,
+ worktreePath,
+ p.Command,
+ p.Args,
+ string.IsNullOrEmpty(p.GroupId) ? null : p.GroupId,
+ colorOverride: null,
+ afterSessionId: parent.Id);
+ sibling.ProfileFontFamily = p.ProfileFontFamily;
+ sibling.ProfileFontSize = p.ProfileFontSize;
+ sibling.ProfileFontWeight = p.ProfileFontWeight;
+ sibling.ProfileFontLigatures = p.ProfileFontLigatures;
+ sibling.ProfileCursorShape = p.ProfileCursorShape;
+ sibling.ProfileCursorBlink = p.ProfileCursorBlink;
+ sibling.ProfilePadding = p.ProfilePadding;
+ sibling.ProfileBackgroundOpacity = p.ProfileBackgroundOpacity;
+ sibling.ProfileRetroEffect = p.ProfileRetroEffect;
+ sibling.ProfileColorSchemeJson = p.ProfileColorSchemeJson;
+ await LaunchSessionAsync(sibling);
}
private static void Log(string msg)
@@ -464,10 +676,12 @@ private async Task LaunchSessionAsync(ShellSession session, bool restoring = fal
// Build sidebar entry
var sidebarItem = BuildSidebarItem(vm);
- SidebarSessionList.Children.Add(sidebarItem);
_sessionUi[session.Id] = (webView, terminalWrapper, sidebarItem);
_vm.RegisterSession(vm);
+ // RebuildSidebarOrder applies the active group filter; the explicit call here ensures
+ // a newly-launched session that doesn't match the filter is correctly hidden.
+ RebuildSidebarOrder();
RefreshTerminalLayout();
bridge.FitTerminal();
UpdateAlertBadge();
@@ -481,10 +695,16 @@ private Border BuildSidebarItem(SessionViewModel vm)
string accent = vm.AccentColor;
var accentBrush = new SolidColorBrush((Color)ColorConverter.ConvertFromString(accent));
+ // Note: the container carries a constant 2px BorderThickness with a Transparent
+ // BorderBrush by default. UpdateSidebarActiveState toggles BorderBrush to the
+ // session accent color when active, so layout doesn't shift as items become
+ // active and the active session stays visually distinct from the multi-select tint.
var container = new Border
{
Margin = new Thickness(0, 2, 0, 2),
Background = Brushes.Transparent,
+ BorderBrush = Brushes.Transparent,
+ BorderThickness = new Thickness(2),
Cursor = System.Windows.Input.Cursors.Hand,
CornerRadius = new CornerRadius(6),
Tag = vm.Id
@@ -587,6 +807,32 @@ void StartRename()
Visibility = Visibility.Collapsed
};
+ // Worktree-siblings subtitle — appears when 2+ live sessions share the same repo root.
+ var worktreeText = new TextBlock
+ {
+ Foreground = new SolidColorBrush(Color.FromRgb(0x93, 0x99, 0xb2)),
+ FontSize = 10,
+ FontStyle = FontStyles.Italic,
+ Margin = new Thickness(0, 1, 0, 0),
+ TextTrimming = TextTrimming.CharacterEllipsis,
+ Visibility = Visibility.Collapsed,
+ ToolTip = "This session shares a git repo with other open sessions."
+ };
+
+ void UpdateWorktreeText()
+ {
+ if (vm.HasWorktreeSiblings && !string.IsNullOrEmpty(vm.RepoRoot))
+ {
+ worktreeText.Text = vm.WorktreeSubtitle;
+ worktreeText.Visibility = Visibility.Visible;
+ }
+ else
+ {
+ worktreeText.Visibility = Visibility.Collapsed;
+ }
+ }
+ UpdateWorktreeText();
+
static void UpdateGitText(TextBlock tb, SessionViewModel svm)
{
if (!svm.GitInfoLoaded || string.IsNullOrEmpty(svm.GitBranch))
@@ -651,6 +897,7 @@ static void UpdateGitText(TextBlock tb, SessionViewModel svm)
textPanel.Children.Add(renameBox);
textPanel.Children.Add(folderText);
textPanel.Children.Add(gitText);
+ textPanel.Children.Add(worktreeText);
textPanel.Children.Add(alertBadge);
// Status dot (waiting indicator)
@@ -675,12 +922,15 @@ static void UpdateGitText(TextBlock tb, SessionViewModel svm)
var exploreBtn = MakeMiniButton("📁", "Open in Explorer", () => vm.OpenInExplorerCommand.Execute(null));
var psBtn = MakeMiniButton(">_", "Open PowerShell here", () => LaunchPowerShellInFolder(vm.WorkingFolder, vm.GroupId));
+ var spawnBtn = MakeMiniButton("➕", "New session here (inherits group + profile)",
+ () => OpenNewSessionDialogFromParent(vm));
var renameBtn = MakeMiniButton("✏", "Rename session", StartRename);
var sleepBtn = MakeMiniButton("💤", "Sleep session (keep it but stop the terminal)", () => SleepSession(vm));
var closeBtn = MakeMiniButton("✕", "Close session", () => vm.CloseCommand.Execute(null));
btnPanel.Children.Add(exploreBtn);
btnPanel.Children.Add(psBtn);
+ btnPanel.Children.Add(spawnBtn);
btnPanel.Children.Add(renameBtn);
btnPanel.Children.Add(sleepBtn);
btnPanel.Children.Add(closeBtn);
@@ -716,23 +966,63 @@ static void UpdateGitText(TextBlock tb, SessionViewModel svm)
}
};
- // Click to activate
- container.MouseLeftButtonDown += (_, _) =>
+ // Click to activate. Ctrl/Shift modifiers drive multi-select instead of activation.
+ container.MouseLeftButtonDown += (_, me) =>
{
+ var mods = Keyboard.Modifiers;
+ if ((mods & ModifierKeys.Shift) != 0)
+ {
+ // Range selection must walk only real session items — header tags like
+ // "groupheader:" (inline mode) and "cluster:" (worktree clusters) are not
+ // sessions and would otherwise leak into SelectedSessionIds.
+ var visibleIds = SidebarSessionList.Children.OfType()
+ .Select(b => b.Tag as string)
+ .Where(t => !string.IsNullOrEmpty(t)
+ && !t.StartsWith("dormant:")
+ && !t.StartsWith("groupheader:")
+ && !t.StartsWith("cluster:"))
+ .Select(t => t!)
+ .ToList();
+ _vm.SetRangeSelection(visibleIds, _selectionAnchorId, vm.Id);
+ me.Handled = true;
+ return;
+ }
+ if ((mods & ModifierKeys.Control) != 0)
+ {
+ _vm.ToggleSelection(vm.Id);
+ _selectionAnchorId = vm.Id;
+ me.Handled = true;
+ return;
+ }
+ _vm.ClearSelection();
+ _selectionAnchorId = vm.Id;
_vm.FocusSessionCommand.Execute(vm);
UpdateSidebarActiveState();
};
+ // Right-click context menu — supports multi-target actions when 2+ sessions are selected.
+ container.ContextMenu = BuildSessionContextMenu(vm);
+ container.ContextMenuOpening += (_, _) =>
+ {
+ container.ContextMenu = BuildSessionContextMenu(vm);
+ };
+
// Hover effect
+ // Hover effect — must not clobber multi-select tint. Selected-but-not-active items
+ // keep their blue background on hover and on mouse leave; only plain, unselected,
+ // non-active items show the muted hover background and clear to transparent.
container.MouseEnter += (_, _) =>
{
- if (vm.Id != _vm.ActiveSession?.Id)
- container.Background = new SolidColorBrush(Color.FromRgb(0x31, 0x32, 0x44));
+ if (vm.Id == _vm.ActiveSession?.Id) return;
+ if (_vm.IsSelected(vm.Id)) return;
+ container.Background = new SolidColorBrush(Color.FromRgb(0x31, 0x32, 0x44));
};
container.MouseLeave += (_, _) =>
{
- if (vm.Id != _vm.ActiveSession?.Id)
- container.Background = Brushes.Transparent;
+ if (vm.Id == _vm.ActiveSession?.Id) return;
+ container.Background = _vm.IsSelected(vm.Id)
+ ? new SolidColorBrush(Color.FromArgb(0x55, 0x89, 0xb4, 0xfa))
+ : Brushes.Transparent;
};
// Subscribe to property changes
@@ -749,6 +1039,7 @@ static void UpdateGitText(TextBlock tb, SessionViewModel svm)
case nameof(SessionViewModel.NeedsAttention):
alertBadge.Visibility = vm.NeedsAttention ? Visibility.Visible : Visibility.Collapsed;
UpdateAlertBadge();
+ UpdateGroupTabIndicators();
break;
case nameof(SessionViewModel.IsWaitingForInput):
@@ -769,12 +1060,43 @@ static void UpdateGitText(TextBlock tb, SessionViewModel svm)
{
statusDot.Visibility = Visibility.Collapsed;
}
+ UpdateGroupTabIndicators();
break;
case nameof(SessionViewModel.GitBranch):
case nameof(SessionViewModel.GitIsDirty):
case nameof(SessionViewModel.GitInfoLoaded):
UpdateGitText(gitText, vm);
+ UpdateWorktreeText();
+ break;
+
+ case nameof(SessionViewModel.RepoRoot):
+ RecomputeWorktreeSiblings();
+ UpdateWorktreeText();
+ break;
+
+ case nameof(SessionViewModel.HasWorktreeSiblings):
+ UpdateWorktreeText();
+ break;
+
+ case nameof(SessionViewModel.AccentColor):
+ // RepoRoot resolved → repaint stripe + active ring with the shared color
+ // so worktree siblings cluster visually.
+ try
+ {
+ var newAccent = (Color)ColorConverter.ConvertFromString(vm.AccentColor);
+ stripe.Background = new SolidColorBrush(newAccent);
+ if (_sessionUi.TryGetValue(vm.Id, out var ui))
+ {
+ ui.terminalWrapper.Tag = vm.AccentColor;
+ if (_vm.ActiveSession?.Id == vm.Id)
+ ui.terminalWrapper.BorderBrush = new SolidColorBrush(newAccent);
+ }
+ // Sidebar ring picks the accent up too when this session is active.
+ if (_vm.ActiveSession?.Id == vm.Id)
+ UpdateSidebarActiveState();
+ }
+ catch { /* invalid hex — ignore */ }
break;
}
});
@@ -807,10 +1129,34 @@ private void UpdateSidebarActiveState()
foreach (Border item in SidebarSessionList.Children)
{
string? id = item.Tag as string;
+ if (id == null || id.StartsWith("dormant:") || id.StartsWith("cluster:") || id.StartsWith("groupheader:")) continue;
bool isActive = id == _vm.ActiveSession?.Id;
- item.Background = isActive
- ? new SolidColorBrush(Color.FromRgb(0x31, 0x32, 0x44))
- : Brushes.Transparent;
+ bool isSelected = _vm.IsSelected(id);
+
+ // Background: selection takes precedence over active (so a multi-selected
+ // active session still shows it belongs to the action set). Active-only items
+ // get the lighter Catppuccin Surface2 so they stand out from the blue tints.
+ if (isSelected)
+ item.Background = new SolidColorBrush(Color.FromArgb(0x55, 0x89, 0xb4, 0xfa));
+ else if (isActive)
+ item.Background = new SolidColorBrush(Color.FromRgb(0x58, 0x5b, 0x70));
+ else
+ item.Background = Brushes.Transparent;
+
+ // Active gets an accent-colored ring around the whole item — the unique signal
+ // that survives any selection state, mirroring the active-terminal ring.
+ if (isActive)
+ {
+ string accentHex = "#89b4fa";
+ var vm = _vm.Sessions.FirstOrDefault(s => s.Id == id);
+ if (vm != null) accentHex = vm.AccentColor;
+ try { item.BorderBrush = new SolidColorBrush((Color)ColorConverter.ConvertFromString(accentHex)); }
+ catch { item.BorderBrush = new SolidColorBrush(Color.FromRgb(0x89, 0xb4, 0xfa)); }
+ }
+ else
+ {
+ item.BorderBrush = Brushes.Transparent;
+ }
}
UpdateActiveTerminalHighlight();
}
@@ -833,6 +1179,841 @@ private void UpdateActiveTerminalHighlight()
}
}
+ // ── Group strip (categories) ──────────────────────────────────────────────
+
+ private void UpdateGroupStripVisibility()
+ {
+ bool show = _vm.Settings.GroupDisplayMode == Models.GroupDisplayMode.FilterStrip
+ && _sessionManager.Groups.Count > 0;
+ GroupStripBorder.Visibility = show ? Visibility.Visible : Visibility.Collapsed;
+ GroupStripCol.Width = new GridLength(show ? 44 : 0);
+ }
+
+ private void RebuildGroupStrip()
+ {
+ GroupStripPanel.Children.Clear();
+ _groupTabIndicators.Clear();
+ if (_sessionManager.Groups.Count == 0) return;
+
+ GroupStripPanel.Children.Add(BuildGroupTab(null, "All", "▦"));
+ GroupStripPanel.Children.Add(BuildGroupTab(GroupFilter.Ungrouped, "Ungrouped", "·"));
+ foreach (var g in _sessionManager.Groups.OrderBy(g => g.SortOrder))
+ GroupStripPanel.Children.Add(BuildGroupTab(g.Id, g.Name, GroupInitials(g.Name)));
+
+ // Footer "+" tab to add a new group inline.
+ var addBtn = new Border
+ {
+ Margin = new Thickness(4, 8, 4, 4),
+ Background = Brushes.Transparent,
+ BorderBrush = new SolidColorBrush(Color.FromRgb(0x45, 0x47, 0x5a)),
+ BorderThickness = new Thickness(1),
+ CornerRadius = new CornerRadius(4),
+ Cursor = System.Windows.Input.Cursors.Hand,
+ ToolTip = "New group"
+ };
+ addBtn.Child = new TextBlock
+ {
+ Text = "+",
+ Foreground = new SolidColorBrush(Color.FromRgb(0xa6, 0xe3, 0xa1)),
+ FontSize = 16,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center,
+ Margin = new Thickness(0, 4, 0, 4)
+ };
+ addBtn.MouseEnter += (_, _) =>
+ addBtn.Background = new SolidColorBrush(Color.FromRgb(0x31, 0x32, 0x44));
+ addBtn.MouseLeave += (_, _) => addBtn.Background = Brushes.Transparent;
+ addBtn.MouseLeftButtonDown += (_, _) => PromptCreateGroup();
+ GroupStripPanel.Children.Add(addBtn);
+
+ UpdateGroupStripActiveState();
+ UpdateGroupTabIndicators();
+ }
+
+ ///
+ /// Refreshes the small status indicator on each group tab. Priority: alert count
+ /// (pink badge with N) > tool-approval (orange dot) > input-required (green dot) >
+ /// hidden. The "All" tab aggregates every live session; "Ungrouped" aggregates only
+ /// sessions with an empty GroupId.
+ ///
+ private void UpdateGroupTabIndicators()
+ {
+ if (_groupTabIndicators.Count == 0) return;
+
+ var pink = new SolidColorBrush(Color.FromRgb(0xf3, 0x8b, 0xa8));
+ var orange = new SolidColorBrush(Color.FromRgb(0xff, 0xb7, 0x4d));
+ var green = new SolidColorBrush(Color.FromRgb(0xa6, 0xe3, 0xa1));
+ var inkOnLight = new SolidColorBrush(Color.FromRgb(0x1e, 0x1e, 0x2e));
+
+ foreach (var (key, (badge, text)) in _groupTabIndicators)
+ {
+ IEnumerable set = key switch
+ {
+ "__ALL__" => _vm.Sessions,
+ _ when key == GroupFilter.Ungrouped =>
+ _vm.Sessions.Where(s => string.IsNullOrEmpty(s.GroupId)),
+ _ => _vm.Sessions.Where(s => s.GroupId == key)
+ };
+
+ int alerts = 0;
+ bool anyApproval = false, anyInput = false;
+ foreach (var s in set)
+ {
+ if (s.NeedsAttention) alerts++;
+ if (s.IsWaitingForApproval) anyApproval = true;
+ if (s.IsWaitingForInput) anyInput = true;
+ }
+
+ if (alerts > 0)
+ {
+ badge.Background = pink;
+ badge.MinWidth = 14;
+ text.Text = alerts.ToString();
+ text.Foreground = inkOnLight;
+ badge.ToolTip = alerts == 1
+ ? "1 session needs attention"
+ : $"{alerts} sessions need attention";
+ badge.Visibility = Visibility.Visible;
+ }
+ else if (anyApproval)
+ {
+ badge.Background = orange;
+ badge.MinWidth = 10;
+ text.Text = "";
+ badge.ToolTip = "Tool approval needed";
+ badge.Visibility = Visibility.Visible;
+ }
+ else if (anyInput)
+ {
+ badge.Background = green;
+ badge.MinWidth = 10;
+ text.Text = "";
+ badge.ToolTip = "Waiting for input";
+ badge.Visibility = Visibility.Visible;
+ }
+ else
+ {
+ badge.Visibility = Visibility.Collapsed;
+ }
+ }
+ }
+
+ private static string GroupInitials(string name)
+ {
+ if (string.IsNullOrWhiteSpace(name)) return "?";
+ var parts = name.Split(new[] { ' ', '-', '_' }, StringSplitOptions.RemoveEmptyEntries);
+ if (parts.Length == 0) return name[..1].ToUpperInvariant();
+ if (parts.Length == 1) return parts[0][..1].ToUpperInvariant();
+ return (parts[0][0].ToString() + parts[^1][0]).ToUpperInvariant();
+ }
+
+ ///
+ /// Builds one tab in the group strip. can be:
+ /// null (the "All" / no-filter tab), , or a real Id.
+ ///
+ private Border BuildGroupTab(string? groupId, string fullName, string label)
+ {
+ var border = new Border
+ {
+ Margin = new Thickness(4, 2, 4, 2),
+ Background = Brushes.Transparent,
+ BorderThickness = new Thickness(0, 0, 2, 0),
+ BorderBrush = Brushes.Transparent,
+ CornerRadius = new CornerRadius(4, 0, 0, 4),
+ Cursor = System.Windows.Input.Cursors.Hand,
+ ToolTip = fullName,
+ Tag = "group:" + (groupId ?? "__ALL__"),
+ Height = 36
+ };
+
+ var grid = new Grid();
+ var labelText = new TextBlock
+ {
+ Text = label,
+ Foreground = new SolidColorBrush(Color.FromRgb(0xa6, 0xad, 0xc8)),
+ FontSize = 11,
+ FontWeight = FontWeights.SemiBold,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center
+ };
+ grid.Children.Add(labelText);
+
+ // Notification indicator: pink badge with count when sessions in this group have
+ // NeedsAttention; orange/green dot when only waiting for approval/input.
+ // Hidden when the group has no active state. UpdateGroupTabIndicators recomputes it.
+ var indicatorText = new TextBlock
+ {
+ Text = "",
+ Foreground = new SolidColorBrush(Color.FromRgb(0x1e, 0x1e, 0x2e)),
+ FontSize = 8,
+ FontWeight = FontWeights.Bold,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center
+ };
+ var indicator = new Border
+ {
+ CornerRadius = new CornerRadius(7),
+ MinWidth = 10,
+ MinHeight = 10,
+ Padding = new Thickness(3, 0, 3, 0),
+ HorizontalAlignment = HorizontalAlignment.Right,
+ VerticalAlignment = VerticalAlignment.Top,
+ Margin = new Thickness(0, 3, 4, 0),
+ Visibility = Visibility.Collapsed,
+ Child = indicatorText,
+ IsHitTestVisible = false
+ };
+ grid.Children.Add(indicator);
+ border.Child = grid;
+
+ _groupTabIndicators[groupId ?? "__ALL__"] = (indicator, indicatorText);
+
+ border.MouseLeftButtonDown += (_, _) =>
+ {
+ _vm.ActiveGroupId = groupId;
+ };
+
+ // Real groups get a right-click menu + drag-to-reorder (the All/Ungrouped pseudo-tabs don't).
+ if (groupId != null && groupId != GroupFilter.Ungrouped)
+ {
+ var menu = new System.Windows.Controls.ContextMenu();
+
+ var moveUp = new System.Windows.Controls.MenuItem { Header = "Move up" };
+ moveUp.Click += (_, _) =>
+ {
+ int idx = IndexOfUserGroup(groupId);
+ if (idx > 0) _vm.MoveGroup(groupId, idx - 1);
+ };
+ menu.Items.Add(moveUp);
+ var moveDown = new System.Windows.Controls.MenuItem { Header = "Move down" };
+ moveDown.Click += (_, _) =>
+ {
+ int idx = IndexOfUserGroup(groupId);
+ if (idx >= 0 && idx < _sessionManager.Groups.Count - 1)
+ _vm.MoveGroup(groupId, idx + 1);
+ };
+ menu.Items.Add(moveDown);
+ menu.Items.Add(new System.Windows.Controls.Separator());
+
+ var rename = new System.Windows.Controls.MenuItem { Header = "Rename group…" };
+ rename.Click += (_, _) => PromptRenameGroup(groupId, fullName);
+ menu.Items.Add(rename);
+ var delete = new System.Windows.Controls.MenuItem { Header = "Delete group" };
+ delete.Click += (_, _) =>
+ {
+ var r = MessageBox.Show(
+ $"Delete group '{fullName}'? Sessions in this group will revert to Ungrouped.",
+ "Delete group", MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No);
+ if (r == MessageBoxResult.Yes) _vm.RemoveGroup(groupId);
+ };
+ menu.Items.Add(delete);
+ menu.Opened += (_, _) =>
+ {
+ int idx = IndexOfUserGroup(groupId);
+ moveUp.IsEnabled = idx > 0;
+ moveDown.IsEnabled = idx >= 0 && idx < _sessionManager.Groups.Count - 1;
+ };
+ border.ContextMenu = menu;
+
+ // Drag-to-reorder. The strip's Drop handler (SetupGroupStripDrop) resolves
+ // the new index from the drop position.
+ System.Windows.Point dragStartPos = default;
+ bool dragPending = false;
+ border.PreviewMouseLeftButtonDown += (_, me) =>
+ {
+ dragStartPos = me.GetPosition(null);
+ dragPending = true;
+ };
+ border.PreviewMouseLeftButtonUp += (_, _) => dragPending = false;
+ border.PreviewMouseMove += (_, me) =>
+ {
+ if (!dragPending || me.LeftButton != MouseButtonState.Pressed) return;
+ var diff = me.GetPosition(null) - dragStartPos;
+ if (Math.Abs(diff.X) > SystemParameters.MinimumHorizontalDragDistance ||
+ Math.Abs(diff.Y) > SystemParameters.MinimumVerticalDragDistance)
+ {
+ dragPending = false;
+ System.Windows.DragDrop.DoDragDrop(border, "group:" + groupId,
+ System.Windows.DragDropEffects.Move);
+ }
+ };
+ }
+
+ // Drop target: assign dragged session(s) to this group. The "All" tab (groupId=null)
+ // is a view, not a real group — don't accept drops there. "Ungrouped" accepts drops
+ // and clears the GroupId. Multi-select drops the whole selection when the dragged
+ // session is part of it.
+ if (groupId != null)
+ {
+ border.AllowDrop = true;
+ border.DragEnter += (_, e) =>
+ {
+ if (!IsSessionDragPayload(e.Data)) return;
+ // Highlight the tab as the active drop target.
+ border.Background = new SolidColorBrush(
+ Color.FromArgb(0x88, 0x89, 0xb4, 0xfa));
+ e.Handled = true;
+ };
+ border.DragLeave += (_, _) =>
+ {
+ // Restore the normal active/inactive state; UpdateGroupStripActiveState
+ // recomputes Background from _vm.ActiveGroupId.
+ UpdateGroupStripActiveState();
+ };
+ border.DragOver += (_, e) =>
+ {
+ if (!IsSessionDragPayload(e.Data))
+ {
+ // Let the parent GroupStripPanel handler take group-tab reorder drags.
+ return;
+ }
+ e.Effects = System.Windows.DragDropEffects.Move;
+ // Handled = true prevents the strip's DragOver from overriding Effects to None.
+ e.Handled = true;
+ };
+ border.Drop += (_, e) =>
+ {
+ if (!IsSessionDragPayload(e.Data))
+ {
+ UpdateGroupStripActiveState();
+ return;
+ }
+ string sessionId = (string)e.Data.GetData(System.Windows.DataFormats.StringFormat);
+ string? targetGroupId = groupId == GroupFilter.Ungrouped ? null : groupId;
+ var targets = _vm.ResolveActionTargets(sessionId);
+ _vm.AssignSessionsToGroup(targets, targetGroupId);
+ e.Handled = true;
+ UpdateGroupStripActiveState();
+ };
+ }
+
+ return border;
+ }
+
+ private int IndexOfUserGroup(string groupId)
+ {
+ for (int i = 0; i < _sessionManager.Groups.Count; i++)
+ if (_sessionManager.Groups[i].Id == groupId) return i;
+ return -1;
+ }
+
+ ///
+ /// Inline-mode header for a group section. = null renders the
+ /// implicit "Ungrouped" header. Click toggles expand/collapse; right-click opens a
+ /// rename/delete/move menu (real groups only); the header is both a drag source for
+ /// group reorder and a drop target for session reassignment.
+ ///
+ private Border BuildInlineGroupHeader(Models.SessionGroup? group, int count, bool expanded)
+ {
+ string label = group?.Name ?? "Ungrouped";
+ string headerTagId = group?.Id ?? GroupFilter.Ungrouped;
+
+ var border = new Border
+ {
+ Margin = new Thickness(0, 8, 0, 2),
+ Padding = new Thickness(8, 4, 8, 4),
+ Background = new SolidColorBrush(Color.FromRgb(0x18, 0x18, 0x25)),
+ BorderBrush = new SolidColorBrush(Color.FromRgb(0x31, 0x32, 0x44)),
+ BorderThickness = new Thickness(0, 0, 0, 1),
+ Cursor = System.Windows.Input.Cursors.Hand,
+ Tag = "groupheader:" + headerTagId,
+ ToolTip = group == null
+ ? "Sessions not assigned to a group"
+ : $"Group: {group.Name}"
+ };
+
+ var grid = new Grid();
+ grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
+ grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
+ grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
+
+ var caret = new TextBlock
+ {
+ Text = expanded ? "▼" : "▶",
+ Foreground = new SolidColorBrush(Color.FromRgb(0x6c, 0x70, 0x86)),
+ FontSize = 9,
+ VerticalAlignment = VerticalAlignment.Center,
+ Margin = new Thickness(0, 0, 8, 0)
+ };
+ Grid.SetColumn(caret, 0);
+
+ var labelText = new TextBlock
+ {
+ Text = label,
+ Foreground = new SolidColorBrush(Color.FromRgb(0xcd, 0xd6, 0xf4)),
+ FontSize = 11,
+ FontWeight = FontWeights.SemiBold,
+ VerticalAlignment = VerticalAlignment.Center,
+ TextTrimming = TextTrimming.CharacterEllipsis
+ };
+ Grid.SetColumn(labelText, 1);
+
+ var countText = new TextBlock
+ {
+ Text = count.ToString(),
+ Foreground = new SolidColorBrush(Color.FromRgb(0x6c, 0x70, 0x86)),
+ FontSize = 10,
+ VerticalAlignment = VerticalAlignment.Center,
+ Margin = new Thickness(8, 0, 0, 0)
+ };
+ Grid.SetColumn(countText, 2);
+
+ grid.Children.Add(caret);
+ grid.Children.Add(labelText);
+ grid.Children.Add(countText);
+ border.Child = grid;
+
+ // Click toggles expand/collapse. Use MouseLeftButtonUp + a dragPending flag so a
+ // drag operation doesn't also fire the toggle.
+ System.Windows.Point dragStartPos = default;
+ bool dragPending = false;
+ border.PreviewMouseLeftButtonDown += (_, me) =>
+ {
+ dragStartPos = me.GetPosition(null);
+ dragPending = true;
+ };
+ border.PreviewMouseLeftButtonUp += (_, _) =>
+ {
+ if (!dragPending) return;
+ dragPending = false;
+ if (group != null)
+ {
+ group.IsExpanded = !group.IsExpanded;
+ }
+ else
+ {
+ _vm.Settings.UngroupedSectionExpanded = !_vm.Settings.UngroupedSectionExpanded;
+ }
+ _ = _vm.SaveStateAsync();
+ RebuildSidebarOrder();
+ };
+
+ // Real groups: drag source for reorder + right-click menu mirroring the strip tab.
+ if (group != null)
+ {
+ border.PreviewMouseMove += (_, me) =>
+ {
+ if (!dragPending || me.LeftButton != MouseButtonState.Pressed) return;
+ var diff = me.GetPosition(null) - dragStartPos;
+ if (Math.Abs(diff.X) > SystemParameters.MinimumHorizontalDragDistance ||
+ Math.Abs(diff.Y) > SystemParameters.MinimumVerticalDragDistance)
+ {
+ dragPending = false;
+ System.Windows.DragDrop.DoDragDrop(border, "group:" + group.Id,
+ System.Windows.DragDropEffects.Move);
+ }
+ };
+
+ var menu = new System.Windows.Controls.ContextMenu();
+ var moveUp = new System.Windows.Controls.MenuItem { Header = "Move up" };
+ moveUp.Click += (_, _) =>
+ {
+ int idx = IndexOfUserGroup(group.Id);
+ if (idx > 0) _vm.MoveGroup(group.Id, idx - 1);
+ };
+ menu.Items.Add(moveUp);
+ var moveDown = new System.Windows.Controls.MenuItem { Header = "Move down" };
+ moveDown.Click += (_, _) =>
+ {
+ int idx = IndexOfUserGroup(group.Id);
+ if (idx >= 0 && idx < _sessionManager.Groups.Count - 1)
+ _vm.MoveGroup(group.Id, idx + 1);
+ };
+ menu.Items.Add(moveDown);
+ menu.Items.Add(new System.Windows.Controls.Separator());
+ var rename = new System.Windows.Controls.MenuItem { Header = "Rename group…" };
+ rename.Click += (_, _) => PromptRenameGroup(group.Id, group.Name);
+ menu.Items.Add(rename);
+ var delete = new System.Windows.Controls.MenuItem { Header = "Delete group" };
+ delete.Click += (_, _) =>
+ {
+ var r = MessageBox.Show(
+ $"Delete group '{group.Name}'? Sessions in this group will revert to Ungrouped.",
+ "Delete group", MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No);
+ if (r == MessageBoxResult.Yes) _vm.RemoveGroup(group.Id);
+ };
+ menu.Items.Add(delete);
+ menu.Opened += (_, _) =>
+ {
+ int idx = IndexOfUserGroup(group.Id);
+ moveUp.IsEnabled = idx > 0;
+ moveDown.IsEnabled = idx >= 0 && idx < _sessionManager.Groups.Count - 1;
+ };
+ border.ContextMenu = menu;
+ }
+
+ // Drop target: sessions get assigned to this group; another group dropped here
+ // reorders to before this group.
+ border.AllowDrop = true;
+ border.DragEnter += (_, e) =>
+ {
+ if (IsSessionDragPayload(e.Data) || IsGroupDragPayload(e.Data, exceptGroupId: group?.Id))
+ {
+ border.Background = new SolidColorBrush(Color.FromArgb(0x55, 0x89, 0xb4, 0xfa));
+ e.Handled = true;
+ }
+ };
+ border.DragLeave += (_, _) =>
+ {
+ border.Background = new SolidColorBrush(Color.FromRgb(0x18, 0x18, 0x25));
+ };
+ border.DragOver += (_, e) =>
+ {
+ if (IsSessionDragPayload(e.Data)
+ || (group != null && IsGroupDragPayload(e.Data, exceptGroupId: group.Id)))
+ {
+ e.Effects = System.Windows.DragDropEffects.Move;
+ e.Handled = true;
+ }
+ };
+ border.Drop += (_, e) =>
+ {
+ border.Background = new SolidColorBrush(Color.FromRgb(0x18, 0x18, 0x25));
+ if (IsSessionDragPayload(e.Data))
+ {
+ string sessionId = (string)e.Data.GetData(System.Windows.DataFormats.StringFormat);
+ var targets = _vm.ResolveActionTargets(sessionId);
+ _vm.AssignSessionsToGroup(targets, group?.Id);
+ e.Handled = true;
+ return;
+ }
+ if (group != null && IsGroupDragPayload(e.Data, exceptGroupId: group.Id))
+ {
+ string payload = (string)e.Data.GetData(System.Windows.DataFormats.StringFormat);
+ string draggedId = payload.Substring("group:".Length);
+ int targetIdx = IndexOfUserGroup(group.Id);
+ if (targetIdx >= 0) _vm.MoveGroup(draggedId, targetIdx);
+ e.Handled = true;
+ }
+ };
+
+ return border;
+ }
+
+ /// True when the drag payload is "group:" and (if specified) not the excepted id.
+ private static bool IsGroupDragPayload(System.Windows.IDataObject data, string? exceptGroupId = null)
+ {
+ if (!data.GetDataPresent(System.Windows.DataFormats.StringFormat)) return false;
+ var payload = data.GetData(System.Windows.DataFormats.StringFormat) as string;
+ if (string.IsNullOrEmpty(payload) || !payload!.StartsWith("group:")) return false;
+ string id = payload.Substring("group:".Length);
+ if (id == "__ALL__" || id == GroupFilter.Ungrouped) return false;
+ if (exceptGroupId != null && id == exceptGroupId) return false;
+ return true;
+ }
+
+ ///
+ /// True when the drag payload is a session id (the raw vm.Id used by BuildSidebarItem),
+ /// not a group-reorder payload (prefixed with "group:") or some unrelated data.
+ ///
+ private static bool IsSessionDragPayload(System.Windows.IDataObject data)
+ {
+ if (!data.GetDataPresent(System.Windows.DataFormats.StringFormat)) return false;
+ var payload = data.GetData(System.Windows.DataFormats.StringFormat) as string;
+ return !string.IsNullOrEmpty(payload) && !payload!.StartsWith("group:");
+ }
+
+ private void UpdateGroupStripActiveState()
+ {
+ string activeKey = "group:" + (_vm.ActiveGroupId ?? "__ALL__");
+ foreach (Border tab in GroupStripPanel.Children.OfType())
+ {
+ if (tab.Tag is not string key || !key.StartsWith("group:")) continue;
+ bool isActive = key == activeKey;
+ tab.Background = isActive
+ ? new SolidColorBrush(Color.FromRgb(0x31, 0x32, 0x44))
+ : Brushes.Transparent;
+ tab.BorderBrush = isActive
+ ? new SolidColorBrush(Color.FromRgb(0x89, 0xb4, 0xfa))
+ : Brushes.Transparent;
+ }
+ }
+
+ private void PromptCreateGroup()
+ {
+ string? name = InputBoxDialog.Prompt(this, "New group", "Group name:", "");
+ if (string.IsNullOrWhiteSpace(name)) return;
+ _vm.CreateGroup(name.Trim());
+ }
+
+ private void PromptRenameGroup(string groupId, string currentName)
+ {
+ string? name = InputBoxDialog.Prompt(this, "Rename group", "Group name:", currentName);
+ if (string.IsNullOrWhiteSpace(name) || name.Trim() == currentName) return;
+ _vm.RenameGroup(groupId, name.Trim());
+ }
+
+ // ── Worktree siblings ─────────────────────────────────────────────────────
+
+ /// Sets HasWorktreeSiblings = true on every live session that shares its RepoRoot with another.
+ private void RecomputeWorktreeSiblings()
+ {
+ var byRoot = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var s in _vm.Sessions)
+ {
+ if (string.IsNullOrEmpty(s.RepoRoot)) continue;
+ byRoot[s.RepoRoot] = byRoot.GetValueOrDefault(s.RepoRoot) + 1;
+ }
+ bool anyChanged = false;
+ foreach (var s in _vm.Sessions)
+ {
+ bool siblings = !string.IsNullOrEmpty(s.RepoRoot) && byRoot[s.RepoRoot] > 1;
+ if (s.HasWorktreeSiblings != siblings)
+ {
+ s.HasWorktreeSiblings = siblings;
+ anyChanged = true;
+ }
+ }
+
+ // Auto-cluster: pull every session in a multi-sibling repo next to its anchor so
+ // siblings always group up, even when added/removed out of order or imported from
+ // a non-worktree creation path. This runs on Add/Remove/Reset and on RepoRoot
+ // resolve — but NOT on Move (the CollectionChanged filter skips it) so user
+ // drag-reorder is preserved.
+ bool reordered = ApplyClusteredOrder();
+
+ if ((anyChanged || reordered) && _vm.Settings.ShowWorktreeClusters)
+ RebuildSidebarOrder();
+ if (reordered)
+ _ = _vm.SaveStateAsync();
+ }
+
+ ///
+ /// Reorders (and the underlying SessionManager
+ /// list) so every session sharing a RepoRoot sits adjacent to its first-seen anchor.
+ /// First-occurrence order is preserved between clusters and for solo sessions, so a
+ /// non-worktree session never gets shuffled past unrelated ones. Returns true when
+ /// at least one Move happened.
+ ///
+ private bool ApplyClusteredOrder()
+ {
+ if (_vm.Sessions.Count < 2) return false;
+
+ // Compute the desired order: stable group-by RepoRoot, anchored at first occurrence.
+ var desired = new List(_vm.Sessions.Count);
+ var anchorIdx = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ static string ClusterKey(SessionViewModel s) =>
+ s.RepoRoot is { Length: > 0 } root ? root : "__solo:" + s.Id;
+
+ foreach (var s in _vm.Sessions)
+ {
+ string key = ClusterKey(s);
+ if (!anchorIdx.TryGetValue(key, out _))
+ {
+ anchorIdx[key] = desired.Count;
+ desired.Add(s);
+ }
+ else
+ {
+ // Insert immediately after the last existing member of this cluster.
+ int insertAt = anchorIdx[key];
+ for (int i = anchorIdx[key]; i < desired.Count; i++)
+ {
+ if (ClusterKey(desired[i]) == key) insertAt = i + 1;
+ }
+ desired.Insert(insertAt, s);
+ }
+ }
+
+ // Apply minimal Move operations to align current order with desired.
+ bool moved = false;
+ for (int i = 0; i < desired.Count; i++)
+ {
+ if (_vm.Sessions[i].Id == desired[i].Id) continue;
+ int j = -1;
+ for (int k = i + 1; k < _vm.Sessions.Count; k++)
+ if (_vm.Sessions[k].Id == desired[i].Id) { j = k; break; }
+ if (j <= i) continue;
+ // Mirror in the SessionManager model so state.json persists the new order.
+ _sessionManager.MoveSession(_vm.Sessions[j].Id, i);
+ _vm.Sessions.Move(j, i);
+ moved = true;
+ }
+ return moved;
+ }
+
+ // ── Per-session context menu ──────────────────────────────────────────────
+
+ private System.Windows.Controls.ContextMenu BuildSessionContextMenu(SessionViewModel vm)
+ {
+ var menu = new System.Windows.Controls.ContextMenu();
+ var targetIds = _vm.ResolveActionTargets(vm.Id);
+ bool isMulti = targetIds.Count > 1;
+ string countSuffix = isMulti ? $" ({targetIds.Count})" : "";
+
+ // Spawn-near-parent + worktree actions — single-target only
+ if (!isMulti)
+ {
+ var dupItem = new System.Windows.Controls.MenuItem { Header = "Duplicate session" };
+ dupItem.Click += async (_, _) => await DuplicateSessionAsync(vm);
+ menu.Items.Add(dupItem);
+
+ if (!vm.Session.IsRemote && !string.IsNullOrEmpty(vm.Session.WorkingFolder))
+ {
+ var newHere = new System.Windows.Controls.MenuItem { Header = "New session here…" };
+ newHere.Click += (_, _) => OpenNewSessionDialogFromParent(vm);
+ menu.Items.Add(newHere);
+
+ var wtItem = new System.Windows.Controls.MenuItem { Header = "New worktree from this branch…" };
+ wtItem.Click += async (_, _) => await OpenNewWorktreeDialogAsync(vm);
+ menu.Items.Add(wtItem);
+
+ // Sibling worktree submenu — populated on demand so we don't shell out to git
+ // for every right-click on a non-worktree session.
+ var siblingMenu = new System.Windows.Controls.MenuItem { Header = "New session in sibling worktree" };
+ siblingMenu.Items.Add(new System.Windows.Controls.MenuItem
+ {
+ Header = "(loading…)",
+ IsEnabled = false
+ });
+ bool populated = false;
+ siblingMenu.SubmenuOpened += async (_, _) =>
+ {
+ if (populated) return;
+ populated = true;
+ var worktrees = await GitService.ListWorktreesAsync(vm.Session.WorkingFolder);
+ siblingMenu.Items.Clear();
+ var liveFolders = new HashSet(
+ _vm.Sessions.Select(s => NormalizePath(s.Session.WorkingFolder)),
+ StringComparer.OrdinalIgnoreCase);
+ string selfNorm = NormalizePath(vm.Session.WorkingFolder);
+ int added = 0;
+ foreach (var w in worktrees)
+ {
+ if (w.IsBare) continue;
+ string wn = NormalizePath(w.Path);
+ if (wn == selfNorm) continue;
+ if (liveFolders.Contains(wn)) continue;
+ string label = string.IsNullOrEmpty(w.Branch)
+ ? System.IO.Path.GetFileName(w.Path)
+ : $"{System.IO.Path.GetFileName(w.Path)} ⎇ {w.Branch}";
+ var mi = new System.Windows.Controls.MenuItem { Header = label, Tag = w.Path };
+ mi.Click += async (_, _) => await LaunchSessionInSiblingWorktreeAsync(vm, w.Path);
+ siblingMenu.Items.Add(mi);
+ added++;
+ }
+ if (added == 0)
+ {
+ siblingMenu.Items.Add(new System.Windows.Controls.MenuItem
+ {
+ Header = "(no other worktrees available)",
+ IsEnabled = false
+ });
+ }
+ };
+ menu.Items.Add(siblingMenu);
+ }
+ menu.Items.Add(new System.Windows.Controls.Separator());
+ }
+
+ // Add to group submenu — always available
+ var addTo = new System.Windows.Controls.MenuItem { Header = $"Add to group{countSuffix}" };
+ foreach (var g in _sessionManager.Groups.OrderBy(g => g.SortOrder))
+ {
+ var gid = g.Id; // capture
+ var item = new System.Windows.Controls.MenuItem { Header = g.Name };
+ item.Click += (_, _) => _vm.AssignSessionsToGroup(targetIds, gid);
+ addTo.Items.Add(item);
+ }
+ if (_sessionManager.Groups.Count > 0)
+ addTo.Items.Add(new System.Windows.Controls.Separator());
+ var newGroup = new System.Windows.Controls.MenuItem { Header = "New group…" };
+ newGroup.Click += (_, _) =>
+ {
+ string? name = InputBoxDialog.Prompt(this, "New group", "Group name:", "");
+ if (string.IsNullOrWhiteSpace(name)) return;
+ var g = _vm.CreateGroup(name.Trim());
+ _vm.AssignSessionsToGroup(targetIds, g.Id);
+ };
+ addTo.Items.Add(newGroup);
+ menu.Items.Add(addTo);
+
+ var removeFrom = new System.Windows.Controls.MenuItem { Header = $"Remove from group{countSuffix}" };
+ removeFrom.Click += (_, _) => _vm.AssignSessionsToGroup(targetIds, null);
+ menu.Items.Add(removeFrom);
+
+ menu.Items.Add(new System.Windows.Controls.Separator());
+ var sleepItem = new System.Windows.Controls.MenuItem { Header = $"Sleep{countSuffix}" };
+ sleepItem.Click += (_, _) =>
+ {
+ foreach (var id in targetIds)
+ {
+ var target = _vm.Sessions.FirstOrDefault(s => s.Id == id);
+ if (target != null) SleepSession(target);
+ }
+ };
+ menu.Items.Add(sleepItem);
+ var closeItem = new System.Windows.Controls.MenuItem { Header = $"Close{countSuffix}" };
+ closeItem.Click += (_, _) =>
+ {
+ foreach (var id in targetIds.ToArray())
+ {
+ var target = _vm.Sessions.FirstOrDefault(s => s.Id == id);
+ target?.CloseCommand.Execute(null);
+ }
+ };
+ menu.Items.Add(closeItem);
+
+ return menu;
+ }
+
+ private static string NormalizePath(string p)
+ {
+ if (string.IsNullOrEmpty(p)) return "";
+ try { return System.IO.Path.TrimEndingDirectorySeparator(System.IO.Path.GetFullPath(p)).Replace('\\', '/'); }
+ catch { return p; }
+ }
+
+ // ── Worktree creation ─────────────────────────────────────────────────────
+
+ private async Task OpenNewWorktreeDialogAsync(SessionViewModel source)
+ {
+ string? repoRoot = source.RepoRoot
+ ?? await GitService.GetRepoRootAsync(source.Session.WorkingFolder);
+ if (string.IsNullOrEmpty(repoRoot))
+ {
+ MessageBox.Show(this,
+ $"'{source.Session.WorkingFolder}' is not inside a git repository.",
+ "Not a git repo", MessageBoxButton.OK, MessageBoxImage.Information);
+ return;
+ }
+
+ var branches = await GitService.ListBranchesAsync(repoRoot);
+ string currentBranch = source.GitBranch ?? "";
+ var dlg = new NewWorktreeDialog(repoRoot, currentBranch, branches) { Owner = this };
+ if (dlg.ShowDialog() != true) return;
+
+ var (ok, err) = await GitService.CreateWorktreeAsync(
+ repoRoot, dlg.TargetPath, dlg.BranchOrRef, dlg.CreateBranch);
+ if (!ok)
+ {
+ MessageBox.Show(this, "git worktree add failed:\n\n" + err,
+ "Worktree error", MessageBoxButton.OK, MessageBoxImage.Error);
+ return;
+ }
+
+ // Clone the source session config into a new session pointing at the worktree.
+ var newSession = _sessionManager.CreateSession(
+ dlg.SessionName,
+ dlg.TargetPath,
+ source.Session.Command,
+ source.Session.Args,
+ string.IsNullOrEmpty(source.Session.GroupId) ? null : source.Session.GroupId,
+ source.Session.ColorOverride);
+ newSession.ProfileFontFamily = source.Session.ProfileFontFamily;
+ newSession.ProfileFontSize = source.Session.ProfileFontSize;
+ newSession.ProfileFontWeight = source.Session.ProfileFontWeight;
+ newSession.ProfileFontLigatures = source.Session.ProfileFontLigatures;
+ newSession.ProfileCursorShape = source.Session.ProfileCursorShape;
+ newSession.ProfileCursorBlink = source.Session.ProfileCursorBlink;
+ newSession.ProfilePadding = source.Session.ProfilePadding;
+ newSession.ProfileBackgroundOpacity = source.Session.ProfileBackgroundOpacity;
+ newSession.ProfileRetroEffect = source.Session.ProfileRetroEffect;
+ newSession.ProfileColorSchemeJson = source.Session.ProfileColorSchemeJson;
+
+ await LaunchSessionAsync(newSession);
+ }
+
// ── Sidebar drag-and-drop ─────────────────────────────────────────────────
// Called once from constructor after SidebarSessionList is accessible.
@@ -841,17 +2022,60 @@ private void SetupSidebarDrop()
SidebarSessionList.AllowDrop = true;
SidebarSessionList.DragOver += (_, e) =>
{
- e.Effects = e.Data.GetDataPresent(System.Windows.DataFormats.StringFormat)
- ? System.Windows.DragDropEffects.Move : System.Windows.DragDropEffects.None;
+ bool inlineMode = _vm.Settings.GroupDisplayMode == Models.GroupDisplayMode.InlineHeaders;
+ bool ok = IsSessionDragPayload(e.Data)
+ || (inlineMode && IsGroupDragPayload(e.Data));
+ e.Effects = ok
+ ? System.Windows.DragDropEffects.Move
+ : System.Windows.DragDropEffects.None;
e.Handled = true;
};
SidebarSessionList.Drop += (_, e) =>
{
- if (!e.Data.GetDataPresent(System.Windows.DataFormats.StringFormat)) return;
- string draggedId = (string)e.Data.GetData(System.Windows.DataFormats.StringFormat);
- int targetIndex = GetSidebarDropIndex(e.GetPosition(SidebarSessionList));
- _vm.MoveSession(draggedId, targetIndex);
- RebuildSidebarOrder();
+ var mode = _vm.Settings.GroupDisplayMode;
+
+ if (IsSessionDragPayload(e.Data))
+ {
+ string draggedId = (string)e.Data.GetData(System.Windows.DataFormats.StringFormat);
+ var pos = e.GetPosition(SidebarSessionList);
+
+ // Inline mode: detect which group section the cursor fell in. If different
+ // from the dragged session's current group, reassign — otherwise the move
+ // would just "snap back" once RebuildSidebarOrder regrouped by GroupId.
+ if (mode == Models.GroupDisplayMode.InlineHeaders
+ && _sessionManager.Groups.Count > 0)
+ {
+ var (targetGroupId, sessionsIndex) = ResolveInlineSessionDropTarget(pos.Y);
+ string normalizedTarget = targetGroupId ?? "";
+ var dragged = _vm.Sessions.FirstOrDefault(s => s.Id == draggedId);
+ if (dragged != null && (dragged.GroupId ?? "") != normalizedTarget)
+ {
+ // Apply the cross-section reassign to the whole selection set when
+ // the dragged session is part of one — mirrors the header-drop UX.
+ var targets = _vm.ResolveActionTargets(draggedId);
+ _vm.AssignSessionsToGroup(targets, targetGroupId);
+ }
+ _vm.MoveSession(draggedId, sessionsIndex);
+ RebuildSidebarOrder();
+ return;
+ }
+
+ int targetIndex = GetSidebarDropIndex(pos);
+ _vm.MoveSession(draggedId, targetIndex);
+ RebuildSidebarOrder();
+ return;
+ }
+ // Inline mode: group reorder drops onto the sidebar resolve to a position
+ // among the visible group headers. The fixed FilterStrip's own drop handler
+ // is in SetupGroupStripDrop — that's the path when the strip is showing.
+ if (mode == Models.GroupDisplayMode.InlineHeaders
+ && IsGroupDragPayload(e.Data))
+ {
+ string payload = (string)e.Data.GetData(System.Windows.DataFormats.StringFormat);
+ string draggedGroupId = payload.Substring("group:".Length);
+ int targetIdx = GetInlineGroupDropIndex(e.GetPosition(SidebarSessionList));
+ if (targetIdx >= 0) _vm.MoveGroup(draggedGroupId, targetIdx);
+ }
};
}
@@ -867,21 +2091,303 @@ private int GetSidebarDropIndex(System.Windows.Point pos)
return children.Count;
}
+ ///
+ /// In inline-headers mode, maps a drop Y to (target group, _vm.Sessions index).
+ /// Walks SidebarSessionList in order: each "groupheader:" item switches the current
+ /// section; session items inside the section define insertion midpoints. If the drop
+ /// falls past every section's midline, the session lands at the end of whatever
+ /// section the cursor was last inside.
+ ///
+ private (string? targetGroupId, int sessionsIndex) ResolveInlineSessionDropTarget(double y)
+ {
+ // Pass 1: build per-section bounds + session lists by walking the visible children.
+ // A section's vertical bounds are [its-header-bottom .. next-header-top] (or [0..]/[..MaxValue]
+ // for the Ungrouped implicit section / the final section).
+ var sections = new List<(string? groupId, double endY, List sessions)>();
+ string? currentGroupId = null;
+ double currentEndY = double.MaxValue;
+ var currentSessions = new List();
+
+ foreach (System.Windows.UIElement child in SidebarSessionList.Children)
+ {
+ if (child is not Border item) continue;
+ string? tag = item.Tag as string;
+ if (tag == null) continue;
+ var itemPos = item.TranslatePoint(new System.Windows.Point(0, 0), SidebarSessionList);
+
+ if (tag.StartsWith("groupheader:"))
+ {
+ // Close out the current section at the new header's top.
+ currentEndY = itemPos.Y;
+ // Skip empty sections: if no sessions appeared before this header
+ // (e.g. drop above the first header in a no-ungrouped sidebar), an
+ // empty entry would let Pass 2 match the section but fall through to
+ // the "past every session" tail and silently retarget to ungrouped.
+ if (currentSessions.Count > 0)
+ sections.Add((currentGroupId, currentEndY, currentSessions));
+ // Start a new section.
+ string id = tag.Substring("groupheader:".Length);
+ currentGroupId = id == GroupFilter.Ungrouped ? null : id;
+ currentSessions = new List();
+ continue;
+ }
+ if (tag.StartsWith("cluster:") || tag.StartsWith("dormant:")) continue;
+ currentSessions.Add(item);
+ }
+ if (currentSessions.Count > 0)
+ sections.Add((currentGroupId, double.MaxValue, currentSessions));
+
+ // Pass 2: find the section whose end-Y is past the drop Y, then resolve the
+ // insertion point within it.
+ foreach (var sec in sections)
+ {
+ if (y >= sec.endY) continue;
+
+ foreach (var sItem in sec.sessions)
+ {
+ var sp = sItem.TranslatePoint(new System.Windows.Point(0, 0), SidebarSessionList);
+ if (y < sp.Y + sItem.ActualHeight / 2)
+ {
+ string? sid = sItem.Tag as string;
+ if (!string.IsNullOrEmpty(sid))
+ {
+ for (int j = 0; j < _vm.Sessions.Count; j++)
+ if (_vm.Sessions[j].Id == sid) return (sec.groupId, j);
+ }
+ }
+ }
+
+ // Past every session in this section — insert at the section's tail, i.e. just
+ // after the last existing member of this group in _vm.Sessions.
+ int lastIdx = -1;
+ for (int j = 0; j < _vm.Sessions.Count; j++)
+ {
+ if ((_vm.Sessions[j].GroupId ?? "") == (sec.groupId ?? ""))
+ lastIdx = j;
+ }
+ return (sec.groupId, lastIdx < 0 ? _vm.Sessions.Count : lastIdx + 1);
+ }
+
+ // Past every section (shouldn't normally happen — last section's endY is MaxValue).
+ return (currentGroupId, _vm.Sessions.Count);
+ }
+
+ ///
+ /// In inline-headers mode, maps a Y coordinate within SidebarSessionList to a user-group
+ /// insertion index (0-based within SessionManager.Groups). The implicit Ungrouped header
+ /// is skipped — only real-group headers are valid targets.
+ ///
+ private int GetInlineGroupDropIndex(System.Windows.Point pos)
+ {
+ var headers = SidebarSessionList.Children.OfType()
+ .Where(b => b.Tag is string t && t.StartsWith("groupheader:")
+ && t != "groupheader:" + GroupFilter.Ungrouped)
+ .ToList();
+ for (int i = 0; i < headers.Count; i++)
+ {
+ var itemPos = headers[i].TranslatePoint(new System.Windows.Point(0, 0), SidebarSessionList);
+ double midY = itemPos.Y + headers[i].ActualHeight / 2;
+ if (pos.Y < midY) return i;
+ }
+ return headers.Count;
+ }
+
+ ///
+ /// Wires the GroupStripPanel as a drop target for group-tab drags. Drop position is
+ /// resolved relative to the user-group tabs only — "All" and "Ungrouped" stay pinned
+ /// at the top of the strip and aren't valid drop targets.
+ ///
+ private void SetupGroupStripDrop()
+ {
+ GroupStripPanel.AllowDrop = true;
+ GroupStripPanel.DragOver += (_, e) =>
+ {
+ bool ok = e.Data.GetDataPresent(System.Windows.DataFormats.StringFormat)
+ && ((string)e.Data.GetData(System.Windows.DataFormats.StringFormat))
+ .StartsWith("group:");
+ e.Effects = ok ? System.Windows.DragDropEffects.Move : System.Windows.DragDropEffects.None;
+ e.Handled = true;
+ };
+ GroupStripPanel.Drop += (_, e) =>
+ {
+ if (!e.Data.GetDataPresent(System.Windows.DataFormats.StringFormat)) return;
+ string payload = (string)e.Data.GetData(System.Windows.DataFormats.StringFormat);
+ if (!payload.StartsWith("group:")) return;
+ string draggedId = payload.Substring("group:".Length);
+ if (draggedId == "__ALL__" || draggedId == GroupFilter.Ungrouped) return;
+ int targetIndex = GetGroupStripDropIndex(e.GetPosition(GroupStripPanel));
+ _vm.MoveGroup(draggedId, targetIndex);
+ };
+ }
+
+ ///
+ /// Maps a Y-coordinate within GroupStripPanel to a user-group insertion index (0-based
+ /// within SessionManager.Groups). The "All" tab is at child 0, "Ungrouped" at child 1,
+ /// and the "+" footer trails the user groups — only children in [2 .. 2+N-1] are tabs.
+ ///
+ private int GetGroupStripDropIndex(System.Windows.Point pos)
+ {
+ var allTabs = GroupStripPanel.Children.OfType().ToList();
+ // Skip the fixed "All" and "Ungrouped" pseudo-tabs at indices 0 and 1, and the "+"
+ // footer at the end (it has no group: tag).
+ var groupTabs = allTabs
+ .Where(b => b.Tag is string t
+ && t.StartsWith("group:")
+ && t != "group:__ALL__"
+ && t != "group:" + GroupFilter.Ungrouped)
+ .ToList();
+ for (int i = 0; i < groupTabs.Count; i++)
+ {
+ var itemPos = groupTabs[i].TranslatePoint(new System.Windows.Point(0, 0), GroupStripPanel);
+ double midY = itemPos.Y + groupTabs[i].ActualHeight / 2;
+ if (pos.Y < midY) return i;
+ }
+ return groupTabs.Count;
+ }
+
private void RebuildSidebarOrder()
{
SidebarSessionList.Children.Clear();
- foreach (var vm in _vm.Sessions)
+ var mode = _vm.Settings.GroupDisplayMode;
+ bool inlineMode = mode == Models.GroupDisplayMode.InlineHeaders
+ && _sessionManager.Groups.Count > 0;
+
+ if (inlineMode)
+ {
+ // Ungrouped section first (only shown when it has members or there are groups).
+ var ungrouped = _vm.Sessions
+ .Where(s => string.IsNullOrEmpty(s.GroupId) && _sessionUi.ContainsKey(s.Id))
+ .ToList();
+ if (ungrouped.Count > 0)
+ {
+ bool ungroupedExpanded = _vm.Settings.UngroupedSectionExpanded;
+ SidebarSessionList.Children.Add(BuildInlineGroupHeader(null, ungrouped.Count, ungroupedExpanded));
+ if (ungroupedExpanded) AppendSessionsWithClusters(ungrouped);
+ }
+ // Each user group, in SortOrder.
+ foreach (var g in _sessionManager.Groups.OrderBy(g => g.SortOrder))
+ {
+ var members = _vm.Sessions
+ .Where(s => s.GroupId == g.Id && _sessionUi.ContainsKey(s.Id))
+ .ToList();
+ SidebarSessionList.Children.Add(BuildInlineGroupHeader(g, members.Count, g.IsExpanded));
+ if (g.IsExpanded) AppendSessionsWithClusters(members);
+ }
+ }
+ else
{
- if (_sessionUi.TryGetValue(vm.Id, out var ui))
- SidebarSessionList.Children.Add(ui.sidebarItem);
+ // Flat list mode (None or FilterStrip).
+ var visibleSessions = new List();
+ foreach (var vm in _vm.Sessions)
+ {
+ if (mode == Models.GroupDisplayMode.FilterStrip && !_vm.SessionMatchesActiveGroup(vm))
+ continue;
+ if (_sessionUi.ContainsKey(vm.Id)) visibleSessions.Add(vm);
+ }
+ AppendSessionsWithClusters(visibleSessions);
}
- // Dormant entries always render at the bottom of the sidebar.
+
+ // Dormant entries always render at the bottom of the sidebar regardless of filter
+ // or display mode so they remain reachable (and a user filtering by category isn't
+ // surprised by missing entries).
foreach (var item in _dormantSidebarItems.Values)
SidebarSessionList.Children.Add(item);
UpdateSidebarActiveState();
RefreshTerminalLayout();
}
+ ///
+ /// Appends a list of session sidebar items to , inserting
+ /// worktree cluster headers above runs of 2+ adjacent siblings (when enabled).
+ ///
+ private void AppendSessionsWithClusters(List sessions)
+ {
+ var clusters = ComputeWorktreeClusters(sessions);
+ int clusterIdx = 0;
+ for (int i = 0; i < sessions.Count; i++)
+ {
+ var vm = sessions[i];
+ if (clusterIdx < clusters.Count && clusters[clusterIdx].start == i)
+ {
+ var (s, e, root) = clusters[clusterIdx];
+ int count = e - s + 1;
+ SidebarSessionList.Children.Add(BuildWorktreeClusterHeader(root, count, vm.AccentColor));
+ clusterIdx++;
+ }
+ SidebarSessionList.Children.Add(_sessionUi[vm.Id].sidebarItem);
+ }
+ }
+
+ ///
+ /// Returns the ranges of that should render under a worktree
+ /// cluster header — runs of 2+ adjacent sessions sharing a RepoRoot. Empty when
+ /// the setting is off.
+ ///
+ private List<(int start, int end, string repoRoot)> ComputeWorktreeClusters(
+ IReadOnlyList visible)
+ {
+ var clusters = new List<(int, int, string)>();
+ if (!_vm.Settings.ShowWorktreeClusters) return clusters;
+
+ int runStart = -1;
+ string? runRoot = null;
+ for (int i = 0; i < visible.Count; i++)
+ {
+ string? root = visible[i].RepoRoot;
+ if (!string.IsNullOrEmpty(root) && root == runRoot) continue;
+ if (runStart >= 0 && (i - runStart) >= 2)
+ clusters.Add((runStart, i - 1, runRoot!));
+ runStart = string.IsNullOrEmpty(root) ? -1 : i;
+ runRoot = root;
+ }
+ if (runStart >= 0 && (visible.Count - runStart) >= 2)
+ clusters.Add((runStart, visible.Count - 1, runRoot!));
+ return clusters;
+ }
+
+ ///
+ /// Tiny banner inserted above an adjacent run of worktree siblings: shows the shared
+ /// repo name and the count of siblings in this view, tinted with the cluster's
+ /// accent color. Tag is prefixed with "cluster:" so UpdateSidebarActiveState skips it.
+ ///
+ private Border BuildWorktreeClusterHeader(string repoRoot, int count, string accentHex)
+ {
+ string repoName = System.IO.Path.GetFileName(repoRoot.TrimEnd('/', '\\'));
+ if (string.IsNullOrEmpty(repoName)) repoName = "worktrees";
+
+ Color accentColor;
+ try { accentColor = (Color)ColorConverter.ConvertFromString(accentHex); }
+ catch { accentColor = Color.FromRgb(0x89, 0xb4, 0xfa); }
+
+ var header = new Border
+ {
+ Margin = new Thickness(0, 8, 0, 0),
+ Padding = new Thickness(10, 3, 8, 3),
+ Background = new SolidColorBrush(Color.FromArgb(0x33, accentColor.R, accentColor.G, accentColor.B)),
+ BorderBrush = new SolidColorBrush(accentColor),
+ BorderThickness = new Thickness(0, 0, 0, 1),
+ CornerRadius = new CornerRadius(4, 4, 0, 0),
+ Tag = "cluster:" + repoRoot,
+ ToolTip = $"{count} worktrees of {repoName} are open"
+ };
+
+ var text = new TextBlock
+ {
+ FontSize = 10,
+ FontWeight = FontWeights.SemiBold
+ };
+ text.Inlines.Add(new System.Windows.Documents.Run($"\U0001F4C1 {repoName}")
+ {
+ Foreground = new SolidColorBrush(accentColor)
+ });
+ text.Inlines.Add(new System.Windows.Documents.Run($" · {count}")
+ {
+ Foreground = new SolidColorBrush(Color.FromRgb(0x6c, 0x70, 0x86))
+ });
+ header.Child = text;
+ return header;
+ }
+
private void RefreshSidebarItem(string sessionId) => UpdateAlertBadge();
private void UpdateAlertBadge()
@@ -1326,6 +2832,7 @@ private void OnSessionVmClosed(SessionViewModel vm)
SidebarSessionList.Children.Remove(ui.sidebarItem);
_sessionUi.Remove(vm.Id);
}
+ if (_selectionAnchorId == vm.Id) _selectionAnchorId = null;
_sessionManager.RemoveSession(vm.Id);
RefreshTerminalLayout();
UpdateAlertBadge();
@@ -1346,6 +2853,7 @@ private void SleepSession(SessionViewModel vm)
var session = vm.Session;
session.IsDormant = true;
+ if (_selectionAnchorId == vm.Id) _selectionAnchorId = null;
if (_sessionUi.TryGetValue(vm.Id, out var ui))
{
if (TerminalGrid.Children.Contains(ui.terminalWrapper))
@@ -1772,7 +3280,15 @@ private void SettingsButton_Click(object sender, RoutedEventArgs e)
_vm.Settings.DefaultCommand = edited.DefaultCommand;
_vm.Settings.DefaultWorkingFolder = edited.DefaultWorkingFolder;
_vm.Settings.ShowGitBranch = edited.ShowGitBranch;
+ _vm.Settings.ShowGroupsTab = edited.ShowGroupsTab;
+ _vm.Settings.GroupDisplayMode = edited.GroupDisplayMode;
+ _vm.Settings.ShowWorktreeClusters = edited.ShowWorktreeClusters;
_vm.Settings.SearchCollapseAfterNavigate = edited.SearchCollapseAfterNavigate;
+
+ // ActiveGroupId only makes sense in FilterStrip mode. Reset it so InlineHeaders
+ // and None modes start unfiltered (all groups visible / no filter applied).
+ if (_vm.Settings.GroupDisplayMode != Models.GroupDisplayMode.FilterStrip)
+ _vm.ActiveGroupId = null;
_vm.Settings.MaxSearchResults = edited.MaxSearchResults;
_vm.Settings.ShowTerminalStatusDot = edited.ShowTerminalStatusDot;
_vm.Settings.ImportWindowsTerminalProfiles = edited.ImportWindowsTerminalProfiles;
@@ -1788,6 +3304,9 @@ private void SettingsButton_Click(object sender, RoutedEventArgs e)
// Push font settings to all active terminal sessions
foreach (var vm in _vm.Sessions)
vm.Bridge?.ApplyFontSettings(_vm.Settings);
+
+ UpdateGroupStripVisibility();
+ RebuildSidebarOrder();
}
}
@@ -1917,6 +3436,11 @@ private void OnBridgeAcceleratorKey(object? sender, WpfKeyEventArgs e)
private bool TryHandleGlobalShortcut(Key key, ModifierKeys mods)
{
if (key == Key.T && mods == ModifierKeys.Control) { OpenNewSessionDialog(); return true; }
+ if (key == Key.T && mods == (ModifierKeys.Control | ModifierKeys.Shift))
+ {
+ if (_vm.ActiveSession != null) _ = DuplicateSessionAsync(_vm.ActiveSession);
+ return true;
+ }
if (key == Key.W && mods == ModifierKeys.Control) { _vm.ActiveSession?.CloseCommand.Execute(null); return true; }
if (key == Key.F && mods == ModifierKeys.Control) { ToggleSearch_Click(this, new RoutedEventArgs()); return true; }
if (key == Key.Tab && mods == ModifierKeys.Control) { CycleSession(forward: true); return true; }
diff --git a/src/CodeShellManager/Models/AppState.cs b/src/CodeShellManager/Models/AppState.cs
index b359ff5..353ad02 100644
--- a/src/CodeShellManager/Models/AppState.cs
+++ b/src/CodeShellManager/Models/AppState.cs
@@ -2,6 +2,19 @@
namespace CodeShellManager.Models;
+///
+/// How the sidebar surfaces session groups.
+/// None = no group UI at all (flat session list). FilterStrip = vertical tab strip
+/// to the left of the sidebar, one filter at a time (default). InlineHeaders =
+/// collapsible group headers inline in the sidebar, all groups visible at once.
+///
+public enum GroupDisplayMode
+{
+ None,
+ FilterStrip,
+ InlineHeaders
+}
+
public class AppSettings
{
public bool AutoRestoreSessions { get; set; } = true;
@@ -13,6 +26,34 @@ public class AppSettings
public string DefaultCommand { get; set; } = "claude";
public string DefaultWorkingFolder { get; set; } = "";
public bool ShowGitBranch { get; set; } = true;
+ /// Authoritative grouping UI selector. Replaces the legacy boolean.
+ public GroupDisplayMode GroupDisplayMode { get; set; } = GroupDisplayMode.FilterStrip;
+ ///
+ /// Legacy flag — kept for back-compat with older state.json files. When deserialized
+ /// as false on a state that still has GroupDisplayMode at its default, the loader
+ /// migrates the mode to None. Newer code paths read GroupDisplayMode instead.
+ ///
+ public bool ShowGroupsTab { get; set; } = true;
+ ///
+ /// When 2+ adjacent visible sessions share a repo root, draw a small header above
+ /// them ("📁 repoName (N)") to make the worktree grouping obvious. Off = the
+ /// implicit subtitle + shared stripe color are the only signals.
+ ///
+ public bool ShowWorktreeClusters { get; set; } = true;
+ ///
+ /// Expand/collapse state of the implicit Ungrouped header in InlineHeaders mode.
+ /// Real groups carry their own bit; this holds
+ /// the equivalent for the Ungrouped pseudo-section so it persists across restarts.
+ ///
+ public bool UngroupedSectionExpanded { get; set; } = true;
+ ///
+ /// One-shot guard for the legacy auto-created "Default" group migration in
+ /// . Without this gate the
+ /// heuristic (single group, name "Default", SortOrder 0) could wipe a user-named
+ /// "Default" group on a later restart. Flipped to true after the first load
+ /// regardless of whether the heuristic matched.
+ ///
+ public bool LegacyDefaultGroupCleared { get; set; } = false;
public bool SearchCollapseAfterNavigate { get; set; } = true;
public string Theme { get; set; } = "dark";
public int MaxSearchResults { get; set; } = 100;
@@ -55,7 +96,7 @@ public class WindowBounds
public class AppState
{
public List Sessions { get; set; } = [];
- public List Groups { get; set; } = [new SessionGroup { Name = "Default" }];
+ public List Groups { get; set; } = [];
public string LastLayout { get; set; } = "Single";
public AppSettings Settings { get; set; } = new();
diff --git a/src/CodeShellManager/Services/GitService.cs b/src/CodeShellManager/Services/GitService.cs
index 2f24b9d..9d50ff4 100644
--- a/src/CodeShellManager/Services/GitService.cs
+++ b/src/CodeShellManager/Services/GitService.cs
@@ -1,4 +1,5 @@
using System;
+using System.Collections.Generic;
using System.Diagnostics;
using System.Threading.Tasks;
@@ -28,7 +29,142 @@ public static class GitService
}
}
+ ///
+ /// Returns the canonical "repo identity" path — the parent of the shared .git
+ /// directory (`git rev-parse --git-common-dir`). This is identical for every
+ /// worktree of the same repo, so it's safe to use as a sibling-detection key.
+ /// (`--show-toplevel` would return each worktree's own folder, missing siblings.)
+ /// Returns null if folderPath isn't inside a repo. Forward slashes throughout
+ /// for stable string comparison on Windows.
+ ///
+ public static async Task GetRepoRootAsync(string folderPath)
+ {
+ if (string.IsNullOrWhiteSpace(folderPath) || !System.IO.Directory.Exists(folderPath))
+ return null;
+ try
+ {
+ string? commonDir = await RunGitAsync(folderPath, "rev-parse --git-common-dir");
+ if (string.IsNullOrWhiteSpace(commonDir)) return null;
+ string trimmed = commonDir.Trim();
+
+ // git may return a path relative to the cwd (e.g. ".git" for a plain repo)
+ // or an absolute path (e.g. "C:/repo/.git" when called from a worktree).
+ // Resolve to an absolute path either way.
+ string absolute = System.IO.Path.IsPathRooted(trimmed)
+ ? trimmed
+ : System.IO.Path.GetFullPath(trimmed, folderPath);
+
+ // Strip the trailing ".git" segment to get the repo's working tree root.
+ string normalized = absolute.Replace('\\', '/').TrimEnd('/');
+ if (normalized.EndsWith("/.git", StringComparison.OrdinalIgnoreCase))
+ normalized = normalized[..^"/.git".Length];
+ else if (normalized.EndsWith(".git", StringComparison.OrdinalIgnoreCase)
+ && !normalized.EndsWith("/.git", StringComparison.OrdinalIgnoreCase))
+ normalized = normalized[..^".git".Length].TrimEnd('/');
+
+ return string.IsNullOrEmpty(normalized) ? null : normalized;
+ }
+ catch { return null; }
+ }
+
+ /// Describes one git worktree as reported by `git worktree list --porcelain`.
+ public record WorktreeInfo(string Path, string? Branch, bool IsBare, bool IsDetached, bool IsLocked, bool IsPrunable);
+
+ ///
+ /// Returns all worktrees (including the main one) of the repo containing
+ /// . Empty if the folder isn't in a repo.
+ ///
+ public static async Task> ListWorktreesAsync(string folderPath)
+ {
+ if (string.IsNullOrWhiteSpace(folderPath) || !System.IO.Directory.Exists(folderPath))
+ return Array.Empty();
+ try
+ {
+ string? raw = await RunGitAsync(folderPath, "worktree list --porcelain");
+ if (string.IsNullOrWhiteSpace(raw)) return Array.Empty();
+
+ // Output is blank-line separated stanzas:
+ // worktree /path
+ // HEAD
+ // branch refs/heads/ (or "detached", "bare")
+ // locked [reason]
+ // prunable [reason]
+ var results = new List();
+ string? path = null;
+ string? branch = null;
+ bool isBare = false, isDetached = false, isLocked = false, isPrunable = false;
+ void Flush()
+ {
+ if (!string.IsNullOrEmpty(path))
+ results.Add(new WorktreeInfo(path, branch, isBare, isDetached, isLocked, isPrunable));
+ path = null; branch = null; isBare = false; isDetached = false; isLocked = false; isPrunable = false;
+ }
+ foreach (var line in raw.Replace("\r", "").Split('\n'))
+ {
+ if (string.IsNullOrEmpty(line)) { Flush(); continue; }
+ if (line.StartsWith("worktree ")) path = line.Substring("worktree ".Length).Trim();
+ else if (line.StartsWith("branch ")) branch = line.Substring("branch ".Length).Trim()
+ .Replace("refs/heads/", "", StringComparison.Ordinal);
+ else if (line == "bare") isBare = true;
+ else if (line == "detached") isDetached = true;
+ else if (line.StartsWith("locked")) isLocked = true;
+ else if (line.StartsWith("prunable")) isPrunable = true;
+ }
+ Flush();
+ return results;
+ }
+ catch { return Array.Empty(); }
+ }
+
+ /// Returns local branch names in the repo, oldest-first by git's default order.
+ public static async Task> ListBranchesAsync(string folderPath)
+ {
+ if (string.IsNullOrWhiteSpace(folderPath) || !System.IO.Directory.Exists(folderPath))
+ return Array.Empty();
+ try
+ {
+ string? raw = await RunGitAsync(folderPath, "for-each-ref --format=%(refname:short) refs/heads");
+ if (string.IsNullOrWhiteSpace(raw)) return Array.Empty();
+ var lines = raw.Replace("\r", "").Split('\n', StringSplitOptions.RemoveEmptyEntries);
+ return lines;
+ }
+ catch { return Array.Empty(); }
+ }
+
+ ///
+ /// Runs `git worktree add` either with a new branch (-b) or pointing at an
+ /// existing ref. Returns (success, errorOutput).
+ ///
+ public static async Task<(bool ok, string error)> CreateWorktreeAsync(
+ string repoRoot, string targetPath, string branchOrRef, bool createBranch)
+ {
+ if (string.IsNullOrWhiteSpace(repoRoot) || !System.IO.Directory.Exists(repoRoot))
+ return (false, "Repo root does not exist.");
+ if (string.IsNullOrWhiteSpace(targetPath))
+ return (false, "Worktree path is required.");
+ if (string.IsNullOrWhiteSpace(branchOrRef))
+ return (false, "Branch is required.");
+
+ string args = createBranch
+ ? $"worktree add -b \"{branchOrRef}\" \"{targetPath}\""
+ : $"worktree add \"{targetPath}\" \"{branchOrRef}\"";
+
+ var (output, stderr, exit) = await RunGitFullAsync(repoRoot, args, timeoutMs: 30_000);
+ if (exit == 0) return (true, "");
+ string err = string.IsNullOrWhiteSpace(stderr)
+ ? (string.IsNullOrWhiteSpace(output) ? "git worktree add failed." : output)
+ : stderr;
+ return (false, err.Trim());
+ }
+
private static async Task RunGitAsync(string workingDir, string arguments)
+ {
+ var (stdout, _, exit) = await RunGitFullAsync(workingDir, arguments, timeoutMs: 3000);
+ return exit == 0 ? stdout : null;
+ }
+
+ private static async Task<(string stdout, string stderr, int exit)> RunGitFullAsync(
+ string workingDir, string arguments, int timeoutMs)
{
var psi = new ProcessStartInfo("git")
{
@@ -40,19 +176,21 @@ public static class GitService
};
using var process = Process.Start(psi);
- if (process is null)
- return null;
+ if (process is null) return ("", "", -1);
- var outputTask = process.StandardOutput.ReadToEndAsync();
- var completed = await Task.WhenAny(outputTask, Task.Delay(3000));
+ var outTask = process.StandardOutput.ReadToEndAsync();
+ var errTask = process.StandardError.ReadToEndAsync();
+ var bothTask = Task.WhenAll(outTask, errTask);
+ var completed = await Task.WhenAny(bothTask, Task.Delay(timeoutMs));
- if (completed != outputTask)
+ if (completed != bothTask)
{
try { process.Kill(); } catch { }
- return null;
}
+ try { await process.WaitForExitAsync(); } catch { }
- await process.WaitForExitAsync();
- return process.ExitCode == 0 ? await outputTask : null;
+ string stdout = outTask.IsCompletedSuccessfully ? outTask.Result : "";
+ string stderr = errTask.IsCompletedSuccessfully ? errTask.Result : "";
+ return (stdout, stderr, process.HasExited ? process.ExitCode : -1);
}
}
diff --git a/src/CodeShellManager/Services/SessionManager.cs b/src/CodeShellManager/Services/SessionManager.cs
index db2990e..fd81b21 100644
--- a/src/CodeShellManager/Services/SessionManager.cs
+++ b/src/CodeShellManager/Services/SessionManager.cs
@@ -16,14 +16,10 @@ public class SessionManager
public event Action? SessionAdded;
public event Action? SessionRemoved;
public event Action? SessionsChanged;
-
- public SessionManager()
- {
- _groups.Add(new SessionGroup { Name = "Default", SortOrder = 0 });
- }
+ public event Action? GroupsChanged;
public ShellSession CreateSession(string name, string folder, string command, string args,
- string? groupId = null, string? colorOverride = null)
+ string? groupId = null, string? colorOverride = null, string? afterSessionId = null)
{
var session = new ShellSession
{
@@ -33,12 +29,20 @@ public ShellSession CreateSession(string name, string folder, string command, st
WorkingFolder = folder,
Command = command,
Args = args,
- GroupId = groupId ?? _groups.FirstOrDefault()?.Id ?? "",
+ GroupId = groupId ?? "",
ColorOverride = colorOverride,
Status = SessionStatus.Running
};
- _sessions.Add(session);
+ int insertAt = -1;
+ if (!string.IsNullOrEmpty(afterSessionId))
+ {
+ int parentIdx = _sessions.FindIndex(s => s.Id == afterSessionId);
+ if (parentIdx >= 0) insertAt = parentIdx + 1;
+ }
+ if (insertAt >= 0 && insertAt <= _sessions.Count) _sessions.Insert(insertAt, session);
+ else _sessions.Add(session);
+
SessionAdded?.Invoke(session);
SessionsChanged?.Invoke();
return session;
@@ -83,23 +87,92 @@ public SessionGroup AddGroup(string name)
{
var group = new SessionGroup { Name = name, SortOrder = _groups.Count };
_groups.Add(group);
- SessionsChanged?.Invoke();
+ GroupsChanged?.Invoke();
return group;
}
+ public void RenameGroup(string groupId, string newName)
+ {
+ var group = _groups.FirstOrDefault(g => g.Id == groupId);
+ if (group == null || string.IsNullOrWhiteSpace(newName)) return;
+ group.Name = newName.Trim();
+ GroupsChanged?.Invoke();
+ }
+
+ ///
+ /// Removes a group. Any sessions assigned to it are moved to "ungrouped"
+ /// (GroupId cleared). Sessions themselves are not deleted.
+ ///
+ public void RemoveGroup(string groupId)
+ {
+ var group = _groups.FirstOrDefault(g => g.Id == groupId);
+ if (group == null) return;
+ foreach (var s in _sessions)
+ {
+ if (s.GroupId == groupId) s.GroupId = "";
+ }
+ _groups.Remove(group);
+ GroupsChanged?.Invoke();
+ SessionsChanged?.Invoke();
+ }
+
+ ///
+ /// Moves a group to a new position in the ordered list (0-based). SortOrder fields
+ /// are renumbered so the new order survives persistence.
+ ///
+ public void MoveGroup(string groupId, int newIndex)
+ {
+ var group = _groups.FirstOrDefault(g => g.Id == groupId);
+ if (group == null) return;
+ int cur = _groups.IndexOf(group);
+ // Allow Count as a legal "insert at end" target.
+ newIndex = Math.Clamp(newIndex, 0, _groups.Count);
+ // After RemoveAt(cur), every index above cur shifts down by one.
+ if (cur < newIndex) newIndex--;
+ if (cur == newIndex) return;
+ _groups.RemoveAt(cur);
+ _groups.Insert(newIndex, group);
+ for (int i = 0; i < _groups.Count; i++)
+ _groups[i].SortOrder = i;
+ GroupsChanged?.Invoke();
+ }
+
+ /// Assigns a session to a group (empty/null groupId = ungrouped).
+ public void SetSessionGroup(string sessionId, string? groupId)
+ {
+ var session = _sessions.FirstOrDefault(s => s.Id == sessionId);
+ if (session == null) return;
+ session.GroupId = groupId ?? "";
+ SessionsChanged?.Invoke();
+ }
+
public void LoadFromState(AppState state)
{
_sessions.Clear();
_groups.Clear();
-
- if (state.Groups.Count > 0)
- _groups.AddRange(state.Groups);
- else
- _groups.Add(new SessionGroup { Name = "Default" });
+ _groups.AddRange(state.Groups);
// Sessions from state are configs only — they get relaunched fresh
foreach (var s in state.Sessions)
_sessions.Add(s);
+
+ // Legacy migration: previous versions auto-created a single "Default" group
+ // (SortOrder 0) and put every session in it. Drop it so existing users see
+ // an empty group strip until they create real categories themselves. Gated on
+ // a one-shot flag so a user-named "Default" group created later survives.
+ if (!state.Settings.LegacyDefaultGroupCleared)
+ {
+ if (_groups.Count == 1 && _groups[0].Name == "Default" && _groups[0].SortOrder == 0)
+ {
+ string legacyId = _groups[0].Id;
+ foreach (var s in _sessions)
+ {
+ if (s.GroupId == legacyId) s.GroupId = "";
+ }
+ _groups.Clear();
+ }
+ state.Settings.LegacyDefaultGroupCleared = true;
+ }
}
public void PopulateState(AppState state)
diff --git a/src/CodeShellManager/ViewModels/MainViewModel.cs b/src/CodeShellManager/ViewModels/MainViewModel.cs
index f43f0f8..f27beb7 100644
--- a/src/CodeShellManager/ViewModels/MainViewModel.cs
+++ b/src/CodeShellManager/ViewModels/MainViewModel.cs
@@ -12,6 +12,12 @@ namespace CodeShellManager.ViewModels;
public enum LayoutMode { Single, TwoColumn, ThreeColumn, TwoByTwo, TwoRow, FourColumn, SixColumn, SixByTwo, SixByThree }
+/// Sentinel value meaning "show only sessions with no group".
+public static class GroupFilter
+{
+ public const string Ungrouped = "__UNGROUPED__";
+}
+
public partial class MainViewModel : ObservableObject
{
private readonly SessionManager _sessionManager;
@@ -26,14 +32,128 @@ public partial class MainViewModel : ObservableObject
[ObservableProperty] private bool _showCommandHelper;
[ObservableProperty] private string _searchQuery = "";
+ ///
+ /// Null = show all sessions (no group filter active).
+ /// = only sessions with no GroupId. Any other value = a specific group's Id.
+ ///
+ [ObservableProperty] private string? _activeGroupId;
+
+ /// IDs of sessions currently in the multi-select set (in addition to ActiveSession).
+ public HashSet SelectedSessionIds { get; } = new();
+
public int AlertCount => Sessions.Count(s => s.NeedsAttention);
public event Action? SessionClosed;
+ public event Action? GroupsChanged;
+ public event Action? SelectionChanged;
+ /// Fired when one or more sessions' GroupId changes — sidebar should re-filter.
+ public event Action? SessionMembershipChanged;
+
+ public SessionManager SessionManager => _sessionManager;
+
+ public IReadOnlyList Groups => _sessionManager.Groups;
public MainViewModel(SessionManager sessionManager, StateService stateService)
{
_sessionManager = sessionManager;
_stateService = stateService;
+ _sessionManager.GroupsChanged += () =>
+ App.Current.Dispatcher.Invoke(() => GroupsChanged?.Invoke());
+ }
+
+ public Models.SessionGroup CreateGroup(string name)
+ {
+ var g = _sessionManager.AddGroup(name);
+ _ = SaveStateAsync();
+ return g;
+ }
+
+ public void RenameGroup(string groupId, string newName)
+ {
+ _sessionManager.RenameGroup(groupId, newName);
+ _ = SaveStateAsync();
+ }
+
+ public void RemoveGroup(string groupId)
+ {
+ _sessionManager.RemoveGroup(groupId);
+ if (ActiveGroupId == groupId) ActiveGroupId = null;
+ _ = SaveStateAsync();
+ }
+
+ /// Reorders a group in the strip. is 0-based within the user-group list.
+ public void MoveGroup(string groupId, int newIndex)
+ {
+ _sessionManager.MoveGroup(groupId, newIndex);
+ _ = SaveStateAsync();
+ }
+
+ /// Returns true when the session matches the current group filter.
+ public bool SessionMatchesActiveGroup(SessionViewModel vm)
+ {
+ if (ActiveGroupId == null) return true;
+ if (ActiveGroupId == GroupFilter.Ungrouped) return string.IsNullOrEmpty(vm.GroupId);
+ return vm.GroupId == ActiveGroupId;
+ }
+
+ public bool IsSelected(string sessionId) => SelectedSessionIds.Contains(sessionId);
+
+ public void ClearSelection()
+ {
+ if (SelectedSessionIds.Count == 0) return;
+ SelectedSessionIds.Clear();
+ SelectionChanged?.Invoke();
+ }
+
+ public void ToggleSelection(string sessionId)
+ {
+ if (!SelectedSessionIds.Add(sessionId))
+ SelectedSessionIds.Remove(sessionId);
+ SelectionChanged?.Invoke();
+ }
+
+ /// Selects every session in the visible list between anchor and target (inclusive).
+ public void SetRangeSelection(IReadOnlyList visibleIdsInOrder, string? anchorId, string targetId)
+ {
+ SelectedSessionIds.Clear();
+ if (visibleIdsInOrder.Count == 0) return;
+ int targetIdx = IndexOf(visibleIdsInOrder, targetId);
+ if (targetIdx < 0) return;
+ int anchorIdx = anchorId != null ? IndexOf(visibleIdsInOrder, anchorId) : -1;
+ if (anchorIdx < 0) anchorIdx = targetIdx;
+ int lo = Math.Min(anchorIdx, targetIdx);
+ int hi = Math.Max(anchorIdx, targetIdx);
+ for (int i = lo; i <= hi; i++)
+ SelectedSessionIds.Add(visibleIdsInOrder[i]);
+ SelectionChanged?.Invoke();
+ }
+
+ ///
+ /// Returns the IDs of all sessions to act on for a multi-target action:
+ /// the current selection if non-empty, otherwise just the explicit target.
+ ///
+ public IReadOnlyList ResolveActionTargets(string targetSessionId)
+ {
+ if (SelectedSessionIds.Count > 0)
+ return SelectedSessionIds.Contains(targetSessionId)
+ ? SelectedSessionIds.ToArray()
+ : new[] { targetSessionId };
+ return new[] { targetSessionId };
+ }
+
+ public void AssignSessionsToGroup(IEnumerable sessionIds, string? groupId)
+ {
+ foreach (var id in sessionIds)
+ _sessionManager.SetSessionGroup(id, groupId);
+ SessionMembershipChanged?.Invoke();
+ _ = SaveStateAsync();
+ }
+
+ private static int IndexOf(IReadOnlyList list, string value)
+ {
+ for (int i = 0; i < list.Count; i++)
+ if (list[i] == value) return i;
+ return -1;
}
public async Task LoadStateAsync()
@@ -41,6 +161,14 @@ public async Task LoadStateAsync()
_appState = await _stateService.LoadAsync();
_sessionManager.LoadFromState(_appState);
Layout = Enum.TryParse(_appState.LastLayout, out var lm) ? lm : LayoutMode.Single;
+
+ // Legacy migration: pre-enum installs persisted "ShowGroupsTab=false" to hide
+ // the strip. Translate to the new enum on first load with the new code.
+ if (_appState.Settings.GroupDisplayMode == Models.GroupDisplayMode.FilterStrip
+ && !_appState.Settings.ShowGroupsTab)
+ {
+ _appState.Settings.GroupDisplayMode = Models.GroupDisplayMode.None;
+ }
}
public async Task SaveStateAsync()
@@ -116,7 +244,14 @@ public void RegisterSession(SessionViewModel vm)
};
}
- Sessions.Add(vm);
+ // Mirror SessionManager order so insert-after-parent (CreateSession with afterSessionId)
+ // also lands the VM at the matching slot — otherwise the model order would be correct
+ // but the sidebar would still show the new entry at the bottom.
+ int idx = -1;
+ for (int i = 0; i < _sessionManager.Sessions.Count; i++)
+ if (_sessionManager.Sessions[i].Id == vm.Id) { idx = i; break; }
+ if (idx >= 0 && idx <= Sessions.Count) Sessions.Insert(idx, vm);
+ else Sessions.Add(vm);
ActiveSession = vm;
_ = SaveStateAsync();
}
@@ -125,6 +260,8 @@ private void OnSessionCloseRequested(SessionViewModel vm)
{
vm.CloseRequested -= OnSessionCloseRequested;
Sessions.Remove(vm);
+ if (SelectedSessionIds.Remove(vm.Id))
+ SelectionChanged?.Invoke();
if (ActiveSession == vm)
ActiveSession = Sessions.LastOrDefault();
diff --git a/src/CodeShellManager/ViewModels/SessionViewModel.cs b/src/CodeShellManager/ViewModels/SessionViewModel.cs
index 8b2b615..1b2d133 100644
--- a/src/CodeShellManager/ViewModels/SessionViewModel.cs
+++ b/src/CodeShellManager/ViewModels/SessionViewModel.cs
@@ -21,6 +21,10 @@ public partial class SessionViewModel : ObservableObject, IDisposable
[ObservableProperty] private string? _gitBranch;
[ObservableProperty] private bool _gitIsDirty;
[ObservableProperty] private bool _gitInfoLoaded;
+ /// Absolute path to the session's repo top-level, or null if the working folder is not in a git repo.
+ [ObservableProperty] private string? _repoRoot;
+ /// Set by MainWindow whenever another live session shares this session's RepoRoot.
+ [ObservableProperty] private bool _hasWorktreeSiblings;
public PseudoTerminal? Pty { get; set; }
public TerminalBridge? Bridge { get; set; }
@@ -40,7 +44,11 @@ public partial class SessionViewModel : ObservableObject, IDisposable
? (string.IsNullOrWhiteSpace(Session.SshUser)
? Session.SshHost
: $"{Session.SshUser}@{Session.SshHost}")
- : Session.WorkingFolder);
+ // Key on RepoRoot when known so worktree siblings share a color;
+ // fall back to WorkingFolder for non-git sessions.
+ : (string.IsNullOrEmpty(RepoRoot) ? Session.WorkingFolder : RepoRoot));
+
+ partial void OnRepoRootChanged(string? value) => OnPropertyChanged(nameof(AccentColor));
public string DisplayName => string.IsNullOrWhiteSpace(Session.Name)
? (Session.IsRemote
@@ -76,6 +84,24 @@ public async Task RefreshGitInfoAsync()
GitBranch = branch;
GitIsDirty = isDirty;
GitInfoLoaded = true;
+
+ // RepoRoot is stable for the life of the session — resolve it once. Don't gate on
+ // a non-empty branch: detached HEADs report no branch but are still valid repos
+ // that should participate in sibling detection, shared accent color, and clusters.
+ if (RepoRoot == null)
+ RepoRoot = await GitService.GetRepoRootAsync(Session.WorkingFolder);
+ }
+
+ /// Short repo + branch label shown beneath the session name when sibling worktrees are open.
+ public string WorktreeSubtitle
+ {
+ get
+ {
+ if (string.IsNullOrEmpty(RepoRoot)) return "";
+ string repoName = System.IO.Path.GetFileName(RepoRoot.TrimEnd('/', '\\')) ?? "";
+ string branch = string.IsNullOrEmpty(GitBranch) ? "—" : GitBranch;
+ return $"\U0001F4C1 {repoName} ⎇ {branch}";
+ }
}
private async Task PollGitInfoAsync(CancellationToken ct)
diff --git a/src/CodeShellManager/Views/InputBoxDialog.xaml b/src/CodeShellManager/Views/InputBoxDialog.xaml
new file mode 100644
index 0000000..47d550e
--- /dev/null
+++ b/src/CodeShellManager/Views/InputBoxDialog.xaml
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/CodeShellManager/Views/InputBoxDialog.xaml.cs b/src/CodeShellManager/Views/InputBoxDialog.xaml.cs
new file mode 100644
index 0000000..e1f5b66
--- /dev/null
+++ b/src/CodeShellManager/Views/InputBoxDialog.xaml.cs
@@ -0,0 +1,38 @@
+using System.Windows;
+
+namespace CodeShellManager.Views;
+
+public partial class InputBoxDialog : Window
+{
+ public string Value => ValueBox.Text;
+
+ public InputBoxDialog(string title, string prompt, string initial)
+ {
+ InitializeComponent();
+ Title = title;
+ PromptText.Text = prompt;
+ ValueBox.Text = initial;
+ Loaded += (_, _) => { ValueBox.Focus(); ValueBox.SelectAll(); };
+ }
+
+ /// Modal helper. Returns the entered text trimmed, or null if the user cancelled / left it empty.
+ public static string? Prompt(Window owner, string title, string prompt, string initial)
+ {
+ var dlg = new InputBoxDialog(title, prompt, initial) { Owner = owner };
+ if (dlg.ShowDialog() != true) return null;
+ var trimmed = dlg.Value.Trim();
+ return string.IsNullOrEmpty(trimmed) ? null : trimmed;
+ }
+
+ private void Ok_Click(object sender, RoutedEventArgs e)
+ {
+ DialogResult = true;
+ Close();
+ }
+
+ private void Cancel_Click(object sender, RoutedEventArgs e)
+ {
+ DialogResult = false;
+ Close();
+ }
+}
diff --git a/src/CodeShellManager/Views/NewSessionDialog.xaml b/src/CodeShellManager/Views/NewSessionDialog.xaml
index 721c2a8..d285b70 100644
--- a/src/CodeShellManager/Views/NewSessionDialog.xaml
+++ b/src/CodeShellManager/Views/NewSessionDialog.xaml
@@ -1,7 +1,7 @@
@@ -152,6 +152,7 @@
+
@@ -238,8 +239,19 @@
+
+
+
+
+
+
+
+
+
+
-
+
diff --git a/src/CodeShellManager/Views/NewSessionDialog.xaml.cs b/src/CodeShellManager/Views/NewSessionDialog.xaml.cs
index 586c169..ad63e6d 100644
--- a/src/CodeShellManager/Views/NewSessionDialog.xaml.cs
+++ b/src/CodeShellManager/Views/NewSessionDialog.xaml.cs
@@ -43,12 +43,21 @@ public partial class NewSessionDialog : Window
public bool? ProfileRetroEffect { get; private set; }
public string? ProfileColorSchemeJson { get; private set; }
+ /// Paths of sibling worktrees the user opted to also launch sessions for.
+ public IReadOnlyList AdditionalWorktreePaths { get; private set; } = Array.Empty();
+
private readonly IReadOnlyList _profiles;
+ private readonly System.Windows.Threading.DispatcherTimer _worktreeDebounce;
+ private System.Threading.CancellationTokenSource? _worktreeProbeCts;
+ private string? _lastProbedFolder;
public NewSessionDialog(
string defaultFolder = "",
IEnumerable? launchCommands = null,
- IReadOnlyList? profiles = null)
+ IReadOnlyList? profiles = null,
+ string? defaultCommand = null,
+ string? defaultArgs = null,
+ string? defaultName = null)
{
InitializeComponent();
FolderBox.Text = defaultFolder;
@@ -59,7 +68,27 @@ public NewSessionDialog(
foreach (var cmd in launchCommands ?? DefaultCommands)
CommandCombo.Items.Add(new ComboBoxItem { Content = cmd, Tag = cmd });
CommandCombo.Items.Add(customItem);
- CommandCombo.SelectedIndex = 0;
+
+ // Pre-fill command if a parent session passed one through; otherwise default to first entry.
+ ComboBoxItem? matchedCmd = null;
+ if (!string.IsNullOrWhiteSpace(defaultCommand))
+ {
+ string combined = string.IsNullOrEmpty(defaultArgs)
+ ? defaultCommand
+ : $"{defaultCommand} {defaultArgs}";
+ matchedCmd = CommandCombo.Items.OfType()
+ .FirstOrDefault(it => string.Equals(it.Tag?.ToString(), combined, StringComparison.Ordinal));
+ if (matchedCmd == null)
+ {
+ // Fall back to [custom] + populate args box.
+ matchedCmd = CommandCombo.Items.OfType()
+ .FirstOrDefault(it => it.Tag?.ToString() == "custom");
+ CustomArgsBox.Text = combined;
+ }
+ }
+ CommandCombo.SelectedItem = matchedCmd ?? CommandCombo.Items[0];
+
+ if (!string.IsNullOrWhiteSpace(defaultName)) NameBox.Text = defaultName;
if (_profiles.Count > 0)
{
@@ -70,8 +99,92 @@ public NewSessionDialog(
ProfileCombo.SelectedIndex = 0;
}
- FolderBox.TextChanged += (_, _) => AutoFillName();
+ _worktreeDebounce = new System.Windows.Threading.DispatcherTimer
+ {
+ Interval = TimeSpan.FromMilliseconds(700)
+ };
+ _worktreeDebounce.Tick += async (_, _) =>
+ {
+ _worktreeDebounce.Stop();
+ await ProbeSiblingWorktreesAsync(FolderBox.Text.Trim());
+ };
+
+ FolderBox.TextChanged += (_, _) => { AutoFillName(); ScheduleWorktreeProbe(); };
SshHostBox.TextChanged += (_, _) => AutoFillName();
+
+ Loaded += async (_, _) =>
+ {
+ if (!IsRemoteMode && !string.IsNullOrWhiteSpace(FolderBox.Text))
+ await ProbeSiblingWorktreesAsync(FolderBox.Text.Trim());
+ };
+ }
+
+ private void ScheduleWorktreeProbe()
+ {
+ if (IsRemoteMode)
+ {
+ WorktreesPanel.Visibility = Visibility.Collapsed;
+ return;
+ }
+ _worktreeDebounce.Stop();
+ _worktreeDebounce.Start();
+ }
+
+ private async System.Threading.Tasks.Task ProbeSiblingWorktreesAsync(string folder)
+ {
+ if (string.IsNullOrWhiteSpace(folder) || !Directory.Exists(folder))
+ {
+ WorktreesPanel.Visibility = Visibility.Collapsed;
+ return;
+ }
+ if (folder == _lastProbedFolder) return;
+ _lastProbedFolder = folder;
+
+ _worktreeProbeCts?.Cancel();
+ var cts = new System.Threading.CancellationTokenSource();
+ _worktreeProbeCts = cts;
+
+ var worktrees = await Services.GitService.ListWorktreesAsync(folder);
+ if (cts.IsCancellationRequested) return;
+
+ // Exclude the chosen folder itself + bare repos. Normalize paths for comparison.
+ string norm = Path.TrimEndingDirectorySeparator(Path.GetFullPath(folder)).Replace('\\', '/');
+ var siblings = worktrees
+ .Where(w => !w.IsBare)
+ .Where(w =>
+ {
+ try
+ {
+ string wp = Path.TrimEndingDirectorySeparator(Path.GetFullPath(w.Path)).Replace('\\', '/');
+ return !string.Equals(wp, norm, StringComparison.OrdinalIgnoreCase);
+ }
+ catch { return true; }
+ })
+ .ToList();
+
+ WorktreesList.Children.Clear();
+ if (siblings.Count == 0)
+ {
+ WorktreesPanel.Visibility = Visibility.Collapsed;
+ return;
+ }
+ foreach (var w in siblings)
+ {
+ string label = string.IsNullOrEmpty(w.Branch)
+ ? (w.IsDetached ? $"{Path.GetFileName(w.Path)} (detached)" : Path.GetFileName(w.Path))
+ : $"{Path.GetFileName(w.Path)} ⎇ {w.Branch}";
+ var cb = new System.Windows.Controls.CheckBox
+ {
+ Content = label,
+ Tag = w.Path,
+ Foreground = new System.Windows.Media.SolidColorBrush(
+ System.Windows.Media.Color.FromRgb(0xcd, 0xd6, 0xf4)),
+ Margin = new Thickness(0, 2, 0, 2),
+ IsChecked = false
+ };
+ WorktreesList.Children.Add(cb);
+ }
+ WorktreesPanel.Visibility = Visibility.Visible;
}
private bool IsRemoteMode => RemoteRadio?.IsChecked == true;
@@ -107,6 +220,11 @@ private void SessionType_Changed(object sender, RoutedEventArgs e)
// Profile combobox is local-only
if (ProfilePanel != null && _profiles.Count > 0)
ProfilePanel.Visibility = IsRemoteMode ? Visibility.Collapsed : Visibility.Visible;
+ if (WorktreesPanel != null)
+ {
+ WorktreesPanel.Visibility = Visibility.Collapsed;
+ _lastProbedFolder = null;
+ }
CommandLabel.Text = IsRemoteMode ? "Remote Shell" : "Command";
NameBox.Text = "";
AutoFillName();
@@ -195,6 +313,16 @@ private void Start_Click(object sender, RoutedEventArgs e)
IsRemote = IsRemoteMode;
SessionName = NameBox.Text.Trim();
+ if (!IsRemoteMode && WorktreesPanel.Visibility == Visibility.Visible)
+ {
+ AdditionalWorktreePaths = WorktreesList.Children.OfType()
+ .Where(c => c.IsChecked == true)
+ .Select(c => c.Tag as string)
+ .Where(p => !string.IsNullOrEmpty(p))
+ .Select(p => p!)
+ .ToList();
+ }
+
if (IsRemote)
{
if (string.IsNullOrWhiteSpace(SshHostBox.Text))
diff --git a/src/CodeShellManager/Views/NewWorktreeDialog.xaml b/src/CodeShellManager/Views/NewWorktreeDialog.xaml
new file mode 100644
index 0000000..5b4d782
--- /dev/null
+++ b/src/CodeShellManager/Views/NewWorktreeDialog.xaml
@@ -0,0 +1,201 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/CodeShellManager/Views/NewWorktreeDialog.xaml.cs b/src/CodeShellManager/Views/NewWorktreeDialog.xaml.cs
new file mode 100644
index 0000000..8501966
--- /dev/null
+++ b/src/CodeShellManager/Views/NewWorktreeDialog.xaml.cs
@@ -0,0 +1,140 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Windows;
+using System.Windows.Controls;
+using MessageBox = System.Windows.MessageBox;
+using MessageBoxButton = System.Windows.MessageBoxButton;
+using MessageBoxImage = System.Windows.MessageBoxImage;
+
+namespace CodeShellManager.Views;
+
+public partial class NewWorktreeDialog : Window
+{
+ public string RepoRoot { get; }
+ public string BranchOrRef { get; private set; } = "";
+ public bool CreateBranch { get; private set; } = true;
+ public string TargetPath { get; private set; } = "";
+ public string SessionName { get; private set; } = "";
+
+ private readonly string _currentBranch;
+ private readonly string _repoName;
+
+ public NewWorktreeDialog(string repoRoot, string currentBranch, IReadOnlyList branches)
+ {
+ InitializeComponent();
+ RepoRoot = repoRoot;
+ _currentBranch = currentBranch ?? "";
+ _repoName = Path.GetFileName(repoRoot.TrimEnd('/', '\\')) ?? "repo";
+
+ RepoLabel.Text = repoRoot;
+ BaseBranchHint.Text = string.IsNullOrEmpty(_currentBranch)
+ ? "Base: HEAD"
+ : $"Base: {_currentBranch}";
+
+ NewBranchBox.Text = SuggestNewBranchName();
+ NewBranchBox.TextChanged += (_, _) => UpdateDerivedFields();
+
+ ExistingBranchCombo.ItemsSource = branches;
+ if (branches.Count > 0)
+ ExistingBranchCombo.SelectedItem = branches.Contains(_currentBranch)
+ ? _currentBranch
+ : branches[0];
+ ExistingBranchCombo.SelectionChanged += (_, _) => UpdateDerivedFields();
+
+ UpdateDerivedFields();
+ }
+
+ private static string SuggestNewBranchName() =>
+ $"feat/{System.DateTime.Now:yyMMdd-HHmm}";
+
+ private string CurrentBranchInput => NewBranchRadio.IsChecked == true
+ ? NewBranchBox.Text.Trim()
+ : (ExistingBranchCombo.SelectedItem as string ?? "").Trim();
+
+ private void UpdateDerivedFields()
+ {
+ string branch = CurrentBranchInput;
+ if (string.IsNullOrEmpty(branch)) return;
+
+ string safe = branch.Replace('/', '-').Replace(' ', '-');
+ string parent = Path.GetDirectoryName(RepoRoot.TrimEnd('/', '\\'))
+ ?? RepoRoot;
+ TargetPathBox.Text = Path.Combine(parent, $"{_repoName}-{safe}");
+ if (string.IsNullOrWhiteSpace(SessionNameBox.Text))
+ SessionNameBox.Text = $"{_repoName} ⎇ {branch}";
+ }
+
+ private void BranchMode_Changed(object sender, RoutedEventArgs e)
+ {
+ if (NewBranchPanel == null) return;
+ bool newMode = NewBranchRadio.IsChecked == true;
+ NewBranchPanel.Visibility = newMode ? Visibility.Visible : Visibility.Collapsed;
+ ExistingBranchPanel.Visibility = newMode ? Visibility.Collapsed : Visibility.Visible;
+ // Clear auto-filled session name so the new branch choice can repopulate it.
+ SessionNameBox.Text = "";
+ UpdateDerivedFields();
+ }
+
+ private void Browse_Click(object sender, RoutedEventArgs e)
+ {
+ using var dlg = new System.Windows.Forms.FolderBrowserDialog
+ {
+ Description = "Select worktree folder",
+ UseDescriptionForTitle = true,
+ SelectedPath = TargetPathBox.Text
+ };
+ if (dlg.ShowDialog() == System.Windows.Forms.DialogResult.OK)
+ TargetPathBox.Text = dlg.SelectedPath;
+ }
+
+ private void Ok_Click(object sender, RoutedEventArgs e)
+ {
+ CreateBranch = NewBranchRadio.IsChecked == true;
+ BranchOrRef = CurrentBranchInput;
+ TargetPath = TargetPathBox.Text.Trim();
+ SessionName = SessionNameBox.Text.Trim();
+
+ if (string.IsNullOrWhiteSpace(BranchOrRef))
+ {
+ MessageBox.Show(this, "Branch name is required.", "Missing branch",
+ MessageBoxButton.OK, MessageBoxImage.Warning);
+ return;
+ }
+ if (string.IsNullOrWhiteSpace(TargetPath))
+ {
+ MessageBox.Show(this, "Worktree folder is required.", "Missing folder",
+ MessageBoxButton.OK, MessageBoxImage.Warning);
+ return;
+ }
+ if (Directory.Exists(TargetPath))
+ {
+ bool nonEmpty;
+ try { nonEmpty = Directory.EnumerateFileSystemEntries(TargetPath).Any(); }
+ catch (Exception ex)
+ {
+ MessageBox.Show(this,
+ $"Cannot inspect '{TargetPath}': {ex.Message}",
+ "Folder not usable", MessageBoxButton.OK, MessageBoxImage.Warning);
+ return;
+ }
+ if (nonEmpty)
+ {
+ MessageBox.Show(this,
+ $"'{TargetPath}' already exists and is non-empty. git worktree add will refuse to use it.",
+ "Folder not empty", MessageBoxButton.OK, MessageBoxImage.Warning);
+ return;
+ }
+ }
+
+ DialogResult = true;
+ Close();
+ }
+
+ private void Cancel_Click(object sender, RoutedEventArgs e)
+ {
+ DialogResult = false;
+ Close();
+ }
+}
diff --git a/src/CodeShellManager/Views/SettingsWindow.xaml b/src/CodeShellManager/Views/SettingsWindow.xaml
index 183c2ff..69cbc50 100644
--- a/src/CodeShellManager/Views/SettingsWindow.xaml
+++ b/src/CodeShellManager/Views/SettingsWindow.xaml
@@ -219,6 +219,18 @@
+
+
+
+
+
+
+
+
+
+
(modeTag, out var newMode))
+ {
+ _edited.GroupDisplayMode = newMode;
+ // Keep the legacy flag in sync so a downgrade to an older build still respects None.
+ _edited.ShowGroupsTab = newMode != Models.GroupDisplayMode.None;
+ }
_edited.ImportWindowsTerminalProfiles = ImportWindowsTerminalProfilesCheck.IsChecked == true;
_edited.SearchCollapseAfterNavigate = SearchCollapseAfterNavigateCheck.IsChecked == true;
_edited.AnthropicApiKey = ApiKeyBox.Password;