Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
55 changes: 55 additions & 0 deletions apps/web/src/components/attachment-chip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { FileText, Loader2, Music, X } from "lucide-react";
import type { PendingAttachment } from "../hooks/use-file-attachments";

export function AttachmentChip({
attachment,
onRemove,
}: {
attachment: PendingAttachment;
onRemove: () => void;
}) {
const isImage = attachment.mimeType.startsWith("image/");
const isPdf = attachment.mimeType === "application/pdf";
const isAudio = attachment.mimeType.startsWith("audio/");

const Icon = isAudio ? Music : FileText;

return (
<div className="group relative flex h-8 shrink-0 items-center overflow-hidden border border-border bg-muted">
{isImage && attachment.previewUrl ? (
<img
src={attachment.previewUrl}
alt={attachment.fileName}
className="h-full w-auto object-cover"
/>
) : (isPdf || isAudio) ? (
<div className="flex items-center gap-1.5 px-2">
<Icon size={12} className="shrink-0 text-muted-foreground" />
<span className="max-w-[120px] truncate text-[11px] text-muted-foreground">
{attachment.fileName}
</span>
</div>
) : null}

{attachment.status === "uploading" && (
<div className="absolute inset-0 flex items-center justify-center bg-background/70">
<Loader2 size={10} className="animate-spin text-foreground" />
</div>
)}

{attachment.status === "error" && (
<div className="absolute inset-0 flex items-center justify-center bg-destructive/20">
<span className="text-[9px] font-medium text-destructive">Error</span>
</div>
)}

<button
type="button"
onClick={onRemove}
className="absolute right-0.5 top-0.5 flex h-3.5 w-3.5 items-center justify-center bg-background/80 text-foreground opacity-0 transition-opacity group-hover:opacity-100"
>
<X size={8} />
</button>
</div>
);
}
102 changes: 102 additions & 0 deletions apps/web/src/components/message-attachments.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { convexQuery } from "@convex-dev/react-query";
import { api } from "@harness/convex-backend/convex/_generated/api";
import type { Id } from "@harness/convex-backend/convex/_generated/dataModel";
import { useQuery } from "@tanstack/react-query";
import { FileText, Music } from "lucide-react";
import {
Dialog,
DialogContent,
DialogTrigger,
} from "./ui/dialog";

interface Attachment {
storageId: Id<"_storage">;
mimeType: string;
fileName: string;
fileSize: number;
}

function AttachmentItem({ attachment }: { attachment: Attachment }) {
const { data: url } = useQuery(
convexQuery(api.files.getFileUrl, { storageId: attachment.storageId }),
);

if (!url) return null;

const square = "h-16 w-16 shrink-0 overflow-hidden rounded-lg border border-border";

if (attachment.mimeType.startsWith("image/")) {
return (
<Dialog>
<DialogTrigger asChild>
<button type="button" className={`${square} cursor-zoom-in bg-muted`}>
<img
src={url}
alt={attachment.fileName}
className="h-full w-full object-cover"
/>
</button>
</DialogTrigger>
<DialogContent
className="max-w-[90vw] border-0 bg-transparent p-0 shadow-none"
showCloseButton={false}
>
<img
src={url}
alt={attachment.fileName}
className="max-h-[90vh] max-w-full rounded-lg object-contain"
/>
</DialogContent>
</Dialog>
);
}

if (attachment.mimeType === "application/pdf") {
return (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className={`${square} flex flex-col items-center justify-center gap-1 bg-muted px-1 transition-colors hover:bg-muted/80`}
>
<FileText size={20} className="shrink-0 text-muted-foreground" />
<span className="w-full truncate text-center text-[9px] leading-tight text-muted-foreground">
{attachment.fileName}
</span>
</a>
);
}

if (attachment.mimeType.startsWith("audio/")) {
return (
<div className="flex w-56 shrink-0 flex-col gap-1 rounded-lg border border-border bg-muted p-2">
<div className="flex items-center gap-1.5">
<Music size={14} className="shrink-0 text-muted-foreground" />
<span className="truncate text-[11px] text-muted-foreground">
{attachment.fileName}
</span>
</div>
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<audio controls src={url} className="h-8 w-full" preload="metadata" />
</div>
);
}

return null;
}

export function MessageAttachments({
attachments,
}: {
attachments: Attachment[];
}) {
if (attachments.length === 0) return null;

return (
<div className="mb-1.5 flex flex-wrap items-end justify-end gap-1.5">
{attachments.map((a) => (
<AttachmentItem key={a.storageId} attachment={a} />
))}
</div>
);
}
165 changes: 165 additions & 0 deletions apps/web/src/hooks/use-file-attachments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import { api } from "@harness/convex-backend/convex/_generated/api";
import type { Id } from "@harness/convex-backend/convex/_generated/dataModel";
import { useConvex } from "convex/react";
import { useCallback, useRef, useState } from "react";
import toast from "react-hot-toast";

const MAX_IMAGE_BYTES = 10 * 1024 * 1024; // 10 MB
const MAX_PDF_BYTES = 20 * 1024 * 1024; // 20 MB
const MAX_AUDIO_BYTES = 25 * 1024 * 1024; // 25 MB
const MAX_ATTACHMENTS = 5;

function getMaxBytes(mimeType: string): number {
if (mimeType === "application/pdf") return MAX_PDF_BYTES;
if (mimeType.startsWith("audio/")) return MAX_AUDIO_BYTES;
return MAX_IMAGE_BYTES;
}

function getSizeLabel(mimeType: string): string {
if (mimeType === "application/pdf") return "20 MB";
if (mimeType.startsWith("audio/")) return "25 MB";
return "10 MB";
}

export interface PendingAttachment {
localId: string;
previewUrl: string | null; // object URL for images, null for PDFs/audio
mimeType: string;
status: "uploading" | "ready" | "error";
storageId?: string;
fileName: string;
fileSize: number;
}

/**
* @param allowedMimes – the set of MIME types the current model accepts.
* Passed in from the caller so validation stays in sync with model capabilities.
*/
export function useFileAttachments(allowedMimes: Set<string>) {
const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
const convex = useConvex();
const localIdCounter = useRef(0);

const uploadOne = useCallback(
async (file: File, localId: string) => {
try {
const uploadUrl = await convex.mutation(
api.files.generateUploadUrl,
{},
);
const res = await fetch(uploadUrl, {
method: "POST",
headers: { "Content-Type": file.type },
body: file,
});
if (!res.ok) throw new Error("Upload failed");
const { storageId } = await res.json();
setAttachments((prev) =>
prev.map((a) =>
a.localId === localId ? { ...a, status: "ready", storageId } : a,
),
);
} catch {
setAttachments((prev) =>
prev.map((a) =>
a.localId === localId ? { ...a, status: "error" } : a,
),
);
toast.error(`Failed to upload ${file.name}`);
}
},
[convex],
);

const addFiles = useCallback(
(files: File[]) => {
let current = attachments.length;
for (const file of files) {
if (current >= MAX_ATTACHMENTS) {
toast.error(`Maximum ${MAX_ATTACHMENTS} attachments per message`);
break;
}
if (!allowedMimes.has(file.type)) {
toast.error(`${file.name}: not supported by this model`);
continue;
}
const maxBytes = getMaxBytes(file.type);
if (file.size > maxBytes) {
toast.error(`${file.name}: exceeds ${getSizeLabel(file.type)} limit`);
continue;
}

const localId = String(++localIdCounter.current);
const previewUrl = file.type.startsWith("image/")
? URL.createObjectURL(file)
: null;

setAttachments((prev) => [
...prev,
{
localId,
previewUrl,
mimeType: file.type,
status: "uploading",
fileName: file.name,
fileSize: file.size,
},
]);

// Fire and forget — state updates happen inside uploadOne
uploadOne(file, localId);
current++;
}
},
[attachments.length, uploadOne, allowedMimes],
);

const removeAttachment = useCallback((localId: string) => {
setAttachments((prev) => {
const target = prev.find((a) => a.localId === localId);
if (target?.previewUrl) URL.revokeObjectURL(target.previewUrl);
return prev.filter((a) => a.localId !== localId);
});
}, []);

const clearAttachments = useCallback(() => {
setAttachments((prev) => {
for (const a of prev) {
if (a.previewUrl) URL.revokeObjectURL(a.previewUrl);
}
return [];
});
}, []);

const hasUploading = attachments.some((a) => a.status === "uploading");

const resolveSignedUrls = useCallback(
async (
readyAttachments: Array<{
storageId: string;
mimeType: string;
fileName: string;
}>,
): Promise<Array<{ url: string; mime_type: string; file_name: string }>> => {
const results = await Promise.all(
readyAttachments.map(async (a) => {
const url = await convex.query(api.files.getFileUrl, {
storageId: a.storageId as Id<"_storage">,
});
return url ? { url, mime_type: a.mimeType, file_name: a.fileName } : null;
}),
);
return results.filter((r): r is NonNullable<typeof r> => r !== null);
},
[convex],
);

return {
attachments,
addFiles,
removeAttachment,
clearAttachments,
hasUploading,
resolveSignedUrls,
};
}
Loading
Loading