From fb405095d042afec07a6ffcb91cc31796f60259a Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Mon, 9 Mar 2026 03:48:23 -0400 Subject: [PATCH 01/18] feat(upload): reject Office documents with convert-to-PDF dialog - Add office-document-validation for Word (.doc, .docx), Excel (.xls, .xlsx), PowerPoint (.ppt, .pptx) - Create OfficeDocumentRejectedDialog with iLovePDF convert links per format - Reject Office docs at all entry points: chat adapter, file picker, dropzones, home, upload dialog - API routes /api/upload-file and /api/upload-url reject with appropriate convertUrl - Pre-filter password-protected PDFs in chat flows for consistent mixed-upload handling - Remove .doc, .docx, .xls, .xlsx from accept lists Made-with: Cursor --- src/app/api/upload-file/route.ts | 17 +- src/app/api/upload-url/route.ts | 14 ++ .../assistant-ui/AssistantDropzone.tsx | 57 ++++--- src/components/assistant-ui/attachment.tsx | 60 +++++-- src/components/home/HomeHeroDropzone.tsx | 22 ++- .../modals/OfficeDocumentRejectedDialog.tsx | 150 ++++++++++++++++++ src/components/modals/UploadDialog.tsx | 21 +++ src/components/providers.tsx | 2 + .../WorkspaceCanvasDropzone.tsx | 32 ++-- src/contexts/HomeAttachmentsContext.tsx | 29 +++- .../supabase-attachment-adapter.ts | 21 ++- src/lib/uploads/client-upload.ts | 21 +++ src/lib/uploads/office-document-validation.ts | 121 ++++++++++++++ 13 files changed, 517 insertions(+), 50 deletions(-) create mode 100644 src/components/modals/OfficeDocumentRejectedDialog.tsx create mode 100644 src/lib/uploads/office-document-validation.ts diff --git a/src/app/api/upload-file/route.ts b/src/app/api/upload-file/route.ts index 6d48fbf2..06c11b96 100644 --- a/src/app/api/upload-file/route.ts +++ b/src/app/api/upload-file/route.ts @@ -5,6 +5,10 @@ import { NextRequest, NextResponse } from 'next/server'; import { writeFile, mkdir } from 'fs/promises'; import { join } from 'path'; import { existsSync } from 'fs'; +import { + isOfficeDocument, + getOfficeDocumentConvertUrl, +} from "@/lib/uploads/office-document-validation"; export const maxDuration = 30; @@ -61,8 +65,17 @@ export async function POST(request: NextRequest) { ); } - // Accept all file types (removed image-only restriction) - // File type validation can be done client-side if needed + // Reject Office documents — convert to PDF at ilovepdf.com + const convertUrl = getOfficeDocumentConvertUrl(file); + if (convertUrl) { + return NextResponse.json( + { + error: "Word, Excel, and PowerPoint files are not supported. Convert to PDF first.", + convertUrl, + }, + { status: 400 } + ); + } // Validate file size (50MB limit) const maxSize = 50 * 1024 * 1024; // 50MB diff --git a/src/app/api/upload-url/route.ts b/src/app/api/upload-url/route.ts index 79edcb96..a264cf44 100644 --- a/src/app/api/upload-url/route.ts +++ b/src/app/api/upload-url/route.ts @@ -3,6 +3,7 @@ import { auth } from "@/lib/auth"; import { createClient } from '@supabase/supabase-js'; import { NextRequest, NextResponse } from 'next/server'; import { withApiLogging } from "@/lib/with-api-logging"; +import { getOfficeDocumentConvertUrlFromMeta } from "@/lib/uploads/office-document-validation"; export const maxDuration = 10; @@ -37,6 +38,19 @@ async function handlePOST(request: NextRequest) { ); } + // Reject Office documents — convert to PDF at ilovepdf.com + const contentType = body.contentType || ""; + const convertUrl = getOfficeDocumentConvertUrlFromMeta(filename, contentType); + if (convertUrl) { + return NextResponse.json( + { + error: "Word, Excel, and PowerPoint files are not supported. Convert to PDF first.", + convertUrl, + }, + { status: 400 } + ); + } + const storageType = process.env.STORAGE_TYPE || 'supabase'; if (storageType === 'local') { diff --git a/src/components/assistant-ui/AssistantDropzone.tsx b/src/components/assistant-ui/AssistantDropzone.tsx index 1a12fc71..fbd521b1 100644 --- a/src/components/assistant-ui/AssistantDropzone.tsx +++ b/src/components/assistant-ui/AssistantDropzone.tsx @@ -6,6 +6,9 @@ import { useWorkspaceStore } from "@/lib/stores/workspace-store"; import { Upload } from "lucide-react"; import { useCallback, useState, useRef } from "react"; import { toast } from "sonner"; +import { emitOfficeDocumentRejected } from "@/components/modals/OfficeDocumentRejectedDialog"; +import { emitPasswordProtectedPdf } from "@/components/modals/PasswordProtectedPdfDialog"; +import { filterPasswordProtectedPdfs } from "@/lib/uploads/pdf-validation"; interface AssistantDropzoneProps { children: React.ReactNode; @@ -87,11 +90,27 @@ export function AssistantDropzone({ children }: AssistantDropzoneProps) { return; } + // Reject password-protected PDFs so other files still upload + let filesToAdd = validFiles; + const pdfFiles = validFiles.filter((f) => f.type === "application/pdf" || f.name.toLowerCase().endsWith(".pdf")); + if (pdfFiles.length > 0) { + const { valid: unprotectedPdfs, rejected: protectedNames } = await filterPasswordProtectedPdfs(pdfFiles); + if (protectedNames.length > 0) { + emitPasswordProtectedPdf(protectedNames); + } + const nonPdfFiles = validFiles.filter((f) => f.type !== "application/pdf" && !f.name.toLowerCase().endsWith(".pdf")); + filesToAdd = [...nonPdfFiles, ...unprotectedPdfs]; + } + + if (filesToAdd.length === 0) { + return; + } + isProcessingRef.current = true; try { // Add each file to the composer - const addPromises = validFiles.map(async (file) => { + const addPromises = filesToAdd.map(async (file) => { try { await aui.composer().addAttachment(file); } catch (error) { @@ -107,8 +126,8 @@ export function AssistantDropzone({ children }: AssistantDropzoneProps) { await Promise.allSettled(addPromises); // Show success message if some files were rejected - if (validFiles.length < acceptedFiles.length) { - toast.success(`${validFiles.length} file${validFiles.length > 1 ? 's' : ''} added successfully`, { + if (filesToAdd.length < acceptedFiles.length) { + toast.success(`${filesToAdd.length} file${filesToAdd.length > 1 ? 's' : ''} added successfully`, { style: { color: '#fff' }, }); } @@ -144,12 +163,6 @@ export function AssistantDropzone({ children }: AssistantDropzoneProps) { 'image/*': ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp', '.heic', '.heif', '.avif', '.tiff', '.tif'], 'video/*': ['.mp4', '.webm', '.avi', '.mov', '.mkv'], 'application/pdf': ['.pdf'], - 'application/msword': ['.doc'], - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'], - 'application/vnd.ms-powerpoint': ['.ppt'], - 'application/vnd.openxmlformats-officedocument.presentationml.presentation': ['.pptx'], - 'application/vnd.ms-excel': ['.xls'], - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'], 'text/plain': ['.txt'], 'text/markdown': ['.md'], 'text/csv': ['.csv'], @@ -167,16 +180,24 @@ export function AssistantDropzone({ children }: AssistantDropzoneProps) { setIsDragging(false); handleDragEnd(); - // Show error for rejected files if (fileRejections.length > 0) { - const rejectedFileNames = fileRejections.map(rejection => rejection.file.name); - toast.error( - `The following file${rejectedFileNames.length > 1 ? 's are' : ' is'} not supported:\n${rejectedFileNames.join('\n')}\n\nSupported: Images, Videos, PDFs, Office documents, Text files`, - { - style: { color: '#fff' }, - duration: 5000, - } - ); + const wordFiles = fileRejections.filter((r) => isWordFile(r.file)); + const excelFiles = fileRejections.filter((r) => isExcelFile(r.file)); + const pptxFiles = fileRejections.filter((r) => isPptxFile(r.file)); + const hasOffice = wordFiles.length > 0 || excelFiles.length > 0 || pptxFiles.length > 0; + if (hasOffice) { + emitOfficeDocumentRejected({ + word: wordFiles.length ? wordFiles.map((r) => r.file.name) : undefined, + excel: excelFiles.length ? excelFiles.map((r) => r.file.name) : undefined, + powerpoint: pptxFiles.length ? pptxFiles.map((r) => r.file.name) : undefined, + }); + } else { + const rejectedFileNames = fileRejections.map((r) => r.file.name); + toast.error( + `The following file${rejectedFileNames.length > 1 ? "s are" : " is"} not supported:\n${rejectedFileNames.join("\n")}\n\nSupported: Images, Videos, PDFs, Text files`, + { style: { color: "#fff" }, duration: 5000 } + ); + } } }, }); diff --git a/src/components/assistant-ui/attachment.tsx b/src/components/assistant-ui/attachment.tsx index aa1019b2..f17f00b3 100644 --- a/src/components/assistant-ui/attachment.tsx +++ b/src/components/assistant-ui/attachment.tsx @@ -29,6 +29,15 @@ import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button import { cn } from "@/lib/utils"; import { useAttachmentUploadStore } from "@/lib/stores/attachment-upload-store"; import { useUIStore } from "@/lib/stores/ui-store"; +import { emitOfficeDocumentRejected } from "@/components/modals/OfficeDocumentRejectedDialog"; +import { emitPasswordProtectedPdf } from "@/components/modals/PasswordProtectedPdfDialog"; +import { + isWordFile, + isExcelFile, + isPptxFile, + isOfficeDocument, +} from "@/lib/uploads/office-document-validation"; +import { filterPasswordProtectedPdfs } from "@/lib/uploads/pdf-validation"; import { FaCheck } from "react-icons/fa"; const useFileSrc = (file: File | undefined) => { @@ -463,7 +472,7 @@ export const ComposerAddAttachment: FC = () => { - const handleFileChange = (e: React.ChangeEvent) => { + const handleFileChange = async (e: React.ChangeEvent) => { const files = e.target.files; if (!files || files.length === 0) return; @@ -479,25 +488,51 @@ export const ComposerAddAttachment: FC = () => { style: { color: '#fff' }, duration: 5000, }); - // Reset input - if (fileInputRef.current) { - fileInputRef.current.value = ""; - } + if (fileInputRef.current) fileInputRef.current.value = ""; return; } - // Validate each file + // Validate each file (size + reject Office documents) const validFiles: File[] = []; const oversizedFiles: string[] = []; + const officeWord: string[] = []; + const officeExcel: string[] = []; + const officePowerpoint: string[] = []; fileArray.forEach((file) => { if (file.size > MAX_FILE_SIZE_BYTES) { oversizedFiles.push(`${file.name} (${(file.size / (1024 * 1024)).toFixed(1)}MB)`); + } else if (isOfficeDocument(file)) { + if (isWordFile(file)) officeWord.push(file.name); + else if (isExcelFile(file)) officeExcel.push(file.name); + else officePowerpoint.push(file.name); } else { validFiles.push(file); } }); + if (officeWord.length > 0 || officeExcel.length > 0 || officePowerpoint.length > 0) { + emitOfficeDocumentRejected({ + word: officeWord.length ? officeWord : undefined, + excel: officeExcel.length ? officeExcel : undefined, + powerpoint: officePowerpoint.length ? officePowerpoint : undefined, + }); + } + + // Reject password-protected PDFs (so other files still upload) + let filesToAdd = validFiles; + if (validFiles.length > 0) { + const pdfFiles = validFiles.filter((f) => f.type === "application/pdf" || f.name.toLowerCase().endsWith(".pdf")); + if (pdfFiles.length > 0) { + const { valid: unprotectedPdfs, rejected: protectedNames } = await filterPasswordProtectedPdfs(pdfFiles); + if (protectedNames.length > 0) { + emitPasswordProtectedPdf(protectedNames); + } + const nonPdfFiles = validFiles.filter((f) => f.type !== "application/pdf" && !f.name.toLowerCase().endsWith(".pdf")); + filesToAdd = [...nonPdfFiles, ...unprotectedPdfs]; + } + } + // Show error for oversized files if (oversizedFiles.length > 0) { toast.error( @@ -509,20 +544,19 @@ export const ComposerAddAttachment: FC = () => { ); } - // Add valid files - if (validFiles.length > 0) { - validFiles.forEach((file) => { + // Add valid files (non–Office-doc, non–password-protected) — others still upload successfully + if (filesToAdd.length > 0) { + filesToAdd.forEach((file) => { aui.composer().addAttachment(file); }); - if (validFiles.length < fileArray.length) { - toast.success(`${validFiles.length} file${validFiles.length > 1 ? 's' : ''} added successfully`, { + if (filesToAdd.length < fileArray.length) { + toast.success(`${filesToAdd.length} file${filesToAdd.length > 1 ? 's' : ''} added successfully`, { style: { color: '#fff' }, }); } } - // Reset input if (fileInputRef.current) { fileInputRef.current.value = ""; } @@ -560,7 +594,7 @@ export const ComposerAddAttachment: FC = () => { className="sr-only" onChange={handleFileChange} multiple={true} - accept="image/*,video/*,.pdf,.doc,.docx,.ppt,.pptx,.xls,.xlsx,.txt,.md,.csv,.json,.heic,.heif,.avif,.tiff,.tif" + accept="image/*,video/*,.pdf,.txt,.md,.csv,.json,.heic,.heif,.avif,.tiff,.tif" /> diff --git a/src/components/home/HomeHeroDropzone.tsx b/src/components/home/HomeHeroDropzone.tsx index 3844823b..8aef6ece 100644 --- a/src/components/home/HomeHeroDropzone.tsx +++ b/src/components/home/HomeHeroDropzone.tsx @@ -5,6 +5,12 @@ import { useHomeAttachments } from "@/contexts/HomeAttachmentsContext"; import { Upload } from "lucide-react"; import { useCallback } from "react"; import { toast } from "sonner"; +import { emitOfficeDocumentRejected } from "@/components/modals/OfficeDocumentRejectedDialog"; +import { + isWordFile, + isExcelFile, + isPptxFile, +} from "@/lib/uploads/office-document-validation"; import { cn } from "@/lib/utils"; interface HomeHeroDropzoneProps { @@ -61,8 +67,20 @@ export function HomeHeroDropzone({ children, onFilesDropped }: HomeHeroDropzoneP }, onDropRejected: (fileRejections) => { if (fileRejections.length > 0) { - const names = fileRejections.map((r) => r.file.name).join(", "); - toast.error(`Only PDF, image, and audio files are supported. Rejected: ${names}`); + const wordFiles = fileRejections.filter((r) => isWordFile(r.file)); + const excelFiles = fileRejections.filter((r) => isExcelFile(r.file)); + const pptxFiles = fileRejections.filter((r) => isPptxFile(r.file)); + const hasOffice = wordFiles.length > 0 || excelFiles.length > 0 || pptxFiles.length > 0; + if (hasOffice) { + emitOfficeDocumentRejected({ + word: wordFiles.length ? wordFiles.map((r) => r.file.name) : undefined, + excel: excelFiles.length ? excelFiles.map((r) => r.file.name) : undefined, + powerpoint: pptxFiles.length ? pptxFiles.map((r) => r.file.name) : undefined, + }); + } else { + const names = fileRejections.map((r) => r.file.name).join(", "); + toast.error(`Only PDF, image, and audio files are supported. Rejected: ${names}`); + } } }, }); diff --git a/src/components/modals/OfficeDocumentRejectedDialog.tsx b/src/components/modals/OfficeDocumentRejectedDialog.tsx new file mode 100644 index 00000000..3fb97fb2 --- /dev/null +++ b/src/components/modals/OfficeDocumentRejectedDialog.tsx @@ -0,0 +1,150 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { FileWarning, ExternalLink } from "lucide-react"; +import { CONVERT_URLS } from "@/lib/uploads/office-document-validation"; + +export interface OfficeDocumentRejectedEvent { + word?: string[]; + excel?: string[]; + powerpoint?: string[]; +} + +type Listener = (e: OfficeDocumentRejectedEvent) => void; +const listeners = new Set(); + +export function emitOfficeDocumentRejected(event: OfficeDocumentRejectedEvent) { + if ( + (!event.word || event.word.length === 0) && + (!event.excel || event.excel.length === 0) && + (!event.powerpoint || event.powerpoint.length === 0) + ) { + return; + } + listeners.forEach((fn) => fn(event)); +} + +const SECTIONS: Array<{ + key: keyof OfficeDocumentRejectedEvent; + label: string; + extLabel: string; + convertUrl: string; +}> = [ + { + key: "word", + label: "Word", + extLabel: ".doc, .docx", + convertUrl: CONVERT_URLS.word, + }, + { + key: "excel", + label: "Excel", + extLabel: ".xls, .xlsx", + convertUrl: CONVERT_URLS.excel, + }, + { + key: "powerpoint", + label: "PowerPoint", + extLabel: ".ppt, .pptx", + convertUrl: CONVERT_URLS.powerpoint, + }, +]; + +/** + * Global dialog for rejected Office documents (Word, Excel, PowerPoint). + * Links users to iLovePDF to convert to PDF. + */ +export function OfficeDocumentRejectedDialog() { + const [open, setOpen] = useState(false); + const [event, setEvent] = useState({}); + + const handleEvent = useCallback((e: OfficeDocumentRejectedEvent) => { + setEvent(e); + setOpen(true); + }, []); + + useEffect(() => { + listeners.add(handleEvent); + return () => listeners.delete(handleEvent); + }, [handleEvent]); + + const sections = SECTIONS.filter((s) => { + const names = event[s.key]; + return names && names.length > 0; + }); + + const totalCount = sections.reduce( + (sum, s) => sum + (event[s.key]?.length ?? 0), + 0 + ); + + const title = + sections.length === 1 + ? `${sections[0].label}${(event[sections[0].key]?.length ?? 0) > 1 ? " files" : " file"} not supported` + : `${totalCount} Office files not supported`; + + const description = + sections.length === 1 + ? `We don't support ${sections[0].label} (${sections[0].extLabel}) files. Convert to PDF first, then upload.` + : "We don't support Word, Excel, or PowerPoint files. Convert them to PDF first, then upload."; + + return ( + + + + + + {title} + + {description} + + +
+ {sections.map((section) => { + const names = event[section.key] ?? []; + return ( +
+

+ {section.label} ({names.length}) +

+
    + {names.map((name, i) => ( +
  • + {name} +
  • + ))} +
+ +
+ ); + })} +
+ + + + +
+
+ ); +} diff --git a/src/components/modals/UploadDialog.tsx b/src/components/modals/UploadDialog.tsx index e1c91df3..8b7bfbe1 100644 --- a/src/components/modals/UploadDialog.tsx +++ b/src/components/modals/UploadDialog.tsx @@ -19,6 +19,12 @@ import { cn } from "@/lib/utils"; import { uploadFileDirect } from "@/lib/uploads/client-upload"; import { filterPasswordProtectedPdfs } from "@/lib/uploads/pdf-validation"; import { emitPasswordProtectedPdf } from "@/components/modals/PasswordProtectedPdfDialog"; +import { emitOfficeDocumentRejected } from "@/components/modals/OfficeDocumentRejectedDialog"; +import { + isWordFile, + isExcelFile, + isPptxFile, +} from "@/lib/uploads/office-document-validation"; interface UploadDialogProps { open: boolean; @@ -227,6 +233,21 @@ export function UploadDialog({ 'application/pdf': ['.pdf'], }, disabled: isUploading, + onDropRejected: (fileRejections) => { + if (fileRejections.length > 0) { + const wordFiles = fileRejections.filter((r) => isWordFile(r.file)); + const excelFiles = fileRejections.filter((r) => isExcelFile(r.file)); + const pptxFiles = fileRejections.filter((r) => isPptxFile(r.file)); + const hasOffice = wordFiles.length > 0 || excelFiles.length > 0 || pptxFiles.length > 0; + if (hasOffice) { + emitOfficeDocumentRejected({ + word: wordFiles.length ? wordFiles.map((r) => r.file.name) : undefined, + excel: excelFiles.length ? excelFiles.map((r) => r.file.name) : undefined, + powerpoint: pptxFiles.length ? pptxFiles.map((r) => r.file.name) : undefined, + }); + } + } + }, }); const handleKeyDown = useCallback((e: React.KeyboardEvent) => { diff --git a/src/components/providers.tsx b/src/components/providers.tsx index e509062f..2140b2cc 100644 --- a/src/components/providers.tsx +++ b/src/components/providers.tsx @@ -4,6 +4,7 @@ import { useRouter } from "next/navigation"; import { Toaster } from "@/components/ui/sonner"; import { PostHogIdentify } from "./providers/PostHogIdentify"; import { PasswordProtectedPdfDialog } from "@/components/modals/PasswordProtectedPdfDialog"; +import { OfficeDocumentRejectedDialog } from "@/components/modals/OfficeDocumentRejectedDialog"; import { TooltipProvider } from "@/components/ui/tooltip"; export function Providers({ children }: { children: React.ReactNode }) { @@ -14,6 +15,7 @@ export function Providers({ children }: { children: React.ReactNode }) { {children} + 0) { - const rejectedFileNames = fileRejections.map(rejection => rejection.file.name); - toast.error( - `Only PDF, image, and audio files can be dropped.\nRejected: ${rejectedFileNames.join(', ')}`, - { - style: { color: '#fff' }, - duration: 5000, - } - ); + const wordFiles = fileRejections.filter((r) => isWordFile(r.file)); + const excelFiles = fileRejections.filter((r) => isExcelFile(r.file)); + const pptxFiles = fileRejections.filter((r) => isPptxFile(r.file)); + const hasOffice = wordFiles.length > 0 || excelFiles.length > 0 || pptxFiles.length > 0; + if (hasOffice) { + emitOfficeDocumentRejected({ + word: wordFiles.length ? wordFiles.map((r) => r.file.name) : undefined, + excel: excelFiles.length ? excelFiles.map((r) => r.file.name) : undefined, + powerpoint: pptxFiles.length ? pptxFiles.map((r) => r.file.name) : undefined, + }); + } else { + const rejectedFileNames = fileRejections.map((r) => r.file.name); + toast.error( + `Only PDF, image, and audio files can be dropped.\nRejected: ${rejectedFileNames.join(", ")}`, + { style: { color: "#fff" }, duration: 5000 } + ); + } } }, }); diff --git a/src/contexts/HomeAttachmentsContext.tsx b/src/contexts/HomeAttachmentsContext.tsx index 2d26c7b5..6666f5e4 100644 --- a/src/contexts/HomeAttachmentsContext.tsx +++ b/src/contexts/HomeAttachmentsContext.tsx @@ -12,6 +12,14 @@ import { } from "react"; import { toast } from "sonner"; import { filterPasswordProtectedPdfs } from "@/lib/uploads/pdf-validation"; +import { + isOfficeDocument, + isWordFile, + isExcelFile, + isPptxFile, +} from "@/lib/uploads/office-document-validation"; +import { emitOfficeDocumentRejected } from "@/components/modals/OfficeDocumentRejectedDialog"; +import { emitPasswordProtectedPdf } from "@/components/modals/PasswordProtectedPdfDialog"; import { uploadFileDirect } from "@/lib/uploads/client-upload"; const MAX_TOTAL_FILE_BYTES = 50 * 1024 * 1024; // 50 MB @@ -88,11 +96,24 @@ export function HomeAttachmentsProvider({ children }: { children: ReactNode }) { const addFiles = useCallback(async (newFiles: File[]) => { if (newFiles.length === 0) return; - const pdfFiles = newFiles.filter( + // Reject Office documents — show dialog, don't add + const officeWord = newFiles.filter(isWordFile).map((f) => f.name); + const officeExcel = newFiles.filter(isExcelFile).map((f) => f.name); + const officePowerpoint = newFiles.filter(isPptxFile).map((f) => f.name); + if (officeWord.length > 0 || officeExcel.length > 0 || officePowerpoint.length > 0) { + emitOfficeDocumentRejected({ + word: officeWord.length ? officeWord : undefined, + excel: officeExcel.length ? officeExcel : undefined, + powerpoint: officePowerpoint.length ? officePowerpoint : undefined, + }); + } + const withoutOffice = newFiles.filter((f) => !isOfficeDocument(f)); + + const pdfFiles = withoutOffice.filter( (f) => f.type === "application/pdf" || f.name.toLowerCase().endsWith(".pdf") ); - const nonPdfFiles = newFiles.filter( + const nonPdfFiles = withoutOffice.filter( (f) => f.type !== "application/pdf" && !f.name.toLowerCase().endsWith(".pdf") ); @@ -100,9 +121,7 @@ export function HomeAttachmentsProvider({ children }: { children: ReactNode }) { const { valid: validPdfs, rejected: protectedNames } = await filterPasswordProtectedPdfs(pdfFiles); if (protectedNames.length > 0) { - toast.error( - `Password-protected PDFs cannot be uploaded: ${protectedNames.join(", ")}` - ); + emitPasswordProtectedPdf(protectedNames); } const toAdd = [...validPdfs, ...nonPdfFiles]; if (toAdd.length === 0) return; diff --git a/src/lib/attachments/supabase-attachment-adapter.ts b/src/lib/attachments/supabase-attachment-adapter.ts index a2f22c65..658a4bdd 100644 --- a/src/lib/attachments/supabase-attachment-adapter.ts +++ b/src/lib/attachments/supabase-attachment-adapter.ts @@ -5,7 +5,14 @@ import type { } from "@assistant-ui/react"; import { uploadFileDirect } from "@/lib/uploads/client-upload"; import { isPasswordProtectedPdf } from "@/lib/uploads/pdf-validation"; +import { + isOfficeDocument, + isWordFile, + isExcelFile, + isPptxFile, +} from "@/lib/uploads/office-document-validation"; import { emitPasswordProtectedPdf } from "@/components/modals/PasswordProtectedPdfDialog"; +import { emitOfficeDocumentRejected } from "@/components/modals/OfficeDocumentRejectedDialog"; import { useAttachmentUploadStore } from "@/lib/stores/attachment-upload-store"; const getUploadStore = () => useAttachmentUploadStore.getState(); @@ -21,7 +28,7 @@ const MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024; // 50MB to match server limit */ export class SupabaseAttachmentAdapter implements AttachmentAdapter { accept = - "image/*,video/*,audio/*,.pdf,.doc,.docx,.ppt,.pptx,.xls,.xlsx,.txt,.md,.csv,.json,.mp3,.wav,.ogg,.aac,.flac,.aiff,.webm,.m4a"; + "image/*,video/*,audio/*,.pdf,.txt,.md,.csv,.json,.mp3,.wav,.ogg,.aac,.flac,.aiff,.webm,.m4a"; // Map of attachment ID → upload promise (started eagerly in add()) private pendingUploads = new Map>(); @@ -33,6 +40,18 @@ export class SupabaseAttachmentAdapter implements AttachmentAdapter { ); } + // Reject Office documents — convert to PDF at ilovepdf.com + if (isOfficeDocument(file)) { + const word = isWordFile(file) ? [file.name] : undefined; + const excel = isExcelFile(file) ? [file.name] : undefined; + const powerpoint = isPptxFile(file) ? [file.name] : undefined; + emitOfficeDocumentRejected({ word, excel, powerpoint }); + const format = word ? "Word" : excel ? "Excel" : "PowerPoint"; + throw new Error( + `"${file.name}" is a ${format} file. ${format} files are not supported. Convert to PDF first.` + ); + } + // Reject password-protected PDFs if (await isPasswordProtectedPdf(file)) { emitPasswordProtectedPdf([file.name]); diff --git a/src/lib/uploads/client-upload.ts b/src/lib/uploads/client-upload.ts index e87f4618..c4744a67 100644 --- a/src/lib/uploads/client-upload.ts +++ b/src/lib/uploads/client-upload.ts @@ -12,6 +12,13 @@ */ import { convertHeicToJpegIfNeeded } from "./convert-heic"; +import { isOfficeDocument } from "./office-document-validation"; +import { + isWordFile, + isExcelFile, + isPptxFile, +} from "./office-document-validation"; +import { emitOfficeDocumentRejected } from "@/components/modals/OfficeDocumentRejectedDialog"; const MAX_FILE_SIZE_BYTES = 200 * 1024 * 1024; // 200MB @@ -57,6 +64,13 @@ export async function uploadFileDirect( if (!urlResponse.ok) { const errorData = await urlResponse.json().catch(() => ({})); + if (urlResponse.status === 400 && isOfficeDocument(file)) { + emitOfficeDocumentRejected({ + word: isWordFile(file) ? [file.name] : undefined, + excel: isExcelFile(file) ? [file.name] : undefined, + powerpoint: isPptxFile(file) ? [file.name] : undefined, + }); + } throw new Error( errorData.error || `Failed to get upload URL: ${urlResponse.statusText}` ); @@ -131,6 +145,13 @@ async function uploadViaApiRoute(file: File): Promise { if (!response.ok) { const errorData = await response.json().catch(() => ({})); + if (response.status === 400 && isOfficeDocument(file)) { + emitOfficeDocumentRejected({ + word: isWordFile(file) ? [file.name] : undefined, + excel: isExcelFile(file) ? [file.name] : undefined, + powerpoint: isPptxFile(file) ? [file.name] : undefined, + }); + } throw new Error( errorData.error || `Upload failed: ${response.statusText}` ); diff --git a/src/lib/uploads/office-document-validation.ts b/src/lib/uploads/office-document-validation.ts new file mode 100644 index 00000000..35e6064e --- /dev/null +++ b/src/lib/uploads/office-document-validation.ts @@ -0,0 +1,121 @@ +/** + * Validation for Office documents (Word, Excel, PowerPoint). + * These are rejected — users can convert to PDF at iLovePDF. + */ + +export const CONVERT_URLS = { + word: "https://www.ilovepdf.com/word_to_pdf", + excel: "https://www.ilovepdf.com/excel_to_pdf", + powerpoint: "https://www.ilovepdf.com/powerpoint_to_pdf", +} as const; + +export type OfficeDocumentType = keyof typeof CONVERT_URLS; + +// Word: .doc, .docx +const WORD_MIMES = [ + "application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", +]; +const WORD_EXTS = [".doc", ".docx"]; + +// Excel: .xls, .xlsx +const EXCEL_MIMES = [ + "application/vnd.ms-excel", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", +]; +const EXCEL_EXTS = [".xls", ".xlsx"]; + +// PowerPoint: .ppt, .pptx +const PPTX_MIMES = [ + "application/vnd.ms-powerpoint", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", +]; +const PPTX_EXTS = [".ppt", ".pptx"]; + +function checkFile( + file: File, + exts: string[], + mimes: string[] +): boolean { + const n = file.name.toLowerCase(); + return exts.some((e) => n.endsWith(e)) || mimes.includes(file.type || ""); +} + +function checkByName(filename: string, exts: string[]): boolean { + const n = filename.toLowerCase(); + return exts.some((e) => n.endsWith(e)); +} + +function checkByMime(contentType: string, mimes: string[]): boolean { + return mimes.includes(contentType); +} + +export function isWordFile(file: File): boolean { + return checkFile(file, WORD_EXTS, WORD_MIMES); +} + +export function isExcelFile(file: File): boolean { + return checkFile(file, EXCEL_EXTS, EXCEL_MIMES); +} + +export function isPptxFile(file: File): boolean { + return checkFile(file, PPTX_EXTS, PPTX_MIMES); +} + +export function isOfficeDocument(file: File): boolean { + return isWordFile(file) || isExcelFile(file) || isPptxFile(file); +} + +export function isWordByName(filename: string): boolean { + return checkByName(filename, WORD_EXTS); +} + +export function isExcelByName(filename: string): boolean { + return checkByName(filename, EXCEL_EXTS); +} + +export function isPptxByName(filename: string): boolean { + return checkByName(filename, PPTX_EXTS); +} + +export function isWordByMime(contentType: string): boolean { + return checkByMime(contentType, WORD_MIMES); +} + +export function isExcelByMime(contentType: string): boolean { + return checkByMime(contentType, EXCEL_MIMES); +} + +export function isPptxByMime(contentType: string): boolean { + return checkByMime(contentType, PPTX_MIMES); +} + +export function isOfficeDocumentByName(filename: string): boolean { + return isWordByName(filename) || isExcelByName(filename) || isPptxByName(filename); +} + +export function isOfficeDocumentByMime(contentType: string): boolean { + return isWordByMime(contentType) || isExcelByMime(contentType) || isPptxByMime(contentType); +} + +/** Get convert URL for a file, or null if not an office document */ +export function getOfficeDocumentConvertUrl(file: File): string | null { + if (isWordFile(file)) return CONVERT_URLS.word; + if (isExcelFile(file)) return CONVERT_URLS.excel; + if (isPptxFile(file)) return CONVERT_URLS.powerpoint; + return null; +} + +/** Get convert URL from filename/contentType (for API routes) */ +export function getOfficeDocumentConvertUrlFromMeta( + filename: string, + contentType: string +): string | null { + if (isWordByName(filename) || isWordByMime(contentType)) + return CONVERT_URLS.word; + if (isExcelByName(filename) || isExcelByMime(contentType)) + return CONVERT_URLS.excel; + if (isPptxByName(filename) || isPptxByMime(contentType)) + return CONVERT_URLS.powerpoint; + return null; +} From 166f87b14850e37a8eb15f9cabf5011610979654 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Mon, 9 Mar 2026 03:49:43 -0400 Subject: [PATCH 02/18] fix: remove padding below suggestions when scroll-to-bottom button is hidden Made-with: Cursor --- src/components/assistant-ui/thread.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/assistant-ui/thread.tsx b/src/components/assistant-ui/thread.tsx index a469f64a..d6a459cd 100644 --- a/src/components/assistant-ui/thread.tsx +++ b/src/components/assistant-ui/thread.tsx @@ -231,7 +231,7 @@ export const Thread: FC = ({ items = [] }) => { }} /> -
+
From 397076f009b05824de11628537e6449fc7df5974 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Mon, 9 Mar 2026 03:57:24 -0400 Subject: [PATCH 03/18] refactor: simplify scroll-to-bottom button; use outline variant; remove tooltip Made-with: Cursor --- src/components/assistant-ui/thread.tsx | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/src/components/assistant-ui/thread.tsx b/src/components/assistant-ui/thread.tsx index d6a459cd..58ecb50c 100644 --- a/src/components/assistant-ui/thread.tsx +++ b/src/components/assistant-ui/thread.tsx @@ -248,19 +248,14 @@ export const Thread: FC = ({ items = [] }) => { const ThreadScrollToBottom: FC = () => { return ( - - + ); }; From f5396278a0ec709e94c38159e038838a58935c26 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Mon, 9 Mar 2026 04:03:10 -0400 Subject: [PATCH 04/18] refactor: remove scroll-to-bottom button Made-with: Cursor --- src/components/assistant-ui/thread.tsx | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/src/components/assistant-ui/thread.tsx b/src/components/assistant-ui/thread.tsx index 58ecb50c..daa06fb2 100644 --- a/src/components/assistant-ui/thread.tsx +++ b/src/components/assistant-ui/thread.tsx @@ -1,5 +1,4 @@ import { - ArrowDownIcon, ArrowUpIcon, CheckIcon, ChevronDown, @@ -230,10 +229,6 @@ export const Thread: FC = ({ items = [] }) => { AssistantMessage, }} /> - -
- -
@@ -245,21 +240,6 @@ export const Thread: FC = ({ items = [] }) => { ); }; -const ThreadScrollToBottom: FC = () => { - return ( - - - - ); -}; - const ThreadLoadingSkeleton: FC = () => { return (
Date: Mon, 9 Mar 2026 04:06:43 -0400 Subject: [PATCH 05/18] refactor: reduce last assistant message margin from mb-24 to mb-4 Made-with: Cursor --- src/components/assistant-ui/thread.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/assistant-ui/thread.tsx b/src/components/assistant-ui/thread.tsx index daa06fb2..9d55e029 100644 --- a/src/components/assistant-ui/thread.tsx +++ b/src/components/assistant-ui/thread.tsx @@ -1217,7 +1217,7 @@ const AssistantMessage: FC = () => { return (
From fb667c585d5b0e2ac6098760bbd34a773c20f7df Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Mon, 9 Mar 2026 04:13:22 -0400 Subject: [PATCH 06/18] chore: remove welcome workspace feature and dead code - Remove createWelcomeWorkspace and isCreatingWelcomeWorkspace from WorkspaceContext - Remove lazy welcome workspace creation effect and loading UI from WorkspaceGrid - Remove unused useSession, useEffect imports - Update SessionHandler comment Made-with: Cursor --- src/components/home/WorkspaceGrid.tsx | 44 +----------------------- src/components/layout/SessionHandler.tsx | 5 ++- src/contexts/WorkspaceContext.tsx | 38 +------------------- 3 files changed, 4 insertions(+), 83 deletions(-) diff --git a/src/components/home/WorkspaceGrid.tsx b/src/components/home/WorkspaceGrid.tsx index 5a8d1a68..25f56373 100644 --- a/src/components/home/WorkspaceGrid.tsx +++ b/src/components/home/WorkspaceGrid.tsx @@ -1,13 +1,12 @@ "use client"; -import { useState, useEffect, useRef, useMemo } from "react"; +import { useState, useRef, useMemo } from "react"; import { createPortal } from "react-dom"; import { useRouter } from "next/navigation"; import { FolderPlus, MoreVertical, Users, Trash2, Share2, X, CheckSquare } from "lucide-react"; import { useUIStore } from "@/lib/stores/ui-store"; import { useWorkspaceContext } from "@/contexts/WorkspaceContext"; import CreateWorkspaceModal from "@/components/workspace/CreateWorkspaceModal"; -import { useSession } from "@/lib/auth-client"; import { IconRenderer } from "@/hooks/use-icon-picker"; import { cn } from "@/lib/utils"; import WorkspaceSettingsModal from "@/components/workspace/WorkspaceSettingsModal"; @@ -29,7 +28,6 @@ export function WorkspaceGrid({ searchQuery = "" }: WorkspaceGridProps) { const router = useRouter(); const { showCreateWorkspaceModal, setShowCreateWorkspaceModal } = useUIStore(); const { workspaces, switchWorkspace, loadWorkspaces, deleteWorkspace, loadingWorkspaces } = useWorkspaceContext(); - const { data: session } = useSession(); const [showSettingsModal, setShowSettingsModal] = useState(false); const [showShareDialog, setShowShareDialog] = useState(false); @@ -45,8 +43,6 @@ export function WorkspaceGrid({ searchQuery = "" }: WorkspaceGridProps) { ); }, [workspaces, searchQuery]); const [settingsWorkspace, setSettingsWorkspace] = useState(null); - const [isCreatingWorkspace, setIsCreatingWorkspace] = useState(false); - const hasAttemptedWelcomeWorkspace = useRef(false); const prefetchTimeoutRef = useRef(null); // Multi-select state @@ -100,36 +96,6 @@ export function WorkspaceGrid({ searchQuery = "" }: WorkspaceGridProps) { } }; - // Lazy workspace creation for anonymous users - useEffect(() => { - const createWelcomeWorkspace = async () => { - if (!session?.user?.isAnonymous) return; - if (workspaces.length > 0) return; // Already has workspaces - if (isCreatingWorkspace) return; // Already creating - if (hasAttemptedWelcomeWorkspace.current) return; // Avoid retry loop - - setIsCreatingWorkspace(true); - hasAttemptedWelcomeWorkspace.current = true; - try { - console.log("Welcome workspace creation disabled"); - // const res = await fetch("/api/guest/create-welcome-workspace", { - // method: "POST", - // }); - - // if (res.ok) { - // // Reload workspaces to show the newly created one - // await loadWorkspaces(); - // } - } catch (error) { - console.error("Failed to create welcome workspace:", error); - } finally { - setIsCreatingWorkspace(false); - } - }; - - createWelcomeWorkspace(); - }, [session, workspaces.length, loadWorkspaces]); - // Format date helper const formatDate = (dateString: string | null | undefined) => { if (!dateString) return ""; @@ -206,14 +172,6 @@ export function WorkspaceGrid({ searchQuery = "" }: WorkspaceGridProps) {
- {/* Loading state for anonymous users creating first workspace */} - {session?.user?.isAnonymous && workspaces.length === 0 && isCreatingWorkspace && ( -
-
-

Setting up your workspace...

-
- )} - {/* Existing Workspaces */} {filteredWorkspaces.map((workspace) => { const color = workspace.color as CardColor | undefined; diff --git a/src/components/layout/SessionHandler.tsx b/src/components/layout/SessionHandler.tsx index f85fe644..9fc1e31d 100644 --- a/src/components/layout/SessionHandler.tsx +++ b/src/components/layout/SessionHandler.tsx @@ -5,9 +5,8 @@ import { useSession, signIn } from "@/lib/auth-client"; import { SidebarProvider } from "@/components/ui/sidebar"; /** - * Handles anonymous session creation and workspace access. - * Creates anonymous session if needed, workspace creation happens lazily in WorkspaceGrid. - * No loading screen - renders children immediately. + * Handles anonymous session creation. + * Creates anonymous session if needed. No loading screen - renders children immediately. */ export function AnonymousSessionHandler({ children }: { children: React.ReactNode }) { const { data: session, isPending } = useSession(); diff --git a/src/contexts/WorkspaceContext.tsx b/src/contexts/WorkspaceContext.tsx index e931906b..d54d8459 100644 --- a/src/contexts/WorkspaceContext.tsx +++ b/src/contexts/WorkspaceContext.tsx @@ -1,12 +1,11 @@ "use client"; -import React, { createContext, useContext, useState, useCallback, useEffect, useMemo } from "react"; +import React, { createContext, useContext, useState, useCallback, useMemo } from "react"; import { useRouter, usePathname, useSearchParams } from "next/navigation"; import { toast } from "sonner"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import type { WorkspaceWithState } from "@/lib/workspace-state/types"; import { useWorkspaceStore } from "@/lib/stores/workspace-store"; -import { useSession } from "@/lib/auth-client"; /** * Simplified WorkspaceContext - ONLY manages workspace list @@ -43,11 +42,8 @@ export function WorkspaceProvider({ children }: { children: React.ReactNode }) { const router = useRouter(); const pathname = usePathname(); const currentWorkspaceId = useWorkspaceStore((state) => state.currentWorkspaceId); - const { data: session } = useSession(); const queryClient = useQueryClient(); - const [isCreatingWelcomeWorkspace, setIsCreatingWelcomeWorkspace] = useState(false); - // Derive current slug synchronously from pathname (no useEffect delay) const currentSlug = useMemo(() => { if (pathname.startsWith("/workspace/") && pathname !== "/workspace") { @@ -128,38 +124,6 @@ export function WorkspaceProvider({ children }: { children: React.ReactNode }) { await queryClient.invalidateQueries({ queryKey: ['workspaces'] }); }, [queryClient]); - // Create welcome workspace for anonymous users - const createWelcomeWorkspace = useCallback(async () => { - if (isCreatingWelcomeWorkspace) return; - - setIsCreatingWelcomeWorkspace(true); - try { - console.log("Welcome workspace creation disabled"); - // const response = await fetch("/api/guest/create-welcome-workspace", { - // method: "POST", - // }); - - // if (response.ok) { - // const data = await response.json(); - // // Reload workspaces to get the new one - // await loadWorkspaces(); - // // Redirect to home (workspace is created but user goes to home) - // router.push("/home"); - // } else { - // console.error("[WORKSPACE CONTEXT] Failed to create welcome workspace"); - // } - } catch (error) { - console.error("[WORKSPACE CONTEXT] Error creating welcome workspace:", error); - } finally { - setIsCreatingWelcomeWorkspace(false); - } - }, [isCreatingWelcomeWorkspace, loadWorkspaces, router]); - - // TanStack Query automatically fetches on mount, no need for manual trigger - - // Note: Welcome workspace creation is now handled lazily in WorkspaceGrid - // This prevents jarring dashboard flash and provides better UX - // Switch workspace const switchWorkspace = useCallback( (slug: string) => { From 4cd96919f5ef283c28c071816583080e3f144a74 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Mon, 9 Mar 2026 04:21:33 -0400 Subject: [PATCH 07/18] fix: remove overflow clipping on PromptBuilderDialog inputs so focus ring is visible Made-with: Cursor --- src/components/assistant-ui/PromptBuilderDialog.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/assistant-ui/PromptBuilderDialog.tsx b/src/components/assistant-ui/PromptBuilderDialog.tsx index 3570afe6..aaf63b4e 100644 --- a/src/components/assistant-ui/PromptBuilderDialog.tsx +++ b/src/components/assistant-ui/PromptBuilderDialog.tsx @@ -428,7 +428,7 @@ export function PromptBuilderDialog({ -
+
{/* Search: Source selector - card style like home action buttons */} {config.hasSearchSource && (
@@ -540,7 +540,7 @@ export function PromptBuilderDialog({ {/* Workspace context - separate dialog for picker (scroll works, no layout shift) */} {config.hasTopicSelector && items.length > 0 && ( -
+
Date: Mon, 9 Mar 2026 04:31:28 -0400 Subject: [PATCH 08/18] fix: skip tool group UI when only one tool call Made-with: Cursor --- src/components/assistant-ui/tool-group.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/components/assistant-ui/tool-group.tsx b/src/components/assistant-ui/tool-group.tsx index 76080498..dfa28481 100644 --- a/src/components/assistant-ui/tool-group.tsx +++ b/src/components/assistant-ui/tool-group.tsx @@ -225,6 +225,12 @@ const ToolGroupImpl: FC< [isToolGroupStreaming], ); + // Only group when there are more than 1 consecutive tool call. + // IMPORTANT: this check must stay *after* hooks to avoid conditional hook calls. + if (toolCount <= 1) { + return <>{children}; + } + return ( From 5c5bcc022151ad0db5a0f838a11f2884e9fdae20 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Mon, 9 Mar 2026 04:45:55 -0400 Subject: [PATCH 09/18] refactor: strip ocrPages and textContent from client workspace events Made-with: Cursor --- src/app/api/workspaces/[id]/events/route.ts | 39 +++++++++++++++++++ .../workspace-canvas/WorkspaceContent.tsx | 1 - 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/app/api/workspaces/[id]/events/route.ts b/src/app/api/workspaces/[id]/events/route.ts index 27ef8c3e..989a74ad 100644 --- a/src/app/api/workspaces/[id]/events/route.ts +++ b/src/app/api/workspaces/[id]/events/route.ts @@ -1,6 +1,40 @@ import { NextRequest, NextResponse } from "next/server"; import type { WorkspaceEvent, EventResponse } from "@/lib/workspace/events"; import { checkAndCreateSnapshot } from "@/lib/workspace/snapshot-manager"; + +/** Strip ocrPages/textContent from PDF items — client doesn't need them for display. */ +function stripPdfOcrFromItem(item: { type?: string; data?: unknown }): void { + if (item?.type === "pdf" && item.data && typeof item.data === "object") { + const d = item.data as Record; + delete d.ocrPages; + delete d.textContent; + } +} + +function stripPdfOcrFromState(state: { items?: Array<{ type?: string; data?: unknown }> } | undefined): void { + state?.items?.forEach(stripPdfOcrFromItem); +} + +function stripPdfOcrFromEventPayload(event: WorkspaceEvent): void { + const p = event.payload as Record; + if (event.type === "ITEM_CREATED" && p?.item) stripPdfOcrFromItem(p.item as { type?: string; data?: unknown }); + if (event.type === "ITEM_UPDATED") { + const changes = p?.changes as Record | undefined; + if (changes?.data && typeof changes.data === "object") { + const d = changes.data as Record; + delete d.ocrPages; + delete d.textContent; + } + } + if (event.type === "BULK_ITEMS_UPDATED") { + (p?.addedItems as Array<{ type?: string; data?: unknown }> | undefined)?.forEach(stripPdfOcrFromItem); + (p?.items as Array<{ type?: string; data?: unknown }> | undefined)?.forEach(stripPdfOcrFromItem); + } + if (event.type === "WORKSPACE_SNAPSHOT" && p?.items) { + (p.items as Array<{ type?: string; data?: unknown }>).forEach(stripPdfOcrFromItem); + } +} + import { loadWorkspaceState } from "@/lib/workspace/state-loader"; import { hasDuplicateName } from "@/lib/workspace/unique-name"; import { db, workspaceEvents } from "@/lib/db/client"; @@ -177,6 +211,10 @@ async function handleGET( ? Math.max(...eventsData.map(e => e.version)) : (snapshotVersion || 0); + // Strip ocrPages/textContent from PDF items — client doesn't need them for display + if (latestSnapshot?.state) stripPdfOcrFromState(latestSnapshot.state as { items?: unknown[] }); + events.forEach(stripPdfOcrFromEventPayload); + const response: EventResponse = { events, version: maxVersion, @@ -324,6 +362,7 @@ async function handlePOST( userName: e.userName || undefined, id: e.eventId, } as WorkspaceEvent)); + events.forEach(stripPdfOcrFromEventPayload); const totalTime = Date.now() - startTime; timings.total = totalTime; diff --git a/src/components/workspace-canvas/WorkspaceContent.tsx b/src/components/workspace-canvas/WorkspaceContent.tsx index 2126d6df..34cb60fe 100644 --- a/src/components/workspace-canvas/WorkspaceContent.tsx +++ b/src/components/workspace-canvas/WorkspaceContent.tsx @@ -175,7 +175,6 @@ export default function WorkspaceContent({ if (item.type !== "pdf") return false; const pdf = item.data as import("@/lib/workspace-state/types").PdfData; if (!pdf.fileUrl) return false; - if (pdf.ocrPages?.length) return false; if (pdf.ocrStatus === "processing" || pdf.ocrStatus === "complete") return false; if (migratedIdsRef.current.has(item.id)) return false; return true; From 919214a601c9d26652fee167acc2b717be019ad7 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Mon, 9 Mar 2026 10:12:23 -0400 Subject: [PATCH 10/18] fix: use byok for vertex --- src/app/api/chat/route.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index b6c3ccf4..531db3fd 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -294,6 +294,13 @@ async function handlePOST(req: Request) { // Prepare provider options // The Gateway passes these through to the specific provider const isClaudeModel = modelId.includes("claude"); + const vertexProject = process.env.GOOGLE_VERTEX_PROJECT; + const vertexLocation = process.env.GOOGLE_VERTEX_LOCATION || "us-central1"; + const vertexClientEmail = process.env.GOOGLE_CLIENT_EMAIL; + const vertexPrivateKey = process.env.GOOGLE_PRIVATE_KEY?.replace(/\\n/g, "\n"); + const hasVertexByok = + vertexProject && vertexClientEmail && vertexPrivateKey; + let providerOptions: any = { gateway: { // Route Claude models through Vertex AI only @@ -302,6 +309,22 @@ async function handlePOST(req: Request) { models: ["google/gemini-2.5-flash"], // Track usage per end-user for analytics ...(userId ? { user: userId } : {}), + // BYOK for Vertex: use GCP credentials so Claude (Haiku, etc.) bypasses Vercel balance + ...(isClaudeModel && + hasVertexByok && { + byok: { + vertex: [ + { + project: vertexProject!, + location: vertexLocation, + googleCredentials: { + clientEmail: vertexClientEmail!, + privateKey: vertexPrivateKey!, + }, + }, + ], + }, + }), // Fail fast if provider doesn't start streaming within 30s (BYOK only) providerTimeouts: { byok: { From a28f80160b395865b3707633f62bb2f75ebfe7f5 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Mon, 9 Mar 2026 12:18:45 -0400 Subject: [PATCH 11/18] feat: search dialog --- src/app/dashboard/page.tsx | 34 ++-- src/components/ui/command.tsx | 7 +- .../workspace-canvas/WorkspaceContent.tsx | 43 +---- .../workspace-canvas/WorkspaceHeader.tsx | 125 ++----------- .../WorkspaceSearchDialog.tsx | 171 ++++++++++++++++++ .../workspace-canvas/WorkspaceSection.tsx | 7 +- src/lib/stores/ui-store.ts | 5 - src/lib/workspace-state/search.ts | 130 +++++++++++++ 8 files changed, 356 insertions(+), 166 deletions(-) create mode 100644 src/components/workspace-canvas/WorkspaceSearchDialog.tsx diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 999475e5..4746e99f 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -28,6 +28,7 @@ import { DashboardLayout } from "@/components/layout/DashboardLayout"; import { SplitViewLayout } from "@/components/layout/SplitViewLayout"; import { ItemPanelContent } from "@/components/workspace-canvas/ItemPanelContent"; import WorkspaceHeader from "@/components/workspace-canvas/WorkspaceHeader"; +import { WorkspaceSearchDialog } from "@/components/workspace-canvas/WorkspaceSearchDialog"; import { SidebarProvider, useSidebar } from "@/components/ui/sidebar"; import { MobileWarning } from "@/components/ui/MobileWarning"; import { AnonymousSessionHandler, SidebarCoordinator } from "@/components/layout/SessionHandler"; @@ -216,13 +217,11 @@ function DashboardContent({ const isDesktop = useMediaQuery("(min-width: 768px)"); const showJsonView = useUIStore((state) => state.showJsonView); const isChatExpanded = useUIStore((state) => state.isChatExpanded); - const searchQuery = useUIStore((state) => state.searchQuery); const isChatMaximized = useUIStore((state) => state.isChatMaximized); const workspacePanelSize = useUIStore((state) => state.workspacePanelSize); const showCreateWorkspaceModal = useUIStore((state) => state.showCreateWorkspaceModal); const setShowJsonView = useUIStore((state) => state.setShowJsonView); const setIsChatExpanded = useUIStore((state) => state.setIsChatExpanded); - const setSearchQuery = useUIStore((state) => state.setSearchQuery); const setIsChatMaximized = useUIStore((state) => state.setIsChatMaximized); const setOpenModalItemId = useUIStore((state) => state.setOpenModalItemId); const setShowCreateWorkspaceModal = useUIStore((state) => state.setShowCreateWorkspaceModal); @@ -265,6 +264,9 @@ function DashboardContent({ isDesktop, }); + // Search dialog state for Cmd+K command palette + const [searchDialogOpen, setSearchDialogOpen] = useState(false); + // Keyboard shortcuts useKeyboardShortcuts( toggleChatExpanded, @@ -272,7 +274,9 @@ function DashboardContent({ onToggleSidebar: toggleSidebar, onToggleChatMaximize: toggleChatMaximized, onFocusSearch: () => { - titleInputRef.current?.focus(); + if (currentWorkspaceId && !isLoadingWorkspace) { + setSearchDialogOpen(true); + } }, } ); @@ -455,8 +459,7 @@ function DashboardContent({ currentSlug={currentSlug} state={state} showJsonView={showJsonView} - searchQuery={searchQuery} - onSearchChange={setSearchQuery} + onOpenSearch={() => setSearchDialogOpen(true)} isSaving={isSaving} lastSavedAt={lastSavedAt} hasUnsavedChanges={hasUnsavedChanges} @@ -494,7 +497,7 @@ function DashboardContent({ onFlushPendingChanges={operations.flushPendingChanges} /> ); - }, [viewMode, state.items, loadingCurrentWorkspace, isLoadingWorkspace, currentWorkspaceId, currentSlug, state, showJsonView, searchQuery, isSaving, lastSavedAt, hasUnsavedChanges, isChatMaximized, isDesktop, isChatExpanded, currentWorkspaceTitle, currentWorkspaceIcon, currentWorkspaceColor, operations, manualSave, setSearchQuery, setIsChatExpanded, setOpenModalItemId, handleShowHistory, titleInputRef, scrollAreaRef, getStatePreviewJSON]); + }, [viewMode, state.items, loadingCurrentWorkspace, isLoadingWorkspace, currentWorkspaceId, currentSlug, state, showJsonView, isSaving, lastSavedAt, hasUnsavedChanges, isChatMaximized, isDesktop, isChatExpanded, currentWorkspaceTitle, currentWorkspaceIcon, currentWorkspaceColor, operations, manualSave, setSearchDialogOpen, setIsChatExpanded, setOpenModalItemId, handleShowHistory, titleInputRef, scrollAreaRef, getStatePreviewJSON]); // Build the single panel content (for workspace+panel mode) const panelContent = useMemo(() => { @@ -553,8 +556,7 @@ function DashboardContent({ !showJsonView && !isChatMaximized && currentWorkspaceId && !isLoadingWorkspace ? ( } - searchQuery={searchQuery} - onSearchChange={setSearchQuery} + onOpenSearch={() => setSearchDialogOpen(true)} currentWorkspaceId={currentWorkspaceId} isSaving={isSaving} lastSavedAt={lastSavedAt} @@ -626,8 +628,7 @@ function DashboardContent({ currentSlug={currentSlug} state={state} showJsonView={showJsonView} - searchQuery={searchQuery} - onSearchChange={setSearchQuery} + onOpenSearch={() => setSearchDialogOpen(true)} isSaving={isSaving} lastSavedAt={lastSavedAt} hasUnsavedChanges={hasUnsavedChanges} @@ -695,6 +696,13 @@ function DashboardContent({ onRevertToVersion={revertToVersion} items={currentWorkspace?.state?.items || []} /> + ); } @@ -745,12 +753,6 @@ function DashboardView() { lastTrackedWorkspaceIdRef.current = currentWorkspaceId; }, [currentWorkspaceId, markWorkspaceOpened]); - // Reset search query when workspace changes - const setSearchQuery = useUIStore((state) => state.setSearchQuery); - useEffect(() => { - setSearchQuery(''); - }, [currentWorkspaceId, setSearchQuery]); - // Sync active folder with URL query param (?folder=) // Reset folder/panels when workspace changes // and enables browser-native back/forward for folder navigation diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx index 8cb4ca7a..312e777f 100644 --- a/src/components/ui/command.tsx +++ b/src/components/ui/command.tsx @@ -35,12 +35,14 @@ function CommandDialog({ children, className, showCloseButton = true, + shouldFilter, ...props }: React.ComponentProps & { title?: string description?: string className?: string showCloseButton?: boolean + shouldFilter?: boolean }) { return ( @@ -52,7 +54,10 @@ function CommandDialog({ className={cn("overflow-hidden p-0", className)} showCloseButton={showCloseButton} > - + {children} diff --git a/src/components/workspace-canvas/WorkspaceContent.tsx b/src/components/workspace-canvas/WorkspaceContent.tsx index 34cb60fe..252a4d4b 100644 --- a/src/components/workspace-canvas/WorkspaceContent.tsx +++ b/src/components/workspace-canvas/WorkspaceContent.tsx @@ -5,7 +5,7 @@ import { useWorkspaceStore } from "@/lib/stores/workspace-store"; import { Plus, Copy, Check, Download, Upload } from "lucide-react"; import { EmptyState } from "@/components/empty-state"; import type { AgentState, Item, CardType } from "@/lib/workspace-state/types"; -import { filterItems } from "@/lib/workspace-state/search"; +import { filterItemsByFolder } from "@/lib/workspace-state/search"; import { useAutoScroll } from "@/hooks/ui/use-auto-scroll"; import { WorkspaceGrid } from "./WorkspaceGrid"; import type { LayoutItem } from "react-grid-layout"; @@ -22,7 +22,6 @@ interface WorkspaceContentProps { deleteItem: (itemId: string) => void; updateAllItems: (items: Item[]) => void; getStatePreviewJSON: (s: AgentState | undefined) => Record; - searchQuery?: string; columns: number; // Pass columns from layout state instead of calculating here setOpenModalItemId: (id: string | null) => void; scrollContainerRef?: React.RefObject; @@ -47,7 +46,6 @@ export default function WorkspaceContent({ deleteItem, updateAllItems, getStatePreviewJSON, - searchQuery = "", columns, // Columns now passed from parent (calculated in use-layout-state) setOpenModalItemId, scrollContainerRef: externalScrollContainerRef, @@ -130,10 +128,10 @@ export default function WorkspaceContent({ } }, [viewState, getStatePreviewJSON, workspaceTitle]); - // Filter items based on search query and active folder + // Filter items by active folder (search is handled by WorkspaceSearchDialog) const filteredItems = useMemo(() => { - return filterItems(viewState.items, searchQuery, activeFolderId); - }, [viewState.items, searchQuery, activeFolderId]); + return filterItemsByFolder(viewState.items ?? [], activeFolderId ?? null); + }, [viewState.items, activeFolderId]); // Handle opening a folder (folders are now items with type: 'folder') // Uses setActiveFolderId to switch folder without closing panels; breadcrumb handles "navigate back" @@ -382,14 +380,11 @@ export default function WorkspaceContent({ onDragStop(); }, [onDragStop]); - // Check if we're in a filtered/folder view with no items - const isFiltering = searchQuery.trim() !== '' || activeFolderId !== null; - // Temporary filters (search) should not save layouts, but folder views should - const isTemporaryFilter = searchQuery.trim() !== ''; + // Check if we're in folder view with no items + const isFiltering = activeFolderId !== null; - // Show empty state if no items exist at all OR if filtering yields no results (and no search query) - // This allows users to see the "Drag and Drop" prompt when selecting a new empty folder - if ((viewState.items ?? []).length === 0 || (isFiltering && !searchQuery && filteredItems.length === 0)) { + // Show empty state if no items exist at all OR if folder filtering yields no results + if ((viewState.items ?? []).length === 0 || (isFiltering && filteredItems.length === 0)) { return (
0 ? 'pb-20' : ''} size-full workspace-grid-container px-4 sm:px-6`}> @@ -454,24 +449,6 @@ export default function WorkspaceContent({ ); } - // Show no results message if search has no matches - if (searchQuery && filteredItems.length === 0) { - return ( -
-
0 ? 'pb-20' : ''} size-full workspace-grid-container px-4 sm:px-6`}> - -
-

No results found

-

- {`No items match "${searchQuery}". Try a different search term.`} -

-
-
-
-
- ); - } - return (
{showJsonView ? ( @@ -519,11 +496,11 @@ export default function WorkspaceContent({
) : ( ; - searchQuery: string; - onSearchChange: (query: string) => void; + onOpenSearch?: () => void; // Save indicator props isSaving?: boolean; lastSavedAt?: Date | null; @@ -110,8 +109,7 @@ interface WorkspaceHeaderProps { export function WorkspaceHeader({ titleInputRef, - searchQuery, - onSearchChange, + onOpenSearch, isSaving, lastSavedAt, hasUnsavedChanges, @@ -147,8 +145,6 @@ export function WorkspaceHeader({ onUpdateActiveItem, }: WorkspaceHeaderProps) { const router = useRouter(); - const [isFocused, setIsFocused] = useState(false); - const [isSearchExpanded, setIsSearchExpanded] = useState(false); const [isNewMenuOpen, setIsNewMenuOpen] = useState(false); const [showRenameDialog, setShowRenameDialog] = useState(false); const [renamingTarget, setRenamingTarget] = useState<{ id: string, type: 'folder' | 'item' } | null>(null); @@ -162,7 +158,6 @@ export function WorkspaceHeader({ const openAudioDialog = useAudioRecordingStore((s) => s.openDialog); const closeAudioDialog = useAudioRecordingStore((s) => s.closeDialog); const renameInputRef = useRef(null); - const searchInputRef = useRef(null); const pathname = usePathname(); const isWorkspaceRoute = pathname.startsWith("/workspace"); @@ -238,13 +233,6 @@ export function WorkspaceHeader({ } }, [showRenameDialog]); - // Auto-focus search input when expanded - useEffect(() => { - if (isSearchExpanded && searchInputRef.current) { - searchInputRef.current.focus(); - } - }, [isSearchExpanded]); - // Listen for drag hover events on breadcrumb elements useEffect(() => { const handleDragHover = (e: Event) => { @@ -291,35 +279,6 @@ export function WorkspaceHeader({ }; }, []); - // Handle keyboard shortcut Cmd/Ctrl+K to expand search - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - // Cmd+K / Ctrl+K - Expand and focus search - if ((e.metaKey || e.ctrlKey) && e.key === 'k') { - e.preventDefault(); - setIsSearchExpanded(true); - } - }; - - window.addEventListener('keydown', handleKeyDown); - return () => { - window.removeEventListener('keydown', handleKeyDown); - }; - }, []); - - // Handle search input blur - collapse if empty - const handleSearchBlur = () => { - setIsFocused(false); - if (!searchQuery) { - setIsSearchExpanded(false); - } - }; - - // Handle search icon click - const handleSearchIconClick = () => { - setIsSearchExpanded(true); - }; - const handleYouTubeCreate = useCallback((url: string, name: string, thumbnail?: string) => { if (addItem) { addItem("youtube", name, { url, thumbnail }); @@ -906,71 +865,25 @@ export function WorkspaceHeader({ )} - {/* Search Input */} - {isSearchExpanded ? ( -
- - { - searchInputRef.current = node; - if (titleInputRef) { - (titleInputRef as React.MutableRefObject).current = node; - } - }} - value={searchQuery} - onChange={(e: React.ChangeEvent) => { - onSearchChange(e.target.value); - }} - onFocus={() => setIsFocused(true)} - onBlur={handleSearchBlur} - placeholder="Search..." + {/* Search - opens command palette */} + + + - )} -
- ) : ( - - - - - - Search workspace {formatKeyboardShortcut('K')} - - - )} + data-tour="search-bar" + aria-label="Search workspace" + > + + + + + Search workspace {formatKeyboardShortcut('K')} + + {/* New Button */} {addItem && ( diff --git a/src/components/workspace-canvas/WorkspaceSearchDialog.tsx b/src/components/workspace-canvas/WorkspaceSearchDialog.tsx new file mode 100644 index 00000000..734d2fc3 --- /dev/null +++ b/src/components/workspace-canvas/WorkspaceSearchDialog.tsx @@ -0,0 +1,171 @@ +"use client"; + +import { useState, useMemo, useEffect, useCallback } from "react"; +import { ChevronRight, FolderOpen } from "lucide-react"; +import { + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandItem, +} from "@/components/ui/command"; +import type { Item } from "@/lib/workspace-state/types"; +import { + rankWorkspaceSearchResults, + getFolderPath, +} from "@/lib/workspace-state/search"; +import { getCardTypeIcon } from "@/components/chat/WorkspaceItemPicker"; +import { useUIStore } from "@/lib/stores/ui-store"; + +interface WorkspaceSearchDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + items: Item[]; + currentWorkspaceId: string | null; + isLoadingWorkspace: boolean; +} + +export function WorkspaceSearchDialog({ + open, + onOpenChange, + items, + currentWorkspaceId, + isLoadingWorkspace, +}: WorkspaceSearchDialogProps) { + const [query, setQuery] = useState(""); + const navigateToFolder = useUIStore((state) => state.navigateToFolder); + const setActiveFolderId = useUIStore((state) => state.setActiveFolderId); + const setOpenModalItemId = useUIStore((state) => state.setOpenModalItemId); + + const safeItems = items ?? []; + + // Reset query when workspace changes + useEffect(() => { + if (!open) return; + setQuery(""); + }, [currentWorkspaceId, open]); + + const rankedResults = useMemo( + () => rankWorkspaceSearchResults(safeItems, query), + [safeItems, query] + ); + + const handleSelect = useCallback( + (item: Item) => { + // No-op if item not found (workspace switch / race) + const exists = safeItems.some((i) => i.id === item.id); + if (!exists) return; + + onOpenChange(false); + + if (item.type === "folder") { + navigateToFolder(item.id); + } else { + // Ensure breadcrumb shows the item's folder, then open the item + if (item.folderId) { + setActiveFolderId(item.folderId); + } else { + setActiveFolderId(null); + } + setOpenModalItemId(item.id); + } + }, + [safeItems, onOpenChange, navigateToFolder, setActiveFolderId, setOpenModalItemId] + ); + + const getFolderPathItems = useCallback( + (item: Item): Item[] => { + if (!item.folderId) return []; + return getFolderPath(item.folderId, safeItems); + }, + [safeItems] + ); + + const emptyMessage = useMemo(() => { + if (isLoadingWorkspace || !currentWorkspaceId) { + return "Loading..."; + } + if (safeItems.length === 0) { + return "No items in this workspace."; + } + if (query.trim()) { + return `No results for "${query}".`; + } + return "Type to search..."; + }, [isLoadingWorkspace, currentWorkspaceId, safeItems.length, query]); + + return ( + + + + {emptyMessage} + {rankedResults.map(({ item, matchType, contentSnippet }) => { + const Icon = getCardTypeIcon(item.type); + const folderPathItems = getFolderPathItems(item); + const hasLocation = folderPathItems.length > 0; + const hasContentSnippet = matchType === "content" && contentSnippet?.match; + + return ( + handleSelect(item)} + > + {Icon} +
+ {item.name || "Untitled"} + {/* Breadcrumb-style folder path (matches header breadcrumbs) */} + {hasLocation && ( +
+ {folderPathItems.map((folder, idx) => ( + + {idx > 0 && ( + + )} + + {folder.name} + + ))} +
+ )} + {/* Content match snippet with bold matching text */} + {hasContentSnippet && contentSnippet && ( +
+ {contentSnippet.before && ( + {contentSnippet.before} + )} + + {contentSnippet.match} + + {contentSnippet.after && ( + {contentSnippet.after} + )} +
+ )} + {/* Subtitle when no location or content snippet */} + {item.subtitle && !hasLocation && !hasContentSnippet && ( + + {item.subtitle} + + )} +
+
+ ); + })} +
+
+ ); +} diff --git a/src/components/workspace-canvas/WorkspaceSection.tsx b/src/components/workspace-canvas/WorkspaceSection.tsx index 49da378c..0f7ab0a1 100644 --- a/src/components/workspace-canvas/WorkspaceSection.tsx +++ b/src/components/workspace-canvas/WorkspaceSection.tsx @@ -71,8 +71,7 @@ interface WorkspaceSectionProps { // View state showJsonView: boolean; - searchQuery: string; - onSearchChange: (query: string) => void; + onOpenSearch?: () => void; // Save state isSaving: boolean; @@ -144,8 +143,7 @@ export function WorkspaceSection({ currentSlug, state, showJsonView, - searchQuery, - onSearchChange, + onOpenSearch, isSaving, lastSavedAt, hasUnsavedChanges, @@ -696,7 +694,6 @@ export function WorkspaceSection({ deleteItem={deleteItem} updateAllItems={updateAllItems} getStatePreviewJSON={getStatePreviewJSON} - searchQuery={searchQuery} columns={columns} setOpenModalItemId={setOpenModalItemId} scrollContainerRef={scrollAreaRef} diff --git a/src/lib/stores/ui-store.ts b/src/lib/stores/ui-store.ts index 27ce0f5c..0b4f8ba2 100644 --- a/src/lib/stores/ui-store.ts +++ b/src/lib/stores/ui-store.ts @@ -29,7 +29,6 @@ interface UIState { showCreateWorkspaceModal: boolean; showSheetModal: boolean; showJsonView: boolean; - searchQuery: string; activeFolderId: string | null; // Active folder for filtering selectedModelId: string; // Selected AI model ID @@ -89,7 +88,6 @@ interface UIState { // Actions - UI Preferences setShowJsonView: (show: boolean) => void; - setSearchQuery: (query: string) => void; setActiveFolderId: (folderId: string | null) => void; /** Atomic: close panels, clear folder, deselect panel cards. Use when panel is focused and user navigates back. */ @@ -160,7 +158,6 @@ const initialState = { // UI Preferences showJsonView: false, - searchQuery: '', activeFolderId: null, selectedModelId: 'gemini-3-flash-preview', @@ -450,7 +447,6 @@ export const useUIStore = create()( // UI Preferences actions setShowJsonView: (show) => set({ showJsonView: show }), - setSearchQuery: (query) => set({ searchQuery: query }), setSelectedModelId: (modelId) => set({ selectedModelId: modelId }), @@ -641,7 +637,6 @@ export const selectSecondaryOpenModalItemId = (state: UIState) => state.openPane export const selectUIPreferences = (state: UIState) => ({ showJsonView: state.showJsonView, - searchQuery: state.searchQuery, }); export const selectTextSelectionState = (state: UIState) => ({ diff --git a/src/lib/workspace-state/search.ts b/src/lib/workspace-state/search.ts index d4dd0083..42796388 100644 --- a/src/lib/workspace-state/search.ts +++ b/src/lib/workspace-state/search.ts @@ -267,3 +267,133 @@ export function filterItemIdsForFolderCreation( return itemIds.filter((id) => !idsToExclude.has(id)); } +export interface ContentMatchSnippet { + before: string; + match: string; + after: string; +} + +export interface RankedSearchResult { + item: Item; + score: number; + matchType: string; + /** When matchType is "content", a snippet with the matching text and context */ + contentSnippet?: ContentMatchSnippet | null; +} + +const SCORE_EXACT_NAME = 1000; +const SCORE_PREFIX_NAME = 800; +const SCORE_TOKEN_NAME = 600; +const SCORE_SUBSTRING_META = 400; +const SCORE_CONTENT = 200; + +const SNIPPET_CONTEXT_CHARS = 40; + +/** + * Extracts a snippet from content text showing the matching query with surrounding context. + * Used for content-match results in the command palette. + */ +function getContentMatchSnippet( + contentText: string, + queryTerms: string[] +): ContentMatchSnippet | null { + if (!contentText || queryTerms.length === 0) return null; + + const contentLower = contentText.toLowerCase(); + let bestStart = -1; + let bestEnd = -1; + + // Find the first occurrence of the first query term + const firstTerm = queryTerms[0]; + const idx = contentLower.indexOf(firstTerm); + if (idx === -1) return null; + + bestStart = idx; + bestEnd = idx + firstTerm.length; + + // Extend to include other query terms if they appear nearby (within ~80 chars) + for (let i = 1; i < queryTerms.length; i++) { + const term = queryTerms[i]; + const termIdx = contentLower.indexOf(term, bestStart); + if (termIdx !== -1 && termIdx <= bestEnd + 60) { + bestEnd = Math.max(bestEnd, termIdx + term.length); + } + } + + const beforeStart = Math.max(0, bestStart - SNIPPET_CONTEXT_CHARS); + const afterEnd = Math.min(contentText.length, bestEnd + SNIPPET_CONTEXT_CHARS); + + const before = (beforeStart > 0 ? "…" : "") + contentText.slice(beforeStart, bestStart).trimStart(); + const match = contentText.slice(bestStart, bestEnd); + const after = contentText.slice(bestEnd, afterEnd).trimEnd() + (afterEnd < contentText.length ? "…" : ""); + + return { before, match, after }; +} + +/** + * Ranks workspace items by search relevance for command palette. + * Name matches score higher than content matches. + */ +export function rankWorkspaceSearchResults( + items: Item[], + query: string +): RankedSearchResult[] { + if (!Array.isArray(items) || items.length === 0) return []; + if (!query || !query.trim()) return []; + + const normalizedQuery = query.toLowerCase().trim(); + const queryTerms = normalizedQuery.split(/\s+/).filter((term) => term.length > 0); + if (queryTerms.length === 0) return []; + + const results: RankedSearchResult[] = []; + + for (const item of items) { + const nameLower = (item.name ?? "").toLowerCase().trim(); + const subtitleLower = (item.subtitle ?? "").toLowerCase(); + const typeLower = (item.type ?? "").toLowerCase(); + const metaIndex = [nameLower, subtitleLower, typeLower].filter(Boolean).join(" "); + const contentText = getSearchableDataText(item).toLowerCase(); + + let score = 0; + let matchType = ""; + + // 1. Exact name match + if (nameLower === normalizedQuery) { + score = SCORE_EXACT_NAME; + matchType = "exact"; + } + // 2. Prefix match on name + else if (nameLower.startsWith(normalizedQuery)) { + score = SCORE_PREFIX_NAME; + matchType = "prefix"; + } + // 3. Token/word match on name (all query terms found in name) + else if (queryTerms.every((term) => nameLower.includes(term))) { + score = SCORE_TOKEN_NAME; + matchType = "token"; + } + // 4. Substring on name + subtitle + type + else if (queryTerms.every((term) => metaIndex.includes(term))) { + score = SCORE_SUBSTRING_META; + matchType = "meta"; + } + // 5. Content-text matches + else if (queryTerms.every((term) => contentText.includes(term))) { + score = SCORE_CONTENT; + matchType = "content"; + } + + if (score > 0) { + const rawContent = getSearchableDataText(item); + const contentSnippet = + matchType === "content" && rawContent + ? getContentMatchSnippet(rawContent, queryTerms) + : null; + results.push({ item, score, matchType, contentSnippet }); + } + } + + results.sort((a, b) => b.score - a.score); + return results.slice(0, 50); +} + From 9d919892ad226d29e445b14b3bf190a55240b40d Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Mon, 9 Mar 2026 13:13:04 -0400 Subject: [PATCH 12/18] fix: type errors --- src/app/api/upload-url/route.ts | 3 +-- src/app/api/workspaces/[id]/events/route.ts | 2 +- src/components/assistant-ui/AssistantDropzone.tsx | 1 + src/components/modals/OfficeDocumentRejectedDialog.tsx | 4 +++- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/app/api/upload-url/route.ts b/src/app/api/upload-url/route.ts index a264cf44..eb186f84 100644 --- a/src/app/api/upload-url/route.ts +++ b/src/app/api/upload-url/route.ts @@ -29,7 +29,7 @@ async function handlePOST(request: NextRequest) { } const body = await request.json(); - const { filename, contentType } = body; + const { filename, contentType = "" } = body; if (!filename || typeof filename !== 'string') { return NextResponse.json( @@ -39,7 +39,6 @@ async function handlePOST(request: NextRequest) { } // Reject Office documents — convert to PDF at ilovepdf.com - const contentType = body.contentType || ""; const convertUrl = getOfficeDocumentConvertUrlFromMeta(filename, contentType); if (convertUrl) { return NextResponse.json( diff --git a/src/app/api/workspaces/[id]/events/route.ts b/src/app/api/workspaces/[id]/events/route.ts index 989a74ad..3f1f9f05 100644 --- a/src/app/api/workspaces/[id]/events/route.ts +++ b/src/app/api/workspaces/[id]/events/route.ts @@ -212,7 +212,7 @@ async function handleGET( : (snapshotVersion || 0); // Strip ocrPages/textContent from PDF items — client doesn't need them for display - if (latestSnapshot?.state) stripPdfOcrFromState(latestSnapshot.state as { items?: unknown[] }); + if (latestSnapshot?.state) stripPdfOcrFromState(latestSnapshot.state as { items?: Array<{ type?: string; data?: unknown }> }); events.forEach(stripPdfOcrFromEventPayload); const response: EventResponse = { diff --git a/src/components/assistant-ui/AssistantDropzone.tsx b/src/components/assistant-ui/AssistantDropzone.tsx index fbd521b1..dccad4fc 100644 --- a/src/components/assistant-ui/AssistantDropzone.tsx +++ b/src/components/assistant-ui/AssistantDropzone.tsx @@ -9,6 +9,7 @@ import { toast } from "sonner"; import { emitOfficeDocumentRejected } from "@/components/modals/OfficeDocumentRejectedDialog"; import { emitPasswordProtectedPdf } from "@/components/modals/PasswordProtectedPdfDialog"; import { filterPasswordProtectedPdfs } from "@/lib/uploads/pdf-validation"; +import { isWordFile, isExcelFile, isPptxFile } from "@/lib/uploads/office-document-validation"; interface AssistantDropzoneProps { children: React.ReactNode; diff --git a/src/components/modals/OfficeDocumentRejectedDialog.tsx b/src/components/modals/OfficeDocumentRejectedDialog.tsx index 3fb97fb2..fba26ff8 100644 --- a/src/components/modals/OfficeDocumentRejectedDialog.tsx +++ b/src/components/modals/OfficeDocumentRejectedDialog.tsx @@ -74,7 +74,9 @@ export function OfficeDocumentRejectedDialog() { useEffect(() => { listeners.add(handleEvent); - return () => listeners.delete(handleEvent); + return () => { + listeners.delete(handleEvent); + }; }, [handleEvent]); const sections = SECTIONS.filter((s) => { From 077e6c21927bb029e697f08c2a64b799175161da Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Mon, 9 Mar 2026 14:03:59 -0400 Subject: [PATCH 13/18] feat: add rename-only mode to editItem tool Made-with: Cursor --- .../ai/tools/__tests__/edit-item-tool.test.ts | 24 +++++++ src/lib/ai/tools/edit-item-tool.ts | 13 ++-- .../__tests__/workspace-worker.edit.test.ts | 39 +++++++++++ src/lib/ai/workers/workspace-worker.ts | 68 +++++++++++-------- 4 files changed, 111 insertions(+), 33 deletions(-) diff --git a/src/lib/ai/tools/__tests__/edit-item-tool.test.ts b/src/lib/ai/tools/__tests__/edit-item-tool.test.ts index f6527f4b..877109ec 100644 --- a/src/lib/ai/tools/__tests__/edit-item-tool.test.ts +++ b/src/lib/ai/tools/__tests__/edit-item-tool.test.ts @@ -133,6 +133,30 @@ describe("createEditItemTool", () => { expect(result.itemName).toBe("Renamed Note"); }); + it("supports rename-only with oldString='' and newString=''", async () => { + const tool: any = createEditItemTool(ctx); + const result = await tool.execute({ + itemName: "My Note", + oldString: "", + newString: "", + newName: "Renamed Note", + }); + + expect(mockWorkspaceWorker).toHaveBeenCalledWith( + "edit", + expect.objectContaining({ + workspaceId: "ws-1", + itemId: "note-1", + itemType: "note", + oldString: "", + newString: "", + newName: "Renamed Note", + }) + ); + expect(result.success).toBe(true); + expect(result.itemName).toBe("Renamed Note"); + }); + it("passes through worker failures", async () => { mockWorkspaceWorker.mockResolvedValueOnce({ success: false, diff --git a/src/lib/ai/tools/edit-item-tool.ts b/src/lib/ai/tools/edit-item-tool.ts index 527172a4..7f9904e9 100644 --- a/src/lib/ai/tools/edit-item-tool.ts +++ b/src/lib/ai/tools/edit-item-tool.ts @@ -15,7 +15,7 @@ const EDITABLE_TYPES = ["note", "flashcard", "quiz"] as const; export function createEditItemTool(ctx: WorkspaceToolContext) { return tool({ description: - "Edit a note, flashcard deck, or quiz. You must use readWorkspace at least once before editing. QUIZZES: readWorkspace may show '--- Progress (read-only) ---' at the top. That block is READ-ONLY. Never include it in oldString or newString. Only edit the {\"questions\":[...]} JSON. FULL REWRITE: oldString='' and newString=entire new content (quizzes: only the JSON). TARGETED EDIT: oldString must match exactly. When copying from readWorkspace: strip the line number prefix (e.g. '1: ' → the part after the space is the content). For notes, content starts at line 1 — no header to skip. Match exact whitespace, indentation, newlines. Do NOT minify JSON. Edit FAILS if oldString not found or matches multiple times — add more context or use replaceAll.", + "Edit a note, flashcard deck, or quiz. You must use readWorkspace at least once before editing. RENAME ONLY: To rename without editing content, pass oldString='', newString='', and newName='new name'. QUIZZES: readWorkspace may show '--- Progress (read-only) ---' at the top. That block is READ-ONLY. Never include it in oldString or newString. Only edit the {\"questions\":[...]} JSON. FULL REWRITE: oldString='' and newString=entire new content (quizzes: only the JSON). TARGETED EDIT: oldString must match exactly. When copying from readWorkspace: strip the line number prefix (e.g. '1: ' → the part after the space is the content). For notes, content starts at line 1 — no header to skip. Match exact whitespace, indentation, newlines. Do NOT minify JSON. Edit FAILS if oldString not found or matches multiple times — add more context or use replaceAll.", inputSchema: zodSchema( z .object({ @@ -64,21 +64,22 @@ export function createEditItemTool(ctx: WorkspaceToolContext) { }; } - if (oldString === undefined || oldString === null) { + const isRenameOnly = Boolean(newName) && (oldString === undefined || oldString === null || oldString === "") && (newString === undefined || newString === null || newString === ""); + if (!isRenameOnly && (oldString === undefined || oldString === null)) { return { success: false, - message: "oldString is required. Use '' for full rewrite.", + message: "oldString is required. Use '' for full rewrite, or for rename-only pass oldString='', newString='', and newName.", }; } - if (newString === undefined || newString === null) { + if (!isRenameOnly && (newString === undefined || newString === null)) { return { success: false, - message: "newString is required.", + message: "newString is required. For rename-only, pass oldString='', newString='', and newName.", }; } - if (oldString === newString) { + if (oldString === newString && !isRenameOnly) { return { success: false, message: "No changes to apply: oldString and newString are identical.", diff --git a/src/lib/ai/workers/__tests__/workspace-worker.edit.test.ts b/src/lib/ai/workers/__tests__/workspace-worker.edit.test.ts index f45e889b..5c488262 100644 --- a/src/lib/ai/workers/__tests__/workspace-worker.edit.test.ts +++ b/src/lib/ai/workers/__tests__/workspace-worker.edit.test.ts @@ -268,6 +268,45 @@ describe("workspaceWorker edit end-to-end paths", () => { expect(result.message).toMatch(/Invalid JSON after edit/i); }); + it("rename-only: updates note name without touching content", async () => { + const noteContent = "Original note content"; + mockLoadWorkspaceState.mockResolvedValue({ + items: [ + { + id: "note-1", + type: "note", + name: "My Note", + subtitle: "", + data: { field1: noteContent, blockContent: [] }, + }, + ], + globalTitle: "", + workspaceId: "ws-1", + }); + + mockExecute + .mockResolvedValueOnce([{ version: 1 }]) // get_workspace_version + .mockResolvedValueOnce([{ result: "(2,f)" }]); // append_workspace_event + + const result = await workspaceWorker("edit", { + workspaceId: "ws-1", + itemId: "note-1", + itemType: "note", + itemName: "My Note", + oldString: "", + newString: "", + newName: "Renamed Note", + }); + + expect(result.success).toBe(true); + expect(result.message).toMatch(/Updated note successfully/); + expect(mockMarkdownToBlocks).not.toHaveBeenCalled(); + expect(mockCreateEvent).toHaveBeenCalled(); + const eventPayload = mockCreateEvent.mock.calls[0][1] as { id: string; changes: Record }; + expect(eventPayload.id).toBe("note-1"); + expect(eventPayload.changes).toEqual({ name: "Renamed Note" }); + }); + it("fails with clear message when oldString is ambiguous", async () => { mockLoadWorkspaceState.mockResolvedValue({ items: [ diff --git a/src/lib/ai/workers/workspace-worker.ts b/src/lib/ai/workers/workspace-worker.ts index e9a30a3e..58fd4a69 100644 --- a/src/lib/ai/workers/workspace-worker.ts +++ b/src/lib/ai/workers/workspace-worker.ts @@ -980,7 +980,11 @@ export async function workspaceWorker( if (!params.itemId || !params.itemType) { throw new Error("Item ID and itemType required for edit"); } - if (params.oldString === undefined || params.newString === undefined) { + const isRenameOnly = + params.newName && + (params.oldString === undefined || params.oldString === "") && + (params.newString === undefined || params.newString === ""); + if (!isRenameOnly && (params.oldString === undefined || params.newString === undefined)) { throw new Error("oldString and newString required for edit"); } @@ -992,8 +996,8 @@ export async function workspaceWorker( const rename = params.newName; const replaceAll = !!params.replaceAll; - const oldStr = String(params.oldString); - const newStr = String(params.newString); + const oldStr = String(params.oldString ?? ""); + const newStr = String(params.newString ?? ""); if (existingItem.type === "note") { const changes: Partial = {}; @@ -1006,20 +1010,26 @@ export async function workspaceWorker( let contentOld = ""; let contentNew = ""; - if (oldStr === "") { + if (isRenameOnly) { contentOld = ""; - contentNew = newStr; + contentNew = ""; + // Skip content update - only apply rename via changes.name above } else { - contentOld = normalizeLineEndings(getNoteContentAsMarkdown(existingItem.data as NoteData)); - const normOld = normalizeLineEndings(oldStr); - const normNew = normalizeLineEndings(newStr); - contentNew = applyReplace(contentOld, normOld, normNew, replaceAll); - } - contentNew = fixLLMDoubleEscaping(contentNew); - const blockContent = await markdownToBlocks(contentNew); - changes.data = { field1: contentNew, blockContent } as NoteData; - if (params.sources !== undefined) { - (changes.data as NoteData).sources = params.sources; + if (oldStr === "") { + contentOld = ""; + contentNew = newStr; + } else { + contentOld = normalizeLineEndings(getNoteContentAsMarkdown(existingItem.data as NoteData)); + const normOld = normalizeLineEndings(oldStr); + const normNew = normalizeLineEndings(newStr); + contentNew = applyReplace(contentOld, normOld, normNew, replaceAll); + } + contentNew = fixLLMDoubleEscaping(contentNew); + const blockContent = await markdownToBlocks(contentNew); + changes.data = { field1: contentNew, blockContent } as NoteData; + if (params.sources !== undefined) { + (changes.data as NoteData).sources = params.sources; + } } if (Object.keys(changes).length === 0) { @@ -1081,12 +1091,14 @@ export async function workspaceWorker( })), }; let serialized = JSON.stringify(payload, null, 2); - serialized = applyReplace( - normalizeLineEndings(serialized), - normalizeLineEndings(oldStr), - normalizeLineEndings(newStr), - replaceAll - ); + if (!isRenameOnly) { + serialized = applyReplace( + normalizeLineEndings(serialized), + normalizeLineEndings(oldStr), + normalizeLineEndings(newStr), + replaceAll + ); + } let parsed: { cards?: Array<{ id?: string; front?: string; back?: string }> }; try { @@ -1157,12 +1169,14 @@ export async function workspaceWorker( const questions = data.questions ?? []; const payload = { questions }; let serialized = JSON.stringify(payload, null, 2); - serialized = applyReplace( - normalizeLineEndings(serialized), - normalizeLineEndings(oldStr), - normalizeLineEndings(newStr), - replaceAll - ); + if (!isRenameOnly) { + serialized = applyReplace( + normalizeLineEndings(serialized), + normalizeLineEndings(oldStr), + normalizeLineEndings(newStr), + replaceAll + ); + } let parsed: { questions?: QuizQuestion[] }; try { From ad107e778611a1fa53ddf0dbac28ed86d26adeb7 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Mon, 9 Mar 2026 14:13:11 -0400 Subject: [PATCH 14/18] fix: close password protect dialog when clicking Unlock PDF Made-with: Cursor --- src/components/modals/PasswordProtectedPdfDialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/modals/PasswordProtectedPdfDialog.tsx b/src/components/modals/PasswordProtectedPdfDialog.tsx index 8f2c7c6c..504361f3 100644 --- a/src/components/modals/PasswordProtectedPdfDialog.tsx +++ b/src/components/modals/PasswordProtectedPdfDialog.tsx @@ -82,7 +82,7 @@ export function PasswordProtectedPdfDialog() { -