Skip to content

feat(app): nest forked sessions under their parent in the session list - #1423

Open
chphch wants to merge 1 commit into
slopus:mainfrom
chphch:feat/fork-lineage-indent
Open

feat(app): nest forked sessions under their parent in the session list#1423
chphch wants to merge 1 commit into
slopus:mainfrom
chphch:feat/fork-lineage-indent

Conversation

@chphch

@chphch chphch commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

Forked sessions already persist parentSessionId in their (encrypted) metadata — it's set on every fork path (the Fork quick-action, duplicate-from-message, and the MCP open_session tool) and already surfaced as the "Forked From" link on the session info screen — but the session list ignored it, so a fork showed up as an unrelated row sorted only by recency and you couldn't tell at a glance what was branched from what.

This renders forked children directly under their parent within each list section (the active project group, and each by-date group), indented by fork depth with a tree connector. A child whose parent isn't in the same section falls back to depth 0 so nesting never crosses section boundaries, and the visual indent caps at a max depth so deep chains never march off-screen (the underlying depth stays accurate).

Gated behind an experimental expForkNesting setting, off by default. With it off the session list is byte-for-byte the upstream layout — rows keep their original order and stay at depth 0, so the renderer draws no indentation and no connectors. Users opt in from Settings → Features.

Shape of the change

  • utils/forkLineage.ts (new) — the pure reorder + indent math, dependency-free and unit-tested.
  • sync/storage.tsSessionRowData gains parentSessionId and forkDepth; a new buildOrderedSessionRows applies the lineage ordering when the flag is on and returns rows untouched at depth 0 when it's off, so the flag check lives in exactly one place.
  • Renderers — both the compact active-session rows and the full by-date rows consume forkDepth for their indent padding.

Proof

Synthetic parent → two children → grandchild sessions rendered through the real SessionsList on Expo web (standalone server, dev_token URL bypass, driven with Playwright). Left = flag off (forks scattered by recency, lineage invisible); right = flag on (children nested under their parent with a connector; the grandchild sits one level deeper). Both render paths are shown — the compact active group (top) and the full by-date "Today" group (bottom).

fork lineage before/after

Verification

pnpm typecheck clean; app suite green. utils/forkLineage.test.ts covers the ordering — multi-level chains, multiple children of one parent, the parent-not-in-section fallback, a parent-cycle guard, and deep-equal reference stability.

@chphch
chphch force-pushed the feat/fork-lineage-indent branch from fb101ed to 7442723 Compare July 19, 2026 12:36
@chphch
chphch force-pushed the feat/fork-lineage-indent branch 3 times, most recently from 28bac10 to 4fd3ca7 Compare July 31, 2026 03:57
@chphch chphch closed this Aug 5, 2026
@chphch chphch reopened this Aug 5, 2026
@chphch
chphch force-pushed the feat/fork-lineage-indent branch from 4fd3ca7 to ce6b6d2 Compare August 9, 2026 01:01
@chphch

chphch commented Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto current main — this was CONFLICTING, and the rebase turned up that the branch had also become a no-op.

Why it stopped working

The session list moved to project cards. buildSessionListViewData now emits only projects-header / project items, so the helper that stamped forkDepth lost its last caller and every row rendered at depth 0 — the indent code in CompactSessionRow was alive the whole time, just never handed a non-zero depth.

What changed

  • Nesting is applied in ProjectGroup.tsx, where the rows actually render, instead of where the list data is built. That also fixes a latent bug in the original approach: the list is filtered after it's built (archive toggle, search box), so a depth stamped at build time could leave a child indented under a parent that had been filtered off-screen. Stamping at render time makes that impossible.
  • getSessionShortcutIdsInDisplayOrder nests the project branch too, so the ⌘1–9 palette numbering matches what's on screen.
  • Removed the now-dead buildOrderedSessionRows and the orderForks parameter of buildSessionListViewData — with nesting done at render time the list no longer needs rebuilding when the setting flips; the component re-renders on its own.
  • The test fixture now sets parentSessionId / forkDepth. The feature commit makes these required on SessionRowData, so without this tsc was red.

pnpm typecheck clean, 48 tests pass.

Proof

Three sessions in one project card, created via the daemon's /spawn-session with an explicit parentSessionId, so the middle one is a genuine fork of the row above it. Row identity was confirmed by clicking through to /session/<id> rather than inferred from position.

Setting on — the child is reordered directly under its parent, indented with a connector:

nested

Setting off — original newest-first order, all three rows at the same offset, no connector:

flat

Narrow width (420px) behaves identically: on / off.

@chphch
chphch force-pushed the feat/fork-lineage-indent branch from ce6b6d2 to 7bb06c5 Compare August 9, 2026 04:38
@chphch

chphch commented Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

Fixed a visibility bug in the connector itself, folded into the first commit.

The connector is drawn with theme.colors.divider; a selected row's background is theme.colors.surfaceSelected. On web those are the same value in light theme (#eaeaea), and near-identical in dark (#292929 vs #2C2C2E) — so the connector was invisible on exactly the row you are currently inside. Unselected rows sit on surface (#ffffff), which is why it looked fine until you opened the forked session.

ForkLineageConnector now takes selected and switches to theme.colors.textSecondary when set; both call sites pass it.

Measured rather than eyeballed, since this is a contrast bug — sampling pixels from the rendered app at 1280:

state connector px row background px per-channel delta
selected, fixed rgb(73,69,79) rgb(234,234,234) [161,165,155]
selected, pre-fix color forced rgb(234,234,234) rgb(234,234,234) [0,0,0]
unselected (control) rgb(234,234,234) rgb(255,255,255) [21,21,21]

Forcing the old colour back on the same element reproduces the bug exactly, and a whole-image diff between the two is 46 pixels — all inside the connector rect, nothing else moved. The unselected rendering is unchanged.

Scope of the measurement: light web theme and the compact row path. The SessionsList row path and dark theme take the identical one-line prop but were not exercised by pixels.

@chphch
chphch force-pushed the feat/fork-lineage-indent branch 2 times, most recently from 5341c3c to e87c747 Compare August 23, 2026 04:12
@chphch

chphch commented Aug 23, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto current main (3cf9ce70f).

The home rework (c63c80ff3 / 6dc878dc3) moved where session rows render, so the nesting had to move with it — twice: the ordering that used to happen in the project loop now goes through buildSessionProjectDisplayGroups in utils/sessionDisplayOrder.ts, and the rows themselves now render inside the worktree card rather than directly in the section. Both sites reapplied; the load-bearing part (orderSessionRowsByForkLineage and the connector) is unchanged.

Worth saying explicitly: the rework grouped the list by filesystem location (project → worktree), not by lineage. That narrows where a fork can hide — a child usually shares its parent's card now — but it never expresses the relation. SessionRowData still has no depth, the display order is still active-first-then-newest, and a fork still sits above its parent among any number of same-directory siblings with nothing marking it as a child. So the gap this PR closes is still open.

pnpm typecheck clean; app suite 929/930 (the one failure is encryption/blob.test.ts's 1 MB case timing out under load — it passes standalone on plain main at ~1.85 s, and this PR touches no encryption code).

@chphch
chphch force-pushed the feat/fork-lineage-indent branch 3 times, most recently from f8b35da to 731c2cf Compare August 25, 2026 13:52
Forked sessions already persist `parentSessionId` in their encrypted
metadata (set on every fork path — Fork action, duplicate-from-message, and
the MCP open_session tool), but the session list ignored it: a fork appeared
as an unrelated row sorted only by recency, so the lineage was invisible.

Render forked children directly under their parent within each group,
indented by fork depth with a "└" tree connector. A child whose parent is
not in the same group falls back to depth 0, so nesting never crosses group
boundaries. The reorder + indent math lives in a pure, unit-tested util
(utils/forkLineage.ts).

Ordering runs at render time rather than while the list data is built: the
list is filtered afterwards (archive toggle, search box), and a depth
stamped before the filter would leave a child indented under a parent that
is no longer on screen.

Behind the `expForkNesting` setting, off by default. Rebased onto current
main, where the home list is assembled per project group — the two original
commits are squashed so the gate and the behavior it gates land together.
@chphch
chphch force-pushed the feat/fork-lineage-indent branch from 731c2cf to e123b03 Compare August 25, 2026 13:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant