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:
Zane
2025-10-02 10:50:43 -07:00
committed by GitHub
parent 610967a55b
commit 031b8b7270
8 changed files with 128 additions and 52 deletions
+10 -12
View File
@@ -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)}
>
+13
View File
@@ -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);
};