Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
f3a4b19
sidebar: lineage nesting is the default
teamleaderleo Oct 22, 2026
62156df
sidebar: treeRows' workspace lookup escapes into the state reader
teamleaderleo Oct 22, 2026
f8376cc
sidebar: an idle-only roll-up is not empty; keycap test turns sidebar…
teamleaderleo Oct 22, 2026
92d8f01
sidebar: fold reveal and hidden checks follow the row's own list
teamleaderleo Oct 22, 2026
fc4eb4e
sidebar: the ancestor walk stays in one list for its whole chain
teamleaderleo Oct 22, 2026
5374b03
sidebar: lineage that arrives after the selection reveals it
teamleaderleo Oct 22, 2026
ccdb9a9
fix(next sidebar): pass list context when revealing needs-you rows
teamleaderleo Oct 27, 2026
c631a35
test(next sidebar): pass list context when revealing needs-you rows
teamleaderleo Oct 27, 2026
e31a3f6
fix(sidebar): translate the French lineage session total
teamleaderleo Oct 28, 2026
17c0923
fix(sidebar): reveal selected lineage after list moves
teamleaderleo Oct 28, 2026
d11e0bd
Merge branch 'feat-cmux-next' into cmux/lineage-nesting-default
teamleaderleo Oct 29, 2026
2357da3
carry #24287 onto current feat-cmux-next
teamleaderleo Oct 30, 2026
f0803b4
fix(sidebar): honor session tree folds in grouped rows
teamleaderleo Oct 30, 2026
96f7c85
merge current feat-cmux-next into sidebar carry
teamleaderleo Oct 30, 2026
95d626d
test(sidebar): a drag of a Done row unfolds with lineage nesting (red)
teamleaderleo Oct 30, 2026
0614c4a
fix(sidebar): a drag unfolds when the Done fold would move a row of t…
teamleaderleo Oct 30, 2026
bd09222
test(sidebar): an unrelated spawn keeps a fold above the selection (red)
teamleaderleo Oct 30, 2026
36e7f4d
fix(sidebar): only the selection's own lineage reopens folds above it
teamleaderleo Oct 30, 2026
cc6384a
media(sidebar): a lineage-in-a-group capture state
teamleaderleo Oct 30, 2026
9f6d9cd
Merge origin/feat-cmux-next into lane/carry-24287-sidebar
teamleaderleo Nov 2, 2026
46d224f
test(sidebar): a drop gap opens where the dropped row draws under lin…
teamleaderleo Nov 2, 2026
4a49300
fix(sidebar): a drop gap with lineage nesting opens at the run bounda…
teamleaderleo Nov 2, 2026
5d04973
fix(sidebar): the needs-you tray stays sidebar.sessionTree's under li…
teamleaderleo Nov 2, 2026
b2a529a
Merge remote-tracking branch 'origin/feat-cmux-next' into lane/carry-…
teamleaderleo Nov 3, 2026
10382f9
Merge remote-tracking branch 'origin/feat-cmux-next' into lane/carry-…
teamleaderleo Nov 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,13 @@ public final class SidebarSessionTreeFeed {
nonisolated struct Pass: Equatable, Sendable {
var state: SidebarSessionTreeState
var asks: Set<String>
/// The session tree is on (`sidebar.sessionTree`): the pass writes `SidebarSessionTree.state`.
/// Off, the pass runs only for the pull request badges and the Priority view's Red CI.
/// The session tree (`sidebar.sessionTree`) or lineage nesting (`SidebarTunables.lineageNesting`, on by
/// default) is on: the pass writes `SidebarSessionTree.state`. Off, the pass runs only for the pull request
/// badges and the Priority view's Red CI.
var showsTree = true
/// The pull request reads run (the tree, the Priority view or a row's badge wants them). Nesting alone asks
/// GitHub nothing.
var readsPullRequests = true
}

/// Follows the setting, the lanes and every machine's `workspaces` from now on. `localHost` is
Expand All @@ -54,7 +58,8 @@ public final class SidebarSessionTreeFeed {
task = Task {
for await pass in ObservationStream({ () -> Pass? in
let sections = DesignSettings.shared.sidebarSections
guard Self.readsPullRequests(sections) || Self.readsBadges(sections) else { return nil }
let reads = Self.readsPullRequests(sections) || Self.readsBadges(sections), nests = SidebarTunables.lineageNesting.value
guard reads || nests else { return nil }
// Each local session's links only (`AgentLaneStore.links(_:)`): an agent's new step or state passes nothing
// again; with the badges on this pass ran over every workspace on every agent event.
let models = workspaces()
Expand All @@ -67,18 +72,29 @@ public final class SidebarSessionTreeFeed {
}
var pass = Self.pass(chats, links: links, remoteParentsByHost: remote.parentsByHost,
hostAliases: remote.hostAliases, recordedParents: storedParents.parents,
localHost: localHost, pullRequests: pullRequests.answers)
pass.showsTree = sections.sessionTree
localHost: localHost, pullRequests: reads ? pullRequests.answers : [:])
pass = Self.shaped(pass, sessionTree: sections.sessionTree, nests: nests, reads: reads)
// Captures: sample pull requests on every row, and no GitHub reads.
if fixture { (pass.state.pullRequests, pass.asks) = (SidebarPullRequestFixture.badges(models), []) }
if fixture, pass.readsPullRequests {
(pass.state.pullRequests, pass.asks) = (SidebarPullRequestFixture.badges(models), [])
}
return pass
}) {
Self.publish(pass, to: tree)
if let pass, !fixture { pullRequests.refresh(pass.asks) } else { pullRequests.stop() }
if let pass, pass.readsPullRequests, !fixture { pullRequests.refresh(pass.asks) } else { pullRequests.stop() }
}
}
}

/// `pass` as the settings shape it: the tree shows while `sidebar.sessionTree` or lineage nesting is on, it nests
/// only while nesting is on (off, every workspace is top level), and GitHub is asked only when `reads`.
static func shaped(_ pass: Pass, sessionTree: Bool, nests: Bool, reads: Bool) -> Pass {
var pass = pass
(pass.showsTree, pass.readsPullRequests) = (sessionTree || nests, reads)
if !nests { pass.state.parents = [:] }
return pass
}

/// `pass` into `tree`: the badges whenever the feed runs, the tree itself only while it shows.
static func publish(_ pass: Pass?, to tree: SidebarSessionTree) {
let state = pass?.showsTree == true ? pass?.state : nil
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ import CoreGraphics
// whose workspaces are all archived, leaves its list for one "Archive (N)" row at the bottom of the sidebar, closed by
// default. Opened, the row lists them under it, dimmed: whole groups and loose archived workspaces in document order,
// then archived members of groups that stay. No list draws an "Archived (N)" row of its own. The active
// workspace keeps its place (a click moves no row); a filter, the session tree and Group by Status list every row in
// place, as before.
// workspace keeps its place (a click moves no row); a filter and Group by Status list every row in place, as before.
// With the session tree (lineage nesting is on by default, #28103) a row whose parent moved to the archive draws as a
// root of its list, and the archive's rows nest among themselves.
nonisolated extension SidebarLayout {
/// The sidebar split for the Archive section: what the lists draw, what the archive holds, and its count.
struct ArchiveSplit {
Expand All @@ -19,9 +20,9 @@ nonisolated extension SidebarLayout {
var count: Int
}

/// Whether this layout draws the Archive section: on, and no filter, session tree or Group by Status.
/// Whether this layout draws the Archive section: on, and no filter or Group by Status.
static func drawsArchiveSection(_ o: SidebarLayoutOptions) -> Bool {
o.archiveSection && o.filterMatches == nil && o.sessionTree == nil && !o.grouping.byStatus
o.archiveSection && o.filterMatches == nil && !o.grouping.byStatus
}

/// Splits `sections` for the Archive section; `active` keeps its place in its list.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,73 +48,128 @@ nonisolated extension SidebarLayout {

/// `drawn` with the workspaces marked Done moved behind one fold entry (`doneFoldIndex`) at the end, and after it
/// when the person opened the fold; then, past `rowLimit`, the rest behind a "Show N more" entry (`limiting`).
/// Unchanged while filtering (a match shows where it is) or with the session tree (it nests rows under others).
/// The active workspace keeps its place.
/// Unchanged while filtering (a match shows where it is). With the session tree a top-level row moves with the
/// rows nested under it (its run): a Done root takes its subtree into the fold, a nested row stays under its parent.
/// The active workspace keeps its place, and so does a run that holds it.
/// `limits` names the entries the limit counts and may hide: a section's groups and items always show.
static func foldingDone(_ drawn: [(index: Int, depth: Int)], workspace: (Int) -> SidebarWorkspace?, fold id: SidebarDoneFoldID,
limits: (Int) -> Bool = { _ in true }, options o: SidebarLayoutOptions) -> [(index: Int, depth: Int)] {
guard o.filterMatches == nil, o.sessionTree == nil else { return drawn }
var shown: [(index: Int, depth: Int)] = []
var done: [(index: Int, depth: Int)] = []
var archived: [(index: Int, depth: Int)] = []
for entry in drawn {
let ws = workspace(entry.index)
if o.foldsArchived, let ws, ws.isArchived, ws.id != o.activeWorkspaceID {
archived.append(entry)
} else if o.foldsDone, let ws, ws.taskStatus == .done, ws.id != o.activeWorkspaceID {
done.append(entry)
guard o.filterMatches == nil else { return drawn }
var shown: [[(index: Int, depth: Int)]] = []
var done: [[(index: Int, depth: Int)]] = []
var archived: [[(index: Int, depth: Int)]] = []
for run in runs(drawn) {
let holdsActive = run.contains { workspace($0.index)?.id == o.activeWorkspaceID }
let ws = workspace(run[0].index)
if o.foldsArchived, let ws, ws.isArchived, !holdsActive {
archived.append(run)
} else if o.foldsDone, let ws, ws.taskStatus == .done, !holdsActive {
done.append(run)
} else {
shown.append(entry)
shown.append(run)
}
}
var listed = limiting(shown, workspace: workspace, limits: limits, list: id, options: o)
if !done.isEmpty { listed += [(doneFoldIndex, done.count)] + (o.openDoneFolds.contains(id.of(.done)) ? done : []) }
if !done.isEmpty {
let folded = Array(done.joined())
listed += [(doneFoldIndex, folded.count)] + (o.openDoneFolds.contains(id.of(.done)) ? folded : [])
}
if !archived.isEmpty {
listed += [(archivedFoldIndex, archived.count)] + (o.openDoneFolds.contains(id.of(.archived)) ? archived : [])
let folded = Array(archived.joined())
listed += [(archivedFoldIndex, folded.count)] + (o.openDoneFolds.contains(id.of(.archived)) ? folded : [])
}
return listed
}

/// `drawn` cut into runs: each top-level entry (depth 0) with the nested entries drawn right after it.
/// Without the session tree every entry is its own run.
static func runs(_ drawn: [(index: Int, depth: Int)]) -> [[(index: Int, depth: Int)]] {
var runs: [[(index: Int, depth: Int)]] = []
for entry in drawn {
if entry.depth > 0, !runs.isEmpty { runs[runs.count - 1].append(entry) } else { runs.append([entry]) }
}
return runs
}

/// `shown` past `rowLimit` rows (`sidebar.groupRowLimit`): the first ones, those that need you and the active one,
/// then a "Show N more" entry for the rest; opened, every row then a "Show fewer" entry. A list one row over the
/// limit shows whole: a row that hides one row saves nothing.
private static func limiting(_ shown: [(index: Int, depth: Int)], workspace: (Int) -> SidebarWorkspace?, limits: (Int) -> Bool,
/// limit shows whole: a row that hides one row saves nothing. A run (`runs`) counts, stays or hides as one row.
private static func limiting(_ shown: [[(index: Int, depth: Int)]], workspace: (Int) -> SidebarWorkspace?, limits: (Int) -> Bool,
list id: SidebarDoneFoldID, options o: SidebarLayoutOptions) -> [(index: Int, depth: Int)] {
let limited = shown.filter { limits($0.index) }.count
guard o.rowLimit > 0, limited > o.rowLimit + 1 else { return shown }
if o.openShowMore.contains(id) { return shown + [(showMoreIndex, 0)] }
let all = Array(shown.joined())
let limited = shown.filter { limits($0[0].index) }.count
guard o.rowLimit > 0, limited > o.rowLimit + 1 else { return all }
if o.openShowMore.contains(id) { return all + [(showMoreIndex, 0)] }
var kept: [(index: Int, depth: Int)] = []
var hidden = 0
var n = 0
for entry in shown {
guard limits(entry.index) else { kept.append(entry); continue }
let stays = workspace(entry.index).map { $0.id == o.activeWorkspaceID || $0.agentChip == .needsYou } ?? false
if n < o.rowLimit || stays { kept.append(entry) }
for run in shown {
guard limits(run[0].index) else { kept += run; continue }
let stays = run.contains { entry in
workspace(entry.index).map { $0.id == o.activeWorkspaceID || $0.agentChip == .needsYou } ?? false
}
if n < o.rowLimit || stays { kept += run } else { hidden += run.count }
n += 1
}
return kept.count == shown.count ? shown : kept + [(showMoreIndex, shown.count - kept.count)]
return hidden == 0 ? all : kept + [(showMoreIndex, hidden)]
}

/// Whether a drag of `dragged` unfolds every list: it carries a row the fold would hide or draw below it (marked
/// Done, not the active one). Such a row would leave its slot (`displayLayout` slides the dragged rows as one), or
/// make or drop a "Done (N)" row the drag's base rows lack, so the drag draws as it did before it kept the folds.
/// A drag of rows that never fold keeps them: it moves no fold row and changes no fold's count.
static func dragUnfolds(_ dragged: [SidebarWorkspace], options o: SidebarLayoutOptions) -> Bool {
guard o.foldsDone || o.foldsArchived || o.archiveSection, o.filterMatches == nil, o.sessionTree == nil else { return false }
return dragged.contains {
/// A drag of rows that never fold keeps them: it moves no fold row and changes no fold's count. Archived rows
/// (`foldsArchived`, or the Archive section) fold the same way.
/// With the session tree the fold moves runs (`foldingDone`), so a drag also unfolds when it carries a row under a
/// folding ancestor (that root's run folds with it, and with the dragged row gone from the base rows a run that held
/// the active row folds too), or when a folding row sits under a dragged one (the base rows lack its parent, so it
/// draws as a top-level row the fold takes). `workspace` looks those rows up by id (the model's); the walk
/// follows `parents` across lists, so it may unfold a drag the fold would not move: that drag draws as it did
/// before drags kept the folds, never with a moved row.
static func dragUnfolds(_ dragged: [SidebarWorkspace], workspace: (WorkspaceID) -> SidebarWorkspace? = { _ in nil },
options o: SidebarLayoutOptions) -> Bool {
guard o.foldsDone || o.foldsArchived || o.archiveSection, o.filterMatches == nil else { return false }
let folds: (SidebarWorkspace) -> Bool = {
$0.id != o.activeWorkspaceID
&& ((o.foldsDone && $0.taskStatus == .done) || ((o.foldsArchived || o.archiveSection) && $0.isArchived))
}
if dragged.contains(where: folds) { return true }
guard let parents = o.sessionTree?.parents, !parents.isEmpty else { return false }
// `id`'s parent chain, nearest first, cycle-safe.
func ancestors(of id: WorkspaceID) -> [WorkspaceID] {
var seen: Set<WorkspaceID> = [id]
var found: [WorkspaceID] = []
var next = parents[id]
while let parent = next, seen.insert(parent).inserted {
found.append(parent)
next = parents[parent]
}
return found
}
let ids = Set(dragged.map(\.id))
if ids.contains(where: { id in ancestors(of: id).contains { workspace($0).map(folds) ?? false } }) { return true }
return parents.keys.contains { child in
!ids.contains(child) && ancestors(of: child).contains(where: ids.contains) && (workspace(child).map(folds) ?? false)
}
}

/// The `drawn` entry a drop gap opens before in a list that draws a fold (a drag keeps the folds): the first row
/// drawn before the fold whose model index is at or past the gap's, else the fold row. That is where the dropped
/// row (never Done) will show: a hidden index between two drawn rows, the list's end, and, with the fold open, an
/// index of a Done row drawn below the fold all open the gap among the rows above it. Nil when the list draws no
/// fold or the gap is elsewhere (`openGapIfNeeded` places it as before).
/// `gapEntry`'s answer when the gap opens after the list's last drawn entry.
static let gapAtEnd = Int.min

/// The `drawn` entry a drop gap opens before in a list whose drawn order is not its model order: one that draws a
/// fold (a drag keeps the folds) or nests rows (the session tree's lineage). The dropped row (never Done, started
/// by no agent) draws as a top-level row in model order, after every run (`runs`) whose root comes before it, so
/// the gap opens before the first drawn top-level row whose model index is at or past the gap's, else before the
/// first fold row, else after the last entry (`gapAtEnd`). A hidden index between two drawn rows, an index nested
/// under an earlier root (#28103 review: `[parent, other, child]` draws `[parent, child, other]`, and a drop at
/// child's index lands after `other`), the list's end and, with a fold open, an index of a Done row drawn below
/// the fold all open the gap where the row will show. Nil when the list draws in model order or the gap is
/// elsewhere (`openGapIfNeeded` places it by model index, as before).
static func gapEntry(_ drawn: [(index: Int, depth: Int)], gap: DropPosition?, section: SectionID, group: GroupID?, count: Int) -> Int? {
guard let gap, gap.section == section, gap.group == group, gap.index <= count, drawn.contains(where: { $0.index < 0 }) else { return nil }
// Shown rows come first in model order, then the fold rows and an open fold's Done rows (`foldingDone`).
return drawn.first { $0.index < 0 || $0.index >= gap.index }?.index
guard let gap, gap.section == section, gap.group == group, gap.index <= count,
drawn.contains(where: { $0.index < 0 || $0.depth > 0 }) else { return nil }
// Shown runs come first, by their roots' model order, then the fold rows and an open fold's rows (`foldingDone`).
// A fold entry's `depth` is its count, so it is told apart by its index alone.
return drawn.first { $0.index < 0 || ($0.depth == 0 && $0.index >= gap.index) }?.index ?? gapAtEnd
}

/// Appends the row of a `foldingDone` entry (`doneFoldIndex`, `archivedFoldIndex` or `showMoreIndex`) at `y`: one
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,11 @@ nonisolated extension SidebarLayout {
// A group holding workspaces marked Done draws them behind its "Done (N)" row (`foldingDone`): laid out again.
// So does one holding archived workspaces (its "Archived (N)" row), and one longer than the row limit (its
// "Show N more" row).
// So does one whose members nest under each other (the session tree's lineage: `treeRows` orders them).
guard group.parent == nil, !(o.foldsDone && group.workspaces.contains { $0.taskStatus == .done }),
!(o.foldsArchived && group.workspaces.contains(where: \.isArchived)),
!(o.archiveSection && (group.isArchived || group.workspaces.contains(where: \.isArchived))),
!(o.sessionTree.map { tree in group.workspaces.contains { tree.parents[$0.id] != nil } } ?? false),
o.rowLimit == 0 || group.members.count <= o.rowLimit + 1,
!section.nodes.contains(where: { if case let .group(child) = $0 { child.parent == group.id } else { false } }) else { return false }
let m = base.resolved(for: o)
Expand Down
Loading
Loading