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 {...@@ -92,6 +92,7 @@ interface FilePreviewPanelProps {
9292
93const FILE_EXPLORER_STORAGE_KEY = "t3code.fileExplorerOpen";93const FILE_EXPLORER_STORAGE_KEY = "t3code.fileExplorerOpen";
94const RENDER_MARKDOWN_STORAGE_KEY = "t3code.renderMarkdown";94const RENDER_MARKDOWN_STORAGE_KEY = "t3code.renderMarkdown";
95const RENDER_BROWSER_FILE_STORAGE_KEY = "t3code.renderBrowserFile";
95const FILE_SAVE_DEBOUNCE_MS = 500;96const FILE_SAVE_DEBOUNCE_MS = 500;
96const FILE_LINK_REVEAL_ATTRIBUTE = "data-file-link-reveal";97const FILE_LINK_REVEAL_ATTRIBUTE = "data-file-link-reveal";
97const FILE_LINK_REVEAL_UNSAFE_CSS = `98const FILE_LINK_REVEAL_UNSAFE_CSS = `
...@@ -197,6 +198,69 @@ function WorkspaceImagePreview(props: {...@@ -197,6 +198,69 @@ function WorkspaceImagePreview(props: {
197 );198 );
198}199}
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
200function WorkspaceVideoPreview(props: {264function WorkspaceVideoPreview(props: {
201 readonly environmentId: EnvironmentId;265 readonly environmentId: EnvironmentId;
202 readonly threadRef: ScopedThreadRef;266 readonly threadRef: ScopedThreadRef;
...@@ -839,6 +903,11 @@ function RenderedMarkdownSurface({...@@ -839,6 +903,11 @@ function RenderedMarkdownSurface({
839 );903 );
840}904}
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
842function initialExplorerOpen(): boolean {911function initialExplorerOpen(): boolean {
843 try {912 try {
844 return getLocalStorageItem(FILE_EXPLORER_STORAGE_KEY, Schema.Boolean) ?? true;913 return getLocalStorageItem(FILE_EXPLORER_STORAGE_KEY, Schema.Boolean) ?? true;
...@@ -878,9 +947,12 @@ export default function FilePreviewPanel({...@@ -878,9 +947,12 @@ export default function FilePreviewPanel({
878 const isVideo = relativePath !== null && isWorkspaceVideoPreviewPath(relativePath);947 const isVideo = relativePath !== null && isWorkspaceVideoPreviewPath(relativePath);
879 const isImage = relativePath !== null && !isVideo && isWorkspaceImagePreviewPath(relativePath);948 const isImage = relativePath !== null && !isVideo && isWorkspaceImagePreviewPath(relativePath);
880 const isMedia = isImage || isVideo;949 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);
881 // A file outside the workspace (an absolute path) is shown, never edited.953 // A file outside the workspace (an absolute path) is shown, never edited.
882 const isHostFile = relativePath !== null && isAbsolutePath(relativePath);954 const isHostFile = relativePath !== null && isAbsolutePath(relativePath);
883 const file = useProjectFileQuery(environmentId, cwd, relativePath, !isMedia);955 const file = useProjectFileQuery(environmentId, cwd, relativePath, !isMedia && !isPdf);
884 const [explorerOpen, setExplorerOpen] = useState(initialExplorerOpen);956 const [explorerOpen, setExplorerOpen] = useState(initialExplorerOpen);
885 // Reading markdown rendered is a preference, not a property of one file. Keeping957 // Reading markdown rendered is a preference, not a property of one file. Keeping
886 // it on the panel meant a thread switch dropped it and forced source back.958 // it on the panel meant a thread switch dropped it and forced source back.
...@@ -889,6 +961,11 @@ export default function FilePreviewPanel({...@@ -889,6 +961,11 @@ export default function FilePreviewPanel({
889 false,961 false,
890 Schema.Boolean,962 Schema.Boolean,
891 );963 );
964 const [renderBrowserFilePreferred, setRenderBrowserFilePreferred] = useLocalStorage(
965 RENDER_BROWSER_FILE_STORAGE_KEY,
966 true,
967 Schema.Boolean,
968 );
892 // Paired with the path on purpose: each file surface counts its reveals from969 // Paired with the path on purpose: each file surface counts its reveals from
893 // one, so a bare id would let a dismissed reveal on one file swallow the first970 // one, so a bare id would let a dismissed reveal on one file swallow the first
894 // reveal on the next.971 // reveal on the next.
...@@ -898,11 +975,16 @@ export default function FilePreviewPanel({...@@ -898,11 +975,16 @@ export default function FilePreviewPanel({
898 const breadcrumbRef = useRef<HTMLDivElement>(null);975 const breadcrumbRef = useRef<HTMLDivElement>(null);
899 const isMarkdown = relativePath ? isMarkdownPreviewFile(relativePath) : false;976 const isMarkdown = relativePath ? isMarkdownPreviewFile(relativePath) : false;
900 // A reveal still wins over the preference: the line only exists in the source.977 // A reveal still wins over the preference: the line only exists in the source.
901 const renderMarkdown =978 const revealHandled =
902 isMarkdown &&979 revealLine === null ||
903 renderMarkdownPreferred &&980 (handledReveal?.path === relativePath && handledReveal.requestId === revealRequestId);
904 (revealLine === null ||981 const renderMarkdown = isMarkdown && renderMarkdownPreferred && revealHandled;
905 (handledReveal?.path === relativePath && handledReveal.requestId === revealRequestId));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;
906 const canOpenInBrowser =988 const canOpenInBrowser =
907 relativePath !== null &&989 relativePath !== null &&
908 !isVideo &&990 !isVideo &&
...@@ -915,7 +997,7 @@ export default function FilePreviewPanel({...@@ -915,7 +997,7 @@ export default function FilePreviewPanel({
915 );997 );
916 const onFilePostRender = useFileLineReveal(relativePath, revealLine, revealRequestId);998 const onFilePostRender = useFileLineReveal(relativePath, revealLine, revealRequestId);
917 useWorkspaceMutationRefresh({999 useWorkspaceMutationRefresh({
918 enabled: relativePath !== null && !isMedia && !selectedFilePending,1000 enabled: relativePath !== null && !isMedia && !isPdf && !selectedFilePending,
919 mutationId: workspaceMutationId,1001 mutationId: workspaceMutationId,
920 refresh: file.refresh,1002 refresh: file.refresh,
921 resourceKey: `file:${environmentId}:${cwd}:${relativePath ?? ""}`,1003 resourceKey: `file:${environmentId}:${cwd}:${relativePath ?? ""}`,
...@@ -1023,32 +1105,30 @@ export default function FilePreviewPanel({...@@ -1023,32 +1105,30 @@ export default function FilePreviewPanel({
1023 enableShortcut={false}1105 enableShortcut={false}
1024 />1106 />
1025 ) : null}1107 ) : null}
1026 {isMarkdown ? (1108 {canToggleRendered ? (
1027 <Tooltip>1109 <Tooltip>
1028 <TooltipTrigger1110 <TooltipTrigger
1029 render={1111 render={
1030 <Toggle1112 <Toggle
1031 className="shrink-0"1113 className="shrink-0"
1032 pressed={renderMarkdown}1114 pressed={rendered}
1033 onPressedChange={(pressed) => {1115 onPressedChange={(pressed) => {
1034 setRenderMarkdownPreferred(pressed);1116 setRenderedPreferred(pressed);
1035 setHandledReveal(1117 setHandledReveal(
1036 pressed && relativePath !== null1118 pressed && relativePath !== null
1037 ? { path: relativePath, requestId: revealRequestId }1119 ? { path: relativePath, requestId: revealRequestId }
1038 : null,1120 : null,
1039 );1121 );
1040 }}1122 }}
1041 aria-label={renderMarkdown ? "Show markdown source" : "Show rendered markdown"}1123 aria-label={renderedToggleLabel(isMarkdown, rendered)}
1042 variant="ghost"1124 variant="ghost"
1043 size="sm"1125 size="sm"
1044 >1126 >
1045 {renderMarkdown ? <Code2 className="size-3.5" /> : <Eye className="size-3.5" />}1127 {rendered ? <Code2 className="size-3.5" /> : <Eye className="size-3.5" />}
1046 </Toggle>1128 </Toggle>
1047 }1129 }
1048 />1130 />
1049 <TooltipPopup>1131 <TooltipPopup>{renderedToggleLabel(isMarkdown, rendered)}</TooltipPopup>
1050 {renderMarkdown ? "Show markdown source" : "Show rendered markdown"}
1051 </TooltipPopup>
1052 </Tooltip>1132 </Tooltip>
1053 ) : null}1133 ) : null}
1054 {canOpenInBrowser ? (1134 {canOpenInBrowser ? (
...@@ -1091,7 +1171,7 @@ export default function FilePreviewPanel({...@@ -1091,7 +1171,7 @@ export default function FilePreviewPanel({
1091 </Tooltip>1171 </Tooltip>
1092 </div>1172 </div>
1093 ) : null}1173 ) : null}
1094 {relativePath && !isMedia && file.data?.truncated ? (1174 {relativePath && !isMedia && !renderBrowserFile && file.data?.truncated ? (
1095 <div className="shrink-0 border-b border-warning/20 bg-warning-surface px-3 py-1.5 text-[11px] text-warning-foreground">1175 <div className="shrink-0 border-b border-warning/20 bg-warning-surface px-3 py-1.5 text-[11px] text-warning-foreground">
1096 Preview limited to the first 1 MB of a {file.data.byteLength.toLocaleString()} byte file.1176 Preview limited to the first 1 MB of a {file.data.byteLength.toLocaleString()} byte file.
1097 </div>1177 </div>
...@@ -1123,6 +1203,16 @@ export default function FilePreviewPanel({...@@ -1123,6 +1203,16 @@ export default function FilePreviewPanel({
1123 alt={relativePath}1203 alt={relativePath}
1124 workspaceMutationId={workspaceMutationId}1204 workspaceMutationId={workspaceMutationId}
1125 />1205 />
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 />
1126 ) : relativePath && file.error && file.data === null ? (1216 ) : relativePath && file.error && file.data === null ? (
1127 <div className="flex min-h-0 flex-1 items-center justify-center px-6 text-center text-xs leading-relaxed text-destructive">1217 <div className="flex min-h-0 flex-1 items-center justify-center px-6 text-center text-xs leading-relaxed text-destructive">
1128 {file.error}1218 {file.error}
...@@ -1204,7 +1294,9 @@ export default function FilePreviewPanel({...@@ -1204,7 +1294,9 @@ export default function FilePreviewPanel({
1204 selectedPathRevealId={revealRequestId}1294 selectedPathRevealId={revealRequestId}
1205 onOpenFile={onOpenFile}1295 onOpenFile={onOpenFile}
1206 workspaceMutationId={workspaceMutationId}1296 workspaceMutationId={workspaceMutationId}
1207 {...(relativePath && !isMedia ? { onRefreshSelectedFile: file.refresh } : {})}1297 {...(relativePath && !isMedia && !isPdf
1298 ? { onRefreshSelectedFile: file.refresh }
1299 : {})}
1208 />1300 />
1209 </aside>1301 </aside>
1210 ) : null}1302 ) : null}
docs/user/composer.md+11-3
...@@ -99,9 +99,17 @@ are not supported; use the Markdown embed syntax above....@@ -99,9 +99,17 @@ are not supported; use the Markdown embed syntax above.
99When an agent links to a file it wrote outside the workspace, such as a Markdown report in99When an agent links to a file it wrote outside the workspace, such as a Markdown report in
100`/tmp`, select the link to open it in the file viewer. The viewer shows the file read-only, with100`/tmp`, select the link to open it in the file viewer. The viewer shows the file read-only, with
101rendered Markdown available as usual; it cannot edit files outside the workspace. HTML and PDF101rendered 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 browser102files outside the workspace open the same way as ones inside it. Because such a file is served on
103where it is available. Because such a file is served on its own, an HTML page outside the workspace103its own, an HTML page outside the workspace cannot load scripts, styles, or images from files beside
104cannot load scripts, styles, or images from files beside it.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
106## Changing projects114## Changing projects
107115