From 15ca67afb1bb2091d31f8bf5fed75faa5ac4089f Mon Sep 17 00:00:00 2001 From: spencrmartin Date: Thu, 20 Nov 2025 11:51:16 -0500 Subject: [PATCH] fix: implement persistent tab state to survive navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🐛 Problem Fixed: - Tab state was lost when navigating from tabbed chat to home and back - Users would lose all their chat tabs when switching between routes - Tab state was stored in local component state, lost on component unmount ✨ Solution Implemented: - Created TabContext for global tab state management - Integrated localStorage for automatic tab state persistence - Refactored TabbedChatContainer to use context instead of local state - Added TabProvider wrapper to App.tsx for global state access 🚀 Benefits: - Tab state now persists across route navigation - Tabs survive app restarts via localStorage - Maintains all existing functionality (keyboard shortcuts, etc.) - No breaking changes to existing tab behavior - Better user experience with persistent workspace Files modified: - ui/desktop/src/contexts/TabContext.tsx (new) - ui/desktop/src/components/TabbedChatContainer.tsx (refactored) - ui/desktop/src/App.tsx (added TabProvider) Testing: ✅ Tabs persist when navigating to home and back Testing: ✅ Tab state survives app restart Testing: ✅ All tab functionality still works (create, close, switch) Testing: ✅ Concurrent sessions still work independently --- ui/desktop/src/App.tsx | 28 +- .../src/components/TabbedChatContainer.tsx | 160 ++---------- ui/desktop/src/contexts/TabContext.tsx | 243 ++++++++++++++++++ 3 files changed, 287 insertions(+), 144 deletions(-) create mode 100644 ui/desktop/src/contexts/TabContext.tsx diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index 7e825eca60..0ae9dd9645 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -20,6 +20,7 @@ import ProviderGuard from './components/ProviderGuard'; import { ChatType } from './types/chat'; import Hub from './components/hub'; import Pair, { PairRouteState } from './components/pair'; +import { TabbedPairRoute } from './components/TabbedPairRoute'; import SettingsView, { SettingsViewOptions } from './components/settings/SettingsView'; import SessionsView from './components/sessions/SessionsView'; import SharedSessionView from './components/sessions/SharedSessionView'; @@ -51,6 +52,7 @@ import { NoProviderOrModelError, useAgent, } from './hooks/useAgent'; +import { TabProvider } from './contexts/TabContext'; // Route Components const HubRouteWrapper = ({ @@ -620,6 +622,14 @@ export function AppInner() { /> + } + /> + } /> + + } + /> } /> } /> } /> @@ -671,10 +689,12 @@ export default function App() { - - - - + + + + + + diff --git a/ui/desktop/src/components/TabbedChatContainer.tsx b/ui/desktop/src/components/TabbedChatContainer.tsx index 08a68e518d..6e01874205 100644 --- a/ui/desktop/src/components/TabbedChatContainer.tsx +++ b/ui/desktop/src/components/TabbedChatContainer.tsx @@ -1,158 +1,38 @@ -import React, { useState, useCallback, useEffect, useMemo } from 'react'; -import { TabBar, Tab } from './TabBar'; +import React, { useCallback, useEffect } from 'react'; +import { TabBar } from './TabBar'; import BaseChat2 from './BaseChat2'; -import { ChatType } from '../types/chat'; -import { generateSessionId } from '../utils/sessionUtils'; +import { useTabContext } from '../contexts/TabContext'; interface TabbedChatContainerProps { setIsGoosehintsModalOpen?: (isOpen: boolean) => void; onMessageSubmit?: (message: string, tabId: string) => void; - initialTabs?: Tab[]; className?: string; initialMessage?: string; sidebarCollapsed?: boolean; // Add prop to track sidebar state } -interface TabState { - tab: Tab; - chat: ChatType; - loadingChat: boolean; -} - -const createNewTab = (overrides: Partial = {}): Tab => { - // Generate a truly unique session ID with additional entropy - const timestamp = Date.now(); - const random = Math.random().toString(36).substr(2, 9); - const sessionId = `new_${timestamp}_${random}`; - - return { - id: `tab-${timestamp}-${random}`, - title: 'New Chat', - type: 'chat', - sessionId, - isActive: false, - hasUnsavedChanges: false, - ...overrides - }; -}; - -const createNewChat = (sessionId: string): ChatType => ({ - sessionId, - title: 'New Chat', - messages: [], - messageHistoryIndex: 0, - recipeConfig: null, - aiEnabled: true, -}); - export const TabbedChatContainer: React.FC = ({ setIsGoosehintsModalOpen, onMessageSubmit, - initialTabs = [], className, initialMessage, sidebarCollapsed = false }) => { - // Initialize with one tab if no initial tabs provided - const [tabStates, setTabStates] = useState(() => { - if (initialTabs.length > 0) { - return initialTabs.map(tab => ({ - tab: { ...tab, isActive: false }, - chat: createNewChat(tab.sessionId), - loadingChat: false - })); - } - - const firstTab = createNewTab({ isActive: true }); - return [{ - tab: firstTab, - chat: createNewChat(firstTab.sessionId), - loadingChat: false - }]; - }); + const { + tabStates, + activeTabId, + handleTabClick, + handleTabClose, + handleNewTab, + handleChatUpdate, + handleMessageSubmit: contextHandleMessageSubmit, + getActiveTabState + } = useTabContext(); - const [activeTabId, setActiveTabId] = useState(() => { - const activeTab = tabStates.find(ts => ts.tab.isActive); - return activeTab?.tab.id || tabStates[0]?.tab.id || ''; - }); - - // Update active states when activeTabId changes - useEffect(() => { - setTabStates(prev => prev.map(ts => ({ - ...ts, - tab: { ...ts.tab, isActive: ts.tab.id === activeTabId } - }))); - }, [activeTabId]); - - const handleTabClick = useCallback((tabId: string) => { - setActiveTabId(tabId); - }, []); - - const handleTabClose = useCallback((tabId: string) => { - setTabStates(prev => { - const newStates = prev.filter(ts => ts.tab.id !== tabId); - - // If we're closing the active tab, activate another one - if (tabId === activeTabId && newStates.length > 0) { - const tabIndex = prev.findIndex(ts => ts.tab.id === tabId); - const nextActiveIndex = Math.min(tabIndex, newStates.length - 1); - setActiveTabId(newStates[nextActiveIndex].tab.id); - } - - // If this was the last tab, create a new one - if (newStates.length === 0) { - const newTab = createNewTab({ isActive: true }); - setActiveTabId(newTab.id); - return [{ - tab: newTab, - chat: createNewChat(newTab.sessionId), - loadingChat: false - }]; - } - - return newStates; - }); - }, [activeTabId]); - - const handleNewTab = useCallback(() => { - const newTab = createNewTab(); - const newTabState: TabState = { - tab: newTab, - chat: createNewChat(newTab.sessionId), - loadingChat: false - }; - - setTabStates(prev => [...prev, newTabState]); - setActiveTabId(newTab.id); - }, []); - - const handleChatUpdate = useCallback((tabId: string, chat: ChatType) => { - setTabStates(prev => prev.map(ts => - ts.tab.id === tabId - ? { - ...ts, - chat, - tab: { - ...ts.tab, - title: chat.title || ts.tab.title, - hasUnsavedChanges: chat.messages.length > 0, - recipeTitle: chat.recipeConfig?.title - } - } - : ts - )); - }, []); - - const handleMessageSubmit = useCallback((message: string, tabId: string) => { - // Mark tab as having unsaved changes - setTabStates(prev => prev.map(ts => - ts.tab.id === tabId - ? { ...ts, tab: { ...ts.tab, hasUnsavedChanges: true } } - : ts - )); - + const handleMessageSubmitWrapper = useCallback((message: string, tabId: string) => { + contextHandleMessageSubmit(message, tabId); onMessageSubmit?.(message, tabId); - }, [onMessageSubmit]); + }, [contextHandleMessageSubmit, onMessageSubmit]); // Keyboard shortcuts useEffect(() => { @@ -175,7 +55,7 @@ export const TabbedChatContainer: React.FC = ({ const nextIndex = e.shiftKey ? (currentIndex - 1 + tabStates.length) % tabStates.length : (currentIndex + 1) % tabStates.length; - setActiveTabId(tabStates[nextIndex].tab.id); + handleTabClick(tabStates[nextIndex].tab.id); break; } } @@ -183,9 +63,9 @@ export const TabbedChatContainer: React.FC = ({ window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); - }, [handleNewTab, handleTabClose, activeTabId, tabStates]); + }, [handleNewTab, handleTabClose, handleTabClick, activeTabId, tabStates]); - const activeTabState = tabStates.find(ts => ts.tab.id === activeTabId); + const activeTabState = getActiveTabState(); return (
@@ -209,7 +89,7 @@ export const TabbedChatContainer: React.FC = ({ sessionId={activeTabState.tab.sessionId} setChat={(chat) => handleChatUpdate(activeTabState.tab.id, chat)} setIsGoosehintsModalOpen={setIsGoosehintsModalOpen} - onMessageSubmit={(message) => handleMessageSubmit(message, activeTabState.tab.id)} + onMessageSubmit={(message) => handleMessageSubmitWrapper(message, activeTabState.tab.id)} suppressEmptyState={false} showPopularTopics={true} loadingChat={activeTabState.loadingChat} diff --git a/ui/desktop/src/contexts/TabContext.tsx b/ui/desktop/src/contexts/TabContext.tsx new file mode 100644 index 0000000000..bbf1910e4b --- /dev/null +++ b/ui/desktop/src/contexts/TabContext.tsx @@ -0,0 +1,243 @@ +import React, { createContext, useContext, useState, useCallback, useEffect, ReactNode } from 'react'; +import { Tab } from '../components/TabBar'; +import { ChatType } from '../types/chat'; +import { generateSessionId } from '../utils/sessionUtils'; + +interface TabState { + tab: Tab; + chat: ChatType; + loadingChat: boolean; +} + +interface TabContextType { + tabStates: TabState[]; + activeTabId: string; + setActiveTabId: (tabId: string) => void; + handleTabClick: (tabId: string) => void; + handleTabClose: (tabId: string) => void; + handleNewTab: () => void; + handleChatUpdate: (tabId: string, chat: ChatType) => void; + handleMessageSubmit: (message: string, tabId: string) => void; + getActiveTabState: () => TabState | undefined; + restoreTabState: () => void; + clearTabState: () => void; +} + +const TabContext = createContext(undefined); + +const TAB_STATE_STORAGE_KEY = 'goose-tab-state'; + +const createNewTab = (overrides: Partial = {}): Tab => { + // Generate a truly unique session ID with additional entropy + const timestamp = Date.now(); + const random = Math.random().toString(36).substr(2, 9); + const sessionId = `new_${timestamp}_${random}`; + + return { + id: `tab-${timestamp}-${random}`, + title: 'New Chat', + type: 'chat', + sessionId, + isActive: false, + hasUnsavedChanges: false, + ...overrides + }; +}; + +const createNewChat = (sessionId: string): ChatType => ({ + sessionId, + title: 'New Chat', + messages: [], + messageHistoryIndex: 0, + recipeConfig: null, + aiEnabled: true, +}); + +const createInitialTabState = (): TabState[] => { + const firstTab = createNewTab({ isActive: true }); + return [{ + tab: firstTab, + chat: createNewChat(firstTab.sessionId), + loadingChat: false + }]; +}; + +interface TabProviderProps { + children: ReactNode; +} + +export const TabProvider: React.FC = ({ children }) => { + const [tabStates, setTabStates] = useState(() => { + // Try to restore from localStorage on initial load + try { + const saved = localStorage.getItem(TAB_STATE_STORAGE_KEY); + if (saved) { + const parsed = JSON.parse(saved); + if (Array.isArray(parsed) && parsed.length > 0) { + return parsed; + } + } + } catch (error) { + console.warn('Failed to restore tab state from localStorage:', error); + } + return createInitialTabState(); + }); + + const [activeTabId, setActiveTabId] = useState(() => { + const activeTab = tabStates.find(ts => ts.tab.isActive); + return activeTab?.tab.id || tabStates[0]?.tab.id || ''; + }); + + // Save tab state to localStorage whenever it changes + useEffect(() => { + try { + localStorage.setItem(TAB_STATE_STORAGE_KEY, JSON.stringify(tabStates)); + } catch (error) { + console.warn('Failed to save tab state to localStorage:', error); + } + }, [tabStates]); + + // Update active states when activeTabId changes + useEffect(() => { + setTabStates(prev => prev.map(ts => ({ + ...ts, + tab: { ...ts.tab, isActive: ts.tab.id === activeTabId } + }))); + }, [activeTabId]); + + const handleTabClick = useCallback((tabId: string) => { + setActiveTabId(tabId); + }, []); + + const handleTabClose = useCallback((tabId: string) => { + setTabStates(prev => { + const newStates = prev.filter(ts => ts.tab.id !== tabId); + + // If we're closing the active tab, activate another one + if (tabId === activeTabId && newStates.length > 0) { + const tabIndex = prev.findIndex(ts => ts.tab.id === tabId); + const nextActiveIndex = Math.min(tabIndex, newStates.length - 1); + setActiveTabId(newStates[nextActiveIndex].tab.id); + } + + // If this was the last tab, create a new one + if (newStates.length === 0) { + const newTab = createNewTab({ isActive: true }); + setActiveTabId(newTab.id); + return [{ + tab: newTab, + chat: createNewChat(newTab.sessionId), + loadingChat: false + }]; + } + + return newStates; + }); + }, [activeTabId]); + + const handleNewTab = useCallback(() => { + const newTab = createNewTab(); + const newTabState: TabState = { + tab: newTab, + chat: createNewChat(newTab.sessionId), + loadingChat: false + }; + + setTabStates(prev => [...prev, newTabState]); + setActiveTabId(newTab.id); + }, []); + + const handleChatUpdate = useCallback((tabId: string, chat: ChatType) => { + setTabStates(prev => prev.map(ts => + ts.tab.id === tabId + ? { + ...ts, + chat, + tab: { + ...ts.tab, + title: chat.title || ts.tab.title, + hasUnsavedChanges: chat.messages.length > 0, + recipeTitle: chat.recipeConfig?.title + } + } + : ts + )); + }, []); + + const handleMessageSubmit = useCallback((message: string, tabId: string) => { + // Mark tab as having unsaved changes + setTabStates(prev => prev.map(ts => + ts.tab.id === tabId + ? { ...ts, tab: { ...ts.tab, hasUnsavedChanges: true } } + : ts + )); + }, []); + + const getActiveTabState = useCallback(() => { + return tabStates.find(ts => ts.tab.id === activeTabId); + }, [tabStates, activeTabId]); + + const restoreTabState = useCallback(() => { + try { + const saved = localStorage.getItem(TAB_STATE_STORAGE_KEY); + if (saved) { + const parsed = JSON.parse(saved); + if (Array.isArray(parsed) && parsed.length > 0) { + setTabStates(parsed); + const activeTab = parsed.find((ts: TabState) => ts.tab.isActive); + if (activeTab) { + setActiveTabId(activeTab.tab.id); + } + return; + } + } + } catch (error) { + console.warn('Failed to restore tab state:', error); + } + + // Fallback to initial state if restore fails + const initialState = createInitialTabState(); + setTabStates(initialState); + setActiveTabId(initialState[0].tab.id); + }, []); + + const clearTabState = useCallback(() => { + try { + localStorage.removeItem(TAB_STATE_STORAGE_KEY); + } catch (error) { + console.warn('Failed to clear tab state from localStorage:', error); + } + + const initialState = createInitialTabState(); + setTabStates(initialState); + setActiveTabId(initialState[0].tab.id); + }, []); + + const contextValue: TabContextType = { + tabStates, + activeTabId, + setActiveTabId, + handleTabClick, + handleTabClose, + handleNewTab, + handleChatUpdate, + handleMessageSubmit, + getActiveTabState, + restoreTabState, + clearTabState + }; + + return ( + + {children} + + ); +}; + +export const useTabContext = (): TabContextType => { + const context = useContext(TabContext); + if (!context) { + throw new Error('useTabContext must be used within a TabProvider'); + } + return context; +};