Summary
The conversation tree panel's graph column has two rendering problems in sessions with multiple/nested branch points:
- Lane collisions — nested branch fans are drawn on top of each other, making branch lines visually intersect and cross through unrelated nodes.
- Abandoned branches render far from their fork — when the active child of a fork is an earlier sibling, the alternate branch's rows are emitted after the entire active subtree, e.g. a branch abandoned early in a 300-entry session renders as the very last row with a connector line spanning the full height of the tree. This also means the
current row is not the last row of the tree.
Root cause
Both issues are in flattenGraphRows() in src/tree/conversationTree.ts:
Lane collisions: lanes are assigned locally per branch point:
const childLanes = children.map((_, index) => frame.lane + index + 1);
childLanes[activeIndex] = frame.lane;
There is no global lane reservation, so a branch point nested inside branch 1 of an ancestor fork hands out the same lane numbers (2, 3, 4, …) that the ancestor's siblings already occupy. Because rows are emitted depth-first, the ancestor's sibling connectors are drawn straight through the nested fan.
Repro shape (two 6-way fans, one nested inside the other's first branch):
assistant ──┬─ branch 1 ── custom ──┬─ branch 1..6 (lanes 1..6)
├─ branch 2..5 (ALSO lanes 2..5 → overlap)
└─ branch 6 (active, lane 0)
Ordering: the DFS emits fork children in tree (creation) order. When the active continuation is child #1 and an abandoned branch is child #2 (branch → abort → navigate back to the original path), the abandoned subtree is emitted after everything else.
Fix (implemented, PR to follow)
Rework flattenGraphRows() into two passes:
- Chain extraction — group rows into chains (a run drawn in one vertical lane: starts at a root or a non-active fork child, continues through single children and the active fork child). Each chain records the row span it occupies, including the connector from its fork parent. The active child's subtree is emitted last at each fork so alternates cluster right below their fork point and
current is always the final row; forks with no active descendant keep natural sibling order.
- Interval packing — chains sorted by start row take the smallest lane that is free over their entire vertical span (greedy interval-graph coloring, optimal lane count). Lanes are reused once an earlier branch's span ends, but never overlap while both are visible.
Verified on a real 311-entry session with 4 branch points (two 6-way fans, one nested): 0 overlapping same-lane edges (previously the fans collided on lanes 2–5), abandoned early branch moved from the last row to directly below its fork, and lanes are reused where spans don't overlap.
Covered by unit tests (tests/conversation-tree-lanes.test.ts): linear chains, fork lane inheritance, lane reuse after a branch ends, nested-fan non-collision, active-child-first ordering, natural order in inactive fans, branch badge metadata.
Notes / possible follow-ups
- Branch colors are keyed by lane, so a reused lane reuses its color for a different branch; coloring by chain would disambiguate.
- Row order is no longer strictly chronological (alternates group at their fork, git-graph style); a strict-chronology mode could be offered if desired.
Summary
The conversation tree panel's graph column has two rendering problems in sessions with multiple/nested branch points:
currentrow is not the last row of the tree.Root cause
Both issues are in
flattenGraphRows()insrc/tree/conversationTree.ts:Lane collisions: lanes are assigned locally per branch point:
There is no global lane reservation, so a branch point nested inside branch 1 of an ancestor fork hands out the same lane numbers (2, 3, 4, …) that the ancestor's siblings already occupy. Because rows are emitted depth-first, the ancestor's sibling connectors are drawn straight through the nested fan.
Repro shape (two 6-way fans, one nested inside the other's first branch):
Ordering: the DFS emits fork children in tree (creation) order. When the active continuation is child #1 and an abandoned branch is child #2 (branch → abort → navigate back to the original path), the abandoned subtree is emitted after everything else.
Fix (implemented, PR to follow)
Rework
flattenGraphRows()into two passes:currentis always the final row; forks with no active descendant keep natural sibling order.Verified on a real 311-entry session with 4 branch points (two 6-way fans, one nested): 0 overlapping same-lane edges (previously the fans collided on lanes 2–5), abandoned early branch moved from the last row to directly below its fork, and lanes are reused where spans don't overlap.
Covered by unit tests (
tests/conversation-tree-lanes.test.ts): linear chains, fork lane inheritance, lane reuse after a branch ends, nested-fan non-collision, active-child-first ordering, natural order in inactive fans, branch badge metadata.Notes / possible follow-ups