Add support for changing working dir and extensions in same window/session (#6057)
This commit is contained in:
+59
-72
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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, '');
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export const getInitialWorkingDir = (): string => {
|
||||
return (window.appConfig?.get('GOOSE_WORKING_DIR') as string) || '';
|
||||
};
|
||||
Reference in New Issue
Block a user