822 lines · 31.0 KB · TypeScript
RawDownload
"use client"; import { scopedThreadKey } from "@t3tools/client-runtime/environment";import {  isAtomCommandInterrupted,  squashAtomCommandFailure,} from "@t3tools/client-runtime/state/runtime";import {  DEFAULT_BROWSER_PROFILE_ID,  FILL_PREVIEW_VIEWPORT,  type PreviewAnnotationPayload,  type PreviewViewportSetting,  type ScopedThreadRef,} from "@t3tools/contracts";import { normalizePreviewUrl } from "@t3tools/shared/preview";import { useCallback, useEffect, useRef, useState } from "react"; import {  BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT,  recordVisitForThread,  removeUrlForThread,  setTitleForThreadUrl,  useThreadRecentHistory,} from "~/browserHistoryStore";import { type ComposerImageAttachment, useComposerDraftStore } from "~/composerDraftStore";import { capturePreviewAnnotationScreenshot } from "~/lib/previewAnnotation";import { ensureLocalApi } from "~/localApi";import {  rememberPreviewUrl,  updatePreviewServerSnapshot,  useThreadPreviewState,} from "~/previewStateStore";import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver";import { useEnvironmentHttpBaseUrl } from "~/state/environments";import { previewEnvironment } from "~/state/preview";import { useAtomCommand } from "~/state/use-atom-command";import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore";import { useRightPanelStore } from "~/rightPanelStore"; import { previewBridge } from "./previewBridge";import { subscribePreviewAction } from "./previewActionBus";import { openPreviewSession } from "./openPreviewSession";import { PreviewChromeRow } from "./PreviewChromeRow";import { PreviewEmptyState } from "./PreviewEmptyState";import { PreviewMoreMenu } from "./PreviewMoreMenu";import {  commitBrowserViewportChange,  subscribeBrowserViewportChange,} from "~/browser/browserViewportActions";import { browserResponsiveViewportForToggle, useBrowserDefaults } from "~/browser/browserDefaults";import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId";import { BrowserSettingsReadError } from "~/browser/openFileInPreview";import { PreviewUnreachable } from "./PreviewUnreachable";import { revealInFileExplorerLabel } from "./fileExplorerLabel";import { shouldShowPreviewEmptyState } from "./previewEmptyStateLogic";import { Badge } from "~/components/ui/badge";import { BrowserSurfaceSlot } from "~/browser/BrowserSurfaceSlot";import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore";import { usePreviewSession } from "./usePreviewSession";import { ZoomIndicator } from "./ZoomIndicator";import { AgentBrowserCursor } from "./AgentBrowserCursor";import {  findActiveBrowserRecordingRuntimeTabId,  isBrowserRecordingStartCancelledError,  startBrowserRecording,  stopBrowserRecording,  useActiveBrowserRecordingTabIds,} from "~/browser/browserRecording";import { stackedThreadToast, toastManager } from "~/components/ui/toast";import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; interface Props {  threadRef: ScopedThreadRef;  tabId?: string | null;  configuredUrls?: ReadonlyArray<string> | undefined;  visible: boolean;  onSendAnnotation?: (    annotation: PreviewAnnotationPayload,    image: ComposerImageAttachment | null,  ) => void;} function previewProfileName(  profiles: ReadonlyArray<{ readonly id: string; readonly name: string }>,  profileId: string,): string {  return profiles.find((profile) => profile.id === profileId)?.name ?? "Removed profile";} 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,  onSendAnnotation,}: Props) {  const [focusUrlNonce, setFocusUrlNonce] = useState<number | undefined>(undefined);  const [pickActive, setPickActive] = useState(false);  const activeRecordingTabIds = useActiveBrowserRecordingTabIds();  const pickActiveRef = useRef(false);  const isMountedRef = useRef(true);  // Kept in sync so the title effect can depend on the stable thread key  // instead of the thread object, which is recreated on every update.  const threadRefRef = useRef(threadRef);  threadRefRef.current = threadRef;  const previewState = useThreadPreviewState(threadRef);  const recentHistoryEntries = useThreadRecentHistory(    threadRef,    BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT,  );  const miniPlayer = usePreviewMiniPlayerStore((state) =>    selectThreadPreviewMiniPlayer(state.byThreadKey, threadRef),  );  const addPreviewAnnotation = useComposerDraftStore((store) => store.addPreviewAnnotation);  const addImage = useComposerDraftStore((store) => store.addImage);  const environmentHttpBaseUrl = useEnvironmentHttpBaseUrl(threadRef.environmentId);  const environmentHostname = environmentHttpBaseUrl    ? new URL(environmentHttpBaseUrl).hostname    : null;  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 runtimeTabId = tabId    ? previewRuntimeTabId(threadRef, previewState.serverEpoch, tabId)    : null;  const recordingRuntimeTabId =    tabId && runtimeTabId      ? activeRecordingTabIds.has(runtimeTabId)        ? runtimeTabId        : findActiveBrowserRecordingRuntimeTabId(threadRef, tabId)      : null;  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 viewport = snapshot?.viewport ?? FILL_PREVIEW_VIEWPORT;  const browserDefaults = useBrowserDefaults();  // A tab created before profiles existed carries no profile of its own. It  // runs in the built-in `default` partition — the scope the browser used  // before profiles — not in whatever profile is configured as the default  // now, so that is what its label names and its clear actions target.  // Passing the snapshot's raw `undefined` through would reach the IPC layer  // as "every profile".  const activeProfileId = snapshot?.profileId ?? DEFAULT_BROWSER_PROFILE_ID;  const activeProfileName = previewProfileName(browserDefaults.profiles, activeProfileId);  const panelRect = useBrowserSurfaceStore((state) =>    runtimeTabId ? (state.byTabId[runtimeTabId]?.rect ?? null) : null,  );   const navUrl = navStatus._tag === "Success" ? navStatus.url : null;  const navTitle = navStatus._tag === "Success" ? navStatus.title : null;  const latestHistoryUrl = recentHistoryEntries[0]?.url;  const threadKey = scopedThreadKey(threadRef);  useEffect(() => {    if (!navUrl || !navTitle || !latestHistoryUrl) return;    // Agent-driven pages only enrich an existing requested URL.    setTitleForThreadUrl(threadRefRef.current, navUrl, navTitle, environmentHostname);    // threadKey stands in for threadRef, whose identity churns on every thread update.  }, [environmentHostname, latestHistoryUrl, navTitle, navUrl, threadKey]);   const navigateToResolvedUrl = useCallback(    async (resolvedUrl: string) => {      if (runtimeTabId && previewBridge) {        // The bridge mirrors the resolved URL back to the server.        await previewBridge.navigate(runtimeTabId, resolvedUrl);        rememberPreviewUrl(threadRef, resolvedUrl);        return true;      }      const result = await openPreviewSession({ openPreview: open, threadRef, url: resolvedUrl });      if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {        const error = squashAtomCommandFailure(result);        if (error instanceof BrowserSettingsReadError) {          toastManager.add({            type: "error",            title: "Unable to open browser",            description: error.message,          });        }      }      return result._tag === "Success";    },    [open, runtimeTabId, threadRef],  );   const handleSubmitUrl = useCallback(    async (next: string) => {      try {        const normalized = normalizePreviewUrl(next);        if (await navigateToResolvedUrl(normalized)) {          recordVisitForThread(threadRef, normalized);        }      } catch {        // Server-side `failed` event renders the unreachable view.      }    },    [navigateToResolvedUrl, threadRef],  );   const handleOpenServerUrl = useCallback(    async (next: string) => {      try {        const resolved = resolveDiscoveredServerUrl(threadRef.environmentId, next);        if (await navigateToResolvedUrl(resolved)) {          recordVisitForThread(threadRef, next);        }      } catch {        // Server-side `failed` event renders the unreachable view.      }    },    [navigateToResolvedUrl, threadRef],  );   const handleRefresh = useCallback(() => {    if (previewBridge && runtimeTabId) void previewBridge.refresh(runtimeTabId);  }, [runtimeTabId]);   const handleZoomIn = useCallback(() => {    if (previewBridge && runtimeTabId) void previewBridge.zoomIn(runtimeTabId);  }, [runtimeTabId]);   const handleZoomOut = useCallback(() => {    if (previewBridge && runtimeTabId) void previewBridge.zoomOut(runtimeTabId);  }, [runtimeTabId]);   const handleResetZoom = useCallback(() => {    if (previewBridge && runtimeTabId) void previewBridge.resetZoom(runtimeTabId);  }, [runtimeTabId]);   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 (!runtimeTabId) return;    if (viewport._tag !== "fill") {      void commitBrowserViewportChange(runtimeTabId, FILL_PREVIEW_VIEWPORT).catch(() => undefined);      return;    }     void commitBrowserViewportChange(      runtimeTabId,      browserResponsiveViewportForToggle({        defaults: browserDefaults,        panelRect,        zoomFactor: desktopOverlay?.zoomFactor,      }),    ).catch(() => undefined);  };   useEffect(() => {    if (!runtimeTabId) return;    return subscribeBrowserViewportChange(runtimeTabId, handleViewportChange);  }, [handleViewportChange, runtimeTabId]);   const handleBack = useCallback(() => {    if (previewBridge && runtimeTabId) void previewBridge.goBack(runtimeTabId);  }, [runtimeTabId]);   const handleForward = useCallback(() => {    if (previewBridge && runtimeTabId) void previewBridge.goForward(runtimeTabId);  }, [runtimeTabId]);   const handleOpenInBrowser = useCallback(() => {    if (!localApi || !url) return;    void localApi.shell.openExternal(url).catch(() => undefined);  }, [url]);   const handlePictureInPicture = useCallback(() => {    if (!tabId) return;    if (miniPlayer?.tabId === tabId) {      usePreviewMiniPlayerStore.getState().close(threadRef);      return;    }    usePreviewMiniPlayerStore.getState().open(threadRef, tabId);    useRightPanelStore.getState().close(threadRef);  }, [miniPlayer?.tabId, tabId, threadRef]);   const handleNativePictureInPicture = useCallback(() => {    if (!previewBridge || !runtimeTabId) return;    const operation = desktopOverlay?.pictureInPicture      ? previewBridge.pictureInPicture.close      : previewBridge.pictureInPicture.open;    void operation(runtimeTabId).catch((error) => {      toastManager.add({        type: "error",        title: "Unable to update popped-out preview",        description: error instanceof Error ? error.message : "An error occurred.",      });    });  }, [desktopOverlay?.pictureInPicture, runtimeTabId]);   const handleCapture = useCallback(    (record: boolean) => {      if (!previewBridge || !runtimeTabId || !tabId) return;      const bridge = previewBridge;      if (recordingRuntimeTabId) {        void stopBrowserRecording(recordingRuntimeTabId).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) {        void startBrowserRecording(runtimeTabId, threadRef, tabId).catch((error) => {          const description = error instanceof Error ? error.message : "An error occurred.";          if (isBrowserRecordingStartCancelledError(error)) return;          toastManager.add({            type: "error",            title: "Unable to start recording",            description,          });        });        return;      }      void bridge.captureScreenshot(runtimeTabId).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(

Lines 1–500 of 822.