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 (
- {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 (
);
}
+ 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 (
-
- );
- }
+ 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 (
+
+ );
+ }
+
+ 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 (
-
- );
- } else if (tag === "pdf") {
- return (
-
-
- PDF preview not supported in your browser. Please
- download the file to view it.
-
-
- );
- } else if (tag === "youtube") {
- return (
-
- );
- } else if (tag === "url" || tag === "site") {
- return (
-
- );
- } else if (tag === "file") {
- // fallback for unknown file types
- return (
-
-
- File preview not supported. Please download the file to
- view it.
-
-
- );
- }
- return null;
- })()}
-
- )}
-
+ {previewError ? (
+
+ ) : previewUrl ? (
+
+ {(() => {
+ if (tag === "image") {
+ return (
+
+ );
+ } else if (tag === "pdf") {
+ return (
+
+
+ PDF preview not supported in your browser. Please
+ download the file to view it.
+
+
+ );
+ } else if (tag === "youtube") {
+ return (
+
+ );
+ } else if (tag === "url" || tag === "site") {
+ // Only render iframe if URL is valid and safe
+ if (isValidExternalUrl(previewUrl)) {
+ return (
+
+ );
+ } else {
+ return (
+
+ Invalid or unsafe URL for preview
+
+ );
+ }
+ } else if (tag === "file") {
+ // fallback for unknown file types
+ return (
+
+
+ File preview not supported. Please download the file to
+ view it.
+
+
+ );
+ }
+ return null;
+ })()}
+
+ ) : null}
+
-
- {tag === "file" ? (
- Download
- ) : tag === "url" || tag === "site" || tag === "youtube" ? (
-
- Open {tag === "youtube" ? "in YouTube" : "in Browser"}
-
- ) : null}
- setDocumentDialogOpen(undefined)}
- >
- Close
-
-
-
-
- );
+
+ {tag === "file" ? (
+ Download
+ ) : tag === "url" || tag === "site" || tag === "youtube" ? (
+
+ Open {tag === "youtube" ? "in YouTube" : "in Browser"}
+
+ ) : null}
+ setDocumentDialogOpen(undefined)}
+ >
+ Close
+
+
+
+
+ );
};
diff --git a/src/components/topnav.tsx b/src/components/topnav.tsx
index 6447b192..062f4aec 100644
--- a/src/components/topnav.tsx
+++ b/src/components/topnav.tsx
@@ -1,16 +1,16 @@
import { SidebarTrigger } from "@/components/ui/sidebar";
import { ModeToggle } from "@/components/theme-provider";
import {
- resizePanelOpenAtom,
- selectedArtifactAtom,
- selectedVibzMcpAtom,
- sidebarOpenAtom,
- userAtom,
+ resizePanelOpenAtom,
+ selectedArtifactAtom,
+ selectedVibzMcpAtom,
+ sidebarOpenAtom,
+ userAtom,
} from "@/store/chatStore";
import {
- PanelRightCloseIcon,
- PlusIcon,
- PanelRightOpenIcon,
+ PanelRightCloseIcon,
+ PlusIcon,
+ PanelRightOpenIcon,
} from "lucide-react";
import { Button } from "./ui/button";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
@@ -20,111 +20,128 @@ import { convexQuery } from "@convex-dev/react-query";
import { api } from "../../convex/_generated/api";
import { useEffect } from "react";
import { Settings2Icon } from "lucide-react";
+import { Navigate } from "@tanstack/react-router";
export function TopNav() {
- const [resizePanelOpen, setResizePanelOpen] = useAtom(resizePanelOpenAtom);
- const setSelectedArtifact = useSetAtom(selectedArtifactAtom);
- const navigate = useNavigate();
- const sidebarOpen = useAtomValue(sidebarOpenAtom);
- const selectedArtifact = useAtomValue(selectedArtifactAtom);
- const selectedVibzMcp = useAtomValue(selectedVibzMcpAtom);
- const setUser = useSetAtom(userAtom);
- const location = useLocation();
+ const [resizePanelOpen, setResizePanelOpen] = useAtom(resizePanelOpenAtom);
+ const setSelectedArtifact = useSetAtom(selectedArtifactAtom);
+ const navigate = useNavigate();
+ const sidebarOpen = useAtomValue(sidebarOpenAtom);
+ const selectedArtifact = useAtomValue(selectedArtifactAtom);
+ const selectedVibzMcp = useAtomValue(selectedVibzMcpAtom);
+ const setUser = useSetAtom(userAtom);
+ const location = useLocation();
+ const params = useParams({ strict: false });
+ const isOnChatRoute = !!params.chatId;
+ const isSettingsRoute = location.pathname.startsWith("/settings");
- const { data: user } = useQuery({
- ...convexQuery(api.auth.getUser, {}),
- });
+ function isUnauthorized(err: unknown) {
+ const e = err as any;
+ return (
+ e?.response?.status === 401 ||
+ e?.status === 401 ||
+ e?.code === "UNAUTHENTICATED" ||
+ e?.code === "UNAUTHORIZED" ||
+ (e instanceof Error && /\b401\b/.test(e.message))
+ );
+ }
- useEffect(() => {
- if (user) {
- setUser(user);
- }
- }, [user]);
+ const { data: user, isError: isErrorUser } = useQuery({
+ ...convexQuery(api.auth.getUser, {}),
+ // Avoid looping on auth errors; tolerate brief transient failures.
+ retry: (failureCount, err) => !isUnauthorized(err) && failureCount < 2,
+ staleTime: 5 * 60 * 1000,
+ });
- // Check if we're on a chat route by looking for chatId parameter
- const params = useParams({ strict: false });
- const isOnChatRoute = !!params.chatId;
- const isSettingsRoute = location.pathname.startsWith("/settings");
+ useEffect(() => {
+ if (user) {
+ setUser(user);
+ }
+ }, [user, setUser]);
- // Global shortcut for toggling resizable panel (Ctrl/Cmd+I)
- useEffect(() => {
- if (!isOnChatRoute) return;
- const handleKeyDown = (event: KeyboardEvent) => {
- if (
- !event.repeat &&
- event.key === "i" &&
- (event.metaKey || event.ctrlKey)
- ) {
- event.preventDefault();
- setResizePanelOpen((open) => {
- if (!open) setSelectedArtifact(undefined);
- return !open;
- });
- }
- };
+ // Global shortcut for toggling resizable panel (Ctrl/Cmd+I)
+ useEffect(() => {
+ if (!isOnChatRoute) return;
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (
+ !event.repeat &&
+ event.key === "i" &&
+ (event.metaKey || event.ctrlKey)
+ ) {
+ event.preventDefault();
+ setResizePanelOpen((open) => {
+ if (!open) setSelectedArtifact(undefined);
+ return !open;
+ });
+ }
+ };
- window.addEventListener("keydown", handleKeyDown);
- return () => window.removeEventListener("keydown", handleKeyDown);
- }, [isOnChatRoute, setResizePanelOpen, setSelectedArtifact]);
+ window.addEventListener("keydown", handleKeyDown);
+ return () => window.removeEventListener("keydown", handleKeyDown);
+ }, [isOnChatRoute, setResizePanelOpen, setSelectedArtifact]);
- return (
-
-
-
-
{
- navigate({ to: "/chat/$chatId", params: { chatId: "new" } });
- }}
- >
-
-
-
-
- {!resizePanelOpen && (
- <>
-
{
- navigate({ to: "/settings/profile" });
- }}
- >
-
-
-
- >
- )}
- {isOnChatRoute && (
-
{
- setResizePanelOpen(!resizePanelOpen);
- setSelectedArtifact(undefined);
- }}
- >
- {resizePanelOpen ? (
-
- ) : (
-
- )}
-
- )}
-
-
- );
+ if (isErrorUser) {
+ return ;
+ }
+
+ return (
+
+
+
+
{
+ navigate({ to: "/chat/$chatId", params: { chatId: "new" } });
+ }}
+ >
+
+
+
+
+ {!resizePanelOpen && (
+ <>
+
{
+ navigate({ to: "/settings/profile" });
+ }}
+ >
+
+
+
+ >
+ )}
+ {isOnChatRoute && (
+
{
+ setResizePanelOpen(!resizePanelOpen);
+ setSelectedArtifact(undefined);
+ }}
+ >
+ {resizePanelOpen ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+
+ );
}
diff --git a/src/components/ui/accordion.tsx b/src/components/ui/accordion.tsx
index a67c8768..2fe9a62d 100644
--- a/src/components/ui/accordion.tsx
+++ b/src/components/ui/accordion.tsx
@@ -113,9 +113,9 @@ interface AccordionTriggerProps {
}
function AccordionTrigger({
+ showIcon = true,
className,
children,
- showIcon = true,
onClick,
...props
}: AccordionTriggerProps) {
diff --git a/src/components/ui/error-state.tsx b/src/components/ui/error-state.tsx
new file mode 100644
index 00000000..050d76db
--- /dev/null
+++ b/src/components/ui/error-state.tsx
@@ -0,0 +1,122 @@
+import * as React from "react";
+import { cn } from "@/lib/utils";
+import { cva, type VariantProps } from "class-variance-authority";
+import { AlertCircleIcon } from "lucide-react";
+
+const errorStateVariants = cva(
+ "relative w-full rounded-md border transition-colors flex gap-2 sm:flex-row flex-col sm:items-center items-start",
+ {
+ variants: {
+ variant: {
+ subtle: "bg-destructive/10 text-destructive border-destructive/20",
+ solid: "bg-destructive text-destructive-foreground border-destructive",
+ outline: "bg-transparent text-destructive border-destructive",
+ },
+ density: {
+ compact: "p-2 text-sm",
+ comfy: "p-3",
+ },
+ align: {
+ left: "justify-start",
+ center: "justify-center",
+ },
+ },
+ defaultVariants: {
+ variant: "subtle",
+ density: "compact",
+ align: "left",
+ },
+ }
+);
+
+export interface ErrorStateProps
+ extends React.HTMLAttributes,
+ VariantProps {
+ error?: unknown;
+ title?: string;
+ description?: string;
+ multiLineDescription?: boolean;
+ showIcon?: boolean;
+ showTitle?: boolean;
+ showDescription?: boolean;
+ icon?: React.ComponentType<{ className?: string }>;
+}
+
+const ErrorState = React.forwardRef(
+ (
+ {
+ className,
+ variant,
+ density,
+ align,
+ error,
+ title = "Something went wrong",
+ description,
+ multiLineDescription = false,
+ showIcon = true,
+ showTitle = true,
+ showDescription = true,
+ icon: Icon = AlertCircleIcon,
+ ...props
+ },
+ ref
+ ) => {
+ const errorMessage = React.useMemo(() => {
+ if (description) return description;
+ if (error instanceof Error) return error.message;
+ if (error) return String(error);
+ return "";
+ }, [error, description]);
+
+ // Only return null if nothing at all would render
+ if (
+ !showIcon &&
+ (!showTitle || !title) &&
+ (!showDescription || !errorMessage)
+ ) {
+ return null;
+ }
+
+ return (
+
+ {showIcon && (
+
+
+
+ )}
+
+ {showTitle && title && (
+
+ {title}
+
+ )}
+ {showDescription && errorMessage && (
+
+ {errorMessage}
+
+ )}
+
+
+ );
+ }
+);
+
+ErrorState.displayName = "ErrorState";
+
+export { ErrorState, errorStateVariants };
diff --git a/src/components/ui/loading-spinner.tsx b/src/components/ui/loading-spinner.tsx
new file mode 100644
index 00000000..40cd646d
--- /dev/null
+++ b/src/components/ui/loading-spinner.tsx
@@ -0,0 +1,35 @@
+import { Loader2 } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+interface LoadingSpinnerProps {
+ sizeClassName?: string;
+ className?: string;
+ label?: string;
+ containerClassName?: string;
+}
+
+export function LoadingSpinner({
+ sizeClassName = "h-5 w-5",
+ className,
+ label,
+ containerClassName,
+}: LoadingSpinnerProps) {
+ return (
+
+
+ {label ?
{label}
: null}
+
+ );
+}
diff --git a/src/components/ui/tooltip-button.tsx b/src/components/ui/tooltip-button.tsx
index e26ed9ea..bf6561c8 100644
--- a/src/components/ui/tooltip-button.tsx
+++ b/src/components/ui/tooltip-button.tsx
@@ -1,38 +1,56 @@
import {
- Tooltip,
- TooltipContent,
- TooltipProvider,
- TooltipTrigger,
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
} from "@/components/ui/tooltip";
import type { ReactNode } from "react";
-import { Button } from "./button";
+import { Button } from "@/components/ui/button";
// Helper component to reduce tooltip boilerplate
export const TooltipButton = ({
- onClick,
- icon,
- tooltip,
- ariaLabel,
+ onClick,
+ icon,
+ tooltip,
+ disabled,
+ ariaLabel,
}: {
- onClick: () => void;
- icon: ReactNode;
- tooltip: string;
- ariaLabel?: string;
+ onClick: () => void;
+ icon: ReactNode;
+ tooltip: string;
+ ariaLabel?: string;
+ disabled?: boolean;
}) => (
-
-
-
-
- {icon}
-
-
- {tooltip}
-
-
+
+
+
+ {disabled ? (
+
+
+ {icon}
+
+
+ ) : (
+
+ {icon}
+
+ )}
+
+ {tooltip}
+
+
);
diff --git a/src/hooks/chats/use-chats.ts b/src/hooks/chats/use-chats.ts
index 6762962f..43906519 100644
--- a/src/hooks/chats/use-chats.ts
+++ b/src/hooks/chats/use-chats.ts
@@ -126,7 +126,12 @@ export const useSearchChats = () => {
const [searchQuery, setSearchQuery] = useState("");
const [debouncedQuery, setDebouncedQuery] = useState("");
- const { data: searchResults } = useQuery({
+ const {
+ data: searchResults,
+ isLoading: isLoadingSearch,
+ isError: isSearchError,
+ error: searchError,
+ } = useQuery({
...convexQuery(api.chats.queries.search, {
query: debouncedQuery.trim() ? debouncedQuery.trim() : "skip",
}),
@@ -141,11 +146,16 @@ export const useSearchChats = () => {
return () => clearTimeout(timer);
}, [searchQuery]);
+ const isSearching = debouncedQuery.trim().length > 0;
+
return {
searchQuery,
setSearchQuery,
searchResults: searchResults || [],
- isSearching: debouncedQuery !== searchQuery,
+ isSearching,
+ isLoadingSearch,
+ isSearchError,
+ searchError,
};
};
diff --git a/src/hooks/chats/use-documents.ts b/src/hooks/chats/use-documents.ts
index eb3f9f9d..eeb40ce7 100644
--- a/src/hooks/chats/use-documents.ts
+++ b/src/hooks/chats/use-documents.ts
@@ -8,16 +8,18 @@ import { useSetAtom, useAtomValue } from "jotai";
export const useRemoveDocument = () => {
const chatId = useAtomValue(chatIdAtom);
+
const { data: chatInputQuery } = useQuery({
...convexQuery(
api.chats.queries.get,
- chatId !== "new" ? { chatId } : "skip",
+ chatId !== "new" ? { chatId } : "skip"
),
- enabled: chatId !== "new",
});
+
const { mutate: updateChatInputMutation } = useMutation({
mutationFn: useConvexMutation(api.chats.mutations.update),
});
+
const setNewChat = useSetAtom(newChatAtom);
return (documentId: Id<"documents">) => {
@@ -27,19 +29,26 @@ export const useRemoveDocument = () => {
}
const filteredDocuments = chatInputQuery.documents.filter(
- (id) => id !== documentId,
+ (id) => id !== documentId
);
- updateChatInputMutation({
- chatId: chatId,
- updates: {
- documents: filteredDocuments,
+ updateChatInputMutation(
+ {
+ chatId: chatId,
+ updates: {
+ documents: filteredDocuments,
+ },
},
- });
+ {
+ onError: () => {
+ toast("Failed to remove document");
+ },
+ }
+ );
} else {
setNewChat((prev) => {
const filteredDocuments = prev.documents.filter(
- (id) => id !== documentId,
+ (id) => id !== documentId
);
return { ...prev, documents: filteredDocuments };
});
@@ -54,7 +63,7 @@ export const useUploadDocuments = (
}: {
type: "file" | "url" | "site" | "youtube" | "text" | "github";
chat?: Doc<"chats">;
- } = { type: "file" },
+ } = { type: "file" }
) => {
const chatId = useAtomValue(chatIdAtom);
const { mutateAsync: updateChatMutation } = useMutation({
@@ -102,7 +111,7 @@ export const useUploadDocuments = (
size: file.size,
key: storageId,
});
- }),
+ })
);
// Update chat input with new documents
@@ -123,7 +132,7 @@ export const useUploadDocuments = (
}
toast(
- `${files.length} file${files.length > 1 ? "s" : ""} uploaded successfully`,
+ `${files.length} file${files.length > 1 ? "s" : ""} uploaded successfully`
);
return documentIds;
diff --git a/src/hooks/chats/use-mcp.ts b/src/hooks/chats/use-mcp.ts
index 47327e57..48165a85 100644
--- a/src/hooks/chats/use-mcp.ts
+++ b/src/hooks/chats/use-mcp.ts
@@ -40,7 +40,12 @@ function validateMCP(mcp: MCPFormState): boolean {
}
export function useMCPs() {
- const { data: mcps } = useQuery({
+ const {
+ data: mcps,
+ isLoading,
+ isError,
+ error,
+ } = useQuery({
...convexQuery(api.mcps.queries.getAll, {
paginationOpts: { numItems: 10, cursor: null },
}),
@@ -131,5 +136,8 @@ export function useMCPs() {
handleDelete,
restartMCP,
validateMCP,
+ isLoading,
+ isError,
+ error,
};
}
diff --git a/src/hooks/chats/use-messages.ts b/src/hooks/chats/use-messages.ts
index e9c52e05..956d342c 100644
--- a/src/hooks/chats/use-messages.ts
+++ b/src/hooks/chats/use-messages.ts
@@ -4,10 +4,10 @@ import { convexQuery } from "@convex-dev/react-query";
import { api } from "../../../convex/_generated/api";
import type { Id } from "../../../convex/_generated/dataModel";
import {
- buildThreadAndGroups,
- type MessageGroup,
- type MessageWithBranchInfo,
- type BranchPath,
+ buildThreadAndGroups,
+ type MessageGroup,
+ type MessageWithBranchInfo,
+ type BranchPath,
} from "../../../convex/chatMessages/helpers";
import { useStreamAtom, currentThreadAtom } from "@/store/chatStore";
import { useSetAtom } from "jotai";
@@ -20,85 +20,94 @@ let globalBranchPath: BranchPath = [];
let forceUpdateFn: (() => void) | null = null;
export const useMessages = ({ chatId }: { chatId: Id<"chats"> | "new" }) => {
- const setCurrentThread = useSetAtom(currentThreadAtom);
- const setUseStreamAtom = useSetAtom(useStreamAtom);
- const [counter, setCounter] = useState(0);
+ const setCurrentThread = useSetAtom(currentThreadAtom);
+ const setUseStreamAtom = useSetAtom(useStreamAtom);
+ const [counter, setCounter] = useState(0);
- // Register the force update function
- forceUpdateFn = () => setCounter((c) => c + 1);
+ // Register the force update function
+ forceUpdateFn = () => setCounter((c) => c + 1);
- const { data: messages, isLoading } = useQuery({
- ...convexQuery(
- api.chatMessages.queries.get,
- chatId !== "new" ? { chatId: chatId as Id<"chats"> } : "skip",
- ),
- enabled: chatId !== "new",
- });
+ const {
+ data: messages,
+ isLoading,
+ isError: isMessagesError,
+ error: messagesError,
+ } = useQuery({
+ ...convexQuery(
+ api.chatMessages.queries.get,
+ chatId !== "new" ? { chatId: chatId as Id<"chats"> } : "skip",
+ ),
+ enabled: chatId !== "new",
+ });
- const messageGroups = useMemo(
- () => (messages ? buildThreadAndGroups(messages, globalBranchPath) : []),
- [messages, counter],
- );
+ const messageGroups = useMemo(
+ () => (messages ? buildThreadAndGroups(messages, globalBranchPath) : []),
+ [messages, counter],
+ );
- const streamData = useStream(chatId);
+ const streamData = useStream(chatId);
- useEffect(() => {
- setCurrentThread(messageGroups);
- setUseStreamAtom(streamData);
- }, [
- messageGroups,
- JSON.stringify(streamData),
- setCurrentThread,
- setUseStreamAtom,
- ]);
+ useEffect(() => {
+ setCurrentThread(messageGroups);
+ setUseStreamAtom(streamData);
+ }, [
+ messageGroups,
+ JSON.stringify(streamData),
+ setCurrentThread,
+ setUseStreamAtom,
+ ]);
- useEffect(() => {
- // Reset branch path when chat changes
- globalBranchPath = [];
- setCounter((c) => c + 1);
- }, [chatId]);
+ useEffect(() => {
+ // Reset branch path when chat changes
+ globalBranchPath = [];
+ setCounter((c) => c + 1);
+ }, [chatId]);
- return {
- isLoading: isLoading,
- isEmpty: messageGroups.length === 0,
- };
+ return {
+ isLoading: isLoading,
+ isEmpty: messageGroups.length === 0,
+ isError: isMessagesError,
+ error: messagesError,
+ isStreamError: Boolean(streamData?.isError),
+ streamError: streamData?.error,
+ };
};
export const useNavigateBranch = () => {
- return useCallback(
- (
- depth: number,
- direction: "prev" | "next" | number,
- totalBranches: number,
- ) => {
- if (typeof direction === "number") {
- globalBranchPath[depth] = direction;
- } else {
- const currentIndex = globalBranchPath[depth] ?? (totalBranches - 1); // Default to current displayed branch
- if (direction === "next") {
- globalBranchPath[depth] = (currentIndex + 1) % totalBranches;
- } else if (direction === "prev") {
- globalBranchPath[depth] =
- (currentIndex - 1 + totalBranches) % totalBranches;
- }
- }
- globalBranchPath.splice(depth + 1);
+ return useCallback(
+ (
+ depth: number,
+ direction: "prev" | "next" | number,
+ totalBranches: number,
+ ) => {
+ if (typeof direction === "number") {
+ globalBranchPath[depth] = direction;
+ } else {
+ const currentIndex = globalBranchPath[depth] ?? totalBranches - 1; // Default to current displayed branch
+ if (direction === "next") {
+ globalBranchPath[depth] = (currentIndex + 1) % totalBranches;
+ } else if (direction === "prev") {
+ globalBranchPath[depth] =
+ (currentIndex - 1 + totalBranches) % totalBranches;
+ }
+ }
+ globalBranchPath.splice(depth + 1);
- if (forceUpdateFn) {
- forceUpdateFn();
- }
- },
- [],
- );
+ if (forceUpdateFn) {
+ forceUpdateFn();
+ }
+ },
+ [],
+ );
};
export const useChangeBranch = () => {
- return useCallback((depth: number, newBranchIndex: number) => {
- globalBranchPath[depth] = newBranchIndex;
- globalBranchPath.splice(depth + 1);
+ return useCallback((depth: number, newBranchIndex: number) => {
+ globalBranchPath[depth] = newBranchIndex;
+ globalBranchPath.splice(depth + 1);
- if (forceUpdateFn) {
- forceUpdateFn();
- }
- }, []);
+ if (forceUpdateFn) {
+ forceUpdateFn();
+ }
+ }, []);
};
diff --git a/src/hooks/chats/use-stream.ts b/src/hooks/chats/use-stream.ts
index c4515761..40520680 100644
--- a/src/hooks/chats/use-stream.ts
+++ b/src/hooks/chats/use-stream.ts
@@ -17,19 +17,24 @@ import {
export type ChunkGroup = AIChunkGroup | ToolChunkGroup;
export function useStream(chatId: Id<"chats"> | "new") {
- const convex = useConvex();
- const { data: stream } = useQuery({
+ const {
+ data: stream,
+ isError: isStreamError,
+ error: streamError,
+ } = useQuery({
...convexQuery(
api.streams.queries.get,
- chatId !== "new" ? { chatId: chatId as Id<"chats"> } : "skip",
+ chatId !== "new" ? { chatId: chatId as Id<"chats"> } : "skip"
),
});
const [groupedChunks, setGroupedChunks] = useState([]);
const [lastSeenTime, setLastSeenTime] = useState(
- undefined,
+ undefined
);
+ const convex = useConvex();
+
// Reset state when chat or stream changes
useEffect(() => {
setLastSeenTime(undefined);
@@ -56,12 +61,12 @@ export function useStream(chatId: Id<"chats"> | "new") {
.filter(
(chunkDoc: any) =>
lastSeenTime === undefined ||
- chunkDoc._creationTime > lastSeenTime,
+ chunkDoc._creationTime > lastSeenTime
)
.flatMap((chunkDoc: any) =>
chunkDoc.chunks.map(
- (chunkStr: string) => JSON.parse(chunkStr) as ChunkGroup,
- ),
+ (chunkStr: string) => JSON.parse(chunkStr) as ChunkGroup
+ )
);
if (newEvents.length > 0) {
setGroupedChunks((prev) => {
@@ -114,7 +119,7 @@ export function useStream(chatId: Id<"chats"> | "new") {
const completedIds = new Set(
groupedChunks
.filter((c) => c.type === "tool" && c.isComplete)
- .map((c) => (c as ToolChunkGroup).toolCallId),
+ .map((c) => (c as ToolChunkGroup).toolCallId)
);
return groupedChunks
.map((chunk) => {
@@ -178,5 +183,7 @@ export function useStream(chatId: Id<"chats"> | "new") {
status: stream?.status,
langchainMessages,
planningStepsMessage,
+ isError: isStreamError,
+ error: streamError,
};
}
diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx
index 18240590..67400080 100644
--- a/src/routes/__root.tsx
+++ b/src/routes/__root.tsx
@@ -6,14 +6,15 @@ import {
HeadContent,
} from "@tanstack/react-router";
import { Toaster } from "@/components/ui/sonner";
-import { Loader2 } from "lucide-react";
+import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { SidebarProvider } from "@/components/ui/sidebar";
import { motion } from "motion/react";
-import { sidebarOpenAtom } from "@/store/chatStore";
+import { sidebarOpenAtom, resizePanelOpenAtom } from "@/store/chatStore";
import { useAtomValue, useSetAtom } from "jotai";
import { useConvexAuth } from "convex/react";
import { AppSidebar } from "@/components/app-sidebar";
import { TopNav } from "@/components/topnav";
+import { useEffect } from "react";
import { DocumentDialog } from "@/components/document-dialog";
import { CreateProjectDialog } from "@/components/create-project-dialog";
@@ -73,11 +74,22 @@ export const Route = createRootRoute({
const publicRoutes = ["/auth"];
const sidebarOpen = useAtomValue(sidebarOpenAtom);
const setSidebarOpen = useSetAtom(sidebarOpenAtom);
+ const setResizePanelOpen = useSetAtom(resizePanelOpenAtom);
+
+ const isSettingsRoute = location.pathname.startsWith("/settings");
+
+ // Ensure sidebar and right resizable panel are closed on settings pages
+ // and keep them hidden there.
+ useEffect(() => {
+ if (!isSettingsRoute) return;
+ sidebarOpen && setSidebarOpen(false);
+ setResizePanelOpen(false);
+ }, [isSettingsRoute, sidebarOpen, setSidebarOpen, setResizePanelOpen]);
if (isLoading) {
return (
-
+
);
}
@@ -102,10 +114,10 @@ export const Route = createRootRoute({
setSidebarOpen(!sidebarOpen);
}}
>
- {/* AppSidebar and TopNav are now available on all routes */}
+ {/* AppSidebar and TopNav (sidebar hidden on settings) */}
{isAuthenticated && (
<>
-
+ {!isSettingsRoute && }
>
)}
diff --git a/src/routes/auth.tsx b/src/routes/auth.tsx
index 7dfb3827..475712a9 100644
--- a/src/routes/auth.tsx
+++ b/src/routes/auth.tsx
@@ -1,7 +1,7 @@
import { createFileRoute } from "@tanstack/react-router";
import { Button } from "@/components/ui/button";
import { motion } from "motion/react";
-import { Loader2 } from "lucide-react";
+import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { Navigate } from "@tanstack/react-router";
import { useConvexAuth } from "convex/react";
import { useAuthActions } from "@convex-dev/auth/react";
@@ -17,7 +17,7 @@ function RouteComponent() {
if (isLoading) {
return (
-
+
);
}
diff --git a/src/routes/chat.$chatId.lazy.tsx b/src/routes/chat.$chatId.lazy.tsx
index 8fe474f6..fb14e623 100644
--- a/src/routes/chat.$chatId.lazy.tsx
+++ b/src/routes/chat.$chatId.lazy.tsx
@@ -23,6 +23,7 @@ import { api } from "../../convex/_generated/api";
import { newChatAtom } from "@/store/chatStore";
import { convexQuery } from "@convex-dev/react-query";
import { useQuery } from "@tanstack/react-query";
+import { ErrorState } from "@/components/ui/error-state";
export const Route = createLazyFileRoute("/chat/$chatId")({
component: RouteComponent,
@@ -44,7 +45,11 @@ function RouteComponent() {
setSelectedVibzMcp(undefined);
}, [chatId, setSelectedArtifact, setSelectedVibzMcp]);
- const { data: queryChat } = useQuery({
+ const {
+ data: queryChat,
+ isError: isChatError,
+ error: chatError,
+ } = useQuery({
...convexQuery(
api.chats.queries.get,
chatId !== "new" ? { chatId: chatId as Id<"chats"> } : "skip",
@@ -55,6 +60,24 @@ function RouteComponent() {
setChat(chatId !== "new" ? queryChat : newChat);
}, [queryChat, setChat, newChat]);
+ if (isChatError) {
+ return (
+
+
+
+ );
+ }
+
return (
<>
diff --git a/src/routes/settings/profile.tsx b/src/routes/settings/profile.tsx
index 96543de0..493e0fc5 100644
--- a/src/routes/settings/profile.tsx
+++ b/src/routes/settings/profile.tsx
@@ -9,6 +9,8 @@ import { useAuthActions } from "@convex-dev/auth/react";
import { convexQuery } from "@convex-dev/react-query";
import { useQuery } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
+import { LoadingSpinner } from "@/components/ui/loading-spinner";
+import { ErrorState } from "@/components/ui/error-state";
export const Route = createFileRoute("/settings/profile")({
component: RouteComponent,
@@ -17,11 +19,34 @@ export const Route = createFileRoute("/settings/profile")({
});
function RouteComponent() {
- const { data: user } = useQuery(convexQuery(api.auth.getUser, {}));
+ const {
+ data: user,
+ isError: isErrorUser,
+ isLoading: isLoadingUser,
+ error: userError,
+ } = useQuery(convexQuery(api.auth.getUser, {}));
const { signOut } = useAuthActions();
const navigate = useNavigate();
+ if (isLoadingUser) {
+ return (
+
+
+
+ );
+ }
+
+ if (isErrorUser || userError) {
+ return (
+
+ );
+ }
+
return (
diff --git a/temp.md b/temp.md
index 6b0589a5..aa9f4d17 100644
--- a/temp.md
+++ b/temp.md
@@ -12,7 +12,6 @@ https://github.com/0bs-chat/zerobs/tree/feat/message-queue : the message queue f
- pricing [imp]
- usage
-- improve ux overall with loading states and whatnot. [done]
- google integration (the code is already there just need to setup oauth)
- business related mcp with ability to autofill connection info (like auto fetching api key/oauth key for the headers in mcp using oauth, etc to reduce friction)