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", () => {
225225 "http:",
226226 "https:",
227227 ]);
228 assert.deepEqual(directives["media-src"], ["'self'", "t3code:", "blob:"]);
228229 assert.deepEqual(directives["font-src"], ["'self'", "t3code:", "data:"]);
229230 });
230231});
apps/desktop/src/electron/ElectronProtocol.ts+1
......@@ -87,6 +87,7 @@ export function makeDesktopContentSecurityPolicy(input: DesktopProtocolRegistrat
8787 `script-src ${scriptSources.join(" ")}`,
8888 `connect-src ${connectSources.join(" ")}`,
8989 `img-src 'self' ${input.scheme}: blob: data: http: https:`,
90 `media-src 'self' ${input.scheme}: blob:`,
9091 "style-src 'self' 'unsafe-inline'",
9192 `font-src 'self' ${input.scheme}: data:`,
9293 "worker-src 'self' blob:",
apps/server/src/assets/AssetAccess.test.ts+31
......@@ -208,6 +208,37 @@ describe("AssetAccess", () => {
208208 }).pipe(Effect.provide(testLayer)),
209209 );
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 );
211242 it.effect("issues project favicon capabilities with a signed fallback", () =>
212243 Effect.gen(function* () {
213244 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";
4848const ASSET_TOKEN_TTL_MS = 60 * 60 * 1000;
4949const PROJECT_FAVICON_TOKEN_BUCKET_MS = 30 * 60 * 1000;
5050const PROJECT_FAVICON_VERSION_PREFIX = "v";
51const INLINE_VIDEO_MIME_TYPE_PATTERN = /^video\/[\w!#$&^.+-]+$/i;
5152const PREVIEW_ASSET_EXTENSIONS = new Set([
5253 ...WORKSPACE_BROWSER_PREVIEW_EXTENSIONS,
5354 ...WORKSPACE_IMAGE_PREVIEW_EXTENSIONS,
......@@ -300,16 +301,20 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
300301 });
301302 }
302303 // 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.
305306 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);
306309 claims = {
307310 version: 1,
308311 kind: "attachment",
309312 attachmentId: input.resource.attachmentId,
310 ...(isGenericFile ? { download: true } : {}),
313 ...(isGenericFile && !isVideo ? { download: true } : {}),
311314 ...(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 : {}),
313318 expiresAt,
314319 };
315320 fileName = input.resource.fileName ?? path.basename(attachmentPath);
apps/server/src/http.test.ts+11
......@@ -50,6 +50,17 @@ describe("assetResponseHeaders", () => {
5050 });
5151 });
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 });
5364 it("declares utf-8 for HTML assets so non-ASCII content renders correctly", () => {
5465 expect(assetResponseHeaders("/workspace/page.html")).toHaveProperty(
5566 "Content-Type",
apps/server/src/http.ts+10-5
......@@ -57,6 +57,8 @@ const DOWNLOAD_MIME_TYPE_PATTERN = /^[\w!#$&^.+-]+\/[\w!#$&^.+-]+$/;
5757const isSafeDownloadMimeType = (mimeType: string): boolean =>
5858 DOWNLOAD_MIME_TYPE_PATTERN.test(mimeType) &&
5959 !/(?:^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
6163/** RFC 6266 disposition with an ASCII fallback name plus a UTF-8 `filename*`. */
6264export function downloadContentDisposition(fileName?: string): string {
......@@ -86,6 +88,7 @@ export function assetResponseHeaders(
8688 },
8789): Record<string, string> {
8890 const lowerPath = filePath.toLowerCase();
91 const inlineVideoMimeType = options?.mimeType?.split(";", 1)[0]?.trim();
8992 return {
9093 "Cache-Control": "private, max-age=3600",
9194 "X-Content-Type-Options": "nosniff",
......@@ -98,9 +101,11 @@ export function assetResponseHeaders(
98101 ? options.mimeType
99102 : "application/octet-stream",
100103 }
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 : {}),
104109 ...(!options?.download && lowerPath.endsWith(".svg")
105110 ? { "Content-Security-Policy": SVG_CONTENT_SECURITY_POLICY }
106111 : {}),
......@@ -276,9 +281,9 @@ export const assetRouteLayer = HttpRouter.add(
276281 status: 200,
277282 headers: assetResponseHeaders(
278283 asset.path,
279 asset.download
284 asset.download || asset.mimeType !== undefined
280285 ? {
281 download: true,
286 ...(asset.download ? { download: true } : {}),
282287 ...(asset.fileName !== undefined ? { fileName: asset.fileName } : {}),
283288 ...(asset.mimeType !== undefined ? { mimeType: asset.mimeType } : {}),
284289 }
apps/web/src/components/ChatView.logic.test.ts+27
......@@ -21,6 +21,8 @@ import {
2121 dismissBranchMismatchForSession,
2222 ENVIRONMENT_RECONNECT_WARNING_GRACE_MS,
2323 getStartedThreadModelChangeBlockReason,
24 loadVideoPreviewUrl,
25 isVideoPreviewRequestCurrent,
2426 hasEnvironmentReconnectWarningGraceElapsed,
2527 hasServerAcknowledgedLocalDispatch,
2628 isBranchMismatchDismissedForSession,
......@@ -41,6 +43,31 @@ import {
4143 shouldWriteThreadErrorToCurrentServerThread,
4244} 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
4471const environmentId = EnvironmentId.make("environment-local");
4572const projectId = ProjectId.make("project-1");
4673const threadId = ThreadId.make("thread-1");
apps/web/src/components/ChatView.logic.ts+15
......@@ -290,6 +290,21 @@ export function revokeBlobPreviewUrl(previewUrl: string | undefined): void {
290290 URL.revokeObjectURL(previewUrl);
291291}
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
293308export function revokeUserMessagePreviewUrls(message: ChatMessage): void {
294309 if (message.role !== "user" || !message.attachments) {
295310 return;
apps/web/src/components/ChatView.tsx+95-14
......@@ -129,6 +129,7 @@ import {
129129 MAX_TERMINALS_PER_GROUP,
130130 type ChatMessage,
131131 isImageAttachment,
132 videoMimeType,
132133 type SessionPhase,
133134 type Thread,
134135 type TurnDiffSummary,
......@@ -360,6 +361,8 @@ import {
360361 cloneComposerImageForRetry,
361362 deriveLockedProvider,
362363 readFileAsDataUrl,
364 loadVideoPreviewUrl,
365 isVideoPreviewRequestCurrent,
363366 reconcileMountedTerminalThreadIds,
364367 resolveBackgroundDraftWorkspaceOptions,
365368 resolveDraftHeroState,
......@@ -1438,8 +1441,23 @@ function ChatViewContent(props: ChatViewProps) {
14381441 const localComposerRef = useRef<ChatComposerHandle | null>(null);
14391442 const composerRef = useComposerHandleContext() ?? localComposerRef;
14401443 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);
14411454 const [showScrollToBottom, setShowScrollToBottom] = useState(false);
14421455 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]);
14431461 const [optimisticUserMessages, setOptimisticUserMessages] = useState<ChatMessage[]>([]);
14441462 const [feedbackSubmissionsByThreadKey, setFeedbackSubmissionsByThreadKey] = useState<
14451463 Record<string, ReadonlyArray<CodexFeedbackSubmission>>
......@@ -2479,11 +2497,12 @@ function ChatViewContent(props: ChatViewProps) {
24792497 useEffect(() => {
24802498 return () => {
24812499 clearAttachmentPreviewHandoffs();
2500 cancelVideoPreviewRequest();
24822501 for (const message of optimisticUserMessagesRef.current) {
24832502 revokeUserMessagePreviewUrls(message);
24842503 }
24852504 };
2486 }, [clearAttachmentPreviewHandoffs]);
2505 }, [cancelVideoPreviewRequest, clearAttachmentPreviewHandoffs]);
24872506 const handoffAttachmentPreviews = useCallback((messageId: MessageId, previewUrls: string[]) => {
24882507 if (previewUrls.length === 0) return;
24892508
......@@ -2504,17 +2523,40 @@ function ChatViewContent(props: ChatViewProps) {
25042523 });
25052524 }, []);
25062525 const serverMessages = activeThread?.messages;
2507 const downloadFileAttachment = useCallback(
2526 const openFileAttachment = useCallback(
25082527 async (attachment: ChatFileAttachment) => {
25092528 const connection = readPreparedConnection(environmentId);
25102529 if (!connection) {
25112530 toastManager.add({ type: "error", title: "The environment is not connected." });
25122531 return;
25132532 }
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 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.
25182560 const result = await createAttachmentAssetUrl({
25192561 environmentId,
25202562 input: {
......@@ -2522,15 +2564,20 @@ function ChatViewContent(props: ChatViewProps) {
25222564 _tag: "attachment",
25232565 attachmentId: attachment.id,
25242566 fileName: attachment.name,
2525 mimeType: attachment.mimeType,
2567 mimeType: videoMime ?? attachment.mimeType,
25262568 },
25272569 },
25282570 });
2571 if (!isCurrentRequest()) {
2572 finishVideoPreviewRequest();
2573 return;
2574 }
25292575 if (result._tag === "Failure") {
2576 finishVideoPreviewRequest();
25302577 const error = squashAtomCommandFailure(result);
25312578 toastManager.add({
25322579 type: "error",
2533 title: `Could not download ${attachment.name}`,
2580 title: "Could not " + action + " " + attachment.name,
25342581 description: error instanceof Error ? error.message : "The attachment is unavailable.",
25352582 });
25362583 return;
......@@ -2538,7 +2585,31 @@ function ChatViewContent(props: ChatViewProps) {
25382585
25392586 const url = resolveAssetUrl(connection.httpBaseUrl, result.value.relativeUrl);
25402587 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 }
25422613 return;
25432614 }
25442615 const anchor = document.createElement("a");
......@@ -2546,7 +2617,7 @@ function ChatViewContent(props: ChatViewProps) {
25462617 anchor.download = attachment.name;
25472618 anchor.click();
25482619 },
2549 [createAttachmentAssetUrl, environmentId],
2620 [createAttachmentAssetUrl, environmentId, routeThreadKey],
25502621 );
25512622 const serverAttachmentIds = useMemo(() => {
25522623 const attachmentIds = new Set<string>();
......@@ -4389,8 +4460,10 @@ function ChatViewContent(props: ChatViewProps) {
43894460 return [];
43904461 });
43914462 resetLocalDispatch();
4463 cancelVideoPreviewRequest();
4464 setOpeningVideoAttachmentId(null);
43924465 setExpandedImage(null);
4393 }, [draftId, resetLocalDispatch, threadId]);
4466 }, [cancelVideoPreviewRequest, draftId, resetLocalDispatch, threadId]);
43944467
43954468 const closeExpandedImage = useCallback(() => {
43964469 setExpandedImage(null);
......@@ -6843,9 +6916,14 @@ function ChatViewContent(props: ChatViewProps) {
68436916 }
68446917 };
68456918
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 );
68496927 const onOpenTurnDiff = useCallback(
68506928 (turnId: TurnId, filePath?: string) => {
68516929 if (!isServerThread || !activeThreadRef) return;
......@@ -7139,7 +7217,8 @@ function ChatViewContent(props: ChatViewProps) {
71397217 onRevertUserMessage={onRevertUserMessage}
71407218 isRevertingCheckpoint={isRevertingCheckpoint}
71417219 onImageExpand={onExpandTimelineImage}
7142 onFileDownload={downloadFileAttachment}
7220 onFileOpen={openFileAttachment}
7221 openingVideoAttachmentId={openingVideoAttachmentId}
71437222 markdownCwd={gitCwd ?? undefined}
71447223 resolvedTheme={resolvedTheme}
71457224 timestampFormat={timestampFormat}
......@@ -7320,6 +7399,8 @@ function ChatViewContent(props: ChatViewProps) {
73207399 scheduleComposerFocus={scheduleComposerFocus}
73217400 setThreadError={setThreadError}
73227401 onExpandImage={onExpandTimelineImage}
7402 onFileOpen={openFileAttachment}
7403 openingVideoAttachmentId={openingVideoAttachmentId}
73237404 />
73247405 </div>
73257406 </div>
apps/web/src/components/chat/ChatComposer.tsx+191-50
......@@ -1,5 +1,6 @@
11import type {
22 ApprovalRequestId,
3 ChatFileAttachment,
34 EnvironmentId,
45 ModelSelection,
56 PreviewAnnotationPayload,
......@@ -55,6 +56,8 @@ import {
5556 type DraftId,
5657 type PersistedComposerFileAttachment,
5758 type PersistedComposerImageAttachment,
59 composerFileDedupKey,
60 composerFileMatchesReattachMarker,
5861 composerFileNeedsReattach,
5962 composerTargetKey,
6063 hydrateImagesFromPersisted,
......@@ -86,6 +89,7 @@ import {
8689 classifyComposerAttachmentFile,
8790 fileAttachmentCapabilityBlockReason,
8891 fileAttachmentStagingLimit,
92 isPreviewableComposerVideo,
8993 normalizeComposerImageFileMimeType,
9094 shouldHandleComposerAttachmentPaste,
9195} from "./composerAttachmentFiles";
......@@ -142,7 +146,11 @@ import {
142146} from "./composerProviderState";
143147import { ContextWindowMeter } from "./ContextWindowMeter";
144148import { resolveContextWindowModelDisplayName } from "./ContextWindowMeter.logic";
145import { buildExpandedImagePreview, type ExpandedImagePreview } from "./ExpandedImagePreview";
149import {
150 attachVideoThumbnail,
151 buildExpandedImagePreview,
152 type ExpandedImagePreview,
153} from "./ExpandedImagePreview";
146154import { basenameOfPath } from "../../pierre-icons";
147155import { cn, randomUUID } from "~/lib/utils";
148156import { Separator } from "../ui/separator";
......@@ -153,6 +161,30 @@ import {
153161} from "./composerSubmission";
154162import { 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
156188type ComposerCommandMenuPosition = {
157189 bottom: number;
158190 left: number;
......@@ -259,6 +291,7 @@ import {
259291 FileIcon,
260292 PaperclipIcon,
261293 PencilRulerIcon,
294 PlayIcon,
262295 type LucideIcon,
263296 LockIcon,
264297 LockOpenIcon,
......@@ -280,7 +313,7 @@ import {
280313} from "../../providerInstances";
281314import { type AppModelOption, getAppModelOptionsForInstance } from "../../modelSelection";
282315import type { UnifiedSettings } from "@t3tools/contracts/settings";
283import type { SessionPhase, Thread } from "../../types";
316import { type SessionPhase, type Thread, videoMimeType } from "../../types";
284317import type { PendingUserInputDraftAnswer } from "../../pendingUserInput";
285318import type { PendingApproval, PendingUserInput } from "../../session-logic";
286319import type { ContextWindowSnapshot } from "../../lib/contextWindow";
......@@ -683,6 +716,8 @@ export interface ChatComposerProps {
683716 scheduleComposerFocus: () => void;
684717 setThreadError: (threadId: ThreadId | null, error: string | null) => void;
685718 onExpandImage: (preview: ExpandedImagePreview) => void;
719 onFileOpen: (attachment: ChatFileAttachment) => void;
720 openingVideoAttachmentId: string | null;
686721}
687722
688723// --------------------------------------------------------------------------
......@@ -762,6 +797,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
762797 scheduleComposerFocus,
763798 setThreadError,
764799 onExpandImage,
800 onFileOpen,
801 openingVideoAttachmentId,
765802 } = props;
766803 // ------------------------------------------------------------------
767804 // Store subscriptions (prompt / images / terminal contexts)
......@@ -774,6 +811,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
774811 const prompt = composerDraft.prompt;
775812 const composerImages = composerDraft.images;
776813 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 );
777820 const composerTerminalContexts = composerDraft.terminalContexts;
778821 const composerElementContexts = composerDraft.elementContexts;
779822 const composerPreviewAnnotations = composerDraft.previewAnnotations;
......@@ -2363,11 +2406,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
23632406 let restoredFileCount = 0;
23642407 const stashedFiles = entry.files ?? [];
23652408 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}`;
23712409 const composerFilesNow = composerFilesRef.current;
23722410 const existingFileIds = new Set(composerFilesNow.map((file) => file.id));
23732411 const retainedUploadIds = new Set(
......@@ -2375,16 +2413,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
23752413 file.uploadedAttachmentId ? [file.uploadedAttachmentId] : [],
23762414 ),
23772415 );
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>();
23822419 const duplicateFiles: PersistedComposerFileAttachment[] = [];
23832420 const markerReplacements: ComposerFileAttachment[] = [];
23842421 const appendedFiles: ComposerFileAttachment[] = [];
23852422 for (const file of stashedFiles) {
23862423 const expired = expiredAttachmentIds.has(file.attachmentId);
2387 const key = fileDedupKey(file);
2424 const key = composerFileDedupKey(file);
23882425 const restored: ComposerFileAttachment = {
23892426 type: "file",
23902427 id: file.id,
......@@ -2404,23 +2441,23 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
24042441 }
24052442 continue;
24062443 }
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);
24232457 }
2458 continue;
2459 }
2460 if (existingFileKeys.has(key)) {
24242461 if (!expired && !retainedUploadIds.has(file.attachmentId)) {
24252462 duplicateFiles.push(file);
24262463 }
......@@ -2956,22 +2993,34 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
29562993 // A pick that matches a needs-reattach marker replaces it in the draft, so
29572994 // it must not consume a slot; a draft full of markers would otherwise hit
29582995 // 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>();
29642998 const acceptedImages: File[] = [];
29652999 const acceptedFiles: ComposerFileAttachment[] = [];
29663000 let error: string | null = null;
29673001 for (const file of files) {
29683002 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) {
29753024 error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per message.`;
29763025 // Keep scanning: a later file in this batch can still replace a
29773026 // needs-reattach marker without needing a free slot.
......@@ -2996,16 +3045,20 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
29963045 error = fileAttachmentTooLargeMessage(file.name, fileStagingLimit);
29973046 continue;
29983047 }
3048 const attachmentFile =
3049 file.type === fileMimeType
3050 ? file
3051 : new File([file], file.name, { type: fileMimeType, lastModified: file.lastModified });
29993052 acceptedFiles.push({
30003053 type: "file",
30013054 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,
30063059 });
30073060 }
3008 if (!replacesReattachMarker) {
3061 if (!matchingReattachMarker) {
30093062 reservedCount += 1;
30103063 }
30113064 }
......@@ -3686,10 +3739,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
36863739 {!isComposerCollapsedMobile &&
36873740 !isComposerApprovalState &&
36883741 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 )) && (
36933747 <div className="mb-3 flex flex-wrap gap-2">
36943748 {composerImages
36953749 .filter(
......@@ -3800,15 +3854,102 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
38003854 </div>
38013855 );
38023856 })}
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 })}
38033944 </div>
38043945 )}
38053946
38063947 {!isComposerCollapsedMobile &&
38073948 !isComposerApprovalState &&
38083949 pendingUserInputs.length === 0 &&
3809 composerFiles.length > 0 && (
3950 composerOtherFiles.length > 0 && (
38103951 <div className="mb-3 flex flex-col gap-1">
3811 {composerFiles.map((file) => {
3952 {composerOtherFiles.map((file) => {
38123953 const fileCanUpload =
38133954 supportsAttachmentUploads &&
38143955 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 @@
11import { memo, useCallback, useEffect, useState } from "react";
2import { ChevronLeftIcon, ChevronRightIcon, XIcon } from "lucide-react";
2import { ChevronLeftIcon, ChevronRightIcon, DownloadIcon, XIcon } from "lucide-react";
33import { Button } from "../ui/button";
4import type { ExpandedImagePreview } from "./ExpandedImagePreview";
4import { downloadVideoPreview, type ExpandedImagePreview } from "./ExpandedImagePreview";
55
66interface ExpandedImageDialogProps {
77 preview: ExpandedImagePreview;
......@@ -13,12 +13,27 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
1313 onClose,
1414}: ExpandedImageDialogProps) {
1515 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);
1619 const index = (preview.index + imageOffset + preview.images.length) % preview.images.length;
1720
1821 const navigateImage = useCallback((direction: -1 | 1) => {
1922 setImageOffset((current) => current + direction);
2023 }, []);
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
2237 useEffect(() => {
2338 const onKeyDown = (event: globalThis.KeyboardEvent) => {
2439 if (event.key === "Escape") {
......@@ -45,18 +60,21 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
4560
4661 const item = preview.images[index];
4762 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;
4967 return (
5068 <div
5169 className="fixed inset-0 z-50 flex items-center justify-center bg-black/75 px-4 py-6 [-webkit-app-region:no-drag]"
5270 role="dialog"
5371 aria-modal="true"
54 aria-label="Expanded image preview"
72 aria-label={`Expanded ${mediaLabel} preview`}
5573 >
5674 <button
5775 type="button"
5876 className="absolute inset-0 z-0 cursor-zoom-out"
59 aria-label="Close image preview"
77 aria-label={`Close ${mediaLabel} preview`}
6078 onClick={onClose}
6179 />
6280 {preview.images.length > 1 && (
......@@ -78,16 +96,49 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
7896 variant="ghost"
7997 className="absolute right-2 top-2"
8098 onClick={onClose}
81 aria-label="Close image preview"
99 aria-label={`Close ${mediaLabel} preview`}
82100 >
83101 <XIcon />
84102 </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 )}
91142 <p className="mt-2 max-w-[92vw] truncate text-center text-xs text-muted-foreground/80">
92143 {item.name}
93144 {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 @@
1import type { ComposerFileAttachment } from "../../composerDraftStore";
2import { type ChatImageAttachment, isVideoAttachment } from "../../types";
3
14export interface ExpandedImageItem {
25 src: string;
36 name: string;
7 type?: "video";
48}
59
610export interface ExpandedImagePreview {
......@@ -8,12 +12,38 @@ export interface ExpandedImagePreview {
812 index: number;
913}
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
1132export function buildExpandedImagePreview(
12 images: ReadonlyArray<{ id: string; name: string; previewUrl?: string }>,
33 images: ReadonlyArray<ChatImageAttachment | ComposerFileAttachment>,
1334 selectedImageId: string,
1435): 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 }
1543 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 : [],
1747 );
1848 if (previewableImages.length === 0) {
1949 return null;
apps/web/src/components/chat/MessagesTimeline.test.tsx+38
......@@ -187,6 +187,7 @@ function buildProps() {
187187 revertTurnCountByUserMessageId: new Map(),
188188 onRevertUserMessage: () => {},
189189 isRevertingCheckpoint: false,
190 openingVideoAttachmentId: null,
190191 onImageExpand: () => {},
191192 activeThreadEnvironmentId: ACTIVE_THREAD_ENVIRONMENT_ID,
192193 markdownCwd: undefined,
......@@ -586,6 +587,43 @@ describe("MessagesTimeline", () => {
586587 expect(markup).not.toContain('alt="report.pdf"');
587588 });
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 });
589627 it("renders a file download button without creating its URL in advance", () => {
590628 const entry = {
591629 ...buildUserTimelineEntry("Read the report."),
apps/web/src/components/chat/MessagesTimeline.tsx+48-10
......@@ -16,7 +16,7 @@ import {
1616
1717const EMPTY_AGENT_PANEL_MODEL = emptyAgentPanelModel();
1818const NOOP_OPEN_AGENTS = () => {};
19const NOOP_DOWNLOAD_ATTACHMENT = (_attachment: ChatFileAttachment) => {};
19const NOOP_OPEN_ATTACHMENT = (_attachment: ChatFileAttachment) => {};
2020import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";
2121import {
2222 createContext,
......@@ -45,6 +45,7 @@ import {
4545 type ChatImageAttachment,
4646 isFileAttachment,
4747 isImageAttachment,
48 isVideoAttachment,
4849 type TurnDiffSummary,
4950} from "../../types";
5051import {
......@@ -67,6 +68,7 @@ import {
6768 MessageCircleIcon,
6869 MousePointerClickIcon,
6970 PaintbrushIcon,
71 PlayIcon,
7072 SearchIcon,
7173 SquarePenIcon,
7274 TerminalIcon,
......@@ -153,7 +155,8 @@ interface TimelineRowSharedState {
153155 activeThreadEnvironmentId: EnvironmentId;
154156 onRevertUserMessage: (messageId: MessageId) => void;
155157 onImageExpand: (preview: ExpandedImagePreview) => void;
156 onFileDownload: (attachment: ChatFileAttachment) => void;
158 onFileOpen: (attachment: ChatFileAttachment) => void;
159 openingVideoAttachmentId: string | null;
157160 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;
158161 onToggleTurnFold: (turnId: TurnId) => void;
159162 onToggleWorkGroup: (groupId: string, anchorKey: string) => void;
......@@ -232,7 +235,8 @@ interface MessagesTimelineProps {
232235 onRevertUserMessage: (messageId: MessageId) => void;
233236 isRevertingCheckpoint: boolean;
234237 onImageExpand: (preview: ExpandedImagePreview) => void;
235 onFileDownload?: (attachment: ChatFileAttachment) => void;
238 onFileOpen?: (attachment: ChatFileAttachment) => void;
239 openingVideoAttachmentId: string | null;
236240 activeThreadEnvironmentId: EnvironmentId;
237241 markdownCwd: string | undefined;
238242 resolvedTheme: "light" | "dark";
......@@ -278,7 +282,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({
278282 onRevertUserMessage,
279283 isRevertingCheckpoint,
280284 onImageExpand,
281 onFileDownload = NOOP_DOWNLOAD_ATTACHMENT,
285 onFileOpen = NOOP_OPEN_ATTACHMENT,
286 openingVideoAttachmentId,
282287 activeThreadEnvironmentId,
283288 markdownCwd,
284289 resolvedTheme,
......@@ -537,7 +542,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({
537542 activeThreadEnvironmentId,
538543 onRevertUserMessage,
539544 onImageExpand,
540 onFileDownload,
545 onFileOpen,
546 openingVideoAttachmentId,
541547 onOpenTurnDiff,
542548 onToggleTurnFold,
543549 onToggleWorkGroup,
......@@ -554,7 +560,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({
554560 activeThreadEnvironmentId,
555561 onRevertUserMessage,
556562 onImageExpand,
557 onFileDownload,
563 onFileOpen,
564 openingVideoAttachmentId,
558565 onOpenTurnDiff,
559566 onToggleTurnFold,
560567 onToggleWorkGroup,
......@@ -1005,6 +1012,8 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
10051012 // comparisons) split it. Unknown types render as inert rows below the files.
10061013 const userImages = (row.message.attachments ?? []).filter(isImageAttachment);
10071014 const userFiles = (row.message.attachments ?? []).filter(isFileAttachment);
1015 const userVideos = userFiles.filter(isVideoAttachment);
1016 const otherUserFiles = userFiles.filter((file) => !isVideoAttachment(file));
10081017 const unknownAttachments = (row.message.attachments ?? []).filter(
10091018 (attachment) => !isImageAttachment(attachment) && !isFileAttachment(attachment),
10101019 );
......@@ -1030,7 +1039,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
10301039 return (
10311040 <div className="group flex flex-col items-end gap-1">
10321041 <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) && (
10341043 <div className="mb-2 grid max-w-[420px] grid-cols-2 gap-2">
10351044 {regularImages.map((image) => (
10361045 <div
......@@ -1061,6 +1070,35 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
10611070 )}
10621071 </div>
10631072 ))}
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 })}
10641102 </div>
10651103 )}
10661104 {previewAnnotations.map((annotation, index) => (
......@@ -1070,9 +1108,9 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
10701108 image={previewImages[index] ?? null}
10711109 />
10721110 ))}
1073 {userFiles.length > 0 || unknownAttachments.length > 0 ? (
1111 {otherUserFiles.length > 0 || unknownAttachments.length > 0 ? (
10741112 <div className="mb-2 flex flex-col gap-1">
1075 {userFiles.map((file) => {
1113 {otherUserFiles.map((file) => {
10761114 const content = (
10771115 <>
10781116 <FileIcon className="size-4 shrink-0 text-secondary-label" />
......@@ -1100,7 +1138,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
11001138 key={file.id}
11011139 type="button"
11021140 aria-label={`Download ${file.name}`}
1103 onClick={() => ctx.onFileDownload(file)}
1141 onClick={() => ctx.onFileOpen(file)}
11041142 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"
11051143 >
11061144 {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
22import { describe, expect, it } from "vite-plus/test";
33
44import type { ComposerFileAttachment, ComposerImageAttachment } from "../../composerDraftStore";
5import { isVideoAttachment, videoMimeType } from "../../types";
56import {
67 attachmentsToReleaseOnUploadCapabilityLoss,
78 classifyComposerAttachmentFile,
89 fileAttachmentCapabilityBlockReason,
910 fileAttachmentStagingLimit,
1011 inferImageMimeTypeFromName,
12 isPreviewableComposerVideo,
1113 normalizeComposerImageFileMimeType,
1214 shouldHandleComposerAttachmentPaste,
1315} from "./composerAttachmentFiles";
......@@ -267,6 +269,48 @@ describe("composer attachment files", () => {
267269 expect(released.map((attachment) => attachment.id)).toEqual(["image-1", "file-uploading"]);
268270 });
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
270314 it("claims image pastes even when clipboard text is present", () => {
271315 const image = new File(["image"], "photo.heic", { type: "image/heic" });
272316
apps/web/src/components/chat/composerAttachmentFiles.ts+13
......@@ -1,4 +1,5 @@
11import {
2 type EnvironmentId,
23 isProviderSendTurnSupportedImageMimeType,
34 PROVIDER_SEND_TURN_MAX_FILE_BYTES,
45} from "@t3tools/contracts";
......@@ -9,6 +10,7 @@ import {
910
1011import type { ComposerFileAttachment, ComposerImageAttachment } from "../../composerDraftStore";
1112import { isHeicImageFile } from "../../lib/imageCompression";
13import { isVideoAttachment } from "../../types";
1214
1315type ComposerAttachmentFileKind = "image" | "file" | "unsupported-image";
1416
......@@ -75,6 +77,17 @@ export function classifyComposerAttachmentFile(
7577 return isProviderSendTurnSupportedImageMimeType(file.type) ? "image" : "unsupported-image";
7678}
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
7891/** Byte limit for adding a generic file to the local composer draft. */
7992export function fileAttachmentStagingLimit(input: FileAttachmentCapabilityState): number | null {
8093 if (!input.attachmentUploadsCapabilityKnown) {
apps/web/src/components/pullRequest/PullRequestMarkdown.tsx+4-6
......@@ -10,12 +10,10 @@ import { splitPullRequestBody } from "./pullRequestMarkdown.logic";
1010 * A pull request body, rendered with the app's markdown renderer plus a card for each upload
1111 * embedded in it, which that renderer drops on the floor.
1212 *
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.
1917 */
2018export function PullRequestMarkdown({
2119 text,
apps/web/src/composerDraftStore.test.ts+58
......@@ -517,6 +517,34 @@ describe("composerDraftStore file attachments", () => {
517517 expect(files?.some(composerFileNeedsReattach)).toBe(false);
518518 });
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
520548 it("replaces a needs-reattach marker with a stash-restored uploaded file", () => {
521549 const store = useComposerDraftStore.getState();
522550 const marker: ComposerFileAttachment = { ...makeFile("file-marker"), file: null };
......@@ -553,6 +581,36 @@ describe("composerDraftStore file attachments", () => {
553581 ]);
554582 });
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
556614 it("keeps the remaining file slot available after a duplicate is skipped", () => {
557615 const store = useComposerDraftStore.getState();
558616 store.addImages(
apps/web/src/composerDraftStore.ts+36-18
......@@ -39,6 +39,7 @@ import {
3939 DEFAULT_RUNTIME_MODE,
4040 type ChatFileAttachment,
4141 type ChatImageAttachment,
42 videoMimeType,
4243} from "./types";
4344import {
4445 type TerminalContextDraft,
......@@ -745,6 +746,26 @@ function composerImageDedupKey(image: ComposerImageAttachment): string {
745746 return `${image.mimeType}\u0000${image.sizeBytes}\u0000${image.name}`;
746747}
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
748769function terminalContextDedupKey(context: TerminalContextDraft): string {
749770 return `${context.terminalId}\u0000${context.lineStart}\u0000${context.lineEnd}`;
750771}
......@@ -3189,29 +3210,32 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
31893210 const existing = state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft();
31903211 const knownIds = new Set(existing.files.map((file) => file.id));
31913212 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]),
31963214 );
31973215 const accepted: ComposerFileAttachment[] = [];
31983216 // Needs-reattach markers replaced in place by a re-pick, keyed by
31993217 // the marker's id.
32003218 const replacements = new Map<string, ComposerFileAttachment>();
32013219 for (const file of files) {
3202 const key = `${file.mimeType}\u0000${file.sizeBytes}\u0000${file.name}`;
3220 const key = composerFileDedupKey(file);
32033221 if (knownIds.has(file.id)) {
32043222 continue;
32053223 }
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 );
32073232 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.
32123235 if (composerFileNeedsReattach(duplicate) && !replacements.has(duplicate.id)) {
32133236 replacements.set(duplicate.id, file);
32143237 knownIds.add(file.id);
3238 knownFiles.set(key, file);
32153239 }
32163240 continue;
32173241 }
......@@ -3802,18 +3826,12 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
38023826 // A file the destination already holds (same id, or same
38033827 // metadata key) stays behind instead of duplicating there.
38043828 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));
38103830 const transferableFiles = source.files.filter(
38113831 (file) =>
38123832 (file.file !== null || file.uploadEnvironmentId === destinationEnvironmentId) &&
38133833 !destinationFileIds.has(file.id) &&
3814 !destinationFileKeys.has(
3815 `${file.mimeType}\u0000${file.sizeBytes}\u0000${file.name}`,
3816 ),
3834 !destinationFileKeys.has(composerFileDedupKey(file)),
38173835 );
38183836 const remainingAttachmentSlots = Math.max(
38193837 0,
apps/web/src/types.ts+25
......@@ -59,6 +59,31 @@ export function isFileAttachment(attachment: ChatAttachment): attachment is Chat
5959 return attachment.type === "file";
6060}
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
6287export interface ChatMessage extends Omit<OrchestrationMessage, "attachments"> {
6388 readonly attachments?: ReadonlyArray<ChatAttachment> | undefined;
6489}
docs/user/composer.md+3
......@@ -13,6 +13,9 @@ On web and desktop, attachments upload as soon as you add them. The send button
1313after every upload finishes. Failed uploads can be retried or removed. On mobile, attachments are
1414currently 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
1619If you reload before a file finishes uploading, the draft keeps the file's name and shows **Attach
1720again** next to it. Attach the file again or remove it, then send.
1821