diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/AIChatButton.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/AIChatButton.tsx new file mode 100644 index 0000000000..dfdf7267c9 --- /dev/null +++ b/apps/web/app/(app)/[emailAccountId]/assistant/AIChatButton.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { useSidebar } from "@/components/ui/sidebar"; +import { MessageCircleIcon } from "lucide-react"; + +export function AIChatButton() { + const { setOpen } = useSidebar(); + + return ( + + ); +} diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/ExecutedRulesTable.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/ExecutedRulesTable.tsx index 46c85964a0..2a408dc07d 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/ExecutedRulesTable.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/ExecutedRulesTable.tsx @@ -15,7 +15,6 @@ import type { ParsedMessage } from "@/utils/types"; import { ViewEmailButton } from "@/components/ViewEmailButton"; import { ExecutedRuleStatus } from "@prisma/client"; import { FixWithChat } from "@/app/(app)/[emailAccountId]/assistant/FixWithChat"; -import type { SetInputFunction } from "@/components/assistant-chat/types"; import { useAssistantNavigation } from "@/hooks/useAssistantNavigation"; import { useAccount } from "@/providers/EmailAccountProvider"; @@ -74,7 +73,7 @@ export function RuleCell({ status: ExecutedRuleStatus; reason?: string | null; message: ParsedMessage; - setInput: SetInputFunction; + setInput: (input: string) => void; }) { const { createAssistantUrl } = useAssistantNavigation(emailAccountId); diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/FixWithChat.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/FixWithChat.tsx index bd946a5f1f..623509ff60 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/FixWithChat.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/FixWithChat.tsx @@ -1,8 +1,5 @@ -import { useRouter } from "next/navigation"; -import { useQueryState } from "nuqs"; import { MessageCircleIcon } from "lucide-react"; import { Button } from "@/components/ui/button"; -import type { SetInputFunction } from "@/components/assistant-chat/types"; import type { ParsedMessage } from "@/utils/types"; import type { RunRulesResult } from "@/utils/ai/choose-rule/run-rules"; import { truncate } from "@/utils/string"; @@ -18,28 +15,30 @@ import { useRules } from "@/hooks/useRules"; import { useAccount } from "@/providers/EmailAccountProvider"; import { useModal } from "@/hooks/useModal"; import { NEW_RULE_ID } from "@/app/(app)/[emailAccountId]/assistant/consts"; -import { useAssistantNavigation } from "@/hooks/useAssistantNavigation"; import { Label } from "@/components/Input"; import { ButtonList } from "@/components/ButtonList"; import type { RulesResponse } from "@/app/api/user/rules/route"; import { ProcessResultDisplay } from "@/app/(app)/[emailAccountId]/assistant/ProcessResultDisplay"; import { NONE_RULE_ID } from "@/app/(app)/[emailAccountId]/assistant/consts"; +import { useSidebar } from "@/components/ui/sidebar"; export function FixWithChat({ setInput, message, result, }: { - setInput: SetInputFunction; + setInput: (input: string) => void; message: ParsedMessage; result: RunRulesResult | null; }) { const { data, isLoading, error } = useRules(); const { emailAccountId } = useAccount(); const { isModalOpen, setIsModalOpen } = useModal(); - const { createAssistantUrl } = useAssistantNavigation(emailAccountId); - const router = useRouter(); - const [currentTab] = useQueryState("tab"); + // const { createAssistantUrl } = useAssistantNavigation(emailAccountId); + // const router = useRouter(); + // const [currentTab] = useQueryState("tab"); + + const { setOpen } = useSidebar(); return ( @@ -82,23 +81,22 @@ export function FixWithChat({ }); } - if (setInput) { - // this is only set if we're in the correct context - setInput(input); - } else { - // redirect to the assistant page - const searchParams = new URLSearchParams(); - searchParams.set("input", input); - if (currentTab) searchParams.set("tab", currentTab); - - router.push( - createAssistantUrl({ - input, - tab: currentTab || undefined, - path: `/assistant${searchParams.toString()}`, - }), - ); - } + setInput(input); + setOpen(["chat-sidebar"]); + // } else { + // // redirect to the assistant page + // const searchParams = new URLSearchParams(); + // searchParams.set("input", input); + // if (currentTab) searchParams.set("tab", currentTab); + + // router.push( + // createAssistantUrl({ + // input, + // tab: currentTab || undefined, + // path: `/assistant${searchParams.toString()}`, + // }), + // ); + // } setIsModalOpen(false); }} diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/History.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/History.tsx index 81f64807bb..d7e292a1d2 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/History.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/History.tsx @@ -22,7 +22,7 @@ import { TablePagination } from "@/components/TablePagination"; import { Badge } from "@/components/Badge"; import { RulesSelect } from "@/app/(app)/[emailAccountId]/assistant/RulesSelect"; import { useAccount } from "@/providers/EmailAccountProvider"; -import { useChat } from "@/components/assistant-chat/ChatContext"; +import { useChat } from "@/providers/ChatProvider"; export function History() { const [page] = useQueryState("page", parseAsInteger.withDefault(1)); diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/Pending.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/Pending.tsx index 717c58c2c1..a00de97f2b 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/Pending.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/Pending.tsx @@ -30,7 +30,7 @@ import { Checkbox } from "@/components/Checkbox"; import { useToggleSelect } from "@/hooks/useToggleSelect"; import { RulesSelect } from "@/app/(app)/[emailAccountId]/assistant/RulesSelect"; import { useAccount } from "@/providers/EmailAccountProvider"; -import { useChat } from "@/components/assistant-chat/ChatContext"; +import { useChat } from "@/providers/ChatProvider"; export function Pending() { const [page] = useQueryState("page", parseAsInteger.withDefault(1)); diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/ProcessRules.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/ProcessRules.tsx index daea555ad4..5d2dcfa6d2 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/ProcessRules.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/ProcessRules.tsx @@ -38,8 +38,7 @@ import { ProcessResultDisplay } from "@/app/(app)/[emailAccountId]/assistant/Pro import { Tooltip } from "@/components/Tooltip"; import { useAccount } from "@/providers/EmailAccountProvider"; import { FixWithChat } from "@/app/(app)/[emailAccountId]/assistant/FixWithChat"; -import { useChat } from "@/components/assistant-chat/ChatContext"; -import type { SetInputFunction } from "@/components/assistant-chat/types"; +import { useChat } from "@/providers/ChatProvider"; type Message = MessagesResponse["messages"][number]; @@ -318,7 +317,7 @@ function ProcessRulesRow({ onRun: (rerun?: boolean) => void; testMode: boolean; emailAccountId: string; - setInput: SetInputFunction; + setInput: (input: string) => void; }) { return (
- +
diff --git a/apps/web/app/(app)/[emailAccountId]/automation/page.tsx b/apps/web/app/(app)/[emailAccountId]/automation/page.tsx index d01021ab46..38d60b3589 100644 --- a/apps/web/app/(app)/[emailAccountId]/automation/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/automation/page.tsx @@ -2,7 +2,7 @@ import { Suspense } from "react"; import { cookies } from "next/headers"; import Link from "next/link"; import { redirect } from "next/navigation"; -import { MessageCircleIcon, SlidersIcon } from "lucide-react"; +import { SlidersIcon } from "lucide-react"; import prisma from "@/utils/prisma"; import { History } from "@/app/(app)/[emailAccountId]/assistant/History"; import { Pending } from "@/app/(app)/[emailAccountId]/assistant/Pending"; @@ -20,6 +20,7 @@ import { SettingsTab } from "@/app/(app)/[emailAccountId]/assistant/settings/Set import { PageHeading } from "@/components/Typography"; import { TabSelect } from "@/components/TabSelect"; import { RulesTab } from "@/app/(app)/[emailAccountId]/assistant/RulesTab"; +import { AIChatButton } from "@/app/(app)/[emailAccountId]/assistant/AIChatButton"; export const maxDuration = 300; // Applies to the actions @@ -85,7 +86,7 @@ export default async function AutomationPage({ -
+
@@ -203,12 +204,7 @@ function ExtraActions({ emailAccountId }: { emailAccountId: string }) { buttonProps={{ size: "sm", variant: "ghost" }} /> - +
); } diff --git a/apps/web/app/(app)/[emailAccountId]/simple/Summary.tsx b/apps/web/app/(app)/[emailAccountId]/simple/Summary.tsx index 074494fd2c..ae51dc1e3e 100644 --- a/apps/web/app/(app)/[emailAccountId]/simple/Summary.tsx +++ b/apps/web/app/(app)/[emailAccountId]/simple/Summary.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCompletion } from "ai/react"; +import { useCompletion } from "@ai-sdk/react"; import { useEffect } from "react"; import { ButtonLoader } from "@/components/Loading"; import { ViewMoreButton } from "@/app/(app)/[emailAccountId]/simple/ViewMoreButton"; diff --git a/apps/web/app/(app)/[emailAccountId]/usage/usage.tsx b/apps/web/app/(app)/[emailAccountId]/usage/usage.tsx index 939efe7fbe..6f29089cb0 100644 --- a/apps/web/app/(app)/[emailAccountId]/usage/usage.tsx +++ b/apps/web/app/(app)/[emailAccountId]/usage/usage.tsx @@ -7,13 +7,9 @@ import { usePremium } from "@/components/PremiumAlert"; import { LoadingContent } from "@/components/LoadingContent"; import { env } from "@/env"; import { isPremium } from "@/utils/premium"; +import type { RedisUsage } from "@/utils/redis/usage"; -export function Usage(props: { - usage?: { - openaiCalls: number; - openaiTokensUsed: number; - } | null; -}) { +export function Usage(props: { usage: RedisUsage | null }) { const { premium, isLoading, error } = usePremium(); return ( diff --git a/apps/web/app/(app)/layout.tsx b/apps/web/app/(app)/layout.tsx index 4ad8131c2f..364dabf19e 100644 --- a/apps/web/app/(app)/layout.tsx +++ b/apps/web/app/(app)/layout.tsx @@ -2,7 +2,6 @@ import "../../styles/globals.css"; import type React from "react"; import { cookies } from "next/headers"; import { Suspense } from "react"; -import dynamic from "next/dynamic"; import { redirect } from "next/navigation"; import { SideNavWithTopNav } from "@/components/SideNavWithTopNav"; import { TokenCheck } from "@/components/TokenCheck"; @@ -40,7 +39,7 @@ export default async function AppLayout({ if (!session?.user.email) redirect("/login"); const cookieStore = await cookies(); - const isClosed = cookieStore.get("sidebar_state")?.value === "false"; + const isClosed = cookieStore.get("left-sidebar:state")?.value === "false"; return ( @@ -59,12 +58,12 @@ export default async function AppLayout({ - + {/* - + */} ); } -const CrispWithNoSSR = dynamic(() => import("@/components/CrispChat")); +// const CrispWithNoSSR = dynamic(() => import("@/components/CrispChat")); diff --git a/apps/web/app/(marketing) b/apps/web/app/(marketing) index 2f5ed3aa7e..dc789dc5a0 160000 --- a/apps/web/app/(marketing) +++ b/apps/web/app/(marketing) @@ -1 +1 @@ -Subproject commit 2f5ed3aa7e6fe6c51233910e7ff27b98819a620c +Subproject commit dc789dc5a0a7b772c61eb22da7383ad9cd5b839a diff --git a/apps/web/app/api/chat/route.ts b/apps/web/app/api/chat/route.ts index 2e31a23131..b95868994d 100644 --- a/apps/web/app/api/chat/route.ts +++ b/apps/web/app/api/chat/route.ts @@ -1,4 +1,4 @@ -import { appendClientMessage, appendResponseMessages } from "ai"; +import { convertToModelMessages, type UIMessage } from "ai"; import { z } from "zod"; import { withEmailAccount } from "@/utils/middleware"; import { getEmailAccountWithAi } from "@/utils/user/get"; @@ -6,7 +6,9 @@ import { NextResponse } from "next/server"; import { aiProcessAssistantChat } from "@/utils/ai/assistant/chat"; import { createScopedLogger } from "@/utils/logger"; import prisma from "@/utils/prisma"; -import { Prisma, type ChatMessage } from "@prisma/client"; +import type { Prisma } from "@prisma/client"; +import { convertToUIMessages } from "@/components/assistant-chat/helpers"; +import { captureException } from "@/utils/error"; export const maxDuration = 120; @@ -21,19 +23,8 @@ const assistantInputSchema = z.object({ id: z.string(), message: z.object({ id: z.string(), - createdAt: z.coerce.date(), role: z.enum(["user"]), - content: z.string().min(1).max(3000), parts: z.array(textPartSchema), - // experimental_attachments: z - // .array( - // z.object({ - // url: z.string().url(), - // name: z.string().min(1).max(100), - // contentType: z.enum(["image/png", "image/jpg", "image/jpeg"]), - // }), - // ) - // .optional(), }), }); @@ -49,7 +40,6 @@ export const POST = withEmailAccount(async (request) => { if (error) return NextResponse.json({ error: error.errors }, { status: 400 }); - // create chat if it doesn't exist const chat = (await getChatById(data.id)) || (await createNewChat({ emailAccountId, chatId: data.id })); @@ -69,63 +59,27 @@ export const POST = withEmailAccount(async (request) => { } const { message } = data; - const mappedDbMessages = chat.messages.map((dbMsg: ChatMessage) => { - return { - ...dbMsg, - role: convertDbRoleToSdkRole(dbMsg.role), - content: "", - parts: dbMsg.parts as any, - }; - }); - - const messages = appendClientMessage({ - messages: mappedDbMessages, - message, - }); + const uiMessages = [...convertToUIMessages(chat), message]; await saveChatMessage({ chat: { connect: { id: chat.id } }, id: message.id, role: "user", parts: message.parts, - // attachments: message.experimental_attachments ?? [], }); try { const result = await aiProcessAssistantChat({ - messages, + messages: convertToModelMessages(uiMessages), emailAccountId, user, - onFinish: async ({ response }) => { - const assistantMessages = response.messages.filter( - (message) => message.role === "assistant", - ); - const assistantId = getTrailingMessageId(assistantMessages); - - if (!assistantId) { - logger.error("No assistant message found!", { response }); - throw new Error("No assistant message found!"); - } - - // handles all tool calls - const [, assistantMessage] = appendResponseMessages({ - messages: [message], - responseMessages: response.messages, - }); - - await saveChatMessage({ - id: assistantId, - chat: { connect: { id: chat.id } }, - role: assistantMessage.role, - parts: assistantMessage.parts - ? (assistantMessage.parts as Prisma.InputJsonValue) - : Prisma.JsonNull, - // attachments: assistantMessage.experimental_attachments ?? [], - }); - }, }); - return result.toDataStreamResponse(); + return result.toUIMessageStreamResponse({ + onFinish: async ({ messages }) => { + await saveChatMessages(messages, chat.id); + }, + }); } catch (error) { logger.error("Error in assistant chat", { error }); return NextResponse.json( @@ -155,10 +109,6 @@ async function createNewChat({ } } -async function saveChatMessage(message: Prisma.ChatMessageCreateInput) { - return prisma.chatMessage.create({ data: message }); -} - async function getChatById(chatId: string) { const chat = await prisma.chat.findUnique({ where: { id: chatId }, @@ -167,29 +117,22 @@ async function getChatById(chatId: string) { return chat; } -function convertDbRoleToSdkRole( - role: string, -): "user" | "assistant" | "system" | "data" { - switch (role) { - case "user": - return "user"; - case "assistant": - return "assistant"; - case "system": - return "system"; - case "data": - return "data"; - default: - return "assistant"; - } +async function saveChatMessage(message: Prisma.ChatMessageCreateInput) { + return prisma.chatMessage.create({ data: message }); } -function getTrailingMessageId( - messages: Array, -): string | null { - const trailingMessage = messages.at(-1); - - if (!trailingMessage) return null; - - return trailingMessage.id; +async function saveChatMessages(messages: UIMessage[], chatId: string) { + try { + return prisma.chatMessage.createMany({ + data: messages.map((message) => ({ + chatId, + role: message.role, + parts: message.parts as Prisma.InputJsonValue, + })), + }); + } catch (error) { + logger.error("Failed to save chat messages", { error, chatId }); + captureException(error, { extra: { chatId } }); + throw error; + } } diff --git a/apps/web/app/api/lemon-squeezy/webhook/route.ts b/apps/web/app/api/lemon-squeezy/webhook/route.ts index 5929272659..8707d4d03a 100644 --- a/apps/web/app/api/lemon-squeezy/webhook/route.ts +++ b/apps/web/app/api/lemon-squeezy/webhook/route.ts @@ -286,7 +286,7 @@ async function subscriptionUpdated({ } async function subscriptionCancelled({ - payload, + payload: _payload, premiumId, endsAt, variantId, diff --git a/apps/web/components/AccountSwitcher.tsx b/apps/web/components/AccountSwitcher.tsx index 7d8bb11068..54849f3d65 100644 --- a/apps/web/components/AccountSwitcher.tsx +++ b/apps/web/components/AccountSwitcher.tsx @@ -20,7 +20,6 @@ import { } from "@/components/ui/sidebar"; import { useAccounts } from "@/hooks/useAccounts"; import type { GetEmailAccountsResponse } from "@/app/api/user/email-accounts/route"; -import { useModifierKey } from "@/hooks/useModifierKey"; import { useAccount } from "@/providers/EmailAccountProvider"; import { ProfileImage } from "@/components/ProfileImage"; @@ -38,7 +37,6 @@ export function AccountSwitcherInternal({ emailAccounts: GetEmailAccountsResponse["emailAccounts"]; }) { const { isMobile } = useSidebar(); - const { symbol: modifierSymbol } = useModifierKey(); const { emailAccountId: activeEmailAccountId, @@ -76,6 +74,7 @@ export function AccountSwitcherInternal({ {activeEmailAccount ? ( <> diff --git a/apps/web/components/Combobox.tsx b/apps/web/components/Combobox.tsx index 49d604f930..e4b9d6863a 100644 --- a/apps/web/components/Combobox.tsx +++ b/apps/web/components/Combobox.tsx @@ -38,7 +38,6 @@ export function Combobox(props: { @@ -243,25 +90,8 @@ function NewChatButton() { ); } -function OpenArtifactButton() { - const [tab, setTab] = useQueryState("tab"); - - if (tab) return null; - - const handleOpenArtifact = () => setTab("rules"); - - return ( - - - - ); -} - function ChatHistoryDropdown() { - const [_chatId, setChatId] = useQueryState("chatId"); + const { setChatId } = useChat(); const [shouldLoadChats, setShouldLoadChats] = useState(false); const { data, error, isLoading, mutate } = useChats(shouldLoadChats); @@ -318,26 +148,3 @@ function ChatHistoryDropdown() { ); } - -// NOTE: not sure why we don't just use the default from AI SDK -function generateUUID(): string { - return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { - const r = (Math.random() * 16) | 0; - const v = c === "x" ? r : (r & 0x3) | 0x8; - return v.toString(16); - }); -} - -function convertToUIMessages(chat: GetChatResponse): Array { - return ( - chat?.messages.map((message) => ({ - id: message.id, - parts: message.parts as UIMessage["parts"], - role: message.role as UIMessage["role"], - // Note: content will soon be deprecated in @ai-sdk/react - content: "", - createdAt: message.createdAt, - // experimental_attachments: (message.attachments as Array) ?? [], - })) || [] - ); -} diff --git a/apps/web/components/assistant-chat/data-stream-handler.tsx b/apps/web/components/assistant-chat/data-stream-handler.tsx deleted file mode 100644 index b58a016c13..0000000000 --- a/apps/web/components/assistant-chat/data-stream-handler.tsx +++ /dev/null @@ -1,31 +0,0 @@ -"use client"; - -import { useChat } from "@ai-sdk/react"; -import { useEffect, useRef } from "react"; - -export type DataStreamDelta = { - type: - | "text-delta" - | "code-delta" - | "sheet-delta" - | "image-delta" - | "title" - | "id" - | "suggestion" - | "clear" - | "finish" - | "kind"; - content: string; -}; - -export function DataStreamHandler({ id }: { id: string }) { - const { data: dataStream } = useChat({ id }); - const lastProcessedIndex = useRef(-1); - - useEffect(() => { - if (!dataStream?.length) return; - lastProcessedIndex.current = dataStream.length - 1; - }, [dataStream]); - - return null; -} diff --git a/apps/web/components/assistant-chat/examples-dialog.tsx b/apps/web/components/assistant-chat/examples-dialog.tsx index d026af0d75..31d5a838ea 100644 --- a/apps/web/components/assistant-chat/examples-dialog.tsx +++ b/apps/web/components/assistant-chat/examples-dialog.tsx @@ -1,7 +1,6 @@ "use client"; import { useState } from "react"; -import type { UseChatHelpers } from "@ai-sdk/react"; import { Dialog, DialogContent, @@ -28,7 +27,7 @@ import { parseAsStringEnum, useQueryState } from "nuqs"; import { cn } from "@/utils"; interface ExamplesDialogProps { - setInput: UseChatHelpers["setInput"]; + setInput: (input: string) => void; children?: React.ReactNode; open?: boolean; onOpenChange?: (open: boolean) => void; diff --git a/apps/web/components/assistant-chat/helpers.ts b/apps/web/components/assistant-chat/helpers.ts new file mode 100644 index 0000000000..070d74c2e3 --- /dev/null +++ b/apps/web/components/assistant-chat/helpers.ts @@ -0,0 +1,20 @@ +import type { UIMessage, UIMessagePart } from "ai"; +import type { GetChatResponse } from "@/app/api/chats/[chatId]/route"; +import type { + ChatMessage, + ChatTools, + CustomUIDataTypes, +} from "@/components/assistant-chat/types"; + +export function convertToUIMessages(chat: GetChatResponse): ChatMessage[] { + return ( + chat?.messages.map((message) => ({ + id: message.id, + role: message.role as UIMessage["role"], + parts: message.parts as UIMessagePart[], + // metadata: { + // createdAt: formatISO(message.createdAt), + // }, + })) || [] + ); +} diff --git a/apps/web/components/assistant-chat/message-editor.tsx b/apps/web/components/assistant-chat/message-editor.tsx index 6501cf785d..54d3d6a079 100644 --- a/apps/web/components/assistant-chat/message-editor.tsx +++ b/apps/web/components/assistant-chat/message-editor.tsx @@ -1,6 +1,5 @@ "use client"; -import type { Message } from "ai"; import { type Dispatch, type SetStateAction, @@ -11,27 +10,30 @@ import { import type { UseChatHelpers } from "@ai-sdk/react"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; +import type { ChatMessage } from "@/components/assistant-chat/types"; // import { deleteTrailingMessages } from "@/app/(app)/assistant/chat/actions"; -export type MessageEditorProps = { - message: Message; +type MessageEditorProps = { + message: ChatMessage; setMode: Dispatch>; - setMessages: UseChatHelpers["setMessages"]; - reload: UseChatHelpers["reload"]; + setMessages: UseChatHelpers["setMessages"]; + regenerate: UseChatHelpers["regenerate"]; }; export function MessageEditor({ message, setMode, setMessages, - reload, + regenerate, }: MessageEditorProps) { const [isSubmitting, setIsSubmitting] = useState(false); - const [draftContent, setDraftContent] = useState(message.content); + const [draftContent, setDraftContent] = useState( + getTextFromMessage(message), + ); const textareaRef = useRef(null); - // biome-ignore lint/correctness/useExhaustiveDependencies: + // biome-ignore lint/correctness/useExhaustiveDependencies: ignore useEffect(() => { if (textareaRef.current) { adjustHeight(); @@ -89,7 +91,6 @@ export function MessageEditor({ if (index !== -1) { const updatedMessage = { ...message, - content: draftContent, parts: [{ type: "text", text: draftContent }], }; @@ -100,7 +101,7 @@ export function MessageEditor({ }); setMode("view"); - reload(); + regenerate(); }} > {isSubmitting ? "Sending..." : "Send"} @@ -109,3 +110,10 @@ export function MessageEditor({
); } + +function getTextFromMessage(message: ChatMessage): string { + return message.parts + .filter((part) => part.type === "text") + .map((part) => part.text) + .join(""); +} diff --git a/apps/web/components/assistant-chat/message.tsx b/apps/web/components/assistant-chat/message.tsx index a97111ed26..bbbacb3630 100644 --- a/apps/web/components/assistant-chat/message.tsx +++ b/apps/web/components/assistant-chat/message.tsx @@ -1,7 +1,6 @@ "use client"; import { memo, useState } from "react"; -import type { UIMessage } from "ai"; import type { UseChatHelpers } from "@ai-sdk/react"; import { AnimatePresence, motion } from "framer-motion"; import { SparklesIcon } from "lucide-react"; @@ -10,19 +9,27 @@ import { Markdown } from "./markdown"; import { cn } from "@/utils"; import { MessageEditor } from "./message-editor"; import { MessageReasoning } from "./message-reasoning"; -import { ToolCard } from "@/components/assistant-chat/tools"; -import { Skeleton } from "@/components/ui/skeleton"; +import { + AddToKnowledgeBase, + BasicToolInfo, + CreatedRuleToolCard, + UpdateAbout, + UpdatedLearnedPatterns, + UpdatedRuleActions, + UpdatedRuleConditions, +} from "@/components/assistant-chat/tools"; +import type { ChatMessage } from "@/components/assistant-chat/types"; const PurePreviewMessage = ({ message, isLoading, setMessages, - reload, + regenerate, }: { - message: UIMessage; + message: ChatMessage; isLoading: boolean; - setMessages: UseChatHelpers["setMessages"]; - reload: UseChatHelpers["reload"]; + setMessages: UseChatHelpers["setMessages"]; + regenerate: UseChatHelpers["regenerate"]; }) => { const [mode, setMode] = useState<"view" | "edit">("view"); @@ -62,13 +69,15 @@ const PurePreviewMessage = ({ ); } if (type === "text") { if (mode === "view") { + if (!part.text) return null; + return (
); } } - if (type === "tool-invocation") { - const { toolInvocation } = part; - const { toolName, toolCallId, state } = toolInvocation; + if (type === "tool-getUserRulesAndSettings") { + const { toolCallId, state } = part; + + if (state === "input-available") { + return ( + + ); + } + + if (state === "output-available") { + const { output } = part; + + if ("error" in output) { + return ( + + ); + } + + return ( + + ); + } + } + + if (type === "tool-getLearnedPatterns") { + const { toolCallId, state } = part; + + if (state === "input-available") { + return ( + + ); + } + + if (state === "output-available") { + const { output } = part; + + if ("error" in output) { + return ( + + ); + } + + return ( + + ); + } + } + + if (type === "tool-createRule") { + const { toolCallId, state } = part; + + if (state === "input-available") { + return ( + + ); + } + + if (state === "output-available") { + const { output } = part; + + if ("error" in output) { + return ( + + ); + } + + return ( + + ); + } + } + + if (type === "tool-updateRuleConditions") { + const { toolCallId, state } = part; + + if (state === "input-available") { + return ( + + ); + } + + if (state === "output-available") { + const { output } = part; + + if ("error" in output) { + return ( + + ); + } + + return ( + + ); + } + } + + if (type === "tool-updateRuleActions") { + const { toolCallId, state } = part; + + if (state === "input-available") { + return ( + + ); + } + + if (state === "output-available") { + const { output } = part; + + if ("error" in output) { + return ( + + ); + } + + return ( + + ); + } + } + + if (type === "tool-updateLearnedPatterns") { + const { toolCallId, state } = part; + + if (state === "input-available") { + return ( + + ); + } + + if (state === "output-available") { + const { output } = part; + + if ("error" in output) { + return ( + + ); + } + + return ( + + ); + } + } + + if (type === "tool-updateAbout") { + const { toolCallId, state } = part; + + if (state === "input-available") { + return ( + + ); + } + + if (state === "output-available") { + const { output } = part; + + if ("error" in output) { + return ( + + ); + } - if (state === "call") { - return ; + return ; } + } - if (state === "result") { + if (type === "tool-addToKnowledgeBase") { + const { toolCallId, state } = part; + + if (state === "input-available") { return ( - ); } + + if (state === "output-available") { + const { output } = part; + + if ("error" in output) { + return ( + + ); + } + + return ( + + ); + } } })}
@@ -129,6 +378,10 @@ const PurePreviewMessage = ({ ); }; +function ErrorToolCard({ error }: { error: string }) { + return
Error: {error}
; +} + export const PreviewMessage = memo( PurePreviewMessage, (prevProps, nextProps) => { diff --git a/apps/web/components/assistant-chat/messages.tsx b/apps/web/components/assistant-chat/messages.tsx index e6569c4ff0..8557b140e7 100644 --- a/apps/web/components/assistant-chat/messages.tsx +++ b/apps/web/components/assistant-chat/messages.tsx @@ -1,25 +1,25 @@ -import type { UIMessage } from "ai"; import { PreviewMessage, ThinkingMessage } from "./message"; import { useScrollToBottom } from "./use-scroll-to-bottom"; import { Overview } from "./overview"; import { memo } from "react"; import equal from "fast-deep-equal"; import type { UseChatHelpers } from "@ai-sdk/react"; +import type { ChatMessage } from "@/components/assistant-chat/types"; interface MessagesProps { - status: UseChatHelpers["status"]; - messages: Array; - setMessages: UseChatHelpers["setMessages"]; - reload: UseChatHelpers["reload"]; + status: UseChatHelpers["status"]; + messages: Array; + setMessages: UseChatHelpers["setMessages"]; + regenerate: UseChatHelpers["regenerate"]; isArtifactVisible: boolean; - setInput: UseChatHelpers["setInput"]; + setInput: (input: string) => void; } function PureMessages({ status, messages, setMessages, - reload, + regenerate, setInput, }: MessagesProps) { const [messagesContainerRef, messagesEndRef] = @@ -38,7 +38,7 @@ function PureMessages({ message={message} isLoading={status === "streaming" && messages.length - 1 === index} setMessages={setMessages} - reload={reload} + regenerate={regenerate} /> ))} diff --git a/apps/web/components/assistant-chat/multimodal-input.tsx b/apps/web/components/assistant-chat/multimodal-input.tsx index d5161a8f69..eef3e2f5f8 100644 --- a/apps/web/components/assistant-chat/multimodal-input.tsx +++ b/apps/web/components/assistant-chat/multimodal-input.tsx @@ -11,6 +11,7 @@ import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; // import { SuggestedActions } from "./suggested-actions"; import { cn } from "@/utils"; +import type { ChatMessage } from "@/components/assistant-chat/types"; function PureMultimodalInput({ // chatId, @@ -25,16 +26,16 @@ function PureMultimodalInput({ className, }: { // chatId?: string; - input: UseChatHelpers["input"]; - setInput: UseChatHelpers["setInput"]; - status: UseChatHelpers["status"]; + input: string; + setInput: (input: string) => void; + status: UseChatHelpers["status"]; stop: () => void; // attachments: Array; // setAttachments: Dispatch>>; // messages: Array; - setMessages: UseChatHelpers["setMessages"]; + setMessages: UseChatHelpers["setMessages"]; // append: UseChatHelpers["append"]; - handleSubmit: UseChatHelpers["handleSubmit"]; + handleSubmit: () => void; className?: string; }) { const textareaRef = useRef(null); @@ -71,7 +72,7 @@ function PureMultimodalInput({ "", ); - // biome-ignore lint/correctness/useExhaustiveDependencies: + // biome-ignore lint/correctness/useExhaustiveDependencies: how vercel chat template had it useEffect(() => { if (textareaRef.current) { const domValue = textareaRef.current.value; @@ -92,11 +93,11 @@ function PureMultimodalInput({ // adjustHeight(); // handled in useEffect }; - // biome-ignore lint/correctness/useExhaustiveDependencies: + // biome-ignore lint/correctness/useExhaustiveDependencies: how vercel chat template had it const submitForm = useCallback(() => { // window.history.replaceState({}, "", `/chat/${chatId}`); - handleSubmit(undefined); + handleSubmit(); setLocalStorageInput(""); resetHeight(); @@ -168,7 +169,7 @@ function PureStopButton({ setMessages, }: { stop: () => void; - setMessages: UseChatHelpers["setMessages"]; + setMessages: UseChatHelpers["setMessages"]; }) { return ( - - ))} -
- ); -} - -export const SuggestedActions = memo(PureSuggestedActions, () => true); diff --git a/apps/web/components/assistant-chat/tools.tsx b/apps/web/components/assistant-chat/tools.tsx index 254240fcd2..7d879677c7 100644 --- a/apps/web/components/assistant-chat/tools.tsx +++ b/apps/web/components/assistant-chat/tools.tsx @@ -2,15 +2,13 @@ import { useState } from "react"; import { motion, AnimatePresence } from "framer-motion"; import { useQueryState } from "nuqs"; import type { - UpdateAboutSchema, - UpdateRuleConditionSchema, - UpdateRuleActionsSchema, - UpdateLearnedPatternsSchema, - AddToKnowledgeBaseSchema, - UpdateRuleConditionsResult, - UpdateRuleActionsResult, + UpdateAboutTool, + UpdateRuleConditionsTool, + UpdateRuleActionsTool, + UpdateLearnedPatternsTool, + AddToKnowledgeBaseTool, + CreateRuleTool, } from "@/utils/ai/assistant/chat"; -import type { CreateRuleSchema } from "@/utils/ai/rule/create-rule-schema"; import { Card } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { EyeIcon, SparklesIcon, TrashIcon, FileDiffIcon } from "lucide-react"; @@ -22,64 +20,7 @@ import { ExpandableText } from "@/components/ExpandableText"; import { RuleDialog } from "@/app/(app)/[emailAccountId]/assistant/RuleDialog"; import { useDialogState } from "@/hooks/useDialogState"; -export function ToolCard({ - toolName, - args, - ruleId, - result, -}: { - toolName: string; - args: any; - ruleId?: string; - result?: any; -}) { - switch (toolName) { - case "get_user_rules_and_settings": - return ; - case "get_learned_patterns": - return ; - case "create_rule": - return ; - case "update_rule_conditions": { - const conditionsResult = result as UpdateRuleConditionsResult; - return ( - - ); - } - case "update_rule_actions": { - const actionsResult = result as UpdateRuleActionsResult; - return ( - - ); - } - case "update_learned_patterns": { - return ( - - ); - } - case "update_about": - return ; - case "add_to_knowledge_base": - return ; - default: - return null; - } -} - -function BasicInfo({ text }: { text: string }) { +export function BasicToolInfo({ text }: { text: string }) { return (
{text}
@@ -87,11 +28,11 @@ function BasicInfo({ text }: { text: string }) { ); } -function CreatedRule({ +export function CreatedRuleToolCard({ args, ruleId, }: { - args: CreateRuleSchema; + args: CreateRuleTool["input"]; ruleId?: string; }) { const conditionsArray = [ @@ -104,8 +45,7 @@ function CreatedRule({ - {ruleId ? "New rule created:" : "Creating rule:"}{" "} - {args.name} + {ruleId ? "New rule created:" : "Creating rule:"} {args.name} } actions={ruleId && } @@ -160,16 +100,16 @@ function CreatedRule({ ); } -function UpdatedRuleConditions({ +export function UpdatedRuleConditions({ args, ruleId, originalConditions, updatedConditions, }: { - args: UpdateRuleConditionSchema; + args: UpdateRuleConditionsTool["input"]; ruleId: string; - originalConditions?: UpdateRuleConditionsResult["originalConditions"]; - updatedConditions?: UpdateRuleConditionsResult["updatedConditions"]; + originalConditions?: UpdateRuleConditionsTool["output"]["originalConditions"]; + updatedConditions?: UpdateRuleConditionsTool["output"]["updatedConditions"]; }) { const [showChanges, setShowChanges] = useState(false); @@ -249,16 +189,16 @@ function UpdatedRuleConditions({ ); } -function UpdatedRuleActions({ +export function UpdatedRuleActions({ args, ruleId, originalActions, updatedActions, }: { - args: UpdateRuleActionsSchema; + args: UpdateRuleActionsTool["input"]; ruleId: string; - originalActions?: UpdateRuleActionsResult["originalActions"]; - updatedActions?: UpdateRuleActionsResult["updatedActions"]; + originalActions?: UpdateRuleActionsTool["output"]["originalActions"]; + updatedActions?: UpdateRuleActionsTool["output"]["updatedActions"]; }) { const [showChanges, setShowChanges] = useState(false); @@ -268,7 +208,11 @@ function UpdatedRuleActions({ updatedActions && JSON.stringify(originalActions) !== JSON.stringify(updatedActions); - const formatActions = (actions: any[]) => { + const formatActions = < + T extends { type: string; fields: Record }, + >( + actions: T[], + ) => { return actions .map((action) => { const parts = [`Type: ${action.type}`]; @@ -333,11 +277,11 @@ function UpdatedRuleActions({ ); } -function UpdatedLearnedPatterns({ +export function UpdatedLearnedPatterns({ args, ruleId, }: { - args: UpdateLearnedPatternsSchema; + args: UpdateLearnedPatternsTool["input"]; ruleId: string; }) { return ( @@ -389,7 +333,7 @@ function UpdatedLearnedPatterns({ ); } -function UpdateAbout({ args }: { args: UpdateAboutSchema }) { +export function UpdateAbout({ args }: { args: UpdateAboutTool["input"] }) { return ( Updated About Information} /> @@ -398,7 +342,11 @@ function UpdateAbout({ args }: { args: UpdateAboutSchema }) { ); } -function AddToKnowledgeBase({ args }: { args: AddToKnowledgeBaseSchema }) { +export function AddToKnowledgeBase({ + args, +}: { + args: AddToKnowledgeBaseTool["input"]; +}) { const [_, setTab] = useQueryState("tab"); return ( @@ -569,7 +517,7 @@ function renderActionFields(fields: { const fieldEntries = []; // Only add fields that have actual values - if (fields.label) fieldEntries.push([fields.label]); + if (fields.label) fieldEntries.push(["Label", fields.label]); if (fields.subject) fieldEntries.push(["Subject", fields.subject]); if (fields.to) fieldEntries.push(["To", fields.to]); if (fields.cc) fieldEntries.push(["CC", fields.cc]); diff --git a/apps/web/components/assistant-chat/types.ts b/apps/web/components/assistant-chat/types.ts index d0694cf1d0..4b498246af 100644 --- a/apps/web/components/assistant-chat/types.ts +++ b/apps/web/components/assistant-chat/types.ts @@ -1,4 +1,47 @@ -// TODO: remove null once this feature is live -export type SetInputFunction = React.Dispatch< - React.SetStateAction -> | null; +import type { UIMessage } from "ai"; +import type { + AddToKnowledgeBaseTool, + CreateRuleTool, + GetLearnedPatternsTool, + GetUserRulesAndSettingsTool, + UpdateAboutTool, + UpdateLearnedPatternsTool, + UpdateRuleActionsTool, + UpdateRuleConditionsTool, +} from "@/utils/ai/assistant/chat"; + +// export type DataPart = { type: "append-message"; message: string }; + +// export const messageMetadataSchema = z.object({ createdAt: z.string() }); + +// export type MessageMetadata = z.infer; + +export type ChatTools = { + getUserRulesAndSettings: GetUserRulesAndSettingsTool; + getLearnedPatterns: GetLearnedPatternsTool; + createRule: CreateRuleTool; + updateRuleConditions: UpdateRuleConditionsTool; + updateRuleActions: UpdateRuleActionsTool; + updateLearnedPatterns: UpdateLearnedPatternsTool; + updateAbout: UpdateAboutTool; + addToKnowledgeBase: AddToKnowledgeBaseTool; +}; + +// biome-ignore lint/complexity/noBannedTypes: ignore +export type CustomUIDataTypes = { + // textDelta: string; + // // suggestion: Suggestion; + // appendMessage: string; + // id: string; + // title: string; + // clear: null; + // finish: null; + // ruleId: string | null; +}; + +export type ChatMessage = UIMessage< + // biome-ignore lint/complexity/noBannedTypes: ignore + {}, // MessageMetadata, + CustomUIDataTypes, + ChatTools +>; diff --git a/apps/web/components/editor/extensions/MentionList.tsx b/apps/web/components/editor/extensions/MentionList.tsx index a1cfcf04a6..fd35b89cde 100644 --- a/apps/web/components/editor/extensions/MentionList.tsx +++ b/apps/web/components/editor/extensions/MentionList.tsx @@ -60,7 +60,9 @@ export const MentionList = forwardRef( if (items.length === 0) { return (
-
No labels found. Type to create a new label.
+
+ No labels found. Type to create a new label. +
); } diff --git a/apps/web/components/email-list/EmailMessage.tsx b/apps/web/components/email-list/EmailMessage.tsx index 2514d2d39f..635e2b4817 100644 --- a/apps/web/components/email-list/EmailMessage.tsx +++ b/apps/web/components/email-list/EmailMessage.tsx @@ -65,7 +65,7 @@ export function EmailMessage({ }, []); return ( - // biome-ignore lint/a11y/useKeyWithClickEvents: + // biome-ignore lint/a11y/useKeyWithClickEvents: ignore
  • , - IconRight: ({ ...props }) => , + IconLeft: () => , + IconRight: () => , }} {...props} /> diff --git a/apps/web/components/ui/sidebar.tsx b/apps/web/components/ui/sidebar.tsx index 558e8359d1..01f391b70e 100644 --- a/apps/web/components/ui/sidebar.tsx +++ b/apps/web/components/ui/sidebar.tsx @@ -18,7 +18,8 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; -const SIDEBAR_COOKIE_NAME = "sidebar_state"; +// multi sidebar support based on: https://gist.github.com/ercnshngit/e1510e966860e04e47d752d16be3cbc1/revisions?diff=unified&w + const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; const SIDEBAR_WIDTH = "16rem"; const SIDEBAR_WIDTH_MOBILE = "18rem"; @@ -26,13 +27,13 @@ const SIDEBAR_WIDTH_ICON = "3rem"; const SIDEBAR_KEYBOARD_SHORTCUT = "b"; type SidebarContext = { - state: "expanded" | "collapsed"; - open: boolean; - setOpen: (open: boolean) => void; - openMobile: boolean; - setOpenMobile: (open: boolean) => void; + state: string[]; + open: string[]; + setOpen: React.Dispatch>; + openMobile: string[]; + setOpenMobile: React.Dispatch>; isMobile: boolean; - toggleSidebar: () => void; + toggleSidebar: (names: string[]) => void; }; const SidebarContext = React.createContext(null); @@ -49,14 +50,16 @@ function useSidebar() { const SidebarProvider = React.forwardRef< HTMLDivElement, React.ComponentProps<"div"> & { - defaultOpen?: boolean; - open?: boolean; - onOpenChange?: (open: boolean) => void; + defaultOpen?: "all" | string[]; + sidebarNames?: string[]; + open?: string[]; + onOpenChange?: (open: string[]) => void; } >( ( { - defaultOpen = true, + defaultOpen = "all", + sidebarNames = [], open: openProp, onOpenChange: setOpenProp, className, @@ -67,14 +70,16 @@ const SidebarProvider = React.forwardRef< ref, ) => { const isMobile = useIsMobile(); - const [openMobile, setOpenMobile] = React.useState(false); + const [openMobile, setOpenMobile] = React.useState([]); // This is the internal state of the sidebar. // We use openProp and setOpenProp for control from outside the component. - const [_open, _setOpen] = React.useState(defaultOpen); + const [_open, _setOpen] = React.useState( + defaultOpen === "all" ? sidebarNames : defaultOpen, + ); const open = openProp ?? _open; const setOpen = React.useCallback( - (value: boolean | ((value: boolean) => boolean)) => { + (value: string[] | ((value: string[]) => string[])) => { const openState = typeof value === "function" ? value(open) : value; if (setOpenProp) { setOpenProp(openState); @@ -83,18 +88,35 @@ const SidebarProvider = React.forwardRef< } // This sets the cookie to keep the sidebar state. - document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`; + // This sets the cookie to keep the sidebar state. + sidebarNames.forEach((sidebarName) => { + document.cookie = `${sidebarName}:state=${openState.includes(sidebarName)}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`; + }); }, - [setOpenProp, open], + [setOpenProp, open, sidebarNames], ); // Helper to toggle the sidebar. // biome-ignore lint/correctness/useExhaustiveDependencies: keeping as shadcn default - const toggleSidebar = React.useCallback(() => { - return isMobile - ? setOpenMobile((open) => !open) - : setOpen((open) => !open); - }, [isMobile, setOpen, setOpenMobile]); + const toggleSidebar = React.useCallback( + (names: string[]) => { + const setOpenState = (prev: string[]) => { + let temp = [...prev]; + + names.forEach((name) => { + if (temp.includes(name)) { + temp = temp.filter((n) => n !== name); + } else { + temp = [...temp, name]; + } + }); + return temp; + }; + + return isMobile ? setOpenMobile(setOpenState) : setOpen(setOpenState); + }, + [isMobile, setOpen, setOpenMobile], + ); // Adds a keyboard shortcut to toggle the sidebar. React.useEffect(() => { @@ -104,17 +126,17 @@ const SidebarProvider = React.forwardRef< (event.metaKey || event.ctrlKey) ) { event.preventDefault(); - toggleSidebar(); + toggleSidebar(sidebarNames); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [toggleSidebar]); + }, [toggleSidebar, sidebarNames]); // We add a state so that we can do data-state="expanded" or "collapsed". // This makes it easier to style the sidebar with Tailwind classes. - const state = open ? "expanded" : "collapsed"; + const state = open; // biome-ignore lint/correctness/useExhaustiveDependencies: keeping as shadcn default const contextValue = React.useMemo( @@ -168,6 +190,7 @@ SidebarProvider.displayName = "SidebarProvider"; const Sidebar = React.forwardRef< HTMLDivElement, React.ComponentProps<"div"> & { + name: string; side?: "left" | "right"; variant?: "sidebar" | "floating" | "inset"; collapsible?: "offcanvas" | "icon" | "none"; @@ -175,6 +198,7 @@ const Sidebar = React.forwardRef< >( ( { + name, side = "left", variant = "sidebar", collapsible = "offcanvas", @@ -203,7 +227,15 @@ const Sidebar = React.forwardRef< if (isMobile) { return ( - + + setOpenMobile((prev) => + open ? [...prev, name] : prev.filter((n) => n !== name), + ) + } + {...props} + > @@ -257,12 +289,19 @@ const Sidebar = React.forwardRef< >
    - {children} + {React.Children.map(children, (child) => { + if ( + React.isValidElement(child) && + child.type === SidebarMenuButton + ) { + return React.cloneElement(child, { + isCollapsed: !state.includes(name), + } as React.ComponentProps); + } + return child; + })}
  • @@ -273,8 +312,10 @@ Sidebar.displayName = "Sidebar"; const SidebarTrigger = React.forwardRef< React.ElementRef, - React.ComponentProps ->(({ className, onClick, ...props }, ref) => { + React.ComponentProps & { + name: string; + } +>(({ name, className, onClick, ...props }, ref) => { const { toggleSidebar } = useSidebar(); return ( @@ -286,7 +327,7 @@ const SidebarTrigger = React.forwardRef< className={cn("h-7 w-7", className)} onClick={(event) => { onClick?.(event); - toggleSidebar(); + toggleSidebar(name ? [name] : []); }} {...props} > @@ -299,8 +340,10 @@ SidebarTrigger.displayName = "SidebarTrigger"; const SidebarRail = React.forwardRef< HTMLButtonElement, - React.ComponentProps<"button"> ->(({ className, ...props }, ref) => { + React.ComponentProps<"button"> & { + name: string; + } +>(({ name, className, ...props }, ref) => { const { toggleSidebar } = useSidebar(); return ( @@ -309,7 +352,7 @@ const SidebarRail = React.forwardRef< data-sidebar="rail" aria-label="Toggle Sidebar" tabIndex={-1} - onClick={toggleSidebar} + onClick={() => toggleSidebar(name ? [name] : [])} title="Toggle Sidebar" className={cn( "absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex", @@ -549,6 +592,7 @@ const SidebarMenuButton = React.forwardRef< HTMLButtonElement, React.ComponentProps<"button"> & { asChild?: boolean; + sidebarName?: string; isActive?: boolean; tooltip?: string | React.ComponentProps; } & VariantProps @@ -561,6 +605,7 @@ const SidebarMenuButton = React.forwardRef< size = "default", tooltip, className, + sidebarName, ...props }, ref, @@ -568,6 +613,8 @@ const SidebarMenuButton = React.forwardRef< const Comp = asChild ? Slot : "button"; const { isMobile, state } = useSidebar(); + const isCollapsed = state.includes(sidebarName ?? ""); + const button = (