persist chat drafts across routes

This commit is contained in:
Zane Staggs
2025-07-11 16:29:58 -07:00
parent 7419059477
commit c2ceef4151
5 changed files with 257 additions and 169 deletions
+158 -151
View File
@@ -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 (
<ModelAndProviderProvider>
<HashRouter>
<ToastContainer
aria-label="Toast notifications"
toastClassName={() =>
`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 && (
<ConfirmationModal
isOpen={modalVisible}
message={modalMessage}
confirmLabel={extensionConfirmLabel}
title={extensionConfirmTitle}
onConfirm={handleConfirm}
onCancel={handleCancel}
<DraftProvider>
<ModelAndProviderProvider>
<HashRouter>
<ToastContainer
aria-label="Toast notifications"
toastClassName={() =>
`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
/>
)}
<div className="relative w-screen h-screen overflow-hidden bg-background-muted flex flex-col">
<div className="titlebar-drag-region" />
<Routes>
<Route path="welcome" element={<WelcomeRoute />} />
<Route path="configure-providers" element={<ConfigureProvidersRoute />} />
<Route
path="/"
element={
<ChatProvider chat={chat} setChat={setChat}>
<AppLayout setIsGoosehintsModalOpen={setIsGoosehintsModalOpen} />
</ChatProvider>
}
>
{modalVisible && (
<ConfirmationModal
isOpen={modalVisible}
message={modalMessage}
confirmLabel={extensionConfirmLabel}
title={extensionConfirmTitle}
onConfirm={handleConfirm}
onCancel={handleCancel}
/>
)}
<div className="relative w-screen h-screen overflow-hidden bg-background-muted flex flex-col">
<div className="titlebar-drag-region" />
<Routes>
<Route path="welcome" element={<WelcomeRoute />} />
<Route path="configure-providers" element={<ConfigureProvidersRoute />} />
<Route
index
path="/"
element={
<ProviderGuard>
<HubRouteWrapper
chat={chat}
setChat={setChat}
setPairChat={setPairChat}
setIsGoosehintsModalOpen={setIsGoosehintsModalOpen}
/>
</ProviderGuard>
<ChatProvider chat={chat} setChat={setChat} contextKey="hub">
<AppLayout setIsGoosehintsModalOpen={setIsGoosehintsModalOpen} />
</ChatProvider>
}
/>
<Route
path="pair"
element={
<ProviderGuard>
<ChatProvider chat={pairChat} setChat={setPairChat}>
<PairRouteWrapper
chat={pairChat}
setChat={setPairChat}
>
<Route
index
element={
<ProviderGuard>
<HubRouteWrapper
chat={chat}
setChat={setChat}
setPairChat={setPairChat}
setIsGoosehintsModalOpen={setIsGoosehintsModalOpen}
/>
</ChatProvider>
</ProviderGuard>
}
/>
<Route
path="settings"
element={
<ProviderGuard>
<SettingsRoute />
</ProviderGuard>
}
/>
<Route
path="extensions"
element={
<ProviderGuard>
<ExtensionsRoute />
</ProviderGuard>
}
/>
<Route
path="sessions"
element={
<ProviderGuard>
<SessionsRoute />
</ProviderGuard>
}
/>
<Route
path="schedules"
element={
<ProviderGuard>
<SchedulesRoute />
</ProviderGuard>
}
/>
<Route
path="recipes"
element={
<ProviderGuard>
<RecipesRoute />
</ProviderGuard>
}
/>
<Route
path="recipe-editor"
element={
<ProviderGuard>
<RecipeEditorRoute />
</ProviderGuard>
}
/>
<Route
path="shared-session"
element={
<ProviderGuard>
<SharedSessionRouteWrapper
isLoadingSharedSession={isLoadingSharedSession}
setIsLoadingSharedSession={setIsLoadingSharedSession}
sharedSessionError={sharedSessionError}
/>
</ProviderGuard>
}
/>
<Route
path="permission"
element={
<ProviderGuard>
<PermissionRoute />
</ProviderGuard>
}
/>
{/*<Route*/}
{/* path="projects"*/}
{/* element={*/}
{/* <ProviderGuard>*/}
{/* <ChatProvider chat={chat} setChat={setChat}>*/}
{/* <ProjectsRoute />*/}
{/* </ChatProvider>*/}
{/* </ProviderGuard> */}
{/* }*/}
{/*/>*/}
</Route>
</Routes>
</div>
{isGoosehintsModalOpen && (
<GoosehintsModal
directory={window.appConfig.get('GOOSE_WORKING_DIR') as string}
setIsGoosehintsModalOpen={setIsGoosehintsModalOpen}
/>
)}
</HashRouter>
<AnnouncementModal />
</ModelAndProviderProvider>
</ProviderGuard>
}
/>
<Route
path="pair"
element={
<ProviderGuard>
<ChatProvider
chat={pairChat}
setChat={setPairChat}
contextKey={`pair-${pairChat.id}`}
>
<PairRouteWrapper
chat={pairChat}
setChat={setPairChat}
setIsGoosehintsModalOpen={setIsGoosehintsModalOpen}
/>
</ChatProvider>
</ProviderGuard>
}
/>
<Route
path="settings"
element={
<ProviderGuard>
<SettingsRoute />
</ProviderGuard>
}
/>
<Route
path="extensions"
element={
<ProviderGuard>
<ExtensionsRoute />
</ProviderGuard>
}
/>
<Route
path="sessions"
element={
<ProviderGuard>
<SessionsRoute />
</ProviderGuard>
}
/>
<Route
path="schedules"
element={
<ProviderGuard>
<SchedulesRoute />
</ProviderGuard>
}
/>
<Route
path="recipes"
element={
<ProviderGuard>
<RecipesRoute />
</ProviderGuard>
}
/>
<Route
path="recipe-editor"
element={
<ProviderGuard>
<RecipeEditorRoute />
</ProviderGuard>
}
/>
<Route
path="shared-session"
element={
<ProviderGuard>
<SharedSessionRouteWrapper
isLoadingSharedSession={isLoadingSharedSession}
setIsLoadingSharedSession={setIsLoadingSharedSession}
sharedSessionError={sharedSessionError}
/>
</ProviderGuard>
}
/>
<Route
path="permission"
element={
<ProviderGuard>
<PermissionRoute />
</ProviderGuard>
}
/>
{/*<Route*/}
{/* path="projects"*/}
{/* element={*/}
{/* <ProviderGuard>*/}
{/* <ChatProvider chat={chat} setChat={setChat}>*/}
{/* <ProjectsRoute />*/}
{/* </ChatProvider>*/}
{/* </ProviderGuard> */}
{/* }*/}
{/*/>*/}
</Route>
</Routes>
</div>
{isGoosehintsModalOpen && (
<GoosehintsModal
directory={window.appConfig.get('GOOSE_WORKING_DIR') as string}
setIsGoosehintsModalOpen={setIsGoosehintsModalOpen}
/>
)}
</HashRouter>
<AnnouncementModal />
</ModelAndProviderProvider>
</DraftProvider>
);
}
+54 -1
View File
@@ -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<number>(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();
@@ -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<SidebarProps> = ({ currentPath }) => {
const navigate = useNavigate();
const { resetChat } = useChatContext();
useEffect(() => {
// Trigger animation after a small delay
@@ -60,9 +58,6 @@ const AppSidebar: React.FC<SidebarProps> = ({ currentPath }) => {
<SidebarMenuItem>
<SidebarMenuButton
onClick={() => {
// Always reset chat and navigate to home when clicking Home button
// This ensures insights are displayed regardless of active session
resetChat();
navigate('/');
}}
isActive={isActivePath('/')}
+35 -6
View File
@@ -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<ChatContextType | undefined>(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<ChatProviderProps> = ({ children, chat, setChat }) => {
export const ChatProvider: React.FC<ChatProviderProps> = ({
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<ChatProviderProps> = ({ 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<ChatProviderProps> = ({ children, chat, setC
hasActiveSession,
setRecipeConfig,
clearRecipeConfig,
draft,
setDraft,
clearDraft,
contextKey,
};
return <ChatContext.Provider value={value}>{children}</ChatContext.Provider>;
};
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;
};
+10 -6
View File
@@ -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(() => {