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