authorgravatar for 62337003+Bil0000@users.noreply.github.comBilal Bakr <62337003+Bil0000@users.noreply.github.com> 2026-08-30 22:58:35+03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-30 12:58:35-07:00
logac4aae101d060b5355a5de58e23c7387886d9021
tree57674b33c41cf7a1bc4f319e6329ca90e1d23c84
parentf15680bd3c08a154bacb9b4d163cc8608a310970
signature Signed by PGP key B5690EEEBB952194

feat(web): play video attachments in chat (#8688)


23 files changed, 850 insertions(+), 120 deletions(-)

apps/desktop/src/electron/ElectronProtocol.test.ts+1
...@@ -225,6 +225,7 @@ describe("ElectronProtocol", () => {...@@ -225,6 +225,7 @@ describe("ElectronProtocol", () => {
225 "http:",225 "http:",
226 "https:",226 "https:",
227 ]);227 ]);
228 assert.deepEqual(directives["media-src"], ["'self'", "t3code:", "blob:"]);
228 assert.deepEqual(directives["font-src"], ["'self'", "t3code:", "data:"]);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,6 +87,7 @@ export function makeDesktopContentSecurityPolicy(input: DesktopProtocolRegistrat
87 `script-src ${scriptSources.join(" ")}`,87 `script-src ${scriptSources.join(" ")}`,
88 `connect-src ${connectSources.join(" ")}`,88 `connect-src ${connectSources.join(" ")}`,
89 `img-src 'self' ${input.scheme}: blob: data: http: https:`,89 `img-src 'self' ${input.scheme}: blob: data: http: https:`,
90 `media-src 'self' ${input.scheme}: blob:`,
90 "style-src 'self' 'unsafe-inline'",91 "style-src 'self' 'unsafe-inline'",
91 `font-src 'self' ${input.scheme}: data:`,92 `font-src 'self' ${input.scheme}: data:`,
92 "worker-src 'self' blob:",93 "worker-src 'self' blob:",
apps/server/src/assets/AssetAccess.test.ts+31
...@@ -208,6 +208,37 @@ describe("AssetAccess", () => {...@@ -208,6 +208,37 @@ describe("AssetAccess", () => {
208 }).pipe(Effect.provide(testLayer)),208 }).pipe(Effect.provide(testLayer)),
209 );209 );
210210
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 it.effect("issues project favicon capabilities with a signed fallback", () =>242 it.effect("issues project favicon capabilities with a signed fallback", () =>
212 Effect.gen(function* () {243 Effect.gen(function* () {
213 const fileSystem = yield* FileSystem.FileSystem;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,6 +48,7 @@ const SIGNING_SECRET_NAME = "asset-access-signing-key";
48const ASSET_TOKEN_TTL_MS = 60 * 60 * 1000;48const ASSET_TOKEN_TTL_MS = 60 * 60 * 1000;
49const PROJECT_FAVICON_TOKEN_BUCKET_MS = 30 * 60 * 1000;49const PROJECT_FAVICON_TOKEN_BUCKET_MS = 30 * 60 * 1000;
50const PROJECT_FAVICON_VERSION_PREFIX = "v";50const PROJECT_FAVICON_VERSION_PREFIX = "v";
51const INLINE_VIDEO_MIME_TYPE_PATTERN = /^video\/[\w!#$&^.+-]+$/i;
51const PREVIEW_ASSET_EXTENSIONS = new Set([52const PREVIEW_ASSET_EXTENSIONS = new Set([
52 ...WORKSPACE_BROWSER_PREVIEW_EXTENSIONS,53 ...WORKSPACE_BROWSER_PREVIEW_EXTENSIONS,
53 ...WORKSPACE_IMAGE_PREVIEW_EXTENSIONS,54 ...WORKSPACE_IMAGE_PREVIEW_EXTENSIONS,
...@@ -300,16 +301,20 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i...@@ -300,16 +301,20 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
300 });301 });
301 }302 }
302 // Generic files carry their extension inside the attachment id (that303 // Generic files carry their extension inside the attachment id (that
303 // shape resolves the on-disk path); images do not. Only generic files304 // shape resolves the on-disk path); images do not. Videos and images
304 // download, images render inline in chat.305 // render inline; other generic files download.
305 const isGenericFile = parseAttachmentFileExtension(input.resource.attachmentId) !== null;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 claims = {309 claims = {
307 version: 1,310 version: 1,
308 kind: "attachment",311 kind: "attachment",
309 attachmentId: input.resource.attachmentId,312 attachmentId: input.resource.attachmentId,
310 ...(isGenericFile ? { download: true } : {}),313 ...(isGenericFile && !isVideo ? { download: true } : {}),
311 ...(input.resource.fileName !== undefined ? { fileName: input.resource.fileName } : {}),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 expiresAt,318 expiresAt,
314 };319 };
315 fileName = input.resource.fileName ?? path.basename(attachmentPath);320 fileName = input.resource.fileName ?? path.basename(attachmentPath);
apps/server/src/http.test.ts+11
...@@ -50,6 +50,17 @@ describe("assetResponseHeaders", () => {...@@ -50,6 +50,17 @@ describe("assetResponseHeaders", () => {
50 });50 });
51 });51 });
5252
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 it("declares utf-8 for HTML assets so non-ASCII content renders correctly", () => {64 it("declares utf-8 for HTML assets so non-ASCII content renders correctly", () => {
54 expect(assetResponseHeaders("/workspace/page.html")).toHaveProperty(65 expect(assetResponseHeaders("/workspace/page.html")).toHaveProperty(
55 "Content-Type",66 "Content-Type",
apps/server/src/http.ts+10-5
...@@ -57,6 +57,8 @@ const DOWNLOAD_MIME_TYPE_PATTERN = /^[\w!#$&^.+-]+\/[\w!#$&^.+-]+$/;...@@ -57,6 +57,8 @@ const DOWNLOAD_MIME_TYPE_PATTERN = /^[\w!#$&^.+-]+\/[\w!#$&^.+-]+$/;
57const isSafeDownloadMimeType = (mimeType: string): boolean =>57const isSafeDownloadMimeType = (mimeType: string): boolean =>
58 DOWNLOAD_MIME_TYPE_PATTERN.test(mimeType) &&58 DOWNLOAD_MIME_TYPE_PATTERN.test(mimeType) &&
59 !/(?:^text\/html$|\/xml(?:$|-)|\+xml$)/i.test(mimeType.trim().toLowerCase());59 !/(?:^text\/html$|\/xml(?:$|-)|\+xml$)/i.test(mimeType.trim().toLowerCase());
60const isSafeInlineVideoMimeType = (mimeType: string): boolean =>
61 DOWNLOAD_MIME_TYPE_PATTERN.test(mimeType) && mimeType.toLowerCase().startsWith("video/");
6062
61/** RFC 6266 disposition with an ASCII fallback name plus a UTF-8 `filename*`. */63/** RFC 6266 disposition with an ASCII fallback name plus a UTF-8 `filename*`. */
62export function downloadContentDisposition(fileName?: string): string {64export function downloadContentDisposition(fileName?: string): string {
...@@ -86,6 +88,7 @@ export function assetResponseHeaders(...@@ -86,6 +88,7 @@ export function assetResponseHeaders(
86 },88 },
87): Record<string, string> {89): Record<string, string> {
88 const lowerPath = filePath.toLowerCase();90 const lowerPath = filePath.toLowerCase();
91 const inlineVideoMimeType = options?.mimeType?.split(";", 1)[0]?.trim();
89 return {92 return {
90 "Cache-Control": "private, max-age=3600",93 "Cache-Control": "private, max-age=3600",
91 "X-Content-Type-Options": "nosniff",94 "X-Content-Type-Options": "nosniff",
...@@ -98,9 +101,11 @@ export function assetResponseHeaders(...@@ -98,9 +101,11 @@ export function assetResponseHeaders(
98 ? options.mimeType101 ? options.mimeType
99 : "application/octet-stream",102 : "application/octet-stream",
100 }103 }
101 : lowerPath.endsWith(".html") || lowerPath.endsWith(".htm")104 : inlineVideoMimeType !== undefined && isSafeInlineVideoMimeType(inlineVideoMimeType)
102 ? { "Content-Type": "text/html; charset=utf-8" }105 ? { "Content-Type": inlineVideoMimeType }
103 : {}),106 : lowerPath.endsWith(".html") || lowerPath.endsWith(".htm")
107 ? { "Content-Type": "text/html; charset=utf-8" }
108 : {}),
104 ...(!options?.download && lowerPath.endsWith(".svg")109 ...(!options?.download && lowerPath.endsWith(".svg")
105 ? { "Content-Security-Policy": SVG_CONTENT_SECURITY_POLICY }110 ? { "Content-Security-Policy": SVG_CONTENT_SECURITY_POLICY }
106 : {}),111 : {}),
...@@ -276,9 +281,9 @@ export const assetRouteLayer = HttpRouter.add(...@@ -276,9 +281,9 @@ export const assetRouteLayer = HttpRouter.add(
276 status: 200,281 status: 200,
277 headers: assetResponseHeaders(282 headers: assetResponseHeaders(
278 asset.path,283 asset.path,
279 asset.download284 asset.download || asset.mimeType !== undefined
280 ? {285 ? {
281 download: true,286 ...(asset.download ? { download: true } : {}),
282 ...(asset.fileName !== undefined ? { fileName: asset.fileName } : {}),287 ...(asset.fileName !== undefined ? { fileName: asset.fileName } : {}),
283 ...(asset.mimeType !== undefined ? { mimeType: asset.mimeType } : {}),288 ...(asset.mimeType !== undefined ? { mimeType: asset.mimeType } : {}),
284 }289 }
apps/web/src/components/ChatView.logic.test.ts+27
...@@ -21,6 +21,8 @@ import {...@@ -21,6 +21,8 @@ import {
21 dismissBranchMismatchForSession,21 dismissBranchMismatchForSession,
22 ENVIRONMENT_RECONNECT_WARNING_GRACE_MS,22 ENVIRONMENT_RECONNECT_WARNING_GRACE_MS,
23 getStartedThreadModelChangeBlockReason,23 getStartedThreadModelChangeBlockReason,
24 loadVideoPreviewUrl,
25 isVideoPreviewRequestCurrent,
24 hasEnvironmentReconnectWarningGraceElapsed,26 hasEnvironmentReconnectWarningGraceElapsed,
25 hasServerAcknowledgedLocalDispatch,27 hasServerAcknowledgedLocalDispatch,
26 isBranchMismatchDismissedForSession,28 isBranchMismatchDismissedForSession,
...@@ -41,6 +43,31 @@ import {...@@ -41,6 +43,31 @@ import {
41 shouldWriteThreadErrorToCurrentServerThread,43 shouldWriteThreadErrorToCurrentServerThread,
42} from "./ChatView.logic";44} from "./ChatView.logic";
4345
46describe("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
63describe("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
44const environmentId = EnvironmentId.make("environment-local");71const environmentId = EnvironmentId.make("environment-local");
45const projectId = ProjectId.make("project-1");72const projectId = ProjectId.make("project-1");
46const threadId = ThreadId.make("thread-1");73const 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,6 +290,21 @@ export function revokeBlobPreviewUrl(previewUrl: string | undefined): void {
290 URL.revokeObjectURL(previewUrl);290 URL.revokeObjectURL(previewUrl);
291}291}
292292
293export 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
299export function isVideoPreviewRequestCurrent(
300 requestThreadKey: string,
301 currentThreadKey: string,
302 requestId: number,
303 currentRequestId: number,
304): boolean {
305 return requestThreadKey === currentThreadKey && requestId === currentRequestId;
306}
307
293export function revokeUserMessagePreviewUrls(message: ChatMessage): void {308export function revokeUserMessagePreviewUrls(message: ChatMessage): void {
294 if (message.role !== "user" || !message.attachments) {309 if (message.role !== "user" || !message.attachments) {
295 return;310 return;
apps/web/src/components/ChatView.tsx+95-14
...@@ -129,6 +129,7 @@ import {...@@ -129,6 +129,7 @@ import {
129 MAX_TERMINALS_PER_GROUP,129 MAX_TERMINALS_PER_GROUP,
130 type ChatMessage,130 type ChatMessage,
131 isImageAttachment,131 isImageAttachment,
132 videoMimeType,
132 type SessionPhase,133 type SessionPhase,
133 type Thread,134 type Thread,
134 type TurnDiffSummary,135 type TurnDiffSummary,
...@@ -360,6 +361,8 @@ import {...@@ -360,6 +361,8 @@ import {
360 cloneComposerImageForRetry,361 cloneComposerImageForRetry,
361 deriveLockedProvider,362 deriveLockedProvider,
362 readFileAsDataUrl,363 readFileAsDataUrl,
364 loadVideoPreviewUrl,
365 isVideoPreviewRequestCurrent,
363 reconcileMountedTerminalThreadIds,366 reconcileMountedTerminalThreadIds,
364 resolveBackgroundDraftWorkspaceOptions,367 resolveBackgroundDraftWorkspaceOptions,
365 resolveDraftHeroState,368 resolveDraftHeroState,
...@@ -1438,8 +1441,23 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1438,8 +1441,23 @@ function ChatViewContent(props: ChatViewProps) {
1438 const localComposerRef = useRef<ChatComposerHandle | null>(null);1441 const localComposerRef = useRef<ChatComposerHandle | null>(null);
1439 const composerRef = useComposerHandleContext() ?? localComposerRef;1442 const composerRef = useComposerHandleContext() ?? localComposerRef;
1440 const [isWorkspaceFileDragActive, setIsWorkspaceFileDragActive] = useState(false);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 const [showScrollToBottom, setShowScrollToBottom] = useState(false);1454 const [showScrollToBottom, setShowScrollToBottom] = useState(false);
1442 const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);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 const [optimisticUserMessages, setOptimisticUserMessages] = useState<ChatMessage[]>([]);1461 const [optimisticUserMessages, setOptimisticUserMessages] = useState<ChatMessage[]>([]);
1444 const [feedbackSubmissionsByThreadKey, setFeedbackSubmissionsByThreadKey] = useState<1462 const [feedbackSubmissionsByThreadKey, setFeedbackSubmissionsByThreadKey] = useState<
1445 Record<string, ReadonlyArray<CodexFeedbackSubmission>>1463 Record<string, ReadonlyArray<CodexFeedbackSubmission>>
...@@ -2479,11 +2497,12 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2479,11 +2497,12 @@ function ChatViewContent(props: ChatViewProps) {
2479 useEffect(() => {2497 useEffect(() => {
2480 return () => {2498 return () => {
2481 clearAttachmentPreviewHandoffs();2499 clearAttachmentPreviewHandoffs();
2500 cancelVideoPreviewRequest();
2482 for (const message of optimisticUserMessagesRef.current) {2501 for (const message of optimisticUserMessagesRef.current) {
2483 revokeUserMessagePreviewUrls(message);2502 revokeUserMessagePreviewUrls(message);
2484 }2503 }
2485 };2504 };
2486 }, [clearAttachmentPreviewHandoffs]);2505 }, [cancelVideoPreviewRequest, clearAttachmentPreviewHandoffs]);
2487 const handoffAttachmentPreviews = useCallback((messageId: MessageId, previewUrls: string[]) => {2506 const handoffAttachmentPreviews = useCallback((messageId: MessageId, previewUrls: string[]) => {
2488 if (previewUrls.length === 0) return;2507 if (previewUrls.length === 0) return;
24892508
...@@ -2504,17 +2523,40 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2504,17 +2523,40 @@ function ChatViewContent(props: ChatViewProps) {
2504 });2523 });
2505 }, []);2524 }, []);
2506 const serverMessages = activeThread?.messages;2525 const serverMessages = activeThread?.messages;
2507 const downloadFileAttachment = useCallback(2526 const openFileAttachment = useCallback(
2508 async (attachment: ChatFileAttachment) => {2527 async (attachment: ChatFileAttachment) => {
2509 const connection = readPreparedConnection(environmentId);2528 const connection = readPreparedConnection(environmentId);
2510 if (!connection) {2529 if (!connection) {
2511 toastManager.add({ type: "error", title: "The environment is not connected." });2530 toastManager.add({ type: "error", title: "The environment is not connected." });
2512 return;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);
25142557
2515 // fileName and mimeType ride in the signed claims so the download gets2558 // fileName and mimeType ride in the signed claims so videos render
2516 // a real filename and Content-Type even when the anchor's `download`2559 // inline while other files keep their real download name and type.
2517 // attribute is ignored (cross-origin environment servers).
2518 const result = await createAttachmentAssetUrl({2560 const result = await createAttachmentAssetUrl({
2519 environmentId,2561 environmentId,
2520 input: {2562 input: {
...@@ -2522,15 +2564,20 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2522,15 +2564,20 @@ function ChatViewContent(props: ChatViewProps) {
2522 _tag: "attachment",2564 _tag: "attachment",
2523 attachmentId: attachment.id,2565 attachmentId: attachment.id,
2524 fileName: attachment.name,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 if (result._tag === "Failure") {2575 if (result._tag === "Failure") {
2576 finishVideoPreviewRequest();
2530 const error = squashAtomCommandFailure(result);2577 const error = squashAtomCommandFailure(result);
2531 toastManager.add({2578 toastManager.add({
2532 type: "error",2579 type: "error",
2533 title: `Could not download ${attachment.name}`,2580 title: "Could not " + action + " " + attachment.name,
2534 description: error instanceof Error ? error.message : "The attachment is unavailable.",2581 description: error instanceof Error ? error.message : "The attachment is unavailable.",
2535 });2582 });
2536 return;2583 return;
...@@ -2538,7 +2585,31 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2538,7 +2585,31 @@ function ChatViewContent(props: ChatViewProps) {
25382585
2539 const url = resolveAssetUrl(connection.httpBaseUrl, result.value.relativeUrl);2586 const url = resolveAssetUrl(connection.httpBaseUrl, result.value.relativeUrl);
2540 if (!url) {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 return;2613 return;
2543 }2614 }
2544 const anchor = document.createElement("a");2615 const anchor = document.createElement("a");
...@@ -2546,7 +2617,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2546,7 +2617,7 @@ function ChatViewContent(props: ChatViewProps) {
2546 anchor.download = attachment.name;2617 anchor.download = attachment.name;
2547 anchor.click();2618 anchor.click();
2548 },2619 },
2549 [createAttachmentAssetUrl, environmentId],2620 [createAttachmentAssetUrl, environmentId, routeThreadKey],
2550 );2621 );
2551 const serverAttachmentIds = useMemo(() => {2622 const serverAttachmentIds = useMemo(() => {
2552 const attachmentIds = new Set<string>();2623 const attachmentIds = new Set<string>();
...@@ -4389,8 +4460,10 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4389,8 +4460,10 @@ function ChatViewContent(props: ChatViewProps) {
4389 return [];4460 return [];
4390 });4461 });
4391 resetLocalDispatch();4462 resetLocalDispatch();
4463 cancelVideoPreviewRequest();
4464 setOpeningVideoAttachmentId(null);
4392 setExpandedImage(null);4465 setExpandedImage(null);
4393 }, [draftId, resetLocalDispatch, threadId]);4466 }, [cancelVideoPreviewRequest, draftId, resetLocalDispatch, threadId]);
43944467
4395 const closeExpandedImage = useCallback(() => {4468 const closeExpandedImage = useCallback(() => {
4396 setExpandedImage(null);4469 setExpandedImage(null);
...@@ -6843,9 +6916,14 @@ function ChatViewContent(props: ChatViewProps) {...@@ -6843,9 +6916,14 @@ function ChatViewContent(props: ChatViewProps) {
6843 }6916 }
6844 };6917 };
68456918
6846 const onExpandTimelineImage = useCallback((preview: ExpandedImagePreview) => {6919 const onExpandTimelineImage = useCallback(
6847 setExpandedImage(preview);6920 (preview: ExpandedImagePreview) => {
6848 }, []);6921 cancelVideoPreviewRequest();
6922 setOpeningVideoAttachmentId(null);
6923 setExpandedImage(preview);
6924 },
6925 [cancelVideoPreviewRequest],
6926 );
6849 const onOpenTurnDiff = useCallback(6927 const onOpenTurnDiff = useCallback(
6850 (turnId: TurnId, filePath?: string) => {6928 (turnId: TurnId, filePath?: string) => {
6851 if (!isServerThread || !activeThreadRef) return;6929 if (!isServerThread || !activeThreadRef) return;
...@@ -7139,7 +7217,8 @@ function ChatViewContent(props: ChatViewProps) {...@@ -7139,7 +7217,8 @@ function ChatViewContent(props: ChatViewProps) {
7139 onRevertUserMessage={onRevertUserMessage}7217 onRevertUserMessage={onRevertUserMessage}
7140 isRevertingCheckpoint={isRevertingCheckpoint}7218 isRevertingCheckpoint={isRevertingCheckpoint}
7141 onImageExpand={onExpandTimelineImage}7219 onImageExpand={onExpandTimelineImage}
7142 onFileDownload={downloadFileAttachment}7220 onFileOpen={openFileAttachment}
7221 openingVideoAttachmentId={openingVideoAttachmentId}
7143 markdownCwd={gitCwd ?? undefined}7222 markdownCwd={gitCwd ?? undefined}
7144 resolvedTheme={resolvedTheme}7223 resolvedTheme={resolvedTheme}
7145 timestampFormat={timestampFormat}7224 timestampFormat={timestampFormat}
...@@ -7320,6 +7399,8 @@ function ChatViewContent(props: ChatViewProps) {...@@ -7320,6 +7399,8 @@ function ChatViewContent(props: ChatViewProps) {
7320 scheduleComposerFocus={scheduleComposerFocus}7399 scheduleComposerFocus={scheduleComposerFocus}
7321 setThreadError={setThreadError}7400 setThreadError={setThreadError}
7322 onExpandImage={onExpandTimelineImage}7401 onExpandImage={onExpandTimelineImage}
7402 onFileOpen={openFileAttachment}
7403 openingVideoAttachmentId={openingVideoAttachmentId}
7323 />7404 />
7324 </div>7405 </div>
7325 </div>7406 </div>
apps/web/src/components/chat/ChatComposer.tsx+191-50
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import type {1import type {
2 ApprovalRequestId,2 ApprovalRequestId,
3 ChatFileAttachment,
3 EnvironmentId,4 EnvironmentId,
4 ModelSelection,5 ModelSelection,
5 PreviewAnnotationPayload,6 PreviewAnnotationPayload,
...@@ -55,6 +56,8 @@ import {...@@ -55,6 +56,8 @@ import {
55 type DraftId,56 type DraftId,
56 type PersistedComposerFileAttachment,57 type PersistedComposerFileAttachment,
57 type PersistedComposerImageAttachment,58 type PersistedComposerImageAttachment,
59 composerFileDedupKey,
60 composerFileMatchesReattachMarker,
58 composerFileNeedsReattach,61 composerFileNeedsReattach,
59 composerTargetKey,62 composerTargetKey,
60 hydrateImagesFromPersisted,63 hydrateImagesFromPersisted,
...@@ -86,6 +89,7 @@ import {...@@ -86,6 +89,7 @@ import {
86 classifyComposerAttachmentFile,89 classifyComposerAttachmentFile,
87 fileAttachmentCapabilityBlockReason,90 fileAttachmentCapabilityBlockReason,
88 fileAttachmentStagingLimit,91 fileAttachmentStagingLimit,
92 isPreviewableComposerVideo,
89 normalizeComposerImageFileMimeType,93 normalizeComposerImageFileMimeType,
90 shouldHandleComposerAttachmentPaste,94 shouldHandleComposerAttachmentPaste,
91} from "./composerAttachmentFiles";95} from "./composerAttachmentFiles";
...@@ -142,7 +146,11 @@ import {...@@ -142,7 +146,11 @@ import {
142} from "./composerProviderState";146} from "./composerProviderState";
143import { ContextWindowMeter } from "./ContextWindowMeter";147import { ContextWindowMeter } from "./ContextWindowMeter";
144import { resolveContextWindowModelDisplayName } from "./ContextWindowMeter.logic";148import { resolveContextWindowModelDisplayName } from "./ContextWindowMeter.logic";
145import { buildExpandedImagePreview, type ExpandedImagePreview } from "./ExpandedImagePreview";149import {
150 attachVideoThumbnail,
151 buildExpandedImagePreview,
152 type ExpandedImagePreview,
153} from "./ExpandedImagePreview";
146import { basenameOfPath } from "../../pierre-icons";154import { basenameOfPath } from "../../pierre-icons";
147import { cn, randomUUID } from "~/lib/utils";155import { cn, randomUUID } from "~/lib/utils";
148import { Separator } from "../ui/separator";156import { Separator } from "../ui/separator";
...@@ -153,6 +161,30 @@ import {...@@ -153,6 +161,30 @@ import {
153} from "./composerSubmission";161} from "./composerSubmission";
154import { ComposerPromptLengthValidation } from "./ComposerPromptLengthValidation";162import { ComposerPromptLengthValidation } from "./ComposerPromptLengthValidation";
155163
164function 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
156type ComposerCommandMenuPosition = {188type ComposerCommandMenuPosition = {
157 bottom: number;189 bottom: number;
158 left: number;190 left: number;
...@@ -259,6 +291,7 @@ import {...@@ -259,6 +291,7 @@ import {
259 FileIcon,291 FileIcon,
260 PaperclipIcon,292 PaperclipIcon,
261 PencilRulerIcon,293 PencilRulerIcon,
294 PlayIcon,
262 type LucideIcon,295 type LucideIcon,
263 LockIcon,296 LockIcon,
264 LockOpenIcon,297 LockOpenIcon,
...@@ -280,7 +313,7 @@ import {...@@ -280,7 +313,7 @@ import {
280} from "../../providerInstances";313} from "../../providerInstances";
281import { type AppModelOption, getAppModelOptionsForInstance } from "../../modelSelection";314import { type AppModelOption, getAppModelOptionsForInstance } from "../../modelSelection";
282import type { UnifiedSettings } from "@t3tools/contracts/settings";315import type { UnifiedSettings } from "@t3tools/contracts/settings";
283import type { SessionPhase, Thread } from "../../types";316import { type SessionPhase, type Thread, videoMimeType } from "../../types";
284import type { PendingUserInputDraftAnswer } from "../../pendingUserInput";317import type { PendingUserInputDraftAnswer } from "../../pendingUserInput";
285import type { PendingApproval, PendingUserInput } from "../../session-logic";318import type { PendingApproval, PendingUserInput } from "../../session-logic";
286import type { ContextWindowSnapshot } from "../../lib/contextWindow";319import type { ContextWindowSnapshot } from "../../lib/contextWindow";
...@@ -683,6 +716,8 @@ export interface ChatComposerProps {...@@ -683,6 +716,8 @@ export interface ChatComposerProps {
683 scheduleComposerFocus: () => void;716 scheduleComposerFocus: () => void;
684 setThreadError: (threadId: ThreadId | null, error: string | null) => void;717 setThreadError: (threadId: ThreadId | null, error: string | null) => void;
685 onExpandImage: (preview: ExpandedImagePreview) => void;718 onExpandImage: (preview: ExpandedImagePreview) => void;
719 onFileOpen: (attachment: ChatFileAttachment) => void;
720 openingVideoAttachmentId: string | null;
686}721}
687722
688// --------------------------------------------------------------------------723// --------------------------------------------------------------------------
...@@ -762,6 +797,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -762,6 +797,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
762 scheduleComposerFocus,797 scheduleComposerFocus,
763 setThreadError,798 setThreadError,
764 onExpandImage,799 onExpandImage,
800 onFileOpen,
801 openingVideoAttachmentId,
765 } = props;802 } = props;
766 // ------------------------------------------------------------------803 // ------------------------------------------------------------------
767 // Store subscriptions (prompt / images / terminal contexts)804 // Store subscriptions (prompt / images / terminal contexts)
...@@ -774,6 +811,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -774,6 +811,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
774 const prompt = composerDraft.prompt;811 const prompt = composerDraft.prompt;
775 const composerImages = composerDraft.images;812 const composerImages = composerDraft.images;
776 const composerFiles = composerDraft.files;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 const composerTerminalContexts = composerDraft.terminalContexts;820 const composerTerminalContexts = composerDraft.terminalContexts;
778 const composerElementContexts = composerDraft.elementContexts;821 const composerElementContexts = composerDraft.elementContexts;
779 const composerPreviewAnnotations = composerDraft.previewAnnotations;822 const composerPreviewAnnotations = composerDraft.previewAnnotations;
...@@ -2363,11 +2406,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2363,11 +2406,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2363 let restoredFileCount = 0;2406 let restoredFileCount = 0;
2364 const stashedFiles = entry.files ?? [];2407 const stashedFiles = entry.files ?? [];
2365 if (stashedFiles.length > 0) {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 const composerFilesNow = composerFilesRef.current;2409 const composerFilesNow = composerFilesRef.current;
2372 const existingFileIds = new Set(composerFilesNow.map((file) => file.id));2410 const existingFileIds = new Set(composerFilesNow.map((file) => file.id));
2373 const retainedUploadIds = new Set(2411 const retainedUploadIds = new Set(
...@@ -2375,16 +2413,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2375,16 +2413,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2375 file.uploadedAttachmentId ? [file.uploadedAttachmentId] : [],2413 file.uploadedAttachmentId ? [file.uploadedAttachmentId] : [],
2376 ),2414 ),
2377 );2415 );
2378 const existingFileKeys = new Set(composerFilesNow.map(fileDedupKey));2416 const existingFileKeys = new Set(composerFilesNow.map(composerFileDedupKey));
2379 const reattachMarkerKeys = new Set(2417 const reattachMarkers = composerFilesNow.filter(composerFileNeedsReattach);
2380 composerFilesNow.filter(composerFileNeedsReattach).map(fileDedupKey),2418 const restoredMarkerIds = new Set<string>();
2381 );
2382 const duplicateFiles: PersistedComposerFileAttachment[] = [];2419 const duplicateFiles: PersistedComposerFileAttachment[] = [];
2383 const markerReplacements: ComposerFileAttachment[] = [];2420 const markerReplacements: ComposerFileAttachment[] = [];
2384 const appendedFiles: ComposerFileAttachment[] = [];2421 const appendedFiles: ComposerFileAttachment[] = [];
2385 for (const file of stashedFiles) {2422 for (const file of stashedFiles) {
2386 const expired = expiredAttachmentIds.has(file.attachmentId);2423 const expired = expiredAttachmentIds.has(file.attachmentId);
2387 const key = fileDedupKey(file);2424 const key = composerFileDedupKey(file);
2388 const restored: ComposerFileAttachment = {2425 const restored: ComposerFileAttachment = {
2389 type: "file",2426 type: "file",
2390 id: file.id,2427 id: file.id,
...@@ -2404,23 +2441,23 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2404,23 +2441,23 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2404 }2441 }
2405 continue;2442 continue;
2406 }2443 }
2407 if (existingFileKeys.has(key)) {2444 const reattachMarker = reattachMarkers.find(
2408 if (reattachMarkerKeys.has(key)) {2445 (marker) =>
2409 // The draft row with this identity is a needs-reattach marker,2446 !restoredMarkerIds.has(marker.id) && composerFileMatchesReattachMarker(marker, file),
2410 // not a real duplicate. Replace it (addFiles swaps a matching2447 );
2411 // marker in place) instead of deleting the only uploaded copy.2448 if (reattachMarker) {
2412 reattachMarkerKeys.delete(key);2449 restoredMarkerIds.add(reattachMarker.id);
2413 existingFileIds.add(file.id);2450 existingFileIds.add(file.id);
2414 if (expired) {2451 existingFileKeys.add(key);
2415 // The draft's marker already says "attach again"; nothing to2452 if (expired) {
2416 // restore or release, but say why the stash copy is gone.2453 expiredFileNames.push(file.name);
2417 expiredFileNames.push(file.name);2454 } else {
2418 } else {2455 retainedUploadIds.add(file.attachmentId);
2419 retainedUploadIds.add(file.attachmentId);2456 markerReplacements.push(restored);
2420 markerReplacements.push(restored);
2421 }
2422 continue;
2423 }2457 }
2458 continue;
2459 }
2460 if (existingFileKeys.has(key)) {
2424 if (!expired && !retainedUploadIds.has(file.attachmentId)) {2461 if (!expired && !retainedUploadIds.has(file.attachmentId)) {
2425 duplicateFiles.push(file);2462 duplicateFiles.push(file);
2426 }2463 }
...@@ -2956,22 +2993,34 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2956,22 +2993,34 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2956 // A pick that matches a needs-reattach marker replaces it in the draft, so2993 // A pick that matches a needs-reattach marker replaces it in the draft, so
2957 // it must not consume a slot; a draft full of markers would otherwise hit2994 // it must not consume a slot; a draft full of markers would otherwise hit
2958 // the capacity error before the replacement path could run.2995 // the capacity error before the replacement path could run.
2959 const reattachKeys = new Set(2996 const reattachMarkers = composerFilesRef.current.filter(composerFileNeedsReattach);
2960 composerFilesRef.current2997 const replacedReattachMarkerIds = new Set<string>();
2961 .filter(composerFileNeedsReattach)
2962 .map((file) => `${file.mimeType}\u0000${file.sizeBytes}\u0000${file.name}`),
2963 );
2964 const acceptedImages: File[] = [];2998 const acceptedImages: File[] = [];
2965 const acceptedFiles: ComposerFileAttachment[] = [];2999 const acceptedFiles: ComposerFileAttachment[] = [];
2966 let error: string | null = null;3000 let error: string | null = null;
2967 for (const file of files) {3001 for (const file of files) {
2968 const attachmentKind = classifyComposerAttachmentFile(file);3002 const attachmentKind = classifyComposerAttachmentFile(file);
2969 const replacesReattachMarker =3003 const fileMimeType =
2970 attachmentKind === "file" &&3004 attachmentKind === "file"
2971 reattachKeys.delete(3005 ? (videoMimeType({ name: file.name, mimeType: file.type }) ??
2972 `${file.type || "application/octet-stream"}\u0000${file.size}\u0000${file.name || "file"}`,3006 (file.type || "application/octet-stream"))
2973 );3007 : file.type;
2974 if (!replacesReattachMarker && reservedCount >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) {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 error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per message.`;3024 error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per message.`;
2976 // Keep scanning: a later file in this batch can still replace a3025 // Keep scanning: a later file in this batch can still replace a
2977 // needs-reattach marker without needing a free slot.3026 // needs-reattach marker without needing a free slot.
...@@ -2996,16 +3045,20 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2996,16 +3045,20 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2996 error = fileAttachmentTooLargeMessage(file.name, fileStagingLimit);3045 error = fileAttachmentTooLargeMessage(file.name, fileStagingLimit);
2997 continue;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 acceptedFiles.push({3052 acceptedFiles.push({
3000 type: "file",3053 type: "file",
3001 id: randomUUID(),3054 id: randomUUID(),
3002 name: file.name || "file",3055 name: attachmentFile.name || "file",
3003 mimeType: file.type || "application/octet-stream",3056 mimeType: fileMimeType,
3004 sizeBytes: file.size,3057 sizeBytes: attachmentFile.size,
3005 file,3058 file: attachmentFile,
3006 });3059 });
3007 }3060 }
3008 if (!replacesReattachMarker) {3061 if (!matchingReattachMarker) {
3009 reservedCount += 1;3062 reservedCount += 1;
3010 }3063 }
3011 }3064 }
...@@ -3686,10 +3739,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3686,10 +3739,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3686 {!isComposerCollapsedMobile &&3739 {!isComposerCollapsedMobile &&
3687 !isComposerApprovalState &&3740 !isComposerApprovalState &&
3688 pendingUserInputs.length === 0 &&3741 pendingUserInputs.length === 0 &&
3689 composerImages.some(3742 (composerVideos.length > 0 ||
3690 (image) =>3743 composerImages.some(
3691 !composerPreviewAnnotations.some((annotation) => annotation.id === image.id),3744 (image) =>
3692 ) && (3745 !composerPreviewAnnotations.some((annotation) => annotation.id === image.id),
3746 )) && (
3693 <div className="mb-3 flex flex-wrap gap-2">3747 <div className="mb-3 flex flex-wrap gap-2">
3694 {composerImages3748 {composerImages
3695 .filter(3749 .filter(
...@@ -3800,15 +3854,102 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3800,15 +3854,102 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3800 </div>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 </div>3944 </div>
3804 )}3945 )}
38053946
3806 {!isComposerCollapsedMobile &&3947 {!isComposerCollapsedMobile &&
3807 !isComposerApprovalState &&3948 !isComposerApprovalState &&
3808 pendingUserInputs.length === 0 &&3949 pendingUserInputs.length === 0 &&
3809 composerFiles.length > 0 && (3950 composerOtherFiles.length > 0 && (
3810 <div className="mb-3 flex flex-col gap-1">3951 <div className="mb-3 flex flex-col gap-1">
3811 {composerFiles.map((file) => {3952 {composerOtherFiles.map((file) => {
3812 const fileCanUpload =3953 const fileCanUpload =
3813 supportsAttachmentUploads &&3954 supportsAttachmentUploads &&
3814 maxFileAttachmentBytes !== null &&3955 maxFileAttachmentBytes !== null &&
apps/web/src/components/chat/ExpandedImageDialog.test.tsx created+28
...@@ -0,0 +1,28 @@
1import { renderToStaticMarkup } from "react-dom/server";
2import { describe, expect, it } from "vite-plus/test";
3
4import { ExpandedImageDialog } from "./ExpandedImageDialog";
5import type { ExpandedImagePreview } from "./ExpandedImagePreview";
6
7describe("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,7 +1,7 @@
1import { memo, useCallback, useEffect, useState } from "react";1import { memo, useCallback, useEffect, useState } from "react";
2import { ChevronLeftIcon, ChevronRightIcon, XIcon } from "lucide-react";2import { ChevronLeftIcon, ChevronRightIcon, DownloadIcon, XIcon } from "lucide-react";
3import { Button } from "../ui/button";3import { Button } from "../ui/button";
4import type { ExpandedImagePreview } from "./ExpandedImagePreview";4import { downloadVideoPreview, type ExpandedImagePreview } from "./ExpandedImagePreview";
55
6interface ExpandedImageDialogProps {6interface ExpandedImageDialogProps {
7 preview: ExpandedImagePreview;7 preview: ExpandedImagePreview;
...@@ -13,12 +13,27 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({...@@ -13,12 +13,27 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
13 onClose,13 onClose,
14}: ExpandedImageDialogProps) {14}: ExpandedImageDialogProps) {
15 const [imageOffset, setImageOffset] = useState(0);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 const index = (preview.index + imageOffset + preview.images.length) % preview.images.length;19 const index = (preview.index + imageOffset + preview.images.length) % preview.images.length;
1720
18 const navigateImage = useCallback((direction: -1 | 1) => {21 const navigateImage = useCallback((direction: -1 | 1) => {
19 setImageOffset((current) => current + direction);22 setImageOffset((current) => current + direction);
20 }, []);23 }, []);
2124
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 useEffect(() => {37 useEffect(() => {
23 const onKeyDown = (event: globalThis.KeyboardEvent) => {38 const onKeyDown = (event: globalThis.KeyboardEvent) => {
24 if (event.key === "Escape") {39 if (event.key === "Escape") {
...@@ -45,18 +60,21 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({...@@ -45,18 +60,21 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
4560
46 const item = preview.images[index];61 const item = preview.images[index];
47 if (!item) return null;62 if (!item) return null;
63 const mediaLabel = item.type === "video" ? "video" : "image";
4864
65 const isDownloadingVideo = downloadingVideoSrc === item.src;
66 const videoDownloadFailed = downloadFailedVideoSrc === item.src;
49 return (67 return (
50 <div68 <div
51 className="fixed inset-0 z-50 flex items-center justify-center bg-black/75 px-4 py-6 [-webkit-app-region:no-drag]"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 role="dialog"70 role="dialog"
53 aria-modal="true"71 aria-modal="true"
54 aria-label="Expanded image preview"72 aria-label={`Expanded ${mediaLabel} preview`}
55 >73 >
56 <button74 <button
57 type="button"75 type="button"
58 className="absolute inset-0 z-0 cursor-zoom-out"76 className="absolute inset-0 z-0 cursor-zoom-out"
59 aria-label="Close image preview"77 aria-label={`Close ${mediaLabel} preview`}
60 onClick={onClose}78 onClick={onClose}
61 />79 />
62 {preview.images.length > 1 && (80 {preview.images.length > 1 && (
...@@ -78,16 +96,49 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({...@@ -78,16 +96,49 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
78 variant="ghost"96 variant="ghost"
79 className="absolute right-2 top-2"97 className="absolute right-2 top-2"
80 onClick={onClose}98 onClick={onClose}
81 aria-label="Close image preview"99 aria-label={`Close ${mediaLabel} preview`}
82 >100 >
83 <XIcon />101 <XIcon />
84 </Button>102 </Button>
85 <img103 {item.type === "video" && failedVideoSrc === item.src ? (
86 src={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">
87 alt={item.name}105 <p className="text-sm">
88 className="max-h-[86vh] max-w-[92vw] select-none rounded-lg border border-border/70 bg-background object-contain shadow-2xl"106 {videoDownloadFailed
89 draggable={false}107 ? "Could not download this video."
90 />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 <p className="mt-2 max-w-[92vw] truncate text-center text-xs text-muted-foreground/80">142 <p className="mt-2 max-w-[92vw] truncate text-center text-xs text-muted-foreground/80">
92 {item.name}143 {item.name}
93 {preview.images.length > 1 ? ` (${index + 1}/${preview.images.length})` : ""}144 {preview.images.length > 1 ? ` (${index + 1}/${preview.images.length})` : ""}
apps/web/src/components/chat/ExpandedImagePreview.test.ts created+68
...@@ -0,0 +1,68 @@
1import { describe, expect, it, vi } from "vite-plus/test";
2
3import type { ComposerFileAttachment } from "../../composerDraftStore";
4import {
5 attachVideoThumbnail,
6 buildExpandedImagePreview,
7 downloadVideoPreview,
8} from "./ExpandedImagePreview";
9
10describe("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,6 +1,10 @@
1import type { ComposerFileAttachment } from "../../composerDraftStore";
2import { type ChatImageAttachment, isVideoAttachment } from "../../types";
3
1export interface ExpandedImageItem {4export interface ExpandedImageItem {
2 src: string;5 src: string;
3 name: string;6 name: string;
7 type?: "video";
4}8}
59
6export interface ExpandedImagePreview {10export interface ExpandedImagePreview {
...@@ -8,12 +12,38 @@ export interface ExpandedImagePreview {...@@ -8,12 +12,38 @@ export interface ExpandedImagePreview {
8 index: number;12 index: number;
9}13}
1014
15export function attachVideoThumbnail(video: HTMLVideoElement, file: File): () => void {
16 const url = URL.createObjectURL(file);
17 video.src = url;
18 return () => URL.revokeObjectURL(url);
19}
20
21export 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
11export function buildExpandedImagePreview(32export function buildExpandedImagePreview(
12 images: ReadonlyArray<{ id: string; name: string; previewUrl?: string }>,33 images: ReadonlyArray<ChatImageAttachment | ComposerFileAttachment>,
13 selectedImageId: string,34 selectedImageId: string,
14): ExpandedImagePreview | null {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 const previewableImages = images.flatMap((image) =>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 if (previewableImages.length === 0) {48 if (previewableImages.length === 0) {
19 return null;49 return null;
apps/web/src/components/chat/MessagesTimeline.test.tsx+38
...@@ -187,6 +187,7 @@ function buildProps() {...@@ -187,6 +187,7 @@ function buildProps() {
187 revertTurnCountByUserMessageId: new Map(),187 revertTurnCountByUserMessageId: new Map(),
188 onRevertUserMessage: () => {},188 onRevertUserMessage: () => {},
189 isRevertingCheckpoint: false,189 isRevertingCheckpoint: false,
190 openingVideoAttachmentId: null,
190 onImageExpand: () => {},191 onImageExpand: () => {},
191 activeThreadEnvironmentId: ACTIVE_THREAD_ENVIRONMENT_ID,192 activeThreadEnvironmentId: ACTIVE_THREAD_ENVIRONMENT_ID,
192 markdownCwd: undefined,193 markdownCwd: undefined,
...@@ -586,6 +587,43 @@ describe("MessagesTimeline", () => {...@@ -586,6 +587,43 @@ describe("MessagesTimeline", () => {
586 expect(markup).not.toContain('alt="report.pdf"');587 expect(markup).not.toContain('alt="report.pdf"');
587 });588 });
588589
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 it("renders a file download button without creating its URL in advance", () => {627 it("renders a file download button without creating its URL in advance", () => {
590 const entry = {628 const entry = {
591 ...buildUserTimelineEntry("Read the report."),629 ...buildUserTimelineEntry("Read the report."),
apps/web/src/components/chat/MessagesTimeline.tsx+48-10
...@@ -16,7 +16,7 @@ import {...@@ -16,7 +16,7 @@ import {
1616
17const EMPTY_AGENT_PANEL_MODEL = emptyAgentPanelModel();17const EMPTY_AGENT_PANEL_MODEL = emptyAgentPanelModel();
18const NOOP_OPEN_AGENTS = () => {};18const NOOP_OPEN_AGENTS = () => {};
19const NOOP_DOWNLOAD_ATTACHMENT = (_attachment: ChatFileAttachment) => {};19const NOOP_OPEN_ATTACHMENT = (_attachment: ChatFileAttachment) => {};
20import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";20import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";
21import {21import {
22 createContext,22 createContext,
...@@ -45,6 +45,7 @@ import {...@@ -45,6 +45,7 @@ import {
45 type ChatImageAttachment,45 type ChatImageAttachment,
46 isFileAttachment,46 isFileAttachment,
47 isImageAttachment,47 isImageAttachment,
48 isVideoAttachment,
48 type TurnDiffSummary,49 type TurnDiffSummary,
49} from "../../types";50} from "../../types";
50import {51import {
...@@ -67,6 +68,7 @@ import {...@@ -67,6 +68,7 @@ import {
67 MessageCircleIcon,68 MessageCircleIcon,
68 MousePointerClickIcon,69 MousePointerClickIcon,
69 PaintbrushIcon,70 PaintbrushIcon,
71 PlayIcon,
70 SearchIcon,72 SearchIcon,
71 SquarePenIcon,73 SquarePenIcon,
72 TerminalIcon,74 TerminalIcon,
...@@ -153,7 +155,8 @@ interface TimelineRowSharedState {...@@ -153,7 +155,8 @@ interface TimelineRowSharedState {
153 activeThreadEnvironmentId: EnvironmentId;155 activeThreadEnvironmentId: EnvironmentId;
154 onRevertUserMessage: (messageId: MessageId) => void;156 onRevertUserMessage: (messageId: MessageId) => void;
155 onImageExpand: (preview: ExpandedImagePreview) => void;157 onImageExpand: (preview: ExpandedImagePreview) => void;
156 onFileDownload: (attachment: ChatFileAttachment) => void;158 onFileOpen: (attachment: ChatFileAttachment) => void;
159 openingVideoAttachmentId: string | null;
157 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;160 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;
158 onToggleTurnFold: (turnId: TurnId) => void;161 onToggleTurnFold: (turnId: TurnId) => void;
159 onToggleWorkGroup: (groupId: string, anchorKey: string) => void;162 onToggleWorkGroup: (groupId: string, anchorKey: string) => void;
...@@ -232,7 +235,8 @@ interface MessagesTimelineProps {...@@ -232,7 +235,8 @@ interface MessagesTimelineProps {
232 onRevertUserMessage: (messageId: MessageId) => void;235 onRevertUserMessage: (messageId: MessageId) => void;
233 isRevertingCheckpoint: boolean;236 isRevertingCheckpoint: boolean;
234 onImageExpand: (preview: ExpandedImagePreview) => void;237 onImageExpand: (preview: ExpandedImagePreview) => void;
235 onFileDownload?: (attachment: ChatFileAttachment) => void;238 onFileOpen?: (attachment: ChatFileAttachment) => void;
239 openingVideoAttachmentId: string | null;
236 activeThreadEnvironmentId: EnvironmentId;240 activeThreadEnvironmentId: EnvironmentId;
237 markdownCwd: string | undefined;241 markdownCwd: string | undefined;
238 resolvedTheme: "light" | "dark";242 resolvedTheme: "light" | "dark";
...@@ -278,7 +282,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -278,7 +282,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({
278 onRevertUserMessage,282 onRevertUserMessage,
279 isRevertingCheckpoint,283 isRevertingCheckpoint,
280 onImageExpand,284 onImageExpand,
281 onFileDownload = NOOP_DOWNLOAD_ATTACHMENT,285 onFileOpen = NOOP_OPEN_ATTACHMENT,
286 openingVideoAttachmentId,
282 activeThreadEnvironmentId,287 activeThreadEnvironmentId,
283 markdownCwd,288 markdownCwd,
284 resolvedTheme,289 resolvedTheme,
...@@ -537,7 +542,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -537,7 +542,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({
537 activeThreadEnvironmentId,542 activeThreadEnvironmentId,
538 onRevertUserMessage,543 onRevertUserMessage,
539 onImageExpand,544 onImageExpand,
540 onFileDownload,545 onFileOpen,
546 openingVideoAttachmentId,
541 onOpenTurnDiff,547 onOpenTurnDiff,
542 onToggleTurnFold,548 onToggleTurnFold,
543 onToggleWorkGroup,549 onToggleWorkGroup,
...@@ -554,7 +560,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -554,7 +560,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({
554 activeThreadEnvironmentId,560 activeThreadEnvironmentId,
555 onRevertUserMessage,561 onRevertUserMessage,
556 onImageExpand,562 onImageExpand,
557 onFileDownload,563 onFileOpen,
564 openingVideoAttachmentId,
558 onOpenTurnDiff,565 onOpenTurnDiff,
559 onToggleTurnFold,566 onToggleTurnFold,
560 onToggleWorkGroup,567 onToggleWorkGroup,
...@@ -1005,6 +1012,8 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"...@@ -1005,6 +1012,8 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
1005 // comparisons) split it. Unknown types render as inert rows below the files.1012 // comparisons) split it. Unknown types render as inert rows below the files.
1006 const userImages = (row.message.attachments ?? []).filter(isImageAttachment);1013 const userImages = (row.message.attachments ?? []).filter(isImageAttachment);
1007 const userFiles = (row.message.attachments ?? []).filter(isFileAttachment);1014 const userFiles = (row.message.attachments ?? []).filter(isFileAttachment);
1015 const userVideos = userFiles.filter(isVideoAttachment);
1016 const otherUserFiles = userFiles.filter((file) => !isVideoAttachment(file));
1008 const unknownAttachments = (row.message.attachments ?? []).filter(1017 const unknownAttachments = (row.message.attachments ?? []).filter(
1009 (attachment) => !isImageAttachment(attachment) && !isFileAttachment(attachment),1018 (attachment) => !isImageAttachment(attachment) && !isFileAttachment(attachment),
1010 );1019 );
...@@ -1030,7 +1039,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"...@@ -1030,7 +1039,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
1030 return (1039 return (
1031 <div className="group flex flex-col items-end gap-1">1040 <div className="group flex flex-col items-end gap-1">
1032 <div className="relative max-w-[80%] rounded-2xl bg-message p-3 text-message-foreground">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 <div className="mb-2 grid max-w-[420px] grid-cols-2 gap-2">1043 <div className="mb-2 grid max-w-[420px] grid-cols-2 gap-2">
1035 {regularImages.map((image) => (1044 {regularImages.map((image) => (
1036 <div1045 <div
...@@ -1061,6 +1070,35 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"...@@ -1061,6 +1070,35 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
1061 )}1070 )}
1062 </div>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 </div>1102 </div>
1065 )}1103 )}
1066 {previewAnnotations.map((annotation, index) => (1104 {previewAnnotations.map((annotation, index) => (
...@@ -1070,9 +1108,9 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"...@@ -1070,9 +1108,9 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
1070 image={previewImages[index] ?? null}1108 image={previewImages[index] ?? null}
1071 />1109 />
1072 ))}1110 ))}
1073 {userFiles.length > 0 || unknownAttachments.length > 0 ? (1111 {otherUserFiles.length > 0 || unknownAttachments.length > 0 ? (
1074 <div className="mb-2 flex flex-col gap-1">1112 <div className="mb-2 flex flex-col gap-1">
1075 {userFiles.map((file) => {1113 {otherUserFiles.map((file) => {
1076 const content = (1114 const content = (
1077 <>1115 <>
1078 <FileIcon className="size-4 shrink-0 text-secondary-label" />1116 <FileIcon className="size-4 shrink-0 text-secondary-label" />
...@@ -1100,7 +1138,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"...@@ -1100,7 +1138,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
1100 key={file.id}1138 key={file.id}
1101 type="button"1139 type="button"
1102 aria-label={`Download ${file.name}`}1140 aria-label={`Download ${file.name}`}
1103 onClick={() => ctx.onFileDownload(file)}1141 onClick={() => ctx.onFileOpen(file)}
1104 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"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 {content}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,12 +2,14 @@ import { EnvironmentId, PROVIDER_SEND_TURN_MAX_FILE_BYTES } from "@t3tools/contr
2import { describe, expect, it } from "vite-plus/test";2import { describe, expect, it } from "vite-plus/test";
33
4import type { ComposerFileAttachment, ComposerImageAttachment } from "../../composerDraftStore";4import type { ComposerFileAttachment, ComposerImageAttachment } from "../../composerDraftStore";
5import { isVideoAttachment, videoMimeType } from "../../types";
5import {6import {
6 attachmentsToReleaseOnUploadCapabilityLoss,7 attachmentsToReleaseOnUploadCapabilityLoss,
7 classifyComposerAttachmentFile,8 classifyComposerAttachmentFile,
8 fileAttachmentCapabilityBlockReason,9 fileAttachmentCapabilityBlockReason,
9 fileAttachmentStagingLimit,10 fileAttachmentStagingLimit,
10 inferImageMimeTypeFromName,11 inferImageMimeTypeFromName,
12 isPreviewableComposerVideo,
11 normalizeComposerImageFileMimeType,13 normalizeComposerImageFileMimeType,
12 shouldHandleComposerAttachmentPaste,14 shouldHandleComposerAttachmentPaste,
13} from "./composerAttachmentFiles";15} from "./composerAttachmentFiles";
...@@ -267,6 +269,48 @@ describe("composer attachment files", () => {...@@ -267,6 +269,48 @@ describe("composer attachment files", () => {
267 expect(released.map((attachment) => attachment.id)).toEqual(["image-1", "file-uploading"]);269 expect(released.map((attachment) => attachment.id)).toEqual(["image-1", "file-uploading"]);
268 });270 });
269271
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 it("claims image pastes even when clipboard text is present", () => {314 it("claims image pastes even when clipboard text is present", () => {
271 const image = new File(["image"], "photo.heic", { type: "image/heic" });315 const image = new File(["image"], "photo.heic", { type: "image/heic" });
272316
apps/web/src/components/chat/composerAttachmentFiles.ts+13
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import {1import {
2 type EnvironmentId,
2 isProviderSendTurnSupportedImageMimeType,3 isProviderSendTurnSupportedImageMimeType,
3 PROVIDER_SEND_TURN_MAX_FILE_BYTES,4 PROVIDER_SEND_TURN_MAX_FILE_BYTES,
4} from "@t3tools/contracts";5} from "@t3tools/contracts";
...@@ -9,6 +10,7 @@ import {...@@ -9,6 +10,7 @@ import {
910
10import type { ComposerFileAttachment, ComposerImageAttachment } from "../../composerDraftStore";11import type { ComposerFileAttachment, ComposerImageAttachment } from "../../composerDraftStore";
11import { isHeicImageFile } from "../../lib/imageCompression";12import { isHeicImageFile } from "../../lib/imageCompression";
13import { isVideoAttachment } from "../../types";
1214
13type ComposerAttachmentFileKind = "image" | "file" | "unsupported-image";15type ComposerAttachmentFileKind = "image" | "file" | "unsupported-image";
1416
...@@ -75,6 +77,17 @@ export function classifyComposerAttachmentFile(...@@ -75,6 +77,17 @@ export function classifyComposerAttachmentFile(
75 return isProviderSendTurnSupportedImageMimeType(file.type) ? "image" : "unsupported-image";77 return isProviderSendTurnSupportedImageMimeType(file.type) ? "image" : "unsupported-image";
76}78}
7779
80export 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/** Byte limit for adding a generic file to the local composer draft. */91/** Byte limit for adding a generic file to the local composer draft. */
79export function fileAttachmentStagingLimit(input: FileAttachmentCapabilityState): number | null {92export function fileAttachmentStagingLimit(input: FileAttachmentCapabilityState): number | null {
80 if (!input.attachmentUploadsCapabilityKnown) {93 if (!input.attachmentUploadsCapabilityKnown) {
apps/web/src/components/pullRequest/PullRequestMarkdown.tsx+4-6
...@@ -10,12 +10,10 @@ import { splitPullRequestBody } from "./pullRequestMarkdown.logic";...@@ -10,12 +10,10 @@ import { splitPullRequestBody } from "./pullRequestMarkdown.logic";
10 * A pull request body, rendered with the app's markdown renderer plus a card for each upload10 * A pull request body, rendered with the app's markdown renderer plus a card for each upload
11 * embedded in it, which that renderer drops on the floor.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. A13 * The card links out instead of playing in place. GitHub serves the file as uploaded — Mac
14 * `github.com/user-attachments/assets/…` link is a 302 to a signed S3 URL that serves the file14 * recordings are `video/quicktime`, which no Chromium decodes — and the desktop window's
15 * as uploaded — `video/quicktime` for anything recorded on a Mac, which no Chromium decodes —15 * `media-src` allows only `'self'`, the app scheme and `blob:`, so a remote source is refused
16 * and the desktop window's content policy declares no `media-src`, so media falls back to16 * before a byte is fetched. Opening the host works for every format.
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.
19 */17 */
20export function PullRequestMarkdown({18export function PullRequestMarkdown({
21 text,19 text,
apps/web/src/composerDraftStore.test.ts+58
...@@ -517,6 +517,34 @@ describe("composerDraftStore file attachments", () => {...@@ -517,6 +517,34 @@ describe("composerDraftStore file attachments", () => {
517 expect(files?.some(composerFileNeedsReattach)).toBe(false);517 expect(files?.some(composerFileNeedsReattach)).toBe(false);
518 });518 });
519519
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 it("replaces a needs-reattach marker with a stash-restored uploaded file", () => {548 it("replaces a needs-reattach marker with a stash-restored uploaded file", () => {
521 const store = useComposerDraftStore.getState();549 const store = useComposerDraftStore.getState();
522 const marker: ComposerFileAttachment = { ...makeFile("file-marker"), file: null };550 const marker: ComposerFileAttachment = { ...makeFile("file-marker"), file: null };
...@@ -553,6 +581,36 @@ describe("composerDraftStore file attachments", () => {...@@ -553,6 +581,36 @@ describe("composerDraftStore file attachments", () => {
553 ]);581 ]);
554 });582 });
555583
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 it("keeps the remaining file slot available after a duplicate is skipped", () => {614 it("keeps the remaining file slot available after a duplicate is skipped", () => {
557 const store = useComposerDraftStore.getState();615 const store = useComposerDraftStore.getState();
558 store.addImages(616 store.addImages(
apps/web/src/composerDraftStore.ts+36-18
...@@ -39,6 +39,7 @@ import {...@@ -39,6 +39,7 @@ import {
39 DEFAULT_RUNTIME_MODE,39 DEFAULT_RUNTIME_MODE,
40 type ChatFileAttachment,40 type ChatFileAttachment,
41 type ChatImageAttachment,41 type ChatImageAttachment,
42 videoMimeType,
42} from "./types";43} from "./types";
43import {44import {
44 type TerminalContextDraft,45 type TerminalContextDraft,
...@@ -745,6 +746,26 @@ function composerImageDedupKey(image: ComposerImageAttachment): string {...@@ -745,6 +746,26 @@ function composerImageDedupKey(image: ComposerImageAttachment): string {
745 return `${image.mimeType}\u0000${image.sizeBytes}\u0000${image.name}`;746 return `${image.mimeType}\u0000${image.sizeBytes}\u0000${image.name}`;
746}747}
747748
749export function composerFileDedupKey(
750 file: Pick<ComposerFileAttachment, "mimeType" | "sizeBytes" | "name">,
751): string {
752 return `${file.mimeType}\u0000${file.sizeBytes}\u0000${file.name}`;
753}
754
755export 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
748function terminalContextDedupKey(context: TerminalContextDraft): string {769function terminalContextDedupKey(context: TerminalContextDraft): string {
749 return `${context.terminalId}\u0000${context.lineStart}\u0000${context.lineEnd}`;770 return `${context.terminalId}\u0000${context.lineStart}\u0000${context.lineEnd}`;
750}771}
...@@ -3189,29 +3210,32 @@ const composerDraftStore = create<ComposerDraftStoreState>()(...@@ -3189,29 +3210,32 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
3189 const existing = state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft();3210 const existing = state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft();
3190 const knownIds = new Set(existing.files.map((file) => file.id));3211 const knownIds = new Set(existing.files.map((file) => file.id));
3191 const knownFiles = new Map<string, ComposerFileAttachment>(3212 const knownFiles = new Map<string, ComposerFileAttachment>(
3192 existing.files.map((file) => [3213 existing.files.map((file) => [composerFileDedupKey(file), file]),
3193 `${file.mimeType}\u0000${file.sizeBytes}\u0000${file.name}`,
3194 file,
3195 ]),
3196 );3214 );
3197 const accepted: ComposerFileAttachment[] = [];3215 const accepted: ComposerFileAttachment[] = [];
3198 // Needs-reattach markers replaced in place by a re-pick, keyed by3216 // Needs-reattach markers replaced in place by a re-pick, keyed by
3199 // the marker's id.3217 // the marker's id.
3200 const replacements = new Map<string, ComposerFileAttachment>();3218 const replacements = new Map<string, ComposerFileAttachment>();
3201 for (const file of files) {3219 for (const file of files) {
3202 const key = `${file.mimeType}\u0000${file.sizeBytes}\u0000${file.name}`;3220 const key = composerFileDedupKey(file);
3203 if (knownIds.has(file.id)) {3221 if (knownIds.has(file.id)) {
3204 continue;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 if (duplicate) {3232 if (duplicate) {
3208 // A needs-reattach marker persists exactly the metadata this3233 // A needs-reattach marker is not a usable duplicate. Replace
3209 // key hashes, so re-picking the same file matches its marker.3234 // it so the upload restarts.
3210 // Dropping the pick as a duplicate would leave the draft
3211 // blocked forever; replace the marker so the upload restarts.
3212 if (composerFileNeedsReattach(duplicate) && !replacements.has(duplicate.id)) {3235 if (composerFileNeedsReattach(duplicate) && !replacements.has(duplicate.id)) {
3213 replacements.set(duplicate.id, file);3236 replacements.set(duplicate.id, file);
3214 knownIds.add(file.id);3237 knownIds.add(file.id);
3238 knownFiles.set(key, file);
3215 }3239 }
3216 continue;3240 continue;
3217 }3241 }
...@@ -3802,18 +3826,12 @@ const composerDraftStore = create<ComposerDraftStoreState>()(...@@ -3802,18 +3826,12 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
3802 // A file the destination already holds (same id, or same3826 // A file the destination already holds (same id, or same
3803 // metadata key) stays behind instead of duplicating there.3827 // metadata key) stays behind instead of duplicating there.
3804 const destinationFileIds = new Set(destination.files.map((file) => file.id));3828 const destinationFileIds = new Set(destination.files.map((file) => file.id));
3805 const destinationFileKeys = new Set(3829 const destinationFileKeys = new Set(destination.files.map(composerFileDedupKey));
3806 destination.files.map(
3807 (file) => `${file.mimeType}\u0000${file.sizeBytes}\u0000${file.name}`,
3808 ),
3809 );
3810 const transferableFiles = source.files.filter(3830 const transferableFiles = source.files.filter(
3811 (file) =>3831 (file) =>
3812 (file.file !== null || file.uploadEnvironmentId === destinationEnvironmentId) &&3832 (file.file !== null || file.uploadEnvironmentId === destinationEnvironmentId) &&
3813 !destinationFileIds.has(file.id) &&3833 !destinationFileIds.has(file.id) &&
3814 !destinationFileKeys.has(3834 !destinationFileKeys.has(composerFileDedupKey(file)),
3815 `${file.mimeType}\u0000${file.sizeBytes}\u0000${file.name}`,
3816 ),
3817 );3835 );
3818 const remainingAttachmentSlots = Math.max(3836 const remainingAttachmentSlots = Math.max(
3819 0,3837 0,
apps/web/src/types.ts+25
...@@ -59,6 +59,31 @@ export function isFileAttachment(attachment: ChatAttachment): attachment is Chat...@@ -59,6 +59,31 @@ export function isFileAttachment(attachment: ChatAttachment): attachment is Chat
59 return attachment.type === "file";59 return attachment.type === "file";
60}60}
6161
62const 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
72export 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
83export function isVideoAttachment(attachment: ChatFileAttachment): boolean {
84 return videoMimeType(attachment) !== null;
85}
86
62export interface ChatMessage extends Omit<OrchestrationMessage, "attachments"> {87export interface ChatMessage extends Omit<OrchestrationMessage, "attachments"> {
63 readonly attachments?: ReadonlyArray<ChatAttachment> | undefined;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,6 +13,9 @@ On web and desktop, attachments upload as soon as you add them. The send button
13after every upload finishes. Failed uploads can be retried or removed. On mobile, attachments are13after every upload finishes. Failed uploads can be retried or removed. On mobile, attachments are
14currently limited to images.14currently limited to images.
1515
16Select a video attachment before or after sending to play it with the browser's built-in controls.
17Playback depends on the video formats and codecs that the browser supports.
18
16If you reload before a file finishes uploading, the draft keeps the file's name and shows **Attach19If you reload before a file finishes uploading, the draft keeps the file's name and shows **Attach
17again** next to it. Attach the file again or remove it, then send.20again** next to it. Attach the file again or remove it, then send.
1821