Skip to content

Commit adb6182

Browse files
committed
Merge main into feat/terminal-diagnostics + address review feedback
Conflicts: OnLoaded restore loop and OnClosing teardown both diverged from the staggered-shutdown work that landed in #37. Resolutions: - OnLoaded: keep main's launching-placeholder + sequential staggered launch flow; layer this PR's batched WebView2-access-denied dialog on top so a single consolidated message replaces the previous N per-session popups. Drop the duplicate dormant-loop tail (main now adds dormant entries up-front before launches start). - OnClosing: keep main's claude-aware sequential disposal and DisposeAndWaitForExitAsync wait; layer this PR's try/catch around _db.Close()/_db.Dispose() in to swallow the SqliteConnection NRE observed during shutdown. Review fixes (from PR self-review): - OnClosing was async void with multiple awaits, which WPF doesn't wait for. Switched to the standard e.Cancel=true + reclose pattern gated by _shutdownComplete so async cleanup actually finishes before the window tears down. - _lastOutputTickMs in TerminalBridge.OnPtyData was read-modify-written without a memory barrier. Replaced with Interlocked.Exchange — atomic read+write in one call, and gives the JIT a barrier even though the PTY read loop is currently single-threaded.
2 parents 092b801 + 13bf935 commit adb6182

6 files changed

Lines changed: 460 additions & 41 deletions

File tree

src/CodeShellManager/MainWindow.xaml.cs

Lines changed: 307 additions & 38 deletions
Large diffs are not rendered by default.

src/CodeShellManager/Models/AppState.cs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,13 @@ public class AppSettings
3333
{
3434
public bool AutoRestoreSessions { get; set; } = true;
3535
public bool AutoResumeClaude { get; set; } = true;
36+
/// <summary>
37+
/// Milliseconds to wait between consecutive Claude session launches (and shutdowns).
38+
/// The Claude CLI performs an unlocked read-modify-write on <c>~/.claude.json</c> on
39+
/// startup and exit, so two claude.exe processes touching the file at the same time
40+
/// can corrupt it. Spacing them out by ~2s avoids the race. 0 disables the stagger.
41+
/// </summary>
42+
public int ClaudeLaunchStaggerMs { get; set; } = 2000;
3643
public bool AutoFocusTerminalOnSelect { get; set; } = true;
3744
public bool ShowToastNotifications { get; set; } = false;
3845
public bool ShowNotificationSound { get; set; } = false;
@@ -43,6 +50,19 @@ public class AppSettings
4350
/// <summary>Authoritative grouping UI selector. Replaces the legacy <see cref="ShowGroupsTab"/> boolean.</summary>
4451
public GroupDisplayMode GroupDisplayMode { get; set; } = GroupDisplayMode.FilterStrip;
4552
/// <summary>
53+
/// In FilterStrip mode with an active group filter, restrict the terminal grid
54+
/// (multi-pane layouts) to sessions belonging to that group. The sidebar already
55+
/// hides non-matching rows; with this on, the panes match. Off = the grid keeps
56+
/// showing every live session regardless of group filter.
57+
/// </summary>
58+
public bool FilterGridByActiveGroup { get; set; } = true;
59+
/// <summary>
60+
/// Remember the grid layout (Single / TwoByTwo / etc.) separately per group so each
61+
/// group restores its own layout when selected. See <see cref="AppState.GroupLayouts"/>
62+
/// for the backing store.
63+
/// </summary>
64+
public bool PerGroupLayout { get; set; } = true;
65+
/// <summary>
4666
/// Legacy flag — kept for back-compat with older state.json files. When deserialized
4767
/// as false on a state that still has GroupDisplayMode at its default, the loader
4868
/// migrates the mode to None. Newer code paths read GroupDisplayMode instead.
@@ -121,6 +141,13 @@ public class AppState
121141
public List<ShellSession> Sessions { get; set; } = [];
122142
public List<SessionGroup> Groups { get; set; } = [];
123143
public string LastLayout { get; set; } = "Single";
144+
/// <summary>
145+
/// Per-group grid layouts when <see cref="AppSettings.PerGroupLayout"/> is on.
146+
/// Key = group Id, <c>GroupFilter.Ungrouped</c>, or <c>"__ALL__"</c> for the
147+
/// no-filter view. Value = <see cref="ViewModels.LayoutMode"/> name. Missing
148+
/// keys fall back to <see cref="LastLayout"/>.
149+
/// </summary>
150+
public Dictionary<string, string> GroupLayouts { get; set; } = new();
124151
public AppSettings Settings { get; set; } = new();
125152

126153
// Window state persistence

src/CodeShellManager/Terminal/TerminalBridge.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -182,8 +182,8 @@ private void OnPtyData(string rawData)
182182
if (DebugSettings?.DebugTerminalTrace == true)
183183
{
184184
long now = Environment.TickCount64;
185-
long gap = _lastOutputTickMs == 0 ? 0 : now - _lastOutputTickMs;
186-
_lastOutputTickMs = now;
185+
long prev = System.Threading.Interlocked.Exchange(ref _lastOutputTickMs, now);
186+
long gap = prev == 0 ? 0 : now - prev;
187187
Trace($"OUTPUT recv len={rawData.Length} gap-since-prev={gap}ms");
188188
}
189189

src/CodeShellManager/ViewModels/MainViewModel.cs

Lines changed: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ public enum LayoutMode { Single, TwoColumn, ThreeColumn, TwoByTwo, TwoRow, FourC
1616
public static class GroupFilter
1717
{
1818
public const string Ungrouped = "__UNGROUPED__";
19+
/// <summary>Key used in <see cref="Models.AppState.GroupLayouts"/> for the no-filter ("All") view.</summary>
20+
public const string AllKey = "__ALL__";
1921
}
2022

2123
public partial class MainViewModel : ObservableObject
@@ -38,6 +40,15 @@ public partial class MainViewModel : ObservableObject
3840
/// </summary>
3941
[ObservableProperty] private string? _activeGroupId;
4042

43+
/// <summary>Guard so layout assignments driven by state-load or per-group restore don't write back to GroupLayouts.</summary>
44+
private bool _suppressLayoutPersist;
45+
46+
/// <summary>Tracks the previous effective group key so the per-group handler can save the old slot before switching.</summary>
47+
private string _lastEffectiveLayoutKey = GroupFilter.AllKey;
48+
49+
/// <summary>Key used to look up the current view's layout in <see cref="AppState.GroupLayouts"/>.</summary>
50+
private string CurrentLayoutKey => EffectiveActiveGroupId ?? GroupFilter.AllKey;
51+
4152
/// <summary>IDs of sessions currently in the multi-select set (in addition to ActiveSession).</summary>
4253
public HashSet<string> SelectedSessionIds { get; } = new();
4354

@@ -96,6 +107,36 @@ public bool SessionMatchesActiveGroup(SessionViewModel vm)
96107
return vm.GroupId == ActiveGroupId;
97108
}
98109

110+
/// <summary>
111+
/// The group the main grid is currently scoped to, accounting for display mode:
112+
/// FilterStrip = ActiveGroupId (explicit tab); InlineHeaders = the ActiveSession's
113+
/// group (there's no tab strip, so the focused session is the implicit selector);
114+
/// None = null (no group concept). Returns <see cref="GroupFilter.Ungrouped"/> for
115+
/// sessions without a group, or the group id, or null for "no filter".
116+
/// </summary>
117+
public string? EffectiveActiveGroupId
118+
{
119+
get
120+
{
121+
var mode = Settings.GroupDisplayMode;
122+
if (mode == Models.GroupDisplayMode.FilterStrip) return ActiveGroupId;
123+
if (mode == Models.GroupDisplayMode.InlineHeaders && ActiveSession != null)
124+
return string.IsNullOrEmpty(ActiveSession.GroupId)
125+
? GroupFilter.Ungrouped
126+
: ActiveSession.GroupId;
127+
return null;
128+
}
129+
}
130+
131+
/// <summary>Like <see cref="SessionMatchesActiveGroup"/> but uses <see cref="EffectiveActiveGroupId"/>.</summary>
132+
public bool SessionMatchesEffectiveGroup(SessionViewModel vm)
133+
{
134+
var eff = EffectiveActiveGroupId;
135+
if (eff == null) return true;
136+
if (eff == GroupFilter.Ungrouped) return string.IsNullOrEmpty(vm.GroupId);
137+
return vm.GroupId == eff;
138+
}
139+
99140
public bool IsSelected(string sessionId) => SelectedSessionIds.Contains(sessionId);
100141

101142
public void ClearSelection()
@@ -160,7 +201,12 @@ public async Task LoadStateAsync()
160201
{
161202
_appState = await _stateService.LoadAsync();
162203
_sessionManager.LoadFromState(_appState);
163-
Layout = Enum.TryParse<LayoutMode>(_appState.LastLayout, out var lm) ? lm : LayoutMode.Single;
204+
_suppressLayoutPersist = true;
205+
try
206+
{
207+
Layout = Enum.TryParse<LayoutMode>(_appState.LastLayout, out var lm) ? lm : LayoutMode.Single;
208+
}
209+
finally { _suppressLayoutPersist = false; }
164210

165211
// Legacy migration: pre-enum installs persisted "ShowGroupsTab=false" to hide
166212
// the strip. Translate to the new enum on first load with the new code.
@@ -317,4 +363,58 @@ public void MoveSession(string sessionId, int newIndex)
317363
if (cur != newIndex) Sessions.Move(cur, newIndex);
318364
_ = SaveStateAsync();
319365
}
366+
367+
partial void OnLayoutChanged(LayoutMode value)
368+
{
369+
if (_suppressLayoutPersist) return;
370+
if (!Settings.PerGroupLayout) return;
371+
_appState.GroupLayouts[CurrentLayoutKey] = value.ToString();
372+
}
373+
374+
partial void OnActiveGroupIdChanged(string? oldValue, string? newValue) => HandleEffectiveGroupChanged();
375+
376+
partial void OnActiveSessionChanged(SessionViewModel? oldValue, SessionViewModel? newValue)
377+
{
378+
// ActiveSession only contributes to the effective group in InlineHeaders mode —
379+
// in other modes its change doesn't move us between groups.
380+
if (Settings.GroupDisplayMode != Models.GroupDisplayMode.InlineHeaders) return;
381+
HandleEffectiveGroupChanged();
382+
}
383+
384+
/// <summary>
385+
/// Called whenever the effective group filter may have changed (ActiveGroupId in
386+
/// FilterStrip mode, or ActiveSession in InlineHeaders mode). Saves the old group's
387+
/// layout if not already persisted, then restores the new group's saved layout if any.
388+
/// </summary>
389+
private void HandleEffectiveGroupChanged()
390+
{
391+
if (!Settings.PerGroupLayout) return;
392+
string newKey = EffectiveActiveGroupId ?? GroupFilter.AllKey;
393+
if (newKey == _lastEffectiveLayoutKey) return;
394+
string oldKey = _lastEffectiveLayoutKey;
395+
_lastEffectiveLayoutKey = newKey;
396+
397+
// Seed the old key with the current layout in case the user never explicitly
398+
// changed it there — otherwise round-tripping back to that group would miss.
399+
bool seeded = false;
400+
if (!_appState.GroupLayouts.ContainsKey(oldKey))
401+
{
402+
_appState.GroupLayouts[oldKey] = Layout.ToString();
403+
seeded = true;
404+
}
405+
406+
bool layoutSwitched = false;
407+
if (_appState.GroupLayouts.TryGetValue(newKey, out var s)
408+
&& Enum.TryParse<LayoutMode>(s, out var lm)
409+
&& lm != Layout)
410+
{
411+
_suppressLayoutPersist = true;
412+
try { Layout = lm; }
413+
finally { _suppressLayoutPersist = false; }
414+
layoutSwitched = true;
415+
}
416+
417+
if (seeded || layoutSwitched)
418+
_ = SaveStateAsync();
419+
}
320420
}

src/CodeShellManager/Views/SettingsWindow.xaml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,13 @@
212212
Margin="0,4,0,0"
213213
ToolTip="When clicking a session in the sidebar (or cycling with Ctrl+Tab), move keyboard focus into the terminal so you can start typing immediately."/>
214214

215+
<StackPanel Margin="0,10,0,0">
216+
<TextBlock Text="Claude launch / shutdown stagger (ms)" Style="{StaticResource Label}"/>
217+
<TextBox x:Name="ClaudeLaunchStaggerBox" Width="100" HorizontalAlignment="Left"/>
218+
<TextBlock Text="Delay between consecutive Claude sessions during restore-on-startup and app shutdown. Claude's CLI rewrites ~/.claude.json on startup/exit without locking, so back-to-back Claude processes can corrupt it. 2000 ms is the safe default; set 0 to disable."
219+
Foreground="#6c7086" FontSize="10" Margin="0,4,0,0" TextWrapping="Wrap"/>
220+
</StackPanel>
221+
215222
<!-- Appearance -->
216223
<TextBlock Text="APPEARANCE" Style="{StaticResource SectionHeader}"/>
217224
<Border BorderBrush="#313244" BorderThickness="0,0,0,1" Margin="0,4,0,0"/>
@@ -239,6 +246,12 @@
239246
<TextBlock Text="Filter strip = one group view at a time, via a tab strip. Inline headers = all groups visible at once, expand/collapse per group."
240247
Foreground="#6c7086" FontSize="10" Margin="0,4,0,0" TextWrapping="Wrap"/>
241248
</StackPanel>
249+
<CheckBox x:Name="FilterGridByActiveGroupCheck"
250+
Content="Limit terminal grid to active group"
251+
ToolTip="When a group filter is selected in the strip, the multi-pane grid only shows sessions from that group. Off = the grid keeps showing every live session regardless of which group is selected."/>
252+
<CheckBox x:Name="PerGroupLayoutCheck"
253+
Content="Remember layout per group"
254+
ToolTip="Switching to a group restores the grid layout (Single / 2×2 / etc.) you last used while that group was active. Off = a single global layout shared across groups."/>
242255
<CheckBox x:Name="ShowWorktreeClustersCheck" Content="Group worktree siblings in sidebar"
243256
ToolTip="When 2+ adjacent sessions share a git repo, render them under a small '📁 repo (N)' header so the worktree cluster is visible at a glance. Off = only the shared stripe color + subtitle line indicate worktree siblings."/>
244257
<CheckBox x:Name="ImportWindowsTerminalProfilesCheck"

src/CodeShellManager/Views/SettingsWindow.xaml.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ public SettingsWindow(AppSettings current, SearchService? searchService = null)
2424
{
2525
AutoRestoreSessions = current.AutoRestoreSessions,
2626
AutoResumeClaude = current.AutoResumeClaude,
27+
ClaudeLaunchStaggerMs = current.ClaudeLaunchStaggerMs,
2728
AutoFocusTerminalOnSelect = current.AutoFocusTerminalOnSelect,
2829
ShowToastNotifications = current.ShowToastNotifications,
2930
ShowNotificationSound = current.ShowNotificationSound,
@@ -33,6 +34,8 @@ public SettingsWindow(AppSettings current, SearchService? searchService = null)
3334
ShowGitBranch = current.ShowGitBranch,
3435
ShowGroupsTab = current.ShowGroupsTab,
3536
GroupDisplayMode = current.GroupDisplayMode,
37+
FilterGridByActiveGroup = current.FilterGridByActiveGroup,
38+
PerGroupLayout = current.PerGroupLayout,
3639
SidebarActionIconsMode = current.SidebarActionIconsMode,
3740
ShowWorktreeClusters = current.ShowWorktreeClusters,
3841
SearchCollapseAfterNavigate = current.SearchCollapseAfterNavigate,
@@ -56,6 +59,7 @@ public SettingsWindow(AppSettings current, SearchService? searchService = null)
5659
DefaultFolderBox.Text = _edited.DefaultWorkingFolder;
5760
AutoRestoreCheck.IsChecked = _edited.AutoRestoreSessions;
5861
AutoResumeClaudeCheck.IsChecked = _edited.AutoResumeClaude;
62+
ClaudeLaunchStaggerBox.Text = _edited.ClaudeLaunchStaggerMs.ToString();
5963
AutoFocusTerminalOnSelectCheck.IsChecked = _edited.AutoFocusTerminalOnSelect;
6064
ShowToastCheck.IsChecked = _edited.ShowToastNotifications;
6165
ShowNotificationSoundCheck.IsChecked = _edited.ShowNotificationSound;
@@ -82,6 +86,8 @@ public SettingsWindow(AppSettings current, SearchService? searchService = null)
8286
}
8387
if (SidebarActionIconsModeCombo.SelectedIndex < 0)
8488
SidebarActionIconsModeCombo.SelectedIndex = 0; // OnHover default
89+
FilterGridByActiveGroupCheck.IsChecked = _edited.FilterGridByActiveGroup;
90+
PerGroupLayoutCheck.IsChecked = _edited.PerGroupLayout;
8591
ImportWindowsTerminalProfilesCheck.IsChecked = _edited.ImportWindowsTerminalProfiles;
8692
SearchCollapseAfterNavigateCheck.IsChecked = _edited.SearchCollapseAfterNavigate;
8793
MaxSearchResultsBox.Text = _edited.MaxSearchResults.ToString();
@@ -141,6 +147,8 @@ private void Save_Click(object sender, RoutedEventArgs e)
141147
_edited.DefaultWorkingFolder = DefaultFolderBox.Text.Trim();
142148
_edited.AutoRestoreSessions = AutoRestoreCheck.IsChecked == true;
143149
_edited.AutoResumeClaude = AutoResumeClaudeCheck.IsChecked == true;
150+
if (int.TryParse(ClaudeLaunchStaggerBox.Text, out int staggerMs) && staggerMs >= 0)
151+
_edited.ClaudeLaunchStaggerMs = staggerMs;
144152
_edited.AutoFocusTerminalOnSelect = AutoFocusTerminalOnSelectCheck.IsChecked == true;
145153
_edited.ShowToastNotifications = ShowToastCheck.IsChecked == true;
146154
_edited.ShowNotificationSound = ShowNotificationSoundCheck.IsChecked == true;
@@ -157,6 +165,8 @@ private void Save_Click(object sender, RoutedEventArgs e)
157165
var iconsModeTag = (SidebarActionIconsModeCombo.SelectedItem as ComboBoxItem)?.Tag?.ToString();
158166
if (System.Enum.TryParse<Models.SidebarActionIconsMode>(iconsModeTag, out var newIconsMode))
159167
_edited.SidebarActionIconsMode = newIconsMode;
168+
_edited.FilterGridByActiveGroup = FilterGridByActiveGroupCheck.IsChecked == true;
169+
_edited.PerGroupLayout = PerGroupLayoutCheck.IsChecked == true;
160170
_edited.ImportWindowsTerminalProfiles = ImportWindowsTerminalProfilesCheck.IsChecked == true;
161171
_edited.SearchCollapseAfterNavigate = SearchCollapseAfterNavigateCheck.IsChecked == true;
162172
_edited.AnthropicApiKey = ApiKeyBox.Password;

0 commit comments

Comments
 (0)