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
141 changes: 127 additions & 14 deletions app/components/AllFilesDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
"use client";

import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useMemo } from "react";
import { X, Download, Circle, CircleCheck, File } from "lucide-react";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { useConvex, useAction } from "convex/react";
import { api } from "@/convex/_generated/api";
import { useFileUrlCacheContext } from "@/app/contexts/FileUrlCacheContext";
import type { FilePart } from "@/types/file";
import JSZip from "jszip";

Expand All @@ -27,21 +30,23 @@ interface FileItemProps {
isSelected: boolean;
selectionMode: boolean;
onToggle: () => void;
fileUrl: string | null;
}

const FileItem = ({
file,
isSelected,
selectionMode,
onToggle,
fileUrl,
}: FileItemProps) => {
const fileName = file.part.name || file.part.filename || "Unknown file";

const handleDownload = async () => {
if (!file.part.url) return;
if (!fileUrl) return;

try {
const response = await fetch(file.part.url);
const response = await fetch(fileUrl);
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
const link = document.createElement("a");
Expand Down Expand Up @@ -102,7 +107,7 @@ const FileItem = ({
</div>
</div>

{!selectionMode && file.part.url && (
{!selectionMode && fileUrl && (
<Button
onClick={handleDownload}
variant="ghost"
Expand All @@ -124,8 +129,93 @@ const AllFilesDialog = ({
files,
chatTitle,
}: AllFilesDialogProps) => {
const convex = useConvex();
const getFileUrlAction = useAction(api.s3Actions.getFileUrlAction);
const fileUrlCache = useFileUrlCacheContext();
const [selectionMode, setSelectionMode] = useState(false);
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
const [fileUrls, setFileUrls] = useState<Map<number, string>>(new Map());
const [isLoadingUrls, setIsLoadingUrls] = useState(false);

// Reset URLs when dialog closes
useEffect(() => {
if (!open) {
setFileUrls(new Map());
setIsLoadingUrls(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);

// Batch fetch all URLs when dialog opens
useEffect(() => {
if (!open) {
return;
}

let cancelled = false;

async function fetchAllUrls() {
if (cancelled) return;
setIsLoadingUrls(true);
const urlMap = new Map<number, string>();

// Fetch URLs in parallel
await Promise.all(
files.map(async (file, index) => {
// If already has URL, use it
if (file.part.url) {
urlMap.set(index, file.part.url);
return;
}

// Check cache first for fileId
if (file.part.fileId && fileUrlCache) {
const cachedUrl = fileUrlCache.getCachedUrl(file.part.fileId);
if (cachedUrl) {
urlMap.set(index, cachedUrl);
return;
}
}

// Fetch URL based on storage type
try {
let url: string | null = null;

if (file.part.fileId) {
// S3 file - fetch presigned URL
url = await getFileUrlAction({ fileId: file.part.fileId });
// Cache it
if (url && fileUrlCache) {
fileUrlCache.setCachedUrl(file.part.fileId, url);
}
} else if (file.part.storageId) {
// Convex storage file - fetch URL
url = await convex.query(api.fileStorage.getFileDownloadUrl, {
storageId: file.part.storageId,
});
}

if (url) {
urlMap.set(index, url);
}
} catch (error) {
console.error(`Failed to fetch URL for file ${index}:`, error);
}
}),
);

if (!cancelled) {
setFileUrls(urlMap);
setIsLoadingUrls(false);
}
}

fetchAllUrls();

return () => {
cancelled = true;
};
}, [open, files, getFileUrlAction, convex, fileUrlCache]);

// Reset selection when dialog closes
useEffect(() => {
Expand Down Expand Up @@ -169,30 +259,47 @@ const AllFilesDialog = ({
};

const handleBatchDownload = async () => {
const filesToDownload = files.filter((_, index) =>
selectedFiles.has(index.toString()),
);
const filesToDownload = files
.map((file, index) => ({ file, index }))
.filter(({ index }) => selectedFiles.has(index.toString()));

if (filesToDownload.length === 0) return;

try {
const zip = new JSZip();

// Add all files to the ZIP
// Use already fetched URLs or fetch missing ones
await Promise.all(
filesToDownload.map(async (file) => {
if (file.part.url) {
try {
const response = await fetch(file.part.url);
filesToDownload.map(async ({ file, index }) => {
try {
let url = fileUrls.get(index) || file.part.url;

// Fetch URL if not already available
if (!url) {
if (file.part.fileId) {
url = await getFileUrlAction({ fileId: file.part.fileId });
} else if (file.part.storageId) {
const fetchedUrl = await convex.query(
api.fileStorage.getFileDownloadUrl,
{
storageId: file.part.storageId,
},
);
url = fetchedUrl || undefined;
}
}

if (url) {
const response = await fetch(url);
const blob = await response.blob();
const fileName =
file.part.name ||
file.part.filename ||
`file-${file.partIndex}`;
zip.file(fileName, blob);
} catch (error) {
console.error(`Error adding ${file.part.name} to ZIP:`, error);
}
} catch (error) {
console.error(`Error adding ${file.part.name} to ZIP:`, error);
}
}),
);
Expand Down Expand Up @@ -300,10 +407,15 @@ const AllFilesDialog = ({
<div className="text-center text-muted-foreground py-8">
No files
</div>
) : isLoadingUrls ? (
<div className="text-center text-muted-foreground py-8">
Loading files...
</div>
) : (
files.map((file, index) => {
const fileId = index.toString();
const isSelected = selectedFiles.has(fileId);
const fileUrl = fileUrls.get(index) || file.part.url || null;

return (
<FileItem
Expand All @@ -312,6 +424,7 @@ const AllFilesDialog = ({
isSelected={isSelected}
selectionMode={selectionMode}
onToggle={() => handleToggleFile(fileId)}
fileUrl={fileUrl}
/>
);
})
Expand Down
21 changes: 9 additions & 12 deletions app/components/ChatHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,14 @@
import React, { useState } from "react";
import { Button } from "@/components/ui/button";
import { useAuth } from "@workos-inc/authkit-nextjs/components";
import { PanelLeft, Sparkle, SquarePen, HatGlasses, Split } from "lucide-react";
import {
PanelLeft,
Sparkle,
SquarePen,
HatGlasses,
Split,
Share,
} from "lucide-react";
import { useGlobalState } from "../contexts/GlobalState";
import { redirectToPricing } from "../hooks/usePricingDialog";
import { useRouter } from "next/navigation";
Expand Down Expand Up @@ -345,17 +352,7 @@ const ChatHeader: React.FC<ChatHeaderProps> = ({
className="relative mx-2 flex-shrink-0 rounded-full h-[34px] px-3 py-0 text-sm font-medium transition-colors hover:bg-[#ffffff1a] max-md:hidden"
>
<div className="flex w-full items-center justify-center gap-1.5">
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
aria-label=""
className="-ms-0.5"
>
<path d="M2.66821 12.6663V12.5003C2.66821 12.1331 2.96598 11.8353 3.33325 11.8353C3.70052 11.8353 3.99829 12.1331 3.99829 12.5003V12.6663C3.99829 13.3772 3.9992 13.8707 4.03052 14.2542C4.0612 14.6298 4.11803 14.8413 4.19849 14.9993L4.2688 15.1263C4.44511 15.4137 4.69813 15.6481 5.00024 15.8021L5.13013 15.8577C5.2739 15.9092 5.46341 15.947 5.74536 15.97C6.12888 16.0014 6.62221 16.0013 7.33325 16.0013H12.6663C13.3771 16.0013 13.8707 16.0014 14.2542 15.97C14.6295 15.9394 14.8413 15.8825 14.9993 15.8021L15.1262 15.7308C15.4136 15.5545 15.6481 15.3014 15.802 14.9993L15.8577 14.8695C15.9091 14.7257 15.9469 14.536 15.97 14.2542C16.0013 13.8707 16.0012 13.3772 16.0012 12.6663V12.5003C16.0012 12.1332 16.2991 11.8355 16.6663 11.8353C17.0335 11.8353 17.3313 12.1331 17.3313 12.5003V12.6663C17.3313 13.3553 17.3319 13.9124 17.2952 14.3626C17.2624 14.7636 17.1974 15.1247 17.053 15.4613L16.9866 15.6038C16.7211 16.1248 16.3172 16.5605 15.8215 16.8646L15.6038 16.9866C15.227 17.1786 14.8206 17.2578 14.3625 17.2952C13.9123 17.332 13.3553 17.3314 12.6663 17.3314H7.33325C6.64416 17.3314 6.0872 17.332 5.63696 17.2952C5.23642 17.2625 4.87552 17.1982 4.53931 17.054L4.39673 16.9866C3.87561 16.7211 3.43911 16.3174 3.13501 15.8216L3.01294 15.6038C2.82097 15.2271 2.74177 14.8206 2.70435 14.3626C2.66758 13.9124 2.66821 13.3553 2.66821 12.6663ZM9.33521 12.5003V4.9388L7.13696 7.13704C6.87732 7.39668 6.45625 7.39657 6.19653 7.13704C5.93684 6.87734 5.93684 6.45631 6.19653 6.19661L9.52954 2.86263L9.6311 2.77962C9.73949 2.70742 9.86809 2.66829 10.0002 2.66829C10.1763 2.66838 10.3454 2.73819 10.47 2.86263L13.804 6.19661C14.0633 6.45628 14.0634 6.87744 13.804 7.13704C13.5443 7.39674 13.1222 7.39674 12.8625 7.13704L10.6653 4.93977V12.5003C10.6651 12.8673 10.3673 13.1652 10.0002 13.1654C9.63308 13.1654 9.33538 12.8674 9.33521 12.5003Z" />
</svg>
<Share className="h-4 w-4 -ms-0.5" />
Share
</div>
</button>
Expand Down
13 changes: 2 additions & 11 deletions app/components/ChatItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Ellipsis, Trash2, Edit2, Split } from "lucide-react";
import { Ellipsis, Trash2, Edit2, Split, Share } from "lucide-react";
import { useMutation } from "convex/react";
import { api } from "@/convex/_generated/api";
import { removeDraft } from "@/lib/utils/client-storage";
Expand Down Expand Up @@ -305,16 +305,7 @@ const ChatItem: React.FC<ChatItemProps> = ({
Rename
</DropdownMenuItem>
<DropdownMenuItem onClick={handleShare}>
<svg
width="16"
height="16"
viewBox="0 0 20 20"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
className="mr-2 h-4 w-4"
>
<path d="M2.66821 12.6663V12.5003C2.66821 12.1331 2.96598 11.8353 3.33325 11.8353C3.70052 11.8353 3.99829 12.1331 3.99829 12.5003V12.6663C3.99829 13.3772 3.9992 13.8707 4.03052 14.2542C4.0612 14.6298 4.11803 14.8413 4.19849 14.9993L4.2688 15.1263C4.44511 15.4137 4.69813 15.6481 5.00024 15.8021L5.13013 15.8577C5.2739 15.9092 5.46341 15.947 5.74536 15.97C6.12888 16.0014 6.62221 16.0013 7.33325 16.0013H12.6663C13.3771 16.0013 13.8707 16.0014 14.2542 15.97C14.6295 15.9394 14.8413 15.8825 14.9993 15.8021L15.1262 15.7308C15.4136 15.5545 15.6481 15.3014 15.802 14.9993L15.8577 14.8695C15.9091 14.7257 15.9469 14.536 15.97 14.2542C16.0013 13.8707 16.0012 13.3772 16.0012 12.6663V12.5003C16.0012 12.1332 16.2991 11.8355 16.6663 11.8353C17.0335 11.8353 17.3313 12.1331 17.3313 12.5003V12.6663C17.3313 13.3553 17.3319 13.9124 17.2952 14.3626C17.2624 14.7636 17.1974 15.1247 17.053 15.4613L16.9866 15.6038C16.7211 16.1248 16.3172 16.5605 15.8215 16.8646L15.6038 16.9866C15.227 17.1786 14.8206 17.2578 14.3625 17.2952C13.9123 17.332 13.3553 17.3314 12.6663 17.3314H7.33325C6.64416 17.3314 6.0872 17.332 5.63696 17.2952C5.23642 17.2625 4.87552 17.1982 4.53931 17.054L4.39673 16.9866C3.87561 16.7211 3.43911 16.3174 3.13501 15.8216L3.01294 15.6038C2.82097 15.2271 2.74177 14.8206 2.70435 14.3626C2.66758 13.9124 2.66821 13.3553 2.66821 12.6663ZM9.33521 12.5003V4.9388L7.13696 7.13704C6.87732 7.39668 6.45625 7.39657 6.19653 7.13704C5.93684 6.87734 5.93684 6.45631 6.19653 6.19661L9.52954 2.86263L9.6311 2.77962C9.73949 2.70742 9.86809 2.66829 10.0002 2.66829C10.1763 2.66838 10.3454 2.73819 10.47 2.86263L13.804 6.19661C14.0633 6.45628 14.0634 6.87744 13.804 7.13704C13.5443 7.39674 13.1222 7.39674 12.8625 7.13704L10.6653 4.93977V12.5003C10.6651 12.8673 10.3673 13.1652 10.0002 13.1654C9.63308 13.1654 9.33538 12.8674 9.33521 12.5003Z" />
</svg>
<Share className="mr-2 h-4 w-4" />
Share
</DropdownMenuItem>
<DropdownMenuItem
Expand Down
51 changes: 31 additions & 20 deletions app/components/Messages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -183,25 +183,31 @@ export const Messages = ({
);

// Handler to show all files for a specific message
const handleShowAllFiles = useCallback((message: ChatMessage) => {
if (!message.fileDetails) return;

const files = message.fileDetails
.filter((file) => file.url)
.map((file, fileIndex) => ({
part: {
url: file.url!,
name: file.name,
filename: file.name,
mediaType: undefined,
},
partIndex: fileIndex,
messageId: message.id,
}));

setDialogFiles(files);
setShowAllFilesDialog(true);
}, []);
const handleShowAllFiles = useCallback(
(message: ChatMessage, fileDetails: FileDetails[]) => {
if (!fileDetails || fileDetails.length === 0) return;

const files = fileDetails
.filter((file) => file.url || file.storageId || file.s3Key)
.map((file, fileIndex) => ({
part: {
url: file.url ?? undefined,
storageId: file.storageId,
fileId: file.fileId,
s3Key: file.s3Key,
name: file.name,
filename: file.name,
mediaType: file.mediaType,
},
partIndex: fileIndex,
messageId: message.id,
}));

setDialogFiles(files);
setShowAllFilesDialog(true);
},
[],
);

// Handler for branching a message
const handleBranchMessage = useCallback(
Expand Down Expand Up @@ -440,7 +446,12 @@ export const Messages = ({
/>
{/* View all files button */}
<button
onClick={() => handleShowAllFiles(message)}
onClick={() =>
handleShowAllFiles(
message,
effectiveFileDetails || [],
)
}
className="h-[55px] ps-4 pe-1.5 w-full max-w-80 min-w-64 flex items-center gap-1.5 rounded-[12px] border-[0.5px] border-border bg-background hover:bg-secondary transition-colors"
type="button"
aria-label="View all files"
Expand Down
Loading