authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-01 19:31:56-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-01 19:31:56-07:00
logd937e30759ceae421bf3cc1b84414de4b67df76c
tree238785e875cf50937f55e84066875aa6ad55b6f4
parentf46a709ee487e1e6e04423fcb7032a26eb103129
signature Signed by PGP key B5690EEEBB952194

feat(web): render HTML and PDF files in the file viewer (#9143)

Co-authored-by: Claude Code <noreply@anthropic.com>

2 files changed, 120 insertions(+), 20 deletions(-)

apps/web/src/components/files/FilePreviewPanel.tsx+109-17
......@@ -92,6 +92,7 @@ interface FilePreviewPanelProps {
9292
9393const FILE_EXPLORER_STORAGE_KEY = "t3code.fileExplorerOpen";
9494const RENDER_MARKDOWN_STORAGE_KEY = "t3code.renderMarkdown";
95const RENDER_BROWSER_FILE_STORAGE_KEY = "t3code.renderBrowserFile";
9596const FILE_SAVE_DEBOUNCE_MS = 500;
9697const FILE_LINK_REVEAL_ATTRIBUTE = "data-file-link-reveal";
9798const FILE_LINK_REVEAL_UNSAFE_CSS = `
......@@ -197,6 +198,69 @@ function WorkspaceImagePreview(props: {
197198 );
198199}
199200
201const isPdfPreviewFile = (path: string): boolean => /\.pdf$/i.test(path.split(/[?#]/, 1)[0] ?? "");
202
203/**
204 * Renders an HTML or PDF file in place from its signed asset URL. HTML runs in
205 * a sandboxed frame with an opaque origin, so a page cannot reach the app's
206 * session or storage. A file inside the workspace may load sibling assets; a
207 * host file outside it is served on its own.
208 */
209function WorkspaceBrowserPreview(props: {
210 readonly environmentId: EnvironmentId;
211 readonly threadRef: ScopedThreadRef;
212 readonly absolutePath: string;
213 readonly workspaceRoot: string;
214 readonly title: string;
215 readonly workspaceMutationId: string | null;
216}) {
217 const insideWorkspace =
218 mediaFileReference(props.absolutePath, props.workspaceRoot).relativePath !== undefined;
219 const resource = useMemo(
220 () => ({
221 _tag: insideWorkspace ? ("workspace-file" as const) : ("media-file" as const),
222 threadId: props.threadRef.threadId,
223 path: props.absolutePath,
224 }),
225 [insideWorkspace, props.threadRef.threadId, props.absolutePath],
226 );
227 const assetUrl = useAssetUrlState(props.environmentId, resource);
228 const revisionSuffix =
229 props.workspaceMutationId === null
230 ? ""
231 : `${assetUrl._tag === "Success" && assetUrl.url.includes("?") ? "&" : "?"}workspace-revision=${encodeURIComponent(props.workspaceMutationId)}`;
232
233 if (assetUrl._tag === "Failure") {
234 return (
235 <div className="flex min-h-0 flex-1 items-center justify-center px-6 text-center text-xs leading-relaxed text-destructive">
236 Unable to load file preview.
237 </div>
238 );
239 }
240 if (assetUrl._tag !== "Success") {
241 return (
242 <div className="flex min-h-0 flex-1 items-center justify-center text-muted-foreground">
243 <LoaderCircle className="size-5 animate-spin" />
244 </div>
245 );
246 }
247 const src = `${assetUrl.url}${revisionSuffix}`;
248 const className = "min-h-0 flex-1 border-0 bg-white";
249 // The built-in PDF viewer needs an unsandboxed frame; a PDF runs no scripts.
250 return isPdfPreviewFile(props.absolutePath) ? (
251 // oxlint-disable-next-line react/iframe-missing-sandbox
252 <iframe key={src} src={src} title={props.title} className={className} />
253 ) : (
254 <iframe
255 key={src}
256 src={src}
257 title={props.title}
258 className={className}
259 sandbox="allow-scripts allow-forms allow-popups allow-modals"
260 />
261 );
262}
263
200264function WorkspaceVideoPreview(props: {
201265 readonly environmentId: EnvironmentId;
202266 readonly threadRef: ScopedThreadRef;
......@@ -839,6 +903,11 @@ function RenderedMarkdownSurface({
839903 );
840904}
841905
906function renderedToggleLabel(isMarkdown: boolean, rendered: boolean): string {
907 if (isMarkdown) return rendered ? "Show markdown source" : "Show rendered markdown";
908 return rendered ? "Show HTML source" : "Show rendered page";
909}
910
842911function initialExplorerOpen(): boolean {
843912 try {
844913 return getLocalStorageItem(FILE_EXPLORER_STORAGE_KEY, Schema.Boolean) ?? true;
......@@ -878,9 +947,12 @@ export default function FilePreviewPanel({
878947 const isVideo = relativePath !== null && isWorkspaceVideoPreviewPath(relativePath);
879948 const isImage = relativePath !== null && !isVideo && isWorkspaceImagePreviewPath(relativePath);
880949 const isMedia = isImage || isVideo;
950 // PDFs have no text to show; HTML has, and can toggle between page and source.
951 const isPdf = relativePath !== null && isPdfPreviewFile(relativePath);
952 const isHtml = relativePath !== null && !isPdf && isBrowserPreviewFile(relativePath);
881953 // A file outside the workspace (an absolute path) is shown, never edited.
882954 const isHostFile = relativePath !== null && isAbsolutePath(relativePath);
883 const file = useProjectFileQuery(environmentId, cwd, relativePath, !isMedia);
955 const file = useProjectFileQuery(environmentId, cwd, relativePath, !isMedia && !isPdf);
884956 const [explorerOpen, setExplorerOpen] = useState(initialExplorerOpen);
885957 // Reading markdown rendered is a preference, not a property of one file. Keeping
886958 // it on the panel meant a thread switch dropped it and forced source back.
......@@ -889,6 +961,11 @@ export default function FilePreviewPanel({
889961 false,
890962 Schema.Boolean,
891963 );
964 const [renderBrowserFilePreferred, setRenderBrowserFilePreferred] = useLocalStorage(
965 RENDER_BROWSER_FILE_STORAGE_KEY,
966 true,
967 Schema.Boolean,
968 );
892969 // Paired with the path on purpose: each file surface counts its reveals from
893970 // one, so a bare id would let a dismissed reveal on one file swallow the first
894971 // reveal on the next.
......@@ -898,11 +975,16 @@ export default function FilePreviewPanel({
898975 const breadcrumbRef = useRef<HTMLDivElement>(null);
899976 const isMarkdown = relativePath ? isMarkdownPreviewFile(relativePath) : false;
900977 // A reveal still wins over the preference: the line only exists in the source.
901 const renderMarkdown =
902 isMarkdown &&
903 renderMarkdownPreferred &&
904 (revealLine === null ||
905 (handledReveal?.path === relativePath && handledReveal.requestId === revealRequestId));
978 const revealHandled =
979 revealLine === null ||
980 (handledReveal?.path === relativePath && handledReveal.requestId === revealRequestId);
981 const renderMarkdown = isMarkdown && renderMarkdownPreferred && revealHandled;
982 const renderBrowserFile = isPdf || (isHtml && renderBrowserFilePreferred && revealHandled);
983 const canToggleRendered = isMarkdown || isHtml;
984 const rendered = isMarkdown ? renderMarkdown : renderBrowserFile;
985 const setRenderedPreferred = isMarkdown
986 ? setRenderMarkdownPreferred
987 : setRenderBrowserFilePreferred;
906988 const canOpenInBrowser =
907989 relativePath !== null &&
908990 !isVideo &&
......@@ -915,7 +997,7 @@ export default function FilePreviewPanel({
915997 );
916998 const onFilePostRender = useFileLineReveal(relativePath, revealLine, revealRequestId);
917999 useWorkspaceMutationRefresh({
918 enabled: relativePath !== null && !isMedia && !selectedFilePending,
1000 enabled: relativePath !== null && !isMedia && !isPdf && !selectedFilePending,
9191001 mutationId: workspaceMutationId,
9201002 refresh: file.refresh,
9211003 resourceKey: `file:${environmentId}:${cwd}:${relativePath ?? ""}`,
......@@ -1023,32 +1105,30 @@ export default function FilePreviewPanel({
10231105 enableShortcut={false}
10241106 />
10251107 ) : null}
1026 {isMarkdown ? (
1108 {canToggleRendered ? (
10271109 <Tooltip>
10281110 <TooltipTrigger
10291111 render={
10301112 <Toggle
10311113 className="shrink-0"
1032 pressed={renderMarkdown}
1114 pressed={rendered}
10331115 onPressedChange={(pressed) => {
1034 setRenderMarkdownPreferred(pressed);
1116 setRenderedPreferred(pressed);
10351117 setHandledReveal(
10361118 pressed && relativePath !== null
10371119 ? { path: relativePath, requestId: revealRequestId }
10381120 : null,
10391121 );
10401122 }}
1041 aria-label={renderMarkdown ? "Show markdown source" : "Show rendered markdown"}
1123 aria-label={renderedToggleLabel(isMarkdown, rendered)}
10421124 variant="ghost"
10431125 size="sm"
10441126 >
1045 {renderMarkdown ? <Code2 className="size-3.5" /> : <Eye className="size-3.5" />}
1127 {rendered ? <Code2 className="size-3.5" /> : <Eye className="size-3.5" />}
10461128 </Toggle>
10471129 }
10481130 />
1049 <TooltipPopup>
1050 {renderMarkdown ? "Show markdown source" : "Show rendered markdown"}
1051 </TooltipPopup>
1131 <TooltipPopup>{renderedToggleLabel(isMarkdown, rendered)}</TooltipPopup>
10521132 </Tooltip>
10531133 ) : null}
10541134 {canOpenInBrowser ? (
......@@ -1091,7 +1171,7 @@ export default function FilePreviewPanel({
10911171 </Tooltip>
10921172 </div>
10931173 ) : null}
1094 {relativePath && !isMedia && file.data?.truncated ? (
1174 {relativePath && !isMedia && !renderBrowserFile && file.data?.truncated ? (
10951175 <div className="shrink-0 border-b border-warning/20 bg-warning-surface px-3 py-1.5 text-[11px] text-warning-foreground">
10961176 Preview limited to the first 1 MB of a {file.data.byteLength.toLocaleString()} byte file.
10971177 </div>
......@@ -1123,6 +1203,16 @@ export default function FilePreviewPanel({
11231203 alt={relativePath}
11241204 workspaceMutationId={workspaceMutationId}
11251205 />
1206 ) : relativePath && renderBrowserFile && absolutePath ? (
1207 <WorkspaceBrowserPreview
1208 key={absolutePath}
1209 environmentId={environmentId}
1210 threadRef={threadRef}
1211 absolutePath={absolutePath}
1212 workspaceRoot={cwd}
1213 title={relativePath}
1214 workspaceMutationId={workspaceMutationId}
1215 />
11261216 ) : relativePath && file.error && file.data === null ? (
11271217 <div className="flex min-h-0 flex-1 items-center justify-center px-6 text-center text-xs leading-relaxed text-destructive">
11281218 {file.error}
......@@ -1204,7 +1294,9 @@ export default function FilePreviewPanel({
12041294 selectedPathRevealId={revealRequestId}
12051295 onOpenFile={onOpenFile}
12061296 workspaceMutationId={workspaceMutationId}
1207 {...(relativePath && !isMedia ? { onRefreshSelectedFile: file.refresh } : {})}
1297 {...(relativePath && !isMedia && !isPdf
1298 ? { onRefreshSelectedFile: file.refresh }
1299 : {})}
12081300 />
12091301 </aside>
12101302 ) : null}
docs/user/composer.md+11-3
......@@ -99,9 +99,17 @@ are not supported; use the Markdown embed syntax above.
9999When an agent links to a file it wrote outside the workspace, such as a Markdown report in
100100`/tmp`, select the link to open it in the file viewer. The viewer shows the file read-only, with
101101rendered Markdown available as usual; it cannot edit files outside the workspace. HTML and PDF
102files outside the workspace open the same way as ones inside it, including the integrated browser
103where it is available. Because such a file is served on its own, an HTML page outside the workspace
104cannot load scripts, styles, or images from files beside it.
102files outside the workspace open the same way as ones inside it. Because such a file is served on
103its own, an HTML page outside the workspace cannot load scripts, styles, or images from files beside
104it.
105
106## HTML and PDF files in the file viewer
107
108On web and desktop, the file viewer shows HTML and PDF files as a rendered page. Use the
109source toggle in the viewer's header to switch an HTML file between the page and its markup; the
110choice persists like the rendered-Markdown toggle. A link to a line always opens the source. HTML
111runs in an isolated frame with no access to your T3 Code session. On desktop, the integrated
112browser remains available from the same header for a full browser view.
105113
106114## Changing projects
107115