diff --git a/.gitignore b/.gitignore index 46d0d63d..78ab3d43 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +stats.html node_modules .DS_Store dist/ @@ -14,5 +15,4 @@ convex/_generated/ scripts/__pycache__/ */__pycache__/* .tanstack -bun.lock -stats.html \ No newline at end of file +bun.lock \ No newline at end of file diff --git a/bun.lock b/bun.lock index 9c378a78..444ec0dc 100644 --- a/bun.lock +++ b/bun.lock @@ -9,6 +9,9 @@ "@codesandbox/sandpack-themes": "^2.0.21", "@convex-dev/auth": "^0.0.87", "@convex-dev/react-query": "^0.0.0-alpha.11", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@isomorphic-git/lightning-fs": "^4.6.2", "@langchain/community": "^0.3.47", "@langchain/core": "^0.3.61", @@ -48,6 +51,7 @@ "@tailwindcss/line-clamp": "^0.4.4", "@tailwindcss/typography": "^0.5.16", "@tailwindcss/vite": "^4.0.6", + "@tanstack/react-query": "^5.85.5", "@tanstack/react-router": "^1.114.3", "@tanstack/react-router-devtools": "^1.114.3", "@tanstack/react-virtual": "^3.13.12", @@ -274,6 +278,14 @@ "@discoveryjs/json-ext": ["@discoveryjs/json-ext@0.5.7", "", {}, "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw=="], + "@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="], + + "@dnd-kit/core": ["@dnd-kit/core@6.3.1", "", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="], + + "@dnd-kit/sortable": ["@dnd-kit/sortable@10.0.0", "", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg=="], + + "@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="], + "@emnapi/core": ["@emnapi/core@0.45.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-DPWjcUDQkCeEM4VnljEOEcXdAD7pp8zSZsgOujk/LGIwCXWbXJngin+MO4zbH429lzeC3WbYLGjE2MaUOwzpyw=="], "@emnapi/runtime": ["@emnapi/runtime@0.45.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Txumi3td7J4A/xTTwlssKieHKTGl3j4A1tglBx72auZ49YK7ePY6XZricgIg9mnZT4xPfA+UPCUdnhRuEFDL+w=="], @@ -766,11 +778,11 @@ "@tanstack/history": ["@tanstack/history@1.121.34", "", {}, "sha512-YL8dGi5ZU+xvtav2boRlw4zrRghkY6hvdcmHhA0RGSJ/CBgzv+cbADW9eYJLx74XMZvIQ1pp6VMbrpXnnM5gHA=="], - "@tanstack/query-core": ["@tanstack/query-core@5.83.1", "", {}, "sha512-OG69LQgT7jSp+5pPuCfzltq/+7l2xoweggjme9vlbCPa/d7D7zaqv5vN/S82SzSYZ4EDLTxNO1PWrv49RAS64Q=="], + "@tanstack/query-core": ["@tanstack/query-core@5.85.5", "", {}, "sha512-KO0WTob4JEApv69iYp1eGvfMSUkgw//IpMnq+//cORBzXf0smyRwPLrUvEe5qtAEGjwZTXrjxg+oJNP/C00t6w=="], "@tanstack/query-devtools": ["@tanstack/query-devtools@5.84.0", "", {}, "sha512-fbF3n+z1rqhvd9EoGp5knHkv3p5B2Zml1yNRjh7sNXklngYI5RVIWUrUjZ1RIcEoscarUb0+bOvIs5x9dwzOXQ=="], - "@tanstack/react-query": ["@tanstack/react-query@5.84.1", "", { "dependencies": { "@tanstack/query-core": "5.83.1" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-zo7EUygcWJMQfFNWDSG7CBhy8irje/XY0RDVKKV4IQJAysb+ZJkkJPcnQi+KboyGUgT+SQebRFoTqLuTtfoDLw=="], + "@tanstack/react-query": ["@tanstack/react-query@5.85.5", "", { "dependencies": { "@tanstack/query-core": "5.85.5" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-/X4EFNcnPiSs8wM2v+b6DqS5mmGeuJQvxBglmDxl6ZQb5V26ouD2SJYAcC3VjbNwqhY2zjxVD15rDA5nGbMn3A=="], "@tanstack/react-query-devtools": ["@tanstack/react-query-devtools@5.84.1", "", { "dependencies": { "@tanstack/query-devtools": "5.84.0" }, "peerDependencies": { "@tanstack/react-query": "^5.84.1", "react": "^18 || ^19" } }, "sha512-nle+OQ9B3Z3EG2R3ixvaNcJ6OeqGwmAc5iMDW6Vj+emLZkWRrN3BDsrzZQu414n34lpxplnC7z1jmKuU/scHCQ=="], diff --git a/services/mcps b/services/mcps index 2d17c1a9..2a33ac72 160000 --- a/services/mcps +++ b/services/mcps @@ -1 +1 @@ -Subproject commit 2d17c1a9eca5a2986474866696a7fa08b594638f +Subproject commit 2a33ac72eaa99ca4b87a2e85991523696de5dd1a diff --git a/src/components/chat/input/document-list.tsx b/src/components/chat/input/document-list.tsx index 9bae7b9e..d983068f 100644 --- a/src/components/chat/input/document-list.tsx +++ b/src/components/chat/input/document-list.tsx @@ -4,6 +4,8 @@ import { convexQuery } from "@convex-dev/react-query"; import { api } from "../../../../convex/_generated/api"; import type { Id, Doc } from "../../../../convex/_generated/dataModel"; import { XIcon } from "lucide-react"; +import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { ErrorState } from "@/components/ui/error-state"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { documentDialogOpenAtom } from "@/store/chatStore"; @@ -74,10 +76,16 @@ export const DocumentList = ({ documentIds?: Id<"documents">[]; model: string; }) => { - const { data: documents } = useQuery({ + const { + data: documents, + isLoading: isLoadingDocuments, + isError: isDocumentsError, + error: documentsError, + } = useQuery({ ...convexQuery(api.documents.queries.getMultiple, { documentIds }), }); const setDocumentDialogOpen = useSetAtom(documentDialogOpenAtom); + const removeDocument = useRemoveDocument(); const handlePreview = useCallback( @@ -99,6 +107,32 @@ export const DocumentList = ({ const selectedModel = models.find((m) => m.model_name === model); const modalities = selectedModel?.modalities; + if (!documents?.length) return null; + + if (isLoadingDocuments) { + return ( +
+
+ + Loading attached documents... +
+
+ ); + } + + if (isDocumentsError || documentsError) { + return ( +
+ +
+ ); + } + return (
diff --git a/src/components/chat/input/toolbar/index.tsx b/src/components/chat/input/toolbar/index.tsx index 9903fca5..65fd05de 100644 --- a/src/components/chat/input/toolbar/index.tsx +++ b/src/components/chat/input/toolbar/index.tsx @@ -45,6 +45,8 @@ import { ModelPopover } from "./model-popover"; import { useRouter } from "@tanstack/react-router"; import { AgentToggles } from "./agent-toggles"; import { useApiKeys } from "@/hooks/use-apikeys"; +import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { ErrorState } from "@/components/ui/error-state"; import { toast } from "sonner"; export const ToolBar = () => { @@ -74,7 +76,11 @@ export const ToolBar = () => { useApiKeys(); // Get project details if chat has a project - const { data: project } = useQuery({ + const { + data: project, + isLoading: isLoadingProject, + isError: isProjectError, + } = useQuery({ ...convexQuery( api.projects.queries.get, chat?.projectId ? { projectId: chat.projectId } : "skip", @@ -152,7 +158,26 @@ export const ToolBar = () => { {/* Render project name with X button on hover */} - {project && ( + {isLoadingProject && ( + + )} + {isProjectError && ( + + )} + {project && !isLoadingProject && !isProjectError && (
- {projects?.page.slice(0, 3).map((project) => ( - { - if (chatId === "new") { - setNewChat((prev) => ({ ...prev, projectId: project._id })); - } else { - updateChatMutation({ - chatId, - updates: { - projectId: project._id, - }, - }); - } - onCloseDropdown(); - setResizePanelOpen(true); - setSelectedPanelTab("projects"); - }} - > - {isFetchingProjects ?
Fetching...
: project.name} -
- ))} - - { - e.preventDefault(); - onCloseDropdown(); - setProjectDialogOpen(true); - }} - > - - Add New Project - + {isProjectsError ? ( + + ) : ( + <> + {isLoadingProjects ? ( + + + + ) : ( + projects?.page?.slice(0, 3).map((project: any) => ( + { + if (chatId === "new") { + setNewChat((prev) => ({ + ...prev, + projectId: project._id, + })); + } else { + updateChatMutation({ + chatId, + updates: { + projectId: project._id, + }, + }); + } + onCloseDropdown(); + setResizePanelOpen(true); + setSelectedPanelTab("projects"); + }} + > + {project.name} + + )) + )} + + { + e.preventDefault(); + onCloseDropdown(); + setProjectDialogOpen(true); + }} + > + + Add New Project + + + )}
); diff --git a/src/components/chat/messages/index.tsx b/src/components/chat/messages/index.tsx index 4eba478c..a17762ab 100644 --- a/src/components/chat/messages/index.tsx +++ b/src/components/chat/messages/index.tsx @@ -1,4 +1,5 @@ import { useMessages } from "../../../hooks/chats/use-messages"; +import { ErrorState } from "@/components/ui/error-state"; import { useMemo } from "react"; import { ScrollArea } from "@/components/ui/scroll-area"; import { MessagesList } from "./messages"; @@ -7,10 +8,12 @@ import { TriangleAlertIcon } from "lucide-react"; import type { Id } from "../../../../convex/_generated/dataModel"; import { streamStatusAtom, userLoadableAtom } from "@/store/chatStore"; import { useAtomValue } from "jotai"; +import { LoadingSpinner } from "@/components/ui/loading-spinner"; export const ChatMessages = ({ chatId }: { chatId: Id<"chats"> | "new" }) => { const userLoadable = useAtomValue(userLoadableAtom); - const { isLoading, isEmpty } = useMessages({ chatId }); + const { isLoading, isEmpty, isError, error, isStreamError, streamError } = + useMessages({ chatId }); const streamStatus = useAtomValue(streamStatusAtom); @@ -38,11 +41,41 @@ export const ChatMessages = ({ chatId }: { chatId: Id<"chats"> | "new" }) => { if (isLoading) { return (
+
Loading messages...
); } + if (isError || error) { + return ( +
+ +
+ ); + } + + if (isStreamError || streamError) { + return ( +
+ +
+ ); + } + if (isEmpty) { return (
diff --git a/src/components/chat/messages/user-message.tsx b/src/components/chat/messages/user-message.tsx index 43617659..a9d2df2d 100644 --- a/src/components/chat/messages/user-message.tsx +++ b/src/components/chat/messages/user-message.tsx @@ -6,6 +6,8 @@ import type { MessageGroup, } from "../../../../convex/chatMessages/helpers"; import { Button } from "@/components/ui/button"; +import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { ErrorState } from "@/components/ui/error-state"; import { documentDialogOpenAtom } from "@/store/chatStore"; import { useSetAtom } from "jotai"; import { api } from "../../../../convex/_generated/api"; @@ -32,10 +34,37 @@ const DocumentList = ({ onPreview?: (documentId: Id<"documents">) => void; showRemove?: boolean; }) => { - const { data: documents } = useQuery({ + const { + data: documents, + isLoading, + isError, + } = useQuery({ ...convexQuery(api.documents.queries.getMultiple, { documentIds }), }); + if (isLoading) { + return ( +
+ + + Loading documents... + +
+ ); + } + + if (isError) { + return ( +
+ +
+ ); + } + if (!documents?.length) return null; const selectedModel = models.find((m) => m.model_name === "gpt-4"); diff --git a/src/components/chat/messages/utils-bar/index.ts b/src/components/chat/messages/utils-bar/index.ts index c7bedae8..cf624c3f 100644 --- a/src/components/chat/messages/utils-bar/index.ts +++ b/src/components/chat/messages/utils-bar/index.ts @@ -11,66 +11,66 @@ export { AiUtilsBar } from "./ai-utils-bar"; // Helper function for navigation logic const navigateToChat = ( - navigate: ReturnType, - chatId: Id<"chats">, + navigate: ReturnType, + chatId: Id<"chats">, ) => { - navigate({ - to: "/chat/$chatId", - params: { chatId }, - }); + navigate({ + to: "/chat/$chatId", + params: { chatId }, + }); }; export function useMessageActions() { - const regenerate = useAction(api.langchain.index.regenerate); - const branchChat = useAction(api.langchain.index.branchChat); - const chat = useAction(api.langchain.index.chat); - const navigate = useNavigate(); - const navigateBranch = useNavigateBranch(); - const { mutateAsync: updateChatMutation } = useMutation({ - mutationFn: useConvexMutation(api.chats.mutations.update), - }); + const regenerate = useAction(api.langchain.index.regenerate); + const branchChat = useAction(api.langchain.index.branchChat); + const chat = useAction(api.langchain.index.chat); + const navigate = useNavigate(); + const navigateBranch = useNavigateBranch(); + const { mutateAsync: updateChatMutation } = useMutation({ + mutationFn: useConvexMutation(api.chats.mutations.update), + }); - const handleBranch = async ( - input: MessageWithBranchInfo, - model?: string, - editedContent?: { text?: string; documents?: Id<"documents">[] }, - ) => { - const result = await branchChat({ - chatId: input.message.chatId!, - branchFrom: input.message._id, - ...(model && { model }), - ...(editedContent && { editedContent }), - }); - if (result?.newChatId) { - // Model is already persisted by branchChat; start chat without forwarding model - chat({ - chatId: result.newChatId, - }); - navigateToChat(navigate, result.newChatId); - } - }; + const handleBranch = async ( + input: MessageWithBranchInfo, + model?: string, + editedContent?: { text?: string; documents?: Id<"documents">[] }, + ) => { + const result = await branchChat({ + chatId: input.message.chatId, + branchFrom: input.message._id, + ...(model && { model }), + ...(editedContent && { editedContent }), + }); + if (result?.newChatId) { + // Model is already persisted by branchChat; start chat without forwarding model + chat({ + chatId: result.newChatId, + }); + navigateToChat(navigate, result.newChatId); + } + }; - const handleRegenerate = async ( - input: MessageWithBranchInfo, - model?: string, - ) => { - navigateBranch?.(input.depth, "next", input.totalBranches); - // If the model is provided, update the chat with the new model or - if (model) { - await updateChatMutation({ - chatId: input.message.chatId!, - updates: { model }, - }); - } - await regenerate({ - messageId: input.message._id, - }); - }; + const handleRegenerate = async ( + input: MessageWithBranchInfo, + model?: string, + ) => { + navigateBranch?.(input.depth, "next", input.totalBranches); + // If the model is provided, update the chat with the new model or + if (model) { + await updateChatMutation({ + chatId: input.message.chatId, + updates: { model }, + }); + } + await regenerate({ + messageId: input.message._id, + }); + }; - return { - handleBranch, - handleRegenerate, - navigate, - navigateBranch, - }; + return { + handleBranch, + handleRegenerate, + navigate, + navigateBranch, + }; } diff --git a/src/components/chat/messages/utils-bar/user-utils-bar.tsx b/src/components/chat/messages/utils-bar/user-utils-bar.tsx index 0d35bf41..18c5e8bc 100644 --- a/src/components/chat/messages/utils-bar/user-utils-bar.tsx +++ b/src/components/chat/messages/utils-bar/user-utils-bar.tsx @@ -3,13 +3,13 @@ import type { Dispatch, SetStateAction } from "react"; import { BranchNavigation } from "./branch-navigation"; import { Button } from "@/components/ui/button"; import { - Check, - CheckCheck, - GitBranch, - Pencil, - RefreshCcw, - X, - PaperclipIcon, + Check, + CheckCheck, + GitBranch, + Pencil, + RefreshCcw, + X, + PaperclipIcon, } from "lucide-react"; import { ActionDropdown } from "./action-dropdown"; import { useMutation, useAction } from "convex/react"; @@ -22,222 +22,232 @@ import { useMessageActions } from "./index"; import { useUploadDocuments } from "@/hooks/chats/use-documents"; interface MessageContent { - type: string; - text?: string; + type: string; + text?: string; } interface UserUtilsBarProps { - input: MessageWithBranchInfo; - isEditing?: boolean; - setEditing?: Dispatch>; - editedText?: string; - editedDocuments?: Id<"documents">[]; - onDone?: () => void; - onDocumentsChange?: (documents: Id<"documents">[]) => void; + input: MessageWithBranchInfo; + isEditing?: boolean; + setEditing?: Dispatch>; + editedText?: string; + editedDocuments?: Id<"documents">[]; + onDone?: () => void; + onDocumentsChange?: (documents: Id<"documents">[]) => void; } export const UserUtilsBar = memo( - ({ - input, - isEditing, - setEditing, - editedText, - editedDocuments, - onDone, - onDocumentsChange, - }: UserUtilsBarProps) => { - const { handleBranch, handleRegenerate, navigateBranch } = - useMessageActions(); - const updateMessage = useMutation(api.chatMessages.mutations.updateInput); - const chat = useAction(api.langchain.index.chat); - const fileInputRef = useRef(null); - const [isDragActive, setIsDragActive] = useState(false); - const handleFileUpload = useUploadDocuments({ type: "file" }); + ({ + input, + isEditing, + setEditing, + editedText, + editedDocuments, + onDone, + onDocumentsChange, + }: UserUtilsBarProps) => { + const { handleBranch, handleRegenerate, navigateBranch } = + useMessageActions(); + const updateMessage = useMutation(api.chatMessages.mutations.updateInput); + const chat = useAction(api.langchain.index.chat); + const fileInputRef = useRef(null); + const [isDragActive, setIsDragActive] = useState(false); + const handleFileUpload = useUploadDocuments({ type: "file" }); - // File upload handlers for editing - const handleFileInputChange = useCallback( - async (e: React.ChangeEvent) => { - if (e.target.files && e.target.files.length > 0) { - const uploadedIds = await handleFileUpload(e.target.files); - if (uploadedIds && onDocumentsChange) { - onDocumentsChange([...(editedDocuments || []), ...uploadedIds]); - } - } - }, - [handleFileUpload, editedDocuments, onDocumentsChange] - ); + // File upload handlers for editing + const handleFileInputChange = useCallback( + async (e: React.ChangeEvent) => { + if (e.target.files && e.target.files.length > 0) { + const uploadedIds = await handleFileUpload(e.target.files); + if (uploadedIds && onDocumentsChange) { + onDocumentsChange([...(editedDocuments || []), ...uploadedIds]); + } + } + }, + [handleFileUpload, editedDocuments, onDocumentsChange], + ); - const handleDrop = useCallback( - async (e: React.DragEvent) => { - e.preventDefault(); - setIsDragActive(false); - if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { - const uploadedIds = await handleFileUpload(e.dataTransfer.files); - if (uploadedIds && onDocumentsChange) { - onDocumentsChange([...(editedDocuments || []), ...uploadedIds]); - } - } - }, - [handleFileUpload, editedDocuments, onDocumentsChange] - ); + const handleDrop = useCallback( + async (e: React.DragEvent) => { + e.preventDefault(); + setIsDragActive(false); + if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { + const uploadedIds = await handleFileUpload(e.dataTransfer.files); + if (uploadedIds && onDocumentsChange) { + onDocumentsChange([...(editedDocuments || []), ...uploadedIds]); + } + } + }, + [handleFileUpload, editedDocuments, onDocumentsChange], + ); - const handleDragOver = useCallback( - (e: React.DragEvent) => { - e.preventDefault(); - if (!isDragActive) setIsDragActive(true); - }, - [isDragActive] - ); + const handleDragOver = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + if (!isDragActive) setIsDragActive(true); + }, + [isDragActive], + ); - const handleDragLeave = useCallback( - (e: React.DragEvent) => { - e.preventDefault(); - if (e.currentTarget.contains(e.relatedTarget as Node)) return; - setIsDragActive(false); - }, - [] - ); + const handleDragLeave = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + const next = (e.relatedTarget as Node | null) ?? null; + if (next && e.currentTarget.contains(next)) return; + setIsDragActive(false); + }, + [], + ); - const copyText = (() => { - const content = input?.message.message.content; - if (!content) return ""; + const copyText = (() => { + const content = input?.message.message.content; + if (!content) return ""; - if (Array.isArray(content)) { - const textContent = (content as MessageContent[]).find( - (entry) => entry.type === "text" - ); - return textContent?.text || ""; - } - return typeof content === "string" ? content : ""; - })(); + if (Array.isArray(content)) { + const textContent = (content as MessageContent[]).find( + (entry) => entry.type === "text", + ); + return textContent?.text || ""; + } + return typeof content === "string" ? content : ""; + })(); - const handleSubmit = (applySame: boolean) => { - if (applySame === false) { - navigateBranch?.(input.depth, input.totalBranches); - } - updateMessage({ - id: input.message._id as Id<"chatMessages">, - updates: { text: editedText!, documents: editedDocuments || [] }, - applySame: applySame, - }).then(() => { - if (applySame === false) { - chat({ chatId: input.message.chatId! }); - } - }); - onDone?.(); - }; + const handleSubmit = (applySame: boolean) => { + if (!editedText) return; - if (isEditing) { - return ( -
- - setEditing?.(null)} - icon={} - tooltip="Cancel" - /> - handleSubmit(true)} - icon={} - tooltip="Submit" - ariaLabel="Submit" - /> - handleSubmit(false)} - icon={} - tooltip="Submit and Regenerate" - ariaLabel="Submit and Regenerate" - /> - - - - } - actionLabel={ - <> - - Branch from edited - - } - onAction={() => - handleBranch(input, undefined, { - text: editedText, - documents: editedDocuments, - }) - } - onActionWithModel={(model) => - handleBranch(input, model, { - text: editedText, - documents: editedDocuments, - }) - } - /> - fileInputRef.current?.click()} - icon={} - tooltip="Attach files" - /> -
- ); - } + if (applySame === false) { + navigateBranch?.(input.depth, "next", input.totalBranches); + } + updateMessage({ + id: input.message._id as Id<"chatMessages">, + updates: { text: editedText, documents: editedDocuments || [] }, + applySame: applySame, + }).then(() => { + if (applySame === false) { + chat({ chatId: input.message.chatId }); + } + }); + onDone?.(); + }; - return ( -
- - {setEditing && ( - setEditing(input.message._id)} - icon={} - tooltip="Edit" - ariaLabel="Edit" - /> - )} - - - - } - actionLabel={ - <> - - Branch - - } - onAction={() => handleBranch(input)} - onActionWithModel={(model) => handleBranch(input, model)} - /> - - - - } - actionLabel={ - <> - - Regenerate - - } - onAction={() => handleRegenerate(input)} - onActionWithModel={(model) => handleRegenerate(input, model)} - /> - {copyText && } -
- ); - } + if (isEditing) { + return ( +
+ + setEditing?.(null)} + icon={} + tooltip="Cancel" + /> + handleSubmit(true)} + disabled={!editedText || editedText.length === 0} + icon={} + tooltip="Submit" + ariaLabel="Submit" + /> + handleSubmit(false)} + disabled={!editedText || editedText.length === 0} + icon={} + tooltip="Submit and Regenerate" + ariaLabel="Submit and Regenerate" + /> + + + + } + actionLabel={ + <> + + Branch from edited + + } + onAction={() => + handleBranch(input, undefined, { + text: editedText, + documents: editedDocuments, + }) + } + onActionWithModel={(model) => + handleBranch(input, model, { + text: editedText, + documents: editedDocuments, + }) + } + /> + fileInputRef.current?.click()} + icon={} + tooltip="Attach files" + /> +
+ ); + } + + return ( +
+ + {setEditing && ( + setEditing(input.message._id)} + icon={} + tooltip="Edit" + ariaLabel="Edit" + /> + )} + + + + } + actionLabel={ + <> + + Branch + + } + onAction={() => handleBranch(input)} + onActionWithModel={(model) => handleBranch(input, model)} + /> + + + + } + actionLabel={ + <> + + Regenerate + + } + onAction={() => handleRegenerate(input)} + onActionWithModel={(model) => handleRegenerate(input, model)} + /> + {copyText && } +
+ ); + }, ); UserUtilsBar.displayName = "UserUtilsBar"; diff --git a/src/components/chat/panels/mcp/index.tsx b/src/components/chat/panels/mcp/index.tsx index 6da15667..ff91baca 100644 --- a/src/components/chat/panels/mcp/index.tsx +++ b/src/components/chat/panels/mcp/index.tsx @@ -1,9 +1,19 @@ import { MCPCard } from "./mcp-card"; import { useMCPs } from "@/hooks/chats/use-mcp"; +import { ErrorState } from "@/components/ui/error-state"; import { MCPDialog } from "./mcp-dialog"; +import { LoadingSpinner } from "@/components/ui/loading-spinner"; export const MCPPanel = () => { - const { getAllMCPs, toggleMCP, handleDelete, restartMCP } = useMCPs(); + const { + getAllMCPs, + toggleMCP, + handleDelete, + restartMCP, + isLoading, + isError, + error, + } = useMCPs(); const mcps = getAllMCPs(); @@ -17,7 +27,26 @@ export const MCPPanel = () => {
- {mcps?.page.map((mcp) => ( + {isLoading && ( +
+ +
+ )} + {(isError || error) && ( +
+ +
+ )} + {mcps?.page?.length === 0 && mcps && !isLoading && !isError && ( +
+
Currently no MCPs are running.
+
+ )} + {mcps?.page?.map((mcp) => ( { const navigate = useNavigate(); - const { data: chats } = useQuery({ + const { + data: chats, + isLoading: isLoadingChats, + isError: isChatsError, + } = useQuery({ ...convexQuery(api.chats.queries.getByProjectId, { projectId }), }); + if (isLoadingChats) { + return ( +
+ +
+ ); + } + + if (isChatsError) { + return ( +
+ +
+ ); + } + if (!chats || chats.length === 0) { return ( diff --git a/src/components/chat/panels/projects/details.tsx b/src/components/chat/panels/projects/details.tsx index b42d0bf4..f70ee3d5 100644 --- a/src/components/chat/panels/projects/details.tsx +++ b/src/components/chat/panels/projects/details.tsx @@ -2,6 +2,8 @@ import { useQuery, useMutation } from "@tanstack/react-query"; import { convexQuery, useConvexMutation } from "@convex-dev/react-query"; import { api } from "../../../../../convex/_generated/api"; import { XIcon } from "lucide-react"; +import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { ErrorState } from "@/components/ui/error-state"; import { Button } from "@/components/ui/button"; import { AutosizeTextarea } from "@/components/ui/autosize-textarea"; import { useDebouncedCallback } from "use-debounce"; @@ -18,7 +20,12 @@ export const ProjectDetails = ({ projectId }: ProjectDetailsProps) => { const chatId = useAtomValue(chatIdAtom); const navigate = useNavigate(); const router = useRouter(); - const { data: project } = useQuery({ + const { + data: project, + isLoading: isLoadingProject, + isError: isProjectError, + error: projectError, + } = useQuery({ ...convexQuery( api.projects.queries.get, projectId ? { projectId } : "skip" @@ -41,10 +48,34 @@ export const ProjectDetails = ({ projectId }: ProjectDetailsProps) => { }); }, 1000); + if (isLoadingProject) { + return ( +
+ +
+ ); + } + + if (isProjectError) { + return ( +
+ +
+ ); + } + if (!project) return null; return ( -
+

{project.name}

diff --git a/src/components/chat/panels/projects/document-list.tsx b/src/components/chat/panels/projects/document-list.tsx index a8c10886..244b799c 100644 --- a/src/components/chat/panels/projects/document-list.tsx +++ b/src/components/chat/panels/projects/document-list.tsx @@ -6,13 +6,20 @@ import { api } from "../../../../../convex/_generated/api"; import type { Id } from "../../../../../convex/_generated/dataModel"; import { ProjectDocumentListItem } from "./document-list-item"; import { Toggle } from "@/components/ui/toggle"; +import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { ErrorState } from "@/components/ui/error-state"; export function ProjectDocumentList({ projectId, }: { projectId: Id<"projects">; }) { - const { data: projectDocuments } = useQuery({ + const { + data: projectDocuments, + isLoading: isLoadingProjectDocs, + isError: isProjectDocumentsError, + error: projectDocumentsError, + } = useQuery({ ...convexQuery( api.projectDocuments.queries.getAll, projectId @@ -23,6 +30,7 @@ export function ProjectDocumentList({ : "skip", ), }); + const { mutate: toggleSelectAll, isPending: isTogglingSelectAll } = useMutation({ mutationFn: useConvexMutation( @@ -31,7 +39,7 @@ export function ProjectDocumentList({ }); const handleSelectAll = async (checked: boolean) => { - await toggleSelectAll({ + toggleSelectAll({ projectId, selected: checked, }); @@ -58,7 +66,7 @@ export function ProjectDocumentList({ handleSelectAll(pressed)} - disabled={isTogglingSelectAll} + disabled={isTogglingSelectAll || isLoadingProjectDocs} className="w-36 gap-2 cursor-pointer rounded-lg flex items-center justify-center" variant={allSelected ? "default" : "outline"} > @@ -84,15 +92,31 @@ export function ProjectDocumentList({
- -
- {projectDocuments.projectDocuments.map((projectDocument) => ( + + {isLoadingProjectDocs ? ( +
+ +

+ Loading project documents... +

+
+ ) : isProjectDocumentsError || projectDocumentsError ? ( +
+ +
+ ) : ( + projectDocuments.projectDocuments.map((projectDocument) => ( - ))} -
+ )) + )}
); diff --git a/src/components/chat/panels/projects/list.tsx b/src/components/chat/panels/projects/list.tsx index 8dda17db..b8c41ee3 100644 --- a/src/components/chat/panels/projects/list.tsx +++ b/src/components/chat/panels/projects/list.tsx @@ -2,6 +2,8 @@ import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { ScrollArea } from "@/components/ui/scroll-area"; import { PlusIcon, TrashIcon } from "lucide-react"; +import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { ErrorState } from "@/components/ui/error-state"; import { useQuery, useMutation } from "@tanstack/react-query"; import { convexQuery, useConvexMutation } from "@convex-dev/react-query"; import { api } from "../../../../../convex/_generated/api"; @@ -16,7 +18,12 @@ export const ProjectsList = () => { const chatId = useAtomValue(chatIdAtom); const navigate = useNavigate(); const router = useRouter(); - const { data: allProjects } = useQuery({ + const { + data: allProjects, + isLoading: isLoadingProjects, + isError: isProjectsError, + error: projectsError, + } = useQuery({ ...convexQuery(api.projects.queries.getAll, { paginationOpts: { numItems: 20, cursor: null }, }), @@ -34,6 +41,30 @@ export const ProjectsList = () => { const isOnProjectsRoute = useLocation().pathname === "/projects"; + if (isLoadingProjects) { + return ( +
+
+ + Loading projects... +
+
+ ); + } + + if (isProjectsError) { + return ( +
+ +
+ ); + } + return ( <>
{ + try { + const parsed = new URL(url); + return parsed.protocol === "http:" || parsed.protocol === "https:"; + } catch { + return false; + } +}; + export const DocumentDialog = () => { - const documentDialogOpen = useAtomValue(documentDialogOpenAtom); - const setDocumentDialogOpen = useSetAtom(documentDialogOpenAtom); - const [previewUrl, setPreviewUrl] = useState(null); + const documentDialogOpen = useAtomValue(documentDialogOpenAtom); + const setDocumentDialogOpen = useSetAtom(documentDialogOpenAtom); + const [previewUrl, setPreviewUrl] = useState(null); + const [previewError, setPreviewError] = useState(null); - const { data: document } = useQuery({ - ...convexQuery( - api.documents.queries.get, - documentDialogOpen ? { documentId: documentDialogOpen } : "skip", - ), - enabled: !!documentDialogOpen, - }); + const { data: document } = useQuery({ + ...convexQuery( + api.documents.queries.get, + documentDialogOpen ? { documentId: documentDialogOpen } : "skip", + ), + enabled: !!documentDialogOpen, + }); - const { mutateAsync: generateDownloadUrl } = useMutation({ - mutationFn: useConvexMutation(api.documents.mutations.generateDownloadUrl), - }); + const { mutateAsync: generateDownloadUrl } = useMutation({ + mutationFn: useConvexMutation(api.documents.mutations.generateDownloadUrl), + }); - const documentName = document?.name ?? ""; - const { - icon: Icon, - className: IconClassName, - tag, - } = document - ? getDocTagInfo(document) - : { icon: () => null, className: "", tag: "" }; + const documentName = document?.name ?? ""; + const { + icon: Icon, + className: IconClassName, + tag, + } = document + ? getDocTagInfo(document) + : { icon: () => null, className: "", tag: "" }; - useEffect(() => { - setPreviewUrl(null); - const loadPreviewUrl = async () => { - if (!document) return; - switch (tag) { - case "image": - case "pdf": - case "file": { - // Only files need download URL - const url = await generateDownloadUrl({ - documentId: document._id!, - }); - setPreviewUrl(url); - break; - } - case "url": - case "site": { - setPreviewUrl(document.key as string); - break; - } - case "youtube": { - setPreviewUrl(`https://www.youtube.com/embed/${document.key}`); - break; - } - default: - if (["file", "text", "github"].includes(document.type)) { - const url = await generateDownloadUrl({ - documentId: document._id!, - }); - setPreviewUrl(url); - break; - } else { - setPreviewUrl(document.key as string); - } - break; - } - }; - loadPreviewUrl(); - }, [document, tag, generateDownloadUrl]); + useEffect(() => { + setPreviewUrl(null); + setPreviewError(null); + const loadPreviewUrl = async () => { + if (!document) return; + try { + switch (tag) { + case "image": + case "pdf": + case "file": { + // Only files need download URL + const url = await generateDownloadUrl({ + documentId: document._id, + }); + setPreviewUrl(url); + break; + } + case "url": + case "site": { + const externalUrl = document.key as string; + if (isValidExternalUrl(externalUrl)) { + setPreviewUrl(externalUrl); + } else { + setPreviewError("Invalid or unsafe URL"); + } + break; + } + case "youtube": { + setPreviewUrl(`https://www.youtube.com/embed/${document.key}`); + break; + } + default: + if (["file", "text", "github"].includes(document.type)) { + const url = await generateDownloadUrl({ + documentId: document._id, + }); + setPreviewUrl(url); + break; + } else { + setPreviewUrl(document.key as string); + } + break; + } + } catch (error) { + setPreviewError( + `Failed to load preview: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } + }; + loadPreviewUrl(); + }, [document, tag, generateDownloadUrl]); - // Early return if dialog is not open - if (!documentDialogOpen) { - return null; - } + // Early return if dialog is not open + if (!documentDialogOpen) { + return null; + } - const handleDownload = async () => { - if (!document || tag !== "file") return; - const url = await generateDownloadUrl({ - documentId: document._id!, - }); - if (url) { - window.open(url, "_blank"); - } - }; + const handleDownload = async () => { + if (!document || tag !== "file") return; + const url = await generateDownloadUrl({ + documentId: document._id, + }); + if (url) { + const w = window.open(url, "_blank", "noopener,noreferrer"); + if (w) w.opener = null; + } + }; - const handleOpen = () => { - if (!document) return; - if (tag === "url" || tag === "site") { - window.open(document.key as string, "_blank"); - } else if (tag === "youtube") { - window.open(`https://youtube.com/watch?v=${document.key}`, "_blank"); - } - }; + const handleOpen = () => { + if (!document) return; + if (tag === "url" || tag === "site") { + const externalUrl = document.key as string; + if (isValidExternalUrl(externalUrl)) { + const w = window.open(externalUrl, "_blank", "noopener,noreferrer"); + if (w) w.opener = null; + } + } else if (tag === "youtube") { + const w = window.open( + `https://youtube.com/watch?v=${document.key}`, + "_blank", + "noopener,noreferrer", + ); + if (w) w.opener = null; + } + }; - return ( - setDocumentDialogOpen(undefined)} - > - - - - - {documentName} - - + return ( + setDocumentDialogOpen(undefined)} + > + + + + + {documentName} + + -
-
-
- Type:{" "} - {document?.type && - document.type.charAt(0).toUpperCase() + document.type.slice(1)} -
- {document?.size && ( -
- Size: {formatBytes(document.size)} -
- )} -
+
+
+
+ Type:{" "} + {document?.type && + document.type.charAt(0).toUpperCase() + document.type.slice(1)} +
+ {document?.size && ( +
+ Size: {formatBytes(document.size)} +
+ )} +
- {previewUrl && ( -
- {(() => { - if (tag === "image") { - return ( - {documentName} - ); - } else if (tag === "pdf") { - return ( - -
- PDF preview not supported in your browser. Please - download the file to view it. -
-
- ); - } else if (tag === "youtube") { - return ( -