From 2828a944856a59e5eee1477a8613a3e753a09be0 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Mon, 7 Jul 2025 00:40:46 +0200 Subject: [PATCH 01/89] WIP: migrate to ai sdk v5. openrouter holding it up --- .../(app)/[emailAccountId]/simple/Summary.tsx | 2 +- apps/web/app/api/chat/route.ts | 45 +- apps/web/components/assistant-chat/chat.tsx | 190 ++- .../assistant-chat/data-stream-handler.tsx | 31 - apps/web/components/assistant-chat/helpers.ts | 20 + .../assistant-chat/message-editor.tsx | 27 +- .../web/components/assistant-chat/message.tsx | 256 +++- .../components/assistant-chat/messages.tsx | 13 +- .../assistant-chat/multimodal-input.tsx | 13 +- apps/web/components/assistant-chat/tools.tsx | 111 +- apps/web/components/assistant-chat/types.ts | 53 +- apps/web/package.json | 15 +- apps/web/utils/ai/assistant/chat.ts | 1169 +++++++++-------- .../utils/ai/choose-rule/ai-choose-args.ts | 8 +- apps/web/utils/llms/index.ts | 19 +- apps/web/utils/llms/model.ts | 52 +- apps/web/utils/redis/usage.ts | 17 +- apps/web/utils/usage.ts | 23 +- pnpm-lock.yaml | 183 ++- 19 files changed, 1303 insertions(+), 944 deletions(-) delete mode 100644 apps/web/components/assistant-chat/data-stream-handler.tsx create mode 100644 apps/web/components/assistant-chat/helpers.ts 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/api/chat/route.ts b/apps/web/app/api/chat/route.ts index 2e31a23131..14e0141182 100644 --- a/apps/web/app/api/chat/route.ts +++ b/apps/web/app/api/chat/route.ts @@ -1,4 +1,8 @@ -import { appendClientMessage, appendResponseMessages } from "ai"; +import { + convertToModelMessages, + createUIMessageStream, + JsonToSseTransformStream, +} from "ai"; import { z } from "zod"; import { withEmailAccount } from "@/utils/middleware"; import { getEmailAccountWithAi } from "@/utils/user/get"; @@ -7,6 +11,7 @@ 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 { convertToUIMessages } from "@/components/assistant-chat/helpers"; export const maxDuration = 120; @@ -23,7 +28,6 @@ const assistantInputSchema = 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( @@ -69,31 +73,18 @@ 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 = [message, ...convertToUIMessages(chat)]; 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 }) => { @@ -120,12 +111,11 @@ export const POST = withEmailAccount(async (request) => { parts: assistantMessage.parts ? (assistantMessage.parts as Prisma.InputJsonValue) : Prisma.JsonNull, - // attachments: assistantMessage.experimental_attachments ?? [], }); }, }); - return result.toDataStreamResponse(); + return result.toUIMessageStreamResponse(); } catch (error) { logger.error("Error in assistant chat", { error }); return NextResponse.json( @@ -167,23 +157,6 @@ 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"; - } -} - function getTrailingMessageId( messages: Array, ): string | null { diff --git a/apps/web/components/assistant-chat/chat.tsx b/apps/web/components/assistant-chat/chat.tsx index 81f0e35fce..9ec70423cb 100644 --- a/apps/web/components/assistant-chat/chat.tsx +++ b/apps/web/components/assistant-chat/chat.tsx @@ -1,9 +1,8 @@ "use client"; -import type React from "react"; import { useEffect, useMemo, useState } from "react"; -import { type ScopedMutator, SWRConfig, useSWRConfig } from "swr"; -import type { UIMessage } from "ai"; +import { useSWRConfig } from "swr"; +import { DefaultChatTransport } from "ai"; import { useChat } from "@ai-sdk/react"; import { ArrowLeftToLineIcon, @@ -20,7 +19,6 @@ import { ResizablePanelGroup } from "@/components/ui/resizable"; import { ResizablePanel } from "@/components/ui/resizable"; import { AssistantTabs } from "@/app/(app)/[emailAccountId]/assistant/AssistantTabs"; import { ChatProvider } from "./ChatContext"; -import { SWRProvider } from "@/providers/SWRProvider"; import { Button } from "@/components/ui/button"; import { DropdownMenu, @@ -31,18 +29,12 @@ import { import { useChats } from "@/hooks/useChats"; import { LoadingContent } from "@/components/LoadingContent"; import { useChatMessages } from "@/hooks/useChatMessages"; -import type { GetChatResponse } from "@/app/api/chats/[chatId]/route"; import { useIsMobile } from "@/hooks/use-mobile"; import { ExamplesDialog } from "@/components/assistant-chat/examples-dialog"; import { Tooltip } from "@/components/Tooltip"; import { toastError } from "@/components/Toast"; - -// Some mega hacky code used here to workaround AI SDK's use of SWR -// AI SDK uses SWR too and this messes with the global SWR config -// Wrapping in SWRConfig to disable global fetcher for this component -// https://github.com/vercel/ai/issues/3214#issuecomment-2675872030 -// We then re-enable the regular SWRProvider in the AssistantTabs component -// AI SDK v5 won't use SWR anymore so we can remove this workaround +import type { ChatMessage } from "@/components/assistant-chat/types"; +import { convertToUIMessages } from "./helpers"; const MAX_MESSAGES = 20; @@ -65,9 +57,6 @@ export function Chat(props: ChatProps) { } function ChatWithEmptySWR(props: ChatProps & { chatId: string }) { - // Use parent SWR config for mutate - const { mutate } = useSWRConfig(); - const { data } = useChatMessages(props.chatId); const [{ input, tab }] = useQueryStates({ @@ -81,20 +70,13 @@ function ChatWithEmptySWR(props: ChatProps & { chatId: string }) { }, [input]); return ( - - - + ); } @@ -102,31 +84,38 @@ function ChatInner({ chatId, initialMessages, emailAccountId, - mutate, initialInput, tab, }: ChatProps & { chatId: string; - initialMessages: Array; - mutate: ScopedMutator; + initialMessages: ChatMessage[]; initialInput?: string; tab?: string; }) { - const chat = useChat({ + const { mutate } = useSWRConfig(); + + const [input, setInput] = useState(""); + + const chat = useChat({ id: chatId, - api: "/api/chat", - experimental_prepareRequestBody: (body) => ({ - id: chatId, - message: body.messages.at(-1), + transport: new DefaultChatTransport({ + api: "/api/chat", + headers: { + [EMAIL_ACCOUNT_HEADER]: emailAccountId, + }, + prepareSendMessagesRequest({ messages, id, body }) { + return { + body: { + id, + message: messages.at(-1), + ...body, + }, + }; + }, }), - initialMessages, - initialInput, + messages: initialMessages, experimental_throttle: 100, - sendExtraMessageFields: true, generateId: generateUUID, - headers: { - [EMAIL_ACCOUNT_HEADER]: emailAccountId, - }, onFinish: () => { mutate("/api/user/rules"); }, @@ -139,22 +128,40 @@ function ChatInner({ }, }); + const handleSubmit = () => { + chat.sendMessage({ + role: "user", + parts: [ + { + type: "text", + text: input, + }, + ], + }); + + setInput(""); + }; + const isMobile = useIsMobile(); - const chatPanel = ; + const chatPanel = ( + + ); return ( - + {tab ? ( - {/* re-enable the regular SWRProvider */} - - - + @@ -168,47 +175,44 @@ function ChatInner({ ); } -function ChatUI({ chat }: { chat: ReturnType }) { - const { - messages, - setMessages, - handleSubmit, - input, - setInput, - status, - stop, - reload, - } = chat; - +function ChatUI({ + chat: { messages, setMessages, status, stop, regenerate }, + input, + setInput, + handleSubmit, +}: { + chat: ReturnType>; + input: string; + setInput: (input: string) => void; + handleSubmit: () => void; +}) { return (
- -
- {messages.length > MAX_MESSAGES ? ( -
- The chat is too long. Please start a new conversation. -
- ) : ( -
- )} - -
- - - - +
+ {messages.length > MAX_MESSAGES ? ( +
+ The chat is too long. Please start a new conversation.
+ ) : ( +
+ )} + +
+ + + +
- - - +
+ +
{ - 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/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 e18bc61daf..af88aa50be 100644 --- a/apps/web/components/assistant-chat/message-editor.tsx +++ b/apps/web/components/assistant-chat/message-editor.tsx @@ -1,6 +1,6 @@ "use client"; -import type { Message } from "ai"; +import type { UIMessage } from "ai"; import { type Dispatch, type SetStateAction, @@ -11,24 +11,27 @@ 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); useEffect(() => { @@ -88,7 +91,6 @@ export function MessageEditor({ if (index !== -1) { const updatedMessage = { ...message, - content: draftContent, parts: [{ type: "text", text: draftContent }], }; @@ -99,7 +101,7 @@ export function MessageEditor({ }); setMode("view"); - reload(); + regenerate(); }} > {isSubmitting ? "Sending..." : "Send"} @@ -108,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 61721ba110..81522df85c 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,7 +69,7 @@ const PurePreviewMessage = ({ ); } @@ -94,33 +101,236 @@ const PurePreviewMessage = ({ message={message} setMode={setMode} setMessages={setMessages} - reload={reload} + regenerate={regenerate} />
); } } - if (type === "tool-invocation") { - const { toolInvocation } = part; - const { toolName, toolCallId, state } = toolInvocation; + if (type === "tool-getUserRulesAndSettings") { + const { toolCallId, state } = part; - if (state === "call") { - return ; + if (state === "input-available") { + return ; } - if (state === "result") { + 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 ( + + ); + } + + return ; + } + } + + 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 +339,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..3b09a286f8 100644 --- a/apps/web/components/assistant-chat/messages.tsx +++ b/apps/web/components/assistant-chat/messages.tsx @@ -5,21 +5,22 @@ 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"]; + status: UseChatHelpers["status"]; messages: Array; - setMessages: UseChatHelpers["setMessages"]; - reload: UseChatHelpers["reload"]; + setMessages: UseChatHelpers["setMessages"]; + regenerate: UseChatHelpers["regenerate"]; isArtifactVisible: boolean; - setInput: UseChatHelpers["setInput"]; + setInput: UseChatHelpers["setInput"]; } function PureMessages({ status, messages, setMessages, - reload, + regenerate, setInput, }: MessagesProps) { const [messagesContainerRef, messagesEndRef] = @@ -38,7 +39,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 7da355e7fa..f5089315df 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: UseChatHelpers["input"]; + setInput: UseChatHelpers["setInput"]; + status: UseChatHelpers["status"]; stop: () => void; // attachments: Array; // setAttachments: Dispatch>>; // messages: Array; - setMessages: UseChatHelpers["setMessages"]; + setMessages: UseChatHelpers["setMessages"]; // append: UseChatHelpers["append"]; - handleSubmit: UseChatHelpers["handleSubmit"]; + handleSubmit: UseChatHelpers["handleSubmit"]; className?: string; }) { const textareaRef = useRef(null); @@ -168,7 +169,7 @@ function PureStopButton({ setMessages, }: { stop: () => void; - setMessages: UseChatHelpers["setMessages"]; + setMessages: UseChatHelpers["setMessages"]; }) { return ( @@ -167,7 +171,7 @@ function OpenArtifactButton() { } function ChatHistoryDropdown() { - const [_chatId, setChatId] = useQueryState("chatId"); + const { setChatId } = useChat(); const [shouldLoadChats, setShouldLoadChats] = useState(false); const { data, error, isLoading, mutate } = useChats(shouldLoadChats); diff --git a/apps/web/providers/ChatProvider.tsx b/apps/web/providers/ChatProvider.tsx index 2aa9c95e81..0d85e9835e 100644 --- a/apps/web/providers/ChatProvider.tsx +++ b/apps/web/providers/ChatProvider.tsx @@ -1,14 +1,5 @@ "use client"; -import { toastError } from "@/components/Toast"; -import { convertToUIMessages } from "@/components/assistant-chat/helpers"; -import type { - ChatMessage, - SetInputFunction, -} from "@/components/assistant-chat/types"; -import { useChatMessages } from "@/hooks/useChatMessages"; -import { useAccount } from "@/providers/EmailAccountProvider"; -import { EMAIL_ACCOUNT_HEADER } from "@/utils/config"; import { useChat as useAiChat } from "@ai-sdk/react"; import { DefaultChatTransport } from "ai"; import { parseAsString, useQueryState } from "nuqs"; @@ -20,13 +11,24 @@ import { useState, } from "react"; import { useSWRConfig } from "swr"; +import { toastError } from "@/components/Toast"; +import { convertToUIMessages } from "@/components/assistant-chat/helpers"; +import type { + ChatMessage, + SetInputFunction, +} from "@/components/assistant-chat/types"; +import { useChatMessages } from "@/hooks/useChatMessages"; +import { useAccount } from "@/providers/EmailAccountProvider"; +import { EMAIL_ACCOUNT_HEADER } from "@/utils/config"; export type Chat = ReturnType>; type ChatContextType = { chat: Chat; input: string; + chatId: string | null; setInput: SetInputFunction; + setChatId: (chatId: string | null) => void; setNewChat: () => void; handleSubmit: () => void; }; @@ -99,7 +101,15 @@ export function ChatProvider({ children }: { children: React.ReactNode }) { return ( {children} diff --git a/apps/web/providers/PostHogProvider.tsx b/apps/web/providers/PostHogProvider.tsx index 2fab1357fb..cada20d5d5 100644 --- a/apps/web/providers/PostHogProvider.tsx +++ b/apps/web/providers/PostHogProvider.tsx @@ -25,7 +25,7 @@ export function PostHogPageview() { } }, [pathname, searchParams]); - return <>; + return null; } export function PostHogIdentify() { @@ -38,7 +38,7 @@ export function PostHogIdentify() { }); }, [session?.data?.user.email]); - return <>; + return null; } if (typeof window !== "undefined" && env.NEXT_PUBLIC_POSTHOG_KEY) { diff --git a/apps/web/utils/outlook/mail.ts b/apps/web/utils/outlook/mail.ts index 8c0b2a4c41..b4963f7326 100644 --- a/apps/web/utils/outlook/mail.ts +++ b/apps/web/utils/outlook/mail.ts @@ -3,7 +3,6 @@ import type { Attachment } from "nodemailer/lib/mailer"; import type { SendEmailBody } from "@/utils/gmail/mail"; import type { ParsedMessage } from "@/utils/types"; import type { EmailForAction } from "@/utils/ai/types"; -import { createScopedLogger } from "@/utils/logger"; import { createReplyContent } from "@/utils/gmail/reply"; import { forwardEmailHtml, forwardEmailSubject } from "@/utils/gmail/forward"; @@ -164,7 +163,7 @@ export async function draftEmail( attachments?: Attachment[]; }, ) { - const { text, html } = createReplyContent({ + const { html } = createReplyContent({ textContent: args.content, message: originalEmail, }); From d9dfcfb392e192c2ebccd47ee4abe96ccb2c654d Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Mon, 4 Aug 2025 15:46:56 +0300 Subject: [PATCH 27/89] biome fix --- apps/web/utils/outlook/filter.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/web/utils/outlook/filter.ts b/apps/web/utils/outlook/filter.ts index ad9503cc96..339a294774 100644 --- a/apps/web/utils/outlook/filter.ts +++ b/apps/web/utils/outlook/filter.ts @@ -14,7 +14,7 @@ export async function createFilter(options: { addLabelIds?: string[]; removeLabelIds?: string[]; }) { - const { client, from, addLabelIds, removeLabelIds } = options; + const { client, from, removeLabelIds } = options; try { // Create a mail rule that moves messages from specific sender @@ -196,7 +196,6 @@ export async function updateFilter({ client, id, from, - addLabelIds, removeLabelIds, }: { client: OutlookClient; From a2f6a41d278bac8900f5245346d9a8d70cb26563 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Mon, 4 Aug 2025 16:01:50 +0300 Subject: [PATCH 28/89] fix label --- apps/web/components/assistant-chat/tools.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/components/assistant-chat/tools.tsx b/apps/web/components/assistant-chat/tools.tsx index 10098a4b20..7d879677c7 100644 --- a/apps/web/components/assistant-chat/tools.tsx +++ b/apps/web/components/assistant-chat/tools.tsx @@ -517,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]); From f39f4d02524f8ca6a13ae4a03df87f8089db7053 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Mon, 4 Aug 2025 16:08:23 +0300 Subject: [PATCH 29/89] spacing --- apps/web/components/assistant-chat/message.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/web/components/assistant-chat/message.tsx b/apps/web/components/assistant-chat/message.tsx index 5be0cbde97..bbbacb3630 100644 --- a/apps/web/components/assistant-chat/message.tsx +++ b/apps/web/components/assistant-chat/message.tsx @@ -76,6 +76,8 @@ const PurePreviewMessage = ({ if (type === "text") { if (mode === "view") { + if (!part.text) return null; + return (
Date: Mon, 4 Aug 2025 16:32:01 +0300 Subject: [PATCH 30/89] clean up set chat message --- .../assistant/FixWithChat.tsx | 44 ++++++++----------- .../assistant/ProcessRules.tsx | 3 +- apps/web/components/assistant-chat/types.ts | 2 - apps/web/providers/ChatProvider.tsx | 7 +-- 4 files changed, 22 insertions(+), 34 deletions(-) diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/FixWithChat.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/FixWithChat.tsx index bd946a5f1f..39ecc70cf1 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,7 +15,6 @@ 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"; @@ -30,16 +26,16 @@ export function FixWithChat({ 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"); return ( @@ -82,23 +78,21 @@ 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); + // } 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/ProcessRules.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/ProcessRules.tsx index 054fe99b17..5d2dcfa6d2 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/ProcessRules.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/ProcessRules.tsx @@ -39,7 +39,6 @@ import { Tooltip } from "@/components/Tooltip"; import { useAccount } from "@/providers/EmailAccountProvider"; import { FixWithChat } from "@/app/(app)/[emailAccountId]/assistant/FixWithChat"; import { useChat } from "@/providers/ChatProvider"; -import type { SetInputFunction } from "@/components/assistant-chat/types"; 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 ( >; - // export type DataPart = { type: "append-message"; message: string }; // export const messageMetadataSchema = z.object({ createdAt: z.string() }); diff --git a/apps/web/providers/ChatProvider.tsx b/apps/web/providers/ChatProvider.tsx index 0d85e9835e..86854135dd 100644 --- a/apps/web/providers/ChatProvider.tsx +++ b/apps/web/providers/ChatProvider.tsx @@ -13,10 +13,7 @@ import { import { useSWRConfig } from "swr"; import { toastError } from "@/components/Toast"; import { convertToUIMessages } from "@/components/assistant-chat/helpers"; -import type { - ChatMessage, - SetInputFunction, -} from "@/components/assistant-chat/types"; +import type { ChatMessage } from "@/components/assistant-chat/types"; import { useChatMessages } from "@/hooks/useChatMessages"; import { useAccount } from "@/providers/EmailAccountProvider"; import { EMAIL_ACCOUNT_HEADER } from "@/utils/config"; @@ -27,7 +24,7 @@ type ChatContextType = { chat: Chat; input: string; chatId: string | null; - setInput: SetInputFunction; + setInput: (input: string) => void; setChatId: (chatId: string | null) => void; setNewChat: () => void; handleSubmit: () => void; From 0b4f026bcb3c9c13539ef9d2ce383c689d16c987 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Tue, 5 Aug 2025 02:39:41 +0300 Subject: [PATCH 31/89] move chat to right sidebar --- .../assistant/AIChatButton.tsx | 20 +++ .../assistant/FixWithChat.tsx | 4 + .../[emailAccountId]/automation/page.tsx | 12 +- apps/web/app/(app)/layout.tsx | 6 +- apps/web/components/SideNavWithTopNav.tsx | 12 +- apps/web/components/SidebarRight.tsx | 25 ++++ apps/web/components/assistant-chat/chat.tsx | 94 ++++++-------- .../assistant-chat/examples-dialog.tsx | 3 +- .../assistant-chat/multimodal-input.tsx | 12 +- apps/web/components/ui/sidebar.tsx | 120 ++++++++++++------ 10 files changed, 192 insertions(+), 116 deletions(-) create mode 100644 apps/web/app/(app)/[emailAccountId]/assistant/AIChatButton.tsx create mode 100644 apps/web/components/SidebarRight.tsx 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/FixWithChat.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/FixWithChat.tsx index 39ecc70cf1..623509ff60 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/FixWithChat.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/FixWithChat.tsx @@ -20,6 +20,7 @@ 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, @@ -37,6 +38,8 @@ export function FixWithChat({ // const router = useRouter(); // const [currentTab] = useQueryState("tab"); + const { setOpen } = useSidebar(); + return ( @@ -79,6 +82,7 @@ export function FixWithChat({ } setInput(input); + setOpen(["chat-sidebar"]); // } else { // // redirect to the assistant page // const searchParams = new URLSearchParams(); 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)/layout.tsx b/apps/web/app/(app)/layout.tsx index 4ad8131c2f..ec55ff870c 100644 --- a/apps/web/app/(app)/layout.tsx +++ b/apps/web/app/(app)/layout.tsx @@ -40,7 +40,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("sidebar:state")?.value === "false"; return ( @@ -59,9 +59,9 @@ export default async function AppLayout({ - + {/* - + */} ); diff --git a/apps/web/components/SideNavWithTopNav.tsx b/apps/web/components/SideNavWithTopNav.tsx index 4297ecba76..88e7522091 100644 --- a/apps/web/components/SideNavWithTopNav.tsx +++ b/apps/web/components/SideNavWithTopNav.tsx @@ -7,6 +7,7 @@ import { SidebarTrigger, } from "@/components/ui/sidebar"; import { SideNav } from "@/components/SideNav"; +import { SidebarRight } from "@/components/SidebarRight"; export function SideNavWithTopNav({ children, @@ -16,14 +17,14 @@ export function SideNavWithTopNav({ defaultOpen?: boolean; }) { return ( - - + + - } /> + } + /> {children} - {/* space for Crisp so it doesn't cover content */} - {/*
*/}
+ ); } diff --git a/apps/web/components/SidebarRight.tsx b/apps/web/components/SidebarRight.tsx new file mode 100644 index 0000000000..fe1670e385 --- /dev/null +++ b/apps/web/components/SidebarRight.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { Sidebar, SidebarContent, useSidebar } from "@/components/ui/sidebar"; +import { Chat } from "@/components/assistant-chat/chat"; + +export function SidebarRight({ + ...props +}: React.ComponentProps) { + const { state } = useSidebar(); + + if (!state.includes("chat-sidebar")) return null; + + return ( + + + + + + ); +} diff --git a/apps/web/components/assistant-chat/chat.tsx b/apps/web/components/assistant-chat/chat.tsx index 777b5cc55e..d085388612 100644 --- a/apps/web/components/assistant-chat/chat.tsx +++ b/apps/web/components/assistant-chat/chat.tsx @@ -1,19 +1,9 @@ "use client"; import { useEffect, useState } from "react"; -import { - ArrowLeftToLineIcon, - HistoryIcon, - Loader2, - PlusIcon, -} from "lucide-react"; -import { parseAsString, useQueryState } from "nuqs"; +import { HistoryIcon, Loader2, PlusIcon } from "lucide-react"; import { MultimodalInput } from "@/components/assistant-chat/multimodal-input"; import { Messages } from "./messages"; -import { ResizableHandle } from "@/components/ui/resizable"; -import { ResizablePanelGroup } from "@/components/ui/resizable"; -import { ResizablePanel } from "@/components/ui/resizable"; -import { AssistantTabs } from "@/app/(app)/[emailAccountId]/assistant/AssistantTabs"; import { Button } from "@/components/ui/button"; import { DropdownMenu, @@ -23,10 +13,10 @@ import { } from "@/components/ui/dropdown-menu"; import { useChats } from "@/hooks/useChats"; import { LoadingContent } from "@/components/LoadingContent"; -import { useIsMobile } from "@/hooks/use-mobile"; import { ExamplesDialog } from "@/components/assistant-chat/examples-dialog"; import { Tooltip } from "@/components/Tooltip"; import { type Chat as ChatType, useChat } from "@/providers/ChatProvider"; +import { SidebarTrigger } from "@/components/ui/sidebar"; const MAX_MESSAGES = 20; @@ -45,9 +35,7 @@ const MAX_MESSAGES = 20; // } export function Chat() { - const [tab] = useQueryState("tab", parseAsString); const { chatId, chat, input, setInput, handleSubmit, setNewChat } = useChat(); - const isMobile = useIsMobile(); useEffect(() => { if (!chatId) { @@ -55,7 +43,7 @@ export function Chat() { } }, [chatId, setNewChat]); - const chatPanel = ( + return ( ); - return tab ? ( - - - - - - - {chatPanel} - - - ) : ( - chatPanel - ); + // return tab ? ( + // + // + // + // + // + // + // {chatPanel} + // + // + // ) : ( + // chatPanel + // ); } function ChatUI({ @@ -96,19 +84,19 @@ function ChatUI({ return (
- {messages.length > MAX_MESSAGES ? ( -
- The chat is too long. Please start a new conversation. -
- ) : ( -
- )} - +
+ + {messages.length > MAX_MESSAGES ? ( +
+ The chat is too long. Please start a new conversation. +
+ ) : null} +
- + {/* */}
@@ -153,22 +141,22 @@ function NewChatButton() { ); } -function OpenArtifactButton() { - const [tab, setTab] = useQueryState("tab"); +// function OpenArtifactButton() { +// const [tab, setTab] = useQueryState("tab"); - if (tab) return null; +// if (tab) return null; - const handleOpenArtifact = () => setTab("rules"); +// const handleOpenArtifact = () => setTab("rules"); - return ( - - - - ); -} +// return ( +// +// +// +// ); +// } function ChatHistoryDropdown() { const { setChatId } = useChat(); 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/multimodal-input.tsx b/apps/web/components/assistant-chat/multimodal-input.tsx index 1f5250ead0..eef3e2f5f8 100644 --- a/apps/web/components/assistant-chat/multimodal-input.tsx +++ b/apps/web/components/assistant-chat/multimodal-input.tsx @@ -26,8 +26,8 @@ function PureMultimodalInput({ className, }: { // chatId?: string; - input: UseChatHelpers["input"]; - setInput: UseChatHelpers["setInput"]; + input: string; + setInput: (input: string) => void; status: UseChatHelpers["status"]; stop: () => void; // attachments: Array; @@ -35,7 +35,7 @@ function PureMultimodalInput({ // messages: Array; setMessages: UseChatHelpers["setMessages"]; // append: UseChatHelpers["append"]; - handleSubmit: UseChatHelpers["handleSubmit"]; + handleSubmit: () => void; className?: string; }) { const textareaRef = useRef(null); @@ -72,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; @@ -93,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(); diff --git a/apps/web/components/ui/sidebar.tsx b/apps/web/components/ui/sidebar.tsx index 558e8359d1..29549d1d0e 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,32 @@ 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}`; + document.cookie = `${openState.map((sidebarName) => `${sidebarName}:state=${openState.includes(sidebarName)}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`).join("; ")}`; }, [setOpenProp, open], ); // 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 +123,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 +187,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 +195,7 @@ const Sidebar = React.forwardRef< >( ( { + name, side = "left", variant = "sidebar", collapsible = "offcanvas", @@ -203,7 +224,15 @@ const Sidebar = React.forwardRef< if (isMobile) { return ( - + + setOpenMobile((prev) => + open ? [...prev, name] : prev.filter((n) => n !== name), + ) + } + {...props} + > @@ -257,12 +286,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 +309,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 +324,7 @@ const SidebarTrigger = React.forwardRef< className={cn("h-7 w-7", className)} onClick={(event) => { onClick?.(event); - toggleSidebar(); + toggleSidebar(name ? [name] : []); }} {...props} > @@ -299,8 +337,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 +349,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 +589,7 @@ const SidebarMenuButton = React.forwardRef< HTMLButtonElement, React.ComponentProps<"button"> & { asChild?: boolean; + isCollapsed?: boolean; isActive?: boolean; tooltip?: string | React.ComponentProps; } & VariantProps @@ -561,12 +602,13 @@ const SidebarMenuButton = React.forwardRef< size = "default", tooltip, className, + isCollapsed = false, ...props }, ref, ) => { const Comp = asChild ? Slot : "button"; - const { isMobile, state } = useSidebar(); + const { isMobile } = useSidebar(); const button = (