feat: add delete button to apps UI

Implement delete functionality for custom apps in the Apps view. Adds:
- DELETE /agent/delete_app/{name} endpoint to delete apps from cache
- McpAppCache.delete_app() method to remove cached app files
- UI delete button with confirmation modal on each custom app card
- OpenAPI schema generation for the new endpoint

Resolves #7965
This commit is contained in:
jh-block
2026-03-19 12:19:46 +01:00
parent c97fbb9f3c
commit 4efd73ebda
9 changed files with 20961 additions and 14 deletions
+2
View File
@@ -428,6 +428,7 @@ derive_utoipa!(Icon as IconSchema);
super::routes::agent::list_apps,
super::routes::agent::export_app,
super::routes::agent::import_app,
super::routes::agent::delete_app,
super::routes::agent::update_from_session,
super::routes::agent::agent_add_extension,
super::routes::agent::agent_remove_extension,
@@ -645,6 +646,7 @@ derive_utoipa!(Icon as IconSchema);
super::routes::agent::ListAppsResponse,
super::routes::agent::ImportAppRequest,
super::routes::agent::ImportAppResponse,
super::routes::agent::DeleteAppResponse,
super::routes::agent::StartAgentRequest,
super::routes::agent::ResumeAgentRequest,
super::routes::agent::StopAgentRequest,
+61 -1
View File
@@ -7,7 +7,7 @@ use axum::response::IntoResponse;
use axum::{
extract::{Query, State},
http::StatusCode,
routing::{get, post},
routing::{delete, get, post},
Json, Router,
};
use goose::agents::{Container, ExtensionLoadResult};
@@ -1284,6 +1284,65 @@ async fn import_app(
))
}
#[derive(Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct DeleteAppResponse {
pub name: String,
pub message: String,
}
#[utoipa::path(
delete,
path = "/agent/delete_app/{name}",
params(
("name" = String, Path, description = "Name of the app to delete")
),
responses(
(status = 200, description = "App deleted successfully", body = DeleteAppResponse),
(status = 404, description = "App not found", body = ErrorResponse),
(status = 500, description = "Internal server error", body = ErrorResponse),
),
security(
("api_key" = [])
),
tag = "Agent"
)]
async fn delete_app(
axum::extract::Path(name): axum::extract::Path<String>,
) -> Result<Json<DeleteAppResponse>, ErrorResponse> {
let cache = McpAppCache::new().map_err(|e| ErrorResponse {
message: format!("Failed to access app cache: {}", e),
status: StatusCode::INTERNAL_SERVER_ERROR,
})?;
let apps = cache.list_apps().map_err(|e| ErrorResponse {
message: format!("Failed to list apps: {}", e),
status: StatusCode::INTERNAL_SERVER_ERROR,
})?;
let app = apps
.into_iter()
.find(|a| a.resource.name == name)
.ok_or_else(|| ErrorResponse {
message: format!("App '{}' not found", name),
status: StatusCode::NOT_FOUND,
})?;
for extension_name in &app.mcp_servers {
cache
.delete_app(extension_name, &app.resource.uri)
.map_err(|e| ErrorResponse {
message: format!("Failed to delete app: {}", e),
status: StatusCode::INTERNAL_SERVER_ERROR,
})?;
}
Ok(Json(DeleteAppResponse {
name: name.clone(),
message: format!("App '{}' deleted successfully", name),
}))
}
pub fn routes(state: Arc<AppState>) -> Router {
Router::new()
.route("/agent/start", post(start_agent))
@@ -1296,6 +1355,7 @@ pub fn routes(state: Arc<AppState>) -> Router {
.route("/agent/list_apps", get(list_apps))
.route("/agent/export_app/{name}", get(export_app))
.route("/agent/import_app", post(import_app))
.route("/agent/delete_app/{name}", delete(delete_app))
.route("/agent/update_provider", post(update_agent_provider))
.route("/agent/update_session", post(update_session))
.route("/agent/update_from_session", post(update_from_session))
+14
View File
@@ -92,6 +92,20 @@ impl McpAppCache {
.and_then(|content| serde_json::from_str::<GooseApp>(&content).ok())
}
pub fn delete_app(&self, extension_name: &str, resource_uri: &str) -> Result<(), std::io::Error> {
let cache_key = Self::cache_key(extension_name, resource_uri);
let app_path = self.cache_dir.join(format!("{}.json", cache_key));
if !app_path.exists() {
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("App not found in cache: {}::{}", extension_name, resource_uri),
));
}
fs::remove_file(app_path)
}
pub fn delete_extension_apps(&self, extension_name: &str) -> Result<usize, std::io::Error> {
let mut deleted_count = 0;
+71
View File
@@ -128,6 +128,62 @@
}
}
},
"/agent/delete_app/{name}": {
"delete": {
"tags": [
"Agent"
],
"operationId": "delete_app",
"parameters": [
{
"name": "name",
"in": "path",
"description": "Name of the app to delete",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "App deleted successfully",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DeleteAppResponse"
}
}
}
},
"404": {
"description": "App not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"500": {
"description": "Internal server error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
},
"security": [
{
"api_key": []
}
]
}
},
"/agent/export_app/{name}": {
"get": {
"tags": [
@@ -4667,6 +4723,21 @@
}
}
},
"DeleteAppResponse": {
"type": "object",
"required": [
"name",
"message"
],
"properties": {
"message": {
"type": "string"
},
"name": {
"type": "string"
}
}
},
"DeleteRecipeRequest": {
"type": "object",
"required": [
+20714
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+39
View File
@@ -229,6 +229,11 @@ export type DecodeRecipeResponse = {
recipe: Recipe;
};
export type DeleteAppResponse = {
message: string;
name: string;
};
export type DeleteRecipeRequest = {
id: string;
};
@@ -1723,6 +1728,40 @@ export type CallToolResponses = {
export type CallToolResponse2 = CallToolResponses[keyof CallToolResponses];
export type DeleteAppData = {
body?: never;
path: {
/**
* Name of the app to delete
*/
name: string;
};
query?: never;
url: '/agent/delete_app/{name}';
};
export type DeleteAppErrors = {
/**
* App not found
*/
404: ErrorResponse;
/**
* Internal server error
*/
500: ErrorResponse;
};
export type DeleteAppError = DeleteAppErrors[keyof DeleteAppErrors];
export type DeleteAppResponses = {
/**
* App deleted successfully
*/
200: DeleteAppResponse;
};
export type DeleteAppResponse2 = DeleteAppResponses[keyof DeleteAppResponses];
export type ExportAppData = {
body?: never;
path: {
+55 -10
View File
@@ -1,8 +1,9 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { MainPanelLayout } from '../Layout/MainPanelLayout';
import { Button } from '../ui/button';
import { Download, Play, Upload } from 'lucide-react';
import { exportApp, GooseApp, importApp, listApps } from '../../api';
import { Download, Play, Trash2, Upload } from 'lucide-react';
import { deleteApp, exportApp, GooseApp, importApp, listApps } from '../../api';
import { ConfirmationModal } from '../ui/ConfirmationModal';
import { useChatContext } from '../../contexts/ChatContext';
import { formatAppName } from '../../utils/conversionUtils';
import { errorMessage } from '../../utils/conversionUtils';
@@ -25,6 +26,7 @@ export default function AppsView() {
const [apps, setApps] = useState<GooseApp[]>([]);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [appToDelete, setAppToDelete] = useState<GooseApp | null>(null);
const chatContext = useChatContext();
const sessionId = chatContext?.chat.sessionId;
@@ -159,6 +161,28 @@ export default function AppsView() {
}
};
const handleDeleteApp = async (app: GooseApp) => {
try {
await deleteApp({
throwOnError: true,
path: { name: app.name },
});
// Refresh apps list
const response = await listApps({
throwOnError: true,
});
const cachedApps = response.data?.apps || [];
setApps(cachedApps.filter((a) => a.mcpServers?.includes('apps')));
setError(null);
} catch (err) {
console.error('Failed to delete app:', err);
setError(errorMessage(err, 'Failed to delete app'));
} finally {
setAppToDelete(null);
}
};
const fileInputRef = useRef<HTMLInputElement>(null);
const handleImportClick = () => {
@@ -284,14 +308,24 @@ export default function AppsView() {
Launch
</Button>
{isCustomApp && (
<Button
variant="outline"
size="sm"
onClick={() => handleDownloadApp(app)}
className="flex items-center gap-2"
>
<Download className="h-4 w-4" />
</Button>
<>
<Button
variant="outline"
size="sm"
onClick={() => handleDownloadApp(app)}
className="flex items-center gap-2"
>
<Download className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setAppToDelete(app)}
className="flex items-center gap-2 text-red-500 hover:text-red-600"
>
<Trash2 className="h-4 w-4" />
</Button>
</>
)}
</div>
</div>
@@ -301,6 +335,17 @@ export default function AppsView() {
)}
</div>
</div>
<ConfirmationModal
isOpen={appToDelete !== null}
title="Delete App"
message={`Are you sure you want to delete "${appToDelete ? formatAppName(appToDelete.name) : ''}"? This action cannot be undone.`}
confirmLabel="Delete"
cancelLabel="Cancel"
confirmVariant="destructive"
onConfirm={() => appToDelete && handleDeleteApp(appToDelete)}
onCancel={() => setAppToDelete(null)}
/>
</MainPanelLayout>
);
}