From 595288c895c3bdafb56b814e0be336aa89c436e1 Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Fri, 9 Oct 2026 20:12:57 +0200 Subject: [PATCH 1/3] feat(web): view visual replies full screen with zoom --- .../components/chat/HtmlRenderDialog.test.tsx | 140 ++++++++++++++++++ .../src/components/chat/HtmlRenderDialog.tsx | 55 +++++++ .../src/components/chat/HtmlRenderFrame.tsx | 47 +++++- .../src/components/chat/MessagesTimeline.tsx | 6 + apps/web/src/components/ui/dialog.tsx | 10 +- docs/user/html-renders.md | 2 +- 6 files changed, 254 insertions(+), 6 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 0000000000000..a423bb3137d87 --- /dev/null +++ b/apps/web/src/components/chat/HtmlRenderDialog.test.tsx @@ -0,0 +1,140 @@ +// @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 } from "@t3tools/contracts"; + +import { HtmlRenderDialog } from "./HtmlRenderDialog"; +import { HtmlRenderFrame } from "./HtmlRenderFrame"; + +const { refresh, showError } = vi.hoisted(() => ({ refresh: vi.fn(), showError: vi.fn() })); +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(); + 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( + undefined} + />, + ), + ); +} + +it("opens with a fresh URL while keeping the inline page loaded", async () => { + await renderInline(); + const inlineFrame = document.querySelector("iframe")!; + const originalSrc = inlineFrame.src; + 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); +}); + +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 0000000000000..47be3d4bd2f6b --- /dev/null +++ b/apps/web/src/components/chat/HtmlRenderDialog.tsx @@ -0,0 +1,55 @@ +import { MinusIcon, PlusIcon } from "lucide-react"; +import { useState } from "react"; + +import { HtmlRenderDocument } from "../files/BrowserDocumentFrame"; +import { Button } from "../ui/button"; +import { Dialog, DialogHeader, DialogPopup, DialogTitle } from "../ui/dialog"; + +export function HtmlRenderDialog(props: { + readonly src: string; + readonly title: string; + readonly onClose: () => void; +}) { + const [zoom, setZoom] = useState(1); + return ( + !open && props.onClose()}> + + +
+
+ {props.title} +
+
+ + + +
+
+
+
+
+ +
+
+
+
+ ); +} diff --git a/apps/web/src/components/chat/HtmlRenderFrame.tsx b/apps/web/src/components/chat/HtmlRenderFrame.tsx index 9661f59249d9b..b3e3c401b77cc 100644 --- a/apps/web/src/components/chat/HtmlRenderFrame.tsx +++ b/apps/web/src/components/chat/HtmlRenderFrame.tsx @@ -5,7 +5,7 @@ import { htmlRenderFrameHeight, type HtmlRenderReference, } from "@t3tools/shared/htmlRender"; -import { Maximize2Icon } from "lucide-react"; +import { Maximize2Icon, PanelRightIcon } from "lucide-react"; import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useAssetUrlRefresh, useAssetUrlState } from "~/assets/assetUrls"; @@ -13,7 +13,9 @@ 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"; // A frame may load its URL a little after mounting. const MIN_URL_LIFE_MS = 5 * 60_000; @@ -28,6 +30,7 @@ export function HtmlRenderFrame(props: { readonly environmentId: EnvironmentId; readonly htmlRender: HtmlRenderReference; 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 +66,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; @@ -101,7 +111,31 @@ export function HtmlRenderFrame(props: { className="block size-full" onContentHeight={setContentHeight} /> -
+
+ + { + 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 full screen + } > - + Open in panel
+ {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 8d2b445a22cea..6f5865d06171b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -2956,6 +2956,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 (

@@ -2965,6 +2970,7 @@ function HtmlRenderTimelineRow({ row }: { row: Extract
); diff --git a/apps/web/src/components/ui/dialog.tsx b/apps/web/src/components/ui/dialog.tsx index 463cedd79fba1..071b17924ee65 100644 --- a/apps/web/src/components/ui/dialog.tsx +++ b/apps/web/src/components/ui/dialog.tsx @@ -66,17 +66,21 @@ function DialogPopup({ }: DialogPrimitive.Popup.Props & { showCloseButton?: boolean; bottomStickOnMobile?: boolean; - variant?: "default" | "media"; + variant?: "default" | "media" | "fullscreen"; }) { return ( {/* Media opens from inside other overlays (a composer chip, a popover), so it sits above them. */} - + Date: Tue, 13 Oct 2026 18:51:35 +0200 Subject: [PATCH 2/3] fix(web): expand visual replies like image previews --- .../components/chat/HtmlRenderDialog.test.tsx | 5 ++ .../src/components/chat/HtmlRenderDialog.tsx | 55 +++++++++++++------ apps/web/src/components/ui/dialog.tsx | 10 +--- 3 files changed, 45 insertions(+), 25 deletions(-) diff --git a/apps/web/src/components/chat/HtmlRenderDialog.test.tsx b/apps/web/src/components/chat/HtmlRenderDialog.test.tsx index a423bb3137d87..59cf57de2de57 100644 --- a/apps/web/src/components/chat/HtmlRenderDialog.test.tsx +++ b/apps/web/src/components/chat/HtmlRenderDialog.test.tsx @@ -113,6 +113,10 @@ it("opens with a fresh URL while keeping the inline page loaded", async () => { await renderInline(); 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(); @@ -123,6 +127,7 @@ it("opens with a fresh URL while keeping the inline page loaded", async () => { await click("Close"); expect(document.querySelector('[role="dialog"]')).toBeNull(); expect(document.querySelector("iframe")).toBe(inlineFrame); + expect(document.activeElement).toBe(expandButton); }); it.each([null, new Error("Disconnected")])( diff --git a/apps/web/src/components/chat/HtmlRenderDialog.tsx b/apps/web/src/components/chat/HtmlRenderDialog.tsx index 47be3d4bd2f6b..92274667eb268 100644 --- a/apps/web/src/components/chat/HtmlRenderDialog.tsx +++ b/apps/web/src/components/chat/HtmlRenderDialog.tsx @@ -1,9 +1,9 @@ -import { MinusIcon, PlusIcon } from "lucide-react"; -import { useState } from "react"; +import { MinusIcon, PlusIcon, XIcon } from "lucide-react"; +import { useRef, useState } from "react"; import { HtmlRenderDocument } from "../files/BrowserDocumentFrame"; import { Button } from "../ui/button"; -import { Dialog, DialogHeader, DialogPopup, DialogTitle } from "../ui/dialog"; +import { Dialog, DialogPopup, DialogTitle } from "../ui/dialog"; export function HtmlRenderDialog(props: { readonly src: string; @@ -11,31 +11,57 @@ export function HtmlRenderDialog(props: { readonly onClose: () => void; }) { const [zoom, setZoom] = useState(1); + const closeButtonRef = useRef(null); return ( !open && props.onClose()}> - - -
-
- {props.title} + + Expanded {props.title} preview +
+ +
+
+
-
+
+
+ {props.title} +
-
- -
-
- -
diff --git a/apps/web/src/components/ui/dialog.tsx b/apps/web/src/components/ui/dialog.tsx index 071b17924ee65..463cedd79fba1 100644 --- a/apps/web/src/components/ui/dialog.tsx +++ b/apps/web/src/components/ui/dialog.tsx @@ -66,21 +66,17 @@ function DialogPopup({ }: DialogPrimitive.Popup.Props & { showCloseButton?: boolean; bottomStickOnMobile?: boolean; - variant?: "default" | "media" | "fullscreen"; + variant?: "default" | "media"; }) { return ( {/* Media opens from inside other overlays (a composer chip, a popover), so it sits above them. */} - + Date: Wed, 14 Oct 2026 04:44:17 +0200 Subject: [PATCH 3/3] fix(web): keep only full-screen visual reply action --- .../components/chat/HtmlRenderDialog.test.tsx | 4 +-- .../src/components/chat/HtmlRenderFrame.tsx | 29 +------------------ .../src/components/chat/MessagesTimeline.tsx | 1 - docs/user/html-renders.md | 2 +- 4 files changed, 4 insertions(+), 32 deletions(-) diff --git a/apps/web/src/components/chat/HtmlRenderDialog.test.tsx b/apps/web/src/components/chat/HtmlRenderDialog.test.tsx index d6306c6d9d0f5..fbf0b4b536c0e 100644 --- a/apps/web/src/components/chat/HtmlRenderDialog.test.tsx +++ b/apps/web/src/components/chat/HtmlRenderDialog.test.tsx @@ -118,14 +118,14 @@ async function renderInline() { environmentId: EnvironmentId.make("environment"), threadId: ThreadId.make("thread"), }} - onOpen={() => undefined} />, ), ); } -it("opens with a fresh URL while keeping the inline page loaded", async () => { +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( diff --git a/apps/web/src/components/chat/HtmlRenderFrame.tsx b/apps/web/src/components/chat/HtmlRenderFrame.tsx index 1dd29d7fefd06..5133c0ea55cd5 100644 --- a/apps/web/src/components/chat/HtmlRenderFrame.tsx +++ b/apps/web/src/components/chat/HtmlRenderFrame.tsx @@ -5,11 +5,10 @@ import { htmlRenderFrameHeight, type HtmlRenderReference, } from "@t3tools/shared/htmlRender"; -import { Maximize2Icon, PanelRightIcon } from "lucide-react"; +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"; @@ -31,7 +30,6 @@ 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; @@ -137,31 +135,6 @@ export function HtmlRenderFrame(props: { Open full screen - - - props.onOpen({ - type: "file", - id: attachmentId, - name: fileName, - mimeType: "text/html", - // Unknown here; the preview leaves it out. - sizeBytes: 0, - htmlRender: true, - }) - } - /> - } - > - - - Open in panel -
{expandedSrc !== null ? ( diff --git a/docs/user/html-renders.md b/docs/user/html-renders.md index 825699601f38c..f7e076a0f98fc 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 screen. On web and desktop, use the zoom controls to see more detail, or open the page in the side panel to 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.