Add support for changing working dir and extensions in same window/session (#6057)

This commit is contained in:
Zane
2026-01-08 16:48:15 -08:00
committed by GitHub
parent 4a60f026d5
commit 9a01fcb740
49 changed files with 1865 additions and 1204 deletions
+32 -6
View File
@@ -36,6 +36,9 @@ import { substituteParameters } from '../utils/providerUtils';
import CreateRecipeFromSessionModal from './recipes/CreateRecipeFromSessionModal';
import { toastSuccess } from '../toasts';
import { Recipe } from '../recipe';
import { createSession } from '../sessions';
import { getInitialWorkingDir } from '../utils/workingDir';
import { useConfig } from './ConfigContext';
// Context for sharing current model info
const CurrentModelContext = createContext<{ model: string; mode: string } | null>(null);
@@ -66,11 +69,13 @@ function BaseChatContent({
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const scrollRef = useRef<ScrollAreaHandle>(null);
const { extensionsList } = useConfig();
const disableAnimation = location.state?.disableAnimation || false;
const [hasStartedUsingRecipe, setHasStartedUsingRecipe] = React.useState(false);
const [hasNotAcceptedRecipe, setHasNotAcceptedRecipe] = useState<boolean>();
const [hasRecipeSecurityWarnings, setHasRecipeSecurityWarnings] = useState(false);
const [isCreatingSession, setIsCreatingSession] = useState(false);
const isMobile = useIsMobile();
const { state: sidebarState } = useSidebar();
@@ -95,6 +100,7 @@ function BaseChatContent({
session,
messages,
chatState,
setChatState,
handleSubmit,
submitElicitationResponse,
stopStreaming,
@@ -131,20 +137,40 @@ function BaseChatContent({
const shouldStartAgent = searchParams.get('shouldStartAgent') === 'true';
if (initialMessage) {
// Submit the initial message (e.g., from fork)
hasAutoSubmittedRef.current = true;
handleSubmit(initialMessage);
// Clear initialMessage from navigation state to prevent re-sending on refresh
navigate(location.pathname + location.search, {
replace: true,
state: { ...location.state, initialMessage: undefined },
});
} else if (shouldStartAgent) {
// Trigger agent to continue with existing conversation
hasAutoSubmittedRef.current = true;
handleSubmit('');
}
}, [session, initialMessage, searchParams, handleSubmit]);
}, [session, initialMessage, searchParams, handleSubmit, navigate, location]);
const handleFormSubmit = (e: React.FormEvent) => {
const handleFormSubmit = async (e: React.FormEvent) => {
const customEvent = e as unknown as CustomEvent;
const textValue = customEvent.detail?.value || '';
// If no session exists, create one and navigate with the initial message
if (!session && !sessionId && textValue.trim() && !isCreatingSession) {
setIsCreatingSession(true);
try {
const newSession = await createSession(getInitialWorkingDir(), {
allExtensions: extensionsList,
});
navigate(`/pair?resumeSessionId=${newSession.id}`, {
replace: true,
state: { resumeSessionId: newSession.id, initialMessage: textValue },
});
} catch {
setIsCreatingSession(false);
}
return;
}
if (recipe && textValue.trim()) {
setHasStartedUsingRecipe(true);
}
@@ -284,8 +310,7 @@ function BaseChatContent({
: recipe.prompt;
}
const initialPrompt =
(initialMessage && !hasAutoSubmittedRef.current ? initialMessage : '') || recipePrompt;
const initialPrompt = recipePrompt;
if (sessionLoadError) {
return (
@@ -402,6 +427,7 @@ function BaseChatContent({
sessionId={sessionId}
handleSubmit={handleFormSubmit}
chatState={chatState}
setChatState={setChatState}
onStop={stopStreaming}
commandHistory={commandHistory}
initialValue={initialPrompt}
+47 -11
View File
@@ -27,9 +27,10 @@ import { Recipe } from '../recipe';
import MessageQueue from './MessageQueue';
import { detectInterruption } from '../utils/interruptionDetector';
import { DiagnosticsModal } from './ui/DownloadDiagnostics';
import { Message } from '../api';
import { getSession, Message } from '../api';
import CreateRecipeFromSessionModal from './recipes/CreateRecipeFromSessionModal';
import CreateEditRecipeModal from './recipes/CreateEditRecipeModal';
import { getInitialWorkingDir } from '../utils/workingDir';
import {
trackFileAttached,
trackVoiceDictation,
@@ -73,6 +74,7 @@ interface ChatInputProps {
sessionId: string | null;
handleSubmit: (e: React.FormEvent) => void;
chatState: ChatState;
setChatState?: (state: ChatState) => void;
onStop?: () => void;
commandHistory?: string[];
initialValue?: string;
@@ -97,12 +99,14 @@ interface ChatInputProps {
initialPrompt?: string;
toolCount: number;
append?: (message: Message) => void;
onWorkingDirChange?: (newDir: string) => void;
}
export default function ChatInput({
sessionId,
handleSubmit,
chatState = ChatState.Idle,
setChatState,
onStop,
commandHistory = [],
initialValue = '',
@@ -121,6 +125,7 @@ export default function ChatInput({
initialPrompt,
toolCount,
append: _append,
onWorkingDirChange,
}: ChatInputProps) {
const [_value, setValue] = useState(initialValue);
const [displayValue, setDisplayValue] = useState(initialValue); // For immediate visual feedback
@@ -149,6 +154,26 @@ export default function ChatInput({
const [showCreateRecipeModal, setShowCreateRecipeModal] = useState(false);
const [showEditRecipeModal, setShowEditRecipeModal] = useState(false);
const [isFilePickerOpen, setIsFilePickerOpen] = useState(false);
const [sessionWorkingDir, setSessionWorkingDir] = useState<string | null>(null);
useEffect(() => {
if (!sessionId) {
return;
}
const fetchSessionWorkingDir = async () => {
try {
const response = await getSession({ path: { session_id: sessionId } });
if (response.data?.working_dir) {
setSessionWorkingDir(response.data.working_dir);
}
} catch (error) {
console.error('[ChatInput] Failed to fetch session working dir:', error);
}
};
fetchSessionWorkingDir();
}, [sessionId]);
// Save queue state (paused/interrupted) to storage
useEffect(() => {
@@ -1108,7 +1133,8 @@ export default function ChatInput({
isAnyImageLoading ||
isAnyDroppedFileLoading ||
isRecording ||
isTranscribing;
isTranscribing ||
chatState === ChatState.RestartingAgent;
// Queue management functions - no storage persistence, only in-memory
const handleRemoveQueuedMessage = (messageId: string) => {
@@ -1359,7 +1385,9 @@ export default function ChatInput({
? 'Recording...'
: isTranscribing
? 'Transcribing...'
: 'Send'}
: chatState === ChatState.RestartingAgent
? 'Restarting session...'
: 'Send'}
</p>
</TooltipContent>
</Tooltip>
@@ -1499,8 +1527,19 @@ export default function ChatInput({
{/* Secondary actions and controls row below input */}
<div className="flex flex-row items-center gap-1 p-2 relative">
{/* Directory path */}
<DirSwitcher className="mr-0" />
<DirSwitcher
className="mr-0"
sessionId={sessionId ?? undefined}
workingDir={sessionWorkingDir ?? getInitialWorkingDir()}
onWorkingDirChange={(newDir) => {
setSessionWorkingDir(newDir);
if (onWorkingDirChange) {
onWorkingDirChange(newDir);
}
}}
onRestartStart={() => setChatState?.(ChatState.RestartingAgent)}
onRestartEnd={() => setChatState?.(ChatState.Idle)}
/>
<div className="w-px h-4 bg-border-default mx-2" />
<Tooltip>
<TooltipTrigger asChild>
@@ -1544,12 +1583,8 @@ export default function ChatInput({
</Tooltip>
<div className="w-px h-4 bg-border-default mx-2" />
<BottomMenuModeSelection />
{sessionId && process.env.ALPHA && (
<>
<div className="w-px h-4 bg-border-default mx-2" />
<BottomMenuExtensionSelection sessionId={sessionId} />
</>
)}
<div className="w-px h-4 bg-border-default mx-2" />
<BottomMenuExtensionSelection sessionId={sessionId} />
{sessionId && messages.length > 0 && (
<>
<div className="w-px h-4 bg-border-default mx-2" />
@@ -1619,6 +1654,7 @@ export default function ChatInput({
onSelectedIndexChange={(index) =>
setMentionPopover((prev) => ({ ...prev, selectedIndex: index }))
}
workingDir={sessionWorkingDir ?? getInitialWorkingDir()}
/>
{sessionId && showCreateRecipeModal && (
@@ -1,6 +1,6 @@
import React, { useEffect } from 'react';
import React, { useEffect, useRef } from 'react';
import { FileText, Clock, Home, Puzzle, History } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { useNavigate, useSearchParams } from 'react-router-dom';
import {
SidebarContent,
SidebarFooter,
@@ -96,7 +96,16 @@ const menuItems: NavigationEntry[] = [
const AppSidebar: React.FC<SidebarProps> = ({ currentPath }) => {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const chatContext = useChatContext();
const lastSessionIdRef = useRef<string | null>(null);
const currentSessionId = currentPath === '/pair' ? searchParams.get('resumeSessionId') : null;
useEffect(() => {
if (currentSessionId) {
lastSessionIdRef.current = currentSessionId;
}
}, [currentSessionId]);
useEffect(() => {
const timer = setTimeout(() => {
@@ -130,6 +139,17 @@ const AppSidebar: React.FC<SidebarProps> = ({ currentPath }) => {
return currentPath === path;
};
const handleNavigation = (path: string) => {
// For /pair, preserve the current session if one exists
// Priority: current URL param > last known session > context
const sessionId = currentSessionId || lastSessionIdRef.current || chatContext?.chat?.sessionId;
if (path === '/pair' && sessionId && sessionId.length > 0) {
navigate(`/pair?resumeSessionId=${sessionId}`);
} else {
navigate(path);
}
};
const renderMenuItem = (entry: NavigationEntry, index: number) => {
if (entry.type === 'separator') {
return <SidebarSeparator key={index} />;
@@ -144,7 +164,7 @@ const AppSidebar: React.FC<SidebarProps> = ({ currentPath }) => {
<SidebarMenuItem>
<SidebarMenuButton
data-testid={`sidebar-${entry.label.toLowerCase()}-button`}
onClick={() => navigate(entry.path)}
onClick={() => handleNavigation(entry.path)}
isActive={isActivePath(entry.path)}
tooltip={entry.tooltip}
className="w-full justify-start px-3 rounded-lg h-fit hover:bg-background-medium/50 transition-all duration-200 data-[active=true]:bg-background-medium"
@@ -5,6 +5,8 @@ import { Button } from './ui/button';
import { startNewSession } from '../sessions';
import { useNavigation } from '../hooks/useNavigation';
import { formatExtensionErrorMessage } from '../utils/extensionErrorUtils';
import { getInitialWorkingDir } from '../utils/workingDir';
import { formatExtensionName } from './settings/extensions/subcomponents/ExtensionList';
export interface ExtensionLoadingStatus {
name: string;
@@ -91,46 +93,53 @@ export function GroupedExtensionLoadingToast({
<CollapsibleContent className="overflow-hidden">
<div className="mt-3 pt-3 border-t border-white/20">
<div className="space-y-3 max-h-64 overflow-y-auto pr-2 pl-1">
{extensions.map((ext) => (
<div key={ext.name} className="flex flex-col gap-2">
<div className="flex items-center gap-3 text-sm">
{getStatusIcon(ext.status)}
<div className="flex-1 min-w-0 truncate">{ext.name}</div>
</div>
{ext.status === 'error' && ext.error && (
<div className="ml-7 flex flex-col gap-2">
<div className="text-xs opacity-75 break-words">
{formatExtensionErrorMessage(ext.error, 'Failed to add extension')}
</div>
{ext.recoverHints && setView ? (
<Button
size="sm"
onClick={(e) => {
e.stopPropagation();
startNewSession(ext.recoverHints, setView);
}}
className="self-start"
>
Ask goose
</Button>
) : (
<Button
size="sm"
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(ext.error!);
setCopiedExtension(ext.name);
setTimeout(() => setCopiedExtension(null), 2000);
}}
className="self-start"
>
{copiedExtension === ext.name ? 'Copied!' : 'Copy error'}
</Button>
)}
{extensions.map((ext) => {
const friendlyName = formatExtensionName(ext.name);
return (
<div key={ext.name} className="flex flex-col gap-2">
<div className="flex items-center gap-3 text-sm">
{getStatusIcon(ext.status)}
<div className="flex-1 min-w-0 truncate">{friendlyName}</div>
</div>
)}
</div>
))}
{ext.status === 'error' && ext.error && (
<div className="ml-7 flex flex-col gap-2">
<div className="text-xs opacity-75 break-words">
{formatExtensionErrorMessage(ext.error, 'Failed to add extension')}
</div>
<div className="flex gap-2">
{ext.recoverHints && setView && (
<Button
size="sm"
onClick={(e) => {
e.stopPropagation();
startNewSession(
getInitialWorkingDir(),
ext.recoverHints,
setView
);
}}
>
Ask goose
</Button>
)}
<Button
size="sm"
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(ext.error!);
setCopiedExtension(ext.name);
setTimeout(() => setCopiedExtension(null), 2000);
}}
>
{copiedExtension === ext.name ? 'Copied!' : 'Copy error'}
</Button>
</div>
</div>
)}
</div>
);
})}
</div>
</div>
</CollapsibleContent>
+45 -8
View File
@@ -7,45 +7,81 @@
* Key Responsibilities:
* - Displays SessionInsights to show session statistics and recent chats
* - Provides a ChatInput for users to start new conversations
* - Navigates to Pair with the submitted message to start a new conversation
* - Ensures each submission from Hub always starts a fresh conversation
* - Creates a new session and navigates to Pair with the session ID
* - Shows loading state while session is being created
*
* Navigation Flow:
* Hub (input submission) → Pair (new conversation with the submitted message)
* Hub (input submission) → Create Session → Pair (with session ID and initial message)
*/
import { useState } from 'react';
import { SessionInsights } from './sessions/SessionsInsights';
import ChatInput from './ChatInput';
import { ChatState } from '../types/chatState';
import 'react-toastify/dist/ReactToastify.css';
import { View, ViewOptions } from '../utils/navigationUtils';
import { startNewSession } from '../sessions';
import { useConfig } from './ConfigContext';
import {
getExtensionConfigsWithOverrides,
clearExtensionOverrides,
} from '../store/extensionOverrides';
import { getInitialWorkingDir } from '../utils/workingDir';
import { createSession } from '../sessions';
import LoadingGoose from './LoadingGoose';
export default function Hub({
setView,
}: {
setView: (view: View, viewOptions?: ViewOptions) => void;
}) {
const { extensionsList } = useConfig();
const [workingDir, setWorkingDir] = useState(getInitialWorkingDir());
const [isCreatingSession, setIsCreatingSession] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
const customEvent = e as unknown as CustomEvent;
const combinedTextFromInput = customEvent.detail?.value || '';
if (combinedTextFromInput.trim()) {
await startNewSession(combinedTextFromInput, setView);
if (combinedTextFromInput.trim() && !isCreatingSession) {
const extensionConfigs = getExtensionConfigsWithOverrides(extensionsList);
clearExtensionOverrides();
setIsCreatingSession(true);
try {
const session = await createSession(workingDir, {
extensionConfigs,
allExtensions: extensionConfigs.length > 0 ? undefined : extensionsList,
});
setView('pair', {
disableAnimation: true,
resumeSessionId: session.id,
initialMessage: combinedTextFromInput,
});
} catch (error) {
console.error('Failed to create session:', error);
setIsCreatingSession(false);
}
e.preventDefault();
}
};
return (
<div className="flex flex-col h-full bg-background-muted">
<div className="flex-1 flex flex-col mb-0.5">
<div className="flex-1 flex flex-col mb-0.5 relative">
<SessionInsights />
{isCreatingSession && (
<div className="absolute bottom-1 left-4 z-20 pointer-events-none">
<LoadingGoose chatState={ChatState.LoadingConversation} />
</div>
)}
</div>
<ChatInput
sessionId={null}
handleSubmit={handleSubmit}
chatState={ChatState.Idle}
chatState={isCreatingSession ? ChatState.LoadingConversation : ChatState.Idle}
onStop={() => {}}
initialValue=""
setView={setView}
@@ -58,6 +94,7 @@ export default function Hub({
disableAnimation={false}
sessionCosts={undefined}
toolCount={0}
onWorkingDirChange={setWorkingDir}
/>
</div>
);
+2 -4
View File
@@ -1,4 +1,5 @@
import { useRef, useState } from 'react';
import { getInitialWorkingDir } from '../utils/workingDir';
export default function LauncherView() {
const [query, setQuery] = useState('');
@@ -7,11 +8,8 @@ export default function LauncherView() {
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (query.trim()) {
// Create a new chat window with the query
const workingDir = window.appConfig?.get('GOOSE_WORKING_DIR') as string;
window.electron.createChatWindow(query, workingDir);
window.electron.createChatWindow(query, getInitialWorkingDir());
setQuery('');
// Don't manually close - the blur handler will close the launcher when the new window takes focus
}
};
@@ -5,6 +5,7 @@ import { View, ViewOptions } from '../../utils/navigationUtils';
import { AppWindowMac, AppWindow } from 'lucide-react';
import { Button } from '../ui/button';
import { Sidebar, SidebarInset, SidebarProvider, SidebarTrigger, useSidebar } from '../ui/sidebar';
import { getInitialWorkingDir } from '../../utils/workingDir';
const AppLayoutContent: React.FC = () => {
const navigate = useNavigate();
@@ -66,10 +67,7 @@ const AppLayoutContent: React.FC = () => {
};
const handleNewWindow = () => {
window.electron.createChatWindow(
undefined,
window.appConfig.get('GOOSE_WORKING_DIR') as string | undefined
);
window.electron.createChatWindow(undefined, getInitialWorkingDir());
};
return (
@@ -15,6 +15,7 @@ const STATE_MESSAGES: Record<ChatState, string> = {
[ChatState.WaitingForUserInput]: 'goose is waiting…',
[ChatState.Compacting]: 'goose is compacting the conversation...',
[ChatState.Idle]: 'goose is working on it…',
[ChatState.RestartingAgent]: 'restarting session...',
};
const STATE_ICONS: Record<ChatState, React.ReactNode> = {
@@ -26,6 +27,7 @@ const STATE_ICONS: Record<ChatState, React.ReactNode> = {
),
[ChatState.Compacting]: <AnimatedIcons className="flex-shrink-0" cycleInterval={600} />,
[ChatState.Idle]: <GooseLogo size="small" hover={false} />,
[ChatState.RestartingAgent]: <AnimatedIcons className="flex-shrink-0" cycleInterval={600} />,
};
const LoadingGoose = ({ message, chatState = ChatState.Idle }: LoadingGooseProps) => {
+4 -2
View File
@@ -9,6 +9,7 @@ import {
} from 'react';
import { ItemIcon } from './ItemIcon';
import { CommandType, getSlashCommands } from '../api';
import { getInitialWorkingDir } from '../utils/workingDir';
type DisplayItemType = CommandType | 'Directory' | 'File';
@@ -41,6 +42,7 @@ interface MentionPopoverProps {
isSlashCommand: boolean;
selectedIndex: number;
onSelectedIndexChange: (index: number) => void;
workingDir?: string;
}
// Enhanced fuzzy matching algorithm
@@ -121,6 +123,7 @@ const MentionPopover = forwardRef<
isSlashCommand,
selectedIndex,
onSelectedIndexChange,
workingDir,
},
ref
) => {
@@ -128,8 +131,7 @@ const MentionPopover = forwardRef<
const [isLoading, setIsLoading] = useState(false);
const popoverRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const currentWorkingDir = window.appConfig.get('GOOSE_WORKING_DIR') as string;
const currentWorkingDir = workingDir ?? getInitialWorkingDir();
const scanDirectoryFromRoot = useCallback(
async (dirPath: string, relativePath = '', depth = 0): Promise<DisplayItem[]> => {
@@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react';
import { Parameter } from '../recipe';
import { Button } from './ui/button';
import { getInitialWorkingDir } from '../utils/workingDir';
interface ParameterInputModalProps {
parameters: Parameter[];
@@ -72,16 +73,12 @@ const ParameterInputModal: React.FC<ParameterInputModalProps> = ({
const handleCancelOption = (option: 'new-chat' | 'back-to-form'): void => {
if (option === 'new-chat') {
// Create a new chat window without recipe config
try {
const workingDir = window.appConfig.get('GOOSE_WORKING_DIR');
console.log(`Creating new chat window without recipe, working dir: ${workingDir}`);
window.electron.createChatWindow(undefined, workingDir as string);
// Close the current window after creating the new one
const workingDir = getInitialWorkingDir();
window.electron.createChatWindow(undefined, workingDir);
window.electron.hideWindow();
} catch (error) {
console.error('Error creating new window:', error);
// Fallback: just close the modal
onClose();
}
} else {
@@ -1,25 +1,119 @@
import { useCallback, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState, useRef } from 'react';
import { Puzzle } from 'lucide-react';
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '../ui/dropdown-menu';
import { Input } from '../ui/input';
import { Switch } from '../ui/switch';
import { FixedExtensionEntry, useConfig } from '../ConfigContext';
import { toggleExtension } from '../settings/extensions/extension-manager';
import { toastService } from '../../toasts';
import { getFriendlyTitle } from '../settings/extensions/subcomponents/ExtensionList';
import { formatExtensionName } from '../settings/extensions/subcomponents/ExtensionList';
import { ExtensionConfig, getSessionExtensions } from '../../api';
import { addToAgent, removeFromAgent } from '../settings/extensions/agent-api';
import {
setExtensionOverride,
getExtensionOverride,
getExtensionOverrides,
} from '../../store/extensionOverrides';
interface BottomMenuExtensionSelectionProps {
sessionId: string;
sessionId: string | null;
}
export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionSelectionProps) => {
const [searchQuery, setSearchQuery] = useState('');
const [isOpen, setIsOpen] = useState(false);
const { extensionsList, addExtension } = useConfig();
const [sessionExtensions, setSessionExtensions] = useState<ExtensionConfig[]>([]);
const [hubUpdateTrigger, setHubUpdateTrigger] = useState(0);
const [isTransitioning, setIsTransitioning] = useState(false);
const [pendingSort, setPendingSort] = useState(false);
const [togglingExtension, setTogglingExtension] = useState<string | null>(null);
const [refreshTrigger, setRefreshTrigger] = useState(0);
const sortTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const { extensionsList: allExtensions } = useConfig();
const isHubView = !sessionId;
useEffect(() => {
const handleSessionLoaded = () => {
setTimeout(() => {
setRefreshTrigger((prev) => prev + 1);
}, 500);
};
window.addEventListener('session-created', handleSessionLoaded);
window.addEventListener('message-stream-finished', handleSessionLoaded);
return () => {
window.removeEventListener('session-created', handleSessionLoaded);
window.removeEventListener('message-stream-finished', handleSessionLoaded);
};
}, []);
useEffect(() => {
return () => {
if (sortTimeoutRef.current) {
clearTimeout(sortTimeoutRef.current);
}
};
}, []);
// Fetch session-specific extensions or use global defaults
useEffect(() => {
const fetchExtensions = async () => {
if (!sessionId) {
return;
}
try {
const response = await getSessionExtensions({
path: { session_id: sessionId },
});
if (response.data?.extensions) {
setSessionExtensions(response.data.extensions);
}
} catch (error) {
console.error('Failed to fetch session extensions:', error);
}
};
fetchExtensions();
}, [sessionId, isOpen, refreshTrigger]);
const handleToggle = useCallback(
async (extensionConfig: FixedExtensionEntry) => {
if (togglingExtension === extensionConfig.name) {
return;
}
setIsTransitioning(true);
setTogglingExtension(extensionConfig.name);
if (isHubView) {
const currentState = getExtensionOverride(extensionConfig.name) ?? extensionConfig.enabled;
setExtensionOverride(extensionConfig.name, !currentState);
setPendingSort(true);
if (sortTimeoutRef.current) {
clearTimeout(sortTimeoutRef.current);
}
// Delay the re-sort to allow animation
sortTimeoutRef.current = setTimeout(() => {
setHubUpdateTrigger((prev) => prev + 1);
setPendingSort(false);
setIsTransitioning(false);
setTogglingExtension(null);
}, 800);
toastService.success({
title: 'Extension Updated',
msg: `${formatExtensionName(extensionConfig.name)} will be ${!currentState ? 'enabled' : 'disabled'} in new chats`,
});
return;
}
if (!sessionId) {
setIsTransitioning(false);
setTogglingExtension(null);
toastService.error({
title: 'Extension Toggle Error',
msg: 'No active session found. Please start a chat session first.',
@@ -29,26 +123,65 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS
}
try {
const toggleDirection = extensionConfig.enabled ? 'toggleOff' : 'toggleOn';
if (extensionConfig.enabled) {
await removeFromAgent(extensionConfig.name, sessionId, true);
} else {
await addToAgent(extensionConfig, sessionId, true);
}
await toggleExtension({
toggle: toggleDirection,
extensionConfig: extensionConfig,
addToConfig: addExtension,
toastOptions: { silent: false },
sessionId: sessionId,
});
} catch (error) {
toastService.error({
title: 'Extension Error',
msg: `Failed to ${extensionConfig.enabled ? 'disable' : 'enable'} ${extensionConfig.name}`,
traceback: error instanceof Error ? error.message : String(error),
});
setPendingSort(true);
if (sortTimeoutRef.current) {
clearTimeout(sortTimeoutRef.current);
}
sortTimeoutRef.current = setTimeout(async () => {
const response = await getSessionExtensions({
path: { session_id: sessionId },
});
if (response.data?.extensions) {
setSessionExtensions(response.data.extensions);
}
setPendingSort(false);
setIsTransitioning(false);
setTogglingExtension(null);
}, 800);
} catch {
setIsTransitioning(false);
setPendingSort(false);
setTogglingExtension(null);
}
},
[sessionId, addExtension]
[sessionId, isHubView, togglingExtension]
);
// Merge all available extensions with session-specific or hub override state
const extensionsList = useMemo(() => {
const hubOverrides = getExtensionOverrides();
if (isHubView) {
return allExtensions.map(
(ext) =>
({
...ext,
enabled: hubOverrides.has(ext.name) ? hubOverrides.get(ext.name)! : ext.enabled,
}) as FixedExtensionEntry
);
}
const sessionExtensionNames = new Set(sessionExtensions.map((ext) => ext.name));
return allExtensions.map(
(ext) =>
({
...ext,
enabled: sessionExtensionNames.has(ext.name),
}) as FixedExtensionEntry
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [allExtensions, sessionExtensions, isHubView, hubUpdateTrigger]);
const filteredExtensions = useMemo(() => {
return extensionsList.filter((ext) => {
const query = searchQuery.toLowerCase();
@@ -60,24 +193,11 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS
}, [extensionsList, searchQuery]);
const sortedExtensions = useMemo(() => {
const getTypePriority = (type: string): number => {
const priorities: Record<string, number> = {
builtin: 0,
platform: 1,
frontend: 2,
};
return priorities[type] ?? Number.MAX_SAFE_INTEGER;
};
return [...filteredExtensions].sort((a, b) => {
// First sort by priority type
const typeDiff = getTypePriority(a.type) - getTypePriority(b.type);
if (typeDiff !== 0) return typeDiff;
// Then sort by enabled status (enabled first)
// Primary sort: enabled first
if (a.enabled !== b.enabled) return a.enabled ? -1 : 1;
// Finally sort alphabetically
// Secondary sort: alphabetically by name
return a.name.localeCompare(b.name);
});
}, [filteredExtensions]);
@@ -92,7 +212,13 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS
onOpenChange={(open) => {
setIsOpen(open);
if (!open) {
setSearchQuery(''); // Reset search when closing
setSearchQuery('');
if (sortTimeoutRef.current) {
clearTimeout(sortTimeoutRef.current);
}
setIsTransitioning(false);
setPendingSort(false);
setTogglingExtension(null);
}
}}
>
@@ -105,7 +231,14 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS
<span>{activeCount}</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="center" className="w-64">
<DropdownMenuContent
side="top"
align="center"
className="w-64"
onCloseAutoFocus={(e) => {
e.preventDefault();
}}
>
<div className="p-2">
<Input
type="text"
@@ -115,30 +248,45 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS
className="h-8 text-sm"
autoFocus
/>
<p className="text-xs text-text-default/60 mt-1.5">
{isHubView ? 'Extensions for new chats' : 'Extensions for this chat session'}
</p>
</div>
<div className="max-h-[400px] overflow-y-auto">
<div
className={`max-h-[400px] overflow-y-auto transition-opacity duration-300 ${
isTransitioning && pendingSort ? 'opacity-50' : 'opacity-100'
}`}
>
{sortedExtensions.length === 0 ? (
<div className="px-2 py-4 text-center text-sm text-text-default/70">
{searchQuery ? 'no extensions found' : 'no extensions available'}
</div>
) : (
sortedExtensions.map((ext) => (
<div
key={ext.name}
className="flex items-center justify-between px-2 py-2 hover:bg-background-hover cursor-pointer"
onClick={() => handleToggle(ext)}
title={ext.description || ext.name}
>
<div className="text-sm font-medium text-text-default">{getFriendlyTitle(ext)}</div>
<div onClick={(e) => e.stopPropagation()}>
<Switch
checked={ext.enabled}
onCheckedChange={() => handleToggle(ext)}
variant="mono"
/>
sortedExtensions.map((ext) => {
const isToggling = togglingExtension === ext.name;
return (
<div
key={ext.name}
className={`flex items-center justify-between px-2 py-2 hover:bg-background-hover transition-all duration-300 ${
isToggling ? 'cursor-wait opacity-70' : 'cursor-pointer'
}`}
onClick={() => !isToggling && handleToggle(ext)}
title={ext.description || ext.name}
>
<div className="text-sm font-medium text-text-default">
{formatExtensionName(ext.name)}
</div>
<div onClick={(e) => e.stopPropagation()}>
<Switch
checked={ext.enabled}
onCheckedChange={() => handleToggle(ext)}
variant="mono"
disabled={isToggling}
/>
</div>
</div>
</div>
))
);
})
)}
</div>
</DropdownMenuContent>
@@ -1,23 +1,65 @@
import React, { useState } from 'react';
import { FolderDot } from 'lucide-react';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/Tooltip';
import { updateWorkingDir } from '../../api';
import { toast } from 'react-toastify';
interface DirSwitcherProps {
className?: string;
className: string;
sessionId: string | undefined;
workingDir: string;
onWorkingDirChange?: (newDir: string) => void;
onRestartStart?: () => void;
onRestartEnd?: () => void;
}
export const DirSwitcher: React.FC<DirSwitcherProps> = ({ className = '' }) => {
export const DirSwitcher: React.FC<DirSwitcherProps> = ({
className,
sessionId,
workingDir,
onWorkingDirChange,
onRestartStart,
onRestartEnd,
}) => {
const [isTooltipOpen, setIsTooltipOpen] = useState(false);
const [isDirectoryChooserOpen, setIsDirectoryChooserOpen] = useState(false);
const handleDirectoryChange = async () => {
if (isDirectoryChooserOpen) return;
setIsDirectoryChooserOpen(true);
let result;
try {
await window.electron.directoryChooser(true);
result = await window.electron.directoryChooser();
} finally {
setIsDirectoryChooserOpen(false);
}
if (result.canceled || result.filePaths.length === 0) {
return;
}
const newDir = result.filePaths[0];
window.electron.addRecentDir(newDir);
if (sessionId) {
onWorkingDirChange?.(newDir);
onRestartStart?.();
try {
await updateWorkingDir({
body: { session_id: sessionId, working_dir: newDir },
});
} catch (error) {
console.error('[DirSwitcher] Failed to update working directory:', error);
toast.error('Failed to update working directory');
} finally {
onRestartEnd?.();
}
} else {
onWorkingDirChange?.(newDir);
}
};
const handleDirectoryClick = async (event: React.MouseEvent) => {
@@ -31,7 +73,6 @@ export const DirSwitcher: React.FC<DirSwitcherProps> = ({ className = '' }) => {
if (isCmdOrCtrlClick) {
event.preventDefault();
event.stopPropagation();
const workingDir = window.appConfig.get('GOOSE_WORKING_DIR') as string;
await window.electron.openDirectoryInExplorer(workingDir);
} else {
await handleDirectoryChange();
@@ -53,14 +94,10 @@ export const DirSwitcher: React.FC<DirSwitcherProps> = ({ className = '' }) => {
disabled={isDirectoryChooserOpen}
>
<FolderDot className="mr-1" size={16} />
<div className="max-w-[200px] truncate [direction:rtl]">
{String(window.appConfig.get('GOOSE_WORKING_DIR'))}
</div>
<div className="max-w-[200px] truncate [direction:rtl]">{workingDir}</div>
</button>
</TooltipTrigger>
<TooltipContent side="top">
{window.appConfig.get('GOOSE_WORKING_DIR') as string}
</TooltipContent>
<TooltipContent side="top">{workingDir}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
@@ -1,5 +1,4 @@
import { View, ViewOptions } from '../../utils/navigationUtils';
import { useChatContext } from '../../contexts/ChatContext';
import ExtensionsSection from '../settings/extensions/ExtensionsSection';
import { ExtensionConfig } from '../../api';
import { MainPanelLayout } from '../Layout/MainPanelLayout';
@@ -14,7 +13,7 @@ import {
ExtensionFormData,
createExtensionConfig,
} from '../settings/extensions/utils';
import { activateExtension } from '../settings/extensions';
import { activateExtensionDefault } from '../settings/extensions';
import { useConfig } from '../ConfigContext';
import { SearchView } from '../conversation/SearchView';
import { getSearchShortcutText } from '../../utils/keyboardShortcuts';
@@ -35,8 +34,6 @@ export default function ExtensionsView({
const [refreshKey, setRefreshKey] = useState(0);
const [searchTerm, setSearchTerm] = useState('');
const { addExtension } = useConfig();
const chatContext = useChatContext();
const sessionId = chatContext?.chat.sessionId;
// Only trigger refresh when deep link config changes AND we don't need to show env vars
useEffect(() => {
@@ -80,7 +77,10 @@ export default function ExtensionsView({
const extensionConfig = createExtensionConfig(formData);
try {
await activateExtension(extensionConfig, addExtension, sessionId);
await activateExtensionDefault({
addToConfig: addExtension,
extensionConfig: extensionConfig,
});
// Trigger a refresh of the extensions list
setRefreshKey((prevKey) => prevKey + 1);
} catch (error) {
@@ -100,11 +100,15 @@ export default function ExtensionsView({
<div className="flex justify-between items-center mb-1">
<h1 className="text-4xl font-light">Extensions</h1>
</div>
<p className="text-sm text-text-muted mb-6">
<p className="text-sm text-text-muted mb-2">
These extensions use the Model Context Protocol (MCP). They can expand Goose's
capabilities using three main components: Prompts, Resources, and Tools.{' '}
{getSearchShortcutText()} to search.
</p>
<p className="text-sm text-text-muted mb-6">
Extensions enabled here are used as the default for new chats. You can also toggle
active extensions during chat.
</p>
{/* Action Buttons */}
<div className="flex gap-4 mb-8">
@@ -134,7 +138,6 @@ export default function ExtensionsView({
<SearchView onSearch={(term) => setSearchTerm(term)} placeholder="Search extensions...">
<ExtensionsSection
key={refreshKey}
sessionId={sessionId}
deepLinkConfig={viewOptions.deepLinkConfig}
showEnvVars={viewOptions.showEnvVars}
hideButtons={true}
@@ -38,6 +38,7 @@ import { CronPicker } from '../schedule/CronPicker';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../ui/dialog';
import { SearchView } from '../conversation/SearchView';
import cronstrue from 'cronstrue';
import { getInitialWorkingDir } from '../../utils/workingDir';
import {
trackRecipeDeleted,
trackRecipeStarted,
@@ -140,7 +141,7 @@ export default function RecipesView() {
try {
const newAgent = await startAgent({
body: {
working_dir: window.appConfig.get('GOOSE_WORKING_DIR') as string,
working_dir: getInitialWorkingDir(),
recipe,
},
throwOnError: true,
@@ -163,7 +164,7 @@ export default function RecipesView() {
try {
window.electron.createChatWindow(
undefined,
window.appConfig.get('GOOSE_WORKING_DIR') as string,
getInitialWorkingDir(),
undefined,
undefined,
'pair',
@@ -10,6 +10,7 @@ import {
Download,
Upload,
ExternalLink,
Puzzle,
} from 'lucide-react';
import { Card } from '../ui/card';
import { Button } from '../ui/button';
@@ -22,6 +23,7 @@ import { groupSessionsByDate, type DateGroup } from '../../utils/dateUtils';
import { Skeleton } from '../ui/skeleton';
import { toast } from 'react-toastify';
import { ConfirmationModal } from '../ui/ConfirmationModal';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/Tooltip';
import {
deleteSession,
exportSession,
@@ -29,9 +31,25 @@ import {
listSessions,
Session,
updateSessionName,
ExtensionConfig,
ExtensionData,
} from '../../api';
import { formatExtensionName } from '../settings/extensions/subcomponents/ExtensionList';
import { getSearchShortcutText } from '../../utils/keyboardShortcuts';
function getSessionExtensionNames(extensionData: ExtensionData): string[] {
try {
const enabledExtensionData = extensionData?.['enabled_extensions.v0'] as
| { extensions?: ExtensionConfig[] }
| undefined;
if (!enabledExtensionData?.extensions) return [];
return enabledExtensionData.extensions.map((ext) => formatExtensionName(ext.name));
} catch {
return [];
}
}
interface EditSessionModalProps {
session: Session | null;
isOpen: boolean;
@@ -49,7 +67,6 @@ const EditSessionModal = React.memo<EditSessionModalProps>(
if (session && isOpen) {
setDescription(session.name);
} else if (!isOpen) {
// Reset state when modal closes
setDescription('');
setIsUpdating(false);
}
@@ -72,8 +89,6 @@ const EditSessionModal = React.memo<EditSessionModalProps>(
throwOnError: true,
});
await onSave(session.id, trimmedDescription);
// Close modal, then show success toast on a timeout to let the UI update complete.
onClose();
setTimeout(() => {
toast.success('Session description updated successfully');
@@ -548,6 +563,12 @@ const SessionListView: React.FC<SessionListViewProps> = React.memo(
[onOpenInNewWindow, session]
);
// Get extension names for this session
const extensionNames = useMemo(
() => getSessionExtensionNames(session.extension_data),
[session.extension_data]
);
return (
<Card
onClick={handleCardClick}
@@ -611,6 +632,28 @@ const SessionListView: React.FC<SessionListViewProps> = React.memo(
<span className="font-mono">{(session.total_tokens || 0).toLocaleString()}</span>
</div>
)}
{extensionNames.length > 0 && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center" onClick={(e) => e.stopPropagation()}>
<Puzzle className="w-3 h-3 mr-1" />
<span className="font-mono">{extensionNames.length}</span>
</div>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
<div className="text-xs">
<div className="font-medium mb-1">Extensions:</div>
<ul className="list-disc list-inside">
{extensionNames.map((name) => (
<li key={name}>{name}</li>
))}
</ul>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
</div>
</Card>
@@ -78,7 +78,6 @@ export function SessionInsights() {
loadInsights();
loadRecentSessions();
// Cleanup timeout on unmount
return () => {
if (loadingTimeout) {
window.clearTimeout(loadingTimeout);
@@ -125,7 +125,6 @@ export default function UpdateSection() {
}
});
// Cleanup timeout on unmount
return () => {
if (progressTimeoutRef.current) {
clearTimeout(progressTimeoutRef.current);
@@ -1,6 +1,6 @@
import { useEffect, useState, useCallback, useMemo } from 'react';
import { Button } from '../../ui/button';
import { Plus, AlertTriangle } from 'lucide-react';
import { Plus } from 'lucide-react';
import { GPSIcon } from '../../ui/icons';
import { useConfig, FixedExtensionEntry } from '../../ConfigContext';
import ExtensionList from './subcomponents/ExtensionList';
@@ -12,11 +12,10 @@ import {
getDefaultFormData,
} from './utils';
import { activateExtension, deleteExtension, toggleExtension, updateExtension } from './index';
import { ExtensionConfig } from '../../../api';
import { activateExtensionDefault, deleteExtension, toggleExtensionDefault } from './index';
import { ExtensionConfig } from '../../../api/types.gen';
interface ExtensionSectionProps {
sessionId?: string;
deepLinkConfig?: ExtensionConfig;
showEnvVars?: boolean;
hideButtons?: boolean;
@@ -28,7 +27,6 @@ interface ExtensionSectionProps {
}
export default function ExtensionsSection({
sessionId,
deepLinkConfig,
showEnvVars,
hideButtons,
@@ -38,8 +36,7 @@ export default function ExtensionsSection({
onModalClose,
searchTerm = '',
}: ExtensionSectionProps) {
const { getExtensions, addExtension, removeExtension, extensionsList, extensionWarnings } =
useConfig();
const { getExtensions, addExtension, removeExtension, extensionsList } = useConfig();
const [selectedExtension, setSelectedExtension] = useState<FixedExtensionEntry | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
@@ -49,25 +46,12 @@ export default function ExtensionsSection({
const [showEnvVarsStateVar, setShowEnvVarsStateVar] = useState<boolean | undefined | null>(
showEnvVars
);
const [pendingActivationExtensions, setPendingActivationExtensions] = useState<Set<string>>(
new Set()
);
// Update deep link state when props change
useEffect(() => {
setDeepLinkConfigStateVar(deepLinkConfig);
setShowEnvVarsStateVar(showEnvVars);
if (deepLinkConfig && !showEnvVars) {
setPendingActivationExtensions((prev) => {
const updated = new Set(prev);
updated.add(deepLinkConfig.name);
return updated;
});
}
}, [deepLinkConfig, showEnvVars]);
// Process extensions from context - this automatically updates when extensionsList changes
const extensions = useMemo(() => {
if (extensionsList.length === 0) return [];
@@ -103,21 +87,12 @@ export default function ExtensionsSection({
return true;
}
// If extension is enabled, we are trying to toggle if off, otherwise on
const toggleDirection = extensionConfig.enabled ? 'toggleOff' : 'toggleOn';
await toggleExtension({
await toggleExtensionDefault({
toggle: toggleDirection,
extensionConfig: extensionConfig,
addToConfig: addExtension,
toastOptions: { silent: false },
sessionId,
});
setPendingActivationExtensions((prev) => {
const updated = new Set(prev);
updated.delete(extensionConfig.name);
return updated;
});
await fetchExtensions();
@@ -135,22 +110,12 @@ export default function ExtensionsSection({
const extensionConfig = createExtensionConfig(formData);
try {
await activateExtension(extensionConfig, addExtension, sessionId);
setPendingActivationExtensions((prev) => {
const updated = new Set(prev);
updated.delete(extensionConfig.name);
return updated;
await activateExtensionDefault({
addToConfig: addExtension,
extensionConfig: extensionConfig,
});
} catch (error) {
console.error('Failed to activate extension:', error);
// If activation fails, mark as pending if it's enabled in config
if (formData.enabled) {
setPendingActivationExtensions((prev) => {
const updated = new Set(prev);
updated.add(extensionConfig.name);
return updated;
});
}
console.error('Failed to add extension:', error);
} finally {
await fetchExtensions();
if (onModalClose) {
@@ -174,42 +139,28 @@ export default function ExtensionsSection({
const originalName = selectedExtension.name;
try {
await updateExtension({
enabled: formData.enabled,
extensionConfig: extensionConfig,
addToConfig: addExtension,
removeFromConfig: removeExtension,
originalName: originalName,
sessionId: sessionId,
});
if (originalName !== extensionConfig.name) {
await removeExtension(originalName);
}
await addExtension(extensionConfig.name, extensionConfig, formData.enabled);
} catch (error) {
console.error('Failed to update extension:', error);
// We don't reopen the modal on failure
} finally {
// Refresh the extensions list regardless of success or failure
await fetchExtensions();
}
};
const handleDeleteExtension = async (name: string) => {
// Capture the selected extension before closing the modal
const extensionToDelete = selectedExtension;
// Close the modal immediately
handleModalClose();
try {
await deleteExtension({
name,
removeFromConfig: removeExtension,
sessionId,
extensionConfig: extensionToDelete ?? undefined,
});
} catch (error) {
console.error('Failed to delete extension:', error);
// We don't reopen the modal on failure
} finally {
// Refresh the extensions list regardless of success or failure
await fetchExtensions();
}
};
@@ -231,29 +182,12 @@ export default function ExtensionsSection({
return (
<section id="extensions">
<div className="">
{/* Unsupported extension warnings */}
{extensionWarnings.length > 0 && (
<div className="mb-4 p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
<div className="flex items-start gap-2">
<AlertTriangle className="h-5 w-5 text-yellow-500 flex-shrink-0 mt-0.5" />
<div className="text-sm text-yellow-500">
{extensionWarnings.map((warning, index) => (
<p key={index} className={index > 0 ? 'mt-1' : ''}>
{warning}
</p>
))}
</div>
</div>
</div>
)}
<ExtensionList
extensions={extensions}
onToggle={handleExtensionToggle}
onConfigure={handleConfigureClick}
disableConfiguration={disableConfiguration}
searchTerm={searchTerm}
pendingActivationExtensions={pendingActivationExtensions}
/>
{!hideButtons && (
@@ -1,255 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { addToAgentOnStartup, updateExtension, toggleExtension } from './extension-manager';
import * as agentApi from './agent-api';
import * as toasts from '../../../toasts';
// Mock dependencies
vi.mock('./agent-api');
vi.mock('../../../toasts');
const mockAddToAgent = vi.mocked(agentApi.addToAgent);
const mockRemoveFromAgent = vi.mocked(agentApi.removeFromAgent);
const mockSanitizeName = vi.mocked(agentApi.sanitizeName);
const mockToastService = vi.mocked(toasts.toastService);
describe('Extension Manager', () => {
const mockAddToConfig = vi.fn();
const mockRemoveFromConfig = vi.fn();
const mockExtensionConfig = {
type: 'stdio' as const,
name: 'test-extension',
description: 'test-extension',
cmd: 'python',
args: ['script.py'],
timeout: 300,
};
beforeEach(() => {
vi.clearAllMocks();
mockSanitizeName.mockImplementation((name: string) => name.toLowerCase());
mockAddToConfig.mockResolvedValue(undefined);
mockRemoveFromConfig.mockResolvedValue(undefined);
});
describe('addToAgentOnStartup', () => {
it('should successfully add extension on startup', async () => {
mockAddToAgent.mockResolvedValue(undefined);
await addToAgentOnStartup({
sessionId: 'test-session',
extensionConfig: mockExtensionConfig,
});
expect(mockAddToAgent).toHaveBeenCalledWith(mockExtensionConfig, 'test-session', true);
});
it('should successfully add extension on startup with custom toast options', async () => {
mockAddToAgent.mockResolvedValue(undefined);
await addToAgentOnStartup({
sessionId: 'test-session',
extensionConfig: mockExtensionConfig,
});
expect(mockAddToAgent).toHaveBeenCalledWith(mockExtensionConfig, 'test-session', true);
});
it('should retry on 428 errors', async () => {
const error428 = new Error('428 Precondition Required');
mockAddToAgent
.mockRejectedValueOnce(error428)
.mockRejectedValueOnce(error428)
.mockResolvedValue(undefined);
await addToAgentOnStartup({
sessionId: 'test-session',
extensionConfig: mockExtensionConfig,
});
expect(mockAddToAgent).toHaveBeenCalledTimes(3);
});
it('should throw error after max retries', async () => {
const error428 = new Error('428 Precondition Required');
mockAddToAgent.mockRejectedValue(error428);
await expect(
addToAgentOnStartup({
sessionId: 'test-session',
extensionConfig: mockExtensionConfig,
})
).rejects.toThrow('428 Precondition Required');
expect(mockAddToAgent).toHaveBeenCalledTimes(4); // Initial + 3 retries
});
});
describe('updateExtension', () => {
it('should update extension without name change', async () => {
mockAddToAgent.mockResolvedValue(undefined);
mockAddToConfig.mockResolvedValue(undefined);
mockToastService.success = vi.fn();
await updateExtension({
enabled: true,
addToConfig: mockAddToConfig,
sessionId: 'test-session',
removeFromConfig: mockRemoveFromConfig,
extensionConfig: mockExtensionConfig,
originalName: 'test-extension',
});
expect(mockAddToConfig).toHaveBeenCalledWith(
'test-extension',
{ ...mockExtensionConfig, name: 'test-extension' },
true
);
expect(mockToastService.success).toHaveBeenCalledWith({
title: 'Update extension',
msg: 'Successfully updated test-extension extension',
});
});
it('should handle name change by removing old and adding new', async () => {
mockAddToAgent.mockResolvedValue(undefined);
mockRemoveFromAgent.mockResolvedValue(undefined);
mockRemoveFromConfig.mockResolvedValue(undefined);
mockAddToConfig.mockResolvedValue(undefined);
mockToastService.success = vi.fn();
await updateExtension({
enabled: true,
addToConfig: mockAddToConfig,
sessionId: 'test-session',
removeFromConfig: mockRemoveFromConfig,
extensionConfig: { ...mockExtensionConfig, name: 'new-extension' },
originalName: 'old-extension',
});
expect(mockRemoveFromConfig).toHaveBeenCalledWith('old-extension');
expect(mockAddToAgent).toHaveBeenCalledWith(
{ ...mockExtensionConfig, name: 'new-extension' },
'test-session',
false
);
expect(mockAddToConfig).toHaveBeenCalledWith(
'new-extension',
{ ...mockExtensionConfig, name: 'new-extension' },
true
);
});
it('should update disabled extension without calling agent', async () => {
mockAddToConfig.mockResolvedValue(undefined);
mockToastService.success = vi.fn();
await updateExtension({
enabled: false,
addToConfig: mockAddToConfig,
sessionId: 'test-session',
removeFromConfig: mockRemoveFromConfig,
extensionConfig: mockExtensionConfig,
originalName: 'test-extension',
});
expect(mockAddToAgent).not.toHaveBeenCalled();
expect(mockAddToConfig).toHaveBeenCalledWith(
'test-extension',
{ ...mockExtensionConfig, name: 'test-extension' },
false
);
expect(mockToastService.success).toHaveBeenCalledWith({
title: 'Update extension',
msg: 'Successfully updated test-extension extension',
});
});
});
describe('toggleExtension', () => {
it('should toggle extension on successfully', async () => {
mockAddToAgent.mockResolvedValue(undefined);
mockAddToConfig.mockResolvedValue(undefined);
await toggleExtension({
toggle: 'toggleOn',
extensionConfig: mockExtensionConfig,
addToConfig: mockAddToConfig,
sessionId: 'test-session',
});
expect(mockAddToAgent).toHaveBeenCalledWith(mockExtensionConfig, 'test-session', true);
expect(mockAddToConfig).toHaveBeenCalledWith('test-extension', mockExtensionConfig, true);
});
it('should toggle extension off successfully', async () => {
mockRemoveFromAgent.mockResolvedValue(undefined);
mockAddToConfig.mockResolvedValue(undefined);
await toggleExtension({
toggle: 'toggleOff',
extensionConfig: mockExtensionConfig,
addToConfig: mockAddToConfig,
sessionId: 'test-session',
});
expect(mockRemoveFromAgent).toHaveBeenCalledWith('test-extension', 'test-session', true);
expect(mockAddToConfig).toHaveBeenCalledWith('test-extension', mockExtensionConfig, false);
});
it('should rollback on agent failure when toggling on', async () => {
const agentError = new Error('Agent failed');
mockAddToAgent.mockRejectedValue(agentError);
mockAddToConfig.mockResolvedValue(undefined);
await expect(
toggleExtension({
toggle: 'toggleOn',
extensionConfig: mockExtensionConfig,
addToConfig: mockAddToConfig,
sessionId: 'test-session',
})
).rejects.toThrow('Agent failed');
expect(mockAddToAgent).toHaveBeenCalledWith(mockExtensionConfig, 'test-session', true);
// addToConfig is called during the rollback (toggleOff)
expect(mockAddToConfig).toHaveBeenCalledWith('test-extension', mockExtensionConfig, false);
});
it('should remove from agent if config update fails when toggling on', async () => {
const configError = new Error('Config failed');
mockAddToAgent.mockResolvedValue(undefined);
mockAddToConfig.mockRejectedValue(configError);
await expect(
toggleExtension({
toggle: 'toggleOn',
extensionConfig: mockExtensionConfig,
addToConfig: mockAddToConfig,
sessionId: 'test-session',
})
).rejects.toThrow('Config failed');
expect(mockAddToAgent).toHaveBeenCalledWith(mockExtensionConfig, 'test-session', true);
expect(mockAddToConfig).toHaveBeenCalledWith('test-extension', mockExtensionConfig, true);
expect(mockRemoveFromAgent).toHaveBeenCalledWith('test-extension', 'test-session', true);
});
it('should update config even if agent removal fails when toggling off', async () => {
const agentError = new Error('Agent removal failed');
mockRemoveFromAgent.mockRejectedValue(agentError);
mockAddToConfig.mockResolvedValue(undefined);
await expect(
toggleExtension({
toggle: 'toggleOff',
extensionConfig: mockExtensionConfig,
addToConfig: mockAddToConfig,
sessionId: 'test-session',
})
).rejects.toThrow('Agent removal failed');
expect(mockAddToConfig).toHaveBeenCalledWith('test-extension', mockExtensionConfig, false);
});
});
});
@@ -1,6 +1,5 @@
import type { ExtensionConfig } from '../../../api/types.gen';
import { toastService, ToastServiceOptions } from '../../../toasts';
import { addToAgent, removeFromAgent, sanitizeName } from './agent-api';
import { toastService } from '../../../toasts';
import {
trackExtensionAdded,
trackExtensionEnabled,
@@ -13,385 +12,97 @@ function isBuiltinExtension(config: ExtensionConfig): boolean {
return config.type === 'builtin';
}
type AddExtension = (name: string, config: ExtensionConfig, enabled: boolean) => Promise<void>;
type ExtensionError = {
message?: string;
code?: number;
name?: string;
stack?: string;
};
type RetryOptions = {
retries?: number;
delayMs?: number;
shouldRetry?: (error: ExtensionError, attempt: number) => boolean;
backoffFactor?: number; // multiplier for exponential backoff
};
async function retryWithBackoff<T>(fn: () => Promise<T>, options: RetryOptions = {}): Promise<T> {
const { retries = 3, delayMs = 1000, backoffFactor = 1.5, shouldRetry = () => true } = options;
let attempt = 0;
let lastError: ExtensionError = new Error('Unknown error');
while (attempt <= retries) {
try {
return await fn();
} catch (err) {
lastError = err as ExtensionError;
attempt++;
if (attempt > retries || !shouldRetry(lastError, attempt)) {
break;
}
const waitTime = delayMs * Math.pow(backoffFactor, attempt - 1);
console.warn(`Retry attempt ${attempt} failed. Retrying in ${waitTime}ms...`, err);
await new Promise((res) => setTimeout(res, waitTime));
}
}
throw lastError;
}
/**
* Activates an extension by adding it config and if a session is set, to the agent
*/
export async function activateExtension(
extensionConfig: ExtensionConfig,
addExtension: AddExtension,
sessionId?: string
) {
const isBuiltin = isBuiltinExtension(extensionConfig);
if (sessionId) {
try {
await addToAgent(extensionConfig, sessionId, true);
} catch (error) {
console.error('Failed to add extension to agent:', error);
await addExtension(extensionConfig.name, extensionConfig, false);
trackExtensionAdded(extensionConfig.name, false, getErrorType(error), isBuiltin);
throw error;
}
}
try {
await addExtension(extensionConfig.name, extensionConfig, true);
trackExtensionAdded(extensionConfig.name, true, undefined, isBuiltin);
} catch (error) {
console.error('Failed to add extension to config:', error);
if (sessionId) {
try {
await removeFromAgent(extensionConfig.name, sessionId, true);
} catch (removeError) {
console.error('Failed to remove extension from agent after config failure:', removeError);
}
}
trackExtensionAdded(extensionConfig.name, false, getErrorType(error), isBuiltin);
throw error;
}
}
interface AddToAgentOnStartupProps {
extensionConfig: ExtensionConfig;
toastOptions?: ToastServiceOptions;
sessionId: string;
}
/**
* Adds an extension to the agent during application startup with retry logic
*
* TODO(Douwe): Delete this after basecamp lands
*/
export async function addToAgentOnStartup({
extensionConfig,
sessionId,
toastOptions,
}: AddToAgentOnStartupProps): Promise<void> {
const showToast = !toastOptions?.silent;
// Errors are caught by the grouped notification in providerUtils.ts
// Individual error toasts are suppressed during startup (showToast=false)
await retryWithBackoff(() => addToAgent(extensionConfig, sessionId, showToast), {
retries: 3,
delayMs: 1000,
shouldRetry: (error: ExtensionError) =>
!!error.message &&
(error.message.includes('428') ||
error.message.includes('Precondition Required') ||
error.message.includes('Agent is not initialized')),
});
}
interface UpdateExtensionProps {
enabled: boolean;
addToConfig: (name: string, extensionConfig: ExtensionConfig, enabled: boolean) => Promise<void>;
removeFromConfig: (name: string) => Promise<void>;
extensionConfig: ExtensionConfig;
originalName?: string;
sessionId?: string;
}
/**
* Updates an extension configuration, handling name changes
*/
export async function updateExtension({
enabled,
addToConfig,
removeFromConfig,
extensionConfig,
originalName,
sessionId,
}: UpdateExtensionProps) {
// Sanitize the new name to match the behavior when adding extensions
const sanitizedNewName = sanitizeName(extensionConfig.name);
const sanitizedOriginalName = originalName ? sanitizeName(originalName) : undefined;
// Check if the sanitized name has changed
const nameChanged = sanitizedOriginalName && sanitizedOriginalName !== sanitizedNewName;
if (nameChanged) {
// Handle name change: remove old extension and add new one
// First remove the old extension from agent (using original name)
try {
if (sessionId) {
await removeFromAgent(originalName!, sessionId, false);
}
} catch (error) {
console.error('Failed to remove old extension from agent during rename:', error);
// Continue with the process even if agent removal fails
}
// Remove old extension from config (using original name)
try {
await removeFromConfig(originalName!); // We know originalName is not undefined here because nameChanged is true
} catch (error) {
console.error('Failed to remove old extension from config during rename:', error);
throw error; // This is more critical, so we throw
}
// Create a copy of the extension config with the sanitized name
const sanitizedExtensionConfig = {
...extensionConfig,
name: sanitizedNewName,
};
// Add new extension with sanitized name
if (enabled && sessionId) {
try {
await addToAgent(sanitizedExtensionConfig, sessionId, false);
} catch (error) {
console.error('[updateExtension]: Failed to add renamed extension to agent:', error);
throw error;
}
}
// Add to config with sanitized name
try {
await addToConfig(sanitizedNewName, sanitizedExtensionConfig, enabled);
} catch (error) {
console.error('[updateExtension]: Failed to add renamed extension to config:', error);
throw error;
}
toastService.configure({ silent: false });
toastService.success({
title: `Update extension`,
msg: `Successfully updated ${sanitizedNewName} extension`,
});
} else {
// Create a copy of the extension config with the sanitized name
const sanitizedExtensionConfig = {
...extensionConfig,
name: sanitizedNewName,
};
if (enabled && sessionId) {
try {
await addToAgent(sanitizedExtensionConfig, sessionId, false);
} catch (error) {
console.error('[updateExtension]: Failed to add extension to agent during update:', error);
// Failed to add to agent -- show that error to user and do not update the config file
throw error;
}
// Then add to config
try {
await addToConfig(sanitizedNewName, sanitizedExtensionConfig, enabled);
} catch (error) {
console.error('[updateExtension]: Failed to update extension in config:', error);
throw error;
}
// show a toast that it was successfully updated
toastService.success({
title: `Update extension`,
msg: `Successfully updated ${sanitizedNewName} extension`,
});
} else {
try {
await addToConfig(sanitizedNewName, sanitizedExtensionConfig, enabled);
} catch (error) {
console.error('[updateExtension]: Failed to update disabled extension in config:', error);
throw error;
}
// show a toast that it was successfully updated
toastService.success({
title: `Update extension`,
msg: `Successfully updated ${sanitizedNewName} extension`,
});
}
}
}
interface ToggleExtensionProps {
toggle: 'toggleOn' | 'toggleOff';
extensionConfig: ExtensionConfig;
addToConfig: (name: string, extensionConfig: ExtensionConfig, enabled: boolean) => Promise<void>;
toastOptions?: ToastServiceOptions;
sessionId?: string;
}
/**
* Toggles an extension between enabled and disabled states
*/
export async function toggleExtension({
toggle,
extensionConfig,
addToConfig,
toastOptions = {},
sessionId,
}: ToggleExtensionProps) {
const isBuiltin = isBuiltinExtension(extensionConfig);
// disabled to enabled
if (toggle == 'toggleOn') {
try {
// add to agent with toast options
if (sessionId) {
await addToAgent(extensionConfig, sessionId, !toastOptions?.silent);
}
} catch (error) {
console.error('Error adding extension to agent. Attempting to toggle back off.');
trackExtensionEnabled(extensionConfig.name, false, getErrorType(error), isBuiltin);
try {
await toggleExtension({
toggle: 'toggleOff',
extensionConfig,
addToConfig,
toastOptions: { silent: true }, // otherwise we will see a toast for removing something that was never added
sessionId,
});
} catch (toggleError) {
console.error('Failed to toggle extension off after agent error:', toggleError);
}
throw error;
}
// update the config
try {
await addToConfig(extensionConfig.name, extensionConfig, true);
trackExtensionEnabled(extensionConfig.name, true, undefined, isBuiltin);
} catch (error) {
console.error('Failed to update config after enabling extension:', error);
trackExtensionEnabled(extensionConfig.name, false, getErrorType(error), isBuiltin);
// remove from agent
try {
if (sessionId) {
await removeFromAgent(extensionConfig.name, sessionId, !toastOptions?.silent);
}
} catch (removeError) {
console.error('Failed to remove extension from agent after config failure:', removeError);
}
throw error;
}
} else if (toggle == 'toggleOff') {
// enabled to disabled
let agentRemoveError = null;
try {
if (sessionId) {
await removeFromAgent(extensionConfig.name, sessionId, !toastOptions?.silent);
}
} catch (error) {
// note there was an error, but attempt to remove from config anyway
console.error('Error removing extension from agent', extensionConfig.name, error);
agentRemoveError = error;
}
// update the config
try {
await addToConfig(extensionConfig.name, extensionConfig, false);
if (agentRemoveError) {
trackExtensionDisabled(
extensionConfig.name,
false,
getErrorType(agentRemoveError),
isBuiltin
);
} else {
trackExtensionDisabled(extensionConfig.name, true, undefined, isBuiltin);
}
} catch (error) {
console.error('Error removing extension from config', extensionConfig.name, 'Error:', error);
trackExtensionDisabled(extensionConfig.name, false, getErrorType(error), isBuiltin);
throw error;
}
// If we had an error removing from agent but succeeded updating config, still throw the original error
if (agentRemoveError) {
throw agentRemoveError;
}
}
}
interface DeleteExtensionProps {
name: string;
removeFromConfig: (name: string) => Promise<void>;
sessionId?: string;
extensionConfig?: ExtensionConfig;
}
/**
* Deletes an extension completely from both agent and config
* Deletes an extension from config (will no longer be loaded in new sessions)
*/
export async function deleteExtension({
name,
removeFromConfig,
sessionId,
extensionConfig,
}: DeleteExtensionProps) {
const isBuiltin = extensionConfig ? isBuiltinExtension(extensionConfig) : false;
let agentRemoveError = null;
try {
if (sessionId) {
await removeFromAgent(name, sessionId, true);
}
} catch (error) {
console.error('Failed to remove extension from agent during deletion:', error);
agentRemoveError = error;
}
try {
await removeFromConfig(name);
if (agentRemoveError) {
trackExtensionDeleted(name, false, getErrorType(agentRemoveError), isBuiltin);
} else {
trackExtensionDeleted(name, true, undefined, isBuiltin);
}
trackExtensionDeleted(name, true, undefined, isBuiltin);
} catch (error) {
console.error(
'Failed to remove extension from config after removing from agent. Error:',
error
);
console.error('Failed to remove extension from config:', error);
trackExtensionDeleted(name, false, getErrorType(error), isBuiltin);
throw error;
}
}
if (agentRemoveError) {
throw agentRemoveError;
interface ToggleExtensionDefaultProps {
toggle: 'toggleOn' | 'toggleOff';
extensionConfig: ExtensionConfig;
addToConfig: (name: string, extensionConfig: ExtensionConfig, enabled: boolean) => Promise<void>;
}
export async function toggleExtensionDefault({
toggle,
extensionConfig,
addToConfig,
}: ToggleExtensionDefaultProps) {
const isBuiltin = isBuiltinExtension(extensionConfig);
const enabled = toggle === 'toggleOn';
try {
await addToConfig(extensionConfig.name, extensionConfig, enabled);
if (enabled) {
trackExtensionEnabled(extensionConfig.name, true, undefined, isBuiltin);
} else {
trackExtensionDisabled(extensionConfig.name, true, undefined, isBuiltin);
}
toastService.success({
title: extensionConfig.name,
msg: enabled ? 'Extension enabled in defaults' : 'Extension removed from defaults',
});
} catch (error) {
console.error('Failed to update extension default in config:', error);
if (enabled) {
trackExtensionEnabled(extensionConfig.name, false, getErrorType(error), isBuiltin);
} else {
trackExtensionDisabled(extensionConfig.name, false, getErrorType(error), isBuiltin);
}
toastService.error({
title: extensionConfig.name,
msg: 'Failed to update extension default',
});
throw error;
}
}
interface ActivateExtensionDefaultProps {
addToConfig: (name: string, extensionConfig: ExtensionConfig, enabled: boolean) => Promise<void>;
extensionConfig: ExtensionConfig;
}
export async function activateExtensionDefault({
addToConfig,
extensionConfig,
}: ActivateExtensionDefaultProps): Promise<void> {
const isBuiltin = isBuiltinExtension(extensionConfig);
try {
await addToConfig(extensionConfig.name, extensionConfig, true);
trackExtensionAdded(extensionConfig.name, true, undefined, isBuiltin);
toastService.success({
title: extensionConfig.name,
msg: 'Extension added as default',
});
} catch (error) {
console.error('Failed to add extension to config:', error);
trackExtensionAdded(extensionConfig.name, false, getErrorType(error), isBuiltin);
toastService.error({
title: extensionConfig.name,
msg: 'Failed to add extension',
});
throw error;
}
}
@@ -1,20 +1,13 @@
// Export public API
export { DEFAULT_EXTENSION_TIMEOUT, nameToKey } from './utils';
// Export extension management functions
export {
activateExtension,
addToAgentOnStartup,
updateExtension,
toggleExtension,
activateExtensionDefault,
toggleExtensionDefault,
deleteExtension,
} from './extension-manager';
// Export built-in extension functions
export { syncBundledExtensions, initializeBundledExtensions } from './bundled-extensions';
// Export deeplink handling
export { addExtensionFromDeepLink } from './deeplink';
// Export agent API functions
export { addToAgent as AddToAgent, removeFromAgent as RemoveFromAgent } from './agent-api';
export { addToAgent, removeFromAgent } from './agent-api';
@@ -11,7 +11,6 @@ interface ExtensionItemProps {
onToggle: (extension: FixedExtensionEntry) => Promise<boolean | void> | void;
onConfigure?: (extension: FixedExtensionEntry) => void;
isStatic?: boolean; // to not allow users to edit configuration
isPendingActivation?: boolean;
}
export default function ExtensionItem({
@@ -19,7 +18,6 @@ export default function ExtensionItem({
onToggle,
onConfigure,
isStatic,
isPendingActivation = false,
}: ExtensionItemProps) {
// Add local state to track the visual toggle state
const [visuallyEnabled, setVisuallyEnabled] = useState(extension.enabled);
@@ -81,17 +79,7 @@ export default function ExtensionItem({
onClick={() => handleToggle(extension)}
>
<CardHeader>
<CardTitle className="flex items-center gap-2">
{getFriendlyTitle(extension)}
{isPendingActivation && (
<span
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400 border border-amber-300 dark:border-amber-700"
title="Extension will be activated when you start a new chat session"
>
Pending
</span>
)}
</CardTitle>
<CardTitle>{getFriendlyTitle(extension)}</CardTitle>
<CardAction onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-end gap-2">
@@ -11,7 +11,6 @@ interface ExtensionListProps {
isStatic?: boolean;
disableConfiguration?: boolean;
searchTerm?: string;
pendingActivationExtensions?: Set<string>;
}
export default function ExtensionList({
@@ -21,7 +20,6 @@ export default function ExtensionList({
isStatic,
disableConfiguration: _disableConfiguration,
searchTerm = '',
pendingActivationExtensions = new Set(),
}: ExtensionListProps) {
const matchesSearch = (extension: FixedExtensionEntry): boolean => {
if (!searchTerm) return true;
@@ -55,7 +53,7 @@ export default function ExtensionList({
<div>
<h2 className="text-lg font-medium text-text-default mb-4 flex items-center gap-2">
<span className="w-2 h-2 bg-green-500 rounded-full"></span>
Enabled Extensions ({sortedEnabledExtensions.length})
Default Extensions ({sortedEnabledExtensions.length})
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-2">
{sortedEnabledExtensions.map((extension) => (
@@ -65,7 +63,6 @@ export default function ExtensionList({
onToggle={onToggle}
onConfigure={onConfigure}
isStatic={isStatic}
isPendingActivation={pendingActivationExtensions.has(extension.name)}
/>
))}
</div>
@@ -100,14 +97,18 @@ export default function ExtensionList({
}
// Helper functions
export function getFriendlyTitle(extension: FixedExtensionEntry): string {
const name = (extension.type === 'builtin' && extension.display_name) || extension.name;
export function formatExtensionName(name: string): string {
return name
.split(/[-_]/) // Split on hyphens and underscores
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
export function getFriendlyTitle(extension: FixedExtensionEntry): string {
const name = (extension.type === 'builtin' && extension.display_name) || extension.name;
return formatExtensionName(name);
}
function normalizeExtensionName(name: string): string {
return name.toLowerCase().replace(/\s+/g, '');
}