blob: ebf299251debd0b48f68bccbb00fa0bef6cd4976 [file]
import {
projectLinkedSessionTree,
sessionRevisionFamilyId,
type LinkedSessionTree,
type SessionSummary,
} from './session.js';
export { sessionRevisionFamilyId } from './session.js';
function freshness(session: SessionSummary): number {
return session.lastMessageAt ?? session.statusUpdatedAt ?? 0;
}
export function visibleSessionRevisionMembers(
members: readonly SessionSummary[],
activeId?: string,
): SessionSummary[] {
return members.filter(
(session) =>
!session.revisionParentSessionId ||
session.id === activeId ||
session.revisionState !== 'preparing',
);
}
function compareFreshness(left: SessionSummary, right: SessionSummary): number {
if ((left.lastMessageAt !== undefined) !== (right.lastMessageAt !== undefined)) {
return left.lastMessageAt !== undefined ? -1 : 1;
}
const delta = freshness(right) - freshness(left);
return delta !== 0 ? delta : left.id.localeCompare(right.id);
}
/**
* Fold physical edit-and-resend versions into one logical conversation row.
* Ordinary branch sessions have no revisionRootSessionId and remain separate.
*/
export function collapseSessionRevisions(
sessions: readonly SessionSummary[],
activeId?: string,
): SessionSummary[] {
const families = new Map<string, SessionSummary[]>();
for (const session of sessions) {
const root = sessionRevisionFamilyId(session);
const members = families.get(root) ?? [];
members.push(session);
families.set(root, members);
}
const selected = new Map<string, string>();
for (const [root, members] of families) {
const active = activeId ? members.find((session) => session.id === activeId) : undefined;
if (active) {
selected.set(root, active.id);
continue;
}
const visible = visibleSessionRevisionMembers(members, activeId);
const candidates = visible.length > 0 ? visible : [...members];
candidates.sort(compareFreshness);
selected.set(root, candidates[0]!.id);
}
return sessions.filter(
(session) => selected.get(sessionRevisionFamilyId(session)) === session.id,
);
}
/**
* Build the host-facing child-session tree over logical revision rows.
*
* Child relations remain durably anchored to the exact physical parent that
* spawned them. This read model aliases every physical revision id to the
* currently selected representative so edit-and-resend cannot orphan a child
* in Desktop/TUI projection.
*/
export function projectRevisionLinkedSessionTree(
sessions: readonly SessionSummary[],
activeId?: string,
): LinkedSessionTree {
const logicalSessions = collapseSessionRevisions(sessions, activeId);
const representativeByFamilyId = new Map(
logicalSessions.map((session) => [sessionRevisionFamilyId(session), session.id]),
);
const parentSessionIdAliases = new Map<string, string>();
for (const session of sessions) {
const representativeId = representativeByFamilyId.get(sessionRevisionFamilyId(session));
if (representativeId) parentSessionIdAliases.set(session.id, representativeId);
}
return projectLinkedSessionTree(logicalSessions, { parentSessionIdAliases });
}
/** Every durable physical version represented by a logical conversation row. */
export function revisionFamilySessionIds(
sessions: readonly SessionSummary[],
sessionId: string,
): string[] {
const target = sessions.find((session) => session.id === sessionId);
if (!target) return [sessionId];
const root = sessionRevisionFamilyId(target);
return sessions
.filter((session) => sessionRevisionFamilyId(session) === root)
.map((session) => session.id);
}