666 lines · 23.4 KB · TypeScript
RawDownload
"use client"; import { scopedThreadKey } from "@t3tools/client-runtime/environment";import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";import {  FILL_PREVIEW_VIEWPORT,  type PreviewViewportSetting,  type ScopedThreadRef,} from "@t3tools/contracts";import { normalizePreviewUrl } from "@t3tools/shared/preview";import { useCallback, useEffect, useRef, useState } from "react"; import { useComposerDraftStore } from "~/composerDraftStore";import { previewAnnotationScreenshotFile } from "~/lib/previewAnnotation";import { ensureLocalApi } from "~/localApi";import {  rememberPreviewUrl,  updatePreviewServerSnapshot,  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 {  commitBrowserViewportChange,  subscribeBrowserViewportChange,} from "~/browser/browserViewportActions";import { resolveResponsiveBrowserViewportSize } from "~/browser/browserViewportLayout";import { PreviewUnreachable } from "./PreviewUnreachable";import { revealInFileExplorerLabel } from "./fileExplorerLabel";import { shouldShowPreviewEmptyState } from "./previewEmptyStateLogic";import { BrowserSurfaceSlot } from "~/browser/BrowserSurfaceSlot";import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore";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);  const resize = useAtomCommand(previewEnvironment.resize, "preview viewport resize");   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 viewport = snapshot?.viewport ?? FILL_PREVIEW_VIEWPORT;  const panelRect = useBrowserSurfaceStore((state) =>    tabId ? (state.byTabId[tabId]?.rect ?? null) : null,  );   const navigateToResolvedUrl = useCallback(    async (resolvedUrl: string) => {      if (tabId && previewBridge) {        // Drive the webview imperatively; `usePreviewBridge` mirrors the        // resolved URL back to the server so other clients stay in sync.        await previewBridge.navigate(tabId, resolvedUrl);        rememberPreviewUrl(threadRef, resolvedUrl);      } else {        await openPreviewSession({          openPreview: open,          threadRef,          url: resolvedUrl,        });      }    },    [open, tabId, threadRef],  );   const handleSubmitUrl = useCallback(    async (next: string) => {      try {        await navigateToResolvedUrl(normalizePreviewUrl(next));      } catch {        // Server-side `failed` event renders the unreachable view.      }    },    [navigateToResolvedUrl],  );   const handleOpenServerUrl = useCallback(    async (next: string) => {      try {        await navigateToResolvedUrl(resolveDiscoveredServerUrl(threadRef.environmentId, next));      } catch {        // Server-side `failed` event renders the unreachable view.      }    },    [navigateToResolvedUrl, threadRef.environmentId],  );   const handleRefresh = useCallback(() => {    if (previewBridge && tabId) void previewBridge.refresh(tabId);  }, [tabId]);   const handleZoomIn = useCallback(() => {    if (previewBridge && tabId) void previewBridge.zoomIn(tabId);  }, [tabId]);   const handleZoomOut = useCallback(() => {    if (previewBridge && tabId) void previewBridge.zoomOut(tabId);  }, [tabId]);   const handleResetZoom = useCallback(() => {    if (previewBridge && tabId) void previewBridge.resetZoom(tabId);  }, [tabId]);   const handleViewportChange = useCallback(    async (nextViewport: PreviewViewportSetting) => {      if (!tabId) return;      const result = await resize({        environmentId: threadRef.environmentId,        input: {          threadId: threadRef.threadId,          tabId,          viewport: nextViewport,        },      });      if (result._tag === "Failure") {        const error = squashAtomCommandFailure(result);        toastManager.add({          type: "error",          title: "Unable to resize browser viewport",          description: error instanceof Error ? error.message : "An error occurred.",        });        throw error;      }      updatePreviewServerSnapshot(threadRef, result.value);    },    [resize, tabId, threadRef],  );   const handleToggleDeviceToolbar = () => {    if (!tabId) return;    if (viewport._tag !== "fill") {      void commitBrowserViewportChange(tabId, FILL_PREVIEW_VIEWPORT).catch(() => undefined);      return;    }     const responsiveSize = panelRect      ? resolveResponsiveBrowserViewportSize(panelRect, desktopOverlay?.zoomFactor)      : { width: 1024, height: 768 };    void commitBrowserViewportChange(tabId, { _tag: "freeform", ...responsiveSize }).catch(      () => undefined,    );  };   useEffect(() => {    if (!tabId) return;    return subscribeBrowserViewportChange(tabId, handleViewportChange);  }, [handleViewportChange, tabId]);   const handleBack = useCallback(() => {    if (previewBridge && tabId) void previewBridge.goBack(tabId);  }, [tabId]);   const handleForward = useCallback(() => {    if (previewBridge && tabId) void previewBridge.goForward(tabId);  }, [tabId]);   const handleOpenInBrowser = useCallback(() => {    if (!localApi || !url) return;    void localApi.shell.openExternal(url).catch(() => undefined);  }, [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) => {                  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),            };            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) => {            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) => {          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),          };          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) => {                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) => {                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) => {          toastManager.add({            type: "error",            title: "Unable to capture screenshot",            description: error instanceof Error ? error.message : "An error occurred.",          });        },      );    },    [activeRecordingTabId, tabId],  );   const handlePickElement = useCallback(() => {    if (!previewBridge || !tabId) return;    if (pickActiveRef.current) {      void previewBridge.cancelPickElement(tabId).catch(() => undefined);      return;    }    // Snapshot whatever the user was focused on (typically the chat    // composer textarea or the chrome-row pick button) BEFORE main steals    // focus into the guest webContents. We restore it when the pick    // resolves so the user's typing context isn't lost — otherwise after    // every pick they'd have to click back into the textarea.    const previouslyFocused =      typeof document !== "undefined" ? (document.activeElement as HTMLElement | null) : null;    pickActiveRef.current = true;    setPickActive(true);    void (async () => {      try {        const annotation = await previewBridge.pickElement(tabId);        if (!annotation) return;        addPreviewAnnotation(threadRef, annotation);        const screenshotFile = await previewAnnotationScreenshotFile(annotation);        if (screenshotFile && annotation.screenshot) {          addImage(threadRef, {

Lines 1–500 of 666.