mirror of
https://github.com/block/goose.git
synced 2026-07-17 12:56:20 +02:00
persist chat drafts across routes
This commit is contained in:
+158
-151
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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('/')}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user