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 ( +
  1. + {isSelf || !onSelectRelatedPlan ? ( +
    + {content} +
    + ) : ( + + )} +
  2. + ); + })} +
+
+ ); +} + 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';