| author | |
| committer | |
| log | ac4aae101d060b5355a5de58e23c7387886d9021 |
| tree | 57674b33c41cf7a1bc4f319e6329ca90e1d23c84 |
| parent | f15680bd3c08a154bacb9b4d163cc8608a310970 |
| signature | Signed by PGP key B5690EEEBB952194 |
23 files changed, 850 insertions(+), 120 deletions(-)
apps/desktop/src/electron/ElectronProtocol.test.ts+1| ... | ... | @@ -225,6 +225,7 @@ describe("ElectronProtocol", () => { |
| 225 | 225 | "http:", |
| 226 | 226 | "https:", |
| 227 | 227 | ]); |
| 228 | assert.deepEqual(directives["media-src"], ["'self'", "t3code:", "blob:"]); | |
| 228 | 229 | assert.deepEqual(directives["font-src"], ["'self'", "t3code:", "data:"]); |
| 229 | 230 | }); |
| 230 | 231 | }); |
apps/desktop/src/electron/ElectronProtocol.ts+1| ... | ... | @@ -87,6 +87,7 @@ export function makeDesktopContentSecurityPolicy(input: DesktopProtocolRegistrat |
| 87 | 87 | `script-src ${scriptSources.join(" ")}`, |
| 88 | 88 | `connect-src ${connectSources.join(" ")}`, |
| 89 | 89 | `img-src 'self' ${input.scheme}: blob: data: http: https:`, |
| 90 | `media-src 'self' ${input.scheme}: blob:`, | |
| 90 | 91 | "style-src 'self' 'unsafe-inline'", |
| 91 | 92 | `font-src 'self' ${input.scheme}: data:`, |
| 92 | 93 | "worker-src 'self' blob:", |
apps/server/src/assets/AssetAccess.test.ts+31| ... | ... | @@ -208,6 +208,37 @@ describe("AssetAccess", () => { |
| 208 | 208 | }).pipe(Effect.provide(testLayer)), |
| 209 | 209 | ); |
| 210 | 210 | |
| 211 | it.effect("serves video attachments inline", () => | |
| 212 | Effect.gen(function* () { | |
| 213 | const config = yield* ServerConfig.ServerConfig; | |
| 214 | const fileSystem = yield* FileSystem.FileSystem; | |
| 215 | const path = yield* Path.Path; | |
| 216 | const attachmentId = "thread-1-00000000-0000-4000-8000-000000000001-mp4"; | |
| 217 | const attachmentPath = path.join(config.attachmentsDir, `${attachmentId}.mp4`); | |
| 218 | yield* fileSystem.makeDirectory(config.attachmentsDir, { recursive: true }); | |
| 219 | yield* fileSystem.writeFile(attachmentPath, new Uint8Array([1, 2, 3])); | |
| 220 | ||
| 221 | const result = yield* issueAssetUrl({ | |
| 222 | resource: { | |
| 223 | _tag: "attachment", | |
| 224 | attachmentId, | |
| 225 | fileName: "demo.mp4", | |
| 226 | mimeType: 'video/mp4; codecs="avc1.42E01E"', | |
| 227 | }, | |
| 228 | }); | |
| 229 | const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); | |
| 230 | const separatorIndex = suffix.indexOf("/"); | |
| 231 | ||
| 232 | expect( | |
| 233 | yield* resolveAsset(suffix.slice(0, separatorIndex), suffix.slice(separatorIndex + 1)), | |
| 234 | ).toEqual({ | |
| 235 | kind: "file", | |
| 236 | path: attachmentPath, | |
| 237 | fileName: "demo.mp4", | |
| 238 | mimeType: "video/mp4", | |
| 239 | }); | |
| 240 | }).pipe(Effect.provide(testLayer)), | |
| 241 | ); | |
| 211 | 242 | it.effect("issues project favicon capabilities with a signed fallback", () => |
| 212 | 243 | Effect.gen(function* () { |
| 213 | 244 | const fileSystem = yield* FileSystem.FileSystem; |
apps/server/src/assets/AssetAccess.ts+9-4| ... | ... | @@ -48,6 +48,7 @@ const SIGNING_SECRET_NAME = "asset-access-signing-key"; |
| 48 | 48 | const ASSET_TOKEN_TTL_MS = 60 * 60 * 1000; |
| 49 | 49 | const PROJECT_FAVICON_TOKEN_BUCKET_MS = 30 * 60 * 1000; |
| 50 | 50 | const PROJECT_FAVICON_VERSION_PREFIX = "v"; |
| 51 | const INLINE_VIDEO_MIME_TYPE_PATTERN = /^video\/[\w!#$&^.+-]+$/i; | |
| 51 | 52 | const PREVIEW_ASSET_EXTENSIONS = new Set([ |
| 52 | 53 | ...WORKSPACE_BROWSER_PREVIEW_EXTENSIONS, |
| 53 | 54 | ...WORKSPACE_IMAGE_PREVIEW_EXTENSIONS, |
| ... | ... | @@ -300,16 +301,20 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i |
| 300 | 301 | }); |
| 301 | 302 | } |
| 302 | 303 | // Generic files carry their extension inside the attachment id (that |
| 303 | // shape resolves the on-disk path); images do not. Only generic files | |
| 304 | // download, images render inline in chat. | |
| 304 | // shape resolves the on-disk path); images do not. Videos and images | |
| 305 | // render inline; other generic files download. | |
| 305 | 306 | const isGenericFile = parseAttachmentFileExtension(input.resource.attachmentId) !== null; |
| 307 | const videoMimeType = input.resource.mimeType?.split(";", 1)[0]?.trim() ?? ""; | |
| 308 | const isVideo = INLINE_VIDEO_MIME_TYPE_PATTERN.test(videoMimeType); | |
| 306 | 309 | claims = { |
| 307 | 310 | version: 1, |
| 308 | 311 | kind: "attachment", |
| 309 | 312 | attachmentId: input.resource.attachmentId, |
| 310 | ...(isGenericFile ? { download: true } : {}), | |
| 313 | ...(isGenericFile && !isVideo ? { download: true } : {}), | |
| 311 | 314 | ...(input.resource.fileName !== undefined ? { fileName: input.resource.fileName } : {}), |
| 312 | ...(input.resource.mimeType !== undefined ? { mimeType: input.resource.mimeType } : {}), | |
| 315 | ...(input.resource.mimeType !== undefined | |
| 316 | ? { mimeType: isVideo ? videoMimeType : input.resource.mimeType } | |
| 317 | : {}), | |
| 313 | 318 | expiresAt, |
| 314 | 319 | }; |
| 315 | 320 | fileName = input.resource.fileName ?? path.basename(attachmentPath); |
apps/server/src/http.test.ts+11| ... | ... | @@ -50,6 +50,17 @@ describe("assetResponseHeaders", () => { |
| 50 | 50 | }); |
| 51 | 51 | }); |
| 52 | 52 | |
| 53 | it("serves inline videos with their declared mime type", () => { | |
| 54 | expect( | |
| 55 | assetResponseHeaders("/attachments/demo.bin", { | |
| 56 | mimeType: 'video/mp4; codecs="avc1.42E01E"', | |
| 57 | }), | |
| 58 | ).toEqual({ | |
| 59 | "Cache-Control": "private, max-age=3600", | |
| 60 | "Content-Type": "video/mp4", | |
| 61 | "X-Content-Type-Options": "nosniff", | |
| 62 | }); | |
| 63 | }); | |
| 53 | 64 | it("declares utf-8 for HTML assets so non-ASCII content renders correctly", () => { |
| 54 | 65 | expect(assetResponseHeaders("/workspace/page.html")).toHaveProperty( |
| 55 | 66 | "Content-Type", |
apps/server/src/http.ts+10-5| ... | ... | @@ -57,6 +57,8 @@ const DOWNLOAD_MIME_TYPE_PATTERN = /^[\w!#$&^.+-]+\/[\w!#$&^.+-]+$/; |
| 57 | 57 | const isSafeDownloadMimeType = (mimeType: string): boolean => |
| 58 | 58 | DOWNLOAD_MIME_TYPE_PATTERN.test(mimeType) && |
| 59 | 59 | !/(?:^text\/html$|\/xml(?:$|-)|\+xml$)/i.test(mimeType.trim().toLowerCase()); |
| 60 | const isSafeInlineVideoMimeType = (mimeType: string): boolean => | |
| 61 | DOWNLOAD_MIME_TYPE_PATTERN.test(mimeType) && mimeType.toLowerCase().startsWith("video/"); | |
| 60 | 62 | |
| 61 | 63 | /** RFC 6266 disposition with an ASCII fallback name plus a UTF-8 `filename*`. */ |
| 62 | 64 | export function downloadContentDisposition(fileName?: string): string { |
| ... | ... | @@ -86,6 +88,7 @@ export function assetResponseHeaders( |
| 86 | 88 | }, |
| 87 | 89 | ): Record<string, string> { |
| 88 | 90 | const lowerPath = filePath.toLowerCase(); |
| 91 | const inlineVideoMimeType = options?.mimeType?.split(";", 1)[0]?.trim(); | |
| 89 | 92 | return { |
| 90 | 93 | "Cache-Control": "private, max-age=3600", |
| 91 | 94 | "X-Content-Type-Options": "nosniff", |
| ... | ... | @@ -98,9 +101,11 @@ export function assetResponseHeaders( |
| 98 | 101 | ? options.mimeType |
| 99 | 102 | : "application/octet-stream", |
| 100 | 103 | } |
| 101 | : lowerPath.endsWith(".html") || lowerPath.endsWith(".htm") | |
| 102 | ? { "Content-Type": "text/html; charset=utf-8" } | |
| 103 | : {}), | |
| 104 | : inlineVideoMimeType !== undefined && isSafeInlineVideoMimeType(inlineVideoMimeType) | |
| 105 | ? { "Content-Type": inlineVideoMimeType } | |
| 106 | : lowerPath.endsWith(".html") || lowerPath.endsWith(".htm") | |
| 107 | ? { "Content-Type": "text/html; charset=utf-8" } | |
| 108 | : {}), | |
| 104 | 109 | ...(!options?.download && lowerPath.endsWith(".svg") |
| 105 | 110 | ? { "Content-Security-Policy": SVG_CONTENT_SECURITY_POLICY } |
| 106 | 111 | : {}), |
| ... | ... | @@ -276,9 +281,9 @@ export const assetRouteLayer = HttpRouter.add( |
| 276 | 281 | status: 200, |
| 277 | 282 | headers: assetResponseHeaders( |
| 278 | 283 | asset.path, |
| 279 | asset.download | |
| 284 | asset.download || asset.mimeType !== undefined | |
| 280 | 285 | ? { |
| 281 | download: true, | |
| 286 | ...(asset.download ? { download: true } : {}), | |
| 282 | 287 | ...(asset.fileName !== undefined ? { fileName: asset.fileName } : {}), |
| 283 | 288 | ...(asset.mimeType !== undefined ? { mimeType: asset.mimeType } : {}), |
| 284 | 289 | } |
apps/web/src/components/ChatView.logic.test.ts+27| ... | ... | @@ -21,6 +21,8 @@ import { |
| 21 | 21 | dismissBranchMismatchForSession, |
| 22 | 22 | ENVIRONMENT_RECONNECT_WARNING_GRACE_MS, |
| 23 | 23 | getStartedThreadModelChangeBlockReason, |
| 24 | loadVideoPreviewUrl, | |
| 25 | isVideoPreviewRequestCurrent, | |
| 24 | 26 | hasEnvironmentReconnectWarningGraceElapsed, |
| 25 | 27 | hasServerAcknowledgedLocalDispatch, |
| 26 | 28 | isBranchMismatchDismissedForSession, |
| ... | ... | @@ -41,6 +43,31 @@ import { |
| 41 | 43 | shouldWriteThreadErrorToCurrentServerThread, |
| 42 | 44 | } from "./ChatView.logic"; |
| 43 | 45 | |
| 46 | describe("loadVideoPreviewUrl", () => { | |
| 47 | it("loads video bytes into an object URL", async () => { | |
| 48 | const objectUrl = await loadVideoPreviewUrl("data:video/mp4;base64,AA=="); | |
| 49 | expect(objectUrl).toMatch(/^blob:/); | |
| 50 | URL.revokeObjectURL(objectUrl); | |
| 51 | }); | |
| 52 | ||
| 53 | it("stops loading when the preview request is cancelled", async () => { | |
| 54 | const controller = new AbortController(); | |
| 55 | controller.abort(); | |
| 56 | ||
| 57 | await expect( | |
| 58 | loadVideoPreviewUrl("data:video/mp4;base64,AA==", controller.signal), | |
| 59 | ).rejects.toMatchObject({ name: "AbortError" }); | |
| 60 | }); | |
| 61 | }); | |
| 62 | ||
| 63 | describe("isVideoPreviewRequestCurrent", () => { | |
| 64 | it("rejects changed threads and replaced previews", () => { | |
| 65 | expect(isVideoPreviewRequestCurrent("thread-1", "thread-2", 1, 1)).toBe(false); | |
| 66 | expect(isVideoPreviewRequestCurrent("thread-1", "thread-1", 1, 2)).toBe(false); | |
| 67 | expect(isVideoPreviewRequestCurrent("thread-1", "thread-1", 2, 2)).toBe(true); | |
| 68 | }); | |
| 69 | }); | |
| 70 | ||
| 44 | 71 | const environmentId = EnvironmentId.make("environment-local"); |
| 45 | 72 | const projectId = ProjectId.make("project-1"); |
| 46 | 73 | const threadId = ThreadId.make("thread-1"); |
apps/web/src/components/ChatView.logic.ts+15| ... | ... | @@ -290,6 +290,21 @@ export function revokeBlobPreviewUrl(previewUrl: string | undefined): void { |
| 290 | 290 | URL.revokeObjectURL(previewUrl); |
| 291 | 291 | } |
| 292 | 292 | |
| 293 | export async function loadVideoPreviewUrl(url: string, signal?: AbortSignal): Promise<string> { | |
| 294 | const response = await fetch(url, signal ? { signal } : {}); | |
| 295 | if (!response.ok) throw new Error(`Could not load video (${response.status}).`); | |
| 296 | return URL.createObjectURL(await response.blob()); | |
| 297 | } | |
| 298 | ||
| 299 | export function isVideoPreviewRequestCurrent( | |
| 300 | requestThreadKey: string, | |
| 301 | currentThreadKey: string, | |
| 302 | requestId: number, | |
| 303 | currentRequestId: number, | |
| 304 | ): boolean { | |
| 305 | return requestThreadKey === currentThreadKey && requestId === currentRequestId; | |
| 306 | } | |
| 307 | ||
| 293 | 308 | export function revokeUserMessagePreviewUrls(message: ChatMessage): void { |
| 294 | 309 | if (message.role !== "user" || !message.attachments) { |
| 295 | 310 | return; |
apps/web/src/components/ChatView.tsx+95-14| ... | ... | @@ -129,6 +129,7 @@ import { |
| 129 | 129 | MAX_TERMINALS_PER_GROUP, |
| 130 | 130 | type ChatMessage, |
| 131 | 131 | isImageAttachment, |
| 132 | videoMimeType, | |
| 132 | 133 | type SessionPhase, |
| 133 | 134 | type Thread, |
| 134 | 135 | type TurnDiffSummary, |
| ... | ... | @@ -360,6 +361,8 @@ import { |
| 360 | 361 | cloneComposerImageForRetry, |
| 361 | 362 | deriveLockedProvider, |
| 362 | 363 | readFileAsDataUrl, |
| 364 | loadVideoPreviewUrl, | |
| 365 | isVideoPreviewRequestCurrent, | |
| 363 | 366 | reconcileMountedTerminalThreadIds, |
| 364 | 367 | resolveBackgroundDraftWorkspaceOptions, |
| 365 | 368 | resolveDraftHeroState, |
| ... | ... | @@ -1438,8 +1441,23 @@ function ChatViewContent(props: ChatViewProps) { |
| 1438 | 1441 | const localComposerRef = useRef<ChatComposerHandle | null>(null); |
| 1439 | 1442 | const composerRef = useComposerHandleContext() ?? localComposerRef; |
| 1440 | 1443 | const [isWorkspaceFileDragActive, setIsWorkspaceFileDragActive] = useState(false); |
| 1444 | const routeThreadKeyRef = useRef(routeThreadKey); | |
| 1445 | routeThreadKeyRef.current = routeThreadKey; | |
| 1446 | const videoPreviewRequestIdRef = useRef(0); | |
| 1447 | const videoPreviewAbortControllerRef = useRef<AbortController | null>(null); | |
| 1448 | const cancelVideoPreviewRequest = useCallback(() => { | |
| 1449 | videoPreviewRequestIdRef.current += 1; | |
| 1450 | videoPreviewAbortControllerRef.current?.abort(); | |
| 1451 | videoPreviewAbortControllerRef.current = null; | |
| 1452 | }, []); | |
| 1453 | const [openingVideoAttachmentId, setOpeningVideoAttachmentId] = useState<string | null>(null); | |
| 1441 | 1454 | const [showScrollToBottom, setShowScrollToBottom] = useState(false); |
| 1442 | 1455 | const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null); |
| 1456 | useEffect(() => { | |
| 1457 | const item = expandedImage?.images[expandedImage.index]; | |
| 1458 | if (item?.type !== "video" || !item.src.startsWith("blob:")) return; | |
| 1459 | return () => revokeBlobPreviewUrl(item.src); | |
| 1460 | }, [expandedImage]); | |
| 1443 | 1461 | const [optimisticUserMessages, setOptimisticUserMessages] = useState<ChatMessage[]>([]); |
| 1444 | 1462 | const [feedbackSubmissionsByThreadKey, setFeedbackSubmissionsByThreadKey] = useState< |
| 1445 | 1463 | Record<string, ReadonlyArray<CodexFeedbackSubmission>> |
| ... | ... | @@ -2479,11 +2497,12 @@ function ChatViewContent(props: ChatViewProps) { |
| 2479 | 2497 | useEffect(() => { |
| 2480 | 2498 | return () => { |
| 2481 | 2499 | clearAttachmentPreviewHandoffs(); |
| 2500 | cancelVideoPreviewRequest(); | |
| 2482 | 2501 | for (const message of optimisticUserMessagesRef.current) { |
| 2483 | 2502 | revokeUserMessagePreviewUrls(message); |
| 2484 | 2503 | } |
| 2485 | 2504 | }; |
| 2486 | }, [clearAttachmentPreviewHandoffs]); | |
| 2505 | }, [cancelVideoPreviewRequest, clearAttachmentPreviewHandoffs]); | |
| 2487 | 2506 | const handoffAttachmentPreviews = useCallback((messageId: MessageId, previewUrls: string[]) => { |
| 2488 | 2507 | if (previewUrls.length === 0) return; |
| 2489 | 2508 | |
| ... | ... | @@ -2504,17 +2523,40 @@ function ChatViewContent(props: ChatViewProps) { |
| 2504 | 2523 | }); |
| 2505 | 2524 | }, []); |
| 2506 | 2525 | const serverMessages = activeThread?.messages; |
| 2507 | const downloadFileAttachment = useCallback( | |
| 2526 | const openFileAttachment = useCallback( | |
| 2508 | 2527 | async (attachment: ChatFileAttachment) => { |
| 2509 | 2528 | const connection = readPreparedConnection(environmentId); |
| 2510 | 2529 | if (!connection) { |
| 2511 | 2530 | toastManager.add({ type: "error", title: "The environment is not connected." }); |
| 2512 | 2531 | return; |
| 2513 | 2532 | } |
| 2533 | const videoMime = videoMimeType(attachment); | |
| 2534 | const isVideo = videoMime !== null; | |
| 2535 | const action = isVideo ? "play" : "download"; | |
| 2536 | const videoPreviewAbortController = isVideo ? new AbortController() : null; | |
| 2537 | if (isVideo) { | |
| 2538 | videoPreviewAbortControllerRef.current?.abort(); | |
| 2539 | videoPreviewAbortControllerRef.current = videoPreviewAbortController; | |
| 2540 | } | |
| 2541 | const videoPreviewRequestId = isVideo ? ++videoPreviewRequestIdRef.current : 0; | |
| 2542 | const isCurrentRequest = () => | |
| 2543 | !isVideo || | |
| 2544 | isVideoPreviewRequestCurrent( | |
| 2545 | routeThreadKey, | |
| 2546 | routeThreadKeyRef.current, | |
| 2547 | videoPreviewRequestId, | |
| 2548 | videoPreviewRequestIdRef.current, | |
| 2549 | ); | |
| 2550 | const finishVideoPreviewRequest = () => { | |
| 2551 | if (videoPreviewRequestIdRef.current === videoPreviewRequestId) { | |
| 2552 | setOpeningVideoAttachmentId(null); | |
| 2553 | videoPreviewAbortControllerRef.current = null; | |
| 2554 | } | |
| 2555 | }; | |
| 2556 | if (isVideo) setOpeningVideoAttachmentId(attachment.id); | |
| 2514 | 2557 | |
| 2515 | // fileName and mimeType ride in the signed claims so the download gets | |
| 2516 | // a real filename and Content-Type even when the anchor's `download` | |
| 2517 | // attribute is ignored (cross-origin environment servers). | |
| 2558 | // fileName and mimeType ride in the signed claims so videos render | |
| 2559 | // inline while other files keep their real download name and type. | |
| 2518 | 2560 | const result = await createAttachmentAssetUrl({ |
| 2519 | 2561 | environmentId, |
| 2520 | 2562 | input: { |
| ... | ... | @@ -2522,15 +2564,20 @@ function ChatViewContent(props: ChatViewProps) { |
| 2522 | 2564 | _tag: "attachment", |
| 2523 | 2565 | attachmentId: attachment.id, |
| 2524 | 2566 | fileName: attachment.name, |
| 2525 | mimeType: attachment.mimeType, | |
| 2567 | mimeType: videoMime ?? attachment.mimeType, | |
| 2526 | 2568 | }, |
| 2527 | 2569 | }, |
| 2528 | 2570 | }); |
| 2571 | if (!isCurrentRequest()) { | |
| 2572 | finishVideoPreviewRequest(); | |
| 2573 | return; | |
| 2574 | } | |
| 2529 | 2575 | if (result._tag === "Failure") { |
| 2576 | finishVideoPreviewRequest(); | |
| 2530 | 2577 | const error = squashAtomCommandFailure(result); |
| 2531 | 2578 | toastManager.add({ |
| 2532 | 2579 | type: "error", |
| 2533 | title: `Could not download ${attachment.name}`, | |
| 2580 | title: "Could not " + action + " " + attachment.name, | |
| 2534 | 2581 | description: error instanceof Error ? error.message : "The attachment is unavailable.", |
| 2535 | 2582 | }); |
| 2536 | 2583 | return; |
| ... | ... | @@ -2538,7 +2585,31 @@ function ChatViewContent(props: ChatViewProps) { |
| 2538 | 2585 | |
| 2539 | 2586 | const url = resolveAssetUrl(connection.httpBaseUrl, result.value.relativeUrl); |
| 2540 | 2587 | if (!url) { |
| 2541 | toastManager.add({ type: "error", title: `Could not download ${attachment.name}` }); | |
| 2588 | finishVideoPreviewRequest(); | |
| 2589 | toastManager.add({ type: "error", title: "Could not " + action + " " + attachment.name }); | |
| 2590 | return; | |
| 2591 | } | |
| 2592 | if (isVideo) { | |
| 2593 | try { | |
| 2594 | const previewUrl = await loadVideoPreviewUrl(url, videoPreviewAbortController?.signal); | |
| 2595 | if (!isCurrentRequest()) { | |
| 2596 | revokeBlobPreviewUrl(previewUrl); | |
| 2597 | return; | |
| 2598 | } | |
| 2599 | setExpandedImage({ | |
| 2600 | images: [{ src: previewUrl, name: attachment.name, type: "video" }], | |
| 2601 | index: 0, | |
| 2602 | }); | |
| 2603 | } catch (error) { | |
| 2604 | if (!isCurrentRequest()) return; | |
| 2605 | toastManager.add({ | |
| 2606 | type: "error", | |
| 2607 | title: "Could not play " + attachment.name, | |
| 2608 | description: error instanceof Error ? error.message : "The attachment is unavailable.", | |
| 2609 | }); | |
| 2610 | } finally { | |
| 2611 | finishVideoPreviewRequest(); | |
| 2612 | } | |
| 2542 | 2613 | return; |
| 2543 | 2614 | } |
| 2544 | 2615 | const anchor = document.createElement("a"); |
| ... | ... | @@ -2546,7 +2617,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 2546 | 2617 | anchor.download = attachment.name; |
| 2547 | 2618 | anchor.click(); |
| 2548 | 2619 | }, |
| 2549 | [createAttachmentAssetUrl, environmentId], | |
| 2620 | [createAttachmentAssetUrl, environmentId, routeThreadKey], | |
| 2550 | 2621 | ); |
| 2551 | 2622 | const serverAttachmentIds = useMemo(() => { |
| 2552 | 2623 | const attachmentIds = new Set<string>(); |
| ... | ... | @@ -4389,8 +4460,10 @@ function ChatViewContent(props: ChatViewProps) { |
| 4389 | 4460 | return []; |
| 4390 | 4461 | }); |
| 4391 | 4462 | resetLocalDispatch(); |
| 4463 | cancelVideoPreviewRequest(); | |
| 4464 | setOpeningVideoAttachmentId(null); | |
| 4392 | 4465 | setExpandedImage(null); |
| 4393 | }, [draftId, resetLocalDispatch, threadId]); | |
| 4466 | }, [cancelVideoPreviewRequest, draftId, resetLocalDispatch, threadId]); | |
| 4394 | 4467 | |
| 4395 | 4468 | const closeExpandedImage = useCallback(() => { |
| 4396 | 4469 | setExpandedImage(null); |
| ... | ... | @@ -6843,9 +6916,14 @@ function ChatViewContent(props: ChatViewProps) { |
| 6843 | 6916 | } |
| 6844 | 6917 | }; |
| 6845 | 6918 | |
| 6846 | const onExpandTimelineImage = useCallback((preview: ExpandedImagePreview) => { | |
| 6847 | setExpandedImage(preview); | |
| 6848 | }, []); | |
| 6919 | const onExpandTimelineImage = useCallback( | |
| 6920 | (preview: ExpandedImagePreview) => { | |
| 6921 | cancelVideoPreviewRequest(); | |
| 6922 | setOpeningVideoAttachmentId(null); | |
| 6923 | setExpandedImage(preview); | |
| 6924 | }, | |
| 6925 | [cancelVideoPreviewRequest], | |
| 6926 | ); | |
| 6849 | 6927 | const onOpenTurnDiff = useCallback( |
| 6850 | 6928 | (turnId: TurnId, filePath?: string) => { |
| 6851 | 6929 | if (!isServerThread || !activeThreadRef) return; |
| ... | ... | @@ -7139,7 +7217,8 @@ function ChatViewContent(props: ChatViewProps) { |
| 7139 | 7217 | onRevertUserMessage={onRevertUserMessage} |
| 7140 | 7218 | isRevertingCheckpoint={isRevertingCheckpoint} |
| 7141 | 7219 | onImageExpand={onExpandTimelineImage} |
| 7142 | onFileDownload={downloadFileAttachment} | |
| 7220 | onFileOpen={openFileAttachment} | |
| 7221 | openingVideoAttachmentId={openingVideoAttachmentId} | |
| 7143 | 7222 | markdownCwd={gitCwd ?? undefined} |
| 7144 | 7223 | resolvedTheme={resolvedTheme} |
| 7145 | 7224 | timestampFormat={timestampFormat} |
| ... | ... | @@ -7320,6 +7399,8 @@ function ChatViewContent(props: ChatViewProps) { |
| 7320 | 7399 | scheduleComposerFocus={scheduleComposerFocus} |
| 7321 | 7400 | setThreadError={setThreadError} |
| 7322 | 7401 | onExpandImage={onExpandTimelineImage} |
| 7402 | onFileOpen={openFileAttachment} | |
| 7403 | openingVideoAttachmentId={openingVideoAttachmentId} | |
| 7323 | 7404 | /> |
| 7324 | 7405 | </div> |
| 7325 | 7406 | </div> |
apps/web/src/components/chat/ChatComposer.tsx+191-50| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 | import type { |
| 2 | 2 | ApprovalRequestId, |
| 3 | ChatFileAttachment, | |
| 3 | 4 | EnvironmentId, |
| 4 | 5 | ModelSelection, |
| 5 | 6 | PreviewAnnotationPayload, |
| ... | ... | @@ -55,6 +56,8 @@ import { |
| 55 | 56 | type DraftId, |
| 56 | 57 | type PersistedComposerFileAttachment, |
| 57 | 58 | type PersistedComposerImageAttachment, |
| 59 | composerFileDedupKey, | |
| 60 | composerFileMatchesReattachMarker, | |
| 58 | 61 | composerFileNeedsReattach, |
| 59 | 62 | composerTargetKey, |
| 60 | 63 | hydrateImagesFromPersisted, |
| ... | ... | @@ -86,6 +89,7 @@ import { |
| 86 | 89 | classifyComposerAttachmentFile, |
| 87 | 90 | fileAttachmentCapabilityBlockReason, |
| 88 | 91 | fileAttachmentStagingLimit, |
| 92 | isPreviewableComposerVideo, | |
| 89 | 93 | normalizeComposerImageFileMimeType, |
| 90 | 94 | shouldHandleComposerAttachmentPaste, |
| 91 | 95 | } from "./composerAttachmentFiles"; |
| ... | ... | @@ -142,7 +146,11 @@ import { |
| 142 | 146 | } from "./composerProviderState"; |
| 143 | 147 | import { ContextWindowMeter } from "./ContextWindowMeter"; |
| 144 | 148 | import { resolveContextWindowModelDisplayName } from "./ContextWindowMeter.logic"; |
| 145 | import { buildExpandedImagePreview, type ExpandedImagePreview } from "./ExpandedImagePreview"; | |
| 149 | import { | |
| 150 | attachVideoThumbnail, | |
| 151 | buildExpandedImagePreview, | |
| 152 | type ExpandedImagePreview, | |
| 153 | } from "./ExpandedImagePreview"; | |
| 146 | 154 | import { basenameOfPath } from "../../pierre-icons"; |
| 147 | 155 | import { cn, randomUUID } from "~/lib/utils"; |
| 148 | 156 | import { Separator } from "../ui/separator"; |
| ... | ... | @@ -153,6 +161,30 @@ import { |
| 153 | 161 | } from "./composerSubmission"; |
| 154 | 162 | import { ComposerPromptLengthValidation } from "./ComposerPromptLengthValidation"; |
| 155 | 163 | |
| 164 | function ComposerVideoThumbnail({ file }: { file: File }) { | |
| 165 | const setVideo = useCallback( | |
| 166 | (video: HTMLVideoElement | null) => { | |
| 167 | if (!video) return; | |
| 168 | return attachVideoThumbnail(video, file); | |
| 169 | }, | |
| 170 | [file], | |
| 171 | ); | |
| 172 | ||
| 173 | return ( | |
| 174 | <video | |
| 175 | ref={setVideo} | |
| 176 | muted | |
| 177 | playsInline | |
| 178 | preload="metadata" | |
| 179 | aria-hidden="true" | |
| 180 | onLoadedMetadata={(event) => { | |
| 181 | event.currentTarget.currentTime = Math.min(0.1, event.currentTarget.duration || 0); | |
| 182 | }} | |
| 183 | className="pointer-events-none absolute inset-0 size-full object-cover" | |
| 184 | /> | |
| 185 | ); | |
| 186 | } | |
| 187 | ||
| 156 | 188 | type ComposerCommandMenuPosition = { |
| 157 | 189 | bottom: number; |
| 158 | 190 | left: number; |
| ... | ... | @@ -259,6 +291,7 @@ import { |
| 259 | 291 | FileIcon, |
| 260 | 292 | PaperclipIcon, |
| 261 | 293 | PencilRulerIcon, |
| 294 | PlayIcon, | |
| 262 | 295 | type LucideIcon, |
| 263 | 296 | LockIcon, |
| 264 | 297 | LockOpenIcon, |
| ... | ... | @@ -280,7 +313,7 @@ import { |
| 280 | 313 | } from "../../providerInstances"; |
| 281 | 314 | import { type AppModelOption, getAppModelOptionsForInstance } from "../../modelSelection"; |
| 282 | 315 | import type { UnifiedSettings } from "@t3tools/contracts/settings"; |
| 283 | import type { SessionPhase, Thread } from "../../types"; | |
| 316 | import { type SessionPhase, type Thread, videoMimeType } from "../../types"; | |
| 284 | 317 | import type { PendingUserInputDraftAnswer } from "../../pendingUserInput"; |
| 285 | 318 | import type { PendingApproval, PendingUserInput } from "../../session-logic"; |
| 286 | 319 | import type { ContextWindowSnapshot } from "../../lib/contextWindow"; |
| ... | ... | @@ -683,6 +716,8 @@ export interface ChatComposerProps { |
| 683 | 716 | scheduleComposerFocus: () => void; |
| 684 | 717 | setThreadError: (threadId: ThreadId | null, error: string | null) => void; |
| 685 | 718 | onExpandImage: (preview: ExpandedImagePreview) => void; |
| 719 | onFileOpen: (attachment: ChatFileAttachment) => void; | |
| 720 | openingVideoAttachmentId: string | null; | |
| 686 | 721 | } |
| 687 | 722 | |
| 688 | 723 | // -------------------------------------------------------------------------- |
| ... | ... | @@ -762,6 +797,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 762 | 797 | scheduleComposerFocus, |
| 763 | 798 | setThreadError, |
| 764 | 799 | onExpandImage, |
| 800 | onFileOpen, | |
| 801 | openingVideoAttachmentId, | |
| 765 | 802 | } = props; |
| 766 | 803 | // ------------------------------------------------------------------ |
| 767 | 804 | // Store subscriptions (prompt / images / terminal contexts) |
| ... | ... | @@ -774,6 +811,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 774 | 811 | const prompt = composerDraft.prompt; |
| 775 | 812 | const composerImages = composerDraft.images; |
| 776 | 813 | const composerFiles = composerDraft.files; |
| 814 | const composerVideos = composerFiles.filter((file) => | |
| 815 | isPreviewableComposerVideo(file, environmentId), | |
| 816 | ); | |
| 817 | const composerOtherFiles = composerFiles.filter( | |
| 818 | (file) => !isPreviewableComposerVideo(file, environmentId), | |
| 819 | ); | |
| 777 | 820 | const composerTerminalContexts = composerDraft.terminalContexts; |
| 778 | 821 | const composerElementContexts = composerDraft.elementContexts; |
| 779 | 822 | const composerPreviewAnnotations = composerDraft.previewAnnotations; |
| ... | ... | @@ -2363,11 +2406,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2363 | 2406 | let restoredFileCount = 0; |
| 2364 | 2407 | const stashedFiles = entry.files ?? []; |
| 2365 | 2408 | if (stashedFiles.length > 0) { |
| 2366 | const fileDedupKey = (file: { | |
| 2367 | readonly mimeType: string; | |
| 2368 | readonly sizeBytes: number; | |
| 2369 | readonly name: string; | |
| 2370 | }) => `${file.mimeType}\u0000${file.sizeBytes}\u0000${file.name}`; | |
| 2371 | 2409 | const composerFilesNow = composerFilesRef.current; |
| 2372 | 2410 | const existingFileIds = new Set(composerFilesNow.map((file) => file.id)); |
| 2373 | 2411 | const retainedUploadIds = new Set( |
| ... | ... | @@ -2375,16 +2413,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2375 | 2413 | file.uploadedAttachmentId ? [file.uploadedAttachmentId] : [], |
| 2376 | 2414 | ), |
| 2377 | 2415 | ); |
| 2378 | const existingFileKeys = new Set(composerFilesNow.map(fileDedupKey)); | |
| 2379 | const reattachMarkerKeys = new Set( | |
| 2380 | composerFilesNow.filter(composerFileNeedsReattach).map(fileDedupKey), | |
| 2381 | ); | |
| 2416 | const existingFileKeys = new Set(composerFilesNow.map(composerFileDedupKey)); | |
| 2417 | const reattachMarkers = composerFilesNow.filter(composerFileNeedsReattach); | |
| 2418 | const restoredMarkerIds = new Set<string>(); | |
| 2382 | 2419 | const duplicateFiles: PersistedComposerFileAttachment[] = []; |
| 2383 | 2420 | const markerReplacements: ComposerFileAttachment[] = []; |
| 2384 | 2421 | const appendedFiles: ComposerFileAttachment[] = []; |
| 2385 | 2422 | for (const file of stashedFiles) { |
| 2386 | 2423 | const expired = expiredAttachmentIds.has(file.attachmentId); |
| 2387 | const key = fileDedupKey(file); | |
| 2424 | const key = composerFileDedupKey(file); | |
| 2388 | 2425 | const restored: ComposerFileAttachment = { |
| 2389 | 2426 | type: "file", |
| 2390 | 2427 | id: file.id, |
| ... | ... | @@ -2404,23 +2441,23 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2404 | 2441 | } |
| 2405 | 2442 | continue; |
| 2406 | 2443 | } |
| 2407 | if (existingFileKeys.has(key)) { | |
| 2408 | if (reattachMarkerKeys.has(key)) { | |
| 2409 | // The draft row with this identity is a needs-reattach marker, | |
| 2410 | // not a real duplicate. Replace it (addFiles swaps a matching | |
| 2411 | // marker in place) instead of deleting the only uploaded copy. | |
| 2412 | reattachMarkerKeys.delete(key); | |
| 2413 | existingFileIds.add(file.id); | |
| 2414 | if (expired) { | |
| 2415 | // The draft's marker already says "attach again"; nothing to | |
| 2416 | // restore or release, but say why the stash copy is gone. | |
| 2417 | expiredFileNames.push(file.name); | |
| 2418 | } else { | |
| 2419 | retainedUploadIds.add(file.attachmentId); | |
| 2420 | markerReplacements.push(restored); | |
| 2421 | } | |
| 2422 | continue; | |
| 2444 | const reattachMarker = reattachMarkers.find( | |
| 2445 | (marker) => | |
| 2446 | !restoredMarkerIds.has(marker.id) && composerFileMatchesReattachMarker(marker, file), | |
| 2447 | ); | |
| 2448 | if (reattachMarker) { | |
| 2449 | restoredMarkerIds.add(reattachMarker.id); | |
| 2450 | existingFileIds.add(file.id); | |
| 2451 | existingFileKeys.add(key); | |
| 2452 | if (expired) { | |
| 2453 | expiredFileNames.push(file.name); | |
| 2454 | } else { | |
| 2455 | retainedUploadIds.add(file.attachmentId); | |
| 2456 | markerReplacements.push(restored); | |
| 2423 | 2457 | } |
| 2458 | continue; | |
| 2459 | } | |
| 2460 | if (existingFileKeys.has(key)) { | |
| 2424 | 2461 | if (!expired && !retainedUploadIds.has(file.attachmentId)) { |
| 2425 | 2462 | duplicateFiles.push(file); |
| 2426 | 2463 | } |
| ... | ... | @@ -2956,22 +2993,34 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2956 | 2993 | // A pick that matches a needs-reattach marker replaces it in the draft, so |
| 2957 | 2994 | // it must not consume a slot; a draft full of markers would otherwise hit |
| 2958 | 2995 | // the capacity error before the replacement path could run. |
| 2959 | const reattachKeys = new Set( | |
| 2960 | composerFilesRef.current | |
| 2961 | .filter(composerFileNeedsReattach) | |
| 2962 | .map((file) => `${file.mimeType}\u0000${file.sizeBytes}\u0000${file.name}`), | |
| 2963 | ); | |
| 2996 | const reattachMarkers = composerFilesRef.current.filter(composerFileNeedsReattach); | |
| 2997 | const replacedReattachMarkerIds = new Set<string>(); | |
| 2964 | 2998 | const acceptedImages: File[] = []; |
| 2965 | 2999 | const acceptedFiles: ComposerFileAttachment[] = []; |
| 2966 | 3000 | let error: string | null = null; |
| 2967 | 3001 | for (const file of files) { |
| 2968 | 3002 | const attachmentKind = classifyComposerAttachmentFile(file); |
| 2969 | const replacesReattachMarker = | |
| 2970 | attachmentKind === "file" && | |
| 2971 | reattachKeys.delete( | |
| 2972 | `${file.type || "application/octet-stream"}\u0000${file.size}\u0000${file.name || "file"}`, | |
| 2973 | ); | |
| 2974 | if (!replacesReattachMarker && reservedCount >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { | |
| 3003 | const fileMimeType = | |
| 3004 | attachmentKind === "file" | |
| 3005 | ? (videoMimeType({ name: file.name, mimeType: file.type }) ?? | |
| 3006 | (file.type || "application/octet-stream")) | |
| 3007 | : file.type; | |
| 3008 | const matchingReattachMarker = | |
| 3009 | attachmentKind === "file" | |
| 3010 | ? reattachMarkers.find( | |
| 3011 | (marker) => | |
| 3012 | !replacedReattachMarkerIds.has(marker.id) && | |
| 3013 | composerFileMatchesReattachMarker(marker, { | |
| 3014 | name: file.name || "file", | |
| 3015 | mimeType: fileMimeType, | |
| 3016 | sizeBytes: file.size, | |
| 3017 | }), | |
| 3018 | ) | |
| 3019 | : undefined; | |
| 3020 | if (matchingReattachMarker) { | |
| 3021 | replacedReattachMarkerIds.add(matchingReattachMarker.id); | |
| 3022 | } | |
| 3023 | if (!matchingReattachMarker && reservedCount >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { | |
| 2975 | 3024 | error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per message.`; |
| 2976 | 3025 | // Keep scanning: a later file in this batch can still replace a |
| 2977 | 3026 | // needs-reattach marker without needing a free slot. |
| ... | ... | @@ -2996,16 +3045,20 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2996 | 3045 | error = fileAttachmentTooLargeMessage(file.name, fileStagingLimit); |
| 2997 | 3046 | continue; |
| 2998 | 3047 | } |
| 3048 | const attachmentFile = | |
| 3049 | file.type === fileMimeType | |
| 3050 | ? file | |
| 3051 | : new File([file], file.name, { type: fileMimeType, lastModified: file.lastModified }); | |
| 2999 | 3052 | acceptedFiles.push({ |
| 3000 | 3053 | type: "file", |
| 3001 | 3054 | id: randomUUID(), |
| 3002 | name: file.name || "file", | |
| 3003 | mimeType: file.type || "application/octet-stream", | |
| 3004 | sizeBytes: file.size, | |
| 3005 | file, | |
| 3055 | name: attachmentFile.name || "file", | |
| 3056 | mimeType: fileMimeType, | |
| 3057 | sizeBytes: attachmentFile.size, | |
| 3058 | file: attachmentFile, | |
| 3006 | 3059 | }); |
| 3007 | 3060 | } |
| 3008 | if (!replacesReattachMarker) { | |
| 3061 | if (!matchingReattachMarker) { | |
| 3009 | 3062 | reservedCount += 1; |
| 3010 | 3063 | } |
| 3011 | 3064 | } |
| ... | ... | @@ -3686,10 +3739,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3686 | 3739 | {!isComposerCollapsedMobile && |
| 3687 | 3740 | !isComposerApprovalState && |
| 3688 | 3741 | pendingUserInputs.length === 0 && |
| 3689 | composerImages.some( | |
| 3690 | (image) => | |
| 3691 | !composerPreviewAnnotations.some((annotation) => annotation.id === image.id), | |
| 3692 | ) && ( | |
| 3742 | (composerVideos.length > 0 || | |
| 3743 | composerImages.some( | |
| 3744 | (image) => | |
| 3745 | !composerPreviewAnnotations.some((annotation) => annotation.id === image.id), | |
| 3746 | )) && ( | |
| 3693 | 3747 | <div className="mb-3 flex flex-wrap gap-2"> |
| 3694 | 3748 | {composerImages |
| 3695 | 3749 | .filter( |
| ... | ... | @@ -3800,15 +3854,102 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3800 | 3854 | </div> |
| 3801 | 3855 | ); |
| 3802 | 3856 | })} |
| 3857 | {composerVideos.map((file) => { | |
| 3858 | const fileCanUpload = | |
| 3859 | supportsAttachmentUploads && | |
| 3860 | maxFileAttachmentBytes !== null && | |
| 3861 | file.sizeBytes <= maxFileAttachmentBytes; | |
| 3862 | const upload = fileCanUpload ? uploadsByImageId[file.id] : undefined; | |
| 3863 | const isOpening = file.uploadedAttachmentId === openingVideoAttachmentId; | |
| 3864 | return ( | |
| 3865 | <div | |
| 3866 | key={file.id} | |
| 3867 | className="relative h-16 w-16 overflow-hidden rounded-lg border border-border/80 bg-black" | |
| 3868 | > | |
| 3869 | <button | |
| 3870 | type="button" | |
| 3871 | className="flex h-full w-full cursor-zoom-in flex-col items-center justify-center gap-1 px-1 text-white aria-disabled:cursor-default aria-disabled:opacity-50" | |
| 3872 | aria-busy={isOpening || undefined} | |
| 3873 | aria-disabled={isOpening || undefined} | |
| 3874 | aria-label={`${isOpening ? "Loading" : "Play"} ${file.name}`} | |
| 3875 | onClick={() => { | |
| 3876 | if (isOpening) return; | |
| 3877 | if (file.file !== null) { | |
| 3878 | const preview = buildExpandedImagePreview([file], file.id); | |
| 3879 | if (preview) onExpandImage(preview); | |
| 3880 | return; | |
| 3881 | } | |
| 3882 | if (!file.uploadedAttachmentId) return; | |
| 3883 | onFileOpen({ ...file, id: file.uploadedAttachmentId }); | |
| 3884 | }} | |
| 3885 | > | |
| 3886 | {file.file && ( | |
| 3887 | <> | |
| 3888 | <ComposerVideoThumbnail file={file.file} /> | |
| 3889 | <span className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/70 via-black/10 to-black/10" /> | |
| 3890 | </> | |
| 3891 | )} | |
| 3892 | {isOpening ? ( | |
| 3893 | <span className="relative z-10 text-[10px]">Loading…</span> | |
| 3894 | ) : ( | |
| 3895 | <PlayIcon className="relative z-10 size-4 fill-current drop-shadow-md" /> | |
| 3896 | )} | |
| 3897 | </button> | |
| 3898 | {upload?.status === "uploading" && ( | |
| 3899 | <span className="pointer-events-none absolute inset-x-0 bottom-0 bg-background/85 px-1 text-center text-[10px] text-foreground"> | |
| 3900 | {formatAttachmentUploadProgress(upload.progress)} | |
| 3901 | </span> | |
| 3902 | )} | |
| 3903 | {upload?.status === "failed" && ( | |
| 3904 | <Tooltip> | |
| 3905 | <TooltipTrigger | |
| 3906 | render={ | |
| 3907 | <Button | |
| 3908 | variant="ghost" | |
| 3909 | size="icon-xs" | |
| 3910 | className="absolute bottom-1 left-1 bg-background/85 hover:bg-background/95" | |
| 3911 | onClick={() => | |
| 3912 | retryAttachmentUpload({ | |
| 3913 | environmentId, | |
| 3914 | image: file, | |
| 3915 | draftTarget: composerDraftTarget, | |
| 3916 | }) | |
| 3917 | } | |
| 3918 | aria-label={`Retry upload for ${file.name}`} | |
| 3919 | /> | |
| 3920 | } | |
| 3921 | > | |
| 3922 | <RotateCcwIcon /> | |
| 3923 | </TooltipTrigger> | |
| 3924 | <TooltipPopup | |
| 3925 | side="top" | |
| 3926 | className="max-w-64 whitespace-normal leading-tight" | |
| 3927 | > | |
| 3928 | {upload.reason} | |
| 3929 | </TooltipPopup> | |
| 3930 | </Tooltip> | |
| 3931 | )} | |
| 3932 | <Button | |
| 3933 | variant="ghost" | |
| 3934 | size="icon-xs" | |
| 3935 | className="absolute right-1 top-1 bg-background/80 hover:bg-background/90" | |
| 3936 | onClick={() => removeComposerFileFromDraft(file.id)} | |
| 3937 | aria-label={`Remove ${file.name}`} | |
| 3938 | > | |
| 3939 | <XIcon /> | |
| 3940 | </Button> | |
| 3941 | </div> | |
| 3942 | ); | |
| 3943 | })} | |
| 3803 | 3944 | </div> |
| 3804 | 3945 | )} |
| 3805 | 3946 | |
| 3806 | 3947 | {!isComposerCollapsedMobile && |
| 3807 | 3948 | !isComposerApprovalState && |
| 3808 | 3949 | pendingUserInputs.length === 0 && |
| 3809 | composerFiles.length > 0 && ( | |
| 3950 | composerOtherFiles.length > 0 && ( | |
| 3810 | 3951 | <div className="mb-3 flex flex-col gap-1"> |
| 3811 | {composerFiles.map((file) => { | |
| 3952 | {composerOtherFiles.map((file) => { | |
| 3812 | 3953 | const fileCanUpload = |
| 3813 | 3954 | supportsAttachmentUploads && |
| 3814 | 3955 | maxFileAttachmentBytes !== null && |
apps/web/src/components/chat/ExpandedImageDialog.test.tsx created+28| ... | ... | @@ -0,0 +1,28 @@ |
| 1 | import { renderToStaticMarkup } from "react-dom/server"; | |
| 2 | import { describe, expect, it } from "vite-plus/test"; | |
| 3 | ||
| 4 | import { ExpandedImageDialog } from "./ExpandedImageDialog"; | |
| 5 | import type { ExpandedImagePreview } from "./ExpandedImagePreview"; | |
| 6 | ||
| 7 | describe("ExpandedImageDialog", () => { | |
| 8 | it("renders video previews with native controls", () => { | |
| 9 | const preview: ExpandedImagePreview = { | |
| 10 | images: [ | |
| 11 | { | |
| 12 | src: "https://environment.test/api/assets/demo.mp4", | |
| 13 | name: "demo.mp4", | |
| 14 | type: "video", | |
| 15 | }, | |
| 16 | ], | |
| 17 | index: 0, | |
| 18 | }; | |
| 19 | ||
| 20 | const markup = renderToStaticMarkup( | |
| 21 | <ExpandedImageDialog preview={preview} onClose={() => {}} />, | |
| 22 | ); | |
| 23 | ||
| 24 | expect(markup).toContain("<video"); | |
| 25 | expect(markup).toContain('controls=""'); | |
| 26 | expect(markup).toContain('aria-label="demo.mp4"'); | |
| 27 | }); | |
| 28 | }); |
apps/web/src/components/chat/ExpandedImageDialog.tsx+62-11| ... | ... | @@ -1,7 +1,7 @@ |
| 1 | 1 | import { memo, useCallback, useEffect, useState } from "react"; |
| 2 | import { ChevronLeftIcon, ChevronRightIcon, XIcon } from "lucide-react"; | |
| 2 | import { ChevronLeftIcon, ChevronRightIcon, DownloadIcon, XIcon } from "lucide-react"; | |
| 3 | 3 | import { Button } from "../ui/button"; |
| 4 | import type { ExpandedImagePreview } from "./ExpandedImagePreview"; | |
| 4 | import { downloadVideoPreview, type ExpandedImagePreview } from "./ExpandedImagePreview"; | |
| 5 | 5 | |
| 6 | 6 | interface ExpandedImageDialogProps { |
| 7 | 7 | preview: ExpandedImagePreview; |
| ... | ... | @@ -13,12 +13,27 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ |
| 13 | 13 | onClose, |
| 14 | 14 | }: ExpandedImageDialogProps) { |
| 15 | 15 | const [imageOffset, setImageOffset] = useState(0); |
| 16 | const [failedVideoSrc, setFailedVideoSrc] = useState<string | null>(null); | |
| 17 | const [downloadingVideoSrc, setDownloadingVideoSrc] = useState<string | null>(null); | |
| 18 | const [downloadFailedVideoSrc, setDownloadFailedVideoSrc] = useState<string | null>(null); | |
| 16 | 19 | const index = (preview.index + imageOffset + preview.images.length) % preview.images.length; |
| 17 | 20 | |
| 18 | 21 | const navigateImage = useCallback((direction: -1 | 1) => { |
| 19 | 22 | setImageOffset((current) => current + direction); |
| 20 | 23 | }, []); |
| 21 | 24 | |
| 25 | const downloadVideo = async (src: string, name: string) => { | |
| 26 | setDownloadFailedVideoSrc(null); | |
| 27 | setDownloadingVideoSrc(src); | |
| 28 | try { | |
| 29 | await downloadVideoPreview(src, name); | |
| 30 | } catch { | |
| 31 | setDownloadFailedVideoSrc(src); | |
| 32 | } finally { | |
| 33 | setDownloadingVideoSrc((current) => (current === src ? null : current)); | |
| 34 | } | |
| 35 | }; | |
| 36 | ||
| 22 | 37 | useEffect(() => { |
| 23 | 38 | const onKeyDown = (event: globalThis.KeyboardEvent) => { |
| 24 | 39 | if (event.key === "Escape") { |
| ... | ... | @@ -45,18 +60,21 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ |
| 45 | 60 | |
| 46 | 61 | const item = preview.images[index]; |
| 47 | 62 | if (!item) return null; |
| 63 | const mediaLabel = item.type === "video" ? "video" : "image"; | |
| 48 | 64 | |
| 65 | const isDownloadingVideo = downloadingVideoSrc === item.src; | |
| 66 | const videoDownloadFailed = downloadFailedVideoSrc === item.src; | |
| 49 | 67 | return ( |
| 50 | 68 | <div |
| 51 | 69 | className="fixed inset-0 z-50 flex items-center justify-center bg-black/75 px-4 py-6 [-webkit-app-region:no-drag]" |
| 52 | 70 | role="dialog" |
| 53 | 71 | aria-modal="true" |
| 54 | aria-label="Expanded image preview" | |
| 72 | aria-label={`Expanded ${mediaLabel} preview`} | |
| 55 | 73 | > |
| 56 | 74 | <button |
| 57 | 75 | type="button" |
| 58 | 76 | className="absolute inset-0 z-0 cursor-zoom-out" |
| 59 | aria-label="Close image preview" | |
| 77 | aria-label={`Close ${mediaLabel} preview`} | |
| 60 | 78 | onClick={onClose} |
| 61 | 79 | /> |
| 62 | 80 | {preview.images.length > 1 && ( |
| ... | ... | @@ -78,16 +96,49 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ |
| 78 | 96 | variant="ghost" |
| 79 | 97 | className="absolute right-2 top-2" |
| 80 | 98 | onClick={onClose} |
| 81 | aria-label="Close image preview" | |
| 99 | aria-label={`Close ${mediaLabel} preview`} | |
| 82 | 100 | > |
| 83 | 101 | <XIcon /> |
| 84 | 102 | </Button> |
| 85 | <img | |
| 86 | src={item.src} | |
| 87 | alt={item.name} | |
| 88 | className="max-h-[86vh] max-w-[92vw] select-none rounded-lg border border-border/70 bg-background object-contain shadow-2xl" | |
| 89 | draggable={false} | |
| 90 | /> | |
| 103 | {item.type === "video" && failedVideoSrc === item.src ? ( | |
| 104 | <div className="flex h-48 w-[min(92vw,32rem)] flex-col items-center justify-center gap-3 rounded-lg border border-border/70 bg-black px-6 text-center text-white shadow-2xl"> | |
| 105 | <p className="text-sm"> | |
| 106 | {videoDownloadFailed | |
| 107 | ? "Could not download this video." | |
| 108 | : "This video format cannot be played here."} | |
| 109 | </p> | |
| 110 | <Button | |
| 111 | size="sm" | |
| 112 | variant="secondary" | |
| 113 | aria-busy={isDownloadingVideo || undefined} | |
| 114 | aria-disabled={isDownloadingVideo || undefined} | |
| 115 | onClick={() => { | |
| 116 | if (isDownloadingVideo) return; | |
| 117 | void downloadVideo(item.src, item.name); | |
| 118 | }} | |
| 119 | > | |
| 120 | <DownloadIcon /> | |
| 121 | {isDownloadingVideo ? "Downloading…" : "Download video"} | |
| 122 | </Button> | |
| 123 | </div> | |
| 124 | ) : item.type === "video" ? ( | |
| 125 | <video | |
| 126 | src={item.src} | |
| 127 | aria-label={item.name} | |
| 128 | autoPlay | |
| 129 | controls | |
| 130 | playsInline | |
| 131 | onError={() => setFailedVideoSrc(item.src)} | |
| 132 | className="max-h-[86vh] max-w-[92vw] rounded-lg border border-border/70 bg-black object-contain shadow-2xl" | |
| 133 | /> | |
| 134 | ) : ( | |
| 135 | <img | |
| 136 | src={item.src} | |
| 137 | alt={item.name} | |
| 138 | className="max-h-[86vh] max-w-[92vw] select-none rounded-lg border border-border/70 bg-background object-contain shadow-2xl" | |
| 139 | draggable={false} | |
| 140 | /> | |
| 141 | )} | |
| 91 | 142 | <p className="mt-2 max-w-[92vw] truncate text-center text-xs text-muted-foreground/80"> |
| 92 | 143 | {item.name} |
| 93 | 144 | {preview.images.length > 1 ? ` (${index + 1}/${preview.images.length})` : ""} |
apps/web/src/components/chat/ExpandedImagePreview.test.ts created+68| ... | ... | @@ -0,0 +1,68 @@ |
| 1 | import { describe, expect, it, vi } from "vite-plus/test"; | |
| 2 | ||
| 3 | import type { ComposerFileAttachment } from "../../composerDraftStore"; | |
| 4 | import { | |
| 5 | attachVideoThumbnail, | |
| 6 | buildExpandedImagePreview, | |
| 7 | downloadVideoPreview, | |
| 8 | } from "./ExpandedImagePreview"; | |
| 9 | ||
| 10 | describe("buildExpandedImagePreview", () => { | |
| 11 | it("builds a video preview for a local video attachment", () => { | |
| 12 | const file = new File([new Uint8Array([1, 2, 3])], "demo.mp4", { type: "video/mp4" }); | |
| 13 | const attachment: ComposerFileAttachment = { | |
| 14 | type: "file", | |
| 15 | id: "video-1", | |
| 16 | name: file.name, | |
| 17 | mimeType: file.type, | |
| 18 | sizeBytes: file.size, | |
| 19 | file, | |
| 20 | }; | |
| 21 | ||
| 22 | const preview = buildExpandedImagePreview([attachment], attachment.id); | |
| 23 | ||
| 24 | expect(preview).toMatchObject({ | |
| 25 | images: [{ name: "demo.mp4", type: "video" }], | |
| 26 | index: 0, | |
| 27 | }); | |
| 28 | expect(preview?.images[0]?.src).toMatch(/^blob:/); | |
| 29 | URL.revokeObjectURL(preview?.images[0]?.src ?? ""); | |
| 30 | }); | |
| 31 | ||
| 32 | it("releases a video thumbnail URL when detached", async () => { | |
| 33 | const video = { src: "" } as HTMLVideoElement; | |
| 34 | const file = new File([new Uint8Array([1, 2, 3])], "demo.mp4", { type: "video/mp4" }); | |
| 35 | ||
| 36 | const detach = attachVideoThumbnail(video, file); | |
| 37 | const url = video.src; | |
| 38 | ||
| 39 | expect((await fetch(url)).ok).toBe(true); | |
| 40 | detach(); | |
| 41 | await expect(fetch(url)).rejects.toThrow(); | |
| 42 | }); | |
| 43 | ||
| 44 | it("downloads a video through a local blob URL", async () => { | |
| 45 | vi.useFakeTimers(); | |
| 46 | const source = URL.createObjectURL(new Blob([new Uint8Array([1, 2, 3])])); | |
| 47 | const click = vi.fn(); | |
| 48 | const anchor = { href: "", download: "", click }; | |
| 49 | vi.stubGlobal("document", { createElement: () => anchor }); | |
| 50 | ||
| 51 | try { | |
| 52 | await downloadVideoPreview(source, "demo.mp4"); | |
| 53 | ||
| 54 | expect(anchor.download).toBe("demo.mp4"); | |
| 55 | expect(anchor.href).toMatch(/^blob:/); | |
| 56 | expect(anchor.href).not.toBe(source); | |
| 57 | expect(click).toHaveBeenCalledOnce(); | |
| 58 | expect((await fetch(anchor.href)).ok).toBe(true); | |
| 59 | ||
| 60 | await vi.runAllTimersAsync(); | |
| 61 | await expect(fetch(anchor.href)).rejects.toThrow(); | |
| 62 | } finally { | |
| 63 | URL.revokeObjectURL(source); | |
| 64 | vi.useRealTimers(); | |
| 65 | vi.unstubAllGlobals(); | |
| 66 | } | |
| 67 | }); | |
| 68 | }); |
apps/web/src/components/chat/ExpandedImagePreview.tsx+32-2| ... | ... | @@ -1,6 +1,10 @@ |
| 1 | import type { ComposerFileAttachment } from "../../composerDraftStore"; | |
| 2 | import { type ChatImageAttachment, isVideoAttachment } from "../../types"; | |
| 3 | ||
| 1 | 4 | export interface ExpandedImageItem { |
| 2 | 5 | src: string; |
| 3 | 6 | name: string; |
| 7 | type?: "video"; | |
| 4 | 8 | } |
| 5 | 9 | |
| 6 | 10 | export interface ExpandedImagePreview { |
| ... | ... | @@ -8,12 +12,38 @@ export interface ExpandedImagePreview { |
| 8 | 12 | index: number; |
| 9 | 13 | } |
| 10 | 14 | |
| 15 | export function attachVideoThumbnail(video: HTMLVideoElement, file: File): () => void { | |
| 16 | const url = URL.createObjectURL(file); | |
| 17 | video.src = url; | |
| 18 | return () => URL.revokeObjectURL(url); | |
| 19 | } | |
| 20 | ||
| 21 | export async function downloadVideoPreview(src: string, name: string): Promise<void> { | |
| 22 | const response = await fetch(src); | |
| 23 | if (!response.ok) throw new Error(`Could not download video (${response.status}).`); | |
| 24 | const url = URL.createObjectURL(await response.blob()); | |
| 25 | const anchor = document.createElement("a"); | |
| 26 | anchor.href = url; | |
| 27 | anchor.download = name; | |
| 28 | anchor.click(); | |
| 29 | setTimeout(() => URL.revokeObjectURL(url), 30_000); | |
| 30 | } | |
| 31 | ||
| 11 | 32 | export function buildExpandedImagePreview( |
| 12 | images: ReadonlyArray<{ id: string; name: string; previewUrl?: string }>, | |
| 33 | images: ReadonlyArray<ChatImageAttachment | ComposerFileAttachment>, | |
| 13 | 34 | selectedImageId: string, |
| 14 | 35 | ): ExpandedImagePreview | null { |
| 36 | const selected = images.find((image) => image.id === selectedImageId); | |
| 37 | if (selected?.type === "file" && selected.file && isVideoAttachment(selected)) { | |
| 38 | return { | |
| 39 | images: [{ src: URL.createObjectURL(selected.file), name: selected.name, type: "video" }], | |
| 40 | index: 0, | |
| 41 | }; | |
| 42 | } | |
| 15 | 43 | const previewableImages = images.flatMap((image) => |
| 16 | image.previewUrl ? [{ id: image.id, src: image.previewUrl, name: image.name }] : [], | |
| 44 | image.type === "image" && image.previewUrl | |
| 45 | ? [{ id: image.id, src: image.previewUrl, name: image.name }] | |
| 46 | : [], | |
| 17 | 47 | ); |
| 18 | 48 | if (previewableImages.length === 0) { |
| 19 | 49 | return null; |
apps/web/src/components/chat/MessagesTimeline.test.tsx+38| ... | ... | @@ -187,6 +187,7 @@ function buildProps() { |
| 187 | 187 | revertTurnCountByUserMessageId: new Map(), |
| 188 | 188 | onRevertUserMessage: () => {}, |
| 189 | 189 | isRevertingCheckpoint: false, |
| 190 | openingVideoAttachmentId: null, | |
| 190 | 191 | onImageExpand: () => {}, |
| 191 | 192 | activeThreadEnvironmentId: ACTIVE_THREAD_ENVIRONMENT_ID, |
| 192 | 193 | markdownCwd: undefined, |
| ... | ... | @@ -586,6 +587,43 @@ describe("MessagesTimeline", () => { |
| 586 | 587 | expect(markup).not.toContain('alt="report.pdf"'); |
| 587 | 588 | }); |
| 588 | 589 | |
| 590 | it("renders video attachments as play buttons", () => { | |
| 591 | const entry = { | |
| 592 | ...buildUserTimelineEntry("Watch the demo."), | |
| 593 | message: { | |
| 594 | ...buildUserTimelineEntry("Watch the demo.").message, | |
| 595 | attachments: [ | |
| 596 | { | |
| 597 | type: "file" as const, | |
| 598 | id: "attachment-demo-mp4", | |
| 599 | name: "demo.mp4", | |
| 600 | mimeType: "video/mp4", | |
| 601 | sizeBytes: 42, | |
| 602 | }, | |
| 603 | ], | |
| 604 | }, | |
| 605 | }; | |
| 606 | ||
| 607 | const markup = renderToStaticMarkup( | |
| 608 | <MessagesTimeline {...buildProps()} timelineEntries={[entry]} />, | |
| 609 | ); | |
| 610 | const busyMarkup = renderToStaticMarkup( | |
| 611 | <MessagesTimeline | |
| 612 | {...buildProps()} | |
| 613 | timelineEntries={[entry]} | |
| 614 | openingVideoAttachmentId="attachment-demo-mp4" | |
| 615 | />, | |
| 616 | ); | |
| 617 | ||
| 618 | expect(markup).toContain('aria-label="Play demo.mp4"'); | |
| 619 | expect(markup).toContain("min-h-[72px]"); | |
| 620 | expect(markup).toContain(">demo.mp4</span>"); | |
| 621 | expect(markup).not.toContain('aria-label="Download demo.mp4"'); | |
| 622 | expect(busyMarkup).toContain('aria-busy="true"'); | |
| 623 | expect(busyMarkup).toContain('aria-disabled="true"'); | |
| 624 | expect(busyMarkup).not.toContain('disabled=""'); | |
| 625 | expect(busyMarkup).toContain(">Loading…</span>"); | |
| 626 | }); | |
| 589 | 627 | it("renders a file download button without creating its URL in advance", () => { |
| 590 | 628 | const entry = { |
| 591 | 629 | ...buildUserTimelineEntry("Read the report."), |
apps/web/src/components/chat/MessagesTimeline.tsx+48-10| ... | ... | @@ -16,7 +16,7 @@ import { |
| 16 | 16 | |
| 17 | 17 | const EMPTY_AGENT_PANEL_MODEL = emptyAgentPanelModel(); |
| 18 | 18 | const NOOP_OPEN_AGENTS = () => {}; |
| 19 | const NOOP_DOWNLOAD_ATTACHMENT = (_attachment: ChatFileAttachment) => {}; | |
| 19 | const NOOP_OPEN_ATTACHMENT = (_attachment: ChatFileAttachment) => {}; | |
| 20 | 20 | import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; |
| 21 | 21 | import { |
| 22 | 22 | createContext, |
| ... | ... | @@ -45,6 +45,7 @@ import { |
| 45 | 45 | type ChatImageAttachment, |
| 46 | 46 | isFileAttachment, |
| 47 | 47 | isImageAttachment, |
| 48 | isVideoAttachment, | |
| 48 | 49 | type TurnDiffSummary, |
| 49 | 50 | } from "../../types"; |
| 50 | 51 | import { |
| ... | ... | @@ -67,6 +68,7 @@ import { |
| 67 | 68 | MessageCircleIcon, |
| 68 | 69 | MousePointerClickIcon, |
| 69 | 70 | PaintbrushIcon, |
| 71 | PlayIcon, | |
| 70 | 72 | SearchIcon, |
| 71 | 73 | SquarePenIcon, |
| 72 | 74 | TerminalIcon, |
| ... | ... | @@ -153,7 +155,8 @@ interface TimelineRowSharedState { |
| 153 | 155 | activeThreadEnvironmentId: EnvironmentId; |
| 154 | 156 | onRevertUserMessage: (messageId: MessageId) => void; |
| 155 | 157 | onImageExpand: (preview: ExpandedImagePreview) => void; |
| 156 | onFileDownload: (attachment: ChatFileAttachment) => void; | |
| 158 | onFileOpen: (attachment: ChatFileAttachment) => void; | |
| 159 | openingVideoAttachmentId: string | null; | |
| 157 | 160 | onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; |
| 158 | 161 | onToggleTurnFold: (turnId: TurnId) => void; |
| 159 | 162 | onToggleWorkGroup: (groupId: string, anchorKey: string) => void; |
| ... | ... | @@ -232,7 +235,8 @@ interface MessagesTimelineProps { |
| 232 | 235 | onRevertUserMessage: (messageId: MessageId) => void; |
| 233 | 236 | isRevertingCheckpoint: boolean; |
| 234 | 237 | onImageExpand: (preview: ExpandedImagePreview) => void; |
| 235 | onFileDownload?: (attachment: ChatFileAttachment) => void; | |
| 238 | onFileOpen?: (attachment: ChatFileAttachment) => void; | |
| 239 | openingVideoAttachmentId: string | null; | |
| 236 | 240 | activeThreadEnvironmentId: EnvironmentId; |
| 237 | 241 | markdownCwd: string | undefined; |
| 238 | 242 | resolvedTheme: "light" | "dark"; |
| ... | ... | @@ -278,7 +282,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 278 | 282 | onRevertUserMessage, |
| 279 | 283 | isRevertingCheckpoint, |
| 280 | 284 | onImageExpand, |
| 281 | onFileDownload = NOOP_DOWNLOAD_ATTACHMENT, | |
| 285 | onFileOpen = NOOP_OPEN_ATTACHMENT, | |
| 286 | openingVideoAttachmentId, | |
| 282 | 287 | activeThreadEnvironmentId, |
| 283 | 288 | markdownCwd, |
| 284 | 289 | resolvedTheme, |
| ... | ... | @@ -537,7 +542,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 537 | 542 | activeThreadEnvironmentId, |
| 538 | 543 | onRevertUserMessage, |
| 539 | 544 | onImageExpand, |
| 540 | onFileDownload, | |
| 545 | onFileOpen, | |
| 546 | openingVideoAttachmentId, | |
| 541 | 547 | onOpenTurnDiff, |
| 542 | 548 | onToggleTurnFold, |
| 543 | 549 | onToggleWorkGroup, |
| ... | ... | @@ -554,7 +560,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 554 | 560 | activeThreadEnvironmentId, |
| 555 | 561 | onRevertUserMessage, |
| 556 | 562 | onImageExpand, |
| 557 | onFileDownload, | |
| 563 | onFileOpen, | |
| 564 | openingVideoAttachmentId, | |
| 558 | 565 | onOpenTurnDiff, |
| 559 | 566 | onToggleTurnFold, |
| 560 | 567 | onToggleWorkGroup, |
| ... | ... | @@ -1005,6 +1012,8 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" |
| 1005 | 1012 | // comparisons) split it. Unknown types render as inert rows below the files. |
| 1006 | 1013 | const userImages = (row.message.attachments ?? []).filter(isImageAttachment); |
| 1007 | 1014 | const userFiles = (row.message.attachments ?? []).filter(isFileAttachment); |
| 1015 | const userVideos = userFiles.filter(isVideoAttachment); | |
| 1016 | const otherUserFiles = userFiles.filter((file) => !isVideoAttachment(file)); | |
| 1008 | 1017 | const unknownAttachments = (row.message.attachments ?? []).filter( |
| 1009 | 1018 | (attachment) => !isImageAttachment(attachment) && !isFileAttachment(attachment), |
| 1010 | 1019 | ); |
| ... | ... | @@ -1030,7 +1039,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" |
| 1030 | 1039 | return ( |
| 1031 | 1040 | <div className="group flex flex-col items-end gap-1"> |
| 1032 | 1041 | <div className="relative max-w-[80%] rounded-2xl bg-message p-3 text-message-foreground"> |
| 1033 | {regularImages.length > 0 && ( | |
| 1042 | {(regularImages.length > 0 || userVideos.length > 0) && ( | |
| 1034 | 1043 | <div className="mb-2 grid max-w-[420px] grid-cols-2 gap-2"> |
| 1035 | 1044 | {regularImages.map((image) => ( |
| 1036 | 1045 | <div |
| ... | ... | @@ -1061,6 +1070,35 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" |
| 1061 | 1070 | )} |
| 1062 | 1071 | </div> |
| 1063 | 1072 | ))} |
| 1073 | {userVideos.map((file) => { | |
| 1074 | const isOpening = ctx.openingVideoAttachmentId === file.id; | |
| 1075 | return ( | |
| 1076 | <div | |
| 1077 | key={file.id} | |
| 1078 | className="overflow-hidden rounded-lg border border-border/80 bg-black" | |
| 1079 | > | |
| 1080 | <button | |
| 1081 | type="button" | |
| 1082 | disabled={file.downloadable === false} | |
| 1083 | className="flex min-h-[72px] w-full cursor-zoom-in flex-col items-center justify-center gap-1 px-2 py-2 text-white disabled:cursor-default disabled:opacity-50 aria-disabled:cursor-default aria-disabled:opacity-50" | |
| 1084 | aria-busy={isOpening || undefined} | |
| 1085 | aria-disabled={isOpening || undefined} | |
| 1086 | aria-label={`${isOpening ? "Loading" : "Play"} ${file.name}`} | |
| 1087 | onClick={() => { | |
| 1088 | if (isOpening) return; | |
| 1089 | ctx.onFileOpen(file); | |
| 1090 | }} | |
| 1091 | > | |
| 1092 | {isOpening ? ( | |
| 1093 | <span className="text-[11px]">Loading…</span> | |
| 1094 | ) : ( | |
| 1095 | <PlayIcon className="size-8 fill-current" /> | |
| 1096 | )} | |
| 1097 | <span className="max-w-full truncate text-[11px]">{file.name}</span> | |
| 1098 | </button> | |
| 1099 | </div> | |
| 1100 | ); | |
| 1101 | })} | |
| 1064 | 1102 | </div> |
| 1065 | 1103 | )} |
| 1066 | 1104 | {previewAnnotations.map((annotation, index) => ( |
| ... | ... | @@ -1070,9 +1108,9 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" |
| 1070 | 1108 | image={previewImages[index] ?? null} |
| 1071 | 1109 | /> |
| 1072 | 1110 | ))} |
| 1073 | {userFiles.length > 0 || unknownAttachments.length > 0 ? ( | |
| 1111 | {otherUserFiles.length > 0 || unknownAttachments.length > 0 ? ( | |
| 1074 | 1112 | <div className="mb-2 flex flex-col gap-1"> |
| 1075 | {userFiles.map((file) => { | |
| 1113 | {otherUserFiles.map((file) => { | |
| 1076 | 1114 | const content = ( |
| 1077 | 1115 | <> |
| 1078 | 1116 | <FileIcon className="size-4 shrink-0 text-secondary-label" /> |
| ... | ... | @@ -1100,7 +1138,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" |
| 1100 | 1138 | key={file.id} |
| 1101 | 1139 | type="button" |
| 1102 | 1140 | aria-label={`Download ${file.name}`} |
| 1103 | onClick={() => ctx.onFileDownload(file)} | |
| 1141 | onClick={() => ctx.onFileOpen(file)} | |
| 1104 | 1142 | 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" |
| 1105 | 1143 | > |
| 1106 | 1144 | {content} |
apps/web/src/components/chat/composerAttachmentFiles.test.ts+44| ... | ... | @@ -2,12 +2,14 @@ import { EnvironmentId, PROVIDER_SEND_TURN_MAX_FILE_BYTES } from "@t3tools/contr |
| 2 | 2 | import { describe, expect, it } from "vite-plus/test"; |
| 3 | 3 | |
| 4 | 4 | import type { ComposerFileAttachment, ComposerImageAttachment } from "../../composerDraftStore"; |
| 5 | import { isVideoAttachment, videoMimeType } from "../../types"; | |
| 5 | 6 | import { |
| 6 | 7 | attachmentsToReleaseOnUploadCapabilityLoss, |
| 7 | 8 | classifyComposerAttachmentFile, |
| 8 | 9 | fileAttachmentCapabilityBlockReason, |
| 9 | 10 | fileAttachmentStagingLimit, |
| 10 | 11 | inferImageMimeTypeFromName, |
| 12 | isPreviewableComposerVideo, | |
| 11 | 13 | normalizeComposerImageFileMimeType, |
| 12 | 14 | shouldHandleComposerAttachmentPaste, |
| 13 | 15 | } from "./composerAttachmentFiles"; |
| ... | ... | @@ -267,6 +269,48 @@ describe("composer attachment files", () => { |
| 267 | 269 | expect(released.map((attachment) => attachment.id)).toEqual(["image-1", "file-uploading"]); |
| 268 | 270 | }); |
| 269 | 271 | |
| 272 | it("keeps restored videos on the preview path in their upload environment", () => { | |
| 273 | const environmentId = EnvironmentId.make("environment-1"); | |
| 274 | const video: ComposerFileAttachment = { | |
| 275 | type: "file", | |
| 276 | id: "video-1", | |
| 277 | name: "clip.mp4", | |
| 278 | mimeType: "video/mp4", | |
| 279 | sizeBytes: 3, | |
| 280 | file: null, | |
| 281 | uploadedAttachmentId: "uploaded-video-1", | |
| 282 | uploadEnvironmentId: environmentId, | |
| 283 | }; | |
| 284 | ||
| 285 | expect(isPreviewableComposerVideo(video, environmentId)).toBe(true); | |
| 286 | expect(isPreviewableComposerVideo(video, EnvironmentId.make("environment-2"))).toBe(false); | |
| 287 | }); | |
| 288 | ||
| 289 | it("recognizes common video extensions when the browser omits the MIME type", () => { | |
| 290 | const formats = [ | |
| 291 | ["clip.mp4", "video/mp4"], | |
| 292 | ["clip.mov", "video/quicktime"], | |
| 293 | ["clip.webm", "video/webm"], | |
| 294 | ["clip.m4v", "video/mp4"], | |
| 295 | ["clip.mkv", "video/x-matroska"], | |
| 296 | ["clip.avi", "video/x-msvideo"], | |
| 297 | ["clip.ogv", "video/ogg"], | |
| 298 | ] as const; | |
| 299 | ||
| 300 | for (const [name, expectedMimeType] of formats) { | |
| 301 | expect( | |
| 302 | isVideoAttachment({ | |
| 303 | type: "file", | |
| 304 | id: name, | |
| 305 | name, | |
| 306 | mimeType: "application/octet-stream", | |
| 307 | sizeBytes: 1, | |
| 308 | }), | |
| 309 | ).toBe(true); | |
| 310 | expect(videoMimeType({ name, mimeType: "application/octet-stream" })).toBe(expectedMimeType); | |
| 311 | } | |
| 312 | }); | |
| 313 | ||
| 270 | 314 | it("claims image pastes even when clipboard text is present", () => { |
| 271 | 315 | const image = new File(["image"], "photo.heic", { type: "image/heic" }); |
| 272 | 316 |
apps/web/src/components/chat/composerAttachmentFiles.ts+13| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import { |
| 2 | type EnvironmentId, | |
| 2 | 3 | isProviderSendTurnSupportedImageMimeType, |
| 3 | 4 | PROVIDER_SEND_TURN_MAX_FILE_BYTES, |
| 4 | 5 | } from "@t3tools/contracts"; |
| ... | ... | @@ -9,6 +10,7 @@ import { |
| 9 | 10 | |
| 10 | 11 | import type { ComposerFileAttachment, ComposerImageAttachment } from "../../composerDraftStore"; |
| 11 | 12 | import { isHeicImageFile } from "../../lib/imageCompression"; |
| 13 | import { isVideoAttachment } from "../../types"; | |
| 12 | 14 | |
| 13 | 15 | type ComposerAttachmentFileKind = "image" | "file" | "unsupported-image"; |
| 14 | 16 | |
| ... | ... | @@ -75,6 +77,17 @@ export function classifyComposerAttachmentFile( |
| 75 | 77 | return isProviderSendTurnSupportedImageMimeType(file.type) ? "image" : "unsupported-image"; |
| 76 | 78 | } |
| 77 | 79 | |
| 80 | export function isPreviewableComposerVideo( | |
| 81 | file: ComposerFileAttachment, | |
| 82 | environmentId: EnvironmentId, | |
| 83 | ): boolean { | |
| 84 | return ( | |
| 85 | isVideoAttachment(file) && | |
| 86 | (file.file !== null || | |
| 87 | (file.uploadedAttachmentId !== undefined && file.uploadEnvironmentId === environmentId)) | |
| 88 | ); | |
| 89 | } | |
| 90 | ||
| 78 | 91 | /** Byte limit for adding a generic file to the local composer draft. */ |
| 79 | 92 | export function fileAttachmentStagingLimit(input: FileAttachmentCapabilityState): number | null { |
| 80 | 93 | if (!input.attachmentUploadsCapabilityKnown) { |
apps/web/src/components/pullRequest/PullRequestMarkdown.tsx+4-6| ... | ... | @@ -10,12 +10,10 @@ import { splitPullRequestBody } from "./pullRequestMarkdown.logic"; |
| 10 | 10 | * A pull request body, rendered with the app's markdown renderer plus a card for each upload |
| 11 | 11 | * embedded in it, which that renderer drops on the floor. |
| 12 | 12 | * |
| 13 | * The card links out instead of playing in place, because nothing here can play. A | |
| 14 | * `github.com/user-attachments/assets/…` link is a 302 to a signed S3 URL that serves the file | |
| 15 | * as uploaded — `video/quicktime` for anything recorded on a Mac, which no Chromium decodes — | |
| 16 | * and the desktop window's content policy declares no `media-src`, so media falls back to | |
| 17 | * `default-src 'self'` and every remote source is refused before a byte is fetched. A player | |
| 18 | * here can only be the box that never fills in; a card that opens the host is a real answer. | |
| 13 | * The card links out instead of playing in place. GitHub serves the file as uploaded — Mac | |
| 14 | * recordings are `video/quicktime`, which no Chromium decodes — and the desktop window's | |
| 15 | * `media-src` allows only `'self'`, the app scheme and `blob:`, so a remote source is refused | |
| 16 | * before a byte is fetched. Opening the host works for every format. | |
| 19 | 17 | */ |
| 20 | 18 | export function PullRequestMarkdown({ |
| 21 | 19 | text, |
apps/web/src/composerDraftStore.test.ts+58| ... | ... | @@ -517,6 +517,34 @@ describe("composerDraftStore file attachments", () => { |
| 517 | 517 | expect(files?.some(composerFileNeedsReattach)).toBe(false); |
| 518 | 518 | }); |
| 519 | 519 | |
| 520 | it("replaces a legacy video marker after its MIME type is normalized", () => { | |
| 521 | const store = useComposerDraftStore.getState(); | |
| 522 | const marker: ComposerFileAttachment = { | |
| 523 | type: "file", | |
| 524 | id: "file-marker", | |
| 525 | name: "clip.mkv", | |
| 526 | mimeType: "application/octet-stream", | |
| 527 | sizeBytes: 6, | |
| 528 | file: null, | |
| 529 | }; | |
| 530 | store.addFiles(threadRef, [marker]); | |
| 531 | ||
| 532 | const file = new File(["report"], marker.name, { type: "video/x-matroska" }); | |
| 533 | const repicked: ComposerFileAttachment = { | |
| 534 | type: "file", | |
| 535 | id: "file-repicked", | |
| 536 | name: file.name, | |
| 537 | mimeType: file.type, | |
| 538 | sizeBytes: file.size, | |
| 539 | file, | |
| 540 | }; | |
| 541 | store.addFiles(threadRef, [repicked, { ...repicked, id: "file-repicked-duplicate" }]); | |
| 542 | ||
| 543 | const files = store.getComposerDraft(threadRef)?.files; | |
| 544 | expect(files?.map((entry) => entry.id)).toEqual(["file-repicked"]); | |
| 545 | expect(files?.some(composerFileNeedsReattach)).toBe(false); | |
| 546 | }); | |
| 547 | ||
| 520 | 548 | it("replaces a needs-reattach marker with a stash-restored uploaded file", () => { |
| 521 | 549 | const store = useComposerDraftStore.getState(); |
| 522 | 550 | const marker: ComposerFileAttachment = { ...makeFile("file-marker"), file: null }; |
| ... | ... | @@ -553,6 +581,36 @@ describe("composerDraftStore file attachments", () => { |
| 553 | 581 | ]); |
| 554 | 582 | }); |
| 555 | 583 | |
| 584 | it("keeps same-name videos with different MIME types", () => { | |
| 585 | const store = useComposerDraftStore.getState(); | |
| 586 | const mp4 = new File(["report"], "clip", { type: "video/mp4" }); | |
| 587 | const webm = new File(["report"], "clip", { type: "video/webm" }); | |
| 588 | ||
| 589 | store.addFiles(threadRef, [ | |
| 590 | { | |
| 591 | type: "file", | |
| 592 | id: "video-mp4", | |
| 593 | name: mp4.name, | |
| 594 | mimeType: mp4.type, | |
| 595 | sizeBytes: mp4.size, | |
| 596 | file: mp4, | |
| 597 | }, | |
| 598 | { | |
| 599 | type: "file", | |
| 600 | id: "video-webm", | |
| 601 | name: webm.name, | |
| 602 | mimeType: webm.type, | |
| 603 | sizeBytes: webm.size, | |
| 604 | file: webm, | |
| 605 | }, | |
| 606 | ]); | |
| 607 | ||
| 608 | expect(store.getComposerDraft(threadRef)?.files.map((file) => file.id)).toEqual([ | |
| 609 | "video-mp4", | |
| 610 | "video-webm", | |
| 611 | ]); | |
| 612 | }); | |
| 613 | ||
| 556 | 614 | it("keeps the remaining file slot available after a duplicate is skipped", () => { |
| 557 | 615 | const store = useComposerDraftStore.getState(); |
| 558 | 616 | store.addImages( |
apps/web/src/composerDraftStore.ts+36-18| ... | ... | @@ -39,6 +39,7 @@ import { |
| 39 | 39 | DEFAULT_RUNTIME_MODE, |
| 40 | 40 | type ChatFileAttachment, |
| 41 | 41 | type ChatImageAttachment, |
| 42 | videoMimeType, | |
| 42 | 43 | } from "./types"; |
| 43 | 44 | import { |
| 44 | 45 | type TerminalContextDraft, |
| ... | ... | @@ -745,6 +746,26 @@ function composerImageDedupKey(image: ComposerImageAttachment): string { |
| 745 | 746 | return `${image.mimeType}\u0000${image.sizeBytes}\u0000${image.name}`; |
| 746 | 747 | } |
| 747 | 748 | |
| 749 | export function composerFileDedupKey( | |
| 750 | file: Pick<ComposerFileAttachment, "mimeType" | "sizeBytes" | "name">, | |
| 751 | ): string { | |
| 752 | return `${file.mimeType}\u0000${file.sizeBytes}\u0000${file.name}`; | |
| 753 | } | |
| 754 | ||
| 755 | export function composerFileMatchesReattachMarker( | |
| 756 | marker: Pick<ComposerFileAttachment, "mimeType" | "sizeBytes" | "name">, | |
| 757 | file: Pick<ComposerFileAttachment, "mimeType" | "sizeBytes" | "name">, | |
| 758 | ): boolean { | |
| 759 | if (marker.name !== file.name || marker.sizeBytes !== file.sizeBytes) return false; | |
| 760 | if (marker.mimeType === file.mimeType) return true; | |
| 761 | const markerMimeType = marker.mimeType.toLowerCase(); | |
| 762 | return ( | |
| 763 | (markerMimeType === "" || markerMimeType === "application/octet-stream") && | |
| 764 | videoMimeType(marker) !== null && | |
| 765 | videoMimeType(file) !== null | |
| 766 | ); | |
| 767 | } | |
| 768 | ||
| 748 | 769 | function terminalContextDedupKey(context: TerminalContextDraft): string { |
| 749 | 770 | return `${context.terminalId}\u0000${context.lineStart}\u0000${context.lineEnd}`; |
| 750 | 771 | } |
| ... | ... | @@ -3189,29 +3210,32 @@ const composerDraftStore = create<ComposerDraftStoreState>()( |
| 3189 | 3210 | const existing = state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft(); |
| 3190 | 3211 | const knownIds = new Set(existing.files.map((file) => file.id)); |
| 3191 | 3212 | const knownFiles = new Map<string, ComposerFileAttachment>( |
| 3192 | existing.files.map((file) => [ | |
| 3193 | `${file.mimeType}\u0000${file.sizeBytes}\u0000${file.name}`, | |
| 3194 | file, | |
| 3195 | ]), | |
| 3213 | existing.files.map((file) => [composerFileDedupKey(file), file]), | |
| 3196 | 3214 | ); |
| 3197 | 3215 | const accepted: ComposerFileAttachment[] = []; |
| 3198 | 3216 | // Needs-reattach markers replaced in place by a re-pick, keyed by |
| 3199 | 3217 | // the marker's id. |
| 3200 | 3218 | const replacements = new Map<string, ComposerFileAttachment>(); |
| 3201 | 3219 | for (const file of files) { |
| 3202 | const key = `${file.mimeType}\u0000${file.sizeBytes}\u0000${file.name}`; | |
| 3220 | const key = composerFileDedupKey(file); | |
| 3203 | 3221 | if (knownIds.has(file.id)) { |
| 3204 | 3222 | continue; |
| 3205 | 3223 | } |
| 3206 | const duplicate = knownFiles.get(key); | |
| 3224 | const duplicate = | |
| 3225 | knownFiles.get(key) ?? | |
| 3226 | existing.files.find( | |
| 3227 | (candidate) => | |
| 3228 | composerFileNeedsReattach(candidate) && | |
| 3229 | !replacements.has(candidate.id) && | |
| 3230 | composerFileMatchesReattachMarker(candidate, file), | |
| 3231 | ); | |
| 3207 | 3232 | if (duplicate) { |
| 3208 | // A needs-reattach marker persists exactly the metadata this | |
| 3209 | // key hashes, so re-picking the same file matches its marker. | |
| 3210 | // Dropping the pick as a duplicate would leave the draft | |
| 3211 | // blocked forever; replace the marker so the upload restarts. | |
| 3233 | // A needs-reattach marker is not a usable duplicate. Replace | |
| 3234 | // it so the upload restarts. | |
| 3212 | 3235 | if (composerFileNeedsReattach(duplicate) && !replacements.has(duplicate.id)) { |
| 3213 | 3236 | replacements.set(duplicate.id, file); |
| 3214 | 3237 | knownIds.add(file.id); |
| 3238 | knownFiles.set(key, file); | |
| 3215 | 3239 | } |
| 3216 | 3240 | continue; |
| 3217 | 3241 | } |
| ... | ... | @@ -3802,18 +3826,12 @@ const composerDraftStore = create<ComposerDraftStoreState>()( |
| 3802 | 3826 | // A file the destination already holds (same id, or same |
| 3803 | 3827 | // metadata key) stays behind instead of duplicating there. |
| 3804 | 3828 | const destinationFileIds = new Set(destination.files.map((file) => file.id)); |
| 3805 | const destinationFileKeys = new Set( | |
| 3806 | destination.files.map( | |
| 3807 | (file) => `${file.mimeType}\u0000${file.sizeBytes}\u0000${file.name}`, | |
| 3808 | ), | |
| 3809 | ); | |
| 3829 | const destinationFileKeys = new Set(destination.files.map(composerFileDedupKey)); | |
| 3810 | 3830 | const transferableFiles = source.files.filter( |
| 3811 | 3831 | (file) => |
| 3812 | 3832 | (file.file !== null || file.uploadEnvironmentId === destinationEnvironmentId) && |
| 3813 | 3833 | !destinationFileIds.has(file.id) && |
| 3814 | !destinationFileKeys.has( | |
| 3815 | `${file.mimeType}\u0000${file.sizeBytes}\u0000${file.name}`, | |
| 3816 | ), | |
| 3834 | !destinationFileKeys.has(composerFileDedupKey(file)), | |
| 3817 | 3835 | ); |
| 3818 | 3836 | const remainingAttachmentSlots = Math.max( |
| 3819 | 3837 | 0, |
apps/web/src/types.ts+25| ... | ... | @@ -59,6 +59,31 @@ export function isFileAttachment(attachment: ChatAttachment): attachment is Chat |
| 59 | 59 | return attachment.type === "file"; |
| 60 | 60 | } |
| 61 | 61 | |
| 62 | const VIDEO_MIME_TYPE_BY_EXTENSION: Readonly<Record<string, string>> = { | |
| 63 | avi: "video/x-msvideo", | |
| 64 | m4v: "video/mp4", | |
| 65 | mkv: "video/x-matroska", | |
| 66 | mov: "video/quicktime", | |
| 67 | mp4: "video/mp4", | |
| 68 | ogv: "video/ogg", | |
| 69 | webm: "video/webm", | |
| 70 | }; | |
| 71 | ||
| 72 | export function videoMimeType( | |
| 73 | attachment: Pick<ChatFileAttachment, "name" | "mimeType">, | |
| 74 | ): string | null { | |
| 75 | const mimeType = attachment.mimeType.split(";", 1)[0]?.trim().toLowerCase() ?? ""; | |
| 76 | if (mimeType.startsWith("video/")) return mimeType; | |
| 77 | const dotIndex = attachment.name.lastIndexOf("."); | |
| 78 | return dotIndex < 0 | |
| 79 | ? null | |
| 80 | : (VIDEO_MIME_TYPE_BY_EXTENSION[attachment.name.slice(dotIndex + 1).toLowerCase()] ?? null); | |
| 81 | } | |
| 82 | ||
| 83 | export function isVideoAttachment(attachment: ChatFileAttachment): boolean { | |
| 84 | return videoMimeType(attachment) !== null; | |
| 85 | } | |
| 86 | ||
| 62 | 87 | export interface ChatMessage extends Omit<OrchestrationMessage, "attachments"> { |
| 63 | 88 | readonly attachments?: ReadonlyArray<ChatAttachment> | undefined; |
| 64 | 89 | } |
docs/user/composer.md+3| ... | ... | @@ -13,6 +13,9 @@ On web and desktop, attachments upload as soon as you add them. The send button |
| 13 | 13 | after every upload finishes. Failed uploads can be retried or removed. On mobile, attachments are |
| 14 | 14 | currently limited to images. |
| 15 | 15 | |
| 16 | Select a video attachment before or after sending to play it with the browser's built-in controls. | |
| 17 | Playback depends on the video formats and codecs that the browser supports. | |
| 18 | ||
| 16 | 19 | If you reload before a file finishes uploading, the draft keeps the file's name and shows **Attach |
| 17 | 20 | again** next to it. Attach the file again or remove it, then send. |
| 18 | 21 |