authorgravatar for 62337003+Bil0000@users.noreply.github.comBilal Bakr <62337003+Bil0000@users.noreply.github.com> 2026-10-14 05:53:11+03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-13 21:53:11-05:00
logaba3a6ddc0d2625739cb65c333f6052a4a35bc71
tree5aef92dedba5e4a43f28b1f896a16380ccf84815
parent1ce8089ebd3317dad220f49deb541654c2f05fb5
signature Signed by PGP key B5690EEEBB952194

feat(web): view visual replies full screen with zoom (#17561)


5 files changed, 306 insertions(+), 18 deletions(-)

apps/web/src/components/chat/HtmlRenderDialog.test.tsx created+185
......@@ -0,0 +1,185 @@
1// @vitest-environment jsdom
2
3import { act } from "react";
4import { createRoot, type Root } from "react-dom/client";
5import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
6import { htmlRenderTheme, HTML_RENDER_DEFAULT_FONTS } from "@t3tools/shared/htmlRender";
7import { T3_CODE_DARK_THEME_COLORS } from "@t3tools/shared/themePalettes";
8import { EnvironmentId, ThreadId } from "@t3tools/contracts";
9
10import { HtmlRenderDialog } from "./HtmlRenderDialog";
11import { HtmlRenderFrame } from "./HtmlRenderFrame";
12
13const { refresh, showError, openLink, openChangeRequestLink } = vi.hoisted(() => ({
14 refresh: vi.fn(),
15 showError: vi.fn(),
16 openLink: vi.fn(),
17 openChangeRequestLink: vi.fn(),
18}));
19vi.mock("~/browser/useOpenLink", () => ({ useOpenLink: () => openLink }));
20vi.mock("~/lib/openPullRequestLink", () => ({
21 useOpenChangeRequestLink: () => openChangeRequestLink,
22}));
23vi.mock("~/assets/assetUrls", () => ({
24 useAssetUrlState: () => ({
25 _tag: "Success",
26 url: "https://environment.test/original.html",
27 expiresAt: Date.now() + 60 * 60_000,
28 }),
29 useAssetUrlRefresh: () => refresh,
30}));
31vi.mock("../ui/toast", () => ({ toastManager: { add: showError } }));
32
33vi.mock("~/hooks/useHtmlRenderTheme", () => ({
34 useHtmlRenderTheme: () =>
35 htmlRenderTheme(T3_CODE_DARK_THEME_COLORS, "dark", HTML_RENDER_DEFAULT_FONTS),
36}));
37
38let root: Root;
39let container: HTMLDivElement;
40const onClose = vi.fn();
41
42beforeEach(async () => {
43 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
44 vi.stubGlobal(
45 "ResizeObserver",
46 class {
47 observe() {}
48 disconnect() {}
49 unobserve() {}
50 },
51 );
52 refresh.mockReset();
53 openLink.mockReset().mockResolvedValue(undefined);
54 openChangeRequestLink.mockReset().mockReturnValue(false);
55 showError.mockClear();
56 onClose.mockClear();
57 container = document.createElement("div");
58 document.body.append(container);
59 root = createRoot(container);
60 await act(() =>
61 root.render(
62 <HtmlRenderDialog
63 src="https://environment.test/page.html"
64 title="Diagram"
65 onClose={onClose}
66 />,
67 ),
68 );
69});
70
71afterEach(async () => {
72 await act(() => root.unmount());
73 container.remove();
74 vi.unstubAllGlobals();
75});
76
77async function click(label: string) {
78 const button = document.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`);
79 expect(button).not.toBeNull();
80 await act(() => button!.click());
81}
82
83it("zooms the loaded page within bounds and resets without reloading it", async () => {
84 const frame = document.querySelector("iframe")!;
85 const src = frame.src;
86 for (let step = 0; step < 6; step += 1) await click("Zoom in");
87 expect(document.querySelector<HTMLButtonElement>('[aria-label="Zoom in"]')!.disabled).toBe(true);
88 expect(frame.parentElement!.style.getPropertyValue("zoom")).toBe("2");
89 for (let step = 0; step < 8; step += 1) await click("Zoom out");
90 expect(document.querySelector<HTMLButtonElement>('[aria-label="Zoom out"]')!.disabled).toBe(true);
91 expect(frame.parentElement!.style.getPropertyValue("zoom")).toBe("0.5");
92 await click("Reset zoom");
93 expect(frame.parentElement!.style.getPropertyValue("zoom")).toBe("1");
94 expect(document.querySelector("iframe")).toBe(frame);
95 expect(frame.src).toBe(src);
96 expect(frame.getAttribute("sandbox")).toBe("allow-scripts allow-forms");
97});
98
99it("closes with the close button or Escape", async () => {
100 await click("Close");
101 expect(onClose).toHaveBeenCalledOnce();
102 onClose.mockClear();
103 await act(() => {
104 document.activeElement?.dispatchEvent(
105 new KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
106 );
107 });
108 expect(onClose).toHaveBeenCalledOnce();
109});
110
111async function renderInline() {
112 await act(() =>
113 root.render(
114 <HtmlRenderFrame
115 environmentId={EnvironmentId.make("environment")}
116 htmlRender={{ attachmentId: "html", title: "Diagram", height: 400 }}
117 threadRef={{
118 environmentId: EnvironmentId.make("environment"),
119 threadId: ThreadId.make("thread"),
120 }}
121 />,
122 ),
123 );
124}
125
126it("offers only full screen and opens with a fresh URL while keeping the inline page loaded", async () => {
127 await renderInline();
128 expect(document.querySelector('[aria-label="Open in panel"]')).toBeNull();
129 const inlineFrame = document.querySelector("iframe")!;
130 const originalSrc = inlineFrame.src;
131 const expandButton = document.querySelector<HTMLButtonElement>(
132 '[aria-label="Open full screen"]',
133 )!;
134 expandButton.focus();
135 refresh.mockResolvedValue("https://environment.test/renewed.html");
136 await click("Open full screen");
137 expect(refresh).toHaveBeenCalledOnce();
138 expect(document.querySelector('[role="dialog"] iframe')?.getAttribute("src")).toContain(
139 "https://environment.test/renewed.html#",
140 );
141 expect(inlineFrame.src).toBe(originalSrc);
142 await click("Close");
143 expect(document.querySelector('[role="dialog"]')).toBeNull();
144 expect(document.querySelector("iframe")).toBe(inlineFrame);
145 expect(document.activeElement).toBe(expandButton);
146});
147
148it("routes links from the expanded page through its thread", async () => {
149 await renderInline();
150 refresh.mockResolvedValue("https://environment.test/renewed.html");
151 openChangeRequestLink.mockReturnValue(true);
152 await click("Open full screen");
153 const frame = document.querySelector<HTMLIFrameElement>('[role="dialog"] iframe')!;
154 frame.focus();
155 const url = "https://github.com/pingdotgg/t3code/pull/17561";
156 await act(() => {
157 window.dispatchEvent(
158 new MessageEvent("message", {
159 source: frame.contentWindow,
160 data: { jsonrpc: "2.0", id: "link", method: "ui/open-link", params: { url } },
161 }),
162 );
163 });
164 expect(openChangeRequestLink).toHaveBeenCalledWith(
165 expect.objectContaining({ metaKey: false, ctrlKey: false }),
166 url,
167 undefined,
168 EnvironmentId.make("environment"),
169 );
170 expect(openLink).not.toHaveBeenCalled();
171});
172
173it.each([null, new Error("Disconnected")])(
174 "reports an unavailable URL instead of opening a blank page: %s",
175 async (failure) => {
176 await renderInline();
177 if (failure === null) refresh.mockResolvedValue(null);
178 else refresh.mockRejectedValue(failure);
179 await click("Open full screen");
180 expect(document.querySelector('[role="dialog"]')).toBeNull();
181 expect(showError).toHaveBeenCalledWith(
182 expect.objectContaining({ type: "error", title: "Page unavailable" }),
183 );
184 },
185);
apps/web/src/components/chat/HtmlRenderDialog.tsx created+83
......@@ -0,0 +1,83 @@
1import type { ScopedThreadRef } from "@t3tools/contracts";
2import { MinusIcon, PlusIcon, XIcon } from "lucide-react";
3import { useRef, useState } from "react";
4
5import { HtmlRenderDocument } from "../files/BrowserDocumentFrame";
6import { Button } from "../ui/button";
7import { Dialog, DialogPopup, DialogTitle } from "../ui/dialog";
8
9export function HtmlRenderDialog(props: {
10 readonly src: string;
11 readonly title: string;
12 readonly threadRef?: ScopedThreadRef | undefined;
13 readonly onClose: () => void;
14}) {
15 const [zoom, setZoom] = useState(1);
16 const closeButtonRef = useRef<HTMLButtonElement>(null);
17 return (
18 <Dialog open onOpenChange={(open) => !open && props.onClose()}>
19 <DialogPopup
20 variant="media"
21 showCloseButton={false}
22 bottomStickOnMobile={false}
23 className="row-start-1"
24 initialFocus={closeButtonRef}
25 >
26 <DialogTitle className="sr-only">Expanded {props.title} preview</DialogTitle>
27 <div className="relative w-[92vw]">
28 <Button
29 ref={closeButtonRef}
30 aria-label="Close"
31 size="icon-xs"
32 variant="media-close"
33 className="absolute right-0 -top-10 z-20"
34 onClick={props.onClose}
35 >
36 <XIcon />
37 </Button>
38 <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]">
39 <div className="h-[100cqh] w-[100cqw]" style={{ zoom }}>
40 <HtmlRenderDocument
41 src={props.src}
42 title={props.title}
43 threadRef={props.threadRef}
44 className="block size-full"
45 />
46 </div>
47 </div>
48 <div className="mt-2 flex min-w-0 items-center justify-center gap-3 text-xs text-white/80">
49 <span className="truncate">{props.title}</span>
50 <div className="flex shrink-0 items-center gap-1">
51 <Button
52 aria-label="Zoom out"
53 size="icon-sm"
54 variant="overlay"
55 disabled={zoom === 0.5}
56 onClick={() => setZoom((value) => value - 0.25)}
57 >
58 <MinusIcon />
59 </Button>
60 <Button
61 aria-label="Reset zoom"
62 size="sm"
63 variant="overlay"
64 onClick={() => setZoom(1)}
65 >
66 {Math.round(zoom * 100)}%
67 </Button>
68 <Button
69 aria-label="Zoom in"
70 size="icon-sm"
71 variant="overlay"
72 disabled={zoom === 2}
73 onClick={() => setZoom((value) => value + 0.25)}
74 >
75 <PlusIcon />
76 </Button>
77 </div>
78 </div>
79 </div>
80 </DialogPopup>
81 </Dialog>
82 );
83}
apps/web/src/components/chat/HtmlRenderFrame.tsx+31-16
......@@ -9,11 +9,12 @@ import { Maximize2Icon } from "lucide-react";
99import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
1010
1111import { useAssetUrlRefresh, useAssetUrlState } from "~/assets/assetUrls";
12import type { ChatFileAttachment } from "~/types";
1312
1413import { HtmlRenderDocument } from "../files/BrowserDocumentFrame";
1514import { Button } from "../ui/button";
15import { toastManager } from "../ui/toast";
1616import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
17import { HtmlRenderDialog } from "./HtmlRenderDialog";
1718import { observeResize } from "~/lib/observeResize";
1819
1920// A frame may load its URL a little after mounting.
......@@ -29,7 +30,7 @@ export function HtmlRenderFrame(props: {
2930 readonly environmentId: EnvironmentId;
3031 readonly htmlRender: HtmlRenderReference;
3132 readonly threadRef: ScopedThreadRef | undefined;
32 readonly onOpen: (attachment: ChatFileAttachment) => void;
33 readonly onFullscreenChange?: (fullscreen: boolean) => void;
3334}) {
3435 const { attachmentId, title } = props.htmlRender;
3536 // The frame takes the page's measured height at its own width, read before
......@@ -63,6 +64,13 @@ export function HtmlRenderFrame(props: {
6364 // page again; one near expiry is minted afresh, since a frame cannot report a
6465 // failed load. The page keeps its first URL for its lifetime.
6566 const [src, setSrc] = useState<string | null>(null);
67 const [expandedSrc, setExpandedSrc] = useState<string | null>(null);
68 const { onFullscreenChange } = props;
69 useEffect(() => {
70 if (expandedSrc === null) return;
71 onFullscreenChange?.(true);
72 return () => onFullscreenChange?.(false);
73 }, [expandedSrc, onFullscreenChange]);
6674 const [failed, setFailed] = useState(false);
6775 const assetUrl = useAssetUrlState(src === null ? props.environmentId : null, resource);
6876 const cachedUrl = assetUrl._tag === "Success" ? assetUrl.url : null;
......@@ -102,33 +110,40 @@ export function HtmlRenderFrame(props: {
102110 className="block size-full"
103111 onContentHeight={setContentHeight}
104112 />
105 <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">
113 <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">
106114 <Tooltip>
107115 <TooltipTrigger
108116 render={
109117 <Button
110 aria-label="Open in panel"
118 aria-label="Open full screen"
111119 size="icon-xs"
112120 variant="glass"
113 onClick={() =>
114 props.onOpen({
115 type: "file",
116 id: attachmentId,
117 name: fileName,
118 mimeType: "text/html",
119 // Unknown here; the preview leaves it out.
120 sizeBytes: 0,
121 htmlRender: true,
122 })
123 }
121 onClick={async () => {
122 const url = await refresh().catch(() => null);
123 if (url !== null) setExpandedSrc(url);
124 else
125 toastManager.add({
126 type: "error",
127 title: "Page unavailable",
128 description: "Reconnect to the environment and try again.",
129 });
130 }}
124131 />
125132 }
126133 >
127134 <Maximize2Icon className="size-3.5" />
128135 </TooltipTrigger>
129 <TooltipPopup side="left">Open in panel</TooltipPopup>
136 <TooltipPopup side="left">Open full screen</TooltipPopup>
130137 </Tooltip>
131138 </div>
139 {expandedSrc !== null ? (
140 <HtmlRenderDialog
141 src={expandedSrc}
142 title={title}
143 threadRef={props.threadRef}
144 onClose={() => setExpandedSrc(null)}
145 />
146 ) : null}
132147 </>
133148 ) : failed ? (
134149 <p className="flex size-full items-center justify-center text-muted-foreground text-xs">
apps/web/src/components/chat/MessagesTimeline.tsx+6-1
......@@ -3044,6 +3044,11 @@ function ProposedPlanTimelineRow({
30443044
30453045function HtmlRenderTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "html-render" }> }) {
30463046 const ctx = use(TimelineRowCtx);
3047 const { onAppFullscreenChange } = ctx;
3048 const onFullscreenChange = useCallback(
3049 (fullscreen: boolean) => onAppFullscreenChange(row.id, fullscreen),
3050 [onAppFullscreenChange, row.id],
3051 );
30473052
30483053 return (
30493054 <div className="min-w-0 px-1">
......@@ -3053,7 +3058,7 @@ function HtmlRenderTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "htm
30533058 environmentId={ctx.activeThreadEnvironmentId}
30543059 htmlRender={row.htmlRender}
30553060 threadRef={ctx.threadRef ?? undefined}
3056 onOpen={ctx.onFileOpen}
3061 onFullscreenChange={onFullscreenChange}
30573062 />
30583063 </div>
30593064 );
docs/user/html-renders.md+1-1
......@@ -2,7 +2,7 @@
22
33Agents 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.
44
5Pages 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.
5Pages 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.
66
77Agents 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.
88