1"use client";
2
3import { scopedThreadKey } from "@t3tools/client-runtime/environment";
4import { previewStreamDownloadUrl } from "@t3tools/client-runtime/preview/server-browser-stream";
5import {
6 isAtomCommandInterrupted,
7 squashAtomCommandFailure,
8} from "@t3tools/client-runtime/state/runtime";
9import {
10 AuthOrchestrationOperateScope,
11 DEFAULT_BROWSER_PROFILE_ID,
12 FILL_PREVIEW_VIEWPORT,
13 type PreviewAnnotationPayload,
14 type PreviewViewportSetting,
15 type ScopedThreadRef,
16 DEFAULT_PREVIEW_ZOOM_FACTOR,
17 PREVIEW_ZOOM_LEVELS,
18 type PreviewAdjustInput,
19} from "@t3tools/contracts";
20import { normalizePreviewUrl, resolveAddressBarInput } from "@t3tools/shared/preview";
21import { useCallback, useEffect, useRef, useState } from "react";
22
23import {
24 BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT,
25 recordVisitForThread,
26 removeUrlForThread,
27 setTitleForThreadUrl,
28 useThreadRecentHistory,
29} from "~/browserHistoryStore";
30import { type ComposerImageAttachment, useComposerDraftStore } from "~/composerDraftStore";
31import { capturePreviewAnnotationScreenshot } from "~/lib/previewAnnotation";
32import { ensureLocalApi } from "~/localApi";
33import {
34 rememberPreviewUrl,
35 updatePreviewServerSnapshot,
36 useThreadPreviewState,
37} from "~/previewStateStore";
38import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver";
39import { useEnvironmentSupportsServerBrowser } from "~/state/entities";
40import {
41 useEnvironment,
42 useEnvironmentHttpBaseUrl,
43 usePrimaryEnvironmentId,
44} from "~/state/environments";
45import { previewEnvironment } from "~/state/preview";
46import { usePreviewStreamAccess } from "~/state/previewStream";
47import { useAtomCommand } from "~/state/use-atom-command";
48import {
49 browserMiniPlayerSource,
50 selectThreadPreviewMiniPlayerTabId,
51 usePreviewMiniPlayerStore,
52} from "~/previewMiniPlayerStore";
53import { readEnvironmentScope, useEnvironmentScope } from "~/state/session";
54import { useRightPanelStore } from "~/rightPanelStore";
55
56import { previewBridge } from "./previewBridge";
57import { subscribePreviewAction } from "./previewActionBus";
58import { closePreviewSession } from "./closePreviewSession";
59import { openPreviewSession } from "./openPreviewSession";
60import { showPreviewPopup } from "./showPreviewPopup";
61import { PreviewChromeRow } from "./PreviewChromeRow";
62import { PreviewEmptyState } from "./PreviewEmptyState";
63import { PreviewMoreMenu, type PreviewMoreMenuActions } from "./PreviewMoreMenu";
64import {
65 commitBrowserViewportChange,
66 subscribeBrowserViewportChange,
67} from "~/browser/browserViewportActions";
68import { browserResponsiveViewportForToggle, useBrowserDefaults } from "~/browser/browserDefaults";
69import { BrowserDeviceToolbar } from "~/browser/BrowserDeviceToolbar";
70import { BROWSER_DEVICE_TOOLBAR_HEIGHT } from "~/browser/browserViewportLayout";
71import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId";
72import { BrowserSettingsReadError } from "~/browser/openFileInPreview";
73import { PreviewFileNotShown, PreviewUnreachable } from "./PreviewUnreachable";
74import { revealInFileExplorerLabel } from "./fileExplorerLabel";
75import { shouldShowPreviewEmptyState } from "./previewEmptyStateLogic";
76import { Badge } from "~/components/ui/badge";
77import { BrowserSurfaceSlot } from "~/browser/BrowserSurfaceSlot";
78import { alternatePreviewRuntime, useRendersServerTabNatively } from "~/browser/previewRuntime";
79import { ServerBrowserSurface, type ServerBrowserHandle } from "~/browser/ServerBrowserSurface";
80import { cn } from "~/lib/utils";
81import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore";
82import { usePreviewSession } from "./usePreviewSession";
83import { ZoomIndicator } from "./ZoomIndicator";
84import { AgentBrowserCursor } from "./AgentBrowserCursor";
85import {
86 findActiveBrowserRecordingRuntimeTabId,
87 isBrowserRecordingStartCancelledError,
88 startBrowserRecording,
89 stopBrowserRecording,
90 useActiveBrowserRecordingTabIds,
91} from "~/browser/browserRecording";
92import { stackedThreadToast, toastManager } from "~/components/ui/toast";
93import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
94import { observeResize } from "~/lib/observeResize";
95
96interface Props {
97 threadRef: ScopedThreadRef;
98 tabId?: string | null;
99 configuredUrls?: ReadonlyArray<string> | undefined;
100 visible: boolean;
101 onSendAnnotation?: (
102 annotation: PreviewAnnotationPayload,
103 image: ComposerImageAttachment | null,
104 ) => void;
105}
106
107function previewProfileName(
108 profiles: ReadonlyArray<{ readonly id: string; readonly name: string }>,
109 profileId: string,
110): string {
111 return profiles.find((profile) => profile.id === profileId)?.name ?? "Removed profile";
112}
113
114const localApi = typeof window === "undefined" ? null : ensureLocalApi();
115
116/** Resolves stream access only while a server tab shows a file it downloaded. */
117function ServerTabFileNotShown(props: {
118 readonly threadRef: ScopedThreadRef;
119 readonly tabId: string;
120 readonly url: string;
121 readonly download: { readonly id: string; readonly fileName: string };
122}) {
123 const access = usePreviewStreamAccess(props.threadRef.environmentId);
124 return (
125 <PreviewFileNotShown
126 url={props.url}
127 fileName={props.download.fileName}
128 downloadUrl={
129 access
130 ? previewStreamDownloadUrl(
131 { access, threadId: props.threadRef.threadId, tabId: props.tabId },
132 props.download.id,
133 )
134 : null
135 }
136 onOpen={() => void localApi?.shell.openExternal(props.url).catch(() => undefined)}
137 />
138 );
139}
140
141/**
142 * Single-tab preview surface: chrome row on top, one webview below, empty
143 * state when no session exists for the thread.
144 */
145export function PreviewView({
146 threadRef,
147 tabId: requestedTabId,
148 configuredUrls,
149 visible,
150 onSendAnnotation,
151}: Props) {
152 const [focusUrlNonce, setFocusUrlNonce] = useState<number | undefined>(undefined);
153 const [pickActive, setPickActive] = useState(false);
154 const canSendAnnotation =
155 useEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope) &&
156 Boolean(onSendAnnotation);
157 const activeRecordingTabIds = useActiveBrowserRecordingTabIds();
158 const pickActiveRef = useRef<{ cancelled: boolean } | null>(null);
159 const isMountedRef = useRef(true);
160 // Kept in sync so the title effect can depend on the stable thread key
161 // instead of the thread object, which is recreated on every update.
162 const threadRefRef = useRef(threadRef);
163 threadRefRef.current = threadRef;
164 const previewState = useThreadPreviewState(threadRef);
165 const recentHistoryEntries = useThreadRecentHistory(
166 threadRef,
167 BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT,
168 );
169 const miniPlayerTabId = usePreviewMiniPlayerStore((state) =>
170 selectThreadPreviewMiniPlayerTabId(state.byThreadKey, threadRef),
171 );
172 const addPreviewAnnotation = useComposerDraftStore((store) => store.addPreviewAnnotation);
173 const addImage = useComposerDraftStore((store) => store.addImage);
174 const environmentHttpBaseUrl = useEnvironmentHttpBaseUrl(threadRef.environmentId);
175 const environmentHostname = environmentHttpBaseUrl
176 ? new URL(environmentHttpBaseUrl).hostname
177 : null;
178 const open = useAtomCommand(previewEnvironment.open);
179 const closePreview = useAtomCommand(previewEnvironment.close, "preview close");
180 const environmentLabel = useEnvironment(threadRef.environmentId)?.label ?? "the environment";
181 const primaryEnvironmentId = usePrimaryEnvironmentId();
182 const serverBrowser = useEnvironmentSupportsServerBrowser(threadRef.environmentId);
183 const resize = useAtomCommand(previewEnvironment.resize, "preview viewport resize");
184 const adjust = useAtomCommand(previewEnvironment.adjust, "preview appearance or zoom");
185
186 usePreviewSession(threadRef);
187
188 useEffect(() => {
189 isMountedRef.current = true;
190 return () => {
191 isMountedRef.current = false;
192 };
193 }, []);
194
195 const tabId = requestedTabId ?? previewState.activeTabId;
196 const runtimeTabId = tabId
197 ? previewRuntimeTabId(threadRef, previewState.serverEpoch, tabId)
198 : null;
199 const recordingRuntimeTabId =
200 tabId && runtimeTabId
201 ? activeRecordingTabIds.has(runtimeTabId)
202 ? runtimeTabId
203 : findActiveBrowserRecordingRuntimeTabId(threadRef, tabId)
204 : null;
205 const snapshot = tabId ? (previewState.sessions[tabId] ?? null) : null;
206 // Server tabs run in the environment's browser and stream to any client, except the
207 // desktop app's own server's tabs, which render here natively while the server drives them.
208 const nativeServerTab = useRendersServerTabNatively(threadRef.environmentId, snapshot);
209 const isServerTab = snapshot?.runtime === "server" && !nativeServerTab;
210 /** The server owns this tab's appearance, zoom, and size, whoever renders it. */
211 const serverOwnsRendering = snapshot?.runtime === "server";
212 const serverSurfaceRef = useRef<ServerBrowserHandle | null>(null);
213 const [serverAspectRatioLocked, setServerAspectRatioLocked] = useState(false);
214 const serverBodyRef = useRef<HTMLDivElement | null>(null);
215 const [serverToolbarWidth, setServerToolbarWidth] = useState(0);
216 // The streamed tab's device toolbar spans the panel; only measured while it shows.
217 const serverToolbarShown =
218 snapshot?.runtime === "server" && (snapshot.viewport ?? FILL_PREVIEW_VIEWPORT)._tag !== "fill";
219 useEffect(() => {
220 const element = serverBodyRef.current;
221 if (!element || !serverToolbarShown) return;
222 return observeResize(element, ([entry]) =>
223 setServerToolbarWidth(Math.max(1, Math.round(entry?.contentRect.width ?? 0))),
224 );
225 }, [serverToolbarShown]);
226 const [serverControlledTabId, setServerControlledTabId] = useState<string | null>(null);
227 const serverInputDisabled = isServerTab && serverControlledTabId !== runtimeTabId;
228 const [serverFrameTabId, setServerFrameTabId] = useState<string | null>(null);
229 const desktopOverlay = tabId ? (previewState.desktopByTabId[tabId] ?? null) : null;
230 const navStatus = snapshot?.navStatus ?? { _tag: "Idle" as const };
231 const url = navStatus._tag === "Idle" ? "" : navStatus.url;
232 const loading = desktopOverlay?.loading ?? navStatus._tag === "Loading";
233 const canGoBack = desktopOverlay?.canGoBack ?? snapshot?.canGoBack ?? false;
234 const canGoForward = desktopOverlay?.canGoForward ?? snapshot?.canGoForward ?? false;
235 const refreshDisabled = navStatus._tag === "Idle";
236 const isUnreachable = navStatus._tag === "LoadFailed";
237 const showEmptyState = shouldShowPreviewEmptyState(snapshot);
238 const serverStreamPending =
239 isServerTab && !showEmptyState && !isUnreachable && serverFrameTabId !== runtimeTabId;
240 const controller = desktopOverlay?.controller ?? "none";
241 const viewport = snapshot?.viewport ?? FILL_PREVIEW_VIEWPORT;
242 const browserDefaults = useBrowserDefaults();
243 // A tab created before profiles existed carries no profile of its own. It
244 // runs in the built-in `default` partition — the scope the browser used
245 // before profiles — not in whatever profile is configured as the default
246 // now, so that is what its label names and its clear actions target.
247 // Passing the snapshot's raw `undefined` through would reach the IPC layer
248 // as "every profile".
249 const activeProfileId = snapshot?.profileId ?? DEFAULT_BROWSER_PROFILE_ID;
250 const activeProfileName = previewProfileName(browserDefaults.profiles, activeProfileId);
251 const panelRect = useBrowserSurfaceStore((state) =>
252 runtimeTabId ? (state.byTabId[runtimeTabId]?.rect ?? null) : null,
253 );
254
255 const navUrl = navStatus._tag === "Success" ? navStatus.url : null;
256 const navTitle = navStatus._tag === "Success" ? navStatus.title : null;
257 const latestHistoryUrl = recentHistoryEntries[0]?.url;
258 const threadKey = scopedThreadKey(threadRef);
259 useEffect(() => {
260 if (!navUrl || !navTitle || !latestHistoryUrl) return;
261 // Agent-driven pages only enrich an existing requested URL.
262 setTitleForThreadUrl(threadRefRef.current, navUrl, navTitle, environmentHostname);
263 // threadKey stands in for threadRef, whose identity churns on every thread update.
264 }, [environmentHostname, latestHistoryUrl, navTitle, navUrl, threadKey]);
265
266 const navigateToResolvedUrl = useCallback(
267 async (resolvedUrl: string) => {
268 if (isServerTab && serverSurfaceRef.current) {
269 if (serverInputDisabled) return false;
270 serverSurfaceRef.current.navigate(resolvedUrl);
271 rememberPreviewUrl(threadRef, resolvedUrl);
272 return true;
273 }
274 if (runtimeTabId && previewBridge) {
275 // The bridge mirrors the resolved URL back to the server.
276 await previewBridge.navigate(runtimeTabId, resolvedUrl);
277 rememberPreviewUrl(threadRef, resolvedUrl);
278 return true;
279 }
280 const result = await openPreviewSession({ openPreview: open, threadRef, url: resolvedUrl });
281 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
282 const error = squashAtomCommandFailure(result);
283 if (error instanceof BrowserSettingsReadError) {
284 toastManager.add({
285 type: "error",
286 title: "Unable to open browser",
287 description: error.message,
288 });
289 }
290 }
291 return result._tag === "Success";
292 },
293 [isServerTab, open, runtimeTabId, serverInputDisabled, threadRef],
294 );
295
296 const handleSubmitUrl = useCallback(
297 async (next: string) => {
298 try {
299 const resolved = resolveAddressBarInput(next);
300 if (await navigateToResolvedUrl(resolved)) {
301 recordVisitForThread(threadRef, resolved);
302 }
303 } catch {
304 // Only empty input or an unsupported scheme lands here; the bar keeps the text.
305 }
306 },
307 [navigateToResolvedUrl, threadRef],
308 );
309
310 const handleOpenServerUrl = useCallback(
311 async (next: string) => {
312 try {
313 // A server tab's browser runs on the environment, where loopback is already right.
314 const resolved =
315 isServerTab || !previewBridge
316 ? normalizePreviewUrl(next)
317 : resolveDiscoveredServerUrl(threadRef.environmentId, next);
318 if (await navigateToResolvedUrl(resolved)) {
319 recordVisitForThread(threadRef, next);
320 }
321 } catch {
322 // Server-side `failed` event renders the unreachable view.
323 }
324 },
325 [isServerTab, navigateToResolvedUrl, threadRef],
326 );
327
328 const handleRefresh = useCallback(() => {
329 if (isServerTab) serverSurfaceRef.current?.reload();
330 else if (previewBridge && runtimeTabId) void previewBridge.refresh(runtimeTabId);
331 }, [isServerTab, runtimeTabId]);
332
333 /** Appearance and zoom of a server tab, through the server for every client. */
334 const adjustServerTab = useCallback(
335 async (change: Omit<PreviewAdjustInput, "threadId" | "tabId">) => {
336 if (!tabId) return;
337 const result = await adjust({
338 environmentId: threadRef.environmentId,
339 input: { threadId: threadRef.threadId, tabId, ...change },
340 });
341 if (result._tag === "Failure") {
342 const error = squashAtomCommandFailure(result);
343 toastManager.add({
344 type: "error",
345 title: "Unable to change the browser tab",
346 description: error instanceof Error ? error.message : "An error occurred.",
347 });
348 return;
349 }
350 updatePreviewServerSnapshot(threadRef, result.value);
351 },
352 [adjust, tabId, threadRef],
353 );
354
355 const serverZoomFactor = snapshot?.zoomFactor ?? DEFAULT_PREVIEW_ZOOM_FACTOR;
356 const stepServerZoom = useCallback(
357 (direction: -1 | 0 | 1) => {
358 const index = PREVIEW_ZOOM_LEVELS.indexOf(serverZoomFactor);
359 const next =
360 direction === 0
361 ? DEFAULT_PREVIEW_ZOOM_FACTOR
362 : PREVIEW_ZOOM_LEVELS[
363 Math.min(
364 Math.max((index < 0 ? 7 : index) + direction, 0),
365 PREVIEW_ZOOM_LEVELS.length - 1,
366 )
367 ]!;
368 if (next !== serverZoomFactor) void adjustServerTab({ zoomFactor: next });
369 },
370 [adjustServerTab, serverZoomFactor],
371 );
372
373 const handleZoomIn = useCallback(() => {
374 if (serverOwnsRendering) stepServerZoom(1);
375 else if (previewBridge && runtimeTabId) void previewBridge.zoomIn(runtimeTabId);
376 }, [runtimeTabId, serverOwnsRendering, stepServerZoom]);
377
378 const handleZoomOut = useCallback(() => {
379 if (serverOwnsRendering) stepServerZoom(-1);
380 else if (previewBridge && runtimeTabId) void previewBridge.zoomOut(runtimeTabId);
381 }, [runtimeTabId, serverOwnsRendering, stepServerZoom]);
382
383 const handleResetZoom = useCallback(() => {
384 if (serverOwnsRendering) stepServerZoom(0);
385 else if (previewBridge && runtimeTabId) void previewBridge.resetZoom(runtimeTabId);
386 }, [runtimeTabId, serverOwnsRendering, stepServerZoom]);
387
388 const handleViewportChange = useCallback(
389 async (nextViewport: PreviewViewportSetting) => {
390 if (!tabId) return;
391 const result = await resize({
392 environmentId: threadRef.environmentId,
393 input: {
394 threadId: threadRef.threadId,
395 tabId,
396 viewport: nextViewport,
397 },
398 });
399 if (result._tag === "Failure") {
400 const error = squashAtomCommandFailure(result);
401 toastManager.add({
402 type: "error",
403 title: "Unable to resize browser viewport",
404 description: error instanceof Error ? error.message : "An error occurred.",
405 });
406 throw error;
407 }
408 updatePreviewServerSnapshot(threadRef, result.value);
409 },
410 [resize, tabId, threadRef],
411 );
412
413 const handleToggleDeviceToolbar = () => {
414 if (!runtimeTabId) return;
415 if (viewport._tag !== "fill") {
416 void commitBrowserViewportChange(runtimeTabId, FILL_PREVIEW_VIEWPORT).catch(() => undefined);
417 return;
418 }
419
420 void commitBrowserViewportChange(
421 runtimeTabId,
422 browserResponsiveViewportForToggle({
423 defaults: browserDefaults,
424 panelRect,
425 zoomFactor: serverOwnsRendering ? serverZoomFactor : desktopOverlay?.zoomFactor,
426 }),
427 ).catch(() => undefined);
428 };
429
430 useEffect(() => {
431 if (!runtimeTabId) return;
432 return subscribeBrowserViewportChange(runtimeTabId, handleViewportChange);
433 }, [handleViewportChange, runtimeTabId]);
434
435 const handleBack = useCallback(() => {
436 if (isServerTab) serverSurfaceRef.current?.history(-1);
437 else if (previewBridge && runtimeTabId) void previewBridge.goBack(runtimeTabId);
438 }, [isServerTab, runtimeTabId]);
439
440 const handleForward = useCallback(() => {
441 if (isServerTab) serverSurfaceRef.current?.history(1);
442 else if (previewBridge && runtimeTabId) void previewBridge.goForward(runtimeTabId);
443 }, [isServerTab, runtimeTabId]);
444
445 const handleOpenInBrowser = useCallback(() => {
446 if (!localApi || !url) return;
447 void localApi.shell.openExternal(url).catch(() => undefined);
448 }, [url]);
449
450 // A desktop tab of a remote environment can only reach what this computer
451 // reaches; the environment's browser reaches its own network, and its agents.
452 const moveTarget = alternatePreviewRuntime(
453 threadRef.environmentId,
454 primaryEnvironmentId,
455 serverBrowser,
456 snapshot,
457 );
458 const moveLabel =
459 moveTarget === "server" ? `Open in ${environmentLabel}'s browser` : "Open on this computer";
460 const handleMoveTab = useCallback(async () => {
461 if (!moveTarget || !tabId || !snapshot) return;
462 const result = await openPreviewSession({
463 openPreview: open,
464 threadRef,
465 // Loopback means the environment's machine, which this computer reaches by its address.
466 ...(url
467 ? {
468 url:
469 moveTarget === "desktop"
470 ? resolveDiscoveredServerUrl(threadRef.environmentId, url)
471 : url,
472 }
473 : {}),
474 viewport,
475 ...(snapshot.profileId === undefined ? {} : { profileId: snapshot.profileId }),
476 runtime: moveTarget,
477 });
478 if (result._tag === "Failure") {
479 if (isAtomCommandInterrupted(result)) return;
480 const error = squashAtomCommandFailure(result);
481 toastManager.add({
482 type: "error",
483 title: "Unable to move the browser tab",
484 description: error instanceof Error ? error.message : "An error occurred.",
485 });
486 return;
487 }
488 useRightPanelStore.getState().openBrowser(threadRef, result.value.tabId);
489 await closePreviewSession({ closePreview, snapshot, tabId, threadRef });
490 }, [closePreview, moveTarget, open, snapshot, tabId, threadRef, url, viewport]);
491
492 const handlePictureInPicture = useCallback(() => {
493 if (!tabId) return;
494 if (miniPlayerTabId === tabId) {
495 usePreviewMiniPlayerStore.getState().close(threadRef);
496 return;
497 }
498 usePreviewMiniPlayerStore.getState().open(threadRef, browserMiniPlayerSource(tabId));
499 useRightPanelStore.getState().close(threadRef);
500 }, [miniPlayerTabId, tabId, threadRef]);
501
502 /**
503 * The menu's actions. A server tab, streamed or rendered natively here, runs
504 * them through its environment, so every client and agent sees one state.
505 * Opening DevTools and a separate window need the desktop's own page.
506 */
507 const desktopCall = (op: ((tabId: string) => Promise<void>) | undefined) => () => {
508 if (op && runtimeTabId) void op(runtimeTabId).catch(() => undefined);
509 };
510 const moreMenuActions: PreviewMoreMenuActions | null = serverOwnsRendering
511 ? {
512 hardReload: () => void adjustServerTab({ hardReload: true }),
513 setColorScheme: (colorScheme) => void adjustServerTab({ colorScheme }),
514 zoomIn: handleZoomIn,
515 zoomOut: handleZoomOut,
516 resetZoom: handleResetZoom,
517 clearCookies: () => void adjustServerTab({ clear: "cookies" }),
518 clearCache: () => void adjustServerTab({ clear: "cache" }),
519 ...(nativeServerTab && previewBridge
520 ? {
521 openDevTools: desktopCall(previewBridge.openDevTools),
522 toggleNativePictureInPicture: () => handleNativePictureInPicture(),
523 }
524 : {}),
525 }
526 : previewBridge
527 ? {
528 hardReload: desktopCall(previewBridge.hardReload),
529 setColorScheme: (colorScheme) => {
530 if (runtimeTabId)
531 void previewBridge?.setColorScheme(runtimeTabId, colorScheme).catch(() => undefined);
532 },
533 zoomIn: handleZoomIn,
534 zoomOut: handleZoomOut,
535 resetZoom: handleResetZoom,
536 clearCookies: () =>
537 void previewBridge
538 ?.clearCookies(threadRef.environmentId, activeProfileId)
539 .catch(() => undefined),
540 clearCache: () =>
541 void previewBridge
542 ?.clearCache(threadRef.environmentId, activeProfileId)
543 .catch(() => undefined),
544 openDevTools: desktopCall(previewBridge.openDevTools),
545 toggleNativePictureInPicture: () => handleNativePictureInPicture(),
546 }
547 : null;
548
549 const handleNativePictureInPicture = useCallback(() => {
550 if (!previewBridge || !runtimeTabId) return;
551 const operation = desktopOverlay?.pictureInPicture
552 ? previewBridge.pictureInPicture.close
553 : previewBridge.pictureInPicture.open;
554 void operation(runtimeTabId).catch((error) => {
555 toastManager.add({
556 type: "error",
557 title: "Unable to update popped-out preview",
558 description: error instanceof Error ? error.message : "An error occurred.",
559 });
560 });
561 }, [desktopOverlay?.pictureInPicture, runtimeTabId]);
562
563 const handleCapture = useCallback(
564 (record: boolean) => {
565 if (!previewBridge || !runtimeTabId || !tabId) return;
566 const bridge = previewBridge;
567 if (recordingRuntimeTabId) {
568 void stopBrowserRecording(recordingRuntimeTabId).then(
569 (artifact) => {
570 if (!artifact) return;
571 let pathCopied = false;
572 let toastId: ReturnType<typeof toastManager.add>;
573
574 const copyPath = () => {
575 if (!navigator.clipboard?.writeText) {
576 toastManager.update(
577 toastId,
578 stackedThreadToast({
579 type: "error",
580 title: "Unable to copy recording path",
581 description: "Clipboard API unavailable.",
582 actionProps: revealAction,
583 }),
584 );
585 return;
586 }
587
588 void navigator.clipboard.writeText(artifact.path).then(
589 () => {
590 pathCopied = true;
591 updateRecordingToast();
592 window.setTimeout(() => {
593 pathCopied = false;
594 updateRecordingToast();
595 }, 2_000);
596 },
597 (error) => {
598 toastManager.update(
599 toastId,
600 stackedThreadToast({
601 type: "error",
602 title: "Unable to copy recording path",
603 description: error instanceof Error ? error.message : "An error occurred.",
604 actionProps: revealAction,
605 }),
606 );
607 },
608 );
609 };
610
611 const revealAction = {
612 children: revealInFileExplorerLabel(navigator.platform),
613 onClick: () => void bridge.revealArtifact(artifact.path),
614 };
615 const updateRecordingToast = () => {
616 toastManager.update(
617 toastId,
618 stackedThreadToast({
619 type: "success",
620 title: "Recording saved",
621 actionProps: revealAction,
622 data: {
623 secondaryActionProps: {
624 children: pathCopied ? "Copied!" : "Copy path",
625 disabled: pathCopied,
626 onClick: copyPath,
627 },
628 secondaryActionVariant: "outline",
629 },
630 }),
631 );
632 };
633
634 toastId = toastManager.add(
635 stackedThreadToast({
636 type: "success",
637 title: "Recording saved",
638 actionProps: revealAction,
639 data: {
640 secondaryActionProps: {
641 children: "Copy path",
642 onClick: copyPath,
643 },
644 secondaryActionVariant: "outline",
645 },
646 }),
647 );
648 },
649 (error) => {
650 toastManager.add({
651 type: "error",
652 title: "Unable to stop recording",
653 description: error instanceof Error ? error.message : "An error occurred.",
654 });
655 },
656 );
657 return;
658 }
659 if (record) {
660 void startBrowserRecording(runtimeTabId, threadRef, tabId).catch((error) => {
661 const description = error instanceof Error ? error.message : "An error occurred.";
662 if (isBrowserRecordingStartCancelledError(error)) return;
663 toastManager.add({
664 type: "error",
665 title: "Unable to start recording",
666 description,
667 });
668 });
669 return;
670 }
671 void bridge.captureScreenshot(runtimeTabId).then(
672 (artifact) => {
673 const revealAction = {
674 children: revealInFileExplorerLabel(navigator.platform),
675 onClick: () => void bridge.revealArtifact(artifact.path),
676 };
677 let pathCopied = false;
678 let imageCopied = false;
679 let toastId: ReturnType<typeof toastManager.add>;
680
681 const updateScreenshotToast = (
682 type: "success" | "error" = "success",
683 title = "Screenshot saved",
684 description?: string,
685 ) => {
686 toastManager.update(
687 toastId,
688 stackedThreadToast({
689 type,
690 title,
691 description,
692 actionProps: {
693 children: imageCopied ? "Copied!" : "Copy image",
694 disabled: imageCopied,
695 onClick: copyImage,
696 },
697 data: {
698 additionalActions: [
699 {
700 id: "copy-path",
701 props: {
702 children: pathCopied ? "Copied!" : "Copy path",
703 disabled: pathCopied,
704 onClick: copyPath,
705 },
706 },
707 ],
708 secondaryActionProps: {
709 ...revealAction,
710 },
711 secondaryActionVariant: "outline",
712 },
713 }),
714 );
715 };
716
717 const copyPath = () => {
718 if (!navigator.clipboard?.writeText) {
719 updateScreenshotToast(
720 "error",
721 "Unable to copy screenshot path",
722 "Clipboard API unavailable.",
723 );
724 return;
725 }
726
727 void navigator.clipboard.writeText(artifact.path).then(
728 () => {
729 pathCopied = true;
730 updateScreenshotToast();
731 window.setTimeout(() => {
732 pathCopied = false;
733 updateScreenshotToast();
734 }, 2_000);
735 },
736 (error) => {
737 updateScreenshotToast(
738 "error",
739 "Unable to copy screenshot path",
740 error instanceof Error ? error.message : "An error occurred.",
741 );
742 },
743 );
744 };
745
746 const copyImage = () => {
747 void bridge.copyArtifactToClipboard(artifact.path).then(
748 () => {
749 imageCopied = true;
750 updateScreenshotToast();
751 window.setTimeout(() => {
752 imageCopied = false;
753 updateScreenshotToast();
754 }, 2_000);
755 },
756 (error) => {
757 updateScreenshotToast(
758 "error",
759 "Unable to copy screenshot",
760 error instanceof Error ? error.message : "An error occurred.",
761 );
762 },
763 );
764 };
765
766 toastId = toastManager.add(
767 stackedThreadToast({
768 type: "success",
769 title: "Screenshot saved",
770 actionProps: {
771 children: "Copy image",
772 onClick: copyImage,
773 },
774 data: {
775 additionalActions: [
776 {
777 id: "copy-path",
778 props: {
779 children: "Copy path",
780 onClick: copyPath,
781 },
782 },
783 ],
784 secondaryActionProps: {
785 ...revealAction,
786 },
787 secondaryActionVariant: "outline",
788 },
789 }),
790 );
791 },
792 (error) => {
793 toastManager.add({
794 type: "error",
795 title: "Unable to capture screenshot",
796 description: error instanceof Error ? error.message : "An error occurred.",
797 });
798 },
799 );
800 },
801 [recordingRuntimeTabId, runtimeTabId, tabId, threadRef],
802 );
803
804 const handlePickElement = useCallback(() => {
805 if (!previewBridge || !runtimeTabId) return;
806 if (pickActiveRef.current) {
807 pickActiveRef.current.cancelled = true;
808 void previewBridge.cancelPickElement(runtimeTabId).catch(() => undefined);
809 return;
810 }
811 // Snapshot whatever the user was focused on (typically the chat
812 // composer textarea or the chrome-row pick button) BEFORE main steals
813 // focus into the guest webContents. We restore it when the pick
814 // resolves so the user's typing context isn't lost — otherwise after
815 // every pick they'd have to click back into the textarea.
816 const previouslyFocused =
817 typeof document !== "undefined" ? (document.activeElement as HTMLElement | null) : null;
818 const pickRequest = { cancelled: false };
819 let submitted = false;
820 pickActiveRef.current = pickRequest;
821 setPickActive(true);
822 void (async () => {
823 try {
824 await previewBridge.setAnnotationSendEnabled?.(
825 runtimeTabId,
826 Boolean(onSendAnnotation) &&
827 readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope),
828 );
829 if (pickRequest.cancelled) return;
830 const result = await previewBridge.pickElement(runtimeTabId);
831 if (!result || pickRequest.cancelled) return;
832 // The user has submitted. Nothing that happens after this point (a
833 // second picker click, a tab change, unmount) may discard it, so the
834 // pick stops being cancellable here rather than in `finally`.
835 if (pickActiveRef.current === pickRequest) {
836 pickActiveRef.current = null;
837 if (isMountedRef.current) setPickActive(false);
838 submitted = true;
839 }
840 const { annotation: picked, submission, screenshotFailed = false } = result;
841 // The structured annotation is still sendable when its optional crop
842 // stalls or fails, so tell the user what they lost and keep going
843 // instead of holding the composer for an attachment that never lands.
844 // The stored copy drops the screenshot on failure, otherwise the prompt
845 // would tell the agent a crop is attached when none was sent.
846 const capture = capturePreviewAnnotationScreenshot(picked);
847 // Main reports a crop that failed or timed out on its side; the local
848 // conversion can fail too. Either way the user should hear about it.
849 const cropDropped = screenshotFailed || capture.status === "failed";
850 const annotation = capture.status === "failed" ? { ...picked, screenshot: null } : picked;
851 addPreviewAnnotation(threadRef, annotation);
852 if (cropDropped) {
853 toastManager.add(
854 stackedThreadToast({
855 type: "error",
856 title: "Could not capture the picked element",
857 // The send path reports its own outcome, so only say what this
858 // handler knows: the crop was dropped.
859 description: "The annotation was kept without the screenshot.",
860 }),
861 );
862 }
863 const screenshotFile = capture.status === "captured" ? capture.file : null;
864 const image =
865 screenshotFile && annotation.screenshot
866 ? ({
867 type: "image",
868 id: annotation.id,
869 name: screenshotFile.name,
870 mimeType: screenshotFile.type,
871 sizeBytes: screenshotFile.size,
872 previewUrl: annotation.screenshot.dataUrl,
873 file: screenshotFile,
874 } satisfies ComposerImageAttachment)
875 : null;
876 if (image) {
877 addImage(threadRef, image);
878 }
879 if (
880 submission === "send" &&
881 readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope)
882 ) {
883 onSendAnnotation?.(annotation, image);
884 }
885 } catch {
886 // Picker failed (e.g. webview navigated). Treat as silent cancel.
887 } finally {
888 // A submitted pick already released itself above; a cancelled or
889 // failed one releases here. Avoid `setState on unmounted component`
890 // if the panel/thread closed while the pick was in flight.
891 const isCurrentPick = pickActiveRef.current === pickRequest;
892 if (isCurrentPick) {
893 pickActiveRef.current = null;
894 if (isMountedRef.current) setPickActive(false);
895 }
896 // Best-effort: restore focus to whatever the user had before the
897 // pick stole it into the guest webContents. Skip if the previously-
898 // focused element was unmounted or is no longer focusable.
899 if (
900 (isCurrentPick || submitted) &&
901 previouslyFocused &&
902 previouslyFocused.isConnected &&
903 typeof previouslyFocused.focus === "function"
904 ) {
905 try {
906 previouslyFocused.focus({ preventScroll: true });
907 } catch {
908 // Some elements throw on .focus() (detached iframes, etc.).
909 }
910 }
911 }
912 })();
913 }, [addImage, addPreviewAnnotation, onSendAnnotation, runtimeTabId, threadRef]);
914
915 useEffect(() => {
916 if (!pickActive || !previewBridge || !runtimeTabId) return;
917 void previewBridge
918 .setAnnotationSendEnabled?.(runtimeTabId, canSendAnnotation)
919 .catch(() => undefined);
920 }, [canSendAnnotation, pickActive, runtimeTabId]);
921
922 // If the active tab changes mid-pick (close, thread switch, hot restart),
923 // tell main to tear down the in-flight session AND reset our local toggle
924 // state so the button doesn't get stuck pressed against a stale tab id.
925 useEffect(() => {
926 return () => {
927 if (!pickActiveRef.current) return;
928 pickActiveRef.current.cancelled = true;
929 pickActiveRef.current = null;
930 if (previewBridge && runtimeTabId) {
931 void previewBridge.cancelPickElement(runtimeTabId).catch(() => undefined);
932 }
933 if (isMountedRef.current) setPickActive(false);
934 };
935 }, [runtimeTabId]);
936
937 // Subscribe only while visible; `toggle-panel` is owned by ChatView's
938 // URL-aware handler regardless of whether the panel is currently mounted.
939 useEffect(() => {
940 if (!visible) return;
941 return subscribePreviewAction((action) => {
942 switch (action) {
943 case "refresh":
944 handleRefresh();
945 return;
946 case "focus-url":
947 setFocusUrlNonce((value) => (value ?? 0) + 1);
948 return;
949 case "zoom-in":
950 handleZoomIn();
951 return;
952 case "zoom-out":
953 handleZoomOut();
954 return;
955 case "reset-zoom":
956 handleResetZoom();
957 return;
958 case "toggle-panel":
959 return;
960 }
961 });
962 }, [handleRefresh, handleResetZoom, handleZoomIn, handleZoomOut, visible]);
963
964 return (
965 <div
966 className="flex min-h-0 flex-1 flex-col bg-background"
967 data-thread-key={scopedThreadKey(threadRef)}
968 >
969 <PreviewChromeRow
970 url={url}
971 loading={loading || serverStreamPending}
972 canGoBack={canGoBack && !serverInputDisabled}
973 canGoForward={canGoForward && !serverInputDisabled}
974 refreshDisabled={refreshDisabled || serverInputDisabled}
975 inputDisabled={serverInputDisabled}
976 focusUrlNonce={focusUrlNonce}
977 onBack={handleBack}
978 onForward={handleForward}
979 onRefresh={handleRefresh}
980 onSubmit={(next) => void handleSubmitUrl(next)}
981 onOpenInBrowser={tabId ? handleOpenInBrowser : undefined}
982 // Capture, annotation, and the more menu drive the desktop webview, so
983 // server tabs leave them out. Floating works for both.
984 onCapture={previewBridge && tabId && !isServerTab ? handleCapture : undefined}
985 captureDisabled={!desktopOverlay || isUnreachable}
986 recording={recordingRuntimeTabId !== null}
987 onPictureInPicture={
988 tabId && (isServerTab || previewBridge) ? handlePictureInPicture : undefined
989 }
990 pictureInPicture={miniPlayerTabId === tabId}
991 pictureInPictureDisabled={
992 isUnreachable || (!isServerTab && !desktopOverlay?.hasWebContents)
993 }
994 onPickElement={previewBridge && tabId && !isServerTab ? handlePickElement : undefined}
995 pickActive={pickActive}
996 // Disable when there's no tab (nothing to pick on) OR the page
997 // failed to load (a React overlay covers the webview, so the
998 // user wouldn't be able to actually click anything underneath).
999 pickDisabled={!tabId || isUnreachable}
1000 pickDisabledReason={
1001 isUnreachable ? "Page didn't load — pick unavailable until the page renders" : undefined
1002 }
1003 leadingActions={
1004 // Only when it differs from the default: labelling every tab
1005 // "Default" would be noise on the common case, while a tab in
1006 // another profile is exactly what needs calling out.
1007 activeProfileId !== browserDefaults.profileId ? (
1008 // Capped: profile names run to 48 characters, and an unbounded
1009 // badge in this row takes its width from the URL input, the only
1010 // flexible element in the compact chrome. The cap sits on the
1011 // badge and the truncation on an inner span, because `Badge` is an
1012 // `inline-flex` with `whitespace-nowrap` — `text-overflow` never
1013 // reaches a bare text node inside it, so the name would be cut off
1014 // at both ends with no ellipsis.
1015 <Tooltip>
1016 <TooltipTrigger render={<Badge variant="outline" className="max-w-28 shrink-0" />}>
1017 <span className="truncate">{activeProfileName}</span>
1018 </TooltipTrigger>
1019 <TooltipPopup side="top">{activeProfileName}</TooltipPopup>
1020 </Tooltip>
1021 ) : null
1022 }
1023 trailingActions={
1024 moreMenuActions ? (
1025 <PreviewMoreMenu
1026 enabled={
1027 runtimeTabId !== null &&
1028 (serverOwnsRendering || (desktopOverlay?.hasWebContents ?? false))
1029 }
1030 actions={moreMenuActions}
1031 {...(moveTarget
1032 ? { move: { label: moveLabel, onMove: () => void handleMoveTab() } }
1033 : {})}
1034 profileName={activeProfileName}
1035 zoomFactor={
1036 serverOwnsRendering ? serverZoomFactor : (desktopOverlay?.zoomFactor ?? 1)
1037 }
1038 colorScheme={
1039 serverOwnsRendering
1040 ? (snapshot?.colorScheme ?? "system")
1041 : (desktopOverlay?.colorScheme ?? "system")
1042 }
1043 deviceToolbarVisible={viewport._tag !== "fill"}
1044 onToggleDeviceToolbar={handleToggleDeviceToolbar}
1045 nativePictureInPicture={desktopOverlay?.pictureInPicture ?? false}
1046 />
1047 ) : null
1048 }
1049 />
1050
1051 <div ref={serverBodyRef} className="relative min-h-0 flex-1 overflow-hidden">
1052 {runtimeTabId && snapshot && isServerTab ? (
1053 <>
1054 {viewport._tag !== "fill" && !showEmptyState && !isUnreachable ? (
1055 <div className="absolute inset-x-0 top-0 z-10">
1056 <BrowserDeviceToolbar
1057 setting={viewport}
1058 width={serverToolbarWidth}
1059 aspectRatio={serverAspectRatioLocked ? viewport.width / viewport.height : null}
1060 onAspectRatioChange={(ratio) => setServerAspectRatioLocked(ratio !== null)}
1061 onChange={(setting) => commitBrowserViewportChange(runtimeTabId, setting)}
1062 />
1063 </div>
1064 ) : null}
1065 <div
1066 className="absolute inset-x-0 bottom-0"
1067 style={{ top: viewport._tag !== "fill" ? BROWSER_DEVICE_TOOLBAR_HEIGHT : 0 }}
1068 >
1069 <ServerBrowserSurface
1070 key={runtimeTabId}
1071 ref={serverSurfaceRef}
1072 environmentId={threadRef.environmentId}
1073 threadId={threadRef.threadId}
1074 tabId={snapshot.tabId}
1075 visible={visible}
1076 onFirstFrame={() => setServerFrameTabId(runtimeTabId)}
1077 onControl={(control) =>
1078 setServerControlledTabId(control?.controller === "you" ? runtimeTabId : null)
1079 }
1080 onPopup={(popupTabId) => showPreviewPopup(threadRef, popupTabId, "panel")}
1081 // Stays connected under the empty state so a URL picked there reaches the page.
1082 className={cn(
1083 "absolute inset-0 h-full w-full",
1084 (showEmptyState || isUnreachable) && "invisible",
1085 )}
1086 />
1087 </div>
1088 </>
1089 ) : runtimeTabId && snapshot && !showEmptyState ? (
1090 previewBridge ? (
1091 <BrowserSurfaceSlot
1092 key={runtimeTabId}
1093 tabId={runtimeTabId}
1094 visible={visible && !isUnreachable}
1095 className="absolute inset-0 h-full w-full"
1096 />
1097 ) : (
1098 <div className="flex h-full items-center justify-center p-8 text-center">
1099 <p className="max-w-sm text-sm text-muted-foreground">
1100 This tab is open in The Knife desktop app.
1101 </p>
1102 </div>
1103 )
1104 ) : null}
1105 {showEmptyState ? (
1106 <PreviewEmptyState
1107 threadRef={threadRef}
1108 environmentId={threadRef.environmentId}
1109 configuredUrls={configuredUrls}
1110 recentEntries={recentHistoryEntries}
1111 onRemoveRecent={(url) => removeUrlForThread(threadRef, url)}
1112 onOpenUrl={(next) => void handleOpenServerUrl(next)}
1113 />
1114 ) : null}
1115 {snapshot && desktopOverlay ? (
1116 <ZoomIndicator zoomFactor={desktopOverlay.zoomFactor} />
1117 ) : null}
1118 {runtimeTabId &&
1119 desktopOverlay &&
1120 !showEmptyState &&
1121 !isUnreachable &&
1122 !activeRecordingTabIds.has(runtimeTabId) ? (
1123 <AgentBrowserCursor
1124 tabId={runtimeTabId}
1125 zoomFactor={desktopOverlay.zoomFactor}
1126 controller={controller}
1127 />
1128 ) : null}
1129 {navStatus._tag === "LoadFailed" && navStatus.download ? (
1130 <div className="absolute inset-0 z-10 bg-background">
1131 <ServerTabFileNotShown
1132 threadRef={threadRef}
1133 tabId={snapshot?.tabId ?? ""}
1134 url={navStatus.url}
1135 download={navStatus.download}
1136 />
1137 </div>
1138 ) : navStatus._tag === "LoadFailed" ? (
1139 <div className="absolute inset-0 z-10 bg-background">
1140 <PreviewUnreachable
1141 url={navStatus.url}
1142 code={navStatus.code}
1143 description={navStatus.description}
1144 onReload={handleRefresh}
1145 {...(moveTarget === "server"
1146 ? { move: { label: moveLabel, onMove: () => void handleMoveTab() } }
1147 : {})}
1148 />
1149 </div>
1150 ) : null}
1151 </div>
1152 </div>
1153 );
1154}