From 493aeeee4e29cd788584c7b9456845fccd7be05e Mon Sep 17 00:00:00 2001 From: Spence Date: Wed, 3 Dec 2025 14:08:18 -0500 Subject: [PATCH 01/10] Implement Matrix channel creation functionality (#5962) --- ui/desktop/src/App.tsx | 1517 +++++++++++++---- ui/desktop/src/components/BaseChat.tsx | 8 + ui/desktop/src/components/BaseChat2.tsx | 30 +- ui/desktop/src/components/ChatInput.tsx | 7 + ui/desktop/src/components/SpaceBreadcrumb.tsx | 107 ++ .../src/components/TabbedChatContainer.tsx | 8 + .../src/components/channels/ChannelsView.tsx | 54 +- .../components/channels/SpaceRoomsView.tsx | 533 ++++++ .../collaborative/CollaborativeSession.tsx | 7 +- ui/desktop/src/components/pair.tsx | 3 + ui/desktop/src/contexts/MatrixContext.tsx | 11 + ui/desktop/src/hooks/useChatEngine.ts | 9 +- ui/desktop/src/hooks/useChatStream.ts | 37 +- ui/desktop/src/hooks/useMessageStream.ts | 343 ++-- ui/desktop/src/matrixRoomInterceptor.ts | 125 ++ ui/desktop/src/services/MatrixHistorySync.ts | 414 +++++ ui/desktop/src/services/MatrixService.ts | 373 +++- ui/desktop/src/sessions.ts | 196 ++- ui/desktop/src/types/chat.ts | 4 + ui/desktop/src/utils/matrixRoomHelper.ts | 122 ++ 20 files changed, 3306 insertions(+), 602 deletions(-) create mode 100644 ui/desktop/src/components/SpaceBreadcrumb.tsx create mode 100644 ui/desktop/src/components/channels/SpaceRoomsView.tsx create mode 100644 ui/desktop/src/matrixRoomInterceptor.ts create mode 100644 ui/desktop/src/services/MatrixHistorySync.ts create mode 100644 ui/desktop/src/utils/matrixRoomHelper.ts diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index a7b92d4124c1..042dfaa48b6a 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -1,79 +1,172 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { IpcRendererEvent } from 'electron'; -import { - HashRouter, - Routes, - Route, - useNavigate, - useLocation, - useSearchParams, -} from 'react-router-dom'; -import { openSharedSessionFromDeepLink } from './sessionLinks'; +import { HashRouter, Routes, Route, useNavigate, useLocation } from 'react-router-dom'; +import { openSharedSessionFromDeepLink, type SessionLinksViewOptions } from './sessionLinks'; import { type SharedSessionDetails } from './sharedSessions'; +import { initializeSystem } from './utils/providerUtils'; +import { initializeCostDatabase } from './utils/costDatabase'; import { ErrorUI } from './components/ErrorBoundary'; -import { ExtensionInstallModal } from './components/ExtensionInstallModal'; +import { ConfirmationModal } from './components/ui/ConfirmationModal'; import { ToastContainer } from 'react-toastify'; +import { extractExtensionName } from './components/settings/extensions/utils'; import { GoosehintsModal } from './components/GoosehintsModal'; +import { type ExtensionConfig } from './extensions'; import AnnouncementModal from './components/AnnouncementModal'; +import { generateSessionId } from './sessions'; import ProviderGuard from './components/ProviderGuard'; +import { initializeMatrixInterceptor } from './matrixRoomInterceptor'; import { ChatType } from './types/chat'; import Hub from './components/hub'; -import Pair, { PairRouteState } from './components/pair'; -import { TabbedPairRoute } from './components/TabbedPairRoute'; +import Pair from './components/pair'; import SettingsView, { SettingsViewOptions } from './components/settings/SettingsView'; import SessionsView from './components/sessions/SessionsView'; import SharedSessionView from './components/sessions/SharedSessionView'; -import MarketplaceView from './components/marketplace/MarketplaceView'; +import SchedulesView from './components/schedule/SchedulesView'; import ProviderSettings from './components/settings/providers/ProviderSettingsPage'; +import { useChat } from './hooks/useChat'; import { AppLayout } from './components/Layout/AppLayout'; import { ChatProvider } from './contexts/ChatContext'; import { DraftProvider } from './contexts/DraftContext'; -import { WebViewerProvider } from './contexts/WebViewerContext'; -import { UnifiedSidecarProvider } from './contexts/UnifiedSidecarContext'; import 'react-toastify/dist/ReactToastify.css'; -import { useConfig, ConfigProvider } from './components/ConfigContext'; +import { useConfig, MalformedConfigError } from './components/ConfigContext'; import { ModelAndProviderProvider } from './components/ModelAndProviderContext'; +import { addExtensionFromDeepLink as addExtensionFromDeepLinkV2 } from './components/settings/extensions'; +import { + backupConfig, + initConfig, + readAllConfig, + recoverConfig, + validateConfig, +} from './api/sdk.gen'; import PermissionSettingsView from './components/settings/permission/PermissionSetting'; -import { MatrixProvider } from './contexts/MatrixContext'; -import { matrixService } from './services/MatrixService'; -import CollaborationInviteNotification from './components/CollaborationInviteNotification'; -import MessageNotification from './components/MessageNotification'; +import { COST_TRACKING_ENABLED } from './updates'; +import { type SessionDetails } from './sessions'; import ExtensionsView, { ExtensionsViewOptions } from './components/extensions/ExtensionsView'; -import RecipesView from './components/recipes/RecipesView'; -import RecipeEditor from './components/recipes/RecipeEditor'; -import PeersView from './components/peers/PeersView'; -import ChannelsView from './components/channels/ChannelsView'; -import { createNavigationHandler, View, ViewOptions } from './utils/navigationUtils'; -import { - AgentState, - InitializationContext, - NoProviderOrModelError, - useAgent, -} from './hooks/useAgent'; -import { TabProvider, useTabContext } from './contexts/TabContext'; +// import ProjectsContainer from './components/projects/ProjectsContainer'; +import { Recipe } from './recipe'; +import RecipesView from './components/RecipesView'; +import RecipeEditor from './components/RecipeEditor'; +import BuildView from './components/build/BuildView'; +import { CleanFeedView } from './components/feed/CleanFeedView'; +import { TestFeed } from './components/feed/TestFeed'; + +export type View = + | 'welcome' + | 'chat' + | 'pair' + | 'settings' + | 'extensions' + | 'moreModels' + | 'configureProviders' + | 'configPage' + | 'ConfigureProviders' + | 'settingsV2' + | 'sessions' + | 'schedules' + | 'sharedSession' + | 'loading' + | 'recipeEditor' + | 'recipes' + | 'permission' + | 'build' + | 'feed'; +// | 'projects'; + +export type ViewOptions = { + // Settings view options + extensionId?: string; + showEnvVars?: boolean; + deepLinkConfig?: ExtensionConfig; + + // Session view options + resumedSession?: SessionDetails; + sessionDetails?: SessionDetails; + error?: string; + shareToken?: string; + baseUrl?: string; + + // Recipe editor options + config?: unknown; + + // Permission view options + parentView?: View; + + // Generic options + [key: string]: unknown; +}; + +export type ViewConfig = { + view: View; + viewOptions?: ViewOptions; +}; // Route Components const HubRouteWrapper = ({ + chat, + setChat, + setPairChat, setIsGoosehintsModalOpen, - isExtensionsLoading, - resetChat, }: { + chat: ChatType; + setChat: (chat: ChatType) => void; + setPairChat: (chat: ChatType) => void; setIsGoosehintsModalOpen: (isOpen: boolean) => void; - isExtensionsLoading: boolean; - resetChat: () => void; }) => { const navigate = useNavigate(); - const setView = useMemo(() => createNavigationHandler(navigate), [navigate]); return ( { + // Convert view to route navigation + switch (view) { + case 'chat': + navigate('/'); + break; + case 'pair': + navigate('/pair', { state: options }); + break; + case 'settings': + navigate('/settings', { state: options }); + break; + case 'sessions': + navigate('/sessions'); + break; + case 'schedules': + navigate('/schedules'); + break; + case 'recipes': + navigate('/recipes'); + break; + case 'build': + navigate('/build'); + break; + case 'permission': + navigate('/permission', { state: options }); + break; + case 'ConfigureProviders': + navigate('/configure-providers'); + break; + case 'sharedSession': + navigate('/shared-session', { state: options }); + break; + case 'recipeEditor': + navigate('/recipe-editor', { state: options }); + break; + case 'welcome': + navigate('/welcome'); + break; + default: + navigate('/'); + } + }} setIsGoosehintsModalOpen={setIsGoosehintsModalOpen} - isExtensionsLoading={isExtensionsLoading} - resetChat={resetChat} /> ); }; @@ -81,42 +174,163 @@ const HubRouteWrapper = ({ const PairRouteWrapper = ({ chat, setChat, + setPairChat, setIsGoosehintsModalOpen, - setAgentWaitingMessage, - setFatalError, - agentState, - loadCurrentChat, }: { chat: ChatType; setChat: (chat: ChatType) => void; + setPairChat: (chat: ChatType) => void; setIsGoosehintsModalOpen: (isOpen: boolean) => void; - setAgentWaitingMessage: (msg: string | null) => void; - setFatalError: (value: ((prevState: string | null) => string | null) | string | null) => void; - agentState: AgentState; - loadCurrentChat: (context: InitializationContext) => Promise; }) => { - const location = useLocation(); const navigate = useNavigate(); - const setView = useMemo(() => createNavigationHandler(navigate), [navigate]); - const routeState = - (location.state as PairRouteState) || (window.history.state as PairRouteState) || {}; - const [searchParams] = useSearchParams(); - const [initialMessage] = useState(routeState.initialMessage); + const location = useLocation(); + const chatRef = useRef(chat); + + // Keep the ref updated with the current chat state + useEffect(() => { + chatRef.current = chat; + }, [chat]); + + // Check if we have a resumed session, recipe config, or Matrix room chat from navigation state + useEffect(() => { + // Only process if we actually have navigation state + if (!location.state) { + console.log('No navigation state, preserving existing chat state'); + return; + } + + const resumedSession = location.state?.resumedSession as SessionDetails | undefined; + const recipeConfig = location.state?.recipeConfig as Recipe | undefined; + const resetChat = location.state?.resetChat as boolean | undefined; + const matrixChat = location.state?.chat as ChatType | undefined; + + // Handle Matrix room chat (highest priority for new Matrix rooms) + if (matrixChat && matrixChat.isMatrixTab && matrixChat.matrixRoomId) { + console.log('[Matrix Room Open] Loading Matrix room chat in pair view:', { + roomId: matrixChat.matrixRoomId, + sessionId: matrixChat.id, + title: matrixChat.title + }); + + // Update both the local chat state and the app-level pairChat state + setChat(matrixChat); + setPairChat(matrixChat); + + // Clear the navigation state to prevent reloading on navigation + window.history.replaceState({}, document.title); + return; + } + + if (resumedSession) { + console.log('Loading resumed session in pair view:', resumedSession.session_id); + console.log('Current chat before resume:', chatRef.current); + + // Convert session to chat format - this clears any existing recipe config + const sessionChat: ChatType = { + id: resumedSession.session_id, + title: resumedSession.metadata?.description || `ID: ${resumedSession.session_id}`, + messages: resumedSession.messages, + messageHistoryIndex: resumedSession.messages.length, + recipeConfig: null, // Clear recipe config when resuming a session + }; + + // Update both the local chat state and the app-level pairChat state + setChat(sessionChat); + setPairChat(sessionChat); + + // Clear the navigation state to prevent reloading on navigation + window.history.replaceState({}, document.title); + } else if (recipeConfig && resetChat) { + console.log('Loading new recipe config in pair view:', recipeConfig.title); + + const updatedChat: ChatType = { + id: chatRef.current.id, // Keep the same ID + title: recipeConfig.title || 'Recipe Chat', + messages: [], // Clear messages to start fresh + messageHistoryIndex: 0, + recipeConfig: recipeConfig, + recipeParameters: null, // Clear parameters for new recipe + }; + + // Update both the local chat state and the app-level pairChat state + setChat(updatedChat); + setPairChat(updatedChat); - const resumeSessionId = searchParams.get('resumeSessionId') ?? undefined; + // Clear the navigation state to prevent reloading on navigation + window.history.replaceState({}, document.title); + } else if (recipeConfig && !chatRef.current.recipeConfig) { + // Only set recipe config if we don't already have one (e.g., from deeplinks) + + const updatedChat: ChatType = { + ...chatRef.current, + recipeConfig: recipeConfig, + title: recipeConfig.title || chatRef.current.title, + }; + + // Update both the local chat state and the app-level pairChat state + setChat(updatedChat); + setPairChat(updatedChat); + + // Clear the navigation state to prevent reloading on navigation + window.history.replaceState({}, document.title); + } else if (location.state) { + // We have navigation state but it doesn't match our conditions + // Clear it to prevent future processing, but don't modify chat state + console.log('Clearing unprocessed navigation state'); + window.history.replaceState({}, document.title); + } + // If we have a recipe config but resetChat is false and we already have a recipe, + // do nothing - just continue with the existing chat state + }, [location.state, setChat, setPairChat]); return ( { + // Convert view to route navigation + switch (view) { + case 'chat': + navigate('/'); + break; + case 'pair': + navigate('/pair', { state: options }); + break; + case 'settings': + navigate('/settings', { state: options }); + break; + case 'sessions': + navigate('/sessions'); + break; + case 'schedules': + navigate('/schedules'); + break; + case 'recipes': + navigate('/recipes'); + break; + case 'build': + navigate('/build'); + break; + case 'permission': + navigate('/permission', { state: options }); + break; + case 'ConfigureProviders': + navigate('/configure-providers'); + break; + case 'sharedSession': + navigate('/shared-session', { state: options }); + break; + case 'recipeEditor': + navigate('/recipe-editor', { state: options }); + break; + case 'welcome': + navigate('/welcome'); + break; + default: + navigate('/'); + } + }} setIsGoosehintsModalOpen={setIsGoosehintsModalOpen} - resumeSessionId={resumeSessionId} - initialMessage={initialMessage} /> ); }; @@ -124,42 +338,150 @@ const PairRouteWrapper = ({ const SettingsRoute = () => { const location = useLocation(); const navigate = useNavigate(); - const setView = useMemo(() => createNavigationHandler(navigate), [navigate]); // Get viewOptions from location.state or history.state const viewOptions = (location.state as SettingsViewOptions) || (window.history.state as SettingsViewOptions) || {}; - return navigate('/')} setView={setView} viewOptions={viewOptions} />; + return ( + navigate('/')} + setView={(view: View, options?: ViewOptions) => { + // Convert view to route navigation + switch (view) { + case 'chat': + navigate('/'); + break; + case 'pair': + navigate('/pair'); + break; + case 'settings': + navigate('/settings', { state: options }); + break; + case 'sessions': + navigate('/sessions'); + break; + case 'schedules': + navigate('/schedules'); + break; + case 'recipes': + navigate('/recipes'); + break; + case 'permission': + navigate('/permission', { state: options }); + break; + case 'ConfigureProviders': + navigate('/configure-providers'); + break; + case 'sharedSession': + navigate('/shared-session', { state: options }); + break; + case 'recipeEditor': + navigate('/recipe-editor', { state: options }); + break; + case 'welcome': + navigate('/welcome'); + break; + default: + navigate('/'); + } + }} + viewOptions={viewOptions} + /> + ); }; const SessionsRoute = () => { const navigate = useNavigate(); - const setView = useMemo(() => createNavigationHandler(navigate), [navigate]); - return ; + return ( + { + // Convert view to route navigation + switch (view) { + case 'chat': + navigate('/', { state: options }); + break; + case 'pair': + navigate('/pair', { state: options }); + break; + case 'settings': + navigate('/settings', { state: options }); + break; + case 'sessions': + navigate('/sessions'); + break; + case 'schedules': + navigate('/schedules'); + break; + case 'recipes': + navigate('/recipes'); + break; + case 'permission': + navigate('/permission', { state: options }); + break; + case 'ConfigureProviders': + navigate('/configure-providers'); + break; + case 'sharedSession': + navigate('/shared-session', { state: options }); + break; + case 'recipeEditor': + navigate('/recipe-editor', { state: options }); + break; + case 'welcome': + navigate('/welcome'); + break; + default: + navigate('/'); + } + }} + /> + ); }; const SchedulesRoute = () => { - return ; + const navigate = useNavigate(); + return navigate('/')} />; }; const RecipesRoute = () => { - return ; + const navigate = useNavigate(); + + return ( + { + // Navigate to pair view with the recipe configuration in state + navigate('/pair', { + state: { + recipeConfig: recipe, + // Reset the pair chat to start fresh with the recipe + resetChat: true, + }, + }); + }} + /> + ); }; const RecipeEditorRoute = () => { + const location = useLocation(); + // Check for config from multiple sources: - // 1. localStorage (from "View Recipe" button) - // 2. Window electron config (from deeplinks) - let config; - const storedConfig = localStorage.getItem('viewRecipeConfig'); - if (storedConfig) { - try { - config = JSON.parse(storedConfig); - // Clear the stored config after using it - localStorage.removeItem('viewRecipeConfig'); - } catch (error) { - console.error('Failed to parse stored recipe config:', error); + // 1. Location state (from navigation) + // 2. localStorage (from "View Recipe" button) + // 3. Window electron config (from deeplinks) + let config = location.state?.config; + + if (!config) { + const storedConfig = localStorage.getItem('viewRecipeConfig'); + if (storedConfig) { + try { + config = JSON.parse(storedConfig); + // Clear the stored config after using it + localStorage.removeItem('viewRecipeConfig'); + } catch (error) { + console.error('Failed to parse stored recipe config:', error); + } } } @@ -221,20 +543,12 @@ const ConfigureProvidersRoute = () => { ); }; -interface WelcomeRouteProps { - onSelectProvider: () => void; -} - -const WelcomeRoute = ({ onSelectProvider }: WelcomeRouteProps) => { +const WelcomeRoute = () => { const navigate = useNavigate(); - const onClose = useCallback(() => { - onSelectProvider(); - navigate('/'); - }, [navigate, onSelectProvider]); return (
- + navigate('/')} isOnboarding={true} />
); }; @@ -251,7 +565,6 @@ const SharedSessionRouteWrapper = ({ }) => { const location = useLocation(); const navigate = useNavigate(); - const setView = createNavigationHandler(navigate); const historyState = window.history.state; const sessionDetails = (location.state?.sessionDetails || @@ -269,7 +582,47 @@ const SharedSessionRouteWrapper = ({ if (shareToken && baseUrl) { setIsLoadingSharedSession(true); try { - await openSharedSessionFromDeepLink(`goose://sessions/${shareToken}`, setView, baseUrl); + await openSharedSessionFromDeepLink( + `goose://sessions/${shareToken}`, + (view: View, _options?: SessionLinksViewOptions) => { + // Convert view to route navigation + switch (view) { + case 'chat': + navigate('/', { state: _options }); + break; + case 'pair': + navigate('/pair', { state: _options }); + break; + case 'settings': + navigate('/settings', { state: _options }); + break; + case 'sessions': + navigate('/sessions'); + break; + case 'schedules': + navigate('/schedules'); + break; + case 'recipes': + navigate('/recipes'); + break; + case 'permission': + navigate('/permission', { state: _options }); + break; + case 'ConfigureProviders': + navigate('/configure-providers'); + break; + case 'sharedSession': + navigate('/shared-session', { state: _options }); + break; + case 'recipeEditor': + navigate('/recipe-editor', { state: _options }); + break; + default: + navigate('/'); + } + }, + baseUrl + ); } catch (error) { console.error('Failed to retry loading shared session:', error); } finally { @@ -314,93 +667,378 @@ const ExtensionsRoute = () => { ); }; -const PeersRoute = () => { - const navigate = useNavigate(); - return navigate('/')} />; -}; - -const ChannelsRoute = () => { - const navigate = useNavigate(); - return navigate('/')} />; -}; +// const ProjectsRoute = () => { +// const navigate = useNavigate(); +// +// const setView = (view: View, viewOptions?: ViewOptions) => { +// // Convert view to route navigation +// switch (view) { +// case 'chat': +// navigate('/'); +// break; +// case 'pair': +// navigate('/pair', { state: viewOptions }); +// break; +// case 'settings': +// navigate('/settings', { state: viewOptions }); +// break; +// case 'sessions': +// navigate('/sessions'); +// break; +// case 'schedules': +// navigate('/schedules'); +// break; +// case 'recipes': +// navigate('/recipes'); +// break; +// case 'permission': +// navigate('/permission', { state: viewOptions }); +// break; +// case 'ConfigureProviders': +// navigate('/configure-providers'); +// break; +// case 'sharedSession': +// navigate('/shared-session', { state: viewOptions }); +// break; +// case 'recipeEditor': +// navigate('/recipe-editor', { state: viewOptions }); +// break; +// case 'welcome': +// navigate('/welcome'); +// break; +// default: +// navigate('/'); +// } +// }; +// +// return ( +// Loading projects...}> +// +// +// ); +// }; -export function AppInner() { +export default function App() { const [fatalError, setFatalError] = useState(null); + const [modalVisible, setModalVisible] = useState(false); + const [pendingLink, setPendingLink] = useState(null); + const [modalMessage, setModalMessage] = useState(''); + const [extensionConfirmLabel, setExtensionConfirmLabel] = useState(''); + const [extensionConfirmTitle, setExtensionConfirmTitle] = useState(''); + const [isLoadingSession, setIsLoadingSession] = useState(false); const [isGoosehintsModalOpen, setIsGoosehintsModalOpen] = useState(false); - const [agentWaitingMessage, setAgentWaitingMessage] = useState(null); const [isLoadingSharedSession, setIsLoadingSharedSession] = useState(false); const [sharedSessionError, setSharedSessionError] = useState(null); - const [isExtensionsLoading, setIsExtensionsLoading] = useState(false); - const [didSelectProvider, setDidSelectProvider] = useState(false); - - // Debug component lifecycle - useEffect(() => { - console.log('šŸ” AppInner: Component MOUNTED'); - return () => { - console.log('šŸ” AppInner: Component UNMOUNTING'); - console.trace('AppInner unmount stack trace'); - }; - }, []); - - const navigate = useNavigate(); - - const location = useLocation(); - const [_searchParams, setSearchParams] = useSearchParams(); - const [chat, setChat] = useState({ - sessionId: '', + // Add separate state for pair chat to maintain its own conversation + const [pairChat, setPairChat] = useState({ + id: generateSessionId(), title: 'Pair Chat', messages: [], messageHistoryIndex: 0, - recipeConfig: null, - aiEnabled: true, // AI is enabled by default for regular chats + recipeConfig: null, // Initialize with no recipe }); - const { addExtension } = useConfig(); - const { agentState, loadCurrentChat, resetChat } = useAgent(); - const resetChatIfNecessary = useCallback(() => { - if (chat.messages.length > 0) { - setSearchParams((prev) => { - prev.delete('resumeSessionId'); - return prev; - }); - resetChat(); + const { getExtensions, addExtension, read } = useConfig(); + const initAttemptedRef = useRef(false); + + // Create a setView function for useChat hook - we'll use window.history instead of navigate + const setView = (view: View, viewOptions: ViewOptions = {}) => { + console.log(`Setting view to: ${view}`, viewOptions); + console.trace('setView called from:'); // This will show the call stack + // Convert view to route navigation using hash routing + switch (view) { + case 'chat': + window.location.hash = '#/'; + break; + case 'pair': + window.location.hash = '#/pair'; + break; + case 'settings': + window.location.hash = '#/settings'; + break; + case 'extensions': + window.location.hash = '#/extensions'; + break; + case 'sessions': + window.location.hash = '#/sessions'; + break; + case 'schedules': + window.location.hash = '#/schedules'; + break; + case 'recipes': + window.location.hash = '#/recipes'; + break; + case 'permission': + window.location.hash = '#/permission'; + break; + case 'ConfigureProviders': + window.location.hash = '#/configure-providers'; + break; + case 'sharedSession': + window.location.hash = '#/shared-session'; + break; + case 'recipeEditor': + window.location.hash = '#/recipe-editor'; + break; + case 'welcome': + window.location.hash = '#/welcome'; + break; + case 'feed': + window.location.hash = '#/feed'; + break; + default: + console.error(`Unknown view: ${view}, not navigating anywhere. This is likely a bug.`); + console.trace('Invalid setView call stack:'); + // Don't navigate anywhere for unknown views to avoid unexpected redirects + break; + } + }; + + const { chat, setChat } = useChat({ setIsLoadingSession, setView, setPairChat }); + + function extractCommand(link: string): string { + const url = new URL(link); + const cmd = url.searchParams.get('cmd') || 'Unknown Command'; + const args = url.searchParams.getAll('arg').map(decodeURIComponent); + return `${cmd} ${args.join(' ')}`.trim(); + } + + function extractRemoteUrl(link: string): string | null { + const url = new URL(link); + return url.searchParams.get('url'); + } + + useEffect(() => { + if (initAttemptedRef.current) { + console.log('Initialization already attempted, skipping...'); + return; + } + initAttemptedRef.current = true; + + // Initialize Matrix room interceptor early + initializeMatrixInterceptor(); + + console.log(`Initializing app`); + + const urlParams = new URLSearchParams(window.location.search); + const viewType = urlParams.get('view'); + const resumeSessionId = urlParams.get('resumeSessionId'); + const recipeConfig = window.appConfig.get('recipe'); + + // Check for session resume first - this takes priority over other navigation + if (resumeSessionId) { + console.log('Session resume detected, letting useChat hook handle navigation'); + + // Even when resuming a session, we need to initialize the system + const initializeForSessionResume = async () => { + try { + await initConfig(); + await readAllConfig({ throwOnError: true }); + + const config = window.electron.getConfig(); + const provider = (await read('GOOSE_PROVIDER', false)) ?? config.GOOSE_DEFAULT_PROVIDER; + const model = (await read('GOOSE_MODEL', false)) ?? config.GOOSE_DEFAULT_MODEL; + + if (provider && model) { + await initializeSystem(provider as string, model as string, { + getExtensions, + addExtension, + }); + } else { + throw new Error('No provider/model configured for session resume'); + } + } catch (error) { + console.error('Failed to initialize system for session resume:', error); + setFatalError( + `Failed to initialize system for session resume: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } + }; + + initializeForSessionResume(); + return; } - }, [chat.messages.length, setSearchParams, resetChat]); - - const { openMatrixChat } = useTabContext(); - - // Handle opening chat from message notifications - const handleOpenChat = useCallback((roomId: string, senderId: string) => { - console.log('šŸ“± App: Opening chat for room:', roomId, 'sender:', senderId); - - // For Matrix rooms (starting with !), use TabContext to open the chat - if (roomId.startsWith('!')) { - console.log('šŸ“± App: Opening Matrix chat for room:', roomId); - - // Check if we're already on the /pair route - const isOnPairRoute = location.pathname === '/pair' || location.pathname === '/tabs'; - - if (!isOnPairRoute) { - // Navigate to pair view first - console.log('šŸ“± App: Navigating to /pair first'); - navigate('/pair'); + + if (viewType) { + if (viewType === 'recipeEditor' && recipeConfig) { + // Handle recipe editor deep link - use hash routing + window.location.hash = '#/recipe-editor'; + window.history.replaceState({ config: recipeConfig }, '', '#/recipe-editor'); + } else { + // Handle other deep links by redirecting to appropriate route + const routeMap: Record = { + chat: '#/', + pair: '#/pair', + settings: '#/settings', + sessions: '#/sessions', + schedules: '#/schedules', + recipes: '#/recipes', + permission: '#/permission', + ConfigureProviders: '#/configure-providers', + sharedSession: '#/shared-session', + recipeEditor: '#/recipe-editor', + welcome: '#/welcome', + feed: '#/feed', + }; + + const route = routeMap[viewType]; + if (route) { + window.location.hash = route; + window.history.replaceState({}, '', route); + } } - - // Open the Matrix chat using TabContext (works whether we're already on /pair or not) - console.log('šŸ“± App: Opening Matrix chat via TabContext'); - openMatrixChat(roomId, senderId); - } else { - // For non-Matrix rooms, navigate to peers view - navigate('/peers', { - state: { - openChat: true, - roomId, - senderId - } - }); + return; } - }, [navigate, location.pathname, openMatrixChat]); + + const initializeApp = async () => { + try { + // Start cost database initialization early (non-blocking) - only if cost tracking is enabled + const costDbPromise = COST_TRACKING_ENABLED + ? initializeCostDatabase().catch((error) => { + console.error('Failed to initialize cost database:', error); + }) + : (() => { + console.log('Cost tracking disabled, skipping cost database initialization'); + return Promise.resolve(); + })(); + + await initConfig(); + + try { + await readAllConfig({ throwOnError: true }); + } catch (error) { + console.warn('Initial config read failed, attempting recovery:', error); + + const configVersion = localStorage.getItem('configVersion'); + const shouldMigrateExtensions = !configVersion || parseInt(configVersion, 10) < 3; + + if (shouldMigrateExtensions) { + console.log('Performing extension migration...'); + try { + await backupConfig({ throwOnError: true }); + await initConfig(); + } catch (migrationError) { + console.error('Migration failed:', migrationError); + // Continue with recovery attempts + } + } + + // Try recovery if migration didn't work or wasn't needed + console.log('Attempting config recovery...'); + try { + // Try to validate first (faster than recovery) + await validateConfig({ throwOnError: true }); + // If validation passes, try reading again + await readAllConfig({ throwOnError: true }); + } catch (validateError) { + console.log('Config validation failed, attempting recovery...'); + try { + await recoverConfig({ throwOnError: true }); + await readAllConfig({ throwOnError: true }); + } catch (recoverError) { + console.warn('Config recovery failed, reinitializing...'); + await initConfig(); + } + } + } + + const config = window.electron.getConfig(); + const provider = (await read('GOOSE_PROVIDER', false)) ?? config.GOOSE_DEFAULT_PROVIDER; + const model = (await read('GOOSE_MODEL', false)) ?? config.GOOSE_DEFAULT_MODEL; + + if (provider && model) { + try { + // Initialize system in parallel with cost database (if enabled) + const initPromises = [ + initializeSystem(provider as string, model as string, { + getExtensions, + addExtension, + }), + ]; + + if (COST_TRACKING_ENABLED) { + initPromises.push(costDbPromise); + } + + await Promise.all(initPromises); + + const recipeConfig = window.appConfig.get('recipe'); + if ( + recipeConfig && + typeof recipeConfig === 'object' && + !window.sessionStorage.getItem('ignoreRecipeConfigChanges') + ) { + console.log( + 'Recipe deeplink detected, navigating to pair view with config:', + recipeConfig + ); + // Set the recipe config in the pair chat state + setPairChat((prevChat) => ({ + ...prevChat, + recipeConfig: recipeConfig as Recipe, + title: (recipeConfig as Recipe).title || 'Recipe Chat', + messages: [], // Start fresh for recipe + messageHistoryIndex: 0, + })); + // Navigate to pair view with recipe config using hash routing + window.location.hash = '#/pair'; + window.history.replaceState( + { + recipeConfig: recipeConfig, + resetChat: true, + }, + '', + '#/pair' + ); + } else if (window.sessionStorage.getItem('ignoreRecipeConfigChanges')) { + console.log( + 'Ignoring recipe config changes to prevent navigation conflicts with new window creation' + ); + } else { + // Only navigate to chat route if we're not already on a valid route + const currentHash = window.location.hash; + const validRoutes = [ + '#/', + '#/pair', + '#/settings', + '#/sessions', + '#/schedules', + '#/recipes', + '#/permission', + '#/configure-providers', + '#/shared-session', + '#/recipe-editor', + '#/extensions', + '#/feed', + ]; + + if (!validRoutes.includes(currentHash)) { + console.log('No valid route detected, navigating to chat route (hub)'); + window.location.hash = '#/'; + window.history.replaceState({}, '', '#/'); + } + } + } catch (error) { + console.error('Error in system initialization:', error); + if (error instanceof MalformedConfigError) { + throw error; + } + window.location.hash = '#/welcome'; + window.history.replaceState({}, '', '#/welcome'); + } + } else { + window.location.hash = '#/welcome'; + window.history.replaceState({}, '', '#/welcome'); + } + } catch (error) { + console.error('Fatal error during initialization:', error); + setFatalError(error instanceof Error ? error.message : 'Unknown error occurred'); + } + }; + + initializeApp(); + }, [getExtensions, addExtension, read, setPairChat]); useEffect(() => { console.log('Sending reactReady signal to Electron'); @@ -414,26 +1052,24 @@ export function AppInner() { } }, []); - // Handle URL parameters and deeplinks on app startup - const loadingHub = location.pathname === '/'; + // Handle navigation to pair view for recipe deeplinks after router is ready useEffect(() => { - if (loadingHub) { - (async () => { - try { - await loadCurrentChat({ - setAgentWaitingMessage, - setIsExtensionsLoading, - }); - } catch (e) { - if (e instanceof NoProviderOrModelError) { - // the onboarding flow will trigger - } else { - throw e; - } - } - })(); + const recipeConfig = window.appConfig.get('recipe'); + if ( + recipeConfig && + typeof recipeConfig === 'object' && + window.location.hash === '#/' && + !window.sessionStorage.getItem('ignoreRecipeConfigChanges') + ) { + console.log('Router ready - navigating to pair view for recipe deeplink:', recipeConfig); + // Small delay to ensure router is fully initialized + setTimeout(() => { + window.location.hash = '#/pair'; + }, 100); + } else if (window.sessionStorage.getItem('ignoreRecipeConfigChanges')) { + console.log('Router ready - ignoring recipe config navigation due to new window creation'); } - }, [resetChat, loadCurrentChat, setAgentWaitingMessage, navigate, loadingHub]); + }, []); useEffect(() => { const handleOpenSharedSession = async (_event: IpcRendererEvent, ...args: unknown[]) => { @@ -442,19 +1078,27 @@ export function AppInner() { setIsLoadingSharedSession(true); setSharedSessionError(null); try { - await openSharedSessionFromDeepLink(link, (_view: View, options?: ViewOptions) => { - navigate('/shared-session', { state: options }); - }); + await openSharedSessionFromDeepLink( + link, + (_view: View, _options?: SessionLinksViewOptions) => { + // Navigate to shared session view with the session data + window.location.hash = '#/shared-session'; + if (_options) { + window.history.replaceState(_options, '', '#/shared-session'); + } + } + ); } catch (error) { console.error('Unexpected error opening shared session:', error); // Navigate to shared session view with error + window.location.hash = '#/shared-session'; const shareToken = link.replace('goose://sessions/', ''); const options = { sessionDetails: null, error: error instanceof Error ? error.message : 'Unknown error', shareToken, }; - navigate('/shared-session', { state: options }); + window.history.replaceState(options, '', '#/shared-session'); } finally { setIsLoadingSharedSession(false); } @@ -463,7 +1107,46 @@ export function AppInner() { return () => { window.electron.off('open-shared-session', handleOpenSharedSession); }; - }, [navigate]); + }, [setSharedSessionError]); + + // Handle recipe decode events from main process + useEffect(() => { + const handleRecipeDecoded = (_event: IpcRendererEvent, ...args: unknown[]) => { + const decodedRecipe = args[0] as Recipe; + console.log('[App] Recipe decoded successfully:', decodedRecipe); + + // Update the pair chat with the decoded recipe + setPairChat((prevChat) => ({ + ...prevChat, + recipeConfig: decodedRecipe, + title: decodedRecipe.title || 'Recipe Chat', + messages: [], // Start fresh for recipe + messageHistoryIndex: 0, + })); + + // Navigate to pair view if not already there + if (window.location.hash !== '#/pair') { + window.location.hash = '#/pair'; + } + }; + + const handleRecipeDecodeError = (_event: IpcRendererEvent, ...args: unknown[]) => { + const errorMessage = args[0] as string; + console.error('[App] Recipe decode error:', errorMessage); + + // Show error to user - you could add a toast notification here + // For now, just log the error and navigate to recipes page + window.location.hash = '#/recipes'; + }; + + window.electron.on('recipe-decoded', handleRecipeDecoded); + window.electron.on('recipe-decode-error', handleRecipeDecodeError); + + return () => { + window.electron.off('recipe-decoded', handleRecipeDecoded); + window.electron.off('recipe-decode-error', handleRecipeDecodeError); + }; + }, [setPairChat]); useEffect(() => { console.log('Setting up keyboard shortcuts'); @@ -472,7 +1155,7 @@ export function AppInner() { if ((isMac ? event.metaKey : event.ctrlKey) && event.key === 'n') { event.preventDefault(); try { - const workingDir = window.appConfig?.get('GOOSE_WORKING_DIR'); + const workingDir = window.appConfig.get('GOOSE_WORKING_DIR'); console.log(`Creating new chat window with working dir: ${workingDir}`); window.electron.createChatWindow(undefined, workingDir as string); } catch (error) { @@ -489,7 +1172,8 @@ export function AppInner() { // Prevent default drag and drop behavior globally to avoid opening files in new windows // but allow our React components to handle drops in designated areas useEffect(() => { - const preventDefaults = (e: globalThis.DragEvent) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const preventDefaults = (e: any) => { // Only prevent default if we're not over a designated drop zone const target = e.target as HTMLElement; const isOverDropZone = target.closest('[data-drop-zone="true"]') !== null; @@ -500,13 +1184,15 @@ export function AppInner() { } }; - const handleDragOver = (e: globalThis.DragEvent) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const handleDragOver = (e: any) => { // Always prevent default for dragover to allow dropping e.preventDefault(); e.stopPropagation(); }; - const handleDrop = (e: globalThis.DragEvent) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const handleDrop = (e: any) => { // Only prevent default if we're not over a designated drop zone const target = e.target as HTMLElement; const isOverDropZone = target.closest('[data-drop-zone="true"]') !== null; @@ -532,18 +1218,21 @@ export function AppInner() { }, []); useEffect(() => { + console.log('Setting up fatal error handler'); const handleFatalError = (_event: IpcRendererEvent, ...args: unknown[]) => { const errorMessage = args[0] as string; - console.error('Encountered a fatal error:', errorMessage); + console.error('Encountered a fatal error: ', errorMessage); + console.error('Is loading session:', isLoadingSession); setFatalError(errorMessage); }; window.electron.on('fatal-error', handleFatalError); return () => { window.electron.off('fatal-error', handleFatalError); }; - }, []); + }, [isLoadingSession]); useEffect(() => { + console.log('Setting up view change handler'); const handleSetView = (_event: IpcRendererEvent, ...args: unknown[]) => { const newView = args[0] as View; const section = args[1] as string | undefined; @@ -552,15 +1241,110 @@ export function AppInner() { ); if (section && newView === 'settings') { - navigate(`/settings?section=${section}`); + window.location.hash = `#/settings?section=${section}`; } else { - navigate(`/${newView}`); + window.location.hash = `#/${newView}`; } }; - + const urlParams = new URLSearchParams(window.location.search); + const viewFromUrl = urlParams.get('view'); + if (viewFromUrl) { + const windowConfig = window.electron.getConfig(); + if (viewFromUrl === 'recipeEditor') { + const initialViewOptions = { + recipeConfig: JSON.stringify(windowConfig?.recipeConfig), + view: viewFromUrl, + }; + window.history.replaceState( + {}, + '', + `/recipe-editor?${new URLSearchParams(initialViewOptions).toString()}` + ); + } else { + window.history.replaceState({}, '', `/${viewFromUrl}`); + } + } window.electron.on('set-view', handleSetView); return () => window.electron.off('set-view', handleSetView); - }, [navigate]); + }, []); + + const config = window.electron.getConfig(); + const STRICT_ALLOWLIST = config.GOOSE_ALLOWLIST_WARNING !== true; + + useEffect(() => { + console.log('Setting up extension handler'); + const handleAddExtension = async (_event: IpcRendererEvent, ...args: unknown[]) => { + const link = args[0] as string; + try { + console.log(`Received add-extension event with link: ${link}`); + const command = extractCommand(link); + const remoteUrl = extractRemoteUrl(link); + const extName = extractExtensionName(link); + window.electron.logInfo(`Adding extension from deep link ${link}`); + setPendingLink(link); + let warningMessage = ''; + let label = 'OK'; + let title = 'Confirm Extension Installation'; + let isBlocked = false; + let useDetailedMessage = false; + if (remoteUrl) { + useDetailedMessage = true; + } else { + try { + const allowedCommands = await window.electron.getAllowedExtensions(); + if (allowedCommands && allowedCommands.length > 0) { + const isCommandAllowed = allowedCommands.some((allowedCmd) => + command.startsWith(allowedCmd) + ); + if (!isCommandAllowed) { + useDetailedMessage = true; + title = 'ā›”ļø Untrusted Extension ā›”ļø'; + if (STRICT_ALLOWLIST) { + isBlocked = true; + label = 'Extension Blocked'; + warningMessage = + '\n\nā›”ļø BLOCKED: This extension command is not in the allowed list. ' + + 'Installation is blocked by your administrator. ' + + 'Please contact your administrator if you need this extension.'; + } else { + label = 'Override and install'; + warningMessage = + '\n\nāš ļø WARNING: This extension command is not in the allowed list. ' + + 'Installing extensions from untrusted sources may pose security risks. ' + + 'Please contact an admin if you are unsure or want to allow this extension.'; + } + } + } + } catch (error) { + console.error('Error checking allowlist:', error); + } + } + if (useDetailedMessage) { + const detailedMessage = remoteUrl + ? `You are about to install the ${extName} extension which connects to:\n\n${remoteUrl}\n\nThis extension will be able to access your conversations and provide additional functionality.` + : `You are about to install the ${extName} extension which runs the command:\n\n${command}\n\nThis extension will be able to access your conversations and provide additional functionality.`; + setModalMessage(`${detailedMessage}${warningMessage}`); + } else { + const messageDetails = `Command: ${command}`; + setModalMessage( + `Are you sure you want to install the ${extName} extension?\n\n${messageDetails}` + ); + } + setExtensionConfirmLabel(label); + setExtensionConfirmTitle(title); + if (isBlocked) { + setPendingLink(null); + } + setModalVisible(true); + } catch (error) { + console.error('Error handling add-extension event:', error); + } + }; + window.electron.on('add-extension', handleAddExtension); + return () => { + window.electron.off('add-extension', handleAddExtension); + }; + }, [STRICT_ALLOWLIST]); useEffect(() => { const handleFocusInput = (_event: IpcRendererEvent, ..._args: unknown[]) => { @@ -575,144 +1359,233 @@ export function AppInner() { }; }, []); + const handleConfirm = async () => { + if (pendingLink) { + console.log(`Confirming installation of extension from: ${pendingLink}`); + setModalVisible(false); + try { + await addExtensionFromDeepLinkV2(pendingLink, addExtension, (view: string, options) => { + console.log('Extension deep link handler called with view:', view, 'options:', options); + switch (view) { + case 'settings': + window.location.hash = '#/extensions'; + // Store the config for the extensions route + window.history.replaceState(options, '', '#/extensions'); + break; + default: + window.location.hash = `#/${view}`; + } + }); + console.log('Extension installation successful'); + } catch (error) { + console.error('Failed to add extension:', error); + } finally { + setPendingLink(null); + } + } else { + console.log('Extension installation blocked by allowlist restrictions'); + setModalVisible(false); + } + }; + + const handleCancel = () => { + console.log('Cancelled extension installation.'); + setModalVisible(false); + setPendingLink(null); + }; + if (fatalError) { return ; } + if (isLoadingSession) + return ( +
+
+
+ ); + return ( - <> - - `relative min-h-16 mb-4 p-2 rounded-lg + + + + + `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 - /> - -
-
- - setDidSelectProvider(true)} />} - /> - } /> - - - - - - - - - } - > - - } + style={{ width: '380px' }} + className="mt-6" + position="top-right" + autoClose={3000} + closeOnClick + pauseOnHover + /> + {modalVisible && ( + - +
+ + } /> + } /> + + + + } + > + + + + } /> - } - /> - + + + + + } /> - } - /> - + + + } /> - } - /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - + + + } /> - } + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + {/**/} + {/* */} + {/* */} + {/* */} + {/* */} + {/* }*/} + {/*/>*/} + + +
+ {isGoosehintsModalOpen && ( + - } /> - -
-
- {isGoosehintsModalOpen && ( - - )} - - - - ); -} - -export default function App() { - return ( - - - - - - - - - - - - - - + )} + + + + ); } diff --git a/ui/desktop/src/components/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx index c225ab03c1d8..10b9fa393e75 100644 --- a/ui/desktop/src/components/BaseChat.tsx +++ b/ui/desktop/src/components/BaseChat.tsx @@ -387,6 +387,14 @@ function BaseChatContent({ const customEvent = e as unknown as CustomEvent; const combinedTextFromInput = customEvent.detail?.value || ''; + // DIAGNOSTIC: Log chat info when submitting message + console.log('[Matrix Message Send - BaseChat] handleSubmit called:', { + chatId: chat.id, + chatTitle: chat.title, + messagePreview: combinedTextFromInput.substring(0, 50) + '...', + timestamp: new Date().toISOString(), + }); + // Mark that user has started using the recipe when they submit a message if (recipeConfig && combinedTextFromInput.trim()) { setHasStartedUsingRecipe(true); diff --git a/ui/desktop/src/components/BaseChat2.tsx b/ui/desktop/src/components/BaseChat2.tsx index 831cbe763961..8d5aaf33d0d4 100644 --- a/ui/desktop/src/components/BaseChat2.tsx +++ b/ui/desktop/src/components/BaseChat2.tsx @@ -114,6 +114,7 @@ function BaseChatContent({ initialMessage, onSessionIdChange, isMatrixTab: !!matrixRoomId, // Pass Matrix tab flag based on whether we have a matrixRoomId + matrixRoomId, // Pass Matrix room ID for message routing tabId, // Pass tabId for sidecar filtering }); @@ -144,20 +145,26 @@ function BaseChatContent({ sender: message.sender?.displayName || message.sender?.userId || 'unknown' }); - // FIXED: Make Matrix message events SESSION-SPECIFIC to prevent cross-tab contamination - // Include sessionId in the event detail so only the correct useChatStream instance processes it + // FIXED: For Matrix tabs, use matrixRoomId as the routing key instead of sessionId + // This ensures messages are routed correctly even if backend session ID changes + const routingKey = matrixRoomId || sessionId; + const messageEvent = new CustomEvent('matrix-message-received', { detail: { message, - targetSessionId: sessionId, // CRITICAL: Only this session should process this message + targetSessionId: sessionId, // Backend session ID (for logging) + targetRoomId: matrixRoomId, // Matrix room ID (for routing) + routingKey: routingKey, // The actual key to match on timestamp: new Date().toISOString() } }); window.dispatchEvent(messageEvent); - console.log('šŸ“„ BaseChat2 dispatched SESSION-SPECIFIC matrix-message-received event:', { + console.log('šŸ“„ BaseChat2 dispatched matrix-message-received event:', { messageId: message.id, - targetSessionId: sessionId.substring(0, 8), + targetSessionId: sessionId?.substring(0, 8), + targetRoomId: matrixRoomId?.substring(0, 20), + routingKey: routingKey?.substring(0, 20), sender: message.sender?.displayName || message.sender?.userId || 'unknown' }); } @@ -286,17 +293,20 @@ function BaseChatContent({ const shouldShowPopularTopics = showPopularTopics && messages.length === 0 && !initialMessage && chatState === ChatState.Idle; - // Debug logging for empty state - console.log('BaseChat2 render state:', { - sessionId: sessionId, // Show full session ID for debugging - sessionIdShort: sessionId.slice(0, 8), // Also show truncated for readability + // DIAGNOSTIC: Enhanced logging for Matrix debugging + console.log('šŸ” BaseChat2 render state:', { + sessionId: sessionId?.slice(0, 8) + '...', // Show truncated for readability + matrixRoomId: matrixRoomId?.substring(0, 20) + '...', + isMatrixTab: !!matrixRoomId, messagesLength: messages.length, chatState, shouldShowPopularTopics, loadingChat, hasSession: !!session, sessionName: session?.name, - sessionDescription: session?.description + sessionDescription: session?.description, + showParticipantsBar, + tabId }); // Memoize the chat object to prevent infinite re-renders diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx index ff34371f1dd4..2410a2c569dc 100644 --- a/ui/desktop/src/components/ChatInput.tsx +++ b/ui/desktop/src/components/ChatInput.tsx @@ -106,6 +106,7 @@ interface ChatInputProps { append?: (message: Message) => void; isExtensionsLoading?: boolean; gooseEnabled?: boolean; + matrixRoomId?: string | null; // Matrix room ID for this specific chat tab } export default function ChatInput({ @@ -134,7 +135,13 @@ export default function ChatInput({ append, isExtensionsLoading = false, gooseEnabled = true, + matrixRoomId, }: ChatInputProps) { + // DIAGNOSTIC: Log the matrixRoomId prop when ChatInput renders + console.log('[ChatInput] Received matrixRoomId prop:', { + matrixRoomId, + timestamp: new Date().toISOString(), + }); // Track the available width for responsive layout const [availableWidth, setAvailableWidth] = useState(window.innerWidth); const chatInputRef = useRef(null); diff --git a/ui/desktop/src/components/SpaceBreadcrumb.tsx b/ui/desktop/src/components/SpaceBreadcrumb.tsx new file mode 100644 index 000000000000..9cb66b9863bf --- /dev/null +++ b/ui/desktop/src/components/SpaceBreadcrumb.tsx @@ -0,0 +1,107 @@ +import React, { useState, useEffect } from 'react'; +import { ChevronRight, Hash } from 'lucide-react'; +import { matrixService } from '../services/MatrixService'; +import { motion } from 'framer-motion'; + +interface SpaceBreadcrumbProps { + roomId: string; + className?: string; +} + +interface BreadcrumbData { + spaceName: string; + spaceId: string; + roomName: string; + roomId: string; +} + +export const SpaceBreadcrumb: React.FC = ({ roomId, className = '' }) => { + const [breadcrumb, setBreadcrumb] = useState(null); + + useEffect(() => { + const loadBreadcrumb = () => { + try { + // Get all Spaces + const spaces = matrixService.getSpaces(); + + // Find which Space contains this room + for (const space of spaces) { + const children = matrixService.getSpaceChildren(space.roomId); + const childRoom = children.find(child => child.roomId === roomId); + + if (childRoom) { + // Found the parent Space! + setBreadcrumb({ + spaceName: space.name || 'Unnamed Space', + spaceId: space.roomId, + roomName: childRoom.name || 'Unnamed Room', + roomId: roomId, + }); + return; + } + } + + // Room is not in any Space + setBreadcrumb(null); + } catch (error) { + console.error('Failed to load Space breadcrumb:', error); + setBreadcrumb(null); + } + }; + + loadBreadcrumb(); + + // Listen for Space changes + const handleSpaceUpdate = () => { + loadBreadcrumb(); + }; + + matrixService.on('spaceChildAdded', handleSpaceUpdate); + matrixService.on('spaceChildRemoved', handleSpaceUpdate); + matrixService.on('ready', handleSpaceUpdate); + + return () => { + matrixService.off('spaceChildAdded', handleSpaceUpdate); + matrixService.off('spaceChildRemoved', handleSpaceUpdate); + matrixService.off('ready', handleSpaceUpdate); + }; + }, [roomId]); + + // Don't render if room is not in a Space + if (!breadcrumb) { + return null; + } + + return ( + + {/* Space Icon */} +
+
+ +
+ {breadcrumb.spaceName} +
+ + {/* Separator */} + + + {/* Room Name */} +
+ + {breadcrumb.roomName} +
+ + {/* Optional: Add a badge to indicate it's a Space room */} +
+ + Space Room + +
+
+ ); +}; diff --git a/ui/desktop/src/components/TabbedChatContainer.tsx b/ui/desktop/src/components/TabbedChatContainer.tsx index 1db5a38c445a..a59d20b7265b 100644 --- a/ui/desktop/src/components/TabbedChatContainer.tsx +++ b/ui/desktop/src/components/TabbedChatContainer.tsx @@ -6,6 +6,7 @@ import MultiPanelTabSidecar from './MultiPanelTabSidecar'; import { useTabContext } from '../contexts/TabContext'; import { ResizableSplitter } from './Layout/ResizableSplitter'; import { TaskExecutionProvider } from '../contexts/TaskExecutionContext'; +import { SpaceBreadcrumb } from './SpaceBreadcrumb'; interface TabbedChatContainerProps { setIsGoosehintsModalOpen?: (isOpen: boolean) => void; @@ -200,6 +201,13 @@ export const TabbedChatContainer: React.FC = ({ />
+ {/* Space Breadcrumb - Show for Matrix tabs with room IDs */} + {activeTabState && activeTabState.tab.type === 'matrix' && activeTabState.tab.matrixRoomId && ( +
+ +
+ )} + {/* Main Content Area - Chat and Sidecar */}
{/* Render all tabs but only show the active one - this prevents unmounting */} diff --git a/ui/desktop/src/components/channels/ChannelsView.tsx b/ui/desktop/src/components/channels/ChannelsView.tsx index df985044924d..10bdef06084a 100644 --- a/ui/desktop/src/components/channels/ChannelsView.tsx +++ b/ui/desktop/src/components/channels/ChannelsView.tsx @@ -19,6 +19,8 @@ import MatrixAuth from '../peers/MatrixAuth'; import { useNavigate } from 'react-router-dom'; import { useTabContext } from '../../contexts/TabContext'; import { matrixService } from '../../services/MatrixService'; +import { sessionMappingService } from '../../services/SessionMappingService'; +import SpaceRoomsView from './SpaceRoomsView'; interface Channel { roomId: string; @@ -547,6 +549,7 @@ const ChannelsView: React.FC = ({ onClose }) => { const [showMatrixAuth, setShowMatrixAuth] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [favorites, setFavorites] = useState>(new Set()); + const [selectedSpace, setSelectedSpace] = useState<{ id: string; name: string } | null>(null); // Load favorites from localStorage on mount useEffect(() => { @@ -606,9 +609,11 @@ const ChannelsView: React.FC = ({ onClose }) => { return mxcUrl; }; - // Filter channels (non-DM rooms) from Matrix rooms and add favorite status - const channels: Channel[] = rooms - .filter(room => !room.isDirectMessage) + // Get Matrix Spaces from context + const { spaces } = useMatrix(); + + // Map Spaces to channels and add favorite status + const channels: Channel[] = spaces .map(room => ({ roomId: room.roomId, name: room.name || 'Unnamed Channel', @@ -638,23 +643,33 @@ const ChannelsView: React.FC = ({ onClose }) => { const handleOpenChannel = async (channel: Channel) => { try { - console.log('šŸ“± Opening channel:', channel); - - // Open a new tab/chat session with Matrix room parameters - // Pass the channel name so it appears in the tab title - openMatrixChat(channel.roomId, currentUser?.userId || '', channel.name); + console.log('šŸ“¦ Opening Space:', channel); - // Navigate to the pair view where the tabs are displayed - navigate('/pair'); + // Set the selected space to show its rooms + setSelectedSpace({ id: channel.roomId, name: channel.name }); } catch (error) { - console.error('Failed to open channel:', error); + console.error('Failed to open Space:', error); } }; const handleCreateChannel = async (name: string, topic: string, isPublic: boolean) => { - // TODO: Implement channel creation via Matrix service - console.log('Creating channel:', { name, topic, isPublic }); - alert('Channel creation not yet implemented'); + try { + console.log('šŸ“¦ Creating Matrix Space:', { name, topic, isPublic }); + + // Use the new createSpace method from MatrixService + const spaceId = await matrixService.createSpace(name, topic, isPublic); + + console.log('āœ… Space created successfully:', spaceId); + + // Create session mapping for the new space + const participants = [currentUser?.userId || '']; + await sessionMappingService.createMappingWithBackendSession(spaceId, participants, name); + + console.log('āœ… Space creation complete'); + } catch (error) { + console.error('āŒ Failed to create Space:', error); + throw error; + } }; const handleEditChannel = (channel: Channel) => { @@ -734,6 +749,17 @@ const ChannelsView: React.FC = ({ onClose }) => { return setShowMatrixAuth(false)} />; } + // Show SpaceRoomsView if a space is selected + if (selectedSpace) { + return ( + setSelectedSpace(null)} + /> + ); + } + return (
{/* Header */} diff --git a/ui/desktop/src/components/channels/SpaceRoomsView.tsx b/ui/desktop/src/components/channels/SpaceRoomsView.tsx new file mode 100644 index 000000000000..28ba573f95ed --- /dev/null +++ b/ui/desktop/src/components/channels/SpaceRoomsView.tsx @@ -0,0 +1,533 @@ +import React, { useState, useEffect } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { + Hash, + Plus, + ArrowLeft, + Users, + Lock, + Globe, + MessageSquare, + Settings, + X +} from 'lucide-react'; +import { useMatrix } from '../../contexts/MatrixContext'; +import { useNavigate } from 'react-router-dom'; +import { useTabContext } from '../../contexts/TabContext'; +import { matrixService } from '../../services/MatrixService'; +import { sessionMappingService } from '../../services/SessionMappingService'; +import { matrixHistorySyncService } from '../../services/MatrixHistorySync'; + +interface SpaceRoomsViewProps { + spaceId: string; + spaceName: string; + onBack: () => void; +} + +interface RoomInfo { + roomId: string; + name: string; + topic?: string; + memberCount: number; + isPublic: boolean; + avatarUrl?: string; + suggested: boolean; +} + +const RoomCard: React.FC<{ + room: RoomInfo; + onOpenRoom: (room: RoomInfo) => void; +}> = ({ room, onOpenRoom }) => { + return ( + onOpenRoom(room)} + className=" + relative cursor-pointer group + bg-background-default + transition-colors duration-200 + hover:bg-background-medium + aspect-square + flex flex-col + rounded-2xl + overflow-hidden + p-6 + " + > + {/* Room Icon */} +
+ +
+ + {/* Privacy Badge */} +
+ {room.isPublic ? ( + + ) : ( + + )} +
+ + {/* Room Info */} +
+

+ {room.name} +

+ {room.topic && ( +

+ {room.topic} +

+ )} +
+ + {room.memberCount} members +
+
+
+ ); +}; + +const EmptyRoomTile: React.FC<{ onCreateRoom: () => void }> = ({ onCreateRoom }) => { + return ( + + {/* Plus icon - hidden by default, shown on hover */} + +
+ +
+

+ Create Room +

+
+ + {/* Subtle hint when not hovering */} + +
+
+
+ + + ); +}; + +const CreateRoomModal: React.FC<{ + isOpen: boolean; + onClose: () => void; + onCreate: (name: string, topic: string, isPublic: boolean) => Promise; +}> = ({ isOpen, onClose, onCreate }) => { + const [name, setName] = useState(''); + const [topic, setTopic] = useState(''); + const [isPublic, setIsPublic] = useState(false); + const [isCreating, setIsCreating] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!name.trim()) return; + + setIsCreating(true); + try { + await onCreate(name.trim(), topic.trim(), isPublic); + onClose(); + setName(''); + setTopic(''); + setIsPublic(false); + } catch (error) { + console.error('Failed to create room:', error); + alert('Failed to create room. Please try again.'); + } finally { + setIsCreating(false); + } + }; + + if (!isOpen) return null; + + return ( + + e.stopPropagation()} + > +
+

Create Room

+ +
+ +
+ {/* Room Name */} +
+ + setName(e.target.value)} + placeholder="general, announcements, etc." + className="w-full px-4 py-3 rounded-lg border border-border-default bg-background-muted focus:outline-none focus:ring-2 focus:ring-background-accent" + disabled={isCreating} + required + /> +
+ + {/* Room Topic */} +
+ +