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
+59 -72
View File
@@ -42,6 +42,7 @@ import { View, ViewOptions } from './utils/navigationUtils';
import { useNavigation } from './hooks/useNavigation';
import { errorMessage } from './utils/conversionUtils';
import { getInitialWorkingDir } from './utils/workingDir';
import { usePageViewTracking } from './hooks/useAnalytics';
import { trackOnboardingCompleted, trackErrorWithContext } from './utils/analytics';
@@ -53,82 +54,74 @@ function PageViewTracker() {
// Route Components
const HubRouteWrapper = () => {
const setView = useNavigation();
return <Hub setView={setView} />;
};
const PairRouteWrapper = ({
chat,
setChat,
activeSessionId,
setActiveSessionId,
}: {
chat: ChatType;
setChat: (chat: ChatType) => void;
activeSessionId: string | null;
setActiveSessionId: (id: string | null) => void;
}) => {
const { extensionsList } = useConfig();
const location = useLocation();
const routeState =
(location.state as PairRouteState) || (window.history.state as PairRouteState) || {};
const [searchParams, setSearchParams] = useSearchParams();
const navigate = useNavigate();
const routeState = (location.state as PairRouteState) || {};
const [searchParams] = useSearchParams();
const [isCreatingSession, setIsCreatingSession] = useState(false);
// Capture initialMessage in local state to survive route state being cleared by setSearchParams
// Capture initialMessage in local state to survive route state being cleared
const [capturedInitialMessage, setCapturedInitialMessage] = useState<string | undefined>(
undefined
);
const [lastSessionId, setLastSessionId] = useState<string | undefined>(undefined);
const [isCreatingSession, setIsCreatingSession] = useState(false);
const resumeSessionId = searchParams.get('resumeSessionId') ?? undefined;
const recipeId = searchParams.get('recipeId') ?? undefined;
const recipeDeeplinkFromConfig = window.appConfig?.get('recipeDeeplink') as string | undefined;
// Determine which session ID to use:
// 1. From route state (when navigating from Hub with a new session)
// 2. From URL params (when resuming a session or after refresh)
// 3. From active session state (when navigating back from other routes)
// 4. From the existing chat state
const sessionId =
routeState.resumeSessionId || resumeSessionId || activeSessionId || chat.sessionId;
// Session ID and initialMessage come from route state (Hub, fork) or URL params (refresh, deeplink)
const sessionIdFromState = routeState.resumeSessionId;
const sessionId = sessionIdFromState || resumeSessionId || chat.sessionId || undefined;
// Use route state if available, otherwise use captured state
const initialMessage = routeState.initialMessage || capturedInitialMessage;
// Capture initialMessage when it comes from route state
useEffect(() => {
console.log(
'[PairRouteWrapper] capture effect:',
JSON.stringify({
routeStateInitialMessage: routeState.initialMessage,
})
);
if (routeState.initialMessage) {
setCapturedInitialMessage(routeState.initialMessage);
}
}, [routeState.initialMessage]);
// Create session if we have an initialMessage, recipeId, or recipeDeeplink but no sessionId
useEffect(() => {
// Create a new session if we have an initialMessage, recipeId, or recipeDeeplink from config but no sessionId
if (
(initialMessage || recipeId || recipeDeeplinkFromConfig) &&
!sessionId &&
!isCreatingSession
) {
console.log(
'[PairRouteWrapper] Creating new session for initialMessage, recipeId, or recipeDeeplink from config'
);
setIsCreatingSession(true);
(async () => {
try {
const newSession = await createSession({
const newSession = await createSession(getInitialWorkingDir(), {
recipeId,
recipeDeeplink: recipeDeeplinkFromConfig,
allExtensions: extensionsList,
});
setSearchParams((prev) => {
prev.set('resumeSessionId', newSession.id);
// Remove recipeId from URL after session is created
prev.delete('recipeId');
return prev;
navigate(`/pair?resumeSessionId=${newSession.id}`, {
replace: true,
state: { resumeSessionId: newSession.id, initialMessage },
});
setActiveSessionId(newSession.id);
} catch (error) {
console.error('[PairRouteWrapper] Failed to create session:', error);
console.error('Failed to create session:', error);
trackErrorWithContext(error, {
component: 'PairRouteWrapper',
action: 'create_session',
@@ -145,39 +138,38 @@ const PairRouteWrapper = ({
recipeDeeplinkFromConfig,
sessionId,
isCreatingSession,
setSearchParams,
setActiveSessionId,
extensionsList,
navigate,
]);
// Clear captured initialMessage when sessionId actually changes to a different session
useEffect(() => {
if (sessionId !== lastSessionId) {
setLastSessionId(sessionId);
if (!routeState.initialMessage) {
setCapturedInitialMessage(undefined);
}
}
}, [sessionId, lastSessionId, routeState.initialMessage]);
// Update URL with session ID when on /pair route (for refresh support)
// Sync URL with session ID for refresh support (only if not already in URL)
useEffect(() => {
if (sessionId && sessionId !== resumeSessionId) {
setSearchParams((prev) => {
prev.set('resumeSessionId', sessionId);
return prev;
navigate(`/pair?resumeSessionId=${sessionId}`, {
replace: true,
state: { resumeSessionId: sessionIdFromState, initialMessage },
});
}
}, [sessionId, resumeSessionId, setSearchParams]);
}, [sessionId, resumeSessionId, navigate, sessionIdFromState, initialMessage]);
// Update active session state when session ID changes
// Clear captured initialMessage when session changes (to prevent re-sending on navigation)
useEffect(() => {
if (sessionId && sessionId !== activeSessionId) {
setActiveSessionId(sessionId);
if (sessionId && capturedInitialMessage && sessionIdFromState) {
const timer = setTimeout(() => {
setCapturedInitialMessage(undefined);
}, 100);
return () => clearTimeout(timer);
}
}, [sessionId, activeSessionId, setActiveSessionId]);
return undefined;
}, [sessionId, capturedInitialMessage, sessionIdFromState]);
return (
<Pair key={sessionId} setChat={setChat} sessionId={sessionId} initialMessage={initialMessage} />
<Pair
key={sessionId}
setChat={setChat}
sessionId={sessionId ?? ''}
initialMessage={initialMessage}
/>
);
};
@@ -377,9 +369,6 @@ export function AppInner() {
recipe: null,
});
// Store the active session ID for navigation persistence
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const { addExtension } = useConfig();
useEffect(() => {
@@ -436,9 +425,7 @@ export function AppInner() {
if ((isMac ? event.metaKey : event.ctrlKey) && event.key === 'n') {
event.preventDefault();
try {
const workingDir = window.appConfig?.get('GOOSE_WORKING_DIR');
console.log(`Creating new chat window with working dir: ${workingDir}`);
window.electron.createChatWindow(undefined, workingDir as string);
window.electron.createChatWindow(undefined, getInitialWorkingDir());
} catch (error) {
console.error('Error creating new window:', error);
}
@@ -541,11 +528,21 @@ export function AppInner() {
// Handle initial message from launcher
useEffect(() => {
const handleSetInitialMessage = (_event: IpcRendererEvent, ...args: unknown[]) => {
const handleSetInitialMessage = async (_event: IpcRendererEvent, ...args: unknown[]) => {
const initialMessage = args[0] as string;
if (initialMessage) {
console.log('Received initial message from launcher:', initialMessage);
navigate('/pair', { state: { initialMessage } });
try {
const session = await createSession(getInitialWorkingDir(), {});
navigate('/pair', {
state: {
initialMessage,
resumeSessionId: session.id,
},
});
} catch (error) {
console.error('Failed to create session for launcher message:', error);
}
}
};
window.electron.on('set-initial-message', handleSetInitialMessage);
@@ -597,17 +594,7 @@ export function AppInner() {
}
>
<Route index element={<HubRouteWrapper />} />
<Route
path="pair"
element={
<PairRouteWrapper
chat={chat}
setChat={setChat}
activeSessionId={activeSessionId}
setActiveSessionId={setActiveSessionId}
/>
}
/>
<Route path="pair" element={<PairRouteWrapper chat={chat} setChat={setChat} />} />
<Route path="settings" element={<SettingsRoute />} />
<Route
path="extensions"
+21 -1
View File
@@ -2,7 +2,7 @@
import type { Client, Options as Options2, TDataShape } from './client';
import { client } from './client.gen';
import type { AddExtensionData, AddExtensionErrors, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponses, BackupConfigData, BackupConfigErrors, BackupConfigResponses, CallToolData, CallToolErrors, CallToolResponses, CheckProviderData, ConfirmToolActionData, ConfirmToolActionErrors, ConfirmToolActionResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DetectProviderData, DetectProviderErrors, DetectProviderResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponses, EditMessageData, EditMessageErrors, EditMessageResponses, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponses, GetPricingData, GetPricingResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponses, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsResponses, GetTunnelStatusData, GetTunnelStatusResponses, ImportSessionData, ImportSessionErrors, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponses, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponses, KillRunningJobData, KillRunningJobResponses, ListRecipesData, ListRecipesErrors, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponses, McpUiProxyData, McpUiProxyErrors, McpUiProxyResponses, ParseRecipeData, ParseRecipeErrors, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponses, ProvidersData, ProvidersResponses, ReadAllConfigData, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, ReadResourceData, ReadResourceErrors, ReadResourceResponses, RecipeToYamlData, RecipeToYamlErrors, RecipeToYamlResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponses, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponses, SaveRecipeData, SaveRecipeErrors, SaveRecipeResponses, ScanRecipeData, ScanRecipeResponses, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeResponses, SendTelemetryEventData, SendTelemetryEventResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponses, SetConfigProviderData, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, StartAgentData, StartAgentErrors, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, StartTunnelData, StartTunnelErrors, StartTunnelResponses, StatusData, StatusResponses, StopTunnelData, StopTunnelErrors, StopTunnelResponses, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponses } from './types.gen';
import type { AddExtensionData, AddExtensionErrors, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponses, BackupConfigData, BackupConfigErrors, BackupConfigResponses, CallToolData, CallToolErrors, CallToolResponses, CheckProviderData, ConfirmToolActionData, ConfirmToolActionErrors, ConfirmToolActionResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DetectProviderData, DetectProviderErrors, DetectProviderResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponses, EditMessageData, EditMessageErrors, EditMessageResponses, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponses, GetPricingData, GetPricingResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionExtensionsData, GetSessionExtensionsErrors, GetSessionExtensionsResponses, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponses, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsResponses, GetTunnelStatusData, GetTunnelStatusResponses, ImportSessionData, ImportSessionErrors, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponses, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponses, KillRunningJobData, KillRunningJobResponses, ListRecipesData, ListRecipesErrors, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponses, McpUiProxyData, McpUiProxyErrors, McpUiProxyResponses, ParseRecipeData, ParseRecipeErrors, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponses, ProvidersData, ProvidersResponses, ReadAllConfigData, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, ReadResourceData, ReadResourceErrors, ReadResourceResponses, RecipeToYamlData, RecipeToYamlErrors, RecipeToYamlResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponses, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponses, RestartAgentData, RestartAgentErrors, RestartAgentResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponses, SaveRecipeData, SaveRecipeErrors, SaveRecipeResponses, ScanRecipeData, ScanRecipeResponses, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeResponses, SendTelemetryEventData, SendTelemetryEventResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponses, SetConfigProviderData, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, StartAgentData, StartAgentErrors, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, StartTunnelData, StartTunnelErrors, StartTunnelResponses, StatusData, StatusResponses, StopTunnelData, StopTunnelErrors, StopTunnelResponses, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpdateWorkingDirData, UpdateWorkingDirErrors, UpdateWorkingDirResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponses } from './types.gen';
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & {
/**
@@ -63,6 +63,15 @@ export const agentRemoveExtension = <ThrowOnError extends boolean = false>(optio
}
});
export const restartAgent = <ThrowOnError extends boolean = false>(options: Options<RestartAgentData, ThrowOnError>) => (options.client ?? client).post<RestartAgentResponses, RestartAgentErrors, ThrowOnError>({
url: '/agent/restart',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
export const resumeAgent = <ThrowOnError extends boolean = false>(options: Options<ResumeAgentData, ThrowOnError>) => (options.client ?? client).post<ResumeAgentResponses, ResumeAgentErrors, ThrowOnError>({
url: '/agent/resume',
...options,
@@ -101,6 +110,15 @@ export const updateAgentProvider = <ThrowOnError extends boolean = false>(option
}
});
export const updateWorkingDir = <ThrowOnError extends boolean = false>(options: Options<UpdateWorkingDirData, ThrowOnError>) => (options.client ?? client).post<UpdateWorkingDirResponses, UpdateWorkingDirErrors, ThrowOnError>({
url: '/agent/update_working_dir',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
export const readAllConfig = <ThrowOnError extends boolean = false>(options?: Options<ReadAllConfigData, ThrowOnError>) => (options?.client ?? client).get<ReadAllConfigResponses, unknown, ThrowOnError>({ url: '/config', ...options });
export const backupConfig = <ThrowOnError extends boolean = false>(options?: Options<BackupConfigData, ThrowOnError>) => (options?.client ?? client).post<BackupConfigResponses, BackupConfigErrors, ThrowOnError>({ url: '/config/backup', ...options });
@@ -395,6 +413,8 @@ export const editMessage = <ThrowOnError extends boolean = false>(options: Optio
export const exportSession = <ThrowOnError extends boolean = false>(options: Options<ExportSessionData, ThrowOnError>) => (options.client ?? client).get<ExportSessionResponses, ExportSessionErrors, ThrowOnError>({ url: '/sessions/{session_id}/export', ...options });
export const getSessionExtensions = <ThrowOnError extends boolean = false>(options: Options<GetSessionExtensionsData, ThrowOnError>) => (options.client ?? client).get<GetSessionExtensionsResponses, GetSessionExtensionsErrors, ThrowOnError>({ url: '/sessions/{session_id}/extensions', ...options });
export const updateSessionName = <ThrowOnError extends boolean = false>(options: Options<UpdateSessionNameData, ThrowOnError>) => (options.client ?? client).put<UpdateSessionNameResponses, UpdateSessionNameErrors, ThrowOnError>({
url: '/sessions/{session_id}/name',
...options,
+131 -2
View File
@@ -335,6 +335,12 @@ export type ExtensionEntry = ExtensionConfig & {
enabled: boolean;
};
export type ExtensionLoadResult = {
error?: string | null;
name: string;
success: boolean;
};
export type ExtensionQuery = {
config: ExtensionConfig;
enabled: boolean;
@@ -776,11 +782,24 @@ export type Response = {
json_schema?: unknown;
};
export type RestartAgentRequest = {
session_id: string;
};
export type RestartAgentResponse = {
extension_results: Array<ExtensionLoadResult>;
};
export type ResumeAgentRequest = {
load_model_and_extensions: boolean;
session_id: string;
};
export type ResumeAgentResponse = {
extension_results?: Array<ExtensionLoadResult> | null;
session: Session;
};
/**
* Configuration for retry logic in recipe execution
*/
@@ -887,6 +906,10 @@ export type SessionDisplayInfo = {
workingDir: string;
};
export type SessionExtensionsResponse = {
extensions: Array<ExtensionConfig>;
};
export type SessionInsights = {
totalSessions: number;
totalTokens: number;
@@ -937,6 +960,7 @@ export type SlashCommandsResponse = {
};
export type StartAgentRequest = {
extension_overrides?: Array<ExtensionConfig> | null;
recipe?: Recipe | null;
recipe_deeplink?: string | null;
recipe_id?: string | null;
@@ -1144,6 +1168,11 @@ export type UpdateSessionUserRecipeValuesResponse = {
recipe: Recipe;
};
export type UpdateWorkingDirRequest = {
session_id: string;
working_dir: string;
};
export type UpsertConfigQuery = {
is_secret: boolean;
key: string;
@@ -1311,6 +1340,37 @@ export type AgentRemoveExtensionResponses = {
export type AgentRemoveExtensionResponse = AgentRemoveExtensionResponses[keyof AgentRemoveExtensionResponses];
export type RestartAgentData = {
body: RestartAgentRequest;
path?: never;
query?: never;
url: '/agent/restart';
};
export type RestartAgentErrors = {
/**
* Unauthorized - invalid secret key
*/
401: unknown;
/**
* Session not found
*/
404: unknown;
/**
* Internal server error
*/
500: unknown;
};
export type RestartAgentResponses = {
/**
* Agent restarted successfully
*/
200: RestartAgentResponse;
};
export type RestartAgentResponse2 = RestartAgentResponses[keyof RestartAgentResponses];
export type ResumeAgentData = {
body: ResumeAgentRequest;
path?: never;
@@ -1337,10 +1397,10 @@ export type ResumeAgentResponses = {
/**
* Agent started successfully
*/
200: Session;
200: ResumeAgentResponse;
};
export type ResumeAgentResponse = ResumeAgentResponses[keyof ResumeAgentResponses];
export type ResumeAgentResponse2 = ResumeAgentResponses[keyof ResumeAgentResponses];
export type StartAgentData = {
body: StartAgentRequest;
@@ -1473,6 +1533,39 @@ export type UpdateAgentProviderResponses = {
200: unknown;
};
export type UpdateWorkingDirData = {
body: UpdateWorkingDirRequest;
path?: never;
query?: never;
url: '/agent/update_working_dir';
};
export type UpdateWorkingDirErrors = {
/**
* Bad request - invalid directory path
*/
400: unknown;
/**
* Unauthorized - invalid secret key
*/
401: unknown;
/**
* Session not found
*/
404: unknown;
/**
* Internal server error
*/
500: unknown;
};
export type UpdateWorkingDirResponses = {
/**
* Working directory updated and agent restarted successfully
*/
200: unknown;
};
export type ReadAllConfigData = {
body?: never;
path?: never;
@@ -2916,6 +3009,42 @@ export type ExportSessionResponses = {
export type ExportSessionResponse = ExportSessionResponses[keyof ExportSessionResponses];
export type GetSessionExtensionsData = {
body?: never;
path: {
/**
* Unique identifier for the session
*/
session_id: string;
};
query?: never;
url: '/sessions/{session_id}/extensions';
};
export type GetSessionExtensionsErrors = {
/**
* Unauthorized - Invalid or missing API key
*/
401: unknown;
/**
* Session not found
*/
404: unknown;
/**
* Internal server error
*/
500: unknown;
};
export type GetSessionExtensionsResponses = {
/**
* Session extensions retrieved successfully
*/
200: SessionExtensionsResponse;
};
export type GetSessionExtensionsResponse = GetSessionExtensionsResponses[keyof GetSessionExtensionsResponses];
export type UpdateSessionNameData = {
body: UpdateSessionNameRequest;
path: {
+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, '');
}
+17 -9
View File
@@ -20,6 +20,7 @@ import {
NotificationEvent,
} from '../types/message';
import { errorMessage } from '../utils/conversionUtils';
import { showExtensionLoadResults } from '../utils/extensionErrorUtils';
const resultsCache = new Map<string, { messages: Message[]; session: Session }>();
@@ -33,6 +34,7 @@ interface UseChatStreamReturn {
session?: Session;
messages: Message[];
chatState: ChatState;
setChatState: (state: ChatState) => void;
handleSubmit: (userMessage: string) => Promise<void>;
submitElicitationResponse: (
elicitationId: string,
@@ -221,6 +223,7 @@ export function useChatStream({
accumulatedTotalTokens: cached.session?.accumulated_total_tokens ?? 0,
});
setChatState(ChatState.Idle);
onSessionLoaded?.();
return;
}
@@ -246,16 +249,20 @@ export function useChatStream({
return;
}
const session = response.data;
setSession(session);
updateMessages(session?.conversation || []);
const resumeData = response.data;
const loadedSession = resumeData?.session;
const extensionResults = resumeData?.extension_results;
showExtensionLoadResults(extensionResults);
setSession(loadedSession);
updateMessages(loadedSession?.conversation || []);
setTokenState({
inputTokens: session?.input_tokens ?? 0,
outputTokens: session?.output_tokens ?? 0,
totalTokens: session?.total_tokens ?? 0,
accumulatedInputTokens: session?.accumulated_input_tokens ?? 0,
accumulatedOutputTokens: session?.accumulated_output_tokens ?? 0,
accumulatedTotalTokens: session?.accumulated_total_tokens ?? 0,
inputTokens: loadedSession?.input_tokens ?? 0,
outputTokens: loadedSession?.output_tokens ?? 0,
totalTokens: loadedSession?.total_tokens ?? 0,
accumulatedInputTokens: loadedSession?.accumulated_input_tokens ?? 0,
accumulatedOutputTokens: loadedSession?.accumulated_output_tokens ?? 0,
accumulatedTotalTokens: loadedSession?.accumulated_total_tokens ?? 0,
});
setChatState(ChatState.Idle);
onSessionLoaded?.();
@@ -507,6 +514,7 @@ export function useChatStream({
messages: maybe_cached_messages,
session: maybe_cached_session,
chatState,
setChatState,
handleSubmit,
submitElicitationResponse,
stopStreaming,
+11 -3
View File
@@ -1189,9 +1189,17 @@ ipcMain.handle('open-external', async (_event, url: string) => {
}
});
// Handle directory chooser
ipcMain.handle('directory-chooser', (_event) => {
return openDirectoryDialog();
ipcMain.handle('directory-chooser', async () => {
return dialog.showOpenDialog({
properties: ['openDirectory', 'createDirectory'],
defaultPath: os.homedir(),
});
});
ipcMain.handle('add-recent-dir', (_event, dir: string) => {
if (dir) {
addRecentDir(dir);
}
});
// Handle scheduling engine settings
+3 -1
View File
@@ -61,7 +61,7 @@ type ElectronAPI = {
reactReady: () => void;
getConfig: () => Record<string, unknown>;
hideWindow: () => void;
directoryChooser: (replace?: boolean) => Promise<Electron.OpenDialogReturnValue>;
directoryChooser: () => Promise<Electron.OpenDialogReturnValue>;
createChatWindow: (
query?: string,
dir?: string,
@@ -134,6 +134,7 @@ type ElectronAPI = {
hasAcceptedRecipeBefore: (recipe: Recipe) => Promise<boolean>;
recordRecipeHash: (recipe: Recipe) => Promise<boolean>;
openDirectoryInExplorer: (directoryPath: string) => Promise<boolean>;
addRecentDir: (dir: string) => Promise<boolean>;
};
type AppConfigAPI = {
@@ -270,6 +271,7 @@ const electronAPI: ElectronAPI = {
recordRecipeHash: (recipe: Recipe) => ipcRenderer.invoke('record-recipe-hash', recipe),
openDirectoryInExplorer: (directoryPath: string) =>
ipcRenderer.invoke('open-directory-in-explorer', directoryPath),
addRecentDir: (dir: string) => ipcRenderer.invoke('add-recent-dir', dir),
};
const appConfigAPI: AppConfigAPI = {
+34 -7
View File
@@ -1,5 +1,11 @@
import { Session, startAgent } from './api';
import { Session, startAgent, ExtensionConfig } from './api';
import type { setViewType } from './hooks/useNavigation';
import {
getExtensionConfigsWithOverrides,
clearExtensionOverrides,
hasExtensionOverrides,
} from './store/extensionOverrides';
import type { FixedExtensionEntry } from './components/ConfigContext';
export function resumeSession(session: Session, setView: setViewType) {
setView('pair', {
@@ -8,16 +14,22 @@ export function resumeSession(session: Session, setView: setViewType) {
});
}
export async function createSession(options?: {
recipeId?: string;
recipeDeeplink?: string;
}): Promise<Session> {
export async function createSession(
workingDir: string,
options?: {
recipeId?: string;
recipeDeeplink?: string;
extensionConfigs?: ExtensionConfig[];
allExtensions?: FixedExtensionEntry[];
}
): Promise<Session> {
const body: {
working_dir: string;
recipe_id?: string;
recipe_deeplink?: string;
extension_overrides?: ExtensionConfig[];
} = {
working_dir: window.appConfig.get('GOOSE_WORKING_DIR') as string,
working_dir: workingDir,
};
if (options?.recipeId) {
@@ -26,22 +38,37 @@ export async function createSession(options?: {
body.recipe_deeplink = options.recipeDeeplink;
}
if (options?.extensionConfigs && options.extensionConfigs.length > 0) {
body.extension_overrides = options.extensionConfigs;
} else if (options?.allExtensions) {
const extensionConfigs = getExtensionConfigsWithOverrides(options.allExtensions);
if (extensionConfigs.length > 0) {
body.extension_overrides = extensionConfigs;
}
if (hasExtensionOverrides()) {
clearExtensionOverrides();
}
}
const newAgent = await startAgent({
body,
throwOnError: true,
});
return newAgent.data;
}
export async function startNewSession(
workingDir: string,
initialText: string | undefined,
setView: setViewType,
options?: {
recipeId?: string;
recipeDeeplink?: string;
allExtensions?: FixedExtensionEntry[];
}
): Promise<Session> {
const session = await createSession(options);
const session = await createSession(workingDir, options);
setView('pair', {
disableAnimation: true,
@@ -0,0 +1,59 @@
// Store for extension overrides when starting a new session from the hub
// These overrides allow temporarily enabling/disabling extensions before creating a session
// Resets after session creation
import type { ExtensionConfig } from '../api';
// Map of extension name -> enabled state (overrides from hub view)
type ExtensionOverrides = Map<string, boolean>;
const state: {
extensionOverrides: ExtensionOverrides;
} = {
extensionOverrides: new Map(),
};
export function setExtensionOverride(name: string, enabled: boolean): void {
state.extensionOverrides.set(name, enabled);
}
export function getExtensionOverride(name: string): boolean | undefined {
return state.extensionOverrides.get(name);
}
export function hasExtensionOverrides(): boolean {
return state.extensionOverrides.size > 0;
}
export function getExtensionOverrides(): ExtensionOverrides {
return state.extensionOverrides;
}
export function clearExtensionOverrides(): void {
state.extensionOverrides.clear();
}
export function getExtensionConfigsWithOverrides(
allExtensions: Array<{ name: string; enabled: boolean } & Omit<ExtensionConfig, 'name'>>
): ExtensionConfig[] {
if (state.extensionOverrides.size === 0) {
return allExtensions
.filter((ext) => ext.enabled)
.map((ext) => {
const { enabled: _enabled, ...config } = ext;
return config as ExtensionConfig;
});
}
return allExtensions
.filter((ext) => {
if (state.extensionOverrides.has(ext.name)) {
return state.extensionOverrides.get(ext.name);
}
return ext.enabled;
})
.map((ext) => {
const { enabled: _enabled, ...config } = ext;
return config as ExtensionConfig;
});
}
+5 -2
View File
@@ -8,6 +8,7 @@ import {
GroupedExtensionLoadingToast,
ExtensionLoadingStatus,
} from './components/GroupedExtensionLoadingToast';
import { getInitialWorkingDir } from './utils/workingDir';
export interface ToastServiceOptions {
silent?: boolean;
@@ -109,7 +110,7 @@ class ToastService {
{
...commonToastOptions,
toastId,
autoClose: false,
autoClose: isComplete ? 5000 : false,
closeButton: true,
closeOnClick: false, // Prevent closing when clicking to expand/collapse
}
@@ -195,7 +196,9 @@ function ToastErrorContent({
</div>
<div className="flex-none flex items-center gap-2">
{showRecovery && (
<Button onClick={() => startNewSession(recoverHints, setView)}>Ask goose</Button>
<Button onClick={() => startNewSession(getInitialWorkingDir(), recoverHints, setView)}>
Ask goose
</Button>
)}
{hasBoth && (
<Tooltip>
+1
View File
@@ -5,4 +5,5 @@ export enum ChatState {
WaitingForUserInput = 'waitingForUserInput',
Compacting = 'compacting',
LoadingConversation = 'loadingConversation',
RestartingAgent = 'restartingAgent',
}
@@ -2,6 +2,9 @@
* Shared constants and utilities for extension error handling
*/
import { ExtensionLoadResult } from '../api/types.gen';
import { toastService, ExtensionLoadingStatus } from '../toasts';
export const MAX_ERROR_MESSAGE_LENGTH = 70;
/**
@@ -28,3 +31,43 @@ export function formatExtensionErrorMessage(
): string {
return errorMsg.length < MAX_ERROR_MESSAGE_LENGTH ? errorMsg : fallback;
}
/**
* Shows toast notifications for extension load results.
* Uses grouped toast for multiple extensions, individual error toast for single failed extension.
* @param results - Array of extension load results from the backend
*/
export function showExtensionLoadResults(results: ExtensionLoadResult[] | null | undefined): void {
if (!results || results.length === 0) {
return;
}
const failedExtensions = results.filter((r) => !r.success);
if (results.length === 1 && failedExtensions.length === 1) {
const failed = failedExtensions[0];
const errorMsg = failed.error || 'Unknown error';
const recoverHints = createExtensionRecoverHints(errorMsg);
const displayMsg = formatExtensionErrorMessage(errorMsg, 'Failed to load extension');
toastService.error({
title: failed.name,
msg: displayMsg,
traceback: errorMsg,
recoverHints,
});
return;
}
const extensionStatuses: ExtensionLoadingStatus[] = results.map((r) => {
const errorMsg = r.error || 'Unknown error';
return {
name: r.name,
status: r.success ? 'success' : 'error',
error: r.success ? undefined : errorMsg,
recoverHints: r.success ? undefined : createExtensionRecoverHints(errorMsg),
};
});
toastService.extensionLoading(extensionStatuses, results.length, true);
}
-3
View File
@@ -19,9 +19,7 @@ export type View =
| 'recipes'
| 'permission';
// TODO(Douwe): check these for usage, especially key: string for resetChat
export type ViewOptions = {
extensionId?: string;
showEnvVars?: boolean;
deepLinkConfig?: unknown;
sessionDetails?: unknown;
@@ -32,7 +30,6 @@ export type ViewOptions = {
parentViewOptions?: ViewOptions;
disableAnimation?: boolean;
initialMessage?: string;
resetChat?: boolean;
shareToken?: string;
resumeSessionId?: string;
pendingScheduleDeepLink?: string;
-89
View File
@@ -1,13 +1,9 @@
import {
initializeBundledExtensions,
syncBundledExtensions,
addToAgentOnStartup,
} from '../components/settings/extensions';
import type { ExtensionConfig, FixedExtensionEntry } from '../components/ConfigContext';
import { Recipe, updateAgentProvider, updateFromSession } from '../api';
import { toastService, ExtensionLoadingStatus } from '../toasts';
import { errorMessage } from './conversionUtils';
import { createExtensionRecoverHints } from './extensionErrorUtils';
// Helper function to substitute parameters in text
export const substituteParameters = (text: string, params: Record<string, string>): string => {
@@ -29,7 +25,6 @@ export const initializeSystem = async (
options?: {
getExtensions?: (b: boolean) => Promise<FixedExtensionEntry[]>;
addExtension?: (name: string, config: ExtensionConfig, enabled: boolean) => Promise<void>;
setIsExtensionsLoading?: (loading: boolean) => void;
recipeParameters?: Record<string, string> | null;
recipe?: Recipe;
}
@@ -72,95 +67,11 @@ export const initializeSystem = async (
if (refreshedExtensions.length === 0) {
await initializeBundledExtensions(options.addExtension);
refreshedExtensions = await options.getExtensions(false);
} else {
await syncBundledExtensions(refreshedExtensions, options.addExtension);
}
// Add enabled extensions to agent in parallel
const enabledExtensions = refreshedExtensions.filter((ext) => ext.enabled);
if (enabledExtensions.length === 0) {
return;
}
options?.setIsExtensionsLoading?.(true);
// Initialize extension status tracking
const extensionStatuses: Map<string, ExtensionLoadingStatus> = new Map(
enabledExtensions.map((ext) => [ext.name, { name: ext.name, status: 'loading' as const }])
);
// Show initial loading toast
const updateToast = (isComplete: boolean = false) => {
toastService.extensionLoading(
Array.from(extensionStatuses.values()),
enabledExtensions.length,
isComplete
);
};
updateToast();
// Load extensions in parallel and update status
const extensionLoadingPromises = enabledExtensions.map(async (extensionConfig) => {
const extensionName = extensionConfig.name;
// SSE is unsupported - fail immediately without calling the backend
if (extensionConfig.type === 'sse') {
const errMsg = 'SSE is unsupported, migrate to streamable_http';
extensionStatuses.set(extensionName, {
name: extensionName,
status: 'error',
error: errMsg,
recoverHints: createExtensionRecoverHints(errMsg),
});
updateToast();
return;
}
try {
await addToAgentOnStartup({
extensionConfig,
toastOptions: { silent: true }, // Silent since we're using grouped notification
sessionId,
});
// Update status to success
extensionStatuses.set(extensionName, {
name: extensionName,
status: 'success',
});
updateToast();
} catch (error) {
console.error(`Failed to load extension ${extensionName}:`, error);
// Extract error message using shared utility
const errMsg = errorMessage(error);
// Create recovery hints for "Ask goose" button
const recoverHints = createExtensionRecoverHints(errMsg);
// Update status to error
extensionStatuses.set(extensionName, {
name: extensionName,
status: 'error',
error: errMsg,
recoverHints,
});
updateToast();
}
});
await Promise.allSettled(extensionLoadingPromises);
// Show final completion toast
updateToast(true);
options?.setIsExtensionsLoading?.(false);
} catch (error) {
console.error('Failed to initialize agent:', error);
options?.setIsExtensionsLoading?.(false);
throw error;
}
};
+3
View File
@@ -0,0 +1,3 @@
export const getInitialWorkingDir = (): string => {
return (window.appConfig?.get('GOOSE_WORKING_DIR') as string) || '';
};