fix redirect to extensions page after deeplink install and show toast with success message (#4863)
Co-authored-by: Amed Rodriguez <amed@squareup.com>
This commit is contained in:
+10
-12
@@ -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 (
|
||||
<Hub
|
||||
@@ -86,8 +86,7 @@ const PairRouteWrapper = ({
|
||||
loadCurrentChat: (context: InitializationContext) => Promise<ChatType>;
|
||||
}) => {
|
||||
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 <SessionsView setView={setView} />;
|
||||
};
|
||||
@@ -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<boolean>(false);
|
||||
|
||||
const navigate = useNavigate();
|
||||
const setView = useNavigation();
|
||||
|
||||
const location = useLocation();
|
||||
const [_searchParams, setSearchParams] = useSearchParams();
|
||||
@@ -535,7 +533,7 @@ export function AppInner() {
|
||||
closeOnClick
|
||||
pauseOnHover
|
||||
/>
|
||||
<ExtensionInstallModal addExtension={addExtension} />
|
||||
<ExtensionInstallModal addExtension={addExtension} setView={setView} />
|
||||
<div className="relative w-screen h-screen overflow-hidden bg-background-muted flex flex-col">
|
||||
<div className="titlebar-drag-region" />
|
||||
<Routes>
|
||||
|
||||
@@ -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(<ExtensionInstallModal addExtension={mockAddExtension} />);
|
||||
render(<ExtensionInstallModal addExtension={mockAddExtension} setView={mockSetView} />);
|
||||
|
||||
const eventHandler = getAddExtensionEventHandler();
|
||||
|
||||
@@ -60,7 +61,7 @@ describe('ExtensionInstallModal', () => {
|
||||
it('should handle trusted extension (from allowlist)', async () => {
|
||||
mockElectron.getAllowedExtensions.mockResolvedValue(['npx test-extension']);
|
||||
|
||||
render(<ExtensionInstallModal addExtension={mockAddExtension} />);
|
||||
render(<ExtensionInstallModal addExtension={mockAddExtension} setView={mockSetView} />);
|
||||
|
||||
const eventHandler = getAddExtensionEventHandler();
|
||||
|
||||
@@ -78,7 +79,7 @@ describe('ExtensionInstallModal', () => {
|
||||
});
|
||||
mockElectron.getAllowedExtensions.mockResolvedValue(['uvx allowed-package']);
|
||||
|
||||
render(<ExtensionInstallModal addExtension={mockAddExtension} />);
|
||||
render(<ExtensionInstallModal addExtension={mockAddExtension} setView={mockSetView} />);
|
||||
|
||||
const eventHandler = getAddExtensionEventHandler();
|
||||
|
||||
@@ -97,7 +98,7 @@ describe('ExtensionInstallModal', () => {
|
||||
it('should handle i-ching-mcp-server as allowed command', async () => {
|
||||
mockElectron.getAllowedExtensions.mockResolvedValue([]);
|
||||
|
||||
render(<ExtensionInstallModal addExtension={mockAddExtension} />);
|
||||
render(<ExtensionInstallModal addExtension={mockAddExtension} setView={mockSetView} />);
|
||||
|
||||
const eventHandler = getAddExtensionEventHandler();
|
||||
|
||||
@@ -116,7 +117,7 @@ describe('ExtensionInstallModal', () => {
|
||||
it('should handle blocked extension', async () => {
|
||||
mockElectron.getAllowedExtensions.mockResolvedValue(['uvx allowed-package']);
|
||||
|
||||
render(<ExtensionInstallModal addExtension={mockAddExtension} />);
|
||||
render(<ExtensionInstallModal addExtension={mockAddExtension} setView={mockSetView} />);
|
||||
|
||||
const eventHandler = getAddExtensionEventHandler();
|
||||
|
||||
@@ -135,7 +136,7 @@ describe('ExtensionInstallModal', () => {
|
||||
it('should dismiss modal correctly', async () => {
|
||||
mockElectron.getAllowedExtensions.mockResolvedValue([]);
|
||||
|
||||
render(<ExtensionInstallModal addExtension={mockAddExtension} />);
|
||||
render(<ExtensionInstallModal addExtension={mockAddExtension} setView={mockSetView} />);
|
||||
|
||||
const eventHandler = getAddExtensionEventHandler();
|
||||
|
||||
@@ -156,7 +157,7 @@ describe('ExtensionInstallModal', () => {
|
||||
vi.mocked(addExtensionFromDeepLink).mockResolvedValue(undefined);
|
||||
mockElectron.getAllowedExtensions.mockResolvedValue([]);
|
||||
|
||||
render(<ExtensionInstallModal addExtension={mockAddExtension} />);
|
||||
render(<ExtensionInstallModal addExtension={mockAddExtension} setView={mockSetView} />);
|
||||
|
||||
const eventHandler = getAddExtensionEventHandler();
|
||||
|
||||
|
||||
@@ -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<void>;
|
||||
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<ExtensionModalState>({
|
||||
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');
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ interface ExtensionSectionProps {
|
||||
disableConfiguration?: boolean;
|
||||
customToggle?: (extension: FixedExtensionEntry) => Promise<boolean | void>;
|
||||
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<FixedExtensionEntry | null>(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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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<void>,
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<Card
|
||||
id={`extension-${kebabCase(extension.name)}`}
|
||||
className="transition-all duration-200 hover:shadow-default hover:cursor-pointer min-h-[120px] overflow-hidden"
|
||||
onClick={() => handleToggle(extension)}
|
||||
>
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
Reference in New Issue
Block a user