authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-02 17:49:09-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-02 17:49:09-07:00
log77512998485718eb1b6c336e20f196eb40a6a32f
treeaa6315a8037f02ba4e3f29ec0f086451a42226cc
parentdbc7bfa3f36cc61a3f1a2015e47d71ae9dee5421
signature Signed by PGP key B5690EEEBB952194

feat(web): preview document attachments in the file viewer (#9292)

Co-authored-by: Julius Marminge <julius@mac.lan> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

16 files changed, 540 insertions(+), 101 deletions(-)

apps/server/src/assets/AssetAccess.test.ts+61
...@@ -576,6 +576,67 @@ describe("AssetAccess", () => {...@@ -576,6 +576,67 @@ describe("AssetAccess", () => {
576 });576 });
577 }).pipe(Effect.provide(testLayer)),577 }).pipe(Effect.provide(testLayer)),
578 );578 );
579
580 it.effect("serves document attachments inline when a viewer requests it", () =>
581 Effect.gen(function* () {
582 const config = yield* ServerConfig.ServerConfig;
583 const fileSystem = yield* FileSystem.FileSystem;
584 const path = yield* Path.Path;
585 const attachmentId = "thread-1-00000000-0000-4000-8000-000000000001-pdf";
586 const attachmentPath = path.join(config.attachmentsDir, `${attachmentId}.pdf`);
587 yield* fileSystem.makeDirectory(config.attachmentsDir, { recursive: true });
588 yield* fileSystem.writeFile(attachmentPath, new Uint8Array([1, 2, 3]));
589
590 const result = yield* issueAssetUrl({
591 resource: {
592 _tag: "attachment",
593 attachmentId,
594 fileName: "report.pdf",
595 mimeType: "application/pdf",
596 disposition: "inline",
597 },
598 });
599 const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
600 const separatorIndex = suffix.indexOf("/");
601
602 expect(
603 yield* resolveAsset(suffix.slice(0, separatorIndex), suffix.slice(separatorIndex + 1)),
604 ).toEqual({
605 kind: "file",
606 path: attachmentPath,
607 fileName: "report.pdf",
608 mimeType: "application/pdf",
609 });
610 }).pipe(Effect.provide(testLayer)),
611 );
612
613 it.effect("keeps inline requests for other attachment types as downloads", () =>
614 Effect.gen(function* () {
615 const config = yield* ServerConfig.ServerConfig;
616 const fileSystem = yield* FileSystem.FileSystem;
617 const path = yield* Path.Path;
618 const attachmentId = "thread-1-00000000-0000-4000-8000-000000000002-zip";
619 const attachmentPath = path.join(config.attachmentsDir, `${attachmentId}.zip`);
620 yield* fileSystem.makeDirectory(config.attachmentsDir, { recursive: true });
621 yield* fileSystem.writeFile(attachmentPath, new Uint8Array([1, 2, 3]));
622
623 const result = yield* issueAssetUrl({
624 resource: {
625 _tag: "attachment",
626 attachmentId,
627 fileName: "archive.zip",
628 mimeType: "text/html",
629 disposition: "inline",
630 },
631 });
632 const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
633 const separatorIndex = suffix.indexOf("/");
634
635 expect(
636 yield* resolveAsset(suffix.slice(0, separatorIndex), suffix.slice(separatorIndex + 1)),
637 ).toMatchObject({ kind: "file", path: attachmentPath, download: true });
638 }).pipe(Effect.provide(testLayer)),
639 );
579 it.effect("issues project favicon capabilities with a signed fallback", () =>640 it.effect("issues project favicon capabilities with a signed fallback", () =>
580 Effect.gen(function* () {641 Effect.gen(function* () {
581 const fileSystem = yield* FileSystem.FileSystem;642 const fileSystem = yield* FileSystem.FileSystem;
apps/server/src/assets/AssetAccess.ts+26-6
...@@ -51,6 +51,14 @@ const ASSET_TOKEN_TTL_MS = 60 * 60 * 1000;...@@ -51,6 +51,14 @@ const ASSET_TOKEN_TTL_MS = 60 * 60 * 1000;
51const PROJECT_FAVICON_TOKEN_BUCKET_MS = 30 * 60 * 1000;51const PROJECT_FAVICON_TOKEN_BUCKET_MS = 30 * 60 * 1000;
52const PROJECT_FAVICON_VERSION_PREFIX = "v";52const PROJECT_FAVICON_VERSION_PREFIX = "v";
53const INLINE_VIDEO_MIME_TYPE_PATTERN = /^video\/[\w!#$&^.+-]+$/i;53const INLINE_VIDEO_MIME_TYPE_PATTERN = /^video\/[\w!#$&^.+-]+$/i;
54// Extensions a document viewer may request inline. The extension comes from
55// the attachment id the server assigned, never from the client's mime type.
56const INLINE_DOCUMENT_EXTENSIONS = new Set(["pdf", "html", "htm"]);
57const INLINE_DOCUMENT_MIME_TYPES: Record<string, string> = {
58 pdf: "application/pdf",
59 html: "text/html",
60 htm: "text/html",
61};
54const PREVIEW_ASSET_EXTENSIONS = new Set([62const PREVIEW_ASSET_EXTENSIONS = new Set([
55 ...WORKSPACE_BROWSER_PREVIEW_EXTENSIONS,63 ...WORKSPACE_BROWSER_PREVIEW_EXTENSIONS,
56 ...WORKSPACE_IMAGE_PREVIEW_EXTENSIONS,64 ...WORKSPACE_IMAGE_PREVIEW_EXTENSIONS,
...@@ -362,19 +370,31 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i...@@ -362,19 +370,31 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
362 }370 }
363 // Generic files carry their extension inside the attachment id (that371 // Generic files carry their extension inside the attachment id (that
364 // shape resolves the on-disk path); images do not. Videos and images372 // shape resolves the on-disk path); images do not. Videos and images
365 // render inline; other generic files download.373 // render inline. Other generic files download, unless a document viewer
366 const isGenericFile = parseAttachmentFileExtension(input.resource.attachmentId) !== null;374 // asked for inline and the stored extension is one a browser can show.
375 const extension = parseAttachmentFileExtension(input.resource.attachmentId);
376 const isGenericFile = extension !== null;
367 const videoMimeType = input.resource.mimeType?.split(";", 1)[0]?.trim() ?? "";377 const videoMimeType = input.resource.mimeType?.split(";", 1)[0]?.trim() ?? "";
368 const isVideo = INLINE_VIDEO_MIME_TYPE_PATTERN.test(videoMimeType);378 const isVideo = INLINE_VIDEO_MIME_TYPE_PATTERN.test(videoMimeType);
379 const inlineDocumentMimeType =
380 input.resource.disposition === "inline" &&
381 extension !== null &&
382 INLINE_DOCUMENT_EXTENSIONS.has(extension)
383 ? INLINE_DOCUMENT_MIME_TYPES[extension]
384 : undefined;
369 claims = {385 claims = {
370 version: 1,386 version: 1,
371 kind: "attachment",387 kind: "attachment",
372 attachmentId: input.resource.attachmentId,388 attachmentId: input.resource.attachmentId,
373 ...(isGenericFile && !isVideo ? { download: true } : {}),389 ...(isGenericFile && !isVideo && inlineDocumentMimeType === undefined
374 ...(input.resource.fileName !== undefined ? { fileName: input.resource.fileName } : {}),390 ? { download: true }
375 ...(input.resource.mimeType !== undefined
376 ? { mimeType: isVideo ? videoMimeType : input.resource.mimeType }
377 : {}),391 : {}),
392 ...(input.resource.fileName !== undefined ? { fileName: input.resource.fileName } : {}),
393 ...(inlineDocumentMimeType !== undefined
394 ? { mimeType: inlineDocumentMimeType }
395 : input.resource.mimeType !== undefined
396 ? { mimeType: isVideo ? videoMimeType : input.resource.mimeType }
397 : {}),
378 expiresAt,398 expiresAt,
379 };399 };
380 fileName = input.resource.fileName ?? path.basename(attachmentPath);400 fileName = input.resource.fileName ?? path.basename(attachmentPath);
apps/server/src/http.test.ts+13
...@@ -324,6 +324,19 @@ describe("assetResponseHeaders", () => {...@@ -324,6 +324,19 @@ describe("assetResponseHeaders", () => {
324 "X-Content-Type-Options": "nosniff",324 "X-Content-Type-Options": "nosniff",
325 });325 });
326 });326 });
327 it("serves inline attachment documents with their declared mime type", () => {
328 expect(
329 assetResponseHeaders("/attachments/upload.bin", { mimeType: "application/pdf" }),
330 ).toMatchObject({
331 "Content-Type": "application/pdf",
332 });
333 expect(
334 assetResponseHeaders("/attachments/upload.bin", { mimeType: "text/html" }),
335 ).toMatchObject({
336 "Content-Type": "text/html; charset=utf-8",
337 "Content-Security-Policy": "sandbox allow-scripts allow-forms allow-popups allow-modals",
338 });
339 });
327 it("serves HTML assets as utf-8 inside a sandboxed origin", () => {340 it("serves HTML assets as utf-8 inside a sandboxed origin", () => {
328 for (const path of ["/workspace/page.html", "/workspace/PAGE.HTM", "/tmp/report.html"]) {341 for (const path of ["/workspace/page.html", "/workspace/PAGE.HTM", "/tmp/report.html"]) {
329 expect(assetResponseHeaders(path)).toMatchObject({342 expect(assetResponseHeaders(path)).toMatchObject({
apps/server/src/http.ts+19-7
...@@ -64,6 +64,8 @@ const isSafeDownloadMimeType = (mimeType: string): boolean =>...@@ -64,6 +64,8 @@ const isSafeDownloadMimeType = (mimeType: string): boolean =>
64 !/(?:^text\/html$|\/xml(?:$|-)|\+xml$)/i.test(mimeType.trim().toLowerCase());64 !/(?:^text\/html$|\/xml(?:$|-)|\+xml$)/i.test(mimeType.trim().toLowerCase());
65const isSafeInlineVideoMimeType = (mimeType: string): boolean =>65const isSafeInlineVideoMimeType = (mimeType: string): boolean =>
66 DOWNLOAD_MIME_TYPE_PATTERN.test(mimeType) && mimeType.toLowerCase().startsWith("video/");66 DOWNLOAD_MIME_TYPE_PATTERN.test(mimeType) && mimeType.toLowerCase().startsWith("video/");
67const isSafeInlineDocumentMimeType = (mimeType: string): boolean =>
68 mimeType.toLowerCase() === "application/pdf" || mimeType.toLowerCase() === "text/html";
6769
68/** RFC 6266 disposition with an ASCII fallback name plus a UTF-8 `filename*`. */70/** RFC 6266 disposition with an ASCII fallback name plus a UTF-8 `filename*`. */
69export function downloadContentDisposition(fileName?: string): string {71export function downloadContentDisposition(fileName?: string): string {
...@@ -93,7 +95,7 @@ export function assetResponseHeaders(...@@ -93,7 +95,7 @@ export function assetResponseHeaders(
93 },95 },
94): Record<string, string> {96): Record<string, string> {
95 const lowerPath = filePath.toLowerCase();97 const lowerPath = filePath.toLowerCase();
96 const inlineVideoMimeType = options?.mimeType?.split(";", 1)[0]?.trim();98 const inlineMimeType = options?.mimeType?.split(";", 1)[0]?.trim();
97 return {99 return {
98 "Cache-Control": "private, max-age=3600",100 "Cache-Control": "private, max-age=3600",
99 "X-Content-Type-Options": "nosniff",101 "X-Content-Type-Options": "nosniff",
...@@ -106,14 +108,24 @@ export function assetResponseHeaders(...@@ -106,14 +108,24 @@ export function assetResponseHeaders(
106 ? options.mimeType108 ? options.mimeType
107 : "application/octet-stream",109 : "application/octet-stream",
108 }110 }
109 : inlineVideoMimeType !== undefined && isSafeInlineVideoMimeType(inlineVideoMimeType)111 : inlineMimeType !== undefined && isSafeInlineVideoMimeType(inlineMimeType)
110 ? { "Content-Type": inlineVideoMimeType }112 ? { "Content-Type": inlineMimeType }
111 : lowerPath.endsWith(".html") || lowerPath.endsWith(".htm")113 : inlineMimeType !== undefined && isSafeInlineDocumentMimeType(inlineMimeType)
112 ? {114 ? {
113 "Content-Type": "text/html; charset=utf-8",115 "Content-Type":
114 "Content-Security-Policy": HTML_CONTENT_SECURITY_POLICY,116 inlineMimeType.toLowerCase() === "text/html"
117 ? "text/html; charset=utf-8"
118 : "application/pdf",
119 ...(inlineMimeType.toLowerCase() === "text/html"
120 ? { "Content-Security-Policy": HTML_CONTENT_SECURITY_POLICY }
121 : {}),
115 }122 }
116 : {}),123 : lowerPath.endsWith(".html") || lowerPath.endsWith(".htm")
124 ? {
125 "Content-Type": "text/html; charset=utf-8",
126 "Content-Security-Policy": HTML_CONTENT_SECURITY_POLICY,
127 }
128 : {}),
117 ...(!options?.download && lowerPath.endsWith(".svg")129 ...(!options?.download && lowerPath.endsWith(".svg")
118 ? { "Content-Security-Policy": SVG_CONTENT_SECURITY_POLICY }130 ? { "Content-Security-Policy": SVG_CONTENT_SECURITY_POLICY }
119 : {}),131 : {}),
apps/web/src/components/ChatView.tsx+26-7
...@@ -132,6 +132,7 @@ import {...@@ -132,6 +132,7 @@ import {
132 DEFAULT_THREAD_TERMINAL_ID,132 DEFAULT_THREAD_TERMINAL_ID,
133 MAX_TERMINALS_PER_GROUP,133 MAX_TERMINALS_PER_GROUP,
134 type ChatMessage,134 type ChatMessage,
135 isBrowserPreviewAttachment,
135 isImageAttachment,136 isImageAttachment,
136 videoMimeType,137 videoMimeType,
137 type SessionPhase,138 type SessionPhase,
...@@ -2602,7 +2603,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2602,7 +2603,7 @@ function ChatViewContent(props: ChatViewProps) {
2602 });2603 });
2603 }, []);2604 }, []);
2604 const serverMessages = activeThread?.messages;2605 const serverMessages = activeThread?.messages;
2605 const openFileAttachment = useCallback(2606 const downloadFileAttachment = useCallback(
2606 async (attachment: ChatFileAttachment) => {2607 async (attachment: ChatFileAttachment) => {
2607 const connection = readPreparedConnection(environmentId);2608 const connection = readPreparedConnection(environmentId);
2608 if (!connection) {2609 if (!connection) {
...@@ -2654,6 +2655,16 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2654,6 +2655,16 @@ function ChatViewContent(props: ChatViewProps) {
2654 },2655 },
2655 [createAttachmentAssetUrl, environmentId, routeThreadKey],2656 [createAttachmentAssetUrl, environmentId, routeThreadKey],
2656 );2657 );
2658 const openFileAttachment = useCallback(
2659 (attachment: ChatFileAttachment) => {
2660 if (isBrowserPreviewAttachment(attachment) && activeThreadRef) {
2661 useRightPanelStore.getState().openAttachment(activeThreadRef, attachment);
2662 return;
2663 }
2664 void downloadFileAttachment(attachment);
2665 },
2666 [activeThreadRef, downloadFileAttachment],
2667 );
2657 const serverAttachmentIds = useMemo(() => {2668 const serverAttachmentIds = useMemo(() => {
2658 const attachmentIds = new Set<string>();2669 const attachmentIds = new Set<string>();
2659 for (const message of serverMessages ?? []) {2670 for (const message of serverMessages ?? []) {
...@@ -7351,14 +7362,18 @@ function ChatViewContent(props: ChatViewProps) {...@@ -7351,14 +7362,18 @@ function ChatViewContent(props: ChatViewProps) {
7351 />7362 />
7352 ) : (renderedRightPanelSurface?.kind === "files" ||7363 ) : (renderedRightPanelSurface?.kind === "files" ||
7353 renderedRightPanelSurface?.kind === "file") &&7364 renderedRightPanelSurface?.kind === "file") &&
7354 activeProject &&7365 ((activeProject && activeWorkspaceRoot) ||
7355 activeWorkspaceRoot ? (7366 (renderedRightPanelSurface.kind === "file" && renderedRightPanelSurface.attachment)) ? (
7356 <Suspense fallback={null}>7367 <Suspense fallback={null}>
7357 <FilePreviewPanel7368 <FilePreviewPanel
7358 key={`${activeProject.environmentId}:${activeWorkspaceRoot}`}7369 key={`${activeThread.environmentId}:${
7359 environmentId={activeProject.environmentId}7370 renderedRightPanelSurface.kind === "file" && renderedRightPanelSurface.attachment
7360 cwd={activeWorkspaceRoot}7371 ? `attachment:${renderedRightPanelSurface.attachment.id}`
7361 projectName={activeProject.title}7372 : activeWorkspaceRoot
7373 }`}
7374 environmentId={activeThread.environmentId}
7375 cwd={activeWorkspaceRoot ?? ""}
7376 projectName={activeProject?.title ?? ""}
7362 threadRef={activeThreadRef}7377 threadRef={activeThreadRef}
7363 composerDraftTarget={composerDraftTarget}7378 composerDraftTarget={composerDraftTarget}
7364 keybindings={keybindings}7379 keybindings={keybindings}
...@@ -7368,6 +7383,9 @@ function ChatViewContent(props: ChatViewProps) {...@@ -7368,6 +7383,9 @@ function ChatViewContent(props: ChatViewProps) {
7368 ? renderedRightPanelSurface.relativePath7383 ? renderedRightPanelSurface.relativePath
7369 : null7384 : null
7370 }7385 }
7386 {...(renderedRightPanelSurface.kind === "file" && renderedRightPanelSurface.attachment
7387 ? { attachment: renderedRightPanelSurface.attachment }
7388 : {})}
7371 revealLine={7389 revealLine={
7372 renderedRightPanelSurface.kind === "file"7390 renderedRightPanelSurface.kind === "file"
7373 ? (renderedRightPanelSurface.revealLine ?? null)7391 ? (renderedRightPanelSurface.revealLine ?? null)
...@@ -7508,6 +7526,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -7508,6 +7526,7 @@ function ChatViewContent(props: ChatViewProps) {
7508 isRevertingCheckpoint={isRevertingCheckpoint}7526 isRevertingCheckpoint={isRevertingCheckpoint}
7509 onImageExpand={onExpandTimelineImage}7527 onImageExpand={onExpandTimelineImage}
7510 onFileOpen={openFileAttachment}7528 onFileOpen={openFileAttachment}
7529 onFileDownload={downloadFileAttachment}
7511 openingVideoAttachmentId={openingVideoAttachmentId}7530 openingVideoAttachmentId={openingVideoAttachmentId}
7512 markdownCwd={gitCwd ?? undefined}7531 markdownCwd={gitCwd ?? undefined}
7513 resolvedTheme={resolvedTheme}7532 resolvedTheme={resolvedTheme}
apps/web/src/components/RightPanelTabs.tsx+4-2
...@@ -793,7 +793,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) {...@@ -793,7 +793,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
793 if (surfaceIndex < 0) return;793 if (surfaceIndex < 0) return;
794794
795 const items: ContextMenuItem<TabContextMenuAction>[] = [];795 const items: ContextMenuItem<TabContextMenuAction>[] = [];
796 if (surface.kind === "file") {796 if (surface.kind === "file" && surface.attachment === undefined) {
797 items.push({ id: "copy-path", label: "Copy path" });797 items.push({ id: "copy-path", label: "Copy path" });
798 }798 }
799 const menuPreviewTabId = previewTabIdOf(surface, props.previewSessions);799 const menuPreviewTabId = previewTabIdOf(surface, props.previewSessions);
...@@ -837,7 +837,9 @@ export function RightPanelTabs(props: RightPanelTabsProps) {...@@ -837,7 +837,9 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
837 const action = await api.contextMenu.show(items, { x: event.clientX, y: event.clientY });837 const action = await api.contextMenu.show(items, { x: event.clientX, y: event.clientY });
838 switch (action) {838 switch (action) {
839 case "copy-path":839 case "copy-path":
840 if (surface.kind === "file") props.onCopyFilePath(surface.relativePath);840 if (surface.kind === "file" && surface.attachment === undefined) {
841 props.onCopyFilePath(surface.relativePath);
842 }
841 break;843 break;
842 case "toggle-mute": {844 case "toggle-mute": {
843 // menuOverlay repeats the disabled gate above: the desktop tab must845 // menuOverlay repeats the disabled gate above: the desktop tab must
apps/web/src/components/chat/MessagesTimeline.test.tsx+10-10
...@@ -437,7 +437,7 @@ describe("MessagesTimeline", () => {...@@ -437,7 +437,7 @@ describe("MessagesTimeline", () => {
437 expect(resolveTimelineMinimapInteractiveWidth(40, true)).toBe("22rem");437 expect(resolveTimelineMinimapInteractiveWidth(40, true)).toBe("22rem");
438 });438 });
439439
440 it("renders generic attachments as download links instead of image previews", () => {440 it("gives browser documents separate preview and download controls", () => {
441 const entry = {441 const entry = {
442 ...buildUserTimelineEntry("Read the report."),442 ...buildUserTimelineEntry("Read the report."),
443 message: {443 message: {
...@@ -459,9 +459,9 @@ describe("MessagesTimeline", () => {...@@ -459,9 +459,9 @@ describe("MessagesTimeline", () => {
459 <MessagesTimeline {...buildProps()} timelineEntries={[entry]} />,459 <MessagesTimeline {...buildProps()} timelineEntries={[entry]} />,
460 );460 );
461461
462 expect(markup).toContain(462 expect(markup).toContain('aria-label="Preview report.pdf"');
463 '<a href="https://environment.test/api/assets/report.pdf" download="report.pdf" class="flex min-w-0 items-center gap-2 rounded-md py-1 text-sm hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70">',463 expect(markup).toContain('aria-label="Download report.pdf"');
464 );464 expect(markup).not.toContain('download="report.pdf"');
465 expect(markup).not.toContain('alt="report.pdf"');465 expect(markup).not.toContain('alt="report.pdf"');
466 });466 });
467467
...@@ -502,7 +502,7 @@ describe("MessagesTimeline", () => {...@@ -502,7 +502,7 @@ describe("MessagesTimeline", () => {
502 expect(busyMarkup).not.toContain('disabled=""');502 expect(busyMarkup).not.toContain('disabled=""');
503 expect(busyMarkup).toContain(">Loading…</span>");503 expect(busyMarkup).toContain(">Loading…</span>");
504 });504 });
505 it("renders a file download button without creating its URL in advance", () => {505 it("renders an ordinary file download button without creating its URL in advance", () => {
506 const entry = {506 const entry = {
507 ...buildUserTimelineEntry("Read the report."),507 ...buildUserTimelineEntry("Read the report."),
508 message: {508 message: {
...@@ -511,8 +511,8 @@ describe("MessagesTimeline", () => {...@@ -511,8 +511,8 @@ describe("MessagesTimeline", () => {
511 {511 {
512 type: "file" as const,512 type: "file" as const,
513 id: "attachment-report-pdf",513 id: "attachment-report-pdf",
514 name: "report.pdf",514 name: "archive.zip",
515 mimeType: "application/pdf",515 mimeType: "application/zip",
516 sizeBytes: 42,516 sizeBytes: 42,
517 },517 },
518 ],518 ],
...@@ -524,9 +524,9 @@ describe("MessagesTimeline", () => {...@@ -524,9 +524,9 @@ describe("MessagesTimeline", () => {
524 );524 );
525525
526 expect(markup).toContain(526 expect(markup).toContain(
527 '<button type="button" aria-label="Download report.pdf" class="flex min-w-0 cursor-pointer items-center gap-2 rounded-md py-1 text-left text-sm hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70">',527 '<button type="button" aria-label="Download archive.zip" class="flex min-w-0 cursor-pointer items-center gap-2 rounded-md py-1 text-left text-sm hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70">',
528 );528 );
529 expect(markup).not.toContain("href=");529 expect(markup).not.toContain("<a href=");
530 });530 });
531531
532 it("does not download an optimistic file before the server supplies its attachment ID", () => {532 it("does not download an optimistic file before the server supplies its attachment ID", () => {
...@@ -581,7 +581,7 @@ describe("MessagesTimeline", () => {...@@ -581,7 +581,7 @@ describe("MessagesTimeline", () => {
581 expect(markup).toContain("voice-memo.ogg");581 expect(markup).toContain("voice-memo.ogg");
582 expect(markup).not.toContain('aria-label="Download voice-memo.ogg"');582 expect(markup).not.toContain('aria-label="Download voice-memo.ogg"');
583 expect(markup).not.toContain('alt="voice-memo.ogg"');583 expect(markup).not.toContain('alt="voice-memo.ogg"');
584 expect(markup).not.toContain("href=");584 expect(markup).not.toContain("<a href=");
585 });585 });
586586
587 it("keeps reserved end space when tool work starts while reading history", () => {587 it("keeps reserved end space when tool work starts while reading history", () => {
apps/web/src/components/chat/MessagesTimeline.tsx+53-6
...@@ -51,6 +51,7 @@ import {...@@ -51,6 +51,7 @@ import {
51} from "../../session-logic";51} from "../../session-logic";
52import {52import {
53 type ChatImageAttachment,53 type ChatImageAttachment,
54 isBrowserPreviewAttachment,
54 isFileAttachment,55 isFileAttachment,
55 isImageAttachment,56 isImageAttachment,
56 isVideoAttachment,57 isVideoAttachment,
...@@ -73,7 +74,6 @@ import {...@@ -73,7 +74,6 @@ import {
73 CircleAlertIcon,74 CircleAlertIcon,
74 DownloadIcon,75 DownloadIcon,
75 EyeIcon,76 EyeIcon,
76 FileIcon,
77 GlobeIcon,77 GlobeIcon,
78 HammerIcon,78 HammerIcon,
79 MessageCircleIcon,79 MessageCircleIcon,
...@@ -99,6 +99,7 @@ import {...@@ -99,6 +99,7 @@ import {
99 keepTimelineEndVisibleAfterOverlayGrowth,99 keepTimelineEndVisibleAfterOverlayGrowth,
100} from "./timelineScrollAnchoring";100} from "./timelineScrollAnchoring";
101import { MessageCopyButton } from "./MessageCopyButton";101import { MessageCopyButton } from "./MessageCopyButton";
102import { PierreEntryIcon } from "./PierreEntryIcon";
102import { AssistantSelectionToolbar } from "./AssistantSelectionToolbar";103import { AssistantSelectionToolbar } from "./AssistantSelectionToolbar";
103import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection";104import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection";
104import {105import {
...@@ -186,6 +187,7 @@ interface TimelineRowSharedState {...@@ -186,6 +187,7 @@ interface TimelineRowSharedState {
186 onUseArtifactTemplate: (template: CodexArtifactTemplate) => void;187 onUseArtifactTemplate: (template: CodexArtifactTemplate) => void;
187 onImageExpand: (preview: ExpandedImagePreview) => void;188 onImageExpand: (preview: ExpandedImagePreview) => void;
188 onFileOpen: (attachment: ChatFileAttachment) => void;189 onFileOpen: (attachment: ChatFileAttachment) => void;
190 onFileDownload: (attachment: ChatFileAttachment) => void;
189 openingVideoAttachmentId: string | null;191 openingVideoAttachmentId: string | null;
190 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;192 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;
191 onToggleTurnFold: (turnId: TurnId) => void;193 onToggleTurnFold: (turnId: TurnId) => void;
...@@ -286,6 +288,7 @@ interface MessagesTimelineProps {...@@ -286,6 +288,7 @@ interface MessagesTimelineProps {
286 isRevertingCheckpoint: boolean;288 isRevertingCheckpoint: boolean;
287 onImageExpand: (preview: ExpandedImagePreview) => void;289 onImageExpand: (preview: ExpandedImagePreview) => void;
288 onFileOpen?: (attachment: ChatFileAttachment) => void;290 onFileOpen?: (attachment: ChatFileAttachment) => void;
291 onFileDownload?: (attachment: ChatFileAttachment) => void;
289 openingVideoAttachmentId: string | null;292 openingVideoAttachmentId: string | null;
290 activeThreadEnvironmentId: EnvironmentId;293 activeThreadEnvironmentId: EnvironmentId;
291 markdownCwd: string | undefined;294 markdownCwd: string | undefined;
...@@ -337,6 +340,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -337,6 +340,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
337 isRevertingCheckpoint,340 isRevertingCheckpoint,
338 onImageExpand,341 onImageExpand,
339 onFileOpen = NOOP_OPEN_ATTACHMENT,342 onFileOpen = NOOP_OPEN_ATTACHMENT,
343 onFileDownload = NOOP_OPEN_ATTACHMENT,
340 openingVideoAttachmentId,344 openingVideoAttachmentId,
341 activeThreadEnvironmentId,345 activeThreadEnvironmentId,
342 markdownCwd,346 markdownCwd,
...@@ -629,6 +633,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -629,6 +633,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
629 onUseArtifactTemplate,633 onUseArtifactTemplate,
630 onImageExpand,634 onImageExpand,
631 onFileOpen,635 onFileOpen,
636 onFileDownload,
632 openingVideoAttachmentId,637 openingVideoAttachmentId,
633 onOpenTurnDiff,638 onOpenTurnDiff,
634 onToggleTurnFold,639 onToggleTurnFold,
...@@ -652,6 +657,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -652,6 +657,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
652 onUseArtifactTemplate,657 onUseArtifactTemplate,
653 onImageExpand,658 onImageExpand,
654 onFileOpen,659 onFileOpen,
660 onFileDownload,
655 openingVideoAttachmentId,661 openingVideoAttachmentId,
656 onOpenTurnDiff,662 onOpenTurnDiff,
657 onToggleTurnFold,663 onToggleTurnFold,
...@@ -1221,16 +1227,53 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"...@@ -1221,16 +1227,53 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
1221 {otherUserFiles.length > 0 || unknownAttachments.length > 0 ? (1227 {otherUserFiles.length > 0 || unknownAttachments.length > 0 ? (
1222 <div className="mb-2 flex flex-col gap-1">1228 <div className="mb-2 flex flex-col gap-1">
1223 {otherUserFiles.map((file) => {1229 {otherUserFiles.map((file) => {
1224 const content = (1230 const opensInPreview = isBrowserPreviewAttachment(file);
1231 const fileIdentity = (
1225 <>1232 <>
1226 <FileIcon className="size-4 shrink-0 text-secondary-label" />1233 <PierreEntryIcon pathValue={file.name} kind="file" theme={ctx.resolvedTheme} />
1227 <span className="min-w-0 flex-1 truncate">{file.name}</span>1234 <span className="min-w-0 flex-1 truncate">{file.name}</span>
1235 </>
1236 );
1237 if (opensInPreview && file.downloadable !== false) {
1238 return (
1239 <div key={file.id} className="flex min-w-0 items-center gap-1">
1240 <button
1241 type="button"
1242 aria-label={`Preview ${file.name}`}
1243 onClick={() => ctx.onFileOpen(file)}
1244 className="focus-visible:ring-ring/70 flex min-w-0 flex-1 cursor-pointer items-center gap-2 rounded-md py-1 text-left text-sm hover:underline focus-visible:ring-2 focus-visible:outline-none focus-visible:ring-inset"
1245 >
1246 {fileIdentity}
1247 <EyeIcon className="size-4 shrink-0" />
1248 </button>
1249 <Tooltip>
1250 <TooltipTrigger
1251 render={
1252 <Button
1253 size="icon-xs"
1254 variant="ghost-muted"
1255 aria-label={`Download ${file.name}`}
1256 onClick={() => ctx.onFileDownload(file)}
1257 />
1258 }
1259 >
1260 <DownloadIcon />
1261 </TooltipTrigger>
1262 <TooltipPopup side="top">Download {file.name}</TooltipPopup>
1263 </Tooltip>
1264 </div>
1265 );
1266 }
1267
1268 const content = (
1269 <>
1270 {fileIdentity}
1228 {file.downloadable === false ? null : (1271 {file.downloadable === false ? null : (
1229 <DownloadIcon className="size-4 shrink-0" />1272 <DownloadIcon className="size-4 shrink-0" />
1230 )}1273 )}
1231 </>1274 </>
1232 );1275 );
1233 return file.previewUrl ? (1276 return file.previewUrl && !opensInPreview ? (
1234 <a1277 <a
1235 key={file.id}1278 key={file.id}
1236 href={file.previewUrl}1279 href={file.previewUrl}
...@@ -1247,7 +1290,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"...@@ -1247,7 +1290,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
1247 <button1290 <button
1248 key={file.id}1291 key={file.id}
1249 type="button"1292 type="button"
1250 aria-label={`Download ${file.name}`}1293 aria-label={`${opensInPreview ? "Preview" : "Download"} ${file.name}`}
1251 onClick={() => ctx.onFileOpen(file)}1294 onClick={() => ctx.onFileOpen(file)}
1252 className="flex min-w-0 cursor-pointer items-center gap-2 rounded-md py-1 text-left text-sm hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"1295 className="flex min-w-0 cursor-pointer items-center gap-2 rounded-md py-1 text-left text-sm hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
1253 >1296 >
...@@ -1257,7 +1300,11 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"...@@ -1257,7 +1300,11 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
1257 })}1300 })}
1258 {unknownAttachments.map((attachment) => (1301 {unknownAttachments.map((attachment) => (
1259 <div key={attachment.id} className="flex min-w-0 items-center gap-2 py-1 text-sm">1302 <div key={attachment.id} className="flex min-w-0 items-center gap-2 py-1 text-sm">
1260 <FileIcon className="size-4 shrink-0 text-secondary-label" />1303 <PierreEntryIcon
1304 pathValue={attachment.name}
1305 kind="file"
1306 theme={ctx.resolvedTheme}
1307 />
1261 <span className="min-w-0 flex-1 truncate">{attachment.name}</span>1308 <span className="min-w-0 flex-1 truncate">{attachment.name}</span>
1262 </div>1309 </div>
1263 ))}1310 ))}
apps/web/src/components/files/FilePreviewPanel.test.ts+41-1
...@@ -5,7 +5,11 @@ import {...@@ -5,7 +5,11 @@ import {
5 normalizeFileCommentRange,5 normalizeFileCommentRange,
6 remapFileCommentAnnotations,6 remapFileCommentAnnotations,
7} from "./fileCommentAnnotations";7} from "./fileCommentAnnotations";
8import { isMarkdownPreviewFile, setMarkdownTaskChecked } from "./filePreviewMode";8import {
9 isMarkdownPreviewFile,
10 setMarkdownTaskChecked,
11 shouldShowFileExplorer,
12} from "./filePreviewMode";
913
10describe("file comment annotations", () => {14describe("file comment annotations", () => {
11 it("normalizes and formats selected line ranges", () => {15 it("normalizes and formats selected line ranges", () => {
...@@ -66,6 +70,42 @@ describe("isMarkdownPreviewFile", () => {...@@ -66,6 +70,42 @@ describe("isMarkdownPreviewFile", () => {
66 });70 });
67});71});
6872
73describe("shouldShowFileExplorer", () => {
74 it("hides the workspace tree for host files and attachments", () => {
75 expect(
76 shouldShowFileExplorer({
77 relativePath: "/tmp/report.pdf",
78 explorerOpen: true,
79 attachmentOpen: false,
80 }),
81 ).toBe(false);
82 expect(
83 shouldShowFileExplorer({
84 relativePath: "report.pdf",
85 explorerOpen: true,
86 attachmentOpen: true,
87 }),
88 ).toBe(false);
89 });
90
91 it("keeps the saved explorer preference for workspace files", () => {
92 expect(
93 shouldShowFileExplorer({
94 relativePath: "docs/report.pdf",
95 explorerOpen: true,
96 attachmentOpen: false,
97 }),
98 ).toBe(true);
99 expect(
100 shouldShowFileExplorer({
101 relativePath: "docs/report.pdf",
102 explorerOpen: false,
103 attachmentOpen: false,
104 }),
105 ).toBe(false);
106 });
107});
108
69describe("setMarkdownTaskChecked", () => {109describe("setMarkdownTaskChecked", () => {
70 const markdown = "- [ ] First\n- [x] Second\n";110 const markdown = "- [ ] First\n- [x] Second\n";
71111
apps/web/src/components/files/FilePreviewPanel.tsx+152-55
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import type {1import type {
2 ChatFileAttachment,
2 EditorId,3 EditorId,
3 EnvironmentId,4 EnvironmentId,
4 ResolvedKeybindingsConfig,5 ResolvedKeybindingsConfig,
...@@ -23,6 +24,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";...@@ -23,6 +24,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
23import { isBrowserPreviewFile, openFileInPreview } from "~/browser/openFileInPreview";24import { isBrowserPreviewFile, openFileInPreview } from "~/browser/openFileInPreview";
24import { useAssetUrlRefresh, useAssetUrlState } from "~/assets/assetUrls";25import { useAssetUrlRefresh, useAssetUrlState } from "~/assets/assetUrls";
25import { OpenInPicker } from "~/components/chat/OpenInPicker";26import { OpenInPicker } from "~/components/chat/OpenInPicker";
27import { PierreEntryIcon } from "~/components/chat/PierreEntryIcon";
26import { MediaVideoPlayer } from "~/components/media/MediaVideoPlayer";28import { MediaVideoPlayer } from "~/components/media/MediaVideoPlayer";
27import { MediaActions, type MediaActionSource } from "~/components/media/MediaActions";29import { MediaActions, type MediaActionSource } from "~/components/media/MediaActions";
28import { useRemoteOpenState } from "~/remoteOpen";30import { useRemoteOpenState } from "~/remoteOpen";
...@@ -64,7 +66,11 @@ import { installFileEditorDismissal } from "./fileEditorDismissal";...@@ -64,7 +66,11 @@ import { installFileEditorDismissal } from "./fileEditorDismissal";
64import { resolveCenteredFileLineScrollTop } from "./fileLineReveal";66import { resolveCenteredFileLineScrollTop } from "./fileLineReveal";
65import { DiffCommentAnnotation } from "../diffs/DiffCommentAnnotation";67import { DiffCommentAnnotation } from "../diffs/DiffCommentAnnotation";
66import { projectFileCacheKey, projectFileEditorCacheKey } from "./fileContentRevision";68import { projectFileCacheKey, projectFileEditorCacheKey } from "./fileContentRevision";
67import { isMarkdownPreviewFile, setMarkdownTaskChecked } from "./filePreviewMode";69import {
70 isMarkdownPreviewFile,
71 setMarkdownTaskChecked,
72 shouldShowFileExplorer,
73} from "./filePreviewMode";
68import { FileSaveCoordinator } from "./fileSaveCoordinator";74import { FileSaveCoordinator } from "./fileSaveCoordinator";
69import {75import {
70 confirmProjectFileQueryData,76 confirmProjectFileQueryData,
...@@ -78,6 +84,7 @@ interface FilePreviewPanelProps {...@@ -78,6 +84,7 @@ interface FilePreviewPanelProps {
78 cwd: string;84 cwd: string;
79 projectName: string;85 projectName: string;
80 relativePath: string | null;86 relativePath: string | null;
87 attachment?: ChatFileAttachment;
81 threadRef: ScopedThreadRef;88 threadRef: ScopedThreadRef;
82 composerDraftTarget: ScopedThreadRef | DraftId;89 composerDraftTarget: ScopedThreadRef | DraftId;
83 keybindings: ResolvedKeybindingsConfig;90 keybindings: ResolvedKeybindingsConfig;
...@@ -200,6 +207,69 @@ function WorkspaceImagePreview(props: {...@@ -200,6 +207,69 @@ function WorkspaceImagePreview(props: {
200207
201const isPdfPreviewFile = (path: string): boolean => /\.pdf$/i.test(path.split(/[?#]/, 1)[0] ?? "");208const isPdfPreviewFile = (path: string): boolean => /\.pdf$/i.test(path.split(/[?#]/, 1)[0] ?? "");
202209
210function BrowserDocumentFrame(props: {
211 readonly src: string;
212 readonly title: string;
213 readonly pdf: boolean;
214}) {
215 const className = "min-h-0 flex-1 border-0 bg-white";
216 // The built-in PDF viewer needs an unsandboxed frame; a PDF runs no scripts.
217 return props.pdf ? (
218 // oxlint-disable-next-line react/iframe-missing-sandbox
219 <iframe key={props.src} src={props.src} title={props.title} className={className} />
220 ) : (
221 <iframe
222 key={props.src}
223 src={props.src}
224 title={props.title}
225 className={className}
226 sandbox="allow-scripts allow-forms allow-popups allow-modals"
227 />
228 );
229}
230
231function AttachmentBrowserPreview(props: {
232 readonly environmentId: EnvironmentId;
233 readonly attachment: ChatFileAttachment;
234}) {
235 const resource = useMemo(
236 () => ({
237 _tag: "attachment" as const,
238 attachmentId: props.attachment.id,
239 fileName: props.attachment.name,
240 mimeType: props.attachment.mimeType,
241 disposition: "inline" as const,
242 }),
243 [props.attachment.id, props.attachment.mimeType, props.attachment.name],
244 );
245 const assetUrl = useAssetUrlState(props.environmentId, resource);
246
247 if (assetUrl._tag === "Failure") {
248 return (
249 <div className="flex min-h-0 flex-1 items-center justify-center px-6 text-center text-xs leading-relaxed text-destructive">
250 Unable to load attachment preview.
251 </div>
252 );
253 }
254 if (assetUrl._tag !== "Success") {
255 return (
256 <div className="flex min-h-0 flex-1 items-center justify-center text-muted-foreground">
257 <LoaderCircle className="size-5 animate-spin" />
258 </div>
259 );
260 }
261 return (
262 <BrowserDocumentFrame
263 src={assetUrl.url}
264 title={props.attachment.name}
265 pdf={
266 isPdfPreviewFile(props.attachment.name) ||
267 props.attachment.mimeType.split(";", 1)[0]?.trim().toLowerCase() === "application/pdf"
268 }
269 />
270 );
271}
272
203/**273/**
204 * Renders an HTML or PDF file in place from its signed asset URL. HTML runs in274 * 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's275 * a sandboxed frame with an opaque origin, so a page cannot reach the app's
...@@ -244,19 +314,11 @@ function WorkspaceBrowserPreview(props: {...@@ -244,19 +314,11 @@ function WorkspaceBrowserPreview(props: {
244 </div>314 </div>
245 );315 );
246 }316 }
247 const src = `${assetUrl.url}${revisionSuffix}`;317 return (
248 const className = "min-h-0 flex-1 border-0 bg-white";318 <BrowserDocumentFrame
249 // The built-in PDF viewer needs an unsandboxed frame; a PDF runs no scripts.319 src={`${assetUrl.url}${revisionSuffix}`}
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}320 title={props.title}
258 className={className}321 pdf={isPdfPreviewFile(props.absolutePath)}
259 sandbox="allow-scripts allow-forms allow-popups allow-modals"
260 />322 />
261 );323 );
262}324}
...@@ -922,6 +984,7 @@ export default function FilePreviewPanel({...@@ -922,6 +984,7 @@ export default function FilePreviewPanel({
922 cwd,984 cwd,
923 projectName,985 projectName,
924 relativePath,986 relativePath,
987 attachment,
925 threadRef,988 threadRef,
926 composerDraftTarget,989 composerDraftTarget,
927 keybindings,990 keybindings,
...@@ -951,9 +1014,20 @@ export default function FilePreviewPanel({...@@ -951,9 +1014,20 @@ export default function FilePreviewPanel({
951 const isPdf = relativePath !== null && isPdfPreviewFile(relativePath);1014 const isPdf = relativePath !== null && isPdfPreviewFile(relativePath);
952 const isHtml = relativePath !== null && !isPdf && isBrowserPreviewFile(relativePath);1015 const isHtml = relativePath !== null && !isPdf && isBrowserPreviewFile(relativePath);
953 // A file outside the workspace (an absolute path) is shown, never edited.1016 // A file outside the workspace (an absolute path) is shown, never edited.
954 const isHostFile = relativePath !== null && isAbsolutePath(relativePath);1017 const isHostFile =
955 const file = useProjectFileQuery(environmentId, cwd, relativePath, !isMedia && !isPdf);1018 attachment !== undefined || (relativePath !== null && isAbsolutePath(relativePath));
1019 const file = useProjectFileQuery(
1020 environmentId,
1021 cwd,
1022 relativePath,
1023 attachment === undefined && !isMedia && !isPdf,
1024 );
956 const [explorerOpen, setExplorerOpen] = useState(initialExplorerOpen);1025 const [explorerOpen, setExplorerOpen] = useState(initialExplorerOpen);
1026 const showExplorer = shouldShowFileExplorer({
1027 relativePath,
1028 explorerOpen,
1029 attachmentOpen: attachment !== undefined,
1030 });
957 // Reading markdown rendered is a preference, not a property of one file. Keeping1031 // Reading markdown rendered is a preference, not a property of one file. Keeping
958 // it on the panel meant a thread switch dropped it and forced source back.1032 // it on the panel meant a thread switch dropped it and forced source back.
959 const [renderMarkdownPreferred, setRenderMarkdownPreferred] = useLocalStorage(1033 const [renderMarkdownPreferred, setRenderMarkdownPreferred] = useLocalStorage(
...@@ -980,20 +1054,27 @@ export default function FilePreviewPanel({...@@ -980,20 +1054,27 @@ export default function FilePreviewPanel({
980 (handledReveal?.path === relativePath && handledReveal.requestId === revealRequestId);1054 (handledReveal?.path === relativePath && handledReveal.requestId === revealRequestId);
981 const renderMarkdown = isMarkdown && renderMarkdownPreferred && revealHandled;1055 const renderMarkdown = isMarkdown && renderMarkdownPreferred && revealHandled;
982 const renderBrowserFile = isPdf || (isHtml && renderBrowserFilePreferred && revealHandled);1056 const renderBrowserFile = isPdf || (isHtml && renderBrowserFilePreferred && revealHandled);
983 const canToggleRendered = isMarkdown || isHtml;1057 const canToggleRendered = attachment === undefined && (isMarkdown || isHtml);
984 const rendered = isMarkdown ? renderMarkdown : renderBrowserFile;1058 const rendered = isMarkdown ? renderMarkdown : renderBrowserFile;
985 const setRenderedPreferred = isMarkdown1059 const setRenderedPreferred = isMarkdown
986 ? setRenderMarkdownPreferred1060 ? setRenderMarkdownPreferred
987 : setRenderBrowserFilePreferred;1061 : setRenderBrowserFilePreferred;
988 const canOpenInBrowser =1062 const canOpenInBrowser =
989 relativePath !== null &&1063 relativePath !== null &&
1064 attachment === undefined &&
990 !isVideo &&1065 !isVideo &&
991 isPreviewSupportedInRuntime() &&1066 isPreviewSupportedInRuntime() &&
992 isBrowserPreviewFile(relativePath);1067 isBrowserPreviewFile(relativePath);
993 const absolutePath = relativePath ? resolvePathLinkTarget(relativePath, cwd) : null;1068 const absolutePath =
1069 relativePath && attachment === undefined ? resolvePathLinkTarget(relativePath, cwd) : null;
994 const onFilePostRender = useFileLineReveal(relativePath, revealLine, revealRequestId);1070 const onFilePostRender = useFileLineReveal(relativePath, revealLine, revealRequestId);
995 useWorkspaceMutationRefresh({1071 useWorkspaceMutationRefresh({
996 enabled: relativePath !== null && !isMedia && !isPdf && !selectedFilePending,1072 enabled:
1073 attachment === undefined &&
1074 relativePath !== null &&
1075 !isMedia &&
1076 !isPdf &&
1077 !selectedFilePending,
997 mutationId: workspaceMutationId,1078 mutationId: workspaceMutationId,
998 refresh: file.refresh,1079 refresh: file.refresh,
999 resourceKey: `file:${environmentId}:${cwd}:${relativePath ?? ""}`,1080 resourceKey: `file:${environmentId}:${cwd}:${relativePath ?? ""}`,
...@@ -1050,24 +1131,36 @@ export default function FilePreviewPanel({...@@ -1050,24 +1131,36 @@ export default function FilePreviewPanel({
1050 className="flex h-10 min-h-10 shrink-0 items-center gap-2 border-b border-border/60 bg-background px-3 in-data-[preview-panel-mode=inline]:mb-3 in-data-[preview-panel-mode=inline]:h-7 in-data-[preview-panel-mode=inline]:min-h-7 in-data-[preview-panel-mode=inline]:border-b-transparent"1131 className="flex h-10 min-h-10 shrink-0 items-center gap-2 border-b border-border/60 bg-background px-3 in-data-[preview-panel-mode=inline]:mb-3 in-data-[preview-panel-mode=inline]:h-7 in-data-[preview-panel-mode=inline]:min-h-7 in-data-[preview-panel-mode=inline]:border-b-transparent"
1051 data-surface-subheader1132 data-surface-subheader
1052 >1133 >
1053 <ScrollArea1134 {attachment ? (
1054 ref={breadcrumbRef}1135 <div className="flex min-w-0 flex-1 items-center gap-1.5 text-xs">
1055 hideScrollbars1136 <PierreEntryIcon
1056 scrollFade1137 pathValue={attachment.name}
1057 className="min-w-0 flex-1 rounded-none"1138 kind="file"
1058 data-file-breadcrumbs1139 theme={resolvedTheme}
1059 >1140 className="size-3.5"
1060 <div className="flex h-full w-max min-w-full items-center text-xs">
1061 <FileBreadcrumbs
1062 cwd={cwd}
1063 environmentId={environmentId}
1064 onOpenFile={onOpenFile}
1065 projectName={projectName}
1066 relativePath={relativePath}
1067 workspaceMutationId={workspaceMutationId}
1068 />1141 />
1142 <span className="truncate font-medium">{attachment.name}</span>
1069 </div>1143 </div>
1070 </ScrollArea>1144 ) : (
1145 <ScrollArea
1146 ref={breadcrumbRef}
1147 hideScrollbars
1148 scrollFade
1149 className="min-w-0 flex-1 rounded-none"
1150 data-file-breadcrumbs
1151 >
1152 <div className="flex h-full w-max min-w-full items-center text-xs">
1153 <FileBreadcrumbs
1154 cwd={cwd}
1155 environmentId={environmentId}
1156 onOpenFile={onOpenFile}
1157 projectName={projectName}
1158 relativePath={relativePath}
1159 workspaceMutationId={workspaceMutationId}
1160 />
1161 </div>
1162 </ScrollArea>
1163 )}
1071 {absolutePath &&1164 {absolutePath &&
1072 (environmentId === primaryEnvironmentId || remoteOpenState.mode !== "local-exec") ? (1165 (environmentId === primaryEnvironmentId || remoteOpenState.mode !== "local-exec") ? (
1073 <OpenInPicker1166 <OpenInPicker
...@@ -1124,25 +1217,27 @@ export default function FilePreviewPanel({...@@ -1124,25 +1217,27 @@ export default function FilePreviewPanel({
1124 <TooltipPopup>Open file in preview browser</TooltipPopup>1217 <TooltipPopup>Open file in preview browser</TooltipPopup>
1125 </Tooltip>1218 </Tooltip>
1126 ) : null}1219 ) : null}
1127 <Tooltip>1220 {!isHostFile ? (
1128 <TooltipTrigger1221 <Tooltip>
1129 render={1222 <TooltipTrigger
1130 <Toggle1223 render={
1131 className="shrink-0"1224 <Toggle
1132 pressed={explorerOpen}1225 className="shrink-0"
1133 onPressedChange={toggleExplorer}1226 pressed={explorerOpen}
1134 aria-label={explorerOpen ? "Hide file explorer" : "Show file explorer"}1227 onPressedChange={toggleExplorer}
1135 variant="ghost"1228 aria-label={explorerOpen ? "Hide file explorer" : "Show file explorer"}
1136 size="sm"1229 variant="ghost"
1137 >1230 size="sm"
1138 <FolderTree className="size-3.5" />1231 >
1139 </Toggle>1232 <FolderTree className="size-3.5" />
1140 }1233 </Toggle>
1141 />1234 }
1142 <TooltipPopup>1235 />
1143 {explorerOpen ? "Hide file explorer" : "Show file explorer"}1236 <TooltipPopup>
1144 </TooltipPopup>1237 {explorerOpen ? "Hide file explorer" : "Show file explorer"}
1145 </Tooltip>1238 </TooltipPopup>
1239 </Tooltip>
1240 ) : null}
1146 </div>1241 </div>
1147 ) : null}1242 ) : null}
1148 {relativePath && !isMedia && !renderBrowserFile && file.data?.truncated ? (1243 {relativePath && !isMedia && !renderBrowserFile && file.data?.truncated ? (
...@@ -1157,7 +1252,9 @@ export default function FilePreviewPanel({...@@ -1157,7 +1252,9 @@ export default function FilePreviewPanel({
1157 relativePath ? "flex" : "hidden",1252 relativePath ? "flex" : "hidden",
1158 )}1253 )}
1159 >1254 >
1160 {relativePath && isVideo && absolutePath ? (1255 {relativePath && attachment ? (
1256 <AttachmentBrowserPreview environmentId={environmentId} attachment={attachment} />
1257 ) : relativePath && isVideo && absolutePath ? (
1161 <WorkspaceVideoPreview1258 <WorkspaceVideoPreview
1162 key={`${environmentId}:${threadRef.threadId}:${absolutePath}`}1259 key={`${environmentId}:${threadRef.threadId}:${absolutePath}`}
1163 environmentId={environmentId}1260 environmentId={environmentId}
...@@ -1250,7 +1347,7 @@ export default function FilePreviewPanel({...@@ -1250,7 +1347,7 @@ export default function FilePreviewPanel({
1250 )1347 )
1251 ) : null}1348 ) : null}
1252 </div>1349 </div>
1253 {explorerOpen || relativePath === null ? (1350 {showExplorer ? (
1254 <aside1351 <aside
1255 className={cn(1352 className={cn(
1256 "flex min-h-0 shrink-0 bg-background",1353 "flex min-h-0 shrink-0 bg-background",
apps/web/src/components/files/filePreviewMode.ts+13
...@@ -1,5 +1,18 @@...@@ -1,5 +1,18 @@
1import { isAbsolutePath } from "~/terminal-links";
2
1export const isMarkdownPreviewFile = (path: string): boolean => /\.(?:md|mdx)$/i.test(path);3export const isMarkdownPreviewFile = (path: string): boolean => /\.(?:md|mdx)$/i.test(path);
24
5export function shouldShowFileExplorer(input: {
6 readonly relativePath: string | null;
7 readonly explorerOpen: boolean;
8 readonly attachmentOpen: boolean;
9}): boolean {
10 if (input.attachmentOpen || (input.relativePath && isAbsolutePath(input.relativePath))) {
11 return false;
12 }
13 return input.explorerOpen || input.relativePath === null;
14}
15
3export function setMarkdownTaskChecked(16export function setMarkdownTaskChecked(
4 markdown: string,17 markdown: string,
5 markerOffset: number,18 markerOffset: number,
apps/web/src/rightPanelStore.test.ts+73
...@@ -279,6 +279,50 @@ describe("rightPanelStore", () => {...@@ -279,6 +279,50 @@ describe("rightPanelStore", () => {
279 });279 });
280 });280 });
281281
282 it("opens an attachment as a file surface without the standalone explorer", () => {
283 const attachment = {
284 type: "file" as const,
285 id: "thread-A-attachment-pdf",
286 name: "report.pdf",
287 mimeType: "application/pdf",
288 sizeBytes: 42,
289 };
290 useRightPanelStore.getState().open(refA, "files");
291 useRightPanelStore.getState().openAttachment(refA, attachment);
292
293 expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({
294 isOpen: true,
295 activeSurfaceId: "attachment:thread-A-attachment-pdf",
296 surfaces: [
297 {
298 id: "attachment:thread-A-attachment-pdf",
299 kind: "file",
300 relativePath: "report.pdf",
301 revealLine: null,
302 revealRequestId: 0,
303 attachment,
304 },
305 ],
306 });
307 });
308
309 it("keeps attachment and workspace file ids disjoint", () => {
310 useRightPanelStore.getState().openFile(refA, "attachment:shared-id");
311 useRightPanelStore.getState().openAttachment(refA, {
312 type: "file",
313 id: "shared-id",
314 name: "report.pdf",
315 mimeType: "application/pdf",
316 sizeBytes: 42,
317 });
318
319 expect(
320 selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).surfaces.map(
321 (surface) => surface.id,
322 ),
323 ).toEqual(["file:attachment:shared-id", "attachment:shared-id"]);
324 });
325
282 it("updates line reveal requests when reopening a file surface", () => {326 it("updates line reveal requests when reopening a file surface", () => {
283 useRightPanelStore.getState().openFile(refA, "src/index.ts", 42);327 useRightPanelStore.getState().openFile(refA, "src/index.ts", 42);
284 useRightPanelStore.getState().openFile(refA, "src/index.ts", 87);328 useRightPanelStore.getState().openFile(refA, "src/index.ts", 87);
...@@ -336,6 +380,35 @@ describe("rightPanelStore", () => {...@@ -336,6 +380,35 @@ describe("rightPanelStore", () => {
336 });380 });
337 });381 });
338382
383 it("keeps attachment previews when their workspace is unavailable", () => {
384 const attachment = {
385 type: "file" as const,
386 id: "thread-A-attachment-pdf",
387 name: "report.pdf",
388 mimeType: "application/pdf",
389 sizeBytes: 42,
390 };
391 useRightPanelStore.getState().openFile(refA, "README.md");
392 useRightPanelStore.getState().openAttachment(refA, attachment);
393
394 useRightPanelStore.getState().reconcileFileSurfaces(refA, false);
395
396 expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({
397 isOpen: true,
398 activeSurfaceId: "attachment:thread-A-attachment-pdf",
399 surfaces: [
400 {
401 id: "attachment:thread-A-attachment-pdf",
402 kind: "file",
403 relativePath: "report.pdf",
404 revealLine: null,
405 revealRequestId: 0,
406 attachment,
407 },
408 ],
409 });
410 });
411
339 it("close hides the panel without clearing its selected surface", () => {412 it("close hides the panel without clearing its selected surface", () => {
340 useRightPanelStore.getState().open(refA, "agents");413 useRightPanelStore.getState().open(refA, "agents");
341 useRightPanelStore.getState().close(refA);414 useRightPanelStore.getState().close(refA);
apps/web/src/rightPanelStore.ts+30-3
...@@ -8,7 +8,7 @@...@@ -8,7 +8,7 @@
8 * workspace paths, and diff/files remain singleton surfaces.8 * workspace paths, and diff/files remain singleton surfaces.
9 */9 */
10import { scopedThreadKey } from "@t3tools/client-runtime/environment";10import { scopedThreadKey } from "@t3tools/client-runtime/environment";
11import type { ScopedThreadRef } from "@t3tools/contracts";11import type { ChatFileAttachment, ScopedThreadRef } from "@t3tools/contracts";
12import { create } from "zustand";12import { create } from "zustand";
13import { createJSONStorage, persist } from "zustand/middleware";13import { createJSONStorage, persist } from "zustand/middleware";
1414
...@@ -39,12 +39,15 @@ export type RightPanelSurface =...@@ -39,12 +39,15 @@ export type RightPanelSurface =
39 | { id: "diff"; kind: "diff" }39 | { id: "diff"; kind: "diff" }
40 | { id: "files"; kind: "files" }40 | { id: "files"; kind: "files" }
41 | {41 | {
42 id: `file:${string}`;42 id: `file:${string}` | `attachment:${string}`;
43 kind: "file";43 kind: "file";
44 /** Workspace-relative, or absolute for a host file outside the workspace. */44 /** Workspace-relative, or absolute for a host file outside the workspace. */
45 relativePath: string;45 relativePath: string;
46 revealLine: number | null;46 revealLine: number | null;
47 revealRequestId: number;47 revealRequestId: number;
48 /** Present when the file lives in the thread's attachment store rather
49 than at a workspace or host path. */
50 attachment?: ChatFileAttachment;
48 }51 }
49 | {52 | {
50 /**53 /**
...@@ -91,6 +94,7 @@ interface RightPanelStoreState {...@@ -91,6 +94,7 @@ interface RightPanelStoreState {
91 ) => void;94 ) => void;
92 openBrowser: (ref: ScopedThreadRef, tabId: string | null) => void;95 openBrowser: (ref: ScopedThreadRef, tabId: string | null) => void;
93 openFile: (ref: ScopedThreadRef, relativePath: string, line?: number) => void;96 openFile: (ref: ScopedThreadRef, relativePath: string, line?: number) => void;
97 openAttachment: (ref: ScopedThreadRef, attachment: ChatFileAttachment) => void;
94 openPullRequest: (98 openPullRequest: (
95 ref: ScopedThreadRef,99 ref: ScopedThreadRef,
96 target: { environmentId?: string; projectId: string; repository: string; number: number },100 target: { environmentId?: string; projectId: string; repository: string; number: number },
...@@ -157,6 +161,15 @@ const fileSurface = (...@@ -157,6 +161,15 @@ const fileSurface = (
157 revealRequestId,161 revealRequestId,
158});162});
159163
164const attachmentSurface = (attachment: ChatFileAttachment): RightPanelSurface => ({
165 id: `attachment:${attachment.id}`,
166 kind: "file",
167 relativePath: attachment.name,
168 revealLine: null,
169 revealRequestId: 0,
170 attachment,
171});
172
160const terminalSurface = (terminalId: string): RightPanelSurface => ({173const terminalSurface = (terminalId: string): RightPanelSurface => ({
161 id: `terminal:${terminalId}`,174 id: `terminal:${terminalId}`,
162 kind: "terminal",175 kind: "terminal",
...@@ -401,6 +414,18 @@ export const useRightPanelStore = create<RightPanelStoreState>()(...@@ -401,6 +414,18 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
401 };414 };
402 }),415 }),
403 })),416 })),
417 openAttachment: (ref, attachment) =>
418 set((state) => ({
419 byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => {
420 const withoutStandaloneExplorer = current.surfaces.filter(
421 (surface) => surface.kind !== "files",
422 );
423 return upsertSurface(
424 { ...current, surfaces: withoutStandaloneExplorer },
425 attachmentSurface(attachment),
426 );
427 }),
428 })),
404 openTerminal: (ref, terminalId) =>429 openTerminal: (ref, terminalId) =>
405 set((state) => ({430 set((state) => ({
406 byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) =>431 byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) =>
...@@ -577,7 +602,9 @@ export const useRightPanelStore = create<RightPanelStoreState>()(...@@ -577,7 +602,9 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
577 byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => {602 byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => {
578 if (workspaceAvailable) return current;603 if (workspaceAvailable) return current;
579 const surfaces = current.surfaces.filter(604 const surfaces = current.surfaces.filter(
580 (surface) => surface.kind !== "files" && surface.kind !== "file",605 (surface) =>
606 surface.kind !== "files" &&
607 (surface.kind !== "file" || surface.attachment !== undefined),
581 );608 );
582 if (surfaces.length === current.surfaces.length) return current;609 if (surfaces.length === current.surfaces.length) return current;
583 const activeStillExists = surfaces.some(610 const activeStillExists = surfaces.some(
apps/web/src/types.ts+9
...@@ -66,6 +66,15 @@ export function isVideoAttachment(attachment: ChatFileAttachment): boolean {...@@ -66,6 +66,15 @@ export function isVideoAttachment(attachment: ChatFileAttachment): boolean {
66 return videoMimeType(attachment) !== null;66 return videoMimeType(attachment) !== null;
67}67}
6868
69export function isBrowserPreviewAttachment(attachment: ChatFileAttachment): boolean {
70 const mimeType = attachment.mimeType.split(";", 1)[0]?.trim().toLowerCase();
71 return (
72 /\.(?:html?|pdf)$/i.test(attachment.name) ||
73 mimeType === "application/pdf" ||
74 mimeType === "text/html"
75 );
76}
77
69export interface ChatMessage extends Omit<OrchestrationMessage, "attachments"> {78export interface ChatMessage extends Omit<OrchestrationMessage, "attachments"> {
70 readonly attachments?: ReadonlyArray<ChatAttachment> | undefined;79 readonly attachments?: ReadonlyArray<ChatAttachment> | undefined;
71}80}
docs/user/composer.md+7-4
...@@ -31,6 +31,9 @@ Use Share to save a copy or send it to another app. PDFs support page navigation...@@ -31,6 +31,9 @@ Use Share to save a copy or send it to another app. PDFs support page navigation
31PDF links in assistant responses open the same preview. On Android, images open in the image31PDF links in assistant responses open the same preview. On Android, images open in the image
32viewer and PDFs open the system chooser.32viewer and PDFs open the system chooser.
3333
34On web and desktop, select a sent PDF or HTML attachment to open it in the file viewer, or use the
35download button beside it to save a copy. Other attached files download when selected.
36
34Select a video attachment before or after sending to play it. Web and desktop use the browser's37Select a video attachment before or after sending to play it. Web and desktop use the browser's
35built-in controls. On mobile, videos open in a full-screen player with native playback controls.38built-in controls. On mobile, videos open in a full-screen player with native playback controls.
36Supported videos show a thumbnail in the conversation and composer.39Supported videos show a thumbnail in the conversation and composer.
...@@ -133,10 +136,10 @@ are not supported; use the Markdown embed syntax above....@@ -133,10 +136,10 @@ are not supported; use the Markdown embed syntax above.
133136
134When an agent links to a file it wrote outside the workspace, such as a Markdown report in137When an agent links to a file it wrote outside the workspace, such as a Markdown report in
135`/tmp`, select the link to open it in the file viewer. The viewer shows the file read-only, with138`/tmp`, select the link to open it in the file viewer. The viewer shows the file read-only, with
136rendered Markdown available as usual; it cannot edit files outside the workspace. HTML and PDF139rendered Markdown available as usual; it cannot edit files outside the workspace. The workspace
137files outside the workspace open the same way as ones inside it. Because such a file is served on140file tree stays hidden because it does not describe the open file. HTML and PDF files outside the
138its own, an HTML page outside the workspace cannot load scripts, styles, or images from files beside141workspace open the same way as ones inside it. Because such a file is served on its own, an HTML
139it.142page outside the workspace cannot load scripts, styles, or images from files beside it.
140143
141## HTML and PDF files in the file viewer144## HTML and PDF files in the file viewer
142145
packages/contracts/src/assets.ts+3
...@@ -30,6 +30,9 @@ export const AssetResource = Schema.Union([...@@ -30,6 +30,9 @@ export const AssetResource = Schema.Union([
30 an octet-stream download without a filename. */30 an octet-stream download without a filename. */
31 fileName: Schema.optionalKey(TrimmedNonEmptyString.check(Schema.isMaxLength(255))),31 fileName: Schema.optionalKey(TrimmedNonEmptyString.check(Schema.isMaxLength(255))),
32 mimeType: Schema.optionalKey(TrimmedNonEmptyString.check(Schema.isMaxLength(100))),32 mimeType: Schema.optionalKey(TrimmedNonEmptyString.check(Schema.isMaxLength(100))),
33 /** Generic attachments download by default. Document viewers opt into an
34 inline response after deciding the file type is safe to preview. */
35 disposition: Schema.optionalKey(Schema.Literals(["inline", "attachment"])),
33 }),36 }),
34 Schema.TaggedStruct("project-favicon", {37 Schema.TaggedStruct("project-favicon", {
35 cwd: TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)),38 cwd: TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)),