From cd0946f0a95437ac2bec6fbe6449ae5c808ccff8 Mon Sep 17 00:00:00 2001
From: Zane Staggs
Date: Thu, 11 Dec 2025 10:06:52 -0800
Subject: [PATCH 01/24] bringing over extensions
---
crates/goose-server/src/openapi.rs | 2 +
crates/goose-server/src/routes/agent.rs | 65 ++++++++---
crates/goose-server/src/routes/session.rs | 46 +++++++-
ui/desktop/openapi.json | 66 +++++++++++
ui/desktop/src/api/sdk.gen.ts | 4 +-
ui/desktop/src/api/types.gen.ts | 41 +++++++
ui/desktop/src/components/Hub.tsx | 14 ++-
.../BottomMenuExtensionSelection.tsx | 110 ++++++++++++++++--
ui/desktop/src/sessions.ts | 24 +++-
ui/desktop/src/store/newChatState.ts | 64 +++++++++-
10 files changed, 397 insertions(+), 39 deletions(-)
diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs
index 51f850e68f1a..122b41dbc617 100644
--- a/crates/goose-server/src/openapi.rs
+++ b/crates/goose-server/src/openapi.rs
@@ -372,6 +372,7 @@ derive_utoipa!(Icon as IconSchema);
super::routes::session::import_session,
super::routes::session::update_session_user_recipe_values,
super::routes::session::edit_message,
+ super::routes::session::get_session_extensions,
super::routes::schedule::create_schedule,
super::routes::schedule::list_schedules,
super::routes::schedule::delete_schedule,
@@ -427,6 +428,7 @@ derive_utoipa!(Icon as IconSchema);
super::routes::session::EditType,
super::routes::session::EditMessageRequest,
super::routes::session::EditMessageResponse,
+ super::routes::session::SessionExtensionsResponse,
Message,
MessageContent,
MessageMetadata,
diff --git a/crates/goose-server/src/routes/agent.rs b/crates/goose-server/src/routes/agent.rs
index ae4b5321c785..ce43c2b77537 100644
--- a/crates/goose-server/src/routes/agent.rs
+++ b/crates/goose-server/src/routes/agent.rs
@@ -19,8 +19,9 @@ use goose::prompt_template::render_global_file;
use goose::providers::create;
use goose::recipe::Recipe;
use goose::recipe_deeplink;
+use goose::session::extension_data::ExtensionState;
use goose::session::session_manager::SessionType;
-use goose::session::{Session, SessionManager};
+use goose::session::{EnabledExtensionsState, Session, SessionManager};
use goose::{
agents::{extension::ToolInfo, extension_manager::get_parameter_names},
config::permission::PermissionLevel,
@@ -65,6 +66,8 @@ pub struct StartAgentRequest {
recipe_id: Option,
#[serde(default)]
recipe_deeplink: Option,
+ #[serde(default)]
+ extension_overrides: Option>,
}
#[derive(Deserialize, utoipa::ToSchema)]
@@ -117,6 +120,7 @@ async fn start_agent(
recipe,
recipe_id,
recipe_deeplink,
+ extension_overrides,
} = payload;
let original_recipe = if let Some(deeplink) = recipe_deeplink {
@@ -162,30 +166,52 @@ async fn start_agent(
}
})?;
- if let Some(recipe) = original_recipe {
+ // Initialize session with extensions (either overrides from hub or global defaults)
+ let extensions_to_use =
+ extension_overrides.unwrap_or_else(goose::config::get_enabled_extensions);
+ let mut extension_data = session.extension_data.clone();
+ let extensions_state = EnabledExtensionsState::new(extensions_to_use);
+ if let Err(e) = extensions_state.to_extension_data(&mut extension_data) {
+ warn!("Failed to initialize session with extensions: {}", e);
+ } else {
SessionManager::update_session(&session.id)
- .recipe(Some(recipe))
+ .extension_data(extension_data.clone())
.apply()
.await
.map_err(|err| {
- error!("Failed to update session with recipe: {}", err);
+ error!("Failed to save initial extension state: {}", err);
ErrorResponse {
- message: format!("Failed to update session with recipe: {}", err),
+ message: format!("Failed to save initial extension state: {}", err),
status: StatusCode::INTERNAL_SERVER_ERROR,
}
})?;
+ }
- session = SessionManager::get_session(&session.id, false)
+ if let Some(recipe) = original_recipe {
+ SessionManager::update_session(&session.id)
+ .recipe(Some(recipe))
+ .apply()
.await
.map_err(|err| {
- error!("Failed to get updated session: {}", err);
+ error!("Failed to update session with recipe: {}", err);
ErrorResponse {
- message: format!("Failed to get updated session: {}", err),
+ message: format!("Failed to update session with recipe: {}", err),
status: StatusCode::INTERNAL_SERVER_ERROR,
}
})?;
}
+ // Refetch session to get all updates
+ session = SessionManager::get_session(&session.id, false)
+ .await
+ .map_err(|err| {
+ error!("Failed to get updated session: {}", err);
+ ErrorResponse {
+ message: format!("Failed to get updated session: {}", err),
+ status: StatusCode::INTERNAL_SERVER_ERROR,
+ }
+ })?;
+
Ok(Json(session))
}
@@ -269,7 +295,12 @@ async fn resume_agent(
};
let extensions_result = async {
- let enabled_configs = goose::config::get_enabled_extensions();
+ // Try to load session-specific extensions first, fall back to global config
+ let enabled_configs =
+ EnabledExtensionsState::from_extension_data(&session.extension_data)
+ .map(|state| state.extensions)
+ .unwrap_or_else(goose::config::get_enabled_extensions);
+
let agent_clone = agent.clone();
let extension_futures = enabled_configs
@@ -288,7 +319,7 @@ async fn resume_agent(
.collect::>();
futures::future::join_all(extension_futures).await;
- Ok::<(), ErrorResponse>(()) // Fixed type annotation
+ Ok::<(), ErrorResponse>(())
};
let (provider_result, _) = tokio::join!(provider_result, extensions_result);
@@ -616,15 +647,19 @@ async fn restore_agent_provider(
async fn restore_agent_extensions(
agent: Arc,
- working_dir: &std::path::Path,
+ session: &Session,
) -> Result<(), ErrorResponse> {
tracing::info!(
"Setting GOOSE_WORKING_DIR environment variable to: {:?}",
- working_dir
+ session.working_dir
);
- std::env::set_var("GOOSE_WORKING_DIR", working_dir);
+ std::env::set_var("GOOSE_WORKING_DIR", &session.working_dir);
+
+ // Try to load session-specific extensions first, fall back to global config
+ let enabled_configs = EnabledExtensionsState::from_extension_data(&session.extension_data)
+ .map(|state| state.extensions)
+ .unwrap_or_else(goose::config::get_enabled_extensions);
- let enabled_configs = goose::config::get_enabled_extensions();
let extension_futures = enabled_configs
.into_iter()
.map(|config| {
@@ -700,7 +735,7 @@ async fn restart_agent(
tracing::info!("New agent created successfully");
let provider_result = restore_agent_provider(&agent, &session, &session_id);
- let extensions_result = restore_agent_extensions(agent.clone(), &session.working_dir);
+ let extensions_result = restore_agent_extensions(agent.clone(), &session);
let (provider_result, extensions_result) = tokio::join!(provider_result, extensions_result);
provider_result?;
diff --git a/crates/goose-server/src/routes/session.rs b/crates/goose-server/src/routes/session.rs
index 4a850b896ff9..7f25702a4f49 100644
--- a/crates/goose-server/src/routes/session.rs
+++ b/crates/goose-server/src/routes/session.rs
@@ -9,9 +9,11 @@ use axum::{
routing::{delete, get, put},
Json, Router,
};
+use goose::agents::ExtensionConfig;
use goose::recipe::Recipe;
+use goose::session::extension_data::ExtensionState;
use goose::session::session_manager::SessionInsights;
-use goose::session::{Session, SessionManager};
+use goose::session::{EnabledExtensionsState, Session, SessionManager};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
@@ -492,6 +494,44 @@ async fn edit_message(
}
}
+#[derive(Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct SessionExtensionsResponse {
+ extensions: Vec,
+}
+
+#[utoipa::path(
+ get,
+ path = "/sessions/{session_id}/extensions",
+ params(
+ ("session_id" = String, Path, description = "Unique identifier for the session")
+ ),
+ responses(
+ (status = 200, description = "Session extensions retrieved successfully", body = SessionExtensionsResponse),
+ (status = 401, description = "Unauthorized - Invalid or missing API key"),
+ (status = 404, description = "Session not found"),
+ (status = 500, description = "Internal server error")
+ ),
+ security(
+ ("api_key" = [])
+ ),
+ tag = "Session Management"
+)]
+async fn get_session_extensions(
+ Path(session_id): Path,
+) -> Result, StatusCode> {
+ let session = SessionManager::get_session(&session_id, false)
+ .await
+ .map_err(|_| StatusCode::NOT_FOUND)?;
+
+ // Try to get session-specific extensions, fall back to global config
+ let extensions = EnabledExtensionsState::from_extension_data(&session.extension_data)
+ .map(|state| state.extensions)
+ .unwrap_or_else(goose::config::get_enabled_extensions);
+
+ Ok(Json(SessionExtensionsResponse { extensions }))
+}
+
pub fn routes(state: Arc) -> Router {
Router::new()
.route("/sessions", get(list_sessions))
@@ -510,5 +550,9 @@ pub fn routes(state: Arc) -> Router {
put(update_session_user_recipe_values),
)
.route("/sessions/{session_id}/edit_message", post(edit_message))
+ .route(
+ "/sessions/{session_id}/extensions",
+ get(get_session_extensions),
+ )
.with_state(state)
}
diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json
index 7cc78281b243..edc3f5021c68 100644
--- a/ui/desktop/openapi.json
+++ b/ui/desktop/openapi.json
@@ -2189,6 +2189,51 @@
]
}
},
+ "/sessions/{session_id}/extensions": {
+ "get": {
+ "tags": [
+ "Session Management"
+ ],
+ "operationId": "get_session_extensions",
+ "parameters": [
+ {
+ "name": "session_id",
+ "in": "path",
+ "description": "Unique identifier for the session",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Session extensions retrieved successfully",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/SessionExtensionsResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - Invalid or missing API key"
+ },
+ "404": {
+ "description": "Session not found"
+ },
+ "500": {
+ "description": "Internal server error"
+ }
+ },
+ "security": [
+ {
+ "api_key": []
+ }
+ ]
+ }
+ },
"/sessions/{session_id}/name": {
"put": {
"tags": [
@@ -4987,6 +5032,20 @@
}
}
},
+ "SessionExtensionsResponse": {
+ "type": "object",
+ "required": [
+ "extensions"
+ ],
+ "properties": {
+ "extensions": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/ExtensionConfig"
+ }
+ }
+ }
+ },
"SessionInsights": {
"type": "object",
"required": [
@@ -5143,6 +5202,13 @@
"working_dir"
],
"properties": {
+ "extension_overrides": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/ExtensionConfig"
+ },
+ "nullable": true
+ },
"recipe": {
"allOf": [
{
diff --git a/ui/desktop/src/api/sdk.gen.ts b/ui/desktop/src/api/sdk.gen.ts
index a9c503863273..162e7262bfac 100644
--- a/ui/desktop/src/api/sdk.gen.ts
+++ b/ui/desktop/src/api/sdk.gen.ts
@@ -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, 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, 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, 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, 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, UpdateRouterToolSelectorData, UpdateRouterToolSelectorErrors, UpdateRouterToolSelectorResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpdateSessionWorkingDirData, UpdateSessionWorkingDirErrors, UpdateSessionWorkingDirResponses, 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, 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, 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, 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, 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, UpdateRouterToolSelectorData, UpdateRouterToolSelectorErrors, UpdateRouterToolSelectorResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpdateSessionWorkingDirData, UpdateSessionWorkingDirErrors, UpdateSessionWorkingDirResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponses } from './types.gen';
export type Options = Options2 & {
/**
@@ -377,6 +377,8 @@ export const editMessage = (options: Optio
export const exportSession = (options: Options) => (options.client ?? client).get({ url: '/sessions/{session_id}/export', ...options });
+export const getSessionExtensions = (options: Options) => (options.client ?? client).get({ url: '/sessions/{session_id}/extensions', ...options });
+
export const updateSessionName = (options: Options) => (options.client ?? client).put({
url: '/sessions/{session_id}/name',
...options,
diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts
index 91dbecc1748c..bea1d1039779 100644
--- a/ui/desktop/src/api/types.gen.ts
+++ b/ui/desktop/src/api/types.gen.ts
@@ -785,6 +785,10 @@ export type SessionDisplayInfo = {
workingDir: string;
};
+export type SessionExtensionsResponse = {
+ extensions: Array;
+};
+
export type SessionInsights = {
totalSessions: number;
totalTokens: number;
@@ -835,6 +839,7 @@ export type SlashCommandsResponse = {
};
export type StartAgentRequest = {
+ extension_overrides?: Array | null;
recipe?: Recipe | null;
recipe_deeplink?: string | null;
recipe_id?: string | null;
@@ -2744,6 +2749,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: {
diff --git a/ui/desktop/src/components/Hub.tsx b/ui/desktop/src/components/Hub.tsx
index 0f354ddd6801..e8fee4ae136d 100644
--- a/ui/desktop/src/components/Hub.tsx
+++ b/ui/desktop/src/components/Hub.tsx
@@ -19,7 +19,8 @@ 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 { createSession } from '../sessions';
+import { useConfig } from './ConfigContext';
export default function Hub({
setView,
@@ -28,12 +29,21 @@ export default function Hub({
setView: (view: View, viewOptions?: ViewOptions) => void;
isExtensionsLoading: boolean;
}) {
+ const { extensionsList } = useConfig();
+
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);
+ const session = await createSession({ allExtensions: extensionsList });
+
+ setView('pair', {
+ disableAnimation: true,
+ initialMessage: combinedTextFromInput,
+ resumeSessionId: session.id,
+ });
+
e.preventDefault();
}
};
diff --git a/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx b/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx
index 05b0b11b782f..7a2e6e763c87 100644
--- a/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx
+++ b/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx
@@ -1,12 +1,18 @@
-import { useCallback, useMemo, useState } from 'react';
+import { useCallback, useEffect, useMemo, useState } 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 { ExtensionConfig, getSessionExtensions } from '../../api';
+import { addToAgent, removeFromAgent } from '../settings/extensions/agent-api';
+import {
+ setExtensionOverride,
+ getExtensionOverride,
+ getExtensionOverrides,
+} from '../../store/newChatState';
interface BottomMenuExtensionSelectionProps {
sessionId: string;
@@ -15,10 +21,52 @@ interface BottomMenuExtensionSelectionProps {
export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionSelectionProps) => {
const [searchQuery, setSearchQuery] = useState('');
const [isOpen, setIsOpen] = useState(false);
- const { extensionsList, addExtension } = useConfig();
+ const [sessionExtensions, setSessionExtensions] = useState([]);
+ const [hubUpdateTrigger, setHubUpdateTrigger] = useState(0); // Force re-render for hub updates
+ const { extensionsList: allExtensions } = useConfig();
+ const isHubView = !sessionId; // True when in hub/new chat view
+
+ // Fetch session-specific extensions or use global defaults
+ useEffect(() => {
+ const fetchExtensions = async () => {
+ if (!sessionId) {
+ // In hub view, don't fetch, we'll use global + overrides
+ 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]); // Refetch when dropdown opens
const handleToggle = useCallback(
async (extensionConfig: FixedExtensionEntry) => {
+ if (isHubView) {
+ // In hub view, just track the override locally using newChatState
+ const currentState = getExtensionOverride(extensionConfig.name) ?? extensionConfig.enabled;
+ setExtensionOverride(extensionConfig.name, !currentState);
+
+ // Force re-render by incrementing the trigger
+ setHubUpdateTrigger((prev) => prev + 1);
+
+ toastService.success({
+ title: 'Extension Updated',
+ msg: `${extensionConfig.name} will be ${!currentState ? 'enabled' : 'disabled'} in new chats`,
+ });
+ return;
+ }
+
if (!sessionId) {
toastService.error({
title: 'Extension Toggle Error',
@@ -29,15 +77,22 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS
}
try {
- const toggleDirection = extensionConfig.enabled ? 'toggleOff' : 'toggleOn';
-
- await toggleExtension({
- toggle: toggleDirection,
- extensionConfig: extensionConfig,
- addToConfig: addExtension,
- toastOptions: { silent: false },
- sessionId: sessionId,
+ if (extensionConfig.enabled) {
+ // Disable extension - only in session, not global config
+ await removeFromAgent(extensionConfig.name, sessionId, true);
+ } else {
+ // Enable extension - only in session, not global config
+ await addToAgent(extensionConfig, sessionId, true);
+ }
+
+ // Refetch extensions after toggle
+ const response = await getSessionExtensions({
+ path: { session_id: sessionId },
});
+
+ if (response.data?.extensions) {
+ setSessionExtensions(response.data.extensions);
+ }
} catch (error) {
toastService.error({
title: 'Extension Error',
@@ -46,9 +101,37 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS
});
}
},
- [sessionId, addExtension]
+ [sessionId, isHubView]
);
+ // Merge all available extensions with session-specific or hub override state
+ const extensionsList = useMemo(() => {
+ const hubOverrides = getExtensionOverrides();
+
+ if (isHubView) {
+ // In hub view, show global extension states with local overrides
+ return allExtensions.map(
+ (ext) =>
+ ({
+ ...ext,
+ enabled: hubOverrides.has(ext.name) ? hubOverrides.get(ext.name)! : ext.enabled,
+ }) as FixedExtensionEntry
+ );
+ }
+
+ // In session view, show session-specific states
+ 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();
@@ -115,6 +198,9 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS
className="h-8 text-sm"
autoFocus
/>
+
+ {isHubView ? 'Extensions for new chats' : 'Extensions for this chat session'}
+
{sortedExtensions.length === 0 ? (
diff --git a/ui/desktop/src/sessions.ts b/ui/desktop/src/sessions.ts
index f2c1943ae495..7a68678200f7 100644
--- a/ui/desktop/src/sessions.ts
+++ b/ui/desktop/src/sessions.ts
@@ -1,6 +1,12 @@
-import { Session, startAgent, restartAgent } from './api';
+import { Session, startAgent, restartAgent, ExtensionConfig } from './api';
import type { setViewType } from './hooks/useNavigation';
-import { getWorkingDir } from './store/newChatState';
+import {
+ getWorkingDir,
+ getExtensionConfigsWithOverrides,
+ clearExtensionOverrides,
+ hasExtensionOverrides,
+} from './store/newChatState';
+import type { FixedExtensionEntry } from './components/ConfigContext';
export function resumeSession(session: Session, setView: setViewType) {
setView('pair', {
@@ -12,16 +18,18 @@ export function resumeSession(session: Session, setView: setViewType) {
export async function createSession(options?: {
recipeId?: string;
recipeDeeplink?: string;
+ allExtensions?: FixedExtensionEntry[];
}): Promise
{
const body: {
working_dir: string;
recipe_id?: string;
recipe_deeplink?: string;
+ extension_overrides?: ExtensionConfig[];
} = {
working_dir: getWorkingDir(),
};
- // Note: We intentionally don't clear newChatState here
+ // Note: We intentionally don't clear workingDir from newChatState here
// so that new sessions in the same window continue to use the last selected directory
if (options?.recipeId) {
@@ -30,6 +38,16 @@ export async function createSession(options?: {
body.recipe_deeplink = options.recipeDeeplink;
}
+ // Get extension configs with any overrides applied
+ if (options?.allExtensions && hasExtensionOverrides()) {
+ const extensionConfigs = getExtensionConfigsWithOverrides(options.allExtensions);
+ if (extensionConfigs.length > 0) {
+ body.extension_overrides = extensionConfigs;
+ }
+ // Clear the overrides after using them
+ clearExtensionOverrides();
+ }
+
const newAgent = await startAgent({
body,
throwOnError: true,
diff --git a/ui/desktop/src/store/newChatState.ts b/ui/desktop/src/store/newChatState.ts
index dc7c461e7f0e..1f3a4865f0e1 100644
--- a/ui/desktop/src/store/newChatState.ts
+++ b/ui/desktop/src/store/newChatState.ts
@@ -2,16 +2,19 @@
// Acts as a cache that can be updated from UI or synced from session
// Resets on page refresh - defaults to window.appConfig.get('GOOSE_WORKING_DIR')
+import type { ExtensionConfig } from '../api';
+
+// Map of extension name -> enabled state (overrides from hub view)
+type ExtensionOverrides = Map;
+
interface NewChatState {
workingDir: string | null;
- // Future additions:
- // extensions?: string[];
- // provider?: string;
- // model?: string;
+ extensionOverrides: ExtensionOverrides;
}
const state: NewChatState = {
workingDir: null,
+ extensionOverrides: new Map(),
};
export function setWorkingDir(dir: string): void {
@@ -26,6 +29,57 @@ export function clearWorkingDir(): void {
state.workingDir = null;
}
+// Extension override functions
+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();
+}
+
+// Get extension configs with overrides applied
+export function getExtensionConfigsWithOverrides(
+ allExtensions: Array<{ name: string; enabled: boolean } & Omit>
+): ExtensionConfig[] {
+ if (state.extensionOverrides.size === 0) {
+ // No overrides, return global enabled extensions
+ return allExtensions
+ .filter((ext) => ext.enabled)
+ .map((ext) => {
+ const { enabled: _enabled, ...config } = ext;
+ return config as ExtensionConfig;
+ });
+ }
+
+ // Apply overrides
+ return allExtensions
+ .filter((ext) => {
+ // Check if we have an override for this extension
+ if (state.extensionOverrides.has(ext.name)) {
+ return state.extensionOverrides.get(ext.name);
+ }
+ // Otherwise use the global enabled state
+ return ext.enabled;
+ })
+ .map((ext) => {
+ const { enabled: _enabled, ...config } = ext;
+ return config as ExtensionConfig;
+ });
+}
+
// Generic getters/setters for future extensibility
export function getNewChatState(): Readonly {
return { ...state };
@@ -33,5 +87,5 @@ export function getNewChatState(): Readonly {
export function resetNewChatState(): void {
state.workingDir = null;
- // Reset future fields here
+ state.extensionOverrides.clear();
}
From 605bd555567ff51ede9c696343a9776b8185e1b8 Mon Sep 17 00:00:00 2001
From: Zane Staggs
Date: Thu, 11 Dec 2025 14:31:12 -0800
Subject: [PATCH 02/24] add working_dir to moim
---
crates/goose/src/agents/agent.rs | 1 +
crates/goose/src/agents/extension_manager.rs | 8 ++++++--
crates/goose/src/agents/moim.rs | 16 ++++++++++++----
3 files changed, 19 insertions(+), 6 deletions(-)
diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs
index a1edb82d27f2..248c4d21d59e 100644
--- a/crates/goose/src/agents/agent.rs
+++ b/crates/goose/src/agents/agent.rs
@@ -999,6 +999,7 @@ impl Agent {
let conversation_with_moim = super::moim::inject_moim(
conversation.clone(),
&self.extension_manager,
+ &working_dir,
).await;
let mut stream = Self::stream_response_from_provider(
diff --git a/crates/goose/src/agents/extension_manager.rs b/crates/goose/src/agents/extension_manager.rs
index d8d7341eee19..7dfc7b749ae9 100644
--- a/crates/goose/src/agents/extension_manager.rs
+++ b/crates/goose/src/agents/extension_manager.rs
@@ -1223,9 +1223,13 @@ impl ExtensionManager {
.map(|ext| ext.get_client())
}
- pub async fn collect_moim(&self) -> Option {
+ pub async fn collect_moim(&self, working_dir: &std::path::Path) -> Option {
let timestamp = chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
- let mut content = format!("\nDatetime: {}\n", timestamp);
+ let mut content = format!(
+ "\nDatetime: {}\nWorking directory: {}\n",
+ timestamp,
+ working_dir.display()
+ );
let extensions = self.extensions.lock().await;
for (name, extension) in extensions.iter() {
diff --git a/crates/goose/src/agents/moim.rs b/crates/goose/src/agents/moim.rs
index d920f3b7dc74..886087cb25e8 100644
--- a/crates/goose/src/agents/moim.rs
+++ b/crates/goose/src/agents/moim.rs
@@ -2,6 +2,7 @@ use crate::agents::extension_manager::ExtensionManager;
use crate::conversation::message::Message;
use crate::conversation::{fix_conversation, Conversation};
use rmcp::model::Role;
+use std::path::Path;
// Test-only utility. Do not use in production code. No `test` directive due to call outside crate.
thread_local! {
@@ -11,12 +12,13 @@ thread_local! {
pub async fn inject_moim(
conversation: Conversation,
extension_manager: &ExtensionManager,
+ working_dir: &Path,
) -> Conversation {
if SKIP.with(|f| f.get()) {
return conversation;
}
- if let Some(moim) = extension_manager.collect_moim().await {
+ if let Some(moim) = extension_manager.collect_moim(working_dir).await {
let mut messages = conversation.messages().clone();
let idx = messages
.iter()
@@ -45,17 +47,19 @@ pub async fn inject_moim(
mod tests {
use super::*;
use rmcp::model::CallToolRequestParam;
+ use std::path::PathBuf;
#[tokio::test]
async fn test_moim_injection_before_assistant() {
let em = ExtensionManager::new_without_provider();
+ let working_dir = PathBuf::from("/test/dir");
let conv = Conversation::new_unvalidated(vec![
Message::user().with_text("Hello"),
Message::assistant().with_text("Hi"),
Message::user().with_text("Bye"),
]);
- let result = inject_moim(conv, &em).await;
+ let result = inject_moim(conv, &em, &working_dir).await;
let msgs = result.messages();
assert_eq!(msgs.len(), 3);
@@ -70,14 +74,16 @@ mod tests {
.join("");
assert!(merged_content.contains("Hello"));
assert!(merged_content.contains(""));
+ assert!(merged_content.contains("Working directory: /test/dir"));
}
#[tokio::test]
async fn test_moim_injection_no_assistant() {
let em = ExtensionManager::new_without_provider();
+ let working_dir = PathBuf::from("/test/dir");
let conv = Conversation::new_unvalidated(vec![Message::user().with_text("Hello")]);
- let result = inject_moim(conv, &em).await;
+ let result = inject_moim(conv, &em, &working_dir).await;
assert_eq!(result.messages().len(), 1);
@@ -89,11 +95,13 @@ mod tests {
.join("");
assert!(merged_content.contains("Hello"));
assert!(merged_content.contains(""));
+ assert!(merged_content.contains("Working directory: /test/dir"));
}
#[tokio::test]
async fn test_moim_with_tool_calls() {
let em = ExtensionManager::new_without_provider();
+ let working_dir = PathBuf::from("/test/dir");
let conv = Conversation::new_unvalidated(vec![
Message::user().with_text("Search for something"),
@@ -119,7 +127,7 @@ mod tests {
Message::user().with_tool_response("search_2", Ok(vec![])),
]);
- let result = inject_moim(conv, &em).await;
+ let result = inject_moim(conv, &em, &working_dir).await;
let msgs = result.messages();
assert_eq!(msgs.len(), 6);
From e8946ccda542d9f441d0d7a072016891a52eda4b Mon Sep 17 00:00:00 2001
From: Zane Staggs
Date: Thu, 11 Dec 2025 15:58:20 -0800
Subject: [PATCH 03/24] Remove ALPHA flag from extensions switcher in bottom
menu
---
ui/desktop/src/components/ChatInput.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx
index 239a6ed71b94..b2427ef09778 100644
--- a/ui/desktop/src/components/ChatInput.tsx
+++ b/ui/desktop/src/components/ChatInput.tsx
@@ -1523,7 +1523,7 @@ export default function ChatInput({
- {sessionId && process.env.ALPHA && (
+ {sessionId && (
<>
From b444271a78bd0a182f5656c6f4b13d65dfc78ef3 Mon Sep 17 00:00:00 2001
From: Zane Staggs
Date: Thu, 11 Dec 2025 15:59:40 -0800
Subject: [PATCH 04/24] Show extensions switcher in hub view for selecting
extensions before starting chat
---
ui/desktop/src/components/ChatInput.tsx | 8 ++------
.../bottom_menu/BottomMenuExtensionSelection.tsx | 2 +-
2 files changed, 3 insertions(+), 7 deletions(-)
diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx
index b2427ef09778..de3b0301c16e 100644
--- a/ui/desktop/src/components/ChatInput.tsx
+++ b/ui/desktop/src/components/ChatInput.tsx
@@ -1523,12 +1523,8 @@ export default function ChatInput({
- {sessionId && (
- <>
-
-
- >
- )}
+
+
{sessionId && messages.length > 0 && (
<>
diff --git a/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx b/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx
index 7a2e6e763c87..70056200aa82 100644
--- a/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx
+++ b/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx
@@ -15,7 +15,7 @@ import {
} from '../../store/newChatState';
interface BottomMenuExtensionSelectionProps {
- sessionId: string;
+ sessionId: string | null;
}
export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionSelectionProps) => {
From 38e37225af8431d60c54681c57fcfa2329235e4b Mon Sep 17 00:00:00 2001
From: Zane Staggs
Date: Mon, 15 Dec 2025 11:04:07 -0800
Subject: [PATCH 05/24] regenerate ts
---
ui/desktop/src/api/sdk.gen.ts | 20 +++++++++++++++++++-
1 file changed, 19 insertions(+), 1 deletion(-)
diff --git a/ui/desktop/src/api/sdk.gen.ts b/ui/desktop/src/api/sdk.gen.ts
index 162e7262bfac..670432463ac3 100644
--- a/ui/desktop/src/api/sdk.gen.ts
+++ b/ui/desktop/src/api/sdk.gen.ts
@@ -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, 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, 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, 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, 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, UpdateRouterToolSelectorData, UpdateRouterToolSelectorErrors, UpdateRouterToolSelectorResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpdateSessionWorkingDirData, UpdateSessionWorkingDirErrors, UpdateSessionWorkingDirResponses, 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, 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, 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, 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, UpdateRouterToolSelectorData, UpdateRouterToolSelectorErrors, UpdateRouterToolSelectorResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpdateSessionWorkingDirData, UpdateSessionWorkingDirErrors, UpdateSessionWorkingDirResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponses } from './types.gen';
export type Options = Options2 & {
/**
@@ -36,6 +36,24 @@ export const agentAddExtension = (options:
}
});
+export const callTool = (options: Options) => (options.client ?? client).post({
+ url: '/agent/call_tool',
+ ...options,
+ headers: {
+ 'Content-Type': 'application/json',
+ ...options.headers
+ }
+});
+
+export const readResource = (options: Options) => (options.client ?? client).post({
+ url: '/agent/read_resource',
+ ...options,
+ headers: {
+ 'Content-Type': 'application/json',
+ ...options.headers
+ }
+});
+
export const agentRemoveExtension = (options: Options) => (options.client ?? client).post({
url: '/agent/remove_extension',
...options,
From ff2b582c82853eaaa9a634a02dcfe716ad2c25bd Mon Sep 17 00:00:00 2001
From: Zane Staggs
Date: Mon, 15 Dec 2025 13:32:52 -0800
Subject: [PATCH 06/24] add resuming session loads session-specific extensions
correctly
---
crates/goose-server/src/routes/agent.rs | 58 +++++++++++++++++++++++--
1 file changed, 55 insertions(+), 3 deletions(-)
diff --git a/crates/goose-server/src/routes/agent.rs b/crates/goose-server/src/routes/agent.rs
index ce56af06514d..ea91fd6a1ad1 100644
--- a/crates/goose-server/src/routes/agent.rs
+++ b/crates/goose-server/src/routes/agent.rs
@@ -557,11 +557,15 @@ async fn agent_add_extension(
}
})?;
- let agent = state.get_agent(request.session_id).await?;
+ let agent = state.get_agent(request.session_id.clone()).await?;
agent
- .add_extension(request.config, Some(session.working_dir))
+ .add_extension(request.config, Some(session.working_dir.clone()))
.await
.map_err(|e| ErrorResponse::internal(format!("Failed to add extension: {}", e)))?;
+
+ // Persist the updated extension state to the session
+ persist_session_extensions(&agent, &request.session_id, &session).await?;
+
Ok(StatusCode::OK)
}
@@ -580,8 +584,22 @@ async fn agent_remove_extension(
State(state): State>,
Json(request): Json,
) -> Result {
- let agent = state.get_agent(request.session_id).await?;
+ let session = SessionManager::get_session(&request.session_id, false)
+ .await
+ .map_err(|err| {
+ error!("Failed to get session for remove_extension: {}", err);
+ ErrorResponse {
+ message: format!("Failed to get session: {}", err),
+ status: StatusCode::NOT_FOUND,
+ }
+ })?;
+
+ let agent = state.get_agent(request.session_id.clone()).await?;
agent.remove_extension(&request.name).await?;
+
+ // Persist the updated extension state to the session
+ persist_session_extensions(&agent, &request.session_id, &session).await?;
+
Ok(StatusCode::OK)
}
@@ -613,6 +631,40 @@ async fn stop_agent(
Ok(StatusCode::OK)
}
+async fn persist_session_extensions(
+ agent: &Arc,
+ session_id: &str,
+ session: &Session,
+) -> Result<(), ErrorResponse> {
+ let current_extensions = agent.extension_manager.get_extension_configs().await;
+ let extensions_state = EnabledExtensionsState::new(current_extensions);
+
+ let mut extension_data = session.extension_data.clone();
+ extensions_state
+ .to_extension_data(&mut extension_data)
+ .map_err(|e| {
+ error!("Failed to serialize extension state: {}", e);
+ ErrorResponse {
+ message: format!("Failed to serialize extension state: {}", e),
+ status: StatusCode::INTERNAL_SERVER_ERROR,
+ }
+ })?;
+
+ SessionManager::update_session(session_id)
+ .extension_data(extension_data)
+ .apply()
+ .await
+ .map_err(|e| {
+ error!("Failed to persist extension state: {}", e);
+ ErrorResponse {
+ message: format!("Failed to persist extension state: {}", e),
+ status: StatusCode::INTERNAL_SERVER_ERROR,
+ }
+ })?;
+
+ Ok(())
+}
+
async fn restore_agent_provider(
agent: &Arc,
session: &Session,
From 079f480034eedf440f616a3cae6076af6fa87a59 Mon Sep 17 00:00:00 2001
From: Zane Staggs
Date: Tue, 16 Dec 2025 14:43:46 -0800
Subject: [PATCH 07/24] add subtle transition when toggling extensions and
update default text
---
.../BottomMenuExtensionSelection.tsx | 101 +++++++++++-------
.../components/extensions/ExtensionsView.tsx | 6 +-
.../components/sessions/SessionListView.tsx | 53 +++++++++
.../subcomponents/ExtensionList.tsx | 2 +-
4 files changed, 124 insertions(+), 38 deletions(-)
diff --git a/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx b/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx
index 9b6af8fddcce..27bcbe2d8651 100644
--- a/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx
+++ b/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, 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';
@@ -22,15 +22,26 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS
const [searchQuery, setSearchQuery] = useState('');
const [isOpen, setIsOpen] = useState(false);
const [sessionExtensions, setSessionExtensions] = useState([]);
- const [hubUpdateTrigger, setHubUpdateTrigger] = useState(0); // Force re-render for hub updates
+ const [hubUpdateTrigger, setHubUpdateTrigger] = useState(0);
+ const [isTransitioning, setIsTransitioning] = useState(false);
+ const [pendingSort, setPendingSort] = useState(false);
+ const sortTimeoutRef = useRef | null>(null);
const { extensionsList: allExtensions } = useConfig();
- const isHubView = !sessionId; // True when in hub/new chat view
+ const isHubView = !sessionId;
+
+ // Cleanup timeout on unmount
+ useEffect(() => {
+ return () => {
+ if (sortTimeoutRef.current) {
+ clearTimeout(sortTimeoutRef.current);
+ }
+ };
+ }, []);
// Fetch session-specific extensions or use global defaults
useEffect(() => {
const fetchExtensions = async () => {
if (!sessionId) {
- // In hub view, don't fetch, we'll use global + overrides
return;
}
@@ -48,17 +59,31 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS
};
fetchExtensions();
- }, [sessionId, isOpen]); // Refetch when dropdown opens
+ }, [sessionId, isOpen]);
const handleToggle = useCallback(
async (extensionConfig: FixedExtensionEntry) => {
+ // Start transition animation
+ setIsTransitioning(true);
+
if (isHubView) {
- // In hub view, just track the override locally using extensionOverrides
const currentState = getExtensionOverride(extensionConfig.name) ?? extensionConfig.enabled;
setExtensionOverride(extensionConfig.name, !currentState);
- // Force re-render by incrementing the trigger
- setHubUpdateTrigger((prev) => prev + 1);
+ // Mark that we need to re-sort after delay
+ setPendingSort(true);
+
+ // Clear any existing timeout
+ if (sortTimeoutRef.current) {
+ clearTimeout(sortTimeoutRef.current);
+ }
+
+ // Delay the re-sort to allow animation
+ sortTimeoutRef.current = setTimeout(() => {
+ setHubUpdateTrigger((prev) => prev + 1);
+ setPendingSort(false);
+ setIsTransitioning(false);
+ }, 800);
toastService.success({
title: 'Extension Updated',
@@ -68,6 +93,7 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS
}
if (!sessionId) {
+ setIsTransitioning(false);
toastService.error({
title: 'Extension Toggle Error',
msg: 'No active session found. Please start a chat session first.',
@@ -78,22 +104,31 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS
try {
if (extensionConfig.enabled) {
- // Disable extension - only in session, not global config
await removeFromAgent(extensionConfig.name, sessionId, true);
} else {
- // Enable extension - only in session, not global config
await addToAgent(extensionConfig, sessionId, true);
}
- // Refetch extensions after toggle
- const response = await getSessionExtensions({
- path: { session_id: sessionId },
- });
+ setPendingSort(true);
- if (response.data?.extensions) {
- setSessionExtensions(response.data.extensions);
+ 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);
+ }, 800);
} catch (error) {
+ setIsTransitioning(false);
+ setPendingSort(false);
toastService.error({
title: 'Extension Error',
msg: `Failed to ${extensionConfig.enabled ? 'disable' : 'enable'} ${extensionConfig.name}`,
@@ -109,7 +144,6 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS
const hubOverrides = getExtensionOverrides();
if (isHubView) {
- // In hub view, show global extension states with local overrides
return allExtensions.map(
(ext) =>
({
@@ -119,7 +153,6 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS
);
}
- // In session view, show session-specific states
const sessionExtensionNames = new Set(sessionExtensions.map((ext) => ext.name));
return allExtensions.map(
@@ -143,24 +176,11 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS
}, [extensionsList, searchQuery]);
const sortedExtensions = useMemo(() => {
- const getTypePriority = (type: string): number => {
- const priorities: Record = {
- 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]);
@@ -175,7 +195,12 @@ 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);
}
}}
>
@@ -202,7 +227,11 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS
{isHubView ? 'Extensions for new chats' : 'Extensions for this chat session'}
-
+
{sortedExtensions.length === 0 ? (
{searchQuery ? 'no extensions found' : 'no extensions available'}
@@ -211,7 +240,7 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS
sortedExtensions.map((ext) => (
handleToggle(ext)}
title={ext.description || ext.name}
>
diff --git a/ui/desktop/src/components/extensions/ExtensionsView.tsx b/ui/desktop/src/components/extensions/ExtensionsView.tsx
index 8177441ae85e..c0f7cdb98ee0 100644
--- a/ui/desktop/src/components/extensions/ExtensionsView.tsx
+++ b/ui/desktop/src/components/extensions/ExtensionsView.tsx
@@ -113,11 +113,15 @@ export default function ExtensionsView({
Extensions
-
+
These extensions use the Model Context Protocol (MCP). They can expand Goose's
capabilities using three main components: Prompts, Resources, and Tools. ⌘F/Ctrl+F to
search.
+
+ Extensions enabled here are used as the default for new chats. You can also toggle
+ extensions using the extensions menu during chat.
+
{/* Action Buttons */}
diff --git a/ui/desktop/src/components/sessions/SessionListView.tsx b/ui/desktop/src/components/sessions/SessionListView.tsx
index bfd6ba385cb3..6d1e135799d9 100644
--- a/ui/desktop/src/components/sessions/SessionListView.tsx
+++ b/ui/desktop/src/components/sessions/SessionListView.tsx
@@ -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,8 +31,28 @@ import {
listSessions,
Session,
updateSessionName,
+ ExtensionConfig,
+ ExtensionData,
} from '../../api';
+// Helper to extract extension names from session's extension_data
+function getSessionExtensionNames(extensionData: ExtensionData): string[] {
+ try {
+ // extension_data structure: { "enabled_extensions": { "v0": { "extensions": [...] } } }
+ const enabledExtensions = extensionData?.['enabled_extensions'] as
+ | Record
+ | undefined;
+ if (!enabledExtensions) return [];
+
+ const v0Data = enabledExtensions['v0'];
+ if (!v0Data?.extensions) return [];
+
+ return v0Data.extensions.map((ext) => ext.name);
+ } catch {
+ return [];
+ }
+}
+
interface EditSessionModalProps {
session: Session | null;
isOpen: boolean;
@@ -547,6 +569,12 @@ const SessionListView: React.FC = React.memo(
[onOpenInNewWindow, session]
);
+ // Get extension names for this session
+ const extensionNames = useMemo(
+ () => getSessionExtensionNames(session.extension_data),
+ [session.extension_data]
+ );
+
return (
= React.memo(
{(session.total_tokens || 0).toLocaleString()}
)}
+ {extensionNames.length > 0 && (
+
+
+
+ e.stopPropagation()}
+ >
+
+
{extensionNames.length}
+
+
+
+
+
Extensions:
+
+ {extensionNames.map((name) => (
+ {name}
+ ))}
+
+
+
+
+
+ )}
diff --git a/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionList.tsx b/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionList.tsx
index a2ab0b121fbf..f29cde546a85 100644
--- a/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionList.tsx
+++ b/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionList.tsx
@@ -55,7 +55,7 @@ export default function ExtensionList({
- Enabled Extensions ({sortedEnabledExtensions.length})
+ Default Extensions ({sortedEnabledExtensions.length})
{sortedEnabledExtensions.map((extension) => (
From ea6b76600a3d89edcddb1e05f067058f2ecc1d8c Mon Sep 17 00:00:00 2001
From: Zane Staggs
Date: Tue, 16 Dec 2025 15:17:02 -0800
Subject: [PATCH 08/24] fix extensions not showing
---
.../src/components/sessions/SessionListView.tsx | 15 ++++++++-------
1 file changed, 8 insertions(+), 7 deletions(-)
diff --git a/ui/desktop/src/components/sessions/SessionListView.tsx b/ui/desktop/src/components/sessions/SessionListView.tsx
index 6d1e135799d9..359872781f70 100644
--- a/ui/desktop/src/components/sessions/SessionListView.tsx
+++ b/ui/desktop/src/components/sessions/SessionListView.tsx
@@ -38,16 +38,17 @@ import {
// Helper to extract extension names from session's extension_data
function getSessionExtensionNames(extensionData: ExtensionData): string[] {
try {
- // extension_data structure: { "enabled_extensions": { "v0": { "extensions": [...] } } }
- const enabledExtensions = extensionData?.['enabled_extensions'] as
- | Record
+ // extension_data structure: { "enabled_extensions.v0": { "extensions": [...] } }
+ const v0Data = extensionData?.['enabled_extensions.v0'] as
+ | { extensions?: ExtensionConfig[] }
| undefined;
- if (!enabledExtensions) return [];
-
- const v0Data = enabledExtensions['v0'];
if (!v0Data?.extensions) return [];
- return v0Data.extensions.map((ext) => ext.name);
+ return v0Data.extensions.map((ext) => {
+ // display_name exists on some ExtensionConfig variants but not all
+ const displayName = (ext as { display_name?: string }).display_name;
+ return displayName || ext.name;
+ });
} catch {
return [];
}
From 3768ac4d5db636144354e543bcd755fbf347b71c Mon Sep 17 00:00:00 2001
From: Zane Staggs
Date: Tue, 16 Dec 2025 16:06:03 -0800
Subject: [PATCH 09/24] make global extension enabling for default only not
current session
---
.../components/extensions/ExtensionsView.tsx | 20 +--
.../settings/extensions/ExtensionsSection.tsx | 34 +----
.../settings/extensions/extension-manager.ts | 124 +++++++++++++-----
.../components/settings/extensions/index.ts | 2 +
4 files changed, 101 insertions(+), 79 deletions(-)
diff --git a/ui/desktop/src/components/extensions/ExtensionsView.tsx b/ui/desktop/src/components/extensions/ExtensionsView.tsx
index c0f7cdb98ee0..25934351b02c 100644
--- a/ui/desktop/src/components/extensions/ExtensionsView.tsx
+++ b/ui/desktop/src/components/extensions/ExtensionsView.tsx
@@ -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';
@@ -34,14 +33,7 @@ export default function ExtensionsView({
const [refreshKey, setRefreshKey] = useState(0);
const [searchTerm, setSearchTerm] = useState('');
const { addExtension } = useConfig();
- const chatContext = useChatContext();
- const sessionId = chatContext?.chat.sessionId || '';
- if (!sessionId) {
- console.error('ExtensionsView: No session ID available');
- }
-
- // Only trigger refresh when deep link config changes AND we don't need to show env vars
useEffect(() => {
if (viewOptions.deepLinkConfig && !viewOptions.showEnvVars) {
setRefreshKey((prevKey) => prevKey + 1);
@@ -80,19 +72,12 @@ export default function ExtensionsView({
// Close the modal immediately
handleModalClose();
- if (!sessionId) {
- console.warn('Cannot activate extension without session');
- setRefreshKey((prevKey) => prevKey + 1);
- return;
- }
-
const extensionConfig = createExtensionConfig(formData);
try {
- await activateExtension({
+ await activateExtensionDefault({
addToConfig: addExtension,
extensionConfig: extensionConfig,
- sessionId: sessionId,
});
// Trigger a refresh of the extensions list
setRefreshKey((prevKey) => prevKey + 1);
@@ -151,7 +136,6 @@ export default function ExtensionsView({
setSearchTerm(term)} placeholder="Search extensions...">
{
@@ -134,10 +129,9 @@ export default function ExtensionsSection({
const extensionConfig = createExtensionConfig(formData);
try {
- await activateExtension({
+ await activateExtensionDefault({
addToConfig: addExtension,
extensionConfig: extensionConfig,
- sessionId: sessionId,
});
setPendingActivationExtensions((prev) => {
const updated = new Set(prev);
@@ -177,42 +171,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: 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();
}
};
diff --git a/ui/desktop/src/components/settings/extensions/extension-manager.ts b/ui/desktop/src/components/settings/extensions/extension-manager.ts
index 37b072894846..e30b971b3dc2 100644
--- a/ui/desktop/src/components/settings/extensions/extension-manager.ts
+++ b/ui/desktop/src/components/settings/extensions/extension-manager.ts
@@ -135,12 +135,9 @@ interface UpdateExtensionProps {
removeFromConfig: (name: string) => Promise;
extensionConfig: ExtensionConfig;
originalName?: string;
- sessionId: string;
+ sessionId?: string;
}
-/**
- * Updates an extension configuration, handling name changes
- */
export async function updateExtension({
enabled,
addToConfig,
@@ -149,40 +146,33 @@ export async function updateExtension({
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 {
- 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
+ if (sessionId) {
+ try {
+ 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
+ await removeFromConfig(originalName!);
} catch (error) {
console.error('Failed to remove old extension from config during rename:', error);
- throw error; // This is more critical, so we throw
+ throw error;
}
- // Create a copy of the extension config with the sanitized name
const sanitizedExtensionConfig = {
...extensionConfig,
name: sanitizedNewName,
};
- // Add new extension with sanitized name
- if (enabled) {
+ if (enabled && sessionId) {
try {
await addToAgent(sanitizedExtensionConfig, sessionId, false);
} catch (error) {
@@ -191,7 +181,6 @@ export async function updateExtension({
}
}
- // Add to config with sanitized name
try {
await addToConfig(sanitizedNewName, sanitizedExtensionConfig, enabled);
} catch (error) {
@@ -205,18 +194,16 @@ export async function updateExtension({
msg: `Successfully updated ${sanitizedNewName} extension`,
});
} else {
- // Create a copy of the extension config with the sanitized name
const sanitizedExtensionConfig = {
...extensionConfig,
name: sanitizedNewName,
};
- if (enabled) {
+ 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;
}
@@ -228,7 +215,6 @@ export async function updateExtension({
throw error;
}
- // show a toast that it was successfully updated
toastService.success({
title: `Update extension`,
msg: `Successfully updated ${sanitizedNewName} extension`,
@@ -237,11 +223,10 @@ export async function updateExtension({
try {
await addToConfig(sanitizedNewName, sanitizedExtensionConfig, enabled);
} catch (error) {
- console.error('[updateExtension]: Failed to update disabled extension in config:', 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`,
@@ -347,7 +332,7 @@ export async function toggleExtension({
interface DeleteExtensionProps {
name: string;
removeFromConfig: (name: string) => Promise;
- sessionId: string;
+ sessionId?: string;
extensionConfig?: ExtensionConfig;
}
@@ -363,11 +348,13 @@ export async function deleteExtension({
const isBuiltin = extensionConfig ? isBuiltinExtension(extensionConfig) : false;
let agentRemoveError = null;
- try {
- await removeFromAgent(name, sessionId, true);
- } catch (error) {
- console.error('Failed to remove extension from agent during deletion:', error);
- agentRemoveError = error;
+ if (sessionId) {
+ try {
+ await removeFromAgent(name, sessionId, true);
+ } catch (error) {
+ console.error('Failed to remove extension from agent during deletion:', error);
+ agentRemoveError = error;
+ }
}
try {
@@ -390,3 +377,72 @@ export async function deleteExtension({
throw agentRemoveError;
}
}
+
+interface ToggleExtensionDefaultProps {
+ toggle: 'toggleOn' | 'toggleOff';
+ extensionConfig: ExtensionConfig;
+ addToConfig: (name: string, extensionConfig: ExtensionConfig, enabled: boolean) => Promise;
+}
+
+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 as default' : 'Extension disabled as default',
+ });
+ } 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;
+ extensionConfig: ExtensionConfig;
+}
+
+export async function activateExtensionDefault({
+ addToConfig,
+ extensionConfig,
+}: ActivateExtensionDefaultProps): Promise {
+ 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;
+ }
+}
diff --git a/ui/desktop/src/components/settings/extensions/index.ts b/ui/desktop/src/components/settings/extensions/index.ts
index 5469fc52ad69..f0fdfbd7986d 100644
--- a/ui/desktop/src/components/settings/extensions/index.ts
+++ b/ui/desktop/src/components/settings/extensions/index.ts
@@ -4,9 +4,11 @@ export { DEFAULT_EXTENSION_TIMEOUT, nameToKey } from './utils';
// Export extension management functions
export {
activateExtension,
+ activateExtensionDefault,
addToAgentOnStartup,
updateExtension,
toggleExtension,
+ toggleExtensionDefault,
deleteExtension,
} from './extension-manager';
From 2c6e15f151d19deb5b6a7ef05269918265e027b2 Mon Sep 17 00:00:00 2001
From: Zane Staggs
Date: Wed, 17 Dec 2025 18:35:02 -0800
Subject: [PATCH 10/24] extension loading per session working
---
crates/goose-server/src/openapi.rs | 3 +
crates/goose-server/src/routes/agent.rs | 51 ++-
crates/goose-server/src/routes/agent_utils.rs | 41 +-
ui/desktop/openapi.json | 62 +++-
ui/desktop/src/App.tsx | 157 +++-----
ui/desktop/src/api/types.gen.ts | 23 +-
ui/desktop/src/components/BaseChat.tsx | 5 +-
ui/desktop/src/components/ChatInput.tsx | 51 ++-
.../GroupedExtensionLoadingToast.tsx | 27 +-
ui/desktop/src/components/Hub.tsx | 57 ++-
ui/desktop/src/components/LoadingGoose.tsx | 2 +
.../BottomMenuExtensionSelection.tsx | 85 +++--
.../components/bottom_menu/DirSwitcher.tsx | 10 +-
.../components/extensions/ExtensionsView.tsx | 2 +-
.../settings/extensions/ExtensionsSection.tsx | 35 +-
.../extensions/extension-manager.test.ts | 255 -------------
.../settings/extensions/extension-manager.ts | 350 +-----------------
.../components/settings/extensions/index.ts | 11 +-
.../subcomponents/ExtensionItem.tsx | 14 +-
.../subcomponents/ExtensionList.tsx | 3 -
ui/desktop/src/hooks/useAgent.ts | 19 +-
ui/desktop/src/hooks/useChatStream.ts | 24 +-
ui/desktop/src/sessions.ts | 22 +-
ui/desktop/src/types/chatState.ts | 1 +
ui/desktop/src/utils/extensionErrorUtils.ts | 47 +++
ui/desktop/src/utils/navigationUtils.ts | 5 +-
ui/desktop/src/utils/providerUtils.ts | 76 ----
27 files changed, 452 insertions(+), 986 deletions(-)
delete mode 100644 ui/desktop/src/components/settings/extensions/extension-manager.test.ts
diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs
index e0950365a6eb..c52a52fd7d98 100644
--- a/crates/goose-server/src/openapi.rs
+++ b/crates/goose-server/src/openapi.rs
@@ -535,6 +535,9 @@ derive_utoipa!(Icon as IconSchema);
super::routes::agent::UpdateFromSessionRequest,
super::routes::agent::AddExtensionRequest,
super::routes::agent::RemoveExtensionRequest,
+ super::routes::agent::ResumeAgentResponse,
+ super::routes::agent::RestartAgentResponse,
+ super::routes::agent_utils::ExtensionLoadResult,
super::routes::setup::SetupResponse,
super::tunnel::TunnelInfo,
super::tunnel::TunnelState,
diff --git a/crates/goose-server/src/routes/agent.rs b/crates/goose-server/src/routes/agent.rs
index d53820f24c96..a0e885a53e15 100644
--- a/crates/goose-server/src/routes/agent.rs
+++ b/crates/goose-server/src/routes/agent.rs
@@ -1,5 +1,6 @@
use crate::routes::agent_utils::{
persist_session_extensions, restore_agent_extensions, restore_agent_provider,
+ ExtensionLoadResult,
};
use crate::routes::errors::ErrorResponse;
use crate::routes::recipe_utils::{
@@ -135,6 +136,18 @@ pub struct CallToolResponse {
is_error: bool,
}
+#[derive(Serialize, utoipa::ToSchema)]
+pub struct ResumeAgentResponse {
+ pub session: Session,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub extension_results: Option>,
+}
+
+#[derive(Serialize, utoipa::ToSchema)]
+pub struct RestartAgentResponse {
+ pub extension_results: Vec,
+}
+
#[utoipa::path(
post,
path = "/agent/start",
@@ -257,7 +270,7 @@ async fn start_agent(
path = "/agent/resume",
request_body = ResumeAgentRequest,
responses(
- (status = 200, description = "Agent started successfully", body = Session),
+ (status = 200, description = "Agent started successfully", body = ResumeAgentResponse),
(status = 400, description = "Bad request - invalid working directory"),
(status = 401, description = "Unauthorized - invalid secret key"),
(status = 500, description = "Internal server error")
@@ -266,7 +279,7 @@ async fn start_agent(
async fn resume_agent(
State(state): State>,
Json(payload): Json,
-) -> Result, ErrorResponse> {
+) -> Result, ErrorResponse> {
goose::posthog::set_session_context("desktop", true);
let session = SessionManager::get_session(&payload.session_id, true)
@@ -279,7 +292,7 @@ async fn resume_agent(
}
})?;
- if payload.load_model_and_extensions {
+ let extension_results = if payload.load_model_and_extensions {
let agent = state
.get_agent_for_route(payload.session_id.clone())
.await
@@ -289,14 +302,19 @@ async fn resume_agent(
})?;
let provider_result = restore_agent_provider(&agent, &session, &payload.session_id);
- let extensions_result = restore_agent_extensions(agent.clone(), &session);
+ let extensions_future = restore_agent_extensions(agent.clone(), &session);
- let (provider_result, extensions_result) = tokio::join!(provider_result, extensions_result);
+ let (provider_result, extension_results) = tokio::join!(provider_result, extensions_future);
provider_result?;
- extensions_result?;
- }
+ Some(extension_results)
+ } else {
+ None
+ };
- Ok(Json(session))
+ Ok(Json(ResumeAgentResponse {
+ session,
+ extension_results,
+ }))
}
#[utoipa::path(
@@ -596,7 +614,7 @@ async fn restart_agent_internal(
state: &Arc,
session_id: &str,
session: &Session,
-) -> Result<(), ErrorResponse> {
+) -> Result, ErrorResponse> {
// Remove existing agent (ignore error if not found)
let _ = state.agent_manager.remove_session(session_id).await;
@@ -609,11 +627,10 @@ async fn restart_agent_internal(
})?;
let provider_result = restore_agent_provider(&agent, session, session_id);
- let extensions_result = restore_agent_extensions(agent.clone(), session);
+ let extensions_future = restore_agent_extensions(agent.clone(), session);
- let (provider_result, extensions_result) = tokio::join!(provider_result, extensions_result);
+ let (provider_result, extension_results) = tokio::join!(provider_result, extensions_future);
provider_result?;
- extensions_result?;
let context: HashMap<&str, Value> = HashMap::new();
let desktop_prompt =
@@ -645,7 +662,7 @@ async fn restart_agent_internal(
}
agent.extend_system_prompt(update_prompt).await;
- Ok(())
+ Ok(extension_results)
}
#[utoipa::path(
@@ -653,7 +670,7 @@ async fn restart_agent_internal(
path = "/agent/restart",
request_body = RestartAgentRequest,
responses(
- (status = 200, description = "Agent restarted successfully"),
+ (status = 200, description = "Agent restarted successfully", body = RestartAgentResponse),
(status = 401, description = "Unauthorized - invalid secret key"),
(status = 404, description = "Session not found"),
(status = 500, description = "Internal server error")
@@ -662,7 +679,7 @@ async fn restart_agent_internal(
async fn restart_agent(
State(state): State>,
Json(payload): Json,
-) -> Result {
+) -> Result, ErrorResponse> {
let session_id = payload.session_id.clone();
let session = SessionManager::get_session(&session_id, false)
@@ -675,9 +692,9 @@ async fn restart_agent(
}
})?;
- restart_agent_internal(&state, &session_id, &session).await?;
+ let extension_results = restart_agent_internal(&state, &session_id, &session).await?;
- Ok(StatusCode::OK)
+ Ok(Json(RestartAgentResponse { extension_results }))
}
#[utoipa::path(
diff --git a/crates/goose-server/src/routes/agent_utils.rs b/crates/goose-server/src/routes/agent_utils.rs
index 6f0190b950db..1f5d9a35cd94 100644
--- a/crates/goose-server/src/routes/agent_utils.rs
+++ b/crates/goose-server/src/routes/agent_utils.rs
@@ -6,9 +6,18 @@ use goose::model::ModelConfig;
use goose::providers::create;
use goose::session::extension_data::ExtensionState;
use goose::session::{EnabledExtensionsState, Session, SessionManager};
+use serde::Serialize;
use std::sync::Arc;
use tracing::{error, warn};
+#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
+pub struct ExtensionLoadResult {
+ pub name: String,
+ pub success: bool,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub error: Option,
+}
+
pub async fn restore_agent_provider(
agent: &Arc,
session: &Session,
@@ -57,14 +66,18 @@ pub async fn restore_agent_provider(
pub async fn restore_agent_extensions(
agent: Arc,
session: &Session,
-) -> Result<(), ErrorResponse> {
+) -> Vec {
// Set the agent's working directory before adding extensions
agent.set_working_dir(session.working_dir.clone()).await;
// Try to load session-specific extensions first, fall back to global config
- let enabled_configs = EnabledExtensionsState::from_extension_data(&session.extension_data)
+ let session_extensions = EnabledExtensionsState::from_extension_data(&session.extension_data);
+ let enabled_configs = session_extensions
.map(|state| state.extensions)
- .unwrap_or_else(goose::config::get_enabled_extensions);
+ .unwrap_or_else(|| {
+ tracing::info!("restore_agent_extensions: falling back to global config");
+ goose::config::get_enabled_extensions()
+ });
let extension_futures = enabled_configs
.into_iter()
@@ -73,16 +86,28 @@ pub async fn restore_agent_extensions(
let agent_ref = agent.clone();
async move {
- if let Err(e) = agent_ref.add_extension(config_clone.clone()).await {
- warn!("Failed to load extension {}: {}", config_clone.name(), e);
+ let name = config_clone.name().to_string();
+ match agent_ref.add_extension(config_clone).await {
+ Ok(_) => ExtensionLoadResult {
+ name,
+ success: true,
+ error: None,
+ },
+ Err(e) => {
+ let error_msg = e.to_string();
+ warn!("Failed to load extension {}: {}", name, error_msg);
+ ExtensionLoadResult {
+ name,
+ success: false,
+ error: Some(error_msg),
+ }
+ }
}
- Ok::<_, ErrorResponse>(())
}
})
.collect::>();
- futures::future::join_all(extension_futures).await;
- Ok(())
+ futures::future::join_all(extension_futures).await
}
pub async fn persist_session_extensions(
diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json
index 66695e111655..aeaa5cc0e97a 100644
--- a/ui/desktop/openapi.json
+++ b/ui/desktop/openapi.json
@@ -227,7 +227,14 @@
},
"responses": {
"200": {
- "description": "Agent restarted successfully"
+ "description": "Agent restarted successfully",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RestartAgentResponse"
+ }
+ }
+ }
},
"401": {
"description": "Unauthorized - invalid secret key"
@@ -263,7 +270,7 @@
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/Session"
+ "$ref": "#/components/schemas/ResumeAgentResponse"
}
}
}
@@ -3664,6 +3671,25 @@
}
]
},
+ "ExtensionLoadResult": {
+ "type": "object",
+ "required": [
+ "name",
+ "success"
+ ],
+ "properties": {
+ "error": {
+ "type": "string",
+ "nullable": true
+ },
+ "name": {
+ "type": "string"
+ },
+ "success": {
+ "type": "boolean"
+ }
+ }
+ },
"ExtensionQuery": {
"type": "object",
"required": [
@@ -4887,6 +4913,20 @@
}
}
},
+ "RestartAgentResponse": {
+ "type": "object",
+ "required": [
+ "extension_results"
+ ],
+ "properties": {
+ "extension_results": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/ExtensionLoadResult"
+ }
+ }
+ }
+ },
"ResumeAgentRequest": {
"type": "object",
"required": [
@@ -4902,6 +4942,24 @@
}
}
},
+ "ResumeAgentResponse": {
+ "type": "object",
+ "required": [
+ "session"
+ ],
+ "properties": {
+ "extension_results": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/ExtensionLoadResult"
+ },
+ "nullable": true
+ },
+ "session": {
+ "$ref": "#/components/schemas/Session"
+ }
+ }
+ },
"RetryConfig": {
"type": "object",
"description": "Configuration for retry logic in recipe execution",
diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx
index 768cba99c9b1..08d720fb2b5f 100644
--- a/ui/desktop/src/App.tsx
+++ b/ui/desktop/src/App.tsx
@@ -51,128 +51,78 @@ function PageViewTracker() {
}
// Route Components
-const HubRouteWrapper = ({ isExtensionsLoading }: { isExtensionsLoading: boolean }) => {
+const HubRouteWrapper = () => {
const setView = useNavigation();
-
- return ;
+ return ;
};
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 routeState = (location.state as PairRouteState) || {};
const [searchParams, setSearchParams] = useSearchParams();
-
- // Capture initialMessage in local state to survive route state being cleared by setSearchParams
- const [capturedInitialMessage, setCapturedInitialMessage] = useState(
- undefined
- );
- const [lastSessionId, setLastSessionId] = useState(undefined);
- const [isCreatingSession, setIsCreatingSession] = useState(false);
-
+ const [initialMessage] = useState(routeState.initialMessage);
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;
-
- // Use route state if available, otherwise use captured state
- const initialMessage = routeState.initialMessage || capturedInitialMessage;
-
- useEffect(() => {
- if (routeState.initialMessage) {
- setCapturedInitialMessage(routeState.initialMessage);
- }
- }, [routeState.initialMessage]);
+ // Session ID comes from URL (set by Hub before navigating, or from session list/recipe deeplink)
+ const [sessionId, setSessionId] = useState(
+ routeState.resumeSessionId || resumeSessionId || chat.sessionId || undefined
+ );
+ // Handle recipe deeplinks - create session if needed
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);
-
+ if ((recipeId || recipeDeeplinkFromConfig) && !sessionId) {
(async () => {
try {
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;
- });
- setActiveSessionId(newSession.id);
+ setSessionId(newSession.id);
+ setSearchParams(
+ (prev) => {
+ prev.set('resumeSessionId', newSession.id);
+ prev.delete('recipeId');
+ return prev;
+ },
+ { replace: true }
+ );
} catch (error) {
- console.error('[PairRouteWrapper] Failed to create session:', error);
- } finally {
- setIsCreatingSession(false);
+ console.error('Failed to create session for recipe:', error);
}
})();
}
- }, [
- initialMessage,
- recipeId,
- recipeDeeplinkFromConfig,
- sessionId,
- isCreatingSession,
- setSearchParams,
- setActiveSessionId,
- ]);
-
- // 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]);
+ }, [recipeId, recipeDeeplinkFromConfig, sessionId, extensionsList, setSearchParams]);
- // Update URL with session ID when on /pair route (for refresh support)
+ // Sync URL with session ID for refresh support
useEffect(() => {
if (sessionId && sessionId !== resumeSessionId) {
- setSearchParams((prev) => {
- prev.set('resumeSessionId', sessionId);
- return prev;
- });
+ setSearchParams(
+ (prev) => {
+ prev.set('resumeSessionId', sessionId);
+ return prev;
+ },
+ { replace: true }
+ );
}
}, [sessionId, resumeSessionId, setSearchParams]);
- // Update active session state when session ID changes
- useEffect(() => {
- if (sessionId && sessionId !== activeSessionId) {
- setActiveSessionId(sessionId);
- }
- }, [sessionId, activeSessionId, setActiveSessionId]);
-
return (
-
+
);
};
@@ -361,7 +311,6 @@ export function AppInner() {
const [agentWaitingMessage, setAgentWaitingMessage] = useState(null);
const [isLoadingSharedSession, setIsLoadingSharedSession] = useState(false);
const [sharedSessionError, setSharedSessionError] = useState(null);
- const [isExtensionsLoading, setIsExtensionsLoading] = useState(false);
const [didSelectProvider, setDidSelectProvider] = useState(false);
const navigate = useNavigate();
@@ -376,9 +325,6 @@ export function AppInner() {
recipe: null,
});
- // Store the active session ID for navigation persistence
- const [activeSessionId, setActiveSessionId] = useState(null);
-
const { addExtension } = useConfig();
const { loadCurrentChat } = useAgent();
@@ -402,7 +348,6 @@ export function AppInner() {
try {
const loadedChat = await loadCurrentChat({
setAgentWaitingMessage,
- setIsExtensionsLoading,
});
setChat(loadedChat);
} catch (e) {
@@ -597,11 +542,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);
@@ -657,18 +612,8 @@ export function AppInner() {
}
>
- } />
-
- }
- />
+ } />
+ } />
} />
;
+};
+
export type ResumeAgentRequest = {
load_model_and_extensions: boolean;
session_id: string;
};
+export type ResumeAgentResponse = {
+ extension_results?: Array | null;
+ session: Session;
+};
+
/**
* Configuration for retry logic in recipe execution
*/
@@ -1255,9 +1270,11 @@ export type RestartAgentResponses = {
/**
* Agent restarted successfully
*/
- 200: unknown;
+ 200: RestartAgentResponse;
};
+export type RestartAgentResponse2 = RestartAgentResponses[keyof RestartAgentResponses];
+
export type ResumeAgentData = {
body: ResumeAgentRequest;
path?: never;
@@ -1284,10 +1301,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;
diff --git a/ui/desktop/src/components/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx
index 8c4ed85e6266..5744aa33077b 100644
--- a/ui/desktop/src/components/BaseChat.tsx
+++ b/ui/desktop/src/components/BaseChat.tsx
@@ -131,11 +131,9 @@ function BaseChatContent({
const shouldStartAgent = searchParams.get('shouldStartAgent') === 'true';
if (initialMessage) {
- // Submit the initial message (e.g., from fork)
hasAutoSubmittedRef.current = true;
handleSubmit(initialMessage);
} else if (shouldStartAgent) {
- // Trigger agent to continue with existing conversation
hasAutoSubmittedRef.current = true;
handleSubmit('');
}
@@ -300,8 +298,7 @@ function BaseChatContent({
: recipe.prompt;
}
- const initialPrompt =
- (initialMessage && !hasAutoSubmittedRef.current ? initialMessage : '') || recipePrompt;
+ const initialPrompt = recipePrompt;
if (sessionLoadError) {
return (
diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx
index 6a7a52934499..e35e5d159699 100644
--- a/ui/desktop/src/components/ChatInput.tsx
+++ b/ui/desktop/src/components/ChatInput.tsx
@@ -97,7 +97,7 @@ interface ChatInputProps {
initialPrompt?: string;
toolCount: number;
append?: (message: Message) => void;
- isExtensionsLoading?: boolean;
+ onWorkingDirChange?: (newDir: string) => void;
}
export default function ChatInput({
@@ -122,7 +122,7 @@ export default function ChatInput({
initialPrompt,
toolCount,
append: _append,
- isExtensionsLoading = false,
+ onWorkingDirChange,
}: ChatInputProps) {
const [_value, setValue] = useState(initialValue);
const [displayValue, setDisplayValue] = useState(initialValue); // For immediate visual feedback
@@ -152,6 +152,7 @@ export default function ChatInput({
const [showEditRecipeModal, setShowEditRecipeModal] = useState(false);
const [isFilePickerOpen, setIsFilePickerOpen] = useState(false);
const [sessionWorkingDir, setSessionWorkingDir] = useState(null);
+ const [isRestartingAgent, setIsRestartingAgent] = useState(false);
useEffect(() => {
if (!sessionId) {
@@ -1131,7 +1132,7 @@ export default function ChatInput({
isAnyDroppedFileLoading ||
isRecording ||
isTranscribing ||
- isExtensionsLoading;
+ isRestartingAgent;
// Queue management functions - no storage persistence, only in-memory
const handleRemoveQueuedMessage = (messageId: string) => {
@@ -1374,16 +1375,16 @@ export default function ChatInput({
- {isExtensionsLoading
- ? 'Loading extensions...'
- : isAnyImageLoading
- ? 'Waiting for images to save...'
- : isAnyDroppedFileLoading
- ? 'Processing dropped files...'
- : isRecording
- ? 'Recording...'
- : isTranscribing
- ? 'Transcribing...'
+ {isAnyImageLoading
+ ? 'Waiting for images to save...'
+ : isAnyDroppedFileLoading
+ ? 'Processing dropped files...'
+ : isRecording
+ ? 'Recording...'
+ : isTranscribing
+ ? 'Transcribing...'
+ : isRestartingAgent
+ ? 'Restarting agent...'
: 'Send'}
@@ -1409,6 +1410,15 @@ export default function ChatInput({
)}
)}
+
+ {isRestartingAgent && !isRecording && !isTranscribing && (
+
+
+
+ Restarting agent...
+
+
+ )}
@@ -1528,7 +1538,14 @@ export default function ChatInput({
className="mr-0"
sessionId={sessionId ?? undefined}
workingDir={sessionWorkingDir ?? getInitialWorkingDir()}
- onWorkingDirChange={(newDir) => setSessionWorkingDir(newDir)}
+ onWorkingDirChange={(newDir) => {
+ setSessionWorkingDir(newDir);
+ if (onWorkingDirChange) {
+ onWorkingDirChange(newDir);
+ }
+ }}
+ onRestartStart={() => setIsRestartingAgent(true)}
+ onRestartEnd={() => setIsRestartingAgent(false)}
/>
@@ -1574,7 +1591,11 @@ export default function ChatInput({
-
+ setIsRestartingAgent(true)}
+ onRestartEnd={() => setIsRestartingAgent(false)}
+ />
{sessionId && messages.length > 0 && (
<>
diff --git a/ui/desktop/src/components/GroupedExtensionLoadingToast.tsx b/ui/desktop/src/components/GroupedExtensionLoadingToast.tsx
index 91cabb33fc83..7f69415d321c 100644
--- a/ui/desktop/src/components/GroupedExtensionLoadingToast.tsx
+++ b/ui/desktop/src/components/GroupedExtensionLoadingToast.tsx
@@ -103,18 +103,18 @@ export function GroupedExtensionLoadingToast({
{formatExtensionErrorMessage(ext.error, 'Failed to add extension')}
- {ext.recoverHints && setView ? (
- {
- e.stopPropagation();
- startNewSession(getInitialWorkingDir(), ext.recoverHints, setView);
- }}
- className="self-start"
- >
- Ask goose
-
- ) : (
+
+ {ext.recoverHints && setView && (
+ {
+ e.stopPropagation();
+ startNewSession(getInitialWorkingDir(), ext.recoverHints, setView);
+ }}
+ >
+ Ask goose
+
+ )}
{
@@ -123,11 +123,10 @@ export function GroupedExtensionLoadingToast({
setCopiedExtension(ext.name);
setTimeout(() => setCopiedExtension(null), 2000);
}}
- className="self-start"
>
{copiedExtension === ext.name ? 'Copied!' : 'Copy error'}
- )}
+
)}
diff --git a/ui/desktop/src/components/Hub.tsx b/ui/desktop/src/components/Hub.tsx
index 1c1865764050..251a10bfcd64 100644
--- a/ui/desktop/src/components/Hub.tsx
+++ b/ui/desktop/src/components/Hub.tsx
@@ -7,45 +7,61 @@
* 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 { createSession } 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,
- isExtensionsLoading,
}: {
setView: (view: View, viewOptions?: ViewOptions) => void;
- isExtensionsLoading: boolean;
}) {
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()) {
- const session = await createSession(getInitialWorkingDir(), {
- allExtensions: extensionsList,
- });
+ if (combinedTextFromInput.trim() && !isCreatingSession) {
+ const extensionConfigs = getExtensionConfigsWithOverrides(extensionsList);
+ clearExtensionOverrides();
+ setIsCreatingSession(true);
+
+ try {
+ const session = await createSession(workingDir, {
+ extensionConfigs,
+ allExtensions: extensionConfigs ? undefined : extensionsList,
+ });
- setView('pair', {
- disableAnimation: true,
- initialMessage: combinedTextFromInput,
- resumeSessionId: session.id,
- });
+ setView('pair', {
+ disableAnimation: true,
+ resumeSessionId: session.id,
+ initialMessage: combinedTextFromInput,
+ });
+ } catch (error) {
+ console.error('Failed to create session:', error);
+ setIsCreatingSession(false);
+ }
e.preventDefault();
}
@@ -53,14 +69,19 @@ export default function Hub({
return (
-
+
+ {isCreatingSession && (
+
+
+
+ )}
{}}
initialValue=""
setView={setView}
@@ -72,8 +93,8 @@ export default function Hub({
messages={[]}
disableAnimation={false}
sessionCosts={undefined}
- isExtensionsLoading={isExtensionsLoading}
toolCount={0}
+ onWorkingDirChange={setWorkingDir}
/>
);
diff --git a/ui/desktop/src/components/LoadingGoose.tsx b/ui/desktop/src/components/LoadingGoose.tsx
index 56cddb27aa61..92f8b81375ea 100644
--- a/ui/desktop/src/components/LoadingGoose.tsx
+++ b/ui/desktop/src/components/LoadingGoose.tsx
@@ -15,6 +15,7 @@ const STATE_MESSAGES: Record
= {
[ChatState.WaitingForUserInput]: 'goose is waiting…',
[ChatState.Compacting]: 'goose is compacting the conversation...',
[ChatState.Idle]: 'goose is working on it…',
+ [ChatState.RestartingAgent]: 'restarting agent...',
};
const STATE_ICONS: Record = {
@@ -26,6 +27,7 @@ const STATE_ICONS: Record = {
),
[ChatState.Compacting]: ,
[ChatState.Idle]: ,
+ [ChatState.RestartingAgent]: ,
};
const LoadingGoose = ({ message, chatState = ChatState.Idle }: LoadingGooseProps) => {
diff --git a/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx b/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx
index 27bcbe2d8651..64e100ebd831 100644
--- a/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx
+++ b/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx
@@ -16,15 +16,22 @@ import {
interface BottomMenuExtensionSelectionProps {
sessionId: string | null;
+ onRestartStart?: () => void;
+ onRestartEnd?: () => void;
}
-export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionSelectionProps) => {
+export const BottomMenuExtensionSelection = ({
+ sessionId,
+ onRestartStart,
+ onRestartEnd,
+}: BottomMenuExtensionSelectionProps) => {
const [searchQuery, setSearchQuery] = useState('');
const [isOpen, setIsOpen] = useState(false);
const [sessionExtensions, setSessionExtensions] = useState([]);
const [hubUpdateTrigger, setHubUpdateTrigger] = useState(0);
const [isTransitioning, setIsTransitioning] = useState(false);
const [pendingSort, setPendingSort] = useState(false);
+ const [togglingExtension, setTogglingExtension] = useState(null);
const sortTimeoutRef = useRef | null>(null);
const { extensionsList: allExtensions } = useConfig();
const isHubView = !sessionId;
@@ -63,17 +70,18 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS
const handleToggle = useCallback(
async (extensionConfig: FixedExtensionEntry) => {
- // Start transition animation
+ if (togglingExtension === extensionConfig.name) {
+ return;
+ }
+
setIsTransitioning(true);
+ setTogglingExtension(extensionConfig.name);
if (isHubView) {
const currentState = getExtensionOverride(extensionConfig.name) ?? extensionConfig.enabled;
setExtensionOverride(extensionConfig.name, !currentState);
-
- // Mark that we need to re-sort after delay
setPendingSort(true);
- // Clear any existing timeout
if (sortTimeoutRef.current) {
clearTimeout(sortTimeoutRef.current);
}
@@ -83,6 +91,7 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS
setHubUpdateTrigger((prev) => prev + 1);
setPendingSort(false);
setIsTransitioning(false);
+ setTogglingExtension(null);
}, 800);
toastService.success({
@@ -94,6 +103,7 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS
if (!sessionId) {
setIsTransitioning(false);
+ setTogglingExtension(null);
toastService.error({
title: 'Extension Toggle Error',
msg: 'No active session found. Please start a chat session first.',
@@ -102,6 +112,8 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS
return;
}
+ onRestartStart?.();
+
try {
if (extensionConfig.enabled) {
await removeFromAgent(extensionConfig.name, sessionId, true);
@@ -125,18 +137,17 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS
}
setPendingSort(false);
setIsTransitioning(false);
+ setTogglingExtension(null);
+ onRestartEnd?.();
}, 800);
- } catch (error) {
+ } catch {
setIsTransitioning(false);
setPendingSort(false);
- toastService.error({
- title: 'Extension Error',
- msg: `Failed to ${extensionConfig.enabled ? 'disable' : 'enable'} ${extensionConfig.name}`,
- traceback: error instanceof Error ? error.message : String(error),
- });
+ setTogglingExtension(null);
+ onRestartEnd?.();
}
},
- [sessionId, isHubView]
+ [sessionId, isHubView, togglingExtension, onRestartStart, onRestartEnd]
);
// Merge all available extensions with session-specific or hub override state
@@ -201,6 +212,7 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS
}
setIsTransitioning(false);
setPendingSort(false);
+ setTogglingExtension(null);
}
}}
>
@@ -213,7 +225,14 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS
{activeCount}
-
+ {
+ e.preventDefault();
+ }}
+ >
) : (
- sortedExtensions.map((ext) => (
-
handleToggle(ext)}
- title={ext.description || ext.name}
- >
-
{getFriendlyTitle(ext)}
-
e.stopPropagation()}>
-
handleToggle(ext)}
- variant="mono"
- />
+ sortedExtensions.map((ext) => {
+ const isToggling = togglingExtension === ext.name;
+ return (
+ !isToggling && handleToggle(ext)}
+ title={ext.description || ext.name}
+ >
+
+ {getFriendlyTitle(ext)}
+
+
e.stopPropagation()}>
+ handleToggle(ext)}
+ variant="mono"
+ disabled={isToggling}
+ />
+
-
- ))
+ );
+ })
)}
diff --git a/ui/desktop/src/components/bottom_menu/DirSwitcher.tsx b/ui/desktop/src/components/bottom_menu/DirSwitcher.tsx
index be505bcb2dfa..26c8dc399eae 100644
--- a/ui/desktop/src/components/bottom_menu/DirSwitcher.tsx
+++ b/ui/desktop/src/components/bottom_menu/DirSwitcher.tsx
@@ -9,6 +9,8 @@ interface DirSwitcherProps {
sessionId: string | undefined;
workingDir: string;
onWorkingDirChange?: (newDir: string) => void;
+ onRestartStart?: () => void;
+ onRestartEnd?: () => void;
}
export const DirSwitcher: React.FC
= ({
@@ -16,6 +18,8 @@ export const DirSwitcher: React.FC = ({
sessionId,
workingDir,
onWorkingDirChange,
+ onRestartStart,
+ onRestartEnd,
}) => {
const [isTooltipOpen, setIsTooltipOpen] = useState(false);
const [isDirectoryChooserOpen, setIsDirectoryChooserOpen] = useState(false);
@@ -40,14 +44,18 @@ export const DirSwitcher: React.FC = ({
window.electron.addRecentDir(newDir);
if (sessionId) {
+ onWorkingDirChange?.(newDir);
+ onRestartStart?.();
+
try {
await updateWorkingDir({
body: { session_id: sessionId, working_dir: newDir },
});
- onWorkingDirChange?.(newDir);
} catch (error) {
console.error('[DirSwitcher] Failed to update working directory:', error);
toast.error('Failed to update working directory');
+ } finally {
+ onRestartEnd?.();
}
} else {
onWorkingDirChange?.(newDir);
diff --git a/ui/desktop/src/components/extensions/ExtensionsView.tsx b/ui/desktop/src/components/extensions/ExtensionsView.tsx
index 25934351b02c..0bbb3ff558b1 100644
--- a/ui/desktop/src/components/extensions/ExtensionsView.tsx
+++ b/ui/desktop/src/components/extensions/ExtensionsView.tsx
@@ -105,7 +105,7 @@ export default function ExtensionsView({
Extensions enabled here are used as the default for new chats. You can also toggle
- extensions using the extensions menu during chat.
+ extensions using the menu during chat.
{/* Action Buttons */}
diff --git a/ui/desktop/src/components/settings/extensions/ExtensionsSection.tsx b/ui/desktop/src/components/settings/extensions/ExtensionsSection.tsx
index 9293edcc18bc..ef3b577ce9a3 100644
--- a/ui/desktop/src/components/settings/extensions/ExtensionsSection.tsx
+++ b/ui/desktop/src/components/settings/extensions/ExtensionsSection.tsx
@@ -46,25 +46,12 @@ export default function ExtensionsSection({
const [showEnvVarsStateVar, setShowEnvVarsStateVar] = useState(
showEnvVars
);
- const [pendingActivationExtensions, setPendingActivationExtensions] = useState>(
- 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 [];
@@ -108,12 +95,6 @@ export default function ExtensionsSection({
addToConfig: addExtension,
});
- setPendingActivationExtensions((prev) => {
- const updated = new Set(prev);
- updated.delete(extensionConfig.name);
- return updated;
- });
-
await fetchExtensions();
return true;
};
@@ -133,21 +114,8 @@ export default function ExtensionsSection({
addToConfig: addExtension,
extensionConfig: extensionConfig,
});
- setPendingActivationExtensions((prev) => {
- const updated = new Set(prev);
- updated.delete(extensionConfig.name);
- return updated;
- });
} 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) {
@@ -220,7 +188,6 @@ export default function ExtensionsSection({
onConfigure={handleConfigureClick}
disableConfiguration={disableConfiguration}
searchTerm={searchTerm}
- pendingActivationExtensions={pendingActivationExtensions}
/>
{!hideButtons && (
diff --git a/ui/desktop/src/components/settings/extensions/extension-manager.test.ts b/ui/desktop/src/components/settings/extensions/extension-manager.test.ts
deleted file mode 100644
index 0151ebca3658..000000000000
--- a/ui/desktop/src/components/settings/extensions/extension-manager.test.ts
+++ /dev/null
@@ -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);
- });
- });
-});
diff --git a/ui/desktop/src/components/settings/extensions/extension-manager.ts b/ui/desktop/src/components/settings/extensions/extension-manager.ts
index e30b971b3dc2..bf5c501eb4c2 100644
--- a/ui/desktop/src/components/settings/extensions/extension-manager.ts
+++ b/ui/desktop/src/components/settings/extensions/extension-manager.ts
@@ -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,369 +12,30 @@ function isBuiltinExtension(config: ExtensionConfig): boolean {
return config.type === 'builtin';
}
-interface ActivateExtensionProps {
- addToConfig: (name: string, extensionConfig: ExtensionConfig, enabled: boolean) => Promise;
- extensionConfig: ExtensionConfig;
- sessionId: string;
-}
-
-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(fn: () => Promise, options: RetryOptions = {}): Promise {
- 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 to both the config system and the API.
- * @param props The extension activation properties
- * @returns Promise that resolves when activation is complete
- */
-export async function activateExtension({
- addToConfig,
- extensionConfig,
- sessionId,
-}: ActivateExtensionProps): Promise {
- const isBuiltin = isBuiltinExtension(extensionConfig);
-
- try {
- // AddToAgent
- await addToAgent(extensionConfig, sessionId, true);
- } catch (error) {
- console.error('Failed to add extension to agent:', error);
- await addToConfig(extensionConfig.name, extensionConfig, false);
- trackExtensionAdded(extensionConfig.name, false, getErrorType(error), isBuiltin);
- throw error;
- }
-
- try {
- await addToConfig(extensionConfig.name, extensionConfig, true);
- trackExtensionAdded(extensionConfig.name, true, undefined, isBuiltin);
- } catch (error) {
- console.error('Failed to add extension to config:', error);
- // remove from Agent
- 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);
- // Rethrow the error to inform the caller
- 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 {
- 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;
- removeFromConfig: (name: string) => Promise;
- extensionConfig: ExtensionConfig;
- originalName?: string;
- sessionId?: string;
-}
-
-export async function updateExtension({
- enabled,
- addToConfig,
- removeFromConfig,
- extensionConfig,
- originalName,
- sessionId,
-}: UpdateExtensionProps) {
- const sanitizedNewName = sanitizeName(extensionConfig.name);
- const sanitizedOriginalName = originalName ? sanitizeName(originalName) : undefined;
- const nameChanged = sanitizedOriginalName && sanitizedOriginalName !== sanitizedNewName;
-
- if (nameChanged) {
- if (sessionId) {
- try {
- 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
- }
- }
-
- try {
- await removeFromConfig(originalName!);
- } catch (error) {
- console.error('Failed to remove old extension from config during rename:', error);
- throw error;
- }
-
- const sanitizedExtensionConfig = {
- ...extensionConfig,
- name: sanitizedNewName,
- };
-
- if (enabled && sessionId) {
- try {
- await addToAgent(sanitizedExtensionConfig, sessionId, false);
- } catch (error) {
- console.error('[updateExtension]: Failed to add renamed extension to agent:', error);
- throw error;
- }
- }
-
- 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 {
- 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);
- 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;
- }
-
- 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 extension in config:', error);
- throw error;
- }
-
- 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;
- 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
- 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 {
- 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 {
- 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;
- 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;
- if (sessionId) {
- try {
- 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 {
@@ -401,7 +61,7 @@ export async function toggleExtensionDefault({
}
toastService.success({
title: extensionConfig.name,
- msg: enabled ? 'Extension enabled as default' : 'Extension disabled as default',
+ msg: enabled ? 'Extension enabled in defaults' : 'Extension removed from defaults',
});
} catch (error) {
console.error('Failed to update extension default in config:', error);
diff --git a/ui/desktop/src/components/settings/extensions/index.ts b/ui/desktop/src/components/settings/extensions/index.ts
index f0fdfbd7986d..67d0dc161df0 100644
--- a/ui/desktop/src/components/settings/extensions/index.ts
+++ b/ui/desktop/src/components/settings/extensions/index.ts
@@ -1,22 +1,13 @@
-// Export public API
export { DEFAULT_EXTENSION_TIMEOUT, nameToKey } from './utils';
-// Export extension management functions
export {
- activateExtension,
activateExtensionDefault,
- addToAgentOnStartup,
- updateExtension,
- toggleExtension,
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';
diff --git a/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionItem.tsx b/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionItem.tsx
index f677260332eb..92663222a58a 100644
--- a/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionItem.tsx
+++ b/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionItem.tsx
@@ -11,7 +11,6 @@ interface ExtensionItemProps {
onToggle: (extension: FixedExtensionEntry) => Promise | 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)}
>
-
- {getFriendlyTitle(extension)}
- {isPendingActivation && (
-
- Pending
-
- )}
-
+ {getFriendlyTitle(extension)}
e.stopPropagation()}>
diff --git a/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionList.tsx b/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionList.tsx
index f29cde546a85..e15e86007892 100644
--- a/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionList.tsx
+++ b/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionList.tsx
@@ -11,7 +11,6 @@ interface ExtensionListProps {
isStatic?: boolean;
disableConfiguration?: boolean;
searchTerm?: string;
- pendingActivationExtensions?: Set;
}
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;
@@ -65,7 +63,6 @@ export default function ExtensionList({
onToggle={onToggle}
onConfigure={onConfigure}
isStatic={isStatic}
- isPendingActivation={pendingActivationExtensions.has(extension.name)}
/>
))}
diff --git a/ui/desktop/src/hooks/useAgent.ts b/ui/desktop/src/hooks/useAgent.ts
index 50c7bd72af7c..47b77513d18d 100644
--- a/ui/desktop/src/hooks/useAgent.ts
+++ b/ui/desktop/src/hooks/useAgent.ts
@@ -28,7 +28,6 @@ export interface InitializationContext {
recipe?: Recipe;
resumeSessionId?: string;
setAgentWaitingMessage: (msg: string | null) => void;
- setIsExtensionsLoading?: (isLoading: boolean) => void;
}
interface UseAgentReturn {
@@ -114,15 +113,15 @@ export function useAgent(): UseAgentReturn {
// Fall through to create new session
if (agentResponse?.data) {
- const agentSession = agentResponse.data;
- const messages = agentSession.conversation || [];
+ const agentSession = agentResponse.data.session;
+ const messages = agentSession?.conversation || [];
return {
- sessionId: agentSession.id,
- name: agentSession.recipe?.title || agentSession.name,
+ sessionId: agentSession?.id || '',
+ name: agentSession?.recipe?.title || agentSession?.name || '',
messageHistoryIndex: 0,
messages,
- recipe: agentSession.recipe,
- recipeParameterValues: agentSession.user_recipe_values || null,
+ recipe: agentSession?.recipe,
+ recipeParameterValues: agentSession?.user_recipe_values || null,
};
}
}
@@ -196,7 +195,10 @@ export function useAgent(): UseAgentReturn {
}
}
- const agentSession = agentResponse.data;
+ // Handle different response types: resumeAgent returns { session, extension_results }, startAgent returns Session directly
+ const responseData = agentResponse.data;
+ const agentSession =
+ responseData && 'session' in responseData ? responseData.session : responseData;
if (!agentSession) {
throw Error('Failed to get session info');
}
@@ -231,7 +233,6 @@ export function useAgent(): UseAgentReturn {
await initializeSystem(agentSession.id, provider as string, model as string, {
getExtensions,
addExtension,
- setIsExtensionsLoading: initContext.setIsExtensionsLoading,
recipeParameters: agentSession.user_recipe_values,
recipe: recipeForInit,
});
diff --git a/ui/desktop/src/hooks/useChatStream.ts b/ui/desktop/src/hooks/useChatStream.ts
index 01555361b53e..6e4efd6d5b87 100644
--- a/ui/desktop/src/hooks/useChatStream.ts
+++ b/ui/desktop/src/hooks/useChatStream.ts
@@ -20,6 +20,7 @@ import {
NotificationEvent,
} from '../types/message';
import { errorMessage } from '../utils/conversionUtils';
+import { showExtensionLoadResults } from '../utils/extensionErrorUtils';
const resultsCache = new Map();
@@ -221,6 +222,7 @@ export function useChatStream({
accumulatedTotalTokens: cached.session?.accumulated_total_tokens ?? 0,
});
setChatState(ChatState.Idle);
+ onSessionLoaded?.();
return;
}
@@ -246,16 +248,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?.();
diff --git a/ui/desktop/src/sessions.ts b/ui/desktop/src/sessions.ts
index b8fd7894be48..1efec4d44c0d 100644
--- a/ui/desktop/src/sessions.ts
+++ b/ui/desktop/src/sessions.ts
@@ -1,4 +1,4 @@
-import { Session, startAgent, restartAgent, ExtensionConfig } from './api';
+import { Session, startAgent, ExtensionConfig } from './api';
import type { setViewType } from './hooks/useNavigation';
import {
getExtensionConfigsWithOverrides,
@@ -19,6 +19,7 @@ export async function createSession(
options?: {
recipeId?: string;
recipeDeeplink?: string;
+ extensionConfigs?: ExtensionConfig[];
allExtensions?: FixedExtensionEntry[];
}
): Promise {
@@ -37,14 +38,16 @@ export async function createSession(
body.recipe_deeplink = options.recipeDeeplink;
}
- // Get extension configs with any overrides applied
- if (options?.allExtensions && hasExtensionOverrides()) {
+ 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;
}
- // Clear the overrides after using them
- clearExtensionOverrides();
+ if (hasExtensionOverrides()) {
+ clearExtensionOverrides();
+ }
}
const newAgent = await startAgent({
@@ -52,14 +55,7 @@ export async function createSession(
throwOnError: true,
});
- const session = newAgent.data;
-
- // Restart agent to ensure it picks up the session's working dir
- await restartAgent({
- body: { session_id: session.id },
- });
-
- return session;
+ return newAgent.data;
}
export async function startNewSession(
diff --git a/ui/desktop/src/types/chatState.ts b/ui/desktop/src/types/chatState.ts
index 067aee4f7b0b..46ec6c36853a 100644
--- a/ui/desktop/src/types/chatState.ts
+++ b/ui/desktop/src/types/chatState.ts
@@ -5,4 +5,5 @@ export enum ChatState {
WaitingForUserInput = 'waitingForUserInput',
Compacting = 'compacting',
LoadingConversation = 'loadingConversation',
+ RestartingAgent = 'restartingAgent',
}
diff --git a/ui/desktop/src/utils/extensionErrorUtils.ts b/ui/desktop/src/utils/extensionErrorUtils.ts
index e707c6c0e757..17b777750b22 100644
--- a/ui/desktop/src/utils/extensionErrorUtils.ts
+++ b/ui/desktop/src/utils/extensionErrorUtils.ts
@@ -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,47 @@ 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 failures, individual toast for single failure.
+ * @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 (failedExtensions.length === 0) {
+ return;
+ }
+
+ if (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);
+}
diff --git a/ui/desktop/src/utils/navigationUtils.ts b/ui/desktop/src/utils/navigationUtils.ts
index d9bbe36e7263..175fa87e9a70 100644
--- a/ui/desktop/src/utils/navigationUtils.ts
+++ b/ui/desktop/src/utils/navigationUtils.ts
@@ -1,5 +1,5 @@
import { NavigateFunction } from 'react-router-dom';
-import { Recipe } from '../api/types.gen';
+import { ExtensionConfig, Recipe } from '../api/types.gen';
export type View =
| 'welcome'
@@ -36,6 +36,9 @@ export type ViewOptions = {
shareToken?: string;
resumeSessionId?: string;
pendingScheduleDeepLink?: string;
+ extensionConfigs?: ExtensionConfig[];
+ workingDir?: string;
+ navigationId?: string; // Unique ID for each navigation to prevent duplicate session creation
};
export const createNavigationHandler = (navigate: NavigateFunction) => {
diff --git a/ui/desktop/src/utils/providerUtils.ts b/ui/desktop/src/utils/providerUtils.ts
index 8862b5c12d69..40627920e3c7 100644
--- a/ui/desktop/src/utils/providerUtils.ts
+++ b/ui/desktop/src/utils/providerUtils.ts
@@ -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 => {
@@ -29,7 +25,6 @@ export const initializeSystem = async (
options?: {
getExtensions?: (b: boolean) => Promise;
addExtension?: (name: string, config: ExtensionConfig, enabled: boolean) => Promise;
- setIsExtensionsLoading?: (loading: boolean) => void;
recipeParameters?: Record | null;
recipe?: Recipe;
}
@@ -72,82 +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 = 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;
-
- 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;
}
};
From b45178489f77d3153de121d0d425112ec002b5bd Mon Sep 17 00:00:00 2001
From: Zane Staggs
Date: Thu, 18 Dec 2025 09:36:02 -0800
Subject: [PATCH 11/24] fix refresh and forking
---
ui/desktop/src/App.tsx | 42 +++++++++++++++++-------------------------
1 file changed, 17 insertions(+), 25 deletions(-)
diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx
index 08d720fb2b5f..af5ab2d96c5d 100644
--- a/ui/desktop/src/App.tsx
+++ b/ui/desktop/src/App.tsx
@@ -65,17 +65,17 @@ const PairRouteWrapper = ({
}) => {
const { extensionsList } = useConfig();
const location = useLocation();
+ const navigate = useNavigate();
const routeState = (location.state as PairRouteState) || {};
- const [searchParams, setSearchParams] = useSearchParams();
- const [initialMessage] = useState(routeState.initialMessage);
+ const [searchParams] = useSearchParams();
const resumeSessionId = searchParams.get('resumeSessionId') ?? undefined;
const recipeId = searchParams.get('recipeId') ?? undefined;
const recipeDeeplinkFromConfig = window.appConfig?.get('recipeDeeplink') as string | undefined;
- // Session ID comes from URL (set by Hub before navigating, or from session list/recipe deeplink)
- const [sessionId, setSessionId] = useState(
- routeState.resumeSessionId || resumeSessionId || chat.sessionId || undefined
- );
+ // Session ID and initialMessage come from route state (Hub, fork) or URL params (refresh, deeplink)
+ const sessionIdFromState = routeState.resumeSessionId;
+ const initialMessage = routeState.initialMessage;
+ const sessionId = sessionIdFromState || resumeSessionId || chat.sessionId || undefined;
// Handle recipe deeplinks - create session if needed
useEffect(() => {
@@ -87,34 +87,26 @@ const PairRouteWrapper = ({
recipeDeeplink: recipeDeeplinkFromConfig,
allExtensions: extensionsList,
});
- setSessionId(newSession.id);
- setSearchParams(
- (prev) => {
- prev.set('resumeSessionId', newSession.id);
- prev.delete('recipeId');
- return prev;
- },
- { replace: true }
- );
+ navigate(`/pair?resumeSessionId=${newSession.id}`, {
+ replace: true,
+ state: { resumeSessionId: newSession.id },
+ });
} catch (error) {
console.error('Failed to create session for recipe:', error);
}
})();
}
- }, [recipeId, recipeDeeplinkFromConfig, sessionId, extensionsList, setSearchParams]);
+ }, [recipeId, recipeDeeplinkFromConfig, sessionId, extensionsList, navigate]);
- // Sync URL with session ID 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;
- },
- { replace: true }
- );
+ navigate(`/pair?resumeSessionId=${sessionId}`, {
+ replace: true,
+ state: { resumeSessionId: sessionIdFromState, initialMessage }, // Preserve state
+ });
}
- }, [sessionId, resumeSessionId, setSearchParams]);
+ }, [sessionId, resumeSessionId, navigate, sessionIdFromState, initialMessage]);
return (
Date: Thu, 18 Dec 2025 11:58:20 -0800
Subject: [PATCH 12/24] eagerly start loading extensions in the background
---
crates/goose-server/src/routes/agent.rs | 59 +++++++++++++++++--
crates/goose-server/src/routes/agent_utils.rs | 17 +++++-
crates/goose-server/src/state.rs | 44 ++++++++++++++
crates/goose/src/agents/extension_manager.rs | 10 ++--
crates/goose/src/agents/mod.rs | 2 +-
5 files changed, 121 insertions(+), 11 deletions(-)
diff --git a/crates/goose-server/src/routes/agent.rs b/crates/goose-server/src/routes/agent.rs
index a0e885a53e15..16b580abbe27 100644
--- a/crates/goose-server/src/routes/agent.rs
+++ b/crates/goose-server/src/routes/agent.rs
@@ -159,6 +159,7 @@ pub struct RestartAgentResponse {
(status = 500, description = "Internal server error", body = ErrorResponse)
)
)]
+#[allow(clippy::too_many_lines)]
async fn start_agent(
State(state): State>,
Json(payload): Json,
@@ -262,6 +263,41 @@ async fn start_agent(
}
})?;
+ // Eagerly start loading extensions in the background
+ let session_for_spawn = session.clone();
+ let state_for_spawn = state.clone();
+ let session_id_for_task = session.id.clone();
+ let task = tokio::spawn(async move {
+ match state_for_spawn
+ .get_agent(session_for_spawn.id.clone())
+ .await
+ {
+ Ok(agent) => {
+ agent
+ .set_working_dir(session_for_spawn.working_dir.clone())
+ .await;
+
+ let results = restore_agent_extensions(agent, &session_for_spawn).await;
+ tracing::debug!(
+ "Background extension loading completed for session {}",
+ session_for_spawn.id
+ );
+ results
+ }
+ Err(e) => {
+ tracing::warn!(
+ "Failed to create agent for background extension loading: {}",
+ e
+ );
+ vec![]
+ }
+ }
+ });
+
+ state
+ .set_extension_loading_task(session_id_for_task, task)
+ .await;
+
Ok(Json(session))
}
@@ -301,11 +337,26 @@ async fn resume_agent(
status: code,
})?;
- let provider_result = restore_agent_provider(&agent, &session, &payload.session_id);
- let extensions_future = restore_agent_extensions(agent.clone(), &session);
+ restore_agent_provider(&agent, &session, &payload.session_id).await?;
+
+ let extension_results =
+ if let Some(results) = state.take_extension_loading_task(&payload.session_id).await {
+ tracing::debug!(
+ "Using background extension loading results for session {}",
+ payload.session_id
+ );
+ state
+ .remove_extension_loading_task(&payload.session_id)
+ .await;
+ results
+ } else {
+ tracing::debug!(
+ "No background task found, loading extensions for session {}",
+ payload.session_id
+ );
+ restore_agent_extensions(agent.clone(), &session).await
+ };
- let (provider_result, extension_results) = tokio::join!(provider_result, extensions_future);
- provider_result?;
Some(extension_results)
} else {
None
diff --git a/crates/goose-server/src/routes/agent_utils.rs b/crates/goose-server/src/routes/agent_utils.rs
index 1f5d9a35cd94..4ba3489bdece 100644
--- a/crates/goose-server/src/routes/agent_utils.rs
+++ b/crates/goose-server/src/routes/agent_utils.rs
@@ -1,6 +1,6 @@
use crate::routes::errors::ErrorResponse;
use axum::http::StatusCode;
-use goose::agents::Agent;
+use goose::agents::{normalize, Agent};
use goose::config::Config;
use goose::model::ModelConfig;
use goose::providers::create;
@@ -87,6 +87,21 @@ pub async fn restore_agent_extensions(
async move {
let name = config_clone.name().to_string();
+ let normalized_name = normalize(&name);
+
+ if agent_ref
+ .extension_manager
+ .is_extension_enabled(&normalized_name)
+ .await
+ {
+ tracing::debug!("Extension {} already loaded, skipping", name);
+ return ExtensionLoadResult {
+ name,
+ success: true,
+ error: None,
+ };
+ }
+
match agent_ref.add_extension(config_clone).await {
Ok(_) => ExtensionLoadResult {
name,
diff --git a/crates/goose-server/src/state.rs b/crates/goose-server/src/state.rs
index 4a9c582e39e2..2ebc5a0b53f3 100644
--- a/crates/goose-server/src/state.rs
+++ b/crates/goose-server/src/state.rs
@@ -6,9 +6,14 @@ use std::path::PathBuf;
use std::sync::atomic::AtomicUsize;
use std::sync::Arc;
use tokio::sync::Mutex;
+use tokio::task::JoinHandle;
+use crate::routes::agent_utils::ExtensionLoadResult;
use crate::tunnel::TunnelManager;
+type ExtensionLoadingTasks =
+ Arc>>>>>>>;
+
#[derive(Clone)]
pub struct AppState {
pub(crate) agent_manager: Arc,
@@ -17,6 +22,7 @@ pub struct AppState {
/// Tracks sessions that have already emitted recipe telemetry to prevent double counting.
recipe_session_tracker: Arc>>,
pub tunnel_manager: Arc,
+ pub extension_loading_tasks: ExtensionLoadingTasks,
}
impl AppState {
@@ -30,9 +36,47 @@ impl AppState {
session_counter: Arc::new(AtomicUsize::new(0)),
recipe_session_tracker: Arc::new(Mutex::new(HashSet::new())),
tunnel_manager,
+ extension_loading_tasks: Arc::new(Mutex::new(HashMap::new())),
}))
}
+ pub async fn set_extension_loading_task(
+ &self,
+ session_id: String,
+ task: JoinHandle>,
+ ) {
+ let mut tasks = self.extension_loading_tasks.lock().await;
+ tasks.insert(session_id, Arc::new(Mutex::new(Some(task))));
+ }
+
+ pub async fn take_extension_loading_task(
+ &self,
+ session_id: &str,
+ ) -> Option> {
+ let task_holder = {
+ let tasks = self.extension_loading_tasks.lock().await;
+ tasks.get(session_id).cloned()
+ };
+
+ if let Some(holder) = task_holder {
+ let task = holder.lock().await.take();
+ if let Some(handle) = task {
+ match handle.await {
+ Ok(results) => return Some(results),
+ Err(e) => {
+ tracing::warn!("Background extension loading task failed: {}", e);
+ }
+ }
+ }
+ }
+ None
+ }
+
+ pub async fn remove_extension_loading_task(&self, session_id: &str) {
+ let mut tasks = self.extension_loading_tasks.lock().await;
+ tasks.remove(session_id);
+ }
+
pub fn scheduler(&self) -> Arc {
self.agent_manager.scheduler()
}
diff --git a/crates/goose/src/agents/extension_manager.rs b/crates/goose/src/agents/extension_manager.rs
index 16c720baf68a..13fe29c143ca 100644
--- a/crates/goose/src/agents/extension_manager.rs
+++ b/crates/goose/src/agents/extension_manager.rs
@@ -133,7 +133,7 @@ impl ResourceItem {
/// Sanitizes a string by replacing invalid characters with underscores.
/// Valid characters match [a-zA-Z0-9_-]
-fn normalize(input: String) -> String {
+pub fn normalize(input: &str) -> String {
let mut result = String::with_capacity(input.len());
for c in input.chars() {
result.push(match c {
@@ -316,7 +316,7 @@ impl ExtensionManager {
working_dir: Option,
) -> ExtensionResult<()> {
let config_name = config.key().to_string();
- let sanitized_name = normalize(config_name.clone());
+ let sanitized_name = normalize(&config_name);
let mut temp_dir = None;
/// Helper function to merge environment variables from direct envs and keychain-stored env_keys
@@ -567,7 +567,7 @@ impl ExtensionManager {
}
ExtensionConfig::Platform { name, .. } => {
// Normalize the name to match the key used in PLATFORM_EXTENSIONS
- let normalized_key = normalize(name.clone());
+ let normalized_key = normalize(name);
let def = PLATFORM_EXTENSIONS
.get(normalized_key.as_str())
.ok_or_else(|| {
@@ -660,7 +660,7 @@ impl ExtensionManager {
/// Get aggregated usage statistics
pub async fn remove_extension(&self, name: &str) -> ExtensionResult<()> {
- let sanitized_name = normalize(name.to_string());
+ let sanitized_name = normalize(name);
self.extensions.lock().await.remove(&sanitized_name);
Ok(())
}
@@ -1375,7 +1375,7 @@ mod tests {
client: McpClientBox,
available_tools: Vec,
) {
- let sanitized_name = normalize(name.clone());
+ let sanitized_name = normalize(&name);
let config = ExtensionConfig::Builtin {
name: name.clone(),
display_name: Some(name.clone()),
diff --git a/crates/goose/src/agents/mod.rs b/crates/goose/src/agents/mod.rs
index 62676edee19e..520431ee6c82 100644
--- a/crates/goose/src/agents/mod.rs
+++ b/crates/goose/src/agents/mod.rs
@@ -29,7 +29,7 @@ pub mod types;
pub use agent::{Agent, AgentEvent, MANUAL_COMPACT_TRIGGERS};
pub use extension::ExtensionConfig;
-pub use extension_manager::ExtensionManager;
+pub use extension_manager::{normalize, ExtensionManager};
pub use prompt_manager::PromptManager;
pub use subagent_task_config::TaskConfig;
pub use types::{FrontendTool, RetryConfig, SessionConfig, SuccessCheck};
From 303b4e24f140b08b1557ce6de00d2cc4cdbc2605 Mon Sep 17 00:00:00 2001
From: Zane Staggs
Date: Thu, 18 Dec 2025 12:16:09 -0800
Subject: [PATCH 13/24] prevent resending initial message on refresh
---
ui/desktop/src/App.tsx | 2 +-
ui/desktop/src/components/BaseChat.tsx | 7 ++++++-
2 files changed, 7 insertions(+), 2 deletions(-)
diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx
index af5ab2d96c5d..3968134e2dab 100644
--- a/ui/desktop/src/App.tsx
+++ b/ui/desktop/src/App.tsx
@@ -103,7 +103,7 @@ const PairRouteWrapper = ({
if (sessionId && sessionId !== resumeSessionId) {
navigate(`/pair?resumeSessionId=${sessionId}`, {
replace: true,
- state: { resumeSessionId: sessionIdFromState, initialMessage }, // Preserve state
+ state: { resumeSessionId: sessionIdFromState, initialMessage },
});
}
}, [sessionId, resumeSessionId, navigate, sessionIdFromState, initialMessage]);
diff --git a/ui/desktop/src/components/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx
index 5744aa33077b..f787ed60d154 100644
--- a/ui/desktop/src/components/BaseChat.tsx
+++ b/ui/desktop/src/components/BaseChat.tsx
@@ -133,11 +133,16 @@ function BaseChatContent({
if (initialMessage) {
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) {
hasAutoSubmittedRef.current = true;
handleSubmit('');
}
- }, [session, initialMessage, searchParams, handleSubmit]);
+ }, [session, initialMessage, searchParams, handleSubmit, navigate, location]);
const handleFormSubmit = (e: React.FormEvent) => {
const customEvent = e as unknown as CustomEvent;
From f4fb87872255fe2b7eb6c881b100c6c510f03f7b Mon Sep 17 00:00:00 2001
From: Zane Staggs
Date: Thu, 18 Dec 2025 12:36:34 -0800
Subject: [PATCH 14/24] change restarting agent display
---
ui/desktop/src/components/BaseChat.tsx | 2 ++
ui/desktop/src/components/ChatInput.tsx | 24 ++++++++----------------
ui/desktop/src/hooks/useChatStream.ts | 2 ++
3 files changed, 12 insertions(+), 16 deletions(-)
diff --git a/ui/desktop/src/components/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx
index f787ed60d154..9327a6fbda81 100644
--- a/ui/desktop/src/components/BaseChat.tsx
+++ b/ui/desktop/src/components/BaseChat.tsx
@@ -95,6 +95,7 @@ function BaseChatContent({
session,
messages,
chatState,
+ setChatState,
handleSubmit,
submitElicitationResponse,
stopStreaming,
@@ -409,6 +410,7 @@ function BaseChatContent({
sessionId={sessionId}
handleSubmit={handleFormSubmit}
chatState={chatState}
+ setChatState={setChatState}
onStop={stopStreaming}
commandHistory={commandHistory}
initialValue={initialPrompt}
diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx
index e35e5d159699..807a342e47e2 100644
--- a/ui/desktop/src/components/ChatInput.tsx
+++ b/ui/desktop/src/components/ChatInput.tsx
@@ -73,6 +73,7 @@ interface ChatInputProps {
sessionId: string | null;
handleSubmit: (e: React.FormEvent) => void;
chatState: ChatState;
+ setChatState?: (state: ChatState) => void;
onStop?: () => void;
commandHistory?: string[];
initialValue?: string;
@@ -104,6 +105,7 @@ export default function ChatInput({
sessionId,
handleSubmit,
chatState = ChatState.Idle,
+ setChatState,
onStop,
commandHistory = [],
initialValue = '',
@@ -152,7 +154,6 @@ export default function ChatInput({
const [showEditRecipeModal, setShowEditRecipeModal] = useState(false);
const [isFilePickerOpen, setIsFilePickerOpen] = useState(false);
const [sessionWorkingDir, setSessionWorkingDir] = useState(null);
- const [isRestartingAgent, setIsRestartingAgent] = useState(false);
useEffect(() => {
if (!sessionId) {
@@ -1132,7 +1133,7 @@ export default function ChatInput({
isAnyDroppedFileLoading ||
isRecording ||
isTranscribing ||
- isRestartingAgent;
+ chatState === ChatState.RestartingAgent;
// Queue management functions - no storage persistence, only in-memory
const handleRemoveQueuedMessage = (messageId: string) => {
@@ -1383,7 +1384,7 @@ export default function ChatInput({
? 'Recording...'
: isTranscribing
? 'Transcribing...'
- : isRestartingAgent
+ : chatState === ChatState.RestartingAgent
? 'Restarting agent...'
: 'Send'}
@@ -1410,15 +1411,6 @@ export default function ChatInput({
)}
)}
-
- {isRestartingAgent && !isRecording && !isTranscribing && (
-
-
-
- Restarting agent...
-
-
- )}
@@ -1544,8 +1536,8 @@ export default function ChatInput({
onWorkingDirChange(newDir);
}
}}
- onRestartStart={() => setIsRestartingAgent(true)}
- onRestartEnd={() => setIsRestartingAgent(false)}
+ onRestartStart={() => setChatState?.(ChatState.RestartingAgent)}
+ onRestartEnd={() => setChatState?.(ChatState.Idle)}
/>
@@ -1593,8 +1585,8 @@ export default function ChatInput({
setIsRestartingAgent(true)}
- onRestartEnd={() => setIsRestartingAgent(false)}
+ onRestartStart={() => setChatState?.(ChatState.RestartingAgent)}
+ onRestartEnd={() => setChatState?.(ChatState.Idle)}
/>
{sessionId && messages.length > 0 && (
<>
diff --git a/ui/desktop/src/hooks/useChatStream.ts b/ui/desktop/src/hooks/useChatStream.ts
index 6e4efd6d5b87..fc6102b9f520 100644
--- a/ui/desktop/src/hooks/useChatStream.ts
+++ b/ui/desktop/src/hooks/useChatStream.ts
@@ -34,6 +34,7 @@ interface UseChatStreamReturn {
session?: Session;
messages: Message[];
chatState: ChatState;
+ setChatState: (state: ChatState) => void;
handleSubmit: (userMessage: string) => Promise;
submitElicitationResponse: (
elicitationId: string,
@@ -512,6 +513,7 @@ export function useChatStream({
messages: maybe_cached_messages,
session: maybe_cached_session,
chatState,
+ setChatState,
handleSubmit,
submitElicitationResponse,
stopStreaming,
From 8ee99b07ec5a9141e9c0f91f1fdc37c81aa1ed98 Mon Sep 17 00:00:00 2001
From: Zane Staggs
Date: Thu, 18 Dec 2025 12:44:06 -0800
Subject: [PATCH 15/24] cleanup
---
crates/goose-server/src/routes/session.rs | 1 -
ui/desktop/src/components/sessions/SessionListView.tsx | 5 -----
ui/desktop/src/store/extensionOverrides.ts | 6 ------
3 files changed, 12 deletions(-)
diff --git a/crates/goose-server/src/routes/session.rs b/crates/goose-server/src/routes/session.rs
index 1719444820d1..b9fe20ee70a2 100644
--- a/crates/goose-server/src/routes/session.rs
+++ b/crates/goose-server/src/routes/session.rs
@@ -44,7 +44,6 @@ pub struct UpdateSessionUserRecipeValuesRequest {
#[derive(Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct UpdateSessionWorkingDirRequest {
- /// New working directory path
working_dir: String,
}
diff --git a/ui/desktop/src/components/sessions/SessionListView.tsx b/ui/desktop/src/components/sessions/SessionListView.tsx
index 359872781f70..7f8b6e0f6d92 100644
--- a/ui/desktop/src/components/sessions/SessionListView.tsx
+++ b/ui/desktop/src/components/sessions/SessionListView.tsx
@@ -35,10 +35,8 @@ import {
ExtensionData,
} from '../../api';
-// Helper to extract extension names from session's extension_data
function getSessionExtensionNames(extensionData: ExtensionData): string[] {
try {
- // extension_data structure: { "enabled_extensions.v0": { "extensions": [...] } }
const v0Data = extensionData?.['enabled_extensions.v0'] as
| { extensions?: ExtensionConfig[] }
| undefined;
@@ -71,7 +69,6 @@ const EditSessionModal = React.memo(
if (session && isOpen) {
setDescription(session.name);
} else if (!isOpen) {
- // Reset state when modal closes
setDescription('');
setIsUpdating(false);
}
@@ -94,8 +91,6 @@ const EditSessionModal = React.memo(
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');
diff --git a/ui/desktop/src/store/extensionOverrides.ts b/ui/desktop/src/store/extensionOverrides.ts
index 8604f6735397..5755126961a0 100644
--- a/ui/desktop/src/store/extensionOverrides.ts
+++ b/ui/desktop/src/store/extensionOverrides.ts
@@ -13,7 +13,6 @@ const state: {
extensionOverrides: new Map(),
};
-// Extension override functions
export function setExtensionOverride(name: string, enabled: boolean): void {
state.extensionOverrides.set(name, enabled);
}
@@ -34,12 +33,10 @@ export function clearExtensionOverrides(): void {
state.extensionOverrides.clear();
}
-// Get extension configs with overrides applied
export function getExtensionConfigsWithOverrides(
allExtensions: Array<{ name: string; enabled: boolean } & Omit>
): ExtensionConfig[] {
if (state.extensionOverrides.size === 0) {
- // No overrides, return global enabled extensions
return allExtensions
.filter((ext) => ext.enabled)
.map((ext) => {
@@ -48,14 +45,11 @@ export function getExtensionConfigsWithOverrides(
});
}
- // Apply overrides
return allExtensions
.filter((ext) => {
- // Check if we have an override for this extension
if (state.extensionOverrides.has(ext.name)) {
return state.extensionOverrides.get(ext.name);
}
- // Otherwise use the global enabled state
return ext.enabled;
})
.map((ext) => {
From 57c2e5ddb4c172f7d28dd16f0aaa7ee7b17757f5 Mon Sep 17 00:00:00 2001
From: Zane Staggs
Date: Thu, 18 Dec 2025 12:58:48 -0800
Subject: [PATCH 16/24] cleanup
---
ui/desktop/src/utils/navigationUtils.ts | 8 +-------
1 file changed, 1 insertion(+), 7 deletions(-)
diff --git a/ui/desktop/src/utils/navigationUtils.ts b/ui/desktop/src/utils/navigationUtils.ts
index 175fa87e9a70..aaa6ba67b1c6 100644
--- a/ui/desktop/src/utils/navigationUtils.ts
+++ b/ui/desktop/src/utils/navigationUtils.ts
@@ -1,5 +1,5 @@
import { NavigateFunction } from 'react-router-dom';
-import { ExtensionConfig, Recipe } from '../api/types.gen';
+import { Recipe } from '../api/types.gen';
export type View =
| 'welcome'
@@ -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,13 +30,9 @@ export type ViewOptions = {
parentViewOptions?: ViewOptions;
disableAnimation?: boolean;
initialMessage?: string;
- resetChat?: boolean;
shareToken?: string;
resumeSessionId?: string;
pendingScheduleDeepLink?: string;
- extensionConfigs?: ExtensionConfig[];
- workingDir?: string;
- navigationId?: string; // Unique ID for each navigation to prevent duplicate session creation
};
export const createNavigationHandler = (navigate: NavigateFunction) => {
From 88d552e7f60c18f7de6dfd358771ac5e683fa6fc Mon Sep 17 00:00:00 2001
From: Zane Staggs
Date: Thu, 18 Dec 2025 13:30:36 -0800
Subject: [PATCH 17/24] bring back extension success toast
---
ui/desktop/src/utils/extensionErrorUtils.ts | 8 ++------
1 file changed, 2 insertions(+), 6 deletions(-)
diff --git a/ui/desktop/src/utils/extensionErrorUtils.ts b/ui/desktop/src/utils/extensionErrorUtils.ts
index 17b777750b22..369a64c82cec 100644
--- a/ui/desktop/src/utils/extensionErrorUtils.ts
+++ b/ui/desktop/src/utils/extensionErrorUtils.ts
@@ -34,7 +34,7 @@ export function formatExtensionErrorMessage(
/**
* Shows toast notifications for extension load results.
- * Uses grouped toast for multiple failures, individual toast for single failure.
+ * 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 {
@@ -44,11 +44,7 @@ export function showExtensionLoadResults(results: ExtensionLoadResult[] | null |
const failedExtensions = results.filter((r) => !r.success);
- if (failedExtensions.length === 0) {
- return;
- }
-
- if (failedExtensions.length === 1) {
+ if (results.length === 1 && failedExtensions.length === 1) {
const failed = failedExtensions[0];
const errorMsg = failed.error || 'Unknown error';
const recoverHints = createExtensionRecoverHints(errorMsg);
From 344918bf51aa6a914f247772d986486b2f896fbe Mon Sep 17 00:00:00 2001
From: Zane Staggs
Date: Thu, 18 Dec 2025 13:54:28 -0800
Subject: [PATCH 18/24] fix bad merge
---
crates/goose-server/src/routes/agent_utils.rs | 4 ----
1 file changed, 4 deletions(-)
diff --git a/crates/goose-server/src/routes/agent_utils.rs b/crates/goose-server/src/routes/agent_utils.rs
index 7a67e19c168e..4ba3489bdece 100644
--- a/crates/goose-server/src/routes/agent_utils.rs
+++ b/crates/goose-server/src/routes/agent_utils.rs
@@ -100,10 +100,6 @@ pub async fn restore_agent_extensions(
success: true,
error: None,
};
- goose::posthog::emit_error(
- "extension_load_failed",
- &format!("{}: {}", config_clone.name(), e),
- );
}
match agent_ref.add_extension(config_clone).await {
From 272dcc25c9f7736df1ae4888443e33093d3bfccd Mon Sep 17 00:00:00 2001
From: Zane Staggs
Date: Thu, 18 Dec 2025 14:08:42 -0800
Subject: [PATCH 19/24] update extensions default text
---
ui/desktop/src/components/extensions/ExtensionsView.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/ui/desktop/src/components/extensions/ExtensionsView.tsx b/ui/desktop/src/components/extensions/ExtensionsView.tsx
index 0bbb3ff558b1..34e243aaa004 100644
--- a/ui/desktop/src/components/extensions/ExtensionsView.tsx
+++ b/ui/desktop/src/components/extensions/ExtensionsView.tsx
@@ -105,7 +105,7 @@ export default function ExtensionsView({
Extensions enabled here are used as the default for new chats. You can also toggle
- extensions using the menu during chat.
+ active extensions during chat.
{/* Action Buttons */}
From da0a5fa69e023864ec1d291d7dc24468c63c0ff5 Mon Sep 17 00:00:00 2001
From: Zane Staggs
Date: Thu, 18 Dec 2025 14:36:14 -0800
Subject: [PATCH 20/24] auto hide the toast
---
ui/desktop/src/toasts.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/ui/desktop/src/toasts.tsx b/ui/desktop/src/toasts.tsx
index b0e6dbaef695..c6d19cd1efbf 100644
--- a/ui/desktop/src/toasts.tsx
+++ b/ui/desktop/src/toasts.tsx
@@ -110,7 +110,7 @@ class ToastService {
{
...commonToastOptions,
toastId,
- autoClose: false,
+ autoClose: isComplete ? 5000 : false,
closeButton: true,
closeOnClick: false, // Prevent closing when clicking to expand/collapse
}
From 1489ff239a4c524a6ac62bec8e9506817a60846e Mon Sep 17 00:00:00 2001
From: Zane Staggs
Date: Thu, 18 Dec 2025 14:50:32 -0800
Subject: [PATCH 21/24] cleanup
---
crates/goose-server/src/routes/agent.rs | 2 --
1 file changed, 2 deletions(-)
diff --git a/crates/goose-server/src/routes/agent.rs b/crates/goose-server/src/routes/agent.rs
index 9bcd29a63146..aad1533a5e5b 100644
--- a/crates/goose-server/src/routes/agent.rs
+++ b/crates/goose-server/src/routes/agent.rs
@@ -610,7 +610,6 @@ async fn agent_add_extension(
ErrorResponse::internal(format!("Failed to add extension: {}", e))
})?;
- // Persist the updated extension state to the session
persist_session_extensions(&agent, &request.session_id).await?;
Ok(StatusCode::OK)
}
@@ -633,7 +632,6 @@ async fn agent_remove_extension(
let agent = state.get_agent(request.session_id.clone()).await?;
agent.remove_extension(&request.name).await?;
- // Persist the updated extension state to the session
persist_session_extensions(&agent, &request.session_id).await?;
Ok(StatusCode::OK)
From c93d66014f1f852b99ee75fd2db16037692c6a71 Mon Sep 17 00:00:00 2001
From: Zane Staggs
Date: Thu, 18 Dec 2025 14:55:59 -0800
Subject: [PATCH 22/24] add back tracking lost in merge
---
ui/desktop/src/App.tsx | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx
index 606609071016..3d6fff5135ff 100644
--- a/ui/desktop/src/App.tsx
+++ b/ui/desktop/src/App.tsx
@@ -94,6 +94,11 @@ const PairRouteWrapper = ({
});
} catch (error) {
console.error('Failed to create session for recipe:', error);
+ trackErrorWithContext(error, {
+ component: 'PairRouteWrapper',
+ action: 'create_session',
+ recoverable: true,
+ });
}
})();
}
From d16d975c01a476a43089bd512565b7626efc414a Mon Sep 17 00:00:00 2001
From: Zane Staggs
Date: Thu, 18 Dec 2025 15:00:04 -0800
Subject: [PATCH 23/24] cleanup
---
.../src/components/bottom_menu/BottomMenuExtensionSelection.tsx | 1 -
ui/desktop/src/components/sessions/SessionsInsights.tsx | 1 -
ui/desktop/src/components/settings/app/UpdateSection.tsx | 1 -
3 files changed, 3 deletions(-)
diff --git a/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx b/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx
index 64e100ebd831..dfe99b2a861e 100644
--- a/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx
+++ b/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx
@@ -36,7 +36,6 @@ export const BottomMenuExtensionSelection = ({
const { extensionsList: allExtensions } = useConfig();
const isHubView = !sessionId;
- // Cleanup timeout on unmount
useEffect(() => {
return () => {
if (sortTimeoutRef.current) {
diff --git a/ui/desktop/src/components/sessions/SessionsInsights.tsx b/ui/desktop/src/components/sessions/SessionsInsights.tsx
index 15c2ad53578a..b9ec0e2cf7dc 100644
--- a/ui/desktop/src/components/sessions/SessionsInsights.tsx
+++ b/ui/desktop/src/components/sessions/SessionsInsights.tsx
@@ -78,7 +78,6 @@ export function SessionInsights() {
loadInsights();
loadRecentSessions();
- // Cleanup timeout on unmount
return () => {
if (loadingTimeout) {
window.clearTimeout(loadingTimeout);
diff --git a/ui/desktop/src/components/settings/app/UpdateSection.tsx b/ui/desktop/src/components/settings/app/UpdateSection.tsx
index c694980e863e..b68aea9e6cad 100644
--- a/ui/desktop/src/components/settings/app/UpdateSection.tsx
+++ b/ui/desktop/src/components/settings/app/UpdateSection.tsx
@@ -125,7 +125,6 @@ export default function UpdateSection() {
}
});
- // Cleanup timeout on unmount
return () => {
if (progressTimeoutRef.current) {
clearTimeout(progressTimeoutRef.current);
From 1d02bb75d9e839dcf32e51e3200cb0552ea517b0 Mon Sep 17 00:00:00 2001
From: Zane Staggs
Date: Thu, 18 Dec 2025 15:27:51 -0800
Subject: [PATCH 24/24] use format extension name helper
---
.../GroupedExtensionLoadingToast.tsx | 71 +++++++++++--------
.../BottomMenuExtensionSelection.tsx | 6 +-
.../components/sessions/SessionListView.tsx | 16 ++---
.../subcomponents/ExtensionList.tsx | 8 ++-
4 files changed, 54 insertions(+), 47 deletions(-)
diff --git a/ui/desktop/src/components/GroupedExtensionLoadingToast.tsx b/ui/desktop/src/components/GroupedExtensionLoadingToast.tsx
index 7f69415d321c..47ca1f89d48b 100644
--- a/ui/desktop/src/components/GroupedExtensionLoadingToast.tsx
+++ b/ui/desktop/src/components/GroupedExtensionLoadingToast.tsx
@@ -6,6 +6,7 @@ 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;
@@ -92,45 +93,53 @@ export function GroupedExtensionLoadingToast({
- {extensions.map((ext) => (
-
-
- {getStatusIcon(ext.status)}
-
{ext.name}
-
- {ext.status === 'error' && ext.error && (
-
-
- {formatExtensionErrorMessage(ext.error, 'Failed to add extension')}
-
-
- {ext.recoverHints && setView && (
+ {extensions.map((ext) => {
+ const friendlyName = formatExtensionName(ext.name);
+
+ return (
+
+
+ {getStatusIcon(ext.status)}
+
{friendlyName}
+
+ {ext.status === 'error' && ext.error && (
+
+
+ {formatExtensionErrorMessage(ext.error, 'Failed to add extension')}
+
+
+ {ext.recoverHints && setView && (
+ {
+ e.stopPropagation();
+ startNewSession(
+ getInitialWorkingDir(),
+ ext.recoverHints,
+ setView
+ );
+ }}
+ >
+ Ask goose
+
+ )}
{
e.stopPropagation();
- startNewSession(getInitialWorkingDir(), ext.recoverHints, setView);
+ navigator.clipboard.writeText(ext.error!);
+ setCopiedExtension(ext.name);
+ setTimeout(() => setCopiedExtension(null), 2000);
}}
>
- Ask goose
+ {copiedExtension === ext.name ? 'Copied!' : 'Copy error'}
- )}
- {
- e.stopPropagation();
- navigator.clipboard.writeText(ext.error!);
- setCopiedExtension(ext.name);
- setTimeout(() => setCopiedExtension(null), 2000);
- }}
- >
- {copiedExtension === ext.name ? 'Copied!' : 'Copy error'}
-
+
-
- )}
-
- ))}
+ )}
+
+ );
+ })}
diff --git a/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx b/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx
index dfe99b2a861e..6a5cfbbb2cc3 100644
--- a/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx
+++ b/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx
@@ -5,7 +5,7 @@ import { Input } from '../ui/input';
import { Switch } from '../ui/switch';
import { FixedExtensionEntry, useConfig } from '../ConfigContext';
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 {
@@ -95,7 +95,7 @@ export const BottomMenuExtensionSelection = ({
toastService.success({
title: 'Extension Updated',
- msg: `${extensionConfig.name} will be ${!currentState ? 'enabled' : 'disabled'} in new chats`,
+ msg: `${formatExtensionName(extensionConfig.name)} will be ${!currentState ? 'enabled' : 'disabled'} in new chats`,
});
return;
}
@@ -267,7 +267,7 @@ export const BottomMenuExtensionSelection = ({
title={ext.description || ext.name}
>
- {getFriendlyTitle(ext)}
+ {formatExtensionName(ext.name)}
e.stopPropagation()}>
{
- // display_name exists on some ExtensionConfig variants but not all
- const displayName = (ext as { display_name?: string }).display_name;
- return displayName || ext.name;
- });
+ return enabledExtensionData.extensions.map((ext) => formatExtensionName(ext.name));
} catch {
return [];
}
@@ -638,10 +635,7 @@ const SessionListView: React.FC = React.memo(
- e.stopPropagation()}
- >
+
e.stopPropagation()}>
{extensionNames.length}
diff --git a/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionList.tsx b/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionList.tsx
index e15e86007892..378bc5c7fda6 100644
--- a/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionList.tsx
+++ b/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionList.tsx
@@ -97,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, '');
}