Skip to content

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

Open
chphch wants to merge 2 commits 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 2 commits 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 and others added 2 commits August 9, 2026 13:38
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 list
section, indented by fork depth with a "└" tree connector. A child whose
parent is not in the same section falls back to depth 0, so nesting never
crosses section boundaries. Both the compact active-sessions rows and the
full inactive (by-date) rows are covered.

The reorder + indent math lives in a pure, unit-tested util
(utils/forkLineage.ts); storage.ts nests within each date group and the
active group nests per project group. Visual indent caps at a max depth so
deep chains never march off-screen (forkDepth itself stays accurate).

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
Put the fork-lineage nesting behind a new `expForkNesting` setting (off by
default) so it ships as an opt-in experiment rather than changing every
user's session list. When off, rows keep their original order and depth 0,
so the renderer shows no indentation or connectors (upstream layout).

- settings: add `expForkNesting` (default false)
- storage: thread the flag into buildSessionListViewData; rebuild the list on
  settings change so the toggle applies immediately
- ActiveSessionsGroupCompact: gate the per-project fork reorder on the setting
- settings/features: add the toggle + i18n strings for all languages

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
@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.

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