diff --git a/packages/app/src/client/App.tsx b/packages/app/src/client/App.tsx
index 8b3da36..619794d 100644
--- a/packages/app/src/client/App.tsx
+++ b/packages/app/src/client/App.tsx
@@ -302,7 +302,12 @@ function Dashboard() {
) : selectedPlan ? (
diff --git a/packages/ee/src/App.tsx b/packages/ee/src/App.tsx
index a9755ea..241a13e 100644
--- a/packages/ee/src/App.tsx
+++ b/packages/ee/src/App.tsx
@@ -854,6 +854,8 @@ function CloudPlanReviewWorkspace({
actionToolbarExtra,
outlineHidden,
chartHidden,
+ allPlans,
+ onSelectRelatedPlan,
onEdit,
onHistory,
onShare,
@@ -872,6 +874,8 @@ function CloudPlanReviewWorkspace({
actionToolbarExtra?: ReactNode;
outlineHidden?: boolean;
chartHidden?: boolean;
+ allPlans?: readonly Plan[];
+ onSelectRelatedPlan?: (plan: Plan) => void;
onEdit: () => void;
onHistory: () => void;
onShare: () => void;
@@ -985,6 +989,8 @@ function CloudPlanReviewWorkspace({
void;
onClose: () => void;
onSaved: () => void;
onCreated: (plan: Plan) => void;
@@ -1295,6 +1305,8 @@ function DashboardMain({
startViewTransition(() => setSelectedPlan(plan))}
onClose={() => startViewTransition(() => setActivePanel(null))}
onSaved={handleSaved}
onCreated={(plan) => {
diff --git a/packages/web/src/client/components/PlanList.tsx b/packages/web/src/client/components/PlanList.tsx
index e48342f..f7ed5a7 100644
--- a/packages/web/src/client/components/PlanList.tsx
+++ b/packages/web/src/client/components/PlanList.tsx
@@ -5,6 +5,7 @@ import { getAgentLabel } from '../lib/agent-colors.ts';
import type { Plan } from '../lib/api.ts';
import { isCustomDirPlan } from '../lib/custom-plan-tree.ts';
import type { FolderState } from '../lib/plan-folders.ts';
+import { plansWithSessionSiblings } from '../lib/plan-lineage.ts';
import type { PlanState } from '../lib/plan-state.ts';
import { AgentIcon } from './AgentIcon.tsx';
import { CustomDirTree } from './CustomDirTree.tsx';
@@ -25,6 +26,7 @@ function PlanRow({
plan,
selected,
unseen,
+ hasSessionSiblings,
onClick,
isSplit,
onContextMenu,
@@ -37,6 +39,7 @@ function PlanRow({
plan: Plan;
selected: boolean;
unseen: boolean;
+ hasSessionSiblings?: boolean;
onClick: () => void;
isSplit?: boolean;
onContextMenu?: (e: React.MouseEvent) => void;
@@ -103,6 +106,14 @@ function PlanRow({
{getAgentLabel(plan.agent)}
·
{timeAgo(plan.updatedAt)}
+ {hasSessionSiblings && (
+ <>
+ ·
+
+ session
+
+ >
+ )}
);
@@ -194,6 +205,7 @@ export function PlanList(props: PlanListProps) {
const [renamingPlanId, setRenamingPlanId] = useState(null);
const [renameValue, setRenameValue] = useState('');
const lastAutoSeenKeyRef = useRef(null);
+ const sessionSiblingIds = useMemo(() => plansWithSessionSiblings(plans), [plans]);
useEffect(() => {
if (!contextMenu) return;
@@ -323,6 +335,7 @@ export function PlanList(props: PlanListProps) {
plan={plan}
selected={plan.id === selectedId}
unseen={isPro && planState.isUnseen(plan.id, plan.updatedAt)}
+ hasSessionSiblings={sessionSiblingIds.has(plan.id)}
onClick={() => handleClick(plan)}
isSplit={isPro && plan.id === splitPlanId}
onContextMenu={(e) => handleContextMenu(e, plan)}
@@ -358,6 +371,7 @@ export function PlanList(props: PlanListProps) {
plan={plan}
selected={plan.id === selectedId}
unseen={planState.isUnseen(plan.id, plan.updatedAt)}
+ hasSessionSiblings={sessionSiblingIds.has(plan.id)}
onClick={() => handleClick(plan)}
isSplit={plan.id === splitPlanId}
onContextMenu={(e) => handleContextMenu(e, plan)}
@@ -394,6 +408,7 @@ export function PlanList(props: PlanListProps) {
plan={plan}
selected={plan.id === selectedId}
unseen
+ hasSessionSiblings={sessionSiblingIds.has(plan.id)}
onClick={() => handleClick(plan)}
isSplit={plan.id === splitPlanId}
onContextMenu={(e) => handleContextMenu(e, plan)}
@@ -417,6 +432,7 @@ export function PlanList(props: PlanListProps) {
plan={plan}
selected={plan.id === selectedId}
unseen={isPro && planState.isUnseen(plan.id, plan.updatedAt)}
+ hasSessionSiblings={sessionSiblingIds.has(plan.id)}
onClick={() => handleClick(plan)}
isSplit={isPro && plan.id === splitPlanId}
onContextMenu={(e) => handleContextMenu(e, plan)}
@@ -435,6 +451,7 @@ export function PlanList(props: PlanListProps) {
plan={plan}
selected={plan.id === selectedId}
unseen={isPro && planState.isUnseen(plan.id, plan.updatedAt)}
+ hasSessionSiblings={sessionSiblingIds.has(plan.id)}
onClick={() => handleClick(plan)}
isSplit={isPro && plan.id === splitPlanId}
onContextMenu={isPro || folderState ? (e) => handleContextMenu(e, plan) : undefined}
diff --git a/packages/web/src/client/components/PlanViewer.tsx b/packages/web/src/client/components/PlanViewer.tsx
index 08c4cff..3401fa2 100644
--- a/packages/web/src/client/components/PlanViewer.tsx
+++ b/packages/web/src/client/components/PlanViewer.tsx
@@ -20,6 +20,12 @@ import {
} from '../lib/annotations.ts';
import type { Plan } from '../lib/api.ts';
import { buildPlanOutline } from '../lib/extract-headings.ts';
+import {
+ getRelatedPlans,
+ type LineageConfidence,
+ type LineageRelation,
+ type RelatedPlanEntry,
+} from '../lib/plan-lineage.ts';
import { extractSyncOrigin, formatSyncOriginLabel } from '../lib/sync-origin.ts';
import { AgentIcon } from './AgentIcon.tsx';
import { ExitFullscreenIcon, FullscreenIcon } from './FullscreenIcons.tsx';
@@ -154,8 +160,85 @@ function intersectsNestedBlockedSelection(root: HTMLElement, range: Range): bool
return false;
}
+function lineageSectionTitle(confidence: LineageConfidence | undefined): string {
+ if (confidence === 'workspace-fallback') return 'Nearby in workspace';
+ if (confidence === 'thread') return 'Session thread';
+ return 'Same session';
+}
+
+function lineageRelationLabel(relation: LineageRelation): string | undefined {
+ if (relation === 'parent') return 'Parent';
+ if (relation === 'child') return 'Child';
+ if (relation === 'self') return 'Current';
+ return undefined;
+}
+
+function PlanLineageSection({
+ entries,
+ confidence,
+ onSelectRelatedPlan,
+}: {
+ entries: RelatedPlanEntry[];
+ confidence: LineageConfidence | undefined;
+ onSelectRelatedPlan?: (plan: Plan) => void;
+}) {
+ return (
+
+
+ {lineageSectionTitle(confidence)}
+ {confidence === 'workspace-fallback' && (
+ Approximate match
+ )}
+
+
+ {entries.map((entry) => {
+ const relationLabel = lineageRelationLabel(entry.relation);
+ const isSelf = entry.relation === 'self';
+ const content = (
+ <>
+ {entry.plan.title}
+
+ {relationLabel && (
+ {relationLabel}
+ )}
+ {timeAgo(entry.plan.createdAt)}
+
+ >
+ );
+
+ return (
+ -
+ {isSelf || !onSelectRelatedPlan ? (
+
+ {content}
+
+ ) : (
+
+ )}
+
+ );
+ })}
+
+
+ );
+}
+
type PlanViewerProps = {
plan: Plan;
+ /** Full indexed plan list used to resolve session lineage. */
+ allPlans?: readonly Plan[];
+ onSelectRelatedPlan?: (plan: Plan) => void;
headerExtra?: ReactNode;
actionToolbarExtra?: ReactNode;
onEdit?: () => void;
@@ -180,6 +263,8 @@ type PlanViewerProps = {
export function PlanViewer({
plan,
+ allPlans,
+ onSelectRelatedPlan,
headerExtra,
actionToolbarExtra,
onEdit,
@@ -250,6 +335,17 @@ export function PlanViewer({
};
const workspace = extractWorkspace(plan);
const syncOrigin = extractSyncOrigin(plan);
+ const lineage = useMemo(
+ () => (allPlans ? getRelatedPlans(plan, allPlans) : null),
+ [allPlans, plan],
+ );
+ const lineageConfidence = useMemo((): LineageConfidence | undefined => {
+ if (!lineage?.hasRelated) return undefined;
+ const related = lineage.items.filter((entry) => entry.relation !== 'self');
+ if (related.some((entry) => entry.confidence === 'session')) return 'session';
+ if (related.some((entry) => entry.confidence === 'thread')) return 'thread';
+ return related[0]?.confidence ?? 'workspace-fallback';
+ }, [lineage]);
const outline = useMemo(
() =>
@@ -741,6 +837,14 @@ export function PlanViewer({
actionToolbar
)}
+
+ {lineage?.hasRelated && (
+
+ )}
{onChartWideChange && !chartHidden && (
diff --git a/packages/web/src/client/index.css b/packages/web/src/client/index.css
index d66fea5..04b291b 100644
--- a/packages/web/src/client/index.css
+++ b/packages/web/src/client/index.css
@@ -723,6 +723,12 @@
white-space: nowrap;
}
+.sidebar-plan-session {
+ color: var(--secondary);
+ font-weight: 500;
+ letter-spacing: 0.01em;
+}
+
.sidebar-unread-dot {
position: absolute;
left: 1px;
@@ -1854,6 +1860,118 @@ body {
padding-top: 2px;
}
+.plan-lineage {
+ display: grid;
+ gap: 8px;
+ min-width: 0;
+ padding-top: 4px;
+}
+
+.plan-lineage-heading {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ gap: 8px;
+ min-width: 0;
+}
+
+.plan-lineage-title {
+ color: var(--secondary);
+ font-size: 11px;
+ font-weight: 560;
+ letter-spacing: 0.02em;
+ text-transform: uppercase;
+}
+
+.plan-lineage-hint {
+ color: var(--tertiary);
+ font-size: 11px;
+ font-weight: 450;
+}
+
+.plan-lineage-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: grid;
+ gap: 2px;
+ min-width: 0;
+ border-left: 1px solid var(--border);
+ padding-left: 12px;
+}
+
+.plan-lineage-item {
+ min-width: 0;
+}
+
+.plan-lineage-item-body {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 4px 12px;
+ min-width: 0;
+ width: 100%;
+ padding: 4px 0;
+ color: var(--secondary);
+ font-size: 12px;
+ line-height: 1.35;
+ text-align: left;
+}
+
+.plan-lineage-item-button {
+ appearance: none;
+ background: transparent;
+ border: none;
+ border-radius: 4px;
+ margin-left: -6px;
+ padding-left: 6px;
+ padding-right: 6px;
+ cursor: pointer;
+ font: inherit;
+}
+
+.plan-lineage-item-button:hover {
+ background: var(--hover);
+ color: var(--text);
+}
+
+.plan-lineage-item-button:focus-visible {
+ outline: 2px solid var(--focus, var(--accent));
+ outline-offset: 1px;
+}
+
+.plan-lineage-item--current .plan-lineage-item-body {
+ color: var(--text);
+}
+
+.plan-lineage-item-title {
+ min-width: 0;
+ flex: 1 1 12ch;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-weight: 500;
+}
+
+.plan-lineage-item--current .plan-lineage-item-title {
+ font-weight: 600;
+}
+
+.plan-lineage-item-meta {
+ display: inline-flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 6px;
+ color: var(--tertiary);
+ white-space: nowrap;
+}
+
+.plan-lineage-item-relation {
+ color: var(--secondary);
+ font-weight: 520;
+}
+
.plan-header-extra {
min-width: 0;
flex: 1 1 auto;
diff --git a/packages/web/src/client/lib/plan-lineage.test.ts b/packages/web/src/client/lib/plan-lineage.test.ts
new file mode 100644
index 0000000..9426922
--- /dev/null
+++ b/packages/web/src/client/lib/plan-lineage.test.ts
@@ -0,0 +1,215 @@
+import { expect, test } from 'bun:test';
+import type { Plan } from './api.ts';
+import { extractLineageKeys, getRelatedPlans, plansWithSessionSiblings } from './plan-lineage.ts';
+
+function makePlan(overrides: Partial
& { id: string }): Plan {
+ return {
+ agent: 'cursor',
+ title: overrides.title ?? overrides.id,
+ content: '',
+ filePath: `/tmp/${overrides.id}.md`,
+ format: 'md',
+ createdAt: '2026-01-01T00:00:00.000Z',
+ updatedAt: '2026-01-01T00:00:00.000Z',
+ metadata: {},
+ ...overrides,
+ };
+}
+
+test('extractLineageKeys reads sessionId parentThreadId and branch', () => {
+ const plan = makePlan({
+ id: 'a',
+ metadata: { sessionId: ' sess-1 ', parentThreadId: 'parent-1', branch: 'feat/x' },
+ });
+ expect(extractLineageKeys(plan)).toEqual({
+ sessionId: 'sess-1',
+ parentThreadId: 'parent-1',
+ branch: 'feat/x',
+ });
+});
+
+test('getRelatedPlans groups same-session peers chronologically and marks self', () => {
+ const plans = [
+ makePlan({
+ id: 'early',
+ title: 'First',
+ createdAt: '2026-03-01T10:00:00.000Z',
+ metadata: { sessionId: 's1' },
+ }),
+ makePlan({
+ id: 'mid',
+ title: 'Current',
+ createdAt: '2026-03-01T11:00:00.000Z',
+ metadata: { sessionId: 's1' },
+ }),
+ makePlan({
+ id: 'late',
+ title: 'Later',
+ createdAt: '2026-03-01T12:00:00.000Z',
+ metadata: { sessionId: 's1' },
+ }),
+ makePlan({
+ id: 'other',
+ title: 'Other session',
+ metadata: { sessionId: 's2' },
+ }),
+ ];
+
+ const current = plans[1];
+ expect(current).toBeDefined();
+ if (!current) throw new Error('expected current plan');
+ const lineage = getRelatedPlans(current, plans);
+ expect(lineage.hasRelated).toBe(true);
+ expect(lineage.peers.map((e) => e.plan.id)).toEqual(['early', 'late']);
+ expect(lineage.items.map((e) => e.plan.id)).toEqual(['early', 'mid', 'late']);
+ expect(lineage.items.map((e) => e.relation)).toEqual(['peer', 'self', 'peer']);
+ expect(lineage.items.every((e) => e.confidence === 'session')).toBe(true);
+});
+
+test('getRelatedPlans links parent and children via parentThreadId', () => {
+ const parent = makePlan({
+ id: 'parent',
+ agent: 'codex-cli',
+ createdAt: '2026-03-01T09:00:00.000Z',
+ metadata: { sessionId: 'parent-thread' },
+ });
+ const child = makePlan({
+ id: 'child',
+ agent: 'codex-cli',
+ createdAt: '2026-03-01T10:00:00.000Z',
+ metadata: { sessionId: 'child-thread', parentThreadId: 'parent-thread' },
+ });
+ const sibling = makePlan({
+ id: 'sibling',
+ agent: 'codex-cli',
+ createdAt: '2026-03-01T11:00:00.000Z',
+ metadata: { sessionId: 'child-thread', parentThreadId: 'parent-thread' },
+ });
+
+ const fromChild = getRelatedPlans(child, [parent, child, sibling]);
+ expect(fromChild.parent?.plan.id).toBe('parent');
+ expect(fromChild.parent?.confidence).toBe('thread');
+ expect(fromChild.peers.map((e) => e.plan.id)).toEqual(['sibling']);
+
+ const fromParent = getRelatedPlans(parent, [parent, child, sibling]);
+ expect(fromParent.children.map((e) => e.plan.id).sort()).toEqual(['child', 'sibling']);
+ expect(fromParent.children.every((e) => e.confidence === 'thread')).toBe(true);
+});
+
+test('getRelatedPlans falls back to workspace agent day or branch when session missing', () => {
+ const current = makePlan({
+ id: 'a',
+ agent: 'grok',
+ workspace: '/repo',
+ createdAt: '2026-04-02T08:00:00.000Z',
+ updatedAt: '2026-04-02T09:00:00.000Z',
+ metadata: { branch: 'main' },
+ });
+ const sameDay = makePlan({
+ id: 'b',
+ agent: 'grok',
+ workspace: '/repo',
+ createdAt: '2026-04-02T14:00:00.000Z',
+ metadata: {},
+ });
+ const sameBranch = makePlan({
+ id: 'c',
+ agent: 'grok',
+ workspace: '/repo',
+ createdAt: '2026-04-10T14:00:00.000Z',
+ metadata: { branch: 'main' },
+ });
+ const otherAgent = makePlan({
+ id: 'd',
+ agent: 'cursor',
+ workspace: '/repo',
+ createdAt: '2026-04-02T14:00:00.000Z',
+ metadata: {},
+ });
+ const otherWorkspace = makePlan({
+ id: 'e',
+ agent: 'grok',
+ workspace: '/other',
+ createdAt: '2026-04-02T14:00:00.000Z',
+ metadata: {},
+ });
+ const withSession = makePlan({
+ id: 'f',
+ agent: 'grok',
+ workspace: '/repo',
+ createdAt: '2026-04-02T14:00:00.000Z',
+ metadata: { sessionId: 'elsewhere' },
+ });
+
+ const lineage = getRelatedPlans(current, [
+ current,
+ sameDay,
+ sameBranch,
+ otherAgent,
+ otherWorkspace,
+ withSession,
+ ]);
+ expect(lineage.hasRelated).toBe(true);
+ expect(lineage.peers.map((e) => e.plan.id).sort()).toEqual(['b', 'c']);
+ expect(lineage.peers.every((e) => e.confidence === 'workspace-fallback')).toBe(true);
+});
+
+test('getRelatedPlans returns no related when nothing matches', () => {
+ const plan = makePlan({ id: 'solo', metadata: { sessionId: 'only-me' } });
+ const other = makePlan({ id: 'other', metadata: { sessionId: 'different' } });
+ const lineage = getRelatedPlans(plan, [plan, other]);
+ expect(lineage.hasRelated).toBe(false);
+ expect(lineage.items).toHaveLength(1);
+ expect(lineage.items[0]?.relation).toBe('self');
+});
+
+test('getRelatedPlans ignores same sessionId from a different agent', () => {
+ const current = makePlan({ id: 'a', agent: 'cursor', metadata: { sessionId: 's1' } });
+ const peer = makePlan({ id: 'b', agent: 'cursor', metadata: { sessionId: 's1' } });
+ const otherAgent = makePlan({ id: 'e', agent: 'grok', metadata: { sessionId: 's1' } });
+
+ const lineage = getRelatedPlans(current, [current, peer, otherAgent]);
+ expect(lineage.peers.map((e) => e.plan.id)).toEqual(['b']);
+ expect(lineage.items.map((e) => e.plan.id)).toEqual(['a', 'b']);
+});
+
+test('getRelatedPlans caps session peers around the current plan', () => {
+ const plans = Array.from({ length: 12 }, (_, index) =>
+ makePlan({
+ id: `p${index}`,
+ createdAt: `2026-05-01T${String(index).padStart(2, '0')}:00:00.000Z`,
+ metadata: { sessionId: 'long' },
+ }),
+ );
+ const current = plans[6];
+ expect(current).toBeDefined();
+ if (!current) throw new Error('expected current plan');
+
+ const lineage = getRelatedPlans(current, plans);
+ expect(lineage.peers).toHaveLength(8);
+ expect(lineage.items).toHaveLength(9); // 8 peers + self
+ expect(lineage.items.map((e) => e.plan.id)).toEqual([
+ 'p2',
+ 'p3',
+ 'p4',
+ 'p5',
+ 'p6',
+ 'p7',
+ 'p8',
+ 'p9',
+ 'p10',
+ ]);
+ expect(lineage.items.find((e) => e.relation === 'self')?.plan.id).toBe('p6');
+});
+
+test('plansWithSessionSiblings marks only plans that share a session', () => {
+ const plans = [
+ makePlan({ id: 'a', metadata: { sessionId: 's1' } }),
+ makePlan({ id: 'b', metadata: { sessionId: 's1' } }),
+ makePlan({ id: 'c', metadata: { sessionId: 's2' } }),
+ makePlan({ id: 'd', metadata: {} }),
+ makePlan({ id: 'e', agent: 'grok', metadata: { sessionId: 's1' } }),
+ ];
+ const ids = plansWithSessionSiblings(plans);
+ expect([...ids].sort()).toEqual(['a', 'b']);
+});
diff --git a/packages/web/src/client/lib/plan-lineage.ts b/packages/web/src/client/lib/plan-lineage.ts
new file mode 100644
index 0000000..e2e456a
--- /dev/null
+++ b/packages/web/src/client/lib/plan-lineage.ts
@@ -0,0 +1,238 @@
+import type { Plan } from './api.ts';
+
+export type LineageConfidence = 'session' | 'thread' | 'workspace-fallback';
+
+export type LineageRelation = 'self' | 'peer' | 'parent' | 'child';
+
+export type RelatedPlanEntry = {
+ plan: Plan;
+ relation: LineageRelation;
+ confidence: LineageConfidence;
+};
+
+export type PlanLineage = {
+ /** Timeline of related plans including the current plan when anything related exists. */
+ items: RelatedPlanEntry[];
+ parent?: RelatedPlanEntry;
+ children: RelatedPlanEntry[];
+ peers: RelatedPlanEntry[];
+ hasRelated: boolean;
+};
+
+/** Cap related peers shown in the viewer (session + workspace-fallback). */
+const LINEAGE_PEER_LIMIT = 8;
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null;
+}
+
+function stringMeta(metadata: Record, key: string): string | undefined {
+ const value = metadata[key];
+ if (typeof value !== 'string') return undefined;
+ const trimmed = value.trim();
+ return trimmed || undefined;
+}
+
+/** Reads lineage keys adapters stash on `plan.metadata`. */
+export function extractLineageKeys(plan: Pick): {
+ sessionId?: string;
+ parentThreadId?: string;
+ branch?: string;
+} {
+ if (!isRecord(plan.metadata)) return {};
+ return {
+ sessionId: stringMeta(plan.metadata, 'sessionId'),
+ parentThreadId: stringMeta(plan.metadata, 'parentThreadId'),
+ branch: stringMeta(plan.metadata, 'branch'),
+ };
+}
+
+function dayKey(iso: string): string | undefined {
+ const ms = Date.parse(iso);
+ if (Number.isNaN(ms)) return undefined;
+ return new Date(ms).toISOString().slice(0, 10);
+}
+
+function planDays(plan: Plan): Set {
+ const days = new Set();
+ const created = dayKey(plan.createdAt);
+ const updated = dayKey(plan.updatedAt);
+ if (created) days.add(created);
+ if (updated) days.add(updated);
+ return days;
+}
+
+function daysOverlap(a: Plan, b: Plan): boolean {
+ const aDays = planDays(a);
+ for (const day of planDays(b)) {
+ if (aDays.has(day)) return true;
+ }
+ return false;
+}
+
+function sortByCreatedThenUpdated(a: Plan, b: Plan): number {
+ const created = Date.parse(a.createdAt) - Date.parse(b.createdAt);
+ if (created !== 0) return created;
+ return Date.parse(a.updatedAt) - Date.parse(b.updatedAt);
+}
+
+/** Keep up to `limit` peers nearest to `current` in chronological order. */
+function limitPeersAroundCurrent(current: Plan, peers: Plan[], limit: number): Plan[] {
+ if (peers.length <= limit) return peers;
+ const currentTime = Date.parse(current.createdAt);
+ let insertAt = peers.findIndex((plan) => Date.parse(plan.createdAt) > currentTime);
+ if (insertAt === -1) insertAt = peers.length;
+ let start = Math.max(0, insertAt - Math.floor(limit / 2));
+ let end = start + limit;
+ if (end > peers.length) {
+ end = peers.length;
+ start = Math.max(0, end - limit);
+ }
+ return peers.slice(start, end);
+}
+
+function emptyLineage(current: Plan): PlanLineage {
+ return {
+ items: [{ plan: current, relation: 'self', confidence: 'session' }],
+ children: [],
+ peers: [],
+ hasRelated: false,
+ };
+}
+
+/**
+ * Groups plans that share agent-session lineage with `current`.
+ *
+ * Priority:
+ * 1. Same `metadata.sessionId` (peers)
+ * 2. Codex-style `parentThreadId` parent/child links
+ * 3. Soft fallback: same agent + workspace + (branch or overlapping day)
+ */
+export function getRelatedPlans(current: Plan, allPlans: readonly Plan[]): PlanLineage {
+ const keys = extractLineageKeys(current);
+ const byId = new Map();
+
+ const remember = (entry: RelatedPlanEntry) => {
+ const existing = byId.get(entry.plan.id);
+ if (!existing) {
+ byId.set(entry.plan.id, entry);
+ return;
+ }
+ // Prefer stronger relations / confidence when the same plan appears twice.
+ const relationRank: Record = {
+ self: 4,
+ parent: 3,
+ child: 2,
+ peer: 1,
+ };
+ const confidenceRank: Record = {
+ session: 3,
+ thread: 2,
+ 'workspace-fallback': 1,
+ };
+ if (
+ relationRank[entry.relation] > relationRank[existing.relation] ||
+ (entry.relation === existing.relation &&
+ confidenceRank[entry.confidence] > confidenceRank[existing.confidence])
+ ) {
+ byId.set(entry.plan.id, entry);
+ }
+ };
+
+ remember({
+ plan: current,
+ relation: 'self',
+ confidence: keys.sessionId ? 'session' : 'workspace-fallback',
+ });
+
+ if (keys.sessionId) {
+ const sessionPeers: Plan[] = [];
+ for (const plan of allPlans) {
+ if (plan.id === current.id) continue;
+ // Match list badge scoping: `${agent}\0${sessionId}`.
+ if (plan.agent !== current.agent) continue;
+ const other = extractLineageKeys(plan);
+ if (other.sessionId === keys.sessionId) {
+ sessionPeers.push(plan);
+ }
+ }
+ sessionPeers.sort(sortByCreatedThenUpdated);
+ for (const plan of limitPeersAroundCurrent(current, sessionPeers, LINEAGE_PEER_LIMIT)) {
+ remember({ plan, relation: 'peer', confidence: 'session' });
+ }
+
+ if (keys.parentThreadId && keys.parentThreadId !== keys.sessionId) {
+ for (const plan of allPlans) {
+ if (plan.id === current.id) continue;
+ if (plan.agent !== current.agent) continue;
+ const other = extractLineageKeys(plan);
+ if (other.sessionId === keys.parentThreadId) {
+ remember({ plan, relation: 'parent', confidence: 'thread' });
+ }
+ }
+ }
+
+ for (const plan of allPlans) {
+ if (plan.id === current.id) continue;
+ if (plan.agent !== current.agent) continue;
+ const other = extractLineageKeys(plan);
+ if (other.parentThreadId && other.parentThreadId === keys.sessionId) {
+ remember({ plan, relation: 'child', confidence: 'thread' });
+ }
+ }
+ } else {
+ const workspace = current.workspace?.trim();
+ if (workspace) {
+ const fallbacks: Plan[] = [];
+ for (const plan of allPlans) {
+ if (plan.id === current.id) continue;
+ if (plan.agent !== current.agent) continue;
+ if (plan.workspace?.trim() !== workspace) continue;
+ const other = extractLineageKeys(plan);
+ // Skip plans that have their own session identity — they belong elsewhere.
+ if (other.sessionId) continue;
+ const branchMatch = Boolean(keys.branch && other.branch && keys.branch === other.branch);
+ if (branchMatch || daysOverlap(current, plan)) {
+ fallbacks.push(plan);
+ }
+ }
+ fallbacks.sort(sortByCreatedThenUpdated);
+ for (const plan of limitPeersAroundCurrent(current, fallbacks, LINEAGE_PEER_LIMIT)) {
+ remember({ plan, relation: 'peer', confidence: 'workspace-fallback' });
+ }
+ }
+ }
+
+ const peers = [...byId.values()].filter((entry) => entry.relation === 'peer');
+ const children = [...byId.values()].filter((entry) => entry.relation === 'child');
+ const parent = [...byId.values()].find((entry) => entry.relation === 'parent');
+ const hasRelated = peers.length > 0 || children.length > 0 || Boolean(parent);
+
+ if (!hasRelated) return emptyLineage(current);
+
+ const items = [...byId.values()].sort((a, b) => sortByCreatedThenUpdated(a.plan, b.plan));
+ return { items, parent, children, peers, hasRelated };
+}
+
+/**
+ * Returns plan ids that share a `sessionId` with at least one other plan.
+ * Used for the lightweight list affordance.
+ */
+export function plansWithSessionSiblings(plans: readonly Plan[]): ReadonlySet {
+ const bySession = new Map();
+ for (const plan of plans) {
+ const { sessionId } = extractLineageKeys(plan);
+ if (!sessionId) continue;
+ const key = `${plan.agent}\0${sessionId}`;
+ const list = bySession.get(key);
+ if (list) list.push(plan.id);
+ else bySession.set(key, [plan.id]);
+ }
+
+ const result = new Set();
+ for (const ids of bySession.values()) {
+ if (ids.length < 2) continue;
+ for (const id of ids) result.add(id);
+ }
+ return result;
+}
diff --git a/packages/web/src/index.ts b/packages/web/src/index.ts
index c2aa9e4..5d0a725 100644
--- a/packages/web/src/index.ts
+++ b/packages/web/src/index.ts
@@ -95,6 +95,17 @@ export type { OutlineEntry } from './client/lib/extract-headings.ts';
export { buildPlanOutline } from './client/lib/extract-headings.ts';
export { looksLikeMarkdown, normalizePlanMarkdown } from './client/lib/plan-markdown.ts';
export { filterPlans } from './client/lib/plan-search.ts';
+export type {
+ LineageConfidence,
+ LineageRelation,
+ PlanLineage,
+ RelatedPlanEntry,
+} from './client/lib/plan-lineage.ts';
+export {
+ extractLineageKeys,
+ getRelatedPlans,
+ plansWithSessionSiblings,
+} from './client/lib/plan-lineage.ts';
export type { PlanState, PlanStatePlan } from './client/lib/plan-state.ts';
export { sanitizeSchema } from './client/lib/sanitize-schema.ts';
export type { PlanSyncOrigin } from './client/lib/sync-origin.ts';