Files
AgentHub/client/src/components/lanes/UrlPreview.tsx
T
nntrivi2001 1ad2fec073 fix(preview): say why an outside site shows blank, with Open tab beside it
YouTube, Google and login pages refuse framing and the browser draws a
broken-page icon; the hint lived only in the empty state. A page not on this
machine now gets a strip under the address bar with Open tab. The refused-
address toast says what is allowed in plain words.
2026-11-02 15:10:22 +07:00

181 lines
6.7 KiB
TypeScript

/**
* @file UrlPreview.tsx
* @description The session panel's Preview tab: a web page (a lane's dev
* server, mostly) shown beside the console. One bar — address, Reload, Open
* in a browser tab — and, before anything is open, the workspace's own ports
* as one-click chips. The page runs in an `<iframe src>` on ITS OWN origin:
* `normalizePreviewUrl` refuses the dashboard's origin and anything but
* http(s), and the sandbox leaves out top navigation so a page cannot move
* the dashboard away. A site that forbids framing (most login pages) shows
* blank or as the browser's broken-page icon; that is the browser's rule and
* cannot be read from here, so any page not on this machine gets a strip
* saying so with the Open button beside it. The
* last address is remembered per workspace in localStorage (a viewer
* convenience, never the server's).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { ExternalLink, Globe, RotateCw } from "lucide-react";
import { isLoopbackUrl, normalizePreviewUrl } from "../../lib/openInPreview";
import { isSubmitEnter } from "../../lib/keys";
import { toast } from "../../lib/toast";
const KEY = (laneId: number) => `agenthub.preview.url.${laneId}`;
function readSaved(laneId: number): string {
try {
return window.localStorage.getItem(KEY(laneId)) ?? "";
} catch {
return "";
}
}
/** The frame's sandbox: everything a web app needs, minus moving the
* dashboard (`allow-top-navigation*`). `allow-same-origin` is the PAGE's
* own origin — never the dashboard's, which `normalizePreviewUrl` refuses. */
const FRAME_SANDBOX =
"allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox allow-modals allow-downloads";
export function UrlPreview({
laneId,
ports,
request,
}: {
laneId: number;
/** The lane's bound ports, by name — the chips of the empty state. */
ports: Record<string, number>;
/** A link asked for this page (`openInPreview`); a new nonce re-opens it. */
request: { url: string; nonce: number } | null;
}) {
const { t } = useTranslation("lanes");
const [url, setUrl] = useState(() => readSaved(laneId));
const [draft, setDraft] = useState(url);
const [reload, setReload] = useState(0);
// Another workspace: its own last page.
useEffect(() => {
const saved = readSaved(laneId);
setUrl(saved);
setDraft(saved);
}, [laneId]);
useEffect(() => {
if (request) go(request.url);
// eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the nonce
}, [request?.nonce]);
function go(raw: string) {
const next = normalizePreviewUrl(raw);
if (!next) {
toast.error(t("preview.badUrl"));
return;
}
setUrl(next);
setDraft(next);
setReload((n) => n + 1);
try {
window.localStorage.setItem(KEY(laneId), next);
} catch {
/* disabled storage: the tab just forgets between visits */
}
}
const openTab = () => url && window.open(url, "_blank", "noopener,noreferrer");
const portList = Object.entries(ports ?? {}).filter(([, p]) => Number.isInteger(p) && p > 0);
const btn =
"flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:opacity-40 disabled:hover:bg-transparent";
return (
<div className="flex min-h-0 flex-1 flex-col" data-testid="url-preview">
<div className="flex shrink-0 items-center gap-1 border-b border-border px-2 py-1.5">
<input
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (isSubmitEnter(e)) {
e.preventDefault();
go(draft);
}
}}
placeholder="localhost:3000"
aria-label={t("preview.address")}
spellCheck={false}
className="min-w-0 flex-1 rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary"
/>
<button
type="button"
className={btn}
disabled={!url}
onClick={() => setReload((n) => n + 1)}
title={t("preview.reload")}
aria-label={t("preview.reload")}
>
<RotateCw className="h-3.5 w-3.5" />
</button>
<button
type="button"
className={btn}
disabled={!url}
onClick={openTab}
title={t("preview.openTab")}
aria-label={t("preview.openTab")}
>
<ExternalLink className="h-3.5 w-3.5" />
</button>
</div>
{url && !isLoopbackUrl(url) && (
// An outside site: most refuse to be framed, and a refusal cannot be
// detected from here — so say it, with the way out beside it.
<div
data-testid="url-preview-blocked-hint"
className="flex shrink-0 items-center gap-2 border-b border-border bg-surface-2 px-2 py-1.5 text-[11px] text-fg-secondary"
>
<span className="min-w-0 flex-1">{t("preview.blockedHint")}</span>
<button
type="button"
onClick={openTab}
className="flex shrink-0 items-center gap-1 rounded-md px-1.5 py-0.5 font-medium text-accent hover:bg-surface-4"
>
<ExternalLink className="h-3 w-3" />
{t("preview.openTabShort")}
</button>
</div>
)}
{url ? (
<iframe
key={`${url}#${reload}`}
src={url}
title={t("preview.title")}
sandbox={FRAME_SANDBOX}
referrerPolicy="no-referrer"
className="min-h-0 w-full flex-1 border-0 bg-white"
data-testid="url-preview-frame"
/>
) : (
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center">
<Globe className="h-6 w-6 text-fg-muted" />
<p className="text-xs text-fg-secondary">{t("preview.empty")}</p>
{portList.length > 0 && (
<div className="flex flex-wrap justify-center gap-1.5">
{portList.map(([name, port]) => (
<button
key={name}
type="button"
onClick={() => go(`localhost:${port}`)}
title={name}
className="rounded-full border border-border px-2.5 py-0.5 font-mono text-[11px] text-fg-secondary transition-colors hover:bg-surface-4 hover:text-fg-primary"
>
localhost:{port}
</button>
))}
</div>
)}
<p className="text-[11px] text-fg-muted">{t("preview.loginHint")}</p>
</div>
)}
</div>
);
}