Skip to content

feat: session groups + per-branch worktrees#28

Merged
AThraen merged 15 commits into
mainfrom
feat/session-groups-worktrees
May 12, 2026
Merged

feat: session groups + per-branch worktrees#28
AThraen merged 15 commits into
mainfrom
feat/session-groups-worktrees

Conversation

@mortenaslo
Copy link
Copy Markdown
Contributor

@mortenaslo mortenaslo commented May 12, 2026

Summary

Three related sidebar features for taming a large session list:

  • Session groups (categories) — sessions can be organised into named groups. The grouping UI is now a 3-way setting (Settings → Appearance → "Session group display"):
    • None — flat session list, no group UI surfaced.
    • Vertical filter strip (default) — narrow tab strip to the left of the session list. Each tab filters the list (All / Ungrouped / per-group), with notification pips per tab (alert count / approval / input).
    • Inline collapsible headers — collapsible ▶/▼ GroupName N rows directly in the sidebar; all groups visible at once, expand/collapse per group (persisted via SessionGroup.IsExpanded). Ungrouped sessions sit under an implicit header at the top.
    • Shift/Ctrl-click multi-select. Right-click → Add to group ▸ / Remove from group / Sleep / Close. Drag a session onto a group tab/header to assign it. Drag group tabs/headers to reorder (right-click also offers Move up / Move down). Worktree clusters still form within each section.
  • Worktrees — right-click any local git session → New worktree from this branch… creates a sibling worktree (new or existing branch) and launches a session cloning the source's command, args, group, and profile. The New Session dialog auto-detects existing sibling worktrees and offers a multi-select "Also start sessions in:" panel. A right-click → "New session in sibling worktree ▸" submenu adds an already-existing worktree on demand. Sibling identity uses git rev-parse --git-common-dir (shared across all worktrees of a repo) so siblings always match, share an accent color, and cluster automatically under a small 📁 repo · N header.
  • Spawn near parent — per-session ➕ button + right-click → "Duplicate session" / "New session here…". Ctrl+Shift+T duplicates the active session (browser-style). New sessions land at parentIndex+1 in both SessionManager.Sessions and the VM Sessions collection, inheriting the parent's group + profile. Top-level "+ New Session" while a group filter is active also lands the new session in that group.

Also fixes the sidebar header + button being right-clipped and vertically misaligned with SESSIONS, and gives the dark theme proper coverage on the worktree dialog's branch dropdown.

Related issues

Migration

  • Legacy "Default" group. Pre-existing installs had every session implicitly in an auto-created "Default" group. On the first load after this upgrade, SessionManager.LoadFromState detects the lone legacy group (count == 1, name == "Default", SortOrder == 0) and silently drops it, clearing the matching GroupIds. Result: existing users see no group tabs/headers at all until they create one themselves.
  • ShowGroupsTabGroupDisplayMode. An earlier iteration of this branch added a ShowGroupsTab boolean; that's been superseded by the three-way GroupDisplayMode enum. On load, if GroupDisplayMode is still at its default (FilterStrip) AND the legacy ShowGroupsTab is false, the mode is migrated to None. ShowGroupsTab is kept in sync on save (= mode != None) so a downgrade to an older build still respects the "no strip" choice.

Test plan

Recommend running with --clean for an isolated state during exploration:

dotnet run --project src/CodeShellManager/CodeShellManager.csproj -- --clean

Sidebar polish

  • + fix: narrow the sidebar; the + no longer clips on the right and is vertically aligned with the SESSIONS label.
  • Worktree dialog dropdown: right-click a git session → "New worktree from this branch…", choose "Use existing branch" → the branch dropdown is dark with readable text (no white-on-white).

Group display modes (#24)

  • None mode: Settings → "Session group display" → "None". The strip disappears; the sidebar is a flat list. Group memberships persist in the data layer; switching to another mode brings them back.
  • Filter strip mode: default. Vertical strip on the left with All / Ungrouped / per-group tabs. Auto-hidden when no groups exist.
  • Inline headers mode: switch to "Inline collapsible headers". Each group renders as a ▶/▼ GroupName N row in the sidebar; click toggles expand/collapse (persisted across restarts). Ungrouped sessions sit under an implicit "Ungrouped" header. The filter strip is hidden in this mode.
  • Multi-select: open 3+ sessions. Ctrl-click two of them — both get a blue tint (still visible after the mouse leaves). Shift-click extends a range from the last anchor. Active session always shows the accent-coloured ring so it stays distinct from the selection.
  • Drag-and-drop:
    • Drag a session onto a group tab (filter strip mode) or group header (inline mode) → session is reassigned.
    • Multi-select a few sessions, drag any one onto a group → the whole set moves.
    • In inline mode, drag a session from one group's section into the body of another section (between session items, not onto the header) → session is reassigned to the new section and inserted at the drop position.
    • Drag a group tab / header to reorder — works in both strip and inline modes.
    • Drag a group onto "Ungrouped" → no-op (Ungrouped isn't a real group, can't host other groups); drag a session onto "Ungrouped" → session's group is cleared.
  • Create group from selection: right-click any selected sidebar item → Add to group ▸ New group…. Sessions move under the new group.
  • Rename / delete / move up / move down: right-click a group tab or inline header. Delete confirms and reverts members to ungrouped — sessions stay open.

Worktrees (#23)

  • Create from branch: right-click a git session → New worktree from this branch…. Pick "Create new branch from current" or "Use existing branch", confirm. New session launches in the worktree folder.
  • Failure modes: right-click a non-git folder session → friendly "not in a git repo" message. Target folder already non-empty → dialog rejects before running git.
  • Sibling auto-detect in New Session: pick a working folder that lives inside a repo with multiple worktrees. Within ~700ms the dialog reveals "Also start sessions in sibling worktrees:" with checkboxes. Selected siblings launch alongside the primary, inheriting command + group + profile.
  • Sibling submenu: right-click an existing worktree session → "New session in sibling worktree ▸". Submenu populates on hover with worktrees that aren't already open.
  • Shared accent + cluster header: open two sessions in the same repo. Both show the same sidebar stripe color, share a 📁 repo ⎇ branch subtitle, and (with the cluster setting on) sit under a 📁 repo · 2 header.
  • Auto-cluster on add/remove: open a session in a repo, then open another session in the same repo unrelated to the worktree dialog (e.g. via Ctrl+T from a different folder of the same repo). The two should pull together into a cluster automatically — no need for them to be created via the worktree dialog. Closing one dissolves the cluster.

Spawn near parent (#27)

  • Per-session ➕: hover a sidebar item, click ➕. Dialog opens pre-filled with that session's folder + command. New session lands immediately after parent, inherits group.
  • Duplicate (right-click + Ctrl+Shift+T): right-click → Duplicate session, or press Ctrl+Shift+T with the session active. Same folder, same command, derived name, lands after parent.
  • Active filter inheritance: with a real group filter active, click "+ New Session" → the new session is automatically assigned to that group.

Migration

  • On the first launch after this PR (existing install only), the legacy Default group disappears silently; sessions are not lost.
  • An install that had the older ShowGroupsTab=false setting saved should come up in None mode (no strip, no inline headers).

Out of scope / not in this PR

  • "Open in new window" / detaching sessions.

🤖 Generated with Claude Code

mortenaslo and others added 14 commits May 12, 2026 11:06
Adds two related sidebar features:

- Categories: a vertical group-tab strip to the left of the session list
  that filters sessions by user-defined group. Shift/Ctrl-click on session
  items to multi-select, then right-click → "Add to group ▸". Strip auto-
  hides when no groups exist or when ShowGroupsTab is off. The legacy auto-
  created "Default" group is silently dropped on first load after upgrade.
- Worktrees: right-click a local git session → "New worktree from this
  branch…" creates a sibling worktree (new or existing branch) and launches
  a new session in it cloning the source session's command + profile. When
  2+ live sessions share a repo root, both show a "📁 repo ⎇ branch"
  subtitle so the relationship is visible inside any category.

Also fixes the sidebar header "+" button alignment and right-edge clipping.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Builds on the worktree + groups foundation to close issues #23 and #27:

- New Session dialog auto-detects sibling worktrees (`git worktree list
  --porcelain`) when the chosen folder is in a multi-worktree repo and
  shows a "Also start sessions in:" multi-select panel. Selected siblings
  launch with the same command + group + profile as the primary and
  cluster immediately after it in the sidebar.
- Per-session ➕ button in the sidebar action panel + "Duplicate session" /
  "New session here…" / "New session in sibling worktree ▸" entries in the
  right-click menu. The sibling-worktree submenu is populated on demand
  and skips worktrees already open as sessions.
- Ctrl+Shift+T duplicates the active session (browser-style).
- New sessions created from a parent inherit the parent's GroupId and
  profile overrides, and land at parentIndex+1 in both SessionManager and
  the VM Sessions collection (added afterSessionId parameter on
  CreateSession; RegisterSession mirrors the index).
- Worktree siblings share an accent color: SessionViewModel.AccentColor
  now keys on RepoRoot instead of WorkingFolder when the session is in
  a git repo, so siblings cluster visually. Sidebar stripe + active-ring
  repaint when RepoRoot resolves.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two follow-up bugs:

- The sidebar hover handlers reset the background to Transparent on
  MouseLeave without checking the selection set, so the multi-select
  blue tint disappeared as soon as the cursor moved away — the only
  surviving signal that a session was selected was the right-click
  count suffix. MouseEnter/MouseLeave now respect IsSelected and keep
  the tint when not actively hovering.
- AssignSessionsToGroup didn't notify the view layer, so the current
  group filter stayed stale until the user clicked a different tab.
  Added a SessionMembershipChanged event on MainViewModel, fired from
  AssignSessionsToGroup; MainWindow subscribes and calls
  RebuildSidebarOrder so the filter updates immediately.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- The active session's #313244 background got drowned out as soon as
  blue-tinted multi-select items appeared next to it. The active item
  now carries a 2px accent-colored border (the same color as the active
  terminal ring) AND uses the lighter Catppuccin Surface2 (#585b70) for
  its background when not also selected. Border thickness is constant
  (with a transparent placeholder on inactive items) so layout doesn't
  shift. Multi-selected and active states compose: selected bg + accent
  ring.
- InputBoxDialog (used for new/rename group) had a fixed 160px Height
  that clipped the bottom edge of the OK/Cancel buttons on some DPI
  setups. Switched to SizeToContent="Height" with a MinHeight so the
  dialog auto-fits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Each tab in the group strip now carries a small status pip in its top-
right corner that aggregates the state of the sessions inside the group:

- Pink badge with the alert count when any session has NeedsAttention.
- Orange dot when at least one session is waiting for tool approval.
- Green dot when at least one session is waiting for input.
- Hidden when the group has no active state.

Priority is alert count > approval > input — same hierarchy the per-
session sidebar dot uses. The "All" tab aggregates every live session;
"Ungrouped" only those with an empty GroupId; named groups match by id.

Refresh is wired to NeedsAttention/IsWaitingForInput/IsWaitingForApproval
property changes per session, to Sessions.CollectionChanged (add/close/
sleep/wake), and to SessionMembershipChanged (Add-to-group / Remove-from-
group). RebuildGroupStrip clears the indicator dictionary so each rebuild
gets fresh references.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds two ways to organize the group strip:

- Drag a group tab vertically within the strip to reorder it. "All" and
  "Ungrouped" stay pinned at the top (not draggable, not valid drop
  targets); only user groups participate. The Drop handler resolves the
  new index from the cursor position relative to the existing user-group
  tabs, ignoring the fixed pseudo-tabs and the "+" footer.
- Right-click any user group tab → "Move up" / "Move down". Items are
  disabled at the edges (first group can't move up; last can't move
  down).

Backend: SessionManager.MoveGroup mutates the in-memory list and
renumbers SortOrder on every group so the new order survives
serialization to state.json. MainViewModel.MoveGroup wraps it and
triggers SaveStateAsync. The existing GroupsChanged subscription in
MainWindow rebuilds the strip in the new order automatically.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The root cause of "no visible grouping with two worktrees" was the
sibling-detection key. GetRepoRootAsync used `git rev-parse --show-
toplevel`, which returns each worktree's own folder, not the shared
repo identity — so two worktrees of the same repo had different
RepoRoot strings and never matched as siblings (no shared accent
color, no subtitle, no cluster).

Now it uses `git rev-parse --git-common-dir` and strips the trailing
.git segment to derive a canonical repo path that's identical across
every worktree. Forward-slash-normalized for stable comparison on
Windows.

On top of the fix, worktree siblings now render under an explicit
labelled header in the sidebar: a thin "📁 repoName · N" banner
tinted with the shared accent color, sitting above each run of
adjacent visible siblings. The header count reflects what's visible
in the current category filter (siblings split across categories
don't pull each other into view).

Toggleable via a new ShowWorktreeClusters setting (default on);
when off, only the existing implicit signals (shared stripe color +
subtitle line) remain. RecomputeWorktreeSiblings now triggers
RebuildSidebarOrder when sibling flags actually change, so clusters
form/dissolve live as RepoRoot resolves or sessions come and go.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two follow-ups so the worktree clustering and category filter behave the
way the user expects:

- Auto-cluster reorder: when a session is added/removed or a session's
  RepoRoot resolves, RecomputeWorktreeSiblings now also pulls every
  session sharing a RepoRoot next to its first-seen anchor. Siblings
  always cluster — no longer dependent on the user creating them via
  the worktree dialog (which alone uses afterSessionId). The reorder
  is stable: first-occurrence order is preserved between clusters and
  for solo sessions, so unrelated sessions never get shuffled past
  each other.
- The Sessions.CollectionChanged subscription now filters Action=Move
  out of the recompute path so the in-place reorder doesn't recurse,
  and a user drag-to-reorder isn't immediately undone.
- New sessions launched from the toolbar/sidebar "+" while a real
  group filter is active now inherit that group automatically. Group
  resolution priority: explicit dialog selection > parent session's
  group > active filter group (skipping All/Ungrouped) > ungrouped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Group tabs are now drop targets for session-id drags:

- Drop a session onto a named group tab → that session's GroupId is
  set to the target group. If the dragged session is part of a multi-
  select set, the whole selection moves to the group (uses the existing
  ResolveActionTargets).
- Drop onto "Ungrouped" → clears GroupId for the target session(s).
- The "All" tab is a view, not a real group, so it doesn't accept drops.
- DragEnter tints the target tab with a stronger blue; DragLeave/Drop
  restore via UpdateGroupStripActiveState.

Payload disambiguation: session drags use the raw vm.Id, group reorder
drags use "group:<id>". The new IsSessionDragPayload helper routes each
to its correct handler — group tabs only respond to session payloads,
the GroupStripPanel only responds to "group:" payloads. Hardened the
SidebarSessionList Drop too so a stray group-tab drop on the session
list no longer shows a misleading "drop here" cursor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a GroupDisplayMode enum that replaces the legacy ShowGroupsTab
boolean as the authoritative grouping UI selector. Three modes:

- None — flat session list, no group UI surfaced at all
- FilterStrip (default) — the existing vertical tab strip
- InlineHeaders — collapsible group sections inline in the sidebar
  (the workflow described in #24)

InlineHeaders rendering:

- Each user group renders as a header bar with a ▶/▼ caret, the group
  name, and a member count. Click toggles expand/collapse via the
  existing SessionGroup.IsExpanded field (persisted to state.json).
- Sessions without a GroupId go under an implicit "Ungrouped" header at
  the top. The Ungrouped expand state is per-process (no need to
  persist a synthetic field).
- Worktree cluster headers still form WITHIN each group section — the
  count reflects visible siblings under that header, so split-across-
  groups doesn't show a misleading total.
- The filter strip is auto-hidden in this mode (mode-driven, not
  count-driven).

Drag-and-drop in inline mode:

- Drag a session onto a group header → assigns the dragged session
  (plus the rest of the multi-select set, if any) to that group.
- Drag a group header onto another → reorders groups. Drop directly
  on the sidebar background also reorders (position resolved by Y).
- Existing session-reorder drag on session items still works
  (constrained to within a group section by RebuildSidebarOrder).
- Group headers use MouseLeftButtonUp + a dragPending flag for the
  expand/collapse toggle, so a drag operation doesn't also fire the
  toggle.

Settings UI: replaces the on/off checkbox with a 3-way ComboBox.
Saves keep ShowGroupsTab in sync (= mode != None) so downgrades to
older builds still respect the "no strip" choice.

Migration: on load, if state has the legacy ShowGroupsTab=false AND
GroupDisplayMode is still at the default FilterStrip, treat as None.
Mode changes reset ActiveGroupId since the filter only applies in
FilterStrip mode.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
NewWorktreeDialog's ComboBox style only set Background/Foreground
but didn't override the ControlTemplate, so the dropdown popup fell
back to the system (light) theme — white text on a white popup.

Adopted the full dark ControlTemplate that NewSessionDialog and
SettingsWindow already use: dark popup Border with Catppuccin
Surface0/1 fills, hover/selected/highlighted triggers on items,
focus-blue trigger on the selection box when the dropdown is open.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
In inline-headers (and None) mode there's no ActiveGroupId — the group
filter only exists in FilterStrip mode — so the previous "inherit
active filter" fallback never fired for users on the new modes.
Added a final fallback: if no parent and no active filter, inherit
the group of the currently active session. Result: in inline mode,
clicking + while a session in "ProjectA" is active lands the new
session in "ProjectA" automatically.

Resolution priority is now:
  1. Explicit dialog selection (currently unused)
  2. Parent session's group (spawn-near-parent flows)
  3. Active filter (FilterStrip mode)
  4. Active session's group (InlineHeaders/None mode)
  5. Ungrouped

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
In inline-headers mode, dragging a session from one group section into
another now updates the session's GroupId to the target section
(instead of snapping back). The drop position resolves both:

- The target group: which section the cursor fell into (Ungrouped /
  any user group).
- The _vm.Sessions index: the insertion point within that section,
  computed against per-section session bounds (cluster / dormant /
  groupheader items are skipped, only real session items count).

If the drop falls past every session in the target section, the dragged
session lands at the section's tail (just after the last existing member
of that group in _vm.Sessions). Same-section drops keep the existing
within-section reorder semantics.

Multi-select: when the dragged session is part of a selection, the
cross-section reassign applies to the whole set (mirroring the existing
header-drop UX). The primary dragged session is moved to the resolved
index; the rest keep their _vm.Sessions positions, which preserves
their within-section order in the new group.

Updates the PR's "out of scope" note — cross-section drag is now in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Property reads aren't narrowed through !string.IsNullOrEmpty, so the
ternary kept typing s.RepoRoot as string?. Extracted a local ClusterKey
helper that uses a pattern-matched local to narrow cleanly, which also
removes the duplicated ternary.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds sidebar organization and git-worktree ergonomics to make large session lists manageable, plus “spawn near parent” creation flows that preserve visual grouping and ordering.

Changes:

  • Adds session groups with a new GroupDisplayMode setting (None / FilterStrip / InlineHeaders) and group CRUD + drag/drop assignment/reorder.
  • Adds git worktree awareness: worktree listing + creation flow, sibling detection, shared accent color, optional cluster headers, and New Session multi-add into sibling worktrees.
  • Adds “spawn near parent” behaviors: per-session ➕, duplicate session (Ctrl+Shift+T), and insert-after-parent ordering with group/profile inheritance.

Reviewed changes

Copilot reviewed 15 out of 15 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
src/CodeShellManager/Views/SettingsWindow.xaml.cs Wires new settings fields into Settings UI and persistence.
src/CodeShellManager/Views/SettingsWindow.xaml Adds group display mode combobox + worktree cluster checkbox.
src/CodeShellManager/Views/NewWorktreeDialog.xaml.cs Implements dialog logic/validation for creating a git worktree.
src/CodeShellManager/Views/NewWorktreeDialog.xaml Adds dark-themed worktree creation dialog UI.
src/CodeShellManager/Views/NewSessionDialog.xaml.cs Adds sibling worktree probing UI + prefill support for spawn/duplicate flows.
src/CodeShellManager/Views/NewSessionDialog.xaml Adds “Also start sessions in sibling worktrees” panel.
src/CodeShellManager/Views/InputBoxDialog.xaml.cs Adds a small reusable modal input dialog helper.
src/CodeShellManager/Views/InputBoxDialog.xaml Adds dark-themed input dialog UI.
src/CodeShellManager/ViewModels/SessionViewModel.cs Adds RepoRoot/sibling flags, shared accent keying, and worktree subtitle.
src/CodeShellManager/ViewModels/MainViewModel.cs Adds group filter state, selection state, and group/session membership operations.
src/CodeShellManager/Services/SessionManager.cs Adds group CRUD/move APIs, session insert-after-parent, and legacy group migration.
src/CodeShellManager/Services/GitService.cs Adds repo identity resolution, worktree parsing, branch listing, and worktree add helper.
src/CodeShellManager/Models/AppState.cs Introduces GroupDisplayMode and new settings flags; drops default “Default” group.
src/CodeShellManager/MainWindow.xaml.cs Implements group strip + inline headers, multi-select, worktree clustering, and new context actions.
src/CodeShellManager/MainWindow.xaml Adds group strip column and fixes sidebar header “+” layout.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +128 to +130
newIndex = Math.Clamp(newIndex, 0, _groups.Count - 1);
if (cur == newIndex) return;
_groups.RemoveAt(cur);
Comment thread src/CodeShellManager/MainWindow.xaml.cs Outdated
{
var visibleIds = SidebarSessionList.Children.OfType<Border>()
.Select(b => b.Tag as string)
.Where(t => t != null && !t.StartsWith("dormant:"))
Comment on lines +109 to +114
if (Directory.Exists(TargetPath) && Directory.EnumerateFileSystemEntries(TargetPath).GetEnumerator().MoveNext())
{
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;
GitInfoLoaded = true;

// RepoRoot is stable for the life of the session — resolve it once.
if (RepoRoot == null && !string.IsNullOrEmpty(branch))
@AThraen
Copy link
Copy Markdown
Contributor

AThraen commented May 12, 2026

Code review

Found 1 issue:

  1. ResolveInlineSessionDropTarget adds a phantom empty section before the first group header. When the drop Y falls above the first header (y < headerY of the first header child), Pass 2 hits the empty (currentGroupId=null, currentEndY=headerY, currentSessions=[]) entry pushed at line 2114, falls through to the "past every session in this section" tail logic at L2148-2154, and returns (null, _vm.Sessions.Count) — i.e. the drop is silently re-targeted to the Ungrouped bucket at the very end of the session list, instead of the visually-intended top of the first real group. With no ungrouped sessions present, this is a clear regression of intent; with ungrouped sessions present, the drop lands after the last ungrouped session rather than where the cursor was.

var sections = new List<(string? groupId, double endY, List<Border> sessions)>();
string? currentGroupId = null;
double currentEndY = double.MaxValue;
var currentSessions = new List<Border>();
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;
sections.Add((currentGroupId, currentEndY, currentSessions));
// Start a new section.
string id = tag.Substring("groupheader:".Length);
currentGroupId = id == GroupFilter.Ungrouped ? null : id;
currentSessions = new List<Border>();
continue;
}
if (tag.StartsWith("cluster:") || tag.StartsWith("dormant:")) continue;
currentSessions.Add(item);
}
sections.Add((currentGroupId, double.MaxValue, currentSessions));

Fix: only sections.Add(...) at line 2114 when currentSessions.Count > 0 (skip the phantom leading section), or initialize currentEndY = double.NegativeInfinity so the first header's add is harmless.

Also worth a look (lower confidence, not posted as separate issues)

  • Stagger from 59a7067 is not honored in the new LaunchAndFollowUpWorktreesAsync loop in MainWindow.xaml.cs — multiple claude sibling worktrees launched back-to-back can re-trigger the ~/.claude.json corruption that commit fixed.
  • _selectionAnchorId is never cleared when its session is closed/slept — stale shift-click anchor silently collapses range selections.
  • Legacy "Default" group migration in SessionManager.LoadFromState matches by name+SortOrder only (no sentinel ID / version flag), so a user-named "Default" group could be wiped on restart.
  • _ungroupedExpanded is in-memory only — collapse state of the implicit Ungrouped header resets on every restart while real SessionGroup.IsExpanded persists.

Installer audit (separate from review): no new files in src/CodeShellManager/Assets/, the new XAML/.cs files in Views/ are auto-included via the WPF Page glob, and the WiX drift guard in .github/workflows/build.yml is not triggered. PR is clean from an MSI-shipping perspective.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

Bundles 9 small fixes raised in PR #28 review:

- ResolveInlineSessionDropTarget no longer emits empty leading/trailing
  sections, so a drop above the first group header lands at the top of
  that group instead of silently retargeting to the end of Ungrouped.
- MoveGroup compensates for RemoveAt when moving downward and accepts
  Count as a legal "insert at end" target, fixing the off-by-one reorder.
- Shift-click range selection excludes "groupheader:" and "cluster:"
  tags, so SelectedSessionIds can no longer contain header IDs.
- NewWorktreeDialog directory check uses Any() and a try/catch — an
  inaccessible target path now produces a friendly warning instead of
  crashing the dialog.
- SessionViewModel resolves RepoRoot regardless of branch presence, so
  detached-HEAD worktrees participate in sibling detection, shared
  accent color, and clustering.
- LaunchAndFollowUpWorktreesAsync staggers consecutive claude launches
  with the same 2s delay the boot path uses (commit 59a7067) to avoid
  re-introducing ~/.claude.json corruption when adding several sibling
  claude sessions at once.
- _selectionAnchorId is cleared when its session is closed or slept, so
  the next shift-click can't collapse to a single item.
- The legacy auto-"Default" group migration is now gated on a one-shot
  AppSettings.LegacyDefaultGroupCleared flag, so a user-named "Default"
  group created after the upgrade can't be wiped on restart.
- _ungroupedExpanded promoted to AppSettings.UngroupedSectionExpanded
  so the collapse state of the implicit Ungrouped header survives
  restarts, matching the behavior of real SessionGroup.IsExpanded.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@AThraen AThraen merged commit 4ed7d36 into main May 12, 2026
1 check passed
@AThraen AThraen deleted the feat/session-groups-worktrees branch May 12, 2026 13:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants