164 lines
5.5 KiB
TypeScript
164 lines
5.5 KiB
TypeScript
|
|
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<string, SessionPresentation>();
|
||
|
|
private version = 0;
|
||
|
|
private listeners = new Set<Listener>();
|
||
|
|
|
||
|
|
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<string>): 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<T extends SessionWithPresentation>(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<T extends SessionWithPresentation>(session: T): T {
|
||
|
|
useSessionPresentationSnapshot(session.id);
|
||
|
|
return applySessionPresentation(session);
|
||
|
|
}
|