Skip to content

Conversation tree graph: nested branch fans overlap lanes and abandoned branches render at the end of the tree #48

Description

@ashwin-pc

Summary

The conversation tree panel's graph column has two rendering problems in sessions with multiple/nested branch points:

  1. Lane collisions — nested branch fans are drawn on top of each other, making branch lines visually intersect and cross through unrelated nodes.
  2. 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:

  1. 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.
  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingimplemented-downstreamImplemented in a downstream fork; track when upstream lands the same change

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions