From aba3a6ddc0d2625739cb65c333f6052a4a35bc71 Mon Sep 17 00:00:00 2001 From: Bilal Bakr <62337003+Bil0000@users.noreply.github.com> Date: Wed, 14 Oct 2026 05:53:11 +0300 Subject: [PATCH] feat(web): view visual replies full screen with zoom (#17561) --- .../components/chat/HtmlRenderDialog.test.tsx | 185 ++++++++++++++++++ .../src/components/chat/HtmlRenderDialog.tsx | 83 ++++++++ .../src/components/chat/HtmlRenderFrame.tsx | 47 +++-- .../src/components/chat/MessagesTimeline.tsx | 7 +- docs/user/html-renders.md | 2 +- 5 files changed, 306 insertions(+), 18 deletions(-) create mode 100644 apps/web/src/components/chat/HtmlRenderDialog.test.tsx create mode 100644 apps/web/src/components/chat/HtmlRenderDialog.tsx 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 0000000000000000000000000000000000000000..fbf0b4b536c0e22f273885542ec1239083f4bc8d --- /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 0000000000000000000000000000000000000000..22686731f26e4b1f74c067a14a0e2999090e2e31 --- /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 38dc48861684094d6dedef3ac992758be8c62c7f..5133c0ea55cd5b47272c545495d02b42a84c9791 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 2ad98d3783329ce7da203d4fe9a81223d212d653..8d3f88ea97b229151ccebf9d970ea3149d8beb93 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 c7ae990f4c31069bd48a31734228989fba23c047..f7e076a0f98fc74e7f0ff5656fc1357b67448375 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. -- 2.54.0