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", () => {
576576 });
577577 }).pipe(Effect.provide(testLayer)),
578578 );
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 );
579640 it.effect("issues project favicon capabilities with a signed fallback", () =>
580641 Effect.gen(function* () {
581642 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;
5151const PROJECT_FAVICON_TOKEN_BUCKET_MS = 30 * 60 * 1000;
5252const PROJECT_FAVICON_VERSION_PREFIX = "v";
5353const 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};
5462const PREVIEW_ASSET_EXTENSIONS = new Set([
5563 ...WORKSPACE_BROWSER_PREVIEW_EXTENSIONS,
5664 ...WORKSPACE_IMAGE_PREVIEW_EXTENSIONS,
......@@ -362,19 +370,31 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
362370 }
363371 // Generic files carry their extension inside the attachment id (that
364372 // shape resolves the on-disk path); images do not. Videos and images
365 // render inline; other generic files download.
366 const isGenericFile = parseAttachmentFileExtension(input.resource.attachmentId) !== null;
373 // render inline. Other generic files download, unless a document viewer
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;
367377 const videoMimeType = input.resource.mimeType?.split(";", 1)[0]?.trim() ?? "";
368378 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;
369385 claims = {
370386 version: 1,
371387 kind: "attachment",
372388 attachmentId: input.resource.attachmentId,
373 ...(isGenericFile && !isVideo ? { download: true } : {}),
374 ...(input.resource.fileName !== undefined ? { fileName: input.resource.fileName } : {}),
375 ...(input.resource.mimeType !== undefined
376 ? { mimeType: isVideo ? videoMimeType : input.resource.mimeType }
389 ...(isGenericFile && !isVideo && inlineDocumentMimeType === undefined
390 ? { download: true }
377391 : {}),
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 : {}),
378398 expiresAt,
379399 };
380400 fileName = input.resource.fileName ?? path.basename(attachmentPath);
apps/server/src/http.test.ts+13
......@@ -324,6 +324,19 @@ describe("assetResponseHeaders", () => {
324324 "X-Content-Type-Options": "nosniff",
325325 });
326326 });
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 });
327340 it("serves HTML assets as utf-8 inside a sandboxed origin", () => {
328341 for (const path of ["/workspace/page.html", "/workspace/PAGE.HTM", "/tmp/report.html"]) {
329342 expect(assetResponseHeaders(path)).toMatchObject({
apps/server/src/http.ts+19-7
......@@ -64,6 +64,8 @@ const isSafeDownloadMimeType = (mimeType: string): boolean =>
6464 !/(?:^text\/html$|\/xml(?:$|-)|\+xml$)/i.test(mimeType.trim().toLowerCase());
6565const isSafeInlineVideoMimeType = (mimeType: string): boolean =>
6666 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
6870/** RFC 6266 disposition with an ASCII fallback name plus a UTF-8 `filename*`. */
6971export function downloadContentDisposition(fileName?: string): string {
......@@ -93,7 +95,7 @@ export function assetResponseHeaders(
9395 },
9496): Record<string, string> {
9597 const lowerPath = filePath.toLowerCase();
96 const inlineVideoMimeType = options?.mimeType?.split(";", 1)[0]?.trim();
98 const inlineMimeType = options?.mimeType?.split(";", 1)[0]?.trim();
9799 return {
98100 "Cache-Control": "private, max-age=3600",
99101 "X-Content-Type-Options": "nosniff",
......@@ -106,14 +108,24 @@ export function assetResponseHeaders(
106108 ? options.mimeType
107109 : "application/octet-stream",
108110 }
109 : inlineVideoMimeType !== undefined && isSafeInlineVideoMimeType(inlineVideoMimeType)
110 ? { "Content-Type": inlineVideoMimeType }
111 : lowerPath.endsWith(".html") || lowerPath.endsWith(".htm")
111 : inlineMimeType !== undefined && isSafeInlineVideoMimeType(inlineMimeType)
112 ? { "Content-Type": inlineMimeType }
113 : inlineMimeType !== undefined && isSafeInlineDocumentMimeType(inlineMimeType)
112114 ? {
113 "Content-Type": "text/html; charset=utf-8",
114 "Content-Security-Policy": HTML_CONTENT_SECURITY_POLICY,
115 "Content-Type":
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 : {}),
115122 }
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 : {}),
117129 ...(!options?.download && lowerPath.endsWith(".svg")
118130 ? { "Content-Security-Policy": SVG_CONTENT_SECURITY_POLICY }
119131 : {}),
apps/web/src/components/ChatView.tsx+26-7
......@@ -132,6 +132,7 @@ import {
132132 DEFAULT_THREAD_TERMINAL_ID,
133133 MAX_TERMINALS_PER_GROUP,
134134 type ChatMessage,
135 isBrowserPreviewAttachment,
135136 isImageAttachment,
136137 videoMimeType,
137138 type SessionPhase,
......@@ -2602,7 +2603,7 @@ function ChatViewContent(props: ChatViewProps) {
26022603 });
26032604 }, []);
26042605 const serverMessages = activeThread?.messages;
2605 const openFileAttachment = useCallback(
2606 const downloadFileAttachment = useCallback(
26062607 async (attachment: ChatFileAttachment) => {
26072608 const connection = readPreparedConnection(environmentId);
26082609 if (!connection) {
......@@ -2654,6 +2655,16 @@ function ChatViewContent(props: ChatViewProps) {
26542655 },
26552656 [createAttachmentAssetUrl, environmentId, routeThreadKey],
26562657 );
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 );
26572668 const serverAttachmentIds = useMemo(() => {
26582669 const attachmentIds = new Set<string>();
26592670 for (const message of serverMessages ?? []) {
......@@ -7351,14 +7362,18 @@ function ChatViewContent(props: ChatViewProps) {
73517362 />
73527363 ) : (renderedRightPanelSurface?.kind === "files" ||
73537364 renderedRightPanelSurface?.kind === "file") &&
7354 activeProject &&
7355 activeWorkspaceRoot ? (
7365 ((activeProject && activeWorkspaceRoot) ||
7366 (renderedRightPanelSurface.kind === "file" && renderedRightPanelSurface.attachment)) ? (
73567367 <Suspense fallback={null}>
73577368 <FilePreviewPanel
7358 key={`${activeProject.environmentId}:${activeWorkspaceRoot}`}
7359 environmentId={activeProject.environmentId}
7360 cwd={activeWorkspaceRoot}
7361 projectName={activeProject.title}
7369 key={`${activeThread.environmentId}:${
7370 renderedRightPanelSurface.kind === "file" && renderedRightPanelSurface.attachment
7371 ? `attachment:${renderedRightPanelSurface.attachment.id}`
7372 : activeWorkspaceRoot
7373 }`}
7374 environmentId={activeThread.environmentId}
7375 cwd={activeWorkspaceRoot ?? ""}
7376 projectName={activeProject?.title ?? ""}
73627377 threadRef={activeThreadRef}
73637378 composerDraftTarget={composerDraftTarget}
73647379 keybindings={keybindings}
......@@ -7368,6 +7383,9 @@ function ChatViewContent(props: ChatViewProps) {
73687383 ? renderedRightPanelSurface.relativePath
73697384 : null
73707385 }
7386 {...(renderedRightPanelSurface.kind === "file" && renderedRightPanelSurface.attachment
7387 ? { attachment: renderedRightPanelSurface.attachment }
7388 : {})}
73717389 revealLine={
73727390 renderedRightPanelSurface.kind === "file"
73737391 ? (renderedRightPanelSurface.revealLine ?? null)
......@@ -7508,6 +7526,7 @@ function ChatViewContent(props: ChatViewProps) {
75087526 isRevertingCheckpoint={isRevertingCheckpoint}
75097527 onImageExpand={onExpandTimelineImage}
75107528 onFileOpen={openFileAttachment}
7529 onFileDownload={downloadFileAttachment}
75117530 openingVideoAttachmentId={openingVideoAttachmentId}
75127531 markdownCwd={gitCwd ?? undefined}
75137532 resolvedTheme={resolvedTheme}
apps/web/src/components/RightPanelTabs.tsx+4-2
......@@ -793,7 +793,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
793793 if (surfaceIndex < 0) return;
794794
795795 const items: ContextMenuItem<TabContextMenuAction>[] = [];
796 if (surface.kind === "file") {
796 if (surface.kind === "file" && surface.attachment === undefined) {
797797 items.push({ id: "copy-path", label: "Copy path" });
798798 }
799799 const menuPreviewTabId = previewTabIdOf(surface, props.previewSessions);
......@@ -837,7 +837,9 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
837837 const action = await api.contextMenu.show(items, { x: event.clientX, y: event.clientY });
838838 switch (action) {
839839 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 }
841843 break;
842844 case "toggle-mute": {
843845 // 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", () => {
437437 expect(resolveTimelineMinimapInteractiveWidth(40, true)).toBe("22rem");
438438 });
439439
440 it("renders generic attachments as download links instead of image previews", () => {
440 it("gives browser documents separate preview and download controls", () => {
441441 const entry = {
442442 ...buildUserTimelineEntry("Read the report."),
443443 message: {
......@@ -459,9 +459,9 @@ describe("MessagesTimeline", () => {
459459 <MessagesTimeline {...buildProps()} timelineEntries={[entry]} />,
460460 );
461461
462 expect(markup).toContain(
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">',
464 );
462 expect(markup).toContain('aria-label="Preview report.pdf"');
463 expect(markup).toContain('aria-label="Download report.pdf"');
464 expect(markup).not.toContain('download="report.pdf"');
465465 expect(markup).not.toContain('alt="report.pdf"');
466466 });
467467
......@@ -502,7 +502,7 @@ describe("MessagesTimeline", () => {
502502 expect(busyMarkup).not.toContain('disabled=""');
503503 expect(busyMarkup).toContain(">Loading…</span>");
504504 });
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", () => {
506506 const entry = {
507507 ...buildUserTimelineEntry("Read the report."),
508508 message: {
......@@ -511,8 +511,8 @@ describe("MessagesTimeline", () => {
511511 {
512512 type: "file" as const,
513513 id: "attachment-report-pdf",
514 name: "report.pdf",
515 mimeType: "application/pdf",
514 name: "archive.zip",
515 mimeType: "application/zip",
516516 sizeBytes: 42,
517517 },
518518 ],
......@@ -524,9 +524,9 @@ describe("MessagesTimeline", () => {
524524 );
525525
526526 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">',
528528 );
529 expect(markup).not.toContain("href=");
529 expect(markup).not.toContain("<a href=");
530530 });
531531
532532 it("does not download an optimistic file before the server supplies its attachment ID", () => {
......@@ -581,7 +581,7 @@ describe("MessagesTimeline", () => {
581581 expect(markup).toContain("voice-memo.ogg");
582582 expect(markup).not.toContain('aria-label="Download voice-memo.ogg"');
583583 expect(markup).not.toContain('alt="voice-memo.ogg"');
584 expect(markup).not.toContain("href=");
584 expect(markup).not.toContain("<a href=");
585585 });
586586
587587 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 {
5151} from "../../session-logic";
5252import {
5353 type ChatImageAttachment,
54 isBrowserPreviewAttachment,
5455 isFileAttachment,
5556 isImageAttachment,
5657 isVideoAttachment,
......@@ -73,7 +74,6 @@ import {
7374 CircleAlertIcon,
7475 DownloadIcon,
7576 EyeIcon,
76 FileIcon,
7777 GlobeIcon,
7878 HammerIcon,
7979 MessageCircleIcon,
......@@ -99,6 +99,7 @@ import {
9999 keepTimelineEndVisibleAfterOverlayGrowth,
100100} from "./timelineScrollAnchoring";
101101import { MessageCopyButton } from "./MessageCopyButton";
102import { PierreEntryIcon } from "./PierreEntryIcon";
102103import { AssistantSelectionToolbar } from "./AssistantSelectionToolbar";
103104import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection";
104105import {
......@@ -186,6 +187,7 @@ interface TimelineRowSharedState {
186187 onUseArtifactTemplate: (template: CodexArtifactTemplate) => void;
187188 onImageExpand: (preview: ExpandedImagePreview) => void;
188189 onFileOpen: (attachment: ChatFileAttachment) => void;
190 onFileDownload: (attachment: ChatFileAttachment) => void;
189191 openingVideoAttachmentId: string | null;
190192 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;
191193 onToggleTurnFold: (turnId: TurnId) => void;
......@@ -286,6 +288,7 @@ interface MessagesTimelineProps {
286288 isRevertingCheckpoint: boolean;
287289 onImageExpand: (preview: ExpandedImagePreview) => void;
288290 onFileOpen?: (attachment: ChatFileAttachment) => void;
291 onFileDownload?: (attachment: ChatFileAttachment) => void;
289292 openingVideoAttachmentId: string | null;
290293 activeThreadEnvironmentId: EnvironmentId;
291294 markdownCwd: string | undefined;
......@@ -337,6 +340,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
337340 isRevertingCheckpoint,
338341 onImageExpand,
339342 onFileOpen = NOOP_OPEN_ATTACHMENT,
343 onFileDownload = NOOP_OPEN_ATTACHMENT,
340344 openingVideoAttachmentId,
341345 activeThreadEnvironmentId,
342346 markdownCwd,
......@@ -629,6 +633,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
629633 onUseArtifactTemplate,
630634 onImageExpand,
631635 onFileOpen,
636 onFileDownload,
632637 openingVideoAttachmentId,
633638 onOpenTurnDiff,
634639 onToggleTurnFold,
......@@ -652,6 +657,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
652657 onUseArtifactTemplate,
653658 onImageExpand,
654659 onFileOpen,
660 onFileDownload,
655661 openingVideoAttachmentId,
656662 onOpenTurnDiff,
657663 onToggleTurnFold,
......@@ -1221,16 +1227,53 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
12211227 {otherUserFiles.length > 0 || unknownAttachments.length > 0 ? (
12221228 <div className="mb-2 flex flex-col gap-1">
12231229 {otherUserFiles.map((file) => {
1224 const content = (
1230 const opensInPreview = isBrowserPreviewAttachment(file);
1231 const fileIdentity = (
12251232 <>
1226 <FileIcon className="size-4 shrink-0 text-secondary-label" />
1233 <PierreEntryIcon pathValue={file.name} kind="file" theme={ctx.resolvedTheme} />
12271234 <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}
12281271 {file.downloadable === false ? null : (
12291272 <DownloadIcon className="size-4 shrink-0" />
12301273 )}
12311274 </>
12321275 );
1233 return file.previewUrl ? (
1276 return file.previewUrl && !opensInPreview ? (
12341277 <a
12351278 key={file.id}
12361279 href={file.previewUrl}
......@@ -1247,7 +1290,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
12471290 <button
12481291 key={file.id}
12491292 type="button"
1250 aria-label={`Download ${file.name}`}
1293 aria-label={`${opensInPreview ? "Preview" : "Download"} ${file.name}`}
12511294 onClick={() => ctx.onFileOpen(file)}
12521295 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"
12531296 >
......@@ -1257,7 +1300,11 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
12571300 })}
12581301 {unknownAttachments.map((attachment) => (
12591302 <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 />
12611308 <span className="min-w-0 flex-1 truncate">{attachment.name}</span>
12621309 </div>
12631310 ))}
apps/web/src/components/files/FilePreviewPanel.test.ts+41-1
......@@ -5,7 +5,11 @@ import {
55 normalizeFileCommentRange,
66 remapFileCommentAnnotations,
77} from "./fileCommentAnnotations";
8import { isMarkdownPreviewFile, setMarkdownTaskChecked } from "./filePreviewMode";
8import {
9 isMarkdownPreviewFile,
10 setMarkdownTaskChecked,
11 shouldShowFileExplorer,
12} from "./filePreviewMode";
913
1014describe("file comment annotations", () => {
1115 it("normalizes and formats selected line ranges", () => {
......@@ -66,6 +70,42 @@ describe("isMarkdownPreviewFile", () => {
6670 });
6771});
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
69109describe("setMarkdownTaskChecked", () => {
70110 const markdown = "- [ ] First\n- [x] Second\n";
71111
apps/web/src/components/files/FilePreviewPanel.tsx+152-55
......@@ -1,4 +1,5 @@
11import type {
2 ChatFileAttachment,
23 EditorId,
34 EnvironmentId,
45 ResolvedKeybindingsConfig,
......@@ -23,6 +24,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2324import { isBrowserPreviewFile, openFileInPreview } from "~/browser/openFileInPreview";
2425import { useAssetUrlRefresh, useAssetUrlState } from "~/assets/assetUrls";
2526import { OpenInPicker } from "~/components/chat/OpenInPicker";
27import { PierreEntryIcon } from "~/components/chat/PierreEntryIcon";
2628import { MediaVideoPlayer } from "~/components/media/MediaVideoPlayer";
2729import { MediaActions, type MediaActionSource } from "~/components/media/MediaActions";
2830import { useRemoteOpenState } from "~/remoteOpen";
......@@ -64,7 +66,11 @@ import { installFileEditorDismissal } from "./fileEditorDismissal";
6466import { resolveCenteredFileLineScrollTop } from "./fileLineReveal";
6567import { DiffCommentAnnotation } from "../diffs/DiffCommentAnnotation";
6668import { projectFileCacheKey, projectFileEditorCacheKey } from "./fileContentRevision";
67import { isMarkdownPreviewFile, setMarkdownTaskChecked } from "./filePreviewMode";
69import {
70 isMarkdownPreviewFile,
71 setMarkdownTaskChecked,
72 shouldShowFileExplorer,
73} from "./filePreviewMode";
6874import { FileSaveCoordinator } from "./fileSaveCoordinator";
6975import {
7076 confirmProjectFileQueryData,
......@@ -78,6 +84,7 @@ interface FilePreviewPanelProps {
7884 cwd: string;
7985 projectName: string;
8086 relativePath: string | null;
87 attachment?: ChatFileAttachment;
8188 threadRef: ScopedThreadRef;
8289 composerDraftTarget: ScopedThreadRef | DraftId;
8390 keybindings: ResolvedKeybindingsConfig;
......@@ -200,6 +207,69 @@ function WorkspaceImagePreview(props: {
200207
201208const 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
203273/**
204274 * Renders an HTML or PDF file in place from its signed asset URL. HTML runs in
205275 * a sandboxed frame with an opaque origin, so a page cannot reach the app's
......@@ -244,19 +314,11 @@ function WorkspaceBrowserPreview(props: {
244314 </div>
245315 );
246316 }
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}
317 return (
318 <BrowserDocumentFrame
319 src={`${assetUrl.url}${revisionSuffix}`}
257320 title={props.title}
258 className={className}
259 sandbox="allow-scripts allow-forms allow-popups allow-modals"
321 pdf={isPdfPreviewFile(props.absolutePath)}
260322 />
261323 );
262324}
......@@ -922,6 +984,7 @@ export default function FilePreviewPanel({
922984 cwd,
923985 projectName,
924986 relativePath,
987 attachment,
925988 threadRef,
926989 composerDraftTarget,
927990 keybindings,
......@@ -951,9 +1014,20 @@ export default function FilePreviewPanel({
9511014 const isPdf = relativePath !== null && isPdfPreviewFile(relativePath);
9521015 const isHtml = relativePath !== null && !isPdf && isBrowserPreviewFile(relativePath);
9531016 // A file outside the workspace (an absolute path) is shown, never edited.
954 const isHostFile = relativePath !== null && isAbsolutePath(relativePath);
955 const file = useProjectFileQuery(environmentId, cwd, relativePath, !isMedia && !isPdf);
1017 const isHostFile =
1018 attachment !== undefined || (relativePath !== null && isAbsolutePath(relativePath));
1019 const file = useProjectFileQuery(
1020 environmentId,
1021 cwd,
1022 relativePath,
1023 attachment === undefined && !isMedia && !isPdf,
1024 );
9561025 const [explorerOpen, setExplorerOpen] = useState(initialExplorerOpen);
1026 const showExplorer = shouldShowFileExplorer({
1027 relativePath,
1028 explorerOpen,
1029 attachmentOpen: attachment !== undefined,
1030 });
9571031 // Reading markdown rendered is a preference, not a property of one file. Keeping
9581032 // it on the panel meant a thread switch dropped it and forced source back.
9591033 const [renderMarkdownPreferred, setRenderMarkdownPreferred] = useLocalStorage(
......@@ -980,20 +1054,27 @@ export default function FilePreviewPanel({
9801054 (handledReveal?.path === relativePath && handledReveal.requestId === revealRequestId);
9811055 const renderMarkdown = isMarkdown && renderMarkdownPreferred && revealHandled;
9821056 const renderBrowserFile = isPdf || (isHtml && renderBrowserFilePreferred && revealHandled);
983 const canToggleRendered = isMarkdown || isHtml;
1057 const canToggleRendered = attachment === undefined && (isMarkdown || isHtml);
9841058 const rendered = isMarkdown ? renderMarkdown : renderBrowserFile;
9851059 const setRenderedPreferred = isMarkdown
9861060 ? setRenderMarkdownPreferred
9871061 : setRenderBrowserFilePreferred;
9881062 const canOpenInBrowser =
9891063 relativePath !== null &&
1064 attachment === undefined &&
9901065 !isVideo &&
9911066 isPreviewSupportedInRuntime() &&
9921067 isBrowserPreviewFile(relativePath);
993 const absolutePath = relativePath ? resolvePathLinkTarget(relativePath, cwd) : null;
1068 const absolutePath =
1069 relativePath && attachment === undefined ? resolvePathLinkTarget(relativePath, cwd) : null;
9941070 const onFilePostRender = useFileLineReveal(relativePath, revealLine, revealRequestId);
9951071 useWorkspaceMutationRefresh({
996 enabled: relativePath !== null && !isMedia && !isPdf && !selectedFilePending,
1072 enabled:
1073 attachment === undefined &&
1074 relativePath !== null &&
1075 !isMedia &&
1076 !isPdf &&
1077 !selectedFilePending,
9971078 mutationId: workspaceMutationId,
9981079 refresh: file.refresh,
9991080 resourceKey: `file:${environmentId}:${cwd}:${relativePath ?? ""}`,
......@@ -1050,24 +1131,36 @@ export default function FilePreviewPanel({
10501131 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"
10511132 data-surface-subheader
10521133 >
1053 <ScrollArea
1054 ref={breadcrumbRef}
1055 hideScrollbars
1056 scrollFade
1057 className="min-w-0 flex-1 rounded-none"
1058 data-file-breadcrumbs
1059 >
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}
1134 {attachment ? (
1135 <div className="flex min-w-0 flex-1 items-center gap-1.5 text-xs">
1136 <PierreEntryIcon
1137 pathValue={attachment.name}
1138 kind="file"
1139 theme={resolvedTheme}
1140 className="size-3.5"
10681141 />
1142 <span className="truncate font-medium">{attachment.name}</span>
10691143 </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 )}
10711164 {absolutePath &&
10721165 (environmentId === primaryEnvironmentId || remoteOpenState.mode !== "local-exec") ? (
10731166 <OpenInPicker
......@@ -1124,25 +1217,27 @@ export default function FilePreviewPanel({
11241217 <TooltipPopup>Open file in preview browser</TooltipPopup>
11251218 </Tooltip>
11261219 ) : null}
1127 <Tooltip>
1128 <TooltipTrigger
1129 render={
1130 <Toggle
1131 className="shrink-0"
1132 pressed={explorerOpen}
1133 onPressedChange={toggleExplorer}
1134 aria-label={explorerOpen ? "Hide file explorer" : "Show file explorer"}
1135 variant="ghost"
1136 size="sm"
1137 >
1138 <FolderTree className="size-3.5" />
1139 </Toggle>
1140 }
1141 />
1142 <TooltipPopup>
1143 {explorerOpen ? "Hide file explorer" : "Show file explorer"}
1144 </TooltipPopup>
1145 </Tooltip>
1220 {!isHostFile ? (
1221 <Tooltip>
1222 <TooltipTrigger
1223 render={
1224 <Toggle
1225 className="shrink-0"
1226 pressed={explorerOpen}
1227 onPressedChange={toggleExplorer}
1228 aria-label={explorerOpen ? "Hide file explorer" : "Show file explorer"}
1229 variant="ghost"
1230 size="sm"
1231 >
1232 <FolderTree className="size-3.5" />
1233 </Toggle>
1234 }
1235 />
1236 <TooltipPopup>
1237 {explorerOpen ? "Hide file explorer" : "Show file explorer"}
1238 </TooltipPopup>
1239 </Tooltip>
1240 ) : null}
11461241 </div>
11471242 ) : null}
11481243 {relativePath && !isMedia && !renderBrowserFile && file.data?.truncated ? (
......@@ -1157,7 +1252,9 @@ export default function FilePreviewPanel({
11571252 relativePath ? "flex" : "hidden",
11581253 )}
11591254 >
1160 {relativePath && isVideo && absolutePath ? (
1255 {relativePath && attachment ? (
1256 <AttachmentBrowserPreview environmentId={environmentId} attachment={attachment} />
1257 ) : relativePath && isVideo && absolutePath ? (
11611258 <WorkspaceVideoPreview
11621259 key={`${environmentId}:${threadRef.threadId}:${absolutePath}`}
11631260 environmentId={environmentId}
......@@ -1250,7 +1347,7 @@ export default function FilePreviewPanel({
12501347 )
12511348 ) : null}
12521349 </div>
1253 {explorerOpen || relativePath === null ? (
1350 {showExplorer ? (
12541351 <aside
12551352 className={cn(
12561353 "flex min-h-0 shrink-0 bg-background",
apps/web/src/components/files/filePreviewMode.ts+13
......@@ -1,5 +1,18 @@
1import { isAbsolutePath } from "~/terminal-links";
2
13export 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
316export function setMarkdownTaskChecked(
417 markdown: string,
518 markerOffset: number,
apps/web/src/rightPanelStore.test.ts+73
......@@ -279,6 +279,50 @@ describe("rightPanelStore", () => {
279279 });
280280 });
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
282326 it("updates line reveal requests when reopening a file surface", () => {
283327 useRightPanelStore.getState().openFile(refA, "src/index.ts", 42);
284328 useRightPanelStore.getState().openFile(refA, "src/index.ts", 87);
......@@ -336,6 +380,35 @@ describe("rightPanelStore", () => {
336380 });
337381 });
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
339412 it("close hides the panel without clearing its selected surface", () => {
340413 useRightPanelStore.getState().open(refA, "agents");
341414 useRightPanelStore.getState().close(refA);
apps/web/src/rightPanelStore.ts+30-3
......@@ -8,7 +8,7 @@
88 * workspace paths, and diff/files remain singleton surfaces.
99 */
1010import { scopedThreadKey } from "@t3tools/client-runtime/environment";
11import type { ScopedThreadRef } from "@t3tools/contracts";
11import type { ChatFileAttachment, ScopedThreadRef } from "@t3tools/contracts";
1212import { create } from "zustand";
1313import { createJSONStorage, persist } from "zustand/middleware";
1414
......@@ -39,12 +39,15 @@ export type RightPanelSurface =
3939 | { id: "diff"; kind: "diff" }
4040 | { id: "files"; kind: "files" }
4141 | {
42 id: `file:${string}`;
42 id: `file:${string}` | `attachment:${string}`;
4343 kind: "file";
4444 /** Workspace-relative, or absolute for a host file outside the workspace. */
4545 relativePath: string;
4646 revealLine: number | null;
4747 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;
4851 }
4952 | {
5053 /**
......@@ -91,6 +94,7 @@ interface RightPanelStoreState {
9194 ) => void;
9295 openBrowser: (ref: ScopedThreadRef, tabId: string | null) => void;
9396 openFile: (ref: ScopedThreadRef, relativePath: string, line?: number) => void;
97 openAttachment: (ref: ScopedThreadRef, attachment: ChatFileAttachment) => void;
9498 openPullRequest: (
9599 ref: ScopedThreadRef,
96100 target: { environmentId?: string; projectId: string; repository: string; number: number },
......@@ -157,6 +161,15 @@ const fileSurface = (
157161 revealRequestId,
158162});
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
160173const terminalSurface = (terminalId: string): RightPanelSurface => ({
161174 id: `terminal:${terminalId}`,
162175 kind: "terminal",
......@@ -401,6 +414,18 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
401414 };
402415 }),
403416 })),
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 })),
404429 openTerminal: (ref, terminalId) =>
405430 set((state) => ({
406431 byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) =>
......@@ -577,7 +602,9 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
577602 byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => {
578603 if (workspaceAvailable) return current;
579604 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),
581608 );
582609 if (surfaces.length === current.surfaces.length) return current;
583610 const activeStillExists = surfaces.some(
apps/web/src/types.ts+9
......@@ -66,6 +66,15 @@ export function isVideoAttachment(attachment: ChatFileAttachment): boolean {
6666 return videoMimeType(attachment) !== null;
6767}
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
6978export interface ChatMessage extends Omit<OrchestrationMessage, "attachments"> {
7079 readonly attachments?: ReadonlyArray<ChatAttachment> | undefined;
7180}
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
3131PDF links in assistant responses open the same preview. On Android, images open in the image
3232viewer 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
3437Select a video attachment before or after sending to play it. Web and desktop use the browser's
3538built-in controls. On mobile, videos open in a full-screen player with native playback controls.
3639Supported videos show a thumbnail in the conversation and composer.
......@@ -133,10 +136,10 @@ are not supported; use the Markdown embed syntax above.
133136
134137When an agent links to a file it wrote outside the workspace, such as a Markdown report in
135138`/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 PDF
137files outside the workspace open the same way as ones inside it. Because such a file is served on
138its own, an HTML page outside the workspace cannot load scripts, styles, or images from files beside
139it.
139rendered Markdown available as usual; it cannot edit files outside the workspace. The workspace
140file tree stays hidden because it does not describe the open file. HTML and PDF files outside the
141workspace open the same way as ones inside it. Because such a file is served on its own, an HTML
142page outside the workspace cannot load scripts, styles, or images from files beside it.
140143
141144## HTML and PDF files in the file viewer
142145
packages/contracts/src/assets.ts+3
......@@ -30,6 +30,9 @@ export const AssetResource = Schema.Union([
3030 an octet-stream download without a filename. */
3131 fileName: Schema.optionalKey(TrimmedNonEmptyString.check(Schema.isMaxLength(255))),
3232 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"])),
3336 }),
3437 Schema.TaggedStruct("project-favicon", {
3538 cwd: TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)),