diff --git a/apps/client/src/widgets/react/hooks.tsx b/apps/client/src/widgets/react/hooks.tsx index 46d83a5614..6bae391f56 100644 --- a/apps/client/src/widgets/react/hooks.tsx +++ b/apps/client/src/widgets/react/hooks.tsx @@ -104,7 +104,7 @@ export interface SavedData { export function useEditorSpacedUpdate({ note, noteType, noteContext, getData, onContentChange, dataSaved, updateInterval }: { noteType: NoteType; - note: FNote, + note: FNote | null | undefined, noteContext: NoteContext | null | undefined, getData: () => Promise | SavedData | undefined, onContentChange: (newContent: string) => void, @@ -118,8 +118,8 @@ export function useEditorSpacedUpdate({ note, noteType, noteContext, getData, on return async () => { const data = await getData(); - // for read only notes - if (data === undefined || note.type !== noteType) return; + // for read only notes, or if note is not yet available (e.g. lazy creation) + if (data === undefined || !note || note.type !== noteType) return; protected_session_holder.touchProtectedSessionIfNecessary(note); @@ -138,7 +138,7 @@ export function useEditorSpacedUpdate({ note, noteType, noteContext, getData, on // React to note/blob changes. useEffect(() => { - if (!blob) return; + if (!blob || !note) return; noteSavedDataStore.set(note.noteId, blob.content); spacedUpdate.allowUpdateWithoutChange(() => onContentChange(blob.content)); }, [ blob ]); diff --git a/apps/client/src/widgets/sidebar/SidebarChat.tsx b/apps/client/src/widgets/sidebar/SidebarChat.tsx index 096da4e624..1a5a9c80cc 100644 --- a/apps/client/src/widgets/sidebar/SidebarChat.tsx +++ b/apps/client/src/widgets/sidebar/SidebarChat.tsx @@ -10,7 +10,7 @@ import { formatDateTime } from "../../utils/formatters"; import ActionButton from "../react/ActionButton.js"; import Dropdown from "../react/Dropdown.js"; import { FormListItem } from "../react/FormList.js"; -import { useActiveNoteContext, useNote, useNoteProperty } from "../react/hooks.js"; +import { useActiveNoteContext, useEditorSpacedUpdate, useNote, useNoteProperty } from "../react/hooks.js"; import NoItems from "../react/NoItems.js"; import ChatInputBar from "../type_widgets/llm_chat/ChatInputBar.js"; import ChatMessage from "../type_widgets/llm_chat/ChatMessage.js"; @@ -25,9 +25,8 @@ import RightPanelWidget from "./RightPanelWidget.js"; */ export default function SidebarChat() { const [chatNoteId, setChatNoteId] = useState(null); - const [shouldSave, setShouldSave] = useState(false); const [recentChats, setRecentChats] = useState([]); - const saveTimeoutRef = useRef>(); + const spacedUpdateRef = useRef<{ scheduleUpdate: () => void }>(null); const historyDropdownRef = useRef(null); // Get the current active note context @@ -40,10 +39,40 @@ export default function SidebarChat() { // Use shared chat hook with sidebar-specific options const chat = useLlmChat( // onMessagesChange - trigger save - () => setShouldSave(true), + () => spacedUpdateRef.current?.scheduleUpdate(), { defaultEnableNoteTools: true, supportsExtendedThinking: true } ); + // Ref to access chat methods in callbacks without triggering re-runs + const chatRef = useRef(chat); + chatRef.current = chat; + + // Persistence via useEditorSpacedUpdate (same mechanism as the LlmChat type widget). + // When chatNote is null (before lazy creation), saves are no-ops. + const spacedUpdate = useEditorSpacedUpdate({ + note: chatNote, + noteType: "llmChat", + noteContext: null, + getData: () => { + const content = chatRef.current.getContent(); + return { content: JSON.stringify(content) }; + }, + onContentChange: (content) => { + if (!content) { + chatRef.current.clearMessages(); + return; + } + try { + const parsed: LlmChatContent = JSON.parse(content); + chatRef.current.loadFromContent(parsed); + } catch (e) { + console.error("Failed to parse LLM chat content:", e); + chatRef.current.clearMessages(); + } + } + }); + spacedUpdateRef.current = spacedUpdate; + // Update chat context when active note changes useEffect(() => { chat.setContextNoteId(activeNoteId ?? undefined); @@ -54,42 +83,6 @@ export default function SidebarChat() { chat.setChatNoteId(chatNoteId ?? undefined); }, [chatNoteId, chat.setChatNoteId]); - // Ref to access chat methods in effects without triggering re-runs - const chatRef = useRef(chat); - chatRef.current = chat; - - // Handle debounced save when shouldSave is triggered - useEffect(() => { - if (!shouldSave || !chatNoteId) { - setShouldSave(false); - return; - } - - setShouldSave(false); - - if (saveTimeoutRef.current) { - clearTimeout(saveTimeoutRef.current); - } - - saveTimeoutRef.current = setTimeout(async () => { - const content = chat.getContent(); - try { - await server.put(`notes/${chatNoteId}/data`, { - content: JSON.stringify(content) - }); - } catch (err) { - console.error("Failed to save chat:", err); - } - }, 500); - - return () => { - if (saveTimeoutRef.current) { - clearTimeout(saveTimeoutRef.current); - saveTimeoutRef.current = undefined; - } - }; - }, [shouldSave, chatNoteId, chat]); - // Load the most recent chat on mount (runs once) useEffect(() => { let cancelled = false; @@ -102,16 +95,6 @@ export default function SidebarChat() { if (existingChat) { setChatNoteId(existingChat.noteId); - // Load content inline to avoid dependency issues - try { - const blob = await server.get<{ content: string }>(`notes/${existingChat.noteId}/blob`); - if (!cancelled && blob?.content) { - const parsed: LlmChatContent = JSON.parse(blob.content); - chatRef.current.loadFromContent(parsed); - } - } catch (err) { - console.error("Failed to load chat content:", err); - } } else { // No existing chat - will create on first message setChatNoteId(null); @@ -170,31 +153,38 @@ export default function SidebarChat() { }, [handleSubmit]); const handleNewChat = useCallback(async () => { + // Save any pending changes before switching + await spacedUpdate.updateNowIfNecessary(); + try { const note = await dateNoteService.createLlmChat(); if (note) { setChatNoteId(note.noteId); - chat.clearMessages(); + chatRef.current.clearMessages(); } } catch (err) { console.error("Failed to create new chat:", err); } - }, [chat]); + }, [spacedUpdate]); const handleSaveChat = useCallback(async () => { if (!chatNoteId) return; + + // Save any pending changes before moving the chat + await spacedUpdate.updateNowIfNecessary(); + try { await server.post("special-notes/save-llm-chat", { llmChatNoteId: chatNoteId }); // Create a new empty chat after saving const note = await dateNoteService.createLlmChat(); if (note) { setChatNoteId(note.noteId); - chat.clearMessages(); + chatRef.current.clearMessages(); } } catch (err) { console.error("Failed to save chat to permanent location:", err); } - }, [chatNoteId, chat]); + }, [chatNoteId, spacedUpdate]); const loadRecentChats = useCallback(async () => { try { @@ -210,17 +200,11 @@ export default function SidebarChat() { if (noteId === chatNoteId) return; - try { - const blob = await server.get<{ content: string }>(`notes/${noteId}/blob`); - if (blob?.content) { - const parsed: LlmChatContent = JSON.parse(blob.content); - setChatNoteId(noteId); - chat.loadFromContent(parsed); - } - } catch (err) { - console.error("Failed to load selected chat:", err); - } - }, [chatNoteId, chat]); + // Save any pending changes before switching + await spacedUpdate.updateNowIfNecessary(); + + setChatNoteId(noteId); + }, [chatNoteId, spacedUpdate]); return (