Skip to content

Commit f8b35da

Browse files
chphchclaudehappy-otter
committed
feat(app): nest forked sessions under their parent in the session list
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>
1 parent 8767b2b commit f8b35da

10 files changed

Lines changed: 293 additions & 5 deletions

packages/happy-app/sources/components/ActiveSessionsGroupCompact.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { useAllMachines, useSessionGitStatus } from '@/sync/storage';
1212
import { StyleSheet, useUnistyles } from 'react-native-unistyles';
1313
import { t } from '@/text';
1414
import { useNavigateToSession } from '@/hooks/useNavigateToSession';
15+
import { ForkLineageConnector, forkIndentPadding } from './ForkLineageConnector';
1516
import { useHappyAction } from '@/hooks/useHappyAction';
1617
import { HappyError } from '@/utils/errors';
1718
import { SessionActionsAnchor, SessionActionsPopover } from './SessionActionsPopover';
@@ -302,11 +303,13 @@ export const CompactSessionRow = React.memo(({ session, selected, showBorder }:
302303
style={[
303304
styles.sessionRow,
304305
showBorder && styles.sessionRowWithBorder,
305-
selected && styles.sessionRowSelected
306+
selected && styles.sessionRowSelected,
307+
{ paddingLeft: forkIndentPadding(session.forkDepth, 16) },
306308
]}
307309
onPress={handlePress}
308310
{...menuProps}
309311
>
312+
<ForkLineageConnector forkDepth={session.forkDepth} rowHeight={56} basePadding={16} selected={selected} />
310313
<View style={styles.sessionContent}>
311314
<View style={styles.sessionTitleRow}>
312315
<Text
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import React from 'react';
2+
import { View } from 'react-native';
3+
import { StyleSheet } from 'react-native-unistyles';
4+
import { FORK_INDENT_SIZE, FORK_MAX_VISUAL_DEPTH, forkIndentPadding } from '@/utils/forkLineage';
5+
6+
// Re-exported so renderer components can pull the indent helper alongside the
7+
// connector from one place. The ordering/indent math lives in the pure,
8+
// unit-tested `@/utils/forkLineage` module.
9+
export { forkIndentPadding };
10+
11+
/**
12+
* An "└" tree connector linking a forked child row to its parent row directly
13+
* above it. Rendered absolutely inside the row, occupying the indent gap just
14+
* left of the row's content. Returns null for root rows (forkDepth 0).
15+
*/
16+
export function ForkLineageConnector({ forkDepth, rowHeight, basePadding, selected }: {
17+
forkDepth: number;
18+
rowHeight: number;
19+
basePadding: number;
20+
selected?: boolean;
21+
}) {
22+
if (forkDepth < 1) {
23+
return null;
24+
}
25+
const visualDepth = Math.min(forkDepth, FORK_MAX_VISUAL_DEPTH);
26+
const left = basePadding + (visualDepth - 1) * FORK_INDENT_SIZE + 4;
27+
return (
28+
<View
29+
pointerEvents="none"
30+
style={[
31+
styles.connector,
32+
selected && styles.connectorSelected,
33+
{ left, width: FORK_INDENT_SIZE - 6, height: Math.round(rowHeight / 2) },
34+
]}
35+
/>
36+
);
37+
}
38+
39+
const styles = StyleSheet.create((theme) => ({
40+
connector: {
41+
position: 'absolute',
42+
top: 0,
43+
borderLeftWidth: 1.5,
44+
borderBottomWidth: 1.5,
45+
borderColor: theme.colors.divider,
46+
borderBottomLeftRadius: 5,
47+
},
48+
connectorSelected: {
49+
// `divider` is toned for `surface`; on a selected row the background is
50+
// `surfaceSelected`, which is the SAME value on web (#eaeaea light,
51+
// near-identical in dark) — the connector would be invisible on exactly
52+
// the row you are looking at.
53+
borderColor: theme.colors.textSecondary,
54+
},
55+
}));

packages/happy-app/sources/components/ProjectGroup.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { Text } from '@/components/StyledText';
77
import { Typography } from '@/constants/Typography';
88
import { t } from '@/text';
99
import { ProjectGroupData, ProjectWorkspaceGroup, useSessionGitStatus } from '@/sync/storage';
10+
import { orderSessionRowsByForkLineage } from '@/utils/forkLineage';
1011
import { CompactSessionRow } from './ActiveSessionsGroupCompact';
1112
import { Avatar } from './Avatar';
1213
import { requestHomeDockFocus } from './homeDockFocus';
@@ -106,6 +107,15 @@ const WorkspaceSection = React.memo(({ project, workspace, selectedSessionId }:
106107
}
107108
}, [firstSession, router]);
108109

110+
// Nesting runs here, not where the list data is built: the list is filtered
111+
// after that (archive toggle, search box), and a depth stamped before the
112+
// filter leaves a child indented under a parent that is no longer on screen.
113+
// What this section receives is exactly what renders.
114+
const sessions = React.useMemo(
115+
() => orderSessionRowsByForkLineage(workspace.sessions),
116+
[workspace.sessions],
117+
);
118+
109119
return (
110120
<View style={styles.section}>
111121
<View style={styles.header}>
@@ -147,7 +157,7 @@ const WorkspaceSection = React.memo(({ project, workspace, selectedSessionId }:
147157
</View>
148158

149159
<View style={styles.workspaceCard}>
150-
{workspace.sessions.map((session, index) => (
160+
{sessions.map((session, index) => (
151161
<CompactSessionRow
152162
key={session.id}
153163
session={session}

packages/happy-app/sources/components/SessionsList.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
1414
import { useHasArchivedSessions, useVisibleSessionListViewData } from '@/hooks/useVisibleSessionListViewData';
1515
import { Typography } from '@/constants/Typography';
1616
import { StatusDot } from './StatusDot';
17+
import { ForkLineageConnector, forkIndentPadding } from './ForkLineageConnector';
1718
import { StyleSheet, useUnistyles } from 'react-native-unistyles';
1819
import { useIsTablet } from '@/utils/responsive';
1920
import { getHarnessName } from '@/utils/harnessCatalog';
@@ -669,11 +670,13 @@ const SessionItem = React.memo(({ session, selected, isFirst, isLast, isSingle }
669670
selected && styles.sessionItemSelected,
670671
isSingle ? styles.sessionItemSingle :
671672
isFirst ? styles.sessionItemFirst :
672-
isLast ? styles.sessionItemLast : {}
673+
isLast ? styles.sessionItemLast : {},
674+
{ paddingLeft: forkIndentPadding(session.forkDepth, 16) },
673675
]}
674676
onPress={handlePress}
675677
{...menuProps}
676678
>
679+
<ForkLineageConnector forkDepth={session.forkDepth} rowHeight={88} basePadding={16} selected={selected} />
677680
<View style={styles.avatarContainer}>
678681
<Avatar id={session.avatarId} size={48} monochrome={!status.isConnected} flavor={session.flavor} clientId={session.clientId} imageUrl={session.projectAvatarUri} thumbhash={session.projectAvatarThumbhash} badgeLocation="sessionList" />
679682
{session.hasDraft && (

packages/happy-app/sources/sync/storage.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,13 @@ export interface SessionRowData {
157157
// Private project art is already materialized as a local/data URI by sync.
158158
projectAvatarUri?: string | null;
159159
projectAvatarThumbhash?: string | null;
160+
// Fork lineage: the Happy session this one was forked from (null if not a
161+
// fork), and its nesting depth within the group it renders in (0 = root /
162+
// not nested). forkDepth is stamped at render time — after the archive and
163+
// search filters have run — so a child is never indented under a parent the
164+
// filter removed.
165+
parentSessionId: string | null;
166+
forkDepth: number;
160167
}
161168

162169
function buildSessionRowData(
@@ -218,10 +225,11 @@ function buildSessionRowData(
218225
workspaceName: session.metadata?.workspace?.name ?? null,
219226
projectAvatarUri: projectAvatar?.uri || null,
220227
projectAvatarThumbhash: projectAvatar?.thumbhash || null,
228+
parentSessionId: session.metadata?.parentSessionId ?? null,
229+
forkDepth: 0,
221230
};
222231
}
223232

224-
225233
// Unified list item type for SessionsList component
226234
export type SessionListViewItem =
227235
| { type: 'header'; title: string }

packages/happy-app/sources/utils/flatSessionList.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ function row(overrides: Partial<SessionRowData> & { id: string }): SessionRowDat
3434
projectName: null,
3535
workspaceId: null,
3636
workspaceName: null,
37+
parentSessionId: null,
38+
forkDepth: 0,
3739
...overrides,
3840
};
3941
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { describe, it, expect } from 'vitest';
2+
import {
3+
orderSessionRowsByForkLineage,
4+
forkIndentPadding,
5+
FORK_INDENT_SIZE,
6+
FORK_MAX_VISUAL_DEPTH,
7+
} from './forkLineage';
8+
9+
type Row = { id: string; parentSessionId: string | null; forkDepth: number };
10+
const row = (id: string, parentSessionId: string | null = null): Row => ({ id, parentSessionId, forkDepth: 0 });
11+
const ids = (rows: Row[]) => rows.map(r => r.id);
12+
const depths = (rows: Row[]) => rows.map(r => r.forkDepth);
13+
14+
describe('orderSessionRowsByForkLineage', () => {
15+
it('leaves a fork-free list in original order at depth 0', () => {
16+
const out = orderSessionRowsByForkLineage([row('a'), row('b'), row('c')]);
17+
expect(ids(out)).toEqual(['a', 'b', 'c']);
18+
expect(depths(out)).toEqual([0, 0, 0]);
19+
});
20+
21+
it('nests a child directly under its parent at depth 1', () => {
22+
// Input is newest-first: forked child 'b' sorts above its parent 'a'.
23+
const out = orderSessionRowsByForkLineage([row('b', 'a'), row('a'), row('c')]);
24+
expect(ids(out)).toEqual(['a', 'b', 'c']);
25+
expect(depths(out)).toEqual([0, 1, 0]);
26+
});
27+
28+
it('nests a multi-level fork chain with increasing depth', () => {
29+
const out = orderSessionRowsByForkLineage([row('c', 'b'), row('b', 'a'), row('a')]);
30+
expect(ids(out)).toEqual(['a', 'b', 'c']);
31+
expect(depths(out)).toEqual([0, 1, 2]);
32+
});
33+
34+
it('keeps a child at depth 0 when its parent is not in the same section', () => {
35+
const out = orderSessionRowsByForkLineage([row('b', 'parent-in-another-group'), row('c')]);
36+
expect(ids(out)).toEqual(['b', 'c']);
37+
expect(depths(out)).toEqual([0, 0]);
38+
});
39+
40+
it('places multiple children under one parent, preserving their order', () => {
41+
const out = orderSessionRowsByForkLineage([row('c1', 'p'), row('c2', 'p'), row('p')]);
42+
expect(ids(out)).toEqual(['p', 'c1', 'c2']);
43+
expect(depths(out)).toEqual([0, 1, 1]);
44+
});
45+
46+
it('does not loop or drop rows on a parent cycle', () => {
47+
// a -> b -> a, both present (pathological metadata).
48+
const out = orderSessionRowsByForkLineage([row('a', 'b'), row('b', 'a')]);
49+
expect(out).toHaveLength(2);
50+
expect(ids(out).sort()).toEqual(['a', 'b']);
51+
});
52+
53+
it('returns the same row reference when depth is unchanged (deep-equal stability)', () => {
54+
const a = row('a');
55+
const b = row('b');
56+
const out = orderSessionRowsByForkLineage([a, b]);
57+
expect(out[0]).toBe(a);
58+
expect(out[1]).toBe(b);
59+
});
60+
});
61+
62+
describe('forkIndentPadding', () => {
63+
it('adds no indent at depth 0', () => {
64+
expect(forkIndentPadding(0, 16)).toBe(16);
65+
});
66+
67+
it('adds one indent step per level', () => {
68+
expect(forkIndentPadding(1, 16)).toBe(16 + FORK_INDENT_SIZE);
69+
expect(forkIndentPadding(2, 16)).toBe(16 + 2 * FORK_INDENT_SIZE);
70+
});
71+
72+
it('caps the visual indent at FORK_MAX_VISUAL_DEPTH', () => {
73+
expect(forkIndentPadding(99, 16)).toBe(16 + FORK_MAX_VISUAL_DEPTH * FORK_INDENT_SIZE);
74+
});
75+
});
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
// Pure helpers for nesting forked sessions in the session list.
2+
//
3+
// Kept free of React / React-Native imports so the ordering logic stays
4+
// unit-testable (storage.ts and the renderer components both pull in RN and
5+
// cannot be loaded under vitest).
6+
7+
export const FORK_INDENT_SIZE = 20;
8+
export const FORK_MAX_VISUAL_DEPTH = 4;
9+
10+
/** Minimal shape the fork ordering needs from a session row. */
11+
export interface ForkLineageRow {
12+
id: string;
13+
parentSessionId: string | null;
14+
forkDepth: number;
15+
}
16+
17+
/** Left padding for a row at `forkDepth`, added on top of the row's base padding. */
18+
export function forkIndentPadding(forkDepth: number, basePadding: number): number {
19+
const visualDepth = Math.min(Math.max(forkDepth, 0), FORK_MAX_VISUAL_DEPTH);
20+
return basePadding + visualDepth * FORK_INDENT_SIZE;
21+
}
22+
23+
/**
24+
* Reorder a flat array of session rows so that forked children appear
25+
* immediately after their parent (depth-first) and stamp each row's `forkDepth`
26+
* (0 = root within this array). A row whose parent is NOT present in the same
27+
* array is treated as a root at depth 0 — nesting therefore happens only within
28+
* a single rendered section (a date group, or an active project group), never
29+
* across section boundaries. New row objects are returned when a row's depth
30+
* changes so deep-equality still detects the update. O(n).
31+
*/
32+
export function orderSessionRowsByForkLineage<T extends ForkLineageRow>(rows: T[]): T[] {
33+
const atRoot = (r: T): T => (r.forkDepth === 0 ? r : { ...r, forkDepth: 0 } as T);
34+
if (rows.length < 2) {
35+
return rows.map(atRoot);
36+
}
37+
38+
const present = new Set(rows.map(r => r.id));
39+
const childrenByParent = new Map<string, T[]>();
40+
const roots: T[] = [];
41+
42+
for (const row of rows) {
43+
const parentId = row.parentSessionId;
44+
if (parentId && parentId !== row.id && present.has(parentId)) {
45+
const siblings = childrenByParent.get(parentId);
46+
if (siblings) {
47+
siblings.push(row);
48+
} else {
49+
childrenByParent.set(parentId, [row]);
50+
}
51+
} else {
52+
roots.push(row);
53+
}
54+
}
55+
56+
// No fork relationships within this array — keep original order at depth 0.
57+
if (childrenByParent.size === 0) {
58+
return rows.map(atRoot);
59+
}
60+
61+
const ordered: T[] = [];
62+
const visited = new Set<string>();
63+
64+
const emit = (row: T, depth: number) => {
65+
if (visited.has(row.id)) {
66+
return; // guard against pathological parent cycles
67+
}
68+
visited.add(row.id);
69+
ordered.push(row.forkDepth === depth ? row : { ...row, forkDepth: depth } as T);
70+
const children = childrenByParent.get(row.id);
71+
if (children) {
72+
for (const child of children) {
73+
emit(child, depth + 1);
74+
}
75+
}
76+
};
77+
78+
for (const root of roots) {
79+
emit(root, 0);
80+
}
81+
82+
// Safety net: emit any rows skipped by a cycle, at depth 0, preserving order.
83+
if (ordered.length !== rows.length) {
84+
for (const row of rows) {
85+
if (!visited.has(row.id)) {
86+
ordered.push(atRoot(row));
87+
}
88+
}
89+
}
90+
91+
return ordered;
92+
}

packages/happy-app/sources/utils/sessionDisplayOrder.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ function session(
4444
projectName: null,
4545
workspaceId: null,
4646
workspaceName: null,
47+
parentSessionId: null,
48+
forkDepth: 0,
4749
};
4850
}
4951

@@ -199,4 +201,38 @@ describe('session display order', () => {
199201
'Zulu project',
200202
]);
201203
});
204+
205+
it('numbers a forked child right after its parent inside a project card', () => {
206+
const child = session('child', 'machine-a', '/happy');
207+
const data: SessionListViewItem[] = [
208+
{ type: 'projects-header', source: 'happy' },
209+
{
210+
type: 'project',
211+
source: 'happy',
212+
project: {
213+
id: 'happy-project',
214+
name: 'happy',
215+
machineId: 'machine-a',
216+
activeCount: 0,
217+
sessionCount: 3,
218+
workspaces: [{
219+
id: '',
220+
name: null,
221+
// Newest-first: the fork sorts above the parent it came from.
222+
sessions: [
223+
{ ...child, parentSessionId: 'parent' },
224+
session('parent', 'machine-a', '/happy'),
225+
session('unrelated', 'machine-a', '/happy'),
226+
],
227+
}],
228+
},
229+
},
230+
];
231+
232+
expect(getSessionShortcutIdsInDisplayOrder(data, machines, 'Unknown')).toEqual([
233+
'parent',
234+
'child',
235+
'unrelated',
236+
]);
237+
});
202238
});

0 commit comments

Comments
 (0)