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";...@@ -9,11 +9,12 @@ import { Maximize2Icon } from "lucide-react";
9import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";9import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
1010
11import { useAssetUrlRefresh, useAssetUrlState } from "~/assets/assetUrls";11import { useAssetUrlRefresh, useAssetUrlState } from "~/assets/assetUrls";
12import type { ChatFileAttachment } from "~/types";
1312
14import { HtmlRenderDocument } from "../files/BrowserDocumentFrame";13import { HtmlRenderDocument } from "../files/BrowserDocumentFrame";
15import { Button } from "../ui/button";14import { Button } from "../ui/button";
15import { toastManager } from "../ui/toast";
16import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";16import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
17import { HtmlRenderDialog } from "./HtmlRenderDialog";
17import { observeResize } from "~/lib/observeResize";18import { observeResize } from "~/lib/observeResize";
1819
19// A frame may load its URL a little after mounting.20// A frame may load its URL a little after mounting.
...@@ -29,7 +30,7 @@ export function HtmlRenderFrame(props: {...@@ -29,7 +30,7 @@ export function HtmlRenderFrame(props: {
29 readonly environmentId: EnvironmentId;30 readonly environmentId: EnvironmentId;
30 readonly htmlRender: HtmlRenderReference;31 readonly htmlRender: HtmlRenderReference;
31 readonly threadRef: ScopedThreadRef | undefined;32 readonly threadRef: ScopedThreadRef | undefined;
32 readonly onOpen: (attachment: ChatFileAttachment) => void;33 readonly onFullscreenChange?: (fullscreen: boolean) => void;
33}) {34}) {
34 const { attachmentId, title } = props.htmlRender;35 const { attachmentId, title } = props.htmlRender;
35 // The frame takes the page's measured height at its own width, read before36 // The frame takes the page's measured height at its own width, read before
...@@ -63,6 +64,13 @@ export function HtmlRenderFrame(props: {...@@ -63,6 +64,13 @@ export function HtmlRenderFrame(props: {
63 // page again; one near expiry is minted afresh, since a frame cannot report a64 // page again; one near expiry is minted afresh, since a frame cannot report a
64 // failed load. The page keeps its first URL for its lifetime.65 // failed load. The page keeps its first URL for its lifetime.
65 const [src, setSrc] = useState<string | null>(null);66 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]);
66 const [failed, setFailed] = useState(false);74 const [failed, setFailed] = useState(false);
67 const assetUrl = useAssetUrlState(src === null ? props.environmentId : null, resource);75 const assetUrl = useAssetUrlState(src === null ? props.environmentId : null, resource);
68 const cachedUrl = assetUrl._tag === "Success" ? assetUrl.url : null;76 const cachedUrl = assetUrl._tag === "Success" ? assetUrl.url : null;
...@@ -102,33 +110,40 @@ export function HtmlRenderFrame(props: {...@@ -102,33 +110,40 @@ export function HtmlRenderFrame(props: {
102 className="block size-full"110 className="block size-full"
103 onContentHeight={setContentHeight}111 onContentHeight={setContentHeight}
104 />112 />
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">
106 <Tooltip>114 <Tooltip>
107 <TooltipTrigger115 <TooltipTrigger
108 render={116 render={
109 <Button117 <Button
110 aria-label="Open in panel"118 aria-label="Open full screen"
111 size="icon-xs"119 size="icon-xs"
112 variant="glass"120 variant="glass"
113 onClick={() =>121 onClick={async () => {
114 props.onOpen({122 const url = await refresh().catch(() => null);
115 type: "file",123 if (url !== null) setExpandedSrc(url);
116 id: attachmentId,124 else
117 name: fileName,125 toastManager.add({
118 mimeType: "text/html",126 type: "error",
119 // Unknown here; the preview leaves it out.127 title: "Page unavailable",
120 sizeBytes: 0,128 description: "Reconnect to the environment and try again.",
121 htmlRender: true,129 });
122 })130 }}
123 }
124 />131 />
125 }132 }
126 >133 >
127 <Maximize2Icon className="size-3.5" />134 <Maximize2Icon className="size-3.5" />
128 </TooltipTrigger>135 </TooltipTrigger>
129 <TooltipPopup side="left">Open in panel</TooltipPopup>136 <TooltipPopup side="left">Open full screen</TooltipPopup>
130 </Tooltip>137 </Tooltip>
131 </div>138 </div>
139 {expandedSrc !== null ? (
140 <HtmlRenderDialog
141 src={expandedSrc}
142 title={title}
143 threadRef={props.threadRef}
144 onClose={() => setExpandedSrc(null)}
145 />
146 ) : null}
132 </>147 </>
133 ) : failed ? (148 ) : failed ? (
134 <p className="flex size-full items-center justify-center text-muted-foreground text-xs">149 <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({...@@ -3044,6 +3044,11 @@ function ProposedPlanTimelineRow({
30443044
3045function HtmlRenderTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "html-render" }> }) {3045function HtmlRenderTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "html-render" }> }) {
3046 const ctx = use(TimelineRowCtx);3046 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
3048 return (3053 return (
3049 <div className="min-w-0 px-1">3054 <div className="min-w-0 px-1">
...@@ -3053,7 +3058,7 @@ function HtmlRenderTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "htm...@@ -3053,7 +3058,7 @@ function HtmlRenderTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "htm
3053 environmentId={ctx.activeThreadEnvironmentId}3058 environmentId={ctx.activeThreadEnvironmentId}
3054 htmlRender={row.htmlRender}3059 htmlRender={row.htmlRender}
3055 threadRef={ctx.threadRef ?? undefined}3060 threadRef={ctx.threadRef ?? undefined}
3056 onOpen={ctx.onFileOpen}3061 onFullscreenChange={onFullscreenChange}
3057 />3062 />
3058 </div>3063 </div>
3059 );3064 );
docs/user/html-renders.md+1-1
...@@ -2,7 +2,7 @@...@@ -2,7 +2,7 @@
22
3Agents 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.3Agents 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
7Agents 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.7Agents 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