Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -29,9 +29,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 @@ -45,7 +49,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 chats = Self.chats(workspaces())
Expand All @@ -55,17 +60,25 @@ public final class SidebarSessionTreeFeed {
if let session = ref.session, let link = lanes.links(session) { links[session] = link }
}
}
var pass = Self.pass(chats, links: links, remoteParents: remote.parents, localHost: localHost,
pullRequests: pullRequests.answers)
pass.showsTree = sections.sessionTree
return pass
let pass = Self.pass(chats, links: links, remoteParents: remote.parents, localHost: localHost,
pullRequests: reads ? pullRequests.answers : [:])
return Self.shaped(pass, sessionTree: sections.sessionTree, nests: nests, reads: reads)
}) {
Self.publish(pass, to: tree)
if let pass { pullRequests.refresh(pass.asks) } else { pullRequests.stop() }
if let pass, pass.readsPullRequests { 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 @@ -24,43 +24,60 @@ 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)] = []
for entry in drawn {
if o.foldsDone, let ws = workspace(entry.index), 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)]] = []
for run in runs(drawn) {
let holdsActive = run.contains { workspace($0.index)?.id == o.activeWorkspaceID }
if o.foldsDone, let ws = workspace(run[0].index), ws.taskStatus == .done, !holdsActive {
done.append(run)
} else {
shown.append(entry)
shown.append(run)
}
}
let listed = limiting(shown, workspace: workspace, limits: limits, list: id, options: o)
guard !done.isEmpty else { return listed }
return listed + [(doneFoldIndex, done.count)] + (o.openDoneFolds.contains(id) ? done : [])
let folded = done.joined()
return listed + [(doneFoldIndex, folded.count)] + (o.openDoneFolds.contains(id) ? Array(folded) : [])
}

/// `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)]
}

/// Appends the row of a `foldingDone` entry (`doneFoldIndex` or `showMoreIndex`) at `y`: one line, as a document
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@ nonisolated extension SidebarLayout {
// rows, so the full layout places them.
// A group holding workspaces marked Done draws them behind its "Done (N)" row (`foldingDone`): laid out again.
// So does 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.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
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,21 @@ nonisolated extension SidebarLayout {
/// the same list draws right after it, one level deeper, before the parent's next sibling, at
/// any depth (who started whom). Siblings keep the list's order. A parent in another list leaves
/// the row a root, and so does a cycle. The descendants of a `collapsed` parent do not draw (the
/// parent shows their roll-up). `ids` are the entries' workspaces (nil: a group or an item).
/// parent shows their roll-up), nor those of one `folded` says folds at its depth.
/// `ids` are the entries' workspaces (nil: a group or an item).
/// Without `parents` the order is the model's. `children`: `treeChildren`, when the caller has it.
static func treeOrder(_ ids: [WorkspaceID?], parents: [WorkspaceID: WorkspaceID]?,
collapsed: Set<WorkspaceID> = [], children known: [WorkspaceID: [Int]]? = nil) -> [(index: Int, depth: Int)] {
collapsed: Set<WorkspaceID> = [], folded: ((WorkspaceID, Int) -> Bool)? = nil,
children known: [WorkspaceID: [Int]]? = nil) -> [(index: Int, depth: Int)] {
guard let parents, !parents.isEmpty else { return ids.indices.map { ($0, 0) } }
let children = known ?? treeChildren(ids, parents: parents)
let nested = Set(children.values.joined())
let folds = folded ?? { id, _ in collapsed.contains(id) }
var order: [(index: Int, depth: Int)] = []
order.reserveCapacity(ids.count)
func visit(_ index: Int, _ depth: Int) {
order.append((index, depth))
guard let id = ids[index], !collapsed.contains(id) else { return }
guard let id = ids[index], !folds(id, depth) else { return }
for child in children[id] ?? [] { visit(child, depth + 1) }
}
for index in ids.indices where !nested.contains(index) { visit(index, 0) }
Expand Down Expand Up @@ -63,14 +66,14 @@ nonisolated extension SidebarLayout {
return found
}

/// The fold and roll-up of the workspace at `index` drawn by `tree`, whose list's `children`
/// The fold and roll-up of the workspace at `index`, drawn at `depth` by `tree`, whose list's `children`
/// (`treeChildren`) the caller computed once: nil without children; a folded parent counts the
/// states of everything under it (`states` by model index).
static func treeFold(_ index: Int, _ ids: [WorkspaceID?], tree: SidebarSessionTreeState?, children: [WorkspaceID: [Int]],
static func treeFold(_ index: Int, depth: Int, _ ids: [WorkspaceID?], tree: SidebarSessionTreeState?, children: [WorkspaceID: [Int]],
states: (Int) -> SidebarAgentState) -> (fold: SidebarTreeFold?, rollUp: SidebarTreeRollUp?) {
guard let tree, let id = ids[index] else { return (nil, nil) }
guard children[id]?.isEmpty == false else { return (nil, nil) }
guard tree.collapsed.contains(id) else { return (.expanded, nil) }
guard tree.isFolded(id, depth: depth) else { return (.expanded, nil) }
let rollUp = SidebarTreeRollUp(treeDescendants(of: id, ids, children: children).map(states))
return (.collapsed, rollUp.isEmpty ? nil : rollUp)
}
Expand All @@ -86,19 +89,26 @@ nonisolated extension SidebarLayout {
/// on a parent, its fold and roll-up. A filter opens every fold: the order and the chevrons both
/// draw it open. Without `tree` the order is the model's, flat.
static func treeRows(_ nodes: [SidebarNode], tree live: SidebarSessionTreeState?, filtering: Bool) -> [TreeRow] {
let ids = workspaceIDs(nodes)
treeRows(workspaceIDs(nodes), workspace: { if case let .workspace(ws) = nodes[$0] { ws } else { nil } },
tree: live, filtering: filtering)
}

/// `treeRows` of one list, a section's loose rows or a group's members: `ids` its workspaces, `workspace` by index.
static func treeRows(_ ids: [WorkspaceID?], workspace: @escaping (Int) -> SidebarWorkspace?, tree live: SidebarSessionTreeState?,
filtering: Bool) -> [TreeRow] {
let tree = live.map { shown in
var tree = shown
if filtering { tree.collapsed = [] }
if filtering { (tree.collapsed, tree.foldsDeep) = ([], false) }
return tree
}
let children = tree.map { treeChildren(ids, parents: $0.parents) } ?? [:]
let state: (Int) -> SidebarAgentState = { i in
guard case let .workspace(ws) = nodes[i] else { return .idle }
guard let ws = workspace(i) else { return .idle }
return SidebarAgentState.of(ws, lane: live?.states[ws.id])
}
return treeOrder(ids, parents: tree?.parents, collapsed: tree?.collapsed ?? [], children: children).map { entry in
let (fold, rollUp) = treeFold(entry.index, ids, tree: tree, children: children, states: state)
let folded: ((WorkspaceID, Int) -> Bool)? = tree.map { shown in { id, depth in shown.isFolded(id, depth: depth) } }
return treeOrder(ids, parents: tree?.parents, folded: folded, children: children).map { entry in
let (fold, rollUp) = treeFold(entry.index, depth: entry.depth, ids, tree: tree, children: children, states: state)
return TreeRow(index: entry.index, depth: entry.depth, fold: fold, rollUp: rollUp)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -389,8 +389,10 @@ public nonisolated struct SidebarLayout: Hashable, Sendable {
y = Self.appendEmptyRow(group, section: section.id, parent: index, metrics: m, to: &rows, y: y)
}
let fold = SidebarDoneFoldID(section: section.id, group: group.id)
let drawn = Self.foldingDone(Self.treeOrder(Self.workspaceIDs(members), parents: o.sessionTree?.parents),
workspace: { if case let .workspace(ws) = members[$0] { ws } else { nil } }, fold: fold, options: o)
let memberWorkspace: (Int) -> SidebarWorkspace? = { if case let .workspace(ws) = members[$0] { ws } else { nil } }
let memberTree = Self.treeRows(Self.workspaceIDs(members), workspace: memberWorkspace, tree: o.sessionTree, filtering: filtering)
let memberTreeRow = Dictionary(memberTree.map { ($0.index, $0) }, uniquingKeysWith: { first, _ in first })
let drawn = Self.foldingDone(memberTree.map { ($0.index, $0.depth) }, workspace: memberWorkspace, fold: fold, options: o)
for (position, (childIndex, depth)) in drawn.enumerated() {
if childIndex < 0 {
Self.appendFold(childIndex, fold, count: depth, parent: index, color: group.color, last: position == drawn.count - 1,
Expand All @@ -415,6 +417,7 @@ public nonisolated struct SidebarLayout: Hashable, Sendable {
}
var content = o.rowContent(ws, machine: machineLabel)
content.treeDepth = depth
(content.treeFold, content.rollUp) = (memberTreeRow[childIndex]?.fold, memberTreeRow[childIndex]?.rollUp)
let h = m.height(for: content)
rows.append(SidebarRow(
key: .workspace(ws.id), y: y, height: h, section: section.id,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,15 @@ public nonisolated enum SidebarTunables {
help: "Lists every computer's rows under the Priority view's states (Needs you, Done, Failed, CI failing, Working, Idle) with counts, in your order inside each. Rows move as their state changes; the active row stays put until another row is active.",
default: false, code: "SidebarTunables.groupByStatus")

/// Lineage nesting (Leo 2026-10-21: "A LOT OF THESE SESSIONS ID IMAGINE DIDNT NECESSSARILY NEED TO SHOW UP ON THE
/// SIDE BY DEFAULT"): a workspace whose agent another workspace's agent started lists under that workspace, at any
/// depth, and a parent below the top level starts folded with its roll-up. Only person-started sessions are top
/// level. On by default; the rest of `sidebar.sessionTree` (keycaps, chat history) stays opt-in.
public static let lineageNesting = Tunable<Bool>.toggle(
"sidebar.lineageNesting", .sidebar, "Nest agent-started sessions",
help: "A workspace an agent started lists under the workspace whose agent started it, at any depth. Sessions a lane started stay folded under it until you open them. Off lists every workspace at the top level.",
default: true, code: "SidebarTunables.lineageNesting")

/// Workspaces marked Done fold into one "Done (N)" row at the end of their group or list (`SidebarLayout+DoneFold`).
public static let doneFold = Tunable<Bool>.toggle(
"sidebar.doneFold", .sidebar, "Fold Done workspaces",
Expand Down Expand Up @@ -293,6 +302,7 @@ public nonisolated enum SidebarTunables {
groupExitFraction.descriptor,
groupRowLimit.descriptor,
laneStatus.descriptor,
lineageNesting.descriptor,
rowStatus.descriptor,
pullRequestBadges.descriptor,
laneFixture.descriptor,
Expand Down
Loading
Loading