Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Swipeable } from 'react-native-gesture-handler';
import { Text } from '@/components/StyledText';
import { Machine } from '@/sync/storageTypes';
import { SessionRowData } from '@/sync/storage';
import { orderSessionRowsByForkLineage } from '@/utils/forkLineage';
import { Ionicons, MaterialCommunityIcons } from '@expo/vector-icons';
import { type SessionState, formatPathRelativeToHome, vibingMessages, formatLastSeen } from '@/utils/sessionUtils';
import { Avatar } from './Avatar';
Expand All @@ -13,6 +14,7 @@ import { useAllMachines, useSessionGitStatus } from '@/sync/storage';
import { StyleSheet, useUnistyles } from 'react-native-unistyles';
import { t } from '@/text';
import { useNavigateToSession } from '@/hooks/useNavigateToSession';
import { ForkLineageConnector, forkIndentPadding } from './ForkLineageConnector';
import { useHappyAction } from '@/hooks/useHappyAction';
import { HappyError } from '@/utils/errors';
import { SessionActionsAnchor, SessionActionsPopover } from './SessionActionsPopover';
Expand Down Expand Up @@ -215,6 +217,8 @@ export function ActiveSessionsGroupCompact({ sessions, selectedSessionId }: Acti
byMachine.forEach(mg => {
mg.projects.forEach(pg => {
pg.sessions.sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0));
// Nest forked children under their parent within this project group.
pg.sessions = orderSessionRowsByForkLineage(pg.sessions);
});
});

Expand Down Expand Up @@ -348,11 +352,13 @@ const CompactSessionRow = React.memo(({ session, selected, showBorder }: { sessi
style={[
styles.sessionRow,
showBorder && styles.sessionRowWithBorder,
selected && styles.sessionRowSelected
selected && styles.sessionRowSelected,
{ paddingLeft: forkIndentPadding(session.forkDepth, 16) },
]}
onPress={handlePress}
{...menuProps}
>
<ForkLineageConnector forkDepth={session.forkDepth} rowHeight={56} basePadding={16} />
<View style={styles.sessionContent}>
<View style={styles.sessionTitleRow}>
{renderLeadingIndicator()}
Expand Down
46 changes: 46 additions & 0 deletions packages/happy-app/sources/components/ForkLineageConnector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import React from 'react';
import { View } from 'react-native';
import { StyleSheet } from 'react-native-unistyles';
import { FORK_INDENT_SIZE, FORK_MAX_VISUAL_DEPTH, forkIndentPadding } from '@/utils/forkLineage';

// Re-exported so renderer components can pull the indent helper alongside the
// connector from one place. The ordering/indent math lives in the pure,
// unit-tested `@/utils/forkLineage` module.
export { forkIndentPadding };

/**
* An "└" tree connector linking a forked child row to its parent row directly
* above it. Rendered absolutely inside the row, occupying the indent gap just
* left of the row's content. Returns null for root rows (forkDepth 0).
*/
export function ForkLineageConnector({ forkDepth, rowHeight, basePadding }: {
forkDepth: number;
rowHeight: number;
basePadding: number;
}) {
if (forkDepth < 1) {
return null;
}
const visualDepth = Math.min(forkDepth, FORK_MAX_VISUAL_DEPTH);
const left = basePadding + (visualDepth - 1) * FORK_INDENT_SIZE + 4;
return (
<View
pointerEvents="none"
style={[
styles.connector,
{ left, width: FORK_INDENT_SIZE - 6, height: Math.round(rowHeight / 2) },
]}
/>
);
}

const styles = StyleSheet.create((theme) => ({
connector: {
position: 'absolute',
top: 0,
borderLeftWidth: 1.5,
borderBottomWidth: 1.5,
borderColor: theme.colors.divider,
borderBottomLeftRadius: 5,
},
}));
5 changes: 4 additions & 1 deletion packages/happy-app/sources/components/SessionsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useVisibleSessionListViewData } from '@/hooks/useVisibleSessionListViewData';
import { Typography } from '@/constants/Typography';
import { StatusDot } from './StatusDot';
import { ForkLineageConnector, forkIndentPadding } from './ForkLineageConnector';
import { StyleSheet } from 'react-native-unistyles';
import { useIsTablet } from '@/utils/responsive';
import { requestReview } from '@/utils/requestReview';
Expand Down Expand Up @@ -406,11 +407,13 @@ const SessionItem = React.memo(({ session, selected, isFirst, isLast, isSingle }
selected && styles.sessionItemSelected,
isSingle ? styles.sessionItemSingle :
isFirst ? styles.sessionItemFirst :
isLast ? styles.sessionItemLast : {}
isLast ? styles.sessionItemLast : {},
{ paddingLeft: forkIndentPadding(session.forkDepth, 16) },
]}
onPress={handlePress}
{...menuProps}
>
<ForkLineageConnector forkDepth={session.forkDepth} rowHeight={88} basePadding={16} />
<View style={styles.avatarContainer}>
<Avatar id={session.avatarId} size={48} monochrome={!status.isConnected} flavor={session.flavor} />
{session.hasDraft && (
Expand Down
22 changes: 18 additions & 4 deletions packages/happy-app/sources/sync/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { Message } from "./typesMessage";
import { NormalizedMessage } from "./typesRaw";
import { isMachineOnline } from '@/utils/machineUtils';
import { getSessionName, getSessionSubtitle, getSessionAvatarId, type SessionState } from '@/utils/sessionUtils';
import { orderSessionRowsByForkLineage } from '@/utils/forkLineage';
import { applySettings, Settings } from "./settings";
import { LocalSettings, applyLocalSettings } from "./localSettings";
import { Purchases, customerInfoToPurchases } from "./purchases";
Expand Down Expand Up @@ -93,6 +94,12 @@ export interface SessionRowData {
completedTodosCount: number;
totalTodosCount: number;
hasUnread: boolean;
// Fork lineage: the Happy session this one was forked from (null if not a
// fork), and its nesting depth within the current list section (0 = root /
// not nested). forkDepth is stamped by orderSessionRowsByForkLineage during
// list assembly so the renderer can indent forked children under their parent.
parentSessionId: string | null;
forkDepth: number;
}

function buildSessionRowData(session: Session, unreadSessionIds?: Set<string>): SessionRowData {
Expand Down Expand Up @@ -126,9 +133,16 @@ function buildSessionRowData(session: Session, unreadSessionIds?: Set<string>):
completedTodosCount: session.todos?.filter(todo => todo.status === 'completed').length ?? 0,
totalTodosCount: session.todos?.length ?? 0,
hasUnread: unreadSessionIds?.has(session.id) ?? false,
parentSessionId: session.metadata?.parentSessionId ?? null,
forkDepth: 0,
};
}

/** Build display rows for a section's sessions and nest forks within it. */
function buildOrderedSessionRows(sessions: Session[], unreadSessionIds?: Set<string>): SessionRowData[] {
return orderSessionRowsByForkLineage(sessions.map(s => buildSessionRowData(s, unreadSessionIds)));
}

// Unified list item type for SessionsList component
export type SessionListViewItem =
| { type: 'header'; title: string }
Expand Down Expand Up @@ -289,8 +303,8 @@ function buildSessionListViewData(
}

listData.push({ type: 'header', title: headerTitle });
currentDateGroup.forEach(sess => {
listData.push({ type: 'session', session: buildSessionRowData(sess, unreadSessionIds) });
buildOrderedSessionRows(currentDateGroup, unreadSessionIds).forEach(row => {
listData.push({ type: 'session', session: row });
});
}

Expand Down Expand Up @@ -319,8 +333,8 @@ function buildSessionListViewData(
}

listData.push({ type: 'header', title: headerTitle });
currentDateGroup.forEach(sess => {
listData.push({ type: 'session', session: buildSessionRowData(sess, unreadSessionIds) });
buildOrderedSessionRows(currentDateGroup, unreadSessionIds).forEach(row => {
listData.push({ type: 'session', session: row });
});
}

Expand Down
75 changes: 75 additions & 0 deletions packages/happy-app/sources/utils/forkLineage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { describe, it, expect } from 'vitest';
import {
orderSessionRowsByForkLineage,
forkIndentPadding,
FORK_INDENT_SIZE,
FORK_MAX_VISUAL_DEPTH,
} from './forkLineage';

type Row = { id: string; parentSessionId: string | null; forkDepth: number };
const row = (id: string, parentSessionId: string | null = null): Row => ({ id, parentSessionId, forkDepth: 0 });
const ids = (rows: Row[]) => rows.map(r => r.id);
const depths = (rows: Row[]) => rows.map(r => r.forkDepth);

describe('orderSessionRowsByForkLineage', () => {
it('leaves a fork-free list in original order at depth 0', () => {
const out = orderSessionRowsByForkLineage([row('a'), row('b'), row('c')]);
expect(ids(out)).toEqual(['a', 'b', 'c']);
expect(depths(out)).toEqual([0, 0, 0]);
});

it('nests a child directly under its parent at depth 1', () => {
// Input is newest-first: forked child 'b' sorts above its parent 'a'.
const out = orderSessionRowsByForkLineage([row('b', 'a'), row('a'), row('c')]);
expect(ids(out)).toEqual(['a', 'b', 'c']);
expect(depths(out)).toEqual([0, 1, 0]);
});

it('nests a multi-level fork chain with increasing depth', () => {
const out = orderSessionRowsByForkLineage([row('c', 'b'), row('b', 'a'), row('a')]);
expect(ids(out)).toEqual(['a', 'b', 'c']);
expect(depths(out)).toEqual([0, 1, 2]);
});

it('keeps a child at depth 0 when its parent is not in the same section', () => {
const out = orderSessionRowsByForkLineage([row('b', 'parent-in-another-group'), row('c')]);
expect(ids(out)).toEqual(['b', 'c']);
expect(depths(out)).toEqual([0, 0]);
});

it('places multiple children under one parent, preserving their order', () => {
const out = orderSessionRowsByForkLineage([row('c1', 'p'), row('c2', 'p'), row('p')]);
expect(ids(out)).toEqual(['p', 'c1', 'c2']);
expect(depths(out)).toEqual([0, 1, 1]);
});

it('does not loop or drop rows on a parent cycle', () => {
// a -> b -> a, both present (pathological metadata).
const out = orderSessionRowsByForkLineage([row('a', 'b'), row('b', 'a')]);
expect(out).toHaveLength(2);
expect(ids(out).sort()).toEqual(['a', 'b']);
});

it('returns the same row reference when depth is unchanged (deep-equal stability)', () => {
const a = row('a');
const b = row('b');
const out = orderSessionRowsByForkLineage([a, b]);
expect(out[0]).toBe(a);
expect(out[1]).toBe(b);
});
});

describe('forkIndentPadding', () => {
it('adds no indent at depth 0', () => {
expect(forkIndentPadding(0, 16)).toBe(16);
});

it('adds one indent step per level', () => {
expect(forkIndentPadding(1, 16)).toBe(16 + FORK_INDENT_SIZE);
expect(forkIndentPadding(2, 16)).toBe(16 + 2 * FORK_INDENT_SIZE);
});

it('caps the visual indent at FORK_MAX_VISUAL_DEPTH', () => {
expect(forkIndentPadding(99, 16)).toBe(16 + FORK_MAX_VISUAL_DEPTH * FORK_INDENT_SIZE);
});
});
92 changes: 92 additions & 0 deletions packages/happy-app/sources/utils/forkLineage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// Pure helpers for nesting forked sessions in the session list.
//
// Kept free of React / React-Native imports so the ordering logic stays
// unit-testable (storage.ts and the renderer components both pull in RN and
// cannot be loaded under vitest).

export const FORK_INDENT_SIZE = 20;
export const FORK_MAX_VISUAL_DEPTH = 4;

/** Minimal shape the fork ordering needs from a session row. */
export interface ForkLineageRow {
id: string;
parentSessionId: string | null;
forkDepth: number;
}

/** Left padding for a row at `forkDepth`, added on top of the row's base padding. */
export function forkIndentPadding(forkDepth: number, basePadding: number): number {
const visualDepth = Math.min(Math.max(forkDepth, 0), FORK_MAX_VISUAL_DEPTH);
return basePadding + visualDepth * FORK_INDENT_SIZE;
}

/**
* Reorder a flat array of session rows so that forked children appear
* immediately after their parent (depth-first) and stamp each row's `forkDepth`
* (0 = root within this array). A row whose parent is NOT present in the same
* array is treated as a root at depth 0 — nesting therefore happens only within
* a single rendered section (a date group, or an active project group), never
* across section boundaries. New row objects are returned when a row's depth
* changes so deep-equality still detects the update. O(n).
*/
export function orderSessionRowsByForkLineage<T extends ForkLineageRow>(rows: T[]): T[] {
const atRoot = (r: T): T => (r.forkDepth === 0 ? r : { ...r, forkDepth: 0 } as T);
if (rows.length < 2) {
return rows.map(atRoot);
}

const present = new Set(rows.map(r => r.id));
const childrenByParent = new Map<string, T[]>();
const roots: T[] = [];

for (const row of rows) {
const parentId = row.parentSessionId;
if (parentId && parentId !== row.id && present.has(parentId)) {
const siblings = childrenByParent.get(parentId);
if (siblings) {
siblings.push(row);
} else {
childrenByParent.set(parentId, [row]);
}
} else {
roots.push(row);
}
}

// No fork relationships within this array — keep original order at depth 0.
if (childrenByParent.size === 0) {
return rows.map(atRoot);
}

const ordered: T[] = [];
const visited = new Set<string>();

const emit = (row: T, depth: number) => {
if (visited.has(row.id)) {
return; // guard against pathological parent cycles
}
visited.add(row.id);
ordered.push(row.forkDepth === depth ? row : { ...row, forkDepth: depth } as T);
const children = childrenByParent.get(row.id);
if (children) {
for (const child of children) {
emit(child, depth + 1);
}
}
};

for (const root of roots) {
emit(root, 0);
}

// Safety net: emit any rows skipped by a cycle, at depth 0, preserving order.
if (ordered.length !== rows.length) {
for (const row of rows) {
if (!visited.has(row.id)) {
ordered.push(atRoot(row));
}
}
}

return ordered;
}
Loading