296 lines · 10.5 KB · TypeScript
RawDownload
/** * Per-thread preview UI state. * * Single-tab model: one snapshot per thread, mirrored two ways: *   - `snapshot` is the server-authoritative URL/title/load-status, replayed *     on WS reconnect so the panel survives backend restarts. *   - `desktopOverlay` is low-latency state from the local <webview> *     (canGoBack/canGoForward/visible/zoom/loading), used by the chrome row's *     button enablement. * * The schema-level `tabId` exists because the server still keys sessions by * `(threadId, tabId)`; the client just always tracks one and ignores the rest. */import { scopedThreadKey } from "@t3tools/client-runtime";import {  type PreviewEvent,  type PreviewSessionSnapshot,  type ScopedThreadRef,} from "@t3tools/contracts";import { create } from "zustand"; import { PREVIEW_RECENT_URL_LIMIT } from "./components/preview/previewConstants"; export interface DesktopPreviewOverlay {  canGoBack: boolean;  canGoForward: boolean;  loading: boolean;  zoomFactor: number;  controller: "human" | "agent" | "none";} export interface ThreadPreviewState {  snapshot: PreviewSessionSnapshot | null;  sessions: Record<string, PreviewSessionSnapshot>;  activeTabId: string | null;  /** Bridge state takes precedence over `snapshot` for nav button enablement. */  desktopOverlay: DesktopPreviewOverlay | null;  desktopByTabId: Record<string, DesktopPreviewOverlay>;  /** Recently-visited URLs surfaced in the empty state. */  recentlySeenUrls: string[];} const EMPTY_THREAD_PREVIEW_STATE: ThreadPreviewState = Object.freeze({  snapshot: null,  sessions: {},  activeTabId: null,  desktopOverlay: null,  desktopByTabId: {},  recentlySeenUrls: [] as string[],}); const revisionByThreadKey = new Map<string, number>(); const bumpPreviewStateRevision = (threadKey: string): void => {  revisionByThreadKey.set(threadKey, (revisionByThreadKey.get(threadKey) ?? 0) + 1);}; export function readPreviewStateRevision(ref: ScopedThreadRef): number {  return revisionByThreadKey.get(scopedThreadKey(ref)) ?? 0;} export interface PreviewStateStoreState {  byThreadKey: Record<string, ThreadPreviewState>;  applyServerEvent: (ref: ScopedThreadRef, event: PreviewEvent) => void;  applyServerSnapshot: (ref: ScopedThreadRef, snapshot: PreviewSessionSnapshot | null) => void;  applyDesktopState: (    ref: ScopedThreadRef,    tabId: string,    overlay: DesktopPreviewOverlay | null,  ) => void;  removeSession: (ref: ScopedThreadRef, tabId: string) => void;  setActiveTab: (ref: ScopedThreadRef, tabId: string) => void;  rememberUrl: (ref: ScopedThreadRef, url: string) => void;  removeThread: (ref: ScopedThreadRef) => void;} const ensureState = (  byThreadKey: Record<string, ThreadPreviewState>,  threadKey: string,): ThreadPreviewState => byThreadKey[threadKey] ?? EMPTY_THREAD_PREVIEW_STATE; const updateThread = (  state: PreviewStateStoreState,  threadKey: string,  updater: (current: ThreadPreviewState) => ThreadPreviewState,): PreviewStateStoreState["byThreadKey"] => {  const current = ensureState(state.byThreadKey, threadKey);  const next = updater(current);  if (next === current) return state.byThreadKey;  return { ...state.byThreadKey, [threadKey]: next };}; const removeThreadKey = (  byThreadKey: Record<string, ThreadPreviewState>,  threadKey: string,): Record<string, ThreadPreviewState> => {  if (!(threadKey in byThreadKey)) return byThreadKey;  const { [threadKey]: _removed, ...rest } = byThreadKey;  return rest;}; const dedupeRecentUrls = (existing: string[], url: string): string[] => {  const next = [url, ...existing.filter((entry) => entry !== url)];  return next.slice(0, PREVIEW_RECENT_URL_LIMIT);}; const removeSession = (current: ThreadPreviewState, tabId: string): ThreadPreviewState => {  if (!current.sessions[tabId]) return current;  const { [tabId]: _closed, ...sessions } = current.sessions;  const { [tabId]: _desktop, ...desktopByTabId } = current.desktopByTabId;  const nextSnapshot =    Object.values(sessions)      .toSorted((a, b) => a.updatedAt.localeCompare(b.updatedAt))      .at(-1) ?? null;  const activeTabId =    current.activeTabId === tabId ? (nextSnapshot?.tabId ?? null) : current.activeTabId;  const snapshot = activeTabId ? (sessions[activeTabId] ?? nextSnapshot) : nextSnapshot;  return {    ...current,    sessions,    desktopByTabId,    activeTabId: snapshot?.tabId ?? null,    snapshot,    desktopOverlay: snapshot ? (desktopByTabId[snapshot.tabId] ?? null) : null,  };}; export const usePreviewStateStore = create<PreviewStateStoreState>()((set) => ({  byThreadKey: {},  applyServerEvent: (ref, event) =>    set((state) => {      const threadKey = scopedThreadKey(ref);      bumpPreviewStateRevision(threadKey);      let nextByThread = state.byThreadKey;      switch (event.type) {        case "opened":        case "navigated":          nextByThread = updateThread(state, threadKey, (current) => {            const snapshot = event.snapshot;            const recentlySeenUrls =              snapshot.navStatus._tag === "Idle"                ? current.recentlySeenUrls                : dedupeRecentUrls(current.recentlySeenUrls, snapshot.navStatus.url);            const sessions = { ...current.sessions, [snapshot.tabId]: snapshot };            const activeTabId = event.type === "opened" ? snapshot.tabId : current.activeTabId;            const activeSnapshot = sessions[activeTabId ?? snapshot.tabId] ?? snapshot;            return {              ...current,              sessions,              activeTabId: activeTabId ?? snapshot.tabId,              snapshot: activeSnapshot,              desktopOverlay: current.desktopByTabId[activeSnapshot.tabId] ?? null,              recentlySeenUrls,            };          });          break;        case "failed":          nextByThread = updateThread(state, threadKey, (current) => {            const existing = current.sessions[event.tabId];            if (!existing) return current;            const failedSnapshot = {              ...existing,              navStatus: {                _tag: "LoadFailed" as const,                url: event.url,                title: event.title,                code: event.code,                description: event.description,              },              updatedAt: event.createdAt,            };            const sessions = { ...current.sessions, [event.tabId]: failedSnapshot };            return {              ...current,              sessions,              snapshot: current.activeTabId === event.tabId ? failedSnapshot : current.snapshot,            };          });          break;        case "closed":          nextByThread = updateThread(state, threadKey, (current) =>            removeSession(current, event.tabId),          );          break;      }      return { byThreadKey: nextByThread };    }),  applyServerSnapshot: (ref, snapshot) =>    set((state) => {      const threadKey = scopedThreadKey(ref);      bumpPreviewStateRevision(threadKey);      const nextByThread = updateThread(state, threadKey, (current) => {        if (!snapshot && current.snapshot === null) return current;        if (!snapshot) {          return {            ...current,            snapshot: null,            sessions: {},            activeTabId: null,            desktopOverlay: null,            desktopByTabId: {},          };        }        const existing = current.sessions[snapshot.tabId];        if (existing && existing.updatedAt > snapshot.updatedAt) {          return current;        }        const recentlySeenUrls =          snapshot && snapshot.navStatus._tag !== "Idle"            ? dedupeRecentUrls(current.recentlySeenUrls, snapshot.navStatus.url)            : current.recentlySeenUrls;        return {          ...current,          snapshot,          sessions: { ...current.sessions, [snapshot.tabId]: snapshot },          activeTabId: snapshot.tabId,          desktopOverlay: current.desktopByTabId[snapshot.tabId] ?? null,          recentlySeenUrls,        };      });      return { byThreadKey: nextByThread };    }),  applyDesktopState: (ref, tabId, overlay) =>    set((state) => {      const threadKey = scopedThreadKey(ref);      const nextByThread = updateThread(state, threadKey, (current) => {        const desktopByTabId = { ...current.desktopByTabId };        if (overlay) desktopByTabId[tabId] = overlay;        else delete desktopByTabId[tabId];        return {          ...current,          desktopByTabId,          desktopOverlay: current.activeTabId === tabId ? overlay : current.desktopOverlay,        };      });      return { byThreadKey: nextByThread };    }),  removeSession: (ref, tabId) =>    set((state) => {      const threadKey = scopedThreadKey(ref);      bumpPreviewStateRevision(threadKey);      return {        byThreadKey: updateThread(state, threadKey, (current) => removeSession(current, tabId)),      };    }),  setActiveTab: (ref, tabId) =>    set((state) => {      const threadKey = scopedThreadKey(ref);      const nextByThread = updateThread(state, threadKey, (current) => {        const snapshot = current.sessions[tabId];        if (!snapshot || current.activeTabId === tabId) return current;        return {          ...current,          activeTabId: tabId,          snapshot,          desktopOverlay: current.desktopByTabId[tabId] ?? null,        };      });      return { byThreadKey: nextByThread };    }),  rememberUrl: (ref, url) =>    set((state) => {      if (url.trim().length === 0) return state;      const threadKey = scopedThreadKey(ref);      const nextByThread = updateThread(state, threadKey, (current) => ({        ...current,        recentlySeenUrls: dedupeRecentUrls(current.recentlySeenUrls, url),      }));      return { byThreadKey: nextByThread };    }),  removeThread: (ref) =>    set((state) => {      const threadKey = scopedThreadKey(ref);      bumpPreviewStateRevision(threadKey);      if (!(threadKey in state.byThreadKey)) return state;      return { byThreadKey: removeThreadKey(state.byThreadKey, threadKey) };    }),})); export function selectThreadPreviewState(  byThreadKey: Record<string, ThreadPreviewState>,  ref: ScopedThreadRef | null | undefined,): ThreadPreviewState {  if (!ref) return EMPTY_THREAD_PREVIEW_STATE;  return ensureState(byThreadKey, scopedThreadKey(ref));} export function isPreviewSupportedInRuntime(): boolean {  if (typeof window === "undefined") return false;  return Boolean(window.desktopBridge?.preview);} export const __testing = {  EMPTY_THREAD_PREVIEW_STATE,  RECENT_URL_LIMIT: PREVIEW_RECENT_URL_LIMIT,};