Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
132 lines
4.6 KiB
TypeScript
132 lines
4.6 KiB
TypeScript
/**
|
|
* Exact-scope AI session helpers for multi-panel memo isolation.
|
|
*
|
|
* History still needs the full sessions list for fuzzy host-match ranking
|
|
* (`getScopedHistorySessions`). Stream thrash is blocked by comparing only
|
|
* exact-scope session object refs in panel are equal — not by pre-filtering
|
|
* the history universe away.
|
|
*/
|
|
|
|
export type AISessionScopeLike = {
|
|
type: string;
|
|
targetId?: string;
|
|
};
|
|
|
|
export type AISessionLike = {
|
|
id: string;
|
|
scope: AISessionScopeLike;
|
|
/** Optional chrome for history list equality (title renames without message thrash). */
|
|
title?: string | null;
|
|
/** Optional chrome for history sort / relative-time display (`getScopedHistorySessions`). */
|
|
updatedAt?: number;
|
|
};
|
|
|
|
export function buildAIScopeKey(scopeType: string, scopeTargetId?: string): string {
|
|
return `${scopeType}:${scopeTargetId ?? ''}`;
|
|
}
|
|
|
|
export function sessionMatchesAIScope(
|
|
session: AISessionLike,
|
|
scopeType: string,
|
|
scopeTargetId?: string,
|
|
): boolean {
|
|
return session.scope.type === scopeType
|
|
&& (session.scope.targetId ?? '') === (scopeTargetId ?? '');
|
|
}
|
|
|
|
export function filterAISessionsForScope<T extends AISessionLike>(
|
|
sessions: readonly T[],
|
|
scopeType: string,
|
|
scopeTargetId?: string,
|
|
): T[] {
|
|
return sessions.filter((session) => sessionMatchesAIScope(session, scopeType, scopeTargetId));
|
|
}
|
|
|
|
/**
|
|
* True when the given session id's object identity is unchanged across arrays
|
|
* (or both sides lack that session).
|
|
*/
|
|
export function aiSessionByIdEqual<T extends AISessionLike>(
|
|
prev: readonly T[] | null | undefined,
|
|
next: readonly T[] | null | undefined,
|
|
sessionId: string | null | undefined,
|
|
): boolean {
|
|
if (!sessionId) return true;
|
|
if (prev === next) return true;
|
|
if (!prev || !next) return false;
|
|
const prevSession = prev.find((session) => session.id === sessionId);
|
|
const nextSession = next.find((session) => session.id === sessionId);
|
|
return prevSession === nextSession;
|
|
}
|
|
|
|
/**
|
|
* True when both arrays contain the same session ids (order-insensitive) and
|
|
* matching history chrome (title + updatedAt). Detects create/delete/rename and
|
|
* timestamp/order changes without treating message-body object replacement alone
|
|
* as a reason to re-render when chrome is unchanged.
|
|
*/
|
|
export function aiSessionIdSetEqual<T extends AISessionLike>(
|
|
prev: readonly T[] | null | undefined,
|
|
next: readonly T[] | null | undefined,
|
|
): boolean {
|
|
if (prev === next) return true;
|
|
if (!prev || !next) return false;
|
|
if (prev.length !== next.length) return false;
|
|
if (prev.length === 0) return true;
|
|
const prevById = new Map(prev.map((session) => [session.id, session]));
|
|
for (const session of next) {
|
|
const prevSession = prevById.get(session.id);
|
|
if (!prevSession) return false;
|
|
if ((prevSession.title ?? '') !== (session.title ?? '')) return false;
|
|
if ((prevSession.updatedAt ?? 0) !== (session.updatedAt ?? 0)) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* True when exact-scope session object identities match (order-insensitive by id).
|
|
* Sibling stream updates replace only their own session objects, so other panels
|
|
* see the same exact-scope refs and can skip re-render.
|
|
*
|
|
* When `selectedSessionId` is set (e.g. a history chat resumed under a newer
|
|
* terminal whose stored scope still points at an older target), that session is
|
|
* also compared by identity so stream updates still re-render the visible panel.
|
|
*/
|
|
export function exactScopeAISessionsEqual<T extends AISessionLike>(
|
|
prev: readonly T[] | null | undefined,
|
|
next: readonly T[] | null | undefined,
|
|
scopeType: string,
|
|
scopeTargetId?: string,
|
|
selectedSessionId?: string | null,
|
|
): boolean {
|
|
if (prev === next) return true;
|
|
if (!prev || !next) return false;
|
|
if (!aiSessionByIdEqual(prev, next, selectedSessionId)) return false;
|
|
const prevExact = filterAISessionsForScope(prev, scopeType, scopeTargetId);
|
|
const nextExact = filterAISessionsForScope(next, scopeType, scopeTargetId);
|
|
if (prevExact.length !== nextExact.length) return false;
|
|
if (prevExact.length === 0) return true;
|
|
const prevById = new Map(prevExact.map((session) => [session.id, session]));
|
|
for (const session of nextExact) {
|
|
if (prevById.get(session.id) !== session) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Keep previous filtered array identity when every matching session ref is the same.
|
|
*/
|
|
export function retainStableAISessionsForScope<T extends AISessionLike>(
|
|
previous: readonly T[] | null | undefined,
|
|
next: readonly T[],
|
|
): readonly T[] {
|
|
if (
|
|
previous
|
|
&& previous.length === next.length
|
|
&& previous.every((session, index) => session === next[index])
|
|
) {
|
|
return previous;
|
|
}
|
|
return next;
|
|
}
|