import { useSyncExternalStore } from 'react'; import type { CodingCliProviderId } from '../../domain/codingCliProviders'; export type SessionPresentation = { dynamicTitle?: string | null; codingCliProviderId?: CodingCliProviderId | null; }; type Listener = () => void; /** Encode undefined / null / present distinctly for useSyncExternalStore snapshots. */ function encodePresentationField(value: string | null | undefined): string { if (value === undefined) return 'u'; if (value === null) return 'n'; return `v:${value}`; } /** * Presentation-only session chrome (tab title / coding-CLI icon) separate from * structural session identity used by TerminalLayer pane equality. */ class SessionPresentationStore { private bySession = new Map(); private version = 0; private listeners = new Set(); getVersion = (): number => this.version; getPresentation = (sessionId: string): SessionPresentation | undefined => this.bySession.get(sessionId); /** * Stable per-session snapshot for useSyncExternalStore. Global listeners still * fire on any change, but React skips re-render when this string is unchanged * for the subscribed sessionId (Object.is). * * Encodes undefined / null / value distinctly so a null tombstone is not * Object.is-equal to a missing field (both used to collapse to ''). */ getSessionSnapshot = (sessionId: string): string => { const presentation = this.bySession.get(sessionId); if (!presentation) return ''; return `${encodePresentationField(presentation.dynamicTitle)}\0${encodePresentationField(presentation.codingCliProviderId)}`; }; subscribe = (listener: Listener): (() => void) => { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; }; setPresentation(sessionId: string, patch: SessionPresentation): void { const prev = this.bySession.get(sessionId) ?? {}; const next: SessionPresentation = { ...prev, ...patch }; // Distinguish missing (undefined) from explicit clear (null) so the first // tombstone is stored even when the session snapshot still has a stale // title/provider and the store had no prior entry. if ( prev.dynamicTitle === next.dynamicTitle && prev.codingCliProviderId === next.codingCliProviderId ) { return; } this.bySession.set(sessionId, next); this.version += 1; for (const listener of this.listeners) listener(); } clearSession(sessionId: string): void { if (!this.bySession.has(sessionId)) return; this.bySession.delete(sessionId); this.version += 1; for (const listener of this.listeners) listener(); } prune(validSessionIds: ReadonlySet): void { let changed = false; for (const id of this.bySession.keys()) { if (!validSessionIds.has(id)) { this.bySession.delete(id); changed = true; } } if (!changed) return; this.version += 1; for (const listener of this.listeners) listener(); } } export const sessionPresentationStore = new SessionPresentationStore(); export function publishSessionDynamicTitle(sessionId: string, title: string | null): void { sessionPresentationStore.setPresentation(sessionId, { dynamicTitle: title }); } export function publishSessionCodingCliProvider( sessionId: string, providerId: CodingCliProviderId | null, ): void { sessionPresentationStore.setPresentation(sessionId, { codingCliProviderId: providerId }); } type SessionWithPresentation = { id: string; dynamicTitle?: string; codingCliProviderId?: CodingCliProviderId; }; /** * Overlay live presentation chrome onto a session snapshot. * Used by TopTabs, focus sidebar, and pane chrome so title/provider updates * stay live without structural setSessions thrash. */ export function applySessionPresentation(session: T): T { const presentation = sessionPresentationStore.getPresentation(session.id); if (!presentation) return session; const nextTitle = presentation.dynamicTitle === undefined ? session.dynamicTitle : (presentation.dynamicTitle ?? undefined); const nextProvider = presentation.codingCliProviderId === undefined ? session.codingCliProviderId : (presentation.codingCliProviderId ?? undefined); if ( nextTitle === session.dynamicTitle && nextProvider === session.codingCliProviderId ) { return session; } return { ...session, dynamicTitle: nextTitle, codingCliProviderId: nextProvider, }; } /** Subscribe to live title/provider chrome version for multi-session consumers. */ export function useSessionPresentationVersion(): number { return useSyncExternalStore( sessionPresentationStore.subscribe, sessionPresentationStore.getVersion, sessionPresentationStore.getVersion, ); } /** * Per-session presentation snapshot. Other sessions' title updates notify the * store but do not re-render this consumer when its own snapshot is unchanged. */ export function useSessionPresentationSnapshot(sessionId: string): string { return useSyncExternalStore( sessionPresentationStore.subscribe, () => sessionPresentationStore.getSessionSnapshot(sessionId), () => sessionPresentationStore.getSessionSnapshot(sessionId), ); } /** Session snapshot with live presentation overlay applied (single session). */ export function usePresentedSession(session: T): T { useSessionPresentationSnapshot(session.id); return applySessionPresentation(session); }