Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
185 changes: 185 additions & 0 deletions apps/web/src/components/chat/HtmlRenderDialog.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
// @vitest-environment jsdom

import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
import { htmlRenderTheme, HTML_RENDER_DEFAULT_FONTS } from "@t3tools/shared/htmlRender";
import { T3_CODE_DARK_THEME_COLORS } from "@t3tools/shared/themePalettes";
import { EnvironmentId, ThreadId } from "@t3tools/contracts";

import { HtmlRenderDialog } from "./HtmlRenderDialog";
import { HtmlRenderFrame } from "./HtmlRenderFrame";

const { refresh, showError, openLink, openChangeRequestLink } = vi.hoisted(() => ({
refresh: vi.fn(),
showError: vi.fn(),
openLink: vi.fn(),
openChangeRequestLink: vi.fn(),
}));
vi.mock("~/browser/useOpenLink", () => ({ useOpenLink: () => openLink }));
vi.mock("~/lib/openPullRequestLink", () => ({
useOpenChangeRequestLink: () => openChangeRequestLink,
}));
vi.mock("~/assets/assetUrls", () => ({
useAssetUrlState: () => ({
_tag: "Success",
url: "https://environment.test/original.html",
expiresAt: Date.now() + 60 * 60_000,
}),
useAssetUrlRefresh: () => refresh,
}));
vi.mock("../ui/toast", () => ({ toastManager: { add: showError } }));

vi.mock("~/hooks/useHtmlRenderTheme", () => ({
useHtmlRenderTheme: () =>
htmlRenderTheme(T3_CODE_DARK_THEME_COLORS, "dark", HTML_RENDER_DEFAULT_FONTS),
}));

let root: Root;
let container: HTMLDivElement;
const onClose = vi.fn();

beforeEach(async () => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal(
"ResizeObserver",
class {
observe() {}
disconnect() {}
unobserve() {}
},
);
refresh.mockReset();
openLink.mockReset().mockResolvedValue(undefined);
openChangeRequestLink.mockReset().mockReturnValue(false);
showError.mockClear();
onClose.mockClear();
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
await act(() =>
root.render(
<HtmlRenderDialog
src="https://environment.test/page.html"
title="Diagram"
onClose={onClose}
/>,
),
);
});

afterEach(async () => {
await act(() => root.unmount());
container.remove();
vi.unstubAllGlobals();
});

async function click(label: string) {
const button = document.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`);
expect(button).not.toBeNull();
await act(() => button!.click());
}

it("zooms the loaded page within bounds and resets without reloading it", async () => {
const frame = document.querySelector("iframe")!;
const src = frame.src;
for (let step = 0; step < 6; step += 1) await click("Zoom in");
expect(document.querySelector<HTMLButtonElement>('[aria-label="Zoom in"]')!.disabled).toBe(true);
expect(frame.parentElement!.style.getPropertyValue("zoom")).toBe("2");
for (let step = 0; step < 8; step += 1) await click("Zoom out");
expect(document.querySelector<HTMLButtonElement>('[aria-label="Zoom out"]')!.disabled).toBe(true);
expect(frame.parentElement!.style.getPropertyValue("zoom")).toBe("0.5");
await click("Reset zoom");
expect(frame.parentElement!.style.getPropertyValue("zoom")).toBe("1");
expect(document.querySelector("iframe")).toBe(frame);
expect(frame.src).toBe(src);
expect(frame.getAttribute("sandbox")).toBe("allow-scripts allow-forms");
});

it("closes with the close button or Escape", async () => {
await click("Close");
expect(onClose).toHaveBeenCalledOnce();
onClose.mockClear();
await act(() => {
document.activeElement?.dispatchEvent(
new KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
);
});
expect(onClose).toHaveBeenCalledOnce();
});

async function renderInline() {
await act(() =>
root.render(
<HtmlRenderFrame
environmentId={EnvironmentId.make("environment")}
htmlRender={{ attachmentId: "html", title: "Diagram", height: 400 }}
threadRef={{
environmentId: EnvironmentId.make("environment"),
threadId: ThreadId.make("thread"),
}}
/>,
),
);
}

it("offers only full screen and opens with a fresh URL while keeping the inline page loaded", async () => {
await renderInline();
expect(document.querySelector('[aria-label="Open in panel"]')).toBeNull();
const inlineFrame = document.querySelector("iframe")!;
const originalSrc = inlineFrame.src;
const expandButton = document.querySelector<HTMLButtonElement>(
'[aria-label="Open full screen"]',
)!;
expandButton.focus();
refresh.mockResolvedValue("https://environment.test/renewed.html");
await click("Open full screen");
expect(refresh).toHaveBeenCalledOnce();
expect(document.querySelector('[role="dialog"] iframe')?.getAttribute("src")).toContain(
"https://environment.test/renewed.html#",
);
expect(inlineFrame.src).toBe(originalSrc);
await click("Close");
expect(document.querySelector('[role="dialog"]')).toBeNull();
expect(document.querySelector("iframe")).toBe(inlineFrame);
expect(document.activeElement).toBe(expandButton);
});

it("routes links from the expanded page through its thread", async () => {
await renderInline();
refresh.mockResolvedValue("https://environment.test/renewed.html");
openChangeRequestLink.mockReturnValue(true);
await click("Open full screen");
const frame = document.querySelector<HTMLIFrameElement>('[role="dialog"] iframe')!;
frame.focus();
const url = "https://github.com/pingdotgg/t3code/pull/17561";
await act(() => {
window.dispatchEvent(
new MessageEvent("message", {
source: frame.contentWindow,
data: { jsonrpc: "2.0", id: "link", method: "ui/open-link", params: { url } },
}),
);
});
expect(openChangeRequestLink).toHaveBeenCalledWith(
expect.objectContaining({ metaKey: false, ctrlKey: false }),
url,
undefined,
EnvironmentId.make("environment"),
);
expect(openLink).not.toHaveBeenCalled();
});

it.each([null, new Error("Disconnected")])(
"reports an unavailable URL instead of opening a blank page: %s",
async (failure) => {
await renderInline();
if (failure === null) refresh.mockResolvedValue(null);
else refresh.mockRejectedValue(failure);
await click("Open full screen");
expect(document.querySelector('[role="dialog"]')).toBeNull();
expect(showError).toHaveBeenCalledWith(
expect.objectContaining({ type: "error", title: "Page unavailable" }),
);
},
);
83 changes: 83 additions & 0 deletions apps/web/src/components/chat/HtmlRenderDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import type { ScopedThreadRef } from "@t3tools/contracts";
import { MinusIcon, PlusIcon, XIcon } from "lucide-react";
import { useRef, useState } from "react";

import { HtmlRenderDocument } from "../files/BrowserDocumentFrame";
import { Button } from "../ui/button";
import { Dialog, DialogPopup, DialogTitle } from "../ui/dialog";

export function HtmlRenderDialog(props: {
readonly src: string;
readonly title: string;
readonly threadRef?: ScopedThreadRef | undefined;
readonly onClose: () => void;
}) {
const [zoom, setZoom] = useState(1);
const closeButtonRef = useRef<HTMLButtonElement>(null);
return (
<Dialog open onOpenChange={(open) => !open && props.onClose()}>
<DialogPopup
variant="media"
showCloseButton={false}
bottomStickOnMobile={false}
className="row-start-1"
initialFocus={closeButtonRef}
>
<DialogTitle className="sr-only">Expanded {props.title} preview</DialogTitle>
<div className="relative w-[92vw]">
<Button
ref={closeButtonRef}
aria-label="Close"
size="icon-xs"
variant="media-close"
className="absolute right-0 -top-10 z-20"
onClick={props.onClose}
>
<XIcon />
</Button>
<div className="h-[min(86vh,calc(100vh-160px))] overflow-auto overscroll-contain rounded-lg bg-background shadow-2xl ring-1 ring-border/70 [container-type:size]">
<div className="h-[100cqh] w-[100cqw]" style={{ zoom }}>
<HtmlRenderDocument
src={props.src}
title={props.title}
threadRef={props.threadRef}
className="block size-full"
/>
</div>
</div>
<div className="mt-2 flex min-w-0 items-center justify-center gap-3 text-xs text-white/80">
<span className="truncate">{props.title}</span>
<div className="flex shrink-0 items-center gap-1">
<Button
aria-label="Zoom out"
size="icon-sm"
variant="overlay"
disabled={zoom === 0.5}
onClick={() => setZoom((value) => value - 0.25)}
>
<MinusIcon />
</Button>
<Button
aria-label="Reset zoom"
size="sm"
variant="overlay"
onClick={() => setZoom(1)}
>
{Math.round(zoom * 100)}%
</Button>
<Button
aria-label="Zoom in"
size="icon-sm"
variant="overlay"
disabled={zoom === 2}
onClick={() => setZoom((value) => value + 0.25)}
>
<PlusIcon />
</Button>
</div>
</div>
</div>
</DialogPopup>
</Dialog>
);
}
47 changes: 31 additions & 16 deletions apps/web/src/components/chat/HtmlRenderFrame.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@ import { Maximize2Icon } from "lucide-react";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";

import { useAssetUrlRefresh, useAssetUrlState } from "~/assets/assetUrls";
import type { ChatFileAttachment } from "~/types";

import { HtmlRenderDocument } from "../files/BrowserDocumentFrame";
import { Button } from "../ui/button";
import { toastManager } from "../ui/toast";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
import { HtmlRenderDialog } from "./HtmlRenderDialog";
import { observeResize } from "~/lib/observeResize";

// A frame may load its URL a little after mounting.
Expand All @@ -29,7 +30,7 @@ export function HtmlRenderFrame(props: {
readonly environmentId: EnvironmentId;
readonly htmlRender: HtmlRenderReference;
readonly threadRef: ScopedThreadRef | undefined;
readonly onOpen: (attachment: ChatFileAttachment) => void;
readonly onFullscreenChange?: (fullscreen: boolean) => void;
}) {
const { attachmentId, title } = props.htmlRender;
// The frame takes the page's measured height at its own width, read before
Expand Down Expand Up @@ -63,6 +64,13 @@ export function HtmlRenderFrame(props: {
// page again; one near expiry is minted afresh, since a frame cannot report a
// failed load. The page keeps its first URL for its lifetime.
const [src, setSrc] = useState<string | null>(null);
const [expandedSrc, setExpandedSrc] = useState<string | null>(null);
const { onFullscreenChange } = props;
useEffect(() => {
if (expandedSrc === null) return;
onFullscreenChange?.(true);
return () => onFullscreenChange?.(false);
}, [expandedSrc, onFullscreenChange]);
const [failed, setFailed] = useState(false);
const assetUrl = useAssetUrlState(src === null ? props.environmentId : null, resource);
const cachedUrl = assetUrl._tag === "Success" ? assetUrl.url : null;
Expand Down Expand Up @@ -102,33 +110,40 @@ export function HtmlRenderFrame(props: {
className="block size-full"
onContentHeight={setContentHeight}
/>
<div className="absolute end-2 top-2 opacity-0 transition-opacity duration-150 focus-within:opacity-100 group-hover/html-render:opacity-100 pointer-coarse:opacity-100">
<div className="absolute end-2 top-2 flex gap-1 opacity-0 transition-opacity duration-150 focus-within:opacity-100 group-hover/html-render:opacity-100 pointer-coarse:opacity-100">
<Tooltip>
<TooltipTrigger
render={
<Button
aria-label="Open in panel"
aria-label="Open full screen"
size="icon-xs"
variant="glass"
onClick={() =>
props.onOpen({
type: "file",
id: attachmentId,
name: fileName,
mimeType: "text/html",
// Unknown here; the preview leaves it out.
sizeBytes: 0,
htmlRender: true,
})
}
onClick={async () => {
const url = await refresh().catch(() => null);
if (url !== null) setExpandedSrc(url);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
else
toastManager.add({
type: "error",
title: "Page unavailable",
description: "Reconnect to the environment and try again.",
});
}}
/>
}
>
<Maximize2Icon className="size-3.5" />
</TooltipTrigger>
<TooltipPopup side="left">Open in panel</TooltipPopup>
<TooltipPopup side="left">Open full screen</TooltipPopup>
</Tooltip>
</div>
{expandedSrc !== null ? (
<HtmlRenderDialog
src={expandedSrc}
title={title}
threadRef={props.threadRef}
onClose={() => setExpandedSrc(null)}
/>
) : null}
</>
) : failed ? (
<p className="flex size-full items-center justify-center text-muted-foreground text-xs">
Expand Down
7 changes: 6 additions & 1 deletion apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3044,6 +3044,11 @@ function ProposedPlanTimelineRow({

function HtmlRenderTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "html-render" }> }) {
const ctx = use(TimelineRowCtx);
const { onAppFullscreenChange } = ctx;
const onFullscreenChange = useCallback(
(fullscreen: boolean) => onAppFullscreenChange(row.id, fullscreen),
[onAppFullscreenChange, row.id],
);

return (
<div className="min-w-0 px-1">
Expand All @@ -3053,7 +3058,7 @@ function HtmlRenderTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "htm
environmentId={ctx.activeThreadEnvironmentId}
htmlRender={row.htmlRender}
threadRef={ctx.threadRef ?? undefined}
onOpen={ctx.onFileOpen}
onFullscreenChange={onFullscreenChange}
/>
</div>
);
Expand Down
2 changes: 1 addition & 1 deletion docs/user/html-renders.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Agents can answer with a page instead of only text: a chart, table, diagram, image collage, or mockup. Ask for one ("show this as a chart", "make a collage of these screenshots") and the agent builds a self-contained HTML page, which appears in the thread above its written reply. It works with every provider, on web, desktop, and mobile.

Pages use your current theme, including custom themes, and follow light and dark mode as you switch. Scripts run inside the page, but it is sandboxed away from T3 Code and your session. Links you click in a page open in your browser. Use the expand button to open a page full size; from there you can view its source or save it.
Pages use your current theme, including custom themes, and follow light and dark mode as you switch. Scripts run inside the page, but it is sandboxed away from T3 Code and your session. Links you click in a page open in your browser. Use the expand button to open a page full screen. On web and desktop, use the zoom controls to see more detail.

Agents can place local images in a page by file path. T3 Code embeds them when the page is published, so the page keeps working after the original files move or are deleted. Deleting the thread deletes its pages.

Expand Down
Loading