| author | |
| committer | |
| log | aba3a6ddc0d2625739cb65c333f6052a4a35bc71 |
| tree | 5aef92dedba5e4a43f28b1f896a16380ccf84815 |
| parent | 1ce8089ebd3317dad220f49deb541654c2f05fb5 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | ||
| 3 | import { act } from "react"; | |
| 4 | import { createRoot, type Root } from "react-dom/client"; | |
| 5 | import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; | |
| 6 | import { htmlRenderTheme, HTML_RENDER_DEFAULT_FONTS } from "@t3tools/shared/htmlRender"; | |
| 7 | import { T3_CODE_DARK_THEME_COLORS } from "@t3tools/shared/themePalettes"; | |
| 8 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; | |
| 9 | ||
| 10 | import { HtmlRenderDialog } from "./HtmlRenderDialog"; | |
| 11 | import { HtmlRenderFrame } from "./HtmlRenderFrame"; | |
| 12 | ||
| 13 | const { refresh, showError, openLink, openChangeRequestLink } = vi.hoisted(() => ({ | |
| 14 | refresh: vi.fn(), | |
| 15 | showError: vi.fn(), | |
| 16 | openLink: vi.fn(), | |
| 17 | openChangeRequestLink: vi.fn(), | |
| 18 | })); | |
| 19 | vi.mock("~/browser/useOpenLink", () => ({ useOpenLink: () => openLink })); | |
| 20 | vi.mock("~/lib/openPullRequestLink", () => ({ | |
| 21 | useOpenChangeRequestLink: () => openChangeRequestLink, | |
| 22 | })); | |
| 23 | vi.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 | })); | |
| 31 | vi.mock("../ui/toast", () => ({ toastManager: { add: showError } })); | |
| 32 | ||
| 33 | vi.mock("~/hooks/useHtmlRenderTheme", () => ({ | |
| 34 | useHtmlRenderTheme: () => | |
| 35 | htmlRenderTheme(T3_CODE_DARK_THEME_COLORS, "dark", HTML_RENDER_DEFAULT_FONTS), | |
| 36 | })); | |
| 37 | ||
| 38 | let root: Root; | |
| 39 | let container: HTMLDivElement; | |
| 40 | const onClose = vi.fn(); | |
| 41 | ||
| 42 | beforeEach(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 | ||
| 71 | afterEach(async () => { | |
| 72 | await act(() => root.unmount()); | |
| 73 | container.remove(); | |
| 74 | vi.unstubAllGlobals(); | |
| 75 | }); | |
| 76 | ||
| 77 | async 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 | ||
| 83 | it("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 | ||
| 99 | it("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 | ||
| 111 | async 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 | ||
| 126 | it("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 | ||
| 148 | it("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 | ||
| 173 | it.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 @@ |
| 1 | import type { ScopedThreadRef } from "@t3tools/contracts"; | |
| 2 | import { MinusIcon, PlusIcon, XIcon } from "lucide-react"; | |
| 3 | import { useRef, useState } from "react"; | |
| 4 | ||
| 5 | import { HtmlRenderDocument } from "../files/BrowserDocumentFrame"; | |
| 6 | import { Button } from "../ui/button"; | |
| 7 | import { Dialog, DialogPopup, DialogTitle } from "../ui/dialog"; | |
| 8 | ||
| 9 | export 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 | 9 | import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; |
| 10 | 10 | |
| 11 | 11 | import { useAssetUrlRefresh, useAssetUrlState } from "~/assets/assetUrls"; |
| 12 | import type { ChatFileAttachment } from "~/types"; | |
| 13 | 12 | |
| 14 | 13 | import { HtmlRenderDocument } from "../files/BrowserDocumentFrame"; |
| 15 | 14 | import { Button } from "../ui/button"; |
| 15 | import { toastManager } from "../ui/toast"; | |
| 16 | 16 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 17 | import { HtmlRenderDialog } from "./HtmlRenderDialog"; | |
| 17 | 18 | import { observeResize } from "~/lib/observeResize"; |
| 18 | 19 | |
| 19 | 20 | // A frame may load its URL a little after mounting. |
| ... | ... | @@ -29,7 +30,7 @@ export function HtmlRenderFrame(props: { |
| 29 | 30 | readonly environmentId: EnvironmentId; |
| 30 | 31 | readonly htmlRender: HtmlRenderReference; |
| 31 | 32 | readonly threadRef: ScopedThreadRef | undefined; |
| 32 | readonly onOpen: (attachment: ChatFileAttachment) => void; | |
| 33 | readonly onFullscreenChange?: (fullscreen: boolean) => void; | |
| 33 | 34 | }) { |
| 34 | 35 | const { attachmentId, title } = props.htmlRender; |
| 35 | 36 | // The frame takes the page's measured height at its own width, read before |
| ... | ... | @@ -63,6 +64,13 @@ export function HtmlRenderFrame(props: { |
| 63 | 64 | // page again; one near expiry is minted afresh, since a frame cannot report a |
| 64 | 65 | // failed load. The page keeps its first URL for its lifetime. |
| 65 | 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 | 74 | const [failed, setFailed] = useState(false); |
| 67 | 75 | const assetUrl = useAssetUrlState(src === null ? props.environmentId : null, resource); |
| 68 | 76 | const cachedUrl = assetUrl._tag === "Success" ? assetUrl.url : null; |
| ... | ... | @@ -102,33 +110,40 @@ export function HtmlRenderFrame(props: { |
| 102 | 110 | className="block size-full" |
| 103 | 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 | 114 | <Tooltip> |
| 107 | 115 | <TooltipTrigger |
| 108 | 116 | render={ |
| 109 | 117 | <Button |
| 110 | aria-label="Open in panel" | |
| 118 | aria-label="Open full screen" | |
| 111 | 119 | size="icon-xs" |
| 112 | 120 | 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 | }} | |
| 124 | 131 | /> |
| 125 | 132 | } |
| 126 | 133 | > |
| 127 | 134 | <Maximize2Icon className="size-3.5" /> |
| 128 | 135 | </TooltipTrigger> |
| 129 | <TooltipPopup side="left">Open in panel</TooltipPopup> | |
| 136 | <TooltipPopup side="left">Open full screen</TooltipPopup> | |
| 130 | 137 | </Tooltip> |
| 131 | 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 | 148 | ) : failed ? ( |
| 134 | 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 | 3044 | |
| 3045 | 3045 | function HtmlRenderTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "html-render" }> }) { |
| 3046 | 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 | ); | |
| 3047 | 3052 | |
| 3048 | 3053 | return ( |
| 3049 | 3054 | <div className="min-w-0 px-1"> |
| ... | ... | @@ -3053,7 +3058,7 @@ function HtmlRenderTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "htm |
| 3053 | 3058 | environmentId={ctx.activeThreadEnvironmentId} |
| 3054 | 3059 | htmlRender={row.htmlRender} |
| 3055 | 3060 | threadRef={ctx.threadRef ?? undefined} |
| 3056 | onOpen={ctx.onFileOpen} | |
| 3061 | onFullscreenChange={onFullscreenChange} | |
| 3057 | 3062 | /> |
| 3058 | 3063 | </div> |
| 3059 | 3064 | ); |
docs/user/html-renders.md+1-1| ... | ... | @@ -2,7 +2,7 @@ |
| 2 | 2 | |
| 3 | 3 | 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. |
| 4 | 4 | |
| 5 | 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. | |
| 5 | 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. | |
| 6 | 6 | |
| 7 | 7 | 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. |
| 8 | 8 |