diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index 0c59d96129af..2a0dbd4a6d03 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { IpcRendererEvent } from 'electron'; import { HashRouter, @@ -37,13 +37,14 @@ import PermissionSettingsView from './components/settings/permission/PermissionS import ExtensionsView, { ExtensionsViewOptions } from './components/extensions/ExtensionsView'; import RecipesView from './components/recipes/RecipesView'; import RecipeEditor from './components/recipes/RecipeEditor'; -import { createNavigationHandler, View, ViewOptions } from './utils/navigationUtils'; +import { View, ViewOptions } from './utils/navigationUtils'; import { AgentState, InitializationContext, NoProviderOrModelError, useAgent, } from './hooks/useAgent'; +import { useNavigation } from './hooks/useNavigation'; // Route Components const HubRouteWrapper = ({ @@ -55,8 +56,7 @@ const HubRouteWrapper = ({ isExtensionsLoading: boolean; resetChat: () => void; }) => { - const navigate = useNavigate(); - const setView = useMemo(() => createNavigationHandler(navigate), [navigate]); + const setView = useNavigation(); return ( Promise; }) => { const location = useLocation(); - const navigate = useNavigate(); - const setView = useMemo(() => createNavigationHandler(navigate), [navigate]); + const setView = useNavigation(); const routeState = (location.state as PairRouteState) || (window.history.state as PairRouteState) || {}; const [searchParams] = useSearchParams(); @@ -114,7 +113,7 @@ const PairRouteWrapper = ({ const SettingsRoute = () => { const location = useLocation(); const navigate = useNavigate(); - const setView = useMemo(() => createNavigationHandler(navigate), [navigate]); + const setView = useNavigation(); // Get viewOptions from location.state or history.state const viewOptions = @@ -123,8 +122,7 @@ const SettingsRoute = () => { }; const SessionsRoute = () => { - const navigate = useNavigate(); - const setView = useMemo(() => createNavigationHandler(navigate), [navigate]); + const setView = useNavigation(); return ; }; @@ -241,8 +239,7 @@ const SharedSessionRouteWrapper = ({ sharedSessionError: string | null; }) => { const location = useLocation(); - const navigate = useNavigate(); - const setView = createNavigationHandler(navigate); + const setView = useNavigation(); const historyState = window.history.state; const sessionDetails = (location.state?.sessionDetails || @@ -315,6 +312,7 @@ export function AppInner() { const [didSelectProvider, setDidSelectProvider] = useState(false); const navigate = useNavigate(); + const setView = useNavigation(); const location = useLocation(); const [_searchParams, setSearchParams] = useSearchParams(); @@ -535,7 +533,7 @@ export function AppInner() { closeOnClick pauseOnHover /> - +
diff --git a/ui/desktop/src/components/ExtensionInstallModal.test.tsx b/ui/desktop/src/components/ExtensionInstallModal.test.tsx index 3720271cf8ae..057451f56a90 100644 --- a/ui/desktop/src/components/ExtensionInstallModal.test.tsx +++ b/ui/desktop/src/components/ExtensionInstallModal.test.tsx @@ -21,6 +21,7 @@ const mockElectron = { describe('ExtensionInstallModal', () => { const mockAddExtension = vi.fn(); + const mockSetView = vi.fn(); const getAddExtensionEventHandler = () => { const addExtensionCall = mockElectron.on.mock.calls.find((call) => call[0] === 'add-extension'); @@ -43,7 +44,7 @@ describe('ExtensionInstallModal', () => { it('should handle trusted extension (default behaviour, no allowlist)', async () => { mockElectron.getAllowedExtensions.mockResolvedValue([]); - render(); + render(); const eventHandler = getAddExtensionEventHandler(); @@ -60,7 +61,7 @@ describe('ExtensionInstallModal', () => { it('should handle trusted extension (from allowlist)', async () => { mockElectron.getAllowedExtensions.mockResolvedValue(['npx test-extension']); - render(); + render(); const eventHandler = getAddExtensionEventHandler(); @@ -78,7 +79,7 @@ describe('ExtensionInstallModal', () => { }); mockElectron.getAllowedExtensions.mockResolvedValue(['uvx allowed-package']); - render(); + render(); const eventHandler = getAddExtensionEventHandler(); @@ -97,7 +98,7 @@ describe('ExtensionInstallModal', () => { it('should handle i-ching-mcp-server as allowed command', async () => { mockElectron.getAllowedExtensions.mockResolvedValue([]); - render(); + render(); const eventHandler = getAddExtensionEventHandler(); @@ -116,7 +117,7 @@ describe('ExtensionInstallModal', () => { it('should handle blocked extension', async () => { mockElectron.getAllowedExtensions.mockResolvedValue(['uvx allowed-package']); - render(); + render(); const eventHandler = getAddExtensionEventHandler(); @@ -135,7 +136,7 @@ describe('ExtensionInstallModal', () => { it('should dismiss modal correctly', async () => { mockElectron.getAllowedExtensions.mockResolvedValue([]); - render(); + render(); const eventHandler = getAddExtensionEventHandler(); @@ -156,7 +157,7 @@ describe('ExtensionInstallModal', () => { vi.mocked(addExtensionFromDeepLink).mockResolvedValue(undefined); mockElectron.getAllowedExtensions.mockResolvedValue([]); - render(); + render(); const eventHandler = getAddExtensionEventHandler(); diff --git a/ui/desktop/src/components/ExtensionInstallModal.tsx b/ui/desktop/src/components/ExtensionInstallModal.tsx index 2ac3bea07ca6..0478730c53c2 100644 --- a/ui/desktop/src/components/ExtensionInstallModal.tsx +++ b/ui/desktop/src/components/ExtensionInstallModal.tsx @@ -12,6 +12,7 @@ import { Button } from './ui/button'; import { extractExtensionName } from './settings/extensions/utils'; import { addExtensionFromDeepLink } from './settings/extensions/deeplink'; import type { ExtensionConfig } from '../api/types.gen'; +import { View, ViewOptions } from '../utils/navigationUtils'; type ModalType = 'blocked' | 'untrusted' | 'trusted'; @@ -41,10 +42,19 @@ interface ExtensionModalConfig { interface ExtensionInstallModalProps { addExtension?: (name: string, config: ExtensionConfig, enabled: boolean) => Promise; + setView: (view: View, options?: ViewOptions) => void; } function extractCommand(link: string): string { const url = new URL(link); + + // For remote extensions (SSE or Streaming HTTP), return the URL + const remoteUrl = url.searchParams.get('url'); + if (remoteUrl) { + return remoteUrl; + } + + // For stdio extensions, return the command const cmd = url.searchParams.get('cmd') || 'Unknown Command'; const args = url.searchParams.getAll('arg').map(decodeURIComponent); return `${cmd} ${args.join(' ')}`.trim(); @@ -55,7 +65,7 @@ function extractRemoteUrl(link: string): string | null { return url.searchParams.get('url'); } -export function ExtensionInstallModal({ addExtension }: ExtensionInstallModalProps) { +export function ExtensionInstallModal({ addExtension, setView }: ExtensionInstallModalProps) { const [modalState, setModalState] = useState({ isOpen: false, modalType: 'trusted', @@ -197,9 +207,14 @@ export function ExtensionInstallModal({ addExtension }: ExtensionInstallModalPro console.log(`Confirming installation of extension from: ${pendingLink}`); if (addExtension) { - await addExtensionFromDeepLink(pendingLink, addExtension, () => { - console.log('Extension installation completed, navigating to extensions'); - }); + await addExtensionFromDeepLink( + pendingLink, + addExtension, + (view: string, options?: ViewOptions) => { + console.log('Extension installation completed, navigating to:', view, options); + setView(view as View, options); + } + ); } else { throw new Error('addExtension function not provided to component'); } @@ -216,7 +231,7 @@ export function ExtensionInstallModal({ addExtension }: ExtensionInstallModalPro isPending: false, })); } - }, [pendingLink, dismissModal, addExtension]); + }, [pendingLink, dismissModal, addExtension, setView]); useEffect(() => { console.log('Setting up extension install modal handler'); diff --git a/ui/desktop/src/components/extensions/ExtensionsView.tsx b/ui/desktop/src/components/extensions/ExtensionsView.tsx index 8907435dc1d2..b4af1adc23c0 100644 --- a/ui/desktop/src/components/extensions/ExtensionsView.tsx +++ b/ui/desktop/src/components/extensions/ExtensionsView.tsx @@ -7,13 +7,14 @@ import { Button } from '../ui/button'; import { Plus } from 'lucide-react'; import { GPSIcon } from '../ui/icons'; import { useState, useEffect } from 'react'; +import kebabCase from 'lodash/kebabCase'; import ExtensionModal from '../settings/extensions/modal/ExtensionModal'; import { getDefaultFormData, ExtensionFormData, createExtensionConfig, } from '../settings/extensions/utils'; -import { activateExtension } from '../settings/extensions/index'; +import { activateExtension } from '../settings/extensions'; import { useConfig } from '../ConfigContext'; export type ExtensionsViewOptions = { @@ -38,13 +39,37 @@ export default function ExtensionsView({ console.error('ExtensionsView: No session ID available'); } - // Trigger refresh when deep link config changes (i.e., when a deep link is processed) + // Only trigger refresh when deep link config changes AND we don't need to show env vars useEffect(() => { - if (viewOptions.deepLinkConfig) { + if (viewOptions.deepLinkConfig && !viewOptions.showEnvVars) { setRefreshKey((prevKey) => prevKey + 1); } }, [viewOptions.deepLinkConfig, viewOptions.showEnvVars]); + const scrollToExtension = (extensionName: string) => { + setTimeout(() => { + const element = document.getElementById(`extension-${kebabCase(extensionName)}`); + if (element) { + element.scrollIntoView({ + behavior: 'smooth', + block: 'center', + }); + // Add a subtle highlight effect + element.style.boxShadow = '0 0 0 2px rgba(59, 130, 246, 0.5)'; + setTimeout(() => { + element.style.boxShadow = ''; + }, 2000); + } + }, 200); + }; + + // Scroll to extension whenever extensionId is provided (after refresh) + useEffect(() => { + if (viewOptions.deepLinkConfig?.name && refreshKey > 0) { + scrollToExtension(viewOptions.deepLinkConfig?.name); + } + }, [viewOptions.deepLinkConfig?.name, refreshKey]); + const handleModalClose = () => { setIsAddModalOpen(false); }; @@ -119,6 +144,9 @@ export default function ExtensionsView({ deepLinkConfig={viewOptions.deepLinkConfig} showEnvVars={viewOptions.showEnvVars} hideButtons={true} + onModalClose={(extensionName: string) => { + scrollToExtension(extensionName); + }} />
diff --git a/ui/desktop/src/components/settings/extensions/ExtensionsSection.tsx b/ui/desktop/src/components/settings/extensions/ExtensionsSection.tsx index c51a46b7cf5a..c225212700e4 100644 --- a/ui/desktop/src/components/settings/extensions/ExtensionsSection.tsx +++ b/ui/desktop/src/components/settings/extensions/ExtensionsSection.tsx @@ -24,6 +24,7 @@ interface ExtensionSectionProps { disableConfiguration?: boolean; customToggle?: (extension: FixedExtensionEntry) => Promise; selectedExtensions?: string[]; // Add controlled state + onModalClose?: (extensionName: string) => void; } export default function ExtensionsSection({ @@ -34,6 +35,7 @@ export default function ExtensionsSection({ disableConfiguration, customToggle, selectedExtensions = [], + onModalClose, }: ExtensionSectionProps) { const { getExtensions, addExtension, removeExtension, extensionsList } = useConfig(); const [selectedExtension, setSelectedExtension] = useState(null); @@ -127,11 +129,15 @@ export default function ExtensionsSection({ extensionConfig: extensionConfig, sessionId: sessionId, }); - // Immediately refresh the extensions list after successful activation - await fetchExtensions(); } catch (error) { console.error('Failed to activate extension:', error); + } finally { await fetchExtensions(); + if (onModalClose) { + setTimeout(() => { + onModalClose(formData.name); + }, 200); + } } }; diff --git a/ui/desktop/src/components/settings/extensions/deeplink.ts b/ui/desktop/src/components/settings/extensions/deeplink.ts index 308dbf2d342c..8620acedbe4c 100644 --- a/ui/desktop/src/components/settings/extensions/deeplink.ts +++ b/ui/desktop/src/components/settings/extensions/deeplink.ts @@ -88,7 +88,8 @@ function getStreamableHttpConfig( name: string, description: string, timeout: number, - headers?: { [key: string]: string } + headers?: { [key: string]: string }, + envs?: { [key: string]: string } ) { const config: ExtensionConfig = { name, @@ -97,6 +98,7 @@ function getStreamableHttpConfig( description, timeout: timeout, headers: headers, + envs: envs, }; return config; @@ -114,14 +116,9 @@ export async function addExtensionFromDeepLink( ) => Promise, setView: ( view: string, - options: - | { extensionId: string; showEnvVars: boolean } - | { deepLinkConfig: ExtensionConfig; showEnvVars: boolean } + options: { showEnvVars: boolean; deepLinkConfig?: ExtensionConfig } ) => void ) { - console.log('=== addExtensionFromDeepLink Debug ==='); - console.log('URL:', url); - const parsedUrl = new URL(url); if (parsedUrl.protocol !== 'goose:') { @@ -168,9 +165,21 @@ export async function addExtensionFromDeepLink( ) : undefined; + // Parse env vars for remote extensions (same logic as stdio) + const envList = parsedUrl.searchParams.getAll('env'); + const envs = + envList.length > 0 + ? Object.fromEntries( + envList.map((env) => { + const [key] = env.split('='); + return [key, '']; + }) + ) + : undefined; + const config = remoteUrl ? transportType === 'streamable_http' - ? getStreamableHttpConfig(remoteUrl, name, description || '', timeout, headers) + ? getStreamableHttpConfig(remoteUrl, name, description || '', timeout, headers, envs) : getSseConfig(remoteUrl, name, description || '', timeout) : getStdioConfig(cmd!, parsedUrl, name, description || '', timeout); @@ -180,21 +189,25 @@ export async function addExtensionFromDeepLink( config.type === 'streamable_http' && config.headers && Object.keys(config.headers).length > 0; if (hasEnvVars || hasHeaders) { - console.log('Environment variables or headers required, redirecting to settings'); - console.log('Calling setView with:', { deepLinkConfig: config, showEnvVars: true }); - setView('settings', { deepLinkConfig: config, showEnvVars: true }); + console.log( + 'Environment variables or headers required, redirecting to extensions with env variables modal showing' + ); + setView('extensions', { deepLinkConfig: config, showEnvVars: true }); return; } - try { - console.log('No env vars required, activating extension directly'); - // Note: deeplink activation doesn't have access to sessionId - // The extension will be added to config but not activated in the current session - // It will be activated when the next session starts - console.warn('Extension will be added to config but requires a session to activate'); - await addExtensionFn(config.name, config, true); - } catch (error) { - console.error('Failed to activate extension from deeplink:', error); - throw error; - } + console.log('No env vars required, activating extension directly'); + // Note: deeplink activation doesn't have access to sessionId + // The extension will be added to config but not activated in the current session + // It will be activated when the next session starts + await addExtensionFn(config.name, config, true); + + // Show success toast and navigate to extensions page + toastService.success({ + title: 'Extension Installed', + msg: `${config.name} extension has been installed successfully. Start a new chat session to use it.`, + }); + + // Navigate to extensions page to show the newly installed extension + setView('extensions', { deepLinkConfig: config, showEnvVars: false }); } diff --git a/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionItem.tsx b/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionItem.tsx index 578a001b83de..872142dfbce9 100644 --- a/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionItem.tsx +++ b/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionItem.tsx @@ -1,6 +1,7 @@ import { useState, useEffect } from 'react'; +import kebabCase from 'lodash/kebabCase'; import { Switch } from '../../../ui/switch'; -import { Gear } from '../../../icons/Gear'; +import { Gear } from '../../../icons'; import { FixedExtensionEntry } from '../../../ConfigContext'; import { getSubtitle, getFriendlyTitle } from './ExtensionList'; import { Card, CardHeader, CardTitle, CardContent, CardAction } from '../../../ui/card'; @@ -73,6 +74,7 @@ export default function ExtensionItem({ return ( handleToggle(extension)} > diff --git a/ui/desktop/src/hooks/useNavigation.ts b/ui/desktop/src/hooks/useNavigation.ts new file mode 100644 index 000000000000..2dfefa3a418b --- /dev/null +++ b/ui/desktop/src/hooks/useNavigation.ts @@ -0,0 +1,13 @@ +import { useNavigate } from 'react-router-dom'; +import { createNavigationHandler } from '../utils/navigationUtils'; + +/** + * Custom hook that provides a navigation handler function. + * Eliminates the repetitive pattern of creating navigation handlers in components. + * + * @returns A navigation handler function + */ +export const useNavigation = () => { + const navigate = useNavigate(); + return createNavigationHandler(navigate); +};