import { RefreshIcon } from "~/components/ui/refresh-icon";import { ArrowLeft, ArrowRight, Camera, ExternalLink, MousePointerClick, PictureInPicture2,} from "lucide-react";import { type FormEvent, type KeyboardEvent, type ReactNode, useEffect, useRef, useState,} from "react"; import { Button } from "~/components/ui/button";import { InputGroup, InputGroupAddon, InputGroupInput } from "~/components/ui/input-group";import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";import { cn } from "~/lib/utils"; interface Props { url: string; loading: boolean; canGoBack: boolean; canGoForward: boolean; refreshDisabled: boolean; inputDisabled?: boolean | undefined; /** Bumping this value re-focuses and selects the URL input. */ focusUrlNonce?: number | undefined; onBack: () => void; onForward: () => void; onRefresh: () => void; onSubmit: (url: string) => void; /** When provided, renders an "Open in browser" affordance to the right. */ onOpenInBrowser?: (() => void) | undefined; onCapture?: ((record: boolean) => void) | undefined; captureDisabled?: boolean | undefined; recording?: boolean | undefined; onPictureInPicture?: (() => void) | undefined; pictureInPicture?: boolean | undefined; pictureInPictureDisabled?: boolean | undefined; /** * When provided, renders an annotation-mode toggle button to the right of * the URL input. Pressed while annotation mode is active (button shows in `pressed` * state). Disabled in `pickDisabled` mode. */ onPickElement?: (() => void) | undefined; pickActive?: boolean | undefined; pickDisabled?: boolean | undefined; /** Optional reason string surfaced in the disabled tooltip. */ pickDisabledReason?: string | undefined; /** * Trailing slot rendered after the URL input. Used by the preview view * to mount the three-dot menu (hard reload, devtools, zoom, clear data). */ trailingActions?: ReactNode; /** * Slot between the nav buttons and the URL input. The preview view uses it * to name the tab's browser profile, which is otherwise invisible. */ leadingActions?: ReactNode;} const NOOP = () => {}; export function PreviewChromeRow({ url, loading, canGoBack, canGoForward, refreshDisabled, inputDisabled, focusUrlNonce, onBack, onForward, onRefresh, onSubmit, onOpenInBrowser, onCapture, captureDisabled, recording, onPictureInPicture, pictureInPicture, pictureInPictureDisabled, onPickElement, pickActive, pickDisabled, pickDisabledReason, trailingActions, leadingActions,}: Props) { const inputRef = useRef<HTMLInputElement | null>(null); const [draft, setDraft] = useState(url); const [inputFocused, setInputFocused] = useState(false); useEffect(() => { if (focusUrlNonce == null) return; const node = inputRef.current; if (!node) return; node.focus(); }, [focusUrlNonce]); const submit = (event?: FormEvent | KeyboardEvent) => { event?.preventDefault(); const next = draft.trim(); if (next.length === 0) return; onSubmit(next); inputRef.current?.blur(); }; return ( <div className="relative"> <form onSubmit={submit} className="flex h-10 min-h-10 shrink-0 items-center gap-1 border-b border-border/60 bg-background px-2 in-data-[preview-panel-mode=inline]:mb-3 in-data-[preview-panel-mode=inline]:h-7 in-data-[preview-panel-mode=inline]:min-h-7 in-data-[preview-panel-mode=inline]:border-b-transparent" data-surface-subheader > <div className="flex items-center gap-0.5" role="group" aria-label="Navigation"> <Tooltip> <TooltipTrigger render={ <Button variant="ghost" size="icon-xs" onClick={canGoBack ? onBack : NOOP} disabled={!canGoBack} aria-label="Back" type="button" /> } > <ArrowLeft /> </TooltipTrigger> <TooltipPopup>Back</TooltipPopup> </Tooltip> <Tooltip> <TooltipTrigger render={ <Button variant="ghost" size="icon-xs" onClick={canGoForward ? onForward : NOOP} disabled={!canGoForward} aria-label="Forward" type="button" /> } > <ArrowRight /> </TooltipTrigger> <TooltipPopup>Forward</TooltipPopup> </Tooltip> <Tooltip> <TooltipTrigger render={ <Button variant="ghost" size="icon-xs" onClick={refreshDisabled ? NOOP : onRefresh} disabled={refreshDisabled} aria-label={loading ? "Stop" : "Refresh"} type="button" /> } > <RefreshIcon refreshing={loading} /> </TooltipTrigger> <TooltipPopup>{loading ? "Loading…" : "Refresh"}</TooltipPopup> </Tooltip> </div> {leadingActions} <InputGroup variant="ghost" className="group/address h-7 flex-1"> <Tooltip> <TooltipTrigger render={ <InputGroupInput ref={inputRef} value={inputFocused ? draft : url} className={cn( onOpenInBrowser && !inputFocused && "group-hover/address:pe-7 transition-[padding]", )} onChange={(event) => setDraft(event.target.value)} onFocus={() => { setDraft(url); setInputFocused(true); queueMicrotask(() => inputRef.current?.select()); }} onBlur={() => { setInputFocused(false); }} onKeyDown={(event) => { if (event.key === "Enter") submit(event); if (event.key === "Escape") { event.preventDefault(); setDraft(url); inputRef.current?.blur(); } }} placeholder="Search or enter URL" spellCheck={false} disabled={inputDisabled} data-preview-url-input size="sm" /> } /> </Tooltip> {onOpenInBrowser && !inputFocused ? ( <InputGroupAddon align="inline-end" className="pointer-events-none absolute inset-y-0 right-0 opacity-0 transition-opacity group-hover/address:pointer-events-auto group-hover/address:opacity-100" > <Tooltip> <TooltipTrigger render={ <Button variant="ghost" size="icon-xs" onClick={onOpenInBrowser} aria-label="Open in system browser" type="button" /> } > <ExternalLink /> </TooltipTrigger> <TooltipPopup>Open in system browser</TooltipPopup> </Tooltip> </InputGroupAddon> ) : null} </InputGroup> {onPickElement ? ( <Tooltip> <TooltipTrigger render={ <Button variant={pickActive ? "secondary" : "ghost"} size="icon-xs" onClick={onPickElement} disabled={pickDisabled} aria-label={pickActive ? "Cancel annotation" : "Annotate preview"} aria-pressed={pickActive ? "true" : "false"} type="button" /> } > <MousePointerClick className={cn(pickActive && "text-primary")} /> </TooltipTrigger> <TooltipPopup> {pickDisabled && pickDisabledReason ? pickDisabledReason : pickActive ? "Cancel annotation (Esc)" : "Annotate elements, regions, and drawings"} </TooltipPopup> </Tooltip> ) : null} {onCapture ? ( <Tooltip> <TooltipTrigger render={ <Button variant={recording ? "secondary" : "ghost"} size="icon-xs" onClick={(event) => onCapture(event.shiftKey)} aria-label={recording ? "Stop recording" : "Capture screenshot"} type="button" className="relative" disabled={captureDisabled} /> } > <Camera className={cn(recording && "text-destructive")} /> {recording ? ( <span className="absolute right-0.5 top-0.5 size-1.5 animate-status-pulse rounded-full bg-destructive" /> ) : null} </TooltipTrigger> <TooltipPopup> {recording ? "Stop recording" : "Screenshot · Shift-click to record"} </TooltipPopup> </Tooltip> ) : null} {onPictureInPicture ? ( <Tooltip> <TooltipTrigger render={ <Button variant={pictureInPicture ? "secondary" : "ghost"} size="icon-xs" onClick={onPictureInPicture} aria-label={ pictureInPicture ? "Close floating preview" : "Float preview over chat" } aria-pressed={pictureInPicture ? "true" : "false"} type="button" disabled={pictureInPictureDisabled} /> } > <PictureInPicture2 className={cn(pictureInPicture && "text-primary")} /> </TooltipTrigger> <TooltipPopup> {pictureInPicture ? "Close floating preview" : "Float preview over chat"} </TooltipPopup> </Tooltip> ) : null} {trailingActions} </form> <div aria-hidden data-loading={loading} className="preview-loading-progress pointer-events-none absolute bottom-0 left-0 z-10 h-0.5 w-full origin-left rounded-r-full bg-primary" style={{ boxShadow: "0 0 6px 1px var(--color-ring)" }} /> </div> );}
import { RefreshIcon } from "~/components/ui/refresh-icon";
import {
ArrowLeft,
ArrowRight,
Camera,
ExternalLink,
MousePointerClick,
PictureInPicture2,
} from "lucide-react";
import {
type FormEvent,
type KeyboardEvent,
type ReactNode,
useEffect,
useRef,
useState,
} from "react";
import { Button } from "~/components/ui/button";
import { InputGroup, InputGroupAddon, InputGroupInput } from "~/components/ui/input-group";
import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
import { cn } from "~/lib/utils";
interface Props {
url: string;
loading: boolean;
canGoBack: boolean;
canGoForward: boolean;
refreshDisabled: boolean;
inputDisabled?: boolean | undefined;
/** Bumping this value re-focuses and selects the URL input. */
focusUrlNonce?: number | undefined;
onBack: () => void;
onForward: () => void;
onRefresh: () => void;
onSubmit: (url: string) => void;
/** When provided, renders an "Open in browser" affordance to the right. */
onOpenInBrowser?: (() => void) | undefined;
onCapture?: ((record: boolean) => void) | undefined;
captureDisabled?: boolean | undefined;
recording?: boolean | undefined;
onPictureInPicture?: (() => void) | undefined;
pictureInPicture?: boolean | undefined;
pictureInPictureDisabled?: boolean | undefined;
/**
* When provided, renders an annotation-mode toggle button to the right of
* the URL input. Pressed while annotation mode is active (button shows in `pressed`
* state). Disabled in `pickDisabled` mode.
*/
onPickElement?: (() => void) | undefined;
pickActive?: boolean | undefined;
pickDisabled?: boolean | undefined;
/** Optional reason string surfaced in the disabled tooltip. */
pickDisabledReason?: string | undefined;
/**
* Trailing slot rendered after the URL input. Used by the preview view
* to mount the three-dot menu (hard reload, devtools, zoom, clear data).
*/
trailingActions?: ReactNode;
/**
* Slot between the nav buttons and the URL input. The preview view uses it
* to name the tab's browser profile, which is otherwise invisible.
*/
leadingActions?: ReactNode;
}
const NOOP = () => {};
export function PreviewChromeRow({
url,
loading,
canGoBack,
canGoForward,
refreshDisabled,
inputDisabled,
focusUrlNonce,
onBack,
onForward,
onRefresh,
onSubmit,
onOpenInBrowser,
onCapture,
captureDisabled,
recording,
onPictureInPicture,
pictureInPicture,
pictureInPictureDisabled,
onPickElement,
pickActive,
pickDisabled,
pickDisabledReason,
trailingActions,
leadingActions,
}: Props) {
const inputRef = useRef<HTMLInputElement | null>(null);
const [draft, setDraft] = useState(url);
const [inputFocused, setInputFocused] = useState(false);
useEffect(() => {
if (focusUrlNonce == null) return;
const node = inputRef.current;
if (!node) return;
node.focus();
}, [focusUrlNonce]);
const submit = (event?: FormEvent | KeyboardEvent) => {
event?.preventDefault();
const next = draft.trim();
if (next.length === 0) return;
onSubmit(next);
inputRef.current?.blur();
};
return (
<div className="relative">
<form
onSubmit={submit}
className="flex h-10 min-h-10 shrink-0 items-center gap-1 border-b border-border/60 bg-background px-2 in-data-[preview-panel-mode=inline]:mb-3 in-data-[preview-panel-mode=inline]:h-7 in-data-[preview-panel-mode=inline]:min-h-7 in-data-[preview-panel-mode=inline]:border-b-transparent"
data-surface-subheader
>
<div className="flex items-center gap-0.5" role="group" aria-label="Navigation">
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-xs"
onClick={canGoBack ? onBack : NOOP}
disabled={!canGoBack}
aria-label="Back"
type="button"
/>
}
>
<ArrowLeft />
</TooltipTrigger>
<TooltipPopup>Back</TooltipPopup>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-xs"
onClick={canGoForward ? onForward : NOOP}
disabled={!canGoForward}
aria-label="Forward"
type="button"
/>
}
>
<ArrowRight />
</TooltipTrigger>
<TooltipPopup>Forward</TooltipPopup>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-xs"
onClick={refreshDisabled ? NOOP : onRefresh}
disabled={refreshDisabled}
aria-label={loading ? "Stop" : "Refresh"}
type="button"
/>
}
>
<RefreshIcon refreshing={loading} />
</TooltipTrigger>
<TooltipPopup>{loading ? "Loading…" : "Refresh"}</TooltipPopup>
</Tooltip>
</div>
{leadingActions}
<InputGroup variant="ghost" className="group/address h-7 flex-1">
<Tooltip>
<TooltipTrigger
render={
<InputGroupInput
ref={inputRef}
value={inputFocused ? draft : url}
className={cn(
onOpenInBrowser &&
!inputFocused &&
"group-hover/address:pe-7 transition-[padding]",
)}
onChange={(event) => setDraft(event.target.value)}
onFocus={() => {
setDraft(url);
setInputFocused(true);
queueMicrotask(() => inputRef.current?.select());
}}
onBlur={() => {
setInputFocused(false);
}}
onKeyDown={(event) => {
if (event.key === "Enter") submit(event);
if (event.key === "Escape") {
event.preventDefault();
setDraft(url);
inputRef.current?.blur();
}
}}
placeholder="Search or enter URL"
spellCheck={false}
disabled={inputDisabled}
data-preview-url-input
size="sm"
/>
}
/>
</Tooltip>
{onOpenInBrowser && !inputFocused ? (
<InputGroupAddon
align="inline-end"
className="pointer-events-none absolute inset-y-0 right-0 opacity-0 transition-opacity group-hover/address:pointer-events-auto group-hover/address:opacity-100"
>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-xs"
onClick={onOpenInBrowser}
aria-label="Open in system browser"
type="button"
/>
}
>
<ExternalLink />
</TooltipTrigger>
<TooltipPopup>Open in system browser</TooltipPopup>
</Tooltip>
</InputGroupAddon>
) : null}
</InputGroup>
{onPickElement ? (
<Tooltip>
<TooltipTrigger
render={
<Button
variant={pickActive ? "secondary" : "ghost"}
size="icon-xs"
onClick={onPickElement}
disabled={pickDisabled}
aria-label={pickActive ? "Cancel annotation" : "Annotate preview"}
aria-pressed={pickActive ? "true" : "false"}
type="button"
/>
}
>
<MousePointerClick className={cn(pickActive && "text-primary")} />
</TooltipTrigger>
<TooltipPopup>
{pickDisabled && pickDisabledReason
? pickDisabledReason
: pickActive
? "Cancel annotation (Esc)"
: "Annotate elements, regions, and drawings"}
</TooltipPopup>
</Tooltip>
) : null}
{onCapture ? (
<Tooltip>
<TooltipTrigger
render={
<Button
variant={recording ? "secondary" : "ghost"}
size="icon-xs"
onClick={(event) => onCapture(event.shiftKey)}
aria-label={recording ? "Stop recording" : "Capture screenshot"}
type="button"
className="relative"
disabled={captureDisabled}
/>
}
>
<Camera className={cn(recording && "text-destructive")} />
{recording ? (
<span className="absolute right-0.5 top-0.5 size-1.5 animate-status-pulse rounded-full bg-destructive" />
) : null}
</TooltipTrigger>
<TooltipPopup>
{recording ? "Stop recording" : "Screenshot · Shift-click to record"}
</TooltipPopup>
</Tooltip>
) : null}
{onPictureInPicture ? (
<Tooltip>
<TooltipTrigger
render={
<Button
variant={pictureInPicture ? "secondary" : "ghost"}
size="icon-xs"
onClick={onPictureInPicture}
aria-label={
pictureInPicture ? "Close floating preview" : "Float preview over chat"
}
aria-pressed={pictureInPicture ? "true" : "false"}
type="button"
disabled={pictureInPictureDisabled}
/>
}
>
<PictureInPicture2 className={cn(pictureInPicture && "text-primary")} />
</TooltipTrigger>
<TooltipPopup>
{pictureInPicture ? "Close floating preview" : "Float preview over chat"}
</TooltipPopup>
</Tooltip>
) : null}
{trailingActions}
</form>
<div
aria-hidden
data-loading={loading}
className="preview-loading-progress pointer-events-none absolute bottom-0 left-0 z-10 h-0.5 w-full origin-left rounded-r-full bg-primary"
style={{ boxShadow: "0 0 6px 1px var(--color-ring)" }}
/>
</div>
);
}