710 lines · 25.4 KB · TypeScript
RawDownload
"use client"; import { scopedThreadKey } from "@t3tools/client-runtime/environment";import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime";import { type ScopedThreadRef } from "@t3tools/contracts";import { useCallback, useEffect, useRef, useState } from "react"; import { useComposerDraftStore } from "~/composerDraftStore";import { previewAnnotationScreenshotFile } from "~/lib/previewAnnotation";import { ensureLocalApi } from "~/localApi";import { rememberPreviewUrl, useThreadPreviewState } from "~/previewStateStore";import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver";import { useEnvironment, useEnvironmentHttpBaseUrl } from "~/state/environments";import { previewEnvironment } from "~/state/preview";import { useAtomCommand } from "~/state/use-atom-command"; import { previewBridge } from "./previewBridge";import { subscribePreviewAction } from "./previewActionBus";import { openPreviewSession } from "./openPreviewSession";import { PreviewChromeRow } from "./PreviewChromeRow";import { formatPreviewUrl } from "./previewUrlPresentation";import { PreviewEmptyState } from "./PreviewEmptyState";import { PreviewMoreMenu } from "./PreviewMoreMenu";import { previewUrlFailureContext, reportPreviewActionFailure } from "./reportPreviewActionFailure";import { PreviewUnreachable } from "./PreviewUnreachable";import { revealInFileExplorerLabel } from "./fileExplorerLabel";import { shouldShowPreviewEmptyState } from "./previewEmptyStateLogic";import { BrowserSurfaceSlot } from "~/browser/BrowserSurfaceSlot";import { useLoadingProgress } from "./useLoadingProgress";import { usePreviewSession } from "./usePreviewSession";import { ZoomIndicator } from "./ZoomIndicator";import { AgentBrowserCursor } from "./AgentBrowserCursor";import {  startBrowserRecording,  stopBrowserRecording,  useActiveBrowserRecordingTabId,} from "~/browser/browserRecording";import { stackedThreadToast, toastManager } from "~/components/ui/toast"; interface Props {  threadRef: ScopedThreadRef;  tabId?: string | null;  configuredUrls?: ReadonlyArray<string> | undefined;  visible: boolean;} const localApi = typeof window === "undefined" ? null : ensureLocalApi(); /** * Single-tab preview surface: chrome row on top, one webview below, empty * state when no session exists for the thread. */export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, visible }: Props) {  const [focusUrlNonce, setFocusUrlNonce] = useState<number | undefined>(undefined);  const [pickActive, setPickActive] = useState(false);  const activeRecordingTabId = useActiveBrowserRecordingTabId();  const pickActiveRef = useRef(false);  const isMountedRef = useRef(true);  const previewState = useThreadPreviewState(threadRef);  const addPreviewAnnotation = useComposerDraftStore((store) => store.addPreviewAnnotation);  const addImage = useComposerDraftStore((store) => store.addImage);  const environment = useEnvironment(threadRef.environmentId);  const environmentHttpBaseUrl = useEnvironmentHttpBaseUrl(threadRef.environmentId);  const open = useAtomCommand(previewEnvironment.open);   usePreviewSession(threadRef);   useEffect(() => {    isMountedRef.current = true;    return () => {      isMountedRef.current = false;    };  }, []);   const tabId = requestedTabId ?? previewState.activeTabId;  const snapshot = tabId ? (previewState.sessions[tabId] ?? null) : null;  const desktopOverlay = tabId ? (previewState.desktopByTabId[tabId] ?? null) : null;  const navStatus = snapshot?.navStatus ?? { _tag: "Idle" as const };  const url = navStatus._tag === "Idle" ? "" : navStatus.url;  const loading = desktopOverlay?.loading ?? navStatus._tag === "Loading";  const canGoBack = desktopOverlay?.canGoBack ?? snapshot?.canGoBack ?? false;  const canGoForward = desktopOverlay?.canGoForward ?? snapshot?.canGoForward ?? false;  const refreshDisabled = navStatus._tag === "Idle";  const isUnreachable = navStatus._tag === "LoadFailed";  const showEmptyState = shouldShowPreviewEmptyState(snapshot);  const controller = desktopOverlay?.controller ?? "none";  const loadProgress = useLoadingProgress(loading);  const displayUrl =    url && environment && environmentHttpBaseUrl      ? (formatPreviewUrl({          url,          environmentLabel: environment.label,          environmentHttpBaseUrl,        }) ?? undefined)      : undefined;  const threadKey = scopedThreadKey(threadRef);   const handleSubmitUrl = useCallback(    async (next: string) => {      let operation = "resolve-url";      let targetUrl = next;      try {        const resolvedUrl = resolveDiscoveredServerUrl(threadRef.environmentId, next);        targetUrl = resolvedUrl;        if (tabId && previewBridge) {          // Drive the webview imperatively; `usePreviewBridge` mirrors the          // resolved URL back to the server so other clients stay in sync.          operation = "navigate";          await previewBridge.navigate(tabId, resolvedUrl);          rememberPreviewUrl(threadRef, resolvedUrl);        } else {          operation = "open-session";          const result = await openPreviewSession({            openPreview: open,            threadRef,            url: resolvedUrl,          });          if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {            reportPreviewActionFailure(              {                operation,                threadKey,                ...(tabId ? { tabId } : {}),                ...previewUrlFailureContext(targetUrl),              },              result.cause,            );          }        }      } catch (cause) {        reportPreviewActionFailure(          {            operation,            threadKey,            ...(tabId ? { tabId } : {}),            ...previewUrlFailureContext(targetUrl),          },          cause,        );        // Server-side `failed` event renders the unreachable view.      }    },    [open, tabId, threadKey, threadRef],  );   const handleRefresh = useCallback(() => {    if (!previewBridge || !tabId) return;    void previewBridge.refresh(tabId).catch((cause) => {      reportPreviewActionFailure({ operation: "refresh", threadKey, tabId }, cause);    });  }, [tabId, threadKey]);   const handleZoomIn = useCallback(() => {    if (!previewBridge || !tabId) return;    void previewBridge.zoomIn(tabId).catch((cause) => {      reportPreviewActionFailure({ operation: "zoom-in", threadKey, tabId }, cause);    });  }, [tabId, threadKey]);   const handleZoomOut = useCallback(() => {    if (!previewBridge || !tabId) return;    void previewBridge.zoomOut(tabId).catch((cause) => {      reportPreviewActionFailure({ operation: "zoom-out", threadKey, tabId }, cause);    });  }, [tabId, threadKey]);   const handleResetZoom = useCallback(() => {    if (!previewBridge || !tabId) return;    void previewBridge.resetZoom(tabId).catch((cause) => {      reportPreviewActionFailure({ operation: "reset-zoom", threadKey, tabId }, cause);    });  }, [tabId, threadKey]);   const handleBack = useCallback(() => {    if (!previewBridge || !tabId) return;    void previewBridge.goBack(tabId).catch((cause) => {      reportPreviewActionFailure({ operation: "go-back", threadKey, tabId }, cause);    });  }, [tabId, threadKey]);   const handleForward = useCallback(() => {    if (!previewBridge || !tabId) return;    void previewBridge.goForward(tabId).catch((cause) => {      reportPreviewActionFailure({ operation: "go-forward", threadKey, tabId }, cause);    });  }, [tabId, threadKey]);   const handleOpenInBrowser = useCallback(() => {    if (!localApi || !url) return;    void localApi.shell.openExternal(url).catch((cause) => {      reportPreviewActionFailure(        {          operation: "open-external",          threadKey,          ...(tabId ? { tabId } : {}),          ...previewUrlFailureContext(url),        },        cause,      );    });  }, [tabId, threadKey, url]);   const handleCapture = useCallback(    (record: boolean) => {      if (!previewBridge || !tabId) return;      const bridge = previewBridge;      const recordingThisTab = activeRecordingTabId === tabId;      if (recordingThisTab) {        void stopBrowserRecording(tabId).then(          (artifact) => {            if (!artifact) return;            let pathCopied = false;            let toastId: ReturnType<typeof toastManager.add>;             const copyPath = () => {              if (!navigator.clipboard?.writeText) {                toastManager.update(                  toastId,                  stackedThreadToast({                    type: "error",                    title: "Unable to copy recording path",                    description: "Clipboard API unavailable.",                    actionProps: revealAction,                  }),                );                return;              }               void navigator.clipboard.writeText(artifact.path).then(                () => {                  pathCopied = true;                  updateRecordingToast();                  window.setTimeout(() => {                    pathCopied = false;                    updateRecordingToast();                  }, 2_000);                },                (error) => {                  reportPreviewActionFailure(                    {                      operation: "copy-recording-path",                      threadKey,                      tabId,                      artifactPath: artifact.path,                    },                    error,                  );                  toastManager.update(                    toastId,                    stackedThreadToast({                      type: "error",                      title: "Unable to copy recording path",                      description: error instanceof Error ? error.message : "An error occurred.",                      actionProps: revealAction,                    }),                  );                },              );            };             const revealAction = {              children: revealInFileExplorerLabel(navigator.platform),              onClick: () =>                void bridge.revealArtifact(artifact.path).catch((cause) => {                  reportPreviewActionFailure(                    {                      operation: "reveal-recording",                      threadKey,                      tabId,                      artifactPath: artifact.path,                    },                    cause,                  );                }),            };            const updateRecordingToast = () => {              toastManager.update(                toastId,                stackedThreadToast({                  type: "success",                  title: "Recording saved",                  actionProps: revealAction,                  data: {                    secondaryActionProps: {                      children: pathCopied ? "Copied!" : "Copy path",                      disabled: pathCopied,                      onClick: copyPath,                    },                    secondaryActionVariant: "outline",                  },                }),              );            };             toastId = toastManager.add(              stackedThreadToast({                type: "success",                title: "Recording saved",                actionProps: revealAction,                data: {                  secondaryActionProps: {                    children: "Copy path",                    onClick: copyPath,                  },                  secondaryActionVariant: "outline",                },              }),            );          },          (error) => {            reportPreviewActionFailure({ operation: "stop-recording", threadKey, tabId }, error);            toastManager.add({              type: "error",              title: "Unable to stop recording",              description: error instanceof Error ? error.message : "An error occurred.",            });          },        );        return;      }      if (record) {        if (activeRecordingTabId !== null) {          toastManager.add({            type: "warning",            title: "Another preview is recording",            description: "Stop the active recording before starting a new one.",          });          return;        }        void startBrowserRecording(tabId).catch((error) => {          reportPreviewActionFailure({ operation: "start-recording", threadKey, tabId }, error);          toastManager.add({            type: "error",            title: "Unable to start recording",            description: error instanceof Error ? error.message : "An error occurred.",          });        });        return;      }      void bridge.captureScreenshot(tabId).then(        (artifact) => {          const revealAction = {            children: revealInFileExplorerLabel(navigator.platform),            onClick: () =>              void bridge.revealArtifact(artifact.path).catch((cause) => {                reportPreviewActionFailure(                  {                    operation: "reveal-screenshot",                    threadKey,                    tabId,                    artifactPath: artifact.path,                  },                  cause,                );              }),          };          let pathCopied = false;          let imageCopied = false;          let toastId: ReturnType<typeof toastManager.add>;           const updateScreenshotToast = (            type: "success" | "error" = "success",            title = "Screenshot saved",            description?: string,          ) => {            toastManager.update(              toastId,              stackedThreadToast({                type,                title,                description,                actionProps: {                  children: imageCopied ? "Copied!" : "Copy image",                  disabled: imageCopied,                  onClick: copyImage,                },                data: {                  additionalActions: [                    {                      id: "copy-path",                      props: {                        children: pathCopied ? "Copied!" : "Copy path",                        disabled: pathCopied,                        onClick: copyPath,                      },                    },                  ],                  secondaryActionProps: {                    ...revealAction,                  },                  secondaryActionVariant: "outline",                },              }),            );          };           const copyPath = () => {            if (!navigator.clipboard?.writeText) {              updateScreenshotToast(                "error",                "Unable to copy screenshot path",                "Clipboard API unavailable.",              );              return;            }             void navigator.clipboard.writeText(artifact.path).then(              () => {                pathCopied = true;                updateScreenshotToast();                window.setTimeout(() => {                  pathCopied = false;                  updateScreenshotToast();                }, 2_000);              },              (error) => {                reportPreviewActionFailure(                  {                    operation: "copy-screenshot-path",                    threadKey,                    tabId,                    artifactPath: artifact.path,                  },                  error,                );                updateScreenshotToast(                  "error",                  "Unable to copy screenshot path",                  error instanceof Error ? error.message : "An error occurred.",                );              },            );          };           const copyImage = () => {            void bridge.copyArtifactToClipboard(artifact.path).then(              () => {                imageCopied = true;                updateScreenshotToast();                window.setTimeout(() => {                  imageCopied = false;                  updateScreenshotToast();                }, 2_000);              },              (error) => {                reportPreviewActionFailure(                  {                    operation: "copy-screenshot-image",                    threadKey,                    tabId,                    artifactPath: artifact.path,                  },                  error,                );                updateScreenshotToast(                  "error",                  "Unable to copy screenshot",                  error instanceof Error ? error.message : "An error occurred.",                );              },            );          };           toastId = toastManager.add(            stackedThreadToast({              type: "success",              title: "Screenshot saved",              actionProps: {                children: "Copy image",                onClick: copyImage,              },              data: {                additionalActions: [                  {                    id: "copy-path",                    props: {                      children: "Copy path",                      onClick: copyPath,                    },                  },                ],                secondaryActionProps: {                  ...revealAction,                },                secondaryActionVariant: "outline",              },            }),          );        },        (error) => {          reportPreviewActionFailure({ operation: "capture-screenshot", threadKey, tabId }, error);          toastManager.add({            type: "error",            title: "Unable to capture screenshot",            description: error instanceof Error ? error.message : "An error occurred.",          });        },      );    },    [activeRecordingTabId, tabId, threadKey],

Lines 1–500 of 710.