"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. Load more
"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(