Repository: marimo-team/marimo
Pull request: feat: made ai chat persist when clicking other tabs in side panel
## 📝 Summary

<!--
Provide a concise summary of what this pull request is addressing.

If this PR fixes any issues, list them here by number (e.g., Fixes #123).
-->

Resolves #4157 by loading the last active chat thread when clicking back into ai chat


https://github.com/user-attachments/assets/22a23a11-aa1a-4148-8e13-2491bfa26f1e



## 🔍 Description of Changes

<!--
Detail the specific changes made in this pull request. Explain the problem addressed and how it was resolved. If applicable, provide before and after comparisons, screenshots, or any relevant details to help reviewers understand the changes easily.
-->
- Added ```{ id: activeChatId || undefined }``` to ```useChat({})``` so that if there is an activeChatId available it will load the messages and state
- Fixed race condition in onFinish() where it checks if an active chat id in state exists. During the first AI response it checks this before react state updates causing ```Logger.warn("No active chat");``` to trigger and the first AI response to not get saved in atom.
- Added a scrollContainerRef and scrollToBottom useEffect to ensure that you always see the latest chat message.

_Note: This does not persist between sessions yet. I'll make another PR for that._

## 📋 Checklist

- [x] I have read the [contributor guidelines](https://github.com/marimo-team/marimo/blob/main/CONTRIBUTING.md).
- [x] For large changes, or changes that affect the public API: this change was discussed or approved through an issue, on [Discord](https://marimo.io/discord?ref=pr), or the community [discussions](https://github.com/marimo-team/marimo/discussions) (Please provide a link if applicable).
- [ ] I have added tests for the changes made.
- [x] I have run the code and verified that it works as expected.

## 📜 Reviewers

<!--
Tag potential reviewers from the community or maintainers who might be interested in reviewing this pull request.

Your PR will be reviewed more quickly if you can figure out the right person to tag with @ -->

@mscolnick


Problem statement:
Persist AI Chat when looking at other sidebar items
### Description

Right now:
- I have a chat with the AI window
- I look at the database view or manage packages view
- I come to the AI chat
- the chat history is gone, as well as anything that was drafted in the text box. Need to restore it by using the history feature

### Suggested solution

Afterwards:
- I have a chat with the AI window
- I look at the database view or manage packages view
- I come to the AI chat
- All chat history and drafts are still there

### Alternative

_No response_

### Additional context

_No response_

Hints from issue discussion:
@bjoaquinc What are your thoughts on implementation? Maybe serializing chat to a json file? Do common AI services like OpenAI have a persistent chat feature?
I think we can save it in an atom, and maybe a local storage atom (like `scratchpad.history.ts`). I'm not sure if we want to save it to local storage 🤔, or maybe keep the last N messages.
> I think we can save it in an atom, and maybe a local storage atom (like `scratchpad.history.ts`). I'm not sure if we want to save it to local storage 🤔, or maybe keep the last N messages.

Sounds good! I was actually going to ask if Marimo already had a convention for local data storage to maintain persistence between sessions. I'll check this out. I think local storage is the convention for other locally hosted AI copilots. Cursor saves it in an SQLite db globally (in ~/.cursor) I believe.

Commit message: Moved logger warning to chat-utils if no activeChatId

Patch to review:
diff --git a/frontend/src/components/chat/chat-panel.tsx b/frontend/src/components/chat/chat-panel.tsx
index 16d8592ea6f..eae3c475ae2 100644
--- a/frontend/src/components/chat/chat-panel.tsx
+++ b/frontend/src/components/chat/chat-panel.tsx
@@ -25,6 +25,7 @@ import {
   type SetStateAction,
   type Dispatch,
   memo,
+  useEffect,
 } from "react";
 import { generateUUID } from "@/utils/uuid";
 import { type Message, useChat } from "ai/react";
@@ -247,6 +248,7 @@ const ChatPanelBody = () => {
   const [newThreadInput, setNewThreadInput] = useState("");
   const newThreadInputRef = useRef<ReactCodeMirrorRef>(null);
   const newMessageInputRef = useRef<ReactCodeMirrorRef>(null);
+  const scrollContainerRef = useRef<HTMLDivElement>(null);
   const messagesEndRef = useRef<HTMLDivElement>(null);
   const { theme } = useTheme();
 
@@ -262,6 +264,7 @@ const ChatPanelBody = () => {
     reload,
     stop,
   } = useChat({
+    id: chatState.activeChatId || undefined,
     keepLastMessageOnError: true,
     // Throttle the messages and data updates to 100ms
     experimental_throttle: 100,
@@ -278,10 +281,6 @@ const ChatPanelBody = () => {
     },
     streamProtocol: "text",
     onFinish: (message) => {
-      if (!chatState.activeChatId) {
-        Logger.warn("No active chat");
-        return;
-      }
       setChatState((prev) =>
         addMessageToChat(prev, prev.activeChatId, "assistant", message.content),
       );
@@ -296,6 +295,18 @@ const ChatPanelBody = () => {
 
   const isLoading = status === "submitted" || status === "streaming";
 
+  // Scroll to the latest chat message at the bottom
+  useEffect(() => {
+    const scrollToBottom = () => {
+      if (scrollContainerRef.current) {
+        const container = scrollContainerRef.current;
+        container.scrollTop = container.scrollHeight;
+      }
+    };
+
+    requestAnimationFrame(scrollToBottom);
+  }, [messages, isLoading]);
+
   const createNewThread = (initialMessage: string) => {
     const newChat: Chat = {
       id: generateUUID(),
@@ -376,7 +387,10 @@ const ChatPanelBody = () => {
         />
       </TooltipProvider>
 
-      <div className="flex-1 px-3 bg-[var(--slate-1)] gap-4 py-3 flex flex-col overflow-y-auto">
+      <div
+        className="flex-1 px-3 bg-[var(--slate-1)] gap-4 py-3 flex flex-col overflow-y-auto"
+        ref={scrollContainerRef}
+      >
         {(!messages || messages.length === 0) && (
           <div className="flex rounded-md border px-1 bg-background">
             <PromptInput
diff --git a/frontend/src/core/ai/chat-utils.ts b/frontend/src/core/ai/chat-utils.ts
index f5a0192c5f5..0cfcfc6a6fd 100644
--- a/frontend/src/core/ai/chat-utils.ts
+++ b/frontend/src/core/ai/chat-utils.ts
@@ -1,4 +1,5 @@
 /* Copyright 2024 Marimo. All rights reserved. */
+import { Logger } from "@/utils/Logger";
 import type { ChatState } from "./state";
 
 export const addMessageToChat = (
@@ -8,6 +9,7 @@ export const addMessageToChat = (
   content: string,
 ): ChatState => {
   if (!chatId) {
+    Logger.warn("No active chat");
     return chatState;
   }
   return {


Code review comments:
diff_hunk: @@ -296,6 +295,18 @@ const ChatPanelBody = () => {
 
   const isLoading = status === "submitted" || status === "streaming";
 
+  // Scroll to the latest chat message at the bottom
+  useEffect(() => {
+    const scrollToBottom = () => {
+      if (scrollContainerRef.current) {
+        const container = scrollContainerRef.current;
+        container.scrollTop = container.scrollHeight;
+      }
+    };
+
+    requestAnimationFrame(scrollToBottom);
+  }, [messages, isLoading]);
path: frontend/src/components/chat/chat-panel.tsx
text: @user1:
I used to have this but folks didn't like it so I removed it. I think we can maybe just run it when the activeChatId changes 

@author:
Updated it. Is this what you were thinking?