Files
AgentHub/client/src/lib/openInPreview.ts
T
nntrivi2001 5d62003fe5 feat(workspace): Preview tab in the session panel; local links open there
The session panel's header becomes two tabs, Changes and Preview. Preview
shows a web page by address (Reload, Open in a browser tab, the workspace's
ports as chips, last address per workspace) in an iframe on the page's own
origin: http(s) only, no userinfo, never the dashboard's origin, no top
navigation. One rule for every link (lib/openInPreview.ts): a chat or
terminal link to localhost/127.0.0.1/0.0.0.0/[::1] opens in Preview while a
session panel answers and the dashboard is on loopback; anything else, or a
Ctrl/Cmd click, opens a browser tab as before.
2026-11-02 15:06:04 +07:00

84 lines
3.3 KiB
TypeScript

/**
* @file openInPreview.ts — the ONE rule every link in the dashboard follows
* (a chat's markdown link or bare URL, a terminal's link): a page served by
* THIS machine (`localhost`, `127.0.0.1`, `0.0.0.0`, `[::1]`) opens in the
* session view's Preview tab, everything else in a browser tab as before.
* The request travels as a window event (like `openInChanges.ts`); the
* Workspace page answers it only while it shows a session panel, so with no
* listener — another page, or a remote dashboard where "localhost" is the
* viewer's own machine — the link falls back to a browser tab. Also holds the
* URL checks the Preview tab itself uses.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
export const OPEN_IN_PREVIEW_EVENT = "agenthub:open-in-preview";
/** The event's detail. `handled` is set by the listener, read by the sender. */
export type OpenInPreview = { url: string; handled: boolean };
const LOOPBACK = new Set(["localhost", "127.0.0.1", "0.0.0.0", "[::1]", "::1"]);
/** A URL the Preview may show: http(s) only, `0.0.0.0` read as `localhost`
* (a dev server prints it, a browser cannot open it), never the dashboard's
* own origin (the frame would then BE the dashboard, cookies and API
* included). A bare `localhost:3000` gets `http://`. Null when refused. */
export function normalizePreviewUrl(raw: string, self = window.location.origin): string | null {
let text = raw.trim();
if (!text) return null;
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(text)) text = `http://${text}`;
let u: URL;
try {
u = new URL(text);
} catch {
return null;
}
if (u.protocol !== "http:" && u.protocol !== "https:") return null;
if (u.username || u.password) return null;
if (u.hostname === "0.0.0.0") u.hostname = "localhost";
if (u.origin === self) return null;
return u.toString();
}
/** True for a page served by the machine the BROWSER runs on. */
export function isLoopbackUrl(url: string): boolean {
try {
const u = new URL(url);
return (u.protocol === "http:" || u.protocol === "https:") && LOOPBACK.has(u.hostname);
} catch {
return false;
}
}
/** The dashboard itself is opened from this machine: only then does a
* `localhost` link name the same machine the agents run on. */
export function dashboardIsLocal(hostname = window.location.hostname): boolean {
return LOOPBACK.has(hostname);
}
/** Asks the open session panel to show `url`. False when nothing took it
* (not a local page, a remote dashboard, no session panel). */
export function openInPreview(url: string): boolean {
if (!isLoopbackUrl(url) || !dashboardIsLocal()) return false;
const target = normalizePreviewUrl(url);
if (!target) return false;
const detail: OpenInPreview = { url: target, handled: false };
window.dispatchEvent(new CustomEvent(OPEN_IN_PREVIEW_EVENT, { detail }));
return detail.handled;
}
/** A link click: Preview when the rule says so, else a browser tab. A
* Ctrl/Cmd/Shift/middle click always keeps the browser's own behaviour. */
export function onLinkClick(
e: {
ctrlKey: boolean;
metaKey: boolean;
shiftKey: boolean;
button: number;
preventDefault: () => void;
},
url: string
): void {
if (e.ctrlKey || e.metaKey || e.shiftKey || e.button !== 0) return;
if (openInPreview(url)) e.preventDefault();
}