diff --git a/apps/web/src/components/chat/HtmlRenderDialog.test.tsx b/apps/web/src/components/chat/HtmlRenderDialog.test.tsx new file mode 100644 index 000000000000..fbf0b4b536c0 --- /dev/null +++ b/apps/web/src/components/chat/HtmlRenderDialog.test.tsx @@ -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( + , + ), + ); +}); + +afterEach(async () => { + await act(() => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); +}); + +async function click(label: string) { + const button = document.querySelector(`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('[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('[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( + , + ), + ); +} + +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( + '[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('[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" }), + ); + }, +); diff --git a/apps/web/src/components/chat/HtmlRenderDialog.tsx b/apps/web/src/components/chat/HtmlRenderDialog.tsx new file mode 100644 index 000000000000..22686731f26e --- /dev/null +++ b/apps/web/src/components/chat/HtmlRenderDialog.tsx @@ -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(null); + return ( + !open && props.onClose()}> + + Expanded {props.title} preview +
+ +
+
+ +
+
+
+ {props.title} +
+ + + +
+
+
+
+
+ ); +} diff --git a/apps/web/src/components/chat/HtmlRenderFrame.tsx b/apps/web/src/components/chat/HtmlRenderFrame.tsx index 38dc48861684..5133c0ea55cd 100644 --- a/apps/web/src/components/chat/HtmlRenderFrame.tsx +++ b/apps/web/src/components/chat/HtmlRenderFrame.tsx @@ -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. @@ -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 @@ -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(null); + const [expandedSrc, setExpandedSrc] = useState(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; @@ -102,33 +110,40 @@ export function HtmlRenderFrame(props: { className="block size-full" onContentHeight={setContentHeight} /> -
+
- 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); + else + toastManager.add({ + type: "error", + title: "Page unavailable", + description: "Reconnect to the environment and try again.", + }); + }} /> } > - Open in panel + Open full screen
+ {expandedSrc !== null ? ( + setExpandedSrc(null)} + /> + ) : null} ) : failed ? (

diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 2ad98d378332..8d3f88ea97b2 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -3044,6 +3044,11 @@ function ProposedPlanTimelineRow({ function HtmlRenderTimelineRow({ row }: { row: Extract }) { const ctx = use(TimelineRowCtx); + const { onAppFullscreenChange } = ctx; + const onFullscreenChange = useCallback( + (fullscreen: boolean) => onAppFullscreenChange(row.id, fullscreen), + [onAppFullscreenChange, row.id], + ); return (

@@ -3053,7 +3058,7 @@ function HtmlRenderTimelineRow({ row }: { row: Extract
); diff --git a/docs/user/html-renders.md b/docs/user/html-renders.md index c7ae990f4c31..f7e076a0f98f 100644 --- a/docs/user/html-renders.md +++ b/docs/user/html-renders.md @@ -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.