Skip to content
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **In-group hierarchy keys: K/J auto-promote at the parent's edge, and Shift+Left / Shift+Right explicitly outdent / indent.** `K` / `J` (and `Shift+Up` / `Shift+Down`) now promote a sub-session to top-level when it is the first / last child of its parent, instead of silently no-op. `Shift+Right` demotes the cursor's top-level session to a sub-session of the previous top-level peer (last child); `Shift+Left` is the symmetric outdent. All four shortcuts stay scoped to the current group — cross-group moves remain on `M`. Single-level nesting and child-count guards mirror the existing `session set-parent` CLI validation. New `GroupTree.PromoteSession` / `GroupTree.DemoteSession` methods plus boundary-promote logic inside `MoveSessionUp` / `MoveSessionDown`; covered by `TestPromoteSession_*`, `TestDemoteSession_*`, `TestMoveSession*Promotes`, `TestMoveSession*TopLevelAt*NoOp` in `internal/session/groups_test.go`.

## [1.9.1] - 2026-05-11

Patch release on top of v1.9.0 — two stability fixes from same-day post-release triage. Both touch the session lifecycle: cascade-prevention via serial-within-group as the new default, and `agent-deck rm` correctness under concurrency plus notifier cleanup.
Expand Down
142 changes: 130 additions & 12 deletions internal/session/groups.go
Original file line number Diff line number Diff line change
Expand Up @@ -566,30 +566,54 @@ func (t *GroupTree) MoveGroupDown(path string) {
// MoveSessionUp moves a session up among its visual siblings: top-level
// sessions (empty ParentSessionID) reorder among other top-level sessions
// in the same group; sub-sessions reorder among other sub-sessions of the
// same parent. Non-siblings interleaved in the flat slice are skipped, so
// a single call always produces a visible change when one is possible.
// same parent. Non-siblings interleaved in the flat slice are skipped.
//
// When a sub-session has no previous same-parent sibling (it is at the
// top of its parent's children block), it is promoted to top-level and
// inserted in the slice immediately before the parent. At the group's
// top boundary (no previous top-level peer) the call is a no-op;
// cross-group moves remain on the M shortcut.
func (t *GroupTree) MoveSessionUp(inst *Instance) {
group, exists := t.Groups[inst.GroupPath]
if !exists {
return
}

currentIdx, prevSiblingIdx := -1, -1
currentIdx, prevSiblingIdx, parentIdx := -1, -1, -1
for i, s := range group.Sessions {
if s.ID == inst.ID {
currentIdx = i
break
continue
}
if s.ParentSessionID == inst.ParentSessionID {
if currentIdx < 0 && s.ParentSessionID == inst.ParentSessionID {
prevSiblingIdx = i
}
if inst.ParentSessionID != "" && s.ID == inst.ParentSessionID {
parentIdx = i
}
}
if currentIdx < 0 || prevSiblingIdx < 0 {
if currentIdx < 0 {
return
}

switch {
case prevSiblingIdx >= 0:
group.Sessions[currentIdx], group.Sessions[prevSiblingIdx] = group.Sessions[prevSiblingIdx], group.Sessions[currentIdx]
case inst.ParentSessionID != "" && parentIdx >= 0:
// Promote sub-session to top-level: clear parent and reposition
// the slice entry immediately before the parent so the renderer
// shows it as a top-level peer just above the parent's block.
inst.ClearParent()
s := group.Sessions[currentIdx]
group.Sessions = append(group.Sessions[:currentIdx], group.Sessions[currentIdx+1:]...)
if parentIdx > currentIdx {
parentIdx--
}
group.Sessions = append(group.Sessions[:parentIdx], append([]*Instance{s}, group.Sessions[parentIdx:]...)...)
default:
return
}
group.Sessions[currentIdx], group.Sessions[prevSiblingIdx] = group.Sessions[prevSiblingIdx], group.Sessions[currentIdx]

// Normalize Order for all sessions in group
for i, s := range group.Sessions {
s.Order = i
}
Expand All @@ -598,6 +622,12 @@ func (t *GroupTree) MoveSessionUp(inst *Instance) {
// MoveSessionDown moves a session down among its visual siblings.
// See MoveSessionUp for the sibling-aware semantics; this is the symmetric
// case that swaps with the next same-parent session in the slice.
//
// When a sub-session has no following same-parent sibling (it is at the
// bottom of its parent's children block), it is promoted to top-level
// at its current slice position so the renderer shows it as a top-level
// peer immediately after the parent's block. At the group's bottom
// boundary (no following top-level peer) the call is a no-op.
func (t *GroupTree) MoveSessionDown(inst *Instance) {
group, exists := t.Groups[inst.GroupPath]
if !exists {
Expand All @@ -610,17 +640,105 @@ func (t *GroupTree) MoveSessionDown(inst *Instance) {
currentIdx = i
continue
}
if currentIdx >= 0 && s.ParentSessionID == inst.ParentSessionID {
if currentIdx >= 0 && s.ParentSessionID == inst.ParentSessionID && nextSiblingIdx < 0 {
nextSiblingIdx = i
}
}
if currentIdx < 0 {
return
}

switch {
case nextSiblingIdx >= 0:
group.Sessions[currentIdx], group.Sessions[nextSiblingIdx] = group.Sessions[nextSiblingIdx], group.Sessions[currentIdx]
case inst.ParentSessionID != "":
// Promote sub-session to top-level. The slice entry already sits
// after the parent's other children, so just clearing the parent
// pointer is enough — the renderer will place it as a top-level
// peer immediately after the parent's block.
inst.ClearParent()
default:
return
}

for i, s := range group.Sessions {
s.Order = i
}
}

// PromoteSession converts a sub-session into a top-level peer in the same
// group. Slice position is preserved so the renderer places the session as
// a top-level peer immediately after its former parent's children block.
// Top-level sessions are unchanged.
func (t *GroupTree) PromoteSession(inst *Instance) {
if inst.ParentSessionID == "" {
return
}
group, exists := t.Groups[inst.GroupPath]
if !exists {
return
}
inst.ClearParent()
for i, s := range group.Sessions {
s.Order = i
}
}

// DemoteSession converts a top-level session into a sub-session of the
// previous top-level peer in the same group, inserting it as that peer's
// last child. No-op if there is no previous peer (group's first
// top-level), if the session is already a sub-session, or if it has its
// own children — single-level nesting only, mirroring the validation in
// `session set-parent`.
func (t *GroupTree) DemoteSession(inst *Instance) {
if inst.ParentSessionID != "" {
return
}
group, exists := t.Groups[inst.GroupPath]
if !exists {
return
}

for _, s := range group.Sessions {
if s.ParentSessionID == inst.ID {
return
}
}

currentIdx, prevTopIdx := -1, -1
for i, s := range group.Sessions {
if s.ID == inst.ID {
currentIdx = i
break
}
if s.ParentSessionID == "" {
prevTopIdx = i
}
}
if currentIdx < 0 || nextSiblingIdx < 0 {
if currentIdx < 0 || prevTopIdx < 0 {
return
}
group.Sessions[currentIdx], group.Sessions[nextSiblingIdx] = group.Sessions[nextSiblingIdx], group.Sessions[currentIdx]

// Normalize Order for all sessions in group
parent := group.Sessions[prevTopIdx]
inst.SetParentWithPath(parent.ID, parent.ProjectPath)

insertIdx := prevTopIdx + 1
for i := prevTopIdx + 1; i < len(group.Sessions); i++ {
if i == currentIdx {
continue
}
if group.Sessions[i].ParentSessionID == parent.ID {
insertIdx = i + 1
}
}

s := group.Sessions[currentIdx]
group.Sessions = append(group.Sessions[:currentIdx], group.Sessions[currentIdx+1:]...)
if insertIdx > currentIdx {
insertIdx--
}
group.Sessions = append(group.Sessions[:insertIdx], append([]*Instance{s}, group.Sessions[insertIdx:]...)...)

for i, s := range group.Sessions {
s.Order = i
}
Expand Down
Loading
Loading