| author | |
| committer | |
| log | 77512998485718eb1b6c336e20f196eb40a6a32f |
| tree | aa6315a8037f02ba4e3f29ec0f086451a42226cc |
| parent | dbc7bfa3f36cc61a3f1a2015e47d71ae9dee5421 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 576 | }); |
| 577 | 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 | 640 | it.effect("issues project favicon capabilities with a signed fallback", () => |
| 580 | 641 | Effect.gen(function* () { |
| 581 | 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 | 51 | const PROJECT_FAVICON_TOKEN_BUCKET_MS = 30 * 60 * 1000; |
| 52 | 52 | const PROJECT_FAVICON_VERSION_PREFIX = "v"; |
| 53 | 53 | const 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. | |
| 56 | const INLINE_DOCUMENT_EXTENSIONS = new Set(["pdf", "html", "htm"]); | |
| 57 | const INLINE_DOCUMENT_MIME_TYPES: Record<string, string> = { | |
| 58 | pdf: "application/pdf", | |
| 59 | html: "text/html", | |
| 60 | htm: "text/html", | |
| 61 | }; | |
| 54 | 62 | const PREVIEW_ASSET_EXTENSIONS = new Set([ |
| 55 | 63 | ...WORKSPACE_BROWSER_PREVIEW_EXTENSIONS, |
| 56 | 64 | ...WORKSPACE_IMAGE_PREVIEW_EXTENSIONS, |
| ... | ... | @@ -362,19 +370,31 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i |
| 362 | 370 | } |
| 363 | 371 | // Generic files carry their extension inside the attachment id (that |
| 364 | 372 | // 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; | |
| 367 | 377 | const videoMimeType = input.resource.mimeType?.split(";", 1)[0]?.trim() ?? ""; |
| 368 | 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 | 385 | claims = { |
| 370 | 386 | version: 1, |
| 371 | 387 | kind: "attachment", |
| 372 | 388 | 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 } | |
| 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 | 398 | expiresAt, |
| 379 | 399 | }; |
| 380 | 400 | fileName = input.resource.fileName ?? path.basename(attachmentPath); |
apps/server/src/http.test.ts+13| ... | ... | @@ -324,6 +324,19 @@ describe("assetResponseHeaders", () => { |
| 324 | 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 | 340 | it("serves HTML assets as utf-8 inside a sandboxed origin", () => { |
| 328 | 341 | for (const path of ["/workspace/page.html", "/workspace/PAGE.HTM", "/tmp/report.html"]) { |
| 329 | 342 | expect(assetResponseHeaders(path)).toMatchObject({ |
apps/server/src/http.ts+19-7| ... | ... | @@ -64,6 +64,8 @@ const isSafeDownloadMimeType = (mimeType: string): boolean => |
| 64 | 64 | !/(?:^text\/html$|\/xml(?:$|-)|\+xml$)/i.test(mimeType.trim().toLowerCase()); |
| 65 | 65 | const isSafeInlineVideoMimeType = (mimeType: string): boolean => |
| 66 | 66 | DOWNLOAD_MIME_TYPE_PATTERN.test(mimeType) && mimeType.toLowerCase().startsWith("video/"); |
| 67 | const isSafeInlineDocumentMimeType = (mimeType: string): boolean => | |
| 68 | mimeType.toLowerCase() === "application/pdf" || mimeType.toLowerCase() === "text/html"; | |
| 67 | 69 | |
| 68 | 70 | /** RFC 6266 disposition with an ASCII fallback name plus a UTF-8 `filename*`. */ |
| 69 | 71 | export function downloadContentDisposition(fileName?: string): string { |
| ... | ... | @@ -93,7 +95,7 @@ export function assetResponseHeaders( |
| 93 | 95 | }, |
| 94 | 96 | ): Record<string, string> { |
| 95 | 97 | const lowerPath = filePath.toLowerCase(); |
| 96 | const inlineVideoMimeType = options?.mimeType?.split(";", 1)[0]?.trim(); | |
| 98 | const inlineMimeType = options?.mimeType?.split(";", 1)[0]?.trim(); | |
| 97 | 99 | return { |
| 98 | 100 | "Cache-Control": "private, max-age=3600", |
| 99 | 101 | "X-Content-Type-Options": "nosniff", |
| ... | ... | @@ -106,14 +108,24 @@ export function assetResponseHeaders( |
| 106 | 108 | ? options.mimeType |
| 107 | 109 | : "application/octet-stream", |
| 108 | 110 | } |
| 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) | |
| 112 | 114 | ? { |
| 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 | : {}), | |
| 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 | 129 | ...(!options?.download && lowerPath.endsWith(".svg") |
| 118 | 130 | ? { "Content-Security-Policy": SVG_CONTENT_SECURITY_POLICY } |
| 119 | 131 | : {}), |
apps/web/src/components/ChatView.tsx+26-7| ... | ... | @@ -132,6 +132,7 @@ import { |
| 132 | 132 | DEFAULT_THREAD_TERMINAL_ID, |
| 133 | 133 | MAX_TERMINALS_PER_GROUP, |
| 134 | 134 | type ChatMessage, |
| 135 | isBrowserPreviewAttachment, | |
| 135 | 136 | isImageAttachment, |
| 136 | 137 | videoMimeType, |
| 137 | 138 | type SessionPhase, |
| ... | ... | @@ -2602,7 +2603,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 2602 | 2603 | }); |
| 2603 | 2604 | }, []); |
| 2604 | 2605 | const serverMessages = activeThread?.messages; |
| 2605 | const openFileAttachment = useCallback( | |
| 2606 | const downloadFileAttachment = useCallback( | |
| 2606 | 2607 | async (attachment: ChatFileAttachment) => { |
| 2607 | 2608 | const connection = readPreparedConnection(environmentId); |
| 2608 | 2609 | if (!connection) { |
| ... | ... | @@ -2654,6 +2655,16 @@ function ChatViewContent(props: ChatViewProps) { |
| 2654 | 2655 | }, |
| 2655 | 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 | 2668 | const serverAttachmentIds = useMemo(() => { |
| 2658 | 2669 | const attachmentIds = new Set<string>(); |
| 2659 | 2670 | for (const message of serverMessages ?? []) { |
| ... | ... | @@ -7351,14 +7362,18 @@ function ChatViewContent(props: ChatViewProps) { |
| 7351 | 7362 | /> |
| 7352 | 7363 | ) : (renderedRightPanelSurface?.kind === "files" || |
| 7353 | 7364 | renderedRightPanelSurface?.kind === "file") && |
| 7354 | activeProject && | |
| 7355 | activeWorkspaceRoot ? ( | |
| 7365 | ((activeProject && activeWorkspaceRoot) || | |
| 7366 | (renderedRightPanelSurface.kind === "file" && renderedRightPanelSurface.attachment)) ? ( | |
| 7356 | 7367 | <Suspense fallback={null}> |
| 7357 | 7368 | <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 ?? ""} | |
| 7362 | 7377 | threadRef={activeThreadRef} |
| 7363 | 7378 | composerDraftTarget={composerDraftTarget} |
| 7364 | 7379 | keybindings={keybindings} |
| ... | ... | @@ -7368,6 +7383,9 @@ function ChatViewContent(props: ChatViewProps) { |
| 7368 | 7383 | ? renderedRightPanelSurface.relativePath |
| 7369 | 7384 | : null |
| 7370 | 7385 | } |
| 7386 | {...(renderedRightPanelSurface.kind === "file" && renderedRightPanelSurface.attachment | |
| 7387 | ? { attachment: renderedRightPanelSurface.attachment } | |
| 7388 | : {})} | |
| 7371 | 7389 | revealLine={ |
| 7372 | 7390 | renderedRightPanelSurface.kind === "file" |
| 7373 | 7391 | ? (renderedRightPanelSurface.revealLine ?? null) |
| ... | ... | @@ -7508,6 +7526,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 7508 | 7526 | isRevertingCheckpoint={isRevertingCheckpoint} |
| 7509 | 7527 | onImageExpand={onExpandTimelineImage} |
| 7510 | 7528 | onFileOpen={openFileAttachment} |
| 7529 | onFileDownload={downloadFileAttachment} | |
| 7511 | 7530 | openingVideoAttachmentId={openingVideoAttachmentId} |
| 7512 | 7531 | markdownCwd={gitCwd ?? undefined} |
| 7513 | 7532 | resolvedTheme={resolvedTheme} |
apps/web/src/components/RightPanelTabs.tsx+4-2| ... | ... | @@ -793,7 +793,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { |
| 793 | 793 | if (surfaceIndex < 0) return; |
| 794 | 794 | |
| 795 | 795 | const items: ContextMenuItem<TabContextMenuAction>[] = []; |
| 796 | if (surface.kind === "file") { | |
| 796 | if (surface.kind === "file" && surface.attachment === undefined) { | |
| 797 | 797 | items.push({ id: "copy-path", label: "Copy path" }); |
| 798 | 798 | } |
| 799 | 799 | const menuPreviewTabId = previewTabIdOf(surface, props.previewSessions); |
| ... | ... | @@ -837,7 +837,9 @@ export function RightPanelTabs(props: RightPanelTabsProps) { |
| 837 | 837 | const action = await api.contextMenu.show(items, { x: event.clientX, y: event.clientY }); |
| 838 | 838 | switch (action) { |
| 839 | 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 | 843 | break; |
| 842 | 844 | case "toggle-mute": { |
| 843 | 845 | // 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 | 437 | expect(resolveTimelineMinimapInteractiveWidth(40, true)).toBe("22rem"); |
| 438 | 438 | }); |
| 439 | 439 | |
| 440 | it("renders generic attachments as download links instead of image previews", () => { | |
| 440 | it("gives browser documents separate preview and download controls", () => { | |
| 441 | 441 | const entry = { |
| 442 | 442 | ...buildUserTimelineEntry("Read the report."), |
| 443 | 443 | message: { |
| ... | ... | @@ -459,9 +459,9 @@ describe("MessagesTimeline", () => { |
| 459 | 459 | <MessagesTimeline {...buildProps()} timelineEntries={[entry]} />, |
| 460 | 460 | ); |
| 461 | 461 | |
| 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"'); | |
| 465 | 465 | expect(markup).not.toContain('alt="report.pdf"'); |
| 466 | 466 | }); |
| 467 | 467 | |
| ... | ... | @@ -502,7 +502,7 @@ describe("MessagesTimeline", () => { |
| 502 | 502 | expect(busyMarkup).not.toContain('disabled=""'); |
| 503 | 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 | 506 | const entry = { |
| 507 | 507 | ...buildUserTimelineEntry("Read the report."), |
| 508 | 508 | message: { |
| ... | ... | @@ -511,8 +511,8 @@ describe("MessagesTimeline", () => { |
| 511 | 511 | { |
| 512 | 512 | type: "file" as const, |
| 513 | 513 | id: "attachment-report-pdf", |
| 514 | name: "report.pdf", | |
| 515 | mimeType: "application/pdf", | |
| 514 | name: "archive.zip", | |
| 515 | mimeType: "application/zip", | |
| 516 | 516 | sizeBytes: 42, |
| 517 | 517 | }, |
| 518 | 518 | ], |
| ... | ... | @@ -524,9 +524,9 @@ describe("MessagesTimeline", () => { |
| 524 | 524 | ); |
| 525 | 525 | |
| 526 | 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 | }); |
| 531 | 531 | |
| 532 | 532 | it("does not download an optimistic file before the server supplies its attachment ID", () => { |
| ... | ... | @@ -581,7 +581,7 @@ describe("MessagesTimeline", () => { |
| 581 | 581 | expect(markup).toContain("voice-memo.ogg"); |
| 582 | 582 | expect(markup).not.toContain('aria-label="Download voice-memo.ogg"'); |
| 583 | 583 | expect(markup).not.toContain('alt="voice-memo.ogg"'); |
| 584 | expect(markup).not.toContain("href="); | |
| 584 | expect(markup).not.toContain("<a href="); | |
| 585 | 585 | }); |
| 586 | 586 | |
| 587 | 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 | 51 | } from "../../session-logic"; |
| 52 | 52 | import { |
| 53 | 53 | type ChatImageAttachment, |
| 54 | isBrowserPreviewAttachment, | |
| 54 | 55 | isFileAttachment, |
| 55 | 56 | isImageAttachment, |
| 56 | 57 | isVideoAttachment, |
| ... | ... | @@ -73,7 +74,6 @@ import { |
| 73 | 74 | CircleAlertIcon, |
| 74 | 75 | DownloadIcon, |
| 75 | 76 | EyeIcon, |
| 76 | FileIcon, | |
| 77 | 77 | GlobeIcon, |
| 78 | 78 | HammerIcon, |
| 79 | 79 | MessageCircleIcon, |
| ... | ... | @@ -99,6 +99,7 @@ import { |
| 99 | 99 | keepTimelineEndVisibleAfterOverlayGrowth, |
| 100 | 100 | } from "./timelineScrollAnchoring"; |
| 101 | 101 | import { MessageCopyButton } from "./MessageCopyButton"; |
| 102 | import { PierreEntryIcon } from "./PierreEntryIcon"; | |
| 102 | 103 | import { AssistantSelectionToolbar } from "./AssistantSelectionToolbar"; |
| 103 | 104 | import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; |
| 104 | 105 | import { |
| ... | ... | @@ -186,6 +187,7 @@ interface TimelineRowSharedState { |
| 186 | 187 | onUseArtifactTemplate: (template: CodexArtifactTemplate) => void; |
| 187 | 188 | onImageExpand: (preview: ExpandedImagePreview) => void; |
| 188 | 189 | onFileOpen: (attachment: ChatFileAttachment) => void; |
| 190 | onFileDownload: (attachment: ChatFileAttachment) => void; | |
| 189 | 191 | openingVideoAttachmentId: string | null; |
| 190 | 192 | onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; |
| 191 | 193 | onToggleTurnFold: (turnId: TurnId) => void; |
| ... | ... | @@ -286,6 +288,7 @@ interface MessagesTimelineProps { |
| 286 | 288 | isRevertingCheckpoint: boolean; |
| 287 | 289 | onImageExpand: (preview: ExpandedImagePreview) => void; |
| 288 | 290 | onFileOpen?: (attachment: ChatFileAttachment) => void; |
| 291 | onFileDownload?: (attachment: ChatFileAttachment) => void; | |
| 289 | 292 | openingVideoAttachmentId: string | null; |
| 290 | 293 | activeThreadEnvironmentId: EnvironmentId; |
| 291 | 294 | markdownCwd: string | undefined; |
| ... | ... | @@ -337,6 +340,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 337 | 340 | isRevertingCheckpoint, |
| 338 | 341 | onImageExpand, |
| 339 | 342 | onFileOpen = NOOP_OPEN_ATTACHMENT, |
| 343 | onFileDownload = NOOP_OPEN_ATTACHMENT, | |
| 340 | 344 | openingVideoAttachmentId, |
| 341 | 345 | activeThreadEnvironmentId, |
| 342 | 346 | markdownCwd, |
| ... | ... | @@ -629,6 +633,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 629 | 633 | onUseArtifactTemplate, |
| 630 | 634 | onImageExpand, |
| 631 | 635 | onFileOpen, |
| 636 | onFileDownload, | |
| 632 | 637 | openingVideoAttachmentId, |
| 633 | 638 | onOpenTurnDiff, |
| 634 | 639 | onToggleTurnFold, |
| ... | ... | @@ -652,6 +657,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 652 | 657 | onUseArtifactTemplate, |
| 653 | 658 | onImageExpand, |
| 654 | 659 | onFileOpen, |
| 660 | onFileDownload, | |
| 655 | 661 | openingVideoAttachmentId, |
| 656 | 662 | onOpenTurnDiff, |
| 657 | 663 | onToggleTurnFold, |
| ... | ... | @@ -1221,16 +1227,53 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" |
| 1221 | 1227 | {otherUserFiles.length > 0 || unknownAttachments.length > 0 ? ( |
| 1222 | 1228 | <div className="mb-2 flex flex-col gap-1"> |
| 1223 | 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 | 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 | 1271 | {file.downloadable === false ? null : ( |
| 1229 | 1272 | <DownloadIcon className="size-4 shrink-0" /> |
| 1230 | 1273 | )} |
| 1231 | 1274 | </> |
| 1232 | 1275 | ); |
| 1233 | return file.previewUrl ? ( | |
| 1276 | return file.previewUrl && !opensInPreview ? ( | |
| 1234 | 1277 | <a |
| 1235 | 1278 | key={file.id} |
| 1236 | 1279 | href={file.previewUrl} |
| ... | ... | @@ -1247,7 +1290,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" |
| 1247 | 1290 | <button |
| 1248 | 1291 | key={file.id} |
| 1249 | 1292 | type="button" |
| 1250 | aria-label={`Download ${file.name}`} | |
| 1293 | aria-label={`${opensInPreview ? "Preview" : "Download"} ${file.name}`} | |
| 1251 | 1294 | onClick={() => ctx.onFileOpen(file)} |
| 1252 | 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 | 1300 | })} |
| 1258 | 1301 | {unknownAttachments.map((attachment) => ( |
| 1259 | 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 | 1308 | <span className="min-w-0 flex-1 truncate">{attachment.name}</span> |
| 1262 | 1309 | </div> |
| 1263 | 1310 | ))} |
apps/web/src/components/files/FilePreviewPanel.test.ts+41-1| ... | ... | @@ -5,7 +5,11 @@ import { |
| 5 | 5 | normalizeFileCommentRange, |
| 6 | 6 | remapFileCommentAnnotations, |
| 7 | 7 | } from "./fileCommentAnnotations"; |
| 8 | import { isMarkdownPreviewFile, setMarkdownTaskChecked } from "./filePreviewMode"; | |
| 8 | import { | |
| 9 | isMarkdownPreviewFile, | |
| 10 | setMarkdownTaskChecked, | |
| 11 | shouldShowFileExplorer, | |
| 12 | } from "./filePreviewMode"; | |
| 9 | 13 | |
| 10 | 14 | describe("file comment annotations", () => { |
| 11 | 15 | it("normalizes and formats selected line ranges", () => { |
| ... | ... | @@ -66,6 +70,42 @@ describe("isMarkdownPreviewFile", () => { |
| 66 | 70 | }); |
| 67 | 71 | }); |
| 68 | 72 | |
| 73 | describe("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 | ||
| 69 | 109 | describe("setMarkdownTaskChecked", () => { |
| 70 | 110 | const markdown = "- [ ] First\n- [x] Second\n"; |
| 71 | 111 |
apps/web/src/components/files/FilePreviewPanel.tsx+152-55| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import type { |
| 2 | ChatFileAttachment, | |
| 2 | 3 | EditorId, |
| 3 | 4 | EnvironmentId, |
| 4 | 5 | ResolvedKeybindingsConfig, |
| ... | ... | @@ -23,6 +24,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 23 | 24 | import { isBrowserPreviewFile, openFileInPreview } from "~/browser/openFileInPreview"; |
| 24 | 25 | import { useAssetUrlRefresh, useAssetUrlState } from "~/assets/assetUrls"; |
| 25 | 26 | import { OpenInPicker } from "~/components/chat/OpenInPicker"; |
| 27 | import { PierreEntryIcon } from "~/components/chat/PierreEntryIcon"; | |
| 26 | 28 | import { MediaVideoPlayer } from "~/components/media/MediaVideoPlayer"; |
| 27 | 29 | import { MediaActions, type MediaActionSource } from "~/components/media/MediaActions"; |
| 28 | 30 | import { useRemoteOpenState } from "~/remoteOpen"; |
| ... | ... | @@ -64,7 +66,11 @@ import { installFileEditorDismissal } from "./fileEditorDismissal"; |
| 64 | 66 | import { resolveCenteredFileLineScrollTop } from "./fileLineReveal"; |
| 65 | 67 | import { DiffCommentAnnotation } from "../diffs/DiffCommentAnnotation"; |
| 66 | 68 | import { projectFileCacheKey, projectFileEditorCacheKey } from "./fileContentRevision"; |
| 67 | import { isMarkdownPreviewFile, setMarkdownTaskChecked } from "./filePreviewMode"; | |
| 69 | import { | |
| 70 | isMarkdownPreviewFile, | |
| 71 | setMarkdownTaskChecked, | |
| 72 | shouldShowFileExplorer, | |
| 73 | } from "./filePreviewMode"; | |
| 68 | 74 | import { FileSaveCoordinator } from "./fileSaveCoordinator"; |
| 69 | 75 | import { |
| 70 | 76 | confirmProjectFileQueryData, |
| ... | ... | @@ -78,6 +84,7 @@ interface FilePreviewPanelProps { |
| 78 | 84 | cwd: string; |
| 79 | 85 | projectName: string; |
| 80 | 86 | relativePath: string | null; |
| 87 | attachment?: ChatFileAttachment; | |
| 81 | 88 | threadRef: ScopedThreadRef; |
| 82 | 89 | composerDraftTarget: ScopedThreadRef | DraftId; |
| 83 | 90 | keybindings: ResolvedKeybindingsConfig; |
| ... | ... | @@ -200,6 +207,69 @@ function WorkspaceImagePreview(props: { |
| 200 | 207 | |
| 201 | 208 | const isPdfPreviewFile = (path: string): boolean => /\.pdf$/i.test(path.split(/[?#]/, 1)[0] ?? ""); |
| 202 | 209 | |
| 210 | function 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 | ||
| 231 | function 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 | 274 | * Renders an HTML or PDF file in place from its signed asset URL. HTML runs in |
| 205 | 275 | * a sandboxed frame with an opaque origin, so a page cannot reach the app's |
| ... | ... | @@ -244,19 +314,11 @@ function WorkspaceBrowserPreview(props: { |
| 244 | 314 | </div> |
| 245 | 315 | ); |
| 246 | 316 | } |
| 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}`} | |
| 257 | 320 | title={props.title} |
| 258 | className={className} | |
| 259 | sandbox="allow-scripts allow-forms allow-popups allow-modals" | |
| 321 | pdf={isPdfPreviewFile(props.absolutePath)} | |
| 260 | 322 | /> |
| 261 | 323 | ); |
| 262 | 324 | } |
| ... | ... | @@ -922,6 +984,7 @@ export default function FilePreviewPanel({ |
| 922 | 984 | cwd, |
| 923 | 985 | projectName, |
| 924 | 986 | relativePath, |
| 987 | attachment, | |
| 925 | 988 | threadRef, |
| 926 | 989 | composerDraftTarget, |
| 927 | 990 | keybindings, |
| ... | ... | @@ -951,9 +1014,20 @@ export default function FilePreviewPanel({ |
| 951 | 1014 | const isPdf = relativePath !== null && isPdfPreviewFile(relativePath); |
| 952 | 1015 | const isHtml = relativePath !== null && !isPdf && isBrowserPreviewFile(relativePath); |
| 953 | 1016 | // 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 | ); | |
| 956 | 1025 | const [explorerOpen, setExplorerOpen] = useState(initialExplorerOpen); |
| 1026 | const showExplorer = shouldShowFileExplorer({ | |
| 1027 | relativePath, | |
| 1028 | explorerOpen, | |
| 1029 | attachmentOpen: attachment !== undefined, | |
| 1030 | }); | |
| 957 | 1031 | // Reading markdown rendered is a preference, not a property of one file. Keeping |
| 958 | 1032 | // it on the panel meant a thread switch dropped it and forced source back. |
| 959 | 1033 | const [renderMarkdownPreferred, setRenderMarkdownPreferred] = useLocalStorage( |
| ... | ... | @@ -980,20 +1054,27 @@ export default function FilePreviewPanel({ |
| 980 | 1054 | (handledReveal?.path === relativePath && handledReveal.requestId === revealRequestId); |
| 981 | 1055 | const renderMarkdown = isMarkdown && renderMarkdownPreferred && revealHandled; |
| 982 | 1056 | const renderBrowserFile = isPdf || (isHtml && renderBrowserFilePreferred && revealHandled); |
| 983 | const canToggleRendered = isMarkdown || isHtml; | |
| 1057 | const canToggleRendered = attachment === undefined && (isMarkdown || isHtml); | |
| 984 | 1058 | const rendered = isMarkdown ? renderMarkdown : renderBrowserFile; |
| 985 | 1059 | const setRenderedPreferred = isMarkdown |
| 986 | 1060 | ? setRenderMarkdownPreferred |
| 987 | 1061 | : setRenderBrowserFilePreferred; |
| 988 | 1062 | const canOpenInBrowser = |
| 989 | 1063 | relativePath !== null && |
| 1064 | attachment === undefined && | |
| 990 | 1065 | !isVideo && |
| 991 | 1066 | isPreviewSupportedInRuntime() && |
| 992 | 1067 | isBrowserPreviewFile(relativePath); |
| 993 | const absolutePath = relativePath ? resolvePathLinkTarget(relativePath, cwd) : null; | |
| 1068 | const absolutePath = | |
| 1069 | relativePath && attachment === undefined ? resolvePathLinkTarget(relativePath, cwd) : null; | |
| 994 | 1070 | const onFilePostRender = useFileLineReveal(relativePath, revealLine, revealRequestId); |
| 995 | 1071 | useWorkspaceMutationRefresh({ |
| 996 | enabled: relativePath !== null && !isMedia && !isPdf && !selectedFilePending, | |
| 1072 | enabled: | |
| 1073 | attachment === undefined && | |
| 1074 | relativePath !== null && | |
| 1075 | !isMedia && | |
| 1076 | !isPdf && | |
| 1077 | !selectedFilePending, | |
| 997 | 1078 | mutationId: workspaceMutationId, |
| 998 | 1079 | refresh: file.refresh, |
| 999 | 1080 | resourceKey: `file:${environmentId}:${cwd}:${relativePath ?? ""}`, |
| ... | ... | @@ -1050,24 +1131,36 @@ export default function FilePreviewPanel({ |
| 1050 | 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 | 1132 | data-surface-subheader |
| 1052 | 1133 | > |
| 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" | |
| 1068 | 1141 | /> |
| 1142 | <span className="truncate font-medium">{attachment.name}</span> | |
| 1069 | 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 | 1164 | {absolutePath && |
| 1072 | 1165 | (environmentId === primaryEnvironmentId || remoteOpenState.mode !== "local-exec") ? ( |
| 1073 | 1166 | <OpenInPicker |
| ... | ... | @@ -1124,25 +1217,27 @@ export default function FilePreviewPanel({ |
| 1124 | 1217 | <TooltipPopup>Open file in preview browser</TooltipPopup> |
| 1125 | 1218 | </Tooltip> |
| 1126 | 1219 | ) : 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} | |
| 1146 | 1241 | </div> |
| 1147 | 1242 | ) : null} |
| 1148 | 1243 | {relativePath && !isMedia && !renderBrowserFile && file.data?.truncated ? ( |
| ... | ... | @@ -1157,7 +1252,9 @@ export default function FilePreviewPanel({ |
| 1157 | 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 | 1258 | <WorkspaceVideoPreview |
| 1162 | 1259 | key={`${environmentId}:${threadRef.threadId}:${absolutePath}`} |
| 1163 | 1260 | environmentId={environmentId} |
| ... | ... | @@ -1250,7 +1347,7 @@ export default function FilePreviewPanel({ |
| 1250 | 1347 | ) |
| 1251 | 1348 | ) : null} |
| 1252 | 1349 | </div> |
| 1253 | {explorerOpen || relativePath === null ? ( | |
| 1350 | {showExplorer ? ( | |
| 1254 | 1351 | <aside |
| 1255 | 1352 | className={cn( |
| 1256 | 1353 | "flex min-h-0 shrink-0 bg-background", |
apps/web/src/components/files/filePreviewMode.ts+13| ... | ... | @@ -1,5 +1,18 @@ |
| 1 | import { isAbsolutePath } from "~/terminal-links"; | |
| 2 | ||
| 1 | 3 | export const isMarkdownPreviewFile = (path: string): boolean => /\.(?:md|mdx)$/i.test(path); |
| 2 | 4 | |
| 5 | export 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 | ||
| 3 | 16 | export function setMarkdownTaskChecked( |
| 4 | 17 | markdown: string, |
| 5 | 18 | markerOffset: number, |
apps/web/src/rightPanelStore.test.ts+73| ... | ... | @@ -279,6 +279,50 @@ describe("rightPanelStore", () => { |
| 279 | 279 | }); |
| 280 | 280 | }); |
| 281 | 281 | |
| 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 | 326 | it("updates line reveal requests when reopening a file surface", () => { |
| 283 | 327 | useRightPanelStore.getState().openFile(refA, "src/index.ts", 42); |
| 284 | 328 | useRightPanelStore.getState().openFile(refA, "src/index.ts", 87); |
| ... | ... | @@ -336,6 +380,35 @@ describe("rightPanelStore", () => { |
| 336 | 380 | }); |
| 337 | 381 | }); |
| 338 | 382 | |
| 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 | 412 | it("close hides the panel without clearing its selected surface", () => { |
| 340 | 413 | useRightPanelStore.getState().open(refA, "agents"); |
| 341 | 414 | useRightPanelStore.getState().close(refA); |
apps/web/src/rightPanelStore.ts+30-3| ... | ... | @@ -8,7 +8,7 @@ |
| 8 | 8 | * workspace paths, and diff/files remain singleton surfaces. |
| 9 | 9 | */ |
| 10 | 10 | import { scopedThreadKey } from "@t3tools/client-runtime/environment"; |
| 11 | import type { ScopedThreadRef } from "@t3tools/contracts"; | |
| 11 | import type { ChatFileAttachment, ScopedThreadRef } from "@t3tools/contracts"; | |
| 12 | 12 | import { create } from "zustand"; |
| 13 | 13 | import { createJSONStorage, persist } from "zustand/middleware"; |
| 14 | 14 | |
| ... | ... | @@ -39,12 +39,15 @@ export type RightPanelSurface = |
| 39 | 39 | | { id: "diff"; kind: "diff" } |
| 40 | 40 | | { id: "files"; kind: "files" } |
| 41 | 41 | | { |
| 42 | id: `file:${string}`; | |
| 42 | id: `file:${string}` | `attachment:${string}`; | |
| 43 | 43 | kind: "file"; |
| 44 | 44 | /** Workspace-relative, or absolute for a host file outside the workspace. */ |
| 45 | 45 | relativePath: string; |
| 46 | 46 | revealLine: number | null; |
| 47 | 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 | 94 | ) => void; |
| 92 | 95 | openBrowser: (ref: ScopedThreadRef, tabId: string | null) => void; |
| 93 | 96 | openFile: (ref: ScopedThreadRef, relativePath: string, line?: number) => void; |
| 97 | openAttachment: (ref: ScopedThreadRef, attachment: ChatFileAttachment) => void; | |
| 94 | 98 | openPullRequest: ( |
| 95 | 99 | ref: ScopedThreadRef, |
| 96 | 100 | target: { environmentId?: string; projectId: string; repository: string; number: number }, |
| ... | ... | @@ -157,6 +161,15 @@ const fileSurface = ( |
| 157 | 161 | revealRequestId, |
| 158 | 162 | }); |
| 159 | 163 | |
| 164 | const 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 | ||
| 160 | 173 | const terminalSurface = (terminalId: string): RightPanelSurface => ({ |
| 161 | 174 | id: `terminal:${terminalId}`, |
| 162 | 175 | kind: "terminal", |
| ... | ... | @@ -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 | 429 | openTerminal: (ref, terminalId) => |
| 405 | 430 | set((state) => ({ |
| 406 | 431 | byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => |
| ... | ... | @@ -577,7 +602,9 @@ export const useRightPanelStore = create<RightPanelStoreState>()( |
| 577 | 602 | byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => { |
| 578 | 603 | if (workspaceAvailable) return current; |
| 579 | 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 | 609 | if (surfaces.length === current.surfaces.length) return current; |
| 583 | 610 | const activeStillExists = surfaces.some( |
apps/web/src/types.ts+9| ... | ... | @@ -66,6 +66,15 @@ export function isVideoAttachment(attachment: ChatFileAttachment): boolean { |
| 66 | 66 | return videoMimeType(attachment) !== null; |
| 67 | 67 | } |
| 68 | 68 | |
| 69 | export 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 | ||
| 69 | 78 | export interface ChatMessage extends Omit<OrchestrationMessage, "attachments"> { |
| 70 | 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 | 31 | PDF links in assistant responses open the same preview. On Android, images open in the image |
| 32 | 32 | viewer and PDFs open the system chooser. |
| 33 | 33 | |
| 34 | On web and desktop, select a sent PDF or HTML attachment to open it in the file viewer, or use the | |
| 35 | download button beside it to save a copy. Other attached files download when selected. | |
| 36 | ||
| 34 | 37 | Select a video attachment before or after sending to play it. Web and desktop use the browser's |
| 35 | 38 | built-in controls. On mobile, videos open in a full-screen player with native playback controls. |
| 36 | 39 | Supported videos show a thumbnail in the conversation and composer. |
| ... | ... | @@ -133,10 +136,10 @@ are not supported; use the Markdown embed syntax above. |
| 133 | 136 | |
| 134 | 137 | When an agent links to a file it wrote outside the workspace, such as a Markdown report in |
| 135 | 138 | `/tmp`, select the link to open it in the file viewer. The viewer shows the file read-only, with |
| 136 | rendered Markdown available as usual; it cannot edit files outside the workspace. HTML and PDF | |
| 137 | files outside the workspace open the same way as ones inside it. Because such a file is served on | |
| 138 | its own, an HTML page outside the workspace cannot load scripts, styles, or images from files beside | |
| 139 | it. | |
| 139 | rendered Markdown available as usual; it cannot edit files outside the workspace. The workspace | |
| 140 | file tree stays hidden because it does not describe the open file. HTML and PDF files outside the | |
| 141 | workspace open the same way as ones inside it. Because such a file is served on its own, an HTML | |
| 142 | page outside the workspace cannot load scripts, styles, or images from files beside it. | |
| 140 | 143 | |
| 141 | 144 | ## HTML and PDF files in the file viewer |
| 142 | 145 |
packages/contracts/src/assets.ts+3| ... | ... | @@ -30,6 +30,9 @@ export const AssetResource = Schema.Union([ |
| 30 | 30 | an octet-stream download without a filename. */ |
| 31 | 31 | fileName: Schema.optionalKey(TrimmedNonEmptyString.check(Schema.isMaxLength(255))), |
| 32 | 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 | 37 | Schema.TaggedStruct("project-favicon", { |
| 35 | 38 | cwd: TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)), |