Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/desktop/src/electron/ElectronProtocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@ describe("ElectronProtocol", () => {
"http:",
"https:",
]);
assert.deepEqual(directives["media-src"], ["'self'", "t3code:", "blob:"]);
assert.deepEqual(directives["font-src"], ["'self'", "t3code:", "data:"]);
});
});
1 change: 1 addition & 0 deletions apps/desktop/src/electron/ElectronProtocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ export function makeDesktopContentSecurityPolicy(input: DesktopProtocolRegistrat
`script-src ${scriptSources.join(" ")}`,
`connect-src ${connectSources.join(" ")}`,
`img-src 'self' ${input.scheme}: blob: data: http: https:`,
`media-src 'self' ${input.scheme}: blob:`,
"style-src 'self' 'unsafe-inline'",
`font-src 'self' ${input.scheme}: data:`,
"worker-src 'self' blob:",
Expand Down
31 changes: 31 additions & 0 deletions apps/server/src/assets/AssetAccess.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,37 @@ describe("AssetAccess", () => {
}).pipe(Effect.provide(testLayer)),
);

it.effect("serves video attachments inline", () =>
Effect.gen(function* () {
const config = yield* ServerConfig.ServerConfig;
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const attachmentId = "thread-1-00000000-0000-4000-8000-000000000001-mp4";
const attachmentPath = path.join(config.attachmentsDir, `${attachmentId}.mp4`);
yield* fileSystem.makeDirectory(config.attachmentsDir, { recursive: true });
yield* fileSystem.writeFile(attachmentPath, new Uint8Array([1, 2, 3]));

const result = yield* issueAssetUrl({
resource: {
_tag: "attachment",
attachmentId,
fileName: "demo.mp4",
mimeType: "video/mp4",
},
});
const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
const separatorIndex = suffix.indexOf("/");

expect(
yield* resolveAsset(suffix.slice(0, separatorIndex), suffix.slice(separatorIndex + 1)),
).toEqual({
kind: "file",
path: attachmentPath,
fileName: "demo.mp4",
mimeType: "video/mp4",
});
}).pipe(Effect.provide(testLayer)),
);
it.effect("issues project favicon capabilities with a signed fallback", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
Expand Down
10 changes: 7 additions & 3 deletions apps/server/src/assets/AssetAccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ const SIGNING_SECRET_NAME = "asset-access-signing-key";
const ASSET_TOKEN_TTL_MS = 60 * 60 * 1000;
const PROJECT_FAVICON_TOKEN_BUCKET_MS = 30 * 60 * 1000;
const PROJECT_FAVICON_VERSION_PREFIX = "v";
const INLINE_VIDEO_MIME_TYPE_PATTERN = /^video\/[\w!#$&^.+-]+$/i;
const PREVIEW_ASSET_EXTENSIONS = new Set([
...WORKSPACE_BROWSER_PREVIEW_EXTENSIONS,
...WORKSPACE_IMAGE_PREVIEW_EXTENSIONS,
Expand Down Expand Up @@ -300,14 +301,17 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
});
}
// Generic files carry their extension inside the attachment id (that
// shape resolves the on-disk path); images do not. Only generic files
// download, images render inline in chat.
// shape resolves the on-disk path); images do not. Videos and images
// render inline; other generic files download.
const isGenericFile = parseAttachmentFileExtension(input.resource.attachmentId) !== null;
const isVideo =
input.resource.mimeType !== undefined &&
INLINE_VIDEO_MIME_TYPE_PATTERN.test(input.resource.mimeType);
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
claims = {
version: 1,
kind: "attachment",
attachmentId: input.resource.attachmentId,
...(isGenericFile ? { download: true } : {}),
...(isGenericFile && !isVideo ? { download: true } : {}),
...(input.resource.fileName !== undefined ? { fileName: input.resource.fileName } : {}),
...(input.resource.mimeType !== undefined ? { mimeType: input.resource.mimeType } : {}),
expiresAt,
Expand Down
7 changes: 7 additions & 0 deletions apps/server/src/http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,13 @@ describe("assetResponseHeaders", () => {
});
});

it("serves inline videos with their declared mime type", () => {
expect(assetResponseHeaders("/attachments/demo.bin", { mimeType: "video/mp4" })).toEqual({
"Cache-Control": "private, max-age=3600",
"Content-Type": "video/mp4",
"X-Content-Type-Options": "nosniff",
});
});
it("declares utf-8 for HTML assets so non-ASCII content renders correctly", () => {
expect(assetResponseHeaders("/workspace/page.html")).toHaveProperty(
"Content-Type",
Expand Down
14 changes: 9 additions & 5 deletions apps/server/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ const DOWNLOAD_MIME_TYPE_PATTERN = /^[\w!#$&^.+-]+\/[\w!#$&^.+-]+$/;
const isSafeDownloadMimeType = (mimeType: string): boolean =>
DOWNLOAD_MIME_TYPE_PATTERN.test(mimeType) &&
!/(?:^text\/html$|\/xml(?:$|-)|\+xml$)/i.test(mimeType.trim().toLowerCase());
const isSafeInlineVideoMimeType = (mimeType: string): boolean =>
DOWNLOAD_MIME_TYPE_PATTERN.test(mimeType) && mimeType.toLowerCase().startsWith("video/");

/** RFC 6266 disposition with an ASCII fallback name plus a UTF-8 `filename*`. */
export function downloadContentDisposition(fileName?: string): string {
Expand Down Expand Up @@ -98,9 +100,11 @@ export function assetResponseHeaders(
? options.mimeType
: "application/octet-stream",
}
: lowerPath.endsWith(".html") || lowerPath.endsWith(".htm")
? { "Content-Type": "text/html; charset=utf-8" }
: {}),
: options?.mimeType !== undefined && isSafeInlineVideoMimeType(options.mimeType)
? { "Content-Type": options.mimeType }
: lowerPath.endsWith(".html") || lowerPath.endsWith(".htm")
? { "Content-Type": "text/html; charset=utf-8" }
: {}),
...(!options?.download && lowerPath.endsWith(".svg")
? { "Content-Security-Policy": SVG_CONTENT_SECURITY_POLICY }
: {}),
Expand Down Expand Up @@ -276,9 +280,9 @@ export const assetRouteLayer = HttpRouter.add(
status: 200,
headers: assetResponseHeaders(
asset.path,
asset.download
asset.download || asset.mimeType !== undefined
? {
download: true,
...(asset.download ? { download: true } : {}),
...(asset.fileName !== undefined ? { fileName: asset.fileName } : {}),
...(asset.mimeType !== undefined ? { mimeType: asset.mimeType } : {}),
}
Expand Down
9 changes: 9 additions & 0 deletions apps/web/src/components/ChatView.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
dismissBranchMismatchForSession,
ENVIRONMENT_RECONNECT_WARNING_GRACE_MS,
getStartedThreadModelChangeBlockReason,
loadDesktopVideoPreviewUrl,
hasEnvironmentReconnectWarningGraceElapsed,
hasServerAcknowledgedLocalDispatch,
isBranchMismatchDismissedForSession,
Expand All @@ -41,6 +42,14 @@ import {
shouldWriteThreadErrorToCurrentServerThread,
} from "./ChatView.logic";

describe("loadDesktopVideoPreviewUrl", () => {
it("loads video bytes into an object URL", async () => {
const objectUrl = await loadDesktopVideoPreviewUrl("data:video/mp4;base64,AA==");
expect(objectUrl).toMatch(/^blob:/);
URL.revokeObjectURL(objectUrl);
});
});

const environmentId = EnvironmentId.make("environment-local");
const projectId = ProjectId.make("project-1");
const threadId = ThreadId.make("thread-1");
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/components/ChatView.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,12 @@ export function revokeBlobPreviewUrl(previewUrl: string | undefined): void {
URL.revokeObjectURL(previewUrl);
}

export async function loadDesktopVideoPreviewUrl(url: string): Promise<string> {
const response = await fetch(url);
if (!response.ok) throw new Error(`Could not load video (${response.status}).`);
return URL.createObjectURL(await response.blob());
}

export function revokeUserMessagePreviewUrls(message: ChatMessage): void {
if (message.role !== "user" || !message.attachments) {
return;
Expand Down
38 changes: 31 additions & 7 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ import {
MAX_TERMINALS_PER_GROUP,
type ChatMessage,
isImageAttachment,
isVideoAttachment,
type SessionPhase,
type Thread,
type TurnDiffSummary,
Expand Down Expand Up @@ -360,6 +361,7 @@ import {
cloneComposerImageForRetry,
deriveLockedProvider,
readFileAsDataUrl,
loadDesktopVideoPreviewUrl,
reconcileMountedTerminalThreadIds,
resolveBackgroundDraftWorkspaceOptions,
resolveDraftHeroState,
Expand Down Expand Up @@ -1440,6 +1442,11 @@ function ChatViewContent(props: ChatViewProps) {
const [isWorkspaceFileDragActive, setIsWorkspaceFileDragActive] = useState(false);
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);
useEffect(() => {
const item = expandedImage?.images[expandedImage.index];
if (item?.type !== "video" || !item.src.startsWith("blob:")) return;
return () => revokeBlobPreviewUrl(item.src);
}, [expandedImage]);
const [optimisticUserMessages, setOptimisticUserMessages] = useState<ChatMessage[]>([]);
const [feedbackSubmissionsByThreadKey, setFeedbackSubmissionsByThreadKey] = useState<
Record<string, ReadonlyArray<CodexFeedbackSubmission>>
Expand Down Expand Up @@ -2504,17 +2511,18 @@ function ChatViewContent(props: ChatViewProps) {
});
}, []);
const serverMessages = activeThread?.messages;
const downloadFileAttachment = useCallback(
const openFileAttachment = useCallback(
async (attachment: ChatFileAttachment) => {
const connection = readPreparedConnection(environmentId);
if (!connection) {
toastManager.add({ type: "error", title: "The environment is not connected." });
return;
}
const isVideo = isVideoAttachment(attachment);
const action = isVideo ? "play" : "download";

// fileName and mimeType ride in the signed claims so the download gets
// a real filename and Content-Type even when the anchor's `download`
// attribute is ignored (cross-origin environment servers).
// fileName and mimeType ride in the signed claims so videos render
// inline while other files keep their real download name and type.
const result = await createAttachmentAssetUrl({
environmentId,
input: {
Expand All @@ -2530,15 +2538,31 @@ function ChatViewContent(props: ChatViewProps) {
const error = squashAtomCommandFailure(result);
toastManager.add({
type: "error",
title: `Could not download ${attachment.name}`,
title: "Could not " + action + " " + attachment.name,
description: error instanceof Error ? error.message : "The attachment is unavailable.",
});
return;
}

const url = resolveAssetUrl(connection.httpBaseUrl, result.value.relativeUrl);
if (!url) {
toastManager.add({ type: "error", title: `Could not download ${attachment.name}` });
toastManager.add({ type: "error", title: "Could not " + action + " " + attachment.name });
return;
}
if (isVideo) {
try {
const previewUrl = window.desktopBridge ? await loadDesktopVideoPreviewUrl(url) : url;
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
setExpandedImage({
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
images: [{ src: previewUrl, name: attachment.name, type: "video" }],
index: 0,
});
} catch (error) {
toastManager.add({
type: "error",
title: "Could not play " + attachment.name,
description: error instanceof Error ? error.message : "The attachment is unavailable.",
});
}
return;
}
const anchor = document.createElement("a");
Expand Down Expand Up @@ -7139,7 +7163,7 @@ function ChatViewContent(props: ChatViewProps) {
onRevertUserMessage={onRevertUserMessage}
isRevertingCheckpoint={isRevertingCheckpoint}
onImageExpand={onExpandTimelineImage}
onFileDownload={downloadFileAttachment}
onFileOpen={openFileAttachment}
markdownCwd={gitCwd ?? undefined}
resolvedTheme={resolvedTheme}
timestampFormat={timestampFormat}
Expand Down
90 changes: 83 additions & 7 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,7 @@ import {
FileIcon,
PaperclipIcon,
PencilRulerIcon,
PlayIcon,
type LucideIcon,
LockIcon,
LockOpenIcon,
Expand All @@ -280,7 +281,7 @@ import {
} from "../../providerInstances";
import { type AppModelOption, getAppModelOptionsForInstance } from "../../modelSelection";
import type { UnifiedSettings } from "@t3tools/contracts/settings";
import type { SessionPhase, Thread } from "../../types";
import { isVideoAttachment, type SessionPhase, type Thread } from "../../types";
import type { PendingUserInputDraftAnswer } from "../../pendingUserInput";
import type { PendingApproval, PendingUserInput } from "../../session-logic";
import type { ContextWindowSnapshot } from "../../lib/contextWindow";
Expand Down Expand Up @@ -774,6 +775,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
const prompt = composerDraft.prompt;
const composerImages = composerDraft.images;
const composerFiles = composerDraft.files;
const composerVideos = composerFiles.filter(
(file) => file.file !== null && isVideoAttachment(file),
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
);
const composerOtherFiles = composerFiles.filter(
(file) => file.file === null || !isVideoAttachment(file),
);
const composerTerminalContexts = composerDraft.terminalContexts;
const composerElementContexts = composerDraft.elementContexts;
const composerPreviewAnnotations = composerDraft.previewAnnotations;
Expand Down Expand Up @@ -3686,10 +3693,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
{!isComposerCollapsedMobile &&
!isComposerApprovalState &&
pendingUserInputs.length === 0 &&
composerImages.some(
(image) =>
!composerPreviewAnnotations.some((annotation) => annotation.id === image.id),
) && (
(composerVideos.length > 0 ||
composerImages.some(
(image) =>
!composerPreviewAnnotations.some((annotation) => annotation.id === image.id),
)) && (
<div className="mb-3 flex flex-wrap gap-2">
{composerImages
.filter(
Expand Down Expand Up @@ -3800,15 +3808,83 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
</div>
);
})}
{composerVideos.map((file) => {
const fileCanUpload =
supportsAttachmentUploads &&
maxFileAttachmentBytes !== null &&
file.sizeBytes <= maxFileAttachmentBytes;
const upload = fileCanUpload ? uploadsByImageId[file.id] : undefined;
return (
<div
key={file.id}
className="relative h-16 w-16 overflow-hidden rounded-lg border border-border/80 bg-black"
>
<button
type="button"
className="flex h-full w-full cursor-zoom-in items-center justify-center text-white"
aria-label={`Play ${file.name}`}
onClick={() => {
const preview = buildExpandedImagePreview([file], file.id);
if (preview) onExpandImage(preview);
}}
>
<PlayIcon className="size-6 fill-current" />
</button>
{upload?.status === "uploading" && (
<span className="pointer-events-none absolute inset-x-0 bottom-0 bg-background/85 px-1 text-center text-[10px] text-foreground">
{formatAttachmentUploadProgress(upload.progress)}
</span>
)}
{upload?.status === "failed" && (
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-xs"
className="absolute bottom-1 left-1 bg-background/85 hover:bg-background/95"
onClick={() =>
retryAttachmentUpload({
environmentId,
image: file,
draftTarget: composerDraftTarget,
})
}
aria-label={`Retry upload for ${file.name}`}
/>
}
>
<RotateCcwIcon />
</TooltipTrigger>
<TooltipPopup
side="top"
className="max-w-64 whitespace-normal leading-tight"
>
{upload.reason}
</TooltipPopup>
</Tooltip>
)}
<Button
variant="ghost"
size="icon-xs"
className="absolute right-1 top-1 bg-background/80 hover:bg-background/90"
onClick={() => removeComposerFileFromDraft(file.id)}
aria-label={`Remove ${file.name}`}
>
<XIcon />
</Button>
</div>
);
})}
</div>
)}

{!isComposerCollapsedMobile &&
!isComposerApprovalState &&
pendingUserInputs.length === 0 &&
composerFiles.length > 0 && (
composerOtherFiles.length > 0 && (
<div className="mb-3 flex flex-col gap-1">
{composerFiles.map((file) => {
{composerOtherFiles.map((file) => {
const fileCanUpload =
supportsAttachmentUploads &&
maxFileAttachmentBytes !== null &&
Expand Down
Loading
Loading