import type { Session, SessionEvent } from "@opengeni/sdk"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { readFreshSessionDetail } from "../lib/session-detail-read"; import { useEmbeddedSessionRead, type EmbeddedSessionReadClientOverride } from "../session-context"; import { deniesRetainedView, useRetainedSessionView } from "./session-view-cache"; import { useMutationRunner, usePolledValue, useSessionEventTrigger, type SessionEventFeedOptions, } from "./internal"; export type UseSessionOptions = EmbeddedSessionReadClientOverride & SessionEventFeedOptions & { /** Re-fetch on an interval (ms). Off by default — pair with `useSessionEvents` for live status. */ pollIntervalMs?: number | undefined; /** Optional shared causal clock invoked when each network read starts. */ beginRead?: (() => number) | undefined; }; export type UseSessionResult = { session: Session | null; loading: boolean; error: Error | null; /** Monotonic revision of accepted authoritative detail reads. */ readRevision: number; /** Causal generation captured when the accepted network read started. */ readGeneration: number; refresh: () => Promise; /** Manually rename the session (PATCH, source='user'). Returns the updated session, or null on failure. */ updateTitle: (title: string) => Promise; /** True while a rename is in flight. */ updating: boolean; mutationError: Error | null; clearMutationError: () => void; }; /** Event types that change the session title (auto + cross-client renames). */ export function isTitleEvent(event: Pick): boolean { return event.type === "session.title_set"; } function isSessionDetailEvent(event: Pick): boolean { return ( isTitleEvent(event) || event.type.startsWith("session.command.") || event.type.startsWith("session.wait.") || event.type === "session.status.changed" || event.type === "system.update.pending" ); } /** Fetch one session (with optional polling), live-patching its title on `session.title_set`. */ export function useSession( sessionId: string | null | undefined, options: UseSessionOptions = {}, ): UseSessionResult { const { client, workspaceId, workspaceControlEvent, registerSessionReconciler } = useEmbeddedSessionRead(options); const enabled = (options.enabled ?? true) && Boolean(sessionId); const [override, setOverride] = useState(null); const nextReadRevision = useRef(0); const nextReadGeneration = useRef(0); const beginRead = options.beginRead; const { run, mutating, mutationError, clearMutationError } = useMutationRunner(); const load = useCallback( async (signal?: AbortSignal) => { if (!sessionId) { return null; } let readGeneration = 0; // The live stream's head read joins this read (see session-detail-read). const fetched = await readFreshSessionDetail(client, workspaceId, sessionId, { signal, onRequestStart: () => { readGeneration = beginRead?.() ?? ++nextReadGeneration.current; }, }); // A fresh server read supersedes any optimistic/event-driven override. setOverride(null); return { session: fetched, revision: ++nextReadRevision.current, readGeneration, }; }, [beginRead, client, workspaceId, sessionId], ); const { data, loading, error, refresh } = usePolledValue(load, { pollIntervalMs: options.pollIntervalMs, enabled, }); useEffect(() => { if (enabled && workspaceControlEvent) void refresh(); }, [enabled, refresh, workspaceControlEvent]); useEffect(() => { if (!sessionId || !enabled) return; return registerSessionReconciler(sessionId, "session", refresh); }, [enabled, refresh, registerSessionReconciler, sessionId]); // Until this conversation's first read lands, show what was left when the // person last had it open (only when the host retains reads for `client`). const retained = useRetainedSessionView( client, workspaceId, sessionId, "detail", data?.session ?? null, { enabled, discard: deniesRetainedView(error) }, ); const base = data?.session ?? null; const readRevision = data?.revision ?? 0; const readGeneration = data?.readGeneration ?? 0; // The override only ever carries title/titleSource patches; it is reset on // every fresh load so it can never go stale against the server snapshot. const session = useMemo( () => base && override && override.id === base.id ? { ...base, title: override.title, titleSource: override.titleSource } : (base ?? retained), [base, override, retained], ); const sharedEvents = options.events; const refreshDetail = useCallback(() => void refresh(), [refresh]); // Live-patch the title on auto (agent) + cross-client (user/agent) renames so // the UI reflects the new title without polling or a full re-fetch. const onTitleEvent = useCallback( (event: SessionEvent) => { // Reconciliation emits only the last matching member of a shared batch. // A later title must not hide an earlier command activity change. if ( sharedEvents?.some( (item) => item.sessionId === sessionId && !isTitleEvent(item) && isSessionDetailEvent(item) && item.sequence > (base?.lastSequence ?? -1), ) ) { void refresh(); } else if (!isTitleEvent(event)) { if (!base || event.sequence > base.lastSequence) void refresh(); return; } // The fetched session row is the authoritative title projection through // lastSequence. Shared feeds may replay that historical tail after the // fetch; applying it would undo a row-only migration quarantine. Only an // event committed after this snapshot may live-patch the title. if (!base || event.sequence <= base.lastSequence) { return; } const payload = (event.payload ?? {}) as { title?: unknown; source?: unknown }; const title = payload.title; if (typeof title !== "string") { return; } const source: "user" | "agent" | null = payload.source === "user" || payload.source === "agent" ? payload.source : null; setOverride((current): Session | null => { const next = current ?? base; return { ...next, title, titleSource: source }; }); }, [base, refresh, sharedEvents, sessionId], ); useSessionEventTrigger(client, workspaceId, sessionId, isSessionDetailEvent, onTitleEvent, { enabled, ...(options.events !== undefined ? { events: options.events } : {}), // The detail row is authoritative through its lastSequence: only a later // event can make it stale, and one that arrives first waits for the read. readThrough: base ? base.lastSequence : loading ? "reading" : null, refresh: refreshDetail, }); const updateTitle = useCallback( async (title: string): Promise => { if (!sessionId) { return null; } const result = await run(() => client.updateSession(workspaceId, sessionId, { title })); if (result) { setOverride(result); } return result; }, [client, workspaceId, sessionId, run], ); return { session, loading: loading && retained === null, error, readRevision, readGeneration, refresh, updateTitle, updating: mutating, mutationError, clearMutationError, }; }