From c2ceef415117a65df92040ee56d40cacade754c7 Mon Sep 17 00:00:00 2001 From: Zane Staggs Date: Fri, 11 Jul 2025 16:29:58 -0700 Subject: [PATCH] persist chat drafts across routes --- ui/desktop/src/App.tsx | 309 +++++++++--------- ui/desktop/src/components/ChatInput.tsx | 55 +++- .../components/GooseSidebar/AppSidebar.tsx | 5 - ui/desktop/src/contexts/ChatContext.tsx | 41 ++- ui/desktop/src/hooks/useRecipeManager.ts | 16 +- 5 files changed, 257 insertions(+), 169 deletions(-) diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index 60674db9ad..cb0d143576 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -25,6 +25,7 @@ import ProviderSettings from './components/settings/providers/ProviderSettingsPa import { useChat } from './hooks/useChat'; import { AppLayout } from './components/Layout/AppLayout'; import { ChatProvider } from './contexts/ChatContext'; +import { DraftProvider } from './contexts/DraftContext'; import 'react-toastify/dist/ReactToastify.css'; import { useConfig, MalformedConfigError } from './components/ConfigContext'; @@ -1126,162 +1127,168 @@ export default function App() { ); return ( - - - - `relative min-h-16 mb-4 p-2 rounded-lg - flex justify-between overflow-hidden cursor-pointer - text-text-on-accent bg-background-inverse - ` - } - style={{ width: '380px' }} - className="mt-6" - position="top-right" - autoClose={3000} - closeOnClick - pauseOnHover - /> - {modalVisible && ( - + + + + `relative min-h-16 mb-4 p-2 rounded-lg + flex justify-between overflow-hidden cursor-pointer + text-text-on-accent bg-background-inverse + ` + } + style={{ width: '380px' }} + className="mt-6" + position="top-right" + autoClose={3000} + closeOnClick + pauseOnHover /> - )} -
-
- - } /> - } /> - - - - } - > + {modalVisible && ( + + )} +
+
+ + } /> + } /> - - + + + } - /> - - - + + - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - {/**/} - {/* */} - {/* */} - {/* */} - {/* */} - {/* }*/} - {/*/>*/} - - -
- {isGoosehintsModalOpen && ( - - )} - - - + + } + /> + + + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + {/**/} + {/* */} + {/* */} + {/* */} + {/* */} + {/* }*/} + {/*/>*/} + + +
+ {isGoosehintsModalOpen && ( + + )} + + + + ); } diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx index fc63258f2c..7a8ae0c742 100644 --- a/ui/desktop/src/components/ChatInput.tsx +++ b/ui/desktop/src/components/ChatInput.tsx @@ -22,6 +22,7 @@ import { toastError } from '../toasts'; import MentionPopover, { FileItemWithMatch } from './MentionPopover'; import { useDictationSettings } from '../hooks/useDictationSettings'; import { useChatContextManager } from './context_management/ChatContextManager'; +import { useChatContext } from '../contexts/ChatContext'; import { COST_TRACKING_ENABLED } from '../updates'; import { CostTracker } from './bottom_menu/CostTracker'; import { DroppedFile, useFileDrop } from '../hooks/useFileDrop'; @@ -106,6 +107,16 @@ export default function ChatInput({ const { getCurrentModelAndProvider, currentModel, currentProvider } = useModelAndProvider(); const [tokenLimit, setTokenLimit] = useState(TOKEN_LIMIT_DEFAULT); const [isTokenLimitLoaded, setIsTokenLimitLoaded] = useState(false); + + // Draft functionality - get chat context and global draft context + // We need to handle the case where ChatInput is used without ChatProvider (e.g., in Hub) + const chatContext = useChatContext(); // This should always be available now + const draftLoadedRef = useRef(false); + + // Debug logging for draft context + useEffect(() => { + // Debug logging removed - draft functionality is working correctly + }, [chatContext?.contextKey, chatContext?.draft, chatContext]); const [mentionPopover, setMentionPopover] = useState<{ isOpen: boolean; position: { x: number; y: number }; @@ -165,6 +176,9 @@ export default function ChatInput({ setValue(initialValue); setDisplayValue(initialValue); + // Reset draft loaded flag when initialValue changes + draftLoadedRef.current = false; + // Use a functional update to get the current pastedImages // and perform cleanup. This avoids needing pastedImages in the deps. setPastedImages((currentPastedImages) => { @@ -182,6 +196,38 @@ export default function ChatInput({ setHasUserTyped(false); }, [initialValue]); // Keep only initialValue as a dependency + // Draft functionality - load draft if no initial value or recipe + useEffect(() => { + // Reset draft loaded flag when context changes + draftLoadedRef.current = false; + }, [chatContext?.contextKey]); + + useEffect(() => { + // Only load draft once and if conditions are met + if (!initialValue && !recipeConfig && !draftLoadedRef.current && chatContext) { + const draftText = chatContext.draft || ''; + + if (draftText) { + setDisplayValue(draftText); + setValue(draftText); + } + + // Always mark as loaded after checking, regardless of whether we found a draft + draftLoadedRef.current = true; + } + }, [chatContext, initialValue, recipeConfig]); + + // Save draft when user types (debounced) + const debouncedSaveDraft = useMemo( + () => + debounce((value: string) => { + if (chatContext && chatContext.setDraft) { + chatContext.setDraft(value); + } + }, 500), // Save draft after 500ms of no typing + [chatContext] + ); + // State to track if the IME is composing (i.e., in the middle of Japanese IME input) const [isComposing, setIsComposing] = useState(false); const [historyIndex, setHistoryIndex] = useState(-1); @@ -416,6 +462,7 @@ export default function ChatInput({ setDisplayValue(val); // Update display immediately debouncedSetValue(val); // Debounce the actual state update + debouncedSaveDraft(val); // Save draft with debounce // Mark that the user has typed something setHasUserTyped(true); @@ -571,8 +618,9 @@ export default function ChatInput({ return () => { debouncedSetValue.cancel?.(); debouncedAutosize.cancel?.(); + debouncedSaveDraft.cancel?.(); }; - }, [debouncedSetValue, debouncedAutosize]); + }, [debouncedSetValue, debouncedAutosize, debouncedSaveDraft]); // Handlers for composition events, which are crucial for proper IME behavior const handleCompositionStart = () => { @@ -699,6 +747,11 @@ export default function ChatInput({ setIsInGlobalHistory(false); setHasUserTyped(false); + // Clear draft when message is sent + if (chatContext && chatContext.clearDraft) { + chatContext.clearDraft(); + } + // Clear both parent and local dropped files after processing if (onFilesProcessed && droppedFiles.length > 0) { onFilesProcessed(); diff --git a/ui/desktop/src/components/GooseSidebar/AppSidebar.tsx b/ui/desktop/src/components/GooseSidebar/AppSidebar.tsx index 69bf423488..18fc7f3d3c 100644 --- a/ui/desktop/src/components/GooseSidebar/AppSidebar.tsx +++ b/ui/desktop/src/components/GooseSidebar/AppSidebar.tsx @@ -14,7 +14,6 @@ import { } from '../ui/sidebar'; import { ChatSmart, Gear } from '../icons'; import { ViewOptions, View } from '../../App'; -import { useChatContext } from '../../contexts/ChatContext'; interface SidebarProps { onSelectSession: (sessionId: string) => void; @@ -28,7 +27,6 @@ interface SidebarProps { // Main Sidebar Component const AppSidebar: React.FC = ({ currentPath }) => { const navigate = useNavigate(); - const { resetChat } = useChatContext(); useEffect(() => { // Trigger animation after a small delay @@ -60,9 +58,6 @@ const AppSidebar: React.FC = ({ currentPath }) => { { - // Always reset chat and navigate to home when clicking Home button - // This ensures insights are displayed regardless of active session - resetChat(); navigate('/'); }} isActive={isActivePath('/')} diff --git a/ui/desktop/src/contexts/ChatContext.tsx b/ui/desktop/src/contexts/ChatContext.tsx index 7854273235..257aa79da6 100644 --- a/ui/desktop/src/contexts/ChatContext.tsx +++ b/ui/desktop/src/contexts/ChatContext.tsx @@ -2,6 +2,7 @@ import React, { createContext, useContext, ReactNode } from 'react'; import { ChatType } from '../components/BaseChat'; import { generateSessionId } from '../sessions'; import { Recipe } from '../recipe'; +import { useDraftContext } from './DraftContext'; interface ChatContextType { chat: ChatType; @@ -10,6 +11,12 @@ interface ChatContextType { hasActiveSession: boolean; setRecipeConfig: (recipe: Recipe | null) => void; clearRecipeConfig: () => void; + // Draft functionality + draft: string; + setDraft: (draft: string) => void; + clearDraft: () => void; + // Context identification + contextKey: string; // 'hub' or 'pair-{sessionId}' } const ChatContext = createContext(undefined); @@ -18,9 +25,28 @@ interface ChatProviderProps { children: ReactNode; chat: ChatType; setChat: (chat: ChatType) => void; + contextKey?: string; // Optional context key, defaults to 'hub' } -export const ChatProvider: React.FC = ({ children, chat, setChat }) => { +export const ChatProvider: React.FC = ({ + children, + chat, + setChat, + contextKey = 'hub', +}) => { + const draftContext = useDraftContext(); + + // Draft functionality using the app-level DraftContext + const draft = draftContext.getDraft(contextKey); + + const setDraft = (newDraft: string) => { + draftContext.setDraft(contextKey, newDraft); + }; + + const clearDraft = () => { + draftContext.clearDraft(contextKey); + }; + const resetChat = () => { const newSessionId = generateSessionId(); setChat({ @@ -30,6 +56,8 @@ export const ChatProvider: React.FC = ({ children, chat, setC messageHistoryIndex: 0, recipeConfig: null, // Clear recipe when resetting chat }); + // Clear draft when resetting chat + clearDraft(); }; const setRecipeConfig = (recipe: Recipe | null) => { @@ -55,15 +83,16 @@ export const ChatProvider: React.FC = ({ children, chat, setC hasActiveSession, setRecipeConfig, clearRecipeConfig, + draft, + setDraft, + clearDraft, + contextKey, }; return {children}; }; -export const useChatContext = (): ChatContextType => { +export const useChatContext = (): ChatContextType | null => { const context = useContext(ChatContext); - if (context === undefined) { - throw new Error('useChatContext must be used within a ChatProvider'); - } - return context; + return context || null; }; diff --git a/ui/desktop/src/hooks/useRecipeManager.ts b/ui/desktop/src/hooks/useRecipeManager.ts index 39598938c6..62b33f7142 100644 --- a/ui/desktop/src/hooks/useRecipeManager.ts +++ b/ui/desktop/src/hooks/useRecipeManager.ts @@ -17,7 +17,7 @@ export const useRecipeManager = (messages: Message[], locationState?: LocationSt const [readyForAutoUserPrompt, setReadyForAutoUserPrompt] = useState(false); // Get chat context to access persisted recipe - const { chat, setRecipeConfig } = useChatContext(); + const chatContext = useChatContext(); // Get recipeConfig from multiple sources with priority: // 1. Chat context (persisted recipe) @@ -25,14 +25,16 @@ export const useRecipeManager = (messages: Message[], locationState?: LocationSt // 3. App config (from deeplinks) const recipeConfig = useMemo(() => { // First check if we have a persisted recipe in chat context - if (chat.recipeConfig) { - return chat.recipeConfig; + if (chatContext?.chat.recipeConfig) { + return chatContext.chat.recipeConfig; } // Then check if recipe config is passed via navigation state if (locationState?.recipeConfig) { // Persist the recipe to chat context for future use - setRecipeConfig(locationState.recipeConfig); + if (chatContext?.setRecipeConfig) { + chatContext.setRecipeConfig(locationState.recipeConfig); + } return locationState.recipeConfig as Recipe; } @@ -40,12 +42,14 @@ export const useRecipeManager = (messages: Message[], locationState?: LocationSt const appRecipeConfig = window.appConfig.get('recipeConfig') as Recipe | null; if (appRecipeConfig) { // Persist the recipe to chat context for future use - setRecipeConfig(appRecipeConfig); + if (chatContext?.setRecipeConfig) { + chatContext.setRecipeConfig(appRecipeConfig); + } return appRecipeConfig; } return null; - }, [chat.recipeConfig, locationState, setRecipeConfig]); + }, [chatContext, locationState]); // Show parameter modal if recipe has parameters and they haven't been set yet useEffect(() => {